Files
AgentHub/client/src/lib/conflicts.ts
T
nntrivi2001 1da46f10f4 feat(git): per-block conflict view with mine/theirs choices (#45)
Opening a conflicted file in the Changes panel now shows each conflict
block as its two sides, labelled from git's markers and tinted apart,
with Keep mine / Take theirs / Keep both per block (undoable, ^/v to
jump). Save and mark resolved writes the chosen text through the
whole-file editor's PUT /git/file (baseSha stale check) then runs
resolve done. The Merge conflicts group's buttons are text, and a
conflicted file is marked with a red ! in the Files tree.

Fix: ls-files --cached lists an unmerged path once per stage, so the
tree showed a conflicted file three times; lsFiles now dedupes.
2026-09-25 13:35:50 +07:00

88 lines
2.9 KiB
TypeScript

/**
* @file Parses git's merge-conflict markers out of a file's lines, and writes
* the file back with one side (or both) chosen per block — the model behind
* the Changes panel's conflict view. Handles diff3 style (`|||||||` base
* section, dropped: it is neither side). Pure, no React.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export type ConflictChoice = "ours" | "theirs" | "both";
export type ConflictPart =
| { kind: "text"; lines: string[] }
| { kind: "conflict"; ours: string[]; theirs: string[]; oursLabel: string; theirsLabel: string };
const OPEN = /^<{7}(?: (.*))?$/;
const BASE = /^\|{7}(?: .*)?$/;
const MID = /^={7}$/;
const CLOSE = /^>{7}(?: (.*))?$/;
/**
* The file as text runs and conflict blocks, or null when its markers do not
* balance (a hand edit left half a block) — the caller falls back to the
* plain editor then rather than guess.
*/
export function parseConflicts(lines: string[]): ConflictPart[] | null {
const parts: ConflictPart[] = [];
let text: string[] = [];
let block: { ours: string[]; theirs: string[]; oursLabel: string } | null = null;
let section: "ours" | "base" | "theirs" = "ours";
for (const line of lines) {
if (!block) {
const open = line.match(OPEN);
if (open) {
if (text.length) parts.push({ kind: "text", lines: text });
text = [];
block = { ours: [], theirs: [], oursLabel: open[1] ?? "" };
section = "ours";
} else if (CLOSE.test(line) || MID.test(line)) {
return null;
} else {
text.push(line);
}
continue;
}
if (section === "ours" && BASE.test(line)) section = "base";
else if (section !== "theirs" && MID.test(line)) section = "theirs";
else if (section === "theirs" && CLOSE.test(line)) {
const close = line.match(CLOSE)!;
parts.push({ kind: "conflict", ...block, theirsLabel: close[1] ?? "" });
block = null;
} else if (OPEN.test(line)) return null;
else if (section === "ours") block.ours.push(line);
else if (section === "theirs") block.theirs.push(line);
}
if (block) return null;
if (text.length || !parts.length) parts.push({ kind: "text", lines: text });
return parts;
}
/** The file's lines with block `i` replaced by `choices[i]`; an unchosen block keeps its markers. */
export function applyChoices(
parts: ConflictPart[],
choices: (ConflictChoice | undefined)[]
): string[] {
const out: string[] = [];
let i = 0;
for (const p of parts) {
if (p.kind === "text") {
out.push(...p.lines);
continue;
}
const c = choices[i++];
if (c === "ours") out.push(...p.ours);
else if (c === "theirs") out.push(...p.theirs);
else if (c === "both") out.push(...p.ours, ...p.theirs);
else
out.push(
`<<<<<<< ${p.oursLabel}`.trimEnd(),
...p.ours,
"=======",
...p.theirs,
`>>>>>>> ${p.theirsLabel}`.trimEnd()
);
}
return out;
}