lint-staged
Version:
Lint files staged by git
162 lines (140 loc) • 4.74 kB
JavaScript
import chalk from 'chalk'
import debug from 'debug'
import spawn from 'nano-spawn'
import pidTree from 'pidtree'
import { parseArgsStringToArgv } from 'string-argv'
import { error, info } from './figures.js'
import { getInitialState } from './state.js'
import { TaskError } from './symbols.js'
const TASK_ERROR = 'lint-staged:taskError'
const debugLog = debug('lint-staged:getSpawnedTask')
/** @type {(error: import('nano-spawn').SubprocessError) => string} */
const getTag = (error) => {
return error.signalName ?? 'FAILED'
}
/**
* Handle task console output.
*
* @param {string} command
* @param {import('nano-spawn').Result | import('nano-spawn').SubprocessError} result
* @param {Object} ctx
* @returns {Error}
*/
const handleOutput = (command, result, ctx, isError = false) => {
if (result.output) {
const outputTitle = isError ? chalk.redBright(`${error} ${command}:`) : `${info} ${command}:`
const output = [...(ctx.quiet ? [] : ['', outputTitle]), result.output]
ctx.output.push(output.join('\n'))
} else if (isError) {
// Show generic error when task had no output
const tag = getTag(result)
const message = chalk.redBright(`\n${error} ${command} failed without output (${tag}).`)
if (!ctx.quiet) ctx.output.push(message)
}
}
/**
* Kill subprocess along with all its child processes.
* @param {import('nano-spawn').Subprocess} subprocess
*/
const killSubprocess = async (subprocess) => {
const childProcess = await subprocess.nodeChildProcess
try {
const childPids = await pidTree(childProcess.pid)
for (const childPid of childPids) {
try {
process.kill(childPid)
} catch (error) {
debugLog(`Failed to kill process with pid "%d": %o`, childPid, error)
}
}
} catch (error) {
// Suppress "No matching pid found" error. This probably means
// the process already died before executing.
debugLog(`Failed to kill process with pid "%d": %o`, childProcess.pid, error)
}
// The child process is terminated separately in order to get the `KILLED` status.
childProcess.kill('SIGKILL')
}
/**
* Interrupts the execution of the subprocess that we spawned if
* another task adds an error to the context.
*
* @param {Object} ctx
* @param {import('nano-spawn').Subprocess} subprocess
* @returns {() => Promise<void>} Function that clears the interval that
* checks the context.
*/
const interruptExecutionOnError = (ctx, subprocess) => {
let killPromise
const errorListener = async () => {
killPromise = killSubprocess(subprocess)
await killPromise
}
ctx.events.on(TASK_ERROR, errorListener, { once: true })
return async () => {
ctx.events.off(TASK_ERROR, errorListener)
await killPromise
}
}
/**
* Create a error output depending on process result.
*
* @param {string} command
* @param {import('nano-spawn').SubprocessError} error
* @param {Object} ctx
* @returns {Error}
*/
export const makeErr = (command, error, ctx) => {
ctx.errors.add(TaskError)
// https://nodejs.org/api/events.html#error-events
ctx.events.emit(TASK_ERROR, TaskError)
handleOutput(command, error, ctx, true)
const tag = getTag(error)
return new Error(`${chalk.redBright(command)} ${chalk.dim(`[${tag}]`)}`)
}
/**
* Returns the task function for the linter.
*
* @param {Object} options
* @param {string} options.command — Linter task
* @param {string} [options.cwd]
* @param {String} options.topLevelDir - Current git repo top-level path
* @param {Boolean} options.isFn - Whether the linter task is a function
* @param {string[]} options.files — Filepaths to run the linter task against
* @param {Boolean} [options.verbose] — Always show task verbose
* @returns {() => Promise<Array<string>>}
*/
export const getSpawnedTask = ({
command,
cwd = process.cwd(),
files,
topLevelDir,
isFn,
verbose = false,
}) => {
const [cmd, ...args] = parseArgsStringToArgv(command)
debugLog('cmd:', cmd)
debugLog('args:', args)
const spawnOptions = {
// Only use topLevelDir as CWD if we are using the git binary
// e.g `npm` should run tasks in the actual CWD
cwd: /^git(\.exe)?/i.test(cmd) ? topLevelDir : cwd,
preferLocal: true,
stdin: 'ignore',
}
debugLog('Spawn options:', spawnOptions)
return async (ctx = getInitialState()) => {
const subprocess = spawn(cmd, isFn ? args : args.concat(files), spawnOptions)
const quitInterruptCheck = interruptExecutionOnError(ctx, subprocess)
try {
const result = await subprocess
if (verbose) {
handleOutput(command, result, ctx)
}
} catch (error) {
throw makeErr(command, error, ctx)
} finally {
await quitInterruptCheck()
}
}
}