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 remote::{RemoteConnectionOptions, same_remote_connection_identity};
14use settings::Settings;
15use util::ResultExt;
16use workspace::{AppState, MultiWorkspace, Workspace};
17
18use crate::thread_metadata_store::{ArchivedGitWorktree, ThreadId, ThreadMetadataStore};
19
20/// The plan for archiving a single git worktree root.
21///
22/// A thread can have multiple folder paths open, so there may be multiple
23/// `RootPlan`s per archival operation. Each one captures everything needed to
24/// persist the worktree's git state and then remove it from disk.
25///
26/// All fields are gathered synchronously by [`build_root_plan`] while the
27/// worktree is still loaded in open projects. This is important because
28/// workspace removal tears down project and repository entities, making
29/// them unavailable for the later async persist/remove steps.
30#[derive(Clone)]
31pub struct RootPlan {
32 /// Absolute path of the git worktree on disk.
33 pub root_path: PathBuf,
34 /// Absolute path to the main git repository this worktree is linked to.
35 /// Used both for creating a git ref to prevent GC of WIP commits during
36 /// [`persist_worktree_state`], and for `git worktree remove` during
37 /// [`remove_root`].
38 pub main_repo_path: PathBuf,
39 /// Every open `Project` that has this worktree loaded, so they can all
40 /// call `remove_worktree` and release it during [`remove_root`].
41 /// Multiple projects can reference the same path when the user has the
42 /// worktree open in more than one workspace.
43 pub affected_projects: Vec<AffectedProject>,
44 /// The `Repository` entity for this linked worktree, used to run git
45 /// commands (create WIP commits, stage files, reset) during
46 /// [`persist_worktree_state`].
47 pub worktree_repo: Entity<Repository>,
48 /// The branch the worktree was on, so it can be restored later.
49 /// `None` if the worktree was in detached HEAD state.
50 pub branch_name: Option<String>,
51 /// Remote connection options for the project that owns this worktree,
52 /// used to create temporary remote projects when the main repo isn't
53 /// loaded in any open workspace.
54 pub remote_connection: Option<RemoteConnectionOptions>,
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/// Resolves the Zed-managed worktrees base directory for a given repo.
76///
77/// This intentionally reads the *global* `git.worktree_directory` setting
78/// rather than any project-local override, because Zed always uses the
79/// global value when creating worktrees and the archive check must match.
80fn worktrees_base_for_repo(main_repo_path: &Path, cx: &App) -> Option<PathBuf> {
81 let setting = &ProjectSettings::get_global(cx).git.worktree_directory;
82 worktrees_directory_for_repo(main_repo_path, setting).log_err()
83}
84
85/// Builds a [`RootPlan`] for archiving the git worktree at `path`.
86///
87/// This is a synchronous planning step that must run *before* any workspace
88/// removal, because it needs live project and repository entities that are
89/// torn down when a workspace is removed. It does three things:
90///
91/// 1. Finds every `Project` across all open workspaces that has this
92/// worktree loaded (`affected_projects`).
93/// 2. Looks for a `Repository` entity whose snapshot identifies this path
94/// as a linked worktree (`worktree_repo`), which is needed for the git
95/// operations in [`persist_worktree_state`].
96/// 3. Determines the `main_repo_path` — the parent repo that owns this
97/// linked worktree — needed for both git ref creation and
98/// `git worktree remove`.
99///
100/// Returns `None` if the path is not a linked worktree (main worktrees
101/// cannot be archived to disk) or if no open project has it loaded.
102pub fn build_root_plan(
103 path: &Path,
104 remote_connection: Option<&RemoteConnectionOptions>,
105 workspaces: &[Entity<Workspace>],
106 cx: &App,
107) -> Option<RootPlan> {
108 let path = path.to_path_buf();
109
110 let matches_target_connection = |project: &Entity<Project>, cx: &App| {
111 same_remote_connection_identity(
112 project.read(cx).remote_connection_options(cx).as_ref(),
113 remote_connection,
114 )
115 };
116
117 let affected_projects = workspaces
118 .iter()
119 .filter_map(|workspace| {
120 let project = workspace.read(cx).project().clone();
121 if !matches_target_connection(&project, cx) {
122 return None;
123 }
124 let worktree = project
125 .read(cx)
126 .visible_worktrees(cx)
127 .find(|worktree| worktree.read(cx).abs_path().as_ref() == path.as_path())?;
128 let worktree_id = worktree.read(cx).id();
129 Some(AffectedProject {
130 project,
131 worktree_id,
132 })
133 })
134 .collect::<Vec<_>>();
135
136 if affected_projects.is_empty() {
137 return None;
138 }
139
140 let linked_repo = workspaces
141 .iter()
142 .filter(|workspace| matches_target_connection(workspace.read(cx).project(), cx))
143 .flat_map(|workspace| {
144 workspace
145 .read(cx)
146 .project()
147 .read(cx)
148 .repositories(cx)
149 .values()
150 .cloned()
151 .collect::<Vec<_>>()
152 })
153 .find_map(|repo| {
154 let snapshot = repo.read(cx).snapshot();
155 (snapshot.is_linked_worktree()
156 && snapshot.work_directory_abs_path.as_ref() == path.as_path())
157 .then_some((snapshot, repo))
158 });
159
160 // Only linked worktrees can be archived to disk via `git worktree remove`.
161 // Main worktrees must be left alone — git refuses to remove them.
162 let (linked_snapshot, repo) = linked_repo?;
163 let main_repo_path = linked_snapshot.original_repo_abs_path.to_path_buf();
164
165 // Only archive worktrees that live inside the Zed-managed worktrees
166 // directory (configured via `git.worktree_directory`). Worktrees the
167 // user created outside that directory should be left untouched.
168 let worktrees_base = worktrees_base_for_repo(&main_repo_path, cx)?;
169 if !path.starts_with(&worktrees_base) {
170 return None;
171 }
172
173 let branch_name = linked_snapshot
174 .branch
175 .as_ref()
176 .map(|branch| branch.name().to_string());
177
178 Some(RootPlan {
179 root_path: path,
180 main_repo_path,
181 affected_projects,
182 worktree_repo: repo,
183 branch_name,
184 remote_connection: remote_connection.cloned(),
185 })
186}
187
188/// Removes a worktree from all affected projects and deletes it from disk
189/// via `git worktree remove`.
190///
191/// This is the destructive counterpart to [`persist_worktree_state`]. It
192/// first detaches the worktree from every [`AffectedProject`], waits for
193/// each project to fully release it, then asks the main repository to
194/// delete the worktree directory. If the git removal fails, the worktree
195/// is re-added to each project via [`rollback_root`].
196pub async fn remove_root(root: RootPlan, cx: &mut AsyncApp) -> Result<()> {
197 let release_tasks: Vec<_> = root
198 .affected_projects
199 .iter()
200 .map(|affected| {
201 let project = affected.project.clone();
202 let worktree_id = affected.worktree_id;
203 project.update(cx, |project, cx| {
204 let wait = project.wait_for_worktree_release(worktree_id, cx);
205 project.remove_worktree(worktree_id, cx);
206 wait
207 })
208 })
209 .collect();
210
211 if let Err(error) = remove_root_after_worktree_removal(&root, release_tasks, cx).await {
212 rollback_root(&root, cx).await;
213 return Err(error);
214 }
215
216 Ok(())
217}
218
219async fn remove_root_after_worktree_removal(
220 root: &RootPlan,
221 release_tasks: Vec<Task<Result<()>>>,
222 cx: &mut AsyncApp,
223) -> Result<()> {
224 for task in release_tasks {
225 if let Err(error) = task.await {
226 log::error!("Failed waiting for worktree release: {error:#}");
227 }
228 }
229
230 let (repo, project) =
231 find_or_create_repository(&root.main_repo_path, root.remote_connection.as_ref(), cx)
232 .await?;
233
234 // `Repository::remove_worktree` with `force = true` deletes the working
235 // directory before running `git worktree remove --force`, so there's no
236 // need to touch the filesystem here. For remote projects that cleanup
237 // runs on the headless server via the `GitRemoveWorktree` RPC, which is
238 // the only code path with access to the remote machine's filesystem.
239 let receiver = repo.update(cx, |repo: &mut Repository, _cx| {
240 repo.remove_worktree(root.root_path.clone(), true)
241 });
242 let result = receiver
243 .await
244 .map_err(|_| anyhow!("git worktree metadata cleanup was canceled"))?;
245 // `project` may be a live workspace project or a temporary one created
246 // by `find_or_create_repository`. In the temporary case we must keep it
247 // alive until the repo removes the worktree
248 drop(project);
249 result.context("git worktree metadata cleanup failed")?;
250 Ok(())
251}
252
253/// Finds a live `Repository` entity for the given path, or creates a temporary
254/// project to obtain one.
255///
256/// `Repository` entities can only be obtained through a `Project` because
257/// `GitStore` (which creates and manages `Repository` entities) is owned by
258/// `Project`. When no open workspace contains the repo we need, we spin up a
259/// headless project just to get a `Repository` handle. For local paths this is
260/// a `Project::local`; for remote paths we build a `Project::remote` through
261/// the connection pool (reusing the existing SSH transport), which requires
262/// the caller to pass the matching `RemoteConnectionOptions` so we only match
263/// and fall back onto projects that share the same remote identity. The
264/// caller keeps the returned `Entity<Project>` alive for the duration of the
265/// git operations, then drops it.
266///
267/// Future improvement: decoupling `GitStore` from `Project` so that
268/// `Repository` entities can be created standalone would eliminate this
269/// temporary-project workaround.
270async fn find_or_create_repository(
271 repo_path: &Path,
272 remote_connection: Option<&RemoteConnectionOptions>,
273 cx: &mut AsyncApp,
274) -> Result<(Entity<Repository>, Entity<Project>)> {
275 let repo_path_owned = repo_path.to_path_buf();
276 let remote_connection_owned = remote_connection.cloned();
277
278 // First, try to find a live repository in any open workspace whose
279 // remote connection matches (so a local `/project` and a remote
280 // `/project` are not confused).
281 let live_repo = cx.update(|cx| {
282 all_open_workspaces(cx)
283 .into_iter()
284 .filter_map(|workspace| {
285 let project = workspace.read(cx).project().clone();
286 let project_connection = project.read(cx).remote_connection_options(cx);
287 if !same_remote_connection_identity(
288 project_connection.as_ref(),
289 remote_connection_owned.as_ref(),
290 ) {
291 return None;
292 }
293 Some((
294 project
295 .read(cx)
296 .repositories(cx)
297 .values()
298 .find(|repo| {
299 repo.read(cx).snapshot().work_directory_abs_path.as_ref()
300 == repo_path_owned.as_path()
301 })
302 .cloned()?,
303 project.clone(),
304 ))
305 })
306 .next()
307 });
308
309 if let Some((repo, project)) = live_repo {
310 return Ok((repo, project));
311 }
312
313 let app_state =
314 current_app_state(cx).context("no app state available for temporary project")?;
315
316 // For remote paths, create a fresh RemoteClient through the connection
317 // pool (reusing the existing SSH transport) and build a temporary
318 // remote project. Each RemoteClient gets its own server-side headless
319 // project, so there are no RPC routing conflicts with other projects.
320 let temp_project = if let Some(connection) = remote_connection_owned {
321 let remote_client = cx
322 .update(|cx| {
323 if !remote::has_active_connection(&connection, cx) {
324 anyhow::bail!("cannot open repository on disconnected remote machine");
325 }
326 Ok(remote_connection::connect_reusing_pool(connection, cx))
327 })?
328 .await?
329 .context("remote connection was canceled")?;
330
331 cx.update(|cx| {
332 Project::remote(
333 remote_client,
334 app_state.client.clone(),
335 app_state.node_runtime.clone(),
336 app_state.user_store.clone(),
337 app_state.languages.clone(),
338 app_state.fs.clone(),
339 false,
340 cx,
341 )
342 })
343 } else {
344 cx.update(|cx| {
345 Project::local(
346 app_state.client.clone(),
347 app_state.node_runtime.clone(),
348 app_state.user_store.clone(),
349 app_state.languages.clone(),
350 app_state.fs.clone(),
351 None,
352 LocalProjectFlags::default(),
353 cx,
354 )
355 })
356 };
357
358 let repo_path_for_worktree = repo_path.to_path_buf();
359 let create_worktree = temp_project.update(cx, |project, cx| {
360 project.create_worktree(repo_path_for_worktree, true, cx)
361 });
362 let _worktree = create_worktree.await?;
363 let initial_scan = temp_project.read_with(cx, |project, cx| project.wait_for_initial_scan(cx));
364 initial_scan.await;
365
366 let repo_path_for_find = repo_path.to_path_buf();
367 let repo = temp_project
368 .update(cx, |project, cx| {
369 project
370 .repositories(cx)
371 .values()
372 .find(|repo| {
373 repo.read(cx).snapshot().work_directory_abs_path.as_ref()
374 == repo_path_for_find.as_path()
375 })
376 .cloned()
377 })
378 .context("failed to resolve temporary repository handle")?;
379
380 let barrier = repo.update(cx, |repo: &mut Repository, _cx| repo.barrier());
381 barrier
382 .await
383 .map_err(|_| anyhow!("temporary repository barrier canceled"))?;
384 Ok((repo, temp_project))
385}
386
387/// Re-adds the worktree to every affected project after a failed
388/// [`remove_root`].
389async fn rollback_root(root: &RootPlan, cx: &mut AsyncApp) {
390 for affected in &root.affected_projects {
391 let task = affected.project.update(cx, |project, cx| {
392 project.create_worktree(root.root_path.clone(), true, cx)
393 });
394 task.await.log_err();
395 }
396}
397
398/// Saves the worktree's full git state so it can be restored later.
399///
400/// This creates two detached commits (via [`create_archive_checkpoint`] on
401/// the `GitRepository` trait) that capture the staged and unstaged state
402/// without moving any branch ref. The commits are:
403/// - "WIP staged": a tree matching the current index, parented on HEAD
404/// - "WIP unstaged": a tree with all files (including untracked),
405/// parented on the staged commit
406///
407/// After creating the commits, this function:
408/// 1. Records the commit SHAs, branch name, and paths in a DB record.
409/// 2. Links every thread referencing this worktree to that record.
410/// 3. Creates a git ref on the main repo to prevent GC of the commits.
411///
412/// On success, returns the archived worktree DB row ID for rollback.
413pub async fn persist_worktree_state(root: &RootPlan, cx: &mut AsyncApp) -> Result<i64> {
414 let worktree_repo = root.worktree_repo.clone();
415
416 let original_commit_hash = worktree_repo
417 .update(cx, |repo, _cx| repo.head_sha())
418 .await
419 .map_err(|_| anyhow!("head_sha canceled"))?
420 .context("failed to read original HEAD SHA")?
421 .context("HEAD SHA is None")?;
422
423 // Create two detached WIP commits without moving the branch.
424 let checkpoint_rx = worktree_repo.update(cx, |repo, _cx| repo.create_archive_checkpoint());
425 let (staged_commit_hash, unstaged_commit_hash) = checkpoint_rx
426 .await
427 .map_err(|_| anyhow!("create_archive_checkpoint canceled"))?
428 .context("failed to create archive checkpoint")?;
429
430 // Create DB record
431 let store = cx.update(|cx| ThreadMetadataStore::global(cx));
432 let worktree_path_str = root.root_path.to_string_lossy().to_string();
433 let main_repo_path_str = root.main_repo_path.to_string_lossy().to_string();
434 let branch_name = root.branch_name.clone().or_else(|| {
435 worktree_repo.read_with(cx, |repo, _cx| {
436 repo.snapshot()
437 .branch
438 .as_ref()
439 .map(|branch| branch.name().to_string())
440 })
441 });
442
443 let db_result = store
444 .read_with(cx, |store, cx| {
445 store.create_archived_worktree(
446 worktree_path_str.clone(),
447 main_repo_path_str.clone(),
448 branch_name.clone(),
449 staged_commit_hash.clone(),
450 unstaged_commit_hash.clone(),
451 original_commit_hash.clone(),
452 cx,
453 )
454 })
455 .await
456 .context("failed to create archived worktree DB record");
457 let archived_worktree_id = match db_result {
458 Ok(id) => id,
459 Err(error) => {
460 return Err(error);
461 }
462 };
463
464 // Link all threads on this worktree to the archived record
465 let thread_ids: Vec<ThreadId> = store.read_with(cx, |store, _cx| {
466 store
467 .entries()
468 .filter(|thread| {
469 thread
470 .folder_paths()
471 .paths()
472 .iter()
473 .any(|p| p.as_path() == root.root_path)
474 })
475 .map(|thread| thread.thread_id)
476 .collect()
477 });
478
479 for thread_id in &thread_ids {
480 let link_result = store
481 .read_with(cx, |store, cx| {
482 store.link_thread_to_archived_worktree(*thread_id, archived_worktree_id, cx)
483 })
484 .await;
485 if let Err(error) = link_result {
486 if let Err(delete_error) = store
487 .read_with(cx, |store, cx| {
488 store.delete_archived_worktree(archived_worktree_id, cx)
489 })
490 .await
491 {
492 log::error!(
493 "Failed to delete archived worktree DB record during link rollback: \
494 {delete_error:#}"
495 );
496 }
497 return Err(error.context("failed to link thread to archived worktree"));
498 }
499 }
500
501 // Create git ref on main repo to prevent GC of the detached commits.
502 // This is fatal: without the ref, git gc will eventually collect the
503 // WIP commits and a later restore will silently fail.
504 let ref_name = archived_worktree_ref_name(archived_worktree_id);
505 let (main_repo, _temp_project) =
506 find_or_create_repository(&root.main_repo_path, root.remote_connection.as_ref(), cx)
507 .await
508 .context("could not open main repo to create archive ref")?;
509 let rx = main_repo.update(cx, |repo, _cx| {
510 repo.update_ref(ref_name.clone(), unstaged_commit_hash.clone())
511 });
512 rx.await
513 .map_err(|_| anyhow!("update_ref canceled"))
514 .and_then(|r| r)
515 .with_context(|| format!("failed to create ref {ref_name} on main repo"))?;
516 // See note in `remove_root_after_worktree_removal`: this may be a live
517 // or temporary project; dropping only matters in the temporary case.
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, root.remote_connection.as_ref(), 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 // See note in `remove_root_after_worktree_removal`: this may be a
536 // live or temporary project; dropping only matters in the temporary
537 // case.
538 drop(_temp_project);
539 }
540
541 // Delete the DB record
542 let store = cx.update(|cx| ThreadMetadataStore::global(cx));
543 if let Err(error) = store
544 .read_with(cx, |store, cx| {
545 store.delete_archived_worktree(archived_worktree_id, cx)
546 })
547 .await
548 {
549 log::error!("Failed to delete archived worktree DB record during rollback: {error:#}");
550 }
551}
552
553/// Restores a previously archived worktree back to disk from its DB record.
554///
555/// Creates the git worktree at the original commit (the branch never moved
556/// during archival since WIP commits are detached), switches to the branch,
557/// then uses [`restore_archive_checkpoint`] to reconstruct the staged/
558/// unstaged state from the WIP commit trees.
559pub async fn restore_worktree_via_git(
560 row: &ArchivedGitWorktree,
561 remote_connection: Option<&RemoteConnectionOptions>,
562 cx: &mut AsyncApp,
563) -> Result<PathBuf> {
564 let (main_repo, _temp_project) =
565 find_or_create_repository(&row.main_repo_path, remote_connection, cx).await?;
566
567 let worktree_path = &row.worktree_path;
568 let app_state = current_app_state(cx).context("no app state available")?;
569 let already_exists = app_state.fs.metadata(worktree_path).await?.is_some();
570
571 let created_new_worktree = if already_exists {
572 let is_git_worktree =
573 resolve_git_worktree_to_main_repo(app_state.fs.as_ref(), worktree_path)
574 .await
575 .is_some();
576
577 if !is_git_worktree {
578 let rx = main_repo.update(cx, |repo, _cx| repo.repair_worktrees());
579 rx.await
580 .map_err(|_| anyhow!("worktree repair was canceled"))?
581 .context("failed to repair worktrees")?;
582 }
583 false
584 } else {
585 // Create worktree at the original commit — the branch still points
586 // here because archival used detached commits.
587 let rx = main_repo.update(cx, |repo, _cx| {
588 repo.create_worktree_detached(worktree_path.clone(), row.original_commit_hash.clone())
589 });
590 rx.await
591 .map_err(|_| anyhow!("worktree creation was canceled"))?
592 .context("failed to create worktree")?;
593 true
594 };
595
596 let (wt_repo, _temp_wt_project) =
597 match find_or_create_repository(worktree_path, remote_connection, cx).await {
598 Ok(result) => result,
599 Err(error) => {
600 remove_new_worktree_on_error(created_new_worktree, &main_repo, worktree_path, cx)
601 .await;
602 return Err(error);
603 }
604 };
605
606 if let Some(branch_name) = &row.branch_name {
607 // Attempt to check out the branch the worktree was previously on.
608 let checkout_result = wt_repo
609 .update(cx, |repo, _cx| repo.change_branch(branch_name.clone()))
610 .await;
611
612 match checkout_result.map_err(|e| anyhow!("{e}")).flatten() {
613 Ok(()) => {
614 // Branch checkout succeeded. Check whether the branch has moved since
615 // we archived the worktree, by comparing HEAD to the expected SHA.
616 let head_sha = wt_repo
617 .update(cx, |repo, _cx| repo.head_sha())
618 .await
619 .map_err(|e| anyhow!("{e}"))
620 .and_then(|r| r);
621
622 match head_sha {
623 Ok(Some(sha)) if sha == row.original_commit_hash => {
624 // Branch still points at the original commit; we're all done!
625 }
626 Ok(Some(sha)) => {
627 // The branch has moved. We don't want to restore the worktree to
628 // a different filesystem state, so checkout the original commit
629 // in detached HEAD state.
630 log::info!(
631 "Branch '{branch_name}' has moved since archival (now at {sha}); \
632 restoring worktree in detached HEAD at {}",
633 row.original_commit_hash
634 );
635 let detach_result = main_repo
636 .update(cx, |repo, _cx| {
637 repo.checkout_branch_in_worktree(
638 row.original_commit_hash.clone(),
639 row.worktree_path.clone(),
640 false,
641 )
642 })
643 .await;
644
645 if let Err(error) = detach_result.map_err(|e| anyhow!("{e}")).flatten() {
646 log::warn!(
647 "Failed to detach HEAD at {}: {error:#}",
648 row.original_commit_hash
649 );
650 }
651 }
652 Ok(None) => {
653 log::warn!(
654 "head_sha unexpectedly returned None after checking out \"{branch_name}\"; \
655 proceeding in current HEAD state."
656 );
657 }
658 Err(error) => {
659 log::warn!(
660 "Failed to read HEAD after checking out \"{branch_name}\": {error:#}"
661 );
662 }
663 }
664 }
665 Err(checkout_error) => {
666 // We weren't able to check out the branch, most likely because it was deleted.
667 // This is fine; users will often delete old branches! We'll try to recreate it.
668 log::debug!(
669 "change_branch('{branch_name}') failed: {checkout_error:#}, trying create_branch"
670 );
671 let create_result = wt_repo
672 .update(cx, |repo, _cx| {
673 repo.create_branch(branch_name.clone(), None)
674 })
675 .await;
676
677 if let Err(error) = create_result.map_err(|e| anyhow!("{e}")).flatten() {
678 log::warn!(
679 "Failed to create branch '{branch_name}': {error:#}; \
680 restored worktree will be in detached HEAD state."
681 );
682 }
683 }
684 }
685 }
686
687 // Restore the staged/unstaged state from the WIP commit trees.
688 // read-tree --reset -u applies the unstaged tree (including deletions)
689 // to the working directory, then a bare read-tree sets the index to
690 // the staged tree without touching the working directory.
691 let restore_rx = wt_repo.update(cx, |repo, _cx| {
692 repo.restore_archive_checkpoint(
693 row.staged_commit_hash.clone(),
694 row.unstaged_commit_hash.clone(),
695 )
696 });
697 if let Err(error) = restore_rx
698 .await
699 .map_err(|_| anyhow!("restore_archive_checkpoint canceled"))
700 .and_then(|r| r)
701 {
702 remove_new_worktree_on_error(created_new_worktree, &main_repo, worktree_path, cx).await;
703 return Err(error.context("failed to restore archive checkpoint"));
704 }
705
706 Ok(worktree_path.clone())
707}
708
709async fn remove_new_worktree_on_error(
710 created_new_worktree: bool,
711 main_repo: &Entity<Repository>,
712 worktree_path: &PathBuf,
713 cx: &mut AsyncApp,
714) {
715 if created_new_worktree {
716 let rx = main_repo.update(cx, |repo, _cx| {
717 repo.remove_worktree(worktree_path.clone(), true)
718 });
719 rx.await.ok().and_then(|r| r.log_err());
720 }
721}
722
723/// Deletes the git ref and DB records for a single archived worktree.
724/// Used when an archived worktree is no longer referenced by any thread.
725pub async fn cleanup_archived_worktree_record(
726 row: &ArchivedGitWorktree,
727 remote_connection: Option<&RemoteConnectionOptions>,
728 cx: &mut AsyncApp,
729) {
730 // Delete the git ref from the main repo
731 if let Ok((main_repo, _temp_project)) =
732 find_or_create_repository(&row.main_repo_path, remote_connection, cx).await
733 {
734 let ref_name = archived_worktree_ref_name(row.id);
735 let rx = main_repo.update(cx, |repo, _cx| repo.delete_ref(ref_name));
736 match rx.await {
737 Ok(Ok(())) => {}
738 Ok(Err(error)) => log::warn!("Failed to delete archive ref: {error}"),
739 Err(_) => log::warn!("Archive ref deletion was canceled"),
740 }
741 // See note in `remove_root_after_worktree_removal`: this may be a
742 // live or temporary project; dropping only matters in the temporary
743 // case.
744 drop(_temp_project);
745 }
746
747 // Delete the DB records
748 let store = cx.update(|cx| ThreadMetadataStore::global(cx));
749 store
750 .read_with(cx, |store, cx| store.delete_archived_worktree(row.id, cx))
751 .await
752 .log_err();
753}
754
755/// Cleans up all archived worktree data associated with a thread being deleted.
756///
757/// This unlinks the thread from all its archived worktrees and, for any
758/// archived worktree that is no longer referenced by any other thread,
759/// deletes the git ref and DB records.
760pub async fn cleanup_thread_archived_worktrees(thread_id: ThreadId, cx: &mut AsyncApp) {
761 let store = cx.update(|cx| ThreadMetadataStore::global(cx));
762 let remote_connection = store.read_with(cx, |store, _cx| {
763 store
764 .entry(thread_id)
765 .and_then(|t| t.remote_connection.clone())
766 });
767
768 let archived_worktrees = store
769 .read_with(cx, |store, cx| {
770 store.get_archived_worktrees_for_thread(thread_id, cx)
771 })
772 .await;
773 let archived_worktrees = match archived_worktrees {
774 Ok(rows) => rows,
775 Err(error) => {
776 log::error!("Failed to fetch archived worktrees for thread {thread_id:?}: {error:#}");
777 return;
778 }
779 };
780
781 if archived_worktrees.is_empty() {
782 return;
783 }
784
785 if let Err(error) = store
786 .read_with(cx, |store, cx| {
787 store.unlink_thread_from_all_archived_worktrees(thread_id, cx)
788 })
789 .await
790 {
791 log::error!("Failed to unlink thread {thread_id:?} from archived worktrees: {error:#}");
792 return;
793 }
794
795 for row in &archived_worktrees {
796 let still_referenced = store
797 .read_with(cx, |store, cx| {
798 store.is_archived_worktree_referenced(row.id, cx)
799 })
800 .await;
801 match still_referenced {
802 Ok(true) => {}
803 Ok(false) => {
804 cleanup_archived_worktree_record(row, remote_connection.as_ref(), cx).await;
805 }
806 Err(error) => {
807 log::error!(
808 "Failed to check if archived worktree {} is still referenced: {error:#}",
809 row.id
810 );
811 }
812 }
813 }
814}
815
816/// Collects every `Workspace` entity across all open `MultiWorkspace` windows.
817pub fn all_open_workspaces(cx: &App) -> Vec<Entity<Workspace>> {
818 cx.windows()
819 .into_iter()
820 .filter_map(|window| window.downcast::<MultiWorkspace>())
821 .flat_map(|multi_workspace| {
822 multi_workspace
823 .read(cx)
824 .map(|multi_workspace| multi_workspace.workspaces().cloned().collect::<Vec<_>>())
825 .unwrap_or_default()
826 })
827 .collect()
828}
829
830fn current_app_state(cx: &mut AsyncApp) -> Option<Arc<AppState>> {
831 cx.update(|cx| {
832 all_open_workspaces(cx)
833 .into_iter()
834 .next()
835 .map(|workspace| workspace.read(cx).app_state().clone())
836 })
837}
838#[cfg(test)]
839mod tests {
840 use super::*;
841 use fs::{FakeFs, Fs as _};
842 use git::repository::Worktree as GitWorktree;
843 use gpui::{BorrowAppContext, TestAppContext};
844 use project::Project;
845 use serde_json::json;
846 use settings::SettingsStore;
847 use workspace::MultiWorkspace;
848
849 fn init_test(cx: &mut TestAppContext) {
850 cx.update(|cx| {
851 let settings_store = SettingsStore::test(cx);
852 cx.set_global(settings_store);
853 theme_settings::init(theme::LoadThemes::JustBase, cx);
854 editor::init(cx);
855 release_channel::init(semver::Version::new(0, 0, 0), cx);
856 });
857 }
858
859 #[gpui::test]
860 async fn test_build_root_plan_returns_none_for_main_worktree(cx: &mut TestAppContext) {
861 init_test(cx);
862
863 let fs = FakeFs::new(cx.executor());
864 fs.insert_tree(
865 "/project",
866 json!({
867 ".git": {},
868 "src": { "main.rs": "fn main() {}" }
869 }),
870 )
871 .await;
872 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
873
874 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
875
876 let multi_workspace =
877 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
878 let workspace = multi_workspace
879 .read_with(cx, |mw, _cx| mw.workspace().clone())
880 .unwrap();
881
882 cx.run_until_parked();
883
884 // The main worktree should NOT produce a root plan.
885 workspace.read_with(cx, |_workspace, cx| {
886 let plan = build_root_plan(
887 Path::new("/project"),
888 None,
889 std::slice::from_ref(&workspace),
890 cx,
891 );
892 assert!(
893 plan.is_none(),
894 "build_root_plan should return None for a main worktree",
895 );
896 });
897 }
898
899 #[gpui::test]
900 async fn test_build_root_plan_returns_some_for_linked_worktree(cx: &mut TestAppContext) {
901 init_test(cx);
902
903 let fs = FakeFs::new(cx.executor());
904 fs.insert_tree(
905 "/project",
906 json!({
907 ".git": {},
908 "src": { "main.rs": "fn main() {}" }
909 }),
910 )
911 .await;
912 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
913 fs.insert_branches(Path::new("/project/.git"), &["main", "feature"]);
914
915 fs.add_linked_worktree_for_repo(
916 Path::new("/project/.git"),
917 true,
918 GitWorktree {
919 path: PathBuf::from("/worktrees/project/feature/project"),
920 ref_name: Some("refs/heads/feature".into()),
921 sha: "abc123".into(),
922 is_main: false,
923 is_bare: false,
924 },
925 )
926 .await;
927
928 let project = Project::test(
929 fs.clone(),
930 [
931 Path::new("/project"),
932 Path::new("/worktrees/project/feature/project"),
933 ],
934 cx,
935 )
936 .await;
937 project
938 .update(cx, |project, cx| project.git_scans_complete(cx))
939 .await;
940
941 let multi_workspace =
942 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
943 let workspace = multi_workspace
944 .read_with(cx, |mw, _cx| mw.workspace().clone())
945 .unwrap();
946
947 cx.run_until_parked();
948
949 workspace.read_with(cx, |_workspace, cx| {
950 // The linked worktree SHOULD produce a root plan.
951 let plan = build_root_plan(
952 Path::new("/worktrees/project/feature/project"),
953 None,
954 std::slice::from_ref(&workspace),
955 cx,
956 );
957 assert!(
958 plan.is_some(),
959 "build_root_plan should return Some for a linked worktree",
960 );
961 let plan = plan.unwrap();
962 assert_eq!(
963 plan.root_path,
964 PathBuf::from("/worktrees/project/feature/project")
965 );
966 assert_eq!(plan.main_repo_path, PathBuf::from("/project"));
967
968 // The main worktree should still return None.
969 let main_plan = build_root_plan(
970 Path::new("/project"),
971 None,
972 std::slice::from_ref(&workspace),
973 cx,
974 );
975 assert!(
976 main_plan.is_none(),
977 "build_root_plan should return None for the main worktree \
978 even when a linked worktree exists",
979 );
980 });
981 }
982
983 #[gpui::test]
984 async fn test_build_root_plan_returns_none_for_external_linked_worktree(
985 cx: &mut TestAppContext,
986 ) {
987 init_test(cx);
988
989 let fs = FakeFs::new(cx.executor());
990 fs.insert_tree(
991 "/project",
992 json!({
993 ".git": {},
994 "src": { "main.rs": "fn main() {}" }
995 }),
996 )
997 .await;
998 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
999 fs.insert_branches(Path::new("/project/.git"), &["main", "feature"]);
1000
1001 fs.add_linked_worktree_for_repo(
1002 Path::new("/project/.git"),
1003 true,
1004 GitWorktree {
1005 path: PathBuf::from("/external-worktree"),
1006 ref_name: Some("refs/heads/feature".into()),
1007 sha: "abc123".into(),
1008 is_main: false,
1009 is_bare: false,
1010 },
1011 )
1012 .await;
1013
1014 let project = Project::test(
1015 fs.clone(),
1016 [Path::new("/project"), Path::new("/external-worktree")],
1017 cx,
1018 )
1019 .await;
1020 project
1021 .update(cx, |project, cx| project.git_scans_complete(cx))
1022 .await;
1023
1024 let multi_workspace =
1025 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1026 let workspace = multi_workspace
1027 .read_with(cx, |mw, _cx| mw.workspace().clone())
1028 .unwrap();
1029
1030 cx.run_until_parked();
1031
1032 workspace.read_with(cx, |_workspace, cx| {
1033 let plan = build_root_plan(
1034 Path::new("/external-worktree"),
1035 None,
1036 std::slice::from_ref(&workspace),
1037 cx,
1038 );
1039 assert!(
1040 plan.is_none(),
1041 "build_root_plan should return None for a linked worktree \
1042 outside the Zed-managed worktrees directory",
1043 );
1044 });
1045 }
1046
1047 #[gpui::test]
1048 async fn test_build_root_plan_with_custom_worktree_directory(cx: &mut TestAppContext) {
1049 init_test(cx);
1050
1051 // Override the worktree_directory setting to a non-default location.
1052 // With main repo at /project and setting "../custom-worktrees", the
1053 // resolved base is /custom-worktrees/project.
1054 cx.update(|cx| {
1055 cx.update_global::<SettingsStore, _>(|store, cx| {
1056 store.update_user_settings(cx, |s| {
1057 s.git.get_or_insert(Default::default()).worktree_directory =
1058 Some("../custom-worktrees".to_string());
1059 });
1060 });
1061 });
1062
1063 let fs = FakeFs::new(cx.executor());
1064 fs.insert_tree(
1065 "/project",
1066 json!({
1067 ".git": {},
1068 "src": { "main.rs": "fn main() {}" }
1069 }),
1070 )
1071 .await;
1072 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
1073 fs.insert_branches(Path::new("/project/.git"), &["main", "feature", "feature2"]);
1074
1075 // Worktree inside the custom managed directory.
1076 fs.add_linked_worktree_for_repo(
1077 Path::new("/project/.git"),
1078 true,
1079 GitWorktree {
1080 path: PathBuf::from("/custom-worktrees/project/feature/project"),
1081 ref_name: Some("refs/heads/feature".into()),
1082 sha: "abc123".into(),
1083 is_main: false,
1084 is_bare: false,
1085 },
1086 )
1087 .await;
1088
1089 // Worktree outside the custom managed directory (at the default
1090 // `../worktrees` location, which is not what the setting says).
1091 fs.add_linked_worktree_for_repo(
1092 Path::new("/project/.git"),
1093 true,
1094 GitWorktree {
1095 path: PathBuf::from("/worktrees/project/feature2/project"),
1096 ref_name: Some("refs/heads/feature2".into()),
1097 sha: "def456".into(),
1098 is_main: false,
1099 is_bare: false,
1100 },
1101 )
1102 .await;
1103
1104 let project = Project::test(
1105 fs.clone(),
1106 [
1107 Path::new("/project"),
1108 Path::new("/custom-worktrees/project/feature/project"),
1109 Path::new("/worktrees/project/feature2/project"),
1110 ],
1111 cx,
1112 )
1113 .await;
1114 project
1115 .update(cx, |project, cx| project.git_scans_complete(cx))
1116 .await;
1117
1118 let multi_workspace =
1119 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1120 let workspace = multi_workspace
1121 .read_with(cx, |mw, _cx| mw.workspace().clone())
1122 .unwrap();
1123
1124 cx.run_until_parked();
1125
1126 workspace.read_with(cx, |_workspace, cx| {
1127 // Worktree inside the custom managed directory SHOULD be archivable.
1128 let plan = build_root_plan(
1129 Path::new("/custom-worktrees/project/feature/project"),
1130 None,
1131 std::slice::from_ref(&workspace),
1132 cx,
1133 );
1134 assert!(
1135 plan.is_some(),
1136 "build_root_plan should return Some for a worktree inside \
1137 the custom worktree_directory",
1138 );
1139
1140 // Worktree at the default location SHOULD NOT be archivable
1141 // because the setting points elsewhere.
1142 let plan = build_root_plan(
1143 Path::new("/worktrees/project/feature2/project"),
1144 None,
1145 std::slice::from_ref(&workspace),
1146 cx,
1147 );
1148 assert!(
1149 plan.is_none(),
1150 "build_root_plan should return None for a worktree outside \
1151 the custom worktree_directory, even if it would match the default",
1152 );
1153 });
1154 }
1155
1156 #[gpui::test]
1157 async fn test_remove_root_deletes_directory_and_git_metadata(cx: &mut TestAppContext) {
1158 init_test(cx);
1159
1160 let fs = FakeFs::new(cx.executor());
1161 fs.insert_tree(
1162 "/project",
1163 json!({
1164 ".git": {},
1165 "src": { "main.rs": "fn main() {}" }
1166 }),
1167 )
1168 .await;
1169 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
1170 fs.insert_branches(Path::new("/project/.git"), &["main", "feature"]);
1171
1172 fs.add_linked_worktree_for_repo(
1173 Path::new("/project/.git"),
1174 true,
1175 GitWorktree {
1176 path: PathBuf::from("/worktrees/project/feature/project"),
1177 ref_name: Some("refs/heads/feature".into()),
1178 sha: "abc123".into(),
1179 is_main: false,
1180 is_bare: false,
1181 },
1182 )
1183 .await;
1184
1185 let project = Project::test(
1186 fs.clone(),
1187 [
1188 Path::new("/project"),
1189 Path::new("/worktrees/project/feature/project"),
1190 ],
1191 cx,
1192 )
1193 .await;
1194 project
1195 .update(cx, |project, cx| project.git_scans_complete(cx))
1196 .await;
1197
1198 let multi_workspace =
1199 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1200 let workspace = multi_workspace
1201 .read_with(cx, |mw, _cx| mw.workspace().clone())
1202 .unwrap();
1203
1204 cx.run_until_parked();
1205
1206 // Build the root plan while the worktree is still loaded.
1207 let root = workspace
1208 .read_with(cx, |_workspace, cx| {
1209 build_root_plan(
1210 Path::new("/worktrees/project/feature/project"),
1211 None,
1212 std::slice::from_ref(&workspace),
1213 cx,
1214 )
1215 })
1216 .expect("should produce a root plan for the linked worktree");
1217
1218 assert!(
1219 fs.is_dir(Path::new("/worktrees/project/feature/project"))
1220 .await
1221 );
1222
1223 // Remove the root.
1224 let task = cx.update(|cx| cx.spawn(async move |cx| remove_root(root, cx).await));
1225 task.await.expect("remove_root should succeed");
1226
1227 cx.run_until_parked();
1228
1229 // The FakeFs directory should be gone.
1230 assert!(
1231 !fs.is_dir(Path::new("/worktrees/project/feature/project"))
1232 .await,
1233 "linked worktree directory should be removed from FakeFs"
1234 );
1235 }
1236
1237 #[gpui::test]
1238 async fn test_remove_root_succeeds_when_directory_already_gone(cx: &mut TestAppContext) {
1239 init_test(cx);
1240
1241 let fs = FakeFs::new(cx.executor());
1242 fs.insert_tree(
1243 "/project",
1244 json!({
1245 ".git": {},
1246 "src": { "main.rs": "fn main() {}" }
1247 }),
1248 )
1249 .await;
1250 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
1251 fs.insert_branches(Path::new("/project/.git"), &["main", "feature"]);
1252
1253 fs.add_linked_worktree_for_repo(
1254 Path::new("/project/.git"),
1255 true,
1256 GitWorktree {
1257 path: PathBuf::from("/worktrees/project/feature/project"),
1258 ref_name: Some("refs/heads/feature".into()),
1259 sha: "abc123".into(),
1260 is_main: false,
1261 is_bare: false,
1262 },
1263 )
1264 .await;
1265
1266 let project = Project::test(
1267 fs.clone(),
1268 [
1269 Path::new("/project"),
1270 Path::new("/worktrees/project/feature/project"),
1271 ],
1272 cx,
1273 )
1274 .await;
1275 project
1276 .update(cx, |project, cx| project.git_scans_complete(cx))
1277 .await;
1278
1279 let multi_workspace =
1280 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1281 let workspace = multi_workspace
1282 .read_with(cx, |mw, _cx| mw.workspace().clone())
1283 .unwrap();
1284
1285 cx.run_until_parked();
1286
1287 let root = workspace
1288 .read_with(cx, |_workspace, cx| {
1289 build_root_plan(
1290 Path::new("/worktrees/project/feature/project"),
1291 None,
1292 std::slice::from_ref(&workspace),
1293 cx,
1294 )
1295 })
1296 .expect("should produce a root plan for the linked worktree");
1297
1298 // Manually remove the worktree directory from FakeFs before calling
1299 // remove_root, simulating the directory being deleted externally.
1300 fs.as_ref()
1301 .remove_dir(
1302 Path::new("/worktrees/project/feature/project"),
1303 fs::RemoveOptions {
1304 recursive: true,
1305 ignore_if_not_exists: false,
1306 },
1307 )
1308 .await
1309 .unwrap();
1310 assert!(
1311 !fs.as_ref()
1312 .is_dir(Path::new("/worktrees/project/feature/project"))
1313 .await
1314 );
1315
1316 // remove_root should still succeed — fs.remove_dir with
1317 // ignore_if_not_exists handles NotFound, and git worktree remove
1318 // handles a missing working tree directory.
1319 let task = cx.update(|cx| cx.spawn(async move |cx| remove_root(root, cx).await));
1320 task.await
1321 .expect("remove_root should succeed even when directory is already gone");
1322 }
1323
1324 #[gpui::test]
1325 async fn test_remove_root_returns_error_and_rolls_back_on_remove_dir_failure(
1326 cx: &mut TestAppContext,
1327 ) {
1328 init_test(cx);
1329
1330 let fs = FakeFs::new(cx.executor());
1331 fs.insert_tree(
1332 "/project",
1333 json!({
1334 ".git": {},
1335 "src": { "main.rs": "fn main() {}" }
1336 }),
1337 )
1338 .await;
1339 fs.set_branch_name(Path::new("/project/.git"), Some("main"));
1340 fs.insert_branches(Path::new("/project/.git"), &["main", "feature"]);
1341
1342 fs.add_linked_worktree_for_repo(
1343 Path::new("/project/.git"),
1344 true,
1345 GitWorktree {
1346 path: PathBuf::from("/worktrees/project/feature/project"),
1347 ref_name: Some("refs/heads/feature".into()),
1348 sha: "abc123".into(),
1349 is_main: false,
1350 is_bare: false,
1351 },
1352 )
1353 .await;
1354
1355 let project = Project::test(
1356 fs.clone(),
1357 [
1358 Path::new("/project"),
1359 Path::new("/worktrees/project/feature/project"),
1360 ],
1361 cx,
1362 )
1363 .await;
1364 project
1365 .update(cx, |project, cx| project.git_scans_complete(cx))
1366 .await;
1367
1368 let multi_workspace =
1369 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1370 let workspace = multi_workspace
1371 .read_with(cx, |mw, _cx| mw.workspace().clone())
1372 .unwrap();
1373
1374 cx.run_until_parked();
1375
1376 let root = workspace
1377 .read_with(cx, |_workspace, cx| {
1378 build_root_plan(
1379 Path::new("/worktrees/project/feature/project"),
1380 None,
1381 std::slice::from_ref(&workspace),
1382 cx,
1383 )
1384 })
1385 .expect("should produce a root plan for the linked worktree");
1386
1387 // Replace the worktree directory with a file so that fs.remove_dir
1388 // fails with a "not a directory" error.
1389 let worktree_path = Path::new("/worktrees/project/feature/project");
1390 fs.remove_dir(
1391 worktree_path,
1392 fs::RemoveOptions {
1393 recursive: true,
1394 ignore_if_not_exists: false,
1395 },
1396 )
1397 .await
1398 .unwrap();
1399 fs.create_file(worktree_path, fs::CreateOptions::default())
1400 .await
1401 .unwrap();
1402 assert!(
1403 fs.is_file(worktree_path).await,
1404 "path should now be a file, not a directory"
1405 );
1406
1407 let task = cx.update(|cx| cx.spawn(async move |cx| remove_root(root, cx).await));
1408 let result = task.await;
1409
1410 assert!(
1411 result.is_err(),
1412 "remove_root should return an error when fs.remove_dir fails"
1413 );
1414 let error_message = format!("{:#}", result.unwrap_err());
1415 assert!(
1416 error_message.contains("failed to delete worktree directory"),
1417 "error should mention the directory deletion failure, got: {error_message}"
1418 );
1419
1420 cx.run_until_parked();
1421
1422 // After rollback, the worktree should be re-added to the project.
1423 let has_worktree = project.read_with(cx, |project, cx| {
1424 project
1425 .worktrees(cx)
1426 .any(|wt| wt.read(cx).abs_path().as_ref() == worktree_path)
1427 });
1428 assert!(
1429 has_worktree,
1430 "rollback should have re-added the worktree to the project"
1431 );
1432 }
1433}