Skip to content

fix(push_signed_commits): recover from shallow/partial-clone object failures during rebase#39859

Merged
dsyme merged 5 commits into
mainfrom
fix/push-signed-commits-rebase-unshallow
Jun 17, 2026
Merged

fix(push_signed_commits): recover from shallow/partial-clone object failures during rebase#39859
dsyme merged 5 commits into
mainfrom
fix/push-signed-commits-rebase-unshallow

Conversation

@dsyme

@dsyme dsyme commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

fix(push_signed_commits): recover from shallow/partial-clone object failures during rebase

Summary

Fixes a class of transient failures in push_signed_commits where git rebase --onto exits with object-missing errors in shallow + partial-clone environments (e.g., partial clone filter, missing object, object file is empty). Previously these errors were either silent or surfaced as opaque rebase failures. This PR introduces a bounded, exact-commit SHA backfill to recover the missing tree/blob objects without triggering an uncontrolled --unshallow or unbounded depth increase, then retries the rebase once before surfacing a human-actionable error for genuine conflicts.


Problem

In shallow + partial-clone checkouts, git rebase --onto can fail because the rebase engine needs tree or blob objects for anchor commits that were not fetched by the partial clone filter. These failures are recoverable — the missing objects exist on the remote — but the old code had no detection or retry logic, causing the entire rebase to fail with a confusing error. Using git fetch --unshallow as a recovery strategy is unsafe because it can pull arbitrarily large history, exceeding time and storage budgets.


Solution

Three coordinated changes across the action's JavaScript helpers:

  1. git_helpers.cjs — new exported backfillCommitObjects(sha[]) function that runs git fetch --no-filter origin <sha>... targeting exactly the anchor commit SHAs needed, keeping the fetch strictly bounded.

  2. push_signed_commits.cjs — new isPartialCloneObjectFailure(stderr) classifier identifies recoverable object errors. The git rebase --onto failure path now:

    • Detects partial-clone object failures via the classifier.
    • Calls backfillCommitObjects with the exact anchor SHAs.
    • Retries the rebase once with gitAuthEnv forwarded so on-demand promisor fetches authenticate correctly.
    • Falls through to the existing human-actionable conflict error for genuine merge conflicts.
  3. push_signed_commits.test.cjs — two new integration tests:

    • Verifies that a simulated promisor fetch failure triggers bounded SHA backfill and a successful retry.
    • Verifies that genuine merge conflicts do not invoke the backfill path.

Files Changed

File Change Type Impact
actions/setup/js/git_helpers.cjs Modified High — new backfillCommitObjects export
actions/setup/js/push_signed_commits.cjs Modified High — rebase failure detection, backfill, retry
actions/setup/js/push_signed_commits.test.cjs Modified Medium — integration test coverage for both recovery paths

Key Design Decisions

  • No --unshallow: Recovery is scoped to exactly the SHA(s) needed for the current rebase anchor, not the full history. This avoids unbounded fetches.
  • Single retry: The rebase is retried exactly once after backfill. If it fails again, the error is surfaced unchanged — this avoids masking genuine conflicts or repeated slow fetches.
  • Auth forwarded on retry: gitAuthEnv is explicitly passed to the retried rebase invocation so on-demand promisor fetches in the retry can authenticate against the remote.
  • Classifier is conservative: isPartialCloneObjectFailure matches only known object-missing stderr patterns specific to shallow/partial-clone failures, so genuine conflict errors are never silently retried.

Testing

  • Existing rebase conflict tests continue to pass and are explicitly validated by the new "genuine conflict does not invoke backfill" test.
  • New backfill-and-retry test exercises the full recovery path end-to-end using a simulated promisor fetch failure.

Commits

SHA Message
c35ea04 Potential fix for pull request finding
1b85aba Pass gitAuthEnv to rebase so on-demand promisor fetches authenticate
19d7bd4 Rework rebase recovery to bounded exact-commit backfill (no unshallow)
16d960c Merge branch 'main' into fix/push-signed-commits-rebase-unshallow
7fe9016 fix(push_signed_commits): recover from shallow/partial-clone object failures during rebase

Risk & Rollout

  • Breaking: No — all changes are additive recovery logic inside existing failure paths.
  • Blast radius: Scoped to the push_signed_commits action; no shared infrastructure or schema changes.
  • Rollback: Safe to revert independently; the prior behaviour (fail without retry) is restored.

Generated by PR Description Updater for issue #39859 ·

…ailures during rebase

When the base branch advances after the agent produced its commits,
pushSignedCommits rebases the commit range onto the current GraphQL
parent. On a shallow + partial (--filter=blob:none) checkout of a large
monorepo, this rebase can fail because git lazily fetches required
objects from the promisor remote and the fetch is rejected
("could not fetch <sha> from promisor remote" / "not our ref").

Previously this aborted and threw immediately, falling back to a review
issue instead of a PR. Now the rebase output is classified: a recoverable
partial-clone object failure triggers a history backfill (git fetch
--unshallow, or a base-ref re-fetch when the repo is already complete)
and the rebase is retried once. Genuine merge conflicts still fail fast.

Closes #39858
Copilot AI review requested due to automatic review settings June 17, 2026 17:54
@github-actions

This comment has been minimized.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves pushSignedCommits’s resilience when rebasing a stale commit range onto an updated base in shallow + partial (--filter=blob:none) checkouts, where git can fail due to missing promisor objects. It adds output-based failure classification, a one-time history backfill + retry path for recoverable object failures, and integration tests covering the recovery vs. genuine merge-conflict behavior.

Changes:

  • Add isPartialCloneObjectFailure() to detect promisor/missing-object failures from git output.
  • Capture git rebase --onto output, attempt git fetch --unshallow (or a fallback fetch) and retry the rebase once on recoverable failures.
  • Add integration tests ensuring recovery occurs only for object failures and not for true merge conflicts.
Show a summary per file
File Description
actions/setup/js/push_signed_commits.cjs Adds partial-clone failure detection and a fetch+retry recovery path for rebase failures.
actions/setup/js/push_signed_commits.test.cjs Adds integration tests for the recovery retry behavior and confirms merge conflicts don’t trigger backfill.

Copilot's findings

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 2/2 changed files
  • Comments generated: 2

Comment thread actions/setup/js/push_signed_commits.cjs Outdated
Comment thread actions/setup/js/push_signed_commits.cjs Outdated
@github-actions

This comment has been minimized.

@dsyme dsyme marked this pull request as draft June 17, 2026 18:03
Replace the uncontrolled 'git fetch --unshallow' recovery in
pushSignedCommits with a bounded, monorepo-safe backfill that fetches
the full object content of EXACTLY the commits the rebase needs (new
GraphQL parent, old replay parent, and branch tip) via
'git fetch --no-filter origin <sha>...'.

Add a unified backfillCommitObjects helper to git_helpers.cjs mirroring
the targeted fetch-by-SHA strategy already used in
ensureFullHistoryForBundle. No --unshallow, no unbounded deepen, so a
large monorepo's full history is never downloaded.

Update tests to assert the recovery fetches a bounded set of exact
commit SHAs and never uses --unshallow/--deepen.
@dsyme

dsyme commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

Updated approach: bounded exact-commit backfill (no --unshallow)

Per review feedback, the recovery no longer uses git fetch --unshallow (or any unbounded deepen). Uncontrolled unshallow/deepen would download a large monorepo's entire history, which is exactly what we must avoid.

The rebase recovery now backfills the full object content of exactly the commits the rebase needs — the new GraphQL parent, the old replay parent, and the branch tip — via:

git fetch --no-filter origin <newParent> <oldParent> <headTip>

--no-filter overrides the clone's blob:none partial-clone filter so the server sends only the missing blobs for the reachable range, and nothing beyond what those anchor commits reach.

This is unified with the existing targeted fetch-by-SHA strategy used as the primary path in ensureFullHistoryForBundle. The new logic lives in a shared, exported backfillCommitObjects helper in git_helpers.cjs. Tests now assert the recovery fetches a bounded set of exact commit SHAs and never uses --unshallow/--deepen.

git rebase --onto can trigger lazy promisor fetches in shallow/partial
clones. Without gitAuthEnv those fetches fail in private repos after
checkout credentials are scrubbed from .git/config. Pass the same auth
env used for other network-touching git operations.
@github-actions

This comment has been minimized.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 1

Comment thread actions/setup/js/git_helpers.cjs
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@dsyme dsyme marked this pull request as ready for review June 17, 2026 18:26
@dsyme dsyme merged commit 7fdf418 into main Jun 17, 2026
0 of 8 checks passed
@dsyme dsyme deleted the fix/push-signed-commits-rebase-unshallow branch June 17, 2026 18:26
@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate 🏗️ completed the design decision gate check.

No ADR enforcement needed: PR #39859 does not have the 'implementation' label and has 0 new lines of code in business logic directories (≤100 threshold).

@github-actions

Copy link
Copy Markdown
Contributor

✅ smoke-ci: safeoutputs CLI comment + comment-memory run (27710791912)

Generated by 🧪 Smoke CI for issue #39859 ·

@github-actions github-actions Bot mentioned this pull request Jun 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 100/100 — Excellent

Analyzed 2 test(s) in push_signed_commits.test.cjs: 2 design tests, 0 implementation tests, 0 guideline violations.

📊 Metrics & Test Classification (2 tests analyzed)
Metric Value
New/modified tests analyzed 2
✅ Design tests (behavioral contracts) 2 (100%)
⚠️ Implementation tests (low value) 0 (0%)
Tests with error/edge cases 2 (100%)
Duplicate test clusters 0
Test inflation detected No (130 test lines / 148 production lines = 0.88:1)
🚨 Coding-guideline violations 0
Test File Classification Issues Detected
should recover from a partial-clone object failure by backfilling the exact commit objects and retrying the rebase push_signed_commits.test.cjs:1964 ✅ Design
should not attempt history backfill for a genuine merge conflict push_signed_commits.test.cjs:2042 ✅ Design

Go: 0 (*_test.go); JavaScript: 2 (*.test.cjs). Other languages detected but not scored.

Verdict

Check passed. 0% implementation tests (threshold: 30%). Both new tests verify behavioral contracts: the first confirms the full recovery loop (failure detected → backfill with exact SHAs, not --unshallow → retry succeeds → GraphQL replay proceeds → warning emitted); the second confirms the complementary invariant that a genuine merge conflict is never misclassified as a recoverable promisor failure. Strong edge-case coverage, no mocking of internal business logic, and no test inflation.

References:

🧪 Test quality analysis by Test Quality Sentinel ·

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Test Quality Sentinel: 100/100. Test quality is excellent — 0% of new tests are implementation tests (threshold: 30%).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔎 Code quality review by PR Code Quality Reviewer

return (
/could not fetch .* from promisor remote/i.test(output) ||
/upload-pack: not our ref/i.test(output) ||
/remote error: upload-pack/i.test(output) ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Over-broad pattern silently hijacks non-partial-clone upload-pack errors: /remote error: upload-pack/i matches any remote upload-pack error — auth failures, rate-limiting, server-side packing errors — not only the not our ref partial-clone case this function is meant to detect. A transient auth error would incorrectly trigger the backfill path, waste time on a git fetch --no-filter, and then surface the misleading message \u201cthe required commit objects could not be backfilled\u201d instead of the real error.

\ud83d\udca1 Suggested fix

Pattern 3 is a superset of pattern 2 (/upload-pack: not our ref/i) and adds only false matches. Remove it:

- /remote error: upload-pack/i.test(output) ||

Pattern 2 already covers the specific promisor-rejection message; any additional upload-pack sub-errors should be added with their exact suffix.

/could not fetch .* from promisor remote/i.test(output) ||
/upload-pack: not our ref/i.test(output) ||
/remote error: upload-pack/i.test(output) ||
/(unable to read|object).*(missing|not found)/i.test(output) ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

object alternation in pattern 4 is too broad — misclassifies genuine merge conflicts: The regex /(unable to read|object).*(missing|not found)/i uses object as a bare alternation. Any git error mentioning the word "object" followed anywhere by "missing" or "not found" matches — including messages from a normal conflict (e.g. error: Your local changes to the following files would be overwritten by merge: ... object or similar). This silently promotes a genuine merge conflict into the partial-clone recovery path, wasting an expensive fetch-and-retry before ultimately failing with a misleading error.

Similarly, /fatal: bad object/i fires for wrong ref names, detached HEAD anomalies, and corrupt local objects — none of which are recoverable via promisor backfill.

💡 Suggested fix

Tighten both patterns to match only the promisor-specific message forms:

- /(unable to read|object).*(missing|not found)/i.test(output) ||
- /fatal: bad object/i.test(output)
+ /unable to read [0-9a-f]{40}/i.test(output) ||
+ /fatal: object [0-9a-f]{40} is unavailable/i.test(output)

Including the SHA hex pattern in the match ensures only object-fetch failures triggered by a specific known commit are classified as partial-clone failures.

} catch {
// If HEAD cannot be resolved, fall back to the known range anchors.
}
const fetchTargets = [firstGraphqlParentOid, firstReplayParentOid, headOid].filter(sha => typeof sha === "string" && sha.length > 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

headOid may be a rebase-internal SHA if --abort failed silently, poisoning the backfill fetch: rev-parse HEAD is called after git rebase --abort whose failure is swallowed. If abort failed (e.g. the rebase-state lock is held), HEAD remains pointing at the cherry-pick-in-progress commit — a local-only SHA that does not exist on origin. Passing that SHA to git fetch --no-filter origin <sha1> <sha2> <bad-sha> causes the entire fetch to fail (git fetch is all-or-nothing for multi-ref fetches), so recovered = false and the caller throws the misleading "could not be backfilled" error rather than attempting recovery with just the two known-good anchor SHAs.

Additionally, the filter at line 449 (sha.length > 0) is weaker than backfillCommitObjects's own guard (/^[0-9a-f]{40}$/i), so the core.warning message may report e.g. "3 anchor commit(s)" while only 2 valid SHAs are actually fetched.

💡 Suggested fix
  1. Validate headOid with the same 40-char hex pattern before including it:
- const fetchTargets = [firstGraphqlParentOid, firstReplayParentOid, headOid].filter(sha => typeof sha === "string" && sha.length > 0);
+ const headOidValid = typeof headOid === "string" && /^[0-9a-f]{40}$/i.test(headOid);
+ const fetchTargets = [firstGraphqlParentOid, firstReplayParentOid, ...(headOidValid ? [headOid] : [])];
  1. Consider using only the two known-good anchor SHAs (firstGraphqlParentOid, firstReplayParentOid) and omitting the post-abort HEAD entirely.

}

if (recovered) {
rebaseResult = await runRebase();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fetching only 3 anchor commits does not guarantee all intermediate objects are present — retry will fail with the same promisor error for long ranges: backfillCommitObjects fetches exactly firstGraphqlParentOid, firstReplayParentOid, and HEAD. In a partial clone, git rebase --onto needs the full tree+blob content for every commit in the range firstReplayParentOid..HEAD. For a branch that is tens or hundreds of commits behind, git must lazily fetch intermediate objects for each cherry-picked commit in turn. Fetching only the three endpoints does not pre-materialise those blobs — the retry rebase hits the same could not fetch ... from promisor remote failure on the first intermediate commit.

The error at line 467 then says "failed to rebase ... even after backfilling the required commit objects", implying the objects were backfilled but the rebase still failed — which is misleading; the objects were never fully backfilled.

💡 Suggested fix options

Option A — Fetch the full range: replace the 3-anchor fetch with:

git fetch --no-filter origin firstReplayParentOid..firstGraphqlParentOid

(or use git fetch --unshallow origin as a last resort if the repo is shallow).

Option B — At minimum, document the limitation in the JSDoc of backfillCommitObjects and in the core.warning message, so operators know the retry may still fail and why.

Option C — Re-use ensureFullHistoryForBundle's iterative-deepen strategy which already handles this case for bundle apply.

const fetchTargets = [firstGraphqlParentOid, firstReplayParentOid, headOid].filter(sha => typeof sha === "string" && sha.length > 0);
core.warning(`pushSignedCommits: rebase failed due to missing objects in a shallow/partial clone; ` + `backfilling object content for ${fetchTargets.length} anchor commit(s) directly from origin by SHA and retrying once`);
let recovered = false;
try {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead outer catch around backfillCommitObjects is unreachable and misleading: backfillCommitObjects catches all errors internally and always returns a boolean — it never throws. The catch (recoveryError) block at this line can therefore never execute, and the core.warning inside it will never fire. This makes the code look like a defensive guard when it is actually dead code, and any future reader will assume backfillCommitObjects can throw (it can't without changing its internal contract).

💡 Suggested fix

Remove the outer try/catch — recovered will correctly be false on any backfill failure:

- let recovered = false;
- try {
-   recovered = await backfillCommitObjects(exec, fetchTargets, { cwd, env: { ...process.env, ...(gitAuthEnv || {}) } });
- } catch (recoveryError) {
-   core.warning(`pushSignedCommits: targeted object backfill failed: ...`);
- }
+ const recovered = await backfillCommitObjects(exec, fetchTargets, { cwd, env: { ...process.env, ...(gitAuthEnv || {}) } });

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skills-Based Review 🧠

Applied /diagnose, /tdd, and /zoom-out — requesting changes on two classifier precision issues and one missing test branch.

📋 Key Themes & Highlights

Blocking concerns

  • Classifier false-positive risk (lines 43–44): two of the five isPartialCloneObjectFailure patterns are broader than the documented git promisor error messages. A false positive would cause the code to abort a real error, run a useless targeted fetch, and surface a misleading "even after backfilling" message instead of the original root cause. See inline comments for tighter patterns.
  • Missing test branch (test line 1964): the path where backfill succeeds but the retry rebase also fails is untested. It has its own error message and rebase --abort call — both worth a regression guard.

Non-blocking suggestions

  • isPartialCloneObjectFailure needs isolated unit tests (one per pattern); the integration tests cover only two of the five patterns via one happy path.
  • fetchTargets filter inconsistency: uses sha.length > 0 while backfillCommitObjects re-validates to /^[0-9a-f]{40}$/i; prefer OID_PATTERN at the call site.
  • backfillCommitObjects: silent false on empty targets; add a core.warning to make this debuggable.
  • backfillCommitObjects: no core.info on success; operators have no confirmation in run logs that the recovery fetch actually ran.
  • gitAuthEnv threading is an independent correctness fix (previously missing); worth a line in the PR description.
  • global.exec overwrite in tests: save/restore in a finally block for test isolation.

Positive highlights

  • ✅ Excellent root-cause analysis in the PR description, with a link to the real failing run
  • ✅ Bounded recovery strategy (git fetch --no-filter origin <sha>...) mirrors the existing ensureFullHistoryForBundle approach — good consistency
  • ✅ Single-retry guard (let recovered = false; if (recovered)) prevents infinite retry loops
  • ✅ Conflict test correctly guards the most important invariant: genuine conflicts must not trigger the backfill path
  • backfillCommitObjects defensively validates all SHA inputs at both the helper and call-site levels

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer

/could not fetch .* from promisor remote/i.test(output) ||
/upload-pack: not our ref/i.test(output) ||
/remote error: upload-pack/i.test(output) ||
/(unable to read|object).*(missing|not found)/i.test(output) ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnose] This regex is too broad and risks false-positives that would suppress real errors.

(unable to read|object).*(missing|not found) can match git output well beyond promisor failures — e.g. a locally corrupted object or working-tree message. A false positive causes the code to abort a healthy rebase failure, run a useless targeted fetch, and surface a confusing "even after backfilling" error instead of the real root cause.

💡 Suggested tighter pattern

Anchor the SHA into the pattern so it only matches specific git lazy-fetch messages:

// before
/(unable to read|object).*(missing|not found)/i.test(output)

// after — require a 40-char SHA adjacent to the keyword
/(?:unable to read|object) [0-9a-f]{40}|[0-9a-f]{40}.*(?:missing|not found)/i.test(output)

This keeps coverage for real promisor messages while excluding generic working-tree and corruption messages.

/upload-pack: not our ref/i.test(output) ||
/remote error: upload-pack/i.test(output) ||
/(unable to read|object).*(missing|not found)/i.test(output) ||
/fatal: bad object/i.test(output)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnose] fatal: bad object is too generic — it fires for invalid/missing revision arguments unrelated to promisor fetches (e.g. a bad branch name, a deleted ref).

A false positive would incorrectly trigger the backfill path for an unrecoverable error, masking the real cause.

💡 Suggested fix

Either remove this pattern entirely (the other four patterns already cover the documented promisor error messages) or anchor it to a SHA:

// before
/fatal: bad object/i.test(output)

// after — only match when a 40-char OID follows the phrase
/fatal: bad object [0-9a-f]{40}/i.test(output)

The specific promisor scenario where this fires (the new GraphQL parent OID isn't present locally) is already covered by upload-pack: not our ref and could not fetch ... from promisor remote.

* @param {string} output - Combined stdout/stderr from the failed git command.
* @returns {boolean} True when the failure looks like a partial-clone object fetch failure.
*/
function isPartialCloneObjectFailure(output) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] isPartialCloneObjectFailure is the load-bearing classifier gate for this entire recovery path, yet it has no isolated unit tests — only the integration tests exercise it (implicitly, via two specific strings).

If git ever changes its error message format, or if a new pattern is added, there is no fast-failing unit test to catch the regression.

💡 Suggested test structure
describe('isPartialCloneObjectFailure', () => {
  // Positive cases — should return true
  it.each([
    ['could not fetch OID from promisor remote',
     'fatal: could not fetch 4f0af081abc from promisor remote'],
    ['upload-pack: not our ref',
     'fatal: remote error: upload-pack: not our ref 0035eb55'],
    ['remote error: upload-pack',
     'error: remote error: upload-pack: filter not supported'],
    ['unable to read SHA',
     'fatal: unable to read tree 1234567890abcdef1234567890abcdef12345678'],
    ['fatal: bad object SHA',
     'fatal: bad object 1234567890abcdef1234567890abcdef12345678'],
  ])('returns true for: %s', (_, output) => {
    expect(isPartialCloneObjectFailure(output)).toBe(true);
  });

  // Negative cases — should return false
  it.each([
    ['merge conflict', 'CONFLICT (content): Merge conflict in file.txt'],
    ['empty string', ''],
    ['null', null],
  ])('returns false for: %s', (_, output) => {
    expect(isPartialCloneObjectFailure(output)).toBe(false);
  });
});

This also serves as an executable specification of what git messages the function intentionally handles.

expect(githubClient.graphql).not.toHaveBeenCalled();
});

it("should recover from a partial-clone object failure by backfilling the exact commit objects and retrying the rebase", async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The new tests cover two of the three branches in the recovery flow, but the third branch — recovered === true yet the retry rebase still fails (lines 460-471 of push_signed_commits.cjs) — is untested. This path produces a distinct error message ("even after backfilling") and has its own rebase --abort call.

💡 Sketch of missing test
it('should throw "even after backfilling" when backfill succeeds but retry rebase also fails', async () => {
  // ... set up workDir with a conflicting change ...
  let rebaseAttempts = 0;
  global.exec = {
    getExecOutput: async (program, args, opts = {}) => {
      if (program === 'git' && args[0] === 'rebase' && args[1] === '--onto') {
        rebaseAttempts++;
        // First attempt: simulate promisor failure
        if (rebaseAttempts === 1) {
          return { exitCode: 128, stdout: '', stderr: 'fatal: could not fetch 0035eb55... from promisor remote' };
        }
        // Second attempt: simulate a conflict (or another failure)
        return { exitCode: 1, stdout: 'CONFLICT (content): Merge conflict in file.txt', stderr: '' };
      }
      if (program === 'git' && args[0] === 'fetch' && args.includes('--no-filter')) {
        return { exitCode: 0, stdout: '', stderr: '' };
      }
      return realExec.getExecOutput(program, args, opts);
    },
    exec: realExec.exec,
  };

  await expect(pushSignedCommits({ ... }))
    .rejects.toThrow('even after backfilling the required commit objects');
  expect(rebaseAttempts).toBe(2);
});

} catch {
// If HEAD cannot be resolved, fall back to the known range anchors.
}
const fetchTargets = [firstGraphqlParentOid, firstReplayParentOid, headOid].filter(sha => typeof sha === "string" && sha.length > 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnose] The fetchTargets filter uses sha.length > 0 while backfillCommitObjects re-validates to /^[0-9a-f]{40}$/i — two different gates for the same invariant.

If headOid ever resolves to an abbreviated SHA (or any non-40-char string), the outer filter passes it through but the inner one discards it silently. If ALL targets end up discarded, backfillCommitObjects returns false with no log and the caller throws a misleading "could not be backfilled" error.

💡 Suggested fix

Reuse the OID_PATTERN already defined at the top of the file for the outer filter so both layers are consistent:

// before
const fetchTargets = [firstGraphqlParentOid, firstReplayParentOid, headOid]
  .filter(sha => typeof sha === "string" && sha.length > 0);

// after
const fetchTargets = [firstGraphqlParentOid, firstReplayParentOid, headOid]
  .filter(sha => typeof sha === "string" && OID_PATTERN.test(sha));

This catches a short/corrupt SHA early, before the backfill attempt, and produces a clearer error if fetchTargets ends up empty.

async function backfillCommitObjects(execApi, commitShas, options = {}) {
const targets = [...new Set((commitShas || []).filter(sha => typeof sha === "string" && /^[0-9a-f]{40}$/i.test(sha)))];
if (targets.length === 0) {
return false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnose] When targets.length === 0, the function returns false silently with no log entry. The caller in push_signed_commits.cjs then throws a "could not be backfilled" error with no indication that the real problem was an empty target list (e.g. all SHAs failed the 40-char hex validation).

💡 Suggested fix
  if (targets.length === 0) {
    core.warning('backfillCommitObjects: no valid 40-char SHA targets provided; targeted fetch skipped');
    return false;
  }

This makes the empty-list scenario immediately visible in run logs rather than surfacing as a confusing "could not be backfilled" error downstream.

if (exitCode !== 0) {
core.warning(`backfillCommitObjects: targeted fetch exited with code ${exitCode}: ${String(stderr || "").trim()}`);
}
return exitCode === 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnose] There is no log entry on the success path — when the targeted fetch completes with exitCode === 0 the function returns true silently. This makes it hard to confirm in run logs that the backfill actually happened vs. that the recovery path was never triggered.

💡 Suggested fix
    if (exitCode !== 0) {
      core.warning(`backfillCommitObjects: targeted fetch exited with code ${exitCode}: ${String(stderr || '').trim()}`);
    } else {
      core.info(`backfillCommitObjects: successfully fetched object content for ${targets.length} anchor commit(s) from origin`);
    }
    return exitCode === 0;

A core.info on success gives operators a clear, searchable confirmation in the Actions log that the recovery fetch ran and succeeded.

// those on-demand promisor fetches can authenticate in private repos even
// after checkout credentials have been scrubbed from .git/config.
const runRebase = async () => {
const result = await exec.getExecOutput("git", ["rebase", "--onto", firstGraphqlParentOid, firstReplayParentOid, "HEAD"], { cwd, env: { ...process.env, ...(gitAuthEnv || {}) }, ignoreReturnCode: true });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/zoom-out] Threading gitAuthEnv into the rebase exec looks like a secondary but independent correctness fix — the original code passed no env override, so in private repos where checkout credentials are scrubbed before this point, the rebase's own promisor lazy-fetches would have failed with auth errors that none of the five classifier patterns would match. Worth calling out explicitly in the PR description (and perhaps with a separate commit) so reviewers understand the two orthogonal improvements: (1) recovery from promisor object failures, and (2) ensuring the rebase step itself has auth context for promisor fetches.

No code change needed — just a documentation/narrative suggestion.

const realExec = makeRealExec(workDir);
let rebaseAttempts = 0;
let backfillTargets = null;
global.exec = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] global.exec is overwritten directly and never restored. If an earlier assertion in this test throws (e.g. the pushSignedCommits call rejects unexpectedly), the global remains polluted for subsequent tests in the same suite.

💡 Suggested fix

Save and restore around the mutation:

const originalExec = global.exec;
try {
  global.exec = { getExecOutput: ..., exec: realExec.exec };
  // ... test body ...
} finally {
  global.exec = originalExec;
}

Or, if the suite already has a beforeEach/afterEach that resets global.exec to a default mock, a comment documenting that would make the test easier to reason about in isolation.

expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("backfilling object content"));
});

it("should not attempt history backfill for a genuine merge conflict", async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The conflict test verifies backfillAttempted === false for --no-filter, --unshallow, and --deepen fetch variants, which is solid. But the test doesn't directly verify that isPartialCloneObjectFailure returns false for real conflict output — it only proves the integrated behaviour works for this one scenario.

If a future pattern addition to isPartialCloneObjectFailure inadvertently matches merge-conflict text, the integration test would catch it, but only for this specific conflict message. A dedicated unit test for the classifier (see the comment on line 37) would catch regressions more precisely and without needing a full integration run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants