From aafa5f9d162bbcd5373e4f51382876079a50cd14 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 16:34:15 +0000 Subject: [PATCH] perf: cap concurrent untracked-file diff subprocesses Bounds the fan-out in /diff/working to 8 concurrent `git diff --no-index` calls instead of spawning one process per untracked file, so a repo with hundreds of untracked files (e.g. an accidental node_modules add) doesn't fork hundreds of processes at once. --- src/server/routes/diff.ts | 45 ++++++++++++++++++++++++++++----------- tests/concurrency.test.ts | 38 +++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 13 deletions(-) create mode 100644 tests/concurrency.test.ts diff --git a/src/server/routes/diff.ts b/src/server/routes/diff.ts index 7176c0c..a3404f7 100644 --- a/src/server/routes/diff.ts +++ b/src/server/routes/diff.ts @@ -9,6 +9,27 @@ import type { CommitDiffPayload, FileDiff, WorkingDiffPayload } from '../../shar const DIFF_ARGS = ['--no-color', '--find-renames']; +/** Max concurrent `git diff --no-index` subprocesses when diffing untracked files. */ +const UNTRACKED_DIFF_CONCURRENCY = 8; + +/** Runs `fn` over `items` with at most `limit` calls in flight at once. */ +export async function mapWithConcurrency( + items: T[], + limit: number, + fn: (item: T) => Promise, +): Promise { + const results = new Array(items.length); + let next = 0; + async function worker() { + while (next < items.length) { + const i = next++; + results[i] = await fn(items[i]!); + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + return results; +} + export function diffRoutes(ctx: AppContext) { const r = new Hono(); @@ -37,19 +58,17 @@ export function diffRoutes(ctx: AppContext) { ]); const status = parseStatus(statusOut); const untracked: FileDiff[] = ( - await Promise.all( - status.untracked.map(async (f) => { - const out = await repo.git.read( - ['diff', '--no-color', '--no-index', '--', '/dev/null', join(cwd, f.path)], - { cwd, okCodes: [0, 1] }, - ); - const parsed = parseUnifiedDiff(out, 'untracked'); - const fd = parsed[0]; - if (!fd) return null; - fd.path = f.path; - return fd; - }), - ) + await mapWithConcurrency(status.untracked, UNTRACKED_DIFF_CONCURRENCY, async (f) => { + const out = await repo.git.read( + ['diff', '--no-color', '--no-index', '--', '/dev/null', join(cwd, f.path)], + { cwd, okCodes: [0, 1] }, + ); + const parsed = parseUnifiedDiff(out, 'untracked'); + const fd = parsed[0]; + if (!fd) return null; + fd.path = f.path; + return fd; + }) ).filter((f): f is FileDiff => f !== null); return c.json({ staged: parseUnifiedDiff(stagedOut), diff --git a/tests/concurrency.test.ts b/tests/concurrency.test.ts new file mode 100644 index 0000000..0b73d6d --- /dev/null +++ b/tests/concurrency.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { mapWithConcurrency } from '../src/server/routes/diff.js'; + +describe('mapWithConcurrency', () => { + it('keeps the number of in-flight calls bounded on a large fixture', async () => { + const items = Array.from({ length: 250 }, (_, i) => i); + let inFlight = 0; + let maxInFlight = 0; + + const results = await mapWithConcurrency(items, 8, async (n) => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 1)); + inFlight--; + return n * 2; + }); + + expect(maxInFlight).toBeLessThanOrEqual(8); + expect(results).toEqual(items.map((n) => n * 2)); + }); + + it('processes every item exactly once, in order', async () => { + const items = Array.from({ length: 37 }, (_, i) => i); + const seen: number[] = []; + + await mapWithConcurrency(items, 5, async (n) => { + seen.push(n); + return n; + }); + + expect(seen.sort((a, b) => a - b)).toEqual(items); + }); + + it('handles an empty list and a limit larger than the item count', async () => { + expect(await mapWithConcurrency([], 8, async (n) => n)).toEqual([]); + expect(await mapWithConcurrency([1, 2], 8, async (n) => n * 10)).toEqual([10, 20]); + }); +});