git: Add option to branch from default branch in branch picker (#34663)

Anthony Eid and Cole Miller created

Closes #33700

The option shows up as an icon that appears on entries that would create
a new branch. You can also branch from the default by secondary
confirming, which the icon has a tooltip for as well.

We based the default branch on the results from this command: `git
symbolic-ref refs/remotes/upstream/HEAD` and fallback to `git
symbolic-ref refs/remotes/origin/HEAD`

Release Notes:

- Add option to create a branch from a default branch in git branch
picker

---------

Co-authored-by: Cole Miller <cole@zed.dev>

Change summary

crates/fs/src/fake_git_repo.rs     |  6 ++
crates/git/src/repository.rs       | 33 +++++++++++++++++
crates/git_ui/src/branch_picker.rs | 60 +++++++++++++++++++++++++++++--
crates/project/src/git_store.rs    | 19 ++++++++++
crates/proto/proto/git.proto       |  9 ++++
crates/proto/proto/zed.proto       |  5 ++
crates/proto/src/proto.rs          | 10 +++-
7 files changed, 133 insertions(+), 9 deletions(-)

Detailed changes

crates/fs/src/fake_git_repo.rs 🔗

@@ -10,7 +10,7 @@ use git::{
     },
     status::{FileStatus, GitStatus, StatusCode, TrackedStatus, UnmergedStatus},
 };
-use gpui::{AsyncApp, BackgroundExecutor};
+use gpui::{AsyncApp, BackgroundExecutor, SharedString};
 use ignore::gitignore::GitignoreBuilder;
 use rope::Rope;
 use smol::future::FutureExt as _;
@@ -491,4 +491,8 @@ impl GitRepository for FakeGitRepository {
     ) -> BoxFuture<'_, Result<String>> {
         unimplemented!()
     }
+
+    fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>> {
+        unimplemented!()
+    }
 }

crates/git/src/repository.rs 🔗

@@ -463,6 +463,8 @@ pub trait GitRepository: Send + Sync {
         base_checkpoint: GitRepositoryCheckpoint,
         target_checkpoint: GitRepositoryCheckpoint,
     ) -> BoxFuture<'_, Result<String>>;
+
+    fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>>;
 }
 
 pub enum DiffType {
@@ -1607,6 +1609,37 @@ impl GitRepository for RealGitRepository {
             })
             .boxed()
     }
+
+    fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>> {
+        let working_directory = self.working_directory();
+        let git_binary_path = self.git_binary_path.clone();
+
+        let executor = self.executor.clone();
+        self.executor
+            .spawn(async move {
+                let working_directory = working_directory?;
+                let git = GitBinary::new(git_binary_path, working_directory, executor);
+
+                if let Ok(output) = git
+                    .run(&["symbolic-ref", "refs/remotes/upstream/HEAD"])
+                    .await
+                {
+                    let output = output
+                        .strip_prefix("refs/remotes/upstream/")
+                        .map(|s| SharedString::from(s.to_owned()));
+                    return Ok(output);
+                }
+
+                let output = git
+                    .run(&["symbolic-ref", "refs/remotes/origin/HEAD"])
+                    .await?;
+
+                Ok(output
+                    .strip_prefix("refs/remotes/origin/")
+                    .map(|s| SharedString::from(s.to_owned())))
+            })
+            .boxed()
+    }
 }
 
 fn git_status_args(path_prefixes: &[RepoPath]) -> Vec<OsString> {

crates/git_ui/src/branch_picker.rs 🔗

@@ -13,7 +13,7 @@ use project::git_store::Repository;
 use std::sync::Arc;
 use time::OffsetDateTime;
 use time_format::format_local_timestamp;
-use ui::{HighlightedLabel, ListItem, ListItemSpacing, prelude::*};
+use ui::{HighlightedLabel, ListItem, ListItemSpacing, Tooltip, prelude::*};
 use util::ResultExt;
 use workspace::notifications::DetachAndPromptErr;
 use workspace::{ModalView, Workspace};
@@ -90,11 +90,21 @@ impl BranchList {
         let all_branches_request = repository
             .clone()
             .map(|repository| repository.update(cx, |repository, _| repository.branches()));
+        let default_branch_request = repository
+            .clone()
+            .map(|repository| repository.update(cx, |repository, _| repository.default_branch()));
 
         cx.spawn_in(window, async move |this, cx| {
             let mut all_branches = all_branches_request
                 .context("No active repository")?
                 .await??;
+            let default_branch = default_branch_request
+                .context("No active repository")?
+                .await
+                .map(Result::ok)
+                .ok()
+                .flatten()
+                .flatten();
 
             let all_branches = cx
                 .background_spawn(async move {
@@ -124,6 +134,7 @@ impl BranchList {
 
             this.update_in(cx, |this, window, cx| {
                 this.picker.update(cx, |picker, cx| {
+                    picker.delegate.default_branch = default_branch;
                     picker.delegate.all_branches = Some(all_branches);
                     picker.refresh(window, cx);
                 })
@@ -192,6 +203,7 @@ struct BranchEntry {
 pub struct BranchListDelegate {
     matches: Vec<BranchEntry>,
     all_branches: Option<Vec<Branch>>,
+    default_branch: Option<SharedString>,
     repo: Option<Entity<Repository>>,
     style: BranchListStyle,
     selected_index: usize,
@@ -206,6 +218,7 @@ impl BranchListDelegate {
             repo,
             style,
             all_branches: None,
+            default_branch: None,
             selected_index: 0,
             last_query: Default::default(),
             modifiers: Default::default(),
@@ -214,6 +227,7 @@ impl BranchListDelegate {
 
     fn create_branch(
         &self,
+        from_branch: Option<SharedString>,
         new_branch_name: SharedString,
         window: &mut Window,
         cx: &mut Context<Picker<Self>>,
@@ -223,6 +237,11 @@ impl BranchListDelegate {
         };
         let new_branch_name = new_branch_name.to_string().replace(' ', "-");
         cx.spawn(async move |_, cx| {
+            if let Some(based_branch) = from_branch {
+                repo.update(cx, |repo, _| repo.change_branch(based_branch.to_string()))?
+                    .await??;
+            }
+
             repo.update(cx, |repo, _| {
                 repo.create_branch(new_branch_name.to_string())
             })?
@@ -353,12 +372,22 @@ impl PickerDelegate for BranchListDelegate {
         })
     }
 
-    fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
         let Some(entry) = self.matches.get(self.selected_index()) else {
             return;
         };
         if entry.is_new {
-            self.create_branch(entry.branch.name().to_owned().into(), window, cx);
+            let from_branch = if secondary {
+                self.default_branch.clone()
+            } else {
+                None
+            };
+            self.create_branch(
+                from_branch,
+                entry.branch.name().to_owned().into(),
+                window,
+                cx,
+            );
             return;
         }
 
@@ -439,6 +468,28 @@ impl PickerDelegate for BranchListDelegate {
             })
             .unwrap_or_else(|| (None, None));
 
+        let icon = if let Some(default_branch) = self.default_branch.clone()
+            && entry.is_new
+        {
+            Some(
+                IconButton::new("branch-from-default", IconName::GitBranchSmall)
+                    .on_click(cx.listener(move |this, _, window, cx| {
+                        this.delegate.set_selected_index(ix, window, cx);
+                        this.delegate.confirm(true, window, cx);
+                    }))
+                    .tooltip(move |window, cx| {
+                        Tooltip::for_action(
+                            format!("Create branch based off default: {default_branch}"),
+                            &menu::SecondaryConfirm,
+                            window,
+                            cx,
+                        )
+                    }),
+            )
+        } else {
+            None
+        };
+
         let branch_name = if entry.is_new {
             h_flex()
                 .gap_1()
@@ -504,7 +555,8 @@ impl PickerDelegate for BranchListDelegate {
                                     .color(Color::Muted)
                             }))
                         }),
-                ),
+                )
+                .end_slot::<IconButton>(icon),
         )
     }
 

crates/project/src/git_store.rs 🔗

@@ -4025,6 +4025,25 @@ impl Repository {
         })
     }
 
+    pub fn default_branch(&mut self) -> oneshot::Receiver<Result<Option<SharedString>>> {
+        let id = self.id;
+        self.send_job(None, move |repo, _| async move {
+            match repo {
+                RepositoryState::Local { backend, .. } => backend.default_branch().await,
+                RepositoryState::Remote { project_id, client } => {
+                    let response = client
+                        .request(proto::GetDefaultBranch {
+                            project_id: project_id.0,
+                            repository_id: id.to_proto(),
+                        })
+                        .await?;
+
+                    anyhow::Ok(response.branch.map(SharedString::from))
+                }
+            }
+        })
+    }
+
     pub fn diff(&mut self, diff_type: DiffType, _cx: &App) -> oneshot::Receiver<Result<String>> {
         let id = self.id;
         self.send_job(None, move |repo, _cx| async move {

crates/proto/proto/git.proto 🔗

@@ -422,3 +422,12 @@ message BlameBufferResponse {
 
     reserved 1 to 4;
 }
+
+message GetDefaultBranch {
+    uint64 project_id = 1;
+    uint64 repository_id = 2;
+}
+
+message GetDefaultBranchResponse {
+    optional string branch = 1;
+}

crates/proto/proto/zed.proto 🔗

@@ -399,7 +399,10 @@ message Envelope {
         GetColorPresentationResponse get_color_presentation_response = 356;
 
         Stash stash = 357;
-        StashPop stash_pop = 358; // current max
+        StashPop stash_pop = 358;
+
+        GetDefaultBranch get_default_branch = 359;
+        GetDefaultBranchResponse get_default_branch_response = 360; // current max
     }
 
     reserved 87 to 88;

crates/proto/src/proto.rs 🔗

@@ -315,7 +315,9 @@ messages!(
     (LogToDebugConsole, Background),
     (GetDocumentDiagnostics, Background),
     (GetDocumentDiagnosticsResponse, Background),
-    (PullWorkspaceDiagnostics, Background)
+    (PullWorkspaceDiagnostics, Background),
+    (GetDefaultBranch, Background),
+    (GetDefaultBranchResponse, Background),
 );
 
 request_messages!(
@@ -483,7 +485,8 @@ request_messages!(
     (GetDebugAdapterBinary, DebugAdapterBinary),
     (RunDebugLocators, DebugRequest),
     (GetDocumentDiagnostics, GetDocumentDiagnosticsResponse),
-    (PullWorkspaceDiagnostics, Ack)
+    (PullWorkspaceDiagnostics, Ack),
+    (GetDefaultBranch, GetDefaultBranchResponse),
 );
 
 entity_messages!(
@@ -615,7 +618,8 @@ entity_messages!(
     GetDebugAdapterBinary,
     LogToDebugConsole,
     GetDocumentDiagnostics,
-    PullWorkspaceDiagnostics
+    PullWorkspaceDiagnostics,
+    GetDefaultBranch
 );
 
 entity_messages!(