@@ -233,11 +233,22 @@ function hasMergeCommitsInRange(baseRef, headRef, options = {}) {
233233}
234234
235235/**
236- * Deepen sequence (per call to `git fetch --deepen=N`). Each value adds N
237- * commits to the existing shallow history. Total reachable depth after the
238- * final step is the sum of these values (~7850 commits).
236+ * Fallback deepen step size (commits added per `git fetch --deepen=N` call).
237+ *
238+ * The primary path fetches the exact prerequisite commit SHAs directly from
239+ * origin (see `ensureFullHistoryForBundle`), so this iterative deepen only runs
240+ * when fetch-by-SHA is unavailable or insufficient. We deepen in small
241+ * increments so a single fetch never tries to pull a huge slice of history,
242+ * which can time out on large monorepos with long, complex branch histories.
243+ */
244+ const BUNDLE_DEEPEN_STEP = 5 ;
245+
246+ /**
247+ * Maximum number of fallback deepen iterations before giving up and attempting
248+ * `--unshallow`. With a step of 5 this caps the fallback at ~1000 commits of
249+ * deepening (200 * 5) before the last-resort unshallow.
239250 */
240- const BUNDLE_DEEPEN_STEPS = [ 50 , 100 , 200 , 500 , 1000 , 2000 , 4000 ] ;
251+ const BUNDLE_DEEPEN_MAX_ITERATIONS = 200 ;
241252
242253/**
243254 * Extract prerequisite commit SHAs declared in a git bundle file.
@@ -291,18 +302,25 @@ async function getBundlePrerequisites(execApi, bundleFilePath, options = {}) {
291302}
292303
293304/**
294- * Check which of the given SHAs are NOT yet ancestors of `targetRef`.
305+ * Check which of the given commit SHAs are NOT present in the local object
306+ * store. Uses `git cat-file -e <sha>^{commit}`, which exits non-zero when the
307+ * object is missing.
308+ *
309+ * This is the correct gate for bundle application: `git fetch <bundle>` only
310+ * needs the prerequisite *objects* to exist locally — it does not require them
311+ * to be reachable from any particular branch. (A prerequisite commit can live
312+ * on the pull request branch and never be an ancestor of the base branch, so an
313+ * ancestry-based check would loop forever trying to deepen the base.)
295314 *
296315 * @param {{ getExecOutput: Function } } execApi
297316 * @param {string[] } shas
298- * @param {string } targetRef
299317 * @param {Object } [options]
300- * @returns {Promise<string[]> } SHAs still missing (not ancestors / not present) .
318+ * @returns {Promise<string[]> } SHAs whose commit object is not present locally .
301319 */
302- async function findMissingAncestors ( execApi , shas , targetRef , options = { } ) {
320+ async function findMissingObjects ( execApi , shas , options = { } ) {
303321 const missing = [ ] ;
304322 for ( const sha of shas ) {
305- const { exitCode } = await execApi . getExecOutput ( "git" , [ "merge-base " , "--is-ancestor " , sha , targetRef ] , { ...options , ignoreReturnCode : true , silent : true } ) ;
323+ const { exitCode } = await execApi . getExecOutput ( "git" , [ "cat-file " , "-e " , ` ${ sha } ^{commit}` ] , { ...options , ignoreReturnCode : true , silent : true } ) ;
306324 if ( exitCode !== 0 ) {
307325 missing . push ( sha ) ;
308326 }
@@ -311,21 +329,31 @@ async function findMissingAncestors(execApi, shas, targetRef, options = {}) {
311329}
312330
313331/**
314- * Probe shallow-repository status before fetching a git bundle, and deepen
315- * the local clone as needed so the bundle's prerequisite commits become
316- * reachable from `origin/<baseRef>`.
332+ * Ensure a shallow checkout contains the prerequisite commits a git bundle
333+ * needs before `git fetch <bundle>` is attempted.
334+ *
335+ * Bundles generated from a commit range declare prerequisite commits. A shallow
336+ * checkout (e.g. `fetch-depth: 20`) may not contain them, and `git fetch
337+ * <bundle>` rejects the bundle before the caller can update refs.
317338 *
318- * Bundles generated from a commit range can declare prerequisite commits. A
319- * shallow checkout (e.g. `fetch-depth: 20`) may not contain those prerequisites,
320- * and `git fetch <bundle>` will reject the bundle before the caller can update
321- * refs. On a high-churn monorepo, `git fetch --unshallow` is catastrophic — it
322- * downloads the entire history. Instead we iterate `git fetch origin <baseRef>
323- * --deepen=<N>` with progressively larger N until every declared prerequisite
324- * satisfies `git merge-base --is-ancestor <prereq> origin/<baseRef>`.
339+ * Strategy (best → worst):
340+ * 1. **Direct SHA fetch (primary).** The bundle declares *exactly* which
341+ * commits it requires (`git bundle verify`). We fetch those SHAs directly
342+ * from origin (`git fetch origin <sha>...`). GitHub honors fetch-by-SHA, so
343+ * this brings precisely the needed objects and is deterministic — it works
344+ * even when a prerequisite lives on the PR branch and is not an ancestor of
345+ * the base branch. This avoids walking back the base history entirely.
346+ * 2. **Iterative deepen (fallback).** Only when fetch-by-SHA is unavailable or
347+ * insufficient, deepen `origin/<baseRef>` in small `BUNDLE_DEEPEN_STEP`
348+ * increments (re-checking object presence each step) up to
349+ * `BUNDLE_DEEPEN_MAX_ITERATIONS`. Small steps keep any single fetch cheap so
350+ * it cannot time out by pulling a huge slice of a large monorepo's history.
351+ * 3. **`--unshallow` (last resort).** On a high-churn monorepo this downloads
352+ * the entire history, so it is only attempted after the bounded deepen.
325353 *
326354 * When `deepenOptions.baseRef` or `deepenOptions.bundleFilePath` is missing
327- * (legacy callers), the function falls back to the previous behavior of a
328- * single `git fetch --unshallow origin`.
355+ * (legacy callers), the function falls back to a single
356+ * `git fetch --unshallow origin`.
329357 *
330358 * @param {{ getExecOutput: Function, exec: Function } } execApi - Exec API to run git commands.
331359 * @param {Object } [options] - Options passed through to exec calls.
@@ -351,7 +379,7 @@ async function ensureFullHistoryForBundle(execApi, options = {}, deepenOptions =
351379
352380 // Legacy path: no base ref / bundle info known — fall back to a single
353381 // unshallow. Callers in monorepos should always supply baseRef + bundleFilePath
354- // to get incremental deepening instead.
382+ // to get targeted prerequisite fetching instead.
355383 if ( ! baseRef || ! bundleFilePath ) {
356384 core . info ( "Repository is shallow; fetching full history before bundle processing (no baseRef/bundle info; using --unshallow)" ) ;
357385 await execApi . exec ( "git" , [ "fetch" , "--unshallow" , "origin" ] , options ) ;
@@ -364,31 +392,52 @@ async function ensureFullHistoryForBundle(execApi, options = {}, deepenOptions =
364392 return ;
365393 }
366394
367- const targetRef = `origin/${ baseRef } ` ;
368- const alreadyMissing = await findMissingAncestors ( execApi , prereqs , targetRef , options ) ;
369- if ( alreadyMissing . length === 0 ) {
370- core . info ( `Bundle prerequisites already reachable from ${ targetRef } ; no deepen required` ) ;
395+ let missing = await findMissingObjects ( execApi , prereqs , options ) ;
396+ if ( missing . length === 0 ) {
397+ core . info ( "Bundle prerequisite commits already present locally; no fetch required" ) ;
371398 return ;
372399 }
373400
374- core . info ( `Repository is shallow; iteratively deepening ${ targetRef } to satisfy ${ alreadyMissing . length } bundle prerequisite commit(s)` ) ;
375- let missing = alreadyMissing ;
376- for ( const depth of BUNDLE_DEEPEN_STEPS ) {
377- core . info ( `Fetching origin ${ baseRef } with --deepen=${ depth } (${ missing . length } prerequisite(s) still missing)` ) ;
401+ // PRIMARY: fetch the exact prerequisite commit SHAs directly from origin.
402+ // The bundle tells us precisely which commits it needs, so a targeted fetch by
403+ // SHA brings exactly those objects without deepening the base branch history.
404+ core . info ( `Repository is shallow; fetching ${ missing . length } bundle prerequisite commit(s) directly from origin by SHA` ) ;
405+ const useBlobFilter = await isShallowOrSparseCheckout ( execApi , options ) ;
406+ const directFetchArgs = useBlobFilter ? [ "fetch" , "--filter=blob:none" , "origin" , ...missing ] : [ "fetch" , "origin" , ...missing ] ;
407+ if ( useBlobFilter ) {
408+ core . info ( "Using --filter=blob:none for prerequisite SHA fetch (shallow or sparse checkout detected)" ) ;
409+ }
410+ try {
411+ await execApi . exec ( "git" , directFetchArgs , options ) ;
412+ missing = await findMissingObjects ( execApi , prereqs , options ) ;
413+ if ( missing . length === 0 ) {
414+ core . info ( "Bundle prerequisite commits fetched directly from origin; no deepen required" ) ;
415+ return ;
416+ }
417+ core . warning ( `${ missing . length } prerequisite commit(s) still missing after direct SHA fetch; falling back to iterative deepen` ) ;
418+ } catch ( directFetchError ) {
419+ core . warning ( `Direct prerequisite SHA fetch failed: ${ getErrorMessage ( directFetchError ) } ; falling back to iterative deepen` ) ;
420+ }
421+
422+ // FALLBACK: deepen origin/<base> in small increments, re-checking object
423+ // presence after each step, until the prerequisites are present or the
424+ // iteration cap is reached.
425+ core . info ( `Iteratively deepening origin/${ baseRef } by ${ BUNDLE_DEEPEN_STEP } commit(s) at a time to satisfy ${ missing . length } prerequisite commit(s)` ) ;
426+ for ( let iteration = 1 ; iteration <= BUNDLE_DEEPEN_MAX_ITERATIONS ; iteration ++ ) {
378427 try {
379- await execApi . exec ( "git" , [ "fetch" , `--deepen=${ depth } ` , "origin" , baseRef ] , options ) ;
428+ await execApi . exec ( "git" , [ "fetch" , `--deepen=${ BUNDLE_DEEPEN_STEP } ` , "origin" , baseRef ] , options ) ;
380429 } catch ( fetchError ) {
381- core . warning ( `git fetch --deepen=${ depth } origin ${ baseRef } failed: ${ getErrorMessage ( fetchError ) } ; aborting iterative deepen` ) ;
430+ core . warning ( `git fetch --deepen=${ BUNDLE_DEEPEN_STEP } origin ${ baseRef } failed: ${ getErrorMessage ( fetchError ) } ; aborting iterative deepen` ) ;
382431 break ;
383432 }
384- missing = await findMissingAncestors ( execApi , prereqs , targetRef , options ) ;
433+ missing = await findMissingObjects ( execApi , prereqs , options ) ;
385434 if ( missing . length === 0 ) {
386- core . info ( `Bundle prerequisites reachable after --deepen= ${ depth } ` ) ;
435+ core . info ( `Bundle prerequisite commits present after deepening ${ iteration * BUNDLE_DEEPEN_STEP } commit(s) ` ) ;
387436 return ;
388437 }
389438 }
390439
391- core . warning ( `Bundle prerequisites still not reachable after iterative deepen (${ missing . length } remaining); attempting --unshallow as a last resort` ) ;
440+ core . warning ( `Bundle prerequisites still not present after iterative deepen (${ missing . length } remaining); attempting --unshallow as a last resort` ) ;
392441 try {
393442 await execApi . exec ( "git" , [ "fetch" , "--unshallow" , "origin" , baseRef ] , options ) ;
394443 } catch ( unshallowError ) {
0 commit comments