about summary refs log tree commit homepage
path: root/lib/PublicInbox/Config.pm
diff options
context:
space:
mode:
Diffstat (limited to 'lib/PublicInbox/Config.pm')
-rw-r--r--lib/PublicInbox/Config.pm552
1 files changed, 391 insertions, 161 deletions
diff --git a/lib/PublicInbox/Config.pm b/lib/PublicInbox/Config.pm
index c0e2cc57..e1843912 100644
--- a/lib/PublicInbox/Config.pm
+++ b/lib/PublicInbox/Config.pm
@@ -1,4 +1,4 @@
-# Copyright (C) 2014-2020 all contributors <meta@public-inbox.org>
+# Copyright (C) all contributors <meta@public-inbox.org>
 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
 #
 # Used throughout the project for reading configuration
@@ -10,33 +10,38 @@
 package PublicInbox::Config;
 use strict;
 use v5.10.1;
+use parent qw(Exporter);
+our @EXPORT_OK = qw(glob2re rel2abs_collapsed);
 use PublicInbox::Inbox;
-use PublicInbox::Spawn qw(popen_rd);
+use PublicInbox::Git qw(git_exe);
+use PublicInbox::Spawn qw(popen_rd run_qx);
+our $LD_PRELOAD = $ENV{LD_PRELOAD}; # only valid at startup
+our $DEDUPE; # set to {} to dedupe or clear cache
 
 sub _array ($) { ref($_[0]) eq 'ARRAY' ? $_[0] : [ $_[0] ] }
 
 # returns key-value pairs of config directives in a hash
 # if keys may be multi-value, the value is an array ref containing all values
 sub new {
-        my ($class, $file) = @_;
-        $file = default_file() unless defined($file);
-        my $self;
-        if (ref($file) eq 'SCALAR') { # used by some tests
-                open my $fh, '<', $file or die;  # PerlIO::scalar
-                $self = config_fh_parse($fh, "\n", '=');
-        } else {
-                $self = git_config_dump($file);
+        my ($class, $file, $lei) = @_;
+        $file //= default_file();
+        my ($self, $set_dedupe);
+        if (-f $file && $DEDUPE) {
+                $file = rel2abs_collapsed($file);
+                $self = $DEDUPE->{$file} and return $self;
+                $set_dedupe = 1;
         }
-        bless $self, $class;
+        $self = git_config_dump($class, $file, $lei);
+        $self->{-f} = $file;
         # caches
         $self->{-by_addr} = {};
         $self->{-by_list_id} = {};
         $self->{-by_name} = {};
         $self->{-by_newsgroup} = {};
+        $self->{-by_eidx_key} = {};
         $self->{-no_obfuscate} = {};
         $self->{-limiters} = {};
-        $self->{-code_repos} = {}; # nick => PublicInbox::Git object
-        $self->{-cgitrc_unparsed} = $self->{'publicinbox.cgitrc'};
+        $self->{-coderepos} = {}; # nick => PublicInbox::Git object
 
         if (my $no = delete $self->{'publicinbox.noobfuscate'}) {
                 $no = _array($no);
@@ -59,7 +64,7 @@ sub new {
         if (my $css = delete $self->{'publicinbox.css'}) {
                 $self->{css} = _array($css);
         }
-
+        $DEDUPE->{$file} = $self if $set_dedupe;
         $self;
 }
 
@@ -86,37 +91,32 @@ sub lookup_list_id {
 
 sub lookup_name ($$) {
         my ($self, $name) = @_;
-        $self->{-by_name}->{$name} // _fill($self, "publicinbox.$name");
+        $self->{-by_name}->{$name} // _fill_ibx($self, $name);
+}
+
+sub lookup_ei {
+        my ($self, $name) = @_;
+        $self->{-ei_by_name}->{$name} //= _fill_ei($self, $name);
+}
+
+sub lookup_eidx_key {
+        my ($self, $eidx_key) = @_;
+        _lookup_fill($self, '-by_eidx_key', $eidx_key);
 }
 
+# special case for [extindex "all"]
+sub ALL { lookup_ei($_[0], 'all') }
+
 sub each_inbox {
-        my ($self, $cb, $arg) = @_;
+        my ($self, $cb, @arg) = @_;
         # may auto-vivify if config file is non-existent:
         foreach my $section (@{$self->{-section_order}}) {
                 next if $section !~ m!\Apublicinbox\.([^/]+)\z!;
                 my $ibx = lookup_name($self, $1) or next;
-                $cb->($ibx, $arg);
+                $cb->($ibx, @arg);
         }
 }
 
-sub iterate_start {
-        my ($self, $cb, $arg) = @_;
-        my $i = 0;
-        $self->{-iter} = [ \$i, $cb, $arg ];
-}
-
-# for PublicInbox::DS::next_tick, we only call this is if
-# PublicInbox::DS is already loaded
-sub event_step {
-        my ($self) = @_;
-        my ($i, $cb, $arg) = @{$self->{-iter}};
-        my $section = $self->{-section_order}->[$$i++];
-        delete($self->{-iter}) unless defined($section);
-        eval { $cb->($self, $section, $arg) };
-        warn "E: $@ in ${self}::event_step" if $@;
-        PublicInbox::DS::requeue($self) if defined($section);
-}
-
 sub lookup_newsgroup {
         my ($self, $ng) = @_;
         _lookup_fill($self, '-by_newsgroup', lc($ng));
@@ -125,9 +125,9 @@ sub lookup_newsgroup {
 sub limiter {
         my ($self, $name) = @_;
         $self->{-limiters}->{$name} //= do {
-                require PublicInbox::Qspawn;
+                require PublicInbox::Limiter;
                 my $max = $self->{"publicinboxlimiter.$name.max"} || 1;
-                my $limiter = PublicInbox::Qspawn::Limiter->new($max);
+                my $limiter = PublicInbox::Limiter->new($max);
                 $limiter->setup_rlimit($name, $self);
                 $limiter;
         };
@@ -136,27 +136,24 @@ sub limiter {
 sub config_dir { $ENV{PI_DIR} // "$ENV{HOME}/.public-inbox" }
 
 sub default_file {
-        my $f = $ENV{PI_CONFIG};
-        return $f if defined $f;
-        config_dir() . '/config';
+        $ENV{PI_CONFIG} // (config_dir() . '/config');
 }
 
 sub config_fh_parse ($$$) {
         my ($fh, $rs, $fs) = @_;
-        my %rv;
-        my (%section_seen, @section_order);
+        my (%rv, %seen, @section_order, $line, $k, $v, $section, $cur, $i);
         local $/ = $rs;
-        while (defined(my $line = <$fh>)) {
-                chomp $line;
-                my ($k, $v) = split($fs, $line, 2);
-                my ($section) = ($k =~ /\A(\S+)\.[^\.]+\z/);
-                unless (defined $section_seen{$section}) {
-                        $section_seen{$section} = 1;
-                        push @section_order, $section;
-                }
-
-                my $cur = $rv{$k};
-                if (defined $cur) {
+        while (defined($line = <$fh>)) { # perf critical with giant configs
+                $i = index($line, $fs);
+                # $i may be -1 if $fs not found and it's a key-only entry
+                # (meaning boolean true).  Either way the -1 will drop the
+                # $rs either from $k or $v.
+                $k = substr($line, 0, $i);
+                $v = $i >= 0 ? substr($line, $i + 1, -1) : 1;
+                $section = substr($k, 0, rindex($k, '.'));
+                $seen{$section} //= push(@section_order, $section);
+
+                if (defined($cur = $rv{$k})) {
                         if (ref($cur) eq "ARRAY") {
                                 push @$cur, $v;
                         } else {
@@ -171,19 +168,39 @@ sub config_fh_parse ($$$) {
         \%rv;
 }
 
+sub tmp_cmd_opt ($$) {
+        my ($env, $opt) = @_;
+        # quiet global and system gitconfig if supported by installed git,
+        # but normally harmless if too noisy (NOGLOBAL no longer exists)
+        $env->{GIT_CONFIG_NOSYSTEM} = 1;
+        $env->{GIT_CONFIG_GLOBAL} = '/dev/null'; # git v2.32+
+        $opt->{-C} = '/'; # avoid $worktree/.git/config on MOST systems :P
+}
+
 sub git_config_dump {
-        my ($file) = @_;
-        return {} unless -e $file;
-        my @cmd = (qw/git config -z -l --includes/, "--file=$file");
-        my $cmd = join(' ', @cmd);
-        my $fh = popen_rd(\@cmd);
+        my ($class, $file, $lei) = @_;
+        my @opt_c = map { ('-c', $_) } @{$lei->{opt}->{c} // []};
+        $file = undef if !-e $file;
+        # XXX should we set {-f} if !-e $file?
+        return bless {}, $class if (!@opt_c && !defined($file));
+        my %env;
+        my $opt = { 2 => $lei->{2} // 2 };
+        if (@opt_c) {
+                unshift(@opt_c, '-c', "include.path=$file") if defined($file);
+                tmp_cmd_opt(\%env, $opt);
+        }
+        my @cmd = (git_exe, @opt_c, qw(config -z -l --includes));
+        push(@cmd, '-f', $file) if !@opt_c && defined($file);
+        my $fh = popen_rd(\@cmd, \%env, $opt);
         my $rv = config_fh_parse($fh, "\0", "\n");
-        close $fh or die "failed to close ($cmd) pipe: $?";
-        $rv;
+        $fh->close or die "@cmd failed: \$?=$?\n";
+        $rv->{-opt_c} = \@opt_c if @opt_c; # for ->urlmatch
+        $rv->{-f} = $file;
+        bless $rv, $class;
 }
 
-sub valid_inbox_name ($) {
-        my ($name) = @_;
+sub valid_foo_name ($;$) {
+        my ($name, $pfx) = @_;
 
         # Similar rules found in git.git/remote.c::valid_remote_nick
         # and git.git/refs.c::check_refname_component
@@ -191,6 +208,7 @@ sub valid_inbox_name ($) {
         if ($name eq '' || $name =~ /\@\{/ ||
             $name =~ /\.\./ || $name =~ m![/:\?\[\]\^~\s\f[:cntrl:]\*]! ||
             $name =~ /\A\./ || $name =~ /\.\z/) {
+                warn "invalid $pfx name: `$name'\n" if $pfx;
                 return 0;
         }
 
@@ -228,7 +246,6 @@ sub cgit_repo_merge ($$$) {
                         $rel =~ s!/?\.git\z!!;
         }
         $self->{"coderepo.$rel.dir"} //= $path;
-        $self->{"coderepo.$rel.cgiturl"} //= _array($rel);
 }
 
 sub is_git_dir ($) {
@@ -264,10 +281,11 @@ sub scan_tree_coderepo ($$) {
         scan_path_coderepo($self, $path, $path);
 }
 
-sub scan_projects_coderepo ($$$) {
-        my ($self, $list, $path) = @_;
-        open my $fh, '<', $list or do {
-                warn "failed to open cgit projectlist=$list: $!\n";
+sub scan_projects_coderepo ($$) {
+        my ($self, $path) = @_;
+        my $l = $self->{-cgit_project_list} // die 'BUG: no cgit_project_list';
+        open my $fh, '<', $l or do {
+                warn "failed to open cgit project-list=$l: $!\n";
                 return;
         };
         while (<$fh>) {
@@ -276,8 +294,20 @@ sub scan_projects_coderepo ($$$) {
         }
 }
 
+sub apply_cgit_scan_path {
+        my ($self, @paths) = @_;
+        @paths or @paths = @{$self->{-cgit_scan_path}};
+        if (defined($self->{-cgit_project_list})) {
+                for my $p (@paths) { scan_projects_coderepo($self, $p) }
+        } else {
+                for my $p (@paths) { scan_tree_coderepo($self, $p) }
+        }
+}
+
 sub parse_cgitrc {
         my ($self, $cgitrc, $nesting) = @_;
+        $cgitrc //= $self->{'publicinbox.cgitrc'} //
+                        $ENV{CGIT_CONFIG} // return;
         if ($nesting == 0) {
                 # defaults:
                 my %s = map { $_ => 1 } qw(/cgit.css /cgit.png
@@ -317,60 +347,52 @@ sub parse_cgitrc {
                         my ($k, $v) = ($1, $2);
                         $k =~ tr/-/_/;
                         $self->{"-cgit_$k"} = $v;
+                        delete $self->{-cgit_scan_path} if $k eq 'project_list';
                 } elsif (m!\Ascan-path=(.+)\z!) {
-                        if (defined(my $list = $self->{-cgit_project_list})) {
-                                scan_projects_coderepo($self, $list, $1);
-                        } else {
-                                scan_tree_coderepo($self, $1);
-                        }
+                        # this depends on being after project-list in the
+                        # config file, just like cgit.c
+                        push @{$self->{-cgit_scan_path}}, $1;
+                        apply_cgit_scan_path($self, $1);
                 } elsif (m!\A(?:css|favicon|logo|repo\.logo)=(/.+)\z!) {
                         # absolute paths for static files via PublicInbox::Cgit
                         $self->{-cgit_static}->{$1} = 1;
+                } elsif (s!\Asnapshots=\s*!!) {
+                        $self->{'coderepo.snapshots'} = $_;
                 }
         }
         cgit_repo_merge($self, $repo->{dir}, $repo) if $repo;
 }
 
-# parse a code repo
-# Only git is supported at the moment, but SVN and Hg are possibilities
-sub _fill_code_repo {
-        my ($self, $nick) = @_;
-        my $pfx = "coderepo.$nick";
-
-        # TODO: support gitweb and other repository viewers?
-        if (defined(my $cgitrc = delete $self->{-cgitrc_unparsed})) {
-                parse_cgitrc($self, $cgitrc, 0);
-        }
-        my $dir = $self->{"$pfx.dir"}; # aka "GIT_DIR"
-        unless (defined $dir) {
-                warn "$pfx.dir unset\n";
-                return;
-        }
-
-        my $git = PublicInbox::Git->new($dir);
-        foreach my $t (qw(blob commit tree tag)) {
-                $git->{$t.'_url_format'} =
-                                _array($self->{lc("$pfx.${t}UrlFormat")});
+sub valid_dir ($$) {
+        my $dir = get_1($_[0], $_[1]) // return;
+        index($dir, "\n") < 0 ? $dir : do {
+                warn "E: `$_[1]=$dir' must not contain `\\n'\n";
+                undef;
         }
+}
 
+# parse a code repo, only git is supported at the moment
+sub fill_coderepo {
+        my ($self, $nick) = @_;
+        my $pfx = "coderepo.$nick";
+        my $git = PublicInbox::Git->new(valid_dir($self, "$pfx.dir") // return);
         if (defined(my $cgits = $self->{"$pfx.cgiturl"})) {
                 $git->{cgit_url} = $cgits = _array($cgits);
                 $self->{"$pfx.cgiturl"} = $cgits;
-
-                # cgit supports "/blob/?id=%s", but it's only a plain-text
-                # display and requires an unabbreviated id=
-                foreach my $t (qw(blob commit tag)) {
-                        $git->{$t.'_url_format'} //= map {
-                                "$_/$t/?id=%s"
-                        } @$cgits;
-                }
         }
-
+        my %dedupe = ($nick => undef);
+        ($git->{nick}) = keys %dedupe;
         $git;
 }
 
-sub _git_config_bool ($) {
-        my ($val) = @_;
+sub get_all {
+        my ($self, $key) = @_;
+        my $v = $self->{$key} // return;
+        _array($v);
+}
+
+sub git_bool {
+        my ($val) = $_[-1]; # $_[0] may be $self, or $val
         if ($val =~ /\A(?:false|no|off|[\-\+]?(?:0x)?0+)\z/i) {
                 0;
         } elsif ($val =~ /\A(?:true|yes|on|[\-\+]?(?:0x)?[0-9]+)\z/i) {
@@ -380,24 +402,76 @@ sub _git_config_bool ($) {
         }
 }
 
-sub _fill {
-        my ($self, $pfx) = @_;
-        my $ibx = {};
+# abs_path resolves symlinks, so we want to avoid it if rel2abs
+# is sufficient and doesn't leave "/.." or "/../"
+sub rel2abs_collapsed {
+        require File::Spec;
+        my $p = File::Spec->rel2abs(@_);
+        return $p if substr($p, -3, 3) ne '/..' && index($p, '/../') < 0;
+        require Cwd;
+        Cwd::abs_path($p);
+}
 
-        foreach my $k (qw(inboxdir filter newsgroup
-                        watch httpbackendmax
-                        replyto feedmax nntpserver indexlevel)) {
+sub get_1 {
+        my ($self, $key) = @_;
+        my $v = $self->{$key};
+        return $v if !ref($v);
+        warn "W: $key has multiple values, only using `$v->[-1]'\n";
+        $v->[-1];
+}
+
+sub repo_objs {
+        my ($self, $ibxish) = @_;
+        $ibxish->{-repo_objs} // do {
+                my $ibx_coderepos = $ibxish->{coderepo} // return;
+                parse_cgitrc($self, undef, 0);
+                my $coderepos = $self->{-coderepos};
+                my @repo_objs;
+                for my $nick (@$ibx_coderepos) {
+                        my @parts = split(m!/!, $nick);
+                        for (@parts) {
+                                @parts = () unless valid_foo_name($_);
+                        }
+                        unless (@parts) {
+                                warn "invalid coderepo name: `$nick'\n";
+                                next;
+                        }
+                        my $repo = $coderepos->{$nick} //=
+                                                fill_coderepo($self, $nick);
+                        $repo ? push(@repo_objs, $repo) :
+                                warn("coderepo.$nick.dir unset\n");
+                }
+                if (scalar @repo_objs) {
+                        for (@repo_objs) {
+                                push @{$_->{ibx_names}}, $ibxish->{name};
+                        }
+                        $ibxish->{-repo_objs} = \@repo_objs;
+                } else {
+                        delete $ibxish->{coderepo};
+                }
+        }
+}
+
+sub _fill_ibx {
+        my ($self, $name) = @_;
+        my $pfx = "publicinbox.$name";
+        my $ibx = {};
+        for my $k (qw(watch)) {
                 my $v = $self->{"$pfx.$k"};
                 $ibx->{$k} = $v if defined $v;
         }
+        for my $k (qw(filter newsgroup replyto httpbackendmax feedmax
+                        indexlevel indexsequentialshard boost)) {
+                my $v = get_1($self, "$pfx.$k") // next;
+                $ibx->{$k} = $v;
+        }
 
-        # backwards compatibility:
-        $ibx->{inboxdir} //= $self->{"$pfx.mainrepo"};
-
-        foreach my $k (qw(obfuscate)) {
-                my $v = $self->{"$pfx.$k"};
-                defined $v or next;
-                if (defined(my $bval = _git_config_bool($v))) {
+        # "mainrepo" is backwards compatibility:
+        my $dir = $ibx->{inboxdir} = valid_dir($self, "$pfx.inboxdir") //
+                                valid_dir($self, "$pfx.mainrepo") // return;
+        for my $k (qw(obfuscate)) {
+                my $v = $self->{"$pfx.$k"} // next;
+                if (defined(my $bval = git_bool($v))) {
                         $ibx->{$k} = $bval;
                 } else {
                         warn "Ignoring $pfx.$k=$v in config, not boolean\n";
@@ -405,24 +479,18 @@ sub _fill {
         }
         # TODO: more arrays, we should support multi-value for
         # more things to encourage decentralization
-        foreach my $k (qw(address altid nntpmirror coderepo hide listid url
-                        infourl watchheader)) {
-                if (defined(my $v = $self->{"$pfx.$k"})) {
-                        $ibx->{$k} = _array($v);
-                }
-        }
-
-        return unless defined($ibx->{inboxdir});
-        my $name = $pfx;
-        $name =~ s/\Apublicinbox\.//;
-
-        if (!valid_inbox_name($name)) {
-                warn "invalid inbox name: '$name'\n";
-                return;
+        for my $k (qw(address altid nntpmirror imapmirror
+                        coderepo hide listid url
+                        infourl watchheader
+                        nntpserver imapserver pop3server)) {
+                my $v = $self->{"$pfx.$k"} // next;
+                $ibx->{$k} = _array($v);
         }
 
-        $ibx->{name} = $name;
-        $ibx->{-pi_config} = $self;
+        return unless valid_foo_name($name, 'publicinbox');
+        my %dedupe = ($name => undef);
+        ($ibx->{name}) = keys %dedupe; # used as a key everywhere
+        $ibx->{-pi_cfg} = $self;
         $ibx = PublicInbox::Inbox->new($ibx);
         foreach (@{$ibx->{address}}) {
                 my $lc_addr = lc($_);
@@ -430,12 +498,45 @@ sub _fill {
                 $self->{-no_obfuscate}->{$lc_addr} = 1;
         }
         if (my $listids = $ibx->{listid}) {
+                # RFC2919 section 6 stipulates "case insensitive equality"
                 foreach my $list_id (@$listids) {
-                        $self->{-by_list_id}->{$list_id} = $ibx;
+                        $self->{-by_list_id}->{lc($list_id)} = $ibx;
+                }
+        }
+        if (defined(my $ngname = $ibx->{newsgroup})) {
+                if (ref($ngname)) {
+                        delete $ibx->{newsgroup};
+                        warn 'multiple newsgroups not supported: '.
+                                join(', ', @$ngname). "\n";
+                # Newsgroup name needs to be compatible with RFC 3977
+                # wildmat-exact and RFC 3501 (IMAP) ATOM-CHAR.
+                # Leave out a few chars likely to cause problems or conflicts:
+                # '|', '<', '>', ';', '#', '$', '&',
+                } elsif ($ngname =~ m![^A-Za-z0-9/_\.\-\~\@\+\=:]! ||
+                                $ngname eq '') {
+                        delete $ibx->{newsgroup};
+                        warn "newsgroup name invalid: `$ngname'\n";
+                } else {
+                        %dedupe = (lc($ngname) => undef);
+                        my ($lc) = keys %dedupe;
+                        $ibx->{newsgroup} = $lc;
+                        warn <<EOM if $lc ne $ngname;
+W: newsgroup=`$ngname' lowercased to `$lc'
+EOM
+                        # PublicInbox::NNTPD does stricter ->nntp_usable
+                        # checks, keep this lean for startup speed
+                        my $cur = $self->{-by_newsgroup}->{$lc} //= $ibx;
+                        warn <<EOM if $cur != $ibx;
+W: newsgroup=`$lc' is used by both `$cur->{name}' and `$ibx->{name}'
+EOM
                 }
         }
-        if (my $ng = $ibx->{newsgroup}) {
-                $self->{-by_newsgroup}->{$ng} = $ibx;
+        unless (defined $ibx->{newsgroup}) { # for ->eidx_key
+                my $abs = rel2abs_collapsed($dir);
+                if ($abs ne $dir) {
+                        warn "W: `$dir' canonicalized to `$abs'\n";
+                        $ibx->{inboxdir} = $abs;
+                }
         }
         $self->{-by_name}->{$name} = $ibx;
         if ($ibx->{obfuscate}) {
@@ -443,42 +544,171 @@ sub _fill {
                 $ibx->{-no_obfuscate_re} = $self->{-no_obfuscate_re};
                 fill_all($self); # noop to populate -no_obfuscate
         }
+        if (my $es = ALL($self)) {
+                require PublicInbox::Isearch;
+                $ibx->{isrch} = PublicInbox::Isearch->new($ibx, $es);
+        }
+        my $cur = $self->{-by_eidx_key}->{my $ekey = $ibx->eidx_key} //= $ibx;
+        $cur == $ibx or warn
+                "W: `$ekey' used by both `$cur->{name}' and `$ibx->{name}'\n";
+        $ibx;
+}
 
-        if (my $ibx_code_repos = $ibx->{coderepo}) {
-                my $code_repos = $self->{-code_repos};
-                my $repo_objs = $ibx->{-repo_objs} = [];
-                foreach my $nick (@$ibx_code_repos) {
-                        my @parts = split(m!/!, $nick);
-                        my $valid = 0;
-                        $valid += valid_inbox_name($_) foreach (@parts);
-                        $valid == scalar(@parts) or next;
+sub _fill_ei ($$) {
+        my ($self, $name) = @_;
+        eval { require PublicInbox::ExtSearch } or return;
+        my $pfx = "extindex.$name";
+        my $d = valid_dir($self, "$pfx.topdir") // return;
+        -d $d or return;
+        my $es = PublicInbox::ExtSearch->new($d);
+        for my $k (qw(indexlevel indexsequentialshard)) {
+                my $v = get_1($self, "$pfx.$k") // next;
+                $es->{$k} = $v;
+        }
+        for my $k (qw(coderepo hide url infourl)) {
+                my $v = $self->{"$pfx.$k"} // next;
+                $es->{$k} = _array($v);
+        }
+        return unless valid_foo_name($name, 'extindex');
+        $es->{name} = $name;
+        $es;
+}
 
-                        my $repo = $code_repos->{$nick} //=
-                                                _fill_code_repo($self, $nick);
-                        push @$repo_objs, $repo if $repo;
-                }
+sub _fill_csrch ($$) {
+        my ($self, $name) = @_; # "" is a valid name for cindex
+        return if $name ne '' && !valid_foo_name($name, 'cindex');
+        eval { require PublicInbox::CodeSearch } or return;
+        my $pfx = "cindex.$name";
+        my $d = valid_dir($self, "$pfx.topdir") // return;
+        -d $d or return;
+        my $csrch = PublicInbox::CodeSearch->new($d, $self);
+        for my $k (qw(localprefix)) {
+                my $v = $self->{"$pfx.$k"} // next;
+                $csrch->{$k} = _array($v);
+        }
+        $csrch->{name} = $name;
+        $csrch;
+}
+
+sub lookup_cindex ($$) {
+        my ($self, $name) = @_;
+        $self->{-csrch_by_name}->{$name} //= _fill_csrch($self, $name);
+}
+
+sub each_cindex {
+        my ($self, $cb, @arg) = @_;
+        my @csrch = map {
+                lookup_cindex($self, substr($_, length('cindex.'))) // ()
+        } grep(m!\Acindex\.[^\./]*\z!, @{$self->{-section_order}});
+        if (ref($cb) eq 'CODE') {
+                $cb->($_, @arg) for @csrch;
+        } else { # string function
+                $_->$cb(@arg) for @csrch;
         }
+}
 
-        $ibx
+sub config_cmd {
+        my ($self, $env, $opt) = @_;
+        my $f = $self->{-f} // default_file();
+        my @opt_c = @{$self->{-opt_c} // []};
+        my @cmd = (git_exe, @opt_c, 'config');
+        @opt_c ? tmp_cmd_opt($env, $opt) : push(@cmd, '-f', $f);
+        \@cmd;
 }
 
 sub urlmatch {
-        my ($self, $key, $url) = @_;
+        my $self = shift;
+        my @bool = $_[0] eq '--bool' ? (shift) : ();
+        my ($key, $url, $try_git) = @_;
         state $urlmatch_broken; # requires git 1.8.5
         return if $urlmatch_broken;
-        my $file = default_file();
-        my $cmd = [qw/git config -z --includes --get-urlmatch/,
-                "--file=$file", $key, $url ];
-        my $fh = popen_rd($cmd);
-        local $/ = "\0";
-        my $val = <$fh>;
-        if (close($fh)) {
-                chomp($val);
-                $val;
-        } else {
-                $urlmatch_broken = 1 if (($? >> 8) != 1);
-                undef;
+        my (%env, %opt);
+        my $cmd = $self->config_cmd(\%env, \%opt);
+        push @$cmd, @bool, qw(--includes -z --get-urlmatch), $key, $url;
+        my $val = run_qx($cmd, \%env, \%opt);
+        if ($?) {
+                undef $val;
+                if (@bool && ($? >> 8) == 128) { # not boolean
+                } elsif (($? >> 8) != 1) {
+                        $urlmatch_broken = 1;
+                } elsif ($try_git) { # n.b. this takes cwd into account
+                        $val = run_qx([qw(git config), @bool,
+                                        qw(-z --get-urlmatch), $key, $url]);
+                        undef $val if $?;
+                }
+        }
+        $? = 0; # don't influence lei exit status
+        if (defined($val)) {
+                local $/ = "\0";
+                chomp $val;
+                $val = git_bool($val) if @bool;
+        }
+        $val;
+}
+
+sub json {
+        state $json;
+        $json //= do {
+                for my $mod (qw(Cpanel::JSON::XS JSON::MaybeXS JSON JSON::PP)) {
+                        eval "require $mod" or next;
+                        # ->ascii encodes non-ASCII to "\uXXXX"
+                        $json = $mod->new->ascii(1) and last;
+                }
+                $json;
+        };
+}
+
+sub squote_maybe ($) {
+        my ($val) = @_;
+        if ($val =~ m{([^\w@\./,\%\+\-])}) {
+                $val =~ s/(['!])/'\\$1'/g; # '!' for csh
+                return "'$val'";
         }
+        $val;
+}
+
+my %re_map = ( '*' => '[^/]*?', '?' => '[^/]',
+                '/**' => '/.*', '**/' => '.*/', '/**/' => '(?:/.*?/|/)',
+                '[' => '[', ']' => ']', ',' => ',' );
+
+sub glob2re ($) {
+        my ($re) = @_;
+        my $p = '';
+        my $in_bracket = 0;
+        my $qm = 0;
+        my $schema_host_port = '';
+
+        # don't glob URL-looking things that look like IPv6
+        if ($re =~ s!\A([a-z0-9\+]+://\[[a-f0-9\:]+\](?::[0-9]+)?/)!!i) {
+                $schema_host_port = quotemeta $1; # "http://[::1]:1234"
+        }
+        my $changes = ($re =~ s!(/\*\*/|\A\*\*/|/\*\*\z|.)!
+                $re_map{$p eq '\\' ? '' : do {
+                        if ($1 eq '[') { ++$in_bracket }
+                        elsif ($1 eq ']') { --$in_bracket }
+                        elsif ($1 eq ',') { ++$qm } # no change
+                        $p = $1;
+                }} // do {
+                        $p = $1;
+                        ($p eq '-' && $in_bracket) ? $p : (++$qm, "\Q$p")
+                }!sge);
+        # bashism (also supported by curl): {a,b,c} => (a|b|c)
+        $changes += ($re =~ s/([^\\]*)\\\{([^,]*,[^\\]*)\\\}/
+                        (my $in_braces = $2) =~ tr!,!|!;
+                        $1."($in_braces)";
+                        /sge);
+        ($changes - $qm) ? $schema_host_port.$re : undef;
+}
+
+sub get_coderepo {
+        my ($self, $nick) = @_;
+        $self->{-coderepos}->{$nick} // do {
+                defined($self->{-cgit_scan_path}) ? do {
+                        apply_cgit_scan_path($self);
+                        my $cr = fill_coderepo($self, $nick);
+                        $cr ? ($self->{-coderepos}->{$nick} = $cr) : undef;
+                } : undef;
+        };
 }
 
 1;