Files
Michael Ramos 15f8d4fe4c feat(review): collapse generated files by default in the all-files view (#1346)
* 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.
2026-08-17 23:32:29 -07:00

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);
}