├── .gitignore ├── .npmignore ├── bin ├── fastlint └── usage.txt ├── img └── screenshot.png ├── index.js ├── package.json └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | img/ 2 | -------------------------------------------------------------------------------- /bin/fastlint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | var subarg = require('subarg'); 3 | var path = require('path'); 4 | var multimatch = require('multimatch'); 5 | var parallel = require('miniq'); 6 | var style = require('ansi-styles'); 7 | var fastlint = require('../index'); 8 | 9 | var findUp = fastlint.findUp; 10 | var getWorkingCopy = fastlint.getWorkingCopy; 11 | var getBetweenCommits = fastlint.getBetweenCommits; 12 | var filterByStatus = fastlint.filterByStatus; 13 | var filterByStaged = fastlint.filterByStaged; 14 | var toPaths = fastlint.toPaths; 15 | 16 | var argv = subarg(process.argv.slice(2), { 17 | boolean: ['working-copy', 'status', 'print0', 'staged'], 18 | default: { 19 | format: 'relative', 20 | delimiter: ' ', 21 | 'working-copy': false, 22 | 'diff-filter': null, 23 | 'staged': null, 24 | }, 25 | }); 26 | 27 | if (argv.v || argv.version) { 28 | console.log(require('../package.json').version); 29 | process.exit(); 30 | } 31 | 32 | if (argv.help || argv.h) { 33 | console.log(fs.readFileSync(__dirname + '/usage.txt').toString()); 34 | process.exit(); 35 | } 36 | 37 | var cwd = process.cwd(); 38 | 39 | var gitRoot = argv.gitroot || path.dirname(findUp('.git', {cwd: cwd})); 40 | 41 | var files = []; 42 | var tasks = []; 43 | 44 | var fromCommit = argv._[0]; 45 | var toCommit = argv._[1]; 46 | 47 | // if no params are passed, select the working copy 48 | if (!fromCommit) { 49 | argv['working-copy'] = true; 50 | } 51 | 52 | // normally, require a confirmation to pick up working copy files 53 | if (argv['working-copy']) { 54 | tasks.push(function(onDone) { 55 | getWorkingCopy(gitRoot, function(err, results) { 56 | files.push.apply(files, results); 57 | return onDone(err); 58 | }) 59 | }); 60 | } 61 | 62 | 63 | if (fromCommit) { 64 | // assume HEAD ... (commit) if toCommit is not set 65 | if (!toCommit) { 66 | toCommit = fromCommit; 67 | fromCommit = 'HEAD'; 68 | } 69 | if (fromCommit === toCommit && toCommit === 'HEAD') { 70 | toCommit = 'HEAD~1'; 71 | } 72 | 73 | tasks.push(function(onDone) { 74 | getBetweenCommits(gitRoot, fromCommit, toCommit, function(err, results) { 75 | files.push.apply(files, results); 76 | return onDone(err); 77 | }) 78 | }); 79 | } 80 | 81 | function pathFormatter(format) { 82 | return function(file) { 83 | switch (format) { 84 | case 'full': 85 | // NOP - internally, we use full paths 86 | return file; 87 | break; 88 | case 'gitroot': 89 | return path.relative(gitRoot, file); 90 | break; 91 | default: 92 | case 'relative': 93 | // make relative 94 | return path.relative(cwd, file); 95 | } 96 | }; 97 | } 98 | 99 | function formatFiles(files, glob, format, delimiter) { 100 | files = files.slice(); 101 | 102 | var delimiter = argv.delimiter.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\r/g, '\r').replace(/\\0/g, '\0'); 103 | return files.map(pathFormatter(format)).join(delimiter); 104 | } 105 | 106 | function formatStatus(status, staged) { 107 | var indicator = staged === false ? style.gray.open + 'u' + style.gray.close : ' '; 108 | switch (status) { 109 | case 'A': 110 | return style.green.open + 'Added ' + indicator + ' ' + style.green.close; 111 | case 'C': 112 | return 'Copied ' + indicator + ' '; 113 | case 'D': 114 | return style.red.open + 'Deleted ' + indicator + ' ' + style.red.close; 115 | case 'M': 116 | return style.yellow.open + 'Modified ' + indicator + ' ' + style.yellow.close; 117 | case 'R': 118 | return 'Renamed ' + indicator + ' '; 119 | case 'T': 120 | return 'Type changed' + indicator; 121 | case 'U': 122 | return 'Unmerged ' + indicator + ' '; 123 | case 'B': 124 | return 'Broken ' + indicator + ' '; 125 | case 'Q': 126 | return style.gray.open + 'Untracked ' + indicator + ' ' + style.gray.close; 127 | case 'X': 128 | default: 129 | return 'Unknown ' + indicator + ' '; 130 | } 131 | } 132 | 133 | function describeFilters() { 134 | var result = []; 135 | if (argv['working-copy']) { 136 | result.push(' - Selecting files from the working copy'); 137 | } 138 | if (fromCommit && toCommit) { 139 | result.push(' - Selecting files from commits between ' + fromCommit + ' and ' + toCommit); 140 | } 141 | if (argv['diff-filter']) { 142 | result.push(' - Filtering out files with git status: ' + argv['diff-filter']); 143 | } 144 | if (argv['staged'] !== null) { 145 | result.push(' - Only selecting files that are ' + (argv['staged'] ? 'staged for commit' : 'unstaged')); 146 | } 147 | if (argv['glob']) { 148 | result.push(' - Applying globs ' + (Array.isArray(argv['glob']) ? argv['glob'] : [argv['glob']]).join(', ')); 149 | } 150 | return result.join('\n'); 151 | } 152 | 153 | parallel(1, tasks, function(err) { 154 | if (err) { 155 | throw err; 156 | } 157 | 158 | var patterns = []; 159 | 160 | if (argv.print0) { 161 | argv.delimiter = '\0'; 162 | } 163 | if (argv.glob) { 164 | files = files.filter(function(file) { 165 | // we want globs to be run against relative paths!! 166 | var relative = path.relative(cwd, file.path); 167 | var targets = [relative]; 168 | if (file.path.substr(0, cwd.length) === cwd) { 169 | // support glob expressions relative to ./ by also matching against ./ for files under the cwd 170 | targets.push('./' + relative); 171 | } 172 | return multimatch(targets, argv.glob, {matchBase: true}).length > 0; 173 | }); 174 | } 175 | files = filterByStatus(files, argv['diff-filter']); 176 | 177 | if (argv['staged'] !== null) { 178 | files = filterByStaged(files, argv['staged']); 179 | } 180 | 181 | if (argv.status) { 182 | console.error(); 183 | console.error(style.underline.open + '`fastlint` filters:' + style.underline.close); 184 | console.error(describeFilters()); 185 | console.error(); 186 | if (files.length === 0) { 187 | console.error('No files matched!'); 188 | } else { 189 | var hasUnstaged = files.some(function(file) { 190 | return file.staged === false; 191 | }); 192 | if (hasUnstaged) { 193 | console.error(style.underline.open + 'Selected files:' + style.underline.close + style.gray.open + ' u = not staged for commit' + style.gray.close); 194 | 195 | } else { 196 | console.error(style.underline.open + 'Selected files:' + style.underline.close); 197 | } 198 | var formatter = pathFormatter(argv.format); 199 | files.forEach(function(file) { 200 | console.error(' ', formatStatus(file.status, file.staged), formatter(file.path)); 201 | }); 202 | console.error(); 203 | } 204 | } 205 | 206 | console.log(formatFiles(toPaths(files), argv.glob, argv.format, argv.delimiter)); 207 | }); 208 | -------------------------------------------------------------------------------- /bin/usage.txt: -------------------------------------------------------------------------------- 1 | USAGE 2 | 3 | fastlint [options] [fromCommit] [toCommit] 4 | 5 | 6 | USAGE EXAMPLES 7 | 8 | `fastlint --status` 9 | 10 | Runs `fastlint` and shows the current set of filters (in stderr). 11 | 12 | `fastlint --status --print0 | xargs -0 eslint` 13 | 14 | Run `eslint` on all modified files in the working copy. 15 | 16 | `fastlint --status --print0 --working-copy HEAD HEAD~5 | xargs -0 eslint` 17 | 18 | Run `eslint` on all files changed in the working copy and in the last five commits in this branch. 19 | 20 | `fastlint HEAD origin/master | xargs -0 eslint` 21 | 22 | Run `eslint` on all files changed compared to the `origin/master` branch. 23 | 24 | `fastlint --status --print0 --glob '{src,tests}/**/*.{js,jsx}' HEAD origin/master | xargs -0 eslint` 25 | 26 | Run `eslint` on all `.js` and `.jsx` files in `src/` or `tests/` changed compared to the `origin/master` branch. 27 | 28 | CLI OPTIONS - FILTERING 29 | 30 | `--glob [glob]`. Use a glob to filter the results. Can be specified multiple times. The matching is processed using [multimatch](https://github.com/sindresorhus/multimatch), see their docs for details. 31 | 32 | `--no-working-copy`. By default, `fastlint` also includes files in the working copy, e.g. files that have been added/modified but not necessarily staged. Set this flag to only look at committed changes. 33 | 34 | `--diff-filter [(A|C|D|M|R|T|U|X|B|Q)]`. Defaults to `--diff-filter d`. Select only files that are Added (A), Copied (C), Deleted (D), Modified (M), Renamed (R), have their type (i.e. regular file, symlink, submodule, …​) changed (T), are Untracked (Q), Unmerged (U), are Unknown (X), or have had their pairing Broken (B). Any combination of the filter characters (including none) can be used. 35 | 36 | Also, these upper-case letters can be downcased to exclude. E.g. `--diff-filter=ad` excludes added and deleted paths. 37 | 38 | `--staged`. Filter files by their staging status. Defaults to not applying any filtering. To select unstaged files, use `--no-staged`. This only applies to files in the working copy, since any committed files are considered staged. 39 | 40 | CLI OPTIONS - STATUS & FORMATTING 41 | 42 | `--status` logs out the list of selected files to `stderr`. 43 | 44 | `--delimiter [character]`. Join the filenames using this delimiter. `\n`, `\t`, `\r` and `\0` are converted to the appropriate character. Default: ` `. 45 | 46 | `--print0`. Same as `--delimiter '\0'`. 47 | 48 | `--paths cwd`. Output paths relative to CWD. Default. 49 | 50 | `--paths full`. Output full paths. 51 | 52 | `--paths gitroot`. Output paths relative to the location of the closest `.git` folder, searching up from the current working directory. 53 | -------------------------------------------------------------------------------- /img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mixu/fastlint/f4a18e17fbc83a6926da33805c1c2c74aebb492b/img/screenshot.png -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var path = require('path'); 3 | var spawn = require('child_process').spawn; 4 | var split = require('binary-split'); 5 | 6 | exports.findUp = function findUp(filename, opts) { 7 | opts = opts || {}; 8 | 9 | var dir = path.resolve(opts.cwd || ''); 10 | var root = path.parse(dir).root; 11 | 12 | while (true) { 13 | var fp = path.join(dir, filename); 14 | 15 | try { 16 | if (fs.statSync(fp)) { 17 | return fp; 18 | } 19 | } catch(e) {} 20 | 21 | if (dir === root) { 22 | return null; 23 | } 24 | 25 | dir = path.dirname(dir); 26 | } 27 | }; 28 | 29 | function parseStatus(line, wd) { 30 | // https://git-scm.com/docs/git-status 31 | var input = line.toString().split('\0')[0]; // only use the first filename in the status 32 | if (input.length > 0) { 33 | var x = input.charAt(0); // shows the status of the index 34 | var y = input.charAt(1); // shows the status of the work tree 35 | var filepath = input.substr(3); 36 | var status = x !== ' ' ? x : y; // prefer our modifications to the status since those are more intuitive. 37 | return { 38 | status: status.replace(/\?+/g, 'Q'), 39 | staged: x !== ' ' && x !== '?', 40 | path: path.join(wd, filepath), 41 | }; 42 | } 43 | }; 44 | 45 | // Files modified in the working copy 46 | exports.getWorkingCopy = function getWorkingCopy(wd, onDone) { 47 | var files = []; 48 | spawn('git', ['status', '--porcelain', '--untracked-files=normal', '--ignore-submodules=all'], {cwd: wd}) 49 | .once('error', onDone) 50 | .on('close', function(code) { 51 | onDone(null, files); 52 | }) 53 | .stdout.pipe(split()) 54 | .on('data', function(line) { 55 | var file = parseStatus(line, wd); 56 | if (file) { 57 | files.push(file); 58 | } 59 | }); 60 | }; 61 | 62 | // Files modified between two commits 63 | exports.getBetweenCommits = function getBetweenCommits(wd, fromCommit, to, onDone) { 64 | var files = []; 65 | spawn('git', ['diff-tree', '-r', '--root', '--no-commit-id', '--name-status', fromCommit, to], {cwd: wd}) 66 | .once('error', onDone) 67 | .on('close', function(code) { 68 | onDone(null, files); 69 | }) 70 | .stdout.pipe(split()) 71 | .on('data', function(line) { 72 | line = line.toString().trim(); 73 | if (line.length > 0) { 74 | var parts = line.split(/\s+/); 75 | files.push({ 76 | status: parts[0].replace(/\?+/g, 'Q'), 77 | staged: true, // committed files are always staged :) 78 | path: path.join(wd, parts[1]), 79 | }); 80 | } 81 | }); 82 | }; 83 | 84 | exports.filterByStaged = function(files, staged) { 85 | return files.filter(function(file) { 86 | return file.staged === staged; 87 | }); 88 | }; 89 | 90 | exports.filterByStatus = function(files, statuses) { 91 | if (!statuses) { 92 | return files; 93 | } 94 | var includes = statuses.split('').filter(function(char) { return char.toUpperCase() === char; }); 95 | var excludes = statuses.split('').filter(function(char) { return char.toLowerCase() === char; }); 96 | 97 | return files.filter(function(file) { 98 | var result; 99 | if (includes.length > 0) { 100 | result = includes.indexOf(file.status) !== -1; 101 | } 102 | if (excludes.length > 0) { 103 | result = excludes.indexOf(file.status.toLowerCase()) === -1; 104 | } 105 | return result; 106 | }); 107 | }; 108 | 109 | exports.toPaths = function(files) { 110 | return files.map(function(file) { return file.path; }); 111 | }; 112 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastlint", 3 | "version": "1.1.0", 4 | "description": "Lint faster by only running linters and other tools on files that have recently changed or files that are different from `master` in git.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "bin": { 10 | "fastlint": "./bin/fastlint" 11 | }, 12 | "author": "Mikito Takada (http://mixu.net/)", 13 | "license": "BSD-3-Clause", 14 | "dependencies": { 15 | "ansi-styles": "~3.0.0", 16 | "binary-split": "~1.0.3", 17 | "miniq": "~1.0.1", 18 | "multimatch": "~2.1.0", 19 | "subarg": "~1.0.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # fastlint 2 | 3 | Lint faster by only running linters and other tools on files that have recently changed or files that are different from `master` in git. 4 | 5 | ![](https://github.com/mixu/fastlint/raw/master/img/screenshot.png) 6 | 7 | ## Installation 8 | 9 | ``` 10 | npm install -g fastlint 11 | ``` 12 | 13 | ## Changelog 14 | 15 | - `v1.1.0`: added `--staged` filter, added support for cwd-relative globs. 16 | 17 | ## Usage examples 18 | 19 | `fastlint --status` 20 | 21 | Runs `fastlint` and shows the current set of filters (in stderr). 22 | 23 | `fastlint --status --print0 | xargs -0 eslint` 24 | 25 | Run `eslint` on all modified files in the working copy. 26 | 27 | `fastlint --status --print0 --working-copy HEAD~5 HEAD | xargs -0 eslint` 28 | 29 | Run `eslint` on all files changed in the working copy and in the last five commits in this branch. 30 | 31 | `fastlint origin/master HEAD | xargs -0 eslint` 32 | 33 | Run `eslint` on all files changed compared to the `origin/master` branch. 34 | 35 | `fastlint --status --print0 --glob '{src,tests}/**/*.{js,jsx}' origin/master HEAD | xargs -0 eslint` 36 | 37 | Run `eslint` on all `.js` and `.jsx` files in `src/` or `tests/` changed compared to the `origin/master` branch. 38 | 39 | ## Integrating with package.json 40 | 41 | Here is an example of a full integration inside `package.json`, runnable via `npm run-script fastlint`: 42 | 43 | ``` 44 | "scripts": { 45 | "fastlint": "fastlint --status --print0 --glob '{src,tests}/**/*.{js,jsx}' --glob './webpack*.js' --working-copy --diff-filter=buxq origin/master HEAD | xargs -0 eslint --cache --fix --ext js,jsx || exit 0" 46 | }, 47 | ``` 48 | 49 | ## CLI options 50 | 51 | Usage: `fastlint [options] [fromCommit] [toCommit]` 52 | 53 | ### Filtering 54 | 55 | `--glob [glob]`. Use a glob to filter the results. Can be specified multiple times. The matching is processed using [multimatch](https://github.com/sindresorhus/multimatch), see their docs for details. You can use `./{glob}` to specify that the glob should match files relative to the current working directory (since `v.1.1.0`). 56 | 57 | `--working-copy`. `fastlint` can also include files in the working copy, e.g. files that have been added/modified but not necessarily staged. For UX reasons this gets set if you don't pass anything in (because otherwise there would be nothing to show if you don't pass two branches to compare). 58 | 59 | Without `--working-copy`, you will only get the files that match between the `fromCommit` and `toCommit.` 60 | 61 | To only include untracked files, use `--diff-filter=Q`. To only include tracked files, use `--diff-filter=q`. 62 | 63 | `--diff-filter [(A|C|D|M|R|T|U|X|B|Q)]`. Only select files that are Added (A), Copied (C), Deleted (D), Modified (M), Renamed (R), have their type (i.e. regular file, symlink, submodule, …​) changed (T), are Untracked (Q), Unmerged (U), are Unknown (X), or have had their pairing Broken (B). Any combination of the filter characters (including none) can be used. 64 | 65 | Also, these upper-case letters can be downcased to exclude. E.g. `--diff-filter=ad` excludes added and deleted paths. 66 | 67 | Note that "Deleted" does not necessarily mean the file was deleted - it may refer to the file being only modified by deleting lines. 68 | 69 | `--staged`. Filter files by their staging status. Defaults to not applying any filtering. To select unstaged files, use `--no-staged`. This only applies to files in the working copy, since any committed files are considered staged. 70 | 71 | Imagine you run `git status`. Here's how the output maps to the two filters: 72 | 73 | | | diff-filter | staged | 74 | |---------------------------------|------------------------|-----------| 75 | | "Changes to be committed" | q (any, not untracked) | staged | 76 | | "Changes not staged for commit" | q (any, not untracked) | no-staged | 77 | | "Untracked files" | Q (untracked) | no-staged | 78 | 79 | For example, if you want all untracked files (only), you'd need `--working-copy --diff-filter=Q --no-staged` and if you wanted "Changes to be committed", you'd use `--working-copy --diff-filter=q --staged`. 80 | 81 | ### Human friendly status 82 | 83 | `--status` logs out the list of selected files to `stderr`. 84 | 85 | ### Result formatting 86 | 87 | - `--delimiter [character]`. Join the filenames using this delimiter. `\n`, `\t`, `\r` and `\0` are converted to the appropriate character. Default: ` `. 88 | - `--print0`. Same as `--delimiter '\0'`. 89 | - `--paths cwd`. Output paths relative to CWD. Default. 90 | - `--paths full`. Output full paths. 91 | - `--paths gitroot`. Output paths relative to the location of the closest `.git` folder, searching up from the current working directory. 92 | --------------------------------------------------------------------------------