lint-staged 16.1.6 → 16.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/loadConfig.js CHANGED
@@ -2,30 +2,28 @@
2
2
 
3
3
  import fs from 'node:fs/promises'
4
4
  import path from 'node:path'
5
+ import { pathToFileURL } from 'node:url'
5
6
 
6
- import debug from 'debug'
7
7
  import YAML from 'yaml'
8
8
 
9
- import {
10
- CONFIG_FILE_NAMES,
11
- CONFIG_NAME,
12
- PACKAGE_JSON_FILE,
13
- PACKAGE_YAML_FILES,
14
- } from './configFiles.js'
15
- import { dynamicImport } from './dynamicImport.js'
9
+ import { CONFIG_NAME, PACKAGE_JSON_FILE, PACKAGE_YAML_FILES } from './configFiles.js'
10
+ import { createDebug } from './debug.js'
16
11
  import { failedToLoadConfig } from './messages.js'
17
12
  import { resolveConfig } from './resolveConfig.js'
18
13
 
19
- const debugLog = debug('lint-staged:loadConfig')
14
+ const debugLog = createDebug('lint-staged:loadConfig')
20
15
 
21
- const jsonParse = (filePath, content) => {
22
- const isPackageFile = PACKAGE_JSON_FILE.includes(path.basename(filePath))
16
+ const readFile = async (filename) => fs.readFile(path.resolve(filename), 'utf-8')
17
+
18
+ const jsonParse = async (filename) => {
19
+ const isPackageFile = PACKAGE_JSON_FILE.includes(path.basename(filename))
23
20
  try {
21
+ const content = await readFile(filename)
24
22
  const json = JSON.parse(content)
25
23
  return isPackageFile ? json[CONFIG_NAME] : json
26
24
  } catch (error) {
27
- if (path.basename(filePath) === PACKAGE_JSON_FILE) {
28
- debugLog('Ignoring invalid package file `%s` with content:\n%s', filePath, content)
25
+ if (path.basename(filename) === PACKAGE_JSON_FILE) {
26
+ debugLog('Ignoring invalid JSON file %s', filename)
29
27
  return undefined
30
28
  }
31
29
 
@@ -33,14 +31,15 @@ const jsonParse = (filePath, content) => {
33
31
  }
34
32
  }
35
33
 
36
- const yamlParse = (filePath, content) => {
37
- const isPackageFile = PACKAGE_YAML_FILES.includes(path.basename(filePath))
34
+ const yamlParse = async (filename) => {
35
+ const isPackageFile = PACKAGE_YAML_FILES.includes(path.basename(filename))
38
36
  try {
37
+ const content = await readFile(filename)
39
38
  const yaml = YAML.parse(content)
40
39
  return isPackageFile ? yaml[CONFIG_NAME] : yaml
41
40
  } catch (error) {
42
41
  if (isPackageFile) {
43
- debugLog('Ignoring invalid package file `%s` with content:\n%s', filePath, content)
42
+ debugLog('Ignoring invalid YAML file %s', filename)
44
43
  return undefined
45
44
  }
46
45
 
@@ -48,6 +47,8 @@ const yamlParse = (filePath, content) => {
48
47
  }
49
48
  }
50
49
 
50
+ export const dynamicImport = (path) => import(pathToFileURL(path)).then((module) => module.default)
51
+
51
52
  const NO_EXT = 'noExt'
52
53
 
53
54
  /**
@@ -69,54 +70,30 @@ const loaders = {
69
70
  '.yml': yamlParse,
70
71
  }
71
72
 
72
- const readFile = async (filepath) => {
73
- const absolutePath = path.resolve(filepath)
74
- return fs.readFile(absolutePath, 'utf-8')
75
- }
76
-
77
- const loadConfigByExt = async (filepath) => {
78
- filepath = path.resolve(filepath)
73
+ const loadConfigByExt = async (filename) => {
74
+ const filepath = path.resolve(filename)
79
75
  const ext = path.extname(filepath) || NO_EXT
80
76
  const loader = loaders[ext]
77
+ const config = await loader(filepath)
81
78
 
82
- /**
83
- * No need to read file contents when loader only takes in the filepath argument
84
- * and reads itself; this is for `lilconfig` compatibility
85
- */
86
- const content = loader.length > 1 ? await readFile(filepath) : undefined
87
-
88
- return {
89
- config: await loader(filepath, content),
90
- filepath,
91
- }
79
+ return { config, filepath }
92
80
  }
93
81
 
94
- /**
95
- * @param {object} options
96
- * @param {string} [options.configPath] - Explicit path to a config file
97
- * @param {string} [options.cwd] - Current working directory
98
- */
99
- export const loadConfig = async ({ configPath, cwd }, logger) => {
82
+ /** @param {string} configPath */
83
+ export const loadConfig = async (configPath, logger) => {
100
84
  try {
101
- let result
102
-
103
- if (configPath) {
104
- debugLog('Loading configuration from `%s`...', configPath)
105
- result = await loadConfigByExt(resolveConfig(configPath))
106
- } else {
107
- debugLog('Searching for configuration from `%s`...', cwd)
108
- const { lilconfig } = await import('lilconfig')
109
- const explorer = lilconfig(CONFIG_NAME, { searchPlaces: CONFIG_FILE_NAMES, loaders })
110
- result = await explorer.search(cwd)
111
- }
112
-
113
- if (!result) return {}
85
+ debugLog('Loading configuration from `%s`...', configPath)
86
+ const result = await loadConfigByExt(resolveConfig(configPath))
114
87
 
115
88
  // config is a promise when using the `dynamicImport` loader
116
89
  const config = (await result.config) ?? null
117
90
  const filepath = result.filepath
118
91
 
119
- debugLog('Successfully loaded config from `%s`:\n%O', filepath, config)
92
+ if (config) {
93
+ debugLog('Successfully loaded config from `%s`:\n%O', filepath, config)
94
+ } else {
95
+ debugLog('Found no config in %s', filepath)
96
+ }
120
97
 
121
98
  return { config, filepath }
122
99
  } catch (error) {
package/lib/messages.js CHANGED
@@ -1,22 +1,21 @@
1
1
  import { inspect } from 'node:util'
2
2
 
3
- import chalk from 'chalk'
4
-
3
+ import { bold, red, yellow } from './colors.js'
5
4
  import { error, info, warning } from './figures.js'
6
5
 
7
6
  export const configurationError = (opt, helpMsg, value) =>
8
- `${chalk.redBright(`${error} Validation Error:`)}
7
+ `${red(`${error} Validation Error:`)}
9
8
 
10
- Invalid value for '${chalk.bold(opt)}': ${chalk.bold(inspect(value))}
9
+ Invalid value for '${bold(opt)}': ${bold(inspect(value))}
11
10
 
12
11
  ${helpMsg}`
13
12
 
14
- export const NOT_GIT_REPO = chalk.redBright(`${error} Current directory is not a git directory!`)
13
+ export const NOT_GIT_REPO = red(`${error} Current directory is not a git directory!`)
15
14
 
16
- export const FAILED_GET_STAGED_FILES = chalk.redBright(`${error} Failed to get staged files!`)
15
+ export const FAILED_GET_STAGED_FILES = red(`${error} Failed to get staged files!`)
17
16
 
18
17
  export const incorrectBraces = (before, after) =>
19
- chalk.yellow(
18
+ yellow(
20
19
  `${warning} Detected incorrect braces with only single value: \`${before}\`. Reformatted as: \`${after}\`
21
20
  `
22
21
  )
@@ -34,54 +33,54 @@ export const skippingBackup = (hasInitialCommit, diff) => {
34
33
  : (hasInitialCommit ? '`--no-stash` was used' : 'there’s no initial commit yet') +
35
34
  '. This might result in data loss'
36
35
 
37
- return chalk.yellow(`${warning} Skipping backup because ${reason}.\n`)
36
+ return yellow(`${warning} Skipping backup because ${reason}.\n`)
38
37
  }
39
38
 
40
- export const SKIPPING_HIDE_PARTIALLY_CHANGED = chalk.yellow(
39
+ export const SKIPPING_HIDE_PARTIALLY_CHANGED = yellow(
41
40
  `${warning} Skipping hiding unstaged changes from partially staged files because \`--no-hide-partially-staged\` was used.\n`
42
41
  )
43
42
 
44
- export const DEPRECATED_GIT_ADD = chalk.yellow(
43
+ export const DEPRECATED_GIT_ADD = yellow(
45
44
  `${warning} Some of your tasks use \`git add\` command. Please remove it from the config since all modifications made by tasks will be automatically added to the git commit index.
46
45
  `
47
46
  )
48
47
 
49
48
  export const TASK_ERROR = 'Skipped because of errors from tasks.'
50
49
 
50
+ export const PREVENTED_TASK_MODIFICATIONS = `\n${error} lint-staged failed because \`--fail-on-changes\` was used.`
51
+
51
52
  export const SKIPPED_GIT_ERROR = 'Skipped because of previous git error.'
52
53
 
53
- export const GIT_ERROR = `\n ${chalk.redBright(`${error} lint-staged failed due to a git error.`)}`
54
+ export const GIT_ERROR = `\n ${red(`${error} lint-staged failed due to a git error.`)}`
54
55
 
55
- export const invalidOption = (name, value, message) => `${chalk.redBright(
56
- `${error} Validation Error:`
57
- )}
56
+ export const invalidOption = (name, value, message) => `${red(`${error} Validation Error:`)}
58
57
 
59
- Invalid value for option '${chalk.bold(name)}': ${chalk.bold(value)}
58
+ Invalid value for option '${bold(name)}': ${bold(value)}
60
59
 
61
60
  ${message}
62
61
 
63
62
  See https://github.com/okonet/lint-staged#command-line-flags`
64
63
 
65
64
  export const PREVENTED_EMPTY_COMMIT = `
66
- ${chalk.yellow(`${warning} lint-staged prevented an empty git commit.
65
+ ${yellow(`${warning} lint-staged prevented an empty git commit.
67
66
  Use the --allow-empty option to continue, or check your task configuration`)}
68
67
  `
69
68
 
70
69
  export const RESTORE_STASH_EXAMPLE = `Any lost modifications can be restored from a git stash:
71
70
 
72
- > git stash list
73
- stash@{0}: automatic lint-staged backup
74
- > git stash apply --index stash@{0}`
71
+ > git stash list --format="%h %s"
72
+ h0a0s0h0 On main: lint-staged automatic backup
73
+ > git apply --index h0a0s0h0`
75
74
 
76
- export const CONFIG_STDIN_ERROR = chalk.redBright(`${error} Failed to read config from stdin.`)
75
+ export const CONFIG_STDIN_ERROR = red(`${error} Failed to read config from stdin.`)
77
76
 
78
77
  export const failedToLoadConfig = (filepath) =>
79
- chalk.redBright(`${error} Failed to read config from file "${filepath}".`)
78
+ red(`${error} Failed to read config from file "${filepath}".`)
80
79
 
81
80
  export const failedToParseConfig = (
82
81
  filepath,
83
82
  error
84
- ) => `${chalk.redBright(`${error} Failed to parse config from file "${filepath}".`)}
83
+ ) => `${red(`${error} Failed to parse config from file "${filepath}".`)}
85
84
 
86
85
  ${error}
87
86
 
@@ -1,11 +1,10 @@
1
1
  import path from 'node:path'
2
2
 
3
- import debug from 'debug'
4
-
3
+ import { createDebug } from './debug.js'
5
4
  import { execGit } from './execGit.js'
6
5
  import { normalizePath } from './normalizePath.js'
7
6
 
8
- const debugLog = debug('lint-staged:resolveGitRepo')
7
+ const debugLog = createDebug('lint-staged:resolveGitRepo')
9
8
 
10
9
  /**
11
10
  * Relative path up to the repo top-level directory
package/lib/runAll.js CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  import path from 'node:path'
4
4
 
5
- import chalk from 'chalk'
6
- import debug from 'debug'
7
5
  import { Listr } from 'listr2'
8
6
 
9
7
  import { chunkFiles } from './chunkFiles.js'
8
+ import { blackBright } from './colors.js'
9
+ import { createDebug } from './debug.js'
10
10
  import { execGit } from './execGit.js'
11
11
  import { generateTasks } from './generateTasks.js'
12
12
  import { getFunctionTask, isFunctionTask } from './getFunctionTask.js'
@@ -37,10 +37,11 @@ import {
37
37
  restoreOriginalStateSkipped,
38
38
  restoreUnstagedChangesSkipped,
39
39
  shouldHidePartiallyStagedFiles,
40
+ shouldRestoreUnstagedChanges,
40
41
  } from './state.js'
41
42
  import { ConfigNotFoundError, GetStagedFilesError, GitError, GitRepoError } from './symbols.js'
42
43
 
43
- const debugLog = debug('lint-staged:runAll')
44
+ const debugLog = createDebug('lint-staged:runAll')
44
45
 
45
46
  /**
46
47
  * @param {ReturnType<typeof getInitialState>} ctx context
@@ -54,15 +55,20 @@ const createError = (ctx, cause) =>
54
55
  *
55
56
  * @param {object} options
56
57
  * @param {boolean} [options.allowEmpty] - Allow empty commits when tasks revert all staged changes
58
+ * @param {boolean} [options.color] - Enable or disable ANSI color codes in output.
57
59
  * @param {boolean | number} [options.concurrent] - The number of tasks to run concurrently, or false to run tasks serially
58
60
  * @param {Object} [options.configObject] - Explicit config object from the js API
59
61
  * @param {string} [options.configPath] - Explicit path to a config file
62
+ * @param {boolean} [options.continueOnError] - Run all tasks to completion even if one fails
60
63
  * @param {string} [options.cwd] - Current working directory
61
64
  * @param {boolean} [options.debug] - Enable debug mode
62
65
  * @param {string} [options.diff] - Override the default "--staged" flag of "git diff" to get list of files
63
66
  * @param {string} [options.diffFilter] - Override the default "--diff-filter=ACMR" flag of "git diff" to get list of files
67
+ * @param {boolean} [options.failOnChanges] - Fail with exit code 1 when tasks modify tracked files
68
+ * @param {boolean} [options.hidePartiallyStaged] - Whether to hide unstaged changes from partially staged files before running tasks
69
+ * @param {boolean} [options.hideUnstaged] - Whether to hide all unstaged changes before running tasks
64
70
  * @param {number} [options.maxArgLength] - Maximum argument string length
65
- * @param {boolean} [options.quiet] - Disable lint-stageds own console output
71
+ * @param {boolean} [options.quiet] - Disable lint-staged's own console output
66
72
  * @param {boolean} [options.relative] - Pass relative filepaths to tasks
67
73
  * @param {boolean} [options.revert] - revert to original state in case of errors
68
74
  * @param {boolean} [options.stash] - Enable the backup stash, and revert in case of errors
@@ -73,13 +79,18 @@ const createError = (ctx, cause) =>
73
79
  export const runAll = async (
74
80
  {
75
81
  allowEmpty = false,
82
+ color = false,
76
83
  concurrent = true,
77
84
  configObject,
78
85
  configPath,
86
+ continueOnError = false,
79
87
  cwd,
80
88
  debug = false,
81
89
  diff,
82
90
  diffFilter,
91
+ failOnChanges = false,
92
+ hideUnstaged = false,
93
+ hidePartiallyStaged = !hideUnstaged,
83
94
  maxArgLength,
84
95
  quiet = false,
85
96
  relative = false,
@@ -87,7 +98,6 @@ export const runAll = async (
87
98
  stash = diff === undefined,
88
99
  // Cannot revert to original state without stash
89
100
  revert = stash,
90
- hidePartiallyStaged = true,
91
101
  verbose = false,
92
102
  },
93
103
  logger = console
@@ -99,7 +109,12 @@ export const runAll = async (
99
109
  cwd = hasExplicitCwd ? path.resolve(cwd) : process.cwd()
100
110
  debugLog('Using working directory `%s`', cwd)
101
111
 
102
- const ctx = getInitialState({ quiet, revert })
112
+ const ctx = getInitialState({
113
+ hidePartiallyStaged,
114
+ hideUnstaged,
115
+ quiet,
116
+ revert,
117
+ })
103
118
 
104
119
  const { topLevelDir, gitConfigDir } = await resolveGitRepo(cwd)
105
120
  if (!topLevelDir) {
@@ -121,12 +136,16 @@ export const runAll = async (
121
136
  logger.warn(skippingBackup(hasInitialCommit, diff))
122
137
  }
123
138
 
124
- ctx.shouldHidePartiallyStaged = hidePartiallyStaged
125
- if (!ctx.shouldHidePartiallyStaged && !quiet) {
139
+ if (!ctx.shouldHidePartiallyStaged && !ctx.shouldHideUnstaged && !quiet) {
126
140
  logger.warn(SKIPPING_HIDE_PARTIALLY_CHANGED)
127
141
  }
128
142
 
129
- const stagedFiles = await getStagedFiles({ cwd: topLevelDir, diff, diffFilter })
143
+ // Run staged files retrieval and config search in parallel since they're independent
144
+ const [stagedFiles, foundConfigs] = await Promise.all([
145
+ getStagedFiles({ cwd: topLevelDir, diff, diffFilter }),
146
+ searchConfigs({ configObject, configPath, cwd, topLevelDir }, logger),
147
+ ])
148
+
130
149
  if (!stagedFiles) {
131
150
  if (!quiet) ctx.output.push(FAILED_GET_STAGED_FILES)
132
151
  ctx.errors.add(GetStagedFilesError)
@@ -140,7 +159,6 @@ export const runAll = async (
140
159
  return ctx
141
160
  }
142
161
 
143
- const foundConfigs = await searchConfigs({ configObject, configPath, cwd, topLevelDir }, logger)
144
162
  const numberOfConfigs = Object.keys(foundConfigs).length
145
163
 
146
164
  // Throw if no configurations were found
@@ -165,7 +183,7 @@ export const runAll = async (
165
183
  ctx,
166
184
  exitOnError: false,
167
185
  registerSignalListeners: false,
168
- ...getRenderer({ debug, quiet }, logger),
186
+ ...getRenderer({ color, debug, quiet }, logger),
169
187
  }
170
188
 
171
189
  /**
@@ -203,6 +221,7 @@ export const runAll = async (
203
221
  (isFunctionTask(task.commands)
204
222
  ? getFunctionTask(task.commands, task.fileList)
205
223
  : getSpawnedTasks({
224
+ color,
206
225
  commands: task.commands,
207
226
  cwd: groupCwd,
208
227
  files: task.fileList,
@@ -231,19 +250,19 @@ export const runAll = async (
231
250
  const fileCount = task.fileList.length
232
251
 
233
252
  return {
234
- title: `${task.pattern}${chalk.dim(
253
+ title: `${task.pattern}${blackBright(
235
254
  ` — ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`
236
255
  )}`,
237
256
  task: async (ctx, task) =>
238
257
  task.newListr(
239
258
  subTasks,
240
259
  // Subtasks should not run in parallel, and should exit on error
241
- { concurrent: false, exitOnError: true }
260
+ { concurrent: false, exitOnError: !continueOnError }
242
261
  ),
243
262
  skip: () => {
244
263
  // Skip task when no files matched
245
264
  if (fileCount === 0) {
246
- return `${task.pattern}${chalk.dim(' — no files')}`
265
+ return `${task.pattern}${blackBright(' — no files')}`
247
266
  }
248
267
  return false
249
268
  },
@@ -256,15 +275,16 @@ export const runAll = async (
256
275
 
257
276
  listrTasks.push({
258
277
  title:
259
- `${configName}${chalk.dim(` — ${files.length} ${files.length > 1 ? 'files' : 'file'}`)}` +
260
- (chunkCount > 1 ? chalk.dim(` (chunk ${index + 1}/${chunkCount})...`) : ''),
261
- task: (ctx, task) => task.newListr(chunkListrTasks, { concurrent, exitOnError: true }),
278
+ `${configName}${blackBright(` — ${files.length} ${files.length > 1 ? 'files' : 'file'}`)}` +
279
+ (chunkCount > 1 ? blackBright(` (chunk ${index + 1}/${chunkCount})...`) : ''),
280
+ task: (ctx, task) =>
281
+ task.newListr(chunkListrTasks, { concurrent, exitOnError: !continueOnError }),
262
282
  skip: () => {
263
283
  // Skip if the first step (backup) failed
264
284
  if (ctx.errors.has(GitError)) return SKIPPED_GIT_ERROR
265
285
  // Skip chunk when no every task is skipped (due to no matches)
266
286
  if (chunkListrTasks.every((task) => task.skip())) {
267
- return `${configName}${chalk.dim(' — no tasks to run')}`
287
+ return `${configName}${blackBright(' — no tasks to run')}`
268
288
  }
269
289
  return false
270
290
  },
@@ -295,11 +315,12 @@ export const runAll = async (
295
315
 
296
316
  const git = new GitWorkflow({
297
317
  allowEmpty,
298
- gitConfigDir,
299
- topLevelDir,
300
- matchedFileChunks,
301
318
  diff,
302
319
  diffFilter,
320
+ failOnChanges,
321
+ gitConfigDir,
322
+ matchedFileChunks,
323
+ topLevelDir,
303
324
  })
304
325
 
305
326
  const runner = new Listr(
@@ -310,7 +331,7 @@ export const runAll = async (
310
331
  },
311
332
  {
312
333
  title: 'Hiding unstaged changes to partially staged files...',
313
- task: (ctx) => git.hideUnstagedChanges(ctx),
334
+ task: (ctx) => git.hidePartiallyStagedChanges(ctx),
314
335
  enabled: shouldHidePartiallyStagedFiles,
315
336
  },
316
337
  {
@@ -324,9 +345,9 @@ export const runAll = async (
324
345
  skip: applyModificationsSkipped,
325
346
  },
326
347
  {
327
- title: 'Restoring unstaged changes to partially staged files...',
348
+ title: 'Restoring unstaged changes...',
328
349
  task: (ctx) => git.restoreUnstagedChanges(ctx),
329
- enabled: shouldHidePartiallyStagedFiles,
350
+ enabled: shouldRestoreUnstagedChanges,
330
351
  skip: restoreUnstagedChangesSkipped,
331
352
  },
332
353
  {
@@ -1,17 +1,17 @@
1
1
  /** @typedef {import('./index').Logger} Logger */
2
2
 
3
+ import fs, { constants } from 'node:fs/promises'
3
4
  import path from 'node:path'
4
5
 
5
- import debug from 'debug'
6
-
7
6
  import { CONFIG_FILE_NAMES } from './configFiles.js'
7
+ import { createDebug } from './debug.js'
8
8
  import { execGit } from './execGit.js'
9
9
  import { loadConfig } from './loadConfig.js'
10
10
  import { normalizePath } from './normalizePath.js'
11
11
  import { parseGitZOutput } from './parseGitZOutput.js'
12
12
  import { validateConfig } from './validateConfig.js'
13
13
 
14
- const debugLog = debug('lint-staged:searchConfigs')
14
+ const debugLog = createDebug('lint-staged:searchConfigs')
15
15
 
16
16
  const EXEC_GIT = ['ls-files', '-z', '--full-name', '-t']
17
17
 
@@ -19,8 +19,89 @@ const CONFIG_PATHSPEC = CONFIG_FILE_NAMES.map((f) => `:(glob)**/${f}`)
19
19
 
20
20
  const numberOfLevels = (file) => file.split('/').length
21
21
 
22
+ const sortAlphabetically = (a, b) => a.localeCompare(b)
23
+
22
24
  const sortDeepestParth = (a, b) => (numberOfLevels(a) > numberOfLevels(b) ? -1 : 1)
23
25
 
26
+ /**
27
+ * Get all possible config files from git
28
+ *
29
+ * @param {object} options
30
+ * @param {string} options.cwd
31
+ * @param {string} options.topLevelDir
32
+ * @returns {Promise<string[]>}
33
+ */
34
+ const listConfigFilesFromGit = async ({ cwd, topLevelDir }) =>
35
+ execGit(
36
+ [
37
+ ...EXEC_GIT,
38
+ '--cached', // show all tracked files
39
+ '--others', // show untracked files
40
+ '--exclude-standard', // apply standard git exclusions (.gitignore, etc.)
41
+ '--',
42
+ ...CONFIG_PATHSPEC,
43
+ ],
44
+ { cwd }
45
+ )
46
+ .then(parseGitZOutput)
47
+ .then((lines) => {
48
+ const possibleConfigFiles = lines.flatMap((line) => {
49
+ /**
50
+ * Leave out lines starting with "S " to ignore not-checked-out files in a sparse repo.
51
+ * The "S" status means a tracked file that is "skip-worktree"
52
+ * @see https://git-scm.com/docs/git-ls-files#Documentation/git-ls-files.txt--t
53
+ */
54
+ if (line.startsWith('S ')) {
55
+ return []
56
+ }
57
+
58
+ const relativePath = line.replace(/^[HSMRCK?U] /, '')
59
+ const absolutePath = normalizePath(path.join(topLevelDir, relativePath))
60
+ return [absolutePath]
61
+ })
62
+
63
+ debugLog('Found possible config files from git:', possibleConfigFiles)
64
+
65
+ return possibleConfigFiles
66
+ })
67
+
68
+ /**
69
+ * Get all possible config files from filesystem, starting from `cwd` and
70
+ * moving upwards if nothing is found.
71
+ *
72
+ * @param {object} options
73
+ * @param {string} options.cwd
74
+ * @param {string[]} [possibleConfigFiles]
75
+ * @returns {Promise<string[]>}
76
+ */
77
+ export const listConfigFilesFromFs = async ({ cwd }) => {
78
+ debugLog('Listing possible configs from filesystem starting from "%s"...', cwd)
79
+
80
+ const results = await Promise.allSettled(
81
+ CONFIG_FILE_NAMES.map(async (f) => {
82
+ const filepath = path.join(cwd, f)
83
+ await fs.access(filepath, constants.F_OK)
84
+ return filepath
85
+ })
86
+ )
87
+
88
+ const possibleConfigFiles = results.flatMap((r) =>
89
+ r.status === 'fulfilled' ? [normalizePath(r.value)] : []
90
+ )
91
+
92
+ if (possibleConfigFiles.length > 0) {
93
+ debugLog('Found possible config files from filesystem:', possibleConfigFiles)
94
+ return possibleConfigFiles
95
+ }
96
+
97
+ const parentDir = path.dirname(cwd)
98
+ if (parentDir === cwd) {
99
+ return [] /** Root-level / */
100
+ }
101
+
102
+ return listConfigFilesFromFs({ cwd: parentDir })
103
+ }
104
+
24
105
  /**
25
106
  * Search all config files from the git repository, preferring those inside `cwd`.
26
107
  *
@@ -28,6 +109,7 @@ const sortDeepestParth = (a, b) => (numberOfLevels(a) > numberOfLevels(b) ? -1 :
28
109
  * @param {Object} [options.configObject] - Explicit config object from the js API
29
110
  * @param {string} [options.configPath] - Explicit path to a config file
30
111
  * @param {string} [options.cwd] - Current working directory
112
+ * @param {string} [options.topLevelDir] - Top-level directory of the git repo
31
113
  * @param {Logger} logger
32
114
  *
33
115
  * @returns {Promise<{ [key: string]: { config: *, files: string[] } }>} found configs with filepath as key, and config as value
@@ -49,57 +131,35 @@ export const searchConfigs = async (
49
131
  if (configPath) {
50
132
  debugLog('Using single configuration path...')
51
133
 
52
- const { config, filepath } = await loadConfig({ configPath }, logger)
134
+ const { config, filepath } = await loadConfig(configPath, logger)
53
135
 
54
136
  if (!config) return {}
55
137
  return { [configPath]: validateConfig(config, filepath, logger) }
56
138
  }
57
139
 
58
- /** Get all possible config files from git (both cached and uncommitted) */
59
- const gitListedFiles = await execGit(
60
- [
61
- ...EXEC_GIT,
62
- '--cached', // show all tracked files
63
- '--others', // show untracked files
64
- '--exclude-standard', // apply standard git exclusions (.gitignore, etc.)
65
- '--',
66
- ...CONFIG_PATHSPEC,
67
- ],
68
- { cwd }
69
- ).then(parseGitZOutput)
70
-
71
- debugLog('Git listed files matching config files:', gitListedFiles)
72
-
73
- /** Sort possible config files so that deepest is first */
74
- const possibleConfigFiles = gitListedFiles
75
- .flatMap((line) => {
76
- /**
77
- * Leave out lines starting with "S " to ignore not-checked-out files in a sparse repo.
78
- * The "S" status means a tracked file that is "skip-worktree"
79
- * @see https://git-scm.com/docs/git-ls-files#Documentation/git-ls-files.txt--t
80
- */
81
- if (line.startsWith('S ')) {
82
- return []
83
- }
84
-
85
- const relativePath = line.replace(/^[HSMRCK?U] /, '')
86
- const absolutePath = normalizePath(path.join(topLevelDir, relativePath))
87
- return [absolutePath]
140
+ const possibleConfigFiles = new Set()
141
+
142
+ const addToSet = (files) => {
143
+ files.forEach((f) => {
144
+ possibleConfigFiles.add(f)
88
145
  })
89
- .sort(sortDeepestParth)
146
+ }
90
147
 
91
- debugLog('Found possible config files:', possibleConfigFiles)
148
+ await Promise.all([
149
+ listConfigFilesFromGit({ cwd, topLevelDir }).then(addToSet),
150
+ listConfigFilesFromFs({ cwd }).then(addToSet),
151
+ ])
92
152
 
93
153
  /** Create object with key as config file, and value as null */
94
- const configs = possibleConfigFiles.reduce(
95
- (acc, configPath) => Object.assign(acc, { [configPath]: null }),
96
- {}
97
- )
154
+ const configs = Array.from(possibleConfigFiles)
155
+ .sort(sortAlphabetically)
156
+ .sort(sortDeepestParth)
157
+ .reduce((acc, configPath) => Object.assign(acc, { [configPath]: null }), {})
98
158
 
99
159
  /** Load and validate all configs to the above object */
100
160
  await Promise.all(
101
161
  Object.keys(configs).map((configPath) =>
102
- loadConfig({ configPath }, logger).then(({ config, filepath }) => {
162
+ loadConfig(configPath, logger).then(({ config, filepath }) => {
103
163
  if (config) {
104
164
  if (configPath !== filepath) {
105
165
  debugLog('Config file "%s" resolved to "%s"', configPath, filepath)
@@ -112,26 +172,13 @@ export const searchConfigs = async (
112
172
  )
113
173
 
114
174
  /** Get validated configs from the above object, without any `null` values (not found) */
115
- const foundConfigs = Object.entries(configs)
116
- .filter(([, value]) => !!value)
117
- .reduce((acc, [key, value]) => Object.assign(acc, { [key]: value }), {})
118
-
119
- /**
120
- * Try to find a single config from parent directories
121
- * to match old behavior before monorepo support
122
- */
123
- if (!Object.keys(foundConfigs).length) {
124
- debugLog('Could not find config files inside "%s"', cwd)
125
-
126
- const { config, filepath } = await loadConfig({ cwd }, logger)
127
- if (config) {
128
- debugLog('Found parent configuration file from "%s"', filepath)
129
-
130
- foundConfigs[filepath] = validateConfig(config, filepath, logger)
131
- } else {
132
- debugLog('Could not find parent configuration files from "%s"', cwd)
175
+ const foundConfigs = Object.entries(configs).reduce((acc, [key, value]) => {
176
+ if (value) {
177
+ Object.assign(acc, { [key]: value })
133
178
  }
134
- }
179
+
180
+ return acc
181
+ }, {})
135
182
 
136
183
  debugLog('Found %d config files', Object.keys(foundConfigs).length)
137
184