thread_worktree_archive.rs

   1use std::{
   2    path::{Path, PathBuf},
   3    sync::Arc,
   4};
   5
   6use anyhow::{Context as _, Result, anyhow};
   7use fs::{Fs, RemoveOptions};
   8use gpui::{App, AsyncApp, Entity, Task};
   9use project::{
  10    LocalProjectFlags, Project, WorktreeId,
  11    git_store::{Repository, resolve_git_worktree_to_main_repo, worktrees_directory_for_repo},
  12    project_settings::ProjectSettings,
  13};
  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}
  52
  53/// A `Project` that references a worktree being archived, paired with the
  54/// `WorktreeId` it uses for that worktree.
  55///
  56/// The same worktree path can appear in multiple open workspaces/projects
  57/// (e.g. when the user has two windows open that both include the same
  58/// linked worktree). Each one needs to call `remove_worktree` and wait for
  59/// the release during [`remove_root`], otherwise the project would still
  60/// hold a reference to the directory and `git worktree remove` would fail.
  61#[derive(Clone)]
  62pub struct AffectedProject {
  63    pub project: Entity<Project>,
  64    pub worktree_id: WorktreeId,
  65}
  66
  67fn archived_worktree_ref_name(id: i64) -> String {
  68    format!("refs/archived-worktrees/{}", id)
  69}
  70
  71/// Resolves the Zed-managed worktrees base directory for a given repo.
  72///
  73/// This intentionally reads the *global* `git.worktree_directory` setting
  74/// rather than any project-local override, because Zed always uses the
  75/// global value when creating worktrees and the archive check must match.
  76fn worktrees_base_for_repo(main_repo_path: &Path, cx: &App) -> Option<PathBuf> {
  77    let setting = &ProjectSettings::get_global(cx).git.worktree_directory;
  78    worktrees_directory_for_repo(main_repo_path, setting).log_err()
  79}
  80
  81/// Builds a [`RootPlan`] for archiving the git worktree at `path`.
  82///
  83/// This is a synchronous planning step that must run *before* any workspace
  84/// removal, because it needs live project and repository entities that are
  85/// torn down when a workspace is removed. It does three things:
  86///
  87/// 1. Finds every `Project` across all open workspaces that has this
  88///    worktree loaded (`affected_projects`).
  89/// 2. Looks for a `Repository` entity whose snapshot identifies this path
  90///    as a linked worktree (`worktree_repo`), which is needed for the git
  91///    operations in [`persist_worktree_state`].
  92/// 3. Determines the `main_repo_path` — the parent repo that owns this
  93///    linked worktree — needed for both git ref creation and
  94///    `git worktree remove`.
  95///
  96/// Returns `None` if the path is not a linked worktree (main worktrees
  97/// cannot be archived to disk) or if no open project has it loaded.
  98pub fn build_root_plan(
  99    path: &Path,
 100    workspaces: &[Entity<Workspace>],
 101    cx: &App,
 102) -> Option<RootPlan> {
 103    let path = path.to_path_buf();
 104
 105    let affected_projects = workspaces
 106        .iter()
 107        .filter_map(|workspace| {
 108            let project = workspace.read(cx).project().clone();
 109            let worktree = project
 110                .read(cx)
 111                .visible_worktrees(cx)
 112                .find(|worktree| worktree.read(cx).abs_path().as_ref() == path.as_path())?;
 113            let worktree_id = worktree.read(cx).id();
 114            Some(AffectedProject {
 115                project,
 116                worktree_id,
 117            })
 118        })
 119        .collect::<Vec<_>>();
 120
 121    if affected_projects.is_empty() {
 122        return None;
 123    }
 124
 125    let linked_repo = workspaces
 126        .iter()
 127        .flat_map(|workspace| {
 128            workspace
 129                .read(cx)
 130                .project()
 131                .read(cx)
 132                .repositories(cx)
 133                .values()
 134                .cloned()
 135                .collect::<Vec<_>>()
 136        })
 137        .find_map(|repo| {
 138            let snapshot = repo.read(cx).snapshot();
 139            (snapshot.is_linked_worktree()
 140                && snapshot.work_directory_abs_path.as_ref() == path.as_path())
 141            .then_some((snapshot, repo))
 142        });
 143
 144    // Only linked worktrees can be archived to disk via `git worktree remove`.
 145    // Main worktrees must be left alone — git refuses to remove them.
 146    let (linked_snapshot, repo) = linked_repo?;
 147    let main_repo_path = linked_snapshot.original_repo_abs_path.to_path_buf();
 148
 149    // Only archive worktrees that live inside the Zed-managed worktrees
 150    // directory (configured via `git.worktree_directory`). Worktrees the
 151    // user created outside that directory should be left untouched.
 152    let worktrees_base = worktrees_base_for_repo(&main_repo_path, cx)?;
 153    if !path.starts_with(&worktrees_base) {
 154        return None;
 155    }
 156
 157    let branch_name = linked_snapshot
 158        .branch
 159        .as_ref()
 160        .map(|branch| branch.name().to_string());
 161    Some(RootPlan {
 162        root_path: path,
 163        main_repo_path,
 164        affected_projects,
 165        worktree_repo: repo,
 166        branch_name,
 167    })
 168}
 169
 170/// Removes a worktree from all affected projects and deletes it from disk
 171/// via `git worktree remove`.
 172///
 173/// This is the destructive counterpart to [`persist_worktree_state`]. It
 174/// first detaches the worktree from every [`AffectedProject`], waits for
 175/// each project to fully release it, then asks the main repository to
 176/// delete the worktree directory. If the git removal fails, the worktree
 177/// is re-added to each project via [`rollback_root`].
 178pub async fn remove_root(root: RootPlan, fs: Arc<dyn Fs>, cx: &mut AsyncApp) -> Result<()> {
 179    let release_tasks: Vec<_> = root
 180        .affected_projects
 181        .iter()
 182        .map(|affected| {
 183            let project = affected.project.clone();
 184            let worktree_id = affected.worktree_id;
 185            project.update(cx, |project, cx| {
 186                let wait = project.wait_for_worktree_release(worktree_id, cx);
 187                project.remove_worktree(worktree_id, cx);
 188                wait
 189            })
 190        })
 191        .collect();
 192
 193    if let Err(error) = remove_root_after_worktree_removal(&root, fs, release_tasks, cx).await {
 194        rollback_root(&root, cx).await;
 195        return Err(error);
 196    }
 197
 198    Ok(())
 199}
 200
 201async fn remove_root_after_worktree_removal(
 202    root: &RootPlan,
 203    fs: Arc<dyn Fs>,
 204    release_tasks: Vec<Task<Result<()>>>,
 205    cx: &mut AsyncApp,
 206) -> Result<()> {
 207    for task in release_tasks {
 208        if let Err(error) = task.await {
 209            log::error!("Failed waiting for worktree release: {error:#}");
 210        }
 211    }
 212
 213    // Delete the directory ourselves first, then tell git to clean up the
 214    // metadata. This avoids a problem where `git worktree remove` can
 215    // remove the metadata in `.git/worktrees/<name>` but fail to delete
 216    // the directory (git continues past directory-removal errors), leaving
 217    // an orphaned folder on disk. By deleting the directory first, we
 218    // guarantee it's gone, and `git worktree remove --force` with a
 219    // missing working tree just cleans up the admin entry.
 220    fs.remove_dir(
 221        &root.root_path,
 222        RemoveOptions {
 223            recursive: true,
 224            ignore_if_not_exists: true,
 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;
 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 fs_clone = fs.clone();
1313        let task = cx.update(|cx| cx.spawn(async move |cx| remove_root(root, fs_clone, cx).await));
1314        task.await.expect("remove_root should succeed");
1315
1316        cx.run_until_parked();
1317
1318        // The FakeFs directory should be gone.
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 — fs.remove_dir with
1405        // ignore_if_not_exists handles NotFound, and git worktree remove
1406        // handles a missing working tree directory.
1407        let fs_clone = fs.clone();
1408        let task = cx.update(|cx| cx.spawn(async move |cx| remove_root(root, fs_clone, cx).await));
1409        task.await
1410            .expect("remove_root should succeed even when directory is already gone");
1411    }
1412
1413    #[gpui::test]
1414    async fn test_remove_root_returns_error_and_rolls_back_on_remove_dir_failure(
1415        cx: &mut TestAppContext,
1416    ) {
1417        init_test(cx);
1418
1419        let fs = FakeFs::new(cx.executor());
1420        fs.insert_tree(
1421            "/project",
1422            json!({
1423                ".git": {},
1424                "src": { "main.rs": "fn main() {}" }
1425            }),
1426        )
1427        .await;
1428        fs.set_branch_name(Path::new("/project/.git"), Some("main"));
1429        fs.insert_branches(Path::new("/project/.git"), &["main", "feature"]);
1430
1431        fs.add_linked_worktree_for_repo(
1432            Path::new("/project/.git"),
1433            true,
1434            GitWorktree {
1435                path: PathBuf::from("/worktrees/project/feature/project"),
1436                ref_name: Some("refs/heads/feature".into()),
1437                sha: "abc123".into(),
1438                is_main: false,
1439                is_bare: false,
1440            },
1441        )
1442        .await;
1443
1444        let project = Project::test(
1445            fs.clone(),
1446            [
1447                Path::new("/project"),
1448                Path::new("/worktrees/project/feature/project"),
1449            ],
1450            cx,
1451        )
1452        .await;
1453        project
1454            .update(cx, |project, cx| project.git_scans_complete(cx))
1455            .await;
1456
1457        let multi_workspace =
1458            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1459        let workspace = multi_workspace
1460            .read_with(cx, |mw, _cx| mw.workspace().clone())
1461            .unwrap();
1462
1463        cx.run_until_parked();
1464
1465        let root = workspace
1466            .read_with(cx, |_workspace, cx| {
1467                build_root_plan(
1468                    Path::new("/worktrees/project/feature/project"),
1469                    std::slice::from_ref(&workspace),
1470                    cx,
1471                )
1472            })
1473            .expect("should produce a root plan for the linked worktree");
1474
1475        // Replace the worktree directory with a file so that fs.remove_dir
1476        // fails with a "not a directory" error.
1477        let worktree_path = Path::new("/worktrees/project/feature/project");
1478        fs.remove_dir(
1479            worktree_path,
1480            fs::RemoveOptions {
1481                recursive: true,
1482                ignore_if_not_exists: false,
1483            },
1484        )
1485        .await
1486        .unwrap();
1487        fs.create_file(worktree_path, fs::CreateOptions::default())
1488            .await
1489            .unwrap();
1490        assert!(
1491            fs.is_file(worktree_path).await,
1492            "path should now be a file, not a directory"
1493        );
1494
1495        let fs_clone = fs.clone();
1496        let task = cx.update(|cx| cx.spawn(async move |cx| remove_root(root, fs_clone, cx).await));
1497        let result = task.await;
1498
1499        assert!(
1500            result.is_err(),
1501            "remove_root should return an error when fs.remove_dir fails"
1502        );
1503        let error_message = format!("{:#}", result.unwrap_err());
1504        assert!(
1505            error_message.contains("failed to delete worktree directory"),
1506            "error should mention the directory deletion failure, got: {error_message}"
1507        );
1508
1509        cx.run_until_parked();
1510
1511        // After rollback, the worktree should be re-added to the project.
1512        let has_worktree = project.read_with(cx, |project, cx| {
1513            project
1514                .worktrees(cx)
1515                .any(|wt| wt.read(cx).abs_path().as_ref() == worktree_path)
1516        });
1517        assert!(
1518            has_worktree,
1519            "rollback should have re-added the worktree to the project"
1520        );
1521    }
1522}