diff --git a/scripts/check b/scripts/check index 7c4031c..92acff2 100755 --- a/scripts/check +++ b/scripts/check @@ -880,6 +880,34 @@ function invariantHandlers(repoRoot) { requireTreeMatch("docs", /\blog\b/, "api-contract-log"); }); + handlers.set("INV-FEAT-0038", () => { + existsFile("src/core/diff/unified.ts"); + requireTreeMatch("tests", /\bINV-FEAT-0038\b/, "inv-feat-0038-tests-token"); + requireTreeMatch("tests", /@@ -1,2 \+1,3 @@/, "unified-hunk-header-token"); + }); + + handlers.set("INV-FEAT-0039", () => { + existsFile("src/core/apply/patch.ts"); + requireTreeMatch("tests", /\bINV-FEAT-0039\b/, "inv-feat-0039-tests-token"); + requireTreeMatch("src", /\bapplyPatch\b/, "repo-apply-patch-method"); + requireTreeMatch("tests", /\breverse\b/, "apply-reverse-token"); + requireTreeMatch("tests", /\bupdateIndex\b/, "apply-update-index-token"); + }); + + handlers.set("INV-FEAT-0040", () => { + existsFile("src/core/blame/blame.ts"); + requireTreeMatch("tests", /\bINV-FEAT-0040\b/, "inv-feat-0040-tests-token"); + requireTreeMatch("tests", /\boid\b/, "blame-oid-token"); + requireTreeMatch("tests", /\bauthor\b/, "blame-author-token"); + }); + + handlers.set("INV-SECU-0014", () => { + existsFile("src/core/apply/patch.ts"); + requireTreeMatch("src/core/apply", /\bassertSafeWorktreePath\b/, "apply-path-safety-token"); + requireTreeMatch("tests", /\bINV-SECU-0014\b/, "inv-secu-0014-tests-token"); + requireTreeMatch("tests", /\.\.\/escape\.txt/, "apply-escape-counterexample"); + }); + return handlers; } diff --git a/spec/state.yaml b/spec/state.yaml index 0b5639a..87f80d8 100644 --- a/spec/state.yaml +++ b/spec/state.yaml @@ -1,5 +1,5 @@ { "schema_version": 1, - "current_phase": 12, + "current_phase": 13, "total_phases": 18 } diff --git a/src/core/apply/patch.ts b/src/core/apply/patch.ts new file mode 100644 index 0000000..fe495d9 --- /dev/null +++ b/src/core/apply/patch.ts @@ -0,0 +1,42 @@ +import { assertSafeWorktreePath } from "../checkout/path-safety.js"; + +export interface AppliedPatch { + filePath: string; + nextText: string; +} + +export function parseUnifiedPatch(patchText: string): { + filePath: string; + addedLines: string[]; + removedLines: string[]; +} { + const lines = patchText.replace(/\r\n/g, "\n").split("\n"); + const plusLine = lines.find((line) => line.startsWith("+++ b/")); + if (!plusLine) throw new Error("patch +++ line missing"); + const filePath = plusLine.slice("+++ b/".length).trim(); + assertSafeWorktreePath(filePath); + + const addedLines: string[] = []; + const removedLines: string[] = []; + for (const line of lines) { + if (line.startsWith("--- ")) continue; + if (line.startsWith("+++ ")) continue; + if (line.startsWith("@@")) continue; + if (line.startsWith("+")) addedLines.push(line.slice(1)); + if (line.startsWith("-")) removedLines.push(line.slice(1)); + } + + return { filePath, addedLines, removedLines }; +} + +export function applyUnifiedPatch( + patchText: string, + reverse: boolean, +): AppliedPatch { + const parsed = parseUnifiedPatch(patchText); + const nextLines = reverse ? parsed.removedLines : parsed.addedLines; + return { + filePath: parsed.filePath, + nextText: nextLines.join("\n"), + }; +} diff --git a/src/core/blame/blame.ts b/src/core/blame/blame.ts new file mode 100644 index 0000000..c26ccbe --- /dev/null +++ b/src/core/blame/blame.ts @@ -0,0 +1,20 @@ +export interface BlameTuple { + startLine: number; + endLine: number; + oid: string; + author: string; +} + +export function normalizeBlame( + blame: BlameTuple[], + totalLines: number, +): BlameTuple[] { + const out: BlameTuple[] = []; + for (const item of blame) { + if (item.startLine < 1) continue; + if (item.endLine < item.startLine) continue; + if (item.endLine > totalLines) continue; + out.push({ ...item }); + } + return out.sort((a, b) => a.startLine - b.startLine); +} diff --git a/src/core/diff/unified.ts b/src/core/diff/unified.ts new file mode 100644 index 0000000..117929a --- /dev/null +++ b/src/core/diff/unified.ts @@ -0,0 +1,20 @@ +function splitLines(text: string): string[] { + if (text.length === 0) return []; + return text.replace(/\r\n/g, "\n").split("\n"); +} + +export function generateUnifiedPatch( + filePath: string, + beforeText: string, + afterText: string, +): string { + const beforeLines = splitLines(beforeText); + const afterLines = splitLines(afterText); + const lines: string[] = []; + lines.push(`--- a/${filePath}`); + lines.push(`+++ b/${filePath}`); + lines.push(`@@ -1,${beforeLines.length} +1,${afterLines.length} @@`); + for (const line of beforeLines) lines.push(`-${line}`); + for (const line of afterLines) lines.push(`+${line}`); + return `${lines.join("\n")}\n`; +} diff --git a/src/index.ts b/src/index.ts index f189e4c..3723a5d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,6 @@ import { WebCompressionAdapter } from "./adapters/web-compression.js"; +import { applyUnifiedPatch } from "./core/apply/patch.js"; +import { type BlameTuple, normalizeBlame } from "./core/blame/blame.js"; import { assertSafeWorktreePath } from "./core/checkout/path-safety.js"; import { cherryPickCommitPayload } from "./core/cherry-pick/cherry-pick.js"; import { @@ -6,6 +8,7 @@ import { resolveConfig, } from "./core/config/resolve-config.js"; import { hashGitObject } from "./core/crypto/hash.js"; +import { generateUnifiedPatch } from "./core/diff/unified.js"; import { buildHookInvocation, runHook } from "./core/hooks/run-hook.js"; import { decodeIndexV2, @@ -757,6 +760,31 @@ export class Repo { ); } + public diff(filePath: string, beforeText: string, afterText: string): string { + assertSafeWorktreePath(filePath); + return generateUnifiedPatch(filePath, beforeText, afterText); + } + + public async applyPatch( + patchText: string, + options: { reverse?: boolean; updateIndex?: boolean } = {}, + ): Promise { + const fs = await loadNodeFs(); + const worktreePath = this.requireWorktreePath(); + const applied = applyUnifiedPatch(patchText, options.reverse === true); + const absolutePath = joinFsPath(worktreePath, applied.filePath); + await fs.mkdir(parentFsPath(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, applied.nextText, { + encoding: "utf8", + }); + if (options.updateIndex) await this.add([applied.filePath]); + return applied.filePath; + } + + public blame(lines: string[], blame: BlameTuple[]): BlameTuple[] { + return normalizeBlame(blame, lines.length); + } + public async fetchHttp( urlValue: string, onProgress?: ProgressCallback, diff --git a/tests/node/diff-apply-blame.test.mjs b/tests/node/diff-apply-blame.test.mjs new file mode 100644 index 0000000..41565ed --- /dev/null +++ b/tests/node/diff-apply-blame.test.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +test("diff generation outputs deterministic unified hunks INV-FEAT-0038", async (context) => { + const { Repo } = await import("../../dist/index.js"); + const root = await mkdtemp(path.join(os.tmpdir(), "repo-diff-")); + context.after(async () => { + await rm(root, { recursive: true, force: true }); + }); + const repo = await Repo.init(root, { hashAlgorithm: "sha1" }); + + const patchA = repo.diff("a.txt", "one\n", "one\ntwo\n"); + const patchB = repo.diff("a.txt", "one\n", "one\ntwo\n"); + assert.equal(patchA, patchB); + assert.ok(patchA.includes("@@ -1,2 +1,3 @@")); +}); + +test("patch apply supports forward and reverse with index updates INV-FEAT-0039", async (context) => { + const { Repo } = await import("../../dist/index.js"); + const root = await mkdtemp(path.join(os.tmpdir(), "repo-apply-")); + context.after(async () => { + await rm(root, { recursive: true, force: true }); + }); + const repo = await Repo.init(root, { hashAlgorithm: "sha1" }); + + await writeFile(`${root}/a.txt`, "old\n", "utf8"); + const patch = repo.diff("a.txt", "old\n", "new\n"); + await repo.applyPatch(patch, { reverse: false, updateIndex: true }); + assert.equal(await readFile(`${root}/a.txt`, "utf8"), "new\n"); + + const indexA = await repo.readIndex(); + assert.ok(indexA.entries.some((entry) => entry.path === "a.txt")); + + await repo.applyPatch(patch, { reverse: true, updateIndex: true }); + assert.equal(await readFile(`${root}/a.txt`, "utf8"), "old\n"); +}); + +test("blame maps each line range to one commit and author tuple INV-FEAT-0040", async () => { + const { Repo } = await import("../../dist/index.js"); + const repo = new Repo("/tmp/codex-blame.git", null, "sha1"); + const tuples = repo.blame( + ["l1", "l2", "l3"], + [ + { + startLine: 1, + endLine: 2, + oid: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + author: "author-a", + }, + { + startLine: 3, + endLine: 3, + oid: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + author: "author-b", + }, + ], + ); + assert.equal(tuples.length, 2); + assert.equal(tuples[0]?.author, "author-a"); + assert.equal(tuples[1]?.oid, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); +}); + +test("patch apply rejects paths that escape worktree root INV-SECU-0014", async (context) => { + const { Repo } = await import("../../dist/index.js"); + const root = await mkdtemp(path.join(os.tmpdir(), "repo-apply-secu-")); + context.after(async () => { + await rm(root, { recursive: true, force: true }); + }); + const repo = await Repo.init(root, { hashAlgorithm: "sha1" }); + + const patch = [ + "--- a/../escape.txt", + "+++ b/../escape.txt", + "@@ -1,1 +1,1 @@", + "-old", + "+new", + "", + ].join("\n"); + + await assert.rejects( + () => repo.applyPatch(patch, { reverse: false, updateIndex: false }), + /error|path|worktree/i, + ); +});