diff options
Diffstat (limited to 'hgext/transplant.py')
-rw-r--r-- | hgext/transplant.py | 676 |
1 files changed, 676 insertions, 0 deletions
diff --git a/hgext/transplant.py b/hgext/transplant.py new file mode 100644 index 0000000..a506c0c --- /dev/null +++ b/hgext/transplant.py @@ -0,0 +1,676 @@ +# Patch transplanting extension for Mercurial +# +# Copyright 2006, 2007 Brendan Cully <brendan@kublai.com> +# +# This software may be used and distributed according to the terms of the +# GNU General Public License version 2 or any later version. + +'''command to transplant changesets from another branch + +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. +''' + +from mercurial.i18n import _ +import os, tempfile +from mercurial.node import short +from mercurial import bundlerepo, hg, merge, match +from mercurial import patch, revlog, scmutil, util, error, cmdutil +from mercurial import revset, templatekw + +class TransplantError(error.Abort): + pass + +cmdtable = {} +command = cmdutil.command(cmdtable) +testedwith = 'internal' + +class transplantentry(object): + def __init__(self, lnode, rnode): + self.lnode = lnode + self.rnode = rnode + +class transplants(object): + def __init__(self, path=None, transplantfile=None, opener=None): + self.path = path + self.transplantfile = transplantfile + self.opener = opener + + if not opener: + self.opener = scmutil.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.read(self.transplantfile).splitlines(): + lnode, rnode = map(revlog.bin, line.split(':')) + list = self.transplants.setdefault(rnode, []) + list.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 list in self.transplants.itervalues(): + for t in list: + l, r = map(revlog.hex, (t.lnode, t.rnode)) + fp.write(l + ':' + r + '\n') + fp.close() + self.dirty = False + + def get(self, rnode): + return self.transplants.get(rnode) or [] + + def set(self, lnode, rnode): + list = self.transplants.setdefault(rnode, []) + list.append(transplantentry(lnode, rnode)) + self.dirty = True + + def remove(self, transplant): + list = self.transplants.get(transplant.rnode) + if list: + del list[list.index(transplant)] + self.dirty = True + +class transplanter(object): + def __init__(self, ui, repo): + self.ui = ui + self.path = repo.join('transplant') + self.opener = scmutil.opener(self.path) + self.transplants = transplants(self.path, 'transplants', + opener=self.opener) + self.editor = None + + def applied(self, repo, node, parent): + '''returns True if a node is already an ancestor of parent + or is parent or has already been transplanted''' + if hasnode(repo, parent): + parentrev = repo.changelog.rev(parent) + if hasnode(repo, node): + rev = repo.changelog.rev(node) + reachable = repo.changelog.incancestors([parentrev], rev) + if rev in reachable: + 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 + lnoderev = repo.changelog.rev(t.lnode) + if lnoderev in repo.changelog.incancestors([parentrev], lnoderev): + return True + return False + + def apply(self, repo, source, revmap, merges, opts={}): + '''apply the revisions in revmap one by one in revision order''' + revs = sorted(revmap) + p1, p2 = repo.dirstate.parents() + pulls = [] + diffopts = patch.diffopts(self.ui, opts) + diffopts.git = True + + lock = wlock = tr = None + try: + wlock = repo.wlock() + lock = repo.lock() + tr = repo.transaction('transplant') + for rev in revs: + node = revmap[rev] + revstr = '%s:%s' % (rev, 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') or opts.get('log')): + # 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.peer(), heads=pulls) + merge.update(repo, pulls[-1], False, False, None) + 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]) + + skipmerge = False + if parents[1] != revlog.nullid: + if not opts.get('parent'): + self.ui.note(_('skipping merge changeset %s:%s\n') + % (rev, short(node))) + skipmerge = True + else: + parent = source.lookup(opts['parent']) + if parent not in parents: + raise util.Abort(_('%s is not a parent of %s') % + (short(parent), short(node))) + else: + parent = parents[0] + + if skipmerge: + patchfile = None + else: + fd, patchfile = tempfile.mkstemp(prefix='hg-transplant-') + fp = os.fdopen(fd, 'w') + gen = patch.diff(source, parent, node, opts=diffopts) + for chunk in gen: + fp.write(chunk) + fp.close() + + del revmap[rev] + if patchfile or domerge: + try: + try: + n = self.applyone(repo, node, + source.changelog.read(node), + patchfile, merge=domerge, + log=opts.get('log'), + filter=opts.get('filter')) + except TransplantError: + # Do not rollback, it is up to the user to + # fix the merge or cancel everything + tr.close() + raise + if n and domerge: + self.ui.status(_('%s merged at %s\n') % (revstr, + short(n))) + elif n: + self.ui.status(_('%s transplanted to %s\n') + % (short(node), + short(n))) + finally: + if patchfile: + os.unlink(patchfile) + tr.close() + if pulls: + repo.pull(source.peer(), heads=pulls) + merge.update(repo, pulls[-1], False, False, None) + finally: + self.saveseries(revmap, merges) + self.transplants.write() + if tr: + tr.release() + lock.release() + wlock.release() + + def filter(self, filter, node, changelog, patchfile): + '''arbitrarily rewrite changeset before applying it''' + + self.ui.status(_('filtering %s\n') % patchfile) + user, date, msg = (changelog[1], changelog[2], changelog[4]) + fd, headerfile = tempfile.mkstemp(prefix='hg-transplant-') + fp = os.fdopen(fd, 'w') + fp.write("# HG changeset patch\n") + fp.write("# User %s\n" % user) + fp.write("# Date %d %d\n" % date) + fp.write(msg + '\n') + fp.close() + + try: + util.system('%s %s %s' % (filter, util.shellquote(headerfile), + util.shellquote(patchfile)), + environ={'HGUSER': changelog[1], + 'HGREVISION': revlog.hex(node), + }, + onerr=util.Abort, errprefix=_('filter failed'), + out=self.ui.fout) + user, date, msg = self.parselog(file(headerfile))[1:4] + finally: + os.unlink(headerfile) + + return (user, date, msg) + + def applyone(self, repo, node, cl, patchfile, merge=False, log=False, + filter=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: + (user, date, message) = self.filter(filter, node, cl, patchfile) + + if log: + # we don't translate messages inserted into commits + message += '\n(transplanted from %s)' % revlog.hex(node) + + self.ui.status(_('applying %s\n') % 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 = set() + patch.patch(self.ui, repo, patchfile, files=files, eolmode=None) + files = list(files) + except Exception, inst: + seriespath = os.path.join(self.path, 'series') + if os.path.exists(seriespath): + os.unlink(seriespath) + p1 = repo.dirstate.p1() + p2 = node + self.log(user, date, message, p1, p2, merge=merge) + self.ui.write(str(inst) + '\n') + raise TransplantError(_('fix up the merge and run ' + 'hg transplant --continue')) + else: + files = None + if merge: + p1, p2 = repo.dirstate.parents() + repo.setparents(p1, node) + m = match.always(repo.root, '') + else: + m = match.exact(repo.root, '', files) + + n = repo.commit(message, user, date, extra=extra, match=m, + editor=self.editor) + if not n: + self.ui.warn(_('skipping emptied changeset %s\n') % short(node)) + return None + 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') % (short(node), + short(n))) + seriespath = os.path.join(self.path, 'series') + if not os.path.exists(seriespath): + self.transplants.write() + 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 = False + + if not user or not date or not message or not parents[0]: + raise util.Abort(_('transplant log file is corrupt')) + + parent = parents[0] + if len(parents) > 1: + if opts.get('parent'): + parent = source.lookup(opts['parent']) + if parent not in parents: + raise util.Abort(_('%s is not a parent of %s') % + (short(parent), short(node))) + else: + merge = True + + extra = {'transplant_source': node} + wlock = repo.wlock() + try: + p1, p2 = repo.dirstate.parents() + if p1 != parent: + raise util.Abort( + _('working dir not at transplant parent %s') % + revlog.hex(parent)) + if merge: + repo.setparents(p1, parents[1]) + n = repo.commit(message, user, date, extra=extra, + editor=self.editor) + if not n: + raise util.Abort(_('commit failed')) + if not merge: + self.transplants.set(n, node) + self.unlog() + + return n, node + finally: + wlock.release() + + def readseries(self): + nodes = [] + merges = [] + cur = nodes + for line in self.opener.read('series').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') + for rev in sorted(revmap): + 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 parselog(self, fp): + parents = [] + message = [] + node = revlog.nullid + inmsg = False + user = None + date = None + for line in fp.read().splitlines(): + if inmsg: + message.append(line) + elif 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:])) + elif not line.startswith('# '): + inmsg = True + message.append(line) + if None in (user, date): + raise util.Abort(_("filter corrupted changeset (no user or date)")) + return (node, user, date, '\n'.join(message), parents) + + def log(self, user, date, message, 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' % user) + fp.write('# Date %s\n' % date) + 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(message.rstrip() + '\n') + fp.close() + + def readlog(self): + return self.parselog(self.opener('journal')) + + 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) is not None + except error.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 = cmdutil.show_changeset(ui, repo, opts) + transplants = [] + merges = [] + for node in nodes: + displayer.show(repo[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] + for chunk in patch.diff(repo, parent, node): + ui.write(chunk) + 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 + displayer.close() + return (transplants, merges) + +@command('transplant', + [('s', 'source', '', _('pull patches from REPO'), _('REPO')), + ('b', 'branch', [], + _('pull patches from branch BRANCH'), _('BRANCH')), + ('a', 'all', None, _('pull all changesets up to BRANCH')), + ('p', 'prune', [], _('skip over REV'), _('REV')), + ('m', 'merge', [], _('merge at REV'), _('REV')), + ('', 'parent', '', + _('parent to choose when transplanting merge'), _('REV')), + ('e', 'edit', False, _('invoke editor on commit messages')), + ('', 'log', None, _('append transplant info to log message')), + ('c', 'continue', None, _('continue last transplant session ' + 'after repair')), + ('', 'filter', '', + _('filter changesets through command'), _('CMD'))], + _('hg transplant [-s REPO] [-b BRANCH [-a]] [-p REV] ' + '[-m REV] [REV]...')) +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. The changesets + are copied and will thus appear twice in the history. Use the + rebase extension instead if you want to move a whole branch of + unpublished changesets. + + 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/-s is specified, selects changesets from the named + repository. If --branch/-b is specified, selects changesets from + the branch holding the named revision, up to that revision. If + --all/-a is specified, all changesets on the branch will be + transplanted, otherwise you will be prompted to select the + changesets you want. + + :hg:`transplant --branch REV --all` will transplant 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. + + Merge changesets may be transplanted directly by specifying the + proper parent changeset by calling :hg:`transplant --parent`. + + 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/-c`. + ''' + def incwalk(repo, csets, match=util.always): + for node in csets: + 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 opts.get('branch') or opts.get('all') or opts.get('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) + if opts.get('edit'): + tp.editor = cmdutil.commitforceeditor + + p1, p2 = repo.dirstate.parents() + if len(repo) > 0 and 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')) + + sourcerepo = opts.get('source') + if sourcerepo: + peer = hg.peer(ui, opts, ui.expandpath(sourcerepo)) + branches = map(peer.lookup, opts.get('branch', ())) + source, csets, cleanupfn = bundlerepo.getremotechanges(ui, repo, peer, + onlyheads=branches, force=True) + else: + source = repo + branches = map(source.lookup, opts.get('branch', ())) + cleanupfn = None + + try: + if opts.get('continue'): + tp.resume(repo, source, opts) + return + + tf = tp.transplantfilter(repo, source, p1) + if opts.get('prune'): + prune = [source.lookup(r) + for r in scmutil.revrange(source, opts.get('prune'))] + matchfn = lambda x: tf(x) and x not in prune + else: + matchfn = tf + merges = map(source.lookup, opts.get('merge', ())) + revmap = {} + if revs: + for r in scmutil.revrange(source, revs): + revmap[int(r)] = source.lookup(r) + elif opts.get('all') or not merges: + if source != repo: + alltransplants = incwalk(source, csets, 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 + + tp.apply(repo, source, revmap, merges, opts) + finally: + if cleanupfn: + cleanupfn() + +def revsettransplanted(repo, subset, x): + """``transplanted([set])`` + Transplanted changesets in set, or all transplanted changesets. + """ + if x: + s = revset.getset(repo, subset, x) + else: + s = subset + return [r for r in s if repo[r].extra().get('transplant_source')] + +def kwtransplanted(repo, ctx, **args): + """:transplanted: String. The node identifier of the transplanted + changeset if any.""" + n = ctx.extra().get('transplant_source') + return n and revlog.hex(n) or '' + +def extsetup(ui): + revset.symbols['transplanted'] = revsettransplanted + templatekw.keywords['transplanted'] = kwtransplanted + +# tell hggettext to extract docstrings from these functions: +i18nfunctions = [revsettransplanted, kwtransplanted] |