Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/commands/apply.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down
84 changes: 80 additions & 4 deletions src/commands/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -112,10 +142,56 @@ export async function apply(opts: ApplyOptions): Promise<void> {

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 <file> # re-create markers"));
}
console.error();
process.exit(1);
}

Expand Down
Loading