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