view hgext3rd/topic/stack.py @ 4285:69fb9e41ce2b

stack: add a --children flag to stack If a commit in the stack has been merged outside the current topic, show a trailing status of (external-children)
author James Reynolds <jreynolds@backstage.com>
date Fri, 21 Sep 2018 11:44:34 -0400
parents fb4801478d5d
children e10ebc58926e
line wrap: on
line source

# stack.py - code related to stack workflow
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2 or any later version.
from mercurial.i18n import _
from mercurial import (
    destutil,
    context,
    error,
    node,
    phases,
    obsolete,
    util,
)
from .evolvebits import (
    _singlesuccessor,
    MultipleSuccessorsError,
    builddependencies,
)

short = node.short

# TODO: compat

if not util.safehasattr(context.basectx, 'orphan'):
    context.basectx.orphan = context.basectx.unstable

if not util.safehasattr(context.basectx, 'isunstable'):
    context.basectx.isunstable = context.basectx.troubled

def parseusername(user):
    """parses the ctx user and returns the username without email ID if
    possible, otherwise returns the mail address from that"""
    username = None
    if user:
        # user is of form "abc <abc@xyz.com>"
        username = user.split('<')[0]
        if not username:
            # assuming user is of form "<abc@xyz.com>"
            if len(user) > 1:
                username = user[1:-1]
            else:
                username = user
        username = username.strip()

    return username

def _stackcandidates(repo):
    """build the smaller set of revs that might be part of a stack.

    The intend is to build something more efficient than what revsets do in
    this area.
    """
    phasecache = repo._phasecache
    if not phasecache._phasesets:
        return repo.revs('(not public()) - obsolete()')
    if any(s is None for s in phasecache._phasesets):
        return repo.revs('(not public()) - obsolete()')

    result = set()
    for s in phasecache._phasesets[phases.draft:]:
        result |= s

    result -= obsolete.getrevs(repo, 'obsolete')
    return result

class stack(object):
    """object represent a stack and common logic associated to it."""

    def __init__(self, repo, branch=None, topic=None):
        self._repo = repo
        self.branch = branch
        self.topic = topic
        self.behinderror = None

        subset = _stackcandidates(repo)

        if topic is not None and branch is not None:
            raise error.ProgrammingError('both branch and topic specified (not defined yet)')
        elif topic is not None:
            trevs = repo.revs("%ld and topic(%s)", subset, topic)
        elif branch is not None:
            trevs = repo.revs("%ld and branch(%s) - topic()", subset, branch)
        else:
            raise error.ProgrammingError('neither branch and topic specified (not defined yet)')
        self._revs = trevs

    def __iter__(self):
        return iter(self.revs)

    def __getitem__(self, index):
        return self.revs[index]

    def index(self, item):
        return self.revs.index(item)

    @util.propertycache
    def _dependencies(self):
        deps, rdeps = builddependencies(self._repo, self._revs)

        repo = self._repo
        srcpfunc = repo.changelog.parentrevs

        ### post process to skip over possible gaps in the stack
        #
        # For example in the following situation, we need to detect that "t3"
        # indirectly depends on t2.
        #
        #  o t3
        #  |
        #  o other
        #  |
        #  o t2
        #  |
        #  o t1

        pmap = {}

        def pfuncrev(repo, rev):
            """a special "parent func" that also consider successors"""
            parents = pmap.get(rev)
            if parents is None:
                parents = [repo[_singlesuccessor(repo, repo[p])].rev()
                           for p in srcpfunc(rev) if 0 <= p]
                pmap[rev] = parents
            return parents

        revs = self._revs
        stackrevs = set(self._revs)
        for root in [r for r in revs if not deps[r]]:
            seen = set()
            stack = [root]
            while stack:
                current = stack.pop()
                for p in pfuncrev(repo, current):
                    if p in seen:
                        continue
                    seen.add(p)
                    if p in stackrevs:
                        rdeps[p].add(root)
                        deps[root].add(p)
                    elif phases.public < repo[p].phase():
                        # traverse only if we did not found a proper candidate
                        stack.append(p)

        return deps, rdeps

    @util.propertycache
    def revs(self):
        # some duplication/change from _orderrevs because we use a post
        # processed dependency graph.

        # Step 1: compute relation of revision with each other
        dependencies, rdependencies = self._dependencies
        dependencies = dependencies.copy()
        rdependencies = rdependencies.copy()
        # Step 2: Build the ordering
        # Remove the revisions with no dependency(A) and add them to the ordering.
        # Removing these revisions leads to new revisions with no dependency (the
        # one depending on A) that we can remove from the dependency graph and add
        # to the ordering. We progress in a similar fashion until the ordering is
        # built
        solvablerevs = [r for r in sorted(dependencies.keys())
                        if not dependencies[r]]
        revs = []
        while solvablerevs:
            rev = solvablerevs.pop()
            for dependent in rdependencies[rev]:
                dependencies[dependent].remove(rev)
                if not dependencies[dependent]:
                    solvablerevs.append(dependent)
            del dependencies[rev]
            revs.append(rev)

        revs.extend(sorted(dependencies))
        # step 3: add t0
        if revs:
            pt1 = self._repo[revs[0]].p1()
        else:
            pt1 = self._repo['.']

        if pt1.obsolete():
            pt1 = self._repo[_singlesuccessor(self._repo, pt1)]
        revs.insert(0, pt1.rev())
        return revs

    @util.propertycache
    def changesetcount(self):
        return len(self._revs)

    @util.propertycache
    def troubledcount(self):
        return len([r for r in self._revs if self._repo[r].isunstable()])

    @util.propertycache
    def heads(self):
        revs = self.revs[1:]
        deps, rdeps = self._dependencies
        return [r for r in revs if not rdeps[r]]

    @util.propertycache
    def behindcount(self):
        revs = self.revs[1:]
        deps, rdeps = self._dependencies
        if revs:
            minroot = [min(r for r in revs if not deps[r])]
            try:
                dest = destutil.destmerge(self._repo, action='rebase',
                                          sourceset=minroot,
                                          onheadcheck=False)
                return len(self._repo.revs("only(%d, %ld)", dest, minroot))
            except error.NoMergeDestAbort:
                return 0
            except error.ManyMergeDestAbort as exc:
                # XXX we should make it easier for upstream to provide the information
                self.behinderror = str(exc).split('-', 1)[0].rstrip()
                return -1
        return 0

    @util.propertycache
    def branches(self):
        branches = sorted(set(self._repo[r].branch() for r in self._revs))
        if not branches:
            branches = set([self._repo[None].branch()])
        return branches

def labelsgen(prefix, labelssuffix):
    """ Takes a label prefix and a list of suffixes. Returns a string of the prefix
    formatted with each suffix separated with a space.
    """
    return ' '.join(prefix % suffix for suffix in labelssuffix)

def showstack(ui, repo, branch=None, topic=None, opts=None):
    if opts is None:
        opts = {}

    if topic is not None and branch is not None:
        msg = 'both branch and topic specified [%s]{%s}(not defined yet)'
        msg %= (branch, topic)
        raise error.ProgrammingError(msg)
    elif topic is not None:
        prefix = 's'
        if topic not in repo.topics:
            raise error.Abort(_('cannot resolve "%s": no such topic found') % topic)
    elif branch is not None:
        prefix = 's'
    else:
        raise error.ProgrammingError('neither branch and topic specified (not defined yet)')

    fm = ui.formatter('topicstack', opts)
    prev = None
    entries = []
    idxmap = {}

    label = 'topic'
    if topic == repo.currenttopic:
        label = 'topic.active'

    data = stackdata(repo, branch=branch, topic=topic)
    empty = False
    if data['changesetcount'] == 0:
        empty = True
    if topic is not None:
        fm.plain(_('### topic: %s')
                 % ui.label(topic, label),
                 label='topic.stack.summary.topic')

        if 1 < data['headcount']:
            fm.plain(' (')
            fm.plain('%d heads' % data['headcount'],
                     label='topic.stack.summary.headcount.multiple')
            fm.plain(')')
        fm.plain('\n')
    fm.plain(_('### target: %s (branch)')
             % '+'.join(data['branches']), # XXX handle multi branches
             label='topic.stack.summary.branches')
    if topic is None:
        if 1 < data['headcount']:
            fm.plain(' (')
            fm.plain('%d heads' % data['headcount'],
                     label='topic.stack.summary.headcount.multiple')
            fm.plain(')')
    else:
        if data['behindcount'] == -1:
            fm.plain(', ')
            fm.plain('ambiguous rebase destination - %s' % data['behinderror'],
                     label='topic.stack.summary.behinderror')
        elif data['behindcount']:
            fm.plain(', ')
            fm.plain('%d behind' % data['behindcount'], label='topic.stack.summary.behindcount')
    fm.plain('\n')

    if empty:
        fm.plain(_("(stack is empty)\n"))

    for idx, r in enumerate(stack(repo, branch=branch, topic=topic), 0):
        ctx = repo[r]
        # special case for t0, b0 as it's hard to plugin into rest of the logic
        if idx == 0:
            # t0, b0 can be None
            if r == -1:
                continue
            entries.append((idx, False, ctx))
            prev = ctx.rev()
            continue
        p1 = ctx.p1()
        p2 = ctx.p2()
        if p1.obsolete():
            try:
                p1 = repo[_singlesuccessor(repo, p1)]
            except MultipleSuccessorsError as e:
                successors = e.successorssets
                if len(successors) > 1:
                    # case of divergence which we don't handle yet
                    raise
                p1 = repo[successors[0][-1]]

        if p2.node() != node.nullid:
            entries.append((idxmap.get(p1.rev()), False, p1))
            entries.append((idxmap.get(p2.rev()), False, p2))
        elif p1.rev() != prev and p1.node() != node.nullid:
            entries.append((idxmap.get(p1.rev()), False, p1))
        entries.append((idx, True, ctx))
        idxmap[ctx.rev()] = idx
        prev = r

    # super crude initial version
    for idx, isentry, ctx in entries[::-1]:

        symbol = None
        states = []
        msg = ''
        iscurrentrevision = repo.revs('%d and parents()', ctx.rev())
        if opts.get('children'):
            if branch:
                t_msg = '-branch("%s")' % branch
            if topic:
                t_msg = '-topic("%s")' % topic
            rev_msg = 'children(%s) and merge() %s'
            revisions = repo.revs(rev_msg % (ctx.rev(), t_msg))
            len_rev = len(revisions)
            if len_rev > 0:
                msg = 'external-children'

        if iscurrentrevision:
            symbol = '@'
            if msg:
                states.append('current - ' + msg)
            else:
                states.append('current')

        if ctx.orphan():
            symbol = '$'
            if msg:
                states.append('unstable - ' + msg)
            else:
                states.append('unstable')

        if not isentry:
            symbol = '^'
            # "base" is kind of a "ghost" entry
            states.append('base')

        # none of the above if statments get executed
        if not symbol:
            symbol = ':'
            if msg:
                states.append(msg)
            else:
                states.append('clean')

        states.sort()

        fm.startitem()
        fm.data(isentry=isentry)

        if idx is None:
            fm.plain('  ')
            if ui.verbose:
                fm.plain('              ')
        else:
            fm.write('topic.stack.index', '%s%%d' % prefix, idx,
                     label='topic.stack.index ' + labelsgen('topic.stack.index.%s', states))
            if ui.verbose:
                fm.write('topic.stack.shortnode', '(%s)', short(ctx.node()),
                         label='topic.stack.shortnode ' + labelsgen('topic.stack.shortnode.%s', states))
        fm.write('topic.stack.state.symbol', '%s', symbol,
                 label='topic.stack.state ' + labelsgen('topic.stack.state.%s', states))
        fm.plain(' ')
        fm.write('topic.stack.desc', '%s', ctx.description().splitlines()[0],
                 label='topic.stack.desc ' + labelsgen('topic.stack.desc.%s', states))
        fm.condwrite(states != ['clean'] and idx is not None, 'topic.stack.state',
                     ' (%s)', fm.formatlist(states, 'topic.stack.state'),
                     label='topic.stack.state ' + labelsgen('topic.stack.state.%s', states))
        fm.plain('\n')
    fm.end()

def stackdata(repo, branch=None, topic=None):
    """get various data about a stack

    :changesetcount: number of non-obsolete changesets in the stack
    :troubledcount: number on troubled changesets
    :headcount: number of heads on the topic
    :behindcount: number of changeset on rebase destination
    """
    data = {}
    current = stack(repo, branch, topic)
    data['changesetcount'] = current.changesetcount
    data['troubledcount'] = current.troubledcount
    data['headcount'] = len(current.heads)
    data['behindcount'] = current.behindcount
    data['behinderror'] = current.behinderror
    data['branches'] = current.branches
    return data