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