Skip to content

refactor: model worktree as workspace, not branch#41

Merged
sebasv merged 2 commits intomainfrom
worktree-workspace-not-branch
Apr 25, 2026
Merged

refactor: model worktree as workspace, not branch#41
sebasv merged 2 commits intomainfrom
worktree-workspace-not-branch

Conversation

@sebasv
Copy link
Copy Markdown
Owner

@sebasv sebasv commented Apr 25, 2026

Motivation

Grove's data model conflated worktree with branchWorktree.branch: String served as both the rendered label and the worktree's identity. That hid two concrete bugs:

  1. Detached-HEAD linked worktrees were silently dropped from the sidebar. Mid-rebase, mid-bisect, or after an explicit commit checkout, the worktree disappeared from grove until HEAD landed on a branch again. (git::list_worktrees returned None for the entry.)
  2. git switch inside a worktree's terminal broke the active selection. ActiveWorktreeId was 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) replaces branch: 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).
  • RepoDirty re-lists worktrees and remaps state by path before refreshing statuses. git switch, git worktree add, git worktree remove performed in a terminal now flow into the sidebar automatically — no manual refresh.
  • PR polling and branch deletion skip detached-HEAD worktrees — they have no branch to operate on.

Tests

  • list_worktrees_includes_detached_linked_worktree — detached HEAD lands as HeadRef::Detached rather than disappearing.
  • reconcile_worktrees_picks_up_branch_switch_in_terminal — running git switch in 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:

  • Path-basename suffix when label diverges from path (helps the rename case where wt-feat-foo/ is now on bugfix/y).
  • A dedicated HEAD watcher — the existing recursive .git/ watcher already covers .git/worktrees/<name>/HEAD, so it's not needed yet.

Test plan

  • Open grove on a repo with a normal worktree; confirm sidebar still renders the same label.
  • Inside a worktree's terminal, run git switch -c some-other-branch; confirm the sidebar label updates within ~150ms (FS-watcher debounce) and the active selection sticks.
  • In a worktree, run git checkout HEAD~1 (detaches HEAD); confirm the row stays put and now shows (detached) <oid>.
  • Create a worktree via grove; confirm it still appears in the sidebar (no regression to try_create_worktree_modal).
  • Delete a worktree on a detached HEAD via grove; confirm the worktree is removed but no branch-deletion is attempted.
  • Delete ~/Library/Application Support/grove/state.toml is NOT required; the schema bump drops v1 state on its own with a warning.

🤖 Generated with Claude Code

sebasv and others added 2 commits April 25, 2026 10:53
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>
@sebasv sebasv merged commit c412fad into main Apr 25, 2026
1 check passed
@sebasv sebasv deleted the worktree-workspace-not-branch branch April 25, 2026 09:36
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.

1 participant