diff --git a/mercurial/state.py b/mercurial/state.py --- a/mercurial/state.py +++ b/mercurial/state.py @@ -19,6 +19,8 @@ from __future__ import absolute_import +import contextlib + from .i18n import _ from . import ( @@ -119,6 +121,7 @@ reportonly, continueflag, stopflag, + childopnames, cmdmsg, cmdhint, statushint, @@ -132,6 +135,8 @@ self._reportonly = reportonly self._continueflag = continueflag self._stopflag = stopflag + self._childopnames = childopnames + self._delegating = False self._cmdmsg = cmdmsg self._cmdhint = cmdhint self._statushint = statushint @@ -181,12 +186,15 @@ """ if self._opname == b'merge': return len(repo[None].parents()) > 1 + elif self._delegating: + return False else: return repo.vfs.exists(self._fname) # A list of statecheck objects for multistep operations like graft. _unfinishedstates = [] +_unfinishedstatesbyname = {} def addunfinished( @@ -197,6 +205,7 @@ reportonly=False, continueflag=False, stopflag=False, + childopnames=None, cmdmsg=b"", cmdhint=b"", statushint=b"", @@ -218,6 +227,8 @@ `--continue` option or not. stopflag is a boolean that determines whether or not a command supports --stop flag + childopnames is a list of other opnames this op uses as sub-steps of its + own execution. They must already be added. cmdmsg is used to pass a different status message in case standard message of the format "abort: cmdname in progress" is not desired. cmdhint is used to pass a different hint message in case standard @@ -230,6 +241,7 @@ continuefunc stores the function required to finish an interrupted operation. """ + childopnames = childopnames or [] statecheckobj = _statecheck( opname, fname, @@ -238,17 +250,98 @@ reportonly, continueflag, stopflag, + childopnames, cmdmsg, cmdhint, statushint, abortfunc, continuefunc, ) + if opname == b'merge': _unfinishedstates.append(statecheckobj) else: + # This check enforces that for any op 'foo' which depends on op 'bar', + # 'foo' comes before 'bar' in _unfinishedstates. This ensures that + # getrepostate() always returns the most specific applicable answer. + for childopname in childopnames: + if childopname not in _unfinishedstatesbyname: + raise error.ProgrammingError( + _(b'op %s depends on unknown op %s') % (opname, childopname) + ) + _unfinishedstates.insert(0, statecheckobj) + if opname in _unfinishedstatesbyname: + raise error.ProgrammingError(_(b'op %s registered twice') % opname) + _unfinishedstatesbyname[opname] = statecheckobj + + +def _getparentandchild(opname, childopname): + p = _unfinishedstatesbyname.get(opname, None) + if not p: + raise error.ProgrammingError(_(b'unknown op %s') % opname) + if childopname not in p._childopnames: + raise error.ProgrammingError( + _(b'op %s does not delegate to %s') % (opname, childopname) + ) + c = _unfinishedstatesbyname[childopname] + return p, c + + +@contextlib.contextmanager +def delegating(repo, opname, childopname): + """context wrapper for delegations from opname to childopname. + + requires that childopname was specified when opname was registered. + + Usage: + def my_command_foo_that_uses_rebase(...): + ... + with state.delegating(repo, 'foo', 'rebase'): + _run_rebase(...) + ... + """ + + p, c = _getparentandchild(opname, childopname) + if p._delegating: + raise error.ProgrammingError( + _(b'cannot delegate from op %s recursively') % opname + ) + p._delegating = True + try: + yield + except error.ConflictResolutionRequired as e: + # Rewrite conflict resolution advice for the parent opname. + if e.opname == childopname: + raise error.ConflictResolutionRequired(opname) + raise e + finally: + p._delegating = False + + +def ischildunfinished(repo, opname, childopname): + """Returns true if both opname and childopname are unfinished.""" + + p, c = _getparentandchild(opname, childopname) + return (p._delegating or p.isunfinished(repo)) and c.isunfinished(repo) + + +def continuechild(ui, repo, opname, childopname): + """Checks that childopname is in progress, and continues it.""" + + p, c = _getparentandchild(opname, childopname) + if not ischildunfinished(repo, opname, childopname): + raise error.ProgrammingError( + _(b'child op %s of parent %s is not unfinished') + % (childopname, opname) + ) + if not c.continuefunc: + raise error.ProgrammingError( + _(b'op %s has no continue function') % childopname + ) + return c.continuefunc(ui, repo) + addunfinished( b'update', diff --git a/tests/test-state-extension.t b/tests/test-state-extension.t new file mode 100644 --- /dev/null +++ b/tests/test-state-extension.t @@ -0,0 +1,136 @@ +Test extension of unfinished states support. + $ mkdir chainify + $ cd chainify + $ cat >> chainify.py < from mercurial import cmdutil, error, extensions, exthelper, node, scmutil, state + > from hgext import rebase + > + > eh = exthelper.exthelper() + > + > extsetup = eh.finalextsetup + > cmdtable = eh.cmdtable + > + > # Rebase calls addunfinished in uisetup, so we have to call it in extsetup. + > # Ideally there'd by an 'extensions.afteruisetup()' just like + > # 'extensions.afterloaded()' to allow nesting multiple commands. + > @eh.extsetup + > def _extsetup(ui): + > state.addunfinished( + > b'chainify', + > b'chainify.state', + > continueflag=True, + > childopnames=[b'rebase']) + > + > def _node(repo, arg): + > return node.hex(scmutil.revsingle(repo, arg).node()) + > + > @eh.command( + > b'chainify', + > [(b'r', b'revs', [], b'revs to chain', b'REV'), + > (b'', b'continue', False, b'continue op')], + > b'chainify [-r REV] +', + > inferrepo=True) + > def chainify(ui, repo, **opts): + > """Rebases r1, r2, r3, etc. into a chain.""" + > with repo.wlock(), repo.lock(): + > cmdstate = state.cmdstate(repo, b'chainify.state') + > if opts['continue']: + > if not cmdstate.exists(): + > raise error.Abort(b'no chainify in progress') + > else: + > cmdutil.checkunfinished(repo) + > data = { + > b'tip': _node(repo, opts['revs'][0]), + > b'revs': b','.join(_node(repo, r) for r in opts['revs'][1:]), + > } + > cmdstate.save(1, data) + > + > data = cmdstate.read() + > while data[b'revs']: + > tip = data[b'tip'] + > revs = data[b'revs'].split(b',') + > with state.delegating(repo, b'chainify', b'rebase'): + > ui.status(b'rebasing %s onto %s\n' % (revs[0][:12], tip[:12])) + > if state.ischildunfinished(repo, b'chainify', b'rebase'): + > rc = state.continuechild(ui, repo, b'chainify', b'rebase') + > else: + > rc = rebase.rebase(ui, repo, rev=[revs[0]], dest=tip) + > if rc and rc != 0: + > raise error.Abort(b'rebase failed (rc: %d)' % rc) + > data[b'tip'] = _node(repo, b'tip') + > data[b'revs'] = b','.join(revs[1:]) + > cmdstate.save(1, data) + > cmdstate.delete() + > ui.status(b'done chainifying\n') + > EOF + + $ chainifypath=`pwd`/chainify.py + $ echo '[extensions]' >> $HGRCPATH + $ echo "chainify = $chainifypath" >> $HGRCPATH + $ echo "rebase =" >> $HGRCPATH + + $ cd $TESTTMP + $ hg init a + $ cd a + $ echo base > base.txt + $ hg commit -Aqm 'base commit' + $ echo foo > file1 + $ hg commit -Aqm 'add file' + $ hg co -q ".^" + $ echo bar > file2 + $ hg commit -Aqm 'add other file' + $ hg co -q ".^" + $ echo foo2 > file1 + $ hg commit -Aqm 'add conflicting file' + $ hg co -q ".^" + $ hg log --graph --template '{rev} {files}' + o 3 file1 + | + | o 2 file2 + |/ + | o 1 file1 + |/ + @ 0 base.txt + + $ hg chainify -r 8430cfdf77c2 -r f8596309dff8 -r a858b338b3e9 + rebasing f8596309dff8 onto 8430cfdf77c2 + rebasing 2:f8596309dff8 "add other file" + saved backup bundle to $TESTTMP/* (glob) + rebasing a858b338b3e9 onto 83c722183a8e + rebasing 2:a858b338b3e9 "add conflicting file" + merging file1 + warning: conflicts while merging file1! (edit, then use 'hg resolve --mark') + unresolved conflicts (see 'hg resolve', then 'hg chainify --continue') + [1] + $ hg status --config commands.status.verbose=True + M file1 + ? file1.orig + # The repository is in an unfinished *chainify* state. + + # Unresolved merge conflicts: + # + # file1 + # + # To mark files as resolved: hg resolve --mark FILE + + # To continue: hg chainify --continue + # To abort: hg chainify --abort + + $ echo foo3 > file1 + $ hg resolve --mark file1 + (no more unresolved files) + continue: hg chainify --continue + $ hg chainify --continue + rebasing a858b338b3e9 onto 83c722183a8e + rebasing 2:a858b338b3e9 "add conflicting file" + saved backup bundle to $TESTTMP/* (glob) + done chainifying + $ hg log --graph --template '{rev} {files}' + o 3 file1 + | + o 2 file2 + | + o 1 file1 + | + @ 0 base.txt +