evolve: add uncommit command
authorPatrick Mezard <patrick@mezard.eu>
Tue, 26 Jun 2012 18:12:31 +0200
changeset 312 a7b5989d1d92
parent 311 5eecfda0a5c7
child 313 47d10459fa24
evolve: add uncommit command
hgext/evolve.py
tests/test-uncommit.t
--- a/hgext/evolve.py	Tue Jun 26 14:35:09 2012 +0200
+++ b/hgext/evolve.py	Tue Jun 26 18:12:31 2012 +0200
@@ -471,6 +471,136 @@
     finally:
         lock.release()
 
+def _commitfiltered(repo, ctx, match):
+    """Recommit ctx with changed files not in match. Return the new
+    node identifier, or None if nothing changed.
+    """
+    base = ctx.p1()
+    m, a, r = repo.status(base, ctx)[:3]
+    allfiles = set(m + a + r)
+    files = set(f for f in allfiles if not match(f))
+    if files == allfiles:
+        return None
+
+    # Filter copies
+    copied = copies.pathcopies(base, ctx)
+    copied = dict((src, dst) for src, dst in copied.iteritems()
+                  if dst in files)
+    def filectxfn(repo, memctx, path):
+        if path not in ctx:
+            raise IOError()
+        fctx = ctx[path]
+        flags = fctx.flags()
+        mctx = context.memfilectx(fctx.path(), fctx.data(),
+                                  islink='l' in flags,
+                                  isexec='x' in flags,
+                                  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())
+    # commitctx always create a new revision, no need to check
+    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())
+    m, a, r = repo.status(oldctx.p1(), oldctx, match=match)[:3]
+    for f in m:
+        if ds[f] == 'r':
+            # modified + removed -> removed
+            continue
+        ds.normallookup(f)
+
+    for f in a:
+        if ds[f] == 'r':
+            # added + removed -> unknown
+            ds.drop(f)
+        elif ds[f] != 'a':
+            ds.add(f)
+
+    for f in r:
+        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 (m + a):
+        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',
+    [] + commands.walkopts,
+    _('[OPTION]... [NAME]'))
+def uncommit(ui, repo, *pats, **opts):
+    """move changes from parent revision to working directory
+
+    Changes to selected files in parent revision appear again as
+    uncommitted changed in the working directory. A new revision
+    without selected changes is created, becomes the new parent and
+    obsoletes the previous one.
+
+    The --include option specify pattern to uncommit
+    The --exclude option specify pattern to keep in the commit
+
+    Return 0 if changed files are uncommitted.
+    """
+    lock = repo.lock()
+    try:
+        wlock = repo.wlock()
+        try:
+            wctx = repo[None]
+            if len(wctx.parents()) <= 0:
+                raise util.Abort(_("cannot uncommit null changeset"))
+            if len(wctx.parents()) > 1:
+                raise util.Abort(_("cannot uncommit while merging"))
+            old = repo['.']
+            if old.phase() == phases.public:
+                raise util.Abort(_("cannot rewrite immutable changeset"))
+            if len(old.parents()) > 1:
+                raise util.Abort(_("cannot uncommit merge changeset"))
+            oldphase = old.phase()
+            # Recommit the filtered changeset
+            newid = None
+            if pats or opts.get('include') or opts.get('exclude'):
+                match = scmutil.match(old, pats, opts)
+                newid = _commitfiltered(repo, old, match)
+            if newid is None:
+                raise util.Abort(_('nothing to uncommit'))
+            # Move local changes on filtered changeset
+            repo.addobsolete(newid, old.node())
+            phases.retractboundary(repo, oldphase, [newid])
+            repo.dirstate.setparents(newid, node.nullid)
+            _uncommitdirstate(repo, old, match)
+        finally:
+            wlock.release()
+    finally:
+        lock.release()
+
 def commitwrapper(orig, ui, repo, *arg, **kwargs):
     lock = repo.lock()
     try:
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test-uncommit.t	Tue Jun 26 18:12:31 2012 +0200
@@ -0,0 +1,252 @@
+  $ cat >> $HGRCPATH <<EOF
+  > [extensions]
+  > hgext.rebase=
+  > hgext.graphlog=
+  > EOF
+  $ echo "obsolete=$(echo $(dirname $TESTDIR))/hgext/obsolete.py" >> $HGRCPATH
+  $ echo "evolve=$(echo $(dirname $TESTDIR))/hgext/evolve.py" >> $HGRCPATH
+
+  $ glog() {
+  >   hg glog --template '{rev}:{node|short}@{branch}({obsolete}/{phase}) {desc|firstline}\n' "$@"
+  > }
+
+  $ hg init repo
+  $ cd repo
+
+Cannot uncommit null changeset
+
+  $ hg uncommit
+  abort: cannot rewrite immutable changeset
+  [255]
+
+Cannot uncommit public changeset
+
+  $ echo a > a
+  $ hg ci -Am adda a
+  $ hg phase --public .
+  $ hg uncommit
+  abort: cannot rewrite immutable changeset
+  [255]
+  $ hg phase --force --draft .
+
+Cannot uncommit merge
+
+  $ hg up -q null
+  $ echo b > b
+  $ echo c > c
+  $ echo d > d
+  $ echo f > f
+  $ echo g > g
+  $ echo j > j
+  $ echo m > m
+  $ echo n > n
+  $ echo o > o
+  $ hg ci -Am addmore
+  adding b
+  adding c
+  adding d
+  adding f
+  adding g
+  adding j
+  adding m
+  adding n
+  adding o
+  created new head
+  $ hg merge
+  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 ci -m merge
+  $ hg uncommit
+  abort: cannot uncommit merge changeset
+  [255]
+
+Prepare complicated changeset
+
+  $ hg branch bar
+  marked working directory as branch bar
+  (branches are permanent and global, did you want a bookmark?)
+  $ hg cp a aa
+  $ echo b >> b
+  $ hg rm c
+  $ echo d >> d
+  $ echo e > e
+  $ hg mv f ff
+  $ hg mv g h
+  $ echo j >> j
+  $ echo k > k
+  $ echo l > l
+  $ hg rm m
+  $ hg rm n
+  $ echo o >> o
+  $ hg ci -Am touncommit
+  adding e
+  adding k
+  adding l
+  $ hg st --copies --change .
+  M b
+  M d
+  M j
+  M o
+  A aa
+    a
+  A e
+  A ff
+    f
+  A h
+    g
+  A k
+  A l
+  R c
+  R f
+  R g
+  R m
+  R n
+  $ hg man -r .
+  a
+  aa
+  b
+  d
+  e
+  ff
+  h
+  j
+  k
+  l
+  o
+
+Prepare complicated working directory
+
+  $ hg branch foo
+  marked working directory as branch foo
+  (branches are permanent and global, did you want a bookmark?)
+  $ hg mv ff f
+  $ hg mv h i
+  $ hg rm j
+  $ hg rm k
+  $ echo l >> l
+  $ echo m > m
+  $ echo o > o
+
+Test uncommit without argument, should be a no-op
+
+  $ hg uncommit
+  abort: nothing to uncommit
+  [255]
+
+Test no matches
+
+  $ hg uncommit --include nothere
+  abort: nothing to uncommit
+  [255]
+
+Enjoy uncommit
+
+  $ hg uncommit aa b c f ff g h j k l m o
+  $ hg branch
+  foo
+  $ hg st --copies
+  M b
+  A aa
+    a
+  A i
+    g
+  A l
+  R c
+  R g
+  R j
+  R m
+  $ cat aa
+  a
+  $ cat b
+  b
+  b
+  $ cat l
+  l
+  l
+  $ cat m
+  m
+  $ test -f c && echo 'error: c was removed!'
+  [1]
+  $ test -f j && echo 'error: j was removed!'
+  [1]
+  $ test -f k && echo 'error: k was removed!'
+  [1]
+  $ hg st --copies --change .
+  M d
+  A e
+  R n
+  $ hg man -r .
+  a
+  b
+  c
+  d
+  e
+  f
+  g
+  j
+  m
+  o
+  $ hg cat -r . d
+  d
+  d
+  $ hg cat -r . e
+  e
+  $ glog --hidden
+  @  4:e8db4aa611f6@bar(stable/draft) touncommit
+  |
+  | o  3:5eb72dbe0cb4@bar(extinct/secret) touncommit
+  |/
+  o    2:f63b90038565@default(stable/draft) merge
+  |\
+  | o  1:f15c744d48e8@default(stable/draft) addmore
+  |
+  o  0:07f494440405@default(stable/draft) adda
+  
+  $ hg debugsuccessors
+  5eb72dbe0cb4 e8db4aa611f6
+
+Test phase is preserved, no local changes
+
+  $ hg up -C 3
+  8 files updated, 0 files merged, 1 files removed, 0 files unresolved
+  Working directory parent is obsolete
+  $ hg --config extensions.purge= purge
+  $ hg uncommit -I 'set:added() and e'
+  $ hg st --copies
+  A e
+  $ hg st --copies --change .
+  M b
+  M d
+  M j
+  M o
+  A aa
+  A ff
+    f
+  A h
+    g
+  A k
+  A l
+  R c
+  R f
+  R g
+  R m
+  R n
+  $ glog --hidden
+  @  5:c706fe2c12f8@bar(stable/secret) touncommit
+  |
+  | o  4:e8db4aa611f6@bar(stable/draft) touncommit
+  |/
+  | o  3:5eb72dbe0cb4@bar(extinct/secret) touncommit
+  |/
+  o    2:f63b90038565@default(stable/draft) merge
+  |\
+  | o  1:f15c744d48e8@default(stable/draft) addmore
+  |
+  o  0:07f494440405@default(stable/draft) adda
+  
+  $ hg debugsuccessors
+  5eb72dbe0cb4 c706fe2c12f8
+  5eb72dbe0cb4 e8db4aa611f6