project: Show detached head commit SHA in branch pickers (#29007)

Smit Barmase created

When Git is in a detached HEAD state, the branch is `None`, and we can't
get any meaningful information from it. This PR adds a `head_commit`
field to the snapshot, which is always populated with the HEAD details,
even when the branch is `None`.

This also pave path to fix:
https://github.com/zed-industries/zed/issues/28736

git panel branch picker (before, after):
<img width="197" alt="image"
src="https://github.com/user-attachments/assets/0b6abbba-2988-4890-a708-bcd8aad84f26"
/> <img width="198" alt="image"
src="https://github.com/user-attachments/assets/4b08b1a8-5e79-4aa3-a44e-932249602c18"
/>

title bar branch picker (before, after):
<img width="183" alt="image"
src="https://github.com/user-attachments/assets/d94357f8-a4da-4d60-8ddd-fdd978b99fdf"
/> <img width="228" alt="image"
src="https://github.com/user-attachments/assets/d20824a1-9279-44d6-afd1-bf9319fc50e4"
/>

Release Notes:

- Added head commit SHA information to the Git branch picker in the
title bar and Git panel.

Change summary

crates/collab/migrations.sqlite/20221109000000_test_schema.sql |  1 
crates/collab/src/db/queries/projects.rs                       | 18 +
crates/collab/src/db/queries/rooms.rs                          |  8 
crates/collab/src/db/tables/project_repository.rs              |  2 
crates/git_ui/src/git_panel.rs                                 | 30 ++
crates/project/src/git_store.rs                                | 36 ++++
crates/proto/proto/git.proto                                   |  1 
crates/title_bar/src/title_bar.rs                              | 20 ++
8 files changed, 109 insertions(+), 7 deletions(-)

Detailed changes

crates/collab/src/db/queries/projects.rs 🔗

@@ -348,9 +348,10 @@ impl Database {
                                         .unwrap(),
                                 )),
 
-                                // Old clients do not use abs path or entry ids.
+                                // Old clients do not use abs path, entry ids or head_commit_details.
                                 abs_path: ActiveValue::set(String::new()),
                                 entry_ids: ActiveValue::set("[]".into()),
+                                head_commit_details: ActiveValue::set(None),
                             }
                         }),
                     )
@@ -490,6 +491,12 @@ impl Database {
                         .as_ref()
                         .map(|summary| serde_json::to_string(summary).unwrap()),
                 ),
+                head_commit_details: ActiveValue::Set(
+                    update
+                        .head_commit_details
+                        .as_ref()
+                        .map(|details| serde_json::to_string(details).unwrap()),
+                ),
                 current_merge_conflicts: ActiveValue::Set(Some(
                     serde_json::to_string(&update.current_merge_conflicts).unwrap(),
                 )),
@@ -505,6 +512,7 @@ impl Database {
                     project_repository::Column::EntryIds,
                     project_repository::Column::AbsPath,
                     project_repository::Column::CurrentMergeConflicts,
+                    project_repository::Column::HeadCommitDetails,
                 ])
                 .to_owned(),
             )
@@ -928,6 +936,13 @@ impl Database {
                     .transpose()?
                     .unwrap_or_default();
 
+                let head_commit_details = db_repository_entry
+                    .head_commit_details
+                    .as_ref()
+                    .map(|head_commit_details| serde_json::from_str(&head_commit_details))
+                    .transpose()?
+                    .unwrap_or_default();
+
                 let entry_ids = serde_json::from_str(&db_repository_entry.entry_ids)
                     .context("failed to deserialize repository's entry ids")?;
 
@@ -954,6 +969,7 @@ impl Database {
                         removed_statuses: Vec::new(),
                         current_merge_conflicts,
                         branch_summary,
+                        head_commit_details,
                         scan_id: db_repository_entry.scan_id as u64,
                         is_last_update: true,
                     });

crates/collab/src/db/queries/rooms.rs 🔗

@@ -755,6 +755,13 @@ impl Database {
                         .transpose()?
                         .unwrap_or_default();
 
+                    let head_commit_details = db_repository
+                        .head_commit_details
+                        .as_ref()
+                        .map(|head_commit_details| serde_json::from_str(&head_commit_details))
+                        .transpose()?
+                        .unwrap_or_default();
+
                     let entry_ids = serde_json::from_str(&db_repository.entry_ids)
                         .context("failed to deserialize repository's entry ids")?;
 
@@ -778,6 +785,7 @@ impl Database {
                             removed_statuses,
                             current_merge_conflicts,
                             branch_summary,
+                            head_commit_details,
                             project_id: project_id.to_proto(),
                             id: db_repository.id as u64,
                             abs_path: db_repository.abs_path,

crates/collab/src/db/tables/project_repository.rs 🔗

@@ -18,6 +18,8 @@ pub struct Model {
     pub current_merge_conflicts: Option<String>,
     // A JSON object representing the current Branch values
     pub branch_summary: Option<String>,
+    // A JSON object representing the current Head commit values
+    pub head_commit_details: Option<String>,
 }
 
 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

crates/git_ui/src/git_panel.rs 🔗

@@ -2938,6 +2938,7 @@ impl GitPanel {
         let expand_tooltip_focus_handle = editor_focus_handle.clone();
 
         let branch = active_repository.read(cx).branch.clone();
+        let head_commit = active_repository.read(cx).head_commit.clone();
 
         let footer_size = px(32.);
         let gap = px(9.0);
@@ -2966,6 +2967,7 @@ impl GitPanel {
             .child(PanelRepoFooter::new(
                 display_name,
                 branch,
+                head_commit,
                 Some(git_panel.clone()),
             ))
             .child(
@@ -4291,6 +4293,8 @@ impl Render for GitPanelMessageTooltip {
 pub struct PanelRepoFooter {
     active_repository: SharedString,
     branch: Option<Branch>,
+    head_commit: Option<CommitDetails>,
+
     // Getting a GitPanel in previews will be difficult.
     //
     // For now just take an option here, and we won't bind handlers to buttons in previews.
@@ -4301,11 +4305,13 @@ impl PanelRepoFooter {
     pub fn new(
         active_repository: SharedString,
         branch: Option<Branch>,
+        head_commit: Option<CommitDetails>,
         git_panel: Option<Entity<GitPanel>>,
     ) -> Self {
         Self {
             active_repository,
             branch,
+            head_commit,
             git_panel,
         }
     }
@@ -4314,6 +4320,7 @@ impl PanelRepoFooter {
         Self {
             active_repository,
             branch,
+            head_commit: None,
             git_panel: None,
         }
     }
@@ -4339,11 +4346,26 @@ impl RenderOnce for PanelRepoFooter {
         const MAX_BRANCH_LEN: usize = 16;
         const MAX_REPO_LEN: usize = 16;
         const LABEL_CHARACTER_BUDGET: usize = MAX_BRANCH_LEN + MAX_REPO_LEN;
+        const MAX_SHORT_SHA_LEN: usize = 8;
 
-        let branch = self.branch.clone();
-        let branch_name = branch
+        let branch_name = self
+            .branch
             .as_ref()
-            .map_or(" (no branch)".into(), |branch| branch.name.clone());
+            .map(|branch| branch.name.clone())
+            .or_else(|| {
+                self.head_commit.as_ref().map(|commit| {
+                    SharedString::from(
+                        commit
+                            .sha
+                            .chars()
+                            .take(MAX_SHORT_SHA_LEN)
+                            .collect::<String>(),
+                    )
+                })
+            })
+            .unwrap_or_else(|| SharedString::from(" (no branch)"));
+        let show_separator = self.branch.is_some() || self.head_commit.is_some();
+
         let active_repo_name = self.active_repository.clone();
 
         let branch_actual_len = branch_name.len();
@@ -4449,7 +4471,7 @@ impl RenderOnce for PanelRepoFooter {
                         ),
                     )
                     .child(repo_selector)
-                    .when_some(branch.clone(), |this, _| {
+                    .when(show_separator, |this| {
                         this.child(
                             div()
                                 .text_color(cx.theme().colors().text_muted)

crates/project/src/git_store.rs 🔗

@@ -231,6 +231,7 @@ pub struct RepositorySnapshot {
     pub statuses_by_path: SumTree<StatusEntry>,
     pub work_directory_abs_path: Arc<Path>,
     pub branch: Option<Branch>,
+    pub head_commit: Option<CommitDetails>,
     pub merge_conflicts: TreeSet<RepoPath>,
     pub merge_head_shas: Vec<SharedString>,
     pub scan_id: u64,
@@ -2426,6 +2427,7 @@ impl RepositorySnapshot {
             statuses_by_path: Default::default(),
             work_directory_abs_path,
             branch: None,
+            head_commit: None,
             merge_conflicts: Default::default(),
             merge_head_shas: Default::default(),
             scan_id: 0,
@@ -2435,6 +2437,7 @@ impl RepositorySnapshot {
     fn initial_update(&self, project_id: u64) -> proto::UpdateRepository {
         proto::UpdateRepository {
             branch_summary: self.branch.as_ref().map(branch_to_proto),
+            head_commit_details: self.head_commit.as_ref().map(commit_details_to_proto),
             updated_statuses: self
                 .statuses_by_path
                 .iter()
@@ -2499,6 +2502,7 @@ impl RepositorySnapshot {
 
         proto::UpdateRepository {
             branch_summary: self.branch.as_ref().map(branch_to_proto),
+            head_commit_details: self.head_commit.as_ref().map(commit_details_to_proto),
             updated_statuses,
             removed_statuses,
             current_merge_conflicts: self
@@ -3748,6 +3752,11 @@ impl Repository {
                 .map(|path| RepoPath(Path::new(&path).into())),
         );
         self.snapshot.branch = update.branch_summary.as_ref().map(proto_to_branch);
+        self.snapshot.head_commit = update
+            .head_commit_details
+            .as_ref()
+            .map(proto_to_commit_details);
+
         self.snapshot.merge_conflicts = conflicted_paths;
 
         let edits = update
@@ -4322,6 +4331,26 @@ fn proto_to_branch(proto: &proto::Branch) -> git::repository::Branch {
     }
 }
 
+fn commit_details_to_proto(commit: &CommitDetails) -> proto::GitCommitDetails {
+    proto::GitCommitDetails {
+        sha: commit.sha.to_string(),
+        message: commit.message.to_string(),
+        commit_timestamp: commit.commit_timestamp,
+        author_email: commit.author_email.to_string(),
+        author_name: commit.author_name.to_string(),
+    }
+}
+
+fn proto_to_commit_details(proto: &proto::GitCommitDetails) -> CommitDetails {
+    CommitDetails {
+        sha: proto.sha.clone().into(),
+        message: proto.message.clone().into(),
+        commit_timestamp: proto.commit_timestamp,
+        author_email: proto.author_email.clone().into(),
+        author_name: proto.author_name.clone().into(),
+    }
+}
+
 async fn compute_snapshot(
     id: RepositoryId,
     work_directory_abs_path: Arc<Path>,
@@ -4377,6 +4406,12 @@ async fn compute_snapshot(
         events.push(RepositoryEvent::MergeHeadsChanged);
     }
 
+    // Useful when branch is None in detached head state
+    let head_commit = match backend.head_sha() {
+        Some(head_sha) => backend.show(head_sha).await.ok(),
+        None => None,
+    };
+
     let snapshot = RepositorySnapshot {
         id,
         merge_message,
@@ -4384,6 +4419,7 @@ async fn compute_snapshot(
         work_directory_abs_path,
         scan_id: prev_snapshot.scan_id + 1,
         branch,
+        head_commit,
         merge_conflicts,
         merge_head_shas,
     };

crates/proto/proto/git.proto 🔗

@@ -120,6 +120,7 @@ message UpdateRepository {
     repeated string current_merge_conflicts = 8;
     uint64 scan_id = 9;
     bool is_last_update = 10;
+    optional GitCommitDetails head_commit_details = 11;
 }
 
 message RemoveRepository {

crates/title_bar/src/title_bar.rs 🔗

@@ -46,6 +46,7 @@ pub use stories::*;
 
 const MAX_PROJECT_NAME_LENGTH: usize = 40;
 const MAX_BRANCH_NAME_LENGTH: usize = 40;
+const MAX_SHORT_SHA_LENGTH: usize = 8;
 
 const BOOK_ONBOARDING: &str = "https://dub.sh/zed-c-onboarding";
 
@@ -513,8 +514,23 @@ impl TitleBar {
     pub fn render_project_branch(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
         let repository = self.project.read(cx).active_repository(cx)?;
         let workspace = self.workspace.upgrade()?;
-        let branch_name = repository.read(cx).branch.as_ref()?.name.clone();
-        let branch_name = util::truncate_and_trailoff(&branch_name, MAX_BRANCH_NAME_LENGTH);
+        let branch_name = {
+            let repo = repository.read(cx);
+            repo.branch
+                .as_ref()
+                .map(|branch| branch.name.clone())
+                .map(|name| util::truncate_and_trailoff(&name, MAX_BRANCH_NAME_LENGTH))
+                .or_else(|| {
+                    repo.head_commit.as_ref().map(|commit| {
+                        commit
+                            .sha
+                            .chars()
+                            .take(MAX_SHORT_SHA_LENGTH)
+                            .collect::<String>()
+                    })
+                })
+        }?;
+
         Some(
             Button::new("project_branch_trigger", branch_name)
                 .color(Color::Muted)