From bbbe7239afa0eecaa3682395ebaac024428df91c Mon Sep 17 00:00:00 2001 From: Bob Mannino Date: Wed, 25 Feb 2026 17:32:22 +0000 Subject: [PATCH] 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. Screenshot 2026-02-18 at 21 25 02 Release Notes: - Added git diff stats to git panel entries --------- Co-authored-by: Danilo Leal Co-authored-by: Anthony Eid --- 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 + .../remote_server/src/remote_editing_tests.rs | 124 +++++++++++++++++ .../settings_content/src/settings_content.rs | 5 + crates/settings_ui/src/page_data.rs | 20 ++- 13 files changed, 706 insertions(+), 4 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 9dc077fb29458089e68061d5bd121ed9770108d7..f9f4fb417e4b0664170f9f6958966018bb48bc63 100644 --- a/assets/settings/default.json +++ b/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. diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 6513d5a33b6eb96f7a69c5f96530f1d44a71c3ec..12cd67cdae1a250d07468047617c8cc7a52737fa 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/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>> { + 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 = 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> { let executor = self.executor.clone(); let fs = self.fs.clone(); diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index ab445a1cd830a726491fab1fc6209686e80960b1..1925e84735a8020c7e1896f3cf2e7ee20ae3f712 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -898,6 +898,11 @@ pub trait GitRepository: Send + Sync { /// Run git diff fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result>; + fn diff_stat( + &self, + diff: DiffType, + ) -> BoxFuture<'_, Result>>; + /// Creates a checkpoint for the repository. fn checkpoint(&self) -> BoxFuture<'static, Result>; @@ -2031,6 +2036,57 @@ impl GitRepository for RealGitRepository { .boxed() } + fn diff_stat( + &self, + diff: DiffType, + ) -> BoxFuture<'_, Result>> { + 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, diff --git a/crates/git/src/status.rs b/crates/git/src/status.rs index be8b0a3a588b40638a895d610cc4b5735d4ae51d..b20919e7ecf4748d0035a003ed5eadebae752dd7 100644 --- a/crates/git/src/status.rs +++ b/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 { + 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::() else { + continue; + }; + let Ok(deleted) = deleted_str.parse::() 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 diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index b86fa0196ae786db7a981427628295c4f9d81061..1c8c09d7fdeaa51b8780f29aa13028355864924f 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/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>, bulk_staging: Option, stash_entries: GitStash, + diff_stats: HashMap, + 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::(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) { + 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) diff --git a/crates/git_ui/src/git_panel_settings.rs b/crates/git_ui/src/git_panel_settings.rs index 6b5334e55544b465864fe3afb780c4673bb5961e..2a7480de355a6190494211d823e4aa440d191371 100644 --- a/crates/git_ui/src/git_panel_settings.rs +++ b/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(), } } } diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 1272a689b908413fff5eef71cf5e0e98fd72429b..67bc21c94227e8f53356ef1b7f626ff922326d29 100644 --- a/crates/project/src/git_store.rs +++ b/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, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + 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, request: TypedEnvelope, @@ -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>, + > { + 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, diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 994d319913c6d84c2e639ccd78bade4547449a7a..facaf43fd5ae3e7ff655f0b4006dc1661d503e10 100644 --- a/crates/proto/proto/git.proto +++ b/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; diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 387ed25027230c7e407983ff5c098ae24bbecc9e..fa55e1f27330fb5fee88fb19296f607b1bf9f3a6 100644 --- a/crates/proto/proto/zed.proto +++ b/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; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index dd0a77beb29345021563b21bafd261d02b87e1ab..3d30551557000c305a82b328828b566c9d78f75e 100644 --- a/crates/proto/src/proto.rs +++ b/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, diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index f15382b67557fa9a9b0eda2a9d4438aa33c7cff3..b3fe30a472c2d098bc6fb9b2a4e276be8867e94b 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/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()); diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 788917b5ebb0fc0f4ba29e29fc95b0da148c6f0f..8c4845e05cbf16d0aacb089a5d16dcdb0ff6d7c7 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -619,6 +619,11 @@ pub struct GitPanelSettingsContent { /// /// Default: false pub tree_view: Option, + + /// Whether to show the addition/deletion change count next to each file in the Git panel. + /// + /// Default: false + pub diff_stats: Option, } #[derive( diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 40bc8705920e5d30d69a22cf8967a8931181db9b..5b3f5480148c30ef89bcae29b23986eac29808d9 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/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.",