mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
15f8d4fe4c
* feat(review): collapse linguist-generated files by default (#1317) Code review now respects linguist-generated (and linguist-generated=true) from .gitattributes, collapsing those diffs by default the way GitHub does. Server (Bun + Pi mirror): a generatedFiles sidecar rides /api/diff and /api/diff/switch, resolved through git's own attribute machinery — one batched 'git check-attr --stdin -z' over the served patch's paths at the review cwd, so stacked and negated rules land exactly as git resolves them. Plain local git sessions only; PR worktrees, workspace multi-repo, jj, GitButler, and P4 omit the sidecar (degrade to no-collapse). Shared logic in packages/shared/generated-files.ts, vendored to Pi. Client: generated files SEED their CodeView item collapsed (the existing Pierre collapse state — same mechanism as commit-diff folding), render the one-line FileHeader bar with a 'generated' tag next to the +/- counts, and expand per file on click. Expansion is session-local App state so it survives remounts and diff switches. Presentation-only: the diff data, annotations, search, and Edit Mode are untouched; the file tree and single-file tabs list generated files normally (tag, no auto-collapse). Guide viewer manifest pin regenerated (AllFilesCodeView/FileHeader are bundled into the guides.show viewer) from a clean frozen-lockfile install. * feat(review): built-in generated defaults, visible collapsed strip, review-round fixes (#1317) Round 2 on PR #1346, per maintainer review. Built-in generated defaults (industry-standard two-layer detection): packages/shared/generated-files.ts (vendored to Pi) now carries DEFAULT_GENERATED_PATTERNS — lockfiles (package-lock.json, yarn.lock, bun.lock, Cargo.lock, go.sum, ...) plus *.min.js / *.min.css / *.map — matched against the path's last segment only. Explicit .gitattributes wins in BOTH directions: linguist-generated (set/true) marks any file, -linguist-generated / =false un-marks even a built-in name, unspecified falls through to the defaults. In plain local git sessions check-attr refines the defaults; the non-git degrade modes (piped patches, PR worktrees, workspace, jj, GitButler, P4) now emit the sidecar from the name-based defaults alone instead of omitting it. Visible collapsed state: a collapsed generated card no longer renders as a bare header — a GeneratedFileNotice strip ('Generated file collapsed', +N/-N, 'Click to view') styled like the other below-header notices sits in the card, and clicking it expands through the SAME reportFileCollapsed funnel as the chevron. Review findings: - F1: search-match and sidebar-comment navigation expanded items without reporting through the funnel, so those expansions died on diff switch. Both now call syncAllCollapsedMirror + reportFileCollapsed; the funnel invariant comment lists the navigation-driven sites. - F2: the check-attr call gets the same 5000ms timeout as review-core's stdin git callers, and Pi's vcs.ts stdin write gets the one-line EPIPE guard (call-flow.ts shape) a timeout kill makes reachable. - F3: removed the dead prevGeneratedRef + collectSetDelta leg — a changed generated set always remounts via fileSetKey, so the delta path was unreachable. Tests: default-list matching (glob + directory-named-bun.lock), both- direction precedence, non-git sidecar from defaults (dual-runtime), the placeholder strip through the funnel, and search expansion surviving a re-seed round-trip. AGENTS.md payload docs updated. Guide viewer manifest pin regenerated from this clean frozen-lockfile worktree.
228 lines
5.8 KiB
TypeScript
228 lines
5.8 KiB
TypeScript
import { spawn, spawnSync } from "node:child_process";
|
|
import { lstatSync, readFileSync, readlinkSync } from "node:fs";
|
|
import { resolve as resolvePath } from "node:path";
|
|
import {
|
|
type DiffResult,
|
|
type DiffType,
|
|
type GitCommandResult,
|
|
type GitCommandOptions,
|
|
type GitContext,
|
|
type GitDiffOptions,
|
|
type PreparedGitCommand,
|
|
type ReviewGitRuntime,
|
|
getGitContext as getGitContextCore,
|
|
prepareGitCommand,
|
|
runGitDiff as runGitDiffCore,
|
|
} from "../generated/review-core.ts";
|
|
import {
|
|
type ReviewJjRuntime,
|
|
} from "../generated/jj-core.ts";
|
|
import {
|
|
type ReviewGitButlerRuntime,
|
|
} from "../generated/gitbutler-core.ts";
|
|
import {
|
|
type VcsSelection,
|
|
createGitButlerProvider,
|
|
createGitProvider,
|
|
createJjProvider,
|
|
createVcsApi,
|
|
resolveInitialDiffType,
|
|
} from "../generated/vcs-core.ts";
|
|
|
|
function runCommand(
|
|
command: string,
|
|
args: string[],
|
|
notFoundMessage: string,
|
|
options?: GitCommandOptions,
|
|
preparedGitCommand?: PreparedGitCommand,
|
|
commandEnvironment?: NodeJS.ProcessEnv,
|
|
isolateProcessGroup = preparedGitCommand?.isolateProcessGroup ?? false,
|
|
): Promise<GitCommandResult> {
|
|
return new Promise((resolve) => {
|
|
const proc = spawn(command, args, {
|
|
cwd: options?.cwd,
|
|
detached: isolateProcessGroup,
|
|
env: preparedGitCommand?.env ?? commandEnvironment,
|
|
stdio: [options?.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
windowsHide: true,
|
|
});
|
|
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
if (options?.timeoutMs) {
|
|
timer = setTimeout(() => {
|
|
if (isolateProcessGroup && proc.pid && process.platform !== "win32") {
|
|
try {
|
|
process.kill(-proc.pid, "SIGKILL");
|
|
return;
|
|
} catch {
|
|
// Fall through when the process exited between the timer and signal.
|
|
}
|
|
}
|
|
if (isolateProcessGroup && proc.pid && process.platform === "win32") {
|
|
const killed = spawnSync(
|
|
"taskkill.exe",
|
|
["/pid", String(proc.pid), "/t", "/f"],
|
|
{ stdio: "ignore", windowsHide: true },
|
|
);
|
|
if (killed.status === 0) return;
|
|
}
|
|
proc.kill("SIGKILL");
|
|
}, options.timeoutMs);
|
|
}
|
|
|
|
const stdoutChunks: Buffer[] = [];
|
|
const stderrChunks: Buffer[] = [];
|
|
// Stop buffering AND kill at `maxOutputBytes`, so a command that can emit
|
|
// an entire repository tree bounds real memory growth instead of being
|
|
// measured and rejected after it is already held in full.
|
|
let stdoutBytes = 0;
|
|
let truncated = false;
|
|
proc.stdout!.on("data", (chunk: Buffer) => {
|
|
if (truncated) return;
|
|
stdoutBytes += chunk.byteLength;
|
|
if (
|
|
options?.maxOutputBytes !== undefined &&
|
|
stdoutBytes > options.maxOutputBytes
|
|
) {
|
|
truncated = true;
|
|
proc.kill("SIGKILL");
|
|
return;
|
|
}
|
|
stdoutChunks.push(chunk);
|
|
});
|
|
proc.stderr!.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
|
|
if (options?.stdin !== undefined) {
|
|
// A timeout-killed process can reject the stdin write while close is
|
|
// already being handled. Do not let that secondary EPIPE escape.
|
|
proc.stdin!.on("error", () => {});
|
|
proc.stdin!.end(options.stdin);
|
|
}
|
|
|
|
proc.on("close", (code) => {
|
|
if (timer) clearTimeout(timer);
|
|
resolve({
|
|
stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
|
|
stderr: Buffer.concat(stderrChunks).toString("utf-8"),
|
|
exitCode: code ?? 1,
|
|
...(truncated ? { truncated: true } : {}),
|
|
});
|
|
});
|
|
|
|
proc.on("error", () => {
|
|
if (timer) clearTimeout(timer);
|
|
resolve({ stdout: "", stderr: notFoundMessage, exitCode: 1 });
|
|
});
|
|
});
|
|
}
|
|
|
|
export const reviewRuntime: ReviewGitRuntime = {
|
|
runGit(
|
|
args: string[],
|
|
options?: GitCommandOptions,
|
|
): Promise<GitCommandResult> {
|
|
const command = prepareGitCommand(args, options, process.env);
|
|
return runCommand("git", command.args, "git not found", options, command);
|
|
},
|
|
|
|
async readTextFile(path: string): Promise<string | null> {
|
|
try {
|
|
return readFileSync(path, "utf-8");
|
|
} 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: string): Promise<string | null> {
|
|
try {
|
|
return readlinkSync(path);
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
};
|
|
|
|
export const jjRuntime: ReviewJjRuntime = {
|
|
runJj(
|
|
args: string[],
|
|
options?: { cwd?: string; timeoutMs?: number },
|
|
): Promise<GitCommandResult> {
|
|
return runCommand("jj", args, "jj not found", options);
|
|
},
|
|
};
|
|
|
|
/** Node Git + GitButler runtime used by the Pi review server. */
|
|
export const gitButlerRuntime: ReviewGitButlerRuntime = {
|
|
...reviewRuntime,
|
|
runBut(
|
|
args: string[],
|
|
options?: GitCommandOptions,
|
|
): Promise<GitCommandResult> {
|
|
return runCommand(
|
|
"but",
|
|
args,
|
|
"but not found",
|
|
options,
|
|
undefined,
|
|
{ ...process.env, NO_BG_TASKS: "1" },
|
|
true,
|
|
);
|
|
},
|
|
};
|
|
|
|
const api = createVcsApi([
|
|
createJjProvider(jjRuntime, reviewRuntime),
|
|
createGitButlerProvider(gitButlerRuntime),
|
|
createGitProvider(reviewRuntime),
|
|
]);
|
|
|
|
export const {
|
|
detectVcs,
|
|
detectManagedVcs,
|
|
vcsOwnsDiffType,
|
|
getVcsContext,
|
|
detectRemoteDefaultCompareTarget,
|
|
prepareLocalReviewDiff,
|
|
runVcsDiff,
|
|
getVcsFileContentsForDiff,
|
|
getVcsDiffFingerprint,
|
|
canStageFiles,
|
|
stageFile,
|
|
unstageFile,
|
|
resolveVcsCwd,
|
|
vcsSupportsSnapshot,
|
|
materializeVcsSnapshot,
|
|
} = api;
|
|
|
|
export { resolveInitialDiffType };
|
|
export type { VcsSelection };
|
|
|
|
export function getGitContext(cwd?: string): Promise<GitContext> {
|
|
return getGitContextCore(reviewRuntime, cwd);
|
|
}
|
|
|
|
export function runGitDiff(
|
|
diffType: DiffType,
|
|
defaultBranch = "main",
|
|
cwd?: string,
|
|
options?: GitDiffOptions,
|
|
): Promise<DiffResult> {
|
|
return runGitDiffCore(reviewRuntime, diffType, defaultBranch, cwd, options);
|
|
}
|