sapling/hgext3rd/undo.py
Jun Wu c1a9fbc79e undo: move undolog to vfs and mark as lock-safe
The upstream has added some warning around repo.svfs/vfs write operations
without taking locks. For undo, we don't want to take the repo lock (since
that means read-only operations like `hg log` will be blocked by another
`hg commit`). So undo has its own locking and own transaction (for lower
overhead). Therefore move all files we may write (including lock and
transaction files) to `.hg/undolog` and whitelist the directory at
`localrepository._wlockfreeprefix` to bypass the checks.

Differential Revision: https://phab.mercurial-scm.org/D112
2017-07-18 11:30:05 -07:00

321 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,
localrepo,
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)
# undo has its own locking, whitelist itself to bypass repo lock audit
localrepo.localrepository._wlockfreeprefix.add('undolog/')
# 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.
repo.vfs.makedirs('undolog')
with lockmod.lock(repo.vfs, "undolog/lock", 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.vfs, vfsmap,
"undolog/tr.journal", "undolog/tr.undo")
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
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.vfs, path)