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
4.9 KiB
TypeScript
194 lines
4.9 KiB
TypeScript
/**
|
|
* Git utilities for code review
|
|
*
|
|
* Centralized git operations for diff collection and branch detection.
|
|
* Used by both Claude Code hook and OpenCode plugin.
|
|
*/
|
|
|
|
import { lstat, readlink } from "node:fs/promises";
|
|
import { resolve as resolvePath } from "node:path";
|
|
|
|
import {
|
|
type DiffOption,
|
|
type DiffResult,
|
|
type DiffType,
|
|
type GitCommandResult,
|
|
type GitCommandOptions,
|
|
type GitContext,
|
|
type GitDiffOptions,
|
|
type ReviewGitRuntime,
|
|
type WorktreeInfo,
|
|
getCurrentBranch as getCurrentBranchCore,
|
|
getDefaultBranch as getDefaultBranchCore,
|
|
getWorktrees as getWorktreesCore,
|
|
getGitContext as getGitContextCore,
|
|
getFileContentsForDiff as getFileContentsForDiffCore,
|
|
gitAddFile as gitAddFileCore,
|
|
gitResetFile as gitResetFileCore,
|
|
parseWorktreeDiffType,
|
|
prepareGitCommand,
|
|
runGitDiff as runGitDiffCore,
|
|
runGitDiffWithContext as runGitDiffWithContextCore,
|
|
validateFilePath,
|
|
} from "@plannotator/shared/review-core";
|
|
|
|
export type {
|
|
DiffOption,
|
|
DiffType,
|
|
DiffResult,
|
|
GitContext,
|
|
GitDiffOptions,
|
|
WorktreeInfo,
|
|
} from "@plannotator/shared/review-core";
|
|
|
|
async function runGit(
|
|
args: string[],
|
|
options?: GitCommandOptions,
|
|
): Promise<GitCommandResult> {
|
|
const command = prepareGitCommand(args, options, process.env);
|
|
const proc = Bun.spawn(["git", ...command.args], {
|
|
cwd: options?.cwd,
|
|
detached: command.isolateProcessGroup,
|
|
env: command.env,
|
|
stdin: options?.stdin === undefined
|
|
? "ignore"
|
|
: new TextEncoder().encode(options.stdin),
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
windowsHide: true,
|
|
});
|
|
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
if (options?.timeoutMs) {
|
|
timer = setTimeout(() => {
|
|
if (command.isolateProcessGroup && process.platform !== "win32") {
|
|
try {
|
|
process.kill(-proc.pid, "SIGKILL");
|
|
return;
|
|
} catch {
|
|
// Fall through when the process exited between the timer and signal.
|
|
}
|
|
}
|
|
if (command.isolateProcessGroup && process.platform === "win32") {
|
|
const killed = Bun.spawnSync(
|
|
["taskkill.exe", "/pid", String(proc.pid), "/t", "/f"],
|
|
{ stdin: "ignore", stdout: "ignore", stderr: "ignore", windowsHide: true },
|
|
);
|
|
if (killed.exitCode === 0) return;
|
|
}
|
|
proc.kill("SIGKILL");
|
|
}, options.timeoutMs);
|
|
}
|
|
|
|
const [stdout, stderr, exitCode] = await Promise.all([
|
|
new Response(proc.stdout).text(),
|
|
new Response(proc.stderr).text(),
|
|
proc.exited,
|
|
]);
|
|
|
|
if (timer) clearTimeout(timer);
|
|
|
|
return { stdout, stderr, exitCode };
|
|
}
|
|
|
|
/** Bun-based git runtime. Exported for use with shared utilities (worktree, etc.) */
|
|
export const runtime: ReviewGitRuntime = {
|
|
runGit,
|
|
async readTextFile(path: string): Promise<string | null> {
|
|
try {
|
|
return await Bun.file(path).text();
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
async getFileInfo(basePath, path) {
|
|
const fullPath = resolvePath(basePath ?? "", path);
|
|
try {
|
|
const fileStat = await lstat(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: string): Promise<string | null> {
|
|
try {
|
|
return await readlink(path);
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
};
|
|
|
|
export function getCurrentBranch(): Promise<string> {
|
|
return getCurrentBranchCore(runtime);
|
|
}
|
|
|
|
export function getDefaultBranch(): Promise<string> {
|
|
return getDefaultBranchCore(runtime);
|
|
}
|
|
|
|
export function getWorktrees(): Promise<WorktreeInfo[]> {
|
|
return getWorktreesCore(runtime);
|
|
}
|
|
|
|
export function getGitContext(cwd?: string): Promise<GitContext> {
|
|
return getGitContextCore(runtime, cwd);
|
|
}
|
|
|
|
export function runGitDiff(
|
|
diffType: DiffType,
|
|
defaultBranch: string = "main",
|
|
cwd?: string,
|
|
options?: GitDiffOptions,
|
|
): Promise<DiffResult> {
|
|
return runGitDiffCore(runtime, diffType, defaultBranch, cwd, options);
|
|
}
|
|
|
|
export function runGitDiffWithContext(
|
|
diffType: DiffType,
|
|
gitContext: GitContext,
|
|
options?: GitDiffOptions,
|
|
): Promise<DiffResult> {
|
|
return runGitDiffWithContextCore(runtime, diffType, gitContext, options);
|
|
}
|
|
|
|
export function getFileContentsForDiff(
|
|
diffType: DiffType,
|
|
defaultBranch: string,
|
|
filePath: string,
|
|
oldPath?: string,
|
|
cwd?: string,
|
|
): Promise<{ oldContent: string | null; newContent: string | null }> {
|
|
return getFileContentsForDiffCore(
|
|
runtime,
|
|
diffType,
|
|
defaultBranch,
|
|
filePath,
|
|
oldPath,
|
|
cwd,
|
|
);
|
|
}
|
|
|
|
export function gitAddFile(
|
|
filePath: string,
|
|
cwd?: string,
|
|
): Promise<void> {
|
|
return gitAddFileCore(runtime, filePath, cwd);
|
|
}
|
|
|
|
export function gitResetFile(
|
|
filePath: string,
|
|
cwd?: string,
|
|
): Promise<void> {
|
|
return gitResetFileCore(runtime, filePath, cwd);
|
|
}
|
|
|
|
export { parseWorktreeDiffType, validateFilePath };
|