From 08afd2ea641fc4087d9cedfbbc331d24af3ae8ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:59:09 +0000 Subject: [PATCH 1/4] Initial plan From 0da47538cd3ea34d2c8b1458fed57004cea95c00 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:10:23 +0000 Subject: [PATCH 2/4] Base full-mode patch/bundle on GITHUB_SHA for non-default-branch runs Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- ...patch-fix-non-default-branch-patch-base.md | 11 ++ actions/setup/js/generate_git_bundle.cjs | 14 ++- actions/setup/js/generate_git_patch.cjs | 61 ++++++++-- .../setup/js/git_patch_integration.test.cjs | 113 ++++++++++++++++++ actions/setup/js/git_patch_utils.cjs | 57 +++++++++ 5 files changed, 247 insertions(+), 9 deletions(-) create mode 100644 .changeset/patch-fix-non-default-branch-patch-base.md diff --git a/.changeset/patch-fix-non-default-branch-patch-base.md b/.changeset/patch-fix-non-default-branch-patch-base.md new file mode 100644 index 00000000000..f1dcf526694 --- /dev/null +++ b/.changeset/patch-fix-non-default-branch-patch-base.md @@ -0,0 +1,11 @@ +--- +"gh-aw": patch +--- + +Fixed `create_pull_request` transport artifacts (patch and bundle) being based on the merge-base with the default branch when a workflow runs from a ref that is not contained in the default branch (for example a `workflow_dispatch` on a feature branch). + +In that situation the merge-base is far behind the checked-out commit, so the generated patch/bundle contained every commit of the dispatched branch in addition to the agent's own commits. On a partial clone (a `blob:none` fetch marks `origin` as a promisor remote) it failed outright: diffing from the older base required base-side blobs that were never fetched, and the lazy hydration fetch is unauthenticated because gh-aw checks out with `persist-credentials: false`. + +Full mode now prefers `GITHUB_SHA` as the base when it is an ancestor of the agent branch but is not contained in the default branch. The patch then holds exactly the agent's commits and needs no objects beyond the checkout. Runs from the default branch are unchanged, since there the merge-base already equals `GITHUB_SHA`. + +Also improved the diagnostics: branch existence is now determined from local refs only, so a network or authentication failure is no longer reported as `Branch 'X' does not exist locally`, and failures that look like a failed lazy object fetch on a partial clone now say so explicitly. diff --git a/actions/setup/js/generate_git_bundle.cjs b/actions/setup/js/generate_git_bundle.cjs index 8b99a3b68f6..f17ea9c775f 100644 --- a/actions/setup/js/generate_git_bundle.cjs +++ b/actions/setup/js/generate_git_bundle.cjs @@ -13,6 +13,8 @@ const path = require("path"); const { getErrorMessage } = require("./error_helpers.cjs"); const { generateGitPatch } = require("./generate_git_patch.cjs"); const { ensureOriginRemoteTrackingRef, execGitSync } = require("./git_helpers.cjs"); +const { isAncestorCommit } = require("./git_patch_utils.cjs"); +const { normalizeCommitSHA } = require("./commit_sha_helpers.cjs"); const { ERR_SYSTEM } = require("./error_codes.cjs"); /** @@ -209,7 +211,17 @@ async function generateGitBundle(branchName, baseBranch, options = {}) { ); } - if (hasLocalDefaultBranch) { + // When the workflow runs from a ref that is not contained in the default + // branch (e.g. a workflow_dispatch on a feature branch), the merge-base with + // the default branch is far behind the checked-out commit: the bundle would + // then carry the dispatched branch's own commits instead of only the agent's, + // and on a partial clone it needs base-side objects that were never fetched. + // GITHUB_SHA is the commit the agent started from and is fully local. + const dispatchedSha = normalizeCommitSHA(githubSha); + if (hasLocalDefaultBranch && dispatchedSha && isAncestorCommit(dispatchedSha, branchName, cwd) && !isAncestorCommit(dispatchedSha, `origin/${defaultBranch}`, cwd)) { + baseRef = dispatchedSha; + debugLog(`Strategy 1 (full): GITHUB_SHA ${dispatchedSha} is not contained in origin/${defaultBranch} (non-default-branch run); using it as the bundle base instead of the merge-base`); + } else if (hasLocalDefaultBranch) { baseRef = execGitSync(["merge-base", "--", `origin/${defaultBranch}`, branchName], { cwd }).trim(); debugLog(`Strategy 1 (full): Computed merge-base: ${baseRef}`); } else { diff --git a/actions/setup/js/generate_git_patch.cjs b/actions/setup/js/generate_git_patch.cjs index a1903e8f3e7..1df13c50a04 100644 --- a/actions/setup/js/generate_git_patch.cjs +++ b/actions/setup/js/generate_git_patch.cjs @@ -12,7 +12,17 @@ const path = require("path"); const { getErrorMessage } = require("./error_helpers.cjs"); const { ensureOriginRemoteTrackingRef, execGitSync } = require("./git_helpers.cjs"); const { ERR_SYSTEM } = require("./error_codes.cjs"); -const { sanitizeForFilename, sanitizeBranchNameForPatch, sanitizeRepoSlugForPatch, getPatchPathForBranch, getPatchPathForBranchInRepo, buildExcludePathspecs, computeIncrementalDiffSize } = require("./git_patch_utils.cjs"); +const { + sanitizeForFilename, + sanitizeBranchNameForPatch, + sanitizeRepoSlugForPatch, + getPatchPathForBranch, + getPatchPathForBranchInRepo, + buildExcludePathspecs, + computeIncrementalDiffSize, + isAncestorCommit, + describeGitFailure, +} = require("./git_patch_utils.cjs"); const { normalizeCommitSHA } = require("./commit_sha_helpers.cjs"); // sanitizeForFilename is re-exported below for backward compatibility with @@ -178,8 +188,10 @@ async function generateGitPatch(branchName, baseBranch, options = {}) { debugLog(`Strategy 1: Using pinned SHA ${options.pinnedSha} (branch: ${branchName})`); } else { debugLog(`Strategy 1: Checking if branch '${branchName}' exists locally`); - // Check if the branch exists locally - execGitSync(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], { cwd }); + // Check if the branch exists locally. This is a local-ref lookup only: + // it never touches the network, so a failure here always means "no such + // local branch" and never an auth/network problem. + execGitSync(["rev-parse", "--verify", "--quiet", `refs/heads/${branchName}`], { cwd }); debugLog(`Strategy 1: Branch '${branchName}' exists locally`); } @@ -263,7 +275,19 @@ async function generateGitPatch(branchName, baseBranch, options = {}) { } } - if (defaultBranchRef) { + // When the workflow runs from a ref that is not contained in the default + // branch (the general case for a workflow_dispatch on a feature branch), + // the merge-base with the default branch is far behind the checked-out + // commit. Basing the patch there would include the dispatched branch's own + // commits instead of only the agent's, and on a partial clone it requires + // base-side blobs that were never fetched (the lazy fetch is unauthenticated + // and fails). GITHUB_SHA is the commit the agent started from and every + // object it needs is already present in the checkout. + const dispatchedSha = normalizeCommitSHA(githubSha); + if (defaultBranchRef && dispatchedSha && isAncestorCommit(dispatchedSha, tipRef, cwd) && !isAncestorCommit(dispatchedSha, defaultBranchRef, cwd)) { + baseRef = dispatchedSha; + debugLog(`Strategy 1 (full): GITHUB_SHA ${dispatchedSha} is not contained in ${defaultBranchRef} (non-default-branch run); using it as the patch base instead of the merge-base`); + } else if (defaultBranchRef) { try { baseRef = execGitSync(["merge-base", "--", defaultBranchRef, tipRef], { cwd }).trim(); } catch (mergeBaseError) { @@ -337,13 +361,27 @@ async function generateGitPatch(branchName, baseBranch, options = {}) { }; } } catch (branchError) { - // Branch does not exist locally (or pinnedSha failed) - debugLog(`Strategy 1: Branch '${branchName}' does not exist locally - ${getErrorMessage(branchError)}`); + // Strategy 1 failed. Determine branch existence from local refs only so a + // network/auth failure (e.g. a lazy blob fetch on a partial clone) is never + // reported as a missing local branch. + let branchExistsLocally = false; + try { + execGitSync(["rev-parse", "--verify", "--quiet", `refs/heads/${branchName}`], { cwd, suppressLogs: true }); + branchExistsLocally = true; + } catch { + // Branch really is absent from the local refs + } + const branchErrorMessage = describeGitFailure(getErrorMessage(branchError), cwd); + if (branchExistsLocally) { + debugLog(`Strategy 1: Failed to generate patch for branch '${branchName}' (branch exists locally) - ${branchErrorMessage}`); + } else { + debugLog(`Strategy 1: Branch '${branchName}' does not exist locally - ${branchErrorMessage}`); + } // Shallow-clone diagnostics (thrown explicitly from the merge-base block // above, marked with isShallowCloneDiagnostic) must reach callers immediately — // falling through to Strategy 2 or 3 would produce a misleading "No changes // to commit" result instead. Other ERR_SYSTEM-prefixed errors (e.g. an - // expected "branch not found" failure from show-ref/rev-parse) must still + // expected "branch not found" failure from rev-parse) must still // fall through to the later strategies. if (branchError && branchError.isShallowCloneDiagnostic) { return { @@ -357,11 +395,18 @@ async function generateGitPatch(branchName, baseBranch, options = {}) { // other strategies that would resolve a different commit. return { success: false, - error: `Pinned SHA ${options.pinnedSha} failed to generate patch: ${getErrorMessage(branchError)}`, + error: `Pinned SHA ${options.pinnedSha} failed to generate patch: ${branchErrorMessage}`, patchPath: patchPath, }; } if (mode === "incremental") { + if (branchExistsLocally) { + return { + success: false, + error: `Cannot generate incremental patch for branch ${branchName} in checkout '${cwd}': ${branchErrorMessage}`, + patchPath: patchPath, + }; + } return { success: false, error: diff --git a/actions/setup/js/git_patch_integration.test.cjs b/actions/setup/js/git_patch_integration.test.cjs index e41331647d2..81e6339580e 100644 --- a/actions/setup/js/git_patch_integration.test.cjs +++ b/actions/setup/js/git_patch_integration.test.cjs @@ -952,6 +952,119 @@ describe("git patch integration tests", () => { } }); + it("should base the full-mode patch on GITHUB_SHA when dispatched from a non-default branch", async () => { + // Simulate workflow_dispatch from a branch that is ahead of main: the + // dispatched branch has commits that are not on main, and the agent adds + // one commit on top of it. + execGit(["checkout", "-b", "dispatch-branch"], { cwd: workingRepo }); + fs.writeFileSync(path.join(workingRepo, "dispatch1.txt"), "dispatched work 1\n"); + execGit(["add", "dispatch1.txt"], { cwd: workingRepo }); + execGit(["commit", "-m", "Dispatched branch commit 1"], { cwd: workingRepo }); + fs.writeFileSync(path.join(workingRepo, "dispatch2.txt"), "dispatched work 2\n"); + execGit(["add", "dispatch2.txt"], { cwd: workingRepo }); + execGit(["commit", "-m", "Dispatched branch commit 2"], { cwd: workingRepo }); + + const dispatchedSha = execGit(["rev-parse", "HEAD"], { cwd: workingRepo }).stdout.trim(); + + // Agent branch created from the dispatched commit with a single commit + execGit(["checkout", "-b", "agent-fix/example"], { cwd: workingRepo }); + fs.writeFileSync(path.join(workingRepo, "agent.txt"), "agent change\n"); + execGit(["add", "agent.txt"], { cwd: workingRepo }); + execGit(["commit", "-m", "Agent commit on dispatched branch"], { cwd: workingRepo }); + + execGit(["fetch", "origin", "main"], { cwd: workingRepo }); + + const origSha = process.env.GITHUB_SHA; + process.env.GITHUB_SHA = dispatchedSha; + const restore = setTestEnv(workingRepo); + try { + const result = await generateGitPatch("agent-fix/example", "main", { mode: "full" }); + + expect(result.success).toBe(true); + expect(result.baseCommit).toBe(dispatchedSha); + + const patchContent = fs.readFileSync(result.patchPath, "utf8"); + expect(patchContent).toContain("Agent commit on dispatched branch"); + expect(patchContent).not.toContain("Dispatched branch commit 1"); + expect(patchContent).not.toContain("Dispatched branch commit 2"); + } finally { + if (origSha === undefined) { + delete process.env.GITHUB_SHA; + } else { + process.env.GITHUB_SHA = origSha; + } + restore(); + } + }); + + it("should still use the merge-base in full mode when GITHUB_SHA is on the default branch", async () => { + const mainSha = execGit(["rev-parse", "main"], { cwd: workingRepo }).stdout.trim(); + + execGit(["checkout", "-b", "default-dispatch-branch"], { cwd: workingRepo }); + fs.writeFileSync(path.join(workingRepo, "agent1.txt"), "agent change 1\n"); + execGit(["add", "agent1.txt"], { cwd: workingRepo }); + execGit(["commit", "-m", "Agent commit one"], { cwd: workingRepo }); + fs.writeFileSync(path.join(workingRepo, "agent2.txt"), "agent change 2\n"); + execGit(["add", "agent2.txt"], { cwd: workingRepo }); + execGit(["commit", "-m", "Agent commit two"], { cwd: workingRepo }); + + execGit(["fetch", "origin", "main"], { cwd: workingRepo }); + + const origSha = process.env.GITHUB_SHA; + process.env.GITHUB_SHA = mainSha; + const restore = setTestEnv(workingRepo); + try { + const result = await generateGitPatch("default-dispatch-branch", "main", { mode: "full" }); + + expect(result.success).toBe(true); + expect(result.baseCommit).toBe(mainSha); + + const patchContent = fs.readFileSync(result.patchPath, "utf8"); + expect(patchContent).toContain("Agent commit one"); + expect(patchContent).toContain("Agent commit two"); + } finally { + if (origSha === undefined) { + delete process.env.GITHUB_SHA; + } else { + process.env.GITHUB_SHA = origSha; + } + restore(); + } + }); + + it("should base the full-mode bundle on GITHUB_SHA when dispatched from a non-default branch", async () => { + execGit(["checkout", "-b", "bundle-dispatch-branch"], { cwd: workingRepo }); + fs.writeFileSync(path.join(workingRepo, "bundle-dispatch.txt"), "dispatched work\n"); + execGit(["add", "bundle-dispatch.txt"], { cwd: workingRepo }); + execGit(["commit", "-m", "Bundle dispatched branch commit"], { cwd: workingRepo }); + + const dispatchedSha = execGit(["rev-parse", "HEAD"], { cwd: workingRepo }).stdout.trim(); + + execGit(["checkout", "-b", "agent-bundle-branch"], { cwd: workingRepo }); + fs.writeFileSync(path.join(workingRepo, "bundle-agent.txt"), "agent change\n"); + execGit(["add", "bundle-agent.txt"], { cwd: workingRepo }); + execGit(["commit", "-m", "Agent bundle commit"], { cwd: workingRepo }); + + execGit(["fetch", "origin", "main"], { cwd: workingRepo }); + + const origSha = process.env.GITHUB_SHA; + process.env.GITHUB_SHA = dispatchedSha; + const restore = setTestEnv(workingRepo); + try { + const result = await generateGitBundle("agent-bundle-branch", "main", { mode: "full" }); + + expect(result.success).toBe(true); + expect(result.baseCommit).toBe(dispatchedSha); + } finally { + if (origSha === undefined) { + delete process.env.GITHUB_SHA; + } else { + process.env.GITHUB_SHA = origSha; + } + restore(); + } + }); + it("should choose origin/main as the closest Strategy 3 base in full mode", async () => { // Create a stale remote ref that sorts before origin/main. execGit(["checkout", "-b", "aaa-stale"], { cwd: workingRepo }); diff --git a/actions/setup/js/git_patch_utils.cjs b/actions/setup/js/git_patch_utils.cjs index a55f884375a..72efe3b5a7a 100644 --- a/actions/setup/js/git_patch_utils.cjs +++ b/actions/setup/js/git_patch_utils.cjs @@ -223,6 +223,60 @@ function computeIncrementalDiffSize({ baseRef, headRef, cwd, tmpPath, excludedFi return diffSize; } +/** + * Returns true when `ancestor` is an ancestor of (or identical to) `descendant`. + * Any git failure (unknown revision, missing object) is treated as "not an ancestor". + * @param {string} ancestor + * @param {string} descendant + * @param {string|undefined} cwd + * @returns {boolean} + */ +function isAncestorCommit(ancestor, descendant, cwd) { + try { + execGitSync(["merge-base", "--is-ancestor", "--", ancestor, descendant], { cwd, suppressLogs: true }); + return true; + } catch { + return false; + } +} + +/** + * Returns true when the repository is a partial clone (objects are fetched lazily + * from a promisor remote). In gh-aw checkouts credentials are not persisted, so any + * lazy fetch is unauthenticated and fails - which surfaces as confusing git errors. + * @param {string|undefined} cwd + * @returns {boolean} + */ +function isPartialClone(cwd) { + try { + return execGitSync(["config", "--get", "remote.origin.promisor"], { cwd, suppressLogs: true }).trim() === "true"; + } catch { + return false; + } +} + +/** + * Appends a partial-clone diagnostic to an error message when the failure looks like + * a failed lazy object hydration from an unauthenticated promisor remote. + * @param {string} message + * @param {string|undefined} cwd + * @returns {string} + */ +function describeGitFailure(message, cwd) { + if (!/promisor|Authentication failed|Invalid username or token|could not fetch/i.test(message)) { + return message; + } + if (!isPartialClone(cwd)) { + return message; + } + return ( + `${message} ` + + "This repository is a partial clone (remote.origin.promisor=true), so git tried to lazily fetch missing objects from the remote. " + + "That fetch is unauthenticated because the checkout used persist-credentials: false. " + + "Fetch the required objects during checkout (for example checkout.fetch-depth: 0 without a blob filter) to avoid lazy fetches." + ); +} + module.exports = { sanitizeForFilename, sanitizeBranchNameForPatch, @@ -233,4 +287,7 @@ module.exports = { computeIncrementalDiffSize, getPatchDiffSizeBytes, getStagedPatchDiffSizeBytes, + isAncestorCommit, + isPartialClone, + describeGitFailure, }; From bccc1365125cf7c244db1f4e7d1a0e12e708e9e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:51:34 +0000 Subject: [PATCH 3/4] Address review feedback: bundle diagnostics, no-commit guard, test coverage Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/generate_git_bundle.cjs | 29 ++++- actions/setup/js/generate_git_patch.cjs | 2 +- .../setup/js/git_patch_integration.test.cjs | 5 + actions/setup/js/git_patch_utils.cjs | 8 +- actions/setup/js/git_patch_utils.test.cjs | 119 ++++++++++++++++++ 5 files changed, 156 insertions(+), 7 deletions(-) diff --git a/actions/setup/js/generate_git_bundle.cjs b/actions/setup/js/generate_git_bundle.cjs index f17ea9c775f..9f6a1e4ac24 100644 --- a/actions/setup/js/generate_git_bundle.cjs +++ b/actions/setup/js/generate_git_bundle.cjs @@ -13,7 +13,7 @@ const path = require("path"); const { getErrorMessage } = require("./error_helpers.cjs"); const { generateGitPatch } = require("./generate_git_patch.cjs"); const { ensureOriginRemoteTrackingRef, execGitSync } = require("./git_helpers.cjs"); -const { isAncestorCommit } = require("./git_patch_utils.cjs"); +const { isAncestorCommit, describeGitFailure } = require("./git_patch_utils.cjs"); const { normalizeCommitSHA } = require("./commit_sha_helpers.cjs"); const { ERR_SYSTEM } = require("./error_codes.cjs"); @@ -218,7 +218,7 @@ async function generateGitBundle(branchName, baseBranch, options = {}) { // and on a partial clone it needs base-side objects that were never fetched. // GITHUB_SHA is the commit the agent started from and is fully local. const dispatchedSha = normalizeCommitSHA(githubSha); - if (hasLocalDefaultBranch && dispatchedSha && isAncestorCommit(dispatchedSha, branchName, cwd) && !isAncestorCommit(dispatchedSha, `origin/${defaultBranch}`, cwd)) { + if (hasLocalDefaultBranch && dispatchedSha && dispatchedSha !== branchName && isAncestorCommit(dispatchedSha, branchName, cwd) && !isAncestorCommit(dispatchedSha, `origin/${defaultBranch}`, cwd)) { baseRef = dispatchedSha; debugLog(`Strategy 1 (full): GITHUB_SHA ${dispatchedSha} is not contained in origin/${defaultBranch} (non-default-branch run); using it as the bundle base instead of the merge-base`); } else if (hasLocalDefaultBranch) { @@ -312,9 +312,30 @@ async function generateGitBundle(branchName, baseBranch, options = {}) { }; } } catch (branchError) { - // Branch does not exist locally - debugLog(`Strategy 1: Branch '${branchName}' does not exist locally - ${getErrorMessage(branchError)}`); + // Strategy 1 failed. Determine branch existence from local refs only so a + // network/auth failure (e.g. a lazy blob fetch on a partial clone) is never + // reported as a missing local branch. + let branchExistsLocally = false; + try { + execGitSync(["rev-parse", "--verify", "--quiet", `refs/heads/${branchName}`], { cwd, suppressLogs: true }); + branchExistsLocally = true; + } catch { + // Branch really is absent from the local refs + } + const branchErrorMessage = describeGitFailure(getErrorMessage(branchError), cwd); + if (branchExistsLocally) { + debugLog(`Strategy 1: Failed to generate bundle for branch '${branchName}' (branch exists locally) - ${branchErrorMessage}`); + } else { + debugLog(`Strategy 1: Branch '${branchName}' does not exist locally - ${branchErrorMessage}`); + } if (mode === "incremental") { + if (branchExistsLocally) { + return { + success: false, + error: `Cannot generate incremental bundle for branch ${branchName} in checkout '${cwd}': ${branchErrorMessage}`, + bundlePath, + }; + } return { success: false, error: diff --git a/actions/setup/js/generate_git_patch.cjs b/actions/setup/js/generate_git_patch.cjs index 1df13c50a04..1dc7a4adead 100644 --- a/actions/setup/js/generate_git_patch.cjs +++ b/actions/setup/js/generate_git_patch.cjs @@ -284,7 +284,7 @@ async function generateGitPatch(branchName, baseBranch, options = {}) { // and fails). GITHUB_SHA is the commit the agent started from and every // object it needs is already present in the checkout. const dispatchedSha = normalizeCommitSHA(githubSha); - if (defaultBranchRef && dispatchedSha && isAncestorCommit(dispatchedSha, tipRef, cwd) && !isAncestorCommit(dispatchedSha, defaultBranchRef, cwd)) { + if (defaultBranchRef && dispatchedSha && dispatchedSha !== tipRef && isAncestorCommit(dispatchedSha, tipRef, cwd) && !isAncestorCommit(dispatchedSha, defaultBranchRef, cwd)) { baseRef = dispatchedSha; debugLog(`Strategy 1 (full): GITHUB_SHA ${dispatchedSha} is not contained in ${defaultBranchRef} (non-default-branch run); using it as the patch base instead of the merge-base`); } else if (defaultBranchRef) { diff --git a/actions/setup/js/git_patch_integration.test.cjs b/actions/setup/js/git_patch_integration.test.cjs index 81e6339580e..492029ef7e3 100644 --- a/actions/setup/js/git_patch_integration.test.cjs +++ b/actions/setup/js/git_patch_integration.test.cjs @@ -1022,6 +1022,11 @@ describe("git patch integration tests", () => { const patchContent = fs.readFileSync(result.patchPath, "utf8"); expect(patchContent).toContain("Agent commit one"); expect(patchContent).toContain("Agent commit two"); + + const logLines = execGit(["log", "--oneline", `${mainSha}..HEAD`], { cwd: workingRepo }) + .stdout.trim() + .split("\n"); + expect(logLines).toHaveLength(2); } finally { if (origSha === undefined) { delete process.env.GITHUB_SHA; diff --git a/actions/setup/js/git_patch_utils.cjs b/actions/setup/js/git_patch_utils.cjs index 72efe3b5a7a..bf8652b1fa1 100644 --- a/actions/setup/js/git_patch_utils.cjs +++ b/actions/setup/js/git_patch_utils.cjs @@ -225,7 +225,11 @@ function computeIncrementalDiffSize({ baseRef, headRef, cwd, tmpPath, excludedFi /** * Returns true when `ancestor` is an ancestor of (or identical to) `descendant`. - * Any git failure (unknown revision, missing object) is treated as "not an ancestor". + * Any git failure (unknown revision, missing object, corrupt object store) is + * treated as "not an ancestor" - callers only need a boolean signal for base + * selection. Failures are suppressed here (suppressLogs: true); callers that + * need richer diagnostics on an unexpected failure should call + * describeGitFailure on their own caught error instead. * @param {string} ancestor * @param {string} descendant * @param {string|undefined} cwd @@ -263,7 +267,7 @@ function isPartialClone(cwd) { * @returns {string} */ function describeGitFailure(message, cwd) { - if (!/promisor|Authentication failed|Invalid username or token|could not fetch/i.test(message)) { + if (!/promisor|Authentication failed|Invalid username or token|fetch-pack|object not found/i.test(message)) { return message; } if (!isPartialClone(cwd)) { diff --git a/actions/setup/js/git_patch_utils.test.cjs b/actions/setup/js/git_patch_utils.test.cjs index 7042439ee7a..87cf300806f 100644 --- a/actions/setup/js/git_patch_utils.test.cjs +++ b/actions/setup/js/git_patch_utils.test.cjs @@ -25,6 +25,9 @@ import { computeIncrementalDiffSize, getPatchDiffSizeBytes, getStagedPatchDiffSizeBytes, + isAncestorCommit, + isPartialClone, + describeGitFailure, } from "./git_patch_utils.cjs"; // computeIncrementalDiffSize delegates to execGitSync from git_helpers.cjs, @@ -378,3 +381,119 @@ describe("getStagedPatchDiffSizeBytes", () => { expect(calls[0].opts.cwd).toBe("/memory/dir"); }); }); + +describe("isAncestorCommit", () => { + /** @type {string} */ + let repoDir; + + beforeEach(() => { + repoDir = createTestRepo(); + }); + + afterEach(() => { + cleanupRepo(repoDir); + }); + + it("returns true when ancestor commit is an ancestor of descendant", () => { + const rootSha = execGit(["rev-parse", "HEAD"], { cwd: repoDir }).stdout.trim(); + fs.writeFileSync(path.join(repoDir, "file.txt"), "content\n"); + execGit(["add", "."], { cwd: repoDir }); + execGit(["commit", "-q", "-m", "Second commit"], { cwd: repoDir }); + const tipSha = execGit(["rev-parse", "HEAD"], { cwd: repoDir }).stdout.trim(); + + expect(isAncestorCommit(rootSha, tipSha, repoDir)).toBe(true); + }); + + it("returns true when ancestor and descendant are identical", () => { + const sha = execGit(["rev-parse", "HEAD"], { cwd: repoDir }).stdout.trim(); + expect(isAncestorCommit(sha, sha, repoDir)).toBe(true); + }); + + it("returns false when ancestor commit is not an ancestor of descendant", () => { + const rootSha = execGit(["rev-parse", "HEAD"], { cwd: repoDir }).stdout.trim(); + fs.writeFileSync(path.join(repoDir, "file.txt"), "content\n"); + execGit(["add", "."], { cwd: repoDir }); + execGit(["commit", "-q", "-m", "Second commit"], { cwd: repoDir }); + const tipSha = execGit(["rev-parse", "HEAD"], { cwd: repoDir }).stdout.trim(); + + expect(isAncestorCommit(tipSha, rootSha, repoDir)).toBe(false); + }); + + it("returns false for an unknown revision instead of throwing", () => { + const rootSha = execGit(["rev-parse", "HEAD"], { cwd: repoDir }).stdout.trim(); + expect(isAncestorCommit("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", rootSha, repoDir)).toBe(false); + }); +}); + +describe("isPartialClone", () => { + /** @type {string} */ + let repoDir; + + beforeEach(() => { + repoDir = createTestRepo(); + }); + + afterEach(() => { + cleanupRepo(repoDir); + }); + + it("returns false when remote.origin.promisor is not set", () => { + expect(isPartialClone(repoDir)).toBe(false); + }); + + it("returns true when remote.origin.promisor is set to true", () => { + execGit(["config", "remote.origin.promisor", "true"], { cwd: repoDir }); + expect(isPartialClone(repoDir)).toBe(true); + }); + + it("returns false when remote.origin.promisor is set to false", () => { + execGit(["config", "remote.origin.promisor", "false"], { cwd: repoDir }); + expect(isPartialClone(repoDir)).toBe(false); + }); +}); + +describe("describeGitFailure", () => { + /** @type {string} */ + let repoDir; + + beforeEach(() => { + repoDir = createTestRepo(); + }); + + afterEach(() => { + cleanupRepo(repoDir); + }); + + it("leaves the message unchanged when the repo is not a partial clone", () => { + const message = "fatal: remote error: Invalid username or token."; + expect(describeGitFailure(message, repoDir)).toBe(message); + }); + + it("leaves the message unchanged when it does not look like an auth/promisor failure", () => { + execGit(["config", "remote.origin.promisor", "true"], { cwd: repoDir }); + const message = "fatal: bad object HEAD"; + expect(describeGitFailure(message, repoDir)).toBe(message); + }); + + it("appends the partial-clone diagnostic when the repo is a partial clone and the message matches", () => { + execGit(["config", "remote.origin.promisor", "true"], { cwd: repoDir }); + const message = "remote: Invalid username or token."; + const result = describeGitFailure(message, repoDir); + expect(result).toContain(message); + expect(result).toContain("partial clone"); + expect(result).toContain("persist-credentials: false"); + }); + + it("matches on 'promisor' in the message even without the exact auth wording", () => { + execGit(["config", "remote.origin.promisor", "true"], { cwd: repoDir }); + const message = "error: promisor remote fetch failed"; + const result = describeGitFailure(message, repoDir); + expect(result).toContain("partial clone"); + }); + + it("does not append the diagnostic for unrelated network errors even on a partial clone", () => { + execGit(["config", "remote.origin.promisor", "true"], { cwd: repoDir }); + const message = "fatal: unable to access 'https://example.com/': Could not resolve host"; + expect(describeGitFailure(message, repoDir)).toBe(message); + }); +}); From dbb3de2d56c7751a873333a17a4cf61b8071ab83 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:59:28 +0000 Subject: [PATCH 4/4] Fix zero-commit guard to compare against tip SHA, not branch name/ref Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/generate_git_bundle.cjs | 3 +- actions/setup/js/generate_git_patch.cjs | 3 +- .../setup/js/git_patch_integration.test.cjs | 74 +++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/generate_git_bundle.cjs b/actions/setup/js/generate_git_bundle.cjs index 9f6a1e4ac24..d818fb8b471 100644 --- a/actions/setup/js/generate_git_bundle.cjs +++ b/actions/setup/js/generate_git_bundle.cjs @@ -218,7 +218,8 @@ async function generateGitBundle(branchName, baseBranch, options = {}) { // and on a partial clone it needs base-side objects that were never fetched. // GITHUB_SHA is the commit the agent started from and is fully local. const dispatchedSha = normalizeCommitSHA(githubSha); - if (hasLocalDefaultBranch && dispatchedSha && dispatchedSha !== branchName && isAncestorCommit(dispatchedSha, branchName, cwd) && !isAncestorCommit(dispatchedSha, `origin/${defaultBranch}`, cwd)) { + const branchTipSha = execGitSync(["rev-parse", branchName], { cwd }).trim(); + if (hasLocalDefaultBranch && dispatchedSha && dispatchedSha !== branchTipSha && isAncestorCommit(dispatchedSha, branchName, cwd) && !isAncestorCommit(dispatchedSha, `origin/${defaultBranch}`, cwd)) { baseRef = dispatchedSha; debugLog(`Strategy 1 (full): GITHUB_SHA ${dispatchedSha} is not contained in origin/${defaultBranch} (non-default-branch run); using it as the bundle base instead of the merge-base`); } else if (hasLocalDefaultBranch) { diff --git a/actions/setup/js/generate_git_patch.cjs b/actions/setup/js/generate_git_patch.cjs index 1dc7a4adead..e00685b3ac7 100644 --- a/actions/setup/js/generate_git_patch.cjs +++ b/actions/setup/js/generate_git_patch.cjs @@ -284,7 +284,8 @@ async function generateGitPatch(branchName, baseBranch, options = {}) { // and fails). GITHUB_SHA is the commit the agent started from and every // object it needs is already present in the checkout. const dispatchedSha = normalizeCommitSHA(githubSha); - if (defaultBranchRef && dispatchedSha && dispatchedSha !== tipRef && isAncestorCommit(dispatchedSha, tipRef, cwd) && !isAncestorCommit(dispatchedSha, defaultBranchRef, cwd)) { + const tipSha = execGitSync(["rev-parse", tipRef], { cwd }).trim(); + if (defaultBranchRef && dispatchedSha && dispatchedSha !== tipSha && isAncestorCommit(dispatchedSha, tipRef, cwd) && !isAncestorCommit(dispatchedSha, defaultBranchRef, cwd)) { baseRef = dispatchedSha; debugLog(`Strategy 1 (full): GITHUB_SHA ${dispatchedSha} is not contained in ${defaultBranchRef} (non-default-branch run); using it as the patch base instead of the merge-base`); } else if (defaultBranchRef) { diff --git a/actions/setup/js/git_patch_integration.test.cjs b/actions/setup/js/git_patch_integration.test.cjs index 492029ef7e3..5e5b5432327 100644 --- a/actions/setup/js/git_patch_integration.test.cjs +++ b/actions/setup/js/git_patch_integration.test.cjs @@ -997,6 +997,47 @@ describe("git patch integration tests", () => { } }); + it("should fall back to merge-base when GITHUB_SHA equals the agent branch tip (no agent commits yet)", async () => { + // Simulate workflow_dispatch from a branch that is ahead of main, where the + // agent branch was created from it but no commits have been made yet, so + // GITHUB_SHA equals the agent branch tip. Basing the patch on GITHUB_SHA in + // this case would produce an empty patch; the merge-base fallback must be + // used instead so the dispatched branch's commits are still visible. + execGit(["checkout", "-b", "dispatch-branch-2"], { cwd: workingRepo }); + fs.writeFileSync(path.join(workingRepo, "dispatch3.txt"), "dispatched work 3\n"); + execGit(["add", "dispatch3.txt"], { cwd: workingRepo }); + execGit(["commit", "-m", "Dispatched branch commit 3"], { cwd: workingRepo }); + + const dispatchedSha = execGit(["rev-parse", "HEAD"], { cwd: workingRepo }).stdout.trim(); + + // Agent branch created from the dispatched commit, but no agent commits yet. + execGit(["checkout", "-b", "agent-fix/no-commits-yet"], { cwd: workingRepo }); + + execGit(["fetch", "origin", "main"], { cwd: workingRepo }); + + const origSha = process.env.GITHUB_SHA; + process.env.GITHUB_SHA = dispatchedSha; + const restore = setTestEnv(workingRepo); + try { + const result = await generateGitPatch("agent-fix/no-commits-yet", "main", { mode: "full" }); + + expect(result.success).toBe(true); + // Must not use GITHUB_SHA as the base (that would be a zero-commit patch); + // the merge-base with main is used instead. + expect(result.baseCommit).not.toBe(dispatchedSha); + + const patchContent = fs.readFileSync(result.patchPath, "utf8"); + expect(patchContent).toContain("Dispatched branch commit 3"); + } finally { + if (origSha === undefined) { + delete process.env.GITHUB_SHA; + } else { + process.env.GITHUB_SHA = origSha; + } + restore(); + } + }); + it("should still use the merge-base in full mode when GITHUB_SHA is on the default branch", async () => { const mainSha = execGit(["rev-parse", "main"], { cwd: workingRepo }).stdout.trim(); @@ -1070,6 +1111,39 @@ describe("git patch integration tests", () => { } }); + it("should fall back to merge-base for the bundle when GITHUB_SHA equals the agent branch tip (no agent commits yet)", async () => { + execGit(["checkout", "-b", "bundle-dispatch-branch-2"], { cwd: workingRepo }); + fs.writeFileSync(path.join(workingRepo, "bundle-dispatch2.txt"), "dispatched work 2\n"); + execGit(["add", "bundle-dispatch2.txt"], { cwd: workingRepo }); + execGit(["commit", "-m", "Bundle dispatched branch commit 2"], { cwd: workingRepo }); + + const dispatchedSha = execGit(["rev-parse", "HEAD"], { cwd: workingRepo }).stdout.trim(); + + // Agent branch created from the dispatched commit, but no agent commits yet. + execGit(["checkout", "-b", "agent-bundle-branch-no-commits"], { cwd: workingRepo }); + + execGit(["fetch", "origin", "main"], { cwd: workingRepo }); + + const origSha = process.env.GITHUB_SHA; + process.env.GITHUB_SHA = dispatchedSha; + const restore = setTestEnv(workingRepo); + try { + const result = await generateGitBundle("agent-bundle-branch-no-commits", "main", { mode: "full" }); + + expect(result.success).toBe(true); + // Must not use GITHUB_SHA as the base (that would be a zero-commit bundle); + // the merge-base with main is used instead. + expect(result.baseCommit).not.toBe(dispatchedSha); + } finally { + if (origSha === undefined) { + delete process.env.GITHUB_SHA; + } else { + process.env.GITHUB_SHA = origSha; + } + restore(); + } + }); + it("should choose origin/main as the closest Strategy 3 base in full mode", async () => { // Create a stale remote ref that sorts before origin/main. execGit(["checkout", "-b", "aaa-stale"], { cwd: workingRepo });