Skip to content

Commit c67d46d

Browse files
committed
simple monorepo favicon detection
1 parent 1234708 commit c67d46d

File tree

7 files changed

+2264
-52
lines changed

7 files changed

+2264
-52
lines changed

apps/server/src/gitIgnore.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { assert, beforeEach, describe, it, vi } from "vitest";
2+
3+
import type { ProcessRunOptions, ProcessRunResult } from "./processRunner";
4+
5+
const { runProcessMock } = vi.hoisted(() => ({
6+
runProcessMock:
7+
vi.fn<
8+
(
9+
command: string,
10+
args: readonly string[],
11+
options?: ProcessRunOptions,
12+
) => Promise<ProcessRunResult>
13+
>(),
14+
}));
15+
16+
vi.mock("./processRunner", () => ({
17+
runProcess: runProcessMock,
18+
}));
19+
20+
function processResult(
21+
overrides: Partial<ProcessRunResult> & Pick<ProcessRunResult, "stdout" | "code">,
22+
): ProcessRunResult {
23+
return {
24+
stdout: overrides.stdout,
25+
code: overrides.code,
26+
stderr: overrides.stderr ?? "",
27+
signal: overrides.signal ?? null,
28+
timedOut: overrides.timedOut ?? false,
29+
stdoutTruncated: overrides.stdoutTruncated ?? false,
30+
stderrTruncated: overrides.stderrTruncated ?? false,
31+
};
32+
}
33+
34+
describe("gitIgnore", () => {
35+
beforeEach(() => {
36+
runProcessMock.mockReset();
37+
vi.resetModules();
38+
});
39+
40+
it("chunks large git check-ignore requests and filters ignored matches", async () => {
41+
const ignoredPaths = Array.from(
42+
{ length: 320 },
43+
(_, index) => `ignored/${index.toString().padStart(4, "0")}/${"x".repeat(1024)}.ts`,
44+
);
45+
const keptPaths = ["src/keep.ts", "docs/readme.md"];
46+
const relativePaths = [...ignoredPaths, ...keptPaths];
47+
let checkIgnoreCalls = 0;
48+
49+
runProcessMock.mockImplementation(async (_command, args, options) => {
50+
if (args[0] === "check-ignore") {
51+
checkIgnoreCalls += 1;
52+
const chunkPaths = (options?.stdin ?? "").split("\0").filter((value) => value.length > 0);
53+
const chunkIgnored = chunkPaths.filter((value) => value.startsWith("ignored/"));
54+
return processResult({
55+
code: chunkIgnored.length > 0 ? 0 : 1,
56+
stdout: chunkIgnored.length > 0 ? `${chunkIgnored.join("\0")}\0` : "",
57+
});
58+
}
59+
60+
throw new Error(`Unexpected command: git ${args.join(" ")}`);
61+
});
62+
63+
const { filterGitIgnoredPaths } = await import("./gitIgnore");
64+
const result = await filterGitIgnoredPaths("/virtual/workspace", relativePaths);
65+
66+
assert.isAbove(checkIgnoreCalls, 1);
67+
assert.deepEqual(result, keptPaths);
68+
});
69+
70+
it("fails open when git check-ignore cannot complete", async () => {
71+
const relativePaths = ["src/keep.ts", "ignored.txt"];
72+
73+
runProcessMock.mockRejectedValueOnce(new Error("spawn failed"));
74+
75+
const { filterGitIgnoredPaths } = await import("./gitIgnore");
76+
const result = await filterGitIgnoredPaths("/virtual/workspace", relativePaths);
77+
78+
assert.deepEqual(result, relativePaths);
79+
});
80+
});

apps/server/src/gitIgnore.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { runProcess } from "./processRunner";
2+
3+
const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;
4+
5+
/**
6+
* Shared git-ignore helpers for server-side workspace scans.
7+
*
8+
* Both callers use these helpers as an optimization and a consistency layer, not
9+
* as a hard dependency. If git is unavailable, slow, or returns an unexpected
10+
* result, we intentionally fail open so the UI keeps working and avoids hiding
11+
* files unpredictably.
12+
*/
13+
14+
export function splitNullSeparatedPaths(input: string, truncated: boolean): string[] {
15+
const parts = input.split("\0");
16+
if (truncated && parts[parts.length - 1]?.length) {
17+
parts.pop();
18+
}
19+
return parts.filter((value) => value.length > 0);
20+
}
21+
22+
/**
23+
* Returns whether `cwd` is inside a git work tree.
24+
*
25+
* This is a cheap capability probe used to decide whether later git-aware
26+
* filtering is worth attempting.
27+
*/
28+
export async function isInsideGitWorkTree(cwd: string): Promise<boolean> {
29+
const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], {
30+
cwd,
31+
allowNonZeroExit: true,
32+
timeoutMs: 5_000,
33+
maxBufferBytes: 4_096,
34+
}).catch(() => null);
35+
36+
return Boolean(
37+
insideWorkTree && insideWorkTree.code === 0 && insideWorkTree.stdout.trim() === "true",
38+
);
39+
}
40+
41+
/**
42+
* Filters repo-relative paths that match git ignore rules for `cwd`.
43+
*
44+
* We use `git check-ignore --no-index` so both tracked and untracked candidates
45+
* respect the current ignore rules. Input is chunked to keep stdin bounded, and
46+
* unexpected git failures return the original paths unchanged so callers fail
47+
* open instead of dropping potentially valid files.
48+
*/
49+
export async function filterGitIgnoredPaths(
50+
cwd: string,
51+
relativePaths: readonly string[],
52+
): Promise<string[]> {
53+
if (relativePaths.length === 0) {
54+
return [...relativePaths];
55+
}
56+
57+
const ignoredPaths = new Set<string>();
58+
let chunk: string[] = [];
59+
let chunkBytes = 0;
60+
61+
const flushChunk = async (): Promise<boolean> => {
62+
if (chunk.length === 0) {
63+
return true;
64+
}
65+
66+
const checkIgnore = await runProcess("git", ["check-ignore", "--no-index", "-z", "--stdin"], {
67+
cwd,
68+
allowNonZeroExit: true,
69+
timeoutMs: 20_000,
70+
maxBufferBytes: 16 * 1024 * 1024,
71+
outputMode: "truncate",
72+
stdin: `${chunk.join("\0")}\0`,
73+
}).catch(() => null);
74+
chunk = [];
75+
chunkBytes = 0;
76+
77+
if (!checkIgnore) {
78+
return false;
79+
}
80+
81+
// git-check-ignore exits with 1 when no paths match.
82+
if (checkIgnore.code !== 0 && checkIgnore.code !== 1) {
83+
return false;
84+
}
85+
86+
const matchedIgnoredPaths = splitNullSeparatedPaths(
87+
checkIgnore.stdout,
88+
Boolean(checkIgnore.stdoutTruncated),
89+
);
90+
for (const ignoredPath of matchedIgnoredPaths) {
91+
ignoredPaths.add(ignoredPath);
92+
}
93+
return true;
94+
};
95+
96+
for (const relativePath of relativePaths) {
97+
const relativePathBytes = Buffer.byteLength(relativePath) + 1;
98+
if (
99+
chunk.length > 0 &&
100+
chunkBytes + relativePathBytes > GIT_CHECK_IGNORE_MAX_STDIN_BYTES &&
101+
!(await flushChunk())
102+
) {
103+
return [...relativePaths];
104+
}
105+
106+
chunk.push(relativePath);
107+
chunkBytes += relativePathBytes;
108+
109+
if (chunkBytes >= GIT_CHECK_IGNORE_MAX_STDIN_BYTES && !(await flushChunk())) {
110+
return [...relativePaths];
111+
}
112+
}
113+
114+
if (!(await flushChunk())) {
115+
return [...relativePaths];
116+
}
117+
118+
if (ignoredPaths.size === 0) {
119+
return [...relativePaths];
120+
}
121+
122+
return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath));
123+
}

0 commit comments

Comments
 (0)