Skip to content

Commit

Permalink
fix: better workaround for git stash --keep-index bug
Browse files Browse the repository at this point in the history
  • Loading branch information
iiroj committed Dec 14, 2019
1 parent 083b8e7 commit f3ae378
Show file tree
Hide file tree
Showing 3 changed files with 105 additions and 12 deletions.
34 changes: 25 additions & 9 deletions lib/gitWorkflow.js
Expand Up @@ -14,6 +14,23 @@ const STASH = 'lint-staged automatic backup'

const gitApplyArgs = ['apply', '-v', '--whitespace=nowarn', '--recount', '--unidiff-zero']

/**
* Delete untracked files using `git clean`
* @param {Function} execGit function for executing git commands using execa
* @returns {Promise<void>}
*/
const cleanUntrackedFiles = async execGit => {
const untrackedFiles = await execGit(['ls-files', '--others', '--exclude-standard'])
if (untrackedFiles) {
debug('Detected unstaged, untracked files: ', untrackedFiles)
debug(
'This is probably due to a bug in git =< 2.13.0 where `git stash --keep-index` resurrects deleted files.'
)
debug('Deleting the files using `git clean`...')
await execGit(['clean', '--force', untrackedFiles.split('\n').join(' ')])
}
}

class GitWorkflow {
constructor({ gitDir, stagedFileChunks }) {
this.execGit = (args, options = {}) => execGit(args, { ...options, cwd: gitDir })
Expand Down Expand Up @@ -69,18 +86,15 @@ class GitWorkflow {
debug('Done backing up merge state!')
}

// Get diff of staged modifications. This will be applied back to the index. after stashing all changes.
// The `git stash save --keep-index` option cannot be used since it resurrects deleted files on
// git versions before v2.23.0 (https://github.com/git/git/blob/master/Documentation/RelNotes/2.23.0.txt#L322)
const stagedDiff = await this.execGit(['diff', '--binary', '--cached'])

// Save stash of entire original state, including unstaged and untracked changes.
// This should remove all changes from the index.
await this.execGit(['stash', 'save', '--quiet', '--include-untracked', STASH])
// `--keep-index leaves only staged files on disk, for tasks.`
await this.execGit(['stash', 'save', '--quiet', '--include-untracked', '--keep-index', STASH])

// Apply staged modifications back to the index
await this.execGit([...gitApplyArgs, '--index'], { input: `${stagedDiff}\n` })
// There is a bug in git =< 2.13.0 where `--keep-index` resurrects deleted files.
// These files should be listed and deleted before proceeding.
await cleanUntrackedFiles(this.execGit)

// Get a diff of unstaged changes by saved stash against what's left on disk.
this.unstagedDiff = await this.execGit([
'diff',
'--binary',
Expand All @@ -91,6 +105,7 @@ class GitWorkflow {
await this.getBackupStash(),
'-R' // Show diff in reverse
])

debug('Done backing up original state!')
}

Expand Down Expand Up @@ -189,3 +204,4 @@ class GitWorkflow {
}

module.exports = GitWorkflow
module.exports.cleanUntrackedFiles = cleanUntrackedFiles
77 changes: 77 additions & 0 deletions test/gitWorkflow.spec.js
@@ -0,0 +1,77 @@
import fs from 'fs-extra'
import normalize from 'normalize-path'
import os from 'os'
import path from 'path'
import nanoid from 'nanoid'

import execGitBase from '../lib/execGit'
import { cleanUntrackedFiles } from '../lib/gitWorkflow'

jest.unmock('execa')

jest.setTimeout(20000)

const isAppveyor = !!process.env.APPVEYOR
const osTmpDir = isAppveyor ? 'C:\\projects' : fs.realpathSync(os.tmpdir())

/**
* Create temporary directory and return its path
* @returns {Promise<String>}
*/
const createTempDir = async () => {
const dirname = path.resolve(osTmpDir, 'lint-staged-test', nanoid())
await fs.ensureDir(dirname)
return dirname
}

/**
* Remove temporary directory
* @param {String} dirname
* @returns {Promise<Void>}
*/
const removeTempDir = async dirname => {
await fs.remove(dirname)
}

let tmpDir, cwd

/** Append to file, creating if it doesn't exist */
const appendFile = async (filename, content, dir = cwd) =>
fs.appendFile(path.resolve(dir, filename), content)

/** Wrap execGit to always pass `gitOps` */
const execGit = async args => execGitBase(args, { cwd })

/** Initialize git repo for test */
const initGitRepo = async () => {
await execGit('init')
await execGit(['config', 'user.name', '"test"'])
await execGit(['config', 'user.email', '"test@test.com"'])
await appendFile('README.md', '# Test\n')
await execGit(['add', 'README.md'])
await execGit(['commit', '-m initial commit'])
}

describe('gitWorkflow', () => {
beforeEach(async () => {
tmpDir = await createTempDir()
cwd = normalize(tmpDir)
await initGitRepo()
})

afterEach(async () => {
if (!isAppveyor) {
await removeTempDir(tmpDir)
}
})

describe('cleanUntrackedFiles', () => {
it('should delete untracked, unstaged files', async () => {
const testFile = path.resolve(cwd, 'test.js')
await appendFile(testFile, 'test')
expect(await fs.exists(testFile)).toEqual(true)
await cleanUntrackedFiles(execGit)
expect(await fs.exists(testFile)).toEqual(false)
})
})
})
6 changes: 3 additions & 3 deletions test/runAll.unmocked.spec.js
Expand Up @@ -55,15 +55,15 @@ let cwd

// Get file content
const readFile = async (filename, dir = cwd) =>
fs.readFile(path.join(dir, filename), { encoding: 'utf-8' })
fs.readFile(path.resolve(dir, filename), { encoding: 'utf-8' })

// Append to file, creating if it doesn't exist
const appendFile = async (filename, content, dir = cwd) =>
fs.appendFile(path.join(dir, filename), content)
fs.appendFile(path.resolve(dir, filename), content)

// Write (over) file, creating if it doesn't exist
const writeFile = async (filename, content, dir = cwd) =>
fs.writeFile(path.join(dir, filename), content)
fs.writeFile(path.resolve(dir, filename), content)

// Wrap execGit to always pass `gitOps`
const execGit = async args => execGitBase(args, { cwd })
Expand Down

0 comments on commit f3ae378

Please sign in to comment.