Automatically keep edits if they are included in a commit (#32093)

Antonio Scandurra created

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.

Change summary

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 
crates/remote_server/src/remote_editing_tests.rs |   2 
11 files changed, 464 insertions(+), 115 deletions(-)

Detailed changes

Cargo.lock ๐Ÿ”—

@@ -631,6 +631,7 @@ name = "assistant_tool"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "async-watch",
  "buffer_diff",
  "clock",
  "collections",

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

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<Self>,
         buffer: Entity<Buffer>,
-        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<ActionLog>,
+        buffer: &Entity<Buffer>,
+        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<ActionLog>,
+        buffer: &Entity<Buffer>,
+        git_diff: &Entity<BufferDiff>,
+        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<ActionLog>,
+        buffer: &Entity<Buffer>,
+        buffer_snapshot: text::BufferSnapshot,
+        new_base_text: Arc<String>,
+        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<Buffer>, Entity<BufferDiff>> {
         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<Point>, 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<Buffer>,
     diff_base: Rope,
-    unreviewed_changes: Patch<u32>,
+    unreviewed_edits: Patch<u32>,
     status: TrackedBufferStatus,
     version: clock::Global,
     diff: Entity<BufferDiff>,
@@ -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<Point>,

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

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;

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<String>,
+    ) {
         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();
     }

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

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

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

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