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