@@ -355,17 +355,114 @@ fn get_session_name(session_name: Option<&str>) -> Result<String> {
355355 }
356356}
357357
358- /// Remove all workspaces for a given repo ID
358+ /// Remove a git worktree by running `git worktree remove --force`.
359+ ///
360+ /// Parses the session's `.git` file to locate the source repo, then
361+ /// delegates to `git worktree remove`. Returns:
362+ /// - `Ok(true)`: worktree was successfully removed.
363+ /// - `Ok(false)`: not applicable (no `.git` file, unparseable gitdir path, or source repo missing).
364+ /// - `Err`: I/O failure, malformed `.git` file, or `git worktree remove` command failed.
365+ pub fn remove_git_worktree ( session_path : & Path ) -> Result < bool > {
366+ // Parse the .git file to find the source repo's git dir.
367+ let dot_git = session_path. join ( ".git" ) ;
368+ if !dot_git. is_file ( ) {
369+ return Ok ( false ) ;
370+ }
371+ let content = std:: fs:: read_to_string ( & dot_git) ?;
372+ // Format: "gitdir: /path/to/repo/.git/worktrees/session-name"
373+ let gitdir = content
374+ . strip_prefix ( "gitdir: " )
375+ . map ( |s| s. trim ( ) )
376+ . ok_or_else ( || eyre ! ( "unexpected .git file format in {}" , dot_git. display( ) ) ) ?;
377+
378+ // The source repo's .git dir is two levels up from the worktrees entry:
379+ // /repo/.git/worktrees/session -> /repo/.git
380+ let git_dir = Path :: new ( gitdir)
381+ . parent ( ) // /repo/.git/worktrees
382+ . and_then ( |p| p. parent ( ) ) ; // /repo/.git
383+
384+ let Some ( git_dir) = git_dir else {
385+ return Ok ( false ) ;
386+ } ;
387+
388+ if !git_dir. exists ( ) {
389+ // Source repo is gone; cannot clean up worktree metadata.
390+ return Ok ( false ) ;
391+ }
392+
393+ // Run git worktree remove. Use --force since the worktree may
394+ // have uncommitted changes (we are removing it regardless).
395+ let output = std:: process:: Command :: new ( "git" )
396+ . args ( [ "worktree" , "remove" , "--force" ] )
397+ . arg ( session_path)
398+ . current_dir ( git_dir. parent ( ) . unwrap_or ( git_dir) )
399+ . output ( ) ?;
400+
401+ if output. status . success ( ) {
402+ Ok ( true )
403+ } else {
404+ let stderr = String :: from_utf8_lossy ( & output. stderr ) ;
405+ Err ( eyre ! ( "git worktree remove failed: {}" , stderr. trim( ) ) )
406+ }
407+ }
408+
409+ /// Attempt to clean up git worktree metadata for all sessions in a directory.
410+ ///
411+ /// Iterates subdirectories, checks for `.git` files (indicating linked worktrees),
412+ /// and calls `remove_git_worktree` on each. Returns `true` if all sessions were
413+ /// cleaned up successfully, `false` if any failed (source repo missing, git error, etc.).
414+ /// Prints per-session status messages.
415+ pub fn cleanup_git_worktrees ( workspace_dir : & Path ) -> bool {
416+ let Ok ( entries) = std:: fs:: read_dir ( workspace_dir) else {
417+ return true ; // Nothing to clean up if we can't read the directory.
418+ } ;
419+ let mut all_ok = true ;
420+ for entry in entries. flatten ( ) {
421+ let session_path = entry. path ( ) ;
422+ if session_path. join ( ".git" ) . is_file ( ) {
423+ match remove_git_worktree ( & session_path) {
424+ Ok ( true ) => println ! ( " Pruned git worktree: {}" , session_path. display( ) ) ,
425+ Ok ( false ) => {
426+ println ! (
427+ " Note: could not clean up worktree metadata for {}" ,
428+ session_path. display( )
429+ ) ;
430+ println ! (
431+ " If the source repo still exists, run `git worktree prune` in the source repo."
432+ ) ;
433+ all_ok = false ;
434+ }
435+ Err ( e) => {
436+ eprintln ! (
437+ " Warning: failed to prune worktree {}: {e}" ,
438+ session_path. display( )
439+ ) ;
440+ all_ok = false ;
441+ }
442+ }
443+ }
444+ }
445+ all_ok
446+ }
447+
448+ /// Remove all workspaces for a given repo ID.
449+ ///
450+ /// For git workspaces, attempts to clean up worktree metadata via
451+ /// `git worktree remove --force` for each session before deleting the
452+ /// directory. If the source repo is missing, prints a note suggesting
453+ /// `git worktree prune`.
359454pub fn remove_repo ( config : & Config , repo_id : & RepoIdentifier , dry_run : bool ) -> Result < ( ) > {
360- let paths_to_remove: Vec < ( & str , PathBuf ) > = vec ! [
455+ let paths_to_remove: Vec < ( WorkspaceType , & str , PathBuf ) > = vec ! [
361456 (
457+ WorkspaceType :: Git ,
362458 "Git worktrees" ,
363459 config
364460 . workspace_dir
365461 . join( WorkspaceType :: Git . as_str( ) )
366462 . join( repo_id. relative_path( ) ) ,
367463 ) ,
368464 (
465+ WorkspaceType :: Jj ,
369466 "JJ workspaces" ,
370467 config
371468 . workspace_dir
@@ -378,10 +475,10 @@ pub fn remove_repo(config: &Config, repo_id: &RepoIdentifier, dry_run: bool) ->
378475 println ! ( "\n The following directories will be removed:" ) ;
379476
380477 let mut found_any = false ;
381- for ( label, path) in & paths_to_remove {
478+ for ( _ , label, path) in & paths_to_remove {
382479 if path. exists ( ) {
383480 found_any = true ;
384- println ! ( " [{}] {}" , label , path. display( ) ) ;
481+ println ! ( " [{label }] {}" , path. display( ) ) ;
385482 }
386483 }
387484
@@ -396,9 +493,15 @@ pub fn remove_repo(config: &Config, repo_id: &RepoIdentifier, dry_run: bool) ->
396493 }
397494
398495 // Remove all existing directories
399- for ( label, path) in & paths_to_remove {
496+ for ( wtype , label, path) in & paths_to_remove {
400497 if path. exists ( ) {
401- println ! ( "\n Removing {}: {}" , label, path. display( ) ) ;
498+ println ! ( "\n Removing {label}: {}" , path. display( ) ) ;
499+
500+ // Clean up git worktree metadata for each session before deleting.
501+ if * wtype == WorkspaceType :: Git {
502+ cleanup_git_worktrees ( path) ;
503+ }
504+
402505 std:: fs:: remove_dir_all ( path) ?;
403506 println ! ( " ✓ Removed" ) ;
404507 }
@@ -689,4 +792,129 @@ mod tests {
689792 // Cleanup
690793 std:: fs:: remove_dir_all ( & tmp) . ok ( ) ;
691794 }
795+
796+ #[ test]
797+ fn test_remove_git_worktree_cleans_metadata ( ) {
798+ // Create a real git repo, add a worktree, then call
799+ // remove_git_worktree and verify the worktree entry is gone.
800+ let tmp = temp_test_dir ( "rm-worktree" ) ;
801+ let repo_dir = tmp. join ( "my-repo" ) ;
802+ let worktree_dir = tmp. join ( "my-worktree" ) ;
803+ git_init_with_commit ( & repo_dir) ;
804+
805+ // Create a linked worktree
806+ let output = Command :: new ( "git" )
807+ . args ( [
808+ "worktree" ,
809+ "add" ,
810+ worktree_dir. to_str ( ) . unwrap ( ) ,
811+ "-b" ,
812+ "rm-test-branch" ,
813+ ] )
814+ . current_dir ( & repo_dir)
815+ . output ( )
816+ . unwrap ( ) ;
817+ assert ! (
818+ output. status. success( ) ,
819+ "git worktree add failed: {}" ,
820+ String :: from_utf8_lossy( & output. stderr)
821+ ) ;
822+
823+ // Verify the worktree metadata exists before removal
824+ let worktree_meta = repo_dir. join ( ".git" ) . join ( "worktrees" ) . join ( "my-worktree" ) ;
825+ assert ! (
826+ worktree_meta. exists( ) ,
827+ "worktree metadata should exist before removal"
828+ ) ;
829+
830+ // Call remove_git_worktree
831+ let result = remove_git_worktree ( & worktree_dir) . unwrap ( ) ;
832+ assert ! ( result, "remove_git_worktree should return true on success" ) ;
833+
834+ // The worktree directory should be removed by git worktree remove
835+ assert ! (
836+ !worktree_dir. exists( ) ,
837+ "worktree directory should be removed"
838+ ) ;
839+
840+ // The .git/worktrees entry should also be gone
841+ assert ! (
842+ !worktree_meta. exists( ) ,
843+ "worktree metadata should be removed from .git/worktrees/"
844+ ) ;
845+
846+ // Cleanup
847+ std:: fs:: remove_dir_all ( & tmp) . ok ( ) ;
848+ }
849+
850+ #[ test]
851+ fn test_remove_git_worktree_missing_source ( ) {
852+ // Create a fake .git file pointing to a nonexistent gitdir.
853+ // remove_git_worktree should return Ok(false) without erroring.
854+ let tmp = temp_test_dir ( "rm-worktree-missing" ) ;
855+ let session_dir = tmp. join ( "fake-session" ) ;
856+ std:: fs:: create_dir_all ( & session_dir) . unwrap ( ) ;
857+ std:: fs:: write (
858+ session_dir. join ( ".git" ) ,
859+ "gitdir: /nonexistent/repo/.git/worktrees/fake-session" ,
860+ )
861+ . unwrap ( ) ;
862+
863+ let result = remove_git_worktree ( & session_dir) . unwrap ( ) ;
864+ assert ! ( !result, "should return false when source repo is missing" ) ;
865+
866+ // Cleanup
867+ std:: fs:: remove_dir_all ( & tmp) . ok ( ) ;
868+ }
869+
870+ #[ test]
871+ fn test_remove_git_worktree_not_a_worktree ( ) {
872+ // Call on a directory without a .git file.
873+ // remove_git_worktree should return Ok(false).
874+ let tmp = temp_test_dir ( "rm-worktree-none" ) ;
875+ let plain_dir = tmp. join ( "plain-dir" ) ;
876+ std:: fs:: create_dir_all ( & plain_dir) . unwrap ( ) ;
877+
878+ let result = remove_git_worktree ( & plain_dir) . unwrap ( ) ;
879+ assert ! ( !result, "should return false for non-worktree directory" ) ;
880+
881+ // Cleanup
882+ std:: fs:: remove_dir_all ( & tmp) . ok ( ) ;
883+ }
884+
885+ #[ test]
886+ fn test_remove_git_worktree_malformed_git_file ( ) {
887+ // A .git file without the "gitdir: " prefix should return Err.
888+ let tmp = temp_test_dir ( "rm-worktree-malformed" ) ;
889+ let session_dir = tmp. join ( "bad-session" ) ;
890+ std:: fs:: create_dir_all ( & session_dir) . unwrap ( ) ;
891+ std:: fs:: write ( session_dir. join ( ".git" ) , "garbage content" ) . unwrap ( ) ;
892+
893+ let result = remove_git_worktree ( & session_dir) ;
894+ assert ! ( result. is_err( ) , "should return Err for malformed .git file" ) ;
895+ let err_msg = format ! ( "{}" , result. unwrap_err( ) ) ;
896+ assert ! (
897+ err_msg. contains( "unexpected .git file format" ) ,
898+ "error should mention unexpected format, got: {err_msg}"
899+ ) ;
900+
901+ // Cleanup
902+ std:: fs:: remove_dir_all ( & tmp) . ok ( ) ;
903+ }
904+
905+ #[ test]
906+ fn test_remove_git_worktree_short_gitdir_path ( ) {
907+ // A .git file with a gitdir that has fewer than 2 parent components
908+ // should return Ok(false) since we can't determine the source repo.
909+ let tmp = temp_test_dir ( "rm-worktree-short" ) ;
910+ let session_dir = tmp. join ( "short-session" ) ;
911+ std:: fs:: create_dir_all ( & session_dir) . unwrap ( ) ;
912+ std:: fs:: write ( session_dir. join ( ".git" ) , "gitdir: /foo" ) . unwrap ( ) ;
913+
914+ let result = remove_git_worktree ( & session_dir) . unwrap ( ) ;
915+ assert ! ( !result, "should return false for short gitdir path" ) ;
916+
917+ // Cleanup
918+ std:: fs:: remove_dir_all ( & tmp) . ok ( ) ;
919+ }
692920}
0 commit comments