aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--LICENSE25
-rw-r--r--README49
-rw-r--r--TODO13
-rwxr-xr-xgit-arr390
-rw-r--r--git.py522
-rw-r--r--sample.conf61
-rw-r--r--static/git-arr.css168
-rw-r--r--static/syntax.css70
-rw-r--r--utils.py41
-rw-r--r--views/blob.html50
-rw-r--r--views/branch.html42
-rw-r--r--views/commit-list.html47
-rw-r--r--views/commit.html72
-rw-r--r--views/index.html29
-rw-r--r--views/paginate.html15
-rw-r--r--views/summary.html81
-rw-r--r--views/tree.html54
18 files changed, 1732 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..faf410c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+*.pyc
+__pycache__
+.*.swp
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..db56b1e
--- /dev/null
+++ b/LICENSE
@@ -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.
+
diff --git a/README b/README
new file mode 100644
index 0000000..d51208d
--- /dev/null
+++ b/README
@@ -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.
+
diff --git a/TODO b/TODO
new file mode 100644
index 0000000..55d9771
--- /dev/null
+++ b/TODO
@@ -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).
+
diff --git a/git-arr b/git-arr
new file mode 100755
index 0000000..8e8ae1f
--- /dev/null
+++ b/git-arr
@@ -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()
diff --git a/git.py b/git.py
new file mode 100644
index 0000000..023f1a6
--- /dev/null
+++ b/git.py
@@ -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
+
diff --git a/sample.conf b/sample.conf
new file mode 100644
index 0000000..1be7f8e
--- /dev/null
+++ b/sample.conf
@@ -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
diff --git a/static/git-arr.css b/static/git-arr.css
new file mode 100644
index 0000000..2e28c69
--- /dev/null
+++ b/static/git-arr.css
@@ -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;
+}
diff --git a/static/syntax.css b/static/syntax.css
new file mode 100644
index 0000000..097e4d2
--- /dev/null
+++ b/static/syntax.css
@@ -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 */
+
diff --git a/utils.py b/utils.py
new file mode 100644
index 0000000..3bd281f
--- /dev/null
+++ b/utils.py
@@ -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)
+
diff --git a/views/blob.html b/views/blob.html
new file mode 100644
index 0000000..4d5f7d0
--- /dev/null
+++ b/views/blob.html
@@ -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>
diff --git a/views/branch.html b/views/branch.html
new file mode 100644
index 0000000..79ea880
--- /dev/null
+++ b/views/branch.html
@@ -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>
+
diff --git a/views/commit-list.html b/views/commit-list.html
new file mode 100644
index 0000000..3af9838
--- /dev/null
+++ b/views/commit-list.html
@@ -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>
+
diff --git a/views/commit.html b/views/commit.html
new file mode 100644
index 0000000..9a9e99d
--- /dev/null
+++ b/views/commit.html
@@ -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>
+
diff --git a/views/index.html b/views/index.html
new file mode 100644
index 0000000..b218b8b
--- /dev/null
+++ b/views/index.html
@@ -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>
+
diff --git a/views/paginate.html b/views/paginate.html
new file mode 100644
index 0000000..72f3156
--- /dev/null
+++ b/views/paginate.html
@@ -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>
+
diff --git a/views/summary.html b/views/summary.html
new file mode 100644
index 0000000..ce92a60
--- /dev/null
+++ b/views/summary.html
@@ -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>
+
diff --git a/views/tree.html b/views/tree.html
new file mode 100644
index 0000000..9682065
--- /dev/null
+++ b/views/tree.html
@@ -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>