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