Skip to content

fix: a bare's HEAD symref outlived the branch it named - #512

Merged
blooop merged 1 commit into
mainfrom
wayfinder/devlaunch-477
Aug 29, 2026
Merged

fix: a bare's HEAD symref outlived the branch it named#512
blooop merged 1 commit into
mainfrom
wayfinder/devlaunch-477

Conversation

@blooop

@blooop blooop commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Closes #477.

The lie

A bare clone remembers the default branch it was cloned with, in its HEAD
symref, and nothing repoints it afterwards. dl has never written a bare's
HEAD (no set-head, no remote show anywhere in the tree), and #477 decided
it should not start: repointing at fetch time is a state mutation standing in
for a reader that is not total, it costs a remote round trip, and it is wrong
between fetches anyway.

So a repository that renames master to main leaves the cache pointing at
refs/heads/master long after the prune that deleted the ref, and
git symbolic-ref HEAD keeps answering master, exit 0, forever. A symbolic
ref is a name, not a branch, and symbolic-ref does not check its target.

default_branch_of read that first answer and returned it, so every later
probe that could have answered correctly sat unreachable behind it.

The fix

The two symref probes now check that the name is a ref the clone really has
(show-ref --verify on the full ref, prefix and all, since refs/heads/main
and refs/remotes/origin/main are different refs), and a name that is not one
is treated exactly as a refusal is: ask the next probe, then branch -r, then
the caller's main.

The seam is the flow layer, RepositoryManager::default_branch_of, and
deliberately not the git client. clients/git states that a verb never falls
back to another verb and decides nothing about sequence, and there is no single
git command that both dereferences a symref and verifies its target
(symbolic-ref does the first, show-ref --verify the second;
rev-parse --verify --symbolic-full-name would do both but answers HEAD for
a detached HEAD, which is a new wrong name in place of the old one). Since
default_branch_of is the only reader of Git::symbolic_ref in the tree and
the only place a default branch is read off a clone, making it total leaves no
caller able to obtain a name that was read off a ref that is not there.

Cost: one extra local show-ref per clone or adopt. It reads refs off the disk
next to a git clone, and the cold-launch sequence test records it.

Where it was measured

register_existing_bare (the adopt path). It rebuilds a record by reading the
clone, so a user who deletes metadata.json had the dead branch written
straight back. real_git_does_not_re_record_a_default_branch_the_remote_has_deleted
builds that state the way it actually arrives: clone the cache while the default
is master, rename the remote's default to main, delete master upstream,
prune-fetch. Before this change the rebuilt record said master, a ref the
clone has not got; after it, main, which the clone has.

Not in scope

The recorded default_branch is written once and never revalidated, so a moved
default goes unnoticed until something fails on it. That is #507, with its own
trace, and nothing here changes it.

Tests

  • a_symbolic_ref_whose_branch_is_gone_is_not_an_answer (fake git: HEAD names a
    dead ref, the answer comes from refs/remotes/origin/HEAD; the argv list
    pins which ref is checked)
  • both_symbolic_refs_being_gone_falls_through_to_the_listing
  • real_git_does_not_re_record_a_default_branch_the_remote_has_deleted
  • the_default_branch_is_read_from_head_then_the_remote_head_then_the_listing
    and a_cold_launch_issues_exactly_this_sequence updated for the added probe

cargo test --workspace, cargo clippy --locked --all-targets -- -D warnings
and cargo fmt --check are green. No public API change, so no snapshot moved.

🤖 Generated with Claude Code

Summary by Sourcery

Ensure default-branch discovery ignores dangling symbolic refs so caches do not preserve deleted branch names.

Bug Fixes:

  • Prevent deleted upstream default branches from being recorded as valid defaults in bare-clone and repository-adoption flows.
  • Fall back to remote symbolic refs, branch listings, or the configured default when a symbolic ref points to a missing local ref.

Enhancements:

  • Validate symbolic-ref targets before using them to determine a repository’s default branch.

Documentation:

  • Document the handling of dangling symbolic refs and the distinction from later revalidation of recorded defaults.

Tests:

  • Add fake-Git and real-Git coverage for dangling symbolic refs, fallback ordering, adoption, and the updated cold-launch command sequence.

`git symbolic-ref HEAD` prints what HEAD points at and exits 0 whether or
not that ref is there, so a bare cache cloned when the default was
`master` kept answering `master` after the upstream rename and the prune
that deleted the ref. Nothing repoints it: dl has never written a bare's
HEAD, and #477 decided it should not start.

The reading verifies the ref before it believes the name, and treats a
name that is not a ref exactly as it treats a refusal: fall through to
the next probe. That leaves default_branch_of the whole of the reading,
so no caller can get a default branch read off a ref the repository has
not got.

The adopt path is where it was measured: register_existing_bare rebuilds
a record by reading the clone, so deleting metadata.json wrote the dead
branch straight back with every later fallback unreachable behind it.
@sourcery-ai

sourcery-ai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Reviewer's Guide

Fixes stale default-branch detection in bare clones by verifying each symbolic ref’s full target before accepting it, allowing fallback probes to identify a branch that still exists; adds focused fake-Git, integration, and sequence coverage plus changelog documentation.

Sequence diagram for verified default-branch detection

sequenceDiagram
    participant RepositoryManager
    participant Git
    RepositoryManager->>Git: symbolic_ref(repo_path, reference)
    Git-->>RepositoryManager: named ref
    RepositoryManager->>Git: verify_ref(repo_path, named)
    alt target ref exists
        Git-->>RepositoryManager: success
        RepositoryManager-->>RepositoryManager: return branch_in_symbolic_ref(named)
    else target ref is missing
        Git-->>RepositoryManager: refusal
        RepositoryManager->>Git: symbolic_ref(repo_path, next reference)
        Git-->>RepositoryManager: next named ref
        RepositoryManager->>Git: verify_ref(repo_path, next named ref)
    end
    opt both symbolic refs are invalid
        RepositoryManager->>Git: remote_branch_listing(repo_path)
        Git-->>RepositoryManager: existing remote branch
    end
Loading

Flow diagram for stale symbolic-ref fallback

flowchart TD
    A[default_branch_of] --> B[symbolic_ref HEAD]
    B --> C[verify_ref full target]
    C -->|exists| D[return branch name]
    C -->|missing| E[symbolic_ref refs/remotes/origin/HEAD]
    E --> F[verify_ref full target]
    F -->|exists| G[return branch name]
    F -->|missing| H[remote_branch_listing]
    H -->|branch found| I[return listed branch]
    H -->|no answer| J[return main fallback]
Loading

File-Level Changes

Change Details Files
Validate symbolic-ref targets before using them as default-branch answers.
  • Added a flow-layer helper that combines symbolic-ref lookup with full-ref verification via show-ref --verify.
  • Falls through from invalid HEAD and remote origin/HEAD targets to the next symbolic ref, remote listing, or fallback.
  • Preserved full ref prefixes so local and remote-tracking refs are validated distinctly.
rust/devlaunch-core/src/flows/repo_manager.rs
Add regression coverage for dangling symrefs, fallback ordering, and the real bare-clone adoption scenario.
  • Added fake-Git tests asserting invalid targets are skipped and command ordering is preserved.
  • Added an integration test covering upstream default-branch rename, prune, metadata deletion, and repository adoption.
  • Updated the cold-launch sequence test for the extra local verification command.
rust/devlaunch-core/src/flows/repo_manager.rs
rust/devlaunch-core/src/flows/workspace_clone.rs
Document the corrected behavior and its operational cost.
  • Added a changelog entry explaining why stale bare-repository HEAD symrefs are no longer recorded.
  • Documents that validation occurs during clone/adopt and does not revalidate already-recorded defaults.
CHANGELOG.md

Assessment against linked issues

Issue Objective Addressed Explanation
#477 Ensure a dangling bare repository HEAD symref is not treated as a valid default branch after the referenced branch is deleted upstream and pruned locally.
#477 Make default-branch detection fall through to other valid probes or the fallback branch when either symbolic ref names a ref that no longer exists.
#477 Verify the behavior against a real bare repository whose upstream default branch was renamed or deleted, including the adopt/rebuild path.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've reviewed your changes and they look great!


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@codecov

codecov Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.61%. Comparing base (669df03) to head (017a076).

Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 95.92% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
shipped code (rust) 95.92% <100.00%> (+<0.01%) ⬆️
harness and tooling (python) 42.98% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@blooop

blooop commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

Fresh-context adversarial review (session devlaunch-2b, orchestrator, not the author; GitHub refuses a self-approval under the shared account, so the review lands as a comment). Hunted three specific holes and could construct none: (1) probe 2 reintroducing the dangling-name lie one probe later — refuted, branch_at_symbolic_ref serves both probes and the fake-git argv pin proves the full refs/remotes/origin/* ref gets verified; (2) exit-code conflation in verify_ref reading a broken git as a missing ref — present but fails in the safe direction, towards the next probe and a loud ensure_branch failure, never towards a deletion; (3) a transient false-gone during a concurrent fetch or pack — bounded by the repo lock on the cold path and by #470's measured pack-vs-read concurrency, degrading to the listing probe. Spec exact: total readers, no writes to the bare's HEAD, #507 fenced off in the doc comment. Both sequence pins extended rather than weakened. Verdict: approve, merging.

@blooop
blooop merged commit e8cc92c into main Aug 29, 2026
15 checks passed
@blooop
blooop deleted the wayfinder/devlaunch-477 branch August 29, 2026 17:41
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.

The bare's HEAD symref outlives the branch it names

1 participant