Clean up empty parent directory after archiving worktree

Richard Feldman created

Zed creates worktrees at <worktrees_dir>/<branch_name>/<project_name>/.
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.

Change summary

crates/agent_ui/src/thread_worktree_archive.rs |  26 +++
crates/sidebar/src/sidebar_tests.rs            | 129 ++++++++++++++++++++
2 files changed, 150 insertions(+), 5 deletions(-)

Detailed changes

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. `<worktrees_dir>/<branch>/<project>/`). After the inner
     // directory is removed, the `<branch>/` 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;
     };

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 <worktrees_dir>/<branch>/<project>/.
+    // After `git worktree remove` deletes the inner <project>/ directory,
+    // the intermediate <branch>/ 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: <worktrees_dir>/<branch>/<project>/
+    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| <dyn fs::Fs>::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"
+    );
+}