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