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 settings::Settings as _;
 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. Only clean it up if it's inside Zed's managed
265    // worktrees directory — we don't want to delete user-created directories
266    // that happen to be empty.
267    if let Some(parent) = root.root_path.parent() {
268        let main_repo_path = root.main_repo_path.clone();
269        let managed_dir = cx.update(|cx| {
270            let setting = &project::project_settings::ProjectSettings::get_global(cx)
271                .git
272                .worktree_directory;
273            project::git_store::worktrees_directory_for_repo(&main_repo_path, setting).ok()
274        });
275        if let Some(managed_dir) = managed_dir {
276            if parent.starts_with(&managed_dir) {
277                remove_empty_dir_if_managed(parent, &managed_dir, cx).await;
278            }
279        }
280    }
281
282    Ok(())
283}
284
285/// Removes a directory only if it exists and is empty. Stops at (does not
286/// remove) `managed_root`, which is the base worktrees directory.
287async fn remove_empty_dir_if_managed(path: &Path, managed_root: &Path, cx: &mut AsyncApp) {
288    if path == managed_root || !path.starts_with(managed_root) {
289        return;
290    }
291    let Some(app_state) = current_app_state(cx) else {
292        return;
293    };
294    let is_empty = match app_state.fs.read_dir(path).await {
295        Ok(mut entries) => futures::StreamExt::next(&mut entries).await.is_none(),
296        Err(_) => return,
297    };
298    if is_empty {
299        app_state
300            .fs
301            .remove_dir(
302                path,
303                fs::RemoveOptions {
304                    recursive: false,
305                    ignore_if_not_exists: true,
306                },
307            )
308            .await
309            .log_err();
310    }
311}
312
313/// Finds a live `Repository` entity for the given path, or creates a temporary
314/// `Project::local` to obtain one.
315///
316/// `Repository` entities can only be obtained through a `Project` because
317/// `GitStore` (which creates and manages `Repository` entities) is owned by
318/// `Project`. When no open workspace contains the repo we need, we spin up a
319/// headless `Project::local` just to get a `Repository` handle. The caller
320/// keeps the returned `Option<Entity<Project>>` alive for the duration of the
321/// git operations, then drops it.
322///
323/// Future improvement: decoupling `GitStore` from `Project` so that
324/// `Repository` entities can be created standalone would eliminate this
325/// temporary-project workaround.
326async fn find_or_create_repository(
327    repo_path: &Path,
328    cx: &mut AsyncApp,
329) -> Result<(Entity<Repository>, Option<Entity<Project>>)> {
330    let repo_path_owned = repo_path.to_path_buf();
331    let live_repo = cx.update(|cx| {
332        all_open_workspaces(cx)
333            .into_iter()
334            .flat_map(|workspace| {
335                workspace
336                    .read(cx)
337                    .project()
338                    .read(cx)
339                    .repositories(cx)
340                    .values()
341                    .cloned()
342                    .collect::<Vec<_>>()
343            })
344            .find(|repo| {
345                repo.read(cx).snapshot().work_directory_abs_path.as_ref()
346                    == repo_path_owned.as_path()
347            })
348    });
349
350    if let Some(repo) = live_repo {
351        return Ok((repo, None));
352    }
353
354    let app_state =
355        current_app_state(cx).context("no app state available for temporary project")?;
356    let temp_project = cx.update(|cx| {
357        Project::local(
358            app_state.client.clone(),
359            app_state.node_runtime.clone(),
360            app_state.user_store.clone(),
361            app_state.languages.clone(),
362            app_state.fs.clone(),
363            None,
364            LocalProjectFlags::default(),
365            cx,
366        )
367    });
368
369    let repo_path_for_worktree = repo_path.to_path_buf();
370    let create_worktree = temp_project.update(cx, |project, cx| {
371        project.create_worktree(repo_path_for_worktree, true, cx)
372    });
373    let _worktree = create_worktree.await?;
374    let initial_scan = temp_project.read_with(cx, |project, cx| project.wait_for_initial_scan(cx));
375    initial_scan.await;
376
377    let repo_path_for_find = repo_path.to_path_buf();
378    let repo = temp_project
379        .update(cx, |project, cx| {
380            project
381                .repositories(cx)
382                .values()
383                .find(|repo| {
384                    repo.read(cx).snapshot().work_directory_abs_path.as_ref()
385                        == repo_path_for_find.as_path()
386                })
387                .cloned()
388        })
389        .context("failed to resolve temporary repository handle")?;
390
391    let barrier = repo.update(cx, |repo: &mut Repository, _cx| repo.barrier());
392    barrier
393        .await
394        .map_err(|_| anyhow!("temporary repository barrier canceled"))?;
395    Ok((repo, Some(temp_project)))
396}
397
398/// Re-adds the worktree to every affected project after a failed
399/// [`remove_root`].
400async fn rollback_root(root: &RootPlan, cx: &mut AsyncApp) {
401    for affected in &root.affected_projects {
402        let task = affected.project.update(cx, |project, cx| {
403            project.create_worktree(root.root_path.clone(), true, cx)
404        });
405        task.await.log_err();
406    }
407}
408
409/// Saves the worktree's full git state so it can be restored later.
410///
411/// This creates two detached commits (via [`create_archive_checkpoint`] on
412/// the `GitRepository` trait) that capture the staged and unstaged state
413/// without moving any branch ref. The commits are:
414///   - "WIP staged": a tree matching the current index, parented on HEAD
415///   - "WIP unstaged": a tree with all files (including untracked),
416///     parented on the staged commit
417///
418/// After creating the commits, this function:
419///   1. Records the commit SHAs, branch name, and paths in a DB record.
420///   2. Links every thread referencing this worktree to that record.
421///   3. Creates a git ref on the main repo to prevent GC of the commits.
422///
423/// On success, returns the archived worktree DB row ID for rollback.
424pub async fn persist_worktree_state(root: &RootPlan, cx: &mut AsyncApp) -> Result<i64> {
425    let (worktree_repo, _temp_worktree_project) = match &root.worktree_repo {
426        Some(worktree_repo) => (worktree_repo.clone(), None),
427        None => find_or_create_repository(&root.root_path, cx).await?,
428    };
429
430    let original_commit_hash = worktree_repo
431        .update(cx, |repo, _cx| repo.head_sha())
432        .await
433        .map_err(|_| anyhow!("head_sha canceled"))?
434        .context("failed to read original HEAD SHA")?
435        .context("HEAD SHA is None")?;
436
437    // Create two detached WIP commits without moving the branch.
438    let checkpoint_rx = worktree_repo.update(cx, |repo, _cx| repo.create_archive_checkpoint());
439    let (staged_commit_hash, unstaged_commit_hash) = checkpoint_rx
440        .await
441        .map_err(|_| anyhow!("create_archive_checkpoint canceled"))?
442        .context("failed to create archive checkpoint")?;
443
444    // Create DB record
445    let store = cx.update(|cx| ThreadMetadataStore::global(cx));
446    let worktree_path_str = root.root_path.to_string_lossy().to_string();
447    let main_repo_path_str = root.main_repo_path.to_string_lossy().to_string();
448    let branch_name = root.branch_name.clone().or_else(|| {
449        worktree_repo.read_with(cx, |repo, _cx| {
450            repo.snapshot()
451                .branch
452                .as_ref()
453                .map(|branch| branch.name().to_string())
454        })
455    });
456
457    let db_result = store
458        .read_with(cx, |store, cx| {
459            store.create_archived_worktree(
460                worktree_path_str.clone(),
461                main_repo_path_str.clone(),
462                branch_name.clone(),
463                staged_commit_hash.clone(),
464                unstaged_commit_hash.clone(),
465                original_commit_hash.clone(),
466                cx,
467            )
468        })
469        .await
470        .context("failed to create archived worktree DB record");
471    let archived_worktree_id = match db_result {
472        Ok(id) => id,
473        Err(error) => {
474            return Err(error);
475        }
476    };
477
478    // Link all threads on this worktree to the archived record
479    let session_ids: Vec<acp::SessionId> = store.read_with(cx, |store, _cx| {
480        store
481            .entries()
482            .filter(|thread| {
483                thread
484                    .folder_paths()
485                    .paths()
486                    .iter()
487                    .any(|p| p.as_path() == root.root_path)
488            })
489            .map(|thread| thread.session_id.clone())
490            .collect()
491    });
492
493    for session_id in &session_ids {
494        let link_result = store
495            .read_with(cx, |store, cx| {
496                store.link_thread_to_archived_worktree(
497                    session_id.0.to_string(),
498                    archived_worktree_id,
499                    cx,
500                )
501            })
502            .await;
503        if let Err(error) = link_result {
504            if let Err(delete_error) = store
505                .read_with(cx, |store, cx| {
506                    store.delete_archived_worktree(archived_worktree_id, cx)
507                })
508                .await
509            {
510                log::error!(
511                    "Failed to delete archived worktree DB record during link rollback: \
512                     {delete_error:#}"
513                );
514            }
515            return Err(error.context("failed to link thread to archived worktree"));
516        }
517    }
518
519    // Create git ref on main repo to prevent GC of the detached commits.
520    // This is fatal: without the ref, git gc will eventually collect the
521    // WIP commits and a later restore will silently fail.
522    let ref_name = archived_worktree_ref_name(archived_worktree_id);
523    let (main_repo, _temp_project) = find_or_create_repository(&root.main_repo_path, cx)
524        .await
525        .context("could not open main repo to create archive ref")?;
526    let rx = main_repo.update(cx, |repo, _cx| {
527        repo.update_ref(ref_name.clone(), unstaged_commit_hash.clone())
528    });
529    rx.await
530        .map_err(|_| anyhow!("update_ref canceled"))
531        .and_then(|r| r)
532        .with_context(|| format!("failed to create ref {ref_name} on main repo"))?;
533    drop(_temp_project);
534
535    Ok(archived_worktree_id)
536}
537
538/// Undoes a successful [`persist_worktree_state`] by deleting the git ref
539/// on the main repo and removing the DB record. Since the WIP commits are
540/// detached (they don't move any branch), no git reset is needed — the
541/// commits will be garbage-collected once the ref is removed.
542pub async fn rollback_persist(archived_worktree_id: i64, root: &RootPlan, cx: &mut AsyncApp) {
543    // Delete the git ref on main repo
544    if let Ok((main_repo, _temp_project)) =
545        find_or_create_repository(&root.main_repo_path, cx).await
546    {
547        let ref_name = archived_worktree_ref_name(archived_worktree_id);
548        let rx = main_repo.update(cx, |repo, _cx| repo.delete_ref(ref_name));
549        rx.await.ok().and_then(|r| r.log_err());
550        drop(_temp_project);
551    }
552
553    // Delete the DB record
554    let store = cx.update(|cx| ThreadMetadataStore::global(cx));
555    if let Err(error) = store
556        .read_with(cx, |store, cx| {
557            store.delete_archived_worktree(archived_worktree_id, cx)
558        })
559        .await
560    {
561        log::error!("Failed to delete archived worktree DB record during rollback: {error:#}");
562    }
563}
564
565/// Restores a previously archived worktree back to disk from its DB record.
566///
567/// Creates the git worktree at the original commit (the branch never moved
568/// during archival since WIP commits are detached), switches to the branch,
569/// then uses [`restore_archive_checkpoint`] to reconstruct the staged/
570/// unstaged state from the WIP commit trees.
571pub async fn restore_worktree_via_git(
572    row: &ArchivedGitWorktree,
573    cx: &mut AsyncApp,
574) -> Result<PathBuf> {
575    let (main_repo, _temp_project) = find_or_create_repository(&row.main_repo_path, cx).await?;
576
577    let worktree_path = &row.worktree_path;
578    let app_state = current_app_state(cx).context("no app state available")?;
579    let already_exists = app_state.fs.metadata(worktree_path).await?.is_some();
580
581    let created_new_worktree = if already_exists {
582        let is_git_worktree =
583            resolve_git_worktree_to_main_repo(app_state.fs.as_ref(), worktree_path)
584                .await
585                .is_some();
586
587        if !is_git_worktree {
588            let rx = main_repo.update(cx, |repo, _cx| repo.repair_worktrees());
589            rx.await
590                .map_err(|_| anyhow!("worktree repair was canceled"))?
591                .context("failed to repair worktrees")?;
592        }
593        false
594    } else {
595        // Create worktree at the original commit — the branch still points
596        // here because archival used detached commits.
597        let rx = main_repo.update(cx, |repo, _cx| {
598            repo.create_worktree_detached(worktree_path.clone(), row.original_commit_hash.clone())
599        });
600        rx.await
601            .map_err(|_| anyhow!("worktree creation was canceled"))?
602            .context("failed to create worktree")?;
603        true
604    };
605
606    let (wt_repo, _temp_wt_project) = match find_or_create_repository(worktree_path, cx).await {
607        Ok(result) => result,
608        Err(error) => {
609            remove_new_worktree_on_error(created_new_worktree, &main_repo, worktree_path, cx).await;
610            return Err(error);
611        }
612    };
613
614    // Switch to the branch. Since the branch was never moved during
615    // archival (WIP commits are detached), it still points at
616    // original_commit_hash, so this is essentially a no-op for HEAD.
617    if let Some(branch_name) = &row.branch_name {
618        let rx = wt_repo.update(cx, |repo, _cx| repo.change_branch(branch_name.clone()));
619        if let Err(checkout_error) = rx.await.map_err(|e| anyhow!("{e}")).and_then(|r| r) {
620            log::debug!(
621                "change_branch('{}') failed: {checkout_error:#}, trying create_branch",
622                branch_name
623            );
624            let rx = wt_repo.update(cx, |repo, _cx| {
625                repo.create_branch(branch_name.clone(), None)
626            });
627            if let Ok(Err(error)) | Err(error) = rx.await.map_err(|e| anyhow!("{e}")) {
628                log::warn!(
629                    "Could not create branch '{}': {error} — \
630                     restored worktree will be in detached HEAD state.",
631                    branch_name
632                );
633            }
634        }
635    }
636
637    // Restore the staged/unstaged state from the WIP commit trees.
638    // read-tree --reset -u applies the unstaged tree (including deletions)
639    // to the working directory, then a bare read-tree sets the index to
640    // the staged tree without touching the working directory.
641    let restore_rx = wt_repo.update(cx, |repo, _cx| {
642        repo.restore_archive_checkpoint(
643            row.staged_commit_hash.clone(),
644            row.unstaged_commit_hash.clone(),
645        )
646    });
647    if let Err(error) = restore_rx
648        .await
649        .map_err(|_| anyhow!("restore_archive_checkpoint canceled"))
650        .and_then(|r| r)
651    {
652        remove_new_worktree_on_error(created_new_worktree, &main_repo, worktree_path, cx).await;
653        return Err(error.context("failed to restore archive checkpoint"));
654    }
655
656    Ok(worktree_path.clone())
657}
658
659async fn remove_new_worktree_on_error(
660    created_new_worktree: bool,
661    main_repo: &Entity<Repository>,
662    worktree_path: &PathBuf,
663    cx: &mut AsyncApp,
664) {
665    if created_new_worktree {
666        let rx = main_repo.update(cx, |repo, _cx| {
667            repo.remove_worktree(worktree_path.clone(), true)
668        });
669        rx.await.ok().and_then(|r| r.log_err());
670    }
671}
672
673/// Deletes the git ref and DB records for a single archived worktree.
674/// Used when an archived worktree is no longer referenced by any thread.
675pub async fn cleanup_archived_worktree_record(row: &ArchivedGitWorktree, cx: &mut AsyncApp) {
676    // Delete the git ref from the main repo
677    if let Ok((main_repo, _temp_project)) = find_or_create_repository(&row.main_repo_path, cx).await
678    {
679        let ref_name = archived_worktree_ref_name(row.id);
680        let rx = main_repo.update(cx, |repo, _cx| repo.delete_ref(ref_name));
681        match rx.await {
682            Ok(Ok(())) => {}
683            Ok(Err(error)) => log::warn!("Failed to delete archive ref: {error}"),
684            Err(_) => log::warn!("Archive ref deletion was canceled"),
685        }
686        // Keep _temp_project alive until after the await so the headless project isn't dropped mid-operation
687        drop(_temp_project);
688    }
689
690    // Delete the DB records
691    let store = cx.update(|cx| ThreadMetadataStore::global(cx));
692    store
693        .read_with(cx, |store, cx| store.delete_archived_worktree(row.id, cx))
694        .await
695        .log_err();
696}
697
698/// Cleans up all archived worktree data associated with a thread being deleted.
699///
700/// This unlinks the thread from all its archived worktrees and, for any
701/// archived worktree that is no longer referenced by any other thread,
702/// deletes the git ref and DB records.
703pub async fn cleanup_thread_archived_worktrees(session_id: &acp::SessionId, cx: &mut AsyncApp) {
704    let store = cx.update(|cx| ThreadMetadataStore::global(cx));
705
706    let archived_worktrees = store
707        .read_with(cx, |store, cx| {
708            store.get_archived_worktrees_for_thread(session_id.0.to_string(), cx)
709        })
710        .await;
711    let archived_worktrees = match archived_worktrees {
712        Ok(rows) => rows,
713        Err(error) => {
714            log::error!(
715                "Failed to fetch archived worktrees for thread {}: {error:#}",
716                session_id.0
717            );
718            return;
719        }
720    };
721
722    if archived_worktrees.is_empty() {
723        return;
724    }
725
726    if let Err(error) = store
727        .read_with(cx, |store, cx| {
728            store.unlink_thread_from_all_archived_worktrees(session_id.0.to_string(), cx)
729        })
730        .await
731    {
732        log::error!(
733            "Failed to unlink thread {} from archived worktrees: {error:#}",
734            session_id.0
735        );
736        return;
737    }
738
739    for row in &archived_worktrees {
740        let still_referenced = store
741            .read_with(cx, |store, cx| {
742                store.is_archived_worktree_referenced(row.id, cx)
743            })
744            .await;
745        match still_referenced {
746            Ok(true) => {}
747            Ok(false) => {
748                cleanup_archived_worktree_record(row, cx).await;
749            }
750            Err(error) => {
751                log::error!(
752                    "Failed to check if archived worktree {} is still referenced: {error:#}",
753                    row.id
754                );
755            }
756        }
757    }
758}
759
760/// Collects every `Workspace` entity across all open `MultiWorkspace` windows.
761pub fn all_open_workspaces(cx: &App) -> Vec<Entity<Workspace>> {
762    cx.windows()
763        .into_iter()
764        .filter_map(|window| window.downcast::<MultiWorkspace>())
765        .flat_map(|multi_workspace| {
766            multi_workspace
767                .read(cx)
768                .map(|multi_workspace| multi_workspace.workspaces().cloned().collect::<Vec<_>>())
769                .unwrap_or_default()
770        })
771        .collect()
772}
773
774fn current_app_state(cx: &mut AsyncApp) -> Option<Arc<AppState>> {
775    cx.update(|cx| {
776        all_open_workspaces(cx)
777            .into_iter()
778            .next()
779            .map(|workspace| workspace.read(cx).app_state().clone())
780    })
781}