1use std::{
2 path::{Path, PathBuf},
3 sync::Arc,
4};
5
6use anyhow::{Context as _, Result, anyhow};
7use gpui::{App, AsyncApp, Entity, Task};
8use project::{
9 LocalProjectFlags, Project, WorktreeId,
10 git_store::{Repository, resolve_git_worktree_to_main_repo, worktrees_directory_for_repo},
11 project_settings::ProjectSettings,
12};
13use remote::{RemoteConnectionOptions, same_remote_connection_identity};
14use settings::Settings;
15use util::ResultExt;
16use workspace::{AppState, MultiWorkspace, Workspace};
17
18use crate::thread_metadata_store::{ArchivedGitWorktree, ThreadId, ThreadMetadataStore};
19
20/// The plan for archiving a single git worktree root.
21///
22/// A thread can have multiple folder paths open, so there may be multiple
23/// `RootPlan`s per archival operation. Each one captures everything needed to
24/// persist the worktree's git state and then remove it from disk.
25///
26/// All fields are gathered synchronously by [`build_root_plan`] while the
27/// worktree is still loaded in open projects. This is important because
28/// workspace removal tears down project and repository entities, making
29/// them unavailable for the later async persist/remove steps.
30#[derive(Clone)]
31pub struct RootPlan {
32 /// Absolute path of the git worktree on disk.
33 pub root_path: PathBuf,
34 /// Absolute path to the main git repository this worktree is linked to.
35 /// Used both for creating a git ref to prevent GC of WIP commits during
36 /// [`persist_worktree_state`], and for `git worktree remove` during
37 /// [`remove_root`].
38 pub main_repo_path: PathBuf,
39 /// Every open `Project` that has this worktree loaded, so they can all
40 /// call `remove_worktree` and release it during [`remove_root`].
41 /// Multiple projects can reference the same path when the user has the
42 /// worktree open in more than one workspace.
43 pub affected_projects: Vec<AffectedProject>,
44 /// The `Repository` entity for this linked worktree, used to run git
45 /// commands (create WIP commits, stage files, reset) during
46 /// [`persist_worktree_state`].
47 pub worktree_repo: Entity<Repository>,
48 /// The branch the worktree was on, so it can be restored later.
49 /// `None` if the worktree was in detached HEAD state.
50 pub branch_name: Option<String>,
51 /// Remote connection options for the project that owns this worktree,
52 /// used to create temporary remote projects when the main repo isn't
53 /// loaded in any open workspace.
54 pub remote_connection: Option<RemoteConnectionOptions>,
55}
56
57/// A `Project` that references a worktree being archived, paired with the
58/// `WorktreeId` it uses for that worktree.
59///
60/// The same worktree path can appear in multiple open workspaces/projects
61/// (e.g. when the user has two windows open that both include the same
62/// linked worktree). Each one needs to call `remove_worktree` and wait for
63/// the release during [`remove_root`], otherwise the project would still
64/// hold a reference to the directory and `git worktree remove` would fail.
65#[derive(Clone)]
66pub struct AffectedProject {
67 pub project: Entity<Project>,
68 pub worktree_id: WorktreeId,
69}
70
71fn archived_worktree_ref_name(id: i64) -> String {
72 format!("refs/archived-worktrees/{}", id)
73}
74
75/// Resolves the Zed-managed worktrees base directory for a given repo.
76///
77/// This intentionally reads the *global* `git.worktree_directory` setting
78/// rather than any project-local override, because Zed always uses the
79/// global value when creating worktrees and the archive check must match.
80fn worktrees_base_for_repo(main_repo_path: &Path, cx: &App) -> Option<PathBuf> {
81 let setting = &ProjectSettings::get_global(cx).git.worktree_directory;
82 worktrees_directory_for_repo(main_repo_path, setting).log_err()
83}
84
85/// Builds a [`RootPlan`] for archiving the git worktree at `path`.
86///
87/// This is a synchronous planning step that must run *before* any workspace
88/// removal, because it needs live project and repository entities that are
89/// torn down when a workspace is removed. It does three things:
90///
91/// 1. Finds every `Project` across all open workspaces that has this
92/// worktree loaded (`affected_projects`).
93/// 2. Looks for a `Repository` entity whose snapshot identifies this path
94/// as a linked worktree (`worktree_repo`), which is needed for the git
95/// operations in [`persist_worktree_state`].
96/// 3. Determines the `main_repo_path` — the parent repo that owns this
97/// linked worktree — needed for both git ref creation and
98/// `git worktree remove`.
99///
100/// Returns `None` if the path is not a linked worktree (main worktrees
101/// cannot be archived to disk) or if no open project has it loaded.
102pub fn build_root_plan(
103 path: &Path,
104 remote_connection: Option<&RemoteConnectionOptions>,
105 workspaces: &[Entity<Workspace>],
106 cx: &App,
107) -> Option<RootPlan> {
108 let path = path.to_path_buf();
109
110 let matches_target_connection = |project: &Entity<Project>, cx: &App| {
111 same_remote_connection_identity(
112 project.read(cx).remote_connection_options(cx).as_ref(),
113 remote_connection,
114 )
115 };
116
117 let affected_projects = workspaces
118 .iter()
119 .filter_map(|workspace| {
120 let project = workspace.read(cx).project().clone();
121 if !matches_target_connection(&project, cx) {
122 return None;
123 }
124 let worktree = project
125 .read(cx)
126 .visible_worktrees(cx)
127 .find(|worktree| worktree.read(cx).abs_path().as_ref() == path.as_path())?;
128 let worktree_id = worktree.read(cx).id();
129 Some(AffectedProject {
130 project,
131 worktree_id,
132 })
133 })
134 .collect::<Vec<_>>();
135
136 if affected_projects.is_empty() {
137 return None;
138 }
139
140 let linked_repo = workspaces
141 .iter()
142 .filter(|workspace| matches_target_connection(workspace.read(cx).project(), cx))
143 .flat_map(|workspace| {
144 workspace
145 .read(cx)
146 .project()
147 .read(cx)
148 .repositories(cx)
149 .values()
150 .cloned()
151 .collect::<Vec<_>>()
152 })
153 .find_map(|repo| {
154 let snapshot = repo.read(cx).snapshot();
155 (snapshot.is_linked_worktree()
156 && snapshot.work_directory_abs_path.as_ref() == path.as_path())
157 .then_some((snapshot, repo))
158 });
159
160 // Only linked worktrees can be archived to disk via `git worktree remove`.
161 // Main worktrees must be left alone — git refuses to remove them.
162 let (linked_snapshot, repo) = linked_repo?;
163 let main_repo_path = linked_snapshot.original_repo_abs_path.to_path_buf();
164
165 // Only archive worktrees that live inside the Zed-managed worktrees
166 // directory (configured via `git.worktree_directory`). Worktrees the
167 // user created outside that directory should be left untouched.
168 let worktrees_base = worktrees_base_for_repo(&main_repo_path, cx)?;
169 if !path.starts_with(&worktrees_base) {
170 return None;
171 }
172
173 let branch_name = linked_snapshot
174 .branch
175 .as_ref()
176 .map(|branch| branch.name().to_string());
177
178 Some(RootPlan {
179 root_path: path,
180 main_repo_path,
181 affected_projects,
182 worktree_repo: repo,
183 branch_name,
184 remote_connection: remote_connection.cloned(),
185 })
186}
187
188/// Removes a worktree from all affected projects and deletes it from disk
189/// via `git worktree remove`.
190///
191/// This is the destructive counterpart to [`persist_worktree_state`]. It
192/// first detaches the worktree from every [`AffectedProject`], waits for
193/// each project to fully release it, then asks the main repository to
194/// delete the worktree directory. If the git removal fails, the worktree
195/// is re-added to each project via [`rollback_root`].
196pub async fn remove_root(root: RootPlan, cx: &mut AsyncApp) -> Result<()> {
197 let release_tasks: Vec<_> = root
198 .affected_projects
199 .iter()
200 .map(|affected| {
201 let project = affected.project.clone();
202 let worktree_id = affected.worktree_id;
203 project.update(cx, |project, cx| {
204 let wait = project.wait_for_worktree_release(worktree_id, cx);
205 project.remove_worktree(worktree_id, cx);
206 wait
207 })
208 })
209 .collect();
210
211 if let Err(error) = remove_root_after_worktree_removal(&root, release_tasks, cx).await {
212 rollback_root(&root, cx).await;
213 return Err(error);
214 }
215
216 Ok(())
217}
218
219async fn remove_root_after_worktree_removal(
220 root: &RootPlan,
221 release_tasks: Vec<Task<Result<()>>>,
222 cx: &mut AsyncApp,
223) -> Result<()> {
224 for task in release_tasks {
225 if let Err(error) = task.await {
226 log::error!("Failed waiting for worktree release: {error:#}");
227 }
228 }
229
230 let (repo, project) =
231 find_or_create_repository(&root.main_repo_path, root.remote_connection.as_ref(), cx)
232 .await?;
233
234 // `Repository::remove_worktree` with `force = true` deletes the working
235 // directory before running `git worktree remove --force`, so there's no
236 // need to touch the filesystem here. For remote projects that cleanup
237 // runs on the headless server via the `GitRemoveWorktree` RPC, which is
238 // the only code path with access to the remote machine's filesystem.
239 let receiver = repo.update(cx, |repo: &mut Repository, _cx| {
240 repo.remove_worktree(root.root_path.clone(), true)
241 });
242 let result = receiver
243 .await
244 .map_err(|_| anyhow!("git worktree metadata cleanup was canceled"))?;
245 // `project` may be a live workspace project or a temporary one created
246 // by `find_or_create_repository`. In the temporary case we must keep it
247 // alive until the repo removes the worktree
248 drop(project);
249 result.context("git worktree metadata cleanup failed")?;
250
251 // Empty-parent cleanup uses local std::fs — skip for remote projects.
252 if root.remote_connection.is_none() {
253 remove_empty_parent_dirs_up_to_worktrees_base(
254 root.root_path.clone(),
255 root.main_repo_path.clone(),
256 cx,
257 )
258 .await;
259 }
260
261 Ok(())
262}
263
264/// After `git worktree remove` deletes the worktree directory, clean up any
265/// empty parent directories between it and the Zed-managed worktrees base
266/// directory (configured via `git.worktree_directory`). The base directory
267/// itself is never removed.
268///
269/// If the base directory is not an ancestor of `root_path`, no parent
270/// directories are removed.
271async fn remove_empty_parent_dirs_up_to_worktrees_base(
272 root_path: PathBuf,
273 main_repo_path: PathBuf,
274 cx: &mut AsyncApp,
275) {
276 let worktrees_base = cx.update(|cx| worktrees_base_for_repo(&main_repo_path, cx));
277
278 if let Some(worktrees_base) = worktrees_base {
279 cx.background_executor()
280 .spawn(async move {
281 remove_empty_ancestors(&root_path, &worktrees_base);
282 })
283 .await;
284 }
285}
286
287/// Removes empty directories between `child_path` and `base_path`.
288///
289/// Walks upward from `child_path`, removing each empty parent directory,
290/// stopping before `base_path` itself is removed. If `base_path` is not
291/// an ancestor of `child_path`, nothing is removed. If any directory is
292/// non-empty (i.e. `std::fs::remove_dir` fails), the walk stops.
293fn remove_empty_ancestors(child_path: &Path, base_path: &Path) {
294 let mut current = child_path;
295 while let Some(parent) = current.parent() {
296 if parent == base_path {
297 break;
298 }
299 if !parent.starts_with(base_path) {
300 break;
301 }
302 match std::fs::remove_dir(parent) {
303 Ok(()) => {
304 log::info!("Removed empty parent directory: {}", parent.display());
305 }
306 Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => break,
307 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
308 // Already removed by a concurrent process; keep walking upward.
309 }
310 Err(err) => {
311 log::error!(
312 "Failed to remove parent directory {}: {err}",
313 parent.display()
314 );
315 break;
316 }
317 }
318 current = parent;
319 }
320}
321
322/// Finds a live `Repository` entity for the given path, or creates a temporary
323/// project to obtain one.
324///
325/// `Repository` entities can only be obtained through a `Project` because
326/// `GitStore` (which creates and manages `Repository` entities) is owned by
327/// `Project`. When no open workspace contains the repo we need, we spin up a
328/// headless project just to get a `Repository` handle. For local paths this is
329/// a `Project::local`; for remote paths we build a `Project::remote` through
330/// the connection pool (reusing the existing SSH transport), which requires
331/// the caller to pass the matching `RemoteConnectionOptions` so we only match
332/// and fall back onto projects that share the same remote identity. The
333/// caller keeps the returned `Entity<Project>` alive for the duration of the
334/// git operations, then drops it.
335///
336/// Future improvement: decoupling `GitStore` from `Project` so that
337/// `Repository` entities can be created standalone would eliminate this
338/// temporary-project workaround.
339async fn find_or_create_repository(
340 repo_path: &Path,
341 remote_connection: Option<&RemoteConnectionOptions>,
342 cx: &mut AsyncApp,
343) -> Result<(Entity<Repository>, Entity<Project>)> {
344 let repo_path_owned = repo_path.to_path_buf();
345 let remote_connection_owned = remote_connection.cloned();
346
347 // First, try to find a live repository in any open workspace whose
348 // remote connection matches (so a local `/project` and a remote
349 // `/project` are not confused).
350 let live_repo = cx.update(|cx| {
351 all_open_workspaces(cx)
352 .into_iter()
353 .filter_map(|workspace| {
354 let project = workspace.read(cx).project().clone();
355 let project_connection = project.read(cx).remote_connection_options(cx);
356 if !same_remote_connection_identity(
357 project_connection.as_ref(),
358 remote_connection_owned.as_ref(),
359 ) {
360 return None;
361 }
362 Some((
363 project
364 .read(cx)
365 .repositories(cx)
366 .values()
367 .find(|repo| {
368 repo.read(cx).snapshot().work_directory_abs_path.as_ref()
369 == repo_path_owned.as_path()
370 })
371 .cloned()?,
372 project.clone(),
373 ))
374 })
375 .next()
376 });
377
378 if let Some((repo, project)) = live_repo {
379 return Ok((repo, project));
380 }
381
382 let app_state =
383 current_app_state(cx).context("no app state available for temporary project")?;
384
385 // For remote paths, create a fresh RemoteClient through the connection
386 // pool (reusing the existing SSH transport) and build a temporary
387 // remote project. Each RemoteClient gets its own server-side headless
388 // project, so there are no RPC routing conflicts with other projects.
389 let temp_project = if let Some(connection) = remote_connection_owned {
390 let remote_client = cx
391 .update(|cx| {
392 if !remote::has_active_connection(&connection, cx) {
393 anyhow::bail!("cannot open repository on disconnected remote machine");
394 }
395 Ok(remote_connection::connect_reusing_pool(connection, cx))
396 })?
397 .await?
398 .context("remote connection was canceled")?;
399
400 cx.update(|cx| {
401 Project::remote(
402 remote_client,
403 app_state.client.clone(),
404 app_state.node_runtime.clone(),
405 app_state.user_store.clone(),
406 app_state.languages.clone(),
407 app_state.fs.clone(),
408 false,
409 cx,
410 )
411 })
412 } else {
413 cx.update(|cx| {
414 Project::local(
415 app_state.client.clone(),
416 app_state.node_runtime.clone(),
417 app_state.user_store.clone(),
418 app_state.languages.clone(),
419 app_state.fs.clone(),
420 None,
421 LocalProjectFlags::default(),
422 cx,
423 )
424 })
425 };
426
427 let repo_path_for_worktree = repo_path.to_path_buf();
428 let create_worktree = temp_project.update(cx, |project, cx| {
429 project.create_worktree(repo_path_for_worktree, true, cx)
430 });
431 let _worktree = create_worktree.await?;
432 let initial_scan = temp_project.read_with(cx, |project, cx| project.wait_for_initial_scan(cx));
433 initial_scan.await;
434
435 let repo_path_for_find = repo_path.to_path_buf();
436 let repo = temp_project
437 .update(cx, |project, cx| {
438 project
439 .repositories(cx)
440 .values()
441 .find(|repo| {
442 repo.read(cx).snapshot().work_directory_abs_path.as_ref()
443 == repo_path_for_find.as_path()
444 })
445 .cloned()
446 })
447 .context("failed to resolve temporary repository handle")?;
448
449 let barrier = repo.update(cx, |repo: &mut Repository, _cx| repo.barrier());
450 barrier
451 .await
452 .map_err(|_| anyhow!("temporary repository barrier canceled"))?;
453 Ok((repo, temp_project))
454}
455
456/// Re-adds the worktree to every affected project after a failed
457/// [`remove_root`].
458async fn rollback_root(root: &RootPlan, cx: &mut AsyncApp) {
459 for affected in &root.affected_projects {
460 let task = affected.project.update(cx, |project, cx| {
461 project.create_worktree(root.root_path.clone(), true, cx)
462 });
463 task.await.log_err();
464 }
465}
466
467/// Saves the worktree's full git state so it can be restored later.
468///
469/// This creates two detached commits (via [`create_archive_checkpoint`] on
470/// the `GitRepository` trait) that capture the staged and unstaged state
471/// without moving any branch ref. The commits are:
472/// - "WIP staged": a tree matching the current index, parented on HEAD
473/// - "WIP unstaged": a tree with all files (including untracked),
474/// parented on the staged commit
475///
476/// After creating the commits, this function:
477/// 1. Records the commit SHAs, branch name, and paths in a DB record.
478/// 2. Links every thread referencing this worktree to that record.
479/// 3. Creates a git ref on the main repo to prevent GC of the commits.
480///
481/// On success, returns the archived worktree DB row ID for rollback.
482pub async fn persist_worktree_state(root: &RootPlan, cx: &mut AsyncApp) -> Result<i64> {
483 let worktree_repo = root.worktree_repo.clone();
484
485 let original_commit_hash = worktree_repo
486 .update(cx, |repo, _cx| repo.head_sha())
487 .await
488 .map_err(|_| anyhow!("head_sha canceled"))?
489 .context("failed to read original HEAD SHA")?
490 .context("HEAD SHA is None")?;
491
492 // Create two detached WIP commits without moving the branch.
493 let checkpoint_rx = worktree_repo.update(cx, |repo, _cx| repo.create_archive_checkpoint());
494 let (staged_commit_hash, unstaged_commit_hash) = checkpoint_rx
495 .await
496 .map_err(|_| anyhow!("create_archive_checkpoint canceled"))?
497 .context("failed to create archive checkpoint")?;
498
499 // Create DB record
500 let store = cx.update(|cx| ThreadMetadataStore::global(cx));
501 let worktree_path_str = root.root_path.to_string_lossy().to_string();
502 let main_repo_path_str = root.main_repo_path.to_string_lossy().to_string();
503 let branch_name = root.branch_name.clone().or_else(|| {
504 worktree_repo.read_with(cx, |repo, _cx| {
505 repo.snapshot()
506 .branch
507 .as_ref()
508 .map(|branch| branch.name().to_string())
509 })
510 });
511
512 let db_result = store
513 .read_with(cx, |store, cx| {
514 store.create_archived_worktree(
515 worktree_path_str.clone(),
516 main_repo_path_str.clone(),
517 branch_name.clone(),
518 staged_commit_hash.clone(),
519 unstaged_commit_hash.clone(),
520 original_commit_hash.clone(),
521 cx,
522 )
523 })
524 .await
525 .context("failed to create archived worktree DB record");
526 let archived_worktree_id = match db_result {
527 Ok(id) => id,
528 Err(error) => {
529 return Err(error);
530 }
531 };
532
533 // Link all threads on this worktree to the archived record
534 let thread_ids: Vec<ThreadId> = store.read_with(cx, |store, _cx| {
535 store
536 .entries()
537 .filter(|thread| {
538 thread
539 .folder_paths()
540 .paths()
541 .iter()
542 .any(|p| p.as_path() == root.root_path)
543 })
544 .map(|thread| thread.thread_id)
545 .collect()
546 });
547
548 for thread_id in &thread_ids {
549 let link_result = store
550 .read_with(cx, |store, cx| {
551 store.link_thread_to_archived_worktree(*thread_id, archived_worktree_id, cx)
552 })
553 .await;
554 if let Err(error) = link_result {
555 if let Err(delete_error) = store
556 .read_with(cx, |store, cx| {
557 store.delete_archived_worktree(archived_worktree_id, cx)
558 })
559 .await
560 {
561 log::error!(
562 "Failed to delete archived worktree DB record during link rollback: \
563 {delete_error:#}"
564 );
565 }
566 return Err(error.context("failed to link thread to archived worktree"));
567 }
568 }
569
570 // Create git ref on main repo to prevent GC of the detached commits.
571 // This is fatal: without the ref, git gc will eventually collect the
572 // WIP commits and a later restore will silently fail.
573 let ref_name = archived_worktree_ref_name(archived_worktree_id);
574 let (main_repo, _temp_project) =
575 find_or_create_repository(&root.main_repo_path, root.remote_connection.as_ref(), cx)
576 .await
577 .context("could not open main repo to create archive ref")?;
578 let rx = main_repo.update(cx, |repo, _cx| {
579 repo.update_ref(ref_name.clone(), unstaged_commit_hash.clone())
580 });
581 rx.await
582 .map_err(|_| anyhow!("update_ref canceled"))
583 .and_then(|r| r)
584 .with_context(|| format!("failed to create ref {ref_name} on main repo"))?;
585 // See note in `remove_root_after_worktree_removal`: this may be a live
586 // or temporary project; dropping only matters in the temporary case.
587 drop(_temp_project);
588
589 Ok(archived_worktree_id)
590}
591
592/// Undoes a successful [`persist_worktree_state`] by deleting the git ref
593/// on the main repo and removing the DB record. Since the WIP commits are
594/// detached (they don't move any branch), no git reset is needed — the
595/// commits will be garbage-collected once the ref is removed.
596pub async fn rollback_persist(archived_worktree_id: i64, root: &RootPlan, cx: &mut AsyncApp) {
597 // Delete the git ref on main repo
598 if let Ok((main_repo, _temp_project)) =
599 find_or_create_repository(&root.main_repo_path, root.remote_connection.as_ref(), cx).await
600 {
601 let ref_name = archived_worktree_ref_name(archived_worktree_id);
602 let rx = main_repo.update(cx, |repo, _cx| repo.delete_ref(ref_name));
603 rx.await.ok().and_then(|r| r.log_err());
604 // See note in `remove_root_after_worktree_removal`: this may be a
605 // live or temporary project; dropping only matters in the temporary
606 // case.
607 drop(_temp_project);
608 }
609
610 // Delete the DB record
611 let store = cx.update(|cx| ThreadMetadataStore::global(cx));
612 if let Err(error) = store
613 .read_with(cx, |store, cx| {
614 store.delete_archived_worktree(archived_worktree_id, cx)
615 })
616 .await
617 {
618 log::error!("Failed to delete archived worktree DB record during rollback: {error:#}");
619 }
620}
621
622/// Restores a previously archived worktree back to disk from its DB record.
623///
624/// Creates the git worktree at the original commit (the branch never moved
625/// during archival since WIP commits are detached), switches to the branch,
626/// then uses [`restore_archive_checkpoint`] to reconstruct the staged/
627/// unstaged state from the WIP commit trees.
628pub async fn restore_worktree_via_git(
629 row: &ArchivedGitWorktree,
630 remote_connection: Option<&RemoteConnectionOptions>,
631 cx: &mut AsyncApp,
632) -> Result<PathBuf> {
633 let (main_repo, _temp_project) =
634 find_or_create_repository(&row.main_repo_path, remote_connection, cx).await?;
635
636 let worktree_path = &row.worktree_path;
637 let app_state = current_app_state(cx).context("no app state available")?;
638 let already_exists = app_state.fs.metadata(worktree_path).await?.is_some();
639
640 let created_new_worktree = if already_exists {
641 let is_git_worktree =
642 resolve_git_worktree_to_main_repo(app_state.fs.as_ref(), worktree_path)
643 .await
644 .is_some();
645
646 if !is_git_worktree {
647 let rx = main_repo.update(cx, |repo, _cx| repo.repair_worktrees());
648 rx.await
649 .map_err(|_| anyhow!("worktree repair was canceled"))?
650 .context("failed to repair worktrees")?;
651 }
652 false
653 } else {
654 // Create worktree at the original commit — the branch still points
655 // here because archival used detached commits.
656 let rx = main_repo.update(cx, |repo, _cx| {
657 repo.create_worktree_detached(worktree_path.clone(), row.original_commit_hash.clone())
658 });
659 rx.await
660 .map_err(|_| anyhow!("worktree creation was canceled"))?
661 .context("failed to create worktree")?;
662 true
663 };
664
665 let (wt_repo, _temp_wt_project) =
666 match find_or_create_repository(worktree_path, remote_connection, cx).await {
667 Ok(result) => result,
668 Err(error) => {
669 remove_new_worktree_on_error(created_new_worktree, &main_repo, worktree_path, cx)
670 .await;
671 return Err(error);
672 }
673 };
674
675 if let Some(branch_name) = &row.branch_name {
676 // Attempt to check out the branch the worktree was previously on.
677 let checkout_result = wt_repo
678 .update(cx, |repo, _cx| repo.change_branch(branch_name.clone()))
679 .await;
680
681 match checkout_result.map_err(|e| anyhow!("{e}")).flatten() {
682 Ok(()) => {
683 // Branch checkout succeeded. Check whether the branch has moved since
684 // we archived the worktree, by comparing HEAD to the expected SHA.
685 let head_sha = wt_repo
686 .update(cx, |repo, _cx| repo.head_sha())
687 .await
688 .map_err(|e| anyhow!("{e}"))
689 .and_then(|r| r);
690
691 match head_sha {
692 Ok(Some(sha)) if sha == row.original_commit_hash => {
693 // Branch still points at the original commit; we're all done!
694 }
695 Ok(Some(sha)) => {
696 // The branch has moved. We don't want to restore the worktree to
697 // a different filesystem state, so checkout the original commit
698 // in detached HEAD state.
699 log::info!(
700 "Branch '{branch_name}' has moved since archival (now at {sha}); \
701 restoring worktree in detached HEAD at {}",
702 row.original_commit_hash
703 );
704 let detach_result = main_repo
705 .update(cx, |repo, _cx| {
706 repo.checkout_branch_in_worktree(
707 row.original_commit_hash.clone(),
708 row.worktree_path.clone(),
709 false,
710 )
711 })
712 .await;
713
714 if let Err(error) = detach_result.map_err(|e| anyhow!("{e}")).flatten() {
715 log::warn!(
716 "Failed to detach HEAD at {}: {error:#}",
717 row.original_commit_hash
718 );
719 }
720 }
721 Ok(None) => {
722 log::warn!(
723 "head_sha unexpectedly returned None after checking out \"{branch_name}\"; \
724 proceeding in current HEAD state."
725 );
726 }
727 Err(error) => {
728 log::warn!(
729 "Failed to read HEAD after checking out \"{branch_name}\": {error:#}"
730 );
731 }
732 }
733 }
734 Err(checkout_error) => {
735 // We weren't able to check out the branch, most likely because it was deleted.
736 // This is fine; users will often delete old branches! We'll try to recreate it.
737 log::debug!(
738 "change_branch('{branch_name}') failed: {checkout_error:#}, trying create_branch"
739 );
740 let create_result = wt_repo
741 .update(cx, |repo, _cx| {
742 repo.create_branch(branch_name.clone(), None)
743 })
744 .await;
745
746 if let Err(error) = create_result.map_err(|e| anyhow!("{e}")).flatten() {
747 log::warn!(
748 "Failed to create branch '{branch_name}': {error:#}; \
749 restored worktree will be in detached HEAD state."
750 );
751 }
752 }
753 }
754 }
755
756 // Restore the staged/unstaged state from the WIP commit trees.
757 // read-tree --reset -u applies the unstaged tree (including deletions)
758 // to the working directory, then a bare read-tree sets the index to
759 // the staged tree without touching the working directory.
760 let restore_rx = wt_repo.update(cx, |repo, _cx| {
761 repo.restore_archive_checkpoint(
762 row.staged_commit_hash.clone(),
763 row.unstaged_commit_hash.clone(),
764 )
765 });
766 if let Err(error) = restore_rx
767 .await
768 .map_err(|_| anyhow!("restore_archive_checkpoint canceled"))
769 .and_then(|r| r)
770 {
771 remove_new_worktree_on_error(created_new_worktree, &main_repo, worktree_path, cx).await;
772 return Err(error.context("failed to restore archive checkpoint"));
773 }
774
775 Ok(worktree_path.clone())
776}
777
778async fn remove_new_worktree_on_error(
779 created_new_worktree: bool,
780 main_repo: &Entity<Repository>,
781 worktree_path: &PathBuf,
782 cx: &mut AsyncApp,
783) {
784 if created_new_worktree {
785 let rx = main_repo.update(cx, |repo, _cx| {
786 repo.remove_worktree(worktree_path.clone(), true)
787 });
788 rx.await.ok().and_then(|r| r.log_err());
789 }
790}
791
792/// Deletes the git ref and DB records for a single archived worktree.
793/// Used when an archived worktree is no longer referenced by any thread.
794pub async fn cleanup_archived_worktree_record(
795 row: &ArchivedGitWorktree,
796 remote_connection: Option<&RemoteConnectionOptions>,
797 cx: &mut AsyncApp,
798) {
799 // Delete the git ref from the main repo
800 if let Ok((main_repo, _temp_project)) =
801 find_or_create_repository(&row.main_repo_path, remote_connection, cx).await
802 {
803 let ref_name = archived_worktree_ref_name(row.id);
804 let rx = main_repo.update(cx, |repo, _cx| repo.delete_ref(ref_name));
805 match rx.await {
806 Ok(Ok(())) => {}
807 Ok(Err(error)) => log::warn!("Failed to delete archive ref: {error}"),
808 Err(_) => log::warn!("Archive ref deletion was canceled"),
809 }
810 // See note in `remove_root_after_worktree_removal`: this may be a
811 // live or temporary project; dropping only matters in the temporary
812 // case.
813 drop(_temp_project);
814 }
815
816 // Delete the DB records
817 let store = cx.update(|cx| ThreadMetadataStore::global(cx));
818 store
819 .read_with(cx, |store, cx| store.delete_archived_worktree(row.id, cx))
820 .await
821 .log_err();
822}
823
824/// Cleans up all archived worktree data associated with a thread being deleted.
825///
826/// This unlinks the thread from all its archived worktrees and, for any
827/// archived worktree that is no longer referenced by any other thread,
828/// deletes the git ref and DB records.
829pub async fn cleanup_thread_archived_worktrees(thread_id: ThreadId, cx: &mut AsyncApp) {
830 let store = cx.update(|cx| ThreadMetadataStore::global(cx));
831 let remote_connection = store.read_with(cx, |store, _cx| {
832 store
833 .entry(thread_id)
834 .and_then(|t| t.remote_connection.clone())
835 });
836
837 let archived_worktrees = store
838 .read_with(cx, |store, cx| {
839 store.get_archived_worktrees_for_thread(thread_id, cx)
840 })
841 .await;
842 let archived_worktrees = match archived_worktrees {
843 Ok(rows) => rows,
844 Err(error) => {
845 log::error!("Failed to fetch archived worktrees for thread {thread_id:?}: {error:#}");
846 return;
847 }
848 };
849
850 if archived_worktrees.is_empty() {
851 return;
852 }
853
854 if let Err(error) = store
855 .read_with(cx, |store, cx| {
856 store.unlink_thread_from_all_archived_worktrees(thread_id, cx)
857 })
858 .await
859 {
860 log::error!("Failed to unlink thread {thread_id:?} from archived worktrees: {error:#}");
861 return;
862 }
863
864 for row in &archived_worktrees {
865 let still_referenced = store
866 .read_with(cx, |store, cx| {
867 store.is_archived_worktree_referenced(row.id, cx)
868 })
869 .await;
870 match still_referenced {
871 Ok(true) => {}
872 Ok(false) => {
873 cleanup_archived_worktree_record(row, remote_connection.as_ref(), cx).await;
874 }
875 Err(error) => {
876 log::error!(
877 "Failed to check if archived worktree {} is still referenced: {error:#}",
878 row.id
879 );
880 }
881 }
882 }
883}
884
885/// Collects every `Workspace` entity across all open `MultiWorkspace` windows.
886pub fn all_open_workspaces(cx: &App) -> Vec<Entity<Workspace>> {
887 cx.windows()
888 .into_iter()
889 .filter_map(|window| window.downcast::<MultiWorkspace>())
890 .flat_map(|multi_workspace| {
891 multi_workspace
892 .read(cx)
893 .map(|multi_workspace| multi_workspace.workspaces().cloned().collect::<Vec<_>>())
894 .unwrap_or_default()
895 })
896 .collect()
897}
898
899fn current_app_state(cx: &mut AsyncApp) -> Option<Arc<AppState>> {
900 cx.update(|cx| {
901 all_open_workspaces(cx)
902 .into_iter()
903 .next()
904 .map(|workspace| workspace.read(cx).app_state().clone())
905 })
906}
907#[cfg(test)]
908mod tests {
909 use super::*;
910 use fs::{FakeFs, Fs as _};
911 use git::repository::Worktree as GitWorktree;
912 use gpui::{BorrowAppContext, TestAppContext};
913 use project::Project;
914 use serde_json::json;
915 use settings::SettingsStore;
916 use tempfile::TempDir;
917 use workspace::MultiWorkspace;
918
919 fn init_test(cx: &mut TestAppContext) {
920 cx.update(|cx| {
921 let settings_store = SettingsStore::test(cx);
922 cx.set_global(settings_store);
923 theme_settings::init(theme::LoadThemes::JustBase, cx);
924 editor::init(cx);
925 release_channel::init(semver::Version::new(0, 0, 0), cx);
926 });
927 }
928
929 #[test]
930 fn test_remove_empty_ancestors_single_empty_parent() {
931 let tmp = TempDir::new().unwrap();
932 let base = tmp.path().join("worktrees");
933 let branch_dir = base.join("my-branch");
934 let child = branch_dir.join("zed");
935
936 std::fs::create_dir_all(&child).unwrap();
937 // Simulate git worktree remove having deleted the child.
938 std::fs::remove_dir(&child).unwrap();
939
940 assert!(branch_dir.exists());
941 remove_empty_ancestors(&child, &base);
942 assert!(!branch_dir.exists(), "empty parent should be removed");
943 assert!(base.exists(), "base directory should be preserved");
944 }
945
946 #[test]
947 fn test_remove_empty_ancestors_nested_empty_parents() {
948 let tmp = TempDir::new().unwrap();
949 let base = tmp.path().join("worktrees");
950 // Branch name with slash creates nested dirs: fix/thing/zed
951 let child = base.join("fix").join("thing").join("zed");
952
953 std::fs::create_dir_all(&child).unwrap();
954 std::fs::remove_dir(&child).unwrap();
955
956 assert!(base.join("fix").join("thing").exists());
957 remove_empty_ancestors(&child, &base);
958 assert!(!base.join("fix").join("thing").exists());
959 assert!(
960 !base.join("fix").exists(),
961 "all empty ancestors should be removed"
962 );
963 assert!(base.exists(), "base directory should be preserved");
964 }
965
966 #[test]
967 fn test_remove_empty_ancestors_stops_at_non_empty_parent() {
968 let tmp = TempDir::new().unwrap();
969 let base = tmp.path().join("worktrees");
970 let branch_dir = base.join("my-branch");
971 let child = branch_dir.join("zed");
972 let sibling = branch_dir.join("other-file.txt");
973
974 std::fs::create_dir_all(&child).unwrap();
975 std::fs::write(&sibling, "content").unwrap();
976 std::fs::remove_dir(&child).unwrap();
977
978 remove_empty_ancestors(&child, &base);
979 assert!(branch_dir.exists(), "non-empty parent should be preserved");
980 assert!(sibling.exists());
981 }
982
983 #[test]
984 fn test_remove_empty_ancestors_not_an_ancestor() {
985 let tmp = TempDir::new().unwrap();
986 let base = tmp.path().join("worktrees");
987 let unrelated = tmp.path().join("other-place").join("branch").join("zed");
988
989 std::fs::create_dir_all(&base).unwrap();
990 std::fs::create_dir_all(&unrelated).unwrap();
991 std::fs::remove_dir(&unrelated).unwrap();
992
993 let parent = unrelated.parent().unwrap();
994 assert!(parent.exists());
995 remove_empty_ancestors(&unrelated, &base);
996 assert!(parent.exists(), "should not remove dirs outside base");
997 }
998
999 #[test]
1000 fn test_remove_empty_ancestors_child_is_direct_child_of_base() {
1001 let tmp = TempDir::new().unwrap();
1002 let base = tmp.path().join("worktrees");
1003 let child = base.join("zed");
1004
1005 std::fs::create_dir_all(&child).unwrap();
1006 std::fs::remove_dir(&child).unwrap();
1007
1008 remove_empty_ancestors(&child, &base);
1009 assert!(base.exists(), "base directory should be preserved");
1010 }
1011
1012 #[test]
1013 fn test_remove_empty_ancestors_partially_non_empty_chain() {
1014 let tmp = TempDir::new().unwrap();
1015 let base = tmp.path().join("worktrees");
1016 // Structure: base/a/b/c/zed where a/ has another child besides b/
1017 let child = base.join("a").join("b").join("c").join("zed");
1018 let other_in_a = base.join("a").join("other-branch");
1019
1020 std::fs::create_dir_all(&child).unwrap();
1021 std::fs::create_dir_all(&other_in_a).unwrap();
1022 std::fs::remove_dir(&child).unwrap();
1023
1024 remove_empty_ancestors(&child, &base);
1025 assert!(
1026 !base.join("a").join("b").join("c").exists(),
1027 "c/ should be removed (empty)"
1028 );
1029 assert!(
1030 !base.join("a").join("b").exists(),
1031 "b/ should be removed (empty)"
1032 );
1033 assert!(
1034 base.join("a").exists(),
1035 "a/ should be preserved (has other-branch sibling)"
1036 );
1037 assert!(other_in_a.exists());
1038 }
1039
1040 #[gpui::test]
1041 async fn test_build_root_plan_returns_none_for_main_worktree(cx: &mut TestAppContext) {
1042 init_test(cx);
1043
1044 let fs = FakeFs::new(cx.executor());
1045 fs.insert_tree(
1046 "/project",
1047 json!({
1048 ".git": {},
1049 "src": { "main.rs": "fn main() {}" }
1050 }),
1051 )
1052 .await;
1053 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
1054
1055 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
1056
1057 let multi_workspace =
1058 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1059 let workspace = multi_workspace
1060 .read_with(cx, |mw, _cx| mw.workspace().clone())
1061 .unwrap();
1062
1063 cx.run_until_parked();
1064
1065 // The main worktree should NOT produce a root plan.
1066 workspace.read_with(cx, |_workspace, cx| {
1067 let plan = build_root_plan(
1068 Path::new("/project"),
1069 None,
1070 std::slice::from_ref(&workspace),
1071 cx,
1072 );
1073 assert!(
1074 plan.is_none(),
1075 "build_root_plan should return None for a main worktree",
1076 );
1077 });
1078 }
1079
1080 #[gpui::test]
1081 async fn test_build_root_plan_returns_some_for_linked_worktree(cx: &mut TestAppContext) {
1082 init_test(cx);
1083
1084 let fs = FakeFs::new(cx.executor());
1085 fs.insert_tree(
1086 "/project",
1087 json!({
1088 ".git": {},
1089 "src": { "main.rs": "fn main() {}" }
1090 }),
1091 )
1092 .await;
1093 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
1094 fs.insert_branches(Path::new("/project/.git"), &["main", "feature"]);
1095
1096 fs.add_linked_worktree_for_repo(
1097 Path::new("/project/.git"),
1098 true,
1099 GitWorktree {
1100 path: PathBuf::from("/worktrees/project/feature/project"),
1101 ref_name: Some("refs/heads/feature".into()),
1102 sha: "abc123".into(),
1103 is_main: false,
1104 is_bare: false,
1105 },
1106 )
1107 .await;
1108
1109 let project = Project::test(
1110 fs.clone(),
1111 [
1112 Path::new("/project"),
1113 Path::new("/worktrees/project/feature/project"),
1114 ],
1115 cx,
1116 )
1117 .await;
1118 project
1119 .update(cx, |project, cx| project.git_scans_complete(cx))
1120 .await;
1121
1122 let multi_workspace =
1123 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1124 let workspace = multi_workspace
1125 .read_with(cx, |mw, _cx| mw.workspace().clone())
1126 .unwrap();
1127
1128 cx.run_until_parked();
1129
1130 workspace.read_with(cx, |_workspace, cx| {
1131 // The linked worktree SHOULD produce a root plan.
1132 let plan = build_root_plan(
1133 Path::new("/worktrees/project/feature/project"),
1134 None,
1135 std::slice::from_ref(&workspace),
1136 cx,
1137 );
1138 assert!(
1139 plan.is_some(),
1140 "build_root_plan should return Some for a linked worktree",
1141 );
1142 let plan = plan.unwrap();
1143 assert_eq!(
1144 plan.root_path,
1145 PathBuf::from("/worktrees/project/feature/project")
1146 );
1147 assert_eq!(plan.main_repo_path, PathBuf::from("/project"));
1148
1149 // The main worktree should still return None.
1150 let main_plan = build_root_plan(
1151 Path::new("/project"),
1152 None,
1153 std::slice::from_ref(&workspace),
1154 cx,
1155 );
1156 assert!(
1157 main_plan.is_none(),
1158 "build_root_plan should return None for the main worktree \
1159 even when a linked worktree exists",
1160 );
1161 });
1162 }
1163
1164 #[gpui::test]
1165 async fn test_build_root_plan_returns_none_for_external_linked_worktree(
1166 cx: &mut TestAppContext,
1167 ) {
1168 init_test(cx);
1169
1170 let fs = FakeFs::new(cx.executor());
1171 fs.insert_tree(
1172 "/project",
1173 json!({
1174 ".git": {},
1175 "src": { "main.rs": "fn main() {}" }
1176 }),
1177 )
1178 .await;
1179 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
1180 fs.insert_branches(Path::new("/project/.git"), &["main", "feature"]);
1181
1182 fs.add_linked_worktree_for_repo(
1183 Path::new("/project/.git"),
1184 true,
1185 GitWorktree {
1186 path: PathBuf::from("/external-worktree"),
1187 ref_name: Some("refs/heads/feature".into()),
1188 sha: "abc123".into(),
1189 is_main: false,
1190 is_bare: false,
1191 },
1192 )
1193 .await;
1194
1195 let project = Project::test(
1196 fs.clone(),
1197 [Path::new("/project"), Path::new("/external-worktree")],
1198 cx,
1199 )
1200 .await;
1201 project
1202 .update(cx, |project, cx| project.git_scans_complete(cx))
1203 .await;
1204
1205 let multi_workspace =
1206 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1207 let workspace = multi_workspace
1208 .read_with(cx, |mw, _cx| mw.workspace().clone())
1209 .unwrap();
1210
1211 cx.run_until_parked();
1212
1213 workspace.read_with(cx, |_workspace, cx| {
1214 let plan = build_root_plan(
1215 Path::new("/external-worktree"),
1216 None,
1217 std::slice::from_ref(&workspace),
1218 cx,
1219 );
1220 assert!(
1221 plan.is_none(),
1222 "build_root_plan should return None for a linked worktree \
1223 outside the Zed-managed worktrees directory",
1224 );
1225 });
1226 }
1227
1228 #[gpui::test]
1229 async fn test_build_root_plan_with_custom_worktree_directory(cx: &mut TestAppContext) {
1230 init_test(cx);
1231
1232 // Override the worktree_directory setting to a non-default location.
1233 // With main repo at /project and setting "../custom-worktrees", the
1234 // resolved base is /custom-worktrees/project.
1235 cx.update(|cx| {
1236 cx.update_global::<SettingsStore, _>(|store, cx| {
1237 store.update_user_settings(cx, |s| {
1238 s.git.get_or_insert(Default::default()).worktree_directory =
1239 Some("../custom-worktrees".to_string());
1240 });
1241 });
1242 });
1243
1244 let fs = FakeFs::new(cx.executor());
1245 fs.insert_tree(
1246 "/project",
1247 json!({
1248 ".git": {},
1249 "src": { "main.rs": "fn main() {}" }
1250 }),
1251 )
1252 .await;
1253 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
1254 fs.insert_branches(Path::new("/project/.git"), &["main", "feature", "feature2"]);
1255
1256 // Worktree inside the custom managed directory.
1257 fs.add_linked_worktree_for_repo(
1258 Path::new("/project/.git"),
1259 true,
1260 GitWorktree {
1261 path: PathBuf::from("/custom-worktrees/project/feature/project"),
1262 ref_name: Some("refs/heads/feature".into()),
1263 sha: "abc123".into(),
1264 is_main: false,
1265 is_bare: false,
1266 },
1267 )
1268 .await;
1269
1270 // Worktree outside the custom managed directory (at the default
1271 // `../worktrees` location, which is not what the setting says).
1272 fs.add_linked_worktree_for_repo(
1273 Path::new("/project/.git"),
1274 true,
1275 GitWorktree {
1276 path: PathBuf::from("/worktrees/project/feature2/project"),
1277 ref_name: Some("refs/heads/feature2".into()),
1278 sha: "def456".into(),
1279 is_main: false,
1280 is_bare: false,
1281 },
1282 )
1283 .await;
1284
1285 let project = Project::test(
1286 fs.clone(),
1287 [
1288 Path::new("/project"),
1289 Path::new("/custom-worktrees/project/feature/project"),
1290 Path::new("/worktrees/project/feature2/project"),
1291 ],
1292 cx,
1293 )
1294 .await;
1295 project
1296 .update(cx, |project, cx| project.git_scans_complete(cx))
1297 .await;
1298
1299 let multi_workspace =
1300 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1301 let workspace = multi_workspace
1302 .read_with(cx, |mw, _cx| mw.workspace().clone())
1303 .unwrap();
1304
1305 cx.run_until_parked();
1306
1307 workspace.read_with(cx, |_workspace, cx| {
1308 // Worktree inside the custom managed directory SHOULD be archivable.
1309 let plan = build_root_plan(
1310 Path::new("/custom-worktrees/project/feature/project"),
1311 None,
1312 std::slice::from_ref(&workspace),
1313 cx,
1314 );
1315 assert!(
1316 plan.is_some(),
1317 "build_root_plan should return Some for a worktree inside \
1318 the custom worktree_directory",
1319 );
1320
1321 // Worktree at the default location SHOULD NOT be archivable
1322 // because the setting points elsewhere.
1323 let plan = build_root_plan(
1324 Path::new("/worktrees/project/feature2/project"),
1325 None,
1326 std::slice::from_ref(&workspace),
1327 cx,
1328 );
1329 assert!(
1330 plan.is_none(),
1331 "build_root_plan should return None for a worktree outside \
1332 the custom worktree_directory, even if it would match the default",
1333 );
1334 });
1335 }
1336
1337 #[gpui::test]
1338 async fn test_remove_root_deletes_directory_and_git_metadata(cx: &mut TestAppContext) {
1339 init_test(cx);
1340
1341 let fs = FakeFs::new(cx.executor());
1342 fs.insert_tree(
1343 "/project",
1344 json!({
1345 ".git": {},
1346 "src": { "main.rs": "fn main() {}" }
1347 }),
1348 )
1349 .await;
1350 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
1351 fs.insert_branches(Path::new("/project/.git"), &["main", "feature"]);
1352
1353 fs.add_linked_worktree_for_repo(
1354 Path::new("/project/.git"),
1355 true,
1356 GitWorktree {
1357 path: PathBuf::from("/worktrees/project/feature/project"),
1358 ref_name: Some("refs/heads/feature".into()),
1359 sha: "abc123".into(),
1360 is_main: false,
1361 is_bare: false,
1362 },
1363 )
1364 .await;
1365
1366 let project = Project::test(
1367 fs.clone(),
1368 [
1369 Path::new("/project"),
1370 Path::new("/worktrees/project/feature/project"),
1371 ],
1372 cx,
1373 )
1374 .await;
1375 project
1376 .update(cx, |project, cx| project.git_scans_complete(cx))
1377 .await;
1378
1379 let multi_workspace =
1380 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1381 let workspace = multi_workspace
1382 .read_with(cx, |mw, _cx| mw.workspace().clone())
1383 .unwrap();
1384
1385 cx.run_until_parked();
1386
1387 // Build the root plan while the worktree is still loaded.
1388 let root = workspace
1389 .read_with(cx, |_workspace, cx| {
1390 build_root_plan(
1391 Path::new("/worktrees/project/feature/project"),
1392 None,
1393 std::slice::from_ref(&workspace),
1394 cx,
1395 )
1396 })
1397 .expect("should produce a root plan for the linked worktree");
1398
1399 assert!(
1400 fs.is_dir(Path::new("/worktrees/project/feature/project"))
1401 .await
1402 );
1403
1404 // Remove the root.
1405 let task = cx.update(|cx| cx.spawn(async move |cx| remove_root(root, cx).await));
1406 task.await.expect("remove_root should succeed");
1407
1408 cx.run_until_parked();
1409
1410 // The FakeFs directory should be gone.
1411 assert!(
1412 !fs.is_dir(Path::new("/worktrees/project/feature/project"))
1413 .await,
1414 "linked worktree directory should be removed from FakeFs"
1415 );
1416 }
1417
1418 #[gpui::test]
1419 async fn test_remove_root_succeeds_when_directory_already_gone(cx: &mut TestAppContext) {
1420 init_test(cx);
1421
1422 let fs = FakeFs::new(cx.executor());
1423 fs.insert_tree(
1424 "/project",
1425 json!({
1426 ".git": {},
1427 "src": { "main.rs": "fn main() {}" }
1428 }),
1429 )
1430 .await;
1431 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
1432 fs.insert_branches(Path::new("/project/.git"), &["main", "feature"]);
1433
1434 fs.add_linked_worktree_for_repo(
1435 Path::new("/project/.git"),
1436 true,
1437 GitWorktree {
1438 path: PathBuf::from("/worktrees/project/feature/project"),
1439 ref_name: Some("refs/heads/feature".into()),
1440 sha: "abc123".into(),
1441 is_main: false,
1442 is_bare: false,
1443 },
1444 )
1445 .await;
1446
1447 let project = Project::test(
1448 fs.clone(),
1449 [
1450 Path::new("/project"),
1451 Path::new("/worktrees/project/feature/project"),
1452 ],
1453 cx,
1454 )
1455 .await;
1456 project
1457 .update(cx, |project, cx| project.git_scans_complete(cx))
1458 .await;
1459
1460 let multi_workspace =
1461 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1462 let workspace = multi_workspace
1463 .read_with(cx, |mw, _cx| mw.workspace().clone())
1464 .unwrap();
1465
1466 cx.run_until_parked();
1467
1468 let root = workspace
1469 .read_with(cx, |_workspace, cx| {
1470 build_root_plan(
1471 Path::new("/worktrees/project/feature/project"),
1472 None,
1473 std::slice::from_ref(&workspace),
1474 cx,
1475 )
1476 })
1477 .expect("should produce a root plan for the linked worktree");
1478
1479 // Manually remove the worktree directory from FakeFs before calling
1480 // remove_root, simulating the directory being deleted externally.
1481 fs.as_ref()
1482 .remove_dir(
1483 Path::new("/worktrees/project/feature/project"),
1484 fs::RemoveOptions {
1485 recursive: true,
1486 ignore_if_not_exists: false,
1487 },
1488 )
1489 .await
1490 .unwrap();
1491 assert!(
1492 !fs.as_ref()
1493 .is_dir(Path::new("/worktrees/project/feature/project"))
1494 .await
1495 );
1496
1497 // remove_root should still succeed — fs.remove_dir with
1498 // ignore_if_not_exists handles NotFound, and git worktree remove
1499 // handles a missing working tree directory.
1500 let task = cx.update(|cx| cx.spawn(async move |cx| remove_root(root, cx).await));
1501 task.await
1502 .expect("remove_root should succeed even when directory is already gone");
1503 }
1504
1505 #[gpui::test]
1506 async fn test_remove_root_returns_error_and_rolls_back_on_remove_dir_failure(
1507 cx: &mut TestAppContext,
1508 ) {
1509 init_test(cx);
1510
1511 let fs = FakeFs::new(cx.executor());
1512 fs.insert_tree(
1513 "/project",
1514 json!({
1515 ".git": {},
1516 "src": { "main.rs": "fn main() {}" }
1517 }),
1518 )
1519 .await;
1520 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
1521 fs.insert_branches(Path::new("/project/.git"), &["main", "feature"]);
1522
1523 fs.add_linked_worktree_for_repo(
1524 Path::new("/project/.git"),
1525 true,
1526 GitWorktree {
1527 path: PathBuf::from("/worktrees/project/feature/project"),
1528 ref_name: Some("refs/heads/feature".into()),
1529 sha: "abc123".into(),
1530 is_main: false,
1531 is_bare: false,
1532 },
1533 )
1534 .await;
1535
1536 let project = Project::test(
1537 fs.clone(),
1538 [
1539 Path::new("/project"),
1540 Path::new("/worktrees/project/feature/project"),
1541 ],
1542 cx,
1543 )
1544 .await;
1545 project
1546 .update(cx, |project, cx| project.git_scans_complete(cx))
1547 .await;
1548
1549 let multi_workspace =
1550 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1551 let workspace = multi_workspace
1552 .read_with(cx, |mw, _cx| mw.workspace().clone())
1553 .unwrap();
1554
1555 cx.run_until_parked();
1556
1557 let root = workspace
1558 .read_with(cx, |_workspace, cx| {
1559 build_root_plan(
1560 Path::new("/worktrees/project/feature/project"),
1561 None,
1562 std::slice::from_ref(&workspace),
1563 cx,
1564 )
1565 })
1566 .expect("should produce a root plan for the linked worktree");
1567
1568 // Replace the worktree directory with a file so that fs.remove_dir
1569 // fails with a "not a directory" error.
1570 let worktree_path = Path::new("/worktrees/project/feature/project");
1571 fs.remove_dir(
1572 worktree_path,
1573 fs::RemoveOptions {
1574 recursive: true,
1575 ignore_if_not_exists: false,
1576 },
1577 )
1578 .await
1579 .unwrap();
1580 fs.create_file(worktree_path, fs::CreateOptions::default())
1581 .await
1582 .unwrap();
1583 assert!(
1584 fs.is_file(worktree_path).await,
1585 "path should now be a file, not a directory"
1586 );
1587
1588 let task = cx.update(|cx| cx.spawn(async move |cx| remove_root(root, cx).await));
1589 let result = task.await;
1590
1591 assert!(
1592 result.is_err(),
1593 "remove_root should return an error when fs.remove_dir fails"
1594 );
1595 let error_message = format!("{:#}", result.unwrap_err());
1596 assert!(
1597 error_message.contains("failed to delete worktree directory"),
1598 "error should mention the directory deletion failure, got: {error_message}"
1599 );
1600
1601 cx.run_until_parked();
1602
1603 // After rollback, the worktree should be re-added to the project.
1604 let has_worktree = project.read_with(cx, |project, cx| {
1605 project
1606 .worktrees(cx)
1607 .any(|wt| wt.read(cx).abs_path().as_ref() == worktree_path)
1608 });
1609 assert!(
1610 has_worktree,
1611 "rollback should have re-added the worktree to the project"
1612 );
1613 }
1614}