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();
}