Clear pending staged/unstaged diff hunks hunks when writing to the git index fails (#26173)

Max Brunsfeld created

Release Notes:

- Git Beta: Fixed a bug where discarding a hunk in the project diff view
performed two concurrent saves of the buffer.
- Git Beta: Fixed an issue where diff hunks appeared in the wrong state
after failing to write to the git index.

Change summary

crates/buffer_diff/src/buffer_diff.rs   |  24 ++
crates/editor/src/editor.rs             | 105 +++++----
crates/editor/src/element.rs            |  48 ++--
crates/fs/src/fs.rs                     |   6 
crates/git/src/repository.rs            |   5 
crates/git_ui/src/git_panel.rs          |   7 
crates/git_ui/src/project_diff.rs       |  17 +
crates/multi_buffer/src/multi_buffer.rs |   1 
crates/project/src/buffer_store.rs      |   4 
crates/project/src/git.rs               | 134 +++++++++++--
crates/project/src/lsp_store.rs         |   2 
crates/project/src/project.rs           |  39 ---
crates/project/src/project_tests.rs     | 273 ++++++++++++++++++++++++++
crates/sum_tree/src/tree_map.rs         |  12 +
14 files changed, 534 insertions(+), 143 deletions(-)

Detailed changes

crates/buffer_diff/src/buffer_diff.rs 🔗

@@ -663,11 +663,13 @@ impl std::fmt::Debug for BufferDiff {
     }
 }
 
+#[derive(Clone, Debug)]
 pub enum BufferDiffEvent {
     DiffChanged {
         changed_range: Option<Range<text::Anchor>>,
     },
     LanguageChanged,
+    HunksStagedOrUnstaged(Option<Rope>),
 }
 
 impl EventEmitter<BufferDiffEvent> for BufferDiff {}
@@ -762,6 +764,17 @@ impl BufferDiff {
         self.secondary_diff.clone()
     }
 
+    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();
+            });
+            cx.emit(BufferDiffEvent::DiffChanged {
+                changed_range: Some(Anchor::MIN..Anchor::MAX),
+            });
+        }
+    }
+
     pub fn stage_or_unstage_hunks(
         &mut self,
         stage: bool,
@@ -784,6 +797,9 @@ impl BufferDiff {
                 }
             });
         }
+        cx.emit(BufferDiffEvent::HunksStagedOrUnstaged(
+            new_index_text.clone(),
+        ));
         if let Some((first, last)) = hunks.first().zip(hunks.last()) {
             let changed_range = first.buffer_range.start..last.buffer_range.end;
             cx.emit(BufferDiffEvent::DiffChanged {
@@ -900,6 +916,14 @@ impl BufferDiff {
         }
     }
 
+    pub fn hunks<'a>(
+        &'a self,
+        buffer_snapshot: &'a text::BufferSnapshot,
+        cx: &'a App,
+    ) -> impl 'a + Iterator<Item = DiffHunk> {
+        self.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer_snapshot, cx)
+    }
+
     pub fn hunks_intersecting_range<'a>(
         &'a self,
         range: Range<text::Anchor>,

crates/editor/src/editor.rs 🔗

@@ -7843,7 +7843,7 @@ impl Editor {
             for hunk in &hunks {
                 self.prepare_restore_change(&mut revert_changes, hunk, cx);
             }
-            self.do_stage_or_unstage(false, buffer_id, hunks.into_iter(), window, cx);
+            self.do_stage_or_unstage(false, buffer_id, hunks.into_iter(), cx);
         }
         drop(chunk_by);
         if !revert_changes.is_empty() {
@@ -13657,13 +13657,13 @@ impl Editor {
     pub fn toggle_staged_selected_diff_hunks(
         &mut self,
         _: &::git::ToggleStaged,
-        window: &mut Window,
+        _: &mut Window,
         cx: &mut Context<Self>,
     ) {
         let snapshot = self.buffer.read(cx).snapshot(cx);
         let ranges: Vec<_> = self.selections.disjoint.iter().map(|s| s.range()).collect();
         let stage = self.has_stageable_diff_hunks_in_ranges(&ranges, &snapshot);
-        self.stage_or_unstage_diff_hunks(stage, &ranges, window, cx);
+        self.stage_or_unstage_diff_hunks(stage, ranges, cx);
     }
 
     pub fn stage_and_next(
@@ -13687,16 +13687,53 @@ impl Editor {
     pub fn stage_or_unstage_diff_hunks(
         &mut self,
         stage: bool,
-        ranges: &[Range<Anchor>],
-        window: &mut Window,
+        ranges: Vec<Range<Anchor>>,
         cx: &mut Context<Self>,
     ) {
-        let snapshot = self.buffer.read(cx).snapshot(cx);
-        let chunk_by = self
-            .diff_hunks_in_ranges(&ranges, &snapshot)
-            .chunk_by(|hunk| hunk.buffer_id);
-        for (buffer_id, hunks) in &chunk_by {
-            self.do_stage_or_unstage(stage, buffer_id, hunks, window, cx);
+        let task = self.save_buffers_for_ranges_if_needed(&ranges, cx);
+        cx.spawn(|this, mut cx| async move {
+            task.await?;
+            this.update(&mut cx, |this, cx| {
+                let snapshot = this.buffer.read(cx).snapshot(cx);
+                let chunk_by = this
+                    .diff_hunks_in_ranges(&ranges, &snapshot)
+                    .chunk_by(|hunk| hunk.buffer_id);
+                for (buffer_id, hunks) in &chunk_by {
+                    this.do_stage_or_unstage(stage, buffer_id, hunks, cx);
+                }
+            })
+        })
+        .detach_and_log_err(cx);
+    }
+
+    fn save_buffers_for_ranges_if_needed(
+        &mut self,
+        ranges: &[Range<Anchor>],
+        cx: &mut Context<'_, Editor>,
+    ) -> Task<Result<()>> {
+        let multibuffer = self.buffer.read(cx);
+        let snapshot = multibuffer.read(cx);
+        let buffer_ids: HashSet<_> = ranges
+            .iter()
+            .flat_map(|range| snapshot.buffer_ids_for_range(range.clone()))
+            .collect();
+        drop(snapshot);
+
+        let mut buffers = HashSet::default();
+        for buffer_id in buffer_ids {
+            if let Some(buffer_entity) = multibuffer.buffer(buffer_id) {
+                let buffer = buffer_entity.read(cx);
+                if buffer.file().is_some_and(|file| file.disk_state().exists()) && buffer.is_dirty()
+                {
+                    buffers.insert(buffer_entity);
+                }
+            }
+        }
+
+        if let Some(project) = &self.project {
+            project.update(cx, |project, cx| project.save_buffers(buffers, cx))
+        } else {
+            Task::ready(Ok(()))
         }
     }
 
@@ -13709,7 +13746,7 @@ impl Editor {
         let ranges = self.selections.disjoint_anchor_ranges().collect::<Vec<_>>();
 
         if ranges.iter().any(|range| range.start != range.end) {
-            self.stage_or_unstage_diff_hunks(stage, &ranges[..], window, cx);
+            self.stage_or_unstage_diff_hunks(stage, ranges, cx);
             return;
         }
 
@@ -13728,7 +13765,7 @@ impl Editor {
         if run_twice {
             self.go_to_next_hunk(&GoToHunk, window, cx);
         }
-        self.stage_or_unstage_diff_hunks(stage, &ranges[..], window, cx);
+        self.stage_or_unstage_diff_hunks(stage, ranges, cx);
         self.go_to_next_hunk(&GoToHunk, window, cx);
     }
 
@@ -13737,31 +13774,16 @@ impl Editor {
         stage: bool,
         buffer_id: BufferId,
         hunks: impl Iterator<Item = MultiBufferDiffHunk>,
-        window: &mut Window,
         cx: &mut App,
-    ) {
-        let Some(project) = self.project.as_ref() else {
-            return;
-        };
-        let Some(buffer) = project.read(cx).buffer_for_id(buffer_id, cx) else {
-            return;
-        };
-        let Some(diff) = self.buffer.read(cx).diff_for(buffer_id) else {
-            return;
-        };
+    ) -> Option<()> {
+        let project = self.project.as_ref()?;
+        let buffer = project.read(cx).buffer_for_id(buffer_id, cx)?;
+        let diff = self.buffer.read(cx).diff_for(buffer_id)?;
         let buffer_snapshot = buffer.read(cx).snapshot();
         let file_exists = buffer_snapshot
             .file()
             .is_some_and(|file| file.disk_state().exists());
-        let Some((repo, path)) = project
-            .read(cx)
-            .repository_and_path_for_buffer_id(buffer_id, cx)
-        else {
-            log::debug!("no git repo for buffer id");
-            return;
-        };
-
-        let new_index_text = diff.update(cx, |diff, cx| {
+        diff.update(cx, |diff, cx| {
             diff.stage_or_unstage_hunks(
                 stage,
                 &hunks
@@ -13777,20 +13799,7 @@ impl Editor {
                 cx,
             )
         });
-
-        if file_exists {
-            let buffer_store = project.read(cx).buffer_store().clone();
-            buffer_store
-                .update(cx, |buffer_store, cx| buffer_store.save_buffer(buffer, cx))
-                .detach_and_log_err(cx);
-        }
-
-        let recv = repo
-            .read(cx)
-            .set_index_text(&path, new_index_text.map(|rope| rope.to_string()));
-
-        cx.background_spawn(async move { recv.await? })
-            .detach_and_notify_err(window, cx);
+        None
     }
 
     pub fn expand_selected_diff_hunks(&mut self, cx: &mut Context<Self>) {
@@ -16305,7 +16314,7 @@ fn get_uncommitted_diff_for_buffer(
         }
     });
     cx.spawn(|mut cx| async move {
-        let diffs = futures::future::join_all(tasks).await;
+        let diffs = future::join_all(tasks).await;
         buffer
             .update(&mut cx, |buffer, cx| {
                 for diff in diffs.into_iter().flatten() {

crates/editor/src/element.rs 🔗

@@ -77,7 +77,7 @@ use ui::{
     POPOVER_Y_PADDING,
 };
 use unicode_segmentation::UnicodeSegmentation;
-use util::{debug_panic, maybe, RangeExt, ResultExt};
+use util::{debug_panic, RangeExt, ResultExt};
 use workspace::{item::Item, notifications::NotifyTaskExt};
 
 const INLINE_BLAME_PADDING_EM_WIDTHS: f32 = 7.;
@@ -2676,24 +2676,21 @@ impl EditorElement {
         window: &mut Window,
         cx: &mut App,
     ) -> Div {
-        let file_status = maybe!({
-            let project = self.editor.read(cx).project.as_ref()?.read(cx);
-            let (repo, path) =
-                project.repository_and_path_for_buffer_id(for_excerpt.buffer_id, cx)?;
-            let status = repo.read(cx).repository_entry.status_for_path(&path)?;
-            Some(status.status)
-        })
-        .filter(|_| {
-            self.editor
-                .read(cx)
-                .buffer
-                .read(cx)
-                .all_diff_hunks_expanded()
-        });
-
-        let include_root = self
-            .editor
+        let editor = self.editor.read(cx);
+        let file_status = editor
+            .buffer
             .read(cx)
+            .all_diff_hunks_expanded()
+            .then(|| {
+                editor
+                    .project
+                    .as_ref()?
+                    .read(cx)
+                    .status_for_buffer_id(for_excerpt.buffer_id, cx)
+            })
+            .flatten();
+
+        let include_root = editor
             .project
             .as_ref()
             .map(|project| project.read(cx).visible_worktrees(cx).count() > 1)
@@ -2705,7 +2702,7 @@ impl EditorElement {
         let parent_path = path.as_ref().and_then(|path| {
             Some(path.parent()?.to_string_lossy().to_string() + std::path::MAIN_SEPARATOR_STR)
         });
-        let focus_handle = self.editor.focus_handle(cx);
+        let focus_handle = editor.focus_handle(cx);
         let colors = cx.theme().colors();
 
         div()
@@ -2778,8 +2775,7 @@ impl EditorElement {
                         )
                     })
                     .children(
-                        self.editor
-                            .read(cx)
+                        editor
                             .addons
                             .values()
                             .filter_map(|addon| {
@@ -8822,12 +8818,11 @@ fn diff_hunk_controls(
                 })
                 .on_click({
                     let editor = editor.clone();
-                    move |_event, window, cx| {
+                    move |_event, _window, cx| {
                         editor.update(cx, |editor, cx| {
                             editor.stage_or_unstage_diff_hunks(
                                 true,
-                                &[hunk_range.start..hunk_range.start],
-                                window,
+                                vec![hunk_range.start..hunk_range.start],
                                 cx,
                             );
                         });
@@ -8850,12 +8845,11 @@ fn diff_hunk_controls(
                 })
                 .on_click({
                     let editor = editor.clone();
-                    move |_event, window, cx| {
+                    move |_event, _window, cx| {
                         editor.update(cx, |editor, cx| {
                             editor.stage_or_unstage_diff_hunks(
                                 false,
-                                &[hunk_range.start..hunk_range.start],
-                                window,
+                                vec![hunk_range.start..hunk_range.start],
                                 cx,
                             );
                         });

crates/fs/src/fs.rs 🔗

@@ -1448,6 +1448,12 @@ impl FakeFs {
         });
     }
 
+    pub fn set_error_message_for_index_write(&self, dot_git: &Path, message: Option<String>) {
+        self.with_git_state(dot_git, true, |state| {
+            state.simulated_index_write_error_message = message;
+        });
+    }
+
     pub fn paths(&self, include_dot_git: bool) -> Vec<PathBuf> {
         let mut result = Vec::new();
         let mut queue = collections::VecDeque::new();

crates/git/src/repository.rs 🔗

@@ -862,6 +862,7 @@ pub struct FakeGitRepositoryState {
     pub statuses: HashMap<RepoPath, FileStatus>,
     pub current_branch_name: Option<String>,
     pub branches: HashSet<String>,
+    pub simulated_index_write_error_message: Option<String>,
 }
 
 impl FakeGitRepository {
@@ -881,6 +882,7 @@ impl FakeGitRepositoryState {
             statuses: Default::default(),
             current_branch_name: Default::default(),
             branches: Default::default(),
+            simulated_index_write_error_message: None,
         }
     }
 }
@@ -900,6 +902,9 @@ impl GitRepository for FakeGitRepository {
 
     fn set_index_text(&self, path: &RepoPath, content: Option<String>) -> anyhow::Result<()> {
         let mut state = self.state.lock();
+        if let Some(message) = state.simulated_index_write_error_message.clone() {
+            return Err(anyhow::anyhow!(message));
+        }
         if let Some(content) = content {
             state.index_contents.insert(path.clone(), content);
         } else {

crates/git_ui/src/git_panel.rs 🔗

@@ -307,6 +307,13 @@ impl GitPanel {
                         this.active_repository = git_store.read(cx).active_repository();
                         this.schedule_update(true, window, cx);
                     }
+                    GitEvent::IndexWriteError(error) => {
+                        this.workspace
+                            .update(cx, |workspace, cx| {
+                                workspace.show_error(error, cx);
+                            })
+                            .ok();
+                    }
                 },
             )
             .detach();

crates/git_ui/src/project_diff.rs 🔗

@@ -19,7 +19,10 @@ use gpui::{
 };
 use language::{Anchor, Buffer, Capability, OffsetRangeExt};
 use multi_buffer::{MultiBuffer, PathKey};
-use project::{git::GitStore, Project, ProjectPath};
+use project::{
+    git::{GitEvent, GitStore},
+    Project, ProjectPath,
+};
 use std::any::{Any, TypeId};
 use theme::ActiveTheme;
 use ui::{prelude::*, vertical_divider, Tooltip};
@@ -141,8 +144,13 @@ impl ProjectDiff {
         let git_store_subscription = cx.subscribe_in(
             &git_store,
             window,
-            move |this, _git_store, _event, _window, _cx| {
-                *this.update_needed.borrow_mut() = ();
+            move |this, _git_store, event, _window, _cx| match event {
+                GitEvent::ActiveRepositoryChanged
+                | GitEvent::FileSystemUpdated
+                | GitEvent::GitStateUpdated => {
+                    *this.update_needed.borrow_mut() = ();
+                }
+                _ => {}
             },
         );
 
@@ -1017,9 +1025,6 @@ mod tests {
         editor.update_in(cx, |editor, window, cx| {
             editor.git_restore(&Default::default(), window, cx);
         });
-        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
-            state.statuses = HashMap::default();
-        });
         cx.run_until_parked();
 
         assert_state_with_diff(&editor, cx, &"ˇ".unindent());

crates/project/src/buffer_store.rs 🔗

@@ -339,6 +339,7 @@ enum OpenBuffer {
 
 pub enum BufferStoreEvent {
     BufferAdded(Entity<Buffer>),
+    BufferDiffAdded(Entity<BufferDiff>),
     BufferDropped(BufferId),
     BufferChangedFilePath {
         buffer: Entity<Buffer>,
@@ -1522,11 +1523,12 @@ impl BufferStore {
             if let Some(OpenBuffer::Complete { diff_state, .. }) =
                 this.opened_buffers.get_mut(&buffer_id)
             {
+                let diff = cx.new(|cx| BufferDiff::new(&text_snapshot, cx));
+                cx.emit(BufferStoreEvent::BufferDiffAdded(diff.clone()));
                 diff_state.update(cx, |diff_state, cx| {
                     diff_state.language = language;
                     diff_state.language_registry = language_registry;
 
-                    let diff = cx.new(|cx| BufferDiff::new(&text_snapshot, cx));
                     match kind {
                         DiffKind::Unstaged => diff_state.unstaged_diff = Some(diff.downgrade()),
                         DiffKind::Uncommitted => {

crates/project/src/git.rs 🔗

@@ -1,24 +1,38 @@
-use crate::buffer_store::BufferStore;
-use crate::worktree_store::{WorktreeStore, WorktreeStoreEvent};
-use crate::{Project, ProjectPath};
+use crate::{
+    buffer_store::{BufferStore, BufferStoreEvent},
+    worktree_store::{WorktreeStore, WorktreeStoreEvent},
+    Project, ProjectItem, ProjectPath,
+};
 use anyhow::{Context as _, Result};
+use buffer_diff::BufferDiffEvent;
 use client::ProjectId;
-use futures::channel::{mpsc, oneshot};
-use futures::StreamExt as _;
-use git::repository::{Branch, CommitDetails, PushOptions, Remote, RemoteCommandOutput, ResetMode};
-use git::repository::{GitRepository, RepoPath};
+use futures::{
+    channel::{mpsc, oneshot},
+    StreamExt as _,
+};
+use git::{
+    repository::{
+        Branch, CommitDetails, GitRepository, PushOptions, Remote, RemoteCommandOutput, RepoPath,
+        ResetMode,
+    },
+    status::FileStatus,
+};
 use gpui::{
     App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task,
     WeakEntity,
 };
 use language::{Buffer, LanguageRegistry};
-use rpc::proto::{git_reset, ToProto};
-use rpc::{proto, AnyProtoClient, TypedEnvelope};
+use rpc::{
+    proto::{self, git_reset, ToProto},
+    AnyProtoClient, TypedEnvelope,
+};
 use settings::WorktreeId;
-use std::collections::VecDeque;
-use std::future::Future;
-use std::path::{Path, PathBuf};
-use std::sync::Arc;
+use std::{
+    collections::VecDeque,
+    future::Future,
+    path::{Path, PathBuf},
+    sync::Arc,
+};
 use text::BufferId;
 use util::{maybe, ResultExt};
 use worktree::{ProjectEntryId, RepositoryEntry, StatusEntry, WorkDirectory};
@@ -30,7 +44,7 @@ pub struct GitStore {
     repositories: Vec<Entity<Repository>>,
     active_index: Option<usize>,
     update_sender: mpsc::UnboundedSender<GitJob>,
-    _subscription: Subscription,
+    _subscriptions: [Subscription; 2],
 }
 
 pub struct Repository {
@@ -54,10 +68,12 @@ pub enum GitRepo {
     },
 }
 
+#[derive(Debug)]
 pub enum GitEvent {
     ActiveRepositoryChanged,
     FileSystemUpdated,
     GitStateUpdated,
+    IndexWriteError(anyhow::Error),
 }
 
 struct GitJob {
@@ -81,7 +97,10 @@ impl GitStore {
         cx: &mut Context<'_, Self>,
     ) -> Self {
         let update_sender = Self::spawn_git_worker(cx);
-        let _subscription = cx.subscribe(worktree_store, Self::on_worktree_store_event);
+        let _subscriptions = [
+            cx.subscribe(worktree_store, Self::on_worktree_store_event),
+            cx.subscribe(&buffer_store, Self::on_buffer_store_event),
+        ];
 
         GitStore {
             project_id,
@@ -90,7 +109,7 @@ impl GitStore {
             repositories: Vec::new(),
             active_index: None,
             update_sender,
-            _subscription,
+            _subscriptions,
         }
     }
 
@@ -227,10 +246,82 @@ impl GitStore {
         }
     }
 
+    fn on_buffer_store_event(
+        &mut self,
+        _: Entity<BufferStore>,
+        event: &BufferStoreEvent,
+        cx: &mut Context<'_, Self>,
+    ) {
+        if let BufferStoreEvent::BufferDiffAdded(diff) = event {
+            cx.subscribe(diff, Self::on_buffer_diff_event).detach();
+        }
+    }
+
+    fn on_buffer_diff_event(
+        this: &mut GitStore,
+        diff: Entity<buffer_diff::BufferDiff>,
+        event: &BufferDiffEvent,
+        cx: &mut Context<'_, GitStore>,
+    ) {
+        if let BufferDiffEvent::HunksStagedOrUnstaged(new_index_text) = event {
+            let buffer_id = diff.read(cx).buffer_id;
+            if let Some((repo, path)) = this.repository_and_path_for_buffer_id(buffer_id, cx) {
+                let recv = repo
+                    .read(cx)
+                    .set_index_text(&path, new_index_text.as_ref().map(|rope| rope.to_string()));
+                let diff = diff.downgrade();
+                cx.spawn(|this, mut cx| async move {
+                    if let Some(result) = cx.background_spawn(async move { recv.await.ok() }).await
+                    {
+                        if let Err(error) = result {
+                            diff.update(&mut cx, |diff, cx| {
+                                diff.clear_pending_hunks(cx);
+                            })
+                            .ok();
+                            this.update(&mut cx, |_, cx| cx.emit(GitEvent::IndexWriteError(error)))
+                                .ok();
+                        }
+                    }
+                })
+                .detach();
+            }
+        }
+    }
+
     pub fn all_repositories(&self) -> Vec<Entity<Repository>> {
         self.repositories.clone()
     }
 
+    pub fn status_for_buffer_id(&self, buffer_id: BufferId, cx: &App) -> Option<FileStatus> {
+        let (repo, path) = self.repository_and_path_for_buffer_id(buffer_id, cx)?;
+        let status = repo.read(cx).repository_entry.status_for_path(&path)?;
+        Some(status.status)
+    }
+
+    fn repository_and_path_for_buffer_id(
+        &self,
+        buffer_id: BufferId,
+        cx: &App,
+    ) -> Option<(Entity<Repository>, RepoPath)> {
+        let buffer = self.buffer_store.read(cx).get(buffer_id)?;
+        let path = buffer.read(cx).project_path(cx)?;
+        let mut result: Option<(Entity<Repository>, RepoPath)> = None;
+        for repo_handle in &self.repositories {
+            let repo = repo_handle.read(cx);
+            if repo.worktree_id == path.worktree_id {
+                if let Ok(relative_path) = repo.repository_entry.relativize(&path.path) {
+                    if result
+                        .as_ref()
+                        .is_none_or(|(result, _)| !repo.contains_sub_repo(result, cx))
+                    {
+                        result = Some((repo_handle.clone(), relative_path))
+                    }
+                }
+            }
+        }
+        result
+    }
+
     fn spawn_git_worker(cx: &mut Context<'_, GitStore>) -> mpsc::UnboundedSender<GitJob> {
         let (job_tx, mut job_rx) = mpsc::unbounded::<GitJob>();
 
@@ -658,9 +749,8 @@ impl GitStore {
         cx: &mut AsyncApp,
     ) -> Result<Entity<Repository>> {
         this.update(cx, |this, cx| {
-            let repository_handle = this
-                .all_repositories()
-                .into_iter()
+            this.repositories
+                .iter()
                 .find(|repository_handle| {
                     repository_handle.read(cx).worktree_id == worktree_id
                         && repository_handle
@@ -669,8 +759,8 @@ impl GitStore {
                             .work_directory_id()
                             == work_directory_id
                 })
-                .context("missing repository handle")?;
-            anyhow::Ok(repository_handle)
+                .context("missing repository handle")
+                .cloned()
         })?
     }
 }
@@ -1297,7 +1387,7 @@ impl Repository {
         })
     }
 
-    pub fn set_index_text(
+    fn set_index_text(
         &self,
         path: &RepoPath,
         content: Option<String>,

crates/project/src/project.rs 🔗

@@ -46,11 +46,7 @@ use futures::{
 pub use image_store::{ImageItem, ImageStore};
 use image_store::{ImageItemEvent, ImageStoreEvent};
 
-use ::git::{
-    blame::Blame,
-    repository::{GitRepository, RepoPath},
-    status::FileStatus,
-};
+use ::git::{blame::Blame, repository::GitRepository, status::FileStatus};
 use gpui::{
     AnyEntity, App, AppContext as _, AsyncApp, BorrowAppContext, Context, Entity, EventEmitter,
     Hsla, SharedString, Task, WeakEntity, Window,
@@ -2276,7 +2272,6 @@ impl Project {
             BufferStoreEvent::BufferAdded(buffer) => {
                 self.register_buffer(buffer, cx).log_err();
             }
-            BufferStoreEvent::BufferChangedFilePath { .. } => {}
             BufferStoreEvent::BufferDropped(buffer_id) => {
                 if let Some(ref ssh_client) = self.ssh_client {
                     ssh_client
@@ -2289,6 +2284,7 @@ impl Project {
                         .log_err();
                 }
             }
+            _ => {}
         }
     }
 
@@ -4336,35 +4332,8 @@ impl Project {
         self.git_store.read(cx).all_repositories()
     }
 
-    pub fn repository_and_path_for_buffer_id(
-        &self,
-        buffer_id: BufferId,
-        cx: &App,
-    ) -> Option<(Entity<Repository>, RepoPath)> {
-        let path = self
-            .buffer_for_id(buffer_id, cx)?
-            .read(cx)
-            .project_path(cx)?;
-
-        let mut found: Option<(Entity<Repository>, RepoPath)> = None;
-        for repo_handle in self.git_store.read(cx).all_repositories() {
-            let repo = repo_handle.read(cx);
-            if repo.worktree_id != path.worktree_id {
-                continue;
-            }
-            let Ok(relative_path) = repo.repository_entry.relativize(&path.path) else {
-                continue;
-            };
-            if found
-                .as_ref()
-                .is_some_and(|(found, _)| repo.contains_sub_repo(found, cx))
-            {
-                continue;
-            }
-            found = Some((repo_handle.clone(), relative_path))
-        }
-
-        found
+    pub fn status_for_buffer_id(&self, buffer_id: BufferId, cx: &App) -> Option<FileStatus> {
+        self.git_store.read(cx).status_for_buffer_id(buffer_id, cx)
     }
 }
 

crates/project/src/project_tests.rs 🔗

@@ -1,5 +1,7 @@
 use crate::{task_inventory::TaskContexts, Event, *};
-use buffer_diff::{assert_hunks, DiffHunkSecondaryStatus, DiffHunkStatus, DiffHunkStatusKind};
+use buffer_diff::{
+    assert_hunks, BufferDiffEvent, DiffHunkSecondaryStatus, DiffHunkStatus, DiffHunkStatusKind,
+};
 use fs::FakeFs;
 use futures::{future, StreamExt};
 use gpui::{App, SemanticVersion, UpdateGlobal};
@@ -5786,7 +5788,7 @@ async fn test_unstaged_diff_for_buffer(cx: &mut gpui::TestAppContext) {
     unstaged_diff.update(cx, |unstaged_diff, cx| {
         let snapshot = buffer.read(cx).snapshot();
         assert_hunks(
-            unstaged_diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx),
+            unstaged_diff.hunks(&snapshot, cx),
             &snapshot,
             &unstaged_diff.base_text_string().unwrap(),
             &[
@@ -6008,6 +6010,271 @@ async fn test_uncommitted_diff_for_buffer(cx: &mut gpui::TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_staging_hunks(cx: &mut gpui::TestAppContext) {
+    use DiffHunkSecondaryStatus::*;
+    init_test(cx);
+
+    let committed_contents = r#"
+        zero
+        one
+        two
+        three
+        four
+        five
+    "#
+    .unindent();
+    let file_contents = r#"
+        one
+        TWO
+        three
+        FOUR
+        five
+    "#
+    .unindent();
+
+    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 mut diff_events = cx.events(&uncommitted_diff);
+
+    // The hunks are initially unstaged.
+    uncommitted_diff.read_with(cx, |diff, cx| {
+        assert_hunks(
+            diff.hunks(&snapshot, cx),
+            &snapshot,
+            &diff.base_text_string().unwrap(),
+            &[
+                (
+                    0..0,
+                    "zero\n",
+                    "",
+                    DiffHunkStatus::deleted(HasSecondaryHunk),
+                ),
+                (
+                    1..2,
+                    "two\n",
+                    "TWO\n",
+                    DiffHunkStatus::modified(HasSecondaryHunk),
+                ),
+                (
+                    3..4,
+                    "four\n",
+                    "FOUR\n",
+                    DiffHunkStatus::modified(HasSecondaryHunk),
+                ),
+            ],
+        );
+    });
+
+    // Stage a hunk. It appears as optimistically staged.
+    uncommitted_diff.update(cx, |diff, cx| {
+        let range =
+            snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_before(Point::new(2, 0));
+        let hunks = diff
+            .hunks_intersecting_range(range, &snapshot, cx)
+            .collect::<Vec<_>>();
+        diff.stage_or_unstage_hunks(true, &hunks, &snapshot, true, cx);
+
+        assert_hunks(
+            diff.hunks(&snapshot, cx),
+            &snapshot,
+            &diff.base_text_string().unwrap(),
+            &[
+                (
+                    0..0,
+                    "zero\n",
+                    "",
+                    DiffHunkStatus::deleted(HasSecondaryHunk),
+                ),
+                (
+                    1..2,
+                    "two\n",
+                    "TWO\n",
+                    DiffHunkStatus::modified(SecondaryHunkRemovalPending),
+                ),
+                (
+                    3..4,
+                    "four\n",
+                    "FOUR\n",
+                    DiffHunkStatus::modified(HasSecondaryHunk),
+                ),
+            ],
+        );
+    });
+
+    // The diff emits a change event for the range of the staged hunk.
+    assert!(matches!(
+        diff_events.next().await.unwrap(),
+        BufferDiffEvent::HunksStagedOrUnstaged(_)
+    ));
+    let event = diff_events.next().await.unwrap();
+    if let BufferDiffEvent::DiffChanged {
+        changed_range: Some(changed_range),
+    } = event
+    {
+        let changed_range = changed_range.to_point(&snapshot);
+        assert_eq!(changed_range, Point::new(1, 0)..Point::new(2, 0));
+    } else {
+        panic!("Unexpected event {event:?}");
+    }
+
+    // When the write to the index completes, it appears as staged.
+    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(HasSecondaryHunk),
+                ),
+                (1..2, "two\n", "TWO\n", DiffHunkStatus::modified(None)),
+                (
+                    3..4,
+                    "four\n",
+                    "FOUR\n",
+                    DiffHunkStatus::modified(HasSecondaryHunk),
+                ),
+            ],
+        );
+    });
+
+    // The diff emits a change event for the changed index text.
+    let event = diff_events.next().await.unwrap();
+    if let BufferDiffEvent::DiffChanged {
+        changed_range: Some(changed_range),
+    } = event
+    {
+        let changed_range = changed_range.to_point(&snapshot);
+        assert_eq!(changed_range, Point::new(0, 0)..Point::new(5, 0));
+    } else {
+        panic!("Unexpected event {event:?}");
+    }
+
+    // Simulate a problem writing to the git index.
+    fs.set_error_message_for_index_write(
+        "/dir/.git".as_ref(),
+        Some("failed to write git index".into()),
+    );
+
+    // Stage another hunk.
+    uncommitted_diff.update(cx, |diff, cx| {
+        let range =
+            snapshot.anchor_before(Point::new(3, 0))..snapshot.anchor_before(Point::new(4, 0));
+        let hunks = diff
+            .hunks_intersecting_range(range, &snapshot, cx)
+            .collect::<Vec<_>>();
+        diff.stage_or_unstage_hunks(true, &hunks, &snapshot, true, cx);
+
+        assert_hunks(
+            diff.hunks(&snapshot, cx),
+            &snapshot,
+            &diff.base_text_string().unwrap(),
+            &[
+                (
+                    0..0,
+                    "zero\n",
+                    "",
+                    DiffHunkStatus::deleted(HasSecondaryHunk),
+                ),
+                (1..2, "two\n", "TWO\n", DiffHunkStatus::modified(None)),
+                (
+                    3..4,
+                    "four\n",
+                    "FOUR\n",
+                    DiffHunkStatus::modified(SecondaryHunkRemovalPending),
+                ),
+            ],
+        );
+    });
+    assert!(matches!(
+        diff_events.next().await.unwrap(),
+        BufferDiffEvent::HunksStagedOrUnstaged(_)
+    ));
+    let event = diff_events.next().await.unwrap();
+    if let BufferDiffEvent::DiffChanged {
+        changed_range: Some(changed_range),
+    } = event
+    {
+        let changed_range = changed_range.to_point(&snapshot);
+        assert_eq!(changed_range, Point::new(3, 0)..Point::new(4, 0));
+    } else {
+        panic!("Unexpected event {event:?}");
+    }
+
+    // When the write fails, the hunk returns to being unstaged.
+    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(HasSecondaryHunk),
+                ),
+                (1..2, "two\n", "TWO\n", DiffHunkStatus::modified(None)),
+                (
+                    3..4,
+                    "four\n",
+                    "FOUR\n",
+                    DiffHunkStatus::modified(HasSecondaryHunk),
+                ),
+            ],
+        );
+    });
+
+    let event = diff_events.next().await.unwrap();
+    if let BufferDiffEvent::DiffChanged {
+        changed_range: Some(changed_range),
+    } = event
+    {
+        let changed_range = changed_range.to_point(&snapshot);
+        assert_eq!(changed_range, Point::new(0, 0)..Point::new(5, 0));
+    } else {
+        panic!("Unexpected event {event:?}");
+    }
+}
+
 #[gpui::test]
 async fn test_single_file_diffs(cx: &mut gpui::TestAppContext) {
     init_test(cx);
@@ -6065,7 +6332,7 @@ async fn test_single_file_diffs(cx: &mut gpui::TestAppContext) {
     uncommitted_diff.update(cx, |uncommitted_diff, cx| {
         let snapshot = buffer.read(cx).snapshot();
         assert_hunks(
-            uncommitted_diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx),
+            uncommitted_diff.hunks(&snapshot, cx),
             &snapshot,
             &uncommitted_diff.base_text_string().unwrap(),
             &[(

crates/sum_tree/src/tree_map.rs 🔗

@@ -70,6 +70,10 @@ impl<K: Clone + Ord, V: Clone> TreeMap<K, V> {
         self.0.insert_or_replace(MapEntry { key, value }, &());
     }
 
+    pub fn clear(&mut self) {
+        self.0 = SumTree::default();
+    }
+
     pub fn remove(&mut self, key: &K) -> Option<V> {
         let mut removed = None;
         let mut cursor = self.0.cursor::<MapKeyRef<'_, K>>(&());
@@ -157,6 +161,14 @@ impl<K: Clone + Ord, V: Clone> TreeMap<K, V> {
         self.0.iter().map(|entry| &entry.value)
     }
 
+    pub fn first(&self) -> Option<(&K, &V)> {
+        self.0.first().map(|entry| (&entry.key, &entry.value))
+    }
+
+    pub fn last(&self) -> Option<(&K, &V)> {
+        self.0.last().map(|entry| (&entry.key, &entry.value))
+    }
+
     pub fn insert_tree(&mut self, other: TreeMap<K, V>) {
         let edits = other
             .iter()