##// END OF EJS Templates
hgweb: wrap {succsandmarkers} with mappinggenerator...
Yuya Nishihara -
r37933:10d3dc81 default
parent child Browse files
Show More
@@ -1,736 +1,739 b''
1 # hgweb/webutil.py - utility library for the web interface.
1 # hgweb/webutil.py - utility library for the web interface.
2 #
2 #
3 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
3 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
4 # Copyright 2005-2007 Matt Mackall <mpm@selenic.com>
4 # Copyright 2005-2007 Matt Mackall <mpm@selenic.com>
5 #
5 #
6 # This software may be used and distributed according to the terms of the
6 # This software may be used and distributed according to the terms of the
7 # GNU General Public License version 2 or any later version.
7 # GNU General Public License version 2 or any later version.
8
8
9 from __future__ import absolute_import
9 from __future__ import absolute_import
10
10
11 import copy
11 import copy
12 import difflib
12 import difflib
13 import os
13 import os
14 import re
14 import re
15
15
16 from ..i18n import _
16 from ..i18n import _
17 from ..node import hex, nullid, short
17 from ..node import hex, nullid, short
18
18
19 from .common import (
19 from .common import (
20 ErrorResponse,
20 ErrorResponse,
21 HTTP_BAD_REQUEST,
21 HTTP_BAD_REQUEST,
22 HTTP_NOT_FOUND,
22 HTTP_NOT_FOUND,
23 paritygen,
23 paritygen,
24 )
24 )
25
25
26 from .. import (
26 from .. import (
27 context,
27 context,
28 error,
28 error,
29 match,
29 match,
30 mdiff,
30 mdiff,
31 obsutil,
31 obsutil,
32 patch,
32 patch,
33 pathutil,
33 pathutil,
34 pycompat,
34 pycompat,
35 scmutil,
35 scmutil,
36 templatefilters,
36 templatefilters,
37 templatekw,
37 templatekw,
38 templateutil,
38 templateutil,
39 ui as uimod,
39 ui as uimod,
40 util,
40 util,
41 )
41 )
42
42
43 from ..utils import (
43 from ..utils import (
44 stringutil,
44 stringutil,
45 )
45 )
46
46
47 archivespecs = util.sortdict((
47 archivespecs = util.sortdict((
48 ('zip', ('application/zip', 'zip', '.zip', None)),
48 ('zip', ('application/zip', 'zip', '.zip', None)),
49 ('gz', ('application/x-gzip', 'tgz', '.tar.gz', None)),
49 ('gz', ('application/x-gzip', 'tgz', '.tar.gz', None)),
50 ('bz2', ('application/x-bzip2', 'tbz2', '.tar.bz2', None)),
50 ('bz2', ('application/x-bzip2', 'tbz2', '.tar.bz2', None)),
51 ))
51 ))
52
52
53 def archivelist(ui, nodeid, url=None):
53 def archivelist(ui, nodeid, url=None):
54 allowed = ui.configlist('web', 'allow_archive', untrusted=True)
54 allowed = ui.configlist('web', 'allow_archive', untrusted=True)
55 archives = []
55 archives = []
56
56
57 for typ, spec in archivespecs.iteritems():
57 for typ, spec in archivespecs.iteritems():
58 if typ in allowed or ui.configbool('web', 'allow' + typ,
58 if typ in allowed or ui.configbool('web', 'allow' + typ,
59 untrusted=True):
59 untrusted=True):
60 archives.append({
60 archives.append({
61 'type': typ,
61 'type': typ,
62 'extension': spec[2],
62 'extension': spec[2],
63 'node': nodeid,
63 'node': nodeid,
64 'url': url,
64 'url': url,
65 })
65 })
66
66
67 return templateutil.mappinglist(archives)
67 return templateutil.mappinglist(archives)
68
68
69 def up(p):
69 def up(p):
70 if p[0:1] != "/":
70 if p[0:1] != "/":
71 p = "/" + p
71 p = "/" + p
72 if p[-1:] == "/":
72 if p[-1:] == "/":
73 p = p[:-1]
73 p = p[:-1]
74 up = os.path.dirname(p)
74 up = os.path.dirname(p)
75 if up == "/":
75 if up == "/":
76 return "/"
76 return "/"
77 return up + "/"
77 return up + "/"
78
78
79 def _navseq(step, firststep=None):
79 def _navseq(step, firststep=None):
80 if firststep:
80 if firststep:
81 yield firststep
81 yield firststep
82 if firststep >= 20 and firststep <= 40:
82 if firststep >= 20 and firststep <= 40:
83 firststep = 50
83 firststep = 50
84 yield firststep
84 yield firststep
85 assert step > 0
85 assert step > 0
86 assert firststep > 0
86 assert firststep > 0
87 while step <= firststep:
87 while step <= firststep:
88 step *= 10
88 step *= 10
89 while True:
89 while True:
90 yield 1 * step
90 yield 1 * step
91 yield 3 * step
91 yield 3 * step
92 step *= 10
92 step *= 10
93
93
94 class revnav(object):
94 class revnav(object):
95
95
96 def __init__(self, repo):
96 def __init__(self, repo):
97 """Navigation generation object
97 """Navigation generation object
98
98
99 :repo: repo object we generate nav for
99 :repo: repo object we generate nav for
100 """
100 """
101 # used for hex generation
101 # used for hex generation
102 self._revlog = repo.changelog
102 self._revlog = repo.changelog
103
103
104 def __nonzero__(self):
104 def __nonzero__(self):
105 """return True if any revision to navigate over"""
105 """return True if any revision to navigate over"""
106 return self._first() is not None
106 return self._first() is not None
107
107
108 __bool__ = __nonzero__
108 __bool__ = __nonzero__
109
109
110 def _first(self):
110 def _first(self):
111 """return the minimum non-filtered changeset or None"""
111 """return the minimum non-filtered changeset or None"""
112 try:
112 try:
113 return next(iter(self._revlog))
113 return next(iter(self._revlog))
114 except StopIteration:
114 except StopIteration:
115 return None
115 return None
116
116
117 def hex(self, rev):
117 def hex(self, rev):
118 return hex(self._revlog.node(rev))
118 return hex(self._revlog.node(rev))
119
119
120 def gen(self, pos, pagelen, limit):
120 def gen(self, pos, pagelen, limit):
121 """computes label and revision id for navigation link
121 """computes label and revision id for navigation link
122
122
123 :pos: is the revision relative to which we generate navigation.
123 :pos: is the revision relative to which we generate navigation.
124 :pagelen: the size of each navigation page
124 :pagelen: the size of each navigation page
125 :limit: how far shall we link
125 :limit: how far shall we link
126
126
127 The return is:
127 The return is:
128 - a single element mappinglist
128 - a single element mappinglist
129 - containing a dictionary with a `before` and `after` key
129 - containing a dictionary with a `before` and `after` key
130 - values are dictionaries with `label` and `node` keys
130 - values are dictionaries with `label` and `node` keys
131 """
131 """
132 if not self:
132 if not self:
133 # empty repo
133 # empty repo
134 return templateutil.mappinglist([
134 return templateutil.mappinglist([
135 {'before': templateutil.mappinglist([]),
135 {'before': templateutil.mappinglist([]),
136 'after': templateutil.mappinglist([])},
136 'after': templateutil.mappinglist([])},
137 ])
137 ])
138
138
139 targets = []
139 targets = []
140 for f in _navseq(1, pagelen):
140 for f in _navseq(1, pagelen):
141 if f > limit:
141 if f > limit:
142 break
142 break
143 targets.append(pos + f)
143 targets.append(pos + f)
144 targets.append(pos - f)
144 targets.append(pos - f)
145 targets.sort()
145 targets.sort()
146
146
147 first = self._first()
147 first = self._first()
148 navbefore = [{'label': '(%i)' % first, 'node': self.hex(first)}]
148 navbefore = [{'label': '(%i)' % first, 'node': self.hex(first)}]
149 navafter = []
149 navafter = []
150 for rev in targets:
150 for rev in targets:
151 if rev not in self._revlog:
151 if rev not in self._revlog:
152 continue
152 continue
153 if pos < rev < limit:
153 if pos < rev < limit:
154 navafter.append({'label': '+%d' % abs(rev - pos),
154 navafter.append({'label': '+%d' % abs(rev - pos),
155 'node': self.hex(rev)})
155 'node': self.hex(rev)})
156 if 0 < rev < pos:
156 if 0 < rev < pos:
157 navbefore.append({'label': '-%d' % abs(rev - pos),
157 navbefore.append({'label': '-%d' % abs(rev - pos),
158 'node': self.hex(rev)})
158 'node': self.hex(rev)})
159
159
160 navafter.append({'label': 'tip', 'node': 'tip'})
160 navafter.append({'label': 'tip', 'node': 'tip'})
161
161
162 # TODO: maybe this can be a scalar object supporting tomap()
162 # TODO: maybe this can be a scalar object supporting tomap()
163 return templateutil.mappinglist([
163 return templateutil.mappinglist([
164 {'before': templateutil.mappinglist(navbefore),
164 {'before': templateutil.mappinglist(navbefore),
165 'after': templateutil.mappinglist(navafter)},
165 'after': templateutil.mappinglist(navafter)},
166 ])
166 ])
167
167
168 class filerevnav(revnav):
168 class filerevnav(revnav):
169
169
170 def __init__(self, repo, path):
170 def __init__(self, repo, path):
171 """Navigation generation object
171 """Navigation generation object
172
172
173 :repo: repo object we generate nav for
173 :repo: repo object we generate nav for
174 :path: path of the file we generate nav for
174 :path: path of the file we generate nav for
175 """
175 """
176 # used for iteration
176 # used for iteration
177 self._changelog = repo.unfiltered().changelog
177 self._changelog = repo.unfiltered().changelog
178 # used for hex generation
178 # used for hex generation
179 self._revlog = repo.file(path)
179 self._revlog = repo.file(path)
180
180
181 def hex(self, rev):
181 def hex(self, rev):
182 return hex(self._changelog.node(self._revlog.linkrev(rev)))
182 return hex(self._changelog.node(self._revlog.linkrev(rev)))
183
183
184 # TODO: maybe this can be a wrapper class for changectx/filectx list, which
184 # TODO: maybe this can be a wrapper class for changectx/filectx list, which
185 # yields {'ctx': ctx}
185 # yields {'ctx': ctx}
186 def _ctxsgen(context, ctxs):
186 def _ctxsgen(context, ctxs):
187 for s in ctxs:
187 for s in ctxs:
188 d = {
188 d = {
189 'node': s.hex(),
189 'node': s.hex(),
190 'rev': s.rev(),
190 'rev': s.rev(),
191 'user': s.user(),
191 'user': s.user(),
192 'date': s.date(),
192 'date': s.date(),
193 'description': s.description(),
193 'description': s.description(),
194 'branch': s.branch(),
194 'branch': s.branch(),
195 }
195 }
196 if util.safehasattr(s, 'path'):
196 if util.safehasattr(s, 'path'):
197 d['file'] = s.path()
197 d['file'] = s.path()
198 yield d
198 yield d
199
199
200 def _siblings(siblings=None, hiderev=None):
200 def _siblings(siblings=None, hiderev=None):
201 if siblings is None:
201 if siblings is None:
202 siblings = []
202 siblings = []
203 siblings = [s for s in siblings if s.node() != nullid]
203 siblings = [s for s in siblings if s.node() != nullid]
204 if len(siblings) == 1 and siblings[0].rev() == hiderev:
204 if len(siblings) == 1 and siblings[0].rev() == hiderev:
205 siblings = []
205 siblings = []
206 return templateutil.mappinggenerator(_ctxsgen, args=(siblings,))
206 return templateutil.mappinggenerator(_ctxsgen, args=(siblings,))
207
207
208 def difffeatureopts(req, ui, section):
208 def difffeatureopts(req, ui, section):
209 diffopts = patch.difffeatureopts(ui, untrusted=True,
209 diffopts = patch.difffeatureopts(ui, untrusted=True,
210 section=section, whitespace=True)
210 section=section, whitespace=True)
211
211
212 for k in ('ignorews', 'ignorewsamount', 'ignorewseol', 'ignoreblanklines'):
212 for k in ('ignorews', 'ignorewsamount', 'ignorewseol', 'ignoreblanklines'):
213 v = req.qsparams.get(k)
213 v = req.qsparams.get(k)
214 if v is not None:
214 if v is not None:
215 v = stringutil.parsebool(v)
215 v = stringutil.parsebool(v)
216 setattr(diffopts, k, v if v is not None else True)
216 setattr(diffopts, k, v if v is not None else True)
217
217
218 return diffopts
218 return diffopts
219
219
220 def annotate(req, fctx, ui):
220 def annotate(req, fctx, ui):
221 diffopts = difffeatureopts(req, ui, 'annotate')
221 diffopts = difffeatureopts(req, ui, 'annotate')
222 return fctx.annotate(follow=True, diffopts=diffopts)
222 return fctx.annotate(follow=True, diffopts=diffopts)
223
223
224 def parents(ctx, hide=None):
224 def parents(ctx, hide=None):
225 if isinstance(ctx, context.basefilectx):
225 if isinstance(ctx, context.basefilectx):
226 introrev = ctx.introrev()
226 introrev = ctx.introrev()
227 if ctx.changectx().rev() != introrev:
227 if ctx.changectx().rev() != introrev:
228 return _siblings([ctx.repo()[introrev]], hide)
228 return _siblings([ctx.repo()[introrev]], hide)
229 return _siblings(ctx.parents(), hide)
229 return _siblings(ctx.parents(), hide)
230
230
231 def children(ctx, hide=None):
231 def children(ctx, hide=None):
232 return _siblings(ctx.children(), hide)
232 return _siblings(ctx.children(), hide)
233
233
234 def renamelink(fctx):
234 def renamelink(fctx):
235 r = fctx.renamed()
235 r = fctx.renamed()
236 if r:
236 if r:
237 return templateutil.mappinglist([{'file': r[0], 'node': hex(r[1])}])
237 return templateutil.mappinglist([{'file': r[0], 'node': hex(r[1])}])
238 return templateutil.mappinglist([])
238 return templateutil.mappinglist([])
239
239
240 def nodetagsdict(repo, node):
240 def nodetagsdict(repo, node):
241 return templateutil.hybridlist(repo.nodetags(node), name='name')
241 return templateutil.hybridlist(repo.nodetags(node), name='name')
242
242
243 def nodebookmarksdict(repo, node):
243 def nodebookmarksdict(repo, node):
244 return templateutil.hybridlist(repo.nodebookmarks(node), name='name')
244 return templateutil.hybridlist(repo.nodebookmarks(node), name='name')
245
245
246 def nodebranchdict(repo, ctx):
246 def nodebranchdict(repo, ctx):
247 branches = []
247 branches = []
248 branch = ctx.branch()
248 branch = ctx.branch()
249 # If this is an empty repo, ctx.node() == nullid,
249 # If this is an empty repo, ctx.node() == nullid,
250 # ctx.branch() == 'default'.
250 # ctx.branch() == 'default'.
251 try:
251 try:
252 branchnode = repo.branchtip(branch)
252 branchnode = repo.branchtip(branch)
253 except error.RepoLookupError:
253 except error.RepoLookupError:
254 branchnode = None
254 branchnode = None
255 if branchnode == ctx.node():
255 if branchnode == ctx.node():
256 branches.append(branch)
256 branches.append(branch)
257 return templateutil.hybridlist(branches, name='name')
257 return templateutil.hybridlist(branches, name='name')
258
258
259 def nodeinbranch(repo, ctx):
259 def nodeinbranch(repo, ctx):
260 branches = []
260 branches = []
261 branch = ctx.branch()
261 branch = ctx.branch()
262 try:
262 try:
263 branchnode = repo.branchtip(branch)
263 branchnode = repo.branchtip(branch)
264 except error.RepoLookupError:
264 except error.RepoLookupError:
265 branchnode = None
265 branchnode = None
266 if branch != 'default' and branchnode != ctx.node():
266 if branch != 'default' and branchnode != ctx.node():
267 branches.append(branch)
267 branches.append(branch)
268 return templateutil.hybridlist(branches, name='name')
268 return templateutil.hybridlist(branches, name='name')
269
269
270 def nodebranchnodefault(ctx):
270 def nodebranchnodefault(ctx):
271 branches = []
271 branches = []
272 branch = ctx.branch()
272 branch = ctx.branch()
273 if branch != 'default':
273 if branch != 'default':
274 branches.append(branch)
274 branches.append(branch)
275 return templateutil.hybridlist(branches, name='name')
275 return templateutil.hybridlist(branches, name='name')
276
276
277 def _nodenamesgen(context, f, node, name):
277 def _nodenamesgen(context, f, node, name):
278 for t in f(node):
278 for t in f(node):
279 yield {name: t}
279 yield {name: t}
280
280
281 def showtag(repo, t1, node=nullid):
281 def showtag(repo, t1, node=nullid):
282 args = (repo.nodetags, node, 'tag')
282 args = (repo.nodetags, node, 'tag')
283 return templateutil.mappinggenerator(_nodenamesgen, args=args, name=t1)
283 return templateutil.mappinggenerator(_nodenamesgen, args=args, name=t1)
284
284
285 def showbookmark(repo, t1, node=nullid):
285 def showbookmark(repo, t1, node=nullid):
286 args = (repo.nodebookmarks, node, 'bookmark')
286 args = (repo.nodebookmarks, node, 'bookmark')
287 return templateutil.mappinggenerator(_nodenamesgen, args=args, name=t1)
287 return templateutil.mappinggenerator(_nodenamesgen, args=args, name=t1)
288
288
289 def branchentries(repo, stripecount, limit=0):
289 def branchentries(repo, stripecount, limit=0):
290 tips = []
290 tips = []
291 heads = repo.heads()
291 heads = repo.heads()
292 parity = paritygen(stripecount)
292 parity = paritygen(stripecount)
293 sortkey = lambda item: (not item[1], item[0].rev())
293 sortkey = lambda item: (not item[1], item[0].rev())
294
294
295 def entries(context):
295 def entries(context):
296 count = 0
296 count = 0
297 if not tips:
297 if not tips:
298 for tag, hs, tip, closed in repo.branchmap().iterbranches():
298 for tag, hs, tip, closed in repo.branchmap().iterbranches():
299 tips.append((repo[tip], closed))
299 tips.append((repo[tip], closed))
300 for ctx, closed in sorted(tips, key=sortkey, reverse=True):
300 for ctx, closed in sorted(tips, key=sortkey, reverse=True):
301 if limit > 0 and count >= limit:
301 if limit > 0 and count >= limit:
302 return
302 return
303 count += 1
303 count += 1
304 if closed:
304 if closed:
305 status = 'closed'
305 status = 'closed'
306 elif ctx.node() not in heads:
306 elif ctx.node() not in heads:
307 status = 'inactive'
307 status = 'inactive'
308 else:
308 else:
309 status = 'open'
309 status = 'open'
310 yield {
310 yield {
311 'parity': next(parity),
311 'parity': next(parity),
312 'branch': ctx.branch(),
312 'branch': ctx.branch(),
313 'status': status,
313 'status': status,
314 'node': ctx.hex(),
314 'node': ctx.hex(),
315 'date': ctx.date()
315 'date': ctx.date()
316 }
316 }
317
317
318 return templateutil.mappinggenerator(entries)
318 return templateutil.mappinggenerator(entries)
319
319
320 def cleanpath(repo, path):
320 def cleanpath(repo, path):
321 path = path.lstrip('/')
321 path = path.lstrip('/')
322 return pathutil.canonpath(repo.root, '', path)
322 return pathutil.canonpath(repo.root, '', path)
323
323
324 def changectx(repo, req):
324 def changectx(repo, req):
325 changeid = "tip"
325 changeid = "tip"
326 if 'node' in req.qsparams:
326 if 'node' in req.qsparams:
327 changeid = req.qsparams['node']
327 changeid = req.qsparams['node']
328 ipos = changeid.find(':')
328 ipos = changeid.find(':')
329 if ipos != -1:
329 if ipos != -1:
330 changeid = changeid[(ipos + 1):]
330 changeid = changeid[(ipos + 1):]
331
331
332 return scmutil.revsymbol(repo, changeid)
332 return scmutil.revsymbol(repo, changeid)
333
333
334 def basechangectx(repo, req):
334 def basechangectx(repo, req):
335 if 'node' in req.qsparams:
335 if 'node' in req.qsparams:
336 changeid = req.qsparams['node']
336 changeid = req.qsparams['node']
337 ipos = changeid.find(':')
337 ipos = changeid.find(':')
338 if ipos != -1:
338 if ipos != -1:
339 changeid = changeid[:ipos]
339 changeid = changeid[:ipos]
340 return scmutil.revsymbol(repo, changeid)
340 return scmutil.revsymbol(repo, changeid)
341
341
342 return None
342 return None
343
343
344 def filectx(repo, req):
344 def filectx(repo, req):
345 if 'file' not in req.qsparams:
345 if 'file' not in req.qsparams:
346 raise ErrorResponse(HTTP_NOT_FOUND, 'file not given')
346 raise ErrorResponse(HTTP_NOT_FOUND, 'file not given')
347 path = cleanpath(repo, req.qsparams['file'])
347 path = cleanpath(repo, req.qsparams['file'])
348 if 'node' in req.qsparams:
348 if 'node' in req.qsparams:
349 changeid = req.qsparams['node']
349 changeid = req.qsparams['node']
350 elif 'filenode' in req.qsparams:
350 elif 'filenode' in req.qsparams:
351 changeid = req.qsparams['filenode']
351 changeid = req.qsparams['filenode']
352 else:
352 else:
353 raise ErrorResponse(HTTP_NOT_FOUND, 'node or filenode not given')
353 raise ErrorResponse(HTTP_NOT_FOUND, 'node or filenode not given')
354 try:
354 try:
355 fctx = scmutil.revsymbol(repo, changeid)[path]
355 fctx = scmutil.revsymbol(repo, changeid)[path]
356 except error.RepoError:
356 except error.RepoError:
357 fctx = repo.filectx(path, fileid=changeid)
357 fctx = repo.filectx(path, fileid=changeid)
358
358
359 return fctx
359 return fctx
360
360
361 def linerange(req):
361 def linerange(req):
362 linerange = req.qsparams.getall('linerange')
362 linerange = req.qsparams.getall('linerange')
363 if not linerange:
363 if not linerange:
364 return None
364 return None
365 if len(linerange) > 1:
365 if len(linerange) > 1:
366 raise ErrorResponse(HTTP_BAD_REQUEST,
366 raise ErrorResponse(HTTP_BAD_REQUEST,
367 'redundant linerange parameter')
367 'redundant linerange parameter')
368 try:
368 try:
369 fromline, toline = map(int, linerange[0].split(':', 1))
369 fromline, toline = map(int, linerange[0].split(':', 1))
370 except ValueError:
370 except ValueError:
371 raise ErrorResponse(HTTP_BAD_REQUEST,
371 raise ErrorResponse(HTTP_BAD_REQUEST,
372 'invalid linerange parameter')
372 'invalid linerange parameter')
373 try:
373 try:
374 return util.processlinerange(fromline, toline)
374 return util.processlinerange(fromline, toline)
375 except error.ParseError as exc:
375 except error.ParseError as exc:
376 raise ErrorResponse(HTTP_BAD_REQUEST, pycompat.bytestr(exc))
376 raise ErrorResponse(HTTP_BAD_REQUEST, pycompat.bytestr(exc))
377
377
378 def formatlinerange(fromline, toline):
378 def formatlinerange(fromline, toline):
379 return '%d:%d' % (fromline + 1, toline)
379 return '%d:%d' % (fromline + 1, toline)
380
380
381 def succsandmarkers(context, mapping):
381 def _succsandmarkersgen(context, mapping):
382 repo = context.resource(mapping, 'repo')
382 repo = context.resource(mapping, 'repo')
383 itemmappings = templatekw.showsuccsandmarkers(context, mapping)
383 itemmappings = templatekw.showsuccsandmarkers(context, mapping)
384 for item in itemmappings.tovalue(context, mapping):
384 for item in itemmappings.tovalue(context, mapping):
385 item['successors'] = _siblings(repo[successor]
385 item['successors'] = _siblings(repo[successor]
386 for successor in item['successors'])
386 for successor in item['successors'])
387 yield item
387 yield item
388
388
389 def succsandmarkers(context, mapping):
390 return templateutil.mappinggenerator(_succsandmarkersgen, args=(mapping,))
391
389 # teach templater succsandmarkers is switched to (context, mapping) API
392 # teach templater succsandmarkers is switched to (context, mapping) API
390 succsandmarkers._requires = {'repo', 'ctx'}
393 succsandmarkers._requires = {'repo', 'ctx'}
391
394
392 def whyunstable(context, mapping):
395 def whyunstable(context, mapping):
393 repo = context.resource(mapping, 'repo')
396 repo = context.resource(mapping, 'repo')
394 ctx = context.resource(mapping, 'ctx')
397 ctx = context.resource(mapping, 'ctx')
395
398
396 entries = obsutil.whyunstable(repo, ctx)
399 entries = obsutil.whyunstable(repo, ctx)
397 for entry in entries:
400 for entry in entries:
398 if entry.get('divergentnodes'):
401 if entry.get('divergentnodes'):
399 entry['divergentnodes'] = _siblings(entry['divergentnodes'])
402 entry['divergentnodes'] = _siblings(entry['divergentnodes'])
400 yield entry
403 yield entry
401
404
402 whyunstable._requires = {'repo', 'ctx'}
405 whyunstable._requires = {'repo', 'ctx'}
403
406
404 def commonentry(repo, ctx):
407 def commonentry(repo, ctx):
405 node = ctx.node()
408 node = ctx.node()
406 return {
409 return {
407 # TODO: perhaps ctx.changectx() should be assigned if ctx is a
410 # TODO: perhaps ctx.changectx() should be assigned if ctx is a
408 # filectx, but I'm not pretty sure if that would always work because
411 # filectx, but I'm not pretty sure if that would always work because
409 # fctx.parents() != fctx.changectx.parents() for example.
412 # fctx.parents() != fctx.changectx.parents() for example.
410 'ctx': ctx,
413 'ctx': ctx,
411 'rev': ctx.rev(),
414 'rev': ctx.rev(),
412 'node': hex(node),
415 'node': hex(node),
413 'author': ctx.user(),
416 'author': ctx.user(),
414 'desc': ctx.description(),
417 'desc': ctx.description(),
415 'date': ctx.date(),
418 'date': ctx.date(),
416 'extra': ctx.extra(),
419 'extra': ctx.extra(),
417 'phase': ctx.phasestr(),
420 'phase': ctx.phasestr(),
418 'obsolete': ctx.obsolete(),
421 'obsolete': ctx.obsolete(),
419 'succsandmarkers': succsandmarkers,
422 'succsandmarkers': succsandmarkers,
420 'instabilities': [{"instability": i} for i in ctx.instabilities()],
423 'instabilities': [{"instability": i} for i in ctx.instabilities()],
421 'whyunstable': whyunstable,
424 'whyunstable': whyunstable,
422 'branch': nodebranchnodefault(ctx),
425 'branch': nodebranchnodefault(ctx),
423 'inbranch': nodeinbranch(repo, ctx),
426 'inbranch': nodeinbranch(repo, ctx),
424 'branches': nodebranchdict(repo, ctx),
427 'branches': nodebranchdict(repo, ctx),
425 'tags': nodetagsdict(repo, node),
428 'tags': nodetagsdict(repo, node),
426 'bookmarks': nodebookmarksdict(repo, node),
429 'bookmarks': nodebookmarksdict(repo, node),
427 'parent': lambda **x: parents(ctx),
430 'parent': lambda **x: parents(ctx),
428 'child': lambda **x: children(ctx),
431 'child': lambda **x: children(ctx),
429 }
432 }
430
433
431 def changelistentry(web, ctx):
434 def changelistentry(web, ctx):
432 '''Obtain a dictionary to be used for entries in a changelist.
435 '''Obtain a dictionary to be used for entries in a changelist.
433
436
434 This function is called when producing items for the "entries" list passed
437 This function is called when producing items for the "entries" list passed
435 to the "shortlog" and "changelog" templates.
438 to the "shortlog" and "changelog" templates.
436 '''
439 '''
437 repo = web.repo
440 repo = web.repo
438 rev = ctx.rev()
441 rev = ctx.rev()
439 n = ctx.node()
442 n = ctx.node()
440 showtags = showtag(repo, 'changelogtag', n)
443 showtags = showtag(repo, 'changelogtag', n)
441 files = listfilediffs(web.tmpl, ctx.files(), n, web.maxfiles)
444 files = listfilediffs(web.tmpl, ctx.files(), n, web.maxfiles)
442
445
443 entry = commonentry(repo, ctx)
446 entry = commonentry(repo, ctx)
444 entry.update(
447 entry.update(
445 allparents=lambda **x: parents(ctx),
448 allparents=lambda **x: parents(ctx),
446 parent=lambda **x: parents(ctx, rev - 1),
449 parent=lambda **x: parents(ctx, rev - 1),
447 child=lambda **x: children(ctx, rev + 1),
450 child=lambda **x: children(ctx, rev + 1),
448 changelogtag=showtags,
451 changelogtag=showtags,
449 files=files,
452 files=files,
450 )
453 )
451 return entry
454 return entry
452
455
453 def symrevorshortnode(req, ctx):
456 def symrevorshortnode(req, ctx):
454 if 'node' in req.qsparams:
457 if 'node' in req.qsparams:
455 return templatefilters.revescape(req.qsparams['node'])
458 return templatefilters.revescape(req.qsparams['node'])
456 else:
459 else:
457 return short(ctx.node())
460 return short(ctx.node())
458
461
459 def changesetentry(web, ctx):
462 def changesetentry(web, ctx):
460 '''Obtain a dictionary to be used to render the "changeset" template.'''
463 '''Obtain a dictionary to be used to render the "changeset" template.'''
461
464
462 showtags = showtag(web.repo, 'changesettag', ctx.node())
465 showtags = showtag(web.repo, 'changesettag', ctx.node())
463 showbookmarks = showbookmark(web.repo, 'changesetbookmark', ctx.node())
466 showbookmarks = showbookmark(web.repo, 'changesetbookmark', ctx.node())
464 showbranch = nodebranchnodefault(ctx)
467 showbranch = nodebranchnodefault(ctx)
465
468
466 files = []
469 files = []
467 parity = paritygen(web.stripecount)
470 parity = paritygen(web.stripecount)
468 for blockno, f in enumerate(ctx.files()):
471 for blockno, f in enumerate(ctx.files()):
469 template = 'filenodelink' if f in ctx else 'filenolink'
472 template = 'filenodelink' if f in ctx else 'filenolink'
470 files.append(web.tmpl.generate(template, {
473 files.append(web.tmpl.generate(template, {
471 'node': ctx.hex(),
474 'node': ctx.hex(),
472 'file': f,
475 'file': f,
473 'blockno': blockno + 1,
476 'blockno': blockno + 1,
474 'parity': next(parity),
477 'parity': next(parity),
475 }))
478 }))
476
479
477 basectx = basechangectx(web.repo, web.req)
480 basectx = basechangectx(web.repo, web.req)
478 if basectx is None:
481 if basectx is None:
479 basectx = ctx.p1()
482 basectx = ctx.p1()
480
483
481 style = web.config('web', 'style')
484 style = web.config('web', 'style')
482 if 'style' in web.req.qsparams:
485 if 'style' in web.req.qsparams:
483 style = web.req.qsparams['style']
486 style = web.req.qsparams['style']
484
487
485 diff = diffs(web, ctx, basectx, None, style)
488 diff = diffs(web, ctx, basectx, None, style)
486
489
487 parity = paritygen(web.stripecount)
490 parity = paritygen(web.stripecount)
488 diffstatsgen = diffstatgen(ctx, basectx)
491 diffstatsgen = diffstatgen(ctx, basectx)
489 diffstats = diffstat(web.tmpl, ctx, diffstatsgen, parity)
492 diffstats = diffstat(web.tmpl, ctx, diffstatsgen, parity)
490
493
491 return dict(
494 return dict(
492 diff=diff,
495 diff=diff,
493 symrev=symrevorshortnode(web.req, ctx),
496 symrev=symrevorshortnode(web.req, ctx),
494 basenode=basectx.hex(),
497 basenode=basectx.hex(),
495 changesettag=showtags,
498 changesettag=showtags,
496 changesetbookmark=showbookmarks,
499 changesetbookmark=showbookmarks,
497 changesetbranch=showbranch,
500 changesetbranch=showbranch,
498 files=files,
501 files=files,
499 diffsummary=lambda **x: diffsummary(diffstatsgen),
502 diffsummary=lambda **x: diffsummary(diffstatsgen),
500 diffstat=diffstats,
503 diffstat=diffstats,
501 archives=web.archivelist(ctx.hex()),
504 archives=web.archivelist(ctx.hex()),
502 **pycompat.strkwargs(commonentry(web.repo, ctx)))
505 **pycompat.strkwargs(commonentry(web.repo, ctx)))
503
506
504 def listfilediffs(tmpl, files, node, max):
507 def listfilediffs(tmpl, files, node, max):
505 for f in files[:max]:
508 for f in files[:max]:
506 yield tmpl.generate('filedifflink', {'node': hex(node), 'file': f})
509 yield tmpl.generate('filedifflink', {'node': hex(node), 'file': f})
507 if len(files) > max:
510 if len(files) > max:
508 yield tmpl.generate('fileellipses', {})
511 yield tmpl.generate('fileellipses', {})
509
512
510 def diffs(web, ctx, basectx, files, style, linerange=None,
513 def diffs(web, ctx, basectx, files, style, linerange=None,
511 lineidprefix=''):
514 lineidprefix=''):
512
515
513 def prettyprintlines(lines, blockno):
516 def prettyprintlines(lines, blockno):
514 for lineno, l in enumerate(lines, 1):
517 for lineno, l in enumerate(lines, 1):
515 difflineno = "%d.%d" % (blockno, lineno)
518 difflineno = "%d.%d" % (blockno, lineno)
516 if l.startswith('+'):
519 if l.startswith('+'):
517 ltype = "difflineplus"
520 ltype = "difflineplus"
518 elif l.startswith('-'):
521 elif l.startswith('-'):
519 ltype = "difflineminus"
522 ltype = "difflineminus"
520 elif l.startswith('@'):
523 elif l.startswith('@'):
521 ltype = "difflineat"
524 ltype = "difflineat"
522 else:
525 else:
523 ltype = "diffline"
526 ltype = "diffline"
524 yield web.tmpl.generate(ltype, {
527 yield web.tmpl.generate(ltype, {
525 'line': l,
528 'line': l,
526 'lineno': lineno,
529 'lineno': lineno,
527 'lineid': lineidprefix + "l%s" % difflineno,
530 'lineid': lineidprefix + "l%s" % difflineno,
528 'linenumber': "% 8s" % difflineno,
531 'linenumber': "% 8s" % difflineno,
529 })
532 })
530
533
531 repo = web.repo
534 repo = web.repo
532 if files:
535 if files:
533 m = match.exact(repo.root, repo.getcwd(), files)
536 m = match.exact(repo.root, repo.getcwd(), files)
534 else:
537 else:
535 m = match.always(repo.root, repo.getcwd())
538 m = match.always(repo.root, repo.getcwd())
536
539
537 diffopts = patch.diffopts(repo.ui, untrusted=True)
540 diffopts = patch.diffopts(repo.ui, untrusted=True)
538 node1 = basectx.node()
541 node1 = basectx.node()
539 node2 = ctx.node()
542 node2 = ctx.node()
540 parity = paritygen(web.stripecount)
543 parity = paritygen(web.stripecount)
541
544
542 diffhunks = patch.diffhunks(repo, node1, node2, m, opts=diffopts)
545 diffhunks = patch.diffhunks(repo, node1, node2, m, opts=diffopts)
543 for blockno, (fctx1, fctx2, header, hunks) in enumerate(diffhunks, 1):
546 for blockno, (fctx1, fctx2, header, hunks) in enumerate(diffhunks, 1):
544 if style != 'raw':
547 if style != 'raw':
545 header = header[1:]
548 header = header[1:]
546 lines = [h + '\n' for h in header]
549 lines = [h + '\n' for h in header]
547 for hunkrange, hunklines in hunks:
550 for hunkrange, hunklines in hunks:
548 if linerange is not None and hunkrange is not None:
551 if linerange is not None and hunkrange is not None:
549 s1, l1, s2, l2 = hunkrange
552 s1, l1, s2, l2 = hunkrange
550 if not mdiff.hunkinrange((s2, l2), linerange):
553 if not mdiff.hunkinrange((s2, l2), linerange):
551 continue
554 continue
552 lines.extend(hunklines)
555 lines.extend(hunklines)
553 if lines:
556 if lines:
554 yield web.tmpl.generate('diffblock', {
557 yield web.tmpl.generate('diffblock', {
555 'parity': next(parity),
558 'parity': next(parity),
556 'blockno': blockno,
559 'blockno': blockno,
557 'lines': prettyprintlines(lines, blockno),
560 'lines': prettyprintlines(lines, blockno),
558 })
561 })
559
562
560 def compare(tmpl, context, leftlines, rightlines):
563 def compare(tmpl, context, leftlines, rightlines):
561 '''Generator function that provides side-by-side comparison data.'''
564 '''Generator function that provides side-by-side comparison data.'''
562
565
563 def compline(type, leftlineno, leftline, rightlineno, rightline):
566 def compline(type, leftlineno, leftline, rightlineno, rightline):
564 lineid = leftlineno and ("l%d" % leftlineno) or ''
567 lineid = leftlineno and ("l%d" % leftlineno) or ''
565 lineid += rightlineno and ("r%d" % rightlineno) or ''
568 lineid += rightlineno and ("r%d" % rightlineno) or ''
566 llno = '%d' % leftlineno if leftlineno else ''
569 llno = '%d' % leftlineno if leftlineno else ''
567 rlno = '%d' % rightlineno if rightlineno else ''
570 rlno = '%d' % rightlineno if rightlineno else ''
568 return tmpl.generate('comparisonline', {
571 return tmpl.generate('comparisonline', {
569 'type': type,
572 'type': type,
570 'lineid': lineid,
573 'lineid': lineid,
571 'leftlineno': leftlineno,
574 'leftlineno': leftlineno,
572 'leftlinenumber': "% 6s" % llno,
575 'leftlinenumber': "% 6s" % llno,
573 'leftline': leftline or '',
576 'leftline': leftline or '',
574 'rightlineno': rightlineno,
577 'rightlineno': rightlineno,
575 'rightlinenumber': "% 6s" % rlno,
578 'rightlinenumber': "% 6s" % rlno,
576 'rightline': rightline or '',
579 'rightline': rightline or '',
577 })
580 })
578
581
579 def getblock(opcodes):
582 def getblock(opcodes):
580 for type, llo, lhi, rlo, rhi in opcodes:
583 for type, llo, lhi, rlo, rhi in opcodes:
581 len1 = lhi - llo
584 len1 = lhi - llo
582 len2 = rhi - rlo
585 len2 = rhi - rlo
583 count = min(len1, len2)
586 count = min(len1, len2)
584 for i in xrange(count):
587 for i in xrange(count):
585 yield compline(type=type,
588 yield compline(type=type,
586 leftlineno=llo + i + 1,
589 leftlineno=llo + i + 1,
587 leftline=leftlines[llo + i],
590 leftline=leftlines[llo + i],
588 rightlineno=rlo + i + 1,
591 rightlineno=rlo + i + 1,
589 rightline=rightlines[rlo + i])
592 rightline=rightlines[rlo + i])
590 if len1 > len2:
593 if len1 > len2:
591 for i in xrange(llo + count, lhi):
594 for i in xrange(llo + count, lhi):
592 yield compline(type=type,
595 yield compline(type=type,
593 leftlineno=i + 1,
596 leftlineno=i + 1,
594 leftline=leftlines[i],
597 leftline=leftlines[i],
595 rightlineno=None,
598 rightlineno=None,
596 rightline=None)
599 rightline=None)
597 elif len2 > len1:
600 elif len2 > len1:
598 for i in xrange(rlo + count, rhi):
601 for i in xrange(rlo + count, rhi):
599 yield compline(type=type,
602 yield compline(type=type,
600 leftlineno=None,
603 leftlineno=None,
601 leftline=None,
604 leftline=None,
602 rightlineno=i + 1,
605 rightlineno=i + 1,
603 rightline=rightlines[i])
606 rightline=rightlines[i])
604
607
605 s = difflib.SequenceMatcher(None, leftlines, rightlines)
608 s = difflib.SequenceMatcher(None, leftlines, rightlines)
606 if context < 0:
609 if context < 0:
607 yield tmpl.generate('comparisonblock',
610 yield tmpl.generate('comparisonblock',
608 {'lines': getblock(s.get_opcodes())})
611 {'lines': getblock(s.get_opcodes())})
609 else:
612 else:
610 for oc in s.get_grouped_opcodes(n=context):
613 for oc in s.get_grouped_opcodes(n=context):
611 yield tmpl.generate('comparisonblock', {'lines': getblock(oc)})
614 yield tmpl.generate('comparisonblock', {'lines': getblock(oc)})
612
615
613 def diffstatgen(ctx, basectx):
616 def diffstatgen(ctx, basectx):
614 '''Generator function that provides the diffstat data.'''
617 '''Generator function that provides the diffstat data.'''
615
618
616 stats = patch.diffstatdata(
619 stats = patch.diffstatdata(
617 util.iterlines(ctx.diff(basectx, noprefix=False)))
620 util.iterlines(ctx.diff(basectx, noprefix=False)))
618 maxname, maxtotal, addtotal, removetotal, binary = patch.diffstatsum(stats)
621 maxname, maxtotal, addtotal, removetotal, binary = patch.diffstatsum(stats)
619 while True:
622 while True:
620 yield stats, maxname, maxtotal, addtotal, removetotal, binary
623 yield stats, maxname, maxtotal, addtotal, removetotal, binary
621
624
622 def diffsummary(statgen):
625 def diffsummary(statgen):
623 '''Return a short summary of the diff.'''
626 '''Return a short summary of the diff.'''
624
627
625 stats, maxname, maxtotal, addtotal, removetotal, binary = next(statgen)
628 stats, maxname, maxtotal, addtotal, removetotal, binary = next(statgen)
626 return _(' %d files changed, %d insertions(+), %d deletions(-)\n') % (
629 return _(' %d files changed, %d insertions(+), %d deletions(-)\n') % (
627 len(stats), addtotal, removetotal)
630 len(stats), addtotal, removetotal)
628
631
629 def diffstat(tmpl, ctx, statgen, parity):
632 def diffstat(tmpl, ctx, statgen, parity):
630 '''Return a diffstat template for each file in the diff.'''
633 '''Return a diffstat template for each file in the diff.'''
631
634
632 stats, maxname, maxtotal, addtotal, removetotal, binary = next(statgen)
635 stats, maxname, maxtotal, addtotal, removetotal, binary = next(statgen)
633 files = ctx.files()
636 files = ctx.files()
634
637
635 def pct(i):
638 def pct(i):
636 if maxtotal == 0:
639 if maxtotal == 0:
637 return 0
640 return 0
638 return (float(i) / maxtotal) * 100
641 return (float(i) / maxtotal) * 100
639
642
640 fileno = 0
643 fileno = 0
641 for filename, adds, removes, isbinary in stats:
644 for filename, adds, removes, isbinary in stats:
642 template = 'diffstatlink' if filename in files else 'diffstatnolink'
645 template = 'diffstatlink' if filename in files else 'diffstatnolink'
643 total = adds + removes
646 total = adds + removes
644 fileno += 1
647 fileno += 1
645 yield tmpl.generate(template, {
648 yield tmpl.generate(template, {
646 'node': ctx.hex(),
649 'node': ctx.hex(),
647 'file': filename,
650 'file': filename,
648 'fileno': fileno,
651 'fileno': fileno,
649 'total': total,
652 'total': total,
650 'addpct': pct(adds),
653 'addpct': pct(adds),
651 'removepct': pct(removes),
654 'removepct': pct(removes),
652 'parity': next(parity),
655 'parity': next(parity),
653 })
656 })
654
657
655 class sessionvars(templateutil.wrapped):
658 class sessionvars(templateutil.wrapped):
656 def __init__(self, vars, start='?'):
659 def __init__(self, vars, start='?'):
657 self._start = start
660 self._start = start
658 self._vars = vars
661 self._vars = vars
659
662
660 def __getitem__(self, key):
663 def __getitem__(self, key):
661 return self._vars[key]
664 return self._vars[key]
662
665
663 def __setitem__(self, key, value):
666 def __setitem__(self, key, value):
664 self._vars[key] = value
667 self._vars[key] = value
665
668
666 def __copy__(self):
669 def __copy__(self):
667 return sessionvars(copy.copy(self._vars), self._start)
670 return sessionvars(copy.copy(self._vars), self._start)
668
671
669 def itermaps(self, context):
672 def itermaps(self, context):
670 separator = self._start
673 separator = self._start
671 for key, value in sorted(self._vars.iteritems()):
674 for key, value in sorted(self._vars.iteritems()):
672 yield {'name': key,
675 yield {'name': key,
673 'value': pycompat.bytestr(value),
676 'value': pycompat.bytestr(value),
674 'separator': separator,
677 'separator': separator,
675 }
678 }
676 separator = '&'
679 separator = '&'
677
680
678 def join(self, context, mapping, sep):
681 def join(self, context, mapping, sep):
679 # could be '{separator}{name}={value|urlescape}'
682 # could be '{separator}{name}={value|urlescape}'
680 raise error.ParseError(_('not displayable without template'))
683 raise error.ParseError(_('not displayable without template'))
681
684
682 def show(self, context, mapping):
685 def show(self, context, mapping):
683 return self.join(context, '')
686 return self.join(context, '')
684
687
685 def tovalue(self, context, mapping):
688 def tovalue(self, context, mapping):
686 return self._vars
689 return self._vars
687
690
688 class wsgiui(uimod.ui):
691 class wsgiui(uimod.ui):
689 # default termwidth breaks under mod_wsgi
692 # default termwidth breaks under mod_wsgi
690 def termwidth(self):
693 def termwidth(self):
691 return 80
694 return 80
692
695
693 def getwebsubs(repo):
696 def getwebsubs(repo):
694 websubtable = []
697 websubtable = []
695 websubdefs = repo.ui.configitems('websub')
698 websubdefs = repo.ui.configitems('websub')
696 # we must maintain interhg backwards compatibility
699 # we must maintain interhg backwards compatibility
697 websubdefs += repo.ui.configitems('interhg')
700 websubdefs += repo.ui.configitems('interhg')
698 for key, pattern in websubdefs:
701 for key, pattern in websubdefs:
699 # grab the delimiter from the character after the "s"
702 # grab the delimiter from the character after the "s"
700 unesc = pattern[1:2]
703 unesc = pattern[1:2]
701 delim = re.escape(unesc)
704 delim = re.escape(unesc)
702
705
703 # identify portions of the pattern, taking care to avoid escaped
706 # identify portions of the pattern, taking care to avoid escaped
704 # delimiters. the replace format and flags are optional, but
707 # delimiters. the replace format and flags are optional, but
705 # delimiters are required.
708 # delimiters are required.
706 match = re.match(
709 match = re.match(
707 br'^s%s(.+)(?:(?<=\\\\)|(?<!\\))%s(.*)%s([ilmsux])*$'
710 br'^s%s(.+)(?:(?<=\\\\)|(?<!\\))%s(.*)%s([ilmsux])*$'
708 % (delim, delim, delim), pattern)
711 % (delim, delim, delim), pattern)
709 if not match:
712 if not match:
710 repo.ui.warn(_("websub: invalid pattern for %s: %s\n")
713 repo.ui.warn(_("websub: invalid pattern for %s: %s\n")
711 % (key, pattern))
714 % (key, pattern))
712 continue
715 continue
713
716
714 # we need to unescape the delimiter for regexp and format
717 # we need to unescape the delimiter for regexp and format
715 delim_re = re.compile(br'(?<!\\)\\%s' % delim)
718 delim_re = re.compile(br'(?<!\\)\\%s' % delim)
716 regexp = delim_re.sub(unesc, match.group(1))
719 regexp = delim_re.sub(unesc, match.group(1))
717 format = delim_re.sub(unesc, match.group(2))
720 format = delim_re.sub(unesc, match.group(2))
718
721
719 # the pattern allows for 6 regexp flags, so set them if necessary
722 # the pattern allows for 6 regexp flags, so set them if necessary
720 flagin = match.group(3)
723 flagin = match.group(3)
721 flags = 0
724 flags = 0
722 if flagin:
725 if flagin:
723 for flag in flagin.upper():
726 for flag in flagin.upper():
724 flags |= re.__dict__[flag]
727 flags |= re.__dict__[flag]
725
728
726 try:
729 try:
727 regexp = re.compile(regexp, flags)
730 regexp = re.compile(regexp, flags)
728 websubtable.append((regexp, format))
731 websubtable.append((regexp, format))
729 except re.error:
732 except re.error:
730 repo.ui.warn(_("websub: invalid regexp for %s: %s\n")
733 repo.ui.warn(_("websub: invalid regexp for %s: %s\n")
731 % (key, regexp))
734 % (key, regexp))
732 return websubtable
735 return websubtable
733
736
734 def getgraphnode(repo, ctx):
737 def getgraphnode(repo, ctx):
735 return (templatekw.getgraphnodecurrent(repo, ctx) +
738 return (templatekw.getgraphnodecurrent(repo, ctx) +
736 templatekw.getgraphnodesymbol(ctx))
739 templatekw.getgraphnodesymbol(ctx))
General Comments 0
You need to be logged in to leave comments. Login now