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}