From a2419396888ac372f8062c1c43856158565aac82 Mon Sep 17 00:00:00 2001
From: Johannes Schindelin <johannes.schindelin@gmx.de>
Date: Sat, 3 Jun 2023 23:39:45 +0200
Subject: [PATCH] sparse-checkout: optionally turn off cone mode

While it _is_ true that cone mode is the default nowadays (mainly for
performance reasons: code mode is much faster than non-cone mode), there
_are_ legitimate use cases where non-cone mode is really useful.

Let's add a flag to optionally disable cone mode.

Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
---
 .github/workflows/test.yml                    | 14 +++++
 README.md                                     | 15 ++++++
 __test__/git-auth-helper.test.ts              |  2 +
 __test__/git-directory-helper.test.ts         |  1 +
 __test__/input-helper.test.ts                 |  1 +
 .../verify-sparse-checkout-non-cone-mode.sh   | 51 +++++++++++++++++++
 action.yml                                    |  4 ++
 dist/index.js                                 | 23 ++++++++-
 src/git-command-manager.ts                    | 19 +++++++
 src/git-source-provider.ts                    |  6 ++-
 src/git-source-settings.ts                    |  5 ++
 src/input-helper.ts                           |  4 ++
 12 files changed, 143 insertions(+), 2 deletions(-)
 create mode 100755 __test__/verify-sparse-checkout-non-cone-mode.sh

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 8d5fffd..d8b0b6d 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -85,6 +85,20 @@ jobs:
       - name: Verify sparse checkout
         run: __test__/verify-sparse-checkout.sh
 
+      # Sparse checkout (non-cone mode)
+      - name: Sparse checkout (non-cone mode)
+        uses: ./
+        with:
+          sparse-checkout: |
+            /__test__/
+            /.github/
+            /dist/
+          sparse-checkout-cone-mode: false
+          path: sparse-checkout-non-cone-mode
+
+      - name: Verify sparse checkout (non-cone mode)
+        run: __test__/verify-sparse-checkout-non-cone-mode.sh
+
       # LFS
       - name: Checkout LFS
         uses: ./
diff --git a/README.md b/README.md
index 14b4366..5427a50 100644
--- a/README.md
+++ b/README.md
@@ -79,6 +79,10 @@ When Git 2.18 or higher is not in your PATH, falls back to the REST API to downl
     # Default: null
     sparse-checkout: ''
 
+    # Specifies whether to use cone-mode when doing a sparse checkout.
+    # Default: true
+    sparse-checkout-cone-mode: ''
+
     # Number of commits to fetch. 0 indicates all history for all branches and tags.
     # Default: 1
     fetch-depth: ''
@@ -113,6 +117,7 @@ When Git 2.18 or higher is not in your PATH, falls back to the REST API to downl
 
 - [Fetch only the root files](#Fetch-only-the-root-files)
 - [Fetch only the root files and `.github` and `src` folder](#Fetch-only-the-root-files-and-github-and-src-folder)
+- [Fetch only a single file](#Fetch-only-a-single-file)
 - [Fetch all history for all tags and branches](#Fetch-all-history-for-all-tags-and-branches)
 - [Checkout a different branch](#Checkout-a-different-branch)
 - [Checkout HEAD^](#Checkout-HEAD)
@@ -141,6 +146,16 @@ When Git 2.18 or higher is not in your PATH, falls back to the REST API to downl
       src
 ```
 
+## Fetch only a single file
+
+```yaml
+- uses: actions/checkout@v3
+  with:
+    sparse-checkout: |
+      README.md
+    sparse-checkout-cone-mode: false
+```
+
 ## Fetch all history for all tags and branches
 
 ```yaml
diff --git a/__test__/git-auth-helper.test.ts b/__test__/git-auth-helper.test.ts
index f2cbfa5..fec6573 100644
--- a/__test__/git-auth-helper.test.ts
+++ b/__test__/git-auth-helper.test.ts
@@ -728,6 +728,7 @@ async function setup(testName: string): Promise<void> {
     branchExists: jest.fn(),
     branchList: jest.fn(),
     sparseCheckout: jest.fn(),
+    sparseCheckoutNonConeMode: jest.fn(),
     checkout: jest.fn(),
     checkoutDetach: jest.fn(),
     config: jest.fn(
@@ -802,6 +803,7 @@ async function setup(testName: string): Promise<void> {
     clean: true,
     commit: '',
     sparseCheckout: [],
+    sparseCheckoutConeMode: true,
     fetchDepth: 1,
     lfs: false,
     submodules: false,
diff --git a/__test__/git-directory-helper.test.ts b/__test__/git-directory-helper.test.ts
index 8b05606..362133f 100644
--- a/__test__/git-directory-helper.test.ts
+++ b/__test__/git-directory-helper.test.ts
@@ -463,6 +463,7 @@ async function setup(testName: string): Promise<void> {
       return []
     }),
     sparseCheckout: jest.fn(),
+    sparseCheckoutNonConeMode: jest.fn(),
     checkout: jest.fn(),
     checkoutDetach: jest.fn(),
     config: jest.fn(),
diff --git a/__test__/input-helper.test.ts b/__test__/input-helper.test.ts
index 6d7421b..069fda4 100644
--- a/__test__/input-helper.test.ts
+++ b/__test__/input-helper.test.ts
@@ -80,6 +80,7 @@ describe('input-helper tests', () => {
     expect(settings.commit).toBeTruthy()
     expect(settings.commit).toBe('1234567890123456789012345678901234567890')
     expect(settings.sparseCheckout).toBe(undefined)
+    expect(settings.sparseCheckoutConeMode).toBe(true)
     expect(settings.fetchDepth).toBe(1)
     expect(settings.lfs).toBe(false)
     expect(settings.ref).toBe('refs/heads/some-ref')
diff --git a/__test__/verify-sparse-checkout-non-cone-mode.sh b/__test__/verify-sparse-checkout-non-cone-mode.sh
new file mode 100755
index 0000000..0d5d56f
--- /dev/null
+++ b/__test__/verify-sparse-checkout-non-cone-mode.sh
@@ -0,0 +1,51 @@
+#!/bin/sh
+
+# Verify .git folder
+if [ ! -d "./sparse-checkout-non-cone-mode/.git" ]; then
+  echo "Expected ./sparse-checkout-non-cone-mode/.git folder to exist"
+  exit 1
+fi
+
+# Verify sparse-checkout (non-cone-mode)
+cd sparse-checkout-non-cone-mode
+
+ENABLED=$(git config --local --get-all core.sparseCheckout)
+
+if [ "$?" != "0" ]; then
+    echo "Failed to verify that sparse-checkout is enabled"
+    exit 1
+fi
+
+# Check that sparse-checkout is enabled
+if [ "$ENABLED" != "true" ]; then
+  echo "Expected sparse-checkout to be enabled (is: $ENABLED)"
+  exit 1
+fi
+
+SPARSE_CHECKOUT_FILE=$(git rev-parse --git-path info/sparse-checkout)
+
+if [ "$?" != "0" ]; then
+    echo "Failed to validate sparse-checkout"
+    exit 1
+fi
+
+# Check that sparse-checkout list is not empty
+if [ ! -f "$SPARSE_CHECKOUT_FILE" ]; then
+  echo "Expected sparse-checkout file to exist"
+  exit 1
+fi
+
+# Check that all folders from sparse-checkout exists
+for pattern in $(cat "$SPARSE_CHECKOUT_FILE")
+do
+  if [ ! -d "${pattern#/}" ]; then
+    echo "Expected directory '${pattern#/}' to exist"
+    exit 1
+  fi
+done
+
+# Verify that the root directory is not checked out
+if [ -f README.md ]; then
+  echo "Expected top-level files not to exist"
+  exit 1
+fi
\ No newline at end of file
diff --git a/action.yml b/action.yml
index d0c96b1..e562b56 100644
--- a/action.yml
+++ b/action.yml
@@ -58,6 +58,10 @@ inputs:
       Do a sparse checkout on given patterns.
       Each pattern should be separated with new lines
     default: null
+  sparse-checkout-cone-mode:
+    description: >
+      Specifies whether to use cone-mode when doing a sparse checkout.
+    default: true
   fetch-depth:
     description: 'Number of commits to fetch. 0 indicates all history for all branches and tags.'
     default: 1
diff --git a/dist/index.js b/dist/index.js
index b48bffb..ca47b4a 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -470,6 +470,7 @@ Object.defineProperty(exports, "__esModule", ({ value: true }));
 exports.createCommandManager = exports.MinimumGitVersion = void 0;
 const core = __importStar(__nccwpck_require__(2186));
 const exec = __importStar(__nccwpck_require__(1514));
+const fs = __importStar(__nccwpck_require__(7147));
 const fshelper = __importStar(__nccwpck_require__(7219));
 const io = __importStar(__nccwpck_require__(7436));
 const path = __importStar(__nccwpck_require__(1017));
@@ -579,6 +580,18 @@ class GitCommandManager {
             yield this.execGit(['sparse-checkout', 'set', ...sparseCheckout]);
         });
     }
+    sparseCheckoutNonConeMode(sparseCheckout) {
+        return __awaiter(this, void 0, void 0, function* () {
+            yield this.execGit(['config', 'core.sparseCheckout', 'true']);
+            const output = yield this.execGit([
+                'rev-parse',
+                '--git-path',
+                'info/sparse-checkout'
+            ]);
+            const sparseCheckoutPath = path.join(this.workingDirectory, output.stdout.trimRight());
+            yield fs.promises.appendFile(sparseCheckoutPath, `\n${sparseCheckout.join('\n')}\n`);
+        });
+    }
     checkout(ref, startPoint) {
         return __awaiter(this, void 0, void 0, function* () {
             const args = ['checkout', '--progress', '--force'];
@@ -1253,7 +1266,12 @@ function getSource(settings) {
             // Sparse checkout
             if (settings.sparseCheckout) {
                 core.startGroup('Setting up sparse checkout');
-                yield git.sparseCheckout(settings.sparseCheckout);
+                if (settings.sparseCheckoutConeMode) {
+                    yield git.sparseCheckout(settings.sparseCheckout);
+                }
+                else {
+                    yield git.sparseCheckoutNonConeMode(settings.sparseCheckout);
+                }
                 core.endGroup();
             }
             // Checkout
@@ -1697,6 +1715,9 @@ function getInputs() {
             result.sparseCheckout = sparseCheckout;
             core.debug(`sparse checkout = ${result.sparseCheckout}`);
         }
+        result.sparseCheckoutConeMode =
+            (core.getInput('sparse-checkout-cone-mode') || 'true').toUpperCase() ===
+                'TRUE';
         // Fetch depth
         result.fetchDepth = Math.floor(Number(core.getInput('fetch-depth') || '1'));
         if (isNaN(result.fetchDepth) || result.fetchDepth < 0) {
diff --git a/src/git-command-manager.ts b/src/git-command-manager.ts
index 3a0ec58..9ef9488 100644
--- a/src/git-command-manager.ts
+++ b/src/git-command-manager.ts
@@ -1,5 +1,6 @@
 import * as core from '@actions/core'
 import * as exec from '@actions/exec'
+import * as fs from 'fs'
 import * as fshelper from './fs-helper'
 import * as io from '@actions/io'
 import * as path from 'path'
@@ -17,6 +18,7 @@ export interface IGitCommandManager {
   branchExists(remote: boolean, pattern: string): Promise<boolean>
   branchList(remote: boolean): Promise<string[]>
   sparseCheckout(sparseCheckout: string[]): Promise<void>
+  sparseCheckoutNonConeMode(sparseCheckout: string[]): Promise<void>
   checkout(ref: string, startPoint: string): Promise<void>
   checkoutDetach(): Promise<void>
   config(
@@ -165,6 +167,23 @@ class GitCommandManager {
     await this.execGit(['sparse-checkout', 'set', ...sparseCheckout])
   }
 
+  async sparseCheckoutNonConeMode(sparseCheckout: string[]): Promise<void> {
+    await this.execGit(['config', 'core.sparseCheckout', 'true'])
+    const output = await this.execGit([
+      'rev-parse',
+      '--git-path',
+      'info/sparse-checkout'
+    ])
+    const sparseCheckoutPath = path.join(
+      this.workingDirectory,
+      output.stdout.trimRight()
+    )
+    await fs.promises.appendFile(
+      sparseCheckoutPath,
+      `\n${sparseCheckout.join('\n')}\n`
+    )
+  }
+
   async checkout(ref: string, startPoint: string): Promise<void> {
     const args = ['checkout', '--progress', '--force']
     if (startPoint) {
diff --git a/src/git-source-provider.ts b/src/git-source-provider.ts
index 92e9d00..b96eb98 100644
--- a/src/git-source-provider.ts
+++ b/src/git-source-provider.ts
@@ -197,7 +197,11 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
     // Sparse checkout
     if (settings.sparseCheckout) {
       core.startGroup('Setting up sparse checkout')
-      await git.sparseCheckout(settings.sparseCheckout)
+      if (settings.sparseCheckoutConeMode) {
+        await git.sparseCheckout(settings.sparseCheckout)
+      } else {
+        await git.sparseCheckoutNonConeMode(settings.sparseCheckout)
+      }
       core.endGroup()
     }
 
diff --git a/src/git-source-settings.ts b/src/git-source-settings.ts
index 182c453..3272e63 100644
--- a/src/git-source-settings.ts
+++ b/src/git-source-settings.ts
@@ -34,6 +34,11 @@ export interface IGitSourceSettings {
    */
   sparseCheckout: string[]
 
+  /**
+   * Indicates whether to use cone mode in the sparse checkout (if any)
+   */
+  sparseCheckoutConeMode: boolean
+
   /**
    * The depth when fetching
    */
diff --git a/src/input-helper.ts b/src/input-helper.ts
index e9a2d73..410e480 100644
--- a/src/input-helper.ts
+++ b/src/input-helper.ts
@@ -89,6 +89,10 @@ export async function getInputs(): Promise<IGitSourceSettings> {
     core.debug(`sparse checkout = ${result.sparseCheckout}`)
   }
 
+  result.sparseCheckoutConeMode =
+    (core.getInput('sparse-checkout-cone-mode') || 'true').toUpperCase() ===
+    'TRUE'
+
   // Fetch depth
   result.fetchDepth = Math.floor(Number(core.getInput('fetch-depth') || '1'))
   if (isNaN(result.fetchDepth) || result.fetchDepth < 0) {