Patchwork [3,of,3,evolve-ext,V2] commands: introduce a new command to edit commit metadata

login
register
mail settings
Submitter Siddharth Agarwal
Date March 20, 2016, 5:02 a.m.
Message ID <6570300a9b764dacb8c5.1458450147@dev666.prn1.facebook.com>
Download mbox | patch
Permalink /patch/13981/
State Changes Requested
Delegated to: Laurent Charignon
Headers show

Comments

Siddharth Agarwal - March 20, 2016, 5:02 a.m.
# HG changeset patch
# User Siddharth Agarwal <sid0@fb.com>
# Date 1458450093 25200
#      Sat Mar 19 22:01:33 2016 -0700
# Branch stable
# Node ID 6570300a9b764dacb8c5551aebb55251153787c1
# Parent  62cee591d6b306cfa8d1f30acbcc4cae972d79ff
commands: introduce a new command to edit commit metadata

This patch introduces metaedit, a command to metadata of a set of revisions
without updating working copy. It also supports folding a stack in the same
operation. This is particularly relevant for repository where changing the
parent of the working copy is time-consuming. We could add more stack
manipulation operations to metaedit in the future.

Crucially, it is different from 'hg fold --exact' in that it also allows
'folding' a single commit and rewriting its metadata. This is really useful to
have as a single logical operation, for example while preparing a series of
multiple local changesets that will need to be pushed as a single changeset.
Laurent Charignon - March 20, 2016, 5:12 p.m.
This looks good to me, Pierre-Yves, could you queue it?

Thanks,

Laurent

On 3/19/16, 10:02 PM, "Mercurial-devel on behalf of Siddharth Agarwal"
<mercurial-devel-bounces@mercurial-scm.org on behalf of sid0@fb.com> wrote:

># HG changeset patch
># User Siddharth Agarwal <sid0@fb.com>
># Date 1458450093 25200
>#      Sat Mar 19 22:01:33 2016 -0700
># Branch stable
># Node ID 6570300a9b764dacb8c5551aebb55251153787c1
># Parent  62cee591d6b306cfa8d1f30acbcc4cae972d79ff
>commands: introduce a new command to edit commit metadata
>
>This patch introduces metaedit, a command to metadata of a set of
>revisions
>without updating working copy. It also supports folding a stack in the
>same
>operation. This is particularly relevant for repository where changing the
>parent of the working copy is time-consuming. We could add more stack
>manipulation operations to metaedit in the future.
>
>Crucially, it is different from 'hg fold --exact' in that it also allows
>'folding' a single commit and rewriting its metadata. This is really
>useful to
>have as a single logical operation, for example while preparing a series
>of
>multiple local changesets that will need to be pushed as a single
>changeset.
>
>diff --git a/hgext/evolve.py b/hgext/evolve.py
>--- a/hgext/evolve.py
>+++ b/hgext/evolve.py
>@@ -2997,6 +2997,128 @@ def fold(ui, repo, *revs, **opts):
>     finally:
>         lockmod.release(lock, wlock)
> 
>+@command('^metaedit',
>+         [('r', 'rev', [], _("revisions to edit")),
>+          ('', 'fold', None, _("also fold specified revisions into one"))
>+         ] + commitopts + commitopts2,
>+         _('hg metaedit [OPTION]... [-r] [REV]'))
>+def metaedit(ui, repo, *revs, **opts):
>+    """edit commit information
>+
>+    Edits the commit information for the specified revisions. By
>default, edits
>+    commit information for the working directory parent.
>+
>+    With --fold, also folds multiple revisions into one if necessary. In
>this
>+    case, the given revisions must form a linear unbroken chain.
>+
>+    .. container:: verbose
>+
>+     Some examples:
>+
>+     - Edit the commit message for the working directory parent::
>+
>+         hg metaedit
>+
>+     - Change the username for the working directory parent::
>+
>+         hg metaedit --user 'New User <new-email@example.com>'
>+
>+     - Combine all draft revisions that are ancestors of foo but not of
>@ into
>+       one::
>+
>+         hg metaedit --fold 'draft() and only(foo,@)'
>+
>+       See :hg:`help phases` for more about draft revisions and
>+       :hg:`help revsets` for more about the `draft()` and `only()`
>keywords
>+    """
>+    revs = list(revs)
>+    revs.extend(opts['rev'])
>+    if not revs:
>+        if opts['fold']:
>+            raise error.Abort(_('revisions must be specified with
>--fold'))
>+        revs = ['.']
>+
>+    revs = scmutil.revrange(repo, revs)
>+    if not opts['fold'] and len(revs) > 1:
>+        # TODO: handle the non-fold case with multiple revisions. This is
>+        # somewhat tricky because if we want to edit a series of commits:
>+        #
>+        #   a ---- b ---- c
>+        #
>+        # we need to rewrite a first, then directly rewrite b on top of
>the new
>+        # a, then rewrite c on top of the new b. So we need to handle
>revisions
>+        # in topological order.
>+        raise error.Abort(_('editing multiple revisions without --fold
>is not '
>+                            'currently supported'))
>+
>+    if opts['fold']:
>+        root, head = _foldcheck(repo, revs)
>+    else:
>+        if _willcreatedisallowedunstable(repo, revs):
>+            raise error.Abort(_('cannot edit commit information in the
>middle '
>+                                'of a stack'))
>+        # check above ensures only one revision
>+        root = head = repo[revs.first()]
>+        if not root.mutable():
>+            raise error.Abort(_('cannot edit commit information for
>public '
>+                                'revisions'))
>+
>+    wlock = lock = None
>+    try:
>+        wlock = repo.wlock()
>+        lock = repo.lock()
>+        wctx = repo[None]
>+        p1, p2 = wctx.p1(), wctx.p2()
>+        tr = repo.transaction('metaedit')
>+        newp1 = None
>+        try:
>+            commitopts = opts.copy()
>+            allctx = [repo[r] for r in revs]
>+            targetphase = max(c.phase() for c in allctx)
>+
>+            if commitopts.get('message') or commitopts.get('logfile'):
>+                commitopts['edit'] = False
>+            else:
>+                if opts['fold']:
>+                    msgs = ["HG: This is a fold of %d changesets." %
>len(allctx)]
>+                    msgs += ["HG: Commit message of changeset
>%s.\n\n%s\n" %
>+                             (c.rev(), c.description()) for c in allctx]
>+                else:
>+                    msgs = [head.description()]
>+                commitopts['message'] =  "\n".join(msgs)
>+                commitopts['edit'] = True
>+
>+            # TODO: if the author and message are the same, don't create
>a new
>+            # hash. Right now we create a new hash because the date can
>be
>+            # different.
>+            newid, created = rewrite(repo, root, allctx, head,
>+                                     [root.p1().node(),
>root.p2().node()],
>+                                     commitopts=commitopts)
>+            # Optimization: if the working copy parent is a *head* (not
>root,
>+            # not in between) of a commit or commit series that got
>rewritten,
>+            # just use localrepo.setparents and avoid any working copy
>+            # updates. It's easier to do this if we don't also have to
>worry
>+            # about p2.
>+            if not p2 and head == p1:
>+                newp1 = newid
>+            if created:
>+                phases.retractboundary(repo, tr, targetphase, [newid])
>+                obsolete.createmarkers(repo, [(ctx, (repo[newid],))
>+                                              for ctx in allctx])
>+            else:
>+                ui.status(_("nothing changed\n"))
>+            tr.close()
>+        finally:
>+            tr.release()
>+        if opts['fold']:
>+            ui.status('%i changesets folded\n' % len(revs))
>+        if newp1 is not None:
>+            repo.setparents(newp1)
>+        elif p1.rev() in revs:
>+            hg.update(repo, newid)
>+    finally:
>+        lockmod.release(lock, wlock)
>+
> def _foldcheck(repo, revs):
>     roots = repo.revs('roots(%ld)', revs)
>     if len(roots) > 1:
>diff --git a/tests/test-evolve.t b/tests/test-evolve.t
>--- a/tests/test-evolve.t
>+++ b/tests/test-evolve.t
>@@ -2,6 +2,7 @@
>   > [defaults]
>   > amend=-d "0 0"
>   > fold=-d "0 0"
>+  > metaedit=-d "0 0"
>   > [web]
>   > push_ssl = false
>   > allow_push = *
>@@ -1449,3 +1450,113 @@ Check that dirstate changes are kept at
> 
>   $ hg status newlyadded
>   A newlyadded
>+
>+hg metaedit
>+-----------
>+
>+deliberately leave the working copy with dirty merges so that we know
>there are
>+no updates going on
>+  $ hg update .
>+  abort: outstanding merge conflicts
>+  [255]
>+check that metaedit respects allowunstable
>+  $ hg metaedit '36 + 42' --fold
>+  abort: cannot fold non-linear revisions (multiple roots given)
>+  [255]
>+  $ hg metaedit '36::39 + 41' --fold
>+  abort: cannot fold non-linear revisions (multiple heads given)
>+  [255]
>+  $ hg metaedit -r 0
>+  abort: cannot edit commit information for public revisions
>+  [255]
>+  $ hg metaedit -r 0 --fold
>+  abort: cannot fold public revisions
>+  [255]
>+  $ hg metaedit '18::20' --fold --config
>'experimental.evolution=createmarkers, allnewcommands'
>+  abort: cannot fold chain not ending with a head or with branching
>+  [255]
>+  $ hg metaedit '.^' --config 'experimental.evolution=createmarkers,
>allnewcommands'
>+  abort: cannot edit commit information in the middle of a stack
>+  [255]
>+  $ hg metaedit --fold
>+  abort: revisions must be specified with --fold
>+  [255]
>+  $ hg metaedit --user foobar
>+  $ hg log --template '{rev}: {author}\n' -r '42:' --hidden
>+  42: test
>+  43: foobar
>+  $ hg log --template '{rev}: {author}\n' -r .
>+  43: foobar
>+  $ hg status newlyadded
>+  A newlyadded
>+  $ hg resolve --list
>+  U newfile
>+
>+TODO: support this
>+  $ hg metaedit '.^::.'
>+  abort: editing multiple revisions without --fold is not currently
>supported
>+  [255]
>+
>+  $ HGEDITOR=cat hg metaedit '.^::.' --fold
>+  HG: This is a fold of 2 changesets.
>+  HG: Commit message of changeset 41.
>+  
>+  amended
>+  
>+  HG: Commit message of changeset 43.
>+  
>+  will be evolved safely
>+  
>+  
>+  
>+  HG: Enter commit message.  Lines beginning with 'HG:' are removed.
>+  HG: Leave message empty to abort commit.
>+  HG: --
>+  HG: user: test
>+  HG: branch 'default'
>+  HG: changed a
>+  HG: changed newfile
>+  2 changesets folded
>+
>+  $ glog -r .
>+  @  44:41bf1183869c@default(draft) amended
>+  |
>+
>+no new commit is created here because the date is the same
>+  $ HGEDITOR=cat hg metaedit
>+  amended
>+  
>+  
>+  will be evolved safely
>+  
>+  
>+  HG: Enter commit message.  Lines beginning with 'HG:' are removed.
>+  HG: Leave message empty to abort commit.
>+  HG: --
>+  HG: user: test
>+  HG: branch 'default'
>+  HG: changed a
>+  HG: changed newfile
>+  nothing changed
>+
>+  $ glog -r '.^::.'
>+  @  44:41bf1183869c@default(draft) amended
>+  |
>+  o  36:43c3f5ef149f@default(draft) add uu
>+  |
>+
>+'fold' one commit
>+  $ hg metaedit 39 --fold --user foobar2
>+  1 changesets folded
>+  $ hg log -r 45 --template '{rev}: {author}\n'
>+  45: foobar2
>+
>+TODO: don't create a new commit in this case
>+  $ hg metaedit --config defaults.metaedit=
>+  $ hg log -r '.^::.' --template '{rev}: {desc|firstline}\n'
>+  36: add uu
>+  46: amended
>+  $ hg status newlyadded
>+  A newlyadded
>+  $ hg resolve --list
>+  U newfile
>_______________________________________________
>Mercurial-devel mailing list
>Mercurial-devel@mercurial-scm.org
>https://urldefense.proofpoint.com/v2/url?u=https-3A__www.mercurial-2Dscm.o
>rg_mailman_listinfo_mercurial-2Ddevel&d=CwIGaQ&c=5VD0RTtNlTh3ycd41b3MUw&r=
>qmwlQ6ljsf0--v3ANP53-V-RM6PPUtJ5zK5Y1fStJGg&m=FSfY7sKPV6VVpZYM4__1Weg5GdQl
>A6xM4Iodw_xDSiw&s=qLcbhFHaXd1jharQmxt2HIv801cXlNptmWMmZlBhM98&e=
Pierre-Yves David - March 27, 2016, 10:14 p.m.
On 03/19/2016 10:02 PM, Siddharth Agarwal wrote:
> # HG changeset patch
> # User Siddharth Agarwal <sid0@fb.com>
> # Date 1458450093 25200
> #      Sat Mar 19 22:01:33 2016 -0700
> # Branch stable
> # Node ID 6570300a9b764dacb8c5551aebb55251153787c1
> # Parent  62cee591d6b306cfa8d1f30acbcc4cae972d79ff
> commands: introduce a new command to edit commit metadata

This patches is both a bit too big and a bit too light. I would prefer 
to have a minimal first patches for this command and then add further 
feature (and testing). In addition, I would like the command to support 
multiple revision before we take a --fold flag. I would like to avoid 
just getting `hg fold` under another name here.

I would expect to see something similar to:
- patch 1: add a `hg metaedit` command,
- patch 2: allow multiple revisions with `hg metaedit`,
- patch 3: add a --fold flag,

> This patch introduces metaedit, a command to metadata of a set of revisions
> without updating working copy. It also supports folding a stack in the same
> operation. This is particularly relevant for repository where changing the
> parent of the working copy is time-consuming. We could add more stack
> manipulation operations to metaedit in the future.
>
> Crucially, it is different from 'hg fold --exact' in that it also allows
> 'folding' a single commit and rewriting its metadata. This is really useful to
> have as a single logical operation, for example while preparing a series of
> multiple local changesets that will need to be pushed as a single changeset.
>
> diff --git a/hgext/evolve.py b/hgext/evolve.py
> --- a/hgext/evolve.py
> +++ b/hgext/evolve.py
> @@ -2997,6 +2997,128 @@ def fold(ui, repo, *revs, **opts):
>       finally:
>           lockmod.release(lock, wlock)
>
> +@command('^metaedit',

We probably don't want to (yep, evolve is a bit a mess here, I'm trying 
to put some order in that)

> +         [('r', 'rev', [], _("revisions to edit")),
> +          ('', 'fold', None, _("also fold specified revisions into one"))
> +         ] + commitopts + commitopts2,
> +         _('hg metaedit [OPTION]... [-r] [REV]'))
> +def metaedit(ui, repo, *revs, **opts):
> +    """edit commit information
> +
> +    Edits the commit information for the specified revisions. By default, edits
> +    commit information for the working directory parent.
> +
> +    With --fold, also folds multiple revisions into one if necessary. In this
> +    case, the given revisions must form a linear unbroken chain.
> +
> +    .. container:: verbose
> +
> +     Some examples:
> +
> +     - Edit the commit message for the working directory parent::
> +
> +         hg metaedit
> +
> +     - Change the username for the working directory parent::
> +
> +         hg metaedit --user 'New User <new-email@example.com>'
> +
> +     - Combine all draft revisions that are ancestors of foo but not of @ into
> +       one::
> +
> +         hg metaedit --fold 'draft() and only(foo,@)'
> +
> +       See :hg:`help phases` for more about draft revisions and
> +       :hg:`help revsets` for more about the `draft()` and `only()` keywords
> +    """
> +    revs = list(revs)
> +    revs.extend(opts['rev'])
> +    if not revs:
> +        if opts['fold']:
> +            raise error.Abort(_('revisions must be specified with --fold'))
> +        revs = ['.']
> +
> +    revs = scmutil.revrange(repo, revs)
> +    if not opts['fold'] and len(revs) > 1:
> +        # TODO: handle the non-fold case with multiple revisions. This is
> +        # somewhat tricky because if we want to edit a series of commits:
> +        #
> +        #   a ---- b ---- c
> +        #
> +        # we need to rewrite a first, then directly rewrite b on top of the new
> +        # a, then rewrite c on top of the new b. So we need to handle revisions
> +        # in topological order.
> +        raise error.Abort(_('editing multiple revisions without --fold is not '
> +                            'currently supported'))

I would be happy to see this lifted. It looks like sorting the revision 
should be sufficient to get them on topological order, and a small 
mapping to forward parent. Am I missing someting?

> +    if opts['fold']:
> +        root, head = _foldcheck(repo, revs)
> +    else:
> +        if _willcreatedisallowedunstable(repo, revs):
> +            raise error.Abort(_('cannot edit commit information in the middle '
> +                                'of a stack'))

If you can add information about a problematic node, that would help the 
user to debug the situation

> +        # check above ensures only one revision
> +        root = head = repo[revs.first()]
> +        if not root.mutable():

Directly check repo.revs("%lr and public()", revs) That will be simpler 
and faster.

> +            raise error.Abort(_('cannot edit commit information for public '
> +                                'revisions'))
> +
> +    wlock = lock = None
> +    try:
> +        wlock = repo.wlock()
> +        lock = repo.lock()

The repository is locked too late. We should compute the target and 
validate its content (especially the unstable detection).

> +        wctx = repo[None]
> +        p1, p2 = wctx.p1(), wctx.p2()
> +        tr = repo.transaction('metaedit')
> +        newp1 = None
> +        try:
> +            commitopts = opts.copy()
> +            allctx = [repo[r] for r in revs]
> +            targetphase = max(c.phase() for c in allctx)
> +
> +            if commitopts.get('message') or commitopts.get('logfile'):
> +                commitopts['edit'] = False
> +            else:
> +                if opts['fold']:
> +                    msgs = ["HG: This is a fold of %d changesets." % len(allctx)]
> +                    msgs += ["HG: Commit message of changeset %s.\n\n%s\n" %
> +                             (c.rev(), c.description()) for c in allctx]
> +                else:
> +                    msgs = [head.description()]
> +                commitopts['message'] =  "\n".join(msgs)
> +                commitopts['edit'] = True
> +
> +            # TODO: if the author and message are the same, don't create a new
> +            # hash. Right now we create a new hash because the date can be
> +            # different.
> +            newid, created = rewrite(repo, root, allctx, head,
> +                                     [root.p1().node(), root.p2().node()],
> +                                     commitopts=commitopts)
> +            # Optimization: if the working copy parent is a *head* (not root,
> +            # not in between) of a commit or commit series that got rewritten,
> +            # just use localrepo.setparents and avoid any working copy
> +            # updates. It's easier to do this if we don't also have to worry
> +            # about p2.

Why do we need this? isn't and update good enough?

> +            if not p2 and head == p1:
> +                newp1 = newid
> +            if created:
> +                phases.retractboundary(repo, tr, targetphase, [newid])
> +                obsolete.createmarkers(repo, [(ctx, (repo[newid],))
> +                                              for ctx in allctx])
> +            else:
> +                ui.status(_("nothing changed\n"))
> +            tr.close()
> +        finally:
> +            tr.release()
> +        if opts['fold']:
> +            ui.status('%i changesets folded\n' % len(revs))
> +        if newp1 is not None:
> +            repo.setparents(newp1)
> +        elif p1.rev() in revs:
> +            hg.update(repo, newid)
> +    finally:
> +        lockmod.release(lock, wlock)
> +
>   def _foldcheck(repo, revs):
>       roots = repo.revs('roots(%ld)', revs)
>       if len(roots) > 1:
Siddharth Agarwal - April 15, 2016, 8:14 p.m.
Following up on this --

On 3/27/16 15:14, Pierre-Yves David wrote:

> On 03/19/2016 10:02 PM, Siddharth Agarwal wrote:
>> # HG changeset patch
>> # User Siddharth Agarwal <sid0@fb.com>
>> # Date 1458450093 25200
>> #      Sat Mar 19 22:01:33 2016 -0700
>> # Branch stable
>> # Node ID 6570300a9b764dacb8c5551aebb55251153787c1
>> # Parent  62cee591d6b306cfa8d1f30acbcc4cae972d79ff
>> commands: introduce a new command to edit commit metadata
>
> This patches is both a bit too big and a bit too light. I would prefer 
> to have a minimal first patches for this command and then add further 
> feature (and testing). In addition, I would like the command to 
> support multiple revision before we take a --fold flag. I would like 
> to avoid just getting `hg fold` under another name here.
>
> I would expect to see something similar to:
> - patch 1: add a `hg metaedit` command,
> - patch 2: allow multiple revisions with `hg metaedit`,

I'm really not inclined to do this right now -- can I do something like 
"accept multiple revisions with `hg metaedit` but abort"?

> - patch 3: add a --fold flag,
>
>> This patch introduces metaedit, a command to metadata of a set of 
>> revisions
>> without updating working copy. It also supports folding a stack in 
>> the same
>> operation. This is particularly relevant for repository where 
>> changing the
>> parent of the working copy is time-consuming. We could add more stack
>> manipulation operations to metaedit in the future.
>>
>> Crucially, it is different from 'hg fold --exact' in that it also allows
>> 'folding' a single commit and rewriting its metadata. This is really 
>> useful to
>> have as a single logical operation, for example while preparing a 
>> series of
>> multiple local changesets that will need to be pushed as a single 
>> changeset.
>>
>> diff --git a/hgext/evolve.py b/hgext/evolve.py
>> --- a/hgext/evolve.py
>> +++ b/hgext/evolve.py
>> @@ -2997,6 +2997,128 @@ def fold(ui, repo, *revs, **opts):
>>       finally:
>>           lockmod.release(lock, wlock)
>>
>> +@command('^metaedit',
>
> We probably don't want to (yep, evolve is a bit a mess here, I'm 
> trying to put some order in that)

We probably don't want to what, precisely?

>
>> +         [('r', 'rev', [], _("revisions to edit")),
>> +          ('', 'fold', None, _("also fold specified revisions into 
>> one"))
>> +         ] + commitopts + commitopts2,
>> +         _('hg metaedit [OPTION]... [-r] [REV]'))
>> +def metaedit(ui, repo, *revs, **opts):
>> +    """edit commit information
>> +
>> +    Edits the commit information for the specified revisions. By 
>> default, edits
>> +    commit information for the working directory parent.
>> +
>> +    With --fold, also folds multiple revisions into one if 
>> necessary. In this
>> +    case, the given revisions must form a linear unbroken chain.
>> +
>> +    .. container:: verbose
>> +
>> +     Some examples:
>> +
>> +     - Edit the commit message for the working directory parent::
>> +
>> +         hg metaedit
>> +
>> +     - Change the username for the working directory parent::
>> +
>> +         hg metaedit --user 'New User <new-email@example.com>'
>> +
>> +     - Combine all draft revisions that are ancestors of foo but not 
>> of @ into
>> +       one::
>> +
>> +         hg metaedit --fold 'draft() and only(foo,@)'
>> +
>> +       See :hg:`help phases` for more about draft revisions and
>> +       :hg:`help revsets` for more about the `draft()` and `only()` 
>> keywords
>> +    """
>> +    revs = list(revs)
>> +    revs.extend(opts['rev'])
>> +    if not revs:
>> +        if opts['fold']:
>> +            raise error.Abort(_('revisions must be specified with 
>> --fold'))
>> +        revs = ['.']
>> +
>> +    revs = scmutil.revrange(repo, revs)
>> +    if not opts['fold'] and len(revs) > 1:
>> +        # TODO: handle the non-fold case with multiple revisions. 
>> This is
>> +        # somewhat tricky because if we want to edit a series of 
>> commits:
>> +        #
>> +        #   a ---- b ---- c
>> +        #
>> +        # we need to rewrite a first, then directly rewrite b on top 
>> of the new
>> +        # a, then rewrite c on top of the new b. So we need to 
>> handle revisions
>> +        # in topological order.
>> +        raise error.Abort(_('editing multiple revisions without 
>> --fold is not '
>> +                            'currently supported'))
>
> I would be happy to see this lifted. It looks like sorting the 
> revision should be sufficient to get them on topological order, and a 
> small mapping to forward parent. Am I missing someting?

There's also other UI issues like how to accept the commit message for 
multiple revisions, etc. That's why I'm inclined to not deal with this 
problem right now.

>
>> +    if opts['fold']:
>> +        root, head = _foldcheck(repo, revs)
>> +    else:
>> +        if _willcreatedisallowedunstable(repo, revs):
>> +            raise error.Abort(_('cannot edit commit information in 
>> the middle '
>> +                                'of a stack'))
>
> If you can add information about a problematic node, that would help 
> the user to debug the situation

OK -- but this code is copied verbatim from other places where we do 
similar checks. We'll probably need to do those sorts of fixups anyway.

>
>> +        # check above ensures only one revision
>> +        root = head = repo[revs.first()]
>> +        if not root.mutable():
>
> Directly check repo.revs("%lr and public()", revs) That will be 
> simpler and faster.

OK.

>
>> +            raise error.Abort(_('cannot edit commit information for 
>> public '
>> +                                'revisions'))
>> +
>> +    wlock = lock = None
>> +    try:
>> +        wlock = repo.wlock()
>> +        lock = repo.lock()
>
> The repository is locked too late. We should compute the target and 
> validate its content (especially the unstable detection).

Good point -- though a lot of other code is also buggy I suspect.

>
>> +        wctx = repo[None]
>> +        p1, p2 = wctx.p1(), wctx.p2()
>> +        tr = repo.transaction('metaedit')
>> +        newp1 = None
>> +        try:
>> +            commitopts = opts.copy()
>> +            allctx = [repo[r] for r in revs]
>> +            targetphase = max(c.phase() for c in allctx)
>> +
>> +            if commitopts.get('message') or commitopts.get('logfile'):
>> +                commitopts['edit'] = False
>> +            else:
>> +                if opts['fold']:
>> +                    msgs = ["HG: This is a fold of %d changesets." % 
>> len(allctx)]
>> +                    msgs += ["HG: Commit message of changeset 
>> %s.\n\n%s\n" %
>> +                             (c.rev(), c.description()) for c in 
>> allctx]
>> +                else:
>> +                    msgs = [head.description()]
>> +                commitopts['message'] =  "\n".join(msgs)
>> +                commitopts['edit'] = True
>> +
>> +            # TODO: if the author and message are the same, don't 
>> create a new
>> +            # hash. Right now we create a new hash because the date 
>> can be
>> +            # different.
>> +            newid, created = rewrite(repo, root, allctx, head,
>> +                                     [root.p1().node(), 
>> root.p2().node()],
>> +                                     commitopts=commitopts)
>> +            # Optimization: if the working copy parent is a *head* 
>> (not root,
>> +            # not in between) of a commit or commit series that got 
>> rewritten,
>> +            # just use localrepo.setparents and avoid any working copy
>> +            # updates. It's easier to do this if we don't also have 
>> to worry
>> +            # about p2.
>
> Why do we need this? isn't and update good enough?

It probably is -- I'll get rid of this.

>
>> +            if not p2 and head == p1:
>> +                newp1 = newid
>> +            if created:
>> +                phases.retractboundary(repo, tr, targetphase, [newid])
>> +                obsolete.createmarkers(repo, [(ctx, (repo[newid],))
>> +                                              for ctx in allctx])
>> +            else:
>> +                ui.status(_("nothing changed\n"))
>> +            tr.close()
>> +        finally:
>> +            tr.release()
>> +        if opts['fold']:
>> +            ui.status('%i changesets folded\n' % len(revs))
>> +        if newp1 is not None:
>> +            repo.setparents(newp1)
>> +        elif p1.rev() in revs:
>> +            hg.update(repo, newid)
>> +    finally:
>> +        lockmod.release(lock, wlock)
>> +
>>   def _foldcheck(repo, revs):
>>       roots = repo.revs('roots(%ld)', revs)
>>       if len(roots) > 1:
>

Patch

diff --git a/hgext/evolve.py b/hgext/evolve.py
--- a/hgext/evolve.py
+++ b/hgext/evolve.py
@@ -2997,6 +2997,128 @@  def fold(ui, repo, *revs, **opts):
     finally:
         lockmod.release(lock, wlock)
 
+@command('^metaedit',
+         [('r', 'rev', [], _("revisions to edit")),
+          ('', 'fold', None, _("also fold specified revisions into one"))
+         ] + commitopts + commitopts2,
+         _('hg metaedit [OPTION]... [-r] [REV]'))
+def metaedit(ui, repo, *revs, **opts):
+    """edit commit information
+
+    Edits the commit information for the specified revisions. By default, edits
+    commit information for the working directory parent.
+
+    With --fold, also folds multiple revisions into one if necessary. In this
+    case, the given revisions must form a linear unbroken chain.
+
+    .. container:: verbose
+
+     Some examples:
+
+     - Edit the commit message for the working directory parent::
+
+         hg metaedit
+
+     - Change the username for the working directory parent::
+
+         hg metaedit --user 'New User <new-email@example.com>'
+
+     - Combine all draft revisions that are ancestors of foo but not of @ into
+       one::
+
+         hg metaedit --fold 'draft() and only(foo,@)'
+
+       See :hg:`help phases` for more about draft revisions and
+       :hg:`help revsets` for more about the `draft()` and `only()` keywords
+    """
+    revs = list(revs)
+    revs.extend(opts['rev'])
+    if not revs:
+        if opts['fold']:
+            raise error.Abort(_('revisions must be specified with --fold'))
+        revs = ['.']
+
+    revs = scmutil.revrange(repo, revs)
+    if not opts['fold'] and len(revs) > 1:
+        # TODO: handle the non-fold case with multiple revisions. This is
+        # somewhat tricky because if we want to edit a series of commits:
+        #
+        #   a ---- b ---- c
+        #
+        # we need to rewrite a first, then directly rewrite b on top of the new
+        # a, then rewrite c on top of the new b. So we need to handle revisions
+        # in topological order.
+        raise error.Abort(_('editing multiple revisions without --fold is not '
+                            'currently supported'))
+
+    if opts['fold']:
+        root, head = _foldcheck(repo, revs)
+    else:
+        if _willcreatedisallowedunstable(repo, revs):
+            raise error.Abort(_('cannot edit commit information in the middle '
+                                'of a stack'))
+        # check above ensures only one revision
+        root = head = repo[revs.first()]
+        if not root.mutable():
+            raise error.Abort(_('cannot edit commit information for public '
+                                'revisions'))
+
+    wlock = lock = None
+    try:
+        wlock = repo.wlock()
+        lock = repo.lock()
+        wctx = repo[None]
+        p1, p2 = wctx.p1(), wctx.p2()
+        tr = repo.transaction('metaedit')
+        newp1 = None
+        try:
+            commitopts = opts.copy()
+            allctx = [repo[r] for r in revs]
+            targetphase = max(c.phase() for c in allctx)
+
+            if commitopts.get('message') or commitopts.get('logfile'):
+                commitopts['edit'] = False
+            else:
+                if opts['fold']:
+                    msgs = ["HG: This is a fold of %d changesets." % len(allctx)]
+                    msgs += ["HG: Commit message of changeset %s.\n\n%s\n" %
+                             (c.rev(), c.description()) for c in allctx]
+                else:
+                    msgs = [head.description()]
+                commitopts['message'] =  "\n".join(msgs)
+                commitopts['edit'] = True
+
+            # TODO: if the author and message are the same, don't create a new
+            # hash. Right now we create a new hash because the date can be
+            # different.
+            newid, created = rewrite(repo, root, allctx, head,
+                                     [root.p1().node(), root.p2().node()],
+                                     commitopts=commitopts)
+            # Optimization: if the working copy parent is a *head* (not root,
+            # not in between) of a commit or commit series that got rewritten,
+            # just use localrepo.setparents and avoid any working copy
+            # updates. It's easier to do this if we don't also have to worry
+            # about p2.
+            if not p2 and head == p1:
+                newp1 = newid
+            if created:
+                phases.retractboundary(repo, tr, targetphase, [newid])
+                obsolete.createmarkers(repo, [(ctx, (repo[newid],))
+                                              for ctx in allctx])
+            else:
+                ui.status(_("nothing changed\n"))
+            tr.close()
+        finally:
+            tr.release()
+        if opts['fold']:
+            ui.status('%i changesets folded\n' % len(revs))
+        if newp1 is not None:
+            repo.setparents(newp1)
+        elif p1.rev() in revs:
+            hg.update(repo, newid)
+    finally:
+        lockmod.release(lock, wlock)
+
 def _foldcheck(repo, revs):
     roots = repo.revs('roots(%ld)', revs)
     if len(roots) > 1:
diff --git a/tests/test-evolve.t b/tests/test-evolve.t
--- a/tests/test-evolve.t
+++ b/tests/test-evolve.t
@@ -2,6 +2,7 @@ 
   > [defaults]
   > amend=-d "0 0"
   > fold=-d "0 0"
+  > metaedit=-d "0 0"
   > [web]
   > push_ssl = false
   > allow_push = *
@@ -1449,3 +1450,113 @@  Check that dirstate changes are kept at 
 
   $ hg status newlyadded
   A newlyadded
+
+hg metaedit
+-----------
+
+deliberately leave the working copy with dirty merges so that we know there are
+no updates going on
+  $ hg update .
+  abort: outstanding merge conflicts
+  [255]
+check that metaedit respects allowunstable
+  $ hg metaedit '36 + 42' --fold
+  abort: cannot fold non-linear revisions (multiple roots given)
+  [255]
+  $ hg metaedit '36::39 + 41' --fold
+  abort: cannot fold non-linear revisions (multiple heads given)
+  [255]
+  $ hg metaedit -r 0
+  abort: cannot edit commit information for public revisions
+  [255]
+  $ hg metaedit -r 0 --fold
+  abort: cannot fold public revisions
+  [255]
+  $ hg metaedit '18::20' --fold --config 'experimental.evolution=createmarkers, allnewcommands'
+  abort: cannot fold chain not ending with a head or with branching
+  [255]
+  $ hg metaedit '.^' --config 'experimental.evolution=createmarkers, allnewcommands'
+  abort: cannot edit commit information in the middle of a stack
+  [255]
+  $ hg metaedit --fold
+  abort: revisions must be specified with --fold
+  [255]
+  $ hg metaedit --user foobar
+  $ hg log --template '{rev}: {author}\n' -r '42:' --hidden
+  42: test
+  43: foobar
+  $ hg log --template '{rev}: {author}\n' -r .
+  43: foobar
+  $ hg status newlyadded
+  A newlyadded
+  $ hg resolve --list
+  U newfile
+
+TODO: support this
+  $ hg metaedit '.^::.'
+  abort: editing multiple revisions without --fold is not currently supported
+  [255]
+
+  $ HGEDITOR=cat hg metaedit '.^::.' --fold
+  HG: This is a fold of 2 changesets.
+  HG: Commit message of changeset 41.
+  
+  amended
+  
+  HG: Commit message of changeset 43.
+  
+  will be evolved safely
+  
+  
+  
+  HG: Enter commit message.  Lines beginning with 'HG:' are removed.
+  HG: Leave message empty to abort commit.
+  HG: --
+  HG: user: test
+  HG: branch 'default'
+  HG: changed a
+  HG: changed newfile
+  2 changesets folded
+
+  $ glog -r .
+  @  44:41bf1183869c@default(draft) amended
+  |
+
+no new commit is created here because the date is the same
+  $ HGEDITOR=cat hg metaedit
+  amended
+  
+  
+  will be evolved safely
+  
+  
+  HG: Enter commit message.  Lines beginning with 'HG:' are removed.
+  HG: Leave message empty to abort commit.
+  HG: --
+  HG: user: test
+  HG: branch 'default'
+  HG: changed a
+  HG: changed newfile
+  nothing changed
+
+  $ glog -r '.^::.'
+  @  44:41bf1183869c@default(draft) amended
+  |
+  o  36:43c3f5ef149f@default(draft) add uu
+  |
+
+'fold' one commit
+  $ hg metaedit 39 --fold --user foobar2
+  1 changesets folded
+  $ hg log -r 45 --template '{rev}: {author}\n'
+  45: foobar2
+
+TODO: don't create a new commit in this case
+  $ hg metaedit --config defaults.metaedit=
+  $ hg log -r '.^::.' --template '{rev}: {desc|firstline}\n'
+  36: add uu
+  46: amended
+  $ hg status newlyadded
+  A newlyadded
+  $ hg resolve --list
+  U newfile