##// END OF EJS Templates
histedit: fix serializing of None backupfile...
Durham Goode -
r24958:a920abf5 stable
parent child Browse files
Show More
@@ -1,1147 +1,1150 b''
1 1 # histedit.py - interactive history editing for mercurial
2 2 #
3 3 # Copyright 2009 Augie Fackler <raf@durin42.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 """interactive history editing
8 8
9 9 With this extension installed, Mercurial gains one new command: histedit. Usage
10 10 is as follows, assuming the following history::
11 11
12 12 @ 3[tip] 7c2fd3b9020c 2009-04-27 18:04 -0500 durin42
13 13 | Add delta
14 14 |
15 15 o 2 030b686bedc4 2009-04-27 18:04 -0500 durin42
16 16 | Add gamma
17 17 |
18 18 o 1 c561b4e977df 2009-04-27 18:04 -0500 durin42
19 19 | Add beta
20 20 |
21 21 o 0 d8d2fcd0e319 2009-04-27 18:04 -0500 durin42
22 22 Add alpha
23 23
24 24 If you were to run ``hg histedit c561b4e977df``, you would see the following
25 25 file open in your editor::
26 26
27 27 pick c561b4e977df Add beta
28 28 pick 030b686bedc4 Add gamma
29 29 pick 7c2fd3b9020c Add delta
30 30
31 31 # Edit history between c561b4e977df and 7c2fd3b9020c
32 32 #
33 33 # Commits are listed from least to most recent
34 34 #
35 35 # Commands:
36 36 # p, pick = use commit
37 37 # e, edit = use commit, but stop for amending
38 38 # f, fold = use commit, but combine it with the one above
39 39 # r, roll = like fold, but discard this commit's description
40 40 # d, drop = remove commit from history
41 41 # m, mess = edit message without changing commit content
42 42 #
43 43
44 44 In this file, lines beginning with ``#`` are ignored. You must specify a rule
45 45 for each revision in your history. For example, if you had meant to add gamma
46 46 before beta, and then wanted to add delta in the same revision as beta, you
47 47 would reorganize the file to look like this::
48 48
49 49 pick 030b686bedc4 Add gamma
50 50 pick c561b4e977df Add beta
51 51 fold 7c2fd3b9020c Add delta
52 52
53 53 # Edit history between c561b4e977df and 7c2fd3b9020c
54 54 #
55 55 # Commits are listed from least to most recent
56 56 #
57 57 # Commands:
58 58 # p, pick = use commit
59 59 # e, edit = use commit, but stop for amending
60 60 # f, fold = use commit, but combine it with the one above
61 61 # r, roll = like fold, but discard this commit's description
62 62 # d, drop = remove commit from history
63 63 # m, mess = edit message without changing commit content
64 64 #
65 65
66 66 At which point you close the editor and ``histedit`` starts working. When you
67 67 specify a ``fold`` operation, ``histedit`` will open an editor when it folds
68 68 those revisions together, offering you a chance to clean up the commit message::
69 69
70 70 Add beta
71 71 ***
72 72 Add delta
73 73
74 74 Edit the commit message to your liking, then close the editor. For
75 75 this example, let's assume that the commit message was changed to
76 76 ``Add beta and delta.`` After histedit has run and had a chance to
77 77 remove any old or temporary revisions it needed, the history looks
78 78 like this::
79 79
80 80 @ 2[tip] 989b4d060121 2009-04-27 18:04 -0500 durin42
81 81 | Add beta and delta.
82 82 |
83 83 o 1 081603921c3f 2009-04-27 18:04 -0500 durin42
84 84 | Add gamma
85 85 |
86 86 o 0 d8d2fcd0e319 2009-04-27 18:04 -0500 durin42
87 87 Add alpha
88 88
89 89 Note that ``histedit`` does *not* remove any revisions (even its own temporary
90 90 ones) until after it has completed all the editing operations, so it will
91 91 probably perform several strip operations when it's done. For the above example,
92 92 it had to run strip twice. Strip can be slow depending on a variety of factors,
93 93 so you might need to be a little patient. You can choose to keep the original
94 94 revisions by passing the ``--keep`` flag.
95 95
96 96 The ``edit`` operation will drop you back to a command prompt,
97 97 allowing you to edit files freely, or even use ``hg record`` to commit
98 98 some changes as a separate commit. When you're done, any remaining
99 99 uncommitted changes will be committed as well. When done, run ``hg
100 100 histedit --continue`` to finish this step. You'll be prompted for a
101 101 new commit message, but the default commit message will be the
102 102 original message for the ``edit`` ed revision.
103 103
104 104 The ``message`` operation will give you a chance to revise a commit
105 105 message without changing the contents. It's a shortcut for doing
106 106 ``edit`` immediately followed by `hg histedit --continue``.
107 107
108 108 If ``histedit`` encounters a conflict when moving a revision (while
109 109 handling ``pick`` or ``fold``), it'll stop in a similar manner to
110 110 ``edit`` with the difference that it won't prompt you for a commit
111 111 message when done. If you decide at this point that you don't like how
112 112 much work it will be to rearrange history, or that you made a mistake,
113 113 you can use ``hg histedit --abort`` to abandon the new changes you
114 114 have made and return to the state before you attempted to edit your
115 115 history.
116 116
117 117 If we clone the histedit-ed example repository above and add four more
118 118 changes, such that we have the following history::
119 119
120 120 @ 6[tip] 038383181893 2009-04-27 18:04 -0500 stefan
121 121 | Add theta
122 122 |
123 123 o 5 140988835471 2009-04-27 18:04 -0500 stefan
124 124 | Add eta
125 125 |
126 126 o 4 122930637314 2009-04-27 18:04 -0500 stefan
127 127 | Add zeta
128 128 |
129 129 o 3 836302820282 2009-04-27 18:04 -0500 stefan
130 130 | Add epsilon
131 131 |
132 132 o 2 989b4d060121 2009-04-27 18:04 -0500 durin42
133 133 | Add beta and delta.
134 134 |
135 135 o 1 081603921c3f 2009-04-27 18:04 -0500 durin42
136 136 | Add gamma
137 137 |
138 138 o 0 d8d2fcd0e319 2009-04-27 18:04 -0500 durin42
139 139 Add alpha
140 140
141 141 If you run ``hg histedit --outgoing`` on the clone then it is the same
142 142 as running ``hg histedit 836302820282``. If you need plan to push to a
143 143 repository that Mercurial does not detect to be related to the source
144 144 repo, you can add a ``--force`` option.
145 145
146 146 Histedit rule lines are truncated to 80 characters by default. You
147 147 can customise this behaviour by setting a different length in your
148 148 configuration file::
149 149
150 150 [histedit]
151 151 linelen = 120 # truncate rule lines at 120 characters
152 152 """
153 153
154 154 try:
155 155 import cPickle as pickle
156 156 pickle.dump # import now
157 157 except ImportError:
158 158 import pickle
159 159 import errno
160 160 import os
161 161 import sys
162 162
163 163 from mercurial import cmdutil
164 164 from mercurial import discovery
165 165 from mercurial import error
166 166 from mercurial import changegroup
167 167 from mercurial import copies
168 168 from mercurial import context
169 169 from mercurial import exchange
170 170 from mercurial import extensions
171 171 from mercurial import hg
172 172 from mercurial import node
173 173 from mercurial import repair
174 174 from mercurial import scmutil
175 175 from mercurial import util
176 176 from mercurial import obsolete
177 177 from mercurial import merge as mergemod
178 178 from mercurial.lock import release
179 179 from mercurial.i18n import _
180 180
181 181 cmdtable = {}
182 182 command = cmdutil.command(cmdtable)
183 183
184 184 testedwith = 'internal'
185 185
186 186 # i18n: command names and abbreviations must remain untranslated
187 187 editcomment = _("""# Edit history between %s and %s
188 188 #
189 189 # Commits are listed from least to most recent
190 190 #
191 191 # Commands:
192 192 # p, pick = use commit
193 193 # e, edit = use commit, but stop for amending
194 194 # f, fold = use commit, but combine it with the one above
195 195 # r, roll = like fold, but discard this commit's description
196 196 # d, drop = remove commit from history
197 197 # m, mess = edit message without changing commit content
198 198 #
199 199 """)
200 200
201 201 class histeditstate(object):
202 202 def __init__(self, repo, parentctxnode=None, rules=None, keep=None,
203 203 topmost=None, replacements=None, lock=None, wlock=None):
204 204 self.repo = repo
205 205 self.rules = rules
206 206 self.keep = keep
207 207 self.topmost = topmost
208 208 self.parentctxnode = parentctxnode
209 209 self.lock = lock
210 210 self.wlock = wlock
211 211 self.backupfile = None
212 212 if replacements is None:
213 213 self.replacements = []
214 214 else:
215 215 self.replacements = replacements
216 216
217 217 def read(self):
218 218 """Load histedit state from disk and set fields appropriately."""
219 219 try:
220 220 fp = self.repo.vfs('histedit-state', 'r')
221 221 except IOError, err:
222 222 if err.errno != errno.ENOENT:
223 223 raise
224 224 raise util.Abort(_('no histedit in progress'))
225 225
226 226 try:
227 227 data = pickle.load(fp)
228 228 parentctxnode, rules, keep, topmost, replacements = data
229 229 backupfile = None
230 230 except pickle.UnpicklingError:
231 231 data = self._load()
232 232 parentctxnode, rules, keep, topmost, replacements, backupfile = data
233 233
234 234 self.parentctxnode = parentctxnode
235 235 self.rules = rules
236 236 self.keep = keep
237 237 self.topmost = topmost
238 238 self.replacements = replacements
239 239 self.backupfile = backupfile
240 240
241 241 def write(self):
242 242 fp = self.repo.vfs('histedit-state', 'w')
243 243 fp.write('v1\n')
244 244 fp.write('%s\n' % node.hex(self.parentctxnode))
245 245 fp.write('%s\n' % node.hex(self.topmost))
246 246 fp.write('%s\n' % self.keep)
247 247 fp.write('%d\n' % len(self.rules))
248 248 for rule in self.rules:
249 249 fp.write('%s\n' % rule[0]) # action
250 250 fp.write('%s\n' % rule[1]) # remainder
251 251 fp.write('%d\n' % len(self.replacements))
252 252 for replacement in self.replacements:
253 253 fp.write('%s%s\n' % (node.hex(replacement[0]), ''.join(node.hex(r)
254 254 for r in replacement[1])))
255 fp.write('%s\n' % self.backupfile)
255 backupfile = self.backupfile
256 if not backupfile:
257 backupfile = ''
258 fp.write('%s\n' % backupfile)
256 259 fp.close()
257 260
258 261 def _load(self):
259 262 fp = self.repo.vfs('histedit-state', 'r')
260 263 lines = [l[:-1] for l in fp.readlines()]
261 264
262 265 index = 0
263 266 lines[index] # version number
264 267 index += 1
265 268
266 269 parentctxnode = node.bin(lines[index])
267 270 index += 1
268 271
269 272 topmost = node.bin(lines[index])
270 273 index += 1
271 274
272 275 keep = lines[index] == 'True'
273 276 index += 1
274 277
275 278 # Rules
276 279 rules = []
277 280 rulelen = int(lines[index])
278 281 index += 1
279 282 for i in xrange(rulelen):
280 283 ruleaction = lines[index]
281 284 index += 1
282 285 rule = lines[index]
283 286 index += 1
284 287 rules.append((ruleaction, rule))
285 288
286 289 # Replacements
287 290 replacements = []
288 291 replacementlen = int(lines[index])
289 292 index += 1
290 293 for i in xrange(replacementlen):
291 294 replacement = lines[index]
292 295 original = node.bin(replacement[:40])
293 296 succ = [node.bin(replacement[i:i + 40]) for i in
294 297 range(40, len(replacement), 40)]
295 298 replacements.append((original, succ))
296 299 index += 1
297 300
298 301 backupfile = lines[index]
299 302 index += 1
300 303
301 304 fp.close()
302 305
303 306 return parentctxnode, rules, keep, topmost, replacements, backupfile
304 307
305 308 def clear(self):
306 309 self.repo.vfs.unlink('histedit-state')
307 310
308 311 class histeditaction(object):
309 312 def __init__(self, state, node):
310 313 self.state = state
311 314 self.repo = state.repo
312 315 self.node = node
313 316
314 317 @classmethod
315 318 def fromrule(cls, state, rule):
316 319 """Parses the given rule, returning an instance of the histeditaction.
317 320 """
318 321 repo = state.repo
319 322 rulehash = rule.strip().split(' ', 1)[0]
320 323 try:
321 324 node = repo[rulehash].node()
322 325 except error.RepoError:
323 326 raise util.Abort(_('unknown changeset %s listed') % rulehash[:12])
324 327 return cls(state, node)
325 328
326 329 def run(self):
327 330 """Runs the action. The default behavior is simply apply the action's
328 331 rulectx onto the current parentctx."""
329 332 self.applychange()
330 333 self.continuedirty()
331 334 return self.continueclean()
332 335
333 336 def applychange(self):
334 337 """Applies the changes from this action's rulectx onto the current
335 338 parentctx, but does not commit them."""
336 339 repo = self.repo
337 340 rulectx = repo[self.node]
338 341 hg.update(repo, self.state.parentctxnode)
339 342 stats = applychanges(repo.ui, repo, rulectx, {})
340 343 if stats and stats[3] > 0:
341 344 raise error.InterventionRequired(_('Fix up the change and run '
342 345 'hg histedit --continue'))
343 346
344 347 def continuedirty(self):
345 348 """Continues the action when changes have been applied to the working
346 349 copy. The default behavior is to commit the dirty changes."""
347 350 repo = self.repo
348 351 rulectx = repo[self.node]
349 352
350 353 editor = self.commiteditor()
351 354 commit = commitfuncfor(repo, rulectx)
352 355
353 356 commit(text=rulectx.description(), user=rulectx.user(),
354 357 date=rulectx.date(), extra=rulectx.extra(), editor=editor)
355 358
356 359 def commiteditor(self):
357 360 """The editor to be used to edit the commit message."""
358 361 return False
359 362
360 363 def continueclean(self):
361 364 """Continues the action when the working copy is clean. The default
362 365 behavior is to accept the current commit as the new version of the
363 366 rulectx."""
364 367 ctx = self.repo['.']
365 368 if ctx.node() == self.state.parentctxnode:
366 369 self.repo.ui.warn(_('%s: empty changeset\n') %
367 370 node.short(self.node))
368 371 return ctx, [(self.node, tuple())]
369 372 if ctx.node() == self.node:
370 373 # Nothing changed
371 374 return ctx, []
372 375 return ctx, [(self.node, (ctx.node(),))]
373 376
374 377 def commitfuncfor(repo, src):
375 378 """Build a commit function for the replacement of <src>
376 379
377 380 This function ensure we apply the same treatment to all changesets.
378 381
379 382 - Add a 'histedit_source' entry in extra.
380 383
381 384 Note that fold have its own separated logic because its handling is a bit
382 385 different and not easily factored out of the fold method.
383 386 """
384 387 phasemin = src.phase()
385 388 def commitfunc(**kwargs):
386 389 phasebackup = repo.ui.backupconfig('phases', 'new-commit')
387 390 try:
388 391 repo.ui.setconfig('phases', 'new-commit', phasemin,
389 392 'histedit')
390 393 extra = kwargs.get('extra', {}).copy()
391 394 extra['histedit_source'] = src.hex()
392 395 kwargs['extra'] = extra
393 396 return repo.commit(**kwargs)
394 397 finally:
395 398 repo.ui.restoreconfig(phasebackup)
396 399 return commitfunc
397 400
398 401 def applychanges(ui, repo, ctx, opts):
399 402 """Merge changeset from ctx (only) in the current working directory"""
400 403 wcpar = repo.dirstate.parents()[0]
401 404 if ctx.p1().node() == wcpar:
402 405 # edition ar "in place" we do not need to make any merge,
403 406 # just applies changes on parent for edition
404 407 cmdutil.revert(ui, repo, ctx, (wcpar, node.nullid), all=True)
405 408 stats = None
406 409 else:
407 410 try:
408 411 # ui.forcemerge is an internal variable, do not document
409 412 repo.ui.setconfig('ui', 'forcemerge', opts.get('tool', ''),
410 413 'histedit')
411 414 stats = mergemod.graft(repo, ctx, ctx.p1(), ['local', 'histedit'])
412 415 finally:
413 416 repo.ui.setconfig('ui', 'forcemerge', '', 'histedit')
414 417 return stats
415 418
416 419 def collapse(repo, first, last, commitopts, skipprompt=False):
417 420 """collapse the set of revisions from first to last as new one.
418 421
419 422 Expected commit options are:
420 423 - message
421 424 - date
422 425 - username
423 426 Commit message is edited in all cases.
424 427
425 428 This function works in memory."""
426 429 ctxs = list(repo.set('%d::%d', first, last))
427 430 if not ctxs:
428 431 return None
429 432 base = first.parents()[0]
430 433
431 434 # commit a new version of the old changeset, including the update
432 435 # collect all files which might be affected
433 436 files = set()
434 437 for ctx in ctxs:
435 438 files.update(ctx.files())
436 439
437 440 # Recompute copies (avoid recording a -> b -> a)
438 441 copied = copies.pathcopies(base, last)
439 442
440 443 # prune files which were reverted by the updates
441 444 def samefile(f):
442 445 if f in last.manifest():
443 446 a = last.filectx(f)
444 447 if f in base.manifest():
445 448 b = base.filectx(f)
446 449 return (a.data() == b.data()
447 450 and a.flags() == b.flags())
448 451 else:
449 452 return False
450 453 else:
451 454 return f not in base.manifest()
452 455 files = [f for f in files if not samefile(f)]
453 456 # commit version of these files as defined by head
454 457 headmf = last.manifest()
455 458 def filectxfn(repo, ctx, path):
456 459 if path in headmf:
457 460 fctx = last[path]
458 461 flags = fctx.flags()
459 462 mctx = context.memfilectx(repo,
460 463 fctx.path(), fctx.data(),
461 464 islink='l' in flags,
462 465 isexec='x' in flags,
463 466 copied=copied.get(path))
464 467 return mctx
465 468 return None
466 469
467 470 if commitopts.get('message'):
468 471 message = commitopts['message']
469 472 else:
470 473 message = first.description()
471 474 user = commitopts.get('user')
472 475 date = commitopts.get('date')
473 476 extra = commitopts.get('extra')
474 477
475 478 parents = (first.p1().node(), first.p2().node())
476 479 editor = None
477 480 if not skipprompt:
478 481 editor = cmdutil.getcommiteditor(edit=True, editform='histedit.fold')
479 482 new = context.memctx(repo,
480 483 parents=parents,
481 484 text=message,
482 485 files=files,
483 486 filectxfn=filectxfn,
484 487 user=user,
485 488 date=date,
486 489 extra=extra,
487 490 editor=editor)
488 491 return repo.commitctx(new)
489 492
490 493 class pick(histeditaction):
491 494 def run(self):
492 495 rulectx = self.repo[self.node]
493 496 if rulectx.parents()[0].node() == self.state.parentctxnode:
494 497 self.repo.ui.debug('node %s unchanged\n' % node.short(self.node))
495 498 return rulectx, []
496 499
497 500 return super(pick, self).run()
498 501
499 502 class edit(histeditaction):
500 503 def run(self):
501 504 repo = self.repo
502 505 rulectx = repo[self.node]
503 506 hg.update(repo, self.state.parentctxnode)
504 507 applychanges(repo.ui, repo, rulectx, {})
505 508 raise error.InterventionRequired(
506 509 _('Make changes as needed, you may commit or record as needed '
507 510 'now.\nWhen you are finished, run hg histedit --continue to '
508 511 'resume.'))
509 512
510 513 def commiteditor(self):
511 514 return cmdutil.getcommiteditor(edit=True, editform='histedit.edit')
512 515
513 516 class fold(histeditaction):
514 517 def continuedirty(self):
515 518 repo = self.repo
516 519 rulectx = repo[self.node]
517 520
518 521 commit = commitfuncfor(repo, rulectx)
519 522 commit(text='fold-temp-revision %s' % node.short(self.node),
520 523 user=rulectx.user(), date=rulectx.date(),
521 524 extra=rulectx.extra())
522 525
523 526 def continueclean(self):
524 527 repo = self.repo
525 528 ctx = repo['.']
526 529 rulectx = repo[self.node]
527 530 parentctxnode = self.state.parentctxnode
528 531 if ctx.node() == parentctxnode:
529 532 repo.ui.warn(_('%s: empty changeset\n') %
530 533 node.short(self.node))
531 534 return ctx, [(self.node, (parentctxnode,))]
532 535
533 536 parentctx = repo[parentctxnode]
534 537 newcommits = set(c.node() for c in repo.set('(%d::. - %d)', parentctx,
535 538 parentctx))
536 539 if not newcommits:
537 540 repo.ui.warn(_('%s: cannot fold - working copy is not a '
538 541 'descendant of previous commit %s\n') %
539 542 (node.short(self.node), node.short(parentctxnode)))
540 543 return ctx, [(self.node, (ctx.node(),))]
541 544
542 545 middlecommits = newcommits.copy()
543 546 middlecommits.discard(ctx.node())
544 547
545 548 return self.finishfold(repo.ui, repo, parentctx, rulectx, ctx.node(),
546 549 middlecommits)
547 550
548 551 def skipprompt(self):
549 552 return False
550 553
551 554 def finishfold(self, ui, repo, ctx, oldctx, newnode, internalchanges):
552 555 parent = ctx.parents()[0].node()
553 556 hg.update(repo, parent)
554 557 ### prepare new commit data
555 558 commitopts = {}
556 559 commitopts['user'] = ctx.user()
557 560 # commit message
558 561 if self.skipprompt():
559 562 newmessage = ctx.description()
560 563 else:
561 564 newmessage = '\n***\n'.join(
562 565 [ctx.description()] +
563 566 [repo[r].description() for r in internalchanges] +
564 567 [oldctx.description()]) + '\n'
565 568 commitopts['message'] = newmessage
566 569 # date
567 570 commitopts['date'] = max(ctx.date(), oldctx.date())
568 571 extra = ctx.extra().copy()
569 572 # histedit_source
570 573 # note: ctx is likely a temporary commit but that the best we can do
571 574 # here. This is sufficient to solve issue3681 anyway.
572 575 extra['histedit_source'] = '%s,%s' % (ctx.hex(), oldctx.hex())
573 576 commitopts['extra'] = extra
574 577 phasebackup = repo.ui.backupconfig('phases', 'new-commit')
575 578 try:
576 579 phasemin = max(ctx.phase(), oldctx.phase())
577 580 repo.ui.setconfig('phases', 'new-commit', phasemin, 'histedit')
578 581 n = collapse(repo, ctx, repo[newnode], commitopts,
579 582 skipprompt=self.skipprompt())
580 583 finally:
581 584 repo.ui.restoreconfig(phasebackup)
582 585 if n is None:
583 586 return ctx, []
584 587 hg.update(repo, n)
585 588 replacements = [(oldctx.node(), (newnode,)),
586 589 (ctx.node(), (n,)),
587 590 (newnode, (n,)),
588 591 ]
589 592 for ich in internalchanges:
590 593 replacements.append((ich, (n,)))
591 594 return repo[n], replacements
592 595
593 596 class rollup(fold):
594 597 def skipprompt(self):
595 598 return True
596 599
597 600 class drop(histeditaction):
598 601 def run(self):
599 602 parentctx = self.repo[self.state.parentctxnode]
600 603 return parentctx, [(self.node, tuple())]
601 604
602 605 class message(histeditaction):
603 606 def commiteditor(self):
604 607 return cmdutil.getcommiteditor(edit=True, editform='histedit.mess')
605 608
606 609 def findoutgoing(ui, repo, remote=None, force=False, opts={}):
607 610 """utility function to find the first outgoing changeset
608 611
609 612 Used by initialisation code"""
610 613 dest = ui.expandpath(remote or 'default-push', remote or 'default')
611 614 dest, revs = hg.parseurl(dest, None)[:2]
612 615 ui.status(_('comparing with %s\n') % util.hidepassword(dest))
613 616
614 617 revs, checkout = hg.addbranchrevs(repo, repo, revs, None)
615 618 other = hg.peer(repo, opts, dest)
616 619
617 620 if revs:
618 621 revs = [repo.lookup(rev) for rev in revs]
619 622
620 623 outgoing = discovery.findcommonoutgoing(repo, other, revs, force=force)
621 624 if not outgoing.missing:
622 625 raise util.Abort(_('no outgoing ancestors'))
623 626 roots = list(repo.revs("roots(%ln)", outgoing.missing))
624 627 if 1 < len(roots):
625 628 msg = _('there are ambiguous outgoing revisions')
626 629 hint = _('see "hg help histedit" for more detail')
627 630 raise util.Abort(msg, hint=hint)
628 631 return repo.lookup(roots[0])
629 632
630 633 actiontable = {'p': pick,
631 634 'pick': pick,
632 635 'e': edit,
633 636 'edit': edit,
634 637 'f': fold,
635 638 'fold': fold,
636 639 'r': rollup,
637 640 'roll': rollup,
638 641 'd': drop,
639 642 'drop': drop,
640 643 'm': message,
641 644 'mess': message,
642 645 }
643 646
644 647 @command('histedit',
645 648 [('', 'commands', '',
646 649 _('read history edits from the specified file'), _('FILE')),
647 650 ('c', 'continue', False, _('continue an edit already in progress')),
648 651 ('', 'edit-plan', False, _('edit remaining actions list')),
649 652 ('k', 'keep', False,
650 653 _("don't strip old nodes after edit is complete")),
651 654 ('', 'abort', False, _('abort an edit in progress')),
652 655 ('o', 'outgoing', False, _('changesets not found in destination')),
653 656 ('f', 'force', False,
654 657 _('force outgoing even for unrelated repositories')),
655 658 ('r', 'rev', [], _('first revision to be edited'), _('REV'))],
656 659 _("ANCESTOR | --outgoing [URL]"))
657 660 def histedit(ui, repo, *freeargs, **opts):
658 661 """interactively edit changeset history
659 662
660 663 This command edits changesets between ANCESTOR and the parent of
661 664 the working directory.
662 665
663 666 With --outgoing, this edits changesets not found in the
664 667 destination repository. If URL of the destination is omitted, the
665 668 'default-push' (or 'default') path will be used.
666 669
667 670 For safety, this command is aborted, also if there are ambiguous
668 671 outgoing revisions which may confuse users: for example, there are
669 672 multiple branches containing outgoing revisions.
670 673
671 674 Use "min(outgoing() and ::.)" or similar revset specification
672 675 instead of --outgoing to specify edit target revision exactly in
673 676 such ambiguous situation. See :hg:`help revsets` for detail about
674 677 selecting revisions.
675 678
676 679 Returns 0 on success, 1 if user intervention is required (not only
677 680 for intentional "edit" command, but also for resolving unexpected
678 681 conflicts).
679 682 """
680 683 state = histeditstate(repo)
681 684 try:
682 685 state.wlock = repo.wlock()
683 686 state.lock = repo.lock()
684 687 _histedit(ui, repo, state, *freeargs, **opts)
685 688 finally:
686 689 release(state.lock, state.wlock)
687 690
688 691 def _histedit(ui, repo, state, *freeargs, **opts):
689 692 # TODO only abort if we try and histedit mq patches, not just
690 693 # blanket if mq patches are applied somewhere
691 694 mq = getattr(repo, 'mq', None)
692 695 if mq and mq.applied:
693 696 raise util.Abort(_('source has mq patches applied'))
694 697
695 698 # basic argument incompatibility processing
696 699 outg = opts.get('outgoing')
697 700 cont = opts.get('continue')
698 701 editplan = opts.get('edit_plan')
699 702 abort = opts.get('abort')
700 703 force = opts.get('force')
701 704 rules = opts.get('commands', '')
702 705 revs = opts.get('rev', [])
703 706 goal = 'new' # This invocation goal, in new, continue, abort
704 707 if force and not outg:
705 708 raise util.Abort(_('--force only allowed with --outgoing'))
706 709 if cont:
707 710 if util.any((outg, abort, revs, freeargs, rules, editplan)):
708 711 raise util.Abort(_('no arguments allowed with --continue'))
709 712 goal = 'continue'
710 713 elif abort:
711 714 if util.any((outg, revs, freeargs, rules, editplan)):
712 715 raise util.Abort(_('no arguments allowed with --abort'))
713 716 goal = 'abort'
714 717 elif editplan:
715 718 if util.any((outg, revs, freeargs)):
716 719 raise util.Abort(_('only --commands argument allowed with '
717 720 '--edit-plan'))
718 721 goal = 'edit-plan'
719 722 else:
720 723 if os.path.exists(os.path.join(repo.path, 'histedit-state')):
721 724 raise util.Abort(_('history edit already in progress, try '
722 725 '--continue or --abort'))
723 726 if outg:
724 727 if revs:
725 728 raise util.Abort(_('no revisions allowed with --outgoing'))
726 729 if len(freeargs) > 1:
727 730 raise util.Abort(
728 731 _('only one repo argument allowed with --outgoing'))
729 732 else:
730 733 revs.extend(freeargs)
731 734 if len(revs) == 0:
732 735 histeditdefault = ui.config('histedit', 'defaultrev')
733 736 if histeditdefault:
734 737 revs.append(histeditdefault)
735 738 if len(revs) != 1:
736 739 raise util.Abort(
737 740 _('histedit requires exactly one ancestor revision'))
738 741
739 742
740 743 replacements = []
741 744 keep = opts.get('keep', False)
742 745
743 746 # rebuild state
744 747 if goal == 'continue':
745 748 state.read()
746 749 state = bootstrapcontinue(ui, state, opts)
747 750 elif goal == 'edit-plan':
748 751 state.read()
749 752 if not rules:
750 753 comment = editcomment % (node.short(state.parentctxnode),
751 754 node.short(state.topmost))
752 755 rules = ruleeditor(repo, ui, state.rules, comment)
753 756 else:
754 757 if rules == '-':
755 758 f = sys.stdin
756 759 else:
757 760 f = open(rules)
758 761 rules = f.read()
759 762 f.close()
760 763 rules = [l for l in (r.strip() for r in rules.splitlines())
761 764 if l and not l.startswith('#')]
762 765 rules = verifyrules(rules, repo, [repo[c] for [_a, c] in state.rules])
763 766 state.rules = rules
764 767 state.write()
765 768 return
766 769 elif goal == 'abort':
767 770 state.read()
768 771 mapping, tmpnodes, leafs, _ntm = processreplacement(state)
769 772 ui.debug('restore wc to old parent %s\n' % node.short(state.topmost))
770 773
771 774 # Recover our old commits if necessary
772 775 if not state.topmost in repo and state.backupfile:
773 776 backupfile = repo.join(state.backupfile)
774 777 f = hg.openpath(ui, backupfile)
775 778 gen = exchange.readbundle(ui, f, backupfile)
776 779 changegroup.addchangegroup(repo, gen, 'histedit',
777 780 'bundle:' + backupfile)
778 781 os.remove(backupfile)
779 782
780 783 # check whether we should update away
781 784 parentnodes = [c.node() for c in repo[None].parents()]
782 785 for n in leafs | set([state.parentctxnode]):
783 786 if n in parentnodes:
784 787 hg.clean(repo, state.topmost)
785 788 break
786 789 else:
787 790 pass
788 791 cleanupnode(ui, repo, 'created', tmpnodes)
789 792 cleanupnode(ui, repo, 'temp', leafs)
790 793 state.clear()
791 794 return
792 795 else:
793 796 cmdutil.checkunfinished(repo)
794 797 cmdutil.bailifchanged(repo)
795 798
796 799 topmost, empty = repo.dirstate.parents()
797 800 if outg:
798 801 if freeargs:
799 802 remote = freeargs[0]
800 803 else:
801 804 remote = None
802 805 root = findoutgoing(ui, repo, remote, force, opts)
803 806 else:
804 807 rr = list(repo.set('roots(%ld)', scmutil.revrange(repo, revs)))
805 808 if len(rr) != 1:
806 809 raise util.Abort(_('The specified revisions must have '
807 810 'exactly one common root'))
808 811 root = rr[0].node()
809 812
810 813 revs = between(repo, root, topmost, keep)
811 814 if not revs:
812 815 raise util.Abort(_('%s is not an ancestor of working directory') %
813 816 node.short(root))
814 817
815 818 ctxs = [repo[r] for r in revs]
816 819 if not rules:
817 820 comment = editcomment % (node.short(root), node.short(topmost))
818 821 rules = ruleeditor(repo, ui, [['pick', c] for c in ctxs], comment)
819 822 else:
820 823 if rules == '-':
821 824 f = sys.stdin
822 825 else:
823 826 f = open(rules)
824 827 rules = f.read()
825 828 f.close()
826 829 rules = [l for l in (r.strip() for r in rules.splitlines())
827 830 if l and not l.startswith('#')]
828 831 rules = verifyrules(rules, repo, ctxs)
829 832
830 833 parentctxnode = repo[root].parents()[0].node()
831 834
832 835 state.parentctxnode = parentctxnode
833 836 state.rules = rules
834 837 state.keep = keep
835 838 state.topmost = topmost
836 839 state.replacements = replacements
837 840
838 841 # Create a backup so we can always abort completely.
839 842 backupfile = None
840 843 if not obsolete.isenabled(repo, obsolete.createmarkersopt):
841 844 backupfile = repair._bundle(repo, [parentctxnode], [topmost], root,
842 845 'histedit')
843 846 state.backupfile = backupfile
844 847
845 848 while state.rules:
846 849 state.write()
847 850 action, ha = state.rules.pop(0)
848 851 ui.debug('histedit: processing %s %s\n' % (action, ha[:12]))
849 852 actobj = actiontable[action].fromrule(state, ha)
850 853 parentctx, replacement_ = actobj.run()
851 854 state.parentctxnode = parentctx.node()
852 855 state.replacements.extend(replacement_)
853 856 state.write()
854 857
855 858 hg.update(repo, state.parentctxnode)
856 859
857 860 mapping, tmpnodes, created, ntm = processreplacement(state)
858 861 if mapping:
859 862 for prec, succs in mapping.iteritems():
860 863 if not succs:
861 864 ui.debug('histedit: %s is dropped\n' % node.short(prec))
862 865 else:
863 866 ui.debug('histedit: %s is replaced by %s\n' % (
864 867 node.short(prec), node.short(succs[0])))
865 868 if len(succs) > 1:
866 869 m = 'histedit: %s'
867 870 for n in succs[1:]:
868 871 ui.debug(m % node.short(n))
869 872
870 873 if not keep:
871 874 if mapping:
872 875 movebookmarks(ui, repo, mapping, state.topmost, ntm)
873 876 # TODO update mq state
874 877 if obsolete.isenabled(repo, obsolete.createmarkersopt):
875 878 markers = []
876 879 # sort by revision number because it sound "right"
877 880 for prec in sorted(mapping, key=repo.changelog.rev):
878 881 succs = mapping[prec]
879 882 markers.append((repo[prec],
880 883 tuple(repo[s] for s in succs)))
881 884 if markers:
882 885 obsolete.createmarkers(repo, markers)
883 886 else:
884 887 cleanupnode(ui, repo, 'replaced', mapping)
885 888
886 889 cleanupnode(ui, repo, 'temp', tmpnodes)
887 890 state.clear()
888 891 if os.path.exists(repo.sjoin('undo')):
889 892 os.unlink(repo.sjoin('undo'))
890 893
891 894 def bootstrapcontinue(ui, state, opts):
892 895 repo = state.repo
893 896 action, currentnode = state.rules.pop(0)
894 897
895 898 actobj = actiontable[action].fromrule(state, currentnode)
896 899
897 900 s = repo.status()
898 901 if s.modified or s.added or s.removed or s.deleted:
899 902 actobj.continuedirty()
900 903 s = repo.status()
901 904 if s.modified or s.added or s.removed or s.deleted:
902 905 raise util.Abort(_("working copy still dirty"))
903 906
904 907 parentctx, replacements = actobj.continueclean()
905 908
906 909 state.parentctxnode = parentctx.node()
907 910 state.replacements.extend(replacements)
908 911
909 912 return state
910 913
911 914 def between(repo, old, new, keep):
912 915 """select and validate the set of revision to edit
913 916
914 917 When keep is false, the specified set can't have children."""
915 918 ctxs = list(repo.set('%n::%n', old, new))
916 919 if ctxs and not keep:
917 920 if (not obsolete.isenabled(repo, obsolete.allowunstableopt) and
918 921 repo.revs('(%ld::) - (%ld)', ctxs, ctxs)):
919 922 raise util.Abort(_('cannot edit history that would orphan nodes'))
920 923 if repo.revs('(%ld) and merge()', ctxs):
921 924 raise util.Abort(_('cannot edit history that contains merges'))
922 925 root = ctxs[0] # list is already sorted by repo.set
923 926 if not root.mutable():
924 927 raise util.Abort(_('cannot edit immutable changeset: %s') % root)
925 928 return [c.node() for c in ctxs]
926 929
927 930 def makedesc(repo, action, rev):
928 931 """build a initial action line for a ctx
929 932
930 933 line are in the form:
931 934
932 935 <action> <hash> <rev> <summary>
933 936 """
934 937 ctx = repo[rev]
935 938 summary = ''
936 939 if ctx.description():
937 940 summary = ctx.description().splitlines()[0]
938 941 line = '%s %s %d %s' % (action, ctx, ctx.rev(), summary)
939 942 # trim to 80 columns so it's not stupidly wide in my editor
940 943 maxlen = repo.ui.configint('histedit', 'linelen', default=80)
941 944 maxlen = max(maxlen, 22) # avoid truncating hash
942 945 return util.ellipsis(line, maxlen)
943 946
944 947 def ruleeditor(repo, ui, rules, editcomment=""):
945 948 """open an editor to edit rules
946 949
947 950 rules are in the format [ [act, ctx], ...] like in state.rules
948 951 """
949 952 rules = '\n'.join([makedesc(repo, act, rev) for [act, rev] in rules])
950 953 rules += '\n\n'
951 954 rules += editcomment
952 955 rules = ui.edit(rules, ui.username())
953 956
954 957 # Save edit rules in .hg/histedit-last-edit.txt in case
955 958 # the user needs to ask for help after something
956 959 # surprising happens.
957 960 f = open(repo.join('histedit-last-edit.txt'), 'w')
958 961 f.write(rules)
959 962 f.close()
960 963
961 964 return rules
962 965
963 966 def verifyrules(rules, repo, ctxs):
964 967 """Verify that there exists exactly one edit rule per given changeset.
965 968
966 969 Will abort if there are to many or too few rules, a malformed rule,
967 970 or a rule on a changeset outside of the user-given range.
968 971 """
969 972 parsed = []
970 973 expected = set(c.hex() for c in ctxs)
971 974 seen = set()
972 975 for r in rules:
973 976 if ' ' not in r:
974 977 raise util.Abort(_('malformed line "%s"') % r)
975 978 action, rest = r.split(' ', 1)
976 979 ha = rest.strip().split(' ', 1)[0]
977 980 try:
978 981 ha = repo[ha].hex()
979 982 except error.RepoError:
980 983 raise util.Abort(_('unknown changeset %s listed') % ha[:12])
981 984 if ha not in expected:
982 985 raise util.Abort(
983 986 _('may not use changesets other than the ones listed'))
984 987 if ha in seen:
985 988 raise util.Abort(_('duplicated command for changeset %s') %
986 989 ha[:12])
987 990 seen.add(ha)
988 991 if action not in actiontable:
989 992 raise util.Abort(_('unknown action "%s"') % action)
990 993 parsed.append([action, ha])
991 994 missing = sorted(expected - seen) # sort to stabilize output
992 995 if missing:
993 996 raise util.Abort(_('missing rules for changeset %s') %
994 997 missing[0][:12],
995 998 hint=_('do you want to use the drop action?'))
996 999 return parsed
997 1000
998 1001 def processreplacement(state):
999 1002 """process the list of replacements to return
1000 1003
1001 1004 1) the final mapping between original and created nodes
1002 1005 2) the list of temporary node created by histedit
1003 1006 3) the list of new commit created by histedit"""
1004 1007 replacements = state.replacements
1005 1008 allsuccs = set()
1006 1009 replaced = set()
1007 1010 fullmapping = {}
1008 1011 # initialise basic set
1009 1012 # fullmapping record all operation recorded in replacement
1010 1013 for rep in replacements:
1011 1014 allsuccs.update(rep[1])
1012 1015 replaced.add(rep[0])
1013 1016 fullmapping.setdefault(rep[0], set()).update(rep[1])
1014 1017 new = allsuccs - replaced
1015 1018 tmpnodes = allsuccs & replaced
1016 1019 # Reduce content fullmapping into direct relation between original nodes
1017 1020 # and final node created during history edition
1018 1021 # Dropped changeset are replaced by an empty list
1019 1022 toproceed = set(fullmapping)
1020 1023 final = {}
1021 1024 while toproceed:
1022 1025 for x in list(toproceed):
1023 1026 succs = fullmapping[x]
1024 1027 for s in list(succs):
1025 1028 if s in toproceed:
1026 1029 # non final node with unknown closure
1027 1030 # We can't process this now
1028 1031 break
1029 1032 elif s in final:
1030 1033 # non final node, replace with closure
1031 1034 succs.remove(s)
1032 1035 succs.update(final[s])
1033 1036 else:
1034 1037 final[x] = succs
1035 1038 toproceed.remove(x)
1036 1039 # remove tmpnodes from final mapping
1037 1040 for n in tmpnodes:
1038 1041 del final[n]
1039 1042 # we expect all changes involved in final to exist in the repo
1040 1043 # turn `final` into list (topologically sorted)
1041 1044 nm = state.repo.changelog.nodemap
1042 1045 for prec, succs in final.items():
1043 1046 final[prec] = sorted(succs, key=nm.get)
1044 1047
1045 1048 # computed topmost element (necessary for bookmark)
1046 1049 if new:
1047 1050 newtopmost = sorted(new, key=state.repo.changelog.rev)[-1]
1048 1051 elif not final:
1049 1052 # Nothing rewritten at all. we won't need `newtopmost`
1050 1053 # It is the same as `oldtopmost` and `processreplacement` know it
1051 1054 newtopmost = None
1052 1055 else:
1053 1056 # every body died. The newtopmost is the parent of the root.
1054 1057 r = state.repo.changelog.rev
1055 1058 newtopmost = state.repo[sorted(final, key=r)[0]].p1().node()
1056 1059
1057 1060 return final, tmpnodes, new, newtopmost
1058 1061
1059 1062 def movebookmarks(ui, repo, mapping, oldtopmost, newtopmost):
1060 1063 """Move bookmark from old to newly created node"""
1061 1064 if not mapping:
1062 1065 # if nothing got rewritten there is not purpose for this function
1063 1066 return
1064 1067 moves = []
1065 1068 for bk, old in sorted(repo._bookmarks.iteritems()):
1066 1069 if old == oldtopmost:
1067 1070 # special case ensure bookmark stay on tip.
1068 1071 #
1069 1072 # This is arguably a feature and we may only want that for the
1070 1073 # active bookmark. But the behavior is kept compatible with the old
1071 1074 # version for now.
1072 1075 moves.append((bk, newtopmost))
1073 1076 continue
1074 1077 base = old
1075 1078 new = mapping.get(base, None)
1076 1079 if new is None:
1077 1080 continue
1078 1081 while not new:
1079 1082 # base is killed, trying with parent
1080 1083 base = repo[base].p1().node()
1081 1084 new = mapping.get(base, (base,))
1082 1085 # nothing to move
1083 1086 moves.append((bk, new[-1]))
1084 1087 if moves:
1085 1088 marks = repo._bookmarks
1086 1089 for mark, new in moves:
1087 1090 old = marks[mark]
1088 1091 ui.note(_('histedit: moving bookmarks %s from %s to %s\n')
1089 1092 % (mark, node.short(old), node.short(new)))
1090 1093 marks[mark] = new
1091 1094 marks.write()
1092 1095
1093 1096 def cleanupnode(ui, repo, name, nodes):
1094 1097 """strip a group of nodes from the repository
1095 1098
1096 1099 The set of node to strip may contains unknown nodes."""
1097 1100 ui.debug('should strip %s nodes %s\n' %
1098 1101 (name, ', '.join([node.short(n) for n in nodes])))
1099 1102 lock = None
1100 1103 try:
1101 1104 lock = repo.lock()
1102 1105 # Find all node that need to be stripped
1103 1106 # (we hg %lr instead of %ln to silently ignore unknown item
1104 1107 nm = repo.changelog.nodemap
1105 1108 nodes = sorted(n for n in nodes if n in nm)
1106 1109 roots = [c.node() for c in repo.set("roots(%ln)", nodes)]
1107 1110 for c in roots:
1108 1111 # We should process node in reverse order to strip tip most first.
1109 1112 # but this trigger a bug in changegroup hook.
1110 1113 # This would reduce bundle overhead
1111 1114 repair.strip(ui, repo, c)
1112 1115 finally:
1113 1116 release(lock)
1114 1117
1115 1118 def stripwrapper(orig, ui, repo, nodelist, *args, **kwargs):
1116 1119 if isinstance(nodelist, str):
1117 1120 nodelist = [nodelist]
1118 1121 if os.path.exists(os.path.join(repo.path, 'histedit-state')):
1119 1122 state = histeditstate(repo)
1120 1123 state.read()
1121 1124 histedit_nodes = set([repo[rulehash].node() for (action, rulehash)
1122 1125 in state.rules if rulehash in repo])
1123 1126 strip_nodes = set([repo[n].node() for n in nodelist])
1124 1127 common_nodes = histedit_nodes & strip_nodes
1125 1128 if common_nodes:
1126 1129 raise util.Abort(_("histedit in progress, can't strip %s")
1127 1130 % ', '.join(node.short(x) for x in common_nodes))
1128 1131 return orig(ui, repo, nodelist, *args, **kwargs)
1129 1132
1130 1133 extensions.wrapfunction(repair, 'strip', stripwrapper)
1131 1134
1132 1135 def summaryhook(ui, repo):
1133 1136 if not os.path.exists(repo.join('histedit-state')):
1134 1137 return
1135 1138 state = histeditstate(repo)
1136 1139 state.read()
1137 1140 if state.rules:
1138 1141 # i18n: column positioning for "hg summary"
1139 1142 ui.write(_('hist: %s (histedit --continue)\n') %
1140 1143 (ui.label(_('%d remaining'), 'histedit.remaining') %
1141 1144 len(state.rules)))
1142 1145
1143 1146 def extsetup(ui):
1144 1147 cmdutil.summaryhooks.add('histedit', summaryhook)
1145 1148 cmdutil.unfinishedstates.append(
1146 1149 ['histedit-state', False, True, _('histedit in progress'),
1147 1150 _("use 'hg histedit --continue' or 'hg histedit --abort'")])
General Comments 0
You need to be logged in to leave comments. Login now