Repository: okonet/lint-staged
Pull request #838 (2020-04-10 16:03:08)

fix: allow lint-staged to run on empty git repo by disabling backup
Since v10, lint-staged has not been able to run on empty git repos, because the backup stash requires at least the initial commit.

In v10.1.0 an option `--no-stash` was added to make lint-staged skip creating the stash. We can use this same logic to enable running on empty repos, and print a descriptive warning message.

okonet: Hmm tests failed
iiroj: Yeah I’m figuring it out. That snapshot shouldn’t be important, but I don’t know why it changed in the CI and not locally.
jinggk: @iiroj Sorry to bother you again, I want to know, can this no-stash option be smarter? Is it possible to check whether my repo already has existing commits, because I found that adding this option seems to return directly every time, worrying about whether it may be affected by the potential normal process
iiroj: @jinggk can you please rephrase your question?

This PR does add an automatic check that disables the backup stash and allows lint-staged to run on an empty git repo.
jinggk: @iiroj thankyou i got it~
codecov[bot]: # [Codecov](https://codecov.io/gh/okonet/lint-staged/pull/838?src=pr&el=h1) Report
> Merging [#838](https://codecov.io/gh/okonet/lint-staged/pull/838?src=pr&el=desc) into [master](https://codecov.io/gh/okonet/lint-staged/commit/1ac6863a283c55e27d92fb91c87d09f30af56f47&el=desc) will **not change** coverage.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/okonet/lint-staged/pull/838/graphs/tree.svg?width=650&height=150&src=pr&token=LSoEgsjk4O)](https://codecov.io/gh/okonet/lint-staged/pull/838?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##            master      #838   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           13        13           
  Lines          521       523    +2     
  Branches       109       109           
=========================================
+ Hits           521       523    +2     
```


| [Impacted Files](https://codecov.io/gh/okonet/lint-staged/pull/838?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [lib/gitWorkflow.js](https://codecov.io/gh/okonet/lint-staged/pull/838/diff?src=pr&el=tree#diff-bGliL2dpdFdvcmtmbG93Lmpz) | `100.00% <100.00%> (ø)` | |
| [lib/runAll.js](https://codecov.io/gh/okonet/lint-staged/pull/838/diff?src=pr&el=tree#diff-bGliL3J1bkFsbC5qcw==) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/okonet/lint-staged/pull/838?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/okonet/lint-staged/pull/838?src=pr&el=footer). Last update [1ac6863...8513934](https://codecov.io/gh/okonet/lint-staged/pull/838?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

iiroj: @okonet I opted to remove the check, and instead just test that it resolves.

Patch:
diff --git a/lib/gitWorkflow.js b/lib/gitWorkflow.js
index f83d2a2..582b38e 100644
--- a/lib/gitWorkflow.js
+++ b/lib/gitWorkflow.js
@@ -166,7 +166,7 @@ class GitWorkflow {
   /**
    * Create a diff of partially staged files and backup stash if enabled.
    */
-  async prepare(ctx, stash) {
+  async prepare(ctx, shouldBackup) {
     try {
       debug('Backing up original state...')
 
@@ -184,7 +184,7 @@ class GitWorkflow {
       /**
        * If backup stash should be skipped, no need to continue
        */
-      if (!stash) return
+      if (!shouldBackup) return
 
       // 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
@@ -207,9 +207,6 @@ class GitWorkflow {
 
       debug('Done backing up original state!')
     } catch (error) {
-      if (error.message && error.message.includes('You do not have the initial commit yet')) {
-        ctx.emptyGitRepo = true
-      }
       handleError(error, ctx)
     }
   }
diff --git a/lib/runAll.js b/lib/runAll.js
index 59a44f7..993d631 100644
--- a/lib/runAll.js
+++ b/lib/runAll.js
@@ -7,6 +7,7 @@ const Listr = require('listr')
 const symbols = require('log-symbols')
 
 const chunkFiles = require('./chunkFiles')
+const execGit = require('./execGit')
 const generateTasks = require('./generateTasks')
 const getStagedFiles = require('./getStagedFiles')
 const GitWorkflow = require('./gitWorkflow')
@@ -91,15 +92,22 @@ const runAll = async (
 ) => {
   debugLog('Running all linter scripts')
 
-  if (!stash) {
-    logger.warn(
-      `${symbols.warning} ${chalk.yellow('Skipping backup because `--no-stash` was used.')}`
-    )
-  }
-
   const { gitDir, gitConfigDir } = await resolveGitRepo(cwd)
   if (!gitDir) throw new Error('Current directory is not a git directory!')
 
+  // Test whether we have any commits or not.
+  // Stashing must be disabled with no initial commit.
+  const hasInitialCommit = await execGit(['log', '-1'], { cwd: gitDir })
+    .then(() => true)
+    .catch(() => false)
+
+  // Lint-staged should create a backup stash only when there's an initial commit
+  const shouldBackup = hasInitialCommit && stash
+  if (!shouldBackup) {
+    const reason = hasInitialCommit ? '`--no-stash` was used' : 'there’s no initial commit yet'
+    logger.warn(`${symbols.warning} ${chalk.yellow(`Skipping backup because ${reason}.\n`)}`)
+  }
+
   const files = await getStagedFiles({ cwd: gitDir })
   if (!files) throw new Error('Unable to get staged files!')
   debugLog('Loaded list of staged files in git:\n%O', files)
@@ -202,7 +210,7 @@ const runAll = async (
     [
       {
         title: 'Preparing...',
-        task: ctx => git.prepare(ctx, stash)
+        task: ctx => git.prepare(ctx, shouldBackup)
       },
       {
         title: 'Hiding unstaged changes to partially staged files...',
@@ -214,7 +222,7 @@ const runAll = async (
         title: 'Applying modifications...',
         task: ctx => git.applyModifications(ctx),
         // Always apply back unstaged modifications when skipping backup
-        skip: ctx => stash && shouldSkipApplyModifications(ctx)
+        skip: ctx => shouldBackup && shouldSkipApplyModifications(ctx)
       },
       {
         title: 'Restoring unstaged changes to partially staged files...',
@@ -226,14 +234,14 @@ const runAll = async (
         title: 'Reverting to original state because of errors...',
         task: ctx => git.restoreOriginalState(ctx),
         enabled: ctx =>
-          stash &&
+          shouldBackup &&
           (ctx.taskError || ctx.gitApplyEmptyCommitError || ctx.gitRestoreUnstagedChangesError),
         skip: shouldSkipRevert
       },
       {
         title: 'Cleaning up...',
         task: ctx => git.cleanup(ctx),
-        enabled: () => stash,
+        enabled: () => shouldBackup,
         skip: shouldSkipCleanup
       }
     ],
@@ -251,12 +259,7 @@ const runAll = async (
     } else if (error.context.gitError && !error.context.gitGetBackupStashError) {
       logger.error(`\n  ${symbols.error} ${chalk.red(`lint-staged failed due to a git error.`)}`)
 
-      if (error.context.emptyGitRepo) {
-        logger.error(
-          `\n    The initial commit is needed for lint-staged to work.
-    Please use the --no-verify flag to skip running lint-staged.`
-        )
-      } else if (stash) {
+      if (shouldBackup) {
         // No sense to show this if the backup stash itself is missing.
         logger.error(`  Any lost modifications can be restored from a git stash:
 


Test patch:
diff --git a/test/__snapshots__/runAll.spec.js.snap b/test/__snapshots__/runAll.spec.js.snap
deleted file mode 100644
index c0f1bb4..0000000
--- a/test/__snapshots__/runAll.spec.js.snap
+++ /dev/null
@@ -1,89 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`runAll should not skip tasks if there are files 1`] = `
-"
-LOG Preparing... [started]
-LOG Preparing... [completed]
-LOG Running tasks... [started]
-LOG Running tasks for *.js [started]
-LOG echo \\"sample\\" [started]
-LOG echo \\"sample\\" [completed]
-LOG Running tasks for *.js [completed]
-LOG Running tasks... [completed]
-LOG Applying modifications... [started]
-LOG Applying modifications... [completed]
-LOG Cleaning up... [started]
-LOG Cleaning up... [completed]"
-`;
-
-exports[`runAll should resolve the promise with no files 1`] = `
-"
-LOG i No staged files found."
-`;
-
-exports[`runAll should skip applying unstaged modifications if there are errors during linting 1`] = `
-"
-LOG Preparing... [started]
-LOG Preparing... [completed]
-LOG Running tasks... [started]
-LOG Running tasks for *.js [started]
-LOG echo \\"sample\\" [started]
-LOG echo \\"sample\\" [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 errors from tasks.
-LOG Reverting to original state because of errors... [started]
-LOG Reverting to original state because of errors... [completed]
-LOG Cleaning up... [started]
-LOG Cleaning up... [completed]
-LOG {
-  name: 'ListrError',
-  errors: [
-    {
-      privateMsg: '\\\\n\\\\n\\\\n× echo found some errors. Please fix them and try committing again.\\\\n\\\\nLinter finished with error',
-      context: {taskError: true}
-    }
-  ],
-  context: {taskError: true}
-}"
-`;
-
-exports[`runAll should skip tasks and restore state if terminated 1`] = `
-"
-LOG Preparing... [started]
-LOG Preparing... [completed]
-LOG Running tasks... [started]
-LOG Running tasks for *.js [started]
-LOG echo \\"sample\\" [started]
-LOG echo \\"sample\\" [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 errors from tasks.
-LOG Reverting to original state because of errors... [started]
-LOG Reverting to original state because of errors... [completed]
-LOG Cleaning up... [started]
-LOG Cleaning up... [completed]
-LOG {
-  name: 'ListrError',
-  errors: [
-    {
-      privateMsg: '\\\\n\\\\n\\\\n‼ echo was terminated with SIGINT',
-      context: {taskError: true}
-    }
-  ],
-  context: {taskError: true}
-}"
-`;
-
-exports[`runAll should use an injected logger 1`] = `
-"
-LOG i No staged files found."
-`;
diff --git a/test/runAll.spec.js b/test/runAll.spec.js
index ffdf038..fe6009d 100644
--- a/test/runAll.spec.js
+++ b/test/runAll.spec.js
@@ -17,9 +17,9 @@ resolveGitRepo.mockImplementation(async () => {
 })
 getStagedFiles.mockImplementation(async () => [])
 
-const globalConsoleTemp = console
-
 describe('runAll', () => {
+  const globalConsoleTemp = console
+
   beforeAll(() => {
     console = makeConsoleMock()
   })
@@ -33,30 +33,52 @@ describe('runAll', () => {
   })
 
   it('should resolve the promise with no tasks', async () => {
-    await expect(runAll({ config: {} })).resolves
+    expect.assertions(1)
+    await expect(runAll({ config: {} })).resolves.toEqual(undefined)
   })
 
   it('should resolve the promise with no files', async () => {
+    expect.assertions(1)
     await runAll({ config: { '*.js': ['echo "sample"'] } })
-    expect(console.printHistory()).toMatchSnapshot()
+    expect(console.printHistory()).toMatchInlineSnapshot(`
+      "
+      LOG i No staged files found."
+    `)
   })
 
   it('should use an injected logger', async () => {
     expect.assertions(1)
     const logger = makeConsoleMock()
     await runAll({ config: { '*.js': ['echo "sample"'] }, debug: true }, logger)
-    expect(logger.printHistory()).toMatchSnapshot()
+    expect(logger.printHistory()).toMatchInlineSnapshot(`
+      "
+      LOG i No staged files found."
+    `)
   })
 
   it('should not skip tasks if there are files', async () => {
     expect.assertions(1)
     getStagedFiles.mockImplementationOnce(async () => ['sample.js'])
     await runAll({ config: { '*.js': ['echo "sample"'] } })
-    expect(console.printHistory()).toMatchSnapshot()
+    expect(console.printHistory()).toMatchInlineSnapshot(`
+      "
+      LOG Preparing... [started]
+      LOG Preparing... [completed]
+      LOG Running tasks... [started]
+      LOG Running tasks for *.js [started]
+      LOG echo \\"sample\\" [started]
+      LOG echo \\"sample\\" [completed]
+      LOG Running tasks for *.js [completed]
+      LOG Running tasks... [completed]
+      LOG Applying modifications... [started]
+      LOG Applying modifications... [completed]
+      LOG Cleaning up... [started]
+      LOG Cleaning up... [completed]"
+    `)
   })
 
   it('should skip applying unstaged modifications if there are errors during linting', async () => {
-    expect.assertions(1)
+    expect.assertions(2)
     getStagedFiles.mockImplementationOnce(async () => ['sample.js'])
     execa.mockImplementation(() =>
       Promise.resolve({
@@ -68,12 +90,30 @@ describe('runAll', () => {
       })
     )
 
-    try {
-      await runAll({ config: { '*.js': ['echo "sample"'] } })
-    } catch (err) {
-      console.log(err)
-    }
-    expect(console.printHistory()).toMatchSnapshot()
+    await expect(
+      runAll({ config: { '*.js': ['echo "sample"'] } })
+    ).rejects.toThrowErrorMatchingInlineSnapshot(`"Something went wrong"`)
+
+    expect(console.printHistory()).toMatchInlineSnapshot(`
+      "
+      LOG Preparing... [started]
+      LOG Preparing... [completed]
+      LOG Running tasks... [started]
+      LOG Running tasks for *.js [started]
+      LOG echo \\"sample\\" [started]
+      LOG echo \\"sample\\" [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 errors from tasks.
+      LOG Reverting to original state because of errors... [started]
+      LOG Reverting to original state because of errors... [completed]
+      LOG Cleaning up... [started]
+      LOG Cleaning up... [completed]"
+    `)
   })
 
   it('should skip tasks and restore state if terminated', async () => {
@@ -96,7 +136,37 @@ describe('runAll', () => {
     } catch (err) {
       console.log(err)
     }
-    expect(console.printHistory()).toMatchSnapshot()
+
+    expect(console.printHistory()).toMatchInlineSnapshot(`
+      "
+      LOG Preparing... [started]
+      LOG Preparing... [completed]
+      LOG Running tasks... [started]
+      LOG Running tasks for *.js [started]
+      LOG echo \\"sample\\" [started]
+      LOG echo \\"sample\\" [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 errors from tasks.
+      LOG Reverting to original state because of errors... [started]
+      LOG Reverting to original state because of errors... [completed]
+      LOG Cleaning up... [started]
+      LOG Cleaning up... [completed]
+      LOG {
+        name: 'ListrError',
+        errors: [
+          {
+            privateMsg: '\\\\n\\\\n\\\\n‼ echo was terminated with SIGINT',
+            context: {taskError: true}
+          }
+        ],
+        context: {taskError: true}
+      }"
+    `)
   })
 })
 
diff --git a/test/runAll.unmocked.spec.js b/test/runAll.unmocked.spec.js
index 3a7a822..7f6ffec 100644
--- a/test/runAll.unmocked.spec.js
+++ b/test/runAll.unmocked.spec.js
@@ -5,11 +5,11 @@ import os from 'os'
 import path from 'path'
 import nanoid from 'nanoid'
 
+jest.unmock('execa')
+
 import execGitBase from '../lib/execGit'
 import runAll from '../lib/runAll'
 
-jest.unmock('execa')
-
 jest.setTimeout(20000)
 
 const testJsFilePretty = `module.exports = {
@@ -70,8 +70,8 @@ const execGit = async (args, options = {}) => execGitBase(args, { cwd, ...option
 
 // Execute runAll before git commit to emulate lint-staged
 const gitCommit = async (options, args = ['-m test']) => {
-  await runAll({ quiet: true, ...options, cwd })
-  await execGit(['commit', ...args])
+  await runAll({ quiet: true, cwd, ...options })
+  await execGit(['commit', ...args], { cwd, ...options })
 }
 
 describe('runAll', () => {
@@ -945,6 +945,7 @@ describe('runAll', () => {
     expect(console.printHistory()).toMatchInlineSnapshot(`
       "
       WARN ‼ Skipping backup because \`--no-stash\` was used.
+
       LOG Preparing... [started]
       LOG Preparing... [completed]
       LOG Running tasks... [started]
@@ -986,6 +987,7 @@ describe('runAll', () => {
     expect(console.printHistory()).toMatchInlineSnapshot(`
       "
       WARN ‼ Skipping backup because \`--no-stash\` was used.
+
       LOG Preparing... [started]
       LOG Preparing... [completed]
       LOG Hiding unstaged changes to partially staged files... [started]
@@ -1041,6 +1043,7 @@ describe('runAll', () => {
     expect(console.printHistory()).toMatchInlineSnapshot(`
       "
       WARN ‼ Skipping backup because \`--no-stash\` was used.
+
       LOG Preparing... [started]
       LOG Preparing... [completed]
       LOG Running tasks... [started]
@@ -1072,26 +1075,49 @@ describe('runAll', () => {
 })
 
 describe('runAll', () => {
-  it('Should throw when run on an empty git repo without an initial commit', async () => {
+  it('Should skip backup when run on an empty git repo without an initial commit', async () => {
+    const globalConsoleTemp = console
+    console = makeConsoleMock()
     const tmpDir = await createTempDir()
     const cwd = normalize(tmpDir)
-    const logger = makeConsoleMock()
 
     await execGit('init', { cwd })
     await execGit(['config', 'user.name', '"test"'], { cwd })
     await execGit(['config', 'user.email', '"test@test.com"'], { cwd })
     await appendFile('test.js', testJsFilePretty, cwd)
     await execGit(['add', 'test.js'], { cwd })
-    await expect(
-      runAll({ config: { '*.js': 'prettier --list-different' }, cwd, quiet: true }, logger)
-    ).rejects.toThrowErrorMatchingInlineSnapshot(`"Something went wrong"`)
-    expect(logger.printHistory()).toMatchInlineSnapshot(`
+
+    await expect(execGit(['log', '-1'], { cwd })).rejects.toThrowErrorMatchingInlineSnapshot(
+      `"fatal: your current branch 'master' does not have any commits yet"`
+    )
+
+    await gitCommit({
+      config: { '*.js': 'prettier --list-different' },
+      cwd,
+      debut: true,
+      quiet: false
+    })
+
+    expect(console.printHistory()).toMatchInlineSnapshot(`
       "
-      ERROR 
-        × lint-staged failed due to a git error.
-      ERROR 
-          The initial commit is needed for lint-staged to work.
-          Please use the --no-verify flag to skip running lint-staged."
+      WARN ‼ Skipping backup because there’s no initial commit yet.
+
+      LOG Preparing... [started]
+      LOG Preparing... [completed]
+      LOG Running tasks... [started]
+      LOG Running tasks for *.js [started]
+      LOG prettier --list-different [started]
+      LOG prettier --list-different [completed]
+      LOG Running tasks for *.js [completed]
+      LOG Running tasks... [completed]
+      LOG Applying modifications... [started]
+      LOG Applying modifications... [completed]"
     `)
+
+    // Nothing is wrong, so the initial commit is created
+    expect(await execGit(['rev-list', '--count', 'HEAD'], { cwd })).toEqual('1')
+    expect(await execGit(['log', '-1', '--pretty=%B'], { cwd })).toMatch('test')
+    expect(await readFile('test.js', cwd)).toEqual(testJsFilePretty)
+    console = globalConsoleTemp
   })
 })