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 | 554 ++++++++++++++---- 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, 464 insertions(+), 115 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5638413119874d2f035d8cb011b1ba6a18d361bb..288bc81a57830aa7e3e7d751ab02abdbe7024218 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 a7b388a7530031017dc17de06f246b195c856f0d..9409e2063f757ad1af4cc3cd54d89228a0a54e7e 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 ea2bf20f375729f2d7ec984ae26e04633c019687..69c7b06366a9ccb8a40b7c4cbc934e4dc8a2b2c4 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,106 +188,272 @@ 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 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_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 (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 + }; + + 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?; + } + } + } + } - 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; + Ok(()) + } - 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; + 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) + } + }); - diff.update(cx, |diff, cx| { - diff.set_snapshot(diff_snapshot, &buffer_snapshot, cx) - })?; - } - this.update(cx, |this, cx| { + 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_mut(&buffer) + .get(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(()) - })??; - } + 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; + } - Ok(()) + 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. @@ -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 196de765f3932371fad4355b0c25bcf22e1f1801..202200ef58e2efa42f13666b7bf1513ad847e3ca 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 4ba5e55fab28ae3ba21782d52fe643a0de1b0f72..585462e3bc9dc1d69c895c3ca4f61964a881837f 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 56186307c0eeba561492e35209486a497e3cb360..dfb41096cd4b842ae7f92ffe3d876dcdb826da4b 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 8bedb90b1a12237c002ba33d7e3a3845e834d933..9adbe495dcf2d10cdbb3df96c9528c3143cde1bd 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 5e06b7bc6690849343b397a9f421436cd382025f..1b4346d7288fb370400e5e805f676dd807001944 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 f7aa263e405a022213c146a00491383986f40eac..b3a45406c360183719f282f3c1b315f1176cf3e5 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 29e24408ee86017a340104b01ed87c28642df4c5..5cd90a6a3c6ab0f59d22fa6f0eec2ed7f2530bae 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 5988b525b79dc334ad5241cea1b0ac1280f33e3f..1b54337a8daca8efa511ac9dde16aa507ed26da3 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();