krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

main: e2e/merge_strategies_test.go · raw

  1package e2e
  2
  3import (
  4	"os"
  5	"path/filepath"
  6	"strings"
  7	"testing"
  8)
  9
 10func TestSquashAndRebaseMerges(t *testing.T) {
 11	inst := startInstance(t)
 12	aliceKey := inst.newKey(t, "alice")
 13	bobKey := inst.newKey(t, "bob")
 14	inst.admin(t, "admin", "user", "create", "alice",
 15		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
 16	inst.admin(t, "admin", "user", "create", "bob",
 17		"--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
 18
 19	// Repo with bob granted write; bob authors branches, alice merges.
 20	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/lib"); code != 0 {
 21		t.Fatalf("repo create: %s", errOut)
 22	}
 23	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/lib", "bob", "write"); code != 0 {
 24		t.Fatal("grant failed")
 25	}
 26
 27	aliceEnv := inst.gitEnv(aliceKey)
 28	bobEnv := inst.gitEnv(bobKey)
 29	work := t.TempDir()
 30	mustGit(t, work, aliceEnv, "clone", inst.sshURL("alice/lib"), "w")
 31	dir := filepath.Join(work, "w")
 32	os.WriteFile(filepath.Join(dir, "base.txt"), []byte("base\n"), 0o644)
 33	mustGit(t, dir, aliceEnv, "checkout", "-q", "-b", "main")
 34	mustGit(t, dir, aliceEnv, "add", ".")
 35	mustGit(t, dir, aliceEnv, "commit", "-q", "-m", "base")
 36	mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main")
 37
 38	// --- squash: two bob commits, diverged target -> one new commit ---
 39	bobWork := t.TempDir()
 40	mustGit(t, bobWork, bobEnv, "clone", inst.sshURL("alice/lib"), "w")
 41	bobDir := filepath.Join(bobWork, "w")
 42	mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "feat1", "origin/main")
 43	os.WriteFile(filepath.Join(bobDir, "a.txt"), []byte("a\n"), 0o644)
 44	mustGit(t, bobDir, bobEnv, "add", ".")
 45	mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "wip 1")
 46	os.WriteFile(filepath.Join(bobDir, "b.txt"), []byte("b\n"), 0o644)
 47	mustGit(t, bobDir, bobEnv, "add", ".")
 48	mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "wip 2")
 49	mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "feat1")
 50	if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/lib",
 51		"--source", "feat1", "--target", "main", "--title", "'squash me'", "--body", "'two wips'"); code != 0 {
 52		t.Fatalf("mr create: %s", errOut)
 53	}
 54	// Target advances so ff is impossible.
 55	mustGit(t, dir, aliceEnv, "commit", "-q", "--allow-empty", "-m", "mainline")
 56	mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main")
 57
 58	before := strings.TrimSpace(mustGit(t, dir, aliceEnv, "rev-parse", "origin/main"))
 59	out, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "1", "--strategy", "squash", "--json")
 60	if code != 0 {
 61		t.Fatalf("squash merge: %s", errOut)
 62	}
 63	if !strings.Contains(out, `"strategy":"squash"`) {
 64		t.Fatalf("squash output: %s", out)
 65	}
 66	mustGit(t, dir, aliceEnv, "fetch", "-q", "origin")
 67	// Exactly one commit landed on top of the old tip.
 68	count := strings.TrimSpace(mustGit(t, dir, aliceEnv, "rev-list", "--count", before+"..origin/main"))
 69	if count != "1" {
 70		t.Fatalf("squash added %s commits, want 1", count)
 71	}
 72	// Single parent, author = MR author (bob), committer = merger (alice).
 73	ident := strings.TrimSpace(mustGit(t, dir, aliceEnv, "log", "-1",
 74		"--format=%an <%ae>|%cn <%ce>|%p|%s", "origin/main"))
 75	parts := strings.Split(ident, "|")
 76	if parts[0] != "bob <bob@example.test>" || parts[1] != "alice <alice@example.test>" {
 77		t.Fatalf("squash identities: %s", ident)
 78	}
 79	if strings.Contains(parts[2], " ") {
 80		t.Fatalf("squash commit has multiple parents: %s", ident)
 81	}
 82	if parts[3] != "squash me (!1)" {
 83		t.Fatalf("squash subject: %s", ident)
 84	}
 85	// Both files present.
 86	mustGit(t, dir, aliceEnv, "checkout", "-q", "main")
 87	mustGit(t, dir, aliceEnv, "pull", "-q", "origin", "main")
 88	for _, f := range []string{"a.txt", "b.txt"} {
 89		if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
 90			t.Fatalf("squashed content missing %s", f)
 91		}
 92	}
 93
 94	// --- rebase: two commits replayed onto a diverged target ---
 95	mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "feat2", "origin/main")
 96	mustGit(t, bobDir, bobEnv, "fetch", "-q", "origin")
 97	mustGit(t, bobDir, bobEnv, "reset", "-q", "--hard", "origin/main")
 98	os.WriteFile(filepath.Join(bobDir, "c.txt"), []byte("c\n"), 0o644)
 99	mustGit(t, bobDir, bobEnv, "add", ".")
100	mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "step one")
101	os.WriteFile(filepath.Join(bobDir, "d.txt"), []byte("d\n"), 0o644)
102	mustGit(t, bobDir, bobEnv, "add", ".")
103	mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "step two")
104	mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "feat2")
105	if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/lib",
106		"--source", "feat2", "--target", "main", "--title", "'rebase me'"); code != 0 {
107		t.Fatalf("mr2 create: %s", errOut)
108	}
109	mustGit(t, dir, aliceEnv, "commit", "-q", "--allow-empty", "-m", "mainline again")
110	mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main")
111
112	before = strings.TrimSpace(mustGit(t, dir, aliceEnv, "rev-parse", "origin/main"))
113	out, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "2", "--strategy", "rebase", "--json")
114	if code != 0 {
115		t.Fatalf("rebase merge: %s", errOut)
116	}
117	mustGit(t, dir, aliceEnv, "fetch", "-q", "origin")
118	// Two commits, linear (no merges), authors preserved, committer alice.
119	count = strings.TrimSpace(mustGit(t, dir, aliceEnv, "rev-list", "--count", before+"..origin/main"))
120	if count != "2" {
121		t.Fatalf("rebase added %s commits, want 2", count)
122	}
123	merges := strings.TrimSpace(mustGit(t, dir, aliceEnv, "rev-list", "--merges", "--count", before+"..origin/main"))
124	if merges != "0" {
125		t.Fatal("rebase produced a merge commit")
126	}
127	logOut := mustGit(t, dir, aliceEnv, "log", "--format=%ae|%ce|%s", before+"..origin/main")
128	for _, line := range strings.Split(strings.TrimSpace(logOut), "\n") {
129		p := strings.Split(line, "|")
130		if p[0] != "t@example.test" || p[1] != "alice@example.test" {
131			t.Fatalf("rebase identities: %s", line)
132		}
133	}
134	if !strings.Contains(logOut, "step one") || !strings.Contains(logOut, "step two") {
135		t.Fatalf("rebase messages: %s", logOut)
136	}
137
138	// --- rebase refuses merge commits in the source ---
139	mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "feat3")
140	mustGit(t, bobDir, bobEnv, "fetch", "-q", "origin")
141	mustGit(t, bobDir, bobEnv, "reset", "-q", "--hard", "origin/main")
142	mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "side")
143	os.WriteFile(filepath.Join(bobDir, "e.txt"), []byte("e\n"), 0o644)
144	mustGit(t, bobDir, bobEnv, "add", ".")
145	mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "side work")
146	mustGit(t, bobDir, bobEnv, "checkout", "-q", "feat3")
147	os.WriteFile(filepath.Join(bobDir, "f.txt"), []byte("f\n"), 0o644)
148	mustGit(t, bobDir, bobEnv, "add", ".")
149	mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "main work")
150	mustGit(t, bobDir, bobEnv, "merge", "-q", "--no-ff", "-m", "internal merge", "side")
151	mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "feat3")
152	if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/lib",
153		"--source", "feat3", "--target", "main", "--title", "'has a merge'"); code != 0 {
154		t.Fatalf("mr3 create: %s", errOut)
155	}
156	mustGit(t, dir, aliceEnv, "fetch", "-q", "origin")
157	mustGit(t, dir, aliceEnv, "reset", "-q", "--hard", "origin/main")
158	mustGit(t, dir, aliceEnv, "commit", "-q", "--allow-empty", "-m", "diverge again")
159	mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main")
160	_, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "3", "--strategy", "rebase")
161	if code != 2 || !strings.Contains(errOut, "linear history") {
162		t.Fatalf("rebase with merge commit: exit %d, %s", code, errOut)
163	}
164
165	// --- require_signed_commits refuses squash outright ---
166	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-signed", "alice/lib", "on"); code != 0 {
167		t.Fatal("require-signed failed")
168	}
169	_, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "3", "--strategy", "squash")
170	if code != 4 || !strings.Contains(errOut, "only fast-forward") {
171		t.Fatalf("squash on require-signed: exit %d, %s", code, errOut)
172	}
173	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-signed", "alice/lib", "off"); code != 0 {
174		t.Fatal("require-signed off failed")
175	}
176
177	// --- rebase when ff is possible IS a fast-forward: shas preserved ---
178	mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "feat4")
179	mustGit(t, bobDir, bobEnv, "fetch", "-q", "origin")
180	mustGit(t, bobDir, bobEnv, "reset", "-q", "--hard", "origin/main")
181	os.WriteFile(filepath.Join(bobDir, "g.txt"), []byte("g\n"), 0o644)
182	mustGit(t, bobDir, bobEnv, "add", ".")
183	mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "clean on top")
184	tip := strings.TrimSpace(mustGit(t, bobDir, bobEnv, "rev-parse", "HEAD"))
185	mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "feat4")
186	if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/lib",
187		"--source", "feat4", "--target", "main", "--title", "'ff-able'"); code != 0 {
188		t.Fatalf("mr4 create: %s", errOut)
189	}
190	out, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "4", "--strategy", "rebase", "--json")
191	if code != 0 {
192		t.Fatalf("ff-able rebase: %s", errOut)
193	}
194	if !strings.Contains(out, `"strategy":"ff"`) || !strings.Contains(out, tip) {
195		t.Fatalf("ff-able rebase should fast-forward to %s: %s", tip, out)
196	}
197}