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