@@ -37,3 +37,157 @@ describe("listFiles", () => {
3737 files . map ( normalizeForComparison ) . should . containEql ( normalizeForComparison ( nestedFile ) )
3838 } )
3939} )
40+
41+ describe ( "listFiles gitignore handling" , ( ) => {
42+ // Each test gets its own isolated subdirectory to avoid cross-test pollution.
43+ // Previous version shared a single tmpDir, which meant later tests could
44+ // overwrite earlier .gitignore files and pass for the wrong reasons.
45+ const baseDir = path . join ( os . tmpdir ( ) , `cline-gitignore-test-${ Math . random ( ) . toString ( 36 ) . slice ( 2 ) } ` )
46+
47+ after ( async ( ) => {
48+ await fs . rm ( baseDir , { recursive : true , force : true } ) . catch ( ( ) => undefined )
49+ } )
50+
51+ it ( "excludes files matching root .gitignore directory patterns" , async ( ) => {
52+ // Verifies the most common .gitignore use case: a directory pattern like "some-dir/"
53+ // at the project root excludes that directory and everything inside it.
54+ //
55+ // project/
56+ // .gitignore → "ignored-dir/"
57+ // visible.ts
58+ // ignored-dir/
59+ // secret.ts ← should be excluded
60+ // src/
61+ // app.ts
62+ const project = path . join ( baseDir , "test-root-gitignore" )
63+ await fs . mkdir ( path . join ( project , "ignored-dir" ) , { recursive : true } )
64+ await fs . mkdir ( path . join ( project , "src" ) , { recursive : true } )
65+ await fs . writeFile ( path . join ( project , ".gitignore" ) , "ignored-dir/\n" )
66+ await fs . writeFile ( path . join ( project , "visible.ts" ) , "export const x = 1\n" )
67+ await fs . writeFile ( path . join ( project , "ignored-dir" , "secret.ts" ) , "secret\n" )
68+ await fs . writeFile ( path . join ( project , "src" , "app.ts" ) , "app\n" )
69+
70+ const [ files ] = await listFiles ( project , true , 200 )
71+ const normalized = files . map ( normalizeForComparison )
72+
73+ normalized . should . containEql ( normalizeForComparison ( path . join ( project , "visible.ts" ) ) )
74+ normalized . should . containEql ( normalizeForComparison ( path . join ( project , "src" , "app.ts" ) ) )
75+
76+ const hasIgnoredContent = normalized . some ( ( f ) => f . includes ( "ignored-dir" ) )
77+ hasIgnoredContent . should . equal ( false , "ignored-dir/ contents should be excluded by root .gitignore" )
78+ } )
79+
80+ it ( "excludes files matching .gitignore file patterns (not just directories)" , async ( ) => {
81+ // The .gitignore parser handles two kinds of patterns differently:
82+ // - Directory patterns ending in "/" → converted to "**/dir/**"
83+ // - File/glob patterns like "*.log" → converted to "**/*.log" + "**/*.log/**"
84+ // This test exercises the file pattern branch.
85+ //
86+ // project/
87+ // .gitignore → "*.log\nsecret.env"
88+ // app.ts
89+ // debug.log ← should be excluded
90+ // src/
91+ // nested.log ← should also be excluded (pattern is global)
92+ // secret.env ← should be excluded
93+ // config.ts
94+ const project = path . join ( baseDir , "test-file-patterns" )
95+ await fs . mkdir ( path . join ( project , "src" ) , { recursive : true } )
96+ await fs . writeFile ( path . join ( project , ".gitignore" ) , "*.log\nsecret.env\n" )
97+ await fs . writeFile ( path . join ( project , "app.ts" ) , "app\n" )
98+ await fs . writeFile ( path . join ( project , "debug.log" ) , "debug output\n" )
99+ await fs . writeFile ( path . join ( project , "src" , "nested.log" ) , "nested log\n" )
100+ await fs . writeFile ( path . join ( project , "src" , "secret.env" ) , "API_KEY=xxx\n" )
101+ await fs . writeFile ( path . join ( project , "src" , "config.ts" ) , "config\n" )
102+
103+ const [ files ] = await listFiles ( project , true , 200 )
104+ const normalized = files . map ( normalizeForComparison )
105+
106+ normalized . should . containEql ( normalizeForComparison ( path . join ( project , "app.ts" ) ) )
107+ normalized . should . containEql ( normalizeForComparison ( path . join ( project , "src" , "config.ts" ) ) )
108+
109+ const hasLogFiles = normalized . some ( ( f ) => f . endsWith ( ".log" ) )
110+ hasLogFiles . should . equal ( false , "*.log files should be excluded" )
111+
112+ const hasSecretEnv = normalized . some ( ( f ) => f . includes ( "secret.env" ) )
113+ hasSecretEnv . should . equal ( false , "secret.env should be excluded" )
114+ } )
115+
116+ it ( "reads .gitignore from subdirectories during BFS traversal" , async ( ) => {
117+ // .gitignore files aren't only at the root — subdirectories can have their own.
118+ // During BFS, when we enter a non-ignored directory, we read its .gitignore
119+ // and add those patterns to the accumulator for all deeper traversal.
120+ //
121+ // project/
122+ // src/
123+ // .gitignore → "generated/"
124+ // code.ts
125+ // generated/
126+ // output.ts ← should be excluded by src/.gitignore
127+ // lib/
128+ // util.ts
129+ const project = path . join ( baseDir , "test-subdirectory-gitignore" )
130+ const srcDir = path . join ( project , "src" )
131+ const genDir = path . join ( srcDir , "generated" )
132+ const libDir = path . join ( project , "lib" )
133+ await fs . mkdir ( genDir , { recursive : true } )
134+ await fs . mkdir ( libDir , { recursive : true } )
135+ await fs . writeFile ( path . join ( srcDir , ".gitignore" ) , "generated/\n" )
136+ await fs . writeFile ( path . join ( srcDir , "code.ts" ) , "code\n" )
137+ await fs . writeFile ( path . join ( genDir , "output.ts" ) , "generated output\n" )
138+ await fs . writeFile ( path . join ( libDir , "util.ts" ) , "util\n" )
139+
140+ const [ files ] = await listFiles ( project , true , 200 )
141+ const normalized = files . map ( normalizeForComparison )
142+
143+ normalized . should . containEql ( normalizeForComparison ( path . join ( srcDir , "code.ts" ) ) )
144+ normalized . should . containEql ( normalizeForComparison ( path . join ( libDir , "util.ts" ) ) )
145+
146+ const hasGeneratedContent = normalized . some ( ( f ) => f . includes ( "generated" ) )
147+ hasGeneratedContent . should . equal ( false , "src/generated/ should be excluded by src/.gitignore" )
148+ } )
149+
150+ it ( "does not read .gitignore from inside gitignored directories" , async ( ) => {
151+ // This is the core OOM-prevention test.
152+ //
153+ // The crash scenario: a gitignored directory (e.g., third-party/) contains
154+ // hundreds of nested repos, each with their own .gitignore. globby's old
155+ // gitignore:true would read ALL of them upfront, build a massive regex,
156+ // and OOM during V8 regex compilation.
157+ //
158+ // With incremental reading, we never enter third-party/ because the root
159+ // .gitignore excludes it, so we never read any .gitignore files inside it.
160+ //
161+ // NOTE: We intentionally use "third-party/" instead of "vendor/" here because
162+ // "vendor" is in DEFAULT_IGNORE_DIRECTORIES and would be excluded regardless
163+ // of .gitignore. Using a name NOT in that list proves the .gitignore-based
164+ // exclusion is actually working.
165+ //
166+ // project/
167+ // .gitignore → "third-party/"
168+ // app.ts
169+ // third-party/
170+ // .gitignore ← should never be read
171+ // repo1/
172+ // .gitignore ← should never be read
173+ // file.ts
174+ const project = path . join ( baseDir , "test-no-read-inside-ignored" )
175+ const thirdPartyDir = path . join ( project , "third-party" )
176+ const repo1Dir = path . join ( thirdPartyDir , "repo1" )
177+ await fs . mkdir ( repo1Dir , { recursive : true } )
178+ await fs . writeFile ( path . join ( project , ".gitignore" ) , "third-party/\n" )
179+ await fs . writeFile ( path . join ( project , "app.ts" ) , "app\n" )
180+ // These .gitignore files simulate the nested repos that caused OOM
181+ await fs . writeFile ( path . join ( thirdPartyDir , ".gitignore" ) , "*.log\nbuild/\n" )
182+ await fs . writeFile ( path . join ( repo1Dir , ".gitignore" ) , "dist/\ncoverage/\n" )
183+ await fs . writeFile ( path . join ( repo1Dir , "file.ts" ) , "file\n" )
184+
185+ const [ files ] = await listFiles ( project , true , 200 )
186+ const normalized = files . map ( normalizeForComparison )
187+
188+ normalized . should . containEql ( normalizeForComparison ( path . join ( project , "app.ts" ) ) )
189+
190+ const hasThirdPartyContent = normalized . some ( ( f ) => f . includes ( "third-party" ) )
191+ hasThirdPartyContent . should . equal ( false , "third-party/ contents should be excluded — and its .gitignore files never read" )
192+ } )
193+ } )
0 commit comments