refactor: model worktree as workspace, not branch#41
Merged
Conversation
Grove conflated "worktree" with "branch" — `Worktree.branch: String` was
both the rendered label and the identity. That hid two real bugs:
- Detached-HEAD linked worktrees were silently dropped from the sidebar.
Mid-rebase, mid-bisect, or after an explicit commit checkout, the
worktree disappears from grove until HEAD lands on a branch again.
- `git switch` inside a worktree's terminal broke the active selection.
The active id was keyed by `(repo, branch)`, so the moment the branch
changed underneath, grove lost track of which row was active.
Both follow from the same conflation. The worktree's persistent identity
is its **path**; what HEAD points at is the *current state*. Fix:
- `Worktree.head: HeadRef = Branch(name) | Detached(short_oid)` replaces
`branch: String`. Detached HEAD is now first-class — `list_worktrees`
emits `HeadRef::Detached(<7-char-oid>)` instead of skipping the entry.
- `ActiveWorktreeId { path }` replaces `{ repo, branch }`. Selection is
resolved by path match; survives branch switches inside the worktree.
Persisted-state schema bumped to v2 (drops v1 active selection once).
- `RepoDirty` (FS watcher) now re-lists worktrees and remaps state by
path before refreshing statuses. So `git switch`, `git worktree add`,
and `git worktree remove` performed in a terminal all reflect in the
sidebar without the user reaching for `r`.
- PR polling and branch deletion skip detached-HEAD worktrees — they
have no branch to operate on.
Tests: detached-HEAD listing; reconcile picks up branch switch in
terminal; active selection survives branch switch.
Deferred (called out as follow-ups):
- Path-basename suffix when label diverges from path (rename case).
- Dedicated HEAD watcher — the existing recursive `.git/` watcher
already covers `.git/worktrees/<name>/HEAD`, so it's not needed yet.
Co-Authored-By: Claude <noreply@anthropic.com>
`load` parsed the full `PersistedState` struct before checking `schema_version`, so an existing v1 state file (with `(repo, branch)` under `[ui.active_worktree]`) raised a TOML parse error before the schema-version check could discard it. Peek at `schema_version` first via a tiny helper struct, drop mismatches with the existing warning, and only do the full parse when the version matches. Co-Authored-By: Claude <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.
Motivation
Grove's data model conflated worktree with branch —
Worktree.branch: Stringserved as both the rendered label and the worktree's identity. That hid two concrete bugs:git::list_worktreesreturnedNonefor the entry.)git switchinside a worktree's terminal broke the active selection.ActiveWorktreeIdwas keyed by(repo, branch), so when the branch changed underneath, grove lost the row.Both fall out of the same conflation. The worktree's persistent identity is its path; what HEAD points at is the current state — observable, mutable, not the worktree itself.
What changed
Worktree.head: HeadRef = Branch(name) | Detached(short_oid)replacesbranch: String. Detached HEAD is now first-class — listed in the sidebar with a(detached) <7-char-oid>label.ActiveWorktreeId { path: PathBuf }replaces{ repo, branch }. Resolution is by path match. Persisted-state schema bumped to v2 (one-time drop of v1 active selection).RepoDirtyre-lists worktrees and remaps state by path before refreshing statuses.git switch,git worktree add,git worktree removeperformed in a terminal now flow into the sidebar automatically — no manual refresh.Tests
list_worktrees_includes_detached_linked_worktree— detached HEAD lands asHeadRef::Detachedrather than disappearing.reconcile_worktrees_picks_up_branch_switch_in_terminal— runninggit switchin the worktree updates the label and keeps the active selection (path-keyed).active_worktree_survives_branch_switch_inside_worktree— pure unit test of the same invariant.Deferred
Called out as follow-ups, not in this PR:
wt-feat-foo/is now onbugfix/y)..git/watcher already covers.git/worktrees/<name>/HEAD, so it's not needed yet.Test plan
git switch -c some-other-branch; confirm the sidebar label updates within ~150ms (FS-watcher debounce) and the active selection sticks.git checkout HEAD~1(detaches HEAD); confirm the row stays put and now shows(detached) <oid>.try_create_worktree_modal).~/Library/Application Support/grove/state.tomlis NOT required; the schema bump drops v1 state on its own with a warning.🤖 Generated with Claude Code