git: Add diff stats in git_panel (#49519)

Bob Mannino , Danilo Leal , and Anthony Eid created

This PR adds the small UI change of `git diff --numstat` to the git
panel so you can see the number of additions/deletions per file. There
is an option in the settings UI for this under `git_panel`.`diff_stats`.
This option is set to `false` by default.

<!-- initial version <img width="1648" height="977" alt="Screenshot
2026-02-18 at 18 42 47"
src="https://github.com/user-attachments/assets/b8b7f07c-9c73-4d06-9734-8f1cf30ce296"
/> -->

<img width="1648" height="977" alt="Screenshot 2026-02-18 at 21 25 02"
src="https://github.com/user-attachments/assets/73257854-6168-4d12-84f8-27c9e0abe89f"
/>


Release Notes:

- Added git diff stats to git panel entries

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
Co-authored-by: Anthony Eid <anthony@zed.dev>

Change summary

assets/settings/default.json                     |   4 
crates/fs/src/fake_git_repo.rs                   | 130 ++++++++++++++++++
crates/git/src/repository.rs                     |  56 +++++++
crates/git/src/status.rs                         | 127 +++++++++++++++++
crates/git_ui/src/git_panel.rs                   | 114 +++++++++++++++
crates/git_ui/src/git_panel_settings.rs          |   2 
crates/project/src/git_store.rs                  |  97 +++++++++++++
crates/proto/proto/git.proto                     |  23 +++
crates/proto/proto/zed.proto                     |   4 
crates/proto/src/proto.rs                        |   4 
crates/remote_server/src/remote_editing_tests.rs | 124 +++++++++++++++++
crates/settings_content/src/settings_content.rs  |   5 
crates/settings_ui/src/page_data.rs              |  20 ++
13 files changed, 706 insertions(+), 4 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -915,6 +915,10 @@
       // Default: inherits editor scrollbar settings
       // "show": null
     },
+    // Whether to show the addition/deletion change count next to each file in the Git panel.
+    //
+    // Default: false
+    "diff_stats": false,
   },
   "message_editor": {
     // Whether to automatically replace emoji shortcodes with emoji characters.

crates/fs/src/fake_git_repo.rs 🔗

@@ -768,6 +768,136 @@ impl GitRepository for FakeGitRepository {
         unimplemented!()
     }
 
+    fn diff_stat(
+        &self,
+        diff_type: git::repository::DiffType,
+    ) -> BoxFuture<'_, Result<HashMap<RepoPath, git::status::DiffStat>>> {
+        fn count_lines(s: &str) -> u32 {
+            if s.is_empty() {
+                0
+            } else {
+                s.lines().count() as u32
+            }
+        }
+
+        match diff_type {
+            git::repository::DiffType::HeadToIndex => self
+                .with_state_async(false, |state| {
+                    let mut result = HashMap::default();
+                    let all_paths: HashSet<&RepoPath> = state
+                        .head_contents
+                        .keys()
+                        .chain(state.index_contents.keys())
+                        .collect();
+                    for path in all_paths {
+                        let head = state.head_contents.get(path);
+                        let index = state.index_contents.get(path);
+                        match (head, index) {
+                            (Some(old), Some(new)) if old != new => {
+                                result.insert(
+                                    path.clone(),
+                                    git::status::DiffStat {
+                                        added: count_lines(new),
+                                        deleted: count_lines(old),
+                                    },
+                                );
+                            }
+                            (Some(old), None) => {
+                                result.insert(
+                                    path.clone(),
+                                    git::status::DiffStat {
+                                        added: 0,
+                                        deleted: count_lines(old),
+                                    },
+                                );
+                            }
+                            (None, Some(new)) => {
+                                result.insert(
+                                    path.clone(),
+                                    git::status::DiffStat {
+                                        added: count_lines(new),
+                                        deleted: 0,
+                                    },
+                                );
+                            }
+                            _ => {}
+                        }
+                    }
+                    Ok(result)
+                })
+                .boxed(),
+            git::repository::DiffType::HeadToWorktree => {
+                let workdir_path = self.dot_git_path.parent().unwrap().to_path_buf();
+                let worktree_files: HashMap<RepoPath, String> = self
+                    .fs
+                    .files()
+                    .iter()
+                    .filter_map(|path| {
+                        let repo_path = path.strip_prefix(&workdir_path).ok()?;
+                        if repo_path.starts_with(".git") {
+                            return None;
+                        }
+                        let content = self
+                            .fs
+                            .read_file_sync(path)
+                            .ok()
+                            .and_then(|bytes| String::from_utf8(bytes).ok())?;
+                        let repo_path = RelPath::new(repo_path, PathStyle::local()).ok()?;
+                        Some((RepoPath::from_rel_path(&repo_path), content))
+                    })
+                    .collect();
+
+                self.with_state_async(false, move |state| {
+                    let mut result = HashMap::default();
+                    let all_paths: HashSet<&RepoPath> = state
+                        .head_contents
+                        .keys()
+                        .chain(worktree_files.keys())
+                        .collect();
+                    for path in all_paths {
+                        let head = state.head_contents.get(path);
+                        let worktree = worktree_files.get(path);
+                        match (head, worktree) {
+                            (Some(old), Some(new)) if old != new => {
+                                result.insert(
+                                    path.clone(),
+                                    git::status::DiffStat {
+                                        added: count_lines(new),
+                                        deleted: count_lines(old),
+                                    },
+                                );
+                            }
+                            (Some(old), None) => {
+                                result.insert(
+                                    path.clone(),
+                                    git::status::DiffStat {
+                                        added: 0,
+                                        deleted: count_lines(old),
+                                    },
+                                );
+                            }
+                            (None, Some(new)) => {
+                                result.insert(
+                                    path.clone(),
+                                    git::status::DiffStat {
+                                        added: count_lines(new),
+                                        deleted: 0,
+                                    },
+                                );
+                            }
+                            _ => {}
+                        }
+                    }
+                    Ok(result)
+                })
+                .boxed()
+            }
+            git::repository::DiffType::MergeBase { .. } => {
+                future::ready(Ok(HashMap::default())).boxed()
+            }
+        }
+    }
+
     fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
         let executor = self.executor.clone();
         let fs = self.fs.clone();

crates/git/src/repository.rs 🔗

@@ -898,6 +898,11 @@ pub trait GitRepository: Send + Sync {
     /// Run git diff
     fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>>;
 
+    fn diff_stat(
+        &self,
+        diff: DiffType,
+    ) -> BoxFuture<'_, Result<HashMap<RepoPath, crate::status::DiffStat>>>;
+
     /// Creates a checkpoint for the repository.
     fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>>;
 
@@ -2031,6 +2036,57 @@ impl GitRepository for RealGitRepository {
             .boxed()
     }
 
+    fn diff_stat(
+        &self,
+        diff: DiffType,
+    ) -> BoxFuture<'_, Result<HashMap<RepoPath, crate::status::DiffStat>>> {
+        let working_directory = self.working_directory();
+        let git_binary_path = self.any_git_binary_path.clone();
+        self.executor
+            .spawn(async move {
+                let working_directory = working_directory?;
+                let output = match diff {
+                    DiffType::HeadToIndex => {
+                        new_command(&git_binary_path)
+                            .current_dir(&working_directory)
+                            .args(["diff", "--numstat", "--staged"])
+                            .output()
+                            .await?
+                    }
+                    DiffType::HeadToWorktree => {
+                        new_command(&git_binary_path)
+                            .current_dir(&working_directory)
+                            .args(["diff", "--numstat"])
+                            .output()
+                            .await?
+                    }
+                    DiffType::MergeBase { base_ref } => {
+                        new_command(&git_binary_path)
+                            .current_dir(&working_directory)
+                            .args([
+                                "diff",
+                                "--numstat",
+                                "--merge-base",
+                                base_ref.as_ref(),
+                                "HEAD",
+                            ])
+                            .output()
+                            .await?
+                    }
+                };
+
+                anyhow::ensure!(
+                    output.status.success(),
+                    "Failed to run git diff --numstat:\n{}",
+                    String::from_utf8_lossy(&output.stderr)
+                );
+                Ok(crate::status::parse_numstat(&String::from_utf8_lossy(
+                    &output.stdout,
+                )))
+            })
+            .boxed()
+    }
+
     fn stage_paths(
         &self,
         paths: Vec<RepoPath>,

crates/git/src/status.rs 🔗

@@ -580,6 +580,45 @@ impl FromStr for TreeDiff {
     }
 }
 
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub struct DiffStat {
+    pub added: u32,
+    pub deleted: u32,
+}
+
+/// Parses the output of `git diff --numstat` where output looks like:
+///
+/// ```text
+/// 24   12   dir/file.txt
+/// ```
+pub fn parse_numstat(output: &str) -> HashMap<RepoPath, DiffStat> {
+    let mut stats = HashMap::default();
+    for line in output.lines() {
+        let line = line.trim();
+        if line.is_empty() {
+            continue;
+        }
+        let mut parts = line.splitn(3, '\t');
+        let (Some(added_str), Some(deleted_str), Some(path_str)) =
+            (parts.next(), parts.next(), parts.next())
+        else {
+            continue;
+        };
+        let Ok(added) = added_str.parse::<u32>() else {
+            continue;
+        };
+        let Ok(deleted) = deleted_str.parse::<u32>() else {
+            continue;
+        };
+        let Ok(path) = RepoPath::new(path_str) else {
+            continue;
+        };
+        let stat = DiffStat { added, deleted };
+        stats.insert(path, stat);
+    }
+    stats
+}
+
 #[cfg(test)]
 mod tests {
 
@@ -588,6 +627,94 @@ mod tests {
         status::{FileStatus, GitStatus, TreeDiff, TreeDiffStatus},
     };
 
+    use super::{DiffStat, parse_numstat};
+
+    #[test]
+    fn test_parse_numstat_normal() {
+        let input = "10\t5\tsrc/main.rs\n3\t1\tREADME.md\n";
+        let result = parse_numstat(input);
+        assert_eq!(result.len(), 2);
+        assert_eq!(
+            result.get(&RepoPath::new("src/main.rs").unwrap()),
+            Some(&DiffStat {
+                added: 10,
+                deleted: 5
+            })
+        );
+        assert_eq!(
+            result.get(&RepoPath::new("README.md").unwrap()),
+            Some(&DiffStat {
+                added: 3,
+                deleted: 1
+            })
+        );
+    }
+
+    #[test]
+    fn test_parse_numstat_binary_files_skipped() {
+        // git diff --numstat outputs "-\t-\tpath" for binary files
+        let input = "-\t-\timage.png\n5\t2\tsrc/lib.rs\n";
+        let result = parse_numstat(input);
+        assert_eq!(result.len(), 1);
+        assert!(!result.contains_key(&RepoPath::new("image.png").unwrap()));
+        assert_eq!(
+            result.get(&RepoPath::new("src/lib.rs").unwrap()),
+            Some(&DiffStat {
+                added: 5,
+                deleted: 2
+            })
+        );
+    }
+
+    #[test]
+    fn test_parse_numstat_empty_input() {
+        assert!(parse_numstat("").is_empty());
+        assert!(parse_numstat("\n\n").is_empty());
+        assert!(parse_numstat("   \n  \n").is_empty());
+    }
+
+    #[test]
+    fn test_parse_numstat_malformed_lines_skipped() {
+        let input = "not_a_number\t5\tfile.rs\n10\t5\tvalid.rs\n";
+        let result = parse_numstat(input);
+        assert_eq!(result.len(), 1);
+        assert_eq!(
+            result.get(&RepoPath::new("valid.rs").unwrap()),
+            Some(&DiffStat {
+                added: 10,
+                deleted: 5
+            })
+        );
+    }
+
+    #[test]
+    fn test_parse_numstat_incomplete_lines_skipped() {
+        // Lines with fewer than 3 tab-separated fields are skipped
+        let input = "10\t5\n7\t3\tok.rs\n";
+        let result = parse_numstat(input);
+        assert_eq!(result.len(), 1);
+        assert_eq!(
+            result.get(&RepoPath::new("ok.rs").unwrap()),
+            Some(&DiffStat {
+                added: 7,
+                deleted: 3
+            })
+        );
+    }
+
+    #[test]
+    fn test_parse_numstat_zero_stats() {
+        let input = "0\t0\tunchanged_but_present.rs\n";
+        let result = parse_numstat(input);
+        assert_eq!(
+            result.get(&RepoPath::new("unchanged_but_present.rs").unwrap()),
+            Some(&DiffStat {
+                added: 0,
+                deleted: 0
+            })
+        );
+    }
+
     #[test]
     fn test_duplicate_untracked_entries() {
         // Regression test for ZED-2XA: git can produce duplicate untracked entries

crates/git_ui/src/git_panel.rs 🔗

@@ -28,7 +28,7 @@ use git::repository::{
     UpstreamTrackingStatus, get_git_committer,
 };
 use git::stash::GitStash;
-use git::status::StageStatus;
+use git::status::{DiffStat, StageStatus};
 use git::{Amend, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus};
 use git::{
     ExpandCommitEditor, GitHostingProviderRegistry, RestoreTrackedFiles, StageAll, StashAll,
@@ -41,7 +41,7 @@ use gpui::{
     WeakEntity, actions, anchored, deferred, point, size, uniform_list,
 };
 use itertools::Itertools;
-use language::{Buffer, File};
+use language::{Buffer, BufferEvent, File};
 use language_model::{
     ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role,
 };
@@ -51,6 +51,7 @@ use notifications::status_toast::{StatusToast, ToastIcon};
 use panel::{PanelHeader, panel_button, panel_filled_button, panel_icon_button};
 use project::{
     Fs, Project, ProjectPath,
+    buffer_store::BufferStoreEvent,
     git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId, pending_op},
     project_settings::{GitPathStyle, ProjectSettings},
 };
@@ -651,6 +652,8 @@ pub struct GitPanel {
     local_committer_task: Option<Task<()>>,
     bulk_staging: Option<BulkStaging>,
     stash_entries: GitStash,
+    diff_stats: HashMap<RepoPath, DiffStat>,
+    diff_stats_task: Task<()>,
     _settings_subscription: Subscription,
 }
 
@@ -711,9 +714,11 @@ impl GitPanel {
 
             let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
             let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view;
+            let mut was_diff_stats = GitPanelSettings::get_global(cx).diff_stats;
             cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
                 let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
                 let tree_view = GitPanelSettings::get_global(cx).tree_view;
+                let diff_stats = GitPanelSettings::get_global(cx).diff_stats;
                 if tree_view != was_tree_view {
                     this.view_mode = GitPanelViewMode::from_settings(cx);
                 }
@@ -721,8 +726,18 @@ impl GitPanel {
                     this.bulk_staging.take();
                     this.update_visible_entries(window, cx);
                 }
+                if diff_stats != was_diff_stats {
+                    if diff_stats {
+                        this.fetch_diff_stats(cx);
+                    } else {
+                        this.diff_stats.clear();
+                        this.diff_stats_task = Task::ready(());
+                        cx.notify();
+                    }
+                }
                 was_sort_by_path = sort_by_path;
                 was_tree_view = tree_view;
+                was_diff_stats = diff_stats;
             })
             .detach();
 
@@ -777,6 +792,33 @@ impl GitPanel {
             )
             .detach();
 
+            let buffer_store = project.read(cx).buffer_store().clone();
+
+            for buffer in project.read(cx).opened_buffers(cx) {
+                cx.subscribe(&buffer, |this, _buffer, event, cx| {
+                    if matches!(event, BufferEvent::Saved) {
+                        if GitPanelSettings::get_global(cx).diff_stats {
+                            this.fetch_diff_stats(cx);
+                        }
+                    }
+                })
+                .detach();
+            }
+
+            cx.subscribe(&buffer_store, |_this, _store, event, cx| {
+                if let BufferStoreEvent::BufferAdded(buffer) = event {
+                    cx.subscribe(buffer, |this, _buffer, event, cx| {
+                        if matches!(event, BufferEvent::Saved) {
+                            if GitPanelSettings::get_global(cx).diff_stats {
+                                this.fetch_diff_stats(cx);
+                            }
+                        }
+                    })
+                    .detach();
+                }
+            })
+            .detach();
+
             let mut this = Self {
                 active_repository,
                 commit_editor,
@@ -817,6 +859,8 @@ impl GitPanel {
                 entry_count: 0,
                 bulk_staging: None,
                 stash_entries: Default::default(),
+                diff_stats: HashMap::default(),
+                diff_stats_task: Task::ready(()),
                 _settings_subscription,
             };
 
@@ -3699,9 +3743,60 @@ impl GitPanel {
             editor.set_placeholder_text(&placeholder_text, window, cx)
         });
 
+        if GitPanelSettings::get_global(cx).diff_stats {
+            self.fetch_diff_stats(cx);
+        }
+
         cx.notify();
     }
 
+    fn fetch_diff_stats(&mut self, cx: &mut Context<Self>) {
+        let Some(repo) = self.active_repository.clone() else {
+            self.diff_stats.clear();
+            return;
+        };
+
+        let unstaged_rx = repo.update(cx, |repo, cx| repo.diff_stat(DiffType::HeadToWorktree, cx));
+        let staged_rx = repo.update(cx, |repo, cx| repo.diff_stat(DiffType::HeadToIndex, cx));
+
+        self.diff_stats_task = cx.spawn(async move |this, cx| {
+            let (unstaged_result, staged_result) =
+                futures::future::join(unstaged_rx, staged_rx).await;
+
+            let mut combined = match unstaged_result {
+                Ok(Ok(stats)) => stats,
+                Ok(Err(err)) => {
+                    log::warn!("Failed to fetch unstaged diff stats: {err:?}");
+                    HashMap::default()
+                }
+                Err(_) => HashMap::default(),
+            };
+
+            let staged = match staged_result {
+                Ok(Ok(stats)) => Some(stats),
+                Ok(Err(err)) => {
+                    log::warn!("Failed to fetch staged diff stats: {err:?}");
+                    None
+                }
+                Err(_) => None,
+            };
+
+            if let Some(staged) = staged {
+                for (path, stat) in staged {
+                    let entry = combined.entry(path).or_default();
+                    entry.added += stat.added;
+                    entry.deleted += stat.deleted;
+                }
+            }
+
+            this.update(cx, |this, cx| {
+                this.diff_stats = combined;
+                cx.notify();
+            })
+            .ok();
+        });
+    }
+
     fn header_state(&self, header_type: Section) -> ToggleState {
         let (staged_count, count) = match header_type {
             Section::New => (self.new_staged_count, self.new_count),
@@ -5113,6 +5208,8 @@ impl GitPanel {
                 }
             });
 
+        let id_for_diff_stat = id.clone();
+
         h_flex()
             .id(id)
             .h(self.list_item_height())
@@ -5129,6 +5226,19 @@ impl GitPanel {
             .hover(|s| s.bg(hover_bg))
             .active(|s| s.bg(active_bg))
             .child(name_row)
+            .when(GitPanelSettings::get_global(cx).diff_stats, |el| {
+                el.when_some(
+                    self.diff_stats.get(&entry.repo_path).copied(),
+                    move |this, stat| {
+                        let id = format!("diff-stat-{}", id_for_diff_stat);
+                        this.child(ui::DiffStat::new(
+                            id,
+                            stat.added as usize,
+                            stat.deleted as usize,
+                        ))
+                    },
+                )
+            })
             .child(
                 div()
                     .id(checkbox_wrapper_id)

crates/git_ui/src/git_panel_settings.rs 🔗

@@ -25,6 +25,7 @@ pub struct GitPanelSettings {
     pub sort_by_path: bool,
     pub collapse_untracked_diff: bool,
     pub tree_view: bool,
+    pub diff_stats: bool,
 }
 
 impl ScrollbarVisibility for GitPanelSettings {
@@ -58,6 +59,7 @@ impl Settings for GitPanelSettings {
             sort_by_path: git_panel.sort_by_path.unwrap(),
             collapse_untracked_diff: git_panel.collapse_untracked_diff.unwrap(),
             tree_view: git_panel.tree_view.unwrap(),
+            diff_stats: git_panel.diff_stats.unwrap(),
         }
     }
 }

crates/project/src/git_store.rs 🔗

@@ -529,6 +529,7 @@ impl GitStore {
         client.add_entity_request_handler(Self::handle_askpass);
         client.add_entity_request_handler(Self::handle_check_for_pushed_commits);
         client.add_entity_request_handler(Self::handle_git_diff);
+        client.add_entity_request_handler(Self::handle_git_diff_stat);
         client.add_entity_request_handler(Self::handle_tree_diff);
         client.add_entity_request_handler(Self::handle_get_blob_content);
         client.add_entity_request_handler(Self::handle_open_unstaged_diff);
@@ -2684,6 +2685,45 @@ impl GitStore {
         Ok(proto::GitDiffResponse { diff })
     }
 
+    async fn handle_git_diff_stat(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::GitDiffStat>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::GitDiffStatResponse> {
+        let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
+        let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
+        let diff_type = match envelope.payload.diff_type() {
+            proto::git_diff_stat::DiffType::HeadToIndex => DiffType::HeadToIndex,
+            proto::git_diff_stat::DiffType::HeadToWorktree => DiffType::HeadToWorktree,
+            proto::git_diff_stat::DiffType::MergeBase => {
+                let base_ref = envelope
+                    .payload
+                    .merge_base_ref
+                    .ok_or_else(|| anyhow!("merge_base_ref is required for MergeBase diff type"))?;
+                DiffType::MergeBase {
+                    base_ref: base_ref.into(),
+                }
+            }
+        };
+
+        let stats = repository_handle
+            .update(&mut cx, |repository_handle, cx| {
+                repository_handle.diff_stat(diff_type, cx)
+            })
+            .await??;
+
+        let entries = stats
+            .into_iter()
+            .map(|(path, stat)| proto::GitDiffStatEntry {
+                path: path.to_proto(),
+                added: stat.added,
+                deleted: stat.deleted,
+            })
+            .collect();
+
+        Ok(proto::GitDiffStatResponse { entries })
+    }
+
     async fn handle_tree_diff(
         this: Entity<Self>,
         request: TypedEnvelope<proto::GetTreeDiff>,
@@ -5690,6 +5730,63 @@ impl Repository {
         })
     }
 
+    /// Fetches per-line diff statistics (additions/deletions) via `git diff --numstat`.
+    pub fn diff_stat(
+        &mut self,
+        diff_type: DiffType,
+        _cx: &App,
+    ) -> oneshot::Receiver<
+        Result<collections::HashMap<git::repository::RepoPath, git::status::DiffStat>>,
+    > {
+        let id = self.id;
+        self.send_job(None, move |repo, _cx| async move {
+            match repo {
+                RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
+                    backend.diff_stat(diff_type).await
+                }
+                RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
+                    let (proto_diff_type, merge_base_ref) = match &diff_type {
+                        DiffType::HeadToIndex => {
+                            (proto::git_diff_stat::DiffType::HeadToIndex.into(), None)
+                        }
+                        DiffType::HeadToWorktree => {
+                            (proto::git_diff_stat::DiffType::HeadToWorktree.into(), None)
+                        }
+                        DiffType::MergeBase { base_ref } => (
+                            proto::git_diff_stat::DiffType::MergeBase.into(),
+                            Some(base_ref.to_string()),
+                        ),
+                    };
+                    let response = client
+                        .request(proto::GitDiffStat {
+                            project_id: project_id.0,
+                            repository_id: id.to_proto(),
+                            diff_type: proto_diff_type,
+                            merge_base_ref,
+                        })
+                        .await?;
+
+                    let stats = response
+                        .entries
+                        .into_iter()
+                        .filter_map(|entry| {
+                            let path = RepoPath::from_proto(&entry.path).log_err()?;
+                            Some((
+                                path,
+                                git::status::DiffStat {
+                                    added: entry.added,
+                                    deleted: entry.deleted,
+                                },
+                            ))
+                        })
+                        .collect();
+
+                    Ok(stats)
+                }
+            }
+        })
+    }
+
     pub fn create_branch(
         &mut self,
         branch_name: String,

crates/proto/proto/git.proto 🔗

@@ -229,6 +229,29 @@ message GitDiffResponse {
     string diff = 1;
 }
 
+message GitDiffStat {
+    uint64 project_id = 1;
+    uint64 repository_id = 2;
+    DiffType diff_type = 3;
+    optional string merge_base_ref = 4;
+
+    enum DiffType {
+        HEAD_TO_WORKTREE = 0;
+        HEAD_TO_INDEX = 1;
+        MERGE_BASE = 2;
+    }
+}
+
+message GitDiffStatResponse {
+    repeated GitDiffStatEntry entries = 1;
+}
+
+message GitDiffStatEntry {
+    string path = 1;
+    uint32 added = 2;
+    uint32 deleted = 3;
+}
+
 message GitInit {
     uint64 project_id = 1;
     string abs_path = 2;

crates/proto/proto/zed.proto 🔗

@@ -476,7 +476,9 @@ message Envelope {
         
         SpawnKernel spawn_kernel = 426;
         SpawnKernelResponse spawn_kernel_response = 427;
-        KillKernel kill_kernel = 428; // current max
+        KillKernel kill_kernel = 428;
+        GitDiffStat git_diff_stat = 429;
+        GitDiffStatResponse git_diff_stat_response = 430; // current max
     }
 
     reserved 87 to 88;

crates/proto/src/proto.rs 🔗

@@ -322,6 +322,8 @@ messages!(
     (CheckForPushedCommitsResponse, Background),
     (GitDiff, Background),
     (GitDiffResponse, Background),
+    (GitDiffStat, Background),
+    (GitDiffStatResponse, Background),
     (GitInit, Background),
     (GetDebugAdapterBinary, Background),
     (DebugAdapterBinary, Background),
@@ -539,6 +541,7 @@ request_messages!(
     (GitRenameBranch, Ack),
     (CheckForPushedCommits, CheckForPushedCommitsResponse),
     (GitDiff, GitDiffResponse),
+    (GitDiffStat, GitDiffStatResponse),
     (GitInit, Ack),
     (ToggleBreakpoint, Ack),
     (GetDebugAdapterBinary, DebugAdapterBinary),
@@ -727,6 +730,7 @@ entity_messages!(
     GitRemoveRemote,
     CheckForPushedCommits,
     GitDiff,
+    GitDiffStat,
     GitInit,
     BreakpointsForFile,
     ToggleBreakpoint,

crates/remote_server/src/remote_editing_tests.rs 🔗

@@ -8,6 +8,7 @@ use agent::{
 use client::{Client, UserStore};
 use clock::FakeSystemClock;
 use collections::{HashMap, HashSet};
+use git::repository::DiffType;
 use language_model::{LanguageModelToolResultContent, fake_provider::FakeLanguageModel};
 use prompt_store::ProjectContext;
 
@@ -1919,6 +1920,129 @@ async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestA
     assert_eq!(server_branch.name(), "totally-new-branch");
 }
 
+#[gpui::test]
+async fn test_remote_git_diff_stat(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
+    let fs = FakeFs::new(server_cx.executor());
+    fs.insert_tree(
+        path!("/code"),
+        json!({
+            "project1": {
+                ".git": {},
+                "src": {
+                    "lib.rs": "line1\nline2\nline3\n",
+                    "new_file.rs": "added1\nadded2\n",
+                },
+                "README.md": "# project 1",
+            },
+        }),
+    )
+    .await;
+
+    let dot_git = Path::new(path!("/code/project1/.git"));
+
+    // HEAD: lib.rs (2 lines), deleted.rs (1 line)
+    fs.set_head_for_repo(
+        dot_git,
+        &[
+            ("src/lib.rs", "line1\nold_line2\n".into()),
+            ("src/deleted.rs", "was_here\n".into()),
+        ],
+        "deadbeef",
+    );
+    // Index: lib.rs modified (4 lines), staged_only.rs new (2 lines)
+    fs.set_index_for_repo(
+        dot_git,
+        &[
+            ("src/lib.rs", "line1\nold_line2\nline3\nline4\n".into()),
+            ("src/staged_only.rs", "x\ny\n".into()),
+        ],
+    );
+
+    let (project, _headless) = init_test(&fs, cx, server_cx).await;
+    let (_worktree, _) = project
+        .update(cx, |project, cx| {
+            project.find_or_create_worktree(path!("/code/project1"), true, cx)
+        })
+        .await
+        .unwrap();
+    cx.run_until_parked();
+
+    let repo_path = |s: &str| git::repository::RepoPath::new(s).unwrap();
+
+    let repository = project.update(cx, |project, cx| project.active_repository(cx).unwrap());
+
+    // --- HeadToWorktree ---
+    let stats = cx
+        .update(|cx| repository.update(cx, |repo, cx| repo.diff_stat(DiffType::HeadToWorktree, cx)))
+        .await
+        .unwrap()
+        .unwrap();
+
+    // src/lib.rs: worktree 3 lines vs HEAD 2 lines
+    let stat = stats.get(&repo_path("src/lib.rs")).expect("src/lib.rs");
+    assert_eq!((stat.added, stat.deleted), (3, 2));
+
+    // src/new_file.rs: only in worktree (2 lines)
+    let stat = stats
+        .get(&repo_path("src/new_file.rs"))
+        .expect("src/new_file.rs");
+    assert_eq!((stat.added, stat.deleted), (2, 0));
+
+    // src/deleted.rs: only in HEAD (1 line)
+    let stat = stats
+        .get(&repo_path("src/deleted.rs"))
+        .expect("src/deleted.rs");
+    assert_eq!((stat.added, stat.deleted), (0, 1));
+
+    // README.md: only in worktree (1 line)
+    let stat = stats.get(&repo_path("README.md")).expect("README.md");
+    assert_eq!((stat.added, stat.deleted), (1, 0));
+
+    // --- HeadToIndex ---
+    let stats = cx
+        .update(|cx| repository.update(cx, |repo, cx| repo.diff_stat(DiffType::HeadToIndex, cx)))
+        .await
+        .unwrap()
+        .unwrap();
+
+    // src/lib.rs: index 4 lines vs HEAD 2 lines
+    let stat = stats.get(&repo_path("src/lib.rs")).expect("src/lib.rs");
+    assert_eq!((stat.added, stat.deleted), (4, 2));
+
+    // src/staged_only.rs: only in index (2 lines)
+    let stat = stats
+        .get(&repo_path("src/staged_only.rs"))
+        .expect("src/staged_only.rs");
+    assert_eq!((stat.added, stat.deleted), (2, 0));
+
+    // src/deleted.rs: in HEAD but not in index
+    let stat = stats
+        .get(&repo_path("src/deleted.rs"))
+        .expect("src/deleted.rs");
+    assert_eq!((stat.added, stat.deleted), (0, 1));
+
+    // --- MergeBase (not implemented in FakeGitRepository) ---
+    let stats = cx
+        .update(|cx| {
+            repository.update(cx, |repo, cx| {
+                repo.diff_stat(
+                    DiffType::MergeBase {
+                        base_ref: "main".into(),
+                    },
+                    cx,
+                )
+            })
+        })
+        .await
+        .unwrap()
+        .unwrap();
+
+    assert!(
+        stats.is_empty(),
+        "MergeBase diff_stat should return empty from FakeGitRepository"
+    );
+}
+
 #[gpui::test]
 async fn test_remote_agent_fs_tool_calls(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
     let fs = FakeFs::new(server_cx.executor());

crates/settings_content/src/settings_content.rs 🔗

@@ -619,6 +619,11 @@ pub struct GitPanelSettingsContent {
     ///
     /// Default: false
     pub tree_view: Option<bool>,
+
+    /// Whether to show the addition/deletion change count next to each file in the Git panel.
+    ///
+    /// Default: false
+    pub diff_stats: Option<bool>,
 }
 
 #[derive(

crates/settings_ui/src/page_data.rs 🔗

@@ -5039,7 +5039,7 @@ fn panels_page() -> SettingsPage {
         ]
     }
 
-    fn git_panel_section() -> [SettingsPageItem; 10] {
+    fn git_panel_section() -> [SettingsPageItem; 11] {
         [
             SettingsPageItem::SectionHeader("Git Panel"),
             SettingsPageItem::SettingItem(SettingItem {
@@ -5181,6 +5181,24 @@ fn panels_page() -> SettingsPage {
                 metadata: None,
                 files: USER,
             }),
+            SettingsPageItem::SettingItem(SettingItem {
+                title: "Diff Stats",
+                description: "Whether to show the addition/deletion change count next to each file in the Git panel.",
+                field: Box::new(SettingField {
+                    json_path: Some("git_panel.diff_stats"),
+                    pick: |settings_content| {
+                        settings_content.git_panel.as_ref()?.diff_stats.as_ref()
+                    },
+                    write: |settings_content, value| {
+                        settings_content
+                            .git_panel
+                            .get_or_insert_default()
+                            .diff_stats = value;
+                    },
+                }),
+                metadata: None,
+                files: USER,
+            }),
             SettingsPageItem::SettingItem(SettingItem {
                 title: "Scroll Bar",
                 description: "How and when the scrollbar should be displayed.",