thread_worktree_archive.rs

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