1 # Patch transplanting extension for Mercurial
3 # Copyright 2006, 2007 Brendan Cully <brendan@kublai.com>
5 # This software may be used and distributed according to the terms of the
6 # GNU General Public License version 2, incorporated herein by reference.
8 '''command to transplant changesets from another branch
10 This extension allows you to transplant patches from another branch.
12 Transplanted patches are recorded in .hg/transplant/transplants, as a
13 map from a changeset hash to its hash in the source repository.
16 from mercurial.i18n import _
18 from mercurial import bundlerepo, changegroup, cmdutil, hg, merge, match
19 from mercurial import patch, revlog, util, error
21 class transplantentry(object):
22 def __init__(self, lnode, rnode):
26 class transplants(object):
27 def __init__(self, path=None, transplantfile=None, opener=None):
29 self.transplantfile = transplantfile
33 self.opener = util.opener(self.path)
39 abspath = os.path.join(self.path, self.transplantfile)
40 if self.transplantfile and os.path.exists(abspath):
41 for line in self.opener(self.transplantfile).read().splitlines():
42 lnode, rnode = map(revlog.bin, line.split(':'))
43 self.transplants.append(transplantentry(lnode, rnode))
46 if self.dirty and self.transplantfile:
47 if not os.path.isdir(self.path):
49 fp = self.opener(self.transplantfile, 'w')
50 for c in self.transplants:
51 l, r = map(revlog.hex, (c.lnode, c.rnode))
52 fp.write(l + ':' + r + '\n')
57 return [t for t in self.transplants if t.rnode == rnode]
59 def set(self, lnode, rnode):
60 self.transplants.append(transplantentry(lnode, rnode))
63 def remove(self, transplant):
64 del self.transplants[self.transplants.index(transplant)]
67 class transplanter(object):
68 def __init__(self, ui, repo):
70 self.path = repo.join('transplant')
71 self.opener = util.opener(self.path)
72 self.transplants = transplants(self.path, 'transplants',
75 def applied(self, repo, node, parent):
76 '''returns True if a node is already an ancestor of parent
77 or has already been transplanted'''
78 if hasnode(repo, node):
79 if node in repo.changelog.reachable(parent, stop=node):
81 for t in self.transplants.get(node):
82 # it might have been stripped
83 if not hasnode(repo, t.lnode):
84 self.transplants.remove(t)
86 if t.lnode in repo.changelog.reachable(parent, stop=t.lnode):
90 def apply(self, repo, source, revmap, merges, opts={}):
91 '''apply the revisions in revmap one by one in revision order'''
93 p1, p2 = repo.dirstate.parents()
95 diffopts = patch.diffopts(self.ui, opts)
104 revstr = '%s:%s' % (rev, revlog.short(node))
106 if self.applied(repo, node, p1):
107 self.ui.warn(_('skipping already applied revision %s\n') %
111 parents = source.changelog.parents(node)
112 if not opts.get('filter'):
113 # If the changeset parent is the same as the
114 # wdir's parent, just pull it.
121 repo.pull(source, heads=pulls)
122 merge.update(repo, pulls[-1], False, False, None)
123 p1, p2 = repo.dirstate.parents()
128 # pulling all the merge revs at once would mean we
129 # couldn't transplant after the latest even if
130 # transplants before them fail.
132 if not hasnode(repo, node):
133 repo.pull(source, heads=[node])
135 if parents[1] != revlog.nullid:
136 self.ui.note(_('skipping merge changeset %s:%s\n')
137 % (rev, revlog.short(node)))
140 fd, patchfile = tempfile.mkstemp(prefix='hg-transplant-')
141 fp = os.fdopen(fd, 'w')
142 gen = patch.diff(source, parents[0], node, opts=diffopts)
148 if patchfile or domerge:
150 n = self.applyone(repo, node,
151 source.changelog.read(node),
152 patchfile, merge=domerge,
154 filter=opts.get('filter'))
156 self.ui.status(_('%s merged at %s\n') % (revstr,
159 self.ui.status(_('%s transplanted to %s\n')
160 % (revlog.short(node),
166 repo.pull(source, heads=pulls)
167 merge.update(repo, pulls[-1], False, False, None)
169 self.saveseries(revmap, merges)
170 self.transplants.write()
174 def filter(self, filter, changelog, patchfile):
175 '''arbitrarily rewrite changeset before applying it'''
177 self.ui.status(_('filtering %s\n') % patchfile)
178 user, date, msg = (changelog[1], changelog[2], changelog[4])
180 fd, headerfile = tempfile.mkstemp(prefix='hg-transplant-')
181 fp = os.fdopen(fd, 'w')
182 fp.write("# HG changeset patch\n")
183 fp.write("# User %s\n" % user)
184 fp.write("# Date %d %d\n" % date)
185 fp.write(changelog[4])
189 util.system('%s %s %s' % (filter, util.shellquote(headerfile),
190 util.shellquote(patchfile)),
191 environ={'HGUSER': changelog[1]},
192 onerr=util.Abort, errprefix=_('filter failed'))
193 user, date, msg = self.parselog(file(headerfile))[1:4]
195 os.unlink(headerfile)
197 return (user, date, msg)
199 def applyone(self, repo, node, cl, patchfile, merge=False, log=False,
201 '''apply the patch in patchfile to the repository as a transplant'''
202 (manifest, user, (time, timezone), files, message) = cl[:5]
203 date = "%d %d" % (time, timezone)
204 extra = {'transplant_source': node}
206 (user, date, message) = self.filter(filter, cl, patchfile)
209 # we don't translate messages inserted into commits
210 message += '\n(transplanted from %s)' % revlog.hex(node)
212 self.ui.status(_('applying %s\n') % revlog.short(node))
213 self.ui.note('%s %s\n%s\n' % (user, date, message))
215 if not patchfile and not merge:
216 raise util.Abort(_('can only omit patchfile if merging'))
221 patch.patch(patchfile, self.ui, cwd=repo.root,
222 files=files, eolmode=None)
224 self.ui.warn(_('%s: empty changeset')
228 files = patch.updatedir(self.ui, repo, files)
229 except Exception, inst:
232 seriespath = os.path.join(self.path, 'series')
233 if os.path.exists(seriespath):
234 os.unlink(seriespath)
235 p1 = repo.dirstate.parents()[0]
237 self.log(user, date, message, p1, p2, merge=merge)
238 self.ui.write(str(inst) + '\n')
239 raise util.Abort(_('Fix up the merge and run '
240 'hg transplant --continue'))
244 p1, p2 = repo.dirstate.parents()
245 repo.dirstate.setparents(p1, node)
246 m = match.always(repo.root, '')
248 m = match.exact(repo.root, '', files)
250 n = repo.commit(message, user, date, extra=extra, match=m)
252 self.transplants.set(n, node)
256 def resume(self, repo, source, opts=None):
257 '''recover last transaction and apply remaining changesets'''
258 if os.path.exists(os.path.join(self.path, 'journal')):
259 n, node = self.recover(repo)
260 self.ui.status(_('%s transplanted as %s\n') % (revlog.short(node),
262 seriespath = os.path.join(self.path, 'series')
263 if not os.path.exists(seriespath):
264 self.transplants.write()
266 nodes, merges = self.readseries()
269 revmap[source.changelog.rev(n)] = n
270 os.unlink(seriespath)
272 self.apply(repo, source, revmap, merges, opts)
274 def recover(self, repo):
275 '''commit working directory using journal metadata'''
276 node, user, date, message, parents = self.readlog()
277 merge = len(parents) == 2
279 if not user or not date or not message or not parents[0]:
280 raise util.Abort(_('transplant log file is corrupt'))
282 extra = {'transplant_source': node}
285 p1, p2 = repo.dirstate.parents()
288 _('working dir not at transplant parent %s') %
289 revlog.hex(parents[0]))
291 repo.dirstate.setparents(p1, parents[1])
292 n = repo.commit(message, user, date, extra=extra)
294 raise util.Abort(_('commit failed'))
296 self.transplants.set(n, node)
303 def readseries(self):
307 for line in self.opener('series').read().splitlines():
308 if line.startswith('# Merges'):
311 cur.append(revlog.bin(line))
313 return (nodes, merges)
315 def saveseries(self, revmap, merges):
319 if not os.path.isdir(self.path):
321 series = self.opener('series', 'w')
322 for rev in sorted(revmap):
323 series.write(revlog.hex(revmap[rev]) + '\n')
325 series.write('# Merges\n')
327 series.write(revlog.hex(m) + '\n')
330 def parselog(self, fp):
335 for line in fp.read().splitlines():
338 elif line.startswith('# User '):
340 elif line.startswith('# Date '):
342 elif line.startswith('# Node ID '):
343 node = revlog.bin(line[10:])
344 elif line.startswith('# Parent '):
345 parents.append(revlog.bin(line[9:]))
346 elif not line.startswith('#'):
349 return (node, user, date, '\n'.join(message), parents)
351 def log(self, user, date, message, p1, p2, merge=False):
352 '''journal changelog metadata for later recover'''
354 if not os.path.isdir(self.path):
356 fp = self.opener('journal', 'w')
357 fp.write('# User %s\n' % user)
358 fp.write('# Date %s\n' % date)
359 fp.write('# Node ID %s\n' % revlog.hex(p2))
360 fp.write('# Parent ' + revlog.hex(p1) + '\n')
362 fp.write('# Parent ' + revlog.hex(p2) + '\n')
363 fp.write(message.rstrip() + '\n')
367 return self.parselog(self.opener('journal'))
370 '''remove changelog journal'''
371 absdst = os.path.join(self.path, 'journal')
372 if os.path.exists(absdst):
375 def transplantfilter(self, repo, source, root):
377 if self.applied(repo, node, root):
379 if source.changelog.parents(node)[1] != revlog.nullid:
381 extra = source.changelog.read(node)[5]
382 cnode = extra.get('transplant_source')
383 if cnode and self.applied(repo, cnode, root):
389 def hasnode(repo, node):
391 return repo.changelog.rev(node) != None
392 except error.RevlogError:
395 def browserevs(ui, repo, nodes, opts):
396 '''interactively transplant changesets'''
398 ui.write('y: transplant this changeset\n'
399 'n: skip this changeset\n'
400 'm: merge at this changeset\n'
402 'c: commit selected changesets\n'
403 'q: cancel transplant\n'
404 '?: show this help\n')
406 displayer = cmdutil.show_changeset(ui, repo, opts)
410 displayer.show(repo[node])
413 action = ui.prompt(_('apply changeset? [ynmpcq?]:'))
418 parent = repo.changelog.parents(node)[0]
419 for chunk in patch.diff(repo, parent, node):
422 elif action not in ('y', 'n', 'm', 'c', 'q'):
423 ui.write('no such option\n')
426 transplants.append(node)
435 return (transplants, merges)
437 def transplant(ui, repo, *revs, **opts):
438 '''transplant changesets from another branch
440 Selected changesets will be applied on top of the current working
441 directory with the log of the original changeset. If --log is
442 specified, log messages will have a comment appended of the form::
444 (transplanted from CHANGESETHASH)
446 You can rewrite the changelog message with the --filter option.
447 Its argument will be invoked with the current changelog message as
448 $1 and the patch as $2.
450 If --source/-s is specified, selects changesets from the named
451 repository. If --branch/-b is specified, selects changesets from
452 the branch holding the named revision, up to that revision. If
453 --all/-a is specified, all changesets on the branch will be
454 transplanted, otherwise you will be prompted to select the
457 hg transplant --branch REVISION --all will rebase the selected
458 branch (up to the named revision) onto your current working
461 You can optionally mark selected transplanted changesets as merge
462 changesets. You will not be prompted to transplant any ancestors
463 of a merged transplant, and you can merge descendants of them
464 normally instead of transplanting them.
466 If no merges or revisions are provided, hg transplant will start
467 an interactive changeset browser.
469 If a changeset application fails, you can fix the merge by hand
470 and then resume where you left off by calling hg transplant
473 def getremotechanges(repo, url):
474 sourcerepo = ui.expandpath(url)
475 source = hg.repository(ui, sourcerepo)
476 common, incoming, rheads = repo.findcommonincoming(source, force=True)
478 return (source, None, None)
481 if not source.local():
482 if source.capable('changegroupsubset'):
483 cg = source.changegroupsubset(incoming, rheads, 'incoming')
485 cg = source.changegroup(incoming, 'incoming')
486 bundle = changegroup.writebundle(cg, None, 'HG10UN')
487 source = bundlerepo.bundlerepository(ui, repo.root, bundle)
489 return (source, incoming, bundle)
491 def incwalk(repo, incoming, branches, match=util.always):
494 for node in repo.changelog.nodesbetween(incoming, branches)[0]:
498 def transplantwalk(repo, root, branches, match=util.always):
500 branches = repo.heads()
502 for branch in branches:
503 ancestors.append(repo.changelog.ancestor(root, branch))
504 for node in repo.changelog.nodesbetween(ancestors, branches)[0]:
508 def checkopts(opts, revs):
509 if opts.get('continue'):
510 if filter(lambda opt: opts.get(opt), ('branch', 'all', 'merge')):
511 raise util.Abort(_('--continue is incompatible with '
512 'branch, all or merge'))
514 if not (opts.get('source') or revs or
515 opts.get('merge') or opts.get('branch')):
516 raise util.Abort(_('no source URL, branch tag or revision '
519 if not opts.get('branch'):
520 raise util.Abort(_('--all requires a branch revision'))
522 raise util.Abort(_('--all is incompatible with a '
525 checkopts(opts, revs)
527 if not opts.get('log'):
528 opts['log'] = ui.config('transplant', 'log')
529 if not opts.get('filter'):
530 opts['filter'] = ui.config('transplant', 'filter')
532 tp = transplanter(ui, repo)
534 p1, p2 = repo.dirstate.parents()
535 if len(repo) > 0 and p1 == revlog.nullid:
536 raise util.Abort(_('no revision checked out'))
537 if not opts.get('continue'):
538 if p2 != revlog.nullid:
539 raise util.Abort(_('outstanding uncommitted merges'))
540 m, a, r, d = repo.status()[:4]
542 raise util.Abort(_('outstanding local changes'))
545 source = opts.get('source')
547 (source, incoming, bundle) = getremotechanges(repo, source)
552 if opts.get('continue'):
553 tp.resume(repo, source, opts)
556 tf=tp.transplantfilter(repo, source, p1)
557 if opts.get('prune'):
558 prune = [source.lookup(r)
559 for r in cmdutil.revrange(source, opts.get('prune'))]
560 matchfn = lambda x: tf(x) and x not in prune
563 branches = map(source.lookup, opts.get('branch', ()))
564 merges = map(source.lookup, opts.get('merge', ()))
567 for r in cmdutil.revrange(source, revs):
568 revmap[int(r)] = source.lookup(r)
569 elif opts.get('all') or not merges:
571 alltransplants = incwalk(source, incoming, branches,
574 alltransplants = transplantwalk(source, p1, branches,
577 revs = alltransplants
579 revs, newmerges = browserevs(ui, source, alltransplants, opts)
580 merges.extend(newmerges)
582 revmap[source.changelog.rev(r)] = r
584 revmap[source.changelog.rev(r)] = r
586 tp.apply(repo, source, revmap, merges, opts)
595 [('s', 'source', '', _('pull patches from REPOSITORY')),
596 ('b', 'branch', [], _('pull patches from branch BRANCH')),
597 ('a', 'all', None, _('pull all changesets up to BRANCH')),
598 ('p', 'prune', [], _('skip over REV')),
599 ('m', 'merge', [], _('merge at REV')),
600 ('', 'log', None, _('append transplant info to log message')),
601 ('c', 'continue', None, _('continue last transplant session '
603 ('', 'filter', '', _('filter changesets through FILTER'))],
604 _('hg transplant [-s REPOSITORY] [-b BRANCH [-a]] [-p REV] '
605 '[-m REV] [REV]...'))