Recreate archived worktrees from scratch on restore

Restoring an archived agent thread always runs git read-tree --reset -u,
which clobbers anything currently in the worktree directory. Previously,
when the worktree directory existed but wasn't a valid linked worktree
(which can happen on Windows if archival's git worktree remove --force
succeeded in unregistering but failed to fully delete files because they
were locked by another process, like node-pty/conpty), the restore code
only ran git worktree repair, which is a no-op when the main repo's
registration is also gone. The user ended up with a disconnected
directory and no clear way to recover.

This PR replaces the restore flow with a recreate-from-scratch approach
that collapses every messy intermediate state (missing dir, stale
registration, leftover content from a partial Windows archive, fully
valid worktree) into one path: rename any pre-existing content into a
sibling zed-restore-backup-<uuid> directory, drop any stale
registration, then git worktree add --detach.

Any pre-existing content at the worktree path is moved aside rather than
deleted up-front. If any destructive step fails, rollback_backup
restores the backup so the user's content is preserved. On success, the
backup is deleted asynchronously. The backup directory is always
created as a sibling so the rename stays same-volume and can't fail
with EXDEV.

Adds NotAWorktreeError to GitRepository::remove_worktree so the restore
code can treat 'nothing to remove' as success without substring-matching
git's localized stderr. The path comparison uses a new
util::paths::paths_resolve_to_same_location that walks up to the
nearest existing ancestor before canonicalizing, so it handles
missing leaves (e.g. macOS /var/... vs the registered /private/var/...
when the directory is already gone).

The DB commit (path replacements + unarchive) runs via AsyncApp so it
survives the user closing the window between the destructive restore
pass and the commit; only the workspace-activation UI is window-bound
and best-effort. The per-window re-entry guard prevents double-clicks
from spawning racing tasks.

Release Notes:

- Fixed a bug where restoring an archived agent thread on Windows could
  leave the worktree disconnected from git.
This commit is contained in:
Richard Feldman 2026-05-21 16:44:56 -04:00
parent 5e36ec8256
commit c83b6f68f3
No known key found for this signature in database
7 changed files with 1981 additions and 189 deletions

File diff suppressed because it is too large Load diff

View file

@ -13,8 +13,8 @@ use git::{
repository::{
AskPassDelegate, Branch, CommitData, CommitDataReader, CommitDetails, CommitOptions,
CreateWorktreeTarget, FetchOptions, GRAPH_CHUNK_SIZE, GitRepository,
GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, LogSource, PushOptions, RefEdit,
Remote, RepoPath, ResetMode, SearchCommitArgs, Worktree,
GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, LogSource, NotAWorktreeError,
PushOptions, RefEdit, Remote, RepoPath, ResetMode, SearchCommitArgs, Worktree,
},
stash::GitStash,
status::{
@ -769,9 +769,17 @@ impl GitRepository for FakeGitRepository {
.trim();
PathBuf::from(gitdir)
} else {
self.find_worktree_entry_dir_by_path(&path)
.await
.with_context(|| format!("no worktree found at path: {}", path.display()))?
match self.find_worktree_entry_dir_by_path(&path).await {
Some(dir) => dir,
None => {
// Match the real backend's behavior: surface a
// structured `NotAWorktreeError` so callers can
// treat "nothing to remove" as success.
return Err(anyhow::Error::new(NotAWorktreeError {
path: path.clone(),
}));
}
}
};
// Remove the worktree checkout directory if it still exists.

View file

@ -823,6 +823,13 @@ pub trait GitRepository: Send + Sync {
create: bool,
) -> BoxFuture<'_, Result<()>>;
/// Removes a git worktree.
///
/// When the path has no entry in the main repo's worktree registry
/// (because it was never registered, or its registration was already
/// pruned), implementations return [`NotAWorktreeError`] as the error
/// payload so callers can downcast and treat the "nothing to remove"
/// case as a no-op.
fn remove_worktree(&self, path: PathBuf, force: bool) -> BoxFuture<'_, Result<()>>;
fn rename_worktree(&self, old_path: PathBuf, new_path: PathBuf) -> BoxFuture<'_, Result<()>>;
@ -1930,6 +1937,35 @@ impl GitRepository for RealGitRepository {
self.executor
.spawn(async move {
// Pre-check the worktree registry directly rather than
// matching `git worktree remove`'s stderr. Git localizes its
// error messages (`LC_MESSAGES`), and Zed makes no attempt to
// pin the locale on its git invocations, so substring-matching
// an English phrase would silently miss the "not registered"
// case on non-English machines.
let list_output = git
.build_command(&["worktree", "list", "--porcelain"])
.output()
.await?;
if !list_output.status.success() {
let stderr = String::from_utf8_lossy(&list_output.stderr);
anyhow::bail!("git worktree list failed: {stderr}");
}
let stdout = String::from_utf8_lossy(&list_output.stdout);
let worktrees = parse_worktrees_from_str(&stdout, None);
// Cheap literal-equality pass first so the common case
// (caller path matches the registered form byte-for-byte)
// doesn't pay for an `fs::canonicalize` per registered
// worktree. Only fall back to the symlink-resolving
// comparison when literal equality misses.
let matches_registration = worktrees.iter().any(|wt| wt.path == path)
|| worktrees
.iter()
.any(|wt| util::paths::paths_resolve_to_same_location(&wt.path, &path));
if !matches_registration {
return Err(anyhow::Error::new(NotAWorktreeError { path }));
}
let mut args: Vec<OsString> = vec!["worktree".into(), "remove".into()];
if force {
args.push("--force".into());
@ -3451,6 +3487,17 @@ struct GitBinaryCommandError {
status: ExitStatus,
}
/// Returned by [`GitRepository::remove_worktree`] implementations when the
/// target path is not a registered worktree (e.g. its registration was
/// already pruned, or it was never `git worktree add`-ed). Callers that
/// want to treat "nothing to remove" as success can match on this with
/// `error.downcast_ref::<NotAWorktreeError>()`.
#[derive(Error, Debug)]
#[error("'{}' is not a registered git worktree", path.display())]
pub struct NotAWorktreeError {
pub path: PathBuf,
}
async fn run_git_command(
env: Arc<HashMap<String, String>>,
ask_pass: AskPassDelegate,
@ -4450,6 +4497,133 @@ mod tests {
assert!(!worktree_path.exists());
}
#[gpui::test]
async fn test_remove_worktree_returns_not_a_worktree_error_for_unregistered_path(
cx: &mut TestAppContext,
) {
disable_git_global_config();
cx.executor().allow_parking();
let temp_dir = tempfile::tempdir().unwrap();
let repo_dir = temp_dir.path().join("repo");
git2::Repository::init(&repo_dir).unwrap();
let repo = RealGitRepository::new(
&repo_dir.join(".git"),
None,
Some("git".into()),
cx.executor(),
)
.unwrap();
smol::fs::write(repo_dir.join("file.txt"), "content")
.await
.unwrap();
repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
.await
.unwrap();
repo.commit(
"Initial commit".into(),
None,
CommitOptions::default(),
AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
Arc::new(checkpoint_author_envs()),
)
.await
.unwrap();
let bogus_path = temp_dir.path().join("never-registered");
let error = repo
.remove_worktree(bogus_path.clone(), true)
.await
.expect_err("removing a path with no worktree registration must fail");
assert!(
error
.downcast_ref::<NotAWorktreeError>()
.is_some_and(|e| e.path == bogus_path),
"error must downcast to NotAWorktreeError with the requested path, got: {error:#}"
);
}
/// Regression: a worktree whose directory has been deleted from disk
/// must still be removable when the caller passes a path that doesn't
/// canonicalize the same way git's registry does. Naive
/// `std::fs::canonicalize` on the caller-passed path returns `Err`
/// (path missing), so the literal/path-equality check would miss the
/// match on systems where `temp_dir()` and its canonical form differ
/// (e.g. macOS, where `/var` is a symlink to `/private/var`). We
/// instead canonicalize the longest existing ancestor and compare
/// that, which must succeed here.
#[gpui::test]
async fn test_remove_worktree_succeeds_when_directory_was_deleted(cx: &mut TestAppContext) {
disable_git_global_config();
cx.executor().allow_parking();
let temp_dir = tempfile::tempdir().unwrap();
let repo_dir = temp_dir.path().join("repo");
let worktrees_dir = temp_dir.path().join("worktrees");
git2::Repository::init(&repo_dir).unwrap();
let repo = RealGitRepository::new(
&repo_dir.join(".git"),
None,
Some("git".into()),
cx.executor(),
)
.unwrap();
smol::fs::write(repo_dir.join("file.txt"), "content")
.await
.unwrap();
repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
.await
.unwrap();
repo.commit(
"Initial commit".into(),
None,
CommitOptions::default(),
AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
Arc::new(checkpoint_author_envs()),
)
.await
.unwrap();
let worktree_path = worktrees_dir.join("to-be-deleted");
repo.create_worktree(
CreateWorktreeTarget::NewBranch {
branch_name: "to-be-deleted".to_string(),
base_sha: Some("HEAD".to_string()),
},
worktree_path.clone(),
)
.await
.unwrap();
assert!(worktree_path.exists(), "precondition: worktree dir exists");
// Simulate the original Windows file-locks scenario where the
// working directory is gone but the main-repo registration still
// points at it.
std::fs::remove_dir_all(&worktree_path).unwrap();
assert!(
!worktree_path.exists(),
"precondition: worktree dir is gone"
);
// Must NOT return `NotAWorktreeError` — the registration is still
// present, and git can still clean it up via
// `git worktree remove --force`.
repo.remove_worktree(worktree_path.clone(), true)
.await
.expect("remove_worktree on a missing-but-registered path must succeed");
let worktrees = repo.worktrees().await.unwrap();
assert!(
worktrees.iter().all(|w| w.path != worktree_path),
"registration must be gone after remove_worktree"
);
}
#[gpui::test]
async fn test_rename_worktree(cx: &mut TestAppContext) {
disable_git_global_config();

View file

@ -7140,6 +7140,21 @@ impl Repository {
)
}
/// Removes a worktree by path.
///
/// Note on error shape: the local backend translates git's "path is
/// not a working tree" failure into a structured
/// [`git::repository::NotAWorktreeError`] (see
/// [`git::repository::GitRepository::remove_worktree`]). The remote arm
/// here does **not** preserve that type — the RPC boundary collapses
/// the error into a generic `anyhow::Error` whose `Display` carries
/// the original message. Callers that need to react to the "nothing to
/// remove" case structurally must either route through the local
/// backend or pre-check with `Self::worktrees()`. As of writing the
/// only such caller (archived-thread restore in `agent_ui`) takes the
/// pre-check path and rejects remote restores at its entry point, so
/// this asymmetry has no live consumer; document it here so the next
/// caller doesn't get surprised.
pub fn remove_worktree(&mut self, path: PathBuf, force: bool) -> oneshot::Receiver<Result<()>> {
let id = self.id;
let repository_anchor_path: Arc<Path> = self

View file

@ -24,9 +24,10 @@ use feature_flags::{
AgentThreadWorktreeLabel, AgentThreadWorktreeLabelFlag, FeatureFlag, FeatureFlagAppExt as _,
};
use gpui::{
Action as _, AnyElement, App, ClickEvent, Context, DismissEvent, Entity, EntityId, FocusHandle,
Focusable, KeyContext, ListState, Modifiers, Pixels, Render, SharedString, Task, TaskExt,
WeakEntity, Window, WindowHandle, linear_color_stop, linear_gradient, list, prelude::*, px,
Action as _, AnyElement, App, AsyncApp, ClickEvent, Context, DismissEvent, Entity, EntityId,
FocusHandle, Focusable, KeyContext, ListState, Modifiers, Pixels, Render, SharedString, Task,
TaskExt, WeakEntity, Window, WindowHandle, linear_color_stop, linear_gradient, list,
prelude::*, px,
};
use itertools::Itertools;
use menu::{
@ -432,6 +433,50 @@ fn workspace_contains_worktree_path(
.any(|worktree| worktree.read(cx).abs_path().as_ref() == worktree_path)
}
/// Commits the DB-visible state changes for a successful restore:
/// rewrites worktree paths on the thread metadata, unarchives the
/// thread, and returns the updated metadata.
///
/// Uses [`AsyncApp`] (not the window-bound context) so this commit
/// succeeds even if the user closed the window between the destructive
/// restore pass and now. That ordering matters for correctness: the
/// on-disk worktrees are already restored at this point, and if the DB
/// is left out-of-sync the user would re-open Zed to a thread marked
/// archived whose worktrees are physically present, and clicking
/// Restore would prompt them to overwrite their own freshly-restored
/// files.
///
/// Returns `None` when the thread has been removed from the store mid-
/// restore (e.g. another window deleted it); the caller distinguishes
/// this case so the spinner gets cleared and the user gets a log line.
async fn commit_db_state_after_restore(
store: &Entity<ThreadMetadataStore>,
thread_id: ThreadId,
path_replacements: &[(PathBuf, PathBuf)],
cx: &mut AsyncApp,
) -> Option<ThreadMetadata> {
if path_replacements.is_empty() {
return None;
}
// `AsyncApp::update` panics if the App is fully dropped (process
// shutdown); for our purposes that's fine, since at that point the
// task is being torn down with the rest of the process.
cx.update(|cx| {
store.update(cx, |store, cx| {
store.update_restored_worktree_paths(thread_id, path_replacements, cx);
});
});
let updated_metadata = cx.update(|cx| store.read(cx).entry(thread_id).cloned());
if updated_metadata.is_some() {
cx.update(|cx| {
store.update(cx, |store, cx| {
store.unarchive(thread_id, cx);
});
});
}
updated_metadata
}
#[derive(Clone)]
struct WorkspaceMenuWorktreeLabel {
icon: Option<IconName>,
@ -2938,6 +2983,62 @@ impl Sidebar {
})
}
fn finish_restore_ui(
&mut self,
thread_id: agent_ui::ThreadId,
weak_archive_view: &Option<WeakEntity<ThreadsArchiveView>>,
cx: &mut Context<Self>,
) {
self.restoring_tasks.remove(&thread_id);
if let Some(weak_archive_view) = weak_archive_view {
weak_archive_view
.update(cx, |view, cx| {
view.clear_restoring(&thread_id, cx);
})
.ok();
}
}
/// Shows a toast in the current window's active workspace. Used for the
/// transient "restore" status messages so each callsite doesn't repeat
/// the `multi_workspace.upgrade()` ladder. Silently no-ops if the
/// multi-workspace has been torn down.
///
/// Takes `&mut self` for consistency with [`Self::finish_restore_ui`]
/// and [`Self::fail_restore_with_toast`], even though the body only
/// reads `self`; sibling helpers are commonly called as a pair and
/// matching receivers keeps borrow patterns at the call sites uniform.
fn show_restore_toast(
&mut self,
notification_id: NotificationId,
message: impl Into<String>,
cx: &mut Context<Self>,
) {
let Some(multi_workspace) = self.multi_workspace.upgrade() else {
return;
};
let workspace = multi_workspace.read(cx).workspace().clone();
let message = message.into();
workspace.update(cx, |workspace, cx| {
workspace.show_toast(Toast::new(notification_id, message).autohide(), cx);
});
}
/// Combined UI cleanup helper for the failure paths of
/// [`Self::open_thread_from_archive`]: clears the spinner / restoring
/// state and shows a toast with the given message.
fn fail_restore_with_toast(
&mut self,
thread_id: agent_ui::ThreadId,
weak_archive_view: &Option<WeakEntity<ThreadsArchiveView>>,
notification_id: NotificationId,
message: impl Into<String>,
cx: &mut Context<Self>,
) {
self.finish_restore_ui(thread_id, weak_archive_view, cx);
self.show_restore_toast(notification_id, message, cx);
}
fn open_thread_from_archive(
&mut self,
metadata: ThreadMetadata,
@ -2945,6 +3046,15 @@ impl Sidebar {
cx: &mut Context<Self>,
) {
let thread_id = metadata.thread_id;
// Re-entry guard: if a restore is already in flight for this
// thread (e.g. the user double-clicked Restore while the
// previous task is still in flight), silently no-op so we don't
// spawn a second task that would race the first and leave the
// worktree in a half-restored state.
if self.restoring_tasks.contains_key(&thread_id) {
log::debug!("restore already in flight for {thread_id:?}; ignoring duplicate request");
return;
}
let weak_archive_view = match &self.view {
SidebarView::Archive(view) => Some(view.downgrade()),
_ => None,
@ -2990,10 +3100,29 @@ impl Sidebar {
};
let path_list = metadata.folder_paths().clone();
let restore_task = cx.spawn_in(window, async move |this, cx| {
// Mark this thread as restoring synchronously, BEFORE spawning, so the
// per-window re-entry guard at the top of this function rejects a
// double-click before the foreground executor has a chance to run the
// spawned body. The map's value type is `Task<()>` for historical
// reasons, but it's only ever read for presence — we insert a
// `Task::ready(())` placeholder here and detach the real task.
//
// The reason not to store the real task is that the spawned body's
// own error handlers call `finish_restore_ui` -> `restoring_tasks
// .remove(...)`. If the map held the real task, that remove would
// drop the task we're currently inside — which GPUI tolerates but
// is a fragile pattern. Detaching the real task makes the remove a
// no-op on a `Task::ready(())`, with no "drop the task we're inside"
// hazard.
self.restoring_tasks.insert(thread_id, Task::ready(()));
cx.spawn_in(window, async move |this, cx| {
let result: anyhow::Result<()> = async {
let archived_worktrees = task.await?;
// Empty-archive activation has no destructive on-disk
// work to serialize against — skip the cross-window claim
// entirely and run the simple activation path.
if archived_worktrees.is_empty() {
this.update_in(cx, |this, window, cx| {
this.restoring_tasks.remove(&thread_id);
@ -3035,51 +3164,37 @@ impl Sidebar {
return anyhow::Ok(());
}
// Destructive pass: recreate each archived worktree.
// We do NOT delete each archived worktree's DB record in
// this loop. If we did, a later worktree's failure would
// strand the thread: the successful rows would have no
// archived metadata left to retry from, leaving on-disk
// worktrees orphaned and the thread permanently broken.
// Cleanup happens only after the DB-visible state is
// committed below.
let mut path_replacements: Vec<(PathBuf, PathBuf)> = Vec::new();
for row in &archived_worktrees {
match thread_worktree_archive::restore_worktree_via_git(
row,
metadata.remote_connection.as_ref(),
&mut *cx,
cx,
)
.await
{
Ok(restored_path) => {
thread_worktree_archive::cleanup_archived_worktree_record(
row,
metadata.remote_connection.as_ref(),
&mut *cx,
)
.await;
path_replacements.push((row.worktree_path.clone(), restored_path));
}
Err(error) => {
log::error!("Failed to restore worktree: {error:#}");
this.update_in(cx, |this, _window, cx| {
this.restoring_tasks.remove(&thread_id);
if let Some(weak_archive_view) = &weak_archive_view {
weak_archive_view
.update(cx, |view, cx| {
view.clear_restoring(&thread_id, cx);
})
.ok();
}
if let Some(multi_workspace) = this.multi_workspace.upgrade() {
let workspace = multi_workspace.read(cx).workspace().clone();
workspace.update(cx, |workspace, cx| {
struct RestoreWorktreeErrorToast;
workspace.show_toast(
Toast::new(
NotificationId::unique::<RestoreWorktreeErrorToast>(
),
format!("Failed to restore worktree: {error:#}"),
)
.autohide(),
cx,
);
});
}
struct RestoreWorktreeErrorToast;
this.fail_restore_with_toast(
thread_id,
&weak_archive_view,
NotificationId::unique::<RestoreWorktreeErrorToast>(),
format!("Failed to restore worktree: {error:#}"),
cx,
);
})
.ok();
return anyhow::Ok(());
@ -3087,29 +3202,22 @@ impl Sidebar {
}
}
if !path_replacements.is_empty() {
cx.update(|_window, cx| {
store.update(cx, |store, cx| {
store.update_restored_worktree_paths(thread_id, &path_replacements, cx);
});
})?;
// Commit the DB-visible state through `AsyncApp` so it
// survives the user closing the window between the
// destructive pass and now (see `commit_db_state_after_
// restore`'s docs). The activation `update_in` below is
// window-bound and best-effort; correctness lives in the
// commit above.
let updated_metadata =
commit_db_state_after_restore(&store, thread_id, &path_replacements, cx).await;
let updated_metadata =
cx.update(|_window, cx| store.read(cx).entry(thread_id).cloned())?;
if let Some(updated_metadata) = updated_metadata {
match updated_metadata {
Some(updated_metadata) => {
let new_paths = updated_metadata.folder_paths().clone();
let key = ProjectGroupKey::from_worktree_paths(
&updated_metadata.worktree_paths,
updated_metadata.remote_connection.clone(),
);
cx.update(|_window, cx| {
store.update(cx, |store, cx| {
store.unarchive(updated_metadata.thread_id, cx);
});
})?;
this.update_in(cx, |this, window, cx| {
this.restoring_tasks.remove(&thread_id);
this.open_workspace_and_activate_thread(
@ -3120,8 +3228,45 @@ impl Sidebar {
cx,
);
this.show_thread_list(window, cx);
})?;
})
.ok();
}
None if !path_replacements.is_empty() => {
// The thread was removed from the store between
// the restore loop and this lookup (e.g. another
// window deleted it). The on-disk restore has
// already succeeded, but there's nothing left
// here to activate. Make sure the per-window
// "restoring..." state is cleared so the
// spinner doesn't get stuck.
log::warn!(
"Thread {thread_id:?} disappeared from the store mid-restore; \
on-disk worktrees were restored but the thread can't be activated."
);
this.update_in(cx, |this, _window, cx| {
this.finish_restore_ui(thread_id, &weak_archive_view, cx);
})
.ok();
}
None => {}
}
// Finally, drop the archive records. This happens last so
// a failure or cancellation between the visible state
// changes above and this loop only leaves the user with
// some stale DB rows (recoverable by a later cleanup
// pass), not a thread stuck in an inconsistent
// "unarchived but archive rows missing" state.
// `cleanup_archived_worktree_record` swallows its own
// errors, so a cleanup failure leaks a DB row but doesn't
// break the thread.
for row in &archived_worktrees {
thread_worktree_archive::cleanup_archived_worktree_record(
row,
metadata.remote_connection.as_ref(),
cx,
)
.await;
}
anyhow::Ok(())
@ -3129,9 +3274,20 @@ impl Sidebar {
.await;
if let Err(error) = result {
log::error!("{error:#}");
this.update_in(cx, |this, _window, cx| {
struct RestoreFailedToast;
this.fail_restore_with_toast(
thread_id,
&weak_archive_view,
NotificationId::unique::<RestoreFailedToast>(),
format!("Failed to restore thread: {error:#}"),
cx,
);
})
.ok();
}
});
self.restoring_tasks.insert(thread_id, restore_task);
})
.detach();
}
fn expand_selected_entry(

View file

@ -3921,6 +3921,131 @@ async fn init_test_project_with_git(
(project, fs)
}
/// Output of [`setup_archived_worktree_fixture`].
///
/// Holds the FakeFs and entity handles needed by tests that exercise the
/// archived-worktree restore flow: a `/project` main repo, a
/// `/wt-<branch>` linked worktree pointing back at it, a multi-workspace
/// containing both projects, and the staged/unstaged checkpoint hashes
/// captured from the worktree.
struct ArchivedWorktreeFixture {
fs: Arc<FakeFs>,
worktree_path: PathBuf,
branch_name: String,
staged_hash: String,
unstaged_hash: String,
}
impl ArchivedWorktreeFixture {
/// Builds an `ArchivedGitWorktree` row pointing at this fixture's
/// captured checkpoint.
fn archived_row(&self) -> agent_ui::thread_metadata_store::ArchivedGitWorktree {
agent_ui::thread_metadata_store::ArchivedGitWorktree {
id: 1,
worktree_path: self.worktree_path.clone(),
main_repo_path: PathBuf::from("/project"),
branch_name: Some(self.branch_name.clone()),
staged_commit_hash: self.staged_hash.clone(),
unstaged_commit_hash: self.unstaged_hash.clone(),
original_commit_hash: "original-sha".to_string(),
}
}
}
/// Sets up the common FakeFs + multi-workspace + checkpoint state every
/// `restore_worktree_via_git` test needs.
///
/// `extra_worktree_files` is merged into the worktree's `insert_tree`
/// payload alongside its `.git` gitfile, so tests can plant fixtures
/// without having to repeat the worktree-registration boilerplate.
/// Pass `serde_json::json!({})` if nothing extra is needed.
async fn setup_archived_worktree_fixture(
branch_name: &str,
extra_worktree_files: serde_json::Value,
cx: &mut TestAppContext,
) -> ArchivedWorktreeFixture {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let worktree_path = PathBuf::from(format!("/wt-{branch_name}"));
fs.insert_tree(
"/project",
serde_json::json!({
".git": {
"worktrees": {
branch_name: {
"commondir": "../../",
"HEAD": format!("ref: refs/heads/{branch_name}"),
},
},
},
"src": {},
}),
)
.await;
// Build the worktree directory tree: always includes the `.git`
// gitfile; merge in caller-provided extras (e.g. a `src/` subtree).
let mut worktree_tree = serde_json::json!({
".git": format!("gitdir: /project/.git/worktrees/{branch_name}"),
});
if let (Some(base), Some(extras)) = (
worktree_tree.as_object_mut(),
extra_worktree_files.as_object(),
) {
for (key, value) in extras {
base.insert(key.clone(), value.clone());
}
}
fs.insert_tree(&worktree_path, worktree_tree).await;
fs.add_linked_worktree_for_repo(
Path::new("/project/.git"),
false,
git::repository::Worktree {
path: worktree_path.clone(),
ref_name: Some(format!("refs/heads/{branch_name}").into()),
sha: "original-sha".into(),
is_main: false,
is_bare: false,
},
)
.await;
cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
let worktree_project = project::Project::test(fs.clone(), [worktree_path.as_path()], cx).await;
main_project
.update(cx, |p, cx| p.git_scans_complete(cx))
.await;
worktree_project
.update(cx, |p, cx| p.git_scans_complete(cx))
.await;
let (multi_workspace, vcx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
multi_workspace.update_in(vcx, |mw, window, cx| {
mw.test_add_workspace(worktree_project.clone(), window, cx)
});
let wt_repo = worktree_project.read_with(cx, |project, cx| {
project.repositories(cx).values().next().unwrap().clone()
});
let (staged_hash, unstaged_hash) = cx
.update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
.await
.expect("create_archive_checkpoint task should not be canceled")
.expect("create_archive_checkpoint should succeed");
ArchivedWorktreeFixture {
fs,
worktree_path,
branch_name: branch_name.to_string(),
staged_hash,
unstaged_hash,
}
}
#[gpui::test]
async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
let (project, fs) = init_test_project_with_git("/project", cx).await;
@ -6254,6 +6379,409 @@ async fn test_restore_worktree_when_branch_does_not_exist(cx: &mut TestAppContex
);
}
#[gpui::test]
async fn test_restore_worktree_cleans_up_backup_on_success(cx: &mut TestAppContext) {
// restore_worktree_via_git should move pre-existing content into a
// sibling backup directory before recreating the worktree, then delete
// that backup directory once the restore has completed successfully.
let fixture =
setup_archived_worktree_fixture("feature-success", serde_json::json!({ "src": {} }), cx)
.await;
let fs = fixture.fs.clone();
// Drop a sentinel file at the worktree path that is *not* part of the
// archive checkpoint, simulating user content that the user agreed to
// overwrite when they confirmed the restore prompt.
fs.write(
Path::new("/wt-feature-success/sentinel.txt"),
b"pre-existing user content",
)
.await
.unwrap();
let row = fixture.archived_row();
let result = cx
.spawn(|mut cx| async move {
agent_ui::thread_worktree_archive::restore_worktree_via_git(&row, None, &mut cx).await
})
.await;
assert!(result.is_ok(), "restore should succeed: {:?}", result.err());
// The success-path backup cleanup is scheduled via
// `cx.background_spawn(...).detach()`, so we have to drain the
// executor before asserting that the backup directory is gone.
// Without this, the assertion races the detached cleanup task.
cx.run_until_parked();
// No backup directory should remain in the parent of the worktree path.
let leftover_backup = fs.directories(true).into_iter().find(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("zed-restore-backup-"))
});
assert!(
leftover_backup.is_none(),
"backup directory should be deleted after a successful restore, found: {leftover_backup:?}"
);
// The restored worktree directory must exist (it was renamed away to a
// backup, then recreated by `git worktree add`).
assert!(
fs.metadata(Path::new("/wt-feature-success"))
.await
.unwrap()
.is_some(),
"worktree path should exist after a successful restore"
);
assert!(
fs.metadata(Path::new("/wt-feature-success/sentinel.txt"))
.await
.unwrap()
.is_none(),
"sentinel file from pre-existing content must not survive a successful restore"
);
}
#[gpui::test]
async fn test_restore_worktree_rolls_back_backup_on_failure(cx: &mut TestAppContext) {
// When restore_worktree_via_git fails partway through (here, because
// the archive checkpoint SHAs are bogus), it must restore the user's
// pre-existing content from the backup and not leave a backup
// directory lying around.
let fixture =
setup_archived_worktree_fixture("feature-fail", serde_json::json!({ "src": {} }), cx).await;
let fs = fixture.fs.clone();
// Drop a sentinel file representing pre-existing user content the
// user expected to be overwritten by the archived state on success.
fs.write(
Path::new("/wt-feature-fail/sentinel.txt"),
b"important user data",
)
.await
.unwrap();
// Bogus checkpoint SHAs will cause `restore_archive_checkpoint` to
// fail, exercising the rollback path.
let bogus_sha = "0".repeat(40);
let row = agent_ui::thread_metadata_store::ArchivedGitWorktree {
staged_commit_hash: bogus_sha.clone(),
unstaged_commit_hash: bogus_sha,
..fixture.archived_row()
};
let result = cx
.spawn(|mut cx| async move {
agent_ui::thread_worktree_archive::restore_worktree_via_git(&row, None, &mut cx).await
})
.await;
assert!(
result.is_err(),
"restore should fail when checkpoint SHAs are bogus",
);
let error_msg = format!("{:#}", result.as_ref().unwrap_err());
assert!(
error_msg.contains("failed to restore archive checkpoint"),
"error should indicate checkpoint failure, got: {error_msg}"
);
// The pre-existing sentinel must be back at the original path.
let sentinel_contents = fs
.load(Path::new("/wt-feature-fail/sentinel.txt"))
.await
.expect("sentinel file must be restored from the backup");
assert_eq!(
sentinel_contents, "important user data",
"sentinel content must match what was on disk before the restore",
);
// No backup directory should remain in the parent of the worktree path.
let leftover_backup = fs.directories(true).into_iter().find(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("zed-restore-backup-"))
});
assert!(
leftover_backup.is_none(),
"backup directory should be cleaned up after a rollback, found: {leftover_backup:?}"
);
}
#[gpui::test]
async fn test_restore_worktree_round_trips_git_admin_state(cx: &mut TestAppContext) {
// End-to-end happy-path smoke test for `restore_worktree_via_git`:
// plant a checkpoint, tear the worktree directory down to simulate
// archival, then call the restore and confirm the captured tree is
// reinstated.
//
// FakeFs limitation: for a linked worktree opened via a `.git` gitfile,
// the fake's `create_archive_checkpoint` captures
// `repository_dir_path.parent()` (see `crates/fs/src/fake_git_repo.rs`),
// which resolves to `<main_repo>/.git/worktrees` — NOT the working
// tree at the linked worktree's path. As a result, `restore_archive_
// checkpoint` only round-trips the contents of
// `<main_repo>/.git/worktrees`, not anything written to the working
// directory. We therefore plant our marker inside the captured
// location (the linked worktree's `worktrees/<name>` registration
// directory) so we have something concrete to assert was actually
// moved through the checkpoint pipeline. We still write some user-
// facing files to the working tree before checkpointing (and assert
// the worktree path itself is recreated) to show that those don't
// crash the round-trip even though their contents aren't captured by
// the fake.
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/project",
serde_json::json!({
".git": {
"worktrees": {
"feature-rt": {
"commondir": "../../",
"HEAD": "ref: refs/heads/feature-rt",
},
},
},
"src": {},
}),
)
.await;
fs.insert_tree(
"/wt-feature-rt",
serde_json::json!({
".git": "gitdir: /project/.git/worktrees/feature-rt",
"src": {},
}),
)
.await;
fs.add_linked_worktree_for_repo(
Path::new("/project/.git"),
false,
git::repository::Worktree {
path: PathBuf::from("/wt-feature-rt"),
ref_name: Some("refs/heads/feature-rt".into()),
sha: "original-sha".into(),
is_main: false,
is_bare: false,
},
)
.await;
cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
let worktree_project =
project::Project::test(fs.clone(), ["/wt-feature-rt".as_ref()], cx).await;
main_project
.update(cx, |p, cx| p.git_scans_complete(cx))
.await;
worktree_project
.update(cx, |p, cx| p.git_scans_complete(cx))
.await;
let (multi_workspace, _cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
multi_workspace.update_in(_cx, |mw, window, cx| {
mw.test_add_workspace(worktree_project.clone(), window, cx)
});
// Working-tree files. The fake doesn't round-trip these, but writing
// them and then asserting the restore still completes proves the
// function tolerates pre-checkpoint content in the working tree.
fs.write(Path::new("/wt-feature-rt/staged.txt"), b"staged contents")
.await
.expect("writing staged.txt should succeed");
fs.write(
Path::new("/wt-feature-rt/src/nested.txt"),
b"nested contents",
)
.await
.expect("writing src/nested.txt should succeed");
// Marker inside the captured location. This is the file we'll
// actually assert round-trips, since it sits inside what the fake's
// `create_archive_checkpoint` snapshots.
fs.write(
Path::new("/project/.git/worktrees/feature-rt/marker.txt"),
b"checkpoint marker",
)
.await
.expect("writing checkpoint marker should succeed");
let wt_repo = worktree_project.read_with(cx, |project, cx| {
project.repositories(cx).values().next().unwrap().clone()
});
let (staged_hash, unstaged_hash) = cx
.update(|cx| wt_repo.update(cx, |repo, _| repo.create_archive_checkpoint()))
.await
.expect("create_archive_checkpoint task should not be canceled")
.expect("create_archive_checkpoint should succeed");
// Simulate the archive having torn down the worktree directory.
fs.remove_dir(
Path::new("/wt-feature-rt"),
fs::RemoveOptions {
recursive: true,
ignore_if_not_exists: false,
},
)
.await
.expect("removing the worktree dir should succeed");
// Also clobber the captured marker so a successful restore is the
// only thing that could put it back.
fs.remove_file(
Path::new("/project/.git/worktrees/feature-rt/marker.txt"),
fs::RemoveOptions {
recursive: false,
ignore_if_not_exists: false,
},
)
.await
.expect("removing checkpoint marker should succeed");
let result = cx
.spawn(|mut cx| async move {
agent_ui::thread_worktree_archive::restore_worktree_via_git(
&agent_ui::thread_metadata_store::ArchivedGitWorktree {
id: 1,
worktree_path: PathBuf::from("/wt-feature-rt"),
main_repo_path: PathBuf::from("/project"),
branch_name: Some("feature-rt".to_string()),
staged_commit_hash: staged_hash,
unstaged_commit_hash: unstaged_hash,
original_commit_hash: "original-sha".to_string(),
},
None,
&mut cx,
)
.await
})
.await;
assert!(
result.is_ok(),
"restore should succeed for a clean round-trip: {:?}",
result.err()
);
// The marker we planted in the captured location must come back
// with its original contents — this is the actual round-trip
// assertion `restore_archive_checkpoint` is exercising in the fake.
let marker = fs
.load(Path::new("/project/.git/worktrees/feature-rt/marker.txt"))
.await
.expect("checkpoint marker must be restored");
assert_eq!(marker, "checkpoint marker");
// The worktree directory itself must exist after restore (created
// by `create_worktree_detached`), and its `.git` gitfile must be
// present so the path is a usable linked worktree again.
assert!(
fs.metadata(Path::new("/wt-feature-rt"))
.await
.expect("metadata for worktree path must succeed")
.is_some(),
"worktree directory should exist after restore"
);
assert!(
fs.metadata(Path::new("/wt-feature-rt/.git"))
.await
.expect("metadata for worktree .git must succeed")
.is_some(),
"worktree .git gitfile should exist after restore"
);
// Success-path backup cleanup runs in a detached background task; pump
// the executor so the assertion below doesn't race it.
cx.run_until_parked();
// No backup directory should remain on success.
let leftover_backup = fs.directories(true).into_iter().find(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("zed-restore-backup-"))
});
assert!(
leftover_backup.is_none(),
"backup directory should be cleaned up after a successful round-trip, found: {leftover_backup:?}"
);
}
#[gpui::test]
async fn test_restore_worktree_rolls_back_when_create_worktree_detached_fails(
cx: &mut TestAppContext,
) {
// Exercises the rollback path that runs when `create_worktree_detached`
// itself fails (the early failure branch in `restore_worktree_via_git`,
// before any branch / checkpoint operations have run). We force the
// failure with `FakeFs::set_create_worktree_error`, which makes the
// fake's `create_worktree` bail before producing any side effects.
let fixture = setup_archived_worktree_fixture(
"feature-create-fail",
serde_json::json!({ "src": {} }),
cx,
)
.await;
let fs = fixture.fs.clone();
// Sentinel content that the rollback must put back.
fs.write(
Path::new("/wt-feature-create-fail/sentinel.txt"),
b"important user data",
)
.await
.expect("writing sentinel.txt should succeed");
// Force `create_worktree_detached` to fail when the restore tries it.
fs.set_create_worktree_error(
Path::new("/project/.git"),
Some("simulated create_worktree failure".to_string()),
);
let row = fixture.archived_row();
let result = cx
.spawn(|mut cx| async move {
agent_ui::thread_worktree_archive::restore_worktree_via_git(&row, None, &mut cx).await
})
.await;
assert!(
result.is_err(),
"restore should fail when create_worktree_detached fails",
);
let error_msg = format!("{:#}", result.as_ref().unwrap_err());
assert!(
error_msg.contains("failed to create worktree")
|| error_msg.contains("simulated create_worktree failure"),
"error should indicate worktree creation failure, got: {error_msg}"
);
// The pre-existing sentinel must be back at the original path.
let sentinel_contents = fs
.load(Path::new("/wt-feature-create-fail/sentinel.txt"))
.await
.expect("sentinel file must be restored from the backup");
assert_eq!(
sentinel_contents, "important user data",
"sentinel content must match what was on disk before the restore",
);
// No backup directory should remain anywhere on the fake fs.
let leftover_backup = fs.directories(true).into_iter().find(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("zed-restore-backup-"))
});
assert!(
leftover_backup.is_none(),
"backup directory should be cleaned up after a create_worktree rollback, found: {leftover_backup:?}"
);
}
#[gpui::test]
async fn test_restore_worktree_thread_uses_main_repo_project_group_key(cx: &mut TestAppContext) {
// Activating an archived linked worktree thread whose directory has
@ -12030,3 +12558,51 @@ async fn test_cmd_click_project_header_returns_to_last_active_linked_worktree_wo
linked-worktree workspace was the last-active one for the group"
);
}
#[gpui::test]
async fn test_restore_worktree_succeeds_when_path_is_missing(cx: &mut TestAppContext) {
// When the worktree path doesn't exist on disk, the destructive
// restore should proceed and recreate the worktree (there's nothing
// to back up).
let fixture = setup_archived_worktree_fixture("feature-empty", serde_json::json!({}), cx).await;
let fs = fixture.fs.clone();
fs.remove_dir(
Path::new("/wt-feature-empty"),
fs::RemoveOptions {
recursive: true,
ignore_if_not_exists: false,
},
)
.await
.unwrap();
assert!(
fs.metadata(Path::new("/wt-feature-empty"))
.await
.unwrap()
.is_none(),
"precondition: worktree directory must not exist"
);
let restore_row = fixture.archived_row();
let result = cx
.spawn(|mut cx| async move {
agent_ui::thread_worktree_archive::restore_worktree_via_git(&restore_row, None, &mut cx)
.await
})
.await;
assert!(
result.is_ok(),
"restore should succeed when the worktree path does not exist: {:?}",
result.err()
);
assert!(
fs.metadata(Path::new("/wt-feature-empty"))
.await
.unwrap()
.is_some(),
"worktree path should exist after a successful restore"
);
}

View file

@ -661,6 +661,68 @@ pub fn normalize_lexically(path: &Path) -> Result<PathBuf, NormalizeError> {
Ok(lexical)
}
/// Returns whether `a` and `b` refer to the same on-disk location.
///
/// Tries literal equality first. Falls back to canonicalizing the longest
/// existing ancestor of each path and joining the unresolved tail, then
/// comparing. This handles two cases that a naive `std::fs::canonicalize`
/// of each side does not:
///
/// - **The leaf path doesn't exist.** `canonicalize` requires every
/// component to exist; if the worktree directory was deleted but its
/// registration is still around, naive canonicalize returns `Err` and
/// the caller falls back to literal comparison, which then misses any
/// symlink-mediated equivalence.
/// - **The path traverses a symlinked ancestor.** Git canonicalizes
/// paths when storing them in its registries (notably resolving
/// macOS's `/var` -> `/private/var` symlink, or Windows junction
/// points), but callers passing paths into Zed APIs typically use the
/// un-resolved form. Walking up to the nearest existing ancestor and
/// canonicalizing *that* lets the comparison succeed even when the
/// leaf is gone.
///
/// Does sync I/O via `std::fs::canonicalize`; call it from a spawned task
/// or background context, not on a foreground async path.
pub fn paths_resolve_to_same_location(a: &Path, b: &Path) -> bool {
if a == b {
return true;
}
let Some(canon_a) = canonicalize_with_existing_ancestor(a) else {
return false;
};
let Some(canon_b) = canonicalize_with_existing_ancestor(b) else {
return false;
};
canon_a == canon_b
}
/// Canonicalizes the longest existing prefix of `path` and re-appends the
/// remaining (unresolved) tail. Returns `None` only if no ancestor of
/// `path` can be canonicalized, which on any working filesystem means
/// even the root is unreadable.
fn canonicalize_with_existing_ancestor(path: &Path) -> Option<PathBuf> {
let mut tail = PathBuf::new();
let mut current = path.to_path_buf();
loop {
if let Ok(canon) = std::fs::canonicalize(&current) {
return Some(if tail.as_os_str().is_empty() {
canon
} else {
canon.join(&tail)
});
}
let Some(file_name) = current.file_name().map(|n| n.to_os_string()) else {
return None;
};
let mut new_tail = PathBuf::from(&file_name);
new_tail.push(&tail);
tail = new_tail;
if !current.pop() {
return None;
}
}
}
/// A delimiter to use in `path_query:row_number:column_number` strings parsing.
pub const FILE_ROW_COLUMN_DELIMITER: char = ':';
@ -3559,4 +3621,64 @@ mod tests {
Ok(PathBuf::from("C:\\Users\\file.txt"))
);
}
#[test]
fn test_paths_resolve_to_same_location_literal_equality() {
let p = Path::new("/some/nonexistent/path/that/will/never/exist/xyz");
assert!(paths_resolve_to_same_location(p, p));
}
#[test]
fn test_paths_resolve_to_same_location_nonexistent_distinct() {
let a = Path::new("/totally/nonexistent/path/a");
let b = Path::new("/totally/nonexistent/path/b");
assert!(!paths_resolve_to_same_location(a, b));
}
#[test]
fn test_paths_resolve_to_same_location_handles_symlinked_ancestor() {
// Set up: tmp/real_dir/ exists, tmp/link_dir is a symlink to it.
// Compare a leaf under each form. The leaf itself does NOT exist,
// so naive `canonicalize` would fail on both and the function
// would have to fall back to ancestor-canonicalize. This mirrors
// the worktree-removal scenario where git registers the resolved
// path and we pass the un-resolved form for a directory that's
// already gone.
let tmp = tempfile::tempdir().unwrap();
let real_dir = tmp.path().join("real_dir");
std::fs::create_dir(&real_dir).unwrap();
let link_dir = tmp.path().join("link_dir");
#[cfg(unix)]
std::os::unix::fs::symlink(&real_dir, &link_dir).unwrap();
#[cfg(windows)]
{
// Best-effort; skip the assertion if the test process lacks
// SeCreateSymbolicLinkPrivilege.
if std::os::windows::fs::symlink_dir(&real_dir, &link_dir).is_err() {
return;
}
}
let via_real = real_dir.join("missing_leaf");
let via_link = link_dir.join("missing_leaf");
assert!(
paths_resolve_to_same_location(&via_real, &via_link),
"paths through a symlinked ancestor must compare equal even when the leaf is missing"
);
}
#[test]
fn test_paths_resolve_to_same_location_distinct_existing_ancestors() {
let tmp = tempfile::tempdir().unwrap();
let a_parent = tmp.path().join("a");
let b_parent = tmp.path().join("b");
std::fs::create_dir(&a_parent).unwrap();
std::fs::create_dir(&b_parent).unwrap();
let a = a_parent.join("missing");
let b = b_parent.join("missing");
assert!(
!paths_resolve_to_same_location(&a, &b),
"distinct parents with the same missing leaf must not compare equal"
);
}
}