git: Forbid main git worktree deletion from worktree picker (#52841)

Anthony Eid and Eric Holk created

This operation would always fail in the UI because the git binary
already disallowed it. This PR just makes the UI more inline with what's
actually valid

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [ ] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- git: Forbid main git worktree deletion from git worktree picker

---------

Co-authored-by: Eric Holk <eric@zed.dev>

Change summary

crates/collab/tests/integration/git_tests.rs |  4 ++++
crates/fs/src/fake_git_repo.rs               |  2 ++
crates/git/src/repository.rs                 | 18 +++++++++++++++++-
crates/git_ui/src/worktree_picker.rs         | 19 +++++++++++++------
crates/project/src/git_store.rs              |  2 ++
crates/proto/proto/git.proto                 |  1 +
crates/sidebar/src/project_group_builder.rs  |  1 +
crates/sidebar/src/sidebar_tests.rs          | 17 +++++++++++++++++
8 files changed, 57 insertions(+), 7 deletions(-)

Detailed changes

crates/collab/tests/integration/git_tests.rs 🔗

@@ -401,16 +401,19 @@ async fn test_linked_worktrees_sync(
                 path: PathBuf::from(path!("/project")),
                 ref_name: Some("refs/heads/main".into()),
                 sha: "aaa111".into(),
+                is_main: false,
             });
             state.worktrees.push(GitWorktree {
                 path: PathBuf::from(path!("/project/feature-branch")),
                 ref_name: Some("refs/heads/feature-branch".into()),
                 sha: "bbb222".into(),
+                is_main: false,
             });
             state.worktrees.push(GitWorktree {
                 path: PathBuf::from(path!("/project/bugfix-branch")),
                 ref_name: Some("refs/heads/bugfix-branch".into()),
                 sha: "ccc333".into(),
+                is_main: false,
             });
         })
         .unwrap();
@@ -480,6 +483,7 @@ async fn test_linked_worktrees_sync(
                 path: PathBuf::from(path!("/project/hotfix-branch")),
                 ref_name: Some("refs/heads/hotfix-branch".into()),
                 sha: "ddd444".into(),
+                is_main: false,
             });
         })
         .unwrap();

crates/fs/src/fake_git_repo.rs 🔗

@@ -429,6 +429,7 @@ impl GitRepository for FakeGitRepository {
                 path: work_dir,
                 ref_name: Some(branch_ref.into()),
                 sha: head_sha.into(),
+                is_main: true,
             };
             let mut all = vec![main_worktree];
             all.extend(state.worktrees.iter().cloned());
@@ -470,6 +471,7 @@ impl GitRepository for FakeGitRepository {
                         path,
                         ref_name: Some(ref_name.into()),
                         sha: sha.into(),
+                        is_main: false,
                     });
                     state.branches.insert(branch_name);
                     Ok::<(), anyhow::Error>(())

crates/git/src/repository.rs 🔗

@@ -238,6 +238,7 @@ pub struct Worktree {
     pub ref_name: Option<SharedString>,
     // todo(git_worktree) This type should be a Oid
     pub sha: SharedString,
+    pub is_main: bool,
 }
 
 impl Worktree {
@@ -259,6 +260,7 @@ impl Worktree {
 
 pub fn parse_worktrees_from_str<T: AsRef<str>>(raw_worktrees: T) -> Vec<Worktree> {
     let mut worktrees = Vec::new();
+    let mut is_first = true;
     let normalized = raw_worktrees.as_ref().replace("\r\n", "\n");
     let entries = normalized.split("\n\n");
     for entry in entries {
@@ -286,7 +288,9 @@ pub fn parse_worktrees_from_str<T: AsRef<str>>(raw_worktrees: T) -> Vec<Worktree
                 path: PathBuf::from(path),
                 ref_name: ref_name.map(Into::into),
                 sha: sha.into(),
-            })
+                is_main: is_first,
+            });
+            is_first = false;
         }
     }
 
@@ -3876,6 +3880,7 @@ mod tests {
         assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
         assert_eq!(result[0].sha.as_ref(), "abc123def");
         assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
+        assert!(result[0].is_main);
 
         // Multiple worktrees
         let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
@@ -3884,8 +3889,10 @@ mod tests {
         assert_eq!(result.len(), 2);
         assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
         assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
+        assert!(result[0].is_main);
         assert_eq!(result[1].path, PathBuf::from("/home/user/project-wt"));
         assert_eq!(result[1].ref_name, Some("refs/heads/feature".into()));
+        assert!(!result[1].is_main);
 
         // Detached HEAD entry (included with ref_name: None)
         let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
@@ -3894,9 +3901,11 @@ mod tests {
         assert_eq!(result.len(), 2);
         assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
         assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
+        assert!(result[0].is_main);
         assert_eq!(result[1].path, PathBuf::from("/home/user/detached"));
         assert_eq!(result[1].ref_name, None);
         assert_eq!(result[1].sha.as_ref(), "def456");
+        assert!(!result[1].is_main);
 
         // Bare repo entry (included with ref_name: None)
         let input = "worktree /home/user/bare.git\nHEAD abc123\nbare\n\n\
@@ -3905,8 +3914,10 @@ mod tests {
         assert_eq!(result.len(), 2);
         assert_eq!(result[0].path, PathBuf::from("/home/user/bare.git"));
         assert_eq!(result[0].ref_name, None);
+        assert!(result[0].is_main);
         assert_eq!(result[1].path, PathBuf::from("/home/user/project"));
         assert_eq!(result[1].ref_name, Some("refs/heads/main".into()));
+        assert!(!result[1].is_main);
 
         // Extra porcelain lines (locked, prunable) should be ignored
         let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
@@ -3916,13 +3927,16 @@ mod tests {
         assert_eq!(result.len(), 3);
         assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
         assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
+        assert!(result[0].is_main);
         assert_eq!(result[1].path, PathBuf::from("/home/user/locked-wt"));
         assert_eq!(result[1].ref_name, Some("refs/heads/locked-branch".into()));
+        assert!(!result[1].is_main);
         assert_eq!(result[2].path, PathBuf::from("/home/user/prunable-wt"));
         assert_eq!(
             result[2].ref_name,
             Some("refs/heads/prunable-branch".into())
         );
+        assert!(!result[2].is_main);
 
         // Leading/trailing whitespace on lines should be tolerated
         let input =
@@ -3932,6 +3946,7 @@ mod tests {
         assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
         assert_eq!(result[0].sha.as_ref(), "abc123");
         assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
+        assert!(result[0].is_main);
 
         // Windows-style line endings should be handled
         let input = "worktree /home/user/project\r\nHEAD abc123\r\nbranch refs/heads/main\r\n\r\n";
@@ -3940,6 +3955,7 @@ mod tests {
         assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
         assert_eq!(result[0].sha.as_ref(), "abc123");
         assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
+        assert!(result[0].is_main);
     }
 
     #[gpui::test]

crates/git_ui/src/worktree_picker.rs 🔗

@@ -254,6 +254,14 @@ struct WorktreeEntry {
     is_new: bool,
 }
 
+impl WorktreeEntry {
+    fn can_delete(&self, forbidden_deletion_path: Option<&PathBuf>) -> bool {
+        !self.is_new
+            && !self.worktree.is_main
+            && forbidden_deletion_path != Some(&self.worktree.path)
+    }
+}
+
 pub struct WorktreeListDelegate {
     matches: Vec<WorktreeEntry>,
     all_worktrees: Option<Vec<GitWorktree>>,
@@ -462,7 +470,7 @@ impl WorktreeListDelegate {
         let Some(entry) = self.matches.get(idx).cloned() else {
             return;
         };
-        if entry.is_new || self.forbidden_deletion_path.as_ref() == Some(&entry.worktree.path) {
+        if !entry.can_delete(self.forbidden_deletion_path.as_ref()) {
             return;
         }
         let Some(repo) = self.repo.clone() else {
@@ -719,6 +727,7 @@ impl PickerDelegate for WorktreeListDelegate {
                                 path: Default::default(),
                                 ref_name: Some(format!("refs/heads/{query}").into()),
                                 sha: Default::default(),
+                                is_main: false,
                             },
                             positions: Vec::new(),
                             is_new: true,
@@ -805,8 +814,7 @@ impl PickerDelegate for WorktreeListDelegate {
 
         let focus_handle = self.focus_handle.clone();
 
-        let can_delete =
-            !entry.is_new && self.forbidden_deletion_path.as_ref() != Some(&entry.worktree.path);
+        let can_delete = entry.can_delete(self.forbidden_deletion_path.as_ref());
 
         let delete_button = |entry_ix: usize| {
             IconButton::new(("delete-worktree", entry_ix), IconName::Trash)
@@ -894,9 +902,8 @@ impl PickerDelegate for WorktreeListDelegate {
         let focus_handle = self.focus_handle.clone();
         let selected_entry = self.matches.get(self.selected_index);
         let is_creating = selected_entry.is_some_and(|entry| entry.is_new);
-        let can_delete = selected_entry.is_some_and(|entry| {
-            !entry.is_new && self.forbidden_deletion_path.as_ref() != Some(&entry.worktree.path)
-        });
+        let can_delete = selected_entry
+            .is_some_and(|entry| entry.can_delete(self.forbidden_deletion_path.as_ref()));
 
         let footer_container = h_flex()
             .w_full()

crates/project/src/git_store.rs 🔗

@@ -7060,6 +7060,7 @@ fn worktree_to_proto(worktree: &git::repository::Worktree) -> proto::Worktree {
             .map(|s| s.to_string())
             .unwrap_or_default(),
         sha: worktree.sha.to_string(),
+        is_main: worktree.is_main,
     }
 }
 
@@ -7068,6 +7069,7 @@ fn proto_to_worktree(proto: &proto::Worktree) -> git::repository::Worktree {
         path: PathBuf::from(proto.path.clone()),
         ref_name: Some(SharedString::from(&proto.ref_name)),
         sha: proto.sha.clone().into(),
+        is_main: proto.is_main,
     }
 }
 

crates/proto/proto/git.proto 🔗

@@ -575,6 +575,7 @@ message Worktree {
   string path = 1;
   string ref_name = 2;
   string sha = 3;
+  bool is_main = 4;
 }
 
 message GitCreateWorktree {

crates/sidebar/src/project_group_builder.rs 🔗

@@ -255,6 +255,7 @@ mod tests {
                 path: std::path::PathBuf::from("/wt/feature-a"),
                 ref_name: Some("refs/heads/feature-a".into()),
                 sha: "abc".into(),
+                is_main: false,
             });
         })
         .expect("git state should be set");

crates/sidebar/src/sidebar_tests.rs 🔗

@@ -2463,6 +2463,7 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp
             path: std::path::PathBuf::from("/wt-feature-a"),
             ref_name: Some("refs/heads/feature-a".into()),
             sha: "aaa".into(),
+            is_main: false,
         });
     })
     .unwrap();
@@ -2577,6 +2578,7 @@ async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
                 path: std::path::PathBuf::from("/wt/rosewood"),
                 ref_name: Some("refs/heads/rosewood".into()),
                 sha: "abc".into(),
+                is_main: false,
             });
         })
         .unwrap();
@@ -2638,6 +2640,7 @@ async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
                 path: std::path::PathBuf::from("/wt/rosewood"),
                 ref_name: Some("refs/heads/rosewood".into()),
                 sha: "abc".into(),
+                is_main: false,
             });
         })
         .unwrap();
@@ -2739,11 +2742,13 @@ async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppC
             path: std::path::PathBuf::from("/wt-feature-a"),
             ref_name: Some("refs/heads/feature-a".into()),
             sha: "aaa".into(),
+            is_main: false,
         });
         state.worktrees.push(git::repository::Worktree {
             path: std::path::PathBuf::from("/wt-feature-b"),
             ref_name: Some("refs/heads/feature-b".into()),
             sha: "bbb".into(),
+            is_main: false,
         });
     })
     .unwrap();
@@ -2821,11 +2826,13 @@ async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut
             path: std::path::PathBuf::from("/wt-feature-a"),
             ref_name: Some("refs/heads/feature-a".into()),
             sha: "aaa".into(),
+            is_main: false,
         });
         state.worktrees.push(git::repository::Worktree {
             path: std::path::PathBuf::from("/wt-feature-b"),
             ref_name: Some("refs/heads/feature-b".into()),
             sha: "bbb".into(),
+            is_main: false,
         });
     })
     .unwrap();
@@ -2941,6 +2948,7 @@ async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext
                     path: std::path::PathBuf::from(format!("/worktrees/{repo}/{branch}/{repo}")),
                     ref_name: Some(format!("refs/heads/{branch}").into()),
                     sha: "aaa".into(),
+                    is_main: false,
                 });
             }
         })
@@ -3043,6 +3051,7 @@ async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext
                 path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")),
                 ref_name: Some("refs/heads/olivetti".into()),
                 sha: "aaa".into(),
+                is_main: false,
             });
         })
         .unwrap();
@@ -3133,6 +3142,7 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp
             path: std::path::PathBuf::from("/wt-feature-a"),
             ref_name: Some("refs/heads/feature-a".into()),
             sha: "aaa".into(),
+            is_main: false,
         });
     })
     .unwrap();
@@ -3248,6 +3258,7 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp
             path: std::path::PathBuf::from("/wt-feature-a"),
             ref_name: Some("refs/heads/feature-a".into()),
             sha: "aaa".into(),
+            is_main: false,
         });
     })
     .unwrap();
@@ -3354,6 +3365,7 @@ async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut
             path: std::path::PathBuf::from("/wt-feature-a"),
             ref_name: Some("refs/heads/feature-a".into()),
             sha: "aaa".into(),
+            is_main: false,
         });
     })
     .unwrap();
@@ -3459,6 +3471,7 @@ async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_proje
             path: std::path::PathBuf::from("/wt-feature-a"),
             ref_name: Some("refs/heads/feature-a".into()),
             sha: "aaa".into(),
+            is_main: false,
         });
     })
     .unwrap();
@@ -3609,6 +3622,7 @@ async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
             path: std::path::PathBuf::from("/wt-feature-a"),
             ref_name: Some("refs/heads/feature-a".into()),
             sha: "aaa".into(),
+            is_main: false,
         });
     })
     .unwrap();
@@ -4203,6 +4217,7 @@ async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppCon
             path: std::path::PathBuf::from("/wt-feature-a"),
             ref_name: Some("refs/heads/feature-a".into()),
             sha: "aaa".into(),
+            is_main: false,
         });
     })
     .unwrap();
@@ -4374,6 +4389,7 @@ async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut Test
             path: std::path::PathBuf::from("/wt-feature-a"),
             ref_name: Some("refs/heads/feature-a".into()),
             sha: "aaa".into(),
+            is_main: false,
         });
     })
     .unwrap();
@@ -5180,6 +5196,7 @@ mod property_test {
                             path: worktree_pathbuf,
                             ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
                             sha: "aaa".into(),
+                            is_main: false,
                         });
                     })
                     .unwrap();