|
# Copyright (C) 2010 - Anton Markov. All rights reserved.
# (with code taken from mercurial/context.py by Matt Mackall)
#
# To enable the "cache-annotate" extension put these lines in your ~/.hgrc:
# [extensions]
# cache-annotate = /path/to/cache-annotate.py
#
# For help on the usage of cache-annotate see:
# hg help cache-annotate
#
# For general help on the annotate command see:
# hg help annotate
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
'''Caches the results of the annotate command
This extension caches the results of the annotate command on every file
and uses it to speed up the annotate command for child revisions of the
file.
Notes:
1. this version of annotate always follows copies and renames;
use the --follow flag to display filenames in the annotations
2. use 'hg annotate --clear-cache' to remove all cached data
3. temporarily disable the extension with --no-cache
Please see 'hg help annotate' for information about the annotate command.
'''
import string
import os
import shutil
from mercurial import bdiff, commands, extensions, store, util
from mercurial.context import filectx
from mercurial.node import nullrev
from mercurial.i18n import _
CACHEPATH = 'annotations/'
def hybridencode(f):
return store._hybridencode(f, lambda path: store._auxencode(path, True))
class annotationcache(object):
''' Provides access to the cache of file annotations.
The cache structure is
/repo-path/.hg/annotations/path-to-file/rev[.{fn}]
The cache is write-once so one does not worry about concurrent
access. A cache file is line-oriented where each line is an
n-tuple of strings separated by the separator character ':'.
If the file has any ancestor with a different name, then we
append .f or .n depending on whether or not we followed the
annotation history to these ancestors. Otherwise a generic
cache is created which works for either case.
'''
def __init__(self, repo, follow = True):
''' Create a new annotations cache for the given repository '''
self.followflag = follow and 'f' or 'n'
self._opener = repo.opener
self.cachepath = repo.join("annotations")
self.sepchar = ':'
# fdcache caches information about existing files:
# fdcache[path] is a file path or a file object if the file exists
# Note: the file object must be open with mode "r"
# fdcache[path] is False if the file does not exist
# fdcache[path] does not exist if the file state is unknown
self.fdcache = {}
def opener(self, path, *args, **kwargs):
return self._opener(CACHEPATH + path, *args, **kwargs)
def makepath(self, filectx):
''' Computes the path to the cache for the given file revision. '''
relpath = os.path.join('data', filectx.path())
encrelpath = hybridencode(relpath)
rev = str(filectx.rev())
return os.path.join(encrelpath, rev)
def findcache(self, path):
''' Finds either the correct (follow or no-follow) cache or a general
cache if the file has no renames
'''
flaggedpath = path + '.' + self.followflag
fullpath = os.path.join(self.cachepath, path)
fullflaggedpath = os.path.join(self.cachepath, flaggedpath)
return ((os.access(fullpath, os.F_OK) and path) or
(os.access(fullflaggedpath, os.F_OK) and flaggedpath))
def __getitem__(self, filectx):
''' Returns the annotations for the given file revision.
Throws IOError is the file does not exist
'''
path = self.makepath(filectx)
fullpath = None
fd = None
# if the file is in the cache, then either the file exists and
# we know the correct or we have a file object open,
# or the file does not exist and we can throw the appropriate
# error right away without trying to open it
if path in self.fdcache:
if self.fdcache[path]:
if isinstance(self.fdcache[path], file):
fd = self.fdcache[path]
else:
fullpath = self.fdcache[path]
else:
raise IOError
if not fullpath:
fullpath = self.findcache(path)
if not fd:
fd = self.opener(fullpath, text=True, mode="r")
self.fdcache[path] = fd
return [tuple(string.split(line.strip(), self.sepchar)) for line in fd]
def __contains__(self, filectx):
''' Checks for the existence of cached annotations for the given
file revision without actually opening the file.
'''
path = self.makepath(filectx)
if not path in self.fdcache:
self.fdcache[path] = self.findcache(path)
return bool(self.fdcache[path])
def write(self, filectx, annotations, wasrenamed):
''' Caches the annotations for the given file revision.
Fails silently if the file cannot be created.
'''
path = self.makepath(filectx)
try:
if wasrenamed:
path = self.makepath(filectx) + '.' + self.followflag
else:
path = self.makepath(filectx)
fd = self.opener(path, text=True, mode="w")
except IOError:
return
for annot in annotations:
fd.write(string.join([str(f) for f in annot], self.sepchar) +
os.linesep)
fd.close()
def clear(self):
''' Deletes all cached annotations '''
try:
shutil.rmtree(self.cachepath)
except (OSError,), err:
if err.errno == os.errno.ENOENT:
return
else:
raise util.Abort("Unable to clear the cache. " +
"Try removing " +
self.cachepath + " manually.")
def cachedannotate(orig, self, follow=False, linenumber=None):
'''This returns tuples of ((ctx, linenumber), line) for each line
in the file, where ctx is the filectx of the node where
that line was last changed and linenumber is the line number
at the first appearance in the managed file.
'''
def decorate(text, filectx):
''' Generate an annotation for every line in the file of the form
(filepath, filerev, linenum, isrenamed, filectx)
Note: this is a performance-critical function
'''
size = len(text.splitlines())
path = filectx.path()
rev = filectx.filerev()
isrenamed = bool(filectx.renamed())
return ([(path, rev, i, isrenamed, filectx) for i in xrange(1, size + 1)], text)
def pair(parent, child):
''' Merge the annotations from two revisions '''
for a1, a2, b1, b2 in bdiff.blocks(parent[1], child[1]):
child[0][b1:b2] = parent[0][a1:a2]
return child
getlog = util.lrucachefunc(lambda x: self._repo.file(x))
def getctx(path, fileid):
''' Retrieves a filectx for the given file and filerev '''
if path == self._path:
log = self._filelog
else:
log = getlog(path)
return filectx(self._repo, path, fileid=fileid, filelog=log)
getctx = util.lrucachefunc(getctx)
# Instantiate an annotation cache object to access the cached data
acache = annotationcache(self._repo, follow)
def parents(f):
''' Retrieves a filectx for every parent of the given revision '''
# we want to reuse filectx objects as much as possible
p = f._path
if f._filerev is None: # working dir
pl = [(n.path(), n.filerev()) for n in f.parents()]
else:
pl = [(p, n) for n in f._filelog.parentrevs(f._filerev)]
# check if there are ancestors of different name
if follow:
r = f.renamed()
if r:
pl[0] = (r[0], getlog(r[0]).rev(r[1]))
return [getctx(p, n) for p, n in pl if n != nullrev]
# use linkrev to find the first changeset where self appeared
if self.rev() != self.linkrev():
base = self.filectx(self.filerev())
else:
base = self
# find all ancestors
needed = {base: 1}
visit = [base]
files = [base._path]
while visit:
f = visit.pop(0)
# check for the the revision in the annotation cache
# if the annotations are cached, stop searching that branch
if f in acache: continue
# if it was not found, we need to find all revisions that this one
# depends on
for p in parents(f):
if p not in needed:
needed[p] = 1
visit.append(p)
if p._path not in files:
files.append(p._path)
else:
# count how many times we'll use this
needed[p] += 1
# sort by revision (per file) which is a topological order
visit = []
for f in files:
visit.extend(n for n in needed if n._path == f)
# The history map contains the mapping from filectx to (decoration, text)
# where decoration is (filepath, filerev, linenumber, filectx)
# and filectx may be None if the annotations were cached
hist = {}
for f in sorted(visit, key=lambda x: x.rev()):
# check for annotations in the cache and use cached if possible
if f in acache:
hist[f] = (acache[f], f.data())
continue
curr = decorate(f.data(), f)
for p in parents(f):
# merge
curr = pair(hist[p], curr)
# trim the history of unneeded revs
needed[p] -= 1
if not needed[p]:
del hist[p]
hist[f] = curr
# Figure out if any entry was renamed
# An entry is renamed if either:
# (1) it comes from another name
# (2) it is marked 'renamed' meaning it depends on another name
def parsebool(s):
return (s == True) or (s == 'True')
isrenamed = any([(parsebool(entry[3]) or entry[0] != f.path())
for entry in hist[f][0]])
# Try to save the entries to the cache
# It may fail silently and we don't care
acache.write(f, [entry[0:4] for entry in hist[f][0]], isrenamed)
def makefullannot(entry):
''' Instantiates missing filectx objects from (path, rev) pairs. '''
if len(entry) < 5:
return (getctx(entry[0], entry[1]), entry[2])
else:
return (entry[4], entry[2])
return zip([makefullannot(e) for e in hist[f][0]],
hist[f][1].splitlines(True))
def clearcache(ui, repo):
cache = annotationcache(repo)
cache.clear()
def uisetup(ui):
''' Setup the extension to handle annotations '''
def annotatewrapper(orig, ui, repo, *pats, **opts):
if opts['clear_cache']:
if not pats:
clearcache(ui, repo)
else:
raise util.Abort("You cannot specify a file with --clear-cache")
elif opts['no_cache']:
orig(ui, repo, *pats, **opts)
else:
extensions.wrapfunction(filectx, 'annotate', cachedannotate)
orig(ui, repo, *pats, **opts)
entry = extensions.wrapcommand(commands.table, 'annotate', annotatewrapper)
entry[1].extend([('', 'clear-cache', False,
_('clear the annotations cache for all files')),
('', 'no-cache', False,
_('temporarily disable caching'))])
|
Loading...