xref: /titanic_50/usr/src/tools/onbld/Scm/WorkSpace.py (revision ba7866cd2cbdf574f47d4e38a1301b90744dd677)
1#
2#  This program is free software; you can redistribute it and/or modify
3#  it under the terms of the GNU General Public License version 2
4#  as published by the Free Software Foundation.
5#
6#  This program is distributed in the hope that it will be useful,
7#  but WITHOUT ANY WARRANTY; without even the implied warranty of
8#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
9#  GNU General Public License for more details.
10#
11#  You should have received a copy of the GNU General Public License
12#  along with this program; if not, write to the Free Software
13#  Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
14#
15
16#
17# Copyright (c) 2008, 2010, Oracle and/or its affiliates. All rights reserved.
18#
19
20#
21# Theory:
22#
23# Workspaces have a non-binding parent/child relationship.
24# All important operations apply to the changes between the two.
25#
26# However, for the sake of remote operation, the 'parent' of a
27# workspace is not seen as a literal entity, instead the figurative
28# parent contains the last changeset common to both parent and child,
29# as such the 'parent tip' is actually nothing of the sort, but instead a
30# convenient imitation.
31#
32# Any change made to a workspace is a change to a file therein, such
33# changes can be represented briefly as whether the file was
34# modified/added/removed as compared to the parent workspace, whether
35# the file has a different name in the parent and if so, whether it
36# was renamed or merely copied.  Each changed file has an
37# associated ActiveEntry.
38#
39# The ActiveList being a list ActiveEntrys can thus present the entire
40# change in workspace state between a parent and its child, and is the
41# important bit here (in that if it is incorrect, everything else will
42# be as incorrect, or more)
43#
44
45import cStringIO
46import os
47from mercurial import cmdutil, context, hg, node, patch, repair, util
48from hgext import mq
49
50from onbld.Scm import Version
51
52#
53# Mercurial >= 1.2 has its exception types in a mercurial.error
54# module, prior versions had them in their associated modules.
55#
56if Version.at_least("1.2"):
57    from mercurial import error
58    HgRepoError = error.RepoError
59    HgLookupError = error.LookupError
60else:
61    from mercurial import repo, revlog
62    HgRepoError = repo.RepoError
63    HgLookupError = revlog.LookupError
64
65
66class ActiveEntry(object):
67    '''Representation of the changes made to a single file.
68
69    MODIFIED   - Contents changed, but no other changes were made
70    ADDED      - File is newly created
71    REMOVED    - File is being removed
72
73    Copies are represented by an Entry whose .parentname is non-nil
74
75    Truly copied files have non-nil .parentname and .renamed = False
76    Renames have non-nil .parentname and .renamed = True
77
78    Do not access any of this information directly, do so via the
79
80    .is_<change>() methods.'''
81
82    MODIFIED = 1
83    ADDED = 2
84    REMOVED = 3
85
86    def __init__(self, name):
87        self.name = name
88        self.change = None
89        self.parentname = None
90        # As opposed to copied (or neither)
91        self.renamed = False
92        self.comments = []
93
94    #
95    # ActiveEntrys sort by the name of the file they represent.
96    #
97    def __cmp__(self, other):
98        return cmp(self.name, other.name)
99
100    def is_added(self):
101        return self.change == self.ADDED
102
103    def is_modified(self):
104        return self.change == self.MODIFIED
105
106    def is_removed(self):
107        return self.change == self.REMOVED
108
109    def is_renamed(self):
110        return self.parentname and self.renamed
111
112    def is_copied(self):
113        return self.parentname and not self.renamed
114
115
116class ActiveList(object):
117    '''Complete representation of workspace change.
118
119    In practice, a container for ActiveEntrys, and methods to build them,
120    update them, and deal with them en masse.'''
121
122    def __init__(self, ws, parenttip, revs=None):
123        self._active = {}
124        self.ws = ws
125
126        self.revs = revs
127
128        self.base = None
129        self.parenttip = parenttip
130
131        #
132        # If we couldn't find a parenttip, the two repositories must
133        # be unrelated (Hg catches most of this, but this case is valid for it
134        # but invalid for us)
135        #
136        if self.parenttip == None:
137            raise util.Abort('repository is unrelated')
138        self.localtip = None
139
140        if revs:
141            self.base = revs[0]
142            self.localtip = revs[-1]
143
144        self._comments = []
145
146        self._build(revs)
147
148    def _build(self, revs):
149        if not revs:
150            return
151
152        status = self.ws.status(self.parenttip.node(), self.localtip.node())
153
154        files = []
155        for ctype in status.values():
156            files.extend(ctype)
157
158        #
159        # When a file is renamed, two operations actually occur.
160        # A file copy from source to dest and a removal of source.
161        #
162        # These are represented as two distinct entries in the
163        # changectx and status (one on the dest file for the
164        # copy, one on the source file for the remove).
165        #
166        # Since these are unconnected in both the context and
167        # status we can only make the association by explicitly
168        # looking for it.
169        #
170        # We deal with this thusly:
171        #
172        # We maintain a dict dest -> source of all copies
173        # (updating dest as appropriate, but leaving source alone).
174        #
175        # After all other processing, we mark as renamed any pair
176        # where source is on the removed list.
177        #
178        copies = {}
179
180        #
181        # Walk revs looking for renames and adding files that
182        # are in both change context and status to the active
183        # list.
184        #
185        for ctx in revs:
186            desc = ctx.description().splitlines()
187
188            self._comments.extend(desc)
189
190            for fname in ctx.files():
191                #
192                # We store comments per-entry as well, for the sake of
193                # webrev and similar.  We store twice to avoid the problems
194                # of uniquifying comments for the general list (and possibly
195                # destroying multi-line entities in the process).
196                #
197                if fname not in self:
198                    self._addentry(fname)
199                self[fname].comments.extend(desc)
200
201                try:
202                    fctx = ctx.filectx(fname)
203                except HgLookupError:
204                    continue
205
206                #
207                # NB: .renamed() is a misnomer, this actually checks
208                #     for copies.
209                #
210                rn = fctx.renamed()
211                if rn:
212                    #
213                    # If the source file is a known copy we know its
214                    # ancestry leads us to the parent.
215                    # Otherwise make sure the source file is known to
216                    # be in the parent, we need not care otherwise.
217                    #
218                    # We detect cycles at a later point.  There is no
219                    # reason to continuously handle them.
220                    #
221                    if rn[0] in copies:
222                        copies[fname] = copies[rn[0]]
223                    elif rn[0] in self.parenttip.manifest():
224                        copies[fname] = rn[0]
225
226        #
227        # Walk the copy list marking as copied any non-cyclic pair
228        # where the destination file is still present in the local
229        # tip (to avoid ephemeral changes)
230        #
231        # Where source is removed, mark as renamed, and remove the
232        # AL entry for the source file
233        #
234        for fname, oldname in copies.iteritems():
235            if fname == oldname or fname not in self.localtip.manifest():
236                continue
237
238            self[fname].parentname = oldname
239
240            if oldname in status['removed']:
241                self[fname].renamed = True
242                if oldname in self:
243                    del self[oldname]
244
245        #
246        # Walk the active list setting the change type for each active
247        # file.
248        #
249        # In the case of modified files that are not renames or
250        # copies, we do a content comparison, and drop entries that
251        # are not actually modified.
252        #
253        # We walk a copy of the AL such that we can drop entries
254        # within the loop.
255        #
256        for entry in self._active.values():
257            if entry.name not in files:
258                del self[entry.name]
259                continue
260
261            if entry.name in status['added']:
262                entry.change = ActiveEntry.ADDED
263            elif entry.name in status['removed']:
264                entry.change = ActiveEntry.REMOVED
265            elif entry.name in status['modified']:
266                entry.change = ActiveEntry.MODIFIED
267
268            #
269            # There are cases during a merge where a file will be in
270            # the status return as modified, but in reality be an
271            # addition (ie, not in the parenttip).
272            #
273            # We need to check whether the file is actually present
274            # in the parenttip, and set it as an add, if not.
275            #
276            if entry.name not in self.parenttip.manifest():
277                entry.change = ActiveEntry.ADDED
278            elif entry.is_modified():
279                if not self._changed_file(entry.name):
280                    del self[entry.name]
281                    continue
282
283            assert entry.change
284
285    def __contains__(self, fname):
286        return fname in self._active
287
288    def __getitem__(self, key):
289        return self._active[key]
290
291    def __setitem__(self, key, value):
292        self._active[key] = value
293
294    def __delitem__(self, key):
295        del self._active[key]
296
297    def __iter__(self):
298        for entry in self._active.values():
299            yield entry
300
301    def _addentry(self, fname):
302        if fname not in self:
303            self[fname] = ActiveEntry(fname)
304
305    def files(self):
306        '''Return the list of pathnames of all files touched by this
307        ActiveList
308
309        Where files have been renamed, this will include both their
310        current name and the name which they had in the parent tip.
311        '''
312
313        ret = self._active.keys()
314        ret.extend([x.parentname for x in self
315                    if x.is_renamed() and x.parentname not in ret])
316        return ret
317
318    def comments(self):
319        return self._comments
320
321    def bases(self):
322        '''Return the list of changesets that are roots of the ActiveList.
323
324        This is the set of active changesets where neither parent
325        changeset is itself active.'''
326
327        revset = set(self.revs)
328        return filter(lambda ctx: not [p for p in ctx.parents() if p in revset],
329                      self.revs)
330
331    def tags(self):
332        '''Find tags that refer to a changeset in the ActiveList,
333        returning a list of 3-tuples (tag, node, is_local) for each.
334
335        We return all instances of a tag that refer to such a node,
336        not just that which takes precedence.'''
337
338        def colliding_tags(iterable, nodes, local):
339            for nd, name in [line.rstrip().split(' ', 1) for line in iterable]:
340                if nd in nodes:
341                    yield (name, self.ws.repo.lookup(nd), local)
342
343        tags = []
344        nodes = set(node.hex(ctx.node()) for ctx in self.revs)
345
346        if os.path.exists(self.ws.repo.join('localtags')):
347            fh = self.ws.repo.opener('localtags')
348            tags.extend(colliding_tags(fh, nodes, True))
349            fh.close()
350
351        # We want to use the tags file from the localtip
352        if '.hgtags' in self.localtip:
353            data = self.localtip.filectx('.hgtags').data().splitlines()
354            tags.extend(colliding_tags(data, nodes, False))
355
356        return tags
357
358    def prune_tags(self, data):
359        '''Return a copy of data, which should correspond to the
360        contents of a Mercurial tags file, with any tags that refer to
361        changesets which are components of the ActiveList removed.'''
362
363        nodes = set(node.hex(ctx.node()) for ctx in self.revs)
364        return [t for t in data if t.split(' ', 1)[0] not in nodes]
365
366    def _changed_file(self, path):
367        '''Compare the parent and local versions of a given file.
368        Return True if file changed, False otherwise.
369
370        Note that this compares the given path in both versions, not the given
371        entry; renamed and copied files are compared by name, not history.
372
373        The fast path compares file metadata, slow path is a
374        real comparison of file content.'''
375
376        # Note that we use localtip.manifest() here because of a bug in
377        # Mercurial 1.1.2's workingctx.__contains__
378        if ((path in self.parenttip) != (path in self.localtip.manifest())):
379            return True
380
381        parentfile = self.parenttip.filectx(path)
382        localfile = self.localtip.filectx(path)
383
384        #
385        # NB: Keep these ordered such as to make every attempt
386        #     to short-circuit the more time consuming checks.
387        #
388        if parentfile.size() != localfile.size():
389            return True
390
391        if parentfile.flags() != localfile.flags():
392            return True
393
394        if parentfile.cmp(localfile.data()):
395            return True
396
397    def context(self, message, user):
398        '''Return a Mercurial context object representing the entire
399        ActiveList as one change.'''
400        return activectx(self, message, user)
401
402
403class activectx(context.memctx):
404    '''Represent an ActiveList as a Mercurial context object.
405
406    Part of the  WorkSpace.squishdeltas implementation.'''
407
408    def __init__(self, active, message, user):
409        '''Build an activectx object.
410
411          active  - The ActiveList object used as the source for all data.
412          message - Changeset description
413          user    - Committing user'''
414
415        def filectxfn(repository, ctx, fname):
416            fctx = active.localtip.filectx(fname)
417            data = fctx.data()
418
419            #
420            # .hgtags is a special case, tags referring to active list
421            # component changesets should be elided.
422            #
423            if fname == '.hgtags':
424                data = '\n'.join(active.prune_tags(data.splitlines()))
425
426            return context.memfilectx(fname, data, 'l' in fctx.flags(),
427                                      'x' in fctx.flags(),
428                                      active[fname].parentname)
429
430        self.__active = active
431        parents = (active.parenttip.node(), node.nullid)
432        extra = {'branch': active.localtip.branch()}
433        context.memctx.__init__(self, active.ws.repo, parents, message,
434                                active.files(), filectxfn, user=user,
435                                extra=extra)
436
437    def modified(self):
438        return [entry.name for entry in self.__active if entry.is_modified()]
439
440    def added(self):
441        return [entry.name for entry in self.__active if entry.is_added()]
442
443    def removed(self):
444        ret = set(entry.name for entry in self.__active if entry.is_removed())
445        ret.update(set(x.parentname for x in self.__active if x.is_renamed()))
446        return list(ret)
447
448    def files(self):
449        return self.__active.files()
450
451
452class WorkSpace(object):
453
454    def __init__(self, repository):
455        self.repo = repository
456        self.ui = self.repo.ui
457        self.name = self.repo.root
458
459        self.activecache = {}
460
461    def parent(self, spec=None):
462        '''Return the canonical workspace parent, either SPEC (which
463        will be expanded) if provided or the default parent
464        otherwise.'''
465
466        if spec:
467            return self.ui.expandpath(spec)
468
469        p = self.ui.expandpath('default')
470        if p == 'default':
471            return None
472        else:
473            return p
474
475    def _localtip(self, outgoing, wctx):
476        '''Return the most representative changeset to act as the
477        localtip.
478
479        If the working directory is modified (has file changes, is a
480        merge, or has switched branches), this will be a workingctx.
481
482        If the working directory is unmodified, this will be the most
483        recent (highest revision number) local (outgoing) head on the
484        current branch, if no heads are determined to be outgoing, it
485        will be the most recent head on the current branch.
486        '''
487
488        #
489        # A modified working copy is seen as a proto-branch, and thus
490        # our only option as the local tip.
491        #
492        if (wctx.files() or len(wctx.parents()) > 1 or
493            wctx.branch() != wctx.parents()[0].branch()):
494            return wctx
495
496        heads = self.repo.heads(start=wctx.parents()[0].node())
497        headctxs = [self.repo.changectx(n) for n in heads]
498        localctxs = [c for c in headctxs if c.node() in outgoing]
499
500        ltip = sorted(localctxs or headctxs, key=lambda x: x.rev())[-1]
501
502        if len(heads) > 1:
503            self.ui.warn('The current branch has more than one head, '
504                         'using %s\n' % ltip.rev())
505
506        return ltip
507
508    def _parenttip(self, heads, outgoing):
509        '''Return the highest-numbered, non-outgoing changeset that is
510        an ancestor of a changeset in heads.
511
512        This is intended to find the most recent changeset on a given
513        branch that is shared between a parent and child workspace,
514        such that it can act as a stand-in for the parent workspace.
515        '''
516
517        def tipmost_shared(head, outnodes):
518            '''Return the tipmost node on the same branch as head that is not
519            in outnodes.
520
521            We walk from head to the bottom of the workspace (revision
522            0) collecting nodes not in outnodes during the add phase
523            and return the first node we see in the iter phase that
524            was previously collected.
525
526            If no node is found (all revisions >= 0 are outgoing), the
527            only possible parenttip is the null node (node.nullid)
528            which is returned explicitly.
529
530            See the docstring of mercurial.cmdutil.walkchangerevs()
531            for the phased approach to the iterator returned.  The
532            important part to note is that the 'add' phase gathers
533            nodes, which the 'iter' phase then iterates through.'''
534
535            opts = {'rev': ['%s:0' % head.rev()],
536                    'follow': True}
537            get = util.cachefunc(lambda r: self.repo.changectx(r).changeset())
538            changeiter = cmdutil.walkchangerevs(self.repo.ui, self.repo, [],
539                                                get, opts)[0]
540            seen = []
541            for st, rev, fns in changeiter:
542                n = self.repo.changelog.node(rev)
543                if st == 'add':
544                    if n not in outnodes:
545                        seen.append(n)
546                elif st == 'iter':
547                    if n in seen:
548                        return rev
549            return self.repo.changelog.rev(node.nullid)
550
551        nodes = set(outgoing)
552        ptips = map(lambda x: tipmost_shared(x, nodes), heads)
553        return self.repo.changectx(sorted(ptips)[-1])
554
555    def status(self, base='.', head=None):
556        '''Translate from the hg 6-tuple status format to a hash keyed
557        on change-type'''
558
559        states = ['modified', 'added', 'removed', 'deleted', 'unknown',
560              'ignored']
561
562        chngs = self.repo.status(base, head)
563        return dict(zip(states, chngs))
564
565    def findoutgoing(self, parent):
566        '''Return the base set of outgoing nodes.
567
568        A caching wrapper around mercurial.localrepo.findoutgoing().
569        Complains (to the user), if the parent workspace is
570        non-existent or inaccessible'''
571
572        self.ui.pushbuffer()
573        try:
574            try:
575                ui = self.ui
576                if hasattr(cmdutil, 'remoteui'):
577                    ui = cmdutil.remoteui(ui, {})
578                pws = hg.repository(ui, parent)
579                return self.repo.findoutgoing(pws)
580            except HgRepoError:
581                self.ui.warn("Warning: Parent workspace '%s' is not "
582                             "accessible\n"
583                             "active list will be incomplete\n\n" % parent)
584                return []
585        finally:
586            self.ui.popbuffer()
587    findoutgoing = util.cachefunc(findoutgoing)
588
589    def modified(self):
590        '''Return a list of files modified in the workspace'''
591        wctx = self.workingctx()
592        return sorted(wctx.files() + wctx.deleted()) or None
593
594    def merged(self):
595        '''Return boolean indicating whether the workspace has an uncommitted
596        merge'''
597        wctx = self.workingctx()
598        return len(wctx.parents()) > 1
599
600    def branched(self):
601        '''Return boolean indicating whether the workspace has an
602        uncommitted named branch'''
603
604        wctx = self.workingctx()
605        return wctx.branch() != wctx.parents()[0].branch()
606
607    def active(self, parent=None):
608        '''Return an ActiveList describing changes between workspace
609        and parent workspace (including uncommitted changes).
610        If workspace has no parent ActiveList will still describe any
611        uncommitted changes'''
612
613        parent = self.parent(parent)
614        if parent in self.activecache:
615            return self.activecache[parent]
616
617        if parent:
618            outgoing = self.findoutgoing(parent)
619            outnodes = self.repo.changelog.nodesbetween(outgoing)[0]
620        else:
621            outgoing = []       # No parent, no outgoing nodes
622            outnodes = []
623
624        localtip = self._localtip(outnodes, self.workingctx())
625
626        if localtip.rev() is None:
627            heads = localtip.parents()
628        else:
629            heads = [localtip]
630
631        ctxs = [self.repo.changectx(n) for n in
632                self.repo.changelog.nodesbetween(outgoing,
633                                                 [h.node() for h in heads])[0]]
634
635        if localtip.rev() is None:
636            ctxs.append(localtip)
637
638        act = ActiveList(self, self._parenttip(heads, outnodes), ctxs)
639
640        self.activecache[parent] = act
641        return act
642
643    def pdiff(self, pats, opts, parent=None):
644        'Return diffs relative to PARENT, as best as we can make out'
645
646        parent = self.parent(parent)
647        act = self.active(parent)
648
649        #
650        # act.localtip maybe nil, in the case of uncommitted local
651        # changes.
652        #
653        if not act.revs:
654            return
655
656        matchfunc = cmdutil.match(self.repo, pats, opts)
657        opts = patch.diffopts(self.ui, opts)
658
659        return self.diff(act.parenttip.node(), act.localtip.node(),
660                         match=matchfunc, opts=opts)
661
662    def squishdeltas(self, active, message, user=None):
663        '''Create a single conglomerate changeset based on a given
664        active list.  Removes the original changesets comprising the
665        given active list, and any tags pointing to them.
666
667        Operation:
668
669          - Commit an activectx object representing the specified
670            active list,
671
672          - Remove any local tags pointing to changesets in the
673            specified active list.
674
675          - Remove the changesets comprising the specified active
676            list.
677
678          - Remove any metadata that may refer to changesets that were
679            removed.
680
681        Calling code is expected to hold both the working copy lock
682        and repository lock of the destination workspace
683        '''
684
685        def strip_local_tags(active):
686            '''Remove any local tags referring to the specified nodes.'''
687
688            if os.path.exists(self.repo.join('localtags')):
689                fh = None
690                try:
691                    fh = self.repo.opener('localtags')
692                    tags = active.prune_tags(fh)
693                    fh.close()
694
695                    fh = self.repo.opener('localtags', 'w', atomictemp=True)
696                    fh.writelines(tags)
697                    fh.rename()
698                finally:
699                    if fh and not fh.closed:
700                        fh.close()
701
702        if active.files():
703            for entry in active:
704                #
705                # Work around Mercurial issue #1666, if the source
706                # file of a rename exists in the working copy
707                # Mercurial will complain, and remove the file.
708                #
709                # We preemptively remove the file to avoid the
710                # complaint (the user was asked about this in
711                # cdm_recommit)
712                #
713                if entry.is_renamed():
714                    path = self.repo.wjoin(entry.parentname)
715                    if os.path.exists(path):
716                        os.unlink(path)
717
718            self.repo.commitctx(active.context(message, user))
719            wsstate = "recommitted"
720            destination = self.repo.changelog.tip()
721        else:
722            #
723            # If all we're doing is stripping the old nodes, we want to
724            # update the working copy such that we're not at a revision
725            # that's about to go away.
726            #
727            wsstate = "tip"
728            destination = active.parenttip.node()
729
730        self.clean(destination)
731
732        #
733        # Tags were elided by the activectx object.  Local tags,
734        # however, must be removed manually.
735        #
736        try:
737            strip_local_tags(active)
738        except EnvironmentError, e:
739            raise util.Abort('Could not recommit tags: %s\n' % e)
740
741        # Silence all the strip and update fun
742        self.ui.pushbuffer()
743
744        #
745        # Remove the active lists component changesets by stripping
746        # the base of any active branch (of which there may be
747        # several)
748        #
749        try:
750            try:
751                for base in active.bases():
752                    #
753                    # Any cached information about the repository is
754                    # likely to be invalid during the strip.  The
755                    # caching of branch tags is especially
756                    # problematic.
757                    #
758                    self.repo.invalidate()
759                    repair.strip(self.ui, self.repo, base.node(), backup=False)
760            except:
761                #
762                # If this fails, it may leave us in a surprising place in
763                # the history.
764                #
765                # We want to warn the user that something went wrong,
766                # and what will happen next, re-raise the exception, and
767                # bring the working copy back into a consistent state
768                # (which the finally block will do)
769                #
770                self.ui.warn("stripping failed, your workspace will have "
771                             "superfluous heads.\n"
772                             "your workspace has been updated to the "
773                             "%s changeset.\n" % wsstate)
774                raise               # Re-raise the exception
775        finally:
776            self.clean()
777            self.repo.dirstate.write() # Flush the dirstate
778            self.repo.invalidate()     # Invalidate caches
779
780            #
781            # We need to remove Hg's undo information (used for rollback),
782            # since it refers to data that will probably not exist after
783            # the strip.
784            #
785            if os.path.exists(self.repo.sjoin('undo')):
786                try:
787                    os.unlink(self.repo.sjoin('undo'))
788                except EnvironmentError, e:
789                    raise util.Abort('failed to remove undo data: %s\n' % e)
790
791            self.ui.popbuffer()
792
793    def filepath(self, path):
794        'Return the full path to a workspace file.'
795        return self.repo.pathto(path)
796
797    def clean(self, rev=None):
798        '''Bring workspace up to REV (or tip) forcefully (discarding in
799        progress changes)'''
800
801        if rev != None:
802            rev = self.repo.lookup(rev)
803        else:
804            rev = self.repo.changelog.tip()
805
806        hg.clean(self.repo, rev, show_stats=False)
807
808    def mq_applied(self):
809        '''True if the workspace has Mq patches applied'''
810        q = mq.queue(self.ui, self.repo.join(''))
811        return q.applied
812
813    def workingctx(self):
814        return self.repo.changectx(None)
815
816    def diff(self, node1=None, node2=None, match=None, opts=None):
817        ret = cStringIO.StringIO()
818        try:
819            for chunk in patch.diff(self.repo, node1, node2, match=match,
820                                    opts=opts):
821                ret.write(chunk)
822        finally:
823            # Workaround Hg bug 1651
824            if not Version.at_least("1.3"):
825                self.repo.dirstate.invalidate()
826
827        return ret.getvalue()
828