Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Closed
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
42 changes: 42 additions & 0 deletions packages/git/src/queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getChangedFilesDetailed,
getGitBusyState,
getLinkedWorktreeMainPath,
listAllFiles,
remoteBranchExists,
splitUnifiedDiffByFile,
} from "./queries";
Expand Down Expand Up @@ -597,3 +598,44 @@ describe("getLinkedWorktreeMainPath", () => {
expect(getLinkedWorktreeMainPath(worktreeDir)).toBeNull();
});
});

describe("listAllFiles", () => {
let repoDir: string;

afterEach(async () => {
if (repoDir) {
await rm(repoDir, { recursive: true, force: true });
}
});

it("combines tracked and untracked files uncapped by default", async () => {
repoDir = await setupRepo();
await writeFile(path.join(repoDir, "untracked.txt"), "content");

const files = await listAllFiles(repoDir);

expect(files.sort()).toEqual(["file.txt", "untracked.txt"]);
});

it("truncates to maxFiles", async () => {
repoDir = await setupRepo();
const git = createGitClient(repoDir);
await writeFile(path.join(repoDir, "b.txt"), "content");
await writeFile(path.join(repoDir, "c.txt"), "content");
await git.add(["b.txt", "c.txt"]);
await git.commit("add more files");

const files = await listAllFiles(repoDir, { maxFiles: 2 });

expect(files.length).toBe(2);
});

it("keeps untracked files over tracked ones when truncating", async () => {
repoDir = await setupRepo();
await writeFile(path.join(repoDir, "untracked.txt"), "content");

const files = await listAllFiles(repoDir, { maxFiles: 1 });

expect(files).toEqual(["untracked.txt"]);
});
});
36 changes: 30 additions & 6 deletions packages/git/src/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1181,15 +1181,39 @@ export async function listUntrackedFiles(
);
}

export interface ListAllFilesOptions {
maxFiles?: number;
timeoutMs?: number;
}

export async function listAllFiles(
baseDir: string,
options?: CreateGitClientOptions,
options?: ListAllFilesOptions,
): Promise<string[]> {
const [tracked, untracked] = await Promise.all([
listFiles(baseDir, options),
listUntrackedFiles(baseDir, options),
]);
return [...tracked, ...untracked];
const { maxFiles, timeoutMs } = options ?? {};
const controller =
timeoutMs !== undefined ? new AbortController() : undefined;
const timer =
controller && timeoutMs !== undefined
? setTimeout(() => controller.abort(), timeoutMs)
: undefined;
try {
const [tracked, untracked] = await Promise.all([
listFiles(baseDir, { abortSignal: controller?.signal }).catch(
(): string[] => [],
),
listUntrackedFiles(baseDir, { abortSignal: controller?.signal }).catch(
(): string[] => [],
Comment on lines +1202 to +1206

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Shared Timeout Hides Files

When the 8s timer fires while both git commands are still running, the shared abort signal cancels the tracked scan as well as the untracked scan. Both catches then return empty arrays, so listRepoFiles can cache and return an empty repo tree instead of the intended tracked-file fallback.

),
]);
Comment on lines +1201 to +1208

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Shared timeout aborts tracked files When FsService passes the 8s timeout, this code sends the same abort signal to both git ls-files calls. In a large or cold repo where the tracked scan is still running when the timer fires, both commands reject and both catches return empty arrays. The no-query listRepoFiles path then caches an empty tree for 30 seconds, so the workspace can show no files instead of falling back to tracked files. The timeout should only degrade the untracked scan, or tracked files should be allowed to complete independently.

Suggested change
const [tracked, untracked] = await Promise.all([
listFiles(baseDir, { abortSignal: controller?.signal }).catch(
(): string[] => [],
),
listUntrackedFiles(baseDir, { abortSignal: controller?.signal }).catch(
(): string[] => [],
),
]);
const [tracked, untracked] = await Promise.all([
listFiles(baseDir).catch((): string[] => []),
listUntrackedFiles(baseDir, { abortSignal: controller?.signal }).catch(
(): string[] => [],
),
]);

const combined = untracked.concat(tracked);
if (maxFiles !== undefined && combined.length > maxFiles) {
combined.splice(maxFiles);
}
Comment on lines +1209 to +1212

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Untracked Files Consume Cap

In a repo with at least 50k untracked generated files, untracked.concat(tracked) fills the cap before any tracked source file is kept. The workspace file list and query path then omit tracked files like package.json even when the tracked scan succeeded.

Suggested change
const combined = untracked.concat(tracked);
if (maxFiles !== undefined && combined.length > maxFiles) {
combined.splice(maxFiles);
}
const combined = tracked.concat(untracked);
if (maxFiles !== undefined && combined.length > maxFiles) {
combined.splice(maxFiles);
}

Comment on lines +1209 to +1212

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Untracked files consume cap This builds the capped list with untracked files first. In a repo with 50k or more untracked/generated files, the splice keeps only those entries and drops every tracked source file even when the tracked scan succeeded. listRepoFiles uses this capped list directly for the file tree and query filtering, so committed files such as package.json or source modules can disappear from the workspace browser and search. Tracked files should be kept first, with untracked files filling any remaining budget.

Suggested change
const combined = untracked.concat(tracked);
if (maxFiles !== undefined && combined.length > maxFiles) {
combined.splice(maxFiles);
}
const combined = tracked.concat(untracked);
if (maxFiles !== undefined && combined.length > maxFiles) {
combined.splice(maxFiles);
}

return combined;
} finally {
if (timer) clearTimeout(timer);
}
}

// Tracked + untracked files containing `pattern` (literal, case-insensitive).
Expand Down
29 changes: 29 additions & 0 deletions packages/workspace-server/src/services/fs/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,35 @@ describe("FsService.listRepoFiles", () => {
{ path: "src/sub/c.ts", kind: "file" },
]);
});

it("passes the file cap and timeout through to listAllFiles", async () => {
vi.mocked(getChangedFiles).mockResolvedValue(new Set());
vi.mocked(listAllFiles).mockResolvedValue([]);

const service = new FsService();
await service.listRepoFiles("/repo");

expect(listAllFiles).toHaveBeenCalledWith("/repo", {
maxFiles: 50_000,
timeoutMs: 8_000,
});
});

it("total entries can exceed the file cap when derived directories are included", async () => {
vi.mocked(getChangedFiles).mockResolvedValue(new Set());
const cappedList = Array.from(
{ length: 50_000 },
(_, i) => `src/sub${i}/file.ts`,
);
vi.mocked(listAllFiles).mockResolvedValue(cappedList);

const service = new FsService();
const entries = await service.listRepoFiles("/repo");

const fileEntries = entries.filter((e) => e.kind === "file");
expect(fileEntries.length).toBe(50_000);
expect(entries.length).toBeGreaterThan(50_000);
});
});

describe("FsService repo file IO", () => {
Expand Down
13 changes: 11 additions & 2 deletions packages/workspace-server/src/services/fs/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type { BoundedReadResult, DirectoryEntry, FileEntry } from "./schemas";
export class FsService {
private static readonly CACHE_TTL = 30000;
private static readonly READ_REPO_FILES_CONCURRENCY = 24;
private static readonly MAX_REPO_FILES = 50_000;
private static readonly LIST_FILES_TIMEOUT_MS = 8_000;
private cache = new Map<string, { files: FileEntry[]; timestamp: number }>();

async listDirectory(dirPath: string): Promise<DirectoryEntry[]> {
Expand Down Expand Up @@ -43,7 +45,7 @@ export class FsService {
const changedFiles = await getChangedFiles(repoPath);

if (query?.trim()) {
const allFiles = await listAllFiles(repoPath);
const allFiles = await this.listAllFilesBounded(repoPath);
const directories = this.deriveDirectories(allFiles);
const lowerQuery = query.toLowerCase();
const matchingDirs = directories.filter((d) =>
Expand All @@ -64,7 +66,7 @@ export class FsService {
return limit ? cached.files.slice(0, limit) : cached.files;
}

const files = await listAllFiles(repoPath);
const files = await this.listAllFilesBounded(repoPath);
const directories = this.deriveDirectories(files);
const entries = [
...this.toDirectoryEntries(directories),
Expand Down Expand Up @@ -221,6 +223,13 @@ export class FsService {
}));
}

private listAllFilesBounded(repoPath: string): Promise<string[]> {
return listAllFiles(repoPath, {
maxFiles: FsService.MAX_REPO_FILES,
timeoutMs: FsService.LIST_FILES_TIMEOUT_MS,
});
}

private deriveDirectories(files: string[]): string[] {
const dirs = new Set<string>();
for (const file of files) {
Expand Down
Loading