source: tailor/vcpx/repository/darcs/source.py @ 1590

Revision 1590, 27.8 KB checked in by lele@…, 5 years ago (diff)

Don't fail badly when the source repository is empty
This fixes the Debian issue 428322, even if not all backends were
updated.

Line 
1# -*- mode: python; coding: utf-8 -*-
2# :Progetto: Tailor -- Darcs peculiarities when used as a source
3# :Creato:   lun 10 lug 2006 00:04:59 CEST
4# :Autore:   Lele Gaifax <lele@nautilus.homeip.net>
5# :Licenza:  GNU General Public License
6#
7
8"""
9This module contains the source specific bits of the darcs backend.
10"""
11
12__docformat__ = 'reStructuredText'
13
14import re
15
16from vcpx.changes import ChangesetEntry, Changeset
17from vcpx.shwrap import ExternalCommand, PIPE, STDOUT
18from vcpx.source import UpdatableSourceWorkingDir, ChangesetApplicationFailure, \
19                        GetUpstreamChangesetsFailure
20from vcpx.target import TargetInitializationFailure
21from vcpx.tzinfo import UTC
22
23
24class DarcsChangeset(Changeset):
25    """
26    Fixup darcs idiosyncrasies:
27
28    - collapse "add A; rename A B" into "add B"
29    - collapse "rename A B; remove B" into "remove A"
30    """
31
32    def __init__(self, revision, date, author, log, entries=None, **other):
33        """
34        Initialize a new DarcsChangeset.
35        """
36
37        super(DarcsChangeset, self).__init__(revision, date, author, log, entries=None, **other)
38        self.darcs_hash = other.get('darcs_hash')
39        if entries is not None:
40            for e in entries:
41                self.addEntry(e, revision)
42
43    def __eq__(self, other):
44        return (self.revision == other.revision and
45                self.date == other.date and
46                self.author == other.author and
47                self.darcs_hash == getattr(other, 'darcs_hash', None))
48
49    def __ne__(self, other):
50        return (self.revision <> other.revision or
51                self.date <> other.date or
52                self.author <> other.author or
53                self.darcs_hash <> getattr(other, 'darcs_hash', None))
54
55    def addEntry(self, entry, revision):
56        """
57        Fixup darcs idiosyncrasies:
58
59        - collapse "add A; rename A B" into "add B"
60        - collapse "rename A B; add B" into "add B"
61        - annihilate "add A; remove A"
62        - collapse "rename A B; remove B" into "remove A"
63        - collapse "rename A B; rename B C" into "rename A C"
64        - collapse "add A; edit A" into "add A"
65        """
66
67        # This should not happen, since the parser feeds us an already built
68        # list of ChangesetEntries, anyway...
69        if not isinstance(entry, ChangesetEntry):
70            return super(DarcsChangeset, self).addEntry(entry, revision)
71
72        # Ok, before adding this entry, check it against already
73        # known: if this is an add, and there's a rename (such as "add
74        # A; rename A B; ") then...
75
76        if entry.action_kind == entry.ADDED:
77            # ... we have to check existings, because of a bug in
78            # darcs: `changes --xml` (as of 1.0.7) emits the changes
79            # in the wrong order, that is, it prefers to start with
80            # renames, *always*, even when they obviously follows the
81            # add of the same entry (even, it should apply this "fix"
82            # by its own).
83            #
84            # So, if there's a rename of this entry there, change that
85            # to an addition instead, and don't insert any other entry
86
87            # darcs hopefully use forward slashes also under win
88            dirname = entry.name+'/'
89
90            for i,e in enumerate(self.entries):
91                if e.action_kind == e.RENAMED:
92                    if e.old_name == entry.name:
93                        # Unfortunately we have to check if the order if
94                        # messed up, in that case we should not do anything.
95                        # Example: mv a a2; mkdir a; mv a2 a/b
96                        skip = False
97                        for j in self.entries:
98                            if j.action_kind == j.RENAMED and j.name.startswith(dirname):
99                                skip = True
100                                break
101                        # Luckily enough (since removes are the first entries
102                        # in the list, that is) by anticipating the add we
103                        # cure also the case below, when addition follows
104                        # edit.
105                        if not skip:
106                            e.action_kind = e.ADDED
107                            e.old_name = None
108                            e.is_directory = entry.is_directory
109
110                            # Collapse "add A; edit A" into "add A"
111                            for j,oe in enumerate(self.entries):
112                                if oe.action_kind == e.UPDATED and e.name == oe.name:
113                                    del self.entries[j]
114
115                            return e
116
117                    # The "rename A B; add B" into "add B"
118                    if e.name == entry.name:
119                        del self.entries[i]
120
121                # Assert also that add_dir events must preceeds any
122                # add_file and ren_file that have that dir as target,
123                # and that add_file preceeds any edit.
124
125                if ((e.name == entry.name or e.name.startswith(dirname))
126                    or (e.action_kind == e.RENAMED and e.old_name.startswith(dirname))):
127                    self.entries.insert(i, entry)
128                    return entry
129
130        # Likewise, if this is a deletion, and there is a rename of
131        # this entry (such as "rename A B; remove B") then turn the
132        # existing rename into a deletion instead.
133
134        # If instead the removed entry was added by the same patch,
135        # annihilate the two: a bug in darcs (possibly fixed in recent
136        # versions) created patches with ADD+EDIT+REMOVE of a single
137        # file (see tailor ticket #71, or darcs issue185). Too bad
138        # another bug (still present in 1.0.8) hides that and makes
139        # very hard (read: impossible) any workaround on the tailor
140        # side. Luckily I learnt another tiny bit of Haskell and
141        # proposed a fix for that: hopefully the patch will be
142        # accepted by darcs developers. In the meantime, I attached it
143        # to ticket #71: without that, tailor does not have enough
144        # information to do the right thing.
145
146        elif entry.action_kind == entry.DELETED:
147            for i,e in enumerate(self.entries):
148                if e.action_kind == e.RENAMED and e.name == entry.name:
149                    e.action_kind = e.DELETED
150                    e.name = e.old_name
151                    e.old_name = None
152                    e.is_directory = entry.is_directory
153                    return e
154                elif e.action_kind == e.ADDED and e.name == entry.name:
155                    del self.entries[i]
156                    return None
157
158        # The "rename A B; rename B C" to "rename A C" part
159        elif entry.action_kind == entry.RENAMED:
160            # Adjust previous renames
161            olddirname = entry.old_name+'/'
162            for e in self.entries:
163                if e.action_kind == e.RENAMED and e.name.startswith(olddirname):
164                    e.name = entry.name + '/' + e.name[len(olddirname):]
165
166            for e in self.entries:
167                if e.action_kind == e.RENAMED and e.name == entry.old_name:
168                    e.name = entry.name
169                    return e
170
171            # The "rename A B; add B" into "add B", part two
172            for i,e in enumerate(self.entries):
173                if e.action_kind == e.ADDED and e.name == entry.name:
174                    return None
175
176        # Ok, it must be either an edit or a rename: the former goes
177        # obviously to the end, and since the latter, as said, come
178        # in very early, appending is just good.
179
180        self.entries.append(entry)
181        return entry
182
183
184def changesets_from_darcschanges(changes, unidiff=False, repodir=None,
185                                 chunksize=2**15, replace_badchars=None):
186    """
187    Parse XML output of ``darcs changes``.
188
189    Return a list of ``Changeset`` instances.
190
191    Filters out the (currently incorrect) tag info from
192    changesets_from_darcschanges_unsafe.
193    """
194
195    csets = changesets_from_darcschanges_unsafe(changes, unidiff,
196                                                repodir, chunksize,
197                                                replace_badchars)
198    for cs in csets:
199        yield cs
200
201def changesets_from_darcschanges_unsafe(changes, unidiff=False, repodir=None,
202                                        chunksize=2**15, replace_badchars=None):
203    """
204    Do the real work of parsing the change log, including tags.
205    Warning: the tag information in the changsets returned by this
206    function are only correct if each darcs tag in the repo depends on
207    all of the patches that precede it.  This is not a valid
208    assumption in general--a tag that does not depend on patch P can
209    be pulled in from another darcs repo after P.  We collect the tag
210    info anyway because DarcsWorkingDir._currentTags() can use it
211    safely despite this problem.  Hopefully the problem will
212    eventually be fixed and this function can be renamed
213    changesets_from_darcschanges.
214    """
215    from xml.sax import make_parser
216    from xml.sax.handler import ContentHandler, ErrorHandler
217    from datetime import datetime
218
219    class DarcsXMLChangesHandler(ContentHandler):
220        def __init__(self):
221            self.changesets = []
222            self.current = None
223            self.current_field = []
224            if unidiff and repodir:
225                cmd = ["darcs", "diff", "--unified", "--repodir", repodir,
226                       "--patch", "%(patchname)s"]
227                self.darcsdiff = ExternalCommand(command=cmd)
228            else:
229                self.darcsdiff = None
230
231        def startElement(self, name, attributes):
232            if name == 'patch':
233                self.current = {}
234                self.current['author'] = attributes['author']
235                date = attributes['date']
236                from time import strptime
237                try:
238                    # 20040619130027
239                    timestamp = datetime(*strptime(date, '%Y%m%d%H%M%S')[:6])
240                except ValueError:
241                    # Old darcs patches use the form Sun Oct 20 20:01:05 EDT 2002
242                    timestamp = datetime(*strptime(date[:19] + date[-5:], '%a %b %d %H:%M:%S %Y')[:6])
243
244                timestamp = timestamp.replace(tzinfo=UTC) # not true for the ValueError case, but oh well
245
246                self.current['date'] = timestamp
247                self.current['comment'] = ''
248                self.current['hash'] = attributes['hash']
249                self.current['entries'] = []
250                self.inverted = (attributes['inverted'] == "True")
251            elif name in ['name', 'comment', 'add_file', 'add_directory',
252                          'modify_file', 'remove_file', 'remove_directory']:
253                self.current_field = []
254            elif name == 'move':
255                self.old_name = attributes['from']
256                self.new_name = attributes['to']
257
258        def endElement(self, name):
259            if name == 'patch':
260                cset = DarcsChangeset(self.current['name'],
261                                      self.current['date'],
262                                      self.current['author'],
263                                      self.current['comment'],
264                                      self.current['entries'],
265                                      tags=self.current.get('tags',[]),
266                                      darcs_hash=self.current['hash'])
267                if self.darcsdiff:
268                    cset.unidiff = self.darcsdiff.execute(TZ='UTC',
269                        stdout=PIPE, patchname=cset.revision)[0].read()
270
271                self.changesets.append(cset)
272                self.current = None
273            elif name in ['name', 'comment']:
274                val = ''.join(self.current_field)
275                if val[:4] == 'TAG ':
276                    self.current.setdefault('tags',[]).append(val[4:])
277                self.current[name] = val
278            elif name == 'move':
279                entry = ChangesetEntry(self.new_name)
280                entry.action_kind = entry.RENAMED
281                entry.old_name = self.old_name
282                self.current['entries'].append(entry)
283            elif name in ['add_file', 'add_directory', 'modify_file',
284                          'remove_file', 'remove_directory']:
285                current_field = ''.join(self.current_field).strip()
286                if self.inverted:
287                    # the filenames in file modifications are outdated
288                    # if there are renames
289                    for i in self.current['entries']:
290                        if i.action_kind == i.RENAMED and current_field.startswith(i.old_name):
291                            current_field = current_field.replace(i.old_name, i.name)
292                entry = ChangesetEntry(current_field)
293                entry.action_kind = { 'add_file': entry.ADDED,
294                                      'add_directory': entry.ADDED,
295                                      'modify_file': entry.UPDATED,
296                                      'remove_file': entry.DELETED,
297                                      'remove_directory': entry.DELETED
298                                    }[name]
299                entry.is_directory = name.endswith('directory')
300                self.current['entries'].append(entry)
301
302        def characters(self, data):
303            self.current_field.append(data)
304
305    parser = make_parser()
306    handler = DarcsXMLChangesHandler()
307    parser.setContentHandler(handler)
308    parser.setErrorHandler(ErrorHandler())
309
310    def fixup_badchars(s, map):
311        if not map:
312            return s
313
314        ret = [map.get(c, c) for c in s]
315        return "".join(ret)
316
317    chunk = fixup_badchars(changes.read(chunksize), replace_badchars)
318    while chunk:
319        parser.feed(chunk)
320        for cs in handler.changesets:
321            yield cs
322        handler.changesets = []
323        chunk = fixup_badchars(changes.read(chunksize), replace_badchars)
324    parser.close()
325    for cs in handler.changesets:
326        yield cs
327
328
329class DarcsSourceWorkingDir(UpdatableSourceWorkingDir):
330    """
331    A source working directory under ``darcs``.
332    """
333
334    is_hash_rx = re.compile('[0-9a-f]{14}-[0-9a-f]{5}-[0-9a-f]{40}\.gz')
335
336    def _getUpstreamChangesets(self, sincerev):
337        """
338        Do the actual work of fetching the upstream changeset.
339        """
340
341        # Use the newer pull --xml-output, if possible
342        use_xml = False
343        if self.repository.darcs_version.startswith('2'):
344            cmd = self.repository.command("pull", "--dry-run", "--xml-output")
345            pull = ExternalCommand(cwd=self.repository.basedir, command=cmd)
346            output = pull.execute(self.repository.repository,
347                                  stdout=PIPE, stderr=STDOUT, TZ='UTC0')[0]
348            # pull --xml-output was introduced *after* 2.0.0
349            if pull.exit_status:
350                errormsg = output.read()
351                if "unrecognized option `--xml-output'" in errormsg:
352                    self.log.warning('Using darcs 1.0 non-XML parser: it may fail '
353                                     'on patches recorded before november 2003! '
354                                     'I would suggest of upgrading to latest darcs 2.0 '
355                                     '(later than 2.0+233).')
356                    # No way, fall back to old behaviour, that will possibly fail,
357                    # on patches recorded before 2003-11-01... :-|
358                else:
359                    raise GetUpstreamChangesetsFailure(
360                        "%s returned status %d saying\n%s" %
361                        (str(pull), pull.exit_status, errormsg))
362            else:
363                use_xml = True
364
365        if not use_xml:
366            cmd = self.repository.command("pull", "--dry-run")
367            pull = ExternalCommand(cwd=self.repository.basedir, command=cmd)
368            output = pull.execute(self.repository.repository,
369                                  stdout=PIPE, stderr=STDOUT, TZ='UTC0')[0]
370
371            if pull.exit_status:
372                raise GetUpstreamChangesetsFailure(
373                    "%s returned status %d saying\n%s" %
374                    (str(pull), pull.exit_status, output.read()))
375
376            return self._parseDarcsPull(output)
377        else:
378            # Skip initial verbosity, as well as the one at end
379            from cStringIO import StringIO
380
381            output.readline() # Would pull from "/home/lele/wip/darcs-2.0"...
382            output.readline() # Would pull the following changes:
383            xml = StringIO(''.join(output.readlines()[:-2]))
384            xml.seek(0)
385            badchars = self.repository.replace_badchars
386
387            return changesets_from_darcschanges(xml, replace_badchars=badchars)
388
389    def _parseDarcsPull(self, output):
390        """Process 'darcs pull' output to Changesets.
391        """
392        from datetime import datetime
393        from time import strptime
394        from sha import new
395
396        l = output.readline()
397        while l and not (l.startswith('Would pull the following changes:') or
398                         l == 'No remote changes to pull in!\n'):
399            l = output.readline()
400
401        if l <> 'No remote changes to pull in!\n':
402            ## Sat Jul 17 01:22:08 CEST 2004  lele@nautilus
403            ##   * Refix _getUpstreamChangesets for darcs
404
405            fsep = re.compile('[ :]+')
406            l = output.readline()
407            while not l.startswith('Making no changes:  this is a dry run.'):
408                # Assume it's a line like
409                #    Sun Jan  2 00:24:04 UTC 2005  lele@nautilus.homeip.net
410                # Use a regular expression matching multiple spaces or colons
411                # to split it, and use the first 7 fields to build up a datetime.
412                pieces = fsep.split(l.rstrip(), 8)
413                assert len(pieces)>=7, "Cannot parse %r as a patch timestamp" % l
414                date = ' '.join(pieces[:8])
415                try:
416                    author = pieces[8]
417                except IndexError, s:
418                    # darcs allows patches with empty author
419                    author = ""
420                y,m,d,hh,mm,ss,d1,d2,d3 = strptime(date, "%a %b %d %H %M %S %Z %Y")
421                date = datetime(y,m,d,hh,mm,ss,0,UTC)
422                l = output.readline().rstrip()
423                assert (l.startswith('  *') or
424                        l.startswith('  UNDO:') or
425                        l.startswith('  tagged')), \
426                        "Got %r but expected the start of the log" % l
427
428                if l.startswith('  *'):
429                    name = l[4:]
430                else:
431                    name = l[2:]
432
433                changelog = []
434                l = output.readline()
435                while l.startswith('  '):
436                    changelog.append(l[2:-1])
437                    l = output.readline()
438
439                cset = DarcsChangeset(name, date, author, '\n'.join(changelog))
440                compactdate = date.strftime("%Y%m%d%H%M%S")
441                if name.startswith('UNDO: '):
442                    name = name[6:]
443                    inverted = 't'
444                else:
445                    inverted = 'f'
446
447                if name.startswith('tagged '):
448                    name = name[7:]
449                    if cset.tags is None:
450                        cset.tags = [name]
451                    else:
452                        cset.tags.append(name)
453                    name = "TAG " + name
454
455                phash = new()
456                phash.update(name)
457                phash.update(author)
458                phash.update(compactdate)
459                phash.update(''.join(changelog))
460                phash.update(inverted)
461                cset.darcs_hash = '%s-%s-%s.gz' % (compactdate,
462                                                   new(author).hexdigest()[:5],
463                                                   phash.hexdigest())
464
465
466                yield cset
467
468                while not l.strip():
469                    l = output.readline()
470
471    def _applyChangeset(self, changeset):
472        """
473        Do the actual work of applying the changeset to the working copy.
474        """
475
476        needspatchesopt = False
477        if hasattr(changeset, 'darcs_hash'):
478            selector = '--match'
479            revtag = 'hash ' + changeset.darcs_hash
480        elif changeset.revision.startswith('tagged '):
481            selector = '--tag'
482            revtag = changeset.revision[7:]
483        else:
484            selector = '--match'
485            revtag = 'date "%s" && author "%s"' % (
486                changeset.date.strftime("%Y%m%d%H%M%S"),
487                changeset.author)
488            # The 'exact' matcher doesn't groke double quotes:
489            # """currently there is no provision for escaping a double
490            # quote, so you have to choose between matching double
491            # quotes and matching spaces"""
492            if not '"' in changeset.revision:
493                revtag += ' && exact "%s"' % changeset.revision.replace('%', '%%')
494            else:
495                needspatchesopt = True
496
497        cmd = self.repository.command("pull", "--all", "--quiet",
498                                      selector, revtag)
499
500        if needspatchesopt:
501            cmd.extend(['--patches', re.escape(changeset.revision)])
502
503        pull = ExternalCommand(cwd=self.repository.basedir, command=cmd)
504        output = pull.execute(stdout=PIPE, stderr=STDOUT, input='y')[0]
505
506        if pull.exit_status:
507            raise ChangesetApplicationFailure(
508                "%s returned status %d saying\n%s" %
509                (str(pull), pull.exit_status, output.read()))
510
511        conflicts = []
512        line = output.readline()
513        while line:
514            if line.startswith('We have conflicts in the following files:'):
515                files = output.readline()[:-1].split(' ')
516                self.log.warning("Conflict after 'darcs pull': %s",
517                                 ' '.join(files))
518                conflicts.extend(files)
519            line = output.readline()
520
521        # Complete the changeset with its entries
522
523        cmd = self.repository.command("changes", selector, revtag,
524                                      "--xml-output", "--summ")
525        changes = ExternalCommand(cwd=self.repository.basedir, command=cmd)
526        last = changesets_from_darcschanges(changes.execute(stdout=PIPE)[0],
527                                            replace_badchars=self.repository.replace_badchars)
528        try:
529            entries = last.next().entries
530        except StopIteration:
531            entries = None
532
533        if entries:
534            for e in entries:
535                changeset.addEntry(e, changeset.revision)
536
537        return conflicts
538
539    def _handleConflict(self, changeset, conflicts, conflict):
540        """
541        Handle the conflict raised by the application of the upstream changeset.
542
543        Override parent behaviour: with darcs, we need to execute a revert
544        on the conflicted files, **trashing** local changes, but there should
545        be none of them in tailor context.
546        """
547
548        from os import walk, unlink
549        from os.path import join
550        from re import compile
551
552        self.log.info("Reverting changes to %s, to solve the conflict",
553                      ' '.join(conflict))
554        cmd = self.repository.command("revert", "--all")
555        revert = ExternalCommand(cwd=self.repository.basedir, command=cmd)
556        revert.execute(conflict, input="\n")
557
558        # Remove also the backups made by darcs
559        bckre = compile('-darcs-backup[0-9]+$')
560        for root, dirs, files in walk(self.repository.basedir):
561            backups = [f for f in files if bckre.search(f)]
562            for bck in backups:
563                self.log.debug("Removing backup file %r in %r", bck, root)
564                unlink(join(root, bck))
565
566    def _checkoutUpstreamRevision(self, revision):
567        """
568        Concretely do the checkout of the upstream revision and return
569        the last applied changeset.
570        """
571
572        from os.path import join, exists
573        from os import mkdir
574        from vcpx.source import InvocationError
575
576        if not self.repository.repository:
577            raise InvocationError("Must specify a the darcs source repository")
578
579        if revision == 'INITIAL' or self.is_hash_rx.match(revision):
580            initial = True
581
582            if revision == 'INITIAL':
583                cmd = self.repository.command("changes", "--xml-output",
584                                              "--repo", self.repository.repository,
585                                               "--reverse")
586                changes = ExternalCommand(command=cmd)
587                output = changes.execute(stdout=PIPE, stderr=STDOUT)[0]
588
589                if changes.exit_status:
590                    raise ChangesetApplicationFailure(
591                        "%s returned status %d saying\n%s" %
592                        (str(changes), changes.exit_status,
593                         output and output.read() or ''))
594
595                csets = changesets_from_darcschanges(output, replace_badchars=self.repository.replace_badchars)
596                try:
597                    changeset = csets.next()
598                except StopIteration:
599                    # No changesets, no party!
600                    return None
601
602                revision = 'hash %s' % changeset.darcs_hash
603            else:
604                revision = 'hash %s' % revision
605        else:
606            initial = False
607
608        # Darcs 2.0 fails with "darcs get --to-match", see issue885
609        darcs2 = self.repository.darcs_version.startswith('2')
610        if darcs2 or self.repository.subdir == '.' or exists(self.repository.basedir):
611            # This is currently *very* slow, compared to the darcs get
612            # below!
613            if not exists(join(self.repository.basedir, '_darcs')):
614                if not exists(self.repository.basedir):
615                    mkdir(self.repository.basedir)
616
617                cmd = self.repository.command("initialize")
618                init = ExternalCommand(cwd=self.repository.basedir, command=cmd)
619                init.execute()
620
621                if init.exit_status:
622                    raise TargetInitializationFailure(
623                        "%s returned status %s" % (str(init),
624                                                   init.exit_status))
625
626                cmd = self.repository.command("pull", "--all", "--quiet")
627                if revision and revision<>'HEAD':
628                    cmd.extend([initial and "--match" or "--tag", revision])
629                dpull = ExternalCommand(cwd=self.repository.basedir, command=cmd)
630                output = dpull.execute(self.repository.repository,
631                                       stdout=PIPE, stderr=STDOUT)[0]
632
633                if dpull.exit_status:
634                    raise TargetInitializationFailure(
635                        "%s returned status %d saying\n%s" %
636                        (str(dpull), dpull.exit_status, output.read()))
637        else:
638            # Use much faster 'darcs get'
639            cmd = self.repository.command("get", "--quiet")
640            if revision and revision<>'HEAD':
641                cmd.extend([initial and "--to-match" or "--tag", revision])
642            else:
643                cmd.append("--partial")
644            dget = ExternalCommand(command=cmd)
645            output = dget.execute(self.repository.repository, self.repository.basedir,
646                                  stdout=PIPE, stderr=STDOUT)[0]
647
648            if dget.exit_status:
649                raise TargetInitializationFailure(
650                    "%s returned status %d saying\n%s" %
651                    (str(dget), dget.exit_status, output.read()))
652
653        cmd = self.repository.command("changes", "--last", "1",
654                                      "--xml-output")
655        changes = ExternalCommand(cwd=self.repository.basedir, command=cmd)
656        output = changes.execute(stdout=PIPE, stderr=STDOUT)[0]
657
658        if changes.exit_status:
659            raise ChangesetApplicationFailure(
660                "%s returned status %d saying\n%s" %
661                (str(changes), changes.exit_status, output.read()))
662
663        try:
664            last = changesets_from_darcschanges(
665                output, replace_badchars=self.repository.replace_badchars).next()
666        except StopIteration:
667            last = None
668
669        return last
Note: See TracBrowser for help on using the repository browser.