| 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 |