返回 DeepSeek-Reasonix
merge_test.go
根目录 / internal / worktree / merge_test.go
1 package worktree
2
3 import (
4 "context"
5 "encoding/json"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "testing"
11 )
12
13 func TestInspectMergeUsesRecordedLinkedSource(t *testing.T) {
14 requireGit(t)
15 repo := initRepo(t)
16 linked := filepath.Join(t.TempDir(), "source-linked")
17 gitTest(t, repo, "worktree", "add", "-b", "feature/source", linked, "HEAD")
18 managed := t.TempDir()
19 created, err := Create(context.Background(), linked, managed)
20 if err != nil {
21 t.Fatal(err)
22 }
23
24 inspection, err := InspectMerge(context.Background(), created.WorkspaceRoot, managed)
25 if err != nil {
26 t.Fatal(err)
27 }
28 if err := sameDirectory(inspection.SourceRoot, linked); err != nil || inspection.TargetBranch != "feature/source" {
29 t.Fatalf("inspection source identity = %+v", inspection)
30 }
31 if inspection.ChangedFiles == nil || inspection.ConflictFiles == nil || inspection.Blockers == nil || inspection.CleanupBlockers == nil {
32 t.Fatalf("inspection arrays must encode as []: %+v", inspection)
33 }
34 }
35
36 func TestMergeBackRequiresExplicitDirtyCommitAndFinalizesSeparately(t *testing.T) {
37 requireGit(t)
38 repo := initRepo(t)
39 managed := t.TempDir()
40 created, err := Create(context.Background(), repo, managed)
41 if err != nil {
42 t.Fatal(err)
43 }
44 if err := os.WriteFile(filepath.Join(created.WorktreeRoot, "feature.go"), []byte("package feature\n"), 0o644); err != nil {
45 t.Fatal(err)
46 }
47 inspection := inspectMergeTest(t, created.WorkspaceRoot, managed)
48 request := requestFromInspection(inspection)
49 if inspection.CanMerge || !inspection.WorktreeDirty {
50 t.Fatalf("dirty inspection = %+v", inspection)
51 }
52 if result, err := MergeBack(context.Background(), managed, request); err == nil || result.Merged {
53 t.Fatalf("dirty merge without opt-in = %+v, %v", result, err)
54 }
55 request.AutoCommitDirty = true
56 result, err := MergeBack(context.Background(), managed, request)
57 if err != nil {
58 t.Fatalf("MergeBack: %v (%+v)", err, result)
59 }
60 if !result.Merged || result.MergedCommit == "" {
61 t.Fatalf("merge result = %+v", result)
62 }
63 if _, err := os.Stat(created.WorktreeRoot); err != nil {
64 t.Fatalf("merge removed worktree before finalize: %v", err)
65 }
66 if _, err := os.Stat(filepath.Join(repo, "feature.go")); err != nil {
67 t.Fatalf("merged file missing from source: %v", err)
68 }
69
70 cleanup, err := FinalizeMerge(context.Background(), managed, cleanupFromMerge(result))
71 if err != nil {
72 t.Fatalf("FinalizeMerge: %v (%+v)", err, cleanup)
73 }
74 if cleanup.Completed || cleanup.WorktreeRemoved || cleanup.BranchDeleted || !cleanup.RecoveryRetained || !cleanup.RecoveryWorktreeRegistered || !cleanup.BranchRetained {
75 t.Fatalf("cleanup result = %+v", cleanup)
76 }
77 if _, err := os.Stat(created.WorktreeRoot); !os.IsNotExist(err) {
78 t.Fatalf("former worktree path remains after finalize: %v", err)
79 }
80 if _, err := os.Stat(cleanup.RecoveryRoot); err != nil {
81 t.Fatalf("retained recovery worktree is missing: %v", err)
82 }
83 }
84
85 func TestMergeBackRejectsTargetHeadDrift(t *testing.T) {
86 requireGit(t)
87 repo := initRepo(t)
88 managed := t.TempDir()
89 created, err := Create(context.Background(), repo, managed)
90 if err != nil {
91 t.Fatal(err)
92 }
93 gitCommitFile(t, created.WorktreeRoot, "feature.txt", "feature\n", "feature")
94 inspection := inspectMergeTest(t, created.WorkspaceRoot, managed)
95 gitCommitFile(t, repo, "source.txt", "source\n", "source drift")
96
97 result, err := MergeBack(context.Background(), managed, requestFromInspection(inspection))
98 if err == nil || result.Merged || !strings.Contains(result.Error, "identity changed") {
99 t.Fatalf("drift merge = %+v, %v", result, err)
100 }
101 if _, err := os.Stat(created.WorktreeRoot); err != nil {
102 t.Fatalf("drift removed recoverable worktree: %v", err)
103 }
104 }
105
106 func TestMergeBackRejectsConfirmedWorktreeContentDrift(t *testing.T) {
107 requireGit(t)
108 repo := initRepo(t)
109 managed := t.TempDir()
110 created, err := Create(context.Background(), repo, managed)
111 if err != nil {
112 t.Fatal(err)
113 }
114 path := filepath.Join(created.WorktreeRoot, "feature.txt")
115 if err := os.WriteFile(path, []byte("confirmed\n"), 0o644); err != nil {
116 t.Fatal(err)
117 }
118 inspection := inspectMergeTest(t, created.WorkspaceRoot, managed)
119 request := requestFromInspection(inspection)
120 request.AutoCommitDirty = true
121 if err := os.WriteFile(path, []byte("changed later\n"), 0o644); err != nil {
122 t.Fatal(err)
123 }
124 result, err := MergeBack(context.Background(), managed, request)
125 if err == nil || result.Merged || !strings.Contains(result.Error, "identity changed") {
126 t.Fatalf("content drift = %+v, %v", result, err)
127 }
128 if got := gitTest(t, created.WorktreeRoot, "rev-parse", "HEAD"); got != inspection.WorktreeHead {
129 t.Fatalf("content drift created a commit: %s", got)
130 }
131 }
132
133 func TestMergeBackPreservesStagingWhenContentChangesDuringAdd(t *testing.T) {
134 requireGit(t)
135 repo := initRepo(t)
136 managed := t.TempDir()
137 created, err := Create(context.Background(), repo, managed)
138 if err != nil {
139 t.Fatal(err)
140 }
141 path := filepath.Join(created.WorktreeRoot, "feature.txt")
142 if err := os.WriteFile(path, []byte("confirmed\n"), 0o644); err != nil {
143 t.Fatal(err)
144 }
145 inspection := inspectMergeTest(t, created.WorkspaceRoot, managed)
146 request := requestFromInspection(inspection)
147 request.AutoCommitDirty = true
148 mergeStepHook = func(step string) {
149 if step == "after_worktree_add" {
150 if err := os.WriteFile(path, []byte("changed during add\n"), 0o644); err != nil {
151 t.Fatal(err)
152 }
153 }
154 }
155 t.Cleanup(func() { mergeStepHook = nil })
156 result, err := MergeBack(context.Background(), managed, request)
157 if err == nil || result.Merged || !strings.Contains(result.Error, "real index was preserved") {
158 t.Fatalf("add drift = %+v, %v", result, err)
159 }
160 status := gitTest(t, created.WorktreeRoot, "status", "--porcelain=v1")
161 if !strings.Contains(status, "?? feature.txt") {
162 t.Fatalf("real index or later worktree contents changed: %q", status)
163 }
164 }
165
166 func TestMergeBackRejectsExtraCommitAfterAutoCommit(t *testing.T) {
167 requireGit(t)
168 repo := initRepo(t)
169 managed := t.TempDir()
170 created, err := Create(context.Background(), repo, managed)
171 if err != nil {
172 t.Fatal(err)
173 }
174 if err := os.WriteFile(filepath.Join(created.WorktreeRoot, "feature.txt"), []byte("feature\n"), 0o644); err != nil {
175 t.Fatal(err)
176 }
177 inspection := inspectMergeTest(t, created.WorkspaceRoot, managed)
178 request := requestFromInspection(inspection)
179 request.AutoCommitDirty = true
180 mergeStepHook = func(step string) {
181 if step == "after_worktree_commit" {
182 gitCommitFile(t, created.WorktreeRoot, "extra.txt", "extra\n", "external extra")
183 }
184 }
185 t.Cleanup(func() { mergeStepHook = nil })
186 result, err := MergeBack(context.Background(), managed, request)
187 if err == nil || result.Merged || !strings.Contains(result.Error, "branch HEAD changed") {
188 t.Fatalf("extra auto-commit = %+v, %v", result, err)
189 }
190 }
191
192 func TestMergeBackStopsWhenSourceBranchChangesBeforePrepare(t *testing.T) {
193 requireGit(t)
194 repo := initRepo(t)
195 managed := t.TempDir()
196 created, err := Create(context.Background(), repo, managed)
197 if err != nil {
198 t.Fatal(err)
199 }
200 gitCommitFile(t, created.WorktreeRoot, "feature.txt", "feature\n", "feature")
201 inspection := inspectMergeTest(t, created.WorkspaceRoot, managed)
202 mergeStepHook = func(step string) {
203 if step == "before_merge_prepare" {
204 gitTest(t, repo, "switch", "-c", "source-drift")
205 }
206 }
207 t.Cleanup(func() { mergeStepHook = nil })
208 result, err := MergeBack(context.Background(), managed, requestFromInspection(inspection))
209 if err == nil || result.Merged || result.RecoveryRequired || !strings.Contains(result.Error, "source changed before merge preparation") {
210 t.Fatalf("source branch drift = %+v, %v", result, err)
211 }
212 }
213
214 func TestMergeBackReportsRecoveryRequiredWhenPreparedSourceDrifts(t *testing.T) {
215 requireGit(t)
216 repo := initRepo(t)
217 managed := t.TempDir()
218 created, err := Create(context.Background(), repo, managed)
219 if err != nil {
220 t.Fatal(err)
221 }
222 gitCommitFile(t, created.WorktreeRoot, "feature.txt", "feature\n", "feature")
223 inspection := inspectMergeTest(t, created.WorkspaceRoot, managed)
224 mergeStepHook = func(step string) {
225 if step == "after_merge_prepare" {
226 // Preserve the prepared index and worktree while clearing merge
227 // metadata so switching branches deterministically simulates source
228 // identity drift on every supported Git platform.
229 gitTest(t, repo, "merge", "--quit")
230 gitTest(t, repo, "switch", "-c", "prepared-drift")
231 }
232 }
233 t.Cleanup(func() { mergeStepHook = nil })
234 result, err := MergeBack(context.Background(), managed, requestFromInspection(inspection))
235 if err == nil || result.Merged || !result.RecoveryRequired {
236 t.Fatalf("prepared source drift = %+v, %v", result, err)
237 }
238 }
239
240 func TestMergeBackRechecksConflictAfterDirtyCommit(t *testing.T) {
241 requireGit(t)
242 repo := initRepo(t)
243 managed := t.TempDir()
244 created, err := Create(context.Background(), repo, managed)
245 if err != nil {
246 t.Fatal(err)
247 }
248 if err := os.WriteFile(filepath.Join(created.WorktreeRoot, "README.md"), []byte("worktree\n"), 0o644); err != nil {
249 t.Fatal(err)
250 }
251 gitCommitFile(t, repo, "README.md", "source\n", "source change")
252 inspection := inspectMergeTest(t, created.WorktreeRoot, managed)
253 request := requestFromInspection(inspection)
254 request.AutoCommitDirty = true
255 sourceHead := gitTest(t, repo, "rev-parse", "HEAD")
256
257 result, err := MergeBack(context.Background(), managed, request)
258 if err == nil || result.Merged || !strings.Contains(result.Error, "merge is blocked") {
259 t.Fatalf("post-commit conflict = %+v, %v", result, err)
260 }
261 if got := gitTest(t, repo, "rev-parse", "HEAD"); got != sourceHead {
262 t.Fatalf("source HEAD changed on preflight conflict: %s != %s", got, sourceHead)
263 }
264 postCommit := inspectMergeTest(t, created.WorktreeRoot, managed)
265 if postCommit.WorktreeDirty || !postCommit.HasConflicts {
266 t.Fatalf("post-commit inspection = %+v", postCommit)
267 }
268 }
269
270 func TestInspectMergeRejectsLegacyAndForgedMetadata(t *testing.T) {
271 requireGit(t)
272 repo := initRepo(t)
273 managed := t.TempDir()
274 created, err := Create(context.Background(), repo, managed)
275 if err != nil {
276 t.Fatal(err)
277 }
278 path := metadataPath(created.WorktreeRoot)
279 if err := os.Remove(path); err != nil {
280 t.Fatal(err)
281 }
282 inspection, err := InspectMerge(context.Background(), created.WorkspaceRoot, managed)
283 if err == nil || inspection.Available || !strings.Contains(inspection.Reason, "predates") {
284 t.Fatalf("legacy inspection = %+v, %v", inspection, err)
285 }
286
287 forged := mergeMetadata{
288 Version: mergeMetadataVersion, SourceRoot: repo, TargetBranch: gitTest(t, repo, "branch", "--show-current"),
289 CreatedHead: created.Head, WorktreeRoot: repo, WorktreeBranch: created.Branch,
290 }
291 body, _ := json.Marshal(forged)
292 if err := os.WriteFile(path, body, 0o600); err != nil {
293 t.Fatal(err)
294 }
295 inspection, err = InspectMerge(context.Background(), created.WorkspaceRoot, managed)
296 if err == nil || inspection.Available || !strings.Contains(inspection.Reason, "root mismatch") {
297 t.Fatalf("forged inspection = %+v, %v", inspection, err)
298 }
299 }
300
301 func TestInspectMergeBlocksSourceBranchAndDirtyState(t *testing.T) {
302 requireGit(t)
303 repo := initRepo(t)
304 managed := t.TempDir()
305 created, err := Create(context.Background(), repo, managed)
306 if err != nil {
307 t.Fatal(err)
308 }
309 if err := os.WriteFile(filepath.Join(repo, "untracked.txt"), []byte("keep\n"), 0o644); err != nil {
310 t.Fatal(err)
311 }
312 inspection := inspectMergeTest(t, created.WorkspaceRoot, managed)
313 if inspection.CanMerge || !hasBlocker(inspection.Blockers, "source_dirty") {
314 t.Fatalf("source dirty inspection = %+v", inspection)
315 }
316 if err := os.Remove(filepath.Join(repo, "untracked.txt")); err != nil {
317 t.Fatal(err)
318 }
319 gitTest(t, repo, "switch", "-c", "other-target")
320 inspection = inspectMergeTest(t, created.WorkspaceRoot, managed)
321 if inspection.CanMerge || !hasBlocker(inspection.Blockers, "target_branch_drift") {
322 t.Fatalf("source branch drift inspection = %+v", inspection)
323 }
324 }
325
326 func TestFinalizeMergePreservesIgnoredContentAndSupportsRetry(t *testing.T) {
327 requireGit(t)
328 repo := initRepo(t)
329 managed := t.TempDir()
330 created, err := Create(context.Background(), repo, managed)
331 if err != nil {
332 t.Fatal(err)
333 }
334 gitCommitFile(t, created.WorktreeRoot, "feature.txt", "feature\n", "feature")
335 inspection := inspectMergeTest(t, created.WorkspaceRoot, managed)
336 result, err := MergeBack(context.Background(), managed, requestFromInspection(inspection))
337 if err != nil {
338 t.Fatal(err)
339 }
340 gitDir := gitTest(t, created.WorktreeRoot, "rev-parse", "--git-common-dir")
341 if !filepath.IsAbs(gitDir) {
342 gitDir = filepath.Join(created.WorktreeRoot, gitDir)
343 }
344 if err := os.WriteFile(filepath.Join(gitDir, "info", "exclude"), []byte("cache.bin\n"), 0o644); err != nil {
345 t.Fatal(err)
346 }
347 if err := os.WriteFile(filepath.Join(created.WorktreeRoot, "cache.bin"), []byte("keep\n"), 0o644); err != nil {
348 t.Fatal(err)
349 }
350 cleanup, err := FinalizeMerge(context.Background(), managed, cleanupFromMerge(result))
351 if err == nil || cleanup.Completed || !hasBlocker(cleanup.Blockers, "worktree_content") {
352 t.Fatalf("ignored cleanup = %+v, %v", cleanup, err)
353 }
354 if _, err := os.Stat(filepath.Join(created.WorktreeRoot, "cache.bin")); err != nil {
355 t.Fatalf("ignored file was not preserved: %v", err)
356 }
357 if err := os.Remove(filepath.Join(created.WorktreeRoot, "cache.bin")); err != nil {
358 t.Fatal(err)
359 }
360 cleanup, err = FinalizeMerge(context.Background(), managed, cleanupFromMerge(result))
361 if err != nil || !cleanup.RecoveryRetained || !cleanup.RecoveryWorktreeRegistered || !cleanup.BranchRetained {
362 t.Fatalf("cleanup retry = %+v, %v", cleanup, err)
363 }
364 }
365
366 func TestInspectMergeRejectsOutsideManagedPath(t *testing.T) {
367 requireGit(t)
368 repo := initRepo(t)
369 inspection, err := InspectMerge(context.Background(), repo, t.TempDir())
370 if err == nil || inspection.Available {
371 t.Fatalf("outside managed inspection = %+v, %v", inspection, err)
372 }
373 }
374
375 func inspectMergeTest(t *testing.T, workspaceRoot, managedRoot string) MergeInspection {
376 t.Helper()
377 inspection, err := InspectMerge(context.Background(), workspaceRoot, managedRoot)
378 if err != nil {
379 t.Fatalf("InspectMerge: %v (%+v)", err, inspection)
380 }
381 return inspection
382 }
383
384 func requestFromInspection(inspection MergeInspection) MergeRequest {
385 return MergeRequest{
386 WorkspaceRoot: inspection.WorktreeRoot, ExpectedTargetBranch: inspection.TargetBranch,
387 ExpectedTargetHead: inspection.TargetHead, ExpectedWorktreeHead: inspection.WorktreeHead,
388 ExpectedWorktreeStateToken: inspection.WorktreeStateToken,
389 }
390 }
391
392 func cleanupFromMerge(result MergeResult) CleanupRequest {
393 return CleanupRequest{
394 WorktreeRoot: result.WorktreeRoot, SourceRoot: result.SourceRoot, TargetBranch: result.TargetBranch,
395 MergedCommit: result.MergedCommit, WorktreeBranch: result.WorktreeBranch, WorktreeHead: result.WorktreeHead,
396 }
397 }
398
399 func hasBlocker(blockers []MergeBlocker, code string) bool {
400 for _, item := range blockers {
401 if item.Code == code {
402 return true
403 }
404 }
405 return false
406 }
407
408 func gitCommitFile(t *testing.T, repo, name, content, message string) {
409 t.Helper()
410 if err := os.WriteFile(filepath.Join(repo, name), []byte(content), 0o644); err != nil {
411 t.Fatal(err)
412 }
413 gitTest(t, repo, "add", name)
414 gitTest(t, repo, "commit", "-m", message)
415 }
416
417 func gitTest(t *testing.T, repo string, args ...string) string {
418 t.Helper()
419 cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
420 cmd.Env = append(os.Environ(),
421 "GIT_CONFIG_NOSYSTEM=1", "GIT_AUTHOR_NAME=Reasonix Test", "GIT_AUTHOR_EMAIL=reasonix@example.invalid",
422 "GIT_COMMITTER_NAME=Reasonix Test", "GIT_COMMITTER_EMAIL=reasonix@example.invalid")
423 out, err := cmd.CombinedOutput()
424 if err != nil {
425 t.Fatalf("git %v: %v\n%s", args, err, out)
426 }
427 return strings.TrimSpace(string(out))
428 }
429
429 lines GO