Initial commit

Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
This commit is contained in:
Alberto Bertogli 2012-09-16 11:17:56 +01:00
commit 80ef0017d4
18 changed files with 1732 additions and 0 deletions

3
.gitignore vendored Normal file

@ -0,0 +1,3 @@
*.pyc
__pycache__
.*.swp

25
LICENSE Normal file

@ -0,0 +1,25 @@
git-arr is under the MIT licence, which is reproduced below (taken from
http://opensource.org/licenses/MIT).
-----
Copyright (c) 2012 Alberto Bertogli
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

49
README Normal file

@ -0,0 +1,49 @@
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
---------------
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.
Where to report bugs
--------------------
If you want to report bugs, or have any questions or comments, just let me
know at albertito@blitiri.com.ar.

13
TODO Normal 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).

390
git-arr Executable file

@ -0,0 +1,390 @@
#!/usr/bin/env python
"""
git-arr: A git web html generator.
"""
from __future__ import print_function
import os
import math
import optparse
try:
import configparser
except ImportError:
import ConfigParser as configparser
import bottle
import git
import utils
# The list of repositories is a global variable for convenience. It will be
# populated by load_config().
repos = {}
def load_config(path):
"""Load the configuration from the given file.
The "repos" global variable will be filled with the repositories
as configured.
"""
defaults = {
'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': 'git_url',
}
config = configparser.SafeConfigParser(defaults)
config.read(path)
# Do a first pass for general sanity checking and recursive expansion.
for s in config.sections():
if not config.has_option(s, 'path'):
raise configparser.NoOptionError(
'%s is missing the mandatory path' % s)
if config.getboolean(s, 'recursive'):
for path in os.listdir(config.get(s, 'path')):
fullpath = config.get(s, 'path') + '/' + path
if not os.path.exists(fullpath + '/HEAD'):
continue
if os.path.exists(fullpath + '/disable_gitweb'):
continue
if config.has_section(path):
continue
config.add_section(path)
for opt, value in config.items(s, raw = True):
config.set(path, opt, value)
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():
fullpath = config.get(s, 'path')
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()
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')
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')
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')
if not r.info.git_url and os.path.isfile(git_url_file):
r.info.git_url = open(git_url_file).read()
repos[r.name] = r
def repo_filter(unused_conf):
"""Bottle route filter for repos."""
# TODO: consider allowing /, which is tricky.
regexp = r'[\w\.~-]+'
def to_python(s):
"""Return the corresponding Python object."""
if s in repos:
return repos[s]
bottle.abort(404, "Unknown repository")
def to_url(r):
"""Return the corresponding URL string."""
return r.name
return regexp, to_python, to_url
app = bottle.Bottle()
app.router.add_filter('repo', repo_filter)
bottle.app.push(app)
def with_utils(f):
"""Decorator to add the utilities to the return value.
Used to wrap functions that return dictionaries which are then passed to
templates.
"""
utilities = {
'shorten': utils.shorten,
'has_colorizer': utils.has_colorizer,
'colorize_diff': utils.colorize_diff,
'colorize_blob': utils.colorize_blob,
'abort': bottle.abort,
'smstr': git.smstr,
}
def wrapped(*args, **kwargs):
"""Wrapped function we will return."""
d = f(*args, **kwargs)
d.update(utilities)
return d
wrapped.__name__ = f.__name__
wrapped.__doc__ = f.__doc__
return wrapped
@bottle.route('/')
@bottle.view('index')
@with_utils
def index():
return dict(repos = repos)
@bottle.route('/r/<repo:repo>/')
@bottle.view('summary')
@with_utils
def summary(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-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')
return dict(repo = repo, c=c)
@bottle.route('/r/<repo:repo>/b/<bname>/t/')
@bottle.route('/r/<repo:repo>/b/<bname>/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)
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
content = r.blob(path)
if content is None:
bottle.abort(404, "File %r not found in branch %s" % (path, bname))
return dict(repo = r, dirname = dirname, fname = fname, blob = content)
@bottle.route('/static/<path:path>')
def static(path):
return bottle.static_file(path, root = './static/')
#
# Static HTML generation
#
def generate(output):
"""Generate static html to the output directory."""
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 = 0
if os.path.exists(path):
path_mtime = os.stat(path).st_mtime
# Make sure they're both float or int, to avoid failing
# comparisons later on because of this.
if isinstance(path_mtime, int):
mtime = int(mtime)
# If we were given mtime, we compare against it to see if we
# should write the file or not. Compare with almost-equality
# because otherwise floating point equality gets in the way, and
# we rather write a bit more, than generate the wrong output.
if abs(path_mtime - mtime) < 0.000001:
return
print(path)
s = func_or_str(*args)
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, unicode)):
print(path)
s = func_or_str
else:
if os.path.exists(path):
return
print(path)
s = func_or_str(*args)
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
if os.path.lexists(from_path):
return
print(from_path, '->', to_path)
os.symlink(to_path, from_path)
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)
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))
continue
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/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('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/git-arr.css'],
os.stat('static/git-arr.css').st_mtime)
write_to('static/syntax.css', read_f, ['static/syntax.css'],
os.stat('static/syntax.css').st_mtime)
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)
for cid in commit_ids:
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 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 = 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)
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))
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 e.status == 404:
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',
help = 'generate/serve only this repository')
opts, args = parser.parse_args()
if not opts.config:
parser.error('--config is mandatory')
try:
load_config(opts.config)
except configparser.NoOptionError as e:
print('Error parsing config:', e)
if not args:
parser.error('Must specify an action (serve|generate)')
if opts.only:
global repos
repos = [ r for r in repos if r.name in opts.only ]
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)
else:
parser.error('Unknown action %s' % args[0])
if __name__ == '__main__':
main()

522
git.py Normal file

@ -0,0 +1,522 @@
"""
Python wrapper for git.
This module is a light Python API for interfacing with it. It calls the git
command line tool directly, so please be careful with using untrusted
parameters.
"""
import sys
import io
import subprocess
from collections import defaultdict
import email.utils
import datetime
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 __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):
"""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)
if not stdin:
p = subprocess.Popen(params, stdin = None, stdout = subprocess.PIPE)
else:
p = subprocess.Popen(params,
stdin = subprocess.PIPE, stdout = subprocess.PIPE)
p.stdin.write(stdin)
p.stdin.close()
# 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):
"""Convenient way of invoking git."""
def __init__(self, path, cmd, *args, **kwargs):
self._override = True
self._path = path
self._cmd = cmd
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:
self.__dict__[k] = v
return
k = k.replace('_', '-')
self._kwargs[k] = v
def arg(self, a):
"""Adds an argument."""
self._args.append(a)
def stdin(self, s):
"""Sets the contents we will send in stdin."""
self._override = True
self._stdin_buf = s
self._override = False
def run(self):
"""Runs the git command."""
params = [self._cmd]
for k, v in self._kwargs.items():
dash = '--' if len(k) > 1 else '-'
if v is None:
params.append('%s%s' % (dash, k))
else:
params.append('%s%s=%s' % (dash, k, str(v)))
params.extend(self._args)
return run_git(self._path, params, self._stdin_buf)
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.
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.
@staticmethod
def from_url(url):
"""Returns an smstr() instance from an url-encoded string."""
return smstr(urllib.url2pathname(url))
def split(self, sep):
"""Like str.split()."""
return [ smstr(s) for s in self.raw.split(sep) ]
def __add__(self, other):
if isinstance(other, smstr):
other = other.raw
return smstr(self.raw + other)
def _to_html(self):
"""Returns an html representation of the unicode string."""
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
return html
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; just
# make sure we return str.
s = s.encode('ascii')
return s
# 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
class Repo:
"""A git repository."""
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 = info or SimpleNamespace()
def cmd(self, cmd):
"""Returns a GitCommand() on our path."""
return GitCommand(self.path, cmd)
def for_each_ref(self, pattern = None, sort = None):
"""Returns a list of references."""
cmd = self.cmd('for-each-ref')
if sort:
cmd.sort = sort
if pattern:
cmd.arg(pattern)
for l in cmd.run():
obj_id, obj_type, ref = l.split()
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
def branch_names(self):
"""Get the names of the branches."""
return ( name for name, _ in self.branches() )
def tags(self, sort = '-taggerdate'):
"""Get the (name, obj_id) of the tags."""
refs = self.for_each_ref(pattern = 'refs/tags/', sort = sort)
for obj_id, _, ref in refs:
yield ref[len('refs/tags/'):], obj_id
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')
if limit:
cmd.max_count = limit
cmd.arg(ref)
for l in cmd.run():
yield l.rstrip('\n')
def commit(self, commit_id):
"""Return a single commit."""
cs = list(self.commits(commit_id, limit = 1))
if len(cs) != 1:
return None
return cs[0]
def commits(self, ref, limit = None, offset = 0):
"""Generate commit objects for the ref."""
cmd = self.cmd('rev-list')
if limit:
cmd.max_count = limit + offset
cmd.header = None
cmd.arg(ref)
info_buffer = ''
count = 0
for l in cmd.run():
if '\0' in l:
pre, post = l.split('\0', 1)
info_buffer += pre
count += 1
if count > offset:
yield Commit.from_str(self, info_buffer)
# Start over.
info_buffer = post
else:
info_buffer += l
if info_buffer:
count += 1
if count > offset:
yield Commit.from_str(self, info_buffer)
def diff(self, ref):
"""Return a Diff object for the ref."""
cmd = self.cmd('diff-tree')
cmd.patch = None
cmd.numstat = None
cmd.find_renames = 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.
cmd.arg(ref)
return Diff.from_str(cmd.run())
def refs(self):
"""Return a dict of obj_id -> 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)
r[obj_id].append(ref)
return r
def tree(self, ref = None):
"""Returns a Tree instance for the given ref."""
if not ref:
ref = self.branch
return Tree(self, ref)
def blob(self, path, ref = None):
"""Returns the contents of the given path."""
if not ref:
ref = self.branch
cmd = self.cmd('cat-file')
cmd.batch = None
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('missing'):
return None
return out.read()
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):
self._repo = repo
self.id = commit_id
self.parents = parents
self.tree = tree
self.author = author
self.author_epoch = author_epoch
self.author_tz = author_tz
self.committer = committer
self.committer_epoch = committer_epoch
self.committer_tz = committer_tz
self.message = message
self.author_name, self.author_email = \
email.utils.parseaddr(self.author)
self.committer_name, self.committer_email = \
email.utils.parseaddr(self.committer)
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>' % (
self.id[:7],
','.join(p[:7] for p in self.parents),
self.author_email,
self.subject[:20])
@property
def diff(self):
"""Return the diff for this commit, in unified format."""
if not self._diff:
self._diff = self._repo.diff(self.id)
return self._diff
@staticmethod
def from_str(repo, buf):
"""Parses git rev-list output, returns a commit object."""
header, raw_message = buf.split('\n\n', 1)
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)
header_dict[k].append(v)
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)
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.fromtimestamp(self.epoch)
self.tz_sec_offset_min = int(tz[1:3]) * 60 + int(tz[4:])
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)
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
class Diff:
"""A diff between two trees."""
def __init__(self, ref, changes, body):
"""Constructor.
- ref: reference id the diff refers to.
- changes: [ (added, deleted, filename), ... ]
- body: diff body, as text, verbatim.
"""
self.ref = ref
self.changes = changes
self.body = body
@staticmethod
def from_str(buf):
"""Parses git diff-tree output, returns a Diff object."""
lines = iter(buf)
try:
ref_id = next(lines)
except StopIteration:
# No diff; this can happen in merges without conflicts.
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')
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)
return Diff(ref_id, changes, body)
class Tree:
""" A git tree."""
def __init__(self, repo, ref):
self.repo = repo
self.ref = ref
def ls(self, path, recursive = False):
"""Generates (type, name, size) for each file in path."""
cmd = self.repo.cmd('ls-tree')
cmd.long = None
if recursive:
cmd.r = None
cmd.t = None
cmd.arg(self.ref)
cmd.arg(path)
for l in cmd.run():
_mode, otype, _oid, size, name = l.split(None, 4)
if size == '-':
size = None
else:
size = int(size)
# Remove the quoting (if any); will always give us a str.
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):]
# We use a smart string for the name, as it's often tricky to
# manipulate otherwise.
yield otype, smstr(name), size

61
sample.conf Normal file

@ -0,0 +1,61 @@
# A single repository.
[repo]
path = /srv/git/repo/
# Description (optional).
# Default: Read from <path>/description, or "" if there is no such file.
#desc = My lovely repository
# Do we allow browsing the file tree for each branch? (optional).
# Useful to disable an expensive operation in very large repositories.
#tree = yes
# How many commits to show in the summary page (optional).
#commits_in_summary = 10
# How many commits to show in each page when viewing a branch (optional).
#commits_per_page = 50
# 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.
#max_pages = 5
# Project website (optional).
# URL to the project's website. %(name)s will be replaced with the current
# section name (here and everywhere).
#web_url = http://example.org/%(name)s
# File name to get the project website from (optional).
# If web_url is not set, attempt to get its value from this file.
# Default: "web_url".
#web_url_file = web_url
# Git repository URLs (optional).
# URLs to the project's git repository.
#git_url = git://example.org/%(name)s http://example.org/git/%(name)s
# File name to get the git URLs from (optional).
# If git_url is not set, attempt to get its value from this file.
# Default: "git_url"
#git_url_file = git_url
# Do we look for repositories within this path? (optional).
# This option enables a recursive, 1 level search for repositories within the
# given path. They will inherit their options from this section.
# Note that repositories that contain a file named "disable_gitweb" will be
# excluded.
#recursive = no
# Another repository, we don't generate a tree for it because it's too big.
[linux]
path = /srv/git/linux/
desc = Linux kernel
tree = no
# Look for repositories within this directory.
[projects]
path = /srv/projects/
recursive = yes

168
static/git-arr.css Normal file

@ -0,0 +1,168 @@
/*
* git-arr style sheet
*/
body {
font-family: sans-serif;
font-size: small;
padding: 0 1em 1em 1em;
}
h1 {
font-size: x-large;
background: #ddd;
padding: 0.3em;
}
h2, h3 {
border-bottom: 1px solid #ccc;
padding-bottom: 0.3em;
margin-bottom: 0.5em;
}
hr {
border: none;
background-color: #e3e3e3;
height: 1px;
}
/* By default, use implied links, more discrete for increased readability. */
a {
text-decoration: none;
color: black;
}
a:hover {
text-decoration: underline;
color: #800;
}
/* Explicit links */
a.explicit {
color: #038;
}
a.explicit:hover, a.explicit:active {
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: #eee;
}
/* Table for commits. */
table.commits td.date {
font-style: italic;
color: gray;
}
table.commits td.subject {
min-width: 32em;
}
table.commits td.author {
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: gray;
}
/* Reference annotations. */
span.refs {
margin: 0px 0.5em;
padding: 0px 0.25em;
border: solid 1px gray;
}
span.head {
background-color: #88ff88;
}
span.tag {
background-color: #ffff88;
}
/* Commit message and diff. */
pre.commit-message {
font-size: large;
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 span.lines-added {
color: green;
}
table.changed-files span.lines-deleted {
color: red;
}
/* Pagination. */
div.paginate {
padding-bottom: 1em;
}
div.paginate span.inactive {
color: gray;
}
/* Directory listing. */
table.ls td.name {
min-width: 20em;
}
table.ls tr.blob td.size {
color: gray;
}
/* Blob. */
pre.blob-body {
/* Note this is only used as a fallback if pygments is not available. */
font-size: medium;
}
/* Pygments overrides. */
div.linenodiv {
padding-right: 0.5em;
color: gray;
font-size: medium;
}
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;
}
table.repo_info td {
vertical-align: top;
}
span.ctrlchr {
color: gray;
padding: 0 0.2ex 0 0.1ex;
margin: 0 0.2ex 0 0.1ex;
}

70
static/syntax.css Normal file

@ -0,0 +1,70 @@
/* CSS for syntax highlighting.
* Generated by pygments (what we use for syntax highlighting):
*
* $ pygmentize -S default -f html -a .source_code
*/
.source_code .hll { background-color: #ffffcc }
.source_code { background: #f8f8f8; }
.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 .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: #FF0000 } /* Generic.Error */
.source_code .gh { color: #000080; font-weight: bold } /* Generic.Heading */
.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: #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 */
.source_code .kp { color: #008000 } /* Keyword.Pseudo */
.source_code .kr { color: #008000; font-weight: bold } /* Keyword.Reserved */
.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: #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: #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: #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 .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 .sb { color: #BA2121 } /* Literal.String.Backtick */
.source_code .sc { color: #BA2121 } /* Literal.String.Char */
.source_code .sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */
.source_code .s2 { color: #BA2121 } /* Literal.String.Double */
.source_code .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */
.source_code .sh { color: #BA2121 } /* Literal.String.Heredoc */
.source_code .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */
.source_code .sx { color: #008000 } /* Literal.String.Other */
.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 .vc { color: #19177C } /* Name.Variable.Class */
.source_code .vg { color: #19177C } /* Name.Variable.Global */
.source_code .vi { color: #19177C } /* Name.Variable.Instance */
.source_code .il { color: #666666 } /* Literal.Number.Integer.Long */

41
utils.py Normal file

@ -0,0 +1,41 @@
"""
Miscellaneous utilities.
These are mostly used in templates, for presentation purposes.
"""
try:
import pygments
from pygments import highlight
from pygments import lexers
from pygments.formatters import HtmlFormatter
except ImportError:
pygments = None
def shorten(s, width = 60):
if len(s) < 60:
return s
return s[:57] + "..."
def has_colorizer():
return pygments is not None
def colorize_diff(s):
lexer = lexers.DiffLexer(encoding = 'utf-8')
formatter = HtmlFormatter(encoding = 'utf-8',
cssclass = 'source_code')
return highlight(s, lexer, formatter)
def colorize_blob(fname, s):
try:
lexer = lexers.guess_lexer_for_filename(fname, s)
except lexers.ClassNotFound:
lexer = lexers.TextLexer(encoding = 'utf-8')
formatter = HtmlFormatter(encoding = 'utf-8',
cssclass = 'source_code',
linenos = 'table')
return highlight(s, lexer, formatter)

50
views/blob.html Normal file

@ -0,0 +1,50 @@
<!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:
% relroot = './'
% else:
% relroot = '../' * (len(dirname.split('/')) - 1)
% end
<title>git &raquo; {{repo.name}} &raquo;
{{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"/>
</head>
<body class="tree">
<h1><a href="{{relroot}}../../../../../">git</a> &raquo;
<a href="{{relroot}}../../../">{{repo.name}}</a> &raquo;
<a href="{{relroot}}../">{{repo.branch}}</a> &raquo;
<a href="{{relroot}}">tree</a>
</h1>
<h3>
<a href="{{relroot}}">[{{repo.branch}}]</a> /
% base = smstr(relroot)
% for c in dirname.split('/'):
% if not c.raw: continue
<a href="{{base.url}}{{c.url}}/">{{c.unicode}}</a> /
% base += c + '/'
% end
<a href="">{{!fname.html}}</a>
</h3>
% if has_colorizer():
{{!colorize_blob(fname.unicode, blob)}}
% else:
<pre class="blob-body">
{{blob}}
</pre>
% end
<hr/>
</body>
</html>

42
views/branch.html Normal file

@ -0,0 +1,42 @@
<!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; {{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"/>
</head>
<body class="branch">
<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/" + repo.branch,
% limit = repo.info.commits_per_page,
% offset = repo.info.commits_per_page * offset)
% commits = list(commits)
% if len(commits) == 0:
% abort(404, "No more commits")
% end
% 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="../..")
% include commit-list **kwargs
<p/>
% include paginate nelem = len(commits), max_per_page = repo.info.commits_per_page, offset = offset
</body>
</html>

47
views/commit-list.html Normal file

@ -0,0 +1,47 @@
% def refs_to_html(refs):
% for ref in refs:
% c = ref.split('/', 2)
% if len(c) != 3:
% return
% end
% if c[1] == 'heads':
<span class="refs head">{{c[2]}}</span>
% elif c[1] == 'tags':
% if c[2].endswith('^{}'):
% c[2] = c[2][:-3]
% end
<span class="refs tag">{{c[2]}}</span>
% end
% end
% end
<table class="nice commits">
% refs = repo.refs()
% if not defined("commits"):
% commits = repo.commits(start_ref, limit = limit, offset = offset)
% end
% for c in commits:
<tr>
<td class="date">
<span title="{{c.author_date.str}}">{{c.author_date.utc.date()}}</span>
</td>
<td class="subject">
<a href="{{repo_root}}/c/{{c.id}}/"
title="{{c.subject}}">
{{shorten(c.subject)}}</a>
</td>
<td class="author">
<span title="{{c.author_name}}">{{shorten(c.author_name, 26)}}</span>
</td>
% if c.id in refs:
<td>
% refs_to_html(refs[c.id])
</td>
% end
</tr>
% end
</table>

72
views/commit.html Normal file

@ -0,0 +1,72 @@
<!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"/>
</head>
<body class="commit">
<h1><a href="../../../../">git</a> &raquo;
<a href="../../">{{repo.name}}</a> &raquo; commit {{c.id[:7]}}
</h1>
<h2>{{c.subject}}</h2>
<table class="nice commit-info">
<tr><td>author</td>
<td><span class="name">{{c.author_name}}</span>
<span class="email">&lt;{{c.author_email}}&gt;</span><br/>
<span class="date" title="{{c.author_date}}">
{{c.author_date.utc}} UTC</span></td></tr>
<tr><td>committer</td>
<td><span class="name">{{c.author_name}}</span>
<span class="email">&lt;{{c.author_email}}&gt;</span><br/>
<span class="date" title="{{c.author_date}}">
{{c.author_date.utc}} UTC</span></td></tr>
% for p in c.parents:
<tr><td>parent</td>
<td><a href="../{{p}}/">{{p}}</a></td></tr>
% end
</table>
<hr/>
<pre class="commit-message">
{{c.message.strip()}}
</pre>
<hr/>
% if c.diff.changes:
<table class="nice changed-files">
% for added, deleted, fname in c.diff.changes:
<tr>
<td class="main">{{!fname.html}}</td>
<td><span class="lines-added">+{{added}}</span></td>
<td><span class="lines-deleted">-{{deleted}}</span></td>
</tr>
% end
</table>
<hr/>
% if has_colorizer():
{{!colorize_diff(c.diff.body)}}
% else:
<pre class="diff-body">
{{c.diff.body}}
</pre>
% end
<hr/>
% end
</body>
</html>

29
views/index.html Normal file

@ -0,0 +1,29 @@
<!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"/>
</head>
<body class="index">
<h1>git</h1>
<table class="nice">
<tr>
<th>project</th>
<th>description</th>
</tr>
% for repo in sorted(repos.values(), key = lambda r: r.name):
<tr>
<td><a href="r/{{repo.name}}/">{{repo.name}}</a></td>
<td><a href="r/{{repo.name}}/">{{repo.info.desc}}</a></td>
</tr>
%end
</table>
</body>
</html>

15
views/paginate.html Normal file

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

81
views/summary.html Normal file

@ -0,0 +1,81 @@
<!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"/>
</head>
<body class="summary">
<h1><a href="../../">git</a> &raquo; <a href="./">{{repo.name}}</a></h1>
<h2>{{repo.info.desc}}</h2>
% if repo.info.web_url or repo.info.git_url:
<table class="nice repo_info">
% if repo.info.web_url:
<tr>
<td class="category">website</td>
<td><a class="explicit" href="{{repo.info.web_url}}">
{{repo.info.web_url}}</a></td>
</tr>
% end
% if repo.info.git_url:
<tr>
<td class="category">repository</td>
<td>{{! '<br/>'.join(repo.info.git_url.split())}}</td>
</tr>
% end
</table>
<hr/>
% end
% if "master" in repo.branch_names():
% 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
% end
<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>
<td class="links">
<a class="explicit" href="b/{{b}}/">commits</a></td>
<td class="links">
<a class="explicit" href="b/{{b}}/t/">tree</a></td>
</tr>
%end
</table>
<hr/>
% tags = list(repo.tags())
% if 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>
</tr>
%end
</table>
% end
</body>
</html>

54
views/tree.html Normal file

@ -0,0 +1,54 @@
<!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:
% relroot = './'
% else:
% relroot = '../' * (len(dirname.split('/')) - 1)
% end
<title>git &raquo; {{repo.name}} &raquo;
{{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"/>
</head>
<body class="tree">
<h1><a href="{{relroot}}../../../../../">git</a> &raquo;
<a href="{{relroot}}../../../">{{repo.name}}</a> &raquo;
<a href="{{relroot}}../">{{repo.branch}}</a> &raquo;
<a href="{{relroot}}">tree</a>
</h1>
<h3>
<a href="{{relroot}}">[{{repo.branch}}]</a> /
% base = smstr(relroot)
% for c in dirname.split('/'):
% if not c.raw: continue
<a href="{{base.url}}{{c.url}}/">{{c.unicode}}</a> /
% base += c + '/'
% end
</h3>
<table class="nice ls">
% key_func = lambda (t, n, s): (0 if t == 'tree' else 1, 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>