22
33use std:: path:: { Path , PathBuf } ;
44
5- use git2:: { Repository , WorktreeLockStatus } ;
5+ use git2:: { Repository , WorktreeLockStatus , WorktreePruneOptions } ;
66use scopetime:: scope_time;
77
8- use super :: { repo, RepoPath } ;
8+ use super :: { is_workdir_clean , repo, RepoPath } ;
99use crate :: error:: { Error , Result } ;
1010
1111/// name reported for the primary working tree
1212const 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) ]
1622pub 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.
172260fn head_branch ( repo : & Repository ) -> Option < String > {
@@ -197,7 +285,10 @@ fn same_workdir(path: &Path, current: Option<&Path>) -> bool {
197285
198286#[ cfg( test) ]
199287mod 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}
0 commit comments