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