sapling/eden/scm/edenscm/hgext/smartlog.py
Jun Wu 609330486e smartlog: optimize --all with narrow-heads
Summary:
The `all()` revset is much slower with narrow-heads for correctness. Use an
alternative that is fast.

Reviewed By: markbt

Differential Revision: D20528063

fbshipit-source-id: c8ae35e67e60407406ca81d67878278392626e9a
2020-03-19 10:05:28 -07:00

710 lines
23 KiB
Python

# Copyright (c) Facebook, Inc. and its affiliates.
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2.
"""command to display a relevant subgraph
With this extension installed, Mercurial gains one new command: smartlog.
It displays a subgraph of changesets containing only the changesets relevant
to the user.
::
[smartlog]
# (remote) names to show
repos = , remote/, default/
names = @, master, stable
# move the top non-public stack to the second column
indentnonpublic = True
# Data. Hide draft commits before "hide-before".
# This is used to migrate away from the "recent days" behavior and
# eventually show all visible commits.
hide-before = 2019-2-22
# Default parameter for master
master = remote/master
"""
from __future__ import absolute_import
import contextlib
import datetime
import itertools
import re
import time
from edenscm.mercurial import (
bookmarks,
cmdutil,
commands,
dagop,
error,
extensions,
graphmod,
mutation,
node as nodemod,
obsutil,
phases,
pycompat,
registrar,
revlog,
revset,
revsetlang,
scmutil,
smartset,
templatekw,
templater,
util,
)
from edenscm.mercurial.i18n import _
from edenscm.mercurial.pycompat import range
cmdtable = {}
command = registrar.command(cmdtable)
revsetpredicate = registrar.revsetpredicate()
testedwith = "ships-with-fb-hgext"
commit_info = False
hiddenchanges = 0
# Remove unsupported --limit option.
logopts = [opt for opt in commands.logopts if opt[1] != "limit"]
def _drawendinglines(orig, lines, extra, edgemap, seen):
# if we are going to have only one single column, draw the missing '|'s
# and restore everything to normal. see comment in 'ascii' below for an
# example of what will be changed. note: we do not respect 'graphstyle'
# but always draw '|' here, for simplicity.
if len(seen) == 1 or any(l[0:2] != [" ", " "] for l in lines):
# draw '|' from bottom to top in the 1st column to connect to
# something, like a '/' in the 2nd column, or a '+' in the 1st column.
for line in reversed(lines):
if line[0:2] != [" ", " "]:
break
line[0] = "|"
# undo the wrapfunction
extensions.unwrapfunction(graphmod, "_drawendinglines", _drawendinglines)
# restore the space to '|'
for k, v in pycompat.iteritems(edgemap):
if v == " ":
edgemap[k] = "|"
orig(lines, extra, edgemap, seen)
def uisetup(ui):
# Hide output for fake nodes
def show(orig, self, ctx, *args):
if ctx.node() == "...":
self.ui.write("\n\n\n")
return
res = orig(self, ctx, *args)
if commit_info and ctx == self.repo["."]:
changes = ctx.p1().status(ctx)
prefixes = ["M", "A", "R", "!", "?", "I", "C"]
for prefix, change in zip(prefixes, changes):
for fname in change:
self.ui.write(" {0} {1}\n".format(prefix, fname))
self.ui.write("\n")
return res
extensions.wrapfunction(cmdutil.changeset_printer, "_show", show)
extensions.wrapfunction(cmdutil.changeset_templater, "_show", show)
def ascii(orig, ui, state, type, char, text, coldata):
if type == "F":
# the fake node is used to move draft changesets to the 2nd column.
# there can be at most one fake node, which should also be at the
# top of the graph.
# we should not draw the fake node and its edges, so change its
# edge style to a space, and return directly.
# these are very hacky but it seems to work well and it seems there
# is no other easy choice for now.
edgemap = state["edges"]
for k in pycompat.iterkeys(edgemap):
edgemap[k] = " "
# also we need to hack _drawendinglines to draw the missing '|'s:
# (before) (after)
# o draft o draft
# / /
# |
# o o
extensions.wrapfunction(graphmod, "_drawendinglines", _drawendinglines)
return
orig(ui, state, type, char, text, coldata)
extensions.wrapfunction(graphmod, "ascii", ascii)
templatekeyword = registrar.templatekeyword()
templatefunc = registrar.templatefunc()
@templatekeyword("singlepublicsuccessor")
def singlepublicsuccessor(repo, ctx, templ, **args):
"""String. Get a single public successor for a
given node. If there's none or more than one, return empty string.
This is intended to be used for "Landed as" marking
in `hg sl` output."""
if mutation.enabled(repo):
return ""
successorssets = obsutil.successorssets(repo, ctx.node())
unfiltered = repo.unfiltered()
ctxs = (unfiltered[n] for n in itertools.chain.from_iterable(successorssets))
public = (c.hex() for c in ctxs if not c.mutable() and c != ctx)
first = next(public, "")
second = next(public, "")
return "" if first and second else first
@templatekeyword("shelveenabled")
def shelveenabled(repo, ctx, **args):
"""Bool. Return true if shelve extension is enabled"""
return "shelve" in extensions.enabled().keys()
@templatekeyword("rebasesuccessors")
def rebasesuccessors(repo, ctx, **args):
"""Return all of the node's successors created as a result of rebase"""
if mutation.enabled(repo):
return ""
rsnodes = list(modifysuccessors(ctx, "rebase"))
return templatekw.showlist("rebasesuccessor", rsnodes, args)
@templatekeyword("amendsuccessors")
def amendsuccessors(repo, ctx, **args):
"""Return all of the node's successors created as a result of amend"""
if mutation.enabled(repo):
return ""
asnodes = list(modifysuccessors(ctx, "amend"))
return templatekw.showlist("amendsuccessor", asnodes, args)
@templatekeyword("splitsuccessors")
def splitsuccessors(repo, ctx, **args):
"""Return all of the node's successors created as a result of split"""
if mutation.enabled(repo):
return ""
asnodes = list(modifysuccessors(ctx, "split"))
return templatekw.showlist("splitsuccessor", asnodes, args)
@templatekeyword("foldsuccessors")
def foldsuccessors(repo, ctx, **args):
"""Return all of the node's successors created as a result of fold"""
if mutation.enabled(repo):
return ""
asnodes = list(modifysuccessors(ctx, "fold"))
return templatekw.showlist("foldsuccessor", asnodes, args)
@templatekeyword("histeditsuccessors")
def histeditsuccessors(repo, ctx, **args):
"""Return all of the node's successors created as a result of
histedit
"""
if mutation.enabled(repo):
return ""
asnodes = list(modifysuccessors(ctx, "histedit"))
return templatekw.showlist("histeditsuccessor", asnodes, args)
@templatekeyword("undosuccessors")
def undosuccessors(repo, ctx, **args):
"""Return all of the node's successors created as a result of undo"""
if mutation.enabled(repo):
return ""
asnodes = list(modifysuccessors(ctx, "undo"))
return templatekw.showlist("undosuccessor", asnodes, args)
def successormarkers(ctx):
for data in ctx.repo().obsstore.successors.get(ctx.node(), ()):
yield obsutil.marker(ctx.repo(), data)
def modifysuccessors(ctx, operation):
"""Return all of the node's successors which were created as a result
of a given modification operation"""
repo = ctx.repo().filtered("visible")
for m in successormarkers(ctx):
if m.metadata().get("operation") == operation:
for node in m.succnodes():
try:
repo[node]
except Exception:
# filtered or unknown node
pass
else:
yield nodemod.hex(node)
def sortnodes(nodes, parentfunc, masters):
"""Topologically sorts the nodes, using the parentfunc to find
the parents of nodes. Given a topological tie between children,
any node in masters is chosen last."""
nodes = set(nodes)
childmap = {}
parentmap = {}
roots = []
# Build a child and parent map
for n in nodes:
parents = [p for p in parentfunc(n) if p in nodes]
parentmap[n] = set(parents)
for p in parents:
childmap.setdefault(p, set()).add(n)
if not parents or (len(parents) == 1 and parents[0] == -1) and n != -1:
roots.append(n)
def childsortkey(x):
# Process children in the master line last. This makes them always
# appear on the left side of the dag, resulting in a nice straight
# master line in the ascii output. Otherwise show the oldest first, so
# the graph is approximately in chronological order.
return (x in masters, x)
# Process roots, adding children to the queue as they become roots
results = []
while roots:
n = roots.pop(0)
results.append(n)
if n in childmap:
children = list(childmap[n])
# reverse=True here because we insert(0) below, resulting
# in a reversed insertion of the children.
children = sorted(children, reverse=True, key=childsortkey)
for c in children:
childparents = parentmap[c]
childparents.remove(n)
if len(childparents) == 0:
# insert at the beginning, that way child nodes
# are likely to be output immediately after their
# parents.
roots.insert(0, c)
return results
def getdag(ui, repo, revs, master):
knownrevs = set(revs)
gpcache = {}
results = []
reserved = []
# we store parents together with the parent type information
# but sometimes we need just a list of parents
# [(a,b), (c,d), (e,f)] => [b, d, f]
def unzip(parents):
if parents:
return list(list(zip(*parents))[1])
else:
return list()
# For each rev we need to show, compute it's parents in the dag.
# If we have to reach for a grandparent, insert a fake node so we
# can show '...' in the graph.
# Use 'reversed' to start at the lowest commit so fake nodes are
# placed at their lowest possible positions.
for rev in reversed(revs):
ctx = repo[rev]
# Parents in the dag
parents = sorted(
set(
[
(graphmod.PARENT, p.rev())
for p in ctx.parents()
if p.rev() in knownrevs
]
)
)
# Parents not in the dag
mpars = [
p.rev()
for p in ctx.parents()
if p.rev() != nodemod.nullrev and p.rev() not in unzip(parents)
]
for mpar in mpars:
gp = gpcache.get(mpar)
if gp is None:
gp = gpcache[mpar] = dagop.reachableroots(
repo, smartset.baseset(revs), [mpar]
)
if not gp:
parents.append((graphmod.MISSINGPARENT, mpar))
else:
gp = [g for g in gp if g not in unzip(parents)]
for g in gp:
parents.append((graphmod.GRANDPARENT, g))
results.append((ctx.rev(), "C", ctx, parents))
# Compute parent rev->parents mapping
lookup = {}
for r in results:
lookup[r[0]] = unzip(r[3])
def parentfunc(node):
return lookup.get(node, [])
# Compute the revs on the master line. We use this for sorting later.
masters = set()
queue = [master]
while queue:
m = queue.pop()
if m not in masters:
masters.add(m)
queue.extend(lookup.get(m, []))
# Topologically sort the noderev numbers. Note: unlike the vanilla
# topological sorting, we move master to the top.
order = sortnodes([r[0] for r in results], parentfunc, masters)
order = dict((e[1], e[0]) for e in enumerate(order))
# Sort the actual results based on their position in the 'order'
try:
results.sort(key=lambda x: order[x[0]], reverse=True)
except ValueError: # Happened when 'order' is empty
ui.warn(_("smartlog encountered an error\n"), notice=_("note"))
ui.warn(_("(so the sorting might be wrong.\n\n)"))
results.reverse()
# indent the top non-public stack
if ui.configbool("smartlog", "indentnonpublic", False):
rev, ch, ctx, parents = results[0]
if ctx.phase() != phases.public:
# find a public parent and add a fake node, so the non-public nodes
# will be shown in the non-first column
prev = None
for i in range(1, len(results)):
pctx = results[i][2]
if pctx.phase() == phases.public:
prev = results[i][0]
break
if prev:
reserved.append(prev)
return results, reserved
def addfakerev(results, prev):
# Fake ctx that we stick in the dag so we can special case it later
class fakectx(object):
def __init__(self, rev):
self._rev = rev
def node(self):
return "..."
def obsolete(self):
return False
def invisible(self):
return False
def phase(self):
return None
def rev(self):
return self._rev
def files(self):
return []
def extra(self):
return {}
def closesbranch(self):
return False
# append the fake node to occupy the first column
rev = results[0][0]
fakerev = rev + 1
results.insert(0, (fakerev, "F", fakectx(fakerev), [("P", prev)]))
def _reposnames(ui):
# '' is local repo. This also defines an order precedence for master.
repos = ui.configlist("smartlog", "repos", ["", "remote/", "default/"])
names = ui.configlist("smartlog", "names", ["@", "master", "stable"])
for repo in repos:
for name in names:
yield repo + name
@revsetpredicate("smartlog([heads], [master])")
def smartlogrevset(repo, subset, x):
"""``smartlog([heads], [master])``
Changesets relevent to you.
'heads' overrides what feature branches to include.
(default: 'interestingbookmarks() + heads(draft()) + .')
'master' is the head of the public branch.
(default: 'interestingmaster()')
"""
args = revset.getargsdict(x, "smartlogrevset", "heads master")
if "master" in args:
masterset = revset.getset(repo, subset, args["master"])
else:
masterset = repo.revs("interestingmaster()")
if "heads" in args:
heads = set(revset.getset(repo, subset, args["heads"]))
else:
heads = set(repo.revs("interestingbookmarks() + heads(draft()) + ."))
# Remove "null" commit. "::x" does not support it.
masterset -= smartset.baseset([nodemod.nullrev])
if nodemod.nullrev in heads:
heads.remove(nodemod.nullrev)
# Explicitly disable revnum deprecation warnings.
with repo.ui.configoverride({("devel", "legacy.revnum:real"): ""}):
# Select ancestors that are draft.
drafts = repo.revs("draft() & ::%ld", heads)
# Include parents of drafts, and public heads.
revs = repo.revs(
"parents(%ld) + %ld + %ld + %ld", drafts, drafts, heads, masterset
)
# Include the ancestor of above commits to make the graph connected.
#
# When calculating ancestors, filter commits using 'public()' to reduce the
# number of commits to calculate. This is sound because the above logic
# includes p1 of draft commits, and assume master is public. Practically,
# this optimization can make a 3x difference.
revs = repo.revs("ancestor(%ld & public()) + %ld", revs, revs)
return subset & revs
@revsetpredicate("interestingbookmarks()")
def interestingheads(repo, subset, x):
"""Set of interesting bookmarks (local and remote)"""
rev = repo.changelog.rev
heads = set()
books = bookmarks.bmstore(repo)
ignore = re.compile(repo.ui.config("smartlog", "ignorebookmarks", "!"))
for b in books:
if not ignore.match(b):
heads.add(rev(books[b]))
# add 'interesting' remote bookmarks as well
if util.safehasattr(repo, "names") and "remotebookmarks" in repo.names:
ns = repo.names["remotebookmarks"]
for name in _reposnames(repo.ui):
nodes = ns.namemap(repo, name)
if nodes:
ns.accessed(repo, name)
heads.add(rev(nodes[0]))
return subset & smartset.baseset(heads)
@revsetpredicate("interestingmaster()")
def interestingmaster(repo, subset, x):
"""Interesting 'master' commit"""
names = set(bookmarks.bmstore(repo).keys())
if util.safehasattr(repo, "names") and "remotebookmarks" in repo.names:
names.update(set(repo.names["remotebookmarks"].listnames(repo)))
for name in _reposnames(repo.ui):
if name in names:
revs = repo.revs("%s", name)
break
else:
revs = repo.revs("last(public())")
return subset & revs
@templatefunc("simpledate(date[, tz])")
def simpledate(context, mapping, args):
"""Date. Returns a human-readable date/time that is simplified for
dates and times in the recent past.
"""
ctx = mapping["ctx"]
repo = ctx.repo()
date = templater.evalfuncarg(context, mapping, args[0])
tz = None
if len(args) == 2:
tzname = templater.evalstring(context, mapping, args[1])
if tzname:
try:
import pytz
tz = pytz.timezone(tzname)
except ImportError:
msg = "Couldn't import pytz, using default time zone\n"
repo.ui.warn(msg)
except pytz.UnknownTimeZoneError:
msg = "Unknown time zone: %s\n" % tzname
repo.ui.warn(msg)
then = datetime.datetime.fromtimestamp(date[0], tz)
now = datetime.datetime.now(tz)
td = now.date() - then.date()
if then > now:
# Time is in the future, render it in full
return then.strftime("%Y-%m-%d %H:%M")
elif td.days == 0:
# Today ("Today at HH:MM")
return then.strftime("Today at %H:%M")
elif td.days == 1:
# Yesterday ("Yesterday at HH:MM")
return then.strftime("Yesterday at %H:%M")
elif td.days <= 6:
# In the last week (e.g. "Monday at HH:MM")
return then.strftime("%A at %H:%M")
elif now.year == then.year or td.days <= 90:
# This year or in the last 3 months (e.g. "Jan 05 at HH:MM")
return then.strftime("%b %d at %H:%M")
else:
# Before, render it in full
return then.strftime("%Y-%m-%d %H:%M")
@templatefunc("smartdate(date, threshold, recent, other)")
def smartdate(context, mapping, args):
"""Date. Returns one of two values depending on whether the date provided
is in the past and recent or not."""
date = templater.evalfuncarg(context, mapping, args[0])
threshold = templater.evalinteger(context, mapping, args[1])
now = time.time()
then = date[0]
if now - threshold <= then <= now:
return templater.evalstring(context, mapping, args[2])
else:
return templater.evalstring(context, mapping, args[3])
@command(
"smartlog|sl|slog|sm|sma|smar|smart|smartl|smartlo",
[
("", "master", "", _("master bookmark"), _("BOOKMARK")),
("r", "rev", [], _("show the specified revisions or range"), _("REV")),
("", "all", False, _("don't hide old local changesets"), ""),
("", "commit-info", False, _("show changes in current changeset"), ""),
]
+ logopts,
_("[OPTION]... [[-r] REV]"),
)
def smartlog(ui, repo, *pats, **opts):
"""show a graph of the commits that are relevant to you
Includes:
- Your local commits
- The master bookmark for your repository
- Any commits with local bookmarks
Excludes:
- All commits under master that aren't related to your commits
- Your local commits that are older than a specified date"""
return _smartlog(ui, repo, *pats, **opts)
def getrevs(ui, repo, masterstring, **opts):
global hiddenchanges
hiddenchanges = 0
global commit_info
commit_info = opts.get("commit_info")
headrevs = opts.get("rev")
if headrevs:
headspec = revsetlang.formatspec("%lr", headrevs)
else:
if opts.get("all"):
datefilter = "_all()"
else:
before = ui.config("smartlog", "hide-before")
if before:
datefilter = revsetlang.formatspec("date(%s)", ">%s" % before)
else:
# last 2 weeks
datefilter = "date(-14)"
# Calculate hiddenchanges
allheads = repo.revs("heads(draft()) - . - interestingbookmarks()")
visibleheads = repo.revs("%ld & %r", allheads, datefilter)
hiddenchanges = len(allheads) - len(visibleheads)
headspec = revsetlang.formatspec(
"interestingbookmarks() + (heads(draft()) & %r) + .", datefilter
)
revstring = revsetlang.formatspec(
"smartlog(heads=%r, master=%r)", headspec, masterstring
)
return set(repo.anyrevs([revstring], user=True))
def _smartlog(ui, repo, *pats, **opts):
if opts.get("rev"):
masterfallback = "null"
else:
masterfallback = "interestingmaster()"
masterstring = (
opts.get("master") or ui.config("smartlog", "master") or masterfallback
)
masterrev = repo.anyrevs([masterstring], user=True).first()
revs = getrevs(ui, repo, masterstring, **opts)
if -1 in revs:
revs.remove(-1)
if len(revs) == 0:
return
# Print it!
revdag, reserved = getdag(
ui, repo.unfiltered(), sorted(revs, reverse=True), masterrev
)
displayer = cmdutil.show_changeset(ui, repo, opts, buffered=True)
ui.pager("smartlog")
if ui.config("experimental", "graph.renderer") == "legacy":
overrides = {}
if ui.config("experimental", "graphstyle.grandparent", "2.") == "|":
overrides[("experimental", "graphstyle.grandparent")] = "2."
with ui.configoverride(overrides, "smartlog"):
if reserved:
for prev in reserved:
addfakerev(revdag, prev)
cmdutil.displaygraph(
ui, repo, revdag, displayer, graphmod.asciiedges, None, None
)
else:
cmdutil.rustdisplaygraph(ui, repo, revdag, displayer, reserved=reserved)
try:
with open(repo.localvfs.join("completionhints"), "w+") as f:
for rev in revdag:
commit_hash = rev[2].node()
# Skip fakectxt nodes
if commit_hash != "...":
f.write(nodemod.short(commit_hash) + "\n")
except IOError:
# No write access. No big deal.
pass
global hiddenchanges
if hiddenchanges:
ui.warn(
_("hiding %s old heads without bookmarks\n") % hiddenchanges,
notice=_("note"),
)
ui.warn(_("(use --all to see them)\n"))