|
|
# 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 (
|
|
|
cmdutil,
|
|
|
commands,
|
|
|
context,
|
|
|
copies as copiesmod,
|
|
|
error,
|
|
|
node,
|
|
|
obsolete,
|
|
|
obsutil,
|
|
|
patch,
|
|
|
pycompat,
|
|
|
registrar,
|
|
|
rewriteutil,
|
|
|
scmutil,
|
|
|
util,
|
|
|
)
|
|
|
|
|
|
cmdtable = {}
|
|
|
command = registrar.command(cmdtable)
|
|
|
|
|
|
configtable = {}
|
|
|
configitem = registrar.configitem(configtable)
|
|
|
|
|
|
configitem('experimental', 'uncommitondirtywdir',
|
|
|
default=False,
|
|
|
)
|
|
|
|
|
|
stringio = util.stringio
|
|
|
|
|
|
# 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, keepcommit):
|
|
|
"""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 keepcommit:
|
|
|
return ctx.p1().node()
|
|
|
|
|
|
# Filter copies
|
|
|
copied = copiesmod.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, memctx, 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())
|
|
|
return repo.commitctx(new)
|
|
|
|
|
|
def _fixdirstate(repo, oldctx, newctx, match=None):
|
|
|
""" fix the dirstate after switching the working directory from oldctx to
|
|
|
newctx which can be result of either unamend or uncommit.
|
|
|
"""
|
|
|
ds = repo.dirstate
|
|
|
ds.setparents(newctx.node(), node.nullid)
|
|
|
copies = dict(ds.copies())
|
|
|
s = newctx.status(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 = copiesmod.pathcopies(newctx, oldctx, match)
|
|
|
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 newctx or dst in newctx or ds[dst] != 'a'):
|
|
|
src = None
|
|
|
ds.copy(src, dst)
|
|
|
|
|
|
|
|
|
def _uncommitdirstate(repo, oldctx, match, interactive):
|
|
|
"""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())
|
|
|
if interactive:
|
|
|
# In interactive cases, we will find the status between oldctx and ctx
|
|
|
# and considering only the files which are changed between oldctx and
|
|
|
# ctx, and the status of what changed between oldctx and ctx will help
|
|
|
# us in defining the exact behavior
|
|
|
m, a, r = repo.status(oldctx, ctx, match=match)[:3]
|
|
|
for f in m:
|
|
|
# These are files which are modified between oldctx and ctx which
|
|
|
# contains two cases: 1) Were modified in oldctx and some
|
|
|
# modifications are uncommitted
|
|
|
# 2) Were added in oldctx but some part is uncommitted (this cannot
|
|
|
# contain the case when added files are uncommitted completely as
|
|
|
# that will result in status as removed not modified.)
|
|
|
# Also any modifications to a removed file will result the status as
|
|
|
# added, so we have only two cases. So in either of the cases, the
|
|
|
# resulting status can be modified or clean.
|
|
|
if ds[f] == 'r':
|
|
|
# But the file is removed in the working directory, leaving that
|
|
|
# as removed
|
|
|
continue
|
|
|
ds.normallookup(f)
|
|
|
|
|
|
for f in a:
|
|
|
# These are the files which are added between oldctx and ctx(new
|
|
|
# one), which means the files which were removed in oldctx
|
|
|
# but uncommitted completely while making the ctx
|
|
|
# This file should be marked as removed if the working directory
|
|
|
# does not adds it back. If it's adds it back, we do a normallookup.
|
|
|
# The file can't be removed in working directory, because it was
|
|
|
# removed in oldctx
|
|
|
if ds[f] == 'a':
|
|
|
ds.normallookup(f)
|
|
|
continue
|
|
|
ds.remove(f)
|
|
|
|
|
|
for f in r:
|
|
|
# These are files which are removed between oldctx and ctx, which
|
|
|
# means the files which were added in oldctx and were completely
|
|
|
# uncommitted in ctx. If a added file is partially uncommitted, that
|
|
|
# would have resulted in modified status, not removed.
|
|
|
# So a file added in a commit, and uncommitting that addition must
|
|
|
# result in file being stated as unknown.
|
|
|
if ds[f] == 'r':
|
|
|
# The working directory say it's removed, so lets make the file
|
|
|
# unknown
|
|
|
ds.drop(f)
|
|
|
continue
|
|
|
ds.add(f)
|
|
|
else:
|
|
|
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 = {}
|
|
|
if interactive:
|
|
|
# Interactive had different meaning of the variables so restoring the
|
|
|
# original meaning to use them
|
|
|
m, a, r = repo.status(oldctx.p1(), oldctx, match=match)[:3]
|
|
|
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',
|
|
|
[('i', 'interactive', False, _('interactive mode to uncommit')),
|
|
|
('', 'keep', False, _('allow an empty commit after uncommiting')),
|
|
|
] + commands.walkopts,
|
|
|
_('[OPTION]... [FILE]...'),
|
|
|
helpcategory=command.CATEGORY_CHANGE_MANAGEMENT)
|
|
|
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.
|
|
|
|
|
|
If no files are specified, the commit will be pruned, unless --keep is
|
|
|
given.
|
|
|
"""
|
|
|
opts = pycompat.byteskwargs(opts)
|
|
|
interactive = opts.get('interactive')
|
|
|
|
|
|
with repo.wlock(), repo.lock():
|
|
|
|
|
|
if not pats and not repo.ui.configbool('experimental',
|
|
|
'uncommitondirtywdir'):
|
|
|
cmdutil.bailifchanged(repo)
|
|
|
old = repo['.']
|
|
|
rewriteutil.precheck(repo, [old.rev()], 'uncommit')
|
|
|
if len(old.parents()) > 1:
|
|
|
raise error.Abort(_("cannot uncommit merge changeset"))
|
|
|
|
|
|
with repo.transaction('uncommit'):
|
|
|
match = scmutil.match(old, pats, opts)
|
|
|
keepcommit = opts.get('keep') or pats
|
|
|
newid = _commitfiltered(repo, old, match, keepcommit)
|
|
|
if interactive:
|
|
|
match = scmutil.match(old, pats, opts)
|
|
|
newid = _interactiveuncommit(ui, repo, old, match)
|
|
|
|
|
|
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', fixphase=True)
|
|
|
|
|
|
with repo.dirstate.parentchange():
|
|
|
repo.dirstate.setparents(newid, node.nullid)
|
|
|
_uncommitdirstate(repo, old, match, interactive)
|
|
|
|
|
|
def _interactiveuncommit(ui, repo, old, match):
|
|
|
""" The function which contains all the logic for interactively uncommiting
|
|
|
a commit. This function makes a temporary commit with the chunks which user
|
|
|
selected to uncommit. After that the diff of the parent and that commit is
|
|
|
applied to the working directory and committed again which results in the
|
|
|
new commit which should be one after uncommitted.
|
|
|
"""
|
|
|
|
|
|
# create a temporary commit with hunks user selected
|
|
|
tempnode = _createtempcommit(ui, repo, old, match)
|
|
|
|
|
|
diffopts = patch.difffeatureopts(repo.ui, whitespace=True)
|
|
|
diffopts.nodates = True
|
|
|
diffopts.git = True
|
|
|
fp = stringio()
|
|
|
for chunk, label in patch.diffui(repo, tempnode, old.node(), None,
|
|
|
opts=diffopts):
|
|
|
fp.write(chunk)
|
|
|
|
|
|
fp.seek(0)
|
|
|
newnode = _patchtocommit(ui, repo, old, fp)
|
|
|
# creating obs marker temp -> ()
|
|
|
obsolete.createmarkers(repo, [(repo[tempnode], ())], operation="uncommit")
|
|
|
return newnode
|
|
|
|
|
|
def _createtempcommit(ui, repo, old, match):
|
|
|
""" Creates a temporary commit for `uncommit --interative` which contains
|
|
|
the hunks which were selected by the user to uncommit.
|
|
|
"""
|
|
|
|
|
|
pold = old.p1()
|
|
|
# The logic to interactively selecting something copied from
|
|
|
# cmdutil.revert()
|
|
|
diffopts = patch.difffeatureopts(repo.ui, whitespace=True)
|
|
|
diffopts.nodates = True
|
|
|
diffopts.git = True
|
|
|
diff = patch.diff(repo, pold.node(), old.node(), match, opts=diffopts)
|
|
|
originalchunks = patch.parsepatch(diff)
|
|
|
# XXX: The interactive selection is buggy and does not let you
|
|
|
# uncommit a removed file partially.
|
|
|
# TODO: wrap the operations in mercurial/patch.py and mercurial/crecord.py
|
|
|
# to add uncommit as an operation taking care of BC.
|
|
|
chunks, opts = cmdutil.recordfilter(repo.ui, originalchunks,
|
|
|
operation='discard')
|
|
|
if not chunks:
|
|
|
raise error.Abort(_("nothing selected to uncommit"))
|
|
|
fp = stringio()
|
|
|
for c in chunks:
|
|
|
c.write(fp)
|
|
|
|
|
|
fp.seek(0)
|
|
|
oldnode = node.hex(old.node())[:12]
|
|
|
message = 'temporary commit for uncommiting %s' % oldnode
|
|
|
tempnode = _patchtocommit(ui, repo, old, fp, message, oldnode)
|
|
|
return tempnode
|
|
|
|
|
|
def _patchtocommit(ui, repo, old, fp, message=None, extras=None):
|
|
|
""" A function which will apply the patch to the working directory and
|
|
|
make a commit whose parents are same as that of old argument. The message
|
|
|
argument tells us whether to use the message of the old commit or a
|
|
|
different message which is passed. Returns the node of new commit made.
|
|
|
"""
|
|
|
pold = old.p1()
|
|
|
parents = (old.p1().node(), old.p2().node())
|
|
|
date = old.date()
|
|
|
branch = old.branch()
|
|
|
user = old.user()
|
|
|
extra = old.extra()
|
|
|
if extras:
|
|
|
extra['uncommit_source'] = extras
|
|
|
if not message:
|
|
|
message = old.description()
|
|
|
store = patch.filestore()
|
|
|
try:
|
|
|
files = set()
|
|
|
try:
|
|
|
patch.patchrepo(ui, repo, pold, store, fp, 1, '',
|
|
|
files=files, eolmode=None)
|
|
|
except patch.PatchError as err:
|
|
|
raise error.Abort(str(err))
|
|
|
|
|
|
finally:
|
|
|
del fp
|
|
|
|
|
|
memctx = context.memctx(repo, parents, message, files=files,
|
|
|
filectxfn=store,
|
|
|
user=user,
|
|
|
date=date,
|
|
|
branch=branch,
|
|
|
extra=extra)
|
|
|
newcm = memctx.commit()
|
|
|
finally:
|
|
|
store.close()
|
|
|
return newcm
|
|
|
|
|
|
def predecessormarkers(ctx):
|
|
|
"""yields the obsolete markers marking the given changeset as a successor"""
|
|
|
for data in ctx.repo().obsstore.predecessors.get(ctx.node(), ()):
|
|
|
yield obsutil.marker(ctx.repo(), data)
|
|
|
|
|
|
@command('unamend', [], helpcategory=command.CATEGORY_CHANGE_MANAGEMENT,
|
|
|
helpbasic=True)
|
|
|
def unamend(ui, repo, **opts):
|
|
|
"""undo the most recent amend operation on a current changeset
|
|
|
|
|
|
This command will roll back to the previous version of a changeset,
|
|
|
leaving working directory in state in which it was before running
|
|
|
`hg amend` (e.g. files modified as part of an amend will be
|
|
|
marked as modified `hg status`)
|
|
|
"""
|
|
|
|
|
|
unfi = repo.unfiltered()
|
|
|
with repo.wlock(), repo.lock(), repo.transaction('unamend'):
|
|
|
|
|
|
# identify the commit from which to unamend
|
|
|
curctx = repo['.']
|
|
|
|
|
|
rewriteutil.precheck(repo, [curctx.rev()], 'unamend')
|
|
|
|
|
|
# identify the commit to which to unamend
|
|
|
markers = list(predecessormarkers(curctx))
|
|
|
if len(markers) != 1:
|
|
|
e = _("changeset must have one predecessor, found %i predecessors")
|
|
|
raise error.Abort(e % len(markers))
|
|
|
|
|
|
prednode = markers[0].prednode()
|
|
|
predctx = unfi[prednode]
|
|
|
|
|
|
# add an extra so that we get a new hash
|
|
|
# note: allowing unamend to undo an unamend is an intentional feature
|
|
|
extras = predctx.extra()
|
|
|
extras['unamend_source'] = curctx.hex()
|
|
|
|
|
|
def filectxfn(repo, ctx_, path):
|
|
|
try:
|
|
|
return predctx.filectx(path)
|
|
|
except KeyError:
|
|
|
return None
|
|
|
|
|
|
# Make a new commit same as predctx
|
|
|
newctx = context.memctx(repo,
|
|
|
parents=(predctx.p1(), predctx.p2()),
|
|
|
text=predctx.description(),
|
|
|
files=predctx.files(),
|
|
|
filectxfn=filectxfn,
|
|
|
user=predctx.user(),
|
|
|
date=predctx.date(),
|
|
|
extra=extras)
|
|
|
newprednode = repo.commitctx(newctx)
|
|
|
newpredctx = repo[newprednode]
|
|
|
dirstate = repo.dirstate
|
|
|
|
|
|
with dirstate.parentchange():
|
|
|
_fixdirstate(repo, curctx, newpredctx)
|
|
|
|
|
|
mapping = {curctx.node(): (newprednode,)}
|
|
|
scmutil.cleanupnodes(repo, mapping, 'unamend', fixphase=True)
|
|
|
|