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.
|
7da9111e
Keenan Nemetz
15d ago
|
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 | |
|
b312e166
Keenan Nemetz
11d ago
|
12 | // the runner's release push, and give the workflow a disposable tree with its own |
|
7da9111e
Keenan Nemetz
15d ago
|
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") | |
|
f594f1a1
Keenan Nemetz
5d ago
|
46 | calls := filepath.Join(tmp, "ssh-calls") |
|
7da9111e
Keenan Nemetz
15d ago
|
47 | script := "#!/bin/sh\n" + |
|
f594f1a1
Keenan Nemetz
5d ago
|
48 | "echo \"$*\" >> \"$VOLE_TEST_SSH_LOG\"\n" + |
|
7da9111e
Keenan Nemetz
15d ago
|
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) | |
|
f594f1a1
Keenan Nemetz
5d ago
|
61 | t.Setenv("VOLE_TEST_SSH_LOG", calls) |
|
7da9111e
Keenan Nemetz
15d ago
|
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 | } | |
|
f594f1a1
Keenan Nemetz
5d ago
|
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") | |
|
7da9111e
Keenan Nemetz
15d ago
|
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 | } |