Repository: isomorphic-git/isomorphic-git
Pull request #1519 (2022-03-19 19:44:06)

feat: add --force and statusMatrix --ignored
## I'm adding a parameter to an existing command add/statusMatrix:
see https://github.com/isomorphic-git/isomorphic-git/issues/1515

create a boolean `force` param for add (equivalent of `git add --force`...add it to the index even if it would match the .gitignore)

create a boolean `ignored` param for statusMatrix (it already returns anything in the index, even if ignored, but would NOT show new files if they are ignored).  This allows for that use case

---

> note 1: the `git status --ignored` flag was at some point modfied to take arguments.  

This hard to implement as an object with a default, so I left it as a boolean and it's doing "traditional"
see https://git-scm.com/docs/git-status

My recommendation would be that if you ever wanted those, you'd have another prop on the object called ignored-mode that defaults to "traditional" and implements all that other logic, so that `ignored` would represent the "presence" of the `ignored` flag, and the ignored-mode would represent its value

---
> note 2: the status command returns `ignored` for ignored files, which seems perfectly fine

- [x] add parameter to the function in `src/api/X.js` (and `src/commands/X.js` if necessary)
- [x] document the parameter in the JSDoc comment above the function
- [x] add a test case in `__tests__/test-X.js` if possible
- [ ] if this is your first time contributing, run `npm run add-contributor` and follow the prompts to add yourself to the README
- [ ] squash merge the PR with commit message "feat(X): Added 'bar' parameter"



Patch:
diff --git a/.releaserc b/.releaserc
index 752147ce..83b48acc 100644
--- a/.releaserc
+++ b/.releaserc
@@ -10,4 +10,4 @@ branches:
 success:
   - '@semantic-release/github'
   - path: '@semantic-release/exec'
-    cmd: "node __tests__/__helpers__/tweet.js 'v${nextRelease.version} has been published! https://github.com/isomorphic-git/isomorphic-git/releases/tag/v${nextRelease.version}'"
+    cmd: "node __tests__/__helpers__/tweet.cjs 'v${nextRelease.version} has been published! https://github.com/isomorphic-git/isomorphic-git/releases/tag/v${nextRelease.version}'"
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 00000000..559bf537
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,16 @@
+{
+    // Use IntelliSense to learn about possible attributes.
+    // Hover to view descriptions of existing attributes.
+    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+    "version": "0.2.0",
+    "configurations": [
+        {
+      "type": "node",
+      "request": "attach",
+      "name": "Attach to Remote",
+      "address": "localhost",
+      "port": 9229,
+      "skipFiles": ["<node_internals>/**/*.js"]
+    },
+    ]
+}
diff --git a/src/api/add.js b/src/api/add.js
index 27173103..6f25bcda 100644
--- a/src/api/add.js
+++ b/src/api/add.js
@@ -19,6 +19,7 @@ import { join } from '../utils/join.js'
  * @param {string} [args.gitdir=join(dir, '.git')] - [required] The [git directory](dir-vs-gitdir.md) path
  * @param {string|string[]} args.filepath - The path to the file to add to the index
  * @param {object} [args.cache] - a [cache](cache.md) object
+ * @param {boolean} [args.force=false] - add to index even if matches gitignore. Think `git add --force`
  *
  * @returns {Promise<void>} Resolves successfully once the git index has been updated
  *
@@ -34,6 +35,7 @@ export async function add({
   gitdir = join(dir, '.git'),
   filepath,
   cache = {},
+  force = false,
 }) {
   try {
     assertParameter('fs', _fs)
@@ -43,7 +45,7 @@ export async function add({
 
     const fs = new FileSystem(_fs)
     await GitIndexManager.acquire({ fs, gitdir, cache }, async index => {
-      return addToIndex({ dir, gitdir, fs, filepath, index })
+      return addToIndex({ dir, gitdir, fs, filepath, index, force })
     })
   } catch (err) {
     err.caller = 'git.add'
@@ -51,17 +53,19 @@ export async function add({
   }
 }
 
-async function addToIndex({ dir, gitdir, fs, filepath, index }) {
+async function addToIndex({ dir, gitdir, fs, filepath, index, force }) {
   // TODO: Should ignore UNLESS it's already in the index.
   filepath = Array.isArray(filepath) ? filepath : [filepath]
   const promises = filepath.map(async currentFilepath => {
-    const ignored = await GitIgnoreManager.isIgnored({
-      fs,
-      dir,
-      gitdir,
-      filepath: currentFilepath,
-    })
-    if (ignored) return
+    if (!force) {
+      const ignored = await GitIgnoreManager.isIgnored({
+        fs,
+        dir,
+        gitdir,
+        filepath: currentFilepath,
+      })
+      if (ignored) return
+    }
     const stats = await fs.lstat(join(dir, currentFilepath))
     if (!stats) throw new NotFoundError(currentFilepath)
 
@@ -74,6 +78,7 @@ async function addToIndex({ dir, gitdir, fs, filepath, index }) {
           fs,
           filepath: [join(currentFilepath, child)],
           index,
+          force,
         })
       )
       await Promise.all(promises)
diff --git a/src/api/statusMatrix.js b/src/api/statusMatrix.js
index 7a781b2e..3fcd9da7 100644
--- a/src/api/statusMatrix.js
+++ b/src/api/statusMatrix.js
@@ -147,6 +147,7 @@ import { worthWalking } from '../utils/worthWalking.js'
  * @param {string[]} [args.filepaths = ['.']] - Limit the query to the given files and directories
  * @param {function(string): boolean} [args.filter] - Filter the results to only those whose filepath matches a function.
  * @param {object} [args.cache] - a [cache](cache.md) object
+ * @param {boolean} [args.ignored = false] - include ignored files in the result
  *
  * @returns {Promise<Array<StatusRow>>} Resolves with a status matrix, described below.
  * @see StatusRow
@@ -159,6 +160,7 @@ export async function statusMatrix({
   filepaths = ['.'],
   filter,
   cache = {},
+  ignored: shouldIgnore = false,
 }) {
   try {
     assertParameter('fs', _fs)
@@ -175,14 +177,15 @@ export async function statusMatrix({
       map: async function(filepath, [head, workdir, stage]) {
         // Ignore ignored files, but only if they are not already tracked.
         if (!head && !stage && workdir) {
-          if (
-            await GitIgnoreManager.isIgnored({
+          if (!shouldIgnore) {
+            const isIgnored = await GitIgnoreManager.isIgnored({
               fs,
               dir,
               filepath,
             })
-          ) {
-            return null
+            if (isIgnored) {
+              return null
+            }
           }
         }
         // match against base paths
diff --git a/src/api/updateIndex.js b/src/api/updateIndex.js
index 6dd6de3d..b7e7929e 100644
--- a/src/api/updateIndex.js
+++ b/src/api/updateIndex.js
@@ -78,15 +78,22 @@ export async function updateIndex({
             fileStats = await fs.lstat(join(dir, filepath))
 
             if (fileStats) {
+              if (fileStats.isDirectory()) {
+                // Removing directories should not work
+                throw new InvalidFilepathError('directory')
+              }
+
               // Do nothing if we don't force and the file still exists in the workdir
               return
             }
           }
 
-          // Remove the file from the index if it's forced or the file does not exist
-          index.delete({
-            filepath,
-          })
+          // Directories are not allowed, so we make sure the provided filepath exists in the index
+          if (index.has({ filepath })) {
+            index.delete({
+              filepath,
+            })
+          }
         }
       )
     }
diff --git a/website/versioned_docs/version-1.x/add.md b/website/versioned_docs/version-1.x/add.md
index 3eba882c..0bfa1210 100644
--- a/website/versioned_docs/version-1.x/add.md
+++ b/website/versioned_docs/version-1.x/add.md
@@ -7,14 +7,15 @@ original_id: add
 
 Add a file to the git index (aka staging area)
 
-| param          | type [= default]                | description                                               |
-| -------------- | ------------------------------- | --------------------------------------------------------- |
-| [**fs**](./fs) | FsClient                        | a file system implementation                              |
-| **dir**        | string                          | The [working tree](dir-vs-gitdir.md) directory path       |
-| **gitdir**     | string = join(dir, '.git')      | The [git directory](dir-vs-gitdir.md) path                |
-| **filepath**   | string  &#124;  Array\<string\> | The path to the file to add to the index                  |
-| cache          | object                          | a [cache](cache.md) object                                |
-| return         | Promise\<void\>                 | Resolves successfully once the git index has been updated |
+| param          | type [= default]                | description                                                     |
+| -------------- | ------------------------------- | --------------------------------------------------------------- |
+| [**fs**](./fs) | FsClient                        | a file system implementation                                    |
+| **dir**        | string                          | The [working tree](dir-vs-gitdir.md) directory path             |
+| **gitdir**     | string = join(dir, '.git')      | The [git directory](dir-vs-gitdir.md) path                      |
+| **filepath**   | string  &#124;  Array\<string\> | The path to the file to add to the index                        |
+| cache          | object                          | a [cache](cache.md) object                                      |
+| **force**      | boolean                         | add to index even if matches gitignore. Think `git add --force` |
+| return         | Promise\<void\>                 | Resolves successfully once the git index has been updated       |
 
 Example Code:
 
diff --git a/website/versioned_docs/version-1.x/statusMatrix.md b/website/versioned_docs/version-1.x/statusMatrix.md
index 40e78035..0668d87a 100644
--- a/website/versioned_docs/version-1.x/statusMatrix.md
+++ b/website/versioned_docs/version-1.x/statusMatrix.md
@@ -16,6 +16,7 @@ Efficiently get the status of multiple files at once.
 | filepaths      | Array\<string\> = ['.']       | Limit the query to the given files and directories                                                 |
 | filter         | function(string): boolean     | Filter the results to only those whose filepath matches a function.                                |
 | cache          | object                        | a [cache](cache.md) object                                                                         |
+| ignored        | boolean                       | include ignored files in the result                                                                |
 | return         | Promise\<Array\<StatusRow\>\> | Resolves with a status matrix, described below.                                                    |
 
 ```ts


Test patch:
diff --git a/__tests__/__helpers__/tweet.js b/__tests__/__helpers__/tweet.cjs
similarity index 100%
rename from __tests__/__helpers__/tweet.js
rename to __tests__/__helpers__/tweet.cjs
diff --git a/__tests__/test-add.js b/__tests__/test-add.js
index 934896c6..ec129f83 100644
--- a/__tests__/test-add.js
+++ b/__tests__/test-add.js
@@ -94,8 +94,22 @@ describe('add', () => {
       dir,
       filepath: ['a.txt', 'i.txt'],
     })
-    expect((await listFiles({ fs, dir })).length).toEqual(1)
-    expect(await listFiles({ fs, dir })).toEqual(['a.txt'])
+  })
+  it('multiple files with 1 ignored and force:true', async () => {
+    // Setup
+    const { fs, dir } = await makeFixture('test-add')
+    await writeGitIgnore(fs, dir)
+
+    // Test
+    await init({ fs, dir })
+    await add({
+      fs,
+      dir,
+      filepath: ['a.txt', 'i.txt'],
+      force: true,
+    })
+    expect((await listFiles({ fs, dir })).length).toEqual(2)
+    expect(await listFiles({ fs, dir })).toEqual(['a.txt', 'i.txt'])
   })
   it('symlink', async () => {
     // Setup
@@ -136,6 +150,15 @@ describe('add', () => {
     await add({ fs, dir, filepath: 'i.txt' })
     expect((await listFiles({ fs, dir })).length).toEqual(0)
   })
+  it('ignored file but with force=true', async () => {
+    // Setup
+    const { fs, dir } = await makeFixture('test-add')
+    await writeGitIgnore(fs, dir)
+    // Test
+    await init({ fs, dir })
+    await add({ fs, dir, filepath: 'i.txt', force: true })
+    expect((await listFiles({ fs, dir })).length).toEqual(1)
+  })
   it('non-existant file', async () => {
     // Setup
     const { fs, dir } = await makeFixture('test-add')
@@ -168,6 +191,16 @@ describe('add', () => {
     await add({ fs, dir, filepath: 'c' })
     expect((await listFiles({ fs, dir })).length).toEqual(3)
   })
+  it('folder with .gitignore and force', async () => {
+    // Setup
+    const { fs, dir } = await makeFixture('test-add')
+    await writeGitIgnore(fs, dir)
+    // Test
+    await init({ fs, dir })
+    expect((await listFiles({ fs, dir })).length).toEqual(0)
+    await add({ fs, dir, filepath: 'c', force: true })
+    expect((await listFiles({ fs, dir })).length).toEqual(4)
+  })
   it('git add .', async () => {
     // Setup
     const { fs, dir } = await makeFixture('test-add')
diff --git a/__tests__/test-statusMatrix.js b/__tests__/test-statusMatrix.js
index a477871f..4afe3c4b 100644
--- a/__tests__/test-statusMatrix.js
+++ b/__tests__/test-statusMatrix.js
@@ -73,6 +73,195 @@ describe('statusMatrix', () => {
     expect(a).toEqual([['.gitignore', 0, 2, 2]])
   })
 
+  it('does not return ignored files already in the index', async () => {
+    // Setup
+    const { fs, dir, gitdir } = await makeFixture('test-empty')
+    await fs.write(path.join(dir, '.gitignore'), 'ignoreme.txt\n')
+    await add({ fs, dir, gitdir, filepath: '.' })
+    await fs.write(path.join(dir, 'ignoreme.txt'), 'ignored')
+
+    // Test
+    const a = await statusMatrix({ fs, dir, gitdir })
+    expect(a).toEqual([['.gitignore', 0, 2, 2]])
+  })
+
+  it('returns ignored files already in the index if ignored:true', async () => {
+    // Setup
+    const { fs, dir, gitdir } = await makeFixture('test-empty')
+    await fs.write(path.join(dir, '.gitignore'), 'ignoreme.txt\n')
+    await add({ fs, dir, gitdir, filepath: '.' })
+    await fs.write(path.join(dir, 'ignoreme.txt'), 'ignored')
+
+    // Test
+    const a = await statusMatrix({ fs, dir, gitdir, ignored: true })
+    expect(a).toEqual([
+      ['.gitignore', 0, 2, 2],
+      ['ignoreme.txt', 0, 2, 0],
+    ])
+  })
+
+  it('ignored:true works with multiple added files ', async () => {
+    // Setup
+    const { fs, dir, gitdir } = await makeFixture('test-empty')
+    await fs.write(
+      path.join(dir, '.gitignore'),
+      'ignoreme.txt\nignoreme2.txt\n'
+    )
+    await add({ fs, dir, gitdir, filepath: '.' })
+    await fs.write(path.join(dir, 'ignoreme.txt'), 'ignored')
+    await fs.write(path.join(dir, 'ignoreme2.txt'), 'ignored')
+
+    // Test
+    const a = await statusMatrix({ fs, dir, gitdir, ignored: true })
+    expect(a).toEqual([
+      ['.gitignore', 0, 2, 2],
+      ['ignoreme.txt', 0, 2, 0],
+      ['ignoreme2.txt', 0, 2, 0],
+    ])
+  })
+
+  describe('ignored:true works supports multiple filepaths', () => {
+    let fs, dir, gitdir
+    const ignoredFolder = 'ignoreThisFolder'
+    const nonIgnoredFolder = 'nonIgnoredFolder'
+
+    beforeAll(async () => {
+      // Setup
+      const output = await makeFixture('test-empty')
+      fs = output.fs
+      dir = output.dir
+      gitdir = output.gitdir
+
+      await fs.write(path.join(dir, '.gitignore'), `${ignoredFolder}/*\n`)
+      await add({ fs, dir, gitdir, filepath: '.' })
+      await fs.mkdir(path.join(dir, ignoredFolder))
+      await fs.mkdir(path.join(dir, nonIgnoredFolder))
+      await fs.write(
+        path.join(dir, nonIgnoredFolder, 'notIgnored.txt'),
+        'notIgnored'
+      )
+      await fs.write(path.join(dir, ignoredFolder, 'ignoreme.txt'), 'ignored')
+      await fs.write(path.join(dir, 'notIgnored.txt'), 'notIgnored')
+    })
+
+    it('base case: no filepaths', async () => {
+      const result = await statusMatrix({
+        fs,
+        dir,
+        gitdir,
+        ignored: true,
+      })
+      expect(result).toEqual([
+        ['.gitignore', 0, 2, 2],
+        [`${ignoredFolder}/ignoreme.txt`, 0, 2, 0],
+        [`${nonIgnoredFolder}/notIgnored.txt`, 0, 2, 0],
+        ['notIgnored.txt', 0, 2, 0],
+      ])
+    })
+
+    it('filepaths on ignored folder should return empty', async () => {
+      const result = await statusMatrix({
+        fs,
+        dir,
+        gitdir,
+        filepaths: [ignoredFolder],
+      })
+      expect(result).toEqual([])
+    })
+
+    it('shows nonignored file and folder', async () => {
+      const result = await statusMatrix({
+        fs,
+        dir,
+        gitdir,
+        filepaths: [ignoredFolder, 'notIgnored.txt', nonIgnoredFolder],
+      })
+      expect(result).toEqual([
+        [`${nonIgnoredFolder}/notIgnored.txt`, 0, 2, 0],
+        ['notIgnored.txt', 0, 2, 0],
+      ])
+    })
+
+    it('filepaths on ignored folder and non-ignored file should show all files with ignored:true ', async () => {
+      const result = await statusMatrix({
+        fs,
+        dir,
+        gitdir,
+        filepaths: [ignoredFolder, 'notIgnored.txt', nonIgnoredFolder],
+        ignored: true,
+      })
+      expect(result).toEqual([
+        [`${ignoredFolder}/ignoreme.txt`, 0, 2, 0],
+        [`${nonIgnoredFolder}/notIgnored.txt`, 0, 2, 0],
+        ['notIgnored.txt', 0, 2, 0],
+      ])
+    })
+
+    describe('all files, ignored:true, test order permutation', () => {
+      it('file, ignored, notignored', async () => {
+        const result = await statusMatrix({
+          fs,
+          dir,
+          gitdir,
+          filepaths: ['notIgnored.txt', ignoredFolder, nonIgnoredFolder],
+          ignored: true,
+        })
+        expect(result).toEqual([
+          [`${ignoredFolder}/ignoreme.txt`, 0, 2, 0],
+          [`${nonIgnoredFolder}/notIgnored.txt`, 0, 2, 0],
+          ['notIgnored.txt', 0, 2, 0],
+        ])
+      })
+      it('ignored, notignored, file', async () => {
+        const result = await statusMatrix({
+          fs,
+          dir,
+          gitdir,
+          filepaths: [ignoredFolder, nonIgnoredFolder, 'notIgnored.txt'],
+          ignored: true,
+        })
+        expect(result).toEqual([
+          [`${ignoredFolder}/ignoreme.txt`, 0, 2, 0],
+          [`${nonIgnoredFolder}/notIgnored.txt`, 0, 2, 0],
+          ['notIgnored.txt', 0, 2, 0],
+        ])
+      })
+      it('notignored, ignored, file', async () => {
+        const result = await statusMatrix({
+          fs,
+          dir,
+          gitdir,
+          filepaths: [nonIgnoredFolder, ignoredFolder, 'notIgnored.txt'],
+          ignored: true,
+        })
+        expect(result).toEqual([
+          [`${ignoredFolder}/ignoreme.txt`, 0, 2, 0],
+          [`${nonIgnoredFolder}/notIgnored.txt`, 0, 2, 0],
+          ['notIgnored.txt', 0, 2, 0],
+        ])
+      })
+    })
+  })
+  it('ignored: true has no impact when file is already in index', async () => {
+    // Setup
+    const { fs, dir, gitdir } = await makeFixture('test-empty')
+    await fs.write(path.join(dir, '.gitignore'), 'ignoreme.txt\n')
+    await fs.write(path.join(dir, 'ignoreme.txt'), 'ignored')
+    await add({ fs, dir, gitdir, filepath: '.', force: true })
+    // Test
+    const a = await statusMatrix({ fs, dir, gitdir, ignored: true })
+    expect(a).toEqual([
+      ['.gitignore', 0, 2, 2],
+      ['ignoreme.txt', 0, 2, 2],
+    ])
+    // Test
+    const b = await statusMatrix({ fs, dir, gitdir, ignored: false })
+    expect(b).toEqual([
+      ['.gitignore', 0, 2, 2],
+      ['ignoreme.txt', 0, 2, 2],
+    ])
+  })
+
   it('statusMatrix with filepaths', async () => {
     // Setup
     const { fs, dir, gitdir } = await makeFixture('test-statusMatrix-filepath')
diff --git a/__tests__/test-updateIndex.js b/__tests__/test-updateIndex.js
index 82dee6f8..341de31b 100644
--- a/__tests__/test-updateIndex.js
+++ b/__tests__/test-updateIndex.js
@@ -265,4 +265,74 @@ describe('updateIndex', () => {
       }
     `)
   })
+
+  it('should throw if we try to remove a directory', async () => {
+    // Setup
+    const { fs, dir } = await makeFixture('test-empty')
+    await fs.mkdir(path.join(dir, 'hello-world'))
+    await fs.write(path.join(dir, 'hello-world/a'), 'a')
+    await add({
+      fs,
+      dir,
+      filepath: 'hello-world/a',
+    })
+    // Test
+    let error = null
+    try {
+      await updateIndex({
+        fs,
+        dir,
+        remove: true,
+        filepath: 'hello-world',
+      })
+    } catch (e) {
+      error = e
+    }
+    expect(error).not.toBeNull()
+    expect(error.caller).toEqual('git.updateIndex')
+    error = error.toJSON()
+    delete error.stack
+    expect(error).toMatchInlineSnapshot(`
+      Object {
+        "caller": "git.updateIndex",
+        "code": "InvalidFilepathError",
+        "data": Object {
+          "reason": "directory",
+        },
+        "message": "\\"filepath\\" should not be a directory.",
+      }
+    `)
+  })
+
+  it('should not throw if we force remove a directory', async () => {
+    // Setup
+    const { fs, dir } = await makeFixture('test-empty')
+    await fs.mkdir(path.join(dir, 'hello-world'))
+    await fs.write(path.join(dir, 'hello-world/a'), 'a')
+    await add({
+      fs,
+      dir,
+      filepath: 'hello-world/a',
+    })
+    // Test
+    let fileStatus = await status({
+      fs,
+      dir,
+      filepath: 'hello-world/a',
+    })
+    expect(fileStatus).toBe('added')
+    await updateIndex({
+      fs,
+      dir,
+      remove: true,
+      force: true,
+      filepath: 'hello-world',
+    })
+    fileStatus = await status({
+      fs,
+      dir,
+      filepath: 'hello-world/a',
+    })
+    expect(fileStatus).toBe('added')
+  })
 })