lint-staged
Version:
Lint files staged by git
360 lines (311 loc) • 11.7 kB
JavaScript
/** @typedef {import('./index').Logger} Logger */
import path from 'node:path'
import { dim } from './colors.js'
import { createDebug } from './debug.js'
import { execGit } from './execGit.js'
import * as figures from './figures.js'
import { generateTasks } from './generateTasks.js'
import { getAbortController } from './getAbortController.js'
import { getFunctionTask, isFunctionTask } from './getFunctionTask.js'
import { getSpawnedTasks } from './getSpawnedTasks.js'
import { getStagedFiles } from './getStagedFiles.js'
import { GitWorkflow } from './gitWorkflow.js'
import { groupFilesByConfig } from './groupFilesByConfig.js'
import {
deprecatedGitAdd,
failedGetStagedFiles,
noStagedFiles,
noTasks,
notGitRepo,
skippingBackup,
skippingHidePartiallyChanged,
} from './messages.js'
import { normalizePath } from './normalizePath.js'
import { resolveGitRepo } from './resolveGitRepo.js'
import { searchConfigs } from './searchConfigs.js'
import {
cleanupEnabled,
cleanupSkipped,
getInitialState,
restoreOriginalStateEnabled,
restoreOriginalStateSkipped,
restoreUnstagedChangesSkipped,
shouldHidePartiallyStagedFiles,
shouldRestoreUnstagedChanges,
shouldRestoreUntrackedFiles,
updateIndexSkipped,
} from './state.js'
import { ConfigNotFoundError, GetStagedFilesError, GitRepoError } from './symbols.js'
const debugLog = createDebug('lint-staged:runAll')
/**
* @param {Logger} logger
* @param {boolean} quiet
* @returns {Logger}
*/
const configureLogger = (logger, quiet) => {
if (quiet) {
const noop = () => {}
return { error: logger.error, log: noop, warn: noop }
}
return logger
}
/**
* @param {ReturnType<typeof getInitialState>} ctx context
* @param {unknown} cause error cause
*/
const createError = (ctx, cause) =>
Object.assign(new Error('lint-staged failed', { cause }), { ctx })
/**
* Executes all tasks and either resolves or rejects the promise
*
* @param {object} options
* @param {boolean} [options.allowEmpty] - Allow empty commits when tasks revert all staged changes
* @param {boolean | number} [options.concurrent] - The number of tasks to run concurrently, or false to run tasks serially
* @param {Object} [options.configObject] - Explicit config object from the js API
* @param {string} [options.configPath] - Explicit path to a config file
* @param {boolean} [options.continueOnError] - Run all tasks to completion even if one fails
* @param {string} [options.cwd] - Current working directory
* @param {string} [options.diff] - Override the default "--staged" flag of "git diff" to get list of files
* @param {string} [options.diffFilter] - Override the default "--diff-filter=ACMR" flag of "git diff" to get list of files
* @param {boolean} [options.failOnChanges] - Fail with exit code 1 when tasks modify tracked files
* @param {boolean} [options.hidePartiallyStaged] - Whether to hide unstaged changes from partially staged files before running tasks
* @param {boolean} [options.hideUnstaged] - Whether to hide all unstaged changes before running tasks
* @param {number} [options.maxArgLength] - Maximum argument string length
* @param {boolean} [options.quiet] - Disable lint-staged's own console output
* @param {boolean} [options.relative] - Pass relative filepaths to tasks
* @param {boolean} [options.revert] - revert to original state in case of errors
* @param {boolean} [options.stash] - Enable the backup stash, and revert in case of errors
* @param {boolean} [options.verbose] - Show task output even when tasks succeed; by default only failed output is shown
* @param {Logger} logger
* @returns {Promise}
*/
export const runAll = async (
{
allowEmpty = false,
concurrent = true,
configObject,
configPath,
continueOnError = false,
cwd,
diff,
diffFilter,
failOnChanges = false,
hideAll = false,
hideUnstaged = false,
hidePartiallyStaged = !(hideAll || hideUnstaged),
maxArgLength,
quiet = false,
relative = false,
// Stashing should be disabled by default when the `diff` option is used
stash = diff === undefined,
// Cannot revert to original state without stash
revert = stash,
verbose = false,
},
logger = console
) => {
const thisLogger = configureLogger(logger, quiet)
debugLog('Running all tasks...')
// Resolve relative CWD option
const hasExplicitCwd = !!cwd
cwd = hasExplicitCwd ? path.resolve(cwd) : process.cwd()
debugLog('Using working directory `%s`', cwd)
const ctx = getInitialState({
failOnChanges,
hideAll,
hideUnstaged,
hidePartiallyStaged,
quiet,
revert,
})
const { topLevelDir, gitConfigDir } = await resolveGitRepo(cwd)
if (!topLevelDir) {
if (!quiet) ctx.output.push(notGitRepo())
ctx.errors.add(GitRepoError)
throw createError(ctx, GitRepoError)
}
// Test whether we have any commits or not.
// Stashing must be disabled with no initial commit.
const hasInitialCommit = await execGit(['log', '-1'], { cwd: topLevelDir })
.then(() => true)
.catch(() => false)
// Lint-staged will create a backup stash only when there's an initial commit,
// and when using the default list of staged files by default
ctx.shouldBackup = hasInitialCommit && stash
if (!ctx.shouldBackup) {
thisLogger.warn(skippingBackup(hasInitialCommit, diff))
}
if (!ctx.shouldHidePartiallyStaged && !ctx.shouldHideUnstaged && !ctx.shouldHideAll) {
thisLogger.warn(skippingHidePartiallyChanged())
}
// Run staged files retrieval and config search in parallel since they're independent
const [stagedFiles, foundConfigs] = await Promise.all([
getStagedFiles({ cwd: topLevelDir, diff, diffFilter }),
searchConfigs({ configObject, configPath, cwd, topLevelDir }, thisLogger),
])
if (!stagedFiles) {
if (!quiet) ctx.output.push(failedGetStagedFiles())
ctx.errors.add(GetStagedFilesError)
throw createError(ctx, GetStagedFilesError)
}
debugLog('Loaded list of staged files in git:\n%O', stagedFiles)
// If there are no files avoid executing any lint-staged logic
if (stagedFiles.length === 0) {
if (!quiet) ctx.output.push(noStagedFiles())
return ctx
}
const numberOfConfigs = Object.keys(foundConfigs).length
// Throw if no configurations were found
if (numberOfConfigs === 0) {
ctx.errors.add(ConfigNotFoundError)
throw createError(ctx, ConfigNotFoundError)
}
const filesByConfig = await groupFilesByConfig({
configs: foundConfigs,
files: stagedFiles,
singleConfigMode: configObject || configPath !== undefined,
})
const hasMultipleConfigs = numberOfConfigs > 1
// lint-staged 10 will automatically add modifications to index
// Warn user when their command includes `git add`
let hasDeprecatedGitAdd = false
const tasks = []
// Set of all staged files that matched a task glob. Values in a set are unique.
/** @type {Set<import('./getStagedFiles.js').StagedFile>} */
const matchedFiles = new Set()
const abortController = getAbortController()
for (const [configPath, { config, files }] of Object.entries(filesByConfig)) {
const configName = configPath ? normalizePath(path.relative(cwd, configPath)) : 'Config object'
// Use actual cwd if it's specified, or there's only a single config file.
// Otherwise use the directory of the config file for each config group,
// to make sure tasks are separated from each other.
const groupCwd = hasExplicitCwd || !hasMultipleConfigs ? cwd : path.dirname(configPath)
const configTasks = await Promise.all(
generateTasks({ config, cwd: groupCwd, files, relative }).map((task) =>
(isFunctionTask(task.commands)
? getFunctionTask({
abortController,
command: task.commands,
continueOnError,
files: task.fileList,
})
: getSpawnedTasks({
abortController,
commands: task.commands,
continueOnError,
cwd: groupCwd,
files: task.fileList,
maxArgLength,
topLevelDir,
verbose,
})
).then((subTasks) => {
// Add files from task to match set
task.fileList.forEach((file) => {
// Make sure relative files are normalized to the
// group cwd, because other there might be identical
// relative filenames in the entire set.
const normalizedFile = path.isAbsolute(file.filepath)
? file
: {
filepath: normalizePath(path.join(groupCwd, file.filepath)),
status: file.status,
}
matchedFiles.add(normalizedFile)
})
hasDeprecatedGitAdd =
hasDeprecatedGitAdd || subTasks.some((subTask) => subTask.title.includes('git add'))
const fileCount = task.fileList.length
return {
title: `${task.pattern}${dim(` — ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`)}`,
task: subTasks,
skip: () => {
// Skip task when no files matched
if (fileCount === 0) {
return `${task.pattern}${dim(' — no files')}`
}
return false
},
}
})
)
)
tasks.push({
title: `${configName}${dim(` — ${files.length} ${files.length > 1 ? 'files' : 'file'}`)}`,
task: configTasks,
skip: () => {
// Skip chunk when no every task is skipped (due to no matches)
if (configTasks.every((task) => task.skip())) {
return `${configName}${dim(' — no tasks to run')}`
}
return false
},
})
}
if (hasDeprecatedGitAdd) {
thisLogger.warn(deprecatedGitAdd())
}
if (tasks.every((task) => task.skip())) {
if (!quiet) ctx.output.push(noTasks())
return ctx
}
const git = new GitWorkflow({
allowEmpty,
diff,
diffFilter,
failOnChanges,
gitConfigDir,
logger: thisLogger,
matchedFiles,
maxArgLength,
topLevelDir,
})
await git.prepare(ctx)
if (shouldHidePartiallyStagedFiles(ctx)) {
await git.hidePartiallyStagedChanges(ctx)
}
if (abortController.signal.aborted || ctx.errors.size > 0) {
thisLogger.log(`${figures.cancelled()} ${dim(`Skipped running tasks…`)}`)
} else {
await git.runTasks(ctx, tasks, { abortController, concurrent })
}
if (updateIndexSkipped(ctx)) {
thisLogger.log(`${figures.cancelled()} ${dim('Skipped staging changes from tasks…')}`)
} else {
await git.updateIndex(ctx)
}
if (shouldRestoreUnstagedChanges(ctx)) {
if (restoreUnstagedChangesSkipped(ctx)) {
thisLogger.log(`${figures.cancelled()} ${dim('Skipped restoring unstaged changes…')}`)
} else {
await git.restoreUnstagedChanges(ctx)
}
}
if (shouldRestoreUntrackedFiles(ctx)) {
if (restoreUnstagedChangesSkipped(ctx)) {
thisLogger.log(`${figures.cancelled()} ${dim('Skipped restoring untracked files…')}`)
} else {
await git.restoreUntrackedFiles(ctx)
}
}
if (restoreOriginalStateEnabled(ctx)) {
if (restoreOriginalStateSkipped(ctx)) {
thisLogger.log(
`${figures.cancelled()} ${dim('Skipped reverting to original state because of errors…')}`
)
} else {
await git.restoreOriginalState(ctx)
}
}
if (cleanupEnabled(ctx)) {
if (cleanupSkipped(ctx)) {
thisLogger.log(`${figures.cancelled()} ${dim('Skipped cleaning up temporary files…')}`)
} else {
await git.cleanup(ctx)
}
}
if (ctx.errors.size > 0) {
throw createError(ctx)
}
return ctx
}