diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 326e42059a4..bdea68046e5 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -17,17 +17,18 @@ import { type MockDispatcher, type RelayDispatcher } from './git-handler-test-setup' +import type { GitExec } from './git-handler-ops' describe('GitHandler', () => { let dispatcher: MockDispatcher let tmpDir: string + let handler: GitHandler beforeEach(() => { tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-')) dispatcher = createMockDispatcher() const ctx = new RelayContext() - // eslint-disable-next-line no-new - new GitHandler(dispatcher as unknown as RelayDispatcher, ctx) + handler = new GitHandler(dispatcher as unknown as RelayDispatcher, ctx) }) afterEach(async () => { @@ -498,6 +499,32 @@ describe('GitHandler', () => { await expect(fs.access(path.join(tmpDir, 'new.txt'))).rejects.toThrow() }) + it('bulk discards a tracked directory with a very large tracked child list', async () => { + const trackedOutput = `${Array.from( + { length: 130_000 }, + (_, index) => `src/generated/file-${index}.ts` + ).join('\0')}\0` + const git = vi.fn(async (args) => { + if (args[0] === 'ls-files') { + return { stdout: trackedOutput, stderr: '' } + } + return { stdout: '', stderr: '' } + }) + ;(handler as unknown as { git: GitExec }).git = git + + await expect( + dispatcher.callRequest('git.bulkDiscard', { + worktreePath: tmpDir, + filePaths: ['src'] + }) + ).resolves.toBeUndefined() + + expect(git).toHaveBeenCalledWith( + ['restore', '--worktree', '--source=HEAD', '--', ':(literal)src'], + tmpDir + ) + }) + it('rejects path traversal', async () => { gitInit(tmpDir) await expect( diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index b532ab070ff..ffd235085aa 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -276,7 +276,13 @@ export class GitHandler { ['ls-files', '-z', '--', ...chunk.map((p) => this.literalPathspec(p))], worktreePath ) - trackedPathSpecs.push(...stdout.split('\0').filter(Boolean)) + // Why: selecting a tracked directory can expand to more child paths than + // V8 allows as function arguments. + for (const trackedPath of stdout.split('\0')) { + if (trackedPath) { + trackedPathSpecs.push(trackedPath) + } + } } const trackedPaths = filePaths.filter((filePath) =>