Mercurial > evolve
view hgext3rd/topic/__init__.py @ 2897:bd04a614b866
topic: move a status message in the right scope
The finally get runs on failure so we should not advertise rewrite topic in the
case. In addition the 'rewrote' variable can be undefined in the finally.
author | Pierre-Yves David <pierre-yves.david@octobus.net> |
---|---|
date | Fri, 01 Sep 2017 17:37:47 +0200 |
parents | 1e3d97486861 |
children | 3dfc88c06378 |
line wrap: on
line source
# __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 (move to the public phase). Compared to bookmark, topic is reference carried by each changesets of the series instead of just the single head revision. Topic are quite similar to the way named branch work, except they eventualy fade away when the changeset becomes part of the immutable history. Changeset can belong to both a topic and a named branch, but as long as it is mutable, its topic identity will prevail. As a result, default destination for 'update', 'merge', etc... will take topic into account. When a topic is active these operations will only consider other changesets on that topic (and, in some occurence, bare changeset on same branch). When no topic is active, changeset with topic will be ignored and only bare one on the same branch will be taken in account. There is currently two commands to be used with that extension: 'topics' and 'stack'. The 'hg topics' command is used to set the current topic, change and list existing one. 'hg topics --verbose' will list various information related to each topic. The 'stack' will show you information about the stack of commit belonging to your current topic. Topic is offering you aliases reference to changeset in your current topic stack as 't#'. For example, 't1' refers to the root of your stack, 't2' to the second commits, etc. The 'hg stack' command show these number. Push behavior will change a bit with topic. When pushing to a publishing repository the changesets will turn public and the topic data on them will fade away. The logic regarding pushing new heads will behave has before, ignore any topic related data. When pushing to a non-publishing repository (supporting topic), the head checking will be done taking topic data into account. Push will complain about multiple heads on a branch if you push multiple heads with no topic information on them (or multiple public heads). But pushing a new topic will not requires any specific flag. However, pushing multiple heads on a topic will be met with the usual warning. The 'evolve' extension takes 'topic' into account. 'hg evolve --all' will evolve all changesets in the active topic. In addition, by default. 'hg next' and 'hg prev' will stick to the current topic. Be aware that this extension is still an experiment, commands and other features are likely to be change/adjusted/dropped over time as we refine the concept. """ from __future__ import absolute_import import re import time from mercurial.i18n import _ from mercurial import ( cmdutil, commands, context, error, extensions, hg, localrepo, lock as lockmod, merge, namespaces, node, obsolete, obsutil, patch, phases, registrar, scmutil, templatefilters, util, ) from . import ( constants, revset as topicrevset, destination, stack, topicmap, discovery, ) if util.safehasattr(registrar, 'command'): commandfunc = registrar.command else: # compat with hg < 4.3 commandfunc = cmdutil.command cmdtable = {} command = commandfunc(cmdtable) colortable = {'topic.active': 'green', 'topic.list.troubledcount': 'red', 'topic.list.headcount.multiple': 'yellow', 'topic.list.behindcount': 'cyan', 'topic.list.behinderror': 'red', 'topic.stack.index': 'yellow', 'topic.stack.index.base': 'none dim', 'topic.stack.desc.base': 'none dim', 'topic.stack.shortnode.base': 'none dim', '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.shortnode.current': 'cyan', # random pick 'topic.stack.state.unstable': 'red', 'topic.stack.summary.behindcount': 'cyan', 'topic.stack.summary.behinderror': 'red', 'topic.stack.summary.headcount.multiple': 'yellow', # default color to help log output and thg # (first pick I could think off, update as needed 'log.topic': 'green_background', 'topic.active': 'green', } __version__ = '0.3.0.dev' testedwith = '4.0.2 4.1.3 4.2.1' minimumhgversion = '4.0' buglink = 'https://bz.mercurial-scm.org/' def _contexttopic(self, force=False): if not (force or self.mutable()): return '' return self.extra().get(constants.extrakey, '') context.basectx.topic = _contexttopic def _contexttopicidx(self): topic = self.topic() if not topic: # XXX we might want to include t0 here, # however t0 is related to 'currenttopic' which has no place here. return None revlist = stack.getstack(self._repo, topic=topic) try: return revlist.index(self.rev()) except IndexError: # Lets move to the last ctx of the current topic return None context.basectx.topicidx = _contexttopicidx topicrev = re.compile(r'^t\d+$') branchrev = re.compile(r'^b\d+$') def _namemap(repo, name): revs = None if topicrev.match(name): idx = int(name[1:]) ttype = 'topic' tname = topic = repo.currenttopic if not tname: raise error.Abort(_('cannot resolve "%s": no active topic') % name) revs = list(stack.getstack(repo, topic=topic)) elif branchrev.match(name): ttype = 'branch' idx = int(name[1:]) tname = branch = repo[None].branch() revs = list(stack.getstack(repo, branch=branch)) if revs is not None: try: r = revs[idx] except IndexError: msg = _('cannot resolve "%s": %s "%s" has only %d changesets') raise error.Abort(msg % (name, ttype, tname, len(revs) - 1)) # b0 or t0 can be None if r == -1 and idx == 0: msg = _('the %s "%s" has no %s') raise error.Abort(msg % (ttype, tname, name)) return [repo[r].node()] if name not in repo.topics: return [] node = repo.changelog.node return [node(rev) for rev in repo.revs('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) # We need to check whether t0 or b0 is passed to override the default update # behaviour of changing topic and I can't find a better way # to do that as scmutil.revsingle returns the rev number and hence we can't # plug into logic for this into mergemod.update(). extensions.wrapcommand(commands.table, 'update', checkt0) try: evolve = extensions.find('evolve') extensions.wrapfunction(evolve.rewriteutil, "presplitupdate", presplitupdatetopic) except (KeyError, AttributeError): pass cmdutil.summaryhooks.add('topic', summaryhook) def reposetup(ui, repo): if not isinstance(repo, localrepo.localrepository): return # this can be a peer in the ssh case (puzzling) repo = repo.unfiltered() if repo.ui.config('experimental', 'thg.displaynames', None) is None: repo.ui.setconfig('experimental', 'thg.displaynames', 'topics', source='topic-extension') 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 super(topicrepo, self).commit(*args, **kwargs) finally: self.ui.restoreconfig(backup) def commitctx(self, ctx, error=None): topicfilter = topicmap.topicfilter(self.filtername) if topicfilter != self.filtername: other = repo.filtered(topicmap.topicfilter(repo.filtername)) other.commitctx(ctx, error=error) 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] return super(topicrepo, self).commitctx(ctx, error=error) @property def topics(self): if self._topics is not None: return self._topics topics = set(['', self.currenttopic]) for c in self.set('not public()'): topics.add(c.topic()) topics.remove('') self._topics = topics return topics @property def currenttopic(self): return self.vfs.tryread('topic') # overwritten at the instance level by topicmap.py _autobranchmaptopic = True def branchmap(self, topic=None): if topic is None: topic = getattr(repo, '_autobranchmaptopic', False) topicfilter = topicmap.topicfilter(self.filtername) if not topic or topicfilter == self.filtername: return super(topicrepo, self).branchmap() return self.filtered(topicfilter).branchmap() def invalidatevolatilesets(self): # XXX we might be able to move this to something invalidated less often super(topicrepo, self).invalidatevolatilesets() self._topics = None 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 repo._topics = None if util.safehasattr(repo, 'names'): repo.names.addnamespace(namespaces.namespace( 'topics', 'topic', namemap=_namemap, nodemap=_nodemap, listnames=lambda repo: repo.topics)) @command('topics', [ ('', 'clear', False, 'clear active topic if any'), ('r', 'rev', '', 'revset of existing revisions', _('REV')), ('l', 'list', False, 'show the stack of changeset in the topic'), ('', 'age', False, 'show when you last touched the topics'), ('', 'current', None, 'display the current topic only'), ] + commands.formatteropts, _('hg topics [TOPIC]')) def topics(ui, repo, topic=None, clear=False, rev=None, list=False, **opts): """View current topic, set current topic, change topic for a set of revisions, or see all topics. Clear topic on existing topiced revisions: `hg topic --rev <related revset> --clear` Change topic on some revisions: `hg topic <newtopicname> --rev <related revset>` Clear current topic: `hg topic --clear` Set current topic: `hg topic <topicname>` List of topics: `hg topics` List of topics with their last touched time sorted according to it: `hg topic --age` The active topic (if any) will be prepended with a "*". The `--current` flag helps to take active topic into account. For example, if you want to set the topic on all the draft changesets to the active topic, you can do: `hg topic -r "draft()" --current` The --verbose version of this command display various information on the state of each topic.""" current = opts.get('current') if current and topic: raise error.Abort(_("cannot use --current when setting a topic")) if current and clear: raise error.Abort(_("cannot use --current and --clear")) if clear and topic: raise error.Abort(_("cannot use --clear when setting a topic")) if topic: topic = topic.strip() if not topic: raise error.Abort(_("topic name cannot consist entirely of whitespaces")) # Have some restrictions on the topic name just like bookmark name scmutil.checknewlabel(repo, topic, 'topic') if list: if clear or rev: raise error.Abort(_("cannot use --clear or --rev with --list")) if not topic: topic = repo.currenttopic if not topic: raise error.Abort(_('no active topic to list')) return stack.showstack(ui, repo, topic=topic, opts=opts) if rev: if not obsolete.isenabled(repo, obsolete.createmarkersopt): raise error.Abort(_('must have obsolete enabled to change topics')) if clear: topic = None elif opts.get('current'): topic = repo.currenttopic elif not topic: raise error.Abort('changing topic requires a topic name or --clear') if any(not c.mutable() for c in repo.set('%r and public()', rev)): raise error.Abort("can't change topic of a public change") wl = l = txn = None try: wl = repo.wlock() l = repo.lock() txn = repo.transaction('rewrite-topics') rewrote = _changetopics(ui, repo, rev, topic) txn.close() ui.status('changed topic on %d changes\n' % rewrote) finally: lockmod.release(txn, l, wl) repo.invalidate() return if clear: return _changecurrenttopic(repo, None) if topic: return _changecurrenttopic(repo, topic) # `hg topic --current` ret = 0 if current and not repo.currenttopic: ui.write_err(_('no active topic\n')) ret = 1 elif current: fm = ui.formatter('topic', opts) namemask = '%s\n' label = 'topic.active' fm.startitem() fm.write('topic', namemask, repo.currenttopic, label=label) fm.end() else: _listtopics(ui, repo, opts) return ret @command('stack', [ ] + commands.formatteropts, _('hg stack [TOPIC]')) def cmdstack(ui, repo, topic='', **opts): """list all changesets in a topic and other information List the current topic by default. The --verbose version shows short nodes for the commits also. """ if not topic: topic = None branch = None if topic is None and repo.currenttopic: topic = repo.currenttopic if topic is None: branch = repo[None].branch() return stack.showstack(ui, repo, branch=branch, topic=topic, opts=opts) def _changecurrenttopic(repo, newtopic): """changes the current topic.""" if newtopic: with repo.wlock(): with repo.vfs.open('topic', 'w') as f: f.write(newtopic) else: if repo.vfs.exists('topic'): repo.vfs.unlink('topic') def _changetopics(ui, repo, revset, newtopic): """ Changes topic to newtopic of all the revisions in the revset and return the count of revisions whose topic has been changed. """ rewrote = 0 p1 = None p2 = None successors = {} for c in repo.set('%r', revset): 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) oldtopic = fixedextra.get(constants.extrakey, None) if oldtopic == newtopic: continue if newtopic is None: del fixedextra[constants.extrakey] else: fixedextra[constants.extrakey] = newtopic fixedextra[constants.changekey] = c.hex() 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) # While changing topic of set of linear commits, make sure that # we base our commits on new parent rather than old parent which # was obsoleted while changing the topic p1 = c.p1().node() p2 = c.p2().node() if p1 in successors: p1 = successors[p1] if p2 in successors: p2 = successors[p2] mc = context.memctx( repo, (p1, p2), c.description(), c.files(), filectxfn, user=c.user(), date=c.date(), extra=fixedextra) newnode = repo.commitctx(mc) successors[c.node()] = newnode ui.debug('new node id is %s\n' % node.hex(newnode)) obsolete.createmarkers(repo, [(c, (repo[newnode],))]) rewrote += 1 # move the working copy too wctx = repo[None] # in-progress merge is a bit too complex for now. if len(wctx.parents()) == 1: newid = successors.get(wctx.p1().node()) if newid is not None: hg.update(repo, newid, quietempty=True) return rewrote def _listtopics(ui, repo, opts): fm = ui.formatter('topics', opts) showlast = opts.get('age') if showlast: # we have a new function as plugging logic into existing function is # pretty much difficult return _showlasttouched(repo, fm, opts) activetopic = repo.currenttopic namemask = '%s' if repo.topics and ui.verbose: maxwidth = max(len(t) for t in repo.topics) namemask = '%%-%is' % maxwidth for topic in sorted(repo.topics): fm.startitem() marker = ' ' label = 'topic' active = (topic == activetopic) if active: marker = '*' label = 'topic.active' if not ui.quiet: # registering the active data is made explicitly later fm.plain(' %s ' % marker, label=label) fm.write('topic', namemask, topic, label=label) fm.data(active=active) if ui.verbose: # XXX we should include the data even when not verbose data = stack.stackdata(repo, topic=topic) fm.plain(' (') fm.write('branches+', 'on branch: %s', '+'.join(data['branches']), # XXX use list directly after 4.0 is released label='topic.list.branches') fm.plain(', ') fm.write('changesetcount', '%d changesets', data['changesetcount'], label='topic.list.changesetcount') if data['troubledcount']: fm.plain(', ') fm.write('troubledcount', '%d troubled', data['troubledcount'], label='topic.list.troubledcount') if 1 < data['headcount']: fm.plain(', ') fm.write('headcount', '%d heads', data['headcount'], label='topic.list.headcount.multiple') if 0 < data['behindcount']: fm.plain(', ') fm.write('behindcount', '%d behind', data['behindcount'], label='topic.list.behindcount') elif -1 == data['behindcount']: fm.plain(', ') fm.write('behinderror', '%s', _('ambiguous destination'), label='topic.list.behinderror') fm.plain(')') fm.plain('\n') fm.end() def _showlasttouched(repo, fm, opts): topics = repo.topics timedict = _getlasttouched(repo, topics) times = timedict.keys() times.sort() if topics: maxwidth = max(len(t) for t in topics) namemask = '%%-%is' % maxwidth activetopic = repo.currenttopic for timevalue in times: curtopics = timedict[timevalue][1] for topic in curtopics: fm.startitem() marker = ' ' label = 'topic' active = (topic == activetopic) if active: marker = '*' label = 'topic.active' fm.plain(' %s ' % marker, label=label) fm.write('topic', namemask, topic, label=label) fm.data(active=active) fm.plain(' (') if timevalue == -1: timestr = 'not yet touched' else: timestr = templatefilters.age(timedict[timevalue][0]) fm.write('lasttouched', '%s', timestr, label='topic.list.time') fm.plain(')') fm.plain('\n') fm.end() def _getlasttouched(repo, topics): """ Calculates the last time a topic was used. Returns a dictionary of seconds passed from current time for a topic as keys and topic name as values. """ topicstime = {} curtime = time.time() for t in topics: maxtime = (0, 0) trevs = repo.revs("topic(%s)", t) # Need to check for the time of all changesets in the topic, whether # they are obsolete of non-heads # XXX: can we just rely on the max rev number for this for revs in trevs: rt = repo[revs].date() if rt[0] > maxtime[0]: # Can store the rev to gather more info # latesthead = revs maxtime = rt # looking on the markers also to get more information and accurate # last touch time. obsmarkers = obsutil.getmarkers(repo, [repo[revs].node()]) for marker in obsmarkers: rt = marker.date() if rt[0] > maxtime[0]: maxtime = rt # is the topic still yet untouched if not trevs: secspassed = -1 else: secspassed = (curtime - maxtime[0]) try: topicstime[secspassed][1].append(t) except KeyError: topicstime[secspassed] = (maxtime, [t]) return topicstime def summaryhook(ui, repo): t = repo.currenttopic if not t: return # i18n: column positioning for "hg summary" ui.write(_("topic: %s\n") % ui.label(t, 'topic.active')) def commitwrap(orig, ui, repo, *args, **opts): with repo.wlock(): enforcetopic = ui.configbool('experimental', 'enforce-topic') if opts.get('topic'): t = opts['topic'] with repo.vfs.open('topic', 'w') as f: f.write(t) elif not repo.currenttopic and enforcetopic: msg = _("no active topic") hint = _("set a current topic or use '--config " + "experimental.enforce-topic=no' to commit without a topic") raise error.Abort(msg, hint=hint) 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() isrebase = False ist0 = False try: ret = orig(repo, node, branchmerge, force, *args, **kwargs) # The mergeupdatewrap function makes the destination's topic as the # current topic. This is right for merge but wrong for rebase. We check # if rebase is running and update the currenttopic to topic of new # rebased commit. We have explicitly stored in config if rebase is # running. if repo.ui.hasconfig('experimental', 'topicrebase'): isrebase = True if repo.ui.configbool('_internal', 'keep-topic'): ist0 = True if ((not partial and not branchmerge) or isrebase) and not ist0: 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) elif ist0: repo.ui.status(_("preserving the current topic '%s'\n") % repo.currenttopic) return ret finally: wlock.release() def checkt0(orig, ui, repo, node=None, rev=None, *args, **kwargs): thezeros = set(['t0', 'b0']) backup = repo.ui.backupconfig('_internal', 'keep-topic') try: if node in thezeros or rev in thezeros: repo.ui.setconfig('_internal', 'keep-topic', 'yes', source='topic-extension') return orig(ui, repo, node, rev, *args, **kwargs) finally: repo.ui.restoreconfig(backup) 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]) def setrebaseconfig(orig, ui, repo, **opts): repo.ui.setconfig('experimental', 'topicrebase', 'yes', source='topic-extension') return orig(ui, repo, **opts) try: rebase = extensions.find("rebase") extensions.wrapfunction(rebase, '_makeextrafn', newmakeextrafn) # This exists to store in the config that rebase is running so that we can # update the topic according to rebase. This is a hack and should be removed # when we have better options. extensions.wrapcommand(rebase.cmdtable, 'rebase', setrebaseconfig) 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')) ## preserve topic during split def presplitupdatetopic(original, repo, ui, prev, ctx): # Save topic of revision topic = None if util.safehasattr(ctx, 'topic'): topic = ctx.topic() # Update the working directory original(repo, ui, prev, ctx) # Restore the topic if need if topic: _changecurrenttopic(repo, topic)