Local repos: display the checked-out branch, drop the branch axis#110
Merged
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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) andsetCurrentSource(CURRENT_SOURCE+ the stored recent). SoCURRENT_SOURCEand 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.sourceKeyhashes it; recents dedupe and the active-row match compare it. No per-read source-kind switch.@branchpill.CURRENT_SOURCE(the canonical applied source) rather thanSOURCE_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.recents→recents.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.branchfalls back tomanifest.repo.branchviaresolveBranchwhen no branch was explicitly chosen. The backend already populatesrepo.branchfrom HEAD for every scan (_collect_repo_info, gated by the existingCODECITY_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:CURRENT_SOURCE@branchpill on a recent rowmanifest.repo.branchsourceKeycache namespace?branch=cleaned on loadCURRENT_SOURCE; checkout change updates the signaturesrcNeedsBranchalready falseTesting
TDD'd the source-key + recents logic. The branch-less-for-local guarantee is tested at the
setCurrentSourcewrite boundary (incl. "same local path, two checkouts → one recent"); the primitives (sourceKey/pushRecent/removeRecent) are branch-faithful.just test-app(2588 tests) andjust lintpass.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