From ad210998740711c08693eb20fc943422136d7d63 Mon Sep 17 00:00:00 2001 From: Gustavo de Paula Date: Mon, 17 Aug 2026 18:28:32 -0300 Subject: [PATCH] fix(git): the file tree survives repositories larger than 1 MB of paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getWorkingTreeFiles and getTreeFingerprint called execFileSync without a maxBuffer, so they inherited Node's 1 MB default. In a monorepo whose git ls-files output is 2.3 MB across ~29k files, every call died with spawnSync git ENOBUFS and the UI showed 'Failed to get tree' — the file browser and any tour that reads the tree were unusable. exec.ts already had the 50 MB ceiling these calls needed, but only for its string-command helpers; tree.ts passes argv arrays, which it should, because a repository path can contain a space or a quote. So this adds execFileLarge as the argv form of execLarge, names the shared constant, and routes tree.ts through it. --- packages/git/src/exec.ts | 30 ++++++++- packages/git/src/tree.ts | 40 ++++------- .../git/tests/get-tree-large-repo.test.ts | 67 +++++++++++++++++++ 3 files changed, 108 insertions(+), 29 deletions(-) create mode 100644 packages/git/tests/get-tree-large-repo.test.ts diff --git a/packages/git/src/exec.ts b/packages/git/src/exec.ts index 623726e..68927ad 100644 --- a/packages/git/src/exec.ts +++ b/packages/git/src/exec.ts @@ -1,13 +1,24 @@ -import { execSync, type StdioOptions } from 'node:child_process'; +import { + execFileSync, + execSync, + type StdioOptions, +} from 'node:child_process'; const STDIO: StdioOptions = ['pipe', 'pipe', 'pipe']; +/** + * Node's default is 1 MB, which is smaller than a large repository's file + * listing: `git ls-files` in a ~29k-file monorepo emits over 2 MB and the child + * process dies with ENOBUFS. + */ +const MAX_BUFFER = 50 * 1024 * 1024; + export function execWithStdin(cmd: string, input: string): string { return execSync(cmd, { encoding: 'utf-8', stdio: STDIO, input, - maxBuffer: 50 * 1024 * 1024, + maxBuffer: MAX_BUFFER, }); } @@ -22,10 +33,23 @@ export function execLarge(cmd: string): string { return execSync(cmd, { encoding: 'utf-8', stdio: STDIO, - maxBuffer: 50 * 1024 * 1024, + maxBuffer: MAX_BUFFER, }); } +/** + * The argv form of `execLarge`, for commands whose arguments come from user + * input — a path with a space or a quote in it cannot be passed safely through + * a shell string. + */ +export function execFileLarge(command: string, args: string[]): string { + return execFileSync(command, args, { + encoding: 'utf-8', + stdio: STDIO, + maxBuffer: MAX_BUFFER, + }).trim(); +} + export function execLines(cmd: string): string[] { const output = exec(cmd); if (!output) { diff --git a/packages/git/src/tree.ts b/packages/git/src/tree.ts index b6a22ef..dec7055 100644 --- a/packages/git/src/tree.ts +++ b/packages/git/src/tree.ts @@ -1,7 +1,8 @@ -import { execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { execFileLarge } from './exec'; + export interface TreeEntry { type: 'blob' | 'tree'; path: string; @@ -11,19 +12,16 @@ export interface TreeEntry { function getWorkingTreeFiles(dirPath?: string): string[] { const pathArgs = dirPath ? [dirPath + '/'] : []; - const tracked = execFileSync('git', ['ls-files', ...pathArgs], { - encoding: 'utf-8', - }).trim(); + const tracked = execFileLarge('git', ['ls-files', ...pathArgs]); - const deleted = execFileSync('git', ['ls-files', '--deleted', ...pathArgs], { - encoding: 'utf-8', - }).trim(); + const deleted = execFileLarge('git', ['ls-files', '--deleted', ...pathArgs]); - const untracked = execFileSync( - 'git', - ['ls-files', '--others', '--exclude-standard', ...pathArgs], - { encoding: 'utf-8' }, - ).trim(); + const untracked = execFileLarge('git', [ + 'ls-files', + '--others', + '--exclude-standard', + ...pathArgs, + ]); const deletedSet = new Set(deleted ? deleted.split('\n') : []); const files = new Set(); @@ -71,30 +69,20 @@ export function getTreeEntries(_ref = 'HEAD', dirPath?: string): TreeEntry[] { } export function getTreeFingerprint(): string { - const tracked = execFileSync('git', ['ls-files'], { - encoding: 'utf-8', - }).trim(); + const tracked = execFileLarge('git', ['ls-files']); - const statOutput = execFileSync( - 'git', - ['status', '--porcelain', '-u'], - { encoding: 'utf-8' }, - ).trim(); + const statOutput = execFileLarge('git', ['status', '--porcelain', '-u']); return `${tracked.length}:${statOutput}`; } export function getWorkingTreeFileContent(filePath: string): string { - const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { - encoding: 'utf-8', - }).trim(); + const root = execFileLarge('git', ['rev-parse', '--show-toplevel']); return readFileSync(join(root, filePath), 'utf-8'); } export function getWorkingTreeRawFile(filePath: string): { data: Buffer; fullPath: string } { - const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { - encoding: 'utf-8', - }).trim(); + const root = execFileLarge('git', ['rev-parse', '--show-toplevel']); const fullPath = join(root, filePath); return { data: readFileSync(fullPath), fullPath }; } diff --git a/packages/git/tests/get-tree-large-repo.test.ts b/packages/git/tests/get-tree-large-repo.test.ts new file mode 100644 index 0000000..6eca7b2 --- /dev/null +++ b/packages/git/tests/get-tree-large-repo.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { getTree, getTreeFingerprint } from '../src/tree'; + +/** + * Node's default maxBuffer is 1 MB. A repository whose file listing is larger + * than that used to kill `git ls-files` with ENOBUFS, which surfaced as + * "Failed to get tree" for every large repo. + */ +const DEFAULT_MAX_BUFFER = 1024 * 1024; + +let repoDir: string; +let origCwd: string; + +function git(cmd: string) { + // This fixture is deliberately larger than the default 1 MB buffer, and + // `git commit` names every file it creates — so the setup needs the same + // headroom the code under test does. + execSync(`git ${cmd}`, { + cwd: repoDir, + stdio: 'pipe', + maxBuffer: 50 * 1024 * 1024, + }); +} + +beforeAll(() => { + origCwd = process.cwd(); + repoDir = mkdtempSync(join(tmpdir(), 'diffity-large-tree-')); + + git('init -b main'); + git('config user.email "test@test.com"'); + git('config user.name "Test"'); + + // Enough path bytes to exceed the default buffer: names are padded so the + // listing crosses 1 MB without needing tens of thousands of files. + const padding = 'p'.repeat(180); + mkdirSync(join(repoDir, 'many')); + for (let i = 0; i < 6000; i += 1) { + writeFileSync(join(repoDir, 'many', `f${i}-${padding}.txt`), ''); + } + git('add .'); + git('commit -m "many files"'); + + process.chdir(repoDir); +}); + +afterAll(() => { + process.chdir(origCwd); + rmSync(repoDir, { recursive: true, force: true }); +}); + +describe('a repository whose listing exceeds the default buffer', () => { + it('lists every file rather than throwing ENOBUFS', () => { + const paths = getTree(); + + expect(paths).toHaveLength(6000); + expect(paths.join('\n').length).toBeGreaterThan(DEFAULT_MAX_BUFFER); + }); + + it('still fingerprints the tree', () => { + expect(getTreeFingerprint()).toMatch(/^\d+:/); + }); +});