Skip to content

Commit d680425

Browse files
stevenpollackclaude
andcommitted
feat(worktrees): remove and lock/unlock from the popup
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
1 parent 6e4b5f2 commit d680425

9 files changed

Lines changed: 278 additions & 6 deletions

File tree

β€ŽCHANGELOG.mdβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
* support x509 commit signing [[@kaden-l-nelson](https://github.com/kaden-l-nelson)] ([#2514](https://github.com/gitui-org/gitui/issues/2514))
1212
* worktrees popup: list worktrees and switch between them ([Shift+W])
1313
* worktrees popup: create a new worktree with a new branch ([c] from the worktrees list)
14+
* worktrees popup: remove a worktree ([Shift+D], refused if it is dirty or locked) and lock/unlock it ([l])
1415

1516
### Changed
1617
* use [tombi](https://github.com/tombi-toml/tombi) for all toml file formatting

β€Žasyncgit/src/sync/mod.rsβ€Ž

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,10 @@ pub use utils::{
109109
get_head, get_head_tuple, repo_dir, repo_open_error,
110110
stage_add_all, stage_add_file, stage_addremoved, Head,
111111
};
112-
pub use worktree::{create_worktree, get_worktrees, WorktreeInfo};
112+
pub use worktree::{
113+
create_worktree, get_worktrees, remove_worktree,
114+
toggle_worktree_lock, WorktreeInfo,
115+
};
113116

114117
pub use git2::ResetType;
115118

β€Žasyncgit/src/sync/worktree.rsβ€Ž

Lines changed: 160 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,23 @@
22
33
use std::path::{Path, PathBuf};
44

5-
use git2::{Repository, WorktreeLockStatus};
5+
use git2::{Repository, WorktreeLockStatus, WorktreePruneOptions};
66
use scopetime::scope_time;
77

8-
use super::{repo, RepoPath};
8+
use super::{is_workdir_clean, repo, RepoPath};
99
use crate::error::{Error, Result};
1010

1111
/// name reported for the primary working tree
1212
const MAIN_WORKTREE_NAME: &str = "(main)";
1313

1414
/// Information about one git worktree (the primary tree or a linked one).
15+
///
16+
/// The four flag fields are independent (e.g. the primary tree can be
17+
/// current or not, a linked tree can be locked or not, etc.), so they
18+
/// don't collapse into a smaller enum; see `status_tree.rs` for the
19+
/// same precedent in this codebase.
1520
#[derive(Debug, Clone)]
21+
#[allow(clippy::struct_excessive_bools)]
1622
pub struct WorktreeInfo {
1723
/// Linked-worktree name; "(main)" for the primary working tree.
1824
pub name: String,
@@ -27,6 +33,9 @@ pub struct WorktreeInfo {
2733
pub is_locked: bool,
2834
/// Whether this is the worktree gitui is currently operating in.
2935
pub is_current: bool,
36+
/// Whether this is the primary ("(main)") working tree, which
37+
/// cannot be removed or locked.
38+
pub is_main: bool,
3039
}
3140

3241
/// Lists the worktrees of the repo at `repo_path`, returning the
@@ -93,6 +102,7 @@ fn main_worktree_info(
93102
branch,
94103
is_valid: true,
95104
is_locked: false,
105+
is_main: true,
96106
})
97107
}
98108

@@ -119,6 +129,7 @@ fn linked_worktree_info(
119129
branch,
120130
is_valid: wt.validate().is_ok(),
121131
is_locked,
132+
is_main: false,
122133
})
123134
}
124135

@@ -167,6 +178,83 @@ pub fn create_worktree(
167178
Ok(worktree.path().to_path_buf())
168179
}
169180

181+
/// Removes the linked worktree named `name`: deletes its working
182+
/// directory and prunes its administrative files.
183+
///
184+
/// Refuses (returns an error, mirroring `git worktree remove` without
185+
/// `--force`) when the worktree is the current one, is locked, or has
186+
/// uncommitted/untracked changes β€” since removal deletes the working
187+
/// directory irrecoverably.
188+
pub fn remove_worktree(
189+
repo_path: &RepoPath,
190+
name: &str,
191+
) -> Result<()> {
192+
scope_time!("remove_worktree");
193+
194+
let repo = repo(repo_path)?;
195+
let worktree = repo.find_worktree(name)?;
196+
197+
// never remove the worktree gitui is operating in.
198+
if same_workdir(worktree.path(), repo.workdir()) {
199+
return Err(Error::Generic(
200+
"cannot remove the current worktree".to_string(),
201+
));
202+
}
203+
204+
// a locked worktree is deliberately protected; require unlock.
205+
if matches!(worktree.is_locked()?, WorktreeLockStatus::Locked(_))
206+
{
207+
return Err(Error::Generic(
208+
"worktree is locked; unlock it before removing"
209+
.to_string(),
210+
));
211+
}
212+
213+
// refuse to delete a worktree with uncommitted or untracked
214+
// changes (libgit2 does not refuse this itself). Only checkable
215+
// when the working dir still exists.
216+
if worktree.validate().is_ok() {
217+
if let Some(path) = worktree.path().to_str() {
218+
let wt_path: RepoPath = path.into();
219+
if !is_workdir_clean(&wt_path, None)? {
220+
return Err(Error::Generic(
221+
"worktree has uncommitted changes; commit or discard them first"
222+
.to_string(),
223+
));
224+
}
225+
}
226+
}
227+
228+
// valid(true): also prune worktrees whose dir still exists;
229+
// working_tree(true): delete the working directory too.
230+
let mut opts = WorktreePruneOptions::new();
231+
opts.valid(true).working_tree(true);
232+
worktree.prune(Some(&mut opts))?;
233+
234+
Ok(())
235+
}
236+
237+
/// Toggles the lock state of the linked worktree named `name`: locks
238+
/// it (with no reason) when unlocked, unlocks it when locked.
239+
pub fn toggle_worktree_lock(
240+
repo_path: &RepoPath,
241+
name: &str,
242+
) -> Result<()> {
243+
scope_time!("toggle_worktree_lock");
244+
245+
let repo = repo(repo_path)?;
246+
let worktree = repo.find_worktree(name)?;
247+
248+
if matches!(worktree.is_locked()?, WorktreeLockStatus::Locked(_))
249+
{
250+
worktree.unlock()?;
251+
} else {
252+
worktree.lock(None)?;
253+
}
254+
255+
Ok(())
256+
}
257+
170258
/// short name of the branch a repo's HEAD points at, or `None` when
171259
/// HEAD is unborn or detached.
172260
fn head_branch(repo: &Repository) -> Option<String> {
@@ -197,7 +285,10 @@ fn same_workdir(path: &Path, current: Option<&Path>) -> bool {
197285

198286
#[cfg(test)]
199287
mod tests {
200-
use super::{create_worktree, get_worktrees, WorktreeInfo};
288+
use super::{
289+
create_worktree, get_worktrees, remove_worktree,
290+
toggle_worktree_lock, WorktreeInfo,
291+
};
201292
use crate::sync::{tests::repo_init, RepoPath};
202293
use pretty_assertions::assert_eq;
203294

@@ -325,4 +416,70 @@ mod tests {
325416
let res = create_worktree(&repo_path, "..");
326417
assert!(res.is_err());
327418
}
419+
420+
#[test]
421+
fn test_remove_worktree() {
422+
let (_td, repo) = repo_init().unwrap();
423+
let root = repo.path().parent().unwrap();
424+
let repo_path: RepoPath = root.to_str().unwrap().into();
425+
426+
let wt_dir = tempfile::TempDir::new().unwrap();
427+
let wt_path = wt_dir.path().join("gone");
428+
create_worktree(&repo_path, wt_path.to_str().unwrap())
429+
.unwrap();
430+
assert_eq!(get_worktrees(&repo_path).unwrap().len(), 2);
431+
432+
remove_worktree(&repo_path, "gone").unwrap();
433+
434+
let list = get_worktrees(&repo_path).unwrap();
435+
assert_eq!(list.len(), 1);
436+
assert_eq!(list[0].name, "(main)");
437+
assert!(!wt_path.exists());
438+
}
439+
440+
#[test]
441+
fn test_remove_worktree_refuses_dirty() {
442+
let (_td, repo) = repo_init().unwrap();
443+
let root = repo.path().parent().unwrap();
444+
let repo_path: RepoPath = root.to_str().unwrap().into();
445+
446+
let wt_dir = tempfile::TempDir::new().unwrap();
447+
let wt_path = wt_dir.path().join("dirty");
448+
create_worktree(&repo_path, wt_path.to_str().unwrap())
449+
.unwrap();
450+
451+
// introduce an untracked file inside the worktree
452+
std::fs::write(wt_path.join("scratch.txt"), b"wip").unwrap();
453+
454+
assert!(remove_worktree(&repo_path, "dirty").is_err());
455+
// still present because removal was refused
456+
assert_eq!(get_worktrees(&repo_path).unwrap().len(), 2);
457+
}
458+
459+
#[test]
460+
fn test_toggle_worktree_lock() {
461+
let (_td, repo) = repo_init().unwrap();
462+
let root = repo.path().parent().unwrap();
463+
let repo_path: RepoPath = root.to_str().unwrap().into();
464+
465+
let wt_dir = tempfile::TempDir::new().unwrap();
466+
let wt_path = wt_dir.path().join("lockme");
467+
create_worktree(&repo_path, wt_path.to_str().unwrap())
468+
.unwrap();
469+
470+
let locked = |p: &RepoPath| {
471+
get_worktrees(p)
472+
.unwrap()
473+
.into_iter()
474+
.find(|w| w.name == "lockme")
475+
.unwrap()
476+
.is_locked
477+
};
478+
479+
assert!(!locked(&repo_path));
480+
toggle_worktree_lock(&repo_path, "lockme").unwrap();
481+
assert!(locked(&repo_path));
482+
toggle_worktree_lock(&repo_path, "lockme").unwrap();
483+
assert!(!locked(&repo_path));
484+
}
328485
}

β€Žsrc/app.rsβ€Ž

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,6 +1009,17 @@ impl App {
10091009

10101010
self.select_branch_popup.update_branches()?;
10111011
}
1012+
Action::DeleteWorktree(name) => {
1013+
if let Err(e) =
1014+
sync::remove_worktree(&self.repo.borrow(), &name)
1015+
{
1016+
self.queue.push(InternalEvent::ShowErrorMsg(
1017+
e.to_string(),
1018+
));
1019+
}
1020+
self.worktree_popup.update_worktrees()?;
1021+
flags.insert(NeedsUpdate::ALL);
1022+
}
10121023
Action::DeleteRemoteBranch(branch_ref) => {
10131024
self.delete_remote_branch(&branch_ref)?;
10141025
}

β€Žsrc/keys/key_list.rsβ€Ž

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ pub struct KeysList {
119119
pub tag_annotate: GituiKeyEvent,
120120
pub view_submodules: GituiKeyEvent,
121121
pub view_worktrees: GituiKeyEvent,
122+
pub lock_worktree: GituiKeyEvent,
122123
pub view_remotes: GituiKeyEvent,
123124
pub update_remote_name: GituiKeyEvent,
124125
pub update_remote_url: GituiKeyEvent,
@@ -218,6 +219,7 @@ impl Default for KeysList {
218219
tag_annotate: GituiKeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
219220
view_submodules: GituiKeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT),
220221
view_worktrees: GituiKeyEvent::new(KeyCode::Char('W'), KeyModifiers::SHIFT),
222+
lock_worktree: GituiKeyEvent::new(KeyCode::Char('l'), KeyModifiers::empty()),
221223
view_remotes: GituiKeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
222224
update_remote_name: GituiKeyEvent::new(KeyCode::Char('n'),KeyModifiers::NONE),
223225
update_remote_url: GituiKeyEvent::new(KeyCode::Char('u'),KeyModifiers::NONE),

β€Žsrc/popups/confirm.rsβ€Ž

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,10 @@ impl ConfirmPopup {
172172
strings::confirm_title_delete_remote(&self.key_config),
173173
strings::confirm_msg_delete_remote(&self.key_config,remote_name),
174174
),
175+
Action::DeleteWorktree(name) => (
176+
strings::confirm_title_delete_worktree(&self.key_config),
177+
strings::confirm_msg_delete_worktree(&self.key_config, name),
178+
),
175179
Action::DeleteTag(tag_name) => (
176180
strings::confirm_title_delete_tag(
177181
&self.key_config,

β€Žsrc/popups/worktrees.rsβ€Ž

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ use crate::{
55
DrawableComponent, EventState, ScrollType, VerticalScroll,
66
},
77
keys::{key_match, SharedKeyConfig},
8-
queue::{InternalEvent, Queue},
8+
queue::{Action, InternalEvent, Queue},
99
strings,
1010
ui::{self, Size},
1111
};
1212
use anyhow::Result;
13-
use asyncgit::sync::{get_worktrees, RepoPathRef, WorktreeInfo};
13+
use asyncgit::sync::{
14+
get_worktrees, toggle_worktree_lock, RepoPathRef, WorktreeInfo,
15+
};
1416
use crossterm::event::Event;
1517
use ratatui::{
1618
layout::{Alignment, Margin, Rect},
@@ -108,6 +110,18 @@ impl Component for WorktreesPopup {
108110
true,
109111
true,
110112
));
113+
114+
out.push(CommandInfo::new(
115+
strings::commands::remove_worktree(&self.key_config),
116+
self.can_remove_worktree(),
117+
true,
118+
));
119+
120+
out.push(CommandInfo::new(
121+
strings::commands::lock_worktree(&self.key_config),
122+
self.can_lock_worktree(),
123+
true,
124+
));
111125
}
112126
visibility_blocking(self)
113127
}
@@ -157,6 +171,37 @@ impl Component for WorktreesPopup {
157171
{
158172
self.queue.push(InternalEvent::CreateWorktree);
159173
self.hide();
174+
} else if key_match(e, self.key_config.keys.delete_branch)
175+
{
176+
if let Some(worktree) = self.selected_entry() {
177+
if !worktree.is_main && !worktree.is_current {
178+
self.queue.push(
179+
InternalEvent::ConfirmAction(
180+
Action::DeleteWorktree(
181+
worktree.name.clone(),
182+
),
183+
),
184+
);
185+
}
186+
}
187+
} else if key_match(e, self.key_config.keys.lock_worktree)
188+
{
189+
let name = self
190+
.selected_entry()
191+
.filter(|w| !w.is_main)
192+
.map(|w| w.name.clone());
193+
194+
if let Some(name) = name {
195+
if let Err(err) = toggle_worktree_lock(
196+
&self.repo.borrow(),
197+
&name,
198+
) {
199+
self.queue.push(InternalEvent::ShowErrorMsg(
200+
err.to_string(),
201+
));
202+
}
203+
self.update_worktrees()?;
204+
}
160205
} else if key_match(
161206
e,
162207
self.key_config.keys.cmd_bar_toggle,
@@ -224,6 +269,15 @@ impl WorktreesPopup {
224269
self.selected_entry().is_some_and(|w| !w.is_current)
225270
}
226271

272+
fn can_remove_worktree(&self) -> bool {
273+
self.selected_entry()
274+
.is_some_and(|w| !w.is_main && !w.is_current)
275+
}
276+
277+
fn can_lock_worktree(&self) -> bool {
278+
self.selected_entry().is_some_and(|w| !w.is_main)
279+
}
280+
227281
//TODO: dedup this almost identical with BranchListComponent
228282
fn move_selection(&mut self, scroll: ScrollType) -> Result<bool> {
229283
let new_selection = match scroll {

β€Žsrc/queue.rsβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ pub enum Action {
5252
DeleteTag(String),
5353
DeleteRemoteTag(String, String),
5454
DeleteRemote(String),
55+
DeleteWorktree(String),
5556
ForcePush(String, bool),
5657
PullMerge { incoming: usize, rebase: bool },
5758
AbortMerge,

0 commit comments

Comments
Β (0)