comparison hgext/transplant.py @ 3714:198173f3957c

Add transplant extension
author Brendan Cully <brendan@kublai.com>
date Mon, 27 Nov 2006 15:13:01 -0800
parents
children c828fca6f38a
comparison
equal deleted inserted replaced
3713:8ae88ed2a3b6 3714:198173f3957c
1 # Patch transplanting extension for Mercurial
2 #
3 # Copyright 2006 Brendan Cully <brendan@kublai.com>
4 #
5 # This software may be used and distributed according to the terms
6 # of the GNU General Public License, incorporated herein by reference.
7
8 from mercurial.demandload import *
9 from mercurial.i18n import gettext as _
10 demandload(globals(), 'os tempfile')
11 demandload(globals(), 'mercurial:bundlerepo,cmdutil,commands,hg,merge,patch')
12 demandload(globals(), 'mercurial:revlog,util')
13
14 '''patch transplanting tool
15
16 This extension allows you to transplant patches from another branch.
17
18 Transplanted patches are recorded in .hg/transplant/transplants, as a map
19 from a changeset hash to its hash in the source repository.
20 '''
21
22 class transplantentry:
23 def __init__(self, lnode, rnode):
24 self.lnode = lnode
25 self.rnode = rnode
26
27 class transplants:
28 def __init__(self, path=None, transplantfile=None, opener=None):
29 self.path = path
30 self.transplantfile = transplantfile
31 self.opener = opener
32
33 if not opener:
34 self.opener = util.opener(self.path)
35 self.transplants = []
36 self.dirty = False
37 self.read()
38
39 def read(self):
40 abspath = os.path.join(self.path, self.transplantfile)
41 if self.transplantfile and os.path.exists(abspath):
42 for line in self.opener(self.transplantfile).read().splitlines():
43 lnode, rnode = map(revlog.bin, line.split(':'))
44 self.transplants.append(transplantentry(lnode, rnode))
45
46 def write(self):
47 if self.dirty and self.transplantfile:
48 if not os.path.isdir(self.path):
49 os.mkdir(self.path)
50 fp = self.opener(self.transplantfile, 'w')
51 for c in self.transplants:
52 l, r = map(revlog.hex, (c.lnode, c.rnode))
53 fp.write(l + ':' + r + '\n')
54 fp.close()
55 self.dirty = False
56
57 def get(self, rnode):
58 return [t for t in self.transplants if t.rnode == rnode]
59
60 def set(self, lnode, rnode):
61 self.transplants.append(transplantentry(lnode, rnode))
62 self.dirty = True
63
64 def remove(self, transplant):
65 del self.transplants[self.transplants.index(transplant)]
66 self.dirty = True
67
68 class transplanter:
69 def __init__(self, ui, repo):
70 self.ui = ui
71 self.path = repo.join('transplant')
72 self.opener = util.opener(self.path)
73 self.transplants = transplants(self.path, 'transplants', opener=self.opener)
74
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):
80 return True
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)
85 return False
86 if t.lnode in repo.changelog.reachable(parent, stop=t.lnode):
87 return True
88 return False
89
90 def apply(self, repo, source, revmap, merges, opts={}):
91 '''apply the revisions in revmap one by one in revision order'''
92 revs = revmap.keys()
93 revs.sort()
94
95 p1, p2 = repo.dirstate.parents()
96 pulls = []
97 diffopts = patch.diffopts(self.ui, opts)
98 diffopts.git = True
99
100 lock = repo.lock()
101 wlock = repo.wlock()
102 try:
103 for rev in revs:
104 node = revmap[rev]
105 revstr = '%s:%s' % (rev, revlog.short(node))
106
107 if self.applied(repo, node, p1):
108 self.ui.warn(_('skipping already applied revision %s\n') %
109 revstr)
110 continue
111
112 parents = source.changelog.parents(node)
113 if not opts.get('filter'):
114 # If the changeset parent is the same as the wdir's parent,
115 # just pull it.
116 if parents[0] == p1:
117 pulls.append(node)
118 p1 = node
119 continue
120 if pulls:
121 if source != repo:
122 repo.pull(source, heads=pulls, lock=lock)
123 merge.update(repo, pulls[-1], wlock=wlock)
124 p1, p2 = repo.dirstate.parents()
125 pulls = []
126
127 domerge = False
128 if node in merges:
129 # pulling all the merge revs at once would mean we couldn't
130 # transplant after the latest even if transplants before them
131 # fail.
132 domerge = True
133 if not hasnode(repo, node):
134 repo.pull(source, heads=[node], lock=lock)
135
136 if parents[1] != revlog.nullid:
137 self.ui.note(_('skipping merge changeset %s:%s\n')
138 % (rev, revlog.short(node)))
139 patchfile = None
140 else:
141 fd, patchfile = tempfile.mkstemp(prefix='hg-transplant-')
142 fp = os.fdopen(fd, 'w')
143 patch.export(source, [node], fp=fp, opts=diffopts)
144 fp.close()
145
146 del revmap[rev]
147 if patchfile or domerge:
148 try:
149 n = self.applyone(repo, node, source.changelog.read(node),
150 patchfile, merge=domerge,
151 log=opts.get('log'),
152 filter=opts.get('filter'),
153 lock=lock, wlock=wlock)
154 if domerge:
155 self.ui.status(_('%s merged at %s\n') % (revstr,
156 revlog.short(n)))
157 else:
158 self.ui.status(_('%s transplanted to %s\n') % (revlog.short(node),
159 revlog.short(n)))
160 finally:
161 if patchfile:
162 os.unlink(patchfile)
163 if pulls:
164 repo.pull(source, heads=pulls, lock=lock)
165 merge.update(repo, pulls[-1], wlock=wlock)
166 finally:
167 self.saveseries(revmap, merges)
168 self.transplants.write()
169
170 def filter(self, filter, changelog, patchfile):
171 '''arbitrarily rewrite changeset before applying it'''
172
173 self.ui.status('filtering %s' % patchfile)
174 util.system('%s %s' % (filter, util.shellquote(patchfile)),
175 environ={'HGUSER': changelog[1]},
176 onerr=util.Abort, errprefix=_('filter failed'))
177
178 def applyone(self, repo, node, cl, patchfile, merge=False, log=False,
179 filter=None, lock=None, wlock=None):
180 '''apply the patch in patchfile to the repository as a transplant'''
181 (manifest, user, (time, timezone), files, message) = cl[:5]
182 date = "%d %d" % (time, timezone)
183 extra = {'transplant_source': node}
184 if filter:
185 self.filter(filter, cl, patchfile)
186 patchfile, message, user, date = patch.extract(self.ui, file(patchfile))
187
188 if log:
189 message += '\n(transplanted from %s)' % revlog.hex(node)
190 cl = list(cl)
191 cl[4] = message
192
193 self.ui.status(_('applying %s\n') % revlog.short(node))
194 self.ui.note('%s %s\n%s\n' % (user, date, message))
195
196 if not patchfile and not merge:
197 raise util.Abort(_('can only omit patchfile if merging'))
198 if patchfile:
199 try:
200 files = {}
201 fuzz = patch.patch(patchfile, self.ui, cwd=repo.root,
202 files=files)
203 if not files:
204 self.ui.warn(_('%s: empty changeset') % revlog.hex(node))
205 return
206 files = patch.updatedir(self.ui, repo, files, wlock=wlock)
207 if filter:
208 os.unlink(patchfile)
209 except Exception, inst:
210 if filter:
211 os.unlink(patchfile)
212 p1 = repo.dirstate.parents()[0]
213 p2 = node
214 self.log(cl, p1, p2, merge=merge)
215 self.ui.write(str(inst) + '\n')
216 raise util.Abort(_('Fix up the merge and run hg transplant --continue'))
217 else:
218 files = None
219 if merge:
220 p1, p2 = repo.dirstate.parents()
221 repo.dirstate.setparents(p1, node)
222
223 n = repo.commit(files, message, user, date, lock=lock, wlock=wlock,
224 extra=extra)
225 if not merge:
226 self.transplants.set(n, node)
227
228 return n
229
230 def resume(self, repo, source, opts=None):
231 '''recover last transaction and apply remaining changesets'''
232 if os.path.exists(os.path.join(self.path, 'journal')):
233 n, node = self.recover(repo)
234 self.ui.status(_('%s transplanted as %s\n') % (revlog.short(node),
235 revlog.short(n)))
236 seriespath = os.path.join(self.path, 'series')
237 if not os.path.exists(seriespath):
238 return
239 nodes, merges = self.readseries()
240 revmap = {}
241 for n in nodes:
242 revmap[source.changelog.rev(n)] = n
243 os.unlink(seriespath)
244
245 self.apply(repo, source, revmap, merges, opts)
246
247 def recover(self, repo):
248 '''commit working directory using journal metadata'''
249 node, user, date, message, parents = self.readlog()
250 merge = len(parents) == 2
251
252 if not user or not date or not message or not parents[0]:
253 raise util.Abort(_('transplant log file is corrupt'))
254
255 wlock = repo.wlock()
256 p1, p2 = repo.dirstate.parents()
257 if p1 != parents[0]:
258 raise util.Abort(_('working dir not at transplant parent %s') %
259 revlog.hex(parents[0]))
260 if merge:
261 repo.dirstate.setparents(p1, parents[1])
262 n = repo.commit(None, message, user, date, wlock=wlock)
263 if not n:
264 raise util.Abort(_('commit failed'))
265 if not merge:
266 self.transplants.set(n, node)
267 self.unlog()
268
269 return n, node
270
271 def readseries(self):
272 nodes = []
273 merges = []
274 cur = nodes
275 for line in self.opener('series').read().splitlines():
276 if line.startswith('# Merges'):
277 cur = merges
278 continue
279 cur.append(revlog.bin(line))
280
281 return (nodes, merges)
282
283 def saveseries(self, revmap, merges):
284 if not revmap:
285 return
286
287 if not os.path.isdir(self.path):
288 os.mkdir(self.path)
289 series = self.opener('series', 'w')
290 revs = revmap.keys()
291 revs.sort()
292 for rev in revs:
293 series.write(revlog.hex(revmap[rev]) + '\n')
294 if merges:
295 series.write('# Merges\n')
296 for m in merges:
297 series.write(revlog.hex(m) + '\n')
298 series.close()
299
300 def log(self, changelog, p1, p2, merge=False):
301 '''journal changelog metadata for later recover'''
302
303 if not os.path.isdir(self.path):
304 os.mkdir(self.path)
305 fp = self.opener('journal', 'w')
306 fp.write('# User %s\n' % changelog[1])
307 fp.write('# Date %d %d\n' % changelog[2])
308 fp.write('# Node ID %s\n' % revlog.hex(p2))
309 fp.write('# Parent ' + revlog.hex(p1) + '\n')
310 if merge:
311 fp.write('# Parent ' + revlog.hex(p2) + '\n')
312 fp.write(changelog[4].rstrip() + '\n')
313 fp.close()
314
315 def readlog(self):
316 parents = []
317 message = []
318 for line in self.opener('journal').read().splitlines():
319 if line.startswith('# User '):
320 user = line[7:]
321 elif line.startswith('# Date '):
322 date = line[7:]
323 elif line.startswith('# Node ID '):
324 node = revlog.bin(line[10:])
325 elif line.startswith('# Parent '):
326 parents.append(revlog.bin(line[9:]))
327 else:
328 message.append(line)
329 return (node, user, date, '\n'.join(message), parents)
330
331 def unlog(self):
332 '''remove changelog journal'''
333 absdst = os.path.join(self.path, 'journal')
334 if os.path.exists(absdst):
335 os.unlink(absdst)
336
337 def transplantfilter(self, repo, source, root):
338 def matchfn(node):
339 if self.applied(repo, node, root):
340 return False
341 if source.changelog.parents(node)[1] != revlog.nullid:
342 return False
343 extra = source.changelog.read(node)[5]
344 cnode = extra.get('transplant_source')
345 if cnode and self.applied(repo, cnode, root):
346 return False
347 return True
348
349 return matchfn
350
351 def hasnode(repo, node):
352 try:
353 return repo.changelog.rev(node) != None
354 except revlog.RevlogError:
355 return False
356
357 def browserevs(ui, repo, nodes, opts):
358 '''interactively transplant changesets'''
359 def browsehelp(ui):
360 ui.write('y: transplant this changeset\n'
361 'n: skip this changeset\n'
362 'm: merge at this changeset\n'
363 'p: show patch\n'
364 'c: commit selected changesets\n'
365 'q: cancel transplant\n'
366 '?: show this help\n')
367
368 displayer = commands.show_changeset(ui, repo, opts)
369 transplants = []
370 merges = []
371 for node in nodes:
372 displayer.show(changenode=node)
373 action = None
374 while not action:
375 action = ui.prompt(_('apply changeset? [ynmpcq?]:'))
376 if action == '?':
377 browsehelp(ui)
378 action = None
379 elif action == 'p':
380 parent = repo.changelog.parents(node)[0]
381 patch.diff(repo, parent, node)
382 action = None
383 elif action not in ('y', 'n', 'm', 'c', 'q'):
384 ui.write('no such option\n')
385 action = None
386 if action == 'y':
387 transplants.append(node)
388 elif action == 'm':
389 merges.append(node)
390 elif action == 'c':
391 break
392 elif action == 'q':
393 transplants = ()
394 merges = ()
395 break
396 return (transplants, merges)
397
398 def transplant(ui, repo, *revs, **opts):
399 '''transplant changesets from another branch
400
401 Selected changesets will be applied on top of the current working
402 directory with the log of the original changeset. If --log is
403 specified, log messages will have a comment appended of the form:
404
405 (transplanted from CHANGESETHASH)
406
407 You can rewrite the changelog message with the --filter option.
408 Its argument will be invoked with the current changelog message
409 as $1 and the patch as $2.
410
411 If --source is specified, selects changesets from the named
412 repository. If --branch is specified, selects changesets from the
413 branch holding the named revision, up to that revision. If --all
414 is specified, all changesets on the branch will be transplanted,
415 otherwise you will be prompted to select the changesets you want.
416
417 hg transplant --branch REVISION --all will rebase the selected branch
418 (up to the named revision) onto your current working directory.
419
420 You can optionally mark selected transplanted changesets as
421 merge changesets. You will not be prompted to transplant any
422 ancestors of a merged transplant, and you can merge descendants
423 of them normally instead of transplanting them.
424
425 If no merges or revisions are provided, hg transplant will start
426 an interactive changeset browser.
427
428 If a changeset application fails, you can fix the merge by hand and
429 then resume where you left off by calling hg transplant --continue.
430 '''
431 def getoneitem(opts, item, errmsg):
432 val = opts.get(item)
433 if val:
434 if len(val) > 1:
435 raise util.Abort(errmsg)
436 else:
437 return val[0]
438
439 def getremotechanges(repo, url):
440 sourcerepo = ui.expandpath(url)
441 source = hg.repository(ui, sourcerepo)
442 incoming = repo.findincoming(source, force=True)
443 if not incoming:
444 return (source, None, None)
445
446 bundle = None
447 if not source.local():
448 cg = source.changegroup(incoming, 'incoming')
449 bundle = commands.write_bundle(cg, compress=False)
450 source = bundlerepo.bundlerepository(ui, repo.root, bundle)
451
452 return (source, incoming, bundle)
453
454 def incwalk(repo, incoming, branches, match=util.always):
455 if not branches:
456 branches=None
457 for node in repo.changelog.nodesbetween(incoming, branches)[0]:
458 if match(node):
459 yield node
460
461 def transplantwalk(repo, root, branches, match=util.always):
462 if not branches:
463 branches = repo.heads()
464 ancestors = []
465 for branch in branches:
466 ancestors.append(repo.changelog.ancestor(root, branch))
467 for node in repo.changelog.nodesbetween(ancestors, branches)[0]:
468 if match(node):
469 yield node
470
471 def checkopts(opts, revs):
472 if opts.get('continue'):
473 if filter(lambda opt: opts.get(opt), ('branch', 'all', 'merge')):
474 raise util.Abort(_('--continue is incompatible with branch, all or merge'))
475 return
476 if not (opts.get('source') or revs or
477 opts.get('merge') or opts.get('branch')):
478 raise util.Abort(_('no source URL, branch tag or revision list provided'))
479 if opts.get('all'):
480 if not opts.get('branch'):
481 raise util.Abort(_('--all requires a branch revision'))
482 if revs:
483 raise util.Abort(_('--all is incompatible with a revision list'))
484
485 checkopts(opts, revs)
486
487 if not opts.get('log'):
488 opts['log'] = ui.config('transplant', 'log')
489 if not opts.get('filter'):
490 opts['filter'] = ui.config('transplant', 'filter')
491
492 tp = transplanter(ui, repo)
493
494 p1, p2 = repo.dirstate.parents()
495 if p1 == revlog.nullid:
496 raise util.Abort(_('no revision checked out'))
497 if not opts.get('continue'):
498 if p2 != revlog.nullid:
499 raise util.Abort(_('outstanding uncommitted merges'))
500 m, a, r, d = repo.status()[:4]
501 if m or a or r or d:
502 raise util.Abort(_('outstanding local changes'))
503
504 bundle = None
505 source = opts.get('source')
506 if source:
507 (source, incoming, bundle) = getremotechanges(repo, source)
508 else:
509 source = repo
510
511 try:
512 if opts.get('continue'):
513 n, node = tp.resume(repo, source, opts)
514 return
515
516 tf=tp.transplantfilter(repo, source, p1)
517 if opts.get('prune'):
518 prune = [source.lookup(r)
519 for r in cmdutil.revrange(source, opts.get('prune'))]
520 matchfn = lambda x: tf(x) and x not in prune
521 else:
522 matchfn = tf
523 branches = map(source.lookup, opts.get('branch', ()))
524 merges = map(source.lookup, opts.get('merge', ()))
525 revmap = {}
526 if revs:
527 for r in cmdutil.revrange(source, revs):
528 revmap[int(r)] = source.lookup(r)
529 elif opts.get('all') or not merges:
530 if source != repo:
531 alltransplants = incwalk(source, incoming, branches, match=matchfn)
532 else:
533 alltransplants = transplantwalk(source, p1, branches, match=matchfn)
534 if opts.get('all'):
535 revs = alltransplants
536 else:
537 revs, newmerges = browserevs(ui, source, alltransplants, opts)
538 merges.extend(newmerges)
539 for r in revs:
540 revmap[source.changelog.rev(r)] = r
541 for r in merges:
542 revmap[source.changelog.rev(r)] = r
543
544 revs = revmap.keys()
545 revs.sort()
546 pulls = []
547
548 tp.apply(repo, source, revmap, merges, opts)
549 finally:
550 if bundle:
551 os.unlink(bundle)
552
553 cmdtable = {
554 "transplant":
555 (transplant,
556 [('s', 'source', '', _('pull patches from REPOSITORY')),
557 ('b', 'branch', [], _('pull patches from branch BRANCH')),
558 ('a', 'all', None, _('pull all changesets up to BRANCH')),
559 ('p', 'prune', [], _('skip over REV')),
560 ('m', 'merge', [], _('merge at REV')),
561 ('', 'log', None, _('append transplant info to log message')),
562 ('c', 'continue', None, _('continue last transplant session after repair')),
563 ('', 'filter', '', _('filter changesets through FILTER'))],
564 _('hg transplant [-s REPOSITORY] [-b BRANCH] [-p REV] [-m REV] [-n] REV...'))
565 }