返回 DeepSeek-Reasonix
merge_cleanup_test.go
根目录 / internal / worktree / merge_cleanup_test.go
1 package worktree
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "runtime"
10 "strings"
11 "testing"
12 )
13
14 func mergedWorktreeFixture(t *testing.T) (string, string, Result, MergeResult) {
15 t.Helper()
16 repo := initRepo(t)
17 managed := t.TempDir()
18 created, err := Create(context.Background(), repo, managed)
19 if err != nil {
20 t.Fatal(err)
21 }
22 gitCommitFile(t, created.WorktreeRoot, "feature.txt", "feature\n", "feature")
23 inspection := inspectMergeTest(t, created.WorktreeRoot, managed)
24 result, err := MergeBack(context.Background(), managed, requestFromInspection(inspection))
25 if err != nil {
26 t.Fatalf("MergeBack: %v (%+v)", err, result)
27 }
28 return repo, managed, created, result
29 }
30
31 func assertRetainedCleanup(t *testing.T, repo string, result MergeResult, cleanup CleanupResult) {
32 t.Helper()
33 if cleanup.Completed || cleanup.WorktreeRemoved || cleanup.BranchDeleted ||
34 !cleanup.RecoveryRetained || !cleanup.RecoveryWorktreeRegistered || !cleanup.BranchRetained || cleanup.RecoveryRoot == "" {
35 t.Fatalf("cleanup did not retain recovery resources: %+v", cleanup)
36 }
37 if got := gitTest(t, cleanup.RecoveryRoot, "rev-parse", "HEAD"); got != result.WorktreeHead {
38 t.Fatalf("recovery HEAD = %s, want %s", got, result.WorktreeHead)
39 }
40 if got := gitTest(t, cleanup.RecoveryRoot, "symbolic-ref", "--short", "HEAD"); got != result.WorktreeBranch {
41 t.Fatalf("recovery branch = %s, want %s", got, result.WorktreeBranch)
42 }
43 if got := gitTest(t, repo, "rev-parse", "refs/heads/"+result.WorktreeBranch); got != result.WorktreeHead {
44 t.Fatalf("retained branch = %s, want %s", got, result.WorktreeHead)
45 }
46 }
47
48 func TestFinalizeMergeRetainsRegisteredRecoveryWorktreeAndBranch(t *testing.T) {
49 requireGit(t)
50 repo, managed, created, result := mergedWorktreeFixture(t)
51 request := cleanupFromMerge(result)
52 cleanup, err := FinalizeMerge(context.Background(), managed, request)
53 if err != nil {
54 t.Fatalf("FinalizeMerge: %v (%+v)", err, cleanup)
55 }
56 assertRetainedCleanup(t, repo, result, cleanup)
57 if _, err := os.Stat(created.WorktreeRoot); !errors.Is(err, os.ErrNotExist) {
58 t.Fatalf("former worktree path still exists: %v", err)
59 }
60 journal, hasState, err := readCleanupState(mergeMetadata{WorktreeRoot: created.WorktreeRoot, WorktreeBranch: result.WorktreeBranch}, result.WorktreeHead)
61 if err != nil || !hasState || journal.Current == nil || journal.Current.Stage != cleanupStageRetained {
62 t.Fatalf("retained journal = %+v, %v, %v", journal, hasState, err)
63 }
64 retried, err := FinalizeMerge(context.Background(), managed, request)
65 if err != nil || retried.RecoveryRoot != cleanup.RecoveryRoot {
66 t.Fatalf("idempotent finalize = %+v, %v", retried, err)
67 }
68 assertRetainedCleanup(t, repo, result, retried)
69 other := filepath.Join(t.TempDir(), "other")
70 if _, _, err := runGit(context.Background(), repo, "worktree", "add", other, result.WorktreeBranch); err == nil {
71 t.Fatal("retained branch could be checked out in a second worktree")
72 }
73 }
74
75 func TestFinalizeMergePreservesOpenFileWritesAtRecoveryPath(t *testing.T) {
76 if runtime.GOOS == "windows" {
77 t.Skip("Windows may refuse to move a checkout containing an open file")
78 }
79 requireGit(t)
80 repo, managed, created, result := mergedWorktreeFixture(t)
81 openFile, err := os.OpenFile(filepath.Join(created.WorktreeRoot, "feature.txt"), os.O_WRONLY|os.O_APPEND, 0)
82 if err != nil {
83 t.Fatal(err)
84 }
85 t.Cleanup(func() { _ = openFile.Close() })
86 mergeStepHook = func(step string) {
87 if step == "after_cleanup_recovery_move" {
88 if _, err := openFile.WriteString("late write\n"); err != nil {
89 t.Fatal(err)
90 }
91 if err := openFile.Sync(); err != nil {
92 t.Fatal(err)
93 }
94 }
95 }
96 t.Cleanup(func() { mergeStepHook = nil })
97
98 cleanup, err := FinalizeMerge(context.Background(), managed, cleanupFromMerge(result))
99 if err == nil || !cleanup.RecoveryRetained || !cleanup.RecoveryWorktreeRegistered || !cleanup.BranchRetained ||
100 !hasBlocker(cleanup.Blockers, "late_content_preserved") || !strings.Contains(cleanup.Error, "cleanup_state_changed") {
101 t.Fatalf("open-file cleanup = %+v, %v", cleanup, err)
102 }
103 body, readErr := os.ReadFile(filepath.Join(cleanup.RecoveryRoot, "feature.txt"))
104 if readErr != nil || string(body) != "feature\nlate write\n" {
105 t.Fatalf("open-file write was lost: %q, %v", body, readErr)
106 }
107 if got := gitTest(t, repo, "rev-parse", "refs/heads/"+result.WorktreeBranch); got != result.WorktreeHead {
108 t.Fatalf("recovery branch changed after open-file write: %s", got)
109 }
110 }
111
112 func TestFinalizeMergePreservesLateContentAtFormerPath(t *testing.T) {
113 requireGit(t)
114 repo, managed, created, result := mergedWorktreeFixture(t)
115 latePath := filepath.Join(created.WorktreeRoot, "late-user.txt")
116 mergeStepHook = func(step string) {
117 if step != "after_cleanup_recovery_move" {
118 return
119 }
120 if err := os.MkdirAll(created.WorktreeRoot, 0o700); err != nil {
121 t.Fatal(err)
122 }
123 if err := os.WriteFile(latePath, []byte("preserve me\n"), 0o600); err != nil {
124 t.Fatal(err)
125 }
126 }
127 t.Cleanup(func() { mergeStepHook = nil })
128
129 cleanup, err := FinalizeMerge(context.Background(), managed, cleanupFromMerge(result))
130 if err != nil || !hasBlocker(cleanup.Blockers, "late_content_preserved") {
131 t.Fatalf("late-path cleanup = %+v, %v", cleanup, err)
132 }
133 assertRetainedCleanup(t, repo, result, cleanup)
134 body, readErr := os.ReadFile(latePath)
135 if readErr != nil || string(body) != "preserve me\n" {
136 t.Fatalf("late path content was not preserved: %q, %v", body, readErr)
137 }
138 retried, err := FinalizeMerge(context.Background(), managed, cleanupFromMerge(result))
139 if err != nil || retried.RecoveryRoot != cleanup.RecoveryRoot || !hasBlocker(retried.Blockers, "late_content_preserved") {
140 t.Fatalf("late-path retry = %+v, %v", retried, err)
141 }
142 }
143
144 func TestFinalizeMergeMoveFailureLeavesOriginalAndBranch(t *testing.T) {
145 requireGit(t)
146 repo, managed, created, result := mergedWorktreeFixture(t)
147 mergeStepHook = func(step string) {
148 if step != "before_cleanup_recovery_move" {
149 return
150 }
151 journal, hasState, err := readCleanupState(mergeMetadata{WorktreeRoot: created.WorktreeRoot, WorktreeBranch: result.WorktreeBranch}, result.WorktreeHead)
152 if err != nil || !hasState || journal.Current == nil {
153 t.Fatalf("planned cleanup journal = %+v, %v, %v", journal, hasState, err)
154 }
155 if err := os.WriteFile(journal.Current.RecoveryRoot, []byte("occupied\n"), 0o600); err != nil {
156 t.Fatal(err)
157 }
158 }
159 t.Cleanup(func() { mergeStepHook = nil })
160
161 cleanup, err := FinalizeMerge(context.Background(), managed, cleanupFromMerge(result))
162 if err == nil || cleanup.RecoveryRetained || cleanup.WorktreeRemoved || cleanup.BranchDeleted {
163 t.Fatalf("move failure = %+v, %v", cleanup, err)
164 }
165 if got := gitTest(t, created.WorktreeRoot, "rev-parse", "HEAD"); got != result.WorktreeHead {
166 t.Fatalf("original worktree changed after move failure: %s", got)
167 }
168 if got := gitTest(t, repo, "rev-parse", "refs/heads/"+result.WorktreeBranch); got != result.WorktreeHead {
169 t.Fatalf("branch changed after move failure: %s", got)
170 }
171 }
172
173 func TestFinalizeMergeResumesPlannedRecoveryMove(t *testing.T) {
174 requireGit(t)
175 repo, managed, created, result := mergedWorktreeFixture(t)
176 metadata, _, _, err := readMergeMetadataForCleanup(created.WorktreeRoot, managed)
177 if err != nil {
178 t.Fatal(err)
179 }
180 cleanupDir := filepath.Join(filepath.Dir(created.WorktreeRoot), ".reasonix-cleanup")
181 if err := ensureCleanupRecoveryDir(cleanupDir); err != nil {
182 t.Fatal(err)
183 }
184 state := cleanupState{
185 Version: cleanupStateVersion, OriginalRoot: created.WorktreeRoot,
186 RecoveryRoot: filepath.Join(cleanupDir, "recovery-crash"), WorktreeBranch: result.WorktreeBranch,
187 WorktreeHead: result.WorktreeHead, Stage: cleanupStagePlanned,
188 }
189 if err := createCleanupState(metadata, state); err != nil {
190 t.Fatal(err)
191 }
192 gitTest(t, repo, "worktree", "move", created.WorktreeRoot, state.RecoveryRoot)
193
194 cleanup, err := FinalizeMerge(context.Background(), managed, cleanupFromMerge(result))
195 if err != nil || cleanup.RecoveryRoot != state.RecoveryRoot {
196 t.Fatalf("planned recovery retry = %+v, %v", cleanup, err)
197 }
198 assertRetainedCleanup(t, repo, result, cleanup)
199 journal, hasState, err := readCleanupState(metadata, result.WorktreeHead)
200 if err != nil || !hasState || journal.Current == nil || journal.Current.Stage != cleanupStageRetained {
201 t.Fatalf("resumed cleanup journal = %+v, %v, %v", journal, hasState, err)
202 }
203 }
204
205 func TestFinalizeMergeMigratesRegisteredLegacyJournal(t *testing.T) {
206 requireGit(t)
207 repo, managed, created, result := mergedWorktreeFixture(t)
208 metadata, _, _, err := readMergeMetadataForCleanup(created.WorktreeRoot, managed)
209 if err != nil {
210 t.Fatal(err)
211 }
212 cleanupDir := filepath.Join(filepath.Dir(created.WorktreeRoot), ".reasonix-cleanup")
213 if err := ensureCleanupRecoveryDir(cleanupDir); err != nil {
214 t.Fatal(err)
215 }
216 registeredRoot := filepath.Join(cleanupDir, "legacy-registered")
217 gitTest(t, repo, "worktree", "move", created.WorktreeRoot, registeredRoot)
218 manifest, err := captureCleanupManifest(context.Background(), registeredRoot)
219 if err != nil {
220 t.Fatal(err)
221 }
222 legacy := legacyCleanupState{
223 Version: legacyCleanupStateVersion, OriginalRoot: created.WorktreeRoot, RegisteredRoot: registeredRoot,
224 DetachedRoot: filepath.Join(cleanupDir, "legacy-detached"), WorktreeBranch: result.WorktreeBranch,
225 WorktreeHead: result.WorktreeHead, Stage: legacyCleanupStagePrepared, Manifest: manifest,
226 }
227 writeLegacyCleanupState(t, metadata, legacy)
228
229 cleanup, err := FinalizeMerge(context.Background(), managed, cleanupFromMerge(result))
230 if err != nil || cleanup.RecoveryRoot != registeredRoot {
231 t.Fatalf("legacy migration = %+v, %v", cleanup, err)
232 }
233 assertRetainedCleanup(t, repo, result, cleanup)
234 journal, hasState, err := readCleanupState(metadata, result.WorktreeHead)
235 if err != nil || !hasState || journal.Current == nil || journal.Current.Version != cleanupStateVersion {
236 t.Fatalf("migrated journal = %+v, %v, %v", journal, hasState, err)
237 }
238 }
239
240 func TestFinalizeMergeRestoresPreparedLegacyDetachedCheckout(t *testing.T) {
241 requireGit(t)
242 repo, managed, created, result := mergedWorktreeFixture(t)
243 metadata, _, _, err := readMergeMetadataForCleanup(created.WorktreeRoot, managed)
244 if err != nil {
245 t.Fatal(err)
246 }
247 cleanupDir := filepath.Join(filepath.Dir(created.WorktreeRoot), ".reasonix-cleanup")
248 if err := ensureCleanupRecoveryDir(cleanupDir); err != nil {
249 t.Fatal(err)
250 }
251 registeredRoot := filepath.Join(cleanupDir, "legacy-registered")
252 detachedRoot := filepath.Join(cleanupDir, "legacy-detached")
253 gitTest(t, repo, "worktree", "move", created.WorktreeRoot, registeredRoot)
254 manifest, err := captureCleanupManifest(context.Background(), registeredRoot)
255 if err != nil {
256 t.Fatal(err)
257 }
258 if err := os.Rename(registeredRoot, detachedRoot); err != nil {
259 t.Fatal(err)
260 }
261 legacy := legacyCleanupState{
262 Version: legacyCleanupStateVersion, OriginalRoot: created.WorktreeRoot, RegisteredRoot: registeredRoot,
263 DetachedRoot: detachedRoot, WorktreeBranch: result.WorktreeBranch, WorktreeHead: result.WorktreeHead,
264 Stage: legacyCleanupStagePrepared, Manifest: manifest,
265 }
266 writeLegacyCleanupState(t, metadata, legacy)
267
268 cleanup, err := FinalizeMerge(context.Background(), managed, cleanupFromMerge(result))
269 if err != nil || cleanup.RecoveryRoot != registeredRoot {
270 t.Fatalf("legacy detached restore = %+v, %v", cleanup, err)
271 }
272 assertRetainedCleanup(t, repo, result, cleanup)
273 }
274
275 func TestFinalizeMergePreservesUnregisteredLegacyDetachedCheckout(t *testing.T) {
276 requireGit(t)
277 repo, managed, created, result := mergedWorktreeFixture(t)
278 metadata, _, _, err := readMergeMetadataForCleanup(created.WorktreeRoot, managed)
279 if err != nil {
280 t.Fatal(err)
281 }
282 cleanupDir := filepath.Join(filepath.Dir(created.WorktreeRoot), ".reasonix-cleanup")
283 if err := ensureCleanupRecoveryDir(cleanupDir); err != nil {
284 t.Fatal(err)
285 }
286 registeredRoot := filepath.Join(cleanupDir, "legacy-registered")
287 detachedRoot := filepath.Join(cleanupDir, "legacy-detached")
288 gitTest(t, repo, "worktree", "move", created.WorktreeRoot, registeredRoot)
289 manifest, err := captureCleanupManifest(context.Background(), registeredRoot)
290 if err != nil {
291 t.Fatal(err)
292 }
293 if err := os.Rename(registeredRoot, detachedRoot); err != nil {
294 t.Fatal(err)
295 }
296 gitTest(t, repo, "worktree", "remove", registeredRoot)
297 legacy := legacyCleanupState{
298 Version: legacyCleanupStateVersion, OriginalRoot: created.WorktreeRoot, RegisteredRoot: registeredRoot,
299 DetachedRoot: detachedRoot, WorktreeBranch: result.WorktreeBranch, WorktreeHead: result.WorktreeHead,
300 Stage: legacyCleanupStageUnregistered, Manifest: manifest,
301 }
302 writeLegacyCleanupState(t, metadata, legacy)
303
304 cleanup, err := FinalizeMerge(context.Background(), managed, cleanupFromMerge(result))
305 if err == nil || cleanup.RecoveryRoot != detachedRoot || cleanup.RecoveryWorktreeRegistered ||
306 !cleanup.BranchRetained || !hasBlocker(cleanup.Blockers, "legacy_recovery_preserved") {
307 t.Fatalf("unregistered legacy recovery = %+v, %v", cleanup, err)
308 }
309 if _, err := os.Stat(detachedRoot); err != nil {
310 t.Fatalf("legacy detached checkout was removed: %v", err)
311 }
312 if got := gitTest(t, repo, "rev-parse", "refs/heads/"+result.WorktreeBranch); got != result.WorktreeHead {
313 t.Fatalf("legacy branch changed: %s", got)
314 }
315 }
316
317 func TestFinalizeMergePreservesWorktreeMovedOutsideRecovery(t *testing.T) {
318 requireGit(t)
319 repo, managed, created, result := mergedWorktreeFixture(t)
320 externalRoot := filepath.Join(t.TempDir(), "externally-moved")
321 gitTest(t, repo, "worktree", "move", created.WorktreeRoot, externalRoot)
322
323 cleanup, err := FinalizeMerge(context.Background(), managed, cleanupFromMerge(result))
324 if err == nil || cleanup.RecoveryRetained || cleanup.WorktreeRemoved || cleanup.BranchDeleted || !strings.Contains(cleanup.Error, "unexpected path") {
325 t.Fatalf("externally moved cleanup = %+v, %v", cleanup, err)
326 }
327 if got := gitTest(t, externalRoot, "rev-parse", "HEAD"); got != result.WorktreeHead {
328 t.Fatalf("externally moved worktree HEAD = %s", got)
329 }
330 }
331
332 func TestFinalizeMergeRejectsUnknownCleanupJournalVersion(t *testing.T) {
333 requireGit(t)
334 repo, managed, created, result := mergedWorktreeFixture(t)
335 journal := cleanupJournalPath(mergeMetadata{WorktreeRoot: created.WorktreeRoot})
336 if err := os.WriteFile(journal, []byte("{\"version\":99}\n"), 0o600); err != nil {
337 t.Fatal(err)
338 }
339
340 cleanup, err := FinalizeMerge(context.Background(), managed, cleanupFromMerge(result))
341 if err == nil || cleanup.RecoveryRetained || cleanup.WorktreeRemoved || cleanup.BranchDeleted || !strings.Contains(cleanup.Error, "unsupported cleanup state version") {
342 t.Fatalf("unknown cleanup journal = %+v, %v", cleanup, err)
343 }
344 if _, err := os.Stat(created.WorktreeRoot); err != nil {
345 t.Fatalf("unknown journal removed worktree: %v", err)
346 }
347 if got := gitTest(t, repo, "rev-parse", "refs/heads/"+result.WorktreeBranch); got != result.WorktreeHead {
348 t.Fatalf("unknown journal changed recovery branch: %s", got)
349 }
350 }
351
352 func writeLegacyCleanupState(t *testing.T, metadata mergeMetadata, state legacyCleanupState) {
353 t.Helper()
354 body, err := json.Marshal(state)
355 if err != nil {
356 t.Fatal(err)
357 }
358 if err := os.WriteFile(cleanupJournalPath(metadata), append(body, '\n'), 0o600); err != nil {
359 t.Fatal(err)
360 }
361 }
362
362 lines GO