| 1 | package worktree |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "slices" |
| 10 | "sort" |
| 11 | "strings" |
| 12 | |
| 13 | "reasonix/internal/gitcmd" |
| 14 | ) |
| 15 | |
| 16 | const ( |
| 17 | mergeCommitterName = "Reasonix" |
| 18 | mergeCommitterEmail = "reasonix@local" |
| 19 | ) |
| 20 | |
| 21 | type sourceMutationFence struct { |
| 22 | files []*os.File |
| 23 | paths []string |
| 24 | } |
| 25 | |
| 26 | func mergeSourceCheckout(ctx context.Context, inspection MergeInspection) (string, bool, error) { |
| 27 | originalHead := inspection.TargetHead |
| 28 | message := fmt.Sprintf("Merge worktree branch '%s' into %s", inspection.WorktreeBranch, inspection.TargetBranch) |
| 29 | noteMergeStep("before_merge_prepare") |
| 30 | if err := verifySourceIdentity(ctx, inspection.source, inspection.TargetBranch, originalHead, false); err != nil { |
| 31 | return "", false, fmt.Errorf("source changed before merge preparation: %w", err) |
| 32 | } |
| 33 | if err := verifyWorktreeMergeIdentity(ctx, inspection); err != nil { |
| 34 | return "", false, fmt.Errorf("worktree changed before merge preparation: %w", err) |
| 35 | } |
| 36 | expectedTree, hasConflicts, conflictFiles, err := mergeTree(ctx, inspection.source, originalHead, inspection.WorktreeHead) |
| 37 | if err != nil { |
| 38 | return "", false, fmt.Errorf("recompute source merge tree: %w", err) |
| 39 | } |
| 40 | if hasConflicts { |
| 41 | return "", false, fmt.Errorf("source merge conflicts changed after inspection: %s", strings.Join(conflictFiles, ", ")) |
| 42 | } |
| 43 | if _, stderr, err := runGit(ctx, inspection.source, |
| 44 | "-c", "user.name="+mergeCommitterName, "-c", "user.email="+mergeCommitterEmail, |
| 45 | "merge", "--no-ff", "--no-commit", "--no-verify", inspection.WorktreeHead); err != nil { |
| 46 | recovered, recoveryErr := abortAndVerifyMerge(ctx, inspection.source, inspection.TargetBranch, originalHead) |
| 47 | if !recovered { |
| 48 | return "", true, fmt.Errorf("merge failed%s: %w", stderrSuffix(stderr), errors.Join(err, fmt.Errorf("automatic recovery failed: %w", recoveryErr))) |
| 49 | } |
| 50 | return "", false, fmt.Errorf("merge failed and was aborted: %w%s", err, stderrSuffix(stderr)) |
| 51 | } |
| 52 | noteMergeStep("after_merge_prepare") |
| 53 | if err := verifyPreparedMerge(ctx, inspection.source, inspection.TargetBranch, originalHead, inspection.WorktreeHead, expectedTree); err != nil { |
| 54 | return "", true, fmt.Errorf("merge preparation identity changed; source state was preserved for recovery: %w", err) |
| 55 | } |
| 56 | if err := verifyWorktreeMergeIdentity(ctx, inspection); err != nil { |
| 57 | return abortPreparedWorktreeDrift(ctx, inspection, originalHead, err) |
| 58 | } |
| 59 | mergedHead, stderr, err := gitValue(ctx, inspection.source, |
| 60 | "-c", "user.name="+mergeCommitterName, "-c", "user.email="+mergeCommitterEmail, |
| 61 | "commit-tree", expectedTree, "-p", originalHead, "-p", inspection.WorktreeHead, "-m", message) |
| 62 | if err != nil { |
| 63 | recovered, recoveryErr := abortAndVerifyMerge(ctx, inspection.source, inspection.TargetBranch, originalHead) |
| 64 | if !recovered { |
| 65 | return "", true, fmt.Errorf("create exact merge commit%s: %w", stderrSuffix(stderr), errors.Join(err, fmt.Errorf("automatic recovery failed: %w", recoveryErr))) |
| 66 | } |
| 67 | return "", false, fmt.Errorf("create exact merge commit: %w%s; merge was aborted", err, stderrSuffix(stderr)) |
| 68 | } |
| 69 | noteMergeStep("after_merge_commit_object") |
| 70 | if err := verifyPreparedMerge(ctx, inspection.source, inspection.TargetBranch, originalHead, inspection.WorktreeHead, expectedTree); err != nil { |
| 71 | return "", true, fmt.Errorf("source changed before target ref update; source state was preserved for recovery: %w", err) |
| 72 | } |
| 73 | snapshot, err := snapshotPreparedSourceFiles(ctx, inspection.source) |
| 74 | if err != nil { |
| 75 | return abortPreparedSourceDrift(ctx, inspection, originalHead, expectedTree, err) |
| 76 | } |
| 77 | noteMergeStep("before_merge_ref_update") |
| 78 | fence, err := acquireSourceMutationFence(ctx, inspection.source) |
| 79 | if err != nil { |
| 80 | return abortPreparedSourceDrift(ctx, inspection, originalHead, expectedTree, fmt.Errorf("acquire source mutation fence: %w", err)) |
| 81 | } |
| 82 | err = verifyPreparedSourceFiles(ctx, inspection.source, snapshot) |
| 83 | if err == nil { |
| 84 | err = verifyWorktreeMergeIdentity(ctx, inspection) |
| 85 | } |
| 86 | if err == nil { |
| 87 | err = verifyPreparedSourceFiles(ctx, inspection.source, snapshot) |
| 88 | } |
| 89 | if err != nil { |
| 90 | fence.release() |
| 91 | return abortPreparedSourceDrift(ctx, inspection, originalHead, expectedTree, err) |
| 92 | } |
| 93 | noteMergeStep("before_merge_ref_transaction") |
| 94 | if stderr, err := updateMergeRefs(ctx, inspection, originalHead, mergedHead); err != nil { |
| 95 | fence.release() |
| 96 | return recoverRefUpdateFailure(ctx, inspection, originalHead, expectedTree, stderr, err) |
| 97 | } |
| 98 | fence.release() |
| 99 | noteMergeStep("after_merge_ref_update") |
| 100 | if err := verifyWorktreeMergeIdentity(ctx, inspection); err != nil { |
| 101 | return "", true, fmt.Errorf("merge commit was installed but the worktree identity changed; recovery is required: %w", err) |
| 102 | } |
| 103 | if err := verifyInstalledMergeState(ctx, inspection, mergedHead, expectedTree); err != nil { |
| 104 | return "", true, fmt.Errorf("merge commit was installed but prepared source state changed; recovery is required: %w", err) |
| 105 | } |
| 106 | if _, stderr, err := runGit(ctx, inspection.source, "merge", "--quit"); err != nil { |
| 107 | return "", true, fmt.Errorf("merge commit was installed but merge state cleanup failed; source requires recovery: %w%s", err, stderrSuffix(stderr)) |
| 108 | } |
| 109 | noteMergeStep("after_merge_commit") |
| 110 | verifiedHead, err := verifySuccessfulMerge(ctx, inspection.source, inspection.TargetBranch, originalHead, inspection.WorktreeHead, expectedTree) |
| 111 | if err != nil { |
| 112 | return "", true, fmt.Errorf("merge succeeded but the source checkout requires recovery: %w", err) |
| 113 | } |
| 114 | if err := verifyWorktreeMergeIdentity(ctx, inspection); err != nil { |
| 115 | return "", true, fmt.Errorf("merge succeeded but the worktree identity changed; recovery is required: %w", err) |
| 116 | } |
| 117 | return verifiedHead, false, nil |
| 118 | } |
| 119 | |
| 120 | func abortPreparedSourceDrift(ctx context.Context, inspection MergeInspection, originalHead, expectedTree string, driftErr error) (string, bool, error) { |
| 121 | if verifyErr := verifyPreparedMerge(ctx, inspection.source, inspection.TargetBranch, originalHead, inspection.WorktreeHead, expectedTree); verifyErr != nil { |
| 122 | return "", true, fmt.Errorf("source changed before target ref update; source state was preserved for recovery: %w", driftErr) |
| 123 | } |
| 124 | recovered, recoveryErr := abortAndVerifyMerge(ctx, inspection.source, inspection.TargetBranch, originalHead) |
| 125 | if recovered { |
| 126 | return "", false, fmt.Errorf("source changed before target ref update; merge was aborted: %w", driftErr) |
| 127 | } |
| 128 | return "", true, fmt.Errorf("source changed before target ref update: %w", errors.Join(driftErr, fmt.Errorf("automatic recovery failed: %w", recoveryErr))) |
| 129 | } |
| 130 | |
| 131 | func abortPreparedWorktreeDrift(ctx context.Context, inspection MergeInspection, originalHead string, driftErr error) (string, bool, error) { |
| 132 | recovered, recoveryErr := abortAndVerifyMerge(ctx, inspection.source, inspection.TargetBranch, originalHead) |
| 133 | if recovered { |
| 134 | return "", false, fmt.Errorf("worktree changed before target ref update; merge was aborted: %w", driftErr) |
| 135 | } |
| 136 | return "", true, fmt.Errorf("worktree changed before target ref update: %w", errors.Join(driftErr, fmt.Errorf("automatic recovery failed: %w", recoveryErr))) |
| 137 | } |
| 138 | |
| 139 | func updateMergeRefs(ctx context.Context, inspection MergeInspection, originalHead, mergedHead string) (string, error) { |
| 140 | targetRef := "refs/heads/" + inspection.TargetBranch |
| 141 | worktreeRef := "refs/heads/" + inspection.WorktreeBranch |
| 142 | input := fmt.Sprintf("verify %s %s\nupdate %s %s %s\n", worktreeRef, inspection.WorktreeHead, targetRef, mergedHead, originalHead) |
| 143 | transactionDir, err := createDetachedRefTransactionDir(ctx, inspection.source) |
| 144 | if err != nil { |
| 145 | return "", err |
| 146 | } |
| 147 | transaction := inspection.source |
| 148 | transaction.GitDir = transactionDir |
| 149 | _, stderr, updateErr := runGitEnvInput(ctx, transaction, input, nil, "update-ref", "--stdin") |
| 150 | cleanupErr := removeDetachedRefTransactionDir(transactionDir) |
| 151 | err = errors.Join(updateErr, cleanupErr) |
| 152 | return stderr, err |
| 153 | } |
| 154 | |
| 155 | // createDetachedRefTransactionDir gives update-ref access to the repository's |
| 156 | // common ref store without identifying the source checkout as its active |
| 157 | // worktree. That lets the caller keep the source HEAD.lock held while Git owns |
| 158 | // and atomically updates the target/worktree branch refs. A normal update-ref |
| 159 | // run from the source checkout also tries to lock HEAD for its reflog. |
| 160 | func createDetachedRefTransactionDir(ctx context.Context, sourceRoot gitcmd.Repo) (string, error) { |
| 161 | commonDir := sourceRoot.CommonDir |
| 162 | transactionDir, err := os.MkdirTemp("", "reasonix-ref-transaction-") |
| 163 | if err != nil { |
| 164 | return "", fmt.Errorf("create detached ref transaction directory: %w", err) |
| 165 | } |
| 166 | write := func(name, body string) error { |
| 167 | path := filepath.Join(transactionDir, name) |
| 168 | if err := os.WriteFile(path, []byte(body), 0o600); err != nil { |
| 169 | return fmt.Errorf("write detached ref transaction %s: %w", name, err) |
| 170 | } |
| 171 | return nil |
| 172 | } |
| 173 | if err := write("commondir", commonDir+"\n"); err != nil { |
| 174 | _ = removeDetachedRefTransactionDir(transactionDir) |
| 175 | return "", err |
| 176 | } |
| 177 | if err := write("HEAD", "ref: refs/reasonix/merge-back-ref-transaction\n"); err != nil { |
| 178 | _ = removeDetachedRefTransactionDir(transactionDir) |
| 179 | return "", err |
| 180 | } |
| 181 | return transactionDir, nil |
| 182 | } |
| 183 | |
| 184 | func removeDetachedRefTransactionDir(path string) error { |
| 185 | var cleanupErr error |
| 186 | for _, name := range []string{"HEAD", "commondir"} { |
| 187 | if err := os.Remove(filepath.Join(path, name)); err != nil && !errors.Is(err, os.ErrNotExist) { |
| 188 | cleanupErr = errors.Join(cleanupErr, fmt.Errorf("remove detached ref transaction %s: %w", name, err)) |
| 189 | } |
| 190 | } |
| 191 | if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { |
| 192 | cleanupErr = errors.Join(cleanupErr, fmt.Errorf("remove detached ref transaction directory: %w", err)) |
| 193 | } |
| 194 | return cleanupErr |
| 195 | } |
| 196 | |
| 197 | func acquireSourceMutationFence(ctx context.Context, sourceRoot gitcmd.Repo) (*sourceMutationFence, error) { |
| 198 | fence := &sourceMutationFence{} |
| 199 | // update-ref must own HEAD.lock while advancing the checked-out target. |
| 200 | // Keep the mutable non-ref state fenced here and verify HEAD in the same |
| 201 | // ref transaction as the target/worktree refs. |
| 202 | markers := []string{"HEAD", "MERGE_HEAD", "index"} |
| 203 | paths := make([]string, 0, len(markers)) |
| 204 | for _, marker := range markers { |
| 205 | path, stderr, err := gitValue(ctx, sourceRoot, "rev-parse", "--git-path", marker) |
| 206 | if err != nil { |
| 207 | return nil, fmt.Errorf("resolve %s lock path: %w%s", marker, err, stderrSuffix(stderr)) |
| 208 | } |
| 209 | if !filepath.IsAbs(path) { |
| 210 | path = filepath.Join(sourceRoot.Dir, path) |
| 211 | } |
| 212 | paths = append(paths, filepath.Clean(path)+".lock") |
| 213 | } |
| 214 | sort.Strings(paths) |
| 215 | for _, path := range paths { |
| 216 | file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) |
| 217 | if err != nil { |
| 218 | fence.release() |
| 219 | return nil, fmt.Errorf("lock %s: %w", filepath.Base(strings.TrimSuffix(path, ".lock")), err) |
| 220 | } |
| 221 | fence.files = append(fence.files, file) |
| 222 | fence.paths = append(fence.paths, path) |
| 223 | } |
| 224 | return fence, nil |
| 225 | } |
| 226 | |
| 227 | func (fence *sourceMutationFence) release() { |
| 228 | if fence == nil { |
| 229 | return |
| 230 | } |
| 231 | for index, file := range slices.Backward(fence.files) { |
| 232 | _ = file.Close() |
| 233 | _ = os.Remove(fence.paths[index]) |
| 234 | } |
| 235 | fence.files = nil |
| 236 | fence.paths = nil |
| 237 | } |
| 238 | |
| 239 | type preparedSourceFiles map[string]string |
| 240 | |
| 241 | func snapshotPreparedSourceFiles(ctx context.Context, sourceRoot gitcmd.Repo) (preparedSourceFiles, error) { |
| 242 | snapshot := preparedSourceFiles{} |
| 243 | for _, marker := range []string{"HEAD", "MERGE_HEAD", "index"} { |
| 244 | path, stderr, err := gitValue(ctx, sourceRoot, "rev-parse", "--git-path", marker) |
| 245 | if err != nil { |
| 246 | return nil, fmt.Errorf("resolve prepared %s: %w%s", marker, err, stderrSuffix(stderr)) |
| 247 | } |
| 248 | if !filepath.IsAbs(path) { |
| 249 | path = filepath.Join(sourceRoot.Dir, path) |
| 250 | } |
| 251 | body, err := os.ReadFile(path) |
| 252 | if err != nil { |
| 253 | return nil, fmt.Errorf("read prepared %s: %w", marker, err) |
| 254 | } |
| 255 | snapshot[filepath.Clean(path)] = string(body) |
| 256 | } |
| 257 | return snapshot, nil |
| 258 | } |
| 259 | |
| 260 | func verifyPreparedSourceFiles(ctx context.Context, sourceRoot gitcmd.Repo, snapshot preparedSourceFiles) error { |
| 261 | current, err := snapshotPreparedSourceFiles(ctx, sourceRoot) |
| 262 | if err != nil { |
| 263 | return err |
| 264 | } |
| 265 | for path, expected := range snapshot { |
| 266 | if current[path] != expected { |
| 267 | return fmt.Errorf("prepared %s changed while target ref was fenced", filepath.Base(path)) |
| 268 | } |
| 269 | } |
| 270 | return nil |
| 271 | } |
| 272 | |
| 273 | func verifyInstalledMergeState(ctx context.Context, inspection MergeInspection, mergedHead, expectedTree string) error { |
| 274 | branch, stderr, err := gitValue(ctx, inspection.source, "symbolic-ref", "--quiet", "--short", "HEAD") |
| 275 | if err != nil || branch != inspection.TargetBranch { |
| 276 | return fmt.Errorf("source branch is %q, expected %q%s", branch, inspection.TargetBranch, stderrSuffix(stderr)) |
| 277 | } |
| 278 | head, stderr, err := gitValue(ctx, inspection.source, "rev-parse", "--verify", "HEAD") |
| 279 | if err != nil || head != mergedHead { |
| 280 | return fmt.Errorf("source HEAD is %s, expected installed merge %s%s", head, mergedHead, stderrSuffix(stderr)) |
| 281 | } |
| 282 | mergeHead, stderr, err := gitValue(ctx, inspection.source, "rev-parse", "--verify", "MERGE_HEAD") |
| 283 | if err != nil || mergeHead != inspection.WorktreeHead { |
| 284 | return fmt.Errorf("MERGE_HEAD changed from %s to %s%s", inspection.WorktreeHead, mergeHead, stderrSuffix(stderr)) |
| 285 | } |
| 286 | preparedTree, stderr, err := gitValue(ctx, inspection.source, "write-tree") |
| 287 | if err != nil || preparedTree != expectedTree { |
| 288 | return fmt.Errorf("prepared source tree is %s, expected %s%s", preparedTree, expectedTree, stderrSuffix(stderr)) |
| 289 | } |
| 290 | return nil |
| 291 | } |
| 292 | |
| 293 | func recoverRefUpdateFailure(ctx context.Context, inspection MergeInspection, originalHead, expectedTree, stderr string, updateErr error) (string, bool, error) { |
| 294 | targetRef, targetStderr, targetErr := gitValue(ctx, inspection.source, "rev-parse", "--verify", "refs/heads/"+inspection.TargetBranch) |
| 295 | if targetErr != nil || targetRef != originalHead { |
| 296 | return "", true, fmt.Errorf("target ref changed during compare-and-swap; source requires recovery: %w%s%s", updateErr, stderrSuffix(stderr), stderrSuffix(targetStderr)) |
| 297 | } |
| 298 | if verifyErr := verifyPreparedMerge(ctx, inspection.source, inspection.TargetBranch, originalHead, inspection.WorktreeHead, expectedTree); verifyErr != nil { |
| 299 | return "", true, fmt.Errorf("target ref changed during compare-and-swap; source requires recovery: %w%s", updateErr, stderrSuffix(stderr)) |
| 300 | } |
| 301 | recovered, recoveryErr := abortAndVerifyMerge(ctx, inspection.source, inspection.TargetBranch, originalHead) |
| 302 | if recovered { |
| 303 | return "", false, fmt.Errorf("target ref update failed and merge was aborted: %w%s", updateErr, stderrSuffix(stderr)) |
| 304 | } |
| 305 | return "", true, fmt.Errorf("target ref update failed%s: %w", stderrSuffix(stderr), errors.Join(updateErr, fmt.Errorf("automatic recovery failed: %w", recoveryErr))) |
| 306 | } |
| 307 | |
| 308 | func verifyWorktreeMergeIdentity(ctx context.Context, inspection MergeInspection) error { |
| 309 | if err := verifyRepositoryRoot(ctx, inspection.worktree); err != nil { |
| 310 | return fmt.Errorf("worktree checkout identity changed: %w", err) |
| 311 | } |
| 312 | if err := verifySameCommonDir(inspection.source, inspection.worktree); err != nil { |
| 313 | return fmt.Errorf("worktree repository identity changed: %w", err) |
| 314 | } |
| 315 | branch, stderr, err := gitValue(ctx, inspection.worktree, "symbolic-ref", "--quiet", "--short", "HEAD") |
| 316 | if err != nil || branch != inspection.WorktreeBranch { |
| 317 | return fmt.Errorf("worktree branch is %q, expected %q%s", branch, inspection.WorktreeBranch, stderrSuffix(stderr)) |
| 318 | } |
| 319 | branchHead, stderr, err := gitValue(ctx, inspection.worktree, "rev-parse", "--verify", "refs/heads/"+inspection.WorktreeBranch) |
| 320 | if err != nil || branchHead != inspection.WorktreeHead { |
| 321 | return fmt.Errorf("worktree branch HEAD changed from %s to %s%s", inspection.WorktreeHead, branchHead, stderrSuffix(stderr)) |
| 322 | } |
| 323 | head, stderr, err := gitValue(ctx, inspection.worktree, "rev-parse", "--verify", "HEAD") |
| 324 | if err != nil || head != inspection.WorktreeHead { |
| 325 | return fmt.Errorf("worktree HEAD changed from %s to %s%s", inspection.WorktreeHead, head, stderrSuffix(stderr)) |
| 326 | } |
| 327 | operation, err := gitOperation(ctx, inspection.worktree) |
| 328 | if err != nil { |
| 329 | return err |
| 330 | } |
| 331 | if operation != "" { |
| 332 | return fmt.Errorf("worktree Git %s operation is in progress", operation) |
| 333 | } |
| 334 | token, err := worktreeStateToken(ctx, inspection.worktree) |
| 335 | if err != nil { |
| 336 | return fmt.Errorf("snapshot worktree contents: %w", err) |
| 337 | } |
| 338 | if token != inspection.WorktreeStateToken { |
| 339 | return errors.New("worktree contents changed after confirmation") |
| 340 | } |
| 341 | return nil |
| 342 | } |
| 343 | |
| 344 | func mergeTree(ctx context.Context, root gitcmd.Repo, targetHead, worktreeHead string) (string, bool, []string, error) { |
| 345 | out, stderr, err := runGit(ctx, root, "merge-tree", "--write-tree", "--name-only", targetHead, worktreeHead) |
| 346 | lines := strings.Split(out, "\n") |
| 347 | tree := "" |
| 348 | if len(lines) > 0 { |
| 349 | tree = strings.TrimSpace(lines[0]) |
| 350 | } |
| 351 | if err == nil { |
| 352 | if !isHexObject(tree) { |
| 353 | return "", false, []string{}, errors.New("preflight merge did not produce a tree") |
| 354 | } |
| 355 | return tree, false, []string{}, nil |
| 356 | } |
| 357 | if exitCode(err) != 1 { |
| 358 | return "", false, []string{}, fmt.Errorf("preflight merge conflicts: %w%s", err, stderrSuffix(stderr)) |
| 359 | } |
| 360 | paths := []string{} |
| 361 | for index, line := range lines { |
| 362 | line = strings.TrimSpace(line) |
| 363 | if index == 0 || line == "" || strings.Contains(line, " ") || isHexObject(line) { |
| 364 | continue |
| 365 | } |
| 366 | paths = append(paths, line) |
| 367 | } |
| 368 | sort.Strings(paths) |
| 369 | return tree, true, paths, nil |
| 370 | } |
| 371 | |
| 372 | func isHexObject(value string) bool { |
| 373 | if len(value) != 40 && len(value) != 64 { |
| 374 | return false |
| 375 | } |
| 376 | for _, char := range value { |
| 377 | if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f')) { |
| 378 | return false |
| 379 | } |
| 380 | } |
| 381 | return true |
| 382 | } |
| 383 | |
| 384 | func verifySourceIdentity(ctx context.Context, sourceRoot gitcmd.Repo, targetBranch, originalHead string, expectMerge bool) error { |
| 385 | branch, stderr, err := gitValue(ctx, sourceRoot, "symbolic-ref", "--quiet", "--short", "HEAD") |
| 386 | if err != nil || branch != targetBranch { |
| 387 | return fmt.Errorf("source branch is %q, expected %q%s", branch, targetBranch, stderrSuffix(stderr)) |
| 388 | } |
| 389 | head, stderr, err := gitValue(ctx, sourceRoot, "rev-parse", "--verify", "HEAD") |
| 390 | if err != nil || head != originalHead { |
| 391 | return fmt.Errorf("source HEAD changed from %s to %s%s", originalHead, head, stderrSuffix(stderr)) |
| 392 | } |
| 393 | operation, err := gitOperation(ctx, sourceRoot) |
| 394 | if err != nil { |
| 395 | return err |
| 396 | } |
| 397 | if expectMerge && operation != "merge" { |
| 398 | return fmt.Errorf("prepared merge operation is missing (found %q)", operation) |
| 399 | } |
| 400 | if !expectMerge && operation != "" { |
| 401 | return fmt.Errorf("source Git %s operation is already in progress", operation) |
| 402 | } |
| 403 | if !expectMerge { |
| 404 | status, stderr, err := runGit(ctx, sourceRoot, "status", "--porcelain=v1", "--untracked-files=all") |
| 405 | if err != nil { |
| 406 | return fmt.Errorf("inspect source status: %w%s", err, stderrSuffix(stderr)) |
| 407 | } |
| 408 | if strings.TrimSpace(status) != "" { |
| 409 | return errors.New("source checkout is no longer clean") |
| 410 | } |
| 411 | } |
| 412 | return nil |
| 413 | } |
| 414 | |
| 415 | func verifyPreparedMerge(ctx context.Context, sourceRoot gitcmd.Repo, targetBranch, originalHead, worktreeHead, expectedTree string) error { |
| 416 | if err := verifySourceIdentity(ctx, sourceRoot, targetBranch, originalHead, true); err != nil { |
| 417 | return err |
| 418 | } |
| 419 | mergeHead, stderr, err := gitValue(ctx, sourceRoot, "rev-parse", "--verify", "MERGE_HEAD") |
| 420 | if err != nil { |
| 421 | return fmt.Errorf("read prepared MERGE_HEAD: %w%s", err, stderrSuffix(stderr)) |
| 422 | } |
| 423 | if mergeHead != worktreeHead { |
| 424 | return fmt.Errorf("prepared MERGE_HEAD is %s, expected %s", mergeHead, worktreeHead) |
| 425 | } |
| 426 | preparedTree, stderr, err := gitValue(ctx, sourceRoot, "write-tree") |
| 427 | if err != nil { |
| 428 | return fmt.Errorf("read prepared merge tree: %w%s", err, stderrSuffix(stderr)) |
| 429 | } |
| 430 | if preparedTree != expectedTree { |
| 431 | return fmt.Errorf("prepared merge tree is %s, expected %s", preparedTree, expectedTree) |
| 432 | } |
| 433 | return nil |
| 434 | } |
| 435 | |
| 436 | func abortAndVerifyMerge(ctx context.Context, sourceRoot gitcmd.Repo, targetBranch, originalHead string) (bool, error) { |
| 437 | operation, operationErr := gitOperation(ctx, sourceRoot) |
| 438 | if operationErr != nil { |
| 439 | return false, operationErr |
| 440 | } |
| 441 | if operation == "merge" { |
| 442 | if _, stderr, err := runGit(ctx, sourceRoot, "merge", "--abort"); err != nil { |
| 443 | return false, fmt.Errorf("git merge --abort: %w%s", err, stderrSuffix(stderr)) |
| 444 | } |
| 445 | } |
| 446 | if err := verifySourceIdentity(ctx, sourceRoot, targetBranch, originalHead, false); err != nil { |
| 447 | return false, err |
| 448 | } |
| 449 | branchRef, stderr, err := gitValue(ctx, sourceRoot, "rev-parse", "--verify", "refs/heads/"+targetBranch) |
| 450 | if err != nil || branchRef != originalHead { |
| 451 | return false, fmt.Errorf("target branch ref was not restored%s", stderrSuffix(stderr)) |
| 452 | } |
| 453 | status, stderr, err := runGit(ctx, sourceRoot, "status", "--porcelain=v1", "--untracked-files=all") |
| 454 | if err != nil || strings.TrimSpace(status) != "" { |
| 455 | return false, fmt.Errorf("source checkout was not restored clean%s", stderrSuffix(stderr)) |
| 456 | } |
| 457 | operation, err = gitOperation(ctx, sourceRoot) |
| 458 | if err != nil || operation != "" { |
| 459 | return false, fmt.Errorf("source Git operation remains after abort: %s", operation) |
| 460 | } |
| 461 | return true, nil |
| 462 | } |
| 463 | |
| 464 | func verifySuccessfulMerge(ctx context.Context, sourceRoot gitcmd.Repo, targetBranch, originalHead, worktreeHead, expectedTree string) (string, error) { |
| 465 | branch, stderr, err := gitValue(ctx, sourceRoot, "symbolic-ref", "--quiet", "--short", "HEAD") |
| 466 | if err != nil || branch != targetBranch { |
| 467 | return "", fmt.Errorf("source branch changed after merge; found %q, expected %q%s", branch, targetBranch, stderrSuffix(stderr)) |
| 468 | } |
| 469 | mergedHead, stderr, err := gitValue(ctx, sourceRoot, "rev-parse", "--verify", "HEAD") |
| 470 | if err != nil { |
| 471 | return "", fmt.Errorf("read merged target HEAD: %w%s", err, stderrSuffix(stderr)) |
| 472 | } |
| 473 | branchHead, stderr, err := gitValue(ctx, sourceRoot, "rev-parse", "--verify", "refs/heads/"+targetBranch) |
| 474 | if err != nil || branchHead != mergedHead { |
| 475 | return "", fmt.Errorf("target branch ref does not identify the merge commit%s", stderrSuffix(stderr)) |
| 476 | } |
| 477 | commitTree, stderr, err := gitValue(ctx, sourceRoot, "rev-parse", "--verify", mergedHead+"^{tree}") |
| 478 | if err != nil { |
| 479 | return "", fmt.Errorf("read merge commit tree: %w%s", err, stderrSuffix(stderr)) |
| 480 | } |
| 481 | if commitTree != expectedTree { |
| 482 | return "", errors.New("merge commit tree differs from the exact prepared tree") |
| 483 | } |
| 484 | indexTree, stderr, err := gitValue(ctx, sourceRoot, "write-tree") |
| 485 | if err != nil { |
| 486 | return "", fmt.Errorf("read merged source index tree: %w%s", err, stderrSuffix(stderr)) |
| 487 | } |
| 488 | if indexTree != expectedTree { |
| 489 | return "", errors.New("merged source index differs from the exact prepared tree") |
| 490 | } |
| 491 | parents, stderr, err := gitValue(ctx, sourceRoot, "rev-list", "--parents", "-n", "1", mergedHead) |
| 492 | if err != nil { |
| 493 | return "", fmt.Errorf("read merge commit parents: %w%s", err, stderrSuffix(stderr)) |
| 494 | } |
| 495 | fields := strings.Fields(parents) |
| 496 | if len(fields) != 3 || fields[0] != mergedHead || fields[1] != originalHead || fields[2] != worktreeHead { |
| 497 | return "", errors.New("merge commit does not have the exact prepared parents") |
| 498 | } |
| 499 | for _, ancestor := range []struct{ label, head string }{{"original target", originalHead}, {"worktree", worktreeHead}} { |
| 500 | contained, ancestorErr := isAncestor(ctx, sourceRoot, ancestor.head, mergedHead) |
| 501 | if ancestorErr != nil { |
| 502 | return "", fmt.Errorf("verify %s ancestry: %w", ancestor.label, ancestorErr) |
| 503 | } |
| 504 | if !contained { |
| 505 | return "", fmt.Errorf("%s HEAD is not contained in merged target", ancestor.label) |
| 506 | } |
| 507 | } |
| 508 | status, stderr, err := runGit(ctx, sourceRoot, "status", "--porcelain=v1", "--untracked-files=all") |
| 509 | if err != nil { |
| 510 | return "", fmt.Errorf("verify merged source status: %w%s", err, stderrSuffix(stderr)) |
| 511 | } |
| 512 | if strings.TrimSpace(status) != "" { |
| 513 | return "", errors.New("merged source checkout is not clean") |
| 514 | } |
| 515 | operation, err := gitOperation(ctx, sourceRoot) |
| 516 | if err != nil { |
| 517 | return "", err |
| 518 | } |
| 519 | if operation != "" { |
| 520 | return "", fmt.Errorf("source Git %s operation remains after merge", operation) |
| 521 | } |
| 522 | return mergedHead, nil |
| 523 | } |
| 524 |