From 4032a445e9f2d3bfd5032522d20eca0e116bb92b Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Fri, 10 Apr 2026 23:50:23 -0400 Subject: [PATCH] Clean up empty parent directory after archiving worktree Zed creates worktrees at ///. When git worktree remove deletes the inner project directory, the intermediate branch directory is left behind as an empty folder. After a successful git worktree remove, check if the parent directory is now empty AND is inside Zed's managed worktrees directory (from the git.worktree_directory setting). If both conditions are met, remove the empty parent. Directories outside the managed worktrees directory are never touched, so user-created worktrees at custom paths won't have their parent directories deleted. --- .../agent_ui/src/thread_worktree_archive.rs | 26 +++- crates/sidebar/src/sidebar_tests.rs | 129 ++++++++++++++++++ 2 files changed, 150 insertions(+), 5 deletions(-) diff --git a/crates/agent_ui/src/thread_worktree_archive.rs b/crates/agent_ui/src/thread_worktree_archive.rs index a397cad0997316840e3dbc627485ca37253dcb9d..84617563928ca251e568ad3c220a757e9c5b2ee7 100644 --- a/crates/agent_ui/src/thread_worktree_archive.rs +++ b/crates/agent_ui/src/thread_worktree_archive.rs @@ -11,6 +11,7 @@ use project::{ LocalProjectFlags, Project, WorktreeId, git_store::{Repository, resolve_git_worktree_to_main_repo}, }; +use settings::Settings as _; use util::ResultExt; use workspace::{AppState, MultiWorkspace, Workspace}; @@ -261,18 +262,33 @@ async fn remove_root_after_worktree_removal( // creates worktrees inside an intermediate directory named after the // branch (e.g. `///`). After the inner // directory is removed, the `/` parent may be left behind as - // an empty directory. Clean it up if so. + // an empty directory. Only clean it up if it's inside Zed's managed + // worktrees directory — we don't want to delete user-created directories + // that happen to be empty. if let Some(parent) = root.root_path.parent() { - if parent != root.main_repo_path { - remove_dir_if_empty(parent, cx).await; + let main_repo_path = root.main_repo_path.clone(); + let managed_dir = cx.update(|cx| { + let setting = &project::project_settings::ProjectSettings::get_global(cx) + .git + .worktree_directory; + project::git_store::worktrees_directory_for_repo(&main_repo_path, setting).ok() + }); + if let Some(managed_dir) = managed_dir { + if parent.starts_with(&managed_dir) { + remove_empty_dir_if_managed(parent, &managed_dir, cx).await; + } } } Ok(()) } -/// Removes a directory only if it exists and is empty. -async fn remove_dir_if_empty(path: &Path, cx: &mut AsyncApp) { +/// Removes a directory only if it exists and is empty. Stops at (does not +/// remove) `managed_root`, which is the base worktrees directory. +async fn remove_empty_dir_if_managed(path: &Path, managed_root: &Path, cx: &mut AsyncApp) { + if path == managed_root || !path.starts_with(managed_root) { + return; + } let Some(app_state) = current_app_state(cx) else { return; }; diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index ea4ec36674878ca958a2f73af0adf749a40157f6..e4906694589c8fea6f2969daab80ebe4b4ca8919 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -8349,3 +8349,132 @@ async fn test_remote_project_integration_does_not_briefly_render_as_separate_pro entries_after_update, ); } + +#[gpui::test] +async fn test_archive_cleans_up_empty_parent_directory(cx: &mut TestAppContext) { + // Zed creates worktrees at ///. + // After `git worktree remove` deletes the inner / directory, + // the intermediate / directory should also be removed if empty. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "dewy-cedar": { + "commondir": "../../", + "HEAD": "ref: refs/heads/dewy-cedar", + }, + }, + }, + "src": {}, + }), + ) + .await; + + // Real-world nested path: /// + fs.insert_tree( + "/worktrees/project/dewy-cedar/project", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/dewy-cedar", + "src": { + "main.rs": "fn main() {}", + }, + }), + ) + .await; + + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/worktrees/project/dewy-cedar/project"), + ref_name: Some("refs/heads/dewy-cedar".into()), + sha: "abc".into(), + is_main: false, + }, + ) + .await; + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test( + fs.clone(), + ["/worktrees/project/dewy-cedar/project".as_ref()], + cx, + ) + .await; + + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx); + }); + + // Save a thread for the main project. + save_thread_metadata( + acp::SessionId::new(Arc::from("main-thread")), + "Main Thread".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + None, + &main_project, + cx, + ); + + // Save a thread for the linked worktree. + let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread")); + save_thread_metadata( + wt_thread_id.clone(), + "Worktree Thread".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + None, + &worktree_project, + cx, + ); + cx.run_until_parked(); + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Archive the worktree thread. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.archive_thread(&wt_thread_id, window, cx); + }); + + for _ in 0..10 { + cx.run_until_parked(); + } + + // The worktree directory should be removed from disk. + assert!( + !fs.is_dir(Path::new("/worktrees/project/dewy-cedar/project")) + .await, + "worktree directory should be removed from disk" + ); + + // The intermediate branch directory should also be removed + // since it's now empty. + assert!( + !fs.is_dir(Path::new("/worktrees/project/dewy-cedar")).await, + "empty parent directory (branch name) should be cleaned up" + ); + + // The worktrees base directory should NOT be removed + // (it may contain other worktrees). + assert!( + fs.is_dir(Path::new("/worktrees/project")).await, + "worktrees base directory should still exist" + ); +}