git: Add diff_base setting for showing changes since the default branch (#61501)
Some checks are pending
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Waiting to run
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions

# Objective

Let git indicators — the editor gutter, file colors, and `git::Diff` —
show all changes on the current branch relative to its merge base with
the default branch, instead of only uncommitted changes.

Supersedes #60398; thanks to @samuelcolvin for the original
implementation and motivation.

Closes FR-135

## Solution

- New `git.diff_base` setting (`"head"` | `"default_branch"`), applied
live and toggleable per session from the editor controls menu ("Diff
Against Default Branch").
- Statuses come from a real merge-base-to-worktree tree diff (`git diff
--merge-base`), so local edits that revert branch changes correctly show
as unchanged.
- `GitStore` shares one `DiffBufferList` per repository with the Branch
Diff view; `repo_snapshots` and `project_path_git_status` keep returning
index/worktree truth, while display surfaces use separate `display_*`
APIs.
- `BufferDiff` now records what its base is (`DiffBaseKind`); hunks
whose base isn't HEAD are read-only in the gutter — stage/restore
buttons and keybindings are inert, so committed work can't be silently
rewritten.
- `git::Diff` follows the setting; new `git::DiffHead` always opens the
HEAD diff; `git::BranchDiff` is renamed `git::DiffBranch` (deprecated
alias kept).

Tradeoffs / known limitations:

- Hunk-level staging is unavailable while in `default_branch` mode
(whole-file staging via the git panel still works). Staging just the
uncommitted sub-ranges of a branch hunk is a follow-up.
- Remote hosts running an older server ignore the new
`GetTreeDiff.includes_worktree` proto field and degrade to
committed-changes-only branch diffs.
- Repositories with no resolvable default branch fall back to
HEAD-relative behavior; a failed first resolution retries on the next
branch-list change.

## Testing

- Real-git-repo tests for the merge-base-to-worktree diff's edge cases:
files recreated after index deletion, committed deletions recreated on
disk, and symlinks.
- GPUI tests for status semantics (a branch change reverted on disk
shows clean), `git::Diff` routing, live setting changes, and read-only
hunk enforcement (restore/stage leave buffer and index untouched).

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

---

Release Notes:

- Git: Added a `git.diff_base` setting (`"head"` or `"default_branch"`)
that makes the editor gutter, file colors, and diff view show all
changes on the current branch since its merge base with the default
branch, instead of only uncommitted changes.

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>
This commit is contained in:
Mikayla Maki 2026-07-31 12:57:13 -07:00 committed by GitHub
parent 5e03f2d387
commit 5e1fd392f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1742 additions and 465 deletions

View file

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

View file

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

View file

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

View file

@ -25,6 +25,23 @@ pub struct BufferDiff {
diff_snapshot: Option<BufferDiffSnapshot>,
secondary_diff: Option<Entity<BufferDiff>>,
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<Arc<Language>>,
language_registry: Option<Arc<LanguageRegistry>>,
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<language::Buffer>,
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<Arc<Language>>,
language_registry: Option<Arc<LanguageRegistry>>,
base_kind: DiffBaseKind,
cx: &mut Context<Self>,
) -> 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>,
) -> 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<Entity<BufferDiff>> {
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)
})

View file

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

View file

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

View file

@ -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::<HashMap<_, _>>()
);
}
#[gpui::test]
async fn test_toggle_selected_diff_hunks(executor: BackgroundExecutor, cx: &mut TestAppContext) {
init_test(cx, |_| {});

View file

@ -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<ResolvedDiffHunks>,
_editor: &mut Editor,
_window: &mut Window,
_cx: &mut Context<Editor>,
) {
}
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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<Editor>,
project: &Entity<Project>,
buffers: impl IntoIterator<Item = Entity<Buffer>>,
buffer: Entity<MultiBuffer>,
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<Project>,
buffers: impl IntoIterator<Item = Entity<Buffer>>,
cx: &mut Context<Self>,
) -> 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);
}
});
})
}
}

View file

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

View file

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

View file

@ -207,29 +207,44 @@ impl GitRepository for FakeGitRepository {
async move { fut.await.unwrap_or_default() }.boxed()
}
fn diff_tree(&self, _request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>> {
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<TreeDiff>> {
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::<HashMap<_, _>>()
});
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::<HashSet<_>>();
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()

View file

@ -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<TreeDiff>> {
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::<TreeDiff>()?;
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::<GitStatus>()?;
// 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();

View file

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

View file

@ -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>) {
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::<Self>(cx);
}
fn deploy_branch_diff(
pub(crate) fn deploy_branch_diff(
workspace: &mut Workspace,
_: &DeployBranchDiff,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
@ -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<Project>,
intended_repo: Entity<Repository>,
base_ref: SharedString,
branch_diff: Option<Entity<diff_buffer_list::DiffBufferList>>,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
@ -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<Workspace>,
base_ref: SharedString,
repo: Entity<Repository>,
branch_diff: Option<Entity<diff_buffer_list::DiffBufferList>>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Entity<Self>>> {
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<Workspace>,
base_ref: SharedString,
repo: Option<Entity<Repository>>,
branch_diff: Option<Entity<diff_buffer_list::DiffBufferList>>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<Entity<Repository>>, cx: &mut Context<Self>) {
self.diff.update(cx, |diff, cx| diff.set_repo(repo, cx));
}
fn set_merge_base(&mut self, base_ref: SharedString, cx: &mut Context<Self>) {
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<Arc<RelPath>, Option<FileStatus>> =
editor.update(cx, |editor, cx| {
@ -1151,6 +1234,7 @@ mod tests {
workspace.clone(),
"topic".into(),
repository,
None,
window,
cx,
)

View file

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

View file

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

View file

@ -1201,7 +1201,9 @@ impl GitPanel {
.ok();
}
GitStoreEvent::RepositoryUpdated(_, _, _) => {}
GitStoreEvent::JobsUpdated | GitStoreEvent::ConflictsUpdated => {}
GitStoreEvent::JobsUpdated
| GitStoreEvent::ConflictsUpdated
| GitStoreEvent::DiffBaseChanged(_) => {}
},
)
.detach();

View file

@ -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>) {
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::<ProjectDiff>(cx);
}
@ -95,6 +104,11 @@ impl ProjectDiff {
window: &mut Window,
cx: &mut Context<Workspace>,
) {
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>,
) -> 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<Result<Entity<Self>>> {
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::<SettingsStore, _>(|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::<BranchDiff>(cx).is_some());
assert_eq!(workspace.items_of_type::<ProjectDiff>(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::<ProjectDiff>(cx).is_some());
});
cx.update(|_window, cx| {
cx.update_global::<SettingsStore, _>(|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);

View file

@ -219,8 +219,8 @@ impl StagedDiff {
window: &mut Window,
cx: &mut Context<Self>,
) -> 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(

View file

@ -120,6 +120,7 @@ impl TextDiffView {
BufferDiff::new_with_base_text_buffer(
&source_buffer_snapshot.text,
clipboard_buffer.clone(),
buffer_diff::DiffBaseKind::Custom,
cx,
)
});

View file

@ -273,8 +273,8 @@ impl UnstagedDiff {
window: &mut Window,
cx: &mut Context<Self>,
) -> 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(

View file

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

View file

@ -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<BufferId> {
match self {
Anchor::Min | Anchor::Max => None,
Anchor::Excerpt(excerpt_anchor) => Some(excerpt_anchor.buffer_id()),
}
}
pub(crate) fn text_anchor(&self) -> Option<text::Anchor> {
match self {
Anchor::Min | Anchor::Max => None,

View file

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

View file

@ -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<BufferStore>,
worktree_store: Entity<WorktreeStore>,
repositories: HashMap<RepositoryId, Entity<Repository>>,
diff_base: GitDiffBaseSetting,
display_diffs: HashMap<RepositoryId, DisplayDiff>,
worktree_ids: HashMap<RepositoryId, HashSet<WorktreeId>>,
active_repo_id: Option<RepositoryId>,
#[allow(clippy::type_complexity)]
@ -112,6 +114,12 @@ pub struct GitStore {
_subscriptions: Vec<Subscription>,
}
#[derive(Default)]
struct DisplayDiff {
buffers: Option<(Entity<diff_buffer_list::DiffBufferList>, Subscription)>,
refresh: Option<Task<()>>,
}
#[derive(Default)]
struct SharedDiffs {
unstaged: Option<Entity<BufferDiff>>,
@ -618,6 +626,7 @@ pub enum GitStoreEvent {
JobsUpdated,
ConflictsUpdated,
GlobalConfigurationUpdated,
DiffBaseChanged(Option<RepositoryId>),
}
impl EventEmitter<RepositoryEvent> for Repository {}
@ -694,6 +703,11 @@ impl GitStore {
repo.schedule_scan(None, cx);
})
}
let display_repo_ids =
this.display_diffs.keys().copied().collect::<Vec<_>>();
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::<SettingsStore>(|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<Buffer>,
cx: &mut Context<Self>,
) -> Task<Result<Entity<BufferDiff>>> {
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<Arc<Path>> = 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<proto::GetTreeDiffResponse> {
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<RepositoryId, RepositorySnapshot> {
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<Self>) {
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::<Vec<_>>();
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<FileStatus> {
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<FileStatus> {
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<Repository>,
repo_path: &RepoPath,
cx: &App,
) -> Option<FileStatus> {
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<Entity<diff_buffer_list::DiffBufferList>> {
let (buffers, _) = self.display_diffs.get(&repo_id)?.buffers.as_ref()?;
Some(buffers.clone())
}
pub fn ensure_display_diff(
&mut self,
repo: Entity<Repository>,
resolved_ref: SharedString,
cx: &mut Context<Self>,
) -> Entity<diff_buffer_list::DiffBufferList> {
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<Self>) {
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<Option<SharedString>> =
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<RepoPath>) -> Vec<RepoPath> {
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<_>>(),
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<dyn Fs> = FakeFs::new(cx.executor());

View file

@ -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<Entity<Repository>>,
project: Entity<Project>,
base_commit: Option<SharedString>,
head_commit: Option<SharedString>,
git_store: WeakEntity<GitStore>,
committed_tree_diff: Option<TreeDiff>,
tree_diff: Option<TreeDiff>,
tree_diff_update_needed: bool,
statuses_by_path: Option<SumTree<StatusEntry>>,
tree_diff_base_task: Option<Task<()>>,
_subscription: Subscription,
update_needed: postage::watch::Sender<()>,
@ -59,53 +60,51 @@ impl EventEmitter<BranchDiffEvent> for DiffBufferList {}
impl DiffBufferList {
pub fn new(
source: DiffBase,
project: Entity<Project>,
window: &mut Window,
git_store: Entity<GitStore>,
repo: Option<Entity<Repository>>,
cx: &mut Context<Self>,
) -> 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<Self>) {
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<Self>,
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<FileStatus> {
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<FileStatus>,
diff_from_merge_base: Option<&TreeDiffStatus>,
) -> Option<FileStatus> {
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<FileStatus> {
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<SumTree<StatusEntry>> {
self.statuses_by_path.clone()
}
pub fn base_oid_for_path(&self, path: &RepoPath) -> Option<Option<git::Oid>> {
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<Self>) {
@ -300,29 +244,43 @@ impl DiffBufferList {
}
pub async fn reload_tree_diff(this: WeakEntity<Self>, 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<git::status::TreeDiffStatus>,
project_path: crate::ProjectPath,
repo: Entity<Repository>,
cx: &Context<'_, Project>,
git_store: Entity<GitStore>,
cx: &Context<Self>,
) -> Task<Result<LoadedDiffBuffer>> {
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<StatusEntry> {
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::<HashMap<_, _>>();
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::<Vec<_>>();
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 {

View file

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

View file

@ -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<SelectedEntry> = None;

View file

@ -547,6 +547,7 @@ message GetTreeDiff {
bool is_merge = 3;
string base = 4;
string head = 5;
bool includes_worktree = 6;
}
message GetTreeDiffResponse {

View file

@ -551,6 +551,10 @@ pub struct GitSettings {
///
/// Default: staged_hollow
pub hunk_style: Option<GitHunkStyleSetting>,
/// Which base git features (gutter, file colors, git::Diff) diff against.
///
/// Default: head
pub diff_base: Option<GitDiffBaseSetting>,
/// 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,

View file

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

View file

@ -559,6 +559,7 @@ fn init_renderers(cx: &mut App) {
.add_basic_renderer::<settings::SidebarDockPosition>(render_dropdown)
.add_basic_renderer::<settings::GitGutterSetting>(render_dropdown)
.add_basic_renderer::<settings::GitHunkStyleSetting>(render_dropdown)
.add_basic_renderer::<settings::GitDiffBaseSetting>(render_dropdown)
.add_basic_renderer::<settings::GitPathStyle>(render_dropdown)
.add_basic_renderer::<settings::InlineBlameLocation>(render_dropdown)
.add_basic_renderer::<settings::DiagnosticSeverityContent>(render_dropdown)

View file

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

View file

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

View file

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