From 24128f7d5aab641041f2e8394ebf81737b33824d Mon Sep 17 00:00:00 2001 From: Steven <2607235+stevenpollack@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:20:55 +1000 Subject: [PATCH 01/10] docs: add project CLAUDE.md with dev-harness notes 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 Claude-Session: https://claude.ai/code/session_01BMQxBdr5f3B3jmDtdvGtTu --- CLAUDE.md | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..a8e28a2ada --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,58 @@ +# gitui — working notes for Claude + +gitui is a Rust TUI git client. Cargo **workspace**: root crate `gitui` (the `src/` TUI binary) + +members `asyncgit` (git logic over `git2`/`gix`), `filetreelist`, `git2-hooks`, `git2-testing`, +`scopetime`. MSRV **1.88** (`Cargo.toml` `rust-version`, `.clippy.toml` `msrv`, CI matrix — keep +all three in sync if ever bumped). + +## Feedback loop (run these — the reviewer here does not read Rust, so tooling IS the review) + +- **Fast inner loop while editing:** `cargo check -p asyncgit` (backend) / `cargo check` (whole + workspace). Never iterate with `--release` — LTO + `opt-level="z"` makes it very slow. +- **Format before every check:** `cargo fmt`. Formatting is unusual (`rustfmt.toml`: + `max_width = 70`, `hard_tabs = true`) — hand-written code almost never matches, and + `cargo fmt -- --check` is a CI gate. +- **Full gate before declaring done / committing:** `make check` + = `fmt` (`cargo fmt -- --check`) + `clippy` (`cargo clippy --workspace --all-features`) + + `test` (`cargo nextest run --workspace`) + `sort` (`tombi format --check`) + `deny` + (`cargo deny check`). CI calls the same `make` targets, so green locally ≈ green CI. +- **Subset fallback** if a tool is missing: `cargo fmt -- --check` + + `cargo clippy --workspace --all-features` + `cargo test --workspace` (plain `cargo test` works; + it just also runs doctests that nextest skips). +- **Run a single crate's tests:** `cargo test -p asyncgit `. + +Required tooling: `cargo-nextest`, `cargo-deny`, `tombi` (installed via mise), plus `python`, +`gpgsm`/`gpg`, `openssl`, `perl`, a C compiler (vendored OpenSSL + bundled libgit2 build from +source on first compile — the first build is slow, later ones are cached). + +## Hard rules clippy enforces (these fail the build, not just warn) + +Crate roots carry `#![deny(clippy::all, perf, nursery, pedantic, cargo)]` plus bans below. +- **No `unwrap` / `expect` / `panic!` in production code** — use `?`, `ok_or(...)`, + `unwrap_or_default()`, `map_or(...)`. (`unwrap`/`expect` are fine in `#[cfg(test)]` code.) +- `asyncgit` has `#![forbid(missing_docs)]` — **every `pub` item needs a `///` doc comment**. +- `#![forbid(unsafe_code)]` in the binary crate. + +## Other gotchas + +- **UI output is snapshot-tested with `insta`** (`src/snapshots/*.snap`). If you change rendered + output, tests fail with a diff — review and accept via `cargo insta review` / `cargo insta + accept`, then commit the updated `.snap` files. (Install once: `cargo install cargo-insta`.) +- **CHANGELOG.md** must gain an entry under `## Unreleased` (`Added`/`Changed`/`Fixes`) or the CI + `log-test` job fails. Format: Keep-a-Changelog, with issue/PR links. +- Only the `stable` and `1.88` CI rows gate (nightly is `continue-on-error`). `cargo +nightly + udeps` runs in CI but not in `make check`, so **don't add unused dependencies**. +- Production code never shells out to the `git` binary; use `git2`/`gix`. The only + `Command::new("git")` calls live in test helpers (`debug_cmd_print` in `asyncgit/src/sync/mod.rs`). + +## Where things live (patterns to copy) + +- Sync git ops: `asyncgit/src/sync/.rs`, re-exported from `asyncgit/src/sync/mod.rs`. Each + op: `pub fn f(repo_path: &RepoPath, ...) -> Result { scope_time!("f"); let repo = + repo(repo_path)?; ... }`. Test helpers (`repo_init`, `debug_cmd_print`) in `sync/mod.rs::tests`. +- Popups (Branches/Submodules/etc.): `src/popups/.rs`, registered in `src/popups/mod.rs` and + the `setup_popups!` macro in `src/app.rs`; opened via an `InternalEvent` in `src/queue.rs`. +- Components implement `Component` + `DrawableComponent` (`src/components/mod.rs`). Non-commit list + views use `VerticalScroll` + `ui::scrolllist::draw_list` (see `src/popups/submodules.rs`). +- Keybindings: `src/keys/key_list.rs` (`KeysList` struct + `Default`). Command/help strings: + `src/strings.rs`. From 51f7f347ef53ed5229916f30161d4b1d9eee0421 Mon Sep 17 00:00:00 2001 From: Steven <2607235+stevenpollack@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:20:55 +1000 Subject: [PATCH 02/10] feat(worktrees): list worktrees and switch between them 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 Claude-Session: https://claude.ai/code/session_01BMQxBdr5f3B3jmDtdvGtTu --- CHANGELOG.md | 1 + asyncgit/src/sync/mod.rs | 2 + asyncgit/src/sync/worktree.rs | 226 ++++++++++++++++++++++ src/app.rs | 9 +- src/keys/key_list.rs | 2 + src/popups/mod.rs | 2 + src/popups/worktrees.rs | 353 ++++++++++++++++++++++++++++++++++ src/queue.rs | 2 + src/strings.rs | 27 +++ src/tabs/status.rs | 12 ++ 10 files changed, 635 insertions(+), 1 deletion(-) create mode 100644 asyncgit/src/sync/worktree.rs create mode 100644 src/popups/worktrees.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 68c2158509..c006228a01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * support x509 commit signing [[@kaden-l-nelson](https://github.com/kaden-l-nelson)] ([#2514](https://github.com/gitui-org/gitui/issues/2514)) +* worktrees popup: list worktrees and switch between them ([Shift+W]) ### Changed * use [tombi](https://github.com/tombi-toml/tombi) for all toml file formatting diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index 2cd358d065..e937377576 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -34,6 +34,7 @@ mod submodules; mod tags; mod tree; pub mod utils; +mod worktree; pub use blame::{blame_file, BlameHunk, FileBlame}; pub use branch::{ @@ -108,6 +109,7 @@ pub use utils::{ get_head, get_head_tuple, repo_dir, repo_open_error, stage_add_all, stage_add_file, stage_addremoved, Head, }; +pub use worktree::{get_worktrees, WorktreeInfo}; pub use git2::ResetType; diff --git a/asyncgit/src/sync/worktree.rs b/asyncgit/src/sync/worktree.rs new file mode 100644 index 0000000000..a3d71063a1 --- /dev/null +++ b/asyncgit/src/sync/worktree.rs @@ -0,0 +1,226 @@ +//! read-only listing of git worktrees + +use std::path::{Path, PathBuf}; + +use git2::{Repository, WorktreeLockStatus}; +use scopetime::scope_time; + +use super::{repo, RepoPath}; +use crate::error::Result; + +/// name reported for the primary working tree +const MAIN_WORKTREE_NAME: &str = "(main)"; + +/// Information about one git worktree (the primary tree or a linked one). +#[derive(Debug, Clone)] +pub struct WorktreeInfo { + /// Linked-worktree name; "(main)" for the primary working tree. + pub name: String, + /// Absolute path to the worktree's working directory. + pub path: PathBuf, + /// Short name of the checked-out branch (HEAD), or None if + /// detached/unborn. + pub branch: Option, + /// Whether the worktree's working directory is present/valid. + pub is_valid: bool, + /// Whether the worktree is locked. + pub is_locked: bool, + /// Whether this is the worktree gitui is currently operating in. + pub is_current: bool, +} + +/// Lists the worktrees of the repo at `repo_path`, returning the +/// primary working tree first followed by any linked worktrees. +pub fn get_worktrees( + repo_path: &RepoPath, +) -> Result> { + scope_time!("get_worktrees"); + + let repo = repo(repo_path)?; + + // working dir gitui is currently operating in, used to flag the + // matching entry as `is_current`. + let current = repo.workdir(); + + let mut worktrees = Vec::new(); + + if let Some(main) = main_worktree_info(&repo, current) { + worktrees.push(main); + } + + // `iter()` yields `Result, Error>`; the two + // flattens drop unreadable/non-utf8 names, leaving `&str`. + for name in repo.worktrees()?.iter().flatten().flatten() { + worktrees.push(linked_worktree_info(&repo, name, current)?); + } + + Ok(worktrees) +} + +/// synthesizes the entry for the primary working tree, which is not +/// part of `Repository::worktrees`. returns `None` for bare repos +/// (no working tree) or when the primary workdir cannot be located. +fn main_worktree_info( + repo: &Repository, + current: Option<&Path>, +) -> Option { + if repo.is_bare() { + return None; + } + + // primary working dir and the repo handle used to read its branch. + let (main_workdir, branch) = if repo.is_worktree() { + // gitui is operating inside a linked worktree; the primary + // tree sits next to the shared common git dir. + let workdir = + repo.commondir().parent().map(Path::to_path_buf)?; + + let branch = Repository::open(&workdir) + .ok() + .and_then(|r| head_branch(&r)); + + (workdir, branch) + } else { + let workdir = repo.workdir()?; + + (workdir.to_path_buf(), head_branch(repo)) + }; + + Some(WorktreeInfo { + is_current: same_workdir(&main_workdir, current), + name: MAIN_WORKTREE_NAME.to_string(), + path: main_workdir, + branch, + is_valid: true, + is_locked: false, + }) +} + +/// builds the entry for a single linked worktree. +fn linked_worktree_info( + repo: &Repository, + name: &str, + current: Option<&Path>, +) -> Result { + let wt = repo.find_worktree(name)?; + + let branch = Repository::open_from_worktree(&wt) + .ok() + .and_then(|r| head_branch(&r)); + + let is_locked = wt.is_locked().is_ok_and(|status| { + matches!(status, WorktreeLockStatus::Locked(_)) + }); + + Ok(WorktreeInfo { + is_current: same_workdir(wt.path(), current), + name: name.to_string(), + path: wt.path().to_path_buf(), + branch, + is_valid: wt.validate().is_ok(), + is_locked, + }) +} + +/// short name of the branch a repo's HEAD points at, or `None` when +/// HEAD is unborn or detached. +fn head_branch(repo: &Repository) -> Option { + // a detached HEAD points at a commit, not a branch, and its + // shorthand is "HEAD" rather than a branch name. + if repo.head_detached().unwrap_or(false) { + return None; + } + + repo.head() + .ok() + .as_ref() + .and_then(|head| head.shorthand().ok().map(String::from)) +} + +/// whether `path` and `current` refer to the same working directory, +/// comparing canonicalized paths and falling back to raw equality when +/// canonicalization fails. +fn same_workdir(path: &Path, current: Option<&Path>) -> bool { + current.is_some_and(|current| { + let canon = |p: &Path| std::fs::canonicalize(p).ok(); + match (canon(path), canon(current)) { + (Some(a), Some(b)) => a == b, + _ => path == current, + } + }) +} + +#[cfg(test)] +mod tests { + use super::{get_worktrees, WorktreeInfo}; + use crate::sync::{tests::repo_init, RepoPath}; + use pretty_assertions::assert_eq; + + fn find<'a>( + list: &'a [WorktreeInfo], + name: &str, + ) -> &'a WorktreeInfo { + list.iter().find(|w| w.name == name).unwrap() + } + + #[test] + fn test_lists_primary_and_linked() { + let (_td, repo) = repo_init().unwrap(); + + let root = repo.path().parent().unwrap(); + let repo_path: RepoPath = root.to_str().unwrap().into(); + + // linked worktree kept outside the main workdir to avoid + // nesting; `wt_dir` must stay alive for the whole test. + let wt_dir = tempfile::TempDir::new().unwrap(); + let wt_path = wt_dir.path().join("wt1"); + repo.worktree("wt1", &wt_path, None).unwrap(); + + let list = get_worktrees(&repo_path).unwrap(); + + assert_eq!(list.len(), 2); + + let linked = find(&list, "wt1"); + assert!(linked.path.is_absolute()); + assert!(linked.path.ends_with("wt1")); + // git names the branch after the worktree by default. + assert!(linked.branch.is_some()); + assert!(!linked.is_current); + + let primary = find(&list, "(main)"); + assert!(primary.is_current); + } + + #[test] + fn test_primary_only() { + let (_td, repo) = repo_init().unwrap(); + + let root = repo.path().parent().unwrap(); + let repo_path: RepoPath = root.to_str().unwrap().into(); + + let list = get_worktrees(&repo_path).unwrap(); + + assert_eq!(list.len(), 1); + assert_eq!(list[0].name, "(main)"); + assert!(list[0].is_current); + assert!(list[0].is_valid); + assert!(!list[0].is_locked); + } + + #[test] + fn test_detached_head_has_no_branch() { + let (_td, repo) = repo_init().unwrap(); + + let root = repo.path().parent().unwrap(); + let repo_path: RepoPath = root.to_str().unwrap().into(); + + let oid = repo.head().unwrap().target().unwrap(); + repo.set_head_detached(oid).unwrap(); + + let list = get_worktrees(&repo_path).unwrap(); + + assert_eq!(list.len(), 1); + assert_eq!(list[0].name, "(main)"); + assert!(list[0].branch.is_none()); + } +} diff --git a/src/app.rs b/src/app.rs index ddb157af3f..0af1144b07 100644 --- a/src/app.rs +++ b/src/app.rs @@ -20,7 +20,7 @@ use crate::{ PushPopup, PushTagsPopup, RemoteListPopup, RenameBranchPopup, RenameRemotePopup, ResetPopup, RevisionFilesPopup, StashMsgPopup, SubmodulesListPopup, TagCommitPopup, - TagListPopup, UpdateRemoteUrlPopup, + TagListPopup, UpdateRemoteUrlPopup, WorktreesPopup, }, queue::{ Action, AppTabs, InternalEvent, NeedsUpdate, Queue, @@ -97,6 +97,7 @@ pub struct App { select_branch_popup: BranchListPopup, options_popup: OptionsPopup, submodule_popup: SubmodulesListPopup, + worktree_popup: WorktreesPopup, tags_popup: TagListPopup, reset_popup: ResetPopup, checkout_option_popup: CheckoutOptionPopup, @@ -221,6 +222,7 @@ impl App { tags_popup: TagListPopup::new(&env), options_popup: OptionsPopup::new(&env), submodule_popup: SubmodulesListPopup::new(&env), + worktree_popup: WorktreesPopup::new(&env), log_search_popup: LogSearchPopupPopup::new(&env), fuzzy_find_popup: FuzzyFindPopup::new(&env), do_quit: QuitState::None, @@ -523,6 +525,7 @@ impl App { select_branch_popup, revision_files_popup, submodule_popup, + worktree_popup, tags_popup, options_popup, help_popup, @@ -552,6 +555,7 @@ impl App { rename_remote_popup, update_remote_url_popup, submodule_popup, + worktree_popup, tags_popup, reset_popup, checkout_option_popup, @@ -797,6 +801,9 @@ impl App { InternalEvent::ViewSubmodules => { self.submodule_popup.open()?; } + InternalEvent::ViewWorktrees => { + self.worktree_popup.open()?; + } InternalEvent::Tags => { self.tags_popup.open()?; } diff --git a/src/keys/key_list.rs b/src/keys/key_list.rs index 24a9507a49..fca4ea2e6a 100644 --- a/src/keys/key_list.rs +++ b/src/keys/key_list.rs @@ -118,6 +118,7 @@ pub struct KeysList { pub stage_unstage_item: GituiKeyEvent, pub tag_annotate: GituiKeyEvent, pub view_submodules: GituiKeyEvent, + pub view_worktrees: GituiKeyEvent, pub view_remotes: GituiKeyEvent, pub update_remote_name: GituiKeyEvent, pub update_remote_url: GituiKeyEvent, @@ -216,6 +217,7 @@ impl Default for KeysList { stage_unstage_item: GituiKeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), tag_annotate: GituiKeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), view_submodules: GituiKeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT), + view_worktrees: GituiKeyEvent::new(KeyCode::Char('W'), KeyModifiers::SHIFT), view_remotes: GituiKeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), update_remote_name: GituiKeyEvent::new(KeyCode::Char('n'),KeyModifiers::NONE), update_remote_url: GituiKeyEvent::new(KeyCode::Char('u'),KeyModifiers::NONE), diff --git a/src/popups/mod.rs b/src/popups/mod.rs index ebb9e1482c..dfc41b7b32 100644 --- a/src/popups/mod.rs +++ b/src/popups/mod.rs @@ -29,6 +29,7 @@ mod submodules; mod tag_commit; mod taglist; mod update_remote_url; +mod worktrees; pub use blame_file::{BlameFileOpen, BlameFilePopup}; pub use branchlist::BranchListPopup; @@ -61,6 +62,7 @@ pub use submodules::SubmodulesListPopup; pub use tag_commit::TagCommitPopup; pub use taglist::TagListPopup; pub use update_remote_url::UpdateRemoteUrlPopup; +pub use worktrees::WorktreesPopup; use crate::ui::style::Theme; use ratatui::{ diff --git a/src/popups/worktrees.rs b/src/popups/worktrees.rs new file mode 100644 index 0000000000..3aebf26c43 --- /dev/null +++ b/src/popups/worktrees.rs @@ -0,0 +1,353 @@ +use crate::{ + app::Environment, + components::{ + visibility_blocking, CommandBlocking, CommandInfo, Component, + DrawableComponent, EventState, ScrollType, VerticalScroll, + }, + keys::{key_match, SharedKeyConfig}, + queue::{InternalEvent, Queue}, + strings, + ui::{self, Size}, +}; +use anyhow::Result; +use asyncgit::sync::{get_worktrees, RepoPathRef, WorktreeInfo}; +use crossterm::event::Event; +use ratatui::{ + layout::{Alignment, Margin, Rect}, + text::{Line, Span, Text}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; +use std::cell::Cell; +use ui::style::SharedTheme; +use unicode_truncate::UnicodeTruncateStr; +use unicode_width::UnicodeWidthStr; + +/// +pub struct WorktreesPopup { + repo: RepoPathRef, + queue: Queue, + worktrees: Vec, + visible: bool, + current_height: Cell, + selection: u16, + scroll: VerticalScroll, + theme: SharedTheme, + key_config: SharedKeyConfig, +} + +impl DrawableComponent for WorktreesPopup { + fn draw(&self, f: &mut Frame, rect: Rect) -> Result<()> { + if self.is_visible() { + const PERCENT_SIZE: Size = Size::new(80, 80); + const MIN_SIZE: Size = Size::new(60, 20); + + let area = ui::centered_rect( + PERCENT_SIZE.width, + PERCENT_SIZE.height, + rect, + ); + let area = ui::rect_inside(MIN_SIZE, rect.into(), area); + let area = area.intersection(rect); + + f.render_widget(Clear, area); + + f.render_widget( + Block::default() + .title(strings::POPUP_TITLE_WORKTREES) + .border_type(ratatui::widgets::BorderType::Thick) + .borders(Borders::ALL), + area, + ); + + let area = area.inner(Margin { + vertical: 1, + horizontal: 1, + }); + + self.draw_list(f, area)?; + } + + Ok(()) + } +} + +impl Component for WorktreesPopup { + fn commands( + &self, + out: &mut Vec, + force_all: bool, + ) -> CommandBlocking { + if self.visible || force_all { + if !force_all { + out.clear(); + } + + out.push(CommandInfo::new( + strings::commands::scroll(&self.key_config), + true, + true, + )); + + out.push(CommandInfo::new( + strings::commands::close_popup(&self.key_config), + true, + true, + )); + + out.push(CommandInfo::new( + strings::commands::open_worktree(&self.key_config), + self.can_switch_worktree(), + true, + )); + } + visibility_blocking(self) + } + + fn event(&mut self, ev: &Event) -> Result { + if !self.visible { + return Ok(EventState::NotConsumed); + } + + if let Event::Key(e) = ev { + if key_match(e, self.key_config.keys.exit_popup) { + self.hide(); + } else if key_match(e, self.key_config.keys.move_down) { + return self + .move_selection(ScrollType::Up) + .map(Into::into); + } else if key_match(e, self.key_config.keys.move_up) { + return self + .move_selection(ScrollType::Down) + .map(Into::into); + } else if key_match(e, self.key_config.keys.page_down) { + return self + .move_selection(ScrollType::PageDown) + .map(Into::into); + } else if key_match(e, self.key_config.keys.page_up) { + return self + .move_selection(ScrollType::PageUp) + .map(Into::into); + } else if key_match(e, self.key_config.keys.home) { + return self + .move_selection(ScrollType::Home) + .map(Into::into); + } else if key_match(e, self.key_config.keys.end) { + return self + .move_selection(ScrollType::End) + .map(Into::into); + } else if key_match(e, self.key_config.keys.enter) { + if let Some(worktree) = self.selected_entry() { + if !worktree.is_current { + self.queue.push(InternalEvent::OpenRepo { + path: worktree.path.clone(), + }); + } + } + self.hide(); + } else if key_match( + e, + self.key_config.keys.cmd_bar_toggle, + ) { + //do not consume if its the more key + return Ok(EventState::NotConsumed); + } + } + + Ok(EventState::Consumed) + } + + fn is_visible(&self) -> bool { + self.visible + } + + fn hide(&mut self) { + self.visible = false; + } + + fn show(&mut self) -> Result<()> { + self.visible = true; + + Ok(()) + } +} + +impl WorktreesPopup { + pub fn new(env: &Environment) -> Self { + Self { + worktrees: Vec::new(), + scroll: VerticalScroll::new(), + queue: env.queue.clone(), + selection: 0, + visible: false, + theme: env.theme.clone(), + key_config: env.key_config.clone(), + current_height: Cell::new(0), + repo: env.repo.clone(), + } + } + + /// + pub fn open(&mut self) -> Result<()> { + self.show()?; + self.update_worktrees()?; + + Ok(()) + } + + /// + pub fn update_worktrees(&mut self) -> Result<()> { + if self.is_visible() { + self.worktrees = get_worktrees(&self.repo.borrow())?; + self.set_selection(self.selection)?; + } + Ok(()) + } + + fn selected_entry(&self) -> Option<&WorktreeInfo> { + self.worktrees.get(self.selection as usize) + } + + fn can_switch_worktree(&self) -> bool { + self.selected_entry().is_some_and(|w| !w.is_current) + } + + //TODO: dedup this almost identical with BranchListComponent + fn move_selection(&mut self, scroll: ScrollType) -> Result { + let new_selection = match scroll { + ScrollType::Up => self.selection.saturating_add(1), + ScrollType::Down => self.selection.saturating_sub(1), + ScrollType::PageDown => self + .selection + .saturating_add(self.current_height.get()), + ScrollType::PageUp => self + .selection + .saturating_sub(self.current_height.get()), + ScrollType::Home => 0, + ScrollType::End => { + let count: u16 = self.worktrees.len().try_into()?; + count.saturating_sub(1) + } + }; + + self.set_selection(new_selection)?; + + Ok(true) + } + + fn set_selection(&mut self, selection: u16) -> Result<()> { + let num_entries: u16 = self.worktrees.len().try_into()?; + let num_entries = num_entries.saturating_sub(1); + + let selection = if selection > num_entries { + num_entries + } else { + selection + }; + + self.selection = selection; + + Ok(()) + } + + fn get_text( + &self, + theme: &SharedTheme, + width_available: u16, + height: usize, + ) -> Text<'_> { + const THREE_DOTS: &str = "..."; + const NAME_WIDTH: usize = 16; + const BRANCH_WIDTH: usize = 20; + + let mut txt = Vec::with_capacity(self.worktrees.len()); + + for (i, worktree) in self + .worktrees + .iter() + .skip(self.scroll.get_top()) + .take(height) + .enumerate() + { + let selected = (self.selection as usize + - self.scroll.get_top()) + == i; + + let marker = if worktree.is_current { "*" } else { " " }; + + let branch = worktree + .branch + .clone() + .unwrap_or_else(|| "(detached)".to_string()); + + let mut suffix = String::new(); + if worktree.is_locked { + suffix.push_str(" 🔒"); + } + if !worktree.is_valid { + suffix.push_str(" (invalid)"); + } + + let prefix = format!( + "{marker} {name:name_w$} {branch:branch_w$} ", + name = worktree.name, + name_w = NAME_WIDTH, + branch_w = BRANCH_WIDTH, + ); + + let used = UnicodeWidthStr::width(prefix.as_str()) + .saturating_add(UnicodeWidthStr::width( + suffix.as_str(), + )); + + let path_width = + (width_available as usize).saturating_sub(used); + + let mut path = + worktree.path.to_string_lossy().to_string(); + + if UnicodeWidthStr::width(path.as_str()) > path_width { + let (trunc, _) = path.unicode_truncate( + path_width.saturating_sub(THREE_DOTS.len()), + ); + path = format!("{trunc}{THREE_DOTS}"); + } + + txt.push(Line::from(vec![Span::styled( + format!("{prefix}{path}{suffix}"), + theme.text(true, selected), + )])); + } + + Text::from(txt) + } + + fn draw_list(&self, f: &mut Frame, r: Rect) -> Result<()> { + let height_in_lines = r.height as usize; + self.current_height.set(height_in_lines.try_into()?); + + self.scroll.update( + self.selection as usize, + self.worktrees.len(), + height_in_lines, + ); + + f.render_widget( + Paragraph::new(self.get_text( + &self.theme, + r.width, + height_in_lines, + )) + .alignment(Alignment::Left), + r, + ); + + let mut r = r; + r.height += 2; + r.y = r.y.saturating_sub(1); + + self.scroll.draw(f, r, &self.theme); + + Ok(()) + } +} diff --git a/src/queue.rs b/src/queue.rs index 5cdfe3cef0..866be404cb 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -147,6 +147,8 @@ pub enum InternalEvent { /// ViewSubmodules, /// + ViewWorktrees, + /// ViewRemotes, /// CreateRemote, diff --git a/src/strings.rs b/src/strings.rs index 93496cd2ca..4ec399b4a3 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -30,6 +30,7 @@ pub static PUSH_TAGS_STATES_PUSHING: &str = "pushing"; pub static PUSH_TAGS_STATES_DONE: &str = "done"; pub static POPUP_TITLE_SUBMODULES: &str = "Submodules"; +pub static POPUP_TITLE_WORKTREES: &str = "Worktrees"; pub static POPUP_TITLE_REMOTES: &str = "Remotes"; pub static POPUP_SUBTITLE_REMOTES: &str = "Details"; pub static POPUP_TITLE_FUZZY_FIND: &str = "Fuzzy Finder"; @@ -933,6 +934,32 @@ pub mod commands { ) } + pub fn view_worktrees( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Worktrees [{}]", + key_config.get_hint(key_config.keys.view_worktrees), + ), + "open worktree view", + CMD_GROUP_GENERAL, + ) + } + + pub fn open_worktree( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Switch [{}]", + key_config.get_hint(key_config.keys.enter), + ), + "switch to selected worktree", + CMD_GROUP_GENERAL, + ) + } + pub fn view_remotes(key_config: &SharedKeyConfig) -> CommandText { CommandText::new( format!( diff --git a/src/tabs/status.rs b/src/tabs/status.rs index 135cf18e4e..eb7693b66d 100644 --- a/src/tabs/status.rs +++ b/src/tabs/status.rs @@ -804,6 +804,12 @@ impl Component for Status { true, true, )); + + out.push(CommandInfo::new( + strings::commands::view_worktrees(&self.key_config), + true, + true, + )); } self.commands_nav(out, force_all); @@ -947,6 +953,12 @@ impl Component for Status { ) { self.queue.push(InternalEvent::ViewSubmodules); Ok(EventState::Consumed) + } else if key_match( + k, + self.key_config.keys.view_worktrees, + ) { + self.queue.push(InternalEvent::ViewWorktrees); + Ok(EventState::Consumed) } else { Ok(EventState::NotConsumed) }; From 33e2840a72d16332b530ec018d7af86a38da9030 Mon Sep 17 00:00:00 2001 From: Steven <2607235+stevenpollack@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:54:02 +1000 Subject: [PATCH 03/10] feat(worktrees): make the worktrees popup available from any tab 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 Claude-Session: https://claude.ai/code/session_01BMQxBdr5f3B3jmDtdvGtTu --- src/app.rs | 17 ++++++++++++++--- src/queue.rs | 2 -- src/tabs/status.rs | 12 ------------ 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/app.rs b/src/app.rs index 0af1144b07..da9289e9e3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -362,6 +362,12 @@ impl App { ) { self.options_popup.show()?; NeedsUpdate::ALL + } else if key_match( + k, + self.key_config.keys.view_worktrees, + ) { + self.worktree_popup.open()?; + NeedsUpdate::ALL } else { NeedsUpdate::empty() }; @@ -801,9 +807,6 @@ impl App { InternalEvent::ViewSubmodules => { self.submodule_popup.open()?; } - InternalEvent::ViewWorktrees => { - self.worktree_popup.open()?; - } InternalEvent::Tags => { self.tags_popup.open()?; } @@ -1150,6 +1153,14 @@ impl App { ) .order(order::NAV), ); + res.push( + CommandInfo::new( + strings::commands::view_worktrees(&self.key_config), + true, + !self.any_popup_visible(), + ) + .order(order::NAV), + ); res.push( CommandInfo::new( diff --git a/src/queue.rs b/src/queue.rs index 866be404cb..5cdfe3cef0 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -147,8 +147,6 @@ pub enum InternalEvent { /// ViewSubmodules, /// - ViewWorktrees, - /// ViewRemotes, /// CreateRemote, diff --git a/src/tabs/status.rs b/src/tabs/status.rs index eb7693b66d..135cf18e4e 100644 --- a/src/tabs/status.rs +++ b/src/tabs/status.rs @@ -804,12 +804,6 @@ impl Component for Status { true, true, )); - - out.push(CommandInfo::new( - strings::commands::view_worktrees(&self.key_config), - true, - true, - )); } self.commands_nav(out, force_all); @@ -953,12 +947,6 @@ impl Component for Status { ) { self.queue.push(InternalEvent::ViewSubmodules); Ok(EventState::Consumed) - } else if key_match( - k, - self.key_config.keys.view_worktrees, - ) { - self.queue.push(InternalEvent::ViewWorktrees); - Ok(EventState::Consumed) } else { Ok(EventState::NotConsumed) }; From 8672275e98ecbfce9619060605653fff1f7b7ae8 Mon Sep 17 00:00:00 2001 From: Steven <2607235+stevenpollack@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:54:02 +1000 Subject: [PATCH 04/10] fix(input): share input reader across app restarts 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 Claude-Session: https://claude.ai/code/session_01BMQxBdr5f3B3jmDtdvGtTu --- CHANGELOG.md | 1 + src/gitui.rs | 17 +++++++++++------ src/main.rs | 13 +++++++++++-- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c006228a01..f8a6ee39f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * support rewording non-HEAD commits when `commit.gpgsign` is enabled (gpg format only) [[@guerinoni](https://github.com/guerinoni)] ([#2959](https://github.com/gitui-org/gitui/pull/2959)) ### Fixes +* first keystroke after switching to a submodule or worktree was dropped * crash when opening submodule ([#2895](https://github.com/gitui-org/gitui/issues/2895)) * when staging the last file in a directory, the first item after the directory is no longer skipped [[@Tillerino](https://github.com/Tillerino)] ([#2748](https://github.com/gitui-org/gitui/issues/2748)) * index-out-of-bounds panic when unstaging lines near the end of a diff ([#2953](https://github.com/gitui-org/gitui/issues/2953)) diff --git a/src/gitui.rs b/src/gitui.rs index 03d73b11c1..2b4b92be38 100644 --- a/src/gitui.rs +++ b/src/gitui.rs @@ -37,12 +37,11 @@ impl Gitui { theme: Theme, key_config: &KeyConfig, updater: Updater, + input: &Input, ) -> Result { let (tx_git, rx_git) = unbounded(); let (tx_app, rx_app) = unbounded(); - let input = Input::new(); - let (rx_ticker, rx_watcher) = match updater { Updater::NotifyWatcher => { let repo_watcher = RepoWatcher::new( @@ -212,7 +211,7 @@ mod tests { use ratatui::{backend::TestBackend, Terminal}; use crate::{ - args::CliArgs, gitui::Gitui, keys::KeyConfig, + args::CliArgs, gitui::Gitui, input::Input, keys::KeyConfig, ui::style::Theme, AsyncNotification, Updater, }; @@ -248,10 +247,16 @@ mod tests { let theme = Theme::init(&PathBuf::new()); let key_config = KeyConfig::default(); + let input = Input::new(); - let mut gitui = - Gitui::new(cliargs, theme, &key_config, Updater::Ticker) - .unwrap(); + let mut gitui = Gitui::new( + cliargs, + theme, + &key_config, + Updater::Ticker, + &input, + ) + .unwrap(); let mut terminal = Terminal::new(TestBackend::new(90, 12)).unwrap(); diff --git a/src/main.rs b/src/main.rs index fd662950a2..ee50b6e077 100644 --- a/src/main.rs +++ b/src/main.rs @@ -97,7 +97,7 @@ use crossterm::{ ExecutableCommand, }; use gitui::Gitui; -use input::InputEvent; +use input::{Input, InputEvent}; use keys::KeyConfig; use ratatui::backend::CrosstermBackend; use scopeguard::defer; @@ -184,6 +184,12 @@ fn main() -> Result<()> { let mut terminal = start_terminal(io::stdout(), &cliargs.repo_path)?; + // the input reader is created once and shared across app restarts + // (e.g. switching to a submodule or worktree). recreating it per + // app would let the outgoing reader consume and drop the first + // keystroke after a switch; a shared reader buffers it instead. + let input = Input::new(); + let updater = if cliargs.notify_watcher { Updater::NotifyWatcher } else { @@ -199,6 +205,7 @@ fn main() -> Result<()> { theme.clone(), &key_config, updater, + &input, &mut terminal, )?; @@ -226,9 +233,11 @@ fn run_app( theme: Theme, key_config: &KeyConfig, updater: Updater, + input: &Input, terminal: &mut Terminal, ) -> Result { - let mut gitui = Gitui::new(cliargs, theme, key_config, updater)?; + let mut gitui = + Gitui::new(cliargs, theme, key_config, updater, input)?; log::trace!("app start: {} ms", app_start.elapsed().as_millis()); From 46a2bf9d774cb9caaf1022796e5907070676ead4 Mon Sep 17 00:00:00 2001 From: Steven <2607235+stevenpollack@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:14:35 +1000 Subject: [PATCH 05/10] feat(worktrees): create a new worktree from the popup 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` 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 Claude-Session: https://claude.ai/code/session_01BMQxBdr5f3B3jmDtdvGtTu --- CHANGELOG.md | 1 + asyncgit/src/sync/mod.rs | 2 +- asyncgit/src/sync/worktree.rs | 78 ++++++++++++++- src/app.rs | 23 +++-- src/popups/create_worktree.rs | 179 ++++++++++++++++++++++++++++++++++ src/popups/mod.rs | 2 + src/popups/worktrees.rs | 12 +++ src/queue.rs | 2 + src/strings.rs | 24 +++++ 9 files changed, 313 insertions(+), 10 deletions(-) create mode 100644 src/popups/create_worktree.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f8a6ee39f7..3434529c46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * support x509 commit signing [[@kaden-l-nelson](https://github.com/kaden-l-nelson)] ([#2514](https://github.com/gitui-org/gitui/issues/2514)) * worktrees popup: list worktrees and switch between them ([Shift+W]) +* worktrees popup: create a new worktree with a new branch ([c] from the worktrees list) ### Changed * use [tombi](https://github.com/tombi-toml/tombi) for all toml file formatting diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index e937377576..d7ee46275b 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -109,7 +109,7 @@ pub use utils::{ get_head, get_head_tuple, repo_dir, repo_open_error, stage_add_all, stage_add_file, stage_addremoved, Head, }; -pub use worktree::{get_worktrees, WorktreeInfo}; +pub use worktree::{create_worktree, get_worktrees, WorktreeInfo}; pub use git2::ResetType; diff --git a/asyncgit/src/sync/worktree.rs b/asyncgit/src/sync/worktree.rs index a3d71063a1..08320f9861 100644 --- a/asyncgit/src/sync/worktree.rs +++ b/asyncgit/src/sync/worktree.rs @@ -6,7 +6,7 @@ use git2::{Repository, WorktreeLockStatus}; use scopetime::scope_time; use super::{repo, RepoPath}; -use crate::error::Result; +use crate::error::{Error, Result}; /// name reported for the primary working tree const MAIN_WORKTREE_NAME: &str = "(main)"; @@ -122,6 +122,44 @@ fn linked_worktree_info( }) } +/// Creates a new linked worktree at `worktree_path`, checking out a +/// new branch named after the final path component. +/// +/// `worktree_path` may be absolute or relative to the repository's +/// working directory. Returns the absolute path of the created +/// worktree. +pub fn create_worktree( + repo_path: &RepoPath, + worktree_path: &str, +) -> Result { + scope_time!("create_worktree"); + + let repo = repo(repo_path)?; + + let requested = Path::new(worktree_path); + + let target = if requested.is_absolute() { + requested.to_path_buf() + } else { + repo.workdir().ok_or(Error::NoWorkDir)?.join(requested) + }; + + let name = target + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| { + Error::Generic( + "invalid worktree path: no final component" + .to_string(), + ) + })? + .to_string(); + + let worktree = repo.worktree(&name, &target, None)?; + + Ok(worktree.path().to_path_buf()) +} + /// short name of the branch a repo's HEAD points at, or `None` when /// HEAD is unborn or detached. fn head_branch(repo: &Repository) -> Option { @@ -152,7 +190,7 @@ fn same_workdir(path: &Path, current: Option<&Path>) -> bool { #[cfg(test)] mod tests { - use super::{get_worktrees, WorktreeInfo}; + use super::{create_worktree, get_worktrees, WorktreeInfo}; use crate::sync::{tests::repo_init, RepoPath}; use pretty_assertions::assert_eq; @@ -223,4 +261,40 @@ mod tests { assert_eq!(list[0].name, "(main)"); assert!(list[0].branch.is_none()); } + + #[test] + fn test_create_worktree_new_branch() { + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path: RepoPath = root.to_str().unwrap().into(); + + // keep the linked worktree outside the main workdir; wt_dir + // must stay alive for the whole test. + let wt_dir = tempfile::TempDir::new().unwrap(); + let wt_path = wt_dir.path().join("feature-x"); + + let created = + create_worktree(&repo_path, wt_path.to_str().unwrap()) + .unwrap(); + + assert!(created.ends_with("feature-x")); + + let list = get_worktrees(&repo_path).unwrap(); + let wt = find(&list, "feature-x"); + assert!(wt.path.is_absolute()); + // libgit2 names the new branch after the worktree. + assert_eq!(wt.branch.as_deref(), Some("feature-x")); + assert!(!wt.is_current); + } + + #[test] + fn test_create_worktree_rejects_pathless_name() { + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path: RepoPath = root.to_str().unwrap().into(); + + // a path ending in ".." has no usable final component + let res = create_worktree(&repo_path, ".."); + assert!(res.is_err()); + } } diff --git a/src/app.rs b/src/app.rs index da9289e9e3..fbc3e1c0f2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,13 +14,14 @@ use crate::{ AppOption, BlameFilePopup, BranchListPopup, CheckoutOptionPopup, CommitPopup, CompareCommitsPopup, ConfirmPopup, CreateBranchPopup, CreateRemotePopup, - ExternalEditorPopup, FetchPopup, FileRevlogPopup, - FuzzyFindPopup, GotoLinePopup, HelpPopup, InspectCommitPopup, - LogSearchPopupPopup, MsgPopup, OptionsPopup, PullPopup, - PushPopup, PushTagsPopup, RemoteListPopup, RenameBranchPopup, - RenameRemotePopup, ResetPopup, RevisionFilesPopup, - StashMsgPopup, SubmodulesListPopup, TagCommitPopup, - TagListPopup, UpdateRemoteUrlPopup, WorktreesPopup, + CreateWorktreePopup, ExternalEditorPopup, FetchPopup, + FileRevlogPopup, FuzzyFindPopup, GotoLinePopup, HelpPopup, + InspectCommitPopup, LogSearchPopupPopup, MsgPopup, + OptionsPopup, PullPopup, PushPopup, PushTagsPopup, + RemoteListPopup, RenameBranchPopup, RenameRemotePopup, + ResetPopup, RevisionFilesPopup, StashMsgPopup, + SubmodulesListPopup, TagCommitPopup, TagListPopup, + UpdateRemoteUrlPopup, WorktreesPopup, }, queue::{ Action, AppTabs, InternalEvent, NeedsUpdate, Queue, @@ -89,6 +90,7 @@ pub struct App { fetch_popup: FetchPopup, tag_commit_popup: TagCommitPopup, create_branch_popup: CreateBranchPopup, + create_worktree_popup: CreateWorktreePopup, create_remote_popup: CreateRemotePopup, rename_remote_popup: RenameRemotePopup, update_remote_url_popup: UpdateRemoteUrlPopup, @@ -213,6 +215,7 @@ impl App { fetch_popup: FetchPopup::new(&env), tag_commit_popup: TagCommitPopup::new(&env), create_branch_popup: CreateBranchPopup::new(&env), + create_worktree_popup: CreateWorktreePopup::new(&env), create_remote_popup: CreateRemotePopup::new(&env), rename_remote_popup: RenameRemotePopup::new(&env), update_remote_url_popup: UpdateRemoteUrlPopup::new(&env), @@ -418,6 +421,7 @@ impl App { self.stashing_tab.update()?; self.stashlist_tab.update()?; self.reset_popup.update()?; + self.worktree_popup.update_worktrees()?; self.update_commands(); @@ -523,6 +527,7 @@ impl App { reset_popup, checkout_option_popup, create_branch_popup, + create_worktree_popup, create_remote_popup, rename_remote_popup, update_remote_url_popup, @@ -566,6 +571,7 @@ impl App { reset_popup, checkout_option_popup, create_branch_popup, + create_worktree_popup, rename_branch_popup, revision_files_popup, fuzzy_find_popup, @@ -797,6 +803,9 @@ impl App { InternalEvent::CreateBranch => { self.create_branch_popup.open()?; } + InternalEvent::CreateWorktree => { + self.create_worktree_popup.open()?; + } InternalEvent::RenameBranch(branch_ref, cur_name) => { self.rename_branch_popup .open(branch_ref, cur_name)?; diff --git a/src/popups/create_worktree.rs b/src/popups/create_worktree.rs new file mode 100644 index 0000000000..3fd1f21340 --- /dev/null +++ b/src/popups/create_worktree.rs @@ -0,0 +1,179 @@ +use crate::components::{ + visibility_blocking, CommandBlocking, CommandInfo, Component, + DrawableComponent, EventState, InputType, TextInputComponent, +}; +use crate::{ + app::Environment, + keys::{key_match, SharedKeyConfig}, + queue::{InternalEvent, NeedsUpdate, Queue}, + strings, + ui::style::SharedTheme, +}; +use anyhow::Result; +use asyncgit::sync::{self, RepoPathRef}; +use crossterm::event::Event; +use easy_cast::Cast; +use ratatui::{layout::Rect, widgets::Paragraph, Frame}; + +pub struct CreateWorktreePopup { + repo: RepoPathRef, + input: TextInputComponent, + queue: Queue, + key_config: SharedKeyConfig, + theme: SharedTheme, +} + +impl DrawableComponent for CreateWorktreePopup { + fn draw(&self, f: &mut Frame, rect: Rect) -> Result<()> { + if self.is_visible() { + self.input.draw(f, rect)?; + self.draw_warnings(f); + } + + Ok(()) + } +} + +impl Component for CreateWorktreePopup { + fn commands( + &self, + out: &mut Vec, + force_all: bool, + ) -> CommandBlocking { + if self.is_visible() || force_all { + self.input.commands(out, force_all); + + out.push(CommandInfo::new( + strings::commands::create_worktree_confirm_msg( + &self.key_config, + ), + true, + true, + )); + } + + visibility_blocking(self) + } + + fn event(&mut self, ev: &Event) -> Result { + if self.is_visible() { + if self.input.event(ev)?.is_consumed() { + return Ok(EventState::Consumed); + } + + if let Event::Key(e) = ev { + if key_match(e, self.key_config.keys.enter) { + self.create_worktree(); + } + + return Ok(EventState::Consumed); + } + } + Ok(EventState::NotConsumed) + } + + fn is_visible(&self) -> bool { + self.input.is_visible() + } + + fn hide(&mut self) { + self.input.hide(); + } + + fn show(&mut self) -> Result<()> { + self.input.show()?; + + Ok(()) + } +} + +impl CreateWorktreePopup { + /// + pub fn new(env: &Environment) -> Self { + Self { + queue: env.queue.clone(), + input: TextInputComponent::new( + env, + &strings::create_worktree_popup_title( + &env.key_config, + ), + &strings::create_worktree_popup_msg(&env.key_config), + true, + ) + .with_input_type(InputType::Singleline), + theme: env.theme.clone(), + key_config: env.key_config.clone(), + repo: env.repo.clone(), + } + } + + /// + pub fn open(&mut self) -> Result<()> { + self.show()?; + + Ok(()) + } + + /// + pub fn create_worktree(&mut self) { + let path = self.input.get_text().to_string(); + + if path.trim().is_empty() { + self.hide(); + return; + } + + let res = sync::create_worktree(&self.repo.borrow(), &path); + + self.input.clear(); + self.hide(); + + match res { + Ok(_) => { + self.queue + .push(InternalEvent::Update(NeedsUpdate::ALL)); + } + Err(e) => { + log::error!("create worktree: {e}"); + self.queue.push(InternalEvent::ShowErrorMsg( + format!("create worktree error:\n{e}"), + )); + } + } + } + + fn draw_warnings(&self, f: &mut Frame) { + let current_text = self.input.get_text(); + + let derived_name = std::path::Path::new(current_text) + .file_name() + .and_then(|s| s.to_str()); + + if let Some(derived_name) = derived_name { + let valid = sync::validate_branch_name(derived_name) + .unwrap_or_default(); + + if !valid { + let msg = strings::branch_name_invalid(); + let msg_length: u16 = msg.len().cast(); + let w = Paragraph::new(msg) + .style(self.theme.text_danger()); + + let rect = { + let mut rect = self.input.get_area(); + rect.y += rect.height.saturating_sub(1); + rect.height = 1; + let offset = + rect.width.saturating_sub(msg_length + 1); + rect.width = + rect.width.saturating_sub(offset + 1); + rect.x += offset; + + rect + }; + + f.render_widget(w, rect); + } + } + } +} diff --git a/src/popups/mod.rs b/src/popups/mod.rs index dfc41b7b32..8e0b83c8a8 100644 --- a/src/popups/mod.rs +++ b/src/popups/mod.rs @@ -6,6 +6,7 @@ mod compare_commits; mod confirm; mod create_branch; mod create_remote; +mod create_worktree; mod externaleditor; mod fetch; mod file_revlog; @@ -39,6 +40,7 @@ pub use compare_commits::CompareCommitsPopup; pub use confirm::ConfirmPopup; pub use create_branch::CreateBranchPopup; pub use create_remote::CreateRemotePopup; +pub use create_worktree::CreateWorktreePopup; pub use externaleditor::ExternalEditorPopup; pub use fetch::FetchPopup; pub use file_revlog::{FileRevOpen, FileRevlogPopup}; diff --git a/src/popups/worktrees.rs b/src/popups/worktrees.rs index 3aebf26c43..2ff0220143 100644 --- a/src/popups/worktrees.rs +++ b/src/popups/worktrees.rs @@ -100,6 +100,14 @@ impl Component for WorktreesPopup { self.can_switch_worktree(), true, )); + + out.push(CommandInfo::new( + strings::commands::create_worktree_confirm_msg( + &self.key_config, + ), + true, + true, + )); } visibility_blocking(self) } @@ -145,6 +153,10 @@ impl Component for WorktreesPopup { } } self.hide(); + } else if key_match(e, self.key_config.keys.create_branch) + { + self.queue.push(InternalEvent::CreateWorktree); + self.hide(); } else if key_match( e, self.key_config.keys.cmd_bar_toggle, diff --git a/src/queue.rs b/src/queue.rs index 5cdfe3cef0..ffd27f9b3b 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -113,6 +113,8 @@ pub enum InternalEvent { /// CreateBranch, /// + CreateWorktree, + /// RenameRemote(String), /// UpdateRemoteUrl(String, String), diff --git a/src/strings.rs b/src/strings.rs index 4ec399b4a3..e0ab69787b 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -353,6 +353,17 @@ pub fn create_branch_popup_msg( ) -> String { "type branch name".to_string() } +pub fn create_worktree_popup_title( + _key_config: &SharedKeyConfig, +) -> String { + "Create Worktree".to_string() +} +pub fn create_worktree_popup_msg( + _key_config: &SharedKeyConfig, +) -> String { + "path (absolute, or relative to repo root — e.g. ../feature-x)" + .to_string() +} pub fn rename_remote_popup_title( _key_config: &SharedKeyConfig, ) -> String { @@ -1712,6 +1723,19 @@ pub mod commands { ) .hide_help() } + + pub fn create_worktree_confirm_msg( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Create Worktree [{}]", + key_config.get_hint(key_config.keys.create_branch), + ), + "create a new worktree", + CMD_GROUP_GENERAL, + ) + } pub fn open_branch_create_popup( key_config: &SharedKeyConfig, ) -> CommandText { From 6e4b5f2af270e3b881ce2fa696ae031f31a809a6 Mon Sep 17 00:00:00 2001 From: Steven <2607235+stevenpollack@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:22:18 +1000 Subject: [PATCH 06/10] fix(worktrees): create missing parent dirs when adding a worktree 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 Claude-Session: https://claude.ai/code/session_01BMQxBdr5f3B3jmDtdvGtTu --- asyncgit/src/sync/worktree.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/asyncgit/src/sync/worktree.rs b/asyncgit/src/sync/worktree.rs index 08320f9861..01d675e610 100644 --- a/asyncgit/src/sync/worktree.rs +++ b/asyncgit/src/sync/worktree.rs @@ -155,6 +155,13 @@ pub fn create_worktree( })? .to_string(); + // libgit2 only creates the leaf directory, not missing parents + // (unlike `git worktree add`), so create the parent chain first + // to support nested paths such as `worktrees/foo`. + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } + let worktree = repo.worktree(&name, &target, None)?; Ok(worktree.path().to_path_buf()) @@ -287,6 +294,27 @@ mod tests { assert!(!wt.is_current); } + #[test] + fn test_create_worktree_creates_missing_parents() { + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path: RepoPath = root.to_str().unwrap().into(); + + // nested path whose parent dirs do not exist yet; libgit2 + // would fail to mkdir the leaf without this being handled. + let wt_dir = tempfile::TempDir::new().unwrap(); + let nested = wt_dir.path().join("a").join("b").join("wt"); + + let created = + create_worktree(&repo_path, nested.to_str().unwrap()) + .unwrap(); + + assert!(created.ends_with("wt")); + let list = get_worktrees(&repo_path).unwrap(); + let wt = find(&list, "wt"); + assert_eq!(wt.branch.as_deref(), Some("wt")); + } + #[test] fn test_create_worktree_rejects_pathless_name() { let (_td, repo) = repo_init().unwrap(); From d680425c591c59d1dc2451e024b29f074d2acf0b Mon Sep 17 00:00:00 2001 From: Steven <2607235+stevenpollack@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:58:34 +1000 Subject: [PATCH 07/10] feat(worktrees): remove and lock/unlock from the popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01BMQxBdr5f3B3jmDtdvGtTu --- CHANGELOG.md | 1 + asyncgit/src/sync/mod.rs | 5 +- asyncgit/src/sync/worktree.rs | 163 +++++++++++++++++++++++++++++++++- src/app.rs | 11 +++ src/keys/key_list.rs | 2 + src/popups/confirm.rs | 4 + src/popups/worktrees.rs | 58 +++++++++++- src/queue.rs | 1 + src/strings.rs | 39 ++++++++ 9 files changed, 278 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3434529c46..3dcca1f656 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * support x509 commit signing [[@kaden-l-nelson](https://github.com/kaden-l-nelson)] ([#2514](https://github.com/gitui-org/gitui/issues/2514)) * worktrees popup: list worktrees and switch between them ([Shift+W]) * worktrees popup: create a new worktree with a new branch ([c] from the worktrees list) +* worktrees popup: remove a worktree ([Shift+D], refused if it is dirty or locked) and lock/unlock it ([l]) ### Changed * use [tombi](https://github.com/tombi-toml/tombi) for all toml file formatting diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index d7ee46275b..bf45eecf1b 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -109,7 +109,10 @@ pub use utils::{ get_head, get_head_tuple, repo_dir, repo_open_error, stage_add_all, stage_add_file, stage_addremoved, Head, }; -pub use worktree::{create_worktree, get_worktrees, WorktreeInfo}; +pub use worktree::{ + create_worktree, get_worktrees, remove_worktree, + toggle_worktree_lock, WorktreeInfo, +}; pub use git2::ResetType; diff --git a/asyncgit/src/sync/worktree.rs b/asyncgit/src/sync/worktree.rs index 01d675e610..40433f78cc 100644 --- a/asyncgit/src/sync/worktree.rs +++ b/asyncgit/src/sync/worktree.rs @@ -2,17 +2,23 @@ use std::path::{Path, PathBuf}; -use git2::{Repository, WorktreeLockStatus}; +use git2::{Repository, WorktreeLockStatus, WorktreePruneOptions}; use scopetime::scope_time; -use super::{repo, RepoPath}; +use super::{is_workdir_clean, repo, RepoPath}; use crate::error::{Error, Result}; /// name reported for the primary working tree const MAIN_WORKTREE_NAME: &str = "(main)"; /// Information about one git worktree (the primary tree or a linked one). +/// +/// The four flag fields are independent (e.g. the primary tree can be +/// current or not, a linked tree can be locked or not, etc.), so they +/// don't collapse into a smaller enum; see `status_tree.rs` for the +/// same precedent in this codebase. #[derive(Debug, Clone)] +#[allow(clippy::struct_excessive_bools)] pub struct WorktreeInfo { /// Linked-worktree name; "(main)" for the primary working tree. pub name: String, @@ -27,6 +33,9 @@ pub struct WorktreeInfo { pub is_locked: bool, /// Whether this is the worktree gitui is currently operating in. pub is_current: bool, + /// Whether this is the primary ("(main)") working tree, which + /// cannot be removed or locked. + pub is_main: bool, } /// Lists the worktrees of the repo at `repo_path`, returning the @@ -93,6 +102,7 @@ fn main_worktree_info( branch, is_valid: true, is_locked: false, + is_main: true, }) } @@ -119,6 +129,7 @@ fn linked_worktree_info( branch, is_valid: wt.validate().is_ok(), is_locked, + is_main: false, }) } @@ -167,6 +178,83 @@ pub fn create_worktree( Ok(worktree.path().to_path_buf()) } +/// Removes the linked worktree named `name`: deletes its working +/// directory and prunes its administrative files. +/// +/// Refuses (returns an error, mirroring `git worktree remove` without +/// `--force`) when the worktree is the current one, is locked, or has +/// uncommitted/untracked changes — since removal deletes the working +/// directory irrecoverably. +pub fn remove_worktree( + repo_path: &RepoPath, + name: &str, +) -> Result<()> { + scope_time!("remove_worktree"); + + let repo = repo(repo_path)?; + let worktree = repo.find_worktree(name)?; + + // never remove the worktree gitui is operating in. + if same_workdir(worktree.path(), repo.workdir()) { + return Err(Error::Generic( + "cannot remove the current worktree".to_string(), + )); + } + + // a locked worktree is deliberately protected; require unlock. + if matches!(worktree.is_locked()?, WorktreeLockStatus::Locked(_)) + { + return Err(Error::Generic( + "worktree is locked; unlock it before removing" + .to_string(), + )); + } + + // refuse to delete a worktree with uncommitted or untracked + // changes (libgit2 does not refuse this itself). Only checkable + // when the working dir still exists. + if worktree.validate().is_ok() { + if let Some(path) = worktree.path().to_str() { + let wt_path: RepoPath = path.into(); + if !is_workdir_clean(&wt_path, None)? { + return Err(Error::Generic( + "worktree has uncommitted changes; commit or discard them first" + .to_string(), + )); + } + } + } + + // valid(true): also prune worktrees whose dir still exists; + // working_tree(true): delete the working directory too. + let mut opts = WorktreePruneOptions::new(); + opts.valid(true).working_tree(true); + worktree.prune(Some(&mut opts))?; + + Ok(()) +} + +/// Toggles the lock state of the linked worktree named `name`: locks +/// it (with no reason) when unlocked, unlocks it when locked. +pub fn toggle_worktree_lock( + repo_path: &RepoPath, + name: &str, +) -> Result<()> { + scope_time!("toggle_worktree_lock"); + + let repo = repo(repo_path)?; + let worktree = repo.find_worktree(name)?; + + if matches!(worktree.is_locked()?, WorktreeLockStatus::Locked(_)) + { + worktree.unlock()?; + } else { + worktree.lock(None)?; + } + + Ok(()) +} + /// short name of the branch a repo's HEAD points at, or `None` when /// HEAD is unborn or detached. fn head_branch(repo: &Repository) -> Option { @@ -197,7 +285,10 @@ fn same_workdir(path: &Path, current: Option<&Path>) -> bool { #[cfg(test)] mod tests { - use super::{create_worktree, get_worktrees, WorktreeInfo}; + use super::{ + create_worktree, get_worktrees, remove_worktree, + toggle_worktree_lock, WorktreeInfo, + }; use crate::sync::{tests::repo_init, RepoPath}; use pretty_assertions::assert_eq; @@ -325,4 +416,70 @@ mod tests { let res = create_worktree(&repo_path, ".."); assert!(res.is_err()); } + + #[test] + fn test_remove_worktree() { + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path: RepoPath = root.to_str().unwrap().into(); + + let wt_dir = tempfile::TempDir::new().unwrap(); + let wt_path = wt_dir.path().join("gone"); + create_worktree(&repo_path, wt_path.to_str().unwrap()) + .unwrap(); + assert_eq!(get_worktrees(&repo_path).unwrap().len(), 2); + + remove_worktree(&repo_path, "gone").unwrap(); + + let list = get_worktrees(&repo_path).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].name, "(main)"); + assert!(!wt_path.exists()); + } + + #[test] + fn test_remove_worktree_refuses_dirty() { + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path: RepoPath = root.to_str().unwrap().into(); + + let wt_dir = tempfile::TempDir::new().unwrap(); + let wt_path = wt_dir.path().join("dirty"); + create_worktree(&repo_path, wt_path.to_str().unwrap()) + .unwrap(); + + // introduce an untracked file inside the worktree + std::fs::write(wt_path.join("scratch.txt"), b"wip").unwrap(); + + assert!(remove_worktree(&repo_path, "dirty").is_err()); + // still present because removal was refused + assert_eq!(get_worktrees(&repo_path).unwrap().len(), 2); + } + + #[test] + fn test_toggle_worktree_lock() { + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path: RepoPath = root.to_str().unwrap().into(); + + let wt_dir = tempfile::TempDir::new().unwrap(); + let wt_path = wt_dir.path().join("lockme"); + create_worktree(&repo_path, wt_path.to_str().unwrap()) + .unwrap(); + + let locked = |p: &RepoPath| { + get_worktrees(p) + .unwrap() + .into_iter() + .find(|w| w.name == "lockme") + .unwrap() + .is_locked + }; + + assert!(!locked(&repo_path)); + toggle_worktree_lock(&repo_path, "lockme").unwrap(); + assert!(locked(&repo_path)); + toggle_worktree_lock(&repo_path, "lockme").unwrap(); + assert!(!locked(&repo_path)); + } } diff --git a/src/app.rs b/src/app.rs index fbc3e1c0f2..f00226d5d8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1009,6 +1009,17 @@ impl App { self.select_branch_popup.update_branches()?; } + Action::DeleteWorktree(name) => { + if let Err(e) = + sync::remove_worktree(&self.repo.borrow(), &name) + { + self.queue.push(InternalEvent::ShowErrorMsg( + e.to_string(), + )); + } + self.worktree_popup.update_worktrees()?; + flags.insert(NeedsUpdate::ALL); + } Action::DeleteRemoteBranch(branch_ref) => { self.delete_remote_branch(&branch_ref)?; } diff --git a/src/keys/key_list.rs b/src/keys/key_list.rs index fca4ea2e6a..a50cc53b57 100644 --- a/src/keys/key_list.rs +++ b/src/keys/key_list.rs @@ -119,6 +119,7 @@ pub struct KeysList { pub tag_annotate: GituiKeyEvent, pub view_submodules: GituiKeyEvent, pub view_worktrees: GituiKeyEvent, + pub lock_worktree: GituiKeyEvent, pub view_remotes: GituiKeyEvent, pub update_remote_name: GituiKeyEvent, pub update_remote_url: GituiKeyEvent, @@ -218,6 +219,7 @@ impl Default for KeysList { tag_annotate: GituiKeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), view_submodules: GituiKeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT), view_worktrees: GituiKeyEvent::new(KeyCode::Char('W'), KeyModifiers::SHIFT), + lock_worktree: GituiKeyEvent::new(KeyCode::Char('l'), KeyModifiers::empty()), view_remotes: GituiKeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), update_remote_name: GituiKeyEvent::new(KeyCode::Char('n'),KeyModifiers::NONE), update_remote_url: GituiKeyEvent::new(KeyCode::Char('u'),KeyModifiers::NONE), diff --git a/src/popups/confirm.rs b/src/popups/confirm.rs index 9910a321f3..8e692bff95 100644 --- a/src/popups/confirm.rs +++ b/src/popups/confirm.rs @@ -172,6 +172,10 @@ impl ConfirmPopup { strings::confirm_title_delete_remote(&self.key_config), strings::confirm_msg_delete_remote(&self.key_config,remote_name), ), + Action::DeleteWorktree(name) => ( + strings::confirm_title_delete_worktree(&self.key_config), + strings::confirm_msg_delete_worktree(&self.key_config, name), + ), Action::DeleteTag(tag_name) => ( strings::confirm_title_delete_tag( &self.key_config, diff --git a/src/popups/worktrees.rs b/src/popups/worktrees.rs index 2ff0220143..35db1d5fd7 100644 --- a/src/popups/worktrees.rs +++ b/src/popups/worktrees.rs @@ -5,12 +5,14 @@ use crate::{ DrawableComponent, EventState, ScrollType, VerticalScroll, }, keys::{key_match, SharedKeyConfig}, - queue::{InternalEvent, Queue}, + queue::{Action, InternalEvent, Queue}, strings, ui::{self, Size}, }; use anyhow::Result; -use asyncgit::sync::{get_worktrees, RepoPathRef, WorktreeInfo}; +use asyncgit::sync::{ + get_worktrees, toggle_worktree_lock, RepoPathRef, WorktreeInfo, +}; use crossterm::event::Event; use ratatui::{ layout::{Alignment, Margin, Rect}, @@ -108,6 +110,18 @@ impl Component for WorktreesPopup { true, true, )); + + out.push(CommandInfo::new( + strings::commands::remove_worktree(&self.key_config), + self.can_remove_worktree(), + true, + )); + + out.push(CommandInfo::new( + strings::commands::lock_worktree(&self.key_config), + self.can_lock_worktree(), + true, + )); } visibility_blocking(self) } @@ -157,6 +171,37 @@ impl Component for WorktreesPopup { { self.queue.push(InternalEvent::CreateWorktree); self.hide(); + } else if key_match(e, self.key_config.keys.delete_branch) + { + if let Some(worktree) = self.selected_entry() { + if !worktree.is_main && !worktree.is_current { + self.queue.push( + InternalEvent::ConfirmAction( + Action::DeleteWorktree( + worktree.name.clone(), + ), + ), + ); + } + } + } else if key_match(e, self.key_config.keys.lock_worktree) + { + let name = self + .selected_entry() + .filter(|w| !w.is_main) + .map(|w| w.name.clone()); + + if let Some(name) = name { + if let Err(err) = toggle_worktree_lock( + &self.repo.borrow(), + &name, + ) { + self.queue.push(InternalEvent::ShowErrorMsg( + err.to_string(), + )); + } + self.update_worktrees()?; + } } else if key_match( e, self.key_config.keys.cmd_bar_toggle, @@ -224,6 +269,15 @@ impl WorktreesPopup { self.selected_entry().is_some_and(|w| !w.is_current) } + fn can_remove_worktree(&self) -> bool { + self.selected_entry() + .is_some_and(|w| !w.is_main && !w.is_current) + } + + fn can_lock_worktree(&self) -> bool { + self.selected_entry().is_some_and(|w| !w.is_main) + } + //TODO: dedup this almost identical with BranchListComponent fn move_selection(&mut self, scroll: ScrollType) -> Result { let new_selection = match scroll { diff --git a/src/queue.rs b/src/queue.rs index ffd27f9b3b..18099318d5 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -52,6 +52,7 @@ pub enum Action { DeleteTag(String), DeleteRemoteTag(String, String), DeleteRemote(String), + DeleteWorktree(String), ForcePush(String, bool), PullMerge { incoming: usize, rebase: bool }, AbortMerge, diff --git a/src/strings.rs b/src/strings.rs index e0ab69787b..c3b98f7da4 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -249,6 +249,19 @@ pub fn confirm_msg_delete_branch( ) -> String { format!("Confirm deleting branch: '{branch_ref}' ?") } +pub fn confirm_title_delete_worktree( + _key_config: &SharedKeyConfig, +) -> String { + "Remove Worktree".to_string() +} +pub fn confirm_msg_delete_worktree( + _key_config: &SharedKeyConfig, + name: &str, +) -> String { + format!( + "Really remove worktree `{name}`? Its working directory will be deleted." + ) +} pub fn confirm_title_delete_remote_branch( _key_config: &SharedKeyConfig, ) -> String { @@ -1736,6 +1749,32 @@ pub mod commands { CMD_GROUP_GENERAL, ) } + + pub fn remove_worktree( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Remove [{}]", + key_config.get_hint(key_config.keys.delete_branch), + ), + "remove the selected worktree", + CMD_GROUP_GENERAL, + ) + } + + pub fn lock_worktree( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Lock/Unlock [{}]", + key_config.get_hint(key_config.keys.lock_worktree), + ), + "lock or unlock the selected worktree", + CMD_GROUP_GENERAL, + ) + } pub fn open_branch_create_popup( key_config: &SharedKeyConfig, ) -> CommandText { From 1f8b38cedbf11d6bff3e85a6e280e808e967209a Mon Sep 17 00:00:00 2001 From: Steven <2607235+stevenpollack@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:23:35 +1000 Subject: [PATCH 08/10] feat(worktrees): accept y/n in the remove confirmation 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 Claude-Session: https://claude.ai/code/session_01BMQxBdr5f3B3jmDtdvGtTu --- src/popups/confirm.rs | 14 +++++++++++++- src/strings.rs | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/popups/confirm.rs b/src/popups/confirm.rs index 8e692bff95..c33d0e2fea 100644 --- a/src/popups/confirm.rs +++ b/src/popups/confirm.rs @@ -9,7 +9,7 @@ use crate::{ strings, ui, }; use anyhow::Result; -use crossterm::event::Event; +use crossterm::event::{Event, KeyCode}; use ratatui::{layout::Rect, text::Text, widgets::Clear, Frame}; use std::borrow::Cow; use ui::style::SharedTheme; @@ -74,6 +74,14 @@ impl Component for ConfirmPopup { self.hide(); } else if key_match(e, self.key_config.keys.enter) { self.confirm(); + } else if self.is_worktree_removal() { + // worktree removal additionally accepts explicit + // y/n, as spelled out in its message copy. + match e.code { + KeyCode::Char('y') => self.confirm(), + KeyCode::Char('n') => self.hide(), + _ => {} + } } return Ok(EventState::Consumed); @@ -125,6 +133,10 @@ impl ConfirmPopup { self.hide(); } + const fn is_worktree_removal(&self) -> bool { + matches!(self.target, Some(Action::DeleteWorktree(_))) + } + fn get_text(&self) -> (String, String) { if let Some(ref a) = self.target { return match a { diff --git a/src/strings.rs b/src/strings.rs index c3b98f7da4..3ff5c4dcce 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -259,7 +259,7 @@ pub fn confirm_msg_delete_worktree( name: &str, ) -> String { format!( - "Really remove worktree `{name}`? Its working directory will be deleted." + "Really remove worktree `{name}`? Its working directory will be deleted.\n\ny = delete n / Esc = cancel" ) } pub fn confirm_title_delete_remote_branch( From 80322f01c91adb075b0b9013bf0b130b43b232de Mon Sep 17 00:00:00 2001 From: Steven <2607235+stevenpollack@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:44:57 +1000 Subject: [PATCH 09/10] fix(worktrees): always dirty-check before removal, even for non-utf8 paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, 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 --- asyncgit/src/sync/worktree.rs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/asyncgit/src/sync/worktree.rs b/asyncgit/src/sync/worktree.rs index 40433f78cc..b4a5892f02 100644 --- a/asyncgit/src/sync/worktree.rs +++ b/asyncgit/src/sync/worktree.rs @@ -214,14 +214,12 @@ pub fn remove_worktree( // changes (libgit2 does not refuse this itself). Only checkable // when the working dir still exists. if worktree.validate().is_ok() { - if let Some(path) = worktree.path().to_str() { - let wt_path: RepoPath = path.into(); - if !is_workdir_clean(&wt_path, None)? { - return Err(Error::Generic( - "worktree has uncommitted changes; commit or discard them first" - .to_string(), - )); - } + let wt_path: RepoPath = worktree.path().to_path_buf().into(); + if !is_workdir_clean(&wt_path, None)? { + return Err(Error::Generic( + "worktree has uncommitted changes; commit or discard them first" + .to_string(), + )); } } From 7e96b0f3386c01f347ea75490055259771ac18a0 Mon Sep 17 00:00:00 2001 From: Steven <2607235+stevenpollack@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:50:13 +1000 Subject: [PATCH 10/10] fix(worktrees): also refuse removal of a worktree with staged changes 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 #2995. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BMQxBdr5f3B3jmDtdvGtTu --- asyncgit/src/sync/worktree.rs | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/asyncgit/src/sync/worktree.rs b/asyncgit/src/sync/worktree.rs index b4a5892f02..bb7a633801 100644 --- a/asyncgit/src/sync/worktree.rs +++ b/asyncgit/src/sync/worktree.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use git2::{Repository, WorktreeLockStatus, WorktreePruneOptions}; use scopetime::scope_time; +use super::status::{get_status, StatusType}; use super::{is_workdir_clean, repo, RepoPath}; use crate::error::{Error, Result}; @@ -215,7 +216,13 @@ pub fn remove_worktree( // when the working dir still exists. if worktree.validate().is_ok() { let wt_path: RepoPath = worktree.path().to_path_buf().into(); - if !is_workdir_clean(&wt_path, None)? { + // `is_workdir_clean` only compares the index to the working + // dir, so it misses staged-but-uncommitted changes; also + // reject those (HEAD vs index) to match `git worktree remove`. + let has_staged = + !get_status(&wt_path, StatusType::Stage, None)? + .is_empty(); + if has_staged || !is_workdir_clean(&wt_path, None)? { return Err(Error::Generic( "worktree has uncommitted changes; commit or discard them first" .to_string(), @@ -454,6 +461,30 @@ mod tests { assert_eq!(get_worktrees(&repo_path).unwrap().len(), 2); } + #[test] + fn test_remove_worktree_refuses_staged() { + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path: RepoPath = root.to_str().unwrap().into(); + + let wt_dir = tempfile::TempDir::new().unwrap(); + let wt_path = wt_dir.path().join("staged"); + create_worktree(&repo_path, wt_path.to_str().unwrap()) + .unwrap(); + + // stage a new file inside the worktree: it matches the index, + // so only the HEAD-vs-index (staged) check can catch it — the + // working-dir check alone would see this as clean. + std::fs::write(wt_path.join("new.txt"), b"data").unwrap(); + let wt_repo = git2::Repository::open(&wt_path).unwrap(); + let mut index = wt_repo.index().unwrap(); + index.add_path(std::path::Path::new("new.txt")).unwrap(); + index.write().unwrap(); + + assert!(remove_worktree(&repo_path, "staged").is_err()); + assert_eq!(get_worktrees(&repo_path).unwrap().len(), 2); + } + #[test] fn test_toggle_worktree_lock() { let (_td, repo) = repo_init().unwrap();