Detailed changes
@@ -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.
@@ -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();
@@ -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>,
@@ -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
@@ -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)
@@ -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(),
}
}
}
@@ -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,
@@ -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;
@@ -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;
@@ -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,
@@ -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());
@@ -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(
@@ -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.",