diff --git a/lib/gitWorkflow.js b/lib/gitWorkflow.js index ac178163c..0674831b9 100644 --- a/lib/gitWorkflow.js +++ b/lib/gitWorkflow.js @@ -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} + */ +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 }) @@ -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', @@ -91,6 +105,7 @@ class GitWorkflow { await this.getBackupStash(), '-R' // Show diff in reverse ]) + debug('Done backing up original state!') } @@ -189,3 +204,4 @@ class GitWorkflow { } module.exports = GitWorkflow +module.exports.cleanUntrackedFiles = cleanUntrackedFiles diff --git a/test/gitWorkflow.spec.js b/test/gitWorkflow.spec.js new file mode 100644 index 000000000..f52a0af81 --- /dev/null +++ b/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} + */ +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} + */ +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) + }) + }) +}) diff --git a/test/runAll.unmocked.spec.js b/test/runAll.unmocked.spec.js index 52c6f9078..6c14c310e 100644 --- a/test/runAll.unmocked.spec.js +++ b/test/runAll.unmocked.spec.js @@ -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 })