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