Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions actions/setup/js/create_pull_request.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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`);
Expand Down
8 changes: 4 additions & 4 deletions actions/setup/js/create_pull_request.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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);
Expand Down
39 changes: 39 additions & 0 deletions actions/setup/js/git_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>}
*/
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.
*
Expand Down Expand Up @@ -265,5 +303,6 @@ module.exports = {
extractBundlePrerequisiteCommits,
getGitAuthEnv,
hasMergeCommitsInRange,
isShallowOrSparseCheckout,
linearizeRangeAsCommit,
};
75 changes: 75 additions & 0 deletions actions/setup/js/git_helpers.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
13 changes: 11 additions & 2 deletions actions/setup/js/push_to_pull_request_branch.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down
3 changes: 2 additions & 1 deletion actions/setup/js/push_to_pull_request_branch.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading