internal/control/build_test.go

v1.22.0
gitbay/internal/control/build_test.go history · blame · raw

607 lines · 23078 bytes

  1package control
  2
  3import (
  4	"os"
  5	"os/exec"
  6	"path/filepath"
  7	"strings"
  8	"testing"
  9	"time"
 10
 11	"gitbay.org/gitbay/internal/store"
 12)
 13
 14const testZeroSHA = "0000000000000000000000000000000000000000"
 15
 16// gitTestEnv sets up an isolated git identity so tests never touch a
 17// developer's real config.
 18func gitTestEnv() []string {
 19	return append(os.Environ(),
 20		"GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
 21		"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
 22		"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test")
 23}
 24
 25func gitRunner(t *testing.T) func(dir string, args ...string) string {
 26	t.Helper()
 27	env := gitTestEnv()
 28	return func(dir string, args ...string) string {
 29		t.Helper()
 30		cmd := exec.Command("git", args...)
 31		cmd.Dir = dir
 32		cmd.Env = env
 33		out, err := cmd.CombinedOutput()
 34		if err != nil {
 35			t.Fatalf("git %v: %v\n%s", args, err, out)
 36		}
 37		return string(out)
 38	}
 39}
 40
 41// newQueueTestRepo returns a store with one public repo (default branch
 42// "main", matching the schema default) and the uid to queue builds as.
 43func newQueueTestRepo(t *testing.T) (*store.Store, store.Repo, int64) {
 44	t.Helper()
 45	st, err := store.Open(":memory:")
 46	if err != nil {
 47		t.Fatal(err)
 48	}
 49	t.Cleanup(func() { st.Close() })
 50	if err := st.MigrateUp(); err != nil {
 51		t.Fatal(err)
 52	}
 53	uid, err := st.CreateUser("alice", false)
 54	if err != nil {
 55		t.Fatal(err)
 56	}
 57	repoID, err := st.CreateRepo("user", uid, "app", "public")
 58	if err != nil {
 59		t.Fatal(err)
 60	}
 61	repo, err := st.RepoByID(repoID)
 62	if err != nil {
 63		t.Fatal(err)
 64	}
 65	return st, repo, uid
 66}
 67
 68// A DiffFiles failure must not turn into a silent skip: when the old sha
 69// on record cannot be diffed against, every job runs regardless of what
 70// it names in paths, the same as when there is no diff base at all.
 71func TestQueueBranchBuildsFailsOpenOnDiffFailure(t *testing.T) {
 72	st, repo, uid := newQueueTestRepo(t)
 73	git := gitRunner(t)
 74
 75	root := t.TempDir()
 76
 77	// A job whose paths would exclude a docs-only change, so the test
 78	// proves something: without fail-open, the diff failure would leave
 79	// the filter unevaluated and this build would never queue.
 80	src := filepath.Join(root, "src")
 81	os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
 82	os.MkdirAll(filepath.Join(src, "docs"), 0o755)
 83	os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
 84		"jobs:\n  unit:\n    paths:\n      - src/**\n    steps:\n      - echo hi\n"), 0o644)
 85	os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
 86	git(root, "init", "-q", "-b", "main", "src")
 87	git(src, "add", ".")
 88	git(src, "commit", "-q", "-m", "base")
 89	os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x changed\n"), 0o644)
 90	git(src, "add", ".")
 91	git(src, "commit", "-q", "-m", "docs only")
 92	newSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
 93
 94	dir := RepoDir(root, repo.OwnerName, repo.Name)
 95	os.MkdirAll(filepath.Dir(dir), 0o755)
 96	git(root, "clone", "-q", "--bare", src, dir)
 97
 98	// Well-formed but names no object in this repo: git diff itself
 99	// fails, rather than the empty/all-zero short-circuit HasDiffBase
100	// already covers.
101	old := strings.Repeat("1", 40)
102
103	QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", old, newSHA, time.Now())
104
105	builds, err := st.ListBuilds(repo.ID, 10)
106	if err != nil || len(builds) != 1 {
107		t.Fatalf("builds after queue: %v %v", builds, err)
108	}
109	if builds[0].Job != "unit" || builds[0].Status != "pending" || builds[0].SHA != newSHA {
110		t.Fatalf("queued build wrong: %+v", builds[0])
111	}
112}
113
114// A new branch's first push carries no old sha, but a diff base still
115// exists: the merge base with the default branch. A push whose commits
116// only touch paths a job ignores must not queue that job, or path
117// filters never do anything on the ordinary branch-then-MR workflow.
118func TestQueueBranchBuildsNewBranchIgnoredPathSkips(t *testing.T) {
119	st, repo, uid := newQueueTestRepo(t)
120	git := gitRunner(t)
121	root := t.TempDir()
122
123	src := filepath.Join(root, "src")
124	os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
125	os.MkdirAll(filepath.Join(src, "docs"), 0o755)
126	os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
127		"jobs:\n  unit:\n    paths-ignore:\n      - docs/**\n    steps:\n      - echo hi\n"), 0o644)
128	os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
129	git(root, "init", "-q", "-b", "main", "src")
130	git(src, "add", ".")
131	git(src, "commit", "-q", "-m", "base")
132
133	git(src, "checkout", "-q", "-b", "feature")
134	os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x changed\n"), 0o644)
135	git(src, "add", ".")
136	git(src, "commit", "-q", "-m", "docs only")
137	featureSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
138
139	dir := RepoDir(root, repo.OwnerName, repo.Name)
140	os.MkdirAll(filepath.Dir(dir), 0o755)
141	git(root, "clone", "-q", "--bare", src, dir)
142
143	QueueBranchBuilds(st, root, "https://x.test", repo, uid, "feature", testZeroSHA, featureSHA, time.Now())
144
145	builds, err := st.ListBuilds(repo.ID, 10)
146	if err != nil {
147		t.Fatal(err)
148	}
149	if len(builds) != 0 {
150		t.Fatalf("docs-only push on a new branch queued a build: %+v", builds)
151	}
152}
153
154// The same new-branch push, but touching a path the job cares about:
155// the merge-base diff must still let it through.
156func TestQueueBranchBuildsNewBranchMatchedPathQueues(t *testing.T) {
157	st, repo, uid := newQueueTestRepo(t)
158	git := gitRunner(t)
159	root := t.TempDir()
160
161	src := filepath.Join(root, "src")
162	os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
163	os.MkdirAll(filepath.Join(src, "src"), 0o755)
164	os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
165		"jobs:\n  unit:\n    paths:\n      - src/**\n    steps:\n      - echo hi\n"), 0o644)
166	os.WriteFile(filepath.Join(src, "src", "x.go"), []byte("package x\n"), 0o644)
167	git(root, "init", "-q", "-b", "main", "src")
168	git(src, "add", ".")
169	git(src, "commit", "-q", "-m", "base")
170
171	git(src, "checkout", "-q", "-b", "feature")
172	os.WriteFile(filepath.Join(src, "src", "x.go"), []byte("package x\n\nvar y int\n"), 0o644)
173	git(src, "add", ".")
174	git(src, "commit", "-q", "-m", "src change")
175	featureSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
176
177	dir := RepoDir(root, repo.OwnerName, repo.Name)
178	os.MkdirAll(filepath.Dir(dir), 0o755)
179	git(root, "clone", "-q", "--bare", src, dir)
180
181	QueueBranchBuilds(st, root, "https://x.test", repo, uid, "feature", testZeroSHA, featureSHA, time.Now())
182
183	builds, err := st.ListBuilds(repo.ID, 10)
184	if err != nil || len(builds) != 1 {
185		t.Fatalf("builds after queue: %v %v", builds, err)
186	}
187	if builds[0].Job != "unit" || builds[0].SHA != featureSHA {
188		t.Fatalf("queued build wrong: %+v", builds[0])
189	}
190}
191
192// When the new branch shares no history with the default branch, the
193// merge base cannot be computed. That must fail open, same as any other
194// diff base that cannot be evaluated.
195func TestQueueBranchBuildsNewBranchFailsOpenWithoutMergeBase(t *testing.T) {
196	st, repo, uid := newQueueTestRepo(t)
197	git := gitRunner(t)
198	root := t.TempDir()
199
200	dir := RepoDir(root, repo.OwnerName, repo.Name)
201	os.MkdirAll(filepath.Dir(dir), 0o755)
202	git(root, "init", "-q", "--bare", dir)
203
204	mainSrc := filepath.Join(root, "main-src")
205	os.MkdirAll(filepath.Join(mainSrc, ".gitbay"), 0o755)
206	os.WriteFile(filepath.Join(mainSrc, ".gitbay", "ci.yml"), []byte(
207		"jobs:\n  unit:\n    paths-ignore:\n      - docs/**\n    steps:\n      - echo hi\n"), 0o644)
208	git(root, "init", "-q", "-b", "main", "main-src")
209	git(mainSrc, "add", ".")
210	git(mainSrc, "commit", "-q", "-m", "base")
211	git(mainSrc, "push", "-q", dir, "main")
212
213	// An unrelated repository: no common commit with main.
214	otherSrc := filepath.Join(root, "other-src")
215	os.MkdirAll(filepath.Join(otherSrc, ".gitbay"), 0o755)
216	os.MkdirAll(filepath.Join(otherSrc, "docs"), 0o755)
217	os.WriteFile(filepath.Join(otherSrc, ".gitbay", "ci.yml"), []byte(
218		"jobs:\n  unit:\n    paths-ignore:\n      - docs/**\n    steps:\n      - echo hi\n"), 0o644)
219	os.WriteFile(filepath.Join(otherSrc, "docs", "x.md"), []byte("# x\n"), 0o644)
220	git(root, "init", "-q", "-b", "feature", "other-src")
221	git(otherSrc, "add", ".")
222	git(otherSrc, "commit", "-q", "-m", "unrelated docs-only")
223	otherSHA := strings.TrimSpace(git(otherSrc, "rev-parse", "HEAD"))
224	git(otherSrc, "push", "-q", dir, "feature")
225
226	QueueBranchBuilds(st, root, "https://x.test", repo, uid, "feature", testZeroSHA, otherSHA, time.Now())
227
228	builds, err := st.ListBuilds(repo.ID, 10)
229	if err != nil || len(builds) != 1 {
230		t.Fatalf("expected fail-open to queue the job: %v %v", builds, err)
231	}
232}
233
234// The first push to a brand-new repository moves the default branch
235// itself with no prior commit: the merge base of the default branch
236// against its own tip is the tip, which carries no diff. That must
237// fail open rather than read as "nothing changed".
238func TestQueueBranchBuildsFreshDefaultBranchFailsOpen(t *testing.T) {
239	st, repo, uid := newQueueTestRepo(t)
240	git := gitRunner(t)
241	root := t.TempDir()
242
243	src := filepath.Join(root, "src")
244	os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
245	os.MkdirAll(filepath.Join(src, "docs"), 0o755)
246	os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
247		"jobs:\n  unit:\n    paths:\n      - src/**\n    steps:\n      - echo hi\n"), 0o644)
248	os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
249	git(root, "init", "-q", "-b", "main", "src")
250	git(src, "add", ".")
251	git(src, "commit", "-q", "-m", "initial")
252	sha := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
253
254	dir := RepoDir(root, repo.OwnerName, repo.Name)
255	os.MkdirAll(filepath.Dir(dir), 0o755)
256	git(root, "clone", "-q", "--bare", src, dir)
257
258	QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", testZeroSHA, sha, time.Now())
259
260	builds, err := st.ListBuilds(repo.ID, 10)
261	if err != nil || len(builds) != 1 {
262		t.Fatalf("expected fail-open on the repository's first commit: %v %v", builds, err)
263	}
264}
265
266// An ordinary push with a genuine old sha is unaffected by the
267// new-branch handling: filtering still works exactly as it did before.
268func TestQueueBranchBuildsOrdinaryPushStillFilters(t *testing.T) {
269	st, repo, uid := newQueueTestRepo(t)
270	git := gitRunner(t)
271	root := t.TempDir()
272
273	src := filepath.Join(root, "src")
274	os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
275	os.MkdirAll(filepath.Join(src, "docs"), 0o755)
276	os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
277		"jobs:\n  unit:\n    paths-ignore:\n      - docs/**\n    steps:\n      - echo hi\n"), 0o644)
278	os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
279	git(root, "init", "-q", "-b", "main", "src")
280	git(src, "add", ".")
281	git(src, "commit", "-q", "-m", "base")
282	oldSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
283
284	os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x changed\n"), 0o644)
285	git(src, "add", ".")
286	git(src, "commit", "-q", "-m", "docs only")
287	newSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
288
289	dir := RepoDir(root, repo.OwnerName, repo.Name)
290	os.MkdirAll(filepath.Dir(dir), 0o755)
291	git(root, "clone", "-q", "--bare", src, dir)
292
293	QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", oldSHA, newSHA, time.Now())
294
295	builds, err := st.ListBuilds(repo.ID, 10)
296	if err != nil {
297		t.Fatal(err)
298	}
299	if len(builds) != 0 {
300		t.Fatalf("docs-only push with a real old sha queued a build: %+v", builds)
301	}
302}
303
304// A job a path filter excludes records a ci/<job> status of "skipped"
305// naming the reason, rather than leaving the commit with no status for
306// that job at all (#172).
307func TestQueueBranchBuildsFilteredJobRecordsSkippedStatus(t *testing.T) {
308	st, repo, uid := newQueueTestRepo(t)
309	git := gitRunner(t)
310	root := t.TempDir()
311
312	src := filepath.Join(root, "src")
313	os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
314	os.MkdirAll(filepath.Join(src, "docs"), 0o755)
315	os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
316		"jobs:\n  unit:\n    paths-ignore:\n      - docs/**\n    steps:\n      - echo hi\n"), 0o644)
317	os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
318	git(root, "init", "-q", "-b", "main", "src")
319	git(src, "add", ".")
320	git(src, "commit", "-q", "-m", "base")
321	oldSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
322
323	os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x changed\n"), 0o644)
324	git(src, "add", ".")
325	git(src, "commit", "-q", "-m", "docs only")
326	newSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
327
328	dir := RepoDir(root, repo.OwnerName, repo.Name)
329	os.MkdirAll(filepath.Dir(dir), 0o755)
330	git(root, "clone", "-q", "--bare", src, dir)
331
332	QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", oldSHA, newSHA, time.Now())
333
334	statuses, err := st.ListCommitStatuses(repo.ID, newSHA)
335	if err != nil {
336		t.Fatal(err)
337	}
338	if len(statuses) != 1 || statuses[0].Context != "ci/unit" {
339		t.Fatalf("statuses on %s: %+v", newSHA, statuses)
340	}
341	if statuses[0].State != "skipped" {
342		t.Fatalf("filtered job status: got %q, want skipped", statuses[0].State)
343	}
344	if statuses[0].Description == "" {
345		t.Fatal("skipped status carries no reason")
346	}
347}
348
349// A tag job and a scheduled job are not push jobs at all: neither records
350// a skipped status, filtered push or not. Only the ordinary job that a
351// path filter actually excluded does.
352func TestQueueBranchBuildsTagAndScheduledJobsRecordNoSkippedStatus(t *testing.T) {
353	st, repo, uid := newQueueTestRepo(t)
354	git := gitRunner(t)
355	root := t.TempDir()
356
357	src := filepath.Join(root, "src")
358	os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
359	os.MkdirAll(filepath.Join(src, "docs"), 0o755)
360	os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(strings.Join([]string{
361		"jobs:",
362		"  unit:",
363		"    paths-ignore:",
364		"      - docs/**",
365		"    steps:",
366		"      - echo hi",
367		"  release:",
368		"    tags: 'v*'",
369		"    steps:",
370		"      - echo release",
371		"  nightly:",
372		"    schedule: '0 0 * * *'",
373		"    steps:",
374		"      - echo nightly",
375		"",
376	}, "\n")), 0o644)
377	os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
378	git(root, "init", "-q", "-b", "main", "src")
379	git(src, "add", ".")
380	git(src, "commit", "-q", "-m", "base")
381	oldSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
382
383	os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x changed\n"), 0o644)
384	git(src, "add", ".")
385	git(src, "commit", "-q", "-m", "docs only")
386	newSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
387
388	dir := RepoDir(root, repo.OwnerName, repo.Name)
389	os.MkdirAll(filepath.Dir(dir), 0o755)
390	git(root, "clone", "-q", "--bare", src, dir)
391
392	QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", oldSHA, newSHA, time.Now())
393
394	statuses, err := st.ListCommitStatuses(repo.ID, newSHA)
395	if err != nil {
396		t.Fatal(err)
397	}
398	if len(statuses) != 1 || statuses[0].Context != "ci/unit" || statuses[0].State != "skipped" {
399		t.Fatalf("statuses on %s: %+v", newSHA, statuses)
400	}
401}
402
403// A job already built for this commit on another branch is a fact about
404// the commit, not something a filter excluded: it keeps whatever status
405// that build reported (or none, if the run is still queued elsewhere) and
406// must not be overwritten with "skipped".
407func TestQueueBranchBuildsAlreadyBuiltJobRecordsNoSkippedStatus(t *testing.T) {
408	st, repo, uid := newQueueTestRepo(t)
409	git := gitRunner(t)
410	root := t.TempDir()
411
412	src := filepath.Join(root, "src")
413	os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
414	os.MkdirAll(filepath.Join(src, "docs"), 0o755)
415	os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
416		"jobs:\n  unit:\n    paths-ignore:\n      - docs/**\n    steps:\n      - echo hi\n"), 0o644)
417	os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
418	git(root, "init", "-q", "-b", "main", "src")
419	git(src, "add", ".")
420	git(src, "commit", "-q", "-m", "base")
421	oldSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
422
423	os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x changed\n"), 0o644)
424	git(src, "add", ".")
425	git(src, "commit", "-q", "-m", "docs only")
426	newSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
427
428	dir := RepoDir(root, repo.OwnerName, repo.Name)
429	os.MkdirAll(filepath.Dir(dir), 0o755)
430	git(root, "clone", "-q", "--bare", src, dir)
431
432	// The same commit already has a build for "unit" from another branch,
433	// still pending. Its filter would exclude this push too, so the only
434	// way to tell the two paths apart is that this one must record nothing.
435	if _, err := st.CreateBuild(repo.ID, "unit", newSHA, "other", `["echo hi"]`, "", "", true); err != nil {
436		t.Fatal(err)
437	}
438
439	QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", oldSHA, newSHA, time.Now())
440
441	statuses, err := st.ListCommitStatuses(repo.ID, newSHA)
442	if err != nil {
443		t.Fatal(err)
444	}
445	if len(statuses) != 0 {
446		t.Fatalf("already-built job recorded a status: %+v", statuses)
447	}
448	builds, err := st.ListBuilds(repo.ID, 10)
449	if err != nil || len(builds) != 1 {
450		t.Fatalf("expected only the pre-existing build: %v %v", builds, err)
451	}
452}
453
454// QueueMRBuilds keeps failing open with no diff base at all: deriving a
455// merge base for the MR head is deliberately out of scope here (#172 —
456// filtering a head down to zero jobs leaves it with no statuses, which
457// the require_checks gate reads as unmergeable). This test documents
458// and locks in that choice.
459func TestQueueMRBuildsStillFailsOpen(t *testing.T) {
460	st, repo, uid := newQueueTestRepo(t)
461	git := gitRunner(t)
462	root := t.TempDir()
463
464	src := filepath.Join(root, "src")
465	os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
466	os.MkdirAll(filepath.Join(src, "docs"), 0o755)
467	os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
468		"jobs:\n  unit:\n    paths-ignore:\n      - docs/**\n    steps:\n      - echo hi\n"), 0o644)
469	os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
470	git(root, "init", "-q", "-b", "main", "src")
471	git(src, "add", ".")
472	git(src, "commit", "-q", "-m", "base")
473
474	git(src, "checkout", "-q", "-b", "pr")
475	os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x changed\n"), 0o644)
476	git(src, "add", ".")
477	git(src, "commit", "-q", "-m", "docs only")
478	prSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
479
480	dir := RepoDir(root, repo.OwnerName, repo.Name)
481	os.MkdirAll(filepath.Dir(dir), 0o755)
482	git(root, "clone", "-q", "--bare", src, dir)
483	git(dir, "update-ref", "refs/merge-requests/1/head", prSHA)
484
485	QueueMRBuilds(st, root, "https://x.test", repo, uid, 1, prSHA)
486
487	builds, err := st.ListBuilds(repo.ID, 10)
488	if err != nil || len(builds) != 1 {
489		t.Fatalf("expected the MR head to fail open and queue a build: %v %v", builds, err)
490	}
491}
492
493// A force-push that rewrote the branch must not filter against the old
494// tip. After a rebase, old..new is the difference between two histories
495// — whatever the new base added — so a branch whose own commits touch
496// code looks like a docs-only push and its jobs are skipped. It then
497// reads as green without having run (#176).
498func TestQueueBranchBuildsRebaseFiltersAgainstMergeBase(t *testing.T) {
499	st, repo, uid := newQueueTestRepo(t)
500	git := gitRunner(t)
501	root := t.TempDir()
502
503	src := filepath.Join(root, "src")
504	os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
505	os.MkdirAll(filepath.Join(src, "docs"), 0o755)
506	os.MkdirAll(filepath.Join(src, "app"), 0o755)
507	os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
508		"jobs:\n  unit:\n    paths-ignore:\n      - docs/**\n    steps:\n      - echo hi\n"), 0o644)
509	os.WriteFile(filepath.Join(src, "app", "a.go"), []byte("package a\n"), 0o644)
510	git(root, "init", "-q", "-b", "main", "src")
511	git(src, "add", ".")
512	git(src, "commit", "-q", "-m", "base")
513
514	// A branch that changes code — the kind of change the filter must
515	// never skip.
516	git(src, "checkout", "-q", "-b", "feat")
517	os.WriteFile(filepath.Join(src, "app", "a.go"), []byte("package a\n\nvar X = 1\n"), 0o644)
518	git(src, "add", ".")
519	git(src, "commit", "-q", "-m", "code change")
520	oldTip := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
521
522	// main moves on with a docs-only commit, and the branch is rebased
523	// onto it — exactly what a fast-forward-only repository forces.
524	git(src, "checkout", "-q", "main")
525	os.WriteFile(filepath.Join(src, "docs", "x.md"), []byte("# x\n"), 0o644)
526	git(src, "add", ".")
527	git(src, "commit", "-q", "-m", "docs only")
528	git(src, "checkout", "-q", "feat")
529	git(src, "rebase", "-q", "main")
530	newTip := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
531
532	// The trap: between the two tips lies only the docs commit.
533	if diff := git(src, "diff", "--name-only", oldTip, newTip); !strings.Contains(diff, "docs/x.md") ||
534		strings.Contains(diff, "app/a.go") {
535		t.Fatalf("fixture does not reproduce the trap; old..new = %q", diff)
536	}
537
538	dir := RepoDir(root, repo.OwnerName, repo.Name)
539	os.MkdirAll(filepath.Dir(dir), 0o755)
540	git(root, "clone", "-q", "--bare", src, dir)
541
542	QueueBranchBuilds(st, root, "https://x.test", repo, uid, "feat", oldTip, newTip, time.Now())
543
544	builds, err := st.ListBuilds(repo.ID, 10)
545	if err != nil {
546		t.Fatal(err)
547	}
548	if len(builds) == 0 {
549		t.Fatal("a rebased branch whose commits change code queued no build")
550	}
551}
552
553// A rebase gives a commit a new sha and the same tree. A job that
554// succeeded for that tree has nothing left to prove, so the new commit
555// gets the earlier result as its status instead of a new build (#177).
556func TestQueueBranchBuildsSameTreeReusesSuccess(t *testing.T) {
557	st, repo, uid := newQueueTestRepo(t)
558	git := gitRunner(t)
559	root := t.TempDir()
560
561	src := filepath.Join(root, "src")
562	os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
563	os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
564		"jobs:\n  unit:\n    steps:\n      - echo hi\n"), 0o644)
565	git(root, "init", "-q", "-b", "main", "src")
566	git(src, "add", ".")
567	git(src, "commit", "-q", "-m", "base")
568	first := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
569	// Same tree, new sha: what a rebase onto an unrelated base produces.
570	git(src, "commit", "-q", "--allow-empty", "-m", "rewritten")
571	second := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
572	if git(src, "rev-parse", first+"^{tree}") != git(src, "rev-parse", second+"^{tree}") {
573		t.Fatal("fixture: trees differ")
574	}
575
576	dir := RepoDir(root, repo.OwnerName, repo.Name)
577	os.MkdirAll(filepath.Dir(dir), 0o755)
578	git(root, "clone", "-q", "--bare", src, dir)
579
580	QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", "", first, time.Now())
581	builds, _ := st.ListBuilds(repo.ID, 10)
582	if len(builds) != 1 {
583		t.Fatalf("first commit queued %d builds, want 1", len(builds))
584	}
585	if _, ok, err := st.ClaimBuild([]int64{repo.ID}, false); err != nil || !ok {
586		t.Fatalf("claim: ok=%v err=%v", ok, err)
587	}
588	if err := st.FinishBuild(builds[0].ID, "success"); err != nil {
589		t.Fatal(err)
590	}
591
592	QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", first, second, time.Now())
593	builds, _ = st.ListBuilds(repo.ID, 10)
594	if len(builds) != 1 {
595		t.Fatalf("same tree queued a second build: %+v", builds)
596	}
597	statuses, _ := st.ListCommitStatuses(repo.ID, second)
598	found := false
599	for _, s := range statuses {
600		if s.Context == "ci/unit" && s.State == "success" && strings.Contains(s.Description, "same tree") {
601			found = true
602		}
603	}
604	if !found {
605		t.Fatalf("second commit has no success status from the first: %+v", statuses)
606	}
607}