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