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