From d106d4669b3bfcb17f11f83f98e1cab478e9f635 Mon Sep 17 00:00:00 2001
From: Johannes Schindelin <johannes.schindelin@gmx.de>
Date: Fri, 9 Jun 2023 15:08:21 +0200
Subject: [PATCH] Add support for sparse checkouts (#1369)

* Add support for sparse checkouts

* 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>

* Verify minimum Git version for sparse checkout

The `git sparse-checkout` command is available only since Git version
v2.25.0. The `actions/checkout` Action actually supports older Git
versions than that; As of time of writing, the minimum version is
v2.18.0.

Instead of raising this minimum version even for users who do not
require a sparse checkout, only check for this minimum version
specifically when a sparse checkout was asked for.

Suggested-by: Tingluo Huang <tingluohuang@github.com>
Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>

* Support sparse checkout/LFS better

Instead of fetching all the LFS objects present in the current revision
in a sparse checkout, whether they are needed inside the sparse cone or
not, let's instead only pull the ones that are actually needed.

To do that, let's avoid running that preemptive `git lfs fetch` call in
case of a sparse checkout.

An alternative that was considered during the development of this patch
(and ultimately rejected) was to use `git lfs pull --include <path>...`,
but it turned out to be too inflexible because it requires exact paths,
not the patterns that are available via the sparse checkout definition,
and that risks running into command-line length limitations.

Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>

---------

Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Co-authored-by: Daniel <daniel.fernandez@feverup.com>
---
 .github/workflows/test.yml                    | 27 ++++++
 README.md                                     | 40 +++++++++
 __test__/git-auth-helper.test.ts              |  4 +
 __test__/git-command-manager.test.ts          | 14 +++-
 __test__/git-directory-helper.test.ts         |  2 +
 __test__/input-helper.test.ts                 |  2 +
 .../verify-sparse-checkout-non-cone-mode.sh   | 51 ++++++++++++
 __test__/verify-sparse-checkout.sh            | 63 ++++++++++++++
 action.yml                                    |  9 ++
 dist/index.js                                 | 83 +++++++++++++++----
 src/git-command-manager.ts                    | 78 +++++++++++++++--
 src/git-source-provider.ts                    | 32 +++++--
 src/git-source-settings.ts                    | 10 +++
 src/input-helper.ts                           | 11 +++
 14 files changed, 395 insertions(+), 31 deletions(-)
 create mode 100755 __test__/verify-sparse-checkout-non-cone-mode.sh
 create mode 100755 __test__/verify-sparse-checkout.sh

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index c28e771..d8b0b6d 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -72,6 +72,33 @@ jobs:
         shell: bash
         run: __test__/verify-side-by-side.sh
 
+      # Sparse checkout
+      - name: Sparse checkout
+        uses: ./
+        with:
+          sparse-checkout: |
+            __test__
+            .github
+            dist
+          path: sparse-checkout
+
+      - 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 8fe140f..5427a50 100644
--- a/README.md
+++ b/README.md
@@ -74,6 +74,15 @@ When Git 2.18 or higher is not in your PATH, falls back to the REST API to downl
     # Default: true
     clean: ''
 
+    # Do a sparse checkout on given patterns. Each pattern should be separated with
+    # new lines
+    # 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: ''
@@ -106,6 +115,9 @@ When Git 2.18 or higher is not in your PATH, falls back to the REST API to downl
 
 # Scenarios
 
+- [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)
@@ -116,6 +128,34 @@ When Git 2.18 or higher is not in your PATH, falls back to the REST API to downl
 - [Checkout pull request on closed event](#Checkout-pull-request-on-closed-event)
 - [Push a commit using the built-in token](#Push-a-commit-using-the-built-in-token)
 
+## Fetch only the root files
+
+```yaml
+- uses: actions/checkout@v3
+  with:
+    sparse-checkout: .
+```
+
+## Fetch only the root files and `.github` and `src` folder
+
+```yaml
+- uses: actions/checkout@v3
+  with:
+    sparse-checkout: |
+      .github
+      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 b58010b..fec6573 100644
--- a/__test__/git-auth-helper.test.ts
+++ b/__test__/git-auth-helper.test.ts
@@ -727,6 +727,8 @@ async function setup(testName: string): Promise<void> {
     branchDelete: jest.fn(),
     branchExists: jest.fn(),
     branchList: jest.fn(),
+    sparseCheckout: jest.fn(),
+    sparseCheckoutNonConeMode: jest.fn(),
     checkout: jest.fn(),
     checkoutDetach: jest.fn(),
     config: jest.fn(
@@ -800,6 +802,8 @@ async function setup(testName: string): Promise<void> {
     authToken: 'some auth token',
     clean: true,
     commit: '',
+    sparseCheckout: [],
+    sparseCheckoutConeMode: true,
     fetchDepth: 1,
     lfs: false,
     submodules: false,
diff --git a/__test__/git-command-manager.test.ts b/__test__/git-command-manager.test.ts
index 6944ff7..1c31ef9 100644
--- a/__test__/git-command-manager.test.ts
+++ b/__test__/git-command-manager.test.ts
@@ -39,7 +39,12 @@ describe('git-auth-helper tests', () => {
     jest.spyOn(exec, 'exec').mockImplementation(mockExec)
     const workingDirectory = 'test'
     const lfs = false
-    git = await commandManager.createCommandManager(workingDirectory, lfs)
+    const doSparseCheckout = false
+    git = await commandManager.createCommandManager(
+      workingDirectory,
+      lfs,
+      doSparseCheckout
+    )
 
     let branches = await git.branchList(false)
 
@@ -70,7 +75,12 @@ describe('git-auth-helper tests', () => {
     jest.spyOn(exec, 'exec').mockImplementation(mockExec)
     const workingDirectory = 'test'
     const lfs = false
-    git = await commandManager.createCommandManager(workingDirectory, lfs)
+    const doSparseCheckout = false
+    git = await commandManager.createCommandManager(
+      workingDirectory,
+      lfs,
+      doSparseCheckout
+    )
 
     let branches = await git.branchList(false)
 
diff --git a/__test__/git-directory-helper.test.ts b/__test__/git-directory-helper.test.ts
index 02118ae..362133f 100644
--- a/__test__/git-directory-helper.test.ts
+++ b/__test__/git-directory-helper.test.ts
@@ -462,6 +462,8 @@ async function setup(testName: string): Promise<void> {
     branchList: jest.fn(async () => {
       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 1a8e5c9..069fda4 100644
--- a/__test__/input-helper.test.ts
+++ b/__test__/input-helper.test.ts
@@ -79,6 +79,8 @@ describe('input-helper tests', () => {
     expect(settings.clean).toBe(true)
     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/__test__/verify-sparse-checkout.sh b/__test__/verify-sparse-checkout.sh
new file mode 100755
index 0000000..f668430
--- /dev/null
+++ b/__test__/verify-sparse-checkout.sh
@@ -0,0 +1,63 @@
+#!/bin/sh
+
+# Verify .git folder
+if [ ! -d "./sparse-checkout/.git" ]; then
+  echo "Expected ./sparse-checkout/.git folder to exist"
+  exit 1
+fi
+
+# Verify sparse-checkout
+cd sparse-checkout
+
+SPARSE=$(git sparse-checkout list)
+
+if [ "$?" != "0" ]; then
+    echo "Failed to validate sparse-checkout"
+    exit 1
+fi
+
+# Check that sparse-checkout list is not empty
+if [ -z "$SPARSE" ]; then
+  echo "Expected sparse-checkout list to not be empty"
+  exit 1
+fi
+
+# Check that all folders of the sparse checkout exist
+for pattern in $SPARSE
+do
+  if [ ! -d "$pattern" ]; then
+    echo "Expected directory '$pattern' to exist"
+    exit 1
+  fi
+done
+
+checkSparse () {
+  if [ ! -d "./$1" ]; then
+    echo "Expected directory '$1' to exist"
+    exit 1
+  fi
+
+  for file in $(git ls-tree -r --name-only HEAD $1)
+  do
+    if [ ! -f "$file" ]; then
+      echo "Expected file '$file' to exist"
+      exit 1
+    fi
+  done
+}
+
+# Check that all folders and their children have been checked out
+checkSparse __test__
+checkSparse .github
+checkSparse dist
+
+# Check that only sparse-checkout folders have been checked out
+for pattern in $(git ls-tree --name-only HEAD)
+do
+  if [ -d "$pattern" ]; then
+    if [[ "$pattern" != "__test__" && "$pattern" != ".github" && "$pattern" != "dist" ]]; then
+      echo "Expected directory '$pattern' to not exist"
+      exit 1
+    fi
+  fi
+done
\ No newline at end of file
diff --git a/action.yml b/action.yml
index cab09eb..e562b56 100644
--- a/action.yml
+++ b/action.yml
@@ -53,6 +53,15 @@ inputs:
   clean:
     description: 'Whether to execute `git clean -ffdx && git reset --hard HEAD` before fetching'
     default: true
+  sparse-checkout:
+    description: >
+      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 e6f5df8..4556295 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));
@@ -480,9 +481,9 @@ const git_version_1 = __nccwpck_require__(3142);
 // Auth header not supported before 2.9
 // Wire protocol v2 not supported before 2.18
 exports.MinimumGitVersion = new git_version_1.GitVersion('2.18');
-function createCommandManager(workingDirectory, lfs) {
+function createCommandManager(workingDirectory, lfs, doSparseCheckout) {
     return __awaiter(this, void 0, void 0, function* () {
-        return yield GitCommandManager.createCommandManager(workingDirectory, lfs);
+        return yield GitCommandManager.createCommandManager(workingDirectory, lfs, doSparseCheckout);
     });
 }
 exports.createCommandManager = createCommandManager;
@@ -495,6 +496,7 @@ class GitCommandManager {
         };
         this.gitPath = '';
         this.lfs = false;
+        this.doSparseCheckout = false;
         this.workingDirectory = '';
     }
     branchDelete(remote, branch) {
@@ -574,6 +576,23 @@ class GitCommandManager {
             return result;
         });
     }
+    sparseCheckout(sparseCheckout) {
+        return __awaiter(this, void 0, void 0, function* () {
+            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'];
@@ -615,15 +634,18 @@ class GitCommandManager {
             return output.exitCode === 0;
         });
     }
-    fetch(refSpec, fetchDepth) {
+    fetch(refSpec, options) {
         return __awaiter(this, void 0, void 0, function* () {
             const args = ['-c', 'protocol.version=2', 'fetch'];
             if (!refSpec.some(x => x === refHelper.tagsRefSpec)) {
                 args.push('--no-tags');
             }
             args.push('--prune', '--progress', '--no-recurse-submodules');
-            if (fetchDepth && fetchDepth > 0) {
-                args.push(`--depth=${fetchDepth}`);
+            if (options.filter) {
+                args.push(`--filter=${options.filter}`);
+            }
+            if (options.fetchDepth && options.fetchDepth > 0) {
+                args.push(`--depth=${options.fetchDepth}`);
             }
             else if (fshelper.fileExistsSync(path.join(this.workingDirectory, '.git', 'shallow'))) {
                 args.push('--unshallow');
@@ -820,10 +842,10 @@ class GitCommandManager {
             return output.exitCode === 0;
         });
     }
-    static createCommandManager(workingDirectory, lfs) {
+    static createCommandManager(workingDirectory, lfs, doSparseCheckout) {
         return __awaiter(this, void 0, void 0, function* () {
             const result = new GitCommandManager();
-            yield result.initializeCommandManager(workingDirectory, lfs);
+            yield result.initializeCommandManager(workingDirectory, lfs, doSparseCheckout);
             return result;
         });
     }
@@ -859,7 +881,7 @@ class GitCommandManager {
             return result;
         });
     }
-    initializeCommandManager(workingDirectory, lfs) {
+    initializeCommandManager(workingDirectory, lfs, doSparseCheckout) {
         return __awaiter(this, void 0, void 0, function* () {
             this.workingDirectory = workingDirectory;
             // Git-lfs will try to pull down assets if any of the local/user/system setting exist.
@@ -911,6 +933,14 @@ class GitCommandManager {
                     throw new Error(`Minimum required git-lfs version is ${minimumGitLfsVersion}. Your git-lfs ('${gitLfsPath}') is ${gitLfsVersion}`);
                 }
             }
+            this.doSparseCheckout = doSparseCheckout;
+            if (this.doSparseCheckout) {
+                // The `git sparse-checkout` command was introduced in Git v2.25.0
+                const minimumGitSparseCheckoutVersion = new git_version_1.GitVersion('2.25');
+                if (!gitVersion.checkMinimum(minimumGitSparseCheckoutVersion)) {
+                    throw new Error(`Minimum Git version required for sparse checkout is ${minimumGitSparseCheckoutVersion}. Your git ('${this.gitPath}') is ${gitVersion}`);
+                }
+            }
             // Set the user agent
             const gitHttpUserAgent = `git/${gitVersion} (github-actions-checkout)`;
             core.debug(`Set git useragent to: ${gitHttpUserAgent}`);
@@ -1210,20 +1240,24 @@ function getSource(settings) {
             }
             // Fetch
             core.startGroup('Fetching the repository');
+            const fetchOptions = {};
+            if (settings.sparseCheckout)
+                fetchOptions.filter = 'blob:none';
             if (settings.fetchDepth <= 0) {
                 // Fetch all branches and tags
                 let refSpec = refHelper.getRefSpecForAllHistory(settings.ref, settings.commit);
-                yield git.fetch(refSpec);
+                yield git.fetch(refSpec, fetchOptions);
                 // When all history is fetched, the ref we're interested in may have moved to a different
                 // commit (push or force push). If so, fetch again with a targeted refspec.
                 if (!(yield refHelper.testRef(git, settings.ref, settings.commit))) {
                     refSpec = refHelper.getRefSpec(settings.ref, settings.commit);
-                    yield git.fetch(refSpec);
+                    yield git.fetch(refSpec, fetchOptions);
                 }
             }
             else {
+                fetchOptions.fetchDepth = settings.fetchDepth;
                 const refSpec = refHelper.getRefSpec(settings.ref, settings.commit);
-                yield git.fetch(refSpec, settings.fetchDepth);
+                yield git.fetch(refSpec, fetchOptions);
             }
             core.endGroup();
             // Checkout info
@@ -1233,11 +1267,23 @@ function getSource(settings) {
             // LFS fetch
             // Explicit lfs-fetch to avoid slow checkout (fetches one lfs object at a time).
             // Explicit lfs fetch will fetch lfs objects in parallel.
-            if (settings.lfs) {
+            // For sparse checkouts, let `checkout` fetch the needed objects lazily.
+            if (settings.lfs && !settings.sparseCheckout) {
                 core.startGroup('Fetching LFS objects');
                 yield git.lfsFetch(checkoutInfo.startPoint || checkoutInfo.ref);
                 core.endGroup();
             }
+            // Sparse checkout
+            if (settings.sparseCheckout) {
+                core.startGroup('Setting up sparse checkout');
+                if (settings.sparseCheckoutConeMode) {
+                    yield git.sparseCheckout(settings.sparseCheckout);
+                }
+                else {
+                    yield git.sparseCheckoutNonConeMode(settings.sparseCheckout);
+                }
+                core.endGroup();
+            }
             // Checkout
             core.startGroup('Checking out the ref');
             yield git.checkout(checkoutInfo.ref, checkoutInfo.startPoint);
@@ -1291,7 +1337,7 @@ function cleanup(repositoryPath) {
         }
         let git;
         try {
-            git = yield gitCommandManager.createCommandManager(repositoryPath, false);
+            git = yield gitCommandManager.createCommandManager(repositoryPath, false, false);
         }
         catch (_a) {
             return;
@@ -1322,7 +1368,7 @@ function getGitCommandManager(settings) {
     return __awaiter(this, void 0, void 0, function* () {
         core.info(`Working directory is '${settings.repositoryPath}'`);
         try {
-            return yield gitCommandManager.createCommandManager(settings.repositoryPath, settings.lfs);
+            return yield gitCommandManager.createCommandManager(settings.repositoryPath, settings.lfs, settings.sparseCheckout != null);
         }
         catch (err) {
             // Git is required for LFS
@@ -1673,6 +1719,15 @@ function getInputs() {
         // Clean
         result.clean = (core.getInput('clean') || 'true').toUpperCase() === 'TRUE';
         core.debug(`clean = ${result.clean}`);
+        // Sparse checkout
+        const sparseCheckout = core.getMultilineInput('sparse-checkout');
+        if (sparseCheckout.length) {
+            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 ab07524..e684dba 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'
@@ -16,6 +17,8 @@ export interface IGitCommandManager {
   branchDelete(remote: boolean, branch: string): Promise<void>
   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(
@@ -25,7 +28,13 @@ export interface IGitCommandManager {
     add?: boolean
   ): Promise<void>
   configExists(configKey: string, globalConfig?: boolean): Promise<boolean>
-  fetch(refSpec: string[], fetchDepth?: number): Promise<void>
+  fetch(
+    refSpec: string[],
+    options: {
+      filter?: string
+      fetchDepth?: number
+    }
+  ): Promise<void>
   getDefaultBranch(repositoryUrl: string): Promise<string>
   getWorkingDirectory(): string
   init(): Promise<void>
@@ -52,9 +61,14 @@ export interface IGitCommandManager {
 
 export async function createCommandManager(
   workingDirectory: string,
-  lfs: boolean
+  lfs: boolean,
+  doSparseCheckout: boolean
 ): Promise<IGitCommandManager> {
-  return await GitCommandManager.createCommandManager(workingDirectory, lfs)
+  return await GitCommandManager.createCommandManager(
+    workingDirectory,
+    lfs,
+    doSparseCheckout
+  )
 }
 
 class GitCommandManager {
@@ -64,6 +78,7 @@ class GitCommandManager {
   }
   private gitPath = ''
   private lfs = false
+  private doSparseCheckout = false
   private workingDirectory = ''
 
   // Private constructor; use createCommandManager()
@@ -154,6 +169,27 @@ class GitCommandManager {
     return result
   }
 
+  async sparseCheckout(sparseCheckout: string[]): Promise<void> {
+    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) {
@@ -202,15 +238,23 @@ class GitCommandManager {
     return output.exitCode === 0
   }
 
-  async fetch(refSpec: string[], fetchDepth?: number): Promise<void> {
+  async fetch(
+    refSpec: string[],
+    options: {filter?: string; fetchDepth?: number}
+  ): Promise<void> {
     const args = ['-c', 'protocol.version=2', 'fetch']
     if (!refSpec.some(x => x === refHelper.tagsRefSpec)) {
       args.push('--no-tags')
     }
 
     args.push('--prune', '--progress', '--no-recurse-submodules')
-    if (fetchDepth && fetchDepth > 0) {
-      args.push(`--depth=${fetchDepth}`)
+
+    if (options.filter) {
+      args.push(`--filter=${options.filter}`)
+    }
+
+    if (options.fetchDepth && options.fetchDepth > 0) {
+      args.push(`--depth=${options.fetchDepth}`)
     } else if (
       fshelper.fileExistsSync(
         path.join(this.workingDirectory, '.git', 'shallow')
@@ -423,10 +467,15 @@ class GitCommandManager {
 
   static async createCommandManager(
     workingDirectory: string,
-    lfs: boolean
+    lfs: boolean,
+    doSparseCheckout: boolean
   ): Promise<GitCommandManager> {
     const result = new GitCommandManager()
-    await result.initializeCommandManager(workingDirectory, lfs)
+    await result.initializeCommandManager(
+      workingDirectory,
+      lfs,
+      doSparseCheckout
+    )
     return result
   }
 
@@ -476,7 +525,8 @@ class GitCommandManager {
 
   private async initializeCommandManager(
     workingDirectory: string,
-    lfs: boolean
+    lfs: boolean,
+    doSparseCheckout: boolean
   ): Promise<void> {
     this.workingDirectory = workingDirectory
 
@@ -539,6 +589,16 @@ class GitCommandManager {
       }
     }
 
+    this.doSparseCheckout = doSparseCheckout
+    if (this.doSparseCheckout) {
+      // The `git sparse-checkout` command was introduced in Git v2.25.0
+      const minimumGitSparseCheckoutVersion = new GitVersion('2.25')
+      if (!gitVersion.checkMinimum(minimumGitSparseCheckoutVersion)) {
+        throw new Error(
+          `Minimum Git version required for sparse checkout is ${minimumGitSparseCheckoutVersion}. Your git ('${this.gitPath}') is ${gitVersion}`
+        )
+      }
+    }
     // Set the user agent
     const gitHttpUserAgent = `git/${gitVersion} (github-actions-checkout)`
     core.debug(`Set git useragent to: ${gitHttpUserAgent}`)
diff --git a/src/git-source-provider.ts b/src/git-source-provider.ts
index 48f20da..8f9d63f 100644
--- a/src/git-source-provider.ts
+++ b/src/git-source-provider.ts
@@ -153,23 +153,26 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
 
     // Fetch
     core.startGroup('Fetching the repository')
+    const fetchOptions: {filter?: string; fetchDepth?: number} = {}
+    if (settings.sparseCheckout) fetchOptions.filter = 'blob:none'
     if (settings.fetchDepth <= 0) {
       // Fetch all branches and tags
       let refSpec = refHelper.getRefSpecForAllHistory(
         settings.ref,
         settings.commit
       )
-      await git.fetch(refSpec)
+      await git.fetch(refSpec, fetchOptions)
 
       // When all history is fetched, the ref we're interested in may have moved to a different
       // commit (push or force push). If so, fetch again with a targeted refspec.
       if (!(await refHelper.testRef(git, settings.ref, settings.commit))) {
         refSpec = refHelper.getRefSpec(settings.ref, settings.commit)
-        await git.fetch(refSpec)
+        await git.fetch(refSpec, fetchOptions)
       }
     } else {
+      fetchOptions.fetchDepth = settings.fetchDepth
       const refSpec = refHelper.getRefSpec(settings.ref, settings.commit)
-      await git.fetch(refSpec, settings.fetchDepth)
+      await git.fetch(refSpec, fetchOptions)
     }
     core.endGroup()
 
@@ -185,12 +188,24 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
     // LFS fetch
     // Explicit lfs-fetch to avoid slow checkout (fetches one lfs object at a time).
     // Explicit lfs fetch will fetch lfs objects in parallel.
-    if (settings.lfs) {
+    // For sparse checkouts, let `checkout` fetch the needed objects lazily.
+    if (settings.lfs && !settings.sparseCheckout) {
       core.startGroup('Fetching LFS objects')
       await git.lfsFetch(checkoutInfo.startPoint || checkoutInfo.ref)
       core.endGroup()
     }
 
+    // Sparse checkout
+    if (settings.sparseCheckout) {
+      core.startGroup('Setting up sparse checkout')
+      if (settings.sparseCheckoutConeMode) {
+        await git.sparseCheckout(settings.sparseCheckout)
+      } else {
+        await git.sparseCheckoutNonConeMode(settings.sparseCheckout)
+      }
+      core.endGroup()
+    }
+
     // Checkout
     core.startGroup('Checking out the ref')
     await git.checkout(checkoutInfo.ref, checkoutInfo.startPoint)
@@ -261,7 +276,11 @@ export async function cleanup(repositoryPath: string): Promise<void> {
 
   let git: IGitCommandManager
   try {
-    git = await gitCommandManager.createCommandManager(repositoryPath, false)
+    git = await gitCommandManager.createCommandManager(
+      repositoryPath,
+      false,
+      false
+    )
   } catch {
     return
   }
@@ -297,7 +316,8 @@ async function getGitCommandManager(
   try {
     return await gitCommandManager.createCommandManager(
       settings.repositoryPath,
-      settings.lfs
+      settings.lfs,
+      settings.sparseCheckout != null
     )
   } catch (err) {
     // Git is required for LFS
diff --git a/src/git-source-settings.ts b/src/git-source-settings.ts
index 2da5622..3272e63 100644
--- a/src/git-source-settings.ts
+++ b/src/git-source-settings.ts
@@ -29,6 +29,16 @@ export interface IGitSourceSettings {
    */
   clean: boolean
 
+  /**
+   * The array of folders to make the sparse checkout
+   */
+  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 237b06a..410e480 100644
--- a/src/input-helper.ts
+++ b/src/input-helper.ts
@@ -82,6 +82,17 @@ export async function getInputs(): Promise<IGitSourceSettings> {
   result.clean = (core.getInput('clean') || 'true').toUpperCase() === 'TRUE'
   core.debug(`clean = ${result.clean}`)
 
+  // Sparse checkout
+  const sparseCheckout = core.getMultilineInput('sparse-checkout')
+  if (sparseCheckout.length) {
+    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) {