feat: worktree management — list, switch, create, remove, lock/unlock#2995
feat: worktree management — list, switch, create, remove, lock/unlock#2995stevenpollack wants to merge 10 commits into
Conversation
Document the exact feedback loop (make check, cargo check/clippy, fmt quirks), the compiler-enforced clippy bans (no unwrap/expect/panic; forbid(missing_docs) in asyncgit), and gotchas (insta snapshots, CHANGELOG requirement, no unused deps) so contributors and AI agents get deterministic feedback when authoring changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BMQxBdr5f3B3jmDtdvGtTu
Add a Worktrees popup (Shift+W from the Status tab) listing the primary and linked worktrees with branch, path, and lock/validity markers, and switch gitui to the selected worktree on Enter by reusing the existing OpenRepo reopen mechanism (absolute worktree paths pass through the handler unchanged). Backend: asyncgit sync::get_worktrees builds WorktreeInfo entries via git2 (worktrees/find_worktree/open_from_worktree), synthesizing the primary tree from commondir and flagging the current worktree; detached HEAD reports no branch. - new asyncgit/src/sync/worktree.rs (+ sync mod wiring, 3 tests) - new src/popups/worktrees.rs (+ app/queue/keys/strings/status wiring) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BMQxBdr5f3B3jmDtdvGtTu
Move the Shift+W trigger from the Status tab into the global key handler in App::event (alongside Options), so the popup opens from any tab, and register it in App::commands for the global help/command bar. The dedicated ViewWorktrees internal event is dropped: the global handler opens the popup directly, mirroring how Options works. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BMQxBdr5f3B3jmDtdvGtTu
Switching repos (enter submodule/worktree) rebuilds the whole App. Previously each App spun up its own input-reader thread, so during the teardown window the outgoing reader could read and drop the first keystroke after a switch (its channel receiver was already gone). Create the Input once in main and thread it into each Gitui, mirroring how the terminal is already reused across restarts. Keystrokes read during the swap are buffered in the channel and delivered to the new App instead of being lost. Fixes the swallowed keypress for both worktree and submodule switching. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BMQxBdr5f3B3jmDtdvGtTu
Press `c` in the Worktrees popup to open a Create Worktree input. Enter a path (absolute, or relative to the repo root); gitui creates a linked worktree there with a new branch named after the final path component, then the list refreshes to show it. - asyncgit: new `create_worktree(repo_path, path) -> Result<PathBuf>` via git2 `Repository::worktree` (no-ref -> new branch at HEAD), + 2 tests - new src/popups/create_worktree.rs (path input, no branch-name normalization; live-validates the derived branch name) - wiring: queue `CreateWorktree`, app field/ctor/macros/handler, worktrees popup `c` trigger, strings; App::update refreshes an open worktrees list Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BMQxBdr5f3B3jmDtdvGtTu
libgit2's `Repository::worktree` creates only the leaf directory, not missing parents (unlike `git worktree add`), so a nested path such as `worktrees/test-wt-2` failed with "failed to make directory ...: No such file or directory" when the parent did not exist. Create the parent chain via `create_dir_all` before the git2 call. Adds a regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BMQxBdr5f3B3jmDtdvGtTu
In the Worktrees popup: - `Shift+D` removes the selected linked worktree (confirmation dialog). The removal is refused — with a clear error — if the worktree is the current one, is locked, or has uncommitted/untracked changes, mirroring `git worktree remove` without `--force` (libgit2 does not refuse a dirty remove on its own, so the check is done in Rust via `is_workdir_clean`). - `l` toggles the lock state of the selected linked worktree; the 🔒 marker updates immediately. The primary "(main)" tree and the current worktree are gated out of both actions. - asyncgit: `remove_worktree` (prune with valid+working_tree, guarded) and `toggle_worktree_lock`; `WorktreeInfo` gains `is_main`; 3 tests - wiring: `Action::DeleteWorktree` -> confirm popup -> confirmed-action handler; `lock_worktree` key (`l`); strings + command hints Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BMQxBdr5f3B3jmDtdvGtTu
The worktree-removal confirmation now spells out its keys in the copy
("y = delete n / Esc = cancel") and accepts `y` to confirm and `n`
to cancel, in addition to the existing Enter/Esc. The y/n handling is
gated to the DeleteWorktree action, so every other confirmation dialog
(reset, stash, delete branch/tag/remote, aborts) is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMQxBdr5f3B3jmDtdvGtTu
…paths remove_worktree only ran the uncommitted/untracked-changes guard when the worktree path was valid UTF-8 (via path().to_str()). For a non-utf8 path the check was silently skipped and the working directory was pruned regardless of its contents — a data-loss window. RepoPath already implements From<PathBuf>, so pass the path directly and drop the to_str hop; behavior is identical for utf8 paths and the guard now always runs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The removal guard used only `is_workdir_clean`, which compares the index to the working directory and therefore misses staged-but- uncommitted changes (HEAD vs index). A worktree whose only changes were `git add`ed (workdir matching the index) was treated as clean and its working directory pruned, whereas `git worktree remove` refuses it. Compose an additional `get_status(StatusType::Stage)` check in `remove_worktree` so the guard matches its "uncommitted changes" contract. Adds a regression test. Found by an independent code review of gitui-org#2995. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BMQxBdr5f3B3jmDtdvGtTu
Code reviewAn independent review of this branch surfaced two real defects in
Minor (not changed): when gitui runs inside a linked worktree whose main repo is bare, the synthesized The rest — switch/create/lock guards, the confirm |
…ove, lock)
Adds a Worktrees popup (Shift+W, available from any tab) to manage git
worktrees without leaving gitui:
- list the primary + linked worktrees with branch / path / lock / valid
markers (* marks the current one)
- switch to the selected worktree (Enter), reusing the existing repo-switch
flow
- create a worktree at a path (c) with a new branch named after the final
path component; missing parent dirs are created
- remove a worktree (Shift+D) via a y/n confirmation; refused if it is the
current tree, is locked, or has uncommitted / staged / untracked changes
- lock / unlock a worktree (l)
Backend is git2-native in asyncgit/src/sync/worktree.rs (get_worktrees /
create_worktree / remove_worktree / toggle_worktree_lock) with tests; UI in
src/popups/{worktrees,create_worktree}.rs.
Also fixes a pre-existing bug where the first keystroke after switching
worktree/submodule was dropped, by sharing the input reader across app
restarts.
Squashed from branch feat/worktrees (PR gitui-org#2995).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMQxBdr5f3B3jmDtdvGtTu
Summary
Adds a Worktrees popup (
Shift+W, available from any tab) to managegit worktrees without leaving gitui. Motivation:
tooling that spins up parallel worktrees (and general multi-worktree workflows) had no in-app way
to see or hop between them — you had to quit and relaunch pointed at another path.
Related: #2990 (enhance workspace functionality), and touches the worktree area of #2920 / #2876.
What it does
From the Worktrees popup:
(main)+ all linked worktrees, each with branch, path, and🔒/(invalid)markers;
*marks the one gitui is currently in.Enter) — reopens gitui against the selected worktree (reuses the existingsubmodule-style repo-switch flow; skipped for the current tree).
c) — prompts for a path (absolute, or relative to the repo root); creates a linkedworktree with a new branch named after the final path component. Missing parent directories are
created (libgit2's
worktree_addonly makes the leaf, unlikegit worktree add).Shift+D) — confirmation dialog (acceptsy/n); prunes the worktree and deletesits working dir. Refused, with a clear message, if the worktree is the current one, is locked, or
has uncommitted/untracked changes — mirroring
git worktree removewithout--force(libgit2does not refuse a dirty remove itself, so the check is done in-app via
is_workdir_clean).l) — toggles the lock state.Design notes
Repository::{worktrees, find_worktree, worktree, open_from_worktree}and
Worktree::{prune, lock, unlock, is_locked, validate}; no shelling out to thegitbinary,per the repo convention.
asyncgit/src/sync/worktree.rs(
get_worktrees/create_worktree/remove_worktree/toggle_worktree_lock), re-exportedfrom
sync/mod.rs, with unit tests.VerticalScroll+draw_list); registeredvia the
setup_popups!macro. The switch path reusesInternalEvent::OpenRepo.move/repair— libgit2 has no API for them, and shelling outwould break the no-
git-binary convention.Bonus fix
While wiring the repo-switch, found and fixed a pre-existing bug: the first keystroke after
switching worktree/submodule was dropped, because each app restart spawned a fresh input-reader
thread and the outgoing thread could consume one event. The
Inputreader is now created once inmain.rsand shared across restarts (8672275e).Testing
make checkgreen (fmt + clippy--workspace --all-features+nextest+ tombi + deny), 324tests including new backend tests for create/remove/dirty-refusal/lock.
the dirty-remove refusal).
Notes for reviewers
CLAUDE.md(AI dev-harness notes). Happy to drop it if it's unwanted upstream.WorktreeInfocarries#[allow(clippy::struct_excessive_bools)]for its four independent flags,matching existing precedent in
src/components/status_tree.rs.## Unreleased.🤖 Generated with Claude Code