| 1 | package worktree |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "io" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | |
| 13 | "reasonix/internal/fileutil" |
| 14 | ) |
| 15 | |
| 16 | var gitNoOptionalLocks = []string{"GIT_OPTIONAL_LOCKS=0"} |
| 17 | |
| 18 | // autoCommitDirtyWorktree commits the confirmed filesystem snapshot without |
| 19 | // exposing the user's real index to git add or hooks. The branch ref and index |
| 20 | // are installed through separate compare-and-apply gates; failures after the |
| 21 | // ref CAS are explicitly recovery-required. |
| 22 | func autoCommitDirtyWorktree(ctx context.Context, inspection MergeInspection) (string, bool, error) { |
| 23 | if err := verifyWorktreeMergeIdentity(ctx, inspection); err != nil { |
| 24 | return "", false, fmt.Errorf("worktree changed before auto-commit: %w", err) |
| 25 | } |
| 26 | indexPath, originalIndex, err := snapshotRealIndex(ctx, inspection.WorktreeRoot) |
| 27 | if err != nil { |
| 28 | return "", false, err |
| 29 | } |
| 30 | realEntries, stderr, err := runGitEnv(ctx, inspection.WorktreeRoot, gitNoOptionalLocks, "ls-files", "--stage", "-z") |
| 31 | if err != nil { |
| 32 | return "", false, fmt.Errorf("snapshot real index entries: %w%s", err, stderrSuffix(stderr)) |
| 33 | } |
| 34 | stagedIndexChanges, err := hasStagedIndexChanges(ctx, inspection.WorktreeRoot, inspection.WorktreeHead) |
| 35 | if err != nil { |
| 36 | return "", false, err |
| 37 | } |
| 38 | |
| 39 | tempIndex, err := newTemporaryIndex(inspection.WorktreeRoot) |
| 40 | if err != nil { |
| 41 | return "", false, err |
| 42 | } |
| 43 | defer os.Remove(tempIndex) |
| 44 | tempEnv := []string{"GIT_OPTIONAL_LOCKS=0", "GIT_INDEX_FILE=" + tempIndex} |
| 45 | if _, stderr, err := runGitEnv(ctx, inspection.WorktreeRoot, tempEnv, "read-tree", inspection.WorktreeHead); err != nil { |
| 46 | return "", false, fmt.Errorf("seed temporary index: %w%s", err, stderrSuffix(stderr)) |
| 47 | } |
| 48 | if err := secureTemporaryIndex(tempIndex); err != nil { |
| 49 | return "", false, err |
| 50 | } |
| 51 | if _, stderr, err := runGitEnv(ctx, inspection.WorktreeRoot, tempEnv, "add", "-A"); err != nil { |
| 52 | return "", false, fmt.Errorf("stage confirmed changes in temporary index: %w%s", err, stderrSuffix(stderr)) |
| 53 | } |
| 54 | if err := secureTemporaryIndex(tempIndex); err != nil { |
| 55 | return "", false, err |
| 56 | } |
| 57 | noteMergeStep("after_worktree_add") |
| 58 | stagedTree, tempEntries, err := verifyTemporaryIndex(ctx, inspection, tempEnv) |
| 59 | if err != nil { |
| 60 | return "", false, fmt.Errorf("worktree changed while staging; the real index was preserved: %w", err) |
| 61 | } |
| 62 | if stagedIndexChanges && realEntries != tempEntries { |
| 63 | return "", false, errors.New("worktree_index_split: staged or index-only content is not fully represented by the working tree; commit, stash, or unstage it manually") |
| 64 | } |
| 65 | headTree, stderr, err := gitValue(ctx, inspection.WorktreeRoot, "rev-parse", "--verify", inspection.WorktreeHead+"^{tree}") |
| 66 | if err != nil { |
| 67 | return "", false, fmt.Errorf("read confirmed worktree tree: %w%s", err, stderrSuffix(stderr)) |
| 68 | } |
| 69 | if stagedTree == headTree { |
| 70 | return "", false, errors.New("confirmed worktree snapshot no longer contains committable changes; inspect again") |
| 71 | } |
| 72 | |
| 73 | committedHead, stderr, err := gitValue(ctx, inspection.WorktreeRoot, |
| 74 | "-c", "user.name=Reasonix", "-c", "user.email=reasonix@local", |
| 75 | "commit-tree", stagedTree, "-p", inspection.WorktreeHead, "-m", "worktree: save changes before merge back") |
| 76 | if err != nil { |
| 77 | return "", false, fmt.Errorf("create exact worktree commit: %w%s", err, stderrSuffix(stderr)) |
| 78 | } |
| 79 | noteMergeStep("after_worktree_commit") |
| 80 | if err := verifyCommitObject(ctx, inspection.WorktreeRoot, committedHead, inspection.WorktreeHead, stagedTree); err != nil { |
| 81 | return "", false, fmt.Errorf("auto-commit object identity changed: %w", err) |
| 82 | } |
| 83 | if err := verifyWorktreeMergeIdentity(ctx, inspection); err != nil { |
| 84 | return "", false, fmt.Errorf("worktree changed before auto-commit ref update: %w", err) |
| 85 | } |
| 86 | noteMergeStep("before_worktree_ref_transaction") |
| 87 | if err := verifyWorktreeMergeIdentity(ctx, inspection); err != nil { |
| 88 | return "", false, fmt.Errorf("worktree changed at auto-commit ref update: %w", err) |
| 89 | } |
| 90 | branchRef := "refs/heads/" + inspection.WorktreeBranch |
| 91 | input := fmt.Sprintf("update %s %s %s\n", branchRef, committedHead, inspection.WorktreeHead) |
| 92 | if _, stderr, err := runGitInput(ctx, inspection.WorktreeRoot, input, "update-ref", "--stdin"); err != nil { |
| 93 | installed, verifyErr := refEquals(ctx, inspection.WorktreeRoot, branchRef, committedHead) |
| 94 | if verifyErr != nil || installed { |
| 95 | return "", true, fmt.Errorf("auto-commit ref transaction is uncertain; recovery is required: %w%s", err, stderrSuffix(stderr)) |
| 96 | } |
| 97 | return "", false, fmt.Errorf("worktree branch changed before auto-commit ref update; the real index was preserved: %w%s", err, stderrSuffix(stderr)) |
| 98 | } |
| 99 | noteMergeStep("after_worktree_ref_update") |
| 100 | if err := verifyWorktreeCheckout(ctx, inspection, committedHead); err != nil { |
| 101 | return "", true, fmt.Errorf("auto-commit ref was installed but checkout identity changed; recovery is required: %w", err) |
| 102 | } |
| 103 | if err := verifyTemporaryIndexAgainstWorktree(ctx, inspection.WorktreeRoot, tempEnv, stagedTree); err != nil { |
| 104 | return "", true, fmt.Errorf("auto-commit ref was installed but worktree contents changed; recovery is required: %w", err) |
| 105 | } |
| 106 | noteMergeStep("before_worktree_index_sync") |
| 107 | if err := installIndexFileCAS(indexPath, tempIndex, originalIndex); err != nil { |
| 108 | return "", true, fmt.Errorf("auto-commit ref was installed but the real index changed; recovery is required: %w", err) |
| 109 | } |
| 110 | noteMergeStep("after_worktree_index_sync") |
| 111 | if err := verifyAutoCommitSuccess(ctx, inspection, committedHead, stagedTree); err != nil { |
| 112 | return "", true, fmt.Errorf("auto-commit was installed but final verification failed; recovery is required: %w", err) |
| 113 | } |
| 114 | return committedHead, false, nil |
| 115 | } |
| 116 | |
| 117 | func newTemporaryIndex(worktreeRoot string) (string, error) { |
| 118 | file, err := os.CreateTemp(filepath.Dir(worktreeRoot), ".reasonix-merge-index-*") |
| 119 | if err != nil { |
| 120 | return "", fmt.Errorf("allocate temporary index: %w", err) |
| 121 | } |
| 122 | path := file.Name() |
| 123 | if err := file.Close(); err != nil { |
| 124 | _ = os.Remove(path) |
| 125 | return "", fmt.Errorf("close temporary index placeholder: %w", err) |
| 126 | } |
| 127 | if err := os.Remove(path); err != nil { |
| 128 | return "", fmt.Errorf("prepare temporary index path: %w", err) |
| 129 | } |
| 130 | return path, nil |
| 131 | } |
| 132 | |
| 133 | func secureTemporaryIndex(path string) error { |
| 134 | if err := os.Chmod(path, 0o600); err != nil { |
| 135 | return fmt.Errorf("secure temporary index: %w", err) |
| 136 | } |
| 137 | return nil |
| 138 | } |
| 139 | |
| 140 | func snapshotRealIndex(ctx context.Context, root string) (string, []byte, error) { |
| 141 | indexPath, stderr, err := gitValue(ctx, root, "rev-parse", "--git-path", "index") |
| 142 | if err != nil { |
| 143 | return "", nil, fmt.Errorf("resolve real index path: %w%s", err, stderrSuffix(stderr)) |
| 144 | } |
| 145 | if !filepath.IsAbs(indexPath) { |
| 146 | indexPath = filepath.Join(root, indexPath) |
| 147 | } |
| 148 | body, err := os.ReadFile(indexPath) |
| 149 | if err != nil { |
| 150 | return "", nil, fmt.Errorf("snapshot real index: %w", err) |
| 151 | } |
| 152 | return filepath.Clean(indexPath), body, nil |
| 153 | } |
| 154 | |
| 155 | func verifyTemporaryIndex(ctx context.Context, inspection MergeInspection, env []string) (string, string, error) { |
| 156 | if err := verifyWorktreeMergeIdentity(ctx, inspection); err != nil { |
| 157 | return "", "", err |
| 158 | } |
| 159 | tree, stderr, err := gitValueEnv(ctx, inspection.WorktreeRoot, env, "write-tree") |
| 160 | if err != nil { |
| 161 | return "", "", fmt.Errorf("record temporary index tree: %w%s", err, stderrSuffix(stderr)) |
| 162 | } |
| 163 | if err := verifyTemporaryIndexAgainstWorktree(ctx, inspection.WorktreeRoot, env, tree); err != nil { |
| 164 | return "", "", err |
| 165 | } |
| 166 | entries, stderr, err := runGitEnv(ctx, inspection.WorktreeRoot, env, "ls-files", "--stage", "-z") |
| 167 | if err != nil { |
| 168 | return "", "", fmt.Errorf("snapshot temporary index entries: %w%s", err, stderrSuffix(stderr)) |
| 169 | } |
| 170 | return tree, entries, nil |
| 171 | } |
| 172 | |
| 173 | func verifyTemporaryIndexAgainstWorktree(ctx context.Context, root string, env []string, expectedTree string) error { |
| 174 | if _, stderr, err := runGitEnv(ctx, root, env, "diff", "--quiet", "--"); err != nil { |
| 175 | if exitCode(err) == 1 { |
| 176 | return errors.New("working tree differs from the temporary index") |
| 177 | } |
| 178 | return fmt.Errorf("verify temporary index worktree: %w%s", err, stderrSuffix(stderr)) |
| 179 | } |
| 180 | untracked, stderr, err := runGitEnv(ctx, root, env, "ls-files", "--others", "--exclude-standard", "-z") |
| 181 | if err != nil { |
| 182 | return fmt.Errorf("inspect untracked files with temporary index: %w%s", err, stderrSuffix(stderr)) |
| 183 | } |
| 184 | if untracked != "" { |
| 185 | return errors.New("untracked files remain outside the temporary index") |
| 186 | } |
| 187 | tree, stderr, err := gitValueEnv(ctx, root, env, "write-tree") |
| 188 | if err != nil { |
| 189 | return fmt.Errorf("re-read temporary index tree: %w%s", err, stderrSuffix(stderr)) |
| 190 | } |
| 191 | if tree != expectedTree { |
| 192 | return errors.New("temporary index tree changed while verifying the worktree") |
| 193 | } |
| 194 | return nil |
| 195 | } |
| 196 | |
| 197 | func installIndexFileCAS(indexPath, preparedPath string, expected []byte) (err error) { |
| 198 | info, err := os.Stat(indexPath) |
| 199 | if err != nil { |
| 200 | return fmt.Errorf("inspect real index: %w", err) |
| 201 | } |
| 202 | lockPath := indexPath + ".lock" |
| 203 | lock, err := os.OpenFile(lockPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, info.Mode().Perm()) |
| 204 | if err != nil { |
| 205 | return fmt.Errorf("lock real index: %w", err) |
| 206 | } |
| 207 | committed := false |
| 208 | defer func() { |
| 209 | if !committed { |
| 210 | _ = lock.Close() |
| 211 | _ = os.Remove(lockPath) |
| 212 | } |
| 213 | }() |
| 214 | current, err := os.ReadFile(indexPath) |
| 215 | if err != nil { |
| 216 | return fmt.Errorf("re-read real index: %w", err) |
| 217 | } |
| 218 | if !bytes.Equal(current, expected) { |
| 219 | return errors.New("real index bytes no longer match the confirmed snapshot") |
| 220 | } |
| 221 | prepared, err := os.Open(preparedPath) |
| 222 | if err != nil { |
| 223 | return fmt.Errorf("open prepared index: %w", err) |
| 224 | } |
| 225 | _, copyErr := io.Copy(lock, prepared) |
| 226 | closePreparedErr := prepared.Close() |
| 227 | if copyErr != nil { |
| 228 | return fmt.Errorf("copy prepared index: %w", copyErr) |
| 229 | } |
| 230 | if closePreparedErr != nil { |
| 231 | return fmt.Errorf("close prepared index: %w", closePreparedErr) |
| 232 | } |
| 233 | if err := lock.Sync(); err != nil { |
| 234 | return fmt.Errorf("sync prepared index: %w", err) |
| 235 | } |
| 236 | if err := lock.Close(); err != nil { |
| 237 | return fmt.Errorf("close prepared index: %w", err) |
| 238 | } |
| 239 | if err := fileutil.ClaimRename(lockPath, indexPath); err != nil { |
| 240 | return fmt.Errorf("install prepared index: %w", err) |
| 241 | } |
| 242 | committed = true |
| 243 | return nil |
| 244 | } |
| 245 | |
| 246 | func verifyCommitObject(ctx context.Context, root, commit, expectedParent, expectedTree string) error { |
| 247 | line, stderr, err := gitValue(ctx, root, "rev-list", "--parents", "-n", "1", commit) |
| 248 | if err != nil { |
| 249 | return fmt.Errorf("read auto-commit parents: %w%s", err, stderrSuffix(stderr)) |
| 250 | } |
| 251 | fields := strings.Fields(line) |
| 252 | if len(fields) != 2 || fields[0] != commit || fields[1] != expectedParent { |
| 253 | return errors.New("auto-commit does not have the confirmed HEAD as its unique parent") |
| 254 | } |
| 255 | tree, stderr, err := gitValue(ctx, root, "rev-parse", "--verify", commit+"^{tree}") |
| 256 | if err != nil { |
| 257 | return fmt.Errorf("read auto-commit tree: %w%s", err, stderrSuffix(stderr)) |
| 258 | } |
| 259 | if tree != expectedTree { |
| 260 | return errors.New("auto-commit tree differs from the confirmed temporary index tree") |
| 261 | } |
| 262 | return nil |
| 263 | } |
| 264 | |
| 265 | func verifyWorktreeCheckout(ctx context.Context, inspection MergeInspection, expectedHead string) error { |
| 266 | branch, stderr, err := gitValue(ctx, inspection.WorktreeRoot, "symbolic-ref", "--quiet", "--short", "HEAD") |
| 267 | if err != nil || branch != inspection.WorktreeBranch { |
| 268 | return fmt.Errorf("worktree branch is %q, expected %q%s", branch, inspection.WorktreeBranch, stderrSuffix(stderr)) |
| 269 | } |
| 270 | branchHead, stderr, err := gitValue(ctx, inspection.WorktreeRoot, "rev-parse", "--verify", "refs/heads/"+inspection.WorktreeBranch) |
| 271 | if err != nil || branchHead != expectedHead { |
| 272 | return fmt.Errorf("worktree branch HEAD is %s, expected %s%s", branchHead, expectedHead, stderrSuffix(stderr)) |
| 273 | } |
| 274 | head, stderr, err := gitValue(ctx, inspection.WorktreeRoot, "rev-parse", "--verify", "HEAD") |
| 275 | if err != nil || head != expectedHead { |
| 276 | return fmt.Errorf("worktree HEAD is %s, expected %s%s", head, expectedHead, stderrSuffix(stderr)) |
| 277 | } |
| 278 | operation, err := gitOperation(ctx, inspection.WorktreeRoot) |
| 279 | if err != nil { |
| 280 | return err |
| 281 | } |
| 282 | if operation != "" { |
| 283 | return fmt.Errorf("worktree Git %s operation is in progress", operation) |
| 284 | } |
| 285 | return nil |
| 286 | } |
| 287 | |
| 288 | func verifyAutoCommitSuccess(ctx context.Context, inspection MergeInspection, committedHead, expectedTree string) error { |
| 289 | if err := verifyWorktreeCheckout(ctx, inspection, committedHead); err != nil { |
| 290 | return err |
| 291 | } |
| 292 | if err := verifyCommitObject(ctx, inspection.WorktreeRoot, committedHead, inspection.WorktreeHead, expectedTree); err != nil { |
| 293 | return err |
| 294 | } |
| 295 | status, stderr, err := runGitEnv(ctx, inspection.WorktreeRoot, gitNoOptionalLocks, "status", "--porcelain=v1", "--untracked-files=all") |
| 296 | if err != nil { |
| 297 | return fmt.Errorf("verify auto-commit status: %w%s", err, stderrSuffix(stderr)) |
| 298 | } |
| 299 | if strings.TrimSpace(status) != "" { |
| 300 | return errors.New("worktree is not clean after auto-commit") |
| 301 | } |
| 302 | return nil |
| 303 | } |
| 304 | |
| 305 | func gitValueEnv(ctx context.Context, root string, env []string, args ...string) (string, string, error) { |
| 306 | out, stderr, err := runGitEnv(ctx, root, env, args...) |
| 307 | return strings.TrimSpace(out), stderr, err |
| 308 | } |
| 309 | |
| 310 | func hasStagedIndexChanges(ctx context.Context, root, head string) (bool, error) { |
| 311 | _, stderr, err := runGitEnv(ctx, root, gitNoOptionalLocks, "diff", "--cached", "--quiet", head, "--") |
| 312 | if err == nil { |
| 313 | return false, nil |
| 314 | } |
| 315 | if exitCode(err) == 1 { |
| 316 | return true, nil |
| 317 | } |
| 318 | return false, fmt.Errorf("inspect staged index changes: %w%s", err, stderrSuffix(stderr)) |
| 319 | } |
| 320 | |
| 321 | func refEquals(ctx context.Context, root, ref, expected string) (bool, error) { |
| 322 | value, stderr, err := gitValue(ctx, root, "rev-parse", "--verify", ref) |
| 323 | if err != nil { |
| 324 | return false, fmt.Errorf("read ref %s: %w%s", ref, err, stderrSuffix(stderr)) |
| 325 | } |
| 326 | return value == expected, nil |
| 327 | } |
| 328 |