Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions apps/server/src/gitIgnore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { assert, beforeEach, describe, it, vi } from "vitest";

import type { ProcessRunOptions, ProcessRunResult } from "./processRunner";

const { runProcessMock } = vi.hoisted(() => ({
runProcessMock:
vi.fn<
(
command: string,
args: readonly string[],
options?: ProcessRunOptions,
) => Promise<ProcessRunResult>
>(),
}));

vi.mock("./processRunner", () => ({
runProcess: runProcessMock,
}));

function processResult(
overrides: Partial<ProcessRunResult> & Pick<ProcessRunResult, "stdout" | "code">,
): ProcessRunResult {
return {
stdout: overrides.stdout,
code: overrides.code,
stderr: overrides.stderr ?? "",
signal: overrides.signal ?? null,
timedOut: overrides.timedOut ?? false,
stdoutTruncated: overrides.stdoutTruncated ?? false,
stderrTruncated: overrides.stderrTruncated ?? false,
};
}

describe("gitIgnore", () => {
beforeEach(() => {
runProcessMock.mockReset();
vi.resetModules();
});

it("chunks large git check-ignore requests and filters ignored matches", async () => {
const ignoredPaths = Array.from(
{ length: 320 },
(_, index) => `ignored/${index.toString().padStart(4, "0")}/${"x".repeat(1024)}.ts`,
);
const keptPaths = ["src/keep.ts", "docs/readme.md"];
const relativePaths = [...ignoredPaths, ...keptPaths];
let checkIgnoreCalls = 0;

runProcessMock.mockImplementation(async (_command, args, options) => {
if (args[0] === "check-ignore") {
checkIgnoreCalls += 1;
const chunkPaths = (options?.stdin ?? "").split("\0").filter((value) => value.length > 0);
const chunkIgnored = chunkPaths.filter((value) => value.startsWith("ignored/"));
return processResult({
code: chunkIgnored.length > 0 ? 0 : 1,
stdout: chunkIgnored.length > 0 ? `${chunkIgnored.join("\0")}\0` : "",
});
}

throw new Error(`Unexpected command: git ${args.join(" ")}`);
});

const { filterGitIgnoredPaths } = await import("./gitIgnore");
const result = await filterGitIgnoredPaths("/virtual/workspace", relativePaths);

assert.isAbove(checkIgnoreCalls, 1);
assert.deepEqual(result, keptPaths);
});

it("fails open when git check-ignore cannot complete", async () => {
const relativePaths = ["src/keep.ts", "ignored.txt"];

runProcessMock.mockRejectedValueOnce(new Error("spawn failed"));

const { filterGitIgnoredPaths } = await import("./gitIgnore");
const result = await filterGitIgnoredPaths("/virtual/workspace", relativePaths);

assert.deepEqual(result, relativePaths);
});
});
123 changes: 123 additions & 0 deletions apps/server/src/gitIgnore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { runProcess } from "./processRunner";

const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;

/**
* Shared git-ignore helpers for server-side workspace scans.
*
* Both callers use these helpers as an optimization and a consistency layer, not
* as a hard dependency. If git is unavailable, slow, or returns an unexpected
* result, we intentionally fail open so the UI keeps working and avoids hiding
* files unpredictably.
*/

function splitNullSeparatedPaths(input: string, truncated: boolean): string[] {
const parts = input.split("\0");
if (truncated && parts[parts.length - 1]?.length) {
parts.pop();
}
return parts.filter((value) => value.length > 0);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated splitNullSeparatedPaths helper across two files

Low Severity

splitNullSeparatedPaths is duplicated between gitIgnore.ts and workspaceEntries.ts with near-identical implementations. The PR explicitly set out to extract shared logic (like filterGitIgnoredPaths and isInsideGitWorkTree) into gitIgnore.ts, but this helper was copied rather than shared. The two copies have a trivial difference (an unreachable parts.length === 0 guard in workspaceEntries.ts), and any future bug fix to one copy risks not being applied to the other.

Additional Locations (1)
Fix in Cursor Fix in Web


/**
* Returns whether `cwd` is inside a git work tree.
*
* This is a cheap capability probe used to decide whether later git-aware
* filtering is worth attempting.
*/
export async function isInsideGitWorkTree(cwd: string): Promise<boolean> {
const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], {
cwd,
allowNonZeroExit: true,
timeoutMs: 5_000,
maxBufferBytes: 4_096,
}).catch(() => null);

return Boolean(
insideWorkTree && insideWorkTree.code === 0 && insideWorkTree.stdout.trim() === "true",
);
}

/**
* Filters repo-relative paths that match git ignore rules for `cwd`.
*
* We use `git check-ignore --no-index` so both tracked and untracked candidates
* respect the current ignore rules. Input is chunked to keep stdin bounded, and
* unexpected git failures return the original paths unchanged so callers fail
* open instead of dropping potentially valid files.
*/
export async function filterGitIgnoredPaths(
cwd: string,
relativePaths: readonly string[],
): Promise<string[]> {
if (relativePaths.length === 0) {
return [...relativePaths];
}

const ignoredPaths = new Set<string>();
let chunk: string[] = [];
let chunkBytes = 0;

const flushChunk = async (): Promise<boolean> => {
if (chunk.length === 0) {
return true;
}

const checkIgnore = await runProcess("git", ["check-ignore", "--no-index", "-z", "--stdin"], {
cwd,
allowNonZeroExit: true,
timeoutMs: 20_000,
maxBufferBytes: 16 * 1024 * 1024,
outputMode: "truncate",
stdin: `${chunk.join("\0")}\0`,
}).catch(() => null);
chunk = [];
chunkBytes = 0;

if (!checkIgnore) {
return false;
}

// git-check-ignore exits with 1 when no paths match.
if (checkIgnore.code !== 0 && checkIgnore.code !== 1) {
return false;
}

const matchedIgnoredPaths = splitNullSeparatedPaths(
checkIgnore.stdout,
Boolean(checkIgnore.stdoutTruncated),
);
for (const ignoredPath of matchedIgnoredPaths) {
ignoredPaths.add(ignoredPath);
}
return true;
};

for (const relativePath of relativePaths) {
const relativePathBytes = Buffer.byteLength(relativePath) + 1;
if (
chunk.length > 0 &&
chunkBytes + relativePathBytes > GIT_CHECK_IGNORE_MAX_STDIN_BYTES &&
!(await flushChunk())
) {
return [...relativePaths];
}

chunk.push(relativePath);
chunkBytes += relativePathBytes;

if (chunkBytes >= GIT_CHECK_IGNORE_MAX_STDIN_BYTES && !(await flushChunk())) {
return [...relativePaths];
}
}

if (!(await flushChunk())) {
return [...relativePaths];
}

if (ignoredPaths.size === 0) {
return [...relativePaths];
}

return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath));
}
Loading