Rewrite restore flow: non-blocking with pending worktree state

Richard Feldman created

The old restore flow blocked everything until the git worktree was
fully restored. The new flow:

1. Immediately associates the thread with the main repo workspace and
   shows it with a 'Restoring worktree...' spinner in the sidebar.
2. Background: does all git operations (create worktree, resets, branch
   switch) via raw fs.open_repo() — no entity system needed.
3. On success: opens a workspace for the restored worktree, reassociates
   the thread, and switches to it if the user is viewing the thread.
4. On failure: clears the pending state, shows an error toast explaining
   the thread is associated with the main repo instead.

This eliminates the workspace-searching code from the restore path
entirely. The archive side is unchanged.

Also adds pending_worktree_restore to ThreadMetadata (transient, not
persisted to DB) and a loading spinner in the sidebar's ThreadItem
when the state is set.

Change summary

crates/agent_ui/src/thread_import.rs         |   1 
crates/agent_ui/src/thread_metadata_store.rs |  38 +
crates/fs/src/fake_git_repo.rs               |   6 
crates/fs/src/fs.rs                          |   1 
crates/sidebar/src/sidebar.rs                | 604 +++++++--------------
crates/sidebar/src/sidebar_tests.rs          | 418 +++++++++-----
crates/ui/src/components/ai/thread_item.rs   | 109 ++-
7 files changed, 578 insertions(+), 599 deletions(-)

Detailed changes

crates/agent_ui/src/thread_import.rs 🔗

@@ -502,6 +502,7 @@ fn collect_importable_threads(
                 folder_paths,
                 main_worktree_paths: PathList::default(),
                 archived: true,
+                pending_worktree_restore: None,
             });
         }
     }

crates/agent_ui/src/thread_metadata_store.rs 🔗

@@ -71,6 +71,7 @@ fn migrate_thread_metadata(cx: &mut App) {
                         folder_paths: entry.folder_paths,
                         main_worktree_paths: PathList::default(),
                         archived: true,
+                        pending_worktree_restore: None,
                     })
                 })
                 .collect::<Vec<_>>()
@@ -132,6 +133,9 @@ pub struct ThreadMetadata {
     pub folder_paths: PathList,
     pub main_worktree_paths: PathList,
     pub archived: bool,
+    /// When set, the thread's original worktree is being restored in the background.
+    /// The PathBuf is the main repo path being used temporarily while restoration is pending.
+    pub pending_worktree_restore: Option<PathBuf>,
 }
 
 impl From<&ThreadMetadata> for acp_thread::AgentSessionInfo {
@@ -158,7 +162,7 @@ pub struct ArchivedGitWorktree {
     /// Absolute path of the main repository ("main worktree") that owned this worktree.
     /// Used when restoring, to reattach the recreated worktree to the correct main worktree.
     /// If the main repo isn't found on disk, unarchiving fails because we only store the
-    /// commit hash, and without the actual git repo's contents, we can't restore the files.
+    /// commit hash, and without the actual git repo being available, we can't restore the files.
     pub main_repo_path: PathBuf,
     /// Branch that was checked out in the worktree at archive time. `None` if
     /// the worktree was in detached HEAD state, which isn't supported in Zed, but
@@ -520,6 +524,33 @@ impl ThreadMetadataStore {
         }
     }
 
+    pub fn set_pending_worktree_restore(
+        &mut self,
+        session_id: &acp::SessionId,
+        main_repo_path: Option<PathBuf>,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(thread) = self.threads.get_mut(session_id) {
+            thread.pending_worktree_restore = main_repo_path;
+            cx.notify();
+        }
+    }
+
+    pub fn complete_worktree_restore(
+        &mut self,
+        session_id: &acp::SessionId,
+        new_folder_paths: PathList,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(thread) = self.threads.get(session_id).cloned() {
+            let mut updated = thread;
+            updated.pending_worktree_restore = None;
+            updated.folder_paths = new_folder_paths;
+            self.save_internal(updated);
+            cx.notify();
+        }
+    }
+
     pub fn delete(&mut self, session_id: acp::SessionId, cx: &mut Context<Self>) {
         if !cx.has_flag::<AgentV2FeatureFlag>() {
             return;
@@ -711,6 +742,7 @@ impl ThreadMetadataStore {
                     folder_paths,
                     main_worktree_paths,
                     archived,
+                    pending_worktree_restore: None,
                 };
 
                 self.save(metadata, cx);
@@ -1005,6 +1037,7 @@ impl Column for ThreadMetadata {
                 folder_paths,
                 main_worktree_paths,
                 archived,
+                pending_worktree_restore: None,
             },
             next,
         ))
@@ -1083,6 +1116,7 @@ mod tests {
             created_at: Some(updated_at),
             folder_paths,
             main_worktree_paths: PathList::default(),
+            pending_worktree_restore: None,
         }
     }
 
@@ -1300,6 +1334,7 @@ mod tests {
             folder_paths: project_a_paths.clone(),
             main_worktree_paths: PathList::default(),
             archived: false,
+            pending_worktree_restore: None,
         };
 
         cx.update(|cx| {
@@ -1410,6 +1445,7 @@ mod tests {
             folder_paths: project_paths.clone(),
             main_worktree_paths: PathList::default(),
             archived: false,
+            pending_worktree_restore: None,
         };
 
         cx.update(|cx| {

crates/fs/src/fake_git_repo.rs 🔗

@@ -548,8 +548,10 @@ impl GitRepository for FakeGitRepository {
                 if let Some(message) = &state.simulated_create_worktree_error {
                     anyhow::bail!("{message}");
                 }
-                if state.branches.contains(&branch_name) {
-                    bail!("a branch named '{}' already exists", branch_name);
+                if let Some(ref name) = branch_name {
+                    if state.branches.contains(name) {
+                        bail!("a branch named '{}' already exists", name);
+                    }
                 }
                 Ok(())
             })??;

crates/fs/src/fs.rs 🔗

@@ -54,6 +54,7 @@ mod fake_git_repo;
 #[cfg(feature = "test-support")]
 use collections::{BTreeMap, btree_map};
 #[cfg(feature = "test-support")]
+pub use fake_git_repo::FakeCommitSnapshot;
 use fake_git_repo::FakeGitRepositoryState;
 #[cfg(feature = "test-support")]
 use git::{

crates/sidebar/src/sidebar.rs 🔗

@@ -2205,10 +2205,137 @@ impl Sidebar {
             return;
         }
 
-        // Check all paths for archived worktrees that may need restoration
-        // before opening the workspace.
-        let paths = metadata.folder_paths.paths().to_vec();
-        self.maybe_restore_git_worktrees(paths, metadata, window, cx);
+        // Cancel any in-flight archive tasks for the paths we're about to restore.
+        for path in metadata.folder_paths.paths() {
+            self.pending_worktree_archives.remove(path);
+        }
+
+        let session_id = metadata.session_id.clone();
+        let store = ThreadMetadataStore::global(cx);
+
+        let task = store.update(cx, |store, cx| {
+            store.get_archived_worktrees_for_thread(session_id.0.to_string(), cx)
+        });
+
+        let fs = <dyn fs::Fs>::global(cx);
+
+        cx.spawn_in(window, async move |this, cx| {
+            let archived_worktrees = task.await.unwrap_or_default();
+
+            if archived_worktrees.is_empty() {
+                this.update_in(cx, |this, window, cx| {
+                    this.activate_unarchived_thread_in_workspace(&metadata, window, cx);
+                })?;
+                return anyhow::Ok(());
+            }
+
+            // Use the first archived worktree's main repo path to show the
+            // thread immediately while restoration proceeds in the background.
+            let first_archived = &archived_worktrees[0];
+            let main_repo_path = first_archived.main_repo_path.clone();
+
+            // Step 1: Immediately associate thread with main repo and show it.
+            let store = cx.update(|_window, cx| ThreadMetadataStore::global(cx))?;
+            store.update(cx, |store, cx| {
+                store.update_working_directories(
+                    &metadata.session_id,
+                    PathList::new(std::slice::from_ref(&main_repo_path)),
+                    cx,
+                );
+                store.set_pending_worktree_restore(
+                    &metadata.session_id,
+                    Some(main_repo_path.clone()),
+                    cx,
+                );
+            });
+
+            let temp_metadata = store
+                .update(cx, |store, _cx| store.entry(&metadata.session_id).cloned())
+                .unwrap_or(metadata.clone());
+
+            this.update_in(cx, |this, window, cx| {
+                this.activate_unarchived_thread_in_workspace(&temp_metadata, window, cx);
+            })?;
+
+            // Step 2: Background git restoration.
+            for row in &archived_worktrees {
+                match Self::restore_worktree_via_git(row, &fs, cx).await {
+                    Ok(restored_path) => {
+                        Self::cleanup_archived_worktree_record(row, &fs, &store, cx).await;
+
+                        // Step 3: Reassociate thread with the restored worktree path.
+                        let new_paths = PathList::new(std::slice::from_ref(&restored_path));
+                        store.update(cx, |store, cx| {
+                            store.complete_worktree_restore(
+                                &metadata.session_id,
+                                new_paths.clone(),
+                                cx,
+                            );
+                        });
+
+                        // Open the restored worktree workspace and activate
+                        // the thread there.
+                        let paths_vec = vec![restored_path];
+                        let open_result = this.update_in(cx, |this, window, cx| {
+                            let Some(multi_workspace) = this.multi_workspace.upgrade() else {
+                                return None;
+                            };
+                            Some(multi_workspace.update(cx, |mw, cx| {
+                                mw.open_project(paths_vec, workspace::OpenMode::Add, window, cx)
+                            }))
+                        })?;
+
+                        if let Some(open_task) = open_result {
+                            if let Ok(workspace) = open_task.await {
+                                let final_metadata = store
+                                    .update(cx, |store, _cx| {
+                                        store.entry(&metadata.session_id).cloned()
+                                    })
+                                    .unwrap_or_else(|| {
+                                        let mut m = metadata.clone();
+                                        m.folder_paths = new_paths;
+                                        m.pending_worktree_restore = None;
+                                        m
+                                    });
+                                this.update_in(cx, |this, window, cx| {
+                                    this.activate_thread_locally(
+                                        &final_metadata,
+                                        &workspace,
+                                        window,
+                                        cx,
+                                    );
+                                })
+                                .ok();
+                            }
+                        }
+                    }
+                    Err(err) => {
+                        log::error!("Failed to restore worktree: {err}");
+
+                        // Clear pending state — leave thread on main repo.
+                        store.update(cx, |store, cx| {
+                            store.set_pending_worktree_restore(&metadata.session_id, None, cx);
+                        });
+
+                        cx.prompt(
+                            PromptLevel::Warning,
+                            "Worktree restoration failed",
+                            Some(&format!(
+                                "Could not restore the git worktree. \
+                                 The thread has been associated with {} instead.",
+                                main_repo_path.display()
+                            )),
+                            &["OK"],
+                        )
+                        .await
+                        .ok();
+                    }
+                }
+            }
+
+            anyhow::Ok(())
+        })
+        .detach_and_log_err(cx);
     }
 
     fn activate_unarchived_thread_in_workspace(
@@ -2248,435 +2375,119 @@ impl Sidebar {
         }
     }
 
-    fn maybe_restore_git_worktrees(
-        &mut self,
-        paths: Vec<std::path::PathBuf>,
-        metadata: ThreadMetadata,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        // Cancel any in-flight archive tasks for the paths we're about to
-        // restore, so a slow archive cannot delete a worktree we are restoring.
-        let canceled_paths: Vec<_> = paths
-            .iter()
-            .filter(|path| self.pending_worktree_archives.remove(*path).is_some())
-            .cloned()
-            .collect();
-
-        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
-            return;
-        };
-        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
-        let session_id = metadata.session_id.0.to_string();
-
-        cx.spawn_in(window, async move |this, cx| {
-            let store = cx.update(|_window, cx| ThreadMetadataStore::global(cx))?;
-
-            // Look up all archived worktrees linked to this thread.
-            let archived_worktrees = store
-                .update(cx, |store, cx| {
-                    store.get_archived_worktrees_for_thread(session_id, cx)
-                })
-                .await
-                .unwrap_or_default();
-
-            // Build a map from worktree_path → ArchivedGitWorktree for quick lookup.
-            let archived_by_path: HashMap<PathBuf, ArchivedGitWorktree> = archived_worktrees
-                .into_iter()
-                .map(|row| (row.worktree_path.clone(), row))
-                .collect();
-
-            // Clean up any canceled in-flight archives that have DB records.
-            for canceled_path in &canceled_paths {
-                if let Some(row) = archived_by_path.get(canceled_path) {
-                    Self::maybe_cleanup_archived_worktree(row, &store, &workspaces, cx).await;
-                }
-            }
-
-            let mut final_paths = Vec::with_capacity(paths.len());
-
-            for path in &paths {
-                match archived_by_path.get(path) {
-                    None => {
-                        final_paths.push(path.clone());
-                    }
-                    Some(row) => {
-                        let fs = cx.update(|_window, cx| <dyn fs::Fs>::global(cx))?;
-                        match Self::restore_archived_worktree(row, &workspaces, fs, cx).await {
-                            Ok(restored_path) => {
-                                final_paths.push(restored_path);
-                                Self::maybe_cleanup_archived_worktree(row, &store, &workspaces, cx)
-                                    .await;
-                            }
-                            Err(err) => {
-                                log::error!(
-                                    "Failed to restore archived worktree for {}: {err}",
-                                    path.display()
-                                );
-                                final_paths.push(path.clone());
-                            }
-                        }
-                    }
-                }
-            }
-
-            let mut updated_metadata = metadata;
-            updated_metadata.folder_paths = PathList::new(&final_paths);
-
-            this.update_in(cx, |this, window, cx| {
-                this.activate_unarchived_thread_in_workspace(&updated_metadata, window, cx);
-            })?;
-
-            anyhow::Ok(())
-        })
-        .detach_and_log_err(cx);
-    }
-
-    async fn restore_archived_worktree(
+    async fn restore_worktree_via_git(
         row: &ArchivedGitWorktree,
-        workspaces: &[Entity<Workspace>],
-        fs: Arc<dyn fs::Fs>,
+        fs: &Arc<dyn fs::Fs>,
         cx: &mut AsyncWindowContext,
     ) -> anyhow::Result<PathBuf> {
+        let main_repo_path = row.main_repo_path.clone();
+        let dot_git_path = main_repo_path.join(git::DOT_GIT);
+        let worktree_path = row.worktree_path.clone();
         let commit_hash = row.commit_hash.clone();
 
-        // Find the main repo entity.
-        let main_repo = cx.update(|_window, cx| {
-            find_main_repo_in_workspaces(workspaces, &row.main_repo_path, cx)
-        })?;
-
-        let Some(main_repo) = main_repo else {
-            // Main repo not found — fall back to fresh worktree.
-            return Self::create_fresh_worktree(row, &fs, cx).await;
-        };
+        if fs.metadata(&dot_git_path).await?.is_none() {
+            anyhow::bail!(
+                "Git repository at {} no longer exists",
+                main_repo_path.display()
+            );
+        }
 
-        // Check if the original worktree path is already in use.
-        let worktree_path = &row.worktree_path;
-        let already_exists = fs.metadata(worktree_path).await?.is_some();
+        let main_repo = cx
+            .background_spawn({
+                let fs = fs.clone();
+                let dot_git_path = dot_git_path.clone();
+                async move { fs.open_repo(&dot_git_path, None) }
+            })
+            .await?;
 
-        let is_restored_and_valid = already_exists
-            && row.restored
-            && cx.update(|_window, cx| {
-                workspaces.iter().any(|workspace| {
-                    let project = workspace.read(cx).project().clone();
-                    project
-                        .read(cx)
-                        .repositories(cx)
-                        .values()
-                        .any(|repo_entity| {
-                            *repo_entity.read(cx).snapshot().work_directory_abs_path
-                                == *worktree_path
-                        })
-                })
-            })?;
+        let already_exists = fs.metadata(&worktree_path).await?.is_some();
 
-        let final_worktree_path = if !already_exists {
-            worktree_path.clone()
-        } else if is_restored_and_valid {
-            // Another thread already restored this worktree and it's
-            // registered as a git worktree in the project — reuse it.
-            worktree_path.clone()
-        } else {
-            // Collision — use a different path. Generate a name based on
-            // the archived worktree ID to keep it deterministic.
-            let suffix = row.id.to_string();
+        let final_path = if already_exists {
+            let worktree_directory = git_store::worktrees_directory_for_repo(
+                &main_repo_path,
+                git::repository::DEFAULT_WORKTREE_DIRECTORY,
+            )?;
             let new_name = format!(
-                "{}-restored-{suffix}",
+                "{}-restored-{}",
                 row.branch_name.as_deref().unwrap_or("worktree"),
+                row.id
             );
-            let path = main_repo.update(cx, |repo, _cx| {
-                let setting = git_store::worktrees_directory_for_repo(
-                    &repo.snapshot().original_repo_abs_path,
-                    git::repository::DEFAULT_WORKTREE_DIRECTORY,
-                )
-                .ok()
-                .map(|p| p.to_string_lossy().to_string())
-                .unwrap_or_default();
-                repo.path_for_new_linked_worktree(&new_name, &setting)
-            })?;
-            path
-        };
-
-        // We need to create the worktree if it doesn't already exist at
-        // the final path (which may differ from the original due to a
-        // collision). If another thread already restored it and it's a
-        // recognized worktree, we skip creation.
-        let final_path_exists = if final_worktree_path == *worktree_path {
-            already_exists
+            let project_name = main_repo_path
+                .file_name()
+                .ok_or_else(|| anyhow::anyhow!("git repo must have a directory name"))?;
+            worktree_directory.join(&new_name).join(project_name)
         } else {
-            fs.metadata(&final_worktree_path).await?.is_some()
+            worktree_path.clone()
         };
 
-        if !final_path_exists && !is_restored_and_valid {
-            // Create the worktree in detached HEAD mode at the WIP commit.
-            let create_receiver = main_repo.update(cx, |repo, _cx| {
-                repo.create_worktree_detached(final_worktree_path.clone(), commit_hash.clone())
-            });
-            match create_receiver.await {
-                Ok(Ok(())) => {}
-                Ok(Err(err)) => {
-                    // Another concurrent restore may have already created
-                    // this worktree. Re-check before falling back.
-                    if fs.metadata(&final_worktree_path).await?.is_some() {
-                        log::info!("Worktree creation failed ({err}) but path exists — reusing it");
-                    } else {
-                        log::error!("Failed to create worktree: {err}");
-                        return Self::create_fresh_worktree(row, &fs, cx).await;
-                    }
-                }
-                Err(_) => {
-                    anyhow::bail!("Worktree creation was canceled");
-                }
-            }
-
-            // Tell the project about the new worktree and wait for it
-            // to finish scanning so the GitStore creates a Repository.
-            let project = cx.update(|_window, cx| {
-                workspaces.iter().find_map(|workspace| {
-                    let project = workspace.read(cx).project().clone();
-                    let has_main_repo = project.read(cx).repositories(cx).values().any(|repo| {
-                        let repo = repo.read(cx);
-                        repo.is_main_worktree()
-                            && *repo.work_directory_abs_path == *row.main_repo_path
-                    });
-                    has_main_repo.then_some(project)
-                })
-            })?;
-
-            if let Some(project) = project {
-                let path_for_register = final_worktree_path.clone();
-                let worktree_result = project
-                    .update(cx, |project, cx| {
-                        project.find_or_create_worktree(path_for_register, true, cx)
-                    })
-                    .await;
-                if let Ok((worktree, _)) = worktree_result {
-                    let scan_complete = cx.update(|_window, cx| {
-                        worktree
-                            .read(cx)
-                            .as_local()
-                            .map(project::LocalWorktree::scan_complete)
-                    })?;
-                    if let Some(future) = scan_complete {
-                        future.await;
-                    }
-                }
-            }
+        main_repo
+            .create_worktree(None, final_path.clone(), Some(commit_hash.clone()))
+            .await?;
 
-            // Find the new worktree's repo entity.
-            let worktree_repo = cx.update(|_window, cx| {
-                workspaces.iter().find_map(|workspace| {
-                    let project = workspace.read(cx).project().clone();
-                    project
-                        .read(cx)
-                        .repositories(cx)
-                        .values()
-                        .find_map(|repo_entity| {
-                            let snapshot = repo_entity.read(cx).snapshot();
-                            (*snapshot.work_directory_abs_path == *final_worktree_path)
-                                .then(|| repo_entity.clone())
-                        })
-                })
-            })?;
+        let wt_dot_git = final_path.join(git::DOT_GIT);
+        let wt_repo = cx
+            .background_spawn({
+                let fs = fs.clone();
+                async move { fs.open_repo(&wt_dot_git, None) }
+            })
+            .await?;
 
-            if let Some(worktree_repo) = worktree_repo {
-                let resets_ok = 'resets: {
-                    let mixed_reset = worktree_repo.update(cx, |repo, cx| {
-                        repo.reset("HEAD~".to_string(), ResetMode::Mixed, cx)
-                    });
-                    match mixed_reset.await {
-                        Ok(Ok(())) => {}
-                        Ok(Err(err)) => {
-                            log::warn!("Failed to mixed-reset WIP unstaged commit: {err}");
-                            break 'resets false;
-                        }
-                        Err(_) => {
-                            log::warn!("Mixed reset was canceled");
-                            break 'resets false;
-                        }
-                    }
+        let empty_env: Arc<collections::HashMap<String, String>> = Arc::default();
 
-                    let soft_reset = worktree_repo.update(cx, |repo, cx| {
-                        repo.reset("HEAD~".to_string(), ResetMode::Soft, cx)
-                    });
-                    match soft_reset.await {
-                        Ok(Ok(())) => {}
-                        Ok(Err(err)) => {
-                            log::warn!("Failed to soft-reset WIP staged commit: {err}");
-                            // Attempt to undo the mixed reset to return to the WIP commit.
-                            let undo = worktree_repo.update(cx, |repo, cx| {
-                                repo.reset(commit_hash.clone(), ResetMode::Mixed, cx)
-                            });
-                            match undo.await {
-                                Ok(Ok(())) => {
-                                    log::info!("Undid mixed reset after soft-reset failure")
-                                }
-                                Ok(Err(undo_err)) => {
-                                    log::warn!("Could not undo mixed reset: {undo_err}")
-                                }
-                                Err(_) => log::warn!("Undo of mixed reset was canceled"),
-                            }
-                            break 'resets false;
-                        }
-                        Err(_) => {
-                            log::warn!("Soft reset was canceled");
-                            // Attempt to undo the mixed reset to return to the WIP commit.
-                            let undo = worktree_repo.update(cx, |repo, cx| {
-                                repo.reset(commit_hash.clone(), ResetMode::Mixed, cx)
-                            });
-                            match undo.await {
-                                Ok(Ok(())) => {
-                                    log::info!("Undid mixed reset after soft-reset cancellation")
-                                }
-                                Ok(Err(undo_err)) => {
-                                    log::warn!("Could not undo mixed reset: {undo_err}")
-                                }
-                                Err(_) => log::warn!("Undo of mixed reset was canceled"),
-                            }
-                            break 'resets false;
-                        }
-                    }
+        if let Err(err) = wt_repo
+            .reset("HEAD~".to_string(), ResetMode::Mixed, empty_env.clone())
+            .await
+        {
+            log::warn!("Failed to mixed-reset: {err}");
+            let _ = wt_repo
+                .reset(commit_hash.clone(), ResetMode::Mixed, empty_env.clone())
+                .await;
+            anyhow::bail!("Failed to restore staging state: {err}");
+        }
 
-                    true
-                };
+        if let Err(err) = wt_repo
+            .reset("HEAD~".to_string(), ResetMode::Soft, empty_env.clone())
+            .await
+        {
+            log::warn!("Failed to soft-reset: {err}");
+            let _ = wt_repo
+                .reset(commit_hash.clone(), ResetMode::Mixed, empty_env)
+                .await;
+            anyhow::bail!("Failed to restore staging state: {err}");
+        }
 
-                if !resets_ok {
+        if let Some(branch_name) = &row.branch_name {
+            if wt_repo.change_branch(branch_name.clone()).await.is_err() {
+                if let Err(_) = wt_repo.create_branch(branch_name.clone(), None).await {
                     log::warn!(
-                        "Staging state could not be fully restored for worktree; proceeding to mark as restored"
+                        "Could not switch to branch '{branch_name}' — \
+                         the restored worktree is in detached HEAD state."
                     );
-                } else if let Some(original_branch) = &row.branch_name {
-                    let switch_receiver = worktree_repo
-                        .update(cx, |repo, _cx| repo.change_branch(original_branch.clone()));
-                    let switch_ok = matches!(switch_receiver.await, Ok(Ok(())));
-
-                    if !switch_ok {
-                        // The branch may already exist but be checked out in
-                        // another worktree. Attempt to create it in case it
-                        // was deleted; if it already exists, just accept the
-                        // detached HEAD and warn.
-                        let create_receiver = worktree_repo.update(cx, |repo, _cx| {
-                            repo.create_branch(original_branch.clone(), None)
-                        });
-                        match create_receiver.await {
-                            Ok(Ok(())) => {}
-                            Ok(Err(_)) => {
-                                log::warn!(
-                                    "Could not switch to branch '{original_branch}' — \
-                                     it may be checked out in another worktree. \
-                                     The restored worktree is in detached HEAD state."
-                                );
-                            }
-                            Err(_) => {
-                                log::warn!(
-                                    "Branch creation for '{original_branch}' was canceled; \
-                                     the restored worktree is in detached HEAD state."
-                                );
-                            }
-                        }
-                    }
                 }
             }
-
-            // Mark the archived worktree as restored in the database.
-            let store = cx.update(|_window, cx| ThreadMetadataStore::global(cx))?;
-            store
-                .update(cx, |store, cx| {
-                    store.set_archived_worktree_restored(
-                        row.id,
-                        final_worktree_path.to_string_lossy().to_string(),
-                        row.branch_name.clone(),
-                        cx,
-                    )
-                })
-                .await?;
         }
 
-        Ok(final_worktree_path)
+        Ok(final_path)
     }
 
-    async fn create_fresh_worktree(
+    async fn cleanup_archived_worktree_record(
         row: &ArchivedGitWorktree,
         fs: &Arc<dyn fs::Fs>,
+        store: &Entity<ThreadMetadataStore>,
         cx: &mut AsyncWindowContext,
-    ) -> anyhow::Result<PathBuf> {
-        let main_repo_path = row.main_repo_path.clone();
-        let dot_git_path = main_repo_path.join(git::DOT_GIT);
-
-        if fs.metadata(&dot_git_path).await?.is_none() {
-            anyhow::bail!(
-                "Cannot unarchive worktree because there is no longer a git repository at {}",
-                main_repo_path.display()
-            );
-        }
-
-        // Open the repo directly from disk — the main repo may not be
-        // open in any workspace.
-        let git_repo = cx
+    ) {
+        let dot_git_path = row.main_repo_path.join(git::DOT_GIT);
+        if let Ok(main_repo) = cx
             .background_spawn({
                 let fs = fs.clone();
-                let dot_git_path = dot_git_path.clone();
                 async move { fs.open_repo(&dot_git_path, None) }
             })
-            .await?;
-
-        // Generate a new branch name for the fresh worktree.
-        let branch_name = {
-            use std::hash::{Hash, Hasher};
-            let mut hasher = std::collections::hash_map::DefaultHasher::new();
-            row.worktree_path.hash(&mut hasher);
-            let suffix = format!("{:x}", hasher.finish())
-                .chars()
-                .take(8)
-                .collect::<String>();
-            format!("restored-{suffix}")
-        };
-
-        // Compute the worktree path (same logic as Repository::path_for_new_linked_worktree).
-        let project_name = main_repo_path
-            .file_name()
-            .ok_or_else(|| anyhow::anyhow!("git repo must have a directory name"))?
-            .to_string_lossy()
-            .to_string();
-        let worktree_directory = git_store::worktrees_directory_for_repo(
-            &main_repo_path,
-            git::repository::DEFAULT_WORKTREE_DIRECTORY,
-        )?;
-        let worktree_path = worktree_directory.join(&branch_name).join(&project_name);
-
-        // Create the fresh worktree.
-        git_repo
-            .create_worktree(Some(branch_name), worktree_path.clone(), None)
-            .await?;
-
-        log::warn!(
-            "Unable to restore the original git worktree. Created a fresh worktree instead."
-        );
-
-        Ok(worktree_path)
-    }
-
-    async fn maybe_cleanup_archived_worktree(
-        row: &ArchivedGitWorktree,
-        store: &Entity<ThreadMetadataStore>,
-        workspaces: &[Entity<Workspace>],
-        cx: &mut AsyncWindowContext,
-    ) {
-        // Delete the git ref from the main repo.
-        let Ok(main_repo) = cx.update(|_window, cx| {
-            find_main_repo_in_workspaces(workspaces, &row.main_repo_path, cx)
-        }) else {
-            return;
-        };
-
-        if let Some(main_repo) = main_repo {
+            .await
+        {
             let ref_name = archived_worktree_ref_name(row.id);
-            let receiver = main_repo.update(cx, |repo, _cx| repo.delete_ref(ref_name));
-            if let Ok(result) = receiver.await {
-                result.log_err();
-            }
+            main_repo.delete_ref(ref_name).await.log_err();
         }
 
-        // Delete the archived worktree record (and join table entries).
         store
             .update(cx, |store, cx| store.delete_archived_worktree(row.id, cx))
             .await
@@ -3878,6 +3689,7 @@ impl Sidebar {
             .timestamp(timestamp)
             .highlight_positions(thread.highlight_positions.to_vec())
             .title_generating(thread.is_title_generating)
+            .pending_worktree_restore(thread.metadata.pending_worktree_restore.is_some())
             .notified(has_notification)
             .when(thread.diff_stats.lines_added > 0, |this| {
                 this.added(thread.diff_stats.lines_added as usize)

crates/sidebar/src/sidebar_tests.rs 🔗

@@ -146,7 +146,9 @@ fn save_thread_metadata(
         updated_at,
         created_at,
         folder_paths: path_list,
+        main_worktree_paths: PathList::default(),
         archived: false,
+        pending_worktree_restore: None,
     };
     cx.update(|cx| {
         ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx))
@@ -694,10 +696,12 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                     session_id: acp::SessionId::new(Arc::from("t-1")),
                     agent_id: AgentId::new("zed-agent"),
                     folder_paths: PathList::default(),
+                    main_worktree_paths: PathList::default(),
                     title: "Completed thread".into(),
                     updated_at: Utc::now(),
                     created_at: Some(Utc::now()),
                     archived: false,
+                    pending_worktree_restore: None,
                 },
                 icon: IconName::ZedAgent,
                 icon_from_external_svg: None,
@@ -716,10 +720,12 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                     session_id: acp::SessionId::new(Arc::from("t-2")),
                     agent_id: AgentId::new("zed-agent"),
                     folder_paths: PathList::default(),
+                    main_worktree_paths: PathList::default(),
                     title: "Running thread".into(),
                     updated_at: Utc::now(),
                     created_at: Some(Utc::now()),
                     archived: false,
+                    pending_worktree_restore: None,
                 },
                 icon: IconName::ZedAgent,
                 icon_from_external_svg: None,
@@ -738,10 +744,12 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                     session_id: acp::SessionId::new(Arc::from("t-3")),
                     agent_id: AgentId::new("zed-agent"),
                     folder_paths: PathList::default(),
+                    main_worktree_paths: PathList::default(),
                     title: "Error thread".into(),
                     updated_at: Utc::now(),
                     created_at: Some(Utc::now()),
                     archived: false,
+                    pending_worktree_restore: None,
                 },
                 icon: IconName::ZedAgent,
                 icon_from_external_svg: None,
@@ -760,10 +768,12 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                     session_id: acp::SessionId::new(Arc::from("t-4")),
                     agent_id: AgentId::new("zed-agent"),
                     folder_paths: PathList::default(),
+                    main_worktree_paths: PathList::default(),
                     title: "Waiting thread".into(),
                     updated_at: Utc::now(),
                     created_at: Some(Utc::now()),
                     archived: false,
+                    pending_worktree_restore: None,
                 },
                 icon: IconName::ZedAgent,
                 icon_from_external_svg: None,
@@ -782,10 +792,12 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                     session_id: acp::SessionId::new(Arc::from("t-5")),
                     agent_id: AgentId::new("zed-agent"),
                     folder_paths: PathList::default(),
+                    main_worktree_paths: PathList::default(),
                     title: "Notified thread".into(),
                     updated_at: Utc::now(),
                     created_at: Some(Utc::now()),
                     archived: false,
+                    pending_worktree_restore: None,
                 },
                 icon: IconName::ZedAgent,
                 icon_from_external_svg: None,
@@ -2049,7 +2061,9 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
                 updated_at: Utc::now(),
                 created_at: None,
                 folder_paths: PathList::default(),
+                main_worktree_paths: PathList::default(),
                 archived: false,
+                pending_worktree_restore: None,
             },
             &workspace_a,
             window,
@@ -2104,7 +2118,9 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
                 updated_at: Utc::now(),
                 created_at: None,
                 folder_paths: PathList::default(),
+                main_worktree_paths: PathList::default(),
                 archived: false,
+                pending_worktree_restore: None,
             },
             &workspace_b,
             window,
@@ -2440,6 +2456,7 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp
                     "feature-a": {
                         "commondir": "../../",
                         "HEAD": "ref: refs/heads/feature-a",
+                        "gitdir": "/wt-feature-a/.git",
                     },
                 },
             },
@@ -2459,12 +2476,9 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp
     .await;
 
     fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
-        state.worktrees.push(git::repository::Worktree {
-            path: std::path::PathBuf::from("/wt-feature-a"),
-            ref_name: Some("refs/heads/feature-a".into()),
-            sha: "aaa".into(),
-            is_main: false,
-        });
+        state
+            .refs
+            .insert("refs/heads/feature-a".to_string(), "aaa".to_string());
     })
     .unwrap();
 
@@ -2568,20 +2582,62 @@ async fn init_test_project_with_git(
     (project, fs)
 }
 
+/// Register a git worktree in the FakeFs by creating the on-disk structures
+/// that `FakeGitRepository::worktrees()` reads (`.git/worktrees/<name>/` entries).
+///
+/// * `dot_git` — path to the main repo's `.git` dir (e.g. `/project/.git`)
+/// * `worktree_path` — checkout path for the worktree (e.g. `/wt-feature-a`)
+/// * `branch` — branch name (e.g. `feature-a`)
+/// * `sha` — fake commit SHA
+async fn register_fake_worktree(
+    fs: &FakeFs,
+    dot_git: &str,
+    worktree_path: &str,
+    branch: &str,
+    sha: &str,
+) {
+    register_fake_worktree_emit(fs, dot_git, worktree_path, branch, sha, false).await;
+}
+
+async fn register_fake_worktree_emit(
+    fs: &FakeFs,
+    dot_git: &str,
+    worktree_path: &str,
+    branch: &str,
+    sha: &str,
+    emit: bool,
+) {
+    let worktrees_entry = format!("{}/worktrees/{}", dot_git, branch);
+    fs.insert_tree(
+        &worktrees_entry,
+        serde_json::json!({
+            "HEAD": format!("ref: refs/heads/{}", branch),
+            "commondir": dot_git,
+            "gitdir": format!("{}/.git", worktree_path),
+        }),
+    )
+    .await;
+
+    fs.with_git_state(std::path::Path::new(dot_git), emit, |state| {
+        state
+            .refs
+            .insert(format!("refs/heads/{}", branch), sha.to_string());
+    })
+    .unwrap();
+}
+
 #[gpui::test]
 async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
     let (project, fs) = init_test_project_with_git("/project", cx).await;
 
-    fs.as_fake()
-        .with_git_state(std::path::Path::new("/project/.git"), false, |state| {
-            state.worktrees.push(git::repository::Worktree {
-                path: std::path::PathBuf::from("/wt/rosewood"),
-                ref_name: Some("refs/heads/rosewood".into()),
-                sha: "abc".into(),
-                is_main: false,
-            });
-        })
-        .unwrap();
+    register_fake_worktree(
+        &fs.as_fake(),
+        "/project/.git",
+        "/wt/rosewood",
+        "rosewood",
+        "abc",
+    )
+    .await;
 
     project
         .update(cx, |project, cx| project.git_scans_complete(cx))
@@ -2634,16 +2690,15 @@ async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
     );
 
     // Now add the worktree to the git state and trigger a rescan.
-    fs.as_fake()
-        .with_git_state(std::path::Path::new("/project/.git"), true, |state| {
-            state.worktrees.push(git::repository::Worktree {
-                path: std::path::PathBuf::from("/wt/rosewood"),
-                ref_name: Some("refs/heads/rosewood".into()),
-                sha: "abc".into(),
-                is_main: false,
-            });
-        })
-        .unwrap();
+    register_fake_worktree_emit(
+        &fs.as_fake(),
+        "/project/.git",
+        "/wt/rosewood",
+        "rosewood",
+        "abc",
+        true,
+    )
+    .await;
 
     cx.run_until_parked();
 
@@ -2671,10 +2726,12 @@ async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppC
                     "feature-a": {
                         "commondir": "../../",
                         "HEAD": "ref: refs/heads/feature-a",
+                        "gitdir": "/wt-feature-a/.git",
                     },
                     "feature-b": {
                         "commondir": "../../",
                         "HEAD": "ref: refs/heads/feature-b",
+                        "gitdir": "/wt-feature-b/.git",
                     },
                 },
             },
@@ -2737,21 +2794,8 @@ async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppC
 
     // Configure the main repo to list both worktrees before opening
     // it so the initial git scan picks them up.
-    fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
-        state.worktrees.push(git::repository::Worktree {
-            path: std::path::PathBuf::from("/wt-feature-a"),
-            ref_name: Some("refs/heads/feature-a".into()),
-            sha: "aaa".into(),
-            is_main: false,
-        });
-        state.worktrees.push(git::repository::Worktree {
-            path: std::path::PathBuf::from("/wt-feature-b"),
-            ref_name: Some("refs/heads/feature-b".into()),
-            sha: "bbb".into(),
-            is_main: false,
-        });
-    })
-    .unwrap();
+    register_fake_worktree(&fs, "/project/.git", "/wt-feature-a", "feature-a", "aaa").await;
+    register_fake_worktree(&fs, "/project/.git", "/wt-feature-b", "feature-b", "bbb").await;
 
     let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
     main_project
@@ -2793,10 +2837,12 @@ async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut
                     "feature-a": {
                         "commondir": "../../",
                         "HEAD": "ref: refs/heads/feature-a",
+                        "gitdir": "/wt-feature-a/.git",
                     },
                     "feature-b": {
                         "commondir": "../../",
                         "HEAD": "ref: refs/heads/feature-b",
+                        "gitdir": "/wt-feature-b/.git",
                     },
                 },
             },
@@ -2821,21 +2867,8 @@ async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut
     )
     .await;
 
-    fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
-        state.worktrees.push(git::repository::Worktree {
-            path: std::path::PathBuf::from("/wt-feature-a"),
-            ref_name: Some("refs/heads/feature-a".into()),
-            sha: "aaa".into(),
-            is_main: false,
-        });
-        state.worktrees.push(git::repository::Worktree {
-            path: std::path::PathBuf::from("/wt-feature-b"),
-            ref_name: Some("refs/heads/feature-b".into()),
-            sha: "bbb".into(),
-            is_main: false,
-        });
-    })
-    .unwrap();
+    register_fake_worktree(&fs, "/project/.git", "/wt-feature-a", "feature-a", "aaa").await;
+    register_fake_worktree(&fs, "/project/.git", "/wt-feature-b", "feature-b", "bbb").await;
 
     cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 
@@ -2889,10 +2922,12 @@ async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext
                     "olivetti": {
                         "commondir": "../../",
                         "HEAD": "ref: refs/heads/olivetti",
+                        "gitdir": "/worktrees/project_a/olivetti/project_a/.git",
                     },
                     "selectric": {
                         "commondir": "../../",
                         "HEAD": "ref: refs/heads/selectric",
+                        "gitdir": "/worktrees/project_a/selectric/project_a/.git",
                     },
                 },
             },
@@ -2908,10 +2943,12 @@ async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext
                     "olivetti": {
                         "commondir": "../../",
                         "HEAD": "ref: refs/heads/olivetti",
+                        "gitdir": "/worktrees/project_b/olivetti/project_b/.git",
                     },
                     "selectric": {
                         "commondir": "../../",
                         "HEAD": "ref: refs/heads/selectric",
+                        "gitdir": "/worktrees/project_b/selectric/project_b/.git",
                     },
                 },
             },
@@ -2942,17 +2979,10 @@ async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext
     // Register linked worktrees.
     for repo in &["project_a", "project_b"] {
         let git_path = format!("/{repo}/.git");
-        fs.with_git_state(std::path::Path::new(&git_path), false, |state| {
-            for branch in &["olivetti", "selectric"] {
-                state.worktrees.push(git::repository::Worktree {
-                    path: std::path::PathBuf::from(format!("/worktrees/{repo}/{branch}/{repo}")),
-                    ref_name: Some(format!("refs/heads/{branch}").into()),
-                    sha: "aaa".into(),
-                    is_main: false,
-                });
-            }
-        })
-        .unwrap();
+        for branch in &["olivetti", "selectric"] {
+            let wt_path = format!("/worktrees/{repo}/{branch}/{repo}");
+            register_fake_worktree(&fs, &git_path, &wt_path, branch, "aaa").await;
+        }
     }
 
     cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
@@ -3010,6 +3040,7 @@ async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext
                     "olivetti": {
                         "commondir": "../../",
                         "HEAD": "ref: refs/heads/olivetti",
+                        "gitdir": "/worktrees/project_a/olivetti/project_a/.git",
                     },
                 },
             },
@@ -3025,6 +3056,7 @@ async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext
                     "olivetti": {
                         "commondir": "../../",
                         "HEAD": "ref: refs/heads/olivetti",
+                        "gitdir": "/worktrees/project_b/olivetti/project_b/.git",
                     },
                 },
             },
@@ -3046,15 +3078,8 @@ async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext
         .await;
 
         let git_path = format!("/{repo}/.git");
-        fs.with_git_state(std::path::Path::new(&git_path), false, |state| {
-            state.worktrees.push(git::repository::Worktree {
-                path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")),
-                ref_name: Some("refs/heads/olivetti".into()),
-                sha: "aaa".into(),
-                is_main: false,
-            });
-        })
-        .unwrap();
+        let wt_path = format!("/worktrees/{repo}/olivetti/{repo}");
+        register_fake_worktree(&fs, &git_path, &wt_path, "olivetti", "aaa").await;
     }
 
     cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
@@ -3119,6 +3144,7 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp
                     "feature-a": {
                         "commondir": "../../",
                         "HEAD": "ref: refs/heads/feature-a",
+                        "gitdir": "/wt-feature-a/.git",
                     },
                 },
             },
@@ -3138,12 +3164,9 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp
     .await;
 
     fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
-        state.worktrees.push(git::repository::Worktree {
-            path: std::path::PathBuf::from("/wt-feature-a"),
-            ref_name: Some("refs/heads/feature-a".into()),
-            sha: "aaa".into(),
-            is_main: false,
-        });
+        state
+            .refs
+            .insert("refs/heads/feature-a".to_string(), "aaa".to_string());
     })
     .unwrap();
 
@@ -3236,6 +3259,7 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp
                     "feature-a": {
                         "commondir": "../../",
                         "HEAD": "ref: refs/heads/feature-a",
+                        "gitdir": "/wt-feature-a/.git",
                     },
                 },
             },
@@ -3254,12 +3278,9 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp
     .await;
 
     fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
-        state.worktrees.push(git::repository::Worktree {
-            path: std::path::PathBuf::from("/wt-feature-a"),
-            ref_name: Some("refs/heads/feature-a".into()),
-            sha: "aaa".into(),
-            is_main: false,
-        });
+        state
+            .refs
+            .insert("refs/heads/feature-a".to_string(), "aaa".to_string());
     })
     .unwrap();
 
@@ -3343,6 +3364,7 @@ async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut
                     "feature-a": {
                         "commondir": "../../",
                         "HEAD": "ref: refs/heads/feature-a",
+                        "gitdir": "/wt-feature-a/.git",
                     },
                 },
             },
@@ -3361,12 +3383,9 @@ async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut
     .await;
 
     fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
-        state.worktrees.push(git::repository::Worktree {
-            path: std::path::PathBuf::from("/wt-feature-a"),
-            ref_name: Some("refs/heads/feature-a".into()),
-            sha: "aaa".into(),
-            is_main: false,
-        });
+        state
+            .refs
+            .insert("refs/heads/feature-a".to_string(), "aaa".to_string());
     })
     .unwrap();
 
@@ -3449,6 +3468,7 @@ async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_proje
                     "feature-a": {
                         "commondir": "../../",
                         "HEAD": "ref: refs/heads/feature-a",
+                        "gitdir": "/wt-feature-a/.git",
                     },
                 },
             },
@@ -3467,12 +3487,9 @@ async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_proje
     .await;
 
     fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
-        state.worktrees.push(git::repository::Worktree {
-            path: std::path::PathBuf::from("/wt-feature-a"),
-            ref_name: Some("refs/heads/feature-a".into()),
-            sha: "aaa".into(),
-            is_main: false,
-        });
+        state
+            .refs
+            .insert("refs/heads/feature-a".to_string(), "aaa".to_string());
     })
     .unwrap();
 
@@ -3600,6 +3617,7 @@ async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
                     "feature-a": {
                         "commondir": "../../",
                         "HEAD": "ref: refs/heads/feature-a",
+                        "gitdir": "/wt-feature-a/.git",
                     },
                 },
             },
@@ -3618,12 +3636,9 @@ async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
     .await;
 
     fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
-        state.worktrees.push(git::repository::Worktree {
-            path: std::path::PathBuf::from("/wt-feature-a"),
-            ref_name: Some("refs/heads/feature-a".into()),
-            sha: "aaa".into(),
-            is_main: false,
-        });
+        state
+            .refs
+            .insert("refs/heads/feature-a".to_string(), "aaa".to_string());
     })
     .unwrap();
 
@@ -3753,7 +3768,9 @@ async fn test_activate_archived_thread_with_saved_paths_activates_matching_works
                 updated_at: Utc::now(),
                 created_at: None,
                 folder_paths: PathList::new(&[PathBuf::from("/project-b")]),
+                main_worktree_paths: PathList::default(),
                 archived: false,
+                pending_worktree_restore: None,
             },
             window,
             cx,
@@ -3815,7 +3832,9 @@ async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
                 updated_at: Utc::now(),
                 created_at: None,
                 folder_paths: PathList::new(&[std::path::PathBuf::from("/project-b")]),
+                main_worktree_paths: PathList::default(),
                 archived: false,
+                pending_worktree_restore: None,
             },
             window,
             cx,
@@ -3877,7 +3896,9 @@ async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
                 updated_at: Utc::now(),
                 created_at: None,
                 folder_paths: PathList::default(),
+                main_worktree_paths: PathList::default(),
                 archived: false,
+                pending_worktree_restore: None,
             },
             window,
             cx,
@@ -3931,7 +3952,9 @@ async fn test_activate_archived_thread_saved_paths_opens_new_workspace(cx: &mut
                 updated_at: Utc::now(),
                 created_at: None,
                 folder_paths: path_list_b,
+                main_worktree_paths: PathList::default(),
                 archived: false,
+                pending_worktree_restore: None,
             },
             window,
             cx,
@@ -3980,7 +4003,9 @@ async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &m
                 updated_at: Utc::now(),
                 created_at: None,
                 folder_paths: PathList::new(&[PathBuf::from("/project-b")]),
+                main_worktree_paths: PathList::default(),
                 archived: false,
+                pending_worktree_restore: None,
             },
             window,
             cx,
@@ -4056,7 +4081,9 @@ async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_t
                 updated_at: Utc::now(),
                 created_at: None,
                 folder_paths: PathList::new(&[PathBuf::from("/project-b")]),
+                main_worktree_paths: PathList::default(),
                 archived: false,
+                pending_worktree_restore: None,
             },
             window,
             cx,
@@ -4131,7 +4158,9 @@ async fn test_activate_archived_thread_prefers_current_window_for_matching_paths
                 updated_at: Utc::now(),
                 created_at: None,
                 folder_paths: PathList::new(&[PathBuf::from("/project-a")]),
+                main_worktree_paths: PathList::default(),
                 archived: false,
+                pending_worktree_restore: None,
             },
             window,
             cx,
@@ -4195,6 +4224,7 @@ async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppCon
                     "feature-a": {
                         "commondir": "../../",
                         "HEAD": "ref: refs/heads/feature-a",
+                        "gitdir": "/wt-feature-a/.git",
                     },
                 },
             },
@@ -4213,12 +4243,9 @@ async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppCon
     .await;
 
     fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
-        state.worktrees.push(git::repository::Worktree {
-            path: std::path::PathBuf::from("/wt-feature-a"),
-            ref_name: Some("refs/heads/feature-a".into()),
-            sha: "aaa".into(),
-            is_main: false,
-        });
+        state
+            .refs
+            .insert("refs/heads/feature-a".to_string(), "aaa".to_string());
     })
     .unwrap();
 
@@ -4359,6 +4386,7 @@ async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut Test
                     "feature-a": {
                         "commondir": "../../",
                         "HEAD": "ref: refs/heads/feature-a",
+                        "gitdir": "/wt-feature-a/.git",
                     },
                 },
             },
@@ -4385,12 +4413,9 @@ async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut Test
 
     // Register the linked worktree in the main repo.
     fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
-        state.worktrees.push(git::repository::Worktree {
-            path: std::path::PathBuf::from("/wt-feature-a"),
-            ref_name: Some("refs/heads/feature-a".into()),
-            sha: "aaa".into(),
-            is_main: false,
-        });
+        state
+            .refs
+            .insert("refs/heads/feature-a".to_string(), "aaa".to_string());
     })
     .unwrap();
 
@@ -4899,12 +4924,15 @@ async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppCon
 
 #[gpui::test]
 async fn test_archive_and_restore_single_worktree(cx: &mut TestAppContext) {
-    // Test the restore/unarchive flow for a git worktree. We set up a main
-    // repo with an archived worktree record (simulating a prior archive) and
-    // then trigger `activate_archived_thread` to verify:
-    //   1. The worktree directory is recreated.
-    //   2. The archived worktree DB record is cleaned up.
-    //   3. The thread is unarchived in the metadata store.
+    // Test the full restore/unarchive flow for a git worktree.
+    //
+    // The new restore flow is:
+    //   1. `activate_archived_thread` is called
+    //   2. Thread is immediately unarchived and associated with the main repo
+    //      path (with `pending_worktree_restore` set)
+    //   3. Background: git worktree is restored via `fs.open_repo()`
+    //   4. Thread is reassociated with the restored worktree path (pending cleared)
+    //   5. A workspace is opened for the restored path
     agent_ui::test_support::init_test(cx);
     cx.update(|cx| {
         cx.update_flags(false, vec!["agent-v2".into()]);
@@ -4918,15 +4946,42 @@ async fn test_archive_and_restore_single_worktree(cx: &mut TestAppContext) {
 
     // Set up a main repo at /project. The linked worktree at /wt-feature does
     // NOT exist on disk — it was deleted during the archive step.
+    //
+    // Pre-create the .git/worktrees/wt-feature/ entry directory so that
+    // when create_worktree runs during restore, the directory already has a
+    // FakeGitRepositoryState we can seed with commit history.
+    // (create_worktree passes branch_name=None, so the entry name is the
+    // path's file_name = "wt-feature".)
     fs.insert_tree(
         "/project",
         serde_json::json!({
-            ".git": {},
+            ".git": {
+                "worktrees": {
+                    "wt-feature": {},
+                },
+            },
             "src": { "main.rs": "fn main() {}" },
         }),
     )
     .await;
 
+    // Seed the worktree entry's state with 2 fake commits so the two
+    // HEAD~ resets in restore_worktree_via_git succeed.
+    fs.with_git_state(
+        std::path::Path::new("/project/.git/worktrees/wt-feature"),
+        false,
+        |state| {
+            for i in 0..2 {
+                state.commit_history.push(fs::FakeCommitSnapshot {
+                    head_contents: Default::default(),
+                    index_contents: Default::default(),
+                    sha: format!("fake-parent-{i}"),
+                });
+            }
+        },
+    )
+    .unwrap();
+
     let wip_commit_hash = "fake-wip-sha-123";
 
     cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
@@ -4983,8 +5038,7 @@ async fn test_archive_and_restore_single_worktree(cx: &mut TestAppContext) {
         .await
         .expect("linking thread to archived worktree should succeed");
 
-    // Verify pre-conditions: the worktree directory does not exist and the
-    // DB record is present.
+    // Verify pre-conditions.
     assert!(
         !fs.directories(false)
             .iter()
@@ -4998,17 +5052,11 @@ async fn test_archive_and_restore_single_worktree(cx: &mut TestAppContext) {
         })
         .await
         .expect("DB query should succeed");
-    assert_eq!(
-        archived_rows.len(),
-        1,
-        "expected exactly one archived worktree record before restore"
-    );
-    let archived_row = &archived_rows[0];
-    assert_eq!(archived_row.id, archived_id);
-    assert_eq!(archived_row.commit_hash, wip_commit_hash);
-    assert_eq!(archived_row.branch_name.as_deref(), Some("feature"));
+    assert_eq!(archived_rows.len(), 1);
+    assert_eq!(archived_rows[0].commit_hash, wip_commit_hash);
+    assert_eq!(archived_rows[0].branch_name.as_deref(), Some("feature"));
 
-    // Now seed the git ref using the actual archived worktree ID.
+    // Seed the git ref on the main repo.
     let expected_ref_name = archived_worktree_ref_name(archived_id);
     fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
         state
@@ -5040,16 +5088,9 @@ async fn test_archive_and_restore_single_worktree(cx: &mut TestAppContext) {
         sidebar.activate_archived_thread(metadata, window, cx);
     });
     // The restore flow involves multiple async steps: worktree creation,
-    // project scan, reset, branch switch, DB cleanup.
+    // reset, branch switch, DB cleanup, workspace open.
     cx.run_until_parked();
 
-    // NOTE: The FakeGitRepository::create_worktree implementation does not
-    // create a `.git` gitfile inside the worktree directory, so the project
-    // scanner does not discover a Repository entity for the restored worktree.
-    // This means the two-reset staging-restoration logic (mixed reset HEAD~,
-    // then soft reset HEAD~) is not exercised by this test. An integration
-    // test with a real git repo would be needed to cover that path.
-
     // 1. The thread should no longer be archived.
     cx.update(|_, cx| {
         let store = ThreadMetadataStore::global(cx);
@@ -5060,8 +5101,8 @@ async fn test_archive_and_restore_single_worktree(cx: &mut TestAppContext) {
         );
     });
 
-    // 2. The worktree directory should exist again on disk (recreated via
-    //    create_worktree_detached).
+    // 2. The worktree directory should exist on disk (recreated via
+    //    create_worktree in restore_worktree_via_git).
     assert!(
         fs.directories(false)
             .iter()
@@ -5070,7 +5111,26 @@ async fn test_archive_and_restore_single_worktree(cx: &mut TestAppContext) {
         fs.directories(false)
     );
 
-    // 3. The archived worktree DB record should be cleaned up.
+    // 3. The thread's folder_paths should point to the restored worktree,
+    //    not the main repo.
+    cx.update(|_, cx| {
+        let store = ThreadMetadataStore::global(cx);
+        let entry = store
+            .read(cx)
+            .entry(&session_id)
+            .expect("thread should exist in the store");
+        assert_eq!(
+            entry.folder_paths.paths(),
+            &[std::path::PathBuf::from("/wt-feature")],
+            "thread should be associated with the restored worktree path"
+        );
+        assert_eq!(
+            entry.pending_worktree_restore, None,
+            "pending_worktree_restore should be cleared after successful restore"
+        );
+    });
+
+    // 4. The archived worktree DB record should be cleaned up.
     let archived_rows_after = store
         .update_in(cx, |store, _window, cx| {
             store.get_archived_worktrees_for_thread(session_id.0.to_string(), cx)
@@ -5082,7 +5142,7 @@ async fn test_archive_and_restore_single_worktree(cx: &mut TestAppContext) {
         "expected archived worktree records to be empty after restore"
     );
 
-    // 4. The git ref should have been cleaned up from the main repo.
+    // 5. The git ref should have been cleaned up from the main repo.
     fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
         assert!(
             !state.refs.contains_key(&expected_ref_name),
@@ -5097,8 +5157,8 @@ async fn test_archive_and_restore_single_worktree(cx: &mut TestAppContext) {
 async fn test_archive_two_threads_same_path_then_restore_first(cx: &mut TestAppContext) {
     // Regression test: archiving two different threads that use the same
     // worktree path should create independent archived worktree records.
-    // Unarchiving the first thread should restore its own record without
-    // losing the second thread's record.
+    // Unarchiving thread A should restore its record and clean up, while
+    // thread B's record survives intact.
     agent_ui::test_support::init_test(cx);
     cx.update(|cx| {
         cx.update_flags(false, vec!["agent-v2".into()]);
@@ -5110,15 +5170,38 @@ async fn test_archive_two_threads_same_path_then_restore_first(cx: &mut TestAppC
 
     let fs = FakeFs::new(cx.executor());
 
+    // Pre-create the .git/worktrees/wt-feature/ entry directory so that
+    // restore_worktree_via_git's create_worktree call finds it and the
+    // FakeGitRepositoryState we seed with commit history is used.
     fs.insert_tree(
         "/project",
         serde_json::json!({
-            ".git": {},
+            ".git": {
+                "worktrees": {
+                    "wt-feature": {},
+                },
+            },
             "src": { "main.rs": "fn main() {}" },
         }),
     )
     .await;
 
+    // Seed the worktree entry's state with 2 fake commits.
+    fs.with_git_state(
+        std::path::Path::new("/project/.git/worktrees/wt-feature"),
+        false,
+        |state| {
+            for i in 0..2 {
+                state.commit_history.push(fs::FakeCommitSnapshot {
+                    head_contents: Default::default(),
+                    index_contents: Default::default(),
+                    sha: format!("fake-parent-{i}"),
+                });
+            }
+        },
+    )
+    .unwrap();
+
     cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 
     let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
@@ -5170,7 +5253,6 @@ async fn test_archive_two_threads_same_path_then_restore_first(cx: &mut TestAppC
         .await
         .expect("link thread A");
 
-    // Seed a git ref for thread A's archive.
     let ref_a = archived_worktree_ref_name(id_a);
     fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
         state.refs.insert(ref_a.clone(), "sha-aaa".into());
@@ -5220,7 +5302,6 @@ async fn test_archive_two_threads_same_path_then_restore_first(cx: &mut TestAppC
     // Both threads should be archived, with independent IDs.
     assert_ne!(id_a, id_b, "each archive should get its own ID");
 
-    // Verify both records exist independently.
     let rows_a = store
         .update_in(cx, |store, _window, cx| {
             store.get_archived_worktrees_for_thread(session_a.0.to_string(), cx)
@@ -5269,6 +5350,24 @@ async fn test_archive_two_threads_same_path_then_restore_first(cx: &mut TestAppC
         );
     });
 
+    // Thread A's folder_paths should point to the restored worktree.
+    cx.update(|_, cx| {
+        let store = ThreadMetadataStore::global(cx);
+        let entry = store
+            .read(cx)
+            .entry(&session_a)
+            .expect("thread A should exist in the store");
+        assert_eq!(
+            entry.folder_paths.paths(),
+            &[std::path::PathBuf::from("/wt-feature")],
+            "thread A should be associated with the restored worktree path"
+        );
+        assert_eq!(
+            entry.pending_worktree_restore, None,
+            "pending_worktree_restore should be cleared after successful restore"
+        );
+    });
+
     // Thread A's archived worktree record should be cleaned up.
     let rows_a_after = store
         .update_in(cx, |store, _window, cx| {
@@ -5602,21 +5701,18 @@ mod property_test {
                         serde_json::json!({
                             "commondir": "../../",
                             "HEAD": format!("ref: refs/heads/{}", worktree_name),
+                            "gitdir": format!("{}/.git", worktree_path),
                         }),
                     )
                     .await;
 
                 let dot_git_path = std::path::Path::new(&dot_git);
-                let worktree_pathbuf = std::path::PathBuf::from(&worktree_path);
                 state
                     .fs
                     .with_git_state(dot_git_path, false, |git_state| {
-                        git_state.worktrees.push(git::repository::Worktree {
-                            path: worktree_pathbuf,
-                            ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
-                            sha: "aaa".into(),
-                            is_main: false,
-                        });
+                        git_state
+                            .refs
+                            .insert(format!("refs/heads/{}", worktree_name), "aaa".to_string());
                     })
                     .unwrap();
 

crates/ui/src/components/ai/thread_item.rs 🔗

@@ -49,6 +49,7 @@ pub struct ThreadItem {
     project_paths: Option<Arc<[PathBuf]>>,
     project_name: Option<SharedString>,
     worktrees: Vec<ThreadItemWorktreeInfo>,
+    pending_worktree_restore: bool,
     on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
     on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
     action_slot: Option<AnyElement>,
@@ -81,6 +82,7 @@ impl ThreadItem {
             project_paths: None,
             project_name: None,
             worktrees: Vec::new(),
+            pending_worktree_restore: false,
             on_click: None,
             on_hover: Box::new(|_, _, _| {}),
             action_slot: None,
@@ -174,6 +176,11 @@ impl ThreadItem {
         self
     }
 
+    pub fn pending_worktree_restore(mut self, pending: bool) -> Self {
+        self.pending_worktree_restore = pending;
+        self
+    }
+
     pub fn hovered(mut self, hovered: bool) -> Self {
         self.hovered = hovered;
         self
@@ -362,7 +369,7 @@ impl RenderOnce for ThreadItem {
 
         let has_project_name = self.project_name.is_some();
         let has_project_paths = project_paths.is_some();
-        let has_worktree = !self.worktrees.is_empty();
+        let has_worktree = !self.worktrees.is_empty() || self.pending_worktree_restore;
         let has_timestamp = !self.timestamp.is_empty();
         let timestamp = self.timestamp;
 
@@ -441,54 +448,78 @@ impl RenderOnce for ThreadItem {
                         "Thread Running in a Local Git Worktree"
                     };
 
-                    // Deduplicate chips by name — e.g. two paths both named
-                    // "olivetti" produce a single chip. Highlight positions
-                    // come from the first occurrence.
-                    let mut seen_names: Vec<SharedString> = Vec::new();
                     let mut worktree_labels: Vec<AnyElement> = Vec::new();
 
-                    for wt in self.worktrees {
-                        if seen_names.contains(&wt.name) {
-                            continue;
-                        }
-
-                        let chip_index = seen_names.len();
-                        seen_names.push(wt.name.clone());
-
-                        let label = if wt.highlight_positions.is_empty() {
-                            Label::new(wt.name)
-                                .size(LabelSize::Small)
-                                .color(Color::Muted)
-                                .into_any_element()
-                        } else {
-                            HighlightedLabel::new(wt.name, wt.highlight_positions)
-                                .size(LabelSize::Small)
-                                .color(Color::Muted)
-                                .into_any_element()
-                        };
-                        let tooltip_title = worktree_tooltip_title;
-                        let tooltip_meta = worktree_tooltip.clone();
-
+                    if self.pending_worktree_restore {
                         worktree_labels.push(
                             h_flex()
-                                .id(format!("{}-worktree-{chip_index}", self.id.clone()))
-                                .gap_0p5()
+                                .id(format!("{}-worktree-restore", self.id.clone()))
+                                .gap_1()
                                 .child(
-                                    Icon::new(IconName::GitWorktree)
+                                    Icon::new(IconName::LoadCircle)
                                         .size(IconSize::XSmall)
+                                        .color(Color::Muted)
+                                        .with_rotate_animation(2),
+                                )
+                                .child(
+                                    Label::new("Restoring worktree\u{2026}")
+                                        .size(LabelSize::Small)
                                         .color(Color::Muted),
                                 )
-                                .child(label)
-                                .tooltip(move |_, cx| {
-                                    Tooltip::with_meta(
-                                        tooltip_title,
-                                        None,
-                                        tooltip_meta.clone(),
-                                        cx,
-                                    )
-                                })
+                                .tooltip(Tooltip::text(
+                                    "Restoring the Git worktree for this thread",
+                                ))
                                 .into_any_element(),
                         );
+                    } else {
+                        // Deduplicate chips by name — e.g. two paths both named
+                        // "olivetti" produce a single chip. Highlight positions
+                        // come from the first occurrence.
+                        let mut seen_names: Vec<SharedString> = Vec::new();
+
+                        for wt in self.worktrees {
+                            if seen_names.contains(&wt.name) {
+                                continue;
+                            }
+
+                            let chip_index = seen_names.len();
+                            seen_names.push(wt.name.clone());
+
+                            let label = if wt.highlight_positions.is_empty() {
+                                Label::new(wt.name)
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted)
+                                    .into_any_element()
+                            } else {
+                                HighlightedLabel::new(wt.name, wt.highlight_positions)
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted)
+                                    .into_any_element()
+                            };
+                            let tooltip_title = worktree_tooltip_title;
+                            let tooltip_meta = worktree_tooltip.clone();
+
+                            worktree_labels.push(
+                                h_flex()
+                                    .id(format!("{}-worktree-{chip_index}", self.id.clone()))
+                                    .gap_0p5()
+                                    .child(
+                                        Icon::new(IconName::GitWorktree)
+                                            .size(IconSize::XSmall)
+                                            .color(Color::Muted),
+                                    )
+                                    .child(label)
+                                    .tooltip(move |_, cx| {
+                                        Tooltip::with_meta(
+                                            tooltip_title,
+                                            None,
+                                            tooltip_meta.clone(),
+                                            cx,
+                                        )
+                                    })
+                                    .into_any_element(),
+                            );
+                        }
                     }
 
                     this.child(