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
38 changes: 38 additions & 0 deletions decisions/0097-file-level-conflict-in-verbose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# File-Level Conflict Predictions in Verbose Mode

Date: 2026-03-26

## Context

The conflict prediction layer (`predictMergeConflict`) already returns per-file conflict lists from `git merge-tree --write-tree --name-only`, but three call sites discarded the file list and reduced it to a boolean (`"conflict"` / `"clean"`). For rebase mode, a second prediction step (`predictRebaseConflictCommits`) gathered per-commit conflict files and displayed them in `--verbose` — but merge mode and retarget mode had no equivalent. This meant rebase users could see which files (and commits) would conflict, while merge and retarget users only saw "will conflict" / "conflict likely" with no file-level detail.

## Options

### Preserve file list, show in --verbose
Stop discarding `prediction.files`. Store on the assessment. In `--verbose` for merge mode, show a "Conflicting files:" section after the commit list. For retarget, also call `predictRebaseConflictCommits` (retarget is always a rebase) and pass per-commit data to the renderer.
- **Pros:** Closes all mode asymmetries. ~40 lines of production code. Uses infrastructure already in place. No change to default output.
- **Cons:** Retarget prediction gains one extra git call per conflicting repo. Merge-mode file list without per-commit context is less granular than rebase's.

### Show conflict files without --verbose
Put file paths directly in the action cell: `(will conflict: auth.ts, middleware.ts)`.
- **Pros:** Most discoverable.
- **Cons:** The action cell already packs mode, ref, diff counts, conflict label, stash hint, base fallback, warning, and HEAD sha. Variable-length file paths would overflow and degrade readability for all users.

### Do nothing
Leave the asymmetry in place.
- **Pros:** Zero change.
- **Cons:** The system computes data it then discards. Merge and retarget users get less useful conflict info than rebase users.

## Decision

Preserve the file list and show it in `--verbose`. For merge mode, render an overall "Conflicting files:" section. For retarget, add per-commit conflict detection (matching rebase behavior).

## Reasoning

The `--verbose` progressive disclosure pattern is well-established: default output stays clean, detail is opt-in. This matches how commit lists, diff stats, and stash predictions already work. The action cell is at capacity — adding variable-length content there would violate the GUIDELINES principle of keeping plan output scannable. The prediction layer already does the work, so the marginal cost is near zero.

## Consequences

- Merge and retarget `--verbose` output now shows file-level conflict detail, matching rebase.
- The `conflictFiles` field on assessment types is available for `--json` output in a future change.
- If merge-tree overhead becomes a concern for retarget (extra per-commit simulation), the per-commit step could be gated on `--verbose` — but the overhead is small (20-80ms per conflicting repo).
5 changes: 4 additions & 1 deletion src/commands/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,7 @@ export function buildPullPlanNodes(
afterRow = verboseCommitsToNodes(a.verbose.commits, a.verbose.totalCommits ?? a.verbose.commits.length, label, {
diffStats: a.verbose.diffStats,
conflictCommits: a.verbose.conflictCommits,
conflictFiles: a.conflictFiles,
});
}

Expand Down Expand Up @@ -847,10 +848,12 @@ async function predictPullConflicts(
if (!shareRemote) return a;
const ref = `${shareRemote}/${a.branch}`;
let conflictPrediction: PullAssessment["conflictPrediction"];
let conflictFiles: string[] | undefined;
let verbose = a.verbose;
if (a.behind > 0 && a.toPush > 0) {
const prediction = await predictMergeConflict(a.repoDir, ref);
conflictPrediction = prediction === null ? null : prediction.hasConflict ? "conflict" : "clean";
if (prediction?.hasConflict) conflictFiles = prediction.files;
if (prediction?.hasConflict && a.pullMode === "rebase") {
const conflictCommits = await predictRebaseConflictCommits(a.repoDir, ref);
if (conflictCommits.length > 0) {
Expand All @@ -865,7 +868,7 @@ async function predictPullConflicts(
const stashPrediction = await predictStashPopConflict(a.repoDir, ref);
stashPopConflictFiles = stashPrediction.overlapping;
}
return { ...a, conflictPrediction, stashPopConflictFiles, verbose };
return { ...a, conflictPrediction, conflictFiles, stashPopConflictFiles, verbose };
}),
);
}
Expand Down
18 changes: 15 additions & 3 deletions src/commands/retarget.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { basename, dirname } from "node:path";
import { type Command, Option } from "commander";
import { detectBranchMerged, predictMergeConflict } from "../lib/analysis";
import { predictStashPopConflict } from "../lib/analysis/conflict-prediction";
import {
detectBranchMerged,
predictMergeConflict,
predictRebaseConflictCommits,
predictStashPopConflict,
} from "../lib/analysis";
import {
ArbError,
type OperationRecord,
Expand Down Expand Up @@ -493,7 +497,10 @@ function buildRetargetPlanNodes(
let afterRow: OutputNode[] | undefined;
if (verbose && a.outcome === "will-retarget" && a.verbose?.commits && a.verbose.commits.length > 0) {
const label = `Incoming from ${a.baseRemote}/${a.targetBranch}:`;
afterRow = verboseCommitsToNodes(a.verbose.commits, a.verbose.totalCommits ?? a.verbose.commits.length, label);
afterRow = verboseCommitsToNodes(a.verbose.commits, a.verbose.totalCommits ?? a.verbose.commits.length, label, {
conflictCommits: a.verbose.conflictCommits,
conflictFiles: a.conflictFiles,
});
}

return {
Expand Down Expand Up @@ -624,6 +631,11 @@ async function predictRetargetConflicts(assessments: RetargetAssessment[]): Prom
const targetRef = `${a.baseRemote}/${a.targetBranch}`;
const prediction = await predictMergeConflict(a.repoDir, targetRef);
a.conflictPrediction = prediction === null ? null : prediction.hasConflict ? "conflict" : "clean";
if (prediction?.hasConflict) {
a.conflictFiles = prediction.files;
const conflictCommits = await predictRebaseConflictCommits(a.repoDir, targetRef);
if (conflictCommits.length > 0) a.verbose = { ...a.verbose, conflictCommits };
}
if (a.needsStash) {
const stashPrediction = await predictStashPopConflict(a.repoDir, targetRef);
a.stashPopConflictFiles = stashPrediction.overlapping;
Expand Down
79 changes: 77 additions & 2 deletions src/lib/render/status-verbose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { makeRepo } from "../status/test-helpers";
import { toJsonVerbose } from "../status/verbose-detail";
import { dim } from "../terminal/output";
import type { SectionNode } from "./model";
import { formatVerboseCommits, formatVerboseDetail, verboseDetailToNodes } from "./status-verbose";
import {
formatVerboseCommits,
formatVerboseDetail,
verboseCommitsToNodes,
verboseDetailToNodes,
} from "./status-verbose";

describe("formatVerboseDetail", () => {
test("annotates already-merged commits with merge commit hash", () => {
Expand Down Expand Up @@ -1044,7 +1049,9 @@ describe("formatVerboseCommits — additional branches", () => {
{ conflictCommits: [{ shortHash: "abc1234", files: ["src/app.ts", "src/index.ts"] }] },
);
expect(out).toContain("(conflict)");
expect(out).toContain("src/app.ts, src/index.ts");
expect(out).toContain("src/app.ts");
expect(out).toContain("src/index.ts");
expect(out).not.toContain("src/app.ts, src/index.ts");
});

test("multiple conflict commits", () => {
Expand All @@ -1068,6 +1075,74 @@ describe("formatVerboseCommits — additional branches", () => {
const conflictCount = (out.match(/\(conflict\)/g) || []).length;
expect(conflictCount).toBe(2);
});

test("conflictFiles shown when no per-commit data (merge mode)", () => {
const out = formatVerboseCommits([{ shortHash: "abc1234", subject: "some change" }], 1, "Incoming:", {
conflictFiles: ["src/app.ts", "src/index.ts"],
});
expect(out).toContain("Conflicting files:");
expect(out).toContain("src/app.ts");
expect(out).toContain("src/index.ts");
expect(out).not.toContain("(conflict)");
});

test("conflictFiles hidden when per-commit data exists", () => {
const out = formatVerboseCommits([{ shortHash: "abc1234", subject: "conflicting" }], 1, "Incoming:", {
conflictCommits: [{ shortHash: "abc1234", files: ["src/app.ts"] }],
conflictFiles: ["src/app.ts", "src/index.ts"],
});
expect(out).not.toContain("Conflicting files:");
expect(out).toContain("(conflict)");
});

test("conflictFiles truncated beyond 10 files", () => {
const files = Array.from({ length: 15 }, (_, i) => `file${i}.ts`);
const out = formatVerboseCommits([{ shortHash: "abc1234", subject: "change" }], 1, "Incoming:", {
conflictFiles: files,
});
expect(out).toContain("Conflicting files:");
expect(out).toContain("file0.ts");
expect(out).toContain("file9.ts");
expect(out).not.toContain("file10.ts");
expect(out).toContain("... and 5 more");
});
});

// ── verboseCommitsToNodes — conflictFiles ──

describe("verboseCommitsToNodes — conflictFiles", () => {
test("conflictFiles rendered as section when no per-commit data", () => {
const nodes = verboseCommitsToNodes([{ shortHash: "abc1234", subject: "some change" }], 1, "Incoming:", {
conflictFiles: ["src/app.ts", "src/index.ts"],
});
const secs = nodes.filter((n): n is SectionNode => n.kind === "section");
expect(secs).toHaveLength(2);
expect(secs[1]?.header.plain).toBe("Conflicting files:");
expect(secs[1]?.items).toHaveLength(2);
expect(secs[1]?.items[0]?.plain).toBe("src/app.ts");
expect(secs[1]?.items[1]?.plain).toBe("src/index.ts");
});

test("conflictFiles hidden when per-commit data exists", () => {
const nodes = verboseCommitsToNodes([{ shortHash: "abc1234", subject: "conflicting" }], 1, "Incoming:", {
conflictCommits: [{ shortHash: "abc1234", files: ["src/app.ts"] }],
conflictFiles: ["src/app.ts"],
});
const secs = nodes.filter((n): n is SectionNode => n.kind === "section");
expect(secs).toHaveLength(1); // Only the commits section, no conflictFiles section
});

test("conflictFiles truncated beyond 10", () => {
const files = Array.from({ length: 12 }, (_, i) => `file${i}.ts`);
const nodes = verboseCommitsToNodes([{ shortHash: "abc1234", subject: "change" }], 1, "Incoming:", {
conflictFiles: files,
});
const secs = nodes.filter((n): n is SectionNode => n.kind === "section");
const fileSec = secs.find((s) => s.header.plain === "Conflicting files:");
expect(fileSec).toBeDefined();
expect(fileSec?.items).toHaveLength(11); // 10 files + "... and 2 more"
expect(fileSec?.items[10]?.plain).toContain("... and 2 more");
});
});

// ── verboseDetailToNodes — to pull section ──
Expand Down
41 changes: 37 additions & 4 deletions src/lib/render/status-verbose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ export function formatVerboseCommits(
options?: {
diffStats?: { files: number; insertions: number; deletions: number };
conflictCommits?: { shortHash: string; files: string[] }[];
conflictFiles?: string[];
},
): string {
let displayLabel = label;
Expand Down Expand Up @@ -398,12 +399,28 @@ export function formatVerboseCommits(
}
out += `${ITEM_INDENT}${dim(c.shortHash)} ${c.subject}${tag}\n`;
if (conflictFiles && conflictFiles.length > 0) {
out += `${ITEM_INDENT} ${dim(conflictFiles.join(", "))}\n`;
for (const f of conflictFiles) {
out += `${ITEM_INDENT} ${dim(f)}\n`;
}
}
}
if (totalCommits > commits.length) {
out += `${ITEM_INDENT}${dim(`... and ${totalCommits - commits.length} more`)}\n`;
}

// Overall conflict files (shown when no per-commit data, e.g. merge mode)
if (options?.conflictFiles && options.conflictFiles.length > 0 && conflictMap.size === 0) {
const MAX_FILES = 10;
const shown = options.conflictFiles.slice(0, MAX_FILES);
out += `\n${SECTION_INDENT}${dim("Conflicting files:")}\n`;
for (const f of shown) {
out += `${ITEM_INDENT}${dim(f)}\n`;
}
if (options.conflictFiles.length > MAX_FILES) {
out += `${ITEM_INDENT}${dim(`... and ${options.conflictFiles.length - MAX_FILES} more`)}\n`;
}
}

out += "\n";
return out;
}
Expand All @@ -422,6 +439,7 @@ export function verboseCommitsToNodes(
options?: {
diffStats?: { files: number; insertions: number; deletions: number };
conflictCommits?: { shortHash: string; files: string[] }[];
conflictFiles?: string[];
},
): OutputNode[] {
// Build header cell
Expand Down Expand Up @@ -459,15 +477,30 @@ export function verboseCommitsToNodes(
}
items.push(commitCell);

// Conflict file sub-item
// Conflict file sub-items
if (conflictFiles && conflictFiles.length > 0) {
items.push(cell(` ${conflictFiles.join(", ")}`, "muted"));
for (const f of conflictFiles) {
items.push(cell(` ${f}`, "muted"));
}
}
}

if (totalCommits > commits.length) {
items.push(cell(`... and ${totalCommits - commits.length} more`, "muted"));
}

return [{ kind: "gap" }, { kind: "section", header, items }, { kind: "gap" }];
// Overall conflict files (shown when no per-commit data, e.g. merge mode)
const nodes: OutputNode[] = [{ kind: "gap" }, { kind: "section", header, items }];
if (options?.conflictFiles && options.conflictFiles.length > 0 && conflictMap.size === 0) {
const MAX_FILES = 10;
const shown = options.conflictFiles.slice(0, MAX_FILES);
const fileItems: Cell[] = shown.map((f) => cell(f, "muted"));
if (options.conflictFiles.length > MAX_FILES) {
fileItems.push(cell(`... and ${options.conflictFiles.length - MAX_FILES} more`, "muted"));
}
nodes.push({ kind: "section", header: cell("Conflicting files:", "muted"), items: fileItems });
}
nodes.push({ kind: "gap" });

return nodes;
}
3 changes: 2 additions & 1 deletion src/lib/sync/integrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1205,7 +1205,8 @@ describe("formatVerboseCommits", () => {
);
expect(out).toContain("abc1234");
expect(out).toContain("(conflict)");
expect(out).toContain("src/auth.ts, src/middleware.ts");
expect(out).toContain("src/auth.ts");
expect(out).toContain("src/middleware.ts");
expect(out).toContain("ghi9012");
expect(out).toContain("src/routes.ts");
// Non-conflicting commit should not have (conflict)
Expand Down
2 changes: 2 additions & 0 deletions src/lib/sync/integrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,7 @@ export function buildIntegratePlanNodes(
afterRow = verboseCommitsToNodes(a.verbose.commits, a.verbose.totalCommits ?? a.verbose.commits.length, label, {
diffStats: a.verbose.diffStats,
conflictCommits: a.verbose.conflictCommits,
conflictFiles: a.conflictFiles,
});
}
}
Expand Down Expand Up @@ -583,6 +584,7 @@ async function predictIntegrateConflicts(assessments: RepoAssessment[], mode: In
if (!a.retarget?.from && a.ahead > 0 && a.behind > 0) {
const prediction = await predictMergeConflict(a.repoDir, ref);
a.conflictPrediction = prediction === null ? null : prediction.hasConflict ? "conflict" : "clean";
if (prediction?.hasConflict) a.conflictFiles = prediction.files;
// Per-commit conflict detail for rebase mode
if (prediction?.hasConflict && mode === "rebase") {
const conflictCommits = await predictRebaseConflictCommits(a.repoDir, ref);
Expand Down
3 changes: 3 additions & 0 deletions src/lib/sync/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ interface IntegrateAssessmentBase {
headSha: string;
shallow: boolean;
conflictPrediction?: ConflictPrediction;
conflictFiles?: string[];
retarget?: IntegrateRetargetInfo;
baseFallback?: string;
needsStash?: boolean;
Expand Down Expand Up @@ -101,6 +102,7 @@ interface PullAssessmentBase {
shallow: boolean;
safeReset?: PullSafeResetInfo;
conflictPrediction?: ConflictPrediction;
conflictFiles?: string[];
needsStash?: boolean;
stashPopConflictFiles?: string[];
verbose?: PullVerboseInfo;
Expand Down Expand Up @@ -220,6 +222,7 @@ interface RetargetAssessmentBase {
baseMerged: boolean;
warning?: string;
conflictPrediction?: ConflictPrediction;
conflictFiles?: string[];
needsStash?: boolean;
stashPopConflictFiles?: string[];
wrongBranch?: boolean;
Expand Down
Loading
Loading