From 013c18f68d1598f3dcf82a405caa84a69479fb9b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:28:03 +0000 Subject: [PATCH 1/3] Initial plan From a582dbe2837a6db304635bc36ffabe26a2ed8420 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:37:07 +0000 Subject: [PATCH 2/3] Fix preserve-branch-name silent bypass on remote branch collision Agent-Logs-Url: https://github.com/github/gh-aw/sessions/e96fa9ed-55e3-405c-9607-653a6964420d Co-authored-by: mrjf <180956+mrjf@users.noreply.github.com> --- actions/setup/js/create_pull_request.cjs | 21 +++++++ actions/setup/js/create_pull_request.test.cjs | 57 +++++++++++++++++++ .../reference/safe-outputs-pull-requests.md | 2 + 3 files changed, 80 insertions(+) diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index 2acf9e735ce..b66d2e45d4b 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -983,6 +983,13 @@ async function main(config = {}) { } if (remoteBranchExists) { + if (preserveBranchName) { + throw new Error( + `Remote branch "${branchName}" already exists and preserve-branch-name is enabled. ` + + `Refusing to silently rename the branch. Either delete the remote branch, choose a different ` + + `branch name, or disable preserve-branch-name to allow a random suffix to be appended.` + ); + } core.warning(`Remote branch ${branchName} already exists - appending random suffix`); const extraHex = crypto.randomBytes(4).toString("hex"); const oldBranch = branchName; @@ -1211,6 +1218,13 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${branchName} --repo } if (remoteBranchExists) { + if (preserveBranchName) { + throw new Error( + `Remote branch "${branchName}" already exists and preserve-branch-name is enabled. ` + + `Refusing to silently rename the branch. Either delete the remote branch, choose a different ` + + `branch name, or disable preserve-branch-name to allow a random suffix to be appended.` + ); + } core.warning(`Remote branch ${branchName} already exists - appending random suffix`); const extraHex = crypto.randomBytes(4).toString("hex"); const oldBranch = branchName; @@ -1374,6 +1388,13 @@ ${patchPreview}`; } if (remoteBranchExists) { + if (preserveBranchName) { + throw new Error( + `Remote branch "${branchName}" already exists and preserve-branch-name is enabled. ` + + `Refusing to silently rename the branch. Either delete the remote branch, choose a different ` + + `branch name, or disable preserve-branch-name to allow a random suffix to be appended.` + ); + } core.warning(`Remote branch ${branchName} already exists - appending random suffix`); const extraHex = crypto.randomBytes(4).toString("hex"); const oldBranch = branchName; diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index 97868b46a57..ad1da7f9b81 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -1574,6 +1574,63 @@ describe("create_pull_request - patch apply fallback to original base commit", ( expect(result.error).toBe("Failed to apply patch"); expect(global.core.warning).toHaveBeenCalledWith("No base_commit recorded in safe output entry - fallback not possible"); }); + + it("should fail loudly when preserve-branch-name is true and remote branch already exists", async () => { + // Simulate the remote branch existing (ls-remote returns content) + global.exec = { + exec: vi.fn().mockResolvedValue(0), + getExecOutput: vi.fn().mockImplementation((cmd, args) => { + const cmdStr = typeof cmd === "string" ? cmd : `${cmd} ${(args || []).join(" ")}`; + if (cmdStr.includes("ls-remote --heads origin")) { + return Promise.resolve({ exitCode: 0, stdout: "abc123\trefs/heads/preserve-me\n", stderr: "" }); + } + return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + }), + }; + + const { main } = require("./create_pull_request.cjs"); + const handler = await main({ preserve_branch_name: true, fallback_as_issue: false }); + + const result = await handler({ title: "Test PR", body: "Test body", patch_path: patchFilePath, branch: "preserve-me", base_commit: MOCK_BASE_COMMIT_SHA }, {}); + + expect(result.success).toBe(false); + expect(result.error_type).toBe("push_failed"); + expect(result.error).toContain('Remote branch "preserve-me" already exists'); + expect(result.error).toContain("preserve-branch-name is enabled"); + // Critical: should NOT have warned about appending random suffix (silent bypass) + const warningCalls = global.core.warning.mock.calls.map(call => String(call[0])); + expect(warningCalls.some(msg => msg.includes("appending random suffix"))).toBe(false); + }); + + it("should append random suffix when preserve-branch-name is false and remote branch already exists", async () => { + let renameCalled = false; + global.exec = { + exec: vi.fn().mockImplementation((cmd, args) => { + const cmdStr = typeof cmd === "string" ? cmd : `${cmd} ${(args || []).join(" ")}`; + if (cmdStr.includes("git branch -m")) { + renameCalled = true; + } + return Promise.resolve(0); + }), + getExecOutput: vi.fn().mockImplementation((cmd, args) => { + const cmdStr = typeof cmd === "string" ? cmd : `${cmd} ${(args || []).join(" ")}`; + if (cmdStr.includes("ls-remote --heads origin")) { + return Promise.resolve({ exitCode: 0, stdout: "abc123\trefs/heads/some-branch\n", stderr: "" }); + } + return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + }), + }; + + const { main } = require("./create_pull_request.cjs"); + const handler = await main({}); + + const result = await handler({ title: "Test PR", body: "Test body", patch_path: patchFilePath, branch: "some-branch", base_commit: MOCK_BASE_COMMIT_SHA }, {}); + + expect(result.success).toBe(true); + expect(renameCalled).toBe(true); + const warningCalls = global.core.warning.mock.calls.map(call => String(call[0])); + expect(warningCalls.some(msg => msg.includes("appending random suffix"))).toBe(true); + }); }); describe("create_pull_request - copilot assignee on fallback issues", () => { diff --git a/docs/src/content/docs/reference/safe-outputs-pull-requests.md b/docs/src/content/docs/reference/safe-outputs-pull-requests.md index 4583974d3d7..af777a6be7c 100644 --- a/docs/src/content/docs/reference/safe-outputs-pull-requests.md +++ b/docs/src/content/docs/reference/safe-outputs-pull-requests.md @@ -83,6 +83,8 @@ The `excluded-files` field accepts a list of glob patterns. Each matching file i The `preserve-branch-name` field, when set to `true`, omits the random hex salt suffix that is normally appended to the agent-specified branch name. This is useful when the target repository enforces branch naming conventions such as Jira keys in uppercase (e.g., `bugfix/BR-329-red` instead of `bugfix/br-329-red-cde2a954`). Invalid characters are always replaced for security, and casing is always preserved regardless of this setting. Defaults to `false`. +When `preserve-branch-name: true` and the agent-supplied branch name already exists on the remote, the workflow fails with an explicit error rather than silently appending a random suffix. To resolve, delete the existing remote branch, choose a different branch name, or disable `preserve-branch-name` to allow collision-avoidance via a random suffix. + The `draft` field is a **configuration policy**, not a default. Whatever value is set in the workflow frontmatter is always used — the agent cannot override it at runtime. By default, when a workflow is triggered from an issue, the `create-pull-request` handler automatically appends `- Fixes #N` to the PR description if no closing keyword is already present. This causes GitHub to auto-close the triggering issue when the PR is merged. Set `auto-close-issue: false` to opt out of this behavior — useful for partial-work PRs, multi-PR workflows, or any case where the PR should reference but not close the issue. From 65bfa286afb1771d4369a6eadc251eed48ca75fb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 00:44:50 +0000 Subject: [PATCH 3/3] Extract remote-branch collision handler; set error_type on allow-empty push failure Agent-Logs-Url: https://github.com/github/gh-aw/sessions/fab5372a-c934-4afd-bef1-c41d44e169ff Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/create_pull_request.cjs | 127 ++++++++--------------- 1 file changed, 46 insertions(+), 81 deletions(-) diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index b66d2e45d4b..48e828c81a3 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -273,6 +273,48 @@ function generatePatchPreview(patchContent) { return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; } +/** + * Check whether the remote branch already exists and, if so, either fail loudly + * (when preserve-branch-name is enabled) or rename the local branch by appending + * a random hex suffix. + * @param {string} branchName - Current local branch name. + * @param {boolean} preserveBranchName - Whether preserve-branch-name is enabled. + * @returns {Promise} The (possibly renamed) branch name to use going forward. + * @throws {Error} If the remote branch exists and preserve-branch-name is true. + */ +async function handleRemoteBranchCollision(branchName, preserveBranchName) { + let remoteBranchExists = false; + try { + const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); + if (stdout.trim()) { + remoteBranchExists = true; + } + } catch (checkError) { + core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); + } + + if (!remoteBranchExists) { + return branchName; + } + + if (preserveBranchName) { + throw new Error( + `Remote branch "${branchName}" already exists and preserve-branch-name is enabled. ` + + `Refusing to silently rename the branch. Either delete the remote branch, choose a different ` + + `branch name, or disable preserve-branch-name to allow a random suffix to be appended.` + ); + } + + core.warning(`Remote branch ${branchName} already exists - appending random suffix`); + const extraHex = crypto.randomBytes(4).toString("hex"); + const oldBranch = branchName; + const renamedBranch = `${branchName}-${extraHex}`; + // Rename local branch + await exec.exec(`git branch -m ${oldBranch} ${renamedBranch}`); + core.info(`Renamed branch to ${renamedBranch}`); + return renamedBranch; +} + /** * Main handler factory for create_pull_request * Returns a message handler function that processes individual create_pull_request messages @@ -971,33 +1013,7 @@ async function main(config = {}) { // Push the commits from the bundle to the remote branch try { - // Check if remote branch already exists (optional precheck) - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - - if (remoteBranchExists) { - if (preserveBranchName) { - throw new Error( - `Remote branch "${branchName}" already exists and preserve-branch-name is enabled. ` + - `Refusing to silently rename the branch. Either delete the remote branch, choose a different ` + - `branch name, or disable preserve-branch-name to allow a random suffix to be appended.` - ); - } - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - // Rename local branch - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } + branchName = await handleRemoteBranchCollision(branchName, preserveBranchName); await pushSignedCommits({ githubClient, @@ -1206,33 +1222,7 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${branchName} --repo // Push the applied commits to the branch (with fallback to issue creation on failure) try { - // Check if remote branch already exists (optional precheck) - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - - if (remoteBranchExists) { - if (preserveBranchName) { - throw new Error( - `Remote branch "${branchName}" already exists and preserve-branch-name is enabled. ` + - `Refusing to silently rename the branch. Either delete the remote branch, choose a different ` + - `branch name, or disable preserve-branch-name to allow a random suffix to be appended.` - ); - } - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - // Rename local branch - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } + branchName = await handleRemoteBranchCollision(branchName, preserveBranchName); await pushSignedCommits({ githubClient, @@ -1376,33 +1366,7 @@ ${patchPreview}`; await exec.exec(`git commit --allow-empty -m "Initialize"`); core.info("Created empty commit"); - // Check if remote branch already exists (optional precheck) - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - - if (remoteBranchExists) { - if (preserveBranchName) { - throw new Error( - `Remote branch "${branchName}" already exists and preserve-branch-name is enabled. ` + - `Refusing to silently rename the branch. Either delete the remote branch, choose a different ` + - `branch name, or disable preserve-branch-name to allow a random suffix to be appended.` - ); - } - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - // Rename local branch - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } + branchName = await handleRemoteBranchCollision(branchName, preserveBranchName); await pushSignedCommits({ githubClient, @@ -1429,6 +1393,7 @@ ${patchPreview}`; return { success: false, error, + error_type: "push_failed", }; } } else {