Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions scripts/check
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
2 changes: 1 addition & 1 deletion spec/state.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"schema_version": 1,
"current_phase": 12,
"current_phase": 13,
"total_phases": 18
}
42 changes: 42 additions & 0 deletions src/core/apply/patch.ts
Original file line number Diff line number Diff line change
@@ -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"),
};
}
20 changes: 20 additions & 0 deletions src/core/blame/blame.ts
Original file line number Diff line number Diff line change
@@ -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);
}
20 changes: 20 additions & 0 deletions src/core/diff/unified.ts
Original file line number Diff line number Diff line change
@@ -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`;
}
28 changes: 28 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
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 {
type ConfigScope,
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,
Expand Down Expand Up @@ -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<string> {
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,
Expand Down
87 changes: 87 additions & 0 deletions tests/node/diff-apply-blame.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
);
});