Compare commits

...

15 Commits
0.15 ... master

Author SHA1 Message Date
Alberto Bertogli
dff4ff6757 css: Work around Pygments' element layout changes
Pygments 2.12 changes the element layout slightly, adding a wrapper
<div> that was accidentally removed before:
https://github.com/pygments/pygments/issues/632.

This patch adds a workaround, so the styling is consistent on both 2.11
and 2.12.
2022-10-13 22:32:21 +01:00
Alberto Bertogli
6ea59bad51 css: Dark mode for Pygments' syntax highlight
This patch updates Pygments' syntax highlight CSS to support dark mode.
It uses two themes from Pygments: `default` for light (same as before),
and `native` for dark.
2022-10-13 22:32:21 +01:00
Alberto Bertogli
4b1e1eb84c css: Introduce dark mode
This patch extends our CSS to introduce dark mode, so the style shown
matches the user media preference.

It is very analogous to the previous one, only minor adjustments have
been made to make the contrast levels pass the accessibility standards.

No changes have been made to the pygments CSS. It works surprisingly
well as-is, but there are some minor changes that may be needed. Those
will be done in subsequent patches.
2022-10-13 22:32:21 +01:00
Alberto Bertogli
518188288e Cache some (possibly) expensive function calls
This patch memoizes some of the functions to help speed up execution.
The speedup is quite variable, but ~30% is normal when generating a
medium size repository, and the output is byte-for-byte identical.
2022-08-31 23:15:16 +01:00
Alberto Bertogli
15547b2796 utils: Update Markdown local links extension to the new API
The Markdown extension for rewriting local links was using an API that
is now deprecated, and as of python-markdown 3.4 it is no longer
available.

This patch adjusts the code to use the new API which should be available
from 3.0 onwards.
2022-08-31 21:07:53 +01:00
Alberto Bertogli
9f3df4899f css: Improve handling of text overflow in <pre>
When a <pre> section (commit message, blob, diff) has a very long line,
today it makes the entire page very wide, causing usability issues.

This patch makes <pre> have a horizontal scroll in those cases, which is
easier to use.
2021-05-15 01:24:58 +01:00
Alberto Bertogli
bc1ee87dfe css: Reduce commit-message left padding
The commit message has a very large left and right padding, but doesn't
improve readability and might make the commit message more difficult to
read on smaller screens.

This patch shortens the padding.
2021-05-15 00:58:11 +01:00
Alberto Bertogli
0d61bbf7f5 css: Auto-format git-arr.css
Auto-format static/git-arr.css with https://blitiri.com.ar/git/r/css3fmt/
for consistency.
2021-05-15 00:53:42 +01:00
Alberto Bertogli
78f1b6def0 Update README
This patch updates README, converting it to markdown, adding more links
and references, and explicitly mention the Python 3 dependency.
2020-05-25 02:22:53 +01:00
Alberto Bertogli
4cb2f59dd4 Remove TODO
The TODO includes many obsolete entries and is generally not kept up to
date; remove it to avoid confusion.
2020-05-25 02:22:53 +01:00
Alberto Bertogli
e2155f6b33 Remove unused/unnecessary code
This patch removes some code that is unused and/or unnecessary. Most of
it is left over from previous situations, but is no longer needed.
2020-05-25 02:04:55 +01:00
Alberto Bertogli
aee18d0edd Simplify smstr
With the Python 2 to 3 migration and the type checking, we can be
fairly confident that smstr are always constructed from strings, not
bytes.

This allows the code to be simplified, as we no longer need to carry
the dual raw/unicode representation.
2020-05-24 16:05:18 +01:00
Alberto Bertogli
20b99ee568 Introduce type annotations
This patch introduces type annotations, which can be checked with mypy.

The coverage is not very comprehensive for now, but it is a starting
point and will be expanded in later patches.
2020-05-24 16:04:24 +01:00
Alberto Bertogli
ad950208bf Auto-format the code with black
This patch applies auto-formatting of the source code using black
(https://github.com/psf/black).

This makes the code style more uniform and simplifies editing.

Note I also tried yapf, and IMO produced nicer output and handled some
corner cases much better, but unfortunately it doesn't yet support type
annotations, which will be introduced in later commits.

So in the future we might switch to yapf instead.
2020-05-24 16:04:04 +01:00
Alberto Bertogli
1183d6f817 Move to Python 3
Python 3 was released more than 10 years ago, and support for Python 2
is going away, with many Linux distributions starting to phase it out.

This patch migrates git-arr to Python 3.

The generated output is almost exactly the same, there are some minor
differences such as HTML characters being quoted more aggresively, and
handling of paths with non-utf8 values.
2020-05-24 04:50:39 +01:00
14 changed files with 891 additions and 568 deletions

3
.gitignore vendored
View File

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

56
README
View File

@ -1,56 +0,0 @@
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.

65
README.md Normal file
View File

@ -0,0 +1,65 @@
# 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
View File

@ -1,13 +0,0 @@
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).

429
git-arr
View File

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

402
git.py
View File

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

3
pyproject.toml Normal file
View File

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

View File

@ -1,15 +1,53 @@
/* /*
* git-arr style sheet * 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 { body {
font-family: sans-serif; font-family: sans-serif;
padding: 0 1em 1em 1em; padding: 0 1em 1em 1em;
color: var(--text-fg);
background: var(--body-bg);
} }
h1 { h1 {
background: #ddd; background: var(--h1-bg);
padding: 0.3em; padding: 0.3em;
} }
@ -21,26 +59,29 @@ h2, h3 {
hr { hr {
border: none; border: none;
background-color: #e3e3e3; background-color: var(--hr-bg);
height: 1px; height: 1px;
} }
/* By default, use implied links, more discrete for increased readability. */ /* By default, use implied links, more discrete for increased readability. */
a { a {
text-decoration: none; text-decoration: none;
color: black; color: var(--text-fg);
} }
a:hover { a:hover {
text-decoration: underline; color: var(--a-fg);
color: #800;
} }
/* Explicit links */ /* Explicit links */
a.explicit { a.explicit {
color: #038; color: var(--a-explicit-fg);
} }
a.explicit:hover, a.explicit:active { a.explicit:hover, a.explicit:active {
color: #880000; color: var(--a-fg);
} }
@ -48,22 +89,27 @@ a.explicit:hover, a.explicit:active {
table.nice { table.nice {
text-align: left; text-align: left;
} }
table.nice td { table.nice td {
padding: 0.15em 0.5em; padding: 0.15em 0.5em;
} }
table.nice td.links { table.nice td.links {
} }
table.nice td.main { table.nice td.main {
min-width: 10em; min-width: 10em;
} }
table.nice tr:hover { table.nice tr:hover {
background: #eee; background: var(--table-hover-bg);
} }
/* Table for commits. */ /* Table for commits. */
table.commits td.date { table.commits td.date {
font-style: italic; font-style: italic;
color: gray; color: var(--text-lowcontrast-fg);
} }
@media (min-width: 600px) { @media (min-width: 600px) {
@ -71,106 +117,134 @@ table.commits td.date {
min-width: 32em; min-width: 32em;
} }
} }
table.commits td.author { table.commits td.author {
color: gray; color: var(--text-lowcontrast-fg);
} }
/* Table for commit information. */ /* Table for commit information. */
table.commit-info tr:hover { table.commit-info tr:hover {
background: inherit; background: inherit;
} }
table.commit-info td { table.commit-info td {
vertical-align: top; vertical-align: top;
} }
table.commit-info span.date, span.email { table.commit-info span.date, span.email {
color: gray; color: var(--text-lowcontrast-fg);
} }
/* Reference annotations. */ /* Reference annotations. */
span.refs { span.refs {
margin: 0px 0.5em; margin: 0px 0.5em;
padding: 0px 0.25em; padding: 0px 0.25em;
border: solid 1px gray; border: solid 1px var(--text-lowcontrast-fg);
} }
span.head { span.head {
background-color: #88ff88; background-color: var(--head-bg);
} }
span.tag { span.tag {
background-color: #ffff88; background-color: var(--tag-bg);
} }
/* Projects table */ /* Projects table */
table.projects td.name a { table.projects td.name a {
color: #038; color: var(--a-explicit-fg);
} }
/* Age of an object. /* Age of an object.
* Note this is hidden by default as we rely on javascript to show it. */ * Note this is hidden by default as we rely on javascript to show it. */
span.age { span.age {
display: none; display: none;
color: gray; color: var(--text-lowcontrast-fg);
font-size: smaller; font-size: smaller;
} }
span.age-band0 { span.age-band0 {
color: darkgreen; color: var(--age-fg0);
} }
span.age-band1 { span.age-band1 {
color: green; color: var(--age-fg1);
} }
span.age-band2 { span.age-band2 {
color: seagreen; color: var(--age-fg2);
} }
/* Toggable titles */ /* Toggable titles */
div.toggable-title { div.toggable-title {
font-weight: bold; font-weight: bold;
margin-bottom: 0.3em; 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. */ /* Commit message and diff. */
pre.commit-message { pre.commit-message {
font-size: large; font-size: large;
padding: 0.2em 2em; padding: 0.2em 0.5em;
} }
pre.diff-body { pre.diff-body {
/* Note this is only used as a fallback if pygments is not available. */ /* Note this is only used as a fallback if pygments is not available. */
} }
table.changed-files { table.changed-files {
font-family: monospace; font-family: monospace;
} }
table.changed-files span.lines-added { table.changed-files span.lines-added {
color: green; color: var(--diff-added-fg);
} }
table.changed-files span.lines-deleted { table.changed-files span.lines-deleted {
color: red; color: var(--diff-deleted-fg);
} }
/* Pagination. */ /* Pagination. */
div.paginate { div.paginate {
padding-bottom: 1em; padding-bottom: 1em;
} }
div.paginate span.inactive { div.paginate span.inactive {
color: gray; color: var(--text-lowcontrast-fg);
} }
/* Directory listing. */ /* Directory listing. */
@media (min-width: 600px) { @media (min-width: 600px) {
table.ls td.name { table.ls td.name {
min-width: 20em; min-width: 20em;
} }
} }
table.ls { table.ls {
font-family: monospace; font-family: monospace;
font-size: larger; font-size: larger;
} }
table.ls tr.blob td.size { table.ls tr.blob td.size {
color: gray; color: var(--text-lowcontrast-fg);
} }
/* Blob. */ /* Blob. */
pre.blob-body { pre.blob-body {
/* Note this is only used as a fallback if pygments is not available. */ /* Note this is only used as a fallback if pygments is not available. */
@ -184,60 +258,79 @@ table.blob-binary pre {
table.blob-binary .offset { table.blob-binary .offset {
text-align: right; text-align: right;
font-size: x-small; font-size: x-small;
color: darkgray; color: var(--text-lowcontrast-fg);
border-right: 1px solid #eee; border-right: 1px solid var(--text-lowcontrast-fg);
} }
table.blob-binary tr.etc { table.blob-binary tr.etc {
text-align: center; text-align: center;
} }
/* Pygments overrides. */ /* Pygments overrides. */
div.linenodiv { div.colorized-src {
padding-right: 0.5em;
font-size: larger; /* must match div.source_code */
}
div.linenodiv a {
color: gray;
}
div.source_code {
background: inherit;
font-size: larger; 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;
}
div.linenodiv a {
color: var(--text-lowcontrast-fg);
}
/* Repository information table. */ /* Repository information table. */
table.repo_info tr:hover { table.repo_info tr:hover {
background: inherit; background: inherit;
} }
table.repo_info td.category { table.repo_info td.category {
font-weight: bold; font-weight: bold;
/* So we can copy-paste rows and preserve spaces, useful for the row: /* So we can copy-paste rows and preserve spaces, useful for the row:
* git clone | url * git clone | url */
*/
white-space: pre-wrap; white-space: pre-wrap;
} }
table.repo_info td { table.repo_info td {
vertical-align: top; vertical-align: top;
} }
span.ctrlchr { span.ctrlchr {
color: gray; color: var(--text-lowcontrast-fg);
padding: 0 0.2ex 0 0.1ex; padding: 0 0.2ex 0 0.1ex;
margin: 0 0.2ex 0 0.1ex; margin: 0 0.2ex 0 0.1ex;
} }
/* /*
* Markdown overrides * Markdown overrides
*/ */
/* Colored links (same as explicit links above) */ /* Colored links (same as explicit links above) */
div.markdown a { div.markdown a {
color: #038; color: var(--a-explicit-fg);
} }
div.markdown a:hover, div.markdown a:active { div.markdown a:hover, div.markdown a:active {
color: #880000; color: var(--a-fg);
} }
/* Restrict max width for readability */ /* Restrict max width for readability */
div.markdown { div.markdown {
max-width: 55em; max-width: 55em;

View File

@ -1,30 +1,37 @@
/* CSS for syntax highlighting. /* CSS for syntax highlighting.
* Generated by pygments (what we use for syntax highlighting): * Generated by pygments (what we use for syntax highlighting).
* *
* $ pygmentize -S default -f html -a .source_code * Light mode: 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 .hll { background-color: #ffffcc }
.source_code { background: #f8f8f8; } .source_code { background: #f8f8f8; }
.source_code .c { color: #408080; font-style: italic } /* Comment */ .source_code .c { color: #3D7B7B; font-style: italic } /* Comment */
.source_code .err { border: 1px solid #FF0000 } /* Error */ .source_code .err { border: 1px solid #FF0000 } /* Error */
.source_code .k { color: #008000; font-weight: bold } /* Keyword */ .source_code .k { color: #008000; font-weight: bold } /* Keyword */
.source_code .o { color: #666666 } /* Operator */ .source_code .o { color: #666666 } /* Operator */
.source_code .cm { color: #408080; font-style: italic } /* Comment.Multiline */ .source_code .ch { color: #3D7B7B; font-style: italic } /* Comment.Hashbang */
.source_code .cp { color: #BC7A00 } /* Comment.Preproc */ .source_code .cm { color: #3D7B7B; font-style: italic } /* Comment.Multiline */
.source_code .c1 { color: #408080; font-style: italic } /* Comment.Single */ .source_code .cp { color: #9C6500 } /* Comment.Preproc */
.source_code .cs { color: #408080; font-style: italic } /* Comment.Special */ .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 .gd { color: #A00000 } /* Generic.Deleted */ .source_code .gd { color: #A00000 } /* Generic.Deleted */
.source_code .ge { font-style: italic } /* Generic.Emph */ .source_code .ge { font-style: italic } /* Generic.Emph */
.source_code .gr { color: #FF0000 } /* Generic.Error */ .source_code .gr { color: #E40000 } /* Generic.Error */
.source_code .gh { color: #000080; font-weight: bold } /* Generic.Heading */ .source_code .gh { color: #000080; font-weight: bold } /* Generic.Heading */
.source_code .gi { color: #00A000 } /* Generic.Inserted */ .source_code .gi { color: #008400 } /* Generic.Inserted */
.source_code .go { color: #808080 } /* Generic.Output */ .source_code .go { color: #717171 } /* Generic.Output */
.source_code .gp { color: #000080; font-weight: bold } /* Generic.Prompt */ .source_code .gp { color: #000080; font-weight: bold } /* Generic.Prompt */
.source_code .gs { font-weight: bold } /* Generic.Strong */ .source_code .gs { font-weight: bold } /* Generic.Strong */
.source_code .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ .source_code .gu { color: #800080; font-weight: bold } /* Generic.Subheading */
.source_code .gt { color: #0040D0 } /* Generic.Traceback */ .source_code .gt { color: #0044DD } /* Generic.Traceback */
.source_code .kc { color: #008000; font-weight: bold } /* Keyword.Constant */ .source_code .kc { color: #008000; font-weight: bold } /* Keyword.Constant */
.source_code .kd { color: #008000; font-weight: bold } /* Keyword.Declaration */ .source_code .kd { color: #008000; font-weight: bold } /* Keyword.Declaration */
.source_code .kn { color: #008000; font-weight: bold } /* Keyword.Namespace */ .source_code .kn { color: #008000; font-weight: bold } /* Keyword.Namespace */
@ -33,38 +40,139 @@
.source_code .kt { color: #B00040 } /* Keyword.Type */ .source_code .kt { color: #B00040 } /* Keyword.Type */
.source_code .m { color: #666666 } /* Literal.Number */ .source_code .m { color: #666666 } /* Literal.Number */
.source_code .s { color: #BA2121 } /* Literal.String */ .source_code .s { color: #BA2121 } /* Literal.String */
.source_code .na { color: #7D9029 } /* Name.Attribute */ .source_code .na { color: #687822 } /* Name.Attribute */
.source_code .nb { color: #008000 } /* Name.Builtin */ .source_code .nb { color: #008000 } /* Name.Builtin */
.source_code .nc { color: #0000FF; font-weight: bold } /* Name.Class */ .source_code .nc { color: #0000FF; font-weight: bold } /* Name.Class */
.source_code .no { color: #880000 } /* Name.Constant */ .source_code .no { color: #880000 } /* Name.Constant */
.source_code .nd { color: #AA22FF } /* Name.Decorator */ .source_code .nd { color: #AA22FF } /* Name.Decorator */
.source_code .ni { color: #999999; font-weight: bold } /* Name.Entity */ .source_code .ni { color: #717171; font-weight: bold } /* Name.Entity */
.source_code .ne { color: #D2413A; font-weight: bold } /* Name.Exception */ .source_code .ne { color: #CB3F38; font-weight: bold } /* Name.Exception */
.source_code .nf { color: #0000FF } /* Name.Function */ .source_code .nf { color: #0000FF } /* Name.Function */
.source_code .nl { color: #A0A000 } /* Name.Label */ .source_code .nl { color: #767600 } /* Name.Label */
.source_code .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */ .source_code .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */
.source_code .nt { color: #008000; font-weight: bold } /* Name.Tag */ .source_code .nt { color: #008000; font-weight: bold } /* Name.Tag */
.source_code .nv { color: #19177C } /* Name.Variable */ .source_code .nv { color: #19177C } /* Name.Variable */
.source_code .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */ .source_code .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */
.source_code .w { color: #bbbbbb } /* Text.Whitespace */ .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 .mf { color: #666666 } /* Literal.Number.Float */
.source_code .mh { color: #666666 } /* Literal.Number.Hex */ .source_code .mh { color: #666666 } /* Literal.Number.Hex */
.source_code .mi { color: #666666 } /* Literal.Number.Integer */ .source_code .mi { color: #666666 } /* Literal.Number.Integer */
.source_code .mo { color: #666666 } /* Literal.Number.Oct */ .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 .sb { color: #BA2121 } /* Literal.String.Backtick */
.source_code .sc { color: #BA2121 } /* Literal.String.Char */ .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 .sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */
.source_code .s2 { color: #BA2121 } /* Literal.String.Double */ .source_code .s2 { color: #BA2121 } /* Literal.String.Double */
.source_code .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */ .source_code .se { color: #AA5D1F; font-weight: bold } /* Literal.String.Escape */
.source_code .sh { color: #BA2121 } /* Literal.String.Heredoc */ .source_code .sh { color: #BA2121 } /* Literal.String.Heredoc */
.source_code .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */ .source_code .si { color: #A45A77; font-weight: bold } /* Literal.String.Interpol */
.source_code .sx { color: #008000 } /* Literal.String.Other */ .source_code .sx { color: #008000 } /* Literal.String.Other */
.source_code .sr { color: #BB6688 } /* Literal.String.Regex */ .source_code .sr { color: #A45A77 } /* Literal.String.Regex */
.source_code .s1 { color: #BA2121 } /* Literal.String.Single */ .source_code .s1 { color: #BA2121 } /* Literal.String.Single */
.source_code .ss { color: #19177C } /* Literal.String.Symbol */ .source_code .ss { color: #19177C } /* Literal.String.Symbol */
.source_code .bp { color: #008000 } /* Name.Builtin.Pseudo */ .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 .vc { color: #19177C } /* Name.Variable.Class */
.source_code .vg { color: #19177C } /* Name.Variable.Global */ .source_code .vg { color: #19177C } /* Name.Variable.Global */
.source_code .vi { color: #19177C } /* Name.Variable.Instance */ .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 */ .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); }
}

135
utils.py
View File

@ -5,30 +5,44 @@ These are mostly used in templates, for presentation purposes.
""" """
try: try:
import pygments import pygments # type: ignore
from pygments import highlight from pygments import highlight # type: ignore
from pygments import lexers from pygments import lexers # type: ignore
from pygments.formatters import HtmlFormatter from pygments.formatters import HtmlFormatter # type: ignore
_html_formatter = HtmlFormatter(
encoding="utf-8",
cssclass="source_code",
linenos="table",
anchorlinenos=True,
lineanchors="line",
)
except ImportError: except ImportError:
pygments = None pygments = None
try: try:
import markdown import markdown # type: ignore
import markdown.treeprocessors import markdown.treeprocessors # type: ignore
except ImportError: except ImportError:
markdown = None markdown = None
import base64 import base64
import functools
import mimetypes import mimetypes
import string import string
import os.path import os.path
def shorten(s, width = 60): import git
def shorten(s: str, width=60):
if len(s) < 60: if len(s) < 60:
return s return s
return s[:57] + "..." return s[:57] + "..."
def can_colorize(s):
@functools.lru_cache
def can_colorize(s: str):
"""True if we can colorize the string, False otherwise.""" """True if we can colorize the string, False otherwise."""
if pygments is None: if pygments is None:
return False return False
@ -41,7 +55,7 @@ def can_colorize(s):
# If any of the first 5 lines is over 300 characters long, don't colorize. # If any of the first 5 lines is over 300 characters long, don't colorize.
start = 0 start = 0
for i in range(5): for i in range(5):
pos = s.find('\n', start) pos = s.find("\n", start)
if pos == -1: if pos == -1:
break break
@ -51,7 +65,8 @@ def can_colorize(s):
return True return True
def can_markdown(repo, fname):
def can_markdown(repo: git.Repo, fname: str):
"""True if we can process file through markdown, False otherwise.""" """True if we can process file through markdown, False otherwise."""
if markdown is None: if markdown is None:
return False return False
@ -61,73 +76,73 @@ def can_markdown(repo, fname):
return fname.endswith(".md") return fname.endswith(".md")
def can_embed_image(repo, fname): def can_embed_image(repo, fname):
"""True if we can embed image file in HTML, False otherwise.""" """True if we can embed image file in HTML, False otherwise."""
if not repo.info.embed_images: if not repo.info.embed_images:
return False return False
return (('.' in fname) and return ("." in fname) and (
(fname.split('.')[-1].lower() in [ 'jpg', 'jpeg', 'png', 'gif' ])) fname.split(".")[-1].lower() in ["jpg", "jpeg", "png", "gif"]
)
def colorize_diff(s):
lexer = lexers.DiffLexer(encoding = 'utf-8') @functools.lru_cache
formatter = HtmlFormatter(encoding = 'utf-8', def colorize_diff(s: str) -> str:
cssclass = 'source_code') lexer = lexers.DiffLexer(encoding="utf-8")
formatter = HtmlFormatter(encoding="utf-8", cssclass="source_code")
return highlight(s, lexer, formatter) return highlight(s, lexer, formatter)
def colorize_blob(fname, s):
@functools.lru_cache
def colorize_blob(fname, s: str) -> str:
try: 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: except lexers.ClassNotFound:
# Only try to guess lexers if the file starts with a shebang, # 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 # otherwise it's likely a text file and guess_lexer() is prone to
# make mistakes with those. # make mistakes with those.
lexer = lexers.TextLexer(encoding = 'utf-8') lexer = lexers.TextLexer(encoding="utf-8")
if s.startswith('#!'): if s.startswith("#!"):
try: try:
lexer = lexers.guess_lexer(s[:80], encoding = 'utf-8') lexer = lexers.guess_lexer(s[:80], encoding="utf-8")
except lexers.ClassNotFound: except lexers.ClassNotFound:
pass pass
formatter = HtmlFormatter(encoding = 'utf-8', return highlight(s, lexer, _html_formatter)
cssclass = 'source_code',
linenos = 'table',
anchorlinenos = True,
lineanchors = 'line')
return highlight(s, lexer, formatter)
def markdown_blob(s): def embed_image_blob(fname: str, image_data: bytes) -> str:
extensions = [
"markdown.extensions.fenced_code",
"markdown.extensions.tables",
RewriteLocalLinksExtension(),
]
return markdown.markdown(s, extensions = extensions)
def embed_image_blob(fname, image_data):
mimetype = mimetypes.guess_type(fname)[0] mimetype = mimetypes.guess_type(fname)[0]
return '<img style="max-width:100%;" src="data:{0};base64,{1}" />'.format( \ b64img = base64.b64encode(image_data).decode("ascii")
mimetype, base64.b64encode(image_data)) return '<img style="max-width:100%;" src="data:{0};base64,{1}" />'.format(
mimetype, b64img
)
def is_binary(s):
@functools.lru_cache
def is_binary(b: bytes):
# Git considers a blob binary if NUL in first ~8KB, so do the same. # Git considers a blob binary if NUL in first ~8KB, so do the same.
return '\0' in s[:8192] return b"\0" in b[:8192]
def hexdump(s):
graph = string.ascii_letters + string.digits + string.punctuation + ' ' @functools.lru_cache
def hexdump(s: bytes):
graph = string.ascii_letters + string.digits + string.punctuation + " "
b = s.decode("latin1")
offset = 0 offset = 0
while s: while b:
t = s[:16] t = b[:16]
hexvals = ['%.2x' % ord(c) for c in t] hexvals = ["%.2x" % ord(c) for c in t]
text = ''.join(c if c in graph else '.' 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 yield offset, " ".join(hexvals[:8]), " ".join(hexvals[8:]), text
offset += 16 offset += 16
s = s[16:] b = b[16:]
if markdown: if markdown:
class RewriteLocalLinks(markdown.treeprocessors.Treeprocessor): class RewriteLocalLinks(markdown.treeprocessors.Treeprocessor):
"""Rewrites relative links to files, to match git-arr's links. """Rewrites relative links to files, to match git-arr's links.
@ -137,6 +152,7 @@ if markdown:
Note that we're already assuming a degree of sanity in the HTML, so we Note that we're already assuming a degree of sanity in the HTML, so we
don't re-check that the path is reasonable. don't re-check that the path is reasonable.
""" """
def run(self, root): def run(self, root):
for child in root: for child in root:
if child.tag == "a": if child.tag == "a":
@ -157,9 +173,24 @@ if markdown:
new_target = os.path.join(head, "f=" + tail + ".html") new_target = os.path.join(head, "f=" + tail + ".html")
tag.set("href", new_target) tag.set("href", new_target)
class RewriteLocalLinksExtension(markdown.Extension): class RewriteLocalLinksExtension(markdown.Extension):
def extendMarkdown(self, md, md_globals): def extendMarkdown(self, md):
md.treeprocessors.add( md.treeprocessors.register(
"RewriteLocalLinks", RewriteLocalLinks(), "_end") 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

@ -10,7 +10,7 @@
% relroot = reltree + '../' * (len(branch.split('/')) - 1) % relroot = reltree + '../' * (len(branch.split('/')) - 1)
<title>git &raquo; {{repo.name}} &raquo; <title>git &raquo; {{repo.name}} &raquo;
{{branch}} &raquo; {{dirname.unicode}}{{fname.unicode}}</title> {{branch}} &raquo; {{dirname.raw}}{{fname.raw}}</title>
<link rel="stylesheet" type="text/css" <link rel="stylesheet" type="text/css"
href="{{relroot}}../../../../../static/git-arr.css"/> href="{{relroot}}../../../../../static/git-arr.css"/>
<link rel="stylesheet" type="text/css" <link rel="stylesheet" type="text/css"
@ -33,7 +33,7 @@
% if not c.raw: % if not c.raw:
% continue % continue
% end % end
<a href="{{base.url}}{{c.url}}/">{{c.unicode}}</a> / <a href="{{base.url}}{{c.url}}/">{{c.raw}}</a> /
% base += c + '/' % base += c + '/'
% end % end
<a href="">{{!fname.html}}</a> <a href="">{{!fname.html}}</a>
@ -45,7 +45,7 @@
<td>empty &mdash; 0 bytes</td> <td>empty &mdash; 0 bytes</td>
</tr> </tr>
</table> </table>
% elif can_embed_image(repo, fname.unicode): % elif can_embed_image(repo, fname.raw):
{{!embed_image_blob(fname.raw, blob.raw_content)}} {{!embed_image_blob(fname.raw, blob.raw_content)}}
% elif is_binary(blob.raw_content): % elif is_binary(blob.raw_content):
<table class="nice blob-binary"> <table class="nice blob-binary">
@ -72,12 +72,14 @@
</tr> </tr>
% end % end
</table> </table>
% elif can_markdown(repo, fname.unicode): % elif can_markdown(repo, fname.raw):
<div class="markdown"> <div class="markdown">
{{!markdown_blob(blob.utf8_content)}} {{!markdown_blob(blob.utf8_content)}}
</div> </div>
% elif can_colorize(blob.utf8_content): % elif can_colorize(blob.utf8_content):
{{!colorize_blob(fname.unicode, blob.utf8_content)}} <div class="colorized-src">
{{!colorize_blob(fname.raw, blob.utf8_content)}}
</div>
% else: % else:
<pre class="blob-body"> <pre class="blob-body">
{{blob.utf8_content}} {{blob.utf8_content}}

View File

@ -56,7 +56,9 @@
<hr/> <hr/>
% if can_colorize(c.diff.body): % if can_colorize(c.diff.body):
<div class="colorized-src">
{{!colorize_diff(c.diff.body)}} {{!colorize_diff(c.diff.body)}}
</div>
% else: % else:
<pre class="diff-body"> <pre class="diff-body">
{{c.diff.body}} {{c.diff.body}}

View File

@ -1,5 +1,5 @@
<table class="nice toggable ls" id="ls"> <table class="nice toggable ls" id="ls">
% key_func = lambda (t, n, s): (t != 'tree', n.raw) % key_func = lambda x: (x[0] != 'tree', x[1].raw)
% for type, name, size in sorted(tree.ls(dirname.raw), key = key_func): % for type, name, size in sorted(tree.ls(dirname.raw), key = key_func):
<tr class="{{type}}"> <tr class="{{type}}">
% if type == "blob": % if type == "blob":

View File

@ -10,7 +10,7 @@
% relroot = reltree + '../' * (len(branch.split('/')) - 1) % relroot = reltree + '../' * (len(branch.split('/')) - 1)
<title>git &raquo; {{repo.name}} &raquo; <title>git &raquo; {{repo.name}} &raquo;
{{branch}} &raquo; {{dirname.unicode if dirname.unicode else '/'}}</title> {{branch}} &raquo; {{dirname.raw if dirname.raw else '/'}}</title>
<link rel="stylesheet" type="text/css" <link rel="stylesheet" type="text/css"
href="{{relroot}}../../../../../static/git-arr.css"/> href="{{relroot}}../../../../../static/git-arr.css"/>
<meta http-equiv="content-type" content="text/html; charset=utf-8"/> <meta http-equiv="content-type" content="text/html; charset=utf-8"/>
@ -31,7 +31,7 @@
% if not c.raw: % if not c.raw:
% continue % continue
% end % end
<a href="{{base.url}}{{c.url}}/">{{c.unicode}}</a> / <a href="{{base.url}}{{c.url}}/">{{c.raw}}</a> /
% base += c + '/' % base += c + '/'
% end % end
</h3> </h3>