Skip to content

Local repos: display the checked-out branch, drop the branch axis#110

Merged
thalida merged 4 commits into
mainfrom
feat/issue-92-local-branch-display
Jul 19, 2026
Merged

Local repos: display the checked-out branch, drop the branch axis#110
thalida merged 4 commits into
mainfrom
feat/issue-92-local-branch-display

Conversation

@thalida

@thalida thalida commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Closes #92

What & why

For a local source, branch is display-only, never an input axis. A local repo scans whatever is checked out on disk, so a stored branch is a lie: it must not namespace the cache, the deep-link URL, or recents. Previously a local source was treated as a (src, branch) identity, which broke in a few places (most visibly: switching a local repo's checkout spawned a second recents row).

Design: normalize once at the commit boundary

The rule "a local source has no branch" is decided in exactly one place — when a source is committed — and everything downstream trusts it:

  • identityBranch(src, branch) (undefined for local, branch for remote) is applied only at the commit boundary: loadSource (fetch URL, overlay, error prefill) and setCurrentSource (CURRENT_SOURCE + the stored recent). So CURRENT_SOURCE and every recent are branch-less for local by construction.
  • sourceIdentity(src, branch) is the canonical identity string (src\0branch) — the one place the join + empty-branch coercion lives. sourceKey hashes it; recents dedupe and the active-row match compare it. No per-read source-kind switch.
  • Recents dedupe by identity, so two opens of the same local path (different checkouts, both committed branch-less) collapse to one row, with no @branch pill.
  • Active-row match compares each recent against CURRENT_SOURCE (the canonical applied source) rather than SOURCE_INFO (which carries the manifest's live checkout for display). Active is an identity question, so this is both correct and lets a branch-less local recent match exactly.
  • Storage bump recentsrecents.v2: pre-fix local entries stored a checkout branch; orphaning the old slot once is cheaper than migrating an MRU list, and removes the only stale-data case, so no defensive read-path normalization is needed.

Displaying the checked-out branch (read-only)

The header branch pill shows the live checkout: SOURCE_INFO.branch falls back to manifest.repo.branch via resolveBranch when no branch was explicitly chosen. The backend already populates repo.branch from HEAD for every scan (_collect_repo_info, gated by the existing CODECITY_ALLOW_LOCAL_REPOS + path validation), so no backend change was needed. On reopen after a checkout switch, the scan signature (which includes the branch) changes → cache miss → fresh rescan → the header shows the new branch.

Audit of the "breaks for local" surface

Swept every place that assumed a (src, branch) identity and confirmed it degrades sanely branch-less:

Surface Result
Recents dedupe / row key / active badge Fixed: one row per local path, matched against CURRENT_SOURCE
@branch pill on a recent row Absent for local (recent is branch-less)
Header pill Shows live checkout via manifest.repo.branch
sourceKey cache namespace Branch-less for local (committed branch-less)
Deep-link URL round-trip Branch omitted; stale ?branch= cleaned on load
Live-update poll Uses branch-less CURRENT_SOURCE; checkout change updates the signature
Source-picker prefill / re-open Prefill is branch-less for local; srcNeedsBranch already false
New-project form Already local-only-no-picker (unchanged)

Testing

TDD'd the source-key + recents logic. The branch-less-for-local guarantee is tested at the setCurrentSource write boundary (incl. "same local path, two checkouts → one recent"); the primitives (sourceKey / pushRecent / removeRecent) are branch-faithful. just test-app (2588 tests) and just lint pass.

Manual check

Open the same local repo, switch its checked-out branch, reopen: should be one recents row, and the header should show the live checked-out branch.

🤖 Generated with Claude Code

A local source scans whatever is checked out on disk, so a stored branch
is a lie: it must not namespace the cache, the URL, or recents. Add
identityBranch(src, branch) as the single "does this source carry a
branch" rule (undefined for local, branch for remote) and route sourceKey,
CURRENT_SOURCE, the deep-link URL, the fetch/overlay, and recents dedupe
through it.

- Recents now key + dedupe a local path by src alone, so switching its
  checkout no longer spawns a second row, and the row shows no @Branch
  identity pill.
- The header branch pill still shows the live checked-out branch: it falls
  back to manifest.repo.branch via resolveBranch/SOURCE_INFO (display only).

Backend already populates repo.branch from HEAD for every scan, so no
change there.

Closes #92

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@thalida thalida linked an issue Jul 19, 2026 that may be closed by this pull request
thalida and others added 3 commits July 19, 2026 13:26
Follow-up to the local-branch-axis work: the "local has no branch" rule
was being re-decided on every read (sourceKey, recents dedupe, the active
match all called identityBranch(...) ?? ''). Collapse it to one boundary.

- Add sourceIdentity(src, branch): the canonical identity string, one place
  the src\0branch join + empty-branch coercion lives. sourceKey hashes it;
  recents dedupe and the active match compare it.
- identityBranch now applies only at the commit boundary (loadSource +
  setCurrentSource), so CURRENT_SOURCE and every stored recent are already
  branch-less for local. sourceIdentity trusts that and does a plain join —
  no per-read source-kind switch.
- Bump the recents localStorage slot to recents.v2. Pre-v2 local entries
  stored a checkout branch; dropping the slot once is cheaper than migrating
  an MRU list, and it removes the only stale-data case the read guard existed
  for.
- RecentsList matches active rows against CURRENT_SOURCE (the canonical
  applied source) instead of SOURCE_INFO (which exposes the manifest's live
  checkout for display). Post-v2 a recent's branch equals CURRENT_SOURCE's on
  both kinds, so the match is exact; a local row no longer needs the display
  branch normalized away. Drop the now-redundant !isLocal pill guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The recents-dedupe predicate was private and RecentsList re-spelled the
same identity comparison inline. Export it as sameSourceIdentity (it takes
any two {src, branch?} refs, not just recents) and use it in the active-row
match, so pushRecent, removeRecent, and isActive all go through one "are
these the same source?" check. sourceIdentity (the string) still backs the
React key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sourceKey (+ djb2), sourceIdentity, and sameSourceIdentity are pure
functions on a source's (src, branch) identity with no signal dependency,
so they belong beside their siblings identityBranch/resolveBranch in
utils/sources, not in the stateful store. state/stores/source keeps the
signals, the recents mutations, and the URL effect built on them.

RecentsList now imports the two identity helpers from utils instead of
reaching into the store for pure logic; excludes + useManifestSource import
sourceKey from utils. Unit tests for the moved functions live in
tests/utils/sources.test.ts (mirroring the new home), with focused
sourceIdentity/sameSourceIdentity coverage added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@thalida
thalida merged commit cfcc838 into main Jul 19, 2026
1 check passed
@thalida
thalida deleted the feat/issue-92-local-branch-display branch July 19, 2026 17:52
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.

Local repos: display the checked-out branch, drop the branch axis

1 participant