Add an undo button to the git panel (#24593)

Mikayla Maki , Conrad Irwin , Conrad , and Nate Butler created

Also prep infrastructure for pushing a commit

Release Notes:

- N/A

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
Co-authored-by: Conrad <conrad@zed.dev>
Co-authored-by: Nate Butler <iamnbutler@gmail.com>

Change summary

Cargo.lock                                                              |   2 
assets/keymaps/vim.json                                                 |   2 
crates/collab/migrations.sqlite/20221109000000_test_schema.sql          |   1 
crates/collab/migrations/20250210223746_add_branch_summary.sql          |   2 
crates/collab/migrations/20250212060936_add_worktree_branch_summary.sql |   1 
crates/collab/src/db/queries/projects.rs                                |  40 
crates/collab/src/db/queries/rooms.rs                                   |   8 
crates/collab/src/db/tables/worktree_repository.rs                      |   2 
crates/collab/src/rpc.rs                                                |   2 
crates/collab/src/tests/integration_tests.rs                            |   9 
crates/collab/src/tests/remote_editing_collaboration_tests.rs           |   4 
crates/editor/src/commit_tooltip.rs                                     | 143 
crates/editor/src/editor.rs                                             |   2 
crates/editor/src/element.rs                                            |  19 
crates/editor/src/git/blame.rs                                          |  31 
crates/git/src/blame.rs                                                 |  16 
crates/git/src/git.rs                                                   |   1 
crates/git/src/hosting_provider.rs                                      |   6 
crates/git/src/repository.rs                                            | 320 
crates/git/test_data/golden/blame_incremental_complex.json              | 164 
crates/git/test_data/golden/blame_incremental_not_committed.json        |  28 
crates/git/test_data/golden/blame_incremental_simple.json               |  28 
crates/git_hosting_providers/src/providers/codeberg.rs                  |   5 
crates/git_hosting_providers/src/providers/github.rs                    |   5 
crates/git_ui/Cargo.toml                                                |   2 
crates/git_ui/src/branch_picker.rs                                      |  23 
crates/git_ui/src/git_panel.rs                                          | 265 
crates/git_ui/src/project_diff.rs                                       |  20 
crates/git_ui/src/quick_commit.rs                                       |   4 
crates/git_ui/src/repository_selector.rs                                |  12 
crates/project/src/buffer_store.rs                                      |   8 
crates/project/src/git.rs                                               | 358 
crates/project/src/project.rs                                           | 181 
crates/project/src/worktree_store.rs                                    |  39 
crates/proto/proto/zed.proto                                            |  50 
crates/proto/src/proto.rs                                               |   7 
crates/remote_server/src/headless_project.rs                            | 178 
crates/remote_server/src/remote_editing_tests.rs                        |   4 
crates/time_format/src/time_format.rs                                   |  24 
crates/title_bar/src/title_bar.rs                                       |   1 
crates/worktree/src/worktree.rs                                         | 158 
41 files changed, 1,437 insertions(+), 738 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5371,6 +5371,8 @@ dependencies = [
  "serde_json",
  "settings",
  "theme",
+ "time",
+ "time_format",
  "ui",
  "util",
  "windows 0.58.0",

assets/keymaps/vim.json 🔗

@@ -631,7 +631,7 @@
     }
   },
   {
-    "context": "GitPanel || EmptyPane || SharedScreen || MarkdownPreview || KeyContextView || Welcome",
+    "context": "ChangesList || EmptyPane || SharedScreen || MarkdownPreview || KeyContextView || Welcome",
     "bindings": {
       ":": "command_palette::Toggle",
       "g /": "pane::DeploySearch"

crates/collab/migrations.sqlite/20221109000000_test_schema.sql 🔗

@@ -101,6 +101,7 @@ CREATE TABLE "worktree_repositories" (
     "scan_id" INTEGER NOT NULL,
     "is_deleted" BOOL NOT NULL,
     "current_merge_conflicts" VARCHAR,
+    "branch_summary" VARCHAR,
     PRIMARY KEY(project_id, worktree_id, work_directory_id),
     FOREIGN KEY(project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE,
     FOREIGN KEY(project_id, worktree_id, work_directory_id) REFERENCES worktree_entries (project_id, worktree_id, id) ON DELETE CASCADE

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

@@ -326,16 +326,26 @@ impl Database {
 
             if !update.updated_repositories.is_empty() {
                 worktree_repository::Entity::insert_many(update.updated_repositories.iter().map(
-                    |repository| worktree_repository::ActiveModel {
-                        project_id: ActiveValue::set(project_id),
-                        worktree_id: ActiveValue::set(worktree_id),
-                        work_directory_id: ActiveValue::set(repository.work_directory_id as i64),
-                        scan_id: ActiveValue::set(update.scan_id as i64),
-                        branch: ActiveValue::set(repository.branch.clone()),
-                        is_deleted: ActiveValue::set(false),
-                        current_merge_conflicts: ActiveValue::Set(Some(
-                            serde_json::to_string(&repository.current_merge_conflicts).unwrap(),
-                        )),
+                    |repository| {
+                        worktree_repository::ActiveModel {
+                            project_id: ActiveValue::set(project_id),
+                            worktree_id: ActiveValue::set(worktree_id),
+                            work_directory_id: ActiveValue::set(
+                                repository.work_directory_id as i64,
+                            ),
+                            scan_id: ActiveValue::set(update.scan_id as i64),
+                            branch: ActiveValue::set(repository.branch.clone()),
+                            is_deleted: ActiveValue::set(false),
+                            branch_summary: ActiveValue::Set(
+                                repository
+                                    .branch_summary
+                                    .as_ref()
+                                    .map(|summary| serde_json::to_string(summary).unwrap()),
+                            ),
+                            current_merge_conflicts: ActiveValue::Set(Some(
+                                serde_json::to_string(&repository.current_merge_conflicts).unwrap(),
+                            )),
+                        }
                     },
                 ))
                 .on_conflict(
@@ -347,6 +357,8 @@ impl Database {
                     .update_columns([
                         worktree_repository::Column::ScanId,
                         worktree_repository::Column::Branch,
+                        worktree_repository::Column::BranchSummary,
+                        worktree_repository::Column::CurrentMergeConflicts,
                     ])
                     .to_owned(),
                 )
@@ -779,6 +791,13 @@ impl Database {
                         .transpose()?
                         .unwrap_or_default();
 
+                    let branch_summary = db_repository_entry
+                        .branch_summary
+                        .as_ref()
+                        .map(|branch_summary| serde_json::from_str(&branch_summary))
+                        .transpose()?
+                        .unwrap_or_default();
+
                     worktree.repository_entries.insert(
                         db_repository_entry.work_directory_id as u64,
                         proto::RepositoryEntry {
@@ -787,6 +806,7 @@ impl Database {
                             updated_statuses,
                             removed_statuses: Vec::new(),
                             current_merge_conflicts,
+                            branch_summary,
                         },
                     );
                 }

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

@@ -743,12 +743,20 @@ impl Database {
                             .transpose()?
                             .unwrap_or_default();
 
+                        let branch_summary = db_repository
+                            .branch_summary
+                            .as_ref()
+                            .map(|branch_summary| serde_json::from_str(&branch_summary))
+                            .transpose()?
+                            .unwrap_or_default();
+
                         worktree.updated_repositories.push(proto::RepositoryEntry {
                             work_directory_id: db_repository.work_directory_id as u64,
                             branch: db_repository.branch,
                             updated_statuses,
                             removed_statuses,
                             current_merge_conflicts,
+                            branch_summary,
                         });
                     }
                 }

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

@@ -15,6 +15,8 @@ pub struct Model {
     pub is_deleted: bool,
     // JSON array typed string
     pub current_merge_conflicts: Option<String>,
+    // A JSON object representing the current Branch values
+    pub branch_summary: Option<String>,
 }
 
 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

crates/collab/src/rpc.rs 🔗

@@ -395,6 +395,8 @@ impl Server {
             .add_request_handler(forward_mutating_project_request::<proto::Stage>)
             .add_request_handler(forward_mutating_project_request::<proto::Unstage>)
             .add_request_handler(forward_mutating_project_request::<proto::Commit>)
+            .add_request_handler(forward_read_only_project_request::<proto::GitShow>)
+            .add_request_handler(forward_read_only_project_request::<proto::GitReset>)
             .add_request_handler(forward_mutating_project_request::<proto::SetIndexText>)
             .add_request_handler(forward_mutating_project_request::<proto::OpenCommitMessageBuffer>)
             .add_message_handler(broadcast_project_message_from_host::<proto::AdvertiseContexts>)

crates/collab/src/tests/integration_tests.rs 🔗

@@ -2895,7 +2895,10 @@ async fn test_git_branch_name(
         assert_eq!(worktrees.len(), 1);
         let worktree = worktrees[0].clone();
         let root_entry = worktree.read(cx).snapshot().root_git_entry().unwrap();
-        assert_eq!(root_entry.branch(), branch_name.map(Into::into));
+        assert_eq!(
+            root_entry.branch().map(|branch| branch.name.to_string()),
+            branch_name
+        );
     }
 
     // Smoke test branch reading
@@ -6783,7 +6786,7 @@ async fn test_remote_git_branches(
         })
     });
 
-    assert_eq!(host_branch.as_ref(), branches[2]);
+    assert_eq!(host_branch.name, branches[2]);
 
     // Also try creating a new branch
     cx_b.update(|cx| {
@@ -6804,5 +6807,5 @@ async fn test_remote_git_branches(
         })
     });
 
-    assert_eq!(host_branch.as_ref(), "totally-new-branch");
+    assert_eq!(host_branch.name, "totally-new-branch");
 }

crates/collab/src/tests/remote_editing_collaboration_tests.rs 🔗

@@ -314,7 +314,7 @@ async fn test_ssh_collaboration_git_branches(
         })
     });
 
-    assert_eq!(server_branch.as_ref(), branches[2]);
+    assert_eq!(server_branch.name, branches[2]);
 
     // Also try creating a new branch
     cx_b.update(|cx| {
@@ -337,7 +337,7 @@ async fn test_ssh_collaboration_git_branches(
         })
     });
 
-    assert_eq!(server_branch.as_ref(), "totally-new-branch");
+    assert_eq!(server_branch.name, "totally-new-branch");
 }
 
 #[gpui::test]

crates/editor/src/blame_entry_tooltip.rs → crates/editor/src/commit_tooltip.rs 🔗

@@ -1,28 +1,48 @@
 use futures::Future;
 use git::blame::BlameEntry;
-use git::Oid;
+use git::PullRequest;
 use gpui::{
     App, Asset, ClipboardItem, Element, ParentElement, Render, ScrollHandle,
     StatefulInteractiveElement, WeakEntity,
 };
+use language::ParsedMarkdown;
 use settings::Settings;
 use std::hash::Hash;
 use theme::ThemeSettings;
-use time::UtcOffset;
+use time::{OffsetDateTime, UtcOffset};
+use time_format::format_local_timestamp;
 use ui::{prelude::*, tooltip_container, Avatar, Divider, IconButtonShape};
+use url::Url;
 use workspace::Workspace;
 
-use crate::git::blame::{CommitDetails, GitRemote};
+use crate::git::blame::GitRemote;
 use crate::EditorStyle;
 
+#[derive(Clone, Debug)]
+pub struct CommitDetails {
+    pub sha: SharedString,
+    pub committer_name: SharedString,
+    pub committer_email: SharedString,
+    pub commit_time: OffsetDateTime,
+    pub message: Option<ParsedCommitMessage>,
+}
+
+#[derive(Clone, Debug, Default)]
+pub struct ParsedCommitMessage {
+    pub message: SharedString,
+    pub parsed_message: ParsedMarkdown,
+    pub permalink: Option<Url>,
+    pub pull_request: Option<PullRequest>,
+    pub remote: Option<GitRemote>,
+}
+
 struct CommitAvatar<'a> {
-    details: Option<&'a CommitDetails>,
-    sha: Oid,
+    commit: &'a CommitDetails,
 }
 
 impl<'a> CommitAvatar<'a> {
-    fn new(details: Option<&'a CommitDetails>, sha: Oid) -> Self {
-        Self { details, sha }
+    fn new(details: &'a CommitDetails) -> Self {
+        Self { commit: details }
     }
 }
 
@@ -30,14 +50,16 @@ impl<'a> CommitAvatar<'a> {
     fn render(
         &'a self,
         window: &mut Window,
-        cx: &mut Context<BlameEntryTooltip>,
+        cx: &mut Context<CommitTooltip>,
     ) -> Option<impl IntoElement> {
         let remote = self
-            .details
+            .commit
+            .message
+            .as_ref()
             .and_then(|details| details.remote.as_ref())
             .filter(|remote| remote.host_supports_avatars())?;
 
-        let avatar_url = CommitAvatarAsset::new(remote.clone(), self.sha);
+        let avatar_url = CommitAvatarAsset::new(remote.clone(), self.commit.sha.clone());
 
         let element = match window.use_asset::<CommitAvatarAsset>(&avatar_url, cx) {
             // Loading or no avatar found
@@ -54,7 +76,7 @@ impl<'a> CommitAvatar<'a> {
 
 #[derive(Clone, Debug)]
 struct CommitAvatarAsset {
-    sha: Oid,
+    sha: SharedString,
     remote: GitRemote,
 }
 
@@ -66,7 +88,7 @@ impl Hash for CommitAvatarAsset {
 }
 
 impl CommitAvatarAsset {
-    fn new(remote: GitRemote, sha: Oid) -> Self {
+    fn new(remote: GitRemote, sha: SharedString) -> Self {
         Self { remote, sha }
     }
 }
@@ -91,50 +113,78 @@ impl Asset for CommitAvatarAsset {
     }
 }
 
-pub(crate) struct BlameEntryTooltip {
-    blame_entry: BlameEntry,
-    details: Option<CommitDetails>,
+pub struct CommitTooltip {
+    commit: CommitDetails,
     editor_style: EditorStyle,
     workspace: Option<WeakEntity<Workspace>>,
     scroll_handle: ScrollHandle,
 }
 
-impl BlameEntryTooltip {
-    pub(crate) fn new(
-        blame_entry: BlameEntry,
-        details: Option<CommitDetails>,
-        style: &EditorStyle,
+impl CommitTooltip {
+    pub fn blame_entry(
+        blame: BlameEntry,
+        details: Option<ParsedCommitMessage>,
+        style: EditorStyle,
+        workspace: Option<WeakEntity<Workspace>>,
+    ) -> Self {
+        let commit_time = blame
+            .committer_time
+            .and_then(|t| OffsetDateTime::from_unix_timestamp(t).ok())
+            .unwrap_or(OffsetDateTime::now_utc());
+        Self::new(
+            CommitDetails {
+                sha: blame.sha.to_string().into(),
+                commit_time,
+                committer_name: blame
+                    .committer_name
+                    .unwrap_or("<no name>".to_string())
+                    .into(),
+                committer_email: blame.committer_email.unwrap_or("".to_string()).into(),
+                message: details,
+            },
+            style,
+            workspace,
+        )
+    }
+
+    pub fn new(
+        commit: CommitDetails,
+        editor_style: EditorStyle,
         workspace: Option<WeakEntity<Workspace>>,
     ) -> Self {
         Self {
-            editor_style: style.clone(),
-            blame_entry,
-            details,
+            editor_style,
+            commit,
             workspace,
             scroll_handle: ScrollHandle::new(),
         }
     }
 }
 
-impl Render for BlameEntryTooltip {
+impl Render for CommitTooltip {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let avatar =
-            CommitAvatar::new(self.details.as_ref(), self.blame_entry.sha).render(window, cx);
+        let avatar = CommitAvatar::new(&self.commit).render(window, cx);
 
-        let author = self
-            .blame_entry
-            .author
-            .clone()
-            .unwrap_or("<no name>".to_string());
+        let author = self.commit.committer_name.clone();
 
-        let author_email = self.blame_entry.author_mail.clone();
+        let author_email = self.commit.committer_email.clone();
 
-        let short_commit_id = self.blame_entry.sha.display_short();
-        let full_sha = self.blame_entry.sha.to_string().clone();
-        let absolute_timestamp = blame_entry_absolute_timestamp(&self.blame_entry);
+        let short_commit_id = self
+            .commit
+            .sha
+            .get(0..8)
+            .map(|sha| sha.to_string().into())
+            .unwrap_or_else(|| self.commit.sha.clone());
+        let full_sha = self.commit.sha.to_string().clone();
+        let absolute_timestamp = format_local_timestamp(
+            self.commit.commit_time,
+            OffsetDateTime::now_utc(),
+            time_format::TimestampFormat::MediumAbsolute,
+        );
 
         let message = self
-            .details
+            .commit
+            .message
             .as_ref()
             .map(|details| {
                 crate::render_parsed_markdown(
@@ -149,7 +199,8 @@ impl Render for BlameEntryTooltip {
             .unwrap_or("<no commit message>".into_any());
 
         let pull_request = self
-            .details
+            .commit
+            .message
             .as_ref()
             .and_then(|details| details.pull_request.clone());
 
@@ -171,7 +222,7 @@ impl Render for BlameEntryTooltip {
                                 .flex_wrap()
                                 .children(avatar)
                                 .child(author)
-                                .when_some(author_email, |this, author_email| {
+                                .when(!author_email.is_empty(), |this| {
                                     this.child(
                                         div()
                                             .text_color(cx.theme().colors().text_muted)
@@ -231,12 +282,16 @@ impl Render for BlameEntryTooltip {
                                             .icon_color(Color::Muted)
                                             .icon_position(IconPosition::Start)
                                             .disabled(
-                                                self.details.as_ref().map_or(true, |details| {
-                                                    details.permalink.is_none()
-                                                }),
+                                                self.commit
+                                                    .message
+                                                    .as_ref()
+                                                    .map_or(true, |details| {
+                                                        details.permalink.is_none()
+                                                    }),
                                             )
                                             .when_some(
-                                                self.details
+                                                self.commit
+                                                    .message
                                                     .as_ref()
                                                     .and_then(|details| details.permalink.clone()),
                                                 |this, url| {
@@ -284,7 +339,3 @@ fn blame_entry_timestamp(blame_entry: &BlameEntry, format: time_format::Timestam
 pub fn blame_entry_relative_timestamp(blame_entry: &BlameEntry) -> String {
     blame_entry_timestamp(blame_entry, time_format::TimestampFormat::Relative)
 }
-
-fn blame_entry_absolute_timestamp(blame_entry: &BlameEntry) -> String {
-    blame_entry_timestamp(blame_entry, time_format::TimestampFormat::MediumAbsolute)
-}

crates/editor/src/editor.rs 🔗

@@ -13,10 +13,10 @@
 //!
 //! If you're looking to improve Vim mode, you should check out Vim crate that wraps Editor and overrides its behavior.
 pub mod actions;
-mod blame_entry_tooltip;
 mod blink_manager;
 mod clangd_ext;
 mod code_context_menus;
+pub mod commit_tooltip;
 pub mod display_map;
 mod editor_settings;
 mod editor_settings_controls;

crates/editor/src/element.rs 🔗

@@ -1,6 +1,6 @@
 use crate::{
-    blame_entry_tooltip::{blame_entry_relative_timestamp, BlameEntryTooltip},
     code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP},
+    commit_tooltip::{blame_entry_relative_timestamp, CommitTooltip, ParsedCommitMessage},
     display_map::{
         Block, BlockContext, BlockStyle, DisplaySnapshot, HighlightedChunk, ToDisplayPoint,
     },
@@ -8,7 +8,7 @@ use crate::{
         CurrentLineHighlight, DoubleClickInMultibuffer, MultiCursorModifier, ScrollBeyondLastLine,
         ScrollbarDiagnostics, ShowScrollbar,
     },
-    git::blame::{CommitDetails, GitBlame},
+    git::blame::GitBlame,
     hover_popover::{
         self, hover_at, HOVER_POPOVER_GAP, MIN_POPOVER_CHARACTER_WIDTH, MIN_POPOVER_LINE_HEIGHT,
     },
@@ -5939,7 +5939,8 @@ fn render_inline_blame_entry(
 
     let details = blame.read(cx).details_for_entry(&blame_entry);
 
-    let tooltip = cx.new(|_| BlameEntryTooltip::new(blame_entry, details, style, workspace));
+    let tooltip =
+        cx.new(|_| CommitTooltip::blame_entry(blame_entry, details, style.clone(), workspace));
 
     h_flex()
         .id("inline-blame")
@@ -5989,8 +5990,14 @@ fn render_blame_entry(
 
     let workspace = editor.read(cx).workspace.as_ref().map(|(w, _)| w.clone());
 
-    let tooltip =
-        cx.new(|_| BlameEntryTooltip::new(blame_entry.clone(), details.clone(), style, workspace));
+    let tooltip = cx.new(|_| {
+        CommitTooltip::blame_entry(
+            blame_entry.clone(),
+            details.clone(),
+            style.clone(),
+            workspace,
+        )
+    });
 
     h_flex()
         .w_full()
@@ -6040,7 +6047,7 @@ fn render_blame_entry(
 
 fn deploy_blame_entry_context_menu(
     blame_entry: &BlameEntry,
-    details: Option<&CommitDetails>,
+    details: Option<&ParsedCommitMessage>,
     editor: Entity<Editor>,
     position: gpui::Point<Pixels>,
     window: &mut Window,

crates/editor/src/git/blame.rs 🔗

@@ -2,7 +2,7 @@ use anyhow::Result;
 use collections::HashMap;
 use git::{
     blame::{Blame, BlameEntry},
-    parse_git_remote_url, GitHostingProvider, GitHostingProviderRegistry, Oid, PullRequest,
+    parse_git_remote_url, GitHostingProvider, GitHostingProviderRegistry, Oid,
 };
 use gpui::{App, Context, Entity, Subscription, Task};
 use http_client::HttpClient;
@@ -12,8 +12,11 @@ use project::{Project, ProjectItem};
 use smallvec::SmallVec;
 use std::{sync::Arc, time::Duration};
 use sum_tree::SumTree;
+use ui::SharedString;
 use url::Url;
 
+use crate::commit_tooltip::ParsedCommitMessage;
+
 #[derive(Clone, Debug, Default)]
 pub struct GitBlameEntry {
     pub rows: u32,
@@ -77,7 +80,11 @@ impl GitRemote {
         self.host.supports_avatars()
     }
 
-    pub async fn avatar_url(&self, commit: Oid, client: Arc<dyn HttpClient>) -> Option<Url> {
+    pub async fn avatar_url(
+        &self,
+        commit: SharedString,
+        client: Arc<dyn HttpClient>,
+    ) -> Option<Url> {
         self.host
             .commit_author_avatar_url(&self.owner, &self.repo, commit, client)
             .await
@@ -85,21 +92,11 @@ impl GitRemote {
             .flatten()
     }
 }
-
-#[derive(Clone, Debug)]
-pub struct CommitDetails {
-    pub message: String,
-    pub parsed_message: ParsedMarkdown,
-    pub permalink: Option<Url>,
-    pub pull_request: Option<PullRequest>,
-    pub remote: Option<GitRemote>,
-}
-
 pub struct GitBlame {
     project: Entity<Project>,
     buffer: Entity<Buffer>,
     entries: SumTree<GitBlameEntry>,
-    commit_details: HashMap<Oid, CommitDetails>,
+    commit_details: HashMap<Oid, crate::commit_tooltip::ParsedCommitMessage>,
     buffer_snapshot: BufferSnapshot,
     buffer_edits: text::Subscription,
     task: Task<Result<()>>,
@@ -187,7 +184,7 @@ impl GitBlame {
         self.generated
     }
 
-    pub fn details_for_entry(&self, entry: &BlameEntry) -> Option<CommitDetails> {
+    pub fn details_for_entry(&self, entry: &BlameEntry) -> Option<ParsedCommitMessage> {
         self.commit_details.get(&entry.sha).cloned()
     }
 
@@ -480,7 +477,7 @@ async fn parse_commit_messages(
     deprecated_permalinks: &HashMap<Oid, Url>,
     provider_registry: Arc<GitHostingProviderRegistry>,
     languages: &Arc<LanguageRegistry>,
-) -> HashMap<Oid, CommitDetails> {
+) -> HashMap<Oid, ParsedCommitMessage> {
     let mut commit_details = HashMap::default();
 
     let parsed_remote_url = remote_url
@@ -519,8 +516,8 @@ async fn parse_commit_messages(
 
         commit_details.insert(
             oid,
-            CommitDetails {
-                message,
+            ParsedCommitMessage {
+                message: message.into(),
                 parsed_message,
                 permalink,
                 remote,

crates/git/src/blame.rs 🔗

@@ -132,8 +132,8 @@ pub struct BlameEntry {
     pub author_time: Option<i64>,
     pub author_tz: Option<String>,
 
-    pub committer: Option<String>,
-    pub committer_mail: Option<String>,
+    pub committer_name: Option<String>,
+    pub committer_email: Option<String>,
     pub committer_time: Option<i64>,
     pub committer_tz: Option<String>,
 
@@ -255,10 +255,12 @@ fn parse_git_blame(output: &str) -> Result<Vec<BlameEntry>> {
                         .clone_from(&existing_entry.author_mail);
                     new_entry.author_time = existing_entry.author_time;
                     new_entry.author_tz.clone_from(&existing_entry.author_tz);
-                    new_entry.committer.clone_from(&existing_entry.committer);
                     new_entry
-                        .committer_mail
-                        .clone_from(&existing_entry.committer_mail);
+                        .committer_name
+                        .clone_from(&existing_entry.committer_name);
+                    new_entry
+                        .committer_email
+                        .clone_from(&existing_entry.committer_email);
                     new_entry.committer_time = existing_entry.committer_time;
                     new_entry
                         .committer_tz
@@ -288,8 +290,8 @@ fn parse_git_blame(output: &str) -> Result<Vec<BlameEntry>> {
                     }
                     "author-tz" if is_committed => entry.author_tz = Some(value.into()),
 
-                    "committer" if is_committed => entry.committer = Some(value.into()),
-                    "committer-mail" if is_committed => entry.committer_mail = Some(value.into()),
+                    "committer" if is_committed => entry.committer_name = Some(value.into()),
+                    "committer-mail" if is_committed => entry.committer_email = Some(value.into()),
                     "committer-time" if is_committed => {
                         entry.committer_time = Some(value.parse::<i64>()?)
                     }

crates/git/src/git.rs 🔗

@@ -38,6 +38,7 @@ actions!(
         StageAll,
         UnstageAll,
         RevertAll,
+        Uncommit,
         Commit,
         ClearCommitMessage
     ]

crates/git/src/hosting_provider.rs 🔗

@@ -4,13 +4,11 @@ use anyhow::Result;
 use async_trait::async_trait;
 use collections::BTreeMap;
 use derive_more::{Deref, DerefMut};
-use gpui::{App, Global};
+use gpui::{App, Global, SharedString};
 use http_client::HttpClient;
 use parking_lot::RwLock;
 use url::Url;
 
-use crate::Oid;
-
 #[derive(Debug, PartialEq, Eq, Clone)]
 pub struct PullRequest {
     pub number: u32,
@@ -83,7 +81,7 @@ pub trait GitHostingProvider {
         &self,
         _repo_owner: &str,
         _repo: &str,
-        _commit: Oid,
+        _commit: SharedString,
         _http_client: Arc<dyn HttpClient>,
     ) -> Result<Option<Url>> {
         Ok(None)

crates/git/src/repository.rs 🔗

@@ -1,7 +1,7 @@
 use crate::status::FileStatus;
 use crate::GitHostingProviderRegistry;
 use crate::{blame::Blame, status::GitStatus};
-use anyhow::{anyhow, Context as _, Result};
+use anyhow::{anyhow, Context, Result};
 use collections::{HashMap, HashSet};
 use git2::BranchType;
 use gpui::SharedString;
@@ -20,12 +20,63 @@ use sum_tree::MapSeekTarget;
 use util::command::new_std_command;
 use util::ResultExt;
 
-#[derive(Clone, Debug, Hash, PartialEq)]
+#[derive(Clone, Debug, Hash, PartialEq, Eq)]
 pub struct Branch {
     pub is_head: bool,
     pub name: SharedString,
-    /// Timestamp of most recent commit, normalized to Unix Epoch format.
-    pub unix_timestamp: Option<i64>,
+    pub upstream: Option<Upstream>,
+    pub most_recent_commit: Option<CommitSummary>,
+}
+
+impl Branch {
+    pub fn priority_key(&self) -> (bool, Option<i64>) {
+        (
+            self.is_head,
+            self.most_recent_commit
+                .as_ref()
+                .map(|commit| commit.commit_timestamp),
+        )
+    }
+}
+
+#[derive(Clone, Debug, Hash, PartialEq, Eq)]
+pub struct Upstream {
+    pub ref_name: SharedString,
+    pub tracking: Option<UpstreamTracking>,
+}
+
+#[derive(Clone, Debug, Hash, PartialEq, Eq)]
+pub struct UpstreamTracking {
+    pub ahead: u32,
+    pub behind: u32,
+}
+
+#[derive(Clone, Debug, Hash, PartialEq, Eq)]
+pub struct CommitSummary {
+    pub sha: SharedString,
+    pub subject: SharedString,
+    /// This is a unix timestamp
+    pub commit_timestamp: i64,
+}
+
+#[derive(Clone, Debug, Hash, PartialEq, Eq)]
+pub struct CommitDetails {
+    pub sha: SharedString,
+    pub message: SharedString,
+    pub commit_timestamp: i64,
+    pub committer_email: SharedString,
+    pub committer_name: SharedString,
+}
+
+pub enum ResetMode {
+    // reset the branch pointer, leave index and worktree unchanged
+    // (this will make it look like things that were committed are now
+    // staged)
+    Soft,
+    // reset the branch pointer and index, leave worktree unchanged
+    // (this makes it look as though things that were committed are now
+    // unstaged)
+    Mixed,
 }
 
 pub trait GitRepository: Send + Sync {
@@ -45,7 +96,6 @@ pub trait GitRepository: Send + Sync {
 
     /// Returns the URL of the remote with the given name.
     fn remote_url(&self, name: &str) -> Option<String>;
-    fn branch_name(&self) -> Option<String>;
 
     /// Returns the SHA of the current HEAD.
     fn head_sha(&self) -> Option<String>;
@@ -60,6 +110,10 @@ pub trait GitRepository: Send + Sync {
     fn create_branch(&self, _: &str) -> Result<()>;
     fn branch_exits(&self, _: &str) -> Result<bool>;
 
+    fn reset(&self, commit: &str, mode: ResetMode) -> Result<()>;
+
+    fn show(&self, commit: &str) -> Result<CommitDetails>;
+
     fn blame(&self, path: &Path, content: Rope) -> Result<crate::blame::Blame>;
 
     /// Returns the absolute path to the repository. For worktrees, this will be the path to the
@@ -132,6 +186,53 @@ impl GitRepository for RealGitRepository {
         repo.commondir().into()
     }
 
+    fn show(&self, commit: &str) -> Result<CommitDetails> {
+        let repo = self.repository.lock();
+        let Ok(commit) = repo.revparse_single(commit)?.into_commit() else {
+            anyhow::bail!("{} is not a commit", commit);
+        };
+        let details = CommitDetails {
+            sha: commit.id().to_string().into(),
+            message: String::from_utf8_lossy(commit.message_raw_bytes())
+                .to_string()
+                .into(),
+            commit_timestamp: commit.time().seconds(),
+            committer_email: String::from_utf8_lossy(commit.committer().email_bytes())
+                .to_string()
+                .into(),
+            committer_name: String::from_utf8_lossy(commit.committer().name_bytes())
+                .to_string()
+                .into(),
+        };
+        Ok(details)
+    }
+
+    fn reset(&self, commit: &str, mode: ResetMode) -> Result<()> {
+        let working_directory = self
+            .repository
+            .lock()
+            .workdir()
+            .context("failed to read git work directory")?
+            .to_path_buf();
+
+        let mode_flag = match mode {
+            ResetMode::Mixed => "--mixed",
+            ResetMode::Soft => "--soft",
+        };
+
+        let output = new_std_command(&self.git_binary_path)
+            .current_dir(&working_directory)
+            .args(["reset", mode_flag, commit])
+            .output()?;
+        if !output.status.success() {
+            return Err(anyhow!(
+                "Failed to reset:\n{}",
+                String::from_utf8_lossy(&output.stderr)
+            ));
+        }
+        Ok(())
+    }
+
     fn load_index_text(&self, path: &RepoPath) -> Option<String> {
         fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
             const STAGE_NORMAL: i32 = 0;
@@ -215,13 +316,6 @@ impl GitRepository for RealGitRepository {
         remote.url().map(|url| url.to_string())
     }
 
-    fn branch_name(&self) -> Option<String> {
-        let repo = self.repository.lock();
-        let head = repo.head().log_err()?;
-        let branch = String::from_utf8_lossy(head.shorthand_bytes());
-        Some(branch.to_string())
-    }
-
     fn head_sha(&self) -> Option<String> {
         Some(self.repository.lock().head().ok()?.target()?.to_string())
     }
@@ -261,33 +355,62 @@ impl GitRepository for RealGitRepository {
     }
 
     fn branches(&self) -> Result<Vec<Branch>> {
-        let repo = self.repository.lock();
-        let local_branches = repo.branches(Some(BranchType::Local))?;
-        let valid_branches = local_branches
-            .filter_map(|branch| {
-                branch.ok().and_then(|(branch, _)| {
-                    let is_head = branch.is_head();
-                    let name = branch
-                        .name()
-                        .ok()
-                        .flatten()
-                        .map(|name| name.to_string().into())?;
-                    let timestamp = branch.get().peel_to_commit().ok()?.time();
-                    let unix_timestamp = timestamp.seconds();
-                    let timezone_offset = timestamp.offset_minutes();
-                    let utc_offset =
-                        time::UtcOffset::from_whole_seconds(timezone_offset * 60).ok()?;
-                    let unix_timestamp =
-                        time::OffsetDateTime::from_unix_timestamp(unix_timestamp).ok()?;
-                    Some(Branch {
-                        is_head,
-                        name,
-                        unix_timestamp: Some(unix_timestamp.to_offset(utc_offset).unix_timestamp()),
-                    })
-                })
-            })
-            .collect();
-        Ok(valid_branches)
+        let working_directory = self
+            .repository
+            .lock()
+            .workdir()
+            .context("failed to read git work directory")?
+            .to_path_buf();
+        let fields = [
+            "%(HEAD)",
+            "%(objectname)",
+            "%(refname)",
+            "%(upstream)",
+            "%(upstream:track)",
+            "%(committerdate:unix)",
+            "%(contents:subject)",
+        ]
+        .join("%00");
+        let args = vec!["for-each-ref", "refs/heads/*", "--format", &fields];
+
+        let output = new_std_command(&self.git_binary_path)
+            .current_dir(&working_directory)
+            .args(args)
+            .output()?;
+
+        if !output.status.success() {
+            return Err(anyhow!(
+                "Failed to git git branches:\n{}",
+                String::from_utf8_lossy(&output.stderr)
+            ));
+        }
+
+        let input = String::from_utf8_lossy(&output.stdout);
+
+        let mut branches = parse_branch_input(&input)?;
+        if branches.is_empty() {
+            let args = vec!["symbolic-ref", "--quiet", "--short", "HEAD"];
+
+            let output = new_std_command(&self.git_binary_path)
+                .current_dir(&working_directory)
+                .args(args)
+                .output()?;
+
+            // git symbolic-ref returns a non-0 exit code if HEAD points
+            // to something other than a branch
+            if output.status.success() {
+                let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
+
+                branches.push(Branch {
+                    name: name.into(),
+                    is_head: true,
+                    upstream: None,
+                    most_recent_commit: None,
+                });
+            }
+        }
+
+        Ok(branches)
     }
 
     fn change_branch(&self, name: &str) -> Result<()> {
@@ -478,11 +601,6 @@ impl GitRepository for FakeGitRepository {
         None
     }
 
-    fn branch_name(&self) -> Option<String> {
-        let state = self.state.lock();
-        state.current_branch_name.clone()
-    }
-
     fn head_sha(&self) -> Option<String> {
         None
     }
@@ -491,6 +609,14 @@ impl GitRepository for FakeGitRepository {
         vec![]
     }
 
+    fn show(&self, _: &str) -> Result<CommitDetails> {
+        unimplemented!()
+    }
+
+    fn reset(&self, _: &str, _: ResetMode) -> Result<()> {
+        unimplemented!()
+    }
+
     fn path(&self) -> PathBuf {
         let state = self.state.lock();
         state.path.clone()
@@ -533,7 +659,8 @@ impl GitRepository for FakeGitRepository {
             .map(|branch_name| Branch {
                 is_head: Some(branch_name) == current_branch.as_ref(),
                 name: branch_name.into(),
-                unix_timestamp: None,
+                most_recent_commit: None,
+                upstream: None,
             })
             .collect())
     }
@@ -703,3 +830,106 @@ impl<'a> MapSeekTarget<RepoPath> for RepoPathDescendants<'a> {
         }
     }
 }
+
+fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
+    let mut branches = Vec::new();
+    for line in input.split('\n') {
+        if line.is_empty() {
+            continue;
+        }
+        let mut fields = line.split('\x00');
+        let is_current_branch = fields.next().context("no HEAD")? == "*";
+        let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into();
+        let ref_name: SharedString = fields
+            .next()
+            .context("no refname")?
+            .strip_prefix("refs/heads/")
+            .context("unexpected format for refname")?
+            .to_string()
+            .into();
+        let upstream_name = fields.next().context("no upstream")?.to_string();
+        let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?;
+        let commiterdate = fields.next().context("no committerdate")?.parse::<i64>()?;
+        let subject: SharedString = fields
+            .next()
+            .context("no contents:subject")?
+            .to_string()
+            .into();
+
+        branches.push(Branch {
+            is_head: is_current_branch,
+            name: ref_name,
+            most_recent_commit: Some(CommitSummary {
+                sha: head_sha,
+                subject,
+                commit_timestamp: commiterdate,
+            }),
+            upstream: if upstream_name.is_empty() {
+                None
+            } else {
+                Some(Upstream {
+                    ref_name: upstream_name.into(),
+                    tracking: upstream_tracking,
+                })
+            },
+        })
+    }
+
+    Ok(branches)
+}
+
+fn parse_upstream_track(upstream_track: &str) -> Result<Option<UpstreamTracking>> {
+    if upstream_track == "" {
+        return Ok(Some(UpstreamTracking {
+            ahead: 0,
+            behind: 0,
+        }));
+    }
+
+    let upstream_track = upstream_track
+        .strip_prefix("[")
+        .ok_or_else(|| anyhow!("missing ["))?;
+    let upstream_track = upstream_track
+        .strip_suffix("]")
+        .ok_or_else(|| anyhow!("missing ["))?;
+    let mut ahead: u32 = 0;
+    let mut behind: u32 = 0;
+    for component in upstream_track.split(", ") {
+        if component == "gone" {
+            return Ok(None);
+        }
+        if let Some(ahead_num) = component.strip_prefix("ahead ") {
+            ahead = ahead_num.parse::<u32>()?;
+        }
+        if let Some(behind_num) = component.strip_prefix("behind ") {
+            behind = behind_num.parse::<u32>()?;
+        }
+    }
+    Ok(Some(UpstreamTracking { ahead, behind }))
+}
+
+#[test]
+fn test_branches_parsing() {
+    // suppress "help: octal escapes are not supported, `\0` is always null"
+    #[allow(clippy::octal_escapes)]
+    let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n";
+    assert_eq!(
+        parse_branch_input(&input).unwrap(),
+        vec![Branch {
+            is_head: true,
+            name: "zed-patches".into(),
+            upstream: Some(Upstream {
+                ref_name: "refs/remotes/origin/zed-patches".into(),
+                tracking: Some(UpstreamTracking {
+                    ahead: 0,
+                    behind: 0
+                })
+            }),
+            most_recent_commit: Some(CommitSummary {
+                sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
+                subject: "generated protobuf".into(),
+                commit_timestamp: 1733187470,
+            })
+        }]
+    )
+}

crates/git/test_data/golden/blame_incremental_complex.json 🔗

@@ -10,8 +10,8 @@
     "author_mail": "<64036912+mmkaram@users.noreply.github.com>",
     "author_time": 1708621949,
     "author_tz": "-0800",
-    "committer": "GitHub",
-    "committer_mail": "<noreply@github.com>",
+    "committer_name": "GitHub",
+    "committer_email": "<noreply@github.com>",
     "committer_time": 1708621949,
     "committer_tz": "-0700",
     "summary": "Add option to either use system clipboard or vim clipboard (#7936)",
@@ -29,8 +29,8 @@
     "author_mail": "<64036912+mmkaram@users.noreply.github.com>",
     "author_time": 1708621949,
     "author_tz": "-0800",
-    "committer": "GitHub",
-    "committer_mail": "<noreply@github.com>",
+    "committer_name": "GitHub",
+    "committer_email": "<noreply@github.com>",
     "committer_time": 1708621949,
     "committer_tz": "-0700",
     "summary": "Add option to either use system clipboard or vim clipboard (#7936)",
@@ -48,8 +48,8 @@
     "author_mail": "<64036912+mmkaram@users.noreply.github.com>",
     "author_time": 1708621949,
     "author_tz": "-0800",
-    "committer": "GitHub",
-    "committer_mail": "<noreply@github.com>",
+    "committer_name": "GitHub",
+    "committer_email": "<noreply@github.com>",
     "committer_time": 1708621949,
     "committer_tz": "-0700",
     "summary": "Add option to either use system clipboard or vim clipboard (#7936)",
@@ -67,8 +67,8 @@
     "author_mail": "<64036912+mmkaram@users.noreply.github.com>",
     "author_time": 1708621949,
     "author_tz": "-0800",
-    "committer": "GitHub",
-    "committer_mail": "<noreply@github.com>",
+    "committer_name": "GitHub",
+    "committer_email": "<noreply@github.com>",
     "committer_time": 1708621949,
     "committer_tz": "-0700",
     "summary": "Add option to either use system clipboard or vim clipboard (#7936)",
@@ -86,8 +86,8 @@
     "author_mail": "<64036912+mmkaram@users.noreply.github.com>",
     "author_time": 1708621949,
     "author_tz": "-0800",
-    "committer": "GitHub",
-    "committer_mail": "<noreply@github.com>",
+    "committer_name": "GitHub",
+    "committer_email": "<noreply@github.com>",
     "committer_time": 1708621949,
     "committer_tz": "-0700",
     "summary": "Add option to either use system clipboard or vim clipboard (#7936)",
@@ -105,8 +105,8 @@
     "author_mail": "<64036912+mmkaram@users.noreply.github.com>",
     "author_time": 1708621949,
     "author_tz": "-0800",
-    "committer": "GitHub",
-    "committer_mail": "<noreply@github.com>",
+    "committer_name": "GitHub",
+    "committer_email": "<noreply@github.com>",
     "committer_time": 1708621949,
     "committer_tz": "-0700",
     "summary": "Add option to either use system clipboard or vim clipboard (#7936)",
@@ -124,8 +124,8 @@
     "author_mail": "<64036912+mmkaram@users.noreply.github.com>",
     "author_time": 1708621949,
     "author_tz": "-0800",
-    "committer": "GitHub",
-    "committer_mail": "<noreply@github.com>",
+    "committer_name": "GitHub",
+    "committer_email": "<noreply@github.com>",
     "committer_time": 1708621949,
     "committer_tz": "-0700",
     "summary": "Add option to either use system clipboard or vim clipboard (#7936)",
@@ -143,8 +143,8 @@
     "author_mail": "<conrad@zed.dev>",
     "author_time": 1707520689,
     "author_tz": "-0700",
-    "committer": "GitHub",
-    "committer_mail": "<noreply@github.com>",
+    "committer_name": "GitHub",
+    "committer_email": "<noreply@github.com>",
     "committer_time": 1707520689,
     "committer_tz": "-0700",
     "summary": "Highlight selections on vim yank (#7638)",
@@ -162,8 +162,8 @@
     "author_mail": "<conrad@zed.dev>",
     "author_time": 1707520689,
     "author_tz": "-0700",
-    "committer": "GitHub",
-    "committer_mail": "<noreply@github.com>",
+    "committer_name": "GitHub",
+    "committer_email": "<noreply@github.com>",
     "committer_time": 1707520689,
     "committer_tz": "-0700",
     "summary": "Highlight selections on vim yank (#7638)",
@@ -181,8 +181,8 @@
     "author_mail": "<conrad@zed.dev>",
     "author_time": 1707520689,
     "author_tz": "-0700",
-    "committer": "GitHub",
-    "committer_mail": "<noreply@github.com>",
+    "committer_name": "GitHub",
+    "committer_email": "<noreply@github.com>",
     "committer_time": 1707520689,
     "committer_tz": "-0700",
     "summary": "Highlight selections on vim yank (#7638)",
@@ -200,8 +200,8 @@
     "author_mail": "<conrad@zed.dev>",
     "author_time": 1707520689,
     "author_tz": "-0700",
-    "committer": "GitHub",
-    "committer_mail": "<noreply@github.com>",
+    "committer_name": "GitHub",
+    "committer_email": "<noreply@github.com>",
     "committer_time": 1707520689,
     "committer_tz": "-0700",
     "summary": "Highlight selections on vim yank (#7638)",
@@ -219,8 +219,8 @@
     "author_mail": "<conrad@zed.dev>",
     "author_time": 1707520689,
     "author_tz": "-0700",
-    "committer": "GitHub",
-    "committer_mail": "<noreply@github.com>",
+    "committer_name": "GitHub",
+    "committer_email": "<noreply@github.com>",
     "committer_time": 1707520689,
     "committer_tz": "-0700",
     "summary": "Highlight selections on vim yank (#7638)",
@@ -238,8 +238,8 @@
     "author_mail": "<conrad@zed.dev>",
     "author_time": 1707520689,
     "author_tz": "-0700",
-    "committer": "GitHub",
-    "committer_mail": "<noreply@github.com>",
+    "committer_name": "GitHub",
+    "committer_email": "<noreply@github.com>",
     "committer_time": 1707520689,
     "committer_tz": "-0700",
     "summary": "Highlight selections on vim yank (#7638)",
@@ -257,8 +257,8 @@
     "author_mail": "<conrad@zed.dev>",
     "author_time": 1707520689,
     "author_tz": "-0700",
-    "committer": "GitHub",
-    "committer_mail": "<noreply@github.com>",
+    "committer_name": "GitHub",
+    "committer_email": "<noreply@github.com>",
     "committer_time": 1707520689,
     "committer_tz": "-0700",
     "summary": "Highlight selections on vim yank (#7638)",
@@ -276,8 +276,8 @@
     "author_mail": "<conrad@zed.dev>",
     "author_time": 1707520689,
     "author_tz": "-0700",
-    "committer": "GitHub",
-    "committer_mail": "<noreply@github.com>",
+    "committer_name": "GitHub",
+    "committer_email": "<noreply@github.com>",
     "committer_time": 1707520689,
     "committer_tz": "-0700",
     "summary": "Highlight selections on vim yank (#7638)",
@@ -295,8 +295,8 @@
     "author_mail": "<conrad@zed.dev>",
     "author_time": 1707520689,
     "author_tz": "-0700",
-    "committer": "GitHub",
-    "committer_mail": "<noreply@github.com>",
+    "committer_name": "GitHub",
+    "committer_email": "<noreply@github.com>",
     "committer_time": 1707520689,
     "committer_tz": "-0700",
     "summary": "Highlight selections on vim yank (#7638)",
@@ -314,8 +314,8 @@
     "author_mail": "<conrad@zed.dev>",
     "author_time": 1707520689,
     "author_tz": "-0700",
-    "committer": "GitHub",
-    "committer_mail": "<noreply@github.com>",
+    "committer_name": "GitHub",
+    "committer_email": "<noreply@github.com>",
     "committer_time": 1707520689,
     "committer_tz": "-0700",
     "summary": "Highlight selections on vim yank (#7638)",
@@ -333,8 +333,8 @@
     "author_mail": "<conrad@zed.dev>",
     "author_time": 1707520689,
     "author_tz": "-0700",
-    "committer": "GitHub",
-    "committer_mail": "<noreply@github.com>",
+    "committer_name": "GitHub",
+    "committer_email": "<noreply@github.com>",
     "committer_time": 1707520689,
     "committer_tz": "-0700",
     "summary": "Highlight selections on vim yank (#7638)",
@@ -352,8 +352,8 @@
     "author_mail": "<maxbrunsfeld@gmail.com>",
     "author_time": 1705619094,
     "author_tz": "-0800",
-    "committer": "Max Brunsfeld",
-    "committer_mail": "<maxbrunsfeld@gmail.com>",
+    "committer_name": "Max Brunsfeld",
+    "committer_email": "<maxbrunsfeld@gmail.com>",
     "committer_time": 1705619205,
     "committer_tz": "-0800",
     "summary": "Merge branch 'main' into language-api-docs",
@@ -371,8 +371,8 @@
     "author_mail": "<maxbrunsfeld@gmail.com>",
     "author_time": 1705619094,
     "author_tz": "-0800",
-    "committer": "Max Brunsfeld",
-    "committer_mail": "<maxbrunsfeld@gmail.com>",
+    "committer_name": "Max Brunsfeld",
+    "committer_email": "<maxbrunsfeld@gmail.com>",
     "committer_time": 1705619205,
     "committer_tz": "-0800",
     "summary": "Merge branch 'main' into language-api-docs",
@@ -390,8 +390,8 @@
     "author_mail": "<conrad@zed.dev>",
     "author_time": 1694798044,
     "author_tz": "-0600",
-    "committer": "Conrad Irwin",
-    "committer_mail": "<conrad@zed.dev>",
+    "committer_name": "Conrad Irwin",
+    "committer_email": "<conrad@zed.dev>",
     "committer_time": 1694798044,
     "committer_tz": "-0600",
     "summary": "Fix Y on last line with no trailing new line",
@@ -409,8 +409,8 @@
     "author_mail": "<conrad@zed.dev>",
     "author_time": 1694798044,
     "author_tz": "-0600",
-    "committer": "Conrad Irwin",
-    "committer_mail": "<conrad@zed.dev>",
+    "committer_name": "Conrad Irwin",
+    "committer_email": "<conrad@zed.dev>",
     "committer_time": 1694798044,
     "committer_tz": "-0600",
     "summary": "Fix Y on last line with no trailing new line",
@@ -428,8 +428,8 @@
     "author_mail": "<conrad@zed.dev>",
     "author_time": 1692855942,
     "author_tz": "-0600",
-    "committer": "Conrad Irwin",
-    "committer_mail": "<conrad@zed.dev>",
+    "committer_name": "Conrad Irwin",
+    "committer_email": "<conrad@zed.dev>",
     "committer_time": 1692856812,
     "committer_tz": "-0600",
     "summary": "vim: Fix linewise copy of last line with no trailing newline",
@@ -447,8 +447,8 @@
     "author_mail": "<conrad@zed.dev>",
     "author_time": 1692855942,
     "author_tz": "-0600",
-    "committer": "Conrad Irwin",
-    "committer_mail": "<conrad@zed.dev>",
+    "committer_name": "Conrad Irwin",
+    "committer_email": "<conrad@zed.dev>",
     "committer_time": 1692856812,
     "committer_tz": "-0600",
     "summary": "vim: Fix linewise copy of last line with no trailing newline",
@@ -466,8 +466,8 @@
     "author_mail": "<conrad@zed.dev>",
     "author_time": 1692855942,
     "author_tz": "-0600",
-    "committer": "Conrad Irwin",
-    "committer_mail": "<conrad@zed.dev>",
+    "committer_name": "Conrad Irwin",
+    "committer_email": "<conrad@zed.dev>",
     "committer_time": 1692856812,
     "committer_tz": "-0600",
     "summary": "vim: Fix linewise copy of last line with no trailing newline",
@@ -485,8 +485,8 @@
     "author_mail": "<conrad@zed.dev>",
     "author_time": 1692855942,
     "author_tz": "-0600",
-    "committer": "Conrad Irwin",
-    "committer_mail": "<conrad@zed.dev>",
+    "committer_name": "Conrad Irwin",
+    "committer_email": "<conrad@zed.dev>",
     "committer_time": 1692856812,
     "committer_tz": "-0600",
     "summary": "vim: Fix linewise copy of last line with no trailing newline",
@@ -504,8 +504,8 @@
     "author_mail": "<conrad@zed.dev>",
     "author_time": 1692855942,
     "author_tz": "-0600",
-    "committer": "Conrad Irwin",
-    "committer_mail": "<conrad@zed.dev>",
+    "committer_name": "Conrad Irwin",
+    "committer_email": "<conrad@zed.dev>",
     "committer_time": 1692856812,
     "committer_tz": "-0600",
     "summary": "vim: Fix linewise copy of last line with no trailing newline",
@@ -523,8 +523,8 @@
     "author_mail": "<conrad@zed.dev>",
     "author_time": 1692644159,
     "author_tz": "-0600",
-    "committer": "Conrad Irwin",
-    "committer_mail": "<conrad@zed.dev>",
+    "committer_name": "Conrad Irwin",
+    "committer_email": "<conrad@zed.dev>",
     "committer_time": 1692732477,
     "committer_tz": "-0600",
     "summary": "Rewrite paste",
@@ -542,8 +542,8 @@
     "author_mail": "<conrad@zed.dev>",
     "author_time": 1692644159,
     "author_tz": "-0600",
-    "committer": "Conrad Irwin",
-    "committer_mail": "<conrad@zed.dev>",
+    "committer_name": "Conrad Irwin",
+    "committer_email": "<conrad@zed.dev>",
     "committer_time": 1692732477,
     "committer_tz": "-0600",
     "summary": "Rewrite paste",
@@ -561,8 +561,8 @@
     "author_mail": "<maxbrunsfeld@gmail.com>",
     "author_time": 1659072896,
     "author_tz": "-0700",
-    "committer": "Max Brunsfeld",
-    "committer_mail": "<maxbrunsfeld@gmail.com>",
+    "committer_name": "Max Brunsfeld",
+    "committer_email": "<maxbrunsfeld@gmail.com>",
     "committer_time": 1659073230,
     "committer_tz": "-0700",
     "summary": ":art: Rename and simplify some autoindent stuff",
@@ -580,8 +580,8 @@
     "author_mail": "<kay@the-simmons.net>",
     "author_time": 1653424557,
     "author_tz": "-0700",
-    "committer": "Kaylee Simmons",
-    "committer_mail": "<kay@the-simmons.net>",
+    "committer_name": "Kaylee Simmons",
+    "committer_email": "<kay@the-simmons.net>",
     "committer_time": 1653609725,
     "committer_tz": "-0700",
     "summary": "Unify visual line_mode and non line_mode operators",
@@ -599,8 +599,8 @@
     "author_mail": "<kay@the-simmons.net>",
     "author_time": 1653007350,
     "author_tz": "-0700",
-    "committer": "Kaylee Simmons",
-    "committer_mail": "<kay@the-simmons.net>",
+    "committer_name": "Kaylee Simmons",
+    "committer_email": "<kay@the-simmons.net>",
     "committer_time": 1653609725,
     "committer_tz": "-0700",
     "summary": "Enable copy and paste in vim mode",
@@ -618,8 +618,8 @@
     "author_mail": "<kay@the-simmons.net>",
     "author_time": 1653007350,
     "author_tz": "-0700",
-    "committer": "Kaylee Simmons",
-    "committer_mail": "<kay@the-simmons.net>",
+    "committer_name": "Kaylee Simmons",
+    "committer_email": "<kay@the-simmons.net>",
     "committer_time": 1653609725,
     "committer_tz": "-0700",
     "summary": "Enable copy and paste in vim mode",
@@ -637,8 +637,8 @@
     "author_mail": "<kay@the-simmons.net>",
     "author_time": 1653007350,
     "author_tz": "-0700",
-    "committer": "Kaylee Simmons",
-    "committer_mail": "<kay@the-simmons.net>",
+    "committer_name": "Kaylee Simmons",
+    "committer_email": "<kay@the-simmons.net>",
     "committer_time": 1653609725,
     "committer_tz": "-0700",
     "summary": "Enable copy and paste in vim mode",
@@ -656,8 +656,8 @@
     "author_mail": "<kay@the-simmons.net>",
     "author_time": 1653007350,
     "author_tz": "-0700",
-    "committer": "Kaylee Simmons",
-    "committer_mail": "<kay@the-simmons.net>",
+    "committer_name": "Kaylee Simmons",
+    "committer_email": "<kay@the-simmons.net>",
     "committer_time": 1653609725,
     "committer_tz": "-0700",
     "summary": "Enable copy and paste in vim mode",
@@ -675,8 +675,8 @@
     "author_mail": "<kay@the-simmons.net>",
     "author_time": 1653007350,
     "author_tz": "-0700",
-    "committer": "Kaylee Simmons",
-    "committer_mail": "<kay@the-simmons.net>",
+    "committer_name": "Kaylee Simmons",
+    "committer_email": "<kay@the-simmons.net>",
     "committer_time": 1653609725,
     "committer_tz": "-0700",
     "summary": "Enable copy and paste in vim mode",
@@ -694,8 +694,8 @@
     "author_mail": "<kay@the-simmons.net>",
     "author_time": 1653007350,
     "author_tz": "-0700",
-    "committer": "Kaylee Simmons",
-    "committer_mail": "<kay@the-simmons.net>",
+    "committer_name": "Kaylee Simmons",
+    "committer_email": "<kay@the-simmons.net>",
     "committer_time": 1653609725,
     "committer_tz": "-0700",
     "summary": "Enable copy and paste in vim mode",
@@ -713,8 +713,8 @@
     "author_mail": "<kay@the-simmons.net>",
     "author_time": 1653007350,
     "author_tz": "-0700",
-    "committer": "Kaylee Simmons",
-    "committer_mail": "<kay@the-simmons.net>",
+    "committer_name": "Kaylee Simmons",
+    "committer_email": "<kay@the-simmons.net>",
     "committer_time": 1653609725,
     "committer_tz": "-0700",
     "summary": "Enable copy and paste in vim mode",
@@ -732,8 +732,8 @@
     "author_mail": "<kay@the-simmons.net>",
     "author_time": 1653007350,
     "author_tz": "-0700",
-    "committer": "Kaylee Simmons",
-    "committer_mail": "<kay@the-simmons.net>",
+    "committer_name": "Kaylee Simmons",
+    "committer_email": "<kay@the-simmons.net>",
     "committer_time": 1653609725,
     "committer_tz": "-0700",
     "summary": "Enable copy and paste in vim mode",
@@ -751,8 +751,8 @@
     "author_mail": "<kay@the-simmons.net>",
     "author_time": 1653007350,
     "author_tz": "-0700",
-    "committer": "Kaylee Simmons",
-    "committer_mail": "<kay@the-simmons.net>",
+    "committer_name": "Kaylee Simmons",
+    "committer_email": "<kay@the-simmons.net>",
     "committer_time": 1653609725,
     "committer_tz": "-0700",
     "summary": "Enable copy and paste in vim mode",
@@ -770,8 +770,8 @@
     "author_mail": "<kay@the-simmons.net>",
     "author_time": 1653007350,
     "author_tz": "-0700",
-    "committer": "Kaylee Simmons",
-    "committer_mail": "<kay@the-simmons.net>",
+    "committer_name": "Kaylee Simmons",
+    "committer_email": "<kay@the-simmons.net>",
     "committer_time": 1653609725,
     "committer_tz": "-0700",
     "summary": "Enable copy and paste in vim mode",

crates/git/test_data/golden/blame_incremental_not_committed.json 🔗

@@ -10,8 +10,8 @@
     "author_mail": "<mrnugget@gmail.com>",
     "author_time": 1710764113,
     "author_tz": "+0100",
-    "committer": "Thorsten Ball",
-    "committer_mail": "<mrnugget@gmail.com>",
+    "committer_name": "Thorsten Ball",
+    "committer_email": "<mrnugget@gmail.com>",
     "committer_time": 1710764113,
     "committer_tz": "+0100",
     "summary": "Another commit",
@@ -29,8 +29,8 @@
     "author_mail": "<mrnugget@gmail.com>",
     "author_time": 1710764113,
     "author_tz": "+0100",
-    "committer": "Thorsten Ball",
-    "committer_mail": "<mrnugget@gmail.com>",
+    "committer_name": "Thorsten Ball",
+    "committer_email": "<mrnugget@gmail.com>",
     "committer_time": 1710764113,
     "committer_tz": "+0100",
     "summary": "Another commit",
@@ -48,8 +48,8 @@
     "author_mail": "<mrnugget@gmail.com>",
     "author_time": 1710764087,
     "author_tz": "+0100",
-    "committer": "Thorsten Ball",
-    "committer_mail": "<mrnugget@gmail.com>",
+    "committer_name": "Thorsten Ball",
+    "committer_email": "<mrnugget@gmail.com>",
     "committer_time": 1710764087,
     "committer_tz": "+0100",
     "summary": "Another commit",
@@ -67,8 +67,8 @@
     "author_mail": "<mrnugget@gmail.com>",
     "author_time": 1710764087,
     "author_tz": "+0100",
-    "committer": "Thorsten Ball",
-    "committer_mail": "<mrnugget@gmail.com>",
+    "committer_name": "Thorsten Ball",
+    "committer_email": "<mrnugget@gmail.com>",
     "committer_time": 1710764087,
     "committer_tz": "+0100",
     "summary": "Another commit",
@@ -86,8 +86,8 @@
     "author_mail": "<mrnugget@gmail.com>",
     "author_time": 1709299737,
     "author_tz": "+0100",
-    "committer": "Thorsten Ball",
-    "committer_mail": "<mrnugget@gmail.com>",
+    "committer_name": "Thorsten Ball",
+    "committer_email": "<mrnugget@gmail.com>",
     "committer_time": 1709299737,
     "committer_tz": "+0100",
     "summary": "Initial",
@@ -105,8 +105,8 @@
     "author_mail": "<mrnugget@gmail.com>",
     "author_time": 1709299737,
     "author_tz": "+0100",
-    "committer": "Thorsten Ball",
-    "committer_mail": "<mrnugget@gmail.com>",
+    "committer_name": "Thorsten Ball",
+    "committer_email": "<mrnugget@gmail.com>",
     "committer_time": 1709299737,
     "committer_tz": "+0100",
     "summary": "Initial",
@@ -124,8 +124,8 @@
     "author_mail": "<mrnugget@gmail.com>",
     "author_time": 1709299737,
     "author_tz": "+0100",
-    "committer": "Thorsten Ball",
-    "committer_mail": "<mrnugget@gmail.com>",
+    "committer_name": "Thorsten Ball",
+    "committer_email": "<mrnugget@gmail.com>",
     "committer_time": 1709299737,
     "committer_tz": "+0100",
     "summary": "Initial",

crates/git/test_data/golden/blame_incremental_simple.json 🔗

@@ -10,8 +10,8 @@
     "author_mail": "<mrnugget@example.com>",
     "author_time": 1709808710,
     "author_tz": "+0100",
-    "committer": "Thorsten Ball",
-    "committer_mail": "<mrnugget@example.com>",
+    "committer_name": "Thorsten Ball",
+    "committer_email": "<mrnugget@example.com>",
     "committer_time": 1709808710,
     "committer_tz": "+0100",
     "summary": "Make a commit",
@@ -29,8 +29,8 @@
     "author_mail": "<joe.schmoe@example.com>",
     "author_time": 1709741400,
     "author_tz": "+0100",
-    "committer": "Joe Schmoe",
-    "committer_mail": "<joe.schmoe@example.com>",
+    "committer_name": "Joe Schmoe",
+    "committer_email": "<joe.schmoe@example.com>",
     "committer_time": 1709741400,
     "committer_tz": "+0100",
     "summary": "Joe's cool commit",
@@ -48,8 +48,8 @@
     "author_mail": "<joe.schmoe@example.com>",
     "author_time": 1709741400,
     "author_tz": "+0100",
-    "committer": "Joe Schmoe",
-    "committer_mail": "<joe.schmoe@example.com>",
+    "committer_name": "Joe Schmoe",
+    "committer_email": "<joe.schmoe@example.com>",
     "committer_time": 1709741400,
     "committer_tz": "+0100",
     "summary": "Joe's cool commit",
@@ -67,8 +67,8 @@
     "author_mail": "<joe.schmoe@example.com>",
     "author_time": 1709741400,
     "author_tz": "+0100",
-    "committer": "Joe Schmoe",
-    "committer_mail": "<joe.schmoe@example.com>",
+    "committer_name": "Joe Schmoe",
+    "committer_email": "<joe.schmoe@example.com>",
     "committer_time": 1709741400,
     "committer_tz": "+0100",
     "summary": "Joe's cool commit",
@@ -86,8 +86,8 @@
     "author_mail": "<mrnugget@example.com>",
     "author_time": 1709129122,
     "author_tz": "+0100",
-    "committer": "Thorsten Ball",
-    "committer_mail": "<mrnugget@example.com>",
+    "committer_name": "Thorsten Ball",
+    "committer_email": "<mrnugget@example.com>",
     "committer_time": 1709129122,
     "committer_tz": "+0100",
     "summary": "Get to a state where eslint would change code and imports",
@@ -105,8 +105,8 @@
     "author_mail": "<mrnugget@example.com>",
     "author_time": 1709128963,
     "author_tz": "+0100",
-    "committer": "Thorsten Ball",
-    "committer_mail": "<mrnugget@example.com>",
+    "committer_name": "Thorsten Ball",
+    "committer_email": "<mrnugget@example.com>",
     "committer_time": 1709128963,
     "committer_tz": "+0100",
     "summary": "Add some stuff",
@@ -124,8 +124,8 @@
     "author_mail": "<mrnugget@example.com>",
     "author_time": 1709128963,
     "author_tz": "+0100",
-    "committer": "Thorsten Ball",
-    "committer_mail": "<mrnugget@example.com>",
+    "committer_name": "Thorsten Ball",
+    "committer_email": "<mrnugget@example.com>",
     "committer_time": 1709128963,
     "committer_tz": "+0100",
     "summary": "Add some stuff",

crates/git_hosting_providers/src/providers/codeberg.rs 🔗

@@ -4,12 +4,13 @@ use std::sync::Arc;
 use anyhow::{bail, Context, Result};
 use async_trait::async_trait;
 use futures::AsyncReadExt;
+use gpui::SharedString;
 use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request};
 use serde::Deserialize;
 use url::Url;
 
 use git::{
-    BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, Oid, ParsedGitRemote,
+    BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
     RemoteUrl,
 };
 
@@ -160,7 +161,7 @@ impl GitHostingProvider for Codeberg {
         &self,
         repo_owner: &str,
         repo: &str,
-        commit: Oid,
+        commit: SharedString,
         http_client: Arc<dyn HttpClient>,
     ) -> Result<Option<Url>> {
         let commit = commit.to_string();

crates/git_hosting_providers/src/providers/github.rs 🔗

@@ -4,13 +4,14 @@ use std::sync::{Arc, LazyLock};
 use anyhow::{bail, Context, Result};
 use async_trait::async_trait;
 use futures::AsyncReadExt;
+use gpui::SharedString;
 use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request};
 use regex::Regex;
 use serde::Deserialize;
 use url::Url;
 
 use git::{
-    BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, Oid, ParsedGitRemote,
+    BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
     PullRequest, RemoteUrl,
 };
 
@@ -178,7 +179,7 @@ impl GitHostingProvider for Github {
         &self,
         repo_owner: &str,
         repo: &str,
-        commit: Oid,
+        commit: SharedString,
         http_client: Arc<dyn HttpClient>,
     ) -> Result<Option<Url>> {
         let commit = commit.to_string();

crates/git_ui/Cargo.toml 🔗

@@ -36,6 +36,8 @@ serde_derive.workspace = true
 serde_json.workspace = true
 settings.workspace = true
 theme.workspace = true
+time.workspace = true
+time_format.workspace = true
 ui.workspace = true
 util.workspace = true
 workspace.workspace = true

crates/git_ui/src/branch_picker.rs 🔗

@@ -190,9 +190,7 @@ impl PickerDelegate for BranchListDelegate {
                         // Truncate list of recent branches
                         // Do a partial sort to show recent-ish branches first.
                         branches.select_nth_unstable_by(RECENT_BRANCHES_COUNT - 1, |lhs, rhs| {
-                            rhs.is_head
-                                .cmp(&lhs.is_head)
-                                .then(rhs.unix_timestamp.cmp(&lhs.unix_timestamp))
+                            rhs.priority_key().cmp(&lhs.priority_key())
                         });
                         branches.truncate(RECENT_BRANCHES_COUNT);
                     }
@@ -255,6 +253,25 @@ impl PickerDelegate for BranchListDelegate {
         let Some(branch) = self.matches.get(self.selected_index()) else {
             return;
         };
+
+        let current_branch = self
+            .workspace
+            .update(cx, |workspace, cx| {
+                workspace
+                    .project()
+                    .read(cx)
+                    .active_repository(cx)
+                    .and_then(|repo| repo.read(cx).branch())
+                    .map(|branch| branch.name.to_string())
+            })
+            .ok()
+            .flatten();
+
+        if current_branch == Some(branch.name().to_string()) {
+            cx.emit(DismissEvent);
+            return;
+        }
+
         cx.spawn_in(window, {
             let branch = branch.clone();
             |picker, mut cx| async move {

crates/git_ui/src/git_panel.rs 🔗

@@ -6,13 +6,15 @@ use crate::{
 };
 use collections::HashMap;
 use db::kvp::KEY_VALUE_STORE;
+use editor::commit_tooltip::CommitTooltip;
 use editor::{
     actions::MoveToEnd, scroll::ScrollbarAutoHide, Editor, EditorElement, EditorMode,
     EditorSettings, MultiBuffer, ShowScrollbar,
 };
+use git::repository::{CommitDetails, ResetMode};
 use git::{repository::RepoPath, status::FileStatus, Commit, ToggleStaged};
 use gpui::*;
-use language::{Buffer, File};
+use language::{markdown, Buffer, File, ParsedMarkdown};
 use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev};
 use multi_buffer::ExcerptInfo;
 use panel::{panel_editor_container, panel_editor_style, panel_filled_button, PanelHeader};
@@ -23,6 +25,7 @@ use project::{
 use serde::{Deserialize, Serialize};
 use settings::Settings as _;
 use std::{collections::HashSet, path::PathBuf, sync::Arc, time::Duration, usize};
+use time::OffsetDateTime;
 use ui::{
     prelude::*, ButtonLike, Checkbox, CheckboxWithLabel, Divider, DividerColor, ElevationIndex,
     IndentGuideColors, ListItem, ListItemSpacing, Scrollbar, ScrollbarState, Tooltip,
@@ -207,7 +210,7 @@ impl GitPanel {
     ) -> Entity<Self> {
         let fs = workspace.app_state().fs.clone();
         let project = workspace.project().clone();
-        let git_state = project.read(cx).git_state().clone();
+        let git_store = project.read(cx).git_store().clone();
         let active_repository = project.read(cx).active_repository(cx);
         let workspace = cx.entity().downgrade();
 
@@ -231,14 +234,14 @@ impl GitPanel {
             let scroll_handle = UniformListScrollHandle::new();
 
             cx.subscribe_in(
-                &git_state,
+                &git_store,
                 window,
-                move |this, git_state, event, window, cx| match event {
+                move |this, git_store, event, window, cx| match event {
                     GitEvent::FileSystemUpdated => {
                         this.schedule_update(false, window, cx);
                     }
                     GitEvent::ActiveRepositoryChanged | GitEvent::GitStateUpdated => {
-                        this.active_repository = git_state.read(cx).active_repository();
+                        this.active_repository = git_store.read(cx).active_repository();
                         this.schedule_update(true, window, cx);
                     }
                 },
@@ -744,6 +747,40 @@ impl GitPanel {
         self.pending_commit = Some(task);
     }
 
+    fn uncommit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(repo) = self.active_repository.clone() else {
+            return;
+        };
+        let prior_head = self.load_commit_details("HEAD", cx);
+
+        let task = cx.spawn(|_, mut cx| async move {
+            let prior_head = prior_head.await?;
+
+            repo.update(&mut cx, |repo, _| repo.reset("HEAD^", ResetMode::Soft))?
+                .await??;
+
+            Ok(prior_head)
+        });
+
+        let task = cx.spawn_in(window, |this, mut cx| async move {
+            let result = task.await;
+            this.update_in(&mut cx, |this, window, cx| {
+                this.pending_commit.take();
+                match result {
+                    Ok(prior_commit) => {
+                        this.commit_editor.update(cx, |editor, cx| {
+                            editor.set_text(prior_commit.message, window, cx)
+                        });
+                    }
+                    Err(e) => this.show_err_toast(e, cx),
+                }
+            })
+            .ok();
+        });
+
+        self.pending_commit = Some(task);
+    }
+
     fn fill_co_authors(&mut self, _: &FillCoAuthors, window: &mut Window, cx: &mut Context<Self>) {
         const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
 
@@ -1131,16 +1168,10 @@ impl GitPanel {
         let all_repositories = self
             .project
             .read(cx)
-            .git_state()
+            .git_store()
             .read(cx)
             .all_repositories();
 
-        let branch = self
-            .active_repository
-            .as_ref()
-            .and_then(|repository| repository.read(cx).branch())
-            .unwrap_or_else(|| "(no current branch)".into());
-
         let has_repo_above = all_repositories.iter().any(|repo| {
             repo.read(cx)
                 .repository_entry
@@ -1148,26 +1179,7 @@ impl GitPanel {
                 .is_above_project()
         });
 
-        let icon_button = Button::new("branch-selector", branch)
-            .color(Color::Muted)
-            .style(ButtonStyle::Subtle)
-            .icon(IconName::GitBranch)
-            .icon_size(IconSize::Small)
-            .icon_color(Color::Muted)
-            .size(ButtonSize::Compact)
-            .icon_position(IconPosition::Start)
-            .tooltip(Tooltip::for_action_title(
-                "Switch Branch",
-                &zed_actions::git::Branch,
-            ))
-            .on_click(cx.listener(|_, _, window, cx| {
-                window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
-            }))
-            .style(ButtonStyle::Transparent);
-
         self.panel_header_container(window, cx)
-            .child(h_flex().pl_1().child(icon_button))
-            .child(div().flex_grow())
             .when(all_repositories.len() > 1 || has_repo_above, |el| {
                 el.child(self.render_repository_selector(cx))
             })
@@ -1200,6 +1212,7 @@ impl GitPanel {
             && !editor.read(cx).is_empty(cx)
             && !self.has_unstaged_conflicts()
             && self.has_write_access(cx);
+
         // let can_commit_all =
         //     !self.commit_pending && self.can_commit_all && !editor.read(cx).is_empty(cx);
         let panel_editor_style = panel_editor_style(true, window, cx);
@@ -1274,10 +1287,108 @@ impl GitPanel {
             )
     }
 
+    fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
+        let active_repository = self.active_repository.as_ref()?;
+        let branch = active_repository.read(cx).branch()?;
+        let commit = branch.most_recent_commit.as_ref()?.clone();
+
+        if branch.upstream.as_ref().is_some_and(|upstream| {
+            if let Some(tracking) = &upstream.tracking {
+                tracking.ahead == 0
+            } else {
+                true
+            }
+        }) {
+            return None;
+        }
+
+        let _branch_selector = Button::new("branch-selector", branch.name.clone())
+            .color(Color::Muted)
+            .style(ButtonStyle::Subtle)
+            .icon(IconName::GitBranch)
+            .icon_size(IconSize::Small)
+            .icon_color(Color::Muted)
+            .size(ButtonSize::Compact)
+            .icon_position(IconPosition::Start)
+            .tooltip(Tooltip::for_action_title(
+                "Switch Branch",
+                &zed_actions::git::Branch,
+            ))
+            .on_click(cx.listener(|_, _, window, cx| {
+                window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
+            }))
+            .style(ButtonStyle::Transparent);
+
+        let _timestamp = Label::new(time_format::format_local_timestamp(
+            OffsetDateTime::from_unix_timestamp(commit.commit_timestamp).log_err()?,
+            OffsetDateTime::now_utc(),
+            time_format::TimestampFormat::Relative,
+        ))
+        .size(LabelSize::Small)
+        .color(Color::Muted);
+
+        let tooltip = if self.has_staged_changes() {
+            "git reset HEAD^ --soft"
+        } else {
+            "git reset HEAD^"
+        };
+
+        let this = cx.entity();
+        Some(
+            h_flex()
+                .items_center()
+                .py_1p5()
+                .px(px(8.))
+                .bg(cx.theme().colors().background)
+                .border_t_1()
+                .border_color(cx.theme().colors().border)
+                .gap_1p5()
+                .child(
+                    div()
+                        .flex_grow()
+                        .overflow_hidden()
+                        .max_w(relative(0.6))
+                        .h_full()
+                        .child(
+                            Label::new(commit.subject.clone())
+                                .size(LabelSize::Small)
+                                .text_ellipsis(),
+                        )
+                        .id("commit-msg-hover")
+                        .hoverable_tooltip(move |window, cx| {
+                            GitPanelMessageTooltip::new(
+                                this.clone(),
+                                commit.sha.clone(),
+                                window,
+                                cx,
+                            )
+                            .into()
+                        }),
+                )
+                .child(div().flex_1())
+                .child(
+                    panel_filled_button("Uncommit")
+                        .icon(IconName::Undo)
+                        .icon_size(IconSize::Small)
+                        .icon_color(Color::Muted)
+                        .icon_position(IconPosition::Start)
+                        .tooltip(Tooltip::for_action_title(tooltip, &git::Uncommit))
+                        .on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))),
+                    // .child(
+                    //     panel_filled_button("Push")
+                    //         .icon(IconName::ArrowUp)
+                    //         .icon_size(IconSize::Small)
+                    //         .icon_color(Color::Muted)
+                    //         .icon_position(IconPosition::Start), // .disabled(true),
+                    // ),
+                ),
+        )
+    }
+
     fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
         h_flex()
             .h_full()
-            .flex_1()
+            .flex_grow()
             .justify_center()
             .items_center()
             .child(
@@ -1563,6 +1674,17 @@ impl GitPanel {
             .into_any_element()
     }
 
+    fn load_commit_details(
+        &self,
+        sha: &str,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<CommitDetails>> {
+        let Some(repo) = self.active_repository.clone() else {
+            return Task::ready(Err(anyhow::anyhow!("no active repo")));
+        };
+        repo.update(cx, |repo, cx| repo.show(sha, cx))
+    }
+
     fn render_entry(
         &self,
         ix: usize,
@@ -1757,6 +1879,7 @@ impl Render for GitPanel {
             } else {
                 self.render_empty_state(cx).into_any_element()
             })
+            .children(self.render_previous_commit(cx))
             .child(self.render_commit_editor(window, cx))
     }
 }
@@ -1843,3 +1966,81 @@ impl Panel for GitPanel {
 }
 
 impl PanelHeader for GitPanel {}
+
+struct GitPanelMessageTooltip {
+    commit_tooltip: Option<Entity<CommitTooltip>>,
+}
+
+impl GitPanelMessageTooltip {
+    fn new(
+        git_panel: Entity<GitPanel>,
+        sha: SharedString,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Entity<Self> {
+        let workspace = git_panel.read(cx).workspace.clone();
+        cx.new(|cx| {
+            cx.spawn_in(window, |this, mut cx| async move {
+                let language_registry = workspace.update(&mut cx, |workspace, _cx| {
+                    workspace.app_state().languages.clone()
+                })?;
+
+                let details = git_panel
+                    .update(&mut cx, |git_panel, cx| {
+                        git_panel.load_commit_details(&sha, cx)
+                    })?
+                    .await?;
+
+                let mut parsed_message = ParsedMarkdown::default();
+                markdown::parse_markdown_block(
+                    &details.message,
+                    Some(&language_registry),
+                    None,
+                    &mut parsed_message.text,
+                    &mut parsed_message.highlights,
+                    &mut parsed_message.region_ranges,
+                    &mut parsed_message.regions,
+                )
+                .await;
+
+                let commit_details = editor::commit_tooltip::CommitDetails {
+                    sha: details.sha.clone(),
+                    committer_name: details.committer_name.clone(),
+                    committer_email: details.committer_email.clone(),
+                    commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
+                    message: Some(editor::commit_tooltip::ParsedCommitMessage {
+                        message: details.message.clone(),
+                        parsed_message,
+                        ..Default::default()
+                    }),
+                };
+
+                this.update_in(&mut cx, |this: &mut GitPanelMessageTooltip, window, cx| {
+                    this.commit_tooltip = Some(cx.new(move |cx| {
+                        CommitTooltip::new(
+                            commit_details,
+                            panel_editor_style(true, window, cx),
+                            Some(workspace),
+                        )
+                    }));
+                    cx.notify();
+                })
+            })
+            .detach();
+
+            Self {
+                commit_tooltip: None,
+            }
+        })
+    }
+}
+
+impl Render for GitPanelMessageTooltip {
+    fn render(&mut self, _window: &mut Window, _cx: &mut Context<'_, Self>) -> impl IntoElement {
+        if let Some(commit_tooltip) = &self.commit_tooltip {
+            commit_tooltip.clone().into_any_element()
+        } else {
+            gpui::Empty.into_any_element()
+        }
+    }
+}

crates/git_ui/src/project_diff.rs 🔗

@@ -12,7 +12,7 @@ use gpui::{
 };
 use language::{Anchor, Buffer, Capability, OffsetRangeExt, Point};
 use multi_buffer::{MultiBuffer, PathKey};
-use project::{git::GitState, Project, ProjectPath};
+use project::{git::GitStore, Project, ProjectPath};
 use theme::ActiveTheme;
 use ui::prelude::*;
 use util::ResultExt as _;
@@ -31,7 +31,7 @@ pub(crate) struct ProjectDiff {
     editor: Entity<Editor>,
     project: Entity<Project>,
     git_panel: Entity<GitPanel>,
-    git_state: Entity<GitState>,
+    git_store: Entity<GitStore>,
     workspace: WeakEntity<Workspace>,
     focus_handle: FocusHandle,
     update_needed: postage::watch::Sender<()>,
@@ -137,11 +137,11 @@ impl ProjectDiff {
         cx.subscribe_in(&editor, window, Self::handle_editor_event)
             .detach();
 
-        let git_state = project.read(cx).git_state().clone();
-        let git_state_subscription = cx.subscribe_in(
-            &git_state,
+        let git_store = project.read(cx).git_store().clone();
+        let git_store_subscription = cx.subscribe_in(
+            &git_store,
             window,
-            move |this, _git_state, _event, _window, _cx| {
+            move |this, _git_store, _event, _window, _cx| {
                 *this.update_needed.borrow_mut() = ();
             },
         );
@@ -156,7 +156,7 @@ impl ProjectDiff {
 
         Self {
             project,
-            git_state: git_state.clone(),
+            git_store: git_store.clone(),
             git_panel: git_panel.clone(),
             workspace: workspace.downgrade(),
             focus_handle,
@@ -165,7 +165,7 @@ impl ProjectDiff {
             pending_scroll: None,
             update_needed: send,
             _task: worker,
-            _subscription: git_state_subscription,
+            _subscription: git_store_subscription,
         }
     }
 
@@ -175,7 +175,7 @@ impl ProjectDiff {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let Some(git_repo) = self.git_state.read(cx).active_repository() else {
+        let Some(git_repo) = self.git_store.read(cx).active_repository() else {
             return;
         };
         let repo = git_repo.read(cx);
@@ -248,7 +248,7 @@ impl ProjectDiff {
     }
 
     fn load_buffers(&mut self, cx: &mut Context<Self>) -> Vec<Task<Result<DiffBuffer>>> {
-        let Some(repo) = self.git_state.read(cx).active_repository() else {
+        let Some(repo) = self.git_store.read(cx).active_repository() else {
             self.multibuffer.update(cx, |multibuffer, cx| {
                 multibuffer.clear(cx);
             });

crates/git_ui/src/quick_commit.rs 🔗

@@ -98,7 +98,7 @@ impl QuickCommitModal {
         commit_message_buffer: Option<Entity<Buffer>>,
         cx: &mut Context<Self>,
     ) -> Self {
-        let git_state = project.read(cx).git_state().clone();
+        let git_store = project.read(cx).git_store().clone();
         let active_repository = project.read(cx).active_repository(cx);
 
         let focus_handle = cx.focus_handle();
@@ -130,7 +130,7 @@ impl QuickCommitModal {
         let all_repositories = self
             .project
             .read(cx)
-            .git_state()
+            .git_store()
             .read(cx)
             .all_repositories();
         let entry_count = self

crates/git_ui/src/repository_selector.rs 🔗

@@ -4,7 +4,7 @@ use gpui::{
 };
 use picker::{Picker, PickerDelegate};
 use project::{
-    git::{GitState, Repository},
+    git::{GitStore, Repository},
     Project,
 };
 use std::sync::Arc;
@@ -20,8 +20,8 @@ pub struct RepositorySelector {
 
 impl RepositorySelector {
     pub fn new(project: Entity<Project>, window: &mut Window, cx: &mut Context<Self>) -> Self {
-        let git_state = project.read(cx).git_state().clone();
-        let all_repositories = git_state.read(cx).all_repositories();
+        let git_store = project.read(cx).git_store().clone();
+        let all_repositories = git_store.read(cx).all_repositories();
         let filtered_repositories = all_repositories.clone();
         let delegate = RepositorySelectorDelegate {
             project: project.downgrade(),
@@ -38,7 +38,7 @@ impl RepositorySelector {
         });
 
         let _subscriptions =
-            vec![cx.subscribe_in(&git_state, window, Self::handle_project_git_event)];
+            vec![cx.subscribe_in(&git_store, window, Self::handle_project_git_event)];
 
         RepositorySelector {
             picker,
@@ -49,7 +49,7 @@ impl RepositorySelector {
 
     fn handle_project_git_event(
         &mut self,
-        git_state: &Entity<GitState>,
+        git_store: &Entity<GitStore>,
         _event: &project::git::GitEvent,
         window: &mut Window,
         cx: &mut Context<Self>,
@@ -57,7 +57,7 @@ impl RepositorySelector {
         // TODO handle events individually
         let task = self.picker.update(cx, |this, cx| {
             let query = this.query(cx);
-            this.delegate.repository_entries = git_state.read(cx).all_repositories();
+            this.delegate.repository_entries = git_store.read(cx).all_repositories();
             this.delegate.update_matches(query, window, cx)
         });
         self.update_matches_task = Some(task);

crates/project/src/buffer_store.rs 🔗

@@ -2722,8 +2722,8 @@ fn serialize_blame_buffer_response(blame: Option<git::blame::Blame>) -> proto::B
             author_mail: entry.author_mail.clone(),
             author_time: entry.author_time,
             author_tz: entry.author_tz.clone(),
-            committer: entry.committer.clone(),
-            committer_mail: entry.committer_mail.clone(),
+            committer: entry.committer_name.clone(),
+            committer_mail: entry.committer_email.clone(),
             committer_time: entry.committer_time,
             committer_tz: entry.committer_tz.clone(),
             summary: entry.summary.clone(),
@@ -2772,10 +2772,10 @@ fn deserialize_blame_buffer_response(
                 sha: git::Oid::from_bytes(&entry.sha).ok()?,
                 range: entry.start_line..entry.end_line,
                 original_line_number: entry.original_line_number,
-                committer: entry.committer,
+                committer_name: entry.committer,
                 committer_time: entry.committer_time,
                 committer_tz: entry.committer_tz,
-                committer_mail: entry.committer_mail,
+                committer_email: entry.committer_mail,
                 author: entry.author,
                 author_mail: entry.author_mail,
                 author_time: entry.author_time,

crates/project/src/git.rs 🔗

@@ -1,20 +1,22 @@
 use crate::buffer_store::BufferStore;
 use crate::worktree_store::{WorktreeStore, WorktreeStoreEvent};
 use crate::{Project, ProjectPath};
-use anyhow::Context as _;
+use anyhow::{Context as _, Result};
 use client::ProjectId;
 use futures::channel::{mpsc, oneshot};
 use futures::StreamExt as _;
+use git::repository::{Branch, CommitDetails, ResetMode};
 use git::{
     repository::{GitRepository, RepoPath},
     status::{GitSummary, TrackedSummary},
 };
 use gpui::{
-    App, AppContext, Context, Entity, EventEmitter, SharedString, Subscription, Task, WeakEntity,
+    App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task,
+    WeakEntity,
 };
 use language::{Buffer, LanguageRegistry};
-use rpc::proto::ToProto;
-use rpc::{proto, AnyProtoClient};
+use rpc::proto::{git_reset, ToProto};
+use rpc::{proto, AnyProtoClient, TypedEnvelope};
 use settings::WorktreeId;
 use std::path::{Path, PathBuf};
 use std::sync::Arc;
@@ -22,22 +24,23 @@ use text::BufferId;
 use util::{maybe, ResultExt};
 use worktree::{ProjectEntryId, RepositoryEntry, StatusEntry};
 
-pub struct GitState {
+pub struct GitStore {
     pub(super) project_id: Option<ProjectId>,
     pub(super) client: Option<AnyProtoClient>,
-    pub update_sender: mpsc::UnboundedSender<(Message, oneshot::Sender<anyhow::Result<()>>)>,
+    buffer_store: Entity<BufferStore>,
     repositories: Vec<Entity<Repository>>,
     active_index: Option<usize>,
+    update_sender: mpsc::UnboundedSender<(Message, oneshot::Sender<Result<()>>)>,
     _subscription: Subscription,
 }
 
 pub struct Repository {
     commit_message_buffer: Option<Entity<Buffer>>,
-    git_state: WeakEntity<GitState>,
+    git_store: WeakEntity<GitStore>,
     pub worktree_id: WorktreeId,
     pub repository_entry: RepositoryEntry,
     pub git_repo: GitRepo,
-    update_sender: mpsc::UnboundedSender<(Message, oneshot::Sender<anyhow::Result<()>>)>,
+    update_sender: mpsc::UnboundedSender<(Message, oneshot::Sender<Result<()>>)>,
 }
 
 #[derive(Clone)]
@@ -57,6 +60,11 @@ pub enum Message {
         message: SharedString,
         name_and_email: Option<(SharedString, SharedString)>,
     },
+    Reset {
+        repo: GitRepo,
+        commit: SharedString,
+        reset_mode: ResetMode,
+    },
     Stage(GitRepo, Vec<RepoPath>),
     Unstage(GitRepo, Vec<RepoPath>),
     SetIndexText(GitRepo, RepoPath, Option<String>),
@@ -68,11 +76,12 @@ pub enum GitEvent {
     GitStateUpdated,
 }
 
-impl EventEmitter<GitEvent> for GitState {}
+impl EventEmitter<GitEvent> for GitStore {}
 
-impl GitState {
+impl GitStore {
     pub fn new(
         worktree_store: &Entity<WorktreeStore>,
+        buffer_store: Entity<BufferStore>,
         client: Option<AnyProtoClient>,
         project_id: Option<ProjectId>,
         cx: &mut Context<'_, Self>,
@@ -80,9 +89,10 @@ impl GitState {
         let update_sender = Self::spawn_git_worker(cx);
         let _subscription = cx.subscribe(worktree_store, Self::on_worktree_store_event);
 
-        GitState {
+        GitStore {
             project_id,
             client,
+            buffer_store,
             repositories: Vec::new(),
             active_index: None,
             update_sender,
@@ -90,6 +100,16 @@ impl GitState {
         }
     }
 
+    pub fn init(client: &AnyProtoClient) {
+        client.add_entity_request_handler(Self::handle_stage);
+        client.add_entity_request_handler(Self::handle_unstage);
+        client.add_entity_request_handler(Self::handle_commit);
+        client.add_entity_request_handler(Self::handle_reset);
+        client.add_entity_request_handler(Self::handle_show);
+        client.add_entity_request_handler(Self::handle_open_commit_message_buffer);
+        client.add_entity_request_handler(Self::handle_set_index_text);
+    }
+
     pub fn active_repository(&self) -> Option<Entity<Repository>> {
         self.active_index
             .map(|index| self.repositories[index].clone())
@@ -153,7 +173,7 @@ impl GitState {
                             existing_handle
                         } else {
                             cx.new(|_| Repository {
-                                git_state: this.clone(),
+                                git_store: this.clone(),
                                 worktree_id,
                                 repository_entry: repo.clone(),
                                 git_repo,
@@ -189,10 +209,10 @@ impl GitState {
     }
 
     fn spawn_git_worker(
-        cx: &mut Context<'_, GitState>,
-    ) -> mpsc::UnboundedSender<(Message, oneshot::Sender<anyhow::Result<()>>)> {
+        cx: &mut Context<'_, GitStore>,
+    ) -> mpsc::UnboundedSender<(Message, oneshot::Sender<Result<()>>)> {
         let (update_sender, mut update_receiver) =
-            mpsc::unbounded::<(Message, oneshot::Sender<anyhow::Result<()>>)>();
+            mpsc::unbounded::<(Message, oneshot::Sender<Result<()>>)>();
         cx.spawn(|_, cx| async move {
             while let Some((msg, respond)) = update_receiver.next().await {
                 let result = cx
@@ -206,7 +226,7 @@ impl GitState {
         update_sender
     }
 
-    async fn process_git_msg(msg: Message) -> Result<(), anyhow::Error> {
+    async fn process_git_msg(msg: Message) -> Result<()> {
         match msg {
             Message::Stage(repo, paths) => {
                 match repo {
@@ -233,6 +253,35 @@ impl GitState {
                 }
                 Ok(())
             }
+            Message::Reset {
+                repo,
+                commit,
+                reset_mode,
+            } => {
+                match repo {
+                    GitRepo::Local(repo) => repo.reset(&commit, reset_mode)?,
+                    GitRepo::Remote {
+                        project_id,
+                        client,
+                        worktree_id,
+                        work_directory_id,
+                    } => {
+                        client
+                            .request(proto::GitReset {
+                                project_id: project_id.0,
+                                worktree_id: worktree_id.to_proto(),
+                                work_directory_id: work_directory_id.to_proto(),
+                                commit: commit.into(),
+                                mode: match reset_mode {
+                                    ResetMode::Soft => git_reset::ResetMode::Soft.into(),
+                                    ResetMode::Mixed => git_reset::ResetMode::Mixed.into(),
+                                },
+                            })
+                            .await?;
+                    }
+                }
+                Ok(())
+            }
             Message::Unstage(repo, paths) => {
                 match repo {
                     GitRepo::Local(repo) => repo.unstage_paths(&paths)?,
@@ -309,20 +358,219 @@ impl GitState {
             },
         }
     }
+
+    async fn handle_stage(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::Stage>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::Ack> {
+        let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
+        let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
+        let repository_handle =
+            Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
+
+        let entries = envelope
+            .payload
+            .paths
+            .into_iter()
+            .map(PathBuf::from)
+            .map(RepoPath::new)
+            .collect();
+
+        repository_handle
+            .update(&mut cx, |repository_handle, _| {
+                repository_handle.stage_entries(entries)
+            })?
+            .await??;
+        Ok(proto::Ack {})
+    }
+
+    async fn handle_unstage(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::Unstage>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::Ack> {
+        let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
+        let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
+        let repository_handle =
+            Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
+
+        let entries = envelope
+            .payload
+            .paths
+            .into_iter()
+            .map(PathBuf::from)
+            .map(RepoPath::new)
+            .collect();
+
+        repository_handle
+            .update(&mut cx, |repository_handle, _| {
+                repository_handle.unstage_entries(entries)
+            })?
+            .await??;
+
+        Ok(proto::Ack {})
+    }
+
+    async fn handle_set_index_text(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::SetIndexText>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::Ack> {
+        let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
+        let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
+        let repository_handle =
+            Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
+
+        repository_handle
+            .update(&mut cx, |repository_handle, _| {
+                repository_handle.set_index_text(
+                    &RepoPath::from_str(&envelope.payload.path),
+                    envelope.payload.text,
+                )
+            })?
+            .await??;
+        Ok(proto::Ack {})
+    }
+
+    async fn handle_commit(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::Commit>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::Ack> {
+        let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
+        let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
+        let repository_handle =
+            Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
+
+        let message = SharedString::from(envelope.payload.message);
+        let name = envelope.payload.name.map(SharedString::from);
+        let email = envelope.payload.email.map(SharedString::from);
+
+        repository_handle
+            .update(&mut cx, |repository_handle, _| {
+                repository_handle.commit(message, name.zip(email))
+            })?
+            .await??;
+        Ok(proto::Ack {})
+    }
+
+    async fn handle_show(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::GitShow>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::GitCommitDetails> {
+        let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
+        let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
+        let repository_handle =
+            Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
+
+        let commit = repository_handle
+            .update(&mut cx, |repository_handle, cx| {
+                repository_handle.show(&envelope.payload.commit, cx)
+            })?
+            .await?;
+        Ok(proto::GitCommitDetails {
+            sha: commit.sha.into(),
+            message: commit.message.into(),
+            commit_timestamp: commit.commit_timestamp,
+            committer_email: commit.committer_email.into(),
+            committer_name: commit.committer_name.into(),
+        })
+    }
+
+    async fn handle_reset(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::GitReset>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::Ack> {
+        let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
+        let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
+        let repository_handle =
+            Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
+
+        let mode = match envelope.payload.mode() {
+            git_reset::ResetMode::Soft => ResetMode::Soft,
+            git_reset::ResetMode::Mixed => ResetMode::Mixed,
+        };
+
+        repository_handle
+            .update(&mut cx, |repository_handle, _| {
+                repository_handle.reset(&envelope.payload.commit, mode)
+            })?
+            .await??;
+        Ok(proto::Ack {})
+    }
+
+    async fn handle_open_commit_message_buffer(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::OpenCommitMessageBuffer>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::OpenBufferResponse> {
+        let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
+        let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
+        let repository =
+            Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
+        let buffer = repository
+            .update(&mut cx, |repository, cx| {
+                repository.open_commit_buffer(None, this.read(cx).buffer_store.clone(), cx)
+            })?
+            .await?;
+
+        let buffer_id = buffer.read_with(&cx, |buffer, _| buffer.remote_id())?;
+        this.update(&mut cx, |this, cx| {
+            this.buffer_store.update(cx, |buffer_store, cx| {
+                buffer_store
+                    .create_buffer_for_peer(
+                        &buffer,
+                        envelope.original_sender_id.unwrap_or(envelope.sender_id),
+                        cx,
+                    )
+                    .detach_and_log_err(cx);
+            })
+        })?;
+
+        Ok(proto::OpenBufferResponse {
+            buffer_id: buffer_id.to_proto(),
+        })
+    }
+
+    fn repository_for_request(
+        this: &Entity<Self>,
+        worktree_id: WorktreeId,
+        work_directory_id: ProjectEntryId,
+        cx: &mut AsyncApp,
+    ) -> Result<Entity<Repository>> {
+        this.update(cx, |this, cx| {
+            let repository_handle = this
+                .all_repositories()
+                .into_iter()
+                .find(|repository_handle| {
+                    repository_handle.read(cx).worktree_id == worktree_id
+                        && repository_handle
+                            .read(cx)
+                            .repository_entry
+                            .work_directory_id()
+                            == work_directory_id
+                })
+                .context("missing repository handle")?;
+            anyhow::Ok(repository_handle)
+        })?
+    }
 }
 
 impl GitRepo {}
 
 impl Repository {
-    pub fn git_state(&self) -> Option<Entity<GitState>> {
-        self.git_state.upgrade()
+    pub fn git_store(&self) -> Option<Entity<GitStore>> {
+        self.git_store.upgrade()
     }
 
     fn id(&self) -> (WorktreeId, ProjectEntryId) {
         (self.worktree_id, self.repository_entry.work_directory_id())
     }
 
-    pub fn branch(&self) -> Option<Arc<str>> {
+    pub fn branch(&self) -> Option<&Branch> {
         self.repository_entry.branch()
     }
 
@@ -344,19 +592,19 @@ impl Repository {
     }
 
     pub fn activate(&self, cx: &mut Context<Self>) {
-        let Some(git_state) = self.git_state.upgrade() else {
+        let Some(git_store) = self.git_store.upgrade() else {
             return;
         };
         let entity = cx.entity();
-        git_state.update(cx, |git_state, cx| {
-            let Some(index) = git_state
+        git_store.update(cx, |git_store, cx| {
+            let Some(index) = git_store
                 .repositories
                 .iter()
                 .position(|handle| *handle == entity)
             else {
                 return;
             };
-            git_state.active_index = Some(index);
+            git_store.active_index = Some(index);
             cx.emit(GitEvent::ActiveRepositoryChanged);
         });
     }
@@ -396,7 +644,7 @@ impl Repository {
         languages: Option<Arc<LanguageRegistry>>,
         buffer_store: Entity<BufferStore>,
         cx: &mut Context<Self>,
-    ) -> Task<anyhow::Result<Entity<Buffer>>> {
+    ) -> Task<Result<Entity<Buffer>>> {
         if let Some(buffer) = self.commit_message_buffer.clone() {
             return Task::ready(Ok(buffer));
         }
@@ -444,7 +692,7 @@ impl Repository {
         language_registry: Option<Arc<LanguageRegistry>>,
         buffer_store: Entity<BufferStore>,
         cx: &mut Context<Self>,
-    ) -> Task<anyhow::Result<Entity<Buffer>>> {
+    ) -> Task<Result<Entity<Buffer>>> {
         cx.spawn(|repository, mut cx| async move {
             let buffer = buffer_store
                 .update(&mut cx, |buffer_store, cx| buffer_store.create_buffer(cx))?
@@ -464,7 +712,57 @@ impl Repository {
         })
     }
 
-    pub fn stage_entries(&self, entries: Vec<RepoPath>) -> oneshot::Receiver<anyhow::Result<()>> {
+    pub fn reset(&self, commit: &str, reset_mode: ResetMode) -> oneshot::Receiver<Result<()>> {
+        let (result_tx, result_rx) = futures::channel::oneshot::channel();
+        let commit = commit.to_string().into();
+        self.update_sender
+            .unbounded_send((
+                Message::Reset {
+                    repo: self.git_repo.clone(),
+                    commit,
+                    reset_mode,
+                },
+                result_tx,
+            ))
+            .ok();
+        result_rx
+    }
+
+    pub fn show(&self, commit: &str, cx: &Context<Self>) -> Task<Result<CommitDetails>> {
+        let commit = commit.to_string();
+        match self.git_repo.clone() {
+            GitRepo::Local(git_repository) => {
+                let commit = commit.to_string();
+                cx.background_executor()
+                    .spawn(async move { git_repository.show(&commit) })
+            }
+            GitRepo::Remote {
+                project_id,
+                client,
+                worktree_id,
+                work_directory_id,
+            } => cx.background_executor().spawn(async move {
+                let resp = client
+                    .request(proto::GitShow {
+                        project_id: project_id.0,
+                        worktree_id: worktree_id.to_proto(),
+                        work_directory_id: work_directory_id.to_proto(),
+                        commit,
+                    })
+                    .await?;
+
+                Ok(CommitDetails {
+                    sha: resp.sha.into(),
+                    message: resp.message.into(),
+                    commit_timestamp: resp.commit_timestamp,
+                    committer_email: resp.committer_email.into(),
+                    committer_name: resp.committer_name.into(),
+                })
+            }),
+        }
+    }
+
+    pub fn stage_entries(&self, entries: Vec<RepoPath>) -> oneshot::Receiver<Result<()>> {
         let (result_tx, result_rx) = futures::channel::oneshot::channel();
         if entries.is_empty() {
             result_tx.send(Ok(())).ok();
@@ -476,7 +774,7 @@ impl Repository {
         result_rx
     }
 
-    pub fn unstage_entries(&self, entries: Vec<RepoPath>) -> oneshot::Receiver<anyhow::Result<()>> {
+    pub fn unstage_entries(&self, entries: Vec<RepoPath>) -> oneshot::Receiver<Result<()>> {
         let (result_tx, result_rx) = futures::channel::oneshot::channel();
         if entries.is_empty() {
             result_tx.send(Ok(())).ok();
@@ -488,7 +786,7 @@ impl Repository {
         result_rx
     }
 
-    pub fn stage_all(&self) -> oneshot::Receiver<anyhow::Result<()>> {
+    pub fn stage_all(&self) -> oneshot::Receiver<Result<()>> {
         let to_stage = self
             .repository_entry
             .status()
@@ -498,7 +796,7 @@ impl Repository {
         self.stage_entries(to_stage)
     }
 
-    pub fn unstage_all(&self) -> oneshot::Receiver<anyhow::Result<()>> {
+    pub fn unstage_all(&self) -> oneshot::Receiver<Result<()>> {
         let to_unstage = self
             .repository_entry
             .status()
@@ -530,7 +828,7 @@ impl Repository {
         &self,
         message: SharedString,
         name_and_email: Option<(SharedString, SharedString)>,
-    ) -> oneshot::Receiver<anyhow::Result<()>> {
+    ) -> oneshot::Receiver<Result<()>> {
         let (result_tx, result_rx) = futures::channel::oneshot::channel();
         self.update_sender
             .unbounded_send((

crates/project/src/project.rs 🔗

@@ -27,7 +27,7 @@ use git::Repository;
 pub mod search_history;
 mod yarn;
 
-use crate::git::GitState;
+use crate::git::GitStore;
 use anyhow::{anyhow, Context as _, Result};
 use buffer_store::{BufferStore, BufferStoreEvent};
 use client::{
@@ -161,7 +161,7 @@ pub struct Project {
     fs: Arc<dyn Fs>,
     ssh_client: Option<Entity<SshRemoteClient>>,
     client_state: ProjectClientState,
-    git_state: Entity<GitState>,
+    git_store: Entity<GitStore>,
     collaborators: HashMap<proto::PeerId, Collaborator>,
     client_subscriptions: Vec<client::Subscription>,
     worktree_store: Entity<WorktreeStore>,
@@ -610,15 +610,10 @@ impl Project {
         client.add_entity_request_handler(Self::handle_open_new_buffer);
         client.add_entity_message_handler(Self::handle_create_buffer_for_peer);
 
-        client.add_entity_request_handler(Self::handle_stage);
-        client.add_entity_request_handler(Self::handle_unstage);
-        client.add_entity_request_handler(Self::handle_commit);
-        client.add_entity_request_handler(Self::handle_set_index_text);
-        client.add_entity_request_handler(Self::handle_open_commit_message_buffer);
-
         WorktreeStore::init(&client);
         BufferStore::init(&client);
         LspStore::init(&client);
+        GitStore::init(&client);
         SettingsObserver::init(&client);
         TaskStore::init(Some(&client));
         ToolchainStore::init(&client);
@@ -705,7 +700,8 @@ impl Project {
                 )
             });
 
-            let git_state = cx.new(|cx| GitState::new(&worktree_store, None, None, cx));
+            let git_store =
+                cx.new(|cx| GitStore::new(&worktree_store, buffer_store.clone(), None, None, cx));
 
             cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach();
 
@@ -718,7 +714,7 @@ impl Project {
                 lsp_store,
                 join_project_response_message_id: 0,
                 client_state: ProjectClientState::Local,
-                git_state,
+                git_store,
                 client_subscriptions: Vec::new(),
                 _subscriptions: vec![cx.on_release(Self::release)],
                 active_entry: None,
@@ -825,9 +821,10 @@ impl Project {
             });
             cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach();
 
-            let git_state = cx.new(|cx| {
-                GitState::new(
+            let git_store = cx.new(|cx| {
+                GitStore::new(
                     &worktree_store,
+                    buffer_store.clone(),
                     Some(ssh_proto.clone()),
                     Some(ProjectId(SSH_PROJECT_ID)),
                     cx,
@@ -846,7 +843,7 @@ impl Project {
                 lsp_store,
                 join_project_response_message_id: 0,
                 client_state: ProjectClientState::Local,
-                git_state,
+                git_store,
                 client_subscriptions: Vec::new(),
                 _subscriptions: vec![
                     cx.on_release(Self::release),
@@ -896,6 +893,7 @@ impl Project {
             ssh.subscribe_to_entity(SSH_PROJECT_ID, &this.worktree_store);
             ssh.subscribe_to_entity(SSH_PROJECT_ID, &this.lsp_store);
             ssh.subscribe_to_entity(SSH_PROJECT_ID, &this.settings_observer);
+            ssh.subscribe_to_entity(SSH_PROJECT_ID, &this.git_store);
 
             ssh_proto.add_entity_message_handler(Self::handle_create_buffer_for_peer);
             ssh_proto.add_entity_message_handler(Self::handle_update_worktree);
@@ -909,6 +907,7 @@ impl Project {
             SettingsObserver::init(&ssh_proto);
             TaskStore::init(Some(&ssh_proto));
             ToolchainStore::init(&ssh_proto);
+            GitStore::init(&ssh_proto);
 
             this
         })
@@ -1030,9 +1029,10 @@ impl Project {
             SettingsObserver::new_remote(worktree_store.clone(), task_store.clone(), cx)
         })?;
 
-        let git_state = cx.new(|cx| {
-            GitState::new(
+        let git_store = cx.new(|cx| {
+            GitStore::new(
                 &worktree_store,
+                buffer_store.clone(),
                 Some(client.clone().into()),
                 Some(ProjectId(remote_id)),
                 cx,
@@ -1089,7 +1089,7 @@ impl Project {
                     remote_id,
                     replica_id,
                 },
-                git_state,
+                git_store,
                 buffers_needing_diff: Default::default(),
                 git_diff_debouncer: DebouncedDelay::new(),
                 terminals: Terminals {
@@ -1675,6 +1675,9 @@ impl Project {
             self.client
                 .subscribe_to_entity(project_id)?
                 .set_entity(&self.settings_observer, &mut cx.to_async()),
+            self.client
+                .subscribe_to_entity(project_id)?
+                .set_entity(&self.git_store, &mut cx.to_async()),
         ]);
 
         self.buffer_store.update(cx, |buffer_store, cx| {
@@ -4038,142 +4041,6 @@ impl Project {
         Project::respond_to_open_buffer_request(this, buffer, peer_id, &mut cx)
     }
 
-    async fn handle_stage(
-        this: Entity<Self>,
-        envelope: TypedEnvelope<proto::Stage>,
-        mut cx: AsyncApp,
-    ) -> Result<proto::Ack> {
-        let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
-        let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
-        let repository_handle =
-            Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
-
-        let entries = envelope
-            .payload
-            .paths
-            .into_iter()
-            .map(PathBuf::from)
-            .map(RepoPath::new)
-            .collect();
-
-        repository_handle
-            .update(&mut cx, |repository_handle, _| {
-                repository_handle.stage_entries(entries)
-            })?
-            .await??;
-        Ok(proto::Ack {})
-    }
-
-    async fn handle_unstage(
-        this: Entity<Self>,
-        envelope: TypedEnvelope<proto::Unstage>,
-        mut cx: AsyncApp,
-    ) -> Result<proto::Ack> {
-        let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
-        let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
-        let repository_handle =
-            Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
-
-        let entries = envelope
-            .payload
-            .paths
-            .into_iter()
-            .map(PathBuf::from)
-            .map(RepoPath::new)
-            .collect();
-
-        repository_handle
-            .update(&mut cx, |repository_handle, _| {
-                repository_handle.unstage_entries(entries)
-            })?
-            .await??;
-        Ok(proto::Ack {})
-    }
-
-    async fn handle_commit(
-        this: Entity<Self>,
-        envelope: TypedEnvelope<proto::Commit>,
-        mut cx: AsyncApp,
-    ) -> Result<proto::Ack> {
-        let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
-        let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
-        let repository_handle =
-            Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
-
-        let message = SharedString::from(envelope.payload.message);
-        let name = envelope.payload.name.map(SharedString::from);
-        let email = envelope.payload.email.map(SharedString::from);
-        repository_handle
-            .update(&mut cx, |repository_handle, _| {
-                repository_handle.commit(message, name.zip(email))
-            })?
-            .await??;
-        Ok(proto::Ack {})
-    }
-
-    async fn handle_set_index_text(
-        this: Entity<Self>,
-        envelope: TypedEnvelope<proto::SetIndexText>,
-        mut cx: AsyncApp,
-    ) -> Result<proto::Ack> {
-        let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
-        let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
-        let repository_handle =
-            Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
-
-        repository_handle
-            .update(&mut cx, |repository_handle, _| {
-                repository_handle.set_index_text(
-                    &RepoPath::from_str(&envelope.payload.path),
-                    envelope.payload.text,
-                )
-            })?
-            .await??;
-        Ok(proto::Ack {})
-    }
-
-    async fn handle_open_commit_message_buffer(
-        this: Entity<Self>,
-        envelope: TypedEnvelope<proto::OpenCommitMessageBuffer>,
-        mut cx: AsyncApp,
-    ) -> Result<proto::OpenBufferResponse> {
-        let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
-        let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
-        let repository_handle =
-            Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
-        let buffer = repository_handle
-            .update(&mut cx, |repository_handle, cx| {
-                repository_handle.open_commit_buffer(None, this.read(cx).buffer_store.clone(), cx)
-            })?
-            .await?;
-
-        let peer_id = envelope.original_sender_id()?;
-        Project::respond_to_open_buffer_request(this, buffer, peer_id, &mut cx)
-    }
-
-    fn repository_for_request(
-        this: &Entity<Self>,
-        worktree_id: WorktreeId,
-        work_directory_id: ProjectEntryId,
-        cx: &mut AsyncApp,
-    ) -> Result<Entity<Repository>> {
-        this.update(cx, |project, cx| {
-            let repository_handle = project
-                .git_state()
-                .read(cx)
-                .all_repositories()
-                .into_iter()
-                .find(|repository_handle| {
-                    let repository_handle = repository_handle.read(cx);
-                    repository_handle.worktree_id == worktree_id
-                        && repository_handle.repository_entry.work_directory_id()
-                            == work_directory_id
-                })
-                .context("missing repository handle")?;
-            anyhow::Ok(repository_handle)
-        })?
-    }
-
     fn respond_to_open_buffer_request(
         this: Entity<Self>,
         buffer: Entity<Buffer>,
@@ -4365,16 +4232,16 @@ impl Project {
         &self.buffer_store
     }
 
-    pub fn git_state(&self) -> &Entity<GitState> {
-        &self.git_state
+    pub fn git_store(&self) -> &Entity<GitStore> {
+        &self.git_store
     }
 
     pub fn active_repository(&self, cx: &App) -> Option<Entity<Repository>> {
-        self.git_state.read(cx).active_repository()
+        self.git_store.read(cx).active_repository()
     }
 
     pub fn all_repositories(&self, cx: &App) -> Vec<Entity<Repository>> {
-        self.git_state.read(cx).all_repositories()
+        self.git_store.read(cx).all_repositories()
     }
 
     pub fn repository_and_path_for_buffer_id(
@@ -4386,7 +4253,7 @@ impl Project {
             .buffer_for_id(buffer_id, cx)?
             .read(cx)
             .project_path(cx)?;
-        self.git_state
+        self.git_store
             .read(cx)
             .all_repositories()
             .into_iter()

crates/project/src/worktree_store.rs 🔗

@@ -12,6 +12,7 @@ use futures::{
     future::{BoxFuture, Shared},
     FutureExt, SinkExt,
 };
+use git::repository::Branch;
 use gpui::{App, AsyncApp, Context, Entity, EntityId, EventEmitter, Task, WeakEntity};
 use postage::oneshot;
 use rpc::{
@@ -24,7 +25,10 @@ use smol::{
 };
 use text::ReplicaId;
 use util::{paths::SanitizedPath, ResultExt};
-use worktree::{Entry, ProjectEntryId, UpdatedEntriesSet, Worktree, WorktreeId, WorktreeSettings};
+use worktree::{
+    branch_to_proto, Entry, ProjectEntryId, UpdatedEntriesSet, Worktree, WorktreeId,
+    WorktreeSettings,
+};
 
 use crate::{search::SearchQuery, ProjectPath};
 
@@ -133,11 +137,12 @@ impl WorktreeStore {
             .find(|worktree| worktree.read(cx).id() == id)
     }
 
-    pub fn current_branch(&self, repository: ProjectPath, cx: &App) -> Option<Arc<str>> {
+    pub fn current_branch(&self, repository: ProjectPath, cx: &App) -> Option<Branch> {
         self.worktree_for_id(repository.worktree_id, cx)?
             .read(cx)
             .git_entry(repository.path)?
             .branch()
+            .cloned()
     }
 
     pub fn worktree_for_entry(
@@ -938,9 +943,24 @@ impl WorktreeStore {
                         .map(|proto_branch| git::repository::Branch {
                             is_head: proto_branch.is_head,
                             name: proto_branch.name.into(),
-                            unix_timestamp: proto_branch
-                                .unix_timestamp
-                                .map(|timestamp| timestamp as i64),
+                            upstream: proto_branch.upstream.map(|upstream| {
+                                git::repository::Upstream {
+                                    ref_name: upstream.ref_name.into(),
+                                    tracking: upstream.tracking.map(|tracking| {
+                                        git::repository::UpstreamTracking {
+                                            ahead: tracking.ahead as u32,
+                                            behind: tracking.behind as u32,
+                                        }
+                                    }),
+                                }
+                            }),
+                            most_recent_commit: proto_branch.most_recent_commit.map(|commit| {
+                                git::repository::CommitSummary {
+                                    sha: commit.sha.into(),
+                                    subject: commit.subject.into(),
+                                    commit_timestamp: commit.commit_timestamp,
+                                }
+                            }),
                         })
                         .collect();
 
@@ -1126,14 +1146,7 @@ impl WorktreeStore {
             .await?;
 
         Ok(proto::GitBranchesResponse {
-            branches: branches
-                .into_iter()
-                .map(|branch| proto::Branch {
-                    is_head: branch.is_head,
-                    name: branch.name.to_string(),
-                    unix_timestamp: branch.unix_timestamp.map(|timestamp| timestamp as u64),
-                })
-                .collect(),
+            branches: branches.iter().map(branch_to_proto).collect(),
         })
     }
 

crates/proto/proto/zed.proto 🔗

@@ -316,6 +316,9 @@ message Envelope {
 
         OpenUncommittedDiff open_uncommitted_diff = 297;
         OpenUncommittedDiffResponse open_uncommitted_diff_response = 298;
+        GitShow git_show = 300;
+        GitReset git_reset = 301;
+        GitCommitDetails git_commit_details = 302;
 
         SetIndexText set_index_text = 299; // current max
     }
@@ -1800,12 +1803,14 @@ message Entry {
 
 message RepositoryEntry {
     uint64 work_directory_id = 1;
-    optional string branch = 2;
+    optional string branch = 2; // deprecated
+    optional Branch branch_summary = 6;
     repeated StatusEntry updated_statuses = 3;
     repeated string removed_statuses = 4;
     repeated string current_merge_conflicts = 5;
 }
 
+
 message StatusEntry {
     string repo_path = 1;
     // Can be removed once collab's min version is >=0.171.0.
@@ -2615,10 +2620,26 @@ message ActiveToolchainResponse {
     optional Toolchain toolchain = 1;
 }
 
+message CommitSummary {
+    string sha = 1;
+    string subject = 2;
+    int64 commit_timestamp = 3;
+}
+
 message Branch {
     bool is_head = 1;
     string name = 2;
     optional uint64 unix_timestamp = 3;
+    optional GitUpstream upstream = 4;
+    optional CommitSummary most_recent_commit = 5;
+}
+message GitUpstream {
+    string ref_name = 1;
+    optional UpstreamTracking tracking = 2;
+}
+message UpstreamTracking {
+    uint64 ahead = 1;
+    uint64 behind = 2;
 }
 
 message GitBranches {
@@ -2639,6 +2660,33 @@ message UpdateGitBranch {
 message GetPanicFiles {
 }
 
+message GitShow {
+    uint64 project_id = 1;
+    uint64 worktree_id = 2;
+    uint64 work_directory_id = 3;
+    string commit = 4;
+}
+
+message GitCommitDetails {
+    string sha = 1;
+    string message = 2;
+    int64 commit_timestamp = 3;
+    string committer_email = 4;
+    string committer_name = 5;
+}
+
+message GitReset {
+    uint64 project_id = 1;
+    uint64 worktree_id = 2;
+    uint64 work_directory_id = 3;
+    string commit = 4;
+    ResetMode mode = 5;
+    enum ResetMode {
+        SOFT = 0;
+        MIXED = 1;
+    }
+}
+
 message GetPanicFilesResponse {
     repeated string file_contents = 2;
 }

crates/proto/src/proto.rs 🔗

@@ -440,6 +440,9 @@ messages!(
     (SyncExtensionsResponse, Background),
     (InstallExtension, Background),
     (RegisterBufferWithLanguageServers, Background),
+    (GitReset, Background),
+    (GitShow, Background),
+    (GitCommitDetails, Background),
     (SetIndexText, Background),
 );
 
@@ -574,6 +577,8 @@ request_messages!(
     (SyncExtensions, SyncExtensionsResponse),
     (InstallExtension, Ack),
     (RegisterBufferWithLanguageServers, Ack),
+    (GitShow, GitCommitDetails),
+    (GitReset, Ack),
     (SetIndexText, Ack),
 );
 
@@ -667,6 +672,8 @@ entity_messages!(
     GetPathMetadata,
     CancelLanguageServerWork,
     RegisterBufferWithLanguageServers,
+    GitShow,
+    GitReset,
     SetIndexText,
 );
 

crates/remote_server/src/headless_project.rs 🔗

@@ -1,22 +1,20 @@
 use ::proto::{FromProto, ToProto};
-use anyhow::{anyhow, Context as _, Result};
+use anyhow::{anyhow, Result};
 use extension::ExtensionHostProxy;
 use extension_host::headless_host::HeadlessExtensionStore;
 use fs::Fs;
-use git::repository::RepoPath;
-use gpui::{App, AppContext as _, AsyncApp, Context, Entity, PromptLevel, SharedString};
+use gpui::{App, AppContext as _, AsyncApp, Context, Entity, PromptLevel};
 use http_client::HttpClient;
 use language::{proto::serialize_operation, Buffer, BufferEvent, LanguageRegistry};
 use node_runtime::NodeRuntime;
 use project::{
     buffer_store::{BufferStore, BufferStoreEvent},
-    git::{GitState, Repository},
+    git::GitStore,
     project_settings::SettingsObserver,
     search::SearchQuery,
     task_store::TaskStore,
     worktree_store::WorktreeStore,
-    LspStore, LspStoreEvent, PrettierStore, ProjectEntryId, ProjectPath, ToolchainStore,
-    WorktreeId,
+    LspStore, LspStoreEvent, PrettierStore, ProjectPath, ToolchainStore, WorktreeId,
 };
 use remote::ssh_session::ChannelClient;
 use rpc::{
@@ -44,7 +42,7 @@ pub struct HeadlessProject {
     pub next_entry_id: Arc<AtomicUsize>,
     pub languages: Arc<LanguageRegistry>,
     pub extensions: Entity<HeadlessExtensionStore>,
-    pub git_state: Entity<GitState>,
+    pub git_store: Entity<GitStore>,
 }
 
 pub struct HeadlessAppState {
@@ -83,13 +81,14 @@ impl HeadlessProject {
             store
         });
 
-        let git_state = cx.new(|cx| GitState::new(&worktree_store, None, None, cx));
-
         let buffer_store = cx.new(|cx| {
             let mut buffer_store = BufferStore::local(worktree_store.clone(), cx);
             buffer_store.shared(SSH_PROJECT_ID, session.clone().into(), cx);
             buffer_store
         });
+
+        let git_store =
+            cx.new(|cx| GitStore::new(&worktree_store, buffer_store.clone(), None, None, cx));
         let prettier_store = cx.new(|cx| {
             PrettierStore::new(
                 node_runtime.clone(),
@@ -180,6 +179,7 @@ impl HeadlessProject {
         session.subscribe_to_entity(SSH_PROJECT_ID, &task_store);
         session.subscribe_to_entity(SSH_PROJECT_ID, &toolchain_store);
         session.subscribe_to_entity(SSH_PROJECT_ID, &settings_observer);
+        session.subscribe_to_entity(SSH_PROJECT_ID, &git_store);
 
         client.add_request_handler(cx.weak_entity(), Self::handle_list_remote_directory);
         client.add_request_handler(cx.weak_entity(), Self::handle_get_path_metadata);
@@ -197,12 +197,6 @@ impl HeadlessProject {
         client.add_entity_request_handler(BufferStore::handle_update_buffer);
         client.add_entity_message_handler(BufferStore::handle_close_buffer);
 
-        client.add_entity_request_handler(Self::handle_stage);
-        client.add_entity_request_handler(Self::handle_unstage);
-        client.add_entity_request_handler(Self::handle_commit);
-        client.add_entity_request_handler(Self::handle_set_index_text);
-        client.add_entity_request_handler(Self::handle_open_commit_message_buffer);
-
         client.add_request_handler(
             extensions.clone().downgrade(),
             HeadlessExtensionStore::handle_sync_extensions,
@@ -218,6 +212,7 @@ impl HeadlessProject {
         LspStore::init(&client);
         TaskStore::init(Some(&client));
         ToolchainStore::init(&client);
+        GitStore::init(&client);
 
         HeadlessProject {
             session: client,
@@ -230,7 +225,7 @@ impl HeadlessProject {
             next_entry_id: Default::default(),
             languages,
             extensions,
-            git_state,
+            git_store,
         }
     }
 
@@ -616,157 +611,6 @@ impl HeadlessProject {
         log::debug!("Received ping from client");
         Ok(proto::Ack {})
     }
-
-    async fn handle_stage(
-        this: Entity<Self>,
-        envelope: TypedEnvelope<proto::Stage>,
-        mut cx: AsyncApp,
-    ) -> Result<proto::Ack> {
-        let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
-        let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
-        let repository_handle =
-            Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
-
-        let entries = envelope
-            .payload
-            .paths
-            .into_iter()
-            .map(PathBuf::from)
-            .map(RepoPath::new)
-            .collect();
-
-        repository_handle
-            .update(&mut cx, |repository_handle, _| {
-                repository_handle.stage_entries(entries)
-            })?
-            .await??;
-        Ok(proto::Ack {})
-    }
-
-    async fn handle_unstage(
-        this: Entity<Self>,
-        envelope: TypedEnvelope<proto::Unstage>,
-        mut cx: AsyncApp,
-    ) -> Result<proto::Ack> {
-        let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
-        let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
-        let repository_handle =
-            Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
-
-        let entries = envelope
-            .payload
-            .paths
-            .into_iter()
-            .map(PathBuf::from)
-            .map(RepoPath::new)
-            .collect();
-
-        repository_handle
-            .update(&mut cx, |repository_handle, _| {
-                repository_handle.unstage_entries(entries)
-            })?
-            .await??;
-
-        Ok(proto::Ack {})
-    }
-
-    async fn handle_commit(
-        this: Entity<Self>,
-        envelope: TypedEnvelope<proto::Commit>,
-        mut cx: AsyncApp,
-    ) -> Result<proto::Ack> {
-        let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
-        let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
-        let repository_handle =
-            Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
-
-        let message = SharedString::from(envelope.payload.message);
-        let name = envelope.payload.name.map(SharedString::from);
-        let email = envelope.payload.email.map(SharedString::from);
-
-        repository_handle
-            .update(&mut cx, |repository_handle, _| {
-                repository_handle.commit(message, name.zip(email))
-            })?
-            .await??;
-        Ok(proto::Ack {})
-    }
-
-    async fn handle_set_index_text(
-        this: Entity<Self>,
-        envelope: TypedEnvelope<proto::SetIndexText>,
-        mut cx: AsyncApp,
-    ) -> Result<proto::Ack> {
-        let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
-        let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
-        let repository =
-            Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
-        repository
-            .update(&mut cx, |repository, _| {
-                repository.set_index_text(
-                    &RepoPath::from(envelope.payload.path.as_str()),
-                    envelope.payload.text,
-                )
-            })?
-            .await??;
-        Ok(proto::Ack {})
-    }
-
-    async fn handle_open_commit_message_buffer(
-        this: Entity<Self>,
-        envelope: TypedEnvelope<proto::OpenCommitMessageBuffer>,
-        mut cx: AsyncApp,
-    ) -> Result<proto::OpenBufferResponse> {
-        let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
-        let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
-        let repository =
-            Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
-        let buffer = repository
-            .update(&mut cx, |repository, cx| {
-                repository.open_commit_buffer(None, this.read(cx).buffer_store.clone(), cx)
-            })?
-            .await?;
-
-        let buffer_id = buffer.read_with(&cx, |buffer, _| buffer.remote_id())?;
-        this.update(&mut cx, |headless_project, cx| {
-            headless_project
-                .buffer_store
-                .update(cx, |buffer_store, cx| {
-                    buffer_store
-                        .create_buffer_for_peer(&buffer, SSH_PEER_ID, cx)
-                        .detach_and_log_err(cx);
-                })
-        })?;
-
-        Ok(proto::OpenBufferResponse {
-            buffer_id: buffer_id.to_proto(),
-        })
-    }
-
-    fn repository_for_request(
-        this: &Entity<Self>,
-        worktree_id: WorktreeId,
-        work_directory_id: ProjectEntryId,
-        cx: &mut AsyncApp,
-    ) -> Result<Entity<Repository>> {
-        this.update(cx, |project, cx| {
-            let repository_handle = project
-                .git_state
-                .read(cx)
-                .all_repositories()
-                .into_iter()
-                .find(|repository_handle| {
-                    repository_handle.read(cx).worktree_id == worktree_id
-                        && repository_handle
-                            .read(cx)
-                            .repository_entry
-                            .work_directory_id()
-                            == work_directory_id
-                })
-                .context("missing repository handle")?;
-            anyhow::Ok(repository_handle)
-        })?
-    }
 }
 
 fn prompt_to_proto(

crates/remote_server/src/remote_editing_tests.rs 🔗

@@ -1364,7 +1364,7 @@ async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestA
         })
     });
 
-    assert_eq!(server_branch.as_ref(), branches[2]);
+    assert_eq!(server_branch.name, branches[2]);
 
     // Also try creating a new branch
     cx.update(|cx| {
@@ -1387,7 +1387,7 @@ async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestA
         })
     });
 
-    assert_eq!(server_branch.as_ref(), "totally-new-branch");
+    assert_eq!(server_branch.name, "totally-new-branch");
 }
 
 pub async fn init_test(

crates/time_format/src/time_format.rs 🔗

@@ -24,19 +24,21 @@ pub fn format_localized_timestamp(
 ) -> String {
     let timestamp_local = timestamp.to_offset(timezone);
     let reference_local = reference.to_offset(timezone);
+    format_local_timestamp(timestamp_local, reference_local, format)
+}
 
+/// Formats a timestamp, which respects the user's date and time preferences/custom format.
+pub fn format_local_timestamp(
+    timestamp: OffsetDateTime,
+    reference: OffsetDateTime,
+    format: TimestampFormat,
+) -> String {
     match format {
-        TimestampFormat::Absolute => {
-            format_absolute_timestamp(timestamp_local, reference_local, false)
-        }
-        TimestampFormat::EnhancedAbsolute => {
-            format_absolute_timestamp(timestamp_local, reference_local, true)
-        }
-        TimestampFormat::MediumAbsolute => {
-            format_absolute_timestamp_medium(timestamp_local, reference_local)
-        }
-        TimestampFormat::Relative => format_relative_time(timestamp_local, reference_local)
-            .unwrap_or_else(|| format_relative_date(timestamp_local, reference_local)),
+        TimestampFormat::Absolute => format_absolute_timestamp(timestamp, reference, false),
+        TimestampFormat::EnhancedAbsolute => format_absolute_timestamp(timestamp, reference, true),
+        TimestampFormat::MediumAbsolute => format_absolute_timestamp_medium(timestamp, reference),
+        TimestampFormat::Relative => format_relative_time(timestamp, reference)
+            .unwrap_or_else(|| format_relative_date(timestamp, reference)),
     }
 }
 

crates/title_bar/src/title_bar.rs 🔗

@@ -521,6 +521,7 @@ impl TitleBar {
         let branch_name = entry
             .as_ref()
             .and_then(|entry| entry.branch())
+            .map(|branch| branch.name.clone())
             .map(|branch| util::truncate_and_trailoff(&branch, MAX_BRANCH_NAME_LENGTH))?;
         Some(
             Button::new("project_branch_trigger", branch_name)

crates/worktree/src/worktree.rs 🔗

@@ -19,7 +19,7 @@ use futures::{
 };
 use fuzzy::CharBag;
 use git::{
-    repository::{GitRepository, RepoPath},
+    repository::{Branch, GitRepository, RepoPath},
     status::{
         FileStatus, GitSummary, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode,
     },
@@ -201,7 +201,7 @@ pub struct RepositoryEntry {
     pub(crate) statuses_by_path: SumTree<StatusEntry>,
     work_directory_id: ProjectEntryId,
     pub work_directory: WorkDirectory,
-    pub(crate) branch: Option<Arc<str>>,
+    pub(crate) branch: Option<Branch>,
     pub current_merge_conflicts: TreeSet<RepoPath>,
 }
 
@@ -214,8 +214,8 @@ impl Deref for RepositoryEntry {
 }
 
 impl RepositoryEntry {
-    pub fn branch(&self) -> Option<Arc<str>> {
-        self.branch.clone()
+    pub fn branch(&self) -> Option<&Branch> {
+        self.branch.as_ref()
     }
 
     pub fn work_directory_id(&self) -> ProjectEntryId {
@@ -243,7 +243,8 @@ impl RepositoryEntry {
     pub fn initial_update(&self) -> proto::RepositoryEntry {
         proto::RepositoryEntry {
             work_directory_id: self.work_directory_id.to_proto(),
-            branch: self.branch.as_ref().map(|branch| branch.to_string()),
+            branch: self.branch.as_ref().map(|branch| branch.name.to_string()),
+            branch_summary: self.branch.as_ref().map(branch_to_proto),
             updated_statuses: self
                 .statuses_by_path
                 .iter()
@@ -302,7 +303,8 @@ impl RepositoryEntry {
 
         proto::RepositoryEntry {
             work_directory_id: self.work_directory_id.to_proto(),
-            branch: self.branch.as_ref().map(|branch| branch.to_string()),
+            branch: self.branch.as_ref().map(|branch| branch.name.to_string()),
+            branch_summary: self.branch.as_ref().map(branch_to_proto),
             updated_statuses,
             removed_statuses,
             current_merge_conflicts: self
@@ -314,6 +316,61 @@ impl RepositoryEntry {
     }
 }
 
+pub fn branch_to_proto(branch: &git::repository::Branch) -> proto::Branch {
+    proto::Branch {
+        is_head: branch.is_head,
+        name: branch.name.to_string(),
+        unix_timestamp: branch
+            .most_recent_commit
+            .as_ref()
+            .map(|commit| commit.commit_timestamp as u64),
+        upstream: branch.upstream.as_ref().map(|upstream| proto::GitUpstream {
+            ref_name: upstream.ref_name.to_string(),
+            tracking: upstream
+                .tracking
+                .as_ref()
+                .map(|upstream| proto::UpstreamTracking {
+                    ahead: upstream.ahead as u64,
+                    behind: upstream.behind as u64,
+                }),
+        }),
+        most_recent_commit: branch
+            .most_recent_commit
+            .as_ref()
+            .map(|commit| proto::CommitSummary {
+                sha: commit.sha.to_string(),
+                subject: commit.subject.to_string(),
+                commit_timestamp: commit.commit_timestamp,
+            }),
+    }
+}
+
+pub fn proto_to_branch(proto: &proto::Branch) -> git::repository::Branch {
+    git::repository::Branch {
+        is_head: proto.is_head,
+        name: proto.name.clone().into(),
+        upstream: proto
+            .upstream
+            .as_ref()
+            .map(|upstream| git::repository::Upstream {
+                ref_name: upstream.ref_name.to_string().into(),
+                tracking: upstream.tracking.as_ref().map(|tracking| {
+                    git::repository::UpstreamTracking {
+                        ahead: tracking.ahead as u32,
+                        behind: tracking.behind as u32,
+                    }
+                }),
+            }),
+        most_recent_commit: proto.most_recent_commit.as_ref().map(|commit| {
+            git::repository::CommitSummary {
+                sha: commit.sha.to_string().into(),
+                subject: commit.subject.to_string().into(),
+                commit_timestamp: commit.commit_timestamp,
+            }
+        }),
+    }
+}
+
 /// This path corresponds to the 'content path' of a repository in relation
 /// to Zed's project root.
 /// In the majority of the cases, this is the folder that contains the .git folder.
@@ -2625,7 +2682,7 @@ impl Snapshot {
 
                     self.repositories
                         .update(&PathKey(work_dir_entry.path.clone()), &(), |repo| {
-                            repo.branch = repository.branch.map(Into::into);
+                            repo.branch = repository.branch_summary.as_ref().map(proto_to_branch);
                             repo.statuses_by_path.edit(edits, &());
                             repo.current_merge_conflicts = conflicted_paths
                         });
@@ -2647,7 +2704,7 @@ impl Snapshot {
                             work_directory: WorkDirectory::InProject {
                                 relative_path: work_dir_entry.path.clone(),
                             },
-                            branch: repository.branch.map(Into::into),
+                            branch: repository.branch_summary.as_ref().map(proto_to_branch),
                             statuses_by_path: statuses,
                             current_merge_conflicts: conflicted_paths,
                         },
@@ -3449,7 +3506,7 @@ impl BackgroundScannerState {
             RepositoryEntry {
                 work_directory_id: work_dir_id,
                 work_directory: work_directory.clone(),
-                branch: repository.branch_name().map(Into::into),
+                branch: None,
                 statuses_by_path: Default::default(),
                 current_merge_conflicts: Default::default(),
             },
@@ -4198,6 +4255,7 @@ impl BackgroundScanner {
         // the git repository in an ancestor directory. Find any gitignore files
         // in ancestor directories.
         let root_abs_path = self.state.lock().snapshot.abs_path.clone();
+        let mut containing_git_repository = None;
         for (index, ancestor) in root_abs_path.as_path().ancestors().enumerate() {
             if index != 0 {
                 if let Ok(ignore) =
@@ -4227,7 +4285,7 @@ impl BackgroundScanner {
                     {
                         // We associate the external git repo with our root folder and
                         // also mark where in the git repo the root folder is located.
-                        self.state.lock().insert_git_repository_for_path(
+                        let local_repository = self.state.lock().insert_git_repository_for_path(
                             WorkDirectory::AboveProject {
                                 absolute_path: ancestor.into(),
                                 location_in_repo: root_abs_path
@@ -4236,10 +4294,14 @@ impl BackgroundScanner {
                                     .unwrap()
                                     .into(),
                             },
-                            ancestor_dot_git.into(),
+                            ancestor_dot_git.clone().into(),
                             self.fs.as_ref(),
                             self.watcher.as_ref(),
                         );
+
+                        if local_repository.is_some() {
+                            containing_git_repository = Some(ancestor_dot_git)
+                        }
                     };
                 }
 
@@ -4285,6 +4347,9 @@ impl BackgroundScanner {
             self.process_events(paths.into_iter().map(Into::into).collect())
                 .await;
         }
+        if let Some(abs_path) = containing_git_repository {
+            self.process_events(vec![abs_path]).await;
+        }
 
         // Continue processing events until the worktree is dropped.
         self.phase = BackgroundScannerPhase::Events;
@@ -4703,7 +4768,7 @@ impl BackgroundScanner {
                 );
 
                 if let Some(local_repo) = repo {
-                    self.update_git_statuses(UpdateGitStatusesJob {
+                    self.update_git_repository(UpdateGitRepoJob {
                         local_repository: local_repo,
                     });
                 }
@@ -5255,15 +5320,6 @@ impl BackgroundScanner {
                         if local_repository.git_dir_scan_id == scan_id {
                             continue;
                         }
-                        let Some(work_dir) = state
-                            .snapshot
-                            .entry_for_id(local_repository.work_directory_id)
-                            .map(|entry| entry.path.clone())
-                        else {
-                            continue;
-                        };
-
-                        let branch = local_repository.repo_ptr.branch_name();
                         local_repository.repo_ptr.reload_index();
 
                         state.snapshot.git_repositories.update(
@@ -5273,17 +5329,12 @@ impl BackgroundScanner {
                                 entry.status_scan_id = scan_id;
                             },
                         );
-                        state.snapshot.snapshot.repositories.update(
-                            &PathKey(work_dir.clone()),
-                            &(),
-                            |entry| entry.branch = branch.map(Into::into),
-                        );
 
                         local_repository
                     }
                 };
 
-                repo_updates.push(UpdateGitStatusesJob { local_repository });
+                repo_updates.push(UpdateGitRepoJob { local_repository });
             }
 
             // Remove any git repositories whose .git entry no longer exists.
@@ -5319,7 +5370,7 @@ impl BackgroundScanner {
             .scoped(|scope| {
                 scope.spawn(async {
                     for repo_update in repo_updates {
-                        self.update_git_statuses(repo_update);
+                        self.update_git_repository(repo_update);
                     }
                     updates_done_tx.blocking_send(()).ok();
                 });
@@ -5343,22 +5394,37 @@ impl BackgroundScanner {
             .await;
     }
 
-    /// Update the git statuses for a given batch of entries.
-    fn update_git_statuses(&self, job: UpdateGitStatusesJob) {
+    fn update_branches(&self, job: &UpdateGitRepoJob) -> Result<()> {
+        let branches = job.local_repository.repo().branches()?;
+        let snapshot = self.state.lock().snapshot.snapshot.clone();
+
+        let mut repository = snapshot
+            .repository(job.local_repository.work_directory.path_key())
+            .context("Missing repository")?;
+
+        repository.branch = branches.into_iter().find(|branch| branch.is_head);
+
+        let mut state = self.state.lock();
+        state
+            .snapshot
+            .repositories
+            .insert_or_replace(repository, &());
+
+        Ok(())
+    }
+
+    fn update_statuses(&self, job: &UpdateGitRepoJob) -> Result<()> {
         log::trace!(
             "updating git statuses for repo {:?}",
             job.local_repository.work_directory.display_name()
         );
         let t0 = Instant::now();
 
-        let Some(statuses) = job
+        let statuses = job
             .local_repository
             .repo()
-            .status(&[git::WORK_DIRECTORY_REPO_PATH.clone()])
-            .log_err()
-        else {
-            return;
-        };
+            .status(&[git::WORK_DIRECTORY_REPO_PATH.clone()])?;
+
         log::trace!(
             "computed git statuses for repo {:?} in {:?}",
             job.local_repository.work_directory.display_name(),
@@ -5369,13 +5435,9 @@ impl BackgroundScanner {
         let mut changed_paths = Vec::new();
         let snapshot = self.state.lock().snapshot.snapshot.clone();
 
-        let Some(mut repository) =
-            snapshot.repository(job.local_repository.work_directory.path_key())
-        else {
-            // happens when a folder is deleted
-            log::debug!("Got an UpdateGitStatusesJob for a repository that isn't in the snapshot");
-            return;
-        };
+        let mut repository = snapshot
+            .repository(job.local_repository.work_directory.path_key())
+            .context("Got an UpdateGitStatusesJob for a repository that isn't in the snapshot")?;
 
         let merge_head_shas = job.local_repository.repo().merge_head_shas();
         if merge_head_shas != job.local_repository.current_merge_head_shas {
@@ -5403,6 +5465,7 @@ impl BackgroundScanner {
         }
 
         repository.statuses_by_path = new_entries_by_path;
+
         let mut state = self.state.lock();
         state
             .snapshot
@@ -5428,6 +5491,13 @@ impl BackgroundScanner {
             job.local_repository.work_directory.display_name(),
             t0.elapsed(),
         );
+        Ok(())
+    }
+
+    /// Update the git statuses for a given batch of entries.
+    fn update_git_repository(&self, job: UpdateGitRepoJob) {
+        self.update_branches(&job).log_err();
+        self.update_statuses(&job).log_err();
     }
 
     fn build_change_set(
@@ -5637,7 +5707,7 @@ struct UpdateIgnoreStatusJob {
     scan_queue: Sender<ScanJob>,
 }
 
-struct UpdateGitStatusesJob {
+struct UpdateGitRepoJob {
     local_repository: LocalRepositoryEntry,
 }