histedit.py
1248 lines
| 44.6 KiB
| text/x-python
|
PythonLexer
/ hgext / histedit.py
|
r17064 | # histedit.py - interactive history editing for mercurial | ||
# | ||||
# Copyright 2009 Augie Fackler <raf@durin42.com> | ||||
# | ||||
# This software may be used and distributed according to the terms of the | ||||
# GNU General Public License version 2 or any later version. | ||||
|
r17131 | """interactive history editing | ||
With this extension installed, Mercurial gains one new command: histedit. Usage | ||||
is as follows, assuming the following history:: | ||||
@ 3[tip] 7c2fd3b9020c 2009-04-27 18:04 -0500 durin42 | ||||
| Add delta | ||||
| | ||||
o 2 030b686bedc4 2009-04-27 18:04 -0500 durin42 | ||||
| Add gamma | ||||
| | ||||
o 1 c561b4e977df 2009-04-27 18:04 -0500 durin42 | ||||
| Add beta | ||||
| | ||||
o 0 d8d2fcd0e319 2009-04-27 18:04 -0500 durin42 | ||||
Add alpha | ||||
If you were to run ``hg histedit c561b4e977df``, you would see the following | ||||
file open in your editor:: | ||||
pick c561b4e977df Add beta | ||||
pick 030b686bedc4 Add gamma | ||||
pick 7c2fd3b9020c Add delta | ||||
|
r18322 | # Edit history between c561b4e977df and 7c2fd3b9020c | ||
|
r17131 | # | ||
|
r20503 | # Commits are listed from least to most recent | ||
# | ||||
|
r17131 | # Commands: | ||
# p, pick = use commit | ||||
# e, edit = use commit, but stop for amending | ||||
|
r20511 | # f, fold = use commit, but combine it with the one above | ||
|
r22152 | # r, roll = like fold, but discard this commit's description | ||
|
r17131 | # d, drop = remove commit from history | ||
|
r26100 | # m, mess = edit commit message without changing commit content | ||
|
r17131 | # | ||
In this file, lines beginning with ``#`` are ignored. You must specify a rule | ||||
for each revision in your history. For example, if you had meant to add gamma | ||||
before beta, and then wanted to add delta in the same revision as beta, you | ||||
would reorganize the file to look like this:: | ||||
pick 030b686bedc4 Add gamma | ||||
pick c561b4e977df Add beta | ||||
fold 7c2fd3b9020c Add delta | ||||
|
r18322 | # Edit history between c561b4e977df and 7c2fd3b9020c | ||
|
r17131 | # | ||
|
r20503 | # Commits are listed from least to most recent | ||
# | ||||
|
r17131 | # Commands: | ||
# p, pick = use commit | ||||
# e, edit = use commit, but stop for amending | ||||
|
r20511 | # f, fold = use commit, but combine it with the one above | ||
|
r22152 | # r, roll = like fold, but discard this commit's description | ||
|
r17131 | # d, drop = remove commit from history | ||
|
r26100 | # m, mess = edit commit message without changing commit content | ||
|
r17131 | # | ||
At which point you close the editor and ``histedit`` starts working. When you | ||||
specify a ``fold`` operation, ``histedit`` will open an editor when it folds | ||||
those revisions together, offering you a chance to clean up the commit message:: | ||||
Add beta | ||||
*** | ||||
Add delta | ||||
|
r17064 | |||
|
r17131 | Edit the commit message to your liking, then close the editor. For | ||
this example, let's assume that the commit message was changed to | ||||
``Add beta and delta.`` After histedit has run and had a chance to | ||||
remove any old or temporary revisions it needed, the history looks | ||||
like this:: | ||||
@ 2[tip] 989b4d060121 2009-04-27 18:04 -0500 durin42 | ||||
| Add beta and delta. | ||||
| | ||||
o 1 081603921c3f 2009-04-27 18:04 -0500 durin42 | ||||
| Add gamma | ||||
| | ||||
o 0 d8d2fcd0e319 2009-04-27 18:04 -0500 durin42 | ||||
Add alpha | ||||
Note that ``histedit`` does *not* remove any revisions (even its own temporary | ||||
ones) until after it has completed all the editing operations, so it will | ||||
probably perform several strip operations when it's done. For the above example, | ||||
it had to run strip twice. Strip can be slow depending on a variety of factors, | ||||
so you might need to be a little patient. You can choose to keep the original | ||||
revisions by passing the ``--keep`` flag. | ||||
The ``edit`` operation will drop you back to a command prompt, | ||||
allowing you to edit files freely, or even use ``hg record`` to commit | ||||
some changes as a separate commit. When you're done, any remaining | ||||
uncommitted changes will be committed as well. When done, run ``hg | ||||
histedit --continue`` to finish this step. You'll be prompted for a | ||||
new commit message, but the default commit message will be the | ||||
original message for the ``edit`` ed revision. | ||||
The ``message`` operation will give you a chance to revise a commit | ||||
message without changing the contents. It's a shortcut for doing | ||||
``edit`` immediately followed by `hg histedit --continue``. | ||||
If ``histedit`` encounters a conflict when moving a revision (while | ||||
handling ``pick`` or ``fold``), it'll stop in a similar manner to | ||||
``edit`` with the difference that it won't prompt you for a commit | ||||
message when done. If you decide at this point that you don't like how | ||||
much work it will be to rearrange history, or that you made a mistake, | ||||
you can use ``hg histedit --abort`` to abandon the new changes you | ||||
have made and return to the state before you attempted to edit your | ||||
history. | ||||
|
r18323 | If we clone the histedit-ed example repository above and add four more | ||
changes, such that we have the following history:: | ||||
|
r17131 | |||
@ 6[tip] 038383181893 2009-04-27 18:04 -0500 stefan | ||||
| Add theta | ||||
| | ||||
o 5 140988835471 2009-04-27 18:04 -0500 stefan | ||||
| Add eta | ||||
| | ||||
o 4 122930637314 2009-04-27 18:04 -0500 stefan | ||||
| Add zeta | ||||
| | ||||
o 3 836302820282 2009-04-27 18:04 -0500 stefan | ||||
| Add epsilon | ||||
| | ||||
o 2 989b4d060121 2009-04-27 18:04 -0500 durin42 | ||||
| Add beta and delta. | ||||
| | ||||
o 1 081603921c3f 2009-04-27 18:04 -0500 durin42 | ||||
| Add gamma | ||||
| | ||||
o 0 d8d2fcd0e319 2009-04-27 18:04 -0500 durin42 | ||||
Add alpha | ||||
If you run ``hg histedit --outgoing`` on the clone then it is the same | ||||
as running ``hg histedit 836302820282``. If you need plan to push to a | ||||
repository that Mercurial does not detect to be related to the source | ||||
repo, you can add a ``--force`` option. | ||||
|
r24199 | |||
Histedit rule lines are truncated to 80 characters by default. You | ||||
|
r26171 | can customize this behavior by setting a different length in your | ||
|
r24869 | configuration file:: | ||
|
r24199 | |||
|
r24869 | [histedit] | ||
linelen = 120 # truncate rule lines at 120 characters | ||||
|
r17064 | """ | ||
|
r17131 | |||
|
r17064 | try: | ||
import cPickle as pickle | ||||
|
r19284 | pickle.dump # import now | ||
|
r17064 | except ImportError: | ||
import pickle | ||||
|
r22368 | import errno | ||
|
r17064 | import os | ||
|
r19018 | import sys | ||
|
r17064 | |||
|
r26798 | from mercurial import bundle2 | ||
|
r17064 | from mercurial import cmdutil | ||
from mercurial import discovery | ||||
from mercurial import error | ||||
|
r17644 | from mercurial import copies | ||
from mercurial import context | ||||
|
r24757 | from mercurial import exchange | ||
|
r24111 | from mercurial import extensions | ||
|
r17064 | from mercurial import hg | ||
from mercurial import node | ||||
from mercurial import repair | ||||
|
r21950 | from mercurial import scmutil | ||
|
r17064 | from mercurial import util | ||
|
r17759 | from mercurial import obsolete | ||
|
r17647 | from mercurial import merge as mergemod | ||
|
r20071 | from mercurial.lock import release | ||
|
r17064 | from mercurial.i18n import _ | ||
|
r17147 | cmdtable = {} | ||
command = cmdutil.command(cmdtable) | ||||
|
r25186 | # Note for extension authors: ONLY specify testedwith = 'internal' for | ||
# extensions which SHIP WITH MERCURIAL. Non-mainline extensions should | ||||
# be specifying the version(s) of Mercurial they are tested with, or | ||||
# leave the attribute unspecified. | ||||
|
r17069 | testedwith = 'internal' | ||
|
r17064 | |||
|
r17337 | # i18n: command names and abbreviations must remain untranslated | ||
|
r17315 | editcomment = _("""# Edit history between %s and %s | ||
|
r17064 | # | ||
|
r20503 | # Commits are listed from least to most recent | ||
# | ||||
|
r17064 | # Commands: | ||
# p, pick = use commit | ||||
# e, edit = use commit, but stop for amending | ||||
|
r20511 | # f, fold = use commit, but combine it with the one above | ||
|
r22152 | # r, roll = like fold, but discard this commit's description | ||
|
r17064 | # d, drop = remove commit from history | ||
|
r26100 | # m, mess = edit commit message without changing commit content | ||
|
r17064 | # | ||
|
r17315 | """) | ||
|
r17064 | |||
|
r22976 | class histeditstate(object): | ||
|
r24112 | def __init__(self, repo, parentctxnode=None, rules=None, keep=None, | ||
|
r22984 | topmost=None, replacements=None, lock=None, wlock=None): | ||
|
r22976 | self.repo = repo | ||
self.rules = rules | ||||
self.keep = keep | ||||
self.topmost = topmost | ||||
|
r24112 | self.parentctxnode = parentctxnode | ||
|
r22984 | self.lock = lock | ||
self.wlock = wlock | ||||
|
r24757 | self.backupfile = None | ||
|
r22976 | if replacements is None: | ||
self.replacements = [] | ||||
else: | ||||
self.replacements = replacements | ||||
|
r22983 | def read(self): | ||
|
r22986 | """Load histedit state from disk and set fields appropriately.""" | ||
|
r22983 | try: | ||
fp = self.repo.vfs('histedit-state', 'r') | ||||
|
r25660 | except IOError as err: | ||
|
r22983 | if err.errno != errno.ENOENT: | ||
raise | ||||
|
r26587 | raise error.Abort(_('no histedit in progress')) | ||
|
r22983 | |||
|
r24756 | try: | ||
data = pickle.load(fp) | ||||
parentctxnode, rules, keep, topmost, replacements = data | ||||
|
r24757 | backupfile = None | ||
|
r24756 | except pickle.UnpicklingError: | ||
data = self._load() | ||||
|
r24757 | parentctxnode, rules, keep, topmost, replacements, backupfile = data | ||
|
r22983 | |||
|
r24112 | self.parentctxnode = parentctxnode | ||
|
r22983 | self.rules = rules | ||
self.keep = keep | ||||
self.topmost = topmost | ||||
self.replacements = replacements | ||||
|
r24757 | self.backupfile = backupfile | ||
|
r22983 | |||
|
r22976 | def write(self): | ||
fp = self.repo.vfs('histedit-state', 'w') | ||||
|
r24756 | fp.write('v1\n') | ||
fp.write('%s\n' % node.hex(self.parentctxnode)) | ||||
fp.write('%s\n' % node.hex(self.topmost)) | ||||
fp.write('%s\n' % self.keep) | ||||
fp.write('%d\n' % len(self.rules)) | ||||
for rule in self.rules: | ||||
|
r24810 | fp.write('%s\n' % rule[0]) # action | ||
fp.write('%s\n' % rule[1]) # remainder | ||||
|
r24756 | fp.write('%d\n' % len(self.replacements)) | ||
for replacement in self.replacements: | ||||
fp.write('%s%s\n' % (node.hex(replacement[0]), ''.join(node.hex(r) | ||||
for r in replacement[1]))) | ||||
|
r24958 | backupfile = self.backupfile | ||
if not backupfile: | ||||
backupfile = '' | ||||
fp.write('%s\n' % backupfile) | ||||
|
r22976 | fp.close() | ||
|
r24756 | def _load(self): | ||
fp = self.repo.vfs('histedit-state', 'r') | ||||
lines = [l[:-1] for l in fp.readlines()] | ||||
index = 0 | ||||
lines[index] # version number | ||||
index += 1 | ||||
parentctxnode = node.bin(lines[index]) | ||||
index += 1 | ||||
topmost = node.bin(lines[index]) | ||||
index += 1 | ||||
keep = lines[index] == 'True' | ||||
index += 1 | ||||
# Rules | ||||
rules = [] | ||||
rulelen = int(lines[index]) | ||||
index += 1 | ||||
for i in xrange(rulelen): | ||||
|
r24810 | ruleaction = lines[index] | ||
index += 1 | ||||
|
r24756 | rule = lines[index] | ||
index += 1 | ||||
|
r24810 | rules.append((ruleaction, rule)) | ||
|
r24756 | |||
# Replacements | ||||
replacements = [] | ||||
replacementlen = int(lines[index]) | ||||
index += 1 | ||||
for i in xrange(replacementlen): | ||||
replacement = lines[index] | ||||
original = node.bin(replacement[:40]) | ||||
succ = [node.bin(replacement[i:i + 40]) for i in | ||||
range(40, len(replacement), 40)] | ||||
replacements.append((original, succ)) | ||||
index += 1 | ||||
|
r24757 | backupfile = lines[index] | ||
index += 1 | ||||
|
r24756 | fp.close() | ||
|
r24757 | return parentctxnode, rules, keep, topmost, replacements, backupfile | ||
|
r24756 | |||
|
r22978 | def clear(self): | ||
|
r26583 | if self.inprogress(): | ||
self.repo.vfs.unlink('histedit-state') | ||||
|
r22978 | |||
|
r26582 | def inprogress(self): | ||
return self.repo.vfs.exists('histedit-state') | ||||
|
r24765 | class histeditaction(object): | ||
def __init__(self, state, node): | ||||
self.state = state | ||||
self.repo = state.repo | ||||
self.node = node | ||||
@classmethod | ||||
def fromrule(cls, state, rule): | ||||
"""Parses the given rule, returning an instance of the histeditaction. | ||||
""" | ||||
repo = state.repo | ||||
rulehash = rule.strip().split(' ', 1)[0] | ||||
try: | ||||
node = repo[rulehash].node() | ||||
except error.RepoError: | ||||
|
r26587 | raise error.Abort(_('unknown changeset %s listed') % rulehash[:12]) | ||
|
r24765 | return cls(state, node) | ||
def run(self): | ||||
"""Runs the action. The default behavior is simply apply the action's | ||||
rulectx onto the current parentctx.""" | ||||
self.applychange() | ||||
self.continuedirty() | ||||
return self.continueclean() | ||||
def applychange(self): | ||||
"""Applies the changes from this action's rulectx onto the current | ||||
parentctx, but does not commit them.""" | ||||
repo = self.repo | ||||
rulectx = repo[self.node] | ||||
hg.update(repo, self.state.parentctxnode) | ||||
stats = applychanges(repo.ui, repo, rulectx, {}) | ||||
if stats and stats[3] > 0: | ||||
raise error.InterventionRequired(_('Fix up the change and run ' | ||||
'hg histedit --continue')) | ||||
def continuedirty(self): | ||||
"""Continues the action when changes have been applied to the working | ||||
copy. The default behavior is to commit the dirty changes.""" | ||||
repo = self.repo | ||||
rulectx = repo[self.node] | ||||
editor = self.commiteditor() | ||||
commit = commitfuncfor(repo, rulectx) | ||||
commit(text=rulectx.description(), user=rulectx.user(), | ||||
date=rulectx.date(), extra=rulectx.extra(), editor=editor) | ||||
def commiteditor(self): | ||||
"""The editor to be used to edit the commit message.""" | ||||
return False | ||||
def continueclean(self): | ||||
"""Continues the action when the working copy is clean. The default | ||||
behavior is to accept the current commit as the new version of the | ||||
rulectx.""" | ||||
ctx = self.repo['.'] | ||||
if ctx.node() == self.state.parentctxnode: | ||||
self.repo.ui.warn(_('%s: empty changeset\n') % | ||||
node.short(self.node)) | ||||
return ctx, [(self.node, tuple())] | ||||
if ctx.node() == self.node: | ||||
# Nothing changed | ||||
return ctx, [] | ||||
return ctx, [(self.node, (ctx.node(),))] | ||||
|
r18436 | def commitfuncfor(repo, src): | ||
"""Build a commit function for the replacement of <src> | ||||
|
r18644 | This function ensure we apply the same treatment to all changesets. | ||
|
r18436 | |||
|
r18437 | - Add a 'histedit_source' entry in extra. | ||
|
r18436 | |||
|
r25450 | Note that fold has its own separated logic because its handling is a bit | ||
|
r18436 | different and not easily factored out of the fold method. | ||
""" | ||||
|
r18440 | phasemin = src.phase() | ||
|
r18436 | def commitfunc(**kwargs): | ||
|
r18440 | phasebackup = repo.ui.backupconfig('phases', 'new-commit') | ||
try: | ||||
|
r20790 | repo.ui.setconfig('phases', 'new-commit', phasemin, | ||
'histedit') | ||||
|
r18440 | extra = kwargs.get('extra', {}).copy() | ||
extra['histedit_source'] = src.hex() | ||||
kwargs['extra'] = extra | ||||
return repo.commit(**kwargs) | ||||
finally: | ||||
repo.ui.restoreconfig(phasebackup) | ||||
|
r18436 | return commitfunc | ||
|
r17647 | def applychanges(ui, repo, ctx, opts): | ||
"""Merge changeset from ctx (only) in the current working directory""" | ||||
wcpar = repo.dirstate.parents()[0] | ||||
if ctx.p1().node() == wcpar: | ||||
|
r26171 | # edits are "in place" we do not need to make any merge, | ||
|
r17647 | # just applies changes on parent for edition | ||
cmdutil.revert(ui, repo, ctx, (wcpar, node.nullid), all=True) | ||||
stats = None | ||||
else: | ||||
try: | ||||
# ui.forcemerge is an internal variable, do not document | ||||
|
r20790 | repo.ui.setconfig('ui', 'forcemerge', opts.get('tool', ''), | ||
'histedit') | ||||
|
r22904 | stats = mergemod.graft(repo, ctx, ctx.p1(), ['local', 'histedit']) | ||
|
r17647 | finally: | ||
|
r20790 | repo.ui.setconfig('ui', 'forcemerge', '', 'histedit') | ||
|
r17647 | return stats | ||
|
r17407 | |||
|
r24828 | def collapse(repo, first, last, commitopts, skipprompt=False): | ||
|
r17644 | """collapse the set of revisions from first to last as new one. | ||
Expected commit options are: | ||||
- message | ||||
- date | ||||
- username | ||||
|
r17738 | Commit message is edited in all cases. | ||
|
r17644 | |||
This function works in memory.""" | ||||
ctxs = list(repo.set('%d::%d', first, last)) | ||||
if not ctxs: | ||||
return None | ||||
|
r25452 | for c in ctxs: | ||
if not c.mutable(): | ||||
|
r26587 | raise error.Abort( | ||
|
r25452 | _("cannot fold into public change %s") % node.short(c.node())) | ||
|
r17644 | base = first.parents()[0] | ||
# commit a new version of the old changeset, including the update | ||||
# collect all files which might be affected | ||||
files = set() | ||||
for ctx in ctxs: | ||||
files.update(ctx.files()) | ||||
# Recompute copies (avoid recording a -> b -> a) | ||||
|
r19392 | copied = copies.pathcopies(base, last) | ||
|
r17644 | |||
# prune files which were reverted by the updates | ||||
def samefile(f): | ||||
if f in last.manifest(): | ||||
a = last.filectx(f) | ||||
if f in base.manifest(): | ||||
b = base.filectx(f) | ||||
return (a.data() == b.data() | ||||
and a.flags() == b.flags()) | ||||
else: | ||||
return False | ||||
else: | ||||
return f not in base.manifest() | ||||
files = [f for f in files if not samefile(f)] | ||||
# commit version of these files as defined by head | ||||
headmf = last.manifest() | ||||
def filectxfn(repo, ctx, path): | ||||
if path in headmf: | ||||
fctx = last[path] | ||||
flags = fctx.flags() | ||||
|
r21689 | mctx = context.memfilectx(repo, | ||
fctx.path(), fctx.data(), | ||||
|
r17644 | islink='l' in flags, | ||
isexec='x' in flags, | ||||
copied=copied.get(path)) | ||||
return mctx | ||||
|
r22296 | return None | ||
|
r17644 | |||
if commitopts.get('message'): | ||||
message = commitopts['message'] | ||||
else: | ||||
message = first.description() | ||||
user = commitopts.get('user') | ||||
date = commitopts.get('date') | ||||
|
r18437 | extra = commitopts.get('extra') | ||
|
r17644 | |||
parents = (first.p1().node(), first.p2().node()) | ||||
|
r22152 | editor = None | ||
|
r24828 | if not skipprompt: | ||
|
r22152 | editor = cmdutil.getcommiteditor(edit=True, editform='histedit.fold') | ||
|
r17644 | new = context.memctx(repo, | ||
parents=parents, | ||||
text=message, | ||||
files=files, | ||||
filectxfn=filectxfn, | ||||
user=user, | ||||
date=date, | ||||
|
r21239 | extra=extra, | ||
|
r22002 | editor=editor) | ||
|
r17644 | return repo.commitctx(new) | ||
|
r24767 | class pick(histeditaction): | ||
def run(self): | ||||
rulectx = self.repo[self.node] | ||||
if rulectx.parents()[0].node() == self.state.parentctxnode: | ||||
self.repo.ui.debug('node %s unchanged\n' % node.short(self.node)) | ||||
return rulectx, [] | ||||
|
r17064 | |||
|
r24767 | return super(pick, self).run() | ||
|
r17064 | |||
|
r24770 | class edit(histeditaction): | ||
def run(self): | ||||
repo = self.repo | ||||
rulectx = repo[self.node] | ||||
hg.update(repo, self.state.parentctxnode) | ||||
applychanges(repo.ui, repo, rulectx, {}) | ||||
raise error.InterventionRequired( | ||||
_('Make changes as needed, you may commit or record as needed ' | ||||
'now.\nWhen you are finished, run hg histedit --continue to ' | ||||
'resume.')) | ||||
def commiteditor(self): | ||||
return cmdutil.getcommiteditor(edit=True, editform='histedit.edit') | ||||
|
r17064 | |||
|
r24771 | class fold(histeditaction): | ||
def continuedirty(self): | ||||
repo = self.repo | ||||
rulectx = repo[self.node] | ||||
commit = commitfuncfor(repo, rulectx) | ||||
commit(text='fold-temp-revision %s' % node.short(self.node), | ||||
user=rulectx.user(), date=rulectx.date(), | ||||
extra=rulectx.extra()) | ||||
def continueclean(self): | ||||
repo = self.repo | ||||
ctx = repo['.'] | ||||
rulectx = repo[self.node] | ||||
parentctxnode = self.state.parentctxnode | ||||
if ctx.node() == parentctxnode: | ||||
repo.ui.warn(_('%s: empty changeset\n') % | ||||
node.short(self.node)) | ||||
return ctx, [(self.node, (parentctxnode,))] | ||||
|
r22152 | |||
|
r24771 | parentctx = repo[parentctxnode] | ||
newcommits = set(c.node() for c in repo.set('(%d::. - %d)', parentctx, | ||||
parentctx)) | ||||
if not newcommits: | ||||
repo.ui.warn(_('%s: cannot fold - working copy is not a ' | ||||
'descendant of previous commit %s\n') % | ||||
(node.short(self.node), node.short(parentctxnode))) | ||||
return ctx, [(self.node, (ctx.node(),))] | ||||
middlecommits = newcommits.copy() | ||||
middlecommits.discard(ctx.node()) | ||||
|
r24773 | return self.finishfold(repo.ui, repo, parentctx, rulectx, ctx.node(), | ||
middlecommits) | ||||
|
r24771 | |||
|
r24773 | def skipprompt(self): | ||
|
r26246 | """Returns true if the rule should skip the message editor. | ||
For example, 'fold' wants to show an editor, but 'rollup' | ||||
doesn't want to. | ||||
""" | ||||
|
r24773 | return False | ||
|
r24772 | |||
|
r26246 | def mergedescs(self): | ||
"""Returns true if the rule should merge messages of multiple changes. | ||||
This exists mainly so that 'rollup' rules can be a subclass of | ||||
'fold'. | ||||
""" | ||||
return True | ||||
|
r24773 | def finishfold(self, ui, repo, ctx, oldctx, newnode, internalchanges): | ||
|
r24772 | parent = ctx.parents()[0].node() | ||
hg.update(repo, parent) | ||||
### prepare new commit data | ||||
|
r24773 | commitopts = {} | ||
|
r24772 | commitopts['user'] = ctx.user() | ||
# commit message | ||||
|
r26246 | if not self.mergedescs(): | ||
|
r24772 | newmessage = ctx.description() | ||
else: | ||||
newmessage = '\n***\n'.join( | ||||
[ctx.description()] + | ||||
[repo[r].description() for r in internalchanges] + | ||||
[oldctx.description()]) + '\n' | ||||
commitopts['message'] = newmessage | ||||
# date | ||||
commitopts['date'] = max(ctx.date(), oldctx.date()) | ||||
extra = ctx.extra().copy() | ||||
# histedit_source | ||||
# note: ctx is likely a temporary commit but that the best we can do | ||||
# here. This is sufficient to solve issue3681 anyway. | ||||
extra['histedit_source'] = '%s,%s' % (ctx.hex(), oldctx.hex()) | ||||
commitopts['extra'] = extra | ||||
phasebackup = repo.ui.backupconfig('phases', 'new-commit') | ||||
try: | ||||
phasemin = max(ctx.phase(), oldctx.phase()) | ||||
repo.ui.setconfig('phases', 'new-commit', phasemin, 'histedit') | ||||
|
r24828 | n = collapse(repo, ctx, repo[newnode], commitopts, | ||
skipprompt=self.skipprompt()) | ||||
|
r24772 | finally: | ||
repo.ui.restoreconfig(phasebackup) | ||||
if n is None: | ||||
return ctx, [] | ||||
hg.update(repo, n) | ||||
replacements = [(oldctx.node(), (newnode,)), | ||||
(ctx.node(), (n,)), | ||||
(newnode, (n,)), | ||||
] | ||||
for ich in internalchanges: | ||||
replacements.append((ich, (n,))) | ||||
return repo[n], replacements | ||||
|
r24771 | |||
|
r26246 | class _multifold(fold): | ||
"""fold subclass used for when multiple folds happen in a row | ||||
We only want to fire the editor for the folded message once when | ||||
(say) four changes are folded down into a single change. This is | ||||
similar to rollup, but we should preserve both messages so that | ||||
when the last fold operation runs we can show the user all the | ||||
commit messages in their editor. | ||||
""" | ||||
def skipprompt(self): | ||||
return True | ||||
|
r24771 | class rollup(fold): | ||
|
r26246 | def mergedescs(self): | ||
return False | ||||
|
r24773 | def skipprompt(self): | ||
return True | ||||
|
r17064 | |||
|
r24768 | class drop(histeditaction): | ||
def run(self): | ||||
parentctx = self.repo[self.state.parentctxnode] | ||||
return parentctx, [(self.node, tuple())] | ||||
|
r17064 | |||
|
r24769 | class message(histeditaction): | ||
def commiteditor(self): | ||||
return cmdutil.getcommiteditor(edit=True, editform='histedit.mess') | ||||
|
r17064 | |||
|
r26335 | def findoutgoing(ui, repo, remote=None, force=False, opts=None): | ||
|
r19021 | """utility function to find the first outgoing changeset | ||
|
r26171 | Used by initialization code""" | ||
|
r26335 | if opts is None: | ||
opts = {} | ||||
|
r19021 | dest = ui.expandpath(remote or 'default-push', remote or 'default') | ||
dest, revs = hg.parseurl(dest, None)[:2] | ||||
ui.status(_('comparing with %s\n') % util.hidepassword(dest)) | ||||
revs, checkout = hg.addbranchrevs(repo, repo, revs, None) | ||||
other = hg.peer(repo, opts, dest) | ||||
if revs: | ||||
revs = [repo.lookup(rev) for rev in revs] | ||||
outgoing = discovery.findcommonoutgoing(repo, other, revs, force=force) | ||||
if not outgoing.missing: | ||||
|
r26587 | raise error.Abort(_('no outgoing ancestors')) | ||
|
r19841 | roots = list(repo.revs("roots(%ln)", outgoing.missing)) | ||
if 1 < len(roots): | ||||
msg = _('there are ambiguous outgoing revisions') | ||||
hint = _('see "hg help histedit" for more detail') | ||||
|
r26587 | raise error.Abort(msg, hint=hint) | ||
|
r19841 | return repo.lookup(roots[0]) | ||
|
r19021 | |||
|
r17064 | actiontable = {'p': pick, | ||
'pick': pick, | ||||
'e': edit, | ||||
'edit': edit, | ||||
'f': fold, | ||||
'fold': fold, | ||||
|
r26246 | '_multifold': _multifold, | ||
|
r22152 | 'r': rollup, | ||
'roll': rollup, | ||||
|
r17064 | 'd': drop, | ||
'drop': drop, | ||||
'm': message, | ||||
'mess': message, | ||||
} | ||||
|
r17147 | |||
@command('histedit', | ||||
[('', 'commands', '', | ||||
|
r24232 | _('read history edits from the specified file'), _('FILE')), | ||
|
r17147 | ('c', 'continue', False, _('continue an edit already in progress')), | ||
|
r24142 | ('', 'edit-plan', False, _('edit remaining actions list')), | ||
|
r17147 | ('k', 'keep', False, | ||
_("don't strip old nodes after edit is complete")), | ||||
('', 'abort', False, _('abort an edit in progress')), | ||||
('o', 'outgoing', False, _('changesets not found in destination')), | ||||
('f', 'force', False, | ||||
_('force outgoing even for unrelated repositories')), | ||||
|
r24232 | ('r', 'rev', [], _('first revision to be edited'), _('REV'))], | ||
|
r19622 | _("ANCESTOR | --outgoing [URL]")) | ||
|
r19020 | def histedit(ui, repo, *freeargs, **opts): | ||
|
r17131 | """interactively edit changeset history | ||
|
r19621 | |||
This command edits changesets between ANCESTOR and the parent of | ||||
the working directory. | ||||
|
r19622 | |||
With --outgoing, this edits changesets not found in the | ||||
destination repository. If URL of the destination is omitted, the | ||||
'default-push' (or 'default') path will be used. | ||||
|
r19842 | |||
|
r26096 | For safety, this command is also aborted if there are ambiguous | ||
outgoing revisions which may confuse users: for example, if there | ||||
are multiple branches containing outgoing revisions. | ||||
|
r19842 | |||
Use "min(outgoing() and ::.)" or similar revset specification | ||||
instead of --outgoing to specify edit target revision exactly in | ||||
such ambiguous situation. See :hg:`help revsets` for detail about | ||||
selecting revisions. | ||||
|
r19972 | |||
Returns 0 on success, 1 if user intervention is required (not only | ||||
for intentional "edit" command, but also for resolving unexpected | ||||
conflicts). | ||||
|
r17064 | """ | ||
|
r22984 | state = histeditstate(repo) | ||
|
r20071 | try: | ||
|
r22984 | state.wlock = repo.wlock() | ||
state.lock = repo.lock() | ||||
_histedit(ui, repo, state, *freeargs, **opts) | ||||
|
r20071 | finally: | ||
|
r22984 | release(state.lock, state.wlock) | ||
|
r20071 | |||
|
r22984 | def _histedit(ui, repo, state, *freeargs, **opts): | ||
|
r17064 | # TODO only abort if we try and histedit mq patches, not just | ||
# blanket if mq patches are applied somewhere | ||||
mq = getattr(repo, 'mq', None) | ||||
if mq and mq.applied: | ||||
|
r26587 | raise error.Abort(_('source has mq patches applied')) | ||
|
r17064 | |||
|
r19020 | # basic argument incompatibility processing | ||
outg = opts.get('outgoing') | ||||
cont = opts.get('continue') | ||||
|
r24142 | editplan = opts.get('edit_plan') | ||
|
r19020 | abort = opts.get('abort') | ||
force = opts.get('force') | ||||
rules = opts.get('commands', '') | ||||
revs = opts.get('rev', []) | ||||
goal = 'new' # This invocation goal, in new, continue, abort | ||||
if force and not outg: | ||||
|
r26587 | raise error.Abort(_('--force only allowed with --outgoing')) | ||
|
r19020 | if cont: | ||
|
r25149 | if any((outg, abort, revs, freeargs, rules, editplan)): | ||
|
r26587 | raise error.Abort(_('no arguments allowed with --continue')) | ||
|
r19020 | goal = 'continue' | ||
elif abort: | ||||
|
r25149 | if any((outg, revs, freeargs, rules, editplan)): | ||
|
r26587 | raise error.Abort(_('no arguments allowed with --abort')) | ||
|
r19020 | goal = 'abort' | ||
|
r24142 | elif editplan: | ||
|
r25149 | if any((outg, revs, freeargs)): | ||
|
r26587 | raise error.Abort(_('only --commands argument allowed with ' | ||
|
r24142 | '--edit-plan')) | ||
goal = 'edit-plan' | ||||
|
r19020 | else: | ||
if os.path.exists(os.path.join(repo.path, 'histedit-state')): | ||||
|
r26587 | raise error.Abort(_('history edit already in progress, try ' | ||
|
r19020 | '--continue or --abort')) | ||
if outg: | ||||
if revs: | ||||
|
r26587 | raise error.Abort(_('no revisions allowed with --outgoing')) | ||
|
r19020 | if len(freeargs) > 1: | ||
|
r26587 | raise error.Abort( | ||
|
r19020 | _('only one repo argument allowed with --outgoing')) | ||
else: | ||||
|
r19021 | revs.extend(freeargs) | ||
|
r24009 | if len(revs) == 0: | ||
|
r25824 | # experimental config: histedit.defaultrev | ||
|
r24009 | histeditdefault = ui.config('histedit', 'defaultrev') | ||
if histeditdefault: | ||||
revs.append(histeditdefault) | ||||
|
r19021 | if len(revs) != 1: | ||
|
r26587 | raise error.Abort( | ||
|
r19621 | _('histedit requires exactly one ancestor revision')) | ||
|
r19020 | |||
|
r17064 | |||
|
r22977 | replacements = [] | ||
|
r25330 | state.keep = opts.get('keep', False) | ||
|
r25808 | supportsmarkers = obsolete.isenabled(repo, obsolete.createmarkersopt) | ||
|
r22977 | |||
# rebuild state | ||||
|
r19020 | if goal == 'continue': | ||
|
r22983 | state.read() | ||
|
r22980 | state = bootstrapcontinue(ui, state, opts) | ||
|
r24142 | elif goal == 'edit-plan': | ||
state.read() | ||||
if not rules: | ||||
|
r24920 | comment = editcomment % (node.short(state.parentctxnode), | ||
node.short(state.topmost)) | ||||
|
r24142 | rules = ruleeditor(repo, ui, state.rules, comment) | ||
else: | ||||
if rules == '-': | ||||
f = sys.stdin | ||||
else: | ||||
f = open(rules) | ||||
rules = f.read() | ||||
f.close() | ||||
rules = [l for l in (r.strip() for r in rules.splitlines()) | ||||
if l and not l.startswith('#')] | ||||
rules = verifyrules(rules, repo, [repo[c] for [_a, c] in state.rules]) | ||||
state.rules = rules | ||||
state.write() | ||||
return | ||||
|
r19020 | elif goal == 'abort': | ||
|
r26584 | try: | ||
state.read() | ||||
tmpnodes, leafs = newnodestoabort(state) | ||||
ui.debug('restore wc to old parent %s\n' | ||||
% node.short(state.topmost)) | ||||
# Recover our old commits if necessary | ||||
if not state.topmost in repo and state.backupfile: | ||||
backupfile = repo.join(state.backupfile) | ||||
f = hg.openpath(ui, backupfile) | ||||
gen = exchange.readbundle(ui, f, backupfile) | ||||
|
r26798 | tr = repo.transaction('histedit.abort') | ||
try: | ||||
if not isinstance(gen, bundle2.unbundle20): | ||||
gen.apply(repo, 'histedit', 'bundle:' + backupfile) | ||||
if isinstance(gen, bundle2.unbundle20): | ||||
bundle2.applybundle(repo, gen, tr, | ||||
source='histedit', | ||||
url='bundle:' + backupfile) | ||||
tr.close() | ||||
finally: | ||||
tr.release() | ||||
|
r26584 | os.remove(backupfile) | ||
|
r24757 | |||
|
r26584 | # check whether we should update away | ||
if repo.unfiltered().revs('parents() and (%n or %ln::)', | ||||
state.parentctxnode, leafs | tmpnodes): | ||||
hg.clean(repo, state.topmost) | ||||
cleanupnode(ui, repo, 'created', tmpnodes) | ||||
cleanupnode(ui, repo, 'temp', leafs) | ||||
except Exception: | ||||
if state.inprogress(): | ||||
ui.warn(_('warning: encountered an exception during histedit ' | ||||
'--abort; the repository may not have been completely ' | ||||
'cleaned up\n')) | ||||
raise | ||||
finally: | ||||
state.clear() | ||||
|
r17064 | return | ||
else: | ||||
|
r19479 | cmdutil.checkunfinished(repo) | ||
|
r17064 | cmdutil.bailifchanged(repo) | ||
|
r17665 | topmost, empty = repo.dirstate.parents() | ||
|
r19021 | if outg: | ||
if freeargs: | ||||
remote = freeargs[0] | ||||
else: | ||||
remote = None | ||||
root = findoutgoing(ui, repo, remote, force, opts) | ||||
else: | ||||
|
r21950 | rr = list(repo.set('roots(%ld)', scmutil.revrange(repo, revs))) | ||
if len(rr) != 1: | ||||
|
r26587 | raise error.Abort(_('The specified revisions must have ' | ||
|
r20806 | 'exactly one common root')) | ||
|
r21950 | root = rr[0].node() | ||
|
r17064 | |||
|
r25330 | revs = between(repo, root, topmost, state.keep) | ||
|
r17766 | if not revs: | ||
|
r26587 | raise error.Abort(_('%s is not an ancestor of working directory') % | ||
|
r19021 | node.short(root)) | ||
|
r17064 | |||
ctxs = [repo[r] for r in revs] | ||||
if not rules: | ||||
|
r24142 | comment = editcomment % (node.short(root), node.short(topmost)) | ||
rules = ruleeditor(repo, ui, [['pick', c] for c in ctxs], comment) | ||||
|
r17064 | else: | ||
|
r19018 | if rules == '-': | ||
f = sys.stdin | ||||
else: | ||||
f = open(rules) | ||||
|
r17064 | rules = f.read() | ||
f.close() | ||||
rules = [l for l in (r.strip() for r in rules.splitlines()) | ||||
|
r22280 | if l and not l.startswith('#')] | ||
|
r17064 | rules = verifyrules(rules, repo, ctxs) | ||
|
r24112 | parentctxnode = repo[root].parents()[0].node() | ||
|
r17064 | |||
|
r24112 | state.parentctxnode = parentctxnode | ||
|
r22984 | state.rules = rules | ||
state.topmost = topmost | ||||
state.replacements = replacements | ||||
|
r17064 | |||
|
r24757 | # Create a backup so we can always abort completely. | ||
backupfile = None | ||||
if not obsolete.isenabled(repo, obsolete.createmarkersopt): | ||||
backupfile = repair._bundle(repo, [parentctxnode], [topmost], root, | ||||
'histedit') | ||||
state.backupfile = backupfile | ||||
|
r26246 | # preprocess rules so that we can hide inner folds from the user | ||
# and only show one editor | ||||
rules = state.rules[:] | ||||
for idx, ((action, ha), (nextact, unused)) in enumerate( | ||||
zip(rules, rules[1:] + [(None, None)])): | ||||
if action == 'fold' and nextact == 'fold': | ||||
state.rules[idx] = '_multifold', ha | ||||
|
r22977 | while state.rules: | ||
state.write() | ||||
action, ha = state.rules.pop(0) | ||||
|
r24002 | ui.debug('histedit: processing %s %s\n' % (action, ha[:12])) | ||
|
r24774 | actobj = actiontable[action].fromrule(state, ha) | ||
parentctx, replacement_ = actobj.run() | ||||
|
r24112 | state.parentctxnode = parentctx.node() | ||
|
r22977 | state.replacements.extend(replacement_) | ||
|
r24111 | state.write() | ||
|
r17064 | |||
|
r24112 | hg.update(repo, state.parentctxnode) | ||
|
r17064 | |||
|
r22985 | mapping, tmpnodes, created, ntm = processreplacement(state) | ||
|
r17758 | if mapping: | ||
for prec, succs in mapping.iteritems(): | ||||
if not succs: | ||||
ui.debug('histedit: %s is dropped\n' % node.short(prec)) | ||||
else: | ||||
ui.debug('histedit: %s is replaced by %s\n' % ( | ||||
node.short(prec), node.short(succs[0]))) | ||||
if len(succs) > 1: | ||||
m = 'histedit: %s' | ||||
for n in succs[1:]: | ||||
ui.debug(m % node.short(n)) | ||||
|
r26763 | if supportsmarkers: | ||
# Only create markers if the temp nodes weren't already removed. | ||||
obsolete.createmarkers(repo, ((repo[t],()) for t in sorted(tmpnodes) | ||||
if t in repo)) | ||||
else: | ||||
cleanupnode(ui, repo, 'temp', tmpnodes) | ||||
|
r25330 | if not state.keep: | ||
|
r17758 | if mapping: | ||
|
r22977 | movebookmarks(ui, repo, mapping, state.topmost, ntm) | ||
|
r17663 | # TODO update mq state | ||
|
r25808 | if supportsmarkers: | ||
|
r17759 | markers = [] | ||
|
r17771 | # sort by revision number because it sound "right" | ||
for prec in sorted(mapping, key=repo.changelog.rev): | ||||
succs = mapping[prec] | ||||
|
r17759 | markers.append((repo[prec], | ||
tuple(repo[s] for s in succs))) | ||||
if markers: | ||||
obsolete.createmarkers(repo, markers) | ||||
else: | ||||
cleanupnode(ui, repo, 'replaced', mapping) | ||||
|
r25894 | |||
|
r22978 | state.clear() | ||
|
r17064 | if os.path.exists(repo.sjoin('undo')): | ||
os.unlink(repo.sjoin('undo')) | ||||
|
r24774 | def bootstrapcontinue(ui, state, opts): | ||
repo = state.repo | ||||
|
r24959 | if state.rules: | ||
action, currentnode = state.rules.pop(0) | ||||
|
r20648 | |||
|
r24959 | actobj = actiontable[action].fromrule(state, currentnode) | ||
|
r24774 | s = repo.status() | ||
|
r24766 | if s.modified or s.added or s.removed or s.deleted: | ||
|
r24959 | actobj.continuedirty() | ||
s = repo.status() | ||||
if s.modified or s.added or s.removed or s.deleted: | ||||
|
r26587 | raise error.Abort(_("working copy still dirty")) | ||
|
r24766 | |||
|
r24959 | parentctx, replacements = actobj.continueclean() | ||
|
r17666 | |||
|
r24959 | state.parentctxnode = parentctx.node() | ||
state.replacements.extend(replacements) | ||||
|
r22980 | |||
return state | ||||
|
r17666 | |||
|
r17642 | def between(repo, old, new, keep): | ||
"""select and validate the set of revision to edit | ||||
When keep is false, the specified set can't have children.""" | ||||
|
r17765 | ctxs = list(repo.set('%n::%n', old, new)) | ||
|
r17766 | if ctxs and not keep: | ||
|
r22952 | if (not obsolete.isenabled(repo, obsolete.allowunstableopt) and | ||
|
r18270 | repo.revs('(%ld::) - (%ld)', ctxs, ctxs)): | ||
|
r26587 | raise error.Abort(_('cannot edit history that would orphan nodes')) | ||
|
r19473 | if repo.revs('(%ld) and merge()', ctxs): | ||
|
r26587 | raise error.Abort(_('cannot edit history that contains merges')) | ||
|
r17767 | root = ctxs[0] # list is already sorted by repo.set | ||
|
r22416 | if not root.mutable(): | ||
|
r26587 | raise error.Abort(_('cannot edit public changeset: %s') % root, | ||
|
r25412 | hint=_('see "hg help phases" for details')) | ||
|
r17765 | return [c.node() for c in ctxs] | ||
|
r17642 | |||
|
r24141 | def makedesc(repo, action, rev): | ||
"""build a initial action line for a ctx | ||||
|
r17643 | |||
line are in the form: | ||||
|
r24141 | <action> <hash> <rev> <summary> | ||
|
r17643 | """ | ||
|
r24141 | ctx = repo[rev] | ||
|
r17643 | summary = '' | ||
|
r24141 | if ctx.description(): | ||
summary = ctx.description().splitlines()[0] | ||||
line = '%s %s %d %s' % (action, ctx, ctx.rev(), summary) | ||||
|
r21858 | # trim to 80 columns so it's not stupidly wide in my editor | ||
|
r24199 | maxlen = repo.ui.configint('histedit', 'linelen', default=80) | ||
maxlen = max(maxlen, 22) # avoid truncating hash | ||||
return util.ellipsis(line, maxlen) | ||||
|
r17643 | |||
|
r24140 | def ruleeditor(repo, ui, rules, editcomment=""): | ||
"""open an editor to edit rules | ||||
rules are in the format [ [act, ctx], ...] like in state.rules | ||||
""" | ||||
|
r24141 | rules = '\n'.join([makedesc(repo, act, rev) for [act, rev] in rules]) | ||
|
r24140 | rules += '\n\n' | ||
rules += editcomment | ||||
rules = ui.edit(rules, ui.username()) | ||||
# Save edit rules in .hg/histedit-last-edit.txt in case | ||||
# the user needs to ask for help after something | ||||
# surprising happens. | ||||
f = open(repo.join('histedit-last-edit.txt'), 'w') | ||||
f.write(rules) | ||||
f.close() | ||||
return rules | ||||
|
r17064 | def verifyrules(rules, repo, ctxs): | ||
"""Verify that there exists exactly one edit rule per given changeset. | ||||
Will abort if there are to many or too few rules, a malformed rule, | ||||
or a rule on a changeset outside of the user-given range. | ||||
""" | ||||
parsed = [] | ||||
|
r24002 | expected = set(c.hex() for c in ctxs) | ||
|
r19047 | seen = set() | ||
|
r17064 | for r in rules: | ||
if ' ' not in r: | ||||
|
r26587 | raise error.Abort(_('malformed line "%s"') % r) | ||
|
r17064 | action, rest = r.split(' ', 1) | ||
|
r19039 | ha = rest.strip().split(' ', 1)[0] | ||
|
r17064 | try: | ||
|
r24002 | ha = repo[ha].hex() | ||
|
r17064 | except error.RepoError: | ||
|
r26587 | raise error.Abort(_('unknown changeset %s listed') % ha[:12]) | ||
|
r19046 | if ha not in expected: | ||
|
r26587 | raise error.Abort( | ||
|
r19045 | _('may not use changesets other than the ones listed')) | ||
|
r19047 | if ha in seen: | ||
|
r26587 | raise error.Abort(_('duplicated command for changeset %s') % | ||
|
r24002 | ha[:12]) | ||
|
r19047 | seen.add(ha) | ||
|
r26246 | if action not in actiontable or action.startswith('_'): | ||
|
r26587 | raise error.Abort(_('unknown action "%s"') % action) | ||
|
r17064 | parsed.append([action, ha]) | ||
|
r19048 | missing = sorted(expected - seen) # sort to stabilize output | ||
if missing: | ||||
|
r26587 | raise error.Abort(_('missing rules for changeset %s') % | ||
|
r24002 | missing[0][:12], | ||
hint=_('do you want to use the drop action?')) | ||||
|
r17064 | return parsed | ||
|
r17663 | |||
|
r25898 | def newnodestoabort(state): | ||
"""process the list of replacements to return | ||||
1) the list of final node | ||||
2) the list of temporary node | ||||
This meant to be used on abort as less data are required in this case. | ||||
""" | ||||
replacements = state.replacements | ||||
allsuccs = set() | ||||
replaced = set() | ||||
for rep in replacements: | ||||
allsuccs.update(rep[1]) | ||||
replaced.add(rep[0]) | ||||
newnodes = allsuccs - replaced | ||||
tmpnodes = allsuccs & replaced | ||||
return newnodes, tmpnodes | ||||
|
r22985 | def processreplacement(state): | ||
|
r17758 | """process the list of replacements to return | ||
1) the final mapping between original and created nodes | ||||
2) the list of temporary node created by histedit | ||||
3) the list of new commit created by histedit""" | ||||
|
r22981 | replacements = state.replacements | ||
|
r17758 | allsuccs = set() | ||
replaced = set() | ||||
fullmapping = {} | ||||
|
r26039 | # initialize basic set | ||
# fullmapping records all operations recorded in replacement | ||||
|
r17758 | for rep in replacements: | ||
allsuccs.update(rep[1]) | ||||
replaced.add(rep[0]) | ||||
fullmapping.setdefault(rep[0], set()).update(rep[1]) | ||||
new = allsuccs - replaced | ||||
tmpnodes = allsuccs & replaced | ||||
|
r26039 | # Reduce content fullmapping into direct relation between original nodes | ||
|
r17758 | # and final node created during history edition | ||
# Dropped changeset are replaced by an empty list | ||||
toproceed = set(fullmapping) | ||||
final = {} | ||||
while toproceed: | ||||
for x in list(toproceed): | ||||
succs = fullmapping[x] | ||||
for s in list(succs): | ||||
if s in toproceed: | ||||
# non final node with unknown closure | ||||
# We can't process this now | ||||
break | ||||
elif s in final: | ||||
# non final node, replace with closure | ||||
succs.remove(s) | ||||
succs.update(final[s]) | ||||
else: | ||||
final[x] = succs | ||||
toproceed.remove(x) | ||||
# remove tmpnodes from final mapping | ||||
for n in tmpnodes: | ||||
del final[n] | ||||
# we expect all changes involved in final to exist in the repo | ||||
# turn `final` into list (topologically sorted) | ||||
|
r22985 | nm = state.repo.changelog.nodemap | ||
|
r17758 | for prec, succs in final.items(): | ||
final[prec] = sorted(succs, key=nm.get) | ||||
|
r17663 | |||
|
r17758 | # computed topmost element (necessary for bookmark) | ||
if new: | ||||
|
r22985 | newtopmost = sorted(new, key=state.repo.changelog.rev)[-1] | ||
|
r17758 | elif not final: | ||
# Nothing rewritten at all. we won't need `newtopmost` | ||||
# It is the same as `oldtopmost` and `processreplacement` know it | ||||
newtopmost = None | ||||
else: | ||||
# every body died. The newtopmost is the parent of the root. | ||||
|
r22985 | r = state.repo.changelog.rev | ||
newtopmost = state.repo[sorted(final, key=r)[0]].p1().node() | ||||
|
r17758 | |||
return final, tmpnodes, new, newtopmost | ||||
|
r17663 | |||
|
r17758 | def movebookmarks(ui, repo, mapping, oldtopmost, newtopmost): | ||
"""Move bookmark from old to newly created node""" | ||||
if not mapping: | ||||
# if nothing got rewritten there is not purpose for this function | ||||
return | ||||
moves = [] | ||||
|
r18370 | for bk, old in sorted(repo._bookmarks.iteritems()): | ||
|
r17758 | if old == oldtopmost: | ||
|
r18436 | # special case ensure bookmark stay on tip. | ||
|
r17758 | # | ||
# This is arguably a feature and we may only want that for the | ||||
# active bookmark. But the behavior is kept compatible with the old | ||||
# version for now. | ||||
moves.append((bk, newtopmost)) | ||||
continue | ||||
base = old | ||||
new = mapping.get(base, None) | ||||
if new is None: | ||||
continue | ||||
while not new: | ||||
# base is killed, trying with parent | ||||
base = repo[base].p1().node() | ||||
new = mapping.get(base, (base,)) | ||||
# nothing to move | ||||
moves.append((bk, new[-1])) | ||||
if moves: | ||||
|
r17922 | marks = repo._bookmarks | ||
|
r17758 | for mark, new in moves: | ||
|
r17922 | old = marks[mark] | ||
|
r17758 | ui.note(_('histedit: moving bookmarks %s from %s to %s\n') | ||
% (mark, node.short(old), node.short(new))) | ||||
|
r17922 | marks[mark] = new | ||
marks.write() | ||||
|
r17664 | |||
def cleanupnode(ui, repo, name, nodes): | ||||
"""strip a group of nodes from the repository | ||||
The set of node to strip may contains unknown nodes.""" | ||||
ui.debug('should strip %s nodes %s\n' % | ||||
(name, ', '.join([node.short(n) for n in nodes]))) | ||||
lock = None | ||||
try: | ||||
lock = repo.lock() | ||||
|
r25906 | # do not let filtering get in the way of the cleanse | ||
|
r26203 | # we should probably get rid of obsolescence marker created during the | ||
|
r25906 | # histedit, but we currently do not have such information. | ||
repo = repo.unfiltered() | ||||
|
r26171 | # Find all nodes that need to be stripped | ||
# (we use %lr instead of %ln to silently ignore unknown items) | ||||
|
r17664 | nm = repo.changelog.nodemap | ||
|
r22873 | nodes = sorted(n for n in nodes if n in nm) | ||
|
r17664 | roots = [c.node() for c in repo.set("roots(%ln)", nodes)] | ||
for c in roots: | ||||
# We should process node in reverse order to strip tip most first. | ||||
# but this trigger a bug in changegroup hook. | ||||
# This would reduce bundle overhead | ||||
repair.strip(ui, repo, c) | ||||
finally: | ||||
|
r20647 | release(lock) | ||
|
r19215 | |||
|
r24111 | def stripwrapper(orig, ui, repo, nodelist, *args, **kwargs): | ||
if isinstance(nodelist, str): | ||||
nodelist = [nodelist] | ||||
if os.path.exists(os.path.join(repo.path, 'histedit-state')): | ||||
state = histeditstate(repo) | ||||
state.read() | ||||
|
r24626 | histedit_nodes = set([repo[rulehash].node() for (action, rulehash) | ||
in state.rules if rulehash in repo]) | ||||
strip_nodes = set([repo[n].node() for n in nodelist]) | ||||
|
r24111 | common_nodes = histedit_nodes & strip_nodes | ||
if common_nodes: | ||||
|
r26587 | raise error.Abort(_("histedit in progress, can't strip %s") | ||
|
r24196 | % ', '.join(node.short(x) for x in common_nodes)) | ||
|
r24111 | return orig(ui, repo, nodelist, *args, **kwargs) | ||
extensions.wrapfunction(repair, 'strip', stripwrapper) | ||||
|
r19215 | def summaryhook(ui, repo): | ||
if not os.path.exists(repo.join('histedit-state')): | ||||
return | ||||
|
r22983 | state = histeditstate(repo) | ||
state.read() | ||||
|
r22977 | if state.rules: | ||
|
r19215 | # i18n: column positioning for "hg summary" | ||
ui.write(_('hist: %s (histedit --continue)\n') % | ||||
(ui.label(_('%d remaining'), 'histedit.remaining') % | ||||
|
r22977 | len(state.rules))) | ||
|
r19215 | |||
def extsetup(ui): | ||||
cmdutil.summaryhooks.add('histedit', summaryhook) | ||||
|
r19479 | cmdutil.unfinishedstates.append( | ||
|
r19496 | ['histedit-state', False, True, _('histedit in progress'), | ||
|
r19479 | _("use 'hg histedit --continue' or 'hg histedit --abort'")]) | ||