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}