mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
bc5470b90d
* fix(review): bound server memory for large tracked-file diffs PR #1118 renders large untracked files as binary additions, but staging one moves it into the tracked `git diff` path, which had no size guard and buffered the full multi-megabyte patch (~240 MB RSS on a 51 MB text artifact). Any large tracked text file modified in the working tree hits the same unguarded path. Add a per-invocation `git -c core.bigFileThreshold=<MAX_REVIEW_FILE_CONTENT_BYTES>` prefix to every content-producing git diff, so git renders oversized blobs as "Binary files ... differ" instead of a text patch. Their bytes never enter git's diff machinery or the server's buffered stdout, mirroring the untracked-file guard. The flag is a no-op at or below the threshold, so smaller files are byte-for-byte unaffected, and the blob hash git emits in the binary diff still changes with content, so staleness detection holds. The guard is applied in the shared cores, so the Bun and Pi runtimes inherit it identically: `review-core.ts` covers the ordinary git provider (working-tree, staged, commit, and the freshness fingerprint) and `gitbutler-core.ts` covers the GitButler object diff. The jj provider runs `jj diff`, which has no `core.bigFileThreshold` equivalent, so it is out of scope here and stays unbounded as before. * fix(review): preflight oversized tracked diffs * fix(review): batch tracked diff preflight * fix(review): restore browser-safe diff core * fix(review): preserve gitlinks and textconv * fix(review): require filesystem runtime seam Fail compilation when a runtime omits file metadata or symlink support instead of silently disabling bounded reads and expansion.
194 lines
7.7 KiB
TypeScript
194 lines
7.7 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
import { lstatSync, mkdtempSync, readlinkSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve as resolvePath } from "node:path";
|
|
import {
|
|
getGitDiffFingerprint,
|
|
MAX_REVIEW_FILE_CONTENT_BYTES,
|
|
type ReviewGitRuntime,
|
|
} from "./review-core";
|
|
|
|
// Real-git runtime against a throwaway repo — fingerprints are only meaningful
|
|
// against actual VCS behavior, so no mocks.
|
|
const runtime: ReviewGitRuntime = {
|
|
async runGit(args, options) {
|
|
const proc = Bun.spawn(["git", ...args], {
|
|
cwd: options?.cwd,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
stdin: options?.stdin === undefined
|
|
? "ignore"
|
|
: new TextEncoder().encode(options.stdin),
|
|
});
|
|
const [stdout, stderr] = await Promise.all([
|
|
new Response(proc.stdout).text(),
|
|
new Response(proc.stderr).text(),
|
|
]);
|
|
const exitCode = await proc.exited;
|
|
return { exitCode, stdout, stderr };
|
|
},
|
|
async readTextFile(path) {
|
|
try {
|
|
return await Bun.file(path).text();
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
async getFileInfo(basePath, path) {
|
|
const fullPath = resolvePath(basePath ?? "", path);
|
|
try {
|
|
const fileStat = lstatSync(fullPath);
|
|
return {
|
|
path: fullPath,
|
|
size: fileStat.size,
|
|
mtimeMs: fileStat.mtimeMs,
|
|
isFile: fileStat.isFile(),
|
|
isSymbolicLink: fileStat.isSymbolicLink(),
|
|
isExecutable: (fileStat.mode & 0o111) !== 0,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
async readLink(path) {
|
|
try {
|
|
return readlinkSync(path);
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
};
|
|
|
|
let repo: string;
|
|
|
|
async function git(...args: string[]): Promise<void> {
|
|
const result = await runtime.runGit(args, { cwd: repo });
|
|
if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${result.stderr}`);
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
repo = mkdtempSync(join(tmpdir(), "pn-fingerprint-"));
|
|
await git("init", "-b", "main");
|
|
await git("config", "user.email", "test@test");
|
|
await git("config", "user.name", "test");
|
|
writeFileSync(join(repo, "a.txt"), "one\ntwo\nthree\n");
|
|
await git("add", "-A");
|
|
await git("commit", "-m", "init");
|
|
});
|
|
|
|
afterAll(() => {
|
|
rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("getGitDiffFingerprint", () => {
|
|
test("uncommitted: stable when nothing changes", async () => {
|
|
const a = await getGitDiffFingerprint(runtime, "uncommitted", "main", repo);
|
|
const b = await getGitDiffFingerprint(runtime, "uncommitted", "main", repo);
|
|
expect(a).not.toBeNull();
|
|
expect(a).toBe(b!);
|
|
});
|
|
|
|
test("uncommitted: changes when a tracked file is edited — and again on a second edit", async () => {
|
|
const before = await getGitDiffFingerprint(runtime, "uncommitted", "main", repo);
|
|
writeFileSync(join(repo, "a.txt"), "one\nTWO\nthree\n");
|
|
const afterFirstEdit = await getGitDiffFingerprint(runtime, "uncommitted", "main", repo);
|
|
expect(afterFirstEdit).not.toBe(before!);
|
|
// The critical case `git status` alone cannot see: an ALREADY-modified
|
|
// file modified again.
|
|
writeFileSync(join(repo, "a.txt"), "one\nTWO!\nthree\n");
|
|
const afterSecondEdit = await getGitDiffFingerprint(runtime, "uncommitted", "main", repo);
|
|
expect(afterSecondEdit).not.toBe(afterFirstEdit!);
|
|
});
|
|
|
|
test("uncommitted: changes when an untracked file appears and when its CONTENT changes", async () => {
|
|
const before = await getGitDiffFingerprint(runtime, "uncommitted", "main", repo);
|
|
writeFileSync(join(repo, "new.txt"), "hello\n");
|
|
const created = await getGitDiffFingerprint(runtime, "uncommitted", "main", repo);
|
|
expect(created).not.toBe(before!);
|
|
writeFileSync(join(repo, "new.txt"), "hello world\n");
|
|
const edited = await getGitDiffFingerprint(runtime, "uncommitted", "main", repo);
|
|
expect(edited).not.toBe(created!);
|
|
});
|
|
|
|
test("large untracked files use metadata without entering the JS heap", async () => {
|
|
const path = join(repo, "large-untracked.bin");
|
|
writeFileSync(path, Buffer.alloc(MAX_REVIEW_FILE_CONTENT_BYTES + 1));
|
|
let largeFileReads = 0;
|
|
const guardedRuntime: ReviewGitRuntime = {
|
|
...runtime,
|
|
async readTextFile(requestedPath) {
|
|
if (requestedPath === path) largeFileReads++;
|
|
return runtime.readTextFile(requestedPath);
|
|
},
|
|
};
|
|
|
|
const before = await getGitDiffFingerprint(guardedRuntime, "uncommitted", "main", repo);
|
|
writeFileSync(path, Buffer.alloc(MAX_REVIEW_FILE_CONTENT_BYTES + 2, 1));
|
|
const after = await getGitDiffFingerprint(guardedRuntime, "uncommitted", "main", repo);
|
|
|
|
expect(largeFileReads).toBe(0);
|
|
expect(after).not.toBe(before);
|
|
});
|
|
|
|
test("uncommitted: changes when a commit lands (HEAD moves)", async () => {
|
|
const before = await getGitDiffFingerprint(runtime, "uncommitted", "main", repo);
|
|
await git("add", "-A");
|
|
await git("commit", "-m", "snapshot");
|
|
const after = await getGitDiffFingerprint(runtime, "uncommitted", "main", repo);
|
|
expect(after).not.toBe(before!);
|
|
});
|
|
|
|
test("since-base: tracks visible working-tree and untracked content", async () => {
|
|
const before = await getGitDiffFingerprint(runtime, "since-base", "main", repo);
|
|
writeFileSync(join(repo, "a.txt"), "since-base edit\n");
|
|
const trackedEdit = await getGitDiffFingerprint(runtime, "since-base", "main", repo);
|
|
expect(trackedEdit).not.toBe(before!);
|
|
writeFileSync(join(repo, "since-base-new.txt"), "new\n");
|
|
const untrackedCreated = await getGitDiffFingerprint(runtime, "since-base", "main", repo);
|
|
expect(untrackedCreated).not.toBe(trackedEdit!);
|
|
writeFileSync(join(repo, "since-base-new.txt"), "newer\n");
|
|
const untrackedEdited = await getGitDiffFingerprint(runtime, "since-base", "main", repo);
|
|
expect(untrackedEdited).not.toBe(untrackedCreated!);
|
|
});
|
|
|
|
test("last-commit: stable across working-tree edits, changes on commit", async () => {
|
|
const before = await getGitDiffFingerprint(runtime, "last-commit", "main", repo);
|
|
writeFileSync(join(repo, "a.txt"), "working tree noise\n");
|
|
const duringEdit = await getGitDiffFingerprint(runtime, "last-commit", "main", repo);
|
|
expect(duringEdit).toBe(before!);
|
|
await git("add", "-A");
|
|
await git("commit", "-m", "another");
|
|
const afterCommit = await getGitDiffFingerprint(runtime, "last-commit", "main", repo);
|
|
expect(afterCommit).not.toBe(before!);
|
|
});
|
|
|
|
test("merge-base: changes when the branch tip moves", async () => {
|
|
await git("checkout", "-b", "feature");
|
|
writeFileSync(join(repo, "b.txt"), "feature\n");
|
|
await git("add", "-A");
|
|
await git("commit", "-m", "feature work");
|
|
const before = await getGitDiffFingerprint(runtime, "merge-base", "main", repo);
|
|
expect(before).not.toBeNull();
|
|
writeFileSync(join(repo, "b.txt"), "feature 2\n");
|
|
await git("add", "-A");
|
|
await git("commit", "-m", "more feature work");
|
|
const after = await getGitDiffFingerprint(runtime, "merge-base", "main", repo);
|
|
expect(after).not.toBe(before!);
|
|
});
|
|
|
|
test("staged: changes when the index changes, not on unstaged edits", async () => {
|
|
const before = await getGitDiffFingerprint(runtime, "staged", "main", repo);
|
|
writeFileSync(join(repo, "a.txt"), "unstaged edit only\n");
|
|
const unstagedOnly = await getGitDiffFingerprint(runtime, "staged", "main", repo);
|
|
expect(unstagedOnly).toBe(before!);
|
|
await git("add", "a.txt");
|
|
const staged = await getGitDiffFingerprint(runtime, "staged", "main", repo);
|
|
expect(staged).not.toBe(before!);
|
|
});
|
|
|
|
test("unknown diff type returns null (treated as always-fresh)", async () => {
|
|
const result = await getGitDiffFingerprint(runtime, "p4-default" as never, "main", repo);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|