Add transplant extension
authorBrendan Cully <brendan@kublai.com>
Mon, 27 Nov 2006 15:13:01 -0800
changeset 3714 198173f3957c
parent 3713 8ae88ed2a3b6
child 3715 6cb3aca69cdc
Add transplant extension
hgext/transplant.py
tests/test-transplant
tests/test-transplant.out
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hgext/transplant.py	Mon Nov 27 15:13:01 2006 -0800
@@ -0,0 +1,565 @@
+# Patch transplanting extension for Mercurial
+#
+# Copyright 2006 Brendan Cully <brendan@kublai.com>
+#
+# This software may be used and distributed according to the terms
+# of the GNU General Public License, incorporated herein by reference.
+
+from mercurial.demandload import *
+from mercurial.i18n import gettext as _
+demandload(globals(), 'os tempfile')
+demandload(globals(), 'mercurial:bundlerepo,cmdutil,commands,hg,merge,patch')
+demandload(globals(), 'mercurial:revlog,util')
+
+'''patch transplanting tool
+
+This extension allows you to transplant patches from another branch.
+
+Transplanted patches are recorded in .hg/transplant/transplants, as a map
+from a changeset hash to its hash in the source repository.
+'''
+
+class transplantentry:
+    def __init__(self, lnode, rnode):
+        self.lnode = lnode
+        self.rnode = rnode
+
+class transplants:
+    def __init__(self, path=None, transplantfile=None, opener=None):
+        self.path = path
+        self.transplantfile = transplantfile
+        self.opener = opener
+
+        if not opener:
+            self.opener = util.opener(self.path)
+        self.transplants = []
+        self.dirty = False
+        self.read()
+
+    def read(self):
+        abspath = os.path.join(self.path, self.transplantfile)
+        if self.transplantfile and os.path.exists(abspath):
+            for line in self.opener(self.transplantfile).read().splitlines():
+                lnode, rnode = map(revlog.bin, line.split(':'))
+                self.transplants.append(transplantentry(lnode, rnode))
+
+    def write(self):
+        if self.dirty and self.transplantfile:
+            if not os.path.isdir(self.path):
+                os.mkdir(self.path)
+            fp = self.opener(self.transplantfile, 'w')
+            for c in self.transplants:
+                l, r = map(revlog.hex, (c.lnode, c.rnode))
+                fp.write(l + ':' + r + '\n')
+            fp.close()
+        self.dirty = False
+
+    def get(self, rnode):
+        return [t for t in self.transplants if t.rnode == rnode]
+
+    def set(self, lnode, rnode):
+        self.transplants.append(transplantentry(lnode, rnode))
+        self.dirty = True
+
+    def remove(self, transplant):
+        del self.transplants[self.transplants.index(transplant)]
+        self.dirty = True
+
+class transplanter:
+    def __init__(self, ui, repo):
+        self.ui = ui
+        self.path = repo.join('transplant')
+        self.opener = util.opener(self.path)
+        self.transplants = transplants(self.path, 'transplants', opener=self.opener)
+
+    def applied(self, repo, node, parent):
+        '''returns True if a node is already an ancestor of parent
+        or has already been transplanted'''
+        if hasnode(repo, node):
+            if node in repo.changelog.reachable(parent, stop=node):
+                return True
+        for t in self.transplants.get(node):
+            # it might have been stripped
+            if not hasnode(repo, t.lnode):
+                self.transplants.remove(t)
+                return False
+            if t.lnode in repo.changelog.reachable(parent, stop=t.lnode):
+                return True
+        return False
+
+    def apply(self, repo, source, revmap, merges, opts={}):
+        '''apply the revisions in revmap one by one in revision order'''
+        revs = revmap.keys()
+        revs.sort()
+
+        p1, p2 = repo.dirstate.parents()
+        pulls = []
+        diffopts = patch.diffopts(self.ui, opts)
+        diffopts.git = True
+
+        lock = repo.lock()
+        wlock = repo.wlock()
+        try:
+            for rev in revs:
+                node = revmap[rev]
+                revstr = '%s:%s' % (rev, revlog.short(node))
+
+                if self.applied(repo, node, p1):
+                    self.ui.warn(_('skipping already applied revision %s\n') %
+                                 revstr)
+                    continue
+
+                parents = source.changelog.parents(node)
+                if not opts.get('filter'):
+                    # If the changeset parent is the same as the wdir's parent,
+                    # just pull it.
+                    if parents[0] == p1:
+                        pulls.append(node)
+                        p1 = node
+                        continue
+                    if pulls:
+                        if source != repo:
+                            repo.pull(source, heads=pulls, lock=lock)
+                        merge.update(repo, pulls[-1], wlock=wlock)
+                        p1, p2 = repo.dirstate.parents()
+                        pulls = []
+
+                domerge = False
+                if node in merges:
+                    # pulling all the merge revs at once would mean we couldn't
+                    # transplant after the latest even if transplants before them
+                    # fail.
+                    domerge = True
+                    if not hasnode(repo, node):
+                        repo.pull(source, heads=[node], lock=lock)
+
+                if parents[1] != revlog.nullid:
+                    self.ui.note(_('skipping merge changeset %s:%s\n')
+                                 % (rev, revlog.short(node)))
+                    patchfile = None
+                else:
+                    fd, patchfile = tempfile.mkstemp(prefix='hg-transplant-')
+                    fp = os.fdopen(fd, 'w')
+                    patch.export(source, [node], fp=fp, opts=diffopts)
+                    fp.close()
+
+                del revmap[rev]
+                if patchfile or domerge:
+                    try:
+                        n = self.applyone(repo, node, source.changelog.read(node),
+                                          patchfile, merge=domerge,
+                                          log=opts.get('log'),
+                                          filter=opts.get('filter'),
+                                          lock=lock, wlock=wlock)
+                        if domerge:
+                            self.ui.status(_('%s merged at %s\n') % (revstr,
+                                      revlog.short(n)))
+                        else:
+                            self.ui.status(_('%s transplanted to %s\n') % (revlog.short(node),
+                                                                       revlog.short(n)))
+                    finally:
+                        if patchfile:
+                            os.unlink(patchfile)
+            if pulls:
+                repo.pull(source, heads=pulls, lock=lock)
+                merge.update(repo, pulls[-1], wlock=wlock)
+        finally:
+            self.saveseries(revmap, merges)
+            self.transplants.write()
+
+    def filter(self, filter, changelog, patchfile):
+        '''arbitrarily rewrite changeset before applying it'''
+
+        self.ui.status('filtering %s' % patchfile)
+        util.system('%s %s' % (filter, util.shellquote(patchfile)),
+                    environ={'HGUSER': changelog[1]},
+                    onerr=util.Abort, errprefix=_('filter failed'))
+
+    def applyone(self, repo, node, cl, patchfile, merge=False, log=False,
+                 filter=None, lock=None, wlock=None):
+        '''apply the patch in patchfile to the repository as a transplant'''
+        (manifest, user, (time, timezone), files, message) = cl[:5]
+        date = "%d %d" % (time, timezone)
+        extra = {'transplant_source': node}
+        if filter:
+            self.filter(filter, cl, patchfile)
+            patchfile, message, user, date = patch.extract(self.ui, file(patchfile))
+
+        if log:
+            message += '\n(transplanted from %s)' % revlog.hex(node)
+            cl = list(cl)
+            cl[4] = message
+
+        self.ui.status(_('applying %s\n') % revlog.short(node))
+        self.ui.note('%s %s\n%s\n' % (user, date, message))
+
+        if not patchfile and not merge:
+            raise util.Abort(_('can only omit patchfile if merging'))
+        if patchfile:
+            try:
+                files = {}
+                fuzz = patch.patch(patchfile, self.ui, cwd=repo.root,
+                                   files=files)
+                if not files:
+                    self.ui.warn(_('%s: empty changeset') % revlog.hex(node))
+                    return
+                files = patch.updatedir(self.ui, repo, files, wlock=wlock)
+                if filter:
+                    os.unlink(patchfile)
+            except Exception, inst:
+                if filter:
+                    os.unlink(patchfile)
+                p1 = repo.dirstate.parents()[0]
+                p2 = node
+                self.log(cl, p1, p2, merge=merge)
+                self.ui.write(str(inst) + '\n')
+                raise util.Abort(_('Fix up the merge and run hg transplant --continue'))
+        else:
+            files = None
+        if merge:
+            p1, p2 = repo.dirstate.parents()
+            repo.dirstate.setparents(p1, node)
+
+        n = repo.commit(files, message, user, date, lock=lock, wlock=wlock,
+                        extra=extra)
+        if not merge:
+            self.transplants.set(n, node)
+
+        return n
+
+    def resume(self, repo, source, opts=None):
+        '''recover last transaction and apply remaining changesets'''
+        if os.path.exists(os.path.join(self.path, 'journal')):
+            n, node = self.recover(repo)
+        self.ui.status(_('%s transplanted as %s\n') % (revlog.short(node),
+                                                       revlog.short(n)))
+        seriespath = os.path.join(self.path, 'series')
+        if not os.path.exists(seriespath):
+            return
+        nodes, merges = self.readseries()
+        revmap = {}
+        for n in nodes:
+            revmap[source.changelog.rev(n)] = n
+        os.unlink(seriespath)
+
+        self.apply(repo, source, revmap, merges, opts)
+
+    def recover(self, repo):
+        '''commit working directory using journal metadata'''
+        node, user, date, message, parents = self.readlog()
+        merge = len(parents) == 2
+
+        if not user or not date or not message or not parents[0]:
+            raise util.Abort(_('transplant log file is corrupt'))
+
+        wlock = repo.wlock()
+        p1, p2 = repo.dirstate.parents()
+        if p1 != parents[0]:
+            raise util.Abort(_('working dir not at transplant parent %s') %
+                             revlog.hex(parents[0]))
+        if merge:
+            repo.dirstate.setparents(p1, parents[1])
+        n = repo.commit(None, message, user, date, wlock=wlock)
+        if not n:
+            raise util.Abort(_('commit failed'))
+        if not merge:
+            self.transplants.set(n, node)
+        self.unlog()
+
+        return n, node
+
+    def readseries(self):
+        nodes = []
+        merges = []
+        cur = nodes
+        for line in self.opener('series').read().splitlines():
+            if line.startswith('# Merges'):
+                cur = merges
+                continue
+            cur.append(revlog.bin(line))
+
+        return (nodes, merges)
+
+    def saveseries(self, revmap, merges):
+        if not revmap:
+            return
+
+        if not os.path.isdir(self.path):
+            os.mkdir(self.path)
+        series = self.opener('series', 'w')
+        revs = revmap.keys()
+        revs.sort()
+        for rev in revs:
+            series.write(revlog.hex(revmap[rev]) + '\n')
+        if merges:
+            series.write('# Merges\n')
+            for m in merges:
+                series.write(revlog.hex(m) + '\n')
+        series.close()
+
+    def log(self, changelog, p1, p2, merge=False):
+        '''journal changelog metadata for later recover'''
+
+        if not os.path.isdir(self.path):
+            os.mkdir(self.path)
+        fp = self.opener('journal', 'w')
+        fp.write('# User %s\n' % changelog[1])
+        fp.write('# Date %d %d\n' % changelog[2])
+        fp.write('# Node ID %s\n' % revlog.hex(p2))
+        fp.write('# Parent ' + revlog.hex(p1) + '\n')
+        if merge:
+            fp.write('# Parent ' + revlog.hex(p2) + '\n')
+        fp.write(changelog[4].rstrip() + '\n')
+        fp.close()
+
+    def readlog(self):
+        parents = []
+        message = []
+        for line in self.opener('journal').read().splitlines():
+            if line.startswith('# User '):
+                user = line[7:]
+            elif line.startswith('# Date '):
+                date = line[7:]
+            elif line.startswith('# Node ID '):
+                node = revlog.bin(line[10:])
+            elif line.startswith('# Parent '):
+                parents.append(revlog.bin(line[9:]))
+            else:
+                message.append(line)
+        return (node, user, date, '\n'.join(message), parents)
+
+    def unlog(self):
+        '''remove changelog journal'''
+        absdst = os.path.join(self.path, 'journal')
+        if os.path.exists(absdst):
+            os.unlink(absdst)
+
+    def transplantfilter(self, repo, source, root):
+        def matchfn(node):
+            if self.applied(repo, node, root):
+                return False
+            if source.changelog.parents(node)[1] != revlog.nullid:
+                return False
+            extra = source.changelog.read(node)[5]
+            cnode = extra.get('transplant_source')
+            if cnode and self.applied(repo, cnode, root):
+                return False
+            return True
+
+        return matchfn
+
+def hasnode(repo, node):
+    try:
+        return repo.changelog.rev(node) != None
+    except revlog.RevlogError:
+        return False
+
+def browserevs(ui, repo, nodes, opts):
+    '''interactively transplant changesets'''
+    def browsehelp(ui):
+        ui.write('y: transplant this changeset\n'
+                 'n: skip this changeset\n'
+                 'm: merge at this changeset\n'
+                 'p: show patch\n'
+                 'c: commit selected changesets\n'
+                 'q: cancel transplant\n'
+                 '?: show this help\n')
+
+    displayer = commands.show_changeset(ui, repo, opts)
+    transplants = []
+    merges = []
+    for node in nodes:
+        displayer.show(changenode=node)
+        action = None
+        while not action:
+            action = ui.prompt(_('apply changeset? [ynmpcq?]:'))
+            if action == '?':
+                browsehelp(ui)
+                action = None
+            elif action == 'p':
+                parent = repo.changelog.parents(node)[0]
+                patch.diff(repo, parent, node)
+                action = None
+            elif action not in ('y', 'n', 'm', 'c', 'q'):
+                ui.write('no such option\n')
+                action = None
+        if action == 'y':
+            transplants.append(node)
+        elif action == 'm':
+            merges.append(node)
+        elif action == 'c':
+            break
+        elif action == 'q':
+            transplants = ()
+            merges = ()
+            break
+    return (transplants, merges)
+
+def transplant(ui, repo, *revs, **opts):
+    '''transplant changesets from another branch
+
+    Selected changesets will be applied on top of the current working
+    directory with the log of the original changeset. If --log is
+    specified, log messages will have a comment appended of the form:
+
+    (transplanted from CHANGESETHASH)
+
+    You can rewrite the changelog message with the --filter option.
+    Its argument will be invoked with the current changelog message
+    as $1 and the patch as $2.
+
+    If --source is specified, selects changesets from the named
+    repository. If --branch is specified, selects changesets from the
+    branch holding the named revision, up to that revision. If --all
+    is specified, all changesets on the branch will be transplanted,
+    otherwise you will be prompted to select the changesets you want.
+
+    hg transplant --branch REVISION --all will rebase the selected branch
+    (up to the named revision) onto your current working directory.
+
+    You can optionally mark selected transplanted changesets as
+    merge changesets. You will not be prompted to transplant any
+    ancestors of a merged transplant, and you can merge descendants
+    of them normally instead of transplanting them.
+
+    If no merges or revisions are provided, hg transplant will start
+    an interactive changeset browser.
+
+    If a changeset application fails, you can fix the merge by hand and
+    then resume where you left off by calling hg transplant --continue.
+    '''
+    def getoneitem(opts, item, errmsg):
+        val = opts.get(item)
+        if val:
+            if len(val) > 1:
+                raise util.Abort(errmsg)
+            else:
+                return val[0]
+
+    def getremotechanges(repo, url):
+        sourcerepo = ui.expandpath(url)
+        source = hg.repository(ui, sourcerepo)
+        incoming = repo.findincoming(source, force=True)
+        if not incoming:
+            return (source, None, None)
+
+        bundle = None
+        if not source.local():
+            cg = source.changegroup(incoming, 'incoming')
+            bundle = commands.write_bundle(cg, compress=False)
+            source = bundlerepo.bundlerepository(ui, repo.root, bundle)
+
+        return (source, incoming, bundle)
+
+    def incwalk(repo, incoming, branches, match=util.always):
+        if not branches:
+            branches=None
+        for node in repo.changelog.nodesbetween(incoming, branches)[0]:
+            if match(node):
+                yield node
+
+    def transplantwalk(repo, root, branches, match=util.always):
+        if not branches:
+            branches = repo.heads()
+        ancestors = []
+        for branch in branches:
+            ancestors.append(repo.changelog.ancestor(root, branch))
+        for node in repo.changelog.nodesbetween(ancestors, branches)[0]:
+            if match(node):
+                yield node
+
+    def checkopts(opts, revs):
+        if opts.get('continue'):
+            if filter(lambda opt: opts.get(opt), ('branch', 'all', 'merge')):
+                raise util.Abort(_('--continue is incompatible with branch, all or merge'))
+            return
+        if not (opts.get('source') or revs or
+                opts.get('merge') or opts.get('branch')):
+            raise util.Abort(_('no source URL, branch tag or revision list provided'))
+        if opts.get('all'):
+            if not opts.get('branch'):
+                raise util.Abort(_('--all requires a branch revision'))
+            if revs:
+                raise util.Abort(_('--all is incompatible with a revision list'))
+
+    checkopts(opts, revs)
+
+    if not opts.get('log'):
+        opts['log'] = ui.config('transplant', 'log')
+    if not opts.get('filter'):
+        opts['filter'] = ui.config('transplant', 'filter')
+
+    tp = transplanter(ui, repo)
+
+    p1, p2 = repo.dirstate.parents()
+    if p1 == revlog.nullid:
+        raise util.Abort(_('no revision checked out'))
+    if not opts.get('continue'):
+        if p2 != revlog.nullid:
+            raise util.Abort(_('outstanding uncommitted merges'))
+        m, a, r, d = repo.status()[:4]
+        if m or a or r or d:
+            raise util.Abort(_('outstanding local changes'))
+
+    bundle = None
+    source = opts.get('source')
+    if source:
+        (source, incoming, bundle) = getremotechanges(repo, source)
+    else:
+        source = repo
+
+    try:
+        if opts.get('continue'):
+            n, node = tp.resume(repo, source, opts)
+            return
+
+        tf=tp.transplantfilter(repo, source, p1)
+        if opts.get('prune'):
+            prune = [source.lookup(r)
+                     for r in cmdutil.revrange(source, opts.get('prune'))]
+            matchfn = lambda x: tf(x) and x not in prune
+        else:
+            matchfn = tf
+        branches = map(source.lookup, opts.get('branch', ()))
+        merges = map(source.lookup, opts.get('merge', ()))
+        revmap = {}
+        if revs:
+            for r in cmdutil.revrange(source, revs):
+                revmap[int(r)] = source.lookup(r)
+        elif opts.get('all') or not merges:
+            if source != repo:
+                alltransplants = incwalk(source, incoming, branches, match=matchfn)
+            else:
+                alltransplants = transplantwalk(source, p1, branches, match=matchfn)
+            if opts.get('all'):
+                revs = alltransplants
+            else:
+                revs, newmerges = browserevs(ui, source, alltransplants, opts)
+                merges.extend(newmerges)
+            for r in revs:
+                revmap[source.changelog.rev(r)] = r
+        for r in merges:
+            revmap[source.changelog.rev(r)] = r
+
+        revs = revmap.keys()
+        revs.sort()
+        pulls = []
+
+        tp.apply(repo, source, revmap, merges, opts)
+    finally:
+        if bundle:
+            os.unlink(bundle)
+
+cmdtable = {
+    "transplant":
+        (transplant,
+         [('s', 'source', '', _('pull patches from REPOSITORY')),
+          ('b', 'branch', [], _('pull patches from branch BRANCH')),
+          ('a', 'all', None, _('pull all changesets up to BRANCH')),
+          ('p', 'prune', [], _('skip over REV')),
+          ('m', 'merge', [], _('merge at REV')),
+          ('', 'log', None, _('append transplant info to log message')),
+          ('c', 'continue', None, _('continue last transplant session after repair')),
+          ('', 'filter', '', _('filter changesets through FILTER'))],
+         _('hg transplant [-s REPOSITORY] [-b BRANCH] [-p REV] [-m REV] [-n] REV...'))
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test-transplant	Mon Nov 27 15:13:01 2006 -0800
@@ -0,0 +1,60 @@
+#!/bin/sh
+
+cat <<EOF >> $HGRCPATH
+[extensions]
+transplant=
+EOF
+
+hg init t
+cd t
+echo r1 > r1
+hg ci -Amr1 -d'0 0'
+echo r2 > r2
+hg ci -Amr2 -d'1 0'
+hg up 0
+
+echo b1 > b1
+hg ci -Amb1 -d '0 0'
+echo b2 > b2
+hg ci -Amb2 -d '1 0'
+echo b3 > b3
+hg ci -Amb3 -d '2 0'
+
+hg log --template '{rev} {parents} {desc}\n'
+
+cd ..
+hg clone t rebase
+cd rebase
+
+hg up -C 1
+echo '% rebase b onto r1'
+hg transplant -a -b tip
+hg log --template '{rev} {parents} {desc}\n'
+
+cd ..
+hg clone t prune
+cd prune
+
+hg up -C 1
+echo '% rebase b onto r1, skipping b2'
+hg transplant -a -b tip -p 3
+hg log --template '{rev} {parents} {desc}\n'
+
+cd ..
+echo '% remote transplant'
+hg clone -r 1 t remote
+cd remote
+hg transplant --log -s ../t 2 4
+hg log --template '{rev} {parents} {desc}\n'
+
+echo '% skip previous transplants'
+hg transplant -s ../t -a -b 4
+hg log --template '{rev} {parents} {desc}\n'
+
+echo '% skip local changes transplanted to the source'
+echo b4 > b4
+hg ci -Amb4 -d '3 0'
+cd ..
+hg clone t pullback
+cd pullback
+hg transplant -s ../remote -a -b tip
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test-transplant.out	Mon Nov 27 15:13:01 2006 -0800
@@ -0,0 +1,77 @@
+adding r1
+adding r2
+0 files updated, 0 files merged, 1 files removed, 0 files unresolved
+adding b1
+adding b2
+adding b3
+4  b3
+3  b2
+2 0:17ab29e464c6  b1
+1  r2
+0  r1
+4 files updated, 0 files merged, 0 files removed, 0 files unresolved
+1 files updated, 0 files merged, 3 files removed, 0 files unresolved
+% rebase b onto r1
+applying 37a1297eb21b
+37a1297eb21b transplanted to e234d668f844
+applying 722f4667af76
+722f4667af76 transplanted to 539f377d78df
+applying a53251cdf717
+a53251cdf717 transplanted to ffd6818a3975
+7  b3
+6  b2
+5 1:d11e3596cc1a  b1
+4  b3
+3  b2
+2 0:17ab29e464c6  b1
+1  r2
+0  r1
+4 files updated, 0 files merged, 0 files removed, 0 files unresolved
+1 files updated, 0 files merged, 3 files removed, 0 files unresolved
+% rebase b onto r1, skipping b2
+applying 37a1297eb21b
+37a1297eb21b transplanted to e234d668f844
+applying a53251cdf717
+a53251cdf717 transplanted to 7275fda4d04f
+6  b3
+5 1:d11e3596cc1a  b1
+4  b3
+3  b2
+2 0:17ab29e464c6  b1
+1  r2
+0  r1
+% remote transplant
+requesting all changes
+adding changesets
+adding manifests
+adding file changes
+added 2 changesets with 2 changes to 2 files
+2 files updated, 0 files merged, 0 files removed, 0 files unresolved
+searching for changes
+applying 37a1297eb21b
+37a1297eb21b transplanted to c19cf0ccb069
+applying a53251cdf717
+a53251cdf717 transplanted to f7fe5bf98525
+3  b3
+(transplanted from a53251cdf717679d1907b289f991534be05c997a)
+2  b1
+(transplanted from 37a1297eb21b3ef5c5d2ffac22121a0988ed9f21)
+1  r2
+0  r1
+% skip previous transplants
+searching for changes
+applying 722f4667af76
+722f4667af76 transplanted to 47156cd86c0b
+4  b2
+3  b3
+(transplanted from a53251cdf717679d1907b289f991534be05c997a)
+2  b1
+(transplanted from 37a1297eb21b3ef5c5d2ffac22121a0988ed9f21)
+1  r2
+0  r1
+% skip local changes transplanted to the source
+adding b4
+4 files updated, 0 files merged, 0 files removed, 0 files unresolved
+searching for changes
+applying 4333daefcb15
+4333daefcb15 transplanted to 5f42c04e07cc