UNPKG

10.9 kBJavaScriptView Raw
1/** @typedef {import('./index').Logger} Logger */
2
3import path from 'node:path'
4
5import { dim } from 'colorette'
6import debug from 'debug'
7import { Listr } from 'listr2'
8import normalize from 'normalize-path'
9
10import { chunkFiles } from './chunkFiles.js'
11import { execGit } from './execGit.js'
12import { generateTasks } from './generateTasks.js'
13import { getRenderer } from './getRenderer.js'
14import { getStagedFiles } from './getStagedFiles.js'
15import { GitWorkflow } from './gitWorkflow.js'
16import { groupFilesByConfig } from './groupFilesByConfig.js'
17import { makeCmdTasks } from './makeCmdTasks.js'
18import {
19 DEPRECATED_GIT_ADD,
20 FAILED_GET_STAGED_FILES,
21 NOT_GIT_REPO,
22 NO_STAGED_FILES,
23 NO_TASKS,
24 SKIPPED_GIT_ERROR,
25 skippingBackup,
26} from './messages.js'
27import { resolveGitRepo } from './resolveGitRepo.js'
28import {
29 applyModificationsSkipped,
30 cleanupEnabled,
31 cleanupSkipped,
32 getInitialState,
33 hasPartiallyStagedFiles,
34 restoreOriginalStateEnabled,
35 restoreOriginalStateSkipped,
36 restoreUnstagedChangesSkipped,
37} from './state.js'
38import { GitRepoError, GetStagedFilesError, GitError, ConfigNotFoundError } from './symbols.js'
39import { searchConfigs } from './searchConfigs.js'
40
41const debugLog = debug('lint-staged:runAll')
42
43const createError = (ctx) => Object.assign(new Error('lint-staged failed'), { ctx })
44
45/**
46 * Executes all tasks and either resolves or rejects the promise
47 *
48 * @param {object} options
49 * @param {boolean} [options.allowEmpty] - Allow empty commits when tasks revert all staged changes
50 * @param {boolean | number} [options.concurrent] - The number of tasks to run concurrently, or false to run tasks serially
51 * @param {Object} [options.configObject] - Explicit config object from the js API
52 * @param {string} [options.configPath] - Explicit path to a config file
53 * @param {string} [options.cwd] - Current working directory
54 * @param {boolean} [options.debug] - Enable debug mode
55 * @param {string} [options.diff] - Override the default "--staged" flag of "git diff" to get list of files
56 * @param {string} [options.diffFilter] - Override the default "--diff-filter=ACMR" flag of "git diff" to get list of files
57 * @param {number} [options.maxArgLength] - Maximum argument string length
58 * @param {boolean} [options.quiet] - Disable lint-staged’s own console output
59 * @param {boolean} [options.relative] - Pass relative filepaths to tasks
60 * @param {boolean} [options.shell] - Skip parsing of tasks for better shell support
61 * @param {boolean} [options.stash] - Enable the backup stash, and revert in case of errors
62 * @param {boolean} [options.verbose] - Show task output even when tasks succeed; by default only failed output is shown
63 * @param {Logger} logger
64 * @returns {Promise}
65 */
66export const runAll = async (
67 {
68 allowEmpty = false,
69 concurrent = true,
70 configObject,
71 configPath,
72 cwd,
73 debug = false,
74 diff,
75 diffFilter,
76 maxArgLength,
77 quiet = false,
78 relative = false,
79 shell = false,
80 stash = true,
81 verbose = false,
82 },
83 logger = console
84) => {
85 debugLog('Running all linter scripts...')
86
87 // Resolve relative CWD option
88 const hasExplicitCwd = !!cwd
89 cwd = hasExplicitCwd ? path.resolve(cwd) : process.cwd()
90 debugLog('Using working directory `%s`', cwd)
91
92 const ctx = getInitialState({ quiet })
93
94 const { gitDir, gitConfigDir } = await resolveGitRepo(cwd)
95 if (!gitDir) {
96 if (!quiet) ctx.output.push(NOT_GIT_REPO)
97 ctx.errors.add(GitRepoError)
98 throw createError(ctx)
99 }
100
101 // Test whether we have any commits or not.
102 // Stashing must be disabled with no initial commit.
103 const hasInitialCommit = await execGit(['log', '-1'], { cwd: gitDir })
104 .then(() => true)
105 .catch(() => false)
106
107 // Lint-staged should create a backup stash only when there's an initial commit,
108 // and when using the default list of staged files
109 ctx.shouldBackup = hasInitialCommit && stash && diff === undefined
110 if (!ctx.shouldBackup) {
111 logger.warn(skippingBackup(hasInitialCommit, diff))
112 }
113
114 const files = await getStagedFiles({ cwd: gitDir, diff, diffFilter })
115 if (!files) {
116 if (!quiet) ctx.output.push(FAILED_GET_STAGED_FILES)
117 ctx.errors.add(GetStagedFilesError)
118 throw createError(ctx, GetStagedFilesError)
119 }
120 debugLog('Loaded list of staged files in git:\n%O', files)
121
122 // If there are no files avoid executing any lint-staged logic
123 if (files.length === 0) {
124 if (!quiet) ctx.output.push(NO_STAGED_FILES)
125 return ctx
126 }
127
128 const foundConfigs = await searchConfigs({ configObject, configPath, cwd, gitDir }, logger)
129 const numberOfConfigs = Object.keys(foundConfigs).length
130
131 // Throw if no configurations were found
132 if (numberOfConfigs === 0) {
133 ctx.errors.add(ConfigNotFoundError)
134 throw createError(ctx, ConfigNotFoundError)
135 }
136
137 const filesByConfig = await groupFilesByConfig({
138 configs: foundConfigs,
139 files,
140 singleConfigMode: configObject || configPath !== undefined,
141 })
142
143 const hasMultipleConfigs = numberOfConfigs > 1
144
145 // lint-staged 10 will automatically add modifications to index
146 // Warn user when their command includes `git add`
147 let hasDeprecatedGitAdd = false
148
149 const listrOptions = {
150 ctx,
151 exitOnError: false,
152 registerSignalListeners: false,
153 ...getRenderer({ debug, quiet }),
154 }
155
156 const listrTasks = []
157
158 // Set of all staged files that matched a task glob. Values in a set are unique.
159 const matchedFiles = new Set()
160
161 for (const [configPath, { config, files }] of Object.entries(filesByConfig)) {
162 const configName = configPath ? normalize(path.relative(cwd, configPath)) : 'Config object'
163
164 const stagedFileChunks = chunkFiles({ baseDir: gitDir, files, maxArgLength, relative })
165
166 // Use actual cwd if it's specified, or there's only a single config file.
167 // Otherwise use the directory of the config file for each config group,
168 // to make sure tasks are separated from each other.
169 const groupCwd = hasMultipleConfigs && !hasExplicitCwd ? path.dirname(configPath) : cwd
170
171 const chunkCount = stagedFileChunks.length
172 if (chunkCount > 1) {
173 debugLog('Chunked staged files from `%s` into %d part', configPath, chunkCount)
174 }
175
176 for (const [index, files] of stagedFileChunks.entries()) {
177 const chunkListrTasks = await Promise.all(
178 generateTasks({ config, cwd: groupCwd, files, relative }).map((task) =>
179 makeCmdTasks({
180 commands: task.commands,
181 cwd: groupCwd,
182 files: task.fileList,
183 gitDir,
184 renderer: listrOptions.renderer,
185 shell,
186 verbose,
187 }).then((subTasks) => {
188 // Add files from task to match set
189 task.fileList.forEach((file) => {
190 // Make sure relative files are normalized to the
191 // group cwd, because other there might be identical
192 // relative filenames in the entire set.
193 const normalizedFile = path.isAbsolute(file)
194 ? file
195 : normalize(path.join(groupCwd, file))
196
197 matchedFiles.add(normalizedFile)
198 })
199
200 hasDeprecatedGitAdd =
201 hasDeprecatedGitAdd || subTasks.some((subTask) => subTask.command === 'git add')
202
203 const fileCount = task.fileList.length
204
205 return {
206 title: `${task.pattern}${dim(
207 ` — ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`
208 )}`,
209 task: async (ctx, task) =>
210 task.newListr(
211 subTasks,
212 // Subtasks should not run in parallel, and should exit on error
213 { concurrent: false, exitOnError: true }
214 ),
215 skip: () => {
216 // Skip task when no files matched
217 if (fileCount === 0) {
218 return `${task.pattern}${dim(' — no files')}`
219 }
220 return false
221 },
222 }
223 })
224 )
225 )
226
227 listrTasks.push({
228 title:
229 `${configName}${dim(` — ${files.length} ${files.length > 1 ? 'files' : 'file'}`)}` +
230 (chunkCount > 1 ? dim(` (chunk ${index + 1}/${chunkCount})...`) : ''),
231 task: (ctx, task) => task.newListr(chunkListrTasks, { concurrent, exitOnError: true }),
232 skip: () => {
233 // Skip if the first step (backup) failed
234 if (ctx.errors.has(GitError)) return SKIPPED_GIT_ERROR
235 // Skip chunk when no every task is skipped (due to no matches)
236 if (chunkListrTasks.every((task) => task.skip())) {
237 return `${configName}${dim(' — no tasks to run')}`
238 }
239 return false
240 },
241 })
242 }
243 }
244
245 if (hasDeprecatedGitAdd) {
246 logger.warn(DEPRECATED_GIT_ADD)
247 }
248
249 // If all of the configured tasks should be skipped
250 // avoid executing any lint-staged logic
251 if (listrTasks.every((task) => task.skip())) {
252 if (!quiet) ctx.output.push(NO_TASKS)
253 return ctx
254 }
255
256 // Chunk matched files for better Windows compatibility
257 const matchedFileChunks = chunkFiles({
258 // matched files are relative to `cwd`, not `gitDir`, when `relative` is used
259 baseDir: cwd,
260 files: Array.from(matchedFiles),
261 maxArgLength,
262 relative: false,
263 })
264
265 const git = new GitWorkflow({
266 allowEmpty,
267 gitConfigDir,
268 gitDir,
269 matchedFileChunks,
270 diff,
271 diffFilter,
272 })
273
274 const runner = new Listr(
275 [
276 {
277 title: 'Preparing lint-staged...',
278 task: (ctx) => git.prepare(ctx),
279 },
280 {
281 title: 'Hiding unstaged changes to partially staged files...',
282 task: (ctx) => git.hideUnstagedChanges(ctx),
283 enabled: hasPartiallyStagedFiles,
284 },
285 {
286 title: `Running tasks for staged files...`,
287 task: (ctx, task) => task.newListr(listrTasks, { concurrent }),
288 skip: () => listrTasks.every((task) => task.skip()),
289 },
290 {
291 title: 'Applying modifications from tasks...',
292 task: (ctx) => git.applyModifications(ctx),
293 skip: applyModificationsSkipped,
294 },
295 {
296 title: 'Restoring unstaged changes to partially staged files...',
297 task: (ctx) => git.restoreUnstagedChanges(ctx),
298 enabled: hasPartiallyStagedFiles,
299 skip: restoreUnstagedChangesSkipped,
300 },
301 {
302 title: 'Reverting to original state because of errors...',
303 task: (ctx) => git.restoreOriginalState(ctx),
304 enabled: restoreOriginalStateEnabled,
305 skip: restoreOriginalStateSkipped,
306 },
307 {
308 title: 'Cleaning up temporary files...',
309 task: (ctx) => git.cleanup(ctx),
310 enabled: cleanupEnabled,
311 skip: cleanupSkipped,
312 },
313 ],
314 listrOptions
315 )
316
317 await runner.run()
318
319 if (ctx.errors.size > 0) {
320 throw createError(ctx)
321 }
322
323 return ctx
324}