##// END OF EJS Templates
merge with stable
Augie Fackler -
r35415:a5154168 merge default
parent child Browse files
Show More
@@ -1,1647 +1,1648
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 ``hg histedit`` attempts to automatically choose an appropriate base
160 160 revision to use. To change which base revision is used, define a
161 161 revset in your configuration file::
162 162
163 163 [histedit]
164 164 defaultrev = only(.) & draft()
165 165
166 166 By default each edited revision needs to be present in histedit commands.
167 167 To remove revision you need to use ``drop`` operation. You can configure
168 168 the drop to be implicit for missing commits by adding::
169 169
170 170 [histedit]
171 171 dropmissing = True
172 172
173 173 By default, histedit will close the transaction after each action. For
174 174 performance purposes, you can configure histedit to use a single transaction
175 175 across the entire histedit. WARNING: This setting introduces a significant risk
176 176 of losing the work you've done in a histedit if the histedit aborts
177 177 unexpectedly::
178 178
179 179 [histedit]
180 180 singletransaction = True
181 181
182 182 """
183 183
184 184 from __future__ import absolute_import
185 185
186 186 import errno
187 187 import os
188 188
189 189 from mercurial.i18n import _
190 190 from mercurial import (
191 191 bundle2,
192 192 cmdutil,
193 193 context,
194 194 copies,
195 195 destutil,
196 196 discovery,
197 197 error,
198 198 exchange,
199 199 extensions,
200 200 hg,
201 201 lock,
202 202 merge as mergemod,
203 203 mergeutil,
204 204 node,
205 205 obsolete,
206 206 pycompat,
207 207 registrar,
208 208 repair,
209 209 scmutil,
210 210 util,
211 211 )
212 212
213 213 pickle = util.pickle
214 214 release = lock.release
215 215 cmdtable = {}
216 216 command = registrar.command(cmdtable)
217 217
218 218 configtable = {}
219 219 configitem = registrar.configitem(configtable)
220 220 configitem('experimental', 'histedit.autoverb',
221 221 default=False,
222 222 )
223 223 configitem('histedit', 'defaultrev',
224 224 default=configitem.dynamicdefault,
225 225 )
226 226 configitem('histedit', 'dropmissing',
227 227 default=False,
228 228 )
229 229 configitem('histedit', 'linelen',
230 230 default=80,
231 231 )
232 232 configitem('histedit', 'singletransaction',
233 233 default=False,
234 234 )
235 235
236 236 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
237 237 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
238 238 # be specifying the version(s) of Mercurial they are tested with, or
239 239 # leave the attribute unspecified.
240 240 testedwith = 'ships-with-hg-core'
241 241
242 242 actiontable = {}
243 243 primaryactions = set()
244 244 secondaryactions = set()
245 245 tertiaryactions = set()
246 246 internalactions = set()
247 247
248 248 def geteditcomment(ui, first, last):
249 249 """ construct the editor comment
250 250 The comment includes::
251 251 - an intro
252 252 - sorted primary commands
253 253 - sorted short commands
254 254 - sorted long commands
255 255 - additional hints
256 256
257 257 Commands are only included once.
258 258 """
259 259 intro = _("""Edit history between %s and %s
260 260
261 261 Commits are listed from least to most recent
262 262
263 263 You can reorder changesets by reordering the lines
264 264
265 265 Commands:
266 266 """)
267 267 actions = []
268 268 def addverb(v):
269 269 a = actiontable[v]
270 270 lines = a.message.split("\n")
271 271 if len(a.verbs):
272 272 v = ', '.join(sorted(a.verbs, key=lambda v: len(v)))
273 273 actions.append(" %s = %s" % (v, lines[0]))
274 274 actions.extend([' %s' for l in lines[1:]])
275 275
276 276 for v in (
277 277 sorted(primaryactions) +
278 278 sorted(secondaryactions) +
279 279 sorted(tertiaryactions)
280 280 ):
281 281 addverb(v)
282 282 actions.append('')
283 283
284 284 hints = []
285 285 if ui.configbool('histedit', 'dropmissing'):
286 286 hints.append("Deleting a changeset from the list "
287 287 "will DISCARD it from the edited history!")
288 288
289 289 lines = (intro % (first, last)).split('\n') + actions + hints
290 290
291 291 return ''.join(['# %s\n' % l if l else '#\n' for l in lines])
292 292
293 293 class histeditstate(object):
294 294 def __init__(self, repo, parentctxnode=None, actions=None, keep=None,
295 295 topmost=None, replacements=None, lock=None, wlock=None):
296 296 self.repo = repo
297 297 self.actions = actions
298 298 self.keep = keep
299 299 self.topmost = topmost
300 300 self.parentctxnode = parentctxnode
301 301 self.lock = lock
302 302 self.wlock = wlock
303 303 self.backupfile = None
304 304 if replacements is None:
305 305 self.replacements = []
306 306 else:
307 307 self.replacements = replacements
308 308
309 309 def read(self):
310 310 """Load histedit state from disk and set fields appropriately."""
311 311 try:
312 312 state = self.repo.vfs.read('histedit-state')
313 313 except IOError as err:
314 314 if err.errno != errno.ENOENT:
315 315 raise
316 316 cmdutil.wrongtooltocontinue(self.repo, _('histedit'))
317 317
318 318 if state.startswith('v1\n'):
319 319 data = self._load()
320 320 parentctxnode, rules, keep, topmost, replacements, backupfile = data
321 321 else:
322 322 data = pickle.loads(state)
323 323 parentctxnode, rules, keep, topmost, replacements = data
324 324 backupfile = None
325 325
326 326 self.parentctxnode = parentctxnode
327 327 rules = "\n".join(["%s %s" % (verb, rest) for [verb, rest] in rules])
328 328 actions = parserules(rules, self)
329 329 self.actions = actions
330 330 self.keep = keep
331 331 self.topmost = topmost
332 332 self.replacements = replacements
333 333 self.backupfile = backupfile
334 334
335 335 def write(self, tr=None):
336 336 if tr:
337 337 tr.addfilegenerator('histedit-state', ('histedit-state',),
338 338 self._write, location='plain')
339 339 else:
340 340 with self.repo.vfs("histedit-state", "w") as f:
341 341 self._write(f)
342 342
343 343 def _write(self, fp):
344 344 fp.write('v1\n')
345 345 fp.write('%s\n' % node.hex(self.parentctxnode))
346 346 fp.write('%s\n' % node.hex(self.topmost))
347 347 fp.write('%s\n' % self.keep)
348 348 fp.write('%d\n' % len(self.actions))
349 349 for action in self.actions:
350 350 fp.write('%s\n' % action.tostate())
351 351 fp.write('%d\n' % len(self.replacements))
352 352 for replacement in self.replacements:
353 353 fp.write('%s%s\n' % (node.hex(replacement[0]), ''.join(node.hex(r)
354 354 for r in replacement[1])))
355 355 backupfile = self.backupfile
356 356 if not backupfile:
357 357 backupfile = ''
358 358 fp.write('%s\n' % backupfile)
359 359
360 360 def _load(self):
361 361 fp = self.repo.vfs('histedit-state', 'r')
362 362 lines = [l[:-1] for l in fp.readlines()]
363 363
364 364 index = 0
365 365 lines[index] # version number
366 366 index += 1
367 367
368 368 parentctxnode = node.bin(lines[index])
369 369 index += 1
370 370
371 371 topmost = node.bin(lines[index])
372 372 index += 1
373 373
374 374 keep = lines[index] == 'True'
375 375 index += 1
376 376
377 377 # Rules
378 378 rules = []
379 379 rulelen = int(lines[index])
380 380 index += 1
381 381 for i in xrange(rulelen):
382 382 ruleaction = lines[index]
383 383 index += 1
384 384 rule = lines[index]
385 385 index += 1
386 386 rules.append((ruleaction, rule))
387 387
388 388 # Replacements
389 389 replacements = []
390 390 replacementlen = int(lines[index])
391 391 index += 1
392 392 for i in xrange(replacementlen):
393 393 replacement = lines[index]
394 394 original = node.bin(replacement[:40])
395 395 succ = [node.bin(replacement[i:i + 40]) for i in
396 396 range(40, len(replacement), 40)]
397 397 replacements.append((original, succ))
398 398 index += 1
399 399
400 400 backupfile = lines[index]
401 401 index += 1
402 402
403 403 fp.close()
404 404
405 405 return parentctxnode, rules, keep, topmost, replacements, backupfile
406 406
407 407 def clear(self):
408 408 if self.inprogress():
409 409 self.repo.vfs.unlink('histedit-state')
410 410
411 411 def inprogress(self):
412 412 return self.repo.vfs.exists('histedit-state')
413 413
414 414
415 415 class histeditaction(object):
416 416 def __init__(self, state, node):
417 417 self.state = state
418 418 self.repo = state.repo
419 419 self.node = node
420 420
421 421 @classmethod
422 422 def fromrule(cls, state, rule):
423 423 """Parses the given rule, returning an instance of the histeditaction.
424 424 """
425 425 rulehash = rule.strip().split(' ', 1)[0]
426 426 try:
427 427 rev = node.bin(rulehash)
428 428 except TypeError:
429 429 raise error.ParseError("invalid changeset %s" % rulehash)
430 430 return cls(state, rev)
431 431
432 432 def verify(self, prev, expected, seen):
433 433 """ Verifies semantic correctness of the rule"""
434 434 repo = self.repo
435 435 ha = node.hex(self.node)
436 436 try:
437 437 self.node = repo[ha].node()
438 438 except error.RepoError:
439 439 raise error.ParseError(_('unknown changeset %s listed')
440 440 % ha[:12])
441 441 if self.node is not None:
442 442 self._verifynodeconstraints(prev, expected, seen)
443 443
444 444 def _verifynodeconstraints(self, prev, expected, seen):
445 445 # by default command need a node in the edited list
446 446 if self.node not in expected:
447 447 raise error.ParseError(_('%s "%s" changeset was not a candidate')
448 448 % (self.verb, node.short(self.node)),
449 449 hint=_('only use listed changesets'))
450 450 # and only one command per node
451 451 if self.node in seen:
452 452 raise error.ParseError(_('duplicated command for changeset %s') %
453 453 node.short(self.node))
454 454
455 455 def torule(self):
456 456 """build a histedit rule line for an action
457 457
458 458 by default lines are in the form:
459 459 <hash> <rev> <summary>
460 460 """
461 461 ctx = self.repo[self.node]
462 462 summary = _getsummary(ctx)
463 463 line = '%s %s %d %s' % (self.verb, ctx, ctx.rev(), summary)
464 464 # trim to 75 columns by default so it's not stupidly wide in my editor
465 465 # (the 5 more are left for verb)
466 466 maxlen = self.repo.ui.configint('histedit', 'linelen')
467 467 maxlen = max(maxlen, 22) # avoid truncating hash
468 468 return util.ellipsis(line, maxlen)
469 469
470 470 def tostate(self):
471 471 """Print an action in format used by histedit state files
472 472 (the first line is a verb, the remainder is the second)
473 473 """
474 474 return "%s\n%s" % (self.verb, node.hex(self.node))
475 475
476 476 def run(self):
477 477 """Runs the action. The default behavior is simply apply the action's
478 478 rulectx onto the current parentctx."""
479 479 self.applychange()
480 480 self.continuedirty()
481 481 return self.continueclean()
482 482
483 483 def applychange(self):
484 484 """Applies the changes from this action's rulectx onto the current
485 485 parentctx, but does not commit them."""
486 486 repo = self.repo
487 487 rulectx = repo[self.node]
488 488 repo.ui.pushbuffer(error=True, labeled=True)
489 489 hg.update(repo, self.state.parentctxnode, quietempty=True)
490 490 stats = applychanges(repo.ui, repo, rulectx, {})
491 repo.dirstate.setbranch(rulectx.branch())
491 492 if stats and stats[3] > 0:
492 493 buf = repo.ui.popbuffer()
493 494 repo.ui.write(*buf)
494 495 raise error.InterventionRequired(
495 496 _('Fix up the change (%s %s)') %
496 497 (self.verb, node.short(self.node)),
497 498 hint=_('hg histedit --continue to resume'))
498 499 else:
499 500 repo.ui.popbuffer()
500 501
501 502 def continuedirty(self):
502 503 """Continues the action when changes have been applied to the working
503 504 copy. The default behavior is to commit the dirty changes."""
504 505 repo = self.repo
505 506 rulectx = repo[self.node]
506 507
507 508 editor = self.commiteditor()
508 509 commit = commitfuncfor(repo, rulectx)
509 510
510 511 commit(text=rulectx.description(), user=rulectx.user(),
511 512 date=rulectx.date(), extra=rulectx.extra(), editor=editor)
512 513
513 514 def commiteditor(self):
514 515 """The editor to be used to edit the commit message."""
515 516 return False
516 517
517 518 def continueclean(self):
518 519 """Continues the action when the working copy is clean. The default
519 520 behavior is to accept the current commit as the new version of the
520 521 rulectx."""
521 522 ctx = self.repo['.']
522 523 if ctx.node() == self.state.parentctxnode:
523 524 self.repo.ui.warn(_('%s: skipping changeset (no changes)\n') %
524 525 node.short(self.node))
525 526 return ctx, [(self.node, tuple())]
526 527 if ctx.node() == self.node:
527 528 # Nothing changed
528 529 return ctx, []
529 530 return ctx, [(self.node, (ctx.node(),))]
530 531
531 532 def commitfuncfor(repo, src):
532 533 """Build a commit function for the replacement of <src>
533 534
534 535 This function ensure we apply the same treatment to all changesets.
535 536
536 537 - Add a 'histedit_source' entry in extra.
537 538
538 539 Note that fold has its own separated logic because its handling is a bit
539 540 different and not easily factored out of the fold method.
540 541 """
541 542 phasemin = src.phase()
542 543 def commitfunc(**kwargs):
543 544 overrides = {('phases', 'new-commit'): phasemin}
544 545 with repo.ui.configoverride(overrides, 'histedit'):
545 546 extra = kwargs.get(r'extra', {}).copy()
546 547 extra['histedit_source'] = src.hex()
547 548 kwargs[r'extra'] = extra
548 549 return repo.commit(**kwargs)
549 550 return commitfunc
550 551
551 552 def applychanges(ui, repo, ctx, opts):
552 553 """Merge changeset from ctx (only) in the current working directory"""
553 554 wcpar = repo.dirstate.parents()[0]
554 555 if ctx.p1().node() == wcpar:
555 556 # edits are "in place" we do not need to make any merge,
556 557 # just applies changes on parent for editing
557 558 cmdutil.revert(ui, repo, ctx, (wcpar, node.nullid), all=True)
558 559 stats = None
559 560 else:
560 561 try:
561 562 # ui.forcemerge is an internal variable, do not document
562 563 repo.ui.setconfig('ui', 'forcemerge', opts.get('tool', ''),
563 564 'histedit')
564 565 stats = mergemod.graft(repo, ctx, ctx.p1(), ['local', 'histedit'])
565 566 finally:
566 567 repo.ui.setconfig('ui', 'forcemerge', '', 'histedit')
567 568 return stats
568 569
569 570 def collapse(repo, first, last, commitopts, skipprompt=False):
570 571 """collapse the set of revisions from first to last as new one.
571 572
572 573 Expected commit options are:
573 574 - message
574 575 - date
575 576 - username
576 577 Commit message is edited in all cases.
577 578
578 579 This function works in memory."""
579 580 ctxs = list(repo.set('%d::%d', first, last))
580 581 if not ctxs:
581 582 return None
582 583 for c in ctxs:
583 584 if not c.mutable():
584 585 raise error.ParseError(
585 586 _("cannot fold into public change %s") % node.short(c.node()))
586 587 base = first.parents()[0]
587 588
588 589 # commit a new version of the old changeset, including the update
589 590 # collect all files which might be affected
590 591 files = set()
591 592 for ctx in ctxs:
592 593 files.update(ctx.files())
593 594
594 595 # Recompute copies (avoid recording a -> b -> a)
595 596 copied = copies.pathcopies(base, last)
596 597
597 598 # prune files which were reverted by the updates
598 599 files = [f for f in files if not cmdutil.samefile(f, last, base)]
599 600 # commit version of these files as defined by head
600 601 headmf = last.manifest()
601 602 def filectxfn(repo, ctx, path):
602 603 if path in headmf:
603 604 fctx = last[path]
604 605 flags = fctx.flags()
605 606 mctx = context.memfilectx(repo, ctx,
606 607 fctx.path(), fctx.data(),
607 608 islink='l' in flags,
608 609 isexec='x' in flags,
609 610 copied=copied.get(path))
610 611 return mctx
611 612 return None
612 613
613 614 if commitopts.get('message'):
614 615 message = commitopts['message']
615 616 else:
616 617 message = first.description()
617 618 user = commitopts.get('user')
618 619 date = commitopts.get('date')
619 620 extra = commitopts.get('extra')
620 621
621 622 parents = (first.p1().node(), first.p2().node())
622 623 editor = None
623 624 if not skipprompt:
624 625 editor = cmdutil.getcommiteditor(edit=True, editform='histedit.fold')
625 626 new = context.memctx(repo,
626 627 parents=parents,
627 628 text=message,
628 629 files=files,
629 630 filectxfn=filectxfn,
630 631 user=user,
631 632 date=date,
632 633 extra=extra,
633 634 editor=editor)
634 635 return repo.commitctx(new)
635 636
636 637 def _isdirtywc(repo):
637 638 return repo[None].dirty(missing=True)
638 639
639 640 def abortdirty():
640 641 raise error.Abort(_('working copy has pending changes'),
641 642 hint=_('amend, commit, or revert them and run histedit '
642 643 '--continue, or abort with histedit --abort'))
643 644
644 645 def action(verbs, message, priority=False, internal=False):
645 646 def wrap(cls):
646 647 assert not priority or not internal
647 648 verb = verbs[0]
648 649 if priority:
649 650 primaryactions.add(verb)
650 651 elif internal:
651 652 internalactions.add(verb)
652 653 elif len(verbs) > 1:
653 654 secondaryactions.add(verb)
654 655 else:
655 656 tertiaryactions.add(verb)
656 657
657 658 cls.verb = verb
658 659 cls.verbs = verbs
659 660 cls.message = message
660 661 for verb in verbs:
661 662 actiontable[verb] = cls
662 663 return cls
663 664 return wrap
664 665
665 666 @action(['pick', 'p'],
666 667 _('use commit'),
667 668 priority=True)
668 669 class pick(histeditaction):
669 670 def run(self):
670 671 rulectx = self.repo[self.node]
671 672 if rulectx.parents()[0].node() == self.state.parentctxnode:
672 673 self.repo.ui.debug('node %s unchanged\n' % node.short(self.node))
673 674 return rulectx, []
674 675
675 676 return super(pick, self).run()
676 677
677 678 @action(['edit', 'e'],
678 679 _('use commit, but stop for amending'),
679 680 priority=True)
680 681 class edit(histeditaction):
681 682 def run(self):
682 683 repo = self.repo
683 684 rulectx = repo[self.node]
684 685 hg.update(repo, self.state.parentctxnode, quietempty=True)
685 686 applychanges(repo.ui, repo, rulectx, {})
686 687 raise error.InterventionRequired(
687 688 _('Editing (%s), you may commit or record as needed now.')
688 689 % node.short(self.node),
689 690 hint=_('hg histedit --continue to resume'))
690 691
691 692 def commiteditor(self):
692 693 return cmdutil.getcommiteditor(edit=True, editform='histedit.edit')
693 694
694 695 @action(['fold', 'f'],
695 696 _('use commit, but combine it with the one above'))
696 697 class fold(histeditaction):
697 698 def verify(self, prev, expected, seen):
698 699 """ Verifies semantic correctness of the fold rule"""
699 700 super(fold, self).verify(prev, expected, seen)
700 701 repo = self.repo
701 702 if not prev:
702 703 c = repo[self.node].parents()[0]
703 704 elif not prev.verb in ('pick', 'base'):
704 705 return
705 706 else:
706 707 c = repo[prev.node]
707 708 if not c.mutable():
708 709 raise error.ParseError(
709 710 _("cannot fold into public change %s") % node.short(c.node()))
710 711
711 712
712 713 def continuedirty(self):
713 714 repo = self.repo
714 715 rulectx = repo[self.node]
715 716
716 717 commit = commitfuncfor(repo, rulectx)
717 718 commit(text='fold-temp-revision %s' % node.short(self.node),
718 719 user=rulectx.user(), date=rulectx.date(),
719 720 extra=rulectx.extra())
720 721
721 722 def continueclean(self):
722 723 repo = self.repo
723 724 ctx = repo['.']
724 725 rulectx = repo[self.node]
725 726 parentctxnode = self.state.parentctxnode
726 727 if ctx.node() == parentctxnode:
727 728 repo.ui.warn(_('%s: empty changeset\n') %
728 729 node.short(self.node))
729 730 return ctx, [(self.node, (parentctxnode,))]
730 731
731 732 parentctx = repo[parentctxnode]
732 733 newcommits = set(c.node() for c in repo.set('(%d::. - %d)', parentctx,
733 734 parentctx))
734 735 if not newcommits:
735 736 repo.ui.warn(_('%s: cannot fold - working copy is not a '
736 737 'descendant of previous commit %s\n') %
737 738 (node.short(self.node), node.short(parentctxnode)))
738 739 return ctx, [(self.node, (ctx.node(),))]
739 740
740 741 middlecommits = newcommits.copy()
741 742 middlecommits.discard(ctx.node())
742 743
743 744 return self.finishfold(repo.ui, repo, parentctx, rulectx, ctx.node(),
744 745 middlecommits)
745 746
746 747 def skipprompt(self):
747 748 """Returns true if the rule should skip the message editor.
748 749
749 750 For example, 'fold' wants to show an editor, but 'rollup'
750 751 doesn't want to.
751 752 """
752 753 return False
753 754
754 755 def mergedescs(self):
755 756 """Returns true if the rule should merge messages of multiple changes.
756 757
757 758 This exists mainly so that 'rollup' rules can be a subclass of
758 759 'fold'.
759 760 """
760 761 return True
761 762
762 763 def firstdate(self):
763 764 """Returns true if the rule should preserve the date of the first
764 765 change.
765 766
766 767 This exists mainly so that 'rollup' rules can be a subclass of
767 768 'fold'.
768 769 """
769 770 return False
770 771
771 772 def finishfold(self, ui, repo, ctx, oldctx, newnode, internalchanges):
772 773 parent = ctx.parents()[0].node()
773 774 repo.ui.pushbuffer()
774 775 hg.update(repo, parent)
775 776 repo.ui.popbuffer()
776 777 ### prepare new commit data
777 778 commitopts = {}
778 779 commitopts['user'] = ctx.user()
779 780 # commit message
780 781 if not self.mergedescs():
781 782 newmessage = ctx.description()
782 783 else:
783 784 newmessage = '\n***\n'.join(
784 785 [ctx.description()] +
785 786 [repo[r].description() for r in internalchanges] +
786 787 [oldctx.description()]) + '\n'
787 788 commitopts['message'] = newmessage
788 789 # date
789 790 if self.firstdate():
790 791 commitopts['date'] = ctx.date()
791 792 else:
792 793 commitopts['date'] = max(ctx.date(), oldctx.date())
793 794 extra = ctx.extra().copy()
794 795 # histedit_source
795 796 # note: ctx is likely a temporary commit but that the best we can do
796 797 # here. This is sufficient to solve issue3681 anyway.
797 798 extra['histedit_source'] = '%s,%s' % (ctx.hex(), oldctx.hex())
798 799 commitopts['extra'] = extra
799 800 phasemin = max(ctx.phase(), oldctx.phase())
800 801 overrides = {('phases', 'new-commit'): phasemin}
801 802 with repo.ui.configoverride(overrides, 'histedit'):
802 803 n = collapse(repo, ctx, repo[newnode], commitopts,
803 804 skipprompt=self.skipprompt())
804 805 if n is None:
805 806 return ctx, []
806 807 repo.ui.pushbuffer()
807 808 hg.update(repo, n)
808 809 repo.ui.popbuffer()
809 810 replacements = [(oldctx.node(), (newnode,)),
810 811 (ctx.node(), (n,)),
811 812 (newnode, (n,)),
812 813 ]
813 814 for ich in internalchanges:
814 815 replacements.append((ich, (n,)))
815 816 return repo[n], replacements
816 817
817 818 @action(['base', 'b'],
818 819 _('checkout changeset and apply further changesets from there'))
819 820 class base(histeditaction):
820 821
821 822 def run(self):
822 823 if self.repo['.'].node() != self.node:
823 824 mergemod.update(self.repo, self.node, False, True)
824 825 # branchmerge, force)
825 826 return self.continueclean()
826 827
827 828 def continuedirty(self):
828 829 abortdirty()
829 830
830 831 def continueclean(self):
831 832 basectx = self.repo['.']
832 833 return basectx, []
833 834
834 835 def _verifynodeconstraints(self, prev, expected, seen):
835 836 # base can only be use with a node not in the edited set
836 837 if self.node in expected:
837 838 msg = _('%s "%s" changeset was an edited list candidate')
838 839 raise error.ParseError(
839 840 msg % (self.verb, node.short(self.node)),
840 841 hint=_('base must only use unlisted changesets'))
841 842
842 843 @action(['_multifold'],
843 844 _(
844 845 """fold subclass used for when multiple folds happen in a row
845 846
846 847 We only want to fire the editor for the folded message once when
847 848 (say) four changes are folded down into a single change. This is
848 849 similar to rollup, but we should preserve both messages so that
849 850 when the last fold operation runs we can show the user all the
850 851 commit messages in their editor.
851 852 """),
852 853 internal=True)
853 854 class _multifold(fold):
854 855 def skipprompt(self):
855 856 return True
856 857
857 858 @action(["roll", "r"],
858 859 _("like fold, but discard this commit's description and date"))
859 860 class rollup(fold):
860 861 def mergedescs(self):
861 862 return False
862 863
863 864 def skipprompt(self):
864 865 return True
865 866
866 867 def firstdate(self):
867 868 return True
868 869
869 870 @action(["drop", "d"],
870 871 _('remove commit from history'))
871 872 class drop(histeditaction):
872 873 def run(self):
873 874 parentctx = self.repo[self.state.parentctxnode]
874 875 return parentctx, [(self.node, tuple())]
875 876
876 877 @action(["mess", "m"],
877 878 _('edit commit message without changing commit content'),
878 879 priority=True)
879 880 class message(histeditaction):
880 881 def commiteditor(self):
881 882 return cmdutil.getcommiteditor(edit=True, editform='histedit.mess')
882 883
883 884 def findoutgoing(ui, repo, remote=None, force=False, opts=None):
884 885 """utility function to find the first outgoing changeset
885 886
886 887 Used by initialization code"""
887 888 if opts is None:
888 889 opts = {}
889 890 dest = ui.expandpath(remote or 'default-push', remote or 'default')
890 891 dest, revs = hg.parseurl(dest, None)[:2]
891 892 ui.status(_('comparing with %s\n') % util.hidepassword(dest))
892 893
893 894 revs, checkout = hg.addbranchrevs(repo, repo, revs, None)
894 895 other = hg.peer(repo, opts, dest)
895 896
896 897 if revs:
897 898 revs = [repo.lookup(rev) for rev in revs]
898 899
899 900 outgoing = discovery.findcommonoutgoing(repo, other, revs, force=force)
900 901 if not outgoing.missing:
901 902 raise error.Abort(_('no outgoing ancestors'))
902 903 roots = list(repo.revs("roots(%ln)", outgoing.missing))
903 904 if 1 < len(roots):
904 905 msg = _('there are ambiguous outgoing revisions')
905 906 hint = _("see 'hg help histedit' for more detail")
906 907 raise error.Abort(msg, hint=hint)
907 908 return repo.lookup(roots[0])
908 909
909 910 @command('histedit',
910 911 [('', 'commands', '',
911 912 _('read history edits from the specified file'), _('FILE')),
912 913 ('c', 'continue', False, _('continue an edit already in progress')),
913 914 ('', 'edit-plan', False, _('edit remaining actions list')),
914 915 ('k', 'keep', False,
915 916 _("don't strip old nodes after edit is complete")),
916 917 ('', 'abort', False, _('abort an edit in progress')),
917 918 ('o', 'outgoing', False, _('changesets not found in destination')),
918 919 ('f', 'force', False,
919 920 _('force outgoing even for unrelated repositories')),
920 921 ('r', 'rev', [], _('first revision to be edited'), _('REV'))] +
921 922 cmdutil.formatteropts,
922 923 _("[OPTIONS] ([ANCESTOR] | --outgoing [URL])"))
923 924 def histedit(ui, repo, *freeargs, **opts):
924 925 """interactively edit changeset history
925 926
926 927 This command lets you edit a linear series of changesets (up to
927 928 and including the working directory, which should be clean).
928 929 You can:
929 930
930 931 - `pick` to [re]order a changeset
931 932
932 933 - `drop` to omit changeset
933 934
934 935 - `mess` to reword the changeset commit message
935 936
936 937 - `fold` to combine it with the preceding changeset (using the later date)
937 938
938 939 - `roll` like fold, but discarding this commit's description and date
939 940
940 941 - `edit` to edit this changeset (preserving date)
941 942
942 943 - `base` to checkout changeset and apply further changesets from there
943 944
944 945 There are a number of ways to select the root changeset:
945 946
946 947 - Specify ANCESTOR directly
947 948
948 949 - Use --outgoing -- it will be the first linear changeset not
949 950 included in destination. (See :hg:`help config.paths.default-push`)
950 951
951 952 - Otherwise, the value from the "histedit.defaultrev" config option
952 953 is used as a revset to select the base revision when ANCESTOR is not
953 954 specified. The first revision returned by the revset is used. By
954 955 default, this selects the editable history that is unique to the
955 956 ancestry of the working directory.
956 957
957 958 .. container:: verbose
958 959
959 960 If you use --outgoing, this command will abort if there are ambiguous
960 961 outgoing revisions. For example, if there are multiple branches
961 962 containing outgoing revisions.
962 963
963 964 Use "min(outgoing() and ::.)" or similar revset specification
964 965 instead of --outgoing to specify edit target revision exactly in
965 966 such ambiguous situation. See :hg:`help revsets` for detail about
966 967 selecting revisions.
967 968
968 969 .. container:: verbose
969 970
970 971 Examples:
971 972
972 973 - A number of changes have been made.
973 974 Revision 3 is no longer needed.
974 975
975 976 Start history editing from revision 3::
976 977
977 978 hg histedit -r 3
978 979
979 980 An editor opens, containing the list of revisions,
980 981 with specific actions specified::
981 982
982 983 pick 5339bf82f0ca 3 Zworgle the foobar
983 984 pick 8ef592ce7cc4 4 Bedazzle the zerlog
984 985 pick 0a9639fcda9d 5 Morgify the cromulancy
985 986
986 987 Additional information about the possible actions
987 988 to take appears below the list of revisions.
988 989
989 990 To remove revision 3 from the history,
990 991 its action (at the beginning of the relevant line)
991 992 is changed to 'drop'::
992 993
993 994 drop 5339bf82f0ca 3 Zworgle the foobar
994 995 pick 8ef592ce7cc4 4 Bedazzle the zerlog
995 996 pick 0a9639fcda9d 5 Morgify the cromulancy
996 997
997 998 - A number of changes have been made.
998 999 Revision 2 and 4 need to be swapped.
999 1000
1000 1001 Start history editing from revision 2::
1001 1002
1002 1003 hg histedit -r 2
1003 1004
1004 1005 An editor opens, containing the list of revisions,
1005 1006 with specific actions specified::
1006 1007
1007 1008 pick 252a1af424ad 2 Blorb a morgwazzle
1008 1009 pick 5339bf82f0ca 3 Zworgle the foobar
1009 1010 pick 8ef592ce7cc4 4 Bedazzle the zerlog
1010 1011
1011 1012 To swap revision 2 and 4, its lines are swapped
1012 1013 in the editor::
1013 1014
1014 1015 pick 8ef592ce7cc4 4 Bedazzle the zerlog
1015 1016 pick 5339bf82f0ca 3 Zworgle the foobar
1016 1017 pick 252a1af424ad 2 Blorb a morgwazzle
1017 1018
1018 1019 Returns 0 on success, 1 if user intervention is required (not only
1019 1020 for intentional "edit" command, but also for resolving unexpected
1020 1021 conflicts).
1021 1022 """
1022 1023 state = histeditstate(repo)
1023 1024 try:
1024 1025 state.wlock = repo.wlock()
1025 1026 state.lock = repo.lock()
1026 1027 _histedit(ui, repo, state, *freeargs, **opts)
1027 1028 finally:
1028 1029 release(state.lock, state.wlock)
1029 1030
1030 1031 goalcontinue = 'continue'
1031 1032 goalabort = 'abort'
1032 1033 goaleditplan = 'edit-plan'
1033 1034 goalnew = 'new'
1034 1035
1035 1036 def _getgoal(opts):
1036 1037 if opts.get('continue'):
1037 1038 return goalcontinue
1038 1039 if opts.get('abort'):
1039 1040 return goalabort
1040 1041 if opts.get('edit_plan'):
1041 1042 return goaleditplan
1042 1043 return goalnew
1043 1044
1044 1045 def _readfile(ui, path):
1045 1046 if path == '-':
1046 1047 with ui.timeblockedsection('histedit'):
1047 1048 return ui.fin.read()
1048 1049 else:
1049 1050 with open(path, 'rb') as f:
1050 1051 return f.read()
1051 1052
1052 1053 def _validateargs(ui, repo, state, freeargs, opts, goal, rules, revs):
1053 1054 # TODO only abort if we try to histedit mq patches, not just
1054 1055 # blanket if mq patches are applied somewhere
1055 1056 mq = getattr(repo, 'mq', None)
1056 1057 if mq and mq.applied:
1057 1058 raise error.Abort(_('source has mq patches applied'))
1058 1059
1059 1060 # basic argument incompatibility processing
1060 1061 outg = opts.get('outgoing')
1061 1062 editplan = opts.get('edit_plan')
1062 1063 abort = opts.get('abort')
1063 1064 force = opts.get('force')
1064 1065 if force and not outg:
1065 1066 raise error.Abort(_('--force only allowed with --outgoing'))
1066 1067 if goal == 'continue':
1067 1068 if any((outg, abort, revs, freeargs, rules, editplan)):
1068 1069 raise error.Abort(_('no arguments allowed with --continue'))
1069 1070 elif goal == 'abort':
1070 1071 if any((outg, revs, freeargs, rules, editplan)):
1071 1072 raise error.Abort(_('no arguments allowed with --abort'))
1072 1073 elif goal == 'edit-plan':
1073 1074 if any((outg, revs, freeargs)):
1074 1075 raise error.Abort(_('only --commands argument allowed with '
1075 1076 '--edit-plan'))
1076 1077 else:
1077 1078 if os.path.exists(os.path.join(repo.path, 'histedit-state')):
1078 1079 raise error.Abort(_('history edit already in progress, try '
1079 1080 '--continue or --abort'))
1080 1081 if outg:
1081 1082 if revs:
1082 1083 raise error.Abort(_('no revisions allowed with --outgoing'))
1083 1084 if len(freeargs) > 1:
1084 1085 raise error.Abort(
1085 1086 _('only one repo argument allowed with --outgoing'))
1086 1087 else:
1087 1088 revs.extend(freeargs)
1088 1089 if len(revs) == 0:
1089 1090 defaultrev = destutil.desthistedit(ui, repo)
1090 1091 if defaultrev is not None:
1091 1092 revs.append(defaultrev)
1092 1093
1093 1094 if len(revs) != 1:
1094 1095 raise error.Abort(
1095 1096 _('histedit requires exactly one ancestor revision'))
1096 1097
1097 1098 def _histedit(ui, repo, state, *freeargs, **opts):
1098 1099 opts = pycompat.byteskwargs(opts)
1099 1100 fm = ui.formatter('histedit', opts)
1100 1101 fm.startitem()
1101 1102 goal = _getgoal(opts)
1102 1103 revs = opts.get('rev', [])
1103 1104 rules = opts.get('commands', '')
1104 1105 state.keep = opts.get('keep', False)
1105 1106
1106 1107 _validateargs(ui, repo, state, freeargs, opts, goal, rules, revs)
1107 1108
1108 1109 # rebuild state
1109 1110 if goal == goalcontinue:
1110 1111 state.read()
1111 1112 state = bootstrapcontinue(ui, state, opts)
1112 1113 elif goal == goaleditplan:
1113 1114 _edithisteditplan(ui, repo, state, rules)
1114 1115 return
1115 1116 elif goal == goalabort:
1116 1117 _aborthistedit(ui, repo, state)
1117 1118 return
1118 1119 else:
1119 1120 # goal == goalnew
1120 1121 _newhistedit(ui, repo, state, revs, freeargs, opts)
1121 1122
1122 1123 _continuehistedit(ui, repo, state)
1123 1124 _finishhistedit(ui, repo, state, fm)
1124 1125 fm.end()
1125 1126
1126 1127 def _continuehistedit(ui, repo, state):
1127 1128 """This function runs after either:
1128 1129 - bootstrapcontinue (if the goal is 'continue')
1129 1130 - _newhistedit (if the goal is 'new')
1130 1131 """
1131 1132 # preprocess rules so that we can hide inner folds from the user
1132 1133 # and only show one editor
1133 1134 actions = state.actions[:]
1134 1135 for idx, (action, nextact) in enumerate(
1135 1136 zip(actions, actions[1:] + [None])):
1136 1137 if action.verb == 'fold' and nextact and nextact.verb == 'fold':
1137 1138 state.actions[idx].__class__ = _multifold
1138 1139
1139 1140 # Force an initial state file write, so the user can run --abort/continue
1140 1141 # even if there's an exception before the first transaction serialize.
1141 1142 state.write()
1142 1143
1143 1144 total = len(state.actions)
1144 1145 pos = 0
1145 1146 tr = None
1146 1147 # Don't use singletransaction by default since it rolls the entire
1147 1148 # transaction back if an unexpected exception happens (like a
1148 1149 # pretxncommit hook throws, or the user aborts the commit msg editor).
1149 1150 if ui.configbool("histedit", "singletransaction"):
1150 1151 # Don't use a 'with' for the transaction, since actions may close
1151 1152 # and reopen a transaction. For example, if the action executes an
1152 1153 # external process it may choose to commit the transaction first.
1153 1154 tr = repo.transaction('histedit')
1154 1155 with util.acceptintervention(tr):
1155 1156 while state.actions:
1156 1157 state.write(tr=tr)
1157 1158 actobj = state.actions[0]
1158 1159 pos += 1
1159 1160 ui.progress(_("editing"), pos, actobj.torule(),
1160 1161 _('changes'), total)
1161 1162 ui.debug('histedit: processing %s %s\n' % (actobj.verb,\
1162 1163 actobj.torule()))
1163 1164 parentctx, replacement_ = actobj.run()
1164 1165 state.parentctxnode = parentctx.node()
1165 1166 state.replacements.extend(replacement_)
1166 1167 state.actions.pop(0)
1167 1168
1168 1169 state.write()
1169 1170 ui.progress(_("editing"), None)
1170 1171
1171 1172 def _finishhistedit(ui, repo, state, fm):
1172 1173 """This action runs when histedit is finishing its session"""
1173 1174 repo.ui.pushbuffer()
1174 1175 hg.update(repo, state.parentctxnode, quietempty=True)
1175 1176 repo.ui.popbuffer()
1176 1177
1177 1178 mapping, tmpnodes, created, ntm = processreplacement(state)
1178 1179 if mapping:
1179 1180 for prec, succs in mapping.iteritems():
1180 1181 if not succs:
1181 1182 ui.debug('histedit: %s is dropped\n' % node.short(prec))
1182 1183 else:
1183 1184 ui.debug('histedit: %s is replaced by %s\n' % (
1184 1185 node.short(prec), node.short(succs[0])))
1185 1186 if len(succs) > 1:
1186 1187 m = 'histedit: %s'
1187 1188 for n in succs[1:]:
1188 1189 ui.debug(m % node.short(n))
1189 1190
1190 1191 if not state.keep:
1191 1192 if mapping:
1192 1193 movetopmostbookmarks(repo, state.topmost, ntm)
1193 1194 # TODO update mq state
1194 1195 else:
1195 1196 mapping = {}
1196 1197
1197 1198 for n in tmpnodes:
1198 1199 mapping[n] = ()
1199 1200
1200 1201 # remove entries about unknown nodes
1201 1202 nodemap = repo.unfiltered().changelog.nodemap
1202 1203 mapping = {k: v for k, v in mapping.items()
1203 1204 if k in nodemap and all(n in nodemap for n in v)}
1204 1205 scmutil.cleanupnodes(repo, mapping, 'histedit')
1205 1206 hf = fm.hexfunc
1206 1207 fl = fm.formatlist
1207 1208 fd = fm.formatdict
1208 1209 nodechanges = fd({hf(oldn): fl([hf(n) for n in newn], name='node')
1209 1210 for oldn, newn in mapping.iteritems()},
1210 1211 key="oldnode", value="newnodes")
1211 1212 fm.data(nodechanges=nodechanges)
1212 1213
1213 1214 state.clear()
1214 1215 if os.path.exists(repo.sjoin('undo')):
1215 1216 os.unlink(repo.sjoin('undo'))
1216 1217 if repo.vfs.exists('histedit-last-edit.txt'):
1217 1218 repo.vfs.unlink('histedit-last-edit.txt')
1218 1219
1219 1220 def _aborthistedit(ui, repo, state):
1220 1221 try:
1221 1222 state.read()
1222 1223 __, leafs, tmpnodes, __ = processreplacement(state)
1223 1224 ui.debug('restore wc to old parent %s\n'
1224 1225 % node.short(state.topmost))
1225 1226
1226 1227 # Recover our old commits if necessary
1227 1228 if not state.topmost in repo and state.backupfile:
1228 1229 backupfile = repo.vfs.join(state.backupfile)
1229 1230 f = hg.openpath(ui, backupfile)
1230 1231 gen = exchange.readbundle(ui, f, backupfile)
1231 1232 with repo.transaction('histedit.abort') as tr:
1232 1233 bundle2.applybundle(repo, gen, tr, source='histedit',
1233 1234 url='bundle:' + backupfile)
1234 1235
1235 1236 os.remove(backupfile)
1236 1237
1237 1238 # check whether we should update away
1238 1239 if repo.unfiltered().revs('parents() and (%n or %ln::)',
1239 1240 state.parentctxnode, leafs | tmpnodes):
1240 1241 hg.clean(repo, state.topmost, show_stats=True, quietempty=True)
1241 1242 cleanupnode(ui, repo, tmpnodes)
1242 1243 cleanupnode(ui, repo, leafs)
1243 1244 except Exception:
1244 1245 if state.inprogress():
1245 1246 ui.warn(_('warning: encountered an exception during histedit '
1246 1247 '--abort; the repository may not have been completely '
1247 1248 'cleaned up\n'))
1248 1249 raise
1249 1250 finally:
1250 1251 state.clear()
1251 1252
1252 1253 def _edithisteditplan(ui, repo, state, rules):
1253 1254 state.read()
1254 1255 if not rules:
1255 1256 comment = geteditcomment(ui,
1256 1257 node.short(state.parentctxnode),
1257 1258 node.short(state.topmost))
1258 1259 rules = ruleeditor(repo, ui, state.actions, comment)
1259 1260 else:
1260 1261 rules = _readfile(ui, rules)
1261 1262 actions = parserules(rules, state)
1262 1263 ctxs = [repo[act.node] \
1263 1264 for act in state.actions if act.node]
1264 1265 warnverifyactions(ui, repo, actions, state, ctxs)
1265 1266 state.actions = actions
1266 1267 state.write()
1267 1268
1268 1269 def _newhistedit(ui, repo, state, revs, freeargs, opts):
1269 1270 outg = opts.get('outgoing')
1270 1271 rules = opts.get('commands', '')
1271 1272 force = opts.get('force')
1272 1273
1273 1274 cmdutil.checkunfinished(repo)
1274 1275 cmdutil.bailifchanged(repo)
1275 1276
1276 1277 topmost, empty = repo.dirstate.parents()
1277 1278 if outg:
1278 1279 if freeargs:
1279 1280 remote = freeargs[0]
1280 1281 else:
1281 1282 remote = None
1282 1283 root = findoutgoing(ui, repo, remote, force, opts)
1283 1284 else:
1284 1285 rr = list(repo.set('roots(%ld)', scmutil.revrange(repo, revs)))
1285 1286 if len(rr) != 1:
1286 1287 raise error.Abort(_('The specified revisions must have '
1287 1288 'exactly one common root'))
1288 1289 root = rr[0].node()
1289 1290
1290 1291 revs = between(repo, root, topmost, state.keep)
1291 1292 if not revs:
1292 1293 raise error.Abort(_('%s is not an ancestor of working directory') %
1293 1294 node.short(root))
1294 1295
1295 1296 ctxs = [repo[r] for r in revs]
1296 1297 if not rules:
1297 1298 comment = geteditcomment(ui, node.short(root), node.short(topmost))
1298 1299 actions = [pick(state, r) for r in revs]
1299 1300 rules = ruleeditor(repo, ui, actions, comment)
1300 1301 else:
1301 1302 rules = _readfile(ui, rules)
1302 1303 actions = parserules(rules, state)
1303 1304 warnverifyactions(ui, repo, actions, state, ctxs)
1304 1305
1305 1306 parentctxnode = repo[root].parents()[0].node()
1306 1307
1307 1308 state.parentctxnode = parentctxnode
1308 1309 state.actions = actions
1309 1310 state.topmost = topmost
1310 1311 state.replacements = []
1311 1312
1312 1313 # Create a backup so we can always abort completely.
1313 1314 backupfile = None
1314 1315 if not obsolete.isenabled(repo, obsolete.createmarkersopt):
1315 1316 backupfile = repair._bundle(repo, [parentctxnode], [topmost], root,
1316 1317 'histedit')
1317 1318 state.backupfile = backupfile
1318 1319
1319 1320 def _getsummary(ctx):
1320 1321 # a common pattern is to extract the summary but default to the empty
1321 1322 # string
1322 1323 summary = ctx.description() or ''
1323 1324 if summary:
1324 1325 summary = summary.splitlines()[0]
1325 1326 return summary
1326 1327
1327 1328 def bootstrapcontinue(ui, state, opts):
1328 1329 repo = state.repo
1329 1330
1330 1331 ms = mergemod.mergestate.read(repo)
1331 1332 mergeutil.checkunresolved(ms)
1332 1333
1333 1334 if state.actions:
1334 1335 actobj = state.actions.pop(0)
1335 1336
1336 1337 if _isdirtywc(repo):
1337 1338 actobj.continuedirty()
1338 1339 if _isdirtywc(repo):
1339 1340 abortdirty()
1340 1341
1341 1342 parentctx, replacements = actobj.continueclean()
1342 1343
1343 1344 state.parentctxnode = parentctx.node()
1344 1345 state.replacements.extend(replacements)
1345 1346
1346 1347 return state
1347 1348
1348 1349 def between(repo, old, new, keep):
1349 1350 """select and validate the set of revision to edit
1350 1351
1351 1352 When keep is false, the specified set can't have children."""
1352 1353 ctxs = list(repo.set('%n::%n', old, new))
1353 1354 if ctxs and not keep:
1354 1355 if (not obsolete.isenabled(repo, obsolete.allowunstableopt) and
1355 1356 repo.revs('(%ld::) - (%ld)', ctxs, ctxs)):
1356 1357 raise error.Abort(_('can only histedit a changeset together '
1357 1358 'with all its descendants'))
1358 1359 if repo.revs('(%ld) and merge()', ctxs):
1359 1360 raise error.Abort(_('cannot edit history that contains merges'))
1360 1361 root = ctxs[0] # list is already sorted by repo.set
1361 1362 if not root.mutable():
1362 1363 raise error.Abort(_('cannot edit public changeset: %s') % root,
1363 1364 hint=_("see 'hg help phases' for details"))
1364 1365 return [c.node() for c in ctxs]
1365 1366
1366 1367 def ruleeditor(repo, ui, actions, editcomment=""):
1367 1368 """open an editor to edit rules
1368 1369
1369 1370 rules are in the format [ [act, ctx], ...] like in state.rules
1370 1371 """
1371 1372 if repo.ui.configbool("experimental", "histedit.autoverb"):
1372 1373 newact = util.sortdict()
1373 1374 for act in actions:
1374 1375 ctx = repo[act.node]
1375 1376 summary = _getsummary(ctx)
1376 1377 fword = summary.split(' ', 1)[0].lower()
1377 1378 added = False
1378 1379
1379 1380 # if it doesn't end with the special character '!' just skip this
1380 1381 if fword.endswith('!'):
1381 1382 fword = fword[:-1]
1382 1383 if fword in primaryactions | secondaryactions | tertiaryactions:
1383 1384 act.verb = fword
1384 1385 # get the target summary
1385 1386 tsum = summary[len(fword) + 1:].lstrip()
1386 1387 # safe but slow: reverse iterate over the actions so we
1387 1388 # don't clash on two commits having the same summary
1388 1389 for na, l in reversed(list(newact.iteritems())):
1389 1390 actx = repo[na.node]
1390 1391 asum = _getsummary(actx)
1391 1392 if asum == tsum:
1392 1393 added = True
1393 1394 l.append(act)
1394 1395 break
1395 1396
1396 1397 if not added:
1397 1398 newact[act] = []
1398 1399
1399 1400 # copy over and flatten the new list
1400 1401 actions = []
1401 1402 for na, l in newact.iteritems():
1402 1403 actions.append(na)
1403 1404 actions += l
1404 1405
1405 1406 rules = '\n'.join([act.torule() for act in actions])
1406 1407 rules += '\n\n'
1407 1408 rules += editcomment
1408 1409 rules = ui.edit(rules, ui.username(), {'prefix': 'histedit'},
1409 1410 repopath=repo.path, action='histedit')
1410 1411
1411 1412 # Save edit rules in .hg/histedit-last-edit.txt in case
1412 1413 # the user needs to ask for help after something
1413 1414 # surprising happens.
1414 1415 f = open(repo.vfs.join('histedit-last-edit.txt'), 'w')
1415 1416 f.write(rules)
1416 1417 f.close()
1417 1418
1418 1419 return rules
1419 1420
1420 1421 def parserules(rules, state):
1421 1422 """Read the histedit rules string and return list of action objects """
1422 1423 rules = [l for l in (r.strip() for r in rules.splitlines())
1423 1424 if l and not l.startswith('#')]
1424 1425 actions = []
1425 1426 for r in rules:
1426 1427 if ' ' not in r:
1427 1428 raise error.ParseError(_('malformed line "%s"') % r)
1428 1429 verb, rest = r.split(' ', 1)
1429 1430
1430 1431 if verb not in actiontable:
1431 1432 raise error.ParseError(_('unknown action "%s"') % verb)
1432 1433
1433 1434 action = actiontable[verb].fromrule(state, rest)
1434 1435 actions.append(action)
1435 1436 return actions
1436 1437
1437 1438 def warnverifyactions(ui, repo, actions, state, ctxs):
1438 1439 try:
1439 1440 verifyactions(actions, state, ctxs)
1440 1441 except error.ParseError:
1441 1442 if repo.vfs.exists('histedit-last-edit.txt'):
1442 1443 ui.warn(_('warning: histedit rules saved '
1443 1444 'to: .hg/histedit-last-edit.txt\n'))
1444 1445 raise
1445 1446
1446 1447 def verifyactions(actions, state, ctxs):
1447 1448 """Verify that there exists exactly one action per given changeset and
1448 1449 other constraints.
1449 1450
1450 1451 Will abort if there are to many or too few rules, a malformed rule,
1451 1452 or a rule on a changeset outside of the user-given range.
1452 1453 """
1453 1454 expected = set(c.node() for c in ctxs)
1454 1455 seen = set()
1455 1456 prev = None
1456 1457
1457 1458 if actions and actions[0].verb in ['roll', 'fold']:
1458 1459 raise error.ParseError(_('first changeset cannot use verb "%s"') %
1459 1460 actions[0].verb)
1460 1461
1461 1462 for action in actions:
1462 1463 action.verify(prev, expected, seen)
1463 1464 prev = action
1464 1465 if action.node is not None:
1465 1466 seen.add(action.node)
1466 1467 missing = sorted(expected - seen) # sort to stabilize output
1467 1468
1468 1469 if state.repo.ui.configbool('histedit', 'dropmissing'):
1469 1470 if len(actions) == 0:
1470 1471 raise error.ParseError(_('no rules provided'),
1471 1472 hint=_('use strip extension to remove commits'))
1472 1473
1473 1474 drops = [drop(state, n) for n in missing]
1474 1475 # put the in the beginning so they execute immediately and
1475 1476 # don't show in the edit-plan in the future
1476 1477 actions[:0] = drops
1477 1478 elif missing:
1478 1479 raise error.ParseError(_('missing rules for changeset %s') %
1479 1480 node.short(missing[0]),
1480 1481 hint=_('use "drop %s" to discard, see also: '
1481 1482 "'hg help -e histedit.config'")
1482 1483 % node.short(missing[0]))
1483 1484
1484 1485 def adjustreplacementsfrommarkers(repo, oldreplacements):
1485 1486 """Adjust replacements from obsolescence markers
1486 1487
1487 1488 Replacements structure is originally generated based on
1488 1489 histedit's state and does not account for changes that are
1489 1490 not recorded there. This function fixes that by adding
1490 1491 data read from obsolescence markers"""
1491 1492 if not obsolete.isenabled(repo, obsolete.createmarkersopt):
1492 1493 return oldreplacements
1493 1494
1494 1495 unfi = repo.unfiltered()
1495 1496 nm = unfi.changelog.nodemap
1496 1497 obsstore = repo.obsstore
1497 1498 newreplacements = list(oldreplacements)
1498 1499 oldsuccs = [r[1] for r in oldreplacements]
1499 1500 # successors that have already been added to succstocheck once
1500 1501 seensuccs = set().union(*oldsuccs) # create a set from an iterable of tuples
1501 1502 succstocheck = list(seensuccs)
1502 1503 while succstocheck:
1503 1504 n = succstocheck.pop()
1504 1505 missing = nm.get(n) is None
1505 1506 markers = obsstore.successors.get(n, ())
1506 1507 if missing and not markers:
1507 1508 # dead end, mark it as such
1508 1509 newreplacements.append((n, ()))
1509 1510 for marker in markers:
1510 1511 nsuccs = marker[1]
1511 1512 newreplacements.append((n, nsuccs))
1512 1513 for nsucc in nsuccs:
1513 1514 if nsucc not in seensuccs:
1514 1515 seensuccs.add(nsucc)
1515 1516 succstocheck.append(nsucc)
1516 1517
1517 1518 return newreplacements
1518 1519
1519 1520 def processreplacement(state):
1520 1521 """process the list of replacements to return
1521 1522
1522 1523 1) the final mapping between original and created nodes
1523 1524 2) the list of temporary node created by histedit
1524 1525 3) the list of new commit created by histedit"""
1525 1526 replacements = adjustreplacementsfrommarkers(state.repo, state.replacements)
1526 1527 allsuccs = set()
1527 1528 replaced = set()
1528 1529 fullmapping = {}
1529 1530 # initialize basic set
1530 1531 # fullmapping records all operations recorded in replacement
1531 1532 for rep in replacements:
1532 1533 allsuccs.update(rep[1])
1533 1534 replaced.add(rep[0])
1534 1535 fullmapping.setdefault(rep[0], set()).update(rep[1])
1535 1536 new = allsuccs - replaced
1536 1537 tmpnodes = allsuccs & replaced
1537 1538 # Reduce content fullmapping into direct relation between original nodes
1538 1539 # and final node created during history edition
1539 1540 # Dropped changeset are replaced by an empty list
1540 1541 toproceed = set(fullmapping)
1541 1542 final = {}
1542 1543 while toproceed:
1543 1544 for x in list(toproceed):
1544 1545 succs = fullmapping[x]
1545 1546 for s in list(succs):
1546 1547 if s in toproceed:
1547 1548 # non final node with unknown closure
1548 1549 # We can't process this now
1549 1550 break
1550 1551 elif s in final:
1551 1552 # non final node, replace with closure
1552 1553 succs.remove(s)
1553 1554 succs.update(final[s])
1554 1555 else:
1555 1556 final[x] = succs
1556 1557 toproceed.remove(x)
1557 1558 # remove tmpnodes from final mapping
1558 1559 for n in tmpnodes:
1559 1560 del final[n]
1560 1561 # we expect all changes involved in final to exist in the repo
1561 1562 # turn `final` into list (topologically sorted)
1562 1563 nm = state.repo.changelog.nodemap
1563 1564 for prec, succs in final.items():
1564 1565 final[prec] = sorted(succs, key=nm.get)
1565 1566
1566 1567 # computed topmost element (necessary for bookmark)
1567 1568 if new:
1568 1569 newtopmost = sorted(new, key=state.repo.changelog.rev)[-1]
1569 1570 elif not final:
1570 1571 # Nothing rewritten at all. we won't need `newtopmost`
1571 1572 # It is the same as `oldtopmost` and `processreplacement` know it
1572 1573 newtopmost = None
1573 1574 else:
1574 1575 # every body died. The newtopmost is the parent of the root.
1575 1576 r = state.repo.changelog.rev
1576 1577 newtopmost = state.repo[sorted(final, key=r)[0]].p1().node()
1577 1578
1578 1579 return final, tmpnodes, new, newtopmost
1579 1580
1580 1581 def movetopmostbookmarks(repo, oldtopmost, newtopmost):
1581 1582 """Move bookmark from oldtopmost to newly created topmost
1582 1583
1583 1584 This is arguably a feature and we may only want that for the active
1584 1585 bookmark. But the behavior is kept compatible with the old version for now.
1585 1586 """
1586 1587 if not oldtopmost or not newtopmost:
1587 1588 return
1588 1589 oldbmarks = repo.nodebookmarks(oldtopmost)
1589 1590 if oldbmarks:
1590 1591 with repo.lock(), repo.transaction('histedit') as tr:
1591 1592 marks = repo._bookmarks
1592 1593 changes = []
1593 1594 for name in oldbmarks:
1594 1595 changes.append((name, newtopmost))
1595 1596 marks.applychanges(repo, tr, changes)
1596 1597
1597 1598 def cleanupnode(ui, repo, nodes):
1598 1599 """strip a group of nodes from the repository
1599 1600
1600 1601 The set of node to strip may contains unknown nodes."""
1601 1602 with repo.lock():
1602 1603 # do not let filtering get in the way of the cleanse
1603 1604 # we should probably get rid of obsolescence marker created during the
1604 1605 # histedit, but we currently do not have such information.
1605 1606 repo = repo.unfiltered()
1606 1607 # Find all nodes that need to be stripped
1607 1608 # (we use %lr instead of %ln to silently ignore unknown items)
1608 1609 nm = repo.changelog.nodemap
1609 1610 nodes = sorted(n for n in nodes if n in nm)
1610 1611 roots = [c.node() for c in repo.set("roots(%ln)", nodes)]
1611 1612 if roots:
1612 1613 repair.strip(ui, repo, roots)
1613 1614
1614 1615 def stripwrapper(orig, ui, repo, nodelist, *args, **kwargs):
1615 1616 if isinstance(nodelist, str):
1616 1617 nodelist = [nodelist]
1617 1618 if os.path.exists(os.path.join(repo.path, 'histedit-state')):
1618 1619 state = histeditstate(repo)
1619 1620 state.read()
1620 1621 histedit_nodes = {action.node for action
1621 1622 in state.actions if action.node}
1622 1623 common_nodes = histedit_nodes & set(nodelist)
1623 1624 if common_nodes:
1624 1625 raise error.Abort(_("histedit in progress, can't strip %s")
1625 1626 % ', '.join(node.short(x) for x in common_nodes))
1626 1627 return orig(ui, repo, nodelist, *args, **kwargs)
1627 1628
1628 1629 extensions.wrapfunction(repair, 'strip', stripwrapper)
1629 1630
1630 1631 def summaryhook(ui, repo):
1631 1632 if not os.path.exists(repo.vfs.join('histedit-state')):
1632 1633 return
1633 1634 state = histeditstate(repo)
1634 1635 state.read()
1635 1636 if state.actions:
1636 1637 # i18n: column positioning for "hg summary"
1637 1638 ui.write(_('hist: %s (histedit --continue)\n') %
1638 1639 (ui.label(_('%d remaining'), 'histedit.remaining') %
1639 1640 len(state.actions)))
1640 1641
1641 1642 def extsetup(ui):
1642 1643 cmdutil.summaryhooks.add('histedit', summaryhook)
1643 1644 cmdutil.unfinishedstates.append(
1644 1645 ['histedit-state', False, True, _('histedit in progress'),
1645 1646 _("use 'hg histedit --continue' or 'hg histedit --abort'")])
1646 1647 cmdutil.afterresolvedstates.append(
1647 1648 ['histedit-state', _('hg histedit --continue')])
@@ -1,1527 +1,1527
1 1 # templater.py - template expansion for output
2 2 #
3 3 # Copyright 2005, 2006 Matt Mackall <mpm@selenic.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 from __future__ import absolute_import, print_function
9 9
10 10 import os
11 11 import re
12 12 import types
13 13
14 14 from .i18n import _
15 15 from . import (
16 16 color,
17 17 config,
18 18 encoding,
19 19 error,
20 20 minirst,
21 21 obsutil,
22 22 parser,
23 23 pycompat,
24 24 registrar,
25 25 revset as revsetmod,
26 26 revsetlang,
27 27 scmutil,
28 28 templatefilters,
29 29 templatekw,
30 30 util,
31 31 )
32 32
33 33 # template parsing
34 34
35 35 elements = {
36 36 # token-type: binding-strength, primary, prefix, infix, suffix
37 37 "(": (20, None, ("group", 1, ")"), ("func", 1, ")"), None),
38 38 ".": (18, None, None, (".", 18), None),
39 39 "%": (15, None, None, ("%", 15), None),
40 40 "|": (15, None, None, ("|", 15), None),
41 41 "*": (5, None, None, ("*", 5), None),
42 42 "/": (5, None, None, ("/", 5), None),
43 43 "+": (4, None, None, ("+", 4), None),
44 44 "-": (4, None, ("negate", 19), ("-", 4), None),
45 45 "=": (3, None, None, ("keyvalue", 3), None),
46 46 ",": (2, None, None, ("list", 2), None),
47 47 ")": (0, None, None, None, None),
48 48 "integer": (0, "integer", None, None, None),
49 49 "symbol": (0, "symbol", None, None, None),
50 50 "string": (0, "string", None, None, None),
51 51 "template": (0, "template", None, None, None),
52 52 "end": (0, None, None, None, None),
53 53 }
54 54
55 55 def tokenize(program, start, end, term=None):
56 56 """Parse a template expression into a stream of tokens, which must end
57 57 with term if specified"""
58 58 pos = start
59 59 program = pycompat.bytestr(program)
60 60 while pos < end:
61 61 c = program[pos]
62 62 if c.isspace(): # skip inter-token whitespace
63 63 pass
64 64 elif c in "(=,).%|+-*/": # handle simple operators
65 65 yield (c, None, pos)
66 66 elif c in '"\'': # handle quoted templates
67 67 s = pos + 1
68 68 data, pos = _parsetemplate(program, s, end, c)
69 69 yield ('template', data, s)
70 70 pos -= 1
71 71 elif c == 'r' and program[pos:pos + 2] in ("r'", 'r"'):
72 72 # handle quoted strings
73 73 c = program[pos + 1]
74 74 s = pos = pos + 2
75 75 while pos < end: # find closing quote
76 76 d = program[pos]
77 77 if d == '\\': # skip over escaped characters
78 78 pos += 2
79 79 continue
80 80 if d == c:
81 81 yield ('string', program[s:pos], s)
82 82 break
83 83 pos += 1
84 84 else:
85 85 raise error.ParseError(_("unterminated string"), s)
86 86 elif c.isdigit():
87 87 s = pos
88 88 while pos < end:
89 89 d = program[pos]
90 90 if not d.isdigit():
91 91 break
92 92 pos += 1
93 93 yield ('integer', program[s:pos], s)
94 94 pos -= 1
95 95 elif (c == '\\' and program[pos:pos + 2] in (r"\'", r'\"')
96 96 or c == 'r' and program[pos:pos + 3] in (r"r\'", r'r\"')):
97 97 # handle escaped quoted strings for compatibility with 2.9.2-3.4,
98 98 # where some of nested templates were preprocessed as strings and
99 99 # then compiled. therefore, \"...\" was allowed. (issue4733)
100 100 #
101 101 # processing flow of _evalifliteral() at 5ab28a2e9962:
102 102 # outer template string -> stringify() -> compiletemplate()
103 103 # ------------------------ ------------ ------------------
104 104 # {f("\\\\ {g(\"\\\"\")}"} \\ {g("\"")} [r'\\', {g("\"")}]
105 105 # ~~~~~~~~
106 106 # escaped quoted string
107 107 if c == 'r':
108 108 pos += 1
109 109 token = 'string'
110 110 else:
111 111 token = 'template'
112 112 quote = program[pos:pos + 2]
113 113 s = pos = pos + 2
114 114 while pos < end: # find closing escaped quote
115 115 if program.startswith('\\\\\\', pos, end):
116 116 pos += 4 # skip over double escaped characters
117 117 continue
118 118 if program.startswith(quote, pos, end):
119 119 # interpret as if it were a part of an outer string
120 120 data = parser.unescapestr(program[s:pos])
121 121 if token == 'template':
122 122 data = _parsetemplate(data, 0, len(data))[0]
123 123 yield (token, data, s)
124 124 pos += 1
125 125 break
126 126 pos += 1
127 127 else:
128 128 raise error.ParseError(_("unterminated string"), s)
129 129 elif c.isalnum() or c in '_':
130 130 s = pos
131 131 pos += 1
132 132 while pos < end: # find end of symbol
133 133 d = program[pos]
134 134 if not (d.isalnum() or d == "_"):
135 135 break
136 136 pos += 1
137 137 sym = program[s:pos]
138 138 yield ('symbol', sym, s)
139 139 pos -= 1
140 140 elif c == term:
141 141 yield ('end', None, pos + 1)
142 142 return
143 143 else:
144 144 raise error.ParseError(_("syntax error"), pos)
145 145 pos += 1
146 146 if term:
147 147 raise error.ParseError(_("unterminated template expansion"), start)
148 148 yield ('end', None, pos)
149 149
150 150 def _parsetemplate(tmpl, start, stop, quote=''):
151 151 r"""
152 152 >>> _parsetemplate(b'foo{bar}"baz', 0, 12)
153 153 ([('string', 'foo'), ('symbol', 'bar'), ('string', '"baz')], 12)
154 154 >>> _parsetemplate(b'foo{bar}"baz', 0, 12, quote=b'"')
155 155 ([('string', 'foo'), ('symbol', 'bar')], 9)
156 156 >>> _parsetemplate(b'foo"{bar}', 0, 9, quote=b'"')
157 157 ([('string', 'foo')], 4)
158 158 >>> _parsetemplate(br'foo\"bar"baz', 0, 12, quote=b'"')
159 159 ([('string', 'foo"'), ('string', 'bar')], 9)
160 160 >>> _parsetemplate(br'foo\\"bar', 0, 10, quote=b'"')
161 161 ([('string', 'foo\\')], 6)
162 162 """
163 163 parsed = []
164 164 sepchars = '{' + quote
165 165 pos = start
166 166 p = parser.parser(elements)
167 167 while pos < stop:
168 168 n = min((tmpl.find(c, pos, stop) for c in sepchars),
169 169 key=lambda n: (n < 0, n))
170 170 if n < 0:
171 171 parsed.append(('string', parser.unescapestr(tmpl[pos:stop])))
172 172 pos = stop
173 173 break
174 174 c = tmpl[n:n + 1]
175 175 bs = (n - pos) - len(tmpl[pos:n].rstrip('\\'))
176 176 if bs % 2 == 1:
177 177 # escaped (e.g. '\{', '\\\{', but not '\\{')
178 178 parsed.append(('string', parser.unescapestr(tmpl[pos:n - 1]) + c))
179 179 pos = n + 1
180 180 continue
181 181 if n > pos:
182 182 parsed.append(('string', parser.unescapestr(tmpl[pos:n])))
183 183 if c == quote:
184 184 return parsed, n + 1
185 185
186 186 parseres, pos = p.parse(tokenize(tmpl, n + 1, stop, '}'))
187 187 parsed.append(parseres)
188 188
189 189 if quote:
190 190 raise error.ParseError(_("unterminated string"), start)
191 191 return parsed, pos
192 192
193 193 def _unnesttemplatelist(tree):
194 194 """Expand list of templates to node tuple
195 195
196 196 >>> def f(tree):
197 197 ... print(pycompat.sysstr(prettyformat(_unnesttemplatelist(tree))))
198 198 >>> f((b'template', []))
199 199 (string '')
200 200 >>> f((b'template', [(b'string', b'foo')]))
201 201 (string 'foo')
202 202 >>> f((b'template', [(b'string', b'foo'), (b'symbol', b'rev')]))
203 203 (template
204 204 (string 'foo')
205 205 (symbol 'rev'))
206 206 >>> f((b'template', [(b'symbol', b'rev')])) # template(rev) -> str
207 207 (template
208 208 (symbol 'rev'))
209 209 >>> f((b'template', [(b'template', [(b'string', b'foo')])]))
210 210 (string 'foo')
211 211 """
212 212 if not isinstance(tree, tuple):
213 213 return tree
214 214 op = tree[0]
215 215 if op != 'template':
216 216 return (op,) + tuple(_unnesttemplatelist(x) for x in tree[1:])
217 217
218 218 assert len(tree) == 2
219 219 xs = tuple(_unnesttemplatelist(x) for x in tree[1])
220 220 if not xs:
221 221 return ('string', '') # empty template ""
222 222 elif len(xs) == 1 and xs[0][0] == 'string':
223 223 return xs[0] # fast path for string with no template fragment "x"
224 224 else:
225 225 return (op,) + xs
226 226
227 227 def parse(tmpl):
228 228 """Parse template string into tree"""
229 229 parsed, pos = _parsetemplate(tmpl, 0, len(tmpl))
230 230 assert pos == len(tmpl), 'unquoted template should be consumed'
231 231 return _unnesttemplatelist(('template', parsed))
232 232
233 233 def _parseexpr(expr):
234 234 """Parse a template expression into tree
235 235
236 236 >>> _parseexpr(b'"foo"')
237 237 ('string', 'foo')
238 238 >>> _parseexpr(b'foo(bar)')
239 239 ('func', ('symbol', 'foo'), ('symbol', 'bar'))
240 240 >>> _parseexpr(b'foo(')
241 241 Traceback (most recent call last):
242 242 ...
243 243 ParseError: ('not a prefix: end', 4)
244 244 >>> _parseexpr(b'"foo" "bar"')
245 245 Traceback (most recent call last):
246 246 ...
247 247 ParseError: ('invalid token', 7)
248 248 """
249 249 p = parser.parser(elements)
250 250 tree, pos = p.parse(tokenize(expr, 0, len(expr)))
251 251 if pos != len(expr):
252 252 raise error.ParseError(_('invalid token'), pos)
253 253 return _unnesttemplatelist(tree)
254 254
255 255 def prettyformat(tree):
256 256 return parser.prettyformat(tree, ('integer', 'string', 'symbol'))
257 257
258 258 def compileexp(exp, context, curmethods):
259 259 """Compile parsed template tree to (func, data) pair"""
260 260 t = exp[0]
261 261 if t in curmethods:
262 262 return curmethods[t](exp, context)
263 263 raise error.ParseError(_("unknown method '%s'") % t)
264 264
265 265 # template evaluation
266 266
267 267 def getsymbol(exp):
268 268 if exp[0] == 'symbol':
269 269 return exp[1]
270 270 raise error.ParseError(_("expected a symbol, got '%s'") % exp[0])
271 271
272 272 def getlist(x):
273 273 if not x:
274 274 return []
275 275 if x[0] == 'list':
276 276 return getlist(x[1]) + [x[2]]
277 277 return [x]
278 278
279 279 def gettemplate(exp, context):
280 280 """Compile given template tree or load named template from map file;
281 281 returns (func, data) pair"""
282 282 if exp[0] in ('template', 'string'):
283 283 return compileexp(exp, context, methods)
284 284 if exp[0] == 'symbol':
285 285 # unlike runsymbol(), here 'symbol' is always taken as template name
286 286 # even if it exists in mapping. this allows us to override mapping
287 287 # by web templates, e.g. 'changelogtag' is redefined in map file.
288 288 return context._load(exp[1])
289 289 raise error.ParseError(_("expected template specifier"))
290 290
291 291 def findsymbolicname(arg):
292 292 """Find symbolic name for the given compiled expression; returns None
293 293 if nothing found reliably"""
294 294 while True:
295 295 func, data = arg
296 296 if func is runsymbol:
297 297 return data
298 298 elif func is runfilter:
299 299 arg = data[0]
300 300 else:
301 301 return None
302 302
303 303 def evalrawexp(context, mapping, arg):
304 304 """Evaluate given argument as a bare template object which may require
305 305 further processing (such as folding generator of strings)"""
306 306 func, data = arg
307 307 return func(context, mapping, data)
308 308
309 309 def evalfuncarg(context, mapping, arg):
310 310 """Evaluate given argument as value type"""
311 311 thing = evalrawexp(context, mapping, arg)
312 312 thing = templatekw.unwrapvalue(thing)
313 313 # evalrawexp() may return string, generator of strings or arbitrary object
314 314 # such as date tuple, but filter does not want generator.
315 315 if isinstance(thing, types.GeneratorType):
316 316 thing = stringify(thing)
317 317 return thing
318 318
319 319 def evalboolean(context, mapping, arg):
320 320 """Evaluate given argument as boolean, but also takes boolean literals"""
321 321 func, data = arg
322 322 if func is runsymbol:
323 323 thing = func(context, mapping, data, default=None)
324 324 if thing is None:
325 325 # not a template keyword, takes as a boolean literal
326 326 thing = util.parsebool(data)
327 327 else:
328 328 thing = func(context, mapping, data)
329 329 thing = templatekw.unwrapvalue(thing)
330 330 if isinstance(thing, bool):
331 331 return thing
332 332 # other objects are evaluated as strings, which means 0 is True, but
333 333 # empty dict/list should be False as they are expected to be ''
334 334 return bool(stringify(thing))
335 335
336 336 def evalinteger(context, mapping, arg, err=None):
337 337 v = evalfuncarg(context, mapping, arg)
338 338 try:
339 339 return int(v)
340 340 except (TypeError, ValueError):
341 341 raise error.ParseError(err or _('not an integer'))
342 342
343 343 def evalstring(context, mapping, arg):
344 344 return stringify(evalrawexp(context, mapping, arg))
345 345
346 346 def evalstringliteral(context, mapping, arg):
347 347 """Evaluate given argument as string template, but returns symbol name
348 348 if it is unknown"""
349 349 func, data = arg
350 350 if func is runsymbol:
351 351 thing = func(context, mapping, data, default=data)
352 352 else:
353 353 thing = func(context, mapping, data)
354 354 return stringify(thing)
355 355
356 356 _evalfuncbytype = {
357 357 bool: evalboolean,
358 358 bytes: evalstring,
359 359 int: evalinteger,
360 360 }
361 361
362 362 def evalastype(context, mapping, arg, typ):
363 363 """Evaluate given argument and coerce its type"""
364 364 try:
365 365 f = _evalfuncbytype[typ]
366 366 except KeyError:
367 367 raise error.ProgrammingError('invalid type specified: %r' % typ)
368 368 return f(context, mapping, arg)
369 369
370 370 def runinteger(context, mapping, data):
371 371 return int(data)
372 372
373 373 def runstring(context, mapping, data):
374 374 return data
375 375
376 376 def _recursivesymbolblocker(key):
377 377 def showrecursion(**args):
378 378 raise error.Abort(_("recursive reference '%s' in template") % key)
379 379 return showrecursion
380 380
381 381 def _runrecursivesymbol(context, mapping, key):
382 382 raise error.Abort(_("recursive reference '%s' in template") % key)
383 383
384 384 def runsymbol(context, mapping, key, default=''):
385 385 v = mapping.get(key)
386 386 if v is None:
387 387 v = context._defaults.get(key)
388 388 if v is None:
389 389 # put poison to cut recursion. we can't move this to parsing phase
390 390 # because "x = {x}" is allowed if "x" is a keyword. (issue4758)
391 391 safemapping = mapping.copy()
392 392 safemapping[key] = _recursivesymbolblocker(key)
393 393 try:
394 394 v = context.process(key, safemapping)
395 395 except TemplateNotFound:
396 396 v = default
397 397 if callable(v):
398 398 return v(**pycompat.strkwargs(mapping))
399 399 return v
400 400
401 401 def buildtemplate(exp, context):
402 402 ctmpl = [compileexp(e, context, methods) for e in exp[1:]]
403 403 return (runtemplate, ctmpl)
404 404
405 405 def runtemplate(context, mapping, template):
406 406 for arg in template:
407 407 yield evalrawexp(context, mapping, arg)
408 408
409 409 def buildfilter(exp, context):
410 410 n = getsymbol(exp[2])
411 411 if n in context._filters:
412 412 filt = context._filters[n]
413 413 arg = compileexp(exp[1], context, methods)
414 414 return (runfilter, (arg, filt))
415 415 if n in funcs:
416 416 f = funcs[n]
417 417 args = _buildfuncargs(exp[1], context, methods, n, f._argspec)
418 418 return (f, args)
419 419 raise error.ParseError(_("unknown function '%s'") % n)
420 420
421 421 def runfilter(context, mapping, data):
422 422 arg, filt = data
423 423 thing = evalfuncarg(context, mapping, arg)
424 424 try:
425 425 return filt(thing)
426 426 except (ValueError, AttributeError, TypeError):
427 427 sym = findsymbolicname(arg)
428 428 if sym:
429 429 msg = (_("template filter '%s' is not compatible with keyword '%s'")
430 430 % (pycompat.sysbytes(filt.__name__), sym))
431 431 else:
432 432 msg = (_("incompatible use of template filter '%s'")
433 433 % pycompat.sysbytes(filt.__name__))
434 434 raise error.Abort(msg)
435 435
436 436 def buildmap(exp, context):
437 437 darg = compileexp(exp[1], context, methods)
438 438 targ = gettemplate(exp[2], context)
439 439 return (runmap, (darg, targ))
440 440
441 441 def runmap(context, mapping, data):
442 442 darg, targ = data
443 443 d = evalrawexp(context, mapping, darg)
444 444 if util.safehasattr(d, 'itermaps'):
445 445 diter = d.itermaps()
446 446 else:
447 447 try:
448 448 diter = iter(d)
449 449 except TypeError:
450 450 sym = findsymbolicname(darg)
451 451 if sym:
452 452 raise error.ParseError(_("keyword '%s' is not iterable") % sym)
453 453 else:
454 454 raise error.ParseError(_("%r is not iterable") % d)
455 455
456 456 for i, v in enumerate(diter):
457 457 lm = mapping.copy()
458 458 lm['index'] = i
459 459 if isinstance(v, dict):
460 460 lm.update(v)
461 461 lm['originalnode'] = mapping.get('node')
462 462 yield evalrawexp(context, lm, targ)
463 463 else:
464 464 # v is not an iterable of dicts, this happen when 'key'
465 465 # has been fully expanded already and format is useless.
466 466 # If so, return the expanded value.
467 467 yield v
468 468
469 469 def buildmember(exp, context):
470 470 darg = compileexp(exp[1], context, methods)
471 471 memb = getsymbol(exp[2])
472 472 return (runmember, (darg, memb))
473 473
474 474 def runmember(context, mapping, data):
475 475 darg, memb = data
476 476 d = evalrawexp(context, mapping, darg)
477 477 if util.safehasattr(d, 'tomap'):
478 478 lm = mapping.copy()
479 479 lm.update(d.tomap())
480 480 return runsymbol(context, lm, memb)
481 481 if util.safehasattr(d, 'get'):
482 482 return _getdictitem(d, memb)
483 483
484 484 sym = findsymbolicname(darg)
485 485 if sym:
486 486 raise error.ParseError(_("keyword '%s' has no member") % sym)
487 487 else:
488 488 raise error.ParseError(_("%r has no member") % d)
489 489
490 490 def buildnegate(exp, context):
491 491 arg = compileexp(exp[1], context, exprmethods)
492 492 return (runnegate, arg)
493 493
494 494 def runnegate(context, mapping, data):
495 495 data = evalinteger(context, mapping, data,
496 496 _('negation needs an integer argument'))
497 497 return -data
498 498
499 499 def buildarithmetic(exp, context, func):
500 500 left = compileexp(exp[1], context, exprmethods)
501 501 right = compileexp(exp[2], context, exprmethods)
502 502 return (runarithmetic, (func, left, right))
503 503
504 504 def runarithmetic(context, mapping, data):
505 505 func, left, right = data
506 506 left = evalinteger(context, mapping, left,
507 507 _('arithmetic only defined on integers'))
508 508 right = evalinteger(context, mapping, right,
509 509 _('arithmetic only defined on integers'))
510 510 try:
511 511 return func(left, right)
512 512 except ZeroDivisionError:
513 513 raise error.Abort(_('division by zero is not defined'))
514 514
515 515 def buildfunc(exp, context):
516 516 n = getsymbol(exp[1])
517 517 if n in funcs:
518 518 f = funcs[n]
519 519 args = _buildfuncargs(exp[2], context, exprmethods, n, f._argspec)
520 520 return (f, args)
521 521 if n in context._filters:
522 522 args = _buildfuncargs(exp[2], context, exprmethods, n, argspec=None)
523 523 if len(args) != 1:
524 524 raise error.ParseError(_("filter %s expects one argument") % n)
525 525 f = context._filters[n]
526 526 return (runfilter, (args[0], f))
527 527 raise error.ParseError(_("unknown function '%s'") % n)
528 528
529 529 def _buildfuncargs(exp, context, curmethods, funcname, argspec):
530 530 """Compile parsed tree of function arguments into list or dict of
531 531 (func, data) pairs
532 532
533 533 >>> context = engine(lambda t: (runsymbol, t))
534 534 >>> def fargs(expr, argspec):
535 535 ... x = _parseexpr(expr)
536 536 ... n = getsymbol(x[1])
537 537 ... return _buildfuncargs(x[2], context, exprmethods, n, argspec)
538 538 >>> list(fargs(b'a(l=1, k=2)', b'k l m').keys())
539 539 ['l', 'k']
540 540 >>> args = fargs(b'a(opts=1, k=2)', b'**opts')
541 541 >>> list(args.keys()), list(args[b'opts'].keys())
542 542 (['opts'], ['opts', 'k'])
543 543 """
544 544 def compiledict(xs):
545 545 return util.sortdict((k, compileexp(x, context, curmethods))
546 546 for k, x in xs.iteritems())
547 547 def compilelist(xs):
548 548 return [compileexp(x, context, curmethods) for x in xs]
549 549
550 550 if not argspec:
551 551 # filter or function with no argspec: return list of positional args
552 552 return compilelist(getlist(exp))
553 553
554 554 # function with argspec: return dict of named args
555 555 _poskeys, varkey, _keys, optkey = argspec = parser.splitargspec(argspec)
556 556 treeargs = parser.buildargsdict(getlist(exp), funcname, argspec,
557 557 keyvaluenode='keyvalue', keynode='symbol')
558 558 compargs = util.sortdict()
559 559 if varkey:
560 560 compargs[varkey] = compilelist(treeargs.pop(varkey))
561 561 if optkey:
562 562 compargs[optkey] = compiledict(treeargs.pop(optkey))
563 563 compargs.update(compiledict(treeargs))
564 564 return compargs
565 565
566 566 def buildkeyvaluepair(exp, content):
567 567 raise error.ParseError(_("can't use a key-value pair in this context"))
568 568
569 569 # dict of template built-in functions
570 570 funcs = {}
571 571
572 572 templatefunc = registrar.templatefunc(funcs)
573 573
574 574 @templatefunc('date(date[, fmt])')
575 575 def date(context, mapping, args):
576 576 """Format a date. See :hg:`help dates` for formatting
577 577 strings. The default is a Unix date format, including the timezone:
578 578 "Mon Sep 04 15:13:13 2006 0700"."""
579 579 if not (1 <= len(args) <= 2):
580 580 # i18n: "date" is a keyword
581 581 raise error.ParseError(_("date expects one or two arguments"))
582 582
583 583 date = evalfuncarg(context, mapping, args[0])
584 584 fmt = None
585 585 if len(args) == 2:
586 586 fmt = evalstring(context, mapping, args[1])
587 587 try:
588 588 if fmt is None:
589 589 return util.datestr(date)
590 590 else:
591 591 return util.datestr(date, fmt)
592 592 except (TypeError, ValueError):
593 593 # i18n: "date" is a keyword
594 594 raise error.ParseError(_("date expects a date information"))
595 595
596 596 @templatefunc('dict([[key=]value...])', argspec='*args **kwargs')
597 597 def dict_(context, mapping, args):
598 598 """Construct a dict from key-value pairs. A key may be omitted if
599 599 a value expression can provide an unambiguous name."""
600 600 data = util.sortdict()
601 601
602 602 for v in args['args']:
603 603 k = findsymbolicname(v)
604 604 if not k:
605 605 raise error.ParseError(_('dict key cannot be inferred'))
606 606 if k in data or k in args['kwargs']:
607 607 raise error.ParseError(_("duplicated dict key '%s' inferred") % k)
608 608 data[k] = evalfuncarg(context, mapping, v)
609 609
610 610 data.update((k, evalfuncarg(context, mapping, v))
611 611 for k, v in args['kwargs'].iteritems())
612 612 return templatekw.hybriddict(data)
613 613
614 614 @templatefunc('diff([includepattern [, excludepattern]])')
615 615 def diff(context, mapping, args):
616 616 """Show a diff, optionally
617 617 specifying files to include or exclude."""
618 618 if len(args) > 2:
619 619 # i18n: "diff" is a keyword
620 620 raise error.ParseError(_("diff expects zero, one, or two arguments"))
621 621
622 622 def getpatterns(i):
623 623 if i < len(args):
624 624 s = evalstring(context, mapping, args[i]).strip()
625 625 if s:
626 626 return [s]
627 627 return []
628 628
629 629 ctx = mapping['ctx']
630 630 chunks = ctx.diff(match=ctx.match([], getpatterns(0), getpatterns(1)))
631 631
632 632 return ''.join(chunks)
633 633
634 634 @templatefunc('extdata(source)', argspec='source')
635 635 def extdata(context, mapping, args):
636 636 """Show a text read from the specified extdata source. (EXPERIMENTAL)"""
637 637 if 'source' not in args:
638 638 # i18n: "extdata" is a keyword
639 639 raise error.ParseError(_('extdata expects one argument'))
640 640
641 641 source = evalstring(context, mapping, args['source'])
642 642 cache = mapping['cache'].setdefault('extdata', {})
643 643 ctx = mapping['ctx']
644 644 if source in cache:
645 645 data = cache[source]
646 646 else:
647 647 data = cache[source] = scmutil.extdatasource(ctx.repo(), source)
648 648 return data.get(ctx.rev(), '')
649 649
650 650 @templatefunc('files(pattern)')
651 651 def files(context, mapping, args):
652 652 """All files of the current changeset matching the pattern. See
653 653 :hg:`help patterns`."""
654 654 if not len(args) == 1:
655 655 # i18n: "files" is a keyword
656 656 raise error.ParseError(_("files expects one argument"))
657 657
658 658 raw = evalstring(context, mapping, args[0])
659 659 ctx = mapping['ctx']
660 660 m = ctx.match([raw])
661 661 files = list(ctx.matches(m))
662 662 return templatekw.showlist("file", files, mapping)
663 663
664 664 @templatefunc('fill(text[, width[, initialident[, hangindent]]])')
665 665 def fill(context, mapping, args):
666 666 """Fill many
667 667 paragraphs with optional indentation. See the "fill" filter."""
668 668 if not (1 <= len(args) <= 4):
669 669 # i18n: "fill" is a keyword
670 670 raise error.ParseError(_("fill expects one to four arguments"))
671 671
672 672 text = evalstring(context, mapping, args[0])
673 673 width = 76
674 674 initindent = ''
675 675 hangindent = ''
676 676 if 2 <= len(args) <= 4:
677 677 width = evalinteger(context, mapping, args[1],
678 678 # i18n: "fill" is a keyword
679 679 _("fill expects an integer width"))
680 680 try:
681 681 initindent = evalstring(context, mapping, args[2])
682 682 hangindent = evalstring(context, mapping, args[3])
683 683 except IndexError:
684 684 pass
685 685
686 686 return templatefilters.fill(text, width, initindent, hangindent)
687 687
688 688 @templatefunc('formatnode(node)')
689 689 def formatnode(context, mapping, args):
690 690 """Obtain the preferred form of a changeset hash. (DEPRECATED)"""
691 691 if len(args) != 1:
692 692 # i18n: "formatnode" is a keyword
693 693 raise error.ParseError(_("formatnode expects one argument"))
694 694
695 695 ui = mapping['ui']
696 696 node = evalstring(context, mapping, args[0])
697 697 if ui.debugflag:
698 698 return node
699 699 return templatefilters.short(node)
700 700
701 701 @templatefunc('pad(text, width[, fillchar=\' \'[, left=False]])',
702 702 argspec='text width fillchar left')
703 703 def pad(context, mapping, args):
704 704 """Pad text with a
705 705 fill character."""
706 706 if 'text' not in args or 'width' not in args:
707 707 # i18n: "pad" is a keyword
708 708 raise error.ParseError(_("pad() expects two to four arguments"))
709 709
710 710 width = evalinteger(context, mapping, args['width'],
711 711 # i18n: "pad" is a keyword
712 712 _("pad() expects an integer width"))
713 713
714 714 text = evalstring(context, mapping, args['text'])
715 715
716 716 left = False
717 717 fillchar = ' '
718 718 if 'fillchar' in args:
719 719 fillchar = evalstring(context, mapping, args['fillchar'])
720 720 if len(color.stripeffects(fillchar)) != 1:
721 721 # i18n: "pad" is a keyword
722 722 raise error.ParseError(_("pad() expects a single fill character"))
723 723 if 'left' in args:
724 724 left = evalboolean(context, mapping, args['left'])
725 725
726 726 fillwidth = width - encoding.colwidth(color.stripeffects(text))
727 727 if fillwidth <= 0:
728 728 return text
729 729 if left:
730 730 return fillchar * fillwidth + text
731 731 else:
732 732 return text + fillchar * fillwidth
733 733
734 734 @templatefunc('indent(text, indentchars[, firstline])')
735 735 def indent(context, mapping, args):
736 736 """Indents all non-empty lines
737 737 with the characters given in the indentchars string. An optional
738 738 third parameter will override the indent for the first line only
739 739 if present."""
740 740 if not (2 <= len(args) <= 3):
741 741 # i18n: "indent" is a keyword
742 742 raise error.ParseError(_("indent() expects two or three arguments"))
743 743
744 744 text = evalstring(context, mapping, args[0])
745 745 indent = evalstring(context, mapping, args[1])
746 746
747 747 if len(args) == 3:
748 748 firstline = evalstring(context, mapping, args[2])
749 749 else:
750 750 firstline = indent
751 751
752 752 # the indent function doesn't indent the first line, so we do it here
753 753 return templatefilters.indent(firstline + text, indent)
754 754
755 755 @templatefunc('get(dict, key)')
756 756 def get(context, mapping, args):
757 757 """Get an attribute/key from an object. Some keywords
758 758 are complex types. This function allows you to obtain the value of an
759 759 attribute on these types."""
760 760 if len(args) != 2:
761 761 # i18n: "get" is a keyword
762 762 raise error.ParseError(_("get() expects two arguments"))
763 763
764 764 dictarg = evalfuncarg(context, mapping, args[0])
765 765 if not util.safehasattr(dictarg, 'get'):
766 766 # i18n: "get" is a keyword
767 767 raise error.ParseError(_("get() expects a dict as first argument"))
768 768
769 769 key = evalfuncarg(context, mapping, args[1])
770 770 return _getdictitem(dictarg, key)
771 771
772 772 def _getdictitem(dictarg, key):
773 773 val = dictarg.get(key)
774 774 if val is None:
775 775 return
776 776 return templatekw.wraphybridvalue(dictarg, key, val)
777 777
778 778 @templatefunc('if(expr, then[, else])')
779 779 def if_(context, mapping, args):
780 780 """Conditionally execute based on the result of
781 781 an expression."""
782 782 if not (2 <= len(args) <= 3):
783 783 # i18n: "if" is a keyword
784 784 raise error.ParseError(_("if expects two or three arguments"))
785 785
786 786 test = evalboolean(context, mapping, args[0])
787 787 if test:
788 788 yield evalrawexp(context, mapping, args[1])
789 789 elif len(args) == 3:
790 790 yield evalrawexp(context, mapping, args[2])
791 791
792 792 @templatefunc('ifcontains(needle, haystack, then[, else])')
793 793 def ifcontains(context, mapping, args):
794 794 """Conditionally execute based
795 795 on whether the item "needle" is in "haystack"."""
796 796 if not (3 <= len(args) <= 4):
797 797 # i18n: "ifcontains" is a keyword
798 798 raise error.ParseError(_("ifcontains expects three or four arguments"))
799 799
800 800 haystack = evalfuncarg(context, mapping, args[1])
801 801 try:
802 802 needle = evalastype(context, mapping, args[0],
803 803 getattr(haystack, 'keytype', None) or bytes)
804 804 found = (needle in haystack)
805 805 except error.ParseError:
806 806 found = False
807 807
808 808 if found:
809 809 yield evalrawexp(context, mapping, args[2])
810 810 elif len(args) == 4:
811 811 yield evalrawexp(context, mapping, args[3])
812 812
813 813 @templatefunc('ifeq(expr1, expr2, then[, else])')
814 814 def ifeq(context, mapping, args):
815 815 """Conditionally execute based on
816 816 whether 2 items are equivalent."""
817 817 if not (3 <= len(args) <= 4):
818 818 # i18n: "ifeq" is a keyword
819 819 raise error.ParseError(_("ifeq expects three or four arguments"))
820 820
821 821 test = evalstring(context, mapping, args[0])
822 822 match = evalstring(context, mapping, args[1])
823 823 if test == match:
824 824 yield evalrawexp(context, mapping, args[2])
825 825 elif len(args) == 4:
826 826 yield evalrawexp(context, mapping, args[3])
827 827
828 828 @templatefunc('join(list, sep)')
829 829 def join(context, mapping, args):
830 830 """Join items in a list with a delimiter."""
831 831 if not (1 <= len(args) <= 2):
832 832 # i18n: "join" is a keyword
833 833 raise error.ParseError(_("join expects one or two arguments"))
834 834
835 835 # TODO: perhaps this should be evalfuncarg(), but it can't because hgweb
836 836 # abuses generator as a keyword that returns a list of dicts.
837 837 joinset = evalrawexp(context, mapping, args[0])
838 838 joinset = templatekw.unwrapvalue(joinset)
839 839 joinfmt = getattr(joinset, 'joinfmt', pycompat.identity)
840 840 joiner = " "
841 841 if len(args) > 1:
842 842 joiner = evalstring(context, mapping, args[1])
843 843
844 844 first = True
845 845 for x in joinset:
846 846 if first:
847 847 first = False
848 848 else:
849 849 yield joiner
850 850 yield joinfmt(x)
851 851
852 852 @templatefunc('label(label, expr)')
853 853 def label(context, mapping, args):
854 854 """Apply a label to generated content. Content with
855 855 a label applied can result in additional post-processing, such as
856 856 automatic colorization."""
857 857 if len(args) != 2:
858 858 # i18n: "label" is a keyword
859 859 raise error.ParseError(_("label expects two arguments"))
860 860
861 861 ui = mapping['ui']
862 862 thing = evalstring(context, mapping, args[1])
863 863 # preserve unknown symbol as literal so effects like 'red', 'bold',
864 864 # etc. don't need to be quoted
865 865 label = evalstringliteral(context, mapping, args[0])
866 866
867 867 return ui.label(thing, label)
868 868
869 869 @templatefunc('latesttag([pattern])')
870 870 def latesttag(context, mapping, args):
871 871 """The global tags matching the given pattern on the
872 872 most recent globally tagged ancestor of this changeset.
873 873 If no such tags exist, the "{tag}" template resolves to
874 874 the string "null"."""
875 875 if len(args) > 1:
876 876 # i18n: "latesttag" is a keyword
877 877 raise error.ParseError(_("latesttag expects at most one argument"))
878 878
879 879 pattern = None
880 880 if len(args) == 1:
881 881 pattern = evalstring(context, mapping, args[0])
882 882
883 883 return templatekw.showlatesttags(pattern, **pycompat.strkwargs(mapping))
884 884
885 885 @templatefunc('localdate(date[, tz])')
886 886 def localdate(context, mapping, args):
887 887 """Converts a date to the specified timezone.
888 888 The default is local date."""
889 889 if not (1 <= len(args) <= 2):
890 890 # i18n: "localdate" is a keyword
891 891 raise error.ParseError(_("localdate expects one or two arguments"))
892 892
893 893 date = evalfuncarg(context, mapping, args[0])
894 894 try:
895 895 date = util.parsedate(date)
896 896 except AttributeError: # not str nor date tuple
897 897 # i18n: "localdate" is a keyword
898 898 raise error.ParseError(_("localdate expects a date information"))
899 899 if len(args) >= 2:
900 900 tzoffset = None
901 901 tz = evalfuncarg(context, mapping, args[1])
902 902 if isinstance(tz, str):
903 903 tzoffset, remainder = util.parsetimezone(tz)
904 904 if remainder:
905 905 tzoffset = None
906 906 if tzoffset is None:
907 907 try:
908 908 tzoffset = int(tz)
909 909 except (TypeError, ValueError):
910 910 # i18n: "localdate" is a keyword
911 911 raise error.ParseError(_("localdate expects a timezone"))
912 912 else:
913 913 tzoffset = util.makedate()[1]
914 914 return (date[0], tzoffset)
915 915
916 916 @templatefunc('max(iterable)')
917 917 def max_(context, mapping, args, **kwargs):
918 918 """Return the max of an iterable"""
919 919 if len(args) != 1:
920 920 # i18n: "max" is a keyword
921 raise error.ParseError(_("max expects one arguments"))
921 raise error.ParseError(_("max expects one argument"))
922 922
923 923 iterable = evalfuncarg(context, mapping, args[0])
924 924 try:
925 925 x = max(iterable)
926 926 except (TypeError, ValueError):
927 927 # i18n: "max" is a keyword
928 928 raise error.ParseError(_("max first argument should be an iterable"))
929 929 return templatekw.wraphybridvalue(iterable, x, x)
930 930
931 931 @templatefunc('min(iterable)')
932 932 def min_(context, mapping, args, **kwargs):
933 933 """Return the min of an iterable"""
934 934 if len(args) != 1:
935 935 # i18n: "min" is a keyword
936 raise error.ParseError(_("min expects one arguments"))
936 raise error.ParseError(_("min expects one argument"))
937 937
938 938 iterable = evalfuncarg(context, mapping, args[0])
939 939 try:
940 940 x = min(iterable)
941 941 except (TypeError, ValueError):
942 942 # i18n: "min" is a keyword
943 943 raise error.ParseError(_("min first argument should be an iterable"))
944 944 return templatekw.wraphybridvalue(iterable, x, x)
945 945
946 946 @templatefunc('mod(a, b)')
947 947 def mod(context, mapping, args):
948 948 """Calculate a mod b such that a / b + a mod b == a"""
949 949 if not len(args) == 2:
950 950 # i18n: "mod" is a keyword
951 951 raise error.ParseError(_("mod expects two arguments"))
952 952
953 953 func = lambda a, b: a % b
954 954 return runarithmetic(context, mapping, (func, args[0], args[1]))
955 955
956 956 @templatefunc('obsfateoperations(markers)')
957 957 def obsfateoperations(context, mapping, args):
958 958 """Compute obsfate related information based on markers (EXPERIMENTAL)"""
959 959 if len(args) != 1:
960 960 # i18n: "obsfateoperations" is a keyword
961 raise error.ParseError(_("obsfateoperations expects one arguments"))
961 raise error.ParseError(_("obsfateoperations expects one argument"))
962 962
963 963 markers = evalfuncarg(context, mapping, args[0])
964 964
965 965 try:
966 966 data = obsutil.markersoperations(markers)
967 967 return templatekw.hybridlist(data, name='operation')
968 968 except (TypeError, KeyError):
969 969 # i18n: "obsfateoperations" is a keyword
970 970 errmsg = _("obsfateoperations first argument should be an iterable")
971 971 raise error.ParseError(errmsg)
972 972
973 973 @templatefunc('obsfatedate(markers)')
974 974 def obsfatedate(context, mapping, args):
975 975 """Compute obsfate related information based on markers (EXPERIMENTAL)"""
976 976 if len(args) != 1:
977 977 # i18n: "obsfatedate" is a keyword
978 raise error.ParseError(_("obsfatedate expects one arguments"))
978 raise error.ParseError(_("obsfatedate expects one argument"))
979 979
980 980 markers = evalfuncarg(context, mapping, args[0])
981 981
982 982 try:
983 983 data = obsutil.markersdates(markers)
984 984 return templatekw.hybridlist(data, name='date', fmt='%d %d')
985 985 except (TypeError, KeyError):
986 986 # i18n: "obsfatedate" is a keyword
987 987 errmsg = _("obsfatedate first argument should be an iterable")
988 988 raise error.ParseError(errmsg)
989 989
990 990 @templatefunc('obsfateusers(markers)')
991 991 def obsfateusers(context, mapping, args):
992 992 """Compute obsfate related information based on markers (EXPERIMENTAL)"""
993 993 if len(args) != 1:
994 994 # i18n: "obsfateusers" is a keyword
995 raise error.ParseError(_("obsfateusers expects one arguments"))
995 raise error.ParseError(_("obsfateusers expects one argument"))
996 996
997 997 markers = evalfuncarg(context, mapping, args[0])
998 998
999 999 try:
1000 1000 data = obsutil.markersusers(markers)
1001 1001 return templatekw.hybridlist(data, name='user')
1002 1002 except (TypeError, KeyError, ValueError):
1003 1003 # i18n: "obsfateusers" is a keyword
1004 1004 msg = _("obsfateusers first argument should be an iterable of "
1005 1005 "obsmakers")
1006 1006 raise error.ParseError(msg)
1007 1007
1008 1008 @templatefunc('obsfateverb(successors, markers)')
1009 1009 def obsfateverb(context, mapping, args):
1010 1010 """Compute obsfate related information based on successors (EXPERIMENTAL)"""
1011 1011 if len(args) != 2:
1012 1012 # i18n: "obsfateverb" is a keyword
1013 1013 raise error.ParseError(_("obsfateverb expects two arguments"))
1014 1014
1015 1015 successors = evalfuncarg(context, mapping, args[0])
1016 1016 markers = evalfuncarg(context, mapping, args[1])
1017 1017
1018 1018 try:
1019 1019 return obsutil.obsfateverb(successors, markers)
1020 1020 except TypeError:
1021 1021 # i18n: "obsfateverb" is a keyword
1022 1022 errmsg = _("obsfateverb first argument should be countable")
1023 1023 raise error.ParseError(errmsg)
1024 1024
1025 1025 @templatefunc('relpath(path)')
1026 1026 def relpath(context, mapping, args):
1027 1027 """Convert a repository-absolute path into a filesystem path relative to
1028 1028 the current working directory."""
1029 1029 if len(args) != 1:
1030 1030 # i18n: "relpath" is a keyword
1031 1031 raise error.ParseError(_("relpath expects one argument"))
1032 1032
1033 1033 repo = mapping['ctx'].repo()
1034 1034 path = evalstring(context, mapping, args[0])
1035 1035 return repo.pathto(path)
1036 1036
1037 1037 @templatefunc('revset(query[, formatargs...])')
1038 1038 def revset(context, mapping, args):
1039 1039 """Execute a revision set query. See
1040 1040 :hg:`help revset`."""
1041 1041 if not len(args) > 0:
1042 1042 # i18n: "revset" is a keyword
1043 1043 raise error.ParseError(_("revset expects one or more arguments"))
1044 1044
1045 1045 raw = evalstring(context, mapping, args[0])
1046 1046 ctx = mapping['ctx']
1047 1047 repo = ctx.repo()
1048 1048
1049 1049 def query(expr):
1050 1050 m = revsetmod.match(repo.ui, expr, repo=repo)
1051 1051 return m(repo)
1052 1052
1053 1053 if len(args) > 1:
1054 1054 formatargs = [evalfuncarg(context, mapping, a) for a in args[1:]]
1055 1055 revs = query(revsetlang.formatspec(raw, *formatargs))
1056 1056 revs = list(revs)
1057 1057 else:
1058 1058 revsetcache = mapping['cache'].setdefault("revsetcache", {})
1059 1059 if raw in revsetcache:
1060 1060 revs = revsetcache[raw]
1061 1061 else:
1062 1062 revs = query(raw)
1063 1063 revs = list(revs)
1064 1064 revsetcache[raw] = revs
1065 1065
1066 1066 return templatekw.showrevslist("revision", revs,
1067 1067 **pycompat.strkwargs(mapping))
1068 1068
1069 1069 @templatefunc('rstdoc(text, style)')
1070 1070 def rstdoc(context, mapping, args):
1071 1071 """Format reStructuredText."""
1072 1072 if len(args) != 2:
1073 1073 # i18n: "rstdoc" is a keyword
1074 1074 raise error.ParseError(_("rstdoc expects two arguments"))
1075 1075
1076 1076 text = evalstring(context, mapping, args[0])
1077 1077 style = evalstring(context, mapping, args[1])
1078 1078
1079 1079 return minirst.format(text, style=style, keep=['verbose'])
1080 1080
1081 1081 @templatefunc('separate(sep, args)', argspec='sep *args')
1082 1082 def separate(context, mapping, args):
1083 1083 """Add a separator between non-empty arguments."""
1084 1084 if 'sep' not in args:
1085 1085 # i18n: "separate" is a keyword
1086 1086 raise error.ParseError(_("separate expects at least one argument"))
1087 1087
1088 1088 sep = evalstring(context, mapping, args['sep'])
1089 1089 first = True
1090 1090 for arg in args['args']:
1091 1091 argstr = evalstring(context, mapping, arg)
1092 1092 if not argstr:
1093 1093 continue
1094 1094 if first:
1095 1095 first = False
1096 1096 else:
1097 1097 yield sep
1098 1098 yield argstr
1099 1099
1100 1100 @templatefunc('shortest(node, minlength=4)')
1101 1101 def shortest(context, mapping, args):
1102 1102 """Obtain the shortest representation of
1103 1103 a node."""
1104 1104 if not (1 <= len(args) <= 2):
1105 1105 # i18n: "shortest" is a keyword
1106 1106 raise error.ParseError(_("shortest() expects one or two arguments"))
1107 1107
1108 1108 node = evalstring(context, mapping, args[0])
1109 1109
1110 1110 minlength = 4
1111 1111 if len(args) > 1:
1112 1112 minlength = evalinteger(context, mapping, args[1],
1113 1113 # i18n: "shortest" is a keyword
1114 1114 _("shortest() expects an integer minlength"))
1115 1115
1116 1116 # _partialmatch() of filtered changelog could take O(len(repo)) time,
1117 1117 # which would be unacceptably slow. so we look for hash collision in
1118 1118 # unfiltered space, which means some hashes may be slightly longer.
1119 1119 cl = mapping['ctx']._repo.unfiltered().changelog
1120 1120 return cl.shortest(node, minlength)
1121 1121
1122 1122 @templatefunc('strip(text[, chars])')
1123 1123 def strip(context, mapping, args):
1124 1124 """Strip characters from a string. By default,
1125 1125 strips all leading and trailing whitespace."""
1126 1126 if not (1 <= len(args) <= 2):
1127 1127 # i18n: "strip" is a keyword
1128 1128 raise error.ParseError(_("strip expects one or two arguments"))
1129 1129
1130 1130 text = evalstring(context, mapping, args[0])
1131 1131 if len(args) == 2:
1132 1132 chars = evalstring(context, mapping, args[1])
1133 1133 return text.strip(chars)
1134 1134 return text.strip()
1135 1135
1136 1136 @templatefunc('sub(pattern, replacement, expression)')
1137 1137 def sub(context, mapping, args):
1138 1138 """Perform text substitution
1139 1139 using regular expressions."""
1140 1140 if len(args) != 3:
1141 1141 # i18n: "sub" is a keyword
1142 1142 raise error.ParseError(_("sub expects three arguments"))
1143 1143
1144 1144 pat = evalstring(context, mapping, args[0])
1145 1145 rpl = evalstring(context, mapping, args[1])
1146 1146 src = evalstring(context, mapping, args[2])
1147 1147 try:
1148 1148 patre = re.compile(pat)
1149 1149 except re.error:
1150 1150 # i18n: "sub" is a keyword
1151 1151 raise error.ParseError(_("sub got an invalid pattern: %s") % pat)
1152 1152 try:
1153 1153 yield patre.sub(rpl, src)
1154 1154 except re.error:
1155 1155 # i18n: "sub" is a keyword
1156 1156 raise error.ParseError(_("sub got an invalid replacement: %s") % rpl)
1157 1157
1158 1158 @templatefunc('startswith(pattern, text)')
1159 1159 def startswith(context, mapping, args):
1160 1160 """Returns the value from the "text" argument
1161 1161 if it begins with the content from the "pattern" argument."""
1162 1162 if len(args) != 2:
1163 1163 # i18n: "startswith" is a keyword
1164 1164 raise error.ParseError(_("startswith expects two arguments"))
1165 1165
1166 1166 patn = evalstring(context, mapping, args[0])
1167 1167 text = evalstring(context, mapping, args[1])
1168 1168 if text.startswith(patn):
1169 1169 return text
1170 1170 return ''
1171 1171
1172 1172 @templatefunc('word(number, text[, separator])')
1173 1173 def word(context, mapping, args):
1174 1174 """Return the nth word from a string."""
1175 1175 if not (2 <= len(args) <= 3):
1176 1176 # i18n: "word" is a keyword
1177 1177 raise error.ParseError(_("word expects two or three arguments, got %d")
1178 1178 % len(args))
1179 1179
1180 1180 num = evalinteger(context, mapping, args[0],
1181 1181 # i18n: "word" is a keyword
1182 1182 _("word expects an integer index"))
1183 1183 text = evalstring(context, mapping, args[1])
1184 1184 if len(args) == 3:
1185 1185 splitter = evalstring(context, mapping, args[2])
1186 1186 else:
1187 1187 splitter = None
1188 1188
1189 1189 tokens = text.split(splitter)
1190 1190 if num >= len(tokens) or num < -len(tokens):
1191 1191 return ''
1192 1192 else:
1193 1193 return tokens[num]
1194 1194
1195 1195 # methods to interpret function arguments or inner expressions (e.g. {_(x)})
1196 1196 exprmethods = {
1197 1197 "integer": lambda e, c: (runinteger, e[1]),
1198 1198 "string": lambda e, c: (runstring, e[1]),
1199 1199 "symbol": lambda e, c: (runsymbol, e[1]),
1200 1200 "template": buildtemplate,
1201 1201 "group": lambda e, c: compileexp(e[1], c, exprmethods),
1202 1202 ".": buildmember,
1203 1203 "|": buildfilter,
1204 1204 "%": buildmap,
1205 1205 "func": buildfunc,
1206 1206 "keyvalue": buildkeyvaluepair,
1207 1207 "+": lambda e, c: buildarithmetic(e, c, lambda a, b: a + b),
1208 1208 "-": lambda e, c: buildarithmetic(e, c, lambda a, b: a - b),
1209 1209 "negate": buildnegate,
1210 1210 "*": lambda e, c: buildarithmetic(e, c, lambda a, b: a * b),
1211 1211 "/": lambda e, c: buildarithmetic(e, c, lambda a, b: a // b),
1212 1212 }
1213 1213
1214 1214 # methods to interpret top-level template (e.g. {x}, {x|_}, {x % "y"})
1215 1215 methods = exprmethods.copy()
1216 1216 methods["integer"] = exprmethods["symbol"] # '{1}' as variable
1217 1217
1218 1218 class _aliasrules(parser.basealiasrules):
1219 1219 """Parsing and expansion rule set of template aliases"""
1220 1220 _section = _('template alias')
1221 1221 _parse = staticmethod(_parseexpr)
1222 1222
1223 1223 @staticmethod
1224 1224 def _trygetfunc(tree):
1225 1225 """Return (name, args) if tree is func(...) or ...|filter; otherwise
1226 1226 None"""
1227 1227 if tree[0] == 'func' and tree[1][0] == 'symbol':
1228 1228 return tree[1][1], getlist(tree[2])
1229 1229 if tree[0] == '|' and tree[2][0] == 'symbol':
1230 1230 return tree[2][1], [tree[1]]
1231 1231
1232 1232 def expandaliases(tree, aliases):
1233 1233 """Return new tree of aliases are expanded"""
1234 1234 aliasmap = _aliasrules.buildmap(aliases)
1235 1235 return _aliasrules.expand(aliasmap, tree)
1236 1236
1237 1237 # template engine
1238 1238
1239 1239 stringify = templatefilters.stringify
1240 1240
1241 1241 def _flatten(thing):
1242 1242 '''yield a single stream from a possibly nested set of iterators'''
1243 1243 thing = templatekw.unwraphybrid(thing)
1244 1244 if isinstance(thing, bytes):
1245 1245 yield thing
1246 1246 elif isinstance(thing, str):
1247 1247 # We can only hit this on Python 3, and it's here to guard
1248 1248 # against infinite recursion.
1249 1249 raise error.ProgrammingError('Mercurial IO including templates is done'
1250 1250 ' with bytes, not strings')
1251 1251 elif thing is None:
1252 1252 pass
1253 1253 elif not util.safehasattr(thing, '__iter__'):
1254 1254 yield pycompat.bytestr(thing)
1255 1255 else:
1256 1256 for i in thing:
1257 1257 i = templatekw.unwraphybrid(i)
1258 1258 if isinstance(i, bytes):
1259 1259 yield i
1260 1260 elif i is None:
1261 1261 pass
1262 1262 elif not util.safehasattr(i, '__iter__'):
1263 1263 yield pycompat.bytestr(i)
1264 1264 else:
1265 1265 for j in _flatten(i):
1266 1266 yield j
1267 1267
1268 1268 def unquotestring(s):
1269 1269 '''unwrap quotes if any; otherwise returns unmodified string'''
1270 1270 if len(s) < 2 or s[0] not in "'\"" or s[0] != s[-1]:
1271 1271 return s
1272 1272 return s[1:-1]
1273 1273
1274 1274 class engine(object):
1275 1275 '''template expansion engine.
1276 1276
1277 1277 template expansion works like this. a map file contains key=value
1278 1278 pairs. if value is quoted, it is treated as string. otherwise, it
1279 1279 is treated as name of template file.
1280 1280
1281 1281 templater is asked to expand a key in map. it looks up key, and
1282 1282 looks for strings like this: {foo}. it expands {foo} by looking up
1283 1283 foo in map, and substituting it. expansion is recursive: it stops
1284 1284 when there is no more {foo} to replace.
1285 1285
1286 1286 expansion also allows formatting and filtering.
1287 1287
1288 1288 format uses key to expand each item in list. syntax is
1289 1289 {key%format}.
1290 1290
1291 1291 filter uses function to transform value. syntax is
1292 1292 {key|filter1|filter2|...}.'''
1293 1293
1294 1294 def __init__(self, loader, filters=None, defaults=None, aliases=()):
1295 1295 self._loader = loader
1296 1296 if filters is None:
1297 1297 filters = {}
1298 1298 self._filters = filters
1299 1299 if defaults is None:
1300 1300 defaults = {}
1301 1301 self._defaults = defaults
1302 1302 self._aliasmap = _aliasrules.buildmap(aliases)
1303 1303 self._cache = {} # key: (func, data)
1304 1304
1305 1305 def _load(self, t):
1306 1306 '''load, parse, and cache a template'''
1307 1307 if t not in self._cache:
1308 1308 # put poison to cut recursion while compiling 't'
1309 1309 self._cache[t] = (_runrecursivesymbol, t)
1310 1310 try:
1311 1311 x = parse(self._loader(t))
1312 1312 if self._aliasmap:
1313 1313 x = _aliasrules.expand(self._aliasmap, x)
1314 1314 self._cache[t] = compileexp(x, self, methods)
1315 1315 except: # re-raises
1316 1316 del self._cache[t]
1317 1317 raise
1318 1318 return self._cache[t]
1319 1319
1320 1320 def process(self, t, mapping):
1321 1321 '''Perform expansion. t is name of map element to expand.
1322 1322 mapping contains added elements for use during expansion. Is a
1323 1323 generator.'''
1324 1324 func, data = self._load(t)
1325 1325 return _flatten(func(self, mapping, data))
1326 1326
1327 1327 engines = {'default': engine}
1328 1328
1329 1329 def stylelist():
1330 1330 paths = templatepaths()
1331 1331 if not paths:
1332 1332 return _('no templates found, try `hg debuginstall` for more info')
1333 1333 dirlist = os.listdir(paths[0])
1334 1334 stylelist = []
1335 1335 for file in dirlist:
1336 1336 split = file.split(".")
1337 1337 if split[-1] in ('orig', 'rej'):
1338 1338 continue
1339 1339 if split[0] == "map-cmdline":
1340 1340 stylelist.append(split[1])
1341 1341 return ", ".join(sorted(stylelist))
1342 1342
1343 1343 def _readmapfile(mapfile):
1344 1344 """Load template elements from the given map file"""
1345 1345 if not os.path.exists(mapfile):
1346 1346 raise error.Abort(_("style '%s' not found") % mapfile,
1347 1347 hint=_("available styles: %s") % stylelist())
1348 1348
1349 1349 base = os.path.dirname(mapfile)
1350 1350 conf = config.config(includepaths=templatepaths())
1351 1351 conf.read(mapfile, remap={'': 'templates'})
1352 1352
1353 1353 cache = {}
1354 1354 tmap = {}
1355 1355 aliases = []
1356 1356
1357 1357 val = conf.get('templates', '__base__')
1358 1358 if val and val[0] not in "'\"":
1359 1359 # treat as a pointer to a base class for this style
1360 1360 path = util.normpath(os.path.join(base, val))
1361 1361
1362 1362 # fallback check in template paths
1363 1363 if not os.path.exists(path):
1364 1364 for p in templatepaths():
1365 1365 p2 = util.normpath(os.path.join(p, val))
1366 1366 if os.path.isfile(p2):
1367 1367 path = p2
1368 1368 break
1369 1369 p3 = util.normpath(os.path.join(p2, "map"))
1370 1370 if os.path.isfile(p3):
1371 1371 path = p3
1372 1372 break
1373 1373
1374 1374 cache, tmap, aliases = _readmapfile(path)
1375 1375
1376 1376 for key, val in conf['templates'].items():
1377 1377 if not val:
1378 1378 raise error.ParseError(_('missing value'),
1379 1379 conf.source('templates', key))
1380 1380 if val[0] in "'\"":
1381 1381 if val[0] != val[-1]:
1382 1382 raise error.ParseError(_('unmatched quotes'),
1383 1383 conf.source('templates', key))
1384 1384 cache[key] = unquotestring(val)
1385 1385 elif key != '__base__':
1386 1386 val = 'default', val
1387 1387 if ':' in val[1]:
1388 1388 val = val[1].split(':', 1)
1389 1389 tmap[key] = val[0], os.path.join(base, val[1])
1390 1390 aliases.extend(conf['templatealias'].items())
1391 1391 return cache, tmap, aliases
1392 1392
1393 1393 class TemplateNotFound(error.Abort):
1394 1394 pass
1395 1395
1396 1396 class templater(object):
1397 1397
1398 1398 def __init__(self, filters=None, defaults=None, cache=None, aliases=(),
1399 1399 minchunk=1024, maxchunk=65536):
1400 1400 '''set up template engine.
1401 1401 filters is dict of functions. each transforms a value into another.
1402 1402 defaults is dict of default map definitions.
1403 1403 aliases is list of alias (name, replacement) pairs.
1404 1404 '''
1405 1405 if filters is None:
1406 1406 filters = {}
1407 1407 if defaults is None:
1408 1408 defaults = {}
1409 1409 if cache is None:
1410 1410 cache = {}
1411 1411 self.cache = cache.copy()
1412 1412 self.map = {}
1413 1413 self.filters = templatefilters.filters.copy()
1414 1414 self.filters.update(filters)
1415 1415 self.defaults = defaults
1416 1416 self._aliases = aliases
1417 1417 self.minchunk, self.maxchunk = minchunk, maxchunk
1418 1418 self.ecache = {}
1419 1419
1420 1420 @classmethod
1421 1421 def frommapfile(cls, mapfile, filters=None, defaults=None, cache=None,
1422 1422 minchunk=1024, maxchunk=65536):
1423 1423 """Create templater from the specified map file"""
1424 1424 t = cls(filters, defaults, cache, [], minchunk, maxchunk)
1425 1425 cache, tmap, aliases = _readmapfile(mapfile)
1426 1426 t.cache.update(cache)
1427 1427 t.map = tmap
1428 1428 t._aliases = aliases
1429 1429 return t
1430 1430
1431 1431 def __contains__(self, key):
1432 1432 return key in self.cache or key in self.map
1433 1433
1434 1434 def load(self, t):
1435 1435 '''Get the template for the given template name. Use a local cache.'''
1436 1436 if t not in self.cache:
1437 1437 try:
1438 1438 self.cache[t] = util.readfile(self.map[t][1])
1439 1439 except KeyError as inst:
1440 1440 raise TemplateNotFound(_('"%s" not in template map') %
1441 1441 inst.args[0])
1442 1442 except IOError as inst:
1443 1443 raise IOError(inst.args[0], _('template file %s: %s') %
1444 1444 (self.map[t][1], inst.args[1]))
1445 1445 return self.cache[t]
1446 1446
1447 1447 def render(self, mapping):
1448 1448 """Render the default unnamed template and return result as string"""
1449 1449 mapping = pycompat.strkwargs(mapping)
1450 1450 return stringify(self('', **mapping))
1451 1451
1452 1452 def __call__(self, t, **mapping):
1453 1453 mapping = pycompat.byteskwargs(mapping)
1454 1454 ttype = t in self.map and self.map[t][0] or 'default'
1455 1455 if ttype not in self.ecache:
1456 1456 try:
1457 1457 ecls = engines[ttype]
1458 1458 except KeyError:
1459 1459 raise error.Abort(_('invalid template engine: %s') % ttype)
1460 1460 self.ecache[ttype] = ecls(self.load, self.filters, self.defaults,
1461 1461 self._aliases)
1462 1462 proc = self.ecache[ttype]
1463 1463
1464 1464 stream = proc.process(t, mapping)
1465 1465 if self.minchunk:
1466 1466 stream = util.increasingchunks(stream, min=self.minchunk,
1467 1467 max=self.maxchunk)
1468 1468 return stream
1469 1469
1470 1470 def templatepaths():
1471 1471 '''return locations used for template files.'''
1472 1472 pathsrel = ['templates']
1473 1473 paths = [os.path.normpath(os.path.join(util.datapath, f))
1474 1474 for f in pathsrel]
1475 1475 return [p for p in paths if os.path.isdir(p)]
1476 1476
1477 1477 def templatepath(name):
1478 1478 '''return location of template file. returns None if not found.'''
1479 1479 for p in templatepaths():
1480 1480 f = os.path.join(p, name)
1481 1481 if os.path.exists(f):
1482 1482 return f
1483 1483 return None
1484 1484
1485 1485 def stylemap(styles, paths=None):
1486 1486 """Return path to mapfile for a given style.
1487 1487
1488 1488 Searches mapfile in the following locations:
1489 1489 1. templatepath/style/map
1490 1490 2. templatepath/map-style
1491 1491 3. templatepath/map
1492 1492 """
1493 1493
1494 1494 if paths is None:
1495 1495 paths = templatepaths()
1496 1496 elif isinstance(paths, str):
1497 1497 paths = [paths]
1498 1498
1499 1499 if isinstance(styles, str):
1500 1500 styles = [styles]
1501 1501
1502 1502 for style in styles:
1503 1503 # only plain name is allowed to honor template paths
1504 1504 if (not style
1505 1505 or style in (os.curdir, os.pardir)
1506 1506 or pycompat.ossep in style
1507 1507 or pycompat.osaltsep and pycompat.osaltsep in style):
1508 1508 continue
1509 1509 locations = [os.path.join(style, 'map'), 'map-' + style]
1510 1510 locations.append('map')
1511 1511
1512 1512 for path in paths:
1513 1513 for location in locations:
1514 1514 mapfile = os.path.join(path, location)
1515 1515 if os.path.isfile(mapfile):
1516 1516 return style, mapfile
1517 1517
1518 1518 raise RuntimeError("No hgweb templates found in %r" % paths)
1519 1519
1520 1520 def loadfunction(ui, extname, registrarobj):
1521 1521 """Load template function from specified registrarobj
1522 1522 """
1523 1523 for name, func in registrarobj._table.iteritems():
1524 1524 funcs[name] = func
1525 1525
1526 1526 # tell hggettext to extract docstrings from these functions:
1527 1527 i18nfunctions = funcs.values()
@@ -1,456 +1,489
1 1 $ . "$TESTDIR/histedit-helpers.sh"
2 2
3 3 $ cat >> $HGRCPATH <<EOF
4 4 > [extensions]
5 5 > histedit=
6 6 > EOF
7 7
8 8 $ initrepo ()
9 9 > {
10 10 > hg init r
11 11 > cd r
12 12 > for x in a b c d e f ; do
13 13 > echo $x > $x
14 14 > hg add $x
15 15 > hg ci -m $x
16 16 > done
17 17 > }
18 18
19 19 $ initrepo
20 20
21 21 log before edit
22 22 $ hg log --graph
23 23 @ changeset: 5:652413bf663e
24 24 | tag: tip
25 25 | user: test
26 26 | date: Thu Jan 01 00:00:00 1970 +0000
27 27 | summary: f
28 28 |
29 29 o changeset: 4:e860deea161a
30 30 | user: test
31 31 | date: Thu Jan 01 00:00:00 1970 +0000
32 32 | summary: e
33 33 |
34 34 o changeset: 3:055a42cdd887
35 35 | user: test
36 36 | date: Thu Jan 01 00:00:00 1970 +0000
37 37 | summary: d
38 38 |
39 39 o changeset: 2:177f92b77385
40 40 | user: test
41 41 | date: Thu Jan 01 00:00:00 1970 +0000
42 42 | summary: c
43 43 |
44 44 o changeset: 1:d2ae7f538514
45 45 | user: test
46 46 | date: Thu Jan 01 00:00:00 1970 +0000
47 47 | summary: b
48 48 |
49 49 o changeset: 0:cb9a9f314b8b
50 50 user: test
51 51 date: Thu Jan 01 00:00:00 1970 +0000
52 52 summary: a
53 53
54 54
55 55 show the edit commands offered
56 56 $ HGEDITOR=cat hg histedit 177f92b77385
57 57 pick 177f92b77385 2 c
58 58 pick 055a42cdd887 3 d
59 59 pick e860deea161a 4 e
60 60 pick 652413bf663e 5 f
61 61
62 62 # Edit history between 177f92b77385 and 652413bf663e
63 63 #
64 64 # Commits are listed from least to most recent
65 65 #
66 66 # You can reorder changesets by reordering the lines
67 67 #
68 68 # Commands:
69 69 #
70 70 # e, edit = use commit, but stop for amending
71 71 # m, mess = edit commit message without changing commit content
72 72 # p, pick = use commit
73 73 # b, base = checkout changeset and apply further changesets from there
74 74 # d, drop = remove commit from history
75 75 # f, fold = use commit, but combine it with the one above
76 76 # r, roll = like fold, but discard this commit's description and date
77 77 #
78 78
79 79 edit the history
80 80 (use a hacky editor to check histedit-last-edit.txt backup)
81 81
82 82 $ EDITED="$TESTTMP/editedhistory"
83 83 $ cat > $EDITED <<EOF
84 84 > edit 177f92b77385 c
85 85 > pick e860deea161a e
86 86 > pick 652413bf663e f
87 87 > pick 055a42cdd887 d
88 88 > EOF
89 89 $ HGEDITOR="cat \"$EDITED\" > " hg histedit 177f92b77385 2>&1 | fixbundle
90 90 0 files updated, 0 files merged, 4 files removed, 0 files unresolved
91 91 Editing (177f92b77385), you may commit or record as needed now.
92 92 (hg histedit --continue to resume)
93 93
94 94 rules should end up in .hg/histedit-last-edit.txt:
95 95 $ cat .hg/histedit-last-edit.txt
96 96 edit 177f92b77385 c
97 97 pick e860deea161a e
98 98 pick 652413bf663e f
99 99 pick 055a42cdd887 d
100 100
101 101 $ hg histedit --abort
102 102 4 files updated, 0 files merged, 0 files removed, 0 files unresolved
103 103 $ cat > $EDITED <<EOF
104 104 > pick 177f92b77385 c
105 105 > pick e860deea161a e
106 106 > pick 652413bf663e f
107 107 > pick 055a42cdd887 d
108 108 > EOF
109 109 $ HGEDITOR="cat \"$EDITED\" > " hg histedit 177f92b77385 2>&1 | fixbundle
110 110
111 111 log after edit
112 112 $ hg log --graph
113 113 @ changeset: 5:07114f51870f
114 114 | tag: tip
115 115 | user: test
116 116 | date: Thu Jan 01 00:00:00 1970 +0000
117 117 | summary: d
118 118 |
119 119 o changeset: 4:8ade9693061e
120 120 | user: test
121 121 | date: Thu Jan 01 00:00:00 1970 +0000
122 122 | summary: f
123 123 |
124 124 o changeset: 3:d8249471110a
125 125 | user: test
126 126 | date: Thu Jan 01 00:00:00 1970 +0000
127 127 | summary: e
128 128 |
129 129 o changeset: 2:177f92b77385
130 130 | user: test
131 131 | date: Thu Jan 01 00:00:00 1970 +0000
132 132 | summary: c
133 133 |
134 134 o changeset: 1:d2ae7f538514
135 135 | user: test
136 136 | date: Thu Jan 01 00:00:00 1970 +0000
137 137 | summary: b
138 138 |
139 139 o changeset: 0:cb9a9f314b8b
140 140 user: test
141 141 date: Thu Jan 01 00:00:00 1970 +0000
142 142 summary: a
143 143
144 144
145 145 put things back
146 146
147 147 $ hg histedit 177f92b77385 --commands - 2>&1 << EOF | fixbundle
148 148 > pick 177f92b77385 c
149 149 > pick 07114f51870f d
150 150 > pick d8249471110a e
151 151 > pick 8ade9693061e f
152 152 > EOF
153 153
154 154 $ hg log --graph
155 155 @ changeset: 5:7eca9b5b1148
156 156 | tag: tip
157 157 | user: test
158 158 | date: Thu Jan 01 00:00:00 1970 +0000
159 159 | summary: f
160 160 |
161 161 o changeset: 4:915da888f2de
162 162 | user: test
163 163 | date: Thu Jan 01 00:00:00 1970 +0000
164 164 | summary: e
165 165 |
166 166 o changeset: 3:10517e47bbbb
167 167 | user: test
168 168 | date: Thu Jan 01 00:00:00 1970 +0000
169 169 | summary: d
170 170 |
171 171 o changeset: 2:177f92b77385
172 172 | user: test
173 173 | date: Thu Jan 01 00:00:00 1970 +0000
174 174 | summary: c
175 175 |
176 176 o changeset: 1:d2ae7f538514
177 177 | user: test
178 178 | date: Thu Jan 01 00:00:00 1970 +0000
179 179 | summary: b
180 180 |
181 181 o changeset: 0:cb9a9f314b8b
182 182 user: test
183 183 date: Thu Jan 01 00:00:00 1970 +0000
184 184 summary: a
185 185
186 186
187 187 slightly different this time
188 188
189 189 $ hg histedit 177f92b77385 --commands - << EOF 2>&1 | fixbundle
190 190 > pick 10517e47bbbb d
191 191 > pick 7eca9b5b1148 f
192 192 > pick 915da888f2de e
193 193 > pick 177f92b77385 c
194 194 > EOF
195 195 $ hg log --graph
196 196 @ changeset: 5:38b92f448761
197 197 | tag: tip
198 198 | user: test
199 199 | date: Thu Jan 01 00:00:00 1970 +0000
200 200 | summary: c
201 201 |
202 202 o changeset: 4:de71b079d9ce
203 203 | user: test
204 204 | date: Thu Jan 01 00:00:00 1970 +0000
205 205 | summary: e
206 206 |
207 207 o changeset: 3:be9ae3a309c6
208 208 | user: test
209 209 | date: Thu Jan 01 00:00:00 1970 +0000
210 210 | summary: f
211 211 |
212 212 o changeset: 2:799205341b6b
213 213 | user: test
214 214 | date: Thu Jan 01 00:00:00 1970 +0000
215 215 | summary: d
216 216 |
217 217 o changeset: 1:d2ae7f538514
218 218 | user: test
219 219 | date: Thu Jan 01 00:00:00 1970 +0000
220 220 | summary: b
221 221 |
222 222 o changeset: 0:cb9a9f314b8b
223 223 user: test
224 224 date: Thu Jan 01 00:00:00 1970 +0000
225 225 summary: a
226 226
227 227
228 228 keep prevents stripping dead revs
229 229 $ hg histedit 799205341b6b --keep --commands - 2>&1 << EOF | fixbundle
230 230 > pick 799205341b6b d
231 231 > pick be9ae3a309c6 f
232 232 > pick 38b92f448761 c
233 233 > pick de71b079d9ce e
234 234 > EOF
235 235 $ hg log --graph
236 236 @ changeset: 7:803ef1c6fcfd
237 237 | tag: tip
238 238 | user: test
239 239 | date: Thu Jan 01 00:00:00 1970 +0000
240 240 | summary: e
241 241 |
242 242 o changeset: 6:ece0b8d93dda
243 243 | parent: 3:be9ae3a309c6
244 244 | user: test
245 245 | date: Thu Jan 01 00:00:00 1970 +0000
246 246 | summary: c
247 247 |
248 248 | o changeset: 5:38b92f448761
249 249 | | user: test
250 250 | | date: Thu Jan 01 00:00:00 1970 +0000
251 251 | | summary: c
252 252 | |
253 253 | o changeset: 4:de71b079d9ce
254 254 |/ user: test
255 255 | date: Thu Jan 01 00:00:00 1970 +0000
256 256 | summary: e
257 257 |
258 258 o changeset: 3:be9ae3a309c6
259 259 | user: test
260 260 | date: Thu Jan 01 00:00:00 1970 +0000
261 261 | summary: f
262 262 |
263 263 o changeset: 2:799205341b6b
264 264 | user: test
265 265 | date: Thu Jan 01 00:00:00 1970 +0000
266 266 | summary: d
267 267 |
268 268 o changeset: 1:d2ae7f538514
269 269 | user: test
270 270 | date: Thu Jan 01 00:00:00 1970 +0000
271 271 | summary: b
272 272 |
273 273 o changeset: 0:cb9a9f314b8b
274 274 user: test
275 275 date: Thu Jan 01 00:00:00 1970 +0000
276 276 summary: a
277 277
278 278
279 279 try with --rev
280 280 $ hg histedit --commands - --rev -2 2>&1 <<EOF | fixbundle
281 281 > pick de71b079d9ce e
282 282 > pick 38b92f448761 c
283 283 > EOF
284 284 hg: parse error: pick "de71b079d9ce" changeset was not a candidate
285 285 (only use listed changesets)
286 286 $ hg log --graph
287 287 @ changeset: 7:803ef1c6fcfd
288 288 | tag: tip
289 289 | user: test
290 290 | date: Thu Jan 01 00:00:00 1970 +0000
291 291 | summary: e
292 292 |
293 293 o changeset: 6:ece0b8d93dda
294 294 | parent: 3:be9ae3a309c6
295 295 | user: test
296 296 | date: Thu Jan 01 00:00:00 1970 +0000
297 297 | summary: c
298 298 |
299 299 | o changeset: 5:38b92f448761
300 300 | | user: test
301 301 | | date: Thu Jan 01 00:00:00 1970 +0000
302 302 | | summary: c
303 303 | |
304 304 | o changeset: 4:de71b079d9ce
305 305 |/ user: test
306 306 | date: Thu Jan 01 00:00:00 1970 +0000
307 307 | summary: e
308 308 |
309 309 o changeset: 3:be9ae3a309c6
310 310 | user: test
311 311 | date: Thu Jan 01 00:00:00 1970 +0000
312 312 | summary: f
313 313 |
314 314 o changeset: 2:799205341b6b
315 315 | user: test
316 316 | date: Thu Jan 01 00:00:00 1970 +0000
317 317 | summary: d
318 318 |
319 319 o changeset: 1:d2ae7f538514
320 320 | user: test
321 321 | date: Thu Jan 01 00:00:00 1970 +0000
322 322 | summary: b
323 323 |
324 324 o changeset: 0:cb9a9f314b8b
325 325 user: test
326 326 date: Thu Jan 01 00:00:00 1970 +0000
327 327 summary: a
328 328
329 329 Verify that revsetalias entries work with histedit:
330 330 $ cat >> $HGRCPATH <<EOF
331 331 > [revsetalias]
332 332 > grandparent(ARG) = p1(p1(ARG))
333 333 > EOF
334 334 $ echo extra commit >> c
335 335 $ hg ci -m 'extra commit to c'
336 336 $ HGEDITOR=cat hg histedit 'grandparent(.)'
337 337 pick ece0b8d93dda 6 c
338 338 pick 803ef1c6fcfd 7 e
339 339 pick 9c863c565126 8 extra commit to c
340 340
341 341 # Edit history between ece0b8d93dda and 9c863c565126
342 342 #
343 343 # Commits are listed from least to most recent
344 344 #
345 345 # You can reorder changesets by reordering the lines
346 346 #
347 347 # Commands:
348 348 #
349 349 # e, edit = use commit, but stop for amending
350 350 # m, mess = edit commit message without changing commit content
351 351 # p, pick = use commit
352 352 # b, base = checkout changeset and apply further changesets from there
353 353 # d, drop = remove commit from history
354 354 # f, fold = use commit, but combine it with the one above
355 355 # r, roll = like fold, but discard this commit's description and date
356 356 #
357 357
358 358 should also work if a commit message is missing
359 359 $ BUNDLE="$TESTDIR/missing-comment.hg"
360 360 $ hg init missing
361 361 $ cd missing
362 362 $ hg unbundle $BUNDLE
363 363 adding changesets
364 364 adding manifests
365 365 adding file changes
366 366 added 3 changesets with 3 changes to 1 files
367 367 new changesets 141947992243:bd22688093b3
368 368 (run 'hg update' to get a working copy)
369 369 $ hg co tip
370 370 1 files updated, 0 files merged, 0 files removed, 0 files unresolved
371 371 $ hg log --graph
372 372 @ changeset: 2:bd22688093b3
373 373 | tag: tip
374 374 | user: Robert Altman <robert.altman@telventDTN.com>
375 375 | date: Mon Nov 28 16:40:04 2011 +0000
376 376 | summary: Update file.
377 377 |
378 378 o changeset: 1:3b3e956f9171
379 379 | user: Robert Altman <robert.altman@telventDTN.com>
380 380 | date: Mon Nov 28 16:37:57 2011 +0000
381 381 |
382 382 o changeset: 0:141947992243
383 383 user: Robert Altman <robert.altman@telventDTN.com>
384 384 date: Mon Nov 28 16:35:28 2011 +0000
385 385 summary: Checked in text file
386 386
387 387 $ hg histedit 0
388 388 $ cd ..
389 389
390 390 $ cd ..
391 391
392 392
393 393 Test to make sure folding renames doesn't cause bogus conflicts (issue4251):
394 394 $ hg init issue4251
395 395 $ cd issue4251
396 396
397 397 $ mkdir initial-dir
398 398 $ echo foo > initial-dir/initial-file
399 399 $ hg add initial-dir/initial-file
400 400 $ hg commit -m "initial commit"
401 401
402 402 Move the file to a new directory, and in the same commit, change its content:
403 403 $ mkdir another-dir
404 404 $ hg mv initial-dir/initial-file another-dir/
405 405 $ echo changed > another-dir/initial-file
406 406 $ hg commit -m "moved and changed"
407 407
408 408 Rename the file:
409 409 $ hg mv another-dir/initial-file another-dir/renamed-file
410 410 $ hg commit -m "renamed"
411 411
412 412 Now, let's try to fold the second commit into the first:
413 413 $ cat > editor.sh <<EOF
414 414 > #!/bin/sh
415 415 > cat > \$1 <<ENDOF
416 416 > pick b0f4233702ca 0 initial commit
417 417 > fold 5e8704a8f2d2 1 moved and changed
418 418 > pick 40e7299e8fa7 2 renamed
419 419 > ENDOF
420 420 > EOF
421 421
422 422 $ HGEDITOR="sh ./editor.sh" hg histedit 0
423 423 saved backup bundle to $TESTTMP/issue4251/.hg/strip-backup/b0f4233702ca-4cf5af69-histedit.hg
424 424
425 425 $ hg --config diff.git=yes export 0
426 426 # HG changeset patch
427 427 # User test
428 428 # Date 0 0
429 429 # Thu Jan 01 00:00:00 1970 +0000
430 430 # Node ID fffadc26f8f85623ce60b028a3f1ccc3730f8530
431 431 # Parent 0000000000000000000000000000000000000000
432 432 pick b0f4233702ca 0 initial commit
433 433 fold 5e8704a8f2d2 1 moved and changed
434 434 pick 40e7299e8fa7 2 renamed
435 435
436 436 diff --git a/another-dir/initial-file b/another-dir/initial-file
437 437 new file mode 100644
438 438 --- /dev/null
439 439 +++ b/another-dir/initial-file
440 440 @@ -0,0 +1,1 @@
441 441 +changed
442 442
443 443 $ hg --config diff.git=yes export 1
444 444 # HG changeset patch
445 445 # User test
446 446 # Date 0 0
447 447 # Thu Jan 01 00:00:00 1970 +0000
448 448 # Node ID 9b730d82b00af8a2766facebfa47cc124405a118
449 449 # Parent fffadc26f8f85623ce60b028a3f1ccc3730f8530
450 450 renamed
451 451
452 452 diff --git a/another-dir/initial-file b/another-dir/renamed-file
453 453 rename from another-dir/initial-file
454 454 rename to another-dir/renamed-file
455 455
456 456 $ cd ..
457
458 Test that branches are preserved and stays active
459 -------------------------------------------------
460
461 $ hg init repo-with-branch
462 $ cd repo-with-branch
463 $ echo a > a
464 $ hg add a
465 $ hg commit -m A
466 $ hg branch foo
467 marked working directory as branch foo
468 (branches are permanent and global, did you want a bookmark?)
469 $ echo a > b
470 $ hg add b
471 $ hg commit -m foo-B
472 $ echo a > c
473 $ hg add c
474 $ hg commit -m foo-C
475
476 $ hg branch
477 foo
478 $ echo "pick efefa76d6dc3 2 foo-C" >> cmd
479 $ echo "pick 7336e7550422 1 foo-B" >> cmd
480
481 $ HGEDITOR=cat hg histedit -r ".^" --commands cmd --quiet
482 $ hg log --template '{rev} {branch}\n'
483 2 foo
484 1 foo
485 0 default
486 $ hg branch
487 foo
488
489 $ cd ..
General Comments 0
You need to be logged in to leave comments. Login now