package gitrepo import ( "os" "path/filepath" "sync" "testing" "time" "gitweb/internal/store" ) // dirCount returns the number of children under the cache root. func dirCount(t *testing.T, root string) int { t.Helper() children, err := os.ReadDir(root) if err != nil { t.Fatal(err) } return len(children) } // twoRepos builds two distinct bare repos in one LocalStore and returns the // store and their repo paths. func twoRepos(t *testing.T) (st store.Store, pathA, pathB string) { t.Helper() root := t.TempDir() pathA = makeBareRepo(t, root, "u@d.com", "a.git") pathB = makeBareRepo(t, root, "u@d.com", "b.git") return store.NewLocalStore(root), pathA, pathB } // repoBytes returns the materialized size of repoPath (sum of manifest sizes). func repoBytes(t *testing.T, st store.Store, repoPath string) int64 { t.Helper() c := newTestCache(t, st, CacheOptions{}) _, release, err := c.Get(repoPath) if err != nil { t.Fatal(err) } defer release() c.mu.Lock() defer c.mu.Unlock() return c.total } func TestCacheByteEviction(t *testing.T) { st, pathA, pathB := twoRepos(t) sizeA := repoBytes(t, st, pathA) // Budget fits one repo but not two: getting B must evict A. dir := t.TempDir() c := newTestCache(t, st, CacheOptions{Dir: dir, BudgetBytes: sizeA + sizeA/2}) rA, releaseA, err := c.Get(pathA) if err != nil { t.Fatal(err) } releaseA() if got := dirCount(t, dir); got != 1 { t.Fatalf("after Get A: %d dirs, want 1", got) } _, releaseB, err := c.Get(pathB) if err != nil { t.Fatal(err) } releaseB() if got := dirCount(t, dir); got != 1 { t.Fatalf("after Get B evicted A: %d dirs, want 1", got) } // A re-materializes on demand as a fresh entry. rA2, releaseA2, err := c.Get(pathA) if err != nil { t.Fatal(err) } defer releaseA2() if rA2 == rA { t.Fatal("expected evicted repo to re-materialize (different pointer)") } } func TestCacheHoldAcrossEviction(t *testing.T) { st, pathA, pathB := twoRepos(t) sizeA := repoBytes(t, st, pathA) dir := t.TempDir() c := newTestCache(t, st, CacheOptions{Dir: dir, BudgetBytes: sizeA + sizeA/2}) rA, releaseA, err := c.Get(pathA) if err != nil { t.Fatal(err) } // A is still held when B evicts it: its dir must survive. _, releaseB, err := c.Get(pathB) if err != nil { t.Fatal(err) } defer releaseB() if got := dirCount(t, dir); got != 2 { t.Fatalf("evicted-but-held entry deleted early: %d dirs, want 2", got) } // The held repo still reads from its (retired) backing dir. if _, err := rA.Head(); err != nil { t.Fatalf("held repo unreadable after eviction: %v", err) } releaseA() if got := dirCount(t, dir); got != 1 { t.Fatalf("after release: %d dirs, want 1", got) } } func TestCacheInvalidateDuringHold(t *testing.T) { root := t.TempDir() repoPath := makeBareRepo(t, root, "u@d.com", "demo.git") st := store.NewLocalStore(root) dir := t.TempDir() c := newTestCache(t, st, CacheOptions{Dir: dir}) r1, release1, err := c.Get(repoPath) if err != nil { t.Fatal(err) } c.Invalidate(repoPath) if got := dirCount(t, dir); got != 1 { t.Fatalf("invalidated-but-held entry deleted early: %d dirs, want 1", got) } if _, err := r1.Head(); err != nil { t.Fatalf("held repo unreadable after Invalidate: %v", err) } // A new Get re-materializes into a fresh dir. r2, release2, err := c.Get(repoPath) if err != nil { t.Fatal(err) } defer release2() if r2 == r1 { t.Fatal("expected fresh repo after Invalidate") } if got := dirCount(t, dir); got != 2 { t.Fatalf("old + new dirs: %d, want 2", got) } release1() if got := dirCount(t, dir); got != 1 { t.Fatalf("old dir survives its last release: %d dirs, want 1", got) } } func TestCacheConcurrentGet(t *testing.T) { root := t.TempDir() repoPath := makeBareRepo(t, root, "u@d.com", "demo.git") st := store.NewLocalStore(root) dir := t.TempDir() c := newTestCache(t, st, CacheOptions{Dir: dir}) const n = 8 var wg sync.WaitGroup errs := make(chan error, n) for range n { wg.Add(1) go func() { defer wg.Done() repo, release, err := c.Get(repoPath) if err != nil { errs <- err return } defer release() if _, err := repo.Head(); err != nil { errs <- err } }() } wg.Wait() close(errs) for err := range errs { t.Fatal(err) } // Race losers cleaned up after themselves: one dir remains. if got := dirCount(t, dir); got != 1 { t.Fatalf("%d dirs after concurrent Gets, want 1", got) } // And it is a usable cache hit. _, release, err := c.Get(repoPath) if err != nil { t.Fatal(err) } release() } func TestCacheStaleReleaseDeletesOldDir(t *testing.T) { root := t.TempDir() repoPath := makeBareRepo(t, root, "u@d.com", "demo.git") st := store.NewLocalStore(root) dir := t.TempDir() c := newTestCache(t, st, CacheOptions{Dir: dir}) _, release1, err := c.Get(repoPath) if err != nil { t.Fatal(err) } release1() // Bump the repo's mtime so LocalStore.Seq changes; the stale entry (now // unreferenced) is retired and deleted during the next Get. later := time.Now().Add(2 * time.Second) if err := os.Chtimes(filepath.Join(root, repoPath, "HEAD"), later, later); err != nil { t.Fatal(err) } _, release2, err := c.Get(repoPath) if err != nil { t.Fatal(err) } defer release2() if got := dirCount(t, dir); got != 1 { t.Fatalf("stale dir not deleted: %d dirs, want 1", got) } } func TestCacheSingleRepoOverBudget(t *testing.T) { root := t.TempDir() repoPath := makeBareRepo(t, root, "u@d.com", "demo.git") st := store.NewLocalStore(root) // Budget of 1 byte: the sole entry must still be kept and servable. dir := t.TempDir() c := newTestCache(t, st, CacheOptions{Dir: dir, BudgetBytes: 1}) r1, release1, err := c.Get(repoPath) if err != nil { t.Fatal(err) } release1() if got := dirCount(t, dir); got != 1 { t.Fatalf("sole over-budget entry evicted: %d dirs, want 1", got) } r2, release2, err := c.Get(repoPath) if err != nil { t.Fatal(err) } defer release2() if r1 != r2 { t.Fatal("expected the over-budget entry to be reused") } }