gitmote / internal / repo / materialize_test.go · history · branches
10290 bytes · raw
package repo
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/atmin/gitmote/internal/meta"
"github.com/atmin/gitmote/internal/store"
)
// git runs a git command hermetically (no user/system config, fixed identity)
// and returns its trimmed combined output, failing the test on a nonzero exit.
func git(t *testing.T, dir string, args ...string) string {
t.Helper()
return gitStdin(t, dir, nil, args...)
}
func gitStdin(t *testing.T, dir string, stdin []byte, args ...string) string {
t.Helper()
cmd := exec.CommandContext(context.Background(), "git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com",
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
)
if stdin != nil {
cmd.Stdin = bytes.NewReader(stdin)
}
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
return strings.TrimSpace(string(out))
}
// openMeta returns a fresh local-only metadata DB.
func openMeta(t *testing.T) *meta.Metadata {
t.Helper()
m, err := meta.Open(context.Background(), meta.Config{
LocalPath: filepath.Join(t.TempDir(), "meta.sqlite3"),
})
if err != nil {
t.Fatalf("meta.Open: %v", err)
}
t.Cleanup(func() { _ = m.Close() })
return m
}
// seedRepo builds a real repo with two commits, loads its objects into the
// store under name's prefix, records the repo + branch ref in meta, and returns
// (branch, headSHA). When repack is set the source is packed first, so the
// store holds a packfile instead of loose objects.
func seedRepo(t *testing.T, m *meta.Metadata, s store.Store, name string, repack bool) (string, string) {
t.Helper()
ctx := context.Background()
src := t.TempDir()
git(t, src, "init", "-b", "main", ".")
if err := os.WriteFile(filepath.Join(src, "README.md"), []byte("hello\n"), 0o644); err != nil {
t.Fatal(err)
}
git(t, src, "add", "README.md")
git(t, src, "commit", "-m", "first")
if err := os.WriteFile(filepath.Join(src, "README.md"), []byte("hello\nworld\n"), 0o644); err != nil {
t.Fatal(err)
}
git(t, src, "commit", "-am", "second")
if repack {
git(t, src, "repack", "-ad")
}
branch := git(t, src, "rev-parse", "--abbrev-ref", "HEAD")
head := git(t, src, "rev-parse", "HEAD")
// Mirror .git/objects/… into the store under {name}/objects/….
objRoot := filepath.Join(src, ".git", "objects")
err := filepath.WalkDir(objRoot, func(p string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
rel, err := filepath.Rel(objRoot, p)
if err != nil {
return err
}
data, err := os.ReadFile(p)
if err != nil {
return err
}
key := name + "/objects/" + filepath.ToSlash(rel)
return s.Put(ctx, key, bytes.NewReader(data))
})
if err != nil {
t.Fatalf("seed objects: %v", err)
}
r, err := m.CreateRepo(ctx, name, branch)
if err != nil {
t.Fatalf("CreateRepo: %v", err)
}
if err := m.CASRef(ctx, r.ID, "refs/heads/"+branch, meta.ZeroSHA, head); err != nil {
t.Fatalf("CASRef: %v", err)
}
return branch, head
}
func TestMaterialize(t *testing.T) {
for _, tc := range []struct {
name string
repack bool
}{
{"loose", false},
{"packed", true},
} {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
m := openMeta(t)
s := store.NewMem()
const repoName = "dotfiles"
branch, head := seedRepo(t, m, s, repoName, tc.repack)
mz := New(m, s, t.TempDir())
dir, err := mz.Materialize(ctx, repoName)
if err != nil {
t.Fatalf("Materialize: %v", err)
}
assertRepo(t, dir, branch, head)
// Cold rebuild: drop the cache dir and rematerialize — must
// reproduce an equivalent repo at the same path.
if err := os.RemoveAll(dir); err != nil {
t.Fatal(err)
}
dir2, err := mz.Materialize(ctx, repoName)
if err != nil {
t.Fatalf("Materialize (cold rebuild): %v", err)
}
if dir2 != dir {
t.Errorf("rebuild path = %q, want %q", dir2, dir)
}
assertRepo(t, dir2, branch, head)
})
}
}
// assertRepo checks the materialized repo the way the transport layer will:
// the ref resolves, the head object is present and typed, HEAD tracks the
// branch, and fsck finds no corruption or missing objects.
func assertRepo(t *testing.T, dir, branch, head string) {
t.Helper()
if got := git(t, dir, "rev-parse", branch); got != head {
t.Errorf("rev-parse %s = %q, want %q", branch, got, head)
}
check := gitStdin(t, dir, []byte(head+"\n"), "cat-file", "--batch-check")
if !strings.HasPrefix(check, head+" commit ") {
t.Errorf("cat-file --batch-check = %q, want %q commit …", check, head)
}
if got := git(t, dir, "symbolic-ref", "HEAD"); got != "refs/heads/"+branch {
t.Errorf("HEAD = %q, want refs/heads/%s", got, branch)
}
git(t, dir, "fsck", "--strict") // fails the test on any corruption/missing object
}
// spyStore wraps a Store and counts Get calls, so a test can assert the
// advertisement path never touches an object.
type spyStore struct {
store.Store
gets int
}
func (s *spyStore) Get(ctx context.Context, key string) (io.ReadCloser, error) {
s.gets++
return s.Store.Get(ctx, key)
}
// advertise runs `git upload-pack --advertise-refs` against dir — exactly what
// the info/refs GET serves — and returns its output, failing on a nonzero exit.
func advertise(t *testing.T, dir string) string {
t.Helper()
return git(t, "", "upload-pack", "--advertise-refs", dir)
}
func TestMaterializeRefsNoObjects(t *testing.T) {
ctx := context.Background()
m := openMeta(t)
s := &spyStore{Store: store.NewMem()}
const repoName = "dotfiles"
branch, head := seedRepo(t, m, s, repoName, false)
// An annotated tag: meta points the ref at the tag object, which is never
// hydrated. The advertisement must still list it.
tag := "refs/tags/v1"
r, err := m.GetRepo(ctx, repoName)
if err != nil {
t.Fatalf("GetRepo: %v", err)
}
tagSHA := "1234567890123456789012345678901234567890"
if err := m.CASRef(ctx, r.ID, tag, meta.ZeroSHA, tagSHA); err != nil {
t.Fatalf("CASRef tag: %v", err)
}
mz := New(m, s, t.TempDir())
s.gets = 0 // seedRepo did no Gets, but be explicit.
dir, err := mz.MaterializeRefs(ctx, repoName)
if err != nil {
t.Fatalf("MaterializeRefs: %v", err)
}
if s.gets != 0 {
t.Errorf("MaterializeRefs called store.Get %d times, want 0", s.gets)
}
// The advertisement lists exactly the meta refs and their SHAs, with HEAD
// tracking the default branch — all with no objects on disk.
adv := advertise(t, dir)
for _, want := range []string{
head + " refs/heads/" + branch,
tagSHA + " " + tag,
"symref=HEAD:refs/heads/" + branch,
} {
if !strings.Contains(adv, want) {
t.Errorf("advertisement missing %q\n%s", want, adv)
}
}
// MVP omits the annotated-tag peel line (the tag object is absent).
if strings.Contains(adv, tag+"^{}") {
t.Errorf("advertisement unexpectedly carries a peel line for %s\n%s", tag, adv)
}
// No object was ever written to disk.
if entries, err := os.ReadDir(filepath.Join(dir, "objects", "pack")); err != nil {
t.Fatalf("ReadDir objects/pack: %v", err)
} else if len(entries) != 0 {
t.Errorf("objects/pack has %d entries, want 0 (no hydration)", len(entries))
}
}
func TestMaterializeUnknownRepo(t *testing.T) {
mz := New(openMeta(t), store.NewMem(), t.TempDir())
if _, err := mz.Materialize(context.Background(), "nope"); !errors.Is(err, meta.ErrNotFound) {
t.Fatalf("Materialize(unknown) = %v, want meta.ErrNotFound", err)
}
}
// TestRepoDirRejectsUnsafeName guards the cache-root escape defense directly:
// CreateRepo now forbids "/" in a name, so such a name can't reach the
// materializer through meta, but repoDir stays defense-in-depth against any name
// that would resolve outside the cache root.
func TestRepoDirRejectsUnsafeName(t *testing.T) {
mz := New(openMeta(t), store.NewMem(), t.TempDir())
for _, name := range []string{"../evil", "/etc/passwd", "a/../../b", ""} {
if _, err := mz.repoDir(name); err == nil {
t.Errorf("repoDir(%q) succeeded, want unsafe-name error", name)
}
}
}
// TestMaterializeManyObjectsParallel drives hydrateObjects with far more loose
// objects than hydrateConcurrency, so the bounded pool actually queues, and
// asserts the concurrent copies still reproduce a byte-identical, fsck-clean
// repo (no torn objects, no lost keys) at the right head.
func TestMaterializeManyObjectsParallel(t *testing.T) {
ctx := context.Background()
m := openMeta(t)
s := store.NewMem()
const repoName = "many"
// One commit touching 60 distinct files → 60 blobs + trees + a commit, well
// past hydrateConcurrency (16), forcing the pool to block and drain.
src := t.TempDir()
git(t, src, "init", "-b", "main", ".")
for i := 0; i < 60; i++ {
name := fmt.Sprintf("f%02d.txt", i)
if err := os.WriteFile(filepath.Join(src, name), []byte(fmt.Sprintf("content %d\n", i)), 0o644); err != nil {
t.Fatal(err)
}
}
git(t, src, "add", "-A")
git(t, src, "commit", "-m", "many files") // no repack: keep objects loose
branch := git(t, src, "rev-parse", "--abbrev-ref", "HEAD")
head := git(t, src, "rev-parse", "HEAD")
objRoot := filepath.Join(src, ".git", "objects")
loose := 0
err := filepath.WalkDir(objRoot, func(p string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
rel, err := filepath.Rel(objRoot, p)
if err != nil {
return err
}
data, err := os.ReadFile(p)
if err != nil {
return err
}
loose++
return s.Put(ctx, repoName+"/objects/"+filepath.ToSlash(rel), bytes.NewReader(data))
})
if err != nil {
t.Fatalf("seed objects: %v", err)
}
if loose <= hydrateConcurrency {
t.Fatalf("seeded %d objects, need > hydrateConcurrency (%d) to exercise queueing", loose, hydrateConcurrency)
}
r, err := m.CreateRepo(ctx, repoName, branch)
if err != nil {
t.Fatalf("CreateRepo: %v", err)
}
if err := m.CASRef(ctx, r.ID, "refs/heads/"+branch, meta.ZeroSHA, head); err != nil {
t.Fatalf("CASRef: %v", err)
}
dir, err := New(m, s, t.TempDir()).Materialize(ctx, repoName)
if err != nil {
t.Fatalf("Materialize: %v", err)
}
assertRepo(t, dir, branch, head)
}