Skip to content

Commit

Permalink
fix: use stash create/store to prevent files from disappearing from disk
Browse files Browse the repository at this point in the history
The `git stash create` command creates a dangling stash commit without removing any files from the disk, and returns its hash. This when passed to the `git stash store` commit saves it as a regular stash.

What follows is that the files never leave disk, and so don't trigger any side-effects with file watchers or other processes that might be running when lint-staged is running.
  • Loading branch information
iiroj committed Apr 21, 2020
1 parent e093b1d commit c9adca5
Show file tree
Hide file tree
Showing 5 changed files with 99 additions and 23 deletions.
24 changes: 10 additions & 14 deletions lib/gitWorkflow.js
Expand Up @@ -159,6 +159,7 @@ class GitWorkflow {
return index !== ' ' && workingTree !== ' ' && index !== '?' && workingTree !== '?'
})
.map((line) => line.substr(3)) // Remove first three letters (index, workingTree, and a whitespace)
.filter(Boolean) // Filter empty string
debug('Found partially staged files:', partiallyStaged)
return partiallyStaged.length ? partiallyStaged : null
}
Expand Down Expand Up @@ -186,24 +187,19 @@ class GitWorkflow {
*/
if (!shouldBackup) return

// When backup is enabled, the revert will clear ongoing merge status.
await this.backupMergeStatus()

// Get a list of unstaged deleted files, because certain bugs might cause them to reappear:
// - in git versions =< 2.13.0 the `--keep-index` flag resurrects deleted files
// - in git versions =< 2.13.0 the `git stash --keep-index` option resurrects deleted files
// - git stash can't infer RD or MD states correctly, and will lose the deletion
this.deletedFiles = await this.getDeletedFiles()

// the `git stash` clears metadata about a possible git merge
// Manually check and backup if necessary
await this.backupMergeStatus()

// Save stash of original state
await this.execGit(['stash', 'save', STASH])
await this.execGit(['stash', 'apply', '--quiet', '--index', await this.getBackupStash()])

// Restore meta information about ongoing git merge, cleared by `git stash`
await this.restoreMergeStatus()

// If stashing resurrected deleted files, clean them out
await Promise.all(this.deletedFiles.map((file) => unlink(file)))
// Save stash of all staged files.
// The `stash create` command creates a dangling commit without removing any files,
// and `stash store` saves it as an actual stash.
const hash = await this.execGit(['stash', 'create'])
await this.execGit(['stash', 'store', '--quiet', '--message', STASH, hash])

debug('Done backing up original state!')
} catch (error) {
Expand Down
23 changes: 23 additions & 0 deletions test/gitWorkflow.spec.js
Expand Up @@ -65,6 +65,29 @@ describe('gitWorkflow', () => {
}
})

describe('prepare', () => {
it('should handle errors', async () => {
const gitWorkflow = new GitWorkflow({
gitDir: cwd,
gitConfigDir: path.resolve(cwd, './.git')
})
jest.doMock('execa', () => Promise.reject({}))
const ctx = {}
// mock a simple failure
gitWorkflow.getPartiallyStagedFiles = () => ['foo']
gitWorkflow.getHiddenFilepath = () => {
throw new Error('test')
}
await expect(gitWorkflow.prepare(ctx, false)).rejects.toThrowErrorMatchingInlineSnapshot(
`"test"`
)
expect(ctx).toEqual({
gitError: true,
hasPartiallyStagedFiles: true
})
})
})

describe('cleanup', () => {
it('should handle errors', async () => {
const gitWorkflow = new GitWorkflow({
Expand Down
56 changes: 53 additions & 3 deletions test/runAll.spec.js
Expand Up @@ -3,19 +3,22 @@ import execa from 'execa'
import normalize from 'normalize-path'
import path from 'path'

import resolveGitRepo from '../lib/resolveGitRepo'
import getStagedFiles from '../lib/getStagedFiles'
import GitWorkflow from '../lib/gitWorkflow'
import resolveGitRepo from '../lib/resolveGitRepo'
import runAll, { shouldSkip } from '../lib/runAll'

jest.mock('../lib/resolveGitRepo')
jest.mock('../lib/file')
jest.mock('../lib/getStagedFiles')
jest.mock('../lib/gitWorkflow')
jest.mock('../lib/resolveGitRepo')

getStagedFiles.mockImplementation(async () => [])

resolveGitRepo.mockImplementation(async () => {
const cwd = process.cwd()
return { gitConfigDir: normalize(path.resolve(cwd, '.git')), gitDir: normalize(cwd) }
})
getStagedFiles.mockImplementation(async () => [])

describe('runAll', () => {
const globalConsoleTemp = console
Expand Down Expand Up @@ -77,6 +80,46 @@ describe('runAll', () => {
`)
})

it('should skip tasks if previous git error', async () => {
expect.assertions(2)
getStagedFiles.mockImplementationOnce(async () => ['sample.js'])
GitWorkflow.mockImplementationOnce(() => ({
...jest.requireActual('../lib/gitWorkflow'),
prepare: (ctx) => {
ctx.gitError = true
throw new Error('test')
}
}))

await expect(
runAll({ config: { '*.js': ['echo "sample"'] } })
).rejects.toThrowErrorMatchingInlineSnapshot(`"Something went wrong"`)

expect(console.printHistory()).toMatchInlineSnapshot(`
"
LOG Preparing... [started]
LOG Preparing... [failed]
LOG β†’ test
LOG Running tasks... [started]
LOG Running tasks... [skipped]
LOG β†’ Skipped because of previous git error.
LOG Applying modifications... [started]
LOG Applying modifications... [skipped]
LOG β†’ Skipped because of previous git error.
LOG Cleaning up... [started]
LOG Cleaning up... [skipped]
LOG β†’ Skipped because of previous git error.
ERROR
Γ— lint-staged failed due to a git error.
ERROR Any lost modifications can be restored from a git stash:
> git stash list
stash@{0}: On master: automatic lint-staged backup
> git stash apply --index stash@{0}
"
`)
})

it('should skip applying unstaged modifications if there are errors during linting', async () => {
expect.assertions(2)
getStagedFiles.mockImplementationOnce(async () => ['sample.js'])
Expand Down Expand Up @@ -171,6 +214,13 @@ describe('runAll', () => {
})

describe('shouldSkip', () => {
describe('shouldSkipApplyModifications', () => {
it('should return error message when there is an unkown git error', () => {
const result = shouldSkip.shouldSkipApplyModifications({ gitError: true })
expect(typeof result === 'string').toEqual(true)
})
})

describe('shouldSkipRevert', () => {
it('should return error message when there is an unkown git error', () => {
const result = shouldSkip.shouldSkipRevert({ gitError: true })
Expand Down
17 changes: 12 additions & 5 deletions test/runAll.unmocked.2.spec.js
Expand Up @@ -107,14 +107,21 @@ describe('runAll', () => {
expect(console.printHistory()).toMatchInlineSnapshot(`
"
LOG Preparing... [started]
LOG Preparing... [failed]
LOG β†’ Merge state could not be restored due to an error!
LOG Preparing... [completed]
LOG Running tasks... [started]
LOG Running tasks... [skipped]
LOG β†’ Skipped because of previous git error.
LOG Running tasks for *.js [started]
LOG prettier --list-different [started]
LOG prettier --list-different [failed]
LOG β†’
LOG Running tasks for *.js [failed]
LOG β†’
LOG Running tasks... [failed]
LOG Applying modifications... [started]
LOG Applying modifications... [skipped]
LOG β†’ Skipped because of previous git error.
LOG β†’ Skipped because of errors from tasks.
LOG Reverting to original state because of errors... [started]
LOG Reverting to original state because of errors... [failed]
LOG β†’ Merge state could not be restored due to an error!
LOG Cleaning up... [started]
LOG Cleaning up... [skipped]
LOG β†’ Skipped because of previous git error.
Expand Down
2 changes: 1 addition & 1 deletion test/runAll.unmocked.spec.js
Expand Up @@ -459,7 +459,7 @@ describe('runAll', () => {

// Luckily there is a stash
expect(await execGit(['stash', 'list'])).toMatchInlineSnapshot(
`"stash@{0}: On master: lint-staged automatic backup"`
`"stash@{0}: lint-staged automatic backup"`
)
await execGit(['reset', '--hard'])
await execGit(['stash', 'pop', '--index'])
Expand Down

0 comments on commit c9adca5

Please sign in to comment.