diff --git a/hgext/split.py b/hgext/split.py new file mode 100644 --- /dev/null +++ b/hgext/split.py @@ -0,0 +1,177 @@ +# split.py - split a changeset into smaller ones +# +# Copyright 2015 Laurent Charignon +# 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.node import ( + nullid, + short, +) + +from mercurial import ( + bookmarks, + cmdutil, + commands, + error, + hg, + obsolete, + phases, + registrar, + revsetlang, + scmutil, +) + +# allow people to use split without explicitly enabling rebase extension +from . import ( + rebase, +) + +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')), + ('', 'rebase', True, _('rebase descendants after split')), + ] + cmdutil.commitopts2, + _('hg split [--no-rebase] [[-r] REV]')) +def split(ui, repo, *revs, **opts): + """split a changeset into smaller ones + + Repeatedly 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. + + By default, rebase connected non-obsoleted descendants onto the new + changeset. Use --no-rebase to avoid the rebase. + """ + revlist = [] + if opts.get('rev'): + revlist.append(opts.get('rev')) + revlist.extend(revs) + with repo.wlock(), repo.lock(), repo.transaction('split') as tr: + 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() == nullid: + ui.status(_('nothing to split\n')) + return 1 + if ctx.node() is None: + raise error.Abort(_('cannot split working directory')) + + # rewriteutil.precheck is not very useful here because: + # 1. null check is done above and it's more friendly to return 1 + # instead of abort + # 2. mergestate check is done below by cmdutil.bailifchanged + # 3. unstable check is more complex here because of --rebase + # + # So only "public" check is useful and it's checked directly here. + if ctx.phase() == phases.public: + raise error.Abort(_('cannot split public changeset'), + hint=_("see 'hg help phases' for details")) + + descendants = list(repo.revs('(%d::) - (%d)', rev, rev)) + alloworphaned = obsolete.isenabled(repo, obsolete.allowunstableopt) + if opts.get('rebase'): + # Skip obsoleted descendants and their descendants so the rebase + # won't cause conflicts for sure. + torebase = list(repo.revs('%ld - (%ld & obsolete())::', + descendants, descendants)) + if not alloworphaned and len(torebase) != len(descendants): + raise error.Abort(_('split would leave orphaned changesets ' + 'behind')) + else: + if not alloworphaned and descendants: + raise error.Abort( + _('cannot split changeset with children without rebase')) + torebase = () + + if len(ctx.parents()) > 1: + raise error.Abort(_('cannot split a merge changeset')) + + cmdutil.bailifchanged(repo) + + # Deactivate bookmark temporarily so it won't get moved unintentionally + bname = repo._activebookmark + if bname and repo._bookmarks[bname] != ctx.node(): + bookmarks.deactivate(repo) + + 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) + +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): + if committed: + header = (_('HG: Splitting %s. So far it has been split into:\n') + % short(ctx.node())) + for c in committed: + firstline = c.description().split('\n', 1)[0] + header += _('HG: - %s: %s\n') % (short(c.node()), firstline) + header += _('HG: Write commit message for the next split ' + 'changeset.\n') + else: + header = _('HG: Splitting %s. Write commit message for the ' + 'first split changeset.\n') % short(ctx.node()) + opts.update({ + 'edit': True, + 'interactive': True, + 'message': header + ctx.description(), + }) + commands.commit(ui, repo, **opts) + newctx = repo['.'] + committed.append(newctx) + + if not committed: + raise error.Abort(_('cannot split an empty revision')) + + scmutil.cleanupnodes(repo, {ctx.node(): [c.node() for c in committed]}, + operation='split') + + return committed[-1] + +def dorebase(ui, repo, src, dest): + 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,525 @@ +#testcases obsstore-on obsstore-off + + $ cat > $TESTTMP/editor.py < #!$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 < [extensions] + > drawdag=$TESTDIR/drawdag.py + > split= + > [ui] + > interactive=1 + > [diff] + > git=1 + > unified=0 + > [alias] + > glog=log -G -T '{rev}:{node|short} {desc} {bookmarks}\n' + > EOF + +#if obsstore-on + $ cat >> $HGRCPATH < [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] + +Generate some content + + $ $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 + +Cannot split a public changeset + + $ hg phase --public -r 'all()' + $ hg split . + abort: cannot split public changeset + (see 'hg help phases' for details) + [255] + + $ hg phase --draft -f -r 'all()' + +Cannot split while working directory is dirty + + $ touch dirty + $ hg add dirty + $ hg split . + abort: uncommitted changes + [255] + $ hg forget dirty + $ rm dirty + +Split a head + + $ cp -R . ../b + $ cp -R . ../c + + $ hg bookmark r3 + + $ hg split 'all()' + abort: cannot split multiple revisions + [255] + + $ runsplit() { + > cat > $TESTTMP/messages < split 1 + > -- + > split 2 + > -- + > split 3 + > EOF + > cat < 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 + + transaction abort! + rollback completed + 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: HG: Splitting 1df0d5c5a3ab. Write commit message for the first split changeset. + 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: HG: Splitting 1df0d5c5a3ab. So far it has been split into: + EDITOR: HG: - e704349bd21b: split 1 + EDITOR: HG: Write commit message for the next split changeset. + 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: HG: Splitting 1df0d5c5a3ab. So far it has been split into: + EDITOR: HG: - e704349bd21b: split 1 + EDITOR: HG: - a09ad58faae3: split 2 + EDITOR: HG: Write commit message for the next split changeset. + 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-split.hg (glob) (obsstore-off !) + +#if obsstore-off + $ 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 + +#else + $ 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 obsstore-off + $ 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 + +#else + $ 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 -r 1 | grep rebasing + rebasing 2:b5c5ea414030 "d1" (d1) + rebasing 3:f4a0a8d004cc "d2" (d2) + rebasing 4:777940761eba "d3" (d3) +#if obsstore-off + $ 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 + +#else + $ 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 obsstore-off + $ runsplit -r 1 --no-rebase + abort: cannot split changeset with children without rebase + [255] +#else + $ 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 + +Split a non-head with obsoleted descendants + +#if obsstore-on + $ hg init $TESTTMP/e + $ cd $TESTTMP/e + $ hg debugdrawdag <<'EOS' + > H I J + > | | | + > F G1 G2 # amend: G1 -> G2 + > | | / # prune: F + > C D E + > \|/ + > B + > | + > A + > EOS + $ eval `hg tags -T '{tag}={node}\n'` + $ rm .hg/localtags + $ hg split $B --config experimental.evolution=createmarkers + abort: split would leave orphaned changesets behind + [255] + $ cat > $TESTTMP/messages < Split B + > EOF + $ cat < y + > y + > EOF + diff --git a/B b/B + new file mode 100644 + examine changes to 'B'? [Ynesfdaq?] y + + @@ -0,0 +1,1 @@ + +B + \ No newline at end of file + record this change to 'B'? [Ynesfdaq?] y + + EDITOR: HG: Splitting 112478962961. Write commit message for the first split changeset. + EDITOR: B + 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: added B + created new head + rebasing 2:26805aba1e60 "C" + rebasing 3:be0ef73c17ad "D" + rebasing 4:49cb92066bfd "E" + rebasing 7:97a6268cc7ef "G2" + rebasing 10:e2f1e425c0db "J" + $ hg glog -r 'sort(all(), topo)' + o 16:556c085f8b52 J + | + o 15:8761f6c9123f G2 + | + o 14:a7aeffe59b65 E + | + | o 13:e1e914ede9ab D + |/ + | o 12:01947e9b98aa C + |/ + o 11:0947baa74d47 Split B + | + | o 9:88ede1d5ee13 I + | | + | x 6:af8cbf225b7b G1 + | | + | x 3:be0ef73c17ad D + | | + | | o 8:74863e5b5074 H + | | | + | | x 5:ee481a2a1e69 F + | | | + | | x 2:26805aba1e60 C + | |/ + | x 1:112478962961 B + |/ + o 0:426bada5c675 A + +#endif