[PATCH 1 of 2] split: new extension to split changesets

Jun Wu quark at fb.com
Sun Jun 25 05:45:01 UTC 2017


# HG changeset patch
# User Jun Wu <quark at fb.com>
# Date 1498360490 25200
#      Sat Jun 24 20:14:50 2017 -0700
# Node ID c5c2d312b8293e9488344b39c0889faa3c4442eb
# Parent  bec821f3bb744a7d13e33436433005654a263bc3
# Available At https://bitbucket.org/quark-zju/hg-draft
#              hg pull https://bitbucket.org/quark-zju/hg-draft -r c5c2d312b829
split: new extension to split changesets

This diff introduces an experimental split extension to split changesets.

The implementation is largely inspired by Laurent Charignon's implementation
for mutable-history (changeset 9603aa1ecdfd54b0d86e262318a72e0a2ffeb6cc [1])

This version contains various improvements:

  - Rebase by default
    This is more friendly for new users. Split won't lead to merge conflicts
    so a rebase won't give the user more trouble.
    It also enables splitting a non-head commit for repos without obsstore,
    since there won't be unstable changesets before and after the single
    transaction.
    This has been on by default at Facebook for months now and seems to be a
    good UX improvement.

  - Remove "Done split? [y/n]" prompt.
    That could be detected by checking repo.status() instead.

[1]: https://bitbucket.org/marmoute/mutable-history/commits/9603aa1ecdfd54b

diff --git a/hgext/split.py b/hgext/split.py
new file mode 100644
--- /dev/null
+++ b/hgext/split.py
@@ -0,0 +1,168 @@
+# split.py - split a changeset into smaller ones
+#
+# Copyright 2015 Laurent Charignon <lcharignon at fb.com>
+# Copyright 2017 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.
+"""command to split a changeset into smaller ones (EXPERIMENTAL)"""
+
+from __future__ import absolute_import
+
+from mercurial.i18n import _
+
+from mercurial import (
+    bookmarks,
+    cmdutil,
+    commands,
+    error,
+    extensions,
+    hg,
+    node,
+    obsolete,
+    registrar,
+    revsetlang,
+    scmutil,
+)
+
+cmdtable = {}
+command = registrar.command(cmdtable)
+
+# 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'
+
+ at command('^split',
+    [('r', 'rev', '', _("revision to split"), _('REV')),
+     ('', 'no-rebase', False, _('do not rebase descendants after split')),
+    ] + cmdutil.commitopts2,
+    _('hg split [[-r] REV] [--no-rebase]'))
+def split(ui, repo, *args, **opts):
+    """split a changeset into smaller ones
+
+    Repetitively prompt changes and commit message for new changesets until
+    there is nothing left in the original changeset.
+
+    If --rev was not given, split the working directory parent.
+
+    If --no-rebase was not set, rebase old descendants on top of the last new
+    changeset.
+    """
+    revlist = []
+    if opts.get('rev'):
+        revlist.append(opts.get('rev'))
+    revlist.extend(args)
+    revs = scmutil.revrange(repo, revlist or ['.'])
+    if len(revs) > 1:
+        raise error.Abort(_('cannot split multiple revisions'))
+
+    rev = revs.first()
+    ctx = repo[rev]
+    if rev is None or ctx.node() == node.nullid:
+        ui.status(_('nothing to split\n'))
+        return 1
+    if ctx.node() is None:
+        raise error.Abort(_('cannot split working directory'))
+
+    descendants = repo.revs('(%d::) - (%d)', ctx, ctx)
+    if opts.get('no_rebase'):
+        allowunstable = obsolete.isenabled(repo, obsolete.allowunstableopt)
+        if not allowunstable and descendants:
+            raise error.Abort(
+                _('cannot split changeset with children without rebase'))
+        torebase = ()
+    else:
+        if descendants:
+            try:
+                extensions.find('rebase').rebase
+            except (KeyError, AttributeError):
+                raise error.Abort(_('rebase extension is required'))
+        torebase = descendants
+
+    if len(ctx.parents()) > 1:
+        raise error.Abort(_('cannot split a merge changeset'))
+
+    cmdutil.bailifchanged(repo, merge=False)
+
+    # Deactivate bookmark temporarily so it won't get moved unintentionally
+    bname = repo._activebookmark
+    if bname and repo._bookmarks[bname] != ctx.node():
+        bookmarks.deactivate(repo)
+
+    with repo.wlock():
+        with repo.lock():
+            # Not using transaction with block because "dorebase" may want to
+            # close the transaction earlier.
+            # NOTE: strip might be made smarter to "work" in a transaction.
+            tr = repo.transaction('split')
+            try:
+                wnode = repo['.'].node()
+                top = None
+                try:
+                    top = dosplit(ui, repo, tr, ctx, opts)
+                finally:
+                    # top is None: split failed, need update --clean recovery.
+                    # wnode == ctx.node(): wnode split, no need to update.
+                    if top is None or wnode != ctx.node():
+                        hg.clean(repo, wnode, show_stats=False)
+                    if bname:
+                        bookmarks.activate(repo, bname)
+                if torebase and top:
+                    dorebase(ui, repo, tr, torebase, top)
+            finally:
+                if tr.running():
+                    tr.close()
+
+def dosplit(ui, repo, tr, ctx, opts):
+    committed = [] # [ctx]
+
+    # Set working parent to ctx.p1(), and keep working copy as ctx's content
+    # NOTE: if we can have "update without touching working copy" API, the
+    # revert step could be cheaper.
+    hg.clean(repo, ctx.p1().node(), show_stats=False)
+    parents = repo.changelog.parents(ctx.node())
+    ui.pushbuffer()
+    cmdutil.revert(ui, repo, ctx, parents)
+    ui.popbuffer() # discard "reverting ..." messages
+
+    # Any modified, added, removed, deleted result means split is incomplete
+    incomplete = lambda repo: any(repo.status()[:4])
+
+    # Main split loop
+    while incomplete(repo):
+        opts.update({
+            'edit': True,
+            'interactive': True,
+            'message': ctx.description(),
+        })
+        commands.commit(ui, repo, **opts)
+        newctx = repo['.']
+        committed.append(newctx)
+
+    if not committed:
+        raise error.Abort(_('cannot split an empty revision'))
+
+    # Move bookmarks
+    oldbookmarks = repo.nodebookmarks(ctx.node())
+    if oldbookmarks and committed:
+        marks = repo._bookmarks
+        newnode = committed[-1].node()
+        for name in oldbookmarks:
+            marks[name] = newnode
+        marks.recordchange(tr)
+
+    # Write obsmarkers
+    if obsolete.isenabled(repo, obsolete.createmarkersopt):
+        obsolete.createmarkers(repo, [(ctx, committed)], operation='split')
+
+    return committed[-1]
+
+def dorebase(ui, repo, tr, src, dest):
+    if not obsolete.isenabled(repo, obsolete.createmarkersopt) and tr:
+        # rebase calls strip, which cannot be inside a transaction
+        tr.close()
+    rebase = extensions.find('rebase')
+    rebase.rebase(ui, repo, rev=[revsetlang.formatspec('%ld', src)],
+                  dest=revsetlang.formatspec('%d', dest))
diff --git a/tests/test-split.t b/tests/test-split.t
new file mode 100644
--- /dev/null
+++ b/tests/test-split.t
@@ -0,0 +1,437 @@
+#testcases default obsstore
+
+  $ cat > $TESTTMP/editor.py <<EOF
+  > #!$PYTHON
+  > import os, sys
+  > path = os.path.join(os.environ['TESTTMP'], 'messages')
+  > messages = open(path).read().split('--\n')
+  > prompt = open(sys.argv[1]).read()
+  > sys.stdout.write(''.join('EDITOR: %s' % l for l in prompt.splitlines(True)))
+  > sys.stdout.flush()
+  > with open(sys.argv[1], 'w') as f:
+  >    f.write(messages[0])
+  > with open(path, 'w') as f:
+  >    f.write('--\n'.join(messages[1:]))
+  > EOF
+
+  $ cat >> $HGRCPATH <<EOF
+  > [extensions]
+  > split=
+  > [ui]
+  > interactive=1
+  > [diff]
+  > git=1
+  > unified=0
+  > [alias]
+  > glog=log -G -T '{rev}:{node|short} {desc} {bookmarks}\n'
+  > EOF
+
+#if obsstore
+  $ cat >> $HGRCPATH <<EOF
+  > [experimental]
+  > evolution=all
+  > EOF
+#endif
+
+  $ hg init a
+  $ cd a
+
+Nothing to split
+
+  $ hg split
+  nothing to split
+  [1]
+
+  $ hg commit -m empty --config ui.allowemptycommit=1
+  $ hg split
+  abort: cannot split an empty revision
+  [255]
+
+  $ rm -rf .hg
+  $ hg init
+
+Cannot split working directory
+
+  $ hg split -r 'wdir()'
+  abort: cannot split working directory
+  [255]
+
+Split a head
+
+  $ $TESTDIR/seq.py 1 5 >> a
+  $ hg ci -m a1 -A a -q
+  $ hg bookmark -i r1
+  $ sed 's/1/11/;s/3/33/;s/5/55/' a > b
+  $ mv b a
+  $ hg ci -m a2 -q
+  $ hg bookmark -i r2
+
+  $ cp -R . ../b
+  $ cp -R . ../c
+
+  $ hg bookmark r3
+
+  $ hg split 'all()'
+  abort: cannot split multiple revisions
+  [255]
+
+  $ runsplit() {
+  > cat > $TESTTMP/messages <<EOF
+  > split 1
+  > --
+  > split 2
+  > --
+  > split 3
+  > EOF
+  > cat <<EOF | hg split "$@"
+  > y
+  > y
+  > y
+  > y
+  > y
+  > y
+  > EOF
+  > }
+
+  $ HGEDITOR=false runsplit
+  diff --git a/a b/a
+  1 hunks, 1 lines changed
+  examine changes to 'a'? [Ynesfdaq?] y
+  
+  @@ -5,1 +5,1 @@ 4
+  -5
+  +55
+  record this change to 'a'? [Ynesfdaq?] y
+  
+  abort: edit failed: false exited with status 1
+  [255]
+  $ hg status
+
+  $ HGEDITOR="$PYTHON $TESTTMP/editor.py"
+  $ runsplit
+  diff --git a/a b/a
+  1 hunks, 1 lines changed
+  examine changes to 'a'? [Ynesfdaq?] y
+  
+  @@ -5,1 +5,1 @@ 4
+  -5
+  +55
+  record this change to 'a'? [Ynesfdaq?] y
+  
+  EDITOR: a2
+  EDITOR: 
+  EDITOR: 
+  EDITOR: HG: Enter commit message.  Lines beginning with 'HG:' are removed.
+  EDITOR: HG: Leave message empty to abort commit.
+  EDITOR: HG: --
+  EDITOR: HG: user: test
+  EDITOR: HG: branch 'default'
+  EDITOR: HG: changed a
+  created new head
+  diff --git a/a b/a
+  1 hunks, 1 lines changed
+  examine changes to 'a'? [Ynesfdaq?] y
+  
+  @@ -3,1 +3,1 @@ 2
+  -3
+  +33
+  record this change to 'a'? [Ynesfdaq?] y
+  
+  EDITOR: a2
+  EDITOR: 
+  EDITOR: 
+  EDITOR: HG: Enter commit message.  Lines beginning with 'HG:' are removed.
+  EDITOR: HG: Leave message empty to abort commit.
+  EDITOR: HG: --
+  EDITOR: HG: user: test
+  EDITOR: HG: branch 'default'
+  EDITOR: HG: changed a
+  diff --git a/a b/a
+  1 hunks, 1 lines changed
+  examine changes to 'a'? [Ynesfdaq?] y
+  
+  @@ -1,1 +1,1 @@
+  -1
+  +11
+  record this change to 'a'? [Ynesfdaq?] y
+  
+  EDITOR: a2
+  EDITOR: 
+  EDITOR: 
+  EDITOR: HG: Enter commit message.  Lines beginning with 'HG:' are removed.
+  EDITOR: HG: Leave message empty to abort commit.
+  EDITOR: HG: --
+  EDITOR: HG: user: test
+  EDITOR: HG: branch 'default'
+  EDITOR: HG: changed a
+
+  $ hg bookmark
+     r1                        0:a61bcde8c529
+     r2                        4:00eebaf8d2e2
+   * r3                        4:00eebaf8d2e2
+#if default
+  $ hg glog -p
+  @  4:00eebaf8d2e2 split 3 r2 r3
+  |  diff --git a/a b/a
+  |  --- a/a
+  |  +++ b/a
+  |  @@ -1,1 +1,1 @@
+  |  -1
+  |  +11
+  |
+  o  3:a09ad58faae3 split 2
+  |  diff --git a/a b/a
+  |  --- a/a
+  |  +++ b/a
+  |  @@ -3,1 +3,1 @@
+  |  -3
+  |  +33
+  |
+  o  2:e704349bd21b split 1
+  |  diff --git a/a b/a
+  |  --- a/a
+  |  +++ b/a
+  |  @@ -5,1 +5,1 @@
+  |  -5
+  |  +55
+  |
+  | o  1:1df0d5c5a3ab a2
+  |/   diff --git a/a b/a
+  |    --- a/a
+  |    +++ b/a
+  |    @@ -1,1 +1,1 @@
+  |    -1
+  |    +11
+  |    @@ -3,1 +3,1 @@
+  |    -3
+  |    +33
+  |    @@ -5,1 +5,1 @@
+  |    -5
+  |    +55
+  |
+  o  0:a61bcde8c529 a1 r1
+     diff --git a/a b/a
+     new file mode 100644
+     --- /dev/null
+     +++ b/a
+     @@ -0,0 +1,5 @@
+     +1
+     +2
+     +3
+     +4
+     +5
+  
+#endif
+#if obsstore
+  $ hg glog
+  @  4:00eebaf8d2e2 split 3 r2 r3
+  |
+  o  3:a09ad58faae3 split 2
+  |
+  o  2:e704349bd21b split 1
+  |
+  o  0:a61bcde8c529 a1 r1
+  
+#endif
+
+Split a head while working parent is not that head
+
+  $ cd $TESTTMP/b
+
+  $ hg up 0 -q
+  $ hg bookmark r3
+
+  $ runsplit tip >/dev/null
+
+  $ hg bookmark
+     r1                        0:a61bcde8c529
+     r2                        4:00eebaf8d2e2
+   * r3                        0:a61bcde8c529
+
+#if default
+  $ hg glog
+  o  4:00eebaf8d2e2 split 3 r2
+  |
+  o  3:a09ad58faae3 split 2
+  |
+  o  2:e704349bd21b split 1
+  |
+  | o  1:1df0d5c5a3ab a2
+  |/
+  @  0:a61bcde8c529 a1 r1 r3
+  
+#endif
+#if obsstore
+  $ hg glog
+  o  4:00eebaf8d2e2 split 3 r2
+  |
+  o  3:a09ad58faae3 split 2
+  |
+  o  2:e704349bd21b split 1
+  |
+  @  0:a61bcde8c529 a1 r1 r3
+  
+#endif
+
+Split a non-head
+
+  $ cd $TESTTMP/c
+  $ echo d > d
+  $ hg ci -m d1 -A d
+  $ hg bookmark -i d1
+  $ echo 2 >> d
+  $ hg ci -m d2
+  $ echo 3 >> d
+  $ hg ci -m d3
+  $ hg bookmark -i d3
+  $ hg up '.^' -q
+  $ hg bookmark d2
+  $ cp -R . ../d
+
+  $ runsplit 1
+  abort: rebase extension is required
+  [255]
+  $ runsplit -r 1 --config extensions.rebase= | grep rebasing
+  rebasing 2:b5c5ea414030 "d1" (d1)
+  rebasing 3:f4a0a8d004cc "d2" (d2)
+  rebasing 4:777940761eba "d3" (d3)
+#if default
+  $ hg bookmark
+     d1                        5:c4b449ef030e
+   * d2                        6:c9dd00ab36a3
+     d3                        7:19f476bc865c
+     r1                        0:a61bcde8c529
+     r2                        4:00eebaf8d2e2
+  $ hg glog -p
+  o  7:19f476bc865c d3 d3
+  |  diff --git a/d b/d
+  |  --- a/d
+  |  +++ b/d
+  |  @@ -2,0 +3,1 @@
+  |  +3
+  |
+  @  6:c9dd00ab36a3 d2 d2
+  |  diff --git a/d b/d
+  |  --- a/d
+  |  +++ b/d
+  |  @@ -1,0 +2,1 @@
+  |  +2
+  |
+  o  5:c4b449ef030e d1 d1
+  |  diff --git a/d b/d
+  |  new file mode 100644
+  |  --- /dev/null
+  |  +++ b/d
+  |  @@ -0,0 +1,1 @@
+  |  +d
+  |
+  o  4:00eebaf8d2e2 split 3 r2
+  |  diff --git a/a b/a
+  |  --- a/a
+  |  +++ b/a
+  |  @@ -1,1 +1,1 @@
+  |  -1
+  |  +11
+  |
+  o  3:a09ad58faae3 split 2
+  |  diff --git a/a b/a
+  |  --- a/a
+  |  +++ b/a
+  |  @@ -3,1 +3,1 @@
+  |  -3
+  |  +33
+  |
+  o  2:e704349bd21b split 1
+  |  diff --git a/a b/a
+  |  --- a/a
+  |  +++ b/a
+  |  @@ -5,1 +5,1 @@
+  |  -5
+  |  +55
+  |
+  | o  1:1df0d5c5a3ab a2
+  |/   diff --git a/a b/a
+  |    --- a/a
+  |    +++ b/a
+  |    @@ -1,1 +1,1 @@
+  |    -1
+  |    +11
+  |    @@ -3,1 +3,1 @@
+  |    -3
+  |    +33
+  |    @@ -5,1 +5,1 @@
+  |    -5
+  |    +55
+  |
+  o  0:a61bcde8c529 a1 r1
+     diff --git a/a b/a
+     new file mode 100644
+     --- /dev/null
+     +++ b/a
+     @@ -0,0 +1,5 @@
+     +1
+     +2
+     +3
+     +4
+     +5
+  
+#endif
+#if obsstore
+  $ hg bookmark
+     d1                        8:c4b449ef030e
+   * d2                        9:c9dd00ab36a3
+     d3                        10:19f476bc865c
+     r1                        0:a61bcde8c529
+     r2                        7:00eebaf8d2e2
+  $ hg glog
+  o  10:19f476bc865c d3 d3
+  |
+  @  9:c9dd00ab36a3 d2 d2
+  |
+  o  8:c4b449ef030e d1 d1
+  |
+  o  7:00eebaf8d2e2 split 3 r2
+  |
+  o  6:a09ad58faae3 split 2
+  |
+  o  5:e704349bd21b split 1
+  |
+  o  0:a61bcde8c529 a1 r1
+  
+#endif
+
+Split a non-head without rebase
+
+  $ cd $TESTTMP/d
+#if default
+  $ runsplit -r 1 --no-rebase
+  abort: cannot split changeset with children without rebase
+  [255]
+#endif
+#if obsstore
+  $ runsplit -r 1 --no-rebase >/dev/null
+  $ hg bookmark
+     d1                        2:b5c5ea414030
+   * d2                        3:f4a0a8d004cc
+     d3                        4:777940761eba
+     r1                        0:a61bcde8c529
+     r2                        7:00eebaf8d2e2
+
+  $ hg glog
+  o  7:00eebaf8d2e2 split 3 r2
+  |
+  o  6:a09ad58faae3 split 2
+  |
+  o  5:e704349bd21b split 1
+  |
+  | o  4:777940761eba d3 d3
+  | |
+  | @  3:f4a0a8d004cc d2 d2
+  | |
+  | o  2:b5c5ea414030 d1 d1
+  | |
+  | x  1:1df0d5c5a3ab a2
+  |/
+  o  0:a61bcde8c529 a1 r1
+  
+#endif


More information about the Mercurial-devel mailing list