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},
 11};
 12use util::ResultExt;
 13use workspace::{AppState, MultiWorkspace, Workspace};
 14
 15use crate::thread_metadata_store::{ArchivedGitWorktree, ThreadId, ThreadMetadataStore};
 16
 17/// The plan for archiving a single git worktree root.
 18///
 19/// A thread can have multiple folder paths open, so there may be multiple
 20/// `RootPlan`s per archival operation. Each one captures everything needed to
 21/// persist the worktree's git state and then remove it from disk.
 22///
 23/// All fields are gathered synchronously by [`build_root_plan`] while the
 24/// worktree is still loaded in open projects. This is important because
 25/// workspace removal tears down project and repository entities, making
 26/// them unavailable for the later async persist/remove steps.
 27#[derive(Clone)]
 28pub struct RootPlan {
 29    /// Absolute path of the git worktree on disk.
 30    pub root_path: PathBuf,
 31    /// Absolute path to the main git repository this worktree is linked to.
 32    /// Used both for creating a git ref to prevent GC of WIP commits during
 33    /// [`persist_worktree_state`], and for `git worktree remove` during
 34    /// [`remove_root`].
 35    pub main_repo_path: PathBuf,
 36    /// Every open `Project` that has this worktree loaded, so they can all
 37    /// call `remove_worktree` and release it during [`remove_root`].
 38    /// Multiple projects can reference the same path when the user has the
 39    /// worktree open in more than one workspace.
 40    pub affected_projects: Vec<AffectedProject>,
 41    /// The `Repository` entity for this linked worktree, used to run git
 42    /// commands (create WIP commits, stage files, reset) during
 43    /// [`persist_worktree_state`].
 44    pub worktree_repo: Entity<Repository>,
 45    /// The branch the worktree was on, so it can be restored later.
 46    /// `None` if the worktree was in detached HEAD state.
 47    pub branch_name: Option<String>,
 48}
 49
 50/// A `Project` that references a worktree being archived, paired with the
 51/// `WorktreeId` it uses for that worktree.
 52///
 53/// The same worktree path can appear in multiple open workspaces/projects
 54/// (e.g. when the user has two windows open that both include the same
 55/// linked worktree). Each one needs to call `remove_worktree` and wait for
 56/// the release during [`remove_root`], otherwise the project would still
 57/// hold a reference to the directory and `git worktree remove` would fail.
 58#[derive(Clone)]
 59pub struct AffectedProject {
 60    pub project: Entity<Project>,
 61    pub worktree_id: WorktreeId,
 62}
 63
 64fn archived_worktree_ref_name(id: i64) -> String {
 65    format!("refs/archived-worktrees/{}", id)
 66}
 67
 68/// Builds a [`RootPlan`] for archiving the git worktree at `path`.
 69///
 70/// This is a synchronous planning step that must run *before* any workspace
 71/// removal, because it needs live project and repository entities that are
 72/// torn down when a workspace is removed. It does three things:
 73///
 74/// 1. Finds every `Project` across all open workspaces that has this
 75///    worktree loaded (`affected_projects`).
 76/// 2. Looks for a `Repository` entity whose snapshot identifies this path
 77///    as a linked worktree (`worktree_repo`), which is needed for the git
 78///    operations in [`persist_worktree_state`].
 79/// 3. Determines the `main_repo_path` — the parent repo that owns this
 80///    linked worktree — needed for both git ref creation and
 81///    `git worktree remove`.
 82///
 83/// Returns `None` if the path is not a linked worktree (main worktrees
 84/// cannot be archived to disk) or if no open project has it loaded.
 85pub fn build_root_plan(
 86    path: &Path,
 87    workspaces: &[Entity<Workspace>],
 88    cx: &App,
 89) -> Option<RootPlan> {
 90    let path = path.to_path_buf();
 91
 92    let affected_projects = workspaces
 93        .iter()
 94        .filter_map(|workspace| {
 95            let project = workspace.read(cx).project().clone();
 96            let worktree = project
 97                .read(cx)
 98                .visible_worktrees(cx)
 99                .find(|worktree| worktree.read(cx).abs_path().as_ref() == path.as_path())?;
100            let worktree_id = worktree.read(cx).id();
101            Some(AffectedProject {
102                project,
103                worktree_id,
104            })
105        })
106        .collect::<Vec<_>>();
107
108    if affected_projects.is_empty() {
109        return None;
110    }
111
112    let linked_repo = workspaces
113        .iter()
114        .flat_map(|workspace| {
115            workspace
116                .read(cx)
117                .project()
118                .read(cx)
119                .repositories(cx)
120                .values()
121                .cloned()
122                .collect::<Vec<_>>()
123        })
124        .find_map(|repo| {
125            let snapshot = repo.read(cx).snapshot();
126            (snapshot.is_linked_worktree()
127                && snapshot.work_directory_abs_path.as_ref() == path.as_path())
128            .then_some((snapshot, repo))
129        });
130
131    // Only linked worktrees can be archived to disk via `git worktree remove`.
132    // Main worktrees must be left alone — git refuses to remove them.
133    let (linked_snapshot, repo) = linked_repo?;
134    let main_repo_path = linked_snapshot.original_repo_abs_path.to_path_buf();
135    let branch_name = linked_snapshot
136        .branch
137        .as_ref()
138        .map(|branch| branch.name().to_string());
139    Some(RootPlan {
140        root_path: path,
141        main_repo_path,
142        affected_projects,
143        worktree_repo: repo,
144        branch_name,
145    })
146}
147
148/// Returns `true` if any unarchived thread other than `current_session_id`
149/// references `path` in its folder paths. Used to determine whether a
150/// worktree can safely be removed from disk.
151pub fn path_is_referenced_by_other_unarchived_threads(
152    current_thread_id: ThreadId,
153    path: &Path,
154    cx: &App,
155) -> bool {
156    ThreadMetadataStore::global(cx)
157        .read(cx)
158        .entries()
159        .filter(|thread| thread.thread_id != current_thread_id)
160        .filter(|thread| !thread.archived)
161        .any(|thread| {
162            thread
163                .folder_paths()
164                .paths()
165                .iter()
166                .any(|other_path| other_path.as_path() == path)
167        })
168}
169
170/// Removes a worktree from all affected projects and deletes it from disk
171/// via `git worktree remove`.
172///
173/// This is the destructive counterpart to [`persist_worktree_state`]. It
174/// first detaches the worktree from every [`AffectedProject`], waits for
175/// each project to fully release it, then asks the main repository to
176/// delete the worktree directory. If the git removal fails, the worktree
177/// is re-added to each project via [`rollback_root`].
178pub async fn remove_root(root: RootPlan, cx: &mut AsyncApp) -> Result<()> {
179    let release_tasks: Vec<_> = root
180        .affected_projects
181        .iter()
182        .map(|affected| {
183            let project = affected.project.clone();
184            let worktree_id = affected.worktree_id;
185            project.update(cx, |project, cx| {
186                let wait = project.wait_for_worktree_release(worktree_id, cx);
187                project.remove_worktree(worktree_id, cx);
188                wait
189            })
190        })
191        .collect();
192
193    if let Err(error) = remove_root_after_worktree_removal(&root, release_tasks, cx).await {
194        rollback_root(&root, cx).await;
195        return Err(error);
196    }
197
198    Ok(())
199}
200
201async fn remove_root_after_worktree_removal(
202    root: &RootPlan,
203    release_tasks: Vec<Task<Result<()>>>,
204    cx: &mut AsyncApp,
205) -> Result<()> {
206    for task in release_tasks {
207        if let Err(error) = task.await {
208            log::error!("Failed waiting for worktree release: {error:#}");
209        }
210    }
211
212    let (repo, _temp_project) = find_or_create_repository(&root.main_repo_path, cx).await?;
213    // force=true is required because the working directory is still dirty
214    // — persist_worktree_state captures state into detached commits without
215    // modifying the real index or working tree, so git refuses to delete
216    // the worktree without --force.
217    let receiver = repo.update(cx, |repo: &mut Repository, _cx| {
218        repo.remove_worktree(root.root_path.clone(), true)
219    });
220    let result = receiver
221        .await
222        .map_err(|_| anyhow!("git worktree removal was canceled"))?;
223    // Keep _temp_project alive until after the await so the headless project isn't dropped mid-operation
224    drop(_temp_project);
225    result
226}
227
228/// Finds a live `Repository` entity for the given path, or creates a temporary
229/// `Project::local` to obtain one.
230///
231/// `Repository` entities can only be obtained through a `Project` because
232/// `GitStore` (which creates and manages `Repository` entities) is owned by
233/// `Project`. When no open workspace contains the repo we need, we spin up a
234/// headless `Project::local` just to get a `Repository` handle. The caller
235/// keeps the returned `Option<Entity<Project>>` alive for the duration of the
236/// git operations, then drops it.
237///
238/// Future improvement: decoupling `GitStore` from `Project` so that
239/// `Repository` entities can be created standalone would eliminate this
240/// temporary-project workaround.
241async fn find_or_create_repository(
242    repo_path: &Path,
243    cx: &mut AsyncApp,
244) -> Result<(Entity<Repository>, Option<Entity<Project>>)> {
245    let repo_path_owned = repo_path.to_path_buf();
246    let live_repo = cx.update(|cx| {
247        all_open_workspaces(cx)
248            .into_iter()
249            .flat_map(|workspace| {
250                workspace
251                    .read(cx)
252                    .project()
253                    .read(cx)
254                    .repositories(cx)
255                    .values()
256                    .cloned()
257                    .collect::<Vec<_>>()
258            })
259            .find(|repo| {
260                repo.read(cx).snapshot().work_directory_abs_path.as_ref()
261                    == repo_path_owned.as_path()
262            })
263    });
264
265    if let Some(repo) = live_repo {
266        return Ok((repo, None));
267    }
268
269    let app_state =
270        current_app_state(cx).context("no app state available for temporary project")?;
271    let temp_project = cx.update(|cx| {
272        Project::local(
273            app_state.client.clone(),
274            app_state.node_runtime.clone(),
275            app_state.user_store.clone(),
276            app_state.languages.clone(),
277            app_state.fs.clone(),
278            None,
279            LocalProjectFlags::default(),
280            cx,
281        )
282    });
283
284    let repo_path_for_worktree = repo_path.to_path_buf();
285    let create_worktree = temp_project.update(cx, |project, cx| {
286        project.create_worktree(repo_path_for_worktree, true, cx)
287    });
288    let _worktree = create_worktree.await?;
289    let initial_scan = temp_project.read_with(cx, |project, cx| project.wait_for_initial_scan(cx));
290    initial_scan.await;
291
292    let repo_path_for_find = repo_path.to_path_buf();
293    let repo = temp_project
294        .update(cx, |project, cx| {
295            project
296                .repositories(cx)
297                .values()
298                .find(|repo| {
299                    repo.read(cx).snapshot().work_directory_abs_path.as_ref()
300                        == repo_path_for_find.as_path()
301                })
302                .cloned()
303        })
304        .context("failed to resolve temporary repository handle")?;
305
306    let barrier = repo.update(cx, |repo: &mut Repository, _cx| repo.barrier());
307    barrier
308        .await
309        .map_err(|_| anyhow!("temporary repository barrier canceled"))?;
310    Ok((repo, Some(temp_project)))
311}
312
313/// Re-adds the worktree to every affected project after a failed
314/// [`remove_root`].
315async fn rollback_root(root: &RootPlan, cx: &mut AsyncApp) {
316    for affected in &root.affected_projects {
317        let task = affected.project.update(cx, |project, cx| {
318            project.create_worktree(root.root_path.clone(), true, cx)
319        });
320        task.await.log_err();
321    }
322}
323
324/// Saves the worktree's full git state so it can be restored later.
325///
326/// This creates two detached commits (via [`create_archive_checkpoint`] on
327/// the `GitRepository` trait) that capture the staged and unstaged state
328/// without moving any branch ref. The commits are:
329///   - "WIP staged": a tree matching the current index, parented on HEAD
330///   - "WIP unstaged": a tree with all files (including untracked),
331///     parented on the staged commit
332///
333/// After creating the commits, this function:
334///   1. Records the commit SHAs, branch name, and paths in a DB record.
335///   2. Links every thread referencing this worktree to that record.
336///   3. Creates a git ref on the main repo to prevent GC of the commits.
337///
338/// On success, returns the archived worktree DB row ID for rollback.
339pub async fn persist_worktree_state(root: &RootPlan, cx: &mut AsyncApp) -> Result<i64> {
340    let worktree_repo = root.worktree_repo.clone();
341
342    let original_commit_hash = worktree_repo
343        .update(cx, |repo, _cx| repo.head_sha())
344        .await
345        .map_err(|_| anyhow!("head_sha canceled"))?
346        .context("failed to read original HEAD SHA")?
347        .context("HEAD SHA is None")?;
348
349    // Create two detached WIP commits without moving the branch.
350    let checkpoint_rx = worktree_repo.update(cx, |repo, _cx| repo.create_archive_checkpoint());
351    let (staged_commit_hash, unstaged_commit_hash) = checkpoint_rx
352        .await
353        .map_err(|_| anyhow!("create_archive_checkpoint canceled"))?
354        .context("failed to create archive checkpoint")?;
355
356    // Create DB record
357    let store = cx.update(|cx| ThreadMetadataStore::global(cx));
358    let worktree_path_str = root.root_path.to_string_lossy().to_string();
359    let main_repo_path_str = root.main_repo_path.to_string_lossy().to_string();
360    let branch_name = root.branch_name.clone().or_else(|| {
361        worktree_repo.read_with(cx, |repo, _cx| {
362            repo.snapshot()
363                .branch
364                .as_ref()
365                .map(|branch| branch.name().to_string())
366        })
367    });
368
369    let db_result = store
370        .read_with(cx, |store, cx| {
371            store.create_archived_worktree(
372                worktree_path_str.clone(),
373                main_repo_path_str.clone(),
374                branch_name.clone(),
375                staged_commit_hash.clone(),
376                unstaged_commit_hash.clone(),
377                original_commit_hash.clone(),
378                cx,
379            )
380        })
381        .await
382        .context("failed to create archived worktree DB record");
383    let archived_worktree_id = match db_result {
384        Ok(id) => id,
385        Err(error) => {
386            return Err(error);
387        }
388    };
389
390    // Link all threads on this worktree to the archived record
391    let thread_ids: Vec<ThreadId> = store.read_with(cx, |store, _cx| {
392        store
393            .entries()
394            .filter(|thread| {
395                thread
396                    .folder_paths()
397                    .paths()
398                    .iter()
399                    .any(|p| p.as_path() == root.root_path)
400            })
401            .map(|thread| thread.thread_id)
402            .collect()
403    });
404
405    for thread_id in &thread_ids {
406        let link_result = store
407            .read_with(cx, |store, cx| {
408                store.link_thread_to_archived_worktree(*thread_id, archived_worktree_id, cx)
409            })
410            .await;
411        if let Err(error) = link_result {
412            if let Err(delete_error) = store
413                .read_with(cx, |store, cx| {
414                    store.delete_archived_worktree(archived_worktree_id, cx)
415                })
416                .await
417            {
418                log::error!(
419                    "Failed to delete archived worktree DB record during link rollback: \
420                     {delete_error:#}"
421                );
422            }
423            return Err(error.context("failed to link thread to archived worktree"));
424        }
425    }
426
427    // Create git ref on main repo to prevent GC of the detached commits.
428    // This is fatal: without the ref, git gc will eventually collect the
429    // WIP commits and a later restore will silently fail.
430    let ref_name = archived_worktree_ref_name(archived_worktree_id);
431    let (main_repo, _temp_project) = find_or_create_repository(&root.main_repo_path, cx)
432        .await
433        .context("could not open main repo to create archive ref")?;
434    let rx = main_repo.update(cx, |repo, _cx| {
435        repo.update_ref(ref_name.clone(), unstaged_commit_hash.clone())
436    });
437    rx.await
438        .map_err(|_| anyhow!("update_ref canceled"))
439        .and_then(|r| r)
440        .with_context(|| format!("failed to create ref {ref_name} on main repo"))?;
441    drop(_temp_project);
442
443    Ok(archived_worktree_id)
444}
445
446/// Undoes a successful [`persist_worktree_state`] by deleting the git ref
447/// on the main repo and removing the DB record. Since the WIP commits are
448/// detached (they don't move any branch), no git reset is needed — the
449/// commits will be garbage-collected once the ref is removed.
450pub async fn rollback_persist(archived_worktree_id: i64, root: &RootPlan, cx: &mut AsyncApp) {
451    // Delete the git ref on main repo
452    if let Ok((main_repo, _temp_project)) =
453        find_or_create_repository(&root.main_repo_path, cx).await
454    {
455        let ref_name = archived_worktree_ref_name(archived_worktree_id);
456        let rx = main_repo.update(cx, |repo, _cx| repo.delete_ref(ref_name));
457        rx.await.ok().and_then(|r| r.log_err());
458        drop(_temp_project);
459    }
460
461    // Delete the DB record
462    let store = cx.update(|cx| ThreadMetadataStore::global(cx));
463    if let Err(error) = store
464        .read_with(cx, |store, cx| {
465            store.delete_archived_worktree(archived_worktree_id, cx)
466        })
467        .await
468    {
469        log::error!("Failed to delete archived worktree DB record during rollback: {error:#}");
470    }
471}
472
473/// Restores a previously archived worktree back to disk from its DB record.
474///
475/// Creates the git worktree at the original commit (the branch never moved
476/// during archival since WIP commits are detached), switches to the branch,
477/// then uses [`restore_archive_checkpoint`] to reconstruct the staged/
478/// unstaged state from the WIP commit trees.
479pub async fn restore_worktree_via_git(
480    row: &ArchivedGitWorktree,
481    cx: &mut AsyncApp,
482) -> Result<PathBuf> {
483    let (main_repo, _temp_project) = find_or_create_repository(&row.main_repo_path, cx).await?;
484
485    let worktree_path = &row.worktree_path;
486    let app_state = current_app_state(cx).context("no app state available")?;
487    let already_exists = app_state.fs.metadata(worktree_path).await?.is_some();
488
489    let created_new_worktree = if already_exists {
490        let is_git_worktree =
491            resolve_git_worktree_to_main_repo(app_state.fs.as_ref(), worktree_path)
492                .await
493                .is_some();
494
495        if !is_git_worktree {
496            let rx = main_repo.update(cx, |repo, _cx| repo.repair_worktrees());
497            rx.await
498                .map_err(|_| anyhow!("worktree repair was canceled"))?
499                .context("failed to repair worktrees")?;
500        }
501        false
502    } else {
503        // Create worktree at the original commit — the branch still points
504        // here because archival used detached commits.
505        let rx = main_repo.update(cx, |repo, _cx| {
506            repo.create_worktree_detached(worktree_path.clone(), row.original_commit_hash.clone())
507        });
508        rx.await
509            .map_err(|_| anyhow!("worktree creation was canceled"))?
510            .context("failed to create worktree")?;
511        true
512    };
513
514    let (wt_repo, _temp_wt_project) = match find_or_create_repository(worktree_path, cx).await {
515        Ok(result) => result,
516        Err(error) => {
517            remove_new_worktree_on_error(created_new_worktree, &main_repo, worktree_path, cx).await;
518            return Err(error);
519        }
520    };
521
522    // Switch to the branch. Since the branch was never moved during
523    // archival (WIP commits are detached), it still points at
524    // original_commit_hash, so this is essentially a no-op for HEAD.
525    if let Some(branch_name) = &row.branch_name {
526        let rx = wt_repo.update(cx, |repo, _cx| repo.change_branch(branch_name.clone()));
527        if let Err(checkout_error) = rx.await.map_err(|e| anyhow!("{e}")).and_then(|r| r) {
528            log::debug!(
529                "change_branch('{}') failed: {checkout_error:#}, trying create_branch",
530                branch_name
531            );
532            let rx = wt_repo.update(cx, |repo, _cx| {
533                repo.create_branch(branch_name.clone(), None)
534            });
535            if let Ok(Err(error)) | Err(error) = rx.await.map_err(|e| anyhow!("{e}")) {
536                log::warn!(
537                    "Could not create branch '{}': {error} — \
538                     restored worktree will be in detached HEAD state.",
539                    branch_name
540                );
541            }
542        }
543    }
544
545    // Restore the staged/unstaged state from the WIP commit trees.
546    // read-tree --reset -u applies the unstaged tree (including deletions)
547    // to the working directory, then a bare read-tree sets the index to
548    // the staged tree without touching the working directory.
549    let restore_rx = wt_repo.update(cx, |repo, _cx| {
550        repo.restore_archive_checkpoint(
551            row.staged_commit_hash.clone(),
552            row.unstaged_commit_hash.clone(),
553        )
554    });
555    if let Err(error) = restore_rx
556        .await
557        .map_err(|_| anyhow!("restore_archive_checkpoint canceled"))
558        .and_then(|r| r)
559    {
560        remove_new_worktree_on_error(created_new_worktree, &main_repo, worktree_path, cx).await;
561        return Err(error.context("failed to restore archive checkpoint"));
562    }
563
564    Ok(worktree_path.clone())
565}
566
567async fn remove_new_worktree_on_error(
568    created_new_worktree: bool,
569    main_repo: &Entity<Repository>,
570    worktree_path: &PathBuf,
571    cx: &mut AsyncApp,
572) {
573    if created_new_worktree {
574        let rx = main_repo.update(cx, |repo, _cx| {
575            repo.remove_worktree(worktree_path.clone(), true)
576        });
577        rx.await.ok().and_then(|r| r.log_err());
578    }
579}
580
581/// Deletes the git ref and DB records for a single archived worktree.
582/// Used when an archived worktree is no longer referenced by any thread.
583pub async fn cleanup_archived_worktree_record(row: &ArchivedGitWorktree, cx: &mut AsyncApp) {
584    // Delete the git ref from the main repo
585    if let Ok((main_repo, _temp_project)) = find_or_create_repository(&row.main_repo_path, cx).await
586    {
587        let ref_name = archived_worktree_ref_name(row.id);
588        let rx = main_repo.update(cx, |repo, _cx| repo.delete_ref(ref_name));
589        match rx.await {
590            Ok(Ok(())) => {}
591            Ok(Err(error)) => log::warn!("Failed to delete archive ref: {error}"),
592            Err(_) => log::warn!("Archive ref deletion was canceled"),
593        }
594        // Keep _temp_project alive until after the await so the headless project isn't dropped mid-operation
595        drop(_temp_project);
596    }
597
598    // Delete the DB records
599    let store = cx.update(|cx| ThreadMetadataStore::global(cx));
600    store
601        .read_with(cx, |store, cx| store.delete_archived_worktree(row.id, cx))
602        .await
603        .log_err();
604}
605
606/// Cleans up all archived worktree data associated with a thread being deleted.
607///
608/// This unlinks the thread from all its archived worktrees and, for any
609/// archived worktree that is no longer referenced by any other thread,
610/// deletes the git ref and DB records.
611pub async fn cleanup_thread_archived_worktrees(thread_id: ThreadId, cx: &mut AsyncApp) {
612    let store = cx.update(|cx| ThreadMetadataStore::global(cx));
613
614    let archived_worktrees = store
615        .read_with(cx, |store, cx| {
616            store.get_archived_worktrees_for_thread(thread_id, cx)
617        })
618        .await;
619    let archived_worktrees = match archived_worktrees {
620        Ok(rows) => rows,
621        Err(error) => {
622            log::error!("Failed to fetch archived worktrees for thread {thread_id:?}: {error:#}");
623            return;
624        }
625    };
626
627    if archived_worktrees.is_empty() {
628        return;
629    }
630
631    if let Err(error) = store
632        .read_with(cx, |store, cx| {
633            store.unlink_thread_from_all_archived_worktrees(thread_id, cx)
634        })
635        .await
636    {
637        log::error!("Failed to unlink thread {thread_id:?} from archived worktrees: {error:#}");
638        return;
639    }
640
641    for row in &archived_worktrees {
642        let still_referenced = store
643            .read_with(cx, |store, cx| {
644                store.is_archived_worktree_referenced(row.id, cx)
645            })
646            .await;
647        match still_referenced {
648            Ok(true) => {}
649            Ok(false) => {
650                cleanup_archived_worktree_record(row, cx).await;
651            }
652            Err(error) => {
653                log::error!(
654                    "Failed to check if archived worktree {} is still referenced: {error:#}",
655                    row.id
656                );
657            }
658        }
659    }
660}
661
662/// Collects every `Workspace` entity across all open `MultiWorkspace` windows.
663pub fn all_open_workspaces(cx: &App) -> Vec<Entity<Workspace>> {
664    cx.windows()
665        .into_iter()
666        .filter_map(|window| window.downcast::<MultiWorkspace>())
667        .flat_map(|multi_workspace| {
668            multi_workspace
669                .read(cx)
670                .map(|multi_workspace| multi_workspace.workspaces().cloned().collect::<Vec<_>>())
671                .unwrap_or_default()
672        })
673        .collect()
674}
675
676fn current_app_state(cx: &mut AsyncApp) -> Option<Arc<AppState>> {
677    cx.update(|cx| {
678        all_open_workspaces(cx)
679            .into_iter()
680            .next()
681            .map(|workspace| workspace.read(cx).app_state().clone())
682    })
683}
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688    use fs::FakeFs;
689    use git::repository::Worktree as GitWorktree;
690    use gpui::TestAppContext;
691    use project::Project;
692    use serde_json::json;
693    use settings::SettingsStore;
694    use workspace::MultiWorkspace;
695
696    fn init_test(cx: &mut TestAppContext) {
697        cx.update(|cx| {
698            let settings_store = SettingsStore::test(cx);
699            cx.set_global(settings_store);
700            theme_settings::init(theme::LoadThemes::JustBase, cx);
701            editor::init(cx);
702            release_channel::init(semver::Version::new(0, 0, 0), cx);
703        });
704    }
705
706    #[gpui::test]
707    async fn test_build_root_plan_returns_none_for_main_worktree(cx: &mut TestAppContext) {
708        init_test(cx);
709
710        let fs = FakeFs::new(cx.executor());
711        fs.insert_tree(
712            "/project",
713            json!({
714                ".git": {},
715                "src": { "main.rs": "fn main() {}" }
716            }),
717        )
718        .await;
719        fs.set_branch_name(Path::new("/project/.git"), Some("main"));
720
721        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
722
723        let multi_workspace =
724            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
725        let workspace = multi_workspace
726            .read_with(cx, |mw, _cx| mw.workspace().clone())
727            .unwrap();
728
729        cx.run_until_parked();
730
731        // The main worktree should NOT produce a root plan.
732        workspace.read_with(cx, |_workspace, cx| {
733            let plan = build_root_plan(Path::new("/project"), std::slice::from_ref(&workspace), cx);
734            assert!(
735                plan.is_none(),
736                "build_root_plan should return None for a main worktree",
737            );
738        });
739    }
740
741    #[gpui::test]
742    async fn test_build_root_plan_returns_some_for_linked_worktree(cx: &mut TestAppContext) {
743        init_test(cx);
744
745        let fs = FakeFs::new(cx.executor());
746        fs.insert_tree(
747            "/project",
748            json!({
749                ".git": {},
750                "src": { "main.rs": "fn main() {}" }
751            }),
752        )
753        .await;
754        fs.set_branch_name(Path::new("/project/.git"), Some("main"));
755        fs.insert_branches(Path::new("/project/.git"), &["main", "feature"]);
756
757        fs.add_linked_worktree_for_repo(
758            Path::new("/project/.git"),
759            true,
760            GitWorktree {
761                path: PathBuf::from("/linked-worktree"),
762                ref_name: Some("refs/heads/feature".into()),
763                sha: "abc123".into(),
764                is_main: false,
765            },
766        )
767        .await;
768
769        let project = Project::test(
770            fs.clone(),
771            [Path::new("/project"), Path::new("/linked-worktree")],
772            cx,
773        )
774        .await;
775        project
776            .update(cx, |project, cx| project.git_scans_complete(cx))
777            .await;
778
779        let multi_workspace =
780            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
781        let workspace = multi_workspace
782            .read_with(cx, |mw, _cx| mw.workspace().clone())
783            .unwrap();
784
785        cx.run_until_parked();
786
787        workspace.read_with(cx, |_workspace, cx| {
788            // The linked worktree SHOULD produce a root plan.
789            let plan = build_root_plan(
790                Path::new("/linked-worktree"),
791                std::slice::from_ref(&workspace),
792                cx,
793            );
794            assert!(
795                plan.is_some(),
796                "build_root_plan should return Some for a linked worktree",
797            );
798            let plan = plan.unwrap();
799            assert_eq!(plan.root_path, PathBuf::from("/linked-worktree"));
800            assert_eq!(plan.main_repo_path, PathBuf::from("/project"));
801
802            // The main worktree should still return None.
803            let main_plan =
804                build_root_plan(Path::new("/project"), std::slice::from_ref(&workspace), cx);
805            assert!(
806                main_plan.is_none(),
807                "build_root_plan should return None for the main worktree \
808                 even when a linked worktree exists",
809            );
810        });
811    }
812}