##// END OF EJS Templates
Add extension for filewise RCS-keyword expansion in working dir...
Christian Ebert -
r5815:0637d97a default
parent child Browse files
Show More
@@ -0,0 +1,498 b''
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 #
22 # Binary files are not touched.
23 #
24 # Setup in hgrc:
25 #
26 # [extensions]
27 # # enable extension
28 # hgext.keyword =
29 #
30 # Files to act upon/ignore are specified in the [keyword] section.
31 # Customized keyword template mappings in the [keywordmaps] section.
32 #
33 # Run "hg help keyword" and "hg kwdemo" to get info on configuration.
34
35 '''keyword expansion in local repositories
36
37 This extension expands RCS/CVS-like or self-customized $Keywords$
38 in tracked text files selected by your configuration.
39
40 Keywords are only expanded in local repositories and not stored in
41 the change history. The mechanism can be regarded as a convenience
42 for the current user or for archive distribution.
43
44 Configuration is done in the [keyword] and [keywordmaps] sections
45 of hgrc files.
46
47 Example:
48
49 [keyword]
50 # expand keywords in every python file except those matching "x*"
51 **.py =
52 x* = ignore
53
54 Note: the more specific you are in your filename patterns
55 the less you lose speed in huge repos.
56
57 For [keywordmaps] template mapping and expansion demonstration and
58 control run "hg kwdemo".
59
60 An additional date template filter {date|utcdate} is provided.
61
62 The default template mappings (view with "hg kwdemo -d") can be replaced
63 with customized keywords and templates.
64 Again, run "hg kwdemo" to control the results of your config changes.
65
66 Before changing/disabling active keywords, run "hg kwshrink" to avoid
67 the risk of inadvertedly storing expanded keywords in the change history.
68
69 To force expansion after enabling it, or a configuration change, run
70 "hg kwexpand".
71
72 Expansions spanning more than one line and incremental expansions,
73 like CVS' $Log$, are not supported. A keyword template map
74 "Log = {desc}" expands to the first line of the changeset description.
75 '''
76
77 from mercurial import commands, cmdutil, context, fancyopts, filelog
78 from mercurial import patch, localrepo, revlog, templater, util
79 from mercurial.node import *
80 from mercurial.i18n import _
81 import re, shutil, sys, tempfile, time
82
83 commands.optionalrepo += ' kwdemo'
84
85 def utcdate(date):
86 '''Returns hgdate in cvs-like UTC format.'''
87 return time.strftime('%Y/%m/%d %H:%M:%S', time.gmtime(date[0]))
88
89 _kwtemplater = None
90
91 class kwtemplater(object):
92 '''
93 Sets up keyword templates, corresponding keyword regex, and
94 provides keyword substitution functions.
95 '''
96 templates = {
97 'Revision': '{node|short}',
98 'Author': '{author|user}',
99 'Date': '{date|utcdate}',
100 'RCSFile': '{file|basename},v',
101 'Source': '{root}/{file},v',
102 'Id': '{file|basename},v {node|short} {date|utcdate} {author|user}',
103 'Header': '{root}/{file},v {node|short} {date|utcdate} {author|user}',
104 }
105
106 def __init__(self, ui, repo, inc, exc):
107 self.ui = ui
108 self.repo = repo
109 self.matcher = util.matcher(repo.root, inc=inc, exc=exc)[1]
110 self.node = None
111 self.path = ''
112
113 kwmaps = self.ui.configitems('keywordmaps')
114 if kwmaps: # override default templates
115 kwmaps = [(k, templater.parsestring(v, quoted=False))
116 for (k, v) in kwmaps]
117 self.templates = dict(kwmaps)
118 escaped = map(re.escape, self.templates.keys())
119 kwpat = r'\$(%s)(: [^$\n\r]*? )??\$' % '|'.join(escaped)
120 self.re_kw = re.compile(kwpat)
121
122 templater.common_filters['utcdate'] = utcdate
123 self.ct = cmdutil.changeset_templater(self.ui, self.repo,
124 False, '', False)
125
126 def substitute(self, node, data, subfunc):
127 '''Obtains node if missing, and calls given substitution function.'''
128 if not self.node:
129 c = context.filectx(self.repo, self.path, fileid=node)
130 self.node = c.node()
131
132 def kwsub(mobj):
133 '''Substitutes keyword using corresponding template.'''
134 kw = mobj.group(1)
135 self.ct.use_template(self.templates[kw])
136 self.ui.pushbuffer()
137 self.ct.show(changenode=self.node,
138 root=self.repo.root, file=self.path)
139 return '$%s: %s $' % (kw, templater.firstline(self.ui.popbuffer()))
140
141 return subfunc(kwsub, data)
142
143 def expand(self, node, data):
144 '''Returns data with keywords expanded.'''
145 if util.binary(data):
146 return data
147 return self.substitute(node, data, self.re_kw.sub)
148
149 def process(self, node, data, expand):
150 '''Returns a tuple: data, count.
151 Count is number of keywords/keyword substitutions, indicates
152 to caller whether to act on file containing data.
153 Keywords in data are expanded, if templater was initialized.'''
154 if util.binary(data):
155 return data, None
156 if expand:
157 return self.substitute(node, data, self.re_kw.subn)
158 return data, self.re_kw.search(data)
159
160 def shrink(self, text):
161 '''Returns text with all keyword substitutions removed.'''
162 if util.binary(text):
163 return text
164 return self.re_kw.sub(r'$\1$', text)
165
166 class kwfilelog(filelog.filelog):
167 '''
168 Subclass of filelog to hook into its read, add, cmp methods.
169 Keywords are "stored" unexpanded, and processed on reading.
170 '''
171 def __init__(self, opener, path):
172 super(kwfilelog, self).__init__(opener, path)
173 _kwtemplater.path = path
174
175 def kwctread(self, node, expand):
176 '''Reads expanding and counting keywords
177 (only called from kwtemplater.overwrite).'''
178 data = super(kwfilelog, self).read(node)
179 return _kwtemplater.process(node, data, expand)
180
181 def read(self, node):
182 '''Expands keywords when reading filelog.'''
183 data = super(kwfilelog, self).read(node)
184 return _kwtemplater.expand(node, data)
185
186 def add(self, text, meta, tr, link, p1=None, p2=None):
187 '''Removes keyword substitutions when adding to filelog.'''
188 text = _kwtemplater.shrink(text)
189 return super(kwfilelog, self).add(text, meta, tr, link, p1=p1, p2=p2)
190
191 def cmp(self, node, text):
192 '''Removes keyword substitutions for comparison.'''
193 text = _kwtemplater.shrink(text)
194 if self.renamed(node):
195 t2 = super(kwfilelog, self).read(node)
196 return t2 != text
197 return revlog.revlog.cmp(self, node, text)
198
199
200 # store original patch.patchfile.__init__
201 _patchfile_init = patch.patchfile.__init__
202
203 def _kwpatchfile_init(self, ui, fname, missing=False):
204 '''Monkeypatch/wrap patch.patchfile.__init__ to avoid
205 rejects or conflicts due to expanded keywords in working dir.'''
206 _patchfile_init(self, ui, fname, missing=missing)
207
208 if _kwtemplater.matcher(self.fname):
209 # shrink keywords read from working dir
210 kwshrunk = _kwtemplater.shrink(''.join(self.lines))
211 self.lines = kwshrunk.splitlines(True)
212
213
214 def _iskwfile(f, link):
215 return not link(f) and _kwtemplater.matcher(f)
216
217 def _status(ui, repo, *pats, **opts):
218 '''Bails out if [keyword] configuration is not active.
219 Returns status of working directory.'''
220 if _kwtemplater:
221 files, match, anypats = cmdutil.matchpats(repo, pats, opts)
222 return repo.status(files=files, match=match, list_clean=True)
223 if ui.configitems('keyword'):
224 raise util.Abort(_('[keyword] patterns cannot match'))
225 raise util.Abort(_('no [keyword] patterns configured'))
226
227 def _overwrite(ui, repo, node=None, expand=True, files=None):
228 '''Overwrites selected files expanding/shrinking keywords.'''
229 ctx = repo.changectx(node)
230 mf = ctx.manifest()
231 if files is None:
232 notify = ui.debug # commit
233 files = [f for f in ctx.files() if mf.has_key(f)]
234 else:
235 notify = ui.note # kwexpand/kwshrink
236 candidates = [f for f in files if _iskwfile(f, mf.linkf)]
237 if candidates:
238 candidates.sort()
239 action = expand and 'expanding' or 'shrinking'
240 _kwtemplater.node = node or ctx.node()
241 for f in candidates:
242 fp = repo.file(f, kwmatch=True)
243 data, kwfound = fp.kwctread(mf[f], expand)
244 if kwfound:
245 notify(_('overwriting %s %s keywords\n') % (f, action))
246 repo.wwrite(f, data, mf.flags(f))
247 repo.dirstate.normal(f)
248
249 def _kwfwrite(ui, repo, expand, *pats, **opts):
250 '''Selects files and passes them to _overwrite.'''
251 status = _status(ui, repo, *pats, **opts)
252 modified, added, removed, deleted, unknown, ignored, clean = status
253 if modified or added or removed or deleted:
254 raise util.Abort(_('outstanding uncommitted changes in given files'))
255 wlock = lock = None
256 try:
257 wlock = repo.wlock()
258 lock = repo.lock()
259 _overwrite(ui, repo, expand=expand, files=clean)
260 finally:
261 del wlock, lock
262
263
264 def demo(ui, repo, *args, **opts):
265 '''print [keywordmaps] configuration and an expansion example
266
267 Show current, custom, or default keyword template maps
268 and their expansion.
269
270 Extend current configuration by specifying maps as arguments
271 and optionally by reading from an additional hgrc file.
272
273 Override current keyword template maps with "default" option.
274 '''
275 def demostatus(stat):
276 ui.status(_('\n\t%s\n') % stat)
277
278 def demoitems(section, items):
279 ui.write('[%s]\n' % section)
280 for k, v in items:
281 ui.write('%s = %s\n' % (k, v))
282
283 msg = 'hg keyword config and expansion example'
284 kwstatus = 'current'
285 fn = 'demo.txt'
286 branchname = 'demobranch'
287 tmpdir = tempfile.mkdtemp('', 'kwdemo.')
288 ui.note(_('creating temporary repo at %s\n') % tmpdir)
289 repo = localrepo.localrepository(ui, path=tmpdir, create=True)
290 ui.setconfig('keyword', fn, '')
291 if args or opts.get('rcfile'):
292 kwstatus = 'custom'
293 if opts.get('rcfile'):
294 ui.readconfig(opts.get('rcfile'))
295 if opts.get('default'):
296 kwstatus = 'default'
297 kwmaps = kwtemplater.templates
298 if ui.configitems('keywordmaps'):
299 # override maps from optional rcfile
300 for k, v in kwmaps.items():
301 ui.setconfig('keywordmaps', k, v)
302 elif args:
303 # simulate hgrc parsing
304 rcmaps = ['[keywordmaps]\n'] + [a + '\n' for a in args]
305 fp = repo.opener('hgrc', 'w')
306 fp.writelines(rcmaps)
307 fp.close()
308 ui.readconfig(repo.join('hgrc'))
309 if not opts.get('default'):
310 kwmaps = dict(ui.configitems('keywordmaps')) or kwtemplater.templates
311 reposetup(ui, repo)
312 for k, v in ui.configitems('extensions'):
313 if k.endswith('keyword'):
314 extension = '%s = %s' % (k, v)
315 break
316 demostatus('config using %s keyword template maps' % kwstatus)
317 ui.write('[extensions]\n%s\n' % extension)
318 demoitems('keyword', ui.configitems('keyword'))
319 demoitems('keywordmaps', kwmaps.items())
320 keywords = '$' + '$\n$'.join(kwmaps.keys()) + '$\n'
321 repo.wopener(fn, 'w').write(keywords)
322 repo.add([fn])
323 path = repo.wjoin(fn)
324 ui.note(_('\n%s keywords written to %s:\n') % (kwstatus, path))
325 ui.note(keywords)
326 ui.note('\nhg -R "%s" branch "%s"\n' % (tmpdir, branchname))
327 # silence branch command if not verbose
328 quiet = ui.quiet
329 verbose = ui.verbose
330 ui.quiet = not verbose
331 commands.branch(ui, repo, branchname)
332 ui.quiet = quiet
333 for name, cmd in ui.configitems('hooks'):
334 if name.split('.', 1)[0].find('commit') > -1:
335 repo.ui.setconfig('hooks', name, '')
336 ui.note(_('unhooked all commit hooks\n'))
337 ui.note('hg -R "%s" ci -m "%s"\n' % (tmpdir, msg))
338 repo.commit(text=msg)
339 format = ui.verbose and ' in %s' % path or ''
340 demostatus('%s keywords expanded%s' % (kwstatus, format))
341 ui.write(repo.wread(fn))
342 ui.debug(_('\nremoving temporary repo %s\n') % tmpdir)
343 shutil.rmtree(tmpdir, ignore_errors=True)
344
345 def expand(ui, repo, *pats, **opts):
346 '''expand keywords in working directory
347
348 Run after (re)enabling keyword expansion.
349
350 kwexpand refuses to run if given files contain local changes.
351 '''
352 # 3rd argument sets expansion to True
353 _kwfwrite(ui, repo, True, *pats, **opts)
354
355 def files(ui, repo, *pats, **opts):
356 '''print files currently configured for keyword expansion
357
358 Crosscheck which files in working directory are potential targets for
359 keyword expansion.
360 That is, files matched by [keyword] config patterns but not symlinks.
361 '''
362 status = _status(ui, repo, *pats, **opts)
363 modified, added, removed, deleted, unknown, ignored, clean = status
364 if opts.get('untracked'):
365 files = modified + added + unknown + clean
366 else:
367 files = modified + added + clean
368 files.sort()
369 kwfiles = [f for f in files if _iskwfile(f, repo._link)]
370 cwd = pats and repo.getcwd() or ''
371 kwfstats = not opts.get('ignore') and (('K', kwfiles),) or ()
372 if opts.get('all') or opts.get('ignore'):
373 kwfstats += (('I', [f for f in files if f not in kwfiles]),)
374 for char, filenames in kwfstats:
375 format = (opts.get('all') or ui.verbose) and '%s %%s\n' % char or '%s\n'
376 for f in filenames:
377 ui.write(format % repo.pathto(f, cwd))
378
379 def shrink(ui, repo, *pats, **opts):
380 '''revert expanded keywords in working directory
381
382 Run before changing/disabling active keywords
383 or if you experience problems with "hg import" or "hg merge".
384
385 kwshrink refuses to run if given files contain local changes.
386 '''
387 # 3rd argument sets expansion to False
388 _kwfwrite(ui, repo, False, *pats, **opts)
389
390
391 def reposetup(ui, repo):
392 '''Sets up repo as kwrepo for keyword substitution.
393 Overrides file method to return kwfilelog instead of filelog
394 if file matches user configuration.
395 Wraps commit to overwrite configured files with updated
396 keyword substitutions.
397 This is done for local repos only, and only if there are
398 files configured at all for keyword substitution.'''
399
400 def kwbailout():
401 '''Obtains command via simplified cmdline parsing,
402 returns True if keyword expansion not needed.'''
403 nokwcommands = ('add', 'addremove', 'bundle', 'clone', 'copy',
404 'export', 'grep', 'identify', 'incoming', 'init',
405 'outgoing', 'push', 'remove', 'rename', 'rollback',
406 'convert')
407 args = fancyopts.fancyopts(sys.argv[1:], commands.globalopts, {})
408 if args:
409 aliases, i = cmdutil.findcmd(ui, args[0], commands.table)
410 return aliases[0] in nokwcommands
411
412 if not repo.local() or kwbailout():
413 return
414
415 inc, exc = [], ['.hgtags']
416 for pat, opt in ui.configitems('keyword'):
417 if opt != 'ignore':
418 inc.append(pat)
419 else:
420 exc.append(pat)
421 if not inc:
422 return
423
424 global _kwtemplater
425 _kwtemplater = kwtemplater(ui, repo, inc, exc)
426
427 class kwrepo(repo.__class__):
428 def file(self, f, kwmatch=False):
429 if f[0] == '/':
430 f = f[1:]
431 if kwmatch or _kwtemplater.matcher(f):
432 return kwfilelog(self.sopener, f)
433 return filelog.filelog(self.sopener, f)
434
435 def commit(self, files=None, text='', user=None, date=None,
436 match=util.always, force=False, force_editor=False,
437 p1=None, p2=None, extra={}):
438 wlock = lock = None
439 _p1 = _p2 = None
440 try:
441 wlock = self.wlock()
442 lock = self.lock()
443 # store and postpone commit hooks
444 commithooks = []
445 for name, cmd in ui.configitems('hooks'):
446 if name.split('.', 1)[0] == 'commit':
447 commithooks.append((name, cmd))
448 ui.setconfig('hooks', name, None)
449 if commithooks:
450 # store parents for commit hook environment
451 if p1 is None:
452 _p1, _p2 = repo.dirstate.parents()
453 else:
454 _p1, _p2 = p1, p2 or nullid
455 _p1 = hex(_p1)
456 if _p2 == nullid:
457 _p2 = ''
458 else:
459 _p2 = hex(_p2)
460
461 node = super(kwrepo,
462 self).commit(files=files, text=text, user=user,
463 date=date, match=match, force=force,
464 force_editor=force_editor,
465 p1=p1, p2=p2, extra=extra)
466
467 # restore commit hooks
468 for name, cmd in commithooks:
469 ui.setconfig('hooks', name, cmd)
470 if node is not None:
471 _overwrite(ui, self, node=node)
472 repo.hook('commit', node=node, parent1=_p1, parent2=_p2)
473 return node
474 finally:
475 del wlock, lock
476
477 repo.__class__ = kwrepo
478 patch.patchfile.__init__ = _kwpatchfile_init
479
480
481 cmdtable = {
482 'kwdemo':
483 (demo,
484 [('d', 'default', None, _('show default keyword template maps')),
485 ('f', 'rcfile', [], _('read maps from rcfile'))],
486 _('hg kwdemo [-d] [-f RCFILE] [TEMPLATEMAP]...')),
487 'kwexpand': (expand, commands.walkopts,
488 _('hg kwexpand [OPTION]... [FILE]...')),
489 'kwfiles':
490 (files,
491 [('a', 'all', None, _('show keyword status flags of all files')),
492 ('i', 'ignore', None, _('show files excluded from expansion')),
493 ('u', 'untracked', None, _('additionally show untracked files')),
494 ] + commands.walkopts,
495 _('hg kwfiles [OPTION]... [FILE]...')),
496 'kwshrink': (shrink, commands.walkopts,
497 _('hg kwshrink [OPTION]... [FILE]...')),
498 }
General Comments 0
You need to be logged in to leave comments. Login now