package main
import (
"bytes"
"context"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
// logLabel names the history in a listing, at a page's top and in the
// crumbs: the command that makes it.
const logLabel = "<(git log)"
// maxLog caps the commits a history page lists.
const maxLog = 500
// gitWait bounds a git run that no request waits on.
const gitWait = 5 * time.Second
// slowGit is how long git may take for a history before its page names
// the command that speeds it up.
var slowGit = 100 * time.Millisecond
// errNoHistory is what a share says in place of git's errors, which name
// paths on the machine.
var errNoHistory = errors.New("No git history here.")
// commit is one entry of a history.
type commit struct {
hash, short string
date time.Time
subject string
body string
}
// git runs git in dir. A repo's .git/config can name programs for git to
// run, so the options that would start one are off, fsmonitor among them
// whichever command runs, and git may not fetch a partial clone's missing
// objects. Pathspecs are literal, and no GIT_
// variable from www's own environment leaks in. git's safe.directory
// check refuses repos that other users own.
//
// In web's cache git may reach a forge, since a repo there is a partial
// clone whose pages fetch what they show: by webScheme alone, without
// credentials, a prompt, hooks or the user's and the system's git config.
// GIT_NO_LAZY_FETCH needs git 2.44; before it protocol.allow=never alone
// keeps other repos from fetching.
func git(ctx context.Context, dir string, args ...string) ([]byte, error) {
return gitProgress(ctx, dir, nil, args...)
}
// gitProgress is git, and hands each line of git's progress to progress,
// when set. git rewrites a progress line in place with \r, so each
// rewrite is a line.
func gitProgress(ctx context.Context, dir string, progress func(string), args ...string) ([]byte, error) {
return gitRun(ctx, dir, nil, progress, args...)
}
// gitIn is git with stdin as its standard input.
func gitIn(ctx context.Context, dir string, stdin io.Reader, args ...string) ([]byte, error) {
return gitRun(ctx, dir, stdin, nil, args...)
}
func gitRun(ctx context.Context, dir string, stdin io.Reader, progress func(string), args ...string) ([]byte, error) {
remote := inCache(dir)
pre := []string{"-c", "protocol.allow=never"}
if remote {
pre = append(pre, "-c", "protocol."+webScheme+".allow=always", "-c", "credential.helper=", "-c", "core.askPass=",
"-c", "core.hooksPath="+os.DevNull)
// git may fetch here, even for a page's blob, so a public server
// checks where it would connect first.
if publicNet.Load() {
if host := gitHost(dir, args); host != "" {
pin, err := reach(ctx, host)
if err != nil {
return nil, err
}
pre = append(pre, pin...)
}
}
}
args = append(append(pre, "-c", "core.fsmonitor=false", "-c", "core.quotePath=false", "--no-pager", "--literal-pathspecs"), args...)
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = dir
cmd.Stdin = stdin
for _, kv := range os.Environ() {
if !strings.HasPrefix(kv, "GIT_") && !strings.HasPrefix(kv, "SSH_ASKPASS=") {
cmd.Env = append(cmd.Env, kv)
}
}
cmd.Env = append(cmd.Env, "GIT_OPTIONAL_LOCKS=0", "GIT_TERMINAL_PROMPT=0")
if remote {
cmd.Env = append(cmd.Env, "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL="+os.DevNull)
} else {
cmd.Env = append(cmd.Env, "GIT_NO_LAZY_FETCH=1")
}
var out, stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if progress != nil {
cmd.Stderr = &progressWriter{buf: &stderr, line: progress}
}
if err := cmd.Run(); err != nil {
if msg := strings.TrimSpace(stderr.String()); msg != "" {
return out.Bytes(), errors.New(msg)
}
return out.Bytes(), fmt.Errorf("git: %w", err)
}
return out.Bytes(), nil
}
// progressWriter splits git's stderr at \r and \n and hands each line to
// line. buf keeps the lines that are not progress, so an error reads as
// git's complaint alone.
type progressWriter struct {
buf *bytes.Buffer
line func(string)
part []byte
}
var progressLine = regexp.MustCompile(`^(remote: )?[A-Z][a-z]+( [a-z]+)*: +[0-9]+%`)
func (p *progressWriter) Write(b []byte) (int, error) {
for _, c := range b {
if c != '\r' && c != '\n' {
p.part = append(p.part, c)
continue
}
if s := strings.TrimSpace(string(p.part)); s != "" {
p.line(s)
if !progressLine.MatchString(s) {
p.buf.WriteString(s + "\n")
}
}
p.part = p.part[:0]
}
return len(b), nil
}
// gitPlace is where git runs for the page at name, and the path it logs.
// Links resolve first, so the history is that of what the page shows.
func (t *tree) gitPlace(name string) (dir, spec string, err error) {
real, err := filepath.EvalSymlinks(filepath.Join(t.dir, filepath.FromSlash(name)))
if err != nil {
return "", "", err
}
info, err := os.Stat(real)
if err != nil {
return "", "", err
}
if info.IsDir() {
return real, ".", nil
}
return filepath.Dir(real), filepath.Base(real), nil
}
// gitLog lists up to n commits that touched name, newest first. A
// directory's history covers everything below it. A share shows it only
// when other users could read the repo, and drops trailers.
func (t *tree) gitLog(ctx context.Context, name string, n int) ([]commit, error) {
dir, spec, err := t.gitPlace(name)
if err != nil {
return nil, t.gitErr(err)
}
if t.shared && !openRepo(ctx, dir) {
return nil, errNoHistory
}
// A history is fixed by the commit HEAD names, so it is kept under
// that commit and a new one misses the cache.
var key string
if r := repoAt(ctx, dir); r.complaint == "" {
key = fmt.Sprintf("log\x00%s\x00%s\x00%s\x00%s\x00%d\x00%t", r.gitDir, r.head(ctx), dir, spec, n, t.shared)
if v, ok := gitCache.get(key); ok {
return v.([]commit), nil
}
}
out, err := git(ctx, dir, "log", "--no-ext-diff", "--no-textconv", "--no-show-signature", "--no-color",
"-z", "--format="+commitFormat, "-n", fmt.Sprint(n), "--", spec)
if err != nil {
return nil, t.gitErr(err)
}
log := t.commits(out)
if key != "" {
size := int64(0)
for _, c := range log {
size += int64(len(c.hash)+len(c.short)+len(c.subject)+len(c.body)) + 64
}
gitCache.put(key, log, size)
}
return log, nil
}
// commitFormat is how git prints a commit for commits to read.
const commitFormat = "%H%n%h%n%aI%n%s%n%b"
// commits reads git's records in commitFormat, each ended by a NUL; a
// share drops their trailers.
func (t *tree) commits(out []byte) []commit {
log := parseCommits(out)
if t.shared {
for i := range log {
log[i].body = dropTrailers(log[i].body)
}
}
return log
}
// parseCommits reads git's records in commitFormat, each ended by a NUL.
func parseCommits(out []byte) []commit {
var log []commit
for _, rec := range strings.Split(string(out), "\x00") {
f := strings.SplitN(rec, "\n", 5)
if len(f) < 4 {
continue
}
c := commit{hash: f[0], short: f[1], subject: f[3]}
c.date, _ = time.Parse(time.RFC3339, f[2])
if len(f) == 5 {
c.body = strings.TrimSpace(f[4])
}
log = append(log, c)
}
return log
}
// lacksPathFilters reports whether the repo name lies in has no
// commit-graph with changed-path filters, the index git reads to skip
// commits that left a path alone. A split graph counts by its newest
// layer, which holds the commits a history reads first.
func (t *tree) lacksPathFilters(ctx context.Context, name string) bool {
dir, _, err := t.gitPlace(name)
if err != nil {
return false
}
r := repoAt(ctx, dir)
if r.complaint != "" {
return false
}
info := filepath.Join(r.common, "objects", "info")
graph := filepath.Join(info, "commit-graph")
if chain, err := os.ReadFile(filepath.Join(info, "commit-graphs", "commit-graph-chain")); err == nil {
lines := strings.Fields(string(chain))
if len(lines) > 0 {
graph = filepath.Join(info, "commit-graphs", "graph-"+lines[len(lines)-1]+".graph")
}
}
return !hasChunk(graph, "BIDX")
}
// hasChunk reports whether the commit-graph file at p holds the chunk id.
// Its header is the signature CGPH, three bytes and the chunk count; the
// table of contents that follows has a 12-byte row per chunk: the id and
// its offset.
func hasChunk(p, id string) bool {
f, err := os.Open(p)
if err != nil {
return false
}
defer f.Close()
head := make([]byte, 8)
if _, err := io.ReadFull(f, head); err != nil || string(head[:4]) != "CGPH" {
return false
}
toc := make([]byte, 12*int(head[6]))
if _, err := io.ReadFull(f, toc); err != nil {
return false
}
for i := 0; i < len(toc); i += 12 {
if string(toc[i:i+4]) == id {
return true
}
}
return false
}
// warm asks git, in the background, what a new tree's first pages need:
// the top lines of its listing and of its README, the page most often
// opened next, and the stardates of its repo, which a large repo takes
// a second to number. Later visits find the answers cached.
func (t *tree) warm() {
ctx, cancel := context.WithTimeout(context.Background(), gitWait)
defer cancel()
start := time.Now()
names := []string{"."}
if entries, err := fs.ReadDir(t.fsys, "."); err == nil {
for _, e := range entries {
if n := strings.ToLower(e.Name()); !e.IsDir() && (n == "readme.md" || n == "readme.markdown") {
names = append(names, e.Name())
break
}
}
}
for _, name := range names {
t.lastCommit(ctx, name)
}
stardatesIn(ctx, t.dir)
logInfo("warmed git answers for %s in %d ms: %s", t.dir, time.Since(start).Milliseconds(), strings.Join(names, ", "))
}
// gitErr is err, or on a share errNoHistory. On a public tree it names
// the cache as "cache", since git's errors name the paths it was given.
func (t *tree) gitErr(err error) error {
if t.shared {
return errNoHistory
}
if t.public && err != nil && cacheRoot != "" && strings.Contains(err.Error(), cacheRoot) {
return errors.New(strings.ReplaceAll(err.Error(), cacheRoot, "cache"))
}
return err
}
// openRepo reports whether other users could read the repo that dir lies
// in: its git directory and HEAD, and the objects and refs it shares with
// its worktrees. A share serves nothing that git run as another user
// could not show.
func openRepo(ctx context.Context, dir string) bool {
out, err := git(ctx, dir, "rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir")
if err != nil {
return false
}
gitDir, common, _ := strings.Cut(strings.TrimSpace(string(out)), "\n")
for _, p := range [][2]string{{gitDir, "HEAD"}, {common, "objects"}, {common, "refs"}} {
if _, _, ok := othersRead(p[0], filepath.Join(p[0], p[1])); !ok {
return false
}
}
return true
}
// dropTrailers cuts a body's trailer block, such as Signed-off-by or
// Claude-Session. As git does, it takes the last paragraph for one when a
// quarter of its lines are trailers, so a cherry-pick note left inside
// does not save the rest. A line that starts with blank space continues
// the trailer above it.
func dropTrailers(body string) string {
paras := strings.Split(body, "\n\n")
trailers, others := 0, 0
for _, line := range strings.Split(paras[len(paras)-1], "\n") {
switch {
case trailer.MatchString(line):
trailers++
case trailers > 0 && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")):
default:
others++
}
}
if trailers == 0 || 3*trailers < others {
return body
}
return strings.TrimSpace(strings.Join(paras[:len(paras)-1], "\n\n"))
}
var trailer = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9-]*[ \t]*:[ \t]*\S`)
// lastCommit is the newest commit that touched name, or nil when none
// did or name lies outside a repo.
func (t *tree) lastCommit(ctx context.Context, name string) *commit {
if t.at != nil {
return nil
}
// Two commits, as the page's top line asks for, share one cache entry.
log, err := t.gitLog(ctx, name, 2)
if err != nil || len(log) == 0 {
return nil
}
return &log[0]
}
// gitHead names the repo name lies in and its HEAD, so a page can watch
// the repo and reload when a commit lands. Outside a repo it returns git's
// complaint, which changes only if a repo appears. A share watches only
// a repo whose history it shows, or a reload would tell when a commit
// lands in one it calls private.
func (t *tree) gitHead(name string) (gitDir, state string) {
dir, _, err := t.gitPlace(name)
if err != nil {
return "", ""
}
ctx, cancel := context.WithTimeout(context.Background(), gitWait)
defer cancel()
if t.shared && !openRepo(ctx, dir) {
return "", ""
}
r := repoAt(ctx, dir)
if r.complaint != "" {
return "", r.complaint
}
return r.gitDir, r.gitDir + "\n" + r.head(ctx)
}
// pageChanged reports whether b, the file of the page at name, differs
// from its last commit. Git's id for b, against the id HEAD keeps for
// the path, answers in microseconds; git status, which took 16 ms on
// node, runs only when the ids differ, since a filter such as autocrlf
// can make equal files differ in bytes.
func (t *tree) pageChanged(ctx context.Context, name string, b []byte) bool {
if id := t.committedBlob(ctx, name); id != "" && blobID(b, len(id)) == id {
return false
}
return t.changed(ctx, name)
}
// committedBlob is the id of the blob HEAD holds for the file at name, or
// "" when git cannot say. It is kept under HEAD's commit.
func (t *tree) committedBlob(ctx context.Context, name string) string {
dir, spec, err := t.gitPlace(name)
if err != nil {
return ""
}
r := repoAt(ctx, dir)
if r.complaint != "" {
return ""
}
key := "blob\x00" + r.gitDir + "\x00" + r.head(ctx) + "\x00" + dir + "\x00" + spec
if v, ok := gitCache.get(key); ok {
return v.(string)
}
out, err := git(ctx, dir, "rev-parse", "--verify", "-q", "HEAD:./"+spec)
id := strings.TrimSpace(string(out))
if err != nil {
id = ""
}
gitCache.put(key, id, int64(len(key)+len(id)+32))
return id
}
// blobID is git's id for a blob holding b: SHA-1 for an id of n = 40 hex
// digits, else SHA-256.
func blobID(b []byte, n int) string {
h := sha256.New()
if n == 40 {
h = sha1.New()
}
fmt.Fprintf(h, "blob %d\x00", len(b))
h.Write(b)
return hex.EncodeToString(h.Sum(nil))
}
// changed reports whether name, a file or everything below a directory,
// differs from its last commit: edited, staged, added or untracked.
func (t *tree) changed(ctx context.Context, name string) bool {
dir, spec, err := t.gitPlace(name)
if err != nil {
return false
}
out, err := git(ctx, dir, "status", "--porcelain", "-z", "--", spec)
return err == nil && len(out) > 0
}