@@ -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)
@@ -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();