Patchwork D529: uncommit: move fb-extension to core which uncommits a changeset

login
register
mail settings
Submitter phabricator
Date Sept. 15, 2017, 4:03 p.m.
Message ID <6cadc4b00075fa2fd019b1c3d99d50e4@localhost.localdomain>
Download mbox | patch
Permalink /patch/23913/
State Not Applicable
Headers show

Comments

phabricator - Sept. 15, 2017, 4:03 p.m.
This revision was automatically updated to reflect the committed changes.
Closed by commit rHGd01819c8f3c4: uncommit: move fb-extension to core which uncommits a changeset (authored by pulkit, committed by ).

REPOSITORY
  rHG Mercurial

CHANGES SINCE LAST UPDATE
  https://phab.mercurial-scm.org/D529?vs=1823&id=1848

REVISION DETAIL
  https://phab.mercurial-scm.org/D529

AFFECTED FILES
  hgext/uncommit.py
  tests/test-uncommit.t

CHANGE DETAILS




To: pulkit, #hg-reviewers, quark, durham, durin42
Cc: durham, quark, martinvonz, yuja, mercurial-devel
Augie Fackler - Sept. 15, 2017, 9:10 p.m.
We've had some discussion about this on irc. The evolve behavior of preserving empty commits is important in one circumstance: when the commit will briefly be empty, but should retain its identity for the purposes of evolution. Personally, I've never used uncommit and ended up with an empty commit (via the evolve version, which I currently use), so I've got a pretty strong sense that this is an edge case. Here's where we're landing for the time being:

We'll add --keep to uncommit, preserve the current default, but refuse to do no-arguments `uncommit` if the working directory is dirty. This prevents users from accidentally blowing their foot off in the cases that are the most likely to cause pain. This still leaves some difficulty at the margins (how do you uncommit everything if you've got a dirty working directory?), but we can probably overcome that in time. One thing we hope will help there is a generalized `hg undo` command, which it sounds like Facebook has at least partially drafted.

Note that this is not necessarily a final state for uncommit: it's possible that after further experimentation we'll flip the default, or otherwise adjust behavior. It's a complicated balancing act between what's intuitive (which we don't all agree on) and preserving the richest metadata possible.

Thanks,
Augie

> On Sep 15, 2017, at 12:03, pulkit (Pulkit Goyal) <phabricator@mercurial-scm.org> wrote:
> 
> This revision was automatically updated to reflect the committed changes.
> Closed by commit rHGd01819c8f3c4: uncommit: move fb-extension to core which uncommits a changeset (authored by pulkit, committed by ).
> 
> REPOSITORY
>  rHG Mercurial
> 
> CHANGES SINCE LAST UPDATE
>  https://phab.mercurial-scm.org/D529?vs=1823&id=1848
> 
> REVISION DETAIL
>  https://phab.mercurial-scm.org/D529
> 
> AFFECTED FILES
>  hgext/uncommit.py
>  tests/test-uncommit.t
> 
> CHANGE DETAILS
> 
> 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 <<EOF
> +  > [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
> +  
> 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 <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)
> 
> 
> 
> To: pulkit, #hg-reviewers, quark, durham, durin42
> Cc: durham, quark, martinvonz, yuja, mercurial-devel
> _______________________________________________
> Mercurial-devel mailing list
> Mercurial-devel@mercurial-scm.org
> https://www.mercurial-scm.org/mailman/listinfo/mercurial-devel

Patch

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 <<EOF
+  > [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
+  
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 <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)