diff --git a/hgext/uncommit.py b/hgext/uncommit.py --- a/hgext/uncommit.py +++ b/hgext/uncommit.py @@ -28,11 +28,14 @@ copies, error, node, + obsolete, obsutil, + patch, pycompat, registrar, rewriteutil, scmutil, + util, ) cmdtable = {} @@ -45,6 +48,8 @@ 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 @@ -135,8 +140,107 @@ 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', - [('', 'keep', False, _('allow an empty commit after uncommiting')), + [('i', 'interactive', False, _('interactive mode to uncommit')), + ('', 'keep', False, _('allow an empty commit after uncommiting')), ] + commands.walkopts, _('[OPTION]... [FILE]...'), helpcategory=command.CATEGORY_CHANGE_MANAGEMENT) @@ -152,6 +256,7 @@ given. """ opts = pycompat.byteskwargs(opts) + interactive = opts.get('interactive') with repo.wlock(), repo.lock(): @@ -167,6 +272,10 @@ 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 @@ -183,8 +292,101 @@ with repo.dirstate.parentchange(): repo.dirstate.setparents(newid, node.nullid) - s = old.p1().status(old, match=match) - _fixdirstate(repo, old, repo[newid], s) + _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""" diff --git a/tests/test-uncommit.t b/tests/test-uncommit.t --- a/tests/test-uncommit.t +++ b/tests/test-uncommit.t @@ -1,6 +1,8 @@ Test uncommit - set up the config $ cat >> $HGRCPATH < [ui] + > interactive = true > [experimental] > evolution.createmarkers=True > evolution.allowunstable=True @@ -34,6 +36,7 @@ options ([+] can be repeated): + -i --interactive interactive mode to uncommit --keep allow an empty commit after uncommiting -I --include PATTERN [+] include names matching the given patterns -X --exclude PATTERN [+] exclude names matching the given patterns @@ -398,3 +401,15 @@ |/ o 0:ea4e33293d4d274a2ba73150733c2612231f398c a 1 +Test for interactive mode + $ hg init repo3 + $ touch x + $ hg add x + $ hg commit -m "added x" + $ hg uncommit -i< y + > EOF + diff --git a/x b/x + new file mode 100644 + examine changes to 'x'? [Ynesfdaq?] y +