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
57 changes: 44 additions & 13 deletions actions/setup/js/create_pull_request.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: "" });
Expand Down Expand Up @@ -267,7 +266,7 @@ describe("create_pull_request - bundle transport shallow checkout", () => {
vi.clearAllMocks();
});

it("should deepen origin/<base> 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,
Expand All @@ -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" }, {});
Expand All @@ -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 () => {
Expand Down
119 changes: 84 additions & 35 deletions actions/setup/js/git_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 <sha>^{commit}`, which exits non-zero when the
* object is missing.
*
* This is the correct gate for bundle application: `git fetch <bundle>` 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<string[]>} SHAs still missing (not ancestors / not present).
* @returns {Promise<string[]>} 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);
}
Expand All @@ -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/<baseRef>`.
* Ensure a shallow checkout contains the prerequisite commits a git bundle
* needs before `git fetch <bundle>` 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
* <bundle>` 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 <bundle>` 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 <baseRef>
* --deepen=<N>` with progressively larger N until every declared prerequisite
* satisfies `git merge-base --is-ancestor <prereq> origin/<baseRef>`.
* 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 <sha>...`). 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/<baseRef>` 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.
Expand All @@ -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);
Expand All @@ -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/<base> 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) {
Expand Down
Loading
Loading