changeset 6130:cd07d6bd4e2a

cmdrewrite: a new `hg fixup` command This new command can be used to amend specific revisions with working copy changes. Implementation-wise what it basically does is: 1) commit working directory changes 2) relocate the new commit onto the target commit 3) fold them into one. After the run, the working directory parent will be the obsoleted changeset created in step 1 and descendants of the target will become orphans.
author Sushil khanchi <sushilkhanchi97@gmail.com>
date Wed, 03 Nov 2021 22:59:17 +0530
parents d3b77e5ee04e
children 6b619b4edbbe
files hgext3rd/evolve/__init__.py hgext3rd/evolve/cmdrewrite.py tests/test-check-sdist.t tests/test-fixup.t
diffstat 4 files changed, 574 insertions(+), 1 deletions(-) [+]
line wrap: on
line diff
--- a/hgext3rd/evolve/__init__.py	Tue Feb 15 14:26:38 2022 +0300
+++ b/hgext3rd/evolve/__init__.py	Wed Nov 03 22:59:17 2021 +0530
@@ -1165,6 +1165,10 @@
                                abortfunc=evolvecmd.hgabortevolve)
         statemod.addunfinished(b'pick', fname=b'pickstate', continueflag=True,
                                abortfunc=cmdrewrite.hgabortpick)
+        _fixup_msg = _(b'To continue:    hg fixup --continue\n'
+                       b'To abort:       hg fixup --abort\n')
+        statemod.addunfinished(b'fixup', fname=b'fixup_state',
+                               continueflag=True, statushint=_fixup_msg)
     else:
         # hg <= 5.0 (5f2f6912c9e6)
         estate = (b'evolvestate', False, False, _(b'evolve in progress'),
--- a/hgext3rd/evolve/cmdrewrite.py	Tue Feb 15 14:26:38 2022 +0300
+++ b/hgext3rd/evolve/cmdrewrite.py	Wed Nov 03 22:59:17 2021 +0530
@@ -30,6 +30,7 @@
     pycompat,
     scmutil,
     util,
+    repair,
 )
 
 from mercurial.utils import dateutil
@@ -42,6 +43,7 @@
     exthelper,
     rewriteutil,
     utility,
+    evolvecmd,
 )
 
 eh = exthelper.exthelper()
@@ -1442,3 +1444,153 @@
     with repo.wlock(), repo.lock():
         pickstate = state.cmdstate(repo, path=b'pickstate')
         return abortpick(ui, repo, pickstate, abortcmd=True)
+
+@eh.command(
+    b'fixup|fix-up',
+    [
+        (b'r', b'rev', b'', _(b'revision to amend'), _(b'REV')),
+        (b'c', b'continue', False, _(b'continue an interrupted fixup')),
+        (b'', b'abort', False, _(b'abort an interrupted fixup')),
+    ],
+    _(b'[OPTION]... [-r] REV'),
+    helpbasic=True,
+    **compat.helpcategorykwargs('CATEGORY_COMMITTING')
+)
+def fixup(ui, repo, node=None, **opts):
+    """add working directory changes to an arbitrary revision
+
+    A new changeset will be created, superseding the one specified. The new
+    changeset will combine working directory changes with the changes in the
+    target revision.
+
+    This operation requires the working directory changes to be relocated onto
+    the target revision, which might result in merge conflicts.
+
+    If fixup is interrupted to manually resolve a conflict, it can be continued
+    with --continue/-c, or aborted with --abort.
+
+    Note that this command is fairly new and its behavior is still
+    experimental. For example, the working copy will be left on a temporary,
+    obsolete commit containing the fixed-up changes after the operation. This
+    might change in the future.
+
+    Returns 0 on success, 1 if nothing changed.
+    """
+    compat.check_at_most_one_arg(opts, 'continue', 'abort')
+    with repo.wlock(), repo.lock():
+        return _perform_fixup(ui, repo, node, **opts)
+
+def _perform_fixup(ui, repo, node, **opts):
+    contopt = opts.get('continue')
+    abortopt = opts.get('abort')
+    if node or opts.get('rev'):
+        if contopt:
+            raise error.Abort(_(b'cannot specify a revision with --continue'))
+        if abortopt:
+            raise error.Abort(_(b'cannot specify a revision with --abort'))
+    # state file for --continue/--abort cases
+    fixup_state = state.cmdstate(repo, b'fixup_state')
+    if contopt:
+        if not fixup_state.exists():
+            raise error.Abort(_(b'no interrupted fixup to continue'))
+        fixup_state.load()
+        return continuefixup(ui, repo, fixup_state)
+    if abortopt:
+        if not fixup_state.exists():
+            raise error.Abort(_(b'no interrupted fixup to abort'))
+        fixup_state.load()
+        return abortfixup(ui, repo, fixup_state)
+
+    if node and opts.get('rev'):
+        raise error.Abort(_(b'please specify just one revision'))
+    if not node:
+        node = opts.get('rev')
+    if not node:
+        raise error.Abort(_(b'please specify a revision to fixup'))
+    target_ctx = scmutil.revsingle(repo, node)
+
+    fixup_state[b'startnode'] = repo[b'.'].node()
+
+    tr = repo.transaction(b'fixup')
+    with util.acceptintervention(tr):
+        overrides = {(b'ui', b'allowemptycommit'): False}
+        with repo.ui.configoverride(overrides, b'fixup'):
+            tempnode = repo.commit(
+                text=b'temporary fixup commit', user=opts.get(b'user'),
+                date=opts.get(b'date'))
+            if tempnode is None:
+                ui.status(_(b"nothing changed\n"))
+                return 1
+        fixup_state[b'tempnode'] = tempnode
+        # XXX: storing 'tempnode' should be enough, but 'current'
+        # is used by _relocate() logic
+        fixup_state[b'current'] = tempnode
+        fixup_state[b'target'] = target_ctx.node()
+        with state.saver(fixup_state):
+            # relocate temporary node to target revision
+            newnode = evolvecmd._relocate(
+                repo, repo[tempnode], target_ctx, fixup_state, update=False
+            )
+        # fold the two changesets
+        revs = (repo[newnode].rev(), target_ctx.rev())
+        root, head, p2 = rewriteutil.foldcheck(repo, revs)
+
+        allctx = [repo[r] for r in revs]
+        commitopts = {b'edit': False, b'message': target_ctx.description()}
+        newid, unusedvariable = rewriteutil.rewrite(
+            repo, root, head, [root.p1().node(), p2.node()],
+            commitopts=commitopts
+        )
+        phases.retractboundary(repo, tr, target_ctx.phase(), [newid])
+        replacements = {tuple(ctx.node() for ctx in allctx): [newid]}
+        compat.cleanupnodes(repo, replacements, operation=b'fixup')
+        fixup_state.delete()
+        compat.update(repo.unfiltered()[tempnode])
+        return 0
+
+def continuefixup(ui, repo, fixup_state):
+    """logic for handling of `hg fixup --continue`"""
+    target_node = fixup_state[b'target']
+    tempnode = fixup_state[b'tempnode']
+    target_ctx = repo[target_node]
+    tr = repo.transaction(b'fixup')
+    with util.acceptintervention(tr):
+        newnode = evolvecmd._completerelocation(ui, repo, fixup_state)
+        current = repo[fixup_state[b'current']]
+        obsolete.createmarkers(repo, [(current, (repo[newnode],))],
+                               operation=b'fixup')
+        # fold the two changesets
+        revs = (repo[newnode].rev(), target_ctx.rev())
+        root, head, p2 = rewriteutil.foldcheck(repo, revs)
+
+        allctx = [repo[r] for r in revs]
+        commitopts = {b'edit': False, b'message': target_ctx.description()}
+        newid, unusedvariable = rewriteutil.rewrite(
+            repo, root, head, [root.p1().node(), p2.node()],
+            commitopts=commitopts
+        )
+        phases.retractboundary(repo, tr, target_ctx.phase(), [newid])
+        replacements = {tuple(ctx.node() for ctx in allctx): [newid]}
+        compat.cleanupnodes(repo, replacements, operation=b'fixup')
+        fixup_state.delete()
+        compat.update(repo.unfiltered()[tempnode])
+        return 0
+
+def abortfixup(ui, repo, fixup_state):
+    """logic for handling of `hg fixup --abort`"""
+    with repo.wlock(), repo.lock():
+        startnode = fixup_state[b'startnode']
+        tempnode = fixup_state[b'tempnode']
+        tempctx = repo[tempnode]
+        compat.clean_update(repo[startnode])
+
+        stats = merge.graft(repo, tempctx, labels=[b'graft', b'fixup'])
+        # conflict is not possible, since grafting changes from descendant
+        assert not stats.unresolvedcount
+        repair.strip(ui, repo, [tempnode], backup=False)
+
+    pctx = repo[b'.']
+    ui.status(_(b'fixup aborted\n'))
+    ui.status(_(b'working directory is now at %s\n') % pctx)
+    fixup_state.delete()
+    return 0
--- a/tests/test-check-sdist.t	Tue Feb 15 14:26:38 2022 +0300
+++ b/tests/test-check-sdist.t	Wed Nov 03 22:59:17 2021 +0530
@@ -35,7 +35,7 @@
 
   $ tar -tzf hg-evolve-*.tar.gz | sed 's|^hg-evolve-[^/]*/||' | sort > files
   $ wc -l files
-  354 files
+  355 files
   $ fgrep debian files
   tests/test-check-debian.t
   $ fgrep __init__.py files
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test-fixup.t	Wed Nov 03 22:59:17 2021 +0530
@@ -0,0 +1,417 @@
+==========================
+Testing `hg fixup` command
+==========================
+
+  $ . $TESTDIR/testlib/common.sh
+
+  $ cat >> $HGRCPATH <<EOF
+  > [extensions]
+  > rebase =
+  > evolve =
+  > [diff]
+  > git = 1
+  > EOF
+
+  $ hg help fixup
+  hg fixup [OPTION]... [-r] REV
+  
+  aliases: fix-up
+  
+  add working directory changes to an arbitrary revision
+  
+      A new changeset will be created, superseding the one specified. The new
+      changeset will combine working directory changes with the changes in the
+      target revision.
+  
+      This operation requires the working directory changes to be relocated onto
+      the target revision, which might result in merge conflicts.
+  
+      If fixup is interrupted to manually resolve a conflict, it can be
+      continued with --continue/-c, or aborted with --abort.
+  
+      Note that this command is fairly new and its behavior is still
+      experimental. For example, the working copy will be left on a temporary,
+      obsolete commit containing the fixed-up changes after the operation. This
+      might change in the future.
+  
+      Returns 0 on success, 1 if nothing changed.
+  
+  options:
+  
+   -r --rev REV  revision to amend
+   -c --continue continue an interrupted fixup
+      --abort    abort an interrupted fixup
+  
+  (some details hidden, use --verbose to show complete help)
+
+Simple cases
+------------
+
+  $ hg init simple
+  $ cd simple
+  $ mkcommit foo
+  $ mkcommit bar
+  $ mkcommit baz
+
+amending the middle of the stack
+--------------------------------
+
+  $ echo 'hookah bar' > bar
+  $ hg fixup -r 'desc(bar)'
+  1 new orphan changesets
+
+  $ hg diff -c tip
+  diff --git a/bar b/bar
+  new file mode 100644
+  --- /dev/null
+  +++ b/bar
+  @@ -0,0 +1,1 @@
+  +hookah bar
+
+  $ hg glog
+  o  5:2eec5320cfc7 bar
+  |   () draft
+  | @  3:fd2f632e47ab temporary fixup commit
+  | |   () draft
+  | *  2:a425495a8e64 baz
+  | |   () draft orphan
+  | x  1:c0c7cf58edc5 bar
+  |/    () draft
+  o  0:e63c23eaa88a foo
+      () draft
+
+  $ hg glog --hidden
+  o  5:2eec5320cfc7 bar
+  |   () draft
+  | x  4:4869c1db2884 temporary fixup commit
+  | |   () draft
+  | | @  3:fd2f632e47ab temporary fixup commit
+  | | |   () draft
+  | | *  2:a425495a8e64 baz
+  | |/    () draft orphan
+  | x  1:c0c7cf58edc5 bar
+  |/    () draft
+  o  0:e63c23eaa88a foo
+      () draft
+
+  $ hg evolve
+  update:[5] bar
+  1 files updated, 0 files merged, 1 files removed, 0 files unresolved
+  working directory is now at 2eec5320cfc7
+
+  $ hg evolve
+  move:[2] baz
+  atop:[5] bar
+
+  $ hg glog
+  o  6:eb1755d9f810 baz
+  |   () draft
+  @  5:2eec5320cfc7 bar
+  |   () draft
+  o  0:e63c23eaa88a foo
+      () draft
+
+amending working directory parent in secret phase
+-------------------------------------------------
+
+  $ hg up -r 'desc(baz)'
+  1 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  $ hg phase --secret --force -r .
+  $ echo buzz >> baz
+  $ hg fix-up -r .
+
+  $ hg evolve
+  update:[9] baz
+  1 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  working directory is now at 12b5e442244f
+  $ hg glog
+  @  9:12b5e442244f baz
+  |   () secret
+  o  5:2eec5320cfc7 bar
+  |   () draft
+  o  0:e63c23eaa88a foo
+      () draft
+
+testing --abort/--continue
+--------------------------
+
+  $ hg up -r 'desc(foo)'
+  0 files updated, 0 files merged, 2 files removed, 0 files unresolved
+  $ echo 'update foo' > foo
+  $ hg ci -m 'update foo'
+  created new head
+  $ hg up -r 'desc(baz)'
+  3 files updated, 0 files merged, 0 files removed, 0 files unresolved
+
+  $ hg glog
+  o  10:c90c517f86b3 update foo
+  |   () draft
+  | @  9:12b5e442244f baz
+  | |   () secret
+  | o  5:2eec5320cfc7 bar
+  |/    () draft
+  o  0:e63c23eaa88a foo
+      () draft
+
+testing --abort flag
+
+  $ echo 'update foo again' >> foo
+
+  $ hg fixup -r 'desc("update foo")'
+  merging foo
+  warning: conflicts while merging foo! (edit, then use 'hg resolve --mark')
+  unresolved merge conflicts
+  (see 'hg help evolve.interrupted')
+  [240]
+
+  $ hg diff
+  diff --git a/foo b/foo
+  --- a/foo
+  +++ b/foo
+  @@ -1,1 +1,6 @@
+  +<<<<<<< destination: c90c517f86b3 - test: update foo
+   update foo
+  +=======
+  +foo
+  +update foo again
+  +>>>>>>> evolving:    1c9958e73c2d - test: temporary fixup commit
+
+  $ hg fixup --abort
+  fixup aborted
+  working directory is now at 12b5e442244f
+
+  $ hg diff
+  diff --git a/foo b/foo
+  --- a/foo
+  +++ b/foo
+  @@ -1,1 +1,2 @@
+   foo
+  +update foo again
+
+testing --continue flag
+
+  $ hg fixup -r 'desc("update foo")'
+  merging foo
+  warning: conflicts while merging foo! (edit, then use 'hg resolve --mark')
+  unresolved merge conflicts
+  (see 'hg help evolve.interrupted')
+  [240]
+
+  $ hg status --verbose
+  M foo
+  ? foo.orig
+  # The repository is in an unfinished *fixup* state.
+  
+  # Unresolved merge conflicts:
+  # 
+  #     foo
+  # 
+  # To mark files as resolved:  hg resolve --mark FILE
+  
+  # To continue:    hg fixup --continue
+  # To abort:       hg fixup --abort
+  
+  $ echo 'finalize foo' > foo
+
+  $ hg resolve -m
+  (no more unresolved files)
+  continue: hg fixup --continue
+
+  $ hg fixup --continue
+  evolving 11:1c9958e73c2d "temporary fixup commit"
+
+  $ hg diff -c tip
+  diff --git a/foo b/foo
+  --- a/foo
+  +++ b/foo
+  @@ -1,1 +1,1 @@
+  -foo
+  +finalize foo
+
+  $ hg glog
+  o  13:fed7e534b3bb update foo
+  |   () draft
+  | @  11:1c9958e73c2d temporary fixup commit
+  | |   () secret
+  | o  9:12b5e442244f baz
+  | |   () secret
+  | o  5:2eec5320cfc7 bar
+  |/    () draft
+  o  0:e63c23eaa88a foo
+      () draft
+
+  $ hg evolve
+  update:[13] update foo
+  1 files updated, 0 files merged, 2 files removed, 0 files unresolved
+  working directory is now at fed7e534b3bb
+
+amending a descendant of wdp
+
+  $ hg up 0
+  1 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  $ echo foobar > foobar
+  $ hg add foobar
+  $ hg fixup -r 'desc(baz)'
+  $ hg glog
+  o  16:b50fd0850076 baz
+  |   () secret
+  | @  14:4a9c4d14d447 temporary fixup commit
+  | |   () draft
+  | | o  13:fed7e534b3bb update foo
+  | |/    () draft
+  o |  5:2eec5320cfc7 bar
+  |/    () draft
+  o  0:e63c23eaa88a foo
+      () draft
+
+  $ hg evolve
+  update:[16] baz
+  2 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  working directory is now at b50fd0850076
+
+  $ hg glog
+  @  16:b50fd0850076 baz
+  |   () secret
+  | o  13:fed7e534b3bb update foo
+  | |   () draft
+  o |  5:2eec5320cfc7 bar
+  |/    () draft
+  o  0:e63c23eaa88a foo
+      () draft
+  $ hg diff -c .
+  diff --git a/baz b/baz
+  new file mode 100644
+  --- /dev/null
+  +++ b/baz
+  @@ -0,0 +1,2 @@
+  +baz
+  +buzz
+  diff --git a/foobar b/foobar
+  new file mode 100644
+  --- /dev/null
+  +++ b/foobar
+  @@ -0,0 +1,1 @@
+  +foobar
+
+no fixup in progress
+
+  $ hg fixup --continue
+  abort: no interrupted fixup to continue
+  [255]
+
+  $ hg fixup --abort
+  abort: no interrupted fixup to abort
+  [255]
+
+testing error cases
+
+  $ hg fixup tip --abort
+  abort: cannot specify a revision with --abort
+  [255]
+
+  $ hg fixup -r tip --continue
+  abort: cannot specify a revision with --continue
+  [255]
+
+  $ hg fixup
+  abort: please specify a revision to fixup
+  [255]
+
+  $ hg fixup tip
+  nothing changed
+  [1]
+
+  $ hg fixup -r tip
+  nothing changed
+  [1]
+
+  $ hg fixup 1 2 3
+  hg fixup: invalid arguments
+  hg fixup [OPTION]... [-r] REV
+  
+  add working directory changes to an arbitrary revision
+  
+  options:
+  
+   -r --rev REV  revision to amend
+   -c --continue continue an interrupted fixup
+      --abort    abort an interrupted fixup
+  
+  (use 'hg fixup -h' to show more help)
+  [10]
+
+  $ hg fixup :10 -r 5
+  abort: please specify just one revision
+  [255]
+
+  $ cd ..
+
+Multiple branches
+-----------------
+
+  $ hg init branches
+  $ cd branches
+
+  $ cat >> .hg/hgrc << EOF
+  > [extensions]
+  > topic =
+  > [alias]
+  > glog = log -GT '{rev}:{node|short} {desc}\n ({branch}) [{topic}]\n'
+  > EOF
+
+  $ mkcommit ROOT
+  $ hg topic topic-A -q
+  $ mkcommit A -q
+  $ hg topic topic-B -q
+  $ mkcommit B -q
+  $ hg up 'desc(ROOT)' -q
+  $ hg branch other-branch -q
+  $ hg topic topic-C -q
+  $ mkcommit C -q
+  $ hg topic topic-D -q
+  $ mkcommit D -q
+  $ hg up 'desc(A)' -q
+
+  $ hg glog
+  o  4:deb0223c611b D
+  |   (other-branch) [topic-D]
+  o  3:381934d792ab C
+  |   (other-branch) [topic-C]
+  | o  2:d2dfccd24f25 B
+  | |   (default) [topic-B]
+  | @  1:0a2783c5c927 A
+  |/    (default) [topic-A]
+  o  0:ea207398892e ROOT
+      (default) []
+
+  $ echo Z > Z
+  $ hg add Z
+  $ hg fix-up -r 'desc(C)'
+  switching to topic topic-C
+  1 new orphan changesets
+
+  $ hg evolve
+  update:[7] C
+  switching to topic topic-C
+  1 files updated, 0 files merged, 1 files removed, 0 files unresolved
+  working directory is now at 57d19d0ff7ee
+  $ hg evolve --any
+  move:[4] D
+  atop:[7] C
+  switching to topic topic-C
+
+C and D keep their original branch and topics
+
+  $ hg glog
+  o  8:203e06b553f5 D
+  |   (other-branch) [topic-D]
+  @  7:57d19d0ff7ee C
+  |   (other-branch) [topic-C]
+  | o  2:d2dfccd24f25 B
+  | |   (default) [topic-B]
+  | o  1:0a2783c5c927 A
+  |/    (default) [topic-A]
+  o  0:ea207398892e ROOT
+      (default) []
+
+  $ cd ..