commit 80ef0017d47f536bf2c8c6af4b514efa50071a23 Author: Alberto Bertogli Date: Sun Sep 16 11:17:56 2012 +0100 Initial commit Signed-off-by: Alberto Bertogli 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//') +@bottle.view('summary') +@with_utils +def summary(repo): + return dict(repo = repo) + +@bottle.route('/r//b//') +@bottle.route('/r//b//.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//c//') +@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//b//t/') +@bottle.route('/r//b//t//') +@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//b//t/f=.html') +@bottle.route('/r//b//t//f=.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/') +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 += '%s' % 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 '' % ( + 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 /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 @@ + + + + +% if not dirname.raw: +% relroot = './' +% else: +% relroot = '../' * (len(dirname.split('/')) - 1) +% end + +git » {{repo.name}} » + {{repo.branch}} » {{dirname.unicode}}/{{fname.unicode}} + + + + + + +

git » + {{repo.name}} » + {{repo.branch}} » + tree +

+ +

+ [{{repo.branch}}] / +% base = smstr(relroot) +% for c in dirname.split('/'): +% if not c.raw: continue + {{c.unicode}} / +% base += c + '/' +% end + {{!fname.html}} +

+ +% if has_colorizer(): +{{!colorize_blob(fname.unicode, blob)}} +% else: +
+{{blob}}
+
+% end + +
+ + + 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 @@ + + + +git » {{repo.name}} » {{repo.branch}} + + + + + +

git » + {{repo.name}} » + {{repo.branch}} +

+ +

+Browse current source tree +

+ +% 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 + +

+ +% include paginate nelem = len(commits), max_per_page = repo.info.commits_per_page, offset = offset + + + + 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': +{{c[2]}} +% elif c[1] == 'tags': +% if c[2].endswith('^{}'): +% c[2] = c[2][:-3] +% end +{{c[2]}} +% end +% end +% end + + + +% refs = repo.refs() +% if not defined("commits"): +% commits = repo.commits(start_ref, limit = limit, offset = offset) +% end + +% for c in commits: + + + + + % if c.id in refs: + + % end + +% end +
+ {{c.author_date.utc.date()}} + + + {{shorten(c.subject)}} + + {{shorten(c.author_name, 26)}} + + % refs_to_html(refs[c.id]) +
+ 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 @@ + + + +git » {{repo.name}} » commit {{c.id[:7]}} + + + + + + +

git » + {{repo.name}} » commit {{c.id[:7]}} +

+ +

{{c.subject}}

+ + + + + + + +% for p in c.parents: + + +% end +
author{{c.author_name}} +
+ + {{c.author_date.utc}} UTC
committer{{c.author_name}} +
+ + {{c.author_date.utc}} UTC
parent{{p}}
+ +
+ +
+{{c.message.strip()}}
+
+ +
+ +% if c.diff.changes: + + +% for added, deleted, fname in c.diff.changes: + + + + + +% end +
{{!fname.html}}+{{added}}-{{deleted}}
+ +
+ +% if has_colorizer(): +{{!colorize_diff(c.diff.body)}} +% else: +
+{{c.diff.body}}
+
+% end + +
+ +% end + + + + 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 @@ + + + +git + + + + + +

git

+ + + + + + + + % for repo in sorted(repos.values(), key = lambda r: r.name): + + + + + %end +
projectdescription
{{repo.name}}{{repo.info.desc}}
+ + + + 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 @@ + +
+% if offset > 0: +← prev +% else: +← prev +% end +| +% if nelem >= max_per_page: +next → +% else: +next → +% end +
+ 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 @@ + + + +git » {{repo.name}} + + + + + +

git » {{repo.name}}

+ +

{{repo.info.desc}}

+ + +% if repo.info.web_url or repo.info.git_url: + + +% if repo.info.web_url: + + + + +% end +% if repo.info.git_url: + + + + +% end + +
website + {{repo.info.web_url}}
repository{{! '
'.join(repo.info.git_url.split())}}
+
+% 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 + +
+ + + + + + + % for b in repo.branch_names(): + + + + + + %end +
branches
{{b}}
+ +
+ +% tags = list(repo.tags()) +% if tags: + + + + + + % for name, obj_id in tags: + + + + %end +
tags
{{name}}
+% end + + + + 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 @@ + + + + +% if not dirname.raw: +% relroot = './' +% else: +% relroot = '../' * (len(dirname.split('/')) - 1) +% end + +git » {{repo.name}} » + {{repo.branch}} » {{dirname.unicode}} + + + + + +

git » + {{repo.name}} » + {{repo.branch}} » + tree +

+ +

+ [{{repo.branch}}] / +% base = smstr(relroot) +% for c in dirname.split('/'): +% if not c.raw: continue + {{c.unicode}} / +% base += c + '/' +% end +

+ + +% 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): + +% if type == "blob": + + +% elif type == "tree": + +% end + +% end +
+ {{!name.html}}{{size}} + + {{!name.html}}/
+ + +