Skip to content

fix push for branches with stale tracking refs#124

Open
skarim wants to merge 1 commit into
skarim/pr-url-argsfrom
skarim/push-explicit-lease
Open

fix push for branches with stale tracking refs#124
skarim wants to merge 1 commit into
skarim/pr-url-argsfrom
skarim/push-explicit-lease

Conversation

@skarim

@skarim skarim commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

gh stack sync can rebase a stack successfully, then fail the final force push with stale info when a branch lacks a local tracking ref (refs/remotes/<remote>/<branch>). This is most visible in checkouts where local tracking refs have been pruned or never existed.

Root cause is two layers:

  1. FetchBranches pre-filters branches by existing tracking ref, so a branch with no tracking ref is never fetched and never gains one.
  2. Push uses a bare --force-with-lease flag, which has no lease basis for a branch without a tracking ref — git rejects the push.

The fix addresses both layers while preserving --force-with-lease safety (never overwrite work we have not seen).

Behavior matrix (after fix)

Branch state Tracking ref after fetch Lease emitted Outcome
Exists remotely, in sync yes, current explicit SHA force push succeeds
Exists remotely, advanced by someone else yes, current explicit SHA rejected (correct, protects their work)
Exists remotely, no local tracking ref before yes (created by fetch fix) explicit SHA force push succeeds (the bug fix)
Absent remotely (new branch) no empty expect created, or rejected if raced (safe)

Changes

internal/git/gitops.go:

  • FetchBranches: Remove pre-filter that skipped branches without existing tracking refs. Build explicit refspecs (+refs/heads/<branch>:refs/remotes/<remote>/<branch>) for every requested branch, creating or updating tracking refs regardless of prior state. The + prefix allows non-fast-forward tracking-ref updates. Keep the existing fast-path (single fetch for all branches) and per-branch fallback (one missing remote branch does not block the rest). Per-branch failures are tolerated — a branch absent on the remote simply gets no tracking ref, which is correct.
  • Push: When force=true, resolve each branch's tracking ref via rev-parse --verify --quiet refs/remotes/<remote>/<branch>. If found, emit --force-with-lease=refs/heads/<branch>:<sha> (explicit SHA lease). If missing (branch genuinely absent on remote), emit --force-with-lease=refs/heads/<branch>: (empty expected value = "must not exist"). Use explicit destination refspecs (<branch>:refs/heads/<branch>) to remove dependence on push.default and upstream configuration. The force=false path is unchanged.

internal/git/gitops_test.go:

  • Add 6 integration test scenarios using real bare git remotes (not mocks):
    1. Branch exists remotely with current tracking ref — push succeeds, remote updated
    2. Branch exists remotely, tracking ref deleted locally — push succeeds after fetch fix (regression test for gh stack sync can fail force-with-lease push after fetching stack branches #118)
    3. Branch advanced on remote by another client after fetch — push rejected (lease protects their work)
    4. New branch absent on remote — created via empty-expect lease
    5. New branch race condition (another client created it first) — push rejected
    6. Mixed stack: one branch with tracking ref + one without — both succeed after fetch fix

Other callers

  • cmd/push.go calls FetchBranches then Push with force=true — benefits automatically
  • cmd/submit.go calls Push with force=true per-branch — benefits automatically
  • cmd/link.go calls Push with force=false — unaffected (non-force path unchanged)

Stack created with GitHub Stacks CLIGive Feedback 💬

gh stack sync could rebase a stack successfully then fail the final
force push with "stale info" when a branch lacked a local tracking ref
(refs/remotes/<remote>/<branch>). This happened because:

1. FetchBranches pre-filtered branches by existing tracking ref, so a
   branch with no tracking ref was never fetched and never gained one.
2. Push used a bare --force-with-lease flag, which has no lease basis
   for a branch without a tracking ref, causing git to reject the push.

FetchBranches now uses explicit refspecs for every branch:
  +refs/heads/<branch>:refs/remotes/<remote>/<branch>
This creates or updates tracking refs regardless of prior state. The
fast-path (single fetch) and per-branch fallback (for branches absent
on the remote) are preserved.

Push now builds explicit per-branch lease arguments when force=true:
  --force-with-lease=refs/heads/<branch>:<tracking-ref-sha>
for branches with a tracking ref, or:
  --force-with-lease=refs/heads/<branch>:
(empty expected value = "must not exist") for branches absent on the
remote. Explicit destination refspecs (<branch>:refs/heads/<branch>)
remove dependence on push.default and upstream configuration. The
non-force push path is unchanged.

Added 6 integration tests using real bare git remotes:
- Branch with current tracking ref: push succeeds
- Tracking ref deleted locally (regression test for #118): push succeeds
- Remote advanced by another client: push rejected (safety preserved)
- New branch absent on remote: created via empty-expect lease
- New branch race condition: rejected (safety preserved)
- Mixed stack (tracked + untracked branches): all succeed after fetch

Fixes #118
@skarim skarim force-pushed the skarim/push-explicit-lease branch from dc07ed9 to 44aac93 Compare June 12, 2026 12:50
GitHub Advanced Security started work on behalf of skarim June 12, 2026 12:51 View session
GitHub Advanced Security finished work on behalf of skarim June 12, 2026 12:52
@skarim skarim marked this pull request as ready for review June 12, 2026 12:53
Copilot AI review requested due to automatic review settings June 12, 2026 12:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes issue #118 where gh stack sync could fail during force push when branches lack local tracking refs (refs/remotes/<remote>/<branch>). The fix addresses two root causes: FetchBranches skipping branches without existing tracking refs, and Push using bare --force-with-lease which has no lease basis without a tracking ref.

Changes:

  • FetchBranches now builds explicit refspecs for all requested branches, creating/updating tracking refs regardless of prior state
  • Push with force=true now resolves each branch's tracking ref SHA and uses explicit --force-with-lease=refs/heads/<branch>:<sha> arguments with explicit destination refspecs
  • Added 6 integration tests covering all scenarios (existing branch, missing tracking ref, remote advanced, new branch, race condition, mixed stack)
Show a summary per file
File Description
internal/git/gitops.go Rewrites FetchBranches to remove tracking-ref pre-filter and use explicit refspecs; rewrites Push force path to use per-branch explicit lease SHAs and refspecs
internal/git/gitops_test.go Adds integration tests using real bare git repos covering all combinations of tracking ref state and remote branch state

Copilot's findings

Tip

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

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

@georgebrock georgebrock left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The new approach makes sense to me 👍

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.

gh stack sync can fail force-with-lease push after fetching stack branches

3 participants