UNPKG

5.28 kBJavaScriptView Raw
1import { redBright, dim } from 'colorette'
2import { execa, execaCommand } from 'execa'
3import debug from 'debug'
4import { parseArgsStringToArgv } from 'string-argv'
5import pidTree from 'pidtree'
6
7import { error, info } from './figures.js'
8import { getInitialState } from './state.js'
9import { TaskError } from './symbols.js'
10
11const TASK_ERROR = 'lint-staged:taskError'
12
13const debugLog = debug('lint-staged:resolveTaskFn')
14
15const getTag = ({ code, killed, signal }) => (killed && 'KILLED') || signal || code || 'FAILED'
16
17/**
18 * Handle task console output.
19 *
20 * @param {string} command
21 * @param {Object} result
22 * @param {string} result.stdout
23 * @param {string} result.stderr
24 * @param {boolean} result.failed
25 * @param {boolean} result.killed
26 * @param {string} result.signal
27 * @param {Object} ctx
28 * @returns {Error}
29 */
30const handleOutput = (command, result, ctx, isError = false) => {
31 const { stderr, stdout } = result
32 const hasOutput = !!stderr || !!stdout
33
34 if (hasOutput) {
35 const outputTitle = isError ? redBright(`${error} ${command}:`) : `${info} ${command}:`
36 const output = []
37 .concat(ctx.quiet ? [] : ['', outputTitle])
38 .concat(stderr ? stderr : [])
39 .concat(stdout ? stdout : [])
40 ctx.output.push(output.join('\n'))
41 } else if (isError) {
42 // Show generic error when task had no output
43 const tag = getTag(result)
44 const message = redBright(`\n${error} ${command} failed without output (${tag}).`)
45 if (!ctx.quiet) ctx.output.push(message)
46 }
47}
48
49/**
50 * Kill an execa process along with all its child processes.
51 * @param {execa.ExecaChildProcess<string>} execaProcess
52 */
53const killExecaProcess = async (execaProcess) => {
54 try {
55 const childPids = await pidTree(execaProcess.pid)
56 for (const childPid of childPids) {
57 try {
58 process.kill(childPid)
59 } catch (error) {
60 debugLog(`Failed to kill process with pid "%d": %o`, childPid, error)
61 }
62 }
63 } catch (error) {
64 // Suppress "No matching pid found" error. This probably means
65 // the process already died before executing.
66 debugLog(`Failed to kill process with pid "%d": %o`, execaProcess.pid, error)
67 }
68
69 // The execa process is killed separately in order to get the `KILLED` status.
70 execaProcess.kill()
71}
72
73/**
74 * Interrupts the execution of the execa process that we spawned if
75 * another task adds an error to the context.
76 *
77 * @param {Object} ctx
78 * @param {execa.ExecaChildProcess<string>} execaChildProcess
79 * @returns {() => Promise<void>} Function that clears the interval that
80 * checks the context.
81 */
82const interruptExecutionOnError = (ctx, execaChildProcess) => {
83 let killPromise
84
85 const errorListener = async () => {
86 killPromise = killExecaProcess(execaChildProcess)
87 await killPromise
88 }
89
90 ctx.events.on(TASK_ERROR, errorListener, { once: true })
91
92 return async () => {
93 ctx.events.off(TASK_ERROR, errorListener)
94 await killPromise
95 }
96}
97
98/**
99 * Create a error output dependding on process result.
100 *
101 * @param {string} command
102 * @param {Object} result
103 * @param {string} result.stdout
104 * @param {string} result.stderr
105 * @param {boolean} result.failed
106 * @param {boolean} result.killed
107 * @param {string} result.signal
108 * @param {Object} ctx
109 * @returns {Error}
110 */
111const makeErr = (command, result, ctx) => {
112 ctx.errors.add(TaskError)
113
114 // https://nodejs.org/api/events.html#error-events
115 ctx.events.emit(TASK_ERROR, TaskError)
116
117 handleOutput(command, result, ctx, true)
118 const tag = getTag(result)
119 return new Error(`${redBright(command)} ${dim(`[${tag}]`)}`)
120}
121
122/**
123 * Returns the task function for the linter.
124 *
125 * @param {Object} options
126 * @param {string} options.command — Linter task
127 * @param {string} [options.cwd]
128 * @param {String} options.gitDir - Current git repo path
129 * @param {Boolean} options.isFn - Whether the linter task is a function
130 * @param {Array<string>} options.files — Filepaths to run the linter task against
131 * @param {Boolean} [options.shell] — Whether to skip parsing linter task for better shell support
132 * @param {Boolean} [options.verbose] — Always show task verbose
133 * @returns {() => Promise<Array<string>>}
134 */
135export const resolveTaskFn = ({
136 command,
137 cwd = process.cwd(),
138 files,
139 gitDir,
140 isFn,
141 shell = false,
142 verbose = false,
143}) => {
144 const [cmd, ...args] = parseArgsStringToArgv(command)
145 debugLog('cmd:', cmd)
146 debugLog('args:', args)
147
148 const execaOptions = {
149 // Only use gitDir as CWD if we are using the git binary
150 // e.g `npm` should run tasks in the actual CWD
151 cwd: /^git(\.exe)?/i.test(cmd) ? gitDir : cwd,
152 preferLocal: true,
153 reject: false,
154 shell,
155 }
156
157 debugLog('execaOptions:', execaOptions)
158
159 return async (ctx = getInitialState()) => {
160 const execaChildProcess = shell
161 ? execaCommand(isFn ? command : `${command} ${files.join(' ')}`, execaOptions)
162 : execa(cmd, isFn ? args : args.concat(files), execaOptions)
163
164 const quitInterruptCheck = interruptExecutionOnError(ctx, execaChildProcess)
165 const result = await execaChildProcess
166 await quitInterruptCheck()
167
168 if (result.failed || result.killed || result.signal != null) {
169 throw makeErr(command, result, ctx)
170 }
171
172 if (verbose) {
173 handleOutput(command, result, ctx)
174 }
175 }
176}