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
8from __future__ import absolute_import
9
10import os
11
12from mercurial.i18n import _
13from mercurial import (
14    commands,
15    encoding,
16    error,
17    extensions,
18    logcmdutil,
19    patch,
20    pycompat,
21    registrar,
22    scmutil,
23    util,
24)
25
26from . import (
27    context as facontext,
28    error as faerror,
29    formatter as faformatter,
30)
31
32cmdtable = {}
33command = registrar.command(cmdtable)
34
35
36def _matchpaths(repo, rev, pats, opts, aopts=facontext.defaultopts):
37    """generate paths matching given patterns"""
38    perfhack = repo.ui.configbool(b'fastannotate', b'perfhack')
39
40    # disable perfhack if:
41    # a) any walkopt is used
42    # b) if we treat pats as plain file names, some of them do not have
43    #    corresponding linelog files
44    if perfhack:
45        # cwd related to reporoot
46        reporoot = os.path.dirname(repo.path)
47        reldir = os.path.relpath(encoding.getcwd(), reporoot)
48        if reldir == b'.':
49            reldir = b''
50        if any(opts.get(o[1]) for o in commands.walkopts):  # a)
51            perfhack = False
52        else:  # b)
53            relpats = [
54                os.path.relpath(p, reporoot) if os.path.isabs(p) else p
55                for p in pats
56            ]
57            # disable perfhack on '..' since it allows escaping from the repo
58            if any(
59                (
60                    b'..' in f
61                    or not os.path.isfile(
62                        facontext.pathhelper(repo, f, aopts).linelogpath
63                    )
64                )
65                for f in relpats
66            ):
67                perfhack = False
68
69    # perfhack: emit paths directory without checking with manifest
70    # this can be incorrect if the rev dos not have file.
71    if perfhack:
72        for p in relpats:
73            yield os.path.join(reldir, p)
74    else:
75
76        def bad(x, y):
77            raise error.Abort(b"%s: %s" % (x, y))
78
79        ctx = logcmdutil.revsingle(repo, rev)
80        m = scmutil.match(ctx, pats, opts, badfn=bad)
81        for p in ctx.walk(m):
82            yield p
83
84
85fastannotatecommandargs = {
86    'options': [
87        (b'r', b'rev', b'.', _(b'annotate the specified revision'), _(b'REV')),
88        (b'u', b'user', None, _(b'list the author (long with -v)')),
89        (b'f', b'file', None, _(b'list the filename')),
90        (b'd', b'date', None, _(b'list the date (short with -q)')),
91        (b'n', b'number', None, _(b'list the revision number (default)')),
92        (b'c', b'changeset', None, _(b'list the changeset')),
93        (
94            b'l',
95            b'line-number',
96            None,
97            _(b'show line number at the first appearance'),
98        ),
99        (
100            b'e',
101            b'deleted',
102            None,
103            _(b'show deleted lines (slow) (EXPERIMENTAL)'),
104        ),
105        (
106            b'',
107            b'no-content',
108            None,
109            _(b'do not show file content (EXPERIMENTAL)'),
110        ),
111        (b'', b'no-follow', None, _(b"don't follow copies and renames")),
112        (
113            b'',
114            b'linear',
115            None,
116            _(
117                b'enforce linear history, ignore second parent '
118                b'of merges (EXPERIMENTAL)'
119            ),
120        ),
121        (
122            b'',
123            b'long-hash',
124            None,
125            _(b'show long changeset hash (EXPERIMENTAL)'),
126        ),
127        (
128            b'',
129            b'rebuild',
130            None,
131            _(b'rebuild cache even if it exists (EXPERIMENTAL)'),
132        ),
133    ]
134    + commands.diffwsopts
135    + commands.walkopts
136    + commands.formatteropts,
137    'synopsis': _(b'[-r REV] [-f] [-a] [-u] [-d] [-n] [-c] [-l] FILE...'),
138    'inferrepo': True,
139}
140
141
142def fastannotate(ui, repo, *pats, **opts):
143    """show changeset information by line for each file
144
145    List changes in files, showing the revision id responsible for each line.
146
147    This command is useful for discovering when a change was made and by whom.
148
149    By default this command prints revision numbers. If you include --file,
150    --user, or --date, the revision number is suppressed unless you also
151    include --number. The default format can also be customized by setting
152    fastannotate.defaultformat.
153
154    Returns 0 on success.
155
156    .. container:: verbose
157
158        This command uses an implementation different from the vanilla annotate
159        command, which may produce slightly different (while still reasonable)
160        outputs for some cases.
161
162        Unlike the vanilla anootate, fastannotate follows rename regardless of
163        the existence of --file.
164
165        For the best performance when running on a full repo, use -c, -l,
166        avoid -u, -d, -n. Use --linear and --no-content to make it even faster.
167
168        For the best performance when running on a shallow (remotefilelog)
169        repo, avoid --linear, --no-follow, or any diff options. As the server
170        won't be able to populate annotate cache when non-default options
171        affecting results are used.
172    """
173    if not pats:
174        raise error.Abort(_(b'at least one filename or pattern is required'))
175
176    # performance hack: filtered repo can be slow. unfilter by default.
177    if ui.configbool(b'fastannotate', b'unfilteredrepo'):
178        repo = repo.unfiltered()
179
180    opts = pycompat.byteskwargs(opts)
181
182    rev = opts.get(b'rev', b'.')
183    rebuild = opts.get(b'rebuild', False)
184
185    diffopts = patch.difffeatureopts(
186        ui, opts, section=b'annotate', whitespace=True
187    )
188    aopts = facontext.annotateopts(
189        diffopts=diffopts,
190        followmerge=not opts.get(b'linear', False),
191        followrename=not opts.get(b'no_follow', False),
192    )
193
194    if not any(
195        opts.get(s)
196        for s in [b'user', b'date', b'file', b'number', b'changeset']
197    ):
198        # default 'number' for compatibility. but fastannotate is more
199        # efficient with "changeset", "line-number" and "no-content".
200        for name in ui.configlist(
201            b'fastannotate', b'defaultformat', [b'number']
202        ):
203            opts[name] = True
204
205    ui.pager(b'fastannotate')
206    template = opts.get(b'template')
207    if template == b'json':
208        formatter = faformatter.jsonformatter(ui, repo, opts)
209    else:
210        formatter = faformatter.defaultformatter(ui, repo, opts)
211    showdeleted = opts.get(b'deleted', False)
212    showlines = not bool(opts.get(b'no_content'))
213    showpath = opts.get(b'file', False)
214
215    # find the head of the main (master) branch
216    master = ui.config(b'fastannotate', b'mainbranch') or rev
217
218    # paths will be used for prefetching and the real annotating
219    paths = list(_matchpaths(repo, rev, pats, opts, aopts))
220
221    # for client, prefetch from the server
222    if util.safehasattr(repo, 'prefetchfastannotate'):
223        repo.prefetchfastannotate(paths)
224
225    for path in paths:
226        result = lines = existinglines = None
227        while True:
228            try:
229                with facontext.annotatecontext(repo, path, aopts, rebuild) as a:
230                    result = a.annotate(
231                        rev,
232                        master=master,
233                        showpath=showpath,
234                        showlines=(showlines and not showdeleted),
235                    )
236                    if showdeleted:
237                        existinglines = {(l[0], l[1]) for l in result}
238                        result = a.annotatealllines(
239                            rev, showpath=showpath, showlines=showlines
240                        )
241                break
242            except (faerror.CannotReuseError, faerror.CorruptedFileError):
243                # happens if master moves backwards, or the file was deleted
244                # and readded, or renamed to an existing name, or corrupted.
245                if rebuild:  # give up since we have tried rebuild already
246                    raise
247                else:  # try a second time rebuilding the cache (slow)
248                    rebuild = True
249                    continue
250
251        if showlines:
252            result, lines = result
253
254        formatter.write(result, lines, existinglines=existinglines)
255    formatter.end()
256
257
258_newopts = set()
259_knownopts = {
260    opt[1].replace(b'-', b'_')
261    for opt in (fastannotatecommandargs['options'] + commands.globalopts)
262}
263
264
265def _annotatewrapper(orig, ui, repo, *pats, **opts):
266    """used by wrapdefault"""
267    # we need this hack until the obsstore has 0.0 seconds perf impact
268    if ui.configbool(b'fastannotate', b'unfilteredrepo'):
269        repo = repo.unfiltered()
270
271    # treat the file as text (skip the isbinary check)
272    if ui.configbool(b'fastannotate', b'forcetext'):
273        opts['text'] = True
274
275    # check if we need to do prefetch (client-side)
276    rev = opts.get('rev')
277    if util.safehasattr(repo, 'prefetchfastannotate') and rev is not None:
278        paths = list(_matchpaths(repo, rev, pats, pycompat.byteskwargs(opts)))
279        repo.prefetchfastannotate(paths)
280
281    return orig(ui, repo, *pats, **opts)
282
283
284def registercommand():
285    """register the fastannotate command"""
286    name = b'fastannotate|fastblame|fa'
287    command(name, helpbasic=True, **fastannotatecommandargs)(fastannotate)
288
289
290def wrapdefault():
291    """wrap the default annotate command, to be aware of the protocol"""
292    extensions.wrapcommand(commands.table, b'annotate', _annotatewrapper)
293
294
295@command(
296    b'debugbuildannotatecache',
297    [(b'r', b'rev', b'', _(b'build up to the specific revision'), _(b'REV'))]
298    + commands.walkopts,
299    _(b'[-r REV] FILE...'),
300)
301def debugbuildannotatecache(ui, repo, *pats, **opts):
302    """incrementally build fastannotate cache up to REV for specified files
303
304    If REV is not specified, use the config 'fastannotate.mainbranch'.
305
306    If fastannotate.client is True, download the annotate cache from the
307    server. Otherwise, build the annotate cache locally.
308
309    The annotate cache will be built using the default diff and follow
310    options and lives in '.hg/fastannotate/default'.
311    """
312    opts = pycompat.byteskwargs(opts)
313    rev = opts.get(b'REV') or ui.config(b'fastannotate', b'mainbranch')
314    if not rev:
315        raise error.Abort(
316            _(b'you need to provide a revision'),
317            hint=_(b'set fastannotate.mainbranch or use --rev'),
318        )
319    if ui.configbool(b'fastannotate', b'unfilteredrepo'):
320        repo = repo.unfiltered()
321    ctx = logcmdutil.revsingle(repo, rev)
322    m = scmutil.match(ctx, pats, opts)
323    paths = list(ctx.walk(m))
324    if util.safehasattr(repo, 'prefetchfastannotate'):
325        # client
326        if opts.get(b'REV'):
327            raise error.Abort(_(b'--rev cannot be used for client'))
328        repo.prefetchfastannotate(paths)
329    else:
330        # server, or full repo
331        progress = ui.makeprogress(_(b'building'), total=len(paths))
332        for i, path in enumerate(paths):
333            progress.update(i)
334            with facontext.annotatecontext(repo, path) as actx:
335                try:
336                    if actx.isuptodate(rev):
337                        continue
338                    actx.annotate(rev, rev)
339                except (faerror.CannotReuseError, faerror.CorruptedFileError):
340                    # the cache is broken (could happen with renaming so the
341                    # file history gets invalidated). rebuild and try again.
342                    ui.debug(
343                        b'fastannotate: %s: rebuilding broken cache\n' % path
344                    )
345                    actx.rebuild()
346                    try:
347                        actx.annotate(rev, rev)
348                    except Exception as ex:
349                        # possibly a bug, but should not stop us from building
350                        # cache for other files.
351                        ui.warn(
352                            _(
353                                b'fastannotate: %s: failed to '
354                                b'build cache: %r\n'
355                            )
356                            % (path, ex)
357                        )
358        progress.complete()
359