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