diff --git a/src/commands/apply.test.ts b/src/commands/apply.test.ts index 3c270e2..f5aa2dc 100644 --- a/src/commands/apply.test.ts +++ b/src/commands/apply.test.ts @@ -1,5 +1,60 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; +import { parseApplyConflicts } from "./apply.js"; + +describe("parseApplyConflicts", () => { + it("parses conflicted files from 3way output", () => { + const stderr = [ + "error: patch failed: src/cli.ts:10", + "Falling back to three-way merge...", + "Applied patch to 'src/cli.ts' with conflicts.", + ].join("\n"); + const info = parseApplyConflicts(stderr); + assert.deepEqual(info.conflictedFiles, ["src/cli.ts"]); + assert.deepEqual(info.appliedFiles, []); + }); + + it("parses mixed applied and conflicted files", () => { + const stderr = [ + "Applied patch to 'src/utils/git.ts' cleanly.", + "error: patch failed: src/cli.ts:10", + "Falling back to three-way merge...", + "Applied patch to 'src/cli.ts' with conflicts.", + "Applied patch to 'src/types.ts' cleanly.", + ].join("\n"); + const info = parseApplyConflicts(stderr); + assert.deepEqual(info.appliedFiles, ["src/utils/git.ts", "src/types.ts"]); + assert.deepEqual(info.conflictedFiles, ["src/cli.ts"]); + }); + + it("returns empty arrays for unparseable stderr", () => { + const info = parseApplyConflicts("fatal: something unexpected"); + assert.deepEqual(info.appliedFiles, []); + assert.deepEqual(info.conflictedFiles, []); + }); + + it("returns empty arrays for empty string", () => { + const info = parseApplyConflicts(""); + assert.deepEqual(info.appliedFiles, []); + assert.deepEqual(info.conflictedFiles, []); + }); + + it("deduplicates conflicted files from error + Applied lines", () => { + const stderr = [ + "error: patch failed: src/foo.ts:5", + "Applied patch to 'src/foo.ts' with conflicts.", + ].join("\n"); + const info = parseApplyConflicts(stderr); + assert.deepEqual(info.conflictedFiles, ["src/foo.ts"]); + }); + + it("captures error-only failures without Applied line", () => { + const stderr = "error: patch failed: src/bar.ts:1\n"; + const info = parseApplyConflicts(stderr); + assert.deepEqual(info.conflictedFiles, ["src/bar.ts"]); + assert.deepEqual(info.appliedFiles, []); + }); +}); // Test the logic of agent selection without actually running git commands describe("apply agent selection logic", () => { diff --git a/src/commands/apply.ts b/src/commands/apply.ts index 4d83140..705496e 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -8,6 +8,36 @@ import { cleanupBranches, getRepoRoot, removeWorktree } from "../utils/git.js"; const exec = promisify(execFile); +export interface ConflictInfo { + appliedFiles: string[]; + conflictedFiles: string[]; +} + +/** Parse git apply --3way stderr to extract applied/conflicted file lists. */ +export function parseApplyConflicts(stderr: string): ConflictInfo { + const conflictedFiles: string[] = []; + const appliedFiles: string[] = []; + for (const line of stderr.split("\n")) { + const patchMatch = line.match(/Applied patch to '([^']+)'/); + if (patchMatch) { + const file = patchMatch[1]; + if (line.includes("with conflicts")) { + if (!conflictedFiles.includes(file)) { + conflictedFiles.push(file); + } + } else if (!appliedFiles.includes(file)) { + appliedFiles.push(file); + } + continue; + } + const failMatch = line.match(/^error: patch failed: ([^:]+)/); + if (failMatch && !conflictedFiles.includes(failMatch[1])) { + conflictedFiles.push(failMatch[1]); + } + } + return { appliedFiles, conflictedFiles }; +} + export interface ApplyOptions { agent?: number; preview?: boolean; @@ -112,10 +142,56 @@ export async function apply(opts: ApplyOptions): Promise { console.log(" Changes applied successfully."); } catch (err: unknown) { - const e = err as { stderr?: string }; - console.error(" Failed to apply diff. There may be conflicts."); - if (e.stderr) console.error(` ${e.stderr}`); - console.error(` You can manually inspect the diff at: ${agent.worktree}`); + const e = err as { stderr?: string; stdout?: string }; + const stderr = e.stderr ?? ""; + const { appliedFiles, conflictedFiles } = parseApplyConflicts(stderr); + + console.error(); + console.error(pc.bold(pc.red(" Apply failed — conflicts detected"))); + console.error(pc.dim(" " + "─".repeat(58))); + console.error(); + + if (appliedFiles.length > 0) { + console.error(pc.green(" Applied cleanly:")); + for (const f of appliedFiles) { + console.error(pc.green(` ✓ ${f}`)); + } + console.error(); + } + + if (conflictedFiles.length > 0) { + console.error(pc.red(" Conflicted:")); + for (const f of conflictedFiles) { + console.error(pc.red(` ✗ ${f}`)); + } + console.error(); + } + + // If we couldn't parse any files, show the raw stderr + if (conflictedFiles.length === 0 && appliedFiles.length === 0 && stderr.trim()) { + console.error(pc.dim(` ${stderr.trim()}`)); + console.error(); + } + + const otherAgents = result.agents + .filter((a) => a.id !== agentId && a.status === "success" && a.diff) + .map((a) => `#${a.id}`); + + console.error(" Next steps:"); + if (otherAgents.length > 0) { + console.error( + ` • Try a different agent: thinktank apply --agent ${otherAgents[0].slice(1)}`, + ); + } + console.error(` • Inspect the diff first: thinktank apply --preview --agent ${agentId}`); + console.error(" • Manually merge from the worktree:"); + console.error(pc.dim(` ${agent.worktree}`)); + if (conflictedFiles.length > 0 && appliedFiles.length > 0) { + console.error(" • Resolve conflict markers in your working tree:"); + console.error(pc.dim(" git diff # review conflict markers")); + console.error(pc.dim(" git checkout --conflict=merge # re-create markers")); + } + console.error(); process.exit(1); }