comparison hgext/split.py @ 35455:02ea370c2baa

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. This has been on by default at Facebook for months now and seems to be a good UX improvement. The rebase skips obsoleted or orphaned changesets, which can avoid issues like allowdivergence, merge conflicts, etc. This is more flexible because the user can decide what to do next (see the last test case in test-split.t) - Remove "Done split? [y/n]" prompt. That could be detected by checking `repo.status()` instead. - Works with obsstore disabled. Without obsstore, split uses strip to clean up old nodes, and it can even handle split a non-head changeset with "allowunstable" disabled, since it runs a rebase to solve the "unstable" issue in a same transaction. - More friendly editor text. Put what has been already split into the editor text so users won't lost track about where they are. [1]: https://bitbucket.org/marmoute/mutable-history/commits/9603aa1ecdfd54b Differential Revision: https://phab.mercurial-scm.org/D1082
author Jun Wu <quark@fb.com>
date Sat, 24 Jun 2017 23:03:41 -0700
parents
children 7b86aa31b004
comparison
equal deleted inserted replaced
35454:786289423e97 35455:02ea370c2baa
1 # split.py - split a changeset into smaller ones
2 #
3 # Copyright 2015 Laurent Charignon <lcharignon@fb.com>
4 # Copyright 2017 Facebook, Inc.
5 #
6 # This software may be used and distributed according to the terms of the
7 # GNU General Public License version 2 or any later version.
8 """command to split a changeset into smaller ones (EXPERIMENTAL)"""
9
10 from __future__ import absolute_import
11
12 from mercurial.i18n import _
13
14 from mercurial.node import (
15 nullid,
16 short,
17 )
18
19 from mercurial import (
20 bookmarks,
21 cmdutil,
22 commands,
23 error,
24 hg,
25 obsolete,
26 phases,
27 registrar,
28 revsetlang,
29 scmutil,
30 )
31
32 # allow people to use split without explicitly enabling rebase extension
33 from . import (
34 rebase,
35 )
36
37 cmdtable = {}
38 command = registrar.command(cmdtable)
39
40 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
41 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
42 # be specifying the version(s) of Mercurial they are tested with, or
43 # leave the attribute unspecified.
44 testedwith = 'ships-with-hg-core'
45
46 @command('^split',
47 [('r', 'rev', '', _("revision to split"), _('REV')),
48 ('', 'rebase', True, _('rebase descendants after split')),
49 ] + cmdutil.commitopts2,
50 _('hg split [--no-rebase] [[-r] REV]'))
51 def split(ui, repo, *revs, **opts):
52 """split a changeset into smaller ones
53
54 Repeatedly prompt changes and commit message for new changesets until there
55 is nothing left in the original changeset.
56
57 If --rev was not given, split the working directory parent.
58
59 By default, rebase connected non-obsoleted descendants onto the new
60 changeset. Use --no-rebase to avoid the rebase.
61 """
62 revlist = []
63 if opts.get('rev'):
64 revlist.append(opts.get('rev'))
65 revlist.extend(revs)
66 with repo.wlock(), repo.lock(), repo.transaction('split') as tr:
67 revs = scmutil.revrange(repo, revlist or ['.'])
68 if len(revs) > 1:
69 raise error.Abort(_('cannot split multiple revisions'))
70
71 rev = revs.first()
72 ctx = repo[rev]
73 if rev is None or ctx.node() == nullid:
74 ui.status(_('nothing to split\n'))
75 return 1
76 if ctx.node() is None:
77 raise error.Abort(_('cannot split working directory'))
78
79 # rewriteutil.precheck is not very useful here because:
80 # 1. null check is done above and it's more friendly to return 1
81 # instead of abort
82 # 2. mergestate check is done below by cmdutil.bailifchanged
83 # 3. unstable check is more complex here because of --rebase
84 #
85 # So only "public" check is useful and it's checked directly here.
86 if ctx.phase() == phases.public:
87 raise error.Abort(_('cannot split public changeset'),
88 hint=_("see 'hg help phases' for details"))
89
90 descendants = list(repo.revs('(%d::) - (%d)', rev, rev))
91 alloworphaned = obsolete.isenabled(repo, obsolete.allowunstableopt)
92 if opts.get('rebase'):
93 # Skip obsoleted descendants and their descendants so the rebase
94 # won't cause conflicts for sure.
95 torebase = list(repo.revs('%ld - (%ld & obsolete())::',
96 descendants, descendants))
97 if not alloworphaned and len(torebase) != len(descendants):
98 raise error.Abort(_('split would leave orphaned changesets '
99 'behind'))
100 else:
101 if not alloworphaned and descendants:
102 raise error.Abort(
103 _('cannot split changeset with children without rebase'))
104 torebase = ()
105
106 if len(ctx.parents()) > 1:
107 raise error.Abort(_('cannot split a merge changeset'))
108
109 cmdutil.bailifchanged(repo)
110
111 # Deactivate bookmark temporarily so it won't get moved unintentionally
112 bname = repo._activebookmark
113 if bname and repo._bookmarks[bname] != ctx.node():
114 bookmarks.deactivate(repo)
115
116 wnode = repo['.'].node()
117 top = None
118 try:
119 top = dosplit(ui, repo, tr, ctx, opts)
120 finally:
121 # top is None: split failed, need update --clean recovery.
122 # wnode == ctx.node(): wnode split, no need to update.
123 if top is None or wnode != ctx.node():
124 hg.clean(repo, wnode, show_stats=False)
125 if bname:
126 bookmarks.activate(repo, bname)
127 if torebase and top:
128 dorebase(ui, repo, torebase, top)
129
130 def dosplit(ui, repo, tr, ctx, opts):
131 committed = [] # [ctx]
132
133 # Set working parent to ctx.p1(), and keep working copy as ctx's content
134 # NOTE: if we can have "update without touching working copy" API, the
135 # revert step could be cheaper.
136 hg.clean(repo, ctx.p1().node(), show_stats=False)
137 parents = repo.changelog.parents(ctx.node())
138 ui.pushbuffer()
139 cmdutil.revert(ui, repo, ctx, parents)
140 ui.popbuffer() # discard "reverting ..." messages
141
142 # Any modified, added, removed, deleted result means split is incomplete
143 incomplete = lambda repo: any(repo.status()[:4])
144
145 # Main split loop
146 while incomplete(repo):
147 if committed:
148 header = (_('HG: Splitting %s. So far it has been split into:\n')
149 % short(ctx.node()))
150 for c in committed:
151 firstline = c.description().split('\n', 1)[0]
152 header += _('HG: - %s: %s\n') % (short(c.node()), firstline)
153 header += _('HG: Write commit message for the next split '
154 'changeset.\n')
155 else:
156 header = _('HG: Splitting %s. Write commit message for the '
157 'first split changeset.\n') % short(ctx.node())
158 opts.update({
159 'edit': True,
160 'interactive': True,
161 'message': header + ctx.description(),
162 })
163 commands.commit(ui, repo, **opts)
164 newctx = repo['.']
165 committed.append(newctx)
166
167 if not committed:
168 raise error.Abort(_('cannot split an empty revision'))
169
170 scmutil.cleanupnodes(repo, {ctx.node(): [c.node() for c in committed]},
171 operation='split')
172
173 return committed[-1]
174
175 def dorebase(ui, repo, src, dest):
176 rebase.rebase(ui, repo, rev=[revsetlang.formatspec('%ld', src)],
177 dest=revsetlang.formatspec('%d', dest))