lint-staged 9.5.0-beta.2 → 9.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  Run linters against staged git files and don't let :poop: slip into your code base!
4
4
 
5
+ ---
6
+
7
+ ## 🚧 Help test `lint-staged@beta`!
8
+
9
+ Version 10 of `lint-staged` is coming with changes that help it run faster on large git repositories and prevent loss of data during errors. Please help test the `beta` version and report any inconsistencies in our [GitHub Issues](https://github.com/okonet/lint-staged/issues):
10
+
11
+ **Using npm**
12
+
13
+ npm install --save-dev lint-staged@beta
14
+
15
+ **Using yarn**
16
+
17
+ yarn add -D lint-staged@beta
18
+
19
+ ### Notable changes
20
+
21
+ - A git stash is created before running any tasks, so in case of errors any lost changes can be restored easily (and automatically unless lint-staged itself crashes)
22
+ - Instead of write-tree/read-tree, `lint-staged@beta` uses git stashes to hide unstaged changes while running tasks against staged files
23
+ - This results in a performance increase of up to 45x on very large repositories
24
+ - The behaviour of committing modifications during tasks (eg. `prettier --write && git add`) is different. The current version creates a diff of these modifications, and applies it against the original state, silently ignoring any errors. The `beta` version leaves modifications of staged files as-is, and then restores all hidden unstaged changes as patch. If applying the patch fails due to a merge conflict (because tasks have modified the same lines), a 3-way merge will be retried. If this also fails, the entire commit will fail and the original state will be restored.
25
+ - **TL;DR** the `beta` version will never skip committing any changes by tasks (due to a merge conflict), but might fail in very complex situations where unstaged changes cannot be restored cleanly. If this happens to you, we are very interested in a repeatable test scenario.
26
+
27
+ ---
28
+
5
29
  [![asciicast](https://asciinema.org/a/199934.svg)](https://asciinema.org/a/199934)
6
30
 
7
31
  ## Why
@@ -46,13 +70,14 @@ $ npx lint-staged --help
46
70
  Usage: lint-staged [options]
47
71
 
48
72
  Options:
49
- -V, --version output the version number
50
- -c, --config [path] Path to configuration file
51
- -r, --relative Pass relative filepaths to tasks
52
- -x, --shell Skip parsing of tasks for better shell support
53
- -q, --quiet Disable lint-staged’s own console output
54
- -d, --debug Enable debug mode
55
- -h, --help output usage information
73
+ -V, --version output the version number
74
+ -c, --config [path] Path to configuration file
75
+ -r, --relative Pass relative filepaths to tasks
76
+ -x, --shell Skip parsing of tasks for better shell support
77
+ -q, --quiet Disable lint-staged’s own console output
78
+ -d, --debug Enable debug mode
79
+ -p, --concurrent [parallel tasks] The number of tasks to run concurrently, or false to run tasks sequentially
80
+ -h, --help output usage information
56
81
  ```
57
82
 
58
83
  - **`--config [path]`**: This can be used to manually specify the `lint-staged` config file location. However, if the specified file cannot be found, it will error out instead of performing the usual search. You may pass a npm package name for configuration also.
@@ -62,6 +87,10 @@ Options:
62
87
  - **`--debug`**: Enabling the debug mode does the following:
63
88
  - `lint-staged` uses the [debug](https://github.com/visionmedia/debug) module internally to log 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*`.
64
89
  - Use the [`verbose` renderer](https://github.com/SamVerschueren/listr-verbose-renderer) for `listr`.
90
+ - **`--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:
91
+ - `false`: Run all tasks serially
92
+ - `true` (default) : _Infinite_ concurrency. Runs as many tasks in parallel as possible.
93
+ - `{number}`: Run the specified number of tasks in parallel, where `1` is equivalent to `false`.
65
94
 
66
95
  ## Configuration
67
96
 
@@ -108,7 +137,7 @@ Linter commands work on a subset of all staged files, defined by a _glob pattern
108
137
  - **`"*.js"`** will match all JS files, like `/test.js` and `/foo/bar/test.js`
109
138
  - **`"!(*test).js"`**. will match all JS files, except those ending in `test.js`, so `foo.js` but not `foo.test.js`
110
139
  - If the glob pattern does contain a slash (`/`), it will match for paths as well:
111
- - **`"/*.js"`** will match all JS files in the git repo root, so `/test.js` but not `/foo/bar/test.js`
140
+ - **`"./*.js"`** will match all JS files in the git repo root, so `/test.js` but not `/foo/bar/test.js`
112
141
  - **`"foo/**/\*.js"`** will match all JS files inside the`/foo`directory, so`/foo/bar/test.js`but not`/test.js`
113
142
 
114
143
  When matching, `lint-staged` will do the following
@@ -229,15 +258,15 @@ module.exports = {
229
258
 
230
259
  ## Reformatting the code
231
260
 
232
- Tools like [Prettier](https://prettier.io), ESLint/TSLint, or stylelint can reformat your code according to an appropriate config by running `prettier --write`/`eslint --fix`/`tslint --fix`/`stylelint --fix`. Lint-staged will automatically add any modifications to the commit as long as there are no errors.
261
+ Tools like [Prettier](https://prettier.io), ESLint/TSLint, or stylelint can reformat your code according to an appropriate config by running `prettier --write`/`eslint --fix`/`tslint --fix`/`stylelint --fix`. After the code is reformatted, we want it to be added to the same commit. This can be done using following config:
233
262
 
234
263
  ```json
235
264
  {
236
- "*.js": "prettier --write"
265
+ "*.js": ["prettier --write", "git add"]
237
266
  }
238
267
  ```
239
268
 
240
- Prior to version 10, tasks had to manually include `git add` as the final step. This behavior has been integrated into lint-staged itself in order to prevent race conditions with multiple tasks editing the same files. If lint-staged detects `git add` in task configurations, it will show a warning in the console. Please remove `git add` from your configuration after upgrading.
269
+ Starting from v8, lint-staged will stash your remaining changes (not added to the index) and restore them from stash afterwards if there are partially staged files detected. This allows you to create partial commits with hunks using `git add --patch`. See the [blog post](https://medium.com/@okonetchnikov/announcing-lint-staged-with-support-for-partially-staged-files-abc24a40d3ff)
241
270
 
242
271
  ## Examples
243
272
 
@@ -273,7 +302,7 @@ _Note we don’t pass a path as an argument for the runners. This is important s
273
302
 
274
303
  ```json
275
304
  {
276
- "*.js": "eslint --fix"
305
+ "*.js": ["eslint --fix", "git add"]
277
306
  }
278
307
  ```
279
308
 
@@ -285,7 +314,7 @@ If you wish to reuse a npm script defined in your package.json:
285
314
 
286
315
  ```json
287
316
  {
288
- "*.js": "npm run my-custom-script --"
317
+ "*.js": ["npm run my-custom-script --", "git add"]
289
318
  }
290
319
  ```
291
320
 
@@ -293,7 +322,7 @@ The following is equivalent:
293
322
 
294
323
  ```json
295
324
  {
296
- "*.js": "linter --arg1 --arg2"
325
+ "*.js": ["linter --arg1 --arg2", "git add"]
297
326
  }
298
327
  ```
299
328
 
@@ -313,19 +342,19 @@ For example, here is `jest` running on all `.js` files with the `NODE_ENV` varia
313
342
 
314
343
  ```json
315
344
  {
316
- "*.{js,jsx}": "prettier --write"
345
+ "*.{js,jsx}": ["prettier --write", "git add"]
317
346
  }
318
347
  ```
319
348
 
320
349
  ```json
321
350
  {
322
- "*.{ts,tsx}": "prettier --write"
351
+ "*.{ts,tsx}": ["prettier --write", "git add"]
323
352
  }
324
353
  ```
325
354
 
326
355
  ```json
327
356
  {
328
- "*.{md,html}": "prettier --write"
357
+ "*.{md,html}": ["prettier --write", "git add"]
329
358
  }
330
359
  ```
331
360
 
@@ -338,19 +367,19 @@ For example, here is `jest` running on all `.js` files with the `NODE_ENV` varia
338
367
  }
339
368
  ```
340
369
 
341
- ### Run PostCSS sorting and Stylelint to check
370
+ ### Run PostCSS sorting, add files to commit and run Stylelint to check
342
371
 
343
372
  ```json
344
373
  {
345
- "*.scss": "postcss --config path/to/your/config --replace", "stylelint"
374
+ "*.scss": ["postcss --config path/to/your/config --replace", "stylelint", "git add"]
346
375
  }
347
376
  ```
348
377
 
349
- ### Minify the images
378
+ ### Minify the images and add files to commit
350
379
 
351
380
  ```json
352
381
  {
353
- "*.{png,jpeg,jpg,gif,svg}": "imagemin-lint-staged"
382
+ "*.{png,jpeg,jpg,gif,svg}": ["imagemin-lint-staged", "git add"]
354
383
  }
355
384
  ```
356
385
 
@@ -367,7 +396,7 @@ See more on [this blog post](https://medium.com/@tomchentw/imagemin-lint-staged-
367
396
 
368
397
  ```json
369
398
  {
370
- "*.{js,jsx}": "flow focus-check"
399
+ "*.{js,jsx}": ["flow focus-check", "git add"]
371
400
  }
372
401
  ```
373
402
 
package/bin/lint-staged CHANGED
@@ -23,7 +23,7 @@ require('please-upgrade-node')(
23
23
 
24
24
  const cmdline = require('commander')
25
25
  const debugLib = require('debug')
26
- const lintStaged = require('../lib')
26
+ const lintStaged = require('../src')
27
27
 
28
28
  const debug = debugLib('lint-staged:bin')
29
29
 
@@ -34,6 +34,11 @@ cmdline
34
34
  .option('-x, --shell', 'Skip parsing of tasks for better shell support')
35
35
  .option('-q, --quiet', 'Disable lint-staged’s own console output')
36
36
  .option('-d, --debug', 'Enable debug mode')
37
+ .option(
38
+ '-p, --concurrent <parallel tasks>',
39
+ 'The number of tasks to run concurrently, or false to run tasks serially',
40
+ true
41
+ )
37
42
  .parse(process.argv)
38
43
 
39
44
  if (cmdline.debug) {
@@ -47,7 +52,8 @@ lintStaged({
47
52
  relative: !!cmdline.relative,
48
53
  shell: !!cmdline.shell,
49
54
  quiet: !!cmdline.quiet,
50
- debug: !!cmdline.debug
55
+ debug: !!cmdline.debug,
56
+ concurrent: cmdline.concurrent
51
57
  })
52
58
  .then(passed => {
53
59
  process.exitCode = passed ? 0 : 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lint-staged",
3
- "version": "9.5.0-beta.2",
3
+ "version": "9.5.0",
4
4
  "description": "Lint files staged by git",
5
5
  "license": "MIT",
6
6
  "repository": "https://github.com/okonet/lint-staged",
@@ -10,11 +10,11 @@
10
10
  "Suhas Karanth <sudo.suhas@gmail.com>",
11
11
  "Iiro Jäppinen <iiro@jappinen.fi> (https://iiro.fi)"
12
12
  ],
13
+ "main": "./src/index.js",
13
14
  "bin": "./bin/lint-staged",
14
- "main": "./lib/index.js",
15
15
  "files": [
16
- "bin",
17
- "lib"
16
+ "src",
17
+ "bin"
18
18
  ],
19
19
  "scripts": {
20
20
  "cz": "git-cz",
@@ -43,6 +43,7 @@
43
43
  "micromatch": "^4.0.2",
44
44
  "normalize-path": "^3.0.0",
45
45
  "please-upgrade-node": "^3.1.1",
46
+ "string-argv": "^0.3.0",
46
47
  "stringify-object": "^3.3.0"
47
48
  },
48
49
  "devDependencies": {
@@ -68,8 +69,8 @@
68
69
  "jest": "^24.8.0",
69
70
  "jest-snapshot-serializer-ansi": "^1.0.0",
70
71
  "jsonlint": "^1.6.3",
71
- "nanoid": "^2.1.1",
72
- "prettier": "1.18.2"
72
+ "prettier": "1.18.2",
73
+ "tmp": "0.1.0"
73
74
  },
74
75
  "config": {
75
76
  "commitizen": {
@@ -79,7 +80,7 @@
79
80
  "jest": {
80
81
  "collectCoverage": true,
81
82
  "collectCoverageFrom": [
82
- "lib/**/*.js"
83
+ "src/**/*.js"
83
84
  ],
84
85
  "setupFiles": [
85
86
  "./testSetup.js"
package/src/execGit.js ADDED
@@ -0,0 +1,13 @@
1
+ 'use strict'
2
+
3
+ const debug = require('debug')('lint-staged:git')
4
+ const execa = require('execa')
5
+
6
+ module.exports = async function execGit(cmd, options = {}) {
7
+ debug('Running git command', cmd)
8
+ const { stdout } = await execa('git', [].concat(cmd), {
9
+ ...options,
10
+ cwd: options.cwd || process.cwd()
11
+ })
12
+ return stdout
13
+ }
@@ -15,6 +15,7 @@ const debug = require('debug')('lint-staged:gen-tasks')
15
15
  * @param {boolean} [options.gitDir] - Git root directory
16
16
  * @param {boolean} [options.files] - Staged filepaths
17
17
  * @param {boolean} [options.relative] - Whether filepaths to should be relative to gitDir
18
+ * @returns {Promise}
18
19
  */
19
20
  module.exports = function generateTasks({
20
21
  config,
File without changes
@@ -0,0 +1,164 @@
1
+ 'use strict'
2
+
3
+ const del = require('del')
4
+ const debug = require('debug')('lint-staged:git')
5
+
6
+ const execGit = require('./execGit')
7
+
8
+ let workingCopyTree = null
9
+ let indexTree = null
10
+ let formattedIndexTree = null
11
+
12
+ async function writeTree(options) {
13
+ return execGit(['write-tree'], options)
14
+ }
15
+
16
+ async function getDiffForTrees(tree1, tree2, options) {
17
+ debug(`Generating diff between trees ${tree1} and ${tree2}...`)
18
+ return execGit(
19
+ [
20
+ 'diff-tree',
21
+ '--ignore-submodules',
22
+ '--binary',
23
+ '--no-color',
24
+ '--no-ext-diff',
25
+ '--unified=0',
26
+ tree1,
27
+ tree2
28
+ ],
29
+ options
30
+ )
31
+ }
32
+
33
+ async function hasPartiallyStagedFiles(options) {
34
+ const stdout = await execGit(['status', '--porcelain'], options)
35
+ if (!stdout) return false
36
+
37
+ const changedFiles = stdout.split('\n')
38
+ const partiallyStaged = changedFiles.filter(line => {
39
+ /**
40
+ * See https://git-scm.com/docs/git-status#_short_format
41
+ * The first letter of the line represents current index status,
42
+ * and second the working tree
43
+ */
44
+ const [index, workingTree] = line
45
+ return index !== ' ' && workingTree !== ' ' && index !== '?' && workingTree !== '?'
46
+ })
47
+
48
+ return partiallyStaged.length > 0
49
+ }
50
+
51
+ // eslint-disable-next-line
52
+ async function gitStashSave(options) {
53
+ debug('Stashing files...')
54
+ // Save ref to the current index
55
+ indexTree = await writeTree(options)
56
+ // Add working copy changes to index
57
+ await execGit(['add', '.'], options)
58
+ // Save ref to the working copy index
59
+ workingCopyTree = await writeTree(options)
60
+ // Restore the current index
61
+ await execGit(['read-tree', indexTree], options)
62
+ // Remove all modifications
63
+ await execGit(['checkout-index', '-af'], options)
64
+ // await execGit(['clean', '-dfx'], options)
65
+ debug('Done stashing files!')
66
+ return [workingCopyTree, indexTree]
67
+ }
68
+
69
+ async function updateStash(options) {
70
+ formattedIndexTree = await writeTree(options)
71
+ return formattedIndexTree
72
+ }
73
+
74
+ async function applyPatchFor(tree1, tree2, options) {
75
+ const diff = await getDiffForTrees(tree1, tree2, options)
76
+ /**
77
+ * This is crucial for patch to work
78
+ * For some reason, git-apply requires that the patch ends with the newline symbol
79
+ * See http://git.661346.n2.nabble.com/Bug-in-Git-Gui-Creates-corrupt-patch-td2384251.html
80
+ * and https://stackoverflow.com/questions/13223868/how-to-stage-line-by-line-in-git-gui-although-no-newline-at-end-of-file-warnin
81
+ */
82
+ // TODO: Figure out how to test this. For some reason tests were working but in the real env it was failing
83
+ if (diff) {
84
+ try {
85
+ /**
86
+ * Apply patch to index. We will apply it with --reject so it it will try apply hunk by hunk
87
+ * We're not interested in failied hunks since this mean that formatting conflicts with user changes
88
+ * and we prioritize user changes over formatter's
89
+ */
90
+ await execGit(
91
+ ['apply', '-v', '--whitespace=nowarn', '--reject', '--recount', '--unidiff-zero'],
92
+ {
93
+ ...options,
94
+ input: `${diff}\n` // TODO: This should also work on Windows but test would be good
95
+ }
96
+ )
97
+ } catch (err) {
98
+ debug('Could not apply patch to the stashed files cleanly')
99
+ debug(err)
100
+ debug('Patch content:')
101
+ debug(diff)
102
+ throw new Error('Could not apply patch to the stashed files cleanly.', err)
103
+ }
104
+ }
105
+ }
106
+
107
+ async function gitStashPop(options) {
108
+ if (workingCopyTree === null) {
109
+ throw new Error('Trying to restore from stash but could not find working copy stash.')
110
+ }
111
+
112
+ debug('Restoring working copy')
113
+ // Restore the stashed files in the index
114
+ await execGit(['read-tree', workingCopyTree], options)
115
+ // and sync it to the working copy (i.e. update files on fs)
116
+ await execGit(['checkout-index', '-af'], options)
117
+
118
+ // Then, restore the index after working copy is restored
119
+ if (indexTree !== null && formattedIndexTree === null) {
120
+ // Restore changes that were in index if there are no formatting changes
121
+ debug('Restoring index')
122
+ await execGit(['read-tree', indexTree], options)
123
+ } else {
124
+ /**
125
+ * There are formatting changes we want to restore in the index
126
+ * and in the working copy. So we start by restoring the index
127
+ * and after that we'll try to carry as many as possible changes
128
+ * to the working copy by applying the patch with --reject option.
129
+ */
130
+ debug('Restoring index with formatting changes')
131
+ await execGit(['read-tree', formattedIndexTree], options)
132
+ try {
133
+ await applyPatchFor(indexTree, formattedIndexTree, options)
134
+ } catch (err) {
135
+ debug(
136
+ 'Found conflicts between formatters and local changes. Formatters changes will be ignored for conflicted hunks.'
137
+ )
138
+ /**
139
+ * Clean up working directory from *.rej files that contain conflicted hanks.
140
+ * These hunks are coming from formatters so we'll just delete them since they are irrelevant.
141
+ */
142
+ try {
143
+ const rejFiles = await del(['*.rej'], options)
144
+ debug('Deleted files and folders:\n', rejFiles.join('\n'))
145
+ } catch (delErr) {
146
+ debug('Error deleting *.rej files', delErr)
147
+ }
148
+ }
149
+ }
150
+ // Clean up references
151
+ workingCopyTree = null
152
+ indexTree = null
153
+ formattedIndexTree = null
154
+
155
+ return null
156
+ }
157
+
158
+ module.exports = {
159
+ execGit,
160
+ gitStashSave,
161
+ gitStashPop,
162
+ hasPartiallyStagedFiles,
163
+ updateStash
164
+ }
@@ -48,12 +48,21 @@ function loadConfig(configPath) {
48
48
  * @param {boolean} [options.shell] - Skip parsing of tasks for better shell support
49
49
  * @param {boolean} [options.quiet] - Disable lint-staged’s own console output
50
50
  * @param {boolean} [options.debug] - Enable debug mode
51
+ * @param {boolean | number} [options.concurrent] - The number of tasks to run concurrently, or false to run tasks serially
51
52
  * @param {Logger} [logger]
52
53
  *
53
54
  * @returns {Promise<boolean>} Promise of whether the linting passed or failed
54
55
  */
55
56
  module.exports = function lintStaged(
56
- { configPath, config, relative = false, shell = false, quiet = false, debug = false } = {},
57
+ {
58
+ configPath,
59
+ config,
60
+ relative = false,
61
+ shell = false,
62
+ quiet = false,
63
+ debug = false,
64
+ concurrent = true
65
+ } = {},
57
66
  logger = console
58
67
  ) {
59
68
  debugLog('Loading config using `cosmiconfig`')
@@ -76,7 +85,7 @@ module.exports = function lintStaged(
76
85
  debugLog('lint-staged config:\n%O', config)
77
86
  }
78
87
 
79
- return runAll({ config, relative, shell, quiet, debug }, logger)
88
+ return runAll({ config, relative, shell, quiet, debug, concurrent }, logger)
80
89
  .then(() => {
81
90
  debugLog('tasks were executed successfully!')
82
91
  return Promise.resolve(true)
@@ -13,7 +13,7 @@ const debug = require('debug')('lint-staged:make-cmd-tasks')
13
13
  * @param {string} options.gitDir
14
14
  * @param {Boolean} shell
15
15
  */
16
- module.exports = function makeCmdTasks({ commands, files, gitDir, shell }) {
16
+ module.exports = async function makeCmdTasks({ commands, files, gitDir, shell }) {
17
17
  debug('Creating listr tasks for commands %o', commands)
18
18
  const commandsArray = Array.isArray(commands) ? commands : [commands]
19
19
 
@@ -41,11 +41,8 @@ module.exports = function makeCmdTasks({ commands, files, gitDir, shell }) {
41
41
  title = mockCommands[i].replace(/\[file\].*\[file\]/, '[file]')
42
42
  }
43
43
 
44
- tasks.push({
45
- title,
46
- command,
47
- task: resolveTaskFn({ command, files, gitDir, isFn, shell })
48
- })
44
+ const task = { title, task: resolveTaskFn({ gitDir, isFn, command, files, shell }) }
45
+ tasks.push(task)
49
46
  })
50
47
 
51
48
  return tasks
File without changes
File without changes
@@ -4,9 +4,27 @@ const chalk = require('chalk')
4
4
  const dedent = require('dedent')
5
5
  const execa = require('execa')
6
6
  const symbols = require('log-symbols')
7
+ const stringArgv = require('string-argv')
7
8
 
8
9
  const debug = require('debug')('lint-staged:task')
9
10
 
11
+ /**
12
+ * Execute the given linter cmd using execa and
13
+ * return the promise.
14
+ *
15
+ * @param {string} cmd
16
+ * @param {Array<string>} args
17
+ * @param {Object} execaOptions
18
+ * @return {Promise} child_process
19
+ */
20
+ const execLinter = (cmd, args, execaOptions) => {
21
+ debug('cmd:', cmd)
22
+ if (args) debug('args:', args)
23
+ debug('execaOptions:', execaOptions)
24
+
25
+ return args ? execa(cmd, args, execaOptions) : execa(cmd, execaOptions)
26
+ }
27
+
10
28
  const successMsg = linter => `${symbols.success} ${linter} passed!`
11
29
 
12
30
  /**
@@ -55,22 +73,21 @@ function makeErr(linter, result, context = {}) {
55
73
  }
56
74
 
57
75
  /**
58
- * Returns the task function for the linter.
76
+ * Returns the task function for the linter. It handles chunking for file paths
77
+ * if the OS is Windows.
59
78
  *
60
79
  * @param {Object} options
61
80
  * @param {string} options.command — Linter task
62
81
  * @param {String} options.gitDir - Current git repo path
63
82
  * @param {Boolean} options.isFn - Whether the linter task is a function
64
- * @param {Array<string>} options.files — Filepaths to run the linter task against
83
+ * @param {Array<string>} options.pathsToLint — Filepaths to run the linter task against
65
84
  * @param {Boolean} [options.relative] — Whether the filepaths should be relative
66
85
  * @param {Boolean} [options.shell] — Whether to skip parsing linter task for better shell support
67
86
  * @returns {function(): Promise<Array<string>>}
68
87
  */
69
88
  module.exports = function resolveTaskFn({ command, files, gitDir, isFn, relative, shell = false }) {
70
- const cmd = isFn ? command : `${command} ${files.join(' ')}`
71
- debug('cmd:', cmd)
72
-
73
89
  const execaOptions = { preferLocal: true, reject: false, shell }
90
+
74
91
  if (relative) {
75
92
  execaOptions.cwd = process.cwd()
76
93
  } else if (/^git(\.exe)?/i.test(command) && gitDir !== process.cwd()) {
@@ -78,15 +95,27 @@ module.exports = function resolveTaskFn({ command, files, gitDir, isFn, relative
78
95
  // e.g `npm` should run tasks in the actual CWD
79
96
  execaOptions.cwd = gitDir
80
97
  }
81
- debug('execaOptions:', execaOptions)
82
98
 
83
- return async ctx => {
84
- const result = await execa.command(cmd, execaOptions)
99
+ let cmd
100
+ let args
85
101
 
86
- if (result.failed || result.killed || result.signal != null) {
87
- throw makeErr(command, result, ctx)
88
- }
89
-
90
- return successMsg(command)
102
+ if (shell) {
103
+ execaOptions.shell = true
104
+ // If `shell`, passed command shouldn't be parsed
105
+ // If `linter` is a function, command already includes `files`.
106
+ cmd = isFn ? command : `${command} ${files.join(' ')}`
107
+ } else {
108
+ const [parsedCmd, ...parsedArgs] = stringArgv.parseArgsStringToArgv(command)
109
+ cmd = parsedCmd
110
+ args = isFn ? parsedArgs : parsedArgs.concat(files)
91
111
  }
112
+
113
+ return ctx =>
114
+ execLinter(cmd, args, execaOptions).then(result => {
115
+ if (result.failed || result.killed || result.signal != null) {
116
+ throw makeErr(command, result, ctx)
117
+ }
118
+
119
+ return successMsg(command)
120
+ })
92
121
  }
package/src/runAll.js ADDED
@@ -0,0 +1,147 @@
1
+ 'use strict'
2
+
3
+ /** @typedef {import('./index').Logger} Logger */
4
+
5
+ const chalk = require('chalk')
6
+ const dedent = require('dedent')
7
+ const Listr = require('listr')
8
+ const symbols = require('log-symbols')
9
+
10
+ const generateTasks = require('./generateTasks')
11
+ const getStagedFiles = require('./getStagedFiles')
12
+ const git = require('./gitWorkflow')
13
+ const makeCmdTasks = require('./makeCmdTasks')
14
+ const resolveGitDir = require('./resolveGitDir')
15
+
16
+ const debugLog = require('debug')('lint-staged:run')
17
+
18
+ /**
19
+ * https://serverfault.com/questions/69430/what-is-the-maximum-length-of-a-command-line-in-mac-os-x
20
+ * https://support.microsoft.com/en-us/help/830473/command-prompt-cmd-exe-command-line-string-limitation
21
+ * https://unix.stackexchange.com/a/120652
22
+ */
23
+ const MAX_ARG_LENGTH =
24
+ (process.platform === 'darwin' && 262144) || (process.platform === 'win32' && 8191) || 131072
25
+
26
+ /**
27
+ * Executes all tasks and either resolves or rejects the promise
28
+ *
29
+ * @param {object} options
30
+ * @param {Object} [options.config] - Task configuration
31
+ * @param {Object} [options.cwd] - Current working directory
32
+ * @param {boolean} [options.relative] - Pass relative filepaths to tasks
33
+ * @param {boolean} [options.shell] - Skip parsing of tasks for better shell support
34
+ * @param {boolean} [options.quiet] - Disable lint-staged’s own console output
35
+ * @param {boolean} [options.debug] - Enable debug mode
36
+ * @param {boolean | number} [options.concurrent] - The number of tasks to run concurrently, or false to run tasks serially
37
+ * @param {Logger} logger
38
+ * @returns {Promise}
39
+ */
40
+ module.exports = async function runAll(
41
+ {
42
+ config,
43
+ cwd = process.cwd(),
44
+ debug = false,
45
+ quiet = false,
46
+ relative = false,
47
+ shell = false,
48
+ concurrent = true
49
+ },
50
+ logger = console
51
+ ) {
52
+ debugLog('Running all linter scripts')
53
+ const gitDir = await resolveGitDir({ cwd })
54
+
55
+ if (!gitDir) {
56
+ throw new Error('Current directory is not a git directory!')
57
+ }
58
+
59
+ debugLog('Resolved git directory to be `%s`', gitDir)
60
+
61
+ const files = await getStagedFiles({ cwd: gitDir })
62
+
63
+ if (!files) {
64
+ throw new Error('Unable to get staged files!')
65
+ }
66
+
67
+ debugLog('Loaded list of staged files in git:\n%O', files)
68
+
69
+ const argLength = files.join(' ').length
70
+ if (argLength > MAX_ARG_LENGTH) {
71
+ logger.warn(
72
+ dedent`${symbols.warning} ${chalk.yellow(
73
+ `lint-staged generated an argument string of ${argLength} characters, and commands might not run correctly on your platform.
74
+ It is recommended to use functions as linters and split your command based on the number of staged files. For more info, please visit:
75
+ https://github.com/okonet/lint-staged#using-js-functions-to-customize-linter-commands`
76
+ )}`
77
+ )
78
+ }
79
+
80
+ const tasks = generateTasks({ config, cwd, gitDir, files, relative }).map(task => ({
81
+ title: `Running tasks for ${task.pattern}`,
82
+ task: async () =>
83
+ new Listr(
84
+ await makeCmdTasks({ commands: task.commands, files: task.fileList, gitDir, shell }),
85
+ {
86
+ // In sub-tasks we don't want to run concurrently
87
+ // and we want to abort on errors
88
+ dateFormat: false,
89
+ concurrent: false,
90
+ exitOnError: true
91
+ }
92
+ ),
93
+ skip: () => {
94
+ if (task.fileList.length === 0) {
95
+ return `No staged files match ${task.pattern}`
96
+ }
97
+ return false
98
+ }
99
+ }))
100
+
101
+ const listrOptions = {
102
+ dateFormat: false,
103
+ renderer: (quiet && 'silent') || (debug && 'verbose') || 'update'
104
+ }
105
+
106
+ // If all of the configured "linters" should be skipped
107
+ // avoid executing any lint-staged logic
108
+ if (tasks.every(task => task.skip())) {
109
+ logger.log('No staged files match any of provided globs.')
110
+ return 'No tasks to run.'
111
+ }
112
+
113
+ return new Listr(
114
+ [
115
+ {
116
+ title: 'Stashing changes...',
117
+ skip: async () => {
118
+ const hasPSF = await git.hasPartiallyStagedFiles({ cwd: gitDir })
119
+ if (!hasPSF) {
120
+ return 'No partially staged files found...'
121
+ }
122
+ return false
123
+ },
124
+ task: ctx => {
125
+ ctx.hasStash = true
126
+ return git.gitStashSave({ cwd: gitDir })
127
+ }
128
+ },
129
+ {
130
+ title: 'Running tasks...',
131
+ task: () => new Listr(tasks, { ...listrOptions, concurrent, exitOnError: false })
132
+ },
133
+ {
134
+ title: 'Updating stash...',
135
+ enabled: ctx => ctx.hasStash,
136
+ skip: ctx => ctx.hasErrors && 'Skipping stash update since some tasks exited with errors',
137
+ task: () => git.updateStash({ cwd: gitDir })
138
+ },
139
+ {
140
+ title: 'Restoring local changes...',
141
+ enabled: ctx => ctx.hasStash,
142
+ task: () => git.gitStashPop({ cwd: gitDir })
143
+ }
144
+ ],
145
+ listrOptions
146
+ ).run()
147
+ }
File without changes
package/lib/execGit.js DELETED
@@ -1,17 +0,0 @@
1
- 'use strict'
2
-
3
- const debug = require('debug')('lint-staged:git')
4
- const execa = require('execa')
5
-
6
- module.exports = async function execGit(cmd, options = {}) {
7
- debug('Running git command', cmd)
8
- try {
9
- const { stdout } = await execa('git', [].concat(cmd), {
10
- ...options,
11
- cwd: options.cwd || process.cwd()
12
- })
13
- return stdout
14
- } catch ({ all }) {
15
- throw new Error(all)
16
- }
17
- }
package/lib/file.js DELETED
@@ -1,51 +0,0 @@
1
- 'use strict'
2
-
3
- const debug = require('debug')('lint-staged:file')
4
- const fs = require('fs')
5
-
6
- /**
7
- * Check if file exists and is accessible
8
- * @param {String} filename
9
- * @returns {Promise<Boolean>}
10
- */
11
- module.exports.checkFile = filename =>
12
- new Promise(resolve => {
13
- debug('Trying to access `%s`', filename)
14
- fs.access(filename, fs.constants.R_OK, error => {
15
- if (error) {
16
- debug('Unable to access file `%s` with error:', filename)
17
- debug(error)
18
- } else {
19
- debug('Successfully accesses file `%s`', filename)
20
- }
21
-
22
- resolve(!error)
23
- })
24
- })
25
-
26
- /**
27
- * @param {String} filename
28
- * @returns {Promise<Buffer|Null>}
29
- */
30
- module.exports.readBufferFromFile = filename =>
31
- new Promise(resolve => {
32
- debug('Reading buffer from file `%s`', filename)
33
- fs.readFile(filename, (error, file) => {
34
- debug('Done reading buffer from file `%s`!', filename)
35
- resolve(file)
36
- })
37
- })
38
-
39
- /**
40
- * @param {String} filename
41
- * @param {Buffer} buffer
42
- * @returns {Promise<Void>}
43
- */
44
- module.exports.writeBufferToFile = (filename, buffer) =>
45
- new Promise(resolve => {
46
- debug('Writing buffer to file `%s`', filename)
47
- fs.writeFile(filename, buffer, () => {
48
- debug('Done writing buffer to file `%s`!', filename)
49
- resolve()
50
- })
51
- })
@@ -1,184 +0,0 @@
1
- 'use strict'
2
-
3
- const debug = require('debug')('lint-staged:git')
4
- const path = require('path')
5
-
6
- const execGit = require('./execGit')
7
- const { checkFile, readBufferFromFile, writeBufferToFile } = require('./file')
8
-
9
- const MERGE_HEAD = 'MERGE_HEAD'
10
- const MERGE_MODE = 'MERGE_MODE'
11
- const MERGE_MSG = 'MERGE_MSG'
12
-
13
- const STASH = 'lint-staged automatic backup'
14
-
15
- const gitApplyArgs = ['apply', '-v', '--whitespace=nowarn', '--recount', '--unidiff-zero']
16
-
17
- class GitWorkflow {
18
- constructor(cwd) {
19
- this.execGit = (args, options = {}) => execGit(args, { ...options, cwd })
20
- this.unstagedDiff = null
21
- this.cwd = cwd
22
-
23
- /**
24
- * These three files hold state about an ongoing git merge
25
- * Resolve paths during constructor
26
- */
27
- this.mergeHeadFile = path.resolve(this.cwd, '.git', MERGE_HEAD)
28
- this.mergeModeFile = path.resolve(this.cwd, '.git', MERGE_MODE)
29
- this.mergeMsgFile = path.resolve(this.cwd, '.git', MERGE_MSG)
30
- }
31
-
32
- /**
33
- * Get name of backup stash
34
- *
35
- * @param {Object} [options]
36
- * @returns {Promise<Object>}
37
- */
38
- async getBackupStash() {
39
- const stashes = await this.execGit(['stash', 'list'])
40
- const index = stashes.split('\n').findIndex(line => line.includes(STASH))
41
- return `stash@{${index}}`
42
- }
43
-
44
- /**
45
- * Create backup stashes, one of everything and one of only staged changes
46
- * Staged files are left in the index for running tasks
47
- *
48
- * @param {Object} [options]
49
- * @returns {Promise<void>}
50
- */
51
- async stashBackup() {
52
- debug('Backing up original state...')
53
-
54
- // Git stash loses metadata about a possible merge mode
55
- // Manually check and backup if necessary
56
- if (await checkFile(this.mergeHeadFile)) {
57
- debug('Detected current merge mode!')
58
- debug('Backing up merge state...')
59
- await Promise.all([
60
- readBufferFromFile(this.mergeHeadFile).then(
61
- mergeHead => (this.mergeHeadBuffer = mergeHead)
62
- ),
63
- readBufferFromFile(this.mergeModeFile).then(
64
- mergeMode => (this.mergeModeBuffer = mergeMode)
65
- ),
66
- readBufferFromFile(this.mergeMsgFile).then(mergeMsg => (this.mergeMsgBuffer = mergeMsg))
67
- ])
68
- debug('Done backing up merge state!')
69
- }
70
-
71
- // Save stash of entire original state, including unstaged and untracked changes.
72
- // This should remove all changes from the index.
73
- // The `--keep-index` option cannot be used since it resurrects deleted files on
74
- // git versions before v2.23.0 (https://github.com/git/git/blob/master/Documentation/RelNotes/2.23.0.txt#L322)
75
- await this.execGit(['stash', 'save', '--quiet', '--include-untracked', STASH])
76
- // Apply only the staged changes back to index
77
- await this.execGit(['stash', 'apply', '--index', await this.getBackupStash()])
78
- // Checkout everything just in case there are unstaged files left behind.
79
- await this.execGit(['checkout', '.'])
80
- // Since only staged files are now present, get a diff of unstaged changes
81
- // by comparing current index against original stash, but in reverse
82
- this.unstagedDiff = await this.execGit([
83
- 'diff',
84
- '--unified=0',
85
- '--no-color',
86
- '--no-ext-diff',
87
- '--patch',
88
- await this.getBackupStash(),
89
- '-R' // Show diff in reverse
90
- ])
91
- debug('Done backing up original state!')
92
- }
93
-
94
- /**
95
- * Applies back task modifications, and unstaged changes hidden in the stash.
96
- * In case of a merge-conflict retry with 3-way merge.
97
- *
98
- * @param {Object} [options]
99
- * @returns {Promise<void>}
100
- */
101
- async applyModifications() {
102
- let modifiedFiles = await this.execGit(['ls-files', '--modified'])
103
- if (modifiedFiles) {
104
- modifiedFiles = modifiedFiles.split('\n')
105
- debug('Detected files modified by tasks:')
106
- debug(modifiedFiles)
107
- debug('Adding files to index...')
108
- await this.execGit(['add', modifiedFiles])
109
- debug('Done adding files to index!')
110
- }
111
-
112
- if (this.unstagedDiff) {
113
- debug('Restoring unstaged changes...')
114
- try {
115
- await this.execGit(gitApplyArgs, { input: `${this.unstagedDiff}\n` })
116
- } catch (error) {
117
- debug('Error while restoring changes:')
118
- debug(error)
119
- debug('Retrying with 3-way merge')
120
-
121
- try {
122
- // Retry with `--3way` if normal apply fails
123
- await this.execGit([...gitApplyArgs, '--3way'], { input: `${this.unstagedDiff}\n` })
124
- } catch (error2) {
125
- debug('Error while restoring unstaged changes using 3-way merge:')
126
- debug(error2)
127
- throw new Error('Unstaged changes could not be restored due to a merge conflict!')
128
- }
129
- }
130
- debug('Done restoring unstaged changes!')
131
- }
132
-
133
- // Restore untracked files by reading from the third commit associated with the backup stash
134
- // Git will return with error code if the commit doesn't exist
135
- // See https://stackoverflow.com/a/52357762
136
- try {
137
- const backupStash = await this.getBackupStash()
138
- const output = await this.execGit(['show', '--format=%b', `${backupStash}^3`])
139
- const untrackedDiff = output.replace(/^\n*/, '') // remove empty lines from start of output
140
- if (!untrackedDiff) return
141
- await this.execGit([...gitApplyArgs], { input: `${untrackedDiff}\n` })
142
- } catch (err) {} // eslint-disable-line no-empty
143
- }
144
-
145
- /**
146
- * Restore original HEAD state in case of errors
147
- *
148
- * @param {Object} [options]
149
- * @returns {Promise<void>}
150
- */
151
- async restoreOriginalState() {
152
- debug('Restoring original state...')
153
- const original = await this.getBackupStash()
154
- await this.execGit(['reset', '--hard', 'HEAD'])
155
- await this.execGit(['stash', 'apply', '--quiet', '--index', original])
156
- debug('Done restoring original state!')
157
- }
158
-
159
- /**
160
- * Drop the created stashes after everything has run
161
- *
162
- * @param {Object} [options]
163
- * @returns {Promise<void>}
164
- */
165
- async dropBackup() {
166
- debug('Dropping backup stash...')
167
- const original = await this.getBackupStash()
168
- await this.execGit(['stash', 'drop', '--quiet', original])
169
- debug('Done dropping backup stash!')
170
-
171
- if (this.mergeHeadBuffer) {
172
- debug('Detected backup merge state!')
173
- debug('Restoring merge state...')
174
- await Promise.all([
175
- writeBufferToFile(this.mergeHeadFile, this.mergeHeadBuffer),
176
- writeBufferToFile(this.mergeModeFile, this.mergeModeBuffer),
177
- writeBufferToFile(this.mergeMsgFile, this.mergeMsgBuffer)
178
- ])
179
- debug('Done restoring merge state!')
180
- }
181
- }
182
- }
183
-
184
- module.exports = GitWorkflow
package/lib/runAll.js DELETED
@@ -1,168 +0,0 @@
1
- 'use strict'
2
-
3
- /** @typedef {import('./index').Logger} Logger */
4
-
5
- const chalk = require('chalk')
6
- const Listr = require('listr')
7
- const symbols = require('log-symbols')
8
-
9
- const generateTasks = require('./generateTasks')
10
- const getStagedFiles = require('./getStagedFiles')
11
- const GitWorkflow = require('./gitWorkflow')
12
- const makeCmdTasks = require('./makeCmdTasks')
13
- const resolveGitDir = require('./resolveGitDir')
14
-
15
- const debugLog = require('debug')('lint-staged:run')
16
-
17
- /**
18
- * https://serverfault.com/questions/69430/what-is-the-maximum-length-of-a-command-line-in-mac-os-x
19
- * https://support.microsoft.com/en-us/help/830473/command-prompt-cmd-exe-command-line-string-limitation
20
- * https://unix.stackexchange.com/a/120652
21
- */
22
- const MAX_ARG_LENGTH =
23
- (process.platform === 'darwin' && 262144) || (process.platform === 'win32' && 8191) || 131072
24
-
25
- /**
26
- * Executes all tasks and either resolves or rejects the promise
27
- *
28
- * @param {object} options
29
- * @param {Object} [options.config] - Task configuration
30
- * @param {Object} [options.cwd] - Current working directory
31
- * @param {boolean} [options.relative] - Pass relative filepaths to tasks
32
- * @param {boolean} [options.shell] - Skip parsing of tasks for better shell support
33
- * @param {boolean} [options.quiet] - Disable lint-staged’s own console output
34
- * @param {boolean} [options.debug] - Enable debug mode
35
- * @param {Logger} logger
36
- * @returns {Promise}
37
- */
38
- module.exports = async function runAll(
39
- { config, cwd = process.cwd(), debug = false, quiet = false, relative = false, shell = false },
40
- logger = console
41
- ) {
42
- debugLog('Running all linter scripts')
43
-
44
- const gitDir = await resolveGitDir({ cwd })
45
-
46
- if (!gitDir) {
47
- throw new Error('Current directory is not a git directory!')
48
- }
49
-
50
- debugLog('Resolved git directory to be `%s`', gitDir)
51
-
52
- const files = await getStagedFiles({ cwd: gitDir })
53
-
54
- if (!files) {
55
- throw new Error('Unable to get staged files!')
56
- }
57
-
58
- debugLog('Loaded list of staged files in git:\n%O', files)
59
-
60
- const argLength = files.join(' ').length
61
- if (argLength > MAX_ARG_LENGTH) {
62
- logger.warn(`
63
- ${symbols.warning} ${chalk.yellow(
64
- `lint-staged generated an argument string of ${argLength} characters, and commands might not run correctly on your platform.
65
- It is recommended to use functions as linters and split your command based on the number of staged files. For more info, please visit:
66
- https://github.com/okonet/lint-staged#using-js-functions-to-customize-linter-commands`
67
- )}
68
- `)
69
- }
70
-
71
- const tasks = generateTasks({ config, cwd, gitDir, files, relative })
72
-
73
- // lint-staged 10 will automatically add modifications to index
74
- // Warn user when their command includes `git add`
75
- let hasDeprecatedGitAdd = false
76
-
77
- const listrTasks = tasks.map(task => {
78
- const subTasks = makeCmdTasks({ commands: task.commands, files: task.fileList, gitDir, shell })
79
-
80
- if (subTasks.some(subTask => subTask.command.includes('git add'))) {
81
- hasDeprecatedGitAdd = true
82
- }
83
-
84
- return {
85
- title: `Running tasks for ${task.pattern}`,
86
- task: async () =>
87
- new Listr(subTasks, {
88
- // In sub-tasks we don't want to run concurrently
89
- // and we want to abort on errors
90
- dateFormat: false,
91
- concurrent: false,
92
- exitOnError: true
93
- }),
94
- skip: () => {
95
- if (task.fileList.length === 0) {
96
- return `No staged files match ${task.pattern}`
97
- }
98
- return false
99
- }
100
- }
101
- })
102
-
103
- if (hasDeprecatedGitAdd) {
104
- logger.warn(`${symbols.warning} ${chalk.yellow(
105
- `Some of your tasks use \`git add\` command. Please remove it from the config since all modifications made by tasks will be automatically added to the git commit index.`
106
- )}
107
- `)
108
- }
109
-
110
- // If all of the configured tasks should be skipped
111
- // avoid executing any lint-staged logic
112
- if (listrTasks.every(task => task.skip())) {
113
- logger.log('No staged files match any of provided globs.')
114
- return 'No tasks to run.'
115
- }
116
-
117
- const listrOptions = {
118
- dateFormat: false,
119
- renderer: (quiet && 'silent') || (debug && 'verbose') || 'update'
120
- }
121
-
122
- const git = new GitWorkflow(gitDir)
123
-
124
- const runner = new Listr(
125
- [
126
- {
127
- title: 'Preparing...',
128
- task: () => git.stashBackup()
129
- },
130
- {
131
- title: 'Running tasks...',
132
- task: () => new Listr(listrTasks, { ...listrOptions, concurrent: true, exitOnError: false })
133
- },
134
- {
135
- title: 'Applying modifications...',
136
- skip: ctx => ctx.hasErrors && 'Skipped because of errors from tasks',
137
- task: () => git.applyModifications()
138
- },
139
- {
140
- title: 'Reverting to original state...',
141
- enabled: ctx => ctx.hasErrors,
142
- task: () => git.restoreOriginalState()
143
- },
144
- {
145
- title: 'Cleaning up...',
146
- task: () => git.dropBackup()
147
- }
148
- ],
149
- listrOptions
150
- )
151
-
152
- try {
153
- await runner.run()
154
- } catch (error) {
155
- if (error.message.includes('Another git process seems to be running in this repository')) {
156
- logger.error(`
157
- ${symbols.error} ${chalk.red(`lint-staged failed due to a git error.
158
- Any lost modifications can be restored from a git stash:
159
-
160
- > git stash list
161
- stash@{0}: On master: automatic lint-staged backup
162
- > git stash pop stash@{0}`)}
163
- `)
164
- }
165
-
166
- throw error
167
- }
168
- }