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}