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