返回 DeepSeek-Reasonix
merge_exact_tree_test.go
根目录 / internal / worktree / merge_exact_tree_test.go
1 package worktree
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "runtime"
8 "strings"
9 "testing"
10 )
11
12 func TestMergeBackUsesExactPreparedTreeWithoutHooks(t *testing.T) {
13 requireGit(t)
14 if runtime.GOOS == "windows" {
15 t.Skip("executable Git hook permissions are not portable to Windows")
16 }
17 repo := initRepo(t)
18 managed := t.TempDir()
19 created, err := Create(context.Background(), repo, managed)
20 if err != nil {
21 t.Fatal(err)
22 }
23 gitCommitFile(t, created.WorktreeRoot, "feature.txt", "feature\n", "feature")
24 inspection := inspectMergeTest(t, created.WorkspaceRoot, managed)
25 expectedTree, conflicts, _, err := mergeTree(context.Background(), repo, inspection.TargetHead, inspection.WorktreeHead)
26 if err != nil || conflicts {
27 t.Fatalf("expected merge tree = %q, conflicts=%v, err=%v", expectedTree, conflicts, err)
28 }
29
30 hookPath := gitTest(t, repo, "rev-parse", "--git-path", "hooks/pre-commit")
31 if !filepath.IsAbs(hookPath) {
32 hookPath = filepath.Join(repo, hookPath)
33 }
34 if err := os.MkdirAll(filepath.Dir(hookPath), 0o755); err != nil {
35 t.Fatal(err)
36 }
37 if err := os.WriteFile(hookPath, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
38 t.Fatal(err)
39 }
40
41 result, err := MergeBack(context.Background(), managed, requestFromInspection(inspection))
42 if err != nil || !result.Merged {
43 t.Fatalf("exact-tree merge = %+v, %v", result, err)
44 }
45 if got := gitTest(t, repo, "rev-parse", "HEAD^{tree}"); got != expectedTree {
46 t.Fatalf("merge commit tree = %s, want %s", got, expectedTree)
47 }
48 parents := strings.Fields(gitTest(t, repo, "rev-list", "--parents", "-n", "1", result.MergedCommit))
49 if len(parents) != 3 || parents[1] != inspection.TargetHead || parents[2] != inspection.WorktreeHead {
50 t.Fatalf("merge parents = %v", parents)
51 }
52 }
53
54 func TestMergeBackDoesNotRequireUserGitIdentity(t *testing.T) {
55 requireGit(t)
56 repo := initRepo(t)
57 managed := t.TempDir()
58 created, err := Create(context.Background(), repo, managed)
59 if err != nil {
60 t.Fatal(err)
61 }
62 gitCommitFile(t, created.WorktreeRoot, "feature.txt", "feature\n", "feature")
63 gitTest(t, repo, "config", "user.useConfigOnly", "true")
64 gitTest(t, repo, "config", "user.name", "")
65 gitTest(t, repo, "config", "user.email", "")
66 inspection := inspectMergeTest(t, created.WorktreeRoot, managed)
67
68 result, err := MergeBack(context.Background(), managed, requestFromInspection(inspection))
69 if err != nil || !result.Merged || result.RecoveryRequired {
70 t.Fatalf("identity-free merge = %+v, %v", result, err)
71 }
72 }
73
74 func TestMergeBackFailsClosedWhenSourceMutationFenceIsBusy(t *testing.T) {
75 requireGit(t)
76 repo := initRepo(t)
77 managed := t.TempDir()
78 created, err := Create(context.Background(), repo, managed)
79 if err != nil {
80 t.Fatal(err)
81 }
82 gitCommitFile(t, created.WorktreeRoot, "feature.txt", "feature\n", "feature")
83 inspection := inspectMergeTest(t, created.WorktreeRoot, managed)
84 indexPath := gitTest(t, repo, "rev-parse", "--git-path", "index")
85 if !filepath.IsAbs(indexPath) {
86 indexPath = filepath.Join(repo, indexPath)
87 }
88 lockPath := indexPath + ".lock"
89 mergeStepHook = func(step string) {
90 if step == "before_merge_ref_update" {
91 if err := os.WriteFile(lockPath, []byte("held\n"), 0o600); err != nil {
92 t.Fatal(err)
93 }
94 }
95 }
96 t.Cleanup(func() {
97 mergeStepHook = nil
98 _ = os.Remove(lockPath)
99 })
100
101 result, err := MergeBack(context.Background(), managed, requestFromInspection(inspection))
102 if err == nil || result.Merged || !result.RecoveryRequired || !strings.Contains(result.Error, "mutation fence") {
103 t.Fatalf("busy source fence = %+v, %v", result, err)
104 }
105 if got := gitTest(t, repo, "rev-parse", "refs/heads/"+inspection.TargetBranch); got != inspection.TargetHead {
106 t.Fatalf("target advanced while source fence was busy: %s", got)
107 }
108 }
109
110 func TestMergeBackRejectsSameHeadSourceBranchSwitchBeforeCAS(t *testing.T) {
111 requireGit(t)
112 repo := initRepo(t)
113 managed := t.TempDir()
114 created, err := Create(context.Background(), repo, managed)
115 if err != nil {
116 t.Fatal(err)
117 }
118 gitCommitFile(t, created.WorktreeRoot, "feature.txt", "feature\n", "feature")
119 inspection := inspectMergeTest(t, created.WorktreeRoot, managed)
120 mergeStepHook = func(step string) {
121 if step == "before_merge_ref_update" {
122 gitTest(t, repo, "update-ref", "refs/heads/same-head-external", inspection.TargetHead)
123 gitTest(t, repo, "symbolic-ref", "HEAD", "refs/heads/same-head-external")
124 }
125 }
126 t.Cleanup(func() { mergeStepHook = nil })
127
128 result, err := MergeBack(context.Background(), managed, requestFromInspection(inspection))
129 if err == nil || result.Merged || !result.RecoveryRequired || !strings.Contains(result.Error, "source changed") {
130 t.Fatalf("same-head source switch = %+v, %v", result, err)
131 }
132 if got := gitTest(t, repo, "rev-parse", "refs/heads/"+inspection.TargetBranch); got != inspection.TargetHead {
133 t.Fatalf("target advanced after source branch switch: %s", got)
134 }
135 if got := gitTest(t, repo, "branch", "--show-current"); got != "same-head-external" {
136 t.Fatalf("external source branch was not preserved: %s", got)
137 }
138 }
139
140 func TestMergeBackFencesSourceHeadDuringRefTransaction(t *testing.T) {
141 requireGit(t)
142 repo := initRepo(t)
143 managed := t.TempDir()
144 created, err := Create(context.Background(), repo, managed)
145 if err != nil {
146 t.Fatal(err)
147 }
148 gitCommitFile(t, created.WorktreeRoot, "feature.txt", "feature\n", "feature")
149 inspection := inspectMergeTest(t, created.WorktreeRoot, managed)
150 attempted := false
151 mergeStepHook = func(step string) {
152 if step != "before_merge_ref_transaction" {
153 return
154 }
155 attempted = true
156 gitTest(t, repo, "update-ref", "refs/heads/same-head-external", inspection.TargetHead)
157 if _, _, switchErr := runGit(context.Background(), repo, "symbolic-ref", "HEAD", "refs/heads/same-head-external"); switchErr == nil {
158 t.Fatal("source HEAD changed while the mutation fence was held")
159 }
160 }
161 t.Cleanup(func() { mergeStepHook = nil })
162
163 result, err := MergeBack(context.Background(), managed, requestFromInspection(inspection))
164 if err != nil || !result.Merged || result.RecoveryRequired {
165 t.Fatalf("HEAD-fenced merge = %+v, %v", result, err)
166 }
167 if !attempted {
168 t.Fatal("source branch switch was not attempted at the ref transaction boundary")
169 }
170 if got := gitTest(t, repo, "branch", "--show-current"); got != inspection.TargetBranch {
171 t.Fatalf("source branch changed despite the HEAD fence: %s", got)
172 }
173 }
174
175 func TestMergeBackRejectsIndexMutationBeforeRefUpdate(t *testing.T) {
176 requireGit(t)
177 repo := initRepo(t)
178 managed := t.TempDir()
179 created, err := Create(context.Background(), repo, managed)
180 if err != nil {
181 t.Fatal(err)
182 }
183 gitCommitFile(t, created.WorktreeRoot, "feature.txt", "feature\n", "feature")
184 inspection := inspectMergeTest(t, created.WorkspaceRoot, managed)
185 latePath := filepath.Join(repo, "late.txt")
186 mergeStepHook = func(step string) {
187 if step != "after_merge_commit_object" {
188 return
189 }
190 if err := os.WriteFile(latePath, []byte("preserve me\n"), 0o644); err != nil {
191 t.Fatal(err)
192 }
193 gitTest(t, repo, "add", "late.txt")
194 }
195 t.Cleanup(func() { mergeStepHook = nil })
196
197 result, err := MergeBack(context.Background(), managed, requestFromInspection(inspection))
198 if err == nil || result.Merged || !result.RecoveryRequired || !strings.Contains(result.Error, "source changed before target ref update") {
199 t.Fatalf("index mutation = %+v, %v", result, err)
200 }
201 if got := gitTest(t, repo, "rev-parse", "refs/heads/"+inspection.TargetBranch); got != inspection.TargetHead {
202 t.Fatalf("target ref advanced to %s", got)
203 }
204 if body, readErr := os.ReadFile(latePath); readErr != nil || string(body) != "preserve me\n" {
205 t.Fatalf("late content was not preserved: %q, %v", body, readErr)
206 }
207 }
208
209 func TestMergeBackRefCompareAndSwapPreservesExternalAdvance(t *testing.T) {
210 requireGit(t)
211 repo := initRepo(t)
212 managed := t.TempDir()
213 created, err := Create(context.Background(), repo, managed)
214 if err != nil {
215 t.Fatal(err)
216 }
217 gitCommitFile(t, created.WorktreeRoot, "feature.txt", "feature\n", "feature")
218 inspection := inspectMergeTest(t, created.WorkspaceRoot, managed)
219 externalHead := ""
220 mergeStepHook = func(step string) {
221 if step != "before_merge_ref_update" {
222 return
223 }
224 tree := gitTest(t, repo, "rev-parse", inspection.TargetHead+"^{tree}")
225 externalHead = gitTest(t, repo, "commit-tree", tree, "-p", inspection.TargetHead, "-m", "external advance")
226 gitTest(t, repo, "update-ref", "refs/heads/"+inspection.TargetBranch, externalHead, inspection.TargetHead)
227 }
228 t.Cleanup(func() { mergeStepHook = nil })
229
230 result, err := MergeBack(context.Background(), managed, requestFromInspection(inspection))
231 if err == nil || result.Merged || !result.RecoveryRequired || !strings.Contains(result.Error, "compare-and-swap") {
232 t.Fatalf("ref drift = %+v, %v", result, err)
233 }
234 if got := gitTest(t, repo, "rev-parse", "refs/heads/"+inspection.TargetBranch); got != externalHead {
235 t.Fatalf("external ref was overwritten: got %s, want %s", got, externalHead)
236 }
237 }
238
239 func TestMergeBackPreservesChangedMergeHeadAfterRefUpdate(t *testing.T) {
240 requireGit(t)
241 repo := initRepo(t)
242 managed := t.TempDir()
243 created, err := Create(context.Background(), repo, managed)
244 if err != nil {
245 t.Fatal(err)
246 }
247 gitCommitFile(t, created.WorktreeRoot, "feature.txt", "feature\n", "feature")
248 inspection := inspectMergeTest(t, created.WorkspaceRoot, managed)
249 mergeStepHook = func(step string) {
250 if step == "after_merge_ref_update" {
251 mergeHeadPath := gitTest(t, repo, "rev-parse", "--git-path", "MERGE_HEAD")
252 if !filepath.IsAbs(mergeHeadPath) {
253 mergeHeadPath = filepath.Join(repo, mergeHeadPath)
254 }
255 if err := os.WriteFile(mergeHeadPath, []byte(inspection.TargetHead+"\n"), 0600); err != nil {
256 t.Fatal(err)
257 }
258 }
259 }
260 t.Cleanup(func() { mergeStepHook = nil })
261
262 result, err := MergeBack(context.Background(), managed, requestFromInspection(inspection))
263 if err == nil || result.Merged || !result.RecoveryRequired || !strings.Contains(result.Error, "MERGE_HEAD changed") {
264 t.Fatalf("MERGE_HEAD drift = %+v, %v", result, err)
265 }
266 if got := gitTest(t, repo, "rev-parse", "MERGE_HEAD"); got != inspection.TargetHead {
267 t.Fatalf("changed MERGE_HEAD was removed: got %s", got)
268 }
269 parents := strings.Fields(gitTest(t, repo, "rev-list", "--parents", "-n", "1", "refs/heads/"+inspection.TargetBranch))
270 if len(parents) != 3 || parents[1] != inspection.TargetHead || parents[2] != inspection.WorktreeHead {
271 t.Fatalf("installed exact merge parents = %v", parents)
272 }
273 }
274
275 func TestMergeBackRejectsWorktreeAdvanceBeforePrepare(t *testing.T) {
276 requireGit(t)
277 repo := initRepo(t)
278 managed := t.TempDir()
279 created, err := Create(context.Background(), repo, managed)
280 if err != nil {
281 t.Fatal(err)
282 }
283 gitCommitFile(t, created.WorktreeRoot, "feature.txt", "feature\n", "feature")
284 inspection := inspectMergeTest(t, created.WorkspaceRoot, managed)
285 externalHead := ""
286 mergeStepHook = func(step string) {
287 if step == "before_merge_prepare" {
288 gitCommitFile(t, created.WorktreeRoot, "late.txt", "late\n", "late worktree commit")
289 externalHead = gitTest(t, created.WorktreeRoot, "rev-parse", "HEAD")
290 }
291 }
292 t.Cleanup(func() { mergeStepHook = nil })
293
294 result, err := MergeBack(context.Background(), managed, requestFromInspection(inspection))
295 if err == nil || result.Merged || result.RecoveryRequired || !strings.Contains(result.Error, "worktree changed before merge preparation") {
296 t.Fatalf("worktree advance before prepare = %+v, %v", result, err)
297 }
298 if got := gitTest(t, repo, "rev-parse", "HEAD"); got != inspection.TargetHead {
299 t.Fatalf("target advanced to %s", got)
300 }
301 if got := gitTest(t, created.WorktreeRoot, "rev-parse", "HEAD"); got != externalHead {
302 t.Fatalf("external worktree commit was not preserved: got %s, want %s", got, externalHead)
303 }
304 }
305
306 func TestMergeBackRejectsWorktreeContentDriftBeforePrepare(t *testing.T) {
307 requireGit(t)
308 repo := initRepo(t)
309 managed := t.TempDir()
310 created, err := Create(context.Background(), repo, managed)
311 if err != nil {
312 t.Fatal(err)
313 }
314 gitCommitFile(t, created.WorktreeRoot, "feature.txt", "feature\n", "feature")
315 inspection := inspectMergeTest(t, created.WorkspaceRoot, managed)
316 latePath := filepath.Join(created.WorktreeRoot, "late.txt")
317 mergeStepHook = func(step string) {
318 if step == "before_merge_prepare" {
319 if err := os.WriteFile(latePath, []byte("preserve me\n"), 0o644); err != nil {
320 t.Fatal(err)
321 }
322 }
323 }
324 t.Cleanup(func() { mergeStepHook = nil })
325
326 result, err := MergeBack(context.Background(), managed, requestFromInspection(inspection))
327 if err == nil || result.Merged || result.RecoveryRequired || !strings.Contains(result.Error, "contents changed") {
328 t.Fatalf("worktree content drift before prepare = %+v, %v", result, err)
329 }
330 if got := gitTest(t, repo, "rev-parse", "HEAD"); got != inspection.TargetHead {
331 t.Fatalf("target advanced to %s", got)
332 }
333 if body, readErr := os.ReadFile(latePath); readErr != nil || string(body) != "preserve me\n" {
334 t.Fatalf("late worktree content was not preserved: %q, %v", body, readErr)
335 }
336 }
337
338 func TestMergeBackRejectsWorktreeBranchSwitchBeforePrepare(t *testing.T) {
339 requireGit(t)
340 repo := initRepo(t)
341 managed := t.TempDir()
342 created, err := Create(context.Background(), repo, managed)
343 if err != nil {
344 t.Fatal(err)
345 }
346 gitCommitFile(t, created.WorktreeRoot, "feature.txt", "feature\n", "feature")
347 inspection := inspectMergeTest(t, created.WorkspaceRoot, managed)
348 mergeStepHook = func(step string) {
349 if step == "before_merge_prepare" {
350 gitTest(t, created.WorktreeRoot, "switch", "-c", "external-worktree-branch")
351 }
352 }
353 t.Cleanup(func() { mergeStepHook = nil })
354
355 result, err := MergeBack(context.Background(), managed, requestFromInspection(inspection))
356 if err == nil || result.Merged || result.RecoveryRequired || !strings.Contains(result.Error, "worktree branch") {
357 t.Fatalf("worktree branch switch = %+v, %v", result, err)
358 }
359 if got := gitTest(t, repo, "rev-parse", "HEAD"); got != inspection.TargetHead {
360 t.Fatalf("target advanced to %s", got)
361 }
362 if got := gitTest(t, created.WorktreeRoot, "symbolic-ref", "--short", "HEAD"); got != "external-worktree-branch" {
363 t.Fatalf("external worktree branch was not preserved: %s", got)
364 }
365 }
366
367 func TestMergeBackRefTransactionRejectsWorktreeAdvanceAtomically(t *testing.T) {
368 requireGit(t)
369 repo := initRepo(t)
370 managed := t.TempDir()
371 created, err := Create(context.Background(), repo, managed)
372 if err != nil {
373 t.Fatal(err)
374 }
375 gitCommitFile(t, created.WorktreeRoot, "feature.txt", "feature\n", "feature")
376 inspection := inspectMergeTest(t, created.WorkspaceRoot, managed)
377 externalHead := ""
378 mergeStepHook = func(step string) {
379 if step == "before_merge_ref_transaction" {
380 gitCommitFile(t, created.WorktreeRoot, "late.txt", "late\n", "late worktree commit")
381 externalHead = gitTest(t, created.WorktreeRoot, "rev-parse", "HEAD")
382 }
383 }
384 t.Cleanup(func() { mergeStepHook = nil })
385
386 result, err := MergeBack(context.Background(), managed, requestFromInspection(inspection))
387 if err == nil || result.Merged || result.RecoveryRequired {
388 t.Fatalf("atomic worktree ref drift = %+v, %v", result, err)
389 }
390 if got := gitTest(t, repo, "rev-parse", "HEAD"); got != inspection.TargetHead {
391 t.Fatalf("target transaction partially updated ref to %s", got)
392 }
393 if operation, operationErr := gitOperation(context.Background(), repo); operationErr != nil || operation != "" {
394 t.Fatalf("source merge state was not aborted: operation=%q, err=%v", operation, operationErr)
395 }
396 if got := gitTest(t, created.WorktreeRoot, "rev-parse", "HEAD"); got != externalHead {
397 t.Fatalf("external worktree commit was not preserved: got %s, want %s", got, externalHead)
398 }
399 }
400
401 func TestMergeBackReportsRecoveryWhenWorktreeAdvancesAfterRefTransaction(t *testing.T) {
402 requireGit(t)
403 repo := initRepo(t)
404 managed := t.TempDir()
405 created, err := Create(context.Background(), repo, managed)
406 if err != nil {
407 t.Fatal(err)
408 }
409 gitCommitFile(t, created.WorktreeRoot, "feature.txt", "feature\n", "feature")
410 inspection := inspectMergeTest(t, created.WorkspaceRoot, managed)
411 externalHead := ""
412 mergeStepHook = func(step string) {
413 if step == "after_merge_ref_update" {
414 gitCommitFile(t, created.WorktreeRoot, "late.txt", "late\n", "late worktree commit")
415 externalHead = gitTest(t, created.WorktreeRoot, "rev-parse", "HEAD")
416 }
417 }
418 t.Cleanup(func() { mergeStepHook = nil })
419
420 result, err := MergeBack(context.Background(), managed, requestFromInspection(inspection))
421 if err == nil || result.Merged || !result.RecoveryRequired || !strings.Contains(result.Error, "worktree identity changed") {
422 t.Fatalf("post-CAS worktree drift = %+v, %v", result, err)
423 }
424 parents := strings.Fields(gitTest(t, repo, "rev-list", "--parents", "-n", "1", "HEAD"))
425 if len(parents) != 3 || parents[1] != inspection.TargetHead || parents[2] != inspection.WorktreeHead {
426 t.Fatalf("installed merge parents = %v", parents)
427 }
428 if got := gitTest(t, created.WorktreeRoot, "rev-parse", "HEAD"); got != externalHead {
429 t.Fatalf("post-CAS worktree commit was not preserved: got %s, want %s", got, externalHead)
430 }
431 }
432
432 lines GO