1 # keyword.py - $Keyword$ expansion for Mercurial
3 # Copyright 2007-2009 Christian Ebert <blacktrash@gmx.net>
5 # This software may be used and distributed according to the terms of the
6 # GNU General Public License version 2, incorporated herein by reference.
10 # Keyword expansion hack against the grain of a DSCM
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
15 # audience not running a version control system.
17 # For in-depth discussion refer to
18 # <http://mercurial.selenic.com/wiki/KeywordPlan>.
20 # Keyword expansion is based on Mercurial's changeset template mappings.
22 # Binary files are not touched.
24 # Files to act upon/ignore are specified in the [keyword] section.
25 # Customized keyword template mappings in the [keywordmaps] section.
27 # Run "hg help keyword" and "hg kwdemo" to get info on configuration.
29 '''expand keywords in tracked files
31 This extension expands RCS/CVS-like or self-customized $Keywords$ in
32 tracked text files selected by your configuration.
34 Keywords are only expanded in local repositories and not stored in the
35 change history. The mechanism can be regarded as a convenience for the
36 current user or for archive distribution.
38 Configuration is done in the [keyword] and [keywordmaps] sections of
44 # expand keywords in every python file except those matching "x*"
48 NOTE: the more specific you are in your filename patterns the less you
49 lose speed in huge repositories.
51 For [keywordmaps] template mapping and expansion demonstration and
52 control run "hg kwdemo". See "hg help templates" for a list of
53 available templates and filters.
55 An additional date template filter {date|utcdate} is provided. It
56 returns a date like "2006/09/18 15:13:13".
58 The default template mappings (view with "hg kwdemo -d") can be
59 replaced with customized keywords and templates. Again, run "hg
60 kwdemo" to control the results of your config changes.
62 Before changing/disabling active keywords, run "hg kwshrink" to avoid
63 the risk of inadvertently storing expanded keywords in the change
66 To force expansion after enabling it, or a configuration change, run
69 Also, when committing with the record extension or using mq's qrecord,
70 be aware that keywords cannot be updated. Again, run "hg kwexpand" on
71 the files in question to update keyword expansions after all changes
74 Expansions spanning more than one line and incremental expansions,
75 like CVS' $Log$, are not supported. A keyword template map "Log =
76 {desc}" expands to the first line of the changeset description.
79 from mercurial import commands, cmdutil, dispatch, filelog, revlog, extensions
80 from mercurial import patch, localrepo, templater, templatefilters, util, match
81 from mercurial.hgweb import webcommands
82 from mercurial.lock import release
83 from mercurial.node import nullid
84 from mercurial.i18n import _
85 import re, shutil, tempfile
87 commands.optionalrepo += ' kwdemo'
89 # hg commands that do not act on keywords
90 nokwcommands = ('add addremove annotate bundle copy export grep incoming init'
91 ' log outgoing push rename rollback tip verify'
92 ' convert email glog')
94 # hg commands that trigger expansion only when writing to working dir,
95 # not when reading filelog, and unexpand when reading from working dir
96 restricted = 'merge record resolve qfold qimport qnew qpush qrefresh qrecord'
98 # provide cvs-like UTC date filter
99 utcdate = lambda x: util.datestr(x, '%Y/%m/%d %H:%M:%S')
101 # make keyword tools accessible
102 kwtools = {'templater': None, 'hgcmd': '', 'inc': [], 'exc': ['.hg*']}
105 class kwtemplater(object):
107 Sets up keyword templates, corresponding keyword regex, and
108 provides keyword substitution functions.
111 'Revision': '{node|short}',
112 'Author': '{author|user}',
113 'Date': '{date|utcdate}',
114 'RCSFile': '{file|basename},v',
115 'Source': '{root}/{file},v',
116 'Id': '{file|basename},v {node|short} {date|utcdate} {author|user}',
117 'Header': '{root}/{file},v {node|short} {date|utcdate} {author|user}',
120 def __init__(self, ui, repo):
123 self.match = match.match(repo.root, '', [],
124 kwtools['inc'], kwtools['exc'])
125 self.restrict = kwtools['hgcmd'] in restricted.split()
127 kwmaps = self.ui.configitems('keywordmaps')
128 if kwmaps: # override default templates
129 self.templates = dict((k, templater.parsestring(v, False))
131 escaped = map(re.escape, self.templates.keys())
132 kwpat = r'\$(%s)(: [^$\n\r]*? )??\$' % '|'.join(escaped)
133 self.re_kw = re.compile(kwpat)
135 templatefilters.filters['utcdate'] = utcdate
136 self.ct = cmdutil.changeset_templater(self.ui, self.repo,
137 False, None, '', False)
139 def substitute(self, data, path, ctx, subfunc):
140 '''Replaces keywords in data with expanded template.'''
143 self.ct.use_template(self.templates[kw])
145 self.ct.show(ctx, root=self.repo.root, file=path)
146 ekw = templatefilters.firstline(self.ui.popbuffer())
147 return '$%s: %s $' % (kw, ekw)
148 return subfunc(kwsub, data)
150 def expand(self, path, node, data):
151 '''Returns data with keywords expanded.'''
152 if not self.restrict and self.match(path) and not util.binary(data):
153 ctx = self.repo.filectx(path, fileid=node).changectx()
154 return self.substitute(data, path, ctx, self.re_kw.sub)
157 def iskwfile(self, path, flagfunc):
158 '''Returns true if path matches [keyword] pattern
159 and is not a symbolic link.
160 Caveat: localrepository._link fails on Windows.'''
161 return self.match(path) and not 'l' in flagfunc(path)
163 def overwrite(self, node, expand, files):
164 '''Overwrites selected files expanding/shrinking keywords.'''
165 ctx = self.repo[node]
167 if node is not None: # commit
168 files = [f for f in ctx.files() if f in mf]
169 notify = self.ui.debug
170 else: # kwexpand/kwshrink
171 notify = self.ui.note
172 candidates = [f for f in files if self.iskwfile(f, ctx.flags)]
174 self.restrict = True # do not expand when reading
175 msg = (expand and _('overwriting %s expanding keywords\n')
176 or _('overwriting %s shrinking keywords\n'))
178 fp = self.repo.file(f)
179 data = fp.read(mf[f])
180 if util.binary(data):
184 ctx = self.repo.filectx(f, fileid=mf[f]).changectx()
185 data, found = self.substitute(data, f, ctx,
188 found = self.re_kw.search(data)
191 self.repo.wwrite(f, data, mf.flags(f))
193 self.repo.dirstate.normal(f)
194 self.restrict = False
196 def shrinktext(self, text):
197 '''Unconditionally removes all keyword substitutions from text.'''
198 return self.re_kw.sub(r'$\1$', text)
200 def shrink(self, fname, text):
201 '''Returns text with all keyword substitutions removed.'''
202 if self.match(fname) and not util.binary(text):
203 return self.shrinktext(text)
206 def shrinklines(self, fname, lines):
207 '''Returns lines with keyword substitutions removed.'''
208 if self.match(fname):
209 text = ''.join(lines)
210 if not util.binary(text):
211 return self.shrinktext(text).splitlines(True)
214 def wread(self, fname, data):
215 '''If in restricted mode returns data read from wdir with
216 keyword substitutions removed.'''
217 return self.restrict and self.shrink(fname, data) or data
219 class kwfilelog(filelog.filelog):
221 Subclass of filelog to hook into its read, add, cmp methods.
222 Keywords are "stored" unexpanded, and processed on reading.
224 def __init__(self, opener, kwt, path):
225 super(kwfilelog, self).__init__(opener, path)
229 def read(self, node):
230 '''Expands keywords when reading filelog.'''
231 data = super(kwfilelog, self).read(node)
232 return self.kwt.expand(self.path, node, data)
234 def add(self, text, meta, tr, link, p1=None, p2=None):
235 '''Removes keyword substitutions when adding to filelog.'''
236 text = self.kwt.shrink(self.path, text)
237 return super(kwfilelog, self).add(text, meta, tr, link, p1, p2)
239 def cmp(self, node, text):
240 '''Removes keyword substitutions for comparison.'''
241 text = self.kwt.shrink(self.path, text)
242 if self.renamed(node):
243 t2 = super(kwfilelog, self).read(node)
245 return revlog.revlog.cmp(self, node, text)
247 def _status(ui, repo, kwt, unknown, *pats, **opts):
248 '''Bails out if [keyword] configuration is not active.
249 Returns status of working directory.'''
251 match = cmdutil.match(repo, pats, opts)
252 return repo.status(match=match, unknown=unknown, clean=True)
253 if ui.configitems('keyword'):
254 raise util.Abort(_('[keyword] patterns cannot match'))
255 raise util.Abort(_('no [keyword] patterns configured'))
257 def _kwfwrite(ui, repo, expand, *pats, **opts):
258 '''Selects files and passes them to kwtemplater.overwrite.'''
259 if repo.dirstate.parents()[1] != nullid:
260 raise util.Abort(_('outstanding uncommitted merge'))
261 kwt = kwtools['templater']
262 status = _status(ui, repo, kwt, False, *pats, **opts)
263 modified, added, removed, deleted = status[:4]
264 if modified or added or removed or deleted:
265 raise util.Abort(_('outstanding uncommitted changes'))
270 kwt.overwrite(None, expand, status[6])
274 def demo(ui, repo, *args, **opts):
275 '''print [keywordmaps] configuration and an expansion example
277 Show current, custom, or default keyword template maps and their
280 Extend the current configuration by specifying maps as arguments
281 and using -f/--rcfile to source an external hgrc file.
283 Use -d/--default to disable current configuration.
285 See "hg help templates" for information on templates and filters.
287 def demoitems(section, items):
288 ui.write('[%s]\n' % section)
290 ui.write('%s = %s\n' % (k, v))
292 msg = 'hg keyword config and expansion example'
294 branchname = 'demobranch'
295 tmpdir = tempfile.mkdtemp('', 'kwdemo.')
296 ui.note(_('creating temporary repository at %s\n') % tmpdir)
297 repo = localrepo.localrepository(ui, tmpdir, True)
298 ui.setconfig('keyword', fn, '')
300 uikwmaps = ui.configitems('keywordmaps')
301 if args or opts.get('rcfile'):
302 ui.status(_('\n\tconfiguration using custom keyword template maps\n'))
304 ui.status(_('\textending current template maps\n'))
305 if opts.get('default') or not uikwmaps:
306 ui.status(_('\toverriding default template maps\n'))
307 if opts.get('rcfile'):
308 ui.readconfig(opts.get('rcfile'))
310 # simulate hgrc parsing
311 rcmaps = ['[keywordmaps]\n'] + [a + '\n' for a in args]
312 fp = repo.opener('hgrc', 'w')
313 fp.writelines(rcmaps)
315 ui.readconfig(repo.join('hgrc'))
316 kwmaps = dict(ui.configitems('keywordmaps'))
317 elif opts.get('default'):
318 ui.status(_('\n\tconfiguration using default keyword template maps\n'))
319 kwmaps = kwtemplater.templates
321 ui.status(_('\tdisabling current template maps\n'))
322 for k, v in kwmaps.iteritems():
323 ui.setconfig('keywordmaps', k, v)
325 ui.status(_('\n\tconfiguration using current keyword template maps\n'))
326 kwmaps = dict(uikwmaps) or kwtemplater.templates
330 for k, v in ui.configitems('extensions'):
331 if k.endswith('keyword'):
332 extension = '%s = %s' % (k, v)
334 ui.write('[extensions]\n%s\n' % extension)
335 demoitems('keyword', ui.configitems('keyword'))
336 demoitems('keywordmaps', kwmaps.iteritems())
337 keywords = '$' + '$\n$'.join(kwmaps.keys()) + '$\n'
338 repo.wopener(fn, 'w').write(keywords)
340 path = repo.wjoin(fn)
341 ui.note(_('\nkeywords written to %s:\n') % path)
343 ui.note('\nhg -R "%s" branch "%s"\n' % (tmpdir, branchname))
344 # silence branch command if not verbose
346 ui.quiet = not ui.verbose
347 commands.branch(ui, repo, branchname)
349 for name, cmd in ui.configitems('hooks'):
350 if name.split('.', 1)[0].find('commit') > -1:
351 repo.ui.setconfig('hooks', name, '')
352 ui.note(_('unhooked all commit hooks\n'))
353 ui.note('hg -R "%s" ci -m "%s"\n' % (tmpdir, msg))
354 repo.commit(text=msg)
355 ui.status(_('\n\tkeywords expanded\n'))
356 ui.write(repo.wread(fn))
357 ui.debug(_('\nremoving temporary repository %s\n') % tmpdir)
358 shutil.rmtree(tmpdir, ignore_errors=True)
360 def expand(ui, repo, *pats, **opts):
361 '''expand keywords in the working directory
363 Run after (re)enabling keyword expansion.
365 kwexpand refuses to run if given files contain local changes.
367 # 3rd argument sets expansion to True
368 _kwfwrite(ui, repo, True, *pats, **opts)
370 def files(ui, repo, *pats, **opts):
371 '''show files configured for keyword expansion
373 List which files in the working directory are matched by the
374 [keyword] configuration patterns.
376 Useful to prevent inadvertent keyword expansion and to speed up
377 execution by including only files that are actual candidates for
380 See "hg help keyword" on how to construct patterns both for
381 inclusion and exclusion of files.
383 Use -u/--untracked to list untracked files as well.
385 With -a/--all and -v/--verbose the codes used to show the status
388 K = keyword expansion candidate
389 k = keyword expansion candidate (untracked)
391 i = ignored (untracked)
393 kwt = kwtools['templater']
394 status = _status(ui, repo, kwt, opts.get('untracked'), *pats, **opts)
395 modified, added, removed, deleted, unknown, ignored, clean = status
396 files = sorted(modified + added + clean)
398 kwfiles = [f for f in files if kwt.iskwfile(f, wctx.flags)]
399 kwuntracked = [f for f in unknown if kwt.iskwfile(f, wctx.flags)]
400 cwd = pats and repo.getcwd() or ''
401 kwfstats = (not opts.get('ignore') and
402 (('K', kwfiles), ('k', kwuntracked),) or ())
403 if opts.get('all') or opts.get('ignore'):
404 kwfstats += (('I', [f for f in files if f not in kwfiles]),
405 ('i', [f for f in unknown if f not in kwuntracked]),)
406 for char, filenames in kwfstats:
407 fmt = (opts.get('all') or ui.verbose) and '%s %%s\n' % char or '%s\n'
409 ui.write(fmt % repo.pathto(f, cwd))
411 def shrink(ui, repo, *pats, **opts):
412 '''revert expanded keywords in the working directory
414 Run before changing/disabling active keywords or if you experience
415 problems with "hg import" or "hg merge".
417 kwshrink refuses to run if given files contain local changes.
419 # 3rd argument sets expansion to False
420 _kwfwrite(ui, repo, False, *pats, **opts)
424 '''Collects [keyword] config in kwtools.
425 Monkeypatches dispatch._parse if needed.'''
427 for pat, opt in ui.configitems('keyword'):
429 kwtools['inc'].append(pat)
431 kwtools['exc'].append(pat)
434 def kwdispatch_parse(orig, ui, args):
435 '''Monkeypatch dispatch._parse to obtain running hg command.'''
436 cmd, func, args, options, cmdoptions = orig(ui, args)
437 kwtools['hgcmd'] = cmd
438 return cmd, func, args, options, cmdoptions
440 extensions.wrapfunction(dispatch, '_parse', kwdispatch_parse)
442 def reposetup(ui, repo):
443 '''Sets up repo as kwrepo for keyword substitution.
444 Overrides file method to return kwfilelog instead of filelog
445 if file matches user configuration.
446 Wraps commit to overwrite configured files with updated
447 keyword substitutions.
448 Monkeypatches patch and webcommands.'''
451 if (not repo.local() or not kwtools['inc']
452 or kwtools['hgcmd'] in nokwcommands.split()
453 or '.hg' in util.splitpath(repo.root)
454 or repo._url.startswith('bundle:')):
456 except AttributeError:
459 kwtools['templater'] = kwt = kwtemplater(ui, repo)
461 class kwrepo(repo.__class__):
465 return kwfilelog(self.sopener, kwt, f)
467 def wread(self, filename):
468 data = super(kwrepo, self).wread(filename)
469 return kwt.wread(filename, data)
471 def commit(self, *args, **opts):
472 # use custom commitctx for user commands
473 # other extensions can still wrap repo.commitctx directly
474 self.commitctx = self.kwcommitctx
476 return super(kwrepo, self).commit(*args, **opts)
480 def kwcommitctx(self, ctx, error=False):
485 # store and postpone commit hooks
487 for name, cmd in ui.configitems('hooks'):
488 if name.split('.', 1)[0] == 'commit':
489 commithooks[name] = cmd
490 ui.setconfig('hooks', name, None)
492 # store parents for commit hooks
493 p1, p2 = ctx.p1(), ctx.p2()
494 xp1, xp2 = p1.hex(), p2 and p2.hex() or ''
496 n = super(kwrepo, self).commitctx(ctx, error)
498 kwt.overwrite(n, True, None)
500 for name, cmd in commithooks.iteritems():
501 ui.setconfig('hooks', name, cmd)
502 self.hook('commit', node=n, parent1=xp1, parent2=xp2)
508 def kwpatchfile_init(orig, self, ui, fname, opener,
509 missing=False, eol=None):
510 '''Monkeypatch/wrap patch.patchfile.__init__ to avoid
511 rejects or conflicts due to expanded keywords in working dir.'''
512 orig(self, ui, fname, opener, missing, eol)
513 # shrink keywords read from working dir
514 self.lines = kwt.shrinklines(self.fname, self.lines)
516 def kw_diff(orig, repo, node1=None, node2=None, match=None, changes=None,
518 '''Monkeypatch patch.diff to avoid expansion except when
519 comparing against working dir.'''
520 if node2 is not None:
521 kwt.match = util.never
522 elif node1 is not None and node1 != repo['.'].node():
524 return orig(repo, node1, node2, match, changes, opts)
526 def kwweb_skip(orig, web, req, tmpl):
527 '''Wraps webcommands.x turning off keyword expansion.'''
528 kwt.match = util.never
529 return orig(web, req, tmpl)
531 repo.__class__ = kwrepo
533 extensions.wrapfunction(patch.patchfile, '__init__', kwpatchfile_init)
534 extensions.wrapfunction(patch, 'diff', kw_diff)
535 for c in 'annotate changeset rev filediff diff'.split():
536 extensions.wrapfunction(webcommands, c, kwweb_skip)
541 [('d', 'default', None, _('show default keyword template maps')),
542 ('f', 'rcfile', '', _('read maps from rcfile'))],
543 _('hg kwdemo [-d] [-f RCFILE] [TEMPLATEMAP]...')),
544 'kwexpand': (expand, commands.walkopts,
545 _('hg kwexpand [OPTION]... [FILE]...')),
548 [('a', 'all', None, _('show keyword status flags of all files')),
549 ('i', 'ignore', None, _('show files excluded from expansion')),
550 ('u', 'untracked', None, _('additionally show untracked files')),
551 ] + commands.walkopts,
552 _('hg kwfiles [OPTION]... [FILE]...')),
553 'kwshrink': (shrink, commands.walkopts,
554 _('hg kwshrink [OPTION]... [FILE]...')),