git: Add diff stats in git_panel (#49519)

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>
This commit is contained in:
Bob Mannino 2026-02-25 17:32:22 +00:00 committed by GitHub
parent f4920f4651
commit bbbe7239af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 706 additions and 4 deletions

View file

@ -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.

View file

@ -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();

View file

@ -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>,

View file

@ -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

View file

@ -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)

View file

@ -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(),
}
}
}

View file

@ -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,

View file

@ -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;

View file

@ -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;

View file

@ -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,

View file

@ -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());

View file

@ -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(

View file

@ -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.",