1#
2# Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
3# Copyright 2005-2007 Olivia Mackall <olivia@selenic.com>
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 copy
11import mimetypes
12import os
13import re
14
15from ..i18n import _
16from ..node import hex, short
17from ..pycompat import getattr
18
19from .common import (
20    ErrorResponse,
21    HTTP_FORBIDDEN,
22    HTTP_NOT_FOUND,
23    get_contact,
24    paritygen,
25    staticfile,
26)
27
28from .. import (
29    archival,
30    dagop,
31    encoding,
32    error,
33    graphmod,
34    pycompat,
35    revset,
36    revsetlang,
37    scmutil,
38    smartset,
39    templateutil,
40)
41
42from ..utils import stringutil
43
44from . import webutil
45
46__all__ = []
47commands = {}
48
49
50class webcommand(object):
51    """Decorator used to register a web command handler.
52
53    The decorator takes as its positional arguments the name/path the
54    command should be accessible under.
55
56    When called, functions receive as arguments a ``requestcontext``,
57    ``wsgirequest``, and a templater instance for generatoring output.
58    The functions should populate the ``rctx.res`` object with details
59    about the HTTP response.
60
61    The function returns a generator to be consumed by the WSGI application.
62    For most commands, this should be the result from
63    ``web.res.sendresponse()``. Many commands will call ``web.sendtemplate()``
64    to render a template.
65
66    Usage:
67
68    @webcommand('mycommand')
69    def mycommand(web):
70        pass
71    """
72
73    def __init__(self, name):
74        self.name = name
75
76    def __call__(self, func):
77        __all__.append(self.name)
78        commands[self.name] = func
79        return func
80
81
82@webcommand(b'log')
83def log(web):
84    """
85    /log[/{revision}[/{path}]]
86    --------------------------
87
88    Show repository or file history.
89
90    For URLs of the form ``/log/{revision}``, a list of changesets starting at
91    the specified changeset identifier is shown. If ``{revision}`` is not
92    defined, the default is ``tip``. This form is equivalent to the
93    ``changelog`` handler.
94
95    For URLs of the form ``/log/{revision}/{file}``, the history for a specific
96    file will be shown. This form is equivalent to the ``filelog`` handler.
97    """
98
99    if web.req.qsparams.get(b'file'):
100        return filelog(web)
101    else:
102        return changelog(web)
103
104
105@webcommand(b'rawfile')
106def rawfile(web):
107    guessmime = web.configbool(b'web', b'guessmime')
108
109    path = webutil.cleanpath(web.repo, web.req.qsparams.get(b'file', b''))
110    if not path:
111        return manifest(web)
112
113    try:
114        fctx = webutil.filectx(web.repo, web.req)
115    except error.LookupError as inst:
116        try:
117            return manifest(web)
118        except ErrorResponse:
119            raise inst
120
121    path = fctx.path()
122    text = fctx.data()
123    mt = b'application/binary'
124    if guessmime:
125        mt = mimetypes.guess_type(pycompat.fsdecode(path))[0]
126        if mt is None:
127            if stringutil.binary(text):
128                mt = b'application/binary'
129            else:
130                mt = b'text/plain'
131        else:
132            mt = pycompat.sysbytes(mt)
133
134    if mt.startswith(b'text/'):
135        mt += b'; charset="%s"' % encoding.encoding
136
137    web.res.headers[b'Content-Type'] = mt
138    filename = (
139        path.rpartition(b'/')[-1].replace(b'\\', b'\\\\').replace(b'"', b'\\"')
140    )
141    web.res.headers[b'Content-Disposition'] = (
142        b'inline; filename="%s"' % filename
143    )
144    web.res.setbodybytes(text)
145    return web.res.sendresponse()
146
147
148def _filerevision(web, fctx):
149    f = fctx.path()
150    text = fctx.data()
151    parity = paritygen(web.stripecount)
152    ishead = fctx.filenode() in fctx.filelog().heads()
153
154    if stringutil.binary(text):
155        mt = pycompat.sysbytes(
156            mimetypes.guess_type(pycompat.fsdecode(f))[0]
157            or r'application/octet-stream'
158        )
159        text = b'(binary:%s)' % mt
160
161    def lines(context):
162        for lineno, t in enumerate(text.splitlines(True)):
163            yield {
164                b"line": t,
165                b"lineid": b"l%d" % (lineno + 1),
166                b"linenumber": b"% 6d" % (lineno + 1),
167                b"parity": next(parity),
168            }
169
170    return web.sendtemplate(
171        b'filerevision',
172        file=f,
173        path=webutil.up(f),
174        text=templateutil.mappinggenerator(lines),
175        symrev=webutil.symrevorshortnode(web.req, fctx),
176        rename=webutil.renamelink(fctx),
177        permissions=fctx.manifest().flags(f),
178        ishead=int(ishead),
179        **pycompat.strkwargs(webutil.commonentry(web.repo, fctx))
180    )
181
182
183@webcommand(b'file')
184def file(web):
185    """
186    /file/{revision}[/{path}]
187    -------------------------
188
189    Show information about a directory or file in the repository.
190
191    Info about the ``path`` given as a URL parameter will be rendered.
192
193    If ``path`` is a directory, information about the entries in that
194    directory will be rendered. This form is equivalent to the ``manifest``
195    handler.
196
197    If ``path`` is a file, information about that file will be shown via
198    the ``filerevision`` template.
199
200    If ``path`` is not defined, information about the root directory will
201    be rendered.
202    """
203    if web.req.qsparams.get(b'style') == b'raw':
204        return rawfile(web)
205
206    path = webutil.cleanpath(web.repo, web.req.qsparams.get(b'file', b''))
207    if not path:
208        return manifest(web)
209    try:
210        return _filerevision(web, webutil.filectx(web.repo, web.req))
211    except error.LookupError as inst:
212        try:
213            return manifest(web)
214        except ErrorResponse:
215            raise inst
216
217
218def _search(web):
219    MODE_REVISION = b'rev'
220    MODE_KEYWORD = b'keyword'
221    MODE_REVSET = b'revset'
222
223    def revsearch(ctx):
224        yield ctx
225
226    def keywordsearch(query):
227        lower = encoding.lower
228        qw = lower(query).split()
229
230        def revgen():
231            cl = web.repo.changelog
232            for i in pycompat.xrange(len(web.repo) - 1, 0, -100):
233                l = []
234                for j in cl.revs(max(0, i - 99), i):
235                    ctx = web.repo[j]
236                    l.append(ctx)
237                l.reverse()
238                for e in l:
239                    yield e
240
241        for ctx in revgen():
242            miss = 0
243            for q in qw:
244                if not (
245                    q in lower(ctx.user())
246                    or q in lower(ctx.description())
247                    or q in lower(b" ".join(ctx.files()))
248                ):
249                    miss = 1
250                    break
251            if miss:
252                continue
253
254            yield ctx
255
256    def revsetsearch(revs):
257        for r in revs:
258            yield web.repo[r]
259
260    searchfuncs = {
261        MODE_REVISION: (revsearch, b'exact revision search'),
262        MODE_KEYWORD: (keywordsearch, b'literal keyword search'),
263        MODE_REVSET: (revsetsearch, b'revset expression search'),
264    }
265
266    def getsearchmode(query):
267        try:
268            ctx = scmutil.revsymbol(web.repo, query)
269        except (error.RepoError, error.LookupError):
270            # query is not an exact revision pointer, need to
271            # decide if it's a revset expression or keywords
272            pass
273        else:
274            return MODE_REVISION, ctx
275
276        revdef = b'reverse(%s)' % query
277        try:
278            tree = revsetlang.parse(revdef)
279        except error.ParseError:
280            # can't parse to a revset tree
281            return MODE_KEYWORD, query
282
283        if revsetlang.depth(tree) <= 2:
284            # no revset syntax used
285            return MODE_KEYWORD, query
286
287        if any(
288            (token, (value or b'')[:3]) == (b'string', b're:')
289            for token, value, pos in revsetlang.tokenize(revdef)
290        ):
291            return MODE_KEYWORD, query
292
293        funcsused = revsetlang.funcsused(tree)
294        if not funcsused.issubset(revset.safesymbols):
295            return MODE_KEYWORD, query
296
297        try:
298            mfunc = revset.match(
299                web.repo.ui, revdef, lookup=revset.lookupfn(web.repo)
300            )
301            revs = mfunc(web.repo)
302            return MODE_REVSET, revs
303            # ParseError: wrongly placed tokens, wrongs arguments, etc
304            # RepoLookupError: no such revision, e.g. in 'revision:'
305            # Abort: bookmark/tag not exists
306            # LookupError: ambiguous identifier, e.g. in '(bc)' on a large repo
307        except (
308            error.ParseError,
309            error.RepoLookupError,
310            error.Abort,
311            LookupError,
312        ):
313            return MODE_KEYWORD, query
314
315    def changelist(context):
316        count = 0
317
318        for ctx in searchfunc[0](funcarg):
319            count += 1
320            n = scmutil.binnode(ctx)
321            showtags = webutil.showtag(web.repo, b'changelogtag', n)
322            files = webutil.listfilediffs(ctx.files(), n, web.maxfiles)
323
324            lm = webutil.commonentry(web.repo, ctx)
325            lm.update(
326                {
327                    b'parity': next(parity),
328                    b'changelogtag': showtags,
329                    b'files': files,
330                }
331            )
332            yield lm
333
334            if count >= revcount:
335                break
336
337    query = web.req.qsparams[b'rev']
338    revcount = web.maxchanges
339    if b'revcount' in web.req.qsparams:
340        try:
341            revcount = int(web.req.qsparams.get(b'revcount', revcount))
342            revcount = max(revcount, 1)
343            web.tmpl.defaults[b'sessionvars'][b'revcount'] = revcount
344        except ValueError:
345            pass
346
347    lessvars = copy.copy(web.tmpl.defaults[b'sessionvars'])
348    lessvars[b'revcount'] = max(revcount // 2, 1)
349    lessvars[b'rev'] = query
350    morevars = copy.copy(web.tmpl.defaults[b'sessionvars'])
351    morevars[b'revcount'] = revcount * 2
352    morevars[b'rev'] = query
353
354    mode, funcarg = getsearchmode(query)
355
356    if b'forcekw' in web.req.qsparams:
357        showforcekw = b''
358        showunforcekw = searchfuncs[mode][1]
359        mode = MODE_KEYWORD
360        funcarg = query
361    else:
362        if mode != MODE_KEYWORD:
363            showforcekw = searchfuncs[MODE_KEYWORD][1]
364        else:
365            showforcekw = b''
366        showunforcekw = b''
367
368    searchfunc = searchfuncs[mode]
369
370    tip = web.repo[b'tip']
371    parity = paritygen(web.stripecount)
372
373    return web.sendtemplate(
374        b'search',
375        query=query,
376        node=tip.hex(),
377        symrev=b'tip',
378        entries=templateutil.mappinggenerator(changelist, name=b'searchentry'),
379        archives=web.archivelist(b'tip'),
380        morevars=morevars,
381        lessvars=lessvars,
382        modedesc=searchfunc[1],
383        showforcekw=showforcekw,
384        showunforcekw=showunforcekw,
385    )
386
387
388@webcommand(b'changelog')
389def changelog(web, shortlog=False):
390    """
391    /changelog[/{revision}]
392    -----------------------
393
394    Show information about multiple changesets.
395
396    If the optional ``revision`` URL argument is absent, information about
397    all changesets starting at ``tip`` will be rendered. If the ``revision``
398    argument is present, changesets will be shown starting from the specified
399    revision.
400
401    If ``revision`` is absent, the ``rev`` query string argument may be
402    defined. This will perform a search for changesets.
403
404    The argument for ``rev`` can be a single revision, a revision set,
405    or a literal keyword to search for in changeset data (equivalent to
406    :hg:`log -k`).
407
408    The ``revcount`` query string argument defines the maximum numbers of
409    changesets to render.
410
411    For non-searches, the ``changelog`` template will be rendered.
412    """
413
414    query = b''
415    if b'node' in web.req.qsparams:
416        ctx = webutil.changectx(web.repo, web.req)
417        symrev = webutil.symrevorshortnode(web.req, ctx)
418    elif b'rev' in web.req.qsparams:
419        return _search(web)
420    else:
421        ctx = web.repo[b'tip']
422        symrev = b'tip'
423
424    def changelist(maxcount):
425        revs = []
426        if pos != -1:
427            revs = web.repo.changelog.revs(pos, 0)
428
429        for entry in webutil.changelistentries(web, revs, maxcount, parity):
430            yield entry
431
432    if shortlog:
433        revcount = web.maxshortchanges
434    else:
435        revcount = web.maxchanges
436
437    if b'revcount' in web.req.qsparams:
438        try:
439            revcount = int(web.req.qsparams.get(b'revcount', revcount))
440            revcount = max(revcount, 1)
441            web.tmpl.defaults[b'sessionvars'][b'revcount'] = revcount
442        except ValueError:
443            pass
444
445    lessvars = copy.copy(web.tmpl.defaults[b'sessionvars'])
446    lessvars[b'revcount'] = max(revcount // 2, 1)
447    morevars = copy.copy(web.tmpl.defaults[b'sessionvars'])
448    morevars[b'revcount'] = revcount * 2
449
450    count = len(web.repo)
451    pos = ctx.rev()
452    parity = paritygen(web.stripecount)
453
454    changenav = webutil.revnav(web.repo).gen(pos, revcount, count)
455
456    entries = list(changelist(revcount + 1))
457    latestentry = entries[:1]
458    if len(entries) > revcount:
459        nextentry = entries[-1:]
460        entries = entries[:-1]
461    else:
462        nextentry = []
463
464    return web.sendtemplate(
465        b'shortlog' if shortlog else b'changelog',
466        changenav=changenav,
467        node=ctx.hex(),
468        rev=pos,
469        symrev=symrev,
470        changesets=count,
471        entries=templateutil.mappinglist(entries),
472        latestentry=templateutil.mappinglist(latestentry),
473        nextentry=templateutil.mappinglist(nextentry),
474        archives=web.archivelist(b'tip'),
475        revcount=revcount,
476        morevars=morevars,
477        lessvars=lessvars,
478        query=query,
479    )
480
481
482@webcommand(b'shortlog')
483def shortlog(web):
484    """
485    /shortlog
486    ---------
487
488    Show basic information about a set of changesets.
489
490    This accepts the same parameters as the ``changelog`` handler. The only
491    difference is the ``shortlog`` template will be rendered instead of the
492    ``changelog`` template.
493    """
494    return changelog(web, shortlog=True)
495
496
497@webcommand(b'changeset')
498def changeset(web):
499    """
500    /changeset[/{revision}]
501    -----------------------
502
503    Show information about a single changeset.
504
505    A URL path argument is the changeset identifier to show. See ``hg help
506    revisions`` for possible values. If not defined, the ``tip`` changeset
507    will be shown.
508
509    The ``changeset`` template is rendered. Contents of the ``changesettag``,
510    ``changesetbookmark``, ``filenodelink``, ``filenolink``, and the many
511    templates related to diffs may all be used to produce the output.
512    """
513    ctx = webutil.changectx(web.repo, web.req)
514
515    return web.sendtemplate(b'changeset', **webutil.changesetentry(web, ctx))
516
517
518rev = webcommand(b'rev')(changeset)
519
520
521def decodepath(path):
522    """Hook for mapping a path in the repository to a path in the
523    working copy.
524
525    Extensions (e.g., largefiles) can override this to remap files in
526    the virtual file system presented by the manifest command below."""
527    return path
528
529
530@webcommand(b'manifest')
531def manifest(web):
532    """
533    /manifest[/{revision}[/{path}]]
534    -------------------------------
535
536    Show information about a directory.
537
538    If the URL path arguments are omitted, information about the root
539    directory for the ``tip`` changeset will be shown.
540
541    Because this handler can only show information for directories, it
542    is recommended to use the ``file`` handler instead, as it can handle both
543    directories and files.
544
545    The ``manifest`` template will be rendered for this handler.
546    """
547    if b'node' in web.req.qsparams:
548        ctx = webutil.changectx(web.repo, web.req)
549        symrev = webutil.symrevorshortnode(web.req, ctx)
550    else:
551        ctx = web.repo[b'tip']
552        symrev = b'tip'
553    path = webutil.cleanpath(web.repo, web.req.qsparams.get(b'file', b''))
554    mf = ctx.manifest()
555    node = scmutil.binnode(ctx)
556
557    files = {}
558    dirs = {}
559    parity = paritygen(web.stripecount)
560
561    if path and path[-1:] != b"/":
562        path += b"/"
563    l = len(path)
564    abspath = b"/" + path
565
566    for full, n in pycompat.iteritems(mf):
567        # the virtual path (working copy path) used for the full
568        # (repository) path
569        f = decodepath(full)
570
571        if f[:l] != path:
572            continue
573        remain = f[l:]
574        elements = remain.split(b'/')
575        if len(elements) == 1:
576            files[remain] = full
577        else:
578            h = dirs  # need to retain ref to dirs (root)
579            for elem in elements[0:-1]:
580                if elem not in h:
581                    h[elem] = {}
582                h = h[elem]
583                if len(h) > 1:
584                    break
585            h[None] = None  # denotes files present
586
587    if mf and not files and not dirs:
588        raise ErrorResponse(HTTP_NOT_FOUND, b'path not found: ' + path)
589
590    def filelist(context):
591        for f in sorted(files):
592            full = files[f]
593
594            fctx = ctx.filectx(full)
595            yield {
596                b"file": full,
597                b"parity": next(parity),
598                b"basename": f,
599                b"date": fctx.date(),
600                b"size": fctx.size(),
601                b"permissions": mf.flags(full),
602            }
603
604    def dirlist(context):
605        for d in sorted(dirs):
606
607            emptydirs = []
608            h = dirs[d]
609            while isinstance(h, dict) and len(h) == 1:
610                k, v = next(iter(h.items()))
611                if v:
612                    emptydirs.append(k)
613                h = v
614
615            path = b"%s%s" % (abspath, d)
616            yield {
617                b"parity": next(parity),
618                b"path": path,
619                b"emptydirs": b"/".join(emptydirs),
620                b"basename": d,
621            }
622
623    return web.sendtemplate(
624        b'manifest',
625        symrev=symrev,
626        path=abspath,
627        up=webutil.up(abspath),
628        upparity=next(parity),
629        fentries=templateutil.mappinggenerator(filelist),
630        dentries=templateutil.mappinggenerator(dirlist),
631        archives=web.archivelist(hex(node)),
632        **pycompat.strkwargs(webutil.commonentry(web.repo, ctx))
633    )
634
635
636@webcommand(b'tags')
637def tags(web):
638    """
639    /tags
640    -----
641
642    Show information about tags.
643
644    No arguments are accepted.
645
646    The ``tags`` template is rendered.
647    """
648    i = list(reversed(web.repo.tagslist()))
649    parity = paritygen(web.stripecount)
650
651    def entries(context, notip, latestonly):
652        t = i
653        if notip:
654            t = [(k, n) for k, n in i if k != b"tip"]
655        if latestonly:
656            t = t[:1]
657        for k, n in t:
658            yield {
659                b"parity": next(parity),
660                b"tag": k,
661                b"date": web.repo[n].date(),
662                b"node": hex(n),
663            }
664
665    return web.sendtemplate(
666        b'tags',
667        node=hex(web.repo.changelog.tip()),
668        entries=templateutil.mappinggenerator(entries, args=(False, False)),
669        entriesnotip=templateutil.mappinggenerator(entries, args=(True, False)),
670        latestentry=templateutil.mappinggenerator(entries, args=(True, True)),
671    )
672
673
674@webcommand(b'bookmarks')
675def bookmarks(web):
676    """
677    /bookmarks
678    ----------
679
680    Show information about bookmarks.
681
682    No arguments are accepted.
683
684    The ``bookmarks`` template is rendered.
685    """
686    i = [b for b in web.repo._bookmarks.items() if b[1] in web.repo]
687    sortkey = lambda b: (web.repo[b[1]].rev(), b[0])
688    i = sorted(i, key=sortkey, reverse=True)
689    parity = paritygen(web.stripecount)
690
691    def entries(context, latestonly):
692        t = i
693        if latestonly:
694            t = i[:1]
695        for k, n in t:
696            yield {
697                b"parity": next(parity),
698                b"bookmark": k,
699                b"date": web.repo[n].date(),
700                b"node": hex(n),
701            }
702
703    if i:
704        latestrev = i[0][1]
705    else:
706        latestrev = -1
707    lastdate = web.repo[latestrev].date()
708
709    return web.sendtemplate(
710        b'bookmarks',
711        node=hex(web.repo.changelog.tip()),
712        lastchange=templateutil.mappinglist([{b'date': lastdate}]),
713        entries=templateutil.mappinggenerator(entries, args=(False,)),
714        latestentry=templateutil.mappinggenerator(entries, args=(True,)),
715    )
716
717
718@webcommand(b'branches')
719def branches(web):
720    """
721    /branches
722    ---------
723
724    Show information about branches.
725
726    All known branches are contained in the output, even closed branches.
727
728    No arguments are accepted.
729
730    The ``branches`` template is rendered.
731    """
732    entries = webutil.branchentries(web.repo, web.stripecount)
733    latestentry = webutil.branchentries(web.repo, web.stripecount, 1)
734
735    return web.sendtemplate(
736        b'branches',
737        node=hex(web.repo.changelog.tip()),
738        entries=entries,
739        latestentry=latestentry,
740    )
741
742
743@webcommand(b'summary')
744def summary(web):
745    """
746    /summary
747    --------
748
749    Show a summary of repository state.
750
751    Information about the latest changesets, bookmarks, tags, and branches
752    is captured by this handler.
753
754    The ``summary`` template is rendered.
755    """
756    i = reversed(web.repo.tagslist())
757
758    def tagentries(context):
759        parity = paritygen(web.stripecount)
760        count = 0
761        for k, n in i:
762            if k == b"tip":  # skip tip
763                continue
764
765            count += 1
766            if count > 10:  # limit to 10 tags
767                break
768
769            yield {
770                b'parity': next(parity),
771                b'tag': k,
772                b'node': hex(n),
773                b'date': web.repo[n].date(),
774            }
775
776    def bookmarks(context):
777        parity = paritygen(web.stripecount)
778        marks = [b for b in web.repo._bookmarks.items() if b[1] in web.repo]
779        sortkey = lambda b: (web.repo[b[1]].rev(), b[0])
780        marks = sorted(marks, key=sortkey, reverse=True)
781        for k, n in marks[:10]:  # limit to 10 bookmarks
782            yield {
783                b'parity': next(parity),
784                b'bookmark': k,
785                b'date': web.repo[n].date(),
786                b'node': hex(n),
787            }
788
789    def changelist(context):
790        parity = paritygen(web.stripecount, offset=start - end)
791        l = []  # build a list in forward order for efficiency
792        revs = []
793        if start < end:
794            revs = web.repo.changelog.revs(start, end - 1)
795        for i in revs:
796            ctx = web.repo[i]
797            lm = webutil.commonentry(web.repo, ctx)
798            lm[b'parity'] = next(parity)
799            l.append(lm)
800
801        for entry in reversed(l):
802            yield entry
803
804    tip = web.repo[b'tip']
805    count = len(web.repo)
806    start = max(0, count - web.maxchanges)
807    end = min(count, start + web.maxchanges)
808
809    desc = web.config(b"web", b"description")
810    if not desc:
811        desc = b'unknown'
812    labels = web.configlist(b'web', b'labels')
813
814    return web.sendtemplate(
815        b'summary',
816        desc=desc,
817        owner=get_contact(web.config) or b'unknown',
818        lastchange=tip.date(),
819        tags=templateutil.mappinggenerator(tagentries, name=b'tagentry'),
820        bookmarks=templateutil.mappinggenerator(bookmarks),
821        branches=webutil.branchentries(web.repo, web.stripecount, 10),
822        shortlog=templateutil.mappinggenerator(
823            changelist, name=b'shortlogentry'
824        ),
825        node=tip.hex(),
826        symrev=b'tip',
827        archives=web.archivelist(b'tip'),
828        labels=templateutil.hybridlist(labels, name=b'label'),
829    )
830
831
832@webcommand(b'filediff')
833def filediff(web):
834    """
835    /diff/{revision}/{path}
836    -----------------------
837
838    Show how a file changed in a particular commit.
839
840    The ``filediff`` template is rendered.
841
842    This handler is registered under both the ``/diff`` and ``/filediff``
843    paths. ``/diff`` is used in modern code.
844    """
845    fctx, ctx = None, None
846    try:
847        fctx = webutil.filectx(web.repo, web.req)
848    except LookupError:
849        ctx = webutil.changectx(web.repo, web.req)
850        path = webutil.cleanpath(web.repo, web.req.qsparams[b'file'])
851        if path not in ctx.files():
852            raise
853
854    if fctx is not None:
855        path = fctx.path()
856        ctx = fctx.changectx()
857    basectx = ctx.p1()
858
859    style = web.config(b'web', b'style')
860    if b'style' in web.req.qsparams:
861        style = web.req.qsparams[b'style']
862
863    diffs = webutil.diffs(web, ctx, basectx, [path], style)
864    if fctx is not None:
865        rename = webutil.renamelink(fctx)
866        ctx = fctx
867    else:
868        rename = templateutil.mappinglist([])
869        ctx = ctx
870
871    return web.sendtemplate(
872        b'filediff',
873        file=path,
874        symrev=webutil.symrevorshortnode(web.req, ctx),
875        rename=rename,
876        diff=diffs,
877        **pycompat.strkwargs(webutil.commonentry(web.repo, ctx))
878    )
879
880
881diff = webcommand(b'diff')(filediff)
882
883
884@webcommand(b'comparison')
885def comparison(web):
886    """
887    /comparison/{revision}/{path}
888    -----------------------------
889
890    Show a comparison between the old and new versions of a file from changes
891    made on a particular revision.
892
893    This is similar to the ``diff`` handler. However, this form features
894    a split or side-by-side diff rather than a unified diff.
895
896    The ``context`` query string argument can be used to control the lines of
897    context in the diff.
898
899    The ``filecomparison`` template is rendered.
900    """
901    ctx = webutil.changectx(web.repo, web.req)
902    if b'file' not in web.req.qsparams:
903        raise ErrorResponse(HTTP_NOT_FOUND, b'file not given')
904    path = webutil.cleanpath(web.repo, web.req.qsparams[b'file'])
905
906    parsecontext = lambda v: v == b'full' and -1 or int(v)
907    if b'context' in web.req.qsparams:
908        context = parsecontext(web.req.qsparams[b'context'])
909    else:
910        context = parsecontext(web.config(b'web', b'comparisoncontext'))
911
912    def filelines(f):
913        if f.isbinary():
914            mt = pycompat.sysbytes(
915                mimetypes.guess_type(pycompat.fsdecode(f.path()))[0]
916                or r'application/octet-stream'
917            )
918            return [_(b'(binary file %s, hash: %s)') % (mt, hex(f.filenode()))]
919        return f.data().splitlines()
920
921    fctx = None
922    parent = ctx.p1()
923    leftrev = parent.rev()
924    leftnode = parent.node()
925    rightrev = ctx.rev()
926    rightnode = scmutil.binnode(ctx)
927    if path in ctx:
928        fctx = ctx[path]
929        rightlines = filelines(fctx)
930        if path not in parent:
931            leftlines = ()
932        else:
933            pfctx = parent[path]
934            leftlines = filelines(pfctx)
935    else:
936        rightlines = ()
937        pfctx = ctx.p1()[path]
938        leftlines = filelines(pfctx)
939
940    comparison = webutil.compare(context, leftlines, rightlines)
941    if fctx is not None:
942        rename = webutil.renamelink(fctx)
943        ctx = fctx
944    else:
945        rename = templateutil.mappinglist([])
946        ctx = ctx
947
948    return web.sendtemplate(
949        b'filecomparison',
950        file=path,
951        symrev=webutil.symrevorshortnode(web.req, ctx),
952        rename=rename,
953        leftrev=leftrev,
954        leftnode=hex(leftnode),
955        rightrev=rightrev,
956        rightnode=hex(rightnode),
957        comparison=comparison,
958        **pycompat.strkwargs(webutil.commonentry(web.repo, ctx))
959    )
960
961
962@webcommand(b'annotate')
963def annotate(web):
964    """
965    /annotate/{revision}/{path}
966    ---------------------------
967
968    Show changeset information for each line in a file.
969
970    The ``ignorews``, ``ignorewsamount``, ``ignorewseol``, and
971    ``ignoreblanklines`` query string arguments have the same meaning as
972    their ``[annotate]`` config equivalents. It uses the hgrc boolean
973    parsing logic to interpret the value. e.g. ``0`` and ``false`` are
974    false and ``1`` and ``true`` are true. If not defined, the server
975    default settings are used.
976
977    The ``fileannotate`` template is rendered.
978    """
979    fctx = webutil.filectx(web.repo, web.req)
980    f = fctx.path()
981    parity = paritygen(web.stripecount)
982    ishead = fctx.filenode() in fctx.filelog().heads()
983
984    # parents() is called once per line and several lines likely belong to
985    # same revision. So it is worth caching.
986    # TODO there are still redundant operations within basefilectx.parents()
987    # and from the fctx.annotate() call itself that could be cached.
988    parentscache = {}
989
990    def parents(context, f):
991        rev = f.rev()
992        if rev not in parentscache:
993            parentscache[rev] = []
994            for p in f.parents():
995                entry = {
996                    b'node': p.hex(),
997                    b'rev': p.rev(),
998                }
999                parentscache[rev].append(entry)
1000
1001        for p in parentscache[rev]:
1002            yield p
1003
1004    def annotate(context):
1005        if fctx.isbinary():
1006            mt = pycompat.sysbytes(
1007                mimetypes.guess_type(pycompat.fsdecode(fctx.path()))[0]
1008                or r'application/octet-stream'
1009            )
1010            lines = [
1011                dagop.annotateline(
1012                    fctx=fctx.filectx(fctx.filerev()),
1013                    lineno=1,
1014                    text=b'(binary:%s)' % mt,
1015                )
1016            ]
1017        else:
1018            lines = webutil.annotate(web.req, fctx, web.repo.ui)
1019
1020        previousrev = None
1021        blockparitygen = paritygen(1)
1022        for lineno, aline in enumerate(lines):
1023            f = aline.fctx
1024            rev = f.rev()
1025            if rev != previousrev:
1026                blockhead = True
1027                blockparity = next(blockparitygen)
1028            else:
1029                blockhead = None
1030            previousrev = rev
1031            yield {
1032                b"parity": next(parity),
1033                b"node": f.hex(),
1034                b"rev": rev,
1035                b"author": f.user(),
1036                b"parents": templateutil.mappinggenerator(parents, args=(f,)),
1037                b"desc": f.description(),
1038                b"extra": f.extra(),
1039                b"file": f.path(),
1040                b"blockhead": blockhead,
1041                b"blockparity": blockparity,
1042                b"targetline": aline.lineno,
1043                b"line": aline.text,
1044                b"lineno": lineno + 1,
1045                b"lineid": b"l%d" % (lineno + 1),
1046                b"linenumber": b"% 6d" % (lineno + 1),
1047                b"revdate": f.date(),
1048            }
1049
1050    diffopts = webutil.difffeatureopts(web.req, web.repo.ui, b'annotate')
1051    diffopts = {k: getattr(diffopts, k) for k in diffopts.defaults}
1052
1053    return web.sendtemplate(
1054        b'fileannotate',
1055        file=f,
1056        annotate=templateutil.mappinggenerator(annotate),
1057        path=webutil.up(f),
1058        symrev=webutil.symrevorshortnode(web.req, fctx),
1059        rename=webutil.renamelink(fctx),
1060        permissions=fctx.manifest().flags(f),
1061        ishead=int(ishead),
1062        diffopts=templateutil.hybriddict(diffopts),
1063        **pycompat.strkwargs(webutil.commonentry(web.repo, fctx))
1064    )
1065
1066
1067@webcommand(b'filelog')
1068def filelog(web):
1069    """
1070    /filelog/{revision}/{path}
1071    --------------------------
1072
1073    Show information about the history of a file in the repository.
1074
1075    The ``revcount`` query string argument can be defined to control the
1076    maximum number of entries to show.
1077
1078    The ``filelog`` template will be rendered.
1079    """
1080
1081    try:
1082        fctx = webutil.filectx(web.repo, web.req)
1083        f = fctx.path()
1084        fl = fctx.filelog()
1085    except error.LookupError:
1086        f = webutil.cleanpath(web.repo, web.req.qsparams[b'file'])
1087        fl = web.repo.file(f)
1088        numrevs = len(fl)
1089        if not numrevs:  # file doesn't exist at all
1090            raise
1091        rev = webutil.changectx(web.repo, web.req).rev()
1092        first = fl.linkrev(0)
1093        if rev < first:  # current rev is from before file existed
1094            raise
1095        frev = numrevs - 1
1096        while fl.linkrev(frev) > rev:
1097            frev -= 1
1098        fctx = web.repo.filectx(f, fl.linkrev(frev))
1099
1100    revcount = web.maxshortchanges
1101    if b'revcount' in web.req.qsparams:
1102        try:
1103            revcount = int(web.req.qsparams.get(b'revcount', revcount))
1104            revcount = max(revcount, 1)
1105            web.tmpl.defaults[b'sessionvars'][b'revcount'] = revcount
1106        except ValueError:
1107            pass
1108
1109    lrange = webutil.linerange(web.req)
1110
1111    lessvars = copy.copy(web.tmpl.defaults[b'sessionvars'])
1112    lessvars[b'revcount'] = max(revcount // 2, 1)
1113    morevars = copy.copy(web.tmpl.defaults[b'sessionvars'])
1114    morevars[b'revcount'] = revcount * 2
1115
1116    patch = b'patch' in web.req.qsparams
1117    if patch:
1118        lessvars[b'patch'] = morevars[b'patch'] = web.req.qsparams[b'patch']
1119    descend = b'descend' in web.req.qsparams
1120    if descend:
1121        lessvars[b'descend'] = morevars[b'descend'] = web.req.qsparams[
1122            b'descend'
1123        ]
1124
1125    count = fctx.filerev() + 1
1126    start = max(0, count - revcount)  # first rev on this page
1127    end = min(count, start + revcount)  # last rev on this page
1128    parity = paritygen(web.stripecount, offset=start - end)
1129
1130    repo = web.repo
1131    filelog = fctx.filelog()
1132    revs = [
1133        filerev
1134        for filerev in filelog.revs(start, end - 1)
1135        if filelog.linkrev(filerev) in repo
1136    ]
1137    entries = []
1138
1139    diffstyle = web.config(b'web', b'style')
1140    if b'style' in web.req.qsparams:
1141        diffstyle = web.req.qsparams[b'style']
1142
1143    def diff(fctx, linerange=None):
1144        ctx = fctx.changectx()
1145        basectx = ctx.p1()
1146        path = fctx.path()
1147        return webutil.diffs(
1148            web,
1149            ctx,
1150            basectx,
1151            [path],
1152            diffstyle,
1153            linerange=linerange,
1154            lineidprefix=b'%s-' % ctx.hex()[:12],
1155        )
1156
1157    linerange = None
1158    if lrange is not None:
1159        assert lrange is not None  # help pytype (!?)
1160        linerange = webutil.formatlinerange(*lrange)
1161        # deactivate numeric nav links when linerange is specified as this
1162        # would required a dedicated "revnav" class
1163        nav = templateutil.mappinglist([])
1164        if descend:
1165            it = dagop.blockdescendants(fctx, *lrange)
1166        else:
1167            it = dagop.blockancestors(fctx, *lrange)
1168        for i, (c, lr) in enumerate(it, 1):
1169            diffs = None
1170            if patch:
1171                diffs = diff(c, linerange=lr)
1172            # follow renames accross filtered (not in range) revisions
1173            path = c.path()
1174            lm = webutil.commonentry(repo, c)
1175            lm.update(
1176                {
1177                    b'parity': next(parity),
1178                    b'filerev': c.rev(),
1179                    b'file': path,
1180                    b'diff': diffs,
1181                    b'linerange': webutil.formatlinerange(*lr),
1182                    b'rename': templateutil.mappinglist([]),
1183                }
1184            )
1185            entries.append(lm)
1186            if i == revcount:
1187                break
1188        lessvars[b'linerange'] = webutil.formatlinerange(*lrange)
1189        morevars[b'linerange'] = lessvars[b'linerange']
1190    else:
1191        for i in revs:
1192            iterfctx = fctx.filectx(i)
1193            diffs = None
1194            if patch:
1195                diffs = diff(iterfctx)
1196            lm = webutil.commonentry(repo, iterfctx)
1197            lm.update(
1198                {
1199                    b'parity': next(parity),
1200                    b'filerev': i,
1201                    b'file': f,
1202                    b'diff': diffs,
1203                    b'rename': webutil.renamelink(iterfctx),
1204                }
1205            )
1206            entries.append(lm)
1207        entries.reverse()
1208        revnav = webutil.filerevnav(web.repo, fctx.path())
1209        nav = revnav.gen(end - 1, revcount, count)
1210
1211    latestentry = entries[:1]
1212
1213    return web.sendtemplate(
1214        b'filelog',
1215        file=f,
1216        nav=nav,
1217        symrev=webutil.symrevorshortnode(web.req, fctx),
1218        entries=templateutil.mappinglist(entries),
1219        descend=descend,
1220        patch=patch,
1221        latestentry=templateutil.mappinglist(latestentry),
1222        linerange=linerange,
1223        revcount=revcount,
1224        morevars=morevars,
1225        lessvars=lessvars,
1226        **pycompat.strkwargs(webutil.commonentry(web.repo, fctx))
1227    )
1228
1229
1230@webcommand(b'archive')
1231def archive(web):
1232    """
1233    /archive/{revision}.{format}[/{path}]
1234    -------------------------------------
1235
1236    Obtain an archive of repository content.
1237
1238    The content and type of the archive is defined by a URL path parameter.
1239    ``format`` is the file extension of the archive type to be generated. e.g.
1240    ``zip`` or ``tar.bz2``. Not all archive types may be allowed by your
1241    server configuration.
1242
1243    The optional ``path`` URL parameter controls content to include in the
1244    archive. If omitted, every file in the specified revision is present in the
1245    archive. If included, only the specified file or contents of the specified
1246    directory will be included in the archive.
1247
1248    No template is used for this handler. Raw, binary content is generated.
1249    """
1250
1251    type_ = web.req.qsparams.get(b'type')
1252    allowed = web.configlist(b"web", b"allow-archive")
1253    key = web.req.qsparams[b'node']
1254
1255    if type_ not in webutil.archivespecs:
1256        msg = b'Unsupported archive type: %s' % stringutil.pprint(type_)
1257        raise ErrorResponse(HTTP_NOT_FOUND, msg)
1258
1259    if not ((type_ in allowed or web.configbool(b"web", b"allow" + type_))):
1260        msg = b'Archive type not allowed: %s' % type_
1261        raise ErrorResponse(HTTP_FORBIDDEN, msg)
1262
1263    reponame = re.sub(br"\W+", b"-", os.path.basename(web.reponame))
1264    cnode = web.repo.lookup(key)
1265    arch_version = key
1266    if cnode == key or key == b'tip':
1267        arch_version = short(cnode)
1268    name = b"%s-%s" % (reponame, arch_version)
1269
1270    ctx = webutil.changectx(web.repo, web.req)
1271    match = scmutil.match(ctx, [])
1272    file = web.req.qsparams.get(b'file')
1273    if file:
1274        pats = [b'path:' + file]
1275        match = scmutil.match(ctx, pats, default=b'path')
1276        if pats:
1277            files = [f for f in ctx.manifest().keys() if match(f)]
1278            if not files:
1279                raise ErrorResponse(
1280                    HTTP_NOT_FOUND, b'file(s) not found: %s' % file
1281                )
1282
1283    mimetype, artype, extension, encoding = webutil.archivespecs[type_]
1284
1285    web.res.headers[b'Content-Type'] = mimetype
1286    web.res.headers[b'Content-Disposition'] = b'attachment; filename=%s%s' % (
1287        name,
1288        extension,
1289    )
1290
1291    if encoding:
1292        web.res.headers[b'Content-Encoding'] = encoding
1293
1294    web.res.setbodywillwrite()
1295    if list(web.res.sendresponse()):
1296        raise error.ProgrammingError(
1297            b'sendresponse() should not emit data if writing later'
1298        )
1299
1300    bodyfh = web.res.getbodyfile()
1301
1302    archival.archive(
1303        web.repo,
1304        bodyfh,
1305        cnode,
1306        artype,
1307        prefix=name,
1308        match=match,
1309        subrepos=web.configbool(b"web", b"archivesubrepos"),
1310    )
1311
1312    return []
1313
1314
1315@webcommand(b'static')
1316def static(web):
1317    fname = web.req.qsparams[b'file']
1318    # a repo owner may set web.static in .hg/hgrc to get any file
1319    # readable by the user running the CGI script
1320    static = web.config(b"web", b"static", untrusted=False)
1321    staticfile(web.templatepath, static, fname, web.res)
1322    return web.res.sendresponse()
1323
1324
1325@webcommand(b'graph')
1326def graph(web):
1327    """
1328    /graph[/{revision}]
1329    -------------------
1330
1331    Show information about the graphical topology of the repository.
1332
1333    Information rendered by this handler can be used to create visual
1334    representations of repository topology.
1335
1336    The ``revision`` URL parameter controls the starting changeset. If it's
1337    absent, the default is ``tip``.
1338
1339    The ``revcount`` query string argument can define the number of changesets
1340    to show information for.
1341
1342    The ``graphtop`` query string argument can specify the starting changeset
1343    for producing ``jsdata`` variable that is used for rendering graph in
1344    JavaScript. By default it has the same value as ``revision``.
1345
1346    This handler will render the ``graph`` template.
1347    """
1348
1349    if b'node' in web.req.qsparams:
1350        ctx = webutil.changectx(web.repo, web.req)
1351        symrev = webutil.symrevorshortnode(web.req, ctx)
1352    else:
1353        ctx = web.repo[b'tip']
1354        symrev = b'tip'
1355    rev = ctx.rev()
1356
1357    bg_height = 39
1358    revcount = web.maxshortchanges
1359    if b'revcount' in web.req.qsparams:
1360        try:
1361            revcount = int(web.req.qsparams.get(b'revcount', revcount))
1362            revcount = max(revcount, 1)
1363            web.tmpl.defaults[b'sessionvars'][b'revcount'] = revcount
1364        except ValueError:
1365            pass
1366
1367    lessvars = copy.copy(web.tmpl.defaults[b'sessionvars'])
1368    lessvars[b'revcount'] = max(revcount // 2, 1)
1369    morevars = copy.copy(web.tmpl.defaults[b'sessionvars'])
1370    morevars[b'revcount'] = revcount * 2
1371
1372    graphtop = web.req.qsparams.get(b'graphtop', ctx.hex())
1373    graphvars = copy.copy(web.tmpl.defaults[b'sessionvars'])
1374    graphvars[b'graphtop'] = graphtop
1375
1376    count = len(web.repo)
1377    pos = rev
1378
1379    uprev = min(max(0, count - 1), rev + revcount)
1380    downrev = max(0, rev - revcount)
1381    changenav = webutil.revnav(web.repo).gen(pos, revcount, count)
1382
1383    tree = []
1384    nextentry = []
1385    lastrev = 0
1386    if pos != -1:
1387        allrevs = web.repo.changelog.revs(pos, 0)
1388        revs = []
1389        for i in allrevs:
1390            revs.append(i)
1391            if len(revs) >= revcount + 1:
1392                break
1393
1394        if len(revs) > revcount:
1395            nextentry = [webutil.commonentry(web.repo, web.repo[revs[-1]])]
1396            revs = revs[:-1]
1397
1398        lastrev = revs[-1]
1399
1400        # We have to feed a baseset to dagwalker as it is expecting smartset
1401        # object. This does not have a big impact on hgweb performance itself
1402        # since hgweb graphing code is not itself lazy yet.
1403        dag = graphmod.dagwalker(web.repo, smartset.baseset(revs))
1404        # As we said one line above... not lazy.
1405        tree = list(
1406            item
1407            for item in graphmod.colored(dag, web.repo)
1408            if item[1] == graphmod.CHANGESET
1409        )
1410
1411    def fulltree():
1412        pos = web.repo[graphtop].rev()
1413        tree = []
1414        if pos != -1:
1415            revs = web.repo.changelog.revs(pos, lastrev)
1416            dag = graphmod.dagwalker(web.repo, smartset.baseset(revs))
1417            tree = list(
1418                item
1419                for item in graphmod.colored(dag, web.repo)
1420                if item[1] == graphmod.CHANGESET
1421            )
1422        return tree
1423
1424    def jsdata(context):
1425        for (id, type, ctx, vtx, edges) in fulltree():
1426            yield {
1427                b'node': pycompat.bytestr(ctx),
1428                b'graphnode': webutil.getgraphnode(web.repo, ctx),
1429                b'vertex': vtx,
1430                b'edges': edges,
1431            }
1432
1433    def nodes(context):
1434        parity = paritygen(web.stripecount)
1435        for row, (id, type, ctx, vtx, edges) in enumerate(tree):
1436            entry = webutil.commonentry(web.repo, ctx)
1437            edgedata = [
1438                {
1439                    b'col': edge[0],
1440                    b'nextcol': edge[1],
1441                    b'color': (edge[2] - 1) % 6 + 1,
1442                    b'width': edge[3],
1443                    b'bcolor': edge[4],
1444                }
1445                for edge in edges
1446            ]
1447
1448            entry.update(
1449                {
1450                    b'col': vtx[0],
1451                    b'color': (vtx[1] - 1) % 6 + 1,
1452                    b'parity': next(parity),
1453                    b'edges': templateutil.mappinglist(edgedata),
1454                    b'row': row,
1455                    b'nextrow': row + 1,
1456                }
1457            )
1458
1459            yield entry
1460
1461    rows = len(tree)
1462
1463    return web.sendtemplate(
1464        b'graph',
1465        rev=rev,
1466        symrev=symrev,
1467        revcount=revcount,
1468        uprev=uprev,
1469        lessvars=lessvars,
1470        morevars=morevars,
1471        downrev=downrev,
1472        graphvars=graphvars,
1473        rows=rows,
1474        bg_height=bg_height,
1475        changesets=count,
1476        nextentry=templateutil.mappinglist(nextentry),
1477        jsdata=templateutil.mappinggenerator(jsdata),
1478        nodes=templateutil.mappinggenerator(nodes),
1479        node=ctx.hex(),
1480        archives=web.archivelist(b'tip'),
1481        changenav=changenav,
1482    )
1483
1484
1485def _getdoc(e):
1486    doc = e[0].__doc__
1487    if doc:
1488        doc = _(doc).partition(b'\n')[0]
1489    else:
1490        doc = _(b'(no help text available)')
1491    return doc
1492
1493
1494@webcommand(b'help')
1495def help(web):
1496    """
1497    /help[/{topic}]
1498    ---------------
1499
1500    Render help documentation.
1501
1502    This web command is roughly equivalent to :hg:`help`. If a ``topic``
1503    is defined, that help topic will be rendered. If not, an index of
1504    available help topics will be rendered.
1505
1506    The ``help`` template will be rendered when requesting help for a topic.
1507    ``helptopics`` will be rendered for the index of help topics.
1508    """
1509    from .. import commands, help as helpmod  # avoid cycle
1510
1511    topicname = web.req.qsparams.get(b'node')
1512    if not topicname:
1513
1514        def topics(context):
1515            for h in helpmod.helptable:
1516                entries, summary, _doc = h[0:3]
1517                yield {b'topic': entries[0], b'summary': summary}
1518
1519        early, other = [], []
1520        primary = lambda s: s.partition(b'|')[0]
1521        for c, e in pycompat.iteritems(commands.table):
1522            doc = _getdoc(e)
1523            if b'DEPRECATED' in doc or c.startswith(b'debug'):
1524                continue
1525            cmd = primary(c)
1526            if getattr(e[0], 'helpbasic', False):
1527                early.append((cmd, doc))
1528            else:
1529                other.append((cmd, doc))
1530
1531        early.sort()
1532        other.sort()
1533
1534        def earlycommands(context):
1535            for c, doc in early:
1536                yield {b'topic': c, b'summary': doc}
1537
1538        def othercommands(context):
1539            for c, doc in other:
1540                yield {b'topic': c, b'summary': doc}
1541
1542        return web.sendtemplate(
1543            b'helptopics',
1544            topics=templateutil.mappinggenerator(topics),
1545            earlycommands=templateutil.mappinggenerator(earlycommands),
1546            othercommands=templateutil.mappinggenerator(othercommands),
1547            title=b'Index',
1548        )
1549
1550    # Render an index of sub-topics.
1551    if topicname in helpmod.subtopics:
1552        topics = []
1553        for entries, summary, _doc in helpmod.subtopics[topicname]:
1554            topics.append(
1555                {
1556                    b'topic': b'%s.%s' % (topicname, entries[0]),
1557                    b'basename': entries[0],
1558                    b'summary': summary,
1559                }
1560            )
1561
1562        return web.sendtemplate(
1563            b'helptopics',
1564            topics=templateutil.mappinglist(topics),
1565            title=topicname,
1566            subindex=True,
1567        )
1568
1569    u = webutil.wsgiui.load()
1570    u.verbose = True
1571
1572    # Render a page from a sub-topic.
1573    if b'.' in topicname:
1574        # TODO implement support for rendering sections, like
1575        # `hg help` works.
1576        topic, subtopic = topicname.split(b'.', 1)
1577        if topic not in helpmod.subtopics:
1578            raise ErrorResponse(HTTP_NOT_FOUND)
1579    else:
1580        topic = topicname
1581        subtopic = None
1582
1583    try:
1584        doc = helpmod.help_(u, commands, topic, subtopic=subtopic)
1585    except error.Abort:
1586        raise ErrorResponse(HTTP_NOT_FOUND)
1587
1588    return web.sendtemplate(b'help', topic=topicname, doc=doc)
1589
1590
1591# tell hggettext to extract docstrings from these functions:
1592i18nfunctions = commands.values()
1593