sapling/hgext3rd/undo.py
Felix Merk 4fdf562a93 undo: improved performance and prep for hg undo
Implements lighttransaction(repo) which improves performance of hg undo and
prevents infinite loops caused by hooks that run true hg commands.  Also adds
_getrevlog and _invertindex commands, factoring out code that will be reused.
Changes output for gaps when typing hg debugundohistory -l for slightly
better ui and clearer testing. Lastly, changes lock from repo lock to new
undolog lock and tests this.  In the future, with some changes to undo.log,
we may be able to take the lock later, bypassing the need to take it for
read-only operations.

Differential Revision: https://phab.mercurial-scm.org/D50
2017-07-17 13:18:54 -07:00

320 lines
10 KiB
Python

# undo.py: records data in revlog for future undo functionality
#
# Copyright 2017 Facebook, Inc.
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2 or any later version.
from __future__ import absolute_import
from mercurial.i18n import _
from mercurial import (
dispatch,
error,
extensions,
lock as lockmod,
registrar,
revlog,
revset,
revsetlang,
smartset,
transaction,
util,
)
from mercurial.node import (
bin,
hex,
nullid,
)
cmdtable = {}
command = registrar.command(cmdtable)
# Setup
def extsetup(ui):
extensions.wrapfunction(dispatch, 'runcommand', _runcommandwrapper)
# Wrappers
def _runcommandwrapper(orig, lui, repo, cmd, fullargs, *args):
# This wrapper executes whenever a command is run.
# Some commands (eg hg sl) don't actually modify anything
# ie can't be undone, but the command doesn't know this.
command = fullargs
# Check wether undolog is consistent
# ie check wether the undo ext was
# off before this command
safelog(repo, [""])
result = orig(lui, repo, cmd, fullargs, *args)
# record changes to repo
safelog(repo, command)
return result
# Write: Log control
def safelog(repo, command):
'''boilerplate for log command
input:
repo: mercurial.localrepo
command: list of strings, first is string of command run
output: bool
True if changes have been recorded, False otherwise
'''
changes = False
if repo is not None:# some hg commands don't require repo
# undolog specific lock
# allows running command during other commands when
# otherwise legal. Could cause weird undolog states,
# which gap handling generally covers.
with lockmod.lock(repo.vfs, "undologlock",
desc="undolog"):
# developer config: undo._duringundologlock
if repo.ui.configbool('undo', '_duringundologlock'):
repo.hook("duringundologlock")
tr = lighttransaction(repo)
with tr:
changes = log(repo.filtered('visible'), command, tr)
return changes
def lighttransaction(repo):
# full fledged transactions have two serious issues:
# 1. they may cause infite loops through hooks
# that run commands
# 2. they are really expensive performance wise
#
# ligtthransaction avoids certain hooks from being
# executed, doesn't check repo locks, doesn't check
# abandoned tr's (since we only record info) and doesn't
# do any tag handling
vfsmap = {'plain': repo.vfs}
tr = transaction.transaction(repo.ui.warn, repo.svfs, vfsmap,
"_undojournal", "_undolog")
return tr
def log(repo, command, tr):
'''logs data neccesary for undo if repo state has changed
input:
repo: mercurial.localrepo
command: los, first is command to be recorded as run
tr: transaction
output: bool
True if changes recorded
False if no changes to record
'''
newnodes = {
'bookmarks': _logbookmarks(repo, tr),
'draftheads': _logdraftheads(repo, tr),
'workingparent': _logworkingparent(repo, tr),
}
try:
exsistingnodes = _readindex(repo, 0)
except IndexError:
exsistingnodes = {}
if all(newnodes.get(x) == exsistingnodes.get(x) for x in newnodes.keys()):
# no changes to record
return False
else:
newnodes.update({
'date': _logdate(repo, tr),
'command': _logcommand(repo, tr, command),
})
_logindex(repo, tr, newnodes)
# changes have been recorded
return True
# Write: Logs
def writelog(repo, tr, name, revstring):
if tr is None:
raise error.ProgrammingError
# the transaction code doesn't work with vfs
# specifically, repo.recover() assumes svfs?
repo.svfs.makedirs('undolog')
rlog = _getrevlog(repo, name)
node = rlog.addrevision(revstring, tr, 1, nullid, nullid)
return hex(node)
def _logdate(repo, tr):
revstring = " ".join(str(x) for x in util.makedate())
return writelog(repo, tr, "date.i", revstring)
def _logdraftheads(repo, tr):
revs = repo.revs('heads(draft())')
tonode = repo.changelog.node
hexnodes = [hex(tonode(x)) for x in revs]
revstring = "\n".join(sorted(hexnodes))
return writelog(repo, tr, "draftheads.i", revstring)
def _logcommand(repo, tr, command):
revstring = "\0".join(command)
return writelog(repo, tr, "command.i", revstring)
def _logbookmarks(repo, tr):
revstring = "\n".join(sorted('%s %s' % (name, hex(node))
for name, node in repo._bookmarks.iteritems()))
return writelog(repo, tr, "bookmarks.i", revstring)
def _logworkingparent(repo, tr):
revstring = repo['.'].hex()
return writelog(repo, tr, "workingparent.i", revstring)
def _logindex(repo, tr, nodes):
revstring = "\n".join(sorted('%s %s' % (k, v) for k, v in nodes.items()))
return writelog(repo, tr, "index.i", revstring)
# Read
def _readindex(repo, reverseindex, prefetchedrevlog=None):
if prefetchedrevlog is None:
rlog = _getrevlog(repo, 'index.i')
else:
rlog = prefetchedrevlog
index = _invertindex(rlog, reverseindex)
if index < 0 or index > len(rlog) - 1:
raise IndexError
chunk = rlog.revision(index)
indexdict = {}
for row in chunk.split("\n"):
kvpair = row.split(' ', 1)
if kvpair[0]:
indexdict[kvpair[0]] = kvpair[1]
return indexdict
def _readnode(repo, filename, hexnode):
rlog = _getrevlog(repo, filename)
return rlog.revision(bin(hexnode))
# Visualize
"""debug commands and instrumentation for the undo extension
Adds the `debugundohistory` and `debugundosmartlog` commands to visualize
operational history and to give a preview of how undo will behave.
"""
@command('debugundohistory', [
('n', 'index', 0, _("details about specific operation")),
('l', 'list', False, _("list recent undo-able operation"))
])
def debugundohistory(ui, repo, *args, **opts):
""" Print operational history
0 is the most recent operation
"""
if repo is not None:
if opts.get('list'):
if args and args[0].isdigit():
offset = int(args[0])
else:
offset = 0
_debugundolist(ui, repo, offset)
else:
reverseindex = opts.get('index')
if 0 == reverseindex and args and args[0].isdigit():
reverseindex = int(args[0])
_debugundoindex(ui, repo, reverseindex)
def _debugundolist(ui, repo, offset):
offset = abs(offset)
template = "{sub('\0', ' ', undo)}\n"
fm = ui.formatter('debugundohistory', {'template': template})
prefetchedrevlog = _getrevlog(repo, 'index.i')
recentrange = min(5, len(prefetchedrevlog) - offset)
if 0 == recentrange:
fm.startitem()
fm.write('undo', '%s', "None")
for i in range(recentrange):
nodedict = _readindex(repo, i + offset, prefetchedrevlog)
commandstr = _readnode(repo, 'command.i', nodedict['command'])
if "" == commandstr:
commandstr = " -- gap in log -- "
fm.startitem()
fm.write('undo', '%s', str(i + offset) + ": " + commandstr)
fm.end()
def _debugundoindex(ui, repo, reverseindex):
try:
nodedict = _readindex(repo, reverseindex)
except IndexError:
raise error.Abort(_("index out of bounds"))
return
template = "{tabindent(sub('\0', ' ', content))}\n"
fm = ui.formatter('debugundohistory', {'template': template})
cabinet = ('command.i', 'bookmarks.i', 'date.i',
'draftheads.i', 'workingparent.i')
for filename in cabinet:
header = filename[:-2] + ":\n"
rawcontent = _readnode(repo, filename, nodedict[filename[:-2]])
if "date.i" == filename:
splitdate = rawcontent.split(" ")
datetuple = (float(splitdate[0]), int(splitdate[1]))
content = util.datestr(datetuple)
elif "draftheads.i" == filename:
try:
oldnodes = _readindex(repo, reverseindex + 1)
oldheads = _readnode(repo, filename, oldnodes[filename[:-2]])
except IndexError: # index is oldest log
content = rawcontent
else:
content = "ADDED:\n\t" + "\n\t".join(sorted(
set(rawcontent.split("\n"))
- set(oldheads.split("\n"))
))
content += "\nREMOVED:\n\t" + "\n\t".join(sorted(
set(oldheads.split("\n"))
- set(rawcontent.split("\n"))
))
elif "command.i" == filename and "" == rawcontent:
content = "unkown command(s) run, gap in log"
else:
content = rawcontent
fm.startitem()
fm.write('content', '%s', header + content)
fm.end()
# Revset logic
def _getolddrafts(repo, reverseindex):
nodedict = _readindex(repo, reverseindex)
olddraftheads = _readnode(repo, "draftheads.i", nodedict["draftheads"])
oldheadslist = olddraftheads.split("\n")
oldlogrevstring = revsetlang.formatspec('draft() & ancestors(%ls)',
oldheadslist)
urepo = repo.unfiltered()
return urepo.revs(oldlogrevstring)
revsetpredicate = registrar.revsetpredicate()
@revsetpredicate('olddraft')
def _olddraft(repo, subset, x):
"""``olddraft([index])``
previous draft commits
'index' is how many undoable commands you want to look back
an undoable command is one that changed draft heads, bookmarks
and or working copy parent
Note: this revset may include hidden commits
"""
args = revset.getargsdict(x, 'olddraftrevset', 'reverseindex')
reverseindex = revsetlang.getinteger(args.get('reverseindex'),
_('index must be a positive integer'), 1)
revs = _getolddrafts(repo, reverseindex)
return smartset.baseset(revs)
# Tools
def _invertindex(rlog, indexorreverseindex):
return len(rlog) - 1 - indexorreverseindex
def _getrevlog(repo, filename):
path = 'undolog/' + filename
return revlog.revlog(repo.svfs, path)