@@ -15,10 +15,7 @@ import {
1515
1616interface TraversalOptions {
1717 pattern ?: string ;
18- useGitignore : boolean ;
1918 maxEntries : number ;
20- ig : ReturnType < typeof ignore > | null ; // Ignore instance loaded once from .gitignore, reused across recursion
21- rootPath : string ; // Root path for calculating relative paths in gitignore matching
2219}
2320
2421interface TraversalResult {
@@ -27,134 +24,6 @@ interface TraversalResult {
2724 exceeded : boolean ;
2825}
2926
30- /**
31- * Recursively build a file tree structure with depth control and filtering.
32- * Counts entries as they're added and stops when limit is reached.
33- *
34- * @param dir - Directory to traverse
35- * @param currentDepth - Current depth level (1 = immediate children)
36- * @param maxDepth - Maximum depth to traverse
37- * @param options - Filtering options (pattern, gitignore, entry limit)
38- * @param currentCount - Shared counter tracking total entries across recursion
39- * @returns Tree structure with entries, total count, and exceeded flag
40- */
41- async function buildFileTree (
42- dir : string ,
43- currentDepth : number ,
44- maxDepth : number ,
45- options : TraversalOptions ,
46- currentCount : { value : number }
47- ) : Promise < TraversalResult > {
48- // Check if we've already exceeded the limit
49- if ( currentCount . value >= options . maxEntries ) {
50- return { entries : [ ] , totalCount : currentCount . value , exceeded : true } ;
51- }
52-
53- let dirents ;
54- try {
55- dirents = await fs . readdir ( dir , { withFileTypes : true } ) ;
56- } catch ( err ) {
57- // If we can't read the directory (permissions, etc.), skip it
58- return { entries : [ ] , totalCount : currentCount . value , exceeded : false } ;
59- }
60-
61- // Sort: directories first, then files, alphabetically within each group
62- dirents . sort ( ( a , b ) => {
63- const aIsDir = a . isDirectory ( ) ;
64- const bIsDir = b . isDirectory ( ) ;
65- if ( aIsDir && ! bIsDir ) return - 1 ;
66- if ( ! aIsDir && bIsDir ) return 1 ;
67- return a . name . localeCompare ( b . name ) ;
68- } ) ;
69-
70- const entries : FileEntry [ ] = [ ] ;
71-
72- for ( const dirent of dirents ) {
73- const fullPath = path . join ( dir , dirent . name ) ;
74- const entryType = dirent . isDirectory ( ) ? "directory" : dirent . isFile ( ) ? "file" : "symlink" ;
75-
76- // Always skip .git directory regardless of gitignore setting
77- if ( dirent . name === ".git" && entryType === "directory" ) {
78- continue ;
79- }
80-
81- // Check gitignore filtering
82- if ( options . useGitignore && options . ig ) {
83- const relativePath = path . relative ( options . rootPath , fullPath ) ;
84- // Add trailing slash for directories for proper gitignore matching
85- const pathToCheck = entryType === "directory" ? relativePath + "/" : relativePath ;
86- if ( options . ig . ignores ( pathToCheck ) ) {
87- continue ;
88- }
89- }
90-
91- // For pattern matching:
92- // - If it's a file, check if it matches the pattern
93- // - If it's a directory, we'll add it provisionally and remove it later if it has no matches
94- let matchesPattern = true ;
95- if ( options . pattern && entryType === "file" ) {
96- matchesPattern = minimatch ( dirent . name , options . pattern , { matchBase : true } ) ;
97- }
98-
99- // Skip files that don't match pattern
100- if ( entryType === "file" && ! matchesPattern ) {
101- continue ;
102- }
103-
104- // Check limit before adding (even for directories we'll explore)
105- if ( currentCount . value >= options . maxEntries ) {
106- return { entries, totalCount : currentCount . value + 1 , exceeded : true } ;
107- }
108-
109- // Increment counter
110- currentCount . value ++ ;
111-
112- const entry : FileEntry = {
113- name : dirent . name ,
114- type : entryType ,
115- } ;
116-
117- // Get size for files
118- if ( entryType === "file" ) {
119- try {
120- const stats = await fs . stat ( fullPath ) ;
121- entry . size = stats . size ;
122- } catch {
123- // If we can't stat the file, skip size
124- }
125- }
126-
127- // Recurse into directories if within depth limit
128- if ( entryType === "directory" && currentDepth < maxDepth ) {
129- const result = await buildFileTree (
130- fullPath ,
131- currentDepth + 1 ,
132- maxDepth ,
133- options ,
134- currentCount
135- ) ;
136-
137- if ( result . exceeded ) {
138- // Don't add this directory since we exceeded the limit while processing it
139- currentCount . value -- ; // Revert the increment for this directory
140- return { entries, totalCount : result . totalCount , exceeded : true } ;
141- }
142-
143- entry . children = result . entries ;
144-
145- // If we have a pattern and this directory has no matching descendants, skip it
146- if ( options . pattern && entry . children . length === 0 ) {
147- currentCount . value -- ; // Revert the increment
148- continue ;
149- }
150- }
151-
152- entries . push ( entry ) ;
153- }
154-
155- return { entries, totalCount : currentCount . value , exceeded : false } ;
156- }
157-
15827/**
15928 * Load and parse .gitignore file if it exists
16029 */
@@ -232,9 +101,142 @@ export function createFileListTool(config: { cwd: string }) {
232101 // Enforce entry limit
233102 const effectiveMaxEntries = Math . min ( Math . max ( 1 , max_entries ) , FILE_LIST_HARD_MAX_ENTRIES ) ;
234103
235- // Load .gitignore if requested
104+ // Load .gitignore if requested (loaded once, used across entire traversal via closure)
236105 const ig = gitignore ? await loadGitignore ( resolvedPath ) : null ;
237106
107+ /**
108+ * Recursively build a file tree structure with depth control and filtering.
109+ * Uses closure to access ig (ignore instance) and resolvedPath without passing them through recursion.
110+ * Counts entries as they're added and stops when limit is reached.
111+ *
112+ * @param dir - Directory to traverse
113+ * @param currentDepth - Current depth level (1 = immediate children)
114+ * @param maxDepth - Maximum depth to traverse
115+ * @param options - Filtering options (pattern, entry limit)
116+ * @param currentCount - Shared counter tracking total entries across recursion
117+ * @returns Tree structure with entries, total count, and exceeded flag
118+ */
119+ async function buildFileTree (
120+ dir : string ,
121+ currentDepth : number ,
122+ maxDepth : number ,
123+ options : TraversalOptions ,
124+ currentCount : { value : number }
125+ ) : Promise < TraversalResult > {
126+ // Check if we've already exceeded the limit
127+ if ( currentCount . value >= options . maxEntries ) {
128+ return { entries : [ ] , totalCount : currentCount . value , exceeded : true } ;
129+ }
130+
131+ let dirents ;
132+ try {
133+ dirents = await fs . readdir ( dir , { withFileTypes : true } ) ;
134+ } catch ( err ) {
135+ // If we can't read the directory (permissions, etc.), skip it
136+ return { entries : [ ] , totalCount : currentCount . value , exceeded : false } ;
137+ }
138+
139+ // Sort: directories first, then files, alphabetically within each group
140+ dirents . sort ( ( a , b ) => {
141+ const aIsDir = a . isDirectory ( ) ;
142+ const bIsDir = b . isDirectory ( ) ;
143+ if ( aIsDir && ! bIsDir ) return - 1 ;
144+ if ( ! aIsDir && bIsDir ) return 1 ;
145+ return a . name . localeCompare ( b . name ) ;
146+ } ) ;
147+
148+ const entries : FileEntry [ ] = [ ] ;
149+
150+ for ( const dirent of dirents ) {
151+ const fullPath = path . join ( dir , dirent . name ) ;
152+ const entryType = dirent . isDirectory ( )
153+ ? "directory"
154+ : dirent . isFile ( )
155+ ? "file"
156+ : "symlink" ;
157+
158+ // Always skip .git directory regardless of gitignore setting
159+ if ( dirent . name === ".git" && entryType === "directory" ) {
160+ continue ;
161+ }
162+
163+ // Check gitignore filtering (uses ig from closure)
164+ if ( gitignore && ig ) {
165+ const relativePath = path . relative ( resolvedPath , fullPath ) ;
166+ // Add trailing slash for directories for proper gitignore matching
167+ const pathToCheck = entryType === "directory" ? relativePath + "/" : relativePath ;
168+ if ( ig . ignores ( pathToCheck ) ) {
169+ continue ;
170+ }
171+ }
172+
173+ // For pattern matching:
174+ // - If it's a file, check if it matches the pattern
175+ // - If it's a directory, we'll add it provisionally and remove it later if it has no matches
176+ let matchesPattern = true ;
177+ if ( options . pattern && entryType === "file" ) {
178+ matchesPattern = minimatch ( dirent . name , options . pattern , { matchBase : true } ) ;
179+ }
180+
181+ // Skip files that don't match pattern
182+ if ( entryType === "file" && ! matchesPattern ) {
183+ continue ;
184+ }
185+
186+ // Check limit before adding (even for directories we'll explore)
187+ if ( currentCount . value >= options . maxEntries ) {
188+ return { entries, totalCount : currentCount . value + 1 , exceeded : true } ;
189+ }
190+
191+ // Increment counter
192+ currentCount . value ++ ;
193+
194+ const entry : FileEntry = {
195+ name : dirent . name ,
196+ type : entryType ,
197+ } ;
198+
199+ // Get size for files
200+ if ( entryType === "file" ) {
201+ try {
202+ const stats = await fs . stat ( fullPath ) ;
203+ entry . size = stats . size ;
204+ } catch {
205+ // If we can't stat the file, skip size
206+ }
207+ }
208+
209+ // Recurse into directories if within depth limit
210+ if ( entryType === "directory" && currentDepth < maxDepth ) {
211+ const result = await buildFileTree (
212+ fullPath ,
213+ currentDepth + 1 ,
214+ maxDepth ,
215+ options ,
216+ currentCount
217+ ) ;
218+
219+ if ( result . exceeded ) {
220+ // Don't add this directory since we exceeded the limit while processing it
221+ currentCount . value -- ; // Revert the increment for this directory
222+ return { entries, totalCount : result . totalCount , exceeded : true } ;
223+ }
224+
225+ entry . children = result . entries ;
226+
227+ // If we have a pattern and this directory has no matching descendants, skip it
228+ if ( options . pattern && entry . children . length === 0 ) {
229+ currentCount . value -- ; // Revert the increment
230+ continue ;
231+ }
232+ }
233+
234+ entries . push ( entry ) ;
235+ }
236+
237+ return { entries, totalCount : currentCount . value , exceeded : false } ;
238+ }
239+
238240 // Build the file tree
239241 const currentCount = { value : 0 } ;
240242 const result = await buildFileTree (
@@ -243,10 +245,7 @@ export function createFileListTool(config: { cwd: string }) {
243245 effectiveDepth ,
244246 {
245247 pattern,
246- useGitignore : gitignore ,
247248 maxEntries : effectiveMaxEntries ,
248- ig,
249- rootPath : resolvedPath ,
250249 } ,
251250 currentCount
252251 ) ;
0 commit comments