|
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 } |