Skip to content

Commit 5e1fd39

Browse files
git: Add diff_base setting for showing changes since the default branch (zed-industries#61501)
# 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 zed-industries#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>
1 parent 5e03f2d commit 5e1fd39

35 files changed

Lines changed: 1737 additions & 460 deletions

File tree

assets/settings/default.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1712,6 +1712,8 @@
17121712
// 2. Show unstaged hunks hollow and staged hunks filled:
17131713
// "hunk_style": "unstaged_hollow"
17141714
"hunk_style": "staged_hollow",
1715+
// Whether git features diff against HEAD ("head") or the default branch ("default_branch").
1716+
"diff_base": "head",
17151717
// Should the name or path be displayed first in the git view.
17161718
// "path_style": "file_name_first" or "file_path_first"
17171719
"path_style": "file_name_first",

crates/acp_thread/src/diff.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,13 @@ impl Diff {
8989
let language = buffer.read(cx).language().cloned();
9090
let language_registry = buffer.read(cx).language_registry();
9191
let buffer_diff = cx.new(|cx| {
92-
BufferDiff::new_unchanged(&buffer_text_snapshot, language, language_registry, cx)
92+
BufferDiff::new_unchanged(
93+
&buffer_text_snapshot,
94+
language,
95+
language_registry,
96+
buffer_diff::DiffBaseKind::Custom,
97+
cx,
98+
)
9399
});
94100

95101
let multibuffer = cx.new(|cx| {
@@ -389,7 +395,15 @@ async fn build_buffer_diff(
389395
let buffer = cx.update(|cx| buffer.read(cx).snapshot());
390396
let base_text = base_text_exists.then(|| old_text);
391397

392-
let diff = cx.new(|cx| BufferDiff::new(&buffer, language, language_registry, cx));
398+
let diff = cx.new(|cx| {
399+
BufferDiff::new(
400+
&buffer,
401+
language,
402+
language_registry,
403+
buffer_diff::DiffBaseKind::Custom,
404+
cx,
405+
)
406+
});
393407
diff.update(cx, |diff, cx| {
394408
diff.set_base_text(base_text, buffer.text, cx)
395409
})

crates/action_log/src/action_log.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,15 @@ impl ActionLog {
159159
let text_snapshot = buffer.read(cx).text_snapshot();
160160
let language = buffer.read(cx).language().cloned();
161161
let language_registry = buffer.read(cx).language_registry();
162-
let diff =
163-
cx.new(|cx| BufferDiff::new(&text_snapshot, language, language_registry, cx));
162+
let diff = cx.new(|cx| {
163+
BufferDiff::new(
164+
&text_snapshot,
165+
language,
166+
language_registry,
167+
buffer_diff::DiffBaseKind::Custom,
168+
cx,
169+
)
170+
});
164171
let (diff_update_tx, diff_update_rx) = mpsc::unbounded();
165172
let diff_base;
166173
let unreviewed_edits;

crates/buffer_diff/src/buffer_diff.rs

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,23 @@ pub struct BufferDiff {
2525
diff_snapshot: Option<BufferDiffSnapshot>,
2626
secondary_diff: Option<Entity<BufferDiff>>,
2727
buffer_snapshot: text::BufferSnapshot,
28+
base_kind: DiffBaseKind,
29+
}
30+
31+
/// Where this diff's base text came from. Only diffs whose base is HEAD
32+
/// support staging and restoring hunks; a diff against any other base (e.g.
33+
/// the merge base with another branch) would rewrite committed work.
34+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35+
pub enum DiffBaseKind {
36+
/// The buffer's committed (HEAD) content.
37+
Head,
38+
/// The buffer's index (staged) content.
39+
Index,
40+
/// An arbitrary blob, such as the merge base with another branch.
41+
Oid,
42+
/// Arbitrary caller-provided text, such as an agent's original text,
43+
/// the clipboard, or another file.
44+
Custom,
2845
}
2946

3047
#[derive(Clone)]
@@ -1553,6 +1570,7 @@ impl BufferDiff {
15531570
buffer: &text::BufferSnapshot,
15541571
language: Option<Arc<Language>>,
15551572
language_registry: Option<Arc<LanguageRegistry>>,
1573+
base_kind: DiffBaseKind,
15561574
cx: &mut App,
15571575
) -> Self {
15581576
let base_text = cx.new(|cx| {
@@ -1571,12 +1589,14 @@ impl BufferDiff {
15711589
diff_snapshot: None,
15721590
buffer_snapshot: buffer.clone(),
15731591
secondary_diff: None,
1592+
base_kind,
15741593
}
15751594
}
15761595

15771596
pub fn new_with_base_text_buffer(
15781597
buffer: &text::BufferSnapshot,
15791598
base_text_buffer: Entity<language::Buffer>,
1599+
base_kind: DiffBaseKind,
15801600
_cx: &mut App,
15811601
) -> Self {
15821602
BufferDiff {
@@ -1585,13 +1605,15 @@ impl BufferDiff {
15851605
diff_snapshot: None,
15861606
buffer_snapshot: buffer.clone(),
15871607
secondary_diff: None,
1608+
base_kind,
15881609
}
15891610
}
15901611

15911612
pub fn new_unchanged(
15921613
buffer: &text::BufferSnapshot,
15931614
language: Option<Arc<Language>>,
15941615
language_registry: Option<Arc<LanguageRegistry>>,
1616+
base_kind: DiffBaseKind,
15951617
cx: &mut Context<Self>,
15961618
) -> Self {
15971619
let base_text = buffer.text();
@@ -1622,6 +1644,7 @@ impl BufferDiff {
16221644
diff_snapshot: Some(diff_snapshot),
16231645
buffer_snapshot: buffer.clone(),
16241646
secondary_diff: None,
1647+
base_kind,
16251648
}
16261649
}
16271650

@@ -1631,7 +1654,7 @@ impl BufferDiff {
16311654
buffer: &text::BufferSnapshot,
16321655
cx: &mut Context<Self>,
16331656
) -> Self {
1634-
let mut this = BufferDiff::new(buffer, None, None, cx);
1657+
let mut this = BufferDiff::new(buffer, None, None, DiffBaseKind::Head, cx);
16351658
let mut base_text = base_text.to_owned();
16361659
text::LineEnding::normalize(&mut base_text);
16371660
let base_text_buffer = cx.new(|cx| {
@@ -1655,6 +1678,16 @@ impl BufferDiff {
16551678
self.secondary_diff = Some(diff);
16561679
}
16571680

1681+
pub fn base_kind(&self) -> DiffBaseKind {
1682+
self.base_kind
1683+
}
1684+
1685+
/// Whether hunks in this diff can be staged or restored: true only when
1686+
/// the diff's base is HEAD.
1687+
pub fn is_stageable(&self) -> bool {
1688+
self.base_kind == DiffBaseKind::Head
1689+
}
1690+
16581691
pub fn secondary_diff(&self) -> Option<Entity<BufferDiff>> {
16591692
self.secondary_diff.clone()
16601693
}
@@ -2449,7 +2482,8 @@ mod tests {
24492482
],
24502483
);
24512484

2452-
diff = cx.update(|cx| BufferDiff::new(&buffer, None, None, cx).snapshot(cx));
2485+
diff = cx
2486+
.update(|cx| BufferDiff::new(&buffer, None, None, DiffBaseKind::Head, cx).snapshot(cx));
24532487
assert_hunks::<&str, _>(
24542488
diff.hunks_intersecting_range(
24552489
Anchor::min_max_range_for_buffer(buffer.remote_id()),
@@ -3154,7 +3188,8 @@ mod tests {
31543188

31553189
let mut buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), buffer_text_1);
31563190

3157-
let empty_diff = cx.update(|cx| BufferDiff::new(&buffer, None, None, cx).snapshot(cx));
3191+
let empty_diff = cx
3192+
.update(|cx| BufferDiff::new(&buffer, None, None, DiffBaseKind::Head, cx).snapshot(cx));
31583193
let diff_1 = BufferDiffSnapshot::new_sync(&buffer, base_text.clone(), cx);
31593194
let DiffChanged {
31603195
changed_range,
@@ -4312,7 +4347,8 @@ mod tests {
43124347
);
43134348
let buffer_snapshot = buffer.snapshot();
43144349

4315-
let diff = cx.new(|cx| BufferDiff::new(&buffer_snapshot, None, None, cx));
4350+
let diff =
4351+
cx.new(|cx| BufferDiff::new(&buffer_snapshot, None, None, DiffBaseKind::Head, cx));
43164352
diff.update(cx, |diff, cx| {
43174353
diff.set_base_text(Some(Arc::from(base_text_crlf)), buffer_snapshot.clone(), cx)
43184354
})

crates/edit_prediction_ui/src/rate_prediction_modal.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,7 @@ impl RatePredictionsModal {
605605
&predicted_buffer_snapshot.text,
606606
predicted_buffer_snapshot.language().cloned(),
607607
predicted_buffer.read(cx).language_registry(),
608+
buffer_diff::DiffBaseKind::Custom,
608609
cx,
609610
)
610611
});
@@ -711,6 +712,7 @@ impl RatePredictionsModal {
711712
&expected_buffer_snapshot.text,
712713
expected_buffer_snapshot.language().cloned(),
713714
expected_buffer.read(cx).language_registry(),
715+
buffer_diff::DiffBaseKind::Custom,
714716
cx,
715717
)
716718
});

crates/editor/src/editor.rs

Lines changed: 52 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,7 @@ pub use git::{
113113
set_blame_renderer,
114114
};
115115
pub(crate) use git::{DiffHunkKey, StoredReviewComment};
116-
use git::{
117-
DiffReviewDragState, DiffReviewOverlay, InlineBlamePopover, update_uncommitted_diff_for_buffer,
118-
};
116+
use git::{DiffReviewDragState, DiffReviewOverlay, InlineBlamePopover};
119117
pub(crate) use git::{DisplayDiffHunk, PhantomDiffReviewIndicator};
120118
pub use hover_popover::hover_markdown_style;
121119
pub use inlays::Inlay;
@@ -2170,20 +2168,41 @@ impl Editor {
21702168
));
21712169
let git_store = project.read(cx).git_store().clone();
21722170
let project = project.clone();
2173-
project_subscriptions.push(cx.subscribe(&git_store, move |this, _, event, cx| {
2174-
if let GitStoreEvent::RepositoryAdded = event {
2175-
this.load_diff_task = Some(
2176-
update_uncommitted_diff_for_buffer(
2177-
cx.entity(),
2178-
&project,
2179-
this.buffer.read(cx).all_buffers(),
2180-
this.buffer.clone(),
2181-
cx,
2182-
)
2183-
.shared(),
2184-
);
2185-
}
2186-
}));
2171+
project_subscriptions.push(cx.subscribe(
2172+
&git_store,
2173+
move |this, git_store, event, cx| {
2174+
let buffers = match event {
2175+
GitStoreEvent::RepositoryAdded | GitStoreEvent::DiffBaseChanged(None) => {
2176+
this.buffer.read(cx).all_buffers()
2177+
}
2178+
GitStoreEvent::DiffBaseChanged(Some(repo_id)) => this
2179+
.buffer
2180+
.read(cx)
2181+
.all_buffers()
2182+
.into_iter()
2183+
.filter(|buffer| {
2184+
git_store
2185+
.read(cx)
2186+
.repository_and_path_for_buffer_id(
2187+
buffer.read(cx).remote_id(),
2188+
cx,
2189+
)
2190+
.is_some_and(|(repo, _)| repo.read(cx).id == *repo_id)
2191+
})
2192+
.collect(),
2193+
_ => return,
2194+
};
2195+
if buffers.is_empty() {
2196+
return;
2197+
}
2198+
let task = this.update_uncommitted_diff_for_buffer(&project, buffers, cx);
2199+
if matches!(event, GitStoreEvent::DiffBaseChanged(Some(_))) {
2200+
task.detach();
2201+
} else {
2202+
this.load_diff_task = Some(task.shared());
2203+
}
2204+
},
2205+
));
21872206
}
21882207

21892208
let buffer_snapshot = multi_buffer.read(cx).snapshot(cx);
@@ -2222,18 +2241,7 @@ impl Editor {
22222241
};
22232242

22242243
let mut code_action_providers = Vec::new();
2225-
let mut load_uncommitted_diff = None;
22262244
if let Some(project) = project.clone() {
2227-
load_uncommitted_diff = Some(
2228-
update_uncommitted_diff_for_buffer(
2229-
cx.entity(),
2230-
&project,
2231-
multi_buffer.read(cx).all_buffers(),
2232-
multi_buffer.clone(),
2233-
cx,
2234-
)
2235-
.shared(),
2236-
);
22372245
code_action_providers.push(Rc::new(project) as Rc<_>);
22382246
}
22392247

@@ -2447,7 +2455,7 @@ impl Editor {
24472455
serialize_selections: Task::ready(()),
24482456
serialize_folds: Task::ready(()),
24492457
text_style_refinement: None,
2450-
load_diff_task: load_uncommitted_diff,
2458+
load_diff_task: None,
24512459
diff_hunk_delegate: None,
24522460
minimap: None,
24532461
change_list: ChangeList::new(),
@@ -2474,6 +2482,18 @@ impl Editor {
24742482
colorize_brackets_task: Task::ready(()),
24752483
};
24762484

2485+
if let Some(project) = editor.project.clone() {
2486+
editor.load_diff_task = Some(
2487+
editor
2488+
.update_uncommitted_diff_for_buffer(
2489+
&project,
2490+
multi_buffer.read(cx).all_buffers(),
2491+
cx,
2492+
)
2493+
.shared(),
2494+
);
2495+
}
2496+
24772497
if is_minimap {
24782498
return editor;
24792499
}
@@ -9631,16 +9651,10 @@ impl Editor {
96319651
self.refresh_document_highlights(cx);
96329652
let buffer_id = buffer.read(cx).remote_id();
96339653
if self.buffer.read(cx).diff_for(buffer_id).is_none()
9634-
&& let Some(project) = &self.project
9654+
&& let Some(project) = self.project.clone()
96359655
{
9636-
update_uncommitted_diff_for_buffer(
9637-
cx.entity(),
9638-
project,
9639-
[buffer.clone()],
9640-
self.buffer.clone(),
9641-
cx,
9642-
)
9643-
.detach();
9656+
self.update_uncommitted_diff_for_buffer(&project, [buffer.clone()], cx)
9657+
.detach();
96449658
}
96459659
self.register_visible_buffers(cx);
96469660
self.update_lsp_data(Some(buffer_id), window, cx);

0 commit comments

Comments
 (0)