Skip to content

Add recreate-ref option to create_pull_request for reusing an existing remote branch#29153

Merged
pelikhan merged 8 commits intomainfrom
copilot/fix-create-pull-request-branch-issue
Apr 29, 2026
Merged

Add recreate-ref option to create_pull_request for reusing an existing remote branch#29153
pelikhan merged 8 commits intomainfrom
copilot/fix-create-pull-request-branch-issue

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 29, 2026

With preserve-branch-name: true, create_pull_request previously aborted (and fell back to opening an issue) whenever the target branch already existed on the remote. This breaks workflows like autoloop that intentionally maintain one long-lived branch per program: after a PR merges, the branch persists, so the next iteration's create_pull_request call always collides.

This PR introduces a new opt-in recreate-ref boolean field that gates the force-delete-and-recreate behavior. When enabled together with preserve-branch-name: true, the handler force-deletes the stale remote ref and lets the subsequent push recreate it from the agent's local HEAD (force-push semantics). When recreate-ref is omitted or false (the default), an existing remote branch under preserve-branch-name: true results in a fallback (e.g. opening an issue) rather than overwriting the remote ref.

Changes

  • actions/setup/js/create_pull_request.cjshandleRemoteBranchCollision now accepts a recreateRef option. When preserve-branch-name: true and the remote branch exists, it force-deletes the stale ref via githubClient.rest.git.deleteRef only if recreateRef is true, letting the subsequent pushSignedCommits recreate the branch from the local HEAD's parent OID through createCommitOnBranch (force-push semantics without needing git credentials at the agent step). When recreateRef is false, it throws so the caller falls back. Concurrent-deletion 422s are swallowed; other failures surface as push_failed. The three call sites now thread { recreateRef, githubClient, owner, repo } into the helper.
  • Go config / schema — Added RecreateRef bool field to CreatePullRequestsConfig, surfaced as recreate_ref in the handler config JSON, and added the recreate-ref boolean property to pkg/parser/schemas/main_workflow_schema.json.
  • Tests — Test for reuse behavior now requires recreate_ref: true (asserts deleteRef called with heads/<branch>, no local rename, success: true). Added a new test for the fallback path when preserve-branch-name: true and recreate_ref is omitted (asserts push_failed, no deleteRef call). Kept coverage for the deleteRef-failure path. Shared mock gains rest.git.deleteRef.
  • Docs / changesetsafe-outputs-pull-requests.md updated to describe the opt-in recreate-ref behavior; changeset updated accordingly. safe-outputs-specification.md (W3C-style spec) updated to document preserve-branch-name and recreate-ref in the create_pull_request section, including new operational semantics for branch name normalization and remote branch collision handling, an updated configuration parameters list, and a behavior matrix.

Behavior matrix

preserve-branch-name recreate-ref Remote branch exists Behavior
true true yes delete remote ref, recreate from HEAD
true true no push as-is
true false (default) yes error → fallback (e.g. issue)
true false (default) no push as-is
false n/a yes append random suffix
false n/a no push as-is

Copilot AI and others added 2 commits April 29, 2026 15:54
Copilot AI changed the title [WIP] Fix create_pull_request to reuse existing branch with preserve-branch-name Reuse existing remote branch in create_pull_request when preserve-branch-name is enabled Apr 29, 2026
Copilot finished work on behalf of mrjf April 29, 2026 15:57
Copilot AI requested a review from mrjf April 29, 2026 15:57
@pelikhan pelikhan marked this pull request as ready for review April 29, 2026 16:17
Copilot AI review requested due to automatic review settings April 29, 2026 16:17
@github-actions github-actions Bot mentioned this pull request Apr 29, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adjusts create_pull_request behavior so preserve-branch-name: true can reuse an existing remote branch instead of failing, enabling long-lived reusable branch workflows.

Changes:

  • Update remote-branch collision handling to delete the existing remote ref (when preserving branch name) and proceed with recreating the branch on push.
  • Update unit tests to assert reuse behavior and cover deleteRef failure behavior.
  • Update documentation and add a patch changeset describing the new reuse semantics.
Show a summary per file
File Description
actions/setup/js/create_pull_request.cjs Reworks remote-branch collision logic to reuse existing remote branch under preserve-branch-name via deleteRef + recreate on push.
actions/setup/js/create_pull_request.test.cjs Updates assertions for reuse behavior; adds coverage for deleteRef failure path; extends GitHub REST mock.
docs/src/content/docs/reference/safe-outputs-pull-requests.md Documents the new reuse behavior for preserve-branch-name when the remote branch already exists.
.changeset/patch-preserve-branch-name-reuse-existing-branch.md Adds a patch changeset entry describing the behavior change.

Copilot's findings

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comments suppressed due to low confidence (1)

actions/setup/js/create_pull_request.test.cjs:1765

  • This test name says it "should fall back to issue", but the test config sets fallback_as_issue: false and asserts error_type === "push_failed" (no fallback). Rename the test to match the behavior being verified (e.g., that deleteRef failures surface as push_failed when reuse is attempted).
  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) => {
        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: "" });
      }),
    };
    // 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 });

  • Files reviewed: 4/4 changed files
  • Comments generated: 2

`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
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The phrase "force-push semantic" is ungrammatical here; this is describing overall behavior, so "force-push semantics" (plural) is clearer.

Suggested change
// 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

Copilot uses AI. Check for mistakes.
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.
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The phrase "force-push semantic" reads like a grammatical error; this describes behavior/meaning, so "force-push semantics" (plural) is clearer and consistent with common usage. Consider updating this wording here and anywhere else it appears in nearby docs/comments.

Suggested change
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.

Copilot uses AI. Check for mistakes.
@github-actions
Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 88/100

Excellent test quality

Metric Value
New/modified tests analyzed 2
✅ Design tests (behavioral contracts) 2 (100%)
⚠️ Implementation tests (low value) 0 (0%)
Tests with error/edge cases 2 (100%)
Duplicate test clusters 0
Test inflation detected No (0.9:1 ratio)
🚨 Coding-guideline violations 0

Test Classification Details

View Per-Test Classification
Test File Classification Notes
should reuse existing remote branch when preserve-branch-name is true (force-delete then recreate) create_pull_request.test.cjs:1705 ✅ Design Verifies observable outputs: result.success, absence of rename, correct warning messages. One assertion on deleteRef.toHaveBeenCalledWith is an external side-effect check (GitHub API call) — acceptable.
should fall back to issue when deleteRef fails for preserve-branch-name reuse create_pull_request.test.cjs:1757 ✅ Design Error path: verifies result.success === false, correct error_type, and the human-readable error message when the GitHub API returns 403.

Minor Observations (Non-Blocking)

  • The expect(global.github.rest.git.deleteRef).toHaveBeenCalledWith(...) assertion in the first test verifies an external side-effect (GitHub API call to delete the remote branch). This is generally acceptable when the external call IS the behavior being contracted. However, pairing it with an end-to-end assertion (e.g., that the PR was ultimately created or the branch state is correct) would add further behavioral depth.
  • Neither test uses expect(..., "descriptive message") — vitest doesn't enforce this, but adding messages improves failure readability.

Language Support

  • 🟨 JavaScript (*.test.cjs): 2 tests analyzed (vitest)
  • 🐹 Go (*_test.go): No Go test files changed in this PR

Verdict

Check passed. 0% of new tests are implementation tests (threshold: 30%). Both new tests cover behavioral contracts — the happy path (reuse + force-delete existing branch) and the error path (graceful failure when deleteRef returns 403). Test inflation ratio is 0.9:1 (within the 2:1 limit). No coding-guideline violations detected.


📖 Understanding Test Classifications

Design Tests (High Value) verify what the system does:

  • Assert on observable outputs, return values, or state changes
  • Cover error paths and boundary conditions
  • Would catch a behavioral regression if deleted
  • Remain valid even after internal refactoring

Implementation Tests (Low Value) verify how the system does it:

  • Assert on internal function calls (mocking internals)
  • Only test the happy path with typical inputs
  • Break during legitimate refactoring even when behavior is correct
  • Give false assurance: they pass even when the system is wrong

Goal: Shift toward tests that describe the system's behavioral contract — the promises it makes to its users and collaborators.

References: §25120480700

🧪 Test quality analysis by Test Quality Sentinel · ● 492.8K ·

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Test Quality Sentinel: 88/100. Test quality is excellent — 0% of new tests are implementation tests (threshold: 30%). Both new tests cover behavioral contracts with appropriate error-path coverage.

@pelikhan
Copy link
Copy Markdown
Collaborator

@copilot review all comments

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 29, 2026

@copilot review all comments

Addressed both reviewer comments in 71a62a4 — "force-push semantic" → "force-push semantics" in create_pull_request.cjs (line 469) and safe-outputs-pull-requests.md (line 87). No other occurrences in the diff.

@pelikhan
Copy link
Copy Markdown
Collaborator

@copilot add a boolean field reuse_existing_ref which allows to delete and recreate the branch ref. Otherwise fallback.

@github-actions
Copy link
Copy Markdown
Contributor

✅ smoke-ci: safeoutputs CLI comment + comment-memory run (25125641307)

Generated by Smoke CI for issue #29153 ·

@github-actions
Copy link
Copy Markdown
Contributor

Comment Memory

CI lights the path
Green checks bloom at dawn
Quiet bots still sing

Note

This comment is managed by comment memory.

It stores persistent context for this thread in the code block at the top of this comment.
Edit only the text inside the backtick fences; workflow metadata and the footer are regenerated automatically.

Learn more about comment memory

Generated by Smoke CI for issue #29153 ·

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 29, 2026

@copilot add a boolean field reuse_existing_ref which allows to delete and recreate the branch ref. Otherwise fallback.

Added reuse-existing-ref boolean field in cb77ea6. Behavior:

  • preserve-branch-name: true + reuse-existing-ref: true → delete the existing remote ref and recreate from local HEAD (force-push semantics).
  • preserve-branch-name: true + reuse-existing-ref: false (default) → throw push_failed, falling back to issue (or the configured fallback).
  • preserve-branch-name: false → unchanged (append random suffix on collision).

Surfaced via ReuseExistingRef on CreatePullRequestsConfig, added to the workflow JSON schema and threaded through to the create_pull_request.cjs handler. Tests, docs and changeset updated accordingly.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • https://api.github.com/graphql
    • Triggering command: /usr/bin/gh /usr/bin/gh api graphql -f query=query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled } } -f owner=github -f name=gh-aw (http block)
    • Triggering command: /usr/bin/gh /usr/bin/gh api graphql -f query=query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled } } -f owner=github -f name=gh-aw /tmp/go-build2577140317/b001/importcfg -pack /tmp/go-build2577140317/b001/_testmain.go (http block)
    • Triggering command: /usr/bin/gh /usr/bin/gh api graphql -f query=query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled } } -f owner=github -f name=gh-aw f|append random -1 (http block)
  • https://api.github.com/repos/actions/checkout/git/ref/tags/v6
    • Triggering command: /usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/v6 --jq [.object.sha, .object.type] | @tsv (http block)
    • Triggering command: /usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/v6 --jq [.object.sha, .object.type] | @tsv get te-pull-request-branch-issue ock.yml credential.helpegit (http block)
    • Triggering command: /usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/v6 --jq [.object.sha, .object.type] | @tsv get HJ94ByTGM yml credential.userninfocmp (http block)
  • https://api.github.com/repos/actions/setup-node/git/ref/tags/v6
    • Triggering command: /usr/bin/gh gh api /repos/actions/setup-node/git/ref/tags/v6 --jq [.object.sha, .object.type] | @tsv (http block)
    • Triggering command: /usr/bin/gh gh api /repos/actions/setup-node/git/ref/tags/v6 --jq [.object.sha, .object.type] | @tsv kdAIFtwS7 .cfg 64/pkg/tool/linu-f credential.userngit (http block)
  • https://api.github.com/repos/astral-sh/setup-uv/git/ref/tags/eac588ad8def6316056a12d4907a9d4d84ff7a3b
    • Triggering command: /usr/bin/gh gh api /repos/astral-sh/setup-uv/git/ref/tags/eac588ad8def6316056a12d4907a9d4d84ff7a3b --jq [.object.sha, .object.type] | @tsv (http block)
  • https://api.github.com/repos/github/gh-aw
    • Triggering command: /usr/bin/gh gh api /repos/github/gh-aw --jq .default_branch get .cfg r: $owner, name: $name) { hasDiscussionsEnabled } } user.email (http block)

If you need me to access, download, or install something from one of these locations, you can either:

Copilot AI changed the title Reuse existing remote branch in create_pull_request when preserve-branch-name is enabled Add reuse-existing-ref option to create_pull_request for reusing an existing remote branch Apr 29, 2026
@pelikhan
Copy link
Copy Markdown
Collaborator

@copilot rename remove-create-ref into 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>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 29, 2026

@copilot rename remove-create-ref into recreate-ref

Renamed reuse-existing-refrecreate-ref everywhere (YAML key, handler config recreate_ref, Go field RecreateRef, JS option recreateRef, schema, tests, docs, changeset) in 9604a01. Behavior unchanged.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • https://api.github.com/graphql
    • Triggering command: /usr/bin/gh /usr/bin/gh api graphql -f query=query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled } } -f owner=github -f name=gh-aw (http block)
    • Triggering command: /usr/bin/gh /usr/bin/gh api graphql -f query=query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled } } -f owner=github -f name=gh-aw ud/lsDX24O6kIdo6-1 (http block)
  • https://api.github.com/repos/actions/checkout/git/ref/tags/v6
    • Triggering command: /usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/v6 --jq [.object.sha, .object.type] | @tsv (http block)
  • https://api.github.com/repos/actions/setup-node/git/ref/tags/v6
    • Triggering command: /usr/bin/gh gh api /repos/actions/setup-node/git/ref/tags/v6 --jq [.object.sha, .object.type] | @tsv (http block)
  • https://api.github.com/repos/astral-sh/setup-uv/git/ref/tags/eac588ad8def6316056a12d4907a9d4d84ff7a3b
    • Triggering command: /usr/bin/gh gh api /repos/astral-sh/setup-uv/git/ref/tags/eac588ad8def6316056a12d4907a9d4d84ff7a3b --jq [.object.sha, .object.type] | @tsv (http block)
  • https://api.github.com/repos/github/gh-aw
    • Triggering command: /usr/bin/gh gh api /repos/github/gh-aw --jq .default_branch (http block)

If you need me to access, download, or install something from one of these locations, you can either:

Copilot AI changed the title Add reuse-existing-ref option to create_pull_request for reusing an existing remote branch Add recreate-ref option to create_pull_request for reusing an existing remote branch Apr 29, 2026
@pelikhan
Copy link
Copy Markdown
Collaborator

@copilot do a deep review, use w3c-spec-writer to update spec

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 29, 2026

@copilot do a deep review, use w3c-spec-writer to update spec

Did a deep review and updated the W3C-style spec at docs/src/content/docs/reference/safe-outputs-specification.md in 6240233. The create_pull_request section now documents both preserve-branch-name and the new recreate-ref parameter with:

  • An expanded "Pull Request Extensions" example block including both fields.
  • New "Branch Name Normalization" and "Remote Branch Collision Handling" operational semantics, with a behavior matrix matching the PR description (preserve-branch-name × recreate-ref × remote-branch-exists).
  • Updated "Configuration Parameters" list with preserve-branch-name and recreate-ref.

Note: I don't have the w3c-spec-writer custom agent available in my environment (only adr-writer, contribution-checker, developer.instructions, opengrep-autofix), so I authored the spec edits directly in the existing W3C-style format used throughout that document.

Code review found no other issues — naming is consistent across cjs/Go/schema/tests, the cjs handler correctly threads recreateRef through both call sites and handles the concurrent-deletion 422 case, and tests cover all three branches (recreate success, recreate-ref omitted fallback, deleteRef failure).

@pelikhan pelikhan merged commit fbcd31b into main Apr 29, 2026
65 checks passed
@pelikhan pelikhan deleted the copilot/fix-create-pull-request-branch-issue branch April 29, 2026 21:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

create_pull_request should reuse existing branch when preserve-branch-name is enabled

4 participants