new file mode 100644
@@ -0,0 +1,182 @@
+# split.py - split a changeset into smaller ones
+#
+# Copyright 2015 Laurent Charignon <lcharignon@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,
+ repair,
+ 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'
+
+@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, torebase, top)
+ if top:
+ dostrip(ui, repo, ctx.node())
+ finally:
+ tr = repo.currenttransaction()
+ if tr:
+ 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, src, dest):
+ tr = repo.currenttransaction()
+ 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))
+
+def dostrip(ui, repo, node):
+ if obsolete.isenabled(repo, obsolete.createmarkersopt):
+ # obsmarker written by dosplit will hide node
+ return
+ tr = repo.currenttransaction()
+ if tr:
+ tr.close()
+ repair.strip(ui, repo, [node])
new file mode 100644
@@ -0,0 +1,415 @@
+#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
+ saved backup bundle to $TESTTMP/a/.hg/strip-backup/1df0d5c5a3ab-8341b760-backup.hg (glob) (?)
+
+#if default
+ $ hg bookmark
+ r1 0:a61bcde8c529
+ r2 3:00eebaf8d2e2
+ * r3 3:00eebaf8d2e2
+ $ hg glog -p
+ @ 3:00eebaf8d2e2 split 3 r2 r3
+ | diff --git a/a b/a
+ | --- a/a
+ | +++ b/a
+ | @@ -1,1 +1,1 @@
+ | -1
+ | +11
+ |
+ o 2:a09ad58faae3 split 2
+ | diff --git a/a b/a
+ | --- a/a
+ | +++ b/a
+ | @@ -3,1 +3,1 @@
+ | -3
+ | +33
+ |
+ o 1:e704349bd21b split 1
+ | diff --git a/a b/a
+ | --- a/a
+ | +++ b/a
+ | @@ -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
+ r1 0:a61bcde8c529
+ r2 4:00eebaf8d2e2
+ * r3 4:00eebaf8d2e2
+ $ 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
+
+#if default
+ $ hg bookmark
+ r1 0:a61bcde8c529
+ r2 3:00eebaf8d2e2
+ * r3 0:a61bcde8c529
+ $ hg glog
+ o 3:00eebaf8d2e2 split 3 r2
+ |
+ o 2:a09ad58faae3 split 2
+ |
+ o 1:e704349bd21b split 1
+ |
+ @ 0:a61bcde8c529 a1 r1 r3
+
+#endif
+#if obsstore
+ $ hg bookmark
+ r1 0:a61bcde8c529
+ r2 4:00eebaf8d2e2
+ * r3 0:a61bcde8c529
+ $ 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 4:c4b449ef030e
+ * d2 5:c9dd00ab36a3
+ d3 6:19f476bc865c
+ r1 0:a61bcde8c529
+ r2 3:00eebaf8d2e2
+ $ hg glog -p
+ o 6:19f476bc865c d3 d3
+ | diff --git a/d b/d
+ | --- a/d
+ | +++ b/d
+ | @@ -2,0 +3,1 @@
+ | +3
+ |
+ @ 5:c9dd00ab36a3 d2 d2
+ | diff --git a/d b/d
+ | --- a/d
+ | +++ b/d
+ | @@ -1,0 +2,1 @@
+ | +2
+ |
+ o 4:c4b449ef030e d1 d1
+ | diff --git a/d b/d
+ | new file mode 100644
+ | --- /dev/null
+ | +++ b/d
+ | @@ -0,0 +1,1 @@
+ | +d
+ |
+ o 3:00eebaf8d2e2 split 3 r2
+ | diff --git a/a b/a
+ | --- a/a
+ | +++ b/a
+ | @@ -1,1 +1,1 @@
+ | -1
+ | +11
+ |
+ o 2:a09ad58faae3 split 2
+ | diff --git a/a b/a
+ | --- a/a
+ | +++ b/a
+ | @@ -3,1 +3,1 @@
+ | -3
+ | +33
+ |
+ o 1:e704349bd21b split 1
+ | diff --git a/a b/a
+ | --- a/a
+ | +++ b/a
+ | @@ -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