hgkw/keyword.py
changeset 193 7a775a8f6fb9
parent 192 0d2a6c9f8343
child 201 e826c3cdc52d
equal deleted inserted replaced
3:b9f2c0853da3 193:7a775a8f6fb9
       
     1 # keyword.py - keyword expansion for Mercurial
       
     2 #
       
     3 # Copyright 2007 Christian Ebert <blacktrash@gmx.net>
       
     4 #
       
     5 # This software may be used and distributed according to the terms
       
     6 # of the GNU General Public License, incorporated herein by reference.
       
     7 #
       
     8 # $Id$
       
     9 #
       
    10 # Keyword expansion hack against the grain of a DSCM
       
    11 #
       
    12 # There are many good reasons why this is not needed in a distributed
       
    13 # SCM, still it may be useful in very small projects based on single
       
    14 # files (like LaTeX packages), that are mostly addressed to an audience
       
    15 # not running a version control system.
       
    16 #
       
    17 # For in-depth discussion refer to
       
    18 # <http://www.selenic.com/mercurial/wiki/index.cgi/KeywordPlan>.
       
    19 #
       
    20 # Keyword expansion is based on Mercurial's changeset template mappings.
       
    21 # The extension provides an additional UTC-date filter ({date|utcdate}).
       
    22 #
       
    23 # Expansions spanning more than one line are truncated to their first line.
       
    24 # Incremental expansion (like CVS' $Log$) is not supported.
       
    25 #
       
    26 # Binary files are not touched.
       
    27 #
       
    28 # Setup in hgrc:
       
    29 #
       
    30 #     # enable extension
       
    31 #     keyword = /full/path/to/keyword.py
       
    32 #     # or, if script in hgext folder:
       
    33 #     # hgext.keyword =
       
    34 
       
    35 '''keyword expansion in local repositories
       
    36 
       
    37 This extension expands RCS/CVS-like or self-customized $Keywords$
       
    38 in the text files selected by your configuration.
       
    39 
       
    40 Keywords are only expanded in local repositories and not logged by
       
    41 Mercurial internally. The mechanism can be regarded as a convenience
       
    42 for the current user or archive distribution.
       
    43 
       
    44 Configuration is done in the [keyword] and [keywordmaps] sections of
       
    45 hgrc files.
       
    46 
       
    47 Example:
       
    48     [extensions]
       
    49     hgext.keyword =
       
    50 
       
    51     [keyword]
       
    52     # expand keywords in every python file except those matching "x*"
       
    53     **.py =
       
    54     x* = ignore
       
    55 
       
    56 Note: the more specific you are in your [keyword] filename patterns
       
    57       the less you lose speed in huge repos.
       
    58 
       
    59 For a [keywordmaps] template mapping and expansion demonstration
       
    60 run "hg kwdemo".
       
    61 
       
    62 An additional date template filter {date|utcdate} is provided.
       
    63 
       
    64 You can replace the default template mappings with customized keywords
       
    65 and templates of your choice.
       
    66 Again, run "hg kwdemo" to control the results of your config changes.
       
    67 
       
    68 When you change keyword configuration, especially the active keywords,
       
    69 and do not want to store expanded keywords in change history, run
       
    70 "hg kwshrink", and then change configuration.
       
    71 
       
    72 Caveat: "hg import" fails if the patch context contains an active
       
    73         keyword. In that case run "hg kwshrink", reimport, and then
       
    74         "hg kwexpand".
       
    75         Or, better, use bundle/unbundle to share changes.
       
    76 '''
       
    77 
       
    78 from mercurial import commands, cmdutil, context, fancyopts
       
    79 from mercurial import filelog, localrepo, templater, util, hg
       
    80 from mercurial.i18n import gettext as _
       
    81 # findcmd might be in cmdutil or commands
       
    82 # depending on mercurial version
       
    83 if hasattr(cmdutil, 'findcmd'):
       
    84     findcmd = cmdutil.findcmd
       
    85 else:
       
    86     findcmd = commands.findcmd
       
    87 import os, re, shutil, sys, tempfile, time
       
    88 
       
    89 commands.optionalrepo += ' kwdemo'
       
    90 
       
    91 deftemplates = {
       
    92     'Revision': '{node|short}',
       
    93     'Author': '{author|user}',
       
    94     'Date': '{date|utcdate}',
       
    95     'RCSFile': '{file|basename},v',
       
    96     'Source': '{root}/{file},v',
       
    97     'Id': '{file|basename},v {node|short} {date|utcdate} {author|user}',
       
    98     'Header': '{root}/{file},v {node|short} {date|utcdate} {author|user}',
       
    99 }
       
   100 
       
   101 nokwcommands = ('add', 'addremove', 'bundle', 'clone', 'copy', 'export',
       
   102                 'incoming', 'outgoing', 'push', 'remove', 'rename', 'rollback')
       
   103 
       
   104 def utcdate(date):
       
   105     '''Returns hgdate in cvs-like UTC format.'''
       
   106     return time.strftime('%Y/%m/%d %H:%M:%S', time.gmtime(date[0]))
       
   107 
       
   108 def getcmd(ui):
       
   109     '''Returns current hg command.'''
       
   110     # commands.parse(ui, sys.argv[1:])[0] breaks "hg diff -r"
       
   111     try:
       
   112         args = fancyopts.fancyopts(sys.argv[1:], commands.globalopts, {})
       
   113     except fancyopts.getopt.GetoptError, inst:
       
   114         raise commands.ParseError(None, inst)
       
   115     if args:
       
   116         cmd = args[0]
       
   117         aliases, i = findcmd(ui, cmd)
       
   118         return aliases[0]
       
   119 
       
   120 def keywordmatcher(ui, repo):
       
   121     '''Collects include/exclude filename patterns for expansion
       
   122     candidates of current configuration. Returns filename matching
       
   123     function if include patterns exist, None otherwise.'''
       
   124     inc, exc = [], ['.hg*']
       
   125     for pat, opt in ui.configitems('keyword'):
       
   126         if opt != 'ignore':
       
   127             inc.append(pat)
       
   128         else:
       
   129             exc.append(pat)
       
   130     if not inc:
       
   131         return None
       
   132     return util.matcher(repo.root, inc=inc, exc=exc)[1]
       
   133 
       
   134 class kwtemplater(object):
       
   135     '''
       
   136     Sets up keyword templates, corresponding keyword regex, and
       
   137     provides keyword substitution functions.
       
   138     '''
       
   139     def __init__(self, ui, repo, path='', node=None, expand=True):
       
   140         self.ui = ui
       
   141         self.repo = repo
       
   142         self.path = path
       
   143         self.node = node
       
   144         templates = dict(ui.configitems('keywordmaps'))
       
   145         if templates:
       
   146             for k in templates.keys():
       
   147                 templates[k] = templater.parsestring(templates[k],
       
   148                                                      quoted=False)
       
   149         self.templates = templates or deftemplates
       
   150         escaped = [re.escape(k) for k in self.templates.keys()]
       
   151         self.re_kw = re.compile(r'\$(%s)[^$]*?\$' % '|'.join(escaped))
       
   152         if expand:
       
   153             templater.common_filters['utcdate'] = utcdate
       
   154             try:
       
   155                 self.t = cmdutil.changeset_templater(ui, repo,
       
   156                                                      False, '', False)
       
   157             except TypeError:
       
   158                 # depending on hg rev changeset_templater has extra "brinfo" arg
       
   159                 self.t = cmdutil.changeset_templater(ui, repo,
       
   160                                                      False, None, '', False)
       
   161         else:
       
   162             self.t = None
       
   163 
       
   164     def ctxnode(self, node):
       
   165         '''Obtains missing node from file context.'''
       
   166         if not self.node:
       
   167             c = context.filectx(self.repo, self.path, fileid=node)
       
   168             self.node = c.node()
       
   169 
       
   170     def kwsub(self, mobj):
       
   171         '''Substitutes keyword using corresponding template.'''
       
   172         kw = mobj.group(1)
       
   173         self.t.use_template(self.templates[kw])
       
   174         self.ui.pushbuffer()
       
   175         self.t.show(changenode=self.node, root=self.repo.root, file=self.path)
       
   176         keywordsub = templater.firstline(self.ui.popbuffer())
       
   177         return '$%s: %s $' % (kw, keywordsub)
       
   178 
       
   179     def expand(self, node, data):
       
   180         '''Returns data with keywords expanded.'''
       
   181         if util.binary(data):
       
   182             return data
       
   183         self.ctxnode(node)
       
   184         return self.re_kw.sub(self.kwsub, data)
       
   185 
       
   186     def process(self, node, data):
       
   187         '''Returns a tuple: data, count.
       
   188         Count is number of keywords/keyword substitutions.
       
   189         Keywords in data are expanded, if templater was initialized.'''
       
   190         if util.binary(data):
       
   191             return data, None
       
   192         if self.t:
       
   193             self.ctxnode(node)
       
   194             return self.re_kw.subn(self.kwsub, data)
       
   195         return data, self.re_kw.search(data)
       
   196 
       
   197     def shrink(self, text):
       
   198         '''Returns text with all keyword substitutions removed.'''
       
   199         if util.binary(text):
       
   200             return text
       
   201         return self.re_kw.sub(r'$\1$', text)
       
   202 
       
   203     def overwrite(self, candidates, man, commit=True):
       
   204         '''Overwrites files in working directory if keywords are detected.
       
   205         Keywords are expanded if keyword templater is initialized,
       
   206         otherwise their substitution is removed.'''
       
   207         expand = self.t is not None
       
   208         action = ('shrinking', 'expanding')[expand]
       
   209         notify = (self.ui.note, self.ui.debug)[commit]
       
   210         files = []
       
   211         for f in candidates:
       
   212             fp = self.repo.file(f, kwcnt=True, kwexp=expand)
       
   213             data, cnt = fp.read(man[f])
       
   214             if cnt:
       
   215                 notify(_('overwriting %s %s keywords\n') % (f, action))
       
   216                 try:
       
   217                     self.repo.wwrite(f, data, man.flags(f))
       
   218                 except AttributeError:
       
   219                     # older versions want file descriptor as 3. optional arg
       
   220                     self.repo.wwrite(f, data)
       
   221                 files.append(f)
       
   222         if files:
       
   223             self.repo.dirstate.update(files, 'n')
       
   224 
       
   225 class kwfilelog(filelog.filelog):
       
   226     '''
       
   227     Subclass of filelog to hook into its read, add, cmp methods.
       
   228     Keywords are "stored" unexpanded, and processed on reading.
       
   229     '''
       
   230     def __init__(self, opener, path, kwtemplater, kwcnt):
       
   231         super(kwfilelog, self).__init__(opener, path)
       
   232         self.kwtemplater = kwtemplater
       
   233         self.kwcnt = kwcnt
       
   234 
       
   235     def read(self, node):
       
   236         '''Passes data through kwemplater methods for
       
   237         either unconditional keyword expansion
       
   238         or counting of keywords and substitution method
       
   239         set by the calling overwrite function.'''
       
   240         data = super(kwfilelog, self).read(node)
       
   241         if not self.kwcnt:
       
   242             return self.kwtemplater.expand(node, data)
       
   243         return self.kwtemplater.process(node, data)
       
   244 
       
   245     def add(self, text, meta, tr, link, p1=None, p2=None):
       
   246         '''Removes keyword substitutions when adding to filelog.'''
       
   247         text = self.kwtemplater.shrink(text)
       
   248         return super(kwfilelog, self).add(text, meta, tr, link, p1=p1, p2=p2)
       
   249 
       
   250     def cmp(self, node, text):
       
   251         '''Removes keyword substitutions for comparison.'''
       
   252         text = self.kwtemplater.shrink(text)
       
   253         if self.renamed(node):
       
   254             t2 = super(kwfilelog, self).read(node)
       
   255             return t2 != text
       
   256         return super(kwfilelog, self).cmp(node, text)
       
   257 
       
   258 def overwrite(ui, repo, files=None, expand=True):
       
   259     '''Expands/shrinks keywords in working directory.'''
       
   260     wlock = repo.wlock()
       
   261     try:
       
   262         ctx = repo.changectx()
       
   263         if not ctx:
       
   264             raise hg.RepoError(_('no changeset found'))
       
   265         for changed in repo.status()[:4]:
       
   266             if changed:
       
   267                 raise util.Abort(_('local changes detected'))
       
   268         kwfmatcher = keywordmatcher(ui, repo)
       
   269         if kwfmatcher is None:
       
   270             ui.warn(_('no files configured for keyword expansion\n'))
       
   271             return
       
   272         m = ctx.manifest()
       
   273         if files:
       
   274             files = [f for f in files if f in m.keys()]
       
   275         else:
       
   276             files = m.keys()
       
   277         files = [f for f in files if kwfmatcher(f) and not os.path.islink(f)]
       
   278         if not files:
       
   279             ui.warn(_('given files not tracked or '
       
   280                       'not configured for expansion\n'))
       
   281             return
       
   282         kwt = kwtemplater(ui, repo, node=ctx.node(), expand=expand)
       
   283         kwt.overwrite(files, m, commit=False)
       
   284     finally:
       
   285         wlock.release()
       
   286 
       
   287 
       
   288 def shrink(ui, repo, *args):
       
   289     '''revert expanded keywords in working directory
       
   290 
       
   291     run before:
       
   292                disabling keyword expansion
       
   293                changing keyword expansion configuration
       
   294     or if you experience problems with "hg import"
       
   295     '''
       
   296     overwrite(ui, repo, files=args, expand=False)
       
   297 
       
   298 def expand(ui, repo, *args):
       
   299     '''expand keywords in working directory
       
   300 
       
   301     run after (re)enabling keyword expansion
       
   302     '''
       
   303     overwrite(ui, repo, files=args)
       
   304 
       
   305 def demo(ui, repo, *args, **opts):
       
   306     '''print [keywordmaps] configuration and an expansion example
       
   307 
       
   308     show current, custom, or default keyword template maps and their expansion
       
   309     '''
       
   310     msg = 'hg keyword config and expansion example'
       
   311     kwstatus = 'current'
       
   312     fn = 'demo.txt'
       
   313     tmpdir = tempfile.mkdtemp('', 'kwdemo.')
       
   314     ui.note(_('creating temporary repo at %s\n') % tmpdir)
       
   315     _repo = localrepo.localrepository(ui, path=tmpdir, create=True)
       
   316     # for backwards compatibility
       
   317     ui = _repo.ui
       
   318     ui.setconfig('keyword', fn, '')
       
   319     if opts['default']:
       
   320         kwstatus = 'default'
       
   321         kwmaps = deftemplates
       
   322     else:
       
   323         if args or opts['rcfile']:
       
   324             kwstatus = 'custom'
       
   325         for tmap in args:
       
   326             k, v = tmap.split('=', 1)
       
   327             ui.setconfig('keywordmaps', k.strip(), v.strip())
       
   328         if opts['rcfile']:
       
   329             ui.readconfig(opts['rcfile'])
       
   330         kwmaps = dict(ui.configitems('keywordmaps')) or deftemplates
       
   331     if ui.configitems('keywordmaps'):
       
   332         for k, v in kwmaps.items():
       
   333             ui.setconfig('keywordmaps', k, v)
       
   334     reposetup(ui, _repo)
       
   335     ui.status(_('config with %s keyword template maps:\n') % kwstatus)
       
   336     ui.write('[keyword]\n%s =\n[keywordmaps]\n' % fn)
       
   337     for k, v in kwmaps.items():
       
   338         ui.write('%s = %s\n' % (k, v))
       
   339     path = _repo.wjoin(fn)
       
   340     keywords = '$' + '$\n$'.join(kwmaps.keys()) + '$\n'
       
   341     _repo.wopener(fn, 'w').write(keywords)
       
   342     _repo.add([fn])
       
   343     ui.note(_('\n%s keywords written to %s:\n') % (kwstatus, path))
       
   344     ui.note(keywords)
       
   345     ui.note(_("\nhg --repository '%s' commit\n") % tmpdir)
       
   346     _repo.commit(text=msg)
       
   347     pathinfo = ('', ' in %s' % path)[ui.verbose]
       
   348     ui.status(_('\n%s keywords expanded%s:\n') % (kwstatus, pathinfo))
       
   349     ui.write(_repo.wread(fn))
       
   350     ui.debug(_('\nremoving temporary repo %s\n') % tmpdir)
       
   351     shutil.rmtree(tmpdir)
       
   352 
       
   353 
       
   354 def reposetup(ui, repo):
       
   355     '''Sets up repo as kwrepo for keyword substitution.
       
   356     Overrides file method to return kwfilelog instead of filelog
       
   357     if file matches user configuration.
       
   358     Wraps commit to overwrite configured files with updated
       
   359     keyword substitutions.
       
   360     This is done for local repos only, and only if there are
       
   361     files configured at all for keyword substitution.'''
       
   362 
       
   363     # for backwards compatibility
       
   364     ui = repo.ui
       
   365 
       
   366     if not repo.local() or getcmd(ui) in nokwcommands:
       
   367         return
       
   368 
       
   369     kwfmatcher = keywordmatcher(ui, repo)
       
   370     if kwfmatcher is None:
       
   371         return
       
   372 
       
   373     class kwrepo(repo.__class__):
       
   374         def file(self, f, kwcnt=False, kwexp=True):
       
   375             if f[0] == '/':
       
   376                 f = f[1:]
       
   377             if kwfmatcher(f):
       
   378                 kwt = kwtemplater(ui, self, path=f, expand=kwexp)
       
   379                 return kwfilelog(self.sopener, f, kwt, kwcnt)
       
   380             else:
       
   381                 return filelog.filelog(self.sopener, f)
       
   382 
       
   383         def commit(self, files=None, text='', user=None, date=None,
       
   384                    match=util.always, force=False, lock=None, wlock=None,
       
   385                    force_editor=False, p1=None, p2=None, extra={}):
       
   386             wrelease = False
       
   387             if not wlock:
       
   388                 wlock = self.wlock()
       
   389                 wrelease = True
       
   390             try:
       
   391                 removed = self.status(node1=p1, node2=p2, files=files,
       
   392                                       match=match, wlock=wlock)[2]
       
   393 
       
   394                 node = super(kwrepo,
       
   395                              self).commit(files=files, text=text, user=user,
       
   396                                           date=date, match=match, force=force,
       
   397                                           lock=lock, wlock=wlock,
       
   398                                           force_editor=force_editor,
       
   399                                           p1=p1, p2=p2, extra=extra)
       
   400                 if node is None:
       
   401                     return node
       
   402 
       
   403                 cl = self.changelog.read(node)
       
   404                 candidates = [f for f in cl[3] if kwfmatcher(f)
       
   405                               and f not in removed
       
   406                               and not os.path.islink(self.wjoin(f))]
       
   407                 if candidates:
       
   408                     m = self.manifest.read(cl[0])
       
   409                     kwt = kwtemplater(ui, self, node=node)
       
   410                     kwt.overwrite(candidates, m)
       
   411                 return node
       
   412             finally:
       
   413                 if wrelease:
       
   414                     wlock.release()
       
   415 
       
   416     repo.__class__ = kwrepo
       
   417 
       
   418 
       
   419 cmdtable = {
       
   420     'kwdemo':
       
   421         (demo,
       
   422          [('d', 'default', None, _('show default keyword template maps')),
       
   423           ('f', 'rcfile', [], _('read maps from RCFILE'))],
       
   424          _('hg kwdemo [-d || [-f RCFILE] TEMPLATEMAP ...]')),
       
   425     'kwshrink': (shrink, [], _('hg kwshrink [NAME] ...')),
       
   426     'kwexpand': (expand, [], _('hg kwexpand [NAME] ...')),
       
   427 }