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