diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index ea4ec36674878ca958a2f73af0adf749a40157f6..7b053fbd879aa1a0d6850932822e9add78e3ef89 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -8349,3 +8349,179 @@ async fn test_remote_project_integration_does_not_briefly_render_as_separate_pro entries_after_update, ); } + +#[gpui::test] +async fn test_archive_removes_worktree_even_when_workspace_paths_diverge(cx: &mut TestAppContext) { + // When the thread's folder_paths don't exactly match any workspace's + // root paths (e.g. because a folder was added to the workspace after + // the thread was created), workspace_to_remove is None. But the linked + // worktree workspace still has the worktree loaded, and its editors + // hold Entity references via File structs in buffers. + // + // Without the fix, the archive task's remove_root calls + // wait_for_worktree_release which hangs forever because the workspace's + // editors prevent the worktree entity from being released. The + // worktree directory is never cleaned up from disk. + // + // With the fix, archive_thread scans roots_to_archive for linked + // worktree workspaces and removes them before starting the cleanup + // task, so editors are dropped and wait_for_worktree_release completes. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": { + "main.rs": "fn main() {}", + }, + }), + ) + .await; + + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".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(), ["/wt-feature-a".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); + + { + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) + }); + + // Open a file from the linked worktree in the worktree workspace's + // editor. This creates a buffer whose File holds an Entity + // strong reference. + worktree_workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from("/wt-feature-a/src/main.rs"), + workspace::OpenOptions::default(), + window, + cx, + ) + }) + .await + .expect("should open file"); + cx.run_until_parked(); + } + // worktree_workspace is dropped here, but the workspace entity + // is still alive because the MultiWorkspace holds it. + + // Save thread metadata using folder_paths that DON'T match the + // workspace's root paths. This simulates the case where the workspace's + // paths diverged (e.g. a folder was added after thread creation). + // This causes workspace_to_remove to be None because + // workspace_for_paths can't find a workspace with these exact paths. + let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread")); + save_thread_metadata_with_main_paths( + "worktree-thread", + "Worktree Thread", + PathList::new(&[ + PathBuf::from("/wt-feature-a"), + PathBuf::from("/nonexistent"), + ]), + PathList::new(&[PathBuf::from("/project"), PathBuf::from("/nonexistent")]), + cx, + ); + + // Also save a main thread so the sidebar has something to show. + 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, + ); + cx.run_until_parked(); + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()), + 2, + "should start with 2 workspaces (main + linked worktree)" + ); + + // Archive the worktree thread. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.archive_thread(&wt_thread_id, window, cx); + }); + + // archive_thread spawns a multi-layered chain of tasks (workspace + // removal → git persist → disk removal), each of which may spawn + // further background work. + for _ in 0..10 { + cx.run_until_parked(); + } + + // The linked worktree workspace should have been removed, even though + // workspace_to_remove was None (paths didn't match). + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()), + 1, + "linked worktree workspace should be removed after archiving, \ + even when folder_paths don't match workspace root paths" + ); + + // The thread should still be archived (not unarchived due to an error). + let still_archived = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(&wt_thread_id) + .map(|t| t.archived) + }); + assert_eq!( + still_archived, + Some(true), + "thread should still be archived (not rolled back due to error)" + ); + + // The linked worktree directory should be removed from disk. + assert!( + !fs.is_dir(Path::new("/wt-feature-a")).await, + "linked worktree directory should be removed from disk" + ); +}