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