Skip to content

Commit 56a36d5

Browse files
authored
Replace fast-glob with custom fs.walk (#1210)
Fixes #1182 This implementation is using `micromatch` (a dependency of fast-glob) and `fs.walk` to traverse the file system itself, instead of relying on fast-glob. As opposed to the previous implementation (from #1209) This implementation takes every .gitignore file into account, not just the root one. The callbacks `entryFilter` and `deepFilter` are used to control which directories to recurse into. When `entryFilter` encounters a .gitignore file, it's patterns are parsed into micromatch compatible ones and every subsequent call to these filter functions respect them.
1 parent b5a8ab9 commit 56a36d5

File tree

5 files changed

+136
-42
lines changed

5 files changed

+136
-42
lines changed

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"fuse.js": "^6.5.3",
4242
"glob": "^7.1.6",
4343
"markdown-it": "^12.0.4",
44+
"micromatch": "^4.0.5",
4445
"open": "^9.1.0",
4546
"punycode": "^2.3.0",
4647
"semver": "^7.3.5",

src/Spago/Config.purs

Lines changed: 2 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -51,20 +51,19 @@ import Effect.Aff as Aff
5151
import Effect.Uncurried (EffectFn2, EffectFn3, runEffectFn2, runEffectFn3)
5252
import Foreign.Object as Foreign
5353
import Node.Path as Path
54-
import Registry.Foreign.FastGlob as Glob
5554
import Registry.Internal.Codec as Internal.Codec
5655
import Registry.PackageName as PackageName
5756
import Registry.PackageSet as Registry.PackageSet
5857
import Registry.Range as Range
5958
import Registry.Version as Version
6059
import Spago.Core.Config as Core
6160
import Spago.FS as FS
62-
import Spago.Git as Git
6361
import Spago.Lock (Lockfile, PackageSetInfo)
6462
import Spago.Lock as Lock
6563
import Spago.Paths as Paths
6664
import Spago.Registry as Registry
6765
import Spago.Yaml as Yaml
66+
import Spago.Glob as Glob
6867

6968
type Workspace =
7069
{ selected :: Maybe WorkspacePackage
@@ -163,31 +162,6 @@ type ReadWorkspaceOptions =
163162
, migrateConfig :: Boolean
164163
}
165164

166-
-- | Same as `Glob.match'` but if there is a .gitignore file in the same directory,
167-
-- | then the `ignore` option will be filled accordingly.
168-
-- | This function does not respect any .gitignore files in subdirectories.
169-
-- | Translation of: https://github.com/sindresorhus/globby/issues/50#issuecomment-467897064
170-
gitIgnoringGlob :: String -> Array String -> Spago (LogEnv _) { failed :: Array String, succeeded :: Array String }
171-
gitIgnoringGlob dir patterns = do
172-
gitignore <- try (liftAff $ FS.readTextFile $ Path.concat [ dir, ".gitignore" ]) >>= case _ of
173-
Left err -> do
174-
logDebug $ "Could not read .gitignore to exclude directories from globbing, error: " <> Aff.message err
175-
pure ""
176-
Right contents -> pure contents
177-
let
178-
isComment = isJust <<< String.stripPrefix (String.Pattern "#")
179-
dropPrefixSlashes line = maybe line dropPrefixSlashes $ String.stripPrefix (String.Pattern "/") line
180-
dropSuffixSlashes line = maybe line dropSuffixSlashes $ String.stripSuffix (String.Pattern "/") line
181-
182-
ignore :: Array String
183-
ignore =
184-
map (dropSuffixSlashes <<< dropPrefixSlashes)
185-
$ Array.filter (not <<< or [ String.null, isComment ])
186-
$ map String.trim
187-
$ String.split (String.Pattern "\n")
188-
$ gitignore
189-
liftAff $ Glob.match' dir patterns { ignore: [ ".spago" ] <> ignore }
190-
191165
-- | Reads all the configurations in the tree and builds up the Map of local
192166
-- | packages to be integrated in the package set
193167
readWorkspace :: ReadWorkspaceOptions -> Spago (Registry.RegistryEnv _) Workspace
@@ -222,23 +196,9 @@ readWorkspace { maybeSelectedPackage, pureBuild, migrateConfig } = do
222196
pure { workspace, package, workspaceDoc: doc }
223197

224198
logDebug "Gathering all the spago configs in the tree..."
225-
{ succeeded: otherConfigPaths, failed, ignored } <- do
226-
result <- gitIgnoringGlob Paths.cwd [ "**/spago.yaml" ]
227-
-- If a file is gitignored then we don't include it as a package
228-
let
229-
filterGitignored path = do
230-
Git.isIgnored path >>= case _ of
231-
true -> pure $ Left path
232-
false -> pure $ Right path
233-
{ right: newSucceeded, left: ignored } <- partitionMap identity
234-
<$> parTraverseSpago filterGitignored result.succeeded
235-
pure { succeeded: newSucceeded, failed: result.failed, ignored }
199+
otherConfigPaths <- liftAff $ Glob.gitignoringGlob Paths.cwd [ "**/spago.yaml" ]
236200
unless (Array.null otherConfigPaths) do
237201
logDebug $ [ toDoc "Found packages at these paths:", Log.indent $ Log.lines (map toDoc otherConfigPaths) ]
238-
unless (Array.null failed) do
239-
logDebug $ "Failed to sanitise some of the glob matches: " <> show failed
240-
unless (Array.null ignored) do
241-
logDebug $ "Ignored some of the glob matches as they are gitignored: " <> show ignored
242202

243203
-- We read all of them in, and only read the package section, if any.
244204
let

src/Spago/Glob.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import mm from 'micromatch';
2+
import * as fsWalk from '@nodelib/fs.walk';
3+
4+
export const micromatch = options => patterns => mm.matcher(patterns, options);
5+
6+
export const fsWalkImpl = Left => Right => respond => options => path => () => {
7+
const entryFilter = entry => options.entryFilter(entry)();
8+
const deepFilter = entry => options.deepFilter(entry)();
9+
fsWalk.walk(path, { entryFilter, deepFilter }, (error, entries) => {
10+
if (error !== null) return respond(Left(error))();
11+
return respond(Right(entries))();
12+
});
13+
};
14+
15+
export const isFile = dirent => dirent.isFile();
16+

src/Spago/Glob.purs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
module Spago.Glob (gitignoringGlob) where
2+
3+
import Spago.Prelude
4+
5+
import Data.Array as Array
6+
import Data.String as String
7+
import Effect.Aff as Aff
8+
import Effect.Ref as Ref
9+
import Node.FS.Sync as SyncFS
10+
import Node.Path as Path
11+
import Record as Record
12+
import Type.Proxy (Proxy(..))
13+
14+
type MicroMatchOptions = { ignore :: Array String }
15+
16+
foreign import micromatch :: MicroMatchOptions -> Array String -> String -> Boolean
17+
18+
type Entry = { name :: String, path :: String, dirent :: DirEnt }
19+
type FsWalkOptions = { entryFilter :: Entry -> Effect Boolean, deepFilter :: Entry -> Effect Boolean }
20+
21+
foreign import data DirEnt :: Type
22+
foreign import isFile :: DirEnt -> Boolean
23+
foreign import fsWalkImpl
24+
:: (forall a b. a -> Either a b)
25+
-> (forall a b. b -> Either a b)
26+
-> (Either Error (Array Entry) -> Effect Unit)
27+
-> FsWalkOptions
28+
-> String
29+
-> Effect Unit
30+
31+
gitignoreToMicromatchPatterns :: String -> String -> { ignore :: Array String, patterns :: Array String }
32+
gitignoreToMicromatchPatterns base =
33+
String.split (String.Pattern "\n")
34+
>>> map String.trim
35+
>>> Array.filter (not <<< or [ String.null, isComment ])
36+
>>> partitionMap
37+
( \line -> do
38+
let negated = isJust $ String.stripPrefix (String.Pattern "!") line
39+
let pattern = Path.concat [ base, gitignorePatternToMicromatch line ]
40+
if negated then Left pattern else Right pattern
41+
)
42+
>>> Record.rename (Proxy :: Proxy "left") (Proxy :: Proxy "ignore")
43+
>>> Record.rename (Proxy :: Proxy "right") (Proxy :: Proxy "patterns")
44+
45+
where
46+
isComment = isJust <<< String.stripPrefix (String.Pattern "#")
47+
dropSuffixSlash str = fromMaybe str $ String.stripSuffix (String.Pattern "/") str
48+
dropPrefixSlash str = fromMaybe str $ String.stripPrefix (String.Pattern "/") str
49+
50+
gitignorePatternToMicromatch :: String -> String
51+
gitignorePatternToMicromatch pattern
52+
-- Git matches every pattern that does not include a `/` by basename.
53+
| not $ String.contains (String.Pattern "/") pattern = "**/" <> pattern
54+
| otherwise =
55+
-- Micromatch treats every pattern like git treats those starting with '/'.
56+
dropPrefixSlash pattern
57+
-- ".spago/" in a .gitignore is the same as ".spago". Micromatch does interpret them differently.
58+
# dropSuffixSlash
59+
60+
fsWalk :: String -> Array String -> Array String -> Aff (Array Entry)
61+
fsWalk cwd ignorePatterns includePatterns = Aff.makeAff \cb -> do
62+
let includeMatcher = micromatch { ignore: [] } includePatterns -- The Stuff we are globbing for.
63+
64+
-- Pattern for directories which can be outright ignored.
65+
-- This will be updated whenver a .gitignore is found.
66+
ignoreMatcherRef <- Ref.new $ micromatch { ignore: [] } ignorePatterns
67+
68+
-- If this Ref contains `true` because this Aff has been canceled, then deepFilter will always return false.
69+
canceled <- Ref.new false
70+
let
71+
-- Should `fsWalk` recurse into this directory?
72+
deepFilter :: Entry -> Effect Boolean
73+
deepFilter entry = Ref.read canceled >>=
74+
if _ then
75+
-- The Aff has been canceled, don't recurse into any further directories!
76+
pure false
77+
else do
78+
matcher <- Ref.read ignoreMatcherRef
79+
pure $ not $ matcher (Path.relative cwd entry.path)
80+
81+
-- Should `fsWalk` retain this entry for the result array?
82+
entryFilter :: Entry -> Effect Boolean
83+
entryFilter entry = do
84+
when (isFile entry.dirent && entry.name == ".gitignore") do -- A .gitignore was encountered
85+
let gitignorePath = entry.path
86+
87+
-- directory of this .gitignore relative to the directory being globbed
88+
let base = Path.relative cwd (Path.dirname gitignorePath)
89+
90+
try (SyncFS.readTextFile UTF8 gitignorePath) >>= case _ of
91+
Left _ -> pure unit
92+
Right gitignore -> do
93+
let { ignore, patterns } = gitignoreToMicromatchPatterns base gitignore
94+
let matcherForThisGitignore = micromatch { ignore } patterns
95+
96+
-- Instead of composing the matcher functions, we could also keep a growing array of
97+
-- patterns and regenerate the matcher on every append. I don't know which option is
98+
-- more performant, but composing functions is more conventient.
99+
let addMatcher currentMatcher = or [ currentMatcher, matcherForThisGitignore ]
100+
void $ Ref.modify addMatcher ignoreMatcherRef
101+
102+
ignoreMatcher <- Ref.read ignoreMatcherRef
103+
let path = Path.relative cwd entry.path
104+
pure $ includeMatcher path && not (ignoreMatcher path)
105+
106+
options = { entryFilter, deepFilter }
107+
108+
fsWalkImpl Left Right cb options cwd
109+
110+
pure $ Aff.Canceler \_ ->
111+
void $ liftEffect $ Ref.write true canceled
112+
113+
gitignoringGlob :: String -> Array String -> Aff (Array String)
114+
gitignoringGlob dir patterns =
115+
map (Path.relative dir <<< _.path)
116+
<$> fsWalk dir [ ".git" ] patterns

0 commit comments

Comments
 (0)