git: Fix race condition when [un]staging hunks in quick succession (#26422)

João Marcos , Cole , and Max created

- [x] Fix `[un]stage` hunk operations cancelling pending ones
  - [x] Add test
- [ ] bugs I stumbled upon (try to repro again before merging)
  - [x] holding `git::StageAndNext` skips hunks randomly 
    - [x] Add test
  - [x] restoring a file keeps it in the git panel
- [x] Double clicking on `toggle staged` fast makes Zed disagree with
`git` CLI
- [x] checkbox shows :heavy_check_mark: (fully staged) after a single
stage

Release Notes:

- N/A

---------

Co-authored-by: Cole <cole@zed.dev>
Co-authored-by: Max <max@zed.dev>

Change summary

crates/buffer_diff/Cargo.toml         |   2 
crates/buffer_diff/src/buffer_diff.rs | 228 ++++++++++++++++++---------
crates/git_ui/src/project_diff.rs     |   2 
crates/project/src/project_tests.rs   | 242 ++++++++++++++++++++++++++++
crates/sum_tree/src/cursor.rs         |   1 
crates/sum_tree/src/sum_tree.rs       |   9 +
crates/text/src/anchor.rs             |   5 
7 files changed, 406 insertions(+), 83 deletions(-)

Detailed changes

crates/buffer_diff/Cargo.toml 🔗

@@ -22,6 +22,7 @@ git2.workspace = true
 gpui.workspace = true
 language.workspace = true
 log.workspace = true
+pretty_assertions.workspace = true
 rope.workspace = true
 sum_tree.workspace = true
 text.workspace = true
@@ -31,7 +32,6 @@ util.workspace = true
 ctor.workspace = true
 env_logger.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
-pretty_assertions.workspace = true
 rand.workspace = true
 serde_json.workspace = true
 text = { workspace = true, features = ["test-support"] }

crates/buffer_diff/src/buffer_diff.rs 🔗

@@ -6,9 +6,9 @@ use rope::Rope;
 use std::cmp::Ordering;
 use std::mem;
 use std::{future::Future, iter, ops::Range, sync::Arc};
-use sum_tree::{SumTree, TreeMap};
-use text::ToOffset as _;
+use sum_tree::SumTree;
 use text::{Anchor, Bias, BufferId, OffsetRangeExt, Point};
+use text::{AnchorRangeExt, ToOffset as _};
 use util::ResultExt;
 
 pub struct BufferDiff {
@@ -26,7 +26,7 @@ pub struct BufferDiffSnapshot {
 #[derive(Clone)]
 struct BufferDiffInner {
     hunks: SumTree<InternalDiffHunk>,
-    pending_hunks: TreeMap<usize, PendingHunk>,
+    pending_hunks: SumTree<PendingHunk>,
     base_text: language::BufferSnapshot,
     base_text_exists: bool,
 }
@@ -48,7 +48,7 @@ pub enum DiffHunkStatusKind {
 pub enum DiffHunkSecondaryStatus {
     HasSecondaryHunk,
     OverlapsWithSecondaryHunk,
-    None,
+    NoSecondaryHunk,
     SecondaryHunkAdditionPending,
     SecondaryHunkRemovalPending,
 }
@@ -74,6 +74,8 @@ struct InternalDiffHunk {
 
 #[derive(Debug, Clone, PartialEq, Eq)]
 struct PendingHunk {
+    buffer_range: Range<Anchor>,
+    diff_base_byte_range: Range<usize>,
     buffer_version: clock::Global,
     new_status: DiffHunkSecondaryStatus,
 }
@@ -93,6 +95,16 @@ impl sum_tree::Item for InternalDiffHunk {
     }
 }
 
+impl sum_tree::Item for PendingHunk {
+    type Summary = DiffHunkSummary;
+
+    fn summary(&self, _cx: &text::BufferSnapshot) -> Self::Summary {
+        DiffHunkSummary {
+            buffer_range: self.buffer_range.clone(),
+        }
+    }
+}
+
 impl sum_tree::Summary for DiffHunkSummary {
     type Context = text::BufferSnapshot;
 
@@ -176,6 +188,7 @@ impl BufferDiffSnapshot {
 }
 
 impl BufferDiffInner {
+    /// Returns the new index text and new pending hunks.
     fn stage_or_unstage_hunks(
         &mut self,
         unstaged_diff: &Self,
@@ -183,7 +196,7 @@ impl BufferDiffInner {
         hunks: &[DiffHunk],
         buffer: &text::BufferSnapshot,
         file_exists: bool,
-    ) -> (Option<Rope>, Vec<(usize, PendingHunk)>) {
+    ) -> (Option<Rope>, SumTree<PendingHunk>) {
         let head_text = self
             .base_text_exists
             .then(|| self.base_text.as_rope().clone());
@@ -195,41 +208,41 @@ impl BufferDiffInner {
         // entire file must be either created or deleted in the index.
         let (index_text, head_text) = match (index_text, head_text) {
             (Some(index_text), Some(head_text)) if file_exists || !stage => (index_text, head_text),
-            (_, head_text @ _) => {
-                if stage {
+            (index_text, head_text) => {
+                let (rope, new_status) = if stage {
                     log::debug!("stage all");
-                    return (
+                    (
                         file_exists.then(|| buffer.as_rope().clone()),
-                        vec![(
-                            0,
-                            PendingHunk {
-                                buffer_version: buffer.version().clone(),
-                                new_status: DiffHunkSecondaryStatus::SecondaryHunkRemovalPending,
-                            },
-                        )],
-                    );
+                        DiffHunkSecondaryStatus::SecondaryHunkRemovalPending,
+                    )
                 } else {
                     log::debug!("unstage all");
-                    return (
+                    (
                         head_text,
-                        vec![(
-                            0,
-                            PendingHunk {
-                                buffer_version: buffer.version().clone(),
-                                new_status: DiffHunkSecondaryStatus::SecondaryHunkAdditionPending,
-                            },
-                        )],
-                    );
-                }
+                        DiffHunkSecondaryStatus::SecondaryHunkAdditionPending,
+                    )
+                };
+
+                let hunk = PendingHunk {
+                    buffer_range: Anchor::MIN..Anchor::MAX,
+                    diff_base_byte_range: 0..index_text.map_or(0, |rope| rope.len()),
+                    buffer_version: buffer.version().clone(),
+                    new_status,
+                };
+                let tree = SumTree::from_item(hunk, buffer);
+                return (rope, tree);
             }
         };
 
         let mut unstaged_hunk_cursor = unstaged_diff.hunks.cursor::<DiffHunkSummary>(buffer);
         unstaged_hunk_cursor.next(buffer);
-        let mut edits = Vec::new();
-        let mut pending_hunks = Vec::new();
-        let mut prev_unstaged_hunk_buffer_offset = 0;
-        let mut prev_unstaged_hunk_base_text_offset = 0;
+
+        let mut pending_hunks = SumTree::new(buffer);
+        let mut old_pending_hunks = unstaged_diff
+            .pending_hunks
+            .cursor::<DiffHunkSummary>(buffer);
+
+        // first, merge new hunks into pending_hunks
         for DiffHunk {
             buffer_range,
             diff_base_byte_range,
@@ -237,12 +250,58 @@ impl BufferDiffInner {
             ..
         } in hunks.iter().cloned()
         {
-            if (stage && secondary_status == DiffHunkSecondaryStatus::None)
+            let preceding_pending_hunks =
+                old_pending_hunks.slice(&buffer_range.start, Bias::Left, buffer);
+
+            pending_hunks.append(preceding_pending_hunks, buffer);
+
+            // skip all overlapping old pending hunks
+            while old_pending_hunks
+                .item()
+                .is_some_and(|preceding_pending_hunk_item| {
+                    preceding_pending_hunk_item
+                        .buffer_range
+                        .overlaps(&buffer_range, buffer)
+                })
+            {
+                old_pending_hunks.next(buffer);
+            }
+
+            // merge into pending hunks
+            if (stage && secondary_status == DiffHunkSecondaryStatus::NoSecondaryHunk)
                 || (!stage && secondary_status == DiffHunkSecondaryStatus::HasSecondaryHunk)
             {
                 continue;
             }
 
+            pending_hunks.push(
+                PendingHunk {
+                    buffer_range,
+                    diff_base_byte_range,
+                    buffer_version: buffer.version().clone(),
+                    new_status: if stage {
+                        DiffHunkSecondaryStatus::SecondaryHunkRemovalPending
+                    } else {
+                        DiffHunkSecondaryStatus::SecondaryHunkAdditionPending
+                    },
+                },
+                buffer,
+            );
+        }
+        // append the remainder
+        pending_hunks.append(old_pending_hunks.suffix(buffer), buffer);
+
+        let mut prev_unstaged_hunk_buffer_offset = 0;
+        let mut prev_unstaged_hunk_base_text_offset = 0;
+        let mut edits = Vec::<(Range<usize>, String)>::new();
+
+        // then, iterate over all pending hunks (both new ones and the existing ones) and compute the edits
+        for PendingHunk {
+            buffer_range,
+            diff_base_byte_range,
+            ..
+        } in pending_hunks.iter().cloned()
+        {
             let skipped_hunks = unstaged_hunk_cursor.slice(&buffer_range.start, Bias::Left, buffer);
 
             if let Some(secondary_hunk) = skipped_hunks.last() {
@@ -294,22 +353,15 @@ impl BufferDiffInner {
                     .chunks_in_range(diff_base_byte_range.clone())
                     .collect::<String>()
             };
-            pending_hunks.push((
-                diff_base_byte_range.start,
-                PendingHunk {
-                    buffer_version: buffer.version().clone(),
-                    new_status: if stage {
-                        DiffHunkSecondaryStatus::SecondaryHunkRemovalPending
-                    } else {
-                        DiffHunkSecondaryStatus::SecondaryHunkAdditionPending
-                    },
-                },
-            ));
+
             edits.push((index_range, replacement_text));
         }
 
+        debug_assert!(edits.iter().is_sorted_by_key(|(range, _)| range.start));
+
         let mut new_index_text = Rope::new();
         let mut index_cursor = index_text.cursor(0);
+
         for (old_range, replacement_text) in edits {
             new_index_text.append(index_cursor.slice(old_range.start));
             index_cursor.seek_forward(old_range.end);
@@ -354,12 +406,14 @@ impl BufferDiffInner {
         });
 
         let mut secondary_cursor = None;
-        let mut pending_hunks = TreeMap::default();
+        let mut pending_hunks_cursor = None;
         if let Some(secondary) = secondary.as_ref() {
             let mut cursor = secondary.hunks.cursor::<DiffHunkSummary>(buffer);
             cursor.next(buffer);
             secondary_cursor = Some(cursor);
-            pending_hunks = secondary.pending_hunks.clone();
+            let mut cursor = secondary.pending_hunks.cursor::<DiffHunkSummary>(buffer);
+            cursor.next(buffer);
+            pending_hunks_cursor = Some(cursor);
         }
 
         let max_point = buffer.max_point();
@@ -378,16 +432,33 @@ impl BufferDiffInner {
                 end_anchor = buffer.anchor_before(end_point);
             }
 
-            let mut secondary_status = DiffHunkSecondaryStatus::None;
+            let mut secondary_status = DiffHunkSecondaryStatus::NoSecondaryHunk;
 
             let mut has_pending = false;
-            if let Some(pending_hunk) = pending_hunks.get(&start_base) {
-                if !buffer.has_edits_since_in_range(
-                    &pending_hunk.buffer_version,
-                    start_anchor..end_anchor,
-                ) {
-                    has_pending = true;
-                    secondary_status = pending_hunk.new_status;
+            if let Some(pending_cursor) = pending_hunks_cursor.as_mut() {
+                if start_anchor
+                    .cmp(&pending_cursor.start().buffer_range.start, buffer)
+                    .is_gt()
+                {
+                    pending_cursor.seek_forward(&start_anchor, Bias::Left, buffer);
+                }
+
+                if let Some(pending_hunk) = pending_cursor.item() {
+                    let mut pending_range = pending_hunk.buffer_range.to_point(buffer);
+                    if pending_range.end.column > 0 {
+                        pending_range.end.row += 1;
+                        pending_range.end.column = 0;
+                    }
+
+                    if pending_range == (start_point..end_point) {
+                        if !buffer.has_edits_since_in_range(
+                            &pending_hunk.buffer_version,
+                            start_anchor..end_anchor,
+                        ) {
+                            has_pending = true;
+                            secondary_status = pending_hunk.new_status;
+                        }
+                    }
                 }
             }
 
@@ -449,7 +520,7 @@ impl BufferDiffInner {
                 diff_base_byte_range: hunk.diff_base_byte_range.clone(),
                 buffer_range: hunk.buffer_range.clone(),
                 // The secondary status is not used by callers of this method.
-                secondary_status: DiffHunkSecondaryStatus::None,
+                secondary_status: DiffHunkSecondaryStatus::NoSecondaryHunk,
             })
         })
     }
@@ -724,7 +795,7 @@ impl BufferDiff {
                 base_text,
                 hunks,
                 base_text_exists,
-                pending_hunks: TreeMap::default(),
+                pending_hunks: SumTree::new(&buffer),
             }
         }
     }
@@ -740,8 +811,8 @@ impl BufferDiff {
         cx.background_spawn(async move {
             BufferDiffInner {
                 base_text: base_text_snapshot,
+                pending_hunks: SumTree::new(&buffer),
                 hunks: compute_hunks(base_text_pair, buffer),
-                pending_hunks: TreeMap::default(),
                 base_text_exists,
             }
         })
@@ -751,7 +822,7 @@ impl BufferDiff {
         BufferDiffInner {
             base_text: language::Buffer::build_empty_snapshot(cx),
             hunks: SumTree::new(buffer),
-            pending_hunks: TreeMap::default(),
+            pending_hunks: SumTree::new(buffer),
             base_text_exists: false,
         }
     }
@@ -767,7 +838,7 @@ impl BufferDiff {
     pub fn clear_pending_hunks(&mut self, cx: &mut Context<Self>) {
         if let Some(secondary_diff) = &self.secondary_diff {
             secondary_diff.update(cx, |diff, _| {
-                diff.inner.pending_hunks.clear();
+                diff.inner.pending_hunks = SumTree::from_summary(DiffHunkSummary::default());
             });
             cx.emit(BufferDiffEvent::DiffChanged {
                 changed_range: Some(Anchor::MIN..Anchor::MAX),
@@ -783,18 +854,17 @@ impl BufferDiff {
         file_exists: bool,
         cx: &mut Context<Self>,
     ) -> Option<Rope> {
-        let (new_index_text, pending_hunks) = self.inner.stage_or_unstage_hunks(
+        let (new_index_text, new_pending_hunks) = self.inner.stage_or_unstage_hunks(
             &self.secondary_diff.as_ref()?.read(cx).inner,
             stage,
             &hunks,
             buffer,
             file_exists,
         );
+
         if let Some(unstaged_diff) = &self.secondary_diff {
             unstaged_diff.update(cx, |diff, _| {
-                for (offset, pending_hunk) in pending_hunks {
-                    diff.inner.pending_hunks.insert(offset, pending_hunk);
-                }
+                diff.inner.pending_hunks = new_pending_hunks;
             });
         }
         cx.emit(BufferDiffEvent::HunksStagedOrUnstaged(
@@ -916,7 +986,9 @@ impl BufferDiff {
                 }
                 _ => (true, Some(text::Anchor::MIN..text::Anchor::MAX)),
             };
-        let pending_hunks = mem::take(&mut self.inner.pending_hunks);
+
+        let pending_hunks = mem::replace(&mut self.inner.pending_hunks, SumTree::new(buffer));
+
         self.inner = new_state;
         if !base_text_changed {
             self.inner.pending_hunks = pending_hunks;
@@ -1149,21 +1221,21 @@ impl DiffHunkStatus {
     pub fn deleted_none() -> Self {
         Self {
             kind: DiffHunkStatusKind::Deleted,
-            secondary: DiffHunkSecondaryStatus::None,
+            secondary: DiffHunkSecondaryStatus::NoSecondaryHunk,
         }
     }
 
     pub fn added_none() -> Self {
         Self {
             kind: DiffHunkStatusKind::Added,
-            secondary: DiffHunkSecondaryStatus::None,
+            secondary: DiffHunkSecondaryStatus::NoSecondaryHunk,
         }
     }
 
     pub fn modified_none() -> Self {
         Self {
             kind: DiffHunkStatusKind::Modified,
-            secondary: DiffHunkSecondaryStatus::None,
+            secondary: DiffHunkSecondaryStatus::NoSecondaryHunk,
         }
     }
 }
@@ -1171,13 +1243,14 @@ impl DiffHunkStatus {
 /// Range (crossing new lines), old, new
 #[cfg(any(test, feature = "test-support"))]
 #[track_caller]
-pub fn assert_hunks<Iter>(
-    diff_hunks: Iter,
+pub fn assert_hunks<ExpectedText, HunkIter>(
+    diff_hunks: HunkIter,
     buffer: &text::BufferSnapshot,
     diff_base: &str,
-    expected_hunks: &[(Range<u32>, &str, &str, DiffHunkStatus)],
+    expected_hunks: &[(Range<u32>, ExpectedText, ExpectedText, DiffHunkStatus)],
 ) where
-    Iter: Iterator<Item = DiffHunk>,
+    HunkIter: Iterator<Item = DiffHunk>,
+    ExpectedText: AsRef<str>,
 {
     let actual_hunks = diff_hunks
         .map(|hunk| {
@@ -1197,14 +1270,14 @@ pub fn assert_hunks<Iter>(
         .map(|(r, old_text, new_text, status)| {
             (
                 Point::new(r.start, 0)..Point::new(r.end, 0),
-                *old_text,
-                new_text.to_string(),
+                old_text.as_ref(),
+                new_text.as_ref().to_string(),
                 *status,
             )
         })
         .collect();
 
-    assert_eq!(actual_hunks, expected_hunks);
+    pretty_assertions::assert_eq!(actual_hunks, expected_hunks);
 }
 
 #[cfg(test)]
@@ -1263,7 +1336,7 @@ mod tests {
         );
 
         diff = cx.update(|cx| BufferDiff::build_empty(&buffer, cx));
-        assert_hunks(
+        assert_hunks::<&str, _>(
             diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, None),
             &buffer,
             &diff_base,
@@ -1601,7 +1674,10 @@ mod tests {
                     .hunks_intersecting_range(hunk_range.clone(), &buffer, &cx)
                     .collect::<Vec<_>>();
                 for hunk in &hunks {
-                    assert_ne!(hunk.secondary_status, DiffHunkSecondaryStatus::None)
+                    assert_ne!(
+                        hunk.secondary_status,
+                        DiffHunkSecondaryStatus::NoSecondaryHunk
+                    )
                 }
 
                 let new_index_text = diff
@@ -1880,10 +1956,10 @@ mod tests {
             let hunk_to_change = hunk.clone();
             let stage = match hunk.secondary_status {
                 DiffHunkSecondaryStatus::HasSecondaryHunk => {
-                    hunk.secondary_status = DiffHunkSecondaryStatus::None;
+                    hunk.secondary_status = DiffHunkSecondaryStatus::NoSecondaryHunk;
                     true
                 }
-                DiffHunkSecondaryStatus::None => {
+                DiffHunkSecondaryStatus::NoSecondaryHunk => {
                     hunk.secondary_status = DiffHunkSecondaryStatus::HasSecondaryHunk;
                     false
                 }

crates/git_ui/src/project_diff.rs 🔗

@@ -278,7 +278,7 @@ impl ProjectDiff {
                     has_staged_hunks = true;
                     has_unstaged_hunks = true;
                 }
-                DiffHunkSecondaryStatus::None
+                DiffHunkSecondaryStatus::NoSecondaryHunk
                 | DiffHunkSecondaryStatus::SecondaryHunkRemovalPending => {
                     has_staged_hunks = true;
                 }

crates/project/src/project_tests.rs 🔗

@@ -941,7 +941,7 @@ async fn test_reporting_fs_changes_to_language_servers(cx: &mut gpui::TestAppCon
     cx.executor().run_until_parked();
 
     // Start the language server by opening a buffer with a compatible file extension.
-    let _ = project
+    project
         .update(cx, |project, cx| {
             project.open_local_buffer_with_lsp(path!("/the-root/src/a.rs"), cx)
         })
@@ -6008,7 +6008,7 @@ async fn test_uncommitted_diff_for_buffer(cx: &mut gpui::TestAppContext) {
                 0..0,
                 "// the-deleted-contents\n",
                 "",
-                DiffHunkStatus::deleted(DiffHunkSecondaryStatus::None),
+                DiffHunkStatus::deleted(DiffHunkSecondaryStatus::NoSecondaryHunk),
             )],
         );
     });
@@ -6168,7 +6168,12 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) {
                     "",
                     DiffHunkStatus::deleted(HasSecondaryHunk),
                 ),
-                (1..2, "two\n", "TWO\n", DiffHunkStatus::modified(None)),
+                (
+                    1..2,
+                    "two\n",
+                    "TWO\n",
+                    DiffHunkStatus::modified(NoSecondaryHunk),
+                ),
                 (
                     3..4,
                     "four\n",
@@ -6217,7 +6222,12 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) {
                     "",
                     DiffHunkStatus::deleted(HasSecondaryHunk),
                 ),
-                (1..2, "two\n", "TWO\n", DiffHunkStatus::modified(None)),
+                (
+                    1..2,
+                    "two\n",
+                    "TWO\n",
+                    DiffHunkStatus::modified(NoSecondaryHunk),
+                ),
                 (
                     3..4,
                     "four\n",
@@ -6256,7 +6266,12 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) {
                     "",
                     DiffHunkStatus::deleted(HasSecondaryHunk),
                 ),
-                (1..2, "two\n", "TWO\n", DiffHunkStatus::modified(None)),
+                (
+                    1..2,
+                    "two\n",
+                    "TWO\n",
+                    DiffHunkStatus::modified(NoSecondaryHunk),
+                ),
                 (
                     3..4,
                     "four\n",
@@ -6277,6 +6292,223 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) {
     } else {
         panic!("Unexpected event {event:?}");
     }
+
+    // Allow writing to the git index to succeed again.
+    fs.set_error_message_for_index_write("/dir/.git".as_ref(), None);
+
+    // Stage two hunks with separate operations.
+    uncommitted_diff.update(cx, |diff, cx| {
+        let hunks = diff.hunks(&snapshot, cx).collect::<Vec<_>>();
+        diff.stage_or_unstage_hunks(true, &hunks[0..1], &snapshot, true, cx);
+        diff.stage_or_unstage_hunks(true, &hunks[2..3], &snapshot, true, cx);
+    });
+
+    // Both staged hunks appear as pending.
+    uncommitted_diff.update(cx, |diff, cx| {
+        assert_hunks(
+            diff.hunks(&snapshot, cx),
+            &snapshot,
+            &diff.base_text_string().unwrap(),
+            &[
+                (
+                    0..0,
+                    "zero\n",
+                    "",
+                    DiffHunkStatus::deleted(SecondaryHunkRemovalPending),
+                ),
+                (
+                    1..2,
+                    "two\n",
+                    "TWO\n",
+                    DiffHunkStatus::modified(NoSecondaryHunk),
+                ),
+                (
+                    3..4,
+                    "four\n",
+                    "FOUR\n",
+                    DiffHunkStatus::modified(SecondaryHunkRemovalPending),
+                ),
+            ],
+        );
+    });
+
+    // Both staging operations take effect.
+    cx.run_until_parked();
+    uncommitted_diff.update(cx, |diff, cx| {
+        assert_hunks(
+            diff.hunks(&snapshot, cx),
+            &snapshot,
+            &diff.base_text_string().unwrap(),
+            &[
+                (0..0, "zero\n", "", DiffHunkStatus::deleted(NoSecondaryHunk)),
+                (
+                    1..2,
+                    "two\n",
+                    "TWO\n",
+                    DiffHunkStatus::modified(NoSecondaryHunk),
+                ),
+                (
+                    3..4,
+                    "four\n",
+                    "FOUR\n",
+                    DiffHunkStatus::modified(NoSecondaryHunk),
+                ),
+            ],
+        );
+    });
+}
+
+#[allow(clippy::format_collect)]
+#[gpui::test]
+async fn test_staging_lots_of_hunks_fast(cx: &mut gpui::TestAppContext) {
+    use DiffHunkSecondaryStatus::*;
+    init_test(cx);
+
+    let different_lines = (0..500)
+        .step_by(5)
+        .map(|i| format!("diff {}\n", i))
+        .collect::<Vec<String>>();
+    let committed_contents = (0..500).map(|i| format!("{}\n", i)).collect::<String>();
+    let file_contents = (0..500)
+        .map(|i| {
+            if i % 5 == 0 {
+                different_lines[i / 5].clone()
+            } else {
+                format!("{}\n", i)
+            }
+        })
+        .collect::<String>();
+
+    let fs = FakeFs::new(cx.background_executor.clone());
+    fs.insert_tree(
+        "/dir",
+        json!({
+            ".git": {},
+            "file.txt": file_contents.clone()
+        }),
+    )
+    .await;
+
+    fs.set_head_for_repo(
+        "/dir/.git".as_ref(),
+        &[("file.txt".into(), committed_contents.clone())],
+    );
+    fs.set_index_for_repo(
+        "/dir/.git".as_ref(),
+        &[("file.txt".into(), committed_contents.clone())],
+    );
+
+    let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer("/dir/file.txt", cx)
+        })
+        .await
+        .unwrap();
+    let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
+    let uncommitted_diff = project
+        .update(cx, |project, cx| {
+            project.open_uncommitted_diff(buffer.clone(), cx)
+        })
+        .await
+        .unwrap();
+
+    let range = Anchor::MIN..snapshot.anchor_after(snapshot.max_point());
+
+    let mut expected_hunks: Vec<(Range<u32>, String, String, DiffHunkStatus)> = (0..500)
+        .step_by(5)
+        .map(|i| {
+            (
+                i as u32..i as u32 + 1,
+                format!("{}\n", i),
+                different_lines[i / 5].clone(),
+                DiffHunkStatus::modified(HasSecondaryHunk),
+            )
+        })
+        .collect();
+
+    // The hunks are initially unstaged
+    uncommitted_diff.read_with(cx, |diff, cx| {
+        assert_hunks(
+            diff.hunks(&snapshot, cx),
+            &snapshot,
+            &diff.base_text_string().unwrap(),
+            &expected_hunks,
+        );
+    });
+
+    for (_, _, _, status) in expected_hunks.iter_mut() {
+        *status = DiffHunkStatus::modified(SecondaryHunkRemovalPending);
+    }
+
+    // Stage every hunk with a different call
+    uncommitted_diff.update(cx, |diff, cx| {
+        let hunks = diff
+            .hunks_intersecting_range(range.clone(), &snapshot, cx)
+            .collect::<Vec<_>>();
+        for hunk in hunks {
+            diff.stage_or_unstage_hunks(true, &[hunk], &snapshot, true, cx);
+        }
+
+        assert_hunks(
+            diff.hunks(&snapshot, cx),
+            &snapshot,
+            &diff.base_text_string().unwrap(),
+            &expected_hunks,
+        );
+    });
+
+    // If we wait, we'll have no pending hunks
+    cx.run_until_parked();
+    for (_, _, _, status) in expected_hunks.iter_mut() {
+        *status = DiffHunkStatus::modified(NoSecondaryHunk);
+    }
+
+    uncommitted_diff.update(cx, |diff, cx| {
+        assert_hunks(
+            diff.hunks(&snapshot, cx),
+            &snapshot,
+            &diff.base_text_string().unwrap(),
+            &expected_hunks,
+        );
+    });
+
+    for (_, _, _, status) in expected_hunks.iter_mut() {
+        *status = DiffHunkStatus::modified(SecondaryHunkAdditionPending);
+    }
+
+    // Unstage every hunk with a different call
+    uncommitted_diff.update(cx, |diff, cx| {
+        let hunks = diff
+            .hunks_intersecting_range(range, &snapshot, cx)
+            .collect::<Vec<_>>();
+        for hunk in hunks {
+            diff.stage_or_unstage_hunks(false, &[hunk], &snapshot, true, cx);
+        }
+
+        assert_hunks(
+            diff.hunks(&snapshot, cx),
+            &snapshot,
+            &diff.base_text_string().unwrap(),
+            &expected_hunks,
+        );
+    });
+
+    // If we wait, we'll have no pending hunks, again
+    cx.run_until_parked();
+    for (_, _, _, status) in expected_hunks.iter_mut() {
+        *status = DiffHunkStatus::modified(HasSecondaryHunk);
+    }
+
+    uncommitted_diff.update(cx, |diff, cx| {
+        assert_hunks(
+            diff.hunks(&snapshot, cx),
+            &snapshot,
+            &diff.base_text_string().unwrap(),
+            &expected_hunks,
+        );
+    });
 }
 
 #[gpui::test]

crates/sum_tree/src/cursor.rs 🔗

@@ -406,6 +406,7 @@ where
         self.seek_internal(pos, bias, &mut (), cx)
     }
 
+    /// Advances the cursor and returns traversed items as a tree.
     #[track_caller]
     pub fn slice<Target>(
         &mut self,

crates/sum_tree/src/sum_tree.rs 🔗

@@ -225,6 +225,15 @@ impl<T: Item> SumTree<T> {
         }))
     }
 
+    /// Useful in cases where the item type has a non-trivial context type, but the zero value of the summary type doesn't depend on that context.
+    pub fn from_summary(summary: T::Summary) -> Self {
+        SumTree(Arc::new(Node::Leaf {
+            summary,
+            items: ArrayVec::new(),
+            item_summaries: ArrayVec::new(),
+        }))
+    }
+
     pub fn from_item(item: T, cx: &<T::Summary as Summary>::Context) -> Self {
         let mut tree = Self::new(cx);
         tree.push(item, cx);

crates/text/src/anchor.rs 🔗

@@ -136,6 +136,7 @@ where
 
 pub trait AnchorRangeExt {
     fn cmp(&self, b: &Range<Anchor>, buffer: &BufferSnapshot) -> Ordering;
+    fn overlaps(&self, b: &Range<Anchor>, buffer: &BufferSnapshot) -> bool;
 }
 
 impl AnchorRangeExt for Range<Anchor> {
@@ -145,4 +146,8 @@ impl AnchorRangeExt for Range<Anchor> {
             ord => ord,
         }
     }
+
+    fn overlaps(&self, other: &Range<Anchor>, buffer: &BufferSnapshot) -> bool {
+        self.start.cmp(&other.end, buffer).is_lt() && other.start.cmp(&self.end, buffer).is_lt()
+    }
 }