 git-arr             | 66 +++++++++++++++++++++++++++++------------------------
 git.py              | 62 +++++++++++++++++++++++++++++--------------------
 sample.conf         | 23 +++++++++++++++++--
 static/git-arr.css  | 16 +++++++++++++
 utils.py            | 27 ++++++++++++++--------
 views/blob.html     | 58 +++++++++++++++++++++++++++++++++++-----------
 views/branch.html   | 28 +++++++++++++++--------
 views/paginate.html |  2 +-
 views/summary.html  |  3 +--
 views/tree.html     | 15 ++++++------
 10 files changed, 201 insertions(+), 99 deletions(-)

diff --git a/git-arr b/git-arr
index 418f883..a6d6651 100755
--- a/git-arr
+++ b/git-arr
@@ -46,11 +46,12 @@ def load_config(path):
     """
     defaults = {
         'tree': 'yes',
+        'rootdiff': 'yes',
         'desc': '',
         'recursive': 'no',
         'commits_in_summary': '10',
         'commits_per_page': '50',
-        'max_pages': '5',
+        'max_pages': '250',
         'web_url': '',
         'web_url_file': 'web_url',
         'git_url': '',
@@ -105,7 +106,10 @@ def load_config(path):
         r.info.commits_in_summary = config.getint(s, 'commits_in_summary')
         r.info.commits_per_page = config.getint(s, 'commits_per_page')
         r.info.max_pages = config.getint(s, 'max_pages')
+        if r.info.max_pages <= 0:
+            r.info.max_pages = sys.maxint
         r.info.generate_tree = config.getboolean(s, 'tree')
+        r.info.root_diff = config.getboolean(s, 'rootdiff')
 
         r.info.web_url = config.get(s, 'web_url')
         web_url_file = fullpath + '/' + config.get(s, 'web_url_file')
@@ -183,6 +187,8 @@ def with_utils(f):
         'markdown_blob': utils.markdown_blob,
         'can_embed_image': utils.can_embed_image,
         'embed_image_blob': utils.embed_image_blob,
+        'is_binary': utils.is_binary,
+        'hexdump': utils.hexdump,
         'abort': bottle.abort,
         'smstr': git.smstr,
     }
@@ -210,14 +216,7 @@ def index():
 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.route('/r/<repo:repo>/c/<cid:re:[0-9a-f]{5,40}>/')
 @bottle.view('commit')
 @with_utils
 def commit(repo, cid):
@@ -227,38 +226,44 @@ def commit(repo, cid):
 
     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')
+@bottle.route('/r/<repo:repo>/b/<bname:path>/t/f=<fname:path>.html')
+@bottle.route('/r/<repo:repo>/b/<bname:path>/t/<dirname:path>/f=<fname:path>.html')
+@bottle.view('blob')
 @with_utils
-def tree(repo, bname, dirname = ''):
+def blob(repo, bname, fname, dirname = ''):
     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
 
-    r = repo.new_in_branch(bname)
-    return dict(repo = r, tree = r.tree(), dirname = dirname)
+    content = repo.blob(path, bname)
+    if content is None:
+        bottle.abort(404, "File %r not found in branch %s" % (path, bname))
 
-@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)
+    return dict(repo = repo, branch = bname, dirname = dirname, fname = fname,
+                blob = content)
 
+@bottle.route('/r/<repo:repo>/b/<bname:path>/t/')
+@bottle.route('/r/<repo:repo>/b/<bname:path>/t/<dirname:path>/')
+@bottle.view('tree')
+@with_utils
+def tree(repo, bname, dirname = ''):
     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 = repo, branch = bname, tree = repo.tree(bname),
+                dirname = dirname)
 
-    return dict(repo = r, dirname = dirname, fname = fname, blob = content)
+@bottle.route('/r/<repo:repo>/b/<bname:path>/')
+@bottle.route('/r/<repo:repo>/b/<bname:path>/<offset:int>.html')
+@bottle.view('branch')
+@with_utils
+def branch(repo, bname, offset = 0):
+    return dict(repo = repo, branch = bname, offset = offset)
 
 @bottle.route('/static/<path:path>')
 def static(path):
@@ -347,8 +352,9 @@ def generate(output, skip_index = False):
                 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),
+                    'r/%s/b/%s/t/%s%sf=%s.html' %
+                        (str(r.name), str(bn),
+                         dirname.raw, '/' if dirname.raw else '', fname.raw),
                     blob, (r, bn, fname.url, dirname.url), mtime)
             else:
                 write_to('r/%s/b/%s/t/%s/index.html' %
@@ -380,7 +386,7 @@ def generate(output, skip_index = False):
 
             # 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
+            # date, and then compare against it to decide whether or not to
             # write.
             branch_mtime = r.commit(bn).committer_date.epoch
 
diff --git a/git.py b/git.py
index 7e7ad74..636f3a2 100644
--- a/git.py
+++ b/git.py
@@ -41,7 +41,7 @@ class EncodeWrapper:
         return s.decode(self.encoding, errors = self.errors)
 
 
-def run_git(repo_path, params, stdin = None, silent_stderr = False):
+def run_git(repo_path, params, stdin = None, silent_stderr = False, raw = False):
     """Invokes git with the given parameters.
 
     This function invokes git with the given parameters, and returns a
@@ -63,6 +63,9 @@ def run_git(repo_path, params, stdin = None, silent_stderr = False):
         p.stdin.write(stdin)
         p.stdin.close()
 
+    if raw:
+        return p.stdout
+
     # 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:
@@ -81,6 +84,7 @@ class GitCommand (object):
         self._args = list(args)
         self._kwargs = {}
         self._stdin_buf = None
+        self._raw = False
         self._override = False
         for k, v in kwargs:
             self.__setattr__(k, v)
@@ -96,6 +100,12 @@ class GitCommand (object):
         """Adds an argument."""
         self._args.append(a)
 
+    def raw(self, b):
+        """Request raw rather than utf8-encoded command output."""
+        self._override = True
+        self._raw = b
+        self._override = False
+
     def stdin(self, s):
         """Sets the contents we will send in stdin."""
         self._override = True
@@ -115,7 +125,7 @@ class GitCommand (object):
 
         params.extend(self._args)
 
-        return run_git(self._path, params, self._stdin_buf)
+        return run_git(self._path, params, self._stdin_buf, raw = self._raw)
 
 
 class SimpleNamespace (object):
@@ -195,11 +205,8 @@ def unquote(s):
 class Repo:
     """A git repository."""
 
-    def __init__(self, path, branch = None, name = None, info = None):
+    def __init__(self, path, 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()
 
@@ -241,11 +248,6 @@ class Repo:
         """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')
@@ -253,6 +255,7 @@ class Repo:
             cmd.max_count = limit
 
         cmd.arg(ref)
+        cmd.arg('--')
 
         for l in cmd.run():
             yield l.rstrip('\n')
@@ -273,6 +276,7 @@ class Repo:
         cmd.header = None
 
         cmd.arg(ref)
+        cmd.arg('--')
 
         info_buffer = ''
         count = 0
@@ -301,6 +305,8 @@ class Repo:
         cmd.patch = None
         cmd.numstat = None
         cmd.find_renames = None
+        if (self.info.root_diff):
+            cmd.root = None
         # Note we intentionally do not use -z, as the filename is just for
         # reference, and it is safer to let git do the escaping.
 
@@ -321,18 +327,15 @@ class Repo:
 
         return r
 
-    def tree(self, ref = None):
+    def tree(self, ref):
         """Returns a Tree instance for the given ref."""
-        if not ref:
-            ref = self.branch
         return Tree(self, ref)
 
-    def blob(self, path, ref = None, raw = False):
-        """Returns the contents of the given path."""
-        if not ref:
-            ref = self.branch
+    def blob(self, path, ref):
+        """Returns a Blob instance for the given path."""
         cmd = self.cmd('cat-file')
-        cmd.batch = None
+        cmd.raw(True)
+        cmd.batch = '%(objectsize)'
 
         if isinstance(ref, unicode):
             ref = ref.encode('utf8')
@@ -343,11 +346,7 @@ class Repo:
         if not head or head.strip().endswith('missing'):
             return None
 
-        # Raw option in case we need a binary blob and not a utf-8 encoded one.
-        if raw:
-            return out.fd.read()
-
-        return out.read()
+        return Blob(out.read()[:int(head)])
 
     def last_commit_timestamp(self):
         """Return the timestamp of the last commit."""
@@ -452,7 +451,7 @@ class Date:
     def __init__(self, epoch, tz):
         self.epoch = int(epoch)
         self.tz = tz
-        self.utc = datetime.datetime.fromtimestamp(self.epoch)
+        self.utc = datetime.datetime.utcfromtimestamp(self.epoch)
 
         self.tz_sec_offset_min = int(tz[1:3]) * 60 + int(tz[4:])
         if tz[0] == '-':
@@ -546,3 +545,16 @@ class Tree:
             # manipulate otherwise.
             yield otype, smstr(name), size
 
+
+class Blob:
+    """A git blob."""
+
+    def __init__(self, raw_content):
+        self.raw_content = raw_content
+        self._utf8_content = None
+
+    @property
+    def utf8_content(self):
+        if not self._utf8_content:
+            self._utf8_content = self.raw_content.decode('utf8', 'replace')
+        return self._utf8_content
diff --git a/sample.conf b/sample.conf
index 5a4db22..fb8bbdd 100644
--- a/sample.conf
+++ b/sample.conf
@@ -11,6 +11,15 @@ path = /srv/git/repo/
 # Useful to disable an expensive operation in very large repositories.
 #tree = yes
 
+# Show a "creation event" diff for a root commit? (optional)
+# For projects placed under revision control from inception, the root commit
+# diff is often meaningful. However, in cases when a well established, large
+# project is placed under revision control belatedly, the root commit may
+# represent a lump import of the entire project, in which case such a
+# "creation event" diff would likely be considered meaningless noise.
+# Default: yes
+#rootdiff = yes
+
 # How many commits to show in the summary page (optional).
 #commits_in_summary = 10
 
@@ -19,8 +28,9 @@ path = /srv/git/repo/
 
 # Maximum number of per-branch pages for static generation (optional).
 # When generating static html, this is the maximum number of pages we will
-# generate for each branch's commit listings.
-#max_pages = 5
+# generate for each branch's commit listings. Zero (0) means unlimited.
+# Default: 250
+#max_pages = 250
 
 # Project website (optional).
 # URL to the project's website. %(name)s will be replaced with the current
@@ -48,6 +58,15 @@ path = /srv/git/repo/
 # excluded.
 #recursive = no
 
+# Render Markdown blobs (*.md) formatted rather than as raw text? (optional)
+# Requires 'markdown' module.
+# Default: yes
+#embed_markdown = yes
+
+# Render image blobs graphically rather than as raw binary data? (optional)
+# Default: no
+#embed_images = no
+
 
 # Another repository, we don't generate a tree for it because it's too big.
 [linux]
diff --git a/static/git-arr.css b/static/git-arr.css
index 8da2b67..4e0c7ad 100644
--- a/static/git-arr.css
+++ b/static/git-arr.css
@@ -159,6 +159,22 @@ pre.blob-body {
     font-size: medium;
 }
 
+table.blob-binary pre {
+    padding: 0;
+    margin: 0;
+}
+
+table.blob-binary .offset {
+    text-align: right;
+    font-size: x-small;
+    color: darkgray;
+    border-right: 1px solid #eee;
+}
+
+table.blob-binary tr.etc {
+    text-align: center;
+}
+
 /* Pygments overrides. */
 div.linenodiv {
     padding-right: 0.5em;
diff --git a/utils.py b/utils.py
index 46407bd..a7d2886 100644
--- a/utils.py
+++ b/utils.py
@@ -19,6 +19,7 @@ except ImportError:
 
 import base64
 import mimetypes
+import string
 
 def shorten(s, width = 60):
     if len(s) < 60:
@@ -98,14 +99,22 @@ def colorize_blob(fname, s):
 def markdown_blob(s):
     return markdown.markdown(s)
 
-def embed_image_blob(repo, dirname, fname):
+def embed_image_blob(fname, image_data):
     mimetype = mimetypes.guess_type(fname)[0]
-
-    # Unfortunately, bottle seems to require utf-8 encoded data.
-    # We have to refetch the blob with raw=True, because the utf-8 encoded
-    # version of the blob available in the bottle template discards binary data.
-    raw_blob = repo.blob(dirname + fname, raw = True)
-
     return '<img style="max-width:100%;" src="data:{0};base64,{1}" />'.format( \
-                                    mimetype, base64.b64encode(raw_blob))
-
+                                    mimetype, base64.b64encode(image_data))
+
+def is_binary(s):
+    # Git considers a blob binary if NUL in first ~8KB, so do the same.
+    return '\0' in s[:8192]
+
+def hexdump(s):
+    graph = string.ascii_letters + string.digits + string.punctuation + ' '
+    offset = 0
+    while s:
+        t = s[:16]
+        hexvals = ['%.2x' % ord(c) for c in t]
+        text = ''.join(c if c in graph else '.' for c in t)
+        yield offset, ' '.join(hexvals[:8]), ' '.join(hexvals[8:]), text
+        offset += 16
+        s = s[16:]
diff --git a/views/blob.html b/views/blob.html
index 6e493ab..c937b37 100644
--- a/views/blob.html
+++ b/views/blob.html
@@ -4,13 +4,14 @@
 <head>
 
 % if not dirname.raw:
-%     relroot = './'
+%     reltree = './'
 % else:
-%     relroot = '../' * (len(dirname.split('/')) - 1)
+%     reltree = '../' * (len(dirname.split('/')) - 1)
 % end
+% relroot = reltree + '../' * (len(branch.split('/')) - 1)
 
 <title>git &raquo; {{repo.name}} &raquo;
-    {{repo.branch}} &raquo; {{dirname.unicode}}/{{fname.unicode}}</title>
+    {{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"
@@ -21,13 +22,13 @@
 <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>
+    <a href="{{reltree}}../">{{branch}}</a> &raquo;
+    <a href="{{reltree}}">tree</a>
 </h1>
 
 <h3>
-    <a href="{{relroot}}">[{{repo.branch}}]</a> /
-% base = smstr(relroot)
+    <a href="{{reltree}}">[{{branch}}]</a> /
+% base = smstr(reltree)
 % for c in dirname.split('/'):
 %   if not c.raw:
 %     continue
@@ -38,15 +39,46 @@
     <a href="">{{!fname.html}}</a>
 </h3>
 
-% if can_embed_image(repo, fname.unicode):
-{{!embed_image_blob(repo, dirname.raw, fname.raw)}}
+% if len(blob.raw_content) == 0:
+<table class="nice">
+    <tr>
+        <td>empty &mdash; 0 bytes</td>
+    </tr>
+</table>
+% elif can_embed_image(repo, fname.unicode):
+{{!embed_image_blob(fname.raw, blob.raw_content)}}
+% elif is_binary(blob.raw_content):
+<table class="nice blob-binary">
+    <tr>
+        <td colspan="4">
+            binary &mdash; {{'{:,}'.format(len(blob.raw_content))}} bytes
+        </td>
+    </tr>
+%   lim = 256
+%   for offset, hex1, hex2, text in hexdump(blob.raw_content[:lim]):
+    <tr>
+        <td class="offset">{{offset}}</td>
+        <td><pre>{{hex1}}</pre></td>
+        <td><pre>{{hex2}}</pre></td>
+        <td><pre>{{text}}</pre></td>
+    </tr>
+%   end
+%   if lim < len(blob.raw_content):
+    <tr class="etc">
+        <td></td>
+        <td>&hellip;</td>
+        <td>&hellip;</td>
+        <td>&hellip;</td>
+    </tr>
+%   end
+</table>
 % elif can_markdown(repo, fname.unicode):
-{{!markdown_blob(blob)}}
-% elif can_colorize(blob):
-{{!colorize_blob(fname.unicode, blob)}}
+{{!markdown_blob(blob.utf8_content)}}
+% elif can_colorize(blob.utf8_content):
+{{!colorize_blob(fname.unicode, blob.utf8_content)}}
 % else:
 <pre class="blob-body">
-{{blob}}
+{{blob.utf8_content}}
 </pre>
 % end
 
diff --git a/views/branch.html b/views/branch.html
index 79ea880..0ca6834 100644
--- a/views/branch.html
+++ b/views/branch.html
@@ -2,23 +2,26 @@
     "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"/>
+
+% relroot = '../' * (len(branch.split('/')) - 1)
+
+<title>git &raquo; {{repo.name}} &raquo; {{branch}}</title>
+<link rel="stylesheet" type="text/css" href="{{relroot}}../../../../static/git-arr.css"/>
 <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><a href="{{relroot}}../../../../">git</a> &raquo;
+    <a href="{{relroot}}../../">{{repo.name}}</a> &raquo;
+    <a href="./">{{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,
+% commits = repo.commits("refs/heads/" + branch,
+%                           limit = repo.info.commits_per_page + 1,
 %                           offset = repo.info.commits_per_page * offset)
 % commits = list(commits)
 
@@ -26,16 +29,21 @@
 %   abort(404, "No more commits")
 % end
 
+% more = len(commits) > repo.info.commits_per_page
+% if more:
+%   commits = commits[:-1]
+% end
+% more = more and offset + 1 < repo.info.max_pages
 
-% include paginate nelem = len(commits), max_per_page = repo.info.commits_per_page, offset = offset
+% include paginate more = more, offset = offset
 
 % kwargs = dict(repo=repo, commits=commits,
-%               shorten=shorten, repo_root="../..")
+%               shorten=shorten, repo_root=relroot + "../..")
 % include commit-list **kwargs
 
 <p/>
 
-% include paginate nelem = len(commits), max_per_page = repo.info.commits_per_page, offset = offset
+% include paginate more = more, offset = offset
 
 </body>
 </html>
diff --git a/views/paginate.html b/views/paginate.html
index 72f3156..08e2773 100644
--- a/views/paginate.html
+++ b/views/paginate.html
@@ -6,7 +6,7 @@
 <span class="inactive">&larr; prev</span>
 % end
 <span class="sep">|</span>
-% if nelem >= max_per_page:
+% if more:
 <a href="{{offset + 1}}.html">next &rarr;</a>
 % else:
 <span class="inactive">next &rarr;</span>
diff --git a/views/summary.html b/views/summary.html
index ce92a60..c05cca7 100644
--- a/views/summary.html
+++ b/views/summary.html
@@ -39,9 +39,8 @@
 %                   limit = repo.info.commits_in_summary,
 %                   shorten = shorten, repo_root = ".", offset = 0)
 %     include commit-list **kwargs
-% end
-
 <hr/>
+% end
 
 <table class="nice">
     <tr>
diff --git a/views/tree.html b/views/tree.html
index 5edacb8..68a5844 100644
--- a/views/tree.html
+++ b/views/tree.html
@@ -4,13 +4,14 @@
 <head>
 
 % if not dirname.raw:
-%     relroot = './'
+%     reltree = './'
 % else:
-%     relroot = '../' * (len(dirname.split('/')) - 1)
+%     reltree = '../' * (len(dirname.split('/')) - 1)
 % end
+% relroot = reltree + '../' * (len(branch.split('/')) - 1)
 
 <title>git &raquo; {{repo.name}} &raquo;
-    {{repo.branch}} &raquo; {{dirname.unicode}}</title>
+    {{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"/>
@@ -19,13 +20,13 @@
 <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>
+    <a href="{{reltree}}../">{{branch}}</a> &raquo;
+    <a href="{{reltree}}">tree</a>
 </h1>
 
 <h3>
-    <a href="{{relroot}}">[{{repo.branch}}]</a> /
-% base = smstr(relroot)
+    <a href="{{reltree}}">[{{branch}}]</a> /
+% base = smstr(reltree)
 % for c in dirname.split('/'):
 %   if not c.raw:
 %     continue
