Compare commits

..

No commits in common. "master" and "0.13" have entirely different histories.
master ... 0.13

22 changed files with 648 additions and 1220 deletions

3
.gitignore vendored
View File

@ -1,4 +1,3 @@
*.pyc
__pycache__
.*
!.gitignore
.*.swp

56
README Normal file
View File

@ -0,0 +1,56 @@
git-arr - A git repository browser
----------------------------------
git-arr is a git repository browser that can generate static HTML instead of
having to run dynamically.
It is smaller, with less features and a different set of tradeoffs than
other similar software, so if you're looking for a robust and featureful git
browser, please look at gitweb or cgit instead.
However, if you want to generate static HTML at the expense of features, then
it's probably going to be useful.
It's open source under the MIT licence, please see the LICENSE file for more
information.
Getting started
---------------
You will need Python, and the bottle.py framework (the package is usually
called python-bottle in most distributions).
If pygments is available, it will be used for syntax highlighting, otherwise
everything will work fine, just in black and white.
First, create a configuration file for your repositories. You can start by
copying sample.conf, which has the list of the available options.
Then, to generate the output to "/var/www/git-arr/" directory, run:
$ ./git-arr --config config.conf generate --output /var/www/git-arr/
That's it!
The first time you generate, depending on the size of your repositories, it
can take some time. Subsequent runs should take less time, as it is smart
enough to only generate what has changed.
You can also use git-arr dynamically, although it's not its intended mode of
use, by running:
$ ./git-arr --config config.conf serve
That can be useful when making changes to the software itself.
Contact
-------
If you want to report bugs, send patches, or have any questions or comments,
just let me know at albertito@blitiri.com.ar.

View File

@ -1,65 +0,0 @@
# git-arr - A git repository browser
[git-arr] is a [git] repository browser that can generate static HTML.
It is smaller, with less features and a different set of tradeoffs than
other similar software, so if you're looking for a robust and featureful git
browser, please look at [gitweb] or [cgit] instead.
However, if you want to generate static HTML at the expense of features, then
it's probably going to be useful.
It's open source under the MIT licence, please see the `LICENSE` file for more
information.
[git-arr]: https://blitiri.com.ar/p/git-arr/
[git]: https://git-scm.com/
[gitweb]: https://git-scm.com/docs/gitweb
[cgit]: https://git.zx2c4.com/cgit/about/
## Getting started
You will need [Python 3], and the [bottle.py] framework (the package is usually
called `python3-bottle` in most distributions).
If [pygments] is available, it will be used for syntax highlighting, otherwise
everything will work fine, just in black and white.
First, create a configuration file for your repositories. You can start by
copying `sample.conf`, which has the list of the available options.
Then, to generate the output to `/var/www/git-arr/` directory, run:
```sh
./git-arr --config config.conf generate --output /var/www/git-arr/
```
That's it!
The first time you generate, depending on the size of your repositories, it
can take some time. Subsequent runs should take less time, as it is smart
enough to only generate what has changed.
You can also use git-arr dynamically, although it's not its intended mode of
use, by running:
```
./git-arr --config config.conf serve
```
That can be useful when making changes to the software itself.
[Python 3]: https://www.python.org/
[bottle.py]: https://bottlepy.org/
[pygments]: https://pygments.org/
## Contact
If you want to report bugs, send patches, or have any questions or comments,
just let me know at albertito@blitiri.com.ar.

13
TODO Normal file
View File

@ -0,0 +1,13 @@
In no particular order.
- Atom/RSS.
- Nicer diff:
- Better stat section, with nicer handling of filenames. We should switch to
--patch-with-raw and parse from that.
- Nicer output, don't use pygments but do our own.
- Anchors in diff sections so we can link to them.
- Short symlinks to commits, with configurable length.
- Handle symlinks properly.
- "X hours ago" via javascript (only if it's not too ugly).

464
git-arr
View File

@ -1,17 +1,21 @@
#!/usr/bin/env python3
#!/usr/bin/env python
"""
git-arr: A git web html generator.
"""
import configparser
from __future__ import print_function
import sys
import os
import math
import optparse
import os
import re
import sys
from typing import Union
import bottle # type: ignore
try:
import configparser
except ImportError:
import ConfigParser as configparser
import bottle
import git
import utils
@ -21,13 +25,12 @@ import utils
# Note this assumes they live next to the executable, and that is not a good
# assumption; but it's good enough for now.
bottle.TEMPLATE_PATH.insert(
0, os.path.abspath(os.path.dirname(sys.argv[0])) + "/views/"
)
0, os.path.abspath(os.path.dirname(sys.argv[0])) + '/views/')
# The path to our static files.
# Note this assumes they live next to the executable, and that is not a good
# assumption; but it's good enough for now.
static_path = os.path.abspath(os.path.dirname(sys.argv[0])) + "/static/"
static_path = os.path.abspath(os.path.dirname(sys.argv[0])) + '/static/'
# The list of repositories is a global variable for convenience. It will be
@ -42,100 +45,83 @@ def load_config(path):
as configured.
"""
defaults = {
"tree": "yes",
"rootdiff": "yes",
"desc": "",
"recursive": "no",
"prefix": "",
"commits_in_summary": "10",
"commits_per_page": "50",
"max_pages": "250",
"web_url": "",
"web_url_file": "web_url",
"git_url": "",
"git_url_file": "cloneurl",
"embed_markdown": "yes",
"embed_images": "no",
"ignore": "",
"generate_patch": "yes",
'tree': 'yes',
'desc': '',
'recursive': 'no',
'commits_in_summary': '10',
'commits_per_page': '50',
'max_pages': '5',
'web_url': '',
'web_url_file': 'web_url',
'git_url': '',
'git_url_file': 'cloneurl',
'embed_markdown': 'yes',
'embed_images': 'no',
}
config = configparser.ConfigParser(defaults)
config = configparser.SafeConfigParser(defaults)
config.read(path)
# Do a first pass for general sanity checking and recursive expansion.
for s in config.sections():
if config.getboolean(s, "recursive"):
root = config.get(s, "path")
prefix = config.get(s, "prefix")
for path in os.listdir(root):
fullpath = find_git_dir(root + "/" + path)
if config.getboolean(s, 'recursive'):
for path in os.listdir(config.get(s, 'path')):
fullpath = find_git_dir(config.get(s, 'path') + '/' + path)
if not fullpath:
continue
if os.path.exists(fullpath + "/disable_gitweb"):
if os.path.exists(fullpath + '/disable_gitweb'):
continue
section = prefix + path
if config.has_section(section):
if config.has_section(path):
continue
config.add_section(section)
for opt, value in config.items(s, raw=True):
config.set(section, opt, value)
config.add_section(path)
for opt, value in config.items(s, raw = True):
config.set(path, opt, value)
config.set(section, "path", fullpath)
config.set(section, "recursive", "no")
config.set(path, 'path', fullpath)
config.set(path, 'recursive', 'no')
# This recursive section is no longer useful.
config.remove_section(s)
for s in config.sections():
if config.get(s, "ignore") and re.search(config.get(s, "ignore"), s):
continue
fullpath = find_git_dir(config.get(s, "path"))
fullpath = find_git_dir(config.get(s, 'path'))
if not fullpath:
raise ValueError(
"%s: path %s is not a valid git repository"
% (s, config.get(s, "path"))
)
'%s: path %s is not a valid git repository' % (
s, config.get(s, 'path')))
config.set(s, "path", fullpath)
config.set(s, "name", s)
config.set(s, 'path', fullpath)
config.set(s, 'name', s)
desc = config.get(s, "desc")
if not desc and os.path.exists(fullpath + "/description"):
desc = open(fullpath + "/description").read().strip()
desc = config.get(s, 'desc')
if not desc and os.path.exists(fullpath + '/description'):
desc = open(fullpath + '/description').read().strip()
r = git.Repo(fullpath, name=s)
r = git.Repo(fullpath, name = s)
r.info.desc = desc
r.info.commits_in_summary = config.getint(s, "commits_in_summary")
r.info.commits_per_page = config.getint(s, "commits_per_page")
r.info.max_pages = config.getint(s, "max_pages")
if r.info.max_pages <= 0:
r.info.max_pages = sys.maxsize
r.info.generate_tree = config.getboolean(s, "tree")
r.info.root_diff = config.getboolean(s, "rootdiff")
r.info.generate_patch = config.getboolean(s, "generate_patch")
r.info.commits_in_summary = config.getint(s, 'commits_in_summary')
r.info.commits_per_page = config.getint(s, 'commits_per_page')
r.info.max_pages = config.getint(s, 'max_pages')
r.info.generate_tree = config.getboolean(s, 'tree')
r.info.web_url = config.get(s, "web_url")
web_url_file = fullpath + "/" + config.get(s, "web_url_file")
r.info.web_url = config.get(s, 'web_url')
web_url_file = fullpath + '/' + config.get(s, 'web_url_file')
if not r.info.web_url and os.path.isfile(web_url_file):
r.info.web_url = open(web_url_file).read()
r.info.git_url = config.get(s, "git_url")
git_url_file = fullpath + "/" + config.get(s, "git_url_file")
r.info.git_url = config.get(s, 'git_url')
git_url_file = fullpath + '/' + config.get(s, 'git_url_file')
if not r.info.git_url and os.path.isfile(git_url_file):
r.info.git_url = open(git_url_file).read()
r.info.embed_markdown = config.getboolean(s, "embed_markdown")
r.info.embed_images = config.getboolean(s, "embed_images")
r.info.embed_markdown = config.getboolean(s, 'embed_markdown')
r.info.embed_images = config.getboolean(s, 'embed_images')
repos[r.name] = r
def find_git_dir(path):
"""Returns the path to the git directory for the given repository.
@ -145,26 +131,25 @@ def find_git_dir(path):
An empty string is returned if the given path is not a valid repository.
"""
def check(p):
"""A dirty check for whether this is a git dir or not."""
# Note silent stderr because we expect this to fail and don't want the
# noise; and also we strip the final \n from the output.
return git.run_git(
p, ["rev-parse", "--git-dir"], silent_stderr=True
).read()[:-1]
return git.run_git(p,
['rev-parse', '--git-dir'],
silent_stderr = True).read()[:-1]
for p in [path, path + "/.git"]:
for p in [ path, path + '/.git' ]:
if check(p):
return p
return ""
return ''
def repo_filter(unused_conf):
"""Bottle route filter for repos."""
# TODO: consider allowing /, which is tricky.
regexp = r"[\w\.~-]+"
regexp = r'[\w\.~-]+'
def to_python(s):
"""Return the corresponding Python object."""
@ -178,9 +163,8 @@ def repo_filter(unused_conf):
return regexp, to_python, to_url
app = bottle.Bottle()
app.router.add_filter("repo", repo_filter)
app.router.add_filter('repo', repo_filter)
bottle.app.push(app)
@ -191,18 +175,16 @@ def with_utils(f):
templates.
"""
utilities = {
"shorten": utils.shorten,
"can_colorize": utils.can_colorize,
"colorize_diff": utils.colorize_diff,
"colorize_blob": utils.colorize_blob,
"can_markdown": utils.can_markdown,
"markdown_blob": utils.markdown_blob,
"can_embed_image": utils.can_embed_image,
"embed_image_blob": utils.embed_image_blob,
"is_binary": utils.is_binary,
"hexdump": utils.hexdump,
"abort": bottle.abort,
"smstr": git.smstr,
'shorten': utils.shorten,
'can_colorize': utils.can_colorize,
'colorize_diff': utils.colorize_diff,
'colorize_blob': utils.colorize_blob,
'can_markdown': utils.can_markdown,
'markdown_blob': utils.markdown_blob,
'can_embed_image': utils.can_embed_image,
'embed_image_blob': utils.embed_image_blob,
'abort': bottle.abort,
'smstr': git.smstr,
}
def wrapped(*args, **kwargs):
@ -216,108 +198,77 @@ def with_utils(f):
return wrapped
@bottle.route("/")
@bottle.view("index")
@bottle.route('/')
@bottle.view('index')
@with_utils
def index():
return dict(repos=repos)
return dict(repos = repos)
@bottle.route("/r/<repo:repo>/")
@bottle.view("summary")
@bottle.route('/r/<repo:repo>/')
@bottle.view('summary')
@with_utils
def summary(repo):
return dict(repo=repo)
return dict(repo = repo)
@bottle.route('/r/<repo:repo>/b/<bname>/')
@bottle.route('/r/<repo:repo>/b/<bname>/<offset:int>.html')
@bottle.view('branch')
@with_utils
def branch(repo, bname, offset = 0):
return dict(repo = repo.new_in_branch(bname), offset = offset)
@bottle.route("/r/<repo:repo>/c/<cid:re:[0-9a-f]{5,40}>/")
@bottle.view("commit")
@bottle.route('/r/<repo:repo>/c/<cid:re:[0-9a-z]{5,40}>/')
@bottle.view('commit')
@with_utils
def commit(repo, cid):
c = repo.commit(cid)
if not c:
bottle.abort(404, "Commit not found")
bottle.abort(404, 'Commit not found')
return dict(repo=repo, c=c)
return dict(repo = repo, c=c)
@bottle.route("/r/<repo:repo>/c/<cid:re:[0-9a-f]{5,40}>.patch")
@bottle.view(
"patch",
# Output is text/plain, don't do HTML escaping.
template_settings={"noescape": True},
)
def patch(repo, cid):
c = repo.commit(cid)
if not c:
bottle.abort(404, "Commit not found")
bottle.response.content_type = "text/plain; charset=utf8"
return dict(repo=repo, c=c)
@bottle.route("/r/<repo:repo>/b/<bname:path>/t/f=<fname:path>.html")
@bottle.route(
"/r/<repo:repo>/b/<bname:path>/t/<dirname:path>/f=<fname:path>.html"
)
@bottle.view("blob")
@bottle.route('/r/<repo:repo>/b/<bname>/t/')
@bottle.route('/r/<repo:repo>/b/<bname>/t/<dirname:path>/')
@bottle.view('tree')
@with_utils
def blob(repo, bname, fname, dirname=""):
if dirname and not dirname.endswith("/"):
dirname = dirname + "/"
def tree(repo, bname, dirname = ''):
if dirname and not dirname.endswith('/'):
dirname = dirname + '/'
dirname = git.smstr.from_url(dirname)
r = repo.new_in_branch(bname)
return dict(repo = r, tree = r.tree(), dirname = dirname)
@bottle.route('/r/<repo:repo>/b/<bname>/t/f=<fname:path>.html')
@bottle.route('/r/<repo:repo>/b/<bname>/t/<dirname:path>/f=<fname:path>.html')
@bottle.view('blob')
@with_utils
def blob(repo, bname, fname, dirname = ''):
r = repo.new_in_branch(bname)
if dirname and not dirname.endswith('/'):
dirname = dirname + '/'
dirname = git.smstr.from_url(dirname)
fname = git.smstr.from_url(fname)
path = dirname.raw + fname.raw
# Handle backslash-escaped characters, which are not utf8.
# This matches the generated links from git.unquote().
path = path.encode("utf8").decode("unicode-escape").encode("latin1")
content = repo.blob(path, bname)
content = r.blob(path)
if content is None:
bottle.abort(404, "File %r not found in branch %s" % (path, bname))
return dict(
repo=repo, branch=bname, dirname=dirname, fname=fname, blob=content
)
return dict(repo = r, dirname = dirname, fname = fname, blob = content)
@bottle.route("/r/<repo:repo>/b/<bname:path>/t/")
@bottle.route("/r/<repo:repo>/b/<bname:path>/t/<dirname:path>/")
@bottle.view("tree")
@with_utils
def tree(repo, bname, dirname=""):
if dirname and not dirname.endswith("/"):
dirname = dirname + "/"
dirname = git.smstr.from_url(dirname)
return dict(
repo=repo, branch=bname, tree=repo.tree(bname), dirname=dirname
)
@bottle.route("/r/<repo:repo>/b/<bname:path>/")
@bottle.route("/r/<repo:repo>/b/<bname:path>/<offset:int>.html")
@bottle.view("branch")
@with_utils
def branch(repo, bname, offset=0):
return dict(repo=repo, branch=bname, offset=offset)
@bottle.route("/static/<path:path>")
@bottle.route('/static/<path:path>')
def static(path):
return bottle.static_file(path, root=static_path)
return bottle.static_file(path, root = static_path)
#
# Static HTML generation
#
def is_404(e):
"""True if e is an HTTPError with status 404, False otherwise."""
# We need this because older bottle.py versions put the status code in
@ -328,19 +279,17 @@ def is_404(e):
else:
return e.status_code == 404
def generate(output: str, only=None):
def generate(output, skip_index = False):
"""Generate static html to the output directory."""
def write_to(path: str, func_or_str, args=(), mtime=None):
path = output + "/" + path
def write_to(path, func_or_str, args = (), mtime = None):
path = output + '/' + path
dirname = os.path.dirname(path)
if not os.path.exists(dirname):
os.makedirs(dirname)
if mtime:
path_mtime: Union[float, int] = 0
path_mtime = 0
if os.path.exists(path):
path_mtime = os.stat(path).st_mtime
@ -360,7 +309,7 @@ def generate(output: str, only=None):
else:
# Otherwise, be lazy if we were given a function to run, or write
# always if they gave us a string.
if isinstance(func_or_str, str):
if isinstance(func_or_str, (str, unicode)):
print(path)
s = func_or_str
else:
@ -369,184 +318,137 @@ def generate(output: str, only=None):
print(path)
s = func_or_str(*args)
open(path, "w").write(s)
open(path, 'w').write(s.encode('utf8', errors = 'xmlcharrefreplace'))
if mtime:
os.utime(path, (mtime, mtime))
def link(from_path, to_path):
from_path = output + "/" + from_path
from_path = output + '/' + from_path
if os.path.lexists(from_path):
return
print(from_path, "->", to_path)
print(from_path, '->', to_path)
os.symlink(to_path, from_path)
def write_tree(r: git.Repo, bn: str, mtime):
t: git.Tree = r.tree(bn)
def write_tree(r, bn, mtime):
t = r.tree(bn)
write_to("r/%s/b/%s/t/index.html" % (r.name, bn), tree, (r, bn), mtime)
write_to('r/%s/b/%s/t/index.html' % (r.name, bn),
tree, (r, bn), mtime)
for otype, oname, _ in t.ls("", recursive=True):
for otype, oname, _ in t.ls('', recursive = True):
# FIXME: bottle cannot route paths with '\n' so those are sadly
# expected to fail for now; we skip them.
if "\n" in oname.raw:
print("skipping file with \\n: %r" % (oname.raw))
if '\n' in oname.raw:
print('skipping file with \\n: %r' % (oname.raw))
continue
if otype == "blob":
if otype == 'blob':
dirname = git.smstr(os.path.dirname(oname.raw))
fname = git.smstr(os.path.basename(oname.raw))
write_to(
"r/%s/b/%s/t/%s%sf=%s.html"
% (
str(r.name),
str(bn),
dirname.raw,
"/" if dirname.raw else "",
fname.raw,
),
blob,
(r, bn, fname.url, dirname.url),
mtime,
)
'r/%s/b/%s/t/%s/f=%s.html' %
(str(r.name), str(bn), dirname.raw, fname.raw),
blob, (r, bn, fname.url, dirname.url), mtime)
else:
write_to(
"r/%s/b/%s/t/%s/index.html"
% (str(r.name), str(bn), oname.raw),
tree,
(r, bn, oname.url),
mtime,
)
write_to('r/%s/b/%s/t/%s/index.html' %
(str(r.name), str(bn), oname.raw),
tree, (r, bn, oname.url), mtime)
# Always generate the index, to keep the "last updated" time fresh.
write_to("index.html", index())
if not skip_index:
write_to('index.html', index())
# We can't call static() because it relies on HTTP headers.
read_f = lambda f: open(f).read()
write_to(
"static/git-arr.css",
read_f,
[static_path + "/git-arr.css"],
os.stat(static_path + "/git-arr.css").st_mtime,
)
write_to(
"static/git-arr.js",
read_f,
[static_path + "/git-arr.js"],
os.stat(static_path + "/git-arr.js").st_mtime,
)
write_to(
"static/syntax.css",
read_f,
[static_path + "/syntax.css"],
os.stat(static_path + "/syntax.css").st_mtime,
)
write_to('static/git-arr.css', read_f, [static_path + '/git-arr.css'],
os.stat(static_path + '/git-arr.css').st_mtime)
write_to('static/git-arr.js', read_f, [static_path + '/git-arr.js'],
os.stat(static_path + '/git-arr.js').st_mtime)
write_to('static/syntax.css', read_f, [static_path + '/syntax.css'],
os.stat(static_path + '/syntax.css').st_mtime)
rs = sorted(list(repos.values()), key=lambda r: r.name)
if only:
rs = [r for r in rs if r.name in only]
for r in rs:
write_to("r/%s/index.html" % r.name, summary(r))
for r in sorted(repos.values(), key = lambda r: r.name):
write_to('r/%s/index.html' % r.name, summary(r))
for bn in r.branch_names():
commit_count = 0
commit_ids = r.commit_ids(
"refs/heads/" + bn,
limit=r.info.commits_per_page * r.info.max_pages,
)
commit_ids = r.commit_ids('refs/heads/' + bn,
limit = r.info.commits_per_page * r.info.max_pages)
for cid in commit_ids:
write_to(
"r/%s/c/%s/index.html" % (r.name, cid), commit, (r, cid)
)
if r.info.generate_patch:
write_to(
"r/%s/c/%s.patch" % (r.name, cid), patch, (r, cid)
)
write_to('r/%s/c/%s/index.html' % (r.name, cid),
commit, (r, cid))
commit_count += 1
# To avoid regenerating files that have not changed, we will
# instruct write_to() to set their mtime to the branch's committer
# date, and then compare against it to decide whether or not to
# date, and then compare against it to decide wether or not to
# write.
branch_mtime = r.commit(bn).committer_date.epoch
nr_pages = int(
math.ceil(float(commit_count) / r.info.commits_per_page)
)
nr_pages = int(math.ceil(
float(commit_count) / r.info.commits_per_page))
nr_pages = min(nr_pages, r.info.max_pages)
for page in range(nr_pages):
write_to(
"r/%s/b/%s/%d.html" % (r.name, bn, page),
branch,
(r, bn, page),
branch_mtime,
)
write_to('r/%s/b/%s/%d.html' % (r.name, bn, page),
branch, (r, bn, page), branch_mtime)
link(
from_path="r/%s/b/%s/index.html" % (r.name, bn),
to_path="0.html",
)
link(from_path = 'r/%s/b/%s/index.html' % (r.name, bn),
to_path = '0.html')
if r.info.generate_tree:
write_tree(r, bn, branch_mtime)
for tag_name, obj_id in r.tags():
try:
write_to(
"r/%s/c/%s/index.html" % (r.name, obj_id),
commit,
(r, obj_id),
)
write_to('r/%s/c/%s/index.html' % (r.name, obj_id),
commit, (r, obj_id))
except bottle.HTTPError as e:
# Some repos can have tags pointing to non-commits. This
# happens in the Linux Kernel's v2.6.11, which points directly
# to a tree. Ignore them.
if is_404(e):
print("404 in tag %s (%s)" % (tag_name, obj_id))
print('404 in tag %s (%s)' % (tag_name, obj_id))
else:
raise
def main():
parser = optparse.OptionParser("usage: %prog [options] serve|generate")
parser.add_option(
"-c", "--config", metavar="FILE", help="configuration file"
)
parser.add_option(
"-o", "--output", metavar="DIR", help="output directory (for generate)"
)
parser.add_option(
"",
"--only",
metavar="REPO",
action="append",
default=[],
help="generate/serve only this repository",
)
parser = optparse.OptionParser('usage: %prog [options] serve|generate')
parser.add_option('-c', '--config', metavar = 'FILE',
help = 'configuration file')
parser.add_option('-o', '--output', metavar = 'DIR',
help = 'output directory (for generate)')
parser.add_option('', '--only', metavar = 'REPO', action = 'append',
default = [],
help = 'generate/serve only this repository')
opts, args = parser.parse_args()
if not opts.config:
parser.error("--config is mandatory")
parser.error('--config is mandatory')
try:
load_config(opts.config)
except (configparser.NoOptionError, ValueError) as e:
print("Error parsing config:", e)
print('Error parsing config:', e)
return
if not args:
parser.error("Must specify an action (serve|generate)")
parser.error('Must specify an action (serve|generate)')
if args[0] == "serve":
bottle.run(host="localhost", port=8008, reloader=True)
elif args[0] == "generate":
if opts.only:
for rname in list(repos.keys()):
if rname not in opts.only:
del repos[rname]
if args[0] == 'serve':
bottle.run(host = 'localhost', port = 8008, reloader = True)
elif args[0] == 'generate':
if not opts.output:
parser.error("Must specify --output")
generate(output=opts.output, only=opts.only)
parser.error('Must specify --output')
generate(output = opts.output,
skip_index = len(opts.only) > 0)
else:
parser.error("Unknown action %s" % args[0])
parser.error('Unknown action %s' % args[0])
if __name__ == "__main__":
if __name__ == '__main__':
main()

445
git.py
View File

@ -6,93 +6,97 @@ command line tool directly, so please be careful with using untrusted
parameters.
"""
import functools
import sys
import io
import subprocess
from collections import defaultdict
import email.utils
import datetime
import urllib.request, urllib.parse, urllib.error
from html import escape
from typing import Any, Dict, IO, Iterable, List, Optional, Tuple, Union
import urllib
from cgi import escape
# Path to the git binary.
GIT_BIN = "git"
class EncodeWrapper:
"""File-like wrapper that returns data utf8 encoded."""
def __init__(self, fd, encoding = 'utf8', errors = 'replace'):
self.fd = fd
self.encoding = encoding
self.errors = errors
def run_git(
repo_path: str, params, stdin: bytes = None, silent_stderr=False, raw=False
) -> Union[IO[str], IO[bytes]]:
def __iter__(self):
for line in self.fd:
yield line.decode(self.encoding, errors = self.errors)
def read(self):
"""Returns the whole content."""
s = self.fd.read()
return s.decode(self.encoding, errors = self.errors)
def readline(self):
"""Returns a single line."""
s = self.fd.readline()
return s.decode(self.encoding, errors = self.errors)
def run_git(repo_path, params, stdin = None, silent_stderr = False):
"""Invokes git with the given parameters.
This function invokes git with the given parameters, and returns a
file-like object with the output (from a pipe).
"""
params = [GIT_BIN, "--git-dir=%s" % repo_path] + list(params)
params = [GIT_BIN, '--git-dir=%s' % repo_path] + list(params)
stderr = None
if silent_stderr:
stderr = subprocess.PIPE
if not stdin:
p = subprocess.Popen(
params, stdin=None, stdout=subprocess.PIPE, stderr=stderr
)
p = subprocess.Popen(params,
stdin = None, stdout = subprocess.PIPE, stderr = stderr)
else:
p = subprocess.Popen(
params,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=stderr,
)
assert p.stdin is not None
p = subprocess.Popen(params,
stdin = subprocess.PIPE, stdout = subprocess.PIPE,
stderr = stderr)
p.stdin.write(stdin)
p.stdin.close()
assert p.stdout is not None
if raw:
return p.stdout
return io.TextIOWrapper(
p.stdout, encoding="utf8", errors="backslashreplace"
)
# We need to wrap stdout if we want to decode it as utf8, subprocess
# doesn't support us telling it the encoding.
if sys.version_info.major == 3:
return io.TextIOWrapper(p.stdout, encoding = 'utf8',
errors = 'replace')
else:
return EncodeWrapper(p.stdout)
class GitCommand(object):
class GitCommand (object):
"""Convenient way of invoking git."""
def __init__(self, path: str, cmd: str):
def __init__(self, path, cmd, *args, **kwargs):
self._override = True
self._path = path
self._cmd = cmd
self._args: List[str] = []
self._kwargs: Dict[str, str] = {}
self._stdin_buf: Optional[bytes] = None
self._raw = False
self._args = list(args)
self._kwargs = {}
self._stdin_buf = None
self._override = False
for k, v in kwargs:
self.__setattr__(k, v)
def __setattr__(self, k, v):
if k == "_override" or self._override:
if k == '_override' or self._override:
self.__dict__[k] = v
return
k = k.replace("_", "-")
k = k.replace('_', '-')
self._kwargs[k] = v
def arg(self, a: str):
def arg(self, a):
"""Adds an argument."""
self._args.append(a)
def raw(self, b: bool):
"""Request raw rather than utf8-encoded command output."""
self._override = True
self._raw = b
self._override = False
def stdin(self, s: bytes):
def stdin(self, s):
"""Sets the contents we will send in stdin."""
self._override = True
self._stdin_buf = s
@ -102,37 +106,46 @@ class GitCommand(object):
"""Runs the git command."""
params = [self._cmd]
for k, v in list(self._kwargs.items()):
dash = "--" if len(k) > 1 else "-"
for k, v in self._kwargs.items():
dash = '--' if len(k) > 1 else '-'
if v is None:
params.append("%s%s" % (dash, k))
params.append('%s%s' % (dash, k))
else:
params.append("%s%s=%s" % (dash, k, str(v)))
params.append('%s%s=%s' % (dash, k, str(v)))
params.extend(self._args)
return run_git(self._path, params, self._stdin_buf, raw=self._raw)
return run_git(self._path, params, self._stdin_buf)
class SimpleNamespace(object):
class SimpleNamespace (object):
"""An entirely flexible object, which provides a convenient namespace."""
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
class smstr:
"""A "smart" string, containing many representations for ease of use."""
"""A "smart" string, containing many representations for ease of use.
raw: str # string, probably utf8-encoded, good enough to show.
url: str # escaped for safe embedding in URLs (not human-readable).
html: str # HTML-embeddable representation.
def __init__(self, s: str):
self.raw = s
self.url = urllib.request.pathname2url(s)
This is a string class that contains:
.raw -> raw string, authoritative source.
.unicode -> unicode representation, may not be perfect if .raw is not
proper utf8 but should be good enough to show.
.url -> escaped for safe embedding in URLs, can be not quite
readable.
.html -> an HTML-embeddable representation.
"""
def __init__(self, raw):
if not isinstance(raw, str):
raise TypeError("The raw string must be instance of 'str'")
self.raw = raw
self.unicode = raw.decode('utf8', errors = 'replace')
self.url = urllib.pathname2url(raw)
self.html = self._to_html()
def __cmp__(self, other):
return cmp(self.raw, other.raw)
# Note we don't define __repr__() or __str__() to prevent accidental
# misuse. It does mean that some uses become more annoying, so it's a
# tradeoff that may change in the future.
@ -140,11 +153,11 @@ class smstr:
@staticmethod
def from_url(url):
"""Returns an smstr() instance from an url-encoded string."""
return smstr(urllib.request.url2pathname(url))
return smstr(urllib.url2pathname(url))
def split(self, sep):
"""Like str.split()."""
return [smstr(s) for s in self.raw.split(sep)]
return [ smstr(s) for s in self.raw.split(sep) ]
def __add__(self, other):
if isinstance(other, smstr):
@ -153,10 +166,10 @@ class smstr:
def _to_html(self):
"""Returns an html representation of the unicode string."""
html = ""
for c in escape(self.raw):
if c in "\t\r\n\r\f\a\b\v\0":
esc_c = c.encode("unicode-escape").decode("utf8")
html = u''
for c in escape(self.unicode):
if c in '\t\r\n\r\f\a\b\v\0':
esc_c = c.encode('ascii').encode('string_escape')
html += '<span class="ctrlchr">%s</span>' % esc_c
else:
html += c
@ -164,26 +177,17 @@ class smstr:
return html
def unquote(s: str):
def unquote(s):
"""Git can return quoted file names, unquote them. Always return a str."""
if not (s[0] == '"' and s[-1] == '"'):
# Unquoted strings are always safe, no need to mess with them
# Unquoted strings are always safe, no need to mess with them; just
# make sure we return str.
s = s.encode('ascii')
return s
# The string will be of the form `"<escaped>"`, where <escaped> is a
# backslash-escaped representation of the name of the file.
# Examples: "with\ttwo\ttabs" , "\303\261aca-utf8", "\361aca-latin1"
# Get rid of the quotes, we never want them in the output.
s = s[1:-1]
# Un-escape the backslashes.
# latin1 is ok to use here because in Python it just maps the code points
# 0-255 to the bytes 0x-0xff, which is what we expect.
s = s.encode("latin1").decode("unicode-escape")
# Convert to utf8.
s = s.encode("latin1").decode("utf8", errors="backslashreplace")
# Get rid of the quotes, we never want them in the output, and convert to
# a raw string, un-escaping the backslashes.
s = s[1:-1].decode('string-escape')
return s
@ -191,19 +195,21 @@ def unquote(s: str):
class Repo:
"""A git repository."""
def __init__(self, path: str, name=None, info=None):
def __init__(self, path, branch = None, name = None, info = None):
self.path = path
self.branch = branch
# We don't need these, but provide them for the users' convenience.
self.name = name
self.info: Any = info or SimpleNamespace()
self.info = info or SimpleNamespace()
def cmd(self, cmd):
"""Returns a GitCommand() on our path."""
return GitCommand(self.path, cmd)
@functools.lru_cache
def _for_each_ref(self, pattern=None, sort=None, count=None):
def for_each_ref(self, pattern = None, sort = None, count = None):
"""Returns a list of references."""
cmd = self.cmd("for-each-ref")
cmd = self.cmd('for-each-ref')
if sort:
cmd.sort = sort
if count:
@ -211,66 +217,73 @@ class Repo:
if pattern:
cmd.arg(pattern)
refs = []
for l in cmd.run():
obj_id, obj_type, ref = l.split()
refs.append((obj_id, obj_type, ref))
return refs
yield obj_id, obj_type, ref
def branches(self, sort = '-authordate'):
"""Get the (name, obj_id) of the branches."""
refs = self.for_each_ref(pattern = 'refs/heads/', sort = sort)
for obj_id, _, ref in refs:
yield ref[len('refs/heads/'):], obj_id
@functools.cache
def branch_names(self):
"""Get the names of the branches."""
refs = self._for_each_ref(pattern="refs/heads/", sort="-authordate")
return [ref[len("refs/heads/") :] for _, _, ref in refs]
return ( name for name, _ in self.branches() )
@functools.cache
def tags(self, sort="-taggerdate"):
def tags(self, sort = '-taggerdate'):
"""Get the (name, obj_id) of the tags."""
refs = self._for_each_ref(pattern="refs/tags/", sort=sort)
return [(ref[len("refs/tags/") :], obj_id) for obj_id, _, ref in refs]
refs = self.for_each_ref(pattern = 'refs/tags/', sort = sort)
for obj_id, _, ref in refs:
yield ref[len('refs/tags/'):], obj_id
@functools.lru_cache
def commit_ids(self, ref, limit=None):
def tag_names(self):
"""Get the names of the tags."""
return ( name for name, _ in self.tags() )
def new_in_branch(self, branch):
"""Returns a new Repo, but on the specific branch."""
return Repo(self.path, branch = branch, name = self.name,
info = self.info)
def commit_ids(self, ref, limit = None):
"""Generate commit ids."""
cmd = self.cmd("rev-list")
cmd = self.cmd('rev-list')
if limit:
cmd.max_count = limit
cmd.arg(ref)
cmd.arg("--")
return [l.rstrip("\n") for l in cmd.run()]
for l in cmd.run():
yield l.rstrip('\n')
@functools.lru_cache
def commit(self, commit_id):
"""Return a single commit."""
cs = list(self.commits(commit_id, limit=1))
cs = list(self.commits(commit_id, limit = 1))
if len(cs) != 1:
return None
return cs[0]
@functools.lru_cache
def commits(self, ref, limit, offset=0):
def commits(self, ref, limit = None, offset = 0):
"""Generate commit objects for the ref."""
cmd = self.cmd("rev-list")
cmd = self.cmd('rev-list')
if limit:
cmd.max_count = limit + offset
cmd.header = None
cmd.arg(ref)
cmd.arg("--")
info_buffer = ""
info_buffer = ''
count = 0
commits = []
for l in cmd.run():
if "\0" in l:
pre, post = l.split("\0", 1)
if '\0' in l:
pre, post = l.split('\0', 1)
info_buffer += pre
count += 1
if count > offset:
commits.append(Commit.from_str(self, info_buffer))
yield Commit.from_str(self, info_buffer)
# Start over.
info_buffer = post
@ -280,19 +293,14 @@ class Repo:
if info_buffer:
count += 1
if count > offset:
commits.append(Commit.from_str(self, info_buffer))
yield Commit.from_str(self, info_buffer)
return commits
@functools.lru_cache
def diff(self, ref):
"""Return a Diff object for the ref."""
cmd = self.cmd("diff-tree")
cmd = self.cmd('diff-tree')
cmd.patch = None
cmd.numstat = None
cmd.find_renames = None
if self.info.root_diff:
cmd.root = None
# Note we intentionally do not use -z, as the filename is just for
# reference, and it is safer to let git do the escaping.
@ -300,72 +308,65 @@ class Repo:
return Diff.from_str(cmd.run())
@functools.lru_cache
def refs(self):
"""Return a dict of obj_id -> ref."""
cmd = self.cmd("show-ref")
cmd = self.cmd('show-ref')
cmd.dereference = None
r = defaultdict(list)
for l in cmd.run():
l = l.strip()
obj_id, ref = l.split(" ", 1)
obj_id, ref = l.split(' ', 1)
r[obj_id].append(ref)
return r
@functools.lru_cache
def tree(self, ref):
def tree(self, ref = None):
"""Returns a Tree instance for the given ref."""
if not ref:
ref = self.branch
return Tree(self, ref)
@functools.lru_cache
def blob(self, path, ref):
"""Returns a Blob instance for the given path."""
cmd = self.cmd("cat-file")
cmd.raw(True)
cmd.batch = "%(objectsize)"
def blob(self, path, ref = None, raw = False):
"""Returns the contents of the given path."""
if not ref:
ref = self.branch
cmd = self.cmd('cat-file')
cmd.batch = None
# Format: <ref>:<path>
# Construct it in binary since the path might not be utf8.
cmd.stdin(ref.encode("utf8") + b":" + path)
if isinstance(ref, unicode):
ref = ref.encode('utf8')
cmd.stdin('%s:%s' % (ref, path))
out = cmd.run()
head = out.readline()
if not head or head.strip().endswith(b"missing"):
if not head or head.strip().endswith('missing'):
return None
return Blob(out.read()[: int(head)])
# Raw option in case we need a binary blob and not a utf-8 encoded one.
if raw:
return out.fd.read()
return out.read()
@functools.cache
def last_commit_timestamp(self):
"""Return the timestamp of the last commit."""
refs = self._for_each_ref(
pattern="refs/heads/", sort="-committerdate", count=1
)
refs = self.for_each_ref(pattern = 'refs/heads/',
sort = '-committerdate', count = 1)
for obj_id, _, _ in refs:
commit = self.commit(obj_id)
return commit.committer_epoch
return -1
class Commit(object):
class Commit (object):
"""A git commit."""
def __init__(
self,
repo,
commit_id,
parents,
tree,
author,
author_epoch,
author_tz,
committer,
committer_epoch,
committer_tz,
message,
):
def __init__(self, repo,
commit_id, parents, tree,
author, author_epoch, author_tz,
committer, committer_epoch, committer_tz,
message):
self._repo = repo
self.id = commit_id
self.parents = parents
@ -378,30 +379,28 @@ class Commit(object):
self.committer_tz = committer_tz
self.message = message
self.author_name, self.author_email = email.utils.parseaddr(
self.author
)
self.author_name, self.author_email = \
email.utils.parseaddr(self.author)
self.committer_name, self.committer_email = email.utils.parseaddr(
self.committer
)
self.committer_name, self.committer_email = \
email.utils.parseaddr(self.committer)
self.subject, self.body = self.message.split("\n", 1)
self.subject, self.body = self.message.split('\n', 1)
self.author_date = Date(self.author_epoch, self.author_tz)
self.committer_date = Date(self.committer_epoch, self.committer_tz)
# Only get this lazily when we need it; most of the time it's not
# required by the caller.
self._diff = None
def __repr__(self):
return "<C %s p:%s a:%s s:%r>" % (
return '<C %s p:%s a:%s s:%r>' % (
self.id[:7],
",".join(p[:7] for p in self.parents),
','.join(p[:7] for p in self.parents),
self.author_email,
self.subject[:20],
)
self.subject[:20])
@property
def diff(self):
@ -413,68 +412,57 @@ class Commit(object):
@staticmethod
def from_str(repo, buf):
"""Parses git rev-list output, returns a commit object."""
if "\n\n" in buf:
if '\n\n' in buf:
# Header, commit message
header, raw_message = buf.split("\n\n", 1)
header, raw_message = buf.split('\n\n', 1)
else:
# Header only, no commit message
header, raw_message = buf.rstrip(), " "
header, raw_message = buf.rstrip(), ' '
header_lines = header.split("\n")
header_lines = header.split('\n')
commit_id = header_lines.pop(0)
header_dict = defaultdict(list)
for line in header_lines:
k, v = line.split(" ", 1)
k, v = line.split(' ', 1)
header_dict[k].append(v)
tree = header_dict["tree"][0]
parents = set(header_dict["parent"])
authorhdr = header_dict["author"][0]
author, author_epoch, author_tz = authorhdr.rsplit(" ", 2)
committerhdr = header_dict["committer"][0]
committer, committer_epoch, committer_tz = committerhdr.rsplit(" ", 2)
tree = header_dict['tree'][0]
parents = set(header_dict['parent'])
author, author_epoch, author_tz = \
header_dict['author'][0].rsplit(' ', 2)
committer, committer_epoch, committer_tz = \
header_dict['committer'][0].rsplit(' ', 2)
# Remove the first four spaces from the message's lines.
message = ""
for line in raw_message.split("\n"):
message += line[4:] + "\n"
return Commit(
repo,
commit_id=commit_id,
tree=tree,
parents=parents,
author=author,
author_epoch=author_epoch,
author_tz=author_tz,
committer=committer,
committer_epoch=committer_epoch,
committer_tz=committer_tz,
message=message,
)
message = ''
for line in raw_message.split('\n'):
message += line[4:] + '\n'
return Commit(repo,
commit_id = commit_id, tree = tree, parents = parents,
author = author,
author_epoch = author_epoch, author_tz = author_tz,
committer = committer,
committer_epoch = committer_epoch, committer_tz = committer_tz,
message = message)
class Date:
"""Handy representation for a datetime from git."""
def __init__(self, epoch, tz):
self.epoch = int(epoch)
self.tz = tz
self.utc = datetime.datetime.utcfromtimestamp(self.epoch)
self.utc = datetime.datetime.fromtimestamp(self.epoch)
self.tz_sec_offset_min = int(tz[1:3]) * 60 + int(tz[4:])
if tz[0] == "-":
if tz[0] == '-':
self.tz_sec_offset_min = -self.tz_sec_offset_min
self.local = self.utc + datetime.timedelta(
minutes=self.tz_sec_offset_min
)
minutes = self.tz_sec_offset_min)
self.str = self.utc.strftime("%a, %d %b %Y %H:%M:%S +0000 ")
self.str += "(%s %s)" % (self.local.strftime("%H:%M"), self.tz)
self.str = self.utc.strftime('%a, %d %b %Y %H:%M:%S +0000 ')
self.str += '(%s %s)' % (self.local.strftime('%H:%M'), self.tz)
def __str__(self):
return self.str
@ -482,7 +470,6 @@ class Date:
class Diff:
"""A diff between two trees."""
def __init__(self, ref, changes, body):
"""Constructor.
@ -502,82 +489,60 @@ class Diff:
ref_id = next(lines)
except StopIteration:
# No diff; this can happen in merges without conflicts.
return Diff(None, [], "")
return Diff(None, [], '')
# First, --numstat information.
changes = []
l = next(lines)
while l != "\n":
l = l.rstrip("\n")
added, deleted, fname = l.split("\t", 2)
added = added.replace("-", "0")
deleted = deleted.replace("-", "0")
while l != '\n':
l = l.rstrip('\n')
added, deleted, fname = l.split('\t', 2)
added = added.replace('-', '0')
deleted = deleted.replace('-', '0')
fname = smstr(unquote(fname))
changes.append((int(added), int(deleted), fname))
l = next(lines)
# And now the diff body. We just store as-is, we don't really care for
# the contents.
body = "".join(lines)
body = ''.join(lines)
return Diff(ref_id, changes, body)
class Tree:
"""A git tree."""
""" A git tree."""
def __init__(self, repo: Repo, ref: str):
def __init__(self, repo, ref):
self.repo = repo
self.ref = ref
@functools.lru_cache
def ls(
self, path, recursive=False
) -> Iterable[Tuple[str, smstr, Optional[int]]]:
def ls(self, path, recursive = False):
"""Generates (type, name, size) for each file in path."""
cmd = self.repo.cmd("ls-tree")
cmd = self.repo.cmd('ls-tree')
cmd.long = None
if recursive:
cmd.r = None
cmd.t = None
cmd.arg(self.ref)
if not path:
cmd.arg(".")
else:
cmd.arg(path)
files = []
for l in cmd.run():
_mode, otype, _oid, size, name = l.split(None, 4)
if size == "-":
if size == '-':
size = None
else:
size = int(size)
# Remove the quoting (if any); will always give us a str.
name = unquote(name.strip("\n"))
name = unquote(name.strip('\n'))
# Strip the leading path, the caller knows it and it's often
# easier to work with this way.
name = name[len(path) :]
name = name[len(path):]
# We use a smart string for the name, as it's often tricky to
# manipulate otherwise.
files.append((otype, smstr(name), size))
yield otype, smstr(name), size
return files
class Blob:
"""A git blob."""
def __init__(self, raw_content: bytes):
self.raw_content = raw_content
self._utf8_content = None
@property
def utf8_content(self):
if not self._utf8_content:
self._utf8_content = self.raw_content.decode("utf8", "replace")
return self._utf8_content

View File

@ -1,3 +0,0 @@
[tool.black]
line-length = 79
include = "(git-arr|git.py|utils.py)$"

View File

@ -11,15 +11,6 @@ path = /srv/git/repo/
# Useful to disable an expensive operation in very large repositories.
#tree = yes
# Show a "creation event" diff for a root commit? (optional)
# For projects placed under revision control from inception, the root commit
# diff is often meaningful. However, in cases when a well established, large
# project is placed under revision control belatedly, the root commit may
# represent a lump import of the entire project, in which case such a
# "creation event" diff would likely be considered meaningless noise.
# Default: yes
#rootdiff = yes
# How many commits to show in the summary page (optional).
#commits_in_summary = 10
@ -28,9 +19,8 @@ path = /srv/git/repo/
# Maximum number of per-branch pages for static generation (optional).
# When generating static html, this is the maximum number of pages we will
# generate for each branch's commit listings. Zero (0) means unlimited.
# Default: 250
#max_pages = 250
# generate for each branch's commit listings.
#max_pages = 5
# Project website (optional).
# URL to the project's website. %(name)s will be replaced with the current
@ -58,22 +48,6 @@ path = /srv/git/repo/
# excluded.
#recursive = no
# Render Markdown blobs (*.md) formatted rather than as raw text? (optional)
# Requires 'markdown' module.
# Default: yes
#embed_markdown = yes
# Render image blobs graphically rather than as raw binary data? (optional)
# Default: no
#embed_images = no
# Ignore repositories that match this regular expression.
# Generally used with recursive = yes, to ignore repeated repositories (for
# example, if using symlinks).
# For ignoring specific repositories, putting a "disable_gitweb" is a much
# better alternative.
# Default: empty (don't ignore)
#ignore = \.git$
# Another repository, we don't generate a tree for it because it's too big.
[linux]

View File

@ -1,53 +1,17 @@
/*
* git-arr style sheet
*/
:root {
--body-bg: white;
--text-fg: black;
--h1-bg: #ddd;
--hr-bg: #e3e3e3;
--text-lowcontrast-fg: grey;
--a-fg: #800;
--a-explicit-fg: #038;
--table-hover-bg: #eee;
--head-bg: #88ff88;
--tag-bg: #ffff88;
--age-fg0: darkgreen;
--age-fg1: green;
--age-fg2: seagreen;
--diff-added-fg: green;
--diff-deleted-fg: red;
}
@media (prefers-color-scheme: dark) {
:root {
--body-bg: #121212;
--text-fg: #c9d1d9;
--h1-bg: #2f2f2f;
--hr-bg: #e3e3e3;
--text-lowcontrast-fg: grey;
--a-fg: #d4b263;
--a-explicit-fg: #44b4ec;
--table-hover-bg: #313131;
--head-bg: #020;
--tag-bg: #333000;
--age-fg0: #51a552;
--age-fg1: #468646;
--age-fg2: #2f722f;
--diff-added-fg: #00A000;
--diff-deleted-fg: #A00000;
}
}
body {
font-family: sans-serif;
font-size: small;
padding: 0 1em 1em 1em;
color: var(--text-fg);
background: var(--body-bg);
}
h1 {
background: var(--h1-bg);
font-size: x-large;
background: #ddd;
padding: 0.3em;
}
@ -59,279 +23,166 @@ h2, h3 {
hr {
border: none;
background-color: var(--hr-bg);
background-color: #e3e3e3;
height: 1px;
}
/* By default, use implied links, more discrete for increased readability. */
a {
text-decoration: none;
color: var(--text-fg);
color: black;
}
a:hover {
color: var(--a-fg);
text-decoration: underline;
color: #800;
}
/* Explicit links */
a.explicit {
color: var(--a-explicit-fg);
color: #038;
}
a.explicit:hover, a.explicit:active {
color: var(--a-fg);
color: #880000;
}
/* Normal table, for listing things like repositories, branches, etc. */
table.nice {
text-align: left;
font-size: small;
}
table.nice td {
padding: 0.15em 0.5em;
}
table.nice td.links {
font-size: smaller;
}
table.nice td.main {
min-width: 10em;
}
table.nice tr:hover {
background: var(--table-hover-bg);
background: #eee;
}
/* Table for commits. */
table.commits td.date {
font-style: italic;
color: var(--text-lowcontrast-fg);
color: gray;
}
@media (min-width: 600px) {
table.commits td.subject {
table.commits td.subject {
min-width: 32em;
}
}
table.commits td.author {
color: var(--text-lowcontrast-fg);
color: gray;
}
/* Table for commit information. */
table.commit-info tr:hover {
background: inherit;
}
table.commit-info td {
vertical-align: top;
}
table.commit-info span.date, span.email {
color: var(--text-lowcontrast-fg);
color: gray;
}
/* Reference annotations. */
span.refs {
margin: 0px 0.5em;
padding: 0px 0.25em;
border: solid 1px var(--text-lowcontrast-fg);
border: solid 1px gray;
}
span.head {
background-color: var(--head-bg);
background-color: #88ff88;
}
span.tag {
background-color: var(--tag-bg);
background-color: #ffff88;
}
/* Projects table */
table.projects td.name a {
color: var(--a-explicit-fg);
}
/* Age of an object.
* Note this is hidden by default as we rely on javascript to show it. */
span.age {
display: none;
color: var(--text-lowcontrast-fg);
font-size: smaller;
color: gray;
font-size: x-small;
}
span.age-band0 {
color: var(--age-fg0);
color: darkgreen;
}
span.age-band1 {
color: var(--age-fg1);
color: green;
}
span.age-band2 {
color: var(--age-fg2);
color: seagreen;
}
/* Toggable titles */
div.toggable-title {
font-weight: bold;
margin-bottom: 0.3em;
}
pre {
/* Sometimes, <pre> elements (commit messages, diffs, blobs) have very
* long lines. In those case, use automatic overflow, which will
* introduce a horizontal scroll bar for this element only (more
* comfortable than stretching the page, which is the default). */
overflow: auto;
}
/* Commit message and diff. */
pre.commit-message {
font-size: large;
padding: 0.2em 0.5em;
padding: 0.2em 2em;
}
pre.diff-body {
/* Note this is only used as a fallback if pygments is not available. */
font-size: medium;
}
table.changed-files {
font-family: monospace;
}
table.changed-files span.lines-added {
color: var(--diff-added-fg);
color: green;
}
table.changed-files span.lines-deleted {
color: var(--diff-deleted-fg);
color: red;
}
/* Pagination. */
div.paginate {
padding-bottom: 1em;
}
div.paginate span.inactive {
color: var(--text-lowcontrast-fg);
color: gray;
}
/* Directory listing. */
@media (min-width: 600px) {
table.ls td.name {
table.ls td.name {
min-width: 20em;
}
}
table.ls {
font-family: monospace;
font-size: larger;
}
table.ls tr.blob td.size {
color: var(--text-lowcontrast-fg);
color: gray;
}
/* Blob. */
pre.blob-body {
/* Note this is only used as a fallback if pygments is not available. */
font-size: medium;
}
table.blob-binary pre {
padding: 0;
margin: 0;
}
table.blob-binary .offset {
text-align: right;
font-size: x-small;
color: var(--text-lowcontrast-fg);
border-right: 1px solid var(--text-lowcontrast-fg);
}
table.blob-binary tr.etc {
text-align: center;
}
/* Pygments overrides. */
div.colorized-src {
font-size: larger;
}
div.colorized-src .source_code {
/* Ignore pygments style's background. */
background: var(--body-bg);
}
td.code > div.source_code {
/* This is a workaround, in pygments 2.11 there's a bug where the wrapper
* div is inside the table, so we need to override the descendant (because
* the style sets it on ".source_code" and the most specific value wins).
* Once we no longer support 2.11, we can remove this. */
background: var(--body-bg);
}
div.linenodiv {
padding-right: 0.5em;
color: gray;
font-size: medium;
}
div.linenodiv a {
color: var(--text-lowcontrast-fg);
div.source_code {
background: inherit;
font-size: medium;
}
/* Repository information table. */
table.repo_info tr:hover {
background: inherit;
}
table.repo_info td.category {
font-weight: bold;
/* So we can copy-paste rows and preserve spaces, useful for the row:
* git clone | url */
white-space: pre-wrap;
}
table.repo_info td {
vertical-align: top;
}
span.ctrlchr {
color: var(--text-lowcontrast-fg);
color: gray;
padding: 0 0.2ex 0 0.1ex;
margin: 0 0.2ex 0 0.1ex;
}
/*
* Markdown overrides
*/
/* Colored links (same as explicit links above) */
div.markdown a {
color: var(--a-explicit-fg);
}
div.markdown a:hover, div.markdown a:active {
color: var(--a-fg);
}
/* Restrict max width for readability */
div.markdown {
max-width: 55em;
}

View File

@ -61,13 +61,3 @@ function replace_timestamps() {
}
}
}
function toggle(id) {
var e = document.getElementById(id);
if (e.style.display == "") {
e.style.display = "none"
} else if (e.style.display == "none") {
e.style.display = ""
}
}

View File

@ -1,37 +1,30 @@
/* CSS for syntax highlighting.
* Generated by pygments (what we use for syntax highlighting).
* Generated by pygments (what we use for syntax highlighting):
*
* Light mode: pygmentize -S default -f html -a .source_code
* $ pygmentize -S default -f html -a .source_code
*/
pre { line-height: 125%; }
td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }
span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }
td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
.source_code .hll { background-color: #ffffcc }
.source_code { background: #f8f8f8; }
.source_code .c { color: #3D7B7B; font-style: italic } /* Comment */
.source_code .c { color: #408080; font-style: italic } /* Comment */
.source_code .err { border: 1px solid #FF0000 } /* Error */
.source_code .k { color: #008000; font-weight: bold } /* Keyword */
.source_code .o { color: #666666 } /* Operator */
.source_code .ch { color: #3D7B7B; font-style: italic } /* Comment.Hashbang */
.source_code .cm { color: #3D7B7B; font-style: italic } /* Comment.Multiline */
.source_code .cp { color: #9C6500 } /* Comment.Preproc */
.source_code .cpf { color: #3D7B7B; font-style: italic } /* Comment.PreprocFile */
.source_code .c1 { color: #3D7B7B; font-style: italic } /* Comment.Single */
.source_code .cs { color: #3D7B7B; font-style: italic } /* Comment.Special */
.source_code .cm { color: #408080; font-style: italic } /* Comment.Multiline */
.source_code .cp { color: #BC7A00 } /* Comment.Preproc */
.source_code .c1 { color: #408080; font-style: italic } /* Comment.Single */
.source_code .cs { color: #408080; font-style: italic } /* Comment.Special */
.source_code .gd { color: #A00000 } /* Generic.Deleted */
.source_code .ge { font-style: italic } /* Generic.Emph */
.source_code .gr { color: #E40000 } /* Generic.Error */
.source_code .gr { color: #FF0000 } /* Generic.Error */
.source_code .gh { color: #000080; font-weight: bold } /* Generic.Heading */
.source_code .gi { color: #008400 } /* Generic.Inserted */
.source_code .go { color: #717171 } /* Generic.Output */
.source_code .gi { color: #00A000 } /* Generic.Inserted */
.source_code .go { color: #808080 } /* Generic.Output */
.source_code .gp { color: #000080; font-weight: bold } /* Generic.Prompt */
.source_code .gs { font-weight: bold } /* Generic.Strong */
.source_code .gu { color: #800080; font-weight: bold } /* Generic.Subheading */
.source_code .gt { color: #0044DD } /* Generic.Traceback */
.source_code .gt { color: #0040D0 } /* Generic.Traceback */
.source_code .kc { color: #008000; font-weight: bold } /* Keyword.Constant */
.source_code .kd { color: #008000; font-weight: bold } /* Keyword.Declaration */
.source_code .kn { color: #008000; font-weight: bold } /* Keyword.Namespace */
@ -40,139 +33,38 @@ span.linenos.special { color: #000000; background-color: #ffffc0; padding-left:
.source_code .kt { color: #B00040 } /* Keyword.Type */
.source_code .m { color: #666666 } /* Literal.Number */
.source_code .s { color: #BA2121 } /* Literal.String */
.source_code .na { color: #687822 } /* Name.Attribute */
.source_code .na { color: #7D9029 } /* Name.Attribute */
.source_code .nb { color: #008000 } /* Name.Builtin */
.source_code .nc { color: #0000FF; font-weight: bold } /* Name.Class */
.source_code .no { color: #880000 } /* Name.Constant */
.source_code .nd { color: #AA22FF } /* Name.Decorator */
.source_code .ni { color: #717171; font-weight: bold } /* Name.Entity */
.source_code .ne { color: #CB3F38; font-weight: bold } /* Name.Exception */
.source_code .ni { color: #999999; font-weight: bold } /* Name.Entity */
.source_code .ne { color: #D2413A; font-weight: bold } /* Name.Exception */
.source_code .nf { color: #0000FF } /* Name.Function */
.source_code .nl { color: #767600 } /* Name.Label */
.source_code .nl { color: #A0A000 } /* Name.Label */
.source_code .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */
.source_code .nt { color: #008000; font-weight: bold } /* Name.Tag */
.source_code .nv { color: #19177C } /* Name.Variable */
.source_code .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */
.source_code .w { color: #bbbbbb } /* Text.Whitespace */
.source_code .mb { color: #666666 } /* Literal.Number.Bin */
.source_code .mf { color: #666666 } /* Literal.Number.Float */
.source_code .mh { color: #666666 } /* Literal.Number.Hex */
.source_code .mi { color: #666666 } /* Literal.Number.Integer */
.source_code .mo { color: #666666 } /* Literal.Number.Oct */
.source_code .sa { color: #BA2121 } /* Literal.String.Affix */
.source_code .sb { color: #BA2121 } /* Literal.String.Backtick */
.source_code .sc { color: #BA2121 } /* Literal.String.Char */
.source_code .dl { color: #BA2121 } /* Literal.String.Delimiter */
.source_code .sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */
.source_code .s2 { color: #BA2121 } /* Literal.String.Double */
.source_code .se { color: #AA5D1F; font-weight: bold } /* Literal.String.Escape */
.source_code .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */
.source_code .sh { color: #BA2121 } /* Literal.String.Heredoc */
.source_code .si { color: #A45A77; font-weight: bold } /* Literal.String.Interpol */
.source_code .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */
.source_code .sx { color: #008000 } /* Literal.String.Other */
.source_code .sr { color: #A45A77 } /* Literal.String.Regex */
.source_code .sr { color: #BB6688 } /* Literal.String.Regex */
.source_code .s1 { color: #BA2121 } /* Literal.String.Single */
.source_code .ss { color: #19177C } /* Literal.String.Symbol */
.source_code .bp { color: #008000 } /* Name.Builtin.Pseudo */
.source_code .fm { color: #0000FF } /* Name.Function.Magic */
.source_code .vc { color: #19177C } /* Name.Variable.Class */
.source_code .vg { color: #19177C } /* Name.Variable.Global */
.source_code .vi { color: #19177C } /* Name.Variable.Instance */
.source_code .vm { color: #19177C } /* Name.Variable.Magic */
.source_code .il { color: #666666 } /* Literal.Number.Integer.Long */
/*
* Dark mode: pygmentize -S native -f html -a .source_code
*/
@media (prefers-color-scheme: dark) {
pre { line-height: 125%; }
td.linenos .normal { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; }
span.linenos { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; }
td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
.source_code .hll { background-color: #404040 }
.source_code { background: #202020; color: #d0d0d0 }
.source_code .c { color: #ababab; font-style: italic } /* Comment */
.source_code .err { color: #a61717; background-color: #e3d2d2 } /* Error */
.source_code .esc { color: #d0d0d0 } /* Escape */
.source_code .g { color: #d0d0d0 } /* Generic */
.source_code .k { color: #6ebf26; font-weight: bold } /* Keyword */
.source_code .l { color: #d0d0d0 } /* Literal */
.source_code .n { color: #d0d0d0 } /* Name */
.source_code .o { color: #d0d0d0 } /* Operator */
.source_code .x { color: #d0d0d0 } /* Other */
.source_code .p { color: #d0d0d0 } /* Punctuation */
.source_code .ch { color: #ababab; font-style: italic } /* Comment.Hashbang */
.source_code .cm { color: #ababab; font-style: italic } /* Comment.Multiline */
.source_code .cp { color: #cd2828; font-weight: bold } /* Comment.Preproc */
.source_code .cpf { color: #ababab; font-style: italic } /* Comment.PreprocFile */
.source_code .c1 { color: #ababab; font-style: italic } /* Comment.Single */
.source_code .cs { color: #e50808; font-weight: bold; background-color: #520000 } /* Comment.Special */
.source_code .gd { color: #d22323 } /* Generic.Deleted */
.source_code .ge { color: #d0d0d0; font-style: italic } /* Generic.Emph */
.source_code .gr { color: #d22323 } /* Generic.Error */
.source_code .gh { color: #ffffff; font-weight: bold } /* Generic.Heading */
.source_code .gi { color: #589819 } /* Generic.Inserted */
.source_code .go { color: #cccccc } /* Generic.Output */
.source_code .gp { color: #aaaaaa } /* Generic.Prompt */
.source_code .gs { color: #d0d0d0; font-weight: bold } /* Generic.Strong */
.source_code .gu { color: #ffffff; text-decoration: underline } /* Generic.Subheading */
.source_code .gt { color: #d22323 } /* Generic.Traceback */
.source_code .kc { color: #6ebf26; font-weight: bold } /* Keyword.Constant */
.source_code .kd { color: #6ebf26; font-weight: bold } /* Keyword.Declaration */
.source_code .kn { color: #6ebf26; font-weight: bold } /* Keyword.Namespace */
.source_code .kp { color: #6ebf26 } /* Keyword.Pseudo */
.source_code .kr { color: #6ebf26; font-weight: bold } /* Keyword.Reserved */
.source_code .kt { color: #6ebf26; font-weight: bold } /* Keyword.Type */
.source_code .ld { color: #d0d0d0 } /* Literal.Date */
.source_code .m { color: #51b2fd } /* Literal.Number */
.source_code .s { color: #ed9d13 } /* Literal.String */
.source_code .na { color: #bbbbbb } /* Name.Attribute */
.source_code .nb { color: #2fbccd } /* Name.Builtin */
.source_code .nc { color: #71adff; text-decoration: underline } /* Name.Class */
.source_code .no { color: #40ffff } /* Name.Constant */
.source_code .nd { color: #ffa500 } /* Name.Decorator */
.source_code .ni { color: #d0d0d0 } /* Name.Entity */
.source_code .ne { color: #bbbbbb } /* Name.Exception */
.source_code .nf { color: #71adff } /* Name.Function */
.source_code .nl { color: #d0d0d0 } /* Name.Label */
.source_code .nn { color: #71adff; text-decoration: underline } /* Name.Namespace */
.source_code .nx { color: #d0d0d0 } /* Name.Other */
.source_code .py { color: #d0d0d0 } /* Name.Property */
.source_code .nt { color: #6ebf26; font-weight: bold } /* Name.Tag */
.source_code .nv { color: #40ffff } /* Name.Variable */
.source_code .ow { color: #6ebf26; font-weight: bold } /* Operator.Word */
.source_code .w { color: #666666 } /* Text.Whitespace */
.source_code .mb { color: #51b2fd } /* Literal.Number.Bin */
.source_code .mf { color: #51b2fd } /* Literal.Number.Float */
.source_code .mh { color: #51b2fd } /* Literal.Number.Hex */
.source_code .mi { color: #51b2fd } /* Literal.Number.Integer */
.source_code .mo { color: #51b2fd } /* Literal.Number.Oct */
.source_code .sa { color: #ed9d13 } /* Literal.String.Affix */
.source_code .sb { color: #ed9d13 } /* Literal.String.Backtick */
.source_code .sc { color: #ed9d13 } /* Literal.String.Char */
.source_code .dl { color: #ed9d13 } /* Literal.String.Delimiter */
.source_code .sd { color: #ed9d13 } /* Literal.String.Doc */
.source_code .s2 { color: #ed9d13 } /* Literal.String.Double */
.source_code .se { color: #ed9d13 } /* Literal.String.Escape */
.source_code .sh { color: #ed9d13 } /* Literal.String.Heredoc */
.source_code .si { color: #ed9d13 } /* Literal.String.Interpol */
.source_code .sx { color: #ffa500 } /* Literal.String.Other */
.source_code .sr { color: #ed9d13 } /* Literal.String.Regex */
.source_code .s1 { color: #ed9d13 } /* Literal.String.Single */
.source_code .ss { color: #ed9d13 } /* Literal.String.Symbol */
.source_code .bp { color: #2fbccd } /* Name.Builtin.Pseudo */
.source_code .fm { color: #71adff } /* Name.Function.Magic */
.source_code .vc { color: #40ffff } /* Name.Variable.Class */
.source_code .vg { color: #40ffff } /* Name.Variable.Global */
.source_code .vi { color: #40ffff } /* Name.Variable.Instance */
.source_code .vm { color: #40ffff } /* Name.Variable.Magic */
.source_code .il { color: #51b2fd } /* Literal.Number.Integer.Long */
/* Dark mode - my overrides, because the defaults are too bright. */
.source_code .gh { color: rgb(189, 193, 198); }
.source_code .gu { color: rgb(189, 193, 198); }
}

157
utils.py
View File

@ -5,44 +5,27 @@ These are mostly used in templates, for presentation purposes.
"""
try:
import pygments # type: ignore
from pygments import highlight # type: ignore
from pygments import lexers # type: ignore
from pygments.formatters import HtmlFormatter # type: ignore
_html_formatter = HtmlFormatter(
encoding="utf-8",
cssclass="source_code",
linenos="table",
anchorlinenos=True,
lineanchors="line",
)
import pygments
from pygments import highlight
from pygments import lexers
from pygments.formatters import HtmlFormatter
except ImportError:
pygments = None
try:
import markdown # type: ignore
import markdown.treeprocessors # type: ignore
import markdown
except ImportError:
markdown = None
import base64
import functools
import mimetypes
import string
import os.path
import git
def shorten(s: str, width=60):
def shorten(s, width = 60):
if len(s) < 60:
return s
return s[:57] + "..."
@functools.lru_cache
def can_colorize(s: str):
def can_colorize(s):
"""True if we can colorize the string, False otherwise."""
if pygments is None:
return False
@ -55,7 +38,7 @@ def can_colorize(s: str):
# If any of the first 5 lines is over 300 characters long, don't colorize.
start = 0
for i in range(5):
pos = s.find("\n", start)
pos = s.find('\n', start)
if pos == -1:
break
@ -65,8 +48,7 @@ def can_colorize(s: str):
return True
def can_markdown(repo: git.Repo, fname: str):
def can_markdown(repo, fname):
"""True if we can process file through markdown, False otherwise."""
if markdown is None:
return False
@ -76,121 +58,54 @@ def can_markdown(repo: git.Repo, fname: str):
return fname.endswith(".md")
def can_embed_image(repo, fname):
"""True if we can embed image file in HTML, False otherwise."""
if not repo.info.embed_images:
return False
return ("." in fname) and (
fname.split(".")[-1].lower() in ["jpg", "jpeg", "png", "gif"]
)
return (('.' in fname) and
(fname.split('.')[-1].lower() in [ 'jpg', 'jpeg', 'png', 'gif' ]))
@functools.lru_cache
def colorize_diff(s: str) -> str:
lexer = lexers.DiffLexer(encoding="utf-8")
formatter = HtmlFormatter(encoding="utf-8", cssclass="source_code")
def colorize_diff(s):
lexer = lexers.DiffLexer(encoding = 'utf-8')
formatter = HtmlFormatter(encoding = 'utf-8',
cssclass = 'source_code')
return highlight(s, lexer, formatter)
@functools.lru_cache
def colorize_blob(fname, s: str) -> str:
def colorize_blob(fname, s):
try:
lexer = lexers.guess_lexer_for_filename(fname, s, encoding="utf-8")
lexer = lexers.guess_lexer_for_filename(fname, s, encoding = 'utf-8')
except lexers.ClassNotFound:
# Only try to guess lexers if the file starts with a shebang,
# otherwise it's likely a text file and guess_lexer() is prone to
# make mistakes with those.
lexer = lexers.TextLexer(encoding="utf-8")
if s.startswith("#!"):
lexer = lexers.TextLexer(encoding = 'utf-8')
if s.startswith('#!'):
try:
lexer = lexers.guess_lexer(s[:80], encoding="utf-8")
lexer = lexers.guess_lexer(s[:80], encoding = 'utf-8')
except lexers.ClassNotFound:
pass
return highlight(s, lexer, _html_formatter)
formatter = HtmlFormatter(encoding = 'utf-8',
cssclass = 'source_code',
linenos = 'table',
anchorlinenos = True,
lineanchors = 'line')
return highlight(s, lexer, formatter)
def embed_image_blob(fname: str, image_data: bytes) -> str:
def markdown_blob(s):
return markdown.markdown(s)
def embed_image_blob(repo, dirname, fname):
mimetype = mimetypes.guess_type(fname)[0]
b64img = base64.b64encode(image_data).decode("ascii")
return '<img style="max-width:100%;" src="data:{0};base64,{1}" />'.format(
mimetype, b64img
)
# Unfortunately, bottle seems to require utf-8 encoded data.
# We have to refetch the blob with raw=True, because the utf-8 encoded
# version of the blob available in the bottle template discards binary data.
raw_blob = repo.blob(dirname + fname, raw = True)
@functools.lru_cache
def is_binary(b: bytes):
# Git considers a blob binary if NUL in first ~8KB, so do the same.
return b"\0" in b[:8192]
return '<img style="max-width:100%;" src="data:{0};base64,{1}" />'.format( \
mimetype, base64.b64encode(raw_blob))
@functools.lru_cache
def hexdump(s: bytes):
graph = string.ascii_letters + string.digits + string.punctuation + " "
b = s.decode("latin1")
offset = 0
while b:
t = b[:16]
hexvals = ["%.2x" % ord(c) for c in t]
text = "".join(c if c in graph else "." for c in t)
yield offset, " ".join(hexvals[:8]), " ".join(hexvals[8:]), text
offset += 16
b = b[16:]
if markdown:
class RewriteLocalLinks(markdown.treeprocessors.Treeprocessor):
"""Rewrites relative links to files, to match git-arr's links.
A link of "[example](a/file.md)" will be rewritten such that it links to
"a/f=file.md.html".
Note that we're already assuming a degree of sanity in the HTML, so we
don't re-check that the path is reasonable.
"""
def run(self, root):
for child in root:
if child.tag == "a":
self.rewrite_href(child)
# Continue recursively.
self.run(child)
def rewrite_href(self, tag):
"""Rewrite an <a>'s href."""
target = tag.get("href")
if not target:
return
if "://" in target or target.startswith("/"):
return
head, tail = os.path.split(target)
new_target = os.path.join(head, "f=" + tail + ".html")
tag.set("href", new_target)
class RewriteLocalLinksExtension(markdown.Extension):
def extendMarkdown(self, md):
md.treeprocessors.register(
RewriteLocalLinks(), "RewriteLocalLinks", 1000
)
_md_extensions = [
"markdown.extensions.fenced_code",
"markdown.extensions.tables",
RewriteLocalLinksExtension(),
]
@functools.lru_cache
def markdown_blob(s: str) -> str:
return markdown.markdown(s, extensions=_md_extensions)
else:
@functools.lru_cache
def markdown_blob(s: str) -> str:
raise RuntimeError("markdown_blob() called without markdown support")

View File

@ -1,88 +1,52 @@
<!DOCTYPE html>
<html>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
% if not dirname.raw:
% reltree = './'
% relroot = './'
% else:
% reltree = '../' * (len(dirname.split('/')) - 1)
% relroot = '../' * (len(dirname.split('/')) - 1)
% end
% relroot = reltree + '../' * (len(branch.split('/')) - 1)
<title>git &raquo; {{repo.name}} &raquo;
{{branch}} &raquo; {{dirname.raw}}{{fname.raw}}</title>
{{repo.branch}} &raquo; {{dirname.unicode}}/{{fname.unicode}}</title>
<link rel="stylesheet" type="text/css"
href="{{relroot}}../../../../../static/git-arr.css"/>
<link rel="stylesheet" type="text/css"
href="{{relroot}}../../../../../static/syntax.css"/>
<meta http-equiv="content-type" content="text/html; charset=utf-8"/>
<meta name=viewport content="width=device-width, initial-scale=1">
</head>
<body class="tree">
<h1><a href="{{relroot}}../../../../../">git</a> &raquo;
<a href="{{relroot}}../../../">{{repo.name}}</a> &raquo;
<a href="{{reltree}}../">{{branch}}</a> &raquo;
<a href="{{reltree}}">tree</a>
<a href="{{relroot}}../">{{repo.branch}}</a> &raquo;
<a href="{{relroot}}">tree</a>
</h1>
<h3>
<a href="{{reltree}}">[{{branch}}]</a> /
% base = smstr(reltree)
<a href="{{relroot}}">[{{repo.branch}}]</a> /
% base = smstr(relroot)
% for c in dirname.split('/'):
% if not c.raw:
% continue
% end
<a href="{{base.url}}{{c.url}}/">{{c.raw}}</a> /
<a href="{{base.url}}{{c.url}}/">{{c.unicode}}</a> /
% base += c + '/'
% end
<a href="">{{!fname.html}}</a>
</h3>
% if len(blob.raw_content) == 0:
<table class="nice">
<tr>
<td>empty &mdash; 0 bytes</td>
</tr>
</table>
% elif can_embed_image(repo, fname.raw):
{{!embed_image_blob(fname.raw, blob.raw_content)}}
% elif is_binary(blob.raw_content):
<table class="nice blob-binary">
<tr>
<td colspan="4">
binary &mdash; {{'{:,}'.format(len(blob.raw_content))}} bytes
</td>
</tr>
% lim = 256
% for offset, hex1, hex2, text in hexdump(blob.raw_content[:lim]):
<tr>
<td class="offset">{{offset}}</td>
<td><pre>{{hex1}}</pre></td>
<td><pre>{{hex2}}</pre></td>
<td><pre>{{text}}</pre></td>
</tr>
% end
% if lim < len(blob.raw_content):
<tr class="etc">
<td></td>
<td>&hellip;</td>
<td>&hellip;</td>
<td>&hellip;</td>
</tr>
% end
</table>
% elif can_markdown(repo, fname.raw):
<div class="markdown">
{{!markdown_blob(blob.utf8_content)}}
</div>
% elif can_colorize(blob.utf8_content):
<div class="colorized-src">
{{!colorize_blob(fname.raw, blob.utf8_content)}}
</div>
% if can_embed_image(repo, fname.unicode):
{{!embed_image_blob(repo, dirname.raw, fname.raw)}}
% elif can_markdown(repo, fname.unicode):
{{!markdown_blob(blob)}}
% elif can_colorize(blob):
{{!colorize_blob(fname.unicode, blob)}}
% else:
<pre class="blob-body">
{{blob.utf8_content}}
{{blob}}
</pre>
% end

View File

@ -1,27 +1,24 @@
<!DOCTYPE html>
<html>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
% relroot = '../' * (len(branch.split('/')) - 1)
<title>git &raquo; {{repo.name}} &raquo; {{branch}}</title>
<link rel="stylesheet" type="text/css" href="{{relroot}}../../../../static/git-arr.css"/>
<title>git &raquo; {{repo.name}} &raquo; {{repo.branch}}</title>
<link rel="stylesheet" type="text/css" href="../../../../static/git-arr.css"/>
<meta http-equiv="content-type" content="text/html; charset=utf-8"/>
<meta name=viewport content="width=device-width, initial-scale=1">
</head>
<body class="branch">
<h1><a href="{{relroot}}../../../../">git</a> &raquo;
<a href="{{relroot}}../../">{{repo.name}}</a> &raquo;
<a href="./">{{branch}}</a>
<h1><a href="../../../../">git</a> &raquo;
<a href="../../">{{repo.name}}</a> &raquo;
<a href="./">{{repo.branch}}</a>
</h1>
<p>
<a class="explicit" href="t/">Browse current source tree</a>
</p>
% commits = repo.commits("refs/heads/" + branch,
% limit = repo.info.commits_per_page + 1,
% commits = repo.commits("refs/heads/" + repo.branch,
% limit = repo.info.commits_per_page,
% offset = repo.info.commits_per_page * offset)
% commits = list(commits)
@ -29,21 +26,16 @@
% abort(404, "No more commits")
% end
% more = len(commits) > repo.info.commits_per_page
% if more:
% commits = commits[:-1]
% end
% more = more and offset + 1 < repo.info.max_pages
% include paginate more = more, offset = offset
% include paginate nelem = len(commits), max_per_page = repo.info.commits_per_page, offset = offset
% kwargs = dict(repo=repo, commits=commits,
% shorten=shorten, repo_root=relroot + "../..")
% shorten=shorten, repo_root="../..")
% include commit-list **kwargs
<p/>
% include paginate more = more, offset = offset
% include paginate nelem = len(commits), max_per_page = repo.info.commits_per_page, offset = offset
</body>
</html>

View File

@ -16,7 +16,7 @@
% end
% end
<table class="nice commits" id="commits">
<table class="nice commits">
% refs = repo.refs()
% if not defined("commits"):

View File

@ -1,11 +1,11 @@
<!DOCTYPE html>
<html>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>git &raquo; {{repo.name}} &raquo; commit {{c.id[:7]}}</title>
<link rel="stylesheet" type="text/css" href="../../../../static/git-arr.css"/>
<link rel="stylesheet" type="text/css" href="../../../../static/syntax.css"/>
<meta http-equiv="content-type" content="text/html; charset=utf-8"/>
<meta name=viewport content="width=device-width, initial-scale=1">
</head>
<body class="commit">
@ -56,9 +56,7 @@
<hr/>
% if can_colorize(c.diff.body):
<div class="colorized-src">
{{!colorize_diff(c.diff.body)}}
</div>
% else:
<pre class="diff-body">
{{c.diff.body}}

View File

@ -1,17 +1,17 @@
<!DOCTYPE html>
<html>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>git</title>
<link rel="stylesheet" type="text/css" href="static/git-arr.css"/>
<meta http-equiv="content-type" content="text/html; charset=utf-8"/>
<meta name=viewport content="width=device-width, initial-scale=1">
<script async src="static/git-arr.js"></script>
<script src="static/git-arr.js"></script>
</head>
<body class="index" onload="replace_timestamps()">
<h1>git</h1>
<table class="nice projects">
<table class="nice">
<tr>
<th>project</th>
<th>description</th>
@ -19,8 +19,8 @@
% for repo in sorted(repos.values(), key = lambda r: r.name):
<tr>
<td class="name"><a href="r/{{repo.name}}/">{{repo.name}}</a></td>
<td class="desc"><a href="r/{{repo.name}}/">{{repo.info.desc}}</a></td>
<td><a href="r/{{repo.name}}/">{{repo.name}}</a></td>
<td><a href="r/{{repo.name}}/">{{repo.info.desc}}</a></td>
<td><span class="age">{{repo.last_commit_timestamp()}}</span></td>
</tr>
%end

View File

@ -6,7 +6,7 @@
<span class="inactive">&larr; prev</span>
% end
<span class="sep">|</span>
% if more:
% if nelem >= max_per_page:
<a href="{{offset + 1}}.html">next &rarr;</a>
% else:
<span class="inactive">next &rarr;</span>

View File

@ -1,8 +0,0 @@
From: {{c.author_name}} <{{c.author_email}}>
Date: {{c.author_date}}
Subject: {{c.subject}}
{{c.body.strip()}}
---
{{c.diff.body}}

View File

@ -1,11 +1,10 @@
<!DOCTYPE html>
<html>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>git &raquo; {{repo.name}}</title>
<link rel="stylesheet" type="text/css" href="../../static/git-arr.css"/>
<meta http-equiv="content-type" content="text/html; charset=utf-8"/>
<meta name=viewport content="width=device-width, initial-scale=1">
<script async src="../../static/git-arr.js"></script>
</head>
<body class="summary">
@ -26,7 +25,7 @@
% end
% if repo.info.git_url:
<tr>
<td class="category">git clone </td>
<td class="category">repository</td>
<td>{{! '<br/>'.join(repo.info.git_url.split())}}</td>
</tr>
% end
@ -36,25 +35,19 @@
% end
% if "master" in repo.branch_names():
<div class="toggable-title" onclick="toggle('commits')">
<a href="b/master/">commits (master)</a>
</div>
% kwargs = dict(repo = repo, start_ref = "refs/heads/master",
% limit = repo.info.commits_in_summary,
% shorten = shorten, repo_root = ".", offset = 0)
% include commit-list **kwargs
<hr/>
<div class="toggable-title" onclick="toggle('ls')">
<a href="b/master/t/">tree (master)</a>
</div>
% kwargs = dict(repo = repo, tree=repo.tree("master"),
% treeroot="b/master/t", dirname=smstr.from_url(""))
% include tree-list **kwargs
<hr/>
% end
<div class="toggable-title" onclick="toggle('branches')">branches</div>
<table class="nice toggable" id="branches">
<hr/>
<table class="nice">
<tr>
<th>branches</th>
</tr>
% for b in repo.branch_names():
<tr>
<td class="main"><a href="b/{{b}}/">{{b}}</a></td>
@ -70,8 +63,11 @@
% tags = list(repo.tags())
% if tags:
<div class="toggable-title" onclick="toggle('tags')">tags</div>
<table class="nice toggable" id="tags">
<table class="nice">
<tr>
<th>tags</th>
</tr>
% for name, obj_id in tags:
<tr>
<td><a href="c/{{obj_id}}/">{{name}}</a></td>

View File

@ -1,16 +0,0 @@
<table class="nice toggable ls" id="ls">
% key_func = lambda x: (x[0] != 'tree', x[1].raw)
% for type, name, size in sorted(tree.ls(dirname.raw), key = key_func):
<tr class="{{type}}">
% if type == "blob":
<td class="name"><a href="{{treeroot}}/f={{name.url}}.html">
{{!name.html}}</a></td>
<td class="size">{{size}}</td>
% elif type == "tree":
<td class="name">
<a class="explicit" href="{{treeroot}}/{{name.url}}/">
{{!name.html}}/</a></td>
% end
</tr>
% end
</table>

View File

@ -1,43 +1,56 @@
<!DOCTYPE html>
<html>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
% if not dirname.raw:
% reltree = './'
% relroot = './'
% else:
% reltree = '../' * (len(dirname.split('/')) - 1)
% relroot = '../' * (len(dirname.split('/')) - 1)
% end
% relroot = reltree + '../' * (len(branch.split('/')) - 1)
<title>git &raquo; {{repo.name}} &raquo;
{{branch}} &raquo; {{dirname.raw if dirname.raw else '/'}}</title>
{{repo.branch}} &raquo; {{dirname.unicode}}</title>
<link rel="stylesheet" type="text/css"
href="{{relroot}}../../../../../static/git-arr.css"/>
<meta http-equiv="content-type" content="text/html; charset=utf-8"/>
<meta name=viewport content="width=device-width, initial-scale=1">
</head>
<body class="tree">
<h1><a href="{{relroot}}../../../../../">git</a> &raquo;
<a href="{{relroot}}../../../">{{repo.name}}</a> &raquo;
<a href="{{reltree}}../">{{branch}}</a> &raquo;
<a href="{{reltree}}">tree</a>
<a href="{{relroot}}../">{{repo.branch}}</a> &raquo;
<a href="{{relroot}}">tree</a>
</h1>
<h3>
<a href="{{reltree}}">[{{branch}}]</a> /
% base = smstr(reltree)
<a href="{{relroot}}">[{{repo.branch}}]</a> /
% base = smstr(relroot)
% for c in dirname.split('/'):
% if not c.raw:
% continue
% end
<a href="{{base.url}}{{c.url}}/">{{c.raw}}</a> /
<a href="{{base.url}}{{c.url}}/">{{c.unicode}}</a> /
% base += c + '/'
% end
</h3>
% kwargs = dict(repo = repo, tree=tree, treeroot=".")
% include tree-list **kwargs
<table class="nice ls">
% key_func = lambda (t, n, s): (t != 'tree', n.raw)
% for type, name, size in sorted(tree.ls(dirname.raw), key = key_func):
<tr class="{{type}}">
% if type == "blob":
<td class="name"><a href="./f={{name.url}}.html">
{{!name.html}}</a></td>
<td class="size">{{size}}</td>
% elif type == "tree":
<td class="name">
<a class="explicit" href="./{{name.url}}/">
{{!name.html}}/</a></td>
% end
</tr>
% end
</table>
</body>
</html>