vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "os/exec" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | ) |
| 10 | |
| 11 | // prepareWorktree has two jobs that must not be confused: keep a trusted cache for |
| 12 | // the runner's release push, and give the workflow a disposable tree with its own |
| 13 | // .git. The test plays the SSH transport with a script so the whole path runs |
| 14 | // without a server, including a commit that is not reachable from any branch. |
| 15 | func TestPrepareWorktreeSeparatesTrustedCacheFromJobTree(t *testing.T) { |
| 16 | tmp := t.TempDir() |
| 17 | src := filepath.Join(tmp, "src") |
| 18 | remote := filepath.Join(tmp, "demo.git") |
| 19 | cache := filepath.Join(tmp, "cache") |
| 20 | |
| 21 | mustGit(t, "", "init", "-q", src) |
| 22 | mustGit(t, src, "config", "user.email", "a@example.com") |
| 23 | mustGit(t, src, "config", "user.name", "a") |
| 24 | if err := os.WriteFile(filepath.Join(src, "file"), []byte("one\n"), 0o644); err != nil { |
| 25 | t.Fatal(err) |
| 26 | } |
| 27 | mustGit(t, src, "add", "file") |
| 28 | mustGit(t, src, "commit", "-qm", "one") |
| 29 | mustGit(t, "", "clone", "-q", "--bare", src, remote) |
| 30 | |
| 31 | // A second commit reaches the remote's object store but no ref. Fetching it by |
| 32 | // id is what the fallback in prepareWorktree has to handle. |
| 33 | if err := os.WriteFile(filepath.Join(src, "file"), []byte("two\n"), 0o644); err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | mustGit(t, src, "commit", "-qam", "two") |
| 37 | mustGit(t, src, "push", "-q", remote, "HEAD:refs/heads/tmp") |
| 38 | sha := strings.TrimSpace(mustGit(t, src, "rev-parse", "HEAD")) |
| 39 | mustGit(t, remote, "update-ref", "-d", "refs/heads/tmp") |
| 40 | mustGit(t, remote, "config", "uploadpack.allowAnySHA1InWant", "true") |
| 41 | |
| 42 | // Git talks to our script instead of ssh. The script serves the bare repository |
| 43 | // for the upload-pack request and fails the protocol probe so Git falls back to |
| 44 | // the ordinary transport. |
| 45 | ssh := filepath.Join(tmp, "ssh") |
| 46 | calls := filepath.Join(tmp, "ssh-calls") |
| 47 | script := "#!/bin/sh\n" + |
| 48 | "echo \"$*\" >> \"$VOLE_TEST_SSH_LOG\"\n" + |
| 49 | "for a in \"$@\"; do\n" + |
| 50 | " case \"$a\" in\n" + |
| 51 | " *git-upload-pack*) exec git-upload-pack \"$VOLE_TEST_REPO\" ;;\n" + |
| 52 | " esac\n" + |
| 53 | "done\n" + |
| 54 | "exit 1\n" |
| 55 | if err := os.WriteFile(ssh, []byte(script), 0o755); err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | t.Setenv("GIT_SSH_COMMAND", ssh) |
| 59 | t.Setenv("GIT_SSH_VARIANT", "ssh") |
| 60 | t.Setenv("VOLE_TEST_REPO", remote) |
| 61 | t.Setenv("VOLE_TEST_SSH_LOG", calls) |
| 62 | |
| 63 | co, err := prepareWorktree("ssh://git@example.invalid", cache, "demo", sha, "run1", 1) |
| 64 | if err != nil { |
| 65 | t.Fatalf("prepareWorktree: %v", err) |
| 66 | } |
| 67 | defer co.cleanup() |
| 68 | |
| 69 | got := strings.TrimSpace(mustGit(t, co.Work, "rev-parse", "HEAD")) |
| 70 | if got != sha { |
| 71 | t.Fatalf("job tree is at %s, want %s", got, sha) |
| 72 | } |
| 73 | if _, err := os.Stat(filepath.Join(co.Work, ".git")); err != nil { |
| 74 | t.Fatalf("the job tree has no .git: %v", err) |
| 75 | } |
| 76 | |
| 77 | // The job may edit its own Git directory. That must not reach the cache the |
| 78 | // runner later uses to push the record. |
| 79 | hook := filepath.Join(co.Work, ".git", "hooks", "pre-push") |
| 80 | if err := os.MkdirAll(filepath.Dir(hook), 0o755); err != nil { |
| 81 | t.Fatal(err) |
| 82 | } |
| 83 | if err := os.WriteFile(hook, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { |
| 84 | t.Fatal(err) |
| 85 | } |
| 86 | cacheHook := filepath.Join(cache, "demo", ".git", "hooks", "pre-push") |
| 87 | if _, err := os.Stat(cacheHook); !os.IsNotExist(err) { |
| 88 | t.Fatalf("a job hook reached the trusted cache: %v", err) |
| 89 | } |
| 90 | |
| 91 | // The temporary ref used to carry an unreachable commit must be gone too. |
| 92 | out := mustGit(t, filepath.Join(cache, "demo"), "for-each-ref", "--format=%(refname)", |
| 93 | "refs/vole/jobs") |
| 94 | if strings.TrimSpace(out) != "" { |
| 95 | t.Fatalf("temporary job refs were left in the cache:\n%s", out) |
| 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") |
| 138 | } |
| 139 | |
| 140 | func mustGit(t *testing.T, dir string, args ...string) string { |
| 141 | t.Helper() |
| 142 | cmd := exec.Command("git", args...) |
| 143 | if dir != "" { |
| 144 | cmd.Dir = dir |
| 145 | } |
| 146 | out, err := cmd.CombinedOutput() |
| 147 | if err != nil { |
| 148 | t.Fatalf("git %s: %v: %s", strings.Join(args, " "), err, out) |
| 149 | } |
| 150 | return string(out) |
| 151 | } |
| 152 |