Add extension for filewise RCS-keyword expansion in working dir
authorChristian Ebert <blacktrash@gmx.net>
Fri, 04 Jan 2008 18:22:09 +0100
changeset 5815 0637d97a8cb4
parent 5814 dd5a501cb97f
child 5816 3bd22fd3b750
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
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 <blacktrash@gmx.net>
+#
+# 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
+# <http://www.selenic.com/mercurial/wiki/index.cgi/KeywordPlan>.
+#
+# 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]...')),
+}