about summary refs log tree commit homepage
diff options
context:
space:
mode:
-rw-r--r--MANIFEST40
-rw-r--r--examples/repobrowse.psgi47
-rw-r--r--lib/PublicInbox/Config.pm12
-rw-r--r--lib/PublicInbox/GetlineBody.pm16
-rw-r--r--lib/PublicInbox/Git.pm193
-rw-r--r--lib/PublicInbox/GitAsync.pm134
-rw-r--r--lib/PublicInbox/GitAsyncWr.pm23
-rw-r--r--lib/PublicInbox/GitHTTPBackend.pm66
-rw-r--r--lib/PublicInbox/GitIdx.pm67
-rw-r--r--lib/PublicInbox/HTTP.pm3
-rw-r--r--lib/PublicInbox/HTTPD/Async.pm18
-rw-r--r--lib/PublicInbox/Hval.pm57
-rw-r--r--lib/PublicInbox/Inbox.pm31
-rw-r--r--lib/PublicInbox/Qspawn.pm136
-rw-r--r--lib/PublicInbox/Repo.pm60
-rw-r--r--lib/PublicInbox/RepoBase.pm121
-rw-r--r--lib/PublicInbox/RepoConfig.pm78
-rw-r--r--lib/PublicInbox/RepoGit.pm69
-rw-r--r--lib/PublicInbox/RepoGitAtom.pm160
-rw-r--r--lib/PublicInbox/RepoGitCommit.pm192
-rw-r--r--lib/PublicInbox/RepoGitDiff.pm69
-rw-r--r--lib/PublicInbox/RepoGitDiffCommon.pm297
-rw-r--r--lib/PublicInbox/RepoGitFallback.pm21
-rw-r--r--lib/PublicInbox/RepoGitLog.pm152
-rw-r--r--lib/PublicInbox/RepoGitPatch.pm58
-rw-r--r--lib/PublicInbox/RepoGitQuery.pm50
-rw-r--r--lib/PublicInbox/RepoGitRaw.pm159
-rw-r--r--lib/PublicInbox/RepoGitSearch.pm179
-rw-r--r--lib/PublicInbox/RepoGitSearchIdx.pm387
-rw-r--r--lib/PublicInbox/RepoGitSnapshot.pm108
-rw-r--r--lib/PublicInbox/RepoGitSrc.pm242
-rw-r--r--lib/PublicInbox/RepoGitSummary.pm100
-rw-r--r--lib/PublicInbox/RepoGitTag.pm211
-rw-r--r--lib/PublicInbox/RepoRoot.pm71
-rw-r--r--lib/PublicInbox/Repobrowse.pm167
-rw-r--r--lib/PublicInbox/Search.pm31
-rw-r--r--lib/PublicInbox/SearchIdx.pm114
-rw-r--r--lib/PublicInbox/SearchMsg.pm2
-rw-r--r--lib/PublicInbox/Spawn.pm2
-rwxr-xr-xscript/repobrowse-index68
-rw-r--r--t/config_limiter.t1
-rw-r--r--t/git.t23
-rw-r--r--t/git_async.t142
-rw-r--r--t/git_idx.t24
-rw-r--r--t/httpd-unix.t7
-rw-r--r--t/hval.t20
-rw-r--r--t/repo_git_search_idx.t28
-rw-r--r--t/repobrowse.t21
-rw-r--r--t/repobrowse_common_git.perl67
-rw-r--r--t/repobrowse_git.t11
-rw-r--r--t/repobrowse_git_atom.t38
-rw-r--r--t/repobrowse_git_commit.t19
-rw-r--r--t/repobrowse_git_httpd.t138
-rw-r--r--t/repobrowse_git_log.t19
-rw-r--r--t/repobrowse_git_raw.t24
-rw-r--r--t/repobrowse_git_snapshot.t46
-rw-r--r--t/repobrowse_git_src.t38
-rw-r--r--t/search.t34
-rw-r--r--t/spawn.t11
-rw-r--r--t/thread-all.t5
60 files changed, 4451 insertions, 276 deletions
diff --git a/MANIFEST b/MANIFEST
index f16843a9..f0a0b0fe 100644
--- a/MANIFEST
+++ b/MANIFEST
@@ -34,6 +34,7 @@ examples/public-inbox-httpd@.service
 examples/public-inbox-nntpd.socket
 examples/public-inbox-nntpd@.service
 examples/public-inbox.psgi
+examples/repobrowse.psgi
 examples/unsubscribe-milter.socket
 examples/unsubscribe-milter@.service
 examples/unsubscribe-psgi.socket
@@ -55,7 +56,10 @@ lib/PublicInbox/Filter/SubjectTag.pm
 lib/PublicInbox/Filter/Vger.pm
 lib/PublicInbox/GetlineBody.pm
 lib/PublicInbox/Git.pm
+lib/PublicInbox/GitAsync.pm
+lib/PublicInbox/GitAsyncWr.pm
 lib/PublicInbox/GitHTTPBackend.pm
+lib/PublicInbox/GitIdx.pm
 lib/PublicInbox/HTTP.pm
 lib/PublicInbox/HTTPD.pm
 lib/PublicInbox/HTTPD/Async.pm
@@ -76,6 +80,27 @@ lib/PublicInbox/NewsWWW.pm
 lib/PublicInbox/ParentPipe.pm
 lib/PublicInbox/ProcessPipe.pm
 lib/PublicInbox/Qspawn.pm
+lib/PublicInbox/Repo.pm
+lib/PublicInbox/RepoBase.pm
+lib/PublicInbox/RepoConfig.pm
+lib/PublicInbox/RepoGit.pm
+lib/PublicInbox/RepoGitAtom.pm
+lib/PublicInbox/RepoGitCommit.pm
+lib/PublicInbox/RepoGitDiff.pm
+lib/PublicInbox/RepoGitDiffCommon.pm
+lib/PublicInbox/RepoGitFallback.pm
+lib/PublicInbox/RepoGitLog.pm
+lib/PublicInbox/RepoGitPatch.pm
+lib/PublicInbox/RepoGitQuery.pm
+lib/PublicInbox/RepoGitRaw.pm
+lib/PublicInbox/RepoGitSearch.pm
+lib/PublicInbox/RepoGitSearchIdx.pm
+lib/PublicInbox/RepoGitSnapshot.pm
+lib/PublicInbox/RepoGitSrc.pm
+lib/PublicInbox/RepoGitSummary.pm
+lib/PublicInbox/RepoGitTag.pm
+lib/PublicInbox/RepoRoot.pm
+lib/PublicInbox/Repobrowse.pm
 lib/PublicInbox/SaPlugin/ListMirror.pm
 lib/PublicInbox/Search.pm
 lib/PublicInbox/SearchIdx.pm
@@ -106,6 +131,7 @@ script/public-inbox-mda
 script/public-inbox-nntpd
 script/public-inbox-watch
 script/public-inbox.cgi
+script/repobrowse-index
 scripts/dc-dlvr
 scripts/dc-dlvr.pre
 scripts/edit-sa-prefs
@@ -134,11 +160,14 @@ t/git-http-backend.psgi
 t/git-http-backend.t
 t/git.fast-import-data
 t/git.t
+t/git_async.t
+t/git_idx.t
 t/html_index.t
 t/httpd-corner.psgi
 t/httpd-corner.t
 t/httpd-unix.t
 t/httpd.t
+t/hval.t
 t/import.t
 t/inbox.t
 t/init.t
@@ -157,6 +186,17 @@ t/psgi_attach.t
 t/psgi_mount.t
 t/psgi_text.t
 t/qspawn.t
+t/repo_git_search_idx.t
+t/repobrowse.t
+t/repobrowse_common_git.perl
+t/repobrowse_git.t
+t/repobrowse_git_atom.t
+t/repobrowse_git_commit.t
+t/repobrowse_git_httpd.t
+t/repobrowse_git_log.t
+t/repobrowse_git_raw.t
+t/repobrowse_git_snapshot.t
+t/repobrowse_git_src.t
 t/search.t
 t/spamcheck_spamc.t
 t/spawn.t
diff --git a/examples/repobrowse.psgi b/examples/repobrowse.psgi
new file mode 100644
index 00000000..faf8c331
--- /dev/null
+++ b/examples/repobrowse.psgi
@@ -0,0 +1,47 @@
+#!/usr/bin/perl -w
+# Copyright (C) 2015-2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+#
+# standalone repobrowse example PSGI
+#
+# Note: this is part of our test suite, update t/*.t if this changes
+# Usage: plackup [OPTIONS] /path/to/this/file
+# A startup command for development which monitors changes:
+#        plackup -I lib -o 127.0.0.1 -R lib -r examples/repobrowse.psgi
+use strict;
+use warnings;
+use PublicInbox::Repobrowse;
+use Plack::Builder;
+my $repobrowse = PublicInbox::Repobrowse->new;
+
+builder {
+        # Chunked middleware conflicts with Starman:
+        # https://github.com/miyagawa/Starman/issues/23
+        # enable 'Chunked';
+        eval {
+                enable 'Deflater',
+                        content_type => [ 'text/html', 'text/plain',
+                                          'application/atom+xml' ];
+        };
+        $@ and warn
+"Plack::Middleware::Deflater missing, bandwidth will be wasted\n";
+
+        # Enable to ensure redirects and Atom feed URLs are generated
+        # properly when running behind a reverse proxy server which
+        # sets X-Forwarded-For and X-Forwarded-Proto request headers.
+        # See Plack::Middleware::ReverseProxy documentation for details
+        eval { enable 'ReverseProxy' };
+        $@ and warn
+"Plack::Middleware::ReverseProxy missing,\n",
+"URL generation for redirects may be wrong if behind a reverse proxy\n";
+
+        # Optional: Log timing information for requests to track performance.
+        # Logging to STDOUT is recommended since public-inbox-httpd knows
+        # how to reopen it via SIGUSR1 after log rotation.
+        # enable 'AccessLog::Timed',
+        #        logger => sub { syswrite(STDOUT, $_[0]) },
+        #        format => '%t "%r" %>s %b %D';
+
+        enable 'Head';
+        sub { $repobrowse->call(@_) }
+}
diff --git a/lib/PublicInbox/Config.pm b/lib/PublicInbox/Config.pm
index f6275cdd..15c2a085 100644
--- a/lib/PublicInbox/Config.pm
+++ b/lib/PublicInbox/Config.pm
@@ -5,7 +5,6 @@
 package PublicInbox::Config;
 use strict;
 use warnings;
-require PublicInbox::Inbox;
 use PublicInbox::Spawn qw(popen_rd);
 
 # returns key-value pairs of config directives in a hash
@@ -131,6 +130,7 @@ sub git_config_dump {
 }
 
 sub _fill {
+        require PublicInbox::Inbox;
         my ($self, $pfx) = @_;
         my $rv = {};
 
@@ -167,4 +167,14 @@ sub _fill {
         $self->{-by_name}->{$name} = $rv;
 }
 
+sub try_cat {
+        my ($path) = @_;
+        my $rv = '';
+        if (open(my $fh, '<', $path)) {
+                local $/;
+                $rv = <$fh>;
+        }
+        $rv;
+}
+
 1;
diff --git a/lib/PublicInbox/GetlineBody.pm b/lib/PublicInbox/GetlineBody.pm
index 5f327828..ccc66e48 100644
--- a/lib/PublicInbox/GetlineBody.pm
+++ b/lib/PublicInbox/GetlineBody.pm
@@ -9,8 +9,13 @@ use strict;
 use warnings;
 
 sub new {
-        my ($class, $rpipe, $end, $buf) = @_;
-        bless { rpipe => $rpipe, end => $end, buf => $buf }, $class;
+        my ($class, $rpipe, $end, $buf, $filter) = @_;
+        bless {
+                rpipe => $rpipe,
+                end => $end,
+                buf => $buf,
+                filter => $filter || 0,
+        }, $class;
 }
 
 # close should always be called after getline returns undef,
@@ -20,8 +25,13 @@ sub DESTROY { $_[0]->close }
 
 sub getline {
         my ($self) = @_;
+        my $filter = $self->{filter};
+        return if $filter == -1; # last call was EOF
+
         my $buf = delete $self->{buf}; # initial buffer
-        defined $buf ? $buf : $self->{rpipe}->getline;
+        $buf = $self->{rpipe}->getline unless defined $buf;
+        $self->{filter} = -1 unless defined $buf; # set EOF for next call
+        $filter ? $filter->($buf) : $buf;
 }
 
 sub close {
diff --git a/lib/PublicInbox/Git.pm b/lib/PublicInbox/Git.pm
index 59c27470..893df71e 100644
--- a/lib/PublicInbox/Git.pm
+++ b/lib/PublicInbox/Git.pm
@@ -12,12 +12,44 @@ use warnings;
 use POSIX qw(dup2);
 require IO::Handle;
 use PublicInbox::Spawn qw(spawn popen_rd);
+use Fcntl qw(:seek);
+my $have_async = eval {
+        require PublicInbox::EvCleanup;
+        require PublicInbox::GitAsync;
+};
 
 sub new {
         my ($class, $git_dir) = @_;
         bless { git_dir => $git_dir }, $class
 }
 
+sub err_begin ($) {
+        my $err = $_[0]->{err};
+        unless ($err) {
+                open($err, '+>', undef);
+                $_[0]->{err} = $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} or return '';
+        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 cmd {
+        my $self = shift;
+        [ 'git', "--git-dir=$self->{git_dir}", @_ ];
+}
+
 sub _bidi_pipe {
         my ($self, $batch, $in, $out, $pid) = @_;
         return if $self->{$pid};
@@ -26,9 +58,8 @@ sub _bidi_pipe {
         pipe($in_r, $in_w) or fail($self, "pipe failed: $!");
         pipe($out_r, $out_w) or fail($self, "pipe failed: $!");
 
-        my @cmd = ('git', "--git-dir=$self->{git_dir}", qw(cat-file), $batch);
         my $redir = { 0 => fileno($out_r), 1 => fileno($in_w) };
-        my $p = spawn(\@cmd, undef, $redir);
+        my $p = spawn(cmd($self, qw(cat-file), $batch), undef, $redir);
         defined $p or fail($self, "spawn failed: $!");
         $self->{$pid} = $p;
         $out_w->autoflush(1);
@@ -36,20 +67,46 @@ sub _bidi_pipe {
         $self->{$in} = $in_r;
 }
 
-sub cat_file {
-        my ($self, $obj, $ref) = @_;
-
-        batch_prepare($self);
+# legacy synchronous API
+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);
+}
+
+# legacy synchronous API
+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");
+}
+
+# legacy synchronous API
+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 +115,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 +129,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;
@@ -91,6 +137,7 @@ sub cat_file {
 
 sub batch_prepare ($) { _bidi_pipe($_[0], qw(--batch in out pid)) }
 
+# legacy synchronous API
 sub check {
         my ($self, $obj) = @_;
         $self->_bidi_pipe(qw(--batch-check in_c out_c pid_c));
@@ -119,8 +166,16 @@ sub fail {
 
 sub popen {
         my ($self, @cmd) = @_;
-        @cmd = ('git', "--git-dir=$self->{git_dir}", @cmd);
-        popen_rd(\@cmd);
+        my $cmd = cmd($self);
+        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 {
@@ -137,10 +192,102 @@ sub cleanup {
         my ($self) = @_;
         _destroy($self, qw(in out pid));
         _destroy($self, qw(in_c out_c pid_c));
+
+        if ($have_async) {
+                my %h = %$self; # yup, copy ourselves
+                %$self = ();
+                my $ds_closed;
+
+                # schedule closing with Danga::Socket::close:
+                foreach (qw(async async_c)) {
+                        my $ds = delete $h{$_} or next;
+                        $ds->close;
+                        $ds_closed = 1;
+                }
+
+                # can't do waitpid in _destroy() until next tick,
+                # since D::S defers closing until end of current event loop
+                $ds_closed and PublicInbox::EvCleanup::next_tick(sub {
+                        _destroy(\%h, qw(in_a out_a pid_a));
+                        _destroy(\%h, qw(in_ac out_ac pid_ac));
+                });
+        }
 }
 
 sub DESTROY { cleanup(@_) }
 
+# modern async API
+sub check_async_ds ($$$) {
+        my ($self, $obj, $cb) = @_;
+        ($self->{async_c} ||= do {
+                _bidi_pipe($self, qw(--batch-check in_ac out_ac pid_ac));
+                PublicInbox::GitAsync->new($self->{in_ac}, $self->{out_ac}, 1);
+        })->cat_file_async($obj, $cb);
+}
+
+sub cat_async_ds ($$$) {
+        my ($self, $obj, $cb) = @_;
+        ($self->{async} ||= do {
+                _bidi_pipe($self, qw(--batch in_a out_a pid_a));
+                PublicInbox::GitAsync->new($self->{in_a}, $self->{out_a});
+        })->cat_file_async($obj, $cb);
+}
+
+sub async_info_compat ($) {
+        local $/ = "\n";
+        chomp(my $line = $_[0]->getline);
+        [ split(/ /, $line) ];
+}
+
+sub check_async_compat ($$$) {
+        my ($self, $obj, $cb) = @_;
+        $self->_bidi_pipe(qw(--batch-check in_c out_c pid_c));
+        $self->{out_c}->print($obj."\n") or fail($self, "write error: $!");
+        my $info = async_info_compat($self->{in_c});
+        $cb->($info);
+}
+
+sub cat_async_compat ($$$) {
+        my ($self, $obj, $cb) = @_;
+        $self->_bidi_pipe(qw(--batch in out pid));
+        $self->{out}->print($obj."\n") or fail($self, "write error: $!");
+        my $in = $self->{in};
+        my $info = async_info_compat($in);
+        my (undef, $type, $left) = @$info;
+        $cb->($info);
+        return if $info->[1] eq 'missing';
+        my $max = 8192;
+        my ($buf, $r);
+        while ($left > 0) {
+                $r = read($in, $buf, $left > $max ? $max : $left);
+                return $cb->($r) unless $r; # undef or 0
+                $left -= $r;
+                $cb->(\$buf);
+        }
+        $r = read($in, $buf, 1);
+        defined($r) or fail($self, "read failed: $!");
+        fail($self, 'newline missing after blob') if ($r != 1 || $buf ne "\n");
+        $cb->(0);
+}
+
+sub check_async {
+        my ($self, $env, $obj, $cb) = @_;
+        if ($env->{'pi-httpd.async'}) {
+                check_async_ds($self, $obj, $cb);
+        } else {
+                check_async_compat($self, $obj, $cb);
+        }
+}
+
+sub cat_async {
+        my ($self, $env, $obj, $cb) = @_;
+        if ($env->{'pi-httpd.async'}) {
+                cat_async_ds($self, $obj, $cb);
+        } else {
+                cat_async_compat($self, $obj, $cb);
+        }
+}
+
 1;
 __END__
 =pod
diff --git a/lib/PublicInbox/GitAsync.pm b/lib/PublicInbox/GitAsync.pm
new file mode 100644
index 00000000..24e0bf3b
--- /dev/null
+++ b/lib/PublicInbox/GitAsync.pm
@@ -0,0 +1,134 @@
+# Copyright (C) 2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+#
+# internal class used by PublicInbox::Git + Danga::Socket
+# This parses the output pipe of "git cat-file --batch/--batch-check"
+package PublicInbox::GitAsync;
+use strict;
+use warnings;
+use base qw(Danga::Socket);
+use fields qw(jobq rbuf wr check);
+use PublicInbox::GitAsyncWr;
+our $MAX = 65536; # Import may bump this in the future
+
+sub new {
+        my ($class, $rd, $wr, $check) = @_;
+        my $self = fields::new($class);
+        IO::Handle::blocking($rd, 0);
+        $self->SUPER::new($rd);
+        $self->{jobq} = []; # [ [ $obj, $cb, $state ], ... ]
+        my $buf = '';
+        $self->{rbuf} = \$buf;
+        $self->{wr} = PublicInbox::GitAsyncWr->new($wr);
+        $self->{check} = $check;
+        $self->watch_read(1);
+        $self;
+}
+
+sub cat_file_async {
+        my ($self, $obj, $cb) = @_;
+        # order matters
+        push @{$self->{jobq}}, [ $obj, $cb ];
+        $self->{wr}->write(\"$obj\n");
+}
+
+# Returns: an array ref of the info line for --batch-check and --batch,
+# which may be: [ $obj, 'missing']
+# Returns undef on error
+sub read_info ($) {
+        my ($self) = @_;
+        my $rbuf = $self->{rbuf};
+        my $rd = $self->{sock};
+
+        while (1) {
+                $$rbuf =~ s/\A([^\n]+)\n//s and return [ split(/ /, $1) ];
+
+                my $r = sysread($rd, $$rbuf, 110, length($$rbuf));
+                next if $r;
+                return $r;
+        }
+}
+
+sub event_read {
+        my ($self) = @_;
+        my $jobq = $self->{jobq};
+        my ($cur, $obj, $cb, $info, $left);
+        my $check = $self->{check};
+        my ($rbuf, $rlen, $need, $buf);
+take_job:
+        $cur = shift @$jobq or die 'BUG: empty job queue in '.__PACKAGE__;
+        ($obj, $cb, $info, $left) = @$cur;
+        if (!$info) {
+                $info = read_info($self);
+                if (!defined $info && ($!{EAGAIN} || $!{EINTR})) {
+                        return unshift(@$jobq, $cur)
+                }
+                $cb->($info); # $info may 0 (EOF, or undef, $cb will see $!)
+                return $self->close unless $info;
+                if ($check || $info->[1] eq 'missing') {
+                        # do not monopolize the event loop if we're drained:
+                        return if ${$self->{rbuf}} eq '';
+                        goto take_job;
+                }
+                $cur->[2] = $info;
+                my $len = $info->[2];
+                $left = \$len;
+                $cur->[3] = $left; # onto reading body...
+        }
+        ref($left) or die 'BUG: $left not ref in '.__PACKAGE__;
+
+        $rbuf = $self->{rbuf};
+        $rlen = length($$rbuf);
+        $need = $$left + 1; # +1 for trailing LF
+        $buf = '';
+
+        if ($rlen == $need) {
+final_hunk:
+                $self->{rbuf} = \$buf;
+                $$left = undef;
+                my $lf = chop $$rbuf;
+                $lf eq "\n" or die "BUG: missing LF (got $lf)";
+                $cb->($rbuf);
+                $cb->(0);
+
+                return if $buf eq '';
+                goto take_job;
+        } elsif ($rlen < $need) {
+                my $all = $need - $rlen;
+                my $n = $all > $MAX ? $MAX : $all;
+                my $r = sysread($self->{sock}, $$rbuf, $n, $rlen);
+                if ($r) {
+                        goto final_hunk if $r == $all;
+
+                        # more to read later...
+                        $$left -= $r;
+                        $self->{rbuf} = \$buf;
+                        $cb->($rbuf);
+
+                        # don't monopolize the event loop
+                        return unshift(@$jobq, $cur);
+                } elsif (!defined $r) {
+                        return unshift(@$jobq, $cur) if $!{EAGAIN} || $!{EINTR};
+                }
+                $cb->($r); # $cb should handle 0 and undef (and see $!)
+                $self->close; # FAIL...
+        } else { # too much data in rbuf
+                $buf = substr($$rbuf, $need, $rlen - $need);
+                $$rbuf = substr($$rbuf, 0, $need);
+                goto final_hunk;
+        }
+}
+
+sub close {
+        my $self = shift;
+        my $jobq = $self->{jobq};
+        $self->{jobq} = [];
+        $_->[1]->(0) for @$jobq;
+        $self->{wr}->close;
+        $self->SUPER::close(@_);
+}
+
+sub event_hup { $_[0]->close }
+sub event_err { $_[0]->close }
+
+1;
diff --git a/lib/PublicInbox/GitAsyncWr.pm b/lib/PublicInbox/GitAsyncWr.pm
new file mode 100644
index 00000000..c22f2fcc
--- /dev/null
+++ b/lib/PublicInbox/GitAsyncWr.pm
@@ -0,0 +1,23 @@
+# Copyright (C) 2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+#
+# internal class used by PublicInbox::Git + Danga::Socket
+# This writes to the input pipe of "git cat-file --batch/--batch-check"
+package PublicInbox::GitAsyncWr;
+use strict;
+use warnings;
+use base qw(Danga::Socket);
+
+sub new {
+        my ($class, $io) = @_;
+        my $self = fields::new($class);
+        IO::Handle::blocking($io, 0);
+        $self->SUPER::new($io);
+}
+
+# we only care about write + event_write
+
+sub event_hup { $_[0]->close }
+sub event_err { $_[0]->close }
+
+1;
diff --git a/lib/PublicInbox/GitHTTPBackend.pm b/lib/PublicInbox/GitHTTPBackend.pm
index 1fa5e30e..d02b0c0e 100644
--- a/lib/PublicInbox/GitHTTPBackend.pm
+++ b/lib/PublicInbox/GitHTTPBackend.pm
@@ -55,8 +55,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);
 }
 
@@ -200,69 +200,15 @@ sub serve_smart {
                 $env{$name} = $val if defined $val;
         }
         my $limiter = $git->{-httpbackend_limiter} || $default_limiter;
-        my $git_dir = $git->{git_dir};
         $env{GIT_HTTP_EXPORT_ALL} = '1';
-        $env{PATH_TRANSLATED} = "$git_dir/$path";
+        $env{PATH_TRANSLATED} = "$git->{git_dir}/$path";
         my $rdr = { 0 => fileno($in) };
         my $qsp = PublicInbox::Qspawn->new([qw(git http-backend)], \%env, $rdr);
-        my ($fh, $rpipe);
-        my $end = sub {
-                if (my $err = $qsp->finish) {
-                        err($env, "git http-backend ($git_dir): $err");
-                }
-                $fh->close if $fh; # async-only
-        };
-
-        # Danga::Socket users, we queue up the read_enable callback to
-        # fire after pending writes are complete:
-        my $buf = '';
-        my $rd_hdr = sub {
-                my $r = sysread($rpipe, $buf, 1024, length($buf));
-                return if !defined($r) && ($!{EINTR} || $!{EAGAIN});
-                return r(500, 'http-backend error') unless $r;
-                $r = parse_cgi_headers(\$buf) or return; # incomplete headers
+        $qsp->psgi_return($env, $limiter, sub {
+                my ($r, $bref) = @_;
+                $r = parse_cgi_headers($bref) or return; # incomplete headers
                 $r->[0] == 403 ? serve_dumb($env, $git, $path) : $r;
-        };
-        my $res;
-        my $async = $env->{'pi-httpd.async'}; # XXX unstable API
-        my $cb = sub {
-                my $r = $rd_hdr->() or return;
-                $rd_hdr = undef;
-                if (scalar(@$r) == 3) { # error:
-                        if ($async) {
-                                $async->close; # calls rpipe->close
-                        } else {
-                                $rpipe->close;
-                                $end->();
-                        }
-                        $res->($r);
-                } elsif ($async) {
-                        $fh = $res->($r);
-                        $async->async_pass($env->{'psgix.io'}, $fh, \$buf);
-                } else { # for synchronous PSGI servers
-                        require PublicInbox::GetlineBody;
-                        $r->[2] = PublicInbox::GetlineBody->new($rpipe, $end,
-                                                                $buf);
-                        $res->($r);
-                }
-        };
-        sub {
-                ($res) = @_;
-
-                # hopefully this doesn't break any middlewares,
-                # holding the input here is a waste of FDs and memory
-                $env->{'psgi.input'} = undef;
-
-                $qsp->start($limiter, sub { # may run later, much later...
-                        ($rpipe) = @_;
-                        $in = undef;
-                        if ($async) {
-                                $async = $async->($rpipe, $cb, $end);
-                        } else { # generic PSGI
-                                $cb->() while $rd_hdr;
-                        }
-                });
-        };
+        });
 }
 
 sub input_to_file {
diff --git a/lib/PublicInbox/GitIdx.pm b/lib/PublicInbox/GitIdx.pm
new file mode 100644
index 00000000..919672a9
--- /dev/null
+++ b/lib/PublicInbox/GitIdx.pm
@@ -0,0 +1,67 @@
+# Copyright (C) 2017 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+package PublicInbox::GitIdx;
+use strict;
+use warnings;
+use base qw(Exporter);
+our @EXPORT = qw(git_umask_for with_umask);
+use PublicInbox::Git;
+use constant {
+        PERM_UMASK => 0,
+        OLD_PERM_GROUP => 1,
+        OLD_PERM_EVERYBODY => 2,
+        PERM_GROUP => 0660,
+        PERM_EVERYBODY => 0664,
+};
+
+sub _git_config_perm ($) {
+        my ($git) = @_;
+        my @cmd = qw(config core.sharedRepository);
+        $git = PublicInbox::Git->new($git) unless ref $git;
+        my $perm = $git->qx(@cmd);
+        chomp $perm if defined $perm;
+        return PERM_GROUP if (!defined($perm) || $perm eq '');
+        return PERM_UMASK if ($perm eq 'umask');
+        return PERM_GROUP if ($perm eq 'group');
+        if ($perm =~ /\A(?:all|world|everybody)\z/) {
+                return PERM_EVERYBODY;
+        }
+        return PERM_GROUP if ($perm =~ /\A(?:true|yes|on|1)\z/);
+        return PERM_UMASK if ($perm =~ /\A(?:false|no|off|0)\z/);
+
+        my $i = oct($perm);
+        return PERM_UMASK if ($i == PERM_UMASK);
+        return PERM_GROUP if ($i == OLD_PERM_GROUP);
+        return PERM_EVERYBODY if ($i == OLD_PERM_EVERYBODY);
+
+        if (($i & 0600) != 0600) {
+                die "core.sharedRepository mode invalid: ".
+                    sprintf('%.3o', $i) . "\nOwner must have permissions\n";
+        }
+        ($i & 0666);
+}
+
+sub git_umask_for ($) {
+        my ($git) = @_;
+        my $perm = _git_config_perm($git);
+        my $rv = $perm;
+        return umask if $rv == 0;
+
+        # set +x bit if +r or +w were set
+        $rv |= 0100 if ($rv & 0600);
+        $rv |= 0010 if ($rv & 0060);
+        $rv |= 0001 if ($rv & 0006);
+        (~$rv & 0777);
+}
+
+sub with_umask ($$) {
+        my ($umask, $cb) = @_;
+        my $old = umask $umask;
+        my $rv = eval { $cb->() };
+        my $err = $@;
+        umask $old;
+        die $err if $@;
+        $rv;
+}
+
+1;
diff --git a/lib/PublicInbox/HTTP.pm b/lib/PublicInbox/HTTP.pm
index 3530f8ba..8ba41ba5 100644
--- a/lib/PublicInbox/HTTP.pm
+++ b/lib/PublicInbox/HTTP.pm
@@ -241,6 +241,7 @@ sub response_done_cb ($$) {
         sub {
                 my $env = $self->{env};
                 $self->{env} = undef;
+                %$env = () if $env; # prevent circular references
                 $self->write("0\r\n\r\n") if $alive == 2;
                 $self->write(sub{$alive ? next_request($self) : $self->close});
         }
@@ -472,7 +473,7 @@ sub close {
         my $self = shift;
         my $forward = $self->{forward};
         my $env = $self->{env};
-        delete $env->{'psgix.io'} if $env; # prevent circular references
+        %$env = () if $env; # prevent circular references
         $self->{pull} = $self->{forward} = $self->{env} = undef;
         if ($forward) {
                 eval { $forward->close };
diff --git a/lib/PublicInbox/HTTPD/Async.pm b/lib/PublicInbox/HTTPD/Async.pm
index 54b62451..71175692 100644
--- a/lib/PublicInbox/HTTPD/Async.pm
+++ b/lib/PublicInbox/HTTPD/Async.pm
@@ -23,6 +23,7 @@ sub new {
         $self;
 }
 
+# fires after pending writes are complete:
 sub restart_read_cb ($) {
         my ($self) = @_;
         sub { $self->watch_read(1) }
@@ -35,14 +36,16 @@ sub main_cb ($$$) {
                 my $r = sysread($self->{sock}, $$bref, 8192);
                 if ($r) {
                         $fh->write($$bref);
-                        return if $http->{closed};
-                        if ($http->{write_buf_size}) {
-                                $self->watch_read(0);
-                                $http->write(restart_read_cb($self));
+                        unless ($http->{closed}) { # Danga::Socket sets this
+                                if ($http->{write_buf_size}) {
+                                        $self->watch_read(0);
+                                        $http->write(restart_read_cb($self));
+                                }
+                                # stay in watch_read, but let other clients
+                                # get some work done, too.
+                                return;
                         }
-                        # stay in watch_read, but let other clients
-                        # get some work done, too.
-                        return;
+                        # fall through to close below...
                 } elsif (!defined $r) {
                         return if $!{EAGAIN} || $!{EINTR};
                 }
@@ -66,7 +69,6 @@ sub async_pass {
 sub event_read { $_[0]->{cb}->(@_) }
 sub event_hup { $_[0]->{cb}->(@_) }
 sub event_err { $_[0]->{cb}->(@_) }
-sub sysread { shift->{sock}->sysread(@_) }
 
 sub close {
         my $self = shift;
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/Inbox.pm b/lib/PublicInbox/Inbox.pm
index a0d69f18..05d04530 100644
--- a/lib/PublicInbox/Inbox.pm
+++ b/lib/PublicInbox/Inbox.pm
@@ -6,6 +6,7 @@ package PublicInbox::Inbox;
 use strict;
 use warnings;
 use PublicInbox::Git;
+use PublicInbox::Config;
 use PublicInbox::MID qw(mid2path);
 use Devel::Peek qw(SvREFCNT);
 
@@ -102,21 +103,11 @@ sub search {
         };
 }
 
-sub try_cat {
-        my ($path) = @_;
-        my $rv = '';
-        if (open(my $fh, '<', $path)) {
-                local $/;
-                $rv = <$fh>;
-        }
-        $rv;
-}
-
 sub description {
         my ($self) = @_;
         my $desc = $self->{description};
         return $desc if defined $desc;
-        $desc = try_cat("$self->{mainrepo}/description");
+        $desc = PublicInbox::Config::try_cat("$self->{mainrepo}/description");
         local $/ = "\n";
         chomp $desc;
         $desc =~ s/\s+/ /smg;
@@ -128,7 +119,7 @@ sub cloneurl {
         my ($self) = @_;
         my $url = $self->{cloneurl};
         return $url if $url;
-        $url = try_cat("$self->{mainrepo}/cloneurl");
+        $url = PublicInbox::Config::try_cat("$self->{mainrepo}/cloneurl");
         my @url = split(/\s+/s, $url);
         local $/ = "\n";
         chomp @url;
@@ -211,16 +202,16 @@ sub nntp_usable {
         $ret;
 }
 
-sub msg_by_path ($$;$) {
+sub msg_by_path ($$) {
         my ($self, $path, $ref) = @_;
         # TODO: allow other refs:
-        my $str = git($self)->cat_file('HEAD:'.$path, $ref);
+        my $str = git($self)->cat_file('HEAD:'.$path);
         $$str =~ s/\A[\r\n]*From [^\r\n]*\r?\n//s if $str;
         $str;
 }
 
-sub msg_by_smsg ($$;$) {
-        my ($self, $smsg, $ref) = @_;
+sub msg_by_smsg ($$) {
+        my ($self, $smsg) = @_;
 
         return unless defined $smsg; # ghost
 
@@ -229,7 +220,7 @@ sub msg_by_smsg ($$;$) {
         defined(my $blob = $smsg->{blob}) or
                         return msg_by_mid($self, $smsg->mid);
 
-        my $str = git($self)->cat_file($blob, $ref);
+        my $str = git($self)->cat_file($blob);
         $$str =~ s/\A[\r\n]*From [^\r\n]*\r?\n//s if $str;
         $str;
 }
@@ -239,9 +230,9 @@ sub path_check {
         git($self)->check('HEAD:'.$path);
 }
 
-sub msg_by_mid ($$;$) {
-        my ($self, $mid, $ref) = @_;
-        msg_by_path($self, mid2path($mid), $ref);
+sub msg_by_mid ($$) {
+        my ($self, $mid) = @_;
+        msg_by_path($self, mid2path($mid));
 }
 
 1;
diff --git a/lib/PublicInbox/Qspawn.pm b/lib/PublicInbox/Qspawn.pm
index 4950da25..73022656 100644
--- a/lib/PublicInbox/Qspawn.pm
+++ b/lib/PublicInbox/Qspawn.pm
@@ -1,7 +1,9 @@
 # Copyright (C) 2016 all contributors <meta@public-inbox.org>
 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
 
-# Limits the number of processes spawned
+# Generic process management framework to limits the number of
+# processes spawned in public-inbox-httpd, but has generic fallbacks
+# to work on any PSGI server.
 # This does not depend on Danga::Socket or any other external
 # scheduling mechanism, you just need to call start and finish
 # appropriately
@@ -9,6 +11,8 @@ package PublicInbox::Qspawn;
 use strict;
 use warnings;
 use PublicInbox::Spawn qw(popen_rd);
+require Plack::Util;
+my $def_limiter;
 
 sub new ($$$;) {
         my ($class, $cmd, $env, $opt) = @_;
@@ -59,6 +63,119 @@ sub start {
         }
 }
 
+sub _psgi_finish ($$) {
+        my ($self, $env) = @_;
+        my $err = $self->finish;
+        if ($err && !$env->{'qspawn.quiet'}) {
+                $err = join(' ', @{$self->{args}->[0]}).": $err\n";
+                $env->{'psgi.errors'}->print($err);
+        }
+}
+
+sub psgi_qx {
+        my ($self, $env, $limiter, $qx_cb) = @_;
+        my $qx = PublicInbox::Qspawn::Qx->new;
+        my $end = sub {
+                _psgi_finish($self, $env);
+                eval { $qx_cb->($qx) };
+                $qx = undef;
+        };
+        my $rpipe;
+        my $async = $env->{'pi-httpd.async'};
+        my $cb = sub {
+                my $r = sysread($rpipe, my $buf, 8192);
+                if ($async) {
+                        $async->async_pass($env->{'psgix.io'}, $qx, \$buf);
+                } elsif (defined $r) {
+                        $r ? $qx->write($buf) : $end->();
+                } else {
+                        return if $!{EAGAIN} || $!{EINTR}; # loop again
+                        $end->();
+                }
+        };
+        $limiter ||= $def_limiter ||= PublicInbox::Qspawn::Limiter->new(32);
+        $self->start($limiter, sub { # may run later, much later...
+                ($rpipe) = @_;
+                if ($async) {
+                # PublicInbox::HTTPD::Async->new($rpipe, $cb, $end)
+                        $async = $async->($rpipe, $cb, $end);
+                } else { # generic PSGI
+                        $cb->() while $qx;
+                }
+        });
+}
+
+# create a filter for "push"-based streaming PSGI writes used by HTTPD::Async
+sub filter_fh ($$) {
+        my ($fh, $filter) = @_;
+        Plack::Util::inline_object(
+                close => sub {
+                        $fh->write($filter->(undef));
+                        $fh->close;
+                },
+                write => sub {
+                        $fh->write($filter->($_[0]));
+                });
+}
+
+sub psgi_return {
+        my ($self, $env, $limiter, $parse_hdr) = @_;
+        my ($fh, $rpipe);
+        my $end = sub {
+                _psgi_finish($self, $env);
+                $fh->close if $fh; # async-only
+        };
+
+        my $buf = '';
+        my $rd_hdr = sub {
+                my $r = sysread($rpipe, $buf, 1024, length($buf));
+                return if !defined($r) && ($!{EINTR} || $!{EAGAIN});
+                $parse_hdr->($r, \$buf);
+        };
+        my $res = delete $env->{'qspawn.response'};
+        my $async = $env->{'pi-httpd.async'};
+        my $cb = sub {
+                my $r = $rd_hdr->() or return;
+                $rd_hdr = undef;
+                my $filter = delete $env->{'qspawn.filter'};
+                if (scalar(@$r) == 3) { # error
+                        if ($async) {
+                                $async->close; # calls rpipe->close and $end
+                        } else {
+                                $rpipe->close;
+                                $end->();
+                        }
+                        $res->($r);
+                } elsif ($async) {
+                        $fh = $res->($r); # scalar @$r == 2
+                        $fh = filter_fh($fh, $filter) if $filter;
+                        $async->async_pass($env->{'psgix.io'}, $fh, \$buf);
+                } else { # for synchronous PSGI servers
+                        require PublicInbox::GetlineBody;
+                        $r->[2] = PublicInbox::GetlineBody->new($rpipe, $end,
+                                                                $buf, $filter);
+                        $res->($r);
+                }
+        };
+        $limiter ||= $def_limiter ||= PublicInbox::Qspawn::Limiter->new(32);
+        my $start_cb = sub { # may run later, much later...
+                ($rpipe) = @_;
+                if ($async) {
+                        # PublicInbox::HTTPD::Async->new($rpipe, $cb, $end)
+                        $async = $async->($rpipe, $cb, $end);
+                } else { # generic PSGI
+                        $cb->() while $rd_hdr;
+                }
+        };
+
+        return $self->start($limiter, $start_cb) if $res;
+
+        sub {
+                ($res) = @_;
+                $self->start($limiter, $start_cb);
+        };
+}
+
 package PublicInbox::Qspawn::Limiter;
 use strict;
 use warnings;
@@ -73,4 +190,21 @@ sub new {
         }, $class;
 }
 
+# captures everything into a buffer and executes a callback when done
+package PublicInbox::Qspawn::Qx;
+use strict;
+use warnings;
+
+sub new {
+        my ($class) = @_;
+        my $buf = '';
+        bless \$buf, $class;
+}
+
+# called by PublicInbox::HTTPD::Async ($fh->write)
+sub write {
+        ${$_[0]} .= $_[1];
+        undef;
+}
+
 1;
diff --git a/lib/PublicInbox/Repo.pm b/lib/PublicInbox/Repo.pm
new file mode 100644
index 00000000..f0cb4b3d
--- /dev/null
+++ b/lib/PublicInbox/Repo.pm
@@ -0,0 +1,60 @@
+# Copyright (C) 2017 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+#
+# Represents a code repository, analoguos to the PublicInbox::Inbox
+# class for represpenting an inbox git repository.
+package PublicInbox::Repo;
+use strict;
+use warnings;
+use PublicInbox::Config;
+
+sub new {
+        my ($class, $opts) = @_;
+        bless $opts, $class;
+}
+
+sub description {
+        my ($self) = @_;
+        my $desc = $self->{description};
+        return $desc if defined $desc;
+        return unless $self->{vcs} eq 'git'; # TODO
+
+        $desc = PublicInbox::Config::try_cat("$self->{path}/description");
+        local $/ = "\n";
+        chomp $desc;
+        $desc =~ s/\s+/ /smg;
+        $desc = '($GIT_DIR/description missing)' if $desc eq '';
+        $self->{description} = $desc;
+}
+
+sub desc_html {
+        my ($self) = @_;
+        $self->{desc_html} ||=
+                PublicInbox::Hval->utf8($self->description)->as_html;
+}
+
+sub cloneurl {
+        my ($self) = @_;
+        my $url = $self->{cloneurl};
+        return $url if $url;
+        if ($self->{vcs} eq 'git') {
+                $url = PublicInbox::Config::try_cat("$self->{path}/cloneurl");
+                $url = [ split(/\s+/s, $url) ];
+                local $/ = "\n";
+                chomp @$url;
+        }
+        $self->{cloneurl} = $url;
+}
+
+sub tip {
+        my ($self) = @_;
+        $self->{-tip} ||= do {
+                if ($self->{vcs} eq 'git') {
+                        my $t = $self->{git}->qx(qw(symbolic-ref --short HEAD));
+                        chomp $t;
+                        $t;
+                }
+        };
+}
+
+1;
diff --git a/lib/PublicInbox/RepoBase.pm b/lib/PublicInbox/RepoBase.pm
new file mode 100644
index 00000000..5d38579c
--- /dev/null
+++ b/lib/PublicInbox/RepoBase.pm
@@ -0,0 +1,121 @@
+# Copyright (C) 2015 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+package PublicInbox::RepoBase;
+use strict;
+use warnings;
+use PublicInbox::Hval;
+our %MIME_TYPE_WHITELIST = ('application/pdf' => 1);
+
+sub new { bless {}, shift }
+
+sub call {
+        my ($self, $cmd, $req) = @_;
+        my $vcs = $req->{-repo}->{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;
+        while (<$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}->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 $env = $req->{env};
+                my $base = PublicInbox::Repobrowse::base_url($env);
+                my ($redir) = @extra;
+                if (index($redir, '/') != 0) { # relative redirect
+                        my @orig = split(m!/+!, $env->{PATH_INFO});
+                        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, 'Content-Type', 'text/plain; charset=UTF-8';
+        }
+
+        [ $status, \@h, [ $body ] ]
+}
+
+sub rt {
+        my ($self, $status, $t) = @_;
+        my $res = [ $status, [ 'Content-Type', "text/$t; charset=UTF-8" ] ];
+        $res->[2] = [ $_[3] ] if defined $_[3];
+        $res;
+}
+
+1;
diff --git a/lib/PublicInbox/RepoConfig.pm b/lib/PublicInbox/RepoConfig.pm
new file mode 100644
index 00000000..e1e2860b
--- /dev/null
+++ b/lib/PublicInbox/RepoConfig.pm
@@ -0,0 +1,78 @@
+# Copyright (C) 2015 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+package PublicInbox::RepoConfig;
+use strict;
+use warnings;
+use PublicInbox::Config;
+use PublicInbox::Repo;
+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',
+#        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;
+
+        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} = PublicInbox::Repo->new($rv);
+}
+
+1;
diff --git a/lib/PublicInbox/RepoGit.pm b/lib/PublicInbox/RepoGit.pm
new file mode 100644
index 00000000..114eb656
--- /dev/null
+++ b/lib/PublicInbox/RepoGit.pm
@@ -0,0 +1,69 @@
+# 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 RepoGit* modules
+package PublicInbox::RepoGit;
+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;
+}
+
+# Remove, hilariously slow
+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/$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/$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/$r">$h</a>);
+                }
+        }
+        @l;
+}
+
+1;
diff --git a/lib/PublicInbox/RepoGitAtom.pm b/lib/PublicInbox/RepoGitAtom.pm
new file mode 100644
index 00000000..4b074fcc
--- /dev/null
+++ b/lib/PublicInbox/RepoGitAtom.pm
@@ -0,0 +1,160 @@
+# 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::RepoGitAtom;
+use strict;
+use warnings;
+use PublicInbox::Hval qw(utf8_html);
+use base qw(PublicInbox::RepoBase);
+use PublicInbox::Qspawn;
+
+use constant DATEFMT => '%Y-%m-%dT%H:%M:%SZ';
+use constant STATES => qw(H ct an ae at s b);
+use constant STATE_BODY => (scalar(STATES) - 1);
+my $ATOM_FMT = '--pretty=tformat:'.
+                join('%n', map { "%$_" } STATES).'%x00';
+use POSIX qw(strftime);
+
+sub repo_root_url {
+        my ($self, $req) = @_;
+        my $env = $req->{env};
+        my $uri = $env->{REQUEST_URI};
+        $uri =~ s/\?.+\z//; # no query string
+        my @uri = split(m!/+!, $uri);
+        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?
+        PublicInbox::Repobrowse::base_url($env) . join('/', @uri);
+}
+
+sub flush_hdr ($$$) {
+        my ($dst, $hdr, $url) = @_;
+        $$dst .= '<entry><title>';
+        $$dst .= utf8_html($hdr->{'s'}); # commit subject
+        $$dst .= '</title><updated>';
+        $$dst .= strftime(DATEFMT, gmtime($hdr->{ct}));
+        $$dst .= '</updated><author><name>';
+        $$dst .= utf8_html($hdr->{an});
+        $$dst .= '</name><email>';
+        $$dst .= utf8_html($hdr->{ae});
+        $$dst .= '</email></author><published>';
+        $$dst .= strftime(DATEFMT, gmtime($hdr->{at}));
+        $$dst .= '</published>';
+        $$dst .= qq(<link\nrel="alternate"\ntype="text/html"\nhref=");
+        $$dst .= $url;
+        $$dst .= '/commit/';
+
+        my $H = $hdr->{H};
+        $$dst .= $H;
+        $$dst .= qq("\n/><id>);
+        $$dst .= $H;
+        $$dst .= qq(</id>);
+
+        $$dst .= qq(<content\ntype="xhtml"><div\nxmlns=");
+        $$dst .= qq(http://www.w3.org/1999/xhtml">);
+        $$dst .= qq(<pre\nstyle="white-space:pre-wrap">);
+        undef
+}
+
+sub git_atom_sed ($$) {
+        my ($self, $req) = @_;
+        my $buf = '';
+        my $state = 0;
+        my $rel = $req->{relcmd};
+        my $repo = $req->{-repo};
+        my $tip = $repo->tip;
+        my $title = join('/', $repo->{repo}, @{$req->{extra}});
+        $title = utf8_html("$title, $tip");
+        my $url = repo_root_url($self, $req);
+        my $hdr = {};
+        my $subtitle = $repo->desc_html;
+        $req->{axml} = qq(<?xml version="1.0"?>\n) .
+                qq(<feed\nxmlns="http://www.w3.org/2005/Atom">) .
+                qq(<title>$title</title>) .
+                qq(<subtitle>$subtitle</subtitle>) .
+                qq(<link\nrel="alternate"\ntype="text/html"\nhref="$url"\n/>);
+        my ($plinks, $ai);
+        my $end = '';
+        my $blines;
+        sub {
+                my $dst;
+                # $_[0] == scalar buffer, undef means EOF from "git log"
+                $dst = delete $req->{axml} || '';
+                my @tmp;
+                if (defined $_[0]) {
+                        $buf .= $_[0];
+                        @tmp = split(/\n/, $buf, -1);
+                        $buf = @tmp ? pop(@tmp) : '';
+                } else {
+                        @tmp = split(/\n/, $buf, -1);
+                        $buf = '';
+                        $end = '</feed>';
+                }
+
+                foreach my $l (@tmp) {
+                        if ($state != STATE_BODY) {
+                                $hdr->{((STATES)[$state])} = $l;
+                                if (++$state == STATE_BODY) {
+                                        flush_hdr(\$dst, $hdr, $url);
+                                        $hdr = {};
+                                        $blines = 0;
+                                }
+                                next;
+                        }
+                        if ($l eq "\0") {
+                                $dst .= qq(</pre></div></content></entry>);
+                                $state = 0;
+                        } else {
+                                $dst .= "\n" if $blines++;
+                                $dst .= utf8_html($l);
+                        }
+                }
+                $dst .= $end;
+        }
+}
+
+sub git_atom_cb {
+        my ($self, $req) = @_;
+        sub {
+                my ($r) = @_;
+                my $env = $req->{env};
+                if (!defined $r) {
+                        my $git = $req->{-repo}->{git};
+                        return $self->rt(400, 'plain', $git->err);
+                }
+                $env->{'qspawn.filter'} = git_atom_sed($self, $req);
+                [ 200, [ 'Content-Type', 'application/atom+xml' ] ];
+        }
+}
+
+sub call_git_atom {
+        my ($self, $req) = @_;
+        my $repo = $req->{-repo};
+        my $max = $repo->{max_commit_count} || 10;
+        $max = int($max);
+        $max = 50 if $max == 0;
+
+        my $git = $repo->{git};
+        my $env = $req->{env};
+        my $tip = $req->{tip} || $repo->tip;
+        my $read_log = sub {
+                my $cmd = $git->cmd(qw(log --no-notes --no-color --no-abbrev),
+                                        $ATOM_FMT, "-$max", $tip, '--');
+                my $expath = $req->{expath};
+                push @$cmd, $expath if $expath ne '';
+                my $rdr = { 2 => $git->err_begin };
+                my $qsp = PublicInbox::Qspawn->new($cmd, undef, undef, $rdr);
+                $qsp->psgi_return($env, undef, git_atom_cb($self, $req));
+        };
+
+        sub {
+                $env->{'qspawn.response'} = $_[0];
+                $read_log->();
+        }
+}
+
+1;
diff --git a/lib/PublicInbox/RepoGitCommit.pm b/lib/PublicInbox/RepoGitCommit.pm
new file mode 100644
index 00000000..acac000e
--- /dev/null
+++ b/lib/PublicInbox/RepoGitCommit.pm
@@ -0,0 +1,192 @@
+# 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::RepoGitCommit;
+use strict;
+use warnings;
+use base qw(PublicInbox::RepoBase);
+use PublicInbox::Hval qw(utf8_html to_attr);
+use PublicInbox::RepoGit qw(git_unquote git_commit_title);
+use PublicInbox::RepoGitDiffCommon;
+use PublicInbox::Qspawn;
+
+use constant {
+        GIT_FMT => '--pretty=format:'.join('%n',
+                '%H', '%s', '%an <%ae>', '%ai', '%cn <%ce>', '%ci',
+                '%t', '%p', '%D', '%b%x00'),
+        CC_EMPTY => " This is a merge, and the combined diff is empty.\n",
+        CC_MERGE => " This is a merge, showing combined diff:\n\n"
+};
+
+sub commit_header {
+        my ($self, $req) = @_;
+        my ($H, $s, $au, $ad, $cu, $cd, $t, $p, $D, $rest) =
+                split("\n", $req->{dbuf}, 10);
+        $s = utf8_html($s);
+        $au = utf8_html($au);
+        $cu = utf8_html($cu);
+        my @p = split(' ', $p);
+
+        my $rel = $req->{relcmd};
+        my $x = $self->html_start($req, $s) . "\n" .
+                qq(   commit $H (<a\nhref="${rel}patch/$H">patch</a>)\n) .
+                qq(     tree <a\nrel=nofollow\nhref="${rel}src/$H">$t</a>);
+
+        my $git = $req->{-repo}->{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}src/$ep/$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, $git, $rel);
+        } elsif ($np > 1) {
+                $req->{mhelp} = CC_MERGE;
+                my @common = ($git, $rel);
+                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;
+
+        # FIXME: deal with excessively long commit message bodies
+        ($bx00, $req->{dbuf}) = split("\0", $rest, 2);
+        $req->{anchors} = {};
+        $req->{H} = $H;
+        $req->{p} = \@p;
+        $x .= utf8_html($bx00) . "<a\nid=D>---</a>\n";
+}
+
+sub git_commit_sed ($$) {
+        my ($self, $req) = @_;
+        git_diff_sed_init($req);
+        my $dbuf = \($req->{dbuf});
+
+        # this filters for $fh->write or $body->getline (see Qspawn)
+        sub {
+                my $dst = '';
+                if (defined $_[0]) { # $_[0] == scalar buffer
+                        $$dbuf .= $_[0];
+                        if ($req->{dstate} == DSTATE_INIT) {
+                                return $dst if index($$dbuf, "\0") < 0;
+                                $req->{dstate} = DSTATE_STAT;
+                                $dst .= commit_header($self, $req);
+                        }
+                        git_diff_sed_run(\$dst, $req);
+                } else { # undef means EOF from "git show", flush the last bit
+                        git_diff_sed_close(\$dst, $req);
+                        $dst .= CC_EMPTY if delete $req->{mhelp};
+                        show_unchanged(\$dst, $req);
+                        $dst .= '</pre></body></html>';
+                }
+                $dst;
+        }
+}
+
+sub call_git_commit { # RepoBase calls this
+        my ($self, $req) = @_;
+        my $env = $req->{env};
+
+        my $expath = $req->{expath};
+        if ($expath ne '') {
+                my $relup = join('', map { '../' } @{$req->{extra}});
+                return $self->r(301, $req, "$relup#".to_attr($expath));
+        }
+        my $tip = $req->{tip} || $req->{-repo}->tip;
+        my $git = $req->{-repo}->{git};
+        my $cmd = $git->cmd(qw(show -z --numstat -p --encoding=UTF-8
+                        --no-notes --no-color -c --no-abbrev),
+                        GIT_FMT, $tip, '--');
+        my $rdr = { 2 => $git->err_begin };
+        my $qsp = PublicInbox::Qspawn->new($cmd, undef, $rdr);
+        $env->{'qspawn.quiet'} = 1;
+        $qsp->psgi_return($env, undef, sub { # parse header
+                my ($r, $bref) = @_;
+                if (!defined $r) {
+                        $self->rt(500, 'plain', $git->err);
+                } elsif ($r == 0) {
+                        git_commit_404($self, $req);
+                } else {
+                        $env->{'qspawn.filter'} = git_commit_sed($self, $req);
+                        $self->rt(200, 'html');
+                }
+        });
+}
+
+sub git_commit_404 {
+        my ($self, $req) = @_;
+        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";
+        $x .= "<a\nhref=\"$pfx\">$try the latest commit in HEAD</a>\n";
+        $x .= '</pre></body>';
+
+        $self->rt(404, 'html', $x);
+}
+
+# FIXME: horrifically expensive...
+sub git_parent_line {
+        my ($pfx, $p, $git, $rel) = @_;
+        my $t = git_commit_title($git, $p);
+        $t = defined $t ? utf8_html($t) : '';
+        my $pad = ' ' x length($pfx);
+        $pfx . qq( <a\nid=P\nhref="${rel}commit/$p">$p</a>\n $pad$t\n);
+}
+
+# do not break anchor links if the combined diff doesn't show changes:
+sub show_unchanged {
+        my ($dst, $req) = @_;
+
+        my @unchanged = sort keys %{$req->{anchors}};
+        return unless @unchanged;
+        my $anchors = $req->{anchors};
+        $$dst .= "\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};
+        foreach my $anchor (@unchanged) {
+                my $fn = $anchors->{$anchor};
+                my $p = PublicInbox::Hval->utf8(git_unquote($fn));
+                $p = $p->as_path;
+                $fn = utf8_html($fn);
+                $$dst .= qq(\t<a\nrel=nofollow);
+                $$dst .= qq(\nid="$anchor"\nhref="${rel}src/$p">);
+                $$dst .= "$fn</a>\n";
+        }
+}
+
+1;
diff --git a/lib/PublicInbox/RepoGitDiff.pm b/lib/PublicInbox/RepoGitDiff.pm
new file mode 100644
index 00000000..35bcb2d9
--- /dev/null
+++ b/lib/PublicInbox/RepoGitDiff.pm
@@ -0,0 +1,69 @@
+# 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/COMMIT_ID_A..COMMIT_ID_B
+#
+# We probably will not link to this outright because it's expensive,
+# but exists to preserve URL compatibility with cgit.
+package PublicInbox::RepoGitDiff;
+use strict;
+use warnings;
+use base qw(PublicInbox::RepoBase);
+use PublicInbox::Hval qw(utf8_html);
+use PublicInbox::RepoGitDiffCommon;
+use PublicInbox::Qspawn;
+
+sub git_diff_sed ($$) {
+        my ($self, $req) = @_;
+        git_diff_sed_init($req);
+        $req->{dstate} = DSTATE_STAT;
+        # this filters for $fh->write or $body->getline (see Qspawn)
+        sub {
+                my $dst = delete $req->{dhtml} || '';
+                if (defined $_[0]) { # $_[0] == scalar buffer
+                        $req->{dbuf} .= $_[0];
+                        git_diff_sed_run(\$dst, $req);
+                } else { # undef means EOF from "git show", flush the last bit
+                        git_diff_sed_close(\$dst, $req);
+                        $dst .= '</pre></body></html>';
+                }
+                $dst;
+        }
+}
+
+# $REPO/diff/$BEFORE..$AFTER
+sub call_git_diff {
+        my ($self, $req) = @_;
+        my ($id_a, $id_b) = split(/\.\./, $req->{tip});
+        my $env = $req->{env};
+        my $git = $req->{-repo}->{git};
+        my $cmd = $git->cmd(qw(diff-tree -z --numstat -p --encoding=UTF-8
+                                --no-color -M -B -D -r), "$id_a..$id_b", '--');
+        my $expath = $req->{expath};
+        push @$cmd, $expath if $expath ne '';
+        my $o = { nofollow => 1, noindex => 1 };
+        my $ex = $expath eq '' ? '' : " $expath";
+        $req->{dhtml} = $self->html_start($req, 'diff', $o). "\n\n".
+                                utf8_html("git diff-tree -r -M -B -D ".
+                                "$id_a..$id_b --$ex"). "\n\n";
+        $req->{p} = [ $id_a ];
+        my $rdr = { 2 => $git->err_begin };
+        my $qsp = PublicInbox::Qspawn->new($cmd, undef, $rdr);
+        # $env->{'qspawn.quiet'} = 1;
+        $qsp->psgi_return($env, undef, sub { # parse header
+                my ($r) = @_;
+                if (!defined $r) {
+                        $self->rt(500, 'plain', $git->err);
+                } elsif ($r == 0) {
+                        $self->rt(200, 'html',
+                                delete($req->{dhtml}).
+                                'No differences</pre></body></html>');
+                } else {
+                        $env->{'qspawn.filter'} = git_diff_sed($self, $req);
+                        $self->rt(200, 'html');
+                }
+        });
+}
+
+1;
diff --git a/lib/PublicInbox/RepoGitDiffCommon.pm b/lib/PublicInbox/RepoGitDiffCommon.pm
new file mode 100644
index 00000000..b60a5fbc
--- /dev/null
+++ b/lib/PublicInbox/RepoGitDiffCommon.pm
@@ -0,0 +1,297 @@
+# 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::RepoGitDiffCommon;
+use strict;
+use warnings;
+use PublicInbox::RepoGit qw/git_unquote git_commit_title/;
+use PublicInbox::Hval qw/utf8_html to_attr/;
+use base qw/Exporter/;
+our @EXPORT = qw/git_diff_sed_init git_diff_sed_close git_diff_sed_run
+        DSTATE_INIT DSTATE_STAT DSTATE_LINES/;
+
+# 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);
+}
+
+# diff (--cc|--combined)
+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);
+}
+
+# @@ -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}src/$p/$req->{path_a}$na">);
+                $rv .= "$ca</a>";
+        }
+        $rv .= ' ';
+        if (defined($nb) && $nb == 0) { # deleted file
+                $rv .= $cb;
+        } else {
+                $nb = defined $nb ? "#n$nb" : '';
+                $rv .= qq(<a\nrel=nofollow);
+                $rv .= qq(\nhref="${rel}src/$req->{-tip}/$req->{path_b}$nb">);
+                $rv .= "$cb</a>";
+        }
+        $rv . ' @@' . utf8_html($ctx);
+}
+
+# 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 'src' 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}src/$H/$path#n$n">$last</a>);
+        }
+        $rv .= " $at" . 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 DSTATE_INIT () { 0 }
+sub DSTATE_STAT () { 1 }
+sub DSTATE_LINES () { 2 }
+
+sub git_diff_sed_init ($) {
+        my ($req) = @_;
+        $req->{dbuf} = '';
+        $req->{-tip} = $req->{-repo}->tip;
+        $req->{ndiff} = $req->{nchg} = $req->{nadd} = $req->{ndel} = 0;
+        $req->{dstate} = DSTATE_INIT;
+}
+
+sub git_diff_sed_stat ($$) {
+        my ($dst, $req) = @_;
+        my @stat = split(/\0/, $req->{dbuf}, -1);
+        my $eos;
+        my $nchg = \($req->{nchg});
+        my $nadd = \($req->{nadd});
+        my $ndel = \($req->{ndel});
+        if (!$req->{dstat_started}) {
+                $req->{dstat_started} = 1;
+
+                # merges start with an extra '\0' before the diffstat
+                # non-merge commits start with an extra '\n', instead
+                if ($req->{mhelp}) {
+                        if ($stat[0] eq '') {
+                                shift @stat;
+                        } else {
+                                warn
+'initial merge diffstat line was not empty';
+                        }
+                } else {
+                        # for commits, only (not diff-tree)
+                        $stat[0] =~ s/\A\n//s;
+                }
+        }
+        while (defined(my $l = shift @stat)) {
+                if ($l eq '') {
+                        $eos = 1 if $stat[0] && $stat[0] =~ /\Ad/; # "diff --"
+                        last;
+                } elsif ($l =~ /\Adiff /) {
+                        unshift @stat, $l;
+                        $eos = 1;
+                        last;
+                }
+                $l =~ /\A(\S+)\t+(\S+)\t+(.*)/ or next;
+                my ($add, $del, $fn) = ($1, $2, $3);
+                if ($fn ne '') { # normal modification
+                        # TODO: discard diffs if they are too big
+                        # gigantic changes with many files may still OOM us
+                        my $anchor = to_attr(git_unquote($fn));
+                        $req->{anchors}->{$anchor} = $fn;
+                        $l = utf8_html($fn);
+                        $l = qq(<a\nhref="#$anchor">$l</a>);
+                } else { # rename
+                        # incomplete...
+                        if (scalar(@stat) < 2) {
+                                unshift @stat, $l;
+                                last;
+                        }
+                        my $from = shift @stat;
+                        my $to = shift @stat;
+                        $l = git_diffstat_rename($req, $from, $to);
+                }
+
+                # text changes show numerically, Binary does not
+                if ($add =~ /\A\d+\z/) {
+                        $$nadd += $add;
+                        $$ndel += $del;
+                        $add = "+$add";
+                        $del = "-$del";
+                }
+                ++$$nchg;
+                my $num = sprintf('% 6s/%-6s', $del, $add);
+                $$dst .= " $num\t$l\n";
+        }
+
+        $req->{dbuf} = join("\0", @stat);
+        return unless $eos;
+
+        $req->{dstate} = DSTATE_LINES;
+        $$dst .= "\n $$nchg ";
+        $$dst .= $$nchg  == 1 ? 'file changed, ' : 'files changed, ';
+        $$dst .= $$nadd;
+        $$dst .= $$nadd == 1 ? ' insertion(+), ' : ' insertions(+), ';
+        $$dst .= $$ndel;
+        $$dst .= $$ndel == 1 ? " deletion(-)\n\n" : " deletions(-)\n\n";
+}
+
+sub git_diff_sed_lines ($$) {
+        my ($dst, $req) = @_;
+
+        # TODO: discard diffs if they are too big
+
+        my @dlines = split(/\n/, $req->{dbuf}, -1);
+        $req->{dbuf} = '';
+
+        if (my $help = delete $req->{mhelp}) {
+                $$dst .= $help; # CC_MERGE
+        }
+
+        # don't touch the last line, it may not be terminated
+        $req->{dbuf} .= pop @dlines;
+
+        my $ndiff = \($req->{ndiff});
+        my $cmt = '[a-f0-9]+';
+        while (defined(my $l = shift @dlines)) {
+                if ($l =~ m{\Adiff --git ("?a/.+) ("?b/.+)\z}) { # regular
+                        $$dst .= git_diff_ab_hdr($req, $1, $2) . "\n";
+                } elsif ($l =~ m{\Adiff --(cc|combined) (.+)\z}) {
+                        $$dst .= git_diff_cc_hdr($req, $1, $2) . "\n";
+                } elsif ($l =~ /\Aindex ($cmt)\.\.($cmt)(.*)\z/o) { # regular
+                        $$dst .= git_diff_ab_index($1, $2, $3) . "\n";
+                } elsif ($l =~ /\A@@ (\S+) (\S+) @@(.*)\z/) { # regular
+                        $$dst .= git_diff_ab_hunk($req, $1, $2, $3) . "\n";
+                } elsif ($l =~ /\Aindex ($cmt,[^\.]+)\.\.($cmt)(.*)$/o) {  #--cc
+                        $$dst .= git_diff_cc_index($req, $1, $2, $3) . "\n";
+                } elsif ($l =~ /\A(@@@+) (\S+.*\S+) @@@+(.*)\z/) { # --cc
+                        $$dst .= git_diff_cc_hunk($req, $1, $2, $3) . "\n";
+                } else {
+                        $$dst .= utf8_html($l) . "\n";
+                }
+                ++$$ndiff;
+        }
+}
+
+sub git_diff_sed_run ($$) {
+        my ($dst, $req) = @_;
+        $req->{dstate} == DSTATE_STAT and git_diff_sed_stat($dst, $req);
+        $req->{dstate} == DSTATE_LINES and git_diff_sed_lines($dst, $req);
+        undef;
+}
+
+sub git_diff_sed_close ($$) {
+        my ($dst, $req) = @_;
+        $$dst .= utf8_html(delete $req->{dbuf});
+        undef;
+}
+
+1;
diff --git a/lib/PublicInbox/RepoGitFallback.pm b/lib/PublicInbox/RepoGitFallback.pm
new file mode 100644
index 00000000..8675d0d7
--- /dev/null
+++ b/lib/PublicInbox/RepoGitFallback.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::RepoGitFallback;
+use strict;
+use warnings;
+use base qw(PublicInbox::RepoBase);
+use PublicInbox::GitHTTPBackend;
+
+# overrides PublicInbox::RepoBase::call
+sub call {
+        my ($self, undef, $req) = @_;
+        my $expath = $req->{expath};
+        return if index($expath, '..') >= 0; # prevent path traversal
+        my $git = $req->{-repo}->{git};
+        PublicInbox::GitHTTPBackend::serve($req->{env}, $git, $expath);
+}
+
+1;
diff --git a/lib/PublicInbox/RepoGitLog.pm b/lib/PublicInbox/RepoGitLog.pm
new file mode 100644
index 00000000..73319633
--- /dev/null
+++ b/lib/PublicInbox/RepoGitLog.pm
@@ -0,0 +1,152 @@
+# 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::RepoGitLog;
+use strict;
+use warnings;
+use PublicInbox::Hval qw(utf8_html);
+use base qw(PublicInbox::RepoBase);
+use PublicInbox::RepoGit qw(git_dec_links git_commit_title);
+use PublicInbox::Qspawn;
+# cannot rely on --date=format-local:... yet, it is too new (September 2015)
+use constant STATES => qw(H p D ai an s b);
+use constant STATE_BODY => (scalar(STATES) - 1);
+my $LOG_FMT = '--pretty=tformat:'.  join('%n', map { "%$_" } STATES).'%x00';
+
+sub parent_links {
+        if (@_ == 1) { # typical, single-parent commit
+                qq(\n  parent <a\nhref="#p$_[0]">$_[0]</a>);
+        } elsif (@_ > 0) { # merge commit
+                "\n parents " .
+                        join("\n         ",
+                        map { qq(<a\nhref="#p$_">$_</a>) } @_);
+        } else {
+                ''; # root commit
+        }
+}
+
+sub flush_log_hdr ($$$) {
+        my ($req, $dst, $hdr) = @_;
+        my $lpfx = $req->{lpfx};
+        my $seen = $req->{seen};
+        $$dst .= '<hr /><pre>' if scalar keys %$seen;
+        my $id = $hdr->{H};
+        $seen->{$id} = 1;
+        $$dst .= qq(<a\nid=p$id\n);
+        $$dst .= qq(href="${lpfx}commit/$id"><b>);
+        $$dst .= utf8_html($hdr->{'s'}); # FIXME may still OOM
+        $$dst .= '</b></a>';
+        my $D = $hdr->{D}; # FIXME: thousands of decorations may OOM us
+        if ($D ne '') {
+                $$dst .= ' (' . join(', ', git_dec_links($lpfx, $D)) . ')';
+        }
+        my @p = split(/ /, $hdr->{p});
+        push @{$req->{parents}}, @p;
+        my $plinks = parent_links(@p);
+        $$dst .= "\n- ";
+        $$dst .= utf8_html($hdr->{an});
+        $$dst .= " @ $hdr->{ai}\n  commit $id$plinks\n";
+        undef
+}
+
+sub git_log_sed_end ($$) {
+        my ($req, $dst) = @_;
+        $$dst .= '<hr /><pre>';
+        my $m = '';
+        my $np = 0;
+        my $seen = $req->{seen};
+        my $git = $req->{-repo}->{git};
+        my $lpfx = $req->{lpfx};
+        foreach my $p (@{$req->{parents}}) {
+                next if $seen->{$p};
+                $seen->{$p} = ++$np;
+                my $s = git_commit_title($git, $p);
+                $m .= qq(\n<a\nid=p$p\nhref="$p">$p</a>\t);
+                $s = defined($s) ? utf8_html($s) : '';
+                $m .= qq(<a\nhref="${lpfx}commit/$p">$s</a>);
+        }
+        if ($np == 0) {
+                $$dst .= "No commits follow";
+        } elsif ($np > 1) {
+                $$dst .= "Unseen parent commits to follow (multiple choice):\n";
+        } else {
+                $$dst .= "Next parent to follow:\n";
+        }
+        $$dst .= $m;
+        $$dst .= '</pre></body></html>';
+}
+
+sub git_log_sed ($$) {
+        my ($self, $req) = @_;
+        my $buf = '';
+        my $state = 0;
+        $req->{seen} = {};
+        $req->{parents} = [];
+        my $hdr = {};
+        sub {
+                my $dst;
+                # $_[0] == scalar buffer, undef means EOF from "git log"
+                $dst = delete $req->{lhtml} || '';
+                my @tmp;
+                if (defined $_[0]) {
+                        $buf .= $_[0];
+                        @tmp = split(/\n/, $buf, -1);
+                        $buf = @tmp ? pop(@tmp) : '';
+                } else {
+                        @tmp = split(/\n/, $buf, -1);
+                        $buf = undef;
+                }
+
+                foreach my $l (@tmp) {
+                        if ($state != STATE_BODY) {
+                                $hdr->{((STATES)[$state])} = $l;
+                                if (++$state == STATE_BODY) {
+                                        flush_log_hdr($req, \$dst, $hdr);
+                                        $hdr = {};
+                                }
+                                next;
+                        }
+                        if ($l eq "\0") {
+                                $dst .= qq(</pre>);
+                                $state = 0;
+                        } else {
+                                $dst .= "\n";
+                                $dst .= utf8_html($l);
+                        }
+                }
+                git_log_sed_end($req, \$dst) unless defined $buf;
+                $dst;
+        };
+}
+
+sub call_git_log {
+        my ($self, $req) = @_;
+        my $repo = $req->{-repo};
+        my $max = $repo->{max_commit_count} || 50;
+        my $tip = $req->{tip} || $repo->tip;
+        $req->{lpfx} = $req->{relcmd};
+        $max = int($max);
+        $max = 50 if $max == 0;
+        my $env = $req->{env};
+        my $git = $repo->{git};
+        my $cmd = $git->cmd(qw(log --no-notes --no-color --no-abbrev),
+                                $LOG_FMT, "-$max", $tip, '--');
+        my $rdr = { 2 => $git->err_begin };
+        my $title = 'log: '.$repo->{repo}.' ('.utf8_html($tip).')';
+        $req->{lhtml} = $self->html_start($req, $title) . "\n\n";
+        my $qsp = PublicInbox::Qspawn->new($cmd, undef, $rdr);
+        $qsp->psgi_return($env, undef, sub {
+                my ($r) = @_;
+                if (!defined $r) {
+                        $self->rt(500, 'html', $git->err);
+                } elsif ($r == 0) {
+                        $self->rt(404, 'html', $git->err);
+                } else {
+                        $env->{'qspawn.filter'} = git_log_sed($self, $req);
+                        $self->rt(200, 'html');
+                }
+        });
+}
+
+1;
diff --git a/lib/PublicInbox/RepoGitPatch.pm b/lib/PublicInbox/RepoGitPatch.pm
new file mode 100644
index 00000000..b9b73e0a
--- /dev/null
+++ b/lib/PublicInbox/RepoGitPatch.pm
@@ -0,0 +1,58 @@
+# 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/COMMIT_ID
+package PublicInbox::RepoGitPatch;
+use strict;
+use warnings;
+use base qw(PublicInbox::RepoBase);
+use PublicInbox::Qspawn;
+
+# 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 fmt_patch ($$$) {
+        my ($self, $req, $res) = @_;
+        my $git = $req->{-repo}->{git};
+        my $tip = $req->{tip};
+        my $env = $req->{env};
+
+        # limit scope, don't take extra args to avoid wasting server
+        # resources buffering:
+        my $range = "$tip~1..$tip^0";
+        my $cmd = $git->cmd(@CMD, $sig." $range", $range, '--');
+        my $expath = $req->{expath};
+        push @$cmd, $expath if $expath ne '';
+        $env->{'qspawn.response'} = $res;
+
+        my $qsp = PublicInbox::Qspawn->new($cmd);
+        $qsp->psgi_return($env, undef, sub {
+                my ($r) = @_;
+                $r ? $self->rt(200, 'plain') :
+                        $self->rt(500, 'plain', "format-patch error\n");
+        });
+}
+
+sub call_git_patch {
+        my ($self, $req) = @_;
+        sub {
+                my ($res) = @_;
+                my $repo = $req->{-repo};
+                my $tip = $req->{tip};
+                my $obj = $tip || $repo->tip;
+                $repo->{git}->check_async($req->{env}, $obj.'^{commit}', sub {
+                        my ($info) = @_;
+                        my ($hex, $type, undef) = @$info;
+                        if (!defined $type || $type ne 'commit') {
+                                return $res->($self->rt(400, 'plain',
+                                                "$obj is not a commit\n"));
+                        }
+                        return fmt_patch($self, $req, $res) if $obj eq $hex;
+                        $res->($self->r(302, $req, $tip ? "../$hex" : $hex));
+                });
+        }
+}
+
+1;
diff --git a/lib/PublicInbox/RepoGitQuery.pm b/lib/PublicInbox/RepoGitQuery.pm
new file mode 100644
index 00000000..6a560d48
--- /dev/null
+++ b/lib/PublicInbox/RepoGitQuery.pm
@@ -0,0 +1,50 @@
+# 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::RepoGitQuery;
+use strict;
+use warnings;
+use PublicInbox::Hval;
+use URI::Escape qw(uri_unescape);
+my @KNOWN_PARAMS = qw(ofs);
+
+sub new {
+        my ($class, $env) = @_;
+        # we don't care about multi-value
+        my %tmp = map {
+                my ($k, $v) = split('=', uri_unescape($_), 2);
+                $v = '' unless defined $v;
+                $v =~ tr/+/ /;
+                ($k, $v)
+        } split(/[&;]/, $env->{QUERY_STRING});
+
+        my $self = {};
+        foreach (@KNOWN_PARAMS) {
+                my $v = $tmp{$_};
+                $self->{$_} = defined $v ? $v : '';
+        }
+        bless $self, $class;
+}
+
+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/RepoGitRaw.pm b/lib/PublicInbox/RepoGitRaw.pm
new file mode 100644
index 00000000..717ca71f
--- /dev/null
+++ b/lib/PublicInbox/RepoGitRaw.pm
@@ -0,0 +1,159 @@
+# 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::RepoGitRaw;
+use strict;
+use warnings;
+use base qw(PublicInbox::RepoBase);
+use PublicInbox::Hval qw(utf8_html);
+use PublicInbox::Qspawn;
+my $MAX_ASYNC = 65536;
+my $BIN_DETECT = 8000;
+
+sub git_raw_check_res ($$$) {
+        my ($self, $req, $res) = @_;
+        sub {
+                my ($info) = @_;
+                my ($hex, $type, $size) = @$info;
+                if (!defined $type || $type eq 'missing') {
+                        return $res->($self->rt(404, 'plain', 'Not Found'));
+                }
+                my $ct;
+                if ($type eq 'blob') {
+                        my $base = $req->{extra}->[-1];
+                        $ct = $self->mime_type($base) if defined $base;
+                        $ct ||= 'text/plain; charset=UTF-8' if !$size;
+                } elsif ($type eq 'commit' || $type eq 'tag' ||
+                                $type eq 'tree') {
+                        return git_tree_raw($self, $req, $res, $hex);
+                } else { # hmm..., just in case
+                        $ct = 'application/octet-stream';
+                }
+
+                $size > $MAX_ASYNC and
+                        return show_big($self, $req, $res, $ct, $info);
+
+                # buffer small files in full
+                my $buf = '';
+                $req->{-repo}->{git}->cat_async($req->{env}, $hex, sub {
+                        my ($r) = @_;
+                        if (ref($r) eq 'SCALAR') {
+                                $buf .= $$r;
+                        } elsif ($r == 0) {
+                                return if bytes::length($buf) < $size;
+                                $ct ||= index($buf, "\0") >= 0 ?
+                                                'application/octet-stream' :
+                                                'text/plain; charset=UTF-8';
+                                $res->([200, ['Content-Type', $ct,
+                                                'Content-Length', $size ],
+                                        [ $buf ]]);
+                        }
+                });
+        }
+}
+
+sub call_git_raw {
+        my ($self, $req) = @_;
+        my $repo = $req->{-repo};
+        my $obj = $req->{tip} || $repo->tip;
+        my $expath = $req->{expath};
+        $obj .= ":$expath" if $expath ne '';
+        sub {
+                my ($res) = @_;
+                $repo->{git}->check_async($req->{env}, $obj,
+                        git_raw_check_res($self, $req, $res));
+        }
+}
+
+sub git_tree_sed ($) {
+        my ($req) = @_;
+        my $buf = '';
+        my $end = '';
+        my $pfx = $req->{tpfx};
+        sub { # $_[0] = buffer or undef
+                my $dst = delete $req->{tstart} || '';
+                my @files;
+                if (defined $_[0]) {
+                        @files = split(/\0/, $buf .= $_[0]);
+                        $buf = pop @files if scalar @files;
+                } else {
+                        @files = split(/\0/, $buf);
+                        $end = '</ul></body></html>';
+                }
+                foreach my $n (@files) {
+                        $n = PublicInbox::Hval->utf8($n);
+                        my $ref = $n->as_path;
+                        $dst .= qq(<li><a\nhref="$pfx$ref">);
+                        $dst .= $n->as_html;
+                        $dst .= '</a></li>';
+                }
+                $dst .= $end;
+        }
+}
+
+sub git_tree_raw {
+        my ($self, $req, $res, $hex) = @_;
+
+        my @ex = @{$req->{extra}};
+        my $rel = $req->{relcmd};
+        my $title = utf8_html(join('/', '', @ex, ''));
+        my $repo = $req->{-repo};
+        my $pfx = ($req->{tip} || $repo->tip) . '/';
+        my $t = "<h2>$title</h2><ul>";
+        if (@ex) {
+                $t .= qq(<li><a\nhref="./">../</a></li>);
+                my $last = PublicInbox::Hval->utf8($ex[-1])->as_href;
+                $pfx = "$last/";
+        }
+
+        $req->{tpfx} = $pfx;
+        $req->{tstart} = "<html><head><title>$title</title></head><body>".$t;
+        my $git = $repo->{git};
+        my $cmd = $git->cmd(qw(ls-tree --name-only -z), $hex);
+        my $rdr = { 2 => $git->err_begin };
+        my $qsp = PublicInbox::Qspawn->new($cmd, undef, $rdr);
+        my $env = $req->{env};
+        $env->{'qspawn.response'} = $res;
+        $qsp->psgi_return($env, undef, sub {
+                my ($r) = @_;
+                if (!defined $r) {
+                        $self->rt(500, 'plain', $git->err);
+                } else {
+                        $env->{'qspawn.filter'} = git_tree_sed($req);
+                        $self->rt(200, 'html');
+                }
+        });
+}
+
+sub show_big {
+        my ($self, $req, $res, $ct, $info) = @_;
+        my ($hex, $type, $size) = @$info;
+        my $env = $req->{env};
+        my $git = $req->{-repo}->{git};
+        my $rdr = { 2 => $git->err_begin };
+        my $cmd = $git->cmd('cat-file', $type, $hex);
+        my $qsp = PublicInbox::Qspawn->new($cmd, undef, $rdr);
+        $env->{'qspawn.response'} = $res;
+        my @cl = ('Content-Length', $size);
+        $qsp->psgi_return($env, undef, sub {
+                my ($r, $bref) = @_;
+                if (!defined $r) {
+                        $self->rt(500, 'plain', $git->err);
+                } elsif (defined $ct) {
+                        [ 200, [ 'Content-Type', $ct, @cl ] ];
+                } else {
+                        return $self->rt(200, 'plain');
+                        if (index($$bref, "\0") >= 0) {
+                                $ct = 'application/octet-stream';
+                                return [200, ['Content-Type', $ct, @cl ] ];
+                        }
+                        my $n = bytes::length($$bref);
+                        if ($n >= $BIN_DETECT || $n == $size) {
+                                $ct ||= 'text/plain; charset=UTF-8';
+                                return [200, ['Content-Type', $ct, @cl] ];
+                        }
+                        # else: bref will keep growing...
+                }
+        });
+}
+
+1;
diff --git a/lib/PublicInbox/RepoGitSearch.pm b/lib/PublicInbox/RepoGitSearch.pm
new file mode 100644
index 00000000..36e3fab3
--- /dev/null
+++ b/lib/PublicInbox/RepoGitSearch.pm
@@ -0,0 +1,179 @@
+# Copyright (C) 2017 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+#
+# Read-only search interface for use by the Repobrowse web interface
+# RepoGitSearchIdx builds upon this for writing a Xapian DB.
+package PublicInbox::RepoGitSearch;
+use strict;
+use warnings;
+use Search::Xapian qw/:standard/;
+
+# values for ranges and sorting
+use constant {
+        CD => 0, # commit date stamp (YYYYMMDD)
+        AD => 1, # author date stamp (YYYYMMDD)
+
+        REPO_SCHEMA_VERSION => 1,
+        # n.b. FLAG_PURE_NOT is expensive not suitable for a public website
+        # as it could become a denial-of-service vector
+        QP_FLAGS => FLAG_PHRASE|FLAG_BOOLEAN|FLAG_LOVEHATE|FLAG_WILDCARD,
+};
+our $LANG = 'english';
+
+my %bool_pfx_internal = (
+        type => 'T', # "commit", "tag", or "ref"
+);
+
+my %bool_pfx_external = ();
+
+my %prob_prefix = (
+        id => 'Q', # git object ID, partial matches supported
+        p => 'XP', # parent commit (partial)
+        s => 'S', # subject
+        a => 'A', # Author name + email
+        c => 'XC', # Committer name + email
+        ac => 'A XC', # Author and Committer name + email
+        b => 'XBODY', # commit message body
+        bs => 'S XBODY', # commit message (subject + body)
+        diff_fn => 'XDFN', # changed filenames
+        diff_hdr => 'XDHH', # diff hunk header
+        diff_ctx => 'XDCTX', # diff context
+        diff_a => 'XDFA', # diff a/ file (before)
+        diff_b => 'XDFB', # diff b/ file (after)
+        diff => 'XDFN XDHH XDCTX XDFA XDFB', # entire diff
+        preimg => 'XPRE', # blob pre-image (full)
+        postimg => 'XPOST', # blob post-image (full)
+        # default:
+        '' => 'Q XP S A XC XBODY XDFN XDHH XDCTX XDFA XDFB XPRE XPOST',
+);
+
+our @HELP = (
+        's:' => 'match within message subject e.g. s:"a quick brown fox"',
+        'ad:' => <<EOF,
+Author date range as YYYYMMDD  e.g. ad:19931002..20101002
+Open-ended ranges such as ad:19931002.. and ad:..20101002
+are also supported
+EOF
+        'cd:' => 'Committer date range as YYYYMMDD, see ad: above',
+        'b:' => 'match within commit message body',
+        'bs:' => 'match within the commit message subject and body',
+);
+chomp @HELP;
+
+sub new {
+        my ($class, $git_dir, $repo_dir) = @_;
+        $repo_dir ||= "$git_dir/public-inbox";
+        my $xdir = "$repo_dir/xr".REPO_SCHEMA_VERSION;
+        bless { git_dir => $git_dir, xdir => $xdir }, $class;
+}
+
+# overriden by RepoGitSearchIdx
+sub xdb ($) { $_[0]->{xdb} ||= Search::Xapian::Database->new($_[0]->{xdir}) }
+
+sub retry_reopen ($$) {
+        my ($self, $cb) = @_;
+        my $ret;
+        for (1..3) {
+                eval { $ret = $cb->() };
+                return $ret unless $@;
+                # Exception: The revision being read has been discarded -
+                # you should call Xapian::Database::reopen()
+                if (ref($@) eq 'Search::Xapian::DatabaseModifiedError') {
+                        $self->{xdb}->reopen;
+                } else {
+                        die;
+                }
+        }
+}
+
+sub _enquire_once ($$$) {
+        my ($self, $query, $opts) = @_;
+        my $enq = $self->{enquire} ||= Search::Xapian::Enquire->new($self->xdb);
+        $enq->set_query($query);
+        $opts ||= {};
+        my $desc = !$opts->{asc};
+        if ($opts->{relevance}) {
+                $enq->set_sort_by_relevance_then_value(AD, $desc);
+        } else {
+                $enq->set_sort_by_value_then_relevance(AD, $desc);
+        }
+        my $offset = $opts->{offset} || 0;
+        my $limit = $opts->{limit} || 50;
+        $enq->get_mset($offset, $limit);
+}
+
+sub _do_enquire ($$$) {
+        my ($self, $query, $opts) = @_;
+        retry_reopen($self, sub { _enquire_once($self, $query, $opts) });
+}
+
+sub stemmer () { Search::Xapian::Stem->new($LANG) }
+
+# read-only
+sub qp ($) {
+        my ($self) = @_;
+
+        my $qp = $self->{query_parser};
+        return $qp if $qp;
+
+        # new parser
+        $qp = Search::Xapian::QueryParser->new;
+        $qp->set_default_op(OP_AND);
+        $qp->set_database($self->xdb);
+        $qp->set_stemmer(stemmer());
+        $qp->set_stemming_strategy(STEM_SOME);
+
+        $qp->add_valuerangeprocessor(
+                Search::Xapian::NumberValueRangeProcessor->new(AD, 'ad:'));
+        $qp->add_valuerangeprocessor(
+                Search::Xapian::NumberValueRangeProcessor->new(CD, 'cd:'));
+
+        while (my ($name, $prefix) = each %bool_pfx_external) {
+                $qp->add_boolean_prefix($name, $prefix);
+        }
+
+        while (my ($name, $prefix) = each %prob_prefix) {
+                $qp->add_prefix($name, $_) foreach split(/ /, $prefix);
+        }
+
+        $self->{query_parser} = $qp;
+}
+
+# returns begin and end PostingIterator
+sub find_docids ($$) {
+        my ($self, $termval) = @_;
+        my $db = $self->xdb;
+        ($db->postlist_begin($termval), $db->postlist_end($termval));
+}
+
+sub find_unique_docid ($$$) {
+        my ($self, $termval) = @_;
+        my ($begin, $end) = find_docids($self, $termval);
+        return undef if $begin->equal($end); # not found
+        my $rv = $begin->get_docid;
+        # sanity check
+        $begin->inc;
+        $begin->equal($end) or die "Term '$termval' is not unique\n";
+        $rv;
+}
+
+sub help ($) {
+        my ($self) = @_;
+        \@HELP;
+}
+
+# read-only
+sub query {
+        my ($self, $query_string, $opts) = @_;
+        my $query;
+
+        $opts ||= {};
+        unless ($query_string eq '') {
+                $query = qp($self)->parse_query($query_string, QP_FLAGS);
+                $opts->{relevance} = 1 unless exists $opts->{relevance};
+        }
+
+        _do_enquire($self, $query, $opts);
+}
+
+1;
diff --git a/lib/PublicInbox/RepoGitSearchIdx.pm b/lib/PublicInbox/RepoGitSearchIdx.pm
new file mode 100644
index 00000000..d2b4597e
--- /dev/null
+++ b/lib/PublicInbox/RepoGitSearchIdx.pm
@@ -0,0 +1,387 @@
+# Copyright (C) 2017 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+#
+# Qrefs/(tags|heads)/foo => 40-byte SHA1 hex of commit
+# Q$SHA1HEX_OF_COMMIT
+#
+# Indexes any git repository with Xapian; intended for code;
+# see PublicInbox::SearchIdx for a mail-specific indexer
+package PublicInbox::RepoGitSearchIdx;
+use strict;
+use warnings;
+use base qw(PublicInbox::RepoGitSearch); # base is read-only
+use POSIX qw(strftime);
+use PublicInbox::Git;
+use PublicInbox::GitIdx;
+use constant {
+        Z40 => ('0' x 40),
+        STATE_GPGSIG => -0x80000000,
+        DEBUG => !!$ENV{DEBUG},
+        BATCH_BYTES => 1_000_000,
+};
+
+sub new {
+        my ($class, $git_dir, $repo_dir) = @_;
+        require Search::Xapian::WritableDatabase;
+        my $self = $class->SUPER::new($git_dir, $repo_dir);
+        my $git = $self->{git} = PublicInbox::Git->new($git_dir);
+        $self->{want_refs_re} = qr!^refs/(?:heads|tags)/!;
+        $self->{'umask'} = git_umask_for($git);
+        $self;
+}
+
+sub xdb ($) {
+        my ($self) = @_;
+        $self->{xdb} ||= with_umask($self->{'umask'}, sub {
+                my $xdir = $self->{xdir};
+                unless (-d $xdir) {
+                        require File::Path;
+                        File::Path::mkpath($xdir);
+                }
+                Search::Xapian::WritableDatabase->new($xdir,
+                                Search::Xapian::DB_CREATE_OR_OPEN);
+        });
+}
+
+sub doc_new ($$) {
+        my ($type, $unique_id) = @_;
+        my $doc = Search::Xapian::Document->new;
+        $doc->add_term('T'.$type);
+        $doc->add_term($unique_id);
+        $doc;
+}
+
+sub add_val ($$$) {
+        my ($doc, $col, $num) = @_;
+        $num = Search::Xapian::sortable_serialise($num);
+        $doc->add_value($col, $num);
+}
+
+sub each_term_val ($$$$) {
+        my ($doc, $pfx, $re, $cb) = @_;
+        my $end = $doc->termlist_end;
+        my $i = $doc->termlist_begin;
+        $i->skip_to($pfx);
+        while ($i != $end) {
+                my $val = $i->get_termname;
+                $val =~ s/$re// and $cb->($val);
+                $i->inc;
+        }
+        undef;
+}
+
+sub get_doc ($$$$) {
+        my ($self, $id_ref, $type, $oid) = @_;
+        my $doc;
+        my $doc_id = $self->find_unique_docid('Q'.$oid);
+        if (defined $doc_id) {
+                $doc = $self->{xdb}->get_document($doc_id);
+        } else {
+                $doc = doc_new($type, 'Q'.$oid);
+        }
+        $$id_ref = $doc_id;
+        $doc;
+}
+
+# increments and returns update generation counter
+sub update_id ($) {
+        my ($self) = @_;
+        my $db = $self->{xdb};
+        my $update_id = int($db->get_metadata('last_update_id') || 0);
+        $db->set_metadata('last_update_id', ++$update_id);
+        $update_id;
+}
+
+sub replace_or_add ($$$) {
+        my ($db, $doc_id, $doc) = @_;
+        # update our ref:
+        if (defined $doc_id) {
+                $db->replace_document($doc_id, $doc);
+        } else {
+                $doc_id = $db->add_document($doc);
+        }
+        $doc_id;
+}
+
+sub decor_update {
+        my ($self, $doc, $decor, $oid) = @_;
+
+        # load all current refs
+        my $want = $self->{want_refs_re};
+        ($decor) = ($decor =~ m!\((.+)\)!);
+        foreach (split(/, /, $decor)) {
+                my ($sym, $refname, $tag);
+                if (/^(\S+) -> (\S+)\z/) {
+                        ($sym, $refname) = ($1, $2);
+                } elsif (s/^tag: //) {
+                        $refname = $_;
+                        $tag = 1; # XXX use this
+                } else {
+                        $refname = $_;
+                }
+                if ($refname =~ $want) {
+                        $self->{-active_refs}->{$refname} = $oid;
+                }
+                # TODO: handle $sym, and do something with tags
+        }
+}
+
+sub term_generator ($) { # write-only
+        my ($self) = @_;
+
+        $self->{term_generator} ||= eval {
+                my $tg = Search::Xapian::TermGenerator->new;
+                $tg->set_stemmer($self->stemmer);
+                $tg;
+        };
+}
+
+sub index_text_inc ($$$) {
+        my ($tg, $text, $pfx) = @_;
+        $tg->index_text($text, 1, $pfx);
+        $tg->increase_termpos;
+}
+
+sub index_blob_id ($$$) {
+        my ($tg, $blob_id, $pfx) = @_;
+        index_text_inc($tg, $blob_id, $pfx) if $blob_id ne Z40;
+}
+
+sub each_log_line ($$) {
+        my ($self, $range) = @_;
+        my $log = $self->{git}->popen(qw(log --decorate=full --pretty=raw
+                        --no-color --no-abbrev --no-notes
+                        -r --raw -p
+                        ), $range, '--');
+        my $db = $self->{xdb};
+        my ($doc, $doc_id);
+        my $tg = term_generator($self);
+        my $state = 0; # 1: subject, 2: body, 3: diff, 4: diff -c
+        my $tip;
+        my $hex = '[a-f0-9]+';
+        my ($cc_ins, $cc_del);
+        my $batch = BATCH_BYTES;
+
+        local $/ = "\n";
+        while (defined(my $l = <$log>)) {
+                $batch -= bytes::length($l);
+                # prevent memory growth from Xapian
+                if ($batch <= 0) {
+                        $db->flush;
+                        $batch = BATCH_BYTES;
+                }
+                if ($l =~ /^commit (\S+)(\s+\([^\)]+\))?/) {
+                        my ($oid, $decor) = ($1, $2);
+                        replace_or_add($db, $doc_id, $doc) if $doc;
+                        $tip ||= $oid;
+                        $state = 0;
+                        $cc_ins = $cc_del = undef;
+
+                        $doc = get_doc($self, \$doc_id, 'commit', $oid);
+                        decor_update($self, $doc, $decor, $oid) if $decor;
+                        # old commit
+                        last if defined $doc_id;
+
+                        # new commit:
+                        $tg->set_document($doc);
+                        $doc->set_data($oid);
+                        $doc->add_term('Q' . $oid);
+                        index_text_inc($tg, $oid, 'Q');
+                } elsif ($l =~ /^parent (\S+)/) {
+                        my $parent = $1;
+                        index_text_inc($tg, $parent, 'XP');
+                } elsif ($l =~ /^author ([^<]*?<[^>]+>) (\d+)/) {
+                        my ($au, $at) = ($1, $2);
+                        index_text_inc($tg, $au, 'A');
+                        add_val($doc, PublicInbox::RepoGitSearch::AD,
+                                strftime('%Y%m%d', gmtime($at)));
+                } elsif ($l =~ /^committer ([^<]*?<[^>]+>) (\d+)/) {
+                        my ($cu, $ct) = ($1, $2);
+                        index_text_inc($tg, $cu, 'XC');
+                        add_val($doc, PublicInbox::RepoGitSearch::CD,
+                                strftime('%Y%m%d', gmtime($ct)));
+                } elsif ($l =~ /^gpgsig /) {
+                        $state = STATE_GPGSIG;
+                } elsif ($l =~ /^mergetag /) {
+                        $state = -1;
+                } elsif ($state < 0) { # inside mergetag or gpgsig
+                        if ($l eq " \n") { # paragraph
+                                $state--;
+                                $tg->increase_termpos;
+                        } elsif ($l eq "-----BEGIN PGP SIGNATURE-----\n") {
+                                # no point in indexing a PGP signature
+                                $state = STATE_GPGSIG;
+                        } elsif ($state == -2) { # mergetag subject
+                                $tg->index_text($l, 1);
+                                $tg->increase_termpos;
+                        } elsif ($state < -2 && $state > STATE_GPGSIG) {
+                                $tg->index_text($l); # mergetag body
+                        } elsif ($l eq "\n") {
+                                # end of mergetag, onto normal commit message
+                                $tg->increase_termpos;
+                                $state = 0;
+                        } elsif ($l =~ /^ (?:tag|tagger|type) /) {
+                                # ignored
+                        } elsif (DEBUG) {
+                                if ($state <= STATE_GPGSIG) {
+                                # skip
+                                } else {
+                                        warn "unhandled mergetag: $l";
+                                }
+                        }
+                } elsif ($state < 3 && $l =~ s/^    //) { # subject and body
+                        if ($state > 0) {
+                                $l =~ /\S/ ? $tg->index_text($l, 1)
+                                                : $tg->increase_termpos;
+                                $state = 2;
+                        } else {
+                                $state = 1;
+                                $tg->index_text($l, 1, 'S') if $l ne "\n";
+                        }
+                } elsif ($l =~ /^:\d{6} \d{6} ($hex) ($hex) (\S+)\t+(.+)/o) {
+                        # --raw output (regular)
+                        my ($pre, $post, $chg, $names) = ($1, $2, $3, $4);
+                        index_blob_id($tg, $pre, 'XPRE');
+                        index_blob_id($tg, $post, 'XPOST');
+                } elsif ($l =~ /^(::+)(?:\d{6} )+ ($hex .+)? (\S+)\t+(.+)/o) {
+                        # --raw output (combined)
+                        my ($colons, $blobs, $chg, $names) = ($1, $2, $3, $4);
+                        my @blobs = split(/ /, $blobs);
+                        my $post = pop @blobs;
+                        my $n = length($colons);
+                        if (scalar(@blobs) != $n) {
+                                die "combined raw parsed wrong:\n$l\n//\n";
+                        }
+                        index_blob_id($tg, $_, 'XPRE') foreach @blobs;
+                        index_blob_id($tg, $post, 'XPOST');
+                        unless ($cc_ins) {
+                                $n--;
+                                $cc_ins = qr/^ {0,$n}[\+]\s*(.*)/;
+                                $cc_del = qr/^ {0,$n}[\-]\s*(.*)/;
+                        }
+                } elsif ($l =~ m!^diff --git (?:"?a/.+?) (?:"?b/.+)!) {
+                        # regular diff, filenames handled by --raw
+                        $state = 3;
+                } elsif ($l =~ /^diff --(?:cc|combined) (?:.+)/) {
+                        # combined diff, filenames handled by --raw
+                        $state = 4;
+                } elsif ($l =~ /^@@ (?:\S+) (?:\S+) @@(.*)/) {
+                        my $hunk_hdr = $1;
+                        # regular hunk header context
+                        $hunk_hdr =~ /\S/ and
+                                        index_text_inc($tg, $hunk_hdr, 'XDHH');
+                # not currently handled:
+                } elsif ($l =~ /^index (?:$hex)\.\.(?:$hex)/o) {
+                } elsif ($l =~ /^index (?:$hex,[^\.]+)\.\.(?:$hex)(.*)$/o) {
+                        #--cc
+                } elsif ($l =~ /^(?:@@@+) (?:\S+.*\S+) @@@+\z/) { # --cc
+                } elsif ($l =~ /^(?:old|new) mode/) {
+                } elsif ($l =~ /^(?:deleted|new) file mode/) {
+                } elsif ($l =~ /^tree (?:\S+)/) {
+                } elsif ($l =~ /^(?:copy|rename) (?:from|to) /) {
+                } elsif ($l =~ /^(?:dis)?similarity index /) {
+                } elsif ($l =~ /^\\ No newline at end of file/) {
+                } elsif ($l =~ /^Binary files .* differ/) {
+                } elsif ($l =~ /^--- /) { # preimage filename
+                } elsif ($l =~ /^\+\+\+ /) { # postimage filename
+                } elsif ($state == 3) { # diff --git
+                        if ($l =~ s/^\+//) {
+                                index_text_inc($tg, $l, 'XDFB');
+                        } elsif ($l =~ s/^\-//) {
+                                index_text_inc($tg, $l, 'XDFA');
+                        } elsif ($l =~ s/^ //) {
+                                index_text_inc($tg, $l, 'XDCTX');
+                        } elsif (DEBUG) {
+                                if ($l eq "\n") {
+                                } else {
+                                        warn "unhandled diff -u $l";
+                                }
+                        }
+                } elsif ($state == 4) { # diff --cc/combined
+                        if ($l =~ $cc_ins) {
+                                index_text_inc($tg, $1, 'XDFB');
+                        } elsif ($l =~ $cc_del) {
+                                index_text_inc($tg, $1, 'XDFA');
+                        } elsif ($l =~ s/^ //) {
+                                index_text_inc($tg, $l, 'XDCTX');
+                        } elsif (DEBUG) {
+                                if ($l eq "\n") {
+                                } else {
+                                        warn "unhandled diff --cc $l";
+                                }
+                        }
+                } elsif (DEBUG) {
+                        warn  "wtf $state $l\n" if $l ne "\n";
+                }
+        }
+        replace_or_add($db, $doc_id, $doc) if $doc;
+        $tip;
+}
+
+sub index_top_ref ($$$) {
+        my ($self, $refname, $end) = @_;
+        my $doc_id;
+        my $db = xdb($self);
+        my $ref_doc = get_doc($self, \$doc_id, 'ref', $refname);
+        my $begin = defined $doc_id ? $ref_doc->get_data : '';
+        my $active = $self->{-active_refs} = { $refname => undef };
+        my $git = $self->{git};
+
+        # check for discontiguous branches (from "push --force")
+        if ($begin ne '') {
+                my $base = $git->qx(qw(merge-base), $begin, $end);
+                chomp $base;
+                if ($base ne $begin) {
+                        warn "$refname updated with force\n";
+                        # TODO: cleanup_forced_update($self, $refname);
+                        $begin = '';
+                }
+        }
+        my $range = $begin eq '' ? $end : "$begin^0..$end^0";
+        my $tip = each_log_line($self, $range);
+        my $progress = $self->{progress};
+        if (defined $tip) {
+                $ref_doc->set_data($tip);
+                print $progress "$refname => $tip\n" if $progress;
+                replace_or_add($db, $doc_id, $ref_doc);
+        }
+        $db->flush;
+
+        # update all decorated refs which got snowballed into this one
+        delete $active->{$refname};
+        my $n = 100;
+        foreach my $ref (keys %$active) {
+                if (--$n <= 0) {
+                        $db->flush;
+                        $n = 100;
+                }
+                $ref_doc = get_doc($self, \$doc_id, 'ref', $ref);
+                $ref_doc->set_data($active->{$ref});
+                if ($progress) {
+                        print $progress "$ref => $active->{$ref} ($refname)\n";
+                }
+                replace_or_add($db, $doc_id, $ref_doc);
+        }
+        $db->flush;
+}
+
+# main entry sub:
+sub index_sync {
+        my ($self, $opts) = @_;
+        $self->{progress} = $opts->{progress};
+        my $db = xdb($self);
+        $self->{-update_id} = update_id($self);
+        # go for most recent refs, first, since that reduces the amount
+        # of work we have to do.
+        my $refs = $self->{git}->popen(qw(for-each-ref --sort=-creatordate));
+        local $/ = "\n";
+        while (defined(my $line = <$refs>)) {
+                chomp $line;
+                my ($oid, $type, $refname) = split(/\s+/, $line);
+                next unless $refname =~ $self->{want_refs_re};
+                next unless $type eq 'commit' || $type eq 'tag';
+                index_top_ref($self, $refname, $oid);
+        }
+}
+
+1;
diff --git a/lib/PublicInbox/RepoGitSnapshot.pm b/lib/PublicInbox/RepoGitSnapshot.pm
new file mode 100644
index 00000000..49d51033
--- /dev/null
+++ b/lib/PublicInbox/RepoGitSnapshot.pm
@@ -0,0 +1,108 @@
+# 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::RepoGitSnapshot;
+use strict;
+use warnings;
+use base qw(PublicInbox::RepoBase);
+use PublicInbox::Git;
+use PublicInbox::Qspawn;
+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::RepoBase::call
+        my ($self, $req) = @_;
+
+        my $ref = $req->{tip} || $req->{-repo}->tip;
+        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 $env = $req->{env};
+        my $repo = $req->{-repo};
+
+        # support disabling certain snapshots types entirely to twart
+        # URL guessing since it could burn server resources.
+        return $self->r(404) if $repo->{snapshots_disabled}->{$fmt};
+
+        # strip optional basename (may not exist)
+        $ref =~ s/$repo->{snapshot_re}//;
+
+        # don't allow option/command injection, git refs do not start with '-'
+        return $self->r(404) if $ref =~ /\A-/;
+
+        my $git = $repo->{git};
+        my $tree = '';
+        my $last_cb = sub {
+                delete $env->{'repobrowse.tree_cb'};
+                delete $env->{'qspawn.quiet'};
+                my $pfx = "$repo->{snapshot_pfx}-$ref/";
+                my $cmd = $git->cmd('archive',
+                                "--prefix=$pfx", "--format=$fmt", $tree);
+                my $rdr = { 2 => $git->err_begin };
+                my $qsp = PublicInbox::Qspawn->new($cmd, undef, $rdr);
+                $qsp->psgi_return($env, undef, sub {
+                        my $r = $_[0];
+                        return $self->r(500) unless $r;
+                        [ 200, [ 'Content-Type',
+                                $FMT_TYPES{$fmt} || 'application/octet-stream',
+                                'Content-Disposition',
+                                        qq(inline; filename="$orig_fn"),
+                                'ETag', qq("$tree") ] ];
+                });
+        };
+
+        my $cmd = $git->cmd(qw(rev-parse --verify --revs-only));
+        # try prefixing "v" or "V" for tag names to get the tree
+        my @refs = ("V$ref", "v$ref", $ref);
+        $env->{'qspawn.quiet'} = 1;
+        my $tree_cb = $env->{'repobrowse.tree_cb'} = sub {
+                my ($ref) = @_;
+                if (defined $ref) {
+                        $tree = $$ref;
+                        chomp $tree;
+                }
+                return $last_cb->() if $tree ne '';
+                unless (scalar(@refs)) {
+                        my $res = delete $env->{'qspawn.response'};
+                        return $res->($self->r(404));
+                }
+                my $rdr = { 2 => $git->err_begin };
+                my $r = pop @refs;
+                my $qsp = PublicInbox::Qspawn->new([@$cmd, $r], undef, $rdr);
+                $qsp->psgi_qx($env, undef, $env->{'repobrowse.tree_cb'});
+        };
+        sub {
+                $env->{'qspawn.response'} = $_[0];
+                # kick off the "loop" foreach @refs
+                $tree_cb->(undef);
+        }
+}
+
+1;
diff --git a/lib/PublicInbox/RepoGitSrc.pm b/lib/PublicInbox/RepoGitSrc.pm
new file mode 100644
index 00000000..51048773
--- /dev/null
+++ b/lib/PublicInbox/RepoGitSrc.pm
@@ -0,0 +1,242 @@
+# Copyright (C) 2015 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+package PublicInbox::RepoGitSrc;
+use strict;
+use warnings;
+use base qw(PublicInbox::RepoBase);
+use PublicInbox::Hval qw(utf8_html);
+use PublicInbox::Qspawn;
+
+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";
+my $TOOBIG_MSG = "File is too big to display, save using the 'raw' link above";
+my $MAX_ASYNC = 65536; # same as pipe size on Linux
+my $BIN_DETECT = 8000; # same as git (buffer_is_binary in git.git)
+
+sub call_git_src {
+        my ($self, $req) = @_;
+        my $repo = $req->{-repo};
+        my $git = $repo->{git};
+        my $tip = $req->{tip} or return $self->r(302, $req, $repo->tip);
+        sub {
+                my ($res) = @_;
+                $git->check_async($req->{env}, "$tip:$req->{expath}", sub {
+                        my ($info) = @_;
+                        my ($hex, $type, $size) = @$info;
+                        unless (defined $type) {
+                                $res->($self->rt(404, 'plain', 'Not Found'));
+                        }
+                        show_tree($self, $req, $res, $hex, $type, $size);
+                });
+        }
+}
+
+sub show_tree {
+        my ($self, $req, $res, $hex, $type, $size) = @_;
+        my $opts = { nofollow => 1 };
+        my $title = "tree: ".utf8_html($req->{expath});
+        $req->{thtml} = $self->html_start($req, $title, $opts) . "\n";
+        if ($type eq 'tree') {
+                $opts->{noindex} = 1;
+                git_tree_show($self, $req, $res, $hex);
+        } elsif ($type eq 'blob') {
+                git_blob_show($self, $req, $res, $hex, $size);
+        } else {
+                $res->($self->rt(404, 'plain',
+                        "Unrecognized type ($type) for $hex\n"));
+        }
+}
+
+sub cur_path {
+        my ($req) = @_;
+        my @ex = @{$req->{extra}} or return '<b>root</b>';
+        my $s;
+        my $tip = $req->{tip};
+        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}src/$tip\">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}src/$tip/$ep\">$eh</a>";
+        } @ex), '<b>'.utf8_html($cur).'</b>');
+}
+
+sub git_blob_sed ($$$) {
+        my ($req, $hex, $size) = @_;
+        my $pfx = $req->{tpfx};
+        my $nl = 0;
+        my $bytes = 0;
+        my @lines;
+        my $buf = '';
+        my $end = '';
+        my $s;
+
+        sub {
+                my $dst = delete $req->{thtml} || '';
+                if (defined $_[0]) {
+                        return '' if $bytes < 0; # binary
+                        if ($bytes <= $BIN_DETECT) {
+                                if (index($_[0], "\0") >= 0) {
+                                        $bytes = -1;
+                                        $s = delete $req->{lstart} and
+                                                $dst .= $s;
+                                        $dst .= "\n";
+                                        $dst .= $BINARY_MSG;
+                                        return $dst .= '</pre></body></html>';
+                                }
+                        }
+                        $bytes += bytes::length($_[0]);
+                        $buf .= $_[0];
+                        $_[0] = ''; # save some memory
+                        $s = delete $req->{lstart} and $dst .= $s;
+                        @lines = split(/\r?\n/, $buf, -1);
+                        $buf = pop @lines; # last line, careful...
+                } else { # EOF
+                        $s = delete $req->{lstart} and $dst .= $s;
+                        @lines = split(/\r?\n/, $buf, -1);
+                        $buf = pop @lines;
+                        $end .= '</pre></body></html>';
+                }
+                foreach (@lines) {
+                        ++$nl;
+                        $dst .= "<a\nid=n$nl>";
+                        $dst .= sprintf("% 5u</a>\t", $nl);
+                        $dst .= utf8_html($_);
+                        $dst .= "\n";
+                }
+                @lines = ();
+                if ($end && defined $buf && $buf ne '') {
+                        ++$nl;
+                        $dst .= "<a\nid=n$nl>";
+                        $dst .= sprintf("% 5u</a>\t", $nl);
+                        $dst .= utf8_html($buf);
+                        $buf = undef;
+                        $dst .= "\n\\ No newline at end of file";
+                }
+                $dst .= $end;
+        }
+}
+
+sub git_blob_show {
+        my ($self, $req, $res, $hex, $size) = @_;
+        my $t = cur_path($req);
+        my $rel = $req->{relcmd};
+        my $raw = join('/', "${rel}raw", $req->{tip}, @{$req->{extra}});
+        $raw = PublicInbox::Hval->utf8($raw)->as_path;
+        $req->{thtml} .= qq{\npath: $t\n\nblob $hex} .
+                        qq{\t$size bytes (<a\nhref="$raw">raw</a>)};
+        $req->{lstart} = '</pre><hr/><pre>';
+        my $git = $req->{-repo}->{git};
+        if ($size > $MAX_ASYNC) {
+                my $html = delete($req->{thtml}) . delete($req->{lstart});
+                $html .= $TOOBIG_MSG;
+                $html .= '</pre></body></html>';
+                return $res->($self->rt(200, 'html', $html));
+        }
+
+        my $buf = ''; # we slurp small files
+        $git->cat_async($req->{env}, $hex, sub {
+                my ($r) = @_;
+                my $ref = ref($r);
+                return if $ref eq 'ARRAY'; # redundant info
+                if ($ref eq 'SCALAR') {
+                        $buf .= $$r;
+                } elsif (!defined $r) {
+                        my $cb = $res or return;
+                        $res = undef;
+                        $cb->($self->rt(500, 'plain', "Error\n"));
+                } elsif ($r == 0) {
+                        my $fh = $res->($self->rt(200, 'html'));
+                        my $sed = git_blob_sed($req, $hex, $size);
+                        $fh->write($sed->($buf));
+                        $fh->write($sed->(undef));
+                        $fh->close;
+                }
+        });
+}
+
+sub git_tree_sed ($) {
+        my ($req) = @_;
+        my @lines;
+        my $buf = '';
+        my $pfx = $req->{tpfx};
+        my $end;
+        sub {
+                my $dst = delete $req->{thtml} || '';
+                if (defined $_[0]) {
+                        @lines = split(/\0/, $buf .= $_[0]);
+                        $buf = pop @lines if @lines;
+                } else {
+                        @lines = split(/\0/, $buf);
+                        $end = '</pre></body></html>';
+                }
+                for (@lines) {
+                        my ($m, $x, $s, $path) =
+                                        (/\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
+                                $dst .= '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;
+
+                        # 'raw' and 'log' links intentionally omitted
+                        # for brevity and speed
+                        $dst .= qq($m\t).
+                                qq($s\t<a\nhref="$pfx/$ref">$path</a>\n);
+                }
+                $dst;
+        }
+}
+
+sub git_tree_show {
+        my ($self, $req, $res, $hex) = @_;
+        my $git = $req->{-repo}->{git};
+        my $cmd = $git->cmd(qw(ls-tree -l -z --no-abbrev), $hex);
+        my $rdr = { 2 => $git->err_begin };
+        my $qsp = PublicInbox::Qspawn->new($cmd, undef, $rdr);
+        my $t = cur_path($req);
+        my $pfx;
+
+        $req->{thtml} .= "\npath: $t\n\n<b>mode\tsize\tname</b>\n";
+        if (defined(my $last = $req->{extra}->[-1])) {
+                $pfx = PublicInbox::Hval->utf8($last)->as_path;
+        } else {
+                $pfx = 'src/' . $req->{tip};
+        }
+        $req->{tpfx} = $pfx;
+        my $env = $req->{env};
+        $env->{'qspawn.response'} = $res;
+        $qsp->psgi_return($env, undef, sub {
+                my ($r) = @_;
+                if (defined $r) {
+                        $env->{'qspawn.filter'} = git_tree_sed($req);
+                        $self->rt(200, 'html');
+                } else {
+                        $self->rt(500, 'plain', $git->err);
+                }
+        });
+}
+
+1;
diff --git a/lib/PublicInbox/RepoGitSummary.pm b/lib/PublicInbox/RepoGitSummary.pm
new file mode 100644
index 00000000..ee9436f3
--- /dev/null
+++ b/lib/PublicInbox/RepoGitSummary.pm
@@ -0,0 +1,100 @@
+# 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::RepoGitSummary;
+use strict;
+use warnings;
+use PublicInbox::Hval qw(utf8_html);
+use base qw(PublicInbox::RepoBase);
+use PublicInbox::Qspawn;
+
+sub call_git_summary {
+        my ($self, $req) = @_;
+        my $git = $req->{-repo}->{git};
+        my $env = $req->{env};
+        sub {
+                my ($res) = @_; # Plack streaming callback
+                for_each_ref($self, $req, $res, $req->{-repo}->tip);
+        }
+}
+
+use constant EACH_REF_FMT => '--format=' .
+                join(' ', map { "%($_)" }
+                qw(refname objecttype objectname creatordate:short subject));
+
+sub for_each_ref {
+        my ($self, $req, $res, $head_ref) = @_;
+        my $count = 10; # TODO: configurable
+        my $fh;
+        my $repo = $req->{-repo};
+        my $git = $repo->{git};
+        my $refs = $git->popen(qw(for-each-ref --sort=-creatordate),
+                                EACH_REF_FMT, "--count=$count",
+                                qw(refs/heads/ refs/tags/));
+        $fh = $res->($self->rt(200, 'html'));
+        # ref names are unpredictable in length and requires tables :<
+        $fh->write($self->html_start($req,
+                                "$repo->{repo}: overview") .
+                        '</pre><table>');
+
+        my $rel = $req->{relcmd};
+        while (<$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/' . $ref;
+                } elsif ($type eq 'commit') {
+                        $sref = $rel . 'commit/' . $ref;
+                        $ref = $rel . 'log/' . $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->{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($req, $rel, $r) .
+                        " (HEAD)\n\n" . utf8_html($$doc) . '</pre>');
+        }
+        $fh->write('</body></html>');
+        $fh->close;
+}
+
+sub readme_path_links {
+        my ($req, $rel, $readme) = @_;
+        my @path = split(m!/+!, $readme);
+        my $tip = $req->{-repo}->tip;
+        my $s = "tree <a\nhref=\"${rel}src/$tip\">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}src/$tip/$ep\">$eh</a>";
+                # bold the last one
+                scalar(@t) == scalar(@path) ? "<b>$e</b>" : $e;
+        } @path));
+}
+
+1;
diff --git a/lib/PublicInbox/RepoGitTag.pm b/lib/PublicInbox/RepoGitTag.pm
new file mode 100644
index 00000000..2a281ed6
--- /dev/null
+++ b/lib/PublicInbox/RepoGitTag.pm
@@ -0,0 +1,211 @@
+# 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::RepoGitTag;
+use strict;
+use warnings;
+use base qw(PublicInbox::RepoBase);
+use POSIX qw(strftime);
+use PublicInbox::Hval qw(utf8_html);
+use PublicInbox::Qspawn;
+
+my %cmd_map = ( # type => action
+        commit => 'commit',
+        tag => 'tag',
+        # tree/blob fall back to 'show'
+);
+
+sub call_git_tag {
+        my ($self, $req) = @_;
+
+        my $tip = $req->{tip};
+        defined $tip or return git_tag_list($self, $req);
+        sub {
+                my ($res) = @_;
+                git_tag_show($self, $req, $tip, $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/$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}->{git};
+        my $fh;
+
+        # 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->($self->rt(200, 'html'));
+                $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->($self->rt(404, 'html'));
+                $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_each_tag_sed ($$) {
+        my ($self, $req) = @_;
+        my $repo = $req->{-repo};
+        my $buf = '';
+        my $nr = 0;
+        $req->{thtml} = $self->html_start($req, "$repo->{repo}: tag list") .
+                '</pre><table><tr>' .
+                join('', map { "<th><tt>$_</tt></th>" } qw(tag date subject)).
+                '</tr>';
+        sub {
+                my $dst = delete $req->{thtml} || '';
+                my $end = '';
+                my @lines;
+                if (defined $_[0]) {
+                        @lines = split(/\n/, $buf .= $_[0]);
+                        $buf = pop @lines if @lines;
+                } else { # for-each-ref EOF
+                        @lines = split(/\n/, $buf);
+                        $buf = undef;
+                        if ($nr == $req->{-tag_count}) {
+                                $end = "<pre>Showing the latest $nr tags</pre>";
+                        } elsif ($nr == 0) {
+                                $end = '<pre>no tags to show</pre>';
+                        }
+                        $end = "</table>$end</body></html>";
+                }
+                for (@lines) {
+                        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;
+                        $dst .= qq(<tr><td><tt>) .
+                                qq(<a\nhref="tag/$ref"><b>$h</b></a>) .
+                                qq(</tt></td><td><tt>$date</tt></td><td><tt>) .
+                                utf8_html($s) . '</tt></td></tr>';
+                }
+                $dst .= $end;
+        }
+}
+
+sub git_tag_list {
+        my ($self, $req) = @_;
+        my $git = $req->{-repo}->{git};
+
+        # TODO: use Xapian so we can more easily handle offsets/limits
+        # for pagination instead of limiting
+        my $count = $req->{-tag_count} = 50;
+        my $cmd = $git->cmd(qw(for-each-ref --sort=-creatordate),
+                '--format=%(refname) %(creatordate:short) %(subject)',
+                "--count=$count", 'refs/tags/');
+        my $rdr = { 2 => $git->err_begin };
+        my $qsp = PublicInbox::Qspawn->new($cmd, undef, $rdr);
+        my $env = $req->{env};
+        $env->{'qspawn.quiet'} = 1;
+        $qsp->psgi_return($env, undef, sub { # parse output
+                my ($r) = @_;
+                if (!defined $r) {
+                        my $errmsg = $git->err;
+                        [ 500, [ 'Content-Type', 'text/html; charset=UTF-8'],
+                                [ $errmsg ] ];
+                } else {
+                        $env->{'qspawn.filter'} = git_each_tag_sed($self, $req);
+                        [ 200, [ 'Content-Type', 'text/html; charset=UTF-8' ]];
+                }
+        });
+}
+
+sub unknown_tag_type {
+        my ($self, $fh, $req, $h, $type, $hex) = @_;
+        my $repo = $req->{-repo};
+        $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/$hex">$label</a>\n);
+
+        $fh->write($self->html_start($req,
+                                "$repo->{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/RepoRoot.pm b/lib/PublicInbox/RepoRoot.pm
new file mode 100644
index 00000000..c04c23c5
--- /dev/null
+++ b/lib/PublicInbox/RepoRoot.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::RepoRoot;
+use strict;
+use warnings;
+use base qw(PublicInbox::RepoBase);
+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;
diff --git a/lib/PublicInbox/Repobrowse.pm b/lib/PublicInbox/Repobrowse.pm
new file mode 100644
index 00000000..03960e2b
--- /dev/null
+++ b/lib/PublicInbox/Repobrowse.pm
@@ -0,0 +1,167 @@
+# 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 PublicInbox::RepoConfig;
+
+my %CMD = map { lc($_) => $_ } qw(Log Commit Src Patch Raw Tag Atom
+        Diff Snapshot);
+my %VCS = (git => 'Git');
+my %LOADED;
+
+sub new {
+        my ($class, $rconfig) = @_;
+        $rconfig ||= PublicInbox::RepoConfig->new;
+        bless { rconfig => $rconfig }, $class;
+}
+
+# simple response for errors
+sub r { [ $_[0], ['Content-Type' => 'text/plain'], [ join(' ', @_, "\n") ] ] }
+
+sub base_url ($) {
+        my ($env) = @_;
+        my $scheme = $env->{'psgi.url_scheme'} || 'http';
+        my $host = $env->{HTTP_HOST};
+        my $base = "$scheme://";
+        if (defined $host) {
+                $base .= $host;
+        } else {
+                $base .= $env->{SERVER_NAME};
+                my $port = $env->{SERVER_PORT} || 80;
+                if (($scheme eq 'http' && $port != 80) ||
+                                ($scheme eq 'https' && $port != 443)) {
+                        $base.= ":$port";
+                }
+        }
+        $base .= $env->{SCRIPT_NAME};
+}
+
+# return a PSGI response if the URL is ambiguous with
+# extra slashes or has a trailing slash
+sub disambiguate_uri {
+        my ($env) = @_;
+        my $redirect;
+        my $uri = $env->{REQUEST_URI};
+        my $qs = '';
+        $uri =~ s!\A([^:]+://)!!;
+        my $scheme = $1 || '';
+        if ($uri =~ s!/(\?.+)?\z!!) { # no trailing slashes
+                $qs = $1 if defined $1;
+                $redirect = 1;
+        }
+        if ($uri =~ s!//+!/!g) { # no redundant slashes
+                $redirect = 1;
+        }
+        return unless $redirect;
+        $uri = ($scheme ? $scheme : base_url($env)) . $uri . $qs;
+        [ 301,
+          [ 'Location', $uri, 'Content-Type', 'text/plain' ],
+          [ "Redirecting to $uri\n" ] ]
+}
+
+sub root_index {
+        my ($self) = @_;
+        my $mod = load_once('PublicInbox::RepoRoot');
+        $mod->new->call($self->{rconfig}); # RepoRoot::call
+}
+
+# PSGI entry point
+sub call {
+        my ($self, $env) = @_;
+        my $method = $env->{REQUEST_METHOD};
+        return r(405, 'Method Not Allowed') if ($method !~ /\AGET|HEAD|POST\z/);
+        if (my $res = disambiguate_uri($env)) {
+                return $res;
+        }
+
+        # URL syntax: / repo [ / cmd [ / head [ / path ] ] ]
+        # cmd: log | commit | diff | src | raw | snapshot
+        # repo and path (@extra) may both contain '/'
+        my $path_info = $env->{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}; # RepoConfig
+        my $repo;
+        until ($repo = $rconfig->lookup($repo_path)) {
+                my $p = shift @extra or last;
+                $repo_path .= "/$p";
+        }
+        return r404() unless $repo;
+
+        my $req = {
+                -repo => $repo,
+                extra => \@extra, # path
+                rconfig => $rconfig,
+                env => $env,
+        };
+        my $cmd = shift @extra;
+        my $vcs_lc = $repo->{vcs};
+        my $vcs = $VCS{$vcs_lc} or return r404();
+        my $mod;
+        my $tip;
+        if (defined $cmd && length $cmd) {
+                $mod = $CMD{$cmd};
+                if ($mod) {
+                        $tip = shift @extra if @extra;
+                } else {
+                        unshift @extra, $cmd;
+                        $mod = 'Fallback';
+                }
+                $req->{relcmd} = '../' x (scalar(@extra) + 1);
+        } else {
+                $mod = 'Summary';
+                $cmd = 'summary';
+                if ($path_info =~ m!/\z!) {
+                        $path_info =~ tr!/!!;
+                } else {
+                        my @x = split('/', $repo_path);
+                        $req->{relcmd} = @x > 1 ? "./$x[-1]/" : "/$x[-1]/";
+                }
+        }
+        while (@extra && $extra[-1] eq '') {
+                pop @extra;
+        }
+        $req->{tip} = $tip;
+        $mod = load_once("PublicInbox::Repo$vcs$mod");
+        $vcs = load_once("PublicInbox::$vcs");
+
+        # $repo->{git} ||= PublicInbox::Git->new(...)
+        $repo->{$vcs_lc} ||= $vcs->new($repo->{path});
+
+        $req->{expath} = join('/', @extra);
+        my $rv = eval { $mod->new->call($cmd, $req) }; # RepoBase::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/Search.pm b/lib/PublicInbox/Search.pm
index bc2b6985..b0bfe232 100644
--- a/lib/PublicInbox/Search.pm
+++ b/lib/PublicInbox/Search.pm
@@ -55,8 +55,6 @@ my %bool_pfx_internal = (
 );
 
 my %bool_pfx_external = (
-        # do we still need these? probably not..
-        path => 'XPATH',
         mid => 'Q', # uniQue id (Message-ID)
 );
 
@@ -106,11 +104,7 @@ chomp @HELP;
 # da (diff a/ removed lines)
 # db (diff b/ added lines)
 
-my %all_pfx = (%bool_pfx_internal, %bool_pfx_external, %prob_prefix);
-
-sub xpfx { $all_pfx{$_[0]} }
-
-my $mail_query = Search::Xapian::Query->new(xpfx('type') . 'mail');
+my $mail_query = Search::Xapian::Query->new('T' . 'mail');
 
 sub xdir {
         my (undef, $git_dir) = @_;
@@ -145,11 +139,11 @@ sub get_thread {
         my $smsg = eval { $self->lookup_message($mid) };
 
         return { total => 0, msgs => [] } unless $smsg;
-        my $qtid = Search::Xapian::Query->new(xpfx('thread').$smsg->thread_id);
+        my $qtid = Search::Xapian::Query->new('G' . $smsg->thread_id);
         my $path = $smsg->path;
         if (defined $path && $path ne '') {
                 my $path = id_compress($smsg->path);
-                my $qsub = Search::Xapian::Query->new(xpfx('path').$path);
+                my $qsub = Search::Xapian::Query->new('XPATH' . $path);
                 $qtid = Search::Xapian::Query->new(OP_OR, $qtid, $qsub);
         }
         $opts ||= {};
@@ -278,7 +272,7 @@ sub lookup_message {
         my ($self, $mid) = @_;
         $mid = mid_clean($mid);
 
-        my $doc_id = $self->find_unique_doc_id('mid', $mid);
+        my $doc_id = $self->find_unique_doc_id('Q' . $mid);
         my $smsg;
         if (defined $doc_id) {
                 # raises on error:
@@ -298,9 +292,9 @@ sub lookup_mail { # no ghosts!
 }
 
 sub find_unique_doc_id {
-        my ($self, $term, $value) = @_;
+        my ($self, $termval) = @_;
 
-        my ($begin, $end) = $self->find_doc_ids($term, $value);
+        my ($begin, $end) = $self->find_doc_ids($termval);
 
         return undef if $begin->equal($end); # not found
 
@@ -308,23 +302,16 @@ sub find_unique_doc_id {
 
         # sanity check
         $begin->inc;
-        $begin->equal($end) or die "Term '$term:$value' is not unique\n";
+        $begin->equal($end) or die "Term '$termval' is not unique\n";
         $rv;
 }
 
 # returns begin and end PostingIterator
 sub find_doc_ids {
-        my ($self, $term, $value) = @_;
-
-        $self->find_doc_ids_for_term(xpfx($term) . $value);
-}
-
-# returns begin and end PostingIterator
-sub find_doc_ids_for_term {
-        my ($self, $term) = @_;
+        my ($self, $termval) = @_;
         my $db = $self->{xdb};
 
-        ($db->postlist_begin($term), $db->postlist_end($term));
+        ($db->postlist_begin($termval), $db->postlist_end($termval));
 }
 
 # normalize subjects so they are suitable as pathnames for URLs
diff --git a/lib/PublicInbox/SearchIdx.pm b/lib/PublicInbox/SearchIdx.pm
index 8a529c66..8200b54c 100644
--- a/lib/PublicInbox/SearchIdx.pm
+++ b/lib/PublicInbox/SearchIdx.pm
@@ -16,18 +16,14 @@ $Email::MIME::ContentType::STRICT_PARAMS = 0;
 use base qw(PublicInbox::Search);
 use PublicInbox::MID qw/mid_clean id_compress mid_mime/;
 use PublicInbox::MsgIter;
+use PublicInbox::GitIdx;
 use Carp qw(croak);
 use POSIX qw(strftime);
 require PublicInbox::Git;
-*xpfx = *PublicInbox::Search::xpfx;
 
-use constant MAX_MID_SIZE => 244; # max term size - 1 in Xapian
 use constant {
-        PERM_UMASK => 0,
-        OLD_PERM_GROUP => 1,
-        OLD_PERM_EVERYBODY => 2,
-        PERM_GROUP => 0660,
-        PERM_EVERYBODY => 0664,
+        MAX_MID_SIZE => 244, # max term size - 1 in Xapian
+        BATCH_BYTES => 1_000_000,
 };
 
 sub new {
@@ -46,11 +42,10 @@ sub new {
         }
         require Search::Xapian::WritableDatabase;
         my $self = bless { git_dir => $git_dir, -altid => $altid }, $class;
-        my $perm = $self->_git_config_perm;
-        my $umask = _umask_for($perm);
-        $self->{umask} = $umask;
+        my $git = $self->{git} = PublicInbox::Git->new($git_dir);
+        my $umask = git_umask_for($git);
+        $self->{'umask'} = $umask;
         $self->{lock_path} = "$git_dir/ssoma.lock";
-        $self->{git} = PublicInbox::Git->new($git_dir);
         $self->{creat} = ($creat || 0) == 1;
         $self;
 }
@@ -72,7 +67,6 @@ sub _xdb_acquire {
                 require File::Path;
                 _lock_acquire($self);
                 File::Path::mkpath($dir);
-                $self->{batch_size} = 100;
                 $flag = Search::Xapian::DB_CREATE_OR_OPEN;
         }
         $self->{xdb} = Search::Xapian::WritableDatabase->new($dir, $flag);
@@ -160,12 +154,12 @@ sub add_message {
                 }
                 $smsg = PublicInbox::SearchMsg->new($mime);
                 my $doc = $smsg->{doc};
-                $doc->add_term(xpfx('mid') . $mid);
+                $doc->add_term('Q' . $mid);
 
                 my $subj = $smsg->subject;
                 if ($subj ne '') {
                         my $path = $self->subject_path($subj);
-                        $doc->add_term(xpfx('path') . id_compress($path));
+                        $doc->add_term('XPATH' . id_compress($path));
                 }
 
                 add_values($smsg, $bytes, $num);
@@ -332,7 +326,7 @@ sub link_message {
         } else {
                 $tid = $self->next_thread_id;
         }
-        $doc->add_term(xpfx('thread') . $tid);
+        $doc->add_term('G' . $tid);
 }
 
 sub index_blob {
@@ -393,7 +387,16 @@ sub do_cat_mail {
 
 sub index_sync {
         my ($self, $opts) = @_;
-        with_umask($self, sub { $self->_index_sync($opts) });
+        with_umask($self->{'umask'}, sub { $self->_index_sync($opts) });
+}
+
+sub batch_adjust ($$$$) {
+        my ($max, $bytes, $batch_cb, $latest) = @_;
+        $$max -= $bytes;
+        if ($$max <= 0) {
+                $$max = BATCH_BYTES;
+                $batch_cb->($latest, 1);
+        }
 }
 
 sub rlog {
@@ -405,23 +408,21 @@ sub rlog {
         my $git = $self->{git};
         my $latest;
         my $bytes;
-        my $max = $self->{batch_size}; # may be undef
+        my $max = BATCH_BYTES;
         local $/ = "\n";
         my $line;
         while (defined($line = <$log>)) {
                 if ($line =~ /$addmsg/o) {
                         my $blob = $1;
                         my $mime = do_cat_mail($git, $blob, \$bytes) or next;
+                        batch_adjust(\$max, $bytes, $batch_cb, $latest);
                         $add_cb->($self, $mime, $bytes, $blob);
                 } elsif ($line =~ /$delmsg/o) {
                         my $blob = $1;
-                        my $mime = do_cat_mail($git, $blob) or next;
+                        my $mime = do_cat_mail($git, $blob, \$bytes) or next;
+                        batch_adjust(\$max, $bytes, $batch_cb, $latest);
                         $del_cb->($self, $mime);
                 } elsif ($line =~ /^commit ($h40)/o) {
-                        if (defined $max && --$max <= 0) {
-                                $max = $self->{batch_size};
-                                $batch_cb->($latest, 1);
-                        }
                         $latest = $1;
                 }
         }
@@ -542,9 +543,9 @@ sub create_ghost {
 
         my $tid = $self->next_thread_id;
         my $doc = Search::Xapian::Document->new;
-        $doc->add_term(xpfx('mid') . $mid);
-        $doc->add_term(xpfx('thread') . $tid);
-        $doc->add_term(xpfx('type') . 'ghost');
+        $doc->add_term('Q' . $mid);
+        $doc->add_term('G' . $tid);
+        $doc->add_term('T' . 'ghost');
 
         my $smsg = PublicInbox::SearchMsg->wrap($doc, $mid);
         $self->{xdb}->add_document($doc);
@@ -555,75 +556,18 @@ sub create_ghost {
 sub merge_threads {
         my ($self, $winner_tid, $loser_tid) = @_;
         return if $winner_tid == $loser_tid;
-        my ($head, $tail) = $self->find_doc_ids('thread', $loser_tid);
-        my $thread_pfx = xpfx('thread');
+        my ($head, $tail) = $self->find_doc_ids('G' . $loser_tid);
         my $db = $self->{xdb};
 
         for (; $head != $tail; $head->inc) {
                 my $docid = $head->get_docid;
                 my $doc = $db->get_document($docid);
-                $doc->remove_term($thread_pfx . $loser_tid);
-                $doc->add_term($thread_pfx . $winner_tid);
+                $doc->remove_term('G' . $loser_tid);
+                $doc->add_term('G' . $winner_tid);
                 $db->replace_document($docid, $doc);
         }
 }
 
-sub _read_git_config_perm {
-        my ($self) = @_;
-        my @cmd = qw(config core.sharedRepository);
-        my $fh = PublicInbox::Git->new($self->{git_dir})->popen(@cmd);
-        local $/ = "\n";
-        my $perm = <$fh>;
-        chomp $perm if defined $perm;
-        $perm;
-}
-
-sub _git_config_perm {
-        my $self = shift;
-        my $perm = scalar @_ ? $_[0] : _read_git_config_perm($self);
-        return PERM_GROUP if (!defined($perm) || $perm eq '');
-        return PERM_UMASK if ($perm eq 'umask');
-        return PERM_GROUP if ($perm eq 'group');
-        if ($perm =~ /\A(?:all|world|everybody)\z/) {
-                return PERM_EVERYBODY;
-        }
-        return PERM_GROUP if ($perm =~ /\A(?:true|yes|on|1)\z/);
-        return PERM_UMASK if ($perm =~ /\A(?:false|no|off|0)\z/);
-
-        my $i = oct($perm);
-        return PERM_UMASK if ($i == PERM_UMASK);
-        return PERM_GROUP if ($i == OLD_PERM_GROUP);
-        return PERM_EVERYBODY if ($i == OLD_PERM_EVERYBODY);
-
-        if (($i & 0600) != 0600) {
-                die "core.sharedRepository mode invalid: ".
-                    sprintf('%.3o', $i) . "\nOwner must have permissions\n";
-        }
-        ($i & 0666);
-}
-
-sub _umask_for {
-        my ($perm) = @_; # _git_config_perm return value
-        my $rv = $perm;
-        return umask if $rv == 0;
-
-        # set +x bit if +r or +w were set
-        $rv |= 0100 if ($rv & 0600);
-        $rv |= 0010 if ($rv & 0060);
-        $rv |= 0001 if ($rv & 0006);
-        (~$rv & 0777);
-}
-
-sub with_umask {
-        my ($self, $cb) = @_;
-        my $old = umask $self->{umask};
-        my $rv = eval { $cb->() };
-        my $err = $@;
-        umask $old;
-        die $err if $@;
-        $rv;
-}
-
 sub DESTROY {
         # order matters for unlocking
         $_[0]->{xdb} = undef;
diff --git a/lib/PublicInbox/SearchMsg.pm b/lib/PublicInbox/SearchMsg.pm
index b8eee665..a19d45db 100644
--- a/lib/PublicInbox/SearchMsg.pm
+++ b/lib/PublicInbox/SearchMsg.pm
@@ -14,7 +14,7 @@ use PublicInbox::Address;
 sub new {
         my ($class, $mime) = @_;
         my $doc = Search::Xapian::Document->new;
-        $doc->add_term(PublicInbox::Search::xpfx('type') . 'mail');
+        $doc->add_term('T' . 'mail');
 
         bless { type => 'mail', doc => $doc, mime => $mime }, $class;
 }
diff --git a/lib/PublicInbox/Spawn.pm b/lib/PublicInbox/Spawn.pm
index 41b08a33..e543be54 100644
--- a/lib/PublicInbox/Spawn.pm
+++ b/lib/PublicInbox/Spawn.pm
@@ -190,8 +190,6 @@ sub popen_rd {
         my ($cmd, $env, $opts) = @_;
         pipe(my ($r, $w)) or die "pipe: $!\n";
         $opts ||= {};
-        my $blocking = $opts->{Blocking};
-        IO::Handle::blocking($r, $blocking) if defined $blocking;
         $opts->{1} = fileno($w);
         my $pid = spawn($cmd, $env, $opts);
         return unless defined $pid;
diff --git a/script/repobrowse-index b/script/repobrowse-index
new file mode 100755
index 00000000..6e939fd5
--- /dev/null
+++ b/script/repobrowse-index
@@ -0,0 +1,68 @@
+#!/usr/bin/perl -w
+# Copyright (C) 2017 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+# Basic tool to create a Xapian search index for any git repository
+# Usage with libeatmydata <https://www.flamingspork.com/projects/libeatmydata/>
+# highly recommended: eatmydata repobrowse-index GIT_DIR
+use strict;
+use warnings;
+use Getopt::Long qw(:config gnu_getopt no_ignore_case auto_abbrev);
+use Cwd 'abs_path';
+my $usage = "repobrowse-index GIT_DIR";
+
+eval { require PublicInbox::RepoGitSearchIdx };
+if ($@) {
+        print STDERR "Search::Xapian required for $0\n";
+        exit 1;
+}
+
+my $reindex;
+my %opts = ( '--reindex' => \$reindex );
+GetOptions(%opts) or die "bad command-line args\n$usage";
+
+my @dirs;
+sub resolve_git_dir {
+        my ($cd) = @_;
+        my @cmd = qw(git rev-parse --git-dir);
+        my $cmd = join(' ', @cmd);
+        my $pid = open my $fh, '-|';
+        defined $pid or die "forking $cmd failed: $!\n";
+        if ($pid == 0) {
+                if (defined $cd) {
+                        chdir $cd or die "chdir $cd failed: $!\n";
+                }
+                exec @cmd;
+                die "Failed to exec $cmd: $!\n";
+        } else {
+                my $dir = eval {
+                        local $/;
+                        <$fh>;
+                };
+                close $fh or die "error in $cmd: $!\n";
+                chomp $dir;
+                return abs_path($cd) if ($dir eq '.' && defined $cd);
+                abs_path($dir);
+        }
+}
+
+if (@ARGV) {
+        @dirs = map { resolve_git_dir($_) } @ARGV;
+} else {
+        @dirs = (resolve_git_dir());
+}
+
+sub usage { print STDERR "Usage: $usage\n"; exit 1 }
+usage() unless @dirs;
+
+foreach my $dir (@dirs) {
+        index_dir($dir);
+}
+
+sub index_dir {
+        my ($git_dir) = @_;
+        if (!ref $git_dir && ! -d $git_dir) {
+                die "$git_dir does not appear to be a git repository\n";
+        }
+        my $s = PublicInbox::RepoGitSearchIdx->new($git_dir);
+        $s->index_sync({ reindex => $reindex, progress => \*STDERR });
+}
diff --git a/t/config_limiter.t b/t/config_limiter.t
index f0b65281..04f32cbf 100644
--- a/t/config_limiter.t
+++ b/t/config_limiter.t
@@ -4,6 +4,7 @@ use strict;
 use warnings;
 use Test::More;
 use PublicInbox::Config;
+use PublicInbox::Inbox;
 my $cfgpfx = "publicinbox.test";
 {
         my $config = PublicInbox::Config->new({
diff --git a/t/git.t b/t/git.t
index d7b20d0d..e7a3c9ea 100644
--- a/t/git.t
+++ b/t/git.t
@@ -139,4 +139,27 @@ if (1) {
         ok($nl > 1, "qx returned array length of $nl");
 }
 
+{
+        my $git = PublicInbox::Git->new($dir);
+
+        my $err = $git->popen([qw(cat-file blob non-existent)], undef,
+                                { 2 => $git->err_begin });
+        my @out = <$err>;
+        my $close_ret = close $err;
+        my $close_err = $?;
+        is(join('', @out), '', 'no output on stdout on error');
+        isnt($close_err, 0, 'close set $? on bad command');
+        ok(!$close_ret, 'close returned error on bad command');
+        isnt($git->err, '', 'got stderr output');
+
+        $err = $git->popen([qw(tag -l)], undef, { 2 => $git->err_begin });
+        @out = <$err>;
+        $close_ret = close $err;
+        $close_err = $?;
+        is(join('', @out), '', 'no output on stdout on error');
+        ok(!$close_err, 'close clobbered $? on empty output');
+        ok($close_ret, 'close returned error on empty output');
+        is($git->err, '', 'no stderr output');
+}
+
 done_testing();
diff --git a/t/git_async.t b/t/git_async.t
new file mode 100644
index 00000000..ffe2b1a2
--- /dev/null
+++ b/t/git_async.t
@@ -0,0 +1,142 @@
+# Copyright (C) 2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict;
+use warnings;
+use Test::More;
+$SIG{PIPE} = 'IGNORE';
+foreach my $mod (qw(Danga::Socket)) {
+        eval "require $mod";
+        plan skip_all => "$mod missing for git_async.t" if $@;
+}
+use File::Temp qw/tempdir/;
+use Cwd qw/getcwd/;
+my $tmpdir = tempdir('git_async-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+use_ok 'PublicInbox::Git';
+my $dir = "$tmpdir/git.git";
+{
+        is(system(qw(git init -q --bare), $dir), 0, 'created git directory');
+        my @cmd = ('git', "--git-dir=$dir", 'fast-import', '--quiet');
+        my $fi_data = getcwd().'/t/git.fast-import-data';
+        ok(-r $fi_data, "fast-import data readable (or run test at top level)");
+        my $pid = fork;
+        defined $pid or die "fork failed: $!\n";
+        if ($pid == 0) {
+                open STDIN, '<', $fi_data or die "open $fi_data: $!\n";
+                exec @cmd;
+                die "failed exec: ",join(' ', @cmd),": $!\n";
+        }
+        waitpid $pid, 0;
+        is($?, 0, 'fast-import succeeded');
+}
+
+{
+        my $f = 'HEAD:foo.txt';
+        my @args;
+        my $n = 0;
+        my $git = PublicInbox::Git->new($dir);
+        Danga::Socket->SetPostLoopCallback(sub {
+                my ($fdmap) = @_;
+                foreach (values %$fdmap) {
+                        return 1 if ref($_) =~ /::GitAsync/;
+                }
+                0
+        });
+        $git->check_async_ds($f, sub {
+                $n++;
+                @args = @_;
+                $git = undef;
+        });
+        Danga::Socket->EventLoop;
+        my @exp = PublicInbox::Git->new($dir)->check($f);
+        my $exp = [ \@exp ];
+        is_deeply(\@args, $exp, 'matches regular check');
+        is($n, 1, 'callback only called once');
+        $git = PublicInbox::Git->new($dir);
+        $n = 0;
+        my $max = 100;
+        my $missing = 'm';
+        my $m = 0;
+        for my $i (0..$max) {
+                my $k = "HEAD:m$i";
+                $git->check_async_ds($k, sub {
+                        my ($info) = @_;
+                        ++$n;
+                        ++$m if $info->[1] eq 'missing' && $info->[0] eq $k;
+                });
+                if ($git->{async_c}->{wr}->{write_buf_size}) {
+                        diag("async_check capped at $i");
+                        $max = $i;
+                        last;
+                }
+        }
+        is($m, $n, 'everything expected missing is missing');
+        $git->check_async_ds($f, sub { $git = undef });
+        Danga::Socket->EventLoop;
+
+        $git = PublicInbox::Git->new($dir);
+        my $info;
+        my $str = '';
+        my @missing;
+        $git->cat_async_ds('HEAD:miss', sub {
+                my ($miss) = @_;
+                push @missing, $miss;
+        });
+        $git->cat_async_ds($f, sub {
+                my $res = $_[0];
+                if (ref($res) eq 'ARRAY') {
+                        is($info, undef, 'info unset, setting..');
+                        $info = $res;
+                } elsif (ref($res) eq 'SCALAR') {
+                        $str .= $$res;
+                        if (length($str) >= $info->[2]) {
+                                is($info->[2], length($str), 'length match');
+                                $git = undef
+                        }
+                }
+        });
+        Danga::Socket->EventLoop;
+        is_deeply(\@missing, [['HEAD:miss', 'missing']], 'missing cat OK');
+        is($git, undef, 'git undefined');
+        $git = PublicInbox::Git->new($dir);
+        my $sref = $git->cat_file($f);
+        is($str, $$sref, 'matches synchronous version');
+        $git = undef;
+        Danga::Socket->RunTimers;
+}
+
+{
+        my $git = PublicInbox::Git->new($dir);
+        foreach my $s (qw(check_async_compat cat_async_compat)) {
+                my @missing;
+                $git->check_async_compat('HED:miss1ng', sub {
+                        my ($miss) = @_;
+                        push @missing, $miss;
+                });
+                is_deeply(\@missing, [['HED:miss1ng', 'missing']],
+                        "missing $s OK");
+        }
+        my @info;
+        my $str = '';
+        my $eof_seen = 0;
+        $git->cat_async_compat('HEAD:foo.txt', sub {
+                my $ref = $_[0];
+                my $t = ref $ref;
+                if ($t eq 'ARRAY') {
+                        push @info, $ref;
+                } elsif ($t eq 'SCALAR') {
+                        $str .= $$ref;
+                } elsif ($ref == 0) {
+                        $eof_seen++;
+                } else {
+                        fail "fail type: $t";
+                }
+        });
+        is($eof_seen, 1, 'EOF seen once');
+        is_deeply(\@info, [ [ 'bf4f17855632367a160bef055fc8ba4675d10e6b',
+                               'blob', 18 ]], 'info matches compat');
+        is($str, "-----\nhello\nworld\n", 'data matches compat');
+}
+
+done_testing();
+
+1;
diff --git a/t/git_idx.t b/t/git_idx.t
new file mode 100644
index 00000000..65667cfc
--- /dev/null
+++ b/t/git_idx.t
@@ -0,0 +1,24 @@
+# Copyright (C) 2015 all contributors <meta@public-inbox.org>
+# License: AGPLv3 or later (https://www.gnu.org/licenses/agpl-3.0.txt)
+use strict;
+use warnings;
+use Test::More;
+use File::Temp qw/tempdir/;
+use Email::MIME;
+my $tmpdir = tempdir('pi-git-idx-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+my $git_dir = "$tmpdir/a.git";
+use_ok 'PublicInbox::Git';
+use_ok 'PublicInbox::GitIdx';
+my $git = PublicInbox::Git->new($git_dir);
+is(0, system(qw(git init -q --bare), $git_dir), "git init (main)");
+
+$git->qx(qw(config core.sharedRepository 0644));
+is(git_umask_for($git_dir), oct '022', 'umask is correct for 644');
+
+$git->qx(qw(config core.sharedRepository 0664));
+is(git_umask_for($git_dir), oct '002', 'umask is correct for 664');
+
+$git->qx(qw(config core.sharedRepository group));
+is(git_umask_for($git_dir), oct '007', 'umask is correct for "group"');
+
+done_testing();
diff --git a/t/httpd-unix.t b/t/httpd-unix.t
index 4b0f116e..5ebe2f50 100644
--- a/t/httpd-unix.t
+++ b/t/httpd-unix.t
@@ -4,6 +4,7 @@
 use strict;
 use warnings;
 use Test::More;
+use Carp qw(carp);
 
 foreach my $mod (qw(Plack::Util Plack::Builder Danga::Socket
                         HTTP::Date HTTP::Status)) {
@@ -54,7 +55,7 @@ ok(-S $unix, 'UNIX socket was bound by -httpd');
 sub check_sock ($) {
         my ($unix) = @_;
         my $sock = IO::Socket::UNIX->new(Peer => $unix, Type => SOCK_STREAM);
-        warn "E: $! connecting to $unix\n" unless defined $sock;
+        carp "E: $! connecting to $unix\n" unless defined $sock;
         ok($sock, 'client UNIX socket connected');
         ok($sock->write("GET /host-port HTTP/1.0\r\n\r\n"),
                 'wrote req to server');
@@ -95,11 +96,13 @@ SKIP: {
         eval 'require Net::Server::Daemonize';
         skip('Net::Server missing for pid-file/daemonization test', 10) if $@;
 
-        # wait for daemonization
+        # wait for daemonization, PublicInbox::Daemon should bind
+        # listener BEFORE the grandparent exits.
         $spawn_httpd->("-l$unix", '-D', '-P', "$tmpdir/pid");
         my $kpid = $pid;
         $pid = undef;
         is(waitpid($kpid, 0), $kpid, 'existing httpd terminated');
+        ok(-S $unix, 'unix socket exists');
         check_sock($unix);
 
         ok(-f "$tmpdir/pid", 'pid file written');
diff --git a/t/hval.t b/t/hval.t
new file mode 100644
index 00000000..f824752c
--- /dev/null
+++ b/t/hval.t
@@ -0,0 +1,20 @@
+# Copyright (C) 2015 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ (https://www.gnu.org/licenses/agpl-3.0.txt)
+use strict;
+use warnings;
+use Test::More;
+use PublicInbox::Hval qw(to_attr from_attr);
+
+foreach my $s ('Hello/World.pm', 'Zcat', 'hello world.c', 'Eléanor', '$at') {
+        my $attr = to_attr($s);
+        is(from_attr($attr), $s, "$s => $attr => $s round trips");
+}
+
+{
+        my $bad = eval { to_attr('foo//bar') };
+        my $err = $@;
+        ok(!$bad, 'double-slash rejected');
+        like($err, qr/invalid filename:/, 'got exception message');
+}
+
+done_testing();
diff --git a/t/repo_git_search_idx.t b/t/repo_git_search_idx.t
new file mode 100644
index 00000000..934a4e6f
--- /dev/null
+++ b/t/repo_git_search_idx.t
@@ -0,0 +1,28 @@
+# Copyright (C) 2017 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict;
+use warnings;
+use Test::More;
+use File::Temp qw/tempdir/;
+use_ok 'PublicInbox::RepoGitSearchIdx';
+my $test = require './t/repobrowse_common_git.perl';
+my $git_dir = $test->{git_dir};
+my $xdir = "$git_dir/rg";
+my $idx = PublicInbox::RepoGitSearchIdx->new($git_dir, $xdir);
+ok($idx->xdb && -d $xdir, 'Xapian dir created');
+$idx->index_sync;
+
+my $mset = $idx->query('bs:"add header"');
+my $doc;
+$doc = $_->get_document foreach $mset->items;
+ok($doc, 'got document');
+is('cb3b92d257e628b512a2eee0861f8935c594cd12', $doc->get_data, 'DATA OK');
+
+foreach my $q (qw(id:cb3b92d257e628b512a2eee0861f8935c594cd12 id:cb3b92d2*)) {
+        $mset = $idx->query($q);
+        $doc = undef;
+        $doc = $_->get_document foreach $mset->items;
+        ok($doc, "got document for $q");
+}
+
+done_testing();
diff --git a/t/repobrowse.t b/t/repobrowse.t
new file mode 100644
index 00000000..de8a7952
--- /dev/null
+++ b/t/repobrowse.t
@@ -0,0 +1,21 @@
+# Copyright (C) 2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict;
+use warnings;
+
+my $test = require './t/repobrowse_common_git.perl';
+test_psgi($test->{app}, sub {
+        my ($cb) = @_;
+        my $req = 'http://example.com/test.git/tree/dir';
+        my $res = $cb->(GET($req . '/'));
+        is($res->code, 301, 'got 301 with trailing slash');
+        is($res->header('Location'), $req, 'redirected without tslash');
+
+        my $q = '?id=deadbeef';
+
+        $res = $cb->(GET($req . "/$q"));
+        is($res->code, 301, 'got 301 with trailing slash + query string');
+        is($res->header('Location'), $req.$q, 'redirected without tslash');
+});
+
+done_testing();
diff --git a/t/repobrowse_common_git.perl b/t/repobrowse_common_git.perl
new file mode 100644
index 00000000..de61efe6
--- /dev/null
+++ b/t/repobrowse_common_git.perl
@@ -0,0 +1,67 @@
+#!/usr/bin/perl -w
+# Copyright (C) 2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict;
+use warnings;
+use Test::More;
+use File::Temp qw/tempdir/;
+use Cwd qw/getcwd/;
+my @mods = qw(HTTP::Request::Common Plack::Test URI::Escape);
+foreach my $mod (@mods) {
+        eval "require $mod";
+        plan skip_all => "$mod missing for $0" if $@;
+}
+
+sub dechunk ($) {
+        my ($res) = @_;
+        my $s = $res->content;
+        if (lc($res->header('Transfer-Encoding') || '') eq 'chunked') {
+                my $rv = '';
+                while ($s =~ s/\A([a-f0-9]+)\r\n//i) { # no comment support :x
+                        my $n = hex($1) or last;
+                        $rv .= substr($s, 0, $n);
+                        $s = substr($s, $n);
+                        $s =~ s/\A\r\n// or die "broken parsing in $s\n";
+                }
+                $s =~ s/\A\r\n// or die "broken end parsing in $s\n";
+                $s = $rv;
+        }
+        $s;
+}
+
+use_ok $_ foreach @mods;
+my $git_dir = tempdir('repobrowse-XXXXXX', CLEANUP => 1, TMPDIR => 1);
+my $psgi = "examples/repobrowse.psgi";
+my $repobrowse_config = "$git_dir/repobrowse_config";
+my $app;
+ok(-f $psgi, 'psgi example for repobrowse.psgi found');
+{
+        is(system(qw(git init -q --bare), $git_dir), 0, 'created git directory');
+        my @cmd = ('git', "--git-dir=$git_dir", 'fast-import', '--quiet');
+        my $fi_data = getcwd().'/t/git.fast-import-data';
+        ok(-r $fi_data, "fast-import data readable (or run test at top level)");
+        my $pid = fork;
+        defined $pid or die "fork failed: $!\n";
+        if ($pid == 0) {
+                open STDIN, '<', $fi_data or die "open $fi_data: $!\n";
+                exec @cmd;
+                die "failed exec: ",join(' ', @cmd),": $!\n";
+        }
+        waitpid $pid, 0;
+        is($?, 0, 'fast-import succeeded');
+        my $fh;
+        ok((open $fh, '>', $repobrowse_config and
+                print $fh '[repo "test.git"]', "\n",
+                        "\t", "path = $git_dir", "\n" and
+                close $fh), 'created repobrowse config');
+        local $ENV{REPOBROWSE_CONFIG} = $repobrowse_config;
+        ok($app = require $psgi, 'loaded PSGI app');
+}
+
+# return value
+bless {
+        psgi => $psgi,
+        git_dir => $git_dir,
+        app => $app,
+        repobrowse_config => $repobrowse_config,
+}, 'Repobrowse::TestGit';
diff --git a/t/repobrowse_git.t b/t/repobrowse_git.t
new file mode 100644
index 00000000..0ac977f3
--- /dev/null
+++ b/t/repobrowse_git.t
@@ -0,0 +1,11 @@
+# Copyright (C) 2015 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ (https://www.gnu.org/licenses/agpl-3.0.txt)
+use strict;
+use warnings;
+use Test::More;
+use PublicInbox::RepoGit qw(git_unquote);
+
+is("foo\nbar", git_unquote('"foo\\nbar"'), 'unquoted newline');
+is("Eléanor", git_unquote('"El\\303\\251anor"'), 'unquoted octal');
+
+done_testing();
diff --git a/t/repobrowse_git_atom.t b/t/repobrowse_git_atom.t
new file mode 100644
index 00000000..6769bf9f
--- /dev/null
+++ b/t/repobrowse_git_atom.t
@@ -0,0 +1,38 @@
+# Copyright (C) 2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict;
+use warnings;
+my $have_xml_feed = eval { require XML::Feed; 1 };
+my $test = require './t/repobrowse_common_git.perl';
+use Test::More;
+
+test_psgi($test->{app}, sub {
+        my ($cb) = @_;
+        my $req = 'http://example.com/test.git/atom';
+        my $res = $cb->(GET($req));
+        is($res->code, 200, 'got 200');
+        is($res->header('Content-Type'), 'application/atom+xml',
+                'got correct Content-Type');
+        my $body = dechunk($res);
+        SKIP: {
+                skip 'XML::Feed missing', 2 unless $have_xml_feed;
+                my $p = XML::Feed->parse(\$body);
+                is($p->format, "Atom", "parsed atom feed");
+                is(scalar $p->entries, 6, "parsed six entries");
+        }
+        like($body, qr!<pre\s*[^>]+>\* header:\n  add header</pre>!,
+                'body wrapped in <pre>');
+
+        $res = $cb->(GET($req . '/master/foo.txt'));
+        is($res->code, 200, 'got 200');
+        $body = dechunk($res);
+        like($body, qr{\bhref="http://[^/]+/test\.git/}, 'hrefs OK');
+        SKIP: {
+                skip 'XML::Feed missing', 2 unless $have_xml_feed;
+                my $p = XML::Feed->parse(\$body);
+                is($p->format, "Atom", "parsed atom feed");
+                is(scalar $p->entries, 4, "parsed 4 entries");
+        }
+});
+
+done_testing();
diff --git a/t/repobrowse_git_commit.t b/t/repobrowse_git_commit.t
new file mode 100644
index 00000000..f5913023
--- /dev/null
+++ b/t/repobrowse_git_commit.t
@@ -0,0 +1,19 @@
+# Copyright (C) 2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict;
+use warnings;
+
+my $test = require './t/repobrowse_common_git.perl';
+test_psgi($test->{app}, sub {
+        my ($cb) = @_;
+        my $path = '/path/to/something';
+        my $req = 'http://example.com/test.git/commit';
+        my $res;
+
+        $res = $cb->(GET($req));
+        is($res->code, 200, 'got proper 200 response for default');
+        my $body = dechunk($res);
+        like($body, qr!</html>\z!, 'response body finished');
+});
+
+done_testing();
diff --git a/t/repobrowse_git_httpd.t b/t/repobrowse_git_httpd.t
new file mode 100644
index 00000000..3e6c074c
--- /dev/null
+++ b/t/repobrowse_git_httpd.t
@@ -0,0 +1,138 @@
+# Copyright (C) 2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+#
+# Integration test for public-inbox-httpd and (git) repobrowse
+# since we may use some special APIs not available in other servers
+use strict;
+use warnings;
+use Test::More;
+foreach my $mod (qw(Danga::Socket HTTP::Date HTTP::Status
+                Plack::Test::ExternalServer)) {
+        eval "require $mod";
+        plan skip_all => "$mod missing for repobrowse_git_httpd.t" if $@;
+}
+my $test = require './t/repobrowse_common_git.perl';
+{
+        no warnings 'once';
+        $Plack::Test::Impl = 'ExternalServer';
+}
+use File::Temp qw/tempdir/;
+use Cwd qw/getcwd/;
+use IO::Socket;
+use Fcntl qw(F_SETFD);
+use POSIX qw(dup2);
+my $tmpdir = tempdir('repobrowse_git_httpd-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+my $err = "$tmpdir/stderr.log";
+my $out = "$tmpdir/stdout.log";
+my $httpd = 'blib/script/public-inbox-httpd';
+my $psgi = getcwd() . '/' . $test->{psgi};
+my %opts = (
+        LocalAddr => '127.0.0.1',
+        ReuseAddr => 1,
+        Proto => 'tcp',
+        Type => SOCK_STREAM,
+        Listen => 1024,
+);
+my $sock = IO::Socket::INET->new(%opts);
+my $host = $sock->sockhost;
+my $port = $sock->sockport;
+my $uri = "http://$host:$port/";
+my $pid;
+END { kill 'TERM', $pid if defined $pid };
+my $spawn_httpd = sub {
+        $pid = fork;
+        if ($pid == 0) {
+                # pretend to be systemd:
+                dup2(fileno($sock), 3) or die "dup2 failed: $!\n";
+                my $t = IO::Handle->new_from_fd(3, 'r');
+                $t->fcntl(F_SETFD, 0);
+                $ENV{REPOBROWSE_CONFIG} = $test->{repobrowse_config};
+                $ENV{LISTEN_PID} = $$;
+                $ENV{LISTEN_FDS} = 1;
+                exec $httpd, '-W0', $psgi;
+                # exec $httpd, '-W0', "--stdout=$out", "--stderr=$err", $psgi;
+                die "FAIL: $!\n";
+        }
+        ok(defined $pid, 'forked httpd process successfully');
+};
+
+$spawn_httpd->();
+
+{ # git clone tests
+        my $url = $uri . 'test.git';
+        is(system(qw(git clone -q --mirror), $url, "$tmpdir/smart.git"),
+                0, 'smart clone successful');
+        is(system('git', "--git-dir=$tmpdir/smart.git", 'fsck'), 0, 'fsck OK');
+        is(system('git', "--git-dir=$test->{git_dir}",
+                qw(config http.uploadpack 0)), 0, 'disabled smart HTTP');
+        is(system('git', "--git-dir=$test->{git_dir}",
+                qw(update-server-info)), 0, 'enable dumb HTTP');
+        is(system(qw(git clone -q --mirror), $url, "$tmpdir/dumb.git"),
+                0, 'dumb clone successful');
+        is(system('git', "--git-dir=$tmpdir/dumb.git", 'fsck'),
+                0, 'fsck dumb OK');
+}
+
+test_psgi(uri => $uri, client => sub {
+        my ($cb) = @_;
+        my $res = $cb->(GET($uri . 'test.git/info/refs'));
+        is(200, $res->code, 'got info/refs');
+
+        $res = $cb->(GET($uri . 'best.git/info/refs'));
+        is(404, $res->code, 'bad request fails');
+
+        $res = $cb->(GET($uri . 'test.git/patch'));
+        is(200, $res->code, 'got patch');
+        is('text/plain; charset=UTF-8', $res->header('Content-Type'),
+                'got proper content-type with patch');
+
+        # ignore signature from git-format-patch:
+        my ($patch, undef) = split(/\n-- \n/s, $res->content);
+
+        my $cmd = 'format-patch --signature=git -1 -M --stdout HEAD';
+        my ($exp, undef) = split(/\n-- \n/s,
+                `git --git-dir=$test->{git_dir} $cmd`);
+        is($patch, $exp, 'patch content matches expected');
+});
+
+{
+        # allow reading description file
+        my %conn = ( PeerAddr => $host, PeerPort => $port, Proto => 'tcp',
+                Type => SOCK_STREAM);
+        my $conn = IO::Socket::INET->new(%conn);
+        ok($conn, "connected for description check");
+        $conn->write("GET /test.git/description HTTP/1.0\r\n\r\n");
+        ok($conn->read(my $buf, 8192), 'read response');
+        my ($head, $body) = split(/\r\n\r\n/, $buf, 2);
+        like($head, qr!\AHTTP/1\.0 200 !s, 'got 200 response for description');
+
+        $conn = IO::Socket::INET->new(%conn);
+        ok($conn, "connected for range check");
+        $conn->write("GET /test.git/description HTTP/1.0\r\n" .
+                        "Range: bytes=5-\r\n\r\n");
+        ok($conn->read($buf, 8192), 'read partial response');
+        my ($h2, $b2) = split(/\r\n\r\n/, $buf, 2);
+        like($h2, qr!\AHTTP/1\.0 206 !s, 'got 206 response for range');
+        is($b2, substr($body, 5), 'substring matches on 206');
+}
+
+test_psgi(uri => $uri, client => sub {
+        my ($cb) = @_;
+        my $res = $cb->(GET($uri . 'test.git/snapshot/test-master.tar.gz'));
+        is(200, $res->code, 'got gzipped tarball');
+        my $got = "$tmpdir/got.tar.gz";
+        my $exp = "$tmpdir/exp.tar.gz";
+        open my $fh, '>', $got or die "open got.tar.gz: $!";
+        print $fh $res->content;
+        close $fh or die "close failed: $!";
+        $res = undef;
+        my $rc = system('git', "--git-dir=$test->{git_dir}",
+                        qw(archive --prefix=test-master/ --format=tar.gz),
+                        '-o', $exp, 'master');
+        is(0, $rc, 'git-archive generated check correctly');
+        is(0, system('cmp', $got, $exp), 'got expected gzipped tarball');
+
+});
+
+done_testing();
+1;
diff --git a/t/repobrowse_git_log.t b/t/repobrowse_git_log.t
new file mode 100644
index 00000000..2caba546
--- /dev/null
+++ b/t/repobrowse_git_log.t
@@ -0,0 +1,19 @@
+# Copyright (C) 2017 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict;
+use warnings;
+my $test = require './t/repobrowse_common_git.perl';
+use Test::More;
+
+test_psgi($test->{app}, sub {
+        my ($cb) = @_;
+        my $req = 'http://example.com/test.git/log';
+        my $res = $cb->(GET($req));
+        is($res->code, 200, 'got 200');
+        is($res->header('Content-Type'), 'text/html; charset=UTF-8',
+                'got correct Content-Type');
+        my $body = dechunk($res);
+        like($body, qr!</html>!, 'valid HTML :)');
+});
+
+done_testing();
diff --git a/t/repobrowse_git_raw.t b/t/repobrowse_git_raw.t
new file mode 100644
index 00000000..aefe88c7
--- /dev/null
+++ b/t/repobrowse_git_raw.t
@@ -0,0 +1,24 @@
+# Copyright (C) 2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict;
+use warnings;
+my $test = require './t/repobrowse_common_git.perl';
+
+test_psgi($test->{app}, sub {
+        my ($cb) = @_;
+
+        my $req = 'http://example.com/test.git/raw/master/dir';
+        my $res = $cb->(GET($req));
+        is(200, $res->code, 'got 200 response from dir');
+        my $noslash_body = dechunk($res);
+        like($noslash_body, qr{href="dir/dur">dur</a></li>},
+                'path ok w/o slash');
+
+        $req = 'http://example.com/test.git/raw/master/foo.txt';
+        my $blob = $cb->(GET($req));
+        like($blob->header('Content-Type'), qr!\Atext/plain\b!,
+                'got text/plain blob');
+        is($blob->content, "-----\nhello\nworld\n", 'raw blob passed');
+});
+
+done_testing();
diff --git a/t/repobrowse_git_snapshot.t b/t/repobrowse_git_snapshot.t
new file mode 100644
index 00000000..b608459e
--- /dev/null
+++ b/t/repobrowse_git_snapshot.t
@@ -0,0 +1,46 @@
+# Copyright (C) 2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict;
+use warnings;
+my $test = require './t/repobrowse_common_git.perl';
+
+test_psgi($test->{app}, sub {
+        my ($cb) = @_;
+        my ($req, $rc, $res);
+
+        $req = 'http://example.com/test.git/snapshot/test-master.tar.gz';
+        $res = $cb->(GET($req));
+        is($res->code, 200, 'got 200 response from $NAME-master-tar.gz');
+        is($res->header('Content-Type'), 'application/x-gzip',
+                'Content-Type is as expected');
+
+        $req = 'http://example.com/test.git/snapshot/test-nonexistent.tar.gz';
+        $res = $cb->(GET($req));
+        is($res->code, 404, 'got 404 for non-existent');
+
+        $rc = system('git', "--git-dir=$test->{git_dir}", 'tag', '-a',
+                        '-m', 'annotated tag!', 'v1.0.0');
+        is($rc, 0, 'created annotated 1.0.0 tag');
+        $req = 'http://example.com/test.git/snapshot/test-1.0.0.tar.gz';
+        $res = $cb->(GET($req));
+        is($res->code, 200, 'got 200 response for tag');
+        is($res->header('Content-Type'), 'application/x-gzip',
+                'Content-Type is as expected');
+        is($res->header('Content-Disposition'),
+                'inline; filename="test-1.0.0.tar.gz"',
+                'Content-Disposition is as expected');
+
+        $rc = system('git', "--git-dir=$test->{git_dir}", 'tag',
+                        '-m', 'lightweight tag!', 'v2.0.0');
+        is($rc, 0, 'created lightweight 2.0.0 tag');
+        $req = 'http://example.com/test.git/snapshot/test-2.0.0.tar.gz';
+        $res = $cb->(GET($req));
+        is($res->code, 200, 'got 200 response for tag');
+        is($res->header('Content-Type'), 'application/x-gzip',
+                'Content-Type is as expected');
+        is($res->header('Content-Disposition'),
+                'inline; filename="test-2.0.0.tar.gz"',
+                'Content-Disposition is as expected');
+});
+
+done_testing();
diff --git a/t/repobrowse_git_src.t b/t/repobrowse_git_src.t
new file mode 100644
index 00000000..aa341d38
--- /dev/null
+++ b/t/repobrowse_git_src.t
@@ -0,0 +1,38 @@
+# Copyright (C) 2016 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict;
+use warnings;
+my $test = require './t/repobrowse_common_git.perl';
+
+test_psgi($test->{app}, sub {
+        my ($cb) = @_;
+
+        my $req = 'http://example.com/test.git/src/HEAD/dir';
+        my $res = $cb->(GET($req));
+        is(200, $res->code, 'got 200 response from dir');
+        my $noslash_body = dechunk($res);
+        like($noslash_body, qr{href="dir/dur">dur/</a>},
+                'path ok w/o slash');
+
+        $req = 'http://example.com/test.git/src';
+        $res = $cb->(GET($req));
+        is(302, $res->code, 'got 302 response from dir');
+        is("$req/master", $res->header('Location'), 'redirected to tip');
+
+        my $slash = $req . '/';
+        my $r2 = $cb->(GET($slash));
+        is(301, $r2->code, 'got 301 response from dir with slash');
+        is($req, $r2->header('Location'), 'redirected w/o slash');
+
+        $req = 'http://example.com/test.git/src/master/foo.txt';
+        my $blob = $cb->(GET($req));
+        is($blob->header('Content-Type'), 'text/html; charset=UTF-8',
+                'got text/html blob');
+
+        my $body = dechunk($blob);
+        foreach (qw(----- hello world)) {
+                ok(index($body, $_) >= 0, "substring $_ in body");
+        }
+});
+
+done_testing();
diff --git a/t/search.t b/t/search.t
index c9c4e346..000a0385 100644
--- a/t/search.t
+++ b/t/search.t
@@ -27,31 +27,6 @@ my $rw_commit = sub {
 };
 
 {
-        # git repository perms
-        is(PublicInbox::SearchIdx->_git_config_perm(undef),
-           &PublicInbox::SearchIdx::PERM_GROUP,
-           "undefined permission is group");
-        is(PublicInbox::SearchIdx::_umask_for(
-             PublicInbox::SearchIdx->_git_config_perm('0644')),
-           0022, "644 => umask(0022)");
-        is(PublicInbox::SearchIdx::_umask_for(
-             PublicInbox::SearchIdx->_git_config_perm('0600')),
-           0077, "600 => umask(0077)");
-        is(PublicInbox::SearchIdx::_umask_for(
-             PublicInbox::SearchIdx->_git_config_perm('0640')),
-           0027, "640 => umask(0027)");
-        is(PublicInbox::SearchIdx::_umask_for(
-             PublicInbox::SearchIdx->_git_config_perm('group')),
-           0007, 'group => umask(0007)');
-        is(PublicInbox::SearchIdx::_umask_for(
-             PublicInbox::SearchIdx->_git_config_perm('everybody')),
-           0002, 'everybody => umask(0002)');
-        is(PublicInbox::SearchIdx::_umask_for(
-             PublicInbox::SearchIdx->_git_config_perm('umask')),
-           umask, 'umask => existing umask');
-}
-
-{
         my $root = Email::MIME->create(
                 header_str => [
                         Date => 'Fri, 02 Oct 1993 00:00:00 +0000',
@@ -95,15 +70,8 @@ sub filter_mids {
         is($found->mid, 'root@s', 'mid set correctly');
         ok(int($found->thread_id) > 0, 'thread_id is an integer');
 
+        my ($res, @res);
         my @exp = sort qw(root@s last@s);
-        my $res = $ro->query("path:hello_world");
-        my @res = filter_mids($res);
-        is_deeply(\@res, \@exp, 'got expected results for path: match');
-
-        foreach my $p (qw(hello hello_ hello_world2 hello_world_)) {
-                $res = $ro->query("path:$p");
-                is($res->{total}, 0, "path variant `$p' does not match");
-        }
 
         $res = $ro->query('s:(Hello world)');
         @res = filter_mids($res);
diff --git a/t/spawn.t b/t/spawn.t
index 0f756462..2dcdf883 100644
--- a/t/spawn.t
+++ b/t/spawn.t
@@ -81,17 +81,6 @@ use PublicInbox::Spawn qw(which spawn popen_rd);
         isnt($?, 0, '$? set properly: '.$?);
 }
 
-{
-        my ($fh, $pid) = popen_rd([qw(sleep 60)], undef, { Blocking => 0 });
-        ok(defined $pid && $pid > 0, 'returned pid when array requested');
-        is(kill(0, $pid), 1, 'child process is running');
-        ok(!defined(sysread($fh, my $buf, 1)) && $!{EAGAIN},
-           'sysread returned quickly with EAGAIN');
-        is(kill(9, $pid), 1, 'child process killed early');
-        is(waitpid($pid, 0), $pid, 'child process reapable');
-        isnt($?, 0, '$? set properly: '.$?);
-}
-
 done_testing();
 
 1;
diff --git a/t/thread-all.t b/t/thread-all.t
index 8ccf4f8c..b1f9b47c 100644
--- a/t/thread-all.t
+++ b/t/thread-all.t
@@ -1,7 +1,7 @@
 # Copyright (C) 2016 all contributors <meta@public-inbox.org>
 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
 #
-# real-world testing of search threading
+# real-world testing of search threading performance
 use strict;
 use warnings;
 use Test::More;
@@ -16,7 +16,6 @@ plan skip_all => "$pi_dir not initialized for $0" if $@;
 require PublicInbox::View;
 require PublicInbox::SearchThread;
 
-my $pfx = PublicInbox::Search::xpfx('thread');
 my $opts = { limit => 1000000, asc => 1 };
 my $t0 = clock_gettime(CLOCK_MONOTONIC);
 my $elapsed;
@@ -35,4 +34,6 @@ PublicInbox::View::thread_results($msgs);
 $elapsed = clock_gettime(CLOCK_MONOTONIC) - $t0;
 diag "thread_results $elapsed";
 
+ok(1, 'test completed without crashing :)');
+
 done_testing();