internal/gitutil/reachable_test.go
143 lines · 5424 bytes
7 symbols in this file
1package gitutil
2
3import (
4 "path/filepath"
5 "strings"
6 "testing"
7)
8
9func mustResolve(t *testing.T, dir, ref string) string {
10 t.Helper()
11 sha, err := ResolveRef(dir, ref)
12 if err != nil {
13 t.Fatalf("resolving %s: %v", ref, err)
14 }
15 return sha
16}
17
18// A commit still an ancestor of a branch, or a branch tip itself, is
19// reachable — the ordinary case a claimed build's sha is in.
20func TestReachableAncestorAndTip(t *testing.T) {
21 dir := t.TempDir()
22 git(t, dir, "init", "-q", "-b", "main")
23 write(t, dir, "f.txt", "one\n")
24 git(t, dir, "add", ".")
25 git(t, dir, "commit", "-q", "-m", "one")
26 first := mustResolve(t, dir, "HEAD")
27 write(t, dir, "f.txt", "two\n")
28 git(t, dir, "add", ".")
29 git(t, dir, "commit", "-q", "-m", "two")
30 tip := mustResolve(t, dir, "HEAD")
31
32 if ok, err := Reachable(dir, first); err != nil || !ok {
33 t.Fatalf("ancestor: ok=%v err=%v", ok, err)
34 }
35 if ok, err := Reachable(dir, tip); err != nil || !ok {
36 t.Fatalf("tip: ok=%v err=%v", ok, err)
37 }
38}
39
40// A force-push moves a branch off a commit, and the old commit's object
41// sticks around until the next gc: exactly what a rebase-and-force-push
42// leaves a queued build pointed at. Reachable must say false here, not
43// error — a git error would let the caller read it as "cannot tell" and
44// hand the build to a runner that fails at clone.
45func TestReachableOrphanedByForcePushNotYetPruned(t *testing.T) {
46 dir := t.TempDir()
47 git(t, dir, "init", "-q", "-b", "main")
48 write(t, dir, "f.txt", "one\n")
49 git(t, dir, "add", ".")
50 git(t, dir, "commit", "-q", "-m", "one")
51 base := mustResolve(t, dir, "HEAD")
52 write(t, dir, "f.txt", "two\n")
53 git(t, dir, "add", ".")
54 git(t, dir, "commit", "-q", "-m", "orphaned")
55 orphaned := mustResolve(t, dir, "HEAD")
56
57 git(t, dir, "update-ref", "refs/heads/main", base)
58
59 if ok, err := Reachable(dir, orphaned); err != nil || ok {
60 t.Fatalf("orphaned, object still present: ok=%v err=%v", ok, err)
61 }
62 // The rewound branch itself is untouched.
63 if ok, err := Reachable(dir, base); err != nil || !ok {
64 t.Fatalf("base after rewind: ok=%v err=%v", ok, err)
65 }
66}
67
68// Once gc has actually removed the object, the object no longer exists at
69// all. Still false, still no error: pruned is a stronger form of orphaned,
70// not a different outcome.
71func TestReachablePrunedObject(t *testing.T) {
72 dir := t.TempDir()
73 git(t, dir, "init", "-q", "-b", "main")
74 write(t, dir, "f.txt", "one\n")
75 git(t, dir, "add", ".")
76 git(t, dir, "commit", "-q", "-m", "one")
77 base := mustResolve(t, dir, "HEAD")
78 write(t, dir, "f.txt", "two\n")
79 git(t, dir, "add", ".")
80 git(t, dir, "commit", "-q", "-m", "orphaned")
81 orphaned := mustResolve(t, dir, "HEAD")
82
83 git(t, dir, "update-ref", "refs/heads/main", base)
84 git(t, dir, "reflog", "expire", "--expire=now", "--all")
85 git(t, dir, "gc", "--prune=now", "-q")
86
87 if ok, err := Reachable(dir, orphaned); err != nil || ok {
88 t.Fatalf("pruned: ok=%v err=%v", ok, err)
89 }
90}
91
92// A check that cannot run at all — no repository at the path — must not
93// come back as "unreachable": that would read as a real answer and cancel
94// a build that was never actually checked.
95func TestReachableErrorsRatherThanFalseWhenItCannotCheck(t *testing.T) {
96 dir := filepath.Join(t.TempDir(), "no-such-repo")
97 if ok, err := Reachable(dir, strings.Repeat("a", 40)); err == nil {
98 t.Fatalf("expected an error for a missing repository, got ok=%v", ok)
99 }
100}
101
102// A directory that exists but holds no git repository at all — corrupted,
103// mid-restore from a backup, or simply never initialized — must error the
104// same way: cat-file -e on a well-formed sha exits 1 only when the object
105// is genuinely absent from a real repository. Outside a repository it
106// exits 128, the same code a peeled ^{commit} lookup uses for a missing
107// object, so collapsing "any exit" to false would read a broken
108// repository as an orphaned queue and cancel every build in it.
109func TestReachableErrorsWhenDirectoryIsNotAGitRepository(t *testing.T) {
110 dir := t.TempDir() // exists, but no `git init` ever ran here
111 if ok, err := Reachable(dir, strings.Repeat("a", 40)); err == nil {
112 t.Fatalf("expected an error for a non-repository directory, got ok=%v", ok)
113 }
114}
115
116// A merge request head fetched from a fork lives at
117// refs/merge-requests/<n>/head — no branch and no tag ever points at it.
118// It must still read as reachable: it is fetched into the target
119// repository and a runner's clone reaches it exactly like a branch tip
120// does. Restricting the ancestry check to refs/heads and refs/tags (a
121// first pass of Reachable did this) would read every such build as
122// unreachable and cancel it, which is worse than the bug this whole
123// change fixes — CI silently stops running on every fork merge request.
124func TestReachableFromMergeRequestHeadRef(t *testing.T) {
125 dir := t.TempDir()
126 git(t, dir, "init", "-q", "-b", "main")
127 write(t, dir, "f.txt", "one\n")
128 git(t, dir, "add", ".")
129 git(t, dir, "commit", "-q", "-m", "base")
130
131 git(t, dir, "checkout", "-q", "-b", "fork-head")
132 write(t, dir, "f.txt", "two\n")
133 git(t, dir, "add", ".")
134 git(t, dir, "commit", "-q", "-m", "mr head")
135 mrSHA := mustResolve(t, dir, "HEAD")
136 git(t, dir, "update-ref", "refs/merge-requests/1/head", mrSHA)
137 git(t, dir, "checkout", "-q", "main")
138 git(t, dir, "branch", "-D", "fork-head")
139
140 if ok, err := Reachable(dir, mrSHA); err != nil || !ok {
141 t.Fatalf("mr head reachable only via refs/merge-requests: ok=%v err=%v", ok, err)
142 }
143}