Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Bound and reclaim the runner's saved checkouts

f594f1a16f6f8a8a3ee2867a44249b72a2c76c96 · Keenan Nemetz · 5d ago · parent de84c32b

Bound and reclaim the runner's saved checkouts

The runner already downloaded each repository once into --dir and cloned every
job from that copy, but nothing kept the copies bounded: they grew with every
tag, every branch, and every commit a job pinned by id, and a force-push left the
old history reachable through the remote-tracking reflogs. A long-lived runner
filled its disk and then could not check anything out at all.

Repack a copy that is most of the cap, dropping the objects no branch or tag can
reach and the temporary refs a killed runner leaves behind; then delete the least
recently used copy when the cap is still reached. --git-cache-size sets the cap
(10G by default, 0 keeps every copy). The repository this runner is about to
build is never the one deleted, and a copy another process holds is passed over.

Reading and writing a saved checkout now takes a per-repository lock, so a repack
or a deletion in one runner process cannot run underneath another's clone, or
underneath the site and release pushes that are written from the same cache.
The first clone skips the working-tree checkout, which nothing ever read.

ci/agent/worktree_test.go +44

@@ -43,7 +43,9 @@ func TestPrepareWorktreeSeparatesTrustedCacheFromJobTree(t *testing.T) {
43 43 // for the upload-pack request and fails the protocol probe so Git falls back to
44 44 // the ordinary transport.
45 45 ssh := filepath.Join(tmp, "ssh")
46 calls := filepath.Join(tmp, "ssh-calls")
46 47 script := "#!/bin/sh\n" +
48 "echo \"$*\" >> \"$VOLE_TEST_SSH_LOG\"\n" +
47 49 "for a in \"$@\"; do\n" +
48 50 " case \"$a\" in\n" +
49 51 " *git-upload-pack*) exec git-upload-pack \"$VOLE_TEST_REPO\" ;;\n" +
@@ -56,6 +58,7 @@ func TestPrepareWorktreeSeparatesTrustedCacheFromJobTree(t *testing.T) {
56 58 t.Setenv("GIT_SSH_COMMAND", ssh)
57 59 t.Setenv("GIT_SSH_VARIANT", "ssh")
58 60 t.Setenv("VOLE_TEST_REPO", remote)
61 t.Setenv("VOLE_TEST_SSH_LOG", calls)
59 62
60 63 co, err := prepareWorktree("ssh://git@example.invalid", cache, "demo", sha, "run1", 1)
61 64 if err != nil {
@@ -91,6 +94,47 @@ func TestPrepareWorktreeSeparatesTrustedCacheFromJobTree(t *testing.T) {
91 94 if strings.TrimSpace(out) != "" {
92 95 t.Fatalf("temporary job refs were left in the cache:\n%s", out)
93 96 }
97
98 // Nothing reads the cache's working tree, so the first clone does not write one:
99 // that disk is spent on the job tree instead.
100 if _, err := os.Stat(filepath.Join(cache, "demo", "file")); !os.IsNotExist(err) {
101 t.Fatalf("the trusted cache has a working tree: %v", err)
102 }
103 if _, err := os.Stat(filepath.Join(cache, "demo", lastUsedStamp)); err != nil {
104 t.Fatalf("the cache does not record that a job used it: %v", err)
105 }
106
107 // A revision the cache already holds costs one fetch for the branches, and no
108 // download of history: that is what keeping the clone is for, and the branch
109 // fetch is what keeps the job's origin/* refs honest.
110 sentinel := filepath.Join(cache, "demo", ".vole-test-sentinel")
111 if err := os.WriteFile(sentinel, []byte("kept\n"), 0o644); err != nil {
112 t.Fatal(err)
113 }
114 before := sshCalls(t, calls)
115 again, err := prepareWorktree("ssh://git@example.invalid", cache, "demo", sha, "run2", 1)
116 if err != nil {
117 t.Fatalf("prepareWorktree for a cached revision: %v", err)
118 }
119 defer again.cleanup()
120 if got := sshCalls(t, calls); got != before+1 {
121 t.Fatalf("a cached revision made %d SSH call(s), want one branch fetch", got-before)
122 }
123 if _, err := os.Stat(sentinel); err != nil {
124 t.Fatalf("the cache was rebuilt instead of reused: %v", err)
125 }
126 if got := strings.TrimSpace(mustGit(t, again.Work, "rev-parse", "HEAD")); got != sha {
127 t.Fatalf("the second job tree is at %s, want %s", got, sha)
128 }
129 }
130
131 func sshCalls(t *testing.T, path string) int {
132 t.Helper()
133 b, err := os.ReadFile(path)
134 if err != nil && !os.IsNotExist(err) {
135 t.Fatal(err)
136 }
137 return strings.Count(string(b), "\n")
94 138 }
95 139
96 140 func mustGit(t *testing.T, dir string, args ...string) string {