Skip to content

feat: worktree management — list, switch, create, remove, lock/unlock#2995

Open
stevenpollack wants to merge 10 commits into
gitui-org:masterfrom
stevenpollack:feat/worktrees
Open

feat: worktree management — list, switch, create, remove, lock/unlock#2995
stevenpollack wants to merge 10 commits into
gitui-org:masterfrom
stevenpollack:feat/worktrees

Conversation

@stevenpollack

Copy link
Copy Markdown

Summary

Adds a Worktrees popup (Shift+W, available from any tab) to manage
git 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:

  • List — primary (main) + all linked worktrees, each with branch, path, and 🔒/(invalid)
    markers; * marks the one gitui is currently in.
  • Switch (Enter) — reopens gitui against the selected worktree (reuses the existing
    submodule-style repo-switch flow; skipped for the current tree).
  • Create (c) — prompts for a path (absolute, or relative to the repo root); creates a linked
    worktree with a new branch named after the final path component. Missing parent directories are
    created (libgit2's worktree_add only makes the leaf, unlike git worktree add).
  • Remove (Shift+D) — confirmation dialog (accepts y/n); prunes the worktree and deletes
    its working dir. Refused, with a clear message, 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 itself, so the check is done in-app via is_workdir_clean).
  • Lock / unlock (l) — toggles the lock state.

Design notes

  • git2-native only — uses Repository::{worktrees, find_worktree, worktree, open_from_worktree}
    and Worktree::{prune, lock, unlock, is_locked, validate}; no shelling out to the git binary,
    per the repo convention.
  • Backend lives in asyncgit/src/sync/worktree.rs
    (get_worktrees / create_worktree / remove_worktree / toggle_worktree_lock), re-exported
    from sync/mod.rs, with unit tests.
  • UI follows the existing submodules-popup pattern (VerticalScroll + draw_list); registered
    via the setup_popups! macro. The switch path reuses InternalEvent::OpenRepo.
  • Scope intentionally excludes move / repair — libgit2 has no API for them, and shelling out
    would 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 Input reader is now created once in
main.rs and shared across restarts (8672275e).

Testing

  • make check green (fmt + clippy --workspace --all-features + nextest + tombi + deny), 324
    tests including new backend tests for create/remove/dirty-refusal/lock.
  • Manually exercised end-to-end (list, switch, create incl. nested paths, lock/unlock, remove, and
    the dirty-remove refusal).

Notes for reviewers

  • Adds a project-level CLAUDE.md (AI dev-harness notes). Happy to drop it if it's unwanted upstream.
  • WorktreeInfo carries #[allow(clippy::struct_excessive_bools)] for its four independent flags,
    matching existing precedent in src/components/status_tree.rs.
  • CHANGELOG updated under ## Unreleased.

🤖 Generated with Claude Code

stevenpollack and others added 10 commits July 14, 2026 08:20
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
@stevenpollack

Copy link
Copy Markdown
Author

Code review

An independent review of this branch surfaced two real defects in remove_worktree, both now fixed on the branch:

  1. Data-loss window on non-UTF8 worktree paths — the "uncommitted/untracked changes" guard was nested inside worktree.path().to_str(), so a non-UTF8 path skipped the check entirely and the working directory was pruned regardless of its contents. Fixed by passing the path through RepoPath: From<PathBuf> so the guard always runs (behavior unchanged for UTF-8 paths): 80322f01.

  2. Staged-only changes bypassed the guard — the 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 sole changes were git added read as clean and was removed, whereas git worktree remove refuses it. Fixed by composing an additional StatusType::Stage check: 7e96b0f3. Both fixes ship with regression tests.

Minor (not changed): when gitui runs inside a linked worktree whose main repo is bare, the synthesized (main) list entry can show an incorrect path (commondir().parent() isn't a working dir). Display-only, no data risk.

The rest — switch/create/lock guards, the confirm y/n gating and modal ordering, git2 prune usage, and the shared-Input refactor — reads as correct. make check is green (325 tests).

stevenpollack added a commit to stevenpollack/gitui that referenced this pull request Jul 14, 2026
…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
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