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 { | |