diff --git a/index.js b/index.js index 89d7d9403..845da6579 100755 --- a/index.js +++ b/index.js @@ -19,6 +19,8 @@ const debug = debugLib('lint-staged:bin') cmdline .version(pkg.version) .option('-c, --config [path]', 'Path to configuration file') + .option('-x, --shell', 'Use execa’s shell mode to execute linter commands') + .option('-s, --silent', 'Use Listr’s silent renderer') .option('-d, --debug', 'Enable debug mode') .parse(process.argv) @@ -28,4 +30,4 @@ if (cmdline.debug) { debug('Running `lint-staged@%s`', pkg.version) -require('./src')(console, cmdline.config, cmdline.debug) +require('./src')(console, cmdline.config, !!cmdline.shell, !!cmdline.silent, !!cmdline.debug) diff --git a/package.json b/package.json index cf2604a35..a74e74595 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "micromatch": "^3.1.8", "path-is-inside": "^1.0.2", "please-upgrade-node": "^3.0.2", + "string-argv": "^0.3.0", "stringify-object": "^3.2.2" }, "devDependencies": { diff --git a/src/index.js b/src/index.js index 3f017a210..d954cf868 100644 --- a/src/index.js +++ b/src/index.js @@ -44,8 +44,19 @@ function loadConfig(configPath) { /** * Root lint-staged function that is called from .bin + * @param {Function} logger + * @param {String} configPath + * @param {Boolean} shellMode Use execa’s shell mode to execute linter commands + * @param {Boolean} silentMode Use Listr’s silent renderer + * @param {Boolean} debugMode Enable debug mode */ -module.exports = function lintStaged(logger = console, configPath, debugMode) { +module.exports = function lintStaged( + logger = console, + configPath, + shellMode = false, + silentMode = false, + debugMode = false +) { debug('Loading config using `cosmiconfig`') return loadConfig(configPath) @@ -66,7 +77,7 @@ module.exports = function lintStaged(logger = console, configPath, debugMode) { debug('Normalized config:\n%O', config) } - return runAll(config, debugMode) + return runAll(config, shellMode, silentMode, debugMode) .then(() => { debug('linters were executed successfully!') // No errors, exiting with 0 diff --git a/src/makeCmdTasks.js b/src/makeCmdTasks.js index 2ff6809cf..f73cf5eac 100644 --- a/src/makeCmdTasks.js +++ b/src/makeCmdTasks.js @@ -8,10 +8,11 @@ const debug = require('debug')('lint-staged:make-cmd-tasks') * Creates and returns an array of listr tasks which map to the given commands. * * @param {Array|string} commands + * @param {Boolean} shell * @param {Array} pathsToLint * @param {Object} [options] */ -module.exports = async function makeCmdTasks(commands, gitDir, pathsToLint) { +module.exports = async function makeCmdTasks(commands, shell, gitDir, pathsToLint) { debug('Creating listr tasks for commands %o', commands) const lintersArray = Array.isArray(commands) ? commands : [commands] @@ -20,6 +21,7 @@ module.exports = async function makeCmdTasks(commands, gitDir, pathsToLint) { title: linter, task: resolveTaskFn({ linter, + shell, gitDir, pathsToLint }) diff --git a/src/resolveTaskFn.js b/src/resolveTaskFn.js index 0a1bc37ae..fcb4890df 100644 --- a/src/resolveTaskFn.js +++ b/src/resolveTaskFn.js @@ -4,6 +4,7 @@ const chalk = require('chalk') const dedent = require('dedent') const execa = require('execa') const symbols = require('log-symbols') +const stringArgv = require('string-argv') const debug = require('debug')('lint-staged:task') @@ -14,10 +15,11 @@ const debug = require('debug')('lint-staged:task') * @param {string} cmd * @return {Promise} child_process */ -const execLinter = (cmd, execaOptions = {}) => { +const execLinter = (cmd, args, execaOptions = {}) => { debug('cmd:', cmd) + debug('args:', args) debug('execaOptions:', execaOptions) - return execa(cmd, execaOptions) + return execa(cmd, args, execaOptions) } const successMsg = linter => `${symbols.success} ${linter} passed!` @@ -73,13 +75,12 @@ function makeErr(linter, result, context = {}) { * * @param {Object} options * @param {string} options.linter + * @param {Boolean} options.shellMode * @param {string} options.gitDir * @param {Array} options.pathsToLint * @returns {function(): Promise>} */ -module.exports = function resolveTaskFn(options) { - const { gitDir, linter, pathsToLint } = options - +module.exports = function resolveTaskFn({ gitDir, linter, pathsToLint, shell = false }) { // If `linter` is a function, it should return a string when evaluated with `pathsToLint`. // Else, it's a already a string const fnLinter = typeof linter === 'function' @@ -88,18 +89,19 @@ module.exports = function resolveTaskFn(options) { const linters = Array.isArray(linterString) ? linterString : [linterString] const tasks = linters.map(command => { - // If `linter` is a function, cmd already includes `pathsToLint`. - const cmdWithPaths = fnLinter ? command : `${command} ${pathsToLint.join(' ')}` + const [cmd, ...args] = stringArgv.parseArgsStringToArgv(command) + // If `linter` is a function, args already include `pathsToLint`. + const argsWithPaths = fnLinter ? args : args.concat(pathsToLint) // Only use gitDir as CWD if we are using the git binary // e.g `npm` should run tasks in the actual CWD - const execaOptions = { preferLocal: true, reject: false, shell: true } + const execaOptions = { preferLocal: true, reject: false, shell } if (/^git(\.exe)?/i.test(command) && gitDir !== process.cwd()) { execaOptions.cwd = gitDir } return ctx => - execLinter(cmdWithPaths, execaOptions).then(result => { + execLinter(cmd, argsWithPaths, execaOptions).then(result => { if (result.failed || result.killed || result.signal != null) { throw makeErr(linter, result, ctx) } diff --git a/src/runAll.js b/src/runAll.js index 668153066..7978c18ce 100644 --- a/src/runAll.js +++ b/src/runAll.js @@ -24,9 +24,17 @@ const MAX_ARG_LENGTH = /** * Executes all tasks and either resolves or rejects the promise * @param config {Object} + * @param {Boolean} shellMode Use execa’s shell mode to execute linter commands + * @param {Boolean} silentMode Use Listr’s silent renderer + * @param {Boolean} debugMode Enable debug mode * @returns {Promise} */ -module.exports = async function runAll(config, debugMode) { +module.exports = async function runAll( + config, + shellMode = false, + silentMode = false, + debugMode = false +) { debug('Running all linter scripts') const gitDir = await resolveGitDir(config) @@ -60,7 +68,7 @@ https://github.com/okonet/lint-staged#using-js-functions-to-customize-linter-com const tasks = (await generateTasks(config, gitDir, files)).map(task => ({ title: `Running tasks for ${task.pattern}`, task: async () => - new Listr(await makeCmdTasks(task.commands, gitDir, task.fileList), { + new Listr(await makeCmdTasks(task.commands, shellMode, gitDir, task.fileList), { // In sub-tasks we don't want to run concurrently // and we want to abort on errors dateFormat: false, @@ -77,7 +85,7 @@ https://github.com/okonet/lint-staged#using-js-functions-to-customize-linter-com const listrOptions = { dateFormat: false, - renderer: debugMode ? 'verbose' : 'update' + renderer: (silentMode && 'silent') || (debugMode && 'verbose') || 'update' } // If all of the configured "linters" should be skipped diff --git a/test/index.spec.js b/test/index.spec.js index 65fe75674..0cebe25f0 100644 --- a/test/index.spec.js +++ b/test/index.spec.js @@ -43,7 +43,7 @@ describe('lintStaged', () => { '*': 'mytask' } mockCosmiconfigWith({ config }) - await lintStaged(logger, undefined, true) + await lintStaged(logger, undefined, false, false, true) expect(logger.printHistory()).toMatchSnapshot() }) @@ -66,20 +66,32 @@ describe('lintStaged', () => { it('should load config file when specified', async () => { expect.assertions(1) - await lintStaged(logger, path.join(__dirname, '__mocks__', 'my-config.json'), true) + await lintStaged( + logger, + path.join(__dirname, '__mocks__', 'my-config.json'), + false, + false, + true + ) expect(logger.printHistory()).toMatchSnapshot() }) it('should parse function linter from js config', async () => { expect.assertions(1) - await lintStaged(logger, path.join(__dirname, '__mocks__', 'advanced-config.js'), true) + await lintStaged( + logger, + path.join(__dirname, '__mocks__', 'advanced-config.js'), + false, + false, + true + ) expect(logger.printHistory()).toMatchSnapshot() }) it('should load an npm config package when specified', async () => { expect.assertions(1) jest.mock('my-lint-staged-config') - await lintStaged(logger, 'my-lint-staged-config', true) + await lintStaged(logger, 'my-lint-staged-config', false, false, true) expect(logger.printHistory()).toMatchSnapshot() }) diff --git a/test/index2.spec.js b/test/index2.spec.js new file mode 100644 index 000000000..ff61973e9 --- /dev/null +++ b/test/index2.spec.js @@ -0,0 +1,41 @@ +import Listr from 'listr' +import path from 'path' + +// silence console from Jest output +console.log = jest.fn(() => {}) +console.error = jest.fn(() => {}) + +jest.mock('listr') + +// eslint-disable-next-line import/first +import lintStaged from '../src/index' + +describe('lintStaged', () => { + afterEach(() => { + Listr.mockClear() + }) + + it('should pass silent flag to Listr', async () => { + expect.assertions(1) + await lintStaged( + console, + path.join(__dirname, '__mocks__', 'my-config.json'), + false, + true, + false + ) + expect(Listr.mock.calls[0][1]).toEqual({ dateFormat: false, renderer: 'silent' }) + }) + + it('should pass debug flag to Listr', async () => { + expect.assertions(1) + await lintStaged( + console, + path.join(__dirname, '__mocks__', 'my-config.json'), + false, + false, + true + ) + expect(Listr.mock.calls[0][1]).toEqual({ dateFormat: false, renderer: 'verbose' }) + }) +}) diff --git a/test/makeCmdTasks.spec.js b/test/makeCmdTasks.spec.js index dc33cf2f7..8dbdd756e 100644 --- a/test/makeCmdTasks.spec.js +++ b/test/makeCmdTasks.spec.js @@ -9,13 +9,13 @@ describe('makeCmdTasks', () => { }) it('should return an array', async () => { - const array = await makeCmdTasks('test', gitDir, ['test.js']) + const array = await makeCmdTasks('test', false, gitDir, ['test.js']) expect(array).toBeInstanceOf(Array) }) it('should work with a single command', async () => { expect.assertions(4) - const res = await makeCmdTasks('test', gitDir, ['test.js']) + const res = await makeCmdTasks('test', false, gitDir, ['test.js']) expect(res.length).toBe(1) const [linter] = res expect(linter.title).toBe('test') @@ -27,7 +27,7 @@ describe('makeCmdTasks', () => { it('should work with multiple commands', async () => { expect.assertions(9) - const res = await makeCmdTasks(['test', 'test2'], gitDir, ['test.js']) + const res = await makeCmdTasks(['test', 'test2'], false, gitDir, ['test.js']) expect(res.length).toBe(2) const [linter1, linter2] = res expect(linter1.title).toBe('test') @@ -37,11 +37,19 @@ describe('makeCmdTasks', () => { expect(taskPromise).toBeInstanceOf(Promise) await taskPromise expect(execa).toHaveBeenCalledTimes(1) - expect(execa).lastCalledWith('test test.js', { preferLocal: true, reject: false, shell: true }) + expect(execa).lastCalledWith('test', ['test.js'], { + preferLocal: true, + reject: false, + shell: false + }) taskPromise = linter2.task() expect(taskPromise).toBeInstanceOf(Promise) await taskPromise expect(execa).toHaveBeenCalledTimes(2) - expect(execa).lastCalledWith('test2 test.js', { preferLocal: true, reject: false, shell: true }) + expect(execa).lastCalledWith('test2', ['test.js'], { + preferLocal: true, + reject: false, + shell: false + }) }) }) diff --git a/test/resolveTaskFn.spec.js b/test/resolveTaskFn.spec.js index f3a4ed45d..fa972ac30 100644 --- a/test/resolveTaskFn.spec.js +++ b/test/resolveTaskFn.spec.js @@ -17,10 +17,10 @@ describe('resolveTaskFn', () => { await taskFn() expect(execa).toHaveBeenCalledTimes(1) - expect(execa).lastCalledWith('node --arg=true ./myscript.js test.js', { + expect(execa).lastCalledWith('node', ['--arg=true', './myscript.js', 'test.js'], { preferLocal: true, reject: false, - shell: true + shell: false }) }) @@ -33,10 +33,10 @@ describe('resolveTaskFn', () => { await taskFn() expect(execa).toHaveBeenCalledTimes(1) - expect(execa).lastCalledWith('node --arg=true ./myscript.js test.js', { + expect(execa).lastCalledWith('node', ['--arg=true', './myscript.js', 'test.js'], { preferLocal: true, reject: false, - shell: true + shell: false }) }) @@ -50,15 +50,15 @@ describe('resolveTaskFn', () => { await taskFn() expect(execa).toHaveBeenCalledTimes(2) - expect(execa).nthCalledWith(1, 'node --arg=true ./myscript.js foo.js', { + expect(execa).nthCalledWith(1, 'node', ['--arg=true', './myscript.js', 'foo.js'], { preferLocal: true, reject: false, - shell: true + shell: false }) - expect(execa).nthCalledWith(2, 'node --arg=true ./myscript.js bar.js', { + expect(execa).nthCalledWith(2, 'node', ['--arg=true', './myscript.js', 'bar.js'], { preferLocal: true, reject: false, - shell: true + shell: false }) }) @@ -72,11 +72,11 @@ describe('resolveTaskFn', () => { await taskFn() expect(execa).toHaveBeenCalledTimes(1) - expect(execa).lastCalledWith('git add test.js', { + expect(execa).lastCalledWith('git', ['add', 'test.js'], { cwd: '../', preferLocal: true, reject: false, - shell: true + shell: false }) }) @@ -86,10 +86,10 @@ describe('resolveTaskFn', () => { await taskFn() expect(execa).toHaveBeenCalledTimes(1) - expect(execa).lastCalledWith('jest test.js', { + expect(execa).lastCalledWith('jest', ['test.js'], { preferLocal: true, reject: false, - shell: true + shell: false }) }) @@ -167,4 +167,21 @@ Mock error" expect(context.hasErrors).toEqual(true) } }) + + it('should call execa with shell when configured so', async () => { + expect.assertions(2) + const taskFn = resolveTaskFn({ + ...defaultOpts, + linter: 'node --arg=true ./myscript.js', + shell: true + }) + + await taskFn() + expect(execa).toHaveBeenCalledTimes(1) + expect(execa).lastCalledWith('node', ['--arg=true', './myscript.js', 'test.js'], { + preferLocal: true, + reject: false, + shell: true + }) + }) }) diff --git a/yarn.lock b/yarn.lock index 60fbb96b9..f8956f608 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5196,6 +5196,11 @@ stealthy-require@^1.1.1: resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks= +string-argv@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.0.tgz#0ea99e7257fea5e97a1bfcdfc19cf12d68e6ec6a" + integrity sha512-NGZHq3nkSXVtGZXTBjFru3MNfoZyIzN25T7BmvdgnSC0LCJczAGLLMQLyjywSIaAoqSemgLzBRHOsnrHbt60+Q== + string-length@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/string-length/-/string-length-2.0.0.tgz#d40dbb686a3ace960c1cffca562bf2c45f8363ed"