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