hgext/uncommit.py
changeset 34192 da2f5f19312c
child 34283 f94442d46984
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hgext/uncommit.py	Thu Aug 24 22:55:56 2017 +0530
@@ -0,0 +1,183 @@
+# uncommit - undo the actions of a commit
+#
+# Copyright 2011 Peter Arrenbrecht <peter.arrenbrecht@gmail.com>
+#                Logilab SA        <contact@logilab.fr>
+#                Pierre-Yves David <pierre-yves.david@ens-lyon.org>
+#                Patrick Mezard <patrick@mezard.eu>
+# 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)