From 4ac67ac5aef9c35333eaaf7d5e3522ca74f4edd1 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 4 Jun 2025 19:54:24 +0200 Subject: [PATCH] Automatically keep edits if they are included in a commit (#32093) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release Notes: - Improved the review experience in the agent panel. Now, when you commit changes (generated by the AI agent) using Git, Zed will automatically dismiss the agent’s review UI for those changes. This means you won’t have to manually “keep” or approve changes twice—just commit, and you’re done. --- Cargo.lock | 1 + crates/assistant_tool/Cargo.toml | 1 + crates/assistant_tool/src/action_log.rs | 556 ++++++++++++++---- crates/collab/src/tests/integration_tests.rs | 3 + crates/editor/src/editor_tests.rs | 1 + crates/editor/src/test/editor_test_context.rs | 1 + crates/fs/src/fs.rs | 8 +- crates/git_ui/src/project_diff.rs | 2 + crates/project/src/git_store/git_traversal.rs | 1 + crates/project/src/project_tests.rs | 5 + .../remote_server/src/remote_editing_tests.rs | 2 + 11 files changed, 465 insertions(+), 116 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5638413119..288bc81a57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -631,6 +631,7 @@ name = "assistant_tool" version = "0.1.0" dependencies = [ "anyhow", + "async-watch", "buffer_diff", "clock", "collections", diff --git a/crates/assistant_tool/Cargo.toml b/crates/assistant_tool/Cargo.toml index a7b388a753..9409e2063f 100644 --- a/crates/assistant_tool/Cargo.toml +++ b/crates/assistant_tool/Cargo.toml @@ -13,6 +13,7 @@ path = "src/assistant_tool.rs" [dependencies] anyhow.workspace = true +async-watch.workspace = true buffer_diff.workspace = true clock.workspace = true collections.workspace = true diff --git a/crates/assistant_tool/src/action_log.rs b/crates/assistant_tool/src/action_log.rs index ea2bf20f37..69c7b06366 100644 --- a/crates/assistant_tool/src/action_log.rs +++ b/crates/assistant_tool/src/action_log.rs @@ -1,7 +1,7 @@ use anyhow::{Context as _, Result}; use buffer_diff::BufferDiff; use collections::BTreeMap; -use futures::{StreamExt, channel::mpsc}; +use futures::{FutureExt, StreamExt, channel::mpsc}; use gpui::{App, AppContext, AsyncApp, Context, Entity, Subscription, Task, WeakEntity}; use language::{Anchor, Buffer, BufferEvent, DiskState, Point, ToPoint}; use project::{Project, ProjectItem, lsp_store::OpenLspBufferHandle}; @@ -92,21 +92,21 @@ impl ActionLog { let diff = cx.new(|cx| BufferDiff::new(&text_snapshot, cx)); let (diff_update_tx, diff_update_rx) = mpsc::unbounded(); let diff_base; - let unreviewed_changes; + let unreviewed_edits; if is_created { diff_base = Rope::default(); - unreviewed_changes = Patch::new(vec![Edit { + unreviewed_edits = Patch::new(vec![Edit { old: 0..1, new: 0..text_snapshot.max_point().row + 1, }]) } else { diff_base = buffer.read(cx).as_rope().clone(); - unreviewed_changes = Patch::default(); + unreviewed_edits = Patch::default(); } TrackedBuffer { buffer: buffer.clone(), diff_base, - unreviewed_changes, + unreviewed_edits: unreviewed_edits, snapshot: text_snapshot.clone(), status, version: buffer.read(cx).version(), @@ -175,7 +175,7 @@ impl ActionLog { .map_or(false, |file| file.disk_state() != DiskState::Deleted) { // If the buffer had been deleted by a tool, but it got - // resurrected externally, we want to clear the changes we + // resurrected externally, we want to clear the edits we // were tracking and reset the buffer's state. self.tracked_buffers.remove(&buffer); self.track_buffer_internal(buffer, false, cx); @@ -188,108 +188,274 @@ impl ActionLog { async fn maintain_diff( this: WeakEntity, buffer: Entity, - mut diff_update: mpsc::UnboundedReceiver<(ChangeAuthor, text::BufferSnapshot)>, + mut buffer_updates: mpsc::UnboundedReceiver<(ChangeAuthor, text::BufferSnapshot)>, cx: &mut AsyncApp, ) -> Result<()> { - while let Some((author, buffer_snapshot)) = diff_update.next().await { - let (rebase, diff, language, language_registry) = - this.read_with(cx, |this, cx| { - let tracked_buffer = this - .tracked_buffers - .get(&buffer) - .context("buffer not tracked")?; + let git_store = this.read_with(cx, |this, cx| this.project.read(cx).git_store().clone())?; + let git_diff = this + .update(cx, |this, cx| { + this.project.update(cx, |project, cx| { + project.open_uncommitted_diff(buffer.clone(), cx) + }) + })? + .await + .ok(); + let buffer_repo = git_store.read_with(cx, |git_store, cx| { + git_store.repository_and_path_for_buffer_id(buffer.read(cx).remote_id(), cx) + })?; - let rebase = cx.background_spawn({ - let mut base_text = tracked_buffer.diff_base.clone(); - let old_snapshot = tracked_buffer.snapshot.clone(); - let new_snapshot = buffer_snapshot.clone(); - let unreviewed_changes = tracked_buffer.unreviewed_changes.clone(); - async move { - let edits = diff_snapshots(&old_snapshot, &new_snapshot); - if let ChangeAuthor::User = author { - apply_non_conflicting_edits( - &unreviewed_changes, - edits, - &mut base_text, - new_snapshot.as_rope(), - ); + let (git_diff_updates_tx, mut git_diff_updates_rx) = async_watch::channel(()); + let _repo_subscription = + if let Some((git_diff, (buffer_repo, _))) = git_diff.as_ref().zip(buffer_repo) { + cx.update(|cx| { + let mut old_head = buffer_repo.read(cx).head_commit.clone(); + Some(cx.subscribe(git_diff, move |_, event, cx| match event { + buffer_diff::BufferDiffEvent::DiffChanged { .. } => { + let new_head = buffer_repo.read(cx).head_commit.clone(); + if new_head != old_head { + old_head = new_head; + git_diff_updates_tx.send(()).ok(); } - (Arc::new(base_text.to_string()), base_text) } - }); + _ => {} + })) + })? + } else { + None + }; - anyhow::Ok(( - rebase, - tracked_buffer.diff.clone(), - tracked_buffer.buffer.read(cx).language().cloned(), - tracked_buffer.buffer.read(cx).language_registry(), - )) - })??; - - let (new_base_text, new_diff_base) = rebase.await; - let diff_snapshot = BufferDiff::update_diff( - diff.clone(), - buffer_snapshot.clone(), - Some(new_base_text), - true, - false, - language, - language_registry, - cx, - ) - .await; - - let mut unreviewed_changes = Patch::default(); - if let Ok(diff_snapshot) = diff_snapshot { - unreviewed_changes = cx - .background_spawn({ - let diff_snapshot = diff_snapshot.clone(); - let buffer_snapshot = buffer_snapshot.clone(); - let new_diff_base = new_diff_base.clone(); - async move { - let mut unreviewed_changes = Patch::default(); - for hunk in diff_snapshot.hunks_intersecting_range( - Anchor::MIN..Anchor::MAX, - &buffer_snapshot, - ) { - let old_range = new_diff_base - .offset_to_point(hunk.diff_base_byte_range.start) - ..new_diff_base.offset_to_point(hunk.diff_base_byte_range.end); - let new_range = hunk.range.start..hunk.range.end; - unreviewed_changes.push(point_to_row_edit( - Edit { - old: old_range, - new: new_range, - }, - &new_diff_base, - &buffer_snapshot.as_rope(), - )); - } - unreviewed_changes - } - }) - .await; - - diff.update(cx, |diff, cx| { - diff.set_snapshot(diff_snapshot, &buffer_snapshot, cx) - })?; + loop { + futures::select_biased! { + buffer_update = buffer_updates.next() => { + if let Some((author, buffer_snapshot)) = buffer_update { + Self::track_edits(&this, &buffer, author, buffer_snapshot, cx).await?; + } else { + break; + } + } + _ = git_diff_updates_rx.changed().fuse() => { + if let Some(git_diff) = git_diff.as_ref() { + Self::keep_committed_edits(&this, &buffer, &git_diff, cx).await?; + } + } } - this.update(cx, |this, cx| { - let tracked_buffer = this - .tracked_buffers - .get_mut(&buffer) - .context("buffer not tracked")?; - tracked_buffer.diff_base = new_diff_base; - tracked_buffer.snapshot = buffer_snapshot; - tracked_buffer.unreviewed_changes = unreviewed_changes; - cx.notify(); - anyhow::Ok(()) - })??; } Ok(()) } + async fn track_edits( + this: &WeakEntity, + buffer: &Entity, + author: ChangeAuthor, + buffer_snapshot: text::BufferSnapshot, + cx: &mut AsyncApp, + ) -> Result<()> { + let rebase = this.read_with(cx, |this, cx| { + let tracked_buffer = this + .tracked_buffers + .get(buffer) + .context("buffer not tracked")?; + + let rebase = cx.background_spawn({ + let mut base_text = tracked_buffer.diff_base.clone(); + let old_snapshot = tracked_buffer.snapshot.clone(); + let new_snapshot = buffer_snapshot.clone(); + let unreviewed_edits = tracked_buffer.unreviewed_edits.clone(); + async move { + let edits = diff_snapshots(&old_snapshot, &new_snapshot); + if let ChangeAuthor::User = author { + apply_non_conflicting_edits( + &unreviewed_edits, + edits, + &mut base_text, + new_snapshot.as_rope(), + ); + } + (Arc::new(base_text.to_string()), base_text) + } + }); + + anyhow::Ok(rebase) + })??; + let (new_base_text, new_diff_base) = rebase.await; + Self::update_diff( + this, + buffer, + buffer_snapshot, + new_base_text, + new_diff_base, + cx, + ) + .await + } + + async fn keep_committed_edits( + this: &WeakEntity, + buffer: &Entity, + git_diff: &Entity, + cx: &mut AsyncApp, + ) -> Result<()> { + let buffer_snapshot = this.read_with(cx, |this, _cx| { + let tracked_buffer = this + .tracked_buffers + .get(buffer) + .context("buffer not tracked")?; + anyhow::Ok(tracked_buffer.snapshot.clone()) + })??; + let (new_base_text, new_diff_base) = this + .read_with(cx, |this, cx| { + let tracked_buffer = this + .tracked_buffers + .get(buffer) + .context("buffer not tracked")?; + let old_unreviewed_edits = tracked_buffer.unreviewed_edits.clone(); + let agent_diff_base = tracked_buffer.diff_base.clone(); + let git_diff_base = git_diff.read(cx).base_text().as_rope().clone(); + let buffer_text = tracked_buffer.snapshot.as_rope().clone(); + anyhow::Ok(cx.background_spawn(async move { + let mut old_unreviewed_edits = old_unreviewed_edits.into_iter().peekable(); + let committed_edits = language::line_diff( + &agent_diff_base.to_string(), + &git_diff_base.to_string(), + ) + .into_iter() + .map(|(old, new)| Edit { old, new }); + + let mut new_agent_diff_base = agent_diff_base.clone(); + let mut row_delta = 0i32; + for committed in committed_edits { + while let Some(unreviewed) = old_unreviewed_edits.peek() { + // If the committed edit matches the unreviewed + // edit, assume the user wants to keep it. + if committed.old == unreviewed.old { + let unreviewed_new = + buffer_text.slice_rows(unreviewed.new.clone()).to_string(); + let committed_new = + git_diff_base.slice_rows(committed.new.clone()).to_string(); + if unreviewed_new == committed_new { + let old_byte_start = + new_agent_diff_base.point_to_offset(Point::new( + (unreviewed.old.start as i32 + row_delta) as u32, + 0, + )); + let old_byte_end = + new_agent_diff_base.point_to_offset(cmp::min( + Point::new( + (unreviewed.old.end as i32 + row_delta) as u32, + 0, + ), + new_agent_diff_base.max_point(), + )); + new_agent_diff_base + .replace(old_byte_start..old_byte_end, &unreviewed_new); + row_delta += + unreviewed.new_len() as i32 - unreviewed.old_len() as i32; + } + } else if unreviewed.old.start >= committed.old.end { + break; + } + + old_unreviewed_edits.next().unwrap(); + } + } + + ( + Arc::new(new_agent_diff_base.to_string()), + new_agent_diff_base, + ) + })) + })?? + .await; + + Self::update_diff( + this, + buffer, + buffer_snapshot, + new_base_text, + new_diff_base, + cx, + ) + .await + } + + async fn update_diff( + this: &WeakEntity, + buffer: &Entity, + buffer_snapshot: text::BufferSnapshot, + new_base_text: Arc, + new_diff_base: Rope, + cx: &mut AsyncApp, + ) -> Result<()> { + let (diff, language, language_registry) = this.read_with(cx, |this, cx| { + let tracked_buffer = this + .tracked_buffers + .get(buffer) + .context("buffer not tracked")?; + anyhow::Ok(( + tracked_buffer.diff.clone(), + buffer.read(cx).language().cloned(), + buffer.read(cx).language_registry().clone(), + )) + })??; + let diff_snapshot = BufferDiff::update_diff( + diff.clone(), + buffer_snapshot.clone(), + Some(new_base_text), + true, + false, + language, + language_registry, + cx, + ) + .await; + let mut unreviewed_edits = Patch::default(); + if let Ok(diff_snapshot) = diff_snapshot { + unreviewed_edits = cx + .background_spawn({ + let diff_snapshot = diff_snapshot.clone(); + let buffer_snapshot = buffer_snapshot.clone(); + let new_diff_base = new_diff_base.clone(); + async move { + let mut unreviewed_edits = Patch::default(); + for hunk in diff_snapshot + .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer_snapshot) + { + let old_range = new_diff_base + .offset_to_point(hunk.diff_base_byte_range.start) + ..new_diff_base.offset_to_point(hunk.diff_base_byte_range.end); + let new_range = hunk.range.start..hunk.range.end; + unreviewed_edits.push(point_to_row_edit( + Edit { + old: old_range, + new: new_range, + }, + &new_diff_base, + &buffer_snapshot.as_rope(), + )); + } + unreviewed_edits + } + }) + .await; + + diff.update(cx, |diff, cx| { + diff.set_snapshot(diff_snapshot, &buffer_snapshot, cx); + })?; + } + this.update(cx, |this, cx| { + let tracked_buffer = this + .tracked_buffers + .get_mut(buffer) + .context("buffer not tracked")?; + tracked_buffer.diff_base = new_diff_base; + tracked_buffer.snapshot = buffer_snapshot; + tracked_buffer.unreviewed_edits = unreviewed_edits; + cx.notify(); + anyhow::Ok(()) + })? + } + /// Track a buffer as read, so we can notify the model about user edits. pub fn buffer_read(&mut self, buffer: Entity, cx: &mut Context) { self.track_buffer_internal(buffer, false, cx); @@ -350,7 +516,7 @@ impl ActionLog { buffer_range.start.to_point(buffer)..buffer_range.end.to_point(buffer); let mut delta = 0i32; - tracked_buffer.unreviewed_changes.retain_mut(|edit| { + tracked_buffer.unreviewed_edits.retain_mut(|edit| { edit.old.start = (edit.old.start as i32 + delta) as u32; edit.old.end = (edit.old.end as i32 + delta) as u32; @@ -461,7 +627,7 @@ impl ActionLog { .project .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)); - // Clear all tracked changes for this buffer and start over as if we just read it. + // Clear all tracked edits for this buffer and start over as if we just read it. self.tracked_buffers.remove(&buffer); self.buffer_read(buffer.clone(), cx); cx.notify(); @@ -477,7 +643,7 @@ impl ActionLog { .peekable(); let mut edits_to_revert = Vec::new(); - for edit in tracked_buffer.unreviewed_changes.edits() { + for edit in tracked_buffer.unreviewed_edits.edits() { let new_range = tracked_buffer .snapshot .anchor_before(Point::new(edit.new.start, 0)) @@ -529,7 +695,7 @@ impl ActionLog { .retain(|_buffer, tracked_buffer| match tracked_buffer.status { TrackedBufferStatus::Deleted => false, _ => { - tracked_buffer.unreviewed_changes.clear(); + tracked_buffer.unreviewed_edits.clear(); tracked_buffer.diff_base = tracked_buffer.snapshot.as_rope().clone(); tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx); true @@ -538,11 +704,11 @@ impl ActionLog { cx.notify(); } - /// Returns the set of buffers that contain changes that haven't been reviewed by the user. + /// Returns the set of buffers that contain edits that haven't been reviewed by the user. pub fn changed_buffers(&self, cx: &App) -> BTreeMap, Entity> { self.tracked_buffers .iter() - .filter(|(_, tracked)| tracked.has_changes(cx)) + .filter(|(_, tracked)| tracked.has_edits(cx)) .map(|(buffer, tracked)| (buffer.clone(), tracked.diff.clone())) .collect() } @@ -662,11 +828,7 @@ fn point_to_row_edit(edit: Edit, old_text: &Rope, new_text: &Rope) -> Edi old: edit.old.start.row + 1..edit.old.end.row + 1, new: edit.new.start.row + 1..edit.new.end.row + 1, } - } else if edit.old.start.column == 0 - && edit.old.end.column == 0 - && edit.new.end.column == 0 - && edit.old.end != old_text.max_point() - { + } else if edit.old.start.column == 0 && edit.old.end.column == 0 && edit.new.end.column == 0 { Edit { old: edit.old.start.row..edit.old.end.row, new: edit.new.start.row..edit.new.end.row, @@ -694,7 +856,7 @@ enum TrackedBufferStatus { struct TrackedBuffer { buffer: Entity, diff_base: Rope, - unreviewed_changes: Patch, + unreviewed_edits: Patch, status: TrackedBufferStatus, version: clock::Global, diff: Entity, @@ -706,7 +868,7 @@ struct TrackedBuffer { } impl TrackedBuffer { - fn has_changes(&self, cx: &App) -> bool { + fn has_edits(&self, cx: &App) -> bool { self.diff .read(cx) .hunks(&self.buffer.read(cx), cx) @@ -727,8 +889,6 @@ pub struct ChangedBuffer { #[cfg(test)] mod tests { - use std::env; - use super::*; use buffer_diff::DiffHunkStatusKind; use gpui::TestAppContext; @@ -737,6 +897,7 @@ mod tests { use rand::prelude::*; use serde_json::json; use settings::SettingsStore; + use std::env; use util::{RandomCharIter, path}; #[ctor::ctor] @@ -1751,15 +1912,15 @@ mod tests { .unwrap(); } _ => { - let is_agent_change = rng.gen_bool(0.5); - if is_agent_change { + let is_agent_edit = rng.gen_bool(0.5); + if is_agent_edit { log::info!("agent edit"); } else { log::info!("user edit"); } cx.update(|cx| { buffer.update(cx, |buffer, cx| buffer.randomly_edit(&mut rng, 1, cx)); - if is_agent_change { + if is_agent_edit { action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); } }); @@ -1784,7 +1945,7 @@ mod tests { let tracked_buffer = log.tracked_buffers.get(&buffer).unwrap(); let mut old_text = tracked_buffer.diff_base.clone(); let new_text = buffer.read(cx).as_rope(); - for edit in tracked_buffer.unreviewed_changes.edits() { + for edit in tracked_buffer.unreviewed_edits.edits() { let old_start = old_text.point_to_offset(Point::new(edit.new.start, 0)); let old_end = old_text.point_to_offset(cmp::min( Point::new(edit.new.start + edit.old_len(), 0), @@ -1800,6 +1961,171 @@ mod tests { } } + #[gpui::test] + async fn test_keep_edits_on_commit(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "file.txt": "a\nb\nc\nd\ne\nf\ng\nh\ni\nj", + }), + ) + .await; + fs.set_head_for_repo( + path!("/project/.git").as_ref(), + &[("file.txt".into(), "a\nb\nc\nd\ne\nf\ng\nh\ni\nj".into())], + "0000000", + ); + cx.run_until_parked(); + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + + let file_path = project + .read_with(cx, |project, cx| { + project.find_project_path(path!("/project/file.txt"), cx) + }) + .unwrap(); + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path, cx)) + .await + .unwrap(); + + cx.update(|cx| { + action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)); + buffer.update(cx, |buffer, cx| { + buffer.edit( + [ + // Edit at the very start: a -> A + (Point::new(0, 0)..Point::new(0, 1), "A"), + // Deletion in the middle: remove lines d and e + (Point::new(3, 0)..Point::new(5, 0), ""), + // Modification: g -> GGG + (Point::new(6, 0)..Point::new(6, 1), "GGG"), + // Addition: insert new line after h + (Point::new(7, 1)..Point::new(7, 1), "\nNEW"), + // Edit the very last character: j -> J + (Point::new(9, 0)..Point::new(9, 1), "J"), + ], + None, + cx, + ); + }); + action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); + }); + cx.run_until_parked(); + assert_eq!( + unreviewed_hunks(&action_log, cx), + vec![( + buffer.clone(), + vec![ + HunkStatus { + range: Point::new(0, 0)..Point::new(1, 0), + diff_status: DiffHunkStatusKind::Modified, + old_text: "a\n".into() + }, + HunkStatus { + range: Point::new(3, 0)..Point::new(3, 0), + diff_status: DiffHunkStatusKind::Deleted, + old_text: "d\ne\n".into() + }, + HunkStatus { + range: Point::new(4, 0)..Point::new(5, 0), + diff_status: DiffHunkStatusKind::Modified, + old_text: "g\n".into() + }, + HunkStatus { + range: Point::new(6, 0)..Point::new(7, 0), + diff_status: DiffHunkStatusKind::Added, + old_text: "".into() + }, + HunkStatus { + range: Point::new(8, 0)..Point::new(8, 1), + diff_status: DiffHunkStatusKind::Modified, + old_text: "j".into() + } + ] + )] + ); + + // Simulate a git commit that matches some edits but not others: + // - Accepts the first edit (a -> A) + // - Accepts the deletion (remove d and e) + // - Makes a different change to g (g -> G instead of GGG) + // - Ignores the NEW line addition + // - Ignores the last line edit (j stays as j) + fs.set_head_for_repo( + path!("/project/.git").as_ref(), + &[("file.txt".into(), "A\nb\nc\nf\nG\nh\ni\nj".into())], + "0000001", + ); + cx.run_until_parked(); + assert_eq!( + unreviewed_hunks(&action_log, cx), + vec![( + buffer.clone(), + vec![ + HunkStatus { + range: Point::new(4, 0)..Point::new(5, 0), + diff_status: DiffHunkStatusKind::Modified, + old_text: "g\n".into() + }, + HunkStatus { + range: Point::new(6, 0)..Point::new(7, 0), + diff_status: DiffHunkStatusKind::Added, + old_text: "".into() + }, + HunkStatus { + range: Point::new(8, 0)..Point::new(8, 1), + diff_status: DiffHunkStatusKind::Modified, + old_text: "j".into() + } + ] + )] + ); + + // Make another commit that accepts the NEW line but with different content + fs.set_head_for_repo( + path!("/project/.git").as_ref(), + &[( + "file.txt".into(), + "A\nb\nc\nf\nGGG\nh\nDIFFERENT\ni\nj".into(), + )], + "0000002", + ); + cx.run_until_parked(); + assert_eq!( + unreviewed_hunks(&action_log, cx), + vec![( + buffer.clone(), + vec![ + HunkStatus { + range: Point::new(6, 0)..Point::new(7, 0), + diff_status: DiffHunkStatusKind::Added, + old_text: "".into() + }, + HunkStatus { + range: Point::new(8, 0)..Point::new(8, 1), + diff_status: DiffHunkStatusKind::Modified, + old_text: "j".into() + } + ] + )] + ); + + // Final commit that accepts all remaining edits + fs.set_head_for_repo( + path!("/project/.git").as_ref(), + &[("file.txt".into(), "A\nb\nc\nf\nGGG\nh\nNEW\ni\nJ".into())], + "0000003", + ); + cx.run_until_parked(); + assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); + } + #[derive(Debug, Clone, PartialEq, Eq)] struct HunkStatus { range: Range, diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 196de765f3..202200ef58 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -2624,6 +2624,7 @@ async fn test_git_diff_base_change( client_a.fs().set_head_for_repo( Path::new("/dir/.git"), &[("a.txt".into(), committed_text.clone())], + "deadbeef", ); // Create the buffer @@ -2717,6 +2718,7 @@ async fn test_git_diff_base_change( client_a.fs().set_head_for_repo( Path::new("/dir/.git"), &[("a.txt".into(), new_committed_text.clone())], + "deadbeef", ); // Wait for buffer_local_a to receive it @@ -3006,6 +3008,7 @@ async fn test_git_status_sync( client_a.fs().set_head_for_repo( path!("/dir/.git").as_ref(), &[("b.txt".into(), "B".into()), ("c.txt".into(), "c".into())], + "deadbeef", ); client_a.fs().set_index_for_repo( path!("/dir/.git").as_ref(), diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 4ba5e55fab..585462e3bc 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -17860,6 +17860,7 @@ async fn test_display_diff_hunks(cx: &mut TestAppContext) { ("file-2".into(), "two\n".into()), ("file-3".into(), "three\n".into()), ], + "deadbeef", ); let project = Project::test(fs, [path!("/test").as_ref()], cx).await; diff --git a/crates/editor/src/test/editor_test_context.rs b/crates/editor/src/test/editor_test_context.rs index 56186307c0..dfb41096cd 100644 --- a/crates/editor/src/test/editor_test_context.rs +++ b/crates/editor/src/test/editor_test_context.rs @@ -304,6 +304,7 @@ impl EditorTestContext { fs.set_head_for_repo( &Self::root_path().join(".git"), &[(path.into(), diff_base.to_string())], + "deadbeef", ); self.cx.run_until_parked(); } diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 8bedb90b1a..9adbe495dc 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -1456,7 +1456,12 @@ impl FakeFs { .unwrap(); } - pub fn set_head_for_repo(&self, dot_git: &Path, head_state: &[(RepoPath, String)]) { + pub fn set_head_for_repo( + &self, + dot_git: &Path, + head_state: &[(RepoPath, String)], + sha: impl Into, + ) { self.with_git_state(dot_git, true, |state| { state.head_contents.clear(); state.head_contents.extend( @@ -1464,6 +1469,7 @@ impl FakeFs { .iter() .map(|(path, content)| (path.clone(), content.clone())), ); + state.refs.insert("HEAD".into(), sha.into()); }) .unwrap(); } diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 5e06b7bc66..1b4346d728 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -1387,6 +1387,7 @@ mod tests { fs.set_head_for_repo( path!("/project/.git").as_ref(), &[("foo.txt".into(), "foo\n".into())], + "deadbeef", ); fs.set_index_for_repo( path!("/project/.git").as_ref(), @@ -1523,6 +1524,7 @@ mod tests { fs.set_head_for_repo( path!("/project/.git").as_ref(), &[("foo".into(), "original\n".into())], + "deadbeef", ); cx.run_until_parked(); diff --git a/crates/project/src/git_store/git_traversal.rs b/crates/project/src/git_store/git_traversal.rs index f7aa263e40..b3a45406c3 100644 --- a/crates/project/src/git_store/git_traversal.rs +++ b/crates/project/src/git_store/git_traversal.rs @@ -741,6 +741,7 @@ mod tests { ("a.txt".into(), "".into()), ("b/c.txt".into(), "something-else".into()), ], + "deadbeef", ); cx.executor().run_until_parked(); cx.executor().advance_clock(Duration::from_secs(1)); diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index 29e24408ee..5cd90a6a3c 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -6499,6 +6499,7 @@ async fn test_uncommitted_diff_for_buffer(cx: &mut gpui::TestAppContext) { ("src/modification.rs".into(), committed_contents), ("src/deletion.rs".into(), "// the-deleted-contents\n".into()), ], + "deadbeef", ); fs.set_index_for_repo( Path::new("/dir/.git"), @@ -6565,6 +6566,7 @@ async fn test_uncommitted_diff_for_buffer(cx: &mut gpui::TestAppContext) { ("src/modification.rs".into(), committed_contents.clone()), ("src/deletion.rs".into(), "// the-deleted-contents\n".into()), ], + "deadbeef", ); // Buffer now has an unstaged hunk. @@ -7011,6 +7013,7 @@ async fn test_staging_hunks_with_delayed_fs_event(cx: &mut gpui::TestAppContext) fs.set_head_for_repo( "/dir/.git".as_ref(), &[("file.txt".into(), committed_contents.clone())], + "deadbeef", ); fs.set_index_for_repo( "/dir/.git".as_ref(), @@ -7207,6 +7210,7 @@ async fn test_staging_random_hunks( fs.set_head_for_repo( path!("/dir/.git").as_ref(), &[("file.txt".into(), committed_text.clone())], + "deadbeef", ); fs.set_index_for_repo( path!("/dir/.git").as_ref(), @@ -7318,6 +7322,7 @@ async fn test_single_file_diffs(cx: &mut gpui::TestAppContext) { fs.set_head_for_repo( Path::new("/dir/.git"), &[("src/main.rs".into(), committed_contents.clone())], + "deadbeef", ); fs.set_index_for_repo( Path::new("/dir/.git"), diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 5988b525b7..1b54337a8d 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -1356,6 +1356,7 @@ async fn test_remote_git_diffs(cx: &mut TestAppContext, server_cx: &mut TestAppC fs.set_head_for_repo( Path::new("/code/project1/.git"), &[("src/lib.rs".into(), text_1.clone())], + "deadbeef", ); let (project, _headless) = init_test(&fs, cx, server_cx).await; @@ -1416,6 +1417,7 @@ async fn test_remote_git_diffs(cx: &mut TestAppContext, server_cx: &mut TestAppC fs.set_head_for_repo( Path::new("/code/project1/.git"), &[("src/lib.rs".into(), text_2.clone())], + "deadbeef", ); cx.executor().run_until_parked();