Skip to content

Commit 4531094

Browse files
that-github-userunknownclaude
authored
Improve apply conflict handling with parsed file list and resolution guidance (#118)
Parse git apply stderr to show which files applied cleanly vs conflicted. Suggest next steps: try different agent, preview first, manually merge. Exported parseApplyConflicts for testing. 6 new tests. Agent #1 chosen over Copeland-recommended #5 (which added zero tests). Human judgment: tests matter more than fewer files changed. Closes #80 Co-authored-by: unknown <that-github-user@github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 23310ec commit 4531094

2 files changed

Lines changed: 135 additions & 4 deletions

File tree

src/commands/apply.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,60 @@
11
import assert from "node:assert/strict";
22
import { describe, it } from "node:test";
3+
import { parseApplyConflicts } from "./apply.js";
4+
5+
describe("parseApplyConflicts", () => {
6+
it("parses conflicted files from 3way output", () => {
7+
const stderr = [
8+
"error: patch failed: src/cli.ts:10",
9+
"Falling back to three-way merge...",
10+
"Applied patch to 'src/cli.ts' with conflicts.",
11+
].join("\n");
12+
const info = parseApplyConflicts(stderr);
13+
assert.deepEqual(info.conflictedFiles, ["src/cli.ts"]);
14+
assert.deepEqual(info.appliedFiles, []);
15+
});
16+
17+
it("parses mixed applied and conflicted files", () => {
18+
const stderr = [
19+
"Applied patch to 'src/utils/git.ts' cleanly.",
20+
"error: patch failed: src/cli.ts:10",
21+
"Falling back to three-way merge...",
22+
"Applied patch to 'src/cli.ts' with conflicts.",
23+
"Applied patch to 'src/types.ts' cleanly.",
24+
].join("\n");
25+
const info = parseApplyConflicts(stderr);
26+
assert.deepEqual(info.appliedFiles, ["src/utils/git.ts", "src/types.ts"]);
27+
assert.deepEqual(info.conflictedFiles, ["src/cli.ts"]);
28+
});
29+
30+
it("returns empty arrays for unparseable stderr", () => {
31+
const info = parseApplyConflicts("fatal: something unexpected");
32+
assert.deepEqual(info.appliedFiles, []);
33+
assert.deepEqual(info.conflictedFiles, []);
34+
});
35+
36+
it("returns empty arrays for empty string", () => {
37+
const info = parseApplyConflicts("");
38+
assert.deepEqual(info.appliedFiles, []);
39+
assert.deepEqual(info.conflictedFiles, []);
40+
});
41+
42+
it("deduplicates conflicted files from error + Applied lines", () => {
43+
const stderr = [
44+
"error: patch failed: src/foo.ts:5",
45+
"Applied patch to 'src/foo.ts' with conflicts.",
46+
].join("\n");
47+
const info = parseApplyConflicts(stderr);
48+
assert.deepEqual(info.conflictedFiles, ["src/foo.ts"]);
49+
});
50+
51+
it("captures error-only failures without Applied line", () => {
52+
const stderr = "error: patch failed: src/bar.ts:1\n";
53+
const info = parseApplyConflicts(stderr);
54+
assert.deepEqual(info.conflictedFiles, ["src/bar.ts"]);
55+
assert.deepEqual(info.appliedFiles, []);
56+
});
57+
});
358

459
// Test the logic of agent selection without actually running git commands
560
describe("apply agent selection logic", () => {

src/commands/apply.ts

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,36 @@ import { cleanupBranches, getRepoRoot, removeWorktree } from "../utils/git.js";
88

99
const exec = promisify(execFile);
1010

11+
export interface ConflictInfo {
12+
appliedFiles: string[];
13+
conflictedFiles: string[];
14+
}
15+
16+
/** Parse git apply --3way stderr to extract applied/conflicted file lists. */
17+
export function parseApplyConflicts(stderr: string): ConflictInfo {
18+
const conflictedFiles: string[] = [];
19+
const appliedFiles: string[] = [];
20+
for (const line of stderr.split("\n")) {
21+
const patchMatch = line.match(/Applied patch to '([^']+)'/);
22+
if (patchMatch) {
23+
const file = patchMatch[1];
24+
if (line.includes("with conflicts")) {
25+
if (!conflictedFiles.includes(file)) {
26+
conflictedFiles.push(file);
27+
}
28+
} else if (!appliedFiles.includes(file)) {
29+
appliedFiles.push(file);
30+
}
31+
continue;
32+
}
33+
const failMatch = line.match(/^error: patch failed: ([^:]+)/);
34+
if (failMatch && !conflictedFiles.includes(failMatch[1])) {
35+
conflictedFiles.push(failMatch[1]);
36+
}
37+
}
38+
return { appliedFiles, conflictedFiles };
39+
}
40+
1141
export interface ApplyOptions {
1242
agent?: number;
1343
preview?: boolean;
@@ -112,10 +142,56 @@ export async function apply(opts: ApplyOptions): Promise<void> {
112142

113143
console.log(" Changes applied successfully.");
114144
} catch (err: unknown) {
115-
const e = err as { stderr?: string };
116-
console.error(" Failed to apply diff. There may be conflicts.");
117-
if (e.stderr) console.error(` ${e.stderr}`);
118-
console.error(` You can manually inspect the diff at: ${agent.worktree}`);
145+
const e = err as { stderr?: string; stdout?: string };
146+
const stderr = e.stderr ?? "";
147+
const { appliedFiles, conflictedFiles } = parseApplyConflicts(stderr);
148+
149+
console.error();
150+
console.error(pc.bold(pc.red(" Apply failed — conflicts detected")));
151+
console.error(pc.dim(" " + "─".repeat(58)));
152+
console.error();
153+
154+
if (appliedFiles.length > 0) {
155+
console.error(pc.green(" Applied cleanly:"));
156+
for (const f of appliedFiles) {
157+
console.error(pc.green(` ✓ ${f}`));
158+
}
159+
console.error();
160+
}
161+
162+
if (conflictedFiles.length > 0) {
163+
console.error(pc.red(" Conflicted:"));
164+
for (const f of conflictedFiles) {
165+
console.error(pc.red(` ✗ ${f}`));
166+
}
167+
console.error();
168+
}
169+
170+
// If we couldn't parse any files, show the raw stderr
171+
if (conflictedFiles.length === 0 && appliedFiles.length === 0 && stderr.trim()) {
172+
console.error(pc.dim(` ${stderr.trim()}`));
173+
console.error();
174+
}
175+
176+
const otherAgents = result.agents
177+
.filter((a) => a.id !== agentId && a.status === "success" && a.diff)
178+
.map((a) => `#${a.id}`);
179+
180+
console.error(" Next steps:");
181+
if (otherAgents.length > 0) {
182+
console.error(
183+
` • Try a different agent: thinktank apply --agent ${otherAgents[0].slice(1)}`,
184+
);
185+
}
186+
console.error(` • Inspect the diff first: thinktank apply --preview --agent ${agentId}`);
187+
console.error(" • Manually merge from the worktree:");
188+
console.error(pc.dim(` ${agent.worktree}`));
189+
if (conflictedFiles.length > 0 && appliedFiles.length > 0) {
190+
console.error(" • Resolve conflict markers in your working tree:");
191+
console.error(pc.dim(" git diff # review conflict markers"));
192+
console.error(pc.dim(" git checkout --conflict=merge <file> # re-create markers"));
193+
}
194+
console.error();
119195
process.exit(1);
120196
}
121197

0 commit comments

Comments
 (0)