Permalink
Browse files

commit: add option to amend the working dir parent

The --amend flag can be used to amend the parent of the working directory
with a new commit that contains the changes in the parent in addition to
those currently reported by "hg status", if there are any. The old commit
is stored in a backup bundle in ".hg/strip-backup"(see "hg help bundle"
and "hg help unbundle" on how to restore it).

Message, user and date are taken from the amended commit unless specified.
When a message isn't specified on the command line, the editor will open
with the message of the amended commit.

It is not possible to amend public changesets (see "hg help phases") or
changesets that have children.

Behind the scenes, first commit the update (if there is one) as a regular
child of the current parent. Then create a new commit on the parent's
parent with the updated contents. Then change the working copy parent
to this new combined changeset. Finally, strip the amended commit and
update commit created in the beginning.

An alternative (cleaner?) approach of doing this is suggested here:
http://selenic.com/pipermail/mercurial-devel/2012-March/038540.html

It is currently not possible to amend merge commits or recursively,
this can be added at a later time.
  • Loading branch information...
idank committed Apr 17, 2012
1 parent c29b3d2 commit 9591f718ae891b2464bc3214f12e36d7b3eb8a50
Showing with 482 additions and 18 deletions.
  1. +118 −1 mercurial/cmdutil.py
  2. +70 −16 mercurial/commands.py
  3. +292 −0 tests/test-commit-amend.t
  4. +1 −1 tests/test-debugcomplete.t
  5. +1 −0 tests/test-qrecord.t
View
@@ -10,7 +10,7 @@
import os, sys, errno, re, tempfile
import util, scmutil, templater, patch, error, templatekw, revlog, copies
import match as matchmod
-import subrepo
+import subrepo, context, repair, bookmarks
def parsealiases(cmd):
return cmd.lstrip("^").split("|")
@@ -1285,6 +1285,123 @@ def commit(ui, repo, commitfunc, pats, opts):
return commitfunc(ui, repo, message,
scmutil.match(repo[None], pats, opts), opts)
+def amend(ui, repo, commitfunc, old, extra, pats, opts):
+ ui.note(_('amending changeset %s\n') % old)
+ base = old.p1()
+
+ wlock = repo.wlock()
+ try:
+ # Fix up dirstate for copies and renames
+ duplicatecopies(repo, None, base.node())
+
+ # First, do a regular commit to record all changes in the working
+ # directory (if there are any)
+ node = commit(ui, repo, commitfunc, pats, opts)
+ ctx = repo[node]
+
+ # Participating changesets:
+ #
+ # node/ctx o - new (intermediate) commit that contains changes from
+ # | working dir to go into amending commit (or a workingctx
+ # | if there were no changes)
+ # |
+ # old o - changeset to amend
+ # |
+ # base o - parent of amending changeset
+
+ files = set(old.files())
+
+ # Second, we use either the commit we just did, or if there were no
+ # changes the parent of the working directory as the version of the
+ # files in the final amend commit
+ if node:
+ ui.note(_('copying changeset %s to %s\n') % (ctx, base))
+
+ user = ctx.user()
+ date = ctx.date()
+ message = ctx.description()
+ extra = ctx.extra()
+
+ # Prune files which were reverted by the updates: if old introduced
+ # file X and our intermediate commit, node, renamed that file, then
+ # those two files are the same and we can discard X from our list
+ # of files. Likewise if X was deleted, it's no longer relevant
+ files.update(ctx.files())
+
+ def samefile(f):
+ if f in ctx.manifest():
+ a = ctx.filectx(f)
+ if f in base.manifest():
+ b = base.filectx(f)
+ return (a.data() == b.data()
+ and a.flags() == b.flags()
+ and a.renamed() == b.renamed())
+ else:
+ return False
+ else:
+ return f not in base.manifest()
+ files = [f for f in files if not samefile(f)]
+
+ def filectxfn(repo, ctx_, path):
+ try:
+ return ctx.filectx(path)
+ except KeyError:
+ raise IOError()
+ else:
+ ui.note(_('copying changeset %s to %s\n') % (old, base))
+
+ # Use version of files as in the old cset
+ def filectxfn(repo, ctx_, path):
+ try:
+ return old.filectx(path)
+ except KeyError:
+ raise IOError()
+
+ # See if we got a message from -m or -l, if not, open the editor
+ # with the message of the changeset to amend
+ user = opts.get('user') or old.user()
+ date = opts.get('date') or old.date()
+ message = logmessage(ui, opts)
+ if not message:
+ cctx = context.workingctx(repo, old.description(), user, date,
+ extra,
+ repo.status(base.node(), old.node()))
+ message = commitforceeditor(repo, cctx, [])
+
+ new = context.memctx(repo,
+ parents=[base.node(), nullid],
+ text=message,
+ files=files,
+ filectxfn=filectxfn,
+ user=user,
+ date=date,
+ extra=extra)
+ newid = repo.commitctx(new)
+ if newid != old.node():
+ # Reroute the working copy parent to the new changeset
+ repo.dirstate.setparents(newid, nullid)
+
+ # Move bookmarks from old parent to amend commit
+ bms = repo.nodebookmarks(old.node())
+ if bms:
+ for bm in bms:
+ repo._bookmarks[bm] = newid
+ bookmarks.write(repo)
+
+ # Strip the intermediate commit (if there was one) and the amended
+ # commit
+ lock = repo.lock()
+ try:
+ if node:
+ ui.note(_('stripping intermediate changeset %s\n') % ctx)
+ ui.note(_('stripping amended changeset %s\n') % old)
+ repair.strip(ui, repo, old.node(), topic='amend-backup')
+ finally:
+ lock.release()
+ finally:
+ wlock.release()
+ return newid
+
def commiteditor(repo, ctx, subs):
if ctx.description():
return ctx.description()
View
@@ -1163,6 +1163,7 @@ def clone(ui, source, dest=None, **opts):
_('mark new/missing files as added/removed before committing')),
('', 'close-branch', None,
_('mark a branch as closed, hiding it from the branch list')),
+ ('', 'amend', None, _('amend the parent of the working dir')),
] + walkopts + commitopts + commitopts2 + subrepoopts,
_('[OPTION]... [FILE]...'))
def commit(ui, repo, *pats, **opts):
@@ -1183,6 +1184,20 @@ def commit(ui, repo, *pats, **opts):
commit fails, you will find a backup of your message in
``.hg/last-message.txt``.
+ The --amend flag can be used to amend the parent of the
+ working directory with a new commit that contains the changes
+ in the parent in addition to those currently reported by :hg:`status`,
+ if there are any. The old commit is stored in a backup bundle in
+ ``.hg/strip-backup`` (see :hg:`help bundle` and :hg:`help unbundle`
+ on how to restore it).
+
+ Message, user and date are taken from the amended commit unless
+ specified. When a message isn't specified on the command line,
+ the editor will open with the message of the amended commit.
+
+ It is not possible to amend public changesets (see :hg:`help phases`)
+ or changesets that have children.
+
See :hg:`help dates` for a list of formats valid for -d/--date.
Returns 0 on success, 1 if nothing changed.
@@ -1198,31 +1213,70 @@ def commit(ui, repo, *pats, **opts):
# current branch, so it's sufficient to test branchheads
raise util.Abort(_('can only close branch heads'))
extra['close'] = 1
- e = cmdutil.commiteditor
- if opts.get('force_editor'):
- e = cmdutil.commitforceeditor
-
- def commitfunc(ui, repo, message, match, opts):
- return repo.commit(message, opts.get('user'), opts.get('date'), match,
- editor=e, extra=extra)
branch = repo[None].branch()
bheads = repo.branchheads(branch)
- node = cmdutil.commit(ui, repo, commitfunc, pats, opts)
- if not node:
- stat = repo.status(match=scmutil.match(repo[None], pats, opts))
- if stat[3]:
- ui.status(_("nothing changed (%d missing files, see 'hg status')\n")
- % len(stat[3]))
- else:
+ if opts.get('amend'):
+ if ui.config('ui', 'commitsubrepos'):
+ raise util.Abort(_('cannot amend recursively'))
+
+ old = repo['.']
+ if old.phase() == phases.public:
+ raise util.Abort(_('cannot amend public changesets'))
+ if len(old.parents()) > 1:
+ raise util.Abort(_('cannot amend merge changesets'))
+ if len(repo[None].parents()) > 1:
+ raise util.Abort(_('cannot amend while merging'))
+ if old.children():
+ raise util.Abort(_('cannot amend changeset with children'))
+
+ e = cmdutil.commiteditor
+ if opts.get('force_editor'):
+ e = cmdutil.commitforceeditor
+
+ def commitfunc(ui, repo, message, match, opts):
+ editor = e
+ # message contains text from -m or -l, if it's empty,
+ # open the editor with the old message
+ if not message:
+ message = old.description()
+ editor = cmdutil.commitforceeditor
+ return repo.commit(message,
+ opts.get('user') or old.user(),
+ opts.get('date') or old.date(),
+ match,
+ editor=editor,
+ extra=extra)
+
+ node = cmdutil.amend(ui, repo, commitfunc, old, extra, pats, opts)
+ if node == old.node():
ui.status(_("nothing changed\n"))
- return 1
+ return 1
+ else:
+ e = cmdutil.commiteditor
+ if opts.get('force_editor'):
+ e = cmdutil.commitforceeditor
+
+ def commitfunc(ui, repo, message, match, opts):
+ return repo.commit(message, opts.get('user'), opts.get('date'),
+ match, editor=e, extra=extra)
+
+ node = cmdutil.commit(ui, repo, commitfunc, pats, opts)
+
+ if not node:
+ stat = repo.status(match=scmutil.match(repo[None], pats, opts))
+ if stat[3]:
+ ui.status(_("nothing changed (%d missing files, see "
+ "'hg status')\n") % len(stat[3]))
+ else:
+ ui.status(_("nothing changed\n"))
+ return 1
ctx = repo[node]
parents = ctx.parents()
- if (bheads and node not in bheads and not
+ if (not opts.get('amend') and bheads and node not in bheads and not
[x for x in parents if x.node() in bheads and x.branch() == branch]):
ui.status(_('created new head\n'))
# The message is not printed for initial roots. For the other
Oops, something went wrong.

0 comments on commit 9591f71

Please sign in to comment.