Skip to content

Commit fbcd31b

Browse files
authored
Add recreate-ref option to create_pull_request for reusing an existing remote branch (#29153)
1 parent e0ee957 commit fbcd31b

8 files changed

Lines changed: 167 additions & 20 deletions

File tree

.changeset/patch-preserve-branch-name-recreate-ref.md

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

actions/setup/js/create_pull_request.cjs

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -427,15 +427,36 @@ function generatePatchPreview(patchContent) {
427427
}
428428

429429
/**
430-
* Check whether the remote branch already exists and, if so, either fail loudly
431-
* (when preserve-branch-name is enabled) or rename the local branch by appending
432-
* a random hex suffix.
430+
* Check whether the remote branch already exists and, if so, either reuse it
431+
* (when preserve-branch-name and recreate-ref are enabled, by force-deleting
432+
* the remote ref so the subsequent push recreates it from the local HEAD) or rename
433+
* the local branch by appending a random hex suffix.
434+
*
435+
* The "force-delete then recreate" semantic is gated behind `recreate-ref`
436+
* because the existing remote branch may have diverged from the local HEAD
437+
* (e.g. a long-lived branch whose previous PR was merged and is now behind
438+
* the base branch). Deleting the ref first lets `pushSignedCommits` recreate
439+
* the branch at the local commit's parent OID and replay only the local
440+
* commits via the GraphQL `createCommitOnBranch` mutation, which is what
441+
* users intend by enabling `recreate-ref` on a reusable branch.
442+
*
443+
* When `preserve-branch-name: true` but `recreate-ref: false` (default),
444+
* an existing remote branch results in an error so the caller falls back to
445+
* the configured fallback (e.g. opening an issue) rather than silently
446+
* destroying the remote ref.
447+
*
433448
* @param {string} branchName - Current local branch name.
434449
* @param {boolean} preserveBranchName - Whether preserve-branch-name is enabled.
450+
* @param {object} [options] - Additional options.
451+
* @param {boolean} [options.recreateRef] - Whether recreate-ref is enabled.
452+
* Only meaningful when preserveBranchName is true.
453+
* @param {object} [options.githubClient] - Authenticated Octokit client used to delete the
454+
* existing remote ref when recreate-ref is enabled.
455+
* @param {string} [options.owner] - Repository owner for the deleteRef call.
456+
* @param {string} [options.repo] - Repository name for the deleteRef call.
435457
* @returns {Promise<string>} The (possibly renamed) branch name to use going forward.
436-
* @throws {Error} If the remote branch exists and preserve-branch-name is true.
437458
*/
438-
async function handleRemoteBranchCollision(branchName, preserveBranchName) {
459+
async function handleRemoteBranchCollision(branchName, preserveBranchName, options = {}) {
439460
let remoteBranchExists = false;
440461
try {
441462
const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`);
@@ -451,11 +472,45 @@ async function handleRemoteBranchCollision(branchName, preserveBranchName) {
451472
}
452473

453474
if (preserveBranchName) {
454-
throw new Error(
455-
`Remote branch "${branchName}" already exists and preserve-branch-name is enabled. ` +
456-
`Refusing to silently rename the branch. Either delete the remote branch, choose a different ` +
457-
`branch name, or disable preserve-branch-name to allow a random suffix to be appended.`
458-
);
475+
const { recreateRef, githubClient, owner, repo } = options;
476+
if (!recreateRef) {
477+
// preserve-branch-name asked us to keep the exact branch name, but
478+
// recreate-ref is not enabled, so we cannot silently destroy the
479+
// existing remote ref. Surface an error so the caller falls back to the
480+
// configured fallback (e.g. opening an issue).
481+
throw new Error(
482+
`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.`
483+
);
484+
}
485+
// Reuse the existing branch by deleting the remote ref so the subsequent
486+
// push recreates it from the local HEAD (force-push semantics). This is the
487+
// intended behavior when recreate-ref is enabled for long-lived
488+
// reusable branches whose previous PR was merged.
489+
if (!githubClient || !owner || !repo) {
490+
throw new Error(
491+
`Remote branch "${branchName}" already exists and recreate-ref is enabled, ` +
492+
`but no GitHub client was provided to delete the existing remote ref. This is an ` +
493+
`internal error: the caller must pass githubClient, owner, and repo to reuse the branch.`
494+
);
495+
}
496+
core.warning(`Remote branch ${branchName} already exists - reusing it (recreate-ref enabled, force-deleting remote ref)`);
497+
try {
498+
await githubClient.rest.git.deleteRef({ owner, repo, ref: `heads/${branchName}` });
499+
core.info(`Deleted remote branch ${branchName} to reuse it`);
500+
} catch (deleteError) {
501+
/** @type {any} */
502+
const err = deleteError;
503+
const status = err && typeof err === "object" ? err.status : undefined;
504+
const message = err && typeof err === "object" ? String(err.message || "") : "";
505+
// 422 "Reference does not exist" can happen if the branch was deleted concurrently;
506+
// treat that as success and continue.
507+
if (status === 422 && /Reference does not exist/i.test(message)) {
508+
core.info(`Remote branch ${branchName} was already deleted concurrently; continuing`);
509+
} else {
510+
throw new Error(`Failed to delete existing remote branch "${branchName}" for reuse with recreate-ref: ${message || String(err)}`);
511+
}
512+
}
513+
return branchName;
459514
}
460515

461516
core.warning(`Remote branch ${branchName} already exists - appending random suffix`);
@@ -488,6 +543,7 @@ async function main(config = {}) {
488543
const allowEmpty = parseBoolTemplatable(config.allow_empty, false);
489544
const autoMerge = parseBoolTemplatable(config.auto_merge, false);
490545
const preserveBranchName = config.preserve_branch_name === true;
546+
const recreateRef = config.recreate_ref === true;
491547
const expiresHours = config.expires ? parseInt(String(config.expires), 10) : 0;
492548
const maxCount = config.max || 1; // PRs are typically limited to 1
493549
const maxSizeKb = config.max_patch_size ? parseInt(String(config.max_patch_size), 10) : 1024;
@@ -1201,7 +1257,7 @@ async function main(config = {}) {
12011257

12021258
// Push the commits from the bundle to the remote branch
12031259
try {
1204-
branchName = await handleRemoteBranchCollision(branchName, preserveBranchName);
1260+
branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { recreateRef, githubClient, owner: repoParts.owner, repo: repoParts.repo });
12051261

12061262
await pushSignedCommits({
12071263
githubClient,
@@ -1410,7 +1466,7 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${branchName} --repo
14101466

14111467
// Push the applied commits to the branch (with fallback to issue creation on failure)
14121468
try {
1413-
branchName = await handleRemoteBranchCollision(branchName, preserveBranchName);
1469+
branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { recreateRef, githubClient, owner: repoParts.owner, repo: repoParts.repo });
14141470

14151471
await pushSignedCommits({
14161472
githubClient,
@@ -1554,7 +1610,7 @@ ${patchPreview}`;
15541610
await exec.exec(`git commit --allow-empty -m "Initialize"`);
15551611
core.info("Created empty commit");
15561612

1557-
branchName = await handleRemoteBranchCollision(branchName, preserveBranchName);
1613+
branchName = await handleRemoteBranchCollision(branchName, preserveBranchName, { recreateRef, githubClient, owner: repoParts.owner, repo: repoParts.repo });
15581614

15591615
await pushSignedCommits({
15601616
githubClient,

actions/setup/js/create_pull_request.test.cjs

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1548,6 +1548,9 @@ describe("create_pull_request - patch apply fallback to original base commit", (
15481548
issues: {
15491549
addLabels: vi.fn().mockResolvedValue({}),
15501550
},
1551+
git: {
1552+
deleteRef: vi.fn().mockResolvedValue({}),
1553+
},
15511554
},
15521555
graphql: vi.fn(),
15531556
};
@@ -1702,8 +1705,48 @@ describe("create_pull_request - patch apply fallback to original base commit", (
17021705
expect(global.core.warning).toHaveBeenCalledWith("No base_commit recorded in safe output entry - fallback not possible");
17031706
});
17041707

1705-
it("should fail loudly when preserve-branch-name is true and remote branch already exists", async () => {
1708+
it("should reuse existing remote branch when preserve-branch-name and recreate-ref are true (force-delete then recreate)", async () => {
17061709
// Simulate the remote branch existing (ls-remote returns content)
1710+
let renameCalled = false;
1711+
global.exec = {
1712+
exec: vi.fn().mockImplementation((cmd, args) => {
1713+
const cmdStr = typeof cmd === "string" ? cmd : `${cmd} ${(args || []).join(" ")}`;
1714+
if (cmdStr.includes("git branch -m")) {
1715+
renameCalled = true;
1716+
}
1717+
return Promise.resolve(0);
1718+
}),
1719+
getExecOutput: vi.fn().mockImplementation((cmd, args) => {
1720+
const cmdStr = typeof cmd === "string" ? cmd : `${cmd} ${(args || []).join(" ")}`;
1721+
if (cmdStr.includes("ls-remote --heads origin")) {
1722+
return Promise.resolve({ exitCode: 0, stdout: "abc123\trefs/heads/preserve-me\n", stderr: "" });
1723+
}
1724+
return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" });
1725+
}),
1726+
};
1727+
1728+
const { main } = require("./create_pull_request.cjs");
1729+
const handler = await main({ preserve_branch_name: true, recreate_ref: true });
1730+
1731+
const result = await handler({ title: "Test PR", body: "Test body", patch_path: patchFilePath, branch: "preserve-me", base_commit: MOCK_BASE_COMMIT_SHA }, {});
1732+
1733+
expect(result.success).toBe(true);
1734+
// Should have called deleteRef to force-delete the existing remote branch
1735+
expect(global.github.rest.git.deleteRef).toHaveBeenCalledWith({
1736+
owner: "test-owner",
1737+
repo: "test-repo",
1738+
ref: "heads/preserve-me",
1739+
});
1740+
// Should NOT have renamed the local branch (preserve-branch-name keeps the name)
1741+
expect(renameCalled).toBe(false);
1742+
// Should NOT have warned about appending random suffix
1743+
const warningCalls = global.core.warning.mock.calls.map(call => String(call[0]));
1744+
expect(warningCalls.some(msg => msg.includes("appending random suffix"))).toBe(false);
1745+
// Should have warned about reusing the branch
1746+
expect(warningCalls.some(msg => msg.includes("reusing it") && msg.includes("recreate-ref"))).toBe(true);
1747+
});
1748+
1749+
it("should fall back when preserve-branch-name is true but recreate-ref is false and remote branch exists", async () => {
17071750
global.exec = {
17081751
exec: vi.fn().mockResolvedValue(0),
17091752
getExecOutput: vi.fn().mockImplementation((cmd, args) => {
@@ -1722,11 +1765,34 @@ describe("create_pull_request - patch apply fallback to original base commit", (
17221765

17231766
expect(result.success).toBe(false);
17241767
expect(result.error_type).toBe("push_failed");
1725-
expect(result.error).toContain('Remote branch "preserve-me" already exists');
1726-
expect(result.error).toContain("preserve-branch-name is enabled");
1727-
// Critical: should NOT have warned about appending random suffix (silent bypass)
1728-
const warningCalls = global.core.warning.mock.calls.map(call => String(call[0]));
1729-
expect(warningCalls.some(msg => msg.includes("appending random suffix"))).toBe(false);
1768+
expect(result.error).toContain("already exists and preserve-branch-name is enabled");
1769+
expect(result.error).toContain("recreate-ref");
1770+
// Should NOT have called deleteRef when recreate-ref is not enabled
1771+
expect(global.github.rest.git.deleteRef).not.toHaveBeenCalled();
1772+
});
1773+
1774+
it("should fall back to issue when deleteRef fails for recreate-ref reuse", async () => {
1775+
global.exec = {
1776+
exec: vi.fn().mockResolvedValue(0),
1777+
getExecOutput: vi.fn().mockImplementation((cmd, args) => {
1778+
const cmdStr = typeof cmd === "string" ? cmd : `${cmd} ${(args || []).join(" ")}`;
1779+
if (cmdStr.includes("ls-remote --heads origin")) {
1780+
return Promise.resolve({ exitCode: 0, stdout: "abc123\trefs/heads/preserve-me\n", stderr: "" });
1781+
}
1782+
return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" });
1783+
}),
1784+
};
1785+
// Simulate deleteRef failing with a non-recoverable error
1786+
global.github.rest.git.deleteRef = vi.fn().mockRejectedValue(Object.assign(new Error("Forbidden"), { status: 403 }));
1787+
1788+
const { main } = require("./create_pull_request.cjs");
1789+
const handler = await main({ preserve_branch_name: true, recreate_ref: true, fallback_as_issue: false });
1790+
1791+
const result = await handler({ title: "Test PR", body: "Test body", patch_path: patchFilePath, branch: "preserve-me", base_commit: MOCK_BASE_COMMIT_SHA }, {});
1792+
1793+
expect(result.success).toBe(false);
1794+
expect(result.error_type).toBe("push_failed");
1795+
expect(result.error).toContain('Failed to delete existing remote branch "preserve-me"');
17301796
});
17311797

17321798
it("should append random suffix when preserve-branch-name is false and remote branch already exists", async () => {

docs/src/content/docs/reference/safe-outputs-pull-requests.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ safe-outputs:
4848
fallback-as-issue: false # disable issue fallback (default: true)
4949
auto-close-issue: false # don't auto-add "Fixes #N" to PR description (default: true)
5050
preserve-branch-name: true # omit random salt suffix from branch name (default: false)
51+
recreate-ref: true # force-delete and recreate the remote branch when it already exists (requires preserve-branch-name; default: false)
5152
excluded-files: # files to omit from the patch entirely
5253
- "**/*.lock"
5354
- "dist/**"
@@ -84,7 +85,7 @@ The `excluded-files` field accepts a list of glob patterns. Each matching file i
8485

8586
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`.
8687

87-
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.
88+
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.
8889

8990
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.
9091

docs/src/content/docs/reference/safe-outputs-specification.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1579,6 +1579,8 @@ create-pull-request:
15791579
commit-changes: true # Auto-commit workspace changes
15801580
reviewers: [user1, copilot] # Auto-request reviewers
15811581
labels: [automated] # Auto-apply labels
1582+
preserve-branch-name: false # Keep agent branch name verbatim (no random salt suffix)
1583+
recreate-ref: false # When preserve-branch-name and remote branch exists, force-delete and recreate the remote ref
15821584
```
15831585

15841586
**Asset Upload Extensions**:
@@ -2179,6 +2181,14 @@ safe-outputs:
21792181
3. **Draft Status**: Creates as draft by default for safety.
21802182
4. **Auto-Commit**: When `commit-changes: true`, commits workspace changes before PR creation.
21812183
5. **Reviewer Assignment**: Auto-requests reviewers if configured.
2184+
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`).
2185+
7. **Remote Branch Collision Handling**: When the resolved branch name already exists on the remote, behavior depends on the configuration:
2186+
2187+
| `preserve-branch-name` | `recreate-ref` | Behavior on collision |
2188+
|---|---|---|
2189+
| `false` (default) | n/a | Append random hex suffix to local branch name and continue |
2190+
| `true` | `false` (default) | Surface `push_failed`; caller falls back (e.g. opens an issue when `fallback-as-issue: true`) |
2191+
| `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. |
21822192

21832193
**Configuration Parameters**:
21842194

@@ -2191,6 +2201,8 @@ safe-outputs:
21912201
- `labels`: Auto-apply labels
21922202
- `title-prefix`: Prepend to titles
21932203
- `footer`: Footer override
2204+
- `preserve-branch-name`: When `true`, use the agent-supplied branch name verbatim without appending a random salt suffix (default: `false`)
2205+
- `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`)
21942206

21952207
**Security Requirements**:
21962208

pkg/parser/schemas/main_workflow_schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5950,6 +5950,11 @@
59505950
"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.",
59515951
"default": false
59525952
},
5953+
"recreate-ref": {
5954+
"type": "boolean",
5955+
"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.",
5956+
"default": false
5957+
},
59535958
"excluded-files": {
59545959
"type": "array",
59555960
"items": {

pkg/workflow/compiler_safe_outputs_handlers.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@ var handlerRegistry = map[string]handlerBuilder{
416416
AddStringSlice("allowed_files", c.AllowedFiles).
417417
AddStringSlice("excluded_files", c.ExcludedFiles).
418418
AddIfTrue("preserve_branch_name", c.PreserveBranchName).
419+
AddIfTrue("recreate_ref", c.RecreateRef).
419420
AddIfNotEmpty("patch_format", c.PatchFormat).
420421
AddIfTrue("staged", c.Staged)
421422
return builder.Build()

0 commit comments

Comments
 (0)