返回 DeepSeek-Reasonix
merge_commit.go
根目录 / internal / worktree / merge_commit.go
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
527 lines GO