返回 DeepSeek-Reasonix
merge_cleanup.go
根目录 / internal / worktree / merge_cleanup.go
1 package worktree
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "runtime"
10 "strings"
11 )
12
13 type cleanupRetention struct {
14 Blockers []MergeBlocker
15 RecoveryRoot string
16 RecoveryRetained bool
17 RecoveryWorktreeRegistered bool
18 BranchRetained bool
19 LegacyCompleted bool
20 }
21
22 type registeredWorktree struct {
23 Root string
24 Head string
25 Branch string
26 }
27
28 func emptyCleanupRetention() cleanupRetention {
29 return cleanupRetention{Blockers: []MergeBlocker{}}
30 }
31
32 func finalizeCleanupWorktree(ctx context.Context, metadata mergeMetadata, expectedHead string, rootExists bool) (cleanupRetention, error) {
33 journal, hasState, err := readCleanupState(metadata, expectedHead)
34 if err != nil {
35 return emptyCleanupRetention(), err
36 }
37 if hasState {
38 if journal.Current != nil {
39 return resumeRetainedCleanup(ctx, metadata, *journal.Current)
40 }
41 return migrateLegacyCleanup(ctx, metadata, *journal.Legacy)
42 }
43
44 entries, err := registeredWorktreesForBranch(ctx, metadata.SourceRoot, metadata.WorktreeBranch)
45 if err != nil {
46 return emptyCleanupRetention(), err
47 }
48 if len(entries) > 1 {
49 return emptyCleanupRetention(), errors.New("multiple registered worktrees use the cleanup branch; all were preserved")
50 }
51 if len(entries) == 1 && !sameCleanupPath(entries[0].Root, metadata.WorktreeRoot) {
52 if entries[0].Head != expectedHead || validateCleanupRecoveryPath(metadata, entries[0].Root) != nil {
53 return emptyCleanupRetention(), errors.New("worktree remains registered at an unexpected path; it was preserved")
54 }
55 state := cleanupState{
56 Version: cleanupStateVersion, OriginalRoot: metadata.WorktreeRoot, RecoveryRoot: entries[0].Root,
57 WorktreeBranch: metadata.WorktreeBranch, WorktreeHead: expectedHead, Stage: cleanupStageRetained,
58 }
59 if err := createCleanupState(metadata, state); err != nil {
60 return emptyCleanupRetention(), err
61 }
62 return resumeRetainedCleanup(ctx, metadata, state)
63 }
64 if !rootExists {
65 branchHead, branchExists, branchErr := cleanupBranchHead(ctx, metadata.SourceRoot, metadata.WorktreeBranch)
66 if branchErr != nil {
67 return emptyCleanupRetention(), branchErr
68 }
69 if len(entries) == 0 && !branchExists {
70 retention := emptyCleanupRetention()
71 retention.LegacyCompleted = true
72 return retention, nil
73 }
74 retention := emptyCleanupRetention()
75 retention.BranchRetained = branchExists
76 if branchExists && branchHead != expectedHead {
77 return retention, errors.New("recovery_required: temporary branch changed after merge and was preserved")
78 }
79 return retention, errors.New("recovery_required: the registered recovery checkout could not be located")
80 }
81 if len(entries) != 1 || !sameCleanupPath(entries[0].Root, metadata.WorktreeRoot) {
82 return emptyCleanupRetention(), errors.New("worktree registration does not match the managed checkout; resources were preserved")
83 }
84 return beginRetainedCleanup(ctx, metadata, expectedHead)
85 }
86
87 func beginRetainedCleanup(ctx context.Context, metadata mergeMetadata, expectedHead string) (cleanupRetention, error) {
88 paths, err := verifyCleanupWorktree(ctx, metadata, metadata.WorktreeRoot, expectedHead)
89 if err != nil {
90 retention := emptyCleanupRetention()
91 if len(paths) > 0 {
92 retention.Blockers = append(retention.Blockers, MergeBlocker{
93 Code: "worktree_content", Message: "tracked, untracked, or ignored files block finalization", Paths: paths,
94 })
95 }
96 return retention, err
97 }
98 cleanupDir := filepath.Join(filepath.Dir(metadata.WorktreeRoot), ".reasonix-cleanup")
99 if err := ensureCleanupRecoveryDir(cleanupDir); err != nil {
100 return emptyCleanupRetention(), err
101 }
102 recoveryID, err := randomID()
103 if err != nil {
104 return emptyCleanupRetention(), err
105 }
106 state := cleanupState{
107 Version: cleanupStateVersion, OriginalRoot: metadata.WorktreeRoot,
108 RecoveryRoot: filepath.Join(cleanupDir, "recovery-"+recoveryID),
109 WorktreeBranch: metadata.WorktreeBranch, WorktreeHead: expectedHead, Stage: cleanupStagePlanned,
110 }
111 if _, err := os.Lstat(state.RecoveryRoot); err == nil {
112 return emptyCleanupRetention(), errors.New("cleanup recovery path already exists")
113 } else if !errors.Is(err, os.ErrNotExist) {
114 return emptyCleanupRetention(), fmt.Errorf("inspect cleanup recovery path: %w", err)
115 }
116 if err := createCleanupState(metadata, state); err != nil {
117 return emptyCleanupRetention(), err
118 }
119 return resumeRetainedCleanup(ctx, metadata, state)
120 }
121
122 func resumeRetainedCleanup(ctx context.Context, metadata mergeMetadata, state cleanupState) (cleanupRetention, error) {
123 retention := emptyCleanupRetention()
124 retention.RecoveryRoot = state.RecoveryRoot
125 branchHead, branchExists, err := cleanupBranchHead(ctx, metadata.SourceRoot, metadata.WorktreeBranch)
126 if err != nil {
127 return retention, err
128 }
129 retention.BranchRetained = branchExists
130 if !branchExists || branchHead != state.WorktreeHead {
131 return retention, errors.New("recovery_required: temporary branch identity changed; the checkout was preserved")
132 }
133 entries, err := registeredWorktreesForBranch(ctx, metadata.SourceRoot, metadata.WorktreeBranch)
134 if err != nil {
135 return retention, err
136 }
137 if len(entries) != 1 || entries[0].Head != state.WorktreeHead {
138 return retention, errors.New("recovery_required: recovery worktree registration changed; all resources were preserved")
139 }
140
141 registeredRoot := entries[0].Root
142 if state.Stage == cleanupStagePlanned && sameCleanupPath(registeredRoot, state.OriginalRoot) {
143 if _, err := os.Lstat(state.RecoveryRoot); err == nil {
144 return retention, errors.New("cleanup_state_changed: planned recovery path is occupied; both paths were preserved")
145 } else if !errors.Is(err, os.ErrNotExist) {
146 return retention, fmt.Errorf("inspect planned recovery path: %w", err)
147 }
148 paths, verifyErr := verifyCleanupWorktree(ctx, metadata, state.OriginalRoot, state.WorktreeHead)
149 if verifyErr != nil {
150 if len(paths) > 0 {
151 retention.Blockers = append(retention.Blockers, MergeBlocker{
152 Code: "worktree_content", Message: "tracked, untracked, or ignored files block finalization", Paths: paths,
153 })
154 }
155 return retention, verifyErr
156 }
157 noteMergeStep("before_cleanup_recovery_move")
158 if _, stderr, moveErr := runGit(ctx, metadata.SourceRoot, "worktree", "move", state.OriginalRoot, state.RecoveryRoot); moveErr != nil {
159 return retention, fmt.Errorf("move worktree to retained recovery path: %w%s", moveErr, stderrSuffix(stderr))
160 }
161 noteMergeStep("after_cleanup_recovery_move")
162 entries, err = registeredWorktreesForBranch(ctx, metadata.SourceRoot, metadata.WorktreeBranch)
163 if err != nil {
164 return retention, err
165 }
166 if len(entries) != 1 {
167 return retention, errors.New("recovery_required: worktree registration changed after recovery move")
168 }
169 registeredRoot = entries[0].Root
170 }
171 if !sameCleanupPath(registeredRoot, state.RecoveryRoot) {
172 return retention, errors.New("recovery_required: registered recovery path differs from the cleanup journal")
173 }
174 retention.RecoveryWorktreeRegistered = true
175 retention.RecoveryRetained = true
176
177 paths, verifyErr := verifyCleanupWorktree(ctx, metadata, state.RecoveryRoot, state.WorktreeHead)
178 if verifyErr != nil {
179 if len(paths) > 0 {
180 retention.Blockers = append(retention.Blockers, MergeBlocker{
181 Code: "late_content_preserved", Message: "content changed in the retained recovery worktree and was preserved", Paths: paths,
182 })
183 }
184 return retention, fmt.Errorf("cleanup_state_changed: %w", verifyErr)
185 }
186 if state.Stage != cleanupStageRetained {
187 state.Stage = cleanupStageRetained
188 if err := writeCleanupState(metadata, state); err != nil {
189 return retention, err
190 }
191 }
192 if _, err := os.Lstat(state.OriginalRoot); err == nil {
193 retention.Blockers = append(retention.Blockers, MergeBlocker{
194 Code: "late_content_preserved", Message: "content appeared at the former worktree path and was preserved", Paths: []string{"."},
195 })
196 } else if !errors.Is(err, os.ErrNotExist) {
197 return retention, fmt.Errorf("inspect former worktree path after recovery move: %w", err)
198 }
199 return retention, nil
200 }
201
202 func migrateLegacyCleanup(ctx context.Context, metadata mergeMetadata, legacy legacyCleanupState) (cleanupRetention, error) {
203 retention := emptyCleanupRetention()
204 branchHead, branchExists, err := cleanupBranchHead(ctx, metadata.SourceRoot, metadata.WorktreeBranch)
205 if err != nil {
206 return retention, err
207 }
208 retention.BranchRetained = branchExists
209 registeredExists, err := cleanupPathExists(legacy.RegisteredRoot)
210 if err != nil {
211 return retention, fmt.Errorf("inspect legacy registered cleanup root: %w", err)
212 }
213 detachedExists, err := cleanupPathExists(legacy.DetachedRoot)
214 if err != nil {
215 return retention, fmt.Errorf("inspect legacy detached cleanup root: %w", err)
216 }
217 if registeredExists && detachedExists {
218 return retention, errors.New("recovery_required: both legacy cleanup roots exist; both were preserved")
219 }
220 entries, err := registeredWorktreesForBranch(ctx, metadata.SourceRoot, metadata.WorktreeBranch)
221 if err != nil {
222 return retention, err
223 }
224 if len(entries) > 1 {
225 return retention, errors.New("recovery_required: multiple worktrees use the legacy cleanup branch")
226 }
227 if len(entries) == 1 && entries[0].Head == legacy.WorktreeHead && sameCleanupPath(entries[0].Root, legacy.RegisteredRoot) {
228 switch {
229 case registeredExists:
230 if err := verifyLegacyManifest(ctx, legacy.RegisteredRoot, legacy.Manifest); err != nil {
231 return retention, err
232 }
233 case detachedExists:
234 if err := verifyLegacyManifest(ctx, legacy.DetachedRoot, legacy.Manifest); err != nil {
235 return retention, err
236 }
237 if err := os.Rename(legacy.DetachedRoot, legacy.RegisteredRoot); err != nil {
238 return retention, fmt.Errorf("restore registered legacy recovery worktree: %w", err)
239 }
240 default:
241 return retention, errors.New("recovery_required: legacy recovery checkout disappeared while still registered")
242 }
243 state := cleanupState{
244 Version: cleanupStateVersion, OriginalRoot: legacy.OriginalRoot, RecoveryRoot: legacy.RegisteredRoot,
245 WorktreeBranch: legacy.WorktreeBranch, WorktreeHead: legacy.WorktreeHead, Stage: cleanupStageRetained,
246 }
247 if err := writeCleanupState(metadata, state); err != nil {
248 return retention, err
249 }
250 return resumeRetainedCleanup(ctx, metadata, state)
251 }
252 if detachedExists || registeredExists {
253 retention.RecoveryRoot = legacy.DetachedRoot
254 if registeredExists {
255 retention.RecoveryRoot = legacy.RegisteredRoot
256 }
257 retention.Blockers = append(retention.Blockers, MergeBlocker{
258 Code: "legacy_recovery_preserved", Message: "a legacy detached recovery checkout was preserved for manual repair", Paths: []string{"."},
259 })
260 return retention, errors.New("recovery_required: legacy cleanup state is no longer a registered worktree")
261 }
262 if len(entries) == 0 && !branchExists {
263 retention.LegacyCompleted = true
264 return retention, nil
265 }
266 if branchExists && branchHead != legacy.WorktreeHead {
267 return retention, errors.New("recovery_required: legacy recovery branch changed and was preserved")
268 }
269 return retention, errors.New("recovery_required: legacy cleanup identity is incomplete; remaining resources were preserved")
270 }
271
272 func verifyLegacyManifest(ctx context.Context, root string, expected []cleanupManifestEntry) error {
273 actual, err := captureCleanupManifest(ctx, root)
274 if err != nil {
275 return err
276 }
277 if !manifestsEqual(expected, actual) {
278 return errors.New("cleanup_state_changed: legacy recovery checkout no longer matches its manifest")
279 }
280 return nil
281 }
282
283 func verifyCleanupWorktree(ctx context.Context, metadata mergeMetadata, root, expectedHead string) ([]string, error) {
284 if err := verifyRepositoryRoot(ctx, root); err != nil {
285 return nil, fmt.Errorf("recovery checkout identity changed: %w", err)
286 }
287 if err := verifySameCommonDir(ctx, metadata.SourceRoot, root); err != nil {
288 return nil, fmt.Errorf("recovery repository identity changed: %w", err)
289 }
290 branch, stderr, err := gitValue(ctx, root, "symbolic-ref", "--quiet", "--short", "HEAD")
291 if err != nil || branch != metadata.WorktreeBranch {
292 return nil, fmt.Errorf("recovery worktree branch changed%s", stderrSuffix(stderr))
293 }
294 head, stderr, err := gitValue(ctx, root, "rev-parse", "--verify", "HEAD")
295 if err != nil || head != expectedHead {
296 return nil, fmt.Errorf("recovery worktree HEAD changed%s", stderrSuffix(stderr))
297 }
298 operation, err := gitOperation(ctx, root)
299 if err != nil {
300 return nil, err
301 }
302 if operation != "" {
303 return nil, fmt.Errorf("recovery worktree has an active Git %s operation", operation)
304 }
305 status, stderr, err := runGitEnv(ctx, root, gitNoOptionalLocks, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored")
306 if err != nil {
307 return nil, fmt.Errorf("inspect recovery worktree state: %w%s", err, stderrSuffix(stderr))
308 }
309 paths, err := nulStatusPaths(status)
310 if err != nil {
311 return nil, fmt.Errorf("decode recovery worktree state: %w", err)
312 }
313 if len(paths) > 0 {
314 return paths, errors.New("recovery worktree contains content that must be preserved")
315 }
316 return []string{}, nil
317 }
318
319 func registeredWorktreesForBranch(ctx context.Context, sourceRoot, branch string) ([]registeredWorktree, error) {
320 out, stderr, err := runGit(ctx, sourceRoot, "worktree", "list", "--porcelain", "-z")
321 if err != nil {
322 return nil, fmt.Errorf("inspect registered worktrees: %w%s", err, stderrSuffix(stderr))
323 }
324 want := "refs/heads/" + branch
325 entries := []registeredWorktree{}
326 current := registeredWorktree{}
327 appendCurrent := func() {
328 if current.Root != "" && current.Branch == want {
329 entries = append(entries, current)
330 }
331 current = registeredWorktree{}
332 }
333 for record := range strings.SplitSeq(out, "\x00") {
334 switch {
335 case strings.HasPrefix(record, "worktree "):
336 appendCurrent()
337 current.Root = strings.TrimPrefix(record, "worktree ")
338 case strings.HasPrefix(record, "HEAD "):
339 current.Head = strings.TrimPrefix(record, "HEAD ")
340 case strings.HasPrefix(record, "branch "):
341 current.Branch = strings.TrimPrefix(record, "branch ")
342 }
343 }
344 appendCurrent()
345 return entries, nil
346 }
347
348 func cleanupBranchHead(ctx context.Context, sourceRoot, branch string) (string, bool, error) {
349 head, stderr, err := gitValue(ctx, sourceRoot, "rev-parse", "--verify", "refs/heads/"+branch)
350 if err == nil {
351 return head, true, nil
352 }
353 if exitCode(err) == 128 || exitCode(err) == 1 {
354 return "", false, nil
355 }
356 return "", false, fmt.Errorf("inspect temporary branch: %w%s", err, stderrSuffix(stderr))
357 }
358
359 func cleanupPathExists(path string) (bool, error) {
360 _, err := os.Lstat(path)
361 if err == nil {
362 return true, nil
363 }
364 if errors.Is(err, os.ErrNotExist) {
365 return false, nil
366 }
367 return false, err
368 }
369
370 func sameCleanupPath(left, right string) bool {
371 leftAbs, leftErr := filepath.Abs(left)
372 rightAbs, rightErr := filepath.Abs(right)
373 if leftErr != nil || rightErr != nil {
374 return false
375 }
376 leftInfo, leftStatErr := os.Stat(leftAbs)
377 rightInfo, rightStatErr := os.Stat(rightAbs)
378 if leftStatErr == nil && rightStatErr == nil {
379 return os.SameFile(leftInfo, rightInfo)
380 }
381 if resolvedLeft, err := resolveMissingCleanupPath(leftAbs); err == nil {
382 leftAbs = resolvedLeft
383 }
384 if resolvedRight, err := resolveMissingCleanupPath(rightAbs); err == nil {
385 rightAbs = resolvedRight
386 }
387 leftAbs, rightAbs = filepath.Clean(leftAbs), filepath.Clean(rightAbs)
388 if runtime.GOOS == "windows" {
389 return strings.EqualFold(leftAbs, rightAbs)
390 }
391 return leftAbs == rightAbs
392 }
393
394 func ensureCleanupRecoveryDir(path string) error {
395 if err := os.Mkdir(path, 0o700); err == nil {
396 return nil
397 } else if !errors.Is(err, os.ErrExist) {
398 return fmt.Errorf("create cleanup recovery directory: %w", err)
399 }
400 info, err := os.Lstat(path)
401 if err != nil {
402 return fmt.Errorf("inspect cleanup recovery directory: %w", err)
403 }
404 if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
405 return errors.New("cleanup recovery directory is not a real directory")
406 }
407 return nil
408 }
409
409 lines GO