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