diff --git a/hgext/uncommit.py b/hgext/uncommit.py new file mode 100644 --- /dev/null +++ b/hgext/uncommit.py @@ -0,0 +1,183 @@ +# uncommit - undo the actions of a commit +# +# Copyright 2011 Peter Arrenbrecht +# Logilab SA +# Pierre-Yves David +# Patrick Mezard +# Copyright 2016 Facebook, Inc. +# +# This software may be used and distributed according to the terms of the +# GNU General Public License version 2 or any later version. + +"""uncommit part or all of a local changeset (EXPERIMENTAL) + +This command undoes the effect of a local commit, returning the affected +files to their uncommitted state. This means that files modified, added or +removed in the changeset will be left unchanged, and so will remain modified, +added and removed in the working directory. +""" + +from __future__ import absolute_import + +from mercurial.i18n import _ + +from mercurial import ( + commands, + context, + copies, + error, + node, + obsolete, + registrar, + scmutil, +) + +cmdtable = {} +command = registrar.command(cmdtable) + +# Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' 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. +testedwith = 'ships-with-hg-core' + +def _commitfiltered(repo, ctx, match, allowempty): + """Recommit ctx with changed files not in match. Return the new + node identifier, or None if nothing changed. + """ + base = ctx.p1() + # ctx + initialfiles = set(ctx.files()) + exclude = set(f for f in initialfiles if match(f)) + + # No files matched commit, so nothing excluded + if not exclude: + return None + + files = (initialfiles - exclude) + # return the p1 so that we don't create an obsmarker later + if not files and not allowempty: + return ctx.parents()[0].node() + + # Filter copies + copied = copies.pathcopies(base, ctx) + copied = dict((dst, src) for dst, src in copied.iteritems() + if dst in files) + def filectxfn(repo, memctx, path, contentctx=ctx, redirect=()): + if path not in contentctx: + return None + fctx = contentctx[path] + mctx = context.memfilectx(repo, fctx.path(), fctx.data(), + fctx.islink(), + fctx.isexec(), + copied=copied.get(path)) + return mctx + + new = context.memctx(repo, + parents=[base.node(), node.nullid], + text=ctx.description(), + files=files, + filectxfn=filectxfn, + user=ctx.user(), + date=ctx.date(), + extra=ctx.extra()) + # phase handling + commitphase = ctx.phase() + overrides = {('phases', 'new-commit'): commitphase} + with repo.ui.configoverride(overrides, 'uncommit'): + newid = repo.commitctx(new) + return newid + +def _uncommitdirstate(repo, oldctx, match): + """Fix the dirstate after switching the working directory from + oldctx to a copy of oldctx not containing changed files matched by + match. + """ + ctx = repo['.'] + ds = repo.dirstate + copies = dict(ds.copies()) + s = repo.status(oldctx.p1(), oldctx, match=match) + for f in s.modified: + if ds[f] == 'r': + # modified + removed -> removed + continue + ds.normallookup(f) + + for f in s.added: + if ds[f] == 'r': + # added + removed -> unknown + ds.drop(f) + elif ds[f] != 'a': + ds.add(f) + + for f in s.removed: + if ds[f] == 'a': + # removed + added -> normal + ds.normallookup(f) + elif ds[f] != 'r': + ds.remove(f) + + # Merge old parent and old working dir copies + oldcopies = {} + for f in (s.modified + s.added): + src = oldctx[f].renamed() + if src: + oldcopies[f] = src[0] + oldcopies.update(copies) + copies = dict((dst, oldcopies.get(src, src)) + for dst, src in oldcopies.iteritems()) + # Adjust the dirstate copies + for dst, src in copies.iteritems(): + if (src not in ctx or dst in ctx or ds[dst] != 'a'): + src = None + ds.copy(src, dst) + +@command('uncommit', + [('', 'empty', False, _('allow an empty commit after uncommiting')), + ] + commands.walkopts, + _('[OPTION]... [FILE]...')) +def uncommit(ui, repo, *pats, **opts): + """uncommit part or all of a local changeset + + This command undoes the effect of a local commit, returning the affected + files to their uncommitted state. This means that files modified or + deleted in the changeset will be left unchanged, and so will remain + modified in the working directory. + """ + + with repo.wlock(), repo.lock(): + wctx = repo[None] + + if wctx.parents()[0].node() == node.nullid: + raise error.Abort(_("cannot uncommit null changeset")) + if len(wctx.parents()) > 1: + raise error.Abort(_("cannot uncommit while merging")) + old = repo['.'] + if not old.mutable(): + raise error.Abort(_('cannot uncommit public changesets')) + if len(old.parents()) > 1: + raise error.Abort(_("cannot uncommit merge changeset")) + allowunstable = obsolete.isenabled(repo, obsolete.allowunstableopt) + if not allowunstable and old.children(): + raise error.Abort(_('cannot uncommit changeset with children')) + + with repo.transaction('uncommit'): + match = scmutil.match(old, pats, opts) + newid = _commitfiltered(repo, old, match, opts.get('empty')) + if newid is None: + ui.status(_("nothing to uncommit\n")) + return 1 + + mapping = {} + if newid != old.p1().node(): + # Move local changes on filtered changeset + mapping[old.node()] = (newid,) + else: + # Fully removed the old commit + mapping[old.node()] = () + + scmutil.cleanupnodes(repo, mapping, 'uncommit') + + with repo.dirstate.parentchange(): + repo.dirstate.setparents(newid, node.nullid) + _uncommitdirstate(repo, old, match) diff --git a/tests/test-uncommit.t b/tests/test-uncommit.t new file mode 100644 --- /dev/null +++ b/tests/test-uncommit.t @@ -0,0 +1,366 @@ +Test uncommit - set up the config + + $ cat >> $HGRCPATH < [experimental] + > evolution=createmarkers, allowunstable + > [extensions] + > uncommit = + > drawdag=$TESTDIR/drawdag.py + > EOF + +Build up a repo + + $ hg init repo + $ cd repo + $ hg bookmark foo + +Help for uncommit + + $ hg help uncommit + hg uncommit [OPTION]... [FILE]... + + uncommit part or all of a local changeset + + This command undoes the effect of a local commit, returning the affected + files to their uncommitted state. This means that files modified or + deleted in the changeset will be left unchanged, and so will remain + modified in the working directory. + + (use 'hg help -e uncommit' to show help for the uncommit extension) + + options ([+] can be repeated): + + --empty allow an empty commit after uncommiting + -I --include PATTERN [+] include names matching the given patterns + -X --exclude PATTERN [+] exclude names matching the given patterns + + (some details hidden, use --verbose to show complete help) + +Uncommit with no commits should fail + + $ hg uncommit + abort: cannot uncommit null changeset + [255] + +Create some commits + + $ touch files + $ hg add files + $ for i in a ab abc abcd abcde; do echo $i > files; echo $i > file-$i; hg add file-$i; hg commit -m "added file-$i"; done + $ ls + file-a + file-ab + file-abc + file-abcd + file-abcde + files + + $ hg log -G -T '{rev}:{node} {desc}' --hidden + @ 4:6c4fd43ed714e7fcd8adbaa7b16c953c2e985b60 added file-abcde + | + o 3:6db330d65db434145c0b59d291853e9a84719b24 added file-abcd + | + o 2:abf2df566fc193b3ac34d946e63c1583e4d4732b added file-abc + | + o 1:69a232e754b08d568c4899475faf2eb44b857802 added file-ab + | + o 0:3004d2d9b50883c1538fc754a3aeb55f1b4084f6 added file-a + +Simple uncommit off the top, also moves bookmark + + $ hg bookmark + * foo 4:6c4fd43ed714 + $ hg uncommit + $ hg status + M files + A file-abcde + $ hg bookmark + * foo 3:6db330d65db4 + + $ hg log -G -T '{rev}:{node} {desc}' --hidden + x 4:6c4fd43ed714e7fcd8adbaa7b16c953c2e985b60 added file-abcde + | + @ 3:6db330d65db434145c0b59d291853e9a84719b24 added file-abcd + | + o 2:abf2df566fc193b3ac34d946e63c1583e4d4732b added file-abc + | + o 1:69a232e754b08d568c4899475faf2eb44b857802 added file-ab + | + o 0:3004d2d9b50883c1538fc754a3aeb55f1b4084f6 added file-a + + +Recommit + + $ hg commit -m 'new change abcde' + $ hg status + $ hg heads -T '{rev}:{node} {desc}' + 5:0c07a3ccda771b25f1cb1edbd02e683723344ef1 new change abcde (no-eol) + +Uncommit of non-existent and unchanged files has no effect + $ hg uncommit nothinghere + nothing to uncommit + [1] + $ hg status + $ hg uncommit file-abc + nothing to uncommit + [1] + $ hg status + +Try partial uncommit, also moves bookmark + + $ hg bookmark + * foo 5:0c07a3ccda77 + $ hg uncommit files + $ hg status + M files + $ hg bookmark + * foo 6:3727deee06f7 + $ hg heads -T '{rev}:{node} {desc}' + 6:3727deee06f72f5ffa8db792ee299cf39e3e190b new change abcde (no-eol) + $ hg log -r . -p -T '{rev}:{node} {desc}' + 6:3727deee06f72f5ffa8db792ee299cf39e3e190b new change abcdediff -r 6db330d65db4 -r 3727deee06f7 file-abcde + --- /dev/null Thu Jan 01 00:00:00 1970 +0000 + +++ b/file-abcde Thu Jan 01 00:00:00 1970 +0000 + @@ -0,0 +1,1 @@ + +abcde + + $ hg log -G -T '{rev}:{node} {desc}' --hidden + @ 6:3727deee06f72f5ffa8db792ee299cf39e3e190b new change abcde + | + | x 5:0c07a3ccda771b25f1cb1edbd02e683723344ef1 new change abcde + |/ + | x 4:6c4fd43ed714e7fcd8adbaa7b16c953c2e985b60 added file-abcde + |/ + o 3:6db330d65db434145c0b59d291853e9a84719b24 added file-abcd + | + o 2:abf2df566fc193b3ac34d946e63c1583e4d4732b added file-abc + | + o 1:69a232e754b08d568c4899475faf2eb44b857802 added file-ab + | + o 0:3004d2d9b50883c1538fc754a3aeb55f1b4084f6 added file-a + + $ hg commit -m 'update files for abcde' + +Uncommit with dirty state + + $ echo "foo" >> files + $ cat files + abcde + foo + $ hg status + M files + $ hg uncommit files + $ cat files + abcde + foo + $ hg commit -m "files abcde + foo" + +Uncommit in the middle of a stack, does not move bookmark + + $ hg checkout '.^^^' + 1 files updated, 0 files merged, 2 files removed, 0 files unresolved + (leaving bookmark foo) + $ hg log -r . -p -T '{rev}:{node} {desc}' + 2:abf2df566fc193b3ac34d946e63c1583e4d4732b added file-abcdiff -r 69a232e754b0 -r abf2df566fc1 file-abc + --- /dev/null Thu Jan 01 00:00:00 1970 +0000 + +++ b/file-abc Thu Jan 01 00:00:00 1970 +0000 + @@ -0,0 +1,1 @@ + +abc + diff -r 69a232e754b0 -r abf2df566fc1 files + --- a/files Thu Jan 01 00:00:00 1970 +0000 + +++ b/files Thu Jan 01 00:00:00 1970 +0000 + @@ -1,1 +1,1 @@ + -ab + +abc + + $ hg bookmark + foo 8:83815831694b + $ hg uncommit + $ hg status + M files + A file-abc + $ hg heads -T '{rev}:{node} {desc}' + 8:83815831694b1271e9f207cb1b79b2b19275edcb files abcde + foo (no-eol) + $ hg bookmark + foo 8:83815831694b + $ hg commit -m 'new abc' + created new head + +Partial uncommit in the middle, does not move bookmark + + $ hg checkout '.^' + 1 files updated, 0 files merged, 1 files removed, 0 files unresolved + $ hg log -r . -p -T '{rev}:{node} {desc}' + 1:69a232e754b08d568c4899475faf2eb44b857802 added file-abdiff -r 3004d2d9b508 -r 69a232e754b0 file-ab + --- /dev/null Thu Jan 01 00:00:00 1970 +0000 + +++ b/file-ab Thu Jan 01 00:00:00 1970 +0000 + @@ -0,0 +1,1 @@ + +ab + diff -r 3004d2d9b508 -r 69a232e754b0 files + --- a/files Thu Jan 01 00:00:00 1970 +0000 + +++ b/files Thu Jan 01 00:00:00 1970 +0000 + @@ -1,1 +1,1 @@ + -a + +ab + + $ hg bookmark + foo 8:83815831694b + $ hg uncommit file-ab + $ hg status + A file-ab + + $ hg heads -T '{rev}:{node} {desc}\n' + 10:8eb87968f2edb7f27f27fe676316e179de65fff6 added file-ab + 9:5dc89ca4486f8a88716c5797fa9f498d13d7c2e1 new abc + 8:83815831694b1271e9f207cb1b79b2b19275edcb files abcde + foo + + $ hg bookmark + foo 8:83815831694b + $ hg commit -m 'update ab' + $ hg status + $ hg heads -T '{rev}:{node} {desc}\n' + 11:f21039c59242b085491bb58f591afc4ed1c04c09 update ab + 9:5dc89ca4486f8a88716c5797fa9f498d13d7c2e1 new abc + 8:83815831694b1271e9f207cb1b79b2b19275edcb files abcde + foo + + $ hg log -G -T '{rev}:{node} {desc}' --hidden + @ 11:f21039c59242b085491bb58f591afc4ed1c04c09 update ab + | + o 10:8eb87968f2edb7f27f27fe676316e179de65fff6 added file-ab + | + | o 9:5dc89ca4486f8a88716c5797fa9f498d13d7c2e1 new abc + | | + | | o 8:83815831694b1271e9f207cb1b79b2b19275edcb files abcde + foo + | | | + | | | x 7:0977fa602c2fd7d8427ed4e7ee15ea13b84c9173 update files for abcde + | | |/ + | | o 6:3727deee06f72f5ffa8db792ee299cf39e3e190b new change abcde + | | | + | | | x 5:0c07a3ccda771b25f1cb1edbd02e683723344ef1 new change abcde + | | |/ + | | | x 4:6c4fd43ed714e7fcd8adbaa7b16c953c2e985b60 added file-abcde + | | |/ + | | o 3:6db330d65db434145c0b59d291853e9a84719b24 added file-abcd + | | | + | | x 2:abf2df566fc193b3ac34d946e63c1583e4d4732b added file-abc + | |/ + | x 1:69a232e754b08d568c4899475faf2eb44b857802 added file-ab + |/ + o 0:3004d2d9b50883c1538fc754a3aeb55f1b4084f6 added file-a + +Uncommit with draft parent + + $ hg uncommit + $ hg phase -r . + 10: draft + $ hg commit -m 'update ab again' + +Uncommit with public parent + + $ hg phase -p "::.^" + $ hg uncommit + $ hg phase -r . + 10: public + +Partial uncommit with public parent + + $ echo xyz > xyz + $ hg add xyz + $ hg commit -m "update ab and add xyz" + $ hg uncommit xyz + $ hg status + A xyz + $ hg phase -r . + 14: draft + $ hg phase -r ".^" + 10: public + +Uncommit leaving an empty changeset + + $ cd $TESTTMP + $ hg init repo1 + $ cd repo1 + $ hg debugdrawdag <<'EOS' + > Q + > | + > P + > EOS + $ hg up Q -q + $ hg uncommit --empty + $ hg log -G -T '{desc} FILES: {files}' + @ Q FILES: + | + | x Q FILES: Q + |/ + o P FILES: P + + $ hg status + A Q + + $ cd .. + $ rm repo1 -rf + +Testing uncommit while merge + + $ hg init repo2 + $ cd repo2 + +Create some history + + $ touch a + $ hg add a + $ for i in 1 2 3; do echo $i > a; hg commit -m "a $i"; done + $ hg checkout 0 + 1 files updated, 0 files merged, 0 files removed, 0 files unresolved + $ touch b + $ hg add b + $ for i in 1 2 3; do echo $i > b; hg commit -m "b $i"; done + created new head + $ hg log -G -T '{rev}:{node} {desc}' --hidden + @ 5:2cd56cdde163ded2fbb16ba2f918c96046ab0bf2 b 3 + | + o 4:c3a0d5bb3b15834ffd2ef9ef603e93ec65cf2037 b 2 + | + o 3:49bb009ca26078726b8870f1edb29fae8f7618f5 b 1 + | + | o 2:990982b7384266e691f1bc08ca36177adcd1c8a9 a 3 + | | + | o 1:24d38e3cf160c7b6f5ffe82179332229886a6d34 a 2 + |/ + o 0:ea4e33293d4d274a2ba73150733c2612231f398c a 1 + + +Add and expect uncommit to fail on both merge working dir and merge changeset + + $ hg merge 2 + 1 files updated, 0 files merged, 0 files removed, 0 files unresolved + (branch merge, don't forget to commit) + + $ hg uncommit + abort: cannot uncommit while merging + [255] + + $ hg status + M a + $ hg commit -m 'merge a and b' + + $ hg uncommit + abort: cannot uncommit merge changeset + [255] + + $ hg status + $ hg log -G -T '{rev}:{node} {desc}' --hidden + @ 6:c03b9c37bc67bf504d4912061cfb527b47a63c6e merge a and b + |\ + | o 5:2cd56cdde163ded2fbb16ba2f918c96046ab0bf2 b 3 + | | + | o 4:c3a0d5bb3b15834ffd2ef9ef603e93ec65cf2037 b 2 + | | + | o 3:49bb009ca26078726b8870f1edb29fae8f7618f5 b 1 + | | + o | 2:990982b7384266e691f1bc08ca36177adcd1c8a9 a 3 + | | + o | 1:24d38e3cf160c7b6f5ffe82179332229886a6d34 a 2 + |/ + o 0:ea4e33293d4d274a2ba73150733c2612231f398c a 1 +