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