diff --git a/assets/settings/default.json b/assets/settings/default.json index 17140e6cf0b..875f65650ea 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1712,6 +1712,8 @@ // 2. Show unstaged hunks hollow and staged hunks filled: // "hunk_style": "unstaged_hollow" "hunk_style": "staged_hollow", + // Whether git features diff against HEAD ("head") or the default branch ("default_branch"). + "diff_base": "head", // Should the name or path be displayed first in the git view. // "path_style": "file_name_first" or "file_path_first" "path_style": "file_name_first", diff --git a/crates/acp_thread/src/diff.rs b/crates/acp_thread/src/diff.rs index 0834d7eabba..72f310f7f31 100644 --- a/crates/acp_thread/src/diff.rs +++ b/crates/acp_thread/src/diff.rs @@ -89,7 +89,13 @@ impl Diff { let language = buffer.read(cx).language().cloned(); let language_registry = buffer.read(cx).language_registry(); let buffer_diff = cx.new(|cx| { - BufferDiff::new_unchanged(&buffer_text_snapshot, language, language_registry, cx) + BufferDiff::new_unchanged( + &buffer_text_snapshot, + language, + language_registry, + buffer_diff::DiffBaseKind::Custom, + cx, + ) }); let multibuffer = cx.new(|cx| { @@ -389,7 +395,15 @@ async fn build_buffer_diff( let buffer = cx.update(|cx| buffer.read(cx).snapshot()); let base_text = base_text_exists.then(|| old_text); - let diff = cx.new(|cx| BufferDiff::new(&buffer, language, language_registry, cx)); + let diff = cx.new(|cx| { + BufferDiff::new( + &buffer, + language, + language_registry, + buffer_diff::DiffBaseKind::Custom, + cx, + ) + }); diff.update(cx, |diff, cx| { diff.set_base_text(base_text, buffer.text, cx) }) diff --git a/crates/action_log/src/action_log.rs b/crates/action_log/src/action_log.rs index 6e2de669ded..f9588c155f1 100644 --- a/crates/action_log/src/action_log.rs +++ b/crates/action_log/src/action_log.rs @@ -159,8 +159,15 @@ impl ActionLog { let text_snapshot = buffer.read(cx).text_snapshot(); let language = buffer.read(cx).language().cloned(); let language_registry = buffer.read(cx).language_registry(); - let diff = - cx.new(|cx| BufferDiff::new(&text_snapshot, language, language_registry, cx)); + let diff = cx.new(|cx| { + BufferDiff::new( + &text_snapshot, + language, + language_registry, + buffer_diff::DiffBaseKind::Custom, + cx, + ) + }); let (diff_update_tx, diff_update_rx) = mpsc::unbounded(); let diff_base; let unreviewed_edits; diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index b73c64ff61e..aa0ffe4e70c 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -25,6 +25,23 @@ pub struct BufferDiff { diff_snapshot: Option, secondary_diff: Option>, buffer_snapshot: text::BufferSnapshot, + base_kind: DiffBaseKind, +} + +/// Where this diff's base text came from. Only diffs whose base is HEAD +/// support staging and restoring hunks; a diff against any other base (e.g. +/// the merge base with another branch) would rewrite committed work. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DiffBaseKind { + /// The buffer's committed (HEAD) content. + Head, + /// The buffer's index (staged) content. + Index, + /// An arbitrary blob, such as the merge base with another branch. + Oid, + /// Arbitrary caller-provided text, such as an agent's original text, + /// the clipboard, or another file. + Custom, } #[derive(Clone)] @@ -1553,6 +1570,7 @@ impl BufferDiff { buffer: &text::BufferSnapshot, language: Option>, language_registry: Option>, + base_kind: DiffBaseKind, cx: &mut App, ) -> Self { let base_text = cx.new(|cx| { @@ -1571,12 +1589,14 @@ impl BufferDiff { diff_snapshot: None, buffer_snapshot: buffer.clone(), secondary_diff: None, + base_kind, } } pub fn new_with_base_text_buffer( buffer: &text::BufferSnapshot, base_text_buffer: Entity, + base_kind: DiffBaseKind, _cx: &mut App, ) -> Self { BufferDiff { @@ -1585,6 +1605,7 @@ impl BufferDiff { diff_snapshot: None, buffer_snapshot: buffer.clone(), secondary_diff: None, + base_kind, } } @@ -1592,6 +1613,7 @@ impl BufferDiff { buffer: &text::BufferSnapshot, language: Option>, language_registry: Option>, + base_kind: DiffBaseKind, cx: &mut Context, ) -> Self { let base_text = buffer.text(); @@ -1622,6 +1644,7 @@ impl BufferDiff { diff_snapshot: Some(diff_snapshot), buffer_snapshot: buffer.clone(), secondary_diff: None, + base_kind, } } @@ -1631,7 +1654,7 @@ impl BufferDiff { buffer: &text::BufferSnapshot, cx: &mut Context, ) -> Self { - let mut this = BufferDiff::new(buffer, None, None, cx); + let mut this = BufferDiff::new(buffer, None, None, DiffBaseKind::Head, cx); let mut base_text = base_text.to_owned(); text::LineEnding::normalize(&mut base_text); let base_text_buffer = cx.new(|cx| { @@ -1655,6 +1678,16 @@ impl BufferDiff { self.secondary_diff = Some(diff); } + pub fn base_kind(&self) -> DiffBaseKind { + self.base_kind + } + + /// Whether hunks in this diff can be staged or restored: true only when + /// the diff's base is HEAD. + pub fn is_stageable(&self) -> bool { + self.base_kind == DiffBaseKind::Head + } + pub fn secondary_diff(&self) -> Option> { self.secondary_diff.clone() } @@ -2449,7 +2482,8 @@ mod tests { ], ); - diff = cx.update(|cx| BufferDiff::new(&buffer, None, None, cx).snapshot(cx)); + diff = cx + .update(|cx| BufferDiff::new(&buffer, None, None, DiffBaseKind::Head, cx).snapshot(cx)); assert_hunks::<&str, _>( diff.hunks_intersecting_range( Anchor::min_max_range_for_buffer(buffer.remote_id()), @@ -3154,7 +3188,8 @@ mod tests { let mut buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), buffer_text_1); - let empty_diff = cx.update(|cx| BufferDiff::new(&buffer, None, None, cx).snapshot(cx)); + let empty_diff = cx + .update(|cx| BufferDiff::new(&buffer, None, None, DiffBaseKind::Head, cx).snapshot(cx)); let diff_1 = BufferDiffSnapshot::new_sync(&buffer, base_text.clone(), cx); let DiffChanged { changed_range, @@ -4312,7 +4347,8 @@ mod tests { ); let buffer_snapshot = buffer.snapshot(); - let diff = cx.new(|cx| BufferDiff::new(&buffer_snapshot, None, None, cx)); + let diff = + cx.new(|cx| BufferDiff::new(&buffer_snapshot, None, None, DiffBaseKind::Head, cx)); diff.update(cx, |diff, cx| { diff.set_base_text(Some(Arc::from(base_text_crlf)), buffer_snapshot.clone(), cx) }) diff --git a/crates/edit_prediction_ui/src/rate_prediction_modal.rs b/crates/edit_prediction_ui/src/rate_prediction_modal.rs index 23607042c7b..6f408069120 100644 --- a/crates/edit_prediction_ui/src/rate_prediction_modal.rs +++ b/crates/edit_prediction_ui/src/rate_prediction_modal.rs @@ -605,6 +605,7 @@ impl RatePredictionsModal { &predicted_buffer_snapshot.text, predicted_buffer_snapshot.language().cloned(), predicted_buffer.read(cx).language_registry(), + buffer_diff::DiffBaseKind::Custom, cx, ) }); @@ -711,6 +712,7 @@ impl RatePredictionsModal { &expected_buffer_snapshot.text, expected_buffer_snapshot.language().cloned(), expected_buffer.read(cx).language_registry(), + buffer_diff::DiffBaseKind::Custom, cx, ) }); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 31ddc4b5c49..c42ed695529 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -113,9 +113,7 @@ pub use git::{ set_blame_renderer, }; pub(crate) use git::{DiffHunkKey, StoredReviewComment}; -use git::{ - DiffReviewDragState, DiffReviewOverlay, InlineBlamePopover, update_uncommitted_diff_for_buffer, -}; +use git::{DiffReviewDragState, DiffReviewOverlay, InlineBlamePopover}; pub(crate) use git::{DisplayDiffHunk, PhantomDiffReviewIndicator}; pub use hover_popover::hover_markdown_style; pub use inlays::Inlay; @@ -2170,20 +2168,41 @@ impl Editor { )); let git_store = project.read(cx).git_store().clone(); let project = project.clone(); - project_subscriptions.push(cx.subscribe(&git_store, move |this, _, event, cx| { - if let GitStoreEvent::RepositoryAdded = event { - this.load_diff_task = Some( - update_uncommitted_diff_for_buffer( - cx.entity(), - &project, - this.buffer.read(cx).all_buffers(), - this.buffer.clone(), - cx, - ) - .shared(), - ); - } - })); + project_subscriptions.push(cx.subscribe( + &git_store, + move |this, git_store, event, cx| { + let buffers = match event { + GitStoreEvent::RepositoryAdded | GitStoreEvent::DiffBaseChanged(None) => { + this.buffer.read(cx).all_buffers() + } + GitStoreEvent::DiffBaseChanged(Some(repo_id)) => this + .buffer + .read(cx) + .all_buffers() + .into_iter() + .filter(|buffer| { + git_store + .read(cx) + .repository_and_path_for_buffer_id( + buffer.read(cx).remote_id(), + cx, + ) + .is_some_and(|(repo, _)| repo.read(cx).id == *repo_id) + }) + .collect(), + _ => return, + }; + if buffers.is_empty() { + return; + } + let task = this.update_uncommitted_diff_for_buffer(&project, buffers, cx); + if matches!(event, GitStoreEvent::DiffBaseChanged(Some(_))) { + task.detach(); + } else { + this.load_diff_task = Some(task.shared()); + } + }, + )); } let buffer_snapshot = multi_buffer.read(cx).snapshot(cx); @@ -2222,18 +2241,7 @@ impl Editor { }; let mut code_action_providers = Vec::new(); - let mut load_uncommitted_diff = None; if let Some(project) = project.clone() { - load_uncommitted_diff = Some( - update_uncommitted_diff_for_buffer( - cx.entity(), - &project, - multi_buffer.read(cx).all_buffers(), - multi_buffer.clone(), - cx, - ) - .shared(), - ); code_action_providers.push(Rc::new(project) as Rc<_>); } @@ -2447,7 +2455,7 @@ impl Editor { serialize_selections: Task::ready(()), serialize_folds: Task::ready(()), text_style_refinement: None, - load_diff_task: load_uncommitted_diff, + load_diff_task: None, diff_hunk_delegate: None, minimap: None, change_list: ChangeList::new(), @@ -2474,6 +2482,18 @@ impl Editor { colorize_brackets_task: Task::ready(()), }; + if let Some(project) = editor.project.clone() { + editor.load_diff_task = Some( + editor + .update_uncommitted_diff_for_buffer( + &project, + multi_buffer.read(cx).all_buffers(), + cx, + ) + .shared(), + ); + } + if is_minimap { return editor; } @@ -9631,16 +9651,10 @@ impl Editor { self.refresh_document_highlights(cx); let buffer_id = buffer.read(cx).remote_id(); if self.buffer.read(cx).diff_for(buffer_id).is_none() - && let Some(project) = &self.project + && let Some(project) = self.project.clone() { - update_uncommitted_diff_for_buffer( - cx.entity(), - project, - [buffer.clone()], - self.buffer.clone(), - cx, - ) - .detach(); + self.update_uncommitted_diff_for_buffer(&project, [buffer.clone()], cx) + .detach(); } self.register_visible_buffers(cx); self.update_lsp_data(Some(buffer_id), window, cx); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 56905d38172..8716c32f632 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -16,7 +16,7 @@ use crate::{ }, }; use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus, DiffHunkStatus, DiffHunkStatusKind}; -use collections::HashMap; +use collections::{HashMap, HashSet}; use fs::Fs as _; use futures::{StreamExt, channel::oneshot}; use gpui::{ @@ -26691,6 +26691,91 @@ async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_merge_base_diff_hunks_are_read_only(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "file.rs": "worktree\n", + }), + ) + .await; + fs.set_head_and_index_for_repo( + std::path::Path::new(path!("/project/.git")), + &[("file.rs", "head\n".to_string())], + ); + fs.set_merge_base_content_for_repo( + std::path::Path::new(path!("/project/.git")), + &[("file.rs", "base\n".to_string())], + ); + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/file.rs"), cx) + }) + .await + .unwrap(); + let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id()); + let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), multi_buffer, window, cx) + }); + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.git.get_or_insert_default().diff_base = + Some(settings::GitDiffBaseSetting::DefaultBranch); + }); + }); + }); + cx.run_until_parked(); + + editor.read_with(cx, |editor, cx| { + let diff = editor + .buffer() + .read(cx) + .diff_for(buffer_id) + .expect("buffer should have a display diff"); + assert!(!diff.read(cx).is_stageable()); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_all(&SelectAll, window, cx); + editor.git_restore(&Default::default(), window, cx); + editor.toggle_staged_selected_diff_hunks(&Default::default(), window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + editor.read_with(cx, |editor, cx| editor.text(cx)), + "worktree\n" + ); + let index_contents = fs + .with_git_state( + std::path::Path::new(path!("/project/.git")), + false, + |state| state.index_contents.clone(), + ) + .unwrap(); + assert_eq!( + index_contents, + [( + ::git::repository::repo_path("file.rs"), + "head\n".to_string(), + )] + .into_iter() + .collect::>() + ); +} + #[gpui::test] async fn test_toggle_selected_diff_hunks(executor: BackgroundExecutor, cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs index ab5b035a05a..aa36ce648df 100644 --- a/crates/editor/src/git.rs +++ b/crates/editor/src/git.rs @@ -113,6 +113,7 @@ impl DiffHunkDelegate for UncommittedDiffHunkDelegate { let Some(buffer) = hunks.buffer else { continue; }; + let ranges = hunks .hunks .into_iter() @@ -183,6 +184,15 @@ impl DiffHunkDelegate for RestoreOnlyDiffHunkDelegate { ) { } + fn restore( + &self, + _hunks: Vec, + _editor: &mut Editor, + _window: &mut Window, + _cx: &mut Context, + ) { + } + fn render_hunk_controls( &self, _row: u32, @@ -485,11 +495,9 @@ impl Editor { if let Some(project) = self.project.clone() { self.load_diff_task = Some( - update_uncommitted_diff_for_buffer( - cx.entity(), + self.update_uncommitted_diff_for_buffer( &project, self.buffer.read(cx).all_buffers(), - self.buffer.clone(), cx, ) .shared(), @@ -1765,7 +1773,10 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - let hunks = self.resolve_diff_hunks(hunks, cx); + let mut hunks = self.resolve_diff_hunks(hunks, cx); + if self.diff_hunk_delegate.is_none() { + hunks.retain(|hunks| hunks.diff.read(cx).is_stageable()); + } if hunks.is_empty() { return; } @@ -1780,7 +1791,10 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - let hunks = self.resolve_diff_hunks(hunks, cx); + let mut hunks = self.resolve_diff_hunks(hunks, cx); + if self.diff_hunk_delegate.is_none() { + hunks.retain(|hunks| hunks.diff.read(cx).is_stageable()); + } if hunks.is_empty() { return; } @@ -1794,7 +1808,10 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - let hunks = self.resolve_diff_hunks(hunks, cx); + let mut hunks = self.resolve_diff_hunks(hunks, cx); + if self.diff_hunk_delegate.is_none() { + hunks.retain(|hunks| hunks.diff.read(cx).is_stageable()); + } if hunks.is_empty() { return; } @@ -2941,9 +2958,15 @@ pub fn render_diff_hunk_controls( _window: &mut Window, cx: &mut App, ) -> AnyElement { - let show_stage_restore = ProjectSettings::get_global(cx) - .git - .show_stage_restore_buttons; + let stageable = hunk_range + .start + .buffer_id() + .and_then(|buffer_id| editor.read(cx).buffer().read(cx).diff_for(buffer_id)) + .is_some_and(|diff| diff.read(cx).is_stageable()); + let show_stage_restore = stageable + && ProjectSettings::get_global(cx) + .git + .show_stage_restore_buttons; h_flex() .h(line_height) @@ -3118,31 +3141,38 @@ pub fn render_diff_hunk_controls( .into_any_element() } -pub(super) fn update_uncommitted_diff_for_buffer( - editor: Entity, - project: &Entity, - buffers: impl IntoIterator>, - buffer: Entity, - cx: &mut App, -) -> Task<()> { - let mut tasks = Vec::new(); - project.update(cx, |project, cx| { - for buffer in buffers { - if project::File::from_dyn(buffer.read(cx).file()).is_some() { - tasks.push(project.open_uncommitted_diff(buffer.clone(), cx)) - } - } - }); - cx.spawn(async move |cx| { - let diffs = future::join_all(tasks).await; - if editor.read_with(cx, |editor, _cx| editor.diff_hunk_delegate.is_some()) { - return; - } - - buffer.update(cx, |buffer, cx| { - for diff in diffs.into_iter().flatten() { - buffer.add_diff(diff, cx); - } +impl Editor { + pub(super) fn update_uncommitted_diff_for_buffer( + &mut self, + project: &Entity, + buffers: impl IntoIterator>, + cx: &mut Context, + ) -> Task<()> { + let mut tasks = Vec::new(); + project.update(cx, |project, cx| { + let git_store = project.git_store().clone(); + git_store.update(cx, |git_store, cx| { + for buffer in buffers { + if project::File::from_dyn(buffer.read(cx).file()).is_some() { + tasks.push(git_store.open_display_diff(buffer, cx)); + } + } + }); }); - }) + + let editor = cx.entity(); + let buffer = self.buffer.clone(); + cx.spawn(async move |_, cx| { + let diffs = future::join_all(tasks).await; + if editor.read_with(cx, |editor, _cx| editor.diff_hunk_delegate.is_some()) { + return; + } + + buffer.update(cx, |buffer, cx| { + for diff in diffs.into_iter().flatten() { + buffer.add_diff(diff, cx); + } + }); + }) + } } diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 73f1a91af16..9edfc3f5879 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -759,11 +759,10 @@ impl Item for Editor { let buffer_id = buffer.remote_id(); let project = self.project()?.read(cx); let entry = project.entry_for_path(&path, cx)?; - let (repo, repo_path) = project + let status = project .git_store() .read(cx) - .repository_and_path_for_buffer_id(buffer_id, cx)?; - let status = repo.read(cx).status_for_path(&repo_path)?.status; + .display_status_for_buffer_id(buffer_id, cx)?; Some(entry_git_aware_label_color( status.summary(), diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index 96ed0a23b0d..2d2a72a43e7 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -3894,7 +3894,15 @@ mod tests { .unindent(); let buffer2 = cx.new(|cx| Buffer::local(current_text.to_string(), cx)); - let diff2 = cx.new(|cx| BufferDiff::new(&buffer2.read(cx).text_snapshot(), None, None, cx)); + let diff2 = cx.new(|cx| { + BufferDiff::new( + &buffer2.read(cx).text_snapshot(), + None, + None, + buffer_diff::DiffBaseKind::Custom, + cx, + ) + }); editor.update(cx, |editor, cx| { let path1 = PathKey::sorted(0); diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index e2f72b9cdbf..268fdc7e6ca 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -207,29 +207,44 @@ impl GitRepository for FakeGitRepository { async move { fut.await.unwrap_or_default() }.boxed() } - fn diff_tree(&self, _request: DiffTreeType) -> BoxFuture<'_, Result> { - let mut entries = HashMap::default(); - self.with_state_async(false, |state| { - for (path, content) in &state.head_contents { - let status = if let Some((oid, original)) = state - .merge_base_contents - .get(path) - .map(|oid| (oid, &state.oids[oid])) - { - if original == content { - continue; + fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result> { + let worktree_contents = + matches!(request, DiffTreeType::MergeBaseWithWorktree { .. }).then(|| { + let workdir_path = self.dot_git_path.parent().unwrap(); + self.fs + .files() + .iter() + .filter_map(|path| { + let path_in_repo = path.strip_prefix(workdir_path).ok()?; + let path_in_repo = RelPath::new(path_in_repo, PathStyle::local()).ok()?; + let content = String::from_utf8(self.fs.read_file_sync(path).ok()?).ok()?; + Some((RepoPath::from_rel_path(&path_in_repo), content)) + }) + .collect::>() + }); + self.with_state_async(false, move |state| { + let contents = worktree_contents.as_ref().unwrap_or(&state.head_contents); + let tracked_paths = state + .merge_base_contents + .keys() + .chain(state.head_contents.keys()) + .chain(state.index_contents.keys()) + .collect::>(); + let mut entries = HashMap::default(); + for path in tracked_paths { + let status = match (state.merge_base_contents.get(path), contents.get(path)) { + (Some(oid), Some(content)) => { + if state.oids.get(oid).context("merge-base blob is missing")? == content { + continue; + } + TreeDiffStatus::Modified { old: *oid } } - TreeDiffStatus::Modified { old: *oid } - } else { - TreeDiffStatus::Added + (Some(oid), None) => TreeDiffStatus::Deleted { old: *oid }, + (None, Some(_)) => TreeDiffStatus::Added, + (None, None) => continue, }; entries.insert(path.clone(), status); } - for (path, oid) in &state.merge_base_contents { - if !entries.contains_key(path) { - entries.insert(path.clone(), TreeDiffStatus::Deleted { old: *oid }); - } - } Ok(TreeDiff { entries }) }) .boxed() diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 59b06fce327..b6393d90936 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1,6 +1,8 @@ use crate::commit::{CommitDiffObject, CommitDiffObjectKind, parse_git_diff_raw}; use crate::stash::GitStash; -use crate::status::{DiffTreeType, GitStatus, TreeDiff}; +use crate::status::{ + DiffTreeType, FileStatus, GitStatus, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus, +}; use crate::{Oid, RunHook, SHORT_SHA_LENGTH}; use anyhow::{Context as _, Result, anyhow, bail}; use async_channel::Sender; @@ -1811,36 +1813,197 @@ impl GitRepository for RealGitRepository { fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result> { let git = self.git_binary_in_worktree(); + let working_directory = self.working_directory.clone(); + let merge_base_ref = match &request { + DiffTreeType::MergeBaseWithWorktree { base } => Some(base.clone()), + DiffTreeType::MergeBase { .. } | DiffTreeType::Since { .. } => None, + }; - let mut args = vec![ - OsString::from("diff-tree"), - OsString::from("-r"), - OsString::from("-z"), - OsString::from("--no-renames"), - ]; - match request { - DiffTreeType::MergeBase { base, head } => { - args.push("--merge-base".into()); - args.push(OsString::from(base.as_str())); - args.push(OsString::from(head.as_str())); - } - DiffTreeType::Since { base, head } => { - args.push(OsString::from(base.as_str())); - args.push(OsString::from(head.as_str())); - } - } + let args = match request { + DiffTreeType::MergeBase { base, head } => [ + "diff-tree", + "-r", + "-z", + "--abbrev=64", + "--no-renames", + "--merge-base", + base.as_str(), + head.as_str(), + ] + .map(OsString::from) + .to_vec(), + DiffTreeType::MergeBaseWithWorktree { base } => [ + "diff", + "--raw", + "-z", + "--abbrev=64", + "--no-renames", + "--merge-base", + base.as_str(), + ] + .map(OsString::from) + .to_vec(), + DiffTreeType::Since { base, head } => [ + "diff-tree", + "-r", + "-z", + "--abbrev=64", + "--no-renames", + base.as_str(), + head.as_str(), + ] + .map(OsString::from) + .to_vec(), + }; self.executor .spawn(async move { let git = git?; let output = git.build_command(&args).output().await?; - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - stdout.parse() - } else { + if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git diff-tree failed: {stderr}"); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut tree_diff = stdout.parse::()?; + let Some(merge_base_ref) = merge_base_ref else { + return Ok(tree_diff); + }; + let Some(working_directory) = working_directory else { + return Ok(tree_diff); + }; + if !tree_diff + .entries + .values() + .any(|status| matches!(status, TreeDiffStatus::Deleted { .. })) + { + return Ok(tree_diff); + } + + let status_output = git.build_command(&git_status_args(&[])).output().await?; + if !status_output.status.success() { + let stderr = String::from_utf8_lossy(&status_output.stderr); anyhow::bail!("git status failed: {stderr}"); } + let status = String::from_utf8_lossy(&status_output.stdout).parse::()?; + // Files the diff reports as deleted but that exist on disk + // (deleted from the index or from a commit, then recreated). + // `git diff` compares them against the index, so compare their + // disk contents against the merge base ourselves. + let recreated: Vec<(RepoPath, Oid)> = status + .entries + .iter() + .filter(|(_, status)| { + matches!( + *status, + FileStatus::Untracked + | FileStatus::Tracked(TrackedStatus { + index_status: StatusCode::Deleted, + worktree_status: StatusCode::Added, + }) + ) + }) + .filter_map(|(path, _)| match tree_diff.entries.get(path) { + Some(TreeDiffStatus::Deleted { old }) => Some((path.clone(), *old)), + _ => None, + }) + .collect(); + if recreated.is_empty() { + return Ok(tree_diff); + } + + let merge_base_output = git + .build_command(&["merge-base", merge_base_ref.as_ref(), "HEAD"]) + .output() + .await?; + if !merge_base_output.status.success() { + let stderr = String::from_utf8_lossy(&merge_base_output.stderr); + anyhow::bail!("git merge-base failed: {stderr}"); + } + let merge_base = String::from_utf8_lossy(&merge_base_output.stdout); + let merge_base = merge_base.trim(); + + for (path, old) in recreated { + let full_path = working_directory.join(path.as_std_path()); + let metadata = match smol::fs::symlink_metadata(&full_path).await { + Ok(metadata) => metadata, + Err(_) => continue, + }; + let base_entry = git + .build_command( + &["ls-tree", merge_base, "--", path.as_unix_str()].map(OsString::from), + ) + .output() + .await?; + if !base_entry.status.success() { + continue; + } + let base_mode = String::from_utf8_lossy(&base_entry.stdout); + let Some(base_mode) = base_mode.split_ascii_whitespace().next() else { + continue; + }; + let current_mode = if metadata.file_type().is_symlink() { + "120000" + } else { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + if metadata.permissions().mode() & 0o111 == 0 { + "100644" + } else { + "100755" + } + } + #[cfg(not(unix))] + { + "100644" + } + }; + if current_mode != base_mode { + tree_diff + .entries + .insert(path.clone(), TreeDiffStatus::Modified { old }); + continue; + } + + let hash_output = if metadata.file_type().is_symlink() { + let target = smol::fs::read_link(&full_path).await?; + let mut child = git + .build_command(&["hash-object", "--stdin"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn()?; + let mut stdin = child.stdin.take().context("hash-object has no stdin")?; + stdin + .write_all(target.as_os_str().as_encoded_bytes()) + .await?; + stdin.flush().await?; + drop(stdin); + child.output().await? + } else { + git.build_command(&[ + OsString::from("hash-object"), + OsString::from(format!("--path={}", path.as_unix_str())), + OsString::from("--"), + full_path.into_os_string(), + ]) + .output() + .await? + }; + if !hash_output.status.success() { + continue; + } + let worktree_oid = String::from_utf8_lossy(&hash_output.stdout); + if worktree_oid.trim() == old.to_string() { + tree_diff.entries.remove(&path); + } else { + tree_diff + .entries + .insert(path.clone(), TreeDiffStatus::Modified { old }); + } + } + Ok(tree_diff) }) .boxed() } @@ -4085,6 +4248,185 @@ mod tests { ); } + #[gpui::test] + async fn test_merge_base_worktree_diff_handles_recreated_index_deletion( + cx: &mut TestAppContext, + ) { + disable_git_global_config(); + cx.executor().allow_parking(); + + let repo_dir = tempfile::tempdir().unwrap(); + git_init_repo(repo_dir.path()); + let file_path = repo_dir.path().join("file.txt"); + fs::write(&file_path, "base\n").unwrap(); + git_command(repo_dir.path(), ["add", "file.txt"]); + git_command(repo_dir.path(), ["commit", "-m", "base"]); + let base_oid = git_command_output(repo_dir.path(), ["rev-parse", "HEAD:file.txt"]) + .parse() + .unwrap(); + + fs::write(&file_path, "head\n").unwrap(); + git_command(repo_dir.path(), ["add", "file.txt"]); + git_command(repo_dir.path(), ["commit", "-m", "head"]); + git_command(repo_dir.path(), ["rm", "--cached", "file.txt"]); + fs::write(&file_path, "base\n").unwrap(); + + let repository = RealGitRepository::new( + &repo_dir.path().join(".git"), + None, + Some("git".into()), + cx.executor(), + ) + .unwrap(); + assert_eq!( + repository + .diff_tree(DiffTreeType::MergeBaseWithWorktree { + base: "HEAD^".into(), + }) + .await + .unwrap(), + TreeDiff { + entries: HashMap::default(), + } + ); + + fs::write(&file_path, "worktree\n").unwrap(); + assert_eq!( + repository + .diff_tree(DiffTreeType::MergeBaseWithWorktree { + base: "HEAD^".into(), + }) + .await + .unwrap(), + TreeDiff { + entries: HashMap::from_iter([( + RepoPath::new("file.txt").unwrap(), + TreeDiffStatus::Modified { old: base_oid }, + )]), + } + ); + } + + #[gpui::test] + async fn test_merge_base_worktree_diff_handles_committed_deletion_recreated_on_disk( + cx: &mut TestAppContext, + ) { + disable_git_global_config(); + cx.executor().allow_parking(); + + let repo_dir = tempfile::tempdir().unwrap(); + git_init_repo(repo_dir.path()); + let file_path = repo_dir.path().join("file.txt"); + fs::write(&file_path, "base\n").unwrap(); + git_command(repo_dir.path(), ["add", "file.txt"]); + git_command(repo_dir.path(), ["commit", "-m", "base"]); + let base_oid = git_command_output(repo_dir.path(), ["rev-parse", "HEAD:file.txt"]) + .parse() + .unwrap(); + + git_command(repo_dir.path(), ["rm", "file.txt"]); + git_command(repo_dir.path(), ["commit", "-m", "delete"]); + fs::write(&file_path, "base\n").unwrap(); + + let repository = RealGitRepository::new( + &repo_dir.path().join(".git"), + None, + Some("git".into()), + cx.executor(), + ) + .unwrap(); + assert_eq!( + repository + .diff_tree(DiffTreeType::MergeBaseWithWorktree { + base: "HEAD^".into(), + }) + .await + .unwrap(), + TreeDiff { + entries: HashMap::default(), + } + ); + + fs::write(&file_path, "worktree\n").unwrap(); + assert_eq!( + repository + .diff_tree(DiffTreeType::MergeBaseWithWorktree { + base: "HEAD^".into(), + }) + .await + .unwrap(), + TreeDiff { + entries: HashMap::from_iter([( + RepoPath::new("file.txt").unwrap(), + TreeDiffStatus::Modified { old: base_oid }, + )]), + } + ); + } + + #[cfg(unix)] + #[gpui::test] + async fn test_merge_base_worktree_diff_handles_recreated_symlink(cx: &mut TestAppContext) { + use std::os::unix::fs::symlink; + + disable_git_global_config(); + cx.executor().allow_parking(); + + let repo_dir = tempfile::tempdir().unwrap(); + git_init_repo(repo_dir.path()); + let file_path = repo_dir.path().join("file.txt"); + symlink("base-target", &file_path).unwrap(); + git_command(repo_dir.path(), ["add", "file.txt"]); + git_command(repo_dir.path(), ["commit", "-m", "base"]); + let base_oid = git_command_output(repo_dir.path(), ["rev-parse", "HEAD:file.txt"]) + .parse() + .unwrap(); + + fs::remove_file(&file_path).unwrap(); + symlink("head-target", &file_path).unwrap(); + git_command(repo_dir.path(), ["add", "file.txt"]); + git_command(repo_dir.path(), ["commit", "-m", "head"]); + git_command(repo_dir.path(), ["rm", "--cached", "file.txt"]); + fs::remove_file(&file_path).unwrap(); + symlink("base-target", &file_path).unwrap(); + + let repository = RealGitRepository::new( + &repo_dir.path().join(".git"), + None, + Some("git".into()), + cx.executor(), + ) + .unwrap(); + assert_eq!( + repository + .diff_tree(DiffTreeType::MergeBaseWithWorktree { + base: "HEAD^".into(), + }) + .await + .unwrap(), + TreeDiff { + entries: HashMap::default(), + } + ); + + fs::remove_file(&file_path).unwrap(); + fs::write(&file_path, "base-target").unwrap(); + assert_eq!( + repository + .diff_tree(DiffTreeType::MergeBaseWithWorktree { + base: "HEAD^".into(), + }) + .await + .unwrap(), + TreeDiff { + entries: HashMap::from_iter([( + RepoPath::new("file.txt").unwrap(), + TreeDiffStatus::Modified { old: base_oid }, + )]), + } + ); + } + #[gpui::test] async fn test_load_commit_with_type_changed_file(cx: &mut TestAppContext) { disable_git_global_config(); diff --git a/crates/git/src/status.rs b/crates/git/src/status.rs index 1a04d02e753..28178bfaedd 100644 --- a/crates/git/src/status.rs +++ b/crates/git/src/status.rs @@ -503,28 +503,15 @@ pub enum DiffTreeType { base: SharedString, head: SharedString, }, + MergeBaseWithWorktree { + base: SharedString, + }, Since { base: SharedString, head: SharedString, }, } -impl DiffTreeType { - pub fn base(&self) -> &SharedString { - match self { - DiffTreeType::MergeBase { base, .. } => base, - DiffTreeType::Since { base, .. } => base, - } - } - - pub fn head(&self) -> &SharedString { - match self { - DiffTreeType::MergeBase { head, .. } => head, - DiffTreeType::Since { head, .. } => head, - } - } -} - #[derive(Debug, PartialEq)] pub struct TreeDiff { pub entries: HashMap, diff --git a/crates/git_ui/src/branch_diff.rs b/crates/git_ui/src/branch_diff.rs index 068d8e9af19..685acea448c 100644 --- a/crates/git_ui/src/branch_diff.rs +++ b/crates/git_ui/src/branch_diff.rs @@ -2,7 +2,8 @@ use crate::{ branch_picker, diff_multibuffer::DiffMultibuffer, project_diff::{ - self, CompareWithBranch, DeployBranchDiff, ReviewDiff, render_send_review_to_agent_button, + self, CompareWithBranch, DeployBranchDiff, ProjectDiff, ReviewDiff, + render_send_review_to_agent_button, }, }; use agent_settings::AgentSettings; @@ -24,7 +25,7 @@ use project::{ diff_buffer_list::{self, DiffBase}, }, }; -use settings::Settings; +use settings::{GitDiffBaseSetting, Settings}; use std::{ any::{Any, TypeId}, sync::Arc, @@ -69,14 +70,15 @@ impl Addon for BranchDiffAddon { impl BranchDiff { pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) { - workspace.register_action(Self::deploy_branch_diff); + workspace.register_action(|workspace, _: &DeployBranchDiff, window, cx| { + Self::deploy_branch_diff(workspace, window, cx) + }); workspace.register_action(Self::compare_with_branch); workspace::register_serializable_item::(cx); } - fn deploy_branch_diff( + pub(crate) fn deploy_branch_diff( workspace: &mut Workspace, - _: &DeployBranchDiff, window: &mut Window, cx: &mut Context, ) { @@ -98,16 +100,31 @@ impl BranchDiff { let workspace_weak = workspace.downgrade(); window .spawn(cx, async move |cx| { - let base_ref = default_branch - .await?? - .context("Could not determine default branch")?; - + let base_ref = default_branch.await??; workspace.update_in(cx, |workspace, window, cx| { + let git_store = project.read(cx).git_store().clone(); + let Some(base_ref) = base_ref else { + ProjectDiff::deploy_at(workspace, None, window, cx); + return; + }; + let branch_diff = + if git_store.read(cx).diff_base() == GitDiffBaseSetting::DefaultBranch { + Some(git_store.update(cx, |git_store, cx| { + git_store.ensure_display_diff( + intended_repo.clone(), + base_ref.clone(), + cx, + ) + })) + } else { + None + }; Self::deploy_branch_diff_with_base_ref( workspace, project, intended_repo, base_ref, + branch_diff, window, cx, ); @@ -154,6 +171,7 @@ impl BranchDiff { project.clone(), repository.clone(), base_ref, + None, window, cx, ); @@ -174,11 +192,12 @@ impl BranchDiff { }); } - fn deploy_branch_diff_with_base_ref( + pub(crate) fn deploy_branch_diff_with_base_ref( workspace: &mut Workspace, project: Entity, intended_repo: Entity, base_ref: SharedString, + branch_diff: Option>, window: &mut Window, cx: &mut Context, ) { @@ -187,21 +206,15 @@ impl BranchDiff { matches!( item.diff_base(cx), DiffBase::Merge { base_ref: existing_base_ref } if existing_base_ref == &base_ref - ) + ) && item + .repo(cx) + .is_some_and(|repo| repo.read(cx).id == intended_repo.read(cx).id) + && branch_diff.as_ref().is_none_or(|expected| { + item.diff.read(cx).branch_diff().entity_id() == expected.entity_id() + }) }); if let Some(existing) = existing { workspace.activate_item(&existing, true, true, window, cx); - - let needs_switch = existing.read(cx).repo(cx).map_or(true, |current| { - current.read(cx).id != intended_repo.read(cx).id - }); - - if needs_switch { - existing.update(cx, |branch_diff, cx| { - branch_diff.set_repo(Some(intended_repo), cx); - }); - } - return; } @@ -216,6 +229,7 @@ impl BranchDiff { workspace.clone(), base_ref, intended_repo, + branch_diff, window, cx, ) @@ -247,8 +261,25 @@ impl BranchDiff { .await?? .context("Could not determine default branch")?; cx.update(|window, cx| { + let git_store = project.read(cx).git_store().clone(); + let branch_diff = + if git_store.read(cx).diff_base() == GitDiffBaseSetting::DefaultBranch { + Some(git_store.update(cx, |git_store, cx| { + git_store.ensure_display_diff(repo.clone(), base_ref.clone(), cx) + })) + } else { + None + }; cx.new(|cx| { - Self::new_with_base_ref(project, workspace, base_ref, Some(repo), window, cx) + Self::new_with_base_ref( + project, + workspace, + base_ref, + Some(repo), + branch_diff, + window, + cx, + ) }) }) }) @@ -259,13 +290,22 @@ impl BranchDiff { workspace: Entity, base_ref: SharedString, repo: Entity, + branch_diff: Option>, window: &mut Window, cx: &mut App, ) -> Task>> { window.spawn(cx, async move |cx| { cx.update(|window, cx| { cx.new(|cx| { - Self::new_with_base_ref(project, workspace, base_ref, Some(repo), window, cx) + Self::new_with_base_ref( + project, + workspace, + base_ref, + Some(repo), + branch_diff, + window, + cx, + ) }) }) }) @@ -276,20 +316,20 @@ impl BranchDiff { workspace: Entity, base_ref: SharedString, repo: Option>, + branch_diff: Option>, window: &mut Window, cx: &mut Context, ) -> Self { - let branch_diff = cx.new(|cx| { - let mut branch_diff = diff_buffer_list::DiffBufferList::new( - DiffBase::Merge { base_ref }, - project.clone(), - window, - cx, - ); - if repo.is_some() { - branch_diff.set_repo(repo, cx); - } - branch_diff + let branch_diff = branch_diff.unwrap_or_else(|| { + let git_store = project.read(cx).git_store().clone(); + cx.new(|cx| { + diff_buffer_list::DiffBufferList::new( + DiffBase::Merge { base_ref }, + git_store, + repo, + cx, + ) + }) }); let branch_diff_for_addon = branch_diff.clone(); let diff = cx.new(|cx| { @@ -340,10 +380,6 @@ impl BranchDiff { self.diff.read(cx).repo(cx) } - pub(crate) fn set_repo(&mut self, repo: Option>, cx: &mut Context) { - self.diff.update(cx, |diff, cx| diff.set_repo(repo, cx)); - } - fn set_merge_base(&mut self, base_ref: SharedString, cx: &mut Context) { self.diff.update(cx, |diff, cx| { diff.branch_diff().update(cx, |branch_diff, cx| { @@ -507,8 +543,17 @@ impl Item for BranchDiff { }; let repo = self.repo(cx); let project = self.project.clone(); + let branch_diff = self.diff.read(cx).branch_diff().clone(); Task::ready(Some(cx.new(|cx| { - Self::new_with_base_ref(project, workspace, base_ref, repo, window, cx) + Self::new_with_base_ref( + project, + workspace, + base_ref, + repo, + Some(branch_diff), + window, + cx, + ) }))) } @@ -635,7 +680,9 @@ impl SerializableItem for BranchDiff { }; let workspace = workspace.upgrade().context("workspace gone")?; cx.update(|window, cx| { - cx.new(|cx| Self::new_with_base_ref(project, workspace, base_ref, None, window, cx)) + cx.new(|cx| { + Self::new_with_base_ref(project, workspace, base_ref, None, None, window, cx) + }) }) }) } @@ -741,6 +788,16 @@ impl Render for BranchDiffToolbar { let base_ref_label = format!("Base: {base_ref}"); let repository = branch_diff.read(cx).repo(cx); let workspace = branch_diff.read(cx).workspace.clone(); + let project = branch_diff.read(cx).project.clone(); + let buffers = branch_diff.read(cx).diff.read(cx).branch_diff().clone(); + let uses_display_diff = repository.as_ref().is_some_and(|repository| { + project + .read(cx) + .git_store() + .read(cx) + .display_diff_for_repo(repository.read(cx).id) + .is_some_and(|display_diff| display_diff.entity_id() == buffers.entity_id()) + }); let view_for_picker = branch_diff.downgrade(); let is_multibuffer_empty = branch_diff @@ -772,17 +829,39 @@ impl Render for BranchDiffToolbar { PopoverMenu::new("branch-diff-base-branch-picker") .menu(move |window, cx| { let view_for_picker = view_for_picker.clone(); + let workspace_for_select = workspace.clone(); + let repository_for_select = repository.clone(); + let project = project.clone(); let on_select = Arc::new( move |branch: git::repository::Branch, - _window: &mut Window, + window: &mut Window, cx: &mut App| { let base_ref: SharedString = branch.name().to_owned().into(); - view_for_picker - .update(cx, |branch_diff, cx| { - branch_diff.set_merge_base(base_ref, cx); - cx.notify(); - }) - .ok(); + if uses_display_diff { + let Some(repository) = repository_for_select.clone() else { + return; + }; + workspace_for_select + .update(cx, |workspace, cx| { + BranchDiff::deploy_branch_diff_with_base_ref( + workspace, + project.clone(), + repository, + base_ref, + None, + window, + cx, + ); + }) + .ok(); + } else { + view_for_picker + .update(cx, |branch_diff, cx| { + branch_diff.set_merge_base(base_ref, cx); + cx.notify(); + }) + .ok(); + } }, ); @@ -1074,16 +1153,20 @@ mod tests { let editor = diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone()); - assert_state_with_diff( - &editor, - cx, - &" + let expected_diff = " - A - + ˇC - + new + + C + + ˇnew + created-in-head" - .unindent(), - ); + .unindent(); + assert_state_with_diff(&editor, cx, &expected_diff); + + cx.update(|window, cx| { + editor.focus_handle(cx).focus(window, cx); + window.dispatch_action(git::Restore.boxed_clone(), cx); + }); + cx.run_until_parked(); + assert_state_with_diff(&editor, cx, &expected_diff); let statuses: HashMap, Option> = editor.update(cx, |editor, cx| { @@ -1151,6 +1234,7 @@ mod tests { workspace.clone(), "topic".into(), repository, + None, window, cx, ) diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index de8e1f2166f..31f39371927 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -373,6 +373,7 @@ impl CommitView { &snapshot, snapshot.language().cloned(), Some(language_registry.clone()), + buffer_diff::DiffBaseKind::Oid, cx, ) }) @@ -1003,8 +1004,15 @@ async fn build_buffer_diff( let language = cx.update(|_, cx| buffer.read(cx).language().cloned())?; let buffer = cx.update(|_, cx| buffer.read(cx).snapshot())?; - let diff = - cx.new(|cx| BufferDiff::new(&buffer.text, language, Some(language_registry.clone()), cx)); + let diff = cx.new(|cx| { + BufferDiff::new( + &buffer.text, + language, + Some(language_registry.clone()), + buffer_diff::DiffBaseKind::Oid, + cx, + ) + }); diff.update(cx, |diff, cx| { diff.set_base_text( diff --git a/crates/git_ui/src/file_diff_view.rs b/crates/git_ui/src/file_diff_view.rs index a9c6b5878d8..2b71d6988b2 100644 --- a/crates/git_ui/src/file_diff_view.rs +++ b/crates/git_ui/src/file_diff_view.rs @@ -218,6 +218,7 @@ pub(crate) async fn build_buffer_diff( &new_buffer_snapshot.text, new_buffer_snapshot.language().cloned(), language_registry, + buffer_diff::DiffBaseKind::Custom, cx, ) }); diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 8c394255c98..7951fac48c7 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -1201,7 +1201,9 @@ impl GitPanel { .ok(); } GitStoreEvent::RepositoryUpdated(_, _, _) => {} - GitStoreEvent::JobsUpdated | GitStoreEvent::ConflictsUpdated => {} + GitStoreEvent::JobsUpdated + | GitStoreEvent::ConflictsUpdated + | GitStoreEvent::DiffBaseChanged(_) => {} }, ) .detach(); diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 445eaf1e704..55fb5f40fa9 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -1,4 +1,5 @@ use crate::{ + branch_diff::BranchDiff, diff_multibuffer::DiffMultibuffer, git_panel::{GitPanel, GitPanelAddon, GitStatusEntry}, staged_diff::StagedDiff, @@ -26,6 +27,7 @@ use project::{ }; use schemars::JsonSchema; use serde::Deserialize; +use settings::GitDiffBaseSetting; use std::any::{Any, TypeId}; use std::sync::Arc; use ui::{DiffStat, Divider, Tooltip, prelude::*}; @@ -40,8 +42,12 @@ use zed_actions::git as git_actions; actions!( git, [ - /// Shows the diff between the working directory and the index. + /// Shows the diff against the configured diff base: working changes + /// relative to HEAD, or all branch changes relative to the default + /// branch, following the `git.diff_base` setting. Diff, + /// Shows working changes relative to HEAD. + DiffHead, /// Adds files to the git staging area. Add, /// Opens a new agent thread with the branch diff for review. @@ -55,7 +61,7 @@ actions!( /// Shows the diff between the working directory and your default /// branch (typically main or master). #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] -#[action(namespace = git, name = "BranchDiff")] +#[action(namespace = git, name = "DiffBranch", deprecated_aliases = ["git::BranchDiff"])] pub(crate) struct DeployBranchDiff; pub struct ProjectDiff { @@ -68,6 +74,9 @@ pub struct ProjectDiff { impl ProjectDiff { pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) { workspace.register_action(Self::deploy); + workspace.register_action(|workspace, _: &DiffHead, window, cx| { + Self::deploy_at(workspace, None, window, cx); + }); workspace.register_action( |workspace, _: &git_actions::ViewUncommittedChanges, window, cx| { Self::deploy_at(workspace, None, window, cx); @@ -84,7 +93,7 @@ impl ProjectDiff { }, ); workspace.register_action(|workspace, _: &Add, window, cx| { - Self::deploy(workspace, &Diff, window, cx); + Self::deploy_at(workspace, None, window, cx); }); workspace::register_serializable_item::(cx); } @@ -95,6 +104,11 @@ impl ProjectDiff { window: &mut Window, cx: &mut Context, ) { + let project = workspace.project().clone(); + if project.read(cx).git_store().read(cx).diff_base() == GitDiffBaseSetting::DefaultBranch { + BranchDiff::deploy_branch_diff(workspace, window, cx); + return; + } Self::deploy_at(workspace, None, window, cx) } @@ -194,9 +208,9 @@ impl ProjectDiff { window: &mut Window, cx: &mut Context, ) -> Self { - let branch_diff = cx.new(|cx| { - diff_buffer_list::DiffBufferList::new(DiffBase::Head, project.clone(), window, cx) - }); + let git_store = project.read(cx).git_store().clone(); + let branch_diff = + cx.new(|cx| diff_buffer_list::DiffBufferList::new(DiffBase::Head, git_store, None, cx)); Self::new_impl(branch_diff, project, workspace, window, cx) } @@ -586,13 +600,9 @@ impl SerializableItem for ProjectDiff { ) -> Task>> { window.spawn(cx, async move |cx| { cx.update(|window, cx| { + let git_store = project.read(cx).git_store().clone(); let branch_diff = cx.new(|cx| { - diff_buffer_list::DiffBufferList::new( - DiffBase::Head, - project.clone(), - window, - cx, - ) + diff_buffer_list::DiffBufferList::new(DiffBase::Head, git_store, None, cx) }); let workspace = workspace.upgrade().context("workspace gone")?; anyhow::Ok( @@ -1069,6 +1079,102 @@ mod tests { ); } + #[gpui::test] + async fn test_diff_action_follows_diff_base_setting(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "committed.txt": "head\n", + }), + ) + .await; + fs.set_head_and_index_for_repo( + Path::new(path!("/project/.git")), + &[("committed.txt", "head\n".into())], + ); + fs.set_merge_base_content_for_repo( + Path::new(path!("/project/.git")), + &[("committed.txt", "base\n".into())], + ); + + let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); + cx.focus(&workspace); + + cx.update(|_window, cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.git.get_or_insert_default().diff_base = + Some(GitDiffBaseSetting::DefaultBranch); + }); + }); + }); + cx.run_until_parked(); + project.read_with(cx, |project, cx| { + assert_eq!( + project.git_store().read(cx).diff_base(), + GitDiffBaseSetting::DefaultBranch + ); + }); + + cx.update(|window, cx| { + window.dispatch_action(Diff.boxed_clone(), cx); + }); + cx.run_until_parked(); + workspace.update(cx, |workspace, cx| { + assert!(workspace.active_item_as::(cx).is_some()); + assert_eq!(workspace.items_of_type::(cx).count(), 0); + }); + + cx.update(|window, cx| { + window.dispatch_action(DiffHead.boxed_clone(), cx); + }); + cx.run_until_parked(); + workspace.update(cx, |workspace, cx| { + assert!(workspace.active_item_as::(cx).is_some()); + }); + + cx.update(|_window, cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.git.get_or_insert_default().diff_base = Some(GitDiffBaseSetting::Head); + }); + }); + }); + cx.run_until_parked(); + let repository_id = project.read_with(cx, |project, cx| { + assert_eq!( + project.git_store().read(cx).diff_base(), + GitDiffBaseSetting::Head + ); + project.active_repository(cx).unwrap().read(cx).id + }); + + cx.update(|window, cx| { + window.dispatch_action(DeployBranchDiff.boxed_clone(), cx); + }); + cx.run_until_parked(); + project.read_with(cx, |project, cx| { + assert!( + project + .git_store() + .read(cx) + .display_diff_for_repo(repository_id) + .is_none() + ); + }); + } + #[gpui::test] async fn test_update_on_uncommit(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/git_ui/src/staged_diff.rs b/crates/git_ui/src/staged_diff.rs index 30ab5bd0590..0897e53219f 100644 --- a/crates/git_ui/src/staged_diff.rs +++ b/crates/git_ui/src/staged_diff.rs @@ -219,8 +219,8 @@ impl StagedDiff { window: &mut Window, cx: &mut Context, ) -> Self { - let branch_diff = - cx.new(|cx| DiffBufferList::new(DiffBase::Staged, project.clone(), window, cx)); + let git_store = project.read(cx).git_store().clone(); + let branch_diff = cx.new(|cx| DiffBufferList::new(DiffBase::Staged, git_store, None, cx)); let workspace_handle = workspace.downgrade(); let diff = cx.new(|cx| { DiffMultibuffer::new( diff --git a/crates/git_ui/src/text_diff_view.rs b/crates/git_ui/src/text_diff_view.rs index c9cf6b5f537..5cb3360ba93 100644 --- a/crates/git_ui/src/text_diff_view.rs +++ b/crates/git_ui/src/text_diff_view.rs @@ -120,6 +120,7 @@ impl TextDiffView { BufferDiff::new_with_base_text_buffer( &source_buffer_snapshot.text, clipboard_buffer.clone(), + buffer_diff::DiffBaseKind::Custom, cx, ) }); diff --git a/crates/git_ui/src/unstaged_diff.rs b/crates/git_ui/src/unstaged_diff.rs index 02cb8f12c24..7cfc4121b5d 100644 --- a/crates/git_ui/src/unstaged_diff.rs +++ b/crates/git_ui/src/unstaged_diff.rs @@ -273,8 +273,8 @@ impl UnstagedDiff { window: &mut Window, cx: &mut Context, ) -> Self { - let branch_diff = - cx.new(|cx| DiffBufferList::new(DiffBase::Index, project.clone(), window, cx)); + let git_store = project.read(cx).git_store().clone(); + let branch_diff = cx.new(|cx| DiffBufferList::new(DiffBase::Index, git_store, None, cx)); let workspace_handle = workspace.downgrade(); let diff = cx.new(|cx| { DiffMultibuffer::new( diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 0a19816d572..623ee0f9e3b 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -19,7 +19,9 @@ use gpui::{ }; use language::File as _; use persistence::ImageViewerDb; -use project::{ImageItem, Project, ProjectPath, image_store::ImageItemEvent}; +use project::{ + ImageItem, Project, ProjectPath, git_store::GitStoreEvent, image_store::ImageItemEvent, +}; use settings::Settings; use theme_settings::ThemeSettings; use ui::{Tooltip, prelude::*}; @@ -148,6 +150,13 @@ impl ImageView { let _render_image = pending_image.clone().get_render_image(window, cx); cx.subscribe(&image_item, Self::on_image_event).detach(); + let git_store = project.read(cx).git_store().clone(); + cx.subscribe(&git_store, |_, _, event, cx| { + if matches!(event, GitStoreEvent::DiffBaseChanged(_)) { + cx.emit(ImageViewEvent::TitleChanged); + } + }) + .detach(); cx.on_release_in(window, |this, window, cx| { let image_data = this.image_item.read(cx).image.clone(); if let Some(image) = image_data.clone().get_render_image(window, cx) { @@ -561,7 +570,9 @@ impl Item for ImageView { let git_status = self .project .read(cx) - .project_path_git_status(&project_path, cx) + .git_store() + .read(cx) + .display_status_for_project_path(&project_path, cx) .map(|status| status.summary()) .unwrap_or_default(); diff --git a/crates/multi_buffer/src/anchor.rs b/crates/multi_buffer/src/anchor.rs index 849e9932c2a..ef80afd385b 100644 --- a/crates/multi_buffer/src/anchor.rs +++ b/crates/multi_buffer/src/anchor.rs @@ -421,6 +421,14 @@ impl Anchor { } } + /// The buffer this anchor is attached to, unless it's `Min` or `Max`. + pub fn buffer_id(&self) -> Option { + match self { + Anchor::Min | Anchor::Max => None, + Anchor::Excerpt(excerpt_anchor) => Some(excerpt_anchor.buffer_id()), + } + } + pub(crate) fn text_anchor(&self) -> Option { match self { Anchor::Min | Anchor::Max => None, diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 2afbdf1f4c2..49018c5b926 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -2748,7 +2748,7 @@ impl OutlinePanel { let active_multi_buffer = active_editor.read(cx).buffer().clone(); let new_entries = outline_panel.new_entries_for_fs_update.clone(); let repo_snapshots = outline_panel.project.update(cx, |project, cx| { - project.git_store().read(cx).repo_snapshots(cx) + project.git_store().read(cx).display_repo_snapshots(cx) }); let git_store = outline_panel.project.read(cx).git_store().clone(); new_collapsed_entries = outline_panel.collapsed_entries.clone(); @@ -2772,10 +2772,7 @@ impl OutlinePanel { let is_folded = active_editor.read(cx).is_buffer_folded(buffer_id, cx); let status = git_store .read(cx) - .repository_and_path_for_buffer_id(buffer_id, cx) - .and_then(|(repo, path)| { - Some(repo.read(cx).status_for_path(&path)?.status) - }); + .display_status_for_buffer_id(buffer_id, cx); buffer_excerpts .entry(buffer_id) .or_insert_with(|| { diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 91426b8bd00..2272f0390cf 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -64,7 +64,7 @@ use rpc::{ proto::{self, git_reset, split_repository_update}, }; use serde::Deserialize; -use settings::{Settings, WorktreeId}; +use settings::{GitDiffBaseSetting, Settings, SettingsStore, WorktreeId}; use smallvec::SmallVec; use smol::future::yield_now; use std::{ @@ -101,6 +101,8 @@ pub struct GitStore { buffer_store: Entity, worktree_store: Entity, repositories: HashMap>, + diff_base: GitDiffBaseSetting, + display_diffs: HashMap, worktree_ids: HashMap>, active_repo_id: Option, #[allow(clippy::type_complexity)] @@ -112,6 +114,12 @@ pub struct GitStore { _subscriptions: Vec, } +#[derive(Default)] +struct DisplayDiff { + buffers: Option<(Entity, Subscription)>, + refresh: Option>, +} + #[derive(Default)] struct SharedDiffs { unstaged: Option>, @@ -618,6 +626,7 @@ pub enum GitStoreEvent { JobsUpdated, ConflictsUpdated, GlobalConfigurationUpdated, + DiffBaseChanged(Option), } impl EventEmitter for Repository {} @@ -694,6 +703,11 @@ impl GitStore { repo.schedule_scan(None, cx); }) } + let display_repo_ids = + this.display_diffs.keys().copied().collect::>(); + for repository_id in display_repo_ids { + this.refresh_diff_base_for_repo(repository_id, cx); + } cx.emit(GitStoreEvent::GlobalConfigurationUpdated); }) else { return; @@ -753,11 +767,21 @@ impl GitStore { _subscriptions.push(cx.subscribe(&trusted_worktrees, Self::on_trusted_worktrees_event)); } + _subscriptions.push(cx.observe_global::(|this, cx| { + let setting = ProjectSettings::get_global(cx).git.diff_base; + if setting != this.diff_base { + this.set_diff_base(setting, cx); + } + })); + + let diff_base_setting = ProjectSettings::get_global(cx).git.diff_base; GitStore { state, buffer_store, worktree_store, repositories: HashMap::default(), + diff_base: diff_base_setting, + display_diffs: HashMap::default(), worktree_ids: HashMap::default(), active_repo_id: None, _subscriptions, @@ -1436,6 +1460,7 @@ impl GitStore { &buffer_snapshot, buffer_snapshot.language().cloned(), language_registry, + buffer_diff::DiffBaseKind::Oid, cx, ) }); @@ -1483,6 +1508,41 @@ impl GitStore { cx.background_spawn(async move { task.await.map_err(|e| anyhow!("{e}")) }) } + /// Opens the diff that editors display in the gutter: the uncommitted diff + /// when the diff base is HEAD, otherwise a diff against the base blob. + pub fn open_display_diff( + &mut self, + buffer: Entity, + cx: &mut Context, + ) -> Task>> { + let mut branch_base = None; + if self.diff_base == GitDiffBaseSetting::DefaultBranch + && let Some((repo, repo_path)) = + self.repository_and_path_for_buffer_id(buffer.read(cx).remote_id(), cx) + && let Some((buffers, _)) = self + .display_diffs + .get(&repo.read(cx).id) + .and_then(|state| state.buffers.as_ref()) + { + let buffers = buffers.read(cx); + // The inner `None` means the file was added since the base and + // diffs against empty content. + let base_oid = buffers.base_oid_for_path(&repo_path).or_else(|| { + buffers + .status_for_path(&repo_path, cx) + .is_some_and(FileStatus::is_created) + .then_some(None) + }); + if let Some(oid) = base_oid { + branch_base = Some((repo, oid)); + } + } + match branch_base { + Some((repo, oid)) => self.open_diff_since(oid, buffer, repo, cx), + None => self.open_uncommitted_diff(buffer, cx), + } + } + #[ztracing::instrument(skip_all)] pub fn open_uncommitted_diff( &mut self, @@ -1595,6 +1655,7 @@ impl GitStore { BufferDiff::new_with_base_text_buffer( &text_snapshot, base_text_buffer, + buffer_diff::DiffBaseKind::Index, cx, ) }) @@ -1622,6 +1683,7 @@ impl GitStore { BufferDiff::new_with_base_text_buffer( &index_text_snapshot, base_text_buffer, + buffer_diff::DiffBaseKind::Head, cx, ) }) @@ -1634,6 +1696,7 @@ impl GitStore { BufferDiff::new_with_base_text_buffer( &text_snapshot, base_text_buffer, + buffer_diff::DiffBaseKind::Head, cx, ) }) @@ -1673,6 +1736,7 @@ impl GitStore { BufferDiff::new_with_base_text_buffer( &text_snapshot, base_text_buffer, + buffer_diff::DiffBaseKind::Index, cx, ) }); @@ -2132,6 +2196,7 @@ impl GitStore { .any(|repo_id| self.active_repo_id == Some(*repo_id)); for repo_id in repos_without_worktree { + self.display_diffs.remove(&repo_id); self.repositories.remove(&repo_id); self.worktree_ids.remove(&repo_id); if let Some(updates_tx) = @@ -2189,6 +2254,11 @@ impl GitStore { .ok(); } } + + if matches!(event, RepositoryEvent::BranchListChanged) { + self.refresh_diff_base_for_repo(id, cx); + } + cx.emit(GitStoreEvent::RepositoryUpdated( id, event.clone(), @@ -2326,6 +2396,7 @@ impl GitStore { self.repositories.insert(id, repo); self.worktree_ids.insert(id, HashSet::from([worktree_id])); cx.emit(GitStoreEvent::RepositoryAdded); + self.refresh_diff_base_for_repo(id, cx); self.active_repo_id.get_or_insert_with(|| { cx.emit(GitStoreEvent::ActiveRepositoryChanged(Some(id))); id @@ -2338,6 +2409,7 @@ impl GitStore { self.active_repo_id = None; cx.emit(GitStoreEvent::ActiveRepositoryChanged(None)); } + self.display_diffs.remove(&id); self.repositories.remove(&id); if let Some(updates_tx) = updates_tx.as_ref() { updates_tx @@ -2719,6 +2791,7 @@ impl GitStore { let id = RepositoryId::from_proto(update.id); let client = this.upstream_client().context("no upstream client")?; + let is_new = !this.repositories.contains_key(&id); let repository_dir_abs_path: Option> = update .repository_dir_abs_path @@ -2756,6 +2829,10 @@ impl GitStore { |repo, cx| repo.apply_remote_update(update, cx) })?; + if is_new { + this.refresh_diff_base_for_repo(id, cx); + } + this.active_repo_id.get_or_insert_with(|| { cx.emit(GitStoreEvent::ActiveRepositoryChanged(Some(id))); id @@ -2777,6 +2854,7 @@ impl GitStore { this.update(&mut cx, |this, cx| { let mut update = envelope.payload; let id = RepositoryId::from_proto(update.id); + this.display_diffs.remove(&id); this.repositories.remove(&id); if let Some((client, project_id)) = this.downstream_client() { update.project_id = project_id.to_proto(); @@ -4061,7 +4139,11 @@ impl GitStore { mut cx: AsyncApp, ) -> Result { let repository_id = RepositoryId(request.payload.repository_id); - let diff_type = if request.payload.is_merge { + let diff_type = if request.payload.includes_worktree { + DiffTreeType::MergeBaseWithWorktree { + base: request.payload.base.into(), + } + } else if request.payload.is_merge { DiffTreeType::MergeBase { base: request.payload.base.into(), head: request.payload.head.into(), @@ -4301,6 +4383,168 @@ impl GitStore { .collect() } + pub fn display_repo_snapshots(&self, cx: &App) -> HashMap { + let mut snapshots = self.repo_snapshots(cx); + if self.diff_base == GitDiffBaseSetting::Head { + return snapshots; + } + for (id, snapshot) in &mut snapshots { + if let Some(statuses) = self + .display_diffs + .get(id) + .and_then(|state| state.buffers.as_ref()) + .and_then(|(buffers, _)| buffers.read(cx).statuses_by_path()) + { + snapshot.statuses_by_path = statuses; + } + } + snapshots + } + + pub fn diff_base(&self) -> GitDiffBaseSetting { + self.diff_base + } + + fn set_diff_base(&mut self, setting: GitDiffBaseSetting, cx: &mut Context) { + if self.diff_base == setting { + return; + } + self.diff_base = setting; + self.display_diffs.clear(); + if setting == GitDiffBaseSetting::DefaultBranch { + let repo_ids = self.repositories.keys().copied().collect::>(); + for repo_id in repo_ids { + self.refresh_diff_base_for_repo(repo_id, cx); + } + } + cx.emit(GitStoreEvent::DiffBaseChanged(None)); + } + + pub fn display_status_for_buffer_id( + &self, + buffer_id: BufferId, + cx: &App, + ) -> Option { + let (repo, repo_path) = self.repository_and_path_for_buffer_id(buffer_id, cx)?; + self.display_status(&repo, &repo_path, cx) + } + + pub fn display_status_for_project_path( + &self, + project_path: &ProjectPath, + cx: &App, + ) -> Option { + let (repo, repo_path) = self.repository_and_path_for_project_path(project_path, cx)?; + self.display_status(&repo, &repo_path, cx) + } + + fn display_status( + &self, + repo: &Entity, + repo_path: &RepoPath, + cx: &App, + ) -> Option { + if self.diff_base == GitDiffBaseSetting::DefaultBranch + && let Some((buffers, _)) = self + .display_diffs + .get(&repo.read(cx).id) + .and_then(|state| state.buffers.as_ref()) + { + return buffers.read(cx).status_for_path(repo_path, cx); + } + repo.read(cx) + .status_for_path(repo_path) + .map(|entry| entry.status) + } + + pub fn display_diff_for_repo( + &self, + repo_id: RepositoryId, + ) -> Option> { + let (buffers, _) = self.display_diffs.get(&repo_id)?.buffers.as_ref()?; + Some(buffers.clone()) + } + + pub fn ensure_display_diff( + &mut self, + repo: Entity, + resolved_ref: SharedString, + cx: &mut Context, + ) -> Entity { + let repo_id = repo.read(cx).id; + if let Some((buffers, _)) = self + .display_diffs + .get(&repo_id) + .and_then(|state| state.buffers.as_ref()) + { + let buffers = buffers.clone(); + buffers.update(cx, |buffers, cx| { + buffers.set_diff_base( + diff_buffer_list::DiffBase::Merge { + base_ref: resolved_ref, + }, + cx, + ) + }); + return buffers; + } + + let git_store = cx.entity(); + let buffers = cx.new(|cx| { + diff_buffer_list::DiffBufferList::new( + diff_buffer_list::DiffBase::Merge { + base_ref: resolved_ref, + }, + git_store, + Some(repo), + cx, + ) + }); + let subscription = cx.subscribe(&buffers, move |_, _, event, cx| { + if matches!(event, diff_buffer_list::BranchDiffEvent::FileListChanged) { + cx.emit(GitStoreEvent::DiffBaseChanged(Some(repo_id))); + } + }); + self.display_diffs.entry(repo_id).or_default().buffers = + Some((buffers.clone(), subscription)); + buffers + } + + fn refresh_diff_base_for_repo(&mut self, repo_id: RepositoryId, cx: &mut Context) { + if self.diff_base == GitDiffBaseSetting::Head { + return; + } + let Some(repo) = self.repositories.get(&repo_id).cloned() else { + return; + }; + + let task = cx.spawn(async move |this, cx| { + let result: Result> = + async { repo.update(cx, |repo, _| repo.default_branch(true)).await? }.await; + this.update(cx, |this, cx| { + let Some(state) = this.display_diffs.get_mut(&repo_id) else { + return; + }; + state.refresh.take(); + match result.as_ref() { + Ok(Some(resolved_ref)) => { + this.ensure_display_diff(repo, resolved_ref.clone(), cx); + cx.emit(GitStoreEvent::DiffBaseChanged(Some(repo_id))); + } + Ok(None) => { + if state.buffers.take().is_some() { + cx.emit(GitStoreEvent::DiffBaseChanged(Some(repo_id))); + } + } + Err(_) => {} + } + }) + .log_err(); + result.log_err(); + }); + self.display_diffs.entry(repo_id).or_default().refresh = Some(task); + } + fn coalesce_repo_paths(mut paths: Vec) -> Vec { paths.sort(); @@ -8552,13 +8796,21 @@ impl Repository { backend.diff_tree(diff_type).await } RepositoryState::Remote(RemoteRepositoryState { client, project_id }) => { + let (is_merge, includes_worktree, base, head) = match diff_type { + DiffTreeType::MergeBase { base, head } => (true, false, base, head), + DiffTreeType::MergeBaseWithWorktree { base } => { + (true, true, base, SharedString::default()) + } + DiffTreeType::Since { base, head } => (false, false, base, head), + }; let response = client .request(proto::GetTreeDiff { project_id: project_id.0, repository_id: repository_id.0, - is_merge: matches!(diff_type, DiffTreeType::MergeBase { .. }), - base: diff_type.base().to_string(), - head: diff_type.head().to_string(), + is_merge, + base: base.to_string(), + head: head.to_string(), + includes_worktree, }) .await?; @@ -10183,8 +10435,8 @@ mod tests { use crate::Project; use fs::{FakeFs, Fs}; use git::repository::{RepoPath, repo_path}; - use gpui::TestAppContext; use gpui::proptest::prelude::*; + use gpui::{TestAppContext, UpdateGlobal}; use rand::{SeedableRng, rngs::StdRng}; use serde_json::json; use settings::SettingsStore; @@ -10321,6 +10573,145 @@ mod tests { }); } + #[gpui::test] + async fn test_merge_base_status_uses_worktree_contents(cx: &mut TestAppContext) { + use util::rel_path::rel_path; + + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + Path::new("/project"), + json!({ + ".git": {}, + "committed.txt": "base\n", + }), + ) + .await; + fs.set_head_and_index_for_repo( + Path::new("/project/.git"), + &[("committed.txt", "head\n".into())], + ); + fs.set_merge_base_content_for_repo( + Path::new("/project/.git"), + &[("committed.txt", "base\n".into())], + ); + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let worktree_id = project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + let git_store = project.read_with(cx, |project, _| project.git_store().clone()); + let project_path = (worktree_id, rel_path("committed.txt")).into(); + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("committed.txt")), cx) + }) + .await + .unwrap(); + let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id()); + + git_store.read_with(cx, |git_store, cx| { + assert!( + git_store + .project_path_git_status(&project_path, cx) + .is_some_and(|status| status.has_changes()) + ); + }); + cx.update(|cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.git.get_or_insert_default().diff_base = + Some(settings::GitDiffBaseSetting::DefaultBranch); + }); + }); + }); + cx.run_until_parked(); + + let display_diff = git_store + .update(cx, |git_store, cx| { + git_store.open_display_diff(buffer.clone(), cx) + }) + .await + .unwrap(); + display_diff.read_with(cx, |diff, cx| { + assert_eq!(diff.base_text_string(cx).as_deref(), Some("base\n")); + }); + git_store.read_with(cx, |git_store, cx| { + assert_eq!(git_store.display_status_for_buffer_id(buffer_id, cx), None); + assert_eq!( + git_store.display_status_for_project_path(&project_path, cx), + None + ); + + let raw_snapshot = git_store.repo_snapshots(cx).into_values().next().unwrap(); + let display_snapshot = git_store + .display_repo_snapshots(cx) + .into_values() + .next() + .unwrap(); + assert_eq!( + raw_snapshot + .statuses_by_path + .iter() + .map(|entry| entry.repo_path.clone()) + .collect::>(), + vec![repo_path("committed.txt")] + ); + assert_eq!(display_snapshot.statuses_by_path.iter().count(), 0); + }); + + fs.set_merge_base_content_for_repo( + Path::new("/project/.git"), + &[("committed.txt", "head\n".into())], + ); + let repository = + project.read_with(cx, |project, cx| project.active_repository(cx).unwrap()); + let branches = repository.read_with(cx, |repository, _| { + repository.snapshot().branch_list.to_vec() + }); + repository.update(cx, |repository, cx| { + repository.set_branch_list_for_test(branches, cx) + }); + cx.run_until_parked(); + + git_store.read_with(cx, |git_store, cx| { + assert!( + git_store + .display_status_for_buffer_id(buffer_id, cx) + .is_some_and(|status| status.has_changes()) + ); + }); + + let repository_id = repository.read_with(cx, |repository, _| repository.id); + let display_diff_list = git_store + .read_with(cx, |git_store, _| { + git_store.display_diff_for_repo(repository_id) + }) + .unwrap(); + let weak_display_diff_list = display_diff_list.downgrade(); + drop(display_diff_list); + drop(display_diff); + cx.update(|cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.git.get_or_insert_default().diff_base = + Some(settings::GitDiffBaseSetting::Head); + }); + }); + }); + cx.run_until_parked(); + + git_store.read_with(cx, |git_store, _| { + assert!(git_store.display_diff_for_repo(repository_id).is_none()); + }); + assert!(weak_display_diff_list.upgrade().is_none()); + } + #[gpui::test] async fn test_append_pattern_to_ignore_file_creates_and_deduplicates(cx: &mut TestAppContext) { let fs: Arc = FakeFs::new(cx.executor()); diff --git a/crates/project/src/git_store/diff_buffer_list.rs b/crates/project/src/git_store/diff_buffer_list.rs index 2f1586d3e04..ee0421b587f 100644 --- a/crates/project/src/git_store/diff_buffer_list.rs +++ b/crates/project/src/git_store/diff_buffer_list.rs @@ -1,24 +1,26 @@ use anyhow::Result; use buffer_diff::BufferDiff; -use collections::HashSet; +use collections::{HashMap, HashSet}; use futures::StreamExt; use git::{ repository::RepoPath, status::{DiffTreeType, FileStatus, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus}, }; use gpui::{ - App, AsyncApp, AsyncWindowContext, Context, Entity, EventEmitter, SharedString, Subscription, - Task, WeakEntity, Window, + App, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task, WeakEntity, }; use language::Buffer; +use sum_tree::SumTree; use text::BufferId; use util::ResultExt; use ztracing::instrument; use crate::{ - ConflictSet, Project, - git_store::{GitStoreEvent, Repository, RepositoryEvent}, + ConflictSet, + git_store::{ + GitStore, GitStoreEvent, Repository, RepositoryEvent, RepositorySnapshot, StatusEntry, + }, }; #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -38,11 +40,10 @@ impl DiffBase { pub struct DiffBufferList { diff_base: DiffBase, repo: Option>, - project: Entity, - base_commit: Option, - head_commit: Option, + git_store: WeakEntity, + committed_tree_diff: Option, tree_diff: Option, - tree_diff_update_needed: bool, + statuses_by_path: Option>, tree_diff_base_task: Option>, _subscription: Subscription, update_needed: postage::watch::Sender<()>, @@ -59,53 +60,51 @@ impl EventEmitter for DiffBufferList {} impl DiffBufferList { pub fn new( source: DiffBase, - project: Entity, - window: &mut Window, + git_store: Entity, + repo: Option>, cx: &mut Context, ) -> Self { - let git_store = project.read(cx).git_store().clone(); - let repo = git_store.read(cx).active_repository(); - let git_store_subscription = cx.subscribe_in( - &git_store, - window, - move |this, _git_store, event, _window, cx| { - let should_update = match event { - GitStoreEvent::ActiveRepositoryChanged(new_repo_id) => { - this.repo.is_none() && new_repo_id.is_some() - } - GitStoreEvent::RepositoryUpdated( - event_repo_id, - RepositoryEvent::StatusesChanged | RepositoryEvent::HeadChanged, - _, - ) => this - .repo - .as_ref() - .is_some_and(|r| r.read(cx).snapshot().id == *event_repo_id), - _ => false, - }; - - if should_update { - cx.emit(BranchDiffEvent::FileListChanged); - *this.update_needed.borrow_mut() = (); + let repo = repo.or_else(|| git_store.read(cx).active_repository()); + let git_store_subscription = cx.subscribe(&git_store, move |this, _, event, cx| { + let should_update = match event { + GitStoreEvent::ActiveRepositoryChanged(new_repo_id) => { + this.repo.is_none() && new_repo_id.is_some() } - }, - ); + GitStoreEvent::RepositoryUpdated( + event_repo_id, + RepositoryEvent::StatusesChanged + | RepositoryEvent::HeadChanged + | RepositoryEvent::BranchListChanged, + _, + ) => this + .repo + .as_ref() + .is_some_and(|repo| repo.read(cx).snapshot().id == *event_repo_id), + _ => false, + }; + + if should_update { + // Merge-base lists refresh after the tree diff reloads; other + // bases have no tree diff, so notify consumers immediately. + if !this.diff_base.is_merge_base() { + cx.emit(BranchDiffEvent::FileListChanged); + } + *this.update_needed.borrow_mut() = (); + } + }); let (send, recv) = postage::watch::channel::<()>(); - let worker = window.spawn(cx, { - let this = cx.weak_entity(); - async |cx| Self::handle_status_updates(this, recv, cx).await - }); + let worker = + cx.spawn(async move |this, cx| Self::handle_status_updates(this, recv, cx).await); Self { diff_base: source, repo, - project, + git_store: git_store.downgrade(), + committed_tree_diff: None, tree_diff: None, - tree_diff_update_needed: false, + statuses_by_path: None, tree_diff_base_task: None, - base_commit: None, - head_commit: None, _subscription: git_store_subscription, _task: worker, update_needed: send, @@ -127,78 +126,46 @@ impl DiffBufferList { } self.repo = repo; + self.committed_tree_diff = None; self.tree_diff = None; - self.tree_diff_update_needed = self.diff_base.is_merge_base(); + self.statuses_by_path = None; self.tree_diff_base_task = None; - self.base_commit = None; - self.head_commit = None; cx.emit(BranchDiffEvent::FileListChanged); *self.update_needed.borrow_mut() = (); } pub fn set_diff_base(&mut self, diff_base: DiffBase, cx: &mut Context) { if self.diff_base == diff_base { + *self.update_needed.borrow_mut() = (); return; } - self.tree_diff_update_needed = diff_base.is_merge_base(); + self.committed_tree_diff = None; self.tree_diff = None; + self.statuses_by_path = None; self.tree_diff_base_task = None; self.diff_base = diff_base; - self.base_commit = None; - self.head_commit = None; cx.emit(BranchDiffEvent::DiffBaseChanged); - if self.tree_diff_update_needed { - *self.update_needed.borrow_mut() = (); - } + *self.update_needed.borrow_mut() = (); } pub async fn handle_status_updates( this: WeakEntity, mut recv: postage::watch::Receiver<()>, - cx: &mut AsyncWindowContext, + cx: &mut AsyncApp, ) { this.update(cx, |this, cx| this.spawn_reload_tree_diff(cx)) .log_err(); while recv.next().await.is_some() { let Ok(()) = this.update(cx, |this, cx| { - let mut needs_update = this.tree_diff_update_needed; - this.tree_diff_update_needed = false; - if this.repo.is_none() { - let active_repo = this - .project - .read(cx) - .git_store() - .read(cx) - .active_repository(); - if active_repo.is_some() { - this.repo = active_repo; - needs_update = true; - } - } else if let Some(repo) = this.repo.as_ref() { - repo.update(cx, |repo, _| { - if let Some(branch) = &repo.branch - && let DiffBase::Merge { base_ref } = &this.diff_base - && let Some(commit) = branch.most_recent_commit.as_ref() - && &branch.ref_name == base_ref - && this.base_commit.as_ref() != Some(&commit.sha) - { - this.base_commit = Some(commit.sha.clone()); - needs_update = true; - } - - if repo.head_commit.as_ref().map(|c| &c.sha) != this.head_commit.as_ref() { - this.head_commit = repo.head_commit.as_ref().map(|c| c.sha.clone()); - needs_update = true; - } - }) - } - - if needs_update { - this.spawn_reload_tree_diff(cx); + this.repo = this + .git_store + .upgrade() + .and_then(|git_store| git_store.read(cx).active_repository()); } + this.spawn_reload_tree_diff(cx); }) else { return; }; @@ -206,78 +173,55 @@ impl DiffBufferList { } pub fn status_for_buffer_id(&self, buffer_id: BufferId, cx: &App) -> Option { - let (repo, path) = self - .project - .read(cx) - .git_store() + let git_store = self.git_store.upgrade()?; + let (repo, path) = git_store .read(cx) .repository_and_path_for_buffer_id(buffer_id, cx)?; - if self.repo() == Some(&repo) { - return self.merge_statuses( - repo.read(cx) - .status_for_path(&path) - .map(|status| status.status), - self.tree_diff - .as_ref() - .and_then(|diff| diff.entries.get(&path)), - ); - } - None + (self.repo() == Some(&repo)) + .then(|| self.status_for_path(&path, cx)) + .flatten() } - pub fn merge_statuses( - &self, - diff_from_head: Option, - diff_from_merge_base: Option<&TreeDiffStatus>, - ) -> Option { - match (diff_from_head, diff_from_merge_base) { - (None, None) => None, - (Some(diff_from_head), None) => Some(diff_from_head), - (Some(diff_from_head @ FileStatus::Unmerged(_)), _) => Some(diff_from_head), - - // file does not exist in HEAD - // but *does* exist in work-tree - // and *does* exist in merge-base - ( - Some(FileStatus::Untracked) - | Some(FileStatus::Tracked(TrackedStatus { - index_status: StatusCode::Added, - worktree_status: _, - })), - Some(_), - ) => Some(FileStatus::Tracked(TrackedStatus { - index_status: StatusCode::Modified, - worktree_status: StatusCode::Modified, - })), - - // file exists in HEAD - // but *does not* exist in work-tree - (Some(diff_from_head), Some(diff_from_merge_base)) if diff_from_head.is_deleted() => { - match diff_from_merge_base { - TreeDiffStatus::Added => None, // unchanged, didn't exist in merge base or worktree - _ => Some(diff_from_head), - } - } - - // file exists in HEAD - // and *does* exist in work-tree - (Some(FileStatus::Tracked(_)), Some(tree_status)) => { - Some(FileStatus::Tracked(TrackedStatus { - index_status: match tree_status { - TreeDiffStatus::Added { .. } => StatusCode::Added, - _ => StatusCode::Modified, - }, - worktree_status: match tree_status { - TreeDiffStatus::Added => StatusCode::Added, - _ => StatusCode::Modified, - }, - })) - } - - (_, Some(diff_from_merge_base)) => { - Some(diff_status_to_file_status(diff_from_merge_base)) - } + pub fn status_for_path(&self, path: &RepoPath, cx: &App) -> Option { + let repo_status = self + .repo + .as_ref() + .and_then(|repo| repo.read(cx).status_for_path(path)) + .map(|entry| entry.status); + if repo_status.is_some_and(|status| { + status_overrides_tree( + status, + self.committed_tree_diff + .as_ref() + .and_then(|diff| diff.entries.get(path)), + ) + }) { + return repo_status; } + self.tree_diff + .as_ref() + .and_then(|diff| diff.entries.get(path)) + .map(diff_status_to_file_status) + } + + pub fn statuses_by_path(&self) -> Option> { + self.statuses_by_path.clone() + } + + pub fn base_oid_for_path(&self, path: &RepoPath) -> Option> { + let status = self + .tree_diff + .as_ref() + .and_then(|diff| diff.entries.get(path)) + .or_else(|| { + self.committed_tree_diff + .as_ref() + .and_then(|diff| diff.entries.get(path)) + })?; + Some(match status { + TreeDiffStatus::Added => None, + TreeDiffStatus::Modified { old } | TreeDiffStatus::Deleted { old } => Some(*old), + }) } fn spawn_reload_tree_diff(&mut self, cx: &mut Context) { @@ -300,29 +244,43 @@ impl DiffBufferList { } pub async fn reload_tree_diff(this: WeakEntity, cx: &mut AsyncApp) -> Result<()> { - let task = this.update(cx, |this, cx| { + let tasks = this.update(cx, |this, cx| { let DiffBase::Merge { base_ref } = this.diff_base.clone() else { return None; }; let Some(repo) = this.repo.as_ref() else { + this.committed_tree_diff.take(); this.tree_diff.take(); + this.statuses_by_path.take(); return None; }; - repo.update(cx, |repo, cx| { - Some(repo.diff_tree( - DiffTreeType::MergeBase { - base: base_ref, - head: "HEAD".into(), - }, - cx, - )) - }) + Some(repo.update(cx, |repo, cx| { + ( + repo.diff_tree( + DiffTreeType::MergeBase { + base: base_ref.clone(), + head: "HEAD".into(), + }, + cx, + ), + repo.diff_tree(DiffTreeType::MergeBaseWithWorktree { base: base_ref }, cx), + ) + })) })?; - let Some(task) = task else { return Ok(()) }; + let Some((committed_task, worktree_task)) = tasks else { + return Ok(()); + }; - let diff = task.await??; + let (committed_tree_diff, tree_diff) = futures::try_join!(committed_task, worktree_task)?; + let committed_tree_diff = committed_tree_diff?; + let tree_diff = tree_diff?; this.update(cx, |this, cx| { - this.tree_diff = Some(diff); + let statuses_by_path = this.repo.as_ref().map(|repo| { + build_statuses(&repo.read(cx).snapshot, &committed_tree_diff, &tree_diff) + }); + this.committed_tree_diff = Some(committed_tree_diff); + this.tree_diff = Some(tree_diff); + this.statuses_by_path = statuses_by_path; cx.emit(BranchDiffEvent::FileListChanged); cx.notify(); }) @@ -342,58 +300,66 @@ impl DiffBufferList { return output; } - self.project.update(cx, |_project, cx| { + let Some(git_store) = self.git_store.upgrade() else { + return output; + }; + { let mut seen = HashSet::default(); for item in repo.read(cx).cached_status() { - seen.insert(item.repo_path.clone()); - let branch_diff = self - .tree_diff - .as_ref() - .and_then(|t| t.entries.get(&item.repo_path)) - .cloned(); - let Some(status) = (match self.diff_base { - DiffBase::Head | DiffBase::Merge { .. } => { - self.merge_statuses(Some(item.status), branch_diff.as_ref()) - } + let status = match self.diff_base { + DiffBase::Head => Some(item.status), DiffBase::Index => item.status.staging().has_unstaged().then_some(item.status), DiffBase::Staged => item.status.staging().has_staged().then_some(item.status), - }) else { + DiffBase::Merge { .. } => status_overrides_tree( + item.status, + self.committed_tree_diff + .as_ref() + .and_then(|diff| diff.entries.get(&item.repo_path)), + ) + .then_some(item.status), + }; + let Some(status) = status.filter(|status| status.has_changes()) else { continue; }; - if !status.has_changes() { - continue; - } + seen.insert(item.repo_path.clone()); let Some(project_path) = repo.read(cx).repo_path_to_project_path(&item.repo_path, cx) else { continue; }; + let branch_diff = self + .tree_diff + .as_ref() + .and_then(|tree| tree.entries.get(&item.repo_path)) + .cloned(); let task = Self::load_buffer( self.diff_base.clone(), branch_diff, project_path, repo.clone(), + git_store.clone(), cx, ); output.push(DiffBuffer { repo_path: item.repo_path.clone(), load: task, - file_status: item.status, + file_status: status, }); } let Some(tree_diff) = self.tree_diff.as_ref() else { - return; + output.sort_by(|left, right| left.repo_path.cmp(&right.repo_path)); + return output; }; for (path, branch_diff) in tree_diff.entries.iter() { - if seen.contains(&path) { + if seen.contains(path) { continue; } - let Some(project_path) = repo.read(cx).repo_path_to_project_path(&path, cx) else { + let Some(project_path) = repo.read(cx).repo_path_to_project_path(path, cx) else { continue; }; let task = Self::load_buffer( @@ -401,18 +367,18 @@ impl DiffBufferList { Some(branch_diff.clone()), project_path, repo.clone(), + git_store.clone(), cx, ); - let file_status = diff_status_to_file_status(branch_diff); - output.push(DiffBuffer { repo_path: path.clone(), load: task, - file_status, + file_status: diff_status_to_file_status(branch_diff), }); } - }); + } + output.sort_by(|left, right| left.repo_path.cmp(&right.repo_path)); output } @@ -422,37 +388,42 @@ impl DiffBufferList { branch_diff: Option, project_path: crate::ProjectPath, repo: Entity, - cx: &Context<'_, Project>, + git_store: Entity, + cx: &Context, ) -> Task> { - let task = cx.spawn(async move |project, cx| { - let buffer = project - .update(cx, |project, cx| project.open_buffer(project_path, cx))? + cx.spawn(async move |_, cx| { + let buffer = git_store + .update(cx, |git_store, cx| { + git_store.buffer_store.update(cx, |buffer_store, cx| { + buffer_store.open_buffer(project_path, cx) + }) + }) .await?; let main_buffer = buffer.clone(); let load_conflict_set = diff_base != DiffBase::Staged; let (display_buffer, changes) = match diff_base { DiffBase::Head => { - let diff = project - .update(cx, |project, cx| { - project.open_uncommitted_diff(buffer.clone(), cx) - })? + let diff = git_store + .update(cx, |git_store, cx| { + git_store.open_uncommitted_diff(buffer.clone(), cx) + }) .await?; (buffer, diff) } DiffBase::Index => { - let diff = project - .update(cx, |project, cx| { - project.open_unstaged_diff(buffer.clone(), cx) - })? + let diff = git_store + .update(cx, |git_store, cx| { + git_store.open_unstaged_diff(buffer.clone(), cx) + }) .await?; (buffer, diff) } DiffBase::Staged => { - let (diff, index_buffer) = project - .update(cx, |project, cx| { - project.open_staged_diff(buffer.clone(), cx) - })? + let (diff, index_buffer) = git_store + .update(cx, |git_store, cx| { + git_store.open_staged_diff(buffer.clone(), cx) + }) .await?; (index_buffer, diff) } @@ -463,18 +434,16 @@ impl DiffBufferList { git::status::TreeDiffStatus::Modified { old, .. } | git::status::TreeDiffStatus::Deleted { old } => Some(old), }; - project - .update(cx, |project, cx| { - project.git_store().update(cx, |git_store, cx| { - git_store.open_diff_since(oid, buffer.clone(), repo, cx) - }) - })? + git_store + .update(cx, |git_store, cx| { + git_store.open_diff_since(oid, buffer.clone(), repo, cx) + }) .await? } else { - project - .update(cx, |project, cx| { - project.open_uncommitted_diff(buffer.clone(), cx) - })? + git_store + .update(cx, |git_store, cx| { + git_store.open_uncommitted_diff(buffer.clone(), cx) + }) .await? }; (buffer, diff) @@ -482,12 +451,10 @@ impl DiffBufferList { }; let conflict_set = if load_conflict_set { Some( - project - .update(cx, |project, cx| { - project.git_store().update(cx, |git_store, cx| { - git_store.open_conflict_set(main_buffer.clone(), cx) - }) - })? + git_store + .update(cx, |git_store, cx| { + git_store.open_conflict_set(main_buffer.clone(), cx) + }) .await, ) } else { @@ -499,11 +466,50 @@ impl DiffBufferList { diff: changes, conflict_set, }) - }); - task + }) } } +fn build_statuses( + snapshot: &RepositorySnapshot, + committed_tree_diff: &TreeDiff, + tree_diff: &TreeDiff, +) -> SumTree { + let mut entries = tree_diff + .entries + .iter() + .map(|(repo_path, status)| { + ( + repo_path.clone(), + StatusEntry { + repo_path: repo_path.clone(), + status: diff_status_to_file_status(status), + diff_stat: None, + staged_diff_stat: None, + unstaged_diff_stat: None, + }, + ) + }) + .collect::>(); + for entry in snapshot.statuses_by_path.iter() { + if status_overrides_tree( + entry.status, + committed_tree_diff.entries.get(&entry.repo_path), + ) { + entries.insert(entry.repo_path.clone(), entry.clone()); + } + } + let mut entries = entries.into_values().collect::>(); + entries.sort_by(|left, right| left.repo_path.cmp(&right.repo_path)); + SumTree::from_iter(entries, ()) +} + +fn status_overrides_tree(status: FileStatus, committed_status: Option<&TreeDiffStatus>) -> bool { + status.is_conflicted() + || status.is_untracked() + && !matches!(committed_status, Some(TreeDiffStatus::Deleted { .. })) +} + fn diff_status_to_file_status(branch_diff: &git::status::TreeDiffStatus) -> FileStatus { let file_status = match branch_diff { git::status::TreeDiffStatus::Added { .. } => FileStatus::Tracked(TrackedStatus { diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index 86d0d43a4a5..275e0218d3b 100644 --- a/crates/project/src/project_settings.rs +++ b/crates/project/src/project_settings.rs @@ -484,6 +484,10 @@ pub struct GitSettings { /// /// Default: staged_hollow pub hunk_style: settings::GitHunkStyleSetting, + /// Which base git features diff against. + /// + /// Default: head + pub diff_base: settings::GitDiffBaseSetting, /// How file paths are displayed in the git gutter. /// /// Default: file_name_first @@ -708,6 +712,7 @@ impl Settings for ProjectSettings { } }, hunk_style: git.hunk_style.unwrap(), + diff_base: git.diff_base.unwrap_or_default(), path_style: git.path_style.unwrap().into(), show_stage_restore_buttons: git.show_stage_restore_buttons.unwrap_or(true), worktree_directory: git diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 67fd8b1f784..067adcd530f 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -682,6 +682,7 @@ impl ProjectPanel { |this, _, event, window, cx| match event { GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::StatusesChanged, _) | GitStoreEvent::RepositoryAdded + | GitStoreEvent::DiffBaseChanged(_) | GitStoreEvent::RepositoryRemoved(_) => { this.update_visible_entries(None, false, false, window, cx); cx.notify(); @@ -2814,7 +2815,7 @@ impl ProjectPanel { let parent_entry = worktree.entry_for_path(parent_path)?; // Remove all siblings that are being deleted except the last marked entry - let repo_snapshots = git_store.repo_snapshots(cx); + let repo_snapshots = git_store.display_repo_snapshots(cx); let worktree_snapshot = worktree.snapshot(); let hide_gitignore = ProjectPanelSettings::get_global(cx).hide_gitignore; let mut siblings: Vec<_> = @@ -4259,7 +4260,7 @@ impl ProjectPanel { let sort_mode = settings.sort_mode; let sort_order = settings.sort_order; let project = self.project.read(cx); - let repo_snapshots = project.git_store().read(cx).repo_snapshots(cx); + let repo_snapshots = project.git_store().read(cx).display_repo_snapshots(cx); let old_ancestors = self.state.ancestors.clone(); let temporary_unfolded_pending_state = self.state.temporarily_unfolded_pending_state.take(); @@ -5297,7 +5298,7 @@ impl ProjectPanel { .read(cx) .git_store() .read(cx) - .repo_snapshots(cx); + .display_repo_snapshots(cx); let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?; worktree.read_with(cx, |tree, _| { utils::ReversibleIterable::new( @@ -5327,7 +5328,7 @@ impl ProjectPanel { .read(cx) .git_store() .read(cx) - .repo_snapshots(cx); + .display_repo_snapshots(cx); let mut last_found: Option = None; diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 2fb5f79ecff..5320328f766 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -547,6 +547,7 @@ message GetTreeDiff { bool is_merge = 3; string base = 4; string head = 5; + bool includes_worktree = 6; } message GetTreeDiffResponse { diff --git a/crates/settings_content/src/project.rs b/crates/settings_content/src/project.rs index 84b41706e58..c6a0b6952b4 100644 --- a/crates/settings_content/src/project.rs +++ b/crates/settings_content/src/project.rs @@ -551,6 +551,10 @@ pub struct GitSettings { /// /// Default: staged_hollow pub hunk_style: Option, + /// Which base git features (gutter, file colors, git::Diff) diff against. + /// + /// Default: head + pub diff_base: Option, /// How file paths are displayed in the git gutter. /// /// Default: file_name_first @@ -730,6 +734,32 @@ pub enum GitHunkStyleSetting { UnstagedHollow, } +#[derive( + Clone, + Copy, + PartialEq, + Debug, + Default, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum GitDiffBaseSetting { + /// Diff against HEAD: show working (uncommitted) changes. + #[default] + Head, + /// Diff against the merge base between HEAD and the repository's + /// default branch: show all changes on the branch. + /// + /// Repositories where no default branch can be resolved fall back + /// to `head` behavior. + DefaultBranch, +} + #[with_fallible_options] #[derive( Copy, diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 941358fcb1d..5da60174a8e 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -7881,7 +7881,7 @@ fn version_control_page() -> SettingsPage { ] } - fn git_hunks_section() -> [SettingsPageItem; 4] { + fn git_hunks_section() -> [SettingsPageItem; 5] { [ SettingsPageItem::SectionHeader("Git Hunks"), SettingsPageItem::SettingItem(SettingItem { @@ -7898,6 +7898,20 @@ fn version_control_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Diff Base", + description: "Whether git features show changes relative to HEAD (uncommitted changes) or to the default branch (all changes on the current branch).", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("git.diff_base"), + pick: |settings_content| settings_content.git.as_ref()?.diff_base.as_ref(), + write: |settings_content, value, _| { + settings_content.git.get_or_insert_default().diff_base = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Path Style", description: "Should the name or path be displayed first in the git view.", diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 93782c96d0b..dbb2278cc3b 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -559,6 +559,7 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index 2f79ad53014..62be1daf1b3 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -275,7 +275,9 @@ impl TabMatch { let project = project.read(cx); let entry = project.entry_for_path(&path, cx)?; let git_status = project - .project_path_git_status(&path, cx) + .git_store() + .read(cx) + .display_status_for_project_path(&path, cx) .map(|status| status.summary()) .unwrap_or_default(); Some(entry_git_aware_label_color( diff --git a/crates/zed/src/zed/quick_action_bar.rs b/crates/zed/src/zed/quick_action_bar.rs index d3f30a73b71..42e3d7e0425 100644 --- a/crates/zed/src/zed/quick_action_bar.rs +++ b/crates/zed/src/zed/quick_action_bar.rs @@ -15,9 +15,12 @@ use gpui::{ FocusHandle, Focusable, InteractiveElement, ParentElement, Render, Styled, Subscription, WeakEntity, Window, anchored, deferred, point, }; -use project::{DisableAiSettings, project_settings::DiagnosticSeverity}; +use project::{ + DisableAiSettings, + project_settings::{DiagnosticSeverity, ProjectSettings}, +}; use search::{BufferSearchBar, buffer_search}; -use settings::{Settings, SettingsStore}; +use settings::{GitDiffBaseSetting, Settings, SettingsStore, update_settings_file}; use ui::{ ButtonStyle, ContextMenu, ContextMenuEntry, DocumentationSide, IconButton, IconName, IconSize, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*, @@ -324,6 +327,12 @@ impl Render for QuickActionBar { let editor_settings_dropdown = { let vim_mode_enabled = VimModeSetting::get_global(cx).0; let helix_mode_enabled = HelixModeSetting::get_global(cx).0; + let diff_against_default_branch = + ProjectSettings::get_global(cx).git.diff_base == GitDiffBaseSetting::DefaultBranch; + let fs = self + .workspace + .upgrade() + .map(|workspace| workspace.read(cx).app_state().fs.clone()); PopoverMenu::new("editor-settings") .trigger_with_tooltip( @@ -634,6 +643,28 @@ impl Render for QuickActionBar { }, ); + if let Some(fs) = fs.clone() { + menu = menu.toggleable_entry( + "Diff Against Default Branch", + diff_against_default_branch, + IconPosition::Start, + None, + { + move |_window, cx| { + let diff_base = if diff_against_default_branch { + GitDiffBaseSetting::Head + } else { + GitDiffBaseSetting::DefaultBranch + }; + update_settings_file(fs.clone(), cx, move |settings, _| { + settings.git.get_or_insert_default().diff_base = + Some(diff_base); + }); + } + }, + ); + } + menu = menu.separator(); menu = menu.toggleable_entry( diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index dd4c9e4ae13..a141d1b3db6 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -2544,6 +2544,42 @@ Example: } ``` +### Diff Base + +- Description: Whether git features show changes relative to HEAD (uncommitted changes) or to the default branch (all changes on the current branch). Also available in the editor controls menu as "Diff Against Default Branch". +- Setting: `diff_base` +- Default: + +```json [settings] +{ + "git": { + "diff_base": "head" + } +} +``` + +**Options** + +1. Show working changes relative to HEAD: + +```json [settings] +{ + "git": { + "diff_base": "head" + } +} +``` + +2. Show all branch changes relative to the merge base with the repository's default branch: + +```json [settings] +{ + "git": { + "diff_base": "default_branch" + } +} +``` + ## Go to Definition Fallback - Description: What to do when the {#action editor::GoToDefinition} action fails to find a definition