socle

the exec-git read layer: open, resolve, list, stream

commit a890b5724a6000e961841a9bf5d204f0710dd301 · parent 5384757982 · browse this tree
Ximianth, 2026-10-26 19:49:18 UTC

All git access lives in git.go, exec'd with argument vectors (never a
shell) and read-only subcommands only.  Every invocation runs with
optional locks off, user/system git config nulled (a server's output
shouldn't vary with the invoking user's dotfiles), and LC_ALL=C.
The repo's own config still applies: it's part of the repo.

Operations per the design: openRepo/openAll (absent dirs warn and skip),
defaultBranch (total; falls back to the always-servable HEAD),
resolveCommit (the one raw-ref entry point; everything else takes the
sha it returns), refs (annotated tags peeled), entryType, lsTree,
blobSize, catBlob (streaming), commits, and the two upload-pack
streamers for the fetch path (ctx-cancellable, GIT_PROTOCOL passthrough;
the pkt-line service preamble stays an HTTP-layer concern).
matchRepo does longest-prefix URL dispatch over opened repos, with
trailing .git accepted as an alias; the config no-prefix rule is what
makes that unambiguous.

Failures that mean 'no such thing' map to errNotFound per operation
(any clean nonzero exit, for the ops where that's the only meaning);
everything else stays a gitError, which the serve layer treats as
internal.

Tests build a hermetic fixture repo (pinned dates, authors, and config
sources) whose shas are byte-stable and asserted as literals: sha drift
means hermeticity broke.  The upload-pack test runs a real protocol-v0
fetch round trip and checks for NAK + PACK.
diff --git a/git.go b/git.go
new file mode 100644
index 0000000..3b64a04
--- /dev/null
+++ b/git.go
@@ -0,0 +1,360 @@
+package main
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"os/exec"
+	"strconv"
+	"strings"
+	"time"
+)
+
+// This file is the only place socle touches git, and it does so exclusively by exec'ing
+// read-only subcommands with argument vectors (never a shell).  Refs and paths pass
+// through the validators in validate.go before reaching any argv.
+
+// repo is an opened repository: a configured name bound to a resolved git directory.
+// Everything socle serves is read through the git directory, so only committed state
+// at refs is ever visible, never a worktree's uncommitted files.
+type repo struct {
+	name        string // the configured URL name
+	description string // from config; may be empty
+	gitDir      string // absolute path to the git directory (a worktree's .git, or the bare repo itself)
+}
+
+// gitEnv is appended to the inherited environment for every git invocation.
+// Optional locks are off because a read-only server should never contend to write;
+// user and system config are nulled because a server's output shouldn't vary with the
+// invoking user's dotfiles (a stray log.showSignature would corrupt log parsing, say);
+// LC_ALL=C keeps git's messages stable for error classification and tests.
+// The repo's own config still applies, as it should: it's part of the repo.
+var gitEnv = []string{
+	"GIT_OPTIONAL_LOCKS=0",
+	"GIT_CONFIG_GLOBAL=/dev/null",
+	"GIT_CONFIG_SYSTEM=/dev/null",
+	"GIT_CONFIG_NOSYSTEM=1",
+	"GIT_ATTR_NOSYSTEM=1",
+	"LC_ALL=C",
+}
+
+// gitCommand builds (but does not run) a git invocation with socle's standard environment.
+func gitCommand(ctx context.Context, args ...string) *exec.Cmd {
+	cmd := exec.CommandContext(ctx, "git", args...)
+	cmd.Env = append(os.Environ(), gitEnv...)
+	return cmd
+}
+
+// git runs one read-only subcommand against this repo's git directory and returns its stdout.
+// Any failure comes back as a *gitError; operations where a clean nonzero exit simply means
+// "no such thing" translate that via notFoundOnExit.
+func (r *repo) git(args ...string) ([]byte, error) {
+	argv := append([]string{"--git-dir=" + r.gitDir}, args...)
+	cmd := gitCommand(context.Background(), argv...)
+	var stdout, stderr bytes.Buffer
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+	if err := cmd.Run(); err != nil {
+		return nil, newGitError(argv, err, stderr.String())
+	}
+	return stdout.Bytes(), nil
+}
+
+func newGitError(argv []string, err error, stderr string) *gitError {
+	ge := &gitError{args: argv, exitCode: -1, stderr: strings.TrimSpace(stderr)}
+	var xe *exec.ExitError
+	if errors.As(err, &xe) {
+		ge.exitCode = xe.ExitCode()
+	} else {
+		ge.cause = err
+	}
+	return ge
+}
+
+// openRepo binds a configured name to the git directory at its path.
+// Worktree checkouts and bare repos are both accepted; resolving the git directory here
+// is what makes every later read see committed state only.
+// Any failure just means "not servable": openAll downgrades it to a warning and a skip.
+func openRepo(name string, rc repoConfig) (*repo, error) {
+	argv := []string{"-C", rc.Path, "rev-parse", "--absolute-git-dir"}
+	cmd := gitCommand(context.Background(), argv...)
+	var stdout, stderr bytes.Buffer
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+	if err := cmd.Run(); err != nil {
+		return nil, newGitError(argv, err, stderr.String())
+	}
+	return &repo{
+		name:        name,
+		description: rc.Description,
+		gitDir:      strings.TrimSpace(stdout.String()),
+	}, nil
+}
+
+// openAll opens every configured repo, in name order.
+// A path that's absent or not a git repository produces a warning and a skip, not an error:
+// one config may describe repos only some hosts have.
+func openAll(cfg *config) (repos map[string]*repo, warnings []string) {
+	repos = make(map[string]*repo, len(cfg.Repos))
+	for _, name := range sortedNames(cfg.Repos) {
+		r, err := openRepo(name, cfg.Repos[name])
+		if err != nil {
+			warnings = append(warnings, fmt.Sprintf("repo %q: skipped: %v", name, err))
+			continue
+		}
+		repos[name] = r
+	}
+	return repos, warnings
+}
+
+// matchRepo resolves a URL path (with no leading slash) to an opened repo and the remainder
+// of the path after the repo name, matching configured names on segment boundaries.
+// A trailing ".git" on the name in the URL is accepted as an alias for the plain name
+// (people type it; git clients send it).  Returns nil if no configured name matches.
+//
+// The config-load rule that no repo name is a path-prefix of another is what makes this
+// unambiguous: at most one configured name can match any given URL.
+func matchRepo(repos map[string]*repo, urlPath string) (r *repo, rest string) {
+	for i := 0; i <= len(urlPath); i++ {
+		if i < len(urlPath) && urlPath[i] != '/' {
+			continue
+		}
+		// Unconditional suffix-trim is safe: configured names never end in ".git".
+		candidate := strings.TrimSuffix(urlPath[:i], ".git")
+		if r := repos[candidate]; r != nil {
+			return r, strings.TrimPrefix(urlPath[i:], "/")
+		}
+	}
+	return nil, ""
+}
+
+// defaultBranch names the branch HEAD points at, or "HEAD" itself when detached.
+// Total by design: "HEAD" is always a servable fallback ref, so display code can
+// call this without an error path.
+func (r *repo) defaultBranch() string {
+	out, err := r.git("symbolic-ref", "--short", "-q", "HEAD")
+	name := strings.TrimSpace(string(out))
+	if err != nil || name == "" {
+		return "HEAD"
+	}
+	return name
+}
+
+// resolveCommit turns a ref (a branch, tag, "HEAD", or hex sha) into the full sha of the
+// commit it names.  This is the step that takes raw ref input; every other operation here
+// expects the sha it returns.  Unresolvable refs are errNotFound; malformed ones errInvalid.
+func (r *repo) resolveCommit(ref string) (string, error) {
+	if err := validateRef(ref); err != nil {
+		return "", err
+	}
+	out, err := r.git("rev-parse", "--verify", "--end-of-options", ref+"^{commit}")
+	if err != nil {
+		return "", notFoundOnExit(err, fmt.Sprintf("ref %q", ref))
+	}
+	return strings.TrimSpace(string(out)), nil
+}
+
+// refInfo is one branch or tag: its short name and the commit it points at.
+type refInfo struct {
+	name string
+	hash string // full sha of the commit (annotated tags are peeled to their target)
+}
+
+// refs lists the repo's branches and tags, each slice sorted by name
+// (for-each-ref's default order, so the output is deterministic).
+func (r *repo) refs() (branches, tags []refInfo, err error) {
+	out, err := r.git("for-each-ref", "--format=%(refname)%00%(objectname)%00%(*objectname)", "refs/heads", "refs/tags")
+	if err != nil {
+		return nil, nil, err
+	}
+	for _, line := range splitLines(out) {
+		full, hashes, ok := strings.Cut(line, "\x00")
+		hash, peeled, ok2 := strings.Cut(hashes, "\x00")
+		if !ok || !ok2 {
+			return nil, nil, fmt.Errorf("unexpected for-each-ref output %q", line)
+		}
+		if peeled != "" {
+			hash = peeled // an annotated tag: report the commit, not the tag object
+		}
+		switch {
+		case strings.HasPrefix(full, "refs/heads/"):
+			branches = append(branches, refInfo{strings.TrimPrefix(full, "refs/heads/"), hash})
+		case strings.HasPrefix(full, "refs/tags/"):
+			tags = append(tags, refInfo{strings.TrimPrefix(full, "refs/tags/"), hash})
+		}
+	}
+	return branches, tags, nil
+}
+
+// entryType reports what lives at a path within a commit: "tree" for directories
+// (including path "" for the root), "blob" for files, "commit" for submodule pointers.
+// sha is a full commit sha as returned by resolveCommit.
+func (r *repo) entryType(sha, path string) (string, error) {
+	if err := validateTreePath(path); err != nil {
+		return "", err
+	}
+	out, err := r.git("cat-file", "-t", sha+":"+path)
+	if err != nil {
+		return "", notFoundOnExit(err, fmt.Sprintf("path %q at %s", path, sha))
+	}
+	return strings.TrimSpace(string(out)), nil
+}
+
+// treeEntry is one row of a directory listing.
+type treeEntry struct {
+	mode string // git's octal mode string: "100644", "100755", "040000", "120000", "160000"
+	typ  string // "blob", "tree", or "commit" (submodule)
+	hash string
+	size int64  // blob size in bytes; -1 for non-blobs
+	name string // the entry's own name, no path prefix
+}
+
+// lsTree lists the directory at a path within a commit (path "" for the root),
+// in git's canonical tree order.  sha is a full commit sha as returned by resolveCommit.
+// Asking for a path that is a file rather than a directory is errNotFound;
+// callers that don't know which they have ask entryType first.
+func (r *repo) lsTree(sha, path string) ([]treeEntry, error) {
+	if err := validateTreePath(path); err != nil {
+		return nil, err
+	}
+	out, err := r.git("ls-tree", "-z", "-l", sha+":"+path)
+	if err != nil {
+		return nil, notFoundOnExit(err, fmt.Sprintf("directory %q at %s", path, sha))
+	}
+	var entries []treeEntry
+	for _, rec := range strings.Split(string(out), "\x00") {
+		if rec == "" {
+			continue // the trailing NUL
+		}
+		// Each record is "<mode> <type> <hash> <size>\t<name>"; size is space-padded.
+		head, name, ok := strings.Cut(rec, "\t")
+		fields := strings.Fields(head)
+		if !ok || len(fields) != 4 {
+			return nil, fmt.Errorf("unexpected ls-tree record %q", rec)
+		}
+		size := int64(-1)
+		if fields[3] != "-" {
+			size, err = strconv.ParseInt(fields[3], 10, 64)
+			if err != nil {
+				return nil, fmt.Errorf("unexpected ls-tree size in record %q", rec)
+			}
+		}
+		entries = append(entries, treeEntry{mode: fields[0], typ: fields[1], hash: fields[2], size: size, name: name})
+	}
+	return entries, nil
+}
+
+// blobSize reports the size in bytes of the blob at a path within a commit.
+// sha is a full commit sha as returned by resolveCommit.
+func (r *repo) blobSize(sha, path string) (int64, error) {
+	if err := validateTreePath(path); err != nil {
+		return 0, err
+	}
+	out, err := r.git("cat-file", "-s", sha+":"+path)
+	if err != nil {
+		return 0, notFoundOnExit(err, fmt.Sprintf("blob %q at %s", path, sha))
+	}
+	return strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64)
+}
+
+// catBlob streams the raw bytes of the blob at a path within a commit to w.
+// sha is a full commit sha as returned by resolveCommit.
+// The missing-blob case fails before any byte is written; a failure after streaming has
+// begun can leave a partial write behind, which an HTTP caller surfaces naturally as a
+// truncated response against the Content-Length from blobSize.
+func (r *repo) catBlob(sha, path string, w io.Writer) error {
+	if err := validateTreePath(path); err != nil {
+		return err
+	}
+	argv := []string{"--git-dir=" + r.gitDir, "cat-file", "blob", sha + ":" + path}
+	cmd := gitCommand(context.Background(), argv...)
+	cmd.Stdout = w
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+	if err := cmd.Run(); err != nil {
+		return notFoundOnExit(newGitError(argv, err, stderr.String()), fmt.Sprintf("blob %q at %s", path, sha))
+	}
+	return nil
+}
+
+// commitInfo is one entry of a commit log.
+type commitInfo struct {
+	hash    string
+	author  string
+	time    time.Time // author time, in UTC
+	subject string
+}
+
+// commits returns the log reachable from a ref, newest first, capped at limit.
+func (r *repo) commits(ref string, limit int) ([]commitInfo, error) {
+	if err := validateRef(ref); err != nil {
+		return nil, err
+	}
+	out, err := r.git("log", "-n", strconv.Itoa(limit), "--format=%H%x00%an%x00%at%x00%s", "--end-of-options", ref, "--")
+	if err != nil {
+		return nil, notFoundOnExit(err, fmt.Sprintf("ref %q", ref))
+	}
+	var cs []commitInfo
+	for _, line := range splitLines(out) {
+		parts := strings.Split(line, "\x00")
+		if len(parts) != 4 {
+			return nil, fmt.Errorf("unexpected log output %q", line)
+		}
+		unix, err := strconv.ParseInt(parts[2], 10, 64)
+		if err != nil {
+			return nil, fmt.Errorf("unexpected author time in log output %q", line)
+		}
+		cs = append(cs, commitInfo{hash: parts[0], author: parts[1], time: time.Unix(unix, 0).UTC(), subject: parts[3]})
+	}
+	return cs, nil
+}
+
+// uploadPackAdvertise writes the ref advertisement for fetch, as produced by
+// git upload-pack --advertise-refs.  The smart-HTTP "# service=" pkt-line preamble is the
+// HTTP layer's framing concern and is not written here.
+// protocol carries the client's Git-Protocol header value verbatim (empty is fine);
+// passing it through as GIT_PROTOCOL is what lets protocol v2 negotiate.
+func (r *repo) uploadPackAdvertise(ctx context.Context, w io.Writer, protocol string) error {
+	return r.uploadPack(ctx, nil, w, protocol, true)
+}
+
+// uploadPackRPC runs one stateless upload-pack exchange: the request body in, the response out.
+// This is the whole fetch/clone data path, and it is read-only by nature:
+// upload-pack only ever sends objects, and no receive-pack exists anywhere in socle.
+// ctx should be the HTTP request's context: cancellation is what kills the subprocess
+// when a client vanishes mid-transfer.
+func (r *repo) uploadPackRPC(ctx context.Context, body io.Reader, w io.Writer, protocol string) error {
+	return r.uploadPack(ctx, body, w, protocol, false)
+}
+
+func (r *repo) uploadPack(ctx context.Context, body io.Reader, w io.Writer, protocol string, advertise bool) error {
+	argv := []string{"upload-pack", "--stateless-rpc"}
+	if advertise {
+		argv = append(argv, "--advertise-refs")
+	}
+	argv = append(argv, r.gitDir)
+	cmd := gitCommand(ctx, argv...)
+	if protocol != "" {
+		cmd.Env = append(cmd.Env, "GIT_PROTOCOL="+protocol)
+	}
+	cmd.Stdin = body
+	cmd.Stdout = w
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+	if err := cmd.Run(); err != nil {
+		return newGitError(argv, err, stderr.String())
+	}
+	return nil
+}
+
+// splitLines splits command output on newlines, dropping the trailing empty piece.
+func splitLines(out []byte) []string {
+	s := strings.TrimSuffix(string(out), "\n")
+	if s == "" {
+		return nil
+	}
+	return strings.Split(s, "\n")
+}
diff --git a/git_test.go b/git_test.go
new file mode 100644
index 0000000..f54e912
--- /dev/null
+++ b/git_test.go
@@ -0,0 +1,327 @@
+package main
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"path/filepath"
+	"reflect"
+	"testing"
+	"time"
+)
+
+// fixtureTime is the pinned author/committer moment from fixtureEnv.
+var fixtureTime = time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
+
+func TestOpenRepo(t *testing.T) {
+	t.Parallel()
+	t.Run("worktree", func(t *testing.T) {
+		dir := buildFixtureRepo(t)
+		r, err := openRepo("fixturama", repoConfig{Path: dir})
+		if err != nil {
+			t.Fatal(err)
+		}
+		if want := filepath.Join(dir, ".git"); r.gitDir != want {
+			t.Errorf("gitDir = %q, want %q", r.gitDir, want)
+		}
+	})
+	t.Run("bare", func(t *testing.T) {
+		src := buildFixtureRepo(t)
+		parent := t.TempDir()
+		gitFixture(t, parent, "clone", "-q", "--bare", src, "fixture.git")
+		bare := filepath.Join(parent, "fixture.git")
+		r, err := openRepo("fixturama", repoConfig{Path: bare})
+		if err != nil {
+			t.Fatal(err)
+		}
+		if r.gitDir != bare {
+			t.Errorf("gitDir = %q, want %q", r.gitDir, bare)
+		}
+		// The clone carries the same objects, so the same sha serves.
+		if sha, err := r.resolveCommit("main"); err != nil || sha != fixtureShaMain {
+			t.Errorf("resolveCommit(main) = %q, %v; want %s", sha, err, fixtureShaMain)
+		}
+	})
+	t.Run("absent and non-repo paths skip with warnings", func(t *testing.T) {
+		dir := buildFixtureRepo(t)
+		cfg := &config{Repos: map[string]repoConfig{
+			"fixturama": {Path: dir},
+			"ghost":     {Path: filepath.Join(dir, "no-such-dir")},
+			"hollow":    {Path: t.TempDir()},
+		}}
+		repos, warnings := openAll(cfg)
+		if len(repos) != 1 || repos["fixturama"] == nil {
+			t.Errorf("expected exactly fixturama to open, got %v", repos)
+		}
+		if len(warnings) != 2 {
+			t.Errorf("expected 2 warnings, got %q", warnings)
+		}
+	})
+}
+
+func TestResolveCommit(t *testing.T) {
+	t.Parallel()
+	r := fixtureRepo(t)
+	for _, c := range []struct {
+		ref  string
+		sha  string
+		want error // nil means success
+	}{
+		{"main", fixtureShaMain, nil},
+		{"HEAD", fixtureShaMain, nil},
+		{"v1.0", fixtureShaMain, nil}, // the annotated tag peels to the commit
+		{"dev", fixtureShaDev, nil},
+		{fixtureShaRoot, fixtureShaRoot, nil}, // a full sha is a fine ref
+		{"bogus", "", errNotFound},
+		{"main..dev", "", errInvalid},
+		{"-option", "", errInvalid},
+	} {
+		sha, err := r.resolveCommit(c.ref)
+		if c.want == nil {
+			if err != nil || sha != c.sha {
+				t.Errorf("resolveCommit(%q) = %q, %v; want %s", c.ref, sha, err, c.sha)
+			}
+		} else if !errors.Is(err, c.want) {
+			t.Errorf("resolveCommit(%q) error = %v; want %v", c.ref, err, c.want)
+		}
+	}
+}
+
+func TestDefaultBranch(t *testing.T) {
+	t.Parallel()
+	r := fixtureRepo(t)
+	if got := r.defaultBranch(); got != "main" {
+		t.Errorf("defaultBranch = %q, want main", got)
+	}
+}
+
+func TestRefs(t *testing.T) {
+	t.Parallel()
+	r := fixtureRepo(t)
+	branches, tags, err := r.refs()
+	if err != nil {
+		t.Fatal(err)
+	}
+	wantBranches := []refInfo{{"dev", fixtureShaDev}, {"main", fixtureShaMain}}
+	wantTags := []refInfo{{"v1.0", fixtureShaMain}}
+	if !reflect.DeepEqual(branches, wantBranches) {
+		t.Errorf("branches = %v, want %v", branches, wantBranches)
+	}
+	if !reflect.DeepEqual(tags, wantTags) {
+		t.Errorf("tags = %v, want %v", tags, wantTags)
+	}
+}
+
+func TestEntryType(t *testing.T) {
+	t.Parallel()
+	r := fixtureRepo(t)
+	for _, c := range []struct {
+		path string
+		typ  string
+		want error
+	}{
+		{"", "tree", nil},
+		{"README.md", "blob", nil},
+		{"docs", "tree", nil},
+		{"docs/with space.txt", "blob", nil},
+		{"absent", "", errNotFound},
+		{"../escape", "", errInvalid},
+	} {
+		typ, err := r.entryType(fixtureShaMain, c.path)
+		if c.want == nil {
+			if err != nil || typ != c.typ {
+				t.Errorf("entryType(%q) = %q, %v; want %q", c.path, typ, err, c.typ)
+			}
+		} else if !errors.Is(err, c.want) {
+			t.Errorf("entryType(%q) error = %v; want %v", c.path, err, c.want)
+		}
+	}
+}
+
+func TestLsTree(t *testing.T) {
+	t.Parallel()
+	r := fixtureRepo(t)
+	t.Run("root", func(t *testing.T) {
+		entries, err := r.lsTree(fixtureShaMain, "")
+		if err != nil {
+			t.Fatal(err)
+		}
+		// Names and shape only; the blob hashes are already covered by catBlob reading content.
+		want := []struct {
+			name string
+			typ  string
+			mode string
+			size int64
+		}{
+			{"README.md", "blob", "100644", int64(len(fixtureReadme))},
+			{"docs", "tree", "040000", -1},
+			{"src", "tree", "040000", -1},
+		}
+		if len(entries) != len(want) {
+			t.Fatalf("got %d entries (%v), want %d", len(entries), entries, len(want))
+		}
+		for i, w := range want {
+			e := entries[i]
+			if e.name != w.name || e.typ != w.typ || e.mode != w.mode || e.size != w.size {
+				t.Errorf("entry %d = %+v, want %+v", i, e, w)
+			}
+		}
+	})
+	t.Run("subdir", func(t *testing.T) {
+		entries, err := r.lsTree(fixtureShaMain, "docs")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if len(entries) != 2 || entries[0].name != "notes.md" || entries[1].name != "with space.txt" {
+			t.Errorf("docs entries = %+v", entries)
+		}
+	})
+	t.Run("missing dir", func(t *testing.T) {
+		if _, err := r.lsTree(fixtureShaMain, "absent"); !errors.Is(err, errNotFound) {
+			t.Errorf("error = %v, want errNotFound", err)
+		}
+	})
+	t.Run("file is not a dir", func(t *testing.T) {
+		if _, err := r.lsTree(fixtureShaMain, "README.md"); !errors.Is(err, errNotFound) {
+			t.Errorf("error = %v, want errNotFound", err)
+		}
+	})
+}
+
+func TestBlobs(t *testing.T) {
+	t.Parallel()
+	r := fixtureRepo(t)
+	size, err := r.blobSize(fixtureShaMain, "README.md")
+	if err != nil || size != int64(len(fixtureReadme)) {
+		t.Errorf("blobSize = %d, %v; want %d", size, err, len(fixtureReadme))
+	}
+	var buf bytes.Buffer
+	if err := r.catBlob(fixtureShaMain, "README.md", &buf); err != nil {
+		t.Fatal(err)
+	}
+	if buf.String() != fixtureReadme {
+		t.Errorf("catBlob = %q, want %q", buf.String(), fixtureReadme)
+	}
+	// The dev branch rewrote the README: the same path at another sha is other bytes.
+	buf.Reset()
+	if err := r.catBlob(fixtureShaDev, "README.md", &buf); err != nil {
+		t.Fatal(err)
+	}
+	if buf.String() == fixtureReadme {
+		t.Error("catBlob at dev's sha should differ from main's README")
+	}
+	if err := r.catBlob(fixtureShaMain, "absent", &buf); !errors.Is(err, errNotFound) {
+		t.Errorf("catBlob(absent) error = %v, want errNotFound", err)
+	}
+}
+
+func TestCommits(t *testing.T) {
+	t.Parallel()
+	r := fixtureRepo(t)
+	got, err := r.commits("main", 100)
+	if err != nil {
+		t.Fatal(err)
+	}
+	want := []commitInfo{
+		{fixtureShaMain, "Ada Fixture", fixtureTime, "add docs and src"},
+		{fixtureShaRoot, "Ada Fixture", fixtureTime, "inaugurate with a readme"},
+	}
+	if !reflect.DeepEqual(got, want) {
+		t.Errorf("commits(main) = %v, want %v", got, want)
+	}
+	if got, err := r.commits("dev", 100); err != nil || len(got) != 3 {
+		t.Errorf("commits(dev) = %d commits, %v; want 3", len(got), err)
+	}
+	if got, err := r.commits("main", 1); err != nil || len(got) != 1 {
+		t.Errorf("commits(main, 1) = %d commits, %v; want 1", len(got), err)
+	}
+	if _, err := r.commits("bogus", 1); !errors.Is(err, errNotFound) {
+		t.Errorf("commits(bogus) error = %v, want errNotFound", err)
+	}
+}
+
+func TestMatchRepo(t *testing.T) {
+	t.Parallel()
+	repos := map[string]*repo{
+		"parnass":           {name: "parnass"},
+		"work/infra/deploy": {name: "work/infra/deploy"},
+	}
+	for _, c := range []struct {
+		urlPath string
+		name    string // "" means no match expected
+		rest    string
+	}{
+		{"parnass", "parnass", ""},
+		{"parnass/", "parnass", ""},
+		{"parnass/tree/main/docs", "parnass", "tree/main/docs"},
+		{"parnass.git", "parnass", ""},
+		{"parnass.git/info/refs", "parnass", "info/refs"},
+		{"work/infra/deploy/log/main", "work/infra/deploy", "log/main"},
+		{"work/infra", "", ""},
+		{"parn", "", ""},
+		{"parnassus/tree", "", ""},
+		{"", "", ""},
+	} {
+		r, rest := matchRepo(repos, c.urlPath)
+		switch {
+		case c.name == "" && r != nil:
+			t.Errorf("matchRepo(%q) = %q, want no match", c.urlPath, r.name)
+		case c.name != "" && (r == nil || r.name != c.name || rest != c.rest):
+			t.Errorf("matchRepo(%q) = %v, %q; want %q, %q", c.urlPath, r, rest, c.name, c.rest)
+		}
+	}
+}
+
+func TestUploadPack(t *testing.T) {
+	t.Parallel()
+	r := fixtureRepo(t)
+	ctx := context.Background()
+	t.Run("advertise v0", func(t *testing.T) {
+		var buf bytes.Buffer
+		if err := r.uploadPackAdvertise(ctx, &buf, ""); err != nil {
+			t.Fatal(err)
+		}
+		for _, want := range []string{fixtureShaMain, "refs/heads/main", "refs/tags/v1.0"} {
+			if !bytes.Contains(buf.Bytes(), []byte(want)) {
+				t.Errorf("advertisement lacks %q:\n%s", want, buf.String())
+			}
+		}
+	})
+	t.Run("advertise v2", func(t *testing.T) {
+		var buf bytes.Buffer
+		if err := r.uploadPackAdvertise(ctx, &buf, "version=2"); err != nil {
+			t.Fatal(err)
+		}
+		if !bytes.Contains(buf.Bytes(), []byte("version 2")) {
+			t.Errorf("expected a v2 capability advertisement:\n%s", buf.String())
+		}
+	})
+	t.Run("rpc fetch", func(t *testing.T) {
+		// A minimal protocol-v0 fetch request: want the tip, no haves, done.
+		// The reply should be a NAK followed by a packfile.
+		var req bytes.Buffer
+		pktline(&req, "want "+fixtureShaMain+"\n")
+		req.WriteString("0000")
+		pktline(&req, "done\n")
+		var resp bytes.Buffer
+		if err := r.uploadPackRPC(ctx, &req, &resp, ""); err != nil {
+			t.Fatal(err)
+		}
+		if !bytes.Contains(resp.Bytes(), []byte("NAK")) || !bytes.Contains(resp.Bytes(), []byte("PACK")) {
+			t.Errorf("expected NAK and PACK in the response, got %d bytes starting %q", resp.Len(), firstBytes(resp.Bytes(), 40))
+		}
+	})
+}
+
+// pktline writes one git pkt-line: a 4-hex-digit length prefix (including itself), then the payload.
+func pktline(buf *bytes.Buffer, payload string) {
+	fmt.Fprintf(buf, "%04x%s", len(payload)+4, payload)
+}
+
+func firstBytes(b []byte, n int) []byte {
+	if len(b) < n {
+		return b
+	}
+	return b[:n]
+}
diff --git a/gitfixture_test.go b/gitfixture_test.go
new file mode 100644
index 0000000..228720e
--- /dev/null
+++ b/gitfixture_test.go
@@ -0,0 +1,100 @@
+package main
+
+import (
+	"os"
+	"os/exec"
+	"path/filepath"
+	"testing"
+)
+
+// fixtureEnv pins every input that could make fixture repo shas vary between runs or hosts:
+// user and system git config are nulled, and authorship names, emails, and dates are fixed.
+// With these pinned, fixture shas are byte-stable, so tests assert them as literals
+// (the fixtureSha* consts); any drift in those assertions means the hermeticity broke.
+var fixtureEnv = []string{
+	"GIT_CONFIG_GLOBAL=/dev/null",
+	"GIT_CONFIG_SYSTEM=/dev/null",
+	"GIT_CONFIG_NOSYSTEM=1",
+	"GIT_ATTR_NOSYSTEM=1",
+	"GIT_AUTHOR_NAME=Ada Fixture",
+	"GIT_AUTHOR_EMAIL=ada@fixture.example",
+	"GIT_AUTHOR_DATE=2026-01-02T03:04:05Z",
+	"GIT_COMMITTER_NAME=Ada Fixture",
+	"GIT_COMMITTER_EMAIL=ada@fixture.example",
+	"GIT_COMMITTER_DATE=2026-01-02T03:04:05Z",
+}
+
+// The shas of the fixture repo built by buildFixtureRepo, byte-stable by construction.
+const (
+	fixtureShaMain = "9db23702e9ae3e8317b5dba562b0f3bd1cb25e7f" // tip of main (the second commit); also what v1.0 peels to
+	fixtureShaRoot = "64ac67a35d36079e167cb8f929fd57f859c8b5d1" // the first commit, parent of main
+	fixtureShaDev  = "4512e6cc66983db33c062ce3f4baa344422fc1a6" // tip of the dev branch
+)
+
+// fixtureReadme is the README content at the tip of main (dev's tip rewrites it).
+const fixtureReadme = "# Fixturama\n\nA repo that exists to be looked at.\n"
+
+// gitFixture runs one git command while building a fixture, failing the test on any problem.
+func gitFixture(t *testing.T, dir string, args ...string) {
+	t.Helper()
+	cmd := exec.Command("git", args...)
+	cmd.Dir = dir
+	cmd.Env = append(os.Environ(), fixtureEnv...)
+	if out, err := cmd.CombinedOutput(); err != nil {
+		t.Fatalf("fixture git %v: %v\n%s", args, err, out)
+	}
+}
+
+// writeFixtureFile writes one file into a fixture worktree, creating parent dirs as needed.
+func writeFixtureFile(t *testing.T, dir, path, content string) {
+	t.Helper()
+	abs := filepath.Join(dir, path)
+	if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
+		t.Fatal(err)
+	}
+}
+
+// buildFixtureRepo creates the standard hermetic fixture repo and returns its worktree path.
+//
+// History: two commits on main (a README, then docs/ and src/), an annotated tag v1.0
+// on main's tip, and a dev branch one commit ahead of main (rewriting the README).
+// HEAD is left on main.  A filename with a space is included deliberately:
+// path handling has to survive it.
+func buildFixtureRepo(t *testing.T) string {
+	t.Helper()
+	dir := t.TempDir()
+	gitFixture(t, dir, "init", "-q", "-b", "main", "--object-format=sha1", ".")
+
+	writeFixtureFile(t, dir, "README.md", fixtureReadme)
+	gitFixture(t, dir, "add", "-A")
+	gitFixture(t, dir, "commit", "-q", "-m", "inaugurate with a readme")
+
+	writeFixtureFile(t, dir, "docs/notes.md", "Notes live here.\n")
+	writeFixtureFile(t, dir, "docs/with space.txt", "even this filename works\n")
+	writeFixtureFile(t, dir, "src/hello.txt", "hello from the fixture\n")
+	gitFixture(t, dir, "add", "-A")
+	gitFixture(t, dir, "commit", "-q", "-m", "add docs and src")
+	gitFixture(t, dir, "tag", "-a", "v1.0", "-m", "the first showing")
+
+	gitFixture(t, dir, "checkout", "-q", "-b", "dev")
+	writeFixtureFile(t, dir, "README.md", "# Fixturama\n\nNow with more verve.\n")
+	gitFixture(t, dir, "add", "-A")
+	gitFixture(t, dir, "commit", "-q", "-m", "sharpen the readme")
+	gitFixture(t, dir, "checkout", "-q", "main")
+
+	return dir
+}
+
+// fixtureRepo builds the fixture and opens it, the common prelude of the read-op tests.
+func fixtureRepo(t *testing.T) *repo {
+	t.Helper()
+	dir := buildFixtureRepo(t)
+	r, err := openRepo("fixturama", repoConfig{Path: dir, Description: "the fixture"})
+	if err != nil {
+		t.Fatalf("opening fixture: %v", err)
+	}
+	return r
+}