UNPKG

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