Fix the project diff sometimes missing updates (#40662)

Cole Miller created

This PR does two related things:

- First, it gets rid of the undifferentiated `RepositoryEvent::Updated`
in favor of three new events that have clearer definitions:
`BranchChanged`, `StashEntriesChanged`, and `StatusesChanged`. An
implication of this is that we no longer emit a `RepositoryEvent` unless
some git state changed; previously we would emit `RepositoryUpdated`
after doing a git status scan even if no statuses changed.
- Second, it changes the subscription strategy of the project diff to
make it update more robustly. Previously, the project diff only
subscribed to the `GitStore`, so it relied on getting a `GitStoreEvent`
when some buffer's diff hunks changed, even if the git status of the
buffer's file didn't change (e.g. a second hunk in a file that was
already modified). After this PR, it also subscribes to the individual
`BufferDiff` entities for buffers that have a git status, so the
`GitStore` is freed from that responsibility. This also fixes some real
cases where the previous strategy was not effective in keeping the
project diff up to date (captured in a test).

Release Notes:

- Fixed some cases where the project diff would fail to update in
response to git events.

Change summary

crates/editor/src/editor.rs               |  11 --
crates/editor/src/editor_tests.rs         |  26 ++---
crates/editor/src/git/blame.rs            |   6 
crates/fs/src/fake_git_repo.rs            |  54 ++++++------
crates/fs/src/fs.rs                       |   3 
crates/git_ui/src/git_panel.rs            |  15 ++
crates/git_ui/src/project_diff.rs         | 105 +++++++++++++++++++++---
crates/git_ui/src/stash_picker.rs         |   2 
crates/multi_buffer/src/multi_buffer.rs   |   9 -
crates/project/src/git_store.rs           | 107 +++++++++++-------------
crates/project/src/project_tests.rs       |  18 ---
crates/project_panel/src/project_panel.rs |   8 +
crates/title_bar/src/title_bar.rs         |   9 -
13 files changed, 214 insertions(+), 159 deletions(-)

Detailed changes

crates/editor/src/editor.rs πŸ”—

@@ -156,7 +156,7 @@ use project::{
         },
         session::{Session, SessionEvent},
     },
-    git_store::{GitStoreEvent, RepositoryEvent},
+    git_store::GitStoreEvent,
     lsp_store::{
         CacheInlayHints, CompletionDocumentation, FormatTrigger, LspFormatTarget,
         OpenLspBufferHandle,
@@ -1978,14 +1978,7 @@ impl Editor {
             let git_store = project.read(cx).git_store().clone();
             let project = project.clone();
             project_subscriptions.push(cx.subscribe(&git_store, move |this, _, event, cx| {
-                if let GitStoreEvent::RepositoryUpdated(
-                    _,
-                    RepositoryEvent::Updated {
-                        new_instance: true, ..
-                    },
-                    _,
-                ) = event
-                {
+                if let GitStoreEvent::RepositoryAdded = event {
                     this.load_diff_task = Some(
                         update_uncommitted_diff_for_buffer(
                             cx.entity(),

crates/editor/src/editor_tests.rs πŸ”—

@@ -12615,18 +12615,6 @@ async fn test_strip_whitespace_and_format_via_lsp(cx: &mut TestAppContext) {
     )
     .await;
 
-    cx.run_until_parked();
-    // Set up a buffer white some trailing whitespace and no trailing newline.
-    cx.set_state(
-        &[
-            "one ",   //
-            "twoˇ",   //
-            "three ", //
-            "four",   //
-        ]
-        .join("\n"),
-    );
-
     // Record which buffer changes have been sent to the language server
     let buffer_changes = Arc::new(Mutex::new(Vec::new()));
     cx.lsp
@@ -12641,8 +12629,6 @@ async fn test_strip_whitespace_and_format_via_lsp(cx: &mut TestAppContext) {
                 );
             }
         });
-    cx.run_until_parked();
-
     // Handle formatting requests to the language server.
     cx.lsp
         .set_request_handler::<lsp::request::Formatting, _, _>({
@@ -12691,6 +12677,18 @@ async fn test_strip_whitespace_and_format_via_lsp(cx: &mut TestAppContext) {
             }
         });
 
+    // Set up a buffer white some trailing whitespace and no trailing newline.
+    cx.set_state(
+        &[
+            "one ",   //
+            "twoˇ",   //
+            "three ", //
+            "four",   //
+        ]
+        .join("\n"),
+    );
+    cx.run_until_parked();
+
     // Submit a format request.
     let format = cx
         .update_editor(|editor, window, cx| editor.format(&Format, window, cx))

crates/editor/src/git/blame.rs πŸ”—

@@ -16,7 +16,7 @@ use markdown::Markdown;
 use multi_buffer::{MultiBuffer, RowInfo};
 use project::{
     Project, ProjectItem as _,
-    git_store::{GitStoreEvent, Repository, RepositoryEvent},
+    git_store::{GitStoreEvent, Repository},
 };
 use smallvec::SmallVec;
 use std::{sync::Arc, time::Duration};
@@ -235,8 +235,8 @@ impl GitBlame {
         let git_store = project.read(cx).git_store().clone();
         let git_store_subscription =
             cx.subscribe(&git_store, move |this, _, event, cx| match event {
-                GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::Updated { .. }, _)
-                | GitStoreEvent::RepositoryAdded(_)
+                GitStoreEvent::RepositoryUpdated(_, _, _)
+                | GitStoreEvent::RepositoryAdded
                 | GitStoreEvent::RepositoryRemoved(_) => {
                     log::debug!("Status of git repositories updated. Regenerating blame data...",);
                     this.generate(cx);

crates/fs/src/fake_git_repo.rs πŸ”—

@@ -11,14 +11,20 @@ use git::{
     },
     status::{FileStatus, GitStatus, StatusCode, TrackedStatus, UnmergedStatus},
 };
-use gpui::{AsyncApp, BackgroundExecutor, SharedString, Task};
+use gpui::{AsyncApp, BackgroundExecutor, SharedString, Task, TaskLabel};
 use ignore::gitignore::GitignoreBuilder;
 use parking_lot::Mutex;
 use rope::Rope;
 use smol::future::FutureExt as _;
-use std::{path::PathBuf, sync::Arc};
+use std::{
+    path::PathBuf,
+    sync::{Arc, LazyLock},
+};
 use util::{paths::PathStyle, rel_path::RelPath};
 
+pub static LOAD_INDEX_TEXT_TASK: LazyLock<TaskLabel> = LazyLock::new(TaskLabel::new);
+pub static LOAD_HEAD_TEXT_TASK: LazyLock<TaskLabel> = LazyLock::new(TaskLabel::new);
+
 #[derive(Clone)]
 pub struct FakeGitRepository {
     pub(crate) fs: Arc<FakeFs>,
@@ -79,33 +85,29 @@ impl GitRepository for FakeGitRepository {
     fn reload_index(&self) {}
 
     fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
-        async {
-            self.with_state_async(false, move |state| {
-                state
-                    .index_contents
-                    .get(&path)
-                    .context("not present in index")
-                    .cloned()
-            })
-            .await
-            .ok()
-        }
-        .boxed()
+        let fut = self.with_state_async(false, move |state| {
+            state
+                .index_contents
+                .get(&path)
+                .context("not present in index")
+                .cloned()
+        });
+        self.executor
+            .spawn_labeled(*LOAD_INDEX_TEXT_TASK, async move { fut.await.ok() })
+            .boxed()
     }
 
     fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
-        async {
-            self.with_state_async(false, move |state| {
-                state
-                    .head_contents
-                    .get(&path)
-                    .context("not present in HEAD")
-                    .cloned()
-            })
-            .await
-            .ok()
-        }
-        .boxed()
+        let fut = self.with_state_async(false, move |state| {
+            state
+                .head_contents
+                .get(&path)
+                .context("not present in HEAD")
+                .cloned()
+        });
+        self.executor
+            .spawn_labeled(*LOAD_HEAD_TEXT_TASK, async move { fut.await.ok() })
+            .boxed()
     }
 
     fn load_commit(

crates/fs/src/fs.rs πŸ”—

@@ -58,6 +58,9 @@ use smol::io::AsyncReadExt;
 #[cfg(any(test, feature = "test-support"))]
 use std::ffi::OsStr;
 
+#[cfg(any(test, feature = "test-support"))]
+pub use fake_git_repo::{LOAD_HEAD_TEXT_TASK, LOAD_INDEX_TEXT_TASK};
+
 pub trait Watcher: Send + Sync {
     fn add(&self, path: &Path) -> Result<()>;
     fn remove(&self, path: &Path) -> Result<()>;

crates/git_ui/src/git_panel.rs πŸ”—

@@ -425,13 +425,20 @@ impl GitPanel {
                     }
                     GitStoreEvent::RepositoryUpdated(
                         _,
-                        RepositoryEvent::Updated { full_scan, .. },
+                        RepositoryEvent::StatusesChanged { full_scan: true }
+                        | RepositoryEvent::BranchChanged
+                        | RepositoryEvent::MergeHeadsChanged,
                         true,
                     ) => {
-                        this.schedule_update(*full_scan, window, cx);
+                        this.schedule_update(true, window, cx);
                     }
-
-                    GitStoreEvent::RepositoryAdded(_) | GitStoreEvent::RepositoryRemoved(_) => {
+                    GitStoreEvent::RepositoryUpdated(
+                        _,
+                        RepositoryEvent::StatusesChanged { full_scan: false },
+                        true,
+                    )
+                    | GitStoreEvent::RepositoryAdded
+                    | GitStoreEvent::RepositoryRemoved(_) => {
                         this.schedule_update(false, window, cx);
                     }
                     GitStoreEvent::IndexWriteError(error) => {

crates/git_ui/src/project_diff.rs πŸ”—

@@ -6,7 +6,7 @@ use crate::{
 };
 use anyhow::Result;
 use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus};
-use collections::HashSet;
+use collections::{HashMap, HashSet};
 use editor::{
     Editor, EditorEvent, SelectionEffects,
     actions::{GoToHunk, GoToPreviousHunk},
@@ -27,7 +27,7 @@ use language::{Anchor, Buffer, Capability, OffsetRangeExt};
 use multi_buffer::{MultiBuffer, PathKey};
 use project::{
     Project, ProjectPath,
-    git_store::{GitStore, GitStoreEvent, Repository},
+    git_store::{GitStore, GitStoreEvent, Repository, RepositoryEvent},
 };
 use settings::{Settings, SettingsStore};
 use std::any::{Any, TypeId};
@@ -57,12 +57,13 @@ pub struct ProjectDiff {
     multibuffer: Entity<MultiBuffer>,
     editor: Entity<Editor>,
     git_store: Entity<GitStore>,
+    buffer_diff_subscriptions: HashMap<RepoPath, (Entity<BufferDiff>, Subscription)>,
     workspace: WeakEntity<Workspace>,
     focus_handle: FocusHandle,
     update_needed: postage::watch::Sender<()>,
     pending_scroll: Option<PathKey>,
     _task: Task<Result<()>>,
-    _subscription: Subscription,
+    _git_store_subscription: Subscription,
 }
 
 #[derive(Debug)]
@@ -177,7 +178,11 @@ impl ProjectDiff {
             window,
             move |this, _git_store, event, _window, _cx| match event {
                 GitStoreEvent::ActiveRepositoryChanged(_)
-                | GitStoreEvent::RepositoryUpdated(_, _, true)
+                | GitStoreEvent::RepositoryUpdated(
+                    _,
+                    RepositoryEvent::StatusesChanged { full_scan: _ },
+                    true,
+                )
                 | GitStoreEvent::ConflictsUpdated => {
                     *this.update_needed.borrow_mut() = ();
                 }
@@ -217,10 +222,11 @@ impl ProjectDiff {
             focus_handle,
             editor,
             multibuffer,
+            buffer_diff_subscriptions: Default::default(),
             pending_scroll: None,
             update_needed: send,
             _task: worker,
-            _subscription: git_store_subscription,
+            _git_store_subscription: git_store_subscription,
         }
     }
 
@@ -365,6 +371,7 @@ impl ProjectDiff {
             self.multibuffer.update(cx, |multibuffer, cx| {
                 multibuffer.clear(cx);
             });
+            self.buffer_diff_subscriptions.clear();
             return vec![];
         };
 
@@ -407,6 +414,8 @@ impl ProjectDiff {
         });
         self.multibuffer.update(cx, |multibuffer, cx| {
             for path in previous_paths {
+                self.buffer_diff_subscriptions
+                    .remove(&path.path.clone().into());
                 multibuffer.remove_excerpts_for_path(path, cx);
             }
         });
@@ -419,9 +428,15 @@ impl ProjectDiff {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let path_key = diff_buffer.path_key;
-        let buffer = diff_buffer.buffer;
-        let diff = diff_buffer.diff;
+        let path_key = diff_buffer.path_key.clone();
+        let buffer = diff_buffer.buffer.clone();
+        let diff = diff_buffer.diff.clone();
+
+        let subscription = cx.subscribe(&diff, move |this, _, _, _| {
+            *this.update_needed.borrow_mut() = ();
+        });
+        self.buffer_diff_subscriptions
+            .insert(path_key.path.clone().into(), (diff.clone(), subscription));
 
         let conflict_addon = self
             .editor
@@ -440,9 +455,10 @@ impl ProjectDiff {
             .unwrap_or_default();
         let conflicts = conflicts.iter().map(|conflict| conflict.range.clone());
 
-        let excerpt_ranges = merge_anchor_ranges(diff_hunk_ranges, conflicts, &snapshot)
-            .map(|range| range.to_point(&snapshot))
-            .collect::<Vec<_>>();
+        let excerpt_ranges =
+            merge_anchor_ranges(diff_hunk_ranges.into_iter(), conflicts, &snapshot)
+                .map(|range| range.to_point(&snapshot))
+                .collect::<Vec<_>>();
 
         let (was_empty, is_excerpt_newly_added) = self.multibuffer.update(cx, |multibuffer, cx| {
             let was_empty = multibuffer.is_empty();
@@ -519,8 +535,7 @@ impl ProjectDiff {
         self.multibuffer
             .read(cx)
             .excerpt_paths()
-            .map(|key| key.path())
-            .cloned()
+            .map(|key| key.path.clone())
             .collect()
     }
 }
@@ -1621,8 +1636,8 @@ mod tests {
             cx,
             &"
                 - original
-                + different
-                  Λ‡"
+                + Λ‡different
+            "
             .unindent(),
         );
     }
@@ -1950,6 +1965,7 @@ mod tests {
             .unindent(),
         );
 
+        // The project diff updates its excerpts when a new hunk appears in a buffer that already has a diff.
         let buffer = project
             .update(cx, |project, cx| {
                 project.open_local_buffer(path!("/project/foo.txt"), cx)
@@ -2002,4 +2018,63 @@ mod tests {
             .unindent(),
         );
     }
+
+    #[gpui::test]
+    async fn test_update_on_uncommit(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                ".git": {},
+                "README.md": "# My cool project\n".to_owned()
+            }),
+        )
+        .await;
+        fs.set_head_and_index_for_repo(
+            Path::new(path!("/project/.git")),
+            &[("README.md", "# My cool project\n".to_owned())],
+        );
+        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
+        let worktree_id = project.read_with(cx, |project, cx| {
+            project.worktrees(cx).next().unwrap().read(cx).id()
+        });
+        let (workspace, cx) =
+            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+        cx.run_until_parked();
+
+        let _editor = workspace
+            .update_in(cx, |workspace, window, cx| {
+                workspace.open_path((worktree_id, rel_path("README.md")), None, true, window, cx)
+            })
+            .await
+            .unwrap()
+            .downcast::<Editor>()
+            .unwrap();
+
+        cx.focus(&workspace);
+        cx.update(|window, cx| {
+            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
+        });
+        cx.run_until_parked();
+        let item = workspace.update(cx, |workspace, cx| {
+            workspace.active_item_as::<ProjectDiff>(cx).unwrap()
+        });
+        cx.focus(&item);
+        let editor = item.read_with(cx, |item, _| item.editor.clone());
+
+        fs.set_head_and_index_for_repo(
+            Path::new(path!("/project/.git")),
+            &[(
+                "README.md",
+                "# My cool project\nDetails to come.\n".to_owned(),
+            )],
+        );
+        cx.run_until_parked();
+
+        let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
+
+        cx.assert_excerpts_with_selections("[EXCERPT]\nˇ# My cool project\nDetails to come.\n");
+    }
 }

crates/git_ui/src/stash_picker.rs πŸ”—

@@ -72,7 +72,7 @@ impl StashList {
         if let Some(repo) = repository.clone() {
             _subscriptions.push(
                 cx.subscribe_in(&repo, window, |this, _, event, window, cx| {
-                    if matches!(event, RepositoryEvent::Updated { .. }) {
+                    if matches!(event, RepositoryEvent::StashEntriesChanged) {
                         let stash_entries = this.picker.read_with(cx, |picker, cx| {
                             picker
                                 .delegate

crates/multi_buffer/src/multi_buffer.rs πŸ”—

@@ -166,8 +166,8 @@ impl MultiBufferDiffHunk {
 #[derive(PartialEq, Eq, Ord, PartialOrd, Clone, Hash, Debug)]
 pub struct PathKey {
     // Used by the derived PartialOrd & Ord
-    sort_prefix: Option<u64>,
-    path: Arc<RelPath>,
+    pub sort_prefix: Option<u64>,
+    pub path: Arc<RelPath>,
 }
 
 impl PathKey {
@@ -190,11 +190,6 @@ impl PathKey {
             }
         }
     }
-
-    #[cfg(any(test, feature = "test-support"))]
-    pub fn path(&self) -> &Arc<RelPath> {
-        &self.path
-    }
 }
 
 pub type MultiBufferPoint = Point;

crates/project/src/git_store.rs πŸ”—

@@ -301,9 +301,13 @@ pub enum RepositoryState {
 
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub enum RepositoryEvent {
-    Updated { full_scan: bool, new_instance: bool },
+    StatusesChanged {
+        // TODO could report which statuses changed here
+        full_scan: bool,
+    },
     MergeHeadsChanged,
-    PathsChanged,
+    BranchChanged,
+    StashEntriesChanged,
 }
 
 #[derive(Clone, Debug)]
@@ -313,7 +317,7 @@ pub struct JobsUpdated;
 pub enum GitStoreEvent {
     ActiveRepositoryChanged(Option<RepositoryId>),
     RepositoryUpdated(RepositoryId, RepositoryEvent, bool),
-    RepositoryAdded(RepositoryId),
+    RepositoryAdded,
     RepositoryRemoved(RepositoryId),
     IndexWriteError(anyhow::Error),
     JobsUpdated,
@@ -1218,7 +1222,7 @@ impl GitStore {
                 self._subscriptions
                     .push(cx.subscribe(&repo, Self::on_jobs_updated));
                 self.repositories.insert(id, repo);
-                cx.emit(GitStoreEvent::RepositoryAdded(id));
+                cx.emit(GitStoreEvent::RepositoryAdded);
                 self.active_repo_id.get_or_insert_with(|| {
                     cx.emit(GitStoreEvent::ActiveRepositoryChanged(Some(id)));
                     id
@@ -1485,11 +1489,10 @@ impl GitStore {
             let id = RepositoryId::from_proto(update.id);
             let client = this.upstream_client().context("no upstream client")?;
 
-            let mut is_new = false;
+            let mut repo_subscription = None;
             let repo = this.repositories.entry(id).or_insert_with(|| {
-                is_new = true;
                 let git_store = cx.weak_entity();
-                cx.new(|cx| {
+                let repo = cx.new(|cx| {
                     Repository::remote(
                         id,
                         Path::new(&update.abs_path).into(),
@@ -1499,16 +1502,16 @@ impl GitStore {
                         git_store,
                         cx,
                     )
-                })
+                });
+                repo_subscription = Some(cx.subscribe(&repo, Self::on_repository_event));
+                cx.emit(GitStoreEvent::RepositoryAdded);
+                repo
             });
-            if is_new {
-                this._subscriptions
-                    .push(cx.subscribe(repo, Self::on_repository_event))
-            }
+            this._subscriptions.extend(repo_subscription);
 
             repo.update(cx, {
                 let update = update.clone();
-                |repo, cx| repo.apply_remote_update(update, is_new, cx)
+                |repo, cx| repo.apply_remote_update(update, cx)
             })?;
 
             this.active_repo_id.get_or_insert_with(|| {
@@ -3877,18 +3880,15 @@ impl Repository {
                     environment,
                     ..
                 } => {
+                    // TODO would be nice to not have to do this manually
                     let result = backend.stash_drop(index, environment).await;
                     if result.is_ok()
                         && let Ok(stash_entries) = backend.stash_entries().await
                     {
                         let snapshot = this.update(&mut cx, |this, cx| {
                             this.snapshot.stash_entries = stash_entries;
-                            let snapshot = this.snapshot.clone();
-                            cx.emit(RepositoryEvent::Updated {
-                                full_scan: false,
-                                new_instance: false,
-                            });
-                            snapshot
+                            cx.emit(RepositoryEvent::StashEntriesChanged);
+                            this.snapshot.clone()
                         })?;
                         if let Some(updates_tx) = updates_tx {
                             updates_tx
@@ -4048,18 +4048,15 @@ impl Repository {
                                 cx.clone(),
                             )
                             .await;
+                        // TODO would be nice to not have to do this manually
                         if result.is_ok() {
                             let branches = backend.branches().await?;
                             let branch = branches.into_iter().find(|branch| branch.is_head);
                             log::info!("head branch after scan is {branch:?}");
                             let snapshot = this.update(&mut cx, |this, cx| {
                                 this.snapshot.branch = branch;
-                                let snapshot = this.snapshot.clone();
-                                cx.emit(RepositoryEvent::Updated {
-                                    full_scan: false,
-                                    new_instance: false,
-                                });
-                                snapshot
+                                cx.emit(RepositoryEvent::BranchChanged);
+                                this.snapshot.clone()
                             })?;
                             if let Some(updates_tx) = updates_tx {
                                 updates_tx
@@ -4458,7 +4455,6 @@ impl Repository {
     pub(crate) fn apply_remote_update(
         &mut self,
         update: proto::UpdateRepository,
-        is_new: bool,
         cx: &mut Context<Self>,
     ) -> Result<()> {
         let conflicted_paths = TreeSet::from_ordered_entries(
@@ -4467,21 +4463,30 @@ impl Repository {
                 .into_iter()
                 .filter_map(|path| RepoPath::from_proto(&path).log_err()),
         );
-        self.snapshot.branch = update.branch_summary.as_ref().map(proto_to_branch);
-        self.snapshot.head_commit = update
+        let new_branch = update.branch_summary.as_ref().map(proto_to_branch);
+        let new_head_commit = update
             .head_commit_details
             .as_ref()
             .map(proto_to_commit_details);
+        if self.snapshot.branch != new_branch || self.snapshot.head_commit != new_head_commit {
+            cx.emit(RepositoryEvent::BranchChanged)
+        }
+        self.snapshot.branch = new_branch;
+        self.snapshot.head_commit = new_head_commit;
 
         self.snapshot.merge.conflicted_paths = conflicted_paths;
         self.snapshot.merge.message = update.merge_message.map(SharedString::from);
-        self.snapshot.stash_entries = GitStash {
+        let new_stash_entries = GitStash {
             entries: update
                 .stash_entries
                 .iter()
                 .filter_map(|entry| proto_to_stash(entry).ok())
                 .collect(),
         };
+        if self.snapshot.stash_entries != new_stash_entries {
+            cx.emit(RepositoryEvent::StashEntriesChanged)
+        }
+        self.snapshot.stash_entries = new_stash_entries;
 
         let edits = update
             .removed_statuses
@@ -4500,14 +4505,13 @@ impl Repository {
                     }),
             )
             .collect::<Vec<_>>();
+        if !edits.is_empty() {
+            cx.emit(RepositoryEvent::StatusesChanged { full_scan: true });
+        }
         self.snapshot.statuses_by_path.edit(edits, ());
         if update.is_last_update {
             self.snapshot.scan_id = update.scan_id;
         }
-        cx.emit(RepositoryEvent::Updated {
-            full_scan: true,
-            new_instance: is_new,
-        });
         Ok(())
     }
 
@@ -4830,23 +4834,19 @@ impl Repository {
                     .await;
 
                 this.update(&mut cx, |this, cx| {
-                    let needs_update = !changed_path_statuses.is_empty()
-                        || this.snapshot.stash_entries != stash_entries;
-                    this.snapshot.stash_entries = stash_entries;
+                    if this.snapshot.stash_entries != stash_entries {
+                        cx.emit(RepositoryEvent::StashEntriesChanged);
+                        this.snapshot.stash_entries = stash_entries;
+                    }
+
                     if !changed_path_statuses.is_empty() {
+                        cx.emit(RepositoryEvent::StatusesChanged { full_scan: false });
                         this.snapshot
                             .statuses_by_path
                             .edit(changed_path_statuses, ());
                         this.snapshot.scan_id += 1;
                     }
 
-                    if needs_update {
-                        cx.emit(RepositoryEvent::Updated {
-                            full_scan: false,
-                            new_instance: false,
-                        });
-                    }
-
                     if let Some(updates_tx) = updates_tx {
                         updates_tx
                             .unbounded_send(DownstreamUpdate::UpdateRepository(
@@ -4854,7 +4854,6 @@ impl Repository {
                             ))
                             .ok();
                     }
-                    cx.emit(RepositoryEvent::PathsChanged);
                 })
             },
         );
@@ -5117,28 +5116,24 @@ async fn compute_snapshot(
         MergeDetails::load(&backend, &statuses_by_path, &prev_snapshot).await?;
     log::debug!("new merge details (changed={merge_heads_changed:?}): {merge_details:?}");
 
-    if merge_heads_changed
-        || branch != prev_snapshot.branch
-        || statuses_by_path != prev_snapshot.statuses_by_path
-    {
-        events.push(RepositoryEvent::Updated {
-            full_scan: true,
-            new_instance: false,
-        });
-    }
-
-    // Cache merge conflict paths so they don't change from staging/unstaging,
-    // until the merge heads change (at commit time, etc.).
     if merge_heads_changed {
         events.push(RepositoryEvent::MergeHeadsChanged);
     }
 
+    if statuses_by_path != prev_snapshot.statuses_by_path {
+        events.push(RepositoryEvent::StatusesChanged { full_scan: true })
+    }
+
     // Useful when branch is None in detached head state
     let head_commit = match backend.head_sha().await {
         Some(head_sha) => backend.show(head_sha).await.log_err(),
         None => None,
     };
 
+    if branch != prev_snapshot.branch || head_commit != prev_snapshot.head_commit {
+        events.push(RepositoryEvent::BranchChanged);
+    }
+
     // Used by edit prediction data collection
     let remote_origin_url = backend.remote_url("origin");
     let remote_upstream_url = backend.remote_url("upstream");

crates/project/src/project_tests.rs πŸ”—

@@ -8936,10 +8936,7 @@ async fn test_ignored_dirs_events(cx: &mut gpui::TestAppContext) {
     assert_eq!(
         repository_updates.lock().drain(..).collect::<Vec<_>>(),
         vec![
-            RepositoryEvent::Updated {
-                full_scan: true,
-                new_instance: false,
-            },
+            RepositoryEvent::StatusesChanged { full_scan: true },
             RepositoryEvent::MergeHeadsChanged,
         ],
         "Initial worktree scan should produce a repo update event"
@@ -9000,7 +8997,6 @@ async fn test_ignored_dirs_events(cx: &mut gpui::TestAppContext) {
         repository_updates
             .lock()
             .iter()
-            .filter(|update| !matches!(update, RepositoryEvent::PathsChanged))
             .cloned()
             .collect::<Vec<_>>(),
         Vec::new(),
@@ -9104,17 +9100,10 @@ async fn test_odd_events_for_ignored_dirs(
     });
 
     assert_eq!(
-        repository_updates
-            .lock()
-            .drain(..)
-            .filter(|update| !matches!(update, RepositoryEvent::PathsChanged))
-            .collect::<Vec<_>>(),
+        repository_updates.lock().drain(..).collect::<Vec<_>>(),
         vec![
-            RepositoryEvent::Updated {
-                full_scan: true,
-                new_instance: false,
-            },
             RepositoryEvent::MergeHeadsChanged,
+            RepositoryEvent::BranchChanged
         ],
         "Initial worktree scan should produce a repo update event"
     );
@@ -9142,7 +9131,6 @@ async fn test_odd_events_for_ignored_dirs(
         repository_updates
             .lock()
             .iter()
-            .filter(|update| !matches!(update, RepositoryEvent::PathsChanged))
             .cloned()
             .collect::<Vec<_>>(),
         Vec::new(),

crates/project_panel/src/project_panel.rs πŸ”—

@@ -496,8 +496,12 @@ impl ProjectPanel {
                 &git_store,
                 window,
                 |this, _, event, window, cx| match event {
-                    GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::Updated { .. }, _)
-                    | GitStoreEvent::RepositoryAdded(_)
+                    GitStoreEvent::RepositoryUpdated(
+                        _,
+                        RepositoryEvent::StatusesChanged { full_scan: _ },
+                        _,
+                    )
+                    | GitStoreEvent::RepositoryAdded
                     | GitStoreEvent::RepositoryRemoved(_) => {
                         this.update_visible_entries(None, false, false, window, cx);
                         cx.notify();

crates/title_bar/src/title_bar.rs πŸ”—

@@ -30,10 +30,7 @@ use gpui::{
     Subscription, WeakEntity, Window, actions, div,
 };
 use onboarding_banner::OnboardingBanner;
-use project::{
-    Project, WorktreeSettings,
-    git_store::{GitStoreEvent, RepositoryEvent},
-};
+use project::{Project, WorktreeSettings, git_store::GitStoreEvent};
 use remote::RemoteConnectionOptions;
 use settings::{Settings, SettingsLocation};
 use std::sync::Arc;
@@ -287,9 +284,7 @@ impl TitleBar {
         subscriptions.push(
             cx.subscribe(&git_store, move |_, _, event, cx| match event {
                 GitStoreEvent::ActiveRepositoryChanged(_)
-                | GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::Updated { .. }, _)
-                | GitStoreEvent::RepositoryAdded(_)
-                | GitStoreEvent::RepositoryRemoved(_) => {
+                | GitStoreEvent::RepositoryUpdated(_, _, true) => {
                     cx.notify();
                 }
                 _ => {}