Skip to content

Commit

Permalink
feat: Add support for partially staged files (#75)
Browse files Browse the repository at this point in the history
Introduces support for partially staged files with the following algorithm:

1. Before running linters, check if there are partially stated files (i.e. both with changes in commit index and the working copy)
2. Stash and remove from the working copy all files that aren't in the commit index
3. Execute commands (linters and formatters) on these files
4. Add formatters changes to index (separate task in the config)
5. Restore the stashed working copy state if all linters exit with zero code (i.e. success). If there are errors running linters, restore stashed working copy.
6. Compare new commit index and working copy and apply as many formatting changes to working copy as possible. If there are conflicting hunks, drop formatter's changes. User modifications that were stashed should always take precedence over formatters.

Closes #62

BREAKING CHANGE: Node >= 8.6 is required
  • Loading branch information
okonet authored and Andrey Okonetchnikov committed Oct 29, 2018
1 parent e6a6ec6 commit f82443c
Show file tree
Hide file tree
Showing 18 changed files with 2,047 additions and 118 deletions.
2 changes: 1 addition & 1 deletion .appveyor.yml
Expand Up @@ -5,8 +5,8 @@
environment:
matrix:
- nodejs_version: '10'
- nodejs_version: '9'
- nodejs_version: '8'
- nodejs_version: '6'

matrix:
fast_finish: true
Expand Down
1 change: 0 additions & 1 deletion .travis.yml
Expand Up @@ -7,7 +7,6 @@ node_js:
- '10'
- '9'
- '8'
- '6'

before_install: yarn global add greenkeeper-lockfile@1
install: yarn install
Expand Down
17 changes: 12 additions & 5 deletions package.json
Expand Up @@ -10,7 +10,7 @@
"Suhas Karanth <sudo.suhas@gmail.com>"
],
"engines": {
"node": ">=6"
"node": ">=8.6.0"
},
"bin": "index.js",
"files": ["index.js", "src"],
Expand All @@ -30,12 +30,16 @@
"cosmiconfig": "^5.0.2",
"debug": "^3.1.0",
"dedent": "^0.7.0",
"execa": "^0.9.0",
"del": "^3.0.0",
"execa": "^1.0.0",
"find-parent-dir": "^0.3.0",
"g-status": "^2.0.2",
"is-glob": "^4.0.0",
"is-windows": "^1.0.2",
"jest-validate": "^23.5.0",
"listr": "^0.14.1",
"listr": "^0.14.2",
"listr-update-renderer":
"https://github.com/okonet/listr-update-renderer/tarball/upgrade-log-update",
"lodash": "^4.17.5",
"log-symbols": "^2.2.0",
"micromatch": "^3.1.8",
Expand All @@ -61,8 +65,10 @@
"eslint-plugin-node": "^6.0.0",
"husky": "^0.14.3",
"jest": "^23.6.0",
"jest-snapshot-serializer-ansi": "^1.0.0",
"jsonlint": "^1.6.2",
"prettier": "1.11.1"
"prettier": "1.11.1",
"tmp": "0.0.33"
},
"config": {
"commitizen": {
Expand All @@ -71,7 +77,8 @@
},
"jest": {
"testEnvironment": "node",
"setupFiles": ["./testSetup.js"]
"setupFiles": ["./testSetup.js"],
"snapshotSerializers": ["jest-snapshot-serializer-ansi"]
},
"keywords": [
"lint",
Expand Down
178 changes: 178 additions & 0 deletions src/gitWorkflow.js
@@ -0,0 +1,178 @@
'use strict'

const path = require('path')
const execa = require('execa')
const gStatus = require('g-status')
const del = require('del')
const debug = require('debug')('lint-staged:git')

let workingCopyTree = null
let indexTree = null
let formattedIndexTree = null

function getAbsolutePath(dir) {
return path.isAbsolute(dir) ? dir : path.resolve(dir)
}

async function execGit(cmd, options) {
const cwd = options && options.cwd ? options.cwd : process.cwd()
debug('Running git command', cmd)
try {
const { stdout } = await execa('git', [].concat(cmd), {
...options,
cwd: getAbsolutePath(cwd)
})
return stdout
} catch (err) {
throw new Error(err)
}
}

async function writeTree(options) {
return execGit(['write-tree'], options)
}

async function getDiffForTrees(tree1, tree2, options) {
debug(`Generating diff between trees ${tree1} and ${tree2}...`)
return execGit(
[
'diff-tree',
'--ignore-submodules',
'--binary',
'--no-color',
'--no-ext-diff',
'--unified=0',
tree1,
tree2
],
options
)
}

async function hasPartiallyStagedFiles(options) {
const files = await gStatus(options)
const partiallyStaged = files.filter(
file =>
file.index !== ' ' &&
file.workingTree !== ' ' &&
file.index !== '?' &&
file.workingTree !== '?'
)
return partiallyStaged.length > 0
}

// eslint-disable-next-line
async function gitStashSave(options) {
debug('Stashing files...')
// Save ref to the current index
indexTree = await writeTree(options)
// Add working copy changes to index
await execGit(['add', '.'], options)
// Save ref to the working copy index
workingCopyTree = await writeTree(options)
// Restore the current index
await execGit(['read-tree', indexTree], options)
// Remove all modifications
await execGit(['checkout-index', '-af'], options)
// await execGit(['clean', '-dfx'], options)
debug('Done stashing files!')
return [workingCopyTree, indexTree]
}

async function updateStash(options) {
formattedIndexTree = await writeTree(options)
return formattedIndexTree
}

async function applyPatchFor(tree1, tree2, options) {
const diff = await getDiffForTrees(tree1, tree2, options)
/**
* This is crucial for patch to work
* For some reason, git-apply requires that the patch ends with the newline symbol
* See http://git.661346.n2.nabble.com/Bug-in-Git-Gui-Creates-corrupt-patch-td2384251.html
* and https://stackoverflow.com/questions/13223868/how-to-stage-line-by-line-in-git-gui-although-no-newline-at-end-of-file-warnin
*/
// TODO: Figure out how to test this. For some reason tests were working but in the real env it was failing
const patch = `${diff}\n` // TODO: This should also work on Windows but test would be good
if (patch) {
try {
/**
* Apply patch to index. We will apply it with --reject so it it will try apply hunk by hunk
* We're not interested in failied hunks since this mean that formatting conflicts with user changes
* and we prioritize user changes over formatter's
*/
await execGit(
['apply', '-v', '--whitespace=nowarn', '--reject', '--recount', '--unidiff-zero'],
{
...options,
input: patch
}
)
} catch (err) {
debug('Could not apply patch to the stashed files cleanly')
debug(err)
debug('Patch content:')
debug(patch)
throw new Error('Could not apply patch to the stashed files cleanly.', err)
}
}
}

async function gitStashPop(options) {
if (workingCopyTree === null) {
throw new Error('Trying to restore from stash but could not find working copy stash.')
}

debug('Restoring working copy')
// Restore the stashed files in the index
await execGit(['read-tree', workingCopyTree], options)
// and sync it to the working copy (i.e. update files on fs)
await execGit(['checkout-index', '-af'], options)

// Then, restore the index after working copy is restored
if (indexTree !== null && formattedIndexTree === null) {
// Restore changes that were in index if there are no formatting changes
debug('Restoring index')
await execGit(['read-tree', indexTree], options)
} else {
/**
* There are formatting changes we want to restore in the index
* and in the working copy. So we start by restoring the index
* and after that we'll try to carry as many as possible changes
* to the working copy by applying the patch with --reject option.
*/
debug('Restoring index with formatting changes')
await execGit(['read-tree', formattedIndexTree], options)
try {
await applyPatchFor(indexTree, formattedIndexTree, options)
} catch (err) {
debug(
'Found conflicts between formatters and local changes. Formatters changes will be ignored for conflicted hunks.'
)
/**
* Clean up working directory from *.rej files that contain conflicted hanks.
* These hunks are coming from formatters so we'll just delete them since they are irrelevant.
*/
try {
const rejFiles = await del(['*.rej'], options)
debug('Deleted files and folders:\n', rejFiles.join('\n'))
} catch (delErr) {
debug('Error deleting *.rej files', delErr)
}
}
}
// Clean up references
workingCopyTree = null
indexTree = null
formattedIndexTree = null

return null
}

module.exports = {
execGit,
gitStashSave,
gitStashPop,
hasPartiallyStagedFiles,
updateStash
}
2 changes: 1 addition & 1 deletion src/index.js
Expand Up @@ -66,7 +66,7 @@ module.exports = function lintStaged(logger = console, configPath, debugMode) {
debug('Normalized config:\n%O', config)
}

runAll(config)
return runAll(config)
.then(() => {
debug('linters were executed successfully!')
// No errors, exiting with 0
Expand Down
84 changes: 61 additions & 23 deletions src/resolveTaskFn.js
Expand Up @@ -4,6 +4,7 @@ const chunk = require('lodash/chunk')
const dedent = require('dedent')
const isWindows = require('is-windows')
const execa = require('execa')
const chalk = require('chalk')
const symbols = require('log-symbols')
const pMap = require('p-map')
const calcChunkSize = require('./calcChunkSize')
Expand All @@ -19,7 +20,7 @@ const debug = require('debug')('lint-staged:task')
* @param {Array<string>} args
* @param {Object} execaOptions
* @param {Array<string>} pathsToLint
* @return {Promise}
* @return {Promise} child_process
*/
function execLinter(bin, args, execaOptions, pathsToLint) {
const binArgs = args.concat(pathsToLint)
Expand All @@ -34,26 +35,50 @@ function execLinter(bin, args, execaOptions, pathsToLint) {
const successMsg = linter => `${symbols.success} ${linter} passed!`

/**
* Create and returns an error instance with given stdout and stderr. If we set
* the message on the error instance, it gets logged multiple times(see #142).
* Create and returns an error instance with a given message.
* If we set the message on the error instance, it gets logged multiple times(see #142).
* So we set the actual error message in a private field and extract it later,
* log only once.
*
* @param {string} linter
* @param {string} errStdout
* @param {string} errStderr
* @param {string} message
* @returns {Error}
*/
function makeErr(linter, errStdout, errStderr) {
function throwError(message) {
const err = new Error()
err.privateMsg = dedent`
${symbols.error} "${linter}" found some errors. Please fix them and try committing again.
${errStdout}
${errStderr}
`
err.privateMsg = `\n\n\n${message}`
return err
}

/**
* Create a failure message dependding on process result.
*
* @param {string} linter
* @param {Object} result
* @param {string} result.stdout
* @param {string} result.stderr
* @param {boolean} result.failed
* @param {boolean} result.killed
* @param {string} result.signal
* @param {Object} context (see https://github.com/SamVerschueren/listr#context)
* @returns {Error}
*/
function makeErr(linter, result, context = {}) {
// Indicate that some linter will fail so we don't update the index with formatting changes
context.hasErrors = true // eslint-disable-line no-param-reassign
const { stdout, stderr, killed, signal } = result
if (killed || (signal && signal !== '')) {
return throwError(
`${symbols.warning} ${chalk.yellow(`${linter} was terminated with ${signal}`)}`
)
}
return throwError(dedent`${symbols.error} ${chalk.redBright(
`${linter} found some errors. Please fix them and try committing again.`
)}
${stdout}
${stderr}
`)
}

/**
* Returns the task function for the linter. It handles chunking for file paths
* if the OS is Windows.
Expand All @@ -79,11 +104,13 @@ module.exports = function resolveTaskFn(options) {

if (!isWindows()) {
debug('%s OS: %s; File path chunking unnecessary', symbols.success, process.platform)
return () =>
return ctx =>
execLinter(bin, args, execaOptions, pathsToLint).then(result => {
if (!result.failed) return successMsg(linter)
if (result.failed || result.killed || result.signal != null) {
throw makeErr(linter, result, ctx)
}

throw makeErr(linter, result.stdout, result.stderr)
return successMsg(linter)
})
}

Expand All @@ -97,7 +124,7 @@ module.exports = function resolveTaskFn(options) {
process.platform,
filePathChunks.length
)
return () =>
return ctx =>
pMap(filePathChunks, mapper, { concurrency })
.catch(err => {
/* This will probably never be called. But just in case.. */
Expand All @@ -107,12 +134,23 @@ module.exports = function resolveTaskFn(options) {
`)
})
.then(results => {
const errors = results.filter(res => res.failed)
if (errors.length === 0) return successMsg(linter)

const errStdout = errors.map(err => err.stdout).join('')
const errStderr = errors.map(err => err.stderr).join('')

throw makeErr(linter, errStdout, errStderr)
const errors = results.filter(res => res.failed || res.killed)
const failed = results.some(res => res.failed)
const killed = results.some(res => res.killed)
const signals = results.map(res => res.signal).filter(Boolean)

if (failed || killed || signals.length > 0) {
const finalResult = {
stdout: errors.map(err => err.stdout).join(''),
stderr: errors.map(err => err.stderr).join(''),
failed,
killed,
signal: signals.join(', ')
}

throw makeErr(linter, finalResult, ctx)
}

return successMsg(linter)
})
}

0 comments on commit f82443c

Please sign in to comment.