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.
164 lines
6.3 KiB
TypeScript
164 lines
6.3 KiB
TypeScript
/**
|
|
* Generated-file detection for code review (#1317).
|
|
*
|
|
* Two layers, matching the GitHub linguist / GitLab semantics:
|
|
*
|
|
* 1. **Built-in name defaults** (`DEFAULT_GENERATED_PATTERNS`) — lockfiles,
|
|
* minified assets, and source maps are generated by their NAME alone, no
|
|
* git needed. Matched against the path's last segment only, so a
|
|
* directory named `bun.lock` never marks the files inside it.
|
|
* 2. **Explicit `.gitattributes`** — wins in BOTH directions.
|
|
* `linguist-generated` / `linguist-generated=true` marks any file;
|
|
* `-linguist-generated` / `linguist-generated=false` UN-marks a file even
|
|
* when it is on the built-in list; `unspecified` falls through to the
|
|
* built-in defaults.
|
|
*
|
|
* Attribute resolution deliberately goes through git's own machinery
|
|
* (`git check-attr`) instead of a hand-rolled `.gitattributes` parser, so
|
|
* stacked and negated rules (per-directory files, `$GIT_DIR/info/attributes`)
|
|
* behave exactly as git resolves them. One `--stdin -z` invocation covers
|
|
* every path — never a per-file subprocess — and it is bounded by a timeout
|
|
* because it sits on the blocking `/api/diff` path.
|
|
*
|
|
* Best-effort by design: any attribute-lookup failure (git missing, not a
|
|
* work tree, non-zero exit, unparsable output, timeout) leaves the name-based
|
|
* defaults standing — name matching needs no git. Attributes are read from
|
|
* the working tree at the review cwd, git's default resolution.
|
|
*
|
|
* Runtime-agnostic like review-core (Pi consumes a build-time copy via
|
|
* vendor.sh).
|
|
*/
|
|
|
|
import type { ReviewGitRuntime } from "./review-core";
|
|
|
|
export const GENERATED_ATTRIBUTE = "linguist-generated";
|
|
|
|
/** Same bound as review-core's stdin-driven git callers (`cat-file --batch-check`). */
|
|
const CHECK_ATTR_TIMEOUT_MS = 5000;
|
|
|
|
/**
|
|
* Built-in name-based generated defaults — files GitHub's linguist collapses
|
|
* without any `.gitattributes`. Exact entries match a path's last segment
|
|
* verbatim; `*.`-prefixed entries match by extension suffix (at least one
|
|
* character before the suffix, so a bare `.min.js` dotfile does not match).
|
|
* Explicit `.gitattributes` always refines this list in both directions.
|
|
*/
|
|
export const DEFAULT_GENERATED_PATTERNS: readonly string[] = [
|
|
// JavaScript ecosystems
|
|
"package-lock.json",
|
|
"npm-shrinkwrap.json",
|
|
"yarn.lock",
|
|
"pnpm-lock.yaml",
|
|
"bun.lock",
|
|
"bun.lockb",
|
|
// Other language ecosystems
|
|
"Cargo.lock",
|
|
"Gemfile.lock",
|
|
"composer.lock",
|
|
"poetry.lock",
|
|
"uv.lock",
|
|
"Pipfile.lock",
|
|
"go.sum",
|
|
"flake.lock",
|
|
"packages.lock.json",
|
|
// Build artifacts
|
|
"*.min.js",
|
|
"*.min.css",
|
|
"*.map",
|
|
];
|
|
|
|
const DEFAULT_EXACT_NAMES = new Set(
|
|
DEFAULT_GENERATED_PATTERNS.filter((p) => !p.startsWith("*.")),
|
|
);
|
|
const DEFAULT_SUFFIXES = DEFAULT_GENERATED_PATTERNS.filter((p) =>
|
|
p.startsWith("*."),
|
|
).map((p) => p.slice(1)); // "*.min.js" -> ".min.js"
|
|
|
|
/**
|
|
* Does the path's LAST segment match a built-in generated default? Pure and
|
|
* git-free, so it also serves the non-git degrade modes (piped patches,
|
|
* workspace multi-repo folder-prefixed paths, PR worktrees, jj, GitButler,
|
|
* P4) where attribute lookup is unavailable.
|
|
*/
|
|
export function isDefaultGeneratedPath(path: string): boolean {
|
|
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
if (name.length === 0) return false;
|
|
if (DEFAULT_EXACT_NAMES.has(name)) return true;
|
|
return DEFAULT_SUFFIXES.some(
|
|
(suffix) => name.length > suffix.length && name.endsWith(suffix),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Name-based detection alone — the built-in defaults with no attribute
|
|
* refinement. Deduplicated, input order preserved.
|
|
*/
|
|
export function detectGeneratedFilesByName(paths: string[]): string[] {
|
|
const unique = [...new Set(paths.filter((p) => p.length > 0))];
|
|
return unique.filter(isDefaultGeneratedPath);
|
|
}
|
|
|
|
/**
|
|
* `git check-attr -z` emits NUL-separated triples: path, attribute name,
|
|
* value. A bare `linguist-generated` reports `set`, `=true` reports `true`;
|
|
* both mark the file. `unset` (negated with `-`) and `false` explicitly
|
|
* UN-mark it — overriding the built-in defaults. `unspecified` (or any other
|
|
* value) expresses no opinion and falls through to the defaults.
|
|
*/
|
|
export function parseCheckAttrStates(
|
|
stdout: string,
|
|
): Map<string, "set" | "unset" | "unspecified"> {
|
|
const tokens = stdout.split("\0");
|
|
const states = new Map<string, "set" | "unset" | "unspecified">();
|
|
for (let i = 0; i + 2 < tokens.length; i += 3) {
|
|
const [path, attribute, value] = [tokens[i], tokens[i + 1], tokens[i + 2]];
|
|
if (attribute !== GENERATED_ATTRIBUTE) continue;
|
|
if (value === "set" || value === "true") states.set(path, "set");
|
|
else if (value === "unset" || value === "false") states.set(path, "unset");
|
|
else states.set(path, "unspecified");
|
|
}
|
|
return states;
|
|
}
|
|
|
|
/**
|
|
* Resolve which of `paths` count as generated: built-in name defaults first,
|
|
* then one `git check-attr` subprocess whose explicit set/unset answers win
|
|
* in both directions (`unspecified` keeps the default). Paths must be
|
|
* repo-relative (the forward-slash paths a parsed unified diff yields).
|
|
* Returns the generated subset in input order (deduplicated); an attribute
|
|
* lookup failure leaves the name-based defaults standing.
|
|
*/
|
|
export async function detectGeneratedFiles(
|
|
runtime: ReviewGitRuntime,
|
|
cwd: string | undefined,
|
|
paths: string[],
|
|
): Promise<string[]> {
|
|
const unique = [...new Set(paths.filter((p) => p.length > 0))];
|
|
if (unique.length === 0) return [];
|
|
const marked = new Set(unique.filter(isDefaultGeneratedPath));
|
|
try {
|
|
const result = await runtime.runGit(
|
|
["check-attr", "--stdin", "-z", GENERATED_ATTRIBUTE],
|
|
{
|
|
cwd,
|
|
// NUL-terminated input pairs with -z output; paths with spaces,
|
|
// quotes, or newlines round-trip without any quoting layer.
|
|
stdin: unique.join("\0") + "\0",
|
|
timeoutMs: CHECK_ATTR_TIMEOUT_MS,
|
|
interaction: "forbid",
|
|
},
|
|
);
|
|
if (result.exitCode === 0 && !result.truncated) {
|
|
for (const [path, state] of parseCheckAttrStates(result.stdout)) {
|
|
if (state === "set") marked.add(path);
|
|
else if (state === "unset") marked.delete(path);
|
|
// unspecified: no opinion — the built-in default stands.
|
|
}
|
|
}
|
|
} catch {
|
|
// Attribute refinement unavailable — the name-based defaults stand.
|
|
}
|
|
// Report in input order so the sidecar is deterministic for a given diff.
|
|
return unique.filter((p) => marked.has(p));
|
|
}
|