Skip to content

Commit 441a181

Browse files
committed
Batch git.log to cap memory on large repos
Fetches commits in batches of 5,000 instead of loading the entire history at once. Reduces peak memory ~10x for repos with 1M+ commits (from ~600 MB to ~60 MB). Also adds cancellation support and live commit count during loading.
1 parent 1651ab3 commit 441a181

3 files changed

Lines changed: 85 additions & 19 deletions

File tree

src/lib/CLAUDE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ run client-side in a Web Worker.
2121
- `git/clone.ts` — Clone/fetch using isomorphic-git + lightning-fs, default branch detection via
2222
`listServerRefs` (protocol v2), abortable HTTP wrapper for signal support, size-warning emission
2323
for repos >1 GB
24-
- `git/history.ts` — Commit log grouped by date, consecutive date generation, gap filling
24+
- `git/history.ts` — Commit log grouped by date, consecutive date generation, gap filling.
25+
`getCommitsByDate` fetches commits in batches (via `git.log` with `depth` parameter) to bound peak
26+
memory on large repos. A `seenOids` Set deduplicates commits across batches (merges can cause overlap).
27+
Supports `signal` for cancellation and `onProgress` for reporting processed commit counts.
2528
- `git/count.ts` — Line counting per commit tree, prod/test classification, blob dedup caching.
2629
Files with unrecognized extensions are counted under the `'other'` bucket (with test-dir detection).
2730
Blob reads are parallelized with a concurrency limit of 8 using an inline `createLimiter` utility

src/lib/git/history.ts

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,37 +7,72 @@ export interface DailyCommit {
77
messages: string[];
88
}
99

10+
const commitBatchSize = 5000;
11+
1012
/**
1113
* Get the commit log for a branch and group by date.
1214
* Returns entries in chronological order (oldest first).
1315
* Keeps the latest commit hash per day but collects all messages.
16+
*
17+
* Fetches commits in batches to bound peak memory on large repos.
1418
*/
1519
export const getCommitsByDate = async (options: {
1620
fs: FsClient;
1721
dir: string;
1822
ref: string;
1923
gitCache?: object;
24+
signal?: AbortSignal;
25+
onProgress?: (processedCommits: number) => void;
2026
}): Promise<DailyCommit[]> => {
21-
const { fs, dir, ref, gitCache } = options;
22-
23-
const commits: ReadCommitResult[] = await git.log({ fs, dir, ref, cache: gitCache });
27+
const { fs, dir, ref, gitCache, signal, onProgress } = options;
2428

25-
// git.log returns newest-first; group by date
2629
const byDate = new Map<string, DailyCommit>();
30+
const seenOids = new Set<string>();
31+
let currentRef: string = ref;
32+
let totalProcessed = 0;
33+
34+
while (true) {
35+
if (signal?.aborted) throw new Error('Cancelled');
36+
37+
const batch: ReadCommitResult[] = await git.log({
38+
fs,
39+
dir,
40+
ref: currentRef,
41+
depth: commitBatchSize,
42+
cache: gitCache
43+
});
44+
45+
if (batch.length === 0) break;
2746

28-
for (const commit of commits) {
29-
const date = formatDate(commit.commit.author.timestamp);
30-
const existing = byDate.get(date);
31-
if (!existing) {
32-
// First commit for this date (latest chronologically since log is reverse order)
33-
byDate.set(date, {
34-
date,
35-
hash: commit.oid,
36-
messages: [commit.commit.message.trim()]
37-
});
38-
} else {
39-
existing.messages.push(commit.commit.message.trim());
47+
for (const commit of batch) {
48+
if (seenOids.has(commit.oid)) continue;
49+
seenOids.add(commit.oid);
50+
totalProcessed++;
51+
52+
const date = formatDate(commit.commit.author.timestamp);
53+
const existing = byDate.get(date);
54+
if (!existing) {
55+
byDate.set(date, {
56+
date,
57+
hash: commit.oid,
58+
messages: [commit.commit.message.trim()]
59+
});
60+
} else {
61+
existing.messages.push(commit.commit.message.trim());
62+
}
4063
}
64+
65+
onProgress?.(totalProcessed);
66+
67+
// Stop if this batch was smaller than requested (end of history)
68+
if (batch.length < commitBatchSize) break;
69+
70+
// Stop if the oldest commit has no parents (root commit)
71+
const oldest = batch[batch.length - 1];
72+
if (oldest.commit.parent.length === 0) break;
73+
74+
// Continue from the first parent of the oldest commit
75+
currentRef = oldest.commit.parent[0];
4176
}
4277

4378
// Sort chronologically (oldest first)

src/lib/worker/analyzer.worker.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,21 @@ const analyzerApi = {
129129
// Step 3: Get commit history grouped by date
130130
onProgress({ type: 'process', current: 0, total: 0, date: 'Loading history...' });
131131
const gitCache = {};
132-
const dailyCommits = await getCommitsByDate({ fs, dir, ref: defaultBranch, gitCache });
132+
const dailyCommits = await getCommitsByDate({
133+
fs,
134+
dir,
135+
ref: defaultBranch,
136+
gitCache,
137+
signal,
138+
onProgress: (processed) => {
139+
onProgress({
140+
type: 'process',
141+
current: 0,
142+
total: 0,
143+
date: `Loading history... (${processed.toLocaleString()} commits)`
144+
});
145+
}
146+
});
133147

134148
if (dailyCommits.length === 0) {
135149
throw new Error('No commits found in repository');
@@ -282,7 +296,21 @@ const analyzerApi = {
282296
// Step 2: Get full commit history
283297
onProgress({ type: 'process', current: 0, total: 0, date: 'Loading history...' });
284298
const gitCache = {};
285-
const dailyCommits = await getCommitsByDate({ fs, dir, ref: defaultBranch, gitCache });
299+
const dailyCommits = await getCommitsByDate({
300+
fs,
301+
dir,
302+
ref: defaultBranch,
303+
gitCache,
304+
signal,
305+
onProgress: (processed) => {
306+
onProgress({
307+
type: 'process',
308+
current: 0,
309+
total: 0,
310+
date: `Loading history... (${processed.toLocaleString()} commits)`
311+
});
312+
}
313+
});
286314

287315
if (dailyCommits.length === 0) {
288316
throw new Error('No commits found in repository');

0 commit comments

Comments
 (0)