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")
}
}