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