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