mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-09 07:39:19 -05:00
1034 lines
35 KiB
Go
1034 lines
35 KiB
Go
package gitcache
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"math"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/git-pkgs/registries/safehttp"
|
|
)
|
|
|
|
func TestCloneMatchesUpstream(t *testing.T) {
|
|
for _, version := range []string{"0", "1", "2"} {
|
|
t.Run("protocol v"+version, func(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
upstream.newRepo("repo")
|
|
tc := newTestCache(t, upstream.server.URL, nil)
|
|
|
|
clone := tc.clone(t, "repo.git", "-c", "protocol.version="+version)
|
|
|
|
want := runGit(t, "", "ls-remote", "--heads", "--tags", upstream.bare())
|
|
if got := runGit(t, "", "-c", "protocol.version="+version, "ls-remote", "--heads", "--tags", tc.url("repo.git")); got != want {
|
|
t.Errorf("ls-remote through the cache:\n%s\nwant:\n%s", got, want)
|
|
}
|
|
if got, want := runGit(t, clone, "rev-parse", "HEAD"), runGit(t, upstream.bare(), "rev-parse", "main"); got != want {
|
|
t.Errorf("HEAD = %s, want %s", got, want)
|
|
}
|
|
runGit(t, clone, "fsck", "--no-dangling", "--no-progress")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUpstreamWithoutProtocolV2(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
upstream.v0Only = true
|
|
upstream.newRepo("repo")
|
|
tc := newTestCache(t, upstream.server.URL, nil)
|
|
|
|
clone := tc.clone(t, "repo")
|
|
if got, want := runGit(t, clone, "rev-parse", "HEAD"), runGit(t, upstream.bare(), "rev-parse", "main"); got != want {
|
|
t.Errorf("HEAD = %s, want %s", got, want)
|
|
}
|
|
}
|
|
|
|
func TestUnchangedRepositoryIsNotFetchedAgain(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
upstream.newRepo("repo")
|
|
tc := newTestCache(t, upstream.server.URL, nil)
|
|
|
|
tc.clone(t, "repo")
|
|
fetches := upstream.gitRequests.Load()
|
|
tc.clone(t, "repo")
|
|
if got := upstream.gitRequests.Load(); got != fetches {
|
|
t.Errorf("second clone made %d upstream fetch requests, want none", got-fetches)
|
|
}
|
|
}
|
|
|
|
func TestPushedCommitIsServedOnNextClone(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
work := upstream.newRepo("repo")
|
|
tc := newTestCache(t, upstream.server.URL, nil)
|
|
existing := tc.clone(t, "repo")
|
|
|
|
head := upstream.commit(work, "second")
|
|
runGit(t, work, "push", "--quiet", "origin", "main")
|
|
|
|
if got := runGit(t, tc.clone(t, "repo"), "rev-parse", "HEAD"); got != head {
|
|
t.Errorf("clone after push: HEAD = %s, want %s", got, head)
|
|
}
|
|
runGit(t, existing, "pull", "--quiet", "--ff-only")
|
|
if got := runGit(t, existing, "rev-parse", "HEAD"); got != head {
|
|
t.Errorf("pull after push: HEAD = %s, want %s", got, head)
|
|
}
|
|
if got := runGit(t, tc.clone(t, "repo", "--depth=1"), "rev-list", "--count", "HEAD"); got != "1" {
|
|
t.Errorf("shallow clone has %s commits, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestOnlyBranchesAndTagsAreCachedAndPruned(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
work := upstream.newRepo("repo")
|
|
runGit(t, work, "push", "--quiet", "origin", "main:refs/changes/01/1/1", "main:refs/heads/gone", "main:refs/tags/gone")
|
|
tc := newTestCache(t, upstream.server.URL, nil)
|
|
tc.clone(t, "repo")
|
|
|
|
refs := tc.cachedRefs(t)
|
|
if strings.Contains(refs, "refs/changes/") {
|
|
t.Errorf("cached refs/changes:\n%s", refs)
|
|
}
|
|
if !strings.Contains(refs, "refs/heads/gone") || !strings.Contains(refs, "refs/tags/gone") {
|
|
t.Fatalf("branch and tag not cached:\n%s", refs)
|
|
}
|
|
|
|
runGit(t, work, "push", "--quiet", "origin", ":refs/heads/gone", ":refs/tags/gone")
|
|
tc.clone(t, "repo")
|
|
if refs := tc.cachedRefs(t); strings.Contains(refs, "gone") {
|
|
t.Errorf("deleted branch or tag still cached:\n%s", refs)
|
|
}
|
|
|
|
// Gerrit advertises a ref per patch set, so asking for every ref would
|
|
// list hundreds of thousands for a large repository.
|
|
bodies := upstream.lsRefsBodies()
|
|
if len(bodies) == 0 {
|
|
t.Fatal("no ls-refs request reached the upstream")
|
|
}
|
|
for _, body := range bodies {
|
|
if !strings.Contains(body, "ref-prefix refs/heads/") || !strings.Contains(body, "ref-prefix refs/tags/") {
|
|
t.Errorf("ls-refs asked for every ref, want branches and tags only:\n%q", body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDefaultBranchChangeIsFollowed(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
work := upstream.newRepo("repo")
|
|
tc := newTestCache(t, upstream.server.URL, nil)
|
|
tc.clone(t, "repo")
|
|
|
|
runGit(t, work, "checkout", "--quiet", "-b", "trunk")
|
|
head := upstream.commit(work, "on trunk")
|
|
runGit(t, work, "push", "--quiet", "origin", "trunk")
|
|
runGit(t, upstream.bare(), "symbolic-ref", "HEAD", "refs/heads/trunk")
|
|
|
|
clone := tc.clone(t, "repo")
|
|
if got := runGit(t, clone, "rev-parse", "HEAD"); got != head {
|
|
t.Errorf("HEAD = %s, want trunk's %s", got, head)
|
|
}
|
|
}
|
|
|
|
func TestConcurrentColdClonesShareOneFetch(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
upstream.newRepo("repo")
|
|
tc := newTestCache(t, upstream.server.URL, nil)
|
|
|
|
release := upstream.holdRequests()
|
|
var wg sync.WaitGroup
|
|
for range 8 {
|
|
wg.Go(func() {
|
|
dir := filepath.Join(t.TempDir(), "clone")
|
|
if out, err := tryGit(t, "", "clone", "--quiet", tc.url("repo"), dir); err != nil {
|
|
t.Errorf("clone: %v\n%s", err, out)
|
|
}
|
|
})
|
|
}
|
|
time.Sleep(200 * time.Millisecond)
|
|
release()
|
|
wg.Wait()
|
|
|
|
if got := upstream.gitSessions.Load(); got != 1 {
|
|
t.Errorf("upstream saw %d fetches, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestMissingRepositoryLeavesNothingBehind(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
tc := newTestCache(t, upstream.server.URL, nil)
|
|
|
|
resp, err := http.Get(tc.url("missing") + "/info/refs?service=git-upload-pack")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Errorf("status %d, want 404", resp.StatusCode)
|
|
}
|
|
waitForRepos(t, tc.Cache, 0)
|
|
if _, err := os.Stat(filepath.Join(tc.dir, "up", "missing.git")); !os.IsNotExist(err) {
|
|
t.Errorf("repository directory left behind: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStaleCopyServedOnlyWithinStaleMaxAge(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
upstream.newRepo("repo")
|
|
tc := newTestCache(t, upstream.server.URL, nil)
|
|
tc.clone(t, "repo")
|
|
|
|
upstream.status.Store(http.StatusServiceUnavailable)
|
|
tc.clone(t, "repo")
|
|
|
|
waitIdle(t, tc.Cache)
|
|
rp := tc.acquire("up/repo.git")
|
|
rp.lastChecked.Store(time.Now().Add(-2 * time.Hour).UnixNano())
|
|
tc.release(rp)
|
|
if out, err := tryGit(t, "", "ls-remote", tc.url("repo")); err == nil {
|
|
t.Errorf("served a copy older than stale_max_age:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestRefusedRefreshIsNotServedStale(t *testing.T) {
|
|
for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusMovedPermanently} {
|
|
t.Run(fmt.Sprint(status), func(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
upstream.newRepo("repo")
|
|
tc := newTestCache(t, upstream.server.URL, nil)
|
|
tc.clone(t, "repo")
|
|
|
|
upstream.status.Store(int32(status))
|
|
if out, err := tryGit(t, "", "ls-remote", tc.url("repo")); err == nil {
|
|
t.Errorf("served the cached copy after status %d:\n%s", status, out)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSlowUpstreamServesCachedCopyAfterRefreshWait(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
work := upstream.newRepo("repo")
|
|
tc := newTestCache(t, upstream.server.URL, func(o *Options) { o.RefreshWait = 100 * time.Millisecond })
|
|
old := runGit(t, tc.clone(t, "repo"), "rev-parse", "HEAD")
|
|
head := upstream.commit(work, "second")
|
|
runGit(t, work, "push", "--quiet", "origin", "main")
|
|
|
|
release := upstream.holdRequests()
|
|
defer release()
|
|
if got := runGit(t, tc.clone(t, "repo"), "rev-parse", "HEAD"); got != old {
|
|
t.Errorf("HEAD = %s, want the cached %s", got, old)
|
|
}
|
|
release()
|
|
waitFor(t, func() bool { return strings.Contains(tc.cachedRefs(t), head) })
|
|
}
|
|
|
|
// TestSlowFetchAfterPushIsWaitedFor has the upstream answer the ref check at
|
|
// once and the fetch after a push still run when RefreshWait is over: a
|
|
// clone still gets the pushed commit.
|
|
func TestSlowFetchAfterPushIsWaitedFor(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
work := upstream.newRepo("repo")
|
|
waitOver := make(chan time.Time)
|
|
tc := newTestCache(t, upstream.server.URL, func(o *Options) { o.refreshWaitOver = waitOver })
|
|
tc.clone(t, "repo")
|
|
head := upstream.commit(work, "second")
|
|
runGit(t, work, "push", "--quiet", "origin", "main")
|
|
|
|
arrived, release := upstream.holdFetches()
|
|
defer release()
|
|
dir := filepath.Join(t.TempDir(), "clone")
|
|
cloned := make(chan error, 1)
|
|
go func() {
|
|
out, err := tryGit(t, "", "clone", "--quiet", tc.url("repo"), dir)
|
|
if err != nil {
|
|
err = fmt.Errorf("%w\n%s", err, out)
|
|
}
|
|
cloned <- err
|
|
}()
|
|
// The fetch starts only after the check found the push.
|
|
select {
|
|
case <-arrived:
|
|
case err := <-cloned:
|
|
t.Fatalf("clone finished before the fetch started: %v", err)
|
|
case <-time.After(30 * time.Second):
|
|
t.Fatal("the fetch after the push never reached the upstream")
|
|
}
|
|
close(waitOver)
|
|
release()
|
|
if err := <-cloned; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := runGit(t, dir, "rev-parse", "HEAD"); got != head {
|
|
t.Errorf("HEAD = %s, want the pushed %s", got, head)
|
|
}
|
|
}
|
|
|
|
// TestStalledFetchAfterPushServesCachedCopy has the fetch after a push
|
|
// stall: once it is abandoned, the clone gets the cached copy.
|
|
func TestStalledFetchAfterPushServesCachedCopy(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
work := upstream.newRepo("repo")
|
|
tc := newTestCache(t, upstream.server.URL, func(o *Options) {
|
|
o.RefreshWait = 100 * time.Millisecond
|
|
o.lowSpeedTime = 1
|
|
})
|
|
old := runGit(t, tc.clone(t, "repo"), "rev-parse", "HEAD")
|
|
upstream.commit(work, "second")
|
|
runGit(t, work, "push", "--quiet", "origin", "main")
|
|
|
|
upstream.stallFetches.Store(true)
|
|
if got := runGit(t, tc.clone(t, "repo"), "rev-parse", "HEAD"); got != old {
|
|
t.Errorf("HEAD = %s, want the cached %s", got, old)
|
|
}
|
|
}
|
|
|
|
func TestUpstreamCredentialsReachEveryUpstreamRequest(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
upstream.requireAuth = "Bearer secret"
|
|
upstream.newRepo("repo")
|
|
client := &http.Client{Transport: headerTransport{"Authorization", "Bearer secret"}}
|
|
tc := newTestCache(t, upstream.server.URL, func(o *Options) { o.Client = client })
|
|
|
|
tc.clone(t, "repo")
|
|
if got := upstream.unauthed.Load(); got != 0 {
|
|
t.Errorf("%d upstream requests lacked credentials", got)
|
|
}
|
|
if upstream.gitRequests.Load() == 0 {
|
|
t.Error("no fetch went through the relay")
|
|
}
|
|
}
|
|
|
|
type headerTransport struct{ name, value string }
|
|
|
|
func (h headerTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
|
r = r.Clone(r.Context())
|
|
r.Header.Set(h.name, h.value)
|
|
return http.DefaultTransport.RoundTrip(r)
|
|
}
|
|
|
|
// TestUpstreamAddressChecksApplyToGit uses a client that refuses loopback
|
|
// addresses, as the proxy's does by default. git must not reach the loopback
|
|
// upstream around it, whether the ref check or only git's fetch is refused.
|
|
func TestUpstreamAddressChecksApplyToGit(t *testing.T) {
|
|
blocked := safehttp.New(nil, safehttp.Options{})
|
|
for name, client := range map[string]*http.Client{
|
|
"every request": blocked,
|
|
"git's fetch": {Transport: gitOnlyTransport{blocked.Transport}},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
upstream.newRepo("repo")
|
|
tc := newTestCache(t, upstream.server.URL, func(o *Options) { o.Client = client })
|
|
|
|
if out, err := tryGit(t, "", "ls-remote", tc.url("repo")); err == nil {
|
|
t.Fatalf("clone through a blocked upstream succeeded:\n%s", out)
|
|
}
|
|
if got := upstream.gitRequests.Load(); got != 0 {
|
|
t.Errorf("git reached the upstream with %d requests", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// gitOnlyTransport sends the cache's own ref checks with the default
|
|
// transport, and git's relayed requests with next.
|
|
type gitOnlyTransport struct{ next http.RoundTripper }
|
|
|
|
func (g gitOnlyTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
|
if r.UserAgent() == gitUserAgent {
|
|
return http.DefaultTransport.RoundTrip(r)
|
|
}
|
|
return g.next.RoundTrip(r)
|
|
}
|
|
|
|
// TestRedirectIsNotFollowed redirects the ref check, then only git's fetch.
|
|
func TestRedirectIsNotFollowed(t *testing.T) {
|
|
for _, redirectRefCheck := range []bool{true, false} {
|
|
t.Run(fmt.Sprint("ref check redirected: ", redirectRefCheck), func(t *testing.T) {
|
|
target := newFakeUpstream(t)
|
|
target.newRepo("repo")
|
|
targetURL, _ := url.Parse(target.server.URL)
|
|
passThrough := httputil.NewSingleHostReverseProxy(targetURL)
|
|
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.UserAgent() == gitUserAgent && !redirectRefCheck {
|
|
passThrough.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
http.Redirect(w, r, target.server.URL+r.URL.RequestURI(), http.StatusFound)
|
|
}))
|
|
defer redirect.Close()
|
|
tc := newTestCache(t, redirect.URL, nil)
|
|
|
|
if out, err := tryGit(t, "", "ls-remote", tc.url("repo")); err == nil {
|
|
t.Fatalf("followed a redirect:\n%s", out)
|
|
}
|
|
if got := target.gitRequests.Load(); got != 0 {
|
|
t.Errorf("redirect target received %d requests from git", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRepositoryOverMaxRepoSizeIsRefused(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
work := upstream.newRepo("repo")
|
|
big := make([]byte, 256<<10)
|
|
_, _ = rand.Read(big)
|
|
if err := os.WriteFile(filepath.Join(work, "big.bin"), big, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runGit(t, work, "add", "big.bin")
|
|
runGit(t, work, "commit", "--quiet", "-m", "big")
|
|
runGit(t, work, "push", "--quiet", "origin", "main")
|
|
|
|
// max_size caps max_repo_size.
|
|
for name, configure := range map[string]func(*Options){
|
|
"max_repo_size": func(o *Options) { o.MaxRepoSize = 64 << 10 },
|
|
"max_size": func(o *Options) { o.MaxSize = 64 << 10 },
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
tc := newTestCache(t, upstream.server.URL, configure)
|
|
|
|
resp, err := http.Get(tc.url("repo") + "/info/refs?service=git-upload-pack")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = resp.Body.Close()
|
|
if resp.StatusCode != http.StatusInsufficientStorage {
|
|
t.Errorf("status %d, want 507", resp.StatusCode)
|
|
}
|
|
waitForRepos(t, tc.Cache, 0)
|
|
if entries, _ := os.ReadDir(tc.tmpDir()); len(entries) != 0 {
|
|
t.Errorf("left %d entries in the temporary directory", len(entries))
|
|
}
|
|
|
|
requests := upstream.requests.Load()
|
|
if out, err := tryGit(t, "", "ls-remote", tc.url("repo")); err == nil {
|
|
t.Errorf("second attempt succeeded:\n%s", out)
|
|
}
|
|
if got := upstream.requests.Load(); got != requests {
|
|
t.Errorf("second attempt made %d upstream requests, want none", got-requests)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEvictionRemovesLeastRecentlyUsedIdleRepositories makes the least
|
|
// recently used repository one a request is reading, so the next one is
|
|
// evicted instead.
|
|
func TestEvictionRemovesLeastRecentlyUsedIdleRepositories(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
for _, name := range []string{"old", "busy", "new"} {
|
|
upstream.newRepo(name)
|
|
}
|
|
size := repoSize(t, upstream, "new")
|
|
tc := newTestCache(t, upstream.server.URL, func(o *Options) { o.MaxSize = 2*size + size/2 })
|
|
|
|
tc.clone(t, "busy")
|
|
busy := tc.acquire("up/busy.git")
|
|
defer tc.release(busy)
|
|
busy.lastUsed.Store(0)
|
|
tc.clone(t, "old")
|
|
waitFor(t, func() bool { return tc.usersOf("up/old.git") == 0 })
|
|
tc.repo("up/old.git").lastUsed.Store(1)
|
|
|
|
// The third clone takes the cache past MaxSize.
|
|
tc.clone(t, "new")
|
|
waitFor(t, func() bool { return !fileExists(filepath.Join(tc.dir, "up", "old.git")) })
|
|
for _, name := range []string{"busy", "new"} {
|
|
if !fileExists(filepath.Join(tc.dir, "up", name+".git")) {
|
|
t.Errorf("%s was evicted", name)
|
|
}
|
|
}
|
|
tc.clone(t, "old")
|
|
}
|
|
|
|
// repoSize is the size a cache gives repository name once cloned.
|
|
func repoSize(t *testing.T, upstream *fakeUpstream, name string) int64 {
|
|
t.Helper()
|
|
probe := newTestCache(t, upstream.server.URL, nil)
|
|
probe.clone(t, name)
|
|
return probe.repo("up/" + name + ".git").size.Load()
|
|
}
|
|
|
|
func TestNewLoadsCachedRepositories(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
upstream.newRepo("repo")
|
|
tc := newTestCache(t, upstream.server.URL, nil)
|
|
tc.clone(t, "repo")
|
|
if err := os.MkdirAll(filepath.Join(tc.tmpDir(), "clone-leftover"), 0o750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := tc.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
reloaded, err := New(Options{
|
|
Upstreams: map[string]string{"up": "up"},
|
|
CacheDir: tc.dir,
|
|
StaleMaxAge: time.Hour,
|
|
Client: &http.Client{},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = reloaded.Close() }()
|
|
|
|
rp := reloaded.repos["up/repo.git"] // nothing else uses reloaded yet
|
|
if rp == nil || rp.size.Load() == 0 || rp.lastChecked.Load() == 0 {
|
|
t.Fatalf("repository not loaded: %+v", rp)
|
|
}
|
|
if entries, _ := os.ReadDir(reloaded.tmpDir()); len(entries) != 0 {
|
|
t.Errorf("temporary directory not cleared: %v", entries)
|
|
}
|
|
}
|
|
|
|
func waitForRepos(t *testing.T, c *Cache, n int) {
|
|
t.Helper()
|
|
waitFor(t, func() bool {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return len(c.repos) == n
|
|
})
|
|
}
|
|
|
|
func waitFor(t *testing.T, cond func() bool) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
for !cond() {
|
|
select {
|
|
case <-ctx.Done():
|
|
t.Fatal("condition not met in time")
|
|
case <-time.After(20 * time.Millisecond):
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRewoundCacheIsBroughtBackInLine changes the cache behind the upstream's
|
|
// back, so it only matches again if the next request refreshes branches and
|
|
// tags and prunes what the upstream does not have.
|
|
func TestRewoundCacheIsBroughtBackInLine(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
work := upstream.newRepo("repo")
|
|
upstream.commit(work, "second")
|
|
runGit(t, work, "push", "--quiet", "origin", "main")
|
|
tc := newTestCache(t, upstream.server.URL, nil)
|
|
tc.clone(t, "repo")
|
|
|
|
cached := filepath.Join(tc.dir, "up", "repo.git")
|
|
runGit(t, cached, "update-ref", "refs/heads/main", "main~1")
|
|
runGit(t, cached, "update-ref", "refs/heads/extra", "main")
|
|
runGit(t, cached, "tag", "extra", "main")
|
|
|
|
want := runGit(t, "", "ls-remote", "--heads", "--tags", upstream.bare())
|
|
if got := runGit(t, "", "ls-remote", "--heads", "--tags", tc.url("repo")); got != want {
|
|
t.Errorf("ls-remote through the cache:\n%s\nwant:\n%s", got, want)
|
|
}
|
|
}
|
|
|
|
// TestConcurrentWarmClonesDuringRefresh serves clones from the cache while a
|
|
// fetch writes to it.
|
|
func TestConcurrentWarmClonesDuringRefresh(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
work := upstream.newRepo("repo")
|
|
tc := newTestCache(t, upstream.server.URL, func(o *Options) { o.RefreshWait = time.Millisecond })
|
|
old := runGit(t, tc.clone(t, "repo"), "rev-parse", "HEAD")
|
|
head := upstream.commit(work, "second")
|
|
runGit(t, work, "push", "--quiet", "origin", "main")
|
|
|
|
var wg sync.WaitGroup
|
|
for range 8 {
|
|
wg.Go(func() {
|
|
dir := filepath.Join(t.TempDir(), "clone")
|
|
if out, err := tryGit(t, "", "clone", "--quiet", tc.url("repo"), dir); err != nil {
|
|
t.Errorf("clone: %v\n%s", err, out)
|
|
return
|
|
}
|
|
if got := runGit(t, dir, "rev-parse", "HEAD"); got != old && got != head {
|
|
t.Errorf("HEAD = %s, want %s or %s", got, old, head)
|
|
}
|
|
runGit(t, dir, "fsck", "--no-dangling", "--no-progress")
|
|
})
|
|
}
|
|
wg.Wait()
|
|
waitFor(t, func() bool { return strings.Contains(tc.cachedRefs(t), head) })
|
|
}
|
|
|
|
// TestStalledRefCheckFailsInsteadOfHanging has the upstream send headers and
|
|
// then nothing, as a broken load balancer might.
|
|
func TestStalledRefCheckFailsInsteadOfHanging(t *testing.T) {
|
|
stall := make(chan struct{})
|
|
defer close(stall)
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", advertisementType)
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = http.NewResponseController(w).Flush()
|
|
select {
|
|
case <-stall:
|
|
case <-r.Context().Done():
|
|
}
|
|
}))
|
|
defer upstream.Close()
|
|
tc := newTestCache(t, upstream.URL, func(o *Options) { o.refCheckTimeout = 200 * time.Millisecond })
|
|
|
|
start := time.Now()
|
|
if out, err := tryGit(t, "", "ls-remote", tc.url("repo")); err == nil {
|
|
t.Fatalf("ls-remote succeeded:\n%s", out)
|
|
}
|
|
if elapsed := time.Since(start); elapsed > 10*time.Second {
|
|
t.Errorf("took %s to fail", elapsed)
|
|
}
|
|
}
|
|
|
|
// TestStalledFetchFailsInsteadOfHanging has the upstream answer the ref check
|
|
// but never git's fetch, as a connection that stalls mid-pack would.
|
|
func TestStalledFetchFailsInsteadOfHanging(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
upstream.newRepo("repo")
|
|
upstream.stallFetches.Store(true)
|
|
tc := newTestCache(t, upstream.server.URL, func(o *Options) { o.lowSpeedTime = 1 })
|
|
|
|
start := time.Now()
|
|
if out, err := tryGit(t, "", "ls-remote", tc.url("repo")); err == nil {
|
|
t.Fatalf("ls-remote succeeded:\n%s", out)
|
|
}
|
|
if elapsed := time.Since(start); elapsed > 20*time.Second {
|
|
t.Errorf("took %s to fail", elapsed)
|
|
}
|
|
}
|
|
|
|
// TestFetchEvictsToMaxSize checks eviction runs on its own once a fetch has
|
|
// grown the cache past MaxSize.
|
|
func TestFetchEvictsToMaxSize(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
upstream.newRepo("first")
|
|
upstream.newRepo("second")
|
|
size := repoSize(t, upstream, "first")
|
|
|
|
tc := newTestCache(t, upstream.server.URL, func(o *Options) { o.MaxSize = size + size/2 })
|
|
tc.clone(t, "first")
|
|
waitIdle(t, tc.Cache)
|
|
tc.clone(t, "second")
|
|
waitFor(t, func() bool { return !fileExists(filepath.Join(tc.dir, "up", "first.git")) })
|
|
if !fileExists(filepath.Join(tc.dir, "up", "second.git")) {
|
|
t.Error("evicted the repository just fetched")
|
|
}
|
|
}
|
|
|
|
// TestReleaseEvictsWhatEvictionHadToSkip fills the cache past MaxSize while
|
|
// every repository is in use, so eviction after the fetch can remove none.
|
|
// The first release brings the cache back within MaxSize.
|
|
func TestReleaseEvictsWhatEvictionHadToSkip(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
upstream.newRepo("first")
|
|
upstream.newRepo("second")
|
|
size := repoSize(t, upstream, "first")
|
|
|
|
tc := newTestCache(t, upstream.server.URL, func(o *Options) { o.MaxSize = size + size/2 })
|
|
first := tc.acquire("up/first.git")
|
|
tc.clone(t, "first")
|
|
second := tc.acquire("up/second.git")
|
|
defer tc.release(second)
|
|
tc.clone(t, "second")
|
|
waitFor(t, func() bool {
|
|
tc.mu.Lock()
|
|
defer tc.mu.Unlock()
|
|
return tc.overBudget
|
|
})
|
|
|
|
tc.release(first)
|
|
if fileExists(first.dir) {
|
|
t.Error("first is still cached once released")
|
|
}
|
|
if !fileExists(second.dir) {
|
|
t.Error("evicted second while in use")
|
|
}
|
|
tc.clone(t, "second")
|
|
}
|
|
|
|
// TestNewRemovesRepointedUpstreams starts a cache whose directory holds an
|
|
// upstream's cache at its old URL, and one of an upstream no longer
|
|
// configured. Only the first is known to be the cache's to remove.
|
|
func TestNewRemovesRepointedUpstreams(t *testing.T) {
|
|
dir := t.TempDir()
|
|
current := UpstreamDir("up", "https://new.example")
|
|
repointed := UpstreamDir("up", "https://old.example")
|
|
unconfigured := UpstreamDir("gone", "https://gone.example")
|
|
// Named after a configured upstream's name, but another upstream's.
|
|
longer := UpstreamDir("up-api", "https://api.example")
|
|
for _, name := range []string{current, repointed, unconfigured, longer} {
|
|
runGit(t, "", "init", "--quiet", "--bare", filepath.Join(dir, name, "repo.git"))
|
|
}
|
|
c, err := New(Options{
|
|
CacheDir: dir,
|
|
StaleMaxAge: time.Hour,
|
|
Client: &http.Client{},
|
|
Upstreams: map[string]string{"up": current},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = c.Close() }()
|
|
|
|
for name, want := range map[string]bool{current: true, repointed: false, unconfigured: true, longer: true} {
|
|
if got := fileExists(filepath.Join(dir, name)); got != want {
|
|
t.Errorf("%s present = %v, want %v", name, got, want)
|
|
}
|
|
}
|
|
if keys := slices.Collect(maps.Keys(c.repos)); len(keys) != 1 || keys[0] != current+"/repo.git" {
|
|
t.Errorf("loaded %v, want only %s/repo.git", keys, current)
|
|
}
|
|
}
|
|
|
|
// TestCacheDirSharedWithOtherData points the cache at a directory that also
|
|
// holds an artifact store and a database, as git.cache_dir: ./cache would.
|
|
func TestCacheDirSharedWithOtherData(t *testing.T) {
|
|
dir := t.TempDir()
|
|
_ = os.MkdirAll(filepath.Join(dir, "artifacts", "npm"), 0o750)
|
|
_ = os.WriteFile(filepath.Join(dir, "artifacts", "npm", "pkg.tgz"), []byte("x"), 0o600)
|
|
_ = os.WriteFile(filepath.Join(dir, "proxy.db"), []byte("db"), 0o600)
|
|
runGit(t, "", "init", "--quiet", "--bare", filepath.Join(dir, "artifacts", "foo", "bar.git"))
|
|
c, err := New(Options{
|
|
CacheDir: dir,
|
|
Client: &http.Client{},
|
|
Upstreams: map[string]string{"github": UpstreamDir("github", "https://github.com")},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = c.Close() }()
|
|
for _, p := range []string{"artifacts/npm/pkg.tgz", "proxy.db", "artifacts/foo/bar.git"} {
|
|
if _, err := os.Stat(filepath.Join(dir, p)); err != nil {
|
|
t.Errorf("%s was deleted at startup: %v", p, err)
|
|
}
|
|
}
|
|
if len(c.repos) != 0 {
|
|
t.Errorf("loaded %v from outside the upstream directories", slices.Collect(maps.Keys(c.repos)))
|
|
}
|
|
}
|
|
|
|
// TestCloseWaitsForRefreshes closes the cache while a clone is held up
|
|
// upstream. When Close returns, the clone must have finished, temporary
|
|
// directory included, so nothing is left writing to the cache.
|
|
func TestCloseWaitsForRefreshes(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
upstream.newRepo("repo")
|
|
upstream.stallFetches.Store(true)
|
|
tc := newTestCache(t, upstream.server.URL, nil)
|
|
clones := func() []string {
|
|
names, _ := filepath.Glob(filepath.Join(tc.tmpDir(), "clone-*"))
|
|
return names
|
|
}
|
|
|
|
go func() { _, _ = tryGit(t, "", "ls-remote", tc.url("repo")) }()
|
|
waitFor(t, func() bool { return len(clones()) > 0 })
|
|
if err := tc.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if left := clones(); len(left) != 0 {
|
|
t.Errorf("Close returned with the clone still running: %v", left)
|
|
}
|
|
|
|
before := upstream.requests.Load()
|
|
if _, err := tc.Open(context.Background(), "up/other.git", upstream.server.URL+"/other", OpenOptions{}); !errors.Is(err, errClosed) {
|
|
t.Errorf("Open after Close: %v, want errClosed", err)
|
|
}
|
|
if got := upstream.requests.Load() - before; got != 0 {
|
|
t.Errorf("Open after Close made %d upstream requests", got)
|
|
}
|
|
}
|
|
|
|
// TestCacheDirInUse opens a second cache on a directory one already uses,
|
|
// as a second pod on a shared volume would, then again once the first has
|
|
// closed.
|
|
func TestCacheDirInUse(t *testing.T) {
|
|
dir := t.TempDir()
|
|
open := func() (*Cache, error) { return New(Options{CacheDir: dir, Client: &http.Client{}}) }
|
|
first, err := open()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if second, err := open(); err == nil {
|
|
_ = second.Close()
|
|
t.Fatal("a second cache opened on the same directory")
|
|
} else if !strings.Contains(err.Error(), "in use by another process") {
|
|
t.Errorf("error = %v, want one naming the directory as in use", err)
|
|
}
|
|
if err := first.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
again, err := open()
|
|
if err != nil {
|
|
t.Fatalf("reopening after Close: %v", err)
|
|
}
|
|
_ = again.Close()
|
|
}
|
|
|
|
// TestCloseTwice checks closing again does nothing, as a cleanup that also
|
|
// closes the cache may.
|
|
func TestCloseTwice(t *testing.T) {
|
|
c, err := New(Options{CacheDir: t.TempDir(), Client: &http.Client{}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := errors.Join(c.Close(), c.Close()); err != nil {
|
|
t.Errorf("closing twice: %v", err)
|
|
}
|
|
}
|
|
|
|
// lsRefs sends a protocol v2 ls-refs request for "repo" straight to
|
|
// git-upload-pack, as a client may without asking for info/refs first, and
|
|
// returns the status.
|
|
func (tc *testCache) lsRefs(t *testing.T) int {
|
|
t.Helper()
|
|
body := pktLine("command=ls-refs\n") + pktDelim + pktLine("peel\n") + pktFlush
|
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, tc.url("repo")+"/git-upload-pack", strings.NewReader(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.Header.Set("Content-Type", requestType)
|
|
req.Header.Set("Git-Protocol", "version=2")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = resp.Body.Close()
|
|
return resp.StatusCode
|
|
}
|
|
|
|
// TestUploadPackChecksCopyPastStaleMaxAge sends upload-pack requests without
|
|
// info/refs: a copy not checked within stale_max_age is checked first.
|
|
func TestUploadPackChecksCopyPastStaleMaxAge(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
upstream.newRepo("repo")
|
|
tc := newTestCache(t, upstream.server.URL, nil)
|
|
tc.clone(t, "repo")
|
|
if status := tc.lsRefs(t); status != http.StatusOK {
|
|
t.Fatalf("ls-refs on a fresh copy: status %d", status)
|
|
}
|
|
|
|
waitIdle(t, tc.Cache)
|
|
rp := tc.acquire("up/repo.git")
|
|
rp.lastChecked.Store(time.Now().Add(-2 * time.Hour).UnixNano())
|
|
tc.release(rp)
|
|
upstream.status.Store(http.StatusNotFound)
|
|
if status := tc.lsRefs(t); status == http.StatusOK {
|
|
t.Errorf("served a copy older than stale_max_age to ls-refs")
|
|
}
|
|
}
|
|
|
|
// TestUploadPackRefusedAfterUpstreamRefusal has the upstream refuse a
|
|
// refresh: upload-pack requests are refused too until a check succeeds.
|
|
func TestUploadPackRefusedAfterUpstreamRefusal(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
upstream.newRepo("repo")
|
|
tc := newTestCache(t, upstream.server.URL, nil)
|
|
tc.clone(t, "repo")
|
|
|
|
upstream.status.Store(http.StatusForbidden)
|
|
if out, err := tryGit(t, "", "ls-remote", tc.url("repo")); err == nil {
|
|
t.Fatalf("served the cached copy after 403:\n%s", out)
|
|
}
|
|
if status := tc.lsRefs(t); status == http.StatusOK {
|
|
t.Errorf("ls-refs served after the upstream refused the repository")
|
|
}
|
|
|
|
upstream.status.Store(0)
|
|
tc.clone(t, "repo")
|
|
if status := tc.lsRefs(t); status != http.StatusOK {
|
|
t.Errorf("ls-refs after a successful check: status %d", status)
|
|
}
|
|
}
|
|
|
|
// TestCloneChecksUpstreamOnce clones with a stale_max_age of 0: the
|
|
// upload-pack requests after info/refs do not check the upstream again.
|
|
func TestCloneChecksUpstreamOnce(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
upstream.newRepo("repo")
|
|
tc := newTestCache(t, upstream.server.URL, func(o *Options) { o.StaleMaxAge = 0 })
|
|
tc.clone(t, "repo")
|
|
waitIdle(t, tc.Cache)
|
|
|
|
checks := func() int32 { return upstream.requests.Load() - upstream.gitRequests.Load() }
|
|
before := checks()
|
|
resp, err := http.Get(tc.url("repo") + "/info/refs?service=git-upload-pack")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = resp.Body.Close()
|
|
perCheck := checks() - before
|
|
|
|
before = checks()
|
|
tc.clone(t, "repo")
|
|
if got := checks() - before; got != perCheck {
|
|
t.Errorf("clone made %d upstream check requests, want %d", got, perCheck)
|
|
}
|
|
}
|
|
|
|
// TestInfoRefsRefusedWhileRefusalStands checks that once the upstream
|
|
// refused a repository, a later check that runs past RefreshWait, which
|
|
// would otherwise serve the cached copy, doesn't advertise its refs.
|
|
func TestInfoRefsRefusedWhileRefusalStands(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
upstream.newRepo("repo")
|
|
tc := newTestCache(t, upstream.server.URL, func(o *Options) { o.RefreshWait = 100 * time.Millisecond })
|
|
tc.clone(t, "repo")
|
|
|
|
upstream.status.Store(http.StatusForbidden)
|
|
if out, err := tryGit(t, "", "ls-remote", tc.url("repo")); err == nil {
|
|
t.Fatalf("served the cached copy after 403:\n%s", out)
|
|
}
|
|
upstream.status.Store(0)
|
|
release := upstream.holdRequests()
|
|
defer release()
|
|
// Protocol v0 advertises the refs in the info/refs response itself.
|
|
if out, err := tryGit(t, "", "-c", "protocol.version=0", "ls-remote", tc.url("repo")); err == nil {
|
|
t.Errorf("advertised refs while the refusal stands:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// refuse has the upstream refuse "repo" with a 403 and checks that the
|
|
// cached copy is no longer served.
|
|
func (tc *testCache) refuse(t *testing.T, upstream *fakeUpstream) {
|
|
t.Helper()
|
|
upstream.status.Store(http.StatusForbidden)
|
|
if out, err := tryGit(t, "", "ls-remote", tc.url("repo")); err == nil {
|
|
t.Fatalf("served the cached copy after 403:\n%s", out)
|
|
}
|
|
upstream.status.Store(0)
|
|
}
|
|
|
|
// TestUploadPackClearsRefusal has a client that only sends upload-pack
|
|
// requests: once the upstream allows the repository again, it is served.
|
|
func TestUploadPackClearsRefusal(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
upstream.newRepo("repo")
|
|
tc := newTestCache(t, upstream.server.URL, nil)
|
|
tc.clone(t, "repo")
|
|
tc.refuse(t, upstream)
|
|
|
|
if status := tc.lsRefs(t); status != http.StatusOK {
|
|
t.Errorf("ls-refs after the upstream allowed the repository again: status %d", status)
|
|
}
|
|
}
|
|
|
|
// TestRefCheckClearsRefusal has the upstream list the refs again after a
|
|
// refusal but fail the fetch that follows: the check alone clears the
|
|
// refusal, and the cached copy is served.
|
|
func TestRefCheckClearsRefusal(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
work := upstream.newRepo("repo")
|
|
tc := newTestCache(t, upstream.server.URL, nil)
|
|
tc.clone(t, "repo")
|
|
tc.refuse(t, upstream)
|
|
|
|
upstream.commit(work, "second")
|
|
runGit(t, work, "push", "--quiet", "origin", "main")
|
|
upstream.fetchStatus.Store(http.StatusInternalServerError)
|
|
if out, err := tryGit(t, "", "ls-remote", tc.url("repo")); err != nil {
|
|
t.Errorf("cached copy refused after the upstream listed its refs: %v\n%s", err, out)
|
|
}
|
|
if status := tc.lsRefs(t); status != http.StatusOK {
|
|
t.Errorf("ls-refs after the upstream listed its refs: status %d", status)
|
|
}
|
|
}
|
|
|
|
// TestHugeStaleMaxAgeUploadPackChecksNothing sets stale_max_age near the
|
|
// largest duration: upload-pack requests on a fresh copy do not check the
|
|
// upstream.
|
|
func TestHugeStaleMaxAgeUploadPackChecksNothing(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
upstream.newRepo("repo")
|
|
tc := newTestCache(t, upstream.server.URL, func(o *Options) { o.StaleMaxAge = math.MaxInt64 - time.Second })
|
|
tc.clone(t, "repo")
|
|
waitIdle(t, tc.Cache)
|
|
|
|
before := upstream.requests.Load()
|
|
if status := tc.lsRefs(t); status != http.StatusOK {
|
|
t.Fatalf("ls-refs: status %d", status)
|
|
}
|
|
waitIdle(t, tc.Cache)
|
|
if got := upstream.requests.Load() - before; got != 0 {
|
|
t.Errorf("ls-refs made %d upstream requests, want 0", got)
|
|
}
|
|
}
|
|
|
|
// TestFetchRedirectRefusesRepository has the upstream list the refs but
|
|
// redirect the fetch that follows: the repository is refused, as when the
|
|
// ref listing is redirected.
|
|
func TestFetchRedirectRefusesRepository(t *testing.T) {
|
|
upstream := newFakeUpstream(t)
|
|
work := upstream.newRepo("repo")
|
|
tc := newTestCache(t, upstream.server.URL, nil)
|
|
tc.clone(t, "repo")
|
|
|
|
upstream.commit(work, "second")
|
|
runGit(t, work, "push", "--quiet", "origin", "main")
|
|
upstream.fetchStatus.Store(http.StatusFound)
|
|
if out, err := tryGit(t, "", "ls-remote", tc.url("repo")); err == nil {
|
|
t.Errorf("served the cached copy after the fetch was redirected:\n%s", out)
|
|
}
|
|
if status := tc.lsRefs(t); status == http.StatusOK {
|
|
t.Errorf("ls-refs served after the fetch was redirected")
|
|
}
|
|
}
|
|
|
|
// TestCaseOnlyDifferentKeysShareOneRepository checks that, where the file
|
|
// system ignores case, keys that differ only in case are one repository, so
|
|
// a user of one spelling keeps the directory from being evicted under it.
|
|
func TestCaseOnlyDifferentKeysShareOneRepository(t *testing.T) {
|
|
if !caseInsensitive(t.TempDir()) {
|
|
t.Skip("file system is case-sensitive")
|
|
}
|
|
upstream := newFakeUpstream(t)
|
|
upstream.newRepo("repo")
|
|
tc := newTestCache(t, upstream.server.URL, nil)
|
|
tc.clone(t, "repo")
|
|
waitIdle(t, tc.Cache)
|
|
|
|
rp := tc.acquire("up/REPO.git")
|
|
defer tc.release(rp)
|
|
if other := tc.repo("up/repo.git"); other != rp {
|
|
t.Error("up/REPO.git and up/repo.git are separate repositories")
|
|
}
|
|
tc.mu.Lock()
|
|
tc.opts.MaxSize = 1
|
|
tc.mu.Unlock()
|
|
tc.evict()
|
|
if !rp.exists() {
|
|
t.Error("repository in use as up/REPO.git was evicted")
|
|
}
|
|
}
|
|
|
|
// TestFoldCaseKeysShareOneEntry checks key folding on any file system by
|
|
// setting foldCase directly.
|
|
func TestFoldCaseKeysShareOneEntry(t *testing.T) {
|
|
tc := newTestCache(t, newFakeUpstream(t).server.URL, nil)
|
|
tc.foldCase = true
|
|
|
|
upper := tc.acquire("up/REPO.git")
|
|
defer tc.release(upper)
|
|
lower := tc.acquire("up/repo.git")
|
|
defer tc.release(lower)
|
|
if upper != lower {
|
|
t.Error("up/REPO.git and up/repo.git are separate repositories")
|
|
}
|
|
tc.mu.Lock()
|
|
n := len(tc.repos)
|
|
tc.mu.Unlock()
|
|
if n != 1 {
|
|
t.Errorf("%d repository entries, want 1", n)
|
|
}
|
|
}
|