Skip to repository content1771 lines · 62.8 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T06:31:12.656Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
worktree_service.rs
1use std::error::Error;
2use std::fmt;
3use std::path::PathBuf;
4use std::sync::Arc;
5
6use anyhow::anyhow;
7use askpass::AskPassDelegate;
8use collections::HashSet;
9use fs::Fs;
10use gpui::{
11 AsyncWindowContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, SharedString,
12 Task, TaskExt, WeakEntity,
13};
14use project::Project;
15use project::git_store::Repository;
16use project::project_settings::ProjectSettings;
17use project::trusted_worktrees::{PathTrust, TrustedWorktrees};
18use remote::RemoteConnectionOptions;
19use settings::Settings;
20use ui::prelude::*;
21use workspace::{
22 MultiWorkspace, OpenMode, PreviousWorkspaceState, ToastView, Workspace, dock::DockPosition,
23};
24use zed_actions::NewWorktreeBranchTarget;
25
26use git::repository::{FetchOptions, Remote};
27
28use util::ResultExt as _;
29
30use crate::askpass_modal::AskPassModal;
31use crate::git_panel::{open_output, show_error_toast};
32use crate::worktree_names;
33
34/// A remote-tracking branch reference parsed into its remote and branch parts,
35/// e.g. `origin/main` -> remote `origin`, branch `main`.
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct RemoteBranchName {
38 pub remote_name: String,
39 pub branch_name: String,
40}
41
42impl RemoteBranchName {
43 pub fn parse(name: &str) -> Option<Self> {
44 let name = name.strip_prefix("refs/remotes/").unwrap_or(name);
45 let (remote_name, branch_name) = name.split_once('/')?;
46 if remote_name.is_empty() || branch_name.is_empty() {
47 return None;
48 }
49 Some(Self {
50 remote_name: remote_name.to_string(),
51 branch_name: branch_name.to_string(),
52 })
53 }
54
55 pub fn display_name(&self) -> String {
56 format!("{}/{}", self.remote_name, self.branch_name)
57 }
58}
59
60/// A "create new worktree" option offered to the user. The set of targets is
61/// derived from repository state by [`worktree_create_targets`] so that the
62/// worktree picker and the sidebar's new-thread menu stay in sync.
63#[derive(Clone, Debug, PartialEq, Eq)]
64pub enum WorktreeCreateTarget {
65 CurrentBranch,
66 DefaultBranch(RemoteBranchName),
67}
68
69impl WorktreeCreateTarget {
70 pub fn branch_target(&self) -> NewWorktreeBranchTarget {
71 match self {
72 WorktreeCreateTarget::CurrentBranch => NewWorktreeBranchTarget::CurrentBranch,
73 WorktreeCreateTarget::DefaultBranch(default_branch) => {
74 NewWorktreeBranchTarget::RemoteBranch {
75 remote_name: default_branch.remote_name.clone(),
76 branch_name: default_branch.branch_name.clone(),
77 }
78 }
79 }
80 }
81
82 pub fn branch_label(
83 &self,
84 has_multiple_repositories: bool,
85 current_branch_name: Option<&str>,
86 ) -> String {
87 match self {
88 WorktreeCreateTarget::DefaultBranch(default_branch) => default_branch.display_name(),
89 WorktreeCreateTarget::CurrentBranch => {
90 if has_multiple_repositories {
91 "current branches".to_string()
92 } else {
93 current_branch_name.unwrap_or("HEAD").to_string()
94 }
95 }
96 }
97 }
98}
99
100/// Determines which "create new worktree" options to surface for the given
101/// repository state: prefer the remote default branch when it differs from the
102/// current branch, and otherwise offer the current branch.
103pub fn worktree_create_targets(
104 has_multiple_repositories: bool,
105 default_branch: Option<RemoteBranchName>,
106 current_branch_name: Option<&str>,
107) -> Vec<WorktreeCreateTarget> {
108 if has_multiple_repositories {
109 return vec![WorktreeCreateTarget::CurrentBranch];
110 }
111 let Some(default_branch) = default_branch else {
112 return vec![WorktreeCreateTarget::CurrentBranch];
113 };
114 let is_different =
115 current_branch_name.is_none_or(|current| current != default_branch.branch_name);
116 let mut targets = vec![WorktreeCreateTarget::DefaultBranch(default_branch)];
117 if is_different {
118 targets.push(WorktreeCreateTarget::CurrentBranch);
119 }
120 targets
121}
122
123/// Whether a worktree operation is creating a new one or switching to an
124/// existing one. Controls whether the source workspace's state (dock layout,
125/// open files, agent panel draft) is inherited by the destination.
126enum WorktreeOperation {
127 Create,
128 Switch,
129}
130
131#[derive(Clone, Copy, PartialEq, Eq)]
132enum RemoteBranchFetchMode {
133 Fetch,
134 UseLocal,
135}
136
137impl RemoteBranchFetchMode {
138 fn should_fetch(self) -> bool {
139 matches!(self, Self::Fetch)
140 }
141}
142
143#[derive(Debug)]
144struct WorktreeFetchError {
145 remote_name: String,
146 branch_name: String,
147 source: anyhow::Error,
148}
149
150impl WorktreeFetchError {
151 fn remote_branch_name(&self) -> String {
152 format!("{}/{}", self.remote_name, self.branch_name)
153 }
154
155 fn output(&self) -> String {
156 format!("git fetch {} failed:\n{:#}", self.remote_name, self.source)
157 }
158}
159
160impl fmt::Display for WorktreeFetchError {
161 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
162 write!(
163 formatter,
164 "git fetch {} failed while creating worktree from {}: {}",
165 self.remote_name,
166 self.remote_branch_name(),
167 self.source
168 )
169 }
170}
171
172impl Error for WorktreeFetchError {
173 fn source(&self) -> Option<&(dyn Error + 'static)> {
174 Some(self.source.as_ref())
175 }
176}
177
178struct WorktreeFetchFailedToast {
179 workspace: WeakEntity<Workspace>,
180 worktree_name: Option<String>,
181 branch_target: NewWorktreeBranchTarget,
182 focused_dock: Option<DockPosition>,
183 remote_branch_name: String,
184 operation: SharedString,
185 output: String,
186 focus_handle: FocusHandle,
187}
188
189impl WorktreeFetchFailedToast {
190 fn new(
191 workspace: WeakEntity<Workspace>,
192 worktree_name: Option<String>,
193 branch_target: NewWorktreeBranchTarget,
194 focused_dock: Option<DockPosition>,
195 fetch_error: &WorktreeFetchError,
196 cx: &mut gpui::Context<Self>,
197 ) -> Self {
198 Self {
199 workspace,
200 worktree_name,
201 branch_target,
202 focused_dock,
203 remote_branch_name: fetch_error.remote_branch_name(),
204 operation: format!("fetch {}", fetch_error.remote_name).into(),
205 output: fetch_error.output(),
206 focus_handle: cx.focus_handle(),
207 }
208 }
209}
210
211impl Focusable for WorktreeFetchFailedToast {
212 fn focus_handle(&self, _cx: &gpui::App) -> FocusHandle {
213 self.focus_handle.clone()
214 }
215}
216
217impl EventEmitter<DismissEvent> for WorktreeFetchFailedToast {}
218
219impl ToastView for WorktreeFetchFailedToast {
220 fn action(&self) -> Option<workspace::ToastAction> {
221 None
222 }
223
224 fn auto_dismiss(&self) -> bool {
225 false
226 }
227}
228
229impl Render for WorktreeFetchFailedToast {
230 fn render(&mut self, _window: &mut Window, cx: &mut gpui::Context<Self>) -> impl IntoElement {
231 let workspace_for_retry = self.workspace.clone();
232 let worktree_name = self.worktree_name.clone();
233 let branch_target = self.branch_target.clone();
234 let focused_dock = self.focused_dock;
235
236 let workspace_for_log = self.workspace.clone();
237 let operation = self.operation.clone();
238 let output = self.output.clone();
239
240 h_flex()
241 .id("worktree-fetch-failed-toast")
242 .elevation_3(cx)
243 .gap_2()
244 .py_1p5()
245 .pl_2p5()
246 .pr_1p5()
247 .flex_none()
248 .bg(cx.theme().colors().surface_background)
249 .shadow_lg()
250 .child(
251 Icon::new(IconName::XCircle)
252 .size(IconSize::Small)
253 .color(Color::Error),
254 )
255 .child(Label::new(format!(
256 "git fetch failed for {}",
257 self.remote_branch_name
258 )))
259 .child(
260 Button::new(
261 "use-local-worktree-base",
262 format!("Use local {}", self.remote_branch_name),
263 )
264 .color(Color::Muted)
265 .on_click(cx.listener(move |_, _event, window, cx| {
266 cx.emit(DismissEvent);
267 if let Some(workspace) = workspace_for_retry.upgrade() {
268 workspace.update(cx, |workspace, cx| {
269 let task = create_worktree_workspace_inner(
270 workspace,
271 &zed_actions::CreateWorktree {
272 worktree_name: worktree_name.clone(),
273 branch_target: branch_target.clone(),
274 },
275 window,
276 focused_dock,
277 RemoteBranchFetchMode::UseLocal,
278 // User-initiated retry of a foreground create.
279 true,
280 cx,
281 );
282 task.detach_and_log_err(cx);
283 });
284 }
285 })),
286 )
287 .child(
288 Button::new("view-worktree-fetch-log", "Show Error Logs")
289 .color(Color::Muted)
290 .on_click(cx.listener(move |_, _event, window, cx| {
291 cx.emit(DismissEvent);
292 let output = output.clone();
293 let operation = operation.clone();
294 workspace_for_log
295 .update(cx, move |workspace, cx| {
296 open_output(operation, workspace, &output, window, cx)
297 })
298 .ok();
299 })),
300 )
301 .child(
302 IconButton::new("dismiss-worktree-fetch-failed-toast", IconName::Close)
303 .shape(ui::IconButtonShape::Square)
304 .icon_size(IconSize::Small)
305 .icon_color(Color::Muted)
306 .on_click(cx.listener(|_, _event, _window, cx| {
307 cx.emit(DismissEvent);
308 })),
309 )
310 }
311}
312
313/// Classifies the project's visible worktrees into git-managed repositories
314/// and non-git paths. Each unique repository is returned only once.
315pub fn classify_worktrees(
316 project: &Project,
317 cx: &gpui::App,
318) -> (Vec<Entity<Repository>>, Vec<PathBuf>) {
319 let repositories = project.repositories(cx).clone();
320 let mut git_repos: Vec<Entity<Repository>> = Vec::new();
321 let mut non_git_paths: Vec<PathBuf> = Vec::new();
322 let mut seen_repo_ids = HashSet::default();
323
324 for worktree in project.visible_worktrees(cx) {
325 let wt_path = worktree.read(cx).abs_path();
326
327 let matching_repo = repositories
328 .iter()
329 .filter_map(|(id, repo)| {
330 let work_dir = repo.read(cx).work_directory_abs_path.clone();
331 if wt_path.starts_with(work_dir.as_ref()) {
332 Some((*id, repo.clone(), work_dir.as_ref().components().count()))
333 } else {
334 None
335 }
336 })
337 .max_by(
338 |(left_id, _left_repo, left_depth), (right_id, _right_repo, right_depth)| {
339 left_depth
340 .cmp(right_depth)
341 .then_with(|| left_id.cmp(right_id))
342 },
343 );
344
345 if let Some((id, repo, _)) = matching_repo {
346 if seen_repo_ids.insert(id) {
347 git_repos.push(repo);
348 }
349 } else {
350 non_git_paths.push(wt_path.to_path_buf());
351 }
352 }
353
354 (git_repos, non_git_paths)
355}
356
357/// Resolves a branch target into the ref the new worktree should be based on.
358/// Returns `None` for `CurrentBranch`, meaning "use the current HEAD".
359pub fn resolve_worktree_branch_target(branch_target: &NewWorktreeBranchTarget) -> Option<String> {
360 match branch_target {
361 NewWorktreeBranchTarget::CurrentBranch => None,
362 NewWorktreeBranchTarget::ExistingBranch { name } => Some(name.clone()),
363 NewWorktreeBranchTarget::RemoteBranch {
364 remote_name,
365 branch_name,
366 } => Some(format!("refs/remotes/{remote_name}/{branch_name}")),
367 }
368}
369
370fn remote_branch_to_fetch(branch_target: &NewWorktreeBranchTarget) -> Option<(&str, &str)> {
371 match branch_target {
372 NewWorktreeBranchTarget::RemoteBranch {
373 remote_name,
374 branch_name,
375 } => Some((remote_name, branch_name)),
376 NewWorktreeBranchTarget::CurrentBranch | NewWorktreeBranchTarget::ExistingBranch { .. } => {
377 None
378 }
379 }
380}
381
382fn create_worktree_askpass_delegate(
383 workspace: WeakEntity<Workspace>,
384 operation: impl Into<SharedString>,
385 window: &mut Window,
386 cx: &mut Context<Workspace>,
387) -> AskPassDelegate {
388 let operation = operation.into();
389 let window = window.window_handle();
390 AskPassDelegate::new(&mut cx.to_async(), move |prompt, tx, cx| {
391 window
392 .update(cx, |_, window, cx| {
393 workspace.update(cx, |workspace, cx| {
394 workspace.toggle_modal(window, cx, |window, cx| {
395 AskPassModal::new(operation.clone(), prompt.into(), tx, window, cx)
396 });
397 })
398 })
399 .ok();
400 })
401}
402
403async fn fetch_remote_for_worktree_base(
404 git_repos: &[Entity<Repository>],
405 remote_name: String,
406 askpass_delegates: Vec<AskPassDelegate>,
407 cx: &mut AsyncWindowContext,
408) -> anyhow::Result<()> {
409 if askpass_delegates.len() != git_repos.len() {
410 return Err(anyhow!(
411 "Unable to fetch {remote_name}: missing credential prompt delegate"
412 ));
413 }
414
415 let fetches = cx.update(|_, cx| {
416 git_repos
417 .iter()
418 .cloned()
419 .zip(askpass_delegates)
420 .map(|(repo, askpass)| {
421 repo.update(cx, |repo, cx| {
422 repo.fetch(
423 FetchOptions::Remote(Remote {
424 name: remote_name.clone().into(),
425 }),
426 askpass,
427 cx,
428 )
429 })
430 })
431 .collect::<Vec<_>>()
432 })?;
433
434 for fetch in futures::future::join_all(fetches).await {
435 fetch??;
436 }
437
438 Ok(())
439}
440
441/// Kicks off an async git-worktree creation for each repository. Returns:
442///
443/// - `creation_infos`: a vec of `(repo, new_path, receiver)` tuples.
444/// - `path_remapping`: `(old_work_dir, new_worktree_path)` pairs for remapping editor tabs.
445///
446/// Multiple entries in `git_repos` can be linked worktrees of the *same*
447/// underlying repository (e.g. a project that has both the main checkout and
448/// one of its linked worktrees open as separate Zed worktrees). Those entries
449/// resolve to the same target path via [`Repository::path_for_new_linked_worktree`],
450/// so we create the new worktree only once and remap every contributing
451/// work directory onto it. Without this dedup, the second `git worktree add`
452/// fails with "already exists".
453fn start_worktree_creations(
454 git_repos: &[Entity<Repository>],
455 worktree_name: Option<String>,
456 existing_worktree_names: &[String],
457 existing_worktree_paths: &HashSet<PathBuf>,
458 base_ref: Option<String>,
459 worktree_directory_setting: &str,
460 rng: &mut impl rand::Rng,
461 cx: &mut gpui::App,
462) -> anyhow::Result<(
463 Vec<(
464 Entity<Repository>,
465 PathBuf,
466 futures::channel::oneshot::Receiver<anyhow::Result<()>>,
467 )>,
468 Vec<(PathBuf, PathBuf)>,
469)> {
470 let mut creation_infos = Vec::new();
471 let mut path_remapping = Vec::new();
472 let mut scheduled_paths: HashSet<PathBuf> = HashSet::default();
473
474 let worktree_name = worktree_name.unwrap_or_else(|| {
475 let existing_refs: Vec<&str> = existing_worktree_names.iter().map(|s| s.as_str()).collect();
476 worktree_names::generate_worktree_name(&existing_refs, rng)
477 .unwrap_or_else(|| "worktree".to_string())
478 });
479
480 for repo in git_repos {
481 let (work_dir, new_path, receiver) = repo.update(cx, |repo, _cx| {
482 let new_path =
483 repo.path_for_new_linked_worktree(&worktree_name, worktree_directory_setting)?;
484 if existing_worktree_paths.contains(&new_path) {
485 anyhow::bail!("A worktree already exists at {}", new_path.display());
486 }
487 let work_dir = repo.work_directory_abs_path.clone();
488 // Only the first repo that resolves to a given target path
489 // actually creates the worktree; subsequent linked worktrees of
490 // the same repository just contribute a path remapping.
491 let receiver = if scheduled_paths.contains(&new_path) {
492 None
493 } else {
494 let target = git::repository::CreateWorktreeTarget::Detached {
495 base_sha: base_ref.clone(),
496 };
497 Some(repo.create_worktree(target, new_path.clone()))
498 };
499 anyhow::Ok((work_dir, new_path, receiver))
500 })?;
501 path_remapping.push((work_dir.to_path_buf(), new_path.clone()));
502 if let Some(receiver) = receiver {
503 scheduled_paths.insert(new_path.clone());
504 creation_infos.push((repo.clone(), new_path, receiver));
505 }
506 }
507
508 Ok((creation_infos, path_remapping))
509}
510
511/// Waits for every in-flight worktree creation to complete. If any
512/// creation fails, all successfully-created worktrees are rolled back
513/// (removed) so the project isn't left in a half-migrated state.
514pub async fn await_and_rollback_on_failure(
515 creation_infos: Vec<(
516 Entity<Repository>,
517 PathBuf,
518 futures::channel::oneshot::Receiver<anyhow::Result<()>>,
519 )>,
520 fs: Arc<dyn Fs>,
521 cx: &mut AsyncWindowContext,
522) -> anyhow::Result<Vec<PathBuf>> {
523 let mut created_paths: Vec<PathBuf> = Vec::new();
524 let mut repos_and_paths: Vec<(Entity<Repository>, PathBuf)> = Vec::new();
525 let mut first_error: Option<anyhow::Error> = None;
526
527 for (repo, new_path, receiver) in creation_infos {
528 repos_and_paths.push((repo.clone(), new_path.clone()));
529 match receiver.await {
530 Ok(Ok(())) => {
531 created_paths.push(new_path);
532 }
533 Ok(Err(err)) => {
534 if first_error.is_none() {
535 first_error = Some(err);
536 }
537 }
538 Err(_canceled) => {
539 if first_error.is_none() {
540 first_error = Some(anyhow!("Worktree creation was canceled"));
541 }
542 }
543 }
544 }
545
546 let Some(err) = first_error else {
547 return Ok(created_paths);
548 };
549
550 // Rollback all attempted worktrees
551 let mut rollback_futures = Vec::new();
552 for (rollback_repo, rollback_path) in &repos_and_paths {
553 let receiver = cx
554 .update(|_, cx| {
555 rollback_repo.update(cx, |repo, _cx| {
556 repo.remove_worktree(rollback_path.clone(), true)
557 })
558 })
559 .ok();
560
561 rollback_futures.push((rollback_path.clone(), receiver));
562 }
563
564 let mut rollback_failures: Vec<String> = Vec::new();
565 for (path, receiver_opt) in rollback_futures {
566 let mut git_remove_failed = false;
567
568 if let Some(receiver) = receiver_opt {
569 match receiver.await {
570 Ok(Ok(())) => {}
571 Ok(Err(rollback_err)) => {
572 log::error!(
573 "git worktree remove failed for {}: {rollback_err}",
574 path.display()
575 );
576 git_remove_failed = true;
577 }
578 Err(canceled) => {
579 log::error!(
580 "git worktree remove failed for {}: {canceled}",
581 path.display()
582 );
583 git_remove_failed = true;
584 }
585 }
586 } else {
587 log::error!(
588 "failed to dispatch git worktree remove for {}",
589 path.display()
590 );
591 git_remove_failed = true;
592 }
593
594 if git_remove_failed {
595 if let Err(fs_err) = fs
596 .remove_dir(
597 &path,
598 fs::RemoveOptions {
599 recursive: true,
600 ignore_if_not_exists: true,
601 },
602 )
603 .await
604 {
605 let msg = format!("{}: failed to remove directory: {fs_err}", path.display());
606 log::error!("{}", msg);
607 rollback_failures.push(msg);
608 }
609 }
610 }
611 let mut error_message = format!("Failed to create worktree: {err}");
612 if !rollback_failures.is_empty() {
613 error_message.push_str("\n\nFailed to clean up: ");
614 error_message.push_str(&rollback_failures.join(", "));
615 }
616 Err(anyhow!(error_message))
617}
618
619/// Propagates worktree trust from the source workspace to the new workspace.
620/// If the source project's worktrees are all trusted, the new worktree paths
621/// will also be trusted automatically.
622fn maybe_propagate_worktree_trust(
623 source_workspace: &WeakEntity<Workspace>,
624 new_workspace: &Entity<Workspace>,
625 paths: &[PathBuf],
626 cx: &mut AsyncWindowContext,
627) {
628 cx.update(|_, cx| {
629 if ProjectSettings::get_global(cx).session.trust_all_worktrees {
630 return;
631 }
632 let source_is_trusted = source_workspace
633 .upgrade()
634 .map(|workspace| {
635 let source_worktree_store = workspace.read(cx).project().read(cx).worktree_store();
636 !TrustedWorktrees::has_restricted_worktrees(&source_worktree_store, cx)
637 })
638 .unwrap_or(false);
639
640 if !source_is_trusted {
641 return;
642 }
643
644 let worktree_store = new_workspace.read(cx).project().read(cx).worktree_store();
645 let paths_to_trust: HashSet<_> = paths
646 .iter()
647 .filter_map(|path| {
648 let (worktree, _) = worktree_store.read(cx).find_worktree(path, cx)?;
649 Some(PathTrust::Worktree(worktree.read(cx).id()))
650 })
651 .collect();
652
653 if !paths_to_trust.is_empty() {
654 if let Some(trusted_store) = TrustedWorktrees::try_get_global(cx) {
655 trusted_store.update(cx, |store, cx| {
656 store.trust(&worktree_store, paths_to_trust, cx);
657 });
658 }
659 }
660 })
661 .ok();
662
663}
664
665/// Handles the `CreateWorktree` action generically, without any agent panel involvement.
666/// Creates a new git worktree, opens the workspace, restores layout and files.
667/// Errors are surfaced to the user via toasts; the new workspace handle is
668/// discarded. Use [`create_worktree_workspace`] when you need the resulting
669/// workspace (e.g., the `create_thread` agent tool spawns a thread in it).
670pub fn handle_create_worktree(
671 workspace: &mut Workspace,
672 action: &zed_actions::CreateWorktree,
673 window: &mut gpui::Window,
674 fallback_focused_dock: Option<DockPosition>,
675 cx: &mut gpui::Context<Workspace>,
676) {
677 let task = create_worktree_workspace_inner(
678 workspace,
679 action,
680 window,
681 fallback_focused_dock,
682 RemoteBranchFetchMode::Fetch,
683 // The user explicitly asked to create a worktree, so foreground it.
684 true,
685 cx,
686 );
687 task.detach_and_log_err(cx);
688}
689
690/// Outcome of [`create_worktree_workspace`].
691pub struct CreatedWorktreeWorkspace {
692 /// The newly opened workspace.
693 pub workspace: Entity<Workspace>,
694 /// True when the project contained more than one Zed worktree backed by
695 /// the same underlying git repository, so they were consolidated into a
696 /// single new worktree (they resolve to the same target path). Callers
697 /// that care — like the `create_thread` agent tool — can use this to warn
698 /// that the result may not reflect every source worktree's state.
699 pub consolidated_worktrees: bool,
700}
701
702/// Same as [`handle_create_worktree`], but returns a `Task` that resolves to
703/// the new workspace once worktree creation and post-open setup are
704/// complete. The caller receives errors as `Result`s and is expected to
705/// handle them. Note that a small set of early failures (no git repositories,
706/// disconnected remote, mid-creation `git fetch` failure) still surface a
707/// toast on the source workspace so the user understands why the action
708/// didn't take effect; the same error is also returned to the caller.
709///
710/// Used by the `create_thread` agent tool to spawn a sibling thread inside
711/// the newly-opened workspace.
712///
713/// The new workspace is opened in the **background** (added as a retained
714/// tab without switching to it or moving focus), and it's a clean checkout
715/// rather than inheriting the source workspace's open files and dock layout.
716/// This mirrors how the agent's non-worktree threads are created in the
717/// background rather than yanking the user away from what they're doing.
718pub fn create_worktree_workspace(
719 workspace: &mut Workspace,
720 action: &zed_actions::CreateWorktree,
721 window: &mut gpui::Window,
722 fallback_focused_dock: Option<DockPosition>,
723 cx: &mut gpui::Context<Workspace>,
724) -> Task<anyhow::Result<CreatedWorktreeWorkspace>> {
725 create_worktree_workspace_inner(
726 workspace,
727 action,
728 window,
729 fallback_focused_dock,
730 RemoteBranchFetchMode::Fetch,
731 // Agent-created worktree workspaces open in the background.
732 false,
733 cx,
734 )
735}
736
737fn create_worktree_workspace_inner(
738 workspace: &mut Workspace,
739 action: &zed_actions::CreateWorktree,
740 window: &mut gpui::Window,
741 fallback_focused_dock: Option<DockPosition>,
742 remote_branch_fetch_mode: RemoteBranchFetchMode,
743 activate: bool,
744 cx: &mut gpui::Context<Workspace>,
745) -> Task<anyhow::Result<CreatedWorktreeWorkspace>> {
746 let project = workspace.project().clone();
747
748 if project.read(cx).repositories(cx).is_empty() {
749 return Task::ready(Err(anyhow!(
750 "create_worktree: no git repository in the project"
751 )));
752 }
753 if project.read(cx).is_via_collab() {
754 return Task::ready(Err(anyhow!(
755 "create_worktree: not supported in collab projects"
756 )));
757 }
758
759 // Guard against concurrent creation. We treat a concurrent creation as
760 // a hard error here so the caller can surface it; the user-facing
761 // wrapper [`handle_create_worktree`] swallows the error via
762 // `detach_and_log_err`, matching the pre-existing silent return.
763 if workspace.active_worktree_creation().label.is_some() {
764 return Task::ready(Err(anyhow!("A worktree creation is already in progress")));
765 }
766
767 let previous_state =
768 workspace.capture_state_for_worktree_switch(window, fallback_focused_dock, cx);
769 let workspace_handle = workspace.weak_handle();
770 let window_handle = window.window_handle().downcast::<MultiWorkspace>();
771 let remote_connection_options = project.read(cx).remote_connection_options(cx);
772
773 let (git_repos, non_git_paths) = classify_worktrees(project.read(cx), cx);
774
775 if git_repos.is_empty() {
776 let toast_workspace = cx.entity();
777 show_error_toast(
778 toast_workspace,
779 "worktree create",
780 anyhow!("No git repositories found in the project"),
781 cx,
782 );
783 return Task::ready(Err(anyhow!("No git repositories found in the project")));
784 }
785
786 if remote_connection_options.is_some() {
787 let is_disconnected = project
788 .read(cx)
789 .remote_client()
790 .is_some_and(|client| client.read(cx).is_disconnected());
791 if is_disconnected {
792 let toast_workspace = cx.entity();
793 show_error_toast(
794 toast_workspace,
795 "worktree create",
796 anyhow!("Cannot create worktree: remote connection is not active"),
797 cx,
798 );
799 return Task::ready(Err(anyhow!(
800 "Cannot create worktree: remote connection is not active"
801 )));
802 }
803 }
804
805 let worktree_name = action.worktree_name.clone();
806 let branch_target = action.branch_target.clone();
807 let fetch_askpass_delegates = if remote_branch_fetch_mode.should_fetch() {
808 remote_branch_to_fetch(&branch_target)
809 .map(|(remote_name, _branch_name)| {
810 git_repos
811 .iter()
812 .map(|_| {
813 create_worktree_askpass_delegate(
814 workspace_handle.clone(),
815 format!("git fetch {remote_name}"),
816 window,
817 cx,
818 )
819 })
820 .collect()
821 })
822 .unwrap_or_default()
823 } else {
824 Vec::new()
825 };
826 let display_name: SharedString = worktree_name
827 .as_deref()
828 .unwrap_or("worktree")
829 .to_string()
830 .into();
831
832 workspace.set_active_worktree_creation(Some(display_name), false, cx);
833
834 cx.spawn_in(window, async move |_workspace_entity, mut cx| {
835 let result = do_create_worktree(
836 git_repos,
837 non_git_paths,
838 worktree_name.clone(),
839 branch_target.clone(),
840 fetch_askpass_delegates,
841 remote_branch_fetch_mode,
842 previous_state,
843 workspace_handle.clone(),
844 window_handle,
845 remote_connection_options,
846 activate,
847 &mut cx,
848 )
849 .await;
850
851 if let Err(err) = &result {
852 log::error!("Failed to create worktree: {err}");
853 workspace_handle
854 .update(cx, |workspace, cx| {
855 workspace.set_active_worktree_creation(None, false, cx);
856 if let Some(fetch_error) = err.downcast_ref::<WorktreeFetchError>() {
857 let toast = cx.new(|cx| {
858 WorktreeFetchFailedToast::new(
859 workspace.weak_handle(),
860 worktree_name,
861 branch_target,
862 fallback_focused_dock,
863 fetch_error,
864 cx,
865 )
866 });
867 workspace.toggle_status_toast(toast, cx);
868 } else {
869 show_error_toast(cx.entity(), "worktree create", anyhow!("{err:#}"), cx);
870 }
871 })
872 .ok();
873 }
874
875 result
876 })
877}
878
879pub fn handle_switch_worktree(
880 workspace: &mut Workspace,
881 action: &zed_actions::SwitchWorktree,
882 window: &mut gpui::Window,
883 fallback_focused_dock: Option<DockPosition>,
884 cx: &mut gpui::Context<Workspace>,
885) {
886 let project = workspace.project().clone();
887
888 if project.read(cx).repositories(cx).is_empty() {
889 log::error!("switch_to_worktree: no git repository in the project");
890 return;
891 }
892 if project.read(cx).is_via_collab() {
893 log::error!("switch_to_worktree: not supported in collab projects");
894 return;
895 }
896
897 // Guard against concurrent creation
898 if workspace.active_worktree_creation().label.is_some() {
899 return;
900 }
901
902 let previous_state =
903 workspace.capture_state_for_worktree_switch(window, fallback_focused_dock, cx);
904 let workspace_handle = workspace.weak_handle();
905 let window_handle = window.window_handle().downcast::<MultiWorkspace>();
906 let remote_connection_options = project.read(cx).remote_connection_options(cx);
907
908 let (git_repos, non_git_paths) = classify_worktrees(project.read(cx), cx);
909
910 let git_repo_work_dirs: Vec<PathBuf> = git_repos
911 .iter()
912 .map(|repo| repo.read(cx).work_directory_abs_path.to_path_buf())
913 .collect();
914
915 let display_name: SharedString = action.display_name.clone().into();
916
917 workspace.set_active_worktree_creation(Some(display_name), true, cx);
918
919 let worktree_path = action.path.clone();
920
921 cx.spawn_in(window, async move |_workspace_entity, mut cx| {
922 let result = do_switch_worktree(
923 worktree_path,
924 git_repo_work_dirs,
925 non_git_paths,
926 previous_state,
927 workspace_handle.clone(),
928 window_handle,
929 remote_connection_options,
930 &mut cx,
931 )
932 .await;
933
934 if let Err(err) = &result {
935 log::error!("Failed to switch worktree: {err}");
936 workspace_handle
937 .update(cx, |workspace, cx| {
938 workspace.set_active_worktree_creation(None, false, cx);
939 show_error_toast(cx.entity(), "worktree switch", anyhow!("{err:#}"), cx);
940 })
941 .ok();
942 }
943
944 result
945 })
946 .detach_and_log_err(cx);
947}
948
949async fn do_create_worktree(
950 git_repos: Vec<Entity<Repository>>,
951 non_git_paths: Vec<PathBuf>,
952 worktree_name: Option<String>,
953 branch_target: NewWorktreeBranchTarget,
954 fetch_askpass_delegates: Vec<AskPassDelegate>,
955 remote_branch_fetch_mode: RemoteBranchFetchMode,
956 previous_state: PreviousWorkspaceState,
957 workspace: WeakEntity<Workspace>,
958 window_handle: Option<gpui::WindowHandle<MultiWorkspace>>,
959 remote_connection_options: Option<RemoteConnectionOptions>,
960 activate: bool,
961 cx: &mut AsyncWindowContext,
962) -> anyhow::Result<CreatedWorktreeWorkspace> {
963 // List existing worktrees from all repos to detect name collisions
964 let worktree_receivers: Vec<_> = cx.update(|_, cx| {
965 git_repos
966 .iter()
967 .map(|repo| repo.update(cx, |repo, _cx| repo.worktrees()))
968 .collect()
969 })?;
970 let worktree_directory_setting = cx.update(|_, cx| {
971 ProjectSettings::get_global(cx)
972 .git
973 .worktree_directory
974 .clone()
975 })?;
976
977 let mut existing_worktree_names = Vec::new();
978 let mut existing_worktree_paths = HashSet::default();
979 for result in futures::future::join_all(worktree_receivers).await {
980 match result {
981 Ok(Ok(worktrees)) => {
982 for worktree in worktrees {
983 if let Some(name) = worktree
984 .path
985 .parent()
986 .and_then(|p| p.file_name())
987 .and_then(|n| n.to_str())
988 {
989 existing_worktree_names.push(name.to_string());
990 }
991 existing_worktree_paths.insert(worktree.path.clone());
992 }
993 }
994 Ok(Err(err)) => {
995 Err::<(), _>(err).log_err();
996 }
997 Err(_) => {}
998 }
999 }
1000
1001 if remote_branch_fetch_mode.should_fetch()
1002 && let Some((remote_name, branch_name)) = remote_branch_to_fetch(&branch_target)
1003 {
1004 let remote_name = remote_name.to_string();
1005 let branch_name = branch_name.to_string();
1006 if let Err(error) = fetch_remote_for_worktree_base(
1007 &git_repos,
1008 remote_name.clone(),
1009 fetch_askpass_delegates,
1010 cx,
1011 )
1012 .await
1013 {
1014 return Err(WorktreeFetchError {
1015 remote_name,
1016 branch_name,
1017 source: error,
1018 }
1019 .into());
1020 }
1021 }
1022
1023 let mut rng = rand::rng();
1024
1025 let base_ref = resolve_worktree_branch_target(&branch_target);
1026
1027 let (creation_infos, path_remapping) = cx.update(|_, cx| {
1028 start_worktree_creations(
1029 &git_repos,
1030 worktree_name,
1031 &existing_worktree_names,
1032 &existing_worktree_paths,
1033 base_ref,
1034 &worktree_directory_setting,
1035 &mut rng,
1036 cx,
1037 )
1038 })??;
1039
1040 let fs = cx.update(|_, cx| <dyn Fs>::global(cx))?;
1041
1042 let creation_pairs: Vec<(Entity<Repository>, PathBuf)> = creation_infos
1043 .iter()
1044 .map(|(repo, path, _)| (repo.clone(), path.clone()))
1045 .collect();
1046
1047 let created_paths = await_and_rollback_on_failure(creation_infos, fs, cx).await?;
1048
1049 // Record each created worktree so thread archival can later verify that
1050 // Zed created it before deleting it from disk. Failures are non-fatal:
1051 // the worktree just won't be eligible for automatic archival.
1052 for (repo, path) in creation_pairs {
1053 crate::created_worktrees::record_created_worktree_for_repo(
1054 &repo,
1055 &path,
1056 remote_connection_options.as_ref(),
1057 cx,
1058 )
1059 .await;
1060 }
1061
1062 // `path_remapping` has one entry per source git repo, while `created_paths`
1063 // has one per *unique* target worktree. When the former is larger, two or
1064 // more source repos were linked worktrees of the same underlying
1065 // repository and `start_worktree_creations` consolidated them.
1066 let consolidated_worktrees = path_remapping.len() > created_paths.len();
1067
1068 let mut all_paths = created_paths;
1069 let has_non_git = !non_git_paths.is_empty();
1070 all_paths.extend(non_git_paths.iter().cloned());
1071
1072 let workspace = open_worktree_workspace(
1073 all_paths,
1074 path_remapping,
1075 non_git_paths,
1076 has_non_git,
1077 previous_state,
1078 workspace,
1079 window_handle,
1080 remote_connection_options,
1081 WorktreeOperation::Create,
1082 activate,
1083 cx,
1084 )
1085 .await?;
1086
1087 Ok(CreatedWorktreeWorkspace {
1088 workspace,
1089 consolidated_worktrees,
1090 })
1091}
1092
1093async fn do_switch_worktree(
1094 worktree_path: PathBuf,
1095 git_repo_work_dirs: Vec<PathBuf>,
1096 non_git_paths: Vec<PathBuf>,
1097 previous_state: PreviousWorkspaceState,
1098 workspace: WeakEntity<Workspace>,
1099 window_handle: Option<gpui::WindowHandle<MultiWorkspace>>,
1100 remote_connection_options: Option<RemoteConnectionOptions>,
1101 cx: &mut AsyncWindowContext,
1102) -> anyhow::Result<Entity<Workspace>> {
1103 let path_remapping: Vec<(PathBuf, PathBuf)> = git_repo_work_dirs
1104 .iter()
1105 .map(|work_dir| (work_dir.clone(), worktree_path.clone()))
1106 .collect();
1107
1108 let mut all_paths = vec![worktree_path];
1109 let has_non_git = !non_git_paths.is_empty();
1110 all_paths.extend(non_git_paths.iter().cloned());
1111
1112 open_worktree_workspace(
1113 all_paths,
1114 path_remapping,
1115 non_git_paths,
1116 has_non_git,
1117 previous_state,
1118 workspace,
1119 window_handle,
1120 remote_connection_options,
1121 WorktreeOperation::Switch,
1122 // Switching is always an explicit, foreground user action.
1123 true,
1124 cx,
1125 )
1126 .await
1127}
1128
1129/// Core workspace opening logic shared by both create and switch flows.
1130/// Returns the newly opened workspace entity so callers can do post-open
1131/// work (e.g., the `create_thread` agent tool spawns a thread inside it).
1132async fn open_worktree_workspace(
1133 all_paths: Vec<PathBuf>,
1134 path_remapping: Vec<(PathBuf, PathBuf)>,
1135 non_git_paths: Vec<PathBuf>,
1136 has_non_git: bool,
1137 previous_state: PreviousWorkspaceState,
1138 workspace: WeakEntity<Workspace>,
1139 window_handle: Option<gpui::WindowHandle<MultiWorkspace>>,
1140 remote_connection_options: Option<RemoteConnectionOptions>,
1141 operation: WorktreeOperation,
1142 activate: bool,
1143 cx: &mut AsyncWindowContext,
1144) -> anyhow::Result<Entity<Workspace>> {
1145 let window_handle = window_handle
1146 .ok_or_else(|| anyhow!("No window handle available for workspace creation"))?;
1147
1148 let focused_dock = previous_state.focused_dock;
1149
1150 let is_creating_new_worktree = matches!(operation, WorktreeOperation::Create);
1151
1152 // When `activate` is false the new workspace is opened in the background
1153 // (e.g. the agent's `create_thread` tool), so it should be a clean
1154 // checkout rather than inheriting the source workspace's open files and
1155 // dock layout. The state transfer only applies when we're foregrounding
1156 // a freshly-created worktree for the user.
1157 let transfer_state = is_creating_new_worktree && activate;
1158
1159 let source_for_transfer = if transfer_state {
1160 Some(workspace.clone())
1161 } else {
1162 None
1163 };
1164
1165 let (workspace_task, modal_workspace) =
1166 window_handle.update(cx, |multi_workspace, window, cx| {
1167 let path_list = util::path_list::PathList::new(&all_paths);
1168 let active_workspace = multi_workspace.workspace().clone();
1169 let modal_workspace = active_workspace.clone();
1170
1171 let init: Option<
1172 Box<
1173 dyn FnOnce(&mut Workspace, &mut gpui::Window, &mut gpui::Context<Workspace>)
1174 + Send,
1175 >,
1176 > = if transfer_state {
1177 let dock_structure = previous_state.dock_structure;
1178 Some(Box::new(
1179 move |workspace: &mut Workspace,
1180 window: &mut gpui::Window,
1181 cx: &mut gpui::Context<Workspace>| {
1182 workspace.set_dock_structure(dock_structure, window, cx);
1183 },
1184 ))
1185 } else {
1186 None
1187 };
1188
1189 let task = multi_workspace.find_or_create_workspace_with_source_workspace(
1190 path_list,
1191 remote_connection_options,
1192 None,
1193 move |connection_options, window, cx| {
1194 remote_connection::connect_with_modal(
1195 &active_workspace,
1196 connection_options,
1197 window,
1198 cx,
1199 )
1200 },
1201 &[],
1202 init,
1203 OpenMode::Add,
1204 source_for_transfer.clone(),
1205 window,
1206 cx,
1207 );
1208 (task, modal_workspace)
1209 })?;
1210
1211 let result = workspace_task.await;
1212 remote_connection::dismiss_connection_modal(&modal_workspace, cx);
1213 let new_workspace = result?;
1214
1215 let panels_task = new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task());
1216
1217 if let Some(task) = panels_task {
1218 task.await.log_err();
1219 }
1220
1221 new_workspace
1222 .update(cx, |workspace, cx| {
1223 workspace.project().read(cx).wait_for_initial_scan(cx)
1224 })
1225 .await;
1226
1227 new_workspace
1228 .update(cx, |workspace, cx| {
1229 let repos = workspace
1230 .project()
1231 .read(cx)
1232 .repositories(cx)
1233 .values()
1234 .cloned()
1235 .collect::<Vec<_>>();
1236
1237 let tasks = repos
1238 .into_iter()
1239 .map(|repo| repo.update(cx, |repo, _| repo.barrier()));
1240 futures::future::join_all(tasks)
1241 })
1242 .await;
1243
1244 maybe_propagate_worktree_trust(&workspace, &new_workspace, &all_paths, cx);
1245
1246 if transfer_state {
1247 window_handle.update(cx, |_multi_workspace, window, cx| {
1248 new_workspace.update(cx, |workspace, cx| {
1249 if has_non_git {
1250 struct WorktreeCreationToast;
1251 let toast_id =
1252 workspace::notifications::NotificationId::unique::<WorktreeCreationToast>();
1253 workspace.show_toast(
1254 workspace::Toast::new(
1255 toast_id,
1256 "Some project folders are not git repositories. \
1257 They were included as-is without creating a worktree.",
1258 ),
1259 cx,
1260 );
1261 }
1262
1263 // Remap every previously-open file path into the new worktree.
1264 let remap_path = |original_path: PathBuf| -> Option<PathBuf> {
1265 let best_match = path_remapping
1266 .iter()
1267 .filter_map(|(old_root, new_root)| {
1268 original_path.strip_prefix(old_root).ok().map(|relative| {
1269 (old_root.components().count(), new_root.join(relative))
1270 })
1271 })
1272 .max_by_key(|(depth, _)| *depth);
1273
1274 if let Some((_, remapped_path)) = best_match {
1275 return Some(remapped_path);
1276 }
1277
1278 for non_git in &non_git_paths {
1279 if original_path.starts_with(non_git) {
1280 return Some(original_path);
1281 }
1282 }
1283 None
1284 };
1285
1286 let remapped_active_path =
1287 previous_state.active_file_path.and_then(|p| remap_path(p));
1288
1289 let mut paths_to_open: Vec<PathBuf> = Vec::new();
1290 let mut seen = HashSet::default();
1291 for path in previous_state.open_file_paths {
1292 if let Some(remapped) = remap_path(path) {
1293 if remapped_active_path.as_ref() != Some(&remapped)
1294 && seen.insert(remapped.clone())
1295 {
1296 paths_to_open.push(remapped);
1297 }
1298 }
1299 }
1300
1301 if let Some(active) = &remapped_active_path {
1302 if seen.insert(active.clone()) {
1303 paths_to_open.push(active.clone());
1304 }
1305 }
1306
1307 if !paths_to_open.is_empty() {
1308 let should_focus_center = focused_dock.is_none();
1309 let open_task = workspace.open_paths(
1310 paths_to_open,
1311 workspace::OpenOptions {
1312 focus: Some(false),
1313 ..Default::default()
1314 },
1315 None,
1316 window,
1317 cx,
1318 );
1319 cx.spawn_in(window, async move |workspace, cx| {
1320 for item in open_task.await.into_iter().flatten() {
1321 item.log_err();
1322 }
1323 if should_focus_center {
1324 workspace.update_in(cx, |workspace, window, cx| {
1325 workspace.focus_center_pane(window, cx);
1326 })?;
1327 }
1328 anyhow::Ok(())
1329 })
1330 .detach_and_log_err(cx);
1331 }
1332 });
1333 })?;
1334 }
1335
1336 // Clear the creation status on the SOURCE workspace so its title bar
1337 // stops showing the loading indicator immediately.
1338 workspace
1339 .update(cx, |ws, cx| {
1340 ws.set_active_worktree_creation(None, false, cx);
1341 })
1342 .ok();
1343
1344 window_handle.update(cx, |multi_workspace, window, cx| {
1345 if activate {
1346 multi_workspace.activate(new_workspace.clone(), source_for_transfer, window, cx);
1347 } else {
1348 // Background open: register the new workspace as a retained tab
1349 // but leave the user where they are.
1350 multi_workspace.add_background_workspace(new_workspace.clone(), window, cx);
1351 }
1352
1353 if is_creating_new_worktree {
1354 new_workspace.update(cx, |workspace, cx| {
1355 // Run create-worktree setup hooks regardless of foreground vs
1356 // background — the worktree was created either way.
1357 workspace.run_create_worktree_tasks(window, cx);
1358
1359 if activate && let Some(dock_position) = focused_dock {
1360 let dock = workspace.dock_at_position(dock_position);
1361 if let Some(panel) = dock.read(cx).active_panel() {
1362 panel.panel_focus_handle(cx).focus(window, cx);
1363 }
1364 }
1365 });
1366 }
1367 })?;
1368
1369 Ok(new_workspace)
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374 use super::*;
1375 use fs::Fs;
1376 use gpui::{App, Task, TestAppContext};
1377 use language::language_settings::AllLanguageSettings;
1378 use project::project_settings::ProjectSettings;
1379 use project::task_store::{TaskSettingsLocation, TaskStore};
1380 use project::{FakeFs, WorktreeSettings};
1381 use serde_json::json;
1382 use settings::{SettingsLocation, SettingsStore};
1383 use std::path::{Path, PathBuf};
1384 use std::process::ExitStatus;
1385 use std::sync::Mutex;
1386 use task::SpawnInTerminal;
1387 use theme::LoadThemes;
1388 use util::path;
1389 use util::rel_path::rel_path;
1390 use workspace::{TerminalProvider, WorkspaceSettings};
1391
1392 struct CountingTerminalProvider {
1393 spawned_task_labels: Arc<Mutex<Vec<String>>>,
1394 }
1395
1396 impl TerminalProvider for CountingTerminalProvider {
1397 fn spawn(
1398 &self,
1399 task: SpawnInTerminal,
1400 _window: &mut ui::Window,
1401 _cx: &mut App,
1402 ) -> Task<Option<anyhow::Result<ExitStatus>>> {
1403 self.spawned_task_labels
1404 .lock()
1405 .expect("terminal spawn mutex should not be poisoned")
1406 .push(task.label);
1407 Task::ready(Some(Ok(ExitStatus::default())))
1408 }
1409 }
1410
1411 fn init_test(cx: &mut TestAppContext) {
1412 zlog::init_test();
1413 cx.update(|cx| {
1414 let settings_store = SettingsStore::test(cx);
1415 cx.set_global(settings_store);
1416 theme_settings::init(LoadThemes::JustBase, cx);
1417 AllLanguageSettings::register(cx);
1418 editor::init(cx);
1419 ProjectSettings::register(cx);
1420 WorktreeSettings::register(cx);
1421 WorkspaceSettings::register(cx);
1422 TaskStore::init(None);
1423 });
1424 }
1425
1426 fn install_counting_provider_and_worktree_hook(
1427 workspace: &Entity<Workspace>,
1428 spawned_task_labels: &Arc<Mutex<Vec<String>>>,
1429 main_project_root: &Path,
1430 hook_tasks_json: &str,
1431 cx: &mut App,
1432 ) {
1433 workspace.update(cx, |workspace, cx| {
1434 workspace.set_terminal_provider(CountingTerminalProvider {
1435 spawned_task_labels: spawned_task_labels.clone(),
1436 });
1437
1438 let project = workspace.project().clone();
1439 let Some(worktree) = project.read(cx).worktrees(cx).next() else {
1440 return;
1441 };
1442 let worktree = worktree.read(cx);
1443 let worktree_id = worktree.id();
1444 let worktree_root = worktree.abs_path().to_path_buf();
1445 if worktree_root == main_project_root {
1446 return;
1447 }
1448
1449 let Some(task_inventory) = project
1450 .read(cx)
1451 .task_store()
1452 .read(cx)
1453 .task_inventory()
1454 .cloned()
1455 else {
1456 return;
1457 };
1458 task_inventory.update(cx, |inventory, _| {
1459 inventory
1460 .update_file_based_tasks(
1461 TaskSettingsLocation::Worktree(SettingsLocation {
1462 worktree_id,
1463 path: rel_path(".zed"),
1464 }),
1465 Some(hook_tasks_json),
1466 )
1467 .expect("should inject create_worktree hook tasks for linked worktree");
1468 });
1469 });
1470 }
1471
1472 #[gpui::test]
1473 async fn test_create_worktree_hook_does_not_run_when_switching_back_to_main_worktree(
1474 cx: &mut TestAppContext,
1475 ) {
1476 init_test(cx);
1477
1478 let hook_tasks_json = r#"[{"label":"setup worktree","command":"echo","hide":"never","hooks":["create_worktree"]}]"#;
1479 let fs = FakeFs::new(cx.background_executor.clone());
1480 cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
1481 fs.insert_tree(
1482 "/root",
1483 json!({
1484 "project": {
1485 ".git": {},
1486 ".zed": {
1487 "tasks.json": hook_tasks_json,
1488 },
1489 "src": {
1490 "main.rs": "fn main() {}",
1491 },
1492 },
1493 }),
1494 )
1495 .await;
1496
1497 let main_project_root = PathBuf::from(path!("/root/project"));
1498 let project = Project::test(fs.clone(), [main_project_root.as_path()], cx).await;
1499 project
1500 .update(cx, |project, cx| project.git_scans_complete(cx))
1501 .await;
1502
1503 let (multi_workspace, cx) =
1504 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1505
1506 let spawned_task_labels = Arc::new(Mutex::new(Vec::new()));
1507 multi_workspace.update(cx, |multi_workspace, cx| {
1508 multi_workspace.retain_active_workspace(cx);
1509 let active_workspace = multi_workspace.workspace().clone();
1510 install_counting_provider_and_worktree_hook(
1511 &active_workspace,
1512 &spawned_task_labels,
1513 &main_project_root,
1514 hook_tasks_json,
1515 cx,
1516 );
1517 });
1518
1519 let main_workspace =
1520 multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone());
1521 main_workspace.update_in(cx, |workspace, window, cx| {
1522 handle_create_worktree(
1523 workspace,
1524 &zed_actions::CreateWorktree {
1525 worktree_name: Some("feature".to_string()),
1526 branch_target: NewWorktreeBranchTarget::CurrentBranch,
1527 },
1528 window,
1529 None,
1530 cx,
1531 );
1532 });
1533 cx.run_until_parked();
1534
1535 let active_workspace =
1536 multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone());
1537 cx.update(|_, cx| {
1538 install_counting_provider_and_worktree_hook(
1539 &active_workspace,
1540 &spawned_task_labels,
1541 &main_project_root,
1542 hook_tasks_json,
1543 cx,
1544 );
1545 });
1546 active_workspace.update_in(cx, |workspace, window, cx| {
1547 workspace.run_create_worktree_tasks(window, cx);
1548 });
1549 cx.run_until_parked();
1550
1551 assert_eq!(
1552 spawned_task_labels
1553 .lock()
1554 .expect("terminal spawn mutex should not be poisoned")
1555 .as_slice(),
1556 ["setup worktree"],
1557 "create_worktree hook should run once for the created linked worktree"
1558 );
1559
1560 active_workspace.update_in(cx, |workspace, window, cx| {
1561 handle_switch_worktree(
1562 workspace,
1563 &zed_actions::SwitchWorktree {
1564 path: main_project_root.clone(),
1565 display_name: "project".to_string(),
1566 },
1567 window,
1568 None,
1569 cx,
1570 );
1571 });
1572 cx.run_until_parked();
1573
1574 assert_eq!(
1575 spawned_task_labels
1576 .lock()
1577 .expect("terminal spawn mutex should not be poisoned")
1578 .as_slice(),
1579 ["setup worktree"],
1580 "switching back to the main worktree should not rerun create_worktree hooks"
1581 );
1582 }
1583
1584 #[gpui::test]
1585 async fn test_linked_worktree_inherits_trust_from_main_worktree(cx: &mut TestAppContext) {
1586 init_test(cx);
1587 cx.update(|cx| {
1588 project::trusted_worktrees::init(collections::HashMap::default(), cx);
1589 });
1590
1591 let fs = FakeFs::new(cx.background_executor.clone());
1592 cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
1593 fs.insert_tree(
1594 "/root",
1595 json!({
1596 "project": {
1597 ".git": {},
1598 "src": {
1599 "main.rs": "fn main() {}",
1600 },
1601 },
1602 }),
1603 )
1604 .await;
1605
1606 let main_project_root = PathBuf::from(path!("/root/project"));
1607 let project =
1608 Project::test_with_worktree_trust(fs.clone(), [main_project_root.as_path()], cx).await;
1609 project
1610 .update(cx, |project, cx| project.git_scans_complete(cx))
1611 .await;
1612
1613 // The main worktree starts restricted; trust it explicitly
1614 let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
1615 let main_worktree_id = worktree_store.read_with(cx, |store, cx| {
1616 store
1617 .worktrees()
1618 .next()
1619 .map(|wt| wt.read(cx).id())
1620 .expect("should have a worktree")
1621 });
1622 let trusted_store = cx
1623 .read(|cx| project::trusted_worktrees::TrustedWorktrees::try_get_global(cx))
1624 .expect("trust store should exist");
1625 trusted_store.update(cx, |store, cx| {
1626 store.trust(
1627 &worktree_store,
1628 collections::HashSet::from_iter([project::trusted_worktrees::PathTrust::Worktree(
1629 main_worktree_id,
1630 )]),
1631 cx,
1632 );
1633 });
1634
1635 // Verify main worktree is now trusted
1636 let has_restricted = cx.read(|cx| {
1637 project::trusted_worktrees::TrustedWorktrees::has_restricted_worktrees(
1638 &worktree_store,
1639 cx,
1640 )
1641 });
1642 assert!(
1643 !has_restricted,
1644 "main worktree should be trusted after explicit trust"
1645 );
1646
1647 let (multi_workspace, cx) =
1648 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1649 multi_workspace.update(cx, |multi_workspace, cx| {
1650 multi_workspace.retain_active_workspace(cx);
1651 });
1652
1653 // Create a linked worktree from the trusted main worktree
1654 let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1655 main_workspace.update_in(cx, |workspace, window, cx| {
1656 handle_create_worktree(
1657 workspace,
1658 &zed_actions::CreateWorktree {
1659 worktree_name: Some("feature".to_string()),
1660 branch_target: NewWorktreeBranchTarget::CurrentBranch,
1661 },
1662 window,
1663 None,
1664 cx,
1665 );
1666 });
1667 cx.run_until_parked();
1668
1669 // The new workspace (linked worktree) should inherit trust
1670 let new_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1671 let new_worktree_store =
1672 new_workspace.read_with(cx, |ws, cx| ws.project().read(cx).worktree_store());
1673 let new_has_restricted = cx.read(|cx| {
1674 project::trusted_worktrees::TrustedWorktrees::has_restricted_worktrees(
1675 &new_worktree_store,
1676 cx,
1677 )
1678 });
1679 assert!(
1680 !new_has_restricted,
1681 "linked worktree should inherit trust from the main worktree"
1682 );
1683
1684 // The security modal it used to assert against no longer exists:
1685 // OMEGA-DELTA-0009 deleted the Restricted Mode UI outright. The
1686 // meaningful half of this test is the trust inheritance asserted
1687 // above, which still holds.
1688 }
1689
1690 #[test]
1691 fn test_remote_branch_name_parse() {
1692 assert_eq!(
1693 RemoteBranchName::parse("refs/remotes/origin/main"),
1694 Some(RemoteBranchName {
1695 remote_name: "origin".to_string(),
1696 branch_name: "main".to_string(),
1697 })
1698 );
1699 assert_eq!(
1700 RemoteBranchName::parse("upstream/feature/foo"),
1701 Some(RemoteBranchName {
1702 remote_name: "upstream".to_string(),
1703 branch_name: "feature/foo".to_string(),
1704 })
1705 );
1706 assert_eq!(RemoteBranchName::parse("main"), None);
1707 assert_eq!(RemoteBranchName::parse("origin/"), None);
1708 }
1709
1710 #[test]
1711 fn test_worktree_create_targets() {
1712 let origin_main = RemoteBranchName {
1713 remote_name: "origin".to_string(),
1714 branch_name: "main".to_string(),
1715 };
1716
1717 // Multiple repositories: only the current branch, regardless of default.
1718 assert_eq!(
1719 worktree_create_targets(true, Some(origin_main.clone()), Some("feature")),
1720 vec![WorktreeCreateTarget::CurrentBranch]
1721 );
1722
1723 // Default branch differs from current: offer both, default first.
1724 assert_eq!(
1725 worktree_create_targets(false, Some(origin_main.clone()), Some("feature")),
1726 vec![
1727 WorktreeCreateTarget::DefaultBranch(origin_main.clone()),
1728 WorktreeCreateTarget::CurrentBranch,
1729 ]
1730 );
1731
1732 // Current branch matches the default: only the default branch entry.
1733 assert_eq!(
1734 worktree_create_targets(false, Some(origin_main.clone()), Some("main")),
1735 vec![WorktreeCreateTarget::DefaultBranch(origin_main)]
1736 );
1737
1738 // No default branch resolved: fall back to the current branch.
1739 assert_eq!(
1740 worktree_create_targets(false, None, Some("feature")),
1741 vec![WorktreeCreateTarget::CurrentBranch]
1742 );
1743 }
1744
1745 #[test]
1746 fn test_worktree_create_target_branch_label() {
1747 let origin_main = RemoteBranchName {
1748 remote_name: "origin".to_string(),
1749 branch_name: "main".to_string(),
1750 };
1751 assert_eq!(
1752 WorktreeCreateTarget::DefaultBranch(origin_main).branch_label(false, Some("feature")),
1753 "origin/main"
1754 );
1755 assert_eq!(
1756 WorktreeCreateTarget::CurrentBranch.branch_label(false, Some("feature")),
1757 "feature"
1758 );
1759 // Detached HEAD falls back to "HEAD".
1760 assert_eq!(
1761 WorktreeCreateTarget::CurrentBranch.branch_label(false, None),
1762 "HEAD"
1763 );
1764 // Multiple repositories pluralize the current branch.
1765 assert_eq!(
1766 WorktreeCreateTarget::CurrentBranch.branch_label(true, Some("feature")),
1767 "current branches"
1768 );
1769 }
1770}
1771