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