diff --git a/.changeset/patch-push-head-only-filtered-bundles.md b/.changeset/patch-push-head-only-filtered-bundles.md new file mode 100644 index 00000000000..291f93cbcbe --- /dev/null +++ b/.changeset/patch-push-head-only-filtered-bundles.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Fix `push_to_pull_request_branch` failing to apply filtered git bundles that advertise only `HEAD` instead of the target branch ref. diff --git a/actions/setup/js/push_to_pull_request_branch.cjs b/actions/setup/js/push_to_pull_request_branch.cjs index bb58d5a1247..7f101bd2684 100644 --- a/actions/setup/js/push_to_pull_request_branch.cjs +++ b/actions/setup/js/push_to_pull_request_branch.cjs @@ -118,6 +118,22 @@ async function getBundlePreApplyFiles(exec, gitOptions, rangeBaseRef, bundleRef) return bundleDiffResult.stdout.split("\0").filter(Boolean); } +async function fetchBundlePrerequisites(exec, core, gitAuthEnv, baseGitOpts, prerequisiteCommits, logPrefix = "") { + core.warning(`${logPrefix}bundle fetch failed due to ${prerequisiteCommits.length} missing prerequisite commit(s); fetching prerequisites from origin and retrying`); + core.info(`${logPrefix}fetching ${prerequisiteCommits.length} prerequisite commit(s) from origin`); + // Use --filter=blob:none only when the local repo is already shallow or sparse — + // in a full clone we already have all blobs and must not convert the repo to a + // partial clone (which would trigger lazy blob fetches on later operations). + const prereqGitOpts = { env: { ...process.env, ...gitAuthEnv }, ...baseGitOpts }; + const useBlobFilter = await isShallowOrSparseCheckout(exec, prereqGitOpts); + const prerequisiteFetchArgs = useBlobFilter ? ["fetch", "--filter=blob:none", "origin", ...prerequisiteCommits] : ["fetch", "origin", ...prerequisiteCommits]; + if (useBlobFilter) { + core.info(`${logPrefix}using --filter=blob:none for prerequisite fetch (shallow or sparse checkout detected)`); + } + await exec.exec("git", prerequisiteFetchArgs, prereqGitOpts); + core.info(`${logPrefix}fetched prerequisite commits from origin successfully`); +} + /** * Measure the expanded blob size of files changed by the applied agent commits. * Deleted files contribute zero bytes because no new content is being introduced. @@ -1026,23 +1042,54 @@ async function main(config = {}) { // (e.g. when the commit is on a ref not in the fetch refspec). const prerequisiteCommits = extractBundlePrerequisiteCommits(initialFetchErrorOutput); if (prerequisiteCommits.length > 0) { - core.warning(`Bundle fetch failed due to ${prerequisiteCommits.length} missing prerequisite commit(s); fetching prerequisites from origin and retrying`); - core.info(`Fetching ${prerequisiteCommits.length} prerequisite commit(s) from origin`); - // Use --filter=blob:none only when the local repo is already shallow or sparse — - // in a full clone we already have all blobs and must not convert the repo to a - // partial clone (which would trigger lazy blob fetches on later operations). - const prereqGitOpts = { env: { ...process.env, ...gitAuthEnv }, ...baseGitOpts }; - const useBlobFilter = await isShallowOrSparseCheckout(exec, prereqGitOpts); - const prerequisiteFetchArgs = useBlobFilter ? ["fetch", "--filter=blob:none", "origin", ...prerequisiteCommits] : ["fetch", "origin", ...prerequisiteCommits]; - if (useBlobFilter) { - core.info("Using --filter=blob:none for prerequisite fetch (shallow or sparse checkout detected)"); - } - await exec.exec("git", prerequisiteFetchArgs, prereqGitOpts); - core.info("Fetched prerequisite commits from origin successfully"); + await fetchBundlePrerequisites(exec, core, gitAuthEnv, baseGitOpts, prerequisiteCommits); await exec.exec("git", ["fetch", bundleFilePath, bundleFetchRef], baseGitOpts); core.info("Bundle fetch retry succeeded after prerequisite recovery"); } else { - throw new Error(`Failed to fetch bundle: ${initialFetchErrorOutput}`); + core.warning(`Bundle fetch from refs/heads/${branchName} failed: ${initialFetchErrorOutput}; resolving source ref from bundle heads`); + const { stdout: bundleHeadsOutput } = await exec.getExecOutput("git", ["bundle", "list-heads", bundleFilePath], baseGitOpts); + const bundleHeads = bundleHeadsOutput + .split("\n") + .map(line => line.trim().split(/\s+/)) + // Bundles produced here advertise SHA-1 object IDs; reject malformed entries. + .filter(parts => parts.length === 2 && /^[0-9a-f]{40}$/.test(parts[0]) && parts[1]); + const branchRefChecks = await Promise.all( + bundleHeads + .filter(([, ref]) => ref.startsWith("refs/heads/")) + .map(async ([, ref]) => ({ + ref, + isValid: (await exec.getExecOutput("git", ["check-ref-format", ref], { ...baseGitOpts, ignoreReturnCode: true })).exitCode === 0, + })) + ); + const branchRefs = branchRefChecks.filter(({ isValid }) => isValid).map(({ ref }) => ref); + + let bundleSourceRef; + if (branchRefs.length === 1) { + bundleSourceRef = branchRefs[0]; + } else if (branchRefs.length === 0) { + const headRefs = bundleHeads.filter(([, ref]) => ref === "HEAD"); + if (headRefs.length !== 1) { + throw new Error(`Failed to resolve bundle source ref from list-heads: expected exactly 1 HEAD entry, found ${headRefs.length}`); + } + bundleSourceRef = "HEAD"; + } else { + throw new Error(`Failed to resolve bundle source ref from list-heads: expected exactly 1 refs/heads entry, found ${branchRefs.length}`); + } + + core.info(`Fetching resolved bundle source ${bundleSourceRef} into ${bundleRef}`); + const resolvedBundleFetchRef = `${bundleSourceRef}:${bundleRef}`; + const resolvedBundleFetch = await exec.getExecOutput("git", ["fetch", bundleFilePath, resolvedBundleFetchRef], { ...baseGitOpts, ignoreReturnCode: true }); + if (resolvedBundleFetch.exitCode !== 0) { + const resolvedFetchErrorOutput = resolvedBundleFetch.stderr || `exit code ${resolvedBundleFetch.exitCode}`; + const resolvedPrerequisiteCommits = extractBundlePrerequisiteCommits(resolvedFetchErrorOutput); + if (resolvedPrerequisiteCommits.length === 0) { + throw new Error(`Failed to fetch resolved bundle source ${bundleSourceRef}: ${resolvedFetchErrorOutput}`); + } + + await fetchBundlePrerequisites(exec, core, gitAuthEnv, baseGitOpts, resolvedPrerequisiteCommits, "[resolved] "); + await exec.exec("git", ["fetch", bundleFilePath, resolvedBundleFetchRef], baseGitOpts); + core.info("Resolved bundle fetch retry succeeded after prerequisite recovery"); + } } } core.info(`Fetched bundle to ${bundleRef}`); diff --git a/actions/setup/js/push_to_pull_request_branch.integration.test.cjs b/actions/setup/js/push_to_pull_request_branch.integration.test.cjs index f8535aa1233..30d15b1a122 100644 --- a/actions/setup/js/push_to_pull_request_branch.integration.test.cjs +++ b/actions/setup/js/push_to_pull_request_branch.integration.test.cjs @@ -104,6 +104,38 @@ describe("push_to_pull_request_branch bundle integration", () => { expect(actualFiles.sort()).toEqual([".changeset/fix.md", "docs/guide.md"]); }); + it("fetches a HEAD-only bundle after its named branch ref is absent", () => { + const branchName = "autoloop/head-only-bundle"; + const sourceRepo = createRepo("push-pr-head-only-source-"); + const targetRepo = createRepo("push-pr-head-only-target-"); + tempDirs.push(sourceRepo, targetRepo); + + writeRepoFile(sourceRepo, "README.md", "base\n"); + execGit(["add", "README.md"], { cwd: sourceRepo }); + execGit(["commit", "-m", "base"], { cwd: sourceRepo }); + execGit(["branch", "-M", "main"], { cwd: sourceRepo }); + const baseSha = execGit(["rev-parse", "HEAD"], { cwd: sourceRepo }).stdout.trim(); + + execGit(["checkout", "-b", branchName], { cwd: sourceRepo }); + writeRepoFile(sourceRepo, "head-only.txt", "bundle change\n"); + execGit(["add", "head-only.txt"], { cwd: sourceRepo }); + execGit(["commit", "-m", "HEAD-only bundle change"], { cwd: sourceRepo }); + const bundleHead = execGit(["rev-parse", "HEAD"], { cwd: sourceRepo }).stdout.trim(); + + const bundlePath = path.join(sourceRepo, "head-only.bundle"); + execGit(["bundle", "create", bundlePath, "HEAD"], { cwd: sourceRepo }); + + fetchBaseCommit(targetRepo, sourceRepo, baseSha, branchName); + const bundleRef = "refs/bundles/test-head-only-bundle"; + const namedRefFetch = execGit(["fetch", bundlePath, `refs/heads/${branchName}:${bundleRef}`], { cwd: targetRepo, allowFailure: true }); + expect(namedRefFetch.status).not.toBe(0); + expect(execGit(["bundle", "list-heads", bundlePath], { cwd: targetRepo }).stdout).toBe(`${bundleHead} HEAD\n`); + + execGit(["fetch", bundlePath, `HEAD:${bundleRef}`], { cwd: targetRepo }); + + expect(execGit(["rev-parse", bundleRef], { cwd: targetRepo }).stdout.trim()).toBe(bundleHead); + }); + it("includes files introduced through merge-commit bundle history", async () => { const branchName = "autoloop/merge-bundle"; const sourceRepo = createRepo("push-pr-merge-source-"); diff --git a/actions/setup/js/push_to_pull_request_branch.test.cjs b/actions/setup/js/push_to_pull_request_branch.test.cjs index a7ffb4be824..b2b81413740 100644 --- a/actions/setup/js/push_to_pull_request_branch.test.cjs +++ b/actions/setup/js/push_to_pull_request_branch.test.cjs @@ -2342,6 +2342,192 @@ index 0000000..abc1234 } }); + it("should fetch a HEAD-only filtered bundle when the named branch ref is absent", async () => { + const bundlePath = canonicalBundlePath("feature-branch"); + const patchPath = createPatchFile("feature-branch", "small patch content"); + fs.writeFileSync(bundlePath, "bundle content"); + const bundleHead = "4f80191700da9afc6d0b20b9ec6c81fb376f8714"; + const prerequisiteSha = "e226f0c6c0f3e37d601fb64cf101fe11de68f7b9"; + + const pushSignedCommitsModule = require("./push_signed_commits.cjs"); + const pushSignedSpy = vi.spyOn(pushSignedCommitsModule, "pushSignedCommits").mockResolvedValue(bundleHead); + + try { + mockExec.getExecOutput.mockImplementation((cmd, args, options) => { + if (cmd === "git" && args[0] === "ls-remote") { + return Promise.resolve({ exitCode: 0, stdout: "remote-head\trefs/heads/feature-branch\n", stderr: "" }); + } + if (cmd === "git" && args[0] === "rev-parse" && args[1] === "HEAD") { + return Promise.resolve({ exitCode: 0, stdout: "remote-head\n", stderr: "" }); + } + 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] === "fetch" && args[1] === bundlePath && args[2].startsWith("refs/heads/") && options && options.ignoreReturnCode) { + return Promise.resolve({ exitCode: 128, stdout: "", stderr: "fatal: couldn't find remote ref refs/heads/feature-branch" }); + } + if (cmd === "git" && args[0] === "bundle" && args[1] === "list-heads") { + return Promise.resolve({ exitCode: 0, stdout: `${bundleHead} HEAD\n`, stderr: "" }); + } + if (cmd === "git" && args[0] === "fetch" && args[1] === bundlePath && args[2].startsWith("HEAD:") && options && options.ignoreReturnCode) { + return Promise.resolve({ exitCode: 1, stdout: "", stderr: `error: Repository lacks these prerequisite commits:\nerror: ${prerequisiteSha}` }); + } + if (cmd === "git" && args[0] === "rev-list") { + return Promise.resolve({ exitCode: 0, stdout: "1\n", stderr: "" }); + } + return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + }); + + const module = await loadModule(); + const handler = await module.main({}); + const result = await handler({ branch: "feature-branch", diff_size: 5 * 1024 }, {}); + + expect(result.success).toBe(true); + expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["bundle", "list-heads", bundlePath], expect.any(Object)); + expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["fetch", bundlePath, "HEAD:refs/bundles/push-feature-branch"], expect.objectContaining({ ignoreReturnCode: true })); + expect(mockExec.exec).toHaveBeenCalledWith("git", ["fetch", "--filter=blob:none", "origin", prerequisiteSha], expect.any(Object)); + expect(mockExec.exec).toHaveBeenCalledWith("git", ["fetch", bundlePath, "HEAD:refs/bundles/push-feature-branch"], expect.any(Object)); + } finally { + pushSignedSpy.mockRestore(); + } + }); + + it("should accept a valid bundle branch ref that contains a plus sign", async () => { + const bundlePath = canonicalBundlePath("feature-branch"); + createPatchFile("feature-branch", "small patch content"); + fs.writeFileSync(bundlePath, "bundle content"); + const bundleHead = "4f80191700da9afc6d0b20b9ec6c81fb376f8714"; + const bundleSourceRef = "refs/heads/feature+fix"; + + const pushSignedCommitsModule = require("./push_signed_commits.cjs"); + const pushSignedSpy = vi.spyOn(pushSignedCommitsModule, "pushSignedCommits").mockResolvedValue(bundleHead); + + try { + mockExec.getExecOutput.mockImplementation((cmd, args, options) => { + if (cmd === "git" && args[0] === "ls-remote") { + return Promise.resolve({ exitCode: 0, stdout: "remote-head\trefs/heads/feature-branch\n", stderr: "" }); + } + if (cmd === "git" && args[0] === "rev-parse" && args[1] === "HEAD") { + return Promise.resolve({ exitCode: 0, stdout: "remote-head\n", stderr: "" }); + } + if (cmd === "git" && args[0] === "rev-parse" && args[1] === "--is-shallow-repository") { + return Promise.resolve({ exitCode: 0, stdout: "false\n", stderr: "" }); + } + if (cmd === "git" && args[0] === "fetch" && args[1] === bundlePath && args[2].startsWith("refs/heads/feature-branch:") && options?.ignoreReturnCode) { + return Promise.resolve({ exitCode: 128, stdout: "", stderr: "fatal: couldn't find remote ref refs/heads/feature-branch" }); + } + if (cmd === "git" && args[0] === "bundle" && args[1] === "list-heads") { + return Promise.resolve({ exitCode: 0, stdout: `${bundleHead} ${bundleSourceRef}\n`, stderr: "" }); + } + if (cmd === "git" && args[0] === "check-ref-format") { + return Promise.resolve({ exitCode: 0, 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: "" }); + }); + + const module = await loadModule(); + const handler = await module.main({}); + const result = await handler({ branch: "feature-branch", diff_size: 5 * 1024 }, {}); + + expect(result.success).toBe(true); + expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["check-ref-format", bundleSourceRef], expect.objectContaining({ ignoreReturnCode: true })); + expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["fetch", bundlePath, `${bundleSourceRef}:refs/bundles/push-feature-branch`], expect.objectContaining({ ignoreReturnCode: true })); + } finally { + pushSignedSpy.mockRestore(); + } + }); + + it("should ignore an invalid bundle branch ref when resolving a HEAD-only bundle", async () => { + const bundlePath = canonicalBundlePath("feature-branch"); + createPatchFile("feature-branch", "small patch content"); + fs.writeFileSync(bundlePath, "bundle content"); + const bundleHead = "4f80191700da9afc6d0b20b9ec6c81fb376f8714"; + const invalidBranchRef = "refs/heads/foo..bar"; + + const pushSignedCommitsModule = require("./push_signed_commits.cjs"); + const pushSignedSpy = vi.spyOn(pushSignedCommitsModule, "pushSignedCommits").mockResolvedValue(bundleHead); + + try { + mockExec.getExecOutput.mockImplementation((cmd, args, options) => { + if (cmd === "git" && args[0] === "ls-remote") { + return Promise.resolve({ exitCode: 0, stdout: "remote-head\trefs/heads/feature-branch\n", stderr: "" }); + } + if (cmd === "git" && args[0] === "rev-parse" && args[1] === "HEAD") { + return Promise.resolve({ exitCode: 0, stdout: "remote-head\n", stderr: "" }); + } + if (cmd === "git" && args[0] === "rev-parse" && args[1] === "--is-shallow-repository") { + return Promise.resolve({ exitCode: 0, stdout: "false\n", stderr: "" }); + } + if (cmd === "git" && args[0] === "fetch" && args[1] === bundlePath && args[2].startsWith("refs/heads/feature-branch:") && options?.ignoreReturnCode) { + return Promise.resolve({ exitCode: 128, stdout: "", stderr: "fatal: couldn't find remote ref refs/heads/feature-branch" }); + } + if (cmd === "git" && args[0] === "bundle" && args[1] === "list-heads") { + return Promise.resolve({ exitCode: 0, stdout: `${bundleHead} ${invalidBranchRef}\n${bundleHead} HEAD\n`, stderr: "" }); + } + if (cmd === "git" && args[0] === "check-ref-format") { + return Promise.resolve({ exitCode: 1, stdout: "", stderr: "invalid ref" }); + } + if (cmd === "git" && args[0] === "rev-list") { + return Promise.resolve({ exitCode: 0, stdout: "1\n", stderr: "" }); + } + return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + }); + + const module = await loadModule(); + const handler = await module.main({}); + const result = await handler({ branch: "feature-branch", diff_size: 5 * 1024 }, {}); + + expect(result.success).toBe(true); + expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["fetch", bundlePath, "HEAD:refs/bundles/push-feature-branch"], expect.objectContaining({ ignoreReturnCode: true })); + } finally { + pushSignedSpy.mockRestore(); + } + }); + + it("should reject ambiguous valid bundle branch refs", async () => { + const bundlePath = canonicalBundlePath("feature-branch"); + createPatchFile("feature-branch", "small patch content"); + fs.writeFileSync(bundlePath, "bundle content"); + const bundleHead = "4f80191700da9afc6d0b20b9ec6c81fb376f8714"; + const bundleSourceRefs = ["refs/heads/feature-one", "refs/heads/feature-two"]; + + const pushSignedCommitsModule = require("./push_signed_commits.cjs"); + const pushSignedSpy = vi.spyOn(pushSignedCommitsModule, "pushSignedCommits").mockResolvedValue(bundleHead); + + try { + mockExec.getExecOutput.mockImplementation((cmd, args, options) => { + if (cmd === "git" && args[0] === "ls-remote") { + return Promise.resolve({ exitCode: 0, stdout: "remote-head\trefs/heads/feature-branch\n", stderr: "" }); + } + if (cmd === "git" && args[0] === "rev-parse" && args[1] === "HEAD") { + return Promise.resolve({ exitCode: 0, stdout: "remote-head\n", stderr: "" }); + } + if (cmd === "git" && args[0] === "fetch" && args[1] === bundlePath && args[2].startsWith("refs/heads/feature-branch:") && options?.ignoreReturnCode) { + return Promise.resolve({ exitCode: 128, stdout: "", stderr: "fatal: couldn't find remote ref refs/heads/feature-branch" }); + } + if (cmd === "git" && args[0] === "bundle" && args[1] === "list-heads") { + return Promise.resolve({ exitCode: 0, stdout: bundleSourceRefs.map(ref => `${bundleHead} ${ref}`).join("\n"), stderr: "" }); + } + if (cmd === "git" && args[0] === "check-ref-format") { + return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + } + return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + }); + + const module = await loadModule(); + const handler = await module.main({}); + const result = await handler({ branch: "feature-branch", diff_size: 5 * 1024 }, {}); + + expect(result.success).toBe(false); + expect(result.error).toContain("expected exactly 1 refs/heads entry, found 2"); + } finally { + pushSignedSpy.mockRestore(); + } + }); + it("should fetch prerequisite commits and retry bundle fetch when bundle lacks prerequisites", async () => { const bundlePath = canonicalBundlePath("feature-branch"); const patchPath = createPatchFile("feature-branch", "small patch content");