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
58 changes: 54 additions & 4 deletions packages/workspace-server/src/services/fs/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("@posthog/git/queries", () => ({
getChangedFiles: vi.fn(async () => new Set<string>()),
listAllFiles: vi.fn(async () => []),
listFiles: vi.fn(async () => []),
listUntrackedFiles: vi.fn(async () => []),
}));

import { getChangedFiles, listAllFiles } from "@posthog/git/queries";
import {
getChangedFiles,
listFiles,
listUntrackedFiles,
} from "@posthog/git/queries";
import { FsService } from "./service";

describe("FsService.listRepoFiles", () => {
it("derives directory entries alongside files", async () => {
vi.mocked(getChangedFiles).mockResolvedValue(new Set());
vi.mocked(listAllFiles).mockResolvedValue([
vi.mocked(listFiles).mockResolvedValue([
"a.ts",
"src/b.ts",
"src/sub/c.ts",
]);
vi.mocked(listUntrackedFiles).mockResolvedValue([]);

const service = new FsService();
const entries = await service.listRepoFiles("/repo");
Expand All @@ -34,11 +40,12 @@ describe("FsService.listRepoFiles", () => {

it("filters directories and files by query substring", async () => {
vi.mocked(getChangedFiles).mockResolvedValue(new Set());
vi.mocked(listAllFiles).mockResolvedValue([
vi.mocked(listFiles).mockResolvedValue([
"a.ts",
"src/b.ts",
"src/sub/c.ts",
]);
vi.mocked(listUntrackedFiles).mockResolvedValue([]);

const service = new FsService();
const entries = await service.listRepoFiles("/repo", "sub");
Expand All @@ -48,6 +55,49 @@ describe("FsService.listRepoFiles", () => {
{ path: "src/sub/c.ts", kind: "file" },
]);
});

it("caps file list at MAX_REPO_FILES when repo is very large", async () => {
vi.mocked(getChangedFiles).mockResolvedValue(new Set());
// Flat paths produce zero derived directory entries, so total === cap.
const bigList = Array.from({ length: 60_000 }, (_, i) => `file${i}.ts`);
vi.mocked(listFiles).mockResolvedValue(bigList);
vi.mocked(listUntrackedFiles).mockResolvedValue([]);

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

expect(entries.length).toBe(50_000);
});

it("total entries can exceed MAX_REPO_FILES when derived directories are included", async () => {
vi.mocked(getChangedFiles).mockResolvedValue(new Set());
// Nested paths cause deriveDirectories to add parent directory entries on
// top of the capped 50k file entries, so the returned total is > 50k.
const bigList = Array.from(
{ length: 60_000 },
(_, i) => `src/sub${i}/file.ts`,
);
vi.mocked(listFiles).mockResolvedValue(bigList);
vi.mocked(listUntrackedFiles).mockResolvedValue([]);

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);
});
Comment thread
ricardo-leiva marked this conversation as resolved.

it("omits untracked files when git ls-files --others is aborted", async () => {
vi.mocked(getChangedFiles).mockResolvedValue(new Set());
vi.mocked(listFiles).mockResolvedValue(["tracked.ts"]);
vi.mocked(listUntrackedFiles).mockRejectedValue(new Error("AbortError"));

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

expect(entries.some((e) => e.path === "tracked.ts")).toBe(true);
});
});

describe("FsService repo file IO", () => {
Expand Down
39 changes: 36 additions & 3 deletions packages/workspace-server/src/services/fs/service.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import fs from "node:fs/promises";
import path from "node:path";
import { getChangedFiles, listAllFiles } from "@posthog/git/queries";
import {
getChangedFiles,
listFiles,
listUntrackedFiles,
} from "@posthog/git/queries";
import { injectable } from "inversify";
import type { BoundedReadResult, DirectoryEntry, FileEntry } from "./schemas";

@injectable()
export class FsService {
private static readonly CACHE_TTL = 30000;
private static readonly READ_REPO_FILES_CONCURRENCY = 24;
// Large repos (100k+ files) cause GC pressure that starves the session-init
// event loop. Cap the combined tracked + untracked list to bound allocation.
private static readonly MAX_REPO_FILES = 50_000;
// Abort git ls-files --others if it takes too long (unignored venvs, caches,
// or staticfiles directories can make it scan millions of entries).
private static readonly UNTRACKED_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 +53,7 @@ export class FsService {
const changedFiles = await getChangedFiles(repoPath);

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

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

private async fetchAllFiles(repoPath: string): Promise<string[]> {
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(),
FsService.UNTRACKED_TIMEOUT_MS,
);
try {
const [tracked, untracked] = await Promise.all([
listFiles(repoPath),
listUntrackedFiles(repoPath, { abortSignal: controller.signal }).catch(
() => [],
),
]);
const combined = tracked.concat(untracked);
if (combined.length > FsService.MAX_REPO_FILES) {
combined.length = FsService.MAX_REPO_FILES;
}
return combined;
} finally {
clearTimeout(timer);
}
}

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