Repository: okonet/lint-staged
Pull request #1094 (2022-01-22 08:53:38)

feat: add `--cwd` option for overriding task directory
This PR exposes the `--cwd` option, previously only available in the Node.js API. It also adds a debug log output for the current value, and whether it was explicit or not.

iiroj: Ping @okonet 
codecov[bot]: # [Codecov](https://codecov.io/gh/okonet/lint-staged/pull/1094?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov) Report
> Merging [#1094](https://codecov.io/gh/okonet/lint-staged/pull/1094?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov) (dc635f1) into [master](https://codecov.io/gh/okonet/lint-staged/commit/554210030dc0b5447da5565ad5f2f03ceb389a4a?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov) (5542100) will **not change** coverage.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/okonet/lint-staged/pull/1094/graphs/tree.svg?width=650&height=150&src=pr&token=LSoEgsjk4O&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov)](https://codecov.io/gh/okonet/lint-staged/pull/1094?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov)

```diff
@@            Coverage Diff            @@
##            master     #1094   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           23        23           
  Lines          641       645    +4     
  Branches       169       169           
=========================================
+ Hits           641       645    +4     
```


| [Impacted Files](https://codecov.io/gh/okonet/lint-staged/pull/1094?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov) | Coverage Δ | |
|---|---|---|
| [lib/index.js](https://codecov.io/gh/okonet/lint-staged/pull/1094/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov#diff-bGliL2luZGV4Lmpz) | `100.00% <ø> (ø)` | |
| [lib/messages.js](https://codecov.io/gh/okonet/lint-staged/pull/1094/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov#diff-bGliL21lc3NhZ2VzLmpz) | `100.00% <100.00%> (ø)` | |
| [lib/runAll.js](https://codecov.io/gh/okonet/lint-staged/pull/1094/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov#diff-bGliL3J1bkFsbC5qcw==) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/okonet/lint-staged/pull/1094?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/okonet/lint-staged/pull/1094?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov). Last update [5542100...dc635f1](https://codecov.io/gh/okonet/lint-staged/pull/1094?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov).

iiroj: What do you think about this now, @okonet?

Patch:
diff --git a/README.md b/README.md
index e70f97e..18a1f7b 100644
--- a/README.md
+++ b/README.md
@@ -81,38 +81,36 @@ See [Releases](https://github.com/okonet/lint-staged/releases).
 
 ## Command line flags
 
-```bash
+```
 ❯ npx lint-staged --help
 Usage: lint-staged [options]
 
 Options:
   -V, --version                      output the version number
-  --allow-empty                      allow empty commits when tasks revert all staged changes
-                                     (default: false)
+  --allow-empty                      allow empty commits when tasks revert all staged changes (default: false)
+  -p, --concurrent <number|boolean>  the number of tasks to run concurrently, or false for serial (default: true)
   -c, --config [path]                path to configuration file, or - to read from stdin
+  --cwd [path]                       run all tasks in specific directory, instead of the current
   -d, --debug                        print additional debug information (default: false)
-  --no-stash                         disable the backup stash, and do not revert in case of
-                                     errors
-  -p, --concurrent <parallel tasks>  the number of tasks to run concurrently, or false to run
-                                     tasks serially (default: true)
+  --no-stash                         disable the backup stash, and do not revert in case of errors
   -q, --quiet                        disable lint-staged’s own console output (default: false)
   -r, --relative                     pass relative filepaths to tasks (default: false)
-  -x, --shell [path]                 skip parsing of tasks for better shell support (default:
-                                     false)
-  -v, --verbose                      show task output even when tasks succeed; by default only
-                                     failed output is shown (default: false)
+  -x, --shell [path]                 skip parsing of tasks for better shell support (default: false)
+  -v, --verbose                      show task output even when tasks succeed; by default only failed output is shown
+                                     (default: false)
   -h, --help                         display help for command
 ```
 
 - **`--allow-empty`**: By default, when linter tasks undo all staged changes, lint-staged will exit with an error and abort the commit. Use this flag to allow creating empty git commits.
-- **`--config [path]`**: Manually specify a path to a config file or npm package name. Note: when used, lint-staged won't perform the config file search and will print an error if the specified file cannot be found. If '-' is provided as the filename then the config will be read from stdin, allowing piping in the config like `cat my-config.json | npx lint-staged --config -`.
-- **`--debug`**: Run in debug mode. When set, it does the following:
+- **`--concurrent [number|boolean]`**: Controls the concurrency of tasks being run by lint-staged. **NOTE**: This does NOT affect the concurrency of subtasks (they will always be run sequentially). Possible values are:
   - uses [debug](https://github.com/visionmedia/debug) internally to log additional information about staged files, commands being executed, location of binaries, etc. Debug logs, which are automatically enabled by passing the flag, can also be enabled by setting the environment variable `$DEBUG` to `lint-staged*`.
   - uses [`verbose` renderer](https://github.com/SamVerschueren/listr-verbose-renderer) for `listr`; this causes serial, uncoloured output to the terminal, instead of the default (beautified, dynamic) output.
-- **`--concurrent [number | (true/false)]`**: Controls the concurrency of tasks being run by lint-staged. **NOTE**: This does NOT affect the concurrency of subtasks (they will always be run sequentially). Possible values are:
   - `false`: Run all tasks serially
   - `true` (default) : _Infinite_ concurrency. Runs as many tasks in parallel as possible.
   - `{number}`: Run the specified number of tasks in parallel, where `1` is equivalent to `false`.
+- **`--config [path]`**: Manually specify a path to a config file or npm package name. Note: when used, lint-staged won't perform the config file search and will print an error if the specified file cannot be found. If '-' is provided as the filename then the config will be read from stdin, allowing piping in the config like `cat my-config.json | npx lint-staged --config -`.
+- **`--cwd [path]`**: By default tasks run in the current working directory. Use the `--cwd some/directory` to override this. The path can be absolute or relative to the current working directory.
+- **`--debug`**: Run in debug mode. When set, it does the following:
 - **`--no-stash`**: By default a backup stash will be created before running the tasks, and all task modifications will be reverted in case of an error. This option will disable creating the stash, and instead leave all modifications in the index when aborting the commit.
 - **`--quiet`**: Supress all CLI output, except from tasks.
 - **`--relative`**: Pass filepaths relative to `process.cwd()` (where `lint-staged` runs) to tasks. Default is `false`.
diff --git a/bin/lint-staged.js b/bin/lint-staged.js
index 1383a39..9a6f6eb 100755
--- a/bin/lint-staged.js
+++ b/bin/lint-staged.js
@@ -26,14 +26,15 @@ const version = packageJson.version
 cmdline
   .version(version)
   .option('--allow-empty', 'allow empty commits when tasks revert all staged changes', false)
-  .option('-c, --config [path]', 'path to configuration file, or - to read from stdin')
-  .option('-d, --debug', 'print additional debug information', false)
-  .option('--no-stash', 'disable the backup stash, and do not revert in case of errors', false)
   .option(
-    '-p, --concurrent <parallel tasks>',
-    'the number of tasks to run concurrently, or false to run tasks serially',
+    '-p, --concurrent <number|boolean>',
+    'the number of tasks to run concurrently, or false for serial',
     true
   )
+  .option('-c, --config [path]', 'path to configuration file, or - to read from stdin')
+  .option('--cwd [path]', 'run all tasks in specific directory, instead of the current')
+  .option('-d, --debug', 'print additional debug information', false)
+  .option('--no-stash', 'disable the backup stash, and do not revert in case of errors', false)
   .option('-q, --quiet', 'disable lint-staged’s own console output', false)
   .option('-r, --relative', 'pass relative filepaths to tasks', false)
   .option('-x, --shell [path]', 'skip parsing of tasks for better shell support', false)
@@ -75,12 +76,13 @@ const options = {
   allowEmpty: !!cmdlineOptions.allowEmpty,
   concurrent: JSON.parse(cmdlineOptions.concurrent),
   configPath: cmdlineOptions.config,
+  cwd: cmdlineOptions.cwd,
   debug: !!cmdlineOptions.debug,
   maxArgLength: getMaxArgLength() / 2,
-  stash: !!cmdlineOptions.stash, // commander inverts `no-<x>` flags to `!x`
   quiet: !!cmdlineOptions.quiet,
   relative: !!cmdlineOptions.relative,
   shell: cmdlineOptions.shell /* Either a boolean or a string pointing to the shell */,
+  stash: !!cmdlineOptions.stash, // commander inverts `no-<x>` flags to `!x`
   verbose: !!cmdlineOptions.verbose,
 }
 
diff --git a/lib/index.js b/lib/index.js
index e01d6d9..4d2c257 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -37,7 +37,7 @@ const lintStaged = async (
     concurrent = true,
     config: configObject,
     configPath,
-    cwd = process.cwd(),
+    cwd,
     debug = false,
     maxArgLength,
     quiet = false,
@@ -48,7 +48,7 @@ const lintStaged = async (
   } = {},
   logger = console
 ) => {
-  await validateOptions({ shell }, logger)
+  await validateOptions({ cwd, shell }, logger)
 
   // Unset GIT_LITERAL_PATHSPECS to not mess with path interpretation
   debugLog('Unset GIT_LITERAL_PATHSPECS (was `%s`)', process.env.GIT_LITERAL_PATHSPECS)
diff --git a/lib/runAll.js b/lib/runAll.js
index eebce3d..4604fdb 100644
--- a/lib/runAll.js
+++ b/lib/runAll.js
@@ -66,7 +66,7 @@ export const runAll = async (
     concurrent = true,
     configObject,
     configPath,
-    cwd = process.cwd(),
+    cwd,
     debug = false,
     maxArgLength,
     quiet = false,
@@ -77,7 +77,11 @@ export const runAll = async (
   },
   logger = console
 ) => {
-  debugLog('Running all linter scripts')
+  debugLog('Running all linter scripts...')
+
+  // Resolve relative CWD option
+  cwd = cwd ? path.resolve(cwd) : process.cwd()
+  debugLog('Using working directory `%s`', cwd)
 
   const ctx = getInitialState({ quiet })
 
diff --git a/lib/validateOptions.js b/lib/validateOptions.js
index 5e140f0..78b4c01 100644
--- a/lib/validateOptions.js
+++ b/lib/validateOptions.js
@@ -1,4 +1,5 @@
 import { constants, promises as fs } from 'fs'
+import path from 'path'
 
 import debug from 'debug'
 
@@ -10,6 +11,7 @@ const debugLog = debug('lint-staged:validateOptions')
 /**
  * Validate lint-staged options, either from the Node.js API or the command line flags.
  * @param {*} options
+ * @param {boolean|string} [options.cwd] - Current working directory
  * @param {boolean|string} [options.shell] - Skip parsing of tasks for better shell support
  *
  * @throws {InvalidOptionsError}
@@ -17,6 +19,17 @@ const debugLog = debug('lint-staged:validateOptions')
 export const validateOptions = async (options = {}, logger) => {
   debugLog('Validating options...')
 
+  /** Ensure the passed cwd option exists; it might also be relative */
+  if (typeof options.cwd === 'string') {
+    try {
+      const resolved = path.resolve(options.cwd)
+      await fs.access(resolved, constants.F_OK)
+    } catch (error) {
+      logger.error(invalidOption('cwd', options.cwd, error.message))
+      throw InvalidOptionsError
+    }
+  }
+
   /** Ensure the passed shell option is executable */
   if (typeof options.shell === 'string') {
     try {


Test patch:
diff --git a/test/validateOptions.spec.js b/test/validateOptions.spec.js
index c207d8f..ea0e369 100644
--- a/test/validateOptions.spec.js
+++ b/test/validateOptions.spec.js
@@ -1,4 +1,5 @@
 import { constants, promises as fs } from 'fs'
+import path from 'path'
 
 import makeConsoleMock from 'consolemock'
 
@@ -7,8 +8,6 @@ import { InvalidOptionsError } from '../lib/symbols'
 
 describe('validateOptions', () => {
   const mockAccess = jest.spyOn(fs, 'access')
-  mockAccess.mockImplementation(async () => {})
-
   beforeEach(() => {
     mockAccess.mockClear()
   })
@@ -18,49 +17,113 @@ describe('validateOptions', () => {
 
     const logger = makeConsoleMock()
 
+    mockAccess.mockImplementationOnce(async () => {})
+
     await expect(validateOptions({}, logger)).resolves.toBeUndefined()
     await expect(validateOptions(undefined, logger)).resolves.toBeUndefined()
 
     expect(logger.history()).toHaveLength(0)
   })
 
-  it('should resolve with valid string-valued shell option', async () => {
-    expect.assertions(4)
+  describe('cwd', () => {
+    it('should resolve with valid absolute cwd option', async () => {
+      expect.assertions(4)
 
-    const logger = makeConsoleMock()
+      const logger = makeConsoleMock()
 
-    await expect(validateOptions({ shell: '/bin/sh' }, logger)).resolves.toBeUndefined()
+      await expect(validateOptions({ cwd: process.cwd() }, logger)).resolves.toBeUndefined()
 
-    expect(mockAccess).toHaveBeenCalledTimes(1)
-    expect(mockAccess).toHaveBeenCalledWith('/bin/sh', constants.X_OK)
+      expect(mockAccess).toHaveBeenCalledTimes(1)
+      expect(mockAccess).toHaveBeenCalledWith(process.cwd(), constants.F_OK)
 
-    expect(logger.history()).toHaveLength(0)
+      expect(logger.history()).toHaveLength(0)
+    })
+
+    it('should resolve with valid relative cwd option', async () => {
+      expect.assertions(4)
+
+      const logger = makeConsoleMock()
+
+      await expect(validateOptions({ cwd: 'test' }, logger)).resolves.toBeUndefined()
+
+      expect(mockAccess).toHaveBeenCalledTimes(1)
+      expect(mockAccess).toHaveBeenCalledWith(path.join(process.cwd(), 'test'), constants.F_OK)
+
+      expect(logger.history()).toHaveLength(0)
+    })
+
+    it('should reject with invalid cwd option', async () => {
+      expect.assertions(4)
+
+      const logger = makeConsoleMock()
+
+      await expect(validateOptions({ cwd: 'non_existent' }, logger)).rejects.toThrowError(
+        InvalidOptionsError
+      )
+
+      expect(mockAccess).toHaveBeenCalledTimes(1)
+      expect(mockAccess).toHaveBeenCalledWith(
+        path.join(process.cwd(), 'non_existent'),
+        constants.F_OK
+      )
+
+      expect(logger.printHistory()).toMatchInlineSnapshot(`
+        "
+        ERROR ✖ Validation Error:
+
+          Invalid value for option 'cwd': non_existent
+
+          ENOENT: no such file or directory, access '${path
+            .join(process.cwd(), 'non_existent')
+            // Windows test fix: D:\something -> D:\\something
+            .replace(/\\/g, '\\\\')}'
+
+        See https://github.com/okonet/lint-staged#command-line-flags"
+      `)
+    })
   })
 
-  it('should reject with invalid string-valued shell option', async () => {
-    expect.assertions(5)
+  describe('shell', () => {
+    it('should resolve with valid string-valued shell option', async () => {
+      expect.assertions(4)
 
-    const logger = makeConsoleMock()
+      const logger = makeConsoleMock()
+
+      mockAccess.mockImplementationOnce(async () => {})
+
+      await expect(validateOptions({ shell: '/bin/sh' }, logger)).resolves.toBeUndefined()
+
+      expect(mockAccess).toHaveBeenCalledTimes(1)
+      expect(mockAccess).toHaveBeenCalledWith('/bin/sh', constants.X_OK)
+
+      expect(logger.history()).toHaveLength(0)
+    })
+
+    it('should reject with invalid string-valued shell option', async () => {
+      expect.assertions(5)
+
+      const logger = makeConsoleMock()
 
-    mockAccess.mockImplementationOnce(() => Promise.reject(new Error('Failed')))
+      mockAccess.mockImplementationOnce(() => Promise.reject(new Error('Failed')))
 
-    await expect(validateOptions({ shell: '/bin/sh' }, logger)).rejects.toThrowError(
-      InvalidOptionsError
-    )
+      await expect(validateOptions({ shell: '/bin/sh' }, logger)).rejects.toThrowError(
+        InvalidOptionsError
+      )
 
-    expect(mockAccess).toHaveBeenCalledTimes(1)
-    expect(mockAccess).toHaveBeenCalledWith('/bin/sh', constants.X_OK)
+      expect(mockAccess).toHaveBeenCalledTimes(1)
+      expect(mockAccess).toHaveBeenCalledWith('/bin/sh', constants.X_OK)
 
-    expect(logger.history()).toHaveLength(1)
-    expect(logger.printHistory()).toMatchInlineSnapshot(`
-      "
-      ERROR ✖ Validation Error:
+      expect(logger.history()).toHaveLength(1)
+      expect(logger.printHistory()).toMatchInlineSnapshot(`
+        "
+        ERROR ✖ Validation Error:
 
-        Invalid value for option 'shell': /bin/sh
+          Invalid value for option 'shell': /bin/sh
 
-        Failed
+          Failed
 
-      See https://github.com/okonet/lint-staged#command-line-flags"
-    `)
+        See https://github.com/okonet/lint-staged#command-line-flags"
+      `)
+    })
   })
 })