Use GitStore Repository entities for restore instead of raw fs.open_repo

Richard Feldman created

The restore flow was using fs.open_repo() to get raw GitRepository
trait objects, bypassing the normal git binary resolution that GitStore
handles. This caused 'no git binary available' errors in production.

Now the restore flow:
1. Opens a workspace for the main repo via open_project
2. Gets the Project from that workspace
3. Finds the main repo's Repository entity
4. Uses entity methods (create_worktree_detached, reset, change_branch)
   which go through GitStore's job queue with proper binary resolution

This matches how the archive side already works and removes the which
crate dependency from sidebar.

Change summary

crates/sidebar/src/sidebar.rs | 233 ++++++++++++++++++++++++++----------
1 file changed, 165 insertions(+), 68 deletions(-)

Detailed changes

crates/sidebar/src/sidebar.rs 🔗

@@ -36,7 +36,6 @@ use std::collections::{HashMap, HashSet};
 use std::mem;
 use std::path::PathBuf;
 use std::rc::Rc;
-use std::sync::Arc;
 use theme::ActiveTheme;
 use ui::{
     AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, HighlightedLabel, KeyBinding,
@@ -2215,8 +2214,6 @@ impl Sidebar {
             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();
 
@@ -2251,15 +2248,41 @@ impl Sidebar {
                 .update(cx, |store, _cx| store.entry(&metadata.session_id).cloned())
                 .unwrap_or(metadata.clone());
 
+            // Open the main repo workspace explicitly so we can capture the
+            // project entity for git operations.
+            let main_workspace = this.update_in(cx, |this, window, cx| {
+                let multi_workspace = this.multi_workspace.upgrade()?;
+                Some(multi_workspace.update(cx, |mw, cx| {
+                    mw.open_project(
+                        vec![main_repo_path.to_path_buf()],
+                        workspace::OpenMode::Activate,
+                        window,
+                        cx,
+                    )
+                }))
+            })?;
+
+            let main_workspace = match main_workspace {
+                Some(task) => task.await?,
+                None => {
+                    this.update_in(cx, |this, window, cx| {
+                        this.activate_unarchived_thread_in_workspace(&temp_metadata, window, cx);
+                    })?;
+                    return anyhow::Ok(());
+                }
+            };
+
             this.update_in(cx, |this, window, cx| {
-                this.activate_unarchived_thread_in_workspace(&temp_metadata, window, cx);
+                this.activate_thread_locally(&temp_metadata, &main_workspace, window, cx);
             })?;
 
+            let project = cx.update(|_window, cx| main_workspace.read(cx).project().clone())?;
+
             // Step 2: Background git restoration.
             for row in &archived_worktrees {
-                match Self::restore_worktree_via_git(row, &fs, cx).await {
+                match Self::restore_worktree_via_git(row, &project, cx).await {
                     Ok(restored_path) => {
-                        Self::cleanup_archived_worktree_record(row, &fs, &store, cx).await;
+                        Self::cleanup_archived_worktree_record(row, &project, &store, cx).await;
 
                         // Step 3: Reassociate thread with the restored worktree path.
                         let new_paths = PathList::new(std::slice::from_ref(&restored_path));
@@ -2375,29 +2398,32 @@ impl Sidebar {
 
     async fn restore_worktree_via_git(
         row: &ArchivedGitWorktree,
-        fs: &Arc<dyn fs::Fs>,
+        project: &Entity<project::Project>,
         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();
 
-        if fs.metadata(&dot_git_path).await?.is_none() {
-            anyhow::bail!(
-                "Git repository at {} no longer exists",
-                main_repo_path.display()
-            );
-        }
-
-        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?;
+        // Find the main repository entity from the project.
+        let main_repo = cx.update(|_window, cx| {
+            project
+                .read(cx)
+                .repositories(cx)
+                .values()
+                .find_map(|repo_entity| {
+                    let repo = repo_entity.read(cx);
+                    (repo.is_main_worktree() && *repo.work_directory_abs_path == *main_repo_path)
+                        .then(|| repo_entity.clone())
+                })
+        })?;
+        let Some(main_repo) = main_repo else {
+            anyhow::bail!("Main repository not found in project");
+        };
 
+        // Check whether the target path already exists and pick a
+        // unique alternative if so.
+        let fs = cx.update(|_window, cx| <dyn fs::Fs>::global(cx))?;
         let already_exists = fs.metadata(&worktree_path).await?.is_some();
 
         let final_path = if already_exists {
@@ -2418,51 +2444,112 @@ impl Sidebar {
             worktree_path.clone()
         };
 
-        main_repo
-            .create_worktree(None, final_path.clone(), Some(commit_hash.clone()))
-            .await?;
+        // Create the worktree via the Repository entity (detached, since
+        // the commit is a WIP snapshot, not a real branch tip).
+        let create_result = main_repo.update(cx, |repo, _cx| {
+            repo.create_worktree_detached(final_path.clone(), commit_hash.clone())
+        });
+        match create_result.await {
+            Ok(Ok(())) => {}
+            Ok(Err(err)) => anyhow::bail!("Failed to create worktree: {err}"),
+            Err(_) => anyhow::bail!("Worktree creation was canceled"),
+        }
 
-        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) }
+        // Register the new worktree directory with the project so it
+        // scans the directory and discovers the git repository.
+        let (worktree, _) = project
+            .update(cx, |project, cx| {
+                project.find_or_create_worktree(final_path.clone(), true, cx)
             })
             .await?;
 
-        let empty_env: Arc<collections::HashMap<String, String>> = Arc::default();
-
-        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}");
+        let scan_complete = cx.update(|_window, cx| {
+            worktree
+                .read(cx)
+                .as_local()
+                .map(|local| local.scan_complete())
+        })?;
+        if let Some(scan) = scan_complete {
+            scan.await;
         }
 
-        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}");
-        }
+        // Find the worktree's repository entity. The git scan runs
+        // asynchronously after the file scan, so the entity may not
+        // exist yet. If it's missing we skip the reset/branch steps
+        // — the worktree still exists on disk with the WIP commit.
+        let wt_repo = cx.update(|_window, cx| {
+            project
+                .read(cx)
+                .repositories(cx)
+                .values()
+                .find_map(|repo_entity| {
+                    let snap = repo_entity.read(cx).snapshot();
+                    (*snap.work_directory_abs_path == *final_path).then(|| repo_entity.clone())
+                })
+        })?;
 
-        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!(
-                        "Could not switch to branch '{branch_name}' — \
-                         the restored worktree is in detached HEAD state."
-                    );
+        if let Some(wt_repo) = wt_repo {
+            // Mixed reset HEAD~ — undoes the second WIP commit, putting
+            // previously-unstaged/untracked files back as unstaged.
+            let mixed_reset = wt_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: {err}");
+                    let undo = wt_repo.update(cx, |repo, cx| {
+                        repo.reset(commit_hash.clone(), ResetMode::Mixed, cx)
+                    });
+                    let _ = undo.await;
+                    anyhow::bail!("Failed to restore staging state: {err}");
                 }
+                Err(_) => anyhow::bail!("Mixed reset was canceled"),
             }
+
+            // Soft reset HEAD~ — undoes the first WIP commit, leaving
+            // originally-staged files in the index.
+            let soft_reset = wt_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: {err}");
+                    let undo = wt_repo.update(cx, |repo, cx| {
+                        repo.reset(commit_hash.clone(), ResetMode::Mixed, cx)
+                    });
+                    let _ = undo.await;
+                    anyhow::bail!("Failed to restore staging state: {err}");
+                }
+                Err(_) => anyhow::bail!("Soft reset was canceled"),
+            }
+
+            // Switch to the original branch if one was recorded.
+            if let Some(branch_name) = &row.branch_name {
+                let switch =
+                    wt_repo.update(cx, |repo, _cx| repo.change_branch(branch_name.clone()));
+                match switch.await {
+                    Ok(Ok(())) => {}
+                    _ => {
+                        let create = wt_repo.update(cx, |repo, _cx| {
+                            repo.create_branch(branch_name.clone(), None)
+                        });
+                        if let Ok(Err(_)) | Err(_) = create.await {
+                            log::warn!(
+                                "Could not switch to branch '{branch_name}' — \
+                                 the restored worktree is in detached HEAD state."
+                            );
+                        }
+                    }
+                }
+            }
+        } else {
+            log::warn!(
+                "Worktree repository entity not found after scan — \
+                 skipping staging restoration for {}",
+                final_path.display()
+            );
         }
 
         Ok(final_path)
@@ -2470,20 +2557,30 @@ impl Sidebar {
 
     async fn cleanup_archived_worktree_record(
         row: &ArchivedGitWorktree,
-        fs: &Arc<dyn fs::Fs>,
+        project: &Entity<project::Project>,
         store: &Entity<ThreadMetadataStore>,
         cx: &mut AsyncWindowContext,
     ) {
-        let dot_git_path = row.main_repo_path.join(git::DOT_GIT);
-        if let Ok(main_repo) = cx
-            .background_spawn({
-                let fs = fs.clone();
-                async move { fs.open_repo(&dot_git_path, None) }
-            })
-            .await
-        {
+        let main_repo_path = &row.main_repo_path;
+        let main_repo = cx.update(|_window, cx| {
+            project
+                .read(cx)
+                .repositories(cx)
+                .values()
+                .find_map(|repo_entity| {
+                    let repo = repo_entity.read(cx);
+                    (repo.is_main_worktree() && *repo.work_directory_abs_path == *main_repo_path)
+                        .then(|| repo_entity.clone())
+                })
+        });
+        if let Ok(Some(main_repo)) = main_repo {
             let ref_name = archived_worktree_ref_name(row.id);
-            main_repo.delete_ref(ref_name).await.log_err();
+            let receiver = main_repo.update(cx, |repo, _cx| repo.delete_ref(ref_name));
+            match receiver.await {
+                Ok(Ok(())) => {}
+                Ok(Err(err)) => log::warn!("Failed to delete archive ref: {err}"),
+                Err(_) => log::warn!("Archive ref deletion was canceled"),
+            }
         }
 
         store