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