@@ -2,6 +2,7 @@ import type { Stats } from 'node:fs';
22import fs from 'node:fs/promises' ;
33import path from 'node:path' ;
44import { type Options as GlobbyOptions , type GlobEntry , globby } from 'globby' ;
5+ import { minimatch } from 'minimatch' ;
56import type { RepomixConfigMerged } from '../../config/configSchema.js' ;
67import { defaultIgnoreList } from '../../config/defaultIgnore.js' ;
78import { mapWithConcurrency } from '../../shared/asyncMap.js' ;
@@ -20,6 +21,7 @@ export interface FileSearchResult {
2021// than awaiting serially. The cap protects very large repos from EMFILE / file
2122// descriptor exhaustion that unbounded `Promise.all` could cause.
2223const EMPTY_DIR_CHECK_CONCURRENCY = 20 ;
24+ const IGNORE_CONTROL_FILE_NAMES = new Set ( [ '.gitignore' , '.ignore' , '.repomixignore' ] ) ;
2325
2426// No per-directory ignore-pattern check is needed here. The `directories` array
2527// comes from globby with the same `ignore` patterns (e.g. `dist/**`), which
@@ -86,6 +88,41 @@ export const normalizeGlobPattern = (pattern: string): string => {
8688 return pattern ;
8789} ;
8890
91+ const toPosixPath = ( value : string ) : string => value . replace ( / \\ / g, '/' ) ;
92+
93+ // Canonical posix form of a deferred ignore pattern: forward slashes and no
94+ // trailing slash. Detection (isIgnoreControlFilePattern) and post-filtering
95+ // (filterDeferredIgnoredFiles) must share this so a pattern that is deferred is
96+ // also matched by the filter. Otherwise e.g. `**/.gitignore/` would be deferred
97+ // (dropped from globby's ignore) yet never matched here, leaking the file.
98+ const toPosixIgnorePattern = ( pattern : string ) : string => toPosixPath ( pattern ) . replace ( / \/ + $ / , '' ) ;
99+
100+ const isIgnoreControlFilePattern = ( pattern : string ) : boolean => {
101+ const normalizedPattern = toPosixIgnorePattern ( pattern ) ;
102+ if ( normalizedPattern . startsWith ( '!' ) ) {
103+ return false ;
104+ }
105+ return IGNORE_CONTROL_FILE_NAMES . has ( path . posix . basename ( normalizedPattern ) ) ;
106+ } ;
107+
108+ const filterDeferredIgnoredFiles = ( filePaths : string [ ] , deferredIgnorePatterns : string [ ] ) : string [ ] => {
109+ if ( deferredIgnorePatterns . length === 0 ) {
110+ return filePaths ;
111+ }
112+ const posixPatterns = deferredIgnorePatterns . map ( toPosixIgnorePattern ) ;
113+ return filePaths . filter ( ( filePath ) => {
114+ const normalizedPath = toPosixPath ( filePath ) ;
115+ // Match the control file itself, and — for the pathological case of a
116+ // directory literally named `.gitignore` — its descendants too. globby
117+ // previously normalized `**/.gitignore` to `**/.gitignore/**` (which excludes
118+ // both), so matching `${pattern}/**` here preserves that behavior.
119+ return ! posixPatterns . some (
120+ ( pattern ) =>
121+ minimatch ( normalizedPath , pattern , { dot : true } ) || minimatch ( normalizedPath , `${ pattern } /**` , { dot : true } ) ,
122+ ) ;
123+ } ) ;
124+ } ;
125+
89126// Get all file paths considering the config
90127export const searchFiles = async (
91128 rootDir : string ,
@@ -140,10 +177,14 @@ export const searchFiles = async (
140177 }
141178
142179 try {
143- const { adjustedIgnorePatterns, ignoreFilePatterns } = await prepareIgnoreContext ( rootDir , config ) ;
180+ const { adjustedIgnorePatterns, ignoreFilePatterns, deferredIgnorePatterns } = await prepareIgnoreContext (
181+ rootDir ,
182+ config ,
183+ ) ;
144184
145185 logger . trace ( 'Ignore patterns:' , adjustedIgnorePatterns ) ;
146186 logger . trace ( 'Ignore file patterns:' , ignoreFilePatterns ) ;
187+ logger . trace ( 'Deferred ignore patterns:' , deferredIgnorePatterns ) ;
147188
148189 // Start with configured include patterns
149190 let includePatterns = config . include . map ( ( pattern ) => escapeGlobPattern ( pattern ) ) ;
@@ -221,7 +262,7 @@ export const searchFiles = async (
221262 directories . push ( entry . path ) ;
222263 }
223264 }
224- filePaths = files ;
265+ filePaths = filterDeferredIgnoredFiles ( files , deferredIgnorePatterns ) ;
225266
226267 const globbyElapsedTime = Date . now ( ) - globbyStartTime ;
227268 logger . debug (
@@ -233,10 +274,13 @@ export const searchFiles = async (
233274 const filterTime = Date . now ( ) - filterStartTime ;
234275 logger . debug ( `[empty dirs] Filtered to ${ emptyDirPaths . length } empty directories in ${ filterTime } ms` ) ;
235276 } else {
236- filePaths = await globby ( includePatterns , {
237- ...createBaseGlobbyOptions ( rootDir , config , adjustedIgnorePatterns , ignoreFilePatterns ) ,
238- onlyFiles : true ,
239- } ) . catch ( handleGlobbyError ) ;
277+ filePaths = filterDeferredIgnoredFiles (
278+ await globby ( includePatterns , {
279+ ...createBaseGlobbyOptions ( rootDir , config , adjustedIgnorePatterns , ignoreFilePatterns ) ,
280+ onlyFiles : true ,
281+ } ) . catch ( handleGlobbyError ) ,
282+ deferredIgnorePatterns ,
283+ ) ;
240284
241285 const globbyElapsedTime = Date . now ( ) - globbyStartTime ;
242286 logger . debug ( `[globby] Completed in ${ globbyElapsedTime } ms, found ${ filePaths . length } files` ) ;
@@ -288,14 +332,25 @@ export const parseIgnoreContent = (content: string): string[] => {
288332const prepareIgnoreContext = async (
289333 rootDir : string ,
290334 config : RepomixConfigMerged ,
291- ) : Promise < { adjustedIgnorePatterns : string [ ] ; ignoreFilePatterns : string [ ] } > => {
335+ ) : Promise < { adjustedIgnorePatterns : string [ ] ; ignoreFilePatterns : string [ ] ; deferredIgnorePatterns : string [ ] } > => {
292336 const [ ignorePatterns , ignoreFilePatterns ] = await Promise . all ( [
293337 getIgnorePatterns ( rootDir , config ) ,
294338 getIgnoreFilePatterns ( config ) ,
295339 ] ) ;
296340
341+ // Keep ignore-control files visible to globby so their rules are loaded, then filter them from final file lists.
342+ const deferredIgnorePatterns : string [ ] = [ ] ;
343+ const globbyIgnorePatterns : string [ ] = [ ] ;
344+ for ( const pattern of ignorePatterns ) {
345+ if ( isIgnoreControlFilePattern ( pattern ) ) {
346+ deferredIgnorePatterns . push ( pattern ) ;
347+ } else {
348+ globbyIgnorePatterns . push ( pattern ) ;
349+ }
350+ }
351+
297352 // Normalize ignore patterns to handle trailing slashes consistently
298- const normalizedIgnorePatterns = ignorePatterns . map ( normalizeGlobPattern ) ;
353+ const normalizedIgnorePatterns = globbyIgnorePatterns . map ( normalizeGlobPattern ) ;
299354
300355 // Check if .git is a worktree reference
301356 const gitPath = path . join ( rootDir , '.git' ) ;
@@ -312,7 +367,7 @@ const prepareIgnoreContext = async (
312367 }
313368 }
314369
315- return { adjustedIgnorePatterns, ignoreFilePatterns } ;
370+ return { adjustedIgnorePatterns, ignoreFilePatterns, deferredIgnorePatterns } ;
316371} ;
317372
318373/**
@@ -433,12 +488,15 @@ export const listDirectories = async (rootDir: string, config: RepomixConfigMerg
433488 * @returns Array of file paths relative to rootDir
434489 */
435490export const listFiles = async ( rootDir : string , config : RepomixConfigMerged ) : Promise < string [ ] > => {
436- const { adjustedIgnorePatterns, ignoreFilePatterns } = await prepareIgnoreContext ( rootDir , config ) ;
491+ const { adjustedIgnorePatterns, ignoreFilePatterns, deferredIgnorePatterns } = await prepareIgnoreContext (
492+ rootDir ,
493+ config ,
494+ ) ;
437495
438496 const files = await globby ( [ '**/*' ] , {
439497 ...createBaseGlobbyOptions ( rootDir , config , adjustedIgnorePatterns , ignoreFilePatterns ) ,
440498 onlyFiles : true ,
441499 } ) ;
442500
443- return sortPaths ( files ) ;
501+ return sortPaths ( filterDeferredIgnoredFiles ( files , deferredIgnorePatterns ) ) ;
444502} ;
0 commit comments