返回 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 "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
524 lines GO