diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index 313840ce3a0..6f4158519b8 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -33,7 +33,7 @@ const { isStagedMode } = require("./safe_output_helpers.cjs"); const { normalizeCommitSHA } = require("./commit_sha_helpers.cjs"); const { withRetry, RATE_LIMIT_RETRY_CONFIG } = require("./error_recovery.cjs"); const { findAgent, getIssueDetails, assignAgentToIssue } = require("./assign_agent_helpers.cjs"); -const { ensureFullHistoryForBundle, extractBundlePrerequisiteCommits, linearizeRangeAsCommit } = require("./git_helpers.cjs"); +const { ensureFullHistoryForBundle, extractBundlePrerequisiteCommits, isShallowOrSparseCheckout, linearizeRangeAsCommit } = require("./git_helpers.cjs"); const { parseDiffGitHeader: parseDiffGitHeaderPaths, extractDiffGitHeaderEntries } = require("./patch_path_helpers.cjs"); const { resolveAllowedMentionsFromPayload } = require("./resolve_mentions_from_payload.cjs"); const { @@ -211,7 +211,15 @@ async function applyBundleToBranch(bundleFilePath, branchName, originalAgentBran core.warning(`Bundle fetch with ${bundleBranchRef} failed due to ${prerequisiteCommits.length} missing prerequisite commit(s); fetching prerequisites from origin and retrying`); core.info(`Prerequisite commits: ${summarizeListForLog(prerequisiteCommits)}`); core.info(`Fetching ${prerequisiteCommits.length} prerequisite commit(s) from origin`); - await execApi.exec("git", ["fetch", "origin", ...prerequisiteCommits]); + // Use --filter=blob:none only when the local repo is already shallow or sparse — + // in a full clone we already have all blobs and must not convert the repo to a + // partial clone (which would trigger lazy blob fetches on later operations). + const useBlobFilter = await isShallowOrSparseCheckout(execApi); + const prerequisiteFetchArgs = useBlobFilter ? ["fetch", "--filter=blob:none", "origin", ...prerequisiteCommits] : ["fetch", "origin", ...prerequisiteCommits]; + if (useBlobFilter) { + core.info("Using --filter=blob:none for prerequisite fetch (shallow or sparse checkout detected)"); + } + await execApi.exec("git", prerequisiteFetchArgs); core.info("Fetched prerequisite commits from origin successfully"); try { core.info(`Retrying bundle fetch from ${bundleBranchRef} into ${bundleTempRef} after prerequisite recovery`); diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index 15df6e3e16a..895801f60bc 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -471,8 +471,8 @@ index 0000000..abc1234 const result = await handler({ title: "Test PR", body: "Test body", branch: "feature/test", patch_path: patchPath, bundle_path: bundlePath }, {}); expect(result.success).toBe(true); - // Prerequisites are fetched from origin via exec - expect(global.exec.exec).toHaveBeenCalledWith("git", ["fetch", "origin", missingSha]); + // Prerequisites are fetched from origin via exec with --filter=blob:none to avoid downloading blobs + expect(global.exec.exec).toHaveBeenCalledWith("git", ["fetch", "--filter=blob:none", "origin", missingSha]); // Retry bundle fetch is via exec (only the retry, not the initial attempt which was getExecOutput) const bundleRetryFetchCalls = global.exec.exec.mock.calls.filter(([, args]) => Array.isArray(args) && args[0] === "fetch" && args[1] === bundlePath); expect(bundleRetryFetchCalls.length).toBe(1); @@ -525,7 +525,7 @@ index 0000000..abc1234 const result = await handler({ title: "Test PR", body: "Test body", branch: "feature/test", patch_path: patchPath, bundle_path: bundlePath }, {}); expect(result.success).toBe(true); - expect(global.exec.exec).toHaveBeenCalledWith("git", ["fetch", "origin", missingSha1, missingSha2]); + expect(global.exec.exec).toHaveBeenCalledWith("git", ["fetch", "--filter=blob:none", "origin", missingSha1, missingSha2]); const bundleRetryFetchCalls = global.exec.exec.mock.calls.filter(([, args]) => Array.isArray(args) && args[0] === "fetch" && args[1] === bundlePath); expect(bundleRetryFetchCalls.length).toBe(1); expect(global.exec.getExecOutput).not.toHaveBeenCalledWith("git", ["bundle", "list-heads", bundlePath]); @@ -571,7 +571,7 @@ index 0000000..abc1234 return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); }); global.exec.exec.mockImplementation((cmd, args) => { - if (cmd === "git" && Array.isArray(args) && args[0] === "fetch" && args[1] === "origin" && args[2] === missingSha) { + if (cmd === "git" && Array.isArray(args) && args[0] === "fetch" && args.includes("origin") && args.includes(missingSha)) { throw new Error("fatal: couldn't connect to 'origin'"); } return Promise.resolve(0); diff --git a/actions/setup/js/git_helpers.cjs b/actions/setup/js/git_helpers.cjs index b6a6c33bbe4..d803db2cdc5 100644 --- a/actions/setup/js/git_helpers.cjs +++ b/actions/setup/js/git_helpers.cjs @@ -180,6 +180,44 @@ async function ensureFullHistoryForBundle(execApi, options = {}) { } } +/** + * Return true when the local repository is shallow OR has sparse-checkout enabled. + * + * This is the gate for using `--filter=blob:none` on follow-up fetches (e.g. bundle + * prerequisite recovery). In a full, non-sparse clone the repo already contains all + * blobs for committed history; adding `--filter=blob:none` to a fetch would convert + * it to a partial clone and cause subsequent operations to lazily re-fetch blobs. + * In shallow or sparse checkouts we already accept partial object availability, so + * filtering blobs is consistent and saves bandwidth. + * + * Both probes are best-effort — on any error we return `false` (do not filter), + * which is the safe default that preserves the legacy unfiltered fetch behavior. + * + * @param {{ getExecOutput: Function }} execApi - Exec API to run git commands. + * @param {Object} [options] - Options passed through to exec calls. + * @returns {Promise} + */ +async function isShallowOrSparseCheckout(execApi, options = {}) { + const probeOptions = { ...options, ignoreReturnCode: true }; + try { + const { stdout, exitCode } = await execApi.getExecOutput("git", ["rev-parse", "--is-shallow-repository"], probeOptions); + if (exitCode === 0 && stdout.trim() === "true") { + return true; + } + } catch { + // Fall through to sparse check; if both probes fail, return false (no filter). + } + try { + const { stdout, exitCode } = await execApi.getExecOutput("git", ["config", "--get", "core.sparseCheckout"], probeOptions); + if (exitCode === 0 && stdout.trim().toLowerCase() === "true") { + return true; + } + } catch { + // Fall through. + } + return false; +} + /** * Extract prerequisite commit SHAs from git bundle fetch error output. * @@ -265,5 +303,6 @@ module.exports = { extractBundlePrerequisiteCommits, getGitAuthEnv, hasMergeCommitsInRange, + isShallowOrSparseCheckout, linearizeRangeAsCommit, }; diff --git a/actions/setup/js/git_helpers.test.cjs b/actions/setup/js/git_helpers.test.cjs index 9ada2116c6b..9ac6ca4a618 100644 --- a/actions/setup/js/git_helpers.test.cjs +++ b/actions/setup/js/git_helpers.test.cjs @@ -340,6 +340,81 @@ describe("git_helpers.cjs", () => { }); }); + describe("isShallowOrSparseCheckout", () => { + const buildExecApi = handler => ({ + getExecOutput: vi.fn().mockImplementation((cmd, args) => Promise.resolve(handler(cmd, args))), + }); + + it("should return true when repository is shallow", async () => { + const { isShallowOrSparseCheckout } = await import("./git_helpers.cjs"); + const execApi = buildExecApi((cmd, args) => { + if (args[0] === "rev-parse" && args[1] === "--is-shallow-repository") { + return { exitCode: 0, stdout: "true\n", stderr: "" }; + } + return { exitCode: 1, stdout: "", stderr: "" }; + }); + + await expect(isShallowOrSparseCheckout(execApi)).resolves.toBe(true); + // Sparse probe must not run when shallow probe already returned true. + expect(execApi.getExecOutput).toHaveBeenCalledTimes(1); + }); + + it("should return true when sparse-checkout is enabled", async () => { + const { isShallowOrSparseCheckout } = await import("./git_helpers.cjs"); + const execApi = buildExecApi((cmd, args) => { + if (args[0] === "rev-parse" && args[1] === "--is-shallow-repository") { + return { exitCode: 0, stdout: "false\n", stderr: "" }; + } + if (args[0] === "config" && args[1] === "--get" && args[2] === "core.sparseCheckout") { + return { exitCode: 0, stdout: "true\n", stderr: "" }; + } + return { exitCode: 1, stdout: "", stderr: "" }; + }); + + await expect(isShallowOrSparseCheckout(execApi)).resolves.toBe(true); + }); + + it("should return false for a full, non-sparse clone", async () => { + const { isShallowOrSparseCheckout } = await import("./git_helpers.cjs"); + const execApi = buildExecApi((cmd, args) => { + if (args[0] === "rev-parse" && args[1] === "--is-shallow-repository") { + return { exitCode: 0, stdout: "false\n", stderr: "" }; + } + if (args[0] === "config" && args[1] === "--get" && args[2] === "core.sparseCheckout") { + // git config exits 1 when the key is not set. + return { exitCode: 1, stdout: "", stderr: "" }; + } + return { exitCode: 0, stdout: "", stderr: "" }; + }); + + await expect(isShallowOrSparseCheckout(execApi)).resolves.toBe(false); + }); + + it("should return false when both probes throw", async () => { + const { isShallowOrSparseCheckout } = await import("./git_helpers.cjs"); + const execApi = { + getExecOutput: vi.fn().mockRejectedValue(new Error("git missing")), + }; + + await expect(isShallowOrSparseCheckout(execApi)).resolves.toBe(false); + }); + + it("should treat sparse-checkout value case-insensitively", async () => { + const { isShallowOrSparseCheckout } = await import("./git_helpers.cjs"); + const execApi = buildExecApi((cmd, args) => { + if (args[0] === "rev-parse") { + return { exitCode: 0, stdout: "false\n", stderr: "" }; + } + if (args[0] === "config") { + return { exitCode: 0, stdout: "True\n", stderr: "" }; + } + return { exitCode: 1, stdout: "", stderr: "" }; + }); + + await expect(isShallowOrSparseCheckout(execApi)).resolves.toBe(true); + }); + }); + describe("extractBundlePrerequisiteCommits", () => { it("should return empty array for empty string", async () => { const { extractBundlePrerequisiteCommits } = await import("./git_helpers.cjs"); diff --git a/actions/setup/js/push_to_pull_request_branch.cjs b/actions/setup/js/push_to_pull_request_branch.cjs index dbe627a82e3..20d6fb2279e 100644 --- a/actions/setup/js/push_to_pull_request_branch.cjs +++ b/actions/setup/js/push_to_pull_request_branch.cjs @@ -17,7 +17,7 @@ const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); const { checkFileProtection } = require("./manifest_file_helpers.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); const { renderTemplateFromFile, buildProtectedFileList, getPromptPath } = require("./messages_core.cjs"); -const { ensureFullHistoryForBundle, getGitAuthEnv, extractBundlePrerequisiteCommits, linearizeRangeAsCommit } = require("./git_helpers.cjs"); +const { ensureFullHistoryForBundle, getGitAuthEnv, extractBundlePrerequisiteCommits, isShallowOrSparseCheckout, linearizeRangeAsCommit } = require("./git_helpers.cjs"); const { normalizeCommitSHA } = require("./commit_sha_helpers.cjs"); const { findRepoCheckout } = require("./find_repo_checkout.cjs"); const { getThreatDetectedMarker } = require("./threat_detection_warning.cjs"); @@ -742,7 +742,16 @@ async function main(config = {}) { if (prerequisiteCommits.length > 0) { core.warning(`Bundle fetch failed due to ${prerequisiteCommits.length} missing prerequisite commit(s); fetching prerequisites from origin and retrying`); core.info(`Fetching ${prerequisiteCommits.length} prerequisite commit(s) from origin`); - await exec.exec("git", ["fetch", "origin", ...prerequisiteCommits], { env: { ...process.env, ...gitAuthEnv }, ...baseGitOpts }); + // Use --filter=blob:none only when the local repo is already shallow or sparse — + // in a full clone we already have all blobs and must not convert the repo to a + // partial clone (which would trigger lazy blob fetches on later operations). + const prereqGitOpts = { env: { ...process.env, ...gitAuthEnv }, ...baseGitOpts }; + const useBlobFilter = await isShallowOrSparseCheckout(exec, prereqGitOpts); + const prerequisiteFetchArgs = useBlobFilter ? ["fetch", "--filter=blob:none", "origin", ...prerequisiteCommits] : ["fetch", "origin", ...prerequisiteCommits]; + if (useBlobFilter) { + core.info("Using --filter=blob:none for prerequisite fetch (shallow or sparse checkout detected)"); + } + await exec.exec("git", prerequisiteFetchArgs, prereqGitOpts); core.info("Fetched prerequisite commits from origin successfully"); await exec.exec("git", ["fetch", bundleFilePath, bundleFetchRef], baseGitOpts); core.info("Bundle fetch retry succeeded after prerequisite recovery"); diff --git a/actions/setup/js/push_to_pull_request_branch.test.cjs b/actions/setup/js/push_to_pull_request_branch.test.cjs index 333bcf28c69..efdbd6ca6b4 100644 --- a/actions/setup/js/push_to_pull_request_branch.test.cjs +++ b/actions/setup/js/push_to_pull_request_branch.test.cjs @@ -1503,7 +1503,8 @@ index 0000000..abc1234 const result = await handler({ branch: "feature-branch", patch_path: patchPath, bundle_path: bundlePath, diff_size: 5 * 1024 }, {}); expect(result.success).toBe(true); - // Should have fetched the missing prerequisite from origin + // Repo is not shallow and not sparse in this scenario, so no --filter=blob:none is used: + // the local clone already has all blobs and we must not convert it to a partial clone. expect(mockExec.exec).toHaveBeenCalledWith("git", ["fetch", "origin", missingSha], expect.any(Object)); // Should have retried the bundle fetch via exec after fetching prerequisites const bundleRetryFetch = mockExec.exec.mock.calls.find(([, args]) => Array.isArray(args) && args[0] === "fetch" && args[1] === bundlePath);