internal/gitutil/reachable_test.go

v1.34.0
gitbay/internal/gitutil/reachable_test.go history · blame · raw

143 lines · 5424 bytes

  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}