git » git-arr » commit 80ef001

Initial commit

author Alberto Bertogli
2012-09-16 10:17:56 UTC
committer Alberto Bertogli
2012-11-10 17:49:54 UTC

Initial commit

Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>

.gitignore +3 -0
LICENSE +25 -0
README +49 -0
TODO +13 -0
git-arr +390 -0
git.py +522 -0
sample.conf +61 -0
static/git-arr.css +168 -0
static/syntax.css +70 -0
utils.py +41 -0
views/blob.html +50 -0
views/branch.html +42 -0
views/commit-list.html +47 -0
views/commit.html +72 -0
views/index.html +29 -0
views/paginate.html +15 -0
views/summary.html +81 -0
views/tree.html +54 -0

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>