diff --git a/.changeset/fix-signed-push-genuine-rebase-conflict-fallback.md b/.changeset/fix-signed-push-genuine-rebase-conflict-fallback.md new file mode 100644 index 00000000000..d624792f1f5 --- /dev/null +++ b/.changeset/fix-signed-push-genuine-rebase-conflict-fallback.md @@ -0,0 +1,15 @@ +--- +"gh-aw": patch +--- + +Fix `pushSignedCommits` unnecessarily failing the whole pull-request-creation operation when the agent's commits genuinely conflict with the base branch during the pre-replay rebase. + +Previously, when the base branch advanced with content that conflicted with the agent's changes (e.g. both edited `CHANGELOG.md`), `pushSignedCommits` aborted the rebase and threw, which caused `create_pull_request` to give up on the pull request entirely and open a fallback issue instead — even though a normal (unsigned) `git push` of the un-rebased commits would have let GitHub create the pull request and simply report it as having conflicts, exactly like any other PR. + +`pushSignedCommits` now falls back to an unsigned `git push` of the original, un-rebased commit range when a genuine (non-recoverable) rebase conflict is detected, unless `allowGitPushFallback: false` is explicitly requested. This lets the pull request still be created in a "has conflicts" state so it can be resolved normally, instead of failing the run and falling back to a GitHub issue. + +If the unsigned push itself is rejected (for example, by a branch-protection rule that requires signed commits), `pushSignedCommits` now throws a combined error describing both the original rebase conflict and the push rejection, instead of letting the raw `git push` failure propagate on its own. + +The un-rebased commit range still goes through the same preflight validation as any other unsigned push fallback (merge-commit detection, unsupported file modes such as symlinks/submodules, and file-protection/size policy) before being pushed, and a failed `git rebase --abort` cleanup is now treated as fatal rather than silently ignored, so this fallback never pushes content that should have been refused or operates on a possibly-corrupted worktree. + +This same fallback (including the preflight checks and fatal-abort handling) now also applies when a genuine content conflict is only revealed *after* a shallow/partial-clone object backfill succeeds and the retried rebase still fails — previously that case unconditionally threw instead of falling back, even though it is the same class of "genuine conflict" as the non-backfill case. diff --git a/actions/setup/js/push_signed_commits.cjs b/actions/setup/js/push_signed_commits.cjs index ed06cbca1cd..b615664267e 100644 --- a/actions/setup/js/push_signed_commits.cjs +++ b/actions/setup/js/push_signed_commits.cjs @@ -82,6 +82,21 @@ class PushSignedCommitsPolicyViolation extends Error { } } +/** + * Sentinel error class marking a terminal failure of the unsigned git push fallback that is + * attempted after a genuine (non-recoverable) rebase conflict. This must propagate as-is + * without triggering the generic "GraphQL failed, retry with git push" fallback further up — + * that push was already attempted (and rejected), so retrying it again would just repeat the + * same failure (e.g. against a branch-protection rule requiring signed commits). + */ +class PushSignedCommitsUnsignedFallbackFailed extends Error { + /** @param {string} message */ + constructor(message, options) { + super(message, options); + this.name = "PushSignedCommitsUnsignedFallbackFailed"; + } +} + /** * Unescape a C-quoted path returned by `git diff-tree --raw`. * @@ -467,6 +482,14 @@ async function pushSignedCommits({ } let shas = revListEntries.map(entry => entry.sha); + // When a genuine (non-recoverable) rebase conflict is hit below, the original un-rebased + // commit range is pushed unsigned instead of being replayed through GraphQL. Rather than + // returning immediately, that path sets this flag and falls through into the same + // preflight validation (merge commit / unsupported file mode / file-protection policy + // checks) that every other unsigned-push fallback goes through, so genuinely unsupported + // or policy-blocked content still refuses the fallback instead of being pushed as-is. + let unsignedPushFallbackReason; + if (shas.length === 0) { core.info("pushSignedCommits: no new commits to push via GraphQL"); return undefined; @@ -564,16 +587,37 @@ async function pushSignedCommits({ if (recovered) { rebaseResult = await runRebase(); if (rebaseResult.exitCode !== 0) { + const retryOutput = `${rebaseResult.stdout || ""}\n${rebaseResult.stderr || ""}`; try { await exec.exec("git", ["rebase", "--abort"], { cwd }); - } catch { - // Ignore cleanup failures. + } catch (abortError) { + throw new Error( + `${ERR_SYSTEM}: pushSignedCommits: failed to rebase commit range onto current GraphQL parent (${firstGraphqlParentOid}) even after backfilling the required commit objects, ` + + `and 'git rebase --abort' also failed to restore branch '${branch}' to its original state. ` + + `Root cause: ${retryOutput.trim()}. Abort failure: ${getErrorMessage(abortError)}`, + { cause: abortError } + ); } - const retryOutput = `${rebaseResult.stdout || ""}\n${rebaseResult.stderr || ""}`; - throw new Error( - `${ERR_SYSTEM}: pushSignedCommits: failed to rebase commit range onto current GraphQL parent (${firstGraphqlParentOid}) even after backfilling the required commit objects. ` + - `Resolve conflicts by rebasing/cherry-picking locally and retry. Root cause: ${retryOutput.trim()}` - ); + if (isPartialCloneObjectFailure(retryOutput)) { + // Still a missing-object failure even after the targeted backfill — the shallow/ + // partial clone genuinely cannot supply what this rebase needs, so there is no + // valid replay range to fall back to unsigned; this remains fatal. + throw new Error( + `${ERR_SYSTEM}: pushSignedCommits: failed to rebase commit range onto current GraphQL parent (${firstGraphqlParentOid}) even after backfilling the required commit objects. ` + + `Resolve conflicts by rebasing/cherry-picking locally and retry. Root cause: ${retryOutput.trim()}` + ); + } + // The backfill succeeded (the objects needed to attempt the rebase are now present), + // but retrying the rebase still failed — this is a genuine content conflict that was + // only revealed once the missing objects were available, not a partial-clone object + // issue. Route it through the same unsigned-push fallback as any other genuine + // conflict below, instead of unconditionally throwing. + const diagnosticMessage = + `${ERR_SYSTEM}: pushSignedCommits: failed to rebase commit range onto current GraphQL parent (${firstGraphqlParentOid}) even after backfilling the required commit objects. ` + `Root cause: ${retryOutput.trim()}`; + if (allowGitPushFallback === false) { + throw new Error(`${ERR_SYSTEM}: ${diagnosticMessage} Resolve conflicts by rebasing/cherry-picking locally and retry.`); + } + unsignedPushFallbackReason = diagnosticMessage; } } else { throw new Error( @@ -585,27 +629,52 @@ async function pushSignedCommits({ } else { try { await exec.exec("git", ["rebase", "--abort"], { cwd }); - } catch { - // Ignore cleanup failures. + } catch (abortError) { + // If the abort itself fails, HEAD can be left in a detached, partially-rebased or + // still-conflicted state. Continuing from there (falling through to the unsigned push + // fallback, or even the strict throw below) would risk operating on / pushing that + // broken worktree state, so this must be fatal rather than silently ignored. + throw new Error( + `${ERR_SYSTEM}: pushSignedCommits: failed to rebase commit range onto current GraphQL parent (${firstGraphqlParentOid}), and 'git rebase --abort' also failed to restore branch '${branch}' to its original state. ` + + `Root cause: ${combinedOutput.trim()}. Abort failure: ${getErrorMessage(abortError)}`, + { cause: abortError } + ); + } + const diagnosticMessage = `${ERR_SYSTEM}: pushSignedCommits: failed to rebase commit range onto current GraphQL parent (${firstGraphqlParentOid}). ` + `Root cause: ${combinedOutput.trim()}`; + if (allowGitPushFallback === false) { + throw new Error(`${ERR_SYSTEM}: ${diagnosticMessage} Resolve conflicts by rebasing/cherry-picking locally and retry.`); } - throw new Error( - `${ERR_SYSTEM}: pushSignedCommits: failed to rebase commit range onto current GraphQL parent (${firstGraphqlParentOid}). ` + `Resolve conflicts by rebasing/cherry-picking locally and retry. Root cause: ${combinedOutput.trim()}` - ); + // Genuine merge conflict (not a shallow/partial-clone object-fetch issue, and no custom + // resolver handled it): rebasing the commit range onto the current base cannot be done + // automatically, and replaying the stale-base commits through GraphQL would silently + // synthesize file content against the wrong parent. Rather than failing the whole + // operation (which previously forced callers to fall back to opening an issue instead + // of a pull request), fall through to push the ORIGINAL un-rebased commits directly via + // unsigned `git push` once they pass the same preflight validation (merge commit / + // unsupported file mode / file-protection policy) as any other unsigned push fallback. + // GitHub will still create the pull request; it will simply report the branch as having + // conflicts that need to be resolved, the same as any normal PR. + // `rebase --abort` (above) already restored HEAD and the branch to their original, + // un-rebased state, so revListEntries/shas (computed before the rebase attempt) still + // describe exactly what will be pushed — no need to recompute them. + unsignedPushFallbackReason = diagnosticMessage; } } - const { stdout: rebasedRevListOut } = await exec.getExecOutput("git", ["rev-list", "--parents", "--topo-order", "--reverse", `${firstGraphqlParentOid}..HEAD`], { cwd }); - revListEntries = rebasedRevListOut - .trim() - .split("\n") - .filter(Boolean) - .map(line => { - const fields = line.split(" "); - return { line, fields, sha: fields[0] }; - }); - shas = revListEntries.map(entry => entry.sha); - if (shas.length === 0) { - core.info("pushSignedCommits: no new commits to replay after rebase"); - return undefined; + if (!unsignedPushFallbackReason) { + const { stdout: rebasedRevListOut } = await exec.getExecOutput("git", ["rev-list", "--parents", "--topo-order", "--reverse", `${firstGraphqlParentOid}..HEAD`], { cwd }); + revListEntries = rebasedRevListOut + .trim() + .split("\n") + .filter(Boolean) + .map(line => { + const fields = line.split(" "); + return { line, fields, sha: fields[0] }; + }); + shas = revListEntries.map(entry => entry.sha); + if (shas.length === 0) { + core.info("pushSignedCommits: no new commits to replay after rebase"); + return undefined; + } } } @@ -739,6 +808,32 @@ async function pushSignedCommits({ deletionsMap.set(sha, deletions); } + // Enforce file-protection/size policy for every commit up front — before choosing between + // an unsigned push fallback and the GraphQL replay below — so a validationConfig-blocked + // file always refuses the unsigned push fallback, not just the GraphQL path. + for (const sha of shas) { + validateSynthesizedFileChanges(additionsMap.get(sha) || [], deletionsMap.get(sha) || [], validationConfig); + } + + if (unsignedPushFallbackReason) { + // All commits in the original, un-rebased range passed the same merge-commit, + // unsupported-file-mode, and file-protection-policy checks as the GraphQL replay path + // above; push them directly via unsigned `git push` instead of replaying through GraphQL + // (which would synthesize file content against the wrong parent). + core.warning(`${unsignedPushFallbackReason} Falling back to an unsigned git push of the un-rebased commit(s) so the pull request can still be created (it will show as having merge conflicts with the base branch).`); + try { + const fallbackSha = await pushBranchAndResolveHead({ branch, cwd, gitAuthEnv, pushRemoteUrl, pushToken }); + core.info(`pushSignedCommits: unsigned git push fallback (unresolved rebase conflict) completed, using pushed SHA ${fallbackSha}`); + return fallbackSha; + } catch (pushError) { + // The unsigned push itself can be rejected (e.g. by branch protection rules requiring + // signed commits). Surface a clear, combined error instead of letting the raw git-push + // failure — or a misleading "success" — propagate; this preserves the original + // throw-on-conflict behavior for repos where an unsigned push is not a viable fallback. + throw new PushSignedCommitsUnsignedFallbackFailed(`${ERR_SYSTEM}: ${unsignedPushFallbackReason} Unsigned git push fallback was also rejected: ${getErrorMessage(pushError)}`, { cause: pushError }); + } + } + // All commits passed the mode checks. Replay via GraphQL. /** @type {string | undefined} */ let lastOid; @@ -814,7 +909,6 @@ async function pushSignedCommits({ const additions = additionsMap.get(sha) || []; const deletions = deletionsMap.get(sha) || []; - validateSynthesizedFileChanges(additions, deletions, validationConfig); core.info(`pushSignedCommits: file changes: ${additions.length} addition(s), ${deletions.length} deletion(s)`); /** @type {any} */ @@ -842,6 +936,11 @@ async function pushSignedCommits({ core.info(`pushSignedCommits: all ${shas.length} commit(s) pushed as signed commits`); return lastOid ?? shas[shas.length - 1]; } catch (err) { + if (err instanceof PushSignedCommitsUnsignedFallbackFailed) { + // The unsigned push fallback for a genuine rebase conflict was already attempted (and + // rejected) above; re-throw as-is instead of retrying the same push again below. + throw err; + } if (err instanceof PushSignedCommitsUnsupportedShape) { throw new Error( `${ERR_VALIDATION}: pushSignedCommits: refusing unsigned push for branch '${branch}': ${stripLeadingErrorCode(getErrorMessage(err))}. ` + diff --git a/actions/setup/js/push_signed_commits.test.cjs b/actions/setup/js/push_signed_commits.test.cjs index 3a913776884..0bf3a33bbbb 100644 --- a/actions/setup/js/push_signed_commits.test.cjs +++ b/actions/setup/js/push_signed_commits.test.cjs @@ -1924,7 +1924,7 @@ describe("push_signed_commits integration tests", () => { }); describe("stale-base and synthesized payload safety", () => { - it("should fail signed replay when rebasing stale commits onto current base conflicts", async () => { + it("should fall back to an unsigned push of the un-rebased commits when rebasing stale commits onto current base conflicts", async () => { // Base branch starts with shared file. fs.writeFileSync(path.join(workDir, "shared.txt"), "base\n"); execGit(["add", "shared.txt"], { cwd: workDir }); @@ -1936,6 +1936,7 @@ describe("push_signed_commits integration tests", () => { fs.writeFileSync(path.join(workDir, "shared.txt"), "agent change\n"); execGit(["add", "shared.txt"], { cwd: workDir }); execGit(["commit", "-m", "Agent edit shared"], { cwd: workDir }); + const localOidBeforePush = execGit(["rev-parse", "HEAD"], { cwd: workDir }).stdout.trim(); // Base branch advances with conflicting edit. execGit(["checkout", "main"], { cwd: workDir }); @@ -1949,18 +1950,230 @@ describe("push_signed_commits integration tests", () => { global.exec = makeRealExec(workDir); const githubClient = makeMockGithubClient(); + const result = await pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: "stale-conflict-branch", + baseRef: "origin/main", + cwd: workDir, + }); + + // GraphQL is never invoked for this path — the un-rebased commits are pushed directly. + expect(githubClient.graphql).not.toHaveBeenCalled(); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("failed to rebase commit range onto current GraphQL parent")); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Falling back to an unsigned git push of the un-rebased commit(s)")); + + // The branch was pushed as-is (still based on the stale parent) so GitHub can create the + // pull request and surface the conflict for manual resolution, instead of the whole + // operation failing outright. + expect(result).toBe(localOidBeforePush); + const lsRemote = execGit(["ls-remote", bareDir, "refs/heads/stale-conflict-branch"], { cwd: workDir }); + const remoteOid = lsRemote.stdout.trim().split(/\s+/)[0]; + expect(remoteOid).toBe(localOidBeforePush); + }); + + it("should surface a combined error (not swallow it) when the unsigned push fallback is itself rejected, e.g. by branch protection requiring signed commits", async () => { + // Simulate a branch-protection rule that requires signed commits by rejecting every + // push with a pre-receive hook on the bare "remote" repo. + const preReceiveHookPath = path.join(bareDir, "hooks", "pre-receive"); + fs.writeFileSync( + preReceiveHookPath, + "#!/bin/sh\n" + + "while read oldrev newrev refname; do\n" + + ' case "$refname" in\n' + + " refs/heads/protected-conflict-branch)\n" + + ' echo "error: commits must be signed" >&2\n' + + " exit 1\n" + + " ;;\n" + + " esac\n" + + "done\n" + + "exit 0\n" + ); + fs.chmodSync(preReceiveHookPath, 0o755); + + // Base branch starts with shared file. + fs.writeFileSync(path.join(workDir, "shared.txt"), "base\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Add shared file"], { cwd: workDir }); + execGit(["push", "origin", "main"], { cwd: workDir }); + + // Agent branch diverges from old main and edits shared.txt. + execGit(["checkout", "-b", "protected-conflict-branch"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "shared.txt"), "agent change\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Agent edit shared"], { cwd: workDir }); + + // Base branch advances with conflicting edit. + execGit(["checkout", "main"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "shared.txt"), "upstream change\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Upstream edit shared"], { cwd: workDir }); + execGit(["push", "origin", "main"], { cwd: workDir }); + + execGit(["checkout", "protected-conflict-branch"], { cwd: workDir }); + + global.exec = makeRealExec(workDir); + const githubClient = makeMockGithubClient(); + + await expect( + pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: "protected-conflict-branch", + baseRef: "origin/main", + cwd: workDir, + }) + ).rejects.toThrow(/failed to rebase commit range onto current GraphQL parent[\s\S]*Unsigned git push fallback was also rejected/); + + expect(githubClient.graphql).not.toHaveBeenCalled(); + // The rejected push must not have landed on the "remote". + const lsRemote = execGit(["ls-remote", bareDir, "refs/heads/protected-conflict-branch"], { cwd: workDir }); + expect(lsRemote.stdout.trim()).toBe(""); + }); + + it("should refuse the unsigned push fallback (not push) when the un-rebased conflicting range violates a file-protection policy", async () => { + // Base branch starts with shared file. + fs.writeFileSync(path.join(workDir, "shared.txt"), "base\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Add shared file"], { cwd: workDir }); + execGit(["push", "origin", "main"], { cwd: workDir }); + + // Agent branch diverges from old main, edits shared.txt AND touches a protected file. + execGit(["checkout", "-b", "policy-conflict-branch"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "shared.txt"), "agent change\n"); + fs.writeFileSync(path.join(workDir, "CODEOWNERS"), "* @octocat\n"); + execGit(["add", "shared.txt", "CODEOWNERS"], { cwd: workDir }); + execGit(["commit", "-m", "Agent edit shared and touch CODEOWNERS"], { cwd: workDir }); + + // Base branch advances with conflicting edit. + execGit(["checkout", "main"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "shared.txt"), "upstream change\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Upstream edit shared"], { cwd: workDir }); + execGit(["push", "origin", "main"], { cwd: workDir }); + + execGit(["checkout", "policy-conflict-branch"], { cwd: workDir }); + + global.exec = makeRealExec(workDir); + const githubClient = makeMockGithubClient(); + + await expect( + pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: "policy-conflict-branch", + baseRef: "origin/main", + cwd: workDir, + validationConfig: { + protected_files: ["CODEOWNERS"], + protected_files_policy: "blocked", + }, + }) + ).rejects.toThrow("Signed-commit payload violates file-protection policy"); + + // The blocked commits must not be pushed unsigned just because the rebase conflicted. + expect(githubClient.graphql).not.toHaveBeenCalled(); + const lsRemote = execGit(["ls-remote", bareDir, "refs/heads/policy-conflict-branch"], { cwd: workDir }); + expect(lsRemote.stdout.trim()).toBe(""); + }); + + it("should still fail (without pushing) when git push fallback is explicitly disabled and rebasing stale commits conflicts", async () => { + // Base branch starts with shared file. + fs.writeFileSync(path.join(workDir, "shared.txt"), "base\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Add shared file"], { cwd: workDir }); + execGit(["push", "origin", "main"], { cwd: workDir }); + + // Agent branch diverges from old main and edits shared.txt. + execGit(["checkout", "-b", "stale-conflict-no-fallback-branch"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "shared.txt"), "agent change\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Agent edit shared"], { cwd: workDir }); + + // Base branch advances with conflicting edit. + execGit(["checkout", "main"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "shared.txt"), "upstream change\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Upstream edit shared"], { cwd: workDir }); + execGit(["push", "origin", "main"], { cwd: workDir }); + + execGit(["checkout", "stale-conflict-no-fallback-branch"], { cwd: workDir }); + + global.exec = makeRealExec(workDir); + const githubClient = makeMockGithubClient(); + await expect( pushSignedCommits({ githubClient, owner: "test-owner", repo: "test-repo", - branch: "stale-conflict-branch", + branch: "stale-conflict-no-fallback-branch", baseRef: "origin/main", cwd: workDir, + allowGitPushFallback: false, }) ).rejects.toThrow("failed to rebase commit range onto current GraphQL parent"); expect(githubClient.graphql).not.toHaveBeenCalled(); + const lsRemote = execGit(["ls-remote", bareDir, "refs/heads/stale-conflict-no-fallback-branch"], { cwd: workDir }); + expect(lsRemote.stdout.trim()).toBe(""); + }); + + it("should fail fatally (without attempting any push) when 'git rebase --abort' itself fails after a genuine conflict", async () => { + // Base branch starts with shared file. + fs.writeFileSync(path.join(workDir, "shared.txt"), "base\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Add shared file"], { cwd: workDir }); + execGit(["push", "origin", "main"], { cwd: workDir }); + + // Agent branch diverges from old main and edits shared.txt. + execGit(["checkout", "-b", "abort-failure-branch"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "shared.txt"), "agent change\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Agent edit shared"], { cwd: workDir }); + + // Base branch advances with conflicting edit. + execGit(["checkout", "main"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "shared.txt"), "upstream change\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Upstream edit shared"], { cwd: workDir }); + execGit(["push", "origin", "main"], { cwd: workDir }); + + execGit(["checkout", "abort-failure-branch"], { cwd: workDir }); + + // Wrap the real exec so that only "git rebase --abort" is forced to fail, simulating a + // broken worktree (e.g. a corrupted rebase-merge state) that prevents cleanup. + const realExec = makeRealExec(workDir); + global.exec = { + ...realExec, + exec: async (program, args, opts) => { + if (program === "git" && args[0] === "rebase" && args[1] === "--abort") { + throw new Error("simulated: could not restore original branch state"); + } + return realExec.exec(program, args, opts); + }, + }; + const githubClient = makeMockGithubClient(); + + await expect( + pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: "abort-failure-branch", + baseRef: "origin/main", + cwd: workDir, + }) + ).rejects.toThrow(/git rebase --abort.*also failed/); + + // Nothing must have been pushed — the abort failure must short-circuit before any + // unsigned-push fallback is attempted. + expect(githubClient.graphql).not.toHaveBeenCalled(); + const lsRemote = execGit(["ls-remote", bareDir, "refs/heads/abort-failure-branch"], { cwd: workDir }); + expect(lsRemote.stdout.trim()).toBe(""); }); it("should recover from a partial-clone object failure by backfilling the exact commit objects and retrying the rebase", async () => { @@ -2086,6 +2299,7 @@ describe("push_signed_commits integration tests", () => { branch: "conflict-no-backfill-branch", baseRef: "origin/main", cwd: workDir, + allowGitPushFallback: false, }) ).rejects.toThrow("failed to rebase commit range onto current GraphQL parent"); @@ -2094,6 +2308,78 @@ describe("push_signed_commits integration tests", () => { expect(githubClient.graphql).not.toHaveBeenCalled(); }); + it("should fall back to an unsigned push when a genuine merge conflict is only revealed after a successful object backfill", async () => { + // Base branch starts with a shared file. + fs.writeFileSync(path.join(workDir, "shared.txt"), "base\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Add shared file"], { cwd: workDir }); + execGit(["push", "origin", "main"], { cwd: workDir }); + + // Agent branch diverges and edits the shared file. + execGit(["checkout", "-b", "backfill-then-conflict-branch"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "shared.txt"), "agent change\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Agent edit shared"], { cwd: workDir }); + + // Base branch advances with a conflicting edit to the same file. + execGit(["checkout", "main"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "shared.txt"), "upstream change\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Upstream edit shared"], { cwd: workDir }); + execGit(["push", "origin", "main"], { cwd: workDir }); + + execGit(["checkout", "backfill-then-conflict-branch"], { cwd: workDir }); + + // Simulate the FIRST rebase attempt failing due to a promisor object-fetch + // failure (recoverable), the backfill "succeeding" (mocked), and then the + // SECOND (real) rebase attempt hitting the genuine content conflict. + const realExec = makeRealExec(workDir); + let rebaseAttempts = 0; + global.exec = { + getExecOutput: async (program, args, opts = {}) => { + if (program === "git" && args[0] === "rebase" && args[1] === "--onto") { + rebaseAttempts++; + if (rebaseAttempts === 1) { + return { + exitCode: 128, + stdout: "", + stderr: "fatal: remote error: upload-pack: not our ref 0035eb55fe03ab52d8b95e7fcfaee53548b5e8d6\nfatal: could not fetch 4f0af08119278bacff5772a1ddf987d4b4045be8 from promisor remote\n", + }; + } + } + if (program === "git" && args[0] === "fetch" && args.includes("--no-filter")) { + return { exitCode: 0, stdout: "", stderr: "" }; + } + return realExec.getExecOutput(program, args, opts); + }, + exec: realExec.exec, + }; + + const githubClient = makeMockGithubClient(); + + const result = await pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: "backfill-then-conflict-branch", + baseRef: "origin/main", + cwd: workDir, + }); + + // The rebase was attempted twice (initial promisor failure + post-backfill + // retry, which then hits the real content conflict). + expect(rebaseAttempts).toBe(2); + // The genuine post-backfill conflict falls back to an unsigned push instead + // of throwing, and no GraphQL replay is attempted. + expect(githubClient.graphql).not.toHaveBeenCalled(); + expect(result).toBeDefined(); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("even after backfilling the required commit objects")); + + // The original (un-rebased) agent commit landed on the remote as-is. + const remoteLog = execGit(["log", "--format=%s", "backfill-then-conflict-branch"], { cwd: bareDir }).stdout.trim().split("\n"); + expect(remoteLog).toContain("Agent edit shared"); + }); + it("should merge state.json conflicts when a custom resolver is provided", async () => { const concurrentDir = fs.mkdtempSync(path.join(os.tmpdir(), "push-signed-concurrent-")); try {