comparison hgext/fastannotate/commands.py @ 39210:1ddb296e0dee

fastannotate: initial import from Facebook's hg-experimental I made as few changes as I could to get the tests to pass, but this was a bit involved due to some churn in the blame code since someone last gave fastannotate any TLC. There's still follow-up work here to rip out support for old versions of hg and to integrate the protocol with modern standards. Some performance numbers (all on my 2016 MacBook Pro with a 2.6Ghz i7): Mercurial mercurial/manifest.py traditional blame time: real 1.050 secs (user 0.990+0.000 sys 0.060+0.000) build cache time: real 5.900 secs (user 5.720+0.000 sys 0.110+0.000) fastannotate time: real 0.120 secs (user 0.100+0.000 sys 0.020+0.000) Mercurial mercurial/localrepo.py traditional blame time: real 3.330 secs (user 3.220+0.000 sys 0.070+0.000) build cache time: real 30.610 secs (user 30.190+0.000 sys 0.230+0.000) fastannotate time: real 0.180 secs (user 0.160+0.000 sys 0.020+0.000) mozilla-central dom/ipc/ContentParent.cpp traditional blame time: real 7.640 secs (user 7.210+0.000 sys 0.380+0.000) build cache time: real 98.650 secs (user 97.000+0.000 sys 0.950+0.000) fastannotate time: real 1.580 secs (user 1.340+0.000 sys 0.240+0.000) mozilla-central dom/base/nsDocument.cpp traditional blame time: real 17.110 secs (user 16.490+0.000 sys 0.500+0.000) build cache time: real 399.750 secs (user 394.520+0.000 sys 2.610+0.000) fastannotate time: real 1.780 secs (user 1.530+0.000 sys 0.240+0.000) So building the cache is expensive (but might be faster with xdiff enabled), but the blame results are *way* faster. Differential Revision: https://phab.mercurial-scm.org/D3994
author Augie Fackler <augie@google.com>
date Mon, 30 Jul 2018 22:50:00 -0400
parents
children b3572f733dbd
comparison
equal deleted inserted replaced
39209:1af95139e5ec 39210:1ddb296e0dee
1 # Copyright 2016-present Facebook. All Rights Reserved.
2 #
3 # commands: fastannotate commands
4 #
5 # This software may be used and distributed according to the terms of the
6 # GNU General Public License version 2 or any later version.
7
8 from __future__ import absolute_import
9
10 import os
11
12 from mercurial.i18n import _
13 from mercurial import (
14 commands,
15 error,
16 extensions,
17 patch,
18 pycompat,
19 registrar,
20 scmutil,
21 util,
22 )
23
24 from . import (
25 context as facontext,
26 error as faerror,
27 formatter as faformatter,
28 )
29
30 cmdtable = {}
31 command = registrar.command(cmdtable)
32
33 def _matchpaths(repo, rev, pats, opts, aopts=facontext.defaultopts):
34 """generate paths matching given patterns"""
35 perfhack = repo.ui.configbool('fastannotate', 'perfhack')
36
37 # disable perfhack if:
38 # a) any walkopt is used
39 # b) if we treat pats as plain file names, some of them do not have
40 # corresponding linelog files
41 if perfhack:
42 # cwd related to reporoot
43 reporoot = os.path.dirname(repo.path)
44 reldir = os.path.relpath(pycompat.getcwd(), reporoot)
45 if reldir == '.':
46 reldir = ''
47 if any(opts.get(o[1]) for o in commands.walkopts): # a)
48 perfhack = False
49 else: # b)
50 relpats = [os.path.relpath(p, reporoot) if os.path.isabs(p) else p
51 for p in pats]
52 # disable perfhack on '..' since it allows escaping from the repo
53 if any(('..' in f or
54 not os.path.isfile(
55 facontext.pathhelper(repo, f, aopts).linelogpath))
56 for f in relpats):
57 perfhack = False
58
59 # perfhack: emit paths directory without checking with manifest
60 # this can be incorrect if the rev dos not have file.
61 if perfhack:
62 for p in relpats:
63 yield os.path.join(reldir, p)
64 else:
65 def bad(x, y):
66 raise error.Abort("%s: %s" % (x, y))
67 ctx = scmutil.revsingle(repo, rev)
68 m = scmutil.match(ctx, pats, opts, badfn=bad)
69 for p in ctx.walk(m):
70 yield p
71
72 fastannotatecommandargs = {
73 'options': [
74 ('r', 'rev', '.', _('annotate the specified revision'), _('REV')),
75 ('u', 'user', None, _('list the author (long with -v)')),
76 ('f', 'file', None, _('list the filename')),
77 ('d', 'date', None, _('list the date (short with -q)')),
78 ('n', 'number', None, _('list the revision number (default)')),
79 ('c', 'changeset', None, _('list the changeset')),
80 ('l', 'line-number', None, _('show line number at the first '
81 'appearance')),
82 ('e', 'deleted', None, _('show deleted lines (slow) (EXPERIMENTAL)')),
83 ('', 'no-content', None, _('do not show file content (EXPERIMENTAL)')),
84 ('', 'no-follow', None, _("don't follow copies and renames")),
85 ('', 'linear', None, _('enforce linear history, ignore second parent '
86 'of merges (EXPERIMENTAL)')),
87 ('', 'long-hash', None, _('show long changeset hash (EXPERIMENTAL)')),
88 ('', 'rebuild', None, _('rebuild cache even if it exists '
89 '(EXPERIMENTAL)')),
90 ] + commands.diffwsopts + commands.walkopts + commands.formatteropts,
91 'synopsis': _('[-r REV] [-f] [-a] [-u] [-d] [-n] [-c] [-l] FILE...'),
92 'inferrepo': True,
93 }
94
95 def fastannotate(ui, repo, *pats, **opts):
96 """show changeset information by line for each file
97
98 List changes in files, showing the revision id responsible for each line.
99
100 This command is useful for discovering when a change was made and by whom.
101
102 By default this command prints revision numbers. If you include --file,
103 --user, or --date, the revision number is suppressed unless you also
104 include --number. The default format can also be customized by setting
105 fastannotate.defaultformat.
106
107 Returns 0 on success.
108
109 .. container:: verbose
110
111 This command uses an implementation different from the vanilla annotate
112 command, which may produce slightly different (while still reasonable)
113 outputs for some cases.
114
115 Unlike the vanilla anootate, fastannotate follows rename regardless of
116 the existence of --file.
117
118 For the best performance when running on a full repo, use -c, -l,
119 avoid -u, -d, -n. Use --linear and --no-content to make it even faster.
120
121 For the best performance when running on a shallow (remotefilelog)
122 repo, avoid --linear, --no-follow, or any diff options. As the server
123 won't be able to populate annotate cache when non-default options
124 affecting results are used.
125 """
126 if not pats:
127 raise error.Abort(_('at least one filename or pattern is required'))
128
129 # performance hack: filtered repo can be slow. unfilter by default.
130 if ui.configbool('fastannotate', 'unfilteredrepo'):
131 repo = repo.unfiltered()
132
133 rev = opts.get('rev', '.')
134 rebuild = opts.get('rebuild', False)
135
136 diffopts = patch.difffeatureopts(ui, opts, section='annotate',
137 whitespace=True)
138 aopts = facontext.annotateopts(
139 diffopts=diffopts,
140 followmerge=not opts.get('linear', False),
141 followrename=not opts.get('no_follow', False))
142
143 if not any(opts.get(s)
144 for s in ['user', 'date', 'file', 'number', 'changeset']):
145 # default 'number' for compatibility. but fastannotate is more
146 # efficient with "changeset", "line-number" and "no-content".
147 for name in ui.configlist('fastannotate', 'defaultformat', ['number']):
148 opts[name] = True
149
150 ui.pager('fastannotate')
151 template = opts.get('template')
152 if template == 'json':
153 formatter = faformatter.jsonformatter(ui, repo, opts)
154 else:
155 formatter = faformatter.defaultformatter(ui, repo, opts)
156 showdeleted = opts.get('deleted', False)
157 showlines = not bool(opts.get('no_content'))
158 showpath = opts.get('file', False)
159
160 # find the head of the main (master) branch
161 master = ui.config('fastannotate', 'mainbranch') or rev
162
163 # paths will be used for prefetching and the real annotating
164 paths = list(_matchpaths(repo, rev, pats, opts, aopts))
165
166 # for client, prefetch from the server
167 if util.safehasattr(repo, 'prefetchfastannotate'):
168 repo.prefetchfastannotate(paths)
169
170 for path in paths:
171 result = lines = existinglines = None
172 while True:
173 try:
174 with facontext.annotatecontext(repo, path, aopts, rebuild) as a:
175 result = a.annotate(rev, master=master, showpath=showpath,
176 showlines=(showlines and
177 not showdeleted))
178 if showdeleted:
179 existinglines = set((l[0], l[1]) for l in result)
180 result = a.annotatealllines(
181 rev, showpath=showpath, showlines=showlines)
182 break
183 except (faerror.CannotReuseError, faerror.CorruptedFileError):
184 # happens if master moves backwards, or the file was deleted
185 # and readded, or renamed to an existing name, or corrupted.
186 if rebuild: # give up since we have tried rebuild already
187 raise
188 else: # try a second time rebuilding the cache (slow)
189 rebuild = True
190 continue
191
192 if showlines:
193 result, lines = result
194
195 formatter.write(result, lines, existinglines=existinglines)
196 formatter.end()
197
198 _newopts = set([])
199 _knownopts = set([opt[1].replace('-', '_') for opt in
200 (fastannotatecommandargs['options'] + commands.globalopts)])
201
202 def _annotatewrapper(orig, ui, repo, *pats, **opts):
203 """used by wrapdefault"""
204 # we need this hack until the obsstore has 0.0 seconds perf impact
205 if ui.configbool('fastannotate', 'unfilteredrepo'):
206 repo = repo.unfiltered()
207
208 # treat the file as text (skip the isbinary check)
209 if ui.configbool('fastannotate', 'forcetext'):
210 opts['text'] = True
211
212 # check if we need to do prefetch (client-side)
213 rev = opts.get('rev')
214 if util.safehasattr(repo, 'prefetchfastannotate') and rev is not None:
215 paths = list(_matchpaths(repo, rev, pats, opts))
216 repo.prefetchfastannotate(paths)
217
218 return orig(ui, repo, *pats, **opts)
219
220 def registercommand():
221 """register the fastannotate command"""
222 name = '^fastannotate|fastblame|fa'
223 command(name, **fastannotatecommandargs)(fastannotate)
224
225 def wrapdefault():
226 """wrap the default annotate command, to be aware of the protocol"""
227 extensions.wrapcommand(commands.table, 'annotate', _annotatewrapper)
228
229 @command('debugbuildannotatecache',
230 [('r', 'rev', '', _('build up to the specific revision'), _('REV'))
231 ] + commands.walkopts,
232 _('[-r REV] FILE...'))
233 def debugbuildannotatecache(ui, repo, *pats, **opts):
234 """incrementally build fastannotate cache up to REV for specified files
235
236 If REV is not specified, use the config 'fastannotate.mainbranch'.
237
238 If fastannotate.client is True, download the annotate cache from the
239 server. Otherwise, build the annotate cache locally.
240
241 The annotate cache will be built using the default diff and follow
242 options and lives in '.hg/fastannotate/default'.
243 """
244 rev = opts.get('REV') or ui.config('fastannotate', 'mainbranch')
245 if not rev:
246 raise error.Abort(_('you need to provide a revision'),
247 hint=_('set fastannotate.mainbranch or use --rev'))
248 if ui.configbool('fastannotate', 'unfilteredrepo'):
249 repo = repo.unfiltered()
250 ctx = scmutil.revsingle(repo, rev)
251 m = scmutil.match(ctx, pats, opts)
252 paths = list(ctx.walk(m))
253 if util.safehasattr(repo, 'prefetchfastannotate'):
254 # client
255 if opts.get('REV'):
256 raise error.Abort(_('--rev cannot be used for client'))
257 repo.prefetchfastannotate(paths)
258 else:
259 # server, or full repo
260 for i, path in enumerate(paths):
261 ui.progress(_('building'), i, total=len(paths))
262 with facontext.annotatecontext(repo, path) as actx:
263 try:
264 if actx.isuptodate(rev):
265 continue
266 actx.annotate(rev, rev)
267 except (faerror.CannotReuseError, faerror.CorruptedFileError):
268 # the cache is broken (could happen with renaming so the
269 # file history gets invalidated). rebuild and try again.
270 ui.debug('fastannotate: %s: rebuilding broken cache\n'
271 % path)
272 actx.rebuild()
273 try:
274 actx.annotate(rev, rev)
275 except Exception as ex:
276 # possibly a bug, but should not stop us from building
277 # cache for other files.
278 ui.warn(_('fastannotate: %s: failed to '
279 'build cache: %r\n') % (path, ex))
280 # clear the progress bar
281 ui.write()