diff -r 3fbc6e3abdbd -r 168cc52ad7c2 hgext/histedit.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hgext/histedit.py Wed Jun 27 17:52:54 2012 -0500 @@ -0,0 +1,564 @@ +# histedit.py - interactive history editing for mercurial +# +# Copyright 2009 Augie Fackler +# +# This software may be used and distributed according to the terms of the +# GNU General Public License version 2 or any later version. +"""Interactive history editing. + +Inspired by git rebase --interactive. +""" +from inspect import getargspec +try: + import cPickle as pickle +except ImportError: + import pickle +import tempfile +import os + +from mercurial import bookmarks +from mercurial import cmdutil +from mercurial import discovery +from mercurial import error +from mercurial import hg +from mercurial import node +from mercurial import patch +from mercurial import repair +from mercurial import scmutil +from mercurial import url +from mercurial import util +from mercurial.i18n import _ + + +editcomment = """ + +# Edit history between %s and %s +# +# Commands: +# p, pick = use commit +# e, edit = use commit, but stop for amending +# f, fold = use commit, but fold into previous commit (combines N and N-1) +# d, drop = remove commit from history +# m, mess = edit message without changing commit content +# +""" + +def between(repo, old, new, keep): + revs = [old, ] + current = old + while current != new: + ctx = repo[current] + if not keep and len(ctx.children()) > 1: + raise util.Abort(_('cannot edit history that would orphan nodes')) + if len(ctx.parents()) != 1 and ctx.parents()[1] != node.nullid: + raise util.Abort(_("can't edit history with merges")) + if not ctx.children(): + current = new + else: + current = ctx.children()[0].node() + revs.append(current) + if len(repo[current].children()) and not keep: + raise util.Abort(_('cannot edit history that would orphan nodes')) + return revs + + +def pick(ui, repo, ctx, ha, opts): + oldctx = repo[ha] + if oldctx.parents()[0] == ctx: + ui.debug('node %s unchanged\n' % ha) + return oldctx, [], [], [] + hg.update(repo, ctx.node()) + fd, patchfile = tempfile.mkstemp(prefix='hg-histedit-') + fp = os.fdopen(fd, 'w') + diffopts = patch.diffopts(ui, opts) + diffopts.git = True + diffopts.ignorews = False + diffopts.ignorewsamount = False + diffopts.ignoreblanklines = False + gen = patch.diff(repo, oldctx.parents()[0].node(), ha, opts=diffopts) + for chunk in gen: + fp.write(chunk) + fp.close() + try: + files = set() + try: + patch.patch(ui, repo, patchfile, files=files, eolmode=None) + if not files: + ui.warn(_('%s: empty changeset') + % node.hex(ha)) + return ctx, [], [], [] + finally: + os.unlink(patchfile) + except Exception, inst: + raise util.Abort(_('Fix up the change and run ' + 'hg histedit --continue')) + n = repo.commit(text=oldctx.description(), user=oldctx.user(), date=oldctx.date(), + extra=oldctx.extra()) + return repo[n], [n, ], [oldctx.node(), ], [] + + +def edit(ui, repo, ctx, ha, opts): + oldctx = repo[ha] + hg.update(repo, ctx.node()) + fd, patchfile = tempfile.mkstemp(prefix='hg-histedit-') + fp = os.fdopen(fd, 'w') + diffopts = patch.diffopts(ui, opts) + diffopts.git = True + diffopts.ignorews = False + diffopts.ignorewsamount = False + diffopts.ignoreblanklines = False + gen = patch.diff(repo, oldctx.parents()[0].node(), ha, opts=diffopts) + for chunk in gen: + fp.write(chunk) + fp.close() + try: + files = set() + try: + patch.patch(ui, repo, patchfile, files=files, eolmode=None) + finally: + os.unlink(patchfile) + except Exception, inst: + pass + raise util.Abort(_('Make changes as needed, you may commit or record as ' + 'needed now.\nWhen you are finished, run hg' + ' histedit --continue to resume.')) + +def fold(ui, repo, ctx, ha, opts): + oldctx = repo[ha] + hg.update(repo, ctx.node()) + fd, patchfile = tempfile.mkstemp(prefix='hg-histedit-') + fp = os.fdopen(fd, 'w') + diffopts = patch.diffopts(ui, opts) + diffopts.git = True + diffopts.ignorews = False + diffopts.ignorewsamount = False + diffopts.ignoreblanklines = False + gen = patch.diff(repo, oldctx.parents()[0].node(), ha, opts=diffopts) + for chunk in gen: + fp.write(chunk) + fp.close() + try: + files = set() + try: + patch.patch(ui, repo, patchfile, files=files, eolmode=None) + if not files: + ui.warn(_('%s: empty changeset') + % node.hex(ha)) + return ctx, [], [], [] + finally: + os.unlink(patchfile) + except Exception, inst: + raise util.Abort(_('Fix up the change and run ' + 'hg histedit --continue')) + n = repo.commit(text='fold-temp-revision %s' % ha, user=oldctx.user(), date=oldctx.date(), + extra=oldctx.extra()) + return finishfold(ui, repo, ctx, oldctx, n, opts, []) + +def finishfold(ui, repo, ctx, oldctx, newnode, opts, internalchanges): + parent = ctx.parents()[0].node() + hg.update(repo, parent) + fd, patchfile = tempfile.mkstemp(prefix='hg-histedit-') + fp = os.fdopen(fd, 'w') + diffopts = patch.diffopts(ui, opts) + diffopts.git = True + diffopts.ignorews = False + diffopts.ignorewsamount = False + diffopts.ignoreblanklines = False + gen = patch.diff(repo, parent, newnode, opts=diffopts) + for chunk in gen: + fp.write(chunk) + fp.close() + files = set() + try: + patch.patch(ui, repo, patchfile, files=files, eolmode=None) + finally: + os.unlink(patchfile) + newmessage = '\n***\n'.join( + [ctx.description(), ] + + [repo[r].description() for r in internalchanges] + + [oldctx.description(), ]) + # If the changesets are from the same author, keep it. + if ctx.user() == oldctx.user(): + username = ctx.user() + else: + username = ui.username() + newmessage = ui.edit(newmessage, username) + n = repo.commit(text=newmessage, user=username, date=max(ctx.date(), oldctx.date()), + extra=oldctx.extra()) + return repo[n], [n, ], [oldctx.node(), ctx.node() ], [newnode, ] + +def drop(ui, repo, ctx, ha, opts): + return ctx, [], [repo[ha].node(), ], [] + + +def message(ui, repo, ctx, ha, opts): + oldctx = repo[ha] + hg.update(repo, ctx.node()) + fd, patchfile = tempfile.mkstemp(prefix='hg-histedit-') + fp = os.fdopen(fd, 'w') + diffopts = patch.diffopts(ui, opts) + diffopts.git = True + diffopts.ignorews = False + diffopts.ignorewsamount = False + diffopts.ignoreblanklines = False + gen = patch.diff(repo, oldctx.parents()[0].node(), ha, opts=diffopts) + for chunk in gen: + fp.write(chunk) + fp.close() + try: + files = set() + try: + patch.patch(ui, repo, patchfile, files=files, eolmode=None) + finally: + os.unlink(patchfile) + except Exception, inst: + raise util.Abort(_('Fix up the change and run ' + 'hg histedit --continue')) + message = oldctx.description() + message = ui.edit(message, ui.username()) + new = repo.commit(text=message, user=oldctx.user(), date=oldctx.date(), + extra=oldctx.extra()) + newctx = repo[new] + if oldctx.node() != newctx.node(): + return newctx, [new], [oldctx.node()], [] + # We didn't make an edit, so just indicate no replaced nodes + return newctx, [new], [], [] + + +def makedesc(c): + summary = '' + if c.description(): + summary = c.description().splitlines()[0] + line = 'pick %s %d %s' % (c.hex()[:12], c.rev(), summary) + return line[:80] # trim to 80 chars so it's not stupidly wide in my editor + +actiontable = {'p': pick, + 'pick': pick, + 'e': edit, + 'edit': edit, + 'f': fold, + 'fold': fold, + 'd': drop, + 'drop': drop, + 'm': message, + 'mess': message, + } +def histedit(ui, repo, *parent, **opts): + """hg histedit + """ + # 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: + raise util.Abort(_('source has mq patches applied')) + + parent = list(parent) + opts.get('rev', []) + if opts.get('outgoing'): + if len(parent) > 1: + raise util.Abort(_('only one repo argument allowed with --outgoing')) + elif parent: + parent = parent[0] + + dest = ui.expandpath(parent or 'default-push', parent 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.repository(hg.remoteui(repo, opts), dest) + + if revs: + revs = [repo.lookup(rev) for rev in revs] + + parent = discovery.findcommonoutgoing( + repo, other, [], force=opts.get('force')).missing[0:1] + else: + if opts.get('force'): + raise util.Abort(_('--force only allowed with --outgoing')) + + if opts.get('continue', False): + if len(parent) != 0: + raise util.Abort(_('no arguments allowed with --continue')) + (parentctxnode, created, replaced, + tmpnodes, existing, rules, keep, tip, replacemap ) = readstate(repo) + currentparent, wantnull = repo.dirstate.parents() + parentctx = repo[parentctxnode] + # discover any nodes the user has added in the interim + newchildren = [c for c in parentctx.children() + if c.node() not in existing] + action, currentnode = rules.pop(0) + while newchildren: + if action in ['f', 'fold', ]: + tmpnodes.extend([n.node() for n in newchildren]) + else: + created.extend([n.node() for n in newchildren]) + newchildren = filter(lambda x: x.node() not in existing, + reduce(lambda x, y: x + y, + map(lambda r: r.children(), + newchildren))) + m, a, r, d = repo.status()[:4] + oldctx = repo[currentnode] + message = oldctx.description() + if action in ('e', 'edit', 'm', 'mess'): + message = ui.edit(message, ui.username()) + elif action in ('f', 'fold', ): + message = 'fold-temp-revision %s' % currentnode + new = None + if m or a or r or d: + new = repo.commit(text=message, user=oldctx.user(), date=oldctx.date(), + extra=oldctx.extra()) + + if action in ('f', 'fold'): + if new: + tmpnodes.append(new) + else: + new = newchildren[-1] + (parentctx, created_, + replaced_, tmpnodes_, ) = finishfold(ui, repo, + parentctx, oldctx, new, + opts, newchildren) + replaced.extend(replaced_) + created.extend(created_) + tmpnodes.extend(tmpnodes_) + elif action not in ('d', 'drop'): + if new != oldctx.node(): + replaced.append(oldctx.node()) + if new: + if new != oldctx.node(): + created.append(new) + parentctx = repo[new] + + elif opts.get('abort', False): + if len(parent) != 0: + raise util.Abort(_('no arguments allowed with --abort')) + (parentctxnode, created, replaced, tmpnodes, + existing, rules, keep, tip, replacemap) = readstate(repo) + ui.debug('restore wc to old tip %s\n' % node.hex(tip)) + hg.clean(repo, tip) + ui.debug('should strip created nodes %s\n' % + ', '.join([node.hex(n)[:12] for n in created])) + ui.debug('should strip temp nodes %s\n' % + ', '.join([node.hex(n)[:12] for n in tmpnodes])) + for nodes in (created, tmpnodes, ): + for n in reversed(nodes): + try: + repair.strip(ui, repo, n) + except error.LookupError: + pass + os.unlink(os.path.join(repo.path, 'histedit-state')) + return + else: + cmdutil.bailifchanged(repo) + if os.path.exists(os.path.join(repo.path, 'histedit-state')): + raise util.Abort(_('history edit already in progress, try ' + '--continue or --abort')) + + tip, empty = repo.dirstate.parents() + + + if len(parent) != 1: + raise util.Abort(_('histedit requires exactly one parent revision')) + parent = scmutil.revsingle(repo, parent[0]).node() + + keep = opts.get('keep', False) + revs = between(repo, parent, tip, keep) + + ctxs = [repo[r] for r in revs] + existing = [r.node() for r in ctxs] + rules = opts.get('commands', '') + if not rules: + rules = '\n'.join([makedesc(c) for c in ctxs]) + rules += editcomment % (node.hex(parent)[:12], node.hex(tip)[:12], ) + 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() + 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[0] == '#'] + rules = verifyrules(rules, repo, ctxs) + + parentctx = repo[parent].parents()[0] + keep = opts.get('keep', False) + replaced = [] + replacemap = {} + tmpnodes = [] + created = [] + + + while rules: + writestate(repo, parentctx.node(), created, replaced, tmpnodes, existing, + rules, keep, tip, replacemap) + action, ha = rules.pop(0) + (parentctx, created_, + replaced_, tmpnodes_, ) = actiontable[action](ui, repo, + parentctx, ha, + opts) + + hexshort = lambda x: node.hex(x)[:12] + + if replaced_: + clen, rlen = len(created_), len(replaced_) + if clen == rlen == 1: + ui.debug('histedit: exact replacement of %s with %s\n' % ( + hexshort(replaced_[0]), hexshort(created_[0]))) + + replacemap[replaced_[0]] = created_[0] + elif clen > rlen: + assert rlen == 1, ('unexpected replacement of ' + '%d changes with %d changes' % (rlen, clen)) + # made more changesets than we're replacing + # TODO synthesize patch names for created patches + replacemap[replaced_[0]] = created_[-1] + ui.debug('histedit: created many, assuming %s replaced by %s' % ( + hexshort(replaced_[0]), hexshort(created_[-1]))) + elif rlen > clen: + if not created_: + # This must be a drop. Try and put our metadata on + # the parent change. + assert rlen == 1 + r = replaced_[0] + ui.debug('histedit: %s seems replaced with nothing, ' + 'finding a parent\n' % (hexshort(r))) + pctx = repo[r].parents()[0] + if pctx.node() in replacemap: + ui.debug('histedit: parent is already replaced\n') + replacemap[r] = replacemap[pctx.node()] + else: + replacemap[r] = pctx.node() + ui.debug('histedit: %s best replaced by %s\n' % ( + hexshort(r), hexshort(replacemap[r]))) + else: + assert len(created_) == 1 + for r in replaced_: + ui.debug('histedit: %s replaced by %s\n' % ( + hexshort(r), hexshort(created_[0]))) + replacemap[r] = created_[0] + else: + assert False, ( + 'Unhandled case in replacement mapping! ' + 'replacing %d changes with %d changes' % (rlen, clen)) + created.extend(created_) + replaced.extend(replaced_) + tmpnodes.extend(tmpnodes_) + + hg.update(repo, parentctx.node()) + + if not keep: + if replacemap: + ui.note('histedit: Should update metadata for the following ' + 'changes:\n') + + def copybms(old, new): + if old in tmpnodes or old in created: + # can't have any metadata we'd want to update + return + while new in replacemap: + new = replacemap[new] + ui.note('histedit: %s to %s\n' % (hexshort(old), hexshort(new))) + octx = repo[old] + marks = octx.bookmarks() + if marks: + ui.note('histedit: moving bookmarks %s\n' % + ', '.join(marks)) + for mark in marks: + repo._bookmarks[mark] = new + bookmarks.write(repo) + + # We assume that bookmarks on the tip should remain + # tipmost, but bookmarks on non-tip changesets should go + # to their most reasonable successor. As a result, find + # the old tip and new tip and copy those bookmarks first, + # then do the rest of the bookmark copies. + oldtip = sorted(replacemap.keys(), key=repo.changelog.rev)[-1] + newtip = sorted(replacemap.values(), key=repo.changelog.rev)[-1] + copybms(oldtip, newtip) + + for old, new in replacemap.iteritems(): + copybms(old, new) + # TODO update mq state + + ui.debug('should strip replaced nodes %s\n' % + ', '.join([node.hex(n)[:12] for n in replaced])) + for n in sorted(replaced, key=lambda x: repo[x].rev()): + try: + repair.strip(ui, repo, n) + except error.LookupError: + pass + + ui.debug('should strip temp nodes %s\n' % + ', '.join([node.hex(n)[:12] for n in tmpnodes])) + for n in reversed(tmpnodes): + try: + repair.strip(ui, repo, n) + except error.LookupError: + pass + os.unlink(os.path.join(repo.path, 'histedit-state')) + if os.path.exists(repo.sjoin('undo')): + os.unlink(repo.sjoin('undo')) + + +def writestate(repo, parentctxnode, created, replaced, + tmpnodes, existing, rules, keep, oldtip, replacemap): + fp = open(os.path.join(repo.path, 'histedit-state'), 'w') + pickle.dump((parentctxnode, created, replaced, + tmpnodes, existing, rules, keep, oldtip, replacemap), + fp) + fp.close() + +def readstate(repo): + """Returns a tuple of (parentnode, created, replaced, tmp, existing, rules, + keep, oldtip, replacemap ). + """ + fp = open(os.path.join(repo.path, 'histedit-state')) + return pickle.load(fp) + + +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 = [] + first = True + if len(rules) != len(ctxs): + raise util.Abort(_('must specify a rule for each changeset once')) + for r in rules: + if ' ' not in r: + raise util.Abort(_('malformed line "%s"') % r) + action, rest = r.split(' ', 1) + if ' ' in rest.strip(): + ha, rest = rest.split(' ', 1) + else: + ha = r.strip() + try: + if repo[ha] not in ctxs: + raise util.Abort(_('may not use changesets other than the ones listed')) + except error.RepoError: + raise util.Abort(_('unknown changeset %s listed') % ha) + if action not in actiontable: + raise util.Abort(_('unknown action "%s"') % action) + parsed.append([action, ha]) + return parsed + + +cmdtable = { + "histedit": + (histedit, + [('', 'commands', '', _('Read history edits from the specified file.')), + ('c', 'continue', False, _('continue an edit already in progress')), + ('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')), + ('r', 'rev', [], _('first revision to be edited')), + ], + __doc__, + ), +}