##// END OF EJS Templates
histedit: Speed up scrolling in patch view mode...
feyu@google.com -
r42419:c4a50e86 default
parent child Browse files
Show More
@@ -1,2317 +1,2320
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 and date
40 40 # d, drop = remove commit from history
41 41 # m, mess = edit commit message without changing commit content
42 42 # b, base = checkout changeset and apply further changesets from there
43 43 #
44 44
45 45 In this file, lines beginning with ``#`` are ignored. You must specify a rule
46 46 for each revision in your history. For example, if you had meant to add gamma
47 47 before beta, and then wanted to add delta in the same revision as beta, you
48 48 would reorganize the file to look like this::
49 49
50 50 pick 030b686bedc4 Add gamma
51 51 pick c561b4e977df Add beta
52 52 fold 7c2fd3b9020c Add delta
53 53
54 54 # Edit history between c561b4e977df and 7c2fd3b9020c
55 55 #
56 56 # Commits are listed from least to most recent
57 57 #
58 58 # Commands:
59 59 # p, pick = use commit
60 60 # e, edit = use commit, but stop for amending
61 61 # f, fold = use commit, but combine it with the one above
62 62 # r, roll = like fold, but discard this commit's description and date
63 63 # d, drop = remove commit from history
64 64 # m, mess = edit commit message without changing commit content
65 65 # b, base = checkout changeset and apply further changesets from there
66 66 #
67 67
68 68 At which point you close the editor and ``histedit`` starts working. When you
69 69 specify a ``fold`` operation, ``histedit`` will open an editor when it folds
70 70 those revisions together, offering you a chance to clean up the commit message::
71 71
72 72 Add beta
73 73 ***
74 74 Add delta
75 75
76 76 Edit the commit message to your liking, then close the editor. The date used
77 77 for the commit will be the later of the two commits' dates. For this example,
78 78 let's assume that the commit message was changed to ``Add beta and delta.``
79 79 After histedit has run and had a chance to remove any old or temporary
80 80 revisions it needed, the history looks like this::
81 81
82 82 @ 2[tip] 989b4d060121 2009-04-27 18:04 -0500 durin42
83 83 | Add beta and delta.
84 84 |
85 85 o 1 081603921c3f 2009-04-27 18:04 -0500 durin42
86 86 | Add gamma
87 87 |
88 88 o 0 d8d2fcd0e319 2009-04-27 18:04 -0500 durin42
89 89 Add alpha
90 90
91 91 Note that ``histedit`` does *not* remove any revisions (even its own temporary
92 92 ones) until after it has completed all the editing operations, so it will
93 93 probably perform several strip operations when it's done. For the above example,
94 94 it had to run strip twice. Strip can be slow depending on a variety of factors,
95 95 so you might need to be a little patient. You can choose to keep the original
96 96 revisions by passing the ``--keep`` flag.
97 97
98 98 The ``edit`` operation will drop you back to a command prompt,
99 99 allowing you to edit files freely, or even use ``hg record`` to commit
100 100 some changes as a separate commit. When you're done, any remaining
101 101 uncommitted changes will be committed as well. When done, run ``hg
102 102 histedit --continue`` to finish this step. If there are uncommitted
103 103 changes, you'll be prompted for a new commit message, but the default
104 104 commit message will be the original message for the ``edit`` ed
105 105 revision, and the date of the original commit will be preserved.
106 106
107 107 The ``message`` operation will give you a chance to revise a commit
108 108 message without changing the contents. It's a shortcut for doing
109 109 ``edit`` immediately followed by `hg histedit --continue``.
110 110
111 111 If ``histedit`` encounters a conflict when moving a revision (while
112 112 handling ``pick`` or ``fold``), it'll stop in a similar manner to
113 113 ``edit`` with the difference that it won't prompt you for a commit
114 114 message when done. If you decide at this point that you don't like how
115 115 much work it will be to rearrange history, or that you made a mistake,
116 116 you can use ``hg histedit --abort`` to abandon the new changes you
117 117 have made and return to the state before you attempted to edit your
118 118 history.
119 119
120 120 If we clone the histedit-ed example repository above and add four more
121 121 changes, such that we have the following history::
122 122
123 123 @ 6[tip] 038383181893 2009-04-27 18:04 -0500 stefan
124 124 | Add theta
125 125 |
126 126 o 5 140988835471 2009-04-27 18:04 -0500 stefan
127 127 | Add eta
128 128 |
129 129 o 4 122930637314 2009-04-27 18:04 -0500 stefan
130 130 | Add zeta
131 131 |
132 132 o 3 836302820282 2009-04-27 18:04 -0500 stefan
133 133 | Add epsilon
134 134 |
135 135 o 2 989b4d060121 2009-04-27 18:04 -0500 durin42
136 136 | Add beta and delta.
137 137 |
138 138 o 1 081603921c3f 2009-04-27 18:04 -0500 durin42
139 139 | Add gamma
140 140 |
141 141 o 0 d8d2fcd0e319 2009-04-27 18:04 -0500 durin42
142 142 Add alpha
143 143
144 144 If you run ``hg histedit --outgoing`` on the clone then it is the same
145 145 as running ``hg histedit 836302820282``. If you need plan to push to a
146 146 repository that Mercurial does not detect to be related to the source
147 147 repo, you can add a ``--force`` option.
148 148
149 149 Config
150 150 ------
151 151
152 152 Histedit rule lines are truncated to 80 characters by default. You
153 153 can customize this behavior by setting a different length in your
154 154 configuration file::
155 155
156 156 [histedit]
157 157 linelen = 120 # truncate rule lines at 120 characters
158 158
159 159 The summary of a change can be customized as well::
160 160
161 161 [histedit]
162 162 summary-template = '{rev} {bookmarks} {desc|firstline}'
163 163
164 164 The customized summary should be kept short enough that rule lines
165 165 will fit in the configured line length. See above if that requires
166 166 customization.
167 167
168 168 ``hg histedit`` attempts to automatically choose an appropriate base
169 169 revision to use. To change which base revision is used, define a
170 170 revset in your configuration file::
171 171
172 172 [histedit]
173 173 defaultrev = only(.) & draft()
174 174
175 175 By default each edited revision needs to be present in histedit commands.
176 176 To remove revision you need to use ``drop`` operation. You can configure
177 177 the drop to be implicit for missing commits by adding::
178 178
179 179 [histedit]
180 180 dropmissing = True
181 181
182 182 By default, histedit will close the transaction after each action. For
183 183 performance purposes, you can configure histedit to use a single transaction
184 184 across the entire histedit. WARNING: This setting introduces a significant risk
185 185 of losing the work you've done in a histedit if the histedit aborts
186 186 unexpectedly::
187 187
188 188 [histedit]
189 189 singletransaction = True
190 190
191 191 """
192 192
193 193 from __future__ import absolute_import
194 194
195 195 # chistedit dependencies that are not available everywhere
196 196 try:
197 197 import fcntl
198 198 import termios
199 199 except ImportError:
200 200 fcntl = None
201 201 termios = None
202 202
203 203 import functools
204 204 import os
205 205 import struct
206 206
207 207 from mercurial.i18n import _
208 208 from mercurial import (
209 209 bundle2,
210 210 cmdutil,
211 211 context,
212 212 copies,
213 213 destutil,
214 214 discovery,
215 215 error,
216 216 exchange,
217 217 extensions,
218 218 hg,
219 219 logcmdutil,
220 220 merge as mergemod,
221 221 mergeutil,
222 222 node,
223 223 obsolete,
224 224 pycompat,
225 225 registrar,
226 226 repair,
227 227 scmutil,
228 228 state as statemod,
229 229 util,
230 230 )
231 231 from mercurial.utils import (
232 232 dateutil,
233 233 stringutil,
234 234 )
235 235
236 236 pickle = util.pickle
237 237 cmdtable = {}
238 238 command = registrar.command(cmdtable)
239 239
240 240 configtable = {}
241 241 configitem = registrar.configitem(configtable)
242 242 configitem('experimental', 'histedit.autoverb',
243 243 default=False,
244 244 )
245 245 configitem('histedit', 'defaultrev',
246 246 default=None,
247 247 )
248 248 configitem('histedit', 'dropmissing',
249 249 default=False,
250 250 )
251 251 configitem('histedit', 'linelen',
252 252 default=80,
253 253 )
254 254 configitem('histedit', 'singletransaction',
255 255 default=False,
256 256 )
257 257 configitem('ui', 'interface.histedit',
258 258 default=None,
259 259 )
260 260 configitem('histedit', 'summary-template',
261 261 default='{rev} {desc|firstline}')
262 262
263 263 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
264 264 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
265 265 # be specifying the version(s) of Mercurial they are tested with, or
266 266 # leave the attribute unspecified.
267 267 testedwith = 'ships-with-hg-core'
268 268
269 269 actiontable = {}
270 270 primaryactions = set()
271 271 secondaryactions = set()
272 272 tertiaryactions = set()
273 273 internalactions = set()
274 274
275 275 def geteditcomment(ui, first, last):
276 276 """ construct the editor comment
277 277 The comment includes::
278 278 - an intro
279 279 - sorted primary commands
280 280 - sorted short commands
281 281 - sorted long commands
282 282 - additional hints
283 283
284 284 Commands are only included once.
285 285 """
286 286 intro = _("""Edit history between %s and %s
287 287
288 288 Commits are listed from least to most recent
289 289
290 290 You can reorder changesets by reordering the lines
291 291
292 292 Commands:
293 293 """)
294 294 actions = []
295 295 def addverb(v):
296 296 a = actiontable[v]
297 297 lines = a.message.split("\n")
298 298 if len(a.verbs):
299 299 v = ', '.join(sorted(a.verbs, key=lambda v: len(v)))
300 300 actions.append(" %s = %s" % (v, lines[0]))
301 301 actions.extend([' %s' for l in lines[1:]])
302 302
303 303 for v in (
304 304 sorted(primaryactions) +
305 305 sorted(secondaryactions) +
306 306 sorted(tertiaryactions)
307 307 ):
308 308 addverb(v)
309 309 actions.append('')
310 310
311 311 hints = []
312 312 if ui.configbool('histedit', 'dropmissing'):
313 313 hints.append("Deleting a changeset from the list "
314 314 "will DISCARD it from the edited history!")
315 315
316 316 lines = (intro % (first, last)).split('\n') + actions + hints
317 317
318 318 return ''.join(['# %s\n' % l if l else '#\n' for l in lines])
319 319
320 320 class histeditstate(object):
321 321 def __init__(self, repo):
322 322 self.repo = repo
323 323 self.actions = None
324 324 self.keep = None
325 325 self.topmost = None
326 326 self.parentctxnode = None
327 327 self.lock = None
328 328 self.wlock = None
329 329 self.backupfile = None
330 330 self.stateobj = statemod.cmdstate(repo, 'histedit-state')
331 331 self.replacements = []
332 332
333 333 def read(self):
334 334 """Load histedit state from disk and set fields appropriately."""
335 335 if not self.stateobj.exists():
336 336 cmdutil.wrongtooltocontinue(self.repo, _('histedit'))
337 337
338 338 data = self._read()
339 339
340 340 self.parentctxnode = data['parentctxnode']
341 341 actions = parserules(data['rules'], self)
342 342 self.actions = actions
343 343 self.keep = data['keep']
344 344 self.topmost = data['topmost']
345 345 self.replacements = data['replacements']
346 346 self.backupfile = data['backupfile']
347 347
348 348 def _read(self):
349 349 fp = self.repo.vfs.read('histedit-state')
350 350 if fp.startswith('v1\n'):
351 351 data = self._load()
352 352 parentctxnode, rules, keep, topmost, replacements, backupfile = data
353 353 else:
354 354 data = pickle.loads(fp)
355 355 parentctxnode, rules, keep, topmost, replacements = data
356 356 backupfile = None
357 357 rules = "\n".join(["%s %s" % (verb, rest) for [verb, rest] in rules])
358 358
359 359 return {'parentctxnode': parentctxnode, "rules": rules, "keep": keep,
360 360 "topmost": topmost, "replacements": replacements,
361 361 "backupfile": backupfile}
362 362
363 363 def write(self, tr=None):
364 364 if tr:
365 365 tr.addfilegenerator('histedit-state', ('histedit-state',),
366 366 self._write, location='plain')
367 367 else:
368 368 with self.repo.vfs("histedit-state", "w") as f:
369 369 self._write(f)
370 370
371 371 def _write(self, fp):
372 372 fp.write('v1\n')
373 373 fp.write('%s\n' % node.hex(self.parentctxnode))
374 374 fp.write('%s\n' % node.hex(self.topmost))
375 375 fp.write('%s\n' % ('True' if self.keep else 'False'))
376 376 fp.write('%d\n' % len(self.actions))
377 377 for action in self.actions:
378 378 fp.write('%s\n' % action.tostate())
379 379 fp.write('%d\n' % len(self.replacements))
380 380 for replacement in self.replacements:
381 381 fp.write('%s%s\n' % (node.hex(replacement[0]), ''.join(node.hex(r)
382 382 for r in replacement[1])))
383 383 backupfile = self.backupfile
384 384 if not backupfile:
385 385 backupfile = ''
386 386 fp.write('%s\n' % backupfile)
387 387
388 388 def _load(self):
389 389 fp = self.repo.vfs('histedit-state', 'r')
390 390 lines = [l[:-1] for l in fp.readlines()]
391 391
392 392 index = 0
393 393 lines[index] # version number
394 394 index += 1
395 395
396 396 parentctxnode = node.bin(lines[index])
397 397 index += 1
398 398
399 399 topmost = node.bin(lines[index])
400 400 index += 1
401 401
402 402 keep = lines[index] == 'True'
403 403 index += 1
404 404
405 405 # Rules
406 406 rules = []
407 407 rulelen = int(lines[index])
408 408 index += 1
409 409 for i in pycompat.xrange(rulelen):
410 410 ruleaction = lines[index]
411 411 index += 1
412 412 rule = lines[index]
413 413 index += 1
414 414 rules.append((ruleaction, rule))
415 415
416 416 # Replacements
417 417 replacements = []
418 418 replacementlen = int(lines[index])
419 419 index += 1
420 420 for i in pycompat.xrange(replacementlen):
421 421 replacement = lines[index]
422 422 original = node.bin(replacement[:40])
423 423 succ = [node.bin(replacement[i:i + 40]) for i in
424 424 range(40, len(replacement), 40)]
425 425 replacements.append((original, succ))
426 426 index += 1
427 427
428 428 backupfile = lines[index]
429 429 index += 1
430 430
431 431 fp.close()
432 432
433 433 return parentctxnode, rules, keep, topmost, replacements, backupfile
434 434
435 435 def clear(self):
436 436 if self.inprogress():
437 437 self.repo.vfs.unlink('histedit-state')
438 438
439 439 def inprogress(self):
440 440 return self.repo.vfs.exists('histedit-state')
441 441
442 442
443 443 class histeditaction(object):
444 444 def __init__(self, state, node):
445 445 self.state = state
446 446 self.repo = state.repo
447 447 self.node = node
448 448
449 449 @classmethod
450 450 def fromrule(cls, state, rule):
451 451 """Parses the given rule, returning an instance of the histeditaction.
452 452 """
453 453 ruleid = rule.strip().split(' ', 1)[0]
454 454 # ruleid can be anything from rev numbers, hashes, "bookmarks" etc
455 455 # Check for validation of rule ids and get the rulehash
456 456 try:
457 457 rev = node.bin(ruleid)
458 458 except TypeError:
459 459 try:
460 460 _ctx = scmutil.revsingle(state.repo, ruleid)
461 461 rulehash = _ctx.hex()
462 462 rev = node.bin(rulehash)
463 463 except error.RepoLookupError:
464 464 raise error.ParseError(_("invalid changeset %s") % ruleid)
465 465 return cls(state, rev)
466 466
467 467 def verify(self, prev, expected, seen):
468 468 """ Verifies semantic correctness of the rule"""
469 469 repo = self.repo
470 470 ha = node.hex(self.node)
471 471 self.node = scmutil.resolvehexnodeidprefix(repo, ha)
472 472 if self.node is None:
473 473 raise error.ParseError(_('unknown changeset %s listed') % ha[:12])
474 474 self._verifynodeconstraints(prev, expected, seen)
475 475
476 476 def _verifynodeconstraints(self, prev, expected, seen):
477 477 # by default command need a node in the edited list
478 478 if self.node not in expected:
479 479 raise error.ParseError(_('%s "%s" changeset was not a candidate')
480 480 % (self.verb, node.short(self.node)),
481 481 hint=_('only use listed changesets'))
482 482 # and only one command per node
483 483 if self.node in seen:
484 484 raise error.ParseError(_('duplicated command for changeset %s') %
485 485 node.short(self.node))
486 486
487 487 def torule(self):
488 488 """build a histedit rule line for an action
489 489
490 490 by default lines are in the form:
491 491 <hash> <rev> <summary>
492 492 """
493 493 ctx = self.repo[self.node]
494 494 ui = self.repo.ui
495 495 summary = cmdutil.rendertemplate(
496 496 ctx, ui.config('histedit', 'summary-template')) or ''
497 497 summary = summary.splitlines()[0]
498 498 line = '%s %s %s' % (self.verb, ctx, summary)
499 499 # trim to 75 columns by default so it's not stupidly wide in my editor
500 500 # (the 5 more are left for verb)
501 501 maxlen = self.repo.ui.configint('histedit', 'linelen')
502 502 maxlen = max(maxlen, 22) # avoid truncating hash
503 503 return stringutil.ellipsis(line, maxlen)
504 504
505 505 def tostate(self):
506 506 """Print an action in format used by histedit state files
507 507 (the first line is a verb, the remainder is the second)
508 508 """
509 509 return "%s\n%s" % (self.verb, node.hex(self.node))
510 510
511 511 def run(self):
512 512 """Runs the action. The default behavior is simply apply the action's
513 513 rulectx onto the current parentctx."""
514 514 self.applychange()
515 515 self.continuedirty()
516 516 return self.continueclean()
517 517
518 518 def applychange(self):
519 519 """Applies the changes from this action's rulectx onto the current
520 520 parentctx, but does not commit them."""
521 521 repo = self.repo
522 522 rulectx = repo[self.node]
523 523 repo.ui.pushbuffer(error=True, labeled=True)
524 524 hg.update(repo, self.state.parentctxnode, quietempty=True)
525 525 repo.ui.popbuffer()
526 526 stats = applychanges(repo.ui, repo, rulectx, {})
527 527 repo.dirstate.setbranch(rulectx.branch())
528 528 if stats.unresolvedcount:
529 529 raise error.InterventionRequired(
530 530 _('Fix up the change (%s %s)') %
531 531 (self.verb, node.short(self.node)),
532 532 hint=_('hg histedit --continue to resume'))
533 533
534 534 def continuedirty(self):
535 535 """Continues the action when changes have been applied to the working
536 536 copy. The default behavior is to commit the dirty changes."""
537 537 repo = self.repo
538 538 rulectx = repo[self.node]
539 539
540 540 editor = self.commiteditor()
541 541 commit = commitfuncfor(repo, rulectx)
542 542 if repo.ui.configbool('rewrite', 'update-timestamp'):
543 543 date = dateutil.makedate()
544 544 else:
545 545 date = rulectx.date()
546 546 commit(text=rulectx.description(), user=rulectx.user(),
547 547 date=date, extra=rulectx.extra(), editor=editor)
548 548
549 549 def commiteditor(self):
550 550 """The editor to be used to edit the commit message."""
551 551 return False
552 552
553 553 def continueclean(self):
554 554 """Continues the action when the working copy is clean. The default
555 555 behavior is to accept the current commit as the new version of the
556 556 rulectx."""
557 557 ctx = self.repo['.']
558 558 if ctx.node() == self.state.parentctxnode:
559 559 self.repo.ui.warn(_('%s: skipping changeset (no changes)\n') %
560 560 node.short(self.node))
561 561 return ctx, [(self.node, tuple())]
562 562 if ctx.node() == self.node:
563 563 # Nothing changed
564 564 return ctx, []
565 565 return ctx, [(self.node, (ctx.node(),))]
566 566
567 567 def commitfuncfor(repo, src):
568 568 """Build a commit function for the replacement of <src>
569 569
570 570 This function ensure we apply the same treatment to all changesets.
571 571
572 572 - Add a 'histedit_source' entry in extra.
573 573
574 574 Note that fold has its own separated logic because its handling is a bit
575 575 different and not easily factored out of the fold method.
576 576 """
577 577 phasemin = src.phase()
578 578 def commitfunc(**kwargs):
579 579 overrides = {('phases', 'new-commit'): phasemin}
580 580 with repo.ui.configoverride(overrides, 'histedit'):
581 581 extra = kwargs.get(r'extra', {}).copy()
582 582 extra['histedit_source'] = src.hex()
583 583 kwargs[r'extra'] = extra
584 584 return repo.commit(**kwargs)
585 585 return commitfunc
586 586
587 587 def applychanges(ui, repo, ctx, opts):
588 588 """Merge changeset from ctx (only) in the current working directory"""
589 589 wcpar = repo.dirstate.p1()
590 590 if ctx.p1().node() == wcpar:
591 591 # edits are "in place" we do not need to make any merge,
592 592 # just applies changes on parent for editing
593 593 ui.pushbuffer()
594 594 cmdutil.revert(ui, repo, ctx, (wcpar, node.nullid), all=True)
595 595 stats = mergemod.updateresult(0, 0, 0, 0)
596 596 ui.popbuffer()
597 597 else:
598 598 try:
599 599 # ui.forcemerge is an internal variable, do not document
600 600 repo.ui.setconfig('ui', 'forcemerge', opts.get('tool', ''),
601 601 'histedit')
602 602 stats = mergemod.graft(repo, ctx, ctx.p1(), ['local', 'histedit'])
603 603 finally:
604 604 repo.ui.setconfig('ui', 'forcemerge', '', 'histedit')
605 605 return stats
606 606
607 607 def collapse(repo, firstctx, lastctx, commitopts, skipprompt=False):
608 608 """collapse the set of revisions from first to last as new one.
609 609
610 610 Expected commit options are:
611 611 - message
612 612 - date
613 613 - username
614 614 Commit message is edited in all cases.
615 615
616 616 This function works in memory."""
617 617 ctxs = list(repo.set('%d::%d', firstctx.rev(), lastctx.rev()))
618 618 if not ctxs:
619 619 return None
620 620 for c in ctxs:
621 621 if not c.mutable():
622 622 raise error.ParseError(
623 623 _("cannot fold into public change %s") % node.short(c.node()))
624 624 base = firstctx.p1()
625 625
626 626 # commit a new version of the old changeset, including the update
627 627 # collect all files which might be affected
628 628 files = set()
629 629 for ctx in ctxs:
630 630 files.update(ctx.files())
631 631
632 632 # Recompute copies (avoid recording a -> b -> a)
633 633 copied = copies.pathcopies(base, lastctx)
634 634
635 635 # prune files which were reverted by the updates
636 636 files = [f for f in files if not cmdutil.samefile(f, lastctx, base)]
637 637 # commit version of these files as defined by head
638 638 headmf = lastctx.manifest()
639 639 def filectxfn(repo, ctx, path):
640 640 if path in headmf:
641 641 fctx = lastctx[path]
642 642 flags = fctx.flags()
643 643 mctx = context.memfilectx(repo, ctx,
644 644 fctx.path(), fctx.data(),
645 645 islink='l' in flags,
646 646 isexec='x' in flags,
647 647 copysource=copied.get(path))
648 648 return mctx
649 649 return None
650 650
651 651 if commitopts.get('message'):
652 652 message = commitopts['message']
653 653 else:
654 654 message = firstctx.description()
655 655 user = commitopts.get('user')
656 656 date = commitopts.get('date')
657 657 extra = commitopts.get('extra')
658 658
659 659 parents = (firstctx.p1().node(), firstctx.p2().node())
660 660 editor = None
661 661 if not skipprompt:
662 662 editor = cmdutil.getcommiteditor(edit=True, editform='histedit.fold')
663 663 new = context.memctx(repo,
664 664 parents=parents,
665 665 text=message,
666 666 files=files,
667 667 filectxfn=filectxfn,
668 668 user=user,
669 669 date=date,
670 670 extra=extra,
671 671 editor=editor)
672 672 return repo.commitctx(new)
673 673
674 674 def _isdirtywc(repo):
675 675 return repo[None].dirty(missing=True)
676 676
677 677 def abortdirty():
678 678 raise error.Abort(_('working copy has pending changes'),
679 679 hint=_('amend, commit, or revert them and run histedit '
680 680 '--continue, or abort with histedit --abort'))
681 681
682 682 def action(verbs, message, priority=False, internal=False):
683 683 def wrap(cls):
684 684 assert not priority or not internal
685 685 verb = verbs[0]
686 686 if priority:
687 687 primaryactions.add(verb)
688 688 elif internal:
689 689 internalactions.add(verb)
690 690 elif len(verbs) > 1:
691 691 secondaryactions.add(verb)
692 692 else:
693 693 tertiaryactions.add(verb)
694 694
695 695 cls.verb = verb
696 696 cls.verbs = verbs
697 697 cls.message = message
698 698 for verb in verbs:
699 699 actiontable[verb] = cls
700 700 return cls
701 701 return wrap
702 702
703 703 @action(['pick', 'p'],
704 704 _('use commit'),
705 705 priority=True)
706 706 class pick(histeditaction):
707 707 def run(self):
708 708 rulectx = self.repo[self.node]
709 709 if rulectx.p1().node() == self.state.parentctxnode:
710 710 self.repo.ui.debug('node %s unchanged\n' % node.short(self.node))
711 711 return rulectx, []
712 712
713 713 return super(pick, self).run()
714 714
715 715 @action(['edit', 'e'],
716 716 _('use commit, but stop for amending'),
717 717 priority=True)
718 718 class edit(histeditaction):
719 719 def run(self):
720 720 repo = self.repo
721 721 rulectx = repo[self.node]
722 722 hg.update(repo, self.state.parentctxnode, quietempty=True)
723 723 applychanges(repo.ui, repo, rulectx, {})
724 724 raise error.InterventionRequired(
725 725 _('Editing (%s), you may commit or record as needed now.')
726 726 % node.short(self.node),
727 727 hint=_('hg histedit --continue to resume'))
728 728
729 729 def commiteditor(self):
730 730 return cmdutil.getcommiteditor(edit=True, editform='histedit.edit')
731 731
732 732 @action(['fold', 'f'],
733 733 _('use commit, but combine it with the one above'))
734 734 class fold(histeditaction):
735 735 def verify(self, prev, expected, seen):
736 736 """ Verifies semantic correctness of the fold rule"""
737 737 super(fold, self).verify(prev, expected, seen)
738 738 repo = self.repo
739 739 if not prev:
740 740 c = repo[self.node].p1()
741 741 elif not prev.verb in ('pick', 'base'):
742 742 return
743 743 else:
744 744 c = repo[prev.node]
745 745 if not c.mutable():
746 746 raise error.ParseError(
747 747 _("cannot fold into public change %s") % node.short(c.node()))
748 748
749 749
750 750 def continuedirty(self):
751 751 repo = self.repo
752 752 rulectx = repo[self.node]
753 753
754 754 commit = commitfuncfor(repo, rulectx)
755 755 commit(text='fold-temp-revision %s' % node.short(self.node),
756 756 user=rulectx.user(), date=rulectx.date(),
757 757 extra=rulectx.extra())
758 758
759 759 def continueclean(self):
760 760 repo = self.repo
761 761 ctx = repo['.']
762 762 rulectx = repo[self.node]
763 763 parentctxnode = self.state.parentctxnode
764 764 if ctx.node() == parentctxnode:
765 765 repo.ui.warn(_('%s: empty changeset\n') %
766 766 node.short(self.node))
767 767 return ctx, [(self.node, (parentctxnode,))]
768 768
769 769 parentctx = repo[parentctxnode]
770 770 newcommits = set(c.node() for c in repo.set('(%d::. - %d)',
771 771 parentctx.rev(),
772 772 parentctx.rev()))
773 773 if not newcommits:
774 774 repo.ui.warn(_('%s: cannot fold - working copy is not a '
775 775 'descendant of previous commit %s\n') %
776 776 (node.short(self.node), node.short(parentctxnode)))
777 777 return ctx, [(self.node, (ctx.node(),))]
778 778
779 779 middlecommits = newcommits.copy()
780 780 middlecommits.discard(ctx.node())
781 781
782 782 return self.finishfold(repo.ui, repo, parentctx, rulectx, ctx.node(),
783 783 middlecommits)
784 784
785 785 def skipprompt(self):
786 786 """Returns true if the rule should skip the message editor.
787 787
788 788 For example, 'fold' wants to show an editor, but 'rollup'
789 789 doesn't want to.
790 790 """
791 791 return False
792 792
793 793 def mergedescs(self):
794 794 """Returns true if the rule should merge messages of multiple changes.
795 795
796 796 This exists mainly so that 'rollup' rules can be a subclass of
797 797 'fold'.
798 798 """
799 799 return True
800 800
801 801 def firstdate(self):
802 802 """Returns true if the rule should preserve the date of the first
803 803 change.
804 804
805 805 This exists mainly so that 'rollup' rules can be a subclass of
806 806 'fold'.
807 807 """
808 808 return False
809 809
810 810 def finishfold(self, ui, repo, ctx, oldctx, newnode, internalchanges):
811 811 parent = ctx.p1().node()
812 812 hg.updaterepo(repo, parent, overwrite=False)
813 813 ### prepare new commit data
814 814 commitopts = {}
815 815 commitopts['user'] = ctx.user()
816 816 # commit message
817 817 if not self.mergedescs():
818 818 newmessage = ctx.description()
819 819 else:
820 820 newmessage = '\n***\n'.join(
821 821 [ctx.description()] +
822 822 [repo[r].description() for r in internalchanges] +
823 823 [oldctx.description()]) + '\n'
824 824 commitopts['message'] = newmessage
825 825 # date
826 826 if self.firstdate():
827 827 commitopts['date'] = ctx.date()
828 828 else:
829 829 commitopts['date'] = max(ctx.date(), oldctx.date())
830 830 # if date is to be updated to current
831 831 if ui.configbool('rewrite', 'update-timestamp'):
832 832 commitopts['date'] = dateutil.makedate()
833 833
834 834 extra = ctx.extra().copy()
835 835 # histedit_source
836 836 # note: ctx is likely a temporary commit but that the best we can do
837 837 # here. This is sufficient to solve issue3681 anyway.
838 838 extra['histedit_source'] = '%s,%s' % (ctx.hex(), oldctx.hex())
839 839 commitopts['extra'] = extra
840 840 phasemin = max(ctx.phase(), oldctx.phase())
841 841 overrides = {('phases', 'new-commit'): phasemin}
842 842 with repo.ui.configoverride(overrides, 'histedit'):
843 843 n = collapse(repo, ctx, repo[newnode], commitopts,
844 844 skipprompt=self.skipprompt())
845 845 if n is None:
846 846 return ctx, []
847 847 hg.updaterepo(repo, n, overwrite=False)
848 848 replacements = [(oldctx.node(), (newnode,)),
849 849 (ctx.node(), (n,)),
850 850 (newnode, (n,)),
851 851 ]
852 852 for ich in internalchanges:
853 853 replacements.append((ich, (n,)))
854 854 return repo[n], replacements
855 855
856 856 @action(['base', 'b'],
857 857 _('checkout changeset and apply further changesets from there'))
858 858 class base(histeditaction):
859 859
860 860 def run(self):
861 861 if self.repo['.'].node() != self.node:
862 862 mergemod.update(self.repo, self.node, branchmerge=False, force=True)
863 863 return self.continueclean()
864 864
865 865 def continuedirty(self):
866 866 abortdirty()
867 867
868 868 def continueclean(self):
869 869 basectx = self.repo['.']
870 870 return basectx, []
871 871
872 872 def _verifynodeconstraints(self, prev, expected, seen):
873 873 # base can only be use with a node not in the edited set
874 874 if self.node in expected:
875 875 msg = _('%s "%s" changeset was an edited list candidate')
876 876 raise error.ParseError(
877 877 msg % (self.verb, node.short(self.node)),
878 878 hint=_('base must only use unlisted changesets'))
879 879
880 880 @action(['_multifold'],
881 881 _(
882 882 """fold subclass used for when multiple folds happen in a row
883 883
884 884 We only want to fire the editor for the folded message once when
885 885 (say) four changes are folded down into a single change. This is
886 886 similar to rollup, but we should preserve both messages so that
887 887 when the last fold operation runs we can show the user all the
888 888 commit messages in their editor.
889 889 """),
890 890 internal=True)
891 891 class _multifold(fold):
892 892 def skipprompt(self):
893 893 return True
894 894
895 895 @action(["roll", "r"],
896 896 _("like fold, but discard this commit's description and date"))
897 897 class rollup(fold):
898 898 def mergedescs(self):
899 899 return False
900 900
901 901 def skipprompt(self):
902 902 return True
903 903
904 904 def firstdate(self):
905 905 return True
906 906
907 907 @action(["drop", "d"],
908 908 _('remove commit from history'))
909 909 class drop(histeditaction):
910 910 def run(self):
911 911 parentctx = self.repo[self.state.parentctxnode]
912 912 return parentctx, [(self.node, tuple())]
913 913
914 914 @action(["mess", "m"],
915 915 _('edit commit message without changing commit content'),
916 916 priority=True)
917 917 class message(histeditaction):
918 918 def commiteditor(self):
919 919 return cmdutil.getcommiteditor(edit=True, editform='histedit.mess')
920 920
921 921 def findoutgoing(ui, repo, remote=None, force=False, opts=None):
922 922 """utility function to find the first outgoing changeset
923 923
924 924 Used by initialization code"""
925 925 if opts is None:
926 926 opts = {}
927 927 dest = ui.expandpath(remote or 'default-push', remote or 'default')
928 928 dest, branches = hg.parseurl(dest, None)[:2]
929 929 ui.status(_('comparing with %s\n') % util.hidepassword(dest))
930 930
931 931 revs, checkout = hg.addbranchrevs(repo, repo, branches, None)
932 932 other = hg.peer(repo, opts, dest)
933 933
934 934 if revs:
935 935 revs = [repo.lookup(rev) for rev in revs]
936 936
937 937 outgoing = discovery.findcommonoutgoing(repo, other, revs, force=force)
938 938 if not outgoing.missing:
939 939 raise error.Abort(_('no outgoing ancestors'))
940 940 roots = list(repo.revs("roots(%ln)", outgoing.missing))
941 941 if len(roots) > 1:
942 942 msg = _('there are ambiguous outgoing revisions')
943 943 hint = _("see 'hg help histedit' for more detail")
944 944 raise error.Abort(msg, hint=hint)
945 945 return repo[roots[0]].node()
946 946
947 947 # Curses Support
948 948 try:
949 949 import curses
950 950
951 951 # Curses requires setting the locale or it will default to the C
952 952 # locale. This sets the locale to the user's default system
953 953 # locale.
954 954 import locale
955 955 locale.setlocale(locale.LC_ALL, r'')
956 956 except ImportError:
957 957 curses = None
958 958
959 959 KEY_LIST = ['pick', 'edit', 'fold', 'drop', 'mess', 'roll']
960 960 ACTION_LABELS = {
961 961 'fold': '^fold',
962 962 'roll': '^roll',
963 963 }
964 964
965 965 COLOR_HELP, COLOR_SELECTED, COLOR_OK, COLOR_WARN, COLOR_CURRENT = 1, 2, 3, 4, 5
966 966 COLOR_DIFF_ADD_LINE, COLOR_DIFF_DEL_LINE, COLOR_DIFF_OFFSET = 6, 7, 8
967 967
968 968 E_QUIT, E_HISTEDIT = 1, 2
969 969 E_PAGEDOWN, E_PAGEUP, E_LINEUP, E_LINEDOWN, E_RESIZE = 3, 4, 5, 6, 7
970 970 MODE_INIT, MODE_PATCH, MODE_RULES, MODE_HELP = 0, 1, 2, 3
971 971
972 972 KEYTABLE = {
973 973 'global': {
974 974 'h': 'next-action',
975 975 'KEY_RIGHT': 'next-action',
976 976 'l': 'prev-action',
977 977 'KEY_LEFT': 'prev-action',
978 978 'q': 'quit',
979 979 'c': 'histedit',
980 980 'C': 'histedit',
981 981 'v': 'showpatch',
982 982 '?': 'help',
983 983 },
984 984 MODE_RULES: {
985 985 'd': 'action-drop',
986 986 'e': 'action-edit',
987 987 'f': 'action-fold',
988 988 'm': 'action-mess',
989 989 'p': 'action-pick',
990 990 'r': 'action-roll',
991 991 ' ': 'select',
992 992 'j': 'down',
993 993 'k': 'up',
994 994 'KEY_DOWN': 'down',
995 995 'KEY_UP': 'up',
996 996 'J': 'move-down',
997 997 'K': 'move-up',
998 998 'KEY_NPAGE': 'move-down',
999 999 'KEY_PPAGE': 'move-up',
1000 1000 '0': 'goto', # Used for 0..9
1001 1001 },
1002 1002 MODE_PATCH: {
1003 1003 ' ': 'page-down',
1004 1004 'KEY_NPAGE': 'page-down',
1005 1005 'KEY_PPAGE': 'page-up',
1006 1006 'j': 'line-down',
1007 1007 'k': 'line-up',
1008 1008 'KEY_DOWN': 'line-down',
1009 1009 'KEY_UP': 'line-up',
1010 1010 'J': 'down',
1011 1011 'K': 'up',
1012 1012 },
1013 1013 MODE_HELP: {
1014 1014 },
1015 1015 }
1016 1016
1017 1017 def screen_size():
1018 1018 return struct.unpack('hh', fcntl.ioctl(1, termios.TIOCGWINSZ, ' '))
1019 1019
1020 1020 class histeditrule(object):
1021 1021 def __init__(self, ctx, pos, action='pick'):
1022 1022 self.ctx = ctx
1023 1023 self.action = action
1024 1024 self.origpos = pos
1025 1025 self.pos = pos
1026 1026 self.conflicts = []
1027 1027
1028 1028 def __str__(self):
1029 1029 # Some actions ('fold' and 'roll') combine a patch with a previous one.
1030 1030 # Add a marker showing which patch they apply to, and also omit the
1031 1031 # description for 'roll' (since it will get discarded). Example display:
1032 1032 #
1033 1033 # #10 pick 316392:06a16c25c053 add option to skip tests
1034 1034 # #11 ^roll 316393:71313c964cc5
1035 1035 # #12 pick 316394:ab31f3973b0d include mfbt for mozilla-config.h
1036 1036 # #13 ^fold 316395:14ce5803f4c3 fix warnings
1037 1037 #
1038 1038 # The carets point to the changeset being folded into ("roll this
1039 1039 # changeset into the changeset above").
1040 1040 action = ACTION_LABELS.get(self.action, self.action)
1041 1041 h = self.ctx.hex()[0:12]
1042 1042 r = self.ctx.rev()
1043 1043 desc = self.ctx.description().splitlines()[0].strip()
1044 1044 if self.action == 'roll':
1045 1045 desc = ''
1046 1046 return "#{0:<2} {1:<6} {2}:{3} {4}".format(
1047 1047 self.origpos, action, r, h, desc)
1048 1048
1049 1049 def checkconflicts(self, other):
1050 1050 if other.pos > self.pos and other.origpos <= self.origpos:
1051 1051 if set(other.ctx.files()) & set(self.ctx.files()) != set():
1052 1052 self.conflicts.append(other)
1053 1053 return self.conflicts
1054 1054
1055 1055 if other in self.conflicts:
1056 1056 self.conflicts.remove(other)
1057 1057 return self.conflicts
1058 1058
1059 1059 # ============ EVENTS ===============
1060 1060 def movecursor(state, oldpos, newpos):
1061 1061 '''Change the rule/changeset that the cursor is pointing to, regardless of
1062 1062 current mode (you can switch between patches from the view patch window).'''
1063 1063 state['pos'] = newpos
1064 1064
1065 1065 mode, _ = state['mode']
1066 1066 if mode == MODE_RULES:
1067 1067 # Scroll through the list by updating the view for MODE_RULES, so that
1068 1068 # even if we are not currently viewing the rules, switching back will
1069 1069 # result in the cursor's rule being visible.
1070 1070 modestate = state['modes'][MODE_RULES]
1071 1071 if newpos < modestate['line_offset']:
1072 1072 modestate['line_offset'] = newpos
1073 1073 elif newpos > modestate['line_offset'] + state['page_height'] - 1:
1074 1074 modestate['line_offset'] = newpos - state['page_height'] + 1
1075 1075
1076 1076 # Reset the patch view region to the top of the new patch.
1077 1077 state['modes'][MODE_PATCH]['line_offset'] = 0
1078 1078
1079 1079 def changemode(state, mode):
1080 1080 curmode, _ = state['mode']
1081 1081 state['mode'] = (mode, curmode)
1082 if mode == MODE_PATCH:
1083 state['modes'][MODE_PATCH]['patchcontents'] = patchcontents(state)
1082 1084
1083 1085 def makeselection(state, pos):
1084 1086 state['selected'] = pos
1085 1087
1086 1088 def swap(state, oldpos, newpos):
1087 1089 """Swap two positions and calculate necessary conflicts in
1088 1090 O(|newpos-oldpos|) time"""
1089 1091
1090 1092 rules = state['rules']
1091 1093 assert 0 <= oldpos < len(rules) and 0 <= newpos < len(rules)
1092 1094
1093 1095 rules[oldpos], rules[newpos] = rules[newpos], rules[oldpos]
1094 1096
1095 1097 # TODO: swap should not know about histeditrule's internals
1096 1098 rules[newpos].pos = newpos
1097 1099 rules[oldpos].pos = oldpos
1098 1100
1099 1101 start = min(oldpos, newpos)
1100 1102 end = max(oldpos, newpos)
1101 1103 for r in pycompat.xrange(start, end + 1):
1102 1104 rules[newpos].checkconflicts(rules[r])
1103 1105 rules[oldpos].checkconflicts(rules[r])
1104 1106
1105 1107 if state['selected']:
1106 1108 makeselection(state, newpos)
1107 1109
1108 1110 def changeaction(state, pos, action):
1109 1111 """Change the action state on the given position to the new action"""
1110 1112 rules = state['rules']
1111 1113 assert 0 <= pos < len(rules)
1112 1114 rules[pos].action = action
1113 1115
1114 1116 def cycleaction(state, pos, next=False):
1115 1117 """Changes the action state the next or the previous action from
1116 1118 the action list"""
1117 1119 rules = state['rules']
1118 1120 assert 0 <= pos < len(rules)
1119 1121 current = rules[pos].action
1120 1122
1121 1123 assert current in KEY_LIST
1122 1124
1123 1125 index = KEY_LIST.index(current)
1124 1126 if next:
1125 1127 index += 1
1126 1128 else:
1127 1129 index -= 1
1128 1130 changeaction(state, pos, KEY_LIST[index % len(KEY_LIST)])
1129 1131
1130 1132 def changeview(state, delta, unit):
1131 1133 '''Change the region of whatever is being viewed (a patch or the list of
1132 1134 changesets). 'delta' is an amount (+/- 1) and 'unit' is 'page' or 'line'.'''
1133 1135 mode, _ = state['mode']
1134 1136 if mode != MODE_PATCH:
1135 1137 return
1136 1138 mode_state = state['modes'][mode]
1137 num_lines = len(patchcontents(state))
1139 num_lines = len(mode_state['patchcontents'])
1138 1140 page_height = state['page_height']
1139 1141 unit = page_height if unit == 'page' else 1
1140 1142 num_pages = 1 + (num_lines - 1) / page_height
1141 1143 max_offset = (num_pages - 1) * page_height
1142 1144 newline = mode_state['line_offset'] + delta * unit
1143 1145 mode_state['line_offset'] = max(0, min(max_offset, newline))
1144 1146
1145 1147 def event(state, ch):
1146 1148 """Change state based on the current character input
1147 1149
1148 1150 This takes the current state and based on the current character input from
1149 1151 the user we change the state.
1150 1152 """
1151 1153 selected = state['selected']
1152 1154 oldpos = state['pos']
1153 1155 rules = state['rules']
1154 1156
1155 1157 if ch in (curses.KEY_RESIZE, "KEY_RESIZE"):
1156 1158 return E_RESIZE
1157 1159
1158 1160 lookup_ch = ch
1159 1161 if '0' <= ch <= '9':
1160 1162 lookup_ch = '0'
1161 1163
1162 1164 curmode, prevmode = state['mode']
1163 1165 action = KEYTABLE[curmode].get(lookup_ch, KEYTABLE['global'].get(lookup_ch))
1164 1166 if action is None:
1165 1167 return
1166 1168 if action in ('down', 'move-down'):
1167 1169 newpos = min(oldpos + 1, len(rules) - 1)
1168 1170 movecursor(state, oldpos, newpos)
1169 1171 if selected is not None or action == 'move-down':
1170 1172 swap(state, oldpos, newpos)
1171 1173 elif action in ('up', 'move-up'):
1172 1174 newpos = max(0, oldpos - 1)
1173 1175 movecursor(state, oldpos, newpos)
1174 1176 if selected is not None or action == 'move-up':
1175 1177 swap(state, oldpos, newpos)
1176 1178 elif action == 'next-action':
1177 1179 cycleaction(state, oldpos, next=True)
1178 1180 elif action == 'prev-action':
1179 1181 cycleaction(state, oldpos, next=False)
1180 1182 elif action == 'select':
1181 1183 selected = oldpos if selected is None else None
1182 1184 makeselection(state, selected)
1183 1185 elif action == 'goto' and int(ch) < len(rules) and len(rules) <= 10:
1184 1186 newrule = next((r for r in rules if r.origpos == int(ch)))
1185 1187 movecursor(state, oldpos, newrule.pos)
1186 1188 if selected is not None:
1187 1189 swap(state, oldpos, newrule.pos)
1188 1190 elif action.startswith('action-'):
1189 1191 changeaction(state, oldpos, action[7:])
1190 1192 elif action == 'showpatch':
1191 1193 changemode(state, MODE_PATCH if curmode != MODE_PATCH else prevmode)
1192 1194 elif action == 'help':
1193 1195 changemode(state, MODE_HELP if curmode != MODE_HELP else prevmode)
1194 1196 elif action == 'quit':
1195 1197 return E_QUIT
1196 1198 elif action == 'histedit':
1197 1199 return E_HISTEDIT
1198 1200 elif action == 'page-down':
1199 1201 return E_PAGEDOWN
1200 1202 elif action == 'page-up':
1201 1203 return E_PAGEUP
1202 1204 elif action == 'line-down':
1203 1205 return E_LINEDOWN
1204 1206 elif action == 'line-up':
1205 1207 return E_LINEUP
1206 1208
1207 1209 def makecommands(rules):
1208 1210 """Returns a list of commands consumable by histedit --commands based on
1209 1211 our list of rules"""
1210 1212 commands = []
1211 1213 for rules in rules:
1212 1214 commands.append("{0} {1}\n".format(rules.action, rules.ctx))
1213 1215 return commands
1214 1216
1215 1217 def addln(win, y, x, line, color=None):
1216 1218 """Add a line to the given window left padding but 100% filled with
1217 1219 whitespace characters, so that the color appears on the whole line"""
1218 1220 maxy, maxx = win.getmaxyx()
1219 1221 length = maxx - 1 - x
1220 1222 line = ("{0:<%d}" % length).format(str(line).strip())[:length]
1221 1223 if y < 0:
1222 1224 y = maxy + y
1223 1225 if x < 0:
1224 1226 x = maxx + x
1225 1227 if color:
1226 1228 win.addstr(y, x, line, color)
1227 1229 else:
1228 1230 win.addstr(y, x, line)
1229 1231
1230 1232 def _trunc_head(line, n):
1231 1233 if len(line) <= n:
1232 1234 return line
1233 1235 return '> ' + line[-(n - 2):]
1234 1236 def _trunc_tail(line, n):
1235 1237 if len(line) <= n:
1236 1238 return line
1237 1239 return line[:n - 2] + ' >'
1238 1240
1239 1241 def patchcontents(state):
1240 1242 repo = state['repo']
1241 1243 rule = state['rules'][state['pos']]
1242 1244 displayer = logcmdutil.changesetdisplayer(repo.ui, repo, {
1243 1245 "patch": True, "template": "status"
1244 1246 }, buffered=True)
1245 1247 overrides = {('ui', 'verbose'): True}
1246 1248 with repo.ui.configoverride(overrides, source='histedit'):
1247 1249 displayer.show(rule.ctx)
1248 1250 displayer.close()
1249 1251 return displayer.hunk[rule.ctx.rev()].splitlines()
1250 1252
1251 1253 def _chisteditmain(repo, rules, stdscr):
1252 1254 try:
1253 1255 curses.use_default_colors()
1254 1256 except curses.error:
1255 1257 pass
1256 1258
1257 1259 # initialize color pattern
1258 1260 curses.init_pair(COLOR_HELP, curses.COLOR_WHITE, curses.COLOR_BLUE)
1259 1261 curses.init_pair(COLOR_SELECTED, curses.COLOR_BLACK, curses.COLOR_WHITE)
1260 1262 curses.init_pair(COLOR_WARN, curses.COLOR_BLACK, curses.COLOR_YELLOW)
1261 1263 curses.init_pair(COLOR_OK, curses.COLOR_BLACK, curses.COLOR_GREEN)
1262 1264 curses.init_pair(COLOR_CURRENT, curses.COLOR_WHITE, curses.COLOR_MAGENTA)
1263 1265 curses.init_pair(COLOR_DIFF_ADD_LINE, curses.COLOR_GREEN, -1)
1264 1266 curses.init_pair(COLOR_DIFF_DEL_LINE, curses.COLOR_RED, -1)
1265 1267 curses.init_pair(COLOR_DIFF_OFFSET, curses.COLOR_MAGENTA, -1)
1266 1268
1267 1269 # don't display the cursor
1268 1270 try:
1269 1271 curses.curs_set(0)
1270 1272 except curses.error:
1271 1273 pass
1272 1274
1273 1275 def rendercommit(win, state):
1274 1276 """Renders the commit window that shows the log of the current selected
1275 1277 commit"""
1276 1278 pos = state['pos']
1277 1279 rules = state['rules']
1278 1280 rule = rules[pos]
1279 1281
1280 1282 ctx = rule.ctx
1281 1283 win.box()
1282 1284
1283 1285 maxy, maxx = win.getmaxyx()
1284 1286 length = maxx - 3
1285 1287
1286 1288 line = "changeset: {0}:{1:<12}".format(ctx.rev(), ctx)
1287 1289 win.addstr(1, 1, line[:length])
1288 1290
1289 1291 line = "user: {0}".format(ctx.user())
1290 1292 win.addstr(2, 1, line[:length])
1291 1293
1292 1294 bms = repo.nodebookmarks(ctx.node())
1293 1295 line = "bookmark: {0}".format(' '.join(bms))
1294 1296 win.addstr(3, 1, line[:length])
1295 1297
1296 1298 line = "summary: {0}".format(ctx.description().splitlines()[0])
1297 1299 win.addstr(4, 1, line[:length])
1298 1300
1299 1301 line = "files: "
1300 1302 win.addstr(5, 1, line)
1301 1303 fnx = 1 + len(line)
1302 1304 fnmaxx = length - fnx + 1
1303 1305 y = 5
1304 1306 fnmaxn = maxy - (1 + y) - 1
1305 1307 files = ctx.files()
1306 1308 for i, line1 in enumerate(files):
1307 1309 if len(files) > fnmaxn and i == fnmaxn - 1:
1308 1310 win.addstr(y, fnx, _trunc_tail(','.join(files[i:]), fnmaxx))
1309 1311 y = y + 1
1310 1312 break
1311 1313 win.addstr(y, fnx, _trunc_head(line1, fnmaxx))
1312 1314 y = y + 1
1313 1315
1314 1316 conflicts = rule.conflicts
1315 1317 if len(conflicts) > 0:
1316 1318 conflictstr = ','.join(map(lambda r: str(r.ctx), conflicts))
1317 1319 conflictstr = "changed files overlap with {0}".format(conflictstr)
1318 1320 else:
1319 1321 conflictstr = 'no overlap'
1320 1322
1321 1323 win.addstr(y, 1, conflictstr[:length])
1322 1324 win.noutrefresh()
1323 1325
1324 1326 def helplines(mode):
1325 1327 if mode == MODE_PATCH:
1326 1328 help = """\
1327 1329 ?: help, k/up: line up, j/down: line down, v: stop viewing patch
1328 1330 pgup: prev page, space/pgdn: next page, c: commit, q: abort
1329 1331 """
1330 1332 else:
1331 1333 help = """\
1332 1334 ?: help, k/up: move up, j/down: move down, space: select, v: view patch
1333 1335 d: drop, e: edit, f: fold, m: mess, p: pick, r: roll
1334 1336 pgup/K: move patch up, pgdn/J: move patch down, c: commit, q: abort
1335 1337 """
1336 1338 return help.splitlines()
1337 1339
1338 1340 def renderhelp(win, state):
1339 1341 maxy, maxx = win.getmaxyx()
1340 1342 mode, _ = state['mode']
1341 1343 for y, line in enumerate(helplines(mode)):
1342 1344 if y >= maxy:
1343 1345 break
1344 1346 addln(win, y, 0, line, curses.color_pair(COLOR_HELP))
1345 1347 win.noutrefresh()
1346 1348
1347 1349 def renderrules(rulesscr, state):
1348 1350 rules = state['rules']
1349 1351 pos = state['pos']
1350 1352 selected = state['selected']
1351 1353 start = state['modes'][MODE_RULES]['line_offset']
1352 1354
1353 1355 conflicts = [r.ctx for r in rules if r.conflicts]
1354 1356 if len(conflicts) > 0:
1355 1357 line = "potential conflict in %s" % ','.join(map(str, conflicts))
1356 1358 addln(rulesscr, -1, 0, line, curses.color_pair(COLOR_WARN))
1357 1359
1358 1360 for y, rule in enumerate(rules[start:]):
1359 1361 if y >= state['page_height']:
1360 1362 break
1361 1363 if len(rule.conflicts) > 0:
1362 1364 rulesscr.addstr(y, 0, " ", curses.color_pair(COLOR_WARN))
1363 1365 else:
1364 1366 rulesscr.addstr(y, 0, " ", curses.COLOR_BLACK)
1365 1367 if y + start == selected:
1366 1368 addln(rulesscr, y, 2, rule, curses.color_pair(COLOR_SELECTED))
1367 1369 elif y + start == pos:
1368 1370 addln(rulesscr, y, 2, rule,
1369 1371 curses.color_pair(COLOR_CURRENT) | curses.A_BOLD)
1370 1372 else:
1371 1373 addln(rulesscr, y, 2, rule)
1372 1374 rulesscr.noutrefresh()
1373 1375
1374 1376 def renderstring(win, state, output, diffcolors=False):
1375 1377 maxy, maxx = win.getmaxyx()
1376 1378 length = min(maxy - 1, len(output))
1377 1379 for y in range(0, length):
1378 1380 line = output[y]
1379 1381 if diffcolors:
1380 1382 if line and line[0] == '+':
1381 1383 win.addstr(
1382 1384 y, 0, line, curses.color_pair(COLOR_DIFF_ADD_LINE))
1383 1385 elif line and line[0] == '-':
1384 1386 win.addstr(
1385 1387 y, 0, line, curses.color_pair(COLOR_DIFF_DEL_LINE))
1386 1388 elif line.startswith('@@ '):
1387 1389 win.addstr(
1388 1390 y, 0, line, curses.color_pair(COLOR_DIFF_OFFSET))
1389 1391 else:
1390 1392 win.addstr(y, 0, line)
1391 1393 else:
1392 1394 win.addstr(y, 0, line)
1393 1395 win.noutrefresh()
1394 1396
1395 1397 def renderpatch(win, state):
1396 1398 start = state['modes'][MODE_PATCH]['line_offset']
1397 renderstring(win, state, patchcontents(state)[start:], diffcolors=True)
1399 content = state['modes'][MODE_PATCH]['patchcontents']
1400 renderstring(win, state, content[start:], diffcolors=True)
1398 1401
1399 1402 def layout(mode):
1400 1403 maxy, maxx = stdscr.getmaxyx()
1401 1404 helplen = len(helplines(mode))
1402 1405 return {
1403 1406 'commit': (12, maxx),
1404 1407 'help': (helplen, maxx),
1405 1408 'main': (maxy - helplen - 12, maxx),
1406 1409 }
1407 1410
1408 1411 def drawvertwin(size, y, x):
1409 1412 win = curses.newwin(size[0], size[1], y, x)
1410 1413 y += size[0]
1411 1414 return win, y, x
1412 1415
1413 1416 state = {
1414 1417 'pos': 0,
1415 1418 'rules': rules,
1416 1419 'selected': None,
1417 1420 'mode': (MODE_INIT, MODE_INIT),
1418 1421 'page_height': None,
1419 1422 'modes': {
1420 1423 MODE_RULES: {
1421 1424 'line_offset': 0,
1422 1425 },
1423 1426 MODE_PATCH: {
1424 1427 'line_offset': 0,
1425 1428 }
1426 1429 },
1427 1430 'repo': repo,
1428 1431 }
1429 1432
1430 1433 # eventloop
1431 1434 ch = None
1432 1435 stdscr.clear()
1433 1436 stdscr.refresh()
1434 1437 while True:
1435 1438 try:
1436 1439 oldmode, _ = state['mode']
1437 1440 if oldmode == MODE_INIT:
1438 1441 changemode(state, MODE_RULES)
1439 1442 e = event(state, ch)
1440 1443
1441 1444 if e == E_QUIT:
1442 1445 return False
1443 1446 if e == E_HISTEDIT:
1444 1447 return state['rules']
1445 1448 else:
1446 1449 if e == E_RESIZE:
1447 1450 size = screen_size()
1448 1451 if size != stdscr.getmaxyx():
1449 1452 curses.resizeterm(*size)
1450 1453
1451 1454 curmode, _ = state['mode']
1452 1455 sizes = layout(curmode)
1453 1456 if curmode != oldmode:
1454 1457 state['page_height'] = sizes['main'][0]
1455 1458 # Adjust the view to fit the current screen size.
1456 1459 movecursor(state, state['pos'], state['pos'])
1457 1460
1458 1461 # Pack the windows against the top, each pane spread across the
1459 1462 # full width of the screen.
1460 1463 y, x = (0, 0)
1461 1464 helpwin, y, x = drawvertwin(sizes['help'], y, x)
1462 1465 mainwin, y, x = drawvertwin(sizes['main'], y, x)
1463 1466 commitwin, y, x = drawvertwin(sizes['commit'], y, x)
1464 1467
1465 1468 if e in (E_PAGEDOWN, E_PAGEUP, E_LINEDOWN, E_LINEUP):
1466 1469 if e == E_PAGEDOWN:
1467 1470 changeview(state, +1, 'page')
1468 1471 elif e == E_PAGEUP:
1469 1472 changeview(state, -1, 'page')
1470 1473 elif e == E_LINEDOWN:
1471 1474 changeview(state, +1, 'line')
1472 1475 elif e == E_LINEUP:
1473 1476 changeview(state, -1, 'line')
1474 1477
1475 1478 # start rendering
1476 1479 commitwin.erase()
1477 1480 helpwin.erase()
1478 1481 mainwin.erase()
1479 1482 if curmode == MODE_PATCH:
1480 1483 renderpatch(mainwin, state)
1481 1484 elif curmode == MODE_HELP:
1482 1485 renderstring(mainwin, state, __doc__.strip().splitlines())
1483 1486 else:
1484 1487 renderrules(mainwin, state)
1485 1488 rendercommit(commitwin, state)
1486 1489 renderhelp(helpwin, state)
1487 1490 curses.doupdate()
1488 1491 # done rendering
1489 1492 ch = stdscr.getkey()
1490 1493 except curses.error:
1491 1494 pass
1492 1495
1493 1496 def _chistedit(ui, repo, *freeargs, **opts):
1494 1497 """interactively edit changeset history via a curses interface
1495 1498
1496 1499 Provides a ncurses interface to histedit. Press ? in chistedit mode
1497 1500 to see an extensive help. Requires python-curses to be installed."""
1498 1501
1499 1502 if curses is None:
1500 1503 raise error.Abort(_("Python curses library required"))
1501 1504
1502 1505 # disable color
1503 1506 ui._colormode = None
1504 1507
1505 1508 try:
1506 1509 keep = opts.get('keep')
1507 1510 revs = opts.get('rev', [])[:]
1508 1511 cmdutil.checkunfinished(repo)
1509 1512 cmdutil.bailifchanged(repo)
1510 1513
1511 1514 if os.path.exists(os.path.join(repo.path, 'histedit-state')):
1512 1515 raise error.Abort(_('history edit already in progress, try '
1513 1516 '--continue or --abort'))
1514 1517 revs.extend(freeargs)
1515 1518 if not revs:
1516 1519 defaultrev = destutil.desthistedit(ui, repo)
1517 1520 if defaultrev is not None:
1518 1521 revs.append(defaultrev)
1519 1522 if len(revs) != 1:
1520 1523 raise error.Abort(
1521 1524 _('histedit requires exactly one ancestor revision'))
1522 1525
1523 1526 rr = list(repo.set('roots(%ld)', scmutil.revrange(repo, revs)))
1524 1527 if len(rr) != 1:
1525 1528 raise error.Abort(_('The specified revisions must have '
1526 1529 'exactly one common root'))
1527 1530 root = rr[0].node()
1528 1531
1529 1532 topmost = repo.dirstate.p1()
1530 1533 revs = between(repo, root, topmost, keep)
1531 1534 if not revs:
1532 1535 raise error.Abort(_('%s is not an ancestor of working directory') %
1533 1536 node.short(root))
1534 1537
1535 1538 ctxs = []
1536 1539 for i, r in enumerate(revs):
1537 1540 ctxs.append(histeditrule(repo[r], i))
1538 1541 rc = curses.wrapper(functools.partial(_chisteditmain, repo, ctxs))
1539 1542 curses.echo()
1540 1543 curses.endwin()
1541 1544 if rc is False:
1542 1545 ui.write(_("histedit aborted\n"))
1543 1546 return 0
1544 1547 if type(rc) is list:
1545 1548 ui.status(_("performing changes\n"))
1546 1549 rules = makecommands(rc)
1547 1550 filename = repo.vfs.join('chistedit')
1548 1551 with open(filename, 'w+') as fp:
1549 1552 for r in rules:
1550 1553 fp.write(r)
1551 1554 opts['commands'] = filename
1552 1555 return _texthistedit(ui, repo, *freeargs, **opts)
1553 1556 except KeyboardInterrupt:
1554 1557 pass
1555 1558 return -1
1556 1559
1557 1560 @command('histedit',
1558 1561 [('', 'commands', '',
1559 1562 _('read history edits from the specified file'), _('FILE')),
1560 1563 ('c', 'continue', False, _('continue an edit already in progress')),
1561 1564 ('', 'edit-plan', False, _('edit remaining actions list')),
1562 1565 ('k', 'keep', False,
1563 1566 _("don't strip old nodes after edit is complete")),
1564 1567 ('', 'abort', False, _('abort an edit in progress')),
1565 1568 ('o', 'outgoing', False, _('changesets not found in destination')),
1566 1569 ('f', 'force', False,
1567 1570 _('force outgoing even for unrelated repositories')),
1568 1571 ('r', 'rev', [], _('first revision to be edited'), _('REV'))] +
1569 1572 cmdutil.formatteropts,
1570 1573 _("[OPTIONS] ([ANCESTOR] | --outgoing [URL])"),
1571 1574 helpcategory=command.CATEGORY_CHANGE_MANAGEMENT)
1572 1575 def histedit(ui, repo, *freeargs, **opts):
1573 1576 """interactively edit changeset history
1574 1577
1575 1578 This command lets you edit a linear series of changesets (up to
1576 1579 and including the working directory, which should be clean).
1577 1580 You can:
1578 1581
1579 1582 - `pick` to [re]order a changeset
1580 1583
1581 1584 - `drop` to omit changeset
1582 1585
1583 1586 - `mess` to reword the changeset commit message
1584 1587
1585 1588 - `fold` to combine it with the preceding changeset (using the later date)
1586 1589
1587 1590 - `roll` like fold, but discarding this commit's description and date
1588 1591
1589 1592 - `edit` to edit this changeset (preserving date)
1590 1593
1591 1594 - `base` to checkout changeset and apply further changesets from there
1592 1595
1593 1596 There are a number of ways to select the root changeset:
1594 1597
1595 1598 - Specify ANCESTOR directly
1596 1599
1597 1600 - Use --outgoing -- it will be the first linear changeset not
1598 1601 included in destination. (See :hg:`help config.paths.default-push`)
1599 1602
1600 1603 - Otherwise, the value from the "histedit.defaultrev" config option
1601 1604 is used as a revset to select the base revision when ANCESTOR is not
1602 1605 specified. The first revision returned by the revset is used. By
1603 1606 default, this selects the editable history that is unique to the
1604 1607 ancestry of the working directory.
1605 1608
1606 1609 .. container:: verbose
1607 1610
1608 1611 If you use --outgoing, this command will abort if there are ambiguous
1609 1612 outgoing revisions. For example, if there are multiple branches
1610 1613 containing outgoing revisions.
1611 1614
1612 1615 Use "min(outgoing() and ::.)" or similar revset specification
1613 1616 instead of --outgoing to specify edit target revision exactly in
1614 1617 such ambiguous situation. See :hg:`help revsets` for detail about
1615 1618 selecting revisions.
1616 1619
1617 1620 .. container:: verbose
1618 1621
1619 1622 Examples:
1620 1623
1621 1624 - A number of changes have been made.
1622 1625 Revision 3 is no longer needed.
1623 1626
1624 1627 Start history editing from revision 3::
1625 1628
1626 1629 hg histedit -r 3
1627 1630
1628 1631 An editor opens, containing the list of revisions,
1629 1632 with specific actions specified::
1630 1633
1631 1634 pick 5339bf82f0ca 3 Zworgle the foobar
1632 1635 pick 8ef592ce7cc4 4 Bedazzle the zerlog
1633 1636 pick 0a9639fcda9d 5 Morgify the cromulancy
1634 1637
1635 1638 Additional information about the possible actions
1636 1639 to take appears below the list of revisions.
1637 1640
1638 1641 To remove revision 3 from the history,
1639 1642 its action (at the beginning of the relevant line)
1640 1643 is changed to 'drop'::
1641 1644
1642 1645 drop 5339bf82f0ca 3 Zworgle the foobar
1643 1646 pick 8ef592ce7cc4 4 Bedazzle the zerlog
1644 1647 pick 0a9639fcda9d 5 Morgify the cromulancy
1645 1648
1646 1649 - A number of changes have been made.
1647 1650 Revision 2 and 4 need to be swapped.
1648 1651
1649 1652 Start history editing from revision 2::
1650 1653
1651 1654 hg histedit -r 2
1652 1655
1653 1656 An editor opens, containing the list of revisions,
1654 1657 with specific actions specified::
1655 1658
1656 1659 pick 252a1af424ad 2 Blorb a morgwazzle
1657 1660 pick 5339bf82f0ca 3 Zworgle the foobar
1658 1661 pick 8ef592ce7cc4 4 Bedazzle the zerlog
1659 1662
1660 1663 To swap revision 2 and 4, its lines are swapped
1661 1664 in the editor::
1662 1665
1663 1666 pick 8ef592ce7cc4 4 Bedazzle the zerlog
1664 1667 pick 5339bf82f0ca 3 Zworgle the foobar
1665 1668 pick 252a1af424ad 2 Blorb a morgwazzle
1666 1669
1667 1670 Returns 0 on success, 1 if user intervention is required (not only
1668 1671 for intentional "edit" command, but also for resolving unexpected
1669 1672 conflicts).
1670 1673 """
1671 1674 # kludge: _chistedit only works for starting an edit, not aborting
1672 1675 # or continuing, so fall back to regular _texthistedit for those
1673 1676 # operations.
1674 1677 if ui.interface('histedit') == 'curses' and _getgoal(
1675 1678 pycompat.byteskwargs(opts)) == goalnew:
1676 1679 return _chistedit(ui, repo, *freeargs, **opts)
1677 1680 return _texthistedit(ui, repo, *freeargs, **opts)
1678 1681
1679 1682 def _texthistedit(ui, repo, *freeargs, **opts):
1680 1683 state = histeditstate(repo)
1681 1684 with repo.wlock() as wlock, repo.lock() as lock:
1682 1685 state.wlock = wlock
1683 1686 state.lock = lock
1684 1687 _histedit(ui, repo, state, *freeargs, **opts)
1685 1688
1686 1689 goalcontinue = 'continue'
1687 1690 goalabort = 'abort'
1688 1691 goaleditplan = 'edit-plan'
1689 1692 goalnew = 'new'
1690 1693
1691 1694 def _getgoal(opts):
1692 1695 if opts.get(b'continue'):
1693 1696 return goalcontinue
1694 1697 if opts.get(b'abort'):
1695 1698 return goalabort
1696 1699 if opts.get(b'edit_plan'):
1697 1700 return goaleditplan
1698 1701 return goalnew
1699 1702
1700 1703 def _readfile(ui, path):
1701 1704 if path == '-':
1702 1705 with ui.timeblockedsection('histedit'):
1703 1706 return ui.fin.read()
1704 1707 else:
1705 1708 with open(path, 'rb') as f:
1706 1709 return f.read()
1707 1710
1708 1711 def _validateargs(ui, repo, state, freeargs, opts, goal, rules, revs):
1709 1712 # TODO only abort if we try to histedit mq patches, not just
1710 1713 # blanket if mq patches are applied somewhere
1711 1714 mq = getattr(repo, 'mq', None)
1712 1715 if mq and mq.applied:
1713 1716 raise error.Abort(_('source has mq patches applied'))
1714 1717
1715 1718 # basic argument incompatibility processing
1716 1719 outg = opts.get('outgoing')
1717 1720 editplan = opts.get('edit_plan')
1718 1721 abort = opts.get('abort')
1719 1722 force = opts.get('force')
1720 1723 if force and not outg:
1721 1724 raise error.Abort(_('--force only allowed with --outgoing'))
1722 1725 if goal == 'continue':
1723 1726 if any((outg, abort, revs, freeargs, rules, editplan)):
1724 1727 raise error.Abort(_('no arguments allowed with --continue'))
1725 1728 elif goal == 'abort':
1726 1729 if any((outg, revs, freeargs, rules, editplan)):
1727 1730 raise error.Abort(_('no arguments allowed with --abort'))
1728 1731 elif goal == 'edit-plan':
1729 1732 if any((outg, revs, freeargs)):
1730 1733 raise error.Abort(_('only --commands argument allowed with '
1731 1734 '--edit-plan'))
1732 1735 else:
1733 1736 if state.inprogress():
1734 1737 raise error.Abort(_('history edit already in progress, try '
1735 1738 '--continue or --abort'))
1736 1739 if outg:
1737 1740 if revs:
1738 1741 raise error.Abort(_('no revisions allowed with --outgoing'))
1739 1742 if len(freeargs) > 1:
1740 1743 raise error.Abort(
1741 1744 _('only one repo argument allowed with --outgoing'))
1742 1745 else:
1743 1746 revs.extend(freeargs)
1744 1747 if len(revs) == 0:
1745 1748 defaultrev = destutil.desthistedit(ui, repo)
1746 1749 if defaultrev is not None:
1747 1750 revs.append(defaultrev)
1748 1751
1749 1752 if len(revs) != 1:
1750 1753 raise error.Abort(
1751 1754 _('histedit requires exactly one ancestor revision'))
1752 1755
1753 1756 def _histedit(ui, repo, state, *freeargs, **opts):
1754 1757 opts = pycompat.byteskwargs(opts)
1755 1758 fm = ui.formatter('histedit', opts)
1756 1759 fm.startitem()
1757 1760 goal = _getgoal(opts)
1758 1761 revs = opts.get('rev', [])
1759 1762 nobackup = not ui.configbool('rewrite', 'backup-bundle')
1760 1763 rules = opts.get('commands', '')
1761 1764 state.keep = opts.get('keep', False)
1762 1765
1763 1766 _validateargs(ui, repo, state, freeargs, opts, goal, rules, revs)
1764 1767
1765 1768 hastags = False
1766 1769 if revs:
1767 1770 revs = scmutil.revrange(repo, revs)
1768 1771 ctxs = [repo[rev] for rev in revs]
1769 1772 for ctx in ctxs:
1770 1773 tags = [tag for tag in ctx.tags() if tag != 'tip']
1771 1774 if not hastags:
1772 1775 hastags = len(tags)
1773 1776 if hastags:
1774 1777 if ui.promptchoice(_('warning: tags associated with the given'
1775 1778 ' changeset will be lost after histedit.\n'
1776 1779 'do you want to continue (yN)? $$ &Yes $$ &No'),
1777 1780 default=1):
1778 1781 raise error.Abort(_('histedit cancelled\n'))
1779 1782 # rebuild state
1780 1783 if goal == goalcontinue:
1781 1784 state.read()
1782 1785 state = bootstrapcontinue(ui, state, opts)
1783 1786 elif goal == goaleditplan:
1784 1787 _edithisteditplan(ui, repo, state, rules)
1785 1788 return
1786 1789 elif goal == goalabort:
1787 1790 _aborthistedit(ui, repo, state, nobackup=nobackup)
1788 1791 return
1789 1792 else:
1790 1793 # goal == goalnew
1791 1794 _newhistedit(ui, repo, state, revs, freeargs, opts)
1792 1795
1793 1796 _continuehistedit(ui, repo, state)
1794 1797 _finishhistedit(ui, repo, state, fm)
1795 1798 fm.end()
1796 1799
1797 1800 def _continuehistedit(ui, repo, state):
1798 1801 """This function runs after either:
1799 1802 - bootstrapcontinue (if the goal is 'continue')
1800 1803 - _newhistedit (if the goal is 'new')
1801 1804 """
1802 1805 # preprocess rules so that we can hide inner folds from the user
1803 1806 # and only show one editor
1804 1807 actions = state.actions[:]
1805 1808 for idx, (action, nextact) in enumerate(
1806 1809 zip(actions, actions[1:] + [None])):
1807 1810 if action.verb == 'fold' and nextact and nextact.verb == 'fold':
1808 1811 state.actions[idx].__class__ = _multifold
1809 1812
1810 1813 # Force an initial state file write, so the user can run --abort/continue
1811 1814 # even if there's an exception before the first transaction serialize.
1812 1815 state.write()
1813 1816
1814 1817 tr = None
1815 1818 # Don't use singletransaction by default since it rolls the entire
1816 1819 # transaction back if an unexpected exception happens (like a
1817 1820 # pretxncommit hook throws, or the user aborts the commit msg editor).
1818 1821 if ui.configbool("histedit", "singletransaction"):
1819 1822 # Don't use a 'with' for the transaction, since actions may close
1820 1823 # and reopen a transaction. For example, if the action executes an
1821 1824 # external process it may choose to commit the transaction first.
1822 1825 tr = repo.transaction('histedit')
1823 1826 progress = ui.makeprogress(_("editing"), unit=_('changes'),
1824 1827 total=len(state.actions))
1825 1828 with progress, util.acceptintervention(tr):
1826 1829 while state.actions:
1827 1830 state.write(tr=tr)
1828 1831 actobj = state.actions[0]
1829 1832 progress.increment(item=actobj.torule())
1830 1833 ui.debug('histedit: processing %s %s\n' % (actobj.verb,
1831 1834 actobj.torule()))
1832 1835 parentctx, replacement_ = actobj.run()
1833 1836 state.parentctxnode = parentctx.node()
1834 1837 state.replacements.extend(replacement_)
1835 1838 state.actions.pop(0)
1836 1839
1837 1840 state.write()
1838 1841
1839 1842 def _finishhistedit(ui, repo, state, fm):
1840 1843 """This action runs when histedit is finishing its session"""
1841 1844 hg.updaterepo(repo, state.parentctxnode, overwrite=False)
1842 1845
1843 1846 mapping, tmpnodes, created, ntm = processreplacement(state)
1844 1847 if mapping:
1845 1848 for prec, succs in mapping.iteritems():
1846 1849 if not succs:
1847 1850 ui.debug('histedit: %s is dropped\n' % node.short(prec))
1848 1851 else:
1849 1852 ui.debug('histedit: %s is replaced by %s\n' % (
1850 1853 node.short(prec), node.short(succs[0])))
1851 1854 if len(succs) > 1:
1852 1855 m = 'histedit: %s'
1853 1856 for n in succs[1:]:
1854 1857 ui.debug(m % node.short(n))
1855 1858
1856 1859 if not state.keep:
1857 1860 if mapping:
1858 1861 movetopmostbookmarks(repo, state.topmost, ntm)
1859 1862 # TODO update mq state
1860 1863 else:
1861 1864 mapping = {}
1862 1865
1863 1866 for n in tmpnodes:
1864 1867 if n in repo:
1865 1868 mapping[n] = ()
1866 1869
1867 1870 # remove entries about unknown nodes
1868 1871 nodemap = repo.unfiltered().changelog.nodemap
1869 1872 mapping = {k: v for k, v in mapping.items()
1870 1873 if k in nodemap and all(n in nodemap for n in v)}
1871 1874 scmutil.cleanupnodes(repo, mapping, 'histedit')
1872 1875 hf = fm.hexfunc
1873 1876 fl = fm.formatlist
1874 1877 fd = fm.formatdict
1875 1878 nodechanges = fd({hf(oldn): fl([hf(n) for n in newn], name='node')
1876 1879 for oldn, newn in mapping.iteritems()},
1877 1880 key="oldnode", value="newnodes")
1878 1881 fm.data(nodechanges=nodechanges)
1879 1882
1880 1883 state.clear()
1881 1884 if os.path.exists(repo.sjoin('undo')):
1882 1885 os.unlink(repo.sjoin('undo'))
1883 1886 if repo.vfs.exists('histedit-last-edit.txt'):
1884 1887 repo.vfs.unlink('histedit-last-edit.txt')
1885 1888
1886 1889 def _aborthistedit(ui, repo, state, nobackup=False):
1887 1890 try:
1888 1891 state.read()
1889 1892 __, leafs, tmpnodes, __ = processreplacement(state)
1890 1893 ui.debug('restore wc to old parent %s\n'
1891 1894 % node.short(state.topmost))
1892 1895
1893 1896 # Recover our old commits if necessary
1894 1897 if not state.topmost in repo and state.backupfile:
1895 1898 backupfile = repo.vfs.join(state.backupfile)
1896 1899 f = hg.openpath(ui, backupfile)
1897 1900 gen = exchange.readbundle(ui, f, backupfile)
1898 1901 with repo.transaction('histedit.abort') as tr:
1899 1902 bundle2.applybundle(repo, gen, tr, source='histedit',
1900 1903 url='bundle:' + backupfile)
1901 1904
1902 1905 os.remove(backupfile)
1903 1906
1904 1907 # check whether we should update away
1905 1908 if repo.unfiltered().revs('parents() and (%n or %ln::)',
1906 1909 state.parentctxnode, leafs | tmpnodes):
1907 1910 hg.clean(repo, state.topmost, show_stats=True, quietempty=True)
1908 1911 cleanupnode(ui, repo, tmpnodes, nobackup=nobackup)
1909 1912 cleanupnode(ui, repo, leafs, nobackup=nobackup)
1910 1913 except Exception:
1911 1914 if state.inprogress():
1912 1915 ui.warn(_('warning: encountered an exception during histedit '
1913 1916 '--abort; the repository may not have been completely '
1914 1917 'cleaned up\n'))
1915 1918 raise
1916 1919 finally:
1917 1920 state.clear()
1918 1921
1919 1922 def _edithisteditplan(ui, repo, state, rules):
1920 1923 state.read()
1921 1924 if not rules:
1922 1925 comment = geteditcomment(ui,
1923 1926 node.short(state.parentctxnode),
1924 1927 node.short(state.topmost))
1925 1928 rules = ruleeditor(repo, ui, state.actions, comment)
1926 1929 else:
1927 1930 rules = _readfile(ui, rules)
1928 1931 actions = parserules(rules, state)
1929 1932 ctxs = [repo[act.node]
1930 1933 for act in state.actions if act.node]
1931 1934 warnverifyactions(ui, repo, actions, state, ctxs)
1932 1935 state.actions = actions
1933 1936 state.write()
1934 1937
1935 1938 def _newhistedit(ui, repo, state, revs, freeargs, opts):
1936 1939 outg = opts.get('outgoing')
1937 1940 rules = opts.get('commands', '')
1938 1941 force = opts.get('force')
1939 1942
1940 1943 cmdutil.checkunfinished(repo)
1941 1944 cmdutil.bailifchanged(repo)
1942 1945
1943 1946 topmost = repo.dirstate.p1()
1944 1947 if outg:
1945 1948 if freeargs:
1946 1949 remote = freeargs[0]
1947 1950 else:
1948 1951 remote = None
1949 1952 root = findoutgoing(ui, repo, remote, force, opts)
1950 1953 else:
1951 1954 rr = list(repo.set('roots(%ld)', scmutil.revrange(repo, revs)))
1952 1955 if len(rr) != 1:
1953 1956 raise error.Abort(_('The specified revisions must have '
1954 1957 'exactly one common root'))
1955 1958 root = rr[0].node()
1956 1959
1957 1960 revs = between(repo, root, topmost, state.keep)
1958 1961 if not revs:
1959 1962 raise error.Abort(_('%s is not an ancestor of working directory') %
1960 1963 node.short(root))
1961 1964
1962 1965 ctxs = [repo[r] for r in revs]
1963 1966 if not rules:
1964 1967 comment = geteditcomment(ui, node.short(root), node.short(topmost))
1965 1968 actions = [pick(state, r) for r in revs]
1966 1969 rules = ruleeditor(repo, ui, actions, comment)
1967 1970 else:
1968 1971 rules = _readfile(ui, rules)
1969 1972 actions = parserules(rules, state)
1970 1973 warnverifyactions(ui, repo, actions, state, ctxs)
1971 1974
1972 1975 parentctxnode = repo[root].p1().node()
1973 1976
1974 1977 state.parentctxnode = parentctxnode
1975 1978 state.actions = actions
1976 1979 state.topmost = topmost
1977 1980 state.replacements = []
1978 1981
1979 1982 ui.log("histedit", "%d actions to histedit\n", len(actions),
1980 1983 histedit_num_actions=len(actions))
1981 1984
1982 1985 # Create a backup so we can always abort completely.
1983 1986 backupfile = None
1984 1987 if not obsolete.isenabled(repo, obsolete.createmarkersopt):
1985 1988 backupfile = repair.backupbundle(repo, [parentctxnode],
1986 1989 [topmost], root, 'histedit')
1987 1990 state.backupfile = backupfile
1988 1991
1989 1992 def _getsummary(ctx):
1990 1993 # a common pattern is to extract the summary but default to the empty
1991 1994 # string
1992 1995 summary = ctx.description() or ''
1993 1996 if summary:
1994 1997 summary = summary.splitlines()[0]
1995 1998 return summary
1996 1999
1997 2000 def bootstrapcontinue(ui, state, opts):
1998 2001 repo = state.repo
1999 2002
2000 2003 ms = mergemod.mergestate.read(repo)
2001 2004 mergeutil.checkunresolved(ms)
2002 2005
2003 2006 if state.actions:
2004 2007 actobj = state.actions.pop(0)
2005 2008
2006 2009 if _isdirtywc(repo):
2007 2010 actobj.continuedirty()
2008 2011 if _isdirtywc(repo):
2009 2012 abortdirty()
2010 2013
2011 2014 parentctx, replacements = actobj.continueclean()
2012 2015
2013 2016 state.parentctxnode = parentctx.node()
2014 2017 state.replacements.extend(replacements)
2015 2018
2016 2019 return state
2017 2020
2018 2021 def between(repo, old, new, keep):
2019 2022 """select and validate the set of revision to edit
2020 2023
2021 2024 When keep is false, the specified set can't have children."""
2022 2025 revs = repo.revs('%n::%n', old, new)
2023 2026 if revs and not keep:
2024 2027 if (not obsolete.isenabled(repo, obsolete.allowunstableopt) and
2025 2028 repo.revs('(%ld::) - (%ld)', revs, revs)):
2026 2029 raise error.Abort(_('can only histedit a changeset together '
2027 2030 'with all its descendants'))
2028 2031 if repo.revs('(%ld) and merge()', revs):
2029 2032 raise error.Abort(_('cannot edit history that contains merges'))
2030 2033 root = repo[revs.first()] # list is already sorted by repo.revs()
2031 2034 if not root.mutable():
2032 2035 raise error.Abort(_('cannot edit public changeset: %s') % root,
2033 2036 hint=_("see 'hg help phases' for details"))
2034 2037 return pycompat.maplist(repo.changelog.node, revs)
2035 2038
2036 2039 def ruleeditor(repo, ui, actions, editcomment=""):
2037 2040 """open an editor to edit rules
2038 2041
2039 2042 rules are in the format [ [act, ctx], ...] like in state.rules
2040 2043 """
2041 2044 if repo.ui.configbool("experimental", "histedit.autoverb"):
2042 2045 newact = util.sortdict()
2043 2046 for act in actions:
2044 2047 ctx = repo[act.node]
2045 2048 summary = _getsummary(ctx)
2046 2049 fword = summary.split(' ', 1)[0].lower()
2047 2050 added = False
2048 2051
2049 2052 # if it doesn't end with the special character '!' just skip this
2050 2053 if fword.endswith('!'):
2051 2054 fword = fword[:-1]
2052 2055 if fword in primaryactions | secondaryactions | tertiaryactions:
2053 2056 act.verb = fword
2054 2057 # get the target summary
2055 2058 tsum = summary[len(fword) + 1:].lstrip()
2056 2059 # safe but slow: reverse iterate over the actions so we
2057 2060 # don't clash on two commits having the same summary
2058 2061 for na, l in reversed(list(newact.iteritems())):
2059 2062 actx = repo[na.node]
2060 2063 asum = _getsummary(actx)
2061 2064 if asum == tsum:
2062 2065 added = True
2063 2066 l.append(act)
2064 2067 break
2065 2068
2066 2069 if not added:
2067 2070 newact[act] = []
2068 2071
2069 2072 # copy over and flatten the new list
2070 2073 actions = []
2071 2074 for na, l in newact.iteritems():
2072 2075 actions.append(na)
2073 2076 actions += l
2074 2077
2075 2078 rules = '\n'.join([act.torule() for act in actions])
2076 2079 rules += '\n\n'
2077 2080 rules += editcomment
2078 2081 rules = ui.edit(rules, ui.username(), {'prefix': 'histedit'},
2079 2082 repopath=repo.path, action='histedit')
2080 2083
2081 2084 # Save edit rules in .hg/histedit-last-edit.txt in case
2082 2085 # the user needs to ask for help after something
2083 2086 # surprising happens.
2084 2087 with repo.vfs('histedit-last-edit.txt', 'wb') as f:
2085 2088 f.write(rules)
2086 2089
2087 2090 return rules
2088 2091
2089 2092 def parserules(rules, state):
2090 2093 """Read the histedit rules string and return list of action objects """
2091 2094 rules = [l for l in (r.strip() for r in rules.splitlines())
2092 2095 if l and not l.startswith('#')]
2093 2096 actions = []
2094 2097 for r in rules:
2095 2098 if ' ' not in r:
2096 2099 raise error.ParseError(_('malformed line "%s"') % r)
2097 2100 verb, rest = r.split(' ', 1)
2098 2101
2099 2102 if verb not in actiontable:
2100 2103 raise error.ParseError(_('unknown action "%s"') % verb)
2101 2104
2102 2105 action = actiontable[verb].fromrule(state, rest)
2103 2106 actions.append(action)
2104 2107 return actions
2105 2108
2106 2109 def warnverifyactions(ui, repo, actions, state, ctxs):
2107 2110 try:
2108 2111 verifyactions(actions, state, ctxs)
2109 2112 except error.ParseError:
2110 2113 if repo.vfs.exists('histedit-last-edit.txt'):
2111 2114 ui.warn(_('warning: histedit rules saved '
2112 2115 'to: .hg/histedit-last-edit.txt\n'))
2113 2116 raise
2114 2117
2115 2118 def verifyactions(actions, state, ctxs):
2116 2119 """Verify that there exists exactly one action per given changeset and
2117 2120 other constraints.
2118 2121
2119 2122 Will abort if there are to many or too few rules, a malformed rule,
2120 2123 or a rule on a changeset outside of the user-given range.
2121 2124 """
2122 2125 expected = set(c.node() for c in ctxs)
2123 2126 seen = set()
2124 2127 prev = None
2125 2128
2126 2129 if actions and actions[0].verb in ['roll', 'fold']:
2127 2130 raise error.ParseError(_('first changeset cannot use verb "%s"') %
2128 2131 actions[0].verb)
2129 2132
2130 2133 for action in actions:
2131 2134 action.verify(prev, expected, seen)
2132 2135 prev = action
2133 2136 if action.node is not None:
2134 2137 seen.add(action.node)
2135 2138 missing = sorted(expected - seen) # sort to stabilize output
2136 2139
2137 2140 if state.repo.ui.configbool('histedit', 'dropmissing'):
2138 2141 if len(actions) == 0:
2139 2142 raise error.ParseError(_('no rules provided'),
2140 2143 hint=_('use strip extension to remove commits'))
2141 2144
2142 2145 drops = [drop(state, n) for n in missing]
2143 2146 # put the in the beginning so they execute immediately and
2144 2147 # don't show in the edit-plan in the future
2145 2148 actions[:0] = drops
2146 2149 elif missing:
2147 2150 raise error.ParseError(_('missing rules for changeset %s') %
2148 2151 node.short(missing[0]),
2149 2152 hint=_('use "drop %s" to discard, see also: '
2150 2153 "'hg help -e histedit.config'")
2151 2154 % node.short(missing[0]))
2152 2155
2153 2156 def adjustreplacementsfrommarkers(repo, oldreplacements):
2154 2157 """Adjust replacements from obsolescence markers
2155 2158
2156 2159 Replacements structure is originally generated based on
2157 2160 histedit's state and does not account for changes that are
2158 2161 not recorded there. This function fixes that by adding
2159 2162 data read from obsolescence markers"""
2160 2163 if not obsolete.isenabled(repo, obsolete.createmarkersopt):
2161 2164 return oldreplacements
2162 2165
2163 2166 unfi = repo.unfiltered()
2164 2167 nm = unfi.changelog.nodemap
2165 2168 obsstore = repo.obsstore
2166 2169 newreplacements = list(oldreplacements)
2167 2170 oldsuccs = [r[1] for r in oldreplacements]
2168 2171 # successors that have already been added to succstocheck once
2169 2172 seensuccs = set().union(*oldsuccs) # create a set from an iterable of tuples
2170 2173 succstocheck = list(seensuccs)
2171 2174 while succstocheck:
2172 2175 n = succstocheck.pop()
2173 2176 missing = nm.get(n) is None
2174 2177 markers = obsstore.successors.get(n, ())
2175 2178 if missing and not markers:
2176 2179 # dead end, mark it as such
2177 2180 newreplacements.append((n, ()))
2178 2181 for marker in markers:
2179 2182 nsuccs = marker[1]
2180 2183 newreplacements.append((n, nsuccs))
2181 2184 for nsucc in nsuccs:
2182 2185 if nsucc not in seensuccs:
2183 2186 seensuccs.add(nsucc)
2184 2187 succstocheck.append(nsucc)
2185 2188
2186 2189 return newreplacements
2187 2190
2188 2191 def processreplacement(state):
2189 2192 """process the list of replacements to return
2190 2193
2191 2194 1) the final mapping between original and created nodes
2192 2195 2) the list of temporary node created by histedit
2193 2196 3) the list of new commit created by histedit"""
2194 2197 replacements = adjustreplacementsfrommarkers(state.repo, state.replacements)
2195 2198 allsuccs = set()
2196 2199 replaced = set()
2197 2200 fullmapping = {}
2198 2201 # initialize basic set
2199 2202 # fullmapping records all operations recorded in replacement
2200 2203 for rep in replacements:
2201 2204 allsuccs.update(rep[1])
2202 2205 replaced.add(rep[0])
2203 2206 fullmapping.setdefault(rep[0], set()).update(rep[1])
2204 2207 new = allsuccs - replaced
2205 2208 tmpnodes = allsuccs & replaced
2206 2209 # Reduce content fullmapping into direct relation between original nodes
2207 2210 # and final node created during history edition
2208 2211 # Dropped changeset are replaced by an empty list
2209 2212 toproceed = set(fullmapping)
2210 2213 final = {}
2211 2214 while toproceed:
2212 2215 for x in list(toproceed):
2213 2216 succs = fullmapping[x]
2214 2217 for s in list(succs):
2215 2218 if s in toproceed:
2216 2219 # non final node with unknown closure
2217 2220 # We can't process this now
2218 2221 break
2219 2222 elif s in final:
2220 2223 # non final node, replace with closure
2221 2224 succs.remove(s)
2222 2225 succs.update(final[s])
2223 2226 else:
2224 2227 final[x] = succs
2225 2228 toproceed.remove(x)
2226 2229 # remove tmpnodes from final mapping
2227 2230 for n in tmpnodes:
2228 2231 del final[n]
2229 2232 # we expect all changes involved in final to exist in the repo
2230 2233 # turn `final` into list (topologically sorted)
2231 2234 nm = state.repo.changelog.nodemap
2232 2235 for prec, succs in final.items():
2233 2236 final[prec] = sorted(succs, key=nm.get)
2234 2237
2235 2238 # computed topmost element (necessary for bookmark)
2236 2239 if new:
2237 2240 newtopmost = sorted(new, key=state.repo.changelog.rev)[-1]
2238 2241 elif not final:
2239 2242 # Nothing rewritten at all. we won't need `newtopmost`
2240 2243 # It is the same as `oldtopmost` and `processreplacement` know it
2241 2244 newtopmost = None
2242 2245 else:
2243 2246 # every body died. The newtopmost is the parent of the root.
2244 2247 r = state.repo.changelog.rev
2245 2248 newtopmost = state.repo[sorted(final, key=r)[0]].p1().node()
2246 2249
2247 2250 return final, tmpnodes, new, newtopmost
2248 2251
2249 2252 def movetopmostbookmarks(repo, oldtopmost, newtopmost):
2250 2253 """Move bookmark from oldtopmost to newly created topmost
2251 2254
2252 2255 This is arguably a feature and we may only want that for the active
2253 2256 bookmark. But the behavior is kept compatible with the old version for now.
2254 2257 """
2255 2258 if not oldtopmost or not newtopmost:
2256 2259 return
2257 2260 oldbmarks = repo.nodebookmarks(oldtopmost)
2258 2261 if oldbmarks:
2259 2262 with repo.lock(), repo.transaction('histedit') as tr:
2260 2263 marks = repo._bookmarks
2261 2264 changes = []
2262 2265 for name in oldbmarks:
2263 2266 changes.append((name, newtopmost))
2264 2267 marks.applychanges(repo, tr, changes)
2265 2268
2266 2269 def cleanupnode(ui, repo, nodes, nobackup=False):
2267 2270 """strip a group of nodes from the repository
2268 2271
2269 2272 The set of node to strip may contains unknown nodes."""
2270 2273 with repo.lock():
2271 2274 # do not let filtering get in the way of the cleanse
2272 2275 # we should probably get rid of obsolescence marker created during the
2273 2276 # histedit, but we currently do not have such information.
2274 2277 repo = repo.unfiltered()
2275 2278 # Find all nodes that need to be stripped
2276 2279 # (we use %lr instead of %ln to silently ignore unknown items)
2277 2280 nm = repo.changelog.nodemap
2278 2281 nodes = sorted(n for n in nodes if n in nm)
2279 2282 roots = [c.node() for c in repo.set("roots(%ln)", nodes)]
2280 2283 if roots:
2281 2284 backup = not nobackup
2282 2285 repair.strip(ui, repo, roots, backup=backup)
2283 2286
2284 2287 def stripwrapper(orig, ui, repo, nodelist, *args, **kwargs):
2285 2288 if isinstance(nodelist, str):
2286 2289 nodelist = [nodelist]
2287 2290 state = histeditstate(repo)
2288 2291 if state.inprogress():
2289 2292 state.read()
2290 2293 histedit_nodes = {action.node for action
2291 2294 in state.actions if action.node}
2292 2295 common_nodes = histedit_nodes & set(nodelist)
2293 2296 if common_nodes:
2294 2297 raise error.Abort(_("histedit in progress, can't strip %s")
2295 2298 % ', '.join(node.short(x) for x in common_nodes))
2296 2299 return orig(ui, repo, nodelist, *args, **kwargs)
2297 2300
2298 2301 extensions.wrapfunction(repair, 'strip', stripwrapper)
2299 2302
2300 2303 def summaryhook(ui, repo):
2301 2304 state = histeditstate(repo)
2302 2305 if not state.inprogress():
2303 2306 return
2304 2307 state.read()
2305 2308 if state.actions:
2306 2309 # i18n: column positioning for "hg summary"
2307 2310 ui.write(_('hist: %s (histedit --continue)\n') %
2308 2311 (ui.label(_('%d remaining'), 'histedit.remaining') %
2309 2312 len(state.actions)))
2310 2313
2311 2314 def extsetup(ui):
2312 2315 cmdutil.summaryhooks.add('histedit', summaryhook)
2313 2316 cmdutil.unfinishedstates.append(
2314 2317 ['histedit-state', False, True, _('histedit in progress'),
2315 2318 _("use 'hg histedit --continue' or 'hg histedit --abort'")])
2316 2319 cmdutil.afterresolvedstates.append(
2317 2320 ['histedit-state', _('hg histedit --continue')])
General Comments 0
You need to be logged in to leave comments. Login now