源码
packages/coding-agent/src/core/tools/file-change.ts
import { createHash, randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createTwoFilesPatch, parsePatch } from "diff";
import { readRegularFile } from "./pi/utils/read-file.ts";
export interface FileChange {
path: string;
operation: "created" | "modified";
toolCallId: string;
beforeHash: string | null;
afterHash: string;
patch?: string;
patchFile?: string;
beforeFile?: string;
afterFile?: string;
reason?: string;
failed?: boolean;
additions?: number;
deletions?: number;
}
export async function beforeFile(path: string): Promise<Buffer | null> {
try {
return await readRegularFile(path);
} catch (e) {
if ((e as NodeJS.ErrnoException).code === "ENOENT") return null;
throw e;
}
}
const hash = (b: Buffer) => createHash("sha256").update(b).digest("hex");
export async function recordFileChange(
path: string,
toolCallId: string,
before: Buffer | null,
outputDir?: string,
failed = false,
): Promise<FileChange | undefined> {
const after = await readRegularFile(path);
if (before?.equals(after)) return undefined;
const change: FileChange = {
path,
toolCallId,
operation: before === null ? "created" : "modified",
beforeHash: before === null ? null : hash(before),
afterHash: hash(after),
...(failed ? { failed } : {}),
};
try {
if ((before?.length ?? 0) + after.length > 4 * 1024 * 1024)
return { ...change, reason: "二进制或文件超限,无法内联比较" };
// Preserve bounded byte snapshots even when an intermediate edit is not text.
// A later repair still needs its final snapshot for the whole turn's net diff.
if (outputDir) {
await mkdir(outputDir, { recursive: true });
const key = randomUUID();
if (before !== null) {
change.beforeFile = join(outputDir, `${key}.before`);
await writeFile(change.beforeFile, before, { mode: 0o600 });
}
change.afterFile = join(outputDir, `${key}.after`);
await writeFile(change.afterFile, after, { mode: 0o600 });
}
if (before?.includes(0) || after.includes(0))
return { ...change, reason: "二进制或文件超限,无法内联比较" };
const decode = new TextDecoder("utf8", { fatal: true });
const beforeText = before === null ? "" : decode.decode(before),
afterText = decode.decode(after);
const patch = createTwoFilesPatch(
before === null ? "/dev/null" : path,
path,
beforeText,
afterText,
"",
"",
{ context: 3, timeout: 50 },
);
if (patch === undefined) return { ...change, reason: "文件已修改,diff 计算超时,无法内联比较" };
Object.assign(change, patchLineCounts(patch));
if (Buffer.byteLength(patch) <= 64 * 1024) change.patch = patch;
else if (outputDir) {
await mkdir(outputDir, { recursive: true });
change.patchFile = join(outputDir, `${randomUUID()}.patch`);
await writeFile(change.patchFile, patch, { mode: 0o600 });
} else change.reason = "Patch 超限;未配置记录目录";
} catch (e) {
change.reason = `文件已修改,但记录不可用: ${String(e)}`;
}
return change;
}
export function patchLineCounts(patch: string): { additions: number; deletions: number } {
let additions = 0,
deletions = 0;
for (const file of parsePatch(patch))
for (const hunk of file.hunks)
for (const line of hunk.lines) {
if (line.startsWith("+")) additions++;
if (line.startsWith("-")) deletions++;
}
return { additions, deletions };
}