hgext3rd/topic/__init__.py
author timeless@gmail.com
Fri, 08 Jul 2016 16:59:43 +0000
changeset 1969 a604423c1500
parent 1966 e67c526c0a25
child 1971 ec4924ea8bc6
permissions -rw-r--r--
compat: tolerate missing rebase extension

# __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.7'

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.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))

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):
    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'))