Files
oh-my-pi/packages/coding-agent/src/patch/applicator.ts
T
can1357 1a64cca199 feat(coding-agent): added persistent shell sessions
- Added persistent shell session support for bash tool with environment variable preservation across commands.
- Added shellForceBasic setting to force bash/sh even if user's default shell is different (default: true).
- Added OMP_SHELL_PERSIST environment variable to control persistent shell behavior (set to 0 to disable).
- Restructured system prompt with coordinator-specific guidance for parallel task delegation.
2026-01-31 05:22:07 +01:00

1381 lines
47 KiB
TypeScript

/**
* Patch application logic for the edit tool.
*
* Applies parsed diff hunks to file content using fuzzy matching
* for robust handling of whitespace and formatting differences.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { resolveToCwd } from "../tools/path-utils";
import { DEFAULT_FUZZY_THRESHOLD, findClosestSequenceMatch, findContextLine, findMatch, seekSequence } from "./fuzzy";
import {
adjustIndentation,
convertLeadingTabsToSpaces,
countLeadingWhitespace,
detectLineEnding,
getLeadingWhitespace,
normalizeToLF,
restoreLineEndings,
stripBom,
} from "./normalize";
import { normalizeCreateContent, parseHunks } from "./parser";
import type {
ApplyPatchOptions,
ApplyPatchResult,
ContextLineResult,
DiffHunk,
FileSystem,
NormalizedPatchInput,
PatchInput,
} from "./types";
import { ApplyPatchError, normalizePatchInput } from "./types";
// ═══════════════════════════════════════════════════════════════════════════
// Default File System
// ═══════════════════════════════════════════════════════════════════════════
/** Default filesystem implementation using Bun APIs */
export const defaultFileSystem: FileSystem = {
async exists(path: string): Promise<boolean> {
return fs.existsSync(path);
},
async read(path: string): Promise<string> {
return Bun.file(path).text();
},
async readBinary(path: string): Promise<Uint8Array> {
const buffer = await Bun.file(path).arrayBuffer();
return new Uint8Array(buffer);
},
async write(path: string, content: string): Promise<void> {
await Bun.write(path, content);
},
async delete(path: string): Promise<void> {
await fs.promises.unlink(path);
},
async mkdir(path: string): Promise<void> {
await fs.promises.mkdir(path, { recursive: true });
},
};
// ═══════════════════════════════════════════════════════════════════════════
// Internal Types
// ═══════════════════════════════════════════════════════════════════════════
interface Replacement {
startIndex: number;
oldLen: number;
newLines: string[];
}
type HunkVariantKind = "trim-common" | "dedupe-shared" | "collapse-repeated" | "single-line";
interface HunkVariant {
oldLines: string[];
newLines: string[];
kind: HunkVariantKind;
}
// ═══════════════════════════════════════════════════════════════════════════
// Replacement Computation
// ═══════════════════════════════════════════════════════════════════════════
/** Adjust indentation of newLines to match the delta between patternLines and actualLines */
function adjustLinesIndentation(patternLines: string[], actualLines: string[], newLines: string[]): string[] {
if (patternLines.length === 0 || actualLines.length === 0 || newLines.length === 0) {
return newLines;
}
// If pattern already matches actual exactly (including indentation), preserve agent's intended changes
if (patternLines.length === actualLines.length) {
let exactMatch = true;
for (let i = 0; i < patternLines.length; i++) {
if (patternLines[i] !== actualLines[i]) {
exactMatch = false;
break;
}
}
if (exactMatch) {
return newLines;
}
}
// If the patch is purely an indentation change (same trimmed content), apply exactly as specified
if (patternLines.length === newLines.length) {
let indentationOnly = true;
for (let i = 0; i < patternLines.length; i++) {
if (patternLines[i].trim() !== newLines[i].trim()) {
indentationOnly = false;
break;
}
}
if (indentationOnly) {
return newLines;
}
}
// Detect indent character from actual content
let indentChar = " ";
for (const line of actualLines) {
const ws = getLeadingWhitespace(line);
if (ws.length > 0) {
indentChar = ws[0];
break;
}
}
let patternTabOnly = true;
let actualSpaceOnly = true;
let patternMixed = false;
let actualMixed = false;
for (const line of patternLines) {
if (line.trim().length === 0) continue;
const ws = getLeadingWhitespace(line);
if (ws.includes(" ")) patternTabOnly = false;
if (ws.includes(" ") && ws.includes("\t")) patternMixed = true;
}
for (const line of actualLines) {
if (line.trim().length === 0) continue;
const ws = getLeadingWhitespace(line);
if (ws.includes("\t")) actualSpaceOnly = false;
if (ws.includes(" ") && ws.includes("\t")) actualMixed = true;
}
if (!patternMixed && !actualMixed && patternTabOnly && actualSpaceOnly) {
let ratio: number | undefined;
const lineCount = Math.min(patternLines.length, actualLines.length);
let consistent = true;
for (let i = 0; i < lineCount; i++) {
const patternLine = patternLines[i];
const actualLine = actualLines[i];
if (patternLine.trim().length === 0 || actualLine.trim().length === 0) continue;
const patternIndent = countLeadingWhitespace(patternLine);
const actualIndent = countLeadingWhitespace(actualLine);
if (patternIndent === 0) continue;
if (actualIndent % patternIndent !== 0) {
consistent = false;
break;
}
const nextRatio = actualIndent / patternIndent;
if (!ratio) {
ratio = nextRatio;
} else if (ratio !== nextRatio) {
consistent = false;
break;
}
}
if (consistent && ratio) {
const converted = convertLeadingTabsToSpaces(newLines.join("\n"), ratio).split("\n");
return converted;
}
}
// Build a map from trimmed content to actual lines (by content, not position)
// This handles fuzzy matches where pattern and actual may not be positionally aligned
const contentToActualLines = new Map<string, string[]>();
for (const line of actualLines) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
const arr = contentToActualLines.get(trimmed);
if (arr) {
arr.push(line);
} else {
contentToActualLines.set(trimmed, [line]);
}
}
let patternMin = Infinity;
for (const line of patternLines) {
if (line.trim().length === 0) continue;
patternMin = Math.min(patternMin, countLeadingWhitespace(line));
}
if (patternMin === Infinity) {
patternMin = 0;
}
let delta: number | undefined;
const deltas: number[] = [];
for (let i = 0; i < Math.min(patternLines.length, actualLines.length); i++) {
const patternLine = patternLines[i];
const actualLine = actualLines[i];
if (patternLine.trim().length === 0 || actualLine.trim().length === 0) continue;
const pIndent = countLeadingWhitespace(patternLine);
const aIndent = countLeadingWhitespace(actualLine);
deltas.push(aIndent - pIndent);
}
if (deltas.length > 0 && deltas.every(value => value === deltas[0])) {
delta = deltas[0];
}
// Track which actual lines we've used to handle duplicate content correctly
const usedActualLines = new Map<string, number>(); // trimmed content -> count used
return newLines.map(newLine => {
if (newLine.trim().length === 0) {
return newLine;
}
const trimmed = newLine.trim();
const matchingActualLines = contentToActualLines.get(trimmed);
// Check if this is a context line (same trimmed content exists in actual)
if (matchingActualLines && matchingActualLines.length > 0) {
if (matchingActualLines.length === 1) {
return matchingActualLines[0];
}
if (matchingActualLines.includes(newLine)) {
return newLine;
}
const usedCount = usedActualLines.get(trimmed) ?? 0;
if (usedCount < matchingActualLines.length) {
usedActualLines.set(trimmed, usedCount + 1);
// Use actual file content directly for context lines
return matchingActualLines[usedCount];
}
}
// This is a new/added line - apply consistent delta if safe
if (delta && delta !== 0) {
const newIndent = countLeadingWhitespace(newLine);
if (newIndent === patternMin) {
if (delta > 0) {
return indentChar.repeat(delta) + newLine;
}
const toRemove = Math.min(-delta, newIndent);
return newLine.slice(toRemove);
}
}
return newLine;
});
}
function trimCommonContext(oldLines: string[], newLines: string[]): HunkVariant | undefined {
let start = 0;
let endOld = oldLines.length;
let endNew = newLines.length;
while (start < endOld && start < endNew && oldLines[start] === newLines[start]) {
start++;
}
while (endOld > start && endNew > start && oldLines[endOld - 1] === newLines[endNew - 1]) {
endOld--;
endNew--;
}
if (start === 0 && endOld === oldLines.length && endNew === newLines.length) {
return undefined;
}
const trimmedOld = oldLines.slice(start, endOld);
const trimmedNew = newLines.slice(start, endNew);
if (trimmedOld.length === 0 && trimmedNew.length === 0) {
return undefined;
}
return { oldLines: trimmedOld, newLines: trimmedNew, kind: "trim-common" };
}
function collapseConsecutiveSharedLines(oldLines: string[], newLines: string[]): HunkVariant | undefined {
const shared = new Set(oldLines.filter(line => newLines.includes(line)));
const collapse = (lines: string[]): string[] => {
const out: string[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
out.push(line);
let j = i + 1;
while (j < lines.length && lines[j] === line && shared.has(line)) {
j++;
}
i = j;
}
return out;
};
const collapsedOld = collapse(oldLines);
const collapsedNew = collapse(newLines);
if (collapsedOld.length === oldLines.length && collapsedNew.length === newLines.length) {
return undefined;
}
return { oldLines: collapsedOld, newLines: collapsedNew, kind: "dedupe-shared" };
}
function collapseRepeatedBlocks(oldLines: string[], newLines: string[]): HunkVariant | undefined {
const shared = new Set(oldLines.filter(line => newLines.includes(line)));
const collapse = (lines: string[]): string[] => {
const output = [...lines];
let changed = false;
let i = 0;
while (i < output.length) {
let collapsed = false;
for (let size = Math.floor((output.length - i) / 2); size >= 2; size--) {
const first = output.slice(i, i + size);
const second = output.slice(i + size, i + size * 2);
if (first.length !== second.length || first.length === 0) continue;
if (!first.every(line => shared.has(line))) continue;
let same = true;
for (let idx = 0; idx < size; idx++) {
if (first[idx] !== second[idx]) {
same = false;
break;
}
}
if (same) {
output.splice(i + size, size);
changed = true;
collapsed = true;
break;
}
}
if (!collapsed) {
i++;
}
}
return changed ? output : lines;
};
const collapsedOld = collapse(oldLines);
const collapsedNew = collapse(newLines);
if (collapsedOld.length === oldLines.length && collapsedNew.length === newLines.length) {
return undefined;
}
return { oldLines: collapsedOld, newLines: collapsedNew, kind: "collapse-repeated" };
}
function reduceToSingleLineChange(oldLines: string[], newLines: string[]): HunkVariant | undefined {
if (oldLines.length !== newLines.length || oldLines.length === 0) return undefined;
let changedIndex: number | undefined;
for (let i = 0; i < oldLines.length; i++) {
if (oldLines[i] !== newLines[i]) {
if (changedIndex !== undefined) return undefined;
changedIndex = i;
}
}
if (changedIndex === undefined) return undefined;
return { oldLines: [oldLines[changedIndex]], newLines: [newLines[changedIndex]], kind: "single-line" };
}
function buildFallbackVariants(hunk: DiffHunk): HunkVariant[] {
const variants: HunkVariant[] = [];
const base: HunkVariant = { oldLines: hunk.oldLines, newLines: hunk.newLines, kind: "trim-common" };
const trimmed = trimCommonContext(base.oldLines, base.newLines);
if (trimmed) variants.push(trimmed);
const deduped = collapseConsecutiveSharedLines(
trimmed?.oldLines ?? base.oldLines,
trimmed?.newLines ?? base.newLines,
);
if (deduped) variants.push(deduped);
const collapsed = collapseRepeatedBlocks(
deduped?.oldLines ?? trimmed?.oldLines ?? base.oldLines,
deduped?.newLines ?? trimmed?.newLines ?? base.newLines,
);
if (collapsed) variants.push(collapsed);
const singleLine = reduceToSingleLineChange(trimmed?.oldLines ?? base.oldLines, trimmed?.newLines ?? base.newLines);
if (singleLine) variants.push(singleLine);
const seen = new Set<string>();
return variants.filter(variant => {
if (variant.oldLines.length === 0 && variant.newLines.length === 0) return false;
const key = `${variant.oldLines.join("\n")}||${variant.newLines.join("\n")}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function filterFallbackVariants(variants: HunkVariant[], allowAggressive: boolean): HunkVariant[] {
if (allowAggressive) return variants;
return variants.filter(variant => variant.kind !== "collapse-repeated" && variant.kind !== "single-line");
}
function findContextRelativeMatch(
lines: string[],
patternLine: string,
contextIndex: number,
preferSecondForwardMatch: boolean,
): number | undefined {
const trimmed = patternLine.trim();
const forwardMatches: number[] = [];
for (let i = contextIndex + 1; i < lines.length; i++) {
if (lines[i].trim() === trimmed) {
forwardMatches.push(i);
}
}
if (forwardMatches.length > 0) {
if (preferSecondForwardMatch && forwardMatches.length > 1) {
return forwardMatches[1];
}
return forwardMatches[0];
}
for (let i = contextIndex - 1; i >= 0; i--) {
if (lines[i].trim() === trimmed) {
return i;
}
}
return undefined;
}
const AMBIGUITY_HINT_WINDOW = 200;
const MATCH_PREVIEW_CONTEXT = 2;
const MATCH_PREVIEW_MAX_LEN = 80;
function formatSequenceMatchPreview(lines: string[], startIdx: number): string {
const start = Math.max(0, startIdx - MATCH_PREVIEW_CONTEXT);
const end = Math.min(lines.length, startIdx + MATCH_PREVIEW_CONTEXT + 1);
const previewLines = lines.slice(start, end);
return previewLines
.map((line, i) => {
const num = start + i + 1;
const truncated =
line.length > MATCH_PREVIEW_MAX_LEN ? `${line.slice(0, MATCH_PREVIEW_MAX_LEN - 3)}...` : line;
return ` ${num} | ${truncated}`;
})
.join("\n");
}
function formatSequenceMatchPreviews(
lines: string[],
matchIndices: number[] | undefined,
matchCount: number | undefined,
): string | undefined {
if (!matchIndices || matchIndices.length === 0) return undefined;
const previews = matchIndices.map(index => formatSequenceMatchPreview(lines, index));
const moreMsg =
matchCount && matchCount > matchIndices.length ? ` (showing first ${matchIndices.length} of ${matchCount})` : "";
return `${previews.join("\n\n")}${moreMsg}`;
}
function chooseHintedMatch(
matchIndices: number[] | undefined,
hintIndex: number | undefined,
window: number,
): number | undefined {
if (!matchIndices || matchIndices.length === 0 || hintIndex === undefined) return undefined;
const candidates = matchIndices.filter(index => Math.abs(index - hintIndex) <= window);
if (candidates.length === 1) return candidates[0];
return undefined;
}
/** Get hint index from hunk's line number */
function getHunkHintIndex(hunk: DiffHunk, currentIndex: number): number | undefined {
if (hunk.oldStartLine === undefined) return undefined;
const hintIndex = Math.max(0, hunk.oldStartLine - 1);
return hintIndex >= currentIndex ? hintIndex : undefined;
}
/**
* Find hierarchical context in file lines.
*
* Handles three formats:
* 1. Simple context: "function foo" - find this line
* 2. Hierarchical (newline): "class Foo\nmethod" - find class, then method after it
* 3. Hierarchical (space): "class Foo method" - try as literal first, then split and search
*
* @returns The result from finding the final (innermost) context, or undefined if not found
*/
function findHierarchicalContext(
lines: string[],
context: string,
startFrom: number,
lineHint: number | undefined,
allowFuzzy: boolean,
): ContextLineResult {
// Check for newline-separated hierarchical contexts (from nested @@ anchors)
if (context.includes("\n")) {
const parts = context
.split("\n")
.map(p => p.trim())
.filter(p => p.length > 0);
let currentStart = startFrom;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const isLast = i === parts.length - 1;
const result = findContextLine(lines, part, currentStart, { allowFuzzy });
if (result.matchCount !== undefined && result.matchCount > 1) {
if (isLast && lineHint !== undefined) {
const hintStart = Math.max(0, lineHint - 1);
if (hintStart >= currentStart) {
const hintedResult = findContextLine(lines, part, hintStart, { allowFuzzy });
if (hintedResult.index !== undefined) {
return { ...hintedResult, matchCount: 1, matchIndices: [hintedResult.index] };
}
}
}
return {
index: undefined,
confidence: result.confidence,
matchCount: result.matchCount,
matchIndices: result.matchIndices,
strategy: result.strategy,
};
}
if (result.index === undefined) {
if (isLast && lineHint !== undefined) {
const hintStart = Math.max(0, lineHint - 1);
if (hintStart >= currentStart) {
const hintedResult = findContextLine(lines, part, hintStart, { allowFuzzy });
if (hintedResult.index !== undefined) {
return { ...hintedResult, matchCount: 1, matchIndices: [hintedResult.index] };
}
}
}
return { index: undefined, confidence: result.confidence };
}
if (isLast) {
return result;
}
currentStart = result.index + 1;
}
return { index: undefined, confidence: 0 };
}
// Try literal context first
const spaceParts = context.split(/\s+/).filter(p => p.length > 0);
const hasSignatureChars = /[(){}[\]]/.test(context);
if (!hasSignatureChars && spaceParts.length > 2) {
const outer = spaceParts.slice(0, -1).join(" ");
const inner = spaceParts[spaceParts.length - 1];
const outerResult = findContextLine(lines, outer, startFrom, { allowFuzzy });
if (outerResult.matchCount !== undefined && outerResult.matchCount > 1) {
return {
index: undefined,
confidence: outerResult.confidence,
matchCount: outerResult.matchCount,
matchIndices: outerResult.matchIndices,
strategy: outerResult.strategy,
};
}
if (outerResult.index !== undefined) {
const innerResult = findContextLine(lines, inner, outerResult.index + 1, { allowFuzzy });
if (innerResult.index !== undefined) {
return innerResult.matchCount && innerResult.matchCount > 1
? { ...innerResult, matchCount: 1, matchIndices: [innerResult.index] }
: innerResult;
}
if (innerResult.matchCount !== undefined && innerResult.matchCount > 1) {
return {
...innerResult,
matchCount: 1,
matchIndices: innerResult.index !== undefined ? [innerResult.index] : innerResult.matchIndices,
};
}
}
}
const result = findContextLine(lines, context, startFrom, { allowFuzzy });
// If line hint exists and result is ambiguous or missing, try from hint
if ((result.index === undefined || (result.matchCount ?? 0) > 1) && lineHint !== undefined) {
const hintStart = Math.max(0, lineHint - 1);
const hintedResult = findContextLine(lines, context, hintStart, { allowFuzzy });
if (hintedResult.index !== undefined) {
return { ...hintedResult, matchCount: 1, matchIndices: [hintedResult.index] };
}
}
// If found uniquely, return it
if (result.index !== undefined && (result.matchCount ?? 0) <= 1) {
return result;
}
if (result.matchCount !== undefined && result.matchCount > 1) {
return result;
}
// Try from beginning if not found from current position
if (result.index === undefined && startFrom !== 0) {
const fromStartResult = findContextLine(lines, context, 0, { allowFuzzy });
if (fromStartResult.index !== undefined && (fromStartResult.matchCount ?? 0) <= 1) {
return fromStartResult;
}
if (fromStartResult.matchCount !== undefined && fromStartResult.matchCount > 1) {
return fromStartResult;
}
}
// Fallback: try space-separated hierarchical matching
// e.g., "class PatchTool constructor" -> find "class PatchTool", then "constructor" after it
if (!hasSignatureChars && spaceParts.length > 1) {
const outer = spaceParts.slice(0, -1).join(" ");
const inner = spaceParts[spaceParts.length - 1];
const outerResult = findContextLine(lines, outer, startFrom, { allowFuzzy });
if (outerResult.matchCount !== undefined && outerResult.matchCount > 1) {
return {
index: undefined,
confidence: outerResult.confidence,
matchCount: outerResult.matchCount,
matchIndices: outerResult.matchIndices,
strategy: outerResult.strategy,
};
}
if (outerResult.index === undefined) {
return { index: undefined, confidence: outerResult.confidence };
}
const innerResult = findContextLine(lines, inner, outerResult.index + 1, { allowFuzzy });
if (innerResult.index !== undefined) {
return innerResult.matchCount && innerResult.matchCount > 1
? { ...innerResult, matchCount: 1, matchIndices: [innerResult.index] }
: innerResult;
}
if (innerResult.matchCount !== undefined && innerResult.matchCount > 1) {
return {
...innerResult,
matchCount: 1,
matchIndices: innerResult.index !== undefined ? [innerResult.index] : innerResult.matchIndices,
};
}
}
return result;
}
/** Find sequence with optional hint position, returning full search result */
function findSequenceWithHint(
lines: string[],
pattern: string[],
currentIndex: number,
hintIndex: number | undefined,
eof: boolean,
allowFuzzy: boolean,
): import("./types").SequenceSearchResult {
// Prefer content-based search starting from currentIndex
const primaryResult = seekSequence(lines, pattern, currentIndex, eof, { allowFuzzy });
if (
primaryResult.matchCount &&
primaryResult.matchCount > 1 &&
hintIndex !== undefined &&
hintIndex !== currentIndex
) {
const hintedResult = seekSequence(lines, pattern, hintIndex, eof, { allowFuzzy });
if (hintedResult.index !== undefined && (hintedResult.matchCount ?? 1) <= 1) {
return hintedResult;
}
if (hintedResult.matchCount && hintedResult.matchCount > 1) {
return hintedResult;
}
}
if (primaryResult.index !== undefined || (primaryResult.matchCount && primaryResult.matchCount > 1)) {
return primaryResult;
}
// Use line hint as a secondary bias only if needed
if (hintIndex !== undefined && hintIndex !== currentIndex) {
const hintedResult = seekSequence(lines, pattern, hintIndex, eof, { allowFuzzy });
if (hintedResult.index !== undefined || (hintedResult.matchCount && hintedResult.matchCount > 1)) {
return hintedResult;
}
}
// Last resort: search from beginning (handles out-of-order hunks)
if (currentIndex !== 0) {
const fromStartResult = seekSequence(lines, pattern, 0, eof, { allowFuzzy });
if (fromStartResult.index !== undefined || (fromStartResult.matchCount && fromStartResult.matchCount > 1)) {
return fromStartResult;
}
}
return primaryResult;
}
function attemptSequenceFallback(
lines: string[],
hunk: DiffHunk,
currentIndex: number,
lineHint: number | undefined,
allowFuzzy: boolean,
allowAggressiveFallbacks: boolean,
): number | undefined {
if (hunk.oldLines.length === 0) return undefined;
const matchHint = getHunkHintIndex(hunk, currentIndex);
const fallbackResult = findSequenceWithHint(
lines,
hunk.oldLines,
currentIndex,
matchHint ?? lineHint,
false,
allowFuzzy,
);
if (fallbackResult.index !== undefined && (fallbackResult.matchCount ?? 1) <= 1) {
const nextIndex = fallbackResult.index + 1;
if (nextIndex <= lines.length - hunk.oldLines.length) {
const secondMatch = seekSequence(lines, hunk.oldLines, nextIndex, false, { allowFuzzy });
if (secondMatch.index !== undefined) {
return undefined;
}
}
return fallbackResult.index;
}
for (const variant of filterFallbackVariants(buildFallbackVariants(hunk), allowAggressiveFallbacks)) {
if (variant.oldLines.length === 0) continue;
const variantResult = findSequenceWithHint(
lines,
variant.oldLines,
currentIndex,
matchHint ?? lineHint,
false,
allowFuzzy,
);
if (variantResult.index !== undefined && (variantResult.matchCount ?? 1) <= 1) {
return variantResult.index;
}
}
return undefined;
}
/**
* Apply a hunk using character-based fuzzy matching.
* Used when the hunk contains only -/+ lines without context.
*/
function applyCharacterMatch(
originalContent: string,
path: string,
hunk: DiffHunk,
fuzzyThreshold: number,
allowFuzzy: boolean,
): { content: string; warnings: string[] } {
const oldText = hunk.oldLines.join("\n");
const newText = hunk.newLines.join("\n");
const normalizedContent = normalizeToLF(originalContent);
const normalizedOldText = normalizeToLF(oldText);
let matchOutcome = findMatch(normalizedContent, normalizedOldText, {
allowFuzzy,
threshold: fuzzyThreshold,
});
if (!matchOutcome.match && allowFuzzy) {
const relaxedThreshold = Math.min(fuzzyThreshold, 0.92);
if (relaxedThreshold < fuzzyThreshold) {
const relaxedOutcome = findMatch(normalizedContent, normalizedOldText, {
allowFuzzy,
threshold: relaxedThreshold,
});
if (relaxedOutcome.match) {
matchOutcome = relaxedOutcome;
}
}
}
// Check for multiple exact occurrences
if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
const previews = matchOutcome.occurrencePreviews?.join("\n\n") ?? "";
const moreMsg = matchOutcome.occurrences > 5 ? ` (showing first 5 of ${matchOutcome.occurrences})` : "";
throw new ApplyPatchError(
`Found ${matchOutcome.occurrences} occurrences in ${path}${moreMsg}:\n\n${previews}\n\n` +
`Add more context lines to disambiguate.`,
);
}
if (matchOutcome.fuzzyMatches && matchOutcome.fuzzyMatches > 1) {
throw new ApplyPatchError(
`Found ${matchOutcome.fuzzyMatches} high-confidence matches in ${path}. ` +
`The text must be unique. Please provide more context to make it unique.`,
);
}
if (!matchOutcome.match) {
const closest = matchOutcome.closest;
if (closest) {
const similarity = Math.round(closest.confidence * 100);
throw new ApplyPatchError(
`Could not find a close enough match in ${path}. ` +
`Closest match (${similarity}% similar) at line ${closest.startLine}.`,
);
}
throw new ApplyPatchError(`Failed to find expected lines in ${path}:\n${oldText}`);
}
// Adjust indentation to match what was actually found
const adjustedNewText = adjustIndentation(normalizedOldText, matchOutcome.match.actualText, newText);
const warnings: string[] = [];
if (matchOutcome.dominantFuzzy && matchOutcome.match) {
const similarity = Math.round(matchOutcome.match.confidence * 100);
warnings.push(
`Dominant fuzzy match selected in ${path} near line ${matchOutcome.match.startLine} (${similarity}% similar).`,
);
}
// Apply the replacement
const before = normalizedContent.substring(0, matchOutcome.match.startIndex);
const after = normalizedContent.substring(matchOutcome.match.startIndex + matchOutcome.match.actualText.length);
return { content: before + adjustedNewText + after, warnings };
}
function applyTrailingNewlinePolicy(content: string, hadFinalNewline: boolean): string {
if (hadFinalNewline) {
return content.endsWith("\n") ? content : `${content}\n`;
}
return content.replace(/\n+$/u, "");
}
/**
* Compute replacements needed to transform originalLines using the diff hunks.
*/
function computeReplacements(
originalLines: string[],
path: string,
hunks: DiffHunk[],
allowFuzzy: boolean,
): { replacements: Replacement[]; warnings: string[] } {
const replacements: Replacement[] = [];
const warnings: string[] = [];
let lineIndex = 0;
for (const hunk of hunks) {
let contextIndex: number | undefined;
if (hunk.oldStartLine !== undefined && hunk.oldStartLine < 1) {
throw new ApplyPatchError(
`Line hint ${hunk.oldStartLine} is out of range for ${path} (line numbers start at 1)`,
);
}
if (hunk.newStartLine !== undefined && hunk.newStartLine < 1) {
throw new ApplyPatchError(
`Line hint ${hunk.newStartLine} is out of range for ${path} (line numbers start at 1)`,
);
}
const lineHint = hunk.oldStartLine;
const allowAggressiveFallbacks = hunk.changeContext !== undefined || lineHint !== undefined || hunk.isEndOfFile;
if (lineHint !== undefined && hunk.changeContext === undefined && !hunk.hasContextLines) {
lineIndex = Math.max(0, Math.min(lineHint - 1, originalLines.length - 1));
}
// If hunk has a changeContext, find it and adjust lineIndex
if (hunk.changeContext !== undefined) {
// Use hierarchical context matching for nested @@ anchors and space-separated contexts
const result = findHierarchicalContext(originalLines, hunk.changeContext, lineIndex, lineHint, allowFuzzy);
const idx = result.index;
contextIndex = idx;
if (idx === undefined || (result.matchCount !== undefined && result.matchCount > 1)) {
const fallback = attemptSequenceFallback(
originalLines,
hunk,
lineIndex,
lineHint,
allowFuzzy,
allowAggressiveFallbacks,
);
if (fallback !== undefined) {
lineIndex = fallback;
} else if (result.matchCount !== undefined && result.matchCount > 1) {
const displayContext = hunk.changeContext.includes("\n")
? hunk.changeContext.split("\n").pop()
: hunk.changeContext;
const previews = formatSequenceMatchPreviews(originalLines, result.matchIndices, result.matchCount);
const strategyHint = result.strategy ? ` Matching strategy: ${result.strategy}.` : "";
const previewText = previews ? `\n\n${previews}` : "";
throw new ApplyPatchError(
`Found ${result.matchCount} matches for context '${displayContext}' in ${path}.${strategyHint}` +
`${previewText}\n\nAdd more surrounding context or additional @@ anchors to make it unique.`,
);
} else {
const displayContext = hunk.changeContext.includes("\n")
? hunk.changeContext.split("\n").join(" > ")
: hunk.changeContext;
throw new ApplyPatchError(`Failed to find context '${displayContext}' in ${path}`);
}
} else {
// If oldLines[0] matches the final context, start search at idx (not idx+1)
// This handles the common case where @@ scope and first context line are identical
const firstOldLine = hunk.oldLines[0];
const finalContext = hunk.changeContext.includes("\n")
? hunk.changeContext.split("\n").pop()?.trim()
: hunk.changeContext.trim();
const isHierarchicalContext =
hunk.changeContext.includes("\n") || hunk.changeContext.trim().split(/\s+/).length > 2;
if (firstOldLine !== undefined && (firstOldLine.trim() === finalContext || isHierarchicalContext)) {
lineIndex = idx;
} else {
lineIndex = idx + 1;
}
}
}
if (hunk.oldLines.length === 0) {
// Pure addition - prefer changeContext position, then line hint, then end of file
let insertionIdx: number;
if (hunk.changeContext !== undefined) {
// changeContext was processed above; lineIndex is set to the context line or after it
insertionIdx = lineIndex;
} else {
const lineHintForInsertion = hunk.oldStartLine ?? hunk.newStartLine;
if (lineHintForInsertion !== undefined) {
// Reject if line hint is out of range for insertion
// Valid insertion points are 1 to (file length + 1) for 1-indexed hints
if (lineHintForInsertion < 1) {
throw new ApplyPatchError(
`Line hint ${lineHintForInsertion} is out of range for insertion in ${path} ` +
`(line numbers start at 1)`,
);
}
if (lineHintForInsertion > originalLines.length + 1) {
throw new ApplyPatchError(
`Line hint ${lineHintForInsertion} is out of range for insertion in ${path} ` +
`(file has ${originalLines.length} lines)`,
);
}
insertionIdx = Math.max(0, lineHintForInsertion - 1);
} else {
insertionIdx =
originalLines.length > 0 && originalLines[originalLines.length - 1] === ""
? originalLines.length - 1
: originalLines.length;
}
}
replacements.push({ startIndex: insertionIdx, oldLen: 0, newLines: [...hunk.newLines] });
continue;
}
// Try to find the old lines in the file
let pattern = [...hunk.oldLines];
const matchHint = getHunkHintIndex(hunk, lineIndex);
let searchResult = findSequenceWithHint(
originalLines,
pattern,
lineIndex,
matchHint,
hunk.isEndOfFile,
allowFuzzy,
);
let newSlice = [...hunk.newLines];
// Retry without trailing empty line if present
if (searchResult.index === undefined && pattern.length > 0 && pattern[pattern.length - 1] === "") {
pattern = pattern.slice(0, -1);
if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
newSlice = newSlice.slice(0, -1);
}
searchResult = findSequenceWithHint(
originalLines,
pattern,
lineIndex,
matchHint,
hunk.isEndOfFile,
allowFuzzy,
);
}
if (searchResult.index === undefined || (searchResult.matchCount ?? 0) > 1) {
for (const variant of filterFallbackVariants(buildFallbackVariants(hunk), allowAggressiveFallbacks)) {
if (variant.oldLines.length === 0) continue;
const variantResult = findSequenceWithHint(
originalLines,
variant.oldLines,
lineIndex,
matchHint,
hunk.isEndOfFile,
allowFuzzy,
);
if (variantResult.index !== undefined && (variantResult.matchCount ?? 1) <= 1) {
pattern = variant.oldLines;
newSlice = variant.newLines;
searchResult = variantResult;
break;
}
}
}
if (searchResult.index === undefined && contextIndex !== undefined) {
for (const variant of filterFallbackVariants(buildFallbackVariants(hunk), allowAggressiveFallbacks)) {
if (variant.oldLines.length !== 1 || variant.newLines.length !== 1) continue;
const removedLine = variant.oldLines[0];
const hasSharedDuplicate = hunk.newLines.some(line => line.trim() === removedLine.trim());
const adjacentIndex = findContextRelativeMatch(
originalLines,
removedLine,
contextIndex,
hasSharedDuplicate,
);
if (adjacentIndex !== undefined) {
pattern = variant.oldLines;
newSlice = variant.newLines;
searchResult = { index: adjacentIndex, confidence: 0.95 };
break;
}
}
}
if (searchResult.index !== undefined && contextIndex !== undefined && pattern.length === 1) {
const trimmed = pattern[0].trim();
let occurrenceCount = 0;
for (const line of originalLines) {
if (line.trim() === trimmed) occurrenceCount++;
}
if (occurrenceCount > 1) {
const hasSharedDuplicate = hunk.newLines.some(line => line.trim() === trimmed);
const contextMatch = findContextRelativeMatch(originalLines, pattern[0], contextIndex, hasSharedDuplicate);
if (contextMatch !== undefined) {
searchResult = { index: contextMatch, confidence: searchResult.confidence ?? 0.95 };
}
}
}
if ((searchResult.matchCount ?? 0) > 1) {
const hintIndex = matchHint ?? (lineHint ? lineHint - 1 : undefined);
const hinted = chooseHintedMatch(searchResult.matchIndices, hintIndex, AMBIGUITY_HINT_WINDOW);
if (hinted !== undefined) {
searchResult = { ...searchResult, index: hinted, matchCount: 1 };
}
}
if (searchResult.index === undefined) {
if (searchResult.matchCount !== undefined && searchResult.matchCount > 1) {
const previews = formatSequenceMatchPreviews(
originalLines,
searchResult.matchIndices,
searchResult.matchCount,
);
const strategyHint = searchResult.strategy ? ` Matching strategy: ${searchResult.strategy}.` : "";
const previewText = previews ? `\n\n${previews}` : "";
throw new ApplyPatchError(
`Found ${searchResult.matchCount} matches for the text in ${path}.${strategyHint}` +
`${previewText}\n\nAdd more surrounding context or additional @@ anchors to make it unique.`,
);
}
const closest = findClosestSequenceMatch(originalLines, pattern, {
start: lineIndex,
eof: hunk.isEndOfFile,
});
if (closest.index !== undefined && closest.confidence > 0) {
const similarity = Math.round(closest.confidence * 100);
const preview = formatSequenceMatchPreview(originalLines, closest.index);
throw new ApplyPatchError(
`Failed to find expected lines in ${path}:\n${hunk.oldLines.join("\n")}\n\n` +
`Closest match (${similarity}% similar) near line ${closest.index + 1}:\n${preview}`,
);
}
throw new ApplyPatchError(`Failed to find expected lines in ${path}:\n${hunk.oldLines.join("\n")}`);
}
const found = searchResult.index;
if (searchResult.strategy === "fuzzy-dominant") {
const similarity = Math.round(searchResult.confidence * 100);
warnings.push(`Dominant fuzzy match selected in ${path} near line ${found + 1} (${similarity}% similar).`);
}
// Reject if match is ambiguous (prefix/substring matching found multiple matches)
if (searchResult.matchCount !== undefined && searchResult.matchCount > 1) {
const previews = formatSequenceMatchPreviews(
originalLines,
searchResult.matchIndices,
searchResult.matchCount,
);
const strategyHint = searchResult.strategy ? ` Matching strategy: ${searchResult.strategy}.` : "";
const previewText = previews ? `\n\n${previews}` : "";
throw new ApplyPatchError(
`Found ${searchResult.matchCount} matches for the text in ${path}.${strategyHint}` +
`${previewText}\n\nAdd more surrounding context or additional @@ anchors to make it unique.`,
);
}
// For simple diffs (no context marker, no context lines), check for multiple occurrences
// This ensures ambiguous replacements are rejected
// Skip this check if isEndOfFile is set (EOF marker provides disambiguation)
if (hunk.changeContext === undefined && !hunk.hasContextLines && !hunk.isEndOfFile && lineHint === undefined) {
const secondMatch = seekSequence(originalLines, pattern, found + 1, false, { allowFuzzy });
if (secondMatch.index !== undefined) {
// Extract 3-line previews for each match
const formatPreview = (startIdx: number) => {
const contextLines = 2;
const maxLineLength = 80;
const start = Math.max(0, startIdx - contextLines);
const end = Math.min(originalLines.length, startIdx + contextLines + 1);
const lines = originalLines.slice(start, end);
return lines
.map((line, i) => {
const num = start + i + 1;
const truncated = line.length > maxLineLength ? `${line.slice(0, maxLineLength - 3)}...` : line;
return ` ${num} | ${truncated}`;
})
.join("\n");
};
const preview1 = formatPreview(found);
const preview2 = formatPreview(secondMatch.index);
throw new ApplyPatchError(
`Found 2 occurrences in ${path}:\n\n${preview1}\n\n${preview2}\n\n` +
`Add more context lines to disambiguate.`,
);
}
}
// Adjust indentation if needed (handles fuzzy matches where indentation differs)
const actualMatchedLines = originalLines.slice(found, found + pattern.length);
const adjustedNewLines = adjustLinesIndentation(pattern, actualMatchedLines, newSlice);
replacements.push({ startIndex: found, oldLen: pattern.length, newLines: adjustedNewLines });
lineIndex = found + pattern.length;
}
// Sort by start index
replacements.sort((a, b) => a.startIndex - b.startIndex);
for (let i = 1; i < replacements.length; i++) {
const prev = replacements[i - 1];
const next = replacements[i];
const prevEnd = prev.startIndex + prev.oldLen;
if (next.startIndex < prevEnd) {
const formatRange = (replacement: Replacement): string => {
if (replacement.oldLen === 0) {
return `${replacement.startIndex + 1} (insertion)`;
}
return `${replacement.startIndex + 1}-${replacement.startIndex + replacement.oldLen}`;
};
const prevRange = formatRange(prev);
const nextRange = formatRange(next);
throw new ApplyPatchError(
`Overlapping hunks detected in ${path} at lines ${prevRange} and ${nextRange}. ` +
`Split hunks or add more context to avoid overlap.`,
);
}
}
return { replacements, warnings };
}
/**
* Apply replacements to lines, returning the modified content.
*/
function applyReplacements(lines: string[], replacements: Replacement[]): string[] {
const result = [...lines];
// Apply in reverse order to maintain indices
for (let i = replacements.length - 1; i >= 0; i--) {
const { startIndex, oldLen, newLines } = replacements[i];
result.splice(startIndex, oldLen);
result.splice(startIndex, 0, ...newLines);
}
return result;
}
/**
* Apply diff hunks to file content.
*/
function applyHunksToContent(
originalContent: string,
path: string,
hunks: DiffHunk[],
fuzzyThreshold: number,
allowFuzzy: boolean,
): { content: string; warnings: string[] } {
const hadFinalNewline = originalContent.endsWith("\n");
// Detect simple replace pattern: single hunk, no @@ context, no context lines, has old lines to match
// Only use character-based matching when there are no hints to disambiguate
if (hunks.length === 1) {
const hunk = hunks[0];
if (
hunk.changeContext === undefined &&
!hunk.hasContextLines &&
hunk.oldLines.length > 0 &&
hunk.oldStartLine === undefined && // No line hint to use for positioning
!hunk.isEndOfFile // No EOF targeting (prefer end of file)
) {
const { content, warnings } = applyCharacterMatch(originalContent, path, hunk, fuzzyThreshold, allowFuzzy);
return { content: applyTrailingNewlinePolicy(content, hadFinalNewline), warnings };
}
}
let originalLines = originalContent.split("\n");
// Track if we have a trailing empty element from the final newline
// Only strip ONE trailing empty (the newline marker), preserve actual blank lines
let strippedTrailingEmpty = false;
if (hadFinalNewline && originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
// Check if the second-to-last is also empty (actual blank line) - if so, only strip one
originalLines = originalLines.slice(0, -1);
strippedTrailingEmpty = true;
}
const { replacements, warnings } = computeReplacements(originalLines, path, hunks, allowFuzzy);
const newLines = applyReplacements(originalLines, replacements);
// Restore the trailing empty element if we stripped it
if (strippedTrailingEmpty) {
newLines.push("");
}
const content = newLines.join("\n");
// Preserve original trailing newline behavior
if (hadFinalNewline && !content.endsWith("\n")) {
return { content: `${content}\n`, warnings };
}
if (!hadFinalNewline && content.endsWith("\n")) {
return { content: content.slice(0, -1), warnings };
}
return { content, warnings };
}
// ═══════════════════════════════════════════════════════════════════════════
// Public API
// ═══════════════════════════════════════════════════════════════════════════
/**
* Apply a patch operation to the filesystem.
*/
export async function applyPatch(input: PatchInput, options: ApplyPatchOptions): Promise<ApplyPatchResult> {
const normalized = normalizePatchInput(input);
return applyNormalizedPatch(normalized, options);
}
/**
* Apply a normalized patch operation to the filesystem.
* @internal
*/
async function applyNormalizedPatch(
input: NormalizedPatchInput,
options: ApplyPatchOptions,
): Promise<ApplyPatchResult> {
const {
cwd,
dryRun = false,
fs = defaultFileSystem,
fuzzyThreshold = DEFAULT_FUZZY_THRESHOLD,
allowFuzzy = true,
} = options;
const resolvePath = (p: string): string => resolveToCwd(p, cwd);
const absolutePath = resolvePath(input.path);
if (input.rename) {
const destPath = resolvePath(input.rename);
if (destPath === absolutePath) {
throw new ApplyPatchError("rename path is the same as source path");
}
}
// Handle CREATE operation
if (input.op === "create") {
if (!input.diff) {
throw new ApplyPatchError("Create operation requires diff (file content)");
}
// Strip + prefixes if present (handles diffs formatted as additions)
const normalizedContent = normalizeCreateContent(input.diff);
const content = normalizedContent.endsWith("\n") ? normalizedContent : `${normalizedContent}\n`;
if (!dryRun) {
const parentDir = path.dirname(absolutePath);
if (parentDir && parentDir !== ".") {
await fs.mkdir(parentDir);
}
await fs.write(absolutePath, content);
}
return {
change: {
type: "create",
path: absolutePath,
newContent: content,
},
};
}
// Handle DELETE operation
if (input.op === "delete") {
if (!(await fs.exists(absolutePath))) {
throw new ApplyPatchError(`File not found: ${input.path}`);
}
const oldContent = await fs.read(absolutePath);
if (!dryRun) {
await fs.delete(absolutePath);
}
return {
change: {
type: "delete",
path: absolutePath,
oldContent,
},
};
}
// Handle UPDATE operation
if (!input.diff) {
throw new ApplyPatchError("Update operation requires diff (hunks)");
}
if (!(await fs.exists(absolutePath))) {
throw new ApplyPatchError(`File not found: ${input.path}`);
}
const originalContent = await fs.read(absolutePath);
const { bom: bomFromText, text: strippedContent } = stripBom(originalContent);
let bom = bomFromText;
if (!bom && fs.readBinary) {
const bytes = await fs.readBinary(absolutePath);
if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
bom = "\uFEFF";
}
}
const lineEnding = detectLineEnding(strippedContent);
const normalizedContent = normalizeToLF(strippedContent);
const hunks = parseHunks(input.diff);
if (hunks.length === 0) {
throw new ApplyPatchError("Diff contains no hunks");
}
const { content: newContent, warnings } = applyHunksToContent(
normalizedContent,
input.path,
hunks,
fuzzyThreshold,
allowFuzzy,
);
const finalContent = bom + restoreLineEndings(newContent, lineEnding);
const destPath = input.rename ? resolvePath(input.rename) : absolutePath;
const isMove = Boolean(input.rename) && destPath !== absolutePath;
if (!dryRun) {
if (isMove) {
const parentDir = path.dirname(destPath);
if (parentDir && parentDir !== ".") {
await fs.mkdir(parentDir);
}
await fs.write(destPath, finalContent);
await fs.delete(absolutePath);
} else {
await fs.write(absolutePath, finalContent);
}
}
return {
change: {
type: "update",
path: absolutePath,
newPath: isMove ? destPath : undefined,
oldContent: originalContent,
newContent: finalContent,
},
warnings: warnings.length > 0 ? warnings : undefined,
};
}
/**
* Preview what changes a patch would make without applying it.
*/
export async function previewPatch(input: PatchInput, options: ApplyPatchOptions): Promise<ApplyPatchResult> {
return applyPatch(input, { ...options, dryRun: true });
}