stack: introduce and explicite command to display the stack
If is worth testing having the feature lives outside 'hg topic --list'. The
'stack' name is sub optimal because the conflict with 'status'. using "hg st"
would still work as 'st' is hard coded, but this mean 'hg stack' has no short
version.
# __init__.py - topic extension
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2 or any later version.
"""support for topic branches
Topic branches are lightweight branches which
disappear when changes are finalized.
This is sort of similar to a bookmark, but it applies to a whole
series instead of a single revision.
"""
from __future__ import absolute_import
import re
from mercurial.i18n import _
from mercurial import (
branchmap,
cmdutil,
commands,
context,
error,
extensions,
localrepo,
lock,
merge,
namespaces,
node,
obsolete,
patch,
phases,
util,
)
from . import (
constants,
revset as topicrevset,
destination,
stack,
topicmap,
discovery,
)
cmdtable = {}
command = cmdutil.command(cmdtable)
colortable = {'topic.stack.index': 'yellow',
'topic.stack.state.base': 'dim',
'topic.stack.state.clean': 'green',
'topic.stack.index.current': 'cyan', # random pick
'topic.stack.state.current': 'cyan bold', # random pick
'topic.stack.desc.current': 'cyan', # random pick
'topic.stack.state.unstable': 'red',
}
testedwith = '3.9'
def _contexttopic(self):
return self.extra().get(constants.extrakey, '')
context.basectx.topic = _contexttopic
topicrev = re.compile(r'^t\d+$')
def _namemap(repo, name):
if topicrev.match(name):
idx = int(name[1:])
topic = repo.currenttopic
if not topic:
raise error.Abort(_('cannot resolve "%s": no active topic') % name)
revs = list(stack.getstack(repo, topic))
try:
r = revs[idx - 1]
except IndexError:
msg = _('cannot resolve "%s": topic "%s" has only %d changesets')
raise error.Abort(msg % (name, topic, len(revs)))
return [repo[r].node()]
return [ctx.node() for ctx in
repo.set('not public() and extra(topic, %s)', name)]
def _nodemap(repo, node):
ctx = repo[node]
t = ctx.topic()
if t and ctx.phase() > phases.public:
return [t]
return []
def uisetup(ui):
destination.modsetup(ui)
topicrevset.modsetup(ui)
discovery.modsetup(ui)
topicmap.modsetup(ui)
setupimportexport(ui)
extensions.afterloaded('rebase', _fixrebase)
entry = extensions.wrapcommand(commands.table, 'commit', commitwrap)
entry[1].append(('t', 'topic', '',
_("use specified topic"), _('TOPIC')))
extensions.wrapfunction(cmdutil, 'buildcommittext', committextwrap)
extensions.wrapfunction(merge, 'update', mergeupdatewrap)
cmdutil.summaryhooks.add('topic', summaryhook)
def reposetup(ui, repo):
orig = repo.__class__
if not isinstance(repo, localrepo.localrepository):
return # this can be a peer in the ssh case (puzzling)
class topicrepo(repo.__class__):
def _restrictcapabilities(self, caps):
caps = super(topicrepo, self)._restrictcapabilities(caps)
caps.add('topics')
return caps
def commit(self, *args, **kwargs):
backup = self.ui.backupconfig('ui', 'allowemptycommit')
try:
if repo.currenttopic != repo['.'].topic():
# bypass the core "nothing changed" logic
self.ui.setconfig('ui', 'allowemptycommit', True)
return orig.commit(self, *args, **kwargs)
finally:
self.ui.restoreconfig(backup)
def commitctx(self, ctx, error=None):
if isinstance(ctx, context.workingcommitctx):
current = self.currenttopic
if current:
ctx.extra()[constants.extrakey] = current
if (isinstance(ctx, context.memctx) and
ctx.extra().get('amend_source') and
ctx.topic() and
not self.currenttopic):
# we are amending and need to remove a topic
del ctx.extra()[constants.extrakey]
with topicmap.usetopicmap(self):
return orig.commitctx(self, ctx, error=error)
@property
def topics(self):
topics = set(['', self.currenttopic])
for c in self.set('not public()'):
topics.add(c.topic())
topics.remove('')
return topics
@property
def currenttopic(self):
return self.vfs.tryread('topic')
def branchmap(self, topic=True):
if not topic:
super(topicrepo, self).branchmap()
with topicmap.usetopicmap(self):
branchmap.updatecache(self)
return self._topiccaches[self.filtername]
def destroyed(self, *args, **kwargs):
with topicmap.usetopicmap(self):
return super(topicrepo, self).destroyed(*args, **kwargs)
def invalidatecaches(self):
super(topicrepo, self).invalidatecaches()
if '_topiccaches' in vars(self.unfiltered()):
self.unfiltered()._topiccaches.clear()
def peer(self):
peer = super(topicrepo, self).peer()
if getattr(peer, '_repo', None) is not None: # localpeer
class topicpeer(peer.__class__):
def branchmap(self):
usetopic = not self._repo.publishing()
return self._repo.branchmap(topic=usetopic)
peer.__class__ = topicpeer
return peer
repo.__class__ = topicrepo
if util.safehasattr(repo, 'names'):
repo.names.addnamespace(namespaces.namespace(
'topics', 'topic', namemap=_namemap, nodemap=_nodemap,
listnames=lambda repo: repo.topics))
@command('topics [TOPIC]', [
('', 'clear', False, 'clear active topic if any'),
('', 'change', '', 'revset of existing revisions to change topic'),
('l', 'list', False, 'show the stack of changeset in the topic'),
] + commands.formatteropts)
def topics(ui, repo, topic='', clear=False, change=None, list=False, **opts):
"""View current topic, set current topic, or see all topics."""
if list:
if clear or change:
raise error.Abort(_("cannot use --clear or --change with --list"))
return stack.showstack(ui, repo, topic, opts)
if change:
if not obsolete.isenabled(repo, obsolete.createmarkersopt):
raise error.Abort(_('must have obsolete enabled to use --change'))
if not topic and not clear:
raise error.Abort('changing topic requires a topic name or --clear')
if any(not c.mutable() for c in repo.set('%r and public()', change)):
raise error.Abort("can't change topic of a public change")
rewrote = 0
needevolve = False
l = repo.lock()
txn = repo.transaction('rewrite-topics')
try:
for c in repo.set('%r', change):
def filectxfn(repo, ctx, path):
try:
return c[path]
except error.ManifestLookupError:
return None
fixedextra = dict(c.extra())
ui.debug('old node id is %s\n' % node.hex(c.node()))
ui.debug('origextra: %r\n' % fixedextra)
newtopic = None if clear else topic
oldtopic = fixedextra.get(constants.extrakey, None)
if oldtopic == newtopic:
continue
if clear:
del fixedextra[constants.extrakey]
else:
fixedextra[constants.extrakey] = topic
if 'amend_source' in fixedextra:
# TODO: right now the commitctx wrapper in
# topicrepo overwrites the topic in extra if
# amend_source is set to support 'hg commit
# --amend'. Support for amend should be adjusted
# to not be so invasive.
del fixedextra['amend_source']
ui.debug('changing topic of %s from %s to %s\n' % (
c, oldtopic, newtopic))
ui.debug('fixedextra: %r\n' % fixedextra)
mc = context.memctx(
repo, (c.p1().node(), c.p2().node()), c.description(),
c.files(), filectxfn,
user=c.user(), date=c.date(), extra=fixedextra)
newnode = repo.commitctx(mc)
ui.debug('new node id is %s\n' % node.hex(newnode))
needevolve = needevolve or (len(c.children()) > 0)
obsolete.createmarkers(repo, [(c, (repo[newnode],))])
rewrote += 1
txn.close()
except:
try:
txn.abort()
finally:
repo.invalidate()
raise
finally:
lock.release(txn, l)
ui.status('changed topic on %d changes\n' % rewrote)
if needevolve:
evolvetarget = 'topic(%s)' % topic if topic else 'not topic()'
ui.status('please run hg evolve --rev "%s" now\n' % evolvetarget)
if clear:
if repo.vfs.exists('topic'):
repo.vfs.unlink('topic')
return
if topic:
with repo.wlock():
with repo.vfs.open('topic', 'w') as f:
f.write(topic)
return
current = repo.currenttopic
for t in sorted(repo.topics):
marker = '*' if t == current else ' '
ui.write(' %s %s\n' % (marker, t))
@command('stack [TOPIC]', [] + commands.formatteropts)
def cmdstack(ui, repo, topic='', **opts):
"""list all changesets in a topic
List the current topic by default."""
return stack.showstack(ui, repo, topic, opts)
def summaryhook(ui, repo):
t = repo.currenttopic
if not t:
return
# i18n: column positioning for "hg summary"
ui.write(_("topic: %s\n") % t)
def commitwrap(orig, ui, repo, *args, **opts):
with repo.wlock():
if opts.get('topic'):
t = opts['topic']
with repo.vfs.open('topic', 'w') as f:
f.write(t)
return orig(ui, repo, *args, **opts)
def committextwrap(orig, repo, ctx, subs, extramsg):
ret = orig(repo, ctx, subs, extramsg)
t = repo.currenttopic
if t:
ret = ret.replace("\nHG: branch",
"\nHG: topic '%s'\nHG: branch" % t)
return ret
def mergeupdatewrap(orig, repo, node, branchmerge, force, *args, **kwargs):
matcher = kwargs.get('matcher')
partial = not (matcher is None or matcher.always())
wlock = repo.wlock()
try:
ret = orig(repo, node, branchmerge, force, *args, **kwargs)
if not partial and not branchmerge:
ot = repo.currenttopic
t = ''
pctx = repo[node]
if pctx.phase() > phases.public:
t = pctx.topic()
with repo.vfs.open('topic', 'w') as f:
f.write(t)
if t and t != ot:
repo.ui.status(_("switching to topic %s\n") % t)
return ret
finally:
wlock.release()
def _fixrebase(loaded):
if not loaded:
return
def savetopic(ctx, extra):
if ctx.topic():
extra[constants.extrakey] = ctx.topic()
def newmakeextrafn(orig, copiers):
return orig(copiers + [savetopic])
try:
rebase = extensions.find("rebase")
extensions.wrapfunction(rebase, '_makeextrafn', newmakeextrafn)
except KeyError:
pass
## preserve topic during import/export
def _exporttopic(seq, ctx):
topic = ctx.topic()
if topic:
return 'EXP-Topic %s' % topic
return None
def _importtopic(repo, patchdata, extra, opts):
if 'topic' in patchdata:
extra['topic'] = patchdata['topic']
def setupimportexport(ui):
"""run at ui setup time to install import/export logic"""
cmdutil.extraexport.append('topic')
cmdutil.extraexportmap['topic'] = _exporttopic
cmdutil.extrapreimport.append('topic')
cmdutil.extrapreimportmap['topic'] = _importtopic
patch.patchheadermap.append(('EXP-Topic', 'topic'))