# show.py - Extension implementing `hg show` # # Copyright 2017 Gregory Szorc # # This software may be used and distributed according to the terms of the # GNU General Public License version 2 or any later version. """unified command to show various repository information (EXPERIMENTAL) This extension provides the :hg:`show` command, which provides a central command for displaying commonly-accessed repository data and views of that data. The following config options can influence operation. ``commands`` ------------ ``show.aliasprefix`` List of strings that will register aliases for views. e.g. ``s`` will effectively set config options ``alias.s = show `` for all views. i.e. `hg swork` would execute `hg show work`. Aliases that would conflict with existing registrations will not be performed. """ from __future__ import annotations from mercurial.i18n import _ from mercurial.node import nullrev from mercurial import ( cmdutil, commands, destutil, error, formatter, graphmod, logcmdutil, phases, pycompat, registrar, revset, revsetlang, scmutil, ) # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should # be specifying the version(s) of Mercurial they are tested with, or # leave the attribute unspecified. testedwith = b'ships-with-hg-core' cmdtable = {} command = registrar.command(cmdtable) revsetpredicate = registrar.revsetpredicate() class showcmdfunc(registrar._funcregistrarbase): """Register a function to be invoked for an `hg show `.""" # Used by _formatdoc(). _docformat = b'%s -- %s' def _extrasetup(self, name, func, fmtopic=None, csettopic=None): """Called with decorator arguments to register a show view. ``name`` is the sub-command name. ``func`` is the function being decorated. ``fmtopic`` is the topic in the style that will be rendered for this view. ``csettopic`` is the topic in the style to be used for a changeset printer. If ``fmtopic`` is specified, the view function will receive a formatter instance. If ``csettopic`` is specified, the view function will receive a changeset printer. """ func._fmtopic = fmtopic func._csettopic = csettopic showview = showcmdfunc() @command( b'show', [ # TODO: Switch this template flag to use cmdutil.formatteropts if # 'hg show' becomes stable before --template/-T is stable. For now, # we are putting it here without the '(EXPERIMENTAL)' flag because it # is an important part of the 'hg show' user experience and the entire # 'hg show' experience is experimental. (b'T', b'template', b'', b'display with template', _(b'TEMPLATE')), ], _(b'VIEW'), helpcategory=command.CATEGORY_CHANGE_NAVIGATION, ) def show(ui, repo, view=None, template=None): """show various repository information A requested view of repository data is displayed. If no view is requested, the list of available views is shown and the command aborts. .. note:: There are no backwards compatibility guarantees for the output of this command. Output may change in any future Mercurial release. Consumers wanting stable command output should specify a template via ``-T/--template``. List of available views: """ if ui.plain() and not template: hint = _(b'invoke with -T/--template to control output format') raise error.Abort( _(b'must specify a template in plain mode'), hint=hint ) views = showview._table if not view: ui.pager(b'show') # TODO consider using formatter here so available views can be # rendered to custom format. ui.write(_(b'available views:\n')) ui.write(b'\n') for name, func in sorted(views.items()): ui.write(b'%s\n' % pycompat.sysbytes(func.__doc__)) ui.write(b'\n') raise error.Abort( _(b'no view requested'), hint=_(b'use "hg show VIEW" to choose a view'), ) # TODO use same logic as dispatch to perform prefix matching. if view not in views: raise error.Abort( _(b'unknown view: %s') % view, hint=_(b'run "hg show" to see available views'), ) template = template or b'show' fn = views[view] ui.pager(b'show') if fn._fmtopic: fmtopic = b'show%s' % fn._fmtopic with ui.formatter(fmtopic, {b'template': template}) as fm: return fn(ui, repo, fm) elif fn._csettopic: ref = b'show%s' % fn._csettopic spec = formatter.lookuptemplate(ui, ref, template) displayer = logcmdutil.changesettemplater(ui, repo, spec, buffered=True) return fn(ui, repo, displayer) else: return fn(ui, repo) @showview(b'bookmarks', fmtopic=b'bookmarks') def showbookmarks(ui, repo, fm): """bookmarks and their associated changeset""" marks = repo._bookmarks if not len(marks): # This is a bit hacky. Ideally, templates would have a way to # specify an empty output, but we shouldn't corrupt JSON while # waiting for this functionality. if not isinstance(fm, formatter.jsonformatter): ui.write(_(b'(no bookmarks set)\n')) return revs = [repo[node].rev() for node in marks.values()] active = repo._activebookmark longestname = max(len(b) for b in marks) nodelen = longestshortest(repo, revs) for bm, node in sorted(marks.items()): fm.startitem() fm.context(ctx=repo[node]) fm.write(b'bookmark', b'%s', bm) fm.write(b'node', fm.hexfunc(node), fm.hexfunc(node)) fm.data( active=bm == active, longestbookmarklen=longestname, nodelen=nodelen ) @showview(b'stack', csettopic=b'stack') def showstack(ui, repo, displayer): """current line of work""" wdirctx = repo[b'.'] if wdirctx.rev() == nullrev: raise error.Abort( _( b'stack view only available when there is a ' b'working directory' ) ) if wdirctx.phase() == phases.public: ui.write( _( b'(empty stack; working directory parent is a published ' b'changeset)\n' ) ) return # TODO extract "find stack" into a function to facilitate # customization and reuse. baserev = destutil.stackbase(ui, repo) basectx = None if baserev is None: baserev = wdirctx.rev() stackrevs = {wdirctx.rev()} else: stackrevs = set(repo.revs(b'%d::.', baserev)) ctx = repo[baserev] if ctx.p1().rev() != nullrev: basectx = ctx.p1() # And relevant descendants. branchpointattip = False cl = repo.changelog for rev in cl.descendants([wdirctx.rev()]): ctx = repo[rev] # Will only happen if . is public. if ctx.phase() == phases.public: break stackrevs.add(ctx.rev()) # ctx.children() within a function iterating on descandants # potentially has severe performance concerns because revlog.children() # iterates over all revisions after ctx's node. However, the number of # draft changesets should be a reasonably small number. So even if # this is quadratic, the perf impact should be minimal. if len(ctx.children()) > 1: branchpointattip = True break stackrevs = list(sorted(stackrevs, reverse=True)) # Find likely target heads for the current stack. These are likely # merge or rebase targets. if basectx: # TODO make this customizable? newheads = set( repo.revs( b'heads(%d::) - %ld - not public()', basectx.rev(), stackrevs ) ) else: newheads = set() allrevs = set(stackrevs) | newheads | {baserev} nodelen = longestshortest(repo, allrevs) try: cmdutil.findcmd(b'rebase', commands.table) haverebase = True except (error.AmbiguousCommand, error.UnknownCommand): haverebase = False # TODO use templating. # TODO consider using graphmod. But it may not be necessary given # our simplicity and the customizations required. # TODO use proper graph symbols from graphmod tres = formatter.templateresources(ui, repo) shortesttmpl = formatter.maketemplater( ui, b'{shortest(node, %d)}' % nodelen, resources=tres ) def shortest(ctx): return shortesttmpl.renderdefault({b'ctx': ctx, b'node': ctx.hex()}) # We write out new heads to aid in DAG awareness and to help with decision # making on how the stack should be reconciled with commits made since the # branch point. if newheads: # Calculate distance from base so we can render the count and so we can # sort display order by commit distance. revdistance = {} for head in newheads: # There is some redundancy in DAG traversal here and therefore # room to optimize. ancestors = cl.ancestors([head], stoprev=basectx.rev()) revdistance[head] = len(list(ancestors)) sourcectx = repo[stackrevs[-1]] sortedheads = sorted( newheads, key=lambda x: revdistance[x], reverse=True ) for i, rev in enumerate(sortedheads): ctx = repo[rev] if i: ui.write(b': ') else: ui.write(b' ') ui.writenoi18n(b'o ') displayer.show(ctx, nodelen=nodelen) displayer.flush(ctx) ui.write(b'\n') if i: ui.write(b':/') else: ui.write(b' /') ui.write(b' (') ui.write( _(b'%d commits ahead') % revdistance[rev], label=b'stack.commitdistance', ) if haverebase: # TODO may be able to omit --source in some scenarios ui.write(b'; ') ui.write( ( b'hg rebase --source %s --dest %s' % (shortest(sourcectx), shortest(ctx)) ), label=b'stack.rebasehint', ) ui.write(b')\n') ui.write(b':\n: ') ui.write(_(b'(stack head)\n'), label=b'stack.label') if branchpointattip: ui.write(b' \\ / ') ui.write(_(b'(multiple children)\n'), label=b'stack.label') ui.write(b' |\n') for rev in stackrevs: ctx = repo[rev] symbol = b'@' if rev == wdirctx.rev() else b'o' if newheads: ui.write(b': ') else: ui.write(b' ') ui.write(symbol, b' ') displayer.show(ctx, nodelen=nodelen) displayer.flush(ctx) ui.write(b'\n') # TODO display histedit hint? if basectx: # Vertically and horizontally separate stack base from parent # to reinforce stack boundary. if newheads: ui.write(b':/ ') else: ui.write(b' / ') ui.write(_(b'(stack base)'), b'\n', label=b'stack.label') ui.writenoi18n(b'o ') displayer.show(basectx, nodelen=nodelen) displayer.flush(basectx) ui.write(b'\n') @revsetpredicate(b'_underway([commitage[, headage]])') def underwayrevset(repo, subset, x): args = revset.getargsdict(x, b'underway', b'commitage headage') if b'commitage' not in args: args[b'commitage'] = None if b'headage' not in args: args[b'headage'] = None # We assume callers of this revset add a topographical sort on the # result. This means there is no benefit to making the revset lazy # since the topographical sort needs to consume all revs. # # With this in mind, we build up the set manually instead of constructing # a complex revset. This enables faster execution. # Mutable changesets (non-public) are the most important changesets # to return. ``not public()`` will also pull in obsolete changesets if # there is a non-obsolete changeset with obsolete ancestors. This is # why we exclude obsolete changesets from this query. rs = b'not public() and not obsolete()' rsargs = [] if args[b'commitage']: rs += b' and date(%s)' rsargs.append( revsetlang.getstring( args[b'commitage'], _(b'commitage requires a string') ) ) mutable = repo.revs(rs, *rsargs) relevant = revset.baseset(mutable) # Add parents of mutable changesets to provide context. relevant += repo.revs(b'parents(%ld)', mutable) # We also pull in (public) heads if they a) aren't closing a branch # b) are recent. rs = b'head() and not closed()' rsargs = [] if args[b'headage']: rs += b' and date(%s)' rsargs.append( revsetlang.getstring( args[b'headage'], _(b'headage requires a string') ) ) relevant += repo.revs(rs, *rsargs) # Add working directory parent. wdirrev = repo[b'.'].rev() if wdirrev != nullrev: relevant += revset.baseset({wdirrev}) return subset & relevant @showview(b'work', csettopic=b'work') def showwork(ui, repo, displayer): """changesets that aren't finished""" # TODO support date-based limiting when calling revset. revs = repo.revs(b'sort(_underway(), topo)') nodelen = longestshortest(repo, revs) revdag = graphmod.dagwalker(repo, revs) ui.setconfig(b'experimental', b'graphshorten', True) logcmdutil.displaygraph( ui, repo, revdag, displayer, graphmod.asciiedges, props={b'nodelen': nodelen}, ) def extsetup(ui): # Alias `hg ` to `hg show `. for prefix in ui.configlist(b'commands', b'show.aliasprefix'): for view in showview._table: name = b'%s%s' % (prefix, view) choice, allcommands = cmdutil.findpossible( name, commands.table, strict=True ) # This alias is already a command name. Don't set it. if name in choice: continue # Same for aliases. if ui.config(b'alias', name, None): continue ui.setconfig(b'alias', name, b'show %s' % view, source=b'show') def longestshortest(repo, revs, minlen=4): """Return the length of the longest shortest node to identify revisions. The result of this function can be used with the ``shortest()`` template function to ensure that a value is unique and unambiguous for a given set of nodes. The number of revisions in the repo is taken into account to prevent a numeric node prefix from conflicting with an integer revision number. If we fail to do this, a value of e.g. ``10023`` could mean either revision 10023 or node ``10023abc...``. """ if not revs: return minlen cl = repo.changelog return max( len(scmutil.shortesthexnodeidprefix(repo, cl.node(r), minlen)) for r in revs ) # Adjust the docstring of the show command so it shows all registered views. # This is a bit hacky because it runs at the end of module load. When moved # into core or when another extension wants to provide a view, we'll need # to do this more robustly. # TODO make this more robust. def _updatedocstring(): longest = max(map(len, showview._table.keys())) entries = [] for key in sorted(showview._table.keys()): entries.append( r'%s %s' % ( pycompat.sysstr(key.ljust(longest)), showview._table[key]._origdoc, ) ) cmdtable[b'show'][0].__doc__ = pycompat.sysstr(b'%s\n\n%s\n ') % ( pycompat.cleandoc(cmdtable[b'show'][0].__doc__), pycompat.sysstr(b'\n\n').join(entries), ) _updatedocstring()