From dfc5378cca9335c369d6b0bb1708878eb0e29cc0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:45:51 +0000 Subject: [PATCH 1/7] Initial plan From cc9f2dc739016140b1d9ca0fdde01a2a07e18680 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:54:40 +0000 Subject: [PATCH 2/7] Allow create_pull_request to reuse existing remote branch when preserve-branch-name is true Agent-Logs-Url: https://github.com/github/gh-aw/sessions/42a2353b-dfa9-404d-a067-2b28b74fa140 Co-authored-by: mrjf <180956+mrjf@users.noreply.github.com> --- ...serve-branch-name-reuse-existing-branch.md | 5 ++ actions/setup/js/create_pull_request.cjs | 65 +++++++++++++++---- actions/setup/js/create_pull_request.test.cjs | 53 +++++++++++++-- .../reference/safe-outputs-pull-requests.md | 2 +- 4 files changed, 105 insertions(+), 20 deletions(-) create mode 100644 .changeset/patch-preserve-branch-name-reuse-existing-branch.md diff --git a/.changeset/patch-preserve-branch-name-reuse-existing-branch.md b/.changeset/patch-preserve-branch-name-reuse-existing-branch.md new file mode 100644 index 00000000000..56706b6d6f1 --- /dev/null +++ b/.changeset/patch-preserve-branch-name-reuse-existing-branch.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Fix `create-pull-request` to reuse an existing remote branch when `preserve-branch-name: true` is enabled. Previously, the handler refused to push if the named branch already existed on the remote, blocking workflows that intentionally maintain long-lived reusable branches across iterations (e.g. autoloop programs whose previous PR was merged but whose branch still exists). The handler now force-deletes the stale remote ref and recreates the branch from the agent's local HEAD, matching the intent of `preserve-branch-name`. diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index 84cf74d8ebe..ebc3945049d 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -427,15 +427,29 @@ function generatePatchPreview(patchContent) { } /** - * 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. + * Check whether the remote branch already exists and, if so, either reuse it + * (when preserve-branch-name is enabled, by force-deleting the remote ref so + * the subsequent push recreates it from the local HEAD) or rename the local + * branch by appending a random hex suffix. + * + * The "force-delete then recreate" semantic is used for preserve-branch-name + * because the existing remote branch may have diverged from the local HEAD + * (e.g. a long-lived branch whose previous PR was merged and is now behind + * the base branch). Deleting the ref first lets `pushSignedCommits` recreate + * the branch at the local commit's parent OID and replay only the local + * commits via the GraphQL `createCommitOnBranch` mutation, which is what + * users intend by enabling preserve-branch-name on a reusable branch. + * * @param {string} branchName - Current local branch name. * @param {boolean} preserveBranchName - Whether preserve-branch-name is enabled. + * @param {object} [options] - Additional options. + * @param {object} [options.githubClient] - Authenticated Octokit client used to delete the + * existing remote ref when preserve-branch-name is enabled. + * @param {string} [options.owner] - Repository owner for the deleteRef call. + * @param {string} [options.repo] - Repository name for the deleteRef call. * @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) { +async function handleRemoteBranchCollision(branchName, preserveBranchName, options = {}) { let remoteBranchExists = false; try { const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); @@ -451,11 +465,36 @@ async function handleRemoteBranchCollision(branchName, preserveBranchName) { } 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.` - ); + // Reuse the existing branch by deleting the remote ref so the subsequent + // push recreates it from the local HEAD (force-push semantic). This is the + // intended behavior of preserve-branch-name for long-lived reusable + // branches whose previous PR was merged. + const { githubClient, owner, repo } = options; + if (!githubClient || !owner || !repo) { + throw new Error( + `Remote branch "${branchName}" already exists and preserve-branch-name is enabled, ` + + `but no GitHub client was provided to delete the existing remote ref. This is an ` + + `internal error: the caller must pass githubClient, owner, and repo to reuse the branch.` + ); + } + core.warning(`Remote branch ${branchName} already exists - reusing it (preserve-branch-name enabled, force-deleting remote ref)`); + try { + await githubClient.rest.git.deleteRef({ owner, repo, ref: `heads/${branchName}` }); + core.info(`Deleted remote branch ${branchName} to reuse it`); + } catch (deleteError) { + /** @type {any} */ + const err = deleteError; + const status = err && typeof err === "object" ? err.status : undefined; + const message = err && typeof err === "object" ? String(err.message || "") : ""; + // 422 "Reference does not exist" can happen if the branch was deleted concurrently; + // treat that as success and continue. + if (status === 422 && /reference.*does not exist/i.test(message)) { + core.info(`Remote branch ${branchName} was already deleted concurrently; continuing`); + } else { + throw new Error(`Failed to delete existing remote branch "${branchName}" for reuse with preserve-branch-name: ${message || String(err)}`); + } + } + return branchName; } core.warning(`Remote branch ${branchName} already exists - appending random suffix`); @@ -1201,7 +1240,7 @@ async function main(config = {}) { // Push the commits from the bundle to the remote branch try { - branchName = await handleRemoteBranchCollision(branchName, preserveBranchName); + branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { githubClient, owner: repoParts.owner, repo: repoParts.repo }); await pushSignedCommits({ githubClient, @@ -1410,7 +1449,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 { - branchName = await handleRemoteBranchCollision(branchName, preserveBranchName); + branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { githubClient, owner: repoParts.owner, repo: repoParts.repo }); await pushSignedCommits({ githubClient, @@ -1554,7 +1593,7 @@ ${patchPreview}`; await exec.exec(`git commit --allow-empty -m "Initialize"`); core.info("Created empty commit"); - branchName = await handleRemoteBranchCollision(branchName, preserveBranchName); + branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { githubClient, owner: repoParts.owner, repo: repoParts.repo }); await pushSignedCommits({ githubClient, diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index 5d73d8abe84..be3437137eb 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -1548,6 +1548,9 @@ describe("create_pull_request - patch apply fallback to original base commit", ( issues: { addLabels: vi.fn().mockResolvedValue({}), }, + git: { + deleteRef: vi.fn().mockResolvedValue({}), + }, }, graphql: vi.fn(), }; @@ -1702,8 +1705,48 @@ describe("create_pull_request - patch apply fallback to original base commit", ( 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 () => { + it("should reuse existing remote branch when preserve-branch-name is true (force-delete then recreate)", async () => { // Simulate the remote branch existing (ls-remote returns content) + 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/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 }); + + 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(true); + // Should have called deleteRef to force-delete the existing remote branch + expect(global.github.rest.git.deleteRef).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + ref: "heads/preserve-me", + }); + // Should NOT have renamed the local branch (preserve-branch-name keeps the name) + expect(renameCalled).toBe(false); + // Should NOT have warned about appending random suffix + const warningCalls = global.core.warning.mock.calls.map(call => String(call[0])); + expect(warningCalls.some(msg => msg.includes("appending random suffix"))).toBe(false); + // Should have warned about reusing the branch + expect(warningCalls.some(msg => msg.includes("reusing it") && msg.includes("preserve-branch-name"))).toBe(true); + }); + + it("should fall back to issue when deleteRef fails for preserve-branch-name reuse", async () => { global.exec = { exec: vi.fn().mockResolvedValue(0), getExecOutput: vi.fn().mockImplementation((cmd, args) => { @@ -1714,6 +1757,8 @@ describe("create_pull_request - patch apply fallback to original base commit", ( return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); }), }; + // Simulate deleteRef failing with a non-recoverable error + global.github.rest.git.deleteRef = vi.fn().mockRejectedValue(Object.assign(new Error("Forbidden"), { status: 403 })); const { main } = require("./create_pull_request.cjs"); const handler = await main({ preserve_branch_name: true, fallback_as_issue: false }); @@ -1722,11 +1767,7 @@ describe("create_pull_request - patch apply fallback to original base commit", ( 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); + expect(result.error).toContain('Failed to delete existing remote branch "preserve-me"'); }); it("should append random suffix when preserve-branch-name is false and remote branch already exists", async () => { 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 52ceb65393a..56f046b5b0f 100644 --- a/docs/src/content/docs/reference/safe-outputs-pull-requests.md +++ b/docs/src/content/docs/reference/safe-outputs-pull-requests.md @@ -84,7 +84,7 @@ 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. +When `preserve-branch-name: true` and the agent-supplied branch name already exists on the remote, the workflow reuses the existing branch by force-deleting the stale remote ref and recreating it from the agent's local HEAD (force-push semantic). This is the intended behavior for long-lived reusable branches whose previous PR was merged. The handler does not silently rename the branch in this case. 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. From 7ea66be4e191cfb42648a016f9f91e3cbc08e222 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:56:29 +0000 Subject: [PATCH 3/7] Reuse existing remote branch in create_pull_request when preserve-branch-name is enabled Agent-Logs-Url: https://github.com/github/gh-aw/sessions/42a2353b-dfa9-404d-a067-2b28b74fa140 Co-authored-by: mrjf <180956+mrjf@users.noreply.github.com> --- actions/setup/js/create_pull_request.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index ebc3945049d..7acd2dbf5e3 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -488,7 +488,7 @@ async function handleRemoteBranchCollision(branchName, preserveBranchName, optio const message = err && typeof err === "object" ? String(err.message || "") : ""; // 422 "Reference does not exist" can happen if the branch was deleted concurrently; // treat that as success and continue. - if (status === 422 && /reference.*does not exist/i.test(message)) { + if (status === 422 && /Reference does not exist/i.test(message)) { core.info(`Remote branch ${branchName} was already deleted concurrently; continuing`); } else { throw new Error(`Failed to delete existing remote branch "${branchName}" for reuse with preserve-branch-name: ${message || String(err)}`); From 71a62a4ff3411991b14e7298d68a76678feb3ffd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:57:37 +0000 Subject: [PATCH 4/7] Use 'force-push semantics' (plural) in docs and code comments Agent-Logs-Url: https://github.com/github/gh-aw/sessions/73c62235-cc79-479b-b145-e8529c7c8ee0 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/create_pull_request.cjs | 2 +- docs/src/content/docs/reference/safe-outputs-pull-requests.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index 7acd2dbf5e3..4a98ed2afd7 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -466,7 +466,7 @@ async function handleRemoteBranchCollision(branchName, preserveBranchName, optio if (preserveBranchName) { // Reuse the existing branch by deleting the remote ref so the subsequent - // push recreates it from the local HEAD (force-push semantic). This is the + // push recreates it from the local HEAD (force-push semantics). This is the // intended behavior of preserve-branch-name for long-lived reusable // branches whose previous PR was merged. const { githubClient, owner, repo } = options; 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 56f046b5b0f..7a87ef17993 100644 --- a/docs/src/content/docs/reference/safe-outputs-pull-requests.md +++ b/docs/src/content/docs/reference/safe-outputs-pull-requests.md @@ -84,7 +84,7 @@ 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 reuses the existing branch by force-deleting the stale remote ref and recreating it from the agent's local HEAD (force-push semantic). This is the intended behavior for long-lived reusable branches whose previous PR was merged. The handler does not silently rename the branch in this case. +When `preserve-branch-name: true` and the agent-supplied branch name already exists on the remote, the workflow reuses the existing branch by force-deleting the stale remote ref and recreating it from the agent's local HEAD (force-push semantics). This is the intended behavior for long-lived reusable branches whose previous PR was merged. The handler does not silently rename the branch in this case. 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. From cb77ea6b728d0aaf338f01cec4c81336970eb649 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:17:06 +0000 Subject: [PATCH 5/7] Add reuse-existing-ref option to gate force-delete of existing remote branch Agent-Logs-Url: https://github.com/github/gh-aw/sessions/6e0849fa-2983-466f-a75a-b9ebf64dfabb Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- ...serve-branch-name-reuse-existing-branch.md | 2 +- actions/setup/js/create_pull_request.cjs | 47 +++++++++++++------ actions/setup/js/create_pull_request.test.cjs | 35 ++++++++++++-- .../reference/safe-outputs-pull-requests.md | 3 +- pkg/parser/schemas/main_workflow_schema.json | 5 ++ .../compiler_safe_outputs_handlers.go | 1 + pkg/workflow/create_pull_request.go | 1 + 7 files changed, 72 insertions(+), 22 deletions(-) diff --git a/.changeset/patch-preserve-branch-name-reuse-existing-branch.md b/.changeset/patch-preserve-branch-name-reuse-existing-branch.md index 56706b6d6f1..8ee8afcea70 100644 --- a/.changeset/patch-preserve-branch-name-reuse-existing-branch.md +++ b/.changeset/patch-preserve-branch-name-reuse-existing-branch.md @@ -2,4 +2,4 @@ "gh-aw": patch --- -Fix `create-pull-request` to reuse an existing remote branch when `preserve-branch-name: true` is enabled. Previously, the handler refused to push if the named branch already existed on the remote, blocking workflows that intentionally maintain long-lived reusable branches across iterations (e.g. autoloop programs whose previous PR was merged but whose branch still exists). The handler now force-deletes the stale remote ref and recreates the branch from the agent's local HEAD, matching the intent of `preserve-branch-name`. +Add `reuse-existing-ref` option to `create-pull-request` safe outputs. When set to `true` together with `preserve-branch-name: true`, the handler force-deletes an existing remote branch ref and recreates it from the agent's local HEAD (force-push semantics), enabling workflows that intentionally maintain long-lived reusable branches across iterations (e.g. autoloop programs whose previous PR was merged but whose branch still exists). When `reuse-existing-ref` is omitted or `false` (the default), an existing remote branch under `preserve-branch-name: true` causes the handler to fall back (e.g. open an issue) rather than overwrite the remote ref. diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index 4a98ed2afd7..51692218408 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -428,23 +428,30 @@ function generatePatchPreview(patchContent) { /** * Check whether the remote branch already exists and, if so, either reuse it - * (when preserve-branch-name is enabled, by force-deleting the remote ref so - * the subsequent push recreates it from the local HEAD) or rename the local - * branch by appending a random hex suffix. + * (when preserve-branch-name and reuse-existing-ref are enabled, by force-deleting + * the remote ref so the subsequent push recreates it from the local HEAD) or rename + * the local branch by appending a random hex suffix. * - * The "force-delete then recreate" semantic is used for preserve-branch-name + * The "force-delete then recreate" semantic is gated behind `reuse-existing-ref` * because the existing remote branch may have diverged from the local HEAD * (e.g. a long-lived branch whose previous PR was merged and is now behind * the base branch). Deleting the ref first lets `pushSignedCommits` recreate * the branch at the local commit's parent OID and replay only the local * commits via the GraphQL `createCommitOnBranch` mutation, which is what - * users intend by enabling preserve-branch-name on a reusable branch. + * users intend by enabling `reuse-existing-ref` on a reusable branch. + * + * When `preserve-branch-name: true` but `reuse-existing-ref: false` (default), + * an existing remote branch results in an error so the caller falls back to + * the configured fallback (e.g. opening an issue) rather than silently + * destroying the remote ref. * * @param {string} branchName - Current local branch name. * @param {boolean} preserveBranchName - Whether preserve-branch-name is enabled. * @param {object} [options] - Additional options. + * @param {boolean} [options.reuseExistingRef] - Whether reuse-existing-ref is enabled. + * Only meaningful when preserveBranchName is true. * @param {object} [options.githubClient] - Authenticated Octokit client used to delete the - * existing remote ref when preserve-branch-name is enabled. + * existing remote ref when reuse-existing-ref is enabled. * @param {string} [options.owner] - Repository owner for the deleteRef call. * @param {string} [options.repo] - Repository name for the deleteRef call. * @returns {Promise} The (possibly renamed) branch name to use going forward. @@ -465,19 +472,28 @@ async function handleRemoteBranchCollision(branchName, preserveBranchName, optio } if (preserveBranchName) { + const { reuseExistingRef, githubClient, owner, repo } = options; + if (!reuseExistingRef) { + // preserve-branch-name asked us to keep the exact branch name, but + // reuse-existing-ref is not enabled, so we cannot silently destroy the + // existing remote ref. Surface an error so the caller falls back to the + // configured fallback (e.g. opening an issue). + throw new Error( + `Remote branch "${branchName}" already exists and preserve-branch-name is enabled. ` + `Set reuse-existing-ref: true to force-delete and recreate the remote ref, or disable ` + `preserve-branch-name to allow renaming the branch.` + ); + } // Reuse the existing branch by deleting the remote ref so the subsequent // push recreates it from the local HEAD (force-push semantics). This is the - // intended behavior of preserve-branch-name for long-lived reusable - // branches whose previous PR was merged. - const { githubClient, owner, repo } = options; + // intended behavior when reuse-existing-ref is enabled for long-lived + // reusable branches whose previous PR was merged. if (!githubClient || !owner || !repo) { throw new Error( - `Remote branch "${branchName}" already exists and preserve-branch-name is enabled, ` + + `Remote branch "${branchName}" already exists and reuse-existing-ref is enabled, ` + `but no GitHub client was provided to delete the existing remote ref. This is an ` + `internal error: the caller must pass githubClient, owner, and repo to reuse the branch.` ); } - core.warning(`Remote branch ${branchName} already exists - reusing it (preserve-branch-name enabled, force-deleting remote ref)`); + core.warning(`Remote branch ${branchName} already exists - reusing it (reuse-existing-ref enabled, force-deleting remote ref)`); try { await githubClient.rest.git.deleteRef({ owner, repo, ref: `heads/${branchName}` }); core.info(`Deleted remote branch ${branchName} to reuse it`); @@ -491,7 +507,7 @@ async function handleRemoteBranchCollision(branchName, preserveBranchName, optio if (status === 422 && /Reference does not exist/i.test(message)) { core.info(`Remote branch ${branchName} was already deleted concurrently; continuing`); } else { - throw new Error(`Failed to delete existing remote branch "${branchName}" for reuse with preserve-branch-name: ${message || String(err)}`); + throw new Error(`Failed to delete existing remote branch "${branchName}" for reuse with reuse-existing-ref: ${message || String(err)}`); } } return branchName; @@ -527,6 +543,7 @@ async function main(config = {}) { const allowEmpty = parseBoolTemplatable(config.allow_empty, false); const autoMerge = parseBoolTemplatable(config.auto_merge, false); const preserveBranchName = config.preserve_branch_name === true; + const reuseExistingRef = config.reuse_existing_ref === true; const expiresHours = config.expires ? parseInt(String(config.expires), 10) : 0; const maxCount = config.max || 1; // PRs are typically limited to 1 const maxSizeKb = config.max_patch_size ? parseInt(String(config.max_patch_size), 10) : 1024; @@ -1240,7 +1257,7 @@ async function main(config = {}) { // Push the commits from the bundle to the remote branch try { - branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { githubClient, owner: repoParts.owner, repo: repoParts.repo }); + branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { reuseExistingRef, githubClient, owner: repoParts.owner, repo: repoParts.repo }); await pushSignedCommits({ githubClient, @@ -1449,7 +1466,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 { - branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { githubClient, owner: repoParts.owner, repo: repoParts.repo }); + branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { reuseExistingRef, githubClient, owner: repoParts.owner, repo: repoParts.repo }); await pushSignedCommits({ githubClient, @@ -1593,7 +1610,7 @@ ${patchPreview}`; await exec.exec(`git commit --allow-empty -m "Initialize"`); core.info("Created empty commit"); - branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { githubClient, owner: repoParts.owner, repo: repoParts.repo }); + branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { reuseExistingRef, githubClient, owner: repoParts.owner, repo: repoParts.repo }); await pushSignedCommits({ githubClient, diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index be3437137eb..01a03b0993e 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -1705,7 +1705,7 @@ describe("create_pull_request - patch apply fallback to original base commit", ( expect(global.core.warning).toHaveBeenCalledWith("No base_commit recorded in safe output entry - fallback not possible"); }); - it("should reuse existing remote branch when preserve-branch-name is true (force-delete then recreate)", async () => { + it("should reuse existing remote branch when preserve-branch-name and reuse-existing-ref are true (force-delete then recreate)", async () => { // Simulate the remote branch existing (ls-remote returns content) let renameCalled = false; global.exec = { @@ -1726,7 +1726,7 @@ describe("create_pull_request - patch apply fallback to original base commit", ( }; const { main } = require("./create_pull_request.cjs"); - const handler = await main({ preserve_branch_name: true }); + const handler = await main({ preserve_branch_name: true, reuse_existing_ref: true }); const result = await handler({ title: "Test PR", body: "Test body", patch_path: patchFilePath, branch: "preserve-me", base_commit: MOCK_BASE_COMMIT_SHA }, {}); @@ -1743,10 +1743,35 @@ describe("create_pull_request - patch apply fallback to original base commit", ( const warningCalls = global.core.warning.mock.calls.map(call => String(call[0])); expect(warningCalls.some(msg => msg.includes("appending random suffix"))).toBe(false); // Should have warned about reusing the branch - expect(warningCalls.some(msg => msg.includes("reusing it") && msg.includes("preserve-branch-name"))).toBe(true); + expect(warningCalls.some(msg => msg.includes("reusing it") && msg.includes("reuse-existing-ref"))).toBe(true); }); - it("should fall back to issue when deleteRef fails for preserve-branch-name reuse", async () => { + it("should fall back when preserve-branch-name is true but reuse-existing-ref is false and remote branch exists", async () => { + 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("already exists and preserve-branch-name is enabled"); + expect(result.error).toContain("reuse-existing-ref"); + // Should NOT have called deleteRef when reuse-existing-ref is not enabled + expect(global.github.rest.git.deleteRef).not.toHaveBeenCalled(); + }); + + it("should fall back to issue when deleteRef fails for reuse-existing-ref reuse", async () => { global.exec = { exec: vi.fn().mockResolvedValue(0), getExecOutput: vi.fn().mockImplementation((cmd, args) => { @@ -1761,7 +1786,7 @@ describe("create_pull_request - patch apply fallback to original base commit", ( global.github.rest.git.deleteRef = vi.fn().mockRejectedValue(Object.assign(new Error("Forbidden"), { status: 403 })); const { main } = require("./create_pull_request.cjs"); - const handler = await main({ preserve_branch_name: true, fallback_as_issue: false }); + const handler = await main({ preserve_branch_name: true, reuse_existing_ref: 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 }, {}); 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 7a87ef17993..61e0b8b9156 100644 --- a/docs/src/content/docs/reference/safe-outputs-pull-requests.md +++ b/docs/src/content/docs/reference/safe-outputs-pull-requests.md @@ -48,6 +48,7 @@ safe-outputs: fallback-as-issue: false # disable issue fallback (default: true) auto-close-issue: false # don't auto-add "Fixes #N" to PR description (default: true) preserve-branch-name: true # omit random salt suffix from branch name (default: false) + reuse-existing-ref: true # force-delete and recreate the remote branch when it already exists (requires preserve-branch-name; default: false) excluded-files: # files to omit from the patch entirely - "**/*.lock" - "dist/**" @@ -84,7 +85,7 @@ 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 reuses the existing branch by force-deleting the stale remote ref and recreating it from the agent's local HEAD (force-push semantics). This is the intended behavior for long-lived reusable branches whose previous PR was merged. The handler does not silently rename the branch in this case. +When `preserve-branch-name: true` and the agent-supplied branch name already exists on the remote, the default behavior is to fall back (e.g. open an issue when `fallback-as-issue: true`) rather than rename the branch or overwrite the remote ref. To enable reuse of the existing remote branch, set `reuse-existing-ref: true`: the handler will force-delete the stale remote ref and recreate it from the agent's local HEAD (force-push semantics). This is the intended behavior for long-lived reusable branches whose previous PR was merged. `reuse-existing-ref` requires `preserve-branch-name: true` to take effect; the handler does not silently rename the branch in this case. 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. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 793cf730117..5325ec4bcd7 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -5950,6 +5950,11 @@ "description": "When true, the random salt suffix is not appended to the agent-specified branch name. Invalid characters are still replaced for security, and casing is always preserved regardless of this setting. Useful when the target repository enforces branch naming conventions (e.g. Jira keys in uppercase such as 'bugfix/BR-329-red'). Defaults to false.", "default": false }, + "reuse-existing-ref": { + "type": "boolean", + "description": "When true (and preserve-branch-name is true), allows the handler to force-delete an existing remote branch ref and recreate it from the agent's local HEAD. When false (default), if the agent-specified branch already exists on the remote with preserve-branch-name enabled, the handler falls back (e.g. opens an issue) rather than overwriting the remote ref. Useful for long-lived reusable branches whose previous PR was merged.", + "default": false + }, "excluded-files": { "type": "array", "items": { diff --git a/pkg/workflow/compiler_safe_outputs_handlers.go b/pkg/workflow/compiler_safe_outputs_handlers.go index 1041cfc3ba2..e47e8ece0e4 100644 --- a/pkg/workflow/compiler_safe_outputs_handlers.go +++ b/pkg/workflow/compiler_safe_outputs_handlers.go @@ -416,6 +416,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_files", c.AllowedFiles). AddStringSlice("excluded_files", c.ExcludedFiles). AddIfTrue("preserve_branch_name", c.PreserveBranchName). + AddIfTrue("reuse_existing_ref", c.ReuseExistingRef). AddIfNotEmpty("patch_format", c.PatchFormat). AddIfTrue("staged", c.Staged) return builder.Build() diff --git a/pkg/workflow/create_pull_request.go b/pkg/workflow/create_pull_request.go index 99ebfae0093..51196a15257 100644 --- a/pkg/workflow/create_pull_request.go +++ b/pkg/workflow/create_pull_request.go @@ -42,6 +42,7 @@ type CreatePullRequestsConfig struct { AllowedFiles []string `yaml:"allowed-files,omitempty"` // Strict allowlist of glob patterns for files eligible for create. Checked independently of protected-files; both checks must pass. ExcludedFiles []string `yaml:"excluded-files,omitempty"` // List of glob patterns for files to exclude from the patch using git :(exclude) pathspecs. Matching files are stripped by git at generation time and will not appear in the commit or be subject to allowed-files or protected-files checks. PreserveBranchName bool `yaml:"preserve-branch-name,omitempty"` // When true, skips the random salt suffix on agent-specified branch names. Invalid characters are still replaced for security; casing is always preserved. Useful when CI enforces branch naming conventions (e.g. Jira keys in uppercase). + ReuseExistingRef bool `yaml:"reuse-existing-ref,omitempty"` // When true (and preserve-branch-name is true), allows the handler to force-delete an existing remote branch ref and recreate it from the agent's local HEAD. When false (default), an existing remote branch causes a fallback to issue (or push_failed). Useful for long-lived reusable branches whose previous PR was merged. PatchFormat string `yaml:"patch-format,omitempty"` // Transport format for packaging changes: "am" (default, uses git format-patch) or "bundle" (uses git bundle, preserves merge topology and per-commit metadata). AllowWorkflows bool `yaml:"allow-workflows,omitempty"` // When true, adds workflows: write to the GitHub App token. Requires safe-outputs.github-app to be configured. } From 9604a01aa40fbb9a96ad3a1419cd7c53fe336bd7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:28:21 +0000 Subject: [PATCH 6/7] Rename reuse-existing-ref to recreate-ref Agent-Logs-Url: https://github.com/github/gh-aw/sessions/2bfd1f6a-b277-46a4-bc57-1a2801884bae Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- ...patch-preserve-branch-name-recreate-ref.md | 5 +++ ...serve-branch-name-reuse-existing-branch.md | 5 --- actions/setup/js/create_pull_request.cjs | 36 +++++++++---------- actions/setup/js/create_pull_request.test.cjs | 16 ++++----- .../reference/safe-outputs-pull-requests.md | 4 +-- pkg/parser/schemas/main_workflow_schema.json | 2 +- .../compiler_safe_outputs_handlers.go | 2 +- pkg/workflow/create_pull_request.go | 2 +- 8 files changed, 36 insertions(+), 36 deletions(-) create mode 100644 .changeset/patch-preserve-branch-name-recreate-ref.md delete mode 100644 .changeset/patch-preserve-branch-name-reuse-existing-branch.md diff --git a/.changeset/patch-preserve-branch-name-recreate-ref.md b/.changeset/patch-preserve-branch-name-recreate-ref.md new file mode 100644 index 00000000000..8ae42550808 --- /dev/null +++ b/.changeset/patch-preserve-branch-name-recreate-ref.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Add `recreate-ref` option to `create-pull-request` safe outputs. When set to `true` together with `preserve-branch-name: true`, the handler force-deletes an existing remote branch ref and recreates it from the agent's local HEAD (force-push semantics), enabling workflows that intentionally maintain long-lived reusable branches across iterations (e.g. autoloop programs whose previous PR was merged but whose branch still exists). When `recreate-ref` is omitted or `false` (the default), an existing remote branch under `preserve-branch-name: true` causes the handler to fall back (e.g. open an issue) rather than overwrite the remote ref. diff --git a/.changeset/patch-preserve-branch-name-reuse-existing-branch.md b/.changeset/patch-preserve-branch-name-reuse-existing-branch.md deleted file mode 100644 index 8ee8afcea70..00000000000 --- a/.changeset/patch-preserve-branch-name-reuse-existing-branch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"gh-aw": patch ---- - -Add `reuse-existing-ref` option to `create-pull-request` safe outputs. When set to `true` together with `preserve-branch-name: true`, the handler force-deletes an existing remote branch ref and recreates it from the agent's local HEAD (force-push semantics), enabling workflows that intentionally maintain long-lived reusable branches across iterations (e.g. autoloop programs whose previous PR was merged but whose branch still exists). When `reuse-existing-ref` is omitted or `false` (the default), an existing remote branch under `preserve-branch-name: true` causes the handler to fall back (e.g. open an issue) rather than overwrite the remote ref. diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index 51692218408..286acece4ea 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -428,19 +428,19 @@ function generatePatchPreview(patchContent) { /** * Check whether the remote branch already exists and, if so, either reuse it - * (when preserve-branch-name and reuse-existing-ref are enabled, by force-deleting + * (when preserve-branch-name and recreate-ref are enabled, by force-deleting * the remote ref so the subsequent push recreates it from the local HEAD) or rename * the local branch by appending a random hex suffix. * - * The "force-delete then recreate" semantic is gated behind `reuse-existing-ref` + * The "force-delete then recreate" semantic is gated behind `recreate-ref` * because the existing remote branch may have diverged from the local HEAD * (e.g. a long-lived branch whose previous PR was merged and is now behind * the base branch). Deleting the ref first lets `pushSignedCommits` recreate * the branch at the local commit's parent OID and replay only the local * commits via the GraphQL `createCommitOnBranch` mutation, which is what - * users intend by enabling `reuse-existing-ref` on a reusable branch. + * users intend by enabling `recreate-ref` on a reusable branch. * - * When `preserve-branch-name: true` but `reuse-existing-ref: false` (default), + * When `preserve-branch-name: true` but `recreate-ref: false` (default), * an existing remote branch results in an error so the caller falls back to * the configured fallback (e.g. opening an issue) rather than silently * destroying the remote ref. @@ -448,10 +448,10 @@ function generatePatchPreview(patchContent) { * @param {string} branchName - Current local branch name. * @param {boolean} preserveBranchName - Whether preserve-branch-name is enabled. * @param {object} [options] - Additional options. - * @param {boolean} [options.reuseExistingRef] - Whether reuse-existing-ref is enabled. + * @param {boolean} [options.recreateRef] - Whether recreate-ref is enabled. * Only meaningful when preserveBranchName is true. * @param {object} [options.githubClient] - Authenticated Octokit client used to delete the - * existing remote ref when reuse-existing-ref is enabled. + * existing remote ref when recreate-ref is enabled. * @param {string} [options.owner] - Repository owner for the deleteRef call. * @param {string} [options.repo] - Repository name for the deleteRef call. * @returns {Promise} The (possibly renamed) branch name to use going forward. @@ -472,28 +472,28 @@ async function handleRemoteBranchCollision(branchName, preserveBranchName, optio } if (preserveBranchName) { - const { reuseExistingRef, githubClient, owner, repo } = options; - if (!reuseExistingRef) { + const { recreateRef, githubClient, owner, repo } = options; + if (!recreateRef) { // preserve-branch-name asked us to keep the exact branch name, but - // reuse-existing-ref is not enabled, so we cannot silently destroy the + // recreate-ref is not enabled, so we cannot silently destroy the // existing remote ref. Surface an error so the caller falls back to the // configured fallback (e.g. opening an issue). throw new Error( - `Remote branch "${branchName}" already exists and preserve-branch-name is enabled. ` + `Set reuse-existing-ref: true to force-delete and recreate the remote ref, or disable ` + `preserve-branch-name to allow renaming the branch.` + `Remote branch "${branchName}" already exists and preserve-branch-name is enabled. ` + `Set recreate-ref: true to force-delete and recreate the remote ref, or disable ` + `preserve-branch-name to allow renaming the branch.` ); } // Reuse the existing branch by deleting the remote ref so the subsequent // push recreates it from the local HEAD (force-push semantics). This is the - // intended behavior when reuse-existing-ref is enabled for long-lived + // intended behavior when recreate-ref is enabled for long-lived // reusable branches whose previous PR was merged. if (!githubClient || !owner || !repo) { throw new Error( - `Remote branch "${branchName}" already exists and reuse-existing-ref is enabled, ` + + `Remote branch "${branchName}" already exists and recreate-ref is enabled, ` + `but no GitHub client was provided to delete the existing remote ref. This is an ` + `internal error: the caller must pass githubClient, owner, and repo to reuse the branch.` ); } - core.warning(`Remote branch ${branchName} already exists - reusing it (reuse-existing-ref enabled, force-deleting remote ref)`); + core.warning(`Remote branch ${branchName} already exists - reusing it (recreate-ref enabled, force-deleting remote ref)`); try { await githubClient.rest.git.deleteRef({ owner, repo, ref: `heads/${branchName}` }); core.info(`Deleted remote branch ${branchName} to reuse it`); @@ -507,7 +507,7 @@ async function handleRemoteBranchCollision(branchName, preserveBranchName, optio if (status === 422 && /Reference does not exist/i.test(message)) { core.info(`Remote branch ${branchName} was already deleted concurrently; continuing`); } else { - throw new Error(`Failed to delete existing remote branch "${branchName}" for reuse with reuse-existing-ref: ${message || String(err)}`); + throw new Error(`Failed to delete existing remote branch "${branchName}" for reuse with recreate-ref: ${message || String(err)}`); } } return branchName; @@ -543,7 +543,7 @@ async function main(config = {}) { const allowEmpty = parseBoolTemplatable(config.allow_empty, false); const autoMerge = parseBoolTemplatable(config.auto_merge, false); const preserveBranchName = config.preserve_branch_name === true; - const reuseExistingRef = config.reuse_existing_ref === true; + const recreateRef = config.recreate_ref === true; const expiresHours = config.expires ? parseInt(String(config.expires), 10) : 0; const maxCount = config.max || 1; // PRs are typically limited to 1 const maxSizeKb = config.max_patch_size ? parseInt(String(config.max_patch_size), 10) : 1024; @@ -1257,7 +1257,7 @@ async function main(config = {}) { // Push the commits from the bundle to the remote branch try { - branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { reuseExistingRef, githubClient, owner: repoParts.owner, repo: repoParts.repo }); + branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { recreateRef, githubClient, owner: repoParts.owner, repo: repoParts.repo }); await pushSignedCommits({ githubClient, @@ -1466,7 +1466,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 { - branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { reuseExistingRef, githubClient, owner: repoParts.owner, repo: repoParts.repo }); + branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { recreateRef, githubClient, owner: repoParts.owner, repo: repoParts.repo }); await pushSignedCommits({ githubClient, @@ -1610,7 +1610,7 @@ ${patchPreview}`; await exec.exec(`git commit --allow-empty -m "Initialize"`); core.info("Created empty commit"); - branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { reuseExistingRef, githubClient, owner: repoParts.owner, repo: repoParts.repo }); + branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { recreateRef, githubClient, owner: repoParts.owner, repo: repoParts.repo }); await pushSignedCommits({ githubClient, diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index 01a03b0993e..423c45953a3 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -1705,7 +1705,7 @@ describe("create_pull_request - patch apply fallback to original base commit", ( expect(global.core.warning).toHaveBeenCalledWith("No base_commit recorded in safe output entry - fallback not possible"); }); - it("should reuse existing remote branch when preserve-branch-name and reuse-existing-ref are true (force-delete then recreate)", async () => { + it("should reuse existing remote branch when preserve-branch-name and recreate-ref are true (force-delete then recreate)", async () => { // Simulate the remote branch existing (ls-remote returns content) let renameCalled = false; global.exec = { @@ -1726,7 +1726,7 @@ describe("create_pull_request - patch apply fallback to original base commit", ( }; const { main } = require("./create_pull_request.cjs"); - const handler = await main({ preserve_branch_name: true, reuse_existing_ref: true }); + const handler = await main({ preserve_branch_name: true, recreate_ref: true }); const result = await handler({ title: "Test PR", body: "Test body", patch_path: patchFilePath, branch: "preserve-me", base_commit: MOCK_BASE_COMMIT_SHA }, {}); @@ -1743,10 +1743,10 @@ describe("create_pull_request - patch apply fallback to original base commit", ( const warningCalls = global.core.warning.mock.calls.map(call => String(call[0])); expect(warningCalls.some(msg => msg.includes("appending random suffix"))).toBe(false); // Should have warned about reusing the branch - expect(warningCalls.some(msg => msg.includes("reusing it") && msg.includes("reuse-existing-ref"))).toBe(true); + expect(warningCalls.some(msg => msg.includes("reusing it") && msg.includes("recreate-ref"))).toBe(true); }); - it("should fall back when preserve-branch-name is true but reuse-existing-ref is false and remote branch exists", async () => { + it("should fall back when preserve-branch-name is true but recreate-ref is false and remote branch exists", async () => { global.exec = { exec: vi.fn().mockResolvedValue(0), getExecOutput: vi.fn().mockImplementation((cmd, args) => { @@ -1766,12 +1766,12 @@ describe("create_pull_request - patch apply fallback to original base commit", ( expect(result.success).toBe(false); expect(result.error_type).toBe("push_failed"); expect(result.error).toContain("already exists and preserve-branch-name is enabled"); - expect(result.error).toContain("reuse-existing-ref"); - // Should NOT have called deleteRef when reuse-existing-ref is not enabled + expect(result.error).toContain("recreate-ref"); + // Should NOT have called deleteRef when recreate-ref is not enabled expect(global.github.rest.git.deleteRef).not.toHaveBeenCalled(); }); - it("should fall back to issue when deleteRef fails for reuse-existing-ref reuse", async () => { + it("should fall back to issue when deleteRef fails for recreate-ref reuse", async () => { global.exec = { exec: vi.fn().mockResolvedValue(0), getExecOutput: vi.fn().mockImplementation((cmd, args) => { @@ -1786,7 +1786,7 @@ describe("create_pull_request - patch apply fallback to original base commit", ( global.github.rest.git.deleteRef = vi.fn().mockRejectedValue(Object.assign(new Error("Forbidden"), { status: 403 })); const { main } = require("./create_pull_request.cjs"); - const handler = await main({ preserve_branch_name: true, reuse_existing_ref: true, fallback_as_issue: false }); + const handler = await main({ preserve_branch_name: true, recreate_ref: 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 }, {}); 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 61e0b8b9156..263e6affd8a 100644 --- a/docs/src/content/docs/reference/safe-outputs-pull-requests.md +++ b/docs/src/content/docs/reference/safe-outputs-pull-requests.md @@ -48,7 +48,7 @@ safe-outputs: fallback-as-issue: false # disable issue fallback (default: true) auto-close-issue: false # don't auto-add "Fixes #N" to PR description (default: true) preserve-branch-name: true # omit random salt suffix from branch name (default: false) - reuse-existing-ref: true # force-delete and recreate the remote branch when it already exists (requires preserve-branch-name; default: false) + recreate-ref: true # force-delete and recreate the remote branch when it already exists (requires preserve-branch-name; default: false) excluded-files: # files to omit from the patch entirely - "**/*.lock" - "dist/**" @@ -85,7 +85,7 @@ 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 default behavior is to fall back (e.g. open an issue when `fallback-as-issue: true`) rather than rename the branch or overwrite the remote ref. To enable reuse of the existing remote branch, set `reuse-existing-ref: true`: the handler will force-delete the stale remote ref and recreate it from the agent's local HEAD (force-push semantics). This is the intended behavior for long-lived reusable branches whose previous PR was merged. `reuse-existing-ref` requires `preserve-branch-name: true` to take effect; the handler does not silently rename the branch in this case. +When `preserve-branch-name: true` and the agent-supplied branch name already exists on the remote, the default behavior is to fall back (e.g. open an issue when `fallback-as-issue: true`) rather than rename the branch or overwrite the remote ref. To enable reuse of the existing remote branch, set `recreate-ref: true`: the handler will force-delete the stale remote ref and recreate it from the agent's local HEAD (force-push semantics). This is the intended behavior for long-lived reusable branches whose previous PR was merged. `recreate-ref` requires `preserve-branch-name: true` to take effect; the handler does not silently rename the branch in this case. 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. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 5325ec4bcd7..5e05504750d 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -5950,7 +5950,7 @@ "description": "When true, the random salt suffix is not appended to the agent-specified branch name. Invalid characters are still replaced for security, and casing is always preserved regardless of this setting. Useful when the target repository enforces branch naming conventions (e.g. Jira keys in uppercase such as 'bugfix/BR-329-red'). Defaults to false.", "default": false }, - "reuse-existing-ref": { + "recreate-ref": { "type": "boolean", "description": "When true (and preserve-branch-name is true), allows the handler to force-delete an existing remote branch ref and recreate it from the agent's local HEAD. When false (default), if the agent-specified branch already exists on the remote with preserve-branch-name enabled, the handler falls back (e.g. opens an issue) rather than overwriting the remote ref. Useful for long-lived reusable branches whose previous PR was merged.", "default": false diff --git a/pkg/workflow/compiler_safe_outputs_handlers.go b/pkg/workflow/compiler_safe_outputs_handlers.go index e47e8ece0e4..65c39172bbd 100644 --- a/pkg/workflow/compiler_safe_outputs_handlers.go +++ b/pkg/workflow/compiler_safe_outputs_handlers.go @@ -416,7 +416,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_files", c.AllowedFiles). AddStringSlice("excluded_files", c.ExcludedFiles). AddIfTrue("preserve_branch_name", c.PreserveBranchName). - AddIfTrue("reuse_existing_ref", c.ReuseExistingRef). + AddIfTrue("recreate_ref", c.RecreateRef). AddIfNotEmpty("patch_format", c.PatchFormat). AddIfTrue("staged", c.Staged) return builder.Build() diff --git a/pkg/workflow/create_pull_request.go b/pkg/workflow/create_pull_request.go index 51196a15257..4823fe04cc0 100644 --- a/pkg/workflow/create_pull_request.go +++ b/pkg/workflow/create_pull_request.go @@ -42,7 +42,7 @@ type CreatePullRequestsConfig struct { AllowedFiles []string `yaml:"allowed-files,omitempty"` // Strict allowlist of glob patterns for files eligible for create. Checked independently of protected-files; both checks must pass. ExcludedFiles []string `yaml:"excluded-files,omitempty"` // List of glob patterns for files to exclude from the patch using git :(exclude) pathspecs. Matching files are stripped by git at generation time and will not appear in the commit or be subject to allowed-files or protected-files checks. PreserveBranchName bool `yaml:"preserve-branch-name,omitempty"` // When true, skips the random salt suffix on agent-specified branch names. Invalid characters are still replaced for security; casing is always preserved. Useful when CI enforces branch naming conventions (e.g. Jira keys in uppercase). - ReuseExistingRef bool `yaml:"reuse-existing-ref,omitempty"` // When true (and preserve-branch-name is true), allows the handler to force-delete an existing remote branch ref and recreate it from the agent's local HEAD. When false (default), an existing remote branch causes a fallback to issue (or push_failed). Useful for long-lived reusable branches whose previous PR was merged. + RecreateRef bool `yaml:"recreate-ref,omitempty"` // When true (and preserve-branch-name is true), allows the handler to force-delete an existing remote branch ref and recreate it from the agent's local HEAD. When false (default), an existing remote branch causes a fallback to issue (or push_failed). Useful for long-lived reusable branches whose previous PR was merged. PatchFormat string `yaml:"patch-format,omitempty"` // Transport format for packaging changes: "am" (default, uses git format-patch) or "bundle" (uses git bundle, preserves merge topology and per-commit metadata). AllowWorkflows bool `yaml:"allow-workflows,omitempty"` // When true, adds workflows: write to the GitHub App token. Requires safe-outputs.github-app to be configured. } From 6240233c7e6ffc566969c32f67209cdbb2e903b3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:58:06 +0000 Subject: [PATCH 7/7] docs(spec): document preserve-branch-name and recreate-ref in safe-outputs spec Agent-Logs-Url: https://github.com/github/gh-aw/sessions/86fca68e-f8dc-4220-9001-fb2c1de35a52 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../docs/reference/safe-outputs-specification.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/src/content/docs/reference/safe-outputs-specification.md b/docs/src/content/docs/reference/safe-outputs-specification.md index d5c66d63dae..fda43cd8dcb 100644 --- a/docs/src/content/docs/reference/safe-outputs-specification.md +++ b/docs/src/content/docs/reference/safe-outputs-specification.md @@ -1579,6 +1579,8 @@ create-pull-request: commit-changes: true # Auto-commit workspace changes reviewers: [user1, copilot] # Auto-request reviewers labels: [automated] # Auto-apply labels + preserve-branch-name: false # Keep agent branch name verbatim (no random salt suffix) + recreate-ref: false # When preserve-branch-name and remote branch exists, force-delete and recreate the remote ref ``` **Asset Upload Extensions**: @@ -2179,6 +2181,14 @@ safe-outputs: 3. **Draft Status**: Creates as draft by default for safety. 4. **Auto-Commit**: When `commit-changes: true`, commits workspace changes before PR creation. 5. **Reviewer Assignment**: Auto-requests reviewers if configured. +6. **Branch Name Normalization**: The agent-supplied branch name is sanitized (invalid characters replaced; casing preserved). When `preserve-branch-name: false` (default), a random hex salt suffix is appended to ensure uniqueness across runs. When `preserve-branch-name: true`, the salt suffix is omitted so the branch name appears verbatim (useful for repository naming conventions, e.g. `bugfix/BR-329-red`). +7. **Remote Branch Collision Handling**: When the resolved branch name already exists on the remote, behavior depends on the configuration: + + | `preserve-branch-name` | `recreate-ref` | Behavior on collision | + |---|---|---| + | `false` (default) | n/a | Append random hex suffix to local branch name and continue | + | `true` | `false` (default) | Surface `push_failed`; caller falls back (e.g. opens an issue when `fallback-as-issue: true`) | + | `true` | `true` | Force-delete the existing remote ref via `DELETE /repos/{owner}/{repo}/git/refs/heads/{branch}` and let the subsequent push recreate it from the agent's local HEAD (force-push semantics). Concurrent-deletion 422 responses with "Reference does not exist" are treated as success. | **Configuration Parameters**: @@ -2191,6 +2201,8 @@ safe-outputs: - `labels`: Auto-apply labels - `title-prefix`: Prepend to titles - `footer`: Footer override +- `preserve-branch-name`: When `true`, use the agent-supplied branch name verbatim without appending a random salt suffix (default: `false`) +- `recreate-ref`: When `true` (and `preserve-branch-name: true`), allows the handler to force-delete an existing remote branch ref and recreate it from the agent's local HEAD on collision. When `false` (default), an existing remote branch under `preserve-branch-name: true` causes a fallback rather than overwriting the remote ref. Has no effect when `preserve-branch-name: false`. (default: `false`) **Security Requirements**: