From 5998c15c8dc2c6b49d9a71eafd292f0d3b79a878 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 2 Apr 2026 22:14:49 -0400 Subject: [PATCH] Rewrite restore flow: non-blocking with pending worktree state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- 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(-) diff --git a/crates/agent_ui/src/thread_import.rs b/crates/agent_ui/src/thread_import.rs index 5402b1c74353b73a522a068aa32dfd0a9dc85c60..a1507e1e53f0e467b5ad333f6357490a039d75fb 100644 --- a/crates/agent_ui/src/thread_import.rs +++ b/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, }); } } diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index d85858e868957e64fc803f5abfa1813a4861f1e8..c8232eefc8743d19aaa5eee00a623dc70a73f0b0 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/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::>() @@ -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, } 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, + cx: &mut Context, + ) { + 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, + ) { + 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) { if !cx.has_flag::() { 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| { diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 12ad38056ee5e9886609ad993f842061e338f158..88a1c12f0b3fc93cb7aa6d49b510cbf4d57fd31c 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/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(()) })??; diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index a26abb81255003e4059f9bcc8a68aa3c6212a73a..76d52cf23f5ac8f6261a098d2f979cd46d2d9ebc 100644 --- a/crates/fs/src/fs.rs +++ b/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::{ diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 69c23652c28971f1cde8a2d97f12335560d143db..b458e0ac629641a1d13571893fcd7a7b60331718 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/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 = ::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, - metadata: ThreadMetadata, - window: &mut Window, - cx: &mut Context, - ) { - // 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 = 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| ::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], - fs: Arc, + fs: &Arc, cx: &mut AsyncWindowContext, ) -> anyhow::Result { + 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> = 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, + store: &Entity, cx: &mut AsyncWindowContext, - ) -> anyhow::Result { - 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::(); - 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, - workspaces: &[Entity], - 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) diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 1f0408acfd1a434d484fcce5305d077ce716ef95..a3a80da648b653a6bf6e86b32a8d90a1f43e26da 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/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//` 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| ::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| ::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| ::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| ::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| ::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(); diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index d6b5f56e0abb33521ae69acc0b61b36b015cf987..ba796d71fc4c12e7196077d91a72d21e0af421d3 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -49,6 +49,7 @@ pub struct ThreadItem { project_paths: Option>, project_name: Option, worktrees: Vec, + pending_worktree_restore: bool, on_click: Option>, on_hover: Box, action_slot: Option, @@ -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 = Vec::new(); let mut worktree_labels: Vec = 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 = 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(