返回 DeepSeek-Reasonix
merge.go
根目录 / internal / worktree / merge.go
1 package worktree
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "sort"
11 "strconv"
12 "strings"
13 )
14
15 // MergeBlocker is a stable, structured reason why merge or cleanup cannot
16 // proceed. Message remains suitable for older clients while Code lets newer
17 // clients localize and group the failure.
18 type MergeBlocker struct {
19 Code string `json:"code"`
20 Message string `json:"message"`
21 Paths []string `json:"paths"`
22 }
23
24 // MergeInspection describes the exact identities used by a later merge
25 // request. Every mutable identity must be sent back by the caller.
26 type MergeInspection struct {
27 Available bool `json:"available"`
28 Reason string `json:"reason,omitempty"`
29 CanMerge bool `json:"canMerge"`
30 AlreadyMerged bool `json:"alreadyMerged"`
31 WorktreeRoot string `json:"worktreeRoot,omitempty"`
32 SourceRoot string `json:"sourceRoot,omitempty"`
33 WorktreeBranch string `json:"worktreeBranch,omitempty"`
34 TargetBranch string `json:"targetBranch,omitempty"`
35 CreatedHead string `json:"createdHead,omitempty"`
36 WorktreeHead string `json:"worktreeHead,omitempty"`
37 WorktreeStateToken string `json:"worktreeStateToken,omitempty"`
38 TargetHead string `json:"targetHead,omitempty"`
39 AheadCount int `json:"aheadCount"`
40 BehindCount int `json:"behindCount"`
41 FilesChanged int `json:"filesChanged"`
42 Insertions int `json:"insertions"`
43 Deletions int `json:"deletions"`
44 ChangedFiles []string `json:"changedFiles"`
45 HasConflicts bool `json:"hasConflicts"`
46 ConflictFiles []string `json:"conflictFiles"`
47 WorktreeDirty bool `json:"worktreeDirty"`
48 SourceDirty bool `json:"sourceDirty"`
49 Blockers []MergeBlocker `json:"blockers"`
50 CleanupBlockers []MergeBlocker `json:"cleanupBlockers"`
51 }
52
53 // MergeRequest proves that the user confirmed a specific inspection. A target
54 // branch or HEAD drift never silently turns into a different merge.
55 type MergeRequest struct {
56 WorkspaceRoot string `json:"workspaceRoot"`
57 ExpectedTargetBranch string `json:"expectedTargetBranch"`
58 ExpectedTargetHead string `json:"expectedTargetHead"`
59 ExpectedWorktreeHead string `json:"expectedWorktreeHead"`
60 ExpectedWorktreeStateToken string `json:"expectedWorktreeStateToken"`
61 AutoCommitDirty bool `json:"autoCommitDirty"`
62 }
63
64 // MergeResult is a merge receipt and cleanup identity. MergeBack never removes
65 // the worktree or its temporary branch.
66 type MergeResult struct {
67 Merged bool `json:"merged"`
68 AlreadyMerged bool `json:"alreadyMerged"`
69 RecoveryRequired bool `json:"recoveryRequired"`
70 SourceRoot string `json:"sourceRoot,omitempty"`
71 TargetBranch string `json:"targetBranch,omitempty"`
72 TargetHead string `json:"targetHead,omitempty"`
73 MergedCommit string `json:"mergedCommit,omitempty"`
74 WorktreeRoot string `json:"worktreeRoot,omitempty"`
75 WorktreeBranch string `json:"worktreeBranch,omitempty"`
76 WorktreeHead string `json:"worktreeHead,omitempty"`
77 Error string `json:"error,omitempty"`
78 }
79
80 // CleanupRequest carries the immutable merge receipt needed for a safe retry.
81 type CleanupRequest struct {
82 WorktreeRoot string `json:"worktreeRoot"`
83 SourceRoot string `json:"sourceRoot"`
84 TargetBranch string `json:"targetBranch"`
85 MergedCommit string `json:"mergedCommit"`
86 WorktreeBranch string `json:"worktreeBranch"`
87 WorktreeHead string `json:"worktreeHead"`
88 }
89
90 // CleanupResult reports partial success without hiding recoverable resources.
91 type CleanupResult struct {
92 Completed bool `json:"completed"`
93 WorktreeRemoved bool `json:"worktreeRemoved"`
94 BranchDeleted bool `json:"branchDeleted"`
95 RecoveryRetained bool `json:"recoveryRetained,omitempty"`
96 RecoveryRoot string `json:"recoveryRoot,omitempty"`
97 RecoveryWorktreeRegistered bool `json:"recoveryWorktreeRegistered,omitempty"`
98 BranchRetained bool `json:"branchRetained,omitempty"`
99 Blockers []MergeBlocker `json:"blockers"`
100 Error string `json:"error,omitempty"`
101 }
102
103 // mergeStepHook is test-only. Tests install it before starting a merge and do
104 // not mutate it concurrently; it makes otherwise sub-millisecond identity
105 // windows deterministic without weakening production checks.
106 var mergeStepHook func(string)
107
108 func noteMergeStep(step string) {
109 if mergeStepHook != nil {
110 mergeStepHook(step)
111 }
112 }
113
114 // InspectMerge performs a failure-closed inspection using creation metadata.
115 func InspectMerge(ctx context.Context, workspaceRoot, managedRoot string) (MergeInspection, error) {
116 inspection := emptyMergeInspection()
117 metadata, err := identifyMergeWorkspace(ctx, workspaceRoot, managedRoot, &inspection)
118 if err != nil {
119 return unavailableInspection(inspection, err.Error())
120 }
121 worktreeStatus, err := inspectCheckoutStates(ctx, metadata, &inspection)
122 if err != nil {
123 return unavailableInspection(inspection, err.Error())
124 }
125 if err := inspectMergeDivergence(ctx, metadata, worktreeStatus, &inspection); err != nil {
126 return unavailableInspection(inspection, err.Error())
127 }
128 if err := inspectCleanupBlockers(ctx, metadata, &inspection); err != nil {
129 return unavailableInspection(inspection, err.Error())
130 }
131 inspection.CanMerge = !hasBlockingMergeIssue(inspection.Blockers)
132 return inspection, nil
133 }
134
135 func identifyMergeWorkspace(ctx context.Context, workspaceRoot, managedRoot string, inspection *MergeInspection) (mergeMetadata, error) {
136 workspaceRoot = strings.TrimSpace(workspaceRoot)
137 if workspaceRoot == "" {
138 return mergeMetadata{}, errors.New("workspace root is required")
139 }
140 worktreeRoot, stderr, err := runGit(ctx, workspaceRoot, "rev-parse", "--show-toplevel")
141 if err != nil {
142 return mergeMetadata{}, fmt.Errorf("resolve worktree root: %w%s", err, stderrSuffix(stderr))
143 }
144 worktreeRoot = filepath.Clean(strings.TrimSpace(worktreeRoot))
145 metadata, _, err := readMergeMetadata(worktreeRoot, managedRoot)
146 if err != nil {
147 return mergeMetadata{}, err
148 }
149 *inspection = emptyMergeInspection()
150 inspection.Available = true
151 inspection.WorktreeRoot, inspection.SourceRoot = metadata.WorktreeRoot, metadata.SourceRoot
152 inspection.WorktreeBranch, inspection.TargetBranch = metadata.WorktreeBranch, metadata.TargetBranch
153 inspection.CreatedHead = metadata.CreatedHead
154 if err := sameDirectory(worktreeRoot, metadata.WorktreeRoot); err != nil {
155 return mergeMetadata{}, errors.New("workspace is not the metadata worktree root")
156 }
157 if err := verifySameCommonDir(ctx, metadata.SourceRoot, metadata.WorktreeRoot); err != nil {
158 return mergeMetadata{}, err
159 }
160 if err := verifyRepositoryRoot(ctx, metadata.SourceRoot); err != nil {
161 return mergeMetadata{}, fmt.Errorf("source checkout identity changed: %w", err)
162 }
163 if metadata.TargetBranch == "" {
164 inspection.Blockers = append(inspection.Blockers, blocker("target_branch_missing", "creation metadata does not contain a target branch"))
165 } else if _, _, err := runGit(ctx, metadata.SourceRoot, "check-ref-format", "refs/heads/"+metadata.TargetBranch); err != nil {
166 inspection.Blockers = append(inspection.Blockers, blocker("target_branch_missing", "creation metadata does not contain a valid target branch"))
167 }
168 if _, _, err := runGit(ctx, metadata.WorktreeRoot, "check-ref-format", "refs/heads/"+metadata.WorktreeBranch); err != nil {
169 return mergeMetadata{}, errors.New("worktree metadata contains an invalid branch")
170 }
171 return metadata, nil
172 }
173
174 func inspectCheckoutStates(ctx context.Context, metadata mergeMetadata, inspection *MergeInspection) (string, error) {
175 worktreeBranch, stderr, err := gitValue(ctx, metadata.WorktreeRoot, "symbolic-ref", "--quiet", "--short", "HEAD")
176 if err != nil {
177 return "", fmt.Errorf("worktree is detached or unreadable%s", stderrSuffix(stderr))
178 }
179 if worktreeBranch != metadata.WorktreeBranch {
180 return "", fmt.Errorf("worktree branch changed from %q to %q", metadata.WorktreeBranch, worktreeBranch)
181 }
182 inspection.WorktreeHead, stderr, err = gitValue(ctx, metadata.WorktreeRoot, "rev-parse", "--verify", "HEAD")
183 if err != nil {
184 return "", fmt.Errorf("read worktree HEAD: %w%s", err, stderrSuffix(stderr))
185 }
186 sourceBranch, _, branchErr := gitValue(ctx, metadata.SourceRoot, "symbolic-ref", "--quiet", "--short", "HEAD")
187 if branchErr != nil {
188 inspection.Blockers = append(inspection.Blockers, blocker("source_detached", "the recorded source checkout is detached"))
189 } else if sourceBranch != metadata.TargetBranch {
190 inspection.Blockers = append(inspection.Blockers, blocker("target_branch_drift", fmt.Sprintf("source checkout is on %q, expected %q", sourceBranch, metadata.TargetBranch)))
191 }
192 inspection.TargetHead, stderr, err = gitValue(ctx, metadata.SourceRoot, "rev-parse", "--verify", "HEAD")
193 if err != nil {
194 return "", fmt.Errorf("read target HEAD: %w%s", err, stderrSuffix(stderr))
195 }
196 worktreeStatus, stderr, err := runGit(ctx, metadata.WorktreeRoot, "status", "--porcelain=v1", "--untracked-files=all")
197 if err != nil {
198 return "", fmt.Errorf("inspect worktree changes: %w%s", err, stderrSuffix(stderr))
199 }
200 inspection.WorktreeDirty = strings.TrimSpace(worktreeStatus) != ""
201 inspection.WorktreeStateToken, err = worktreeStateToken(ctx, metadata.WorktreeRoot)
202 if err != nil {
203 return "", fmt.Errorf("snapshot worktree changes: %w", err)
204 }
205 if inspection.WorktreeDirty {
206 inspection.Blockers = append(inspection.Blockers, blocker("worktree_dirty", "worktree has uncommitted changes"))
207 }
208 if err := inspectSourceState(ctx, metadata.SourceRoot, inspection); err != nil {
209 return "", err
210 }
211 return worktreeStatus, nil
212 }
213
214 func inspectSourceState(ctx context.Context, sourceRoot string, inspection *MergeInspection) error {
215 status, stderr, err := runGit(ctx, sourceRoot, "status", "--porcelain=v1", "--untracked-files=all")
216 if err != nil {
217 return fmt.Errorf("inspect source changes: %w%s", err, stderrSuffix(stderr))
218 }
219 inspection.SourceDirty = strings.TrimSpace(status) != ""
220 if inspection.SourceDirty {
221 inspection.Blockers = append(inspection.Blockers, blocker("source_dirty", "the recorded source checkout has uncommitted changes"))
222 }
223 operation, err := gitOperation(ctx, sourceRoot)
224 if err != nil {
225 return err
226 }
227 if operation != "" {
228 inspection.Blockers = append(inspection.Blockers, blocker("source_operation", "the source checkout has an in-progress Git "+operation))
229 }
230 return nil
231 }
232
233 func inspectMergeDivergence(ctx context.Context, metadata mergeMetadata, worktreeStatus string, inspection *MergeInspection) error {
234 if metadata.TargetBranch == "" {
235 return nil
236 }
237 behind, ahead, err := aheadBehind(ctx, metadata.WorktreeRoot, inspection.TargetHead, inspection.WorktreeHead)
238 if err != nil {
239 return err
240 }
241 inspection.AheadCount, inspection.BehindCount = ahead, behind
242 inspection.FilesChanged, inspection.Insertions, inspection.Deletions, inspection.ChangedFiles, err = diffStats(ctx, metadata.WorktreeRoot, inspection.TargetHead, inspection.WorktreeHead, worktreeStatus)
243 if err != nil {
244 return err
245 }
246 inspection.AlreadyMerged, err = isAncestor(ctx, metadata.SourceRoot, inspection.WorktreeHead, inspection.TargetHead)
247 if err != nil {
248 return fmt.Errorf("check merged ancestry: %w", err)
249 }
250 if inspection.AlreadyMerged {
251 return nil
252 }
253 _, inspection.HasConflicts, inspection.ConflictFiles, err = mergeTree(ctx, metadata.SourceRoot, inspection.TargetHead, inspection.WorktreeHead)
254 if err != nil {
255 return err
256 }
257 if inspection.HasConflicts {
258 inspection.Blockers = append(inspection.Blockers, MergeBlocker{Code: "merge_conflict", Message: "the branches do not merge cleanly", Paths: inspection.ConflictFiles})
259 }
260 return nil
261 }
262
263 func inspectCleanupBlockers(ctx context.Context, metadata mergeMetadata, inspection *MergeInspection) error {
264 status, stderr, err := runGitEnv(ctx, metadata.WorktreeRoot, gitNoOptionalLocks, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored")
265 if err != nil {
266 return fmt.Errorf("inspect cleanup safety: %w%s", err, stderrSuffix(stderr))
267 }
268 paths, err := nulStatusPaths(status)
269 if err != nil {
270 return fmt.Errorf("decode cleanup safety: %w", err)
271 }
272 if len(paths) > 0 {
273 inspection.CleanupBlockers = append(inspection.CleanupBlockers, MergeBlocker{Code: "worktree_content", Message: "tracked, untracked, or ignored files would be preserved", Paths: paths})
274 }
275 if !inspection.AlreadyMerged {
276 inspection.CleanupBlockers = append(inspection.CleanupBlockers, blocker("not_merged", "worktree HEAD is not contained in the target branch"))
277 }
278 return nil
279 }
280
281 // MergeBack commits opted-in dirty changes, re-runs inspection, and merges.
282 // It never removes the worktree or its branch.
283 func MergeBack(ctx context.Context, managedRoot string, request MergeRequest) (MergeResult, error) {
284 inspection, err := InspectMerge(ctx, request.WorkspaceRoot, managedRoot)
285 if err != nil {
286 return mergeFailure(inspection, false, err)
287 }
288 if err := verifyExpectedInspection(inspection, request); err != nil {
289 return mergeFailure(inspection, false, err)
290 }
291 if inspection.WorktreeDirty {
292 if !request.AutoCommitDirty {
293 return mergeFailure(inspection, false, errors.New("worktree has uncommitted changes; explicit auto-commit is required"))
294 }
295 committedHead, recoveryRequired, err := autoCommitDirtyWorktree(ctx, inspection)
296 if err != nil {
297 return mergeFailure(inspection, recoveryRequired, err)
298 }
299 inspection, err = InspectMerge(ctx, request.WorkspaceRoot, managedRoot)
300 if err != nil {
301 return mergeFailure(inspection, false, fmt.Errorf("re-inspect after auto-commit: %w", err))
302 }
303 if inspection.WorktreeHead != committedHead {
304 return mergeFailure(inspection, false, errors.New("worktree HEAD changed after Reasonix auto-commit; inspect again"))
305 }
306 request.ExpectedWorktreeHead = committedHead
307 request.ExpectedWorktreeStateToken = inspection.WorktreeStateToken
308 if err := verifyExpectedInspection(inspection, request); err != nil {
309 return mergeFailure(inspection, false, err)
310 }
311 }
312 if !inspection.CanMerge {
313 return mergeFailure(inspection, false, fmt.Errorf("merge is blocked: %s", blockerMessages(inspection.Blockers)))
314 }
315 if inspection.AlreadyMerged {
316 return mergeReceipt(inspection, inspection.TargetHead, true), nil
317 }
318
319 mergedHead, recoveryRequired, err := mergeSourceCheckout(ctx, inspection)
320 if err != nil {
321 return mergeFailure(inspection, recoveryRequired, err)
322 }
323 return mergeReceipt(inspection, mergedHead, false), nil
324 }
325
326 // FinalizeMerge moves an exact, clean, fully merged worktree to a registered
327 // recovery location and retains its branch. Callers must separately prove
328 // there are no runtime or visible-tab references before invoking it.
329 func FinalizeMerge(ctx context.Context, managedRoot string, request CleanupRequest) (CleanupResult, error) {
330 result := CleanupResult{Blockers: []MergeBlocker{}}
331 metadata, metadataFile, rootExists, err := readMergeMetadataForCleanup(request.WorktreeRoot, managedRoot)
332 if err != nil {
333 return cleanupFailure(result, err)
334 }
335 if err := verifyCleanupIdentity(metadata, request); err != nil {
336 return cleanupFailure(result, err)
337 }
338 if err := verifyRepositoryRoot(ctx, metadata.SourceRoot); err != nil {
339 return cleanupFailure(result, fmt.Errorf("source checkout identity changed: %w", err))
340 }
341 sourceBranch, stderr, err := gitValue(ctx, metadata.SourceRoot, "symbolic-ref", "--quiet", "--short", "HEAD")
342 if err != nil || sourceBranch != metadata.TargetBranch {
343 return cleanupFailure(result, fmt.Errorf("source checkout is not on target branch %q%s", metadata.TargetBranch, stderrSuffix(stderr)))
344 }
345 targetHead, stderr, err := gitValue(ctx, metadata.SourceRoot, "rev-parse", "--verify", "HEAD")
346 if err != nil {
347 return cleanupFailure(result, fmt.Errorf("read target HEAD: %w%s", err, stderrSuffix(stderr)))
348 }
349 if ok, err := isAncestor(ctx, metadata.SourceRoot, request.MergedCommit, targetHead); err != nil || !ok {
350 return cleanupFailure(result, errors.New("the recorded merge commit is no longer contained in the target branch"))
351 }
352 if ok, err := isAncestor(ctx, metadata.SourceRoot, request.WorktreeHead, targetHead); err != nil || !ok {
353 return cleanupFailure(result, errors.New("worktree HEAD is not contained in the target branch"))
354 }
355
356 retention, err := finalizeCleanupWorktree(ctx, metadata, request.WorktreeHead, rootExists)
357 result.Blockers = append(result.Blockers, retention.Blockers...)
358 result.RecoveryRetained = retention.RecoveryRetained
359 result.RecoveryRoot = retention.RecoveryRoot
360 result.RecoveryWorktreeRegistered = retention.RecoveryWorktreeRegistered
361 result.BranchRetained = retention.BranchRetained
362 if err != nil {
363 return cleanupFailure(result, err)
364 }
365 if retention.LegacyCompleted {
366 if err := os.Remove(cleanupJournalPath(metadata)); err != nil && !errors.Is(err, os.ErrNotExist) {
367 return cleanupFailure(result, fmt.Errorf("remove completed legacy cleanup state: %w", err))
368 }
369 if err := os.Remove(metadataFile); err != nil && !errors.Is(err, os.ErrNotExist) {
370 return cleanupFailure(result, fmt.Errorf("remove completed merge metadata: %w", err))
371 }
372 result.Completed = true
373 result.WorktreeRemoved = true
374 result.BranchDeleted = true
375 }
376 return result, nil
377 }
378
379 func emptyMergeInspection() MergeInspection {
380 return MergeInspection{ChangedFiles: []string{}, ConflictFiles: []string{}, Blockers: []MergeBlocker{}, CleanupBlockers: []MergeBlocker{}}
381 }
382
383 func unavailableInspection(inspection MergeInspection, reason string) (MergeInspection, error) {
384 inspection.Available = false
385 inspection.CanMerge = false
386 inspection.Reason = reason
387 inspection.Blockers = append(inspection.Blockers, blocker("identity", reason))
388 return inspection, errors.New(reason)
389 }
390
391 func blocker(code, message string) MergeBlocker {
392 return MergeBlocker{Code: code, Message: message, Paths: []string{}}
393 }
394
395 func verifyRepositoryRoot(ctx context.Context, expected string) error {
396 reported, stderr, err := runGit(ctx, expected, "rev-parse", "--show-toplevel")
397 if err != nil {
398 return fmt.Errorf("resolve repository root: %w%s", err, stderrSuffix(stderr))
399 }
400 return sameDirectory(expected, strings.TrimSpace(reported))
401 }
402
403 func gitValue(ctx context.Context, dir string, args ...string) (string, string, error) {
404 out, stderr, err := runGit(ctx, dir, args...)
405 return strings.TrimSpace(out), stderr, err
406 }
407
408 func aheadBehind(ctx context.Context, root, targetHead, worktreeHead string) (behind, ahead int, err error) {
409 out, stderr, err := runGit(ctx, root, "rev-list", "--left-right", "--count", targetHead+"..."+worktreeHead)
410 if err != nil {
411 return 0, 0, fmt.Errorf("inspect branch divergence: %w%s", err, stderrSuffix(stderr))
412 }
413 fields := strings.Fields(out)
414 if len(fields) != 2 {
415 return 0, 0, fmt.Errorf("inspect branch divergence: unexpected output %q", strings.TrimSpace(out))
416 }
417 behind, err = strconv.Atoi(fields[0])
418 if err != nil {
419 return 0, 0, fmt.Errorf("parse behind count: %w", err)
420 }
421 ahead, err = strconv.Atoi(fields[1])
422 if err != nil {
423 return 0, 0, fmt.Errorf("parse ahead count: %w", err)
424 }
425 return behind, ahead, nil
426 }
427
428 func diffStats(ctx context.Context, root, targetHead, worktreeHead, status string) (files, insertions, deletions int, paths []string, err error) {
429 paths = []string{}
430 seen := map[string]struct{}{}
431 out, stderr, err := runGit(ctx, root, "diff", "--numstat", targetHead+"..."+worktreeHead)
432 if err != nil {
433 return 0, 0, 0, paths, fmt.Errorf("inspect committed diff: %w%s", err, stderrSuffix(stderr))
434 }
435 for line := range strings.SplitSeq(strings.TrimSpace(out), "\n") {
436 if strings.TrimSpace(line) == "" {
437 continue
438 }
439 fields := strings.SplitN(line, "\t", 3)
440 if len(fields) != 3 {
441 return 0, 0, 0, paths, fmt.Errorf("inspect committed diff: unexpected numstat %q", line)
442 }
443 if value, parseErr := strconv.Atoi(fields[0]); parseErr == nil {
444 insertions += value
445 }
446 if value, parseErr := strconv.Atoi(fields[1]); parseErr == nil {
447 deletions += value
448 }
449 if _, ok := seen[fields[2]]; !ok {
450 seen[fields[2]] = struct{}{}
451 paths = append(paths, fields[2])
452 }
453 }
454 for _, path := range statusPaths(status) {
455 if _, ok := seen[path]; !ok {
456 seen[path] = struct{}{}
457 paths = append(paths, path)
458 }
459 }
460 sort.Strings(paths)
461 return len(paths), insertions, deletions, paths, nil
462 }
463
464 func statusPaths(status string) []string {
465 seen := map[string]struct{}{}
466 paths := []string{}
467 for line := range strings.SplitSeq(status, "\n") {
468 line = strings.TrimRight(line, "\r")
469 if len(line) < 4 {
470 continue
471 }
472 path := strings.TrimSpace(line[3:])
473 if arrow := strings.LastIndex(path, " -> "); arrow >= 0 {
474 path = strings.TrimSpace(path[arrow+4:])
475 }
476 path = strings.Trim(path, "\"")
477 if path != "" {
478 if _, ok := seen[path]; !ok {
479 seen[path] = struct{}{}
480 paths = append(paths, path)
481 }
482 }
483 }
484 sort.Strings(paths)
485 return paths
486 }
487
488 func isAncestor(ctx context.Context, root, ancestor, descendant string) (bool, error) {
489 _, stderr, err := runGit(ctx, root, "merge-base", "--is-ancestor", ancestor, descendant)
490 if err == nil {
491 return true, nil
492 }
493 if exitCode(err) == 1 {
494 return false, nil
495 }
496 return false, fmt.Errorf("git merge-base --is-ancestor: %w%s", err, stderrSuffix(stderr))
497 }
498
499 func hasBlockingMergeIssue(blockers []MergeBlocker) bool {
500 return len(blockers) > 0
501 }
502
503 func verifyExpectedInspection(inspection MergeInspection, request MergeRequest) error {
504 if request.ExpectedTargetBranch == "" || request.ExpectedTargetHead == "" || request.ExpectedWorktreeHead == "" || request.ExpectedWorktreeStateToken == "" {
505 return errors.New("merge confirmation identity is incomplete; inspect again")
506 }
507 if inspection.TargetBranch != request.ExpectedTargetBranch || inspection.TargetHead != request.ExpectedTargetHead || inspection.WorktreeHead != request.ExpectedWorktreeHead || inspection.WorktreeStateToken != request.ExpectedWorktreeStateToken {
508 return errors.New("merge identity changed after inspection; inspect and confirm again")
509 }
510 return nil
511 }
512
513 func blockerMessages(blockers []MergeBlocker) string {
514 items := make([]string, 0, len(blockers))
515 for _, item := range blockers {
516 items = append(items, item.Message)
517 }
518 return strings.Join(items, "; ")
519 }
520
521 func mergeReceipt(inspection MergeInspection, mergedHead string, alreadyMerged bool) MergeResult {
522 return MergeResult{
523 Merged: true, AlreadyMerged: alreadyMerged, SourceRoot: inspection.SourceRoot,
524 TargetBranch: inspection.TargetBranch, TargetHead: mergedHead, MergedCommit: mergedHead,
525 WorktreeRoot: inspection.WorktreeRoot, WorktreeBranch: inspection.WorktreeBranch, WorktreeHead: inspection.WorktreeHead,
526 }
527 }
528
529 func mergeFailure(inspection MergeInspection, recoveryRequired bool, err error) (MergeResult, error) {
530 result := mergeReceipt(inspection, "", inspection.AlreadyMerged)
531 result.Merged = false
532 result.RecoveryRequired = recoveryRequired
533 result.Error = err.Error()
534 return result, err
535 }
536
537 func verifyCleanupIdentity(metadata mergeMetadata, request CleanupRequest) error {
538 if strings.TrimSpace(request.SourceRoot) == "" || strings.TrimSpace(request.TargetBranch) == "" || strings.TrimSpace(request.MergedCommit) == "" ||
539 strings.TrimSpace(request.WorktreeBranch) == "" || strings.TrimSpace(request.WorktreeHead) == "" {
540 return errors.New("cleanup identity is incomplete")
541 }
542 if err := sameDirectory(metadata.SourceRoot, request.SourceRoot); err != nil {
543 return errors.New("cleanup source identity changed")
544 }
545 if metadata.TargetBranch != request.TargetBranch || metadata.WorktreeBranch != request.WorktreeBranch {
546 return errors.New("cleanup branch identity changed")
547 }
548 return nil
549 }
550
551 func cleanupFailure(result CleanupResult, err error) (CleanupResult, error) {
552 result.Error = err.Error()
553 return result, err
554 }
555
556 func exitCode(err error) int {
557 var exitErr *exec.ExitError
558 if errors.As(err, &exitErr) {
559 return exitErr.ExitCode()
560 }
561 return -1
562 }
563
563 lines GO