@@ -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<Worktree> 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| <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(), ["/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<Worktree>
+ // 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"
+ );
+}