about summary refs log tree commit homepage
path: root/lib
diff options
context:
space:
mode:
authorEric Wong <e@80x24.org>2016-12-13 21:56:39 +0000
committerEric Wong <e@80x24.org>2016-12-14 00:22:55 +0000
commit00488f0cfe9f81d04cd65d09ea783e860c937401 (patch)
tree24e4d9282d5e7098fc6cbdcc9d5615c46513661f /lib
parentf9d4f28d9761011d3c7ffad9e2c9d1e54b65c519 (diff)
parent6cdb0221d18b2caed4d0caebf7c20d6eb159497d (diff)
downloadpublic-inbox-00488f0cfe9f81d04cd65d09ea783e860c937401.tar.gz
* origin/repobrowse: (98 commits)
  t/repobrowse_git_httpd.t: ensure signature exists for split
  t/repobrowse_git_tree.t: fix test for lack of bold
  repobrowse: fix alignment of gitlink entries
  repobrowse: show invalid type for tree views
  repobrowse: do not bold directory names in tree view
  repobrowse: reduce checks for response fh
  repobrowse: larger, short-lived buffer for reading patches
  repobrowse: reduce risk of callback reference cycles
  repobrowse: snapshot support for cgit compatibility
  test: disable warning for Plack::Test::Impl
  repobrowse: avoid confusing linkification for "diff"
  repobrowse: git commit view uses pi-httpd.async
  repobrowse: more consistent variable naming for /commit/
  repobrowse: show roughly equivalent "diff-tree" invocation
  repobrowse: reduce local variables for state management
  repobrowse: summary handles multiple README types
  repobrowse: remove bold decorations from diff view
  repobrowse: common git diff parsing code
  repobrowse: implement diff view for compatibility
  examples/repobrowse.psgi: disable Chunked response by default
  ...
Diffstat (limited to 'lib')
-rw-r--r--lib/PublicInbox/Git.pm89
-rw-r--r--lib/PublicInbox/GitHTTPBackend.pm2
-rw-r--r--lib/PublicInbox/Hval.pm57
-rw-r--r--lib/PublicInbox/Repobrowse.pm151
-rw-r--r--lib/PublicInbox/RepobrowseBase.pm116
-rw-r--r--lib/PublicInbox/RepobrowseConfig.pm88
-rw-r--r--lib/PublicInbox/RepobrowseGit.pm68
-rw-r--r--lib/PublicInbox/RepobrowseGitAtom.pm117
-rw-r--r--lib/PublicInbox/RepobrowseGitBlob.pm79
-rw-r--r--lib/PublicInbox/RepobrowseGitCommit.pm330
-rw-r--r--lib/PublicInbox/RepobrowseGitDiff.pm131
-rw-r--r--lib/PublicInbox/RepobrowseGitDiffCommon.pm135
-rw-r--r--lib/PublicInbox/RepobrowseGitFallback.pm21
-rw-r--r--lib/PublicInbox/RepobrowseGitLog.pm102
-rw-r--r--lib/PublicInbox/RepobrowseGitPatch.pm80
-rw-r--r--lib/PublicInbox/RepobrowseGitPlain.pm81
-rw-r--r--lib/PublicInbox/RepobrowseGitQuery.pm42
-rw-r--r--lib/PublicInbox/RepobrowseGitSnapshot.pm130
-rw-r--r--lib/PublicInbox/RepobrowseGitSummary.pm102
-rw-r--r--lib/PublicInbox/RepobrowseGitTag.pm187
-rw-r--r--lib/PublicInbox/RepobrowseGitTree.pm196
-rw-r--r--lib/PublicInbox/RepobrowseRoot.pm71
22 files changed, 2351 insertions, 24 deletions
diff --git a/lib/PublicInbox/Git.pm b/lib/PublicInbox/Git.pm
index 59c27470..dee027a3 100644
--- a/lib/PublicInbox/Git.pm
+++ b/lib/PublicInbox/Git.pm
@@ -12,10 +12,35 @@ use warnings;
 use POSIX qw(dup2);
 require IO::Handle;
 use PublicInbox::Spawn qw(spawn popen_rd);
+use IO::File;
+use Fcntl qw(:seek);
+
+# Documentation/SubmittingPatches recommends 12 (Linux v4.4)
+my $abbrev = `git config core.abbrev` || 12;
+
+sub abbrev { "--abbrev=$abbrev" }
 
 sub new {
         my ($class, $git_dir) = @_;
-        bless { git_dir => $git_dir }, $class
+        bless { git_dir => $git_dir, err => IO::File->new_tmpfile }, $class
+}
+
+sub err_begin ($) {
+        my $err = $_[0]->{err};
+        sysseek($err, 0, SEEK_SET) or die "sysseek failed: $!";
+        truncate($err, 0) or die "truncate failed: $!";
+        my $ret = fileno($err);
+        defined $ret or die "fileno failed: $!";
+        $ret;
+}
+
+sub err ($) {
+        my $err = $_[0]->{err};
+        sysseek($err, 0, SEEK_SET) or die "sysseek failed: $!";
+        defined(sysread($err, my $buf, -s $err)) or die "sysread failed: $!";
+        sysseek($err, 0, SEEK_SET) or die "sysseek failed: $!";
+        truncate($err, 0) or die "truncate failed: $!";
+        $buf;
 }
 
 sub _bidi_pipe {
@@ -36,20 +61,43 @@ sub _bidi_pipe {
         $self->{$in} = $in_r;
 }
 
-sub cat_file {
-        my ($self, $obj, $ref) = @_;
-
-        batch_prepare($self);
+sub cat_file_begin {
+        my ($self, $obj) = @_;
+        $self->_bidi_pipe(qw(--batch in out pid));
         $self->{out}->print($obj, "\n") or fail($self, "write error: $!");
 
         my $in = $self->{in};
         local $/ = "\n";
         my $head = $in->getline;
         $head =~ / missing$/ and return undef;
-        $head =~ /^[0-9a-f]{40} \S+ (\d+)$/ or
+        $head =~ /^([0-9a-f]{40}) (\S+) (\d+)$/ or
                 fail($self, "Unexpected result from git cat-file: $head");
 
-        my $size = $1;
+        ($in, $1, $2, $3);
+}
+
+sub cat_file_finish {
+        my ($self, $left) = @_;
+        my $max = 8192;
+        my $in = $self->{in};
+        my $buf;
+        while ($left > 0) {
+                my $r = read($in, $buf, $left > $max ? $max : $left);
+                defined($r) or fail($self, "read failed: $!");
+                $r == 0 and fail($self, 'exited unexpectedly');
+                $left -= $r;
+        }
+
+        my $r = read($in, $buf, 1);
+        defined($r) or fail($self, "read failed: $!");
+        fail($self, 'newline missing after blob') if ($r != 1 || $buf ne "\n");
+}
+
+sub cat_file {
+        my ($self, $obj, $ref) = @_;
+
+        my ($in, $hex, $type, $size) = $self->cat_file_begin($obj);
+        return unless $in;
         my $ref_type = $ref ? ref($ref) : '';
 
         my $rv;
@@ -58,16 +106,8 @@ sub cat_file {
         my $cb_err;
 
         if ($ref_type eq 'CODE') {
-                $rv = eval { $ref->($in, \$left) };
+                $rv = eval { $ref->($in, \$left, $type, $hex) };
                 $cb_err = $@;
-                # drain the rest
-                my $max = 8192;
-                while ($left > 0) {
-                        my $r = read($in, my $x, $left > $max ? $max : $left);
-                        defined($r) or fail($self, "read failed: $!");
-                        $r == 0 and fail($self, 'exited unexpectedly');
-                        $left -= $r;
-                }
         } else {
                 my $offset = 0;
                 my $buf = '';
@@ -80,10 +120,7 @@ sub cat_file {
                 }
                 $rv = \$buf;
         }
-
-        my $r = read($in, my $buf, 1);
-        defined($r) or fail($self, "read failed: $!");
-        fail($self, 'newline missing after blob') if ($r != 1 || $buf ne "\n");
+        $self->cat_file_finish($left);
         die $cb_err if $cb_err;
 
         $rv;
@@ -119,8 +156,16 @@ sub fail {
 
 sub popen {
         my ($self, @cmd) = @_;
-        @cmd = ('git', "--git-dir=$self->{git_dir}", @cmd);
-        popen_rd(\@cmd);
+        my $cmd = [ 'git', "--git-dir=$self->{git_dir}" ];
+        my ($env, $opt);
+        if (ref $cmd[0]) {
+                push @$cmd, @{$cmd[0]};
+                $env = $cmd[1];
+                $opt = $cmd[2];
+        } else {
+                push @$cmd, @cmd;
+        }
+        popen_rd($cmd, $env, $opt);
 }
 
 sub qx {
diff --git a/lib/PublicInbox/GitHTTPBackend.pm b/lib/PublicInbox/GitHTTPBackend.pm
index 1987a013..0275a2a0 100644
--- a/lib/PublicInbox/GitHTTPBackend.pm
+++ b/lib/PublicInbox/GitHTTPBackend.pm
@@ -52,8 +52,8 @@ sub serve {
                                 $path =~ /\Agit-\w+-pack\z/) {
                 my $ok = serve_smart($env, $git, $path);
                 return $ok if $ok;
+                # fall through to dumb HTTP...
         }
-
         serve_dumb($env, $git, $path);
 }
 
diff --git a/lib/PublicInbox/Hval.pm b/lib/PublicInbox/Hval.pm
index 77acecda..15b5fd3e 100644
--- a/lib/PublicInbox/Hval.pm
+++ b/lib/PublicInbox/Hval.pm
@@ -8,16 +8,28 @@ use strict;
 use warnings;
 use Encode qw(find_encoding);
 use PublicInbox::MID qw/mid_clean mid_escape/;
+use URI::Escape qw(uri_escape_utf8);
 use base qw/Exporter/;
-our @EXPORT_OK = qw/ascii_html/;
+our @EXPORT_OK = qw/ascii_html utf8_html to_attr from_attr/;
 
 # for user-generated content (UGC) which may have excessively long lines
 # and screw up rendering on some browsers.  This is the only CSS style
 # feature we use.
 use constant STYLE => '<style>pre{white-space:pre-wrap}</style>';
 
+my $enc_utf8 = find_encoding('UTF-8');
 my $enc_ascii = find_encoding('us-ascii');
 
+sub utf8 {
+        my ($class, $raw, $href) = @_;
+
+        $raw = $enc_utf8->decode($raw);
+        bless {
+                raw => $raw,
+                href => defined $href ? $href : $raw,
+        }, $class;
+}
+
 sub new {
         my ($class, $raw, $href) = @_;
 
@@ -71,7 +83,19 @@ sub ascii_html {
         $enc_ascii->encode($s, Encode::HTMLCREF);
 }
 
+sub utf8_html {
+        my ($raw) = @_;
+        ascii_html($enc_utf8->decode($raw));
+}
+
 sub as_html { ascii_html($_[0]->{raw}) }
+sub as_href { ascii_html(uri_escape_utf8($_[0]->{href})) }
+
+sub as_path {
+        my $p = uri_escape_utf8($_[0]->{href});
+        $p =~ s!%2[fF]!/!g;
+        ascii_html($p);
+}
 
 sub raw {
         if (defined $_[1]) {
@@ -86,4 +110,35 @@ sub prurl {
         index($u, '//') == 0 ? "$env->{'psgi.url_scheme'}:$u" : $u;
 }
 
+# convert a filename (or any string) to HTML attribute
+
+my %ESCAPES = map { chr($_) => sprintf('::%02x', $_) } (0..255);
+$ESCAPES{'/'} = ':'; # common
+
+sub to_attr ($) {
+        my ($str) = @_;
+
+        # git would never do this to us:
+        die "invalid filename: $str" if index($str, '//') >= 0;
+
+        my $first = '';
+        if ($str =~ s/\A([^A-Ya-z])//ms) { # start with a letter
+                  $first = sprintf('Z%02x', ord($1));
+        }
+        $str =~ s/([^A-Za-z0-9_\.\-])/$ESCAPES{$1}/egms;
+        $first . $str;
+}
+
+# reverse the result of to_attr
+sub from_attr ($) {
+        my ($str) = @_;
+        my $first = '';
+        if ($str =~ s/\AZ([a-f0-9]{2})//ms) {
+                $first = chr(hex($1));
+        }
+        $str =~ s!::([a-f0-9]{2})!chr(hex($1))!egms;
+        $str =~ tr!:!/!;
+        $first . $str;
+}
+
 1;
diff --git a/lib/PublicInbox/Repobrowse.pm b/lib/PublicInbox/Repobrowse.pm
new file mode 100644
index 00000000..cdd708e9
--- /dev/null
+++ b/lib/PublicInbox/Repobrowse.pm
@@ -0,0 +1,151 @@
+# Copyright (C) 2015 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+
+# Version control system (VCS) repository viewer like cgit or gitweb,
+# but with optional public-inbox archive integration.
+# This uses cgit-compatible PATH_INFO URLs.
+# This may be expanded to support other Free Software VCSes such as
+# Subversion and Mercurial, so not just git
+#
+# Same web design principles as PublicInbox::WWW for supporting the
+# lowest common denominators (see bottom of Documentation/design_www.txt)
+#
+# This allows an M:N relationship between "normal" repos for project
+# and public-inbox (ssoma) git repositories where N may be zero.
+# In other words, repobrowse must work for repositories without
+# any public-inbox at all; or with multiple public-inboxes.
+# And the rest of public-inbox will always work without a "normal"
+# code repo for the project.
+
+package PublicInbox::Repobrowse;
+use strict;
+use warnings;
+use Plack::Request;
+use URI::Escape qw(uri_escape_utf8 uri_unescape);
+use PublicInbox::RepobrowseConfig;
+
+my %CMD = map { lc($_) => $_ } qw(Log Commit Tree Patch Blob Plain Tag Atom
+        Diff Snapshot);
+my %VCS = (git => 'Git');
+my %LOADED;
+
+sub new {
+        my ($class, $rconfig) = @_;
+        $rconfig ||= PublicInbox::RepobrowseConfig->new;
+        bless { rconfig => $rconfig }, $class;
+}
+
+# simple response for errors
+sub r { [ $_[0], ['Content-Type' => 'text/plain'], [ join(' ', @_, "\n") ] ] }
+
+# Remove trailing slash in URLs which regular humans are likely to read
+# in an attempt to improve cache hit ratios.  Do not redirect
+# plain|patch|blob|fallback endpoints since those could be using
+# automated tools which may not follow redirects automatically
+# (e.g. curl does not follow 301 unless given "-L")
+my %NO_TSLASH = map { $_ => 1 } qw(Log Commit Tree Summary Tag);
+sub no_tslash {
+        my ($cgi) = @_; # Plack::Request
+        my ($base, $uri);
+        $base = $cgi->base;
+        $base =~ s!/+\z!!;
+        $uri = $cgi->request_uri;
+        my $qs = '';
+        if ($uri =~ s/(\?.+)\z//) {
+                $qs = $1;
+        }
+        if ($uri !~ s!/+\z!!) {
+                warn "W: buggy redirect? base=$base request_uri=$uri\n";
+        }
+        my $url = $base . $uri . $qs;
+        [ 301,
+          [ Location => $url, 'Content-Type' => 'text/plain' ],
+          [ "Redirecting to $url\n" ] ]
+}
+
+sub root_index {
+        my ($self) = @_;
+        my $mod = load_once('PublicInbox::RepobrowseRoot');
+        $mod->new->call($self->{rconfig}); # RepobrowseRoot::call
+}
+
+sub call {
+        my ($self, $env) = @_;
+        my $cgi = Plack::Request->new($env);
+        my $method = $cgi->method;
+        return r(405, 'Method Not Allowed') if ($method !~ /\AGET|HEAD|POST\z/);
+
+        # URL syntax: / repo [ / cmd [ / path ] ]
+        # cmd: log | commit | diff | tree | view | blob | snapshot
+        # repo and path (@extra) may both contain '/'
+        my $path_info = uri_unescape($cgi->path_info);
+        my (undef, $repo_path, @extra) = split(m{/+}, $path_info, -1);
+
+        return $self->root_index($self) unless length($repo_path);
+
+        my $rconfig = $self->{rconfig}; # RepobrowseConfig
+        my $repo_info;
+        until ($repo_info = $rconfig->lookup($repo_path)) {
+                my $p = shift @extra or last;
+                $repo_path .= "/$p";
+        }
+        return r404() unless $repo_info;
+
+        my $req = {
+                repo_info => $repo_info,
+                extra => \@extra, # path
+                cgi => $cgi,
+                rconfig => $rconfig,
+                env => $env,
+        };
+        my $tslash = 0;
+        my $cmd = shift @extra;
+        my $vcs_lc = $repo_info->{vcs};
+        my $vcs = $VCS{$vcs_lc} or return r404();
+        my $mod;
+        if (defined $cmd && length $cmd) {
+                $mod = $CMD{$cmd};
+                unless ($mod) {
+                        unshift @extra, $cmd;
+                        $mod = 'Fallback';
+                }
+                $req->{relcmd} = '../' x scalar(@extra);
+        } else {
+                $mod = 'Summary';
+                $cmd = 'summary';
+                if ($path_info =~ m!/\z!) {
+                        $tslash = $path_info =~ tr!/!!;
+                } else {
+                        my @x = split('/', $repo_path);
+                        $req->{relcmd} = @x > 1 ? "./$x[-1]/" : "/$x[-1]/";
+                }
+        }
+        while (@extra && $extra[-1] eq '') {
+                pop @extra;
+                ++$tslash;
+        }
+
+        return no_tslash($cgi) if ($tslash && $NO_TSLASH{$mod});
+
+        $req->{tslash} = $tslash;
+        $mod = load_once("PublicInbox::Repobrowse$vcs$mod");
+        $vcs = load_once("PublicInbox::$vcs");
+        $repo_info->{$vcs_lc} ||= $vcs->new($repo_info->{path});
+
+        $req->{expath} = join('/', @extra);
+        my $rv = eval { $mod->new->call($cmd, $req) }; # RepobrowseBase::call
+        $rv || r404();
+}
+
+sub r404 { r(404, 'Not Found') }
+
+sub load_once {
+        my ($mod) = @_;
+
+        return $mod if $LOADED{$mod};
+        eval "require $mod";
+        $LOADED{$mod} = 1 unless $@;
+        $mod;
+}
+
+1;
diff --git a/lib/PublicInbox/RepobrowseBase.pm b/lib/PublicInbox/RepobrowseBase.pm
new file mode 100644
index 00000000..33647fca
--- /dev/null
+++ b/lib/PublicInbox/RepobrowseBase.pm
@@ -0,0 +1,116 @@
+# Copyright (C) 2015 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+package PublicInbox::RepobrowseBase;
+use strict;
+use warnings;
+require PublicInbox::RepobrowseGitQuery;
+use PublicInbox::Hval;
+our %MIME_TYPE_WHITELIST = ('application/pdf' => 1);
+
+sub new { bless {}, shift }
+
+sub call {
+        my ($self, $cmd, $req) = @_;
+        my $vcs = $req->{repo_info}->{vcs};
+        my $rv = eval {
+                no strict 'refs';
+                my $sub = "call_${vcs}_$cmd";
+                $self->$sub($req);
+        };
+        $@ ? [ 500, ['Content-Type'=>'text/plain'], [] ] : $rv;
+}
+
+sub mime_load {
+        my ($self, $file) = @_;
+        my %rv;
+        open my $fh, '<', $file or return \%rv;
+        foreach (<$fh>) {
+                next if /^#/; # no comments
+                my ($type, @ext) = split(/\s+/);
+
+                if (defined $type) {
+                        $rv{$_} = $type foreach @ext;
+                }
+        }
+        \%rv;
+}
+
+# returns undef if missing, so users can scan the blob if needed
+sub mime_type_unsafe {
+        my ($self, $fn) = @_;
+        $fn =~ /\.([^\.]+)\z/ or return;
+        my $ext = $1;
+        my $m = $self->{mime_types} ||= $self->mime_load('/etc/mime.types');
+        $m->{$ext};
+}
+
+sub mime_type {
+        my ($self, $fn) = @_;
+        my $ct = $self->mime_type_unsafe($fn);
+        return unless defined $ct;
+
+        # XSS protection.  Assume the browser knows what to do
+        # with images/audio/video; but don't allow random HTML from
+        # a repository to be served
+        ($ct =~ m!\A(?:image|audio|video)/! || $MIME_TYPE_WHITELIST{$ct}) ?
+                $ct : undef;
+}
+
+# starts an HTML page for Repobrowse in a consistent way
+sub html_start {
+        my ($self, $req, $title_html, $opts) = @_;
+        my $desc = $req->{repo_info}->{desc_html};
+        my $meta = '';
+
+        if ($opts) {
+                my @robots;
+                foreach (qw(nofollow noindex)) {
+                        push @robots, $_ if $opts->{$_};
+                }
+                $meta = qq(<meta\nname=robots\ncontent=") .
+                        join(',', @robots) . '" />';
+        }
+
+        "<html><head><title>$title_html</title>" .
+                PublicInbox::Hval::STYLE . $meta .
+                "</head><body><pre><b>$desc</b>";
+}
+
+sub r {
+        my ($self, $status, $req, @extra) = @_;
+        my @h;
+
+        my $body = '';
+        if ($status == 301 || $status == 302) {
+                # The goal is to be able to make redirects like we make
+                # <a href=> tags with '../'
+                my $cgi = $req->{cgi};
+                my $base = $cgi->base;
+                my ($redir) = @extra;
+                if ($redir =~ m!\A\.\./!) { # relative redirect
+                        my @orig = split(m!/+!, $cgi->path_info, -1);
+                        shift @orig; # drop leading '/'
+                        my @dest = split(m!/+!, $redir);
+
+                        while ($dest[0] eq '..') {
+                                pop @orig;
+                                shift @dest;
+                        }
+                        my $end = '';
+                        $end = pop @dest if $dest[-1] =~ /\A[#\?]/;
+                        $redir = $base . join('/', @orig, @dest) . $end;
+                } else {
+                        $redir = $base . '/' . $redir;
+                }
+                push @h, qw(Content-Type text/plain Location), $redir;
+
+                # mainly for curl (no-'-L') users:
+                $body = "Redirecting to $redir\n";
+        } else {
+                push @h, qw(Content-Type text/plain);
+        }
+
+        [ $status, \@h, [ $body ] ]
+}
+
+1;
diff --git a/lib/PublicInbox/RepobrowseConfig.pm b/lib/PublicInbox/RepobrowseConfig.pm
new file mode 100644
index 00000000..a08c6cec
--- /dev/null
+++ b/lib/PublicInbox/RepobrowseConfig.pm
@@ -0,0 +1,88 @@
+# Copyright (C) 2015 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+package PublicInbox::RepobrowseConfig;
+use strict;
+use warnings;
+use PublicInbox::Inbox;
+use PublicInbox::Config;
+require PublicInbox::Hval;
+
+sub new {
+        my ($class, $file) = @_;
+        $file = default_file() unless defined($file);
+        my $self = bless PublicInbox::Config::git_config_dump($file), $class;
+        $self->{-cache} = {};
+
+        # hard disable these with '-' prefix by default:
+        $self->{'repobrowse.snapshots'} ||= '-tar.bz2 -tar.xz';
+
+        # for root
+        $self->{-groups} = { -hidden => [], -none => [] };
+        $self;
+}
+
+sub default_file {
+        my $f = $ENV{REPOBROWSE_CONFIG};
+        return $f if defined $f;
+        PublicInbox::Config::config_dir() . '/repobrowse_config';
+}
+
+# Returns something like:
+# {
+#        path => '/home/git/foo.git',
+#        description => 'foo repo',
+#        cloneurl => "git://example.com/foo.git\nhttp://example.com/foo.git",
+#        publicinbox => '/home/pub/foo-public.git',
+# }
+sub lookup {
+        my ($self, $repo_path) = @_; # "git.git"
+        my $rv;
+
+        $rv = $self->{-cache}->{$repo_path} and return $rv;
+
+        my $path = $self->{"repo.$repo_path.path"};
+        (defined $path && -d $path) or return;
+        $rv->{path} = $path;
+        $rv->{repo} = $repo_path;
+
+        # snapshots:
+        my $snap = (split('/', $repo_path))[-1];
+        $snap =~ s/\.git\z//; # seems common for git URLs to end in ".git"
+        $rv->{snapshot_re} = qr/\A\Q$snap\E[-_]/;
+        $rv->{snapshot_pfx} = $snap;
+
+        # gitweb compatibility
+        foreach my $key (qw(description cloneurl)) {
+                $rv->{$key} = PublicInbox::Inbox::try_cat("$path/$key");
+        }
+
+        $rv->{desc_html} =
+                PublicInbox::Hval->new_oneline($rv->{description})->as_html;
+
+        foreach my $key (qw(publicinbox vcs readme group snapshots)) {
+                $rv->{$key} = $self->{"repo.$repo_path.$key"};
+        }
+        unless (defined $rv->{snapshots}) {
+                $rv->{snapshots} = $self->{'repobrowse.snapshots'} || '';
+        }
+
+        my %disabled;
+        foreach (split(/\s+/, $rv->{snapshots})) {
+                s/\A-// and $disabled{$_} = 1;
+        }
+        $rv->{snapshots_disabled} = \%disabled;
+
+        my $g = $rv->{group};
+        defined $g or $g = '-none';
+        if (ref($g) eq 'ARRAY') {
+                push @{$self->{-groups}->{$_} ||= []}, $repo_path foreach @$g;
+        } else {
+                push @{$self->{-groups}->{$g} ||= []}, $repo_path;
+        }
+
+        # of course git is the default VCS
+        $rv->{vcs} ||= 'git';
+        $self->{-cache}->{$repo_path} = $rv;
+}
+
+1;
diff --git a/lib/PublicInbox/RepobrowseGit.pm b/lib/PublicInbox/RepobrowseGit.pm
new file mode 100644
index 00000000..eb79e563
--- /dev/null
+++ b/lib/PublicInbox/RepobrowseGit.pm
@@ -0,0 +1,68 @@
+# Copyright (C) 2015 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ (https://www.gnu.org/licenses/agpl-3.0.txt)
+
+# common functions used by other RepobrowseGit* modules
+package PublicInbox::RepobrowseGit;
+use strict;
+use warnings;
+use base qw(Exporter);
+our @EXPORT_OK = qw(git_unquote git_commit_title git_dec_links);
+use PublicInbox::Hval qw(utf8_html);
+
+my %GIT_ESC = (
+        a => "\a",
+        b => "\b",
+        f => "\f",
+        n => "\n",
+        r => "\r",
+        t => "\t",
+        v => "\013",
+);
+
+sub git_unquote {
+        my ($s) = @_;
+        return $s unless ($s =~ /\A"(.*)"\z/);
+        $s = $1;
+        $s =~ s/\\([abfnrtv])/$GIT_ESC{$1}/g;
+        $s =~ s/\\([0-7]{1,3})/chr(oct($1))/ge;
+        $s;
+}
+
+sub git_commit_title {
+        my ($git, $obj) = @_; # PublicInbox::Git, $sha1hex
+        my $rv;
+        eval {
+                my $buf = $git->cat_file($obj);
+                ($rv) = ($$buf =~ /\r?\n\r?\n([^\r\n]+)\r?\n?/);
+        };
+        $rv;
+}
+
+# example inputs: "HEAD -> master", "tag: v1.0.0",
+sub git_dec_links {
+        my ($rel, $D) = @_;
+        my @l;
+        foreach (split /, /, $D) {
+                if (/\A(\S+) -> (\S+)/) { # 'HEAD -> master'
+                        my ($s, $h) = ($1, $2);
+                        $s = utf8_html($s);
+                        $h = PublicInbox::Hval->utf8($h);
+                        my $r = $h->as_href;
+                        $h = $h->as_html;
+                        push @l, qq($s -&gt; <a\nhref="${rel}log?h=$r">$h</a>);
+                } elsif (s/\Atag: //) {
+                        my $h = PublicInbox::Hval->utf8($_);
+                        my $r = $h->as_href;
+                        $h = $h->as_html;
+                        push @l, qq(<a\nhref="${rel}tag?h=$r"><b>$h</b></a>);
+                } else {
+                        my $h = PublicInbox::Hval->utf8($_);
+                        my $r = $h->as_href;
+                        $h = $h->as_html;
+                        push @l, qq(<a\nhref="${rel}log?h=$r">$h</a>);
+                }
+        }
+        @l;
+}
+
+1;
diff --git a/lib/PublicInbox/RepobrowseGitAtom.pm b/lib/PublicInbox/RepobrowseGitAtom.pm
new file mode 100644
index 00000000..9326841d
--- /dev/null
+++ b/lib/PublicInbox/RepobrowseGitAtom.pm
@@ -0,0 +1,117 @@
+# Copyright (C) 2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+
+# show log as an Atom feed
+package PublicInbox::RepobrowseGitAtom;
+use strict;
+use warnings;
+use PublicInbox::Hval qw(utf8_html);
+use base qw(PublicInbox::RepobrowseBase);
+my $ATOM_FMT = '--pretty=tformat:'.
+                join('%x00', qw(%s %ct %an %ae %at %h %H %b), '', '');
+
+use constant DATEFMT => '%Y-%m-%dT%H:%M:%SZ';
+use POSIX qw(strftime);
+
+sub call_git_atom {
+        my ($self, $req) = @_;
+        my $repo_info = $req->{repo_info};
+        my $max = $repo_info->{max_commit_count} || 10;
+        $max = int($max);
+        $max = 50 if $max == 0;
+
+        my $git = $repo_info->{git};
+        my $q = PublicInbox::RepobrowseGitQuery->new($req->{cgi});
+        my $h = $q->{h};
+        $h eq '' and chomp($h = $git->qx(qw(symbolic-ref --short HEAD)));
+
+        my @cmd = (qw(log --no-notes --no-color --abbrev-commit),
+                        $git->abbrev, $ATOM_FMT, "-$max", $h, '--');
+        push @cmd, $req->{expath} if length($req->{expath});
+        my $log = $git->popen(@cmd);
+
+        sub {
+                my ($res) = @_; # Plack callback
+                my @h = ( 'Content-Type' => 'application/atom+xml' );
+                my $fh = $res->([200, \@h]);
+                $self->git_atom_stream($req, $q, $log, $fh, $h);
+                $fh->close;
+        }
+}
+
+sub repo_root_url {
+        my ($self, $req) = @_;
+        my $cgi = $req->{cgi};
+        my $uri = $cgi->request_uri;
+        $uri =~ s/\?.+\z//; # no query string
+        my @uri = split(m!/+!, $uri);
+        shift @uri; # leading slash
+        my @extra = @{$req->{extra}};
+        while (@uri && @extra && $uri[-1] eq $extra[-1]) {
+                pop @uri;
+                pop @extra;
+        }
+        pop @uri if $uri[-1] eq 'atom'; # warn if not equal?
+        $cgi->base . join('/', @uri);
+}
+
+sub git_atom_stream {
+        my ($self, $req, $q, $log, $fh, $h) = @_;
+        my $repo_info = $req->{repo_info};
+        my $title = join('/', $repo_info->{repo}, @{$req->{extra}});
+        $title = utf8_html("$title, branch $h");
+
+        my $url = $self->repo_root_url($req);
+        $fh->write(qq(<?xml version="1.0"?>\n) .
+                qq(<feed\nxmlns="http://www.w3.org/2005/Atom">) .
+                qq(<title>$title</title>) .
+                qq(<subtitle>$repo_info->{desc_html}</subtitle>) .
+                qq(<link\nrel="alternate"\ntype="text/html"\nhref="$url"\n/>));
+        my $rel = $req->{relcmd};
+        my %acache;
+        local $/ = "\0";
+        while (defined(my $s = <$log>)) {
+                chomp $s;
+                my $entry = '<entry><title>';
+                $entry .= utf8_html($s); # commit subject
+                $entry .= '</title><updated>';
+
+                chomp($s = <$log>); # commit time
+                $entry .= strftime(DATEFMT, gmtime($s));
+                $entry .= '</updated><author><name>';
+
+                chomp($s = <$log>); # author name
+                $entry .= $acache{$s} ||= utf8_html($s);
+                $entry .= '</name><email>';
+
+                chomp($s = <$log>); # author email
+                $entry .= $acache{$s} ||= utf8_html($s);
+                $entry .= '</email></author><published>';
+
+                chomp($s = <$log>); # author time
+                $entry .= strftime(DATEFMT, gmtime($s));
+                $entry .= '</published>';
+
+                $entry .= qq(<link\nrel="alternate"\ntype="text/html"\nhref=");
+                $entry .= $url;
+                chomp($s = <$log>); # abbreviated commit hash for URL
+                $entry .= qq(/commit?id=$s"\n/><id>);
+                chomp($s = <$log>); # unabbreviated commit hash
+                $entry .= $s;
+                $entry .= qq(</id>);
+
+                $entry .= qq(<content\ntype="xhtml"><div\nxmlns=");
+                $entry .= qq(http://www.w3.org/1999/xhtml">);
+                $entry .= qq(<pre\nstyle="white-space:pre-wrap">\n);
+                chomp($s = <$log>);
+                $entry .= utf8_html($s);  # body
+                $fh->write($entry .= qq(</pre></div></content></entry>));
+                eval {
+                        local $/ = "\0\n";
+                        $s = <$log>;
+                };
+        }
+        $fh->write('</feed>');
+}
+
+1;
diff --git a/lib/PublicInbox/RepobrowseGitBlob.pm b/lib/PublicInbox/RepobrowseGitBlob.pm
new file mode 100644
index 00000000..f2b38a0c
--- /dev/null
+++ b/lib/PublicInbox/RepobrowseGitBlob.pm
@@ -0,0 +1,79 @@
+# Copyright (C) 2015-2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+
+# Show a blob as-is
+package PublicInbox::RepobrowseGitBlob;
+use strict;
+use warnings;
+use base qw(PublicInbox::RepobrowseBase);
+use base qw(Exporter);
+our @EXPORT = qw(git_blob_mime_type git_blob_stream_response);
+
+sub call_git_blob {
+        my ($self, $req) = @_;
+        my $git = $req->{repo_info}->{git};
+        my $q = PublicInbox::RepobrowseGitQuery->new($req->{cgi});
+        my $id = $q->{id};
+        $id eq '' and $id = 'HEAD';
+
+        if (length(my $expath = $req->{expath})) {
+                $id .= ":$expath";
+        }
+        my ($cat, $hex, $type, $size) = $git->cat_file_begin($id);
+        return unless defined $cat;
+
+        my ($r, $buf);
+        my $left = $size;
+        if ($type eq 'blob') {
+                $type = git_blob_mime_type($self, $req, $cat, \$buf, \$left);
+        } elsif ($type eq 'commit' || $type eq 'tag') {
+                $type = 'text/plain; charset=UTF-8';
+        } else {
+                $type = 'application/octet-stream';
+        }
+        git_blob_stream_response($git, $cat, $size, $type, $buf, $left);
+}
+
+sub git_blob_mime_type {
+        my ($self, $req, $cat, $buf, $left) = @_;
+        my $base = $req->{extra}->[-1];
+        my $type = $self->mime_type($base) if defined $base;
+        return $type if $type;
+
+        my $to_read = 8000; # git uses this size to detect binary files
+        $to_read = $$left if $to_read > $$left;
+        my $r = read($cat, $$buf, $to_read);
+        if (!defined $r || $r <= 0) {
+                my $git = $req->{repo_info}->{git};
+                $git->cat_file_finish($$left);
+                return;
+        }
+        $$left -= $r;
+        (index($buf, "\0") < 0) ? 'text/plain; charset=UTF-8'
+                                : 'application/octet-stream';
+}
+
+sub git_blob_stream_response {
+        my ($git, $cat, $size, $type, $buf, $left) = @_;
+
+        sub {
+                my ($res) = @_;
+                my $to_read = 8192;
+                eval {
+                        my $fh = $res->([ 200, ['Content-Length' => $size,
+                                                'Content-Type' => $type]]);
+                        $fh->write($buf) if defined $buf;
+                        while ($left > 0) {
+                                $to_read = $left if $to_read > $left;
+                                my $r = read($cat, $buf, $to_read);
+                                last if (!defined $r || $r <= 0);
+                                $left -= $r;
+                                $fh->write($buf);
+                        }
+                        $fh->close;
+                };
+                $git->cat_file_finish($left);
+        }
+}
+
+1;
diff --git a/lib/PublicInbox/RepobrowseGitCommit.pm b/lib/PublicInbox/RepobrowseGitCommit.pm
new file mode 100644
index 00000000..227dad97
--- /dev/null
+++ b/lib/PublicInbox/RepobrowseGitCommit.pm
@@ -0,0 +1,330 @@
+# Copyright (C) 2015 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+
+# shows the /commit/ endpoint for git repositories
+#
+# anchors used:
+#        D - diffstat
+#        P - parents
+#        ...and various filenames from to_attr
+# The 'D' and 'P' anchors may conflict with odd filenames, but we won't
+# punish the common case with extra bytes if somebody uses 'D' or 'P'
+# in filenames.
+
+package PublicInbox::RepobrowseGitCommit;
+use strict;
+use warnings;
+use base qw(PublicInbox::RepobrowseBase);
+use PublicInbox::Hval qw(utf8_html to_attr);
+use PublicInbox::RepobrowseGit qw(git_unquote git_commit_title);
+use PublicInbox::RepobrowseGitDiffCommon qw/git_diffstat_emit
+        git_diff_ab_index git_diff_ab_hdr git_diff_ab_hunk/;
+
+use constant GIT_FMT => '--pretty=format:'.join('%n',
+        '%H', '%h', '%s', '%an <%ae>', '%ai', '%cn <%ce>', '%ci',
+        '%t', '%p', '%D', '%b%x00');
+
+use constant CC_EMPTY => " This is a merge, and the combined diff is empty.\n";
+use constant CC_MERGE => " This is a merge, showing combined diff:\n\n";
+
+sub commit_header {
+        my ($self, $req) = @_;
+        my $res = delete $req->{res} or die "BUG: missing res\n";
+        my ($H, $h, $s, $au, $ad, $cu, $cd, $t, $p, $D, $rest) =
+                split("\n", $req->{dbuf}, 11);
+        $s = utf8_html($s);
+        $au = utf8_html($au);
+        $cu = utf8_html($cu);
+        my @p = split(' ', $p);
+        my $fh = $req->{fh} = $res->([200, ['Content-Type'=>'text/html']]);
+
+        my $rel = $req->{relcmd};
+        my $q = $req->{'q'};
+        my $qs = $req->{qs} = $q->qs(id => $h);
+        my $x = $self->html_start($req, $s) . "\n" .
+                qq(   commit $H (<a\nhref="${rel}patch$qs">patch</a>)\n) .
+                qq(     tree <a\nrel=nofollow\nhref="${rel}tree?id=$h">$t</a>);
+
+        my $git = $req->{repo_info}->{git};
+        # extra show path information, if any
+        my $extra = $req->{extra};
+        my $path = '';
+        if (@$extra) {
+                my @t;
+                my $ep;
+                $x .= ' -- ';
+                $x .= join('/', map {
+                        push @t, $_;
+                        my $e = PublicInbox::Hval->utf8($_, join('/', @t));
+                        $ep = $e->as_path;
+                        my $eh = $e->as_html;
+                        $ep = "${rel}tree/$ep?id=$h";
+                        qq(<a\nrel=nofollow\nhref="$ep">$eh</a>);
+                } @$extra);
+                $path = "/$ep";
+        }
+
+        $x .= "\n   author $au\t$ad\ncommitter $cu\t$cd\n";
+        my $np = scalar @p;
+        if ($np == 1) {
+                my $p = $p[0];
+                $x .= git_parent_line('   parent', $p, $q, $git, $rel, $path);
+        } elsif ($np > 1) {
+                $req->{help} = CC_MERGE;
+                my @common = ($q, $git, $rel, $path);
+                my @t = @p;
+                my $p = shift @t;
+                $x .= git_parent_line('  parents', $p, @common);
+                foreach $p (@t) {
+                        $x .= git_parent_line('         ', $p, @common);
+                }
+        }
+        $x .= "\n<b>";
+        $x .= $s;
+        $x .= "</b>\n\n";
+        my $bx00;
+        ($bx00, $req->{dbuf}) = split("\0", $rest, 2);
+        $fh->write($x .= utf8_html($bx00) . "<a\nid=D>---</a>\n");
+        $req->{anchors} = {};
+        $req->{h} = $h;
+        $req->{p} = \@p;
+}
+
+sub git_diff_cc_line_i ($$) {
+        my ($req, $l) = @_;
+        my $cmt = '[a-f0-9]+';
+
+        if ($l =~ m{^diff --git ("?a/.+) ("?b/.+)$}) { # regular
+                git_diff_ab_hdr($req, $1, $2) . "\n";
+        } elsif ($l =~ m{^diff --(cc|combined) (.+)$}) {
+                git_diff_cc_hdr($req, $1, $2) . "\n";
+        } elsif ($l =~ /^index ($cmt)\.\.($cmt)(.*)$/o) { # regular
+                git_diff_ab_index($1, $2, $3) . "\n";
+        } elsif ($l =~ /^@@ (\S+) (\S+) @@(.*)$/) { # regular
+                git_diff_ab_hunk($req, $1, $2, $3) . "\n";
+        } elsif ($l =~ /^index ($cmt,[^\.]+)\.\.($cmt)(.*)$/o) { # --cc
+                git_diff_cc_index($req, $1, $2, $3) . "\n";
+        } elsif ($l =~ /^(@@@+) (\S+.*\S+) @@@+(.*)$/) { # --cc
+                git_diff_cc_hunk($req, $1, $2, $3) . "\n";
+        } else {
+                utf8_html($l) . "\n";
+        }
+}
+
+sub git_commit_stream ($$$$) {
+        my ($self, $req, $fail, $end) = @_;
+        my $dbuf = \($req->{dbuf});
+        my $off = length($$dbuf);
+        my $n = $req->{rpipe}->sysread($$dbuf, 8192, $off);
+        return $fail->() unless defined $n;
+        return $end->() if $n == 0;
+        my $res = $req->{res};
+        if ($res) {
+                return if index($$dbuf, "\0") < 0;
+                commit_header($self, $req);
+                return if $$dbuf eq '';
+        }
+        my $fh = $req->{fh};
+        if (!$req->{diff_state}) {
+                my ($stat, $buf) = split("\0\0", $$dbuf, 2);
+                return unless defined $buf;
+                $$dbuf = $buf;
+                git_diffstat_emit($req, $fh, $stat);
+                $req->{diff_state} = 1;
+        }
+        my @buf = split("\n", $$dbuf, -1);
+        $$dbuf = pop @buf; # last line, careful...
+        if (@buf) {
+                my $s = delete($req->{help}) || '';
+                $s .= git_diff_cc_line_i($req, $_) foreach @buf;
+                $fh->write($s) if $s ne '';
+        }
+}
+
+sub call_git_commit {
+        my ($self, $req) = @_;
+
+        my $q = PublicInbox::RepobrowseGitQuery->new($req->{cgi});
+        my $id = $q->{id};
+        $id eq '' and $id = 'HEAD';
+
+        my $expath = $req->{expath};
+        if ($expath ne '') {
+                my $relup = join('', map { '../' } @{$req->{extra}});
+                my $qs = $q->qs;
+                return $self->r(301, $req, "$relup$qs#".to_attr($expath));
+        }
+
+        my $git = $req->{repo_info}->{git};
+        my $cmd = [ qw(show -z --numstat -p --encoding=UTF-8
+                        --no-notes --no-color -c),
+                        $git->abbrev, GIT_FMT, $id, '--' ];
+        $req->{rpipe} = $git->popen($cmd, undef, { 2 => $git->err_begin });
+        my $env = $req->{cgi}->env;
+        my $err = $env->{'psgi.errors'};
+        my $vin;
+        $req->{dbuf} = '';
+        my $end = sub {
+                if (my $fh = delete $req->{fh}) {
+                        my $dbuf = delete $req->{dbuf};
+                        if (!$req->{diff_state}) {
+                                my ($stat, $buf) = split("\0\0", $dbuf, 2);
+                                $dbuf = defined $buf ? $buf : '';
+                                git_diffstat_emit($req, $fh, $stat);
+                                $req->{diff_state} = 1;
+                        }
+                        my @buf = split("\n", $dbuf, -1);
+                        if (@buf) {
+                                my $s = delete($req->{help}) || '';
+                                $s .= git_diff_cc_line_i($req, $_) foreach @buf;
+                                $fh->write($s) if $s ne '';
+                        }
+                        $fh->write(CC_EMPTY) if delete($req->{help});
+                        show_unchanged($req, $fh);
+                        $fh->write('</pre></body></html>');
+                        $fh->close;
+                } elsif (my $res = delete $req->{res}) {
+                        git_commit_404($req, $res);
+                }
+                if (my $rpipe = delete $req->{rpipe}) {
+                        $rpipe->close; # _may_ be Danga::Socket::close
+                }
+                # zero the error file for now, be careful about printing
+                # $id to psgi.errors w/o sanitizing...
+                $git->err;
+        };
+        my $fail = sub {
+                if ($!{EAGAIN} || $!{EINTR}) {
+                        select($vin, undef, undef, undef) if defined $vin;
+                        # $vin is undef on async, so this is a noop on EAGAIN
+                        return;
+                }
+                my $e = $!;
+                $end->();
+                $err->print("git show ($git->{git_dir}): $e\n");
+        };
+        $req->{'q'} = $q;
+        my $cb = sub { # read git-show output and stream to client
+                git_commit_stream($self, $req, $fail, $end);
+        };
+        if (my $async = $env->{'pi-httpd.async'}) {
+                $req->{rpipe} = $async->($req->{rpipe}, $cb);
+                sub { $req->{res} = $_[0] } # let Danga::Socket handle the rest
+        } else { # synchronous loop for other PSGI servers
+                $vin = '';
+                vec($vin, fileno($req->{rpipe}), 1) = 1;
+                sub {
+                        $req->{res} = $_[0];
+                        while ($req->{rpipe}) { $cb->() }
+                }
+        }
+}
+
+sub git_commit_404 {
+        my ($req, $res) = @_;
+        my $x = 'Missing commit or path';
+        my $pfx = "$req->{relcmd}commit";
+
+        my $try = 'try';
+        $x = "<html><head><title>$x</title></head><body><pre><b>$x</b>\n\n";
+        my $qs = $req->{'q'}->qs(id => '');
+        $x .= "<a\nhref=\"$pfx$qs\">$try the latest commit in HEAD</a>\n";
+        $x .= '</pre></body>';
+
+        $res->([404, ['Content-Type'=>'text/html'], [ $x ]]);
+}
+
+sub git_diff_cc_hdr {
+        my ($req, $combined, $path) = @_;
+        my $html_path = utf8_html($path);
+        $path = git_unquote($path);
+        my $anchor = to_attr($path);
+        delete $req->{anchors}->{$anchor};
+        my $cc = $req->{cc} = PublicInbox::Hval->utf8($path);
+        $req->{path_cc} = $cc->as_path;
+        qq(<a\nid="$anchor">diff</a> --$combined $html_path);
+}
+
+# index abcdef09,01234567..76543210
+sub git_diff_cc_index {
+        my ($req, $before, $last, $end) = @_;
+        $end = utf8_html($end);
+        my @before = split(',', $before);
+        $req->{pobj_cc} = \@before;
+
+        # not wasting bandwidth on links here, yet
+        # links in hunk headers are far more useful with line offsets
+        "index $before..$last$end";
+}
+
+# @@@ -1,2 -3,4 +5,6 @@@ (combined diff)
+sub git_diff_cc_hunk {
+        my ($req, $at, $offs, $ctx) = @_;
+        my @offs = split(' ', $offs);
+        my $last = pop @offs;
+        my @p = @{$req->{p}};
+        my @pobj = @{$req->{pobj_cc}};
+        my $path = $req->{path_cc};
+        my $rel = $req->{relcmd};
+        my $rv = $at;
+
+        # special 'cc' action as we don't have reliable paths from parents
+        my $ppath = "${rel}cc/$path";
+        foreach my $off (@offs) {
+                my $p = shift @p;
+                my $obj = shift @pobj; # blob SHA-1
+                my ($n) = ($off =~ /\A-(\d+)/); # line number
+
+                if ($n == 0) { # new file (does this happen with --cc?)
+                        $rv .= " $off";
+                } else {
+                        $rv .= " <a\nhref=\"$ppath?id=$p&obj=$obj#n$n\">";
+                        $rv .= "$off</a>";
+                }
+        }
+
+        # we can use the normal 'tree' endpoint for the result
+        my ($n) = ($last =~ /\A\+(\d+)/); # line number
+        if ($n == 0) { # deleted file (does this happen with --cc?)
+                $rv .= " $last";
+        } else {
+                my $h = $req->{h};
+                $rv .= qq( <a\nrel=nofollow);
+                $rv .= qq(\nhref="${rel}tree/$path?id=$h#n$n">$last</a>);
+        }
+        $rv .= " $at" . utf8_html($ctx);
+}
+
+sub git_parent_line {
+        my ($pfx, $p, $q, $git, $rel, $path) = @_;
+        my $qs = $q->qs(id => $p);
+        my $t = git_commit_title($git, $p);
+        $t = defined $t ? utf8_html($t) : '';
+        $pfx . qq( <a\nid=P\nhref="${rel}commit$path$qs">$p</a> $t\n);
+}
+
+# do not break anchor links if the combined diff doesn't show changes:
+sub show_unchanged {
+        my ($req, $fh) = @_;
+
+        my @unchanged = sort keys %{$req->{anchors}};
+        return unless @unchanged;
+        my $anchors = $req->{anchors};
+        my $s = "\n There are uninteresting changes from this merge.\n" .
+                qq( See the <a\nhref="#P">parents</a>, ) .
+                "or view final state(s) below:\n\n";
+        my $rel = $req->{relcmd};
+        my $qs = $req->{qs};
+        foreach my $anchor (@unchanged) {
+                my $fn = $anchors->{$anchor};
+                my $p = PublicInbox::Hval->utf8(git_unquote($fn));
+                $p = $p->as_path;
+                $fn = utf8_html($fn);
+                $s .= qq(\t<a\nrel=nofollow);
+                $s .= qq(\nid="$anchor"\nhref="${rel}tree/$p$qs">);
+                $s .= "$fn</a>\n";
+        }
+        $fh->write($s);
+}
+
+1;
diff --git a/lib/PublicInbox/RepobrowseGitDiff.pm b/lib/PublicInbox/RepobrowseGitDiff.pm
new file mode 100644
index 00000000..02a2c562
--- /dev/null
+++ b/lib/PublicInbox/RepobrowseGitDiff.pm
@@ -0,0 +1,131 @@
+# Copyright (C) 2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+
+# shows the /diff endpoint for git repositories for cgit compatibility
+# usage: /repo.git/diff?id=COMMIT_ID&id2=COMMIT_ID2
+#
+# FIXME: much duplicated code between this and RepobrowseGitCommit.pm
+#
+# We probably will not link to this outright because it's expensive,
+# but exists to preserve URL compatibility.
+package PublicInbox::RepobrowseGitDiff;
+use strict;
+use warnings;
+use base qw(PublicInbox::RepobrowseBase);
+use PublicInbox::Hval qw(utf8_html to_attr);
+use PublicInbox::RepobrowseGit qw(git_unquote git_commit_title);
+use PublicInbox::RepobrowseGitDiffCommon qw/git_diffstat_emit
+        git_diff_ab_index git_diff_ab_hdr git_diff_ab_hunk/;
+
+sub call_git_diff {
+        my ($self, $req) = @_;
+        my $git = $req->{repo_info}->{git};
+        my $q = PublicInbox::RepobrowseGitQuery->new($req->{cgi});
+        my $id = $q->{id};
+        my $id2 = $q->{id2};
+
+        my @cmd = (qw(diff-tree -z --numstat -p --encoding=UTF-8
+                        --no-notes --no-color -M -B -D -r),
+                        $id2, $id, '--');
+        my $expath = $req->{expath};
+        push @cmd, $expath if defined $expath;
+        $req->{rpipe} = $git->popen(\@cmd, undef, { 2 => $git->err_begin });
+        my $env = $req->{cgi}->env;
+        my $err = $env->{'psgi.errors'};
+        my ($vin);
+        $req->{dbuf} = '';
+        $req->{p} = [ $id2 ];
+        $req->{h} = $id;
+        my $end = sub {
+                if (my $fh = delete $req->{fh}) {
+                        # write out the last bit that was buffered
+                        my @buf = split(/\n/, delete $req->{dbuf}, -1);
+                        my $s = '';
+                        $s .= git_diff_line_i($req, $_) foreach @buf;
+                        $s .= '</pre></body></html>';
+                        $fh->write($s);
+
+                        $fh->close;
+                } elsif (my $res = delete $req->{res}) {
+                        $res->($self->r(500));
+                }
+                if (my $rpipe = delete $req->{rpipe}) {
+                        $rpipe->close; # _may_ be Danga::Socket::close
+                }
+        };
+        my $fail = sub {
+                if ($!{EAGAIN} || $!{EINTR}) {
+                        select($vin, undef, undef, undef) if defined $vin;
+                        # $vin is undef on async, so this is a noop on EAGAIN
+                        return;
+                }
+                my $e = $!;
+                $end->();
+                $err->print("git diff ($git->{git_dir}): $e\n");
+        };
+        my $cb = sub {
+                my $off = length($req->{dbuf});
+                my $n = $req->{rpipe}->sysread($req->{dbuf}, 8192, $off);
+                return $fail->() unless defined $n;
+                return $end->() if $n == 0;
+                if (my $res = delete $req->{res}) {
+                        my $h = ['Content-Type', 'text/html; charset=UTF-8'];
+                        my $fh = $req->{fh} = $res->([200, $h]);
+                        my $o = { nofollow => 1, noindex => 1 };
+                        my $ex = defined $expath ? " $expath" : '';
+                        $fh->write($self->html_start($req, 'diff', $o).
+                                        "\n\n".
+                                        utf8_html("git diff-tree -r -M -B -D ".
+                                                "$id2 $id --$ex"). "\n\n");
+                }
+                git_diff_to_html($req);
+        };
+        if (my $async = $env->{'pi-httpd.async'}) {
+                $req->{rpipe} = $async->($req->{rpipe}, $cb);
+                sub { $req->{res} = $_[0] } # let Danga::Socket handle the rest.
+        } else { # synchronous loop for other PSGI servers
+                $vin = '';
+                vec($vin, fileno($req->{rpipe}), 1) = 1;
+                sub {
+                        $req->{res} = $_[0];
+                        while ($req->{rpipe}) { $cb->() }
+                }
+        }
+}
+
+sub git_diff_line_i {
+        my ($req, $l) = @_;
+        my $cmt = '[a-f0-9]+';
+
+        if ($l =~ m{^diff --git ("?a/.+) ("?b/.+)$}) { # regular
+                $l = git_diff_ab_hdr($req, $1, $2);
+        } elsif ($l =~ /^index ($cmt)\.\.($cmt)(.*)$/o) { # regular
+                $l = git_diff_ab_index($1, $2, $3);
+        } elsif ($l =~ /^@@ (\S+) (\S+) @@(.*)$/) { # regular
+                $l = git_diff_ab_hunk($req, $1, $2, $3);
+        } else {
+                $l = utf8_html($l);
+        }
+        $l .= "\n";
+}
+
+sub git_diff_to_html {
+        my ($req) = @_;
+        my $fh = $req->{fh};
+        if (!$req->{diff_state}) {
+                my ($stat, $buf) = split(/\0\0/, $req->{dbuf}, 2);
+                return unless defined $buf;
+                $req->{dbuf} = $buf;
+                git_diffstat_emit($req, $fh, $stat);
+                $req->{diff_state} = 1;
+        }
+        my @buf = split(/\n/, $req->{dbuf}, -1);
+        $req->{dbuf} = pop @buf; # last line, careful...
+        if (@buf) {
+                my $s = '';
+                $s .= git_diff_line_i($req, $_) foreach @buf;
+                $fh->write($s) if $s ne '';
+        }
+}
+
+1;
diff --git a/lib/PublicInbox/RepobrowseGitDiffCommon.pm b/lib/PublicInbox/RepobrowseGitDiffCommon.pm
new file mode 100644
index 00000000..9ed24d03
--- /dev/null
+++ b/lib/PublicInbox/RepobrowseGitDiffCommon.pm
@@ -0,0 +1,135 @@
+# Copyright (C) 2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+
+# common git diff-related code
+package PublicInbox::RepobrowseGitDiffCommon;
+use strict;
+use warnings;
+use PublicInbox::RepobrowseGit qw/git_unquote git_commit_title/;
+use PublicInbox::Hval qw/utf8_html to_attr/;
+use base qw/Exporter/;
+our @EXPORT_OK = qw/git_diffstat_emit
+        git_diff_ab_index git_diff_ab_hdr git_diff_ab_hunk/;
+
+# index abcdef89..01234567
+sub git_diff_ab_index ($$$) {
+        my ($xa, $xb, $end) = @_;
+        # not wasting bandwidth on links here, yet
+        # links in hunk headers are far more useful with line offsets
+        $end = utf8_html($end);
+        "index $xa..$xb$end";
+}
+
+# diff --git a/foo.c b/bar.c
+sub git_diff_ab_hdr ($$$) {
+        my ($req, $fa, $fb) = @_;
+        my $html_a = utf8_html($fa);
+        my $html_b = utf8_html($fb);
+        $fa = git_unquote($fa);
+        $fb = git_unquote($fb);
+        $fa =~ s!\Aa/!!;
+        $fb =~ s!\Ab/!!;
+        my $anchor = to_attr($fb);
+        delete $req->{anchors}->{$anchor};
+        $fa = $req->{fa} = PublicInbox::Hval->utf8($fa);
+        $fb = $req->{fb} = PublicInbox::Hval->utf8($fb);
+        $req->{path_a} = $fa->as_path;
+        $req->{path_b} = $fb->as_path;
+
+        # not wasting bandwidth on links here
+        # links in hunk headers are far more useful with line offsets
+        qq(<a\nid="$anchor">diff</a> --git $html_a $html_b);
+}
+
+# @@ -1,2 +3,4 @@ (regular diff)
+sub git_diff_ab_hunk ($$$$) {
+        my ($req, $ca, $cb, $ctx) = @_;
+        my ($na) = ($ca =~ /\A-(\d+)/);
+        my ($nb) = ($cb =~ /\A\+(\d+)/);
+
+        # we add "rel=nofollow" here to reduce load on search engines, here
+        my $rel = $req->{relcmd};
+        my $rv = '@@ ';
+        if (defined($na) && $na == 0) { # new file
+                $rv .= $ca;
+        } else {
+                $na = defined $na ? "#n$na" : '';
+                my $p = $req->{p}->[0];
+                $rv .= qq(<a\nrel=nofollow);
+                $rv .= qq(\nhref="${rel}tree/$req->{path_a}?id=$p$na">);
+                $rv .= "$ca</a>";
+        }
+        $rv .= ' ';
+        if (defined($nb) && $nb == 0) { # deleted file
+                $rv .= $cb;
+        } else {
+                my $h = $req->{h};
+                $nb = defined $nb ? "#n$nb" : '';
+                $rv .= qq(<a\nrel=nofollow);
+                $rv .= qq(\nhref="${rel}tree/$req->{path_b}?id=$h$nb">);
+                $rv .= "$cb</a>";
+        }
+        $rv . ' @@' . utf8_html($ctx);
+}
+
+sub git_diffstat_rename ($$$) {
+        my ($req, $from, $to) = @_;
+        my $anchor = to_attr(git_unquote($to));
+        $req->{anchors}->{$anchor} = $to;
+        my @from = split('/', $from);
+        my @to = split('/', $to);
+        my $orig_to = $to;
+        my ($base, @base);
+        while (@to && @from && $to[0] eq $from[0]) {
+                push @base, shift(@to);
+                shift @from;
+        }
+
+        $base = utf8_html(join('/', @base)) if @base;
+        $from = utf8_html(join('/', @from));
+        $to = PublicInbox::Hval->utf8(join('/', @to), $orig_to);
+        my $tp = $to->as_path;
+        my $th = $to->as_html;
+        $to = qq(<a\nhref="#$anchor">$th</a>);
+        @base ? "$base/{$from =&gt; $to}" : "$from =&gt; $to";
+}
+
+sub git_diffstat_emit ($$$) {
+        my ($req, $fh, undef) = @_;
+        my @stat = split("\0", $_[2]); # avoiding copy for $_[2]
+        my $nr = 0;
+        my ($nadd, $ndel) = (0, 0);
+        my $s = '';
+        while (defined(my $l = shift @stat)) {
+                $l =~ s/\n?(\S+)\t+(\S+)\t+// or next;
+                my ($add, $del) = ($1, $2);
+                if ($add =~ /\A\d+\z/) {
+                        $nadd += $add;
+                        $ndel += $del;
+                        $add = "+$add";
+                        $del = "-$del";
+                }
+                my $num = sprintf('% 6s/%-6s', $del, $add);
+                if (length $l) {
+                        my $anchor = to_attr(git_unquote($l));
+                        $req->{anchors}->{$anchor} = $l;
+                        $l = utf8_html($l);
+                        $l = qq(<a\nhref="#$anchor">$l</a>);
+                } else {
+                        my $from = shift @stat;
+                        my $to = shift @stat;
+                        $l = git_diffstat_rename($req, $from, $to);
+                }
+                ++$nr;
+                $s .= ' '.$num."\t".$l."\n";
+        }
+        $s .= "\n $nr ";
+        $s .= $nr == 1 ? 'file changed, ' : 'files changed, ';
+        $s .= $nadd;
+        $s .= $nadd == 1 ? ' insertion(+), ' : ' insertions(+), ';
+        $s .= $ndel;
+        $s .= $ndel == 1 ? " deletion(-)\n\n" : " deletions(-)\n\n";
+        $fh->write($s);
+}
+
+1;
diff --git a/lib/PublicInbox/RepobrowseGitFallback.pm b/lib/PublicInbox/RepobrowseGitFallback.pm
new file mode 100644
index 00000000..38640139
--- /dev/null
+++ b/lib/PublicInbox/RepobrowseGitFallback.pm
@@ -0,0 +1,21 @@
+# Copyright (C) 2015 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ (https://www.gnu.org/licenses/agpl-3.0.txt)
+
+# when no endpoints match, fallback to this and serve a static file
+# This can serve Smart HTTP in the future.
+package PublicInbox::RepobrowseGitFallback;
+use strict;
+use warnings;
+use base qw(PublicInbox::RepobrowseBase);
+use PublicInbox::GitHTTPBackend;
+
+# overrides PublicInbox::RepobrowseBase::call
+sub call {
+        my ($self, undef, $req) = @_;
+        my $expath = $req->{expath};
+        return if index($expath, '..') >= 0; # prevent path traversal
+        my $git = $req->{repo_info}->{git};
+        PublicInbox::GitHTTPBackend::serve($req->{env}, $git, $expath);
+}
+
+1;
diff --git a/lib/PublicInbox/RepobrowseGitLog.pm b/lib/PublicInbox/RepobrowseGitLog.pm
new file mode 100644
index 00000000..0c360e73
--- /dev/null
+++ b/lib/PublicInbox/RepobrowseGitLog.pm
@@ -0,0 +1,102 @@
+# Copyright (C) 2015 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+
+# show the log view
+package PublicInbox::RepobrowseGitLog;
+use strict;
+use warnings;
+use PublicInbox::Hval qw(utf8_html);
+use base qw(PublicInbox::RepobrowseBase);
+use PublicInbox::RepobrowseGit qw(git_dec_links git_commit_title);
+# cannot rely on --date=format-local:... yet, it is too new (September 2015)
+my $LOG_FMT = '--pretty=tformat:'.
+                join('%x00', qw(%h %p %s D%D %ai a%an b%b), '', '');
+
+sub call_git_log {
+        my ($self, $req) = @_;
+        my $repo_info = $req->{repo_info};
+        my $max = $repo_info->{max_commit_count} || 50;
+        $max = int($max);
+        $max = 50 if $max == 0;
+
+        my $q = PublicInbox::RepobrowseGitQuery->new($req->{cgi});
+        my $h = $q->{h};
+        $h eq '' and $h = 'HEAD';
+
+        my $git = $repo_info->{git};
+        my $log = $git->popen(qw(log --no-notes --no-color --abbrev-commit),
+                                $git->abbrev, $LOG_FMT, "-$max", $h, '--');
+        sub {
+                my ($res) = @_; # Plack callback
+                my $fh = $res->([200, ['Content-Type'=>'text/html']]);
+                my $title = "log: $repo_info->{repo} ".utf8_html("($h)");
+                $fh->write($self->html_start($req, $title));
+                git_log_stream($req, $q, $log, $fh, $git);
+                $fh->close;
+        }
+}
+
+sub git_log_stream {
+        my ($req, $q, $log, $fh, $git) = @_;
+
+        my $rel = $req->{relcmd};
+        my %acache;
+        local $/ = "\0\0\n";
+        my $nr = 0;
+        my (@parents, %seen);
+        while (defined(my $line = <$log>)) {
+                my ($id, $p, $s, $D, $ai, $an, $b) = split("\0", $line);
+                $seen{$id} = 1;
+                my @p = split(' ', $p);
+                push @parents, @p;
+                my $plinks;
+                if (@p == 1) { # typical, single-parent commit
+                        $plinks = qq( / parent <a\nhref="#p$p[0]">$p[0]</a>);
+                } elsif (@p > 0) { # merge commit
+                        $plinks = ' / parents ' . join(' ', map {
+                                qq(<a\nhref="#p$_">$_</a>);
+                                } @p);
+                } else {
+                        $plinks = ''; # root commit
+                }
+
+                $s = utf8_html($s);
+                $s = qq(<a\nid=p$id\nhref="${rel}commit?id=$id"><b>$s</b></a>);
+                if ($D =~ /\AD(.+)/) {
+                        $s .= ' ('. join(', ', git_dec_links($rel, $1)) . ')';
+                }
+
+                $an =~ s/\Aa//;
+                $b =~ s/\Ab//;
+                $b =~ s/\s*\z//s;
+
+                my $ah = $acache{$an} ||= utf8_html($an);
+                my $nl = $b eq '' ? '' : "\n"; # empty bodies :<
+                $b = "$s\n- $ah @ $ai\n  commit $id$plinks\n$nl" .
+                        utf8_html($b);
+                $fh->write("\n\n" .$b);
+                ++$nr;
+        }
+
+        my $m = '';
+        my $np = 0;
+        foreach my $p (@parents) {
+                next if $seen{$p};
+                $seen{$p} = ++$np;
+                my $s = git_commit_title($git, $p);
+                $m .= qq(\n<a\nid=p$p\nhref="?h=$p">$p</a>\t);
+                $s = defined($s) ? utf8_html($s) : '';
+                $m .= qq(<a\nhref="${rel}commit?id=$p">$s</a>);
+        }
+        my $foot = "</pre><hr /><pre>";
+        if ($np == 0) {
+                $foot .= "No commits follow";
+        } elsif ($np > 1) {
+                $foot .= "Unseen parent commits to follow (multiple choice):\n";
+        } else {
+                $foot .= "Next parent to follow:\n";
+        }
+        $fh->write($foot .= $m . '</pre></body></html>');
+}
+
+1;
diff --git a/lib/PublicInbox/RepobrowseGitPatch.pm b/lib/PublicInbox/RepobrowseGitPatch.pm
new file mode 100644
index 00000000..b8a44e51
--- /dev/null
+++ b/lib/PublicInbox/RepobrowseGitPatch.pm
@@ -0,0 +1,80 @@
+# Copyright (C) 2015 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+
+# shows the /patch/ endpoint for git repositories
+# usage: /repo.git/patch?id=COMMIT_ID
+package PublicInbox::RepobrowseGitPatch;
+use strict;
+use warnings;
+use base qw(PublicInbox::RepobrowseBase);
+
+# try to be educational and show the command-line used in the signature
+my @CMD = qw(format-patch -M --stdout);
+my $sig = '--signature=git '.join(' ', @CMD);
+
+sub call_git_patch {
+        my ($self, $req) = @_;
+        my $git = $req->{repo_info}->{git};
+        my $q = PublicInbox::RepobrowseGitQuery->new($req->{cgi});
+        my $id = $q->{id};
+        $id =~ /\A[\w-]+([~\^][~\^\d])*\z/ or $id = 'HEAD';
+
+        # limit scope, don't take extra args to avoid wasting server
+        # resources buffering:
+        my $range = "$id~1..$id^0";
+        my @cmd = (@CMD, $sig." $range", $range, '--');
+        if (defined(my $expath = $req->{expath})) {
+                push @cmd, $expath;
+        }
+        my $rpipe = $git->popen(@cmd);
+        my $env = $req->{cgi}->env;
+        my $err = $env->{'psgi.errors'};
+        my ($n, $res, $vin, $fh);
+        my $end = sub {
+                if ($fh) {
+                        $fh->close;
+                        $fh = undef;
+                } elsif ($res) {
+                        $res->($self->r(500));
+                }
+                if ($rpipe) {
+                        $rpipe->close; # _may_ be Danga::Socket::close
+                        $rpipe = undef;
+                }
+        };
+        my $fail = sub {
+                if ($!{EAGAIN} || $!{EINTR}) {
+                        select($vin, undef, undef, undef) if defined $vin;
+                        # $vin is undef on async, so this is a noop on EAGAIN
+                        return;
+                }
+                my $e = $!;
+                $end->();
+                $err->print("git format-patch ($git->{git_dir}): $e\n");
+        };
+        my $cb = sub {
+                $n = $rpipe->sysread(my $buf, 65536);
+                return $fail->() unless defined $n;
+                return $end->() if $n == 0;
+                if ($res) {
+                        my $h = ['Content-Type', 'text/plain; charset=UTF-8'];
+                        $fh = $res->([200, $h]);
+                        $res = undef;
+                }
+                $fh->write($buf);
+        };
+
+        if (my $async = $env->{'pi-httpd.async'}) {
+                $rpipe = $async->($rpipe, $cb);
+                sub { ($res) = @_ } # let Danga::Socket handle the rest.
+        } else { # synchronous loop for other PSGI servers
+                $vin = '';
+                vec($vin, fileno($rpipe), 1) = 1;
+                sub {
+                        ($res) = @_;
+                        while ($rpipe) { $cb->() }
+                }
+        }
+}
+
+1;
diff --git a/lib/PublicInbox/RepobrowseGitPlain.pm b/lib/PublicInbox/RepobrowseGitPlain.pm
new file mode 100644
index 00000000..01919895
--- /dev/null
+++ b/lib/PublicInbox/RepobrowseGitPlain.pm
@@ -0,0 +1,81 @@
+# Copyright (C) 2015-2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+package PublicInbox::RepobrowseGitPlain;
+use strict;
+use warnings;
+use base qw(PublicInbox::RepobrowseBase);
+use PublicInbox::RepobrowseGitBlob;
+use PublicInbox::Hval qw(utf8_html);
+
+sub call_git_plain {
+        my ($self, $req) = @_;
+        my $git = $req->{repo_info}->{git};
+        my $q = PublicInbox::RepobrowseGitQuery->new($req->{cgi});
+        my $id = $q->{id};
+        $id eq '' and $id = 'HEAD';
+
+        if (length(my $expath = $req->{expath})) {
+                $id .= ":$expath";
+        } else {
+                $id .= ':';
+        }
+        my ($cat, $hex, $type, $size) = $git->cat_file_begin($id);
+        return unless defined $cat;
+
+        my ($r, $buf);
+        my $left = $size;
+        if ($type eq 'blob') {
+                $type = git_blob_mime_type($self, $req, $cat, \$buf, \$left);
+        } elsif ($type eq 'commit' || $type eq 'tag') {
+                $type = 'text/plain';
+        } elsif ($type eq 'tree') {
+                $git->cat_file_finish($left);
+                return git_tree_plain($req, $git, $hex);
+        } else {
+                $type = 'application/octet-stream';
+        }
+        git_blob_stream_response($git, $cat, $size, $type, $buf, $left);
+}
+
+# This should follow the cgit DOM structure in case anybody depends on it,
+# not using <pre> here as we don't expect people to actually view it much
+sub git_tree_plain {
+        my ($req, $git, $hex) = @_;
+
+        my @ex = @{$req->{extra}};
+        my $rel = $req->{relcmd};
+        my $title = utf8_html(join('/', '', @ex, ''));
+        my $tslash = $req->{tslash};
+        my $pfx = $tslash ? './' : 'plain/';
+        my $t = "<h2>$title</h2><ul>";
+        if (@ex) {
+                if ($tslash) {
+                        $t .= qq(<li><a\nhref="../">../</a></li>);
+                } else  {
+                        $t .= qq(<li><a\nhref="./">../</a></li>);
+                        my $last = PublicInbox::Hval->utf8($ex[-1])->as_href;
+                        $pfx = "$last/";
+                }
+        }
+        my $ls = $git->popen(qw(ls-tree --name-only -z), $git->abbrev, $hex);
+        sub {
+                my ($res) = @_;
+                my $fh = $res->([ 200, ['Content-Type' => 'text/html']]);
+                $fh->write("<html><head><title>$title</title></head><body>".
+                                $t);
+
+                local $/ = "\0";
+                while (defined(my $n = <$ls>)) {
+                        chomp $n;
+                        $n = PublicInbox::Hval->utf8($n);
+                        my $ref = $n->as_path;
+                        $n = $n->as_html;
+
+                        $fh->write(qq(<li><a\nhref="$pfx$ref">$n</a></li>))
+                }
+                $fh->write('</ul></body></html>');
+                $fh->close;
+        }
+}
+
+1;
diff --git a/lib/PublicInbox/RepobrowseGitQuery.pm b/lib/PublicInbox/RepobrowseGitQuery.pm
new file mode 100644
index 00000000..0ee9df04
--- /dev/null
+++ b/lib/PublicInbox/RepobrowseGitQuery.pm
@@ -0,0 +1,42 @@
+# Copyright (C) 2015 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+
+# query parameter management for repobrowse
+package PublicInbox::RepobrowseGitQuery;
+use strict;
+use warnings;
+use PublicInbox::Hval;
+my @KNOWN_PARAMS = qw(id id2 h ofs);
+
+sub new {
+        my ($class, $cgi) = @_;
+        my $self = bless {}, $class;
+
+        foreach my $k (@KNOWN_PARAMS) {
+                my $v = $cgi->param($k);
+                $self->{$k} = defined $v ? $v : '';
+        }
+        $self;
+}
+
+sub qs {
+        my ($self, %over) = @_;
+
+        if (keys %over) {
+                my $tmp = bless { %$self }, ref($self);
+                foreach my $k (keys %over) { $tmp->{$k} = $over{$k}; }
+                $self = $tmp;
+        }
+
+        my @qs;
+        foreach my $k (@KNOWN_PARAMS) {
+                my $v = $self->{$k};
+
+                next if ($v eq '');
+                $v = PublicInbox::Hval->new($v)->as_href;
+                push @qs, "$k=$v";
+        }
+        scalar(@qs) ? ('?' . join('&amp;', @qs)) : '';
+}
+
+1;
diff --git a/lib/PublicInbox/RepobrowseGitSnapshot.pm b/lib/PublicInbox/RepobrowseGitSnapshot.pm
new file mode 100644
index 00000000..8bd4b0a5
--- /dev/null
+++ b/lib/PublicInbox/RepobrowseGitSnapshot.pm
@@ -0,0 +1,130 @@
+# Copyright (C) 2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+
+# shows the /snapshot/ endpoint for git repositories
+# Mainly for compatibility reasons with cgit, I'm unsure if
+# showing this in a repository viewer is a good idea.
+
+package PublicInbox::RepobrowseGitSnapshot;
+use strict;
+use warnings;
+use base qw(PublicInbox::RepobrowseBase);
+use PublicInbox::Git;
+our $SUFFIX;
+BEGIN {
+        # as described in git-archive(1), users may add support for
+        # other compression schemes such as xz or bz2 via git-config(1):
+        #        git config tar.tar.xz.command "xz -c"
+        #        git config tar.tar.bz2.command "bzip2 -c"
+        chomp(my @l = `git archive --list`);
+        $SUFFIX = join('|', map { quotemeta $_ } @l);
+}
+
+# Not using standard mime types since the compressed tarballs are
+# special or do not match my /etc/mime.types.  Choose what gitweb
+# and cgit agree on for compatibility.
+our %FMT_TYPES = (
+        'tar' => 'application/x-tar',
+        'tar.bz2' => 'application/x-bzip2',
+        'tar.gz' => 'application/x-gzip',
+        'tar.xz' => 'application/x-xz',
+        'tgz' => 'application/x-gzip',
+        'zip' => 'application/x-zip',
+);
+
+sub call_git_snapshot ($$) { # invoked by PublicInbox::RepobrowseBase::call
+        my ($self, $req) = @_;
+
+        my @extra = @{$req->{extra}};
+        my $ref = shift @extra;
+        return $self->r(404) if (!defined $ref) || scalar(@extra);
+        my $orig_fn = $ref;
+
+        # just in case git changes refname rules, don't allow wonky filenames
+        # to break the Content-Disposition header, either.
+        return $self->r(404) if $orig_fn =~ /["\s]/s;
+        return $self->r(404) unless ($ref =~ s/\.($SUFFIX)\z//o);
+        my $fmt = $1;
+
+        my $repo_info = $req->{repo_info};
+
+        # support disabling certain snapshots types entirely to twart
+        # URL guessing since it could burn server resources.
+        return $self->r(404) if $repo_info->{snapshots_disabled}->{$fmt};
+
+        # strip optional basename (may not exist)
+        $ref =~ s/$repo_info->{snapshot_re}//;
+
+        # don't allow option/command injection, git refs do not start with '-'
+        return $self->r(404) if $ref =~ /\A-/;
+
+        my $git = $repo_info->{git};
+        my $tree;
+
+        # try prefixing "v" or "V" for tag names
+        foreach my $r ($ref, "v$ref", "V$ref") {
+                $tree = $git->qx([qw(rev-parse --verify --revs-only), $r],
+                                 undef, { 2 => $git->err_begin });
+                if (defined $tree) {
+                        chomp $tree;
+                        last if $tree ne '';
+                }
+        }
+        return $self->r(404) if (!defined $tree || $tree eq '');
+
+        my $pfx = "$repo_info->{snapshot_pfx}-$ref/";
+        my @cmd = ('archive', "--prefix=$pfx", "--format=$fmt", $tree);
+        $req->{rpipe} = $git->popen(\@cmd, undef, { 2 => $git->err_begin });
+
+        my $env = $req->{cgi}->env;
+        my $vin;
+        my $end = sub {
+                my ($n) = @_;
+                if (my $fh = delete $req->{fh}) {
+                        $fh->close;
+                } elsif (my $res = delete $req->{res}) {
+                        $res->($self->r(500));
+                }
+                if (my $rpipe = delete $req->{rpipe}) {
+                        $rpipe->close; # _may_ be Danga::Socket::close
+                }
+        };
+        my $fail = sub {
+                if ($!{EAGAIN} || $!{EINTR}) {
+                        select($vin, undef, undef, undef) if $vin;
+                        # $vin is undef on async, so this is a noop
+                        return;
+                }
+                my $e = $!;
+                $end->();
+                my $err = $env->{'psgi.errors'};
+                $err->print("git archive ($git->{git_dir}): $e\n");
+        };
+        my $cb = sub {
+                my $n = $req->{rpipe}->sysread(my $buf, 65536);
+                return $fail->() unless defined $n;
+                return $end->() if $n == 0;
+                if (my $res = delete $req->{res}) {
+                        my $h = [ 'Content-Type',
+                                $FMT_TYPES{$fmt} || 'application/octet-stream',
+                                'Content-Disposition',
+                                qq(inline; filename="$orig_fn"),
+                                'ETag', qq("$tree") ];
+                        $req->{fh} = $res->([200, $h]);
+                }
+                $req->{fh}->write($buf);
+        };
+        if (my $async = $env->{'pi-httpd.async'}) {
+                $req->{rpipe} = $async->($req->{rpipe}, $cb);
+                sub { $req->{res} = $_[0] } # let Danga::Socket handle the rest.
+        } else { # synchronous loop for other PSGI servers
+                $vin = '';
+                vec($vin, fileno($req->{rpipe}), 1) = 1;
+                sub {
+                        $req->{res} = $_[0]; # Plack response callback
+                        while ($req->{rpipe}) { $cb->() }
+                }
+        }
+}
+
+1;
diff --git a/lib/PublicInbox/RepobrowseGitSummary.pm b/lib/PublicInbox/RepobrowseGitSummary.pm
new file mode 100644
index 00000000..3ddfbe74
--- /dev/null
+++ b/lib/PublicInbox/RepobrowseGitSummary.pm
@@ -0,0 +1,102 @@
+# Copyright (C) 2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+
+# The main summary/landing page of a git repository viewer
+package PublicInbox::RepobrowseGitSummary;
+use strict;
+use warnings;
+use PublicInbox::Hval qw(utf8_html);
+use base qw(PublicInbox::RepobrowseBase);
+
+sub call_git_summary {
+        my ($self, $req) = @_;
+        sub {
+                my ($res) = @_; # Plack streaming callback
+                emit_summary($self, $req, $res);
+        }
+}
+
+use constant EACH_REF_FMT => '--format=' .
+                join(' ', map { "%($_)" }
+                qw(refname objecttype objectname creatordate:short subject));
+
+sub emit_summary {
+        my ($self, $req, $res) = @_;
+        my $repo_info = $req->{repo_info};
+        my $git = $repo_info->{git};
+        my $count = 10; # TODO: configurable
+        my $fh;
+
+        # n.b. we would use %(HEAD) in for-each-ref --format if we could
+        # rely on git 1.9.0+, but it's too soon for that in early 2016...
+        chomp(my $head_ref = $git->qx(qw(symbolic-ref HEAD)));
+
+        my $refs = $git->popen(qw(for-each-ref --sort=-creatordate),
+                                EACH_REF_FMT, "--count=$count",
+                                qw(refs/heads/ refs/tags/));
+        $fh = $res->([200, ['Content-Type'=>'text/html; charset=UTF-8']]);
+        # ref names are unpredictable in length and requires tables :<
+        $fh->write($self->html_start($req,
+                                "$repo_info->{repo}: overview") .
+                        '</pre><table>');
+
+        my $rel = $req->{relcmd};
+        foreach (<$refs>) {
+                my ($ref, $type, $hex, $date, $s) = split(' ', $_, 5);
+                my $x = $ref eq $head_ref ? ' (HEAD)' : '';
+                $ref =~ s!\Arefs/(?:heads|tags)/!!;
+                $ref = PublicInbox::Hval->utf8($ref);
+                my $h = $ref->as_html;
+                $ref = $ref->as_href;
+                my $sref;
+                if ($type eq 'tag') {
+                        $h = "<b>$h</b>";
+                        $sref = $ref = $rel . 'tag?h=' . $ref;
+                } elsif ($type eq 'commit') {
+                        $sref = $rel . 'commit?h=' . $ref;
+                        $ref = $rel . 'log?h=' . $ref;
+                } else {
+                        # no point in wasting code to support tagged
+                        # trees/blobs...
+                        next;
+                }
+                chomp $s;
+                $fh->write(qq(<tr><td><tt><a\nhref="$ref">$h</a>$x</tt></td>) .
+                        qq(<td><tt>$date <a\nhref="$sref">) . utf8_html($s) .
+                        '</a></tt></td></tr>');
+
+        }
+        $fh->write('</table>');
+
+        # some people will use README.md or even README.sh here...
+        my $readme = $repo_info->{readme};
+        defined $readme or $readme = [ 'README', 'README.md' ];
+        $readme = [ $readme ] if (ref($readme) ne 'ARRAY');
+        foreach my $r (@$readme) {
+                my $doc = $git->cat_file('HEAD:'.$r);
+                defined $doc or next;
+                $fh->write('<pre>' . readme_path_links($rel, $r) .
+                        " (HEAD)\n\n" . utf8_html($$doc) . '</pre>');
+        }
+        $fh->write('</body></html>');
+        $fh->close;
+}
+
+sub readme_path_links {
+        my ($rel, $readme) = @_;
+        my @path = split(m!/+!, $readme);
+
+        my $s = "tree <a\nhref=\"${rel}tree\">root</a>/";
+        my @t;
+        $s .= join('/', (map {
+                push @t, $_;
+                my $e = PublicInbox::Hval->utf8($_, join('/', @t));
+                my $ep = $e->as_path;
+                my $eh = $e->as_html;
+                $e = "<a\nhref=\"${rel}tree/$ep\">$eh</a>";
+                # bold the last one
+                scalar(@t) == scalar(@path) ? "<b>$e</b>" : $e;
+        } @path));
+}
+
+1;
diff --git a/lib/PublicInbox/RepobrowseGitTag.pm b/lib/PublicInbox/RepobrowseGitTag.pm
new file mode 100644
index 00000000..229d5ff0
--- /dev/null
+++ b/lib/PublicInbox/RepobrowseGitTag.pm
@@ -0,0 +1,187 @@
+# Copyright (C) 2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+
+# shows the /tag/ endpoint for git repositories
+package PublicInbox::RepobrowseGitTag;
+use strict;
+use warnings;
+use base qw(PublicInbox::RepobrowseBase);
+use POSIX qw(strftime);
+use PublicInbox::Hval qw(utf8_html);
+
+my %cmd_map = ( # type => action
+        commit => 'commit',
+        tag => 'tag',
+        # tree/blob fall back to 'show'
+);
+
+sub call_git_tag {
+        my ($self, $req) = @_;
+
+        my $q = PublicInbox::RepobrowseGitQuery->new($req->{cgi});
+        my $h = $q->{h};
+        $h eq '' and return sub {
+                my ($res) = @_;
+                git_tag_list($self, $req, $res);
+        };
+        sub {
+                my ($res) = @_;
+                git_tag_show($self, $req, $h, $res);
+        }
+}
+
+sub read_err {
+        my ($fh, $type, $hex) = @_;
+
+        $fh->write("</pre><hr /><pre><b>error reading $type $hex</b>");
+}
+
+sub git_show_tag_as_tag {
+        my ($self, $fh, $req, $h, $cat, $left, $type, $hex) = @_;
+        my $buf = '';
+        my $offset = 0;
+        while ($$left > 0) {
+                my $r = read($cat, $buf, $$left, $offset);
+                unless (defined $r) {
+                        read_err($fh, $type, $hex);
+                        last;
+                }
+                $offset += $r;
+                $$left -= $r;
+        }
+        my $head;
+        ($head, $buf) = split(/\r?\n\r?\n/, $buf, 2);
+
+        my %h = map { split(/[ \t]/, $_, 2) } split(/\r?\n/, $head);
+        my $tag = utf8_html($h{tag});
+        $type = $h{type} || '(unknown)';
+        my $obj = $h{object};
+        $h = $self->html_start($req, 'tag: ' . $tag);
+        my $label = "$type $obj";
+        my $cmd = $cmd_map{$type} || 'show';
+        my $rel = $req->{relcmd};
+        my $obj_link = qq(<a\nhref="$rel$cmd?id=$obj">$label</a>);
+        $head = $h . "\n\n   tag <b>$tag</b>\nobject $obj_link\n";
+        if (my $tagger = $h{tagger}) {
+                $head .= 'tagger ' . join("\t", creator_split($tagger)) . "\n";
+        }
+        $fh->write($head . "\n");
+
+        # n.b. tag subjects may not have a blank line after them,
+        # but we bold the first line anyways
+        my @buf = split(/\r?\n/s, $buf);
+        if (defined(my $subj = shift @buf)) {
+                $fh->write('<b>' . utf8_html($subj) . "</b>\n");
+
+                $fh->write(utf8_html($_) . "\n") foreach @buf;
+        }
+}
+
+sub git_tag_show {
+        my ($self, $req, $h, $res) = @_;
+        my $git = $req->{repo_info}->{git};
+        my $fh;
+        my $hdr = ['Content-Type', 'text/html; charset=UTF-8'];
+
+        # yes, this could still theoretically show anything,
+        # but a tag could also point to anything:
+        $git->cat_file("refs/tags/$h", sub {
+                my ($cat, $left, $type, $hex) = @_;
+                $fh = $res->([200, $hdr]);
+                $h = PublicInbox::Hval->utf8($h);
+                my $m = "git_show_${type}_as_tag";
+
+                # git_show_tag_as_tag, git_show_commit_as_tag,
+                # git_show_tree_as_tag, git_show_blob_as_tag
+                if ($self->can($m)) {
+                        $self->$m($fh, $req, $h, $cat, $left, $type, $hex);
+                } else {
+                        $self->unknown_tag_type($fh, $req, $h, $type, $hex);
+                }
+        });
+        unless ($fh) {
+                $fh = $res->([404, $hdr]);
+                $fh->write(invalid_tag_start($req, $h));
+        }
+        $fh->write('</pre></body></html>');
+        $fh->close;
+}
+
+sub invalid_tag_start {
+        my ($self, $req, $h) = @_;
+        my $rel = $req->{relcmd};
+        $h = 'missing tag: ' . utf8_html($h);
+        $self->html_start($req, $h) . "\n\n\t$h\n\n" .
+                qq(see <a\nhref="${rel}tag">tag list</a> for valid tags.);
+}
+
+sub git_tag_list {
+        my ($self, $req, $res) = @_;
+        my $repo_info = $req->{repo_info};
+        my $git = $repo_info->{git};
+        my $desc = $repo_info->{desc_html};
+
+        # TODO: use Xapian so we can more easily handle offsets/limits
+        # for pagination instead of limiting
+        my $nr = 0;
+        my $count = 50;
+        my @cmd = (qw(for-each-ref --sort=-creatordate),
+                '--format=%(refname) %(creatordate:short) %(subject)',
+                "--count=$count", 'refs/tags/');
+        my $refs = $git->popen(@cmd);
+        my $fh = $res->([200, ['Content-Type', 'text/html; charset=UTF-8']]);
+
+        # tag names are unpredictable in length and requires tables :<
+        $fh->write($self->html_start($req,
+                                "$repo_info->{repo}: tag list") .
+                '</pre><table><tr>' .
+                join('', map { "<th><tt>$_</tt></th>" } qw(tag subject date)).
+                '</tr>');
+
+        foreach (<$refs>) {
+                my ($ref, $date, $s) = split(' ', $_, 3);
+                ++$nr;
+                $ref =~ s!\Arefs/tags/!!;
+                $ref = PublicInbox::Hval->utf8($ref);
+                my $h = $ref->as_html;
+                $ref = $ref->as_href;
+                $fh->write(qq(<tr><td><a\nhref="?h=$ref">$h</a></td><td>) .
+                        utf8_html($s) . "</td><td>$date</td></tr>");
+        }
+        my $end = '';
+        if ($nr == $count) {
+                $end = "<pre>Showing the latest $nr tags</pre>";
+        }
+        $fh->write("</table>$end</body></html>");
+        $fh->close;
+}
+
+sub unknown_tag_type {
+        my ($self, $fh, $req, $h, $type, $hex) = @_;
+        my $repo_info = $req->{repo_info};
+        $h = $h->as_html;
+        my $rel = $req->{relcmd};
+        my $label = "$type $hex";
+        my $cmd = $cmd_map{$type} || 'show';
+        my $obj_link = qq(<a\nhref="$rel$cmd?id=$hex">$label</a>\n);
+
+        $fh->write($self->html_start($req,
+                                "$repo_info->{repo}: ref: $h") .
+                "\n\n       <b>$h</b> (lightweight tag)\nobject $obj_link\n");
+}
+
+sub creator_split {
+        my ($tagger) = @_;
+        $tagger =~ s/\s*(\d+)(?:\s+([\+\-])?([ \d]{1,2})(\d\d))\z// or
+                return ($tagger, 0);
+        my ($tz_sign, $tz_H, $tz_M) = ($2, $3, $4);
+        my $sec = $1;
+        my $off = $tz_H * 3600 + $tz_M * 60;
+        $off *= -1 if $tz_sign eq '-';
+        my @time = gmtime($sec + $off);
+        my $time = strftime('%Y-%m-%d %H:%M:%S', @time)." $tz_sign$tz_H$tz_M";
+
+        (utf8_html($tagger), $time);
+}
+
+1;
diff --git a/lib/PublicInbox/RepobrowseGitTree.pm b/lib/PublicInbox/RepobrowseGitTree.pm
new file mode 100644
index 00000000..3900cf9d
--- /dev/null
+++ b/lib/PublicInbox/RepobrowseGitTree.pm
@@ -0,0 +1,196 @@
+# Copyright (C) 2015 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+package PublicInbox::RepobrowseGitTree;
+use strict;
+use warnings;
+use base qw(PublicInbox::RepobrowseBase);
+use PublicInbox::Hval qw(utf8_html);
+
+my %GIT_MODE = (
+        '100644' => ' ', # blob
+        '100755' => 'x', # executable blob
+        '040000' => 'd', # tree
+        '120000' => 'l', # symlink
+        '160000' => 'g', # commit (gitlink)
+);
+
+my $BINARY_MSG = "Binary file, save using the 'raw' link above";
+
+sub git_tree_stream {
+        my ($self, $req, $res) = @_; # res: Plack callback
+        my @extra = @{$req->{extra}};
+        my $git = $req->{repo_info}->{git};
+        my $q = PublicInbox::RepobrowseGitQuery->new($req->{cgi});
+        my $id = $q->{id};
+        if ($id eq '') {
+                chomp($id = $git->qx(qw(rev-parse --short=10 HEAD)));
+                $q->{id} = $id;
+        }
+
+        my $obj = "$id:$req->{expath}";
+        my ($hex, $type, $size) = $git->check($obj);
+
+        unless (defined($type)) {
+                return $res->([404, ['Content-Type'=>'text/plain'],
+                         ['Not Found']]);
+        }
+        if ($type ne 'blob' && $type ne 'tree') {
+                return $res->([404,
+                        ['Content-Type'=>'text/plain; charset=UTF-8'],
+                         ["Unrecognized type ($type) for $obj\n"]]);
+        }
+
+        my $fh = $res->([200, ['Content-Type'=>'text/html; charset=UTF-8']]);
+        my $opts = { nofollow => 1 };
+        my $title = $req->{expath};
+        $title = $title eq '' ? 'tree' : utf8_html($title);
+
+        if ($type eq 'tree') {
+                $opts->{noindex} = 1;
+                $fh->write($self->html_start($req, $title, $opts) . "\n");
+                git_tree_show($req, $fh, $git, $hex, $q);
+        } elsif ($type eq 'blob') {
+                $fh->write($self->html_start($req, $title, $opts) . "\n");
+                git_blob_show($req, $fh, $git, $hex, $q);
+        } else {
+                # TODO
+        }
+        $fh->write('</body></html>');
+        $fh->close;
+}
+
+sub call_git_tree {
+        my ($self, $req) = @_;
+        sub { git_tree_stream($self, $req, @_) };
+}
+
+sub cur_path {
+        my ($req, $q) = @_;
+        my $qs = $q->qs;
+        my @ex = @{$req->{extra}} or return '<b>root</b>';
+        my $s;
+
+        my $rel = $req->{relcmd};
+        # avoid relative paths, here, we don't want to propagate
+        # trailing-slash URLs although we tolerate them
+        $s = "<a\nhref=\"${rel}tree$qs\">root</a>/";
+        my $cur = pop @ex;
+        my @t;
+        $s .= join('/', (map {
+                push @t, $_;
+                my $e = PublicInbox::Hval->utf8($_, join('/', @t));
+                my $ep = $e->as_path;
+                my $eh = $e->as_html;
+                "<a\nhref=\"${rel}tree/$ep$qs\">$eh</a>";
+        } @ex), '<b>'.utf8_html($cur).'</b>');
+}
+
+sub git_blob_show {
+        my ($req, $fh, $git, $hex, $q) = @_;
+        # ref: buffer_is_binary in git.git
+        my $to_read = 8000; # git uses this size to detect binary files
+        my $text_p;
+        my $n = 0;
+
+        my $rel = $req->{relcmd};
+        my $plain = join('/', "${rel}plain", @{$req->{extra}});
+        $plain = PublicInbox::Hval->utf8($plain)->as_path . $q->qs;
+        my $t = cur_path($req, $q);
+        my $h = qq{\npath: $t\n\nblob $hex};
+        my $end = '';
+
+        $git->cat_file($hex, sub {
+                my ($cat, $left) = @_; # $$left == $size
+                $h .= qq{\t$$left bytes (<a\nhref="$plain">raw</a>)};
+                $to_read = $$left if $to_read > $$left;
+                my $r = read($cat, my $buf, $to_read);
+                return unless defined($r) && $r > 0;
+                $$left -= $r;
+
+                if (index($buf, "\0") >= 0) {
+                        $fh->write("$h\n$BINARY_MSG</pre>");
+                        return;
+                }
+                $fh->write($h."</pre><hr/><table\nsummary=blob><tr><td><pre>");
+                $text_p = 1;
+
+                while (1) {
+                        my @buf = split(/\r?\n/, $buf, -1);
+                        $buf = pop @buf; # last line, careful...
+                        foreach my $l (@buf) {
+                                ++$n;
+                                $fh->write("<a\nid=n$n>". utf8_html($l).
+                                                "</a>\n");
+                        }
+                        # no trailing newline:
+                        if ($$left == 0 && $buf ne '') {
+                                ++$n;
+                                $buf = utf8_html($buf);
+                                $fh->write("<a\nid=n$n>". $buf ."</a>");
+                                $end = '<pre>\ No newline at end of file</pre>';
+                                last;
+                        }
+
+                        last unless defined($buf);
+
+                        $to_read = $$left if $to_read > $$left;
+                        my $off = length $buf; # last line from previous read
+                        $r = read($cat, $buf, $to_read, $off);
+                        return unless defined($r) && $r > 0;
+                        $$left -= $r;
+                }
+                0;
+        });
+
+        # line numbers go in a second column:
+        $fh->write('</pre></td><td><pre>');
+        $fh->write(qq(<a\nhref="#n$_">$_</a>\n)) foreach (1..$n);
+        $fh->write("</pre></td></tr></table><hr />$end");
+}
+
+sub git_tree_show {
+        my ($req, $fh, $git, $hex, $q) = @_;
+        my $ls = $git->popen(qw(ls-tree -l -z), $git->abbrev, $hex);
+        my $t = cur_path($req, $q);
+        my $pfx;
+        $fh->write("\npath: $t\n\n");
+        my $qs = $q->qs;
+
+        if ($req->{tslash}) {
+                $pfx = './';
+        } elsif (defined(my $last = $req->{extra}->[-1])) {
+                $pfx = PublicInbox::Hval->utf8($last)->as_path . '/';
+        } else {
+                $pfx = 'tree/';
+        }
+
+        local $/ = "\0";
+        $fh->write("<b>mode\tsize\tname</b>\n");
+        while (defined(my $l = <$ls>)) {
+                chomp $l;
+                my ($m, $t, $x, $s, $path) =
+                        ($l =~ /\A(\S+) (\S+) (\S+)( *\S+)\t(.+)\z/s);
+                $m = $GIT_MODE{$m} or next;
+                $path = PublicInbox::Hval->utf8($path);
+                my $ref = $path->as_path;
+                $path = $path->as_html;
+
+                if ($m eq 'g') {
+                        # TODO: support cross-repository gitlinks
+                        $fh->write('g' . (' ' x 15) . "$path @ $x\n");
+                        next;
+                }
+                elsif ($m eq 'd') { $path = "$path/" }
+                elsif ($m eq 'x') { $path = "<b>$path</b>" }
+                elsif ($m eq 'l') { $path = "<i>$path</i>" }
+                $s =~ s/\s+//g;
+
+                # 'plain' and 'log' links intentionally omitted for brevity
+                # and speed
+                $fh->write(qq($m\t).
+                        qq($s\t<a\nhref="$pfx$ref$qs">$path</a>\n));
+        }
+        $fh->write('</pre>');
+}
+
+1;
diff --git a/lib/PublicInbox/RepobrowseRoot.pm b/lib/PublicInbox/RepobrowseRoot.pm
new file mode 100644
index 00000000..fda96437
--- /dev/null
+++ b/lib/PublicInbox/RepobrowseRoot.pm
@@ -0,0 +1,71 @@
+# Copyright (C) 2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+
+# displays the root '/' where all the projects lie
+package PublicInbox::RepobrowseRoot;
+use strict;
+use warnings;
+use base qw(PublicInbox::RepobrowseBase);
+use PublicInbox::Hval qw(utf8_html);
+
+sub call {
+        my ($self, $rconfig) = @_;
+        sub {
+                my ($res) = @_; # PSGI callback
+                my @h = ('Content-Type', 'text/html; charset=UTF-8');
+                my $fh = $res->([200, \@h]);
+                repobrowse_index($fh, $rconfig);
+                $fh->close;
+        }
+}
+
+sub repobrowse_index {
+        my ($fh, $rconfig) = @_;
+        my $title = 'repobrowse index';
+        $fh->write("<html><head><title>$title</title>" .
+                        PublicInbox::Hval::STYLE .
+                        "</head><body><pre><b>$title</b>");
+
+        # preload all groups
+        foreach my $k (sort keys %$rconfig) {
+                $k =~ /\Arepo\.(.+)\.path\z/ or next;
+                my $repo_path = $1;
+                $rconfig->lookup($repo_path); # insert into groups
+        }
+
+        my $groups = $rconfig->{-groups};
+        if (scalar(keys %$groups) > 2) { # default has '-none' + '-hidden'
+                $fh->write("\n\n<b>uncategorized</b></pre>".
+                        "<table\nsummary=repoindex>");
+        } else {
+                $fh->write("</pre><table\nsummary=repoindex>");
+        }
+        foreach my $repo_path (sort @{$groups->{-none}}) {
+                my $r = $rconfig->lookup($repo_path);
+                my $p = PublicInbox::Hval->utf8($r->{repo});
+                my $l = $p->as_html;
+                $p = $p->as_path;
+                $fh->write(qq(<tr><td><tt><a\nhref="$p">$l</a></tt></td>) .
+                        "<td><tt> $r->{desc_html}</tt></td></tr>");
+        }
+
+        foreach my $group (keys %$groups) {
+                next if $group =~ /\A-(?:none|hidden)\z/;
+                my $g = utf8_html($group);
+                $fh->write("<tr><td><pre> </pre></td></tr>".
+                        "<tr><td><pre><b>$g</b></pre></tr>");
+                foreach my $repo_path (sort @{$groups->{$group}}) {
+                        my $r = $rconfig->lookup($repo_path);
+                        my $p = PublicInbox::Hval->utf8($r->{repo});
+                        my $l = $p->as_html;
+                        $p = $p->as_path;
+                        $fh->write('<tr><td><tt> ' .
+                                qq(<a\nhref="$p">$l</a></tt></td>) .
+                                "<td><tt> $r->{desc_html}</tt></td></tr>");
+                }
+        }
+
+        $fh->write('</table></body></html>');
+}
+
+1;