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