1- import { lstat } from 'node:fs/promises' ;
1+ import { lstat , readFile } from 'node:fs/promises' ;
22import path from 'node:path' ;
3+ import ignore from 'ignore' ;
34import isBinaryPath from 'is-binary-path' ;
45import micromatch from 'micromatch' ;
56import readdir , { type Dirent } from 'tiny-readdir' ;
@@ -50,7 +51,148 @@ const hasAlwaysIgnoredSegment = (cwd: string, filePath: string): boolean =>
5051 . split ( path . sep )
5152 . some ( ( segment ) => alwaysIgnoredNames . has ( segment ) ) ;
5253
53- const createTraversalOptions = ( isIncluded ?: ( filePath : string ) => boolean ) => {
54+ const findGitRoot = async ( cwd : string ) : Promise < string > => {
55+ let directoryPath = cwd ;
56+
57+ while ( true ) {
58+ if ( await lstatSafe ( path . join ( directoryPath , '.git' ) ) ) {
59+ return directoryPath ;
60+ }
61+
62+ const parentPath = path . dirname ( directoryPath ) ;
63+ if ( parentPath === directoryPath ) {
64+ return cwd ;
65+ }
66+ directoryPath = parentPath ;
67+ }
68+ } ;
69+
70+ class GitIgnoreMatcher {
71+ readonly #rootPath: string ;
72+ readonly #matchers = new Map < string , ReturnType < typeof ignore > > ( ) ;
73+ readonly #loads = new Map < string , Promise < void > > ( ) ;
74+ readonly #ignoredDirectories = new Map < string , boolean > ( ) ;
75+
76+ private constructor ( rootPath : string ) {
77+ this . #rootPath = rootPath ;
78+ }
79+
80+ static async create ( cwd : string ) : Promise < GitIgnoreMatcher > {
81+ const matcher = new GitIgnoreMatcher ( await findGitRoot ( cwd ) ) ;
82+ await matcher . loadThrough ( cwd ) ;
83+ return matcher ;
84+ }
85+
86+ async loadThrough ( directoryPath : string ) : Promise < void > {
87+ if ( ! isPathInside ( this . #rootPath, directoryPath ) ) {
88+ return ;
89+ }
90+
91+ const relativePath = path . relative ( this . #rootPath, directoryPath ) ;
92+ const segments = relativePath ? relativePath . split ( path . sep ) : [ ] ;
93+ const loads = [ this . #load( this . #rootPath) ] ;
94+ let currentPath = this . #rootPath;
95+
96+ for ( const segment of segments ) {
97+ currentPath = path . join ( currentPath , segment ) ;
98+ loads . push ( this . #load( currentPath ) ) ;
99+ }
100+
101+ await Promise . all ( loads ) ;
102+ }
103+
104+ async load ( directoryPath : string ) : Promise < void > {
105+ if ( isPathInside ( this . #rootPath, directoryPath ) ) {
106+ await this . #load( directoryPath ) ;
107+ }
108+ }
109+
110+ isIgnored ( filePath : string , isDirectory : boolean ) : boolean {
111+ if ( this . #matchers. size === 0 ) {
112+ return false ;
113+ }
114+
115+ const relativePath = path . relative ( this . #rootPath, filePath ) ;
116+ if ( relativePath === '' || ! isRelativePathInside ( relativePath ) ) {
117+ return false ;
118+ }
119+
120+ if ( isDirectory ) {
121+ return this . #isDirectoryIgnored( filePath , relativePath ) ;
122+ }
123+
124+ const parentPath = path . dirname ( filePath ) ;
125+ return (
126+ ( parentPath !== this . #rootPath && this . #isDirectoryIgnored( parentPath ) ) ||
127+ this . #matches( relativePath , false )
128+ ) ;
129+ }
130+
131+ #load( directoryPath : string ) : Promise < void > {
132+ const cached = this . #loads. get ( directoryPath ) ;
133+ if ( cached ) {
134+ return cached ;
135+ }
136+
137+ // Ignore files may disappear or become unreadable during traversal.
138+ const loading = readFile ( path . join ( directoryPath , '.gitignore' ) , 'utf8' )
139+ . then ( ( content ) => {
140+ this . #matchers. set ( directoryPath , ignore ( ) . add ( content ) ) ;
141+ } )
142+ . catch ( ( ) => undefined ) ;
143+
144+ this . #loads. set ( directoryPath , loading ) ;
145+ return loading ;
146+ }
147+
148+ #isDirectoryIgnored(
149+ directoryPath : string ,
150+ relativePath = path . relative ( this . #rootPath, directoryPath ) ,
151+ ) : boolean {
152+ const cached = this . #ignoredDirectories. get ( directoryPath ) ;
153+ if ( cached !== undefined ) {
154+ return cached ;
155+ }
156+
157+ // Git cannot re-include a path below an ignored directory.
158+ const parentPath = path . dirname ( directoryPath ) ;
159+ const ignored =
160+ ( parentPath !== this . #rootPath && this . #isDirectoryIgnored( parentPath ) ) ||
161+ this . #matches( relativePath , true ) ;
162+ this . #ignoredDirectories. set ( directoryPath , ignored ) ;
163+ return ignored ;
164+ }
165+
166+ #matches( relativePath : string , isDirectory : boolean ) : boolean {
167+ const segments = relativePath . split ( path . sep ) ;
168+ let directoryPath = this . #rootPath;
169+ let pathFromMatcher = segments . join ( '/' ) ;
170+ let ignored = false ;
171+
172+ for ( const segment of segments ) {
173+ const matcher = this . #matchers. get ( directoryPath ) ;
174+ if ( matcher ) {
175+ const result = matcher . test ( isDirectory ? `${ pathFromMatcher } /` : pathFromMatcher ) ;
176+
177+ if ( result . ignored ) {
178+ ignored = true ;
179+ } else if ( result . unignored ) {
180+ ignored = false ;
181+ }
182+ }
183+
184+ directoryPath = path . join ( directoryPath , segment ) ;
185+ pathFromMatcher = pathFromMatcher . slice ( segment . length + 1 ) ;
186+ }
187+
188+ return ignored ;
189+ }
190+ }
191+
192+ const createTraversalOptions = (
193+ gitIgnore : GitIgnoreMatcher ,
194+ isIncluded ?: ( filePath : string ) => boolean ,
195+ ) => {
54196 // tiny-readdir passes only a path to `ignore`, so retain the dirent type briefly.
55197 const directories = new Set < string > ( ) ;
56198
@@ -62,18 +204,31 @@ const createTraversalOptions = (isIncluded?: (filePath: string) => boolean) => {
62204 return true ;
63205 }
64206
207+ if ( isDirectory ) {
208+ return gitIgnore . isIgnored ( targetPath , true ) ;
209+ }
210+
65211 return (
66- ! isDirectory &&
67- ( isBinaryPath ( targetPath ) || ( isIncluded !== undefined && ! isIncluded ( targetPath ) ) )
212+ isBinaryPath ( targetPath ) ||
213+ ( isIncluded !== undefined && ! isIncluded ( targetPath ) ) ||
214+ gitIgnore . isIgnored ( targetPath , false )
68215 ) ;
69216 } ,
70- onDirents : ( dirents : Dirent [ ] ) => {
217+ onDirents : async ( dirents : Dirent [ ] ) => {
71218 const parentPath = getDirentParentPath ( dirents [ 0 ] ) ;
219+ let hasGitIgnore = false ;
72220
73221 for ( const dirent of dirents ) {
74222 if ( dirent . isDirectory ( ) ) {
75223 directories . add ( getDirentPath ( dirent , parentPath ) ) ;
76224 }
225+ if ( dirent . name === '.gitignore' ) {
226+ hasGitIgnore = true ;
227+ }
228+ }
229+
230+ if ( hasGitIgnore ) {
231+ await gitIgnore . load ( parentPath ) ;
77232 }
78233
79234 return undefined ;
@@ -192,34 +347,42 @@ const discoverFmtPaths = async ({
192347 const candidates = new Set ( explicitFiles ) ;
193348 const traversalRoots = getTraversalRoots ( cwd , directoryRoots , globs ) ;
194349
195- const results = await Promise . all (
196- traversalRoots . map ( async ( rootPath ) => {
197- const stats = await lstatSafe ( rootPath ) ;
198- if ( ! stats ?. isDirectory ( ) ) {
199- return [ ] ;
200- }
350+ if ( traversalRoots . length ) {
351+ const gitIgnore = await GitIgnoreMatcher . create ( cwd ) ;
352+ const results = await Promise . all (
353+ traversalRoots . map ( async ( rootPath ) => {
354+ const stats = await lstatSafe ( rootPath ) ;
355+ if ( ! stats ?. isDirectory ( ) ) {
356+ return [ ] ;
357+ }
201358
202- const includesAll = directoryRoots . some ( ( directoryPath ) =>
203- isPathInside ( directoryPath , rootPath ) ,
204- ) ;
205- const isIncluded = includesAll
206- ? undefined
207- : ( filePath : string ) : boolean => {
208- if ( directoryRoots . some ( ( directoryPath ) => isPathInside ( directoryPath , filePath ) ) ) {
209- return true ;
210- }
211-
212- const relativePath = toPosixPath ( path . relative ( cwd , filePath ) ) ;
213- return globMatchers . some ( ( matches ) => matches ( relativePath ) ) ;
214- } ;
215-
216- return ( await readdir ( rootPath , createTraversalOptions ( isIncluded ) ) ) . files ;
217- } ) ,
218- ) ;
359+ await gitIgnore . loadThrough ( rootPath ) ;
360+ if ( gitIgnore . isIgnored ( rootPath , true ) ) {
361+ return [ ] ;
362+ }
219363
220- for ( const files of results ) {
221- for ( const filePath of files ) {
222- candidates . add ( filePath ) ;
364+ const includesAll = directoryRoots . some ( ( directoryPath ) =>
365+ isPathInside ( directoryPath , rootPath ) ,
366+ ) ;
367+ const isIncluded = includesAll
368+ ? undefined
369+ : ( filePath : string ) : boolean => {
370+ if ( directoryRoots . some ( ( directoryPath ) => isPathInside ( directoryPath , filePath ) ) ) {
371+ return true ;
372+ }
373+
374+ const relativePath = toPosixPath ( path . relative ( cwd , filePath ) ) ;
375+ return globMatchers . some ( ( matches ) => matches ( relativePath ) ) ;
376+ } ;
377+
378+ return ( await readdir ( rootPath , createTraversalOptions ( gitIgnore , isIncluded ) ) ) . files ;
379+ } ) ,
380+ ) ;
381+
382+ for ( const files of results ) {
383+ for ( const filePath of files ) {
384+ candidates . add ( filePath ) ;
385+ }
223386 }
224387 }
225388
0 commit comments