import { hasConflictMarkers, splitLines } from "../git/merge3"; import { parseConflictText } from "~/lib/conflict-blocks"; import type { Segment } from "~/lib/conflict-blocks"; import type { ConflictHunk } from "../git/merge3"; import { scanText } from "../security/detectors"; export type CandidateSource = "ours" | "theirs" | "ours_then_theirs" | "theirs_then_ours" | "ai"; export type Candidate = { id: string; source: CandidateSource; label: string; text: string }; function terminate(text: string): string { return text === "" || text.endsWith("\n") ? text : `${text}\n`; } /** The four deterministic resolutions, deduplicated by text so Clef never sees two identical options. */ export function baseCandidates(hunk: ConflictHunk): Candidate[] { const ours = terminate(hunk.ours); const theirs = terminate(hunk.theirs); const all: Candidate[] = [ { id: "ours", source: "ours", label: "Keep the head branch side only", text: ours }, { id: "theirs", source: "theirs", label: "Keep the base branch side only", text: theirs }, { id: "ours_then_theirs", source: "ours_then_theirs", label: "Head side, then base side", text: ours + theirs }, { id: "theirs_then_ours", source: "theirs_then_ours", label: "Base side, then head side", text: theirs + ours }, ]; return dedupe(all); } export function dedupe(candidates: Candidate[]): Candidate[] { const seen = new Set(); return candidates.filter((candidate) => { if (seen.has(candidate.text)) return false; seen.add(candidate.text); return true; }); } /** * Applies one chosen text per conflict block. Refuses when the parsed blocks do not line up with the * merge's hunks (for example a file that already contained marker-like lines) or markers would remain. */ export function assembleResolution(content: string, hunks: ConflictHunk[], choices: string[]): string | null { const parsed = parseConflictText(content); if (!parsed || parsed.blocks.length !== hunks.length || choices.length !== hunks.length) return null; for (const [index, block] of parsed.blocks.entries()) { if (block.ours !== terminate(hunks[index].ours) || block.theirs !== terminate(hunks[index].theirs)) return null; } const text = parsed.segments.map((segment) => ("text" in segment ? segment.text : choices[segment.block])).join(""); return hasConflictMarkers(text) ? null : text; } /** Up to `radius` lines of resolved-side text around a block, so the model sees what the hunk sits in. */ export function blockContext(content: string, block: number, radius = 20): { before: string; after: string } { const parsed = parseConflictText(content); if (!parsed) return { before: "", after: "" }; const position = parsed.segments.findIndex((segment) => "block" in segment && segment.block === block); const flatten = (segment: Segment | undefined) => !segment ? "" : "text" in segment ? segment.text : (parsed.blocks[segment.block]?.ours ?? ""); const before = splitLines(flatten(parsed.segments[position - 1])); const after = splitLines(flatten(parsed.segments[position + 1])); return { before: before.slice(-radius).join(""), after: after.slice(0, radius).join("") }; } const PLACEHOLDER = /__GITFOR_REDACTED_(\d+)__/g; /** Maps placeholder tokens to the secrets they replaced; one vault per file keeps placeholders stable. */ export type SecretVault = Map; export function redactSecrets(text: string, vault: SecretVault): string { let redacted = text; const findings = scanText(text).sort((left, right) => right.secret.length - left.secret.length); for (const finding of findings) { let token = [...vault].find(([, secret]) => secret === finding.secret)?.[0]; if (!token) { token = `__GITFOR_REDACTED_${vault.size}__`; vault.set(token, finding.secret); } redacted = redacted.split(finding.secret).join(token); } return redacted; } /** Puts secrets back into model output; null when the output references a placeholder we never issued. */ export function restoreSecrets(text: string, vault: SecretVault): string | null { let unknown = false; const restored = text.replace(PLACEHOLDER, (token) => { const secret = vault.get(token); if (secret === undefined) unknown = true; return secret ?? token; }); return unknown ? null : restored; } /** Accepts generated text only when it is bounded, marker-free and fully restorable. */ export function acceptGenerated(raw: string, vault: SecretVault, maxBytes: number): string | null { const fenced = /```merge\r?\n([\s\S]*?)```/.exec(raw); if (!fenced) return null; const restored = restoreSecrets(fenced[1], vault); if (restored === null || restored.length > maxBytes || hasConflictMarkers(restored)) return null; return terminate(restored); }