feat(lan,companion,settings): support sharing multiple repositories
Brought to you by:
thebguy
Originally created by: theBGuy
Originally owned by: theBGuy
Extend the LAN Companion from a single shared repository to a selectable set of repositories, so phones can keep browsing a shared repository while the desktop switches to another. The desktop now manages persisted share state, while the companion scopes routes and data requests to the repository selected by the user.
src-tauri/src/lan/shared_repos.rs.src-tauri/src/lan/mod.rs with an active-plus-shared repository registry, repository install/unshare lifecycle handling, and scoped SSE monitor cuts.src-tauri/src/lan/routes/mod.rs, src-tauri/src/lan/routes/reviews.rs, and src-tauri/src/lan/server.rs.src-tauri/src/lib.rs and src-tauri/src/state.rs.src/lib/git/api.ts, src/lib/git/queries.ts, and src/lib/git/types.ts.src/features/settings/CompanionSection.tsx to share and unshare additional repositories while retaining the active repository automatically.src/App.tsx and src/lib/hotkeys/registry.ts to expose repository sharing through desktop commands and navigation.src/features/help/content.ts.companion/src/screens/Repos.tsx, including active-repository indication, sorting, selection state, and empty-state guidance.companion/src/lib/router.ts.companion/src/App.tsx and companion/src/components/Chrome.tsx to bootstrap repository selection, preserve the selected tab when switching, and make the top-bar repository name a switcher.companion/src/lib/api.ts and repository-scoped query caching and polling in companion/src/lib/queries.ts.companion/src/screens/Status.tsx, companion/src/screens/Prs.tsx, companion/src/screens/Ci.tsx, and companion/src/screens/Agents.tsx to fetch and navigate within the selected repository.companion/src/components/states.tsx.companion/src/lib/use-review-stream.ts.changelog.d/added-lan-companion-preview.md to document multiple shared repositories, switching behavior, and repository-level unsharing.
Originally posted by: cloudflare-workers-and-pages[bot]
Deploying gitdesktop with
Cloudflare Pages
7cb71b3View logs
Originally posted by: theBGuy
🤖 GitDesktop AI security audit ·
opus· automatedI've traced the full data flow across the new multi-repo LAN sharing feature. Verifying the key trust boundaries:
build_routermerges the/api/repos/{repoId}/…scoped subtree, the alias subtree, andGET /api/reposintoapi, which is wrapped inauth::require_auth(per-device bearer) and the outerhost_guard. The SSE stream route lives inside this protected subtree.repoId.resolve_scoped_repousesrepoIdpurely as aHashMaplookup key against the registry (state.repos…get(&repo_id)); a miss returns 404noSuchRepo. The filesystem path only ever comes from registry entries installed by the trusted desktop (share_repo/set_active_repo), never constructed from the wire id. An arbitrary/forged id just fails the lookup./api/reposemits only{id, name, active}; the test pins the absence ofpath/repoPath.install_active_repo/unshare_repo/MonitorCut: a registry entry survives only while at least one role (active or shared) holds it, and a repo leaving the registry always fires eitherRepo(path)(scoped end) orAll, while new connections to a removed id 404. There is no state where a phone retains access to a repo after both roles drop it.lan_share_repo/lan_unshare_repo/lan_shared_repos_listare Tauri commands callable only from the desktop webview; therepo_pathis the user's own path, not attacker-controlled. The phone-facing companion only ever calls read routes.repoId/streamIdare grammar-validated (REPO_ID_RE,STREAM_ID_RE) andencodeURIComponent-encoded before building same-origin relative EventSource/hash URLs;replace()builds a same-origin path — no attacker-controlled host/protocol. React escapes thename/pathdisplay; nodangerouslySetInnerHTML.No genuine security issues in these changes.
Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Context for reviewers — slice 4 of the LAN phone-companion epic (epic-branch PR into
epic/lan-companion; not master). Deliberate calls on record, so rounds can ground against them:/api/repos={id, name, active}, pinned by a route test asserting the exact keys and the absence ofpath). Unknown, malformed, and real-but-unshared ids all return the identical404 noSuchRepo— deliberate anti-enumeration; please don't suggest distinguishing them.MonitorCut::Allon disable/rebind/device-revoke stays deliberately coarse;Repo(path)fires only when a repo leaves the registry. A non-matching cut re-arms the SSE forward loop by emitting an inert empty comment frame (a bare:line — valid SSE, ignored byEventSource; covered by the router-level testsse_monitor_survives_a_scoped_cut_on_another_repoand observed live).Laggeddegrades toAllfail-safe.noSuchRepois definitive:RepoGoneStatetakes precedence even over cached content (found on a real phone mid-E2E, fixed, re-tested), andnoSuchRepois excluded from retries.lan-shared-repos.jsonstores paths only ({"repos":[{"path"}]}, unknown top-level keys preserved, atomic write + cache invalidation — thelan-devices.jsonidiom); names/ids are recomputed. Seeding atlan_enablesilently skips paths missing on disk; the desktop list still shows them from the store (no "unavailable" affordance this slice, deliberately). Corrupt individual entries are skipped defensively; code-written data can't produce them.lan-share-current-repo/lan-unshare-current-repohave mutually exclusive enabled gates; the palette hides disabled actions, so exactly one is visible at a time — a toggle by design, not a missing action.#status,#prs/4) still parse and auto-upgrade via hash-replace once/api/reposloads (bookmark back-compat). Repo ids are validated against/^[0-9a-f]{16}$/at both entry points (router parse + picker list), making a malformed-id redirect loop unreachable. A repo-less route with a failed repos query renders a retryable error state; 401 still routes centrally to#pair.Verification on record: cargo 861/861 + clippy
-D warningsclean (8 new registry/cut tests incl. the wire-shape pin), fullpnpm buildchain green, scoped biome,changelog:check. Machine E2E: unauthenticated 401 on all three surfaces, pairing ceremony twice via curl, per-repo scoped fetches (distinct repos return their own branch/status), alias mount byte-identical for the active repo, store shape verified on disk. Real-phone E2E: sharing via settings and palette, picker switching, a live agent watch surviving a desktop repo switch (the slice's marquee behavior), and unshare-mid-browse landing the teaching state within one poll.Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedI've now confirmed the key behaviors against the actual code. Here's my review.
Summary
This is a large, carefully-built slice that extends the LAN companion from one shared repo to an active ∪ shared registry, with scoped routes, scoped SSE cuts, a persisted store, a repo picker, and desktop share/unshare controls. The Rust registry bookkeeping is thorough and well-tested, and the companion routing/redirect logic is thoughtful. There is one blocker: the device-level
useReposprobe now runs on the pairing screen and can self-lock a revoked device out of re-pairing — a regression of a previously-fixed bug.Correctness
companion/src/lib/queries.ts(useRepos) +companion/src/App.tsx(the unconditionaluseRepos()call). The new probe drops theenabled: !route.isPairinggate thatuseStatushad, and additionally setsrefetchOnWindowFocus: true.App()callsuseRepos()before theif (route.isPairing) return <Pair />early-return, so on#pairit issuesGET /api/repos. For a device that was paired then revoked, the browser still holds thegd_lancookie, so that request is a present-but-invalid credential — andrequire_auth(src-tauri/src/lan/auth.rs:786-791) callsrecord_failurefor exactly that case (only a missing credential is penalty-free, line 772-778). Because a phone "backgrounds/foregrounds constantly" during re-pairing (walking between desktop and phone to read the PIN),refetchOnWindowFocus: truere-fires/api/reposon each foreground, banking a failure each time untilrate_checklocks the IP out — so the revoked device can't re-pair. This is the precise self-lockout the round-6 PR [#75] fix documented in thequeryCache.onErrorcomment ("re-sent its dead cookie every poll … until it rate-limited itself out of RE-pairing"), and the olduseStatus(active, !route.isPairing)fired zero authed traffic on#pairto prevent it. Fix: thread anenabledparam intouseReposand pass!route.isPairingfromApp(and don't leaverefetchOnWindowFocusfiring on the pairing takeover).Edge cases
src-tauri/src/lan/routes/mod.rs(list_repos, theactiveflag). The registry dedups by opaque id, butactiveis computed by path (repo_paths_match(&repo.path, active)). These identity bases disagree for a repo with two worktrees where one is shared and the other is active: they collide on one id → one registry entry, andseed_shared(mod.rs, called on everylan_enable) doesrepos.insert(id, RegisteredRepo{ path: shared_worktree_path, … }), overwriting the entry's path with the shared worktree path. Thenrepo_paths_match(shared_worktree_path, active_worktree_path)isfalse, so the open repo is reportedactive: falseon the wire and the picker shows no "Active" badge on the repo you're actually in. Therepo_paths_matchdoc even asserts active/shared/stream/cut identity "must all agree" — but this one branch uses path where everything else uses id. Fix: deriveactivefrom id equality against the active repo's resolved id rather than path-matching the (possibly-clobbered) stored path.Performance
src-tauri/src/lan/routes/mod.rs(list_repos).repo_paths_matchcan canonicalize (filesystem stat) both paths, and it's invoked for every non-active entry while thereposmutex is held./api/reposisn't polled (30s staleTime + focus), so it's low-frequency, but snapshotting the paths and doing the matching after dropping the lock would keep the registry mutex off the filesystem.Readability / UX
companion/src/components/Chrome.tsx(BottomNav) +App.tsxpicker. On#reposwith an ambiguous set (multiple repos, none active),route.repoIdisnull, so a bottom-nav tap goes to the legacy#${tab}, which the bootstrap effect resolves back toreplace("#repos")— the tab momentarily grabs roving focus and bounces straight back. The tabs are effectively inert on the picker; consider hiding/disabling the bottom nav there rather than rendering self-bouncing links.The Rust registry logic (active∪shared dedup-by-id, the scoped-cut
install_active_repo/unshare_repopaths, theshared_index→reposlock ordering, and the store's unknown-key preservation) is sound and has good test coverage for the coexistence, switch, unshare, and seed cases.Posted by GitDesktop — AI output, verify before acting on it.
Related
Tickets:
#75Originally posted by: theBGuy
Round-1 dispositions (AI review + audit; Copilot's three threads answered inline):
useReposon#pair→ re-pair self-lockout): accepted, fix in the next push. Verified end-to-end:useRepos()mounts unconditionally inApp()with noenabledgate plusrefetchOnWindowFocus: true, andrequire_authdeliberately penalizes a present-but-invalid credential (the PR [#75] lockout-budget design) — so a revoked device's stale cookie banks a rate-limit failure per phone-foreground during the pairing dance. This regresses the exact class [#75] closed, and the review's citation of thequeryCache.onErrorcomment is on point. Fix:useRepos(enabled)with!route.isPairingfrom the shell (the picker's own call is unreachable on#pair); a disabled query neither mount-fetches nor focus-refetches, so zero authed traffic on the pairing screen again. The post-pair race guards (errorUpdatedAt > lastPairedAt(),!isFetching) are untouched.activecomputed by path where everything else agrees on id): accepted with the suggested mechanism, fix in the next push. The two-worktree shared+active collision is real — the single id-keyed entry's stored path can be the other worktree's, so the open repo reportsactive: false. Fix:LanState.active_repo_id(already maintained by the registry bookkeeping) is shared intoRouterState, andlist_reposflagsactiveby id equality. This deletesrepo_paths_matchfromlist_reposentirely.reposmutex): resolved as a side effect of [#2] — with the id-equality flag there is no path matching (and no filesystem touch) inlist_reposat all.BottomNavon#reposgets the last scoped repo's id (tabs become a functional way out of the picker), and is hidden when no context exists.Also in the next push (Copilot threads): the picker's remembered
{repoId, tab}context and the palette twins gating on the shared list having loaded. The docs thread is declined per context-comment call [#7] (epic-close docs-sync). All fixes will re-run the full gates (cargo + clippy,pnpm build, scoped biome) before the push.Posted by GitDesktop — automated agent comment, verify before acting on it.
Related
Tickets:
#2Tickets:
#7Tickets:
#75Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedVerified against the code:
resolve_scoped_repo(routes/mod.rs:150–171) resolves a{repoId}request by readingrepos[id].pathverbatim into theScopedRepoon every request, andrepoNameis a real store field (ui.ts:163), so the palette-twin fallback is sound.Summary
This slice extends the LAN companion from a single active repo to an active ∪ shared registry — a persisted store, scoped routes/queries/streams, a repo picker, and desktop share/unshare controls. The Rust registry bookkeeping is thorough and well-tested (coexistence, switch, unshare, seed, scoped-cut cases are all covered), and the companion routing/redirect/teaching-state logic is careful. Overall sound; the two blockers/should-fixes from the previous round are genuinely fixed. One remaining correctness edge (a shared repo can be served under the wrong worktree path) is worth addressing but is niche.
Resolved since last review
#pair) — fixed.useReposnow takes anenabledgate;ApppassesuseRepos(!route.isPairing), andPairearly-returns beforeShellso the picker's ownuseRepos(true)can't mount on#pair. A disabled query neither mount-fetches nor focus-refetches, so zero authed traffic hits/api/reposduring the pairing dance. (Copilot's related picker-context and share-gating threads — App.tsx:224 / :171 — are also resolved:lastScoped/pickerContextpreserve repo+tab into the picker, and both share/unshare twins now gate onsharedLoaded.)activecomputed by path) — fixed.RouterState.active_repo_id(the sameArcLanStateowns) is now the source of truth, andlist_reposflagsactivebyactive_id == idrather thanrepo_paths_match, with a dedicated two-worktree test. This also removes the previous per-entrycanonicalizeunder thereposlock (the old perf nit).#reposwith no remembered context, and otherwise scoped tochromeContext.repoId.Correctness
src-tauri/src/lan/mod.rs(install_active_repo/share_repo) withroutes/mod.rs::resolve_scoped_repo. A repo that is both shared and active under a different worktree path has its single registry entry's storedpathclobbered to the active worktree's path, and that path is never restored when the repo stops being active — so scoped routes serve the wrong worktree's data. Concrete path: open worktreeC:/Rand share it (shared_index[X]=C:/R,repos[X].path=C:/R); open a linked worktreeC:/R-featureof the same repo — it resolves to the same idX(identity is the common git dir), andinstall_active_repodoesrepos.insert(X, {path: "C:/R-feature"}), overwriting the stored path; then switch the desktop to a third repo —install_active_reposeesstill_sharedand keeps the entry, leavingrepos[X].path == "C:/R-feature". Now a phone browsing repoXhitsresolve_scoped_repo, which readsrepos[X].path(C:/R-feature) and serves that worktree's Status/branch — not theC:/Rthe user actually shared. Only reachable with linked worktrees, so it's an edge, and it may overlap the "path-vs-id asymmetry (accepted)" the team noted — but that note was about the desktop-side verbatim already-shared check, not the served path, so it's worth a deliberate call. Fix: wheninstall_active_reporetains an entry becausestill_shared, reset its path toshared_index[prev_id](and/or don't letinstall/shareoverwrite a shared entry's stored path with a different worktree path), so a shared repo reverts to serving its shared path once it's no longer the active worktree.The rest of the registry logic holds up: the
shared_index→reposlock ordering (async mutex taken first,std::Mutexes only briefly inside, never across an.await), the seed/share/unshare/switch cut-scoping, and the store's unknown-key preservation are all correct and well-covered by the new tests.Copilot also flagged the missing README/marketing-site update (changelog:4). Not actionable here: the in-app help guide and changelog fragment are updated in this PR, and the README/site sync for the whole companion preview is a standing epic-branch deferral rather than an omission in this change.
Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round-2 disposition (one new should-fix):
Shared-path clobber under linked worktrees: accepted — fix in the next push. The trace is correct and the distinction from the recorded "path-vs-id asymmetry (accepted)" note is fair: that note covered the desktop-side already-shared check; this is the served path, which must not silently follow the active worktree. The fix makes the explicitly shared path authoritative for a shared repo's registry entry:
install_active_repono longer overwrites an entry whose id is in the shared set (it still records the active id, so the/api/reposactiveflag is unaffected), and sharing a repo whose entry came from the active mirror adopts the shared path. Net semantic, documented at the site: the scoped surface serves the worktree you explicitly shared, even while the desktop is active in a sibling worktree (the alias surface keeps serving the active path); switch-away needs no restore because the entry never lost the shared path. Tests will cover both orders (share-then-activate, activate-then-share) plus the reviewer's exact repro (path staysAwhile active underBand after switching away).Round-1 closures and the docs self-disposition are appreciated — nothing further on those.
Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
Round-2 disposition addendum (the fix as implemented, one step past the recorded plan): the implementer extended the shared-path-authoritative rule to the symmetric edge the disposition didn't name — unsharing a repo that is also the active repo re-points its surviving registry entry from the shared worktree path back to the active worktree path. Rationale: once the explicit share is gone, "active" is the entry's only remaining role, so serving the stale shared path would be the same bug class in the other direction. No cut fires (the repo never leaves the registry). Covered by a dedicated test alongside the reviewer's exact linked-worktree repro (real git worktrees, no mocks); suite is 864/864 with clippy
-D warningsclean.Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedI read the SSE
streamhandler (reviews.rs), the scoped resolver (routes/mod.rs), and confirmedrepoBasenameexists — enough to ground the review below.Summary
This slice extends the LAN companion from a single active repo to an active ∪ shared registry: a persisted store, scoped routes/queries/streams, a repo picker, and desktop share/unshare controls plus command-palette twins. The registry bookkeeping and its lock discipline are careful and unusually well-tested (coexistence, switch, unshare, seed, scoped-cut, and all three shared-path-authoritative worktree cases). Overall sound and mergeable; I found one genuine correctness gap in SSE cut-scoping, niche enough to be a should-fix rather than a blocker.
Resolved since last review
install_active_reponow only inserts the active entryif !shared.contains_key(&id),share_repo/seed_sharedmake the shared path authoritative, andunshare_repore-points an active entry back to the active worktree. Traced through the reviewer's exact repro (share A → open linked worktree B → switch away):repos[X].pathstays A. Backed byshared_worktree_path_survives_active_open_of_a_linked_worktree,active_first_then_share_makes_the_shared_path_authoritative, andunshare_active_repo_repoints_the_entry_to_the_active_worktree.Correctness
src-tauri/src/lan/routes/reviews.rs::forward_stream(theMonitorCut::Repo(p)arm,repo_paths_match(&p, &repo)) together withmod.rs::install_active_repo. Scoped cuts are matched to open streams by path, but this PR makes an entry's served path mutable for a fixed id, so a cut carrying the entry's current path fails to sever a stream that captured the entry's earlier path.Concrete case (one repo, two worktrees A/B, no sharing even required):
repos[X].path = A. A phone opens a live agent-run watch;resolve_scoped_reporeadsrepos[X].path = A, so the SSE stream capturesrepo = A(andsubscribe_in_formatches because the run was launched on A).install_active_reposeesstill_new(identical id), so it fires no cut and overwritesrepos[X].path = B. The open stream still holdsrepo = A.install_active_reporemoves X and firesMonitorCut::Repo(B)(repos.remove(prev_id).path, now B). The still-open stream'srepois A, sorepo_paths_match("B", "A")is false and the stream is never severed — the phone keeps watching a run whose repo has fully left the registry (its/api/repos/X/…routes now 404), until the run itself ends. Theuse-review-streamreviews-probe only reclassifies on stream close, so a long-running review/session stays watchable, violating the module's "paired devices never watch a run on a repo that isn't shared" containment.Before this PR the same worktree switch fired the unconditional
send(())cut, which ended every stream, so this is a regression introduced by the id-based dedup + mutable path. Thesse_monitor_survives_a_scoped_cut_on_another_repotest only exercises two distinct string paths, never a single id whose served path changes, so the gap is untested. (The linked-worktree share→active→unshare→switch-away sequence hits the same miss via theunshare_repore-point.)Fix: scope cuts by the stable repo id rather than the mutable served path — carry
repoIdinto the stream and intoMonitorCut::Repo, comparing ids — or, whenever an entry's served path changes while the entry stays registered (the same-id worktree switch, and the unshare-while-active re-point), fire aMonitorCut::Repo(old_path)so streams on the stale path reconnect and re-resolve to the new one.Readability
companion/src/screens/Prs.tsx,ActivitySection/ThreadsSection. The comments say a first-seennoSuchRepohere will "take over the whole screen with the teaching state," but each returns<RepoGoneState />at its own position inside the parent's<article>, so it renders inline (sandwiched underConversationSection), not full-screen — and if both sub-sections 404 you briefly get two stacked cards. It self-corrects within oneusePrpoll (or whenuseReposrefetches andShellswaps to the picker), so it's cosmetic; either soften the comment or lift the gone-state decision toPrDetail.The other reviewers' three threads are all addressed in the current diff: the picker context (
lastScoped/pickerContext) and the palette share-gating (sharedLoaded) are wired as described, and the README/marketing-site omission is the standing epic-branch deferral (the changelog fragment and in-app help guide are both updated here), not an omission in this change.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round-3 dispositions:
MonitorCut::Repo(<old path>)at the two places a registered entry's served path changes (the same-id active switch overwrite, and the unshare-while-active re-point), which severs exactly the streams that captured the stale path and restores pre-PR semantics for these cases (the unconditional cut also ended such streams; a run registered under the old worktree not appearing in lists scoped to the new one is likewise pre-existing behavior, and will be said at the fire site). Deliberately NOT taking the id-based alternative this round: carrying the stable id into stream registration/matching touchesstate.rs/agent.rsstream identity and is recorded as the architecturally-cleaner follow-up for the epic-close pass rather than a review-round patch.ActivitySection/ThreadsSectioncomments now describe the real behavior: the teaching state renders in place of the section as a transitional state, and the parent's own poll takes the full detail over within one cycle (the brief two-card race is transitional, not the destination). Comment-only change, applied by the orchestrator and verified withtsc -b+ biome.Round-2's closure re-trace and the cross-reviewer thread verifications are appreciated.
Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
Round-3 disposition addendum (fix as implemented, one site beyond the recorded plan): the disposition named the two served-path-mutation sites from the finding (
install_active_reposame-id switch,unshare_repore-point). The implemented fix covers a third site with the same invariant —share_repo's active-mirror overwrite (active under worktree B, then sharing path A flips the entry B→A) now also firesMonitorCut::Repo(B), since a stream captured on B would be stranded identically. The invariant as shipped: any change to a registered entry's served path fires a cut carrying the old path. Verified by cut-value assertions at all three sites plus a no-cut guard for identical-path re-activation/re-share; the pre-existing SSE test coversforward_streamhonoring a path-carrying cut. Suite 866/866, clippy clean.Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedI traced the SSE cut path end-to-end:
streamcapturesrepofromExtension<ScopedRepo>at connect (line 115/128/147) and passes it intoforward_stream; the cut fix carries the old served path at each of the three registry-mutation sites, which is exactly the string a stream on that path captured. ConfirmedrepoName/repoBasenameboth exist, so the desktop palette twins are grounded.Summary
Slice 4 extends the LAN companion registry from a single active repo to active ∪ shared, with a persisted store, scoped routes/queries/streams, a repo picker, and desktop share/unshare controls with command-palette twins. The registry lock discipline and cut-scoping are careful and unusually well-tested. The round-3 correctness gap (streams stranded when an entry's served path mutates under a fixed id) is now fixed and I can verify it in the current diff. Sound and mergeable — I found no blocking or should-fix issues in this revision.
Resolved since last review
MonitorCut::Repo(<old path>)now fires at each of the three sites where a registered entry's served path changes while the id stays registered:install_active_reposame-id worktree switch (if existing.path != repo.pathbefore the insert),share_repo's active-mirror overwrite (repos.get(&id).and_then(|e| (e.path != repo_path).then(...))),unshare_repo's re-point (if e.path != path { path_cut = Some(...) }).I re-walked the round-3 repro (active A → switch to same-repo worktree B → switch to unrelated Z): step 2 now fires
Repo(A), so the stream that captured A is severed viarepo_paths_match(A, A)and the phone re-resolves against B — the stream is no longer stranded. I also checked the two cut-assignment branches ininstall_active_repocan't clobber each other: the path-change branch only fires when the new id equalsprev_id(still_new, removal skipped), and the removal branch only when it differs and the new id has no pre-existing non-shared entry — so at most one served-path change per call, one cut. Lock ordering is consistent (shared_indexasync guard outermost, std mutexes taken sequentially and never nested, no await under a std guard), so it's deadlock-free. Backed bysame_id_active_worktree_switch_cuts_the_old_worktree_path,active_first_then_share_makes_the_shared_path_authoritative,unshare_active_repo_repoints_the_entry_to_the_active_worktree, and the no-cut guardsame_id_reactivation_with_identical_path_fires_no_cut.ActivitySection/ThreadsSectioncomments now accurately describe in-place rendering with the parent taking over within one poll, rather than the earlier "take over the whole screen" claim.Notes on other reviewers
App.tsx:224(picker can't preserve repo/tab) andApp.tsx:171(palette twin gating during initial load) are both addressed in the current diff —lastScoped/pickerContextcarry the last scoped context to the picker andBottomNav, and both twins gate onsharedLoaded = sharedRepos.data !== undefined. Not issues.changelog.d:4(README/marketing site don't mention the companion) reflects the standing epic-branch deferral — the changelog fragment and in-app help guide are updated here; the README/site docs-sync lands at the epic→master close. Not an omission in this change.Nothing else stands out — the frontend routing grammar degrades malformed
#r/…ids to the picker without looping (isRepoIdfilter inresolveRepo/sortRepos), the bootstrap redirect isreplace-based and self-terminates oncerepoId != null, and theactiveflag is correctly id-based (the two-worktree case is pinned by test 1b inlist_repos_lists_the_registered_repo_…).Posted by GitDesktop — AI output, verify before acting on it.
Ticket changed by: theBGuy