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
30 changes: 27 additions & 3 deletions packages/git/src/exec.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}

Expand All @@ -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) {
Expand Down
40 changes: 14 additions & 26 deletions packages/git/src/tree.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<string>();
Expand Down Expand Up @@ -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 };
}
67 changes: 67 additions & 0 deletions packages/git/tests/get-tree-large-repo.test.ts
Original file line number Diff line number Diff line change
@@ -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+:/);
});
});