]> git.lizzy.rs Git - plan9front.git/blob - sys/lib/python/hgext/keyword.py
hgwebfs: write headers individually, so they are not limited by webfs iounit (thanks...
[plan9front.git] / sys / lib / python / hgext / keyword.py
1 # keyword.py - $Keyword$ expansion for Mercurial
2 #
3 # Copyright 2007-2009 Christian Ebert <blacktrash@gmx.net>
4 #
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.
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
15 # audience not running a version control system.
16 #
17 # For in-depth discussion refer to
18 # <http://mercurial.selenic.com/wiki/KeywordPlan>.
19 #
20 # Keyword expansion is based on Mercurial's changeset template mappings.
21 #
22 # Binary files are not touched.
23 #
24 # Files to act upon/ignore are specified in the [keyword] section.
25 # Customized keyword template mappings in the [keywordmaps] section.
26 #
27 # Run "hg help keyword" and "hg kwdemo" to get info on configuration.
28
29 '''expand keywords in tracked files
30
31 This extension expands RCS/CVS-like or self-customized $Keywords$ in
32 tracked text files selected by your configuration.
33
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.
37
38 Configuration is done in the [keyword] and [keywordmaps] sections of
39 hgrc files.
40
41 Example::
42
43     [keyword]
44     # expand keywords in every python file except those matching "x*"
45     **.py =
46     x*    = ignore
47
48 NOTE: the more specific you are in your filename patterns the less you
49 lose speed in huge repositories.
50
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.
54
55 An additional date template filter {date|utcdate} is provided. It
56 returns a date like "2006/09/18 15:13:13".
57
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.
61
62 Before changing/disabling active keywords, run "hg kwshrink" to avoid
63 the risk of inadvertently storing expanded keywords in the change
64 history.
65
66 To force expansion after enabling it, or a configuration change, run
67 "hg kwexpand".
68
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
72 have been checked in.
73
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.
77 '''
78
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
86
87 commands.optionalrepo += ' kwdemo'
88
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')
93
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'
97
98 # provide cvs-like UTC date filter
99 utcdate = lambda x: util.datestr(x, '%Y/%m/%d %H:%M:%S')
100
101 # make keyword tools accessible
102 kwtools = {'templater': None, 'hgcmd': '', 'inc': [], 'exc': ['.hg*']}
103
104
105 class kwtemplater(object):
106     '''
107     Sets up keyword templates, corresponding keyword regex, and
108     provides keyword substitution functions.
109     '''
110     templates = {
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}',
118     }
119
120     def __init__(self, ui, repo):
121         self.ui = ui
122         self.repo = repo
123         self.match = match.match(repo.root, '', [],
124                                  kwtools['inc'], kwtools['exc'])
125         self.restrict = kwtools['hgcmd'] in restricted.split()
126
127         kwmaps = self.ui.configitems('keywordmaps')
128         if kwmaps: # override default templates
129             self.templates = dict((k, templater.parsestring(v, False))
130                                   for k, v in kwmaps)
131         escaped = map(re.escape, self.templates.keys())
132         kwpat = r'\$(%s)(: [^$\n\r]*? )??\$' % '|'.join(escaped)
133         self.re_kw = re.compile(kwpat)
134
135         templatefilters.filters['utcdate'] = utcdate
136         self.ct = cmdutil.changeset_templater(self.ui, self.repo,
137                                               False, None, '', False)
138
139     def substitute(self, data, path, ctx, subfunc):
140         '''Replaces keywords in data with expanded template.'''
141         def kwsub(mobj):
142             kw = mobj.group(1)
143             self.ct.use_template(self.templates[kw])
144             self.ui.pushbuffer()
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)
149
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)
155         return data
156
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)
162
163     def overwrite(self, node, expand, files):
164         '''Overwrites selected files expanding/shrinking keywords.'''
165         ctx = self.repo[node]
166         mf = ctx.manifest()
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)]
173         if candidates:
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'))
177             for f in candidates:
178                 fp = self.repo.file(f)
179                 data = fp.read(mf[f])
180                 if util.binary(data):
181                     continue
182                 if expand:
183                     if node is None:
184                         ctx = self.repo.filectx(f, fileid=mf[f]).changectx()
185                     data, found = self.substitute(data, f, ctx,
186                                                   self.re_kw.subn)
187                 else:
188                     found = self.re_kw.search(data)
189                 if found:
190                     notify(msg % f)
191                     self.repo.wwrite(f, data, mf.flags(f))
192                     if node is None:
193                         self.repo.dirstate.normal(f)
194             self.restrict = False
195
196     def shrinktext(self, text):
197         '''Unconditionally removes all keyword substitutions from text.'''
198         return self.re_kw.sub(r'$\1$', text)
199
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)
204         return text
205
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)
212         return lines
213
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
218
219 class kwfilelog(filelog.filelog):
220     '''
221     Subclass of filelog to hook into its read, add, cmp methods.
222     Keywords are "stored" unexpanded, and processed on reading.
223     '''
224     def __init__(self, opener, kwt, path):
225         super(kwfilelog, self).__init__(opener, path)
226         self.kwt = kwt
227         self.path = path
228
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)
233
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)
238
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)
244             return t2 != text
245         return revlog.revlog.cmp(self, node, text)
246
247 def _status(ui, repo, kwt, unknown, *pats, **opts):
248     '''Bails out if [keyword] configuration is not active.
249     Returns status of working directory.'''
250     if kwt:
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'))
256
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'))
266     wlock = lock = None
267     try:
268         wlock = repo.wlock()
269         lock = repo.lock()
270         kwt.overwrite(None, expand, status[6])
271     finally:
272         release(lock, wlock)
273
274 def demo(ui, repo, *args, **opts):
275     '''print [keywordmaps] configuration and an expansion example
276
277     Show current, custom, or default keyword template maps and their
278     expansions.
279
280     Extend the current configuration by specifying maps as arguments
281     and using -f/--rcfile to source an external hgrc file.
282
283     Use -d/--default to disable current configuration.
284
285     See "hg help templates" for information on templates and filters.
286     '''
287     def demoitems(section, items):
288         ui.write('[%s]\n' % section)
289         for k, v in items:
290             ui.write('%s = %s\n' % (k, v))
291
292     msg = 'hg keyword config and expansion example'
293     fn = 'demo.txt'
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, '')
299
300     uikwmaps = ui.configitems('keywordmaps')
301     if args or opts.get('rcfile'):
302         ui.status(_('\n\tconfiguration using custom keyword template maps\n'))
303         if uikwmaps:
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'))
309         if args:
310             # simulate hgrc parsing
311             rcmaps = ['[keywordmaps]\n'] + [a + '\n' for a in args]
312             fp = repo.opener('hgrc', 'w')
313             fp.writelines(rcmaps)
314             fp.close()
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
320         if uikwmaps:
321             ui.status(_('\tdisabling current template maps\n'))
322             for k, v in kwmaps.iteritems():
323                 ui.setconfig('keywordmaps', k, v)
324     else:
325         ui.status(_('\n\tconfiguration using current keyword template maps\n'))
326         kwmaps = dict(uikwmaps) or kwtemplater.templates
327
328     uisetup(ui)
329     reposetup(ui, repo)
330     for k, v in ui.configitems('extensions'):
331         if k.endswith('keyword'):
332             extension = '%s = %s' % (k, v)
333             break
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)
339     repo.add([fn])
340     path = repo.wjoin(fn)
341     ui.note(_('\nkeywords written to %s:\n') % path)
342     ui.note(keywords)
343     ui.note('\nhg -R "%s" branch "%s"\n' % (tmpdir, branchname))
344     # silence branch command if not verbose
345     quiet = ui.quiet
346     ui.quiet = not ui.verbose
347     commands.branch(ui, repo, branchname)
348     ui.quiet = quiet
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)
359
360 def expand(ui, repo, *pats, **opts):
361     '''expand keywords in the working directory
362
363     Run after (re)enabling keyword expansion.
364
365     kwexpand refuses to run if given files contain local changes.
366     '''
367     # 3rd argument sets expansion to True
368     _kwfwrite(ui, repo, True, *pats, **opts)
369
370 def files(ui, repo, *pats, **opts):
371     '''show files configured for keyword expansion
372
373     List which files in the working directory are matched by the
374     [keyword] configuration patterns.
375
376     Useful to prevent inadvertent keyword expansion and to speed up
377     execution by including only files that are actual candidates for
378     expansion.
379
380     See "hg help keyword" on how to construct patterns both for
381     inclusion and exclusion of files.
382
383     Use -u/--untracked to list untracked files as well.
384
385     With -a/--all and -v/--verbose the codes used to show the status
386     of files are::
387
388       K = keyword expansion candidate
389       k = keyword expansion candidate (untracked)
390       I = ignored
391       i = ignored (untracked)
392     '''
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)
397     wctx = repo[None]
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'
408         for f in filenames:
409             ui.write(fmt % repo.pathto(f, cwd))
410
411 def shrink(ui, repo, *pats, **opts):
412     '''revert expanded keywords in the working directory
413
414     Run before changing/disabling active keywords or if you experience
415     problems with "hg import" or "hg merge".
416
417     kwshrink refuses to run if given files contain local changes.
418     '''
419     # 3rd argument sets expansion to False
420     _kwfwrite(ui, repo, False, *pats, **opts)
421
422
423 def uisetup(ui):
424     '''Collects [keyword] config in kwtools.
425     Monkeypatches dispatch._parse if needed.'''
426
427     for pat, opt in ui.configitems('keyword'):
428         if opt != 'ignore':
429             kwtools['inc'].append(pat)
430         else:
431             kwtools['exc'].append(pat)
432
433     if kwtools['inc']:
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
439
440         extensions.wrapfunction(dispatch, '_parse', kwdispatch_parse)
441
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.'''
449
450     try:
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:')):
455             return
456     except AttributeError:
457         pass
458
459     kwtools['templater'] = kwt = kwtemplater(ui, repo)
460
461     class kwrepo(repo.__class__):
462         def file(self, f):
463             if f[0] == '/':
464                 f = f[1:]
465             return kwfilelog(self.sopener, kwt, f)
466
467         def wread(self, filename):
468             data = super(kwrepo, self).wread(filename)
469             return kwt.wread(filename, data)
470
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
475             try:
476                 return super(kwrepo, self).commit(*args, **opts)
477             finally:
478                 del self.commitctx
479
480         def kwcommitctx(self, ctx, error=False):
481             wlock = lock = None
482             try:
483                 wlock = self.wlock()
484                 lock = self.lock()
485                 # store and postpone commit hooks
486                 commithooks = {}
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)
491                 if commithooks:
492                     # store parents for commit hooks
493                     p1, p2 = ctx.p1(), ctx.p2()
494                     xp1, xp2 = p1.hex(), p2 and p2.hex() or ''
495
496                 n = super(kwrepo, self).commitctx(ctx, error)
497
498                 kwt.overwrite(n, True, None)
499                 if commithooks:
500                     for name, cmd in commithooks.iteritems():
501                         ui.setconfig('hooks', name, cmd)
502                     self.hook('commit', node=n, parent1=xp1, parent2=xp2)
503                 return n
504             finally:
505                 release(lock, wlock)
506
507     # monkeypatches
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)
515
516     def kw_diff(orig, repo, node1=None, node2=None, match=None, changes=None,
517                 opts=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():
523             kwt.restrict = True
524         return orig(repo, node1, node2, match, changes, opts)
525
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)
530
531     repo.__class__ = kwrepo
532
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)
537
538 cmdtable = {
539     'kwdemo':
540         (demo,
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]...')),
546     'kwfiles':
547         (files,
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]...')),
555 }