diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index c9ee905a6ff..e3b0d56ae7c 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -219,15 +219,14 @@ describe("create_pull_request - bundle transport shallow checkout", () => { return Promise.resolve({ exitCode: 0, stdout: "true\n", stderr: "" }); } if (cmd === "git" && args[0] === "bundle" && args[1] === "verify") { - // Declare a fake prerequisite so ensureFullHistoryForBundle proceeds to deepen. + // Declare a fake prerequisite so ensureFullHistoryForBundle proceeds. return Promise.resolve({ exitCode: 1, stdout: "", stderr: `The bundle requires this ref:\n${"a".repeat(40)}\n` }); } - if (cmd === "git" && args[0] === "merge-base" && args[1] === "--is-ancestor") { - // Report prereq missing initially → iterative deepen kicks in; after the - // first deepen fetch we still report missing so the fallback --unshallow - // path is exercised. The default mock for exec() resolves successfully, - // so all 7 deepen steps complete instantly before the fallback fires. - return Promise.resolve({ exitCode: 1, stdout: "", stderr: "" }); + if (cmd === "git" && args[0] === "cat-file" && args[1] === "-e") { + // Report the prerequisite object as already present by default so + // ensureFullHistoryForBundle returns early (no fetch). Tests that need + // to exercise the fetch/deepen path override this within the test. + return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); } if (cmd === "git" && args[0] === "rev-list") { return Promise.resolve({ exitCode: 0, stdout: "1\n", stderr: "" }); @@ -267,7 +266,7 @@ describe("create_pull_request - bundle transport shallow checkout", () => { vi.clearAllMocks(); }); - it("should deepen origin/ before fetching bundle in shallow repositories", async () => { + it("should fetch bundle prerequisite commits directly from origin in shallow repositories", async () => { const patchPath = canonicalPatchPath("feature/test"); fs.writeFileSync( patchPath, @@ -290,6 +289,35 @@ index 0000000..abc1234 const bundlePath = canonicalBundlePath("feature/test"); fs.writeFileSync(bundlePath, "bundle content"); + // Force the prerequisite-missing path so the direct SHA fetch runs. + const prereq = "a".repeat(40); + let prereqFetched = false; + global.exec.getExecOutput = vi.fn().mockImplementation((cmd, args) => { + if (cmd === "git" && args[0] === "rev-parse" && args[1] === "--is-shallow-repository") { + return Promise.resolve({ exitCode: 0, stdout: "true\n", stderr: "" }); + } + if (cmd === "git" && args[0] === "bundle" && args[1] === "verify") { + return Promise.resolve({ exitCode: 1, stdout: "", stderr: `The bundle requires this ref:\n${prereq}\n` }); + } + if (cmd === "git" && args[0] === "config") { + return Promise.resolve({ exitCode: 1, stdout: "", stderr: "" }); + } + if (cmd === "git" && args[0] === "cat-file" && args[1] === "-e") { + // Missing until the direct SHA fetch brings it in. + return Promise.resolve({ exitCode: prereqFetched ? 0 : 1, stdout: "", stderr: "" }); + } + if (cmd === "git" && args[0] === "rev-list") { + return Promise.resolve({ exitCode: 0, stdout: "1\n", stderr: "" }); + } + return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + }); + global.exec.exec = vi.fn().mockImplementation((cmd, args) => { + if (cmd === "git" && Array.isArray(args) && args[0] === "fetch" && args.includes("origin") && args.includes(prereq)) { + prereqFetched = true; + } + return Promise.resolve(0); + }); + const { main } = require("./create_pull_request.cjs"); const handler = await main({ base_branch: "main", preserve_branch_name: true }); const result = await handler({ title: "Test PR", body: "Test body", branch: "feature/test" }, {}); @@ -305,11 +333,14 @@ index 0000000..abc1234 const bundleTempRef = bundleFetchCall[1][2].split(":")[1]; expect(global.exec.exec).toHaveBeenCalledWith("git", ["update-ref", "refs/heads/feature/test", bundleTempRef]); expect(global.exec.exec).toHaveBeenCalledWith("git", ["reset", "--hard"]); - const bundleFetchCallIndex = global.exec.getExecOutput.mock.calls.findIndex(([, args]) => Array.isArray(args) && args[0] === "fetch" && args[1] === bundlePath); - // Iterative deepen replaces a single --unshallow: assert the first --deepen step ran. - const deepenCallIndex = global.exec.exec.mock.calls.findIndex(([, args]) => Array.isArray(args) && args[0] === "fetch" && typeof args[1] === "string" && args[1].startsWith("--deepen=")); - expect(deepenCallIndex).toBeGreaterThanOrEqual(0); - expect(bundleFetchCallIndex).toBeGreaterThanOrEqual(0); + // Primary path: the exact prerequisite SHA is fetched directly from origin, + // with no broad iterative deepen and no --unshallow. + const directFetch = global.exec.exec.mock.calls.find(([, args]) => Array.isArray(args) && args[0] === "fetch" && args.includes("origin") && args.includes(prereq)); + expect(directFetch).toBeTruthy(); + const deepenCall = global.exec.exec.mock.calls.find(([, args]) => Array.isArray(args) && args[0] === "fetch" && typeof args[1] === "string" && args[1].startsWith("--deepen=")); + expect(deepenCall).toBeUndefined(); + const unshallowCall = global.exec.exec.mock.calls.find(([, args]) => Array.isArray(args) && args[0] === "fetch" && args.includes("--unshallow")); + expect(unshallowCall).toBeUndefined(); }); it("should pass signed_commits false to bundle pushes", async () => { diff --git a/actions/setup/js/git_helpers.cjs b/actions/setup/js/git_helpers.cjs index f81f2a9221f..46c4ff7adcf 100644 --- a/actions/setup/js/git_helpers.cjs +++ b/actions/setup/js/git_helpers.cjs @@ -233,11 +233,22 @@ function hasMergeCommitsInRange(baseRef, headRef, options = {}) { } /** - * Deepen sequence (per call to `git fetch --deepen=N`). Each value adds N - * commits to the existing shallow history. Total reachable depth after the - * final step is the sum of these values (~7850 commits). + * Fallback deepen step size (commits added per `git fetch --deepen=N` call). + * + * The primary path fetches the exact prerequisite commit SHAs directly from + * origin (see `ensureFullHistoryForBundle`), so this iterative deepen only runs + * when fetch-by-SHA is unavailable or insufficient. We deepen in small + * increments so a single fetch never tries to pull a huge slice of history, + * which can time out on large monorepos with long, complex branch histories. + */ +const BUNDLE_DEEPEN_STEP = 5; + +/** + * Maximum number of fallback deepen iterations before giving up and attempting + * `--unshallow`. With a step of 5 this caps the fallback at ~1000 commits of + * deepening (200 * 5) before the last-resort unshallow. */ -const BUNDLE_DEEPEN_STEPS = [50, 100, 200, 500, 1000, 2000, 4000]; +const BUNDLE_DEEPEN_MAX_ITERATIONS = 200; /** * Extract prerequisite commit SHAs declared in a git bundle file. @@ -291,18 +302,25 @@ async function getBundlePrerequisites(execApi, bundleFilePath, options = {}) { } /** - * Check which of the given SHAs are NOT yet ancestors of `targetRef`. + * Check which of the given commit SHAs are NOT present in the local object + * store. Uses `git cat-file -e ^{commit}`, which exits non-zero when the + * object is missing. + * + * This is the correct gate for bundle application: `git fetch ` only + * needs the prerequisite *objects* to exist locally — it does not require them + * to be reachable from any particular branch. (A prerequisite commit can live + * on the pull request branch and never be an ancestor of the base branch, so an + * ancestry-based check would loop forever trying to deepen the base.) * * @param {{ getExecOutput: Function }} execApi * @param {string[]} shas - * @param {string} targetRef * @param {Object} [options] - * @returns {Promise} SHAs still missing (not ancestors / not present). + * @returns {Promise} SHAs whose commit object is not present locally. */ -async function findMissingAncestors(execApi, shas, targetRef, options = {}) { +async function findMissingObjects(execApi, shas, options = {}) { const missing = []; for (const sha of shas) { - const { exitCode } = await execApi.getExecOutput("git", ["merge-base", "--is-ancestor", sha, targetRef], { ...options, ignoreReturnCode: true, silent: true }); + const { exitCode } = await execApi.getExecOutput("git", ["cat-file", "-e", `${sha}^{commit}`], { ...options, ignoreReturnCode: true, silent: true }); if (exitCode !== 0) { missing.push(sha); } @@ -311,21 +329,31 @@ async function findMissingAncestors(execApi, shas, targetRef, options = {}) { } /** - * Probe shallow-repository status before fetching a git bundle, and deepen - * the local clone as needed so the bundle's prerequisite commits become - * reachable from `origin/`. + * Ensure a shallow checkout contains the prerequisite commits a git bundle + * needs before `git fetch ` is attempted. + * + * Bundles generated from a commit range declare prerequisite commits. A shallow + * checkout (e.g. `fetch-depth: 20`) may not contain them, and `git fetch + * ` rejects the bundle before the caller can update refs. * - * Bundles generated from a commit range can declare prerequisite commits. A - * shallow checkout (e.g. `fetch-depth: 20`) may not contain those prerequisites, - * and `git fetch ` will reject the bundle before the caller can update - * refs. On a high-churn monorepo, `git fetch --unshallow` is catastrophic — it - * downloads the entire history. Instead we iterate `git fetch origin - * --deepen=` with progressively larger N until every declared prerequisite - * satisfies `git merge-base --is-ancestor origin/`. + * Strategy (best → worst): + * 1. **Direct SHA fetch (primary).** The bundle declares *exactly* which + * commits it requires (`git bundle verify`). We fetch those SHAs directly + * from origin (`git fetch origin ...`). GitHub honors fetch-by-SHA, so + * this brings precisely the needed objects and is deterministic — it works + * even when a prerequisite lives on the PR branch and is not an ancestor of + * the base branch. This avoids walking back the base history entirely. + * 2. **Iterative deepen (fallback).** Only when fetch-by-SHA is unavailable or + * insufficient, deepen `origin/` in small `BUNDLE_DEEPEN_STEP` + * increments (re-checking object presence each step) up to + * `BUNDLE_DEEPEN_MAX_ITERATIONS`. Small steps keep any single fetch cheap so + * it cannot time out by pulling a huge slice of a large monorepo's history. + * 3. **`--unshallow` (last resort).** On a high-churn monorepo this downloads + * the entire history, so it is only attempted after the bounded deepen. * * When `deepenOptions.baseRef` or `deepenOptions.bundleFilePath` is missing - * (legacy callers), the function falls back to the previous behavior of a - * single `git fetch --unshallow origin`. + * (legacy callers), the function falls back to a single + * `git fetch --unshallow origin`. * * @param {{ getExecOutput: Function, exec: Function }} execApi - Exec API to run git commands. * @param {Object} [options] - Options passed through to exec calls. @@ -351,7 +379,7 @@ async function ensureFullHistoryForBundle(execApi, options = {}, deepenOptions = // Legacy path: no base ref / bundle info known — fall back to a single // unshallow. Callers in monorepos should always supply baseRef + bundleFilePath - // to get incremental deepening instead. + // to get targeted prerequisite fetching instead. if (!baseRef || !bundleFilePath) { core.info("Repository is shallow; fetching full history before bundle processing (no baseRef/bundle info; using --unshallow)"); await execApi.exec("git", ["fetch", "--unshallow", "origin"], options); @@ -364,31 +392,52 @@ async function ensureFullHistoryForBundle(execApi, options = {}, deepenOptions = return; } - const targetRef = `origin/${baseRef}`; - const alreadyMissing = await findMissingAncestors(execApi, prereqs, targetRef, options); - if (alreadyMissing.length === 0) { - core.info(`Bundle prerequisites already reachable from ${targetRef}; no deepen required`); + let missing = await findMissingObjects(execApi, prereqs, options); + if (missing.length === 0) { + core.info("Bundle prerequisite commits already present locally; no fetch required"); return; } - core.info(`Repository is shallow; iteratively deepening ${targetRef} to satisfy ${alreadyMissing.length} bundle prerequisite commit(s)`); - let missing = alreadyMissing; - for (const depth of BUNDLE_DEEPEN_STEPS) { - core.info(`Fetching origin ${baseRef} with --deepen=${depth} (${missing.length} prerequisite(s) still missing)`); + // PRIMARY: fetch the exact prerequisite commit SHAs directly from origin. + // The bundle tells us precisely which commits it needs, so a targeted fetch by + // SHA brings exactly those objects without deepening the base branch history. + core.info(`Repository is shallow; fetching ${missing.length} bundle prerequisite commit(s) directly from origin by SHA`); + const useBlobFilter = await isShallowOrSparseCheckout(execApi, options); + const directFetchArgs = useBlobFilter ? ["fetch", "--filter=blob:none", "origin", ...missing] : ["fetch", "origin", ...missing]; + if (useBlobFilter) { + core.info("Using --filter=blob:none for prerequisite SHA fetch (shallow or sparse checkout detected)"); + } + try { + await execApi.exec("git", directFetchArgs, options); + missing = await findMissingObjects(execApi, prereqs, options); + if (missing.length === 0) { + core.info("Bundle prerequisite commits fetched directly from origin; no deepen required"); + return; + } + core.warning(`${missing.length} prerequisite commit(s) still missing after direct SHA fetch; falling back to iterative deepen`); + } catch (directFetchError) { + core.warning(`Direct prerequisite SHA fetch failed: ${getErrorMessage(directFetchError)}; falling back to iterative deepen`); + } + + // FALLBACK: deepen origin/ in small increments, re-checking object + // presence after each step, until the prerequisites are present or the + // iteration cap is reached. + core.info(`Iteratively deepening origin/${baseRef} by ${BUNDLE_DEEPEN_STEP} commit(s) at a time to satisfy ${missing.length} prerequisite commit(s)`); + for (let iteration = 1; iteration <= BUNDLE_DEEPEN_MAX_ITERATIONS; iteration++) { try { - await execApi.exec("git", ["fetch", `--deepen=${depth}`, "origin", baseRef], options); + await execApi.exec("git", ["fetch", `--deepen=${BUNDLE_DEEPEN_STEP}`, "origin", baseRef], options); } catch (fetchError) { - core.warning(`git fetch --deepen=${depth} origin ${baseRef} failed: ${getErrorMessage(fetchError)}; aborting iterative deepen`); + core.warning(`git fetch --deepen=${BUNDLE_DEEPEN_STEP} origin ${baseRef} failed: ${getErrorMessage(fetchError)}; aborting iterative deepen`); break; } - missing = await findMissingAncestors(execApi, prereqs, targetRef, options); + missing = await findMissingObjects(execApi, prereqs, options); if (missing.length === 0) { - core.info(`Bundle prerequisites reachable after --deepen=${depth}`); + core.info(`Bundle prerequisite commits present after deepening ${iteration * BUNDLE_DEEPEN_STEP} commit(s)`); return; } } - core.warning(`Bundle prerequisites still not reachable after iterative deepen (${missing.length} remaining); attempting --unshallow as a last resort`); + core.warning(`Bundle prerequisites still not present after iterative deepen (${missing.length} remaining); attempting --unshallow as a last resort`); try { await execApi.exec("git", ["fetch", "--unshallow", "origin", baseRef], options); } catch (unshallowError) { diff --git a/actions/setup/js/git_helpers.test.cjs b/actions/setup/js/git_helpers.test.cjs index b338c2ad48b..6f4035901a1 100644 --- a/actions/setup/js/git_helpers.test.cjs +++ b/actions/setup/js/git_helpers.test.cjs @@ -339,14 +339,18 @@ describe("git_helpers.cjs", () => { expect(warning).toHaveBeenCalledWith("Could not determine shallow repository status; skipping full-history fetch probe: unknown failure"); }); - it("should iteratively deepen origin/ when bundle prereqs are known and shallow", async () => { + it("should fetch prerequisite commit SHAs directly from origin when known and shallow", async () => { const { ensureFullHistoryForBundle } = await import("./git_helpers.cjs"); const prereq = "a".repeat(40); - let deepenCalls = 0; + let prereqFetched = false; const execApi = { getExecOutput: vi.fn().mockImplementation((cmd, args) => { if (args[0] === "rev-parse" && args[1] === "--is-shallow-repository") { - return Promise.resolve({ stdout: "true\n" }); + return Promise.resolve({ stdout: "true\n", exitCode: 0 }); + } + if (args[0] === "config") { + // sparse-checkout not set + return Promise.resolve({ stdout: "", exitCode: 1 }); } if (args[0] === "bundle" && args[1] === "verify") { return Promise.resolve({ @@ -355,8 +359,46 @@ describe("git_helpers.cjs", () => { exitCode: 1, }); } - if (args[0] === "merge-base" && args[1] === "--is-ancestor") { - // Become reachable only after the second deepen fetch. + if (args[0] === "cat-file" && args[1] === "-e") { + // Object is present only after the direct SHA fetch. + return Promise.resolve({ exitCode: prereqFetched ? 0 : 1, stdout: "", stderr: "" }); + } + return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + }), + exec: vi.fn().mockImplementation((cmd, args) => { + if (args && args[0] === "fetch" && args.includes("origin") && args.includes(prereq)) { + prereqFetched = true; + } + return Promise.resolve(0); + }), + }; + + await ensureFullHistoryForBundle(execApi, {}, { baseRef: "main", bundleFilePath: "/tmp/test.bundle" }); + + // Direct SHA fetch satisfies the prerequisite; no deepen, no --unshallow. + const fetchCalls = execApi.exec.mock.calls.filter(c => c[1] && c[1][0] === "fetch"); + expect(fetchCalls.length).toBe(1); + expect(fetchCalls[0][1]).toEqual(["fetch", "--filter=blob:none", "origin", prereq]); + expect(execApi.exec).not.toHaveBeenCalledWith("git", expect.arrayContaining(["--unshallow"]), expect.anything()); + }); + + it("should fall back to deepening by 5 commits at a time when direct SHA fetch is insufficient", async () => { + const { ensureFullHistoryForBundle } = await import("./git_helpers.cjs"); + const prereq = "c".repeat(40); + let deepenCalls = 0; + const execApi = { + getExecOutput: vi.fn().mockImplementation((cmd, args) => { + if (args[0] === "rev-parse" && args[1] === "--is-shallow-repository") { + return Promise.resolve({ stdout: "true\n", exitCode: 0 }); + } + if (args[0] === "config") { + return Promise.resolve({ stdout: "", exitCode: 1 }); + } + if (args[0] === "bundle" && args[1] === "verify") { + return Promise.resolve({ stdout: "", stderr: `The bundle requires this ref:\n${prereq}\n`, exitCode: 1 }); + } + if (args[0] === "cat-file" && args[1] === "-e") { + // Present only after the second deepen fetch; direct SHA fetch leaves it missing. return Promise.resolve({ exitCode: deepenCalls >= 2 ? 0 : 1, stdout: "", stderr: "" }); } return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); @@ -371,12 +413,11 @@ describe("git_helpers.cjs", () => { await ensureFullHistoryForBundle(execApi, {}, { baseRef: "main", bundleFilePath: "/tmp/test.bundle" }); - // Two deepen fetches before ancestry succeeds; no --unshallow. - const fetchCalls = execApi.exec.mock.calls.filter(c => c[1] && c[1][0] === "fetch"); - expect(fetchCalls.length).toBe(2); - expect(fetchCalls[0][1]).toEqual(["fetch", "--deepen=50", "origin", "main"]); - expect(fetchCalls[1][1]).toEqual(["fetch", "--deepen=100", "origin", "main"]); - expect(execApi.exec).not.toHaveBeenCalledWith("git", ["fetch", "--unshallow", "origin"], expect.anything()); + const deepenFetchCalls = execApi.exec.mock.calls.filter(c => c[1] && c[1][0] === "fetch" && c[1][1] && c[1][1].startsWith("--deepen=")); + expect(deepenFetchCalls.length).toBe(2); + // Each deepen step is a small, fixed increment of 5. + expect(deepenFetchCalls[0][1]).toEqual(["fetch", "--deepen=5", "origin", "main"]); + expect(execApi.exec).not.toHaveBeenCalledWith("git", expect.arrayContaining(["--unshallow"]), expect.anything()); }); it("should skip deepening when bundle declares no prerequisites", async () => { @@ -397,7 +438,7 @@ describe("git_helpers.cjs", () => { expect(execApi.exec).not.toHaveBeenCalled(); }); - it("should skip deepening when prereqs are already reachable from origin/", async () => { + it("should skip fetching when prereqs are already present locally", async () => { const { ensureFullHistoryForBundle } = await import("./git_helpers.cjs"); const prereq = "b".repeat(40); const execApi = { @@ -406,7 +447,7 @@ describe("git_helpers.cjs", () => { if (args[0] === "bundle" && args[1] === "verify") { return Promise.resolve({ stdout: `The bundle requires this ref:\n${prereq}\n`, stderr: "", exitCode: 0 }); } - if (args[0] === "merge-base" && args[1] === "--is-ancestor") { + if (args[0] === "cat-file" && args[1] === "-e") { return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); } return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); 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 7eaaa380acc..56c0f13d36f 100644 --- a/actions/setup/js/push_to_pull_request_branch.test.cjs +++ b/actions/setup/js/push_to_pull_request_branch.test.cjs @@ -1467,6 +1467,8 @@ index 0000000..abc1234 const pushSignedSpy = vi.spyOn(pushSignedCommitsModule, "pushSignedCommits").mockResolvedValue("bundle-tip"); try { + const prereqSha = "a".repeat(40); + let prereqFetched = false; mockExec.getExecOutput.mockImplementation((cmd, args, options) => { if (cmd === "git" && args[0] === "ls-remote") { return Promise.resolve({ exitCode: 0, stdout: "remote-head\trefs/heads/feature-branch\n", stderr: "" }); @@ -1477,17 +1479,27 @@ index 0000000..abc1234 if (cmd === "git" && args[0] === "rev-parse" && args[1] === "--is-shallow-repository") { return Promise.resolve({ exitCode: 0, stdout: "true\n", stderr: "" }); } + if (cmd === "git" && args[0] === "config") { + return Promise.resolve({ exitCode: 1, stdout: "", stderr: "" }); + } if (cmd === "git" && args[0] === "bundle" && args[1] === "verify") { - return Promise.resolve({ exitCode: 1, stdout: "", stderr: `The bundle requires this ref:\n${"a".repeat(40)}\n` }); + return Promise.resolve({ exitCode: 1, stdout: "", stderr: `The bundle requires this ref:\n${prereqSha}\n` }); } - if (cmd === "git" && args[0] === "merge-base" && args[1] === "--is-ancestor") { - return Promise.resolve({ exitCode: 1, stdout: "", stderr: "" }); + if (cmd === "git" && args[0] === "cat-file" && args[1] === "-e") { + // Missing until the direct SHA fetch brings it in. + return Promise.resolve({ exitCode: prereqFetched ? 0 : 1, stdout: "", stderr: "" }); } if (cmd === "git" && args[0] === "rev-list") { return Promise.resolve({ exitCode: 0, stdout: "2\n", stderr: "" }); } return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); }); + mockExec.exec.mockImplementation((cmd, args) => { + if (cmd === "git" && Array.isArray(args) && args[0] === "fetch" && args.includes("origin") && args.includes(prereqSha)) { + prereqFetched = true; + } + return Promise.resolve(0); + }); const module = await loadModule(); const handler = await module.main({}); @@ -1501,9 +1513,11 @@ index 0000000..abc1234 expect(mockExec.exec).toHaveBeenCalledWith("git", ["update-ref", "refs/heads/feature-branch", "refs/bundles/push-feature-branch", "remote-head"], expect.any(Object)); expect(mockExec.exec).toHaveBeenCalledWith("git", ["reset", "--hard"], expect.any(Object)); expect(mockExec.exec).not.toHaveBeenCalledWith("git", ["merge", "--ff-only", "refs/bundles/push-feature-branch"], expect.any(Object)); - // Iterative deepen replaces a single --unshallow: assert the first --deepen step ran. + // Primary path: the exact prerequisite SHA is fetched directly from origin; no broad deepen. + const directFetch = mockExec.exec.mock.calls.find(([, args]) => Array.isArray(args) && args[0] === "fetch" && args.includes("origin") && args.includes(prereqSha)); + expect(directFetch).toBeTruthy(); const deepenCallIndex = mockExec.exec.mock.calls.findIndex(([, args]) => Array.isArray(args) && args[0] === "fetch" && typeof args[1] === "string" && args[1].startsWith("--deepen=")); - expect(deepenCallIndex).toBeGreaterThanOrEqual(0); + expect(deepenCallIndex).toBe(-1); } finally { pushSignedSpy.mockRestore(); }