Patchwork D5792: uncommit: added interactive mode(issue6062)

login
register
mail settings
Submitter phabricator
Date Feb. 2, 2019, 6:11 p.m.
Message ID <5398f7d4b85ae03a7b294bdf6124c569@localhost.localdomain>
Download mbox | patch
Permalink /patch/38329/
State Not Applicable
Headers show

Comments

phabricator - Feb. 2, 2019, 6:11 p.m.
taapas1128 updated this revision to Diff 13701.

REPOSITORY
  rHG Mercurial

CHANGES SINCE LAST UPDATE
  https://phab.mercurial-scm.org/D5792?vs=13669&id=13701

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

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

CHANGE DETAILS




To: taapas1128, #hg-reviewers
Cc: pulkit, lothiraldan, mercurial-devel

Patch

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 <<EOF
+  > [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<<EOF
+  > y
+  > EOF
+  diff --git a/x b/x
+  new file mode 100644
+  examine changes to 'x'? [Ynesfdaq?] y
+  
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"""