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