thread_worktree_archive.rs

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