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 +}