diff --git a/CLAUDE.md b/CLAUDE.md index ad50ef3..14ac582 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,6 +85,19 @@ Tier 4: Pause (human intervention required) `NewConflictResolver` signature: `(senior llm.Client, seniorModel string, techLead llm.Client, techLeadModel string, maxTokens int, projStore state.ProjectionStore, es state.EventStore)`. Pass `nil, ""` for `techLead/techLeadModel` to disable escalation (senior-only mode, used in tests). +#### Deterministic resolution tiers (no LLM) + commentary fallback (2026-06-25) + +Before any LLM call, conflicts are resolved deterministically where a correct rule exists — and crucially, the LLM is given a **non-abortive fallback** so a single un-mergeable file can never thrash a story through every escalation tier forever (the root cause of clipforge/pulsereview hanging for hours on `package.json`/`vitest.config.ts` with *"tech lead returned commentary, not file content"*). + +Resolution order per conflicted file (`RebaseWithResolution` loop): +1. **Generated lock files** (`isGeneratedLockFile`: package-lock.json, go.sum, Cargo.lock, …) → `git checkout --ours` (regenerated by the build). +2. **Line-oriented configs** (`isUnionMergeableConfig`: .gitignore, .dockerignore, …) → `unionResolveConflict` (union of both sides' lines). +3. **JSON configs** (`isStructuredJSONMergeable`: package.json, tsconfig*.json, jsconfig.json, composer.json, .eslintrc.json, …; lock files excluded) → `structuralJSONMerge`: pulls the two sides from the index via `git.ConflictSides` (`:2:`=ours/base, `:3:`=theirs/story) and **deep-unions the objects** (`deepMergeJSON`) so BOTH sides' dependencies/scripts/compilerOptions survive; scalar/array/type-mismatch positions take theirs (the story side). Invalid-JSON on either side falls through to the LLM. This is the deterministic fix for the file class that deadlocked the LLM. +4. **Senior → Tech-Lead LLM** for everything else. +5. **Deterministic `--theirs` fallback** (`git.CheckoutTheirs`): when the LLM returns commentary or leaves conflict markers — surfaced as the sentinel `errUnmergeable` (wrapped by `resolveFile`/`resolveFileTechLead`) — the resolver keeps the story-branch version and continues instead of aborting. The pre-merge QA gate + post-merge integration build validate the result. **API/transport errors (`IsFatalAPIError`, `IsCapacityError`, exhausted/transient client) are NOT `errUnmergeable`** → they still abort/pause so a retry or `vxd resume` can produce a correct merge (never silently take a side under a transient outage). Emits `deterministic_fallback_theirs` / `structural_json_merge_deterministic` escalation events. + +Rebase ours/theirs semantics (pinned by `TestConflictSides_OursIsBaseTheirsIsStory`): `git rebase ` runs from the story worktree, so during a conflict **ours = the base/upstream, theirs = the story commit being replayed**. Keeping the story's work = `--theirs`. Tests: `conflict_json_merge_test.go`, `conflict_sides_test.go` (git pkg), `conflict_fallback_test.go` (commentary→fallback succeeds; generic LLM error still aborts). + ### Config (vxd.yaml) ```yaml workspace: diff --git a/internal/cli/resume_orphan_recovery_test.go b/internal/cli/resume_orphan_recovery_test.go new file mode 100644 index 0000000..3b1862d --- /dev/null +++ b/internal/cli/resume_orphan_recovery_test.go @@ -0,0 +1,86 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/tzone85/vortex-dispatch/internal/state" +) + +func TestIsRecoverableStalledStatus(t *testing.T) { + recoverable := []string{"in_progress", "review", "qa"} + for _, s := range recoverable { + if !isRecoverableStalledStatus(s) { + t.Errorf("status %q should be recoverable", s) + } + } + notRecoverable := []string{"draft", "merged", "split", "awaiting_approval", "pr_submitted", ""} + for _, s := range notRecoverable { + if isRecoverableStalledStatus(s) { + t.Errorf("status %q should NOT be recoverable", s) + } + } +} + +// A story stranded in "review" (agent emitted STORY_COMPLETED, then the monitor +// was killed before review→QA→merge) with a worktree of committed work must be +// recovered so an interrupted build resumes instead of stalling forever. This +// guards the transient-failure recovery fix (#103), which shipped without a test. +func TestRecoverOrphanedStories_RecoversReviewAndQAStalls(t *testing.T) { + dir := t.TempDir() + ps, err := state.NewSQLiteStore(filepath.Join(dir, "vxd.db")) + if err != nil { + t.Fatalf("create store: %v", err) + } + defer ps.Close() + + cfg := defaultConfig() + cfg.Workspace.StateDir = dir + + for _, id := range []string{"s-review", "s-qa", "s-draft"} { + if err := os.MkdirAll(filepath.Join(dir, "worktrees", id), 0o755); err != nil { + t.Fatal(err) + } + } + + stories := []state.Story{ + {ID: "s-review", Status: "review"}, + {ID: "s-qa", Status: "qa"}, + {ID: "s-draft", Status: "draft"}, // dispatchable, not an orphan + } + + orphans := recoverOrphanedStories(stories, ps, cfg) + got := map[string]bool{} + for _, o := range orphans { + got[o.Assignment.StoryID] = true + } + if !got["s-review"] || !got["s-qa"] { + t.Fatalf("expected review+qa stalls recovered, got %v", got) + } + if got["s-draft"] { + t.Fatal("a draft (dispatchable) story must not be recovered as an orphan") + } + if len(orphans) != 2 { + t.Fatalf("expected exactly 2 orphans, got %d", len(orphans)) + } +} + +// A post-agent-state story with NO worktree (work lost) cannot be recovered — +// there is nothing to re-route through post-execution. +func TestRecoverOrphanedStories_ReviewWithoutWorktreeSkipped(t *testing.T) { + dir := t.TempDir() + ps, err := state.NewSQLiteStore(filepath.Join(dir, "vxd.db")) + if err != nil { + t.Fatalf("create store: %v", err) + } + defer ps.Close() + + cfg := defaultConfig() + cfg.Workspace.StateDir = dir + + stories := []state.Story{{ID: "s-review", Status: "review"}} + if orphans := recoverOrphanedStories(stories, ps, cfg); len(orphans) != 0 { + t.Fatalf("expected no orphans without a worktree, got %d", len(orphans)) + } +} diff --git a/internal/engine/conflict_fallback_test.go b/internal/engine/conflict_fallback_test.go new file mode 100644 index 0000000..fe75cc6 --- /dev/null +++ b/internal/engine/conflict_fallback_test.go @@ -0,0 +1,53 @@ +package engine + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/tzone85/vortex-dispatch/internal/llm" +) + +// When the LLM returns conversational COMMENTARY instead of merged file content, +// the resolver must NOT abort and thrash the story through every escalation tier +// forever (the clipforge/pulsereview failure). Instead it deterministically +// keeps the story-branch version (--theirs) and the rebase completes. The +// pre-merge QA gate validates the result downstream. +func TestRebaseWithResolution_CommentaryFallsBackToStoryVersion(t *testing.T) { + _, worktreeDir := setupDivergentRepos(t, true) + + // Every resolver call returns commentary (a known chatter marker), which + // classifies as errUnmergeable → deterministic fallback, not abort. + commentary := llm.CompletionResponse{Content: "Conflict resolved. Kept both sides merged."} + client := llm.NewReplayClient(commentary, commentary, commentary, commentary) + + cr := NewConflictResolver(client, "test-model", nil, "", 4096, nil, nil) + + err := cr.RebaseWithResolution(context.Background(), "s-commentary", worktreeDir, "origin/main") + if err != nil { + t.Fatalf("expected rebase to succeed via deterministic fallback, got: %v", err) + } + + // Rebase must be finished, not left in progress. + status := exec.Command("git", "status") + status.Dir = worktreeDir + out, _ := status.CombinedOutput() + if strings.Contains(string(out), "rebase in progress") { + t.Errorf("rebase still in progress after fallback:\n%s", out) + } + + // The story-branch (theirs) version must have won. + got, err := os.ReadFile(filepath.Join(worktreeDir, "feature.go")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), "feature version") { + t.Errorf("expected story-branch content after fallback, got:\n%s", got) + } + if strings.Contains(string(got), "<<<<<<<") { + t.Errorf("conflict markers left in file:\n%s", got) + } +} diff --git a/internal/engine/conflict_json_merge_test.go b/internal/engine/conflict_json_merge_test.go new file mode 100644 index 0000000..733a439 --- /dev/null +++ b/internal/engine/conflict_json_merge_test.go @@ -0,0 +1,117 @@ +package engine + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestIsStructuredJSONMergeable(t *testing.T) { + yes := []string{ + "package.json", "tsconfig.json", "jsconfig.json", + "app/tsconfig.json", "tsconfig.build.json", "tsconfig.spec.json", + "composer.json", ".eslintrc.json", "nest-cli.json", + } + for _, f := range yes { + if !isStructuredJSONMergeable(f) { + t.Errorf("%q should be structurally JSON-mergeable", f) + } + } + no := []string{ + "package-lock.json", // lock file — handled separately, never merged + "composer.lock", + "main.ts", "README.md", ".gitignore", "Cargo.toml", "data.json.txt", + } + for _, f := range no { + if isStructuredJSONMergeable(f) { + t.Errorf("%q should NOT be structurally JSON-mergeable", f) + } + } +} + +// The headline case: both sides add different dependencies and scripts to +// package.json. The correct resolution keeps BOTH — this is exactly what the +// LLM resolver kept failing to do (returning commentary), thrashing the story. +func TestStructuralJSONMerge_UnionsDependencies(t *testing.T) { + ours := []byte(`{ + "name": "app", + "version": "1.0.0", + "dependencies": { "react": "^18.0.0", "left-pad": "^1.0.0" }, + "scripts": { "build": "tsc" } +}`) + theirs := []byte(`{ + "name": "app", + "version": "1.1.0", + "dependencies": { "react": "^18.0.0", "zod": "^3.0.0" }, + "scripts": { "test": "vitest" } +}`) + + out, err := structuralJSONMerge(ours, theirs) + if err != nil { + t.Fatalf("merge: %v", err) + } + var got map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("merged output is not valid JSON: %v\n%s", err, out) + } + + deps := got["dependencies"].(map[string]any) + for _, want := range []string{"react", "left-pad", "zod"} { + if _, ok := deps[want]; !ok { + t.Errorf("merged dependencies missing %q (deps=%v)", want, deps) + } + } + scripts := got["scripts"].(map[string]any) + if _, ok := scripts["build"]; !ok { + t.Error("merged scripts lost ours.build") + } + if _, ok := scripts["test"]; !ok { + t.Error("merged scripts lost theirs.test") + } + // Scalar conflict (version): theirs (story side) wins. + if got["version"] != "1.1.0" { + t.Errorf("version = %v, want theirs 1.1.0", got["version"]) + } +} + +func TestStructuralJSONMerge_TsconfigCompilerOptions(t *testing.T) { + ours := []byte(`{"compilerOptions":{"strict":true,"outDir":"./dist"}}`) + theirs := []byte(`{"compilerOptions":{"strict":true,"target":"ES2022"}}`) + out, err := structuralJSONMerge(ours, theirs) + if err != nil { + t.Fatalf("merge: %v", err) + } + var got map[string]any + _ = json.Unmarshal(out, &got) + co := got["compilerOptions"].(map[string]any) + if co["outDir"] != "./dist" || co["target"] != "ES2022" || co["strict"] != true { + t.Errorf("compilerOptions not unioned: %v", co) + } +} + +func TestStructuralJSONMerge_EmptySideKeepsOther(t *testing.T) { + body := []byte(`{"name":"app"}`) + if out, err := structuralJSONMerge(nil, body); err != nil || string(out) != string(body) { + t.Errorf("empty ours: got %q err %v", out, err) + } + if out, err := structuralJSONMerge(body, []byte(" ")); err != nil || string(out) != string(body) { + t.Errorf("empty theirs: got %q err %v", out, err) + } +} + +func TestStructuralJSONMerge_InvalidJSONErrors(t *testing.T) { + if _, err := structuralJSONMerge([]byte(`{"a":1}`), []byte(`not json`)); err == nil { + t.Fatal("expected error when a side is not valid JSON (so caller falls back to LLM)") + } +} + +func TestDeepMergeJSON_TheirsWinsOnTypeMismatch(t *testing.T) { + // ours value is an object, theirs is a scalar at the same key → theirs wins. + ours := map[string]any{"x": map[string]any{"deep": true}} + theirs := map[string]any{"x": "scalar"} + got := deepMergeJSON(ours, theirs) + want := map[string]any{"x": "scalar"} + if !reflect.DeepEqual(got, want) { + t.Errorf("deepMergeJSON = %v, want %v", got, want) + } +} diff --git a/internal/engine/conflict_resolver.go b/internal/engine/conflict_resolver.go index caa5c49..2f45c38 100644 --- a/internal/engine/conflict_resolver.go +++ b/internal/engine/conflict_resolver.go @@ -2,6 +2,8 @@ package engine import ( "context" + "encoding/json" + "errors" "fmt" "log" "os" @@ -15,6 +17,15 @@ import ( "github.com/tzone85/vortex-dispatch/internal/state" ) +// errUnmergeable signals that the LLM responded but could NOT produce usable +// merged file content — it returned conversational commentary, or left conflict +// markers in place. This is a genuine resolution dead-end: retrying the same +// model will not help, so a deterministic story-branch fallback is appropriate. +// It is deliberately distinct from API/transport errors (exhausted client, +// network blip, 429, auth) — there a retry or resume may yet succeed, so the +// resolver must abort rather than silently take a side. +var errUnmergeable = errors.New("resolver could not produce merged file content") + // oversizedBinaryPattern matches compiled binary names that should be removed // rather than kept when they appear as merge conflicts. var oversizedBinaryPattern = regexp.MustCompile(`(?i)(^|/)(server|main|app|binary|\.exe)$`) @@ -182,6 +193,29 @@ func (cr *ConflictResolver) RebaseWithResolution(ctx context.Context, storyID, w continue } + // JSON config files (package.json, tsconfig.json, …): both sides + // usually ADD keys, so a deep structural union is the correct, fully + // deterministic resolution — and it sidesteps the LLM, which kept + // returning commentary instead of merged JSON for exactly these files + // and thrashed the story through every escalation tier. If either + // side is not valid JSON the merge errors and we fall through to the + // LLM path unchanged. + if isStructuredJSONMergeable(file) { + if ours, theirs, sErr := vxdgit.ConflictSides(worktreePath, file); sErr == nil { + if merged, mErr := structuralJSONMerge(ours, theirs); mErr == nil { + if wErr := os.WriteFile(absPath, merged, 0o644); wErr != nil { + _ = vxdgit.RebaseAbort(worktreePath) + return fmt.Errorf("write JSON-merged %s: %w", file, wErr) + } + cr.emitEscalationEvent(storyID, file, "structural_json_merge_deterministic") + log.Printf("[conflict-resolver] deterministic structural JSON merge for %s in %s", file, storyID) + continue + } else { + log.Printf("[conflict-resolver] structural JSON merge unavailable for %s (%v) — falling back to LLM", file, mErr) + } + } + } + // Try senior resolver first (fast path). resolved, seniorErr := cr.resolveFile(ctx, file, string(content)) @@ -194,21 +228,53 @@ func (cr *ConflictResolver) RebaseWithResolution(ctx context.Context, storyID, w tlCtx := cr.buildTechLeadContext(ctx, storyID, worktreePath, file) resolved, rErr = cr.resolveFileTechLead(ctx, file, string(content), tlCtx) if rErr != nil { - cr.emitEscalationEvent(storyID, file, "tech_lead_failed") - _ = vxdgit.RebaseAbort(worktreePath) // best-effort cleanup; original error is what matters - if llm.IsFatalAPIError(rErr) { - log.Printf("[conflict-resolver] FATAL: Tech Lead API error for %s: %v", storyID, rErr) + // Only a genuine resolution dead-end (the model returned + // commentary or left conflict markers) gets the + // deterministic fallback. API/transport errors (fatal, + // capacity, transient client failures) must abort so the + // pipeline pauses/escalates — a retry or resume may yet + // produce a correct merge, and we must not silently take a + // side under a transient outage. + if !errors.Is(rErr, errUnmergeable) { + cr.emitEscalationEvent(storyID, file, "tech_lead_failed") + _ = vxdgit.RebaseAbort(worktreePath) // best-effort cleanup; original error is what matters + if llm.IsFatalAPIError(rErr) { + log.Printf("[conflict-resolver] FATAL: Tech Lead API error for %s: %v", storyID, rErr) + } + return fmt.Errorf("tech lead resolve %s: %w", file, rErr) } - return fmt.Errorf("tech lead resolve %s: %w", file, rErr) + // The LLM cannot merge this file. Rather than abort the + // whole story and thrash through every escalation tier + // forever, resolve deterministically by keeping the + // story-branch version; the pre-merge QA gate and + // post-merge integration build then validate it. + if fbErr := vxdgit.CheckoutTheirs(worktreePath, file); fbErr != nil { + _ = vxdgit.RebaseAbort(worktreePath) + return fmt.Errorf("deterministic fallback for %s after tech-lead failure (%v): %w", file, rErr, fbErr) + } + cr.emitEscalationEvent(storyID, file, "deterministic_fallback_theirs") + log.Printf("[conflict-resolver] %s: LLM could not merge %s (%v) — kept story-branch version (--theirs); QA/integration build will validate", storyID, file, rErr) + continue } cr.emitEscalationEvent(storyID, file, "tech_lead_resolved") } else if seniorErr != nil { // No tech lead available and senior failed. - _ = vxdgit.RebaseAbort(worktreePath) // best-effort cleanup; original error is what matters - if llm.IsFatalAPIError(seniorErr) { - log.Printf("[conflict-resolver] FATAL: API error during conflict resolution for %s: %v", storyID, seniorErr) + if !errors.Is(seniorErr, errUnmergeable) { + _ = vxdgit.RebaseAbort(worktreePath) // best-effort cleanup; original error is what matters + if llm.IsFatalAPIError(seniorErr) { + log.Printf("[conflict-resolver] FATAL: API error during conflict resolution for %s: %v", storyID, seniorErr) + } + return fmt.Errorf("LLM resolve %s: %w", file, seniorErr) + } + // Resolution dead-end with no tech lead to escalate to: take + // the deterministic story-branch fallback instead of aborting. + if fbErr := vxdgit.CheckoutTheirs(worktreePath, file); fbErr != nil { + _ = vxdgit.RebaseAbort(worktreePath) + return fmt.Errorf("deterministic fallback for %s after senior failure (%v): %w", file, seniorErr, fbErr) } - return fmt.Errorf("LLM resolve %s: %w", file, seniorErr) + cr.emitEscalationEvent(storyID, file, "deterministic_fallback_theirs") + log.Printf("[conflict-resolver] %s: senior could not merge %s and no tech lead configured (%v) — kept story-branch version (--theirs)", storyID, file, seniorErr) + continue } else if needsTechLead { // Senior succeeded but the round was integration-level // (>3 files). Policy says escalate to Tech Lead — but @@ -305,6 +371,95 @@ func unionResolveConflict(conflicted string) string { return strings.Join(out, "\n") } +// structuredJSONConfigs are JSON config files where both sides typically ADD +// keys (dependencies, scripts, compilerOptions) and the correct resolution is a +// deep union of the two objects — not picking one side, and never the LLM. This +// is the file class that repeatedly deadlocked the LLM resolver: it returned +// conversational commentary instead of merged JSON for package.json/tsconfig, +// aborting an otherwise-clean rebase and thrashing the story through every +// escalation tier. Lock files are deliberately excluded (handled separately, +// regenerated by the build). +var structuredJSONConfigs = map[string]bool{ + "package.json": true, + "tsconfig.json": true, + "jsconfig.json": true, + "composer.json": true, + "app.json": true, + ".babelrc": true, + ".eslintrc.json": true, + ".prettierrc.json": true, + "nest-cli.json": true, +} + +// isStructuredJSONMergeable reports whether a conflicted path is a JSON config +// that should be resolved by a deep structural union rather than by the LLM. +// Matches known basenames plus the tsconfig..json family. Lock files are +// excluded (isGeneratedLockFile owns those). +func isStructuredJSONMergeable(file string) bool { + base := filepath.Base(file) + if isGeneratedLockFile(base) { + return false + } + if structuredJSONConfigs[base] { + return true + } + // tsconfig.build.json, tsconfig.spec.json, etc. + return strings.HasPrefix(base, "tsconfig.") && strings.HasSuffix(base, ".json") +} + +// structuralJSONMerge deep-merges the two sides of a JSON conflict. Objects are +// unioned key-by-key (recursively), so both sides' dependencies/scripts/options +// are preserved. For any non-object position (scalars, arrays, or a type +// mismatch) the theirs side — the story branch being rebased — wins, since it +// is the newer intent being layered on. Returns an error if either side is not +// valid JSON, so the caller can fall back to the LLM path for non-JSON content. +func structuralJSONMerge(ours, theirs []byte) ([]byte, error) { + // An empty side (file added on only one side) means there is nothing to + // merge — keep the present side verbatim. + if len(strings.TrimSpace(string(ours))) == 0 { + return theirs, nil + } + if len(strings.TrimSpace(string(theirs))) == 0 { + return ours, nil + } + var o, t any + if err := json.Unmarshal(ours, &o); err != nil { + return nil, fmt.Errorf("ours side is not valid JSON: %w", err) + } + if err := json.Unmarshal(theirs, &t); err != nil { + return nil, fmt.Errorf("theirs side is not valid JSON: %w", err) + } + merged := deepMergeJSON(o, t) + out, err := json.MarshalIndent(merged, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal merged JSON: %w", err) + } + return append(out, '\n'), nil +} + +// deepMergeJSON recursively unions two decoded JSON values. When both are +// objects, keys are unioned and shared keys are merged recursively. Otherwise +// theirs (the story side) wins. +func deepMergeJSON(ours, theirs any) any { + om, ok1 := ours.(map[string]any) + tm, ok2 := theirs.(map[string]any) + if !ok1 || !ok2 { + return theirs + } + result := make(map[string]any, len(om)+len(tm)) + for k, v := range om { + result[k] = v + } + for k, tv := range tm { + if ov, exists := result[k]; exists { + result[k] = deepMergeJSON(ov, tv) + } else { + result[k] = tv + } + } + return result +} + // handleBinaryConflict applies a deterministic policy for binary-file conflicts // without invoking the LLM: // - Oversized (>500 KB) or compiled binary names (server, main, *.exe) → git rm @@ -409,7 +564,7 @@ File: %s // Sanity check: resolved content must not contain conflict markers. if strings.Contains(resolved, "<<<<<<<") || strings.Contains(resolved, ">>>>>>>") { - return "", fmt.Errorf("LLM output still contains conflict markers") + return "", fmt.Errorf("LLM output still contains conflict markers: %w", errUnmergeable) } // Sanity check: reject conversational commentary. When the model returns @@ -417,7 +572,7 @@ File: %s // stylesheet reduced to "Conflict resolved. Kept both sides…"). Failing here // escalates to the Tech Lead instead of corrupting the file. if looksLikeResolverChatter(resolved) { - return "", fmt.Errorf("LLM returned commentary, not file content") + return "", fmt.Errorf("LLM returned commentary, not file content: %w", errUnmergeable) } return resolved, nil @@ -512,11 +667,11 @@ resolved file content — no explanations, no markdown fences.`, resolved := extractResolvedFileContent(resp.Content) if strings.Contains(resolved, "<<<<<<<") || strings.Contains(resolved, ">>>>>>>") { - return "", fmt.Errorf("tech lead output still contains conflict markers") + return "", fmt.Errorf("tech lead output still contains conflict markers: %w", errUnmergeable) } if looksLikeResolverChatter(resolved) { - return "", fmt.Errorf("tech lead returned commentary, not file content") + return "", fmt.Errorf("tech lead returned commentary, not file content: %w", errUnmergeable) } return resolved, nil diff --git a/internal/git/conflict.go b/internal/git/conflict.go index 395953c..119292a 100644 --- a/internal/git/conflict.go +++ b/internal/git/conflict.go @@ -61,6 +61,44 @@ func ConflictedFiles(worktreePath string) ([]string, error) { return files, nil } +// ConflictSides returns the two competing versions of a conflicted file from +// the index: ours (stage :2:, the branch being rebased ONTO — i.e. the base) +// and theirs (stage :3:, the commit being replayed — i.e. the story branch). +// A side may be empty with a nil error when the file was added on only one side +// (no stage entry); callers must tolerate an empty side. Both stages missing is +// an error (not a normal 3-way text conflict). +func ConflictSides(worktreePath, file string) (ours, theirs []byte, err error) { + read := func(stage string) ([]byte, error) { + cmd := exec.Command("git", "show", stage+":"+file) + cmd.Dir = worktreePath + return cmd.Output() + } + ours, oErr := read(":2") + theirs, tErr := read(":3") + if oErr != nil && tErr != nil { + return nil, nil, fmt.Errorf("no index stages for %s (ours: %v; theirs: %v)", file, oErr, tErr) + } + return ours, theirs, nil +} + +// CheckoutTheirs resolves a conflicted file by taking the theirs side (stage +// :3:, the story branch's version during a rebase) and staging it. Used as a +// deterministic last resort when LLM resolution cannot produce file content, so +// a single unresolvable file never aborts the whole story. +func CheckoutTheirs(worktreePath, file string) error { + co := exec.Command("git", "checkout", "--theirs", "--", file) + co.Dir = worktreePath + if out, err := co.CombinedOutput(); err != nil { + return fmt.Errorf("git checkout --theirs %s: %w (%s)", file, err, strings.TrimSpace(string(out))) + } + add := exec.Command("git", "add", "-f", "--", file) + add.Dir = worktreePath + if out, err := add.CombinedOutput(); err != nil { + return fmt.Errorf("git add %s after checkout --theirs: %w (%s)", file, err, strings.TrimSpace(string(out))) + } + return nil +} + // StageFiles stages the specified files in the worktree (git add -f). // Uses -f to force-add files that may be gitignored (e.g., VXD's own // .vxd-prompts directory which appears in conflict resolution). diff --git a/internal/git/conflict_sides_test.go b/internal/git/conflict_sides_test.go new file mode 100644 index 0000000..61aff8a --- /dev/null +++ b/internal/git/conflict_sides_test.go @@ -0,0 +1,98 @@ +package git + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// setupRebaseConflict builds a repo where `base` and `topic` both change the +// same file, then starts `git rebase base` on topic (leaving it conflicted). +// Returns the worktree dir and the conflicted filename. +func setupRebaseConflict(t *testing.T, name, baseContent, topicContent string) string { + t.Helper() + dir := t.TempDir() + helperRun(t, dir, "git", "init") + helperRun(t, dir, "git", "config", "user.email", "test@test.com") + helperRun(t, dir, "git", "config", "user.name", "Test") + + write := func(c string) { + if err := os.WriteFile(filepath.Join(dir, name), []byte(c), 0644); err != nil { + t.Fatalf("write: %v", err) + } + } + write("common\n") + helperRun(t, dir, "git", "add", ".") + helperRun(t, dir, "git", "commit", "-m", "init") + base := helperRun(t, dir, "git", "rev-parse", "--abbrev-ref", "HEAD") + + helperRun(t, dir, "git", "checkout", "-b", "topic") + write(topicContent) + helperRun(t, dir, "git", "add", ".") + helperRun(t, dir, "git", "commit", "-m", "topic") + + helperRun(t, dir, "git", "checkout", base) + write(baseContent) + helperRun(t, dir, "git", "add", ".") + helperRun(t, dir, "git", "commit", "-m", "base") + + helperRun(t, dir, "git", "checkout", "topic") + rebase := exec.Command("git", "rebase", base) + rebase.Dir = dir + _, _ = rebase.CombinedOutput() // expected to conflict + return dir +} + +// ConflictSides must return ours = the base (rebased-onto) version and theirs = +// the topic (story) version. This pins the rebase ours/theirs semantics that the +// deterministic conflict fallbacks depend on. +func TestConflictSides_OursIsBaseTheirsIsStory(t *testing.T) { + dir := setupRebaseConflict(t, "package.json", + `{"base":true}`+"\n", // base side + `{"story":true}`+"\n", // topic/story side + ) + + ours, theirs, err := ConflictSides(dir, "package.json") + if err != nil { + t.Fatalf("ConflictSides: %v", err) + } + if !strings.Contains(string(ours), `"base"`) { + t.Errorf("ours should be the base version, got %q", ours) + } + if !strings.Contains(string(theirs), `"story"`) { + t.Errorf("theirs should be the story version, got %q", theirs) + } +} + +// CheckoutTheirs must resolve the file to the story version AND stage it (so the +// rebase can continue with no remaining conflict). +func TestCheckoutTheirs_ResolvesToStoryAndStages(t *testing.T) { + dir := setupRebaseConflict(t, "config.ts", + "export const base = true\n", + "export const story = true\n", + ) + + if err := CheckoutTheirs(dir, "config.ts"); err != nil { + t.Fatalf("CheckoutTheirs: %v", err) + } + + got, err := os.ReadFile(filepath.Join(dir, "config.ts")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), "story") { + t.Errorf("working tree should hold the story version, got %q", got) + } + // No remaining conflicts: the file must be staged/resolved. + files, err := ConflictedFiles(dir) + if err != nil { + t.Fatalf("ConflictedFiles: %v", err) + } + for _, f := range files { + if f == "config.ts" { + t.Errorf("config.ts still conflicted after CheckoutTheirs; remaining=%v", files) + } + } +}