thread_worktree_archive.rs

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