@@ -6,6 +6,7 @@ import { createGitClient } from "./client";
66import {
77 detectDefaultBranch ,
88 getBranchDiffPatchesByPath ,
9+ getChangedFilesDetailed ,
910 splitUnifiedDiffByFile ,
1011} from "./queries" ;
1112
@@ -242,3 +243,72 @@ describe("getBranchDiffPatchesByPath", () => {
242243 }
243244 } ) ;
244245} ) ;
246+
247+ // Picked to land well past the default 64KB highWaterMark of
248+ // `createReadStream` so the regression test actually exercises the
249+ // across-chunk path of the streaming line counter.
250+ const LINE_COUNT_LARGER_THAN_READ_STREAM_CHUNK = 800_000 ;
251+
252+ describe ( "getChangedFilesDetailed > untracked line counts" , ( ) => {
253+ let repoDir : string ;
254+
255+ afterEach ( async ( ) => {
256+ if ( repoDir ) {
257+ await rm ( repoDir , { recursive : true , force : true } ) ;
258+ repoDir = "" ;
259+ }
260+ } ) ;
261+
262+ it . each ( [
263+ { name : "trailing newline" , content : "a\nb\nc\n" , expected : 3 } ,
264+ { name : "no trailing newline" , content : "a\nb\nc" , expected : 3 } ,
265+ { name : "single byte, no newline" , content : "a" , expected : 1 } ,
266+ { name : "lone newline" , content : "\n" , expected : 1 } ,
267+ { name : "consecutive newlines" , content : "\n\n" , expected : 2 } ,
268+ // CRLF: legacy `split("\n")` counted only `\n` separators, so
269+ // `"a\r\nb\r\n"` -> 2 lines. Byte-counter matches.
270+ { name : "CRLF endings" , content : "a\r\nb\r\n" , expected : 2 } ,
271+ ] ) ( "counts $name as $expected line(s)" , async ( { content, expected } ) => {
272+ repoDir = await setupRepo ( ) ;
273+ await writeFile ( path . join ( repoDir , "f.txt" ) , content ) ;
274+
275+ const files = await getChangedFilesDetailed ( repoDir ) ;
276+ const f = files . find ( ( file ) => file . path === "f.txt" ) ;
277+
278+ expect ( f ) . toMatchObject ( { status : "untracked" , linesAdded : expected } ) ;
279+ } ) ;
280+
281+ it ( "reports 0 lines for empty untracked files" , async ( ) => {
282+ repoDir = await setupRepo ( ) ;
283+ await writeFile ( path . join ( repoDir , "empty.txt" ) , "" ) ;
284+
285+ const files = await getChangedFilesDetailed ( repoDir ) ;
286+ const empty = files . find ( ( f ) => f . path === "empty.txt" ) ;
287+
288+ expect ( empty ) . toMatchObject ( { status : "untracked" , linesAdded : 0 } ) ;
289+ } ) ;
290+
291+ // Regression guard for the OOM in #2218. Before the fix `countFileLines`
292+ // read each untracked file's full content into memory via
293+ // `fs.readFile(..., "utf-8")`, 16-way concurrent against every untracked
294+ // path returned by `streamGitStatus` (up to 50k). On a monorepo with
295+ // multi-MB build artifacts this exhausted the main-process V8 heap
296+ // (`16 * file_bytes * 2` for V8's UTF-16) and froze the renderer waiting
297+ // on the dead tRPC call. The fix stream-counts via `createReadStream`,
298+ // so peak per-stream memory is ~64KB regardless of file size — the
299+ // multi-MB case below would have OOM'd pre-fix and must still report an
300+ // accurate line count.
301+ it ( "stream-counts untracked files larger than the streaming chunk size" , async ( ) => {
302+ repoDir = await setupRepo ( ) ;
303+ const content = "a\n" . repeat ( LINE_COUNT_LARGER_THAN_READ_STREAM_CHUNK ) ;
304+ await writeFile ( path . join ( repoDir , "huge.txt" ) , content ) ;
305+
306+ const files = await getChangedFilesDetailed ( repoDir ) ;
307+ const huge = files . find ( ( f ) => f . path === "huge.txt" ) ;
308+
309+ expect ( huge ) . toMatchObject ( {
310+ status : "untracked" ,
311+ linesAdded : LINE_COUNT_LARGER_THAN_READ_STREAM_CHUNK ,
312+ } ) ;
313+ } ) ;
314+ } ) ;
0 commit comments