# HG changeset patch # User Christian Ebert # Date 1199467329 -3600 # Node ID 0637d97a8cb48817637f4ed4b995c41c3e2b9736 # Parent dd5a501cb97fbe6aea0fd25f8fc7451f5285ea21 Add extension for filewise RCS-keyword expansion in working dir - keywords are only expanded working dir, not in change history - completely customizable keywords/expansions based on hg templates - intrusiveness/slowdown can be reduced by specifying precise [keyword] file patterns - can be turned off/on anytime - changesets can be exchanged regardless of remote/local keyword settings diff -r dd5a501cb97f -r 0637d97a8cb4 hgext/keyword.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hgext/keyword.py Fri Jan 04 18:22:09 2008 +0100 @@ -0,0 +1,498 @@ +# keyword.py - $Keyword$ expansion for Mercurial +# +# Copyright 2007 Christian Ebert +# +# This software may be used and distributed according to the terms +# of the GNU General Public License, incorporated herein by reference. +# +# $Id$ +# +# Keyword expansion hack against the grain of a DSCM +# +# There are many good reasons why this is not needed in a distributed +# SCM, still it may be useful in very small projects based on single +# files (like LaTeX packages), that are mostly addressed to an audience +# not running a version control system. +# +# For in-depth discussion refer to +# . +# +# Keyword expansion is based on Mercurial's changeset template mappings. +# +# Binary files are not touched. +# +# Setup in hgrc: +# +# [extensions] +# # enable extension +# hgext.keyword = +# +# Files to act upon/ignore are specified in the [keyword] section. +# Customized keyword template mappings in the [keywordmaps] section. +# +# Run "hg help keyword" and "hg kwdemo" to get info on configuration. + +'''keyword expansion in local repositories + +This extension expands RCS/CVS-like or self-customized $Keywords$ +in tracked text files selected by your configuration. + +Keywords are only expanded in local repositories and not stored in +the change history. The mechanism can be regarded as a convenience +for the current user or for archive distribution. + +Configuration is done in the [keyword] and [keywordmaps] sections +of hgrc files. + +Example: + + [keyword] + # expand keywords in every python file except those matching "x*" + **.py = + x* = ignore + +Note: the more specific you are in your filename patterns + the less you lose speed in huge repos. + +For [keywordmaps] template mapping and expansion demonstration and +control run "hg kwdemo". + +An additional date template filter {date|utcdate} is provided. + +The default template mappings (view with "hg kwdemo -d") can be replaced +with customized keywords and templates. +Again, run "hg kwdemo" to control the results of your config changes. + +Before changing/disabling active keywords, run "hg kwshrink" to avoid +the risk of inadvertedly storing expanded keywords in the change history. + +To force expansion after enabling it, or a configuration change, run +"hg kwexpand". + +Expansions spanning more than one line and incremental expansions, +like CVS' $Log$, are not supported. A keyword template map +"Log = {desc}" expands to the first line of the changeset description. +''' + +from mercurial import commands, cmdutil, context, fancyopts, filelog +from mercurial import patch, localrepo, revlog, templater, util +from mercurial.node import * +from mercurial.i18n import _ +import re, shutil, sys, tempfile, time + +commands.optionalrepo += ' kwdemo' + +def utcdate(date): + '''Returns hgdate in cvs-like UTC format.''' + return time.strftime('%Y/%m/%d %H:%M:%S', time.gmtime(date[0])) + +_kwtemplater = None + +class kwtemplater(object): + ''' + Sets up keyword templates, corresponding keyword regex, and + provides keyword substitution functions. + ''' + templates = { + 'Revision': '{node|short}', + 'Author': '{author|user}', + 'Date': '{date|utcdate}', + 'RCSFile': '{file|basename},v', + 'Source': '{root}/{file},v', + 'Id': '{file|basename},v {node|short} {date|utcdate} {author|user}', + 'Header': '{root}/{file},v {node|short} {date|utcdate} {author|user}', + } + + def __init__(self, ui, repo, inc, exc): + self.ui = ui + self.repo = repo + self.matcher = util.matcher(repo.root, inc=inc, exc=exc)[1] + self.node = None + self.path = '' + + kwmaps = self.ui.configitems('keywordmaps') + if kwmaps: # override default templates + kwmaps = [(k, templater.parsestring(v, quoted=False)) + for (k, v) in kwmaps] + self.templates = dict(kwmaps) + escaped = map(re.escape, self.templates.keys()) + kwpat = r'\$(%s)(: [^$\n\r]*? )??\$' % '|'.join(escaped) + self.re_kw = re.compile(kwpat) + + templater.common_filters['utcdate'] = utcdate + self.ct = cmdutil.changeset_templater(self.ui, self.repo, + False, '', False) + + def substitute(self, node, data, subfunc): + '''Obtains node if missing, and calls given substitution function.''' + if not self.node: + c = context.filectx(self.repo, self.path, fileid=node) + self.node = c.node() + + def kwsub(mobj): + '''Substitutes keyword using corresponding template.''' + kw = mobj.group(1) + self.ct.use_template(self.templates[kw]) + self.ui.pushbuffer() + self.ct.show(changenode=self.node, + root=self.repo.root, file=self.path) + return '$%s: %s $' % (kw, templater.firstline(self.ui.popbuffer())) + + return subfunc(kwsub, data) + + def expand(self, node, data): + '''Returns data with keywords expanded.''' + if util.binary(data): + return data + return self.substitute(node, data, self.re_kw.sub) + + def process(self, node, data, expand): + '''Returns a tuple: data, count. + Count is number of keywords/keyword substitutions, indicates + to caller whether to act on file containing data. + Keywords in data are expanded, if templater was initialized.''' + if util.binary(data): + return data, None + if expand: + return self.substitute(node, data, self.re_kw.subn) + return data, self.re_kw.search(data) + + def shrink(self, text): + '''Returns text with all keyword substitutions removed.''' + if util.binary(text): + return text + return self.re_kw.sub(r'$\1$', text) + +class kwfilelog(filelog.filelog): + ''' + Subclass of filelog to hook into its read, add, cmp methods. + Keywords are "stored" unexpanded, and processed on reading. + ''' + def __init__(self, opener, path): + super(kwfilelog, self).__init__(opener, path) + _kwtemplater.path = path + + def kwctread(self, node, expand): + '''Reads expanding and counting keywords + (only called from kwtemplater.overwrite).''' + data = super(kwfilelog, self).read(node) + return _kwtemplater.process(node, data, expand) + + def read(self, node): + '''Expands keywords when reading filelog.''' + data = super(kwfilelog, self).read(node) + return _kwtemplater.expand(node, data) + + def add(self, text, meta, tr, link, p1=None, p2=None): + '''Removes keyword substitutions when adding to filelog.''' + text = _kwtemplater.shrink(text) + return super(kwfilelog, self).add(text, meta, tr, link, p1=p1, p2=p2) + + def cmp(self, node, text): + '''Removes keyword substitutions for comparison.''' + text = _kwtemplater.shrink(text) + if self.renamed(node): + t2 = super(kwfilelog, self).read(node) + return t2 != text + return revlog.revlog.cmp(self, node, text) + + +# store original patch.patchfile.__init__ +_patchfile_init = patch.patchfile.__init__ + +def _kwpatchfile_init(self, ui, fname, missing=False): + '''Monkeypatch/wrap patch.patchfile.__init__ to avoid + rejects or conflicts due to expanded keywords in working dir.''' + _patchfile_init(self, ui, fname, missing=missing) + + if _kwtemplater.matcher(self.fname): + # shrink keywords read from working dir + kwshrunk = _kwtemplater.shrink(''.join(self.lines)) + self.lines = kwshrunk.splitlines(True) + + +def _iskwfile(f, link): + return not link(f) and _kwtemplater.matcher(f) + +def _status(ui, repo, *pats, **opts): + '''Bails out if [keyword] configuration is not active. + Returns status of working directory.''' + if _kwtemplater: + files, match, anypats = cmdutil.matchpats(repo, pats, opts) + return repo.status(files=files, match=match, list_clean=True) + if ui.configitems('keyword'): + raise util.Abort(_('[keyword] patterns cannot match')) + raise util.Abort(_('no [keyword] patterns configured')) + +def _overwrite(ui, repo, node=None, expand=True, files=None): + '''Overwrites selected files expanding/shrinking keywords.''' + ctx = repo.changectx(node) + mf = ctx.manifest() + if files is None: + notify = ui.debug # commit + files = [f for f in ctx.files() if mf.has_key(f)] + else: + notify = ui.note # kwexpand/kwshrink + candidates = [f for f in files if _iskwfile(f, mf.linkf)] + if candidates: + candidates.sort() + action = expand and 'expanding' or 'shrinking' + _kwtemplater.node = node or ctx.node() + for f in candidates: + fp = repo.file(f, kwmatch=True) + data, kwfound = fp.kwctread(mf[f], expand) + if kwfound: + notify(_('overwriting %s %s keywords\n') % (f, action)) + repo.wwrite(f, data, mf.flags(f)) + repo.dirstate.normal(f) + +def _kwfwrite(ui, repo, expand, *pats, **opts): + '''Selects files and passes them to _overwrite.''' + status = _status(ui, repo, *pats, **opts) + modified, added, removed, deleted, unknown, ignored, clean = status + if modified or added or removed or deleted: + raise util.Abort(_('outstanding uncommitted changes in given files')) + wlock = lock = None + try: + wlock = repo.wlock() + lock = repo.lock() + _overwrite(ui, repo, expand=expand, files=clean) + finally: + del wlock, lock + + +def demo(ui, repo, *args, **opts): + '''print [keywordmaps] configuration and an expansion example + + Show current, custom, or default keyword template maps + and their expansion. + + Extend current configuration by specifying maps as arguments + and optionally by reading from an additional hgrc file. + + Override current keyword template maps with "default" option. + ''' + def demostatus(stat): + ui.status(_('\n\t%s\n') % stat) + + def demoitems(section, items): + ui.write('[%s]\n' % section) + for k, v in items: + ui.write('%s = %s\n' % (k, v)) + + msg = 'hg keyword config and expansion example' + kwstatus = 'current' + fn = 'demo.txt' + branchname = 'demobranch' + tmpdir = tempfile.mkdtemp('', 'kwdemo.') + ui.note(_('creating temporary repo at %s\n') % tmpdir) + repo = localrepo.localrepository(ui, path=tmpdir, create=True) + ui.setconfig('keyword', fn, '') + if args or opts.get('rcfile'): + kwstatus = 'custom' + if opts.get('rcfile'): + ui.readconfig(opts.get('rcfile')) + if opts.get('default'): + kwstatus = 'default' + kwmaps = kwtemplater.templates + if ui.configitems('keywordmaps'): + # override maps from optional rcfile + for k, v in kwmaps.items(): + ui.setconfig('keywordmaps', k, v) + elif args: + # simulate hgrc parsing + rcmaps = ['[keywordmaps]\n'] + [a + '\n' for a in args] + fp = repo.opener('hgrc', 'w') + fp.writelines(rcmaps) + fp.close() + ui.readconfig(repo.join('hgrc')) + if not opts.get('default'): + kwmaps = dict(ui.configitems('keywordmaps')) or kwtemplater.templates + reposetup(ui, repo) + for k, v in ui.configitems('extensions'): + if k.endswith('keyword'): + extension = '%s = %s' % (k, v) + break + demostatus('config using %s keyword template maps' % kwstatus) + ui.write('[extensions]\n%s\n' % extension) + demoitems('keyword', ui.configitems('keyword')) + demoitems('keywordmaps', kwmaps.items()) + keywords = '$' + '$\n$'.join(kwmaps.keys()) + '$\n' + repo.wopener(fn, 'w').write(keywords) + repo.add([fn]) + path = repo.wjoin(fn) + ui.note(_('\n%s keywords written to %s:\n') % (kwstatus, path)) + ui.note(keywords) + ui.note('\nhg -R "%s" branch "%s"\n' % (tmpdir, branchname)) + # silence branch command if not verbose + quiet = ui.quiet + verbose = ui.verbose + ui.quiet = not verbose + commands.branch(ui, repo, branchname) + ui.quiet = quiet + for name, cmd in ui.configitems('hooks'): + if name.split('.', 1)[0].find('commit') > -1: + repo.ui.setconfig('hooks', name, '') + ui.note(_('unhooked all commit hooks\n')) + ui.note('hg -R "%s" ci -m "%s"\n' % (tmpdir, msg)) + repo.commit(text=msg) + format = ui.verbose and ' in %s' % path or '' + demostatus('%s keywords expanded%s' % (kwstatus, format)) + ui.write(repo.wread(fn)) + ui.debug(_('\nremoving temporary repo %s\n') % tmpdir) + shutil.rmtree(tmpdir, ignore_errors=True) + +def expand(ui, repo, *pats, **opts): + '''expand keywords in working directory + + Run after (re)enabling keyword expansion. + + kwexpand refuses to run if given files contain local changes. + ''' + # 3rd argument sets expansion to True + _kwfwrite(ui, repo, True, *pats, **opts) + +def files(ui, repo, *pats, **opts): + '''print files currently configured for keyword expansion + + Crosscheck which files in working directory are potential targets for + keyword expansion. + That is, files matched by [keyword] config patterns but not symlinks. + ''' + status = _status(ui, repo, *pats, **opts) + modified, added, removed, deleted, unknown, ignored, clean = status + if opts.get('untracked'): + files = modified + added + unknown + clean + else: + files = modified + added + clean + files.sort() + kwfiles = [f for f in files if _iskwfile(f, repo._link)] + cwd = pats and repo.getcwd() or '' + kwfstats = not opts.get('ignore') and (('K', kwfiles),) or () + if opts.get('all') or opts.get('ignore'): + kwfstats += (('I', [f for f in files if f not in kwfiles]),) + for char, filenames in kwfstats: + format = (opts.get('all') or ui.verbose) and '%s %%s\n' % char or '%s\n' + for f in filenames: + ui.write(format % repo.pathto(f, cwd)) + +def shrink(ui, repo, *pats, **opts): + '''revert expanded keywords in working directory + + Run before changing/disabling active keywords + or if you experience problems with "hg import" or "hg merge". + + kwshrink refuses to run if given files contain local changes. + ''' + # 3rd argument sets expansion to False + _kwfwrite(ui, repo, False, *pats, **opts) + + +def reposetup(ui, repo): + '''Sets up repo as kwrepo for keyword substitution. + Overrides file method to return kwfilelog instead of filelog + if file matches user configuration. + Wraps commit to overwrite configured files with updated + keyword substitutions. + This is done for local repos only, and only if there are + files configured at all for keyword substitution.''' + + def kwbailout(): + '''Obtains command via simplified cmdline parsing, + returns True if keyword expansion not needed.''' + nokwcommands = ('add', 'addremove', 'bundle', 'clone', 'copy', + 'export', 'grep', 'identify', 'incoming', 'init', + 'outgoing', 'push', 'remove', 'rename', 'rollback', + 'convert') + args = fancyopts.fancyopts(sys.argv[1:], commands.globalopts, {}) + if args: + aliases, i = cmdutil.findcmd(ui, args[0], commands.table) + return aliases[0] in nokwcommands + + if not repo.local() or kwbailout(): + return + + inc, exc = [], ['.hgtags'] + for pat, opt in ui.configitems('keyword'): + if opt != 'ignore': + inc.append(pat) + else: + exc.append(pat) + if not inc: + return + + global _kwtemplater + _kwtemplater = kwtemplater(ui, repo, inc, exc) + + class kwrepo(repo.__class__): + def file(self, f, kwmatch=False): + if f[0] == '/': + f = f[1:] + if kwmatch or _kwtemplater.matcher(f): + return kwfilelog(self.sopener, f) + return filelog.filelog(self.sopener, f) + + def commit(self, files=None, text='', user=None, date=None, + match=util.always, force=False, force_editor=False, + p1=None, p2=None, extra={}): + wlock = lock = None + _p1 = _p2 = None + try: + wlock = self.wlock() + lock = self.lock() + # store and postpone commit hooks + commithooks = [] + for name, cmd in ui.configitems('hooks'): + if name.split('.', 1)[0] == 'commit': + commithooks.append((name, cmd)) + ui.setconfig('hooks', name, None) + if commithooks: + # store parents for commit hook environment + if p1 is None: + _p1, _p2 = repo.dirstate.parents() + else: + _p1, _p2 = p1, p2 or nullid + _p1 = hex(_p1) + if _p2 == nullid: + _p2 = '' + else: + _p2 = hex(_p2) + + node = super(kwrepo, + self).commit(files=files, text=text, user=user, + date=date, match=match, force=force, + force_editor=force_editor, + p1=p1, p2=p2, extra=extra) + + # restore commit hooks + for name, cmd in commithooks: + ui.setconfig('hooks', name, cmd) + if node is not None: + _overwrite(ui, self, node=node) + repo.hook('commit', node=node, parent1=_p1, parent2=_p2) + return node + finally: + del wlock, lock + + repo.__class__ = kwrepo + patch.patchfile.__init__ = _kwpatchfile_init + + +cmdtable = { + 'kwdemo': + (demo, + [('d', 'default', None, _('show default keyword template maps')), + ('f', 'rcfile', [], _('read maps from rcfile'))], + _('hg kwdemo [-d] [-f RCFILE] [TEMPLATEMAP]...')), + 'kwexpand': (expand, commands.walkopts, + _('hg kwexpand [OPTION]... [FILE]...')), + 'kwfiles': + (files, + [('a', 'all', None, _('show keyword status flags of all files')), + ('i', 'ignore', None, _('show files excluded from expansion')), + ('u', 'untracked', None, _('additionally show untracked files')), + ] + commands.walkopts, + _('hg kwfiles [OPTION]... [FILE]...')), + 'kwshrink': (shrink, commands.walkopts, + _('hg kwshrink [OPTION]... [FILE]...')), +}