PageRenderTime 195ms CodeModel.GetById 28ms RepoModel.GetById 1ms app.codeStats 0ms

/hgext/histedit.py

https://bitbucket.org/mirror/mercurial/
Python | 928 lines | 844 code | 9 blank | 75 comment | 19 complexity | 3e6198528fac92f637dc63b497e2328e MD5 | raw file
Possible License(s): GPL-2.0
  1. # histedit.py - interactive history editing for mercurial
  2. #
  3. # Copyright 2009 Augie Fackler <raf@durin42.com>
  4. #
  5. # This software may be used and distributed according to the terms of the
  6. # GNU General Public License version 2 or any later version.
  7. """interactive history editing
  8. With this extension installed, Mercurial gains one new command: histedit. Usage
  9. is as follows, assuming the following history::
  10. @ 3[tip] 7c2fd3b9020c 2009-04-27 18:04 -0500 durin42
  11. | Add delta
  12. |
  13. o 2 030b686bedc4 2009-04-27 18:04 -0500 durin42
  14. | Add gamma
  15. |
  16. o 1 c561b4e977df 2009-04-27 18:04 -0500 durin42
  17. | Add beta
  18. |
  19. o 0 d8d2fcd0e319 2009-04-27 18:04 -0500 durin42
  20. Add alpha
  21. If you were to run ``hg histedit c561b4e977df``, you would see the following
  22. file open in your editor::
  23. pick c561b4e977df Add beta
  24. pick 030b686bedc4 Add gamma
  25. pick 7c2fd3b9020c Add delta
  26. # Edit history between c561b4e977df and 7c2fd3b9020c
  27. #
  28. # Commits are listed from least to most recent
  29. #
  30. # Commands:
  31. # p, pick = use commit
  32. # e, edit = use commit, but stop for amending
  33. # f, fold = use commit, but combine it with the one above
  34. # d, drop = remove commit from history
  35. # m, mess = edit message without changing commit content
  36. #
  37. In this file, lines beginning with ``#`` are ignored. You must specify a rule
  38. for each revision in your history. For example, if you had meant to add gamma
  39. before beta, and then wanted to add delta in the same revision as beta, you
  40. would reorganize the file to look like this::
  41. pick 030b686bedc4 Add gamma
  42. pick c561b4e977df Add beta
  43. fold 7c2fd3b9020c Add delta
  44. # Edit history between c561b4e977df and 7c2fd3b9020c
  45. #
  46. # Commits are listed from least to most recent
  47. #
  48. # Commands:
  49. # p, pick = use commit
  50. # e, edit = use commit, but stop for amending
  51. # f, fold = use commit, but combine it with the one above
  52. # d, drop = remove commit from history
  53. # m, mess = edit message without changing commit content
  54. #
  55. At which point you close the editor and ``histedit`` starts working. When you
  56. specify a ``fold`` operation, ``histedit`` will open an editor when it folds
  57. those revisions together, offering you a chance to clean up the commit message::
  58. Add beta
  59. ***
  60. Add delta
  61. Edit the commit message to your liking, then close the editor. For
  62. this example, let's assume that the commit message was changed to
  63. ``Add beta and delta.`` After histedit has run and had a chance to
  64. remove any old or temporary revisions it needed, the history looks
  65. like this::
  66. @ 2[tip] 989b4d060121 2009-04-27 18:04 -0500 durin42
  67. | Add beta and delta.
  68. |
  69. o 1 081603921c3f 2009-04-27 18:04 -0500 durin42
  70. | Add gamma
  71. |
  72. o 0 d8d2fcd0e319 2009-04-27 18:04 -0500 durin42
  73. Add alpha
  74. Note that ``histedit`` does *not* remove any revisions (even its own temporary
  75. ones) until after it has completed all the editing operations, so it will
  76. probably perform several strip operations when it's done. For the above example,
  77. it had to run strip twice. Strip can be slow depending on a variety of factors,
  78. so you might need to be a little patient. You can choose to keep the original
  79. revisions by passing the ``--keep`` flag.
  80. The ``edit`` operation will drop you back to a command prompt,
  81. allowing you to edit files freely, or even use ``hg record`` to commit
  82. some changes as a separate commit. When you're done, any remaining
  83. uncommitted changes will be committed as well. When done, run ``hg
  84. histedit --continue`` to finish this step. You'll be prompted for a
  85. new commit message, but the default commit message will be the
  86. original message for the ``edit`` ed revision.
  87. The ``message`` operation will give you a chance to revise a commit
  88. message without changing the contents. It's a shortcut for doing
  89. ``edit`` immediately followed by `hg histedit --continue``.
  90. If ``histedit`` encounters a conflict when moving a revision (while
  91. handling ``pick`` or ``fold``), it'll stop in a similar manner to
  92. ``edit`` with the difference that it won't prompt you for a commit
  93. message when done. If you decide at this point that you don't like how
  94. much work it will be to rearrange history, or that you made a mistake,
  95. you can use ``hg histedit --abort`` to abandon the new changes you
  96. have made and return to the state before you attempted to edit your
  97. history.
  98. If we clone the histedit-ed example repository above and add four more
  99. changes, such that we have the following history::
  100. @ 6[tip] 038383181893 2009-04-27 18:04 -0500 stefan
  101. | Add theta
  102. |
  103. o 5 140988835471 2009-04-27 18:04 -0500 stefan
  104. | Add eta
  105. |
  106. o 4 122930637314 2009-04-27 18:04 -0500 stefan
  107. | Add zeta
  108. |
  109. o 3 836302820282 2009-04-27 18:04 -0500 stefan
  110. | Add epsilon
  111. |
  112. o 2 989b4d060121 2009-04-27 18:04 -0500 durin42
  113. | Add beta and delta.
  114. |
  115. o 1 081603921c3f 2009-04-27 18:04 -0500 durin42
  116. | Add gamma
  117. |
  118. o 0 d8d2fcd0e319 2009-04-27 18:04 -0500 durin42
  119. Add alpha
  120. If you run ``hg histedit --outgoing`` on the clone then it is the same
  121. as running ``hg histedit 836302820282``. If you need plan to push to a
  122. repository that Mercurial does not detect to be related to the source
  123. repo, you can add a ``--force`` option.
  124. """
  125. try:
  126. import cPickle as pickle
  127. pickle.dump # import now
  128. except ImportError:
  129. import pickle
  130. import os
  131. import sys
  132. from mercurial import cmdutil
  133. from mercurial import discovery
  134. from mercurial import error
  135. from mercurial import copies
  136. from mercurial import context
  137. from mercurial import hg
  138. from mercurial import node
  139. from mercurial import repair
  140. from mercurial import util
  141. from mercurial import obsolete
  142. from mercurial import merge as mergemod
  143. from mercurial.lock import release
  144. from mercurial.i18n import _
  145. cmdtable = {}
  146. command = cmdutil.command(cmdtable)
  147. testedwith = 'internal'
  148. # i18n: command names and abbreviations must remain untranslated
  149. editcomment = _("""# Edit history between %s and %s
  150. #
  151. # Commits are listed from least to most recent
  152. #
  153. # Commands:
  154. # p, pick = use commit
  155. # e, edit = use commit, but stop for amending
  156. # f, fold = use commit, but combine it with the one above
  157. # d, drop = remove commit from history
  158. # m, mess = edit message without changing commit content
  159. #
  160. """)
  161. def commitfuncfor(repo, src):
  162. """Build a commit function for the replacement of <src>
  163. This function ensure we apply the same treatment to all changesets.
  164. - Add a 'histedit_source' entry in extra.
  165. Note that fold have its own separated logic because its handling is a bit
  166. different and not easily factored out of the fold method.
  167. """
  168. phasemin = src.phase()
  169. def commitfunc(**kwargs):
  170. phasebackup = repo.ui.backupconfig('phases', 'new-commit')
  171. try:
  172. repo.ui.setconfig('phases', 'new-commit', phasemin,
  173. 'histedit')
  174. extra = kwargs.get('extra', {}).copy()
  175. extra['histedit_source'] = src.hex()
  176. kwargs['extra'] = extra
  177. return repo.commit(**kwargs)
  178. finally:
  179. repo.ui.restoreconfig(phasebackup)
  180. return commitfunc
  181. def applychanges(ui, repo, ctx, opts):
  182. """Merge changeset from ctx (only) in the current working directory"""
  183. wcpar = repo.dirstate.parents()[0]
  184. if ctx.p1().node() == wcpar:
  185. # edition ar "in place" we do not need to make any merge,
  186. # just applies changes on parent for edition
  187. cmdutil.revert(ui, repo, ctx, (wcpar, node.nullid), all=True)
  188. stats = None
  189. else:
  190. try:
  191. # ui.forcemerge is an internal variable, do not document
  192. repo.ui.setconfig('ui', 'forcemerge', opts.get('tool', ''),
  193. 'histedit')
  194. stats = mergemod.update(repo, ctx.node(), True, True, False,
  195. ctx.p1().node())
  196. finally:
  197. repo.ui.setconfig('ui', 'forcemerge', '', 'histedit')
  198. repo.setparents(wcpar, node.nullid)
  199. repo.dirstate.write()
  200. # fix up dirstate for copies and renames
  201. cmdutil.duplicatecopies(repo, ctx.rev(), ctx.p1().rev())
  202. return stats
  203. def collapse(repo, first, last, commitopts):
  204. """collapse the set of revisions from first to last as new one.
  205. Expected commit options are:
  206. - message
  207. - date
  208. - username
  209. Commit message is edited in all cases.
  210. This function works in memory."""
  211. ctxs = list(repo.set('%d::%d', first, last))
  212. if not ctxs:
  213. return None
  214. base = first.parents()[0]
  215. # commit a new version of the old changeset, including the update
  216. # collect all files which might be affected
  217. files = set()
  218. for ctx in ctxs:
  219. files.update(ctx.files())
  220. # Recompute copies (avoid recording a -> b -> a)
  221. copied = copies.pathcopies(base, last)
  222. # prune files which were reverted by the updates
  223. def samefile(f):
  224. if f in last.manifest():
  225. a = last.filectx(f)
  226. if f in base.manifest():
  227. b = base.filectx(f)
  228. return (a.data() == b.data()
  229. and a.flags() == b.flags())
  230. else:
  231. return False
  232. else:
  233. return f not in base.manifest()
  234. files = [f for f in files if not samefile(f)]
  235. # commit version of these files as defined by head
  236. headmf = last.manifest()
  237. def filectxfn(repo, ctx, path):
  238. if path in headmf:
  239. fctx = last[path]
  240. flags = fctx.flags()
  241. mctx = context.memfilectx(repo,
  242. fctx.path(), fctx.data(),
  243. islink='l' in flags,
  244. isexec='x' in flags,
  245. copied=copied.get(path))
  246. return mctx
  247. raise IOError()
  248. if commitopts.get('message'):
  249. message = commitopts['message']
  250. else:
  251. message = first.description()
  252. user = commitopts.get('user')
  253. date = commitopts.get('date')
  254. extra = commitopts.get('extra')
  255. parents = (first.p1().node(), first.p2().node())
  256. new = context.memctx(repo,
  257. parents=parents,
  258. text=message,
  259. files=files,
  260. filectxfn=filectxfn,
  261. user=user,
  262. date=date,
  263. extra=extra,
  264. editor=cmdutil.getcommiteditor(edit=True))
  265. return repo.commitctx(new)
  266. def pick(ui, repo, ctx, ha, opts):
  267. oldctx = repo[ha]
  268. if oldctx.parents()[0] == ctx:
  269. ui.debug('node %s unchanged\n' % ha)
  270. return oldctx, []
  271. hg.update(repo, ctx.node())
  272. stats = applychanges(ui, repo, oldctx, opts)
  273. if stats and stats[3] > 0:
  274. raise error.InterventionRequired(_('Fix up the change and run '
  275. 'hg histedit --continue'))
  276. # drop the second merge parent
  277. commit = commitfuncfor(repo, oldctx)
  278. n = commit(text=oldctx.description(), user=oldctx.user(),
  279. date=oldctx.date(), extra=oldctx.extra())
  280. if n is None:
  281. ui.warn(_('%s: empty changeset\n')
  282. % node.hex(ha))
  283. return ctx, []
  284. new = repo[n]
  285. return new, [(oldctx.node(), (n,))]
  286. def edit(ui, repo, ctx, ha, opts):
  287. oldctx = repo[ha]
  288. hg.update(repo, ctx.node())
  289. applychanges(ui, repo, oldctx, opts)
  290. raise error.InterventionRequired(
  291. _('Make changes as needed, you may commit or record as needed now.\n'
  292. 'When you are finished, run hg histedit --continue to resume.'))
  293. def fold(ui, repo, ctx, ha, opts):
  294. oldctx = repo[ha]
  295. hg.update(repo, ctx.node())
  296. stats = applychanges(ui, repo, oldctx, opts)
  297. if stats and stats[3] > 0:
  298. raise error.InterventionRequired(
  299. _('Fix up the change and run hg histedit --continue'))
  300. n = repo.commit(text='fold-temp-revision %s' % ha, user=oldctx.user(),
  301. date=oldctx.date(), extra=oldctx.extra())
  302. if n is None:
  303. ui.warn(_('%s: empty changeset')
  304. % node.hex(ha))
  305. return ctx, []
  306. return finishfold(ui, repo, ctx, oldctx, n, opts, [])
  307. def finishfold(ui, repo, ctx, oldctx, newnode, opts, internalchanges):
  308. parent = ctx.parents()[0].node()
  309. hg.update(repo, parent)
  310. ### prepare new commit data
  311. commitopts = opts.copy()
  312. # username
  313. if ctx.user() == oldctx.user():
  314. username = ctx.user()
  315. else:
  316. username = ui.username()
  317. commitopts['user'] = username
  318. # commit message
  319. newmessage = '\n***\n'.join(
  320. [ctx.description()] +
  321. [repo[r].description() for r in internalchanges] +
  322. [oldctx.description()]) + '\n'
  323. commitopts['message'] = newmessage
  324. # date
  325. commitopts['date'] = max(ctx.date(), oldctx.date())
  326. extra = ctx.extra().copy()
  327. # histedit_source
  328. # note: ctx is likely a temporary commit but that the best we can do here
  329. # This is sufficient to solve issue3681 anyway
  330. extra['histedit_source'] = '%s,%s' % (ctx.hex(), oldctx.hex())
  331. commitopts['extra'] = extra
  332. phasebackup = repo.ui.backupconfig('phases', 'new-commit')
  333. try:
  334. phasemin = max(ctx.phase(), oldctx.phase())
  335. repo.ui.setconfig('phases', 'new-commit', phasemin, 'histedit')
  336. n = collapse(repo, ctx, repo[newnode], commitopts)
  337. finally:
  338. repo.ui.restoreconfig(phasebackup)
  339. if n is None:
  340. return ctx, []
  341. hg.update(repo, n)
  342. replacements = [(oldctx.node(), (newnode,)),
  343. (ctx.node(), (n,)),
  344. (newnode, (n,)),
  345. ]
  346. for ich in internalchanges:
  347. replacements.append((ich, (n,)))
  348. return repo[n], replacements
  349. def drop(ui, repo, ctx, ha, opts):
  350. return ctx, [(repo[ha].node(), ())]
  351. def message(ui, repo, ctx, ha, opts):
  352. oldctx = repo[ha]
  353. hg.update(repo, ctx.node())
  354. stats = applychanges(ui, repo, oldctx, opts)
  355. if stats and stats[3] > 0:
  356. raise error.InterventionRequired(
  357. _('Fix up the change and run hg histedit --continue'))
  358. message = oldctx.description()
  359. commit = commitfuncfor(repo, oldctx)
  360. new = commit(text=message, user=oldctx.user(), date=oldctx.date(),
  361. extra=oldctx.extra(),
  362. editor=cmdutil.getcommiteditor(edit=True))
  363. newctx = repo[new]
  364. if oldctx.node() != newctx.node():
  365. return newctx, [(oldctx.node(), (new,))]
  366. # We didn't make an edit, so just indicate no replaced nodes
  367. return newctx, []
  368. def findoutgoing(ui, repo, remote=None, force=False, opts={}):
  369. """utility function to find the first outgoing changeset
  370. Used by initialisation code"""
  371. dest = ui.expandpath(remote or 'default-push', remote or 'default')
  372. dest, revs = hg.parseurl(dest, None)[:2]
  373. ui.status(_('comparing with %s\n') % util.hidepassword(dest))
  374. revs, checkout = hg.addbranchrevs(repo, repo, revs, None)
  375. other = hg.peer(repo, opts, dest)
  376. if revs:
  377. revs = [repo.lookup(rev) for rev in revs]
  378. outgoing = discovery.findcommonoutgoing(repo, other, revs, force=force)
  379. if not outgoing.missing:
  380. raise util.Abort(_('no outgoing ancestors'))
  381. roots = list(repo.revs("roots(%ln)", outgoing.missing))
  382. if 1 < len(roots):
  383. msg = _('there are ambiguous outgoing revisions')
  384. hint = _('see "hg help histedit" for more detail')
  385. raise util.Abort(msg, hint=hint)
  386. return repo.lookup(roots[0])
  387. actiontable = {'p': pick,
  388. 'pick': pick,
  389. 'e': edit,
  390. 'edit': edit,
  391. 'f': fold,
  392. 'fold': fold,
  393. 'd': drop,
  394. 'drop': drop,
  395. 'm': message,
  396. 'mess': message,
  397. }
  398. @command('histedit',
  399. [('', 'commands', '',
  400. _('Read history edits from the specified file.')),
  401. ('c', 'continue', False, _('continue an edit already in progress')),
  402. ('k', 'keep', False,
  403. _("don't strip old nodes after edit is complete")),
  404. ('', 'abort', False, _('abort an edit in progress')),
  405. ('o', 'outgoing', False, _('changesets not found in destination')),
  406. ('f', 'force', False,
  407. _('force outgoing even for unrelated repositories')),
  408. ('r', 'rev', [], _('first revision to be edited'))],
  409. _("ANCESTOR | --outgoing [URL]"))
  410. def histedit(ui, repo, *freeargs, **opts):
  411. """interactively edit changeset history
  412. This command edits changesets between ANCESTOR and the parent of
  413. the working directory.
  414. With --outgoing, this edits changesets not found in the
  415. destination repository. If URL of the destination is omitted, the
  416. 'default-push' (or 'default') path will be used.
  417. For safety, this command is aborted, also if there are ambiguous
  418. outgoing revisions which may confuse users: for example, there are
  419. multiple branches containing outgoing revisions.
  420. Use "min(outgoing() and ::.)" or similar revset specification
  421. instead of --outgoing to specify edit target revision exactly in
  422. such ambiguous situation. See :hg:`help revsets` for detail about
  423. selecting revisions.
  424. Returns 0 on success, 1 if user intervention is required (not only
  425. for intentional "edit" command, but also for resolving unexpected
  426. conflicts).
  427. """
  428. lock = wlock = None
  429. try:
  430. wlock = repo.wlock()
  431. lock = repo.lock()
  432. _histedit(ui, repo, *freeargs, **opts)
  433. finally:
  434. release(lock, wlock)
  435. def _histedit(ui, repo, *freeargs, **opts):
  436. # TODO only abort if we try and histedit mq patches, not just
  437. # blanket if mq patches are applied somewhere
  438. mq = getattr(repo, 'mq', None)
  439. if mq and mq.applied:
  440. raise util.Abort(_('source has mq patches applied'))
  441. # basic argument incompatibility processing
  442. outg = opts.get('outgoing')
  443. cont = opts.get('continue')
  444. abort = opts.get('abort')
  445. force = opts.get('force')
  446. rules = opts.get('commands', '')
  447. revs = opts.get('rev', [])
  448. goal = 'new' # This invocation goal, in new, continue, abort
  449. if force and not outg:
  450. raise util.Abort(_('--force only allowed with --outgoing'))
  451. if cont:
  452. if util.any((outg, abort, revs, freeargs, rules)):
  453. raise util.Abort(_('no arguments allowed with --continue'))
  454. goal = 'continue'
  455. elif abort:
  456. if util.any((outg, revs, freeargs, rules)):
  457. raise util.Abort(_('no arguments allowed with --abort'))
  458. goal = 'abort'
  459. else:
  460. if os.path.exists(os.path.join(repo.path, 'histedit-state')):
  461. raise util.Abort(_('history edit already in progress, try '
  462. '--continue or --abort'))
  463. if outg:
  464. if revs:
  465. raise util.Abort(_('no revisions allowed with --outgoing'))
  466. if len(freeargs) > 1:
  467. raise util.Abort(
  468. _('only one repo argument allowed with --outgoing'))
  469. else:
  470. revs.extend(freeargs)
  471. if len(revs) != 1:
  472. raise util.Abort(
  473. _('histedit requires exactly one ancestor revision'))
  474. if goal == 'continue':
  475. (parentctxnode, rules, keep, topmost, replacements) = readstate(repo)
  476. parentctx = repo[parentctxnode]
  477. parentctx, repl = bootstrapcontinue(ui, repo, parentctx, rules, opts)
  478. replacements.extend(repl)
  479. elif goal == 'abort':
  480. (parentctxnode, rules, keep, topmost, replacements) = readstate(repo)
  481. mapping, tmpnodes, leafs, _ntm = processreplacement(repo, replacements)
  482. ui.debug('restore wc to old parent %s\n' % node.short(topmost))
  483. # check whether we should update away
  484. parentnodes = [c.node() for c in repo[None].parents()]
  485. for n in leafs | set([parentctxnode]):
  486. if n in parentnodes:
  487. hg.clean(repo, topmost)
  488. break
  489. else:
  490. pass
  491. cleanupnode(ui, repo, 'created', tmpnodes)
  492. cleanupnode(ui, repo, 'temp', leafs)
  493. os.unlink(os.path.join(repo.path, 'histedit-state'))
  494. return
  495. else:
  496. cmdutil.checkunfinished(repo)
  497. cmdutil.bailifchanged(repo)
  498. topmost, empty = repo.dirstate.parents()
  499. if outg:
  500. if freeargs:
  501. remote = freeargs[0]
  502. else:
  503. remote = None
  504. root = findoutgoing(ui, repo, remote, force, opts)
  505. else:
  506. rootrevs = list(repo.set('roots(%lr)', revs))
  507. if len(rootrevs) != 1:
  508. raise util.Abort(_('The specified revisions must have '
  509. 'exactly one common root'))
  510. root = rootrevs[0].node()
  511. keep = opts.get('keep', False)
  512. revs = between(repo, root, topmost, keep)
  513. if not revs:
  514. raise util.Abort(_('%s is not an ancestor of working directory') %
  515. node.short(root))
  516. ctxs = [repo[r] for r in revs]
  517. if not rules:
  518. rules = '\n'.join([makedesc(c) for c in ctxs])
  519. rules += '\n\n'
  520. rules += editcomment % (node.short(root), node.short(topmost))
  521. rules = ui.edit(rules, ui.username())
  522. # Save edit rules in .hg/histedit-last-edit.txt in case
  523. # the user needs to ask for help after something
  524. # surprising happens.
  525. f = open(repo.join('histedit-last-edit.txt'), 'w')
  526. f.write(rules)
  527. f.close()
  528. else:
  529. if rules == '-':
  530. f = sys.stdin
  531. else:
  532. f = open(rules)
  533. rules = f.read()
  534. f.close()
  535. rules = [l for l in (r.strip() for r in rules.splitlines())
  536. if l and not l[0] == '#']
  537. rules = verifyrules(rules, repo, ctxs)
  538. parentctx = repo[root].parents()[0]
  539. keep = opts.get('keep', False)
  540. replacements = []
  541. while rules:
  542. writestate(repo, parentctx.node(), rules, keep, topmost, replacements)
  543. action, ha = rules.pop(0)
  544. ui.debug('histedit: processing %s %s\n' % (action, ha))
  545. actfunc = actiontable[action]
  546. parentctx, replacement_ = actfunc(ui, repo, parentctx, ha, opts)
  547. replacements.extend(replacement_)
  548. hg.update(repo, parentctx.node())
  549. mapping, tmpnodes, created, ntm = processreplacement(repo, replacements)
  550. if mapping:
  551. for prec, succs in mapping.iteritems():
  552. if not succs:
  553. ui.debug('histedit: %s is dropped\n' % node.short(prec))
  554. else:
  555. ui.debug('histedit: %s is replaced by %s\n' % (
  556. node.short(prec), node.short(succs[0])))
  557. if len(succs) > 1:
  558. m = 'histedit: %s'
  559. for n in succs[1:]:
  560. ui.debug(m % node.short(n))
  561. if not keep:
  562. if mapping:
  563. movebookmarks(ui, repo, mapping, topmost, ntm)
  564. # TODO update mq state
  565. if obsolete._enabled:
  566. markers = []
  567. # sort by revision number because it sound "right"
  568. for prec in sorted(mapping, key=repo.changelog.rev):
  569. succs = mapping[prec]
  570. markers.append((repo[prec],
  571. tuple(repo[s] for s in succs)))
  572. if markers:
  573. obsolete.createmarkers(repo, markers)
  574. else:
  575. cleanupnode(ui, repo, 'replaced', mapping)
  576. cleanupnode(ui, repo, 'temp', tmpnodes)
  577. os.unlink(os.path.join(repo.path, 'histedit-state'))
  578. if os.path.exists(repo.sjoin('undo')):
  579. os.unlink(repo.sjoin('undo'))
  580. def gatherchildren(repo, ctx):
  581. # is there any new commit between the expected parent and "."
  582. #
  583. # note: does not take non linear new change in account (but previous
  584. # implementation didn't used them anyway (issue3655)
  585. newchildren = [c.node() for c in repo.set('(%d::.)', ctx)]
  586. if ctx.node() != node.nullid:
  587. if not newchildren:
  588. # `ctx` should match but no result. This means that
  589. # currentnode is not a descendant from ctx.
  590. msg = _('%s is not an ancestor of working directory')
  591. hint = _('use "histedit --abort" to clear broken state')
  592. raise util.Abort(msg % ctx, hint=hint)
  593. newchildren.pop(0) # remove ctx
  594. return newchildren
  595. def bootstrapcontinue(ui, repo, parentctx, rules, opts):
  596. action, currentnode = rules.pop(0)
  597. ctx = repo[currentnode]
  598. newchildren = gatherchildren(repo, parentctx)
  599. # Commit dirty working directory if necessary
  600. new = None
  601. m, a, r, d = repo.status()[:4]
  602. if m or a or r or d:
  603. # prepare the message for the commit to comes
  604. if action in ('f', 'fold'):
  605. message = 'fold-temp-revision %s' % currentnode
  606. else:
  607. message = ctx.description()
  608. editopt = action in ('e', 'edit', 'm', 'mess')
  609. editor = cmdutil.getcommiteditor(edit=editopt)
  610. commit = commitfuncfor(repo, ctx)
  611. new = commit(text=message, user=ctx.user(),
  612. date=ctx.date(), extra=ctx.extra(),
  613. editor=editor)
  614. if new is not None:
  615. newchildren.append(new)
  616. replacements = []
  617. # track replacements
  618. if ctx.node() not in newchildren:
  619. # note: new children may be empty when the changeset is dropped.
  620. # this happen e.g during conflicting pick where we revert content
  621. # to parent.
  622. replacements.append((ctx.node(), tuple(newchildren)))
  623. if action in ('f', 'fold'):
  624. if newchildren:
  625. # finalize fold operation if applicable
  626. if new is None:
  627. new = newchildren[-1]
  628. else:
  629. newchildren.pop() # remove new from internal changes
  630. parentctx, repl = finishfold(ui, repo, parentctx, ctx, new, opts,
  631. newchildren)
  632. replacements.extend(repl)
  633. else:
  634. # newchildren is empty if the fold did not result in any commit
  635. # this happen when all folded change are discarded during the
  636. # merge.
  637. replacements.append((ctx.node(), (parentctx.node(),)))
  638. elif newchildren:
  639. # otherwise update "parentctx" before proceeding to further operation
  640. parentctx = repo[newchildren[-1]]
  641. return parentctx, replacements
  642. def between(repo, old, new, keep):
  643. """select and validate the set of revision to edit
  644. When keep is false, the specified set can't have children."""
  645. ctxs = list(repo.set('%n::%n', old, new))
  646. if ctxs and not keep:
  647. if (not obsolete._enabled and
  648. repo.revs('(%ld::) - (%ld)', ctxs, ctxs)):
  649. raise util.Abort(_('cannot edit history that would orphan nodes'))
  650. if repo.revs('(%ld) and merge()', ctxs):
  651. raise util.Abort(_('cannot edit history that contains merges'))
  652. root = ctxs[0] # list is already sorted by repo.set
  653. if not root.phase():
  654. raise util.Abort(_('cannot edit immutable changeset: %s') % root)
  655. return [c.node() for c in ctxs]
  656. def writestate(repo, parentnode, rules, keep, topmost, replacements):
  657. fp = open(os.path.join(repo.path, 'histedit-state'), 'w')
  658. pickle.dump((parentnode, rules, keep, topmost, replacements), fp)
  659. fp.close()
  660. def readstate(repo):
  661. """Returns a tuple of (parentnode, rules, keep, topmost, replacements).
  662. """
  663. fp = open(os.path.join(repo.path, 'histedit-state'))
  664. return pickle.load(fp)
  665. def makedesc(c):
  666. """build a initial action line for a ctx `c`
  667. line are in the form:
  668. pick <hash> <rev> <summary>
  669. """
  670. summary = ''
  671. if c.description():
  672. summary = c.description().splitlines()[0]
  673. line = 'pick %s %d %s' % (c, c.rev(), summary)
  674. # trim to 80 columns so it's not stupidly wide in my editor
  675. return util.ellipsis(line, 80)
  676. def verifyrules(rules, repo, ctxs):
  677. """Verify that there exists exactly one edit rule per given changeset.
  678. Will abort if there are to many or too few rules, a malformed rule,
  679. or a rule on a changeset outside of the user-given range.
  680. """
  681. parsed = []
  682. expected = set(str(c) for c in ctxs)
  683. seen = set()
  684. for r in rules:
  685. if ' ' not in r:
  686. raise util.Abort(_('malformed line "%s"') % r)
  687. action, rest = r.split(' ', 1)
  688. ha = rest.strip().split(' ', 1)[0]
  689. try:
  690. ha = str(repo[ha]) # ensure its a short hash
  691. except error.RepoError:
  692. raise util.Abort(_('unknown changeset %s listed') % ha)
  693. if ha not in expected:
  694. raise util.Abort(
  695. _('may not use changesets other than the ones listed'))
  696. if ha in seen:
  697. raise util.Abort(_('duplicated command for changeset %s') % ha)
  698. seen.add(ha)
  699. if action not in actiontable:
  700. raise util.Abort(_('unknown action "%s"') % action)
  701. parsed.append([action, ha])
  702. missing = sorted(expected - seen) # sort to stabilize output
  703. if missing:
  704. raise util.Abort(_('missing rules for changeset %s') % missing[0],
  705. hint=_('do you want to use the drop action?'))
  706. return parsed
  707. def processreplacement(repo, replacements):
  708. """process the list of replacements to return
  709. 1) the final mapping between original and created nodes
  710. 2) the list of temporary node created by histedit
  711. 3) the list of new commit created by histedit"""
  712. allsuccs = set()
  713. replaced = set()
  714. fullmapping = {}
  715. # initialise basic set
  716. # fullmapping record all operation recorded in replacement
  717. for rep in replacements:
  718. allsuccs.update(rep[1])
  719. replaced.add(rep[0])
  720. fullmapping.setdefault(rep[0], set()).update(rep[1])
  721. new = allsuccs - replaced
  722. tmpnodes = allsuccs & replaced
  723. # Reduce content fullmapping into direct relation between original nodes
  724. # and final node created during history edition
  725. # Dropped changeset are replaced by an empty list
  726. toproceed = set(fullmapping)
  727. final = {}
  728. while toproceed:
  729. for x in list(toproceed):
  730. succs = fullmapping[x]
  731. for s in list(succs):
  732. if s in toproceed:
  733. # non final node with unknown closure
  734. # We can't process this now
  735. break
  736. elif s in final:
  737. # non final node, replace with closure
  738. succs.remove(s)
  739. succs.update(final[s])
  740. else:
  741. final[x] = succs
  742. toproceed.remove(x)
  743. # remove tmpnodes from final mapping
  744. for n in tmpnodes:
  745. del final[n]
  746. # we expect all changes involved in final to exist in the repo
  747. # turn `final` into list (topologically sorted)
  748. nm = repo.changelog.nodemap
  749. for prec, succs in final.items():
  750. final[prec] = sorted(succs, key=nm.get)
  751. # computed topmost element (necessary for bookmark)
  752. if new:
  753. newtopmost = sorted(new, key=repo.changelog.rev)[-1]
  754. elif not final:
  755. # Nothing rewritten at all. we won't need `newtopmost`
  756. # It is the same as `oldtopmost` and `processreplacement` know it
  757. newtopmost = None
  758. else:
  759. # every body died. The newtopmost is the parent of the root.
  760. newtopmost = repo[sorted(final, key=repo.changelog.rev)[0]].p1().node()
  761. return final, tmpnodes, new, newtopmost
  762. def movebookmarks(ui, repo, mapping, oldtopmost, newtopmost):
  763. """Move bookmark from old to newly created node"""
  764. if not mapping:
  765. # if nothing got rewritten there is not purpose for this function
  766. return
  767. moves = []
  768. for bk, old in sorted(repo._bookmarks.iteritems()):
  769. if old == oldtopmost:
  770. # special case ensure bookmark stay on tip.
  771. #
  772. # This is arguably a feature and we may only want that for the
  773. # active bookmark. But the behavior is kept compatible with the old
  774. # version for now.
  775. moves.append((bk, newtopmost))
  776. continue
  777. base = old
  778. new = mapping.get(base, None)
  779. if new is None:
  780. continue
  781. while not new:
  782. # base is killed, trying with parent
  783. base = repo[base].p1().node()
  784. new = mapping.get(base, (base,))
  785. # nothing to move
  786. moves.append((bk, new[-1]))
  787. if moves:
  788. marks = repo._bookmarks
  789. for mark, new in moves:
  790. old = marks[mark]
  791. ui.note(_('histedit: moving bookmarks %s from %s to %s\n')
  792. % (mark, node.short(old), node.short(new)))
  793. marks[mark] = new
  794. marks.write()
  795. def cleanupnode(ui, repo, name, nodes):
  796. """strip a group of nodes from the repository
  797. The set of node to strip may contains unknown nodes."""
  798. ui.debug('should strip %s nodes %s\n' %
  799. (name, ', '.join([node.short(n) for n in nodes])))
  800. lock = None
  801. try:
  802. lock = repo.lock()
  803. # Find all node that need to be stripped
  804. # (we hg %lr instead of %ln to silently ignore unknown item
  805. nm = repo.changelog.nodemap
  806. nodes = [n for n in nodes if n in nm]
  807. roots = [c.node() for c in repo.set("roots(%ln)", nodes)]
  808. for c in roots:
  809. # We should process node in reverse order to strip tip most first.
  810. # but this trigger a bug in changegroup hook.
  811. # This would reduce bundle overhead
  812. repair.strip(ui, repo, c)
  813. finally:
  814. release(lock)
  815. def summaryhook(ui, repo):
  816. if not os.path.exists(repo.join('histedit-state')):
  817. return
  818. (parentctxnode, rules, keep, topmost, replacements) = readstate(repo)
  819. if rules:
  820. # i18n: column positioning for "hg summary"
  821. ui.write(_('hist: %s (histedit --continue)\n') %
  822. (ui.label(_('%d remaining'), 'histedit.remaining') %
  823. len(rules)))
  824. def extsetup(ui):
  825. cmdutil.summaryhooks.add('histedit', summaryhook)
  826. cmdutil.unfinishedstates.append(
  827. ['histedit-state', False, True, _('histedit in progress'),
  828. _("use 'hg histedit --continue' or 'hg histedit --abort'")])