Git activity indicator (#28204)

Julia Ryan and Mikayla Maki created

Closes #26182

Release Notes:

- Added an activity indicator for long-running git commands.

---------

Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>

Change summary

crates/activity_indicator/src/activity_indicator.rs           |  48 
crates/agent/src/message_editor.rs                            |   3 
crates/agent/src/thread.rs                                    |  22 
crates/collab/src/tests/integration_tests.rs                  |  24 
crates/collab/src/tests/remote_editing_collaboration_tests.rs |  26 
crates/editor/src/git/blame.rs                                |   4 
crates/git_ui/src/branch_picker.rs                            |  23 
crates/git_ui/src/git_panel.rs                                |  69 
crates/project/src/git_store.rs                               | 735 ++--
crates/project/src/project.rs                                 |  14 
crates/remote_server/src/remote_editing_tests.rs              |  24 
11 files changed, 556 insertions(+), 436 deletions(-)

Detailed changes

crates/activity_indicator/src/activity_indicator.rs 🔗

@@ -11,13 +11,22 @@ use language::{BinaryStatus, LanguageRegistry, LanguageServerId};
 use project::{
     EnvironmentErrorMessage, LanguageServerProgress, LspStoreEvent, Project,
     ProjectEnvironmentEvent,
+    git_store::{GitStoreEvent, Repository},
 };
 use smallvec::SmallVec;
-use std::{cmp::Reverse, fmt::Write, path::Path, sync::Arc, time::Duration};
+use std::{
+    cmp::Reverse,
+    fmt::Write,
+    path::Path,
+    sync::Arc,
+    time::{Duration, Instant},
+};
 use ui::{ButtonLike, ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*};
 use util::truncate_and_trailoff;
 use workspace::{StatusItemView, Workspace, item::ItemHandle};
 
+const GIT_OPERATION_DELAY: Duration = Duration::from_millis(0);
+
 actions!(activity_indicator, [ShowErrorMessage]);
 
 pub enum Event {
@@ -105,6 +114,15 @@ impl ActivityIndicator {
             )
             .detach();
 
+            cx.subscribe(
+                &project.read(cx).git_store().clone(),
+                |_, _, event: &GitStoreEvent, cx| match event {
+                    project::git_store::GitStoreEvent::JobsUpdated => cx.notify(),
+                    _ => {}
+                },
+            )
+            .detach();
+
             if let Some(auto_updater) = auto_updater.as_ref() {
                 cx.observe(auto_updater, |_, _, cx| cx.notify()).detach();
             }
@@ -285,6 +303,34 @@ impl ActivityIndicator {
             });
         }
 
+        let current_job = self
+            .project
+            .read(cx)
+            .active_repository(cx)
+            .map(|r| r.read(cx))
+            .and_then(Repository::current_job);
+        // Show any long-running git command
+        if let Some(job_info) = current_job {
+            if Instant::now() - job_info.start >= GIT_OPERATION_DELAY {
+                return Some(Content {
+                    icon: Some(
+                        Icon::new(IconName::ArrowCircle)
+                            .size(IconSize::Small)
+                            .with_animation(
+                                "arrow-circle",
+                                Animation::new(Duration::from_secs(2)).repeat(),
+                                |icon, delta| {
+                                    icon.transform(Transformation::rotate(percentage(delta)))
+                                },
+                            )
+                            .into_any_element(),
+                    ),
+                    message: job_info.message.into(),
+                    on_click: None,
+                });
+            }
+        }
+
         // Show any language server installation info.
         let mut downloading = SmallVec::<[_; 3]>::new();
         let mut checking_for_update = SmallVec::<[_; 3]>::new();

crates/agent/src/message_editor.rs 🔗

@@ -226,7 +226,8 @@ impl MessageEditor {
 
         let thread = self.thread.clone();
         let context_store = self.context_store.clone();
-        let checkpoint = self.project.read(cx).git_store().read(cx).checkpoint(cx);
+        let git_store = self.project.read(cx).git_store().clone();
+        let checkpoint = git_store.update(cx, |git_store, cx| git_store.checkpoint(cx));
 
         cx.spawn(async move |this, cx| {
             let checkpoint = checkpoint.await.ok();

crates/agent/src/thread.rs 🔗

@@ -471,11 +471,11 @@ impl Thread {
         cx.emit(ThreadEvent::CheckpointChanged);
         cx.notify();
 
-        let project = self.project.read(cx);
-        let restore = project
-            .git_store()
-            .read(cx)
-            .restore_checkpoint(checkpoint.git_checkpoint.clone(), cx);
+        let git_store = self.project().read(cx).git_store().clone();
+        let restore = git_store.update(cx, |git_store, cx| {
+            git_store.restore_checkpoint(checkpoint.git_checkpoint.clone(), cx)
+        });
+
         cx.spawn(async move |this, cx| {
             let result = restore.await;
             this.update(cx, |this, cx| {
@@ -506,11 +506,11 @@ impl Thread {
         };
 
         let git_store = self.project.read(cx).git_store().clone();
-        let final_checkpoint = git_store.read(cx).checkpoint(cx);
+        let final_checkpoint = git_store.update(cx, |git_store, cx| git_store.checkpoint(cx));
         cx.spawn(async move |this, cx| match final_checkpoint.await {
             Ok(final_checkpoint) => {
                 let equal = git_store
-                    .read_with(cx, |store, cx| {
+                    .update(cx, |store, cx| {
                         store.compare_checkpoints(
                             pending_checkpoint.git_checkpoint.clone(),
                             final_checkpoint.clone(),
@@ -522,7 +522,7 @@ impl Thread {
 
                 if equal {
                     git_store
-                        .read_with(cx, |store, cx| {
+                        .update(cx, |store, cx| {
                             store.delete_checkpoint(pending_checkpoint.git_checkpoint, cx)
                         })?
                         .detach();
@@ -533,7 +533,7 @@ impl Thread {
                 }
 
                 git_store
-                    .read_with(cx, |store, cx| {
+                    .update(cx, |store, cx| {
                         store.delete_checkpoint(final_checkpoint, cx)
                     })?
                     .detach();
@@ -1650,10 +1650,10 @@ impl Thread {
                 .ok()
                 .flatten()
                 .map(|repo| {
-                    repo.read_with(cx, |repo, _| {
+                    repo.update(cx, |repo, _| {
                         let current_branch =
                             repo.branch.as_ref().map(|branch| branch.name.to_string());
-                        repo.send_job(|state, _| async move {
+                        repo.send_job(None, |state, _| async move {
                             let RepositoryState::Local { backend, .. } = state else {
                                 return GitState {
                                     remote_url: None,

crates/collab/src/tests/integration_tests.rs 🔗

@@ -6866,10 +6866,14 @@ async fn test_remote_git_branches(
 
     assert_eq!(branches_b, branches_set);
 
-    cx_b.update(|cx| repo_b.read(cx).change_branch(new_branch.to_string()))
-        .await
-        .unwrap()
-        .unwrap();
+    cx_b.update(|cx| {
+        repo_b.update(cx, |repository, _cx| {
+            repository.change_branch(new_branch.to_string())
+        })
+    })
+    .await
+    .unwrap()
+    .unwrap();
 
     executor.run_until_parked();
 
@@ -6892,18 +6896,18 @@ async fn test_remote_git_branches(
 
     // Also try creating a new branch
     cx_b.update(|cx| {
-        repo_b
-            .read(cx)
-            .create_branch("totally-new-branch".to_string())
+        repo_b.update(cx, |repository, _cx| {
+            repository.create_branch("totally-new-branch".to_string())
+        })
     })
     .await
     .unwrap()
     .unwrap();
 
     cx_b.update(|cx| {
-        repo_b
-            .read(cx)
-            .change_branch("totally-new-branch".to_string())
+        repo_b.update(cx, |repository, _cx| {
+            repository.change_branch("totally-new-branch".to_string())
+        })
     })
     .await
     .unwrap()

crates/collab/src/tests/remote_editing_collaboration_tests.rs 🔗

@@ -283,7 +283,7 @@ async fn test_ssh_collaboration_git_branches(
     let repo_b = cx_b.update(|cx| project_b.read(cx).active_repository(cx).unwrap());
 
     let branches_b = cx_b
-        .update(|cx| repo_b.read(cx).branches())
+        .update(|cx| repo_b.update(cx, |repo_b, _cx| repo_b.branches()))
         .await
         .unwrap()
         .unwrap();
@@ -297,10 +297,14 @@ async fn test_ssh_collaboration_git_branches(
 
     assert_eq!(&branches_b, &branches_set);
 
-    cx_b.update(|cx| repo_b.read(cx).change_branch(new_branch.to_string()))
-        .await
-        .unwrap()
-        .unwrap();
+    cx_b.update(|cx| {
+        repo_b.update(cx, |repo_b, _cx| {
+            repo_b.change_branch(new_branch.to_string())
+        })
+    })
+    .await
+    .unwrap()
+    .unwrap();
 
     executor.run_until_parked();
 
@@ -325,18 +329,18 @@ async fn test_ssh_collaboration_git_branches(
 
     // Also try creating a new branch
     cx_b.update(|cx| {
-        repo_b
-            .read(cx)
-            .create_branch("totally-new-branch".to_string())
+        repo_b.update(cx, |repo_b, _cx| {
+            repo_b.create_branch("totally-new-branch".to_string())
+        })
     })
     .await
     .unwrap()
     .unwrap();
 
     cx_b.update(|cx| {
-        repo_b
-            .read(cx)
-            .change_branch("totally-new-branch".to_string())
+        repo_b.update(cx, |repo_b, _cx| {
+            repo_b.change_branch("totally-new-branch".to_string())
+        })
     })
     .await
     .unwrap()

crates/editor/src/git/blame.rs 🔗

@@ -436,7 +436,9 @@ impl GitBlame {
         }
         let buffer_edits = self.buffer.update(cx, |buffer, _| buffer.subscribe());
         let snapshot = self.buffer.read(cx).snapshot();
-        let blame = self.project.read(cx).blame_buffer(&self.buffer, None, cx);
+        let blame = self.project.update(cx, |project, cx| {
+            project.blame_buffer(&self.buffer, None, cx)
+        });
         let provider_registry = GitHostingProviderRegistry::default_global(cx);
 
         self.task = cx.spawn(async move |this, cx| {

crates/git_ui/src/branch_picker.rs 🔗

@@ -88,7 +88,7 @@ impl BranchList {
     ) -> Self {
         let all_branches_request = repository
             .clone()
-            .map(|repository| repository.read(cx).branches());
+            .map(|repository| repository.update(cx, |repository, _| repository.branches()));
 
         cx.spawn_in(window, async move |this, cx| {
             let mut all_branches = all_branches_request
@@ -202,10 +202,15 @@ impl BranchListDelegate {
             return;
         };
         cx.spawn(async move |_, cx| {
-            cx.update(|cx| repo.read(cx).create_branch(new_branch_name.to_string()))?
-                .await??;
-            cx.update(|cx| repo.read(cx).change_branch(new_branch_name.to_string()))?
-                .await??;
+            repo.update(cx, |repo, _| {
+                repo.create_branch(new_branch_name.to_string())
+            })?
+            .await??;
+            repo.update(cx, |repo, _| {
+                repo.change_branch(new_branch_name.to_string())
+            })?
+            .await??;
+
             Ok(())
         })
         .detach_and_prompt_err("Failed to create branch", window, cx, |e, _, _| {
@@ -359,11 +364,13 @@ impl PickerDelegate for BranchListDelegate {
                         .ok_or_else(|| anyhow!("No active repository"))?
                         .clone();
 
-                    let cx = cx.to_async();
+                    let mut cx = cx.to_async();
 
                     anyhow::Ok(async move {
-                        cx.update(|cx| repo.read(cx).change_branch(branch.name.to_string()))?
-                            .await?
+                        repo.update(&mut cx, |repo, _| {
+                            repo.change_branch(branch.name.to_string())
+                        })?
+                        .await?
                     })
                 })??;
 

crates/git_ui/src/git_panel.rs 🔗

@@ -53,10 +53,8 @@ use project::{
 };
 use serde::{Deserialize, Serialize};
 use settings::{Settings as _, SettingsStore};
-use std::cell::RefCell;
 use std::future::Future;
 use std::path::{Path, PathBuf};
-use std::rc::Rc;
 use std::{collections::HashSet, sync::Arc, time::Duration, usize};
 use strum::{IntoEnumIterator, VariantNames};
 use time::OffsetDateTime;
@@ -64,7 +62,7 @@ use ui::{
     Checkbox, ContextMenu, ElevationIndex, PopoverMenu, Scrollbar, ScrollbarState, Tooltip,
     prelude::*,
 };
-use util::{ResultExt, TryFutureExt, maybe, post_inc};
+use util::{ResultExt, TryFutureExt, maybe};
 use workspace::AppState;
 
 use notifications::status_toast::{StatusToast, ToastIcon};
@@ -232,8 +230,6 @@ struct PendingOperation {
     op_id: usize,
 }
 
-type RemoteOperations = Rc<RefCell<HashSet<u32>>>;
-
 // computed state related to how to render scrollbars
 // one per axis
 // on render we just read this off the panel
@@ -290,8 +286,6 @@ impl ScrollbarProperties {
 }
 
 pub struct GitPanel {
-    remote_operation_id: u32,
-    pending_remote_operations: RemoteOperations,
     pub(crate) active_repository: Option<Entity<Repository>>,
     pub(crate) commit_editor: Entity<Editor>,
     conflicted_count: usize,
@@ -327,17 +321,6 @@ pub struct GitPanel {
     _settings_subscription: Subscription,
 }
 
-struct RemoteOperationGuard {
-    id: u32,
-    pending_remote_operations: RemoteOperations,
-}
-
-impl Drop for RemoteOperationGuard {
-    fn drop(&mut self) {
-        self.pending_remote_operations.borrow_mut().remove(&self.id);
-    }
-}
-
 const MAX_PANEL_EDITOR_LINES: usize = 6;
 
 pub(crate) fn commit_message_editor(
@@ -416,7 +399,7 @@ impl GitPanel {
                 ) => {
                     this.schedule_update(*full_scan, window, cx);
                 }
-                GitStoreEvent::RepositoryUpdated(_, _, _) => {}
+
                 GitStoreEvent::RepositoryAdded(_) | GitStoreEvent::RepositoryRemoved(_) => {
                     this.schedule_update(false, window, cx);
                 }
@@ -427,6 +410,8 @@ impl GitPanel {
                         })
                         .ok();
                 }
+                GitStoreEvent::RepositoryUpdated(_, _, _) => {}
+                GitStoreEvent::JobsUpdated => {}
             },
         )
         .detach();
@@ -458,8 +443,6 @@ impl GitPanel {
         });
 
         let mut git_panel = Self {
-            pending_remote_operations: Default::default(),
-            remote_operation_id: 0,
             active_repository,
             commit_editor,
             conflicted_count: 0,
@@ -671,16 +654,6 @@ impl GitPanel {
         cx.notify();
     }
 
-    fn start_remote_operation(&mut self) -> RemoteOperationGuard {
-        let id = post_inc(&mut self.remote_operation_id);
-        self.pending_remote_operations.borrow_mut().insert(id);
-
-        RemoteOperationGuard {
-            id,
-            pending_remote_operations: self.pending_remote_operations.clone(),
-        }
-    }
-
     fn serialize(&mut self, cx: &mut Context<Self>) {
         let width = self.width;
         self.pending_serialization = cx.background_spawn(
@@ -1743,7 +1716,6 @@ impl GitPanel {
             return;
         };
         telemetry::event!("Git Fetched");
-        let guard = self.start_remote_operation();
         let askpass = self.askpass_delegate("git fetch", window, cx);
         let this = cx.weak_entity();
         window
@@ -1751,7 +1723,6 @@ impl GitPanel {
                 let fetch = repo.update(cx, |repo, cx| repo.fetch(askpass, cx))?;
 
                 let remote_message = fetch.await?;
-                drop(guard);
                 this.update(cx, |this, cx| {
                     let action = RemoteAction::Fetch;
                     match remote_message {
@@ -1883,16 +1854,11 @@ impl GitPanel {
                 this.askpass_delegate(format!("git pull {}", remote.name), window, cx)
             })?;
 
-            let guard = this
-                .update(cx, |this, _| this.start_remote_operation())
-                .ok();
-
             let pull = repo.update(cx, |repo, cx| {
                 repo.pull(branch.name.clone(), remote.name.clone(), askpass, cx)
             })?;
 
             let remote_message = pull.await?;
-            drop(guard);
 
             let action = RemoteAction::Pull(remote);
             this.update(cx, |this, cx| match remote_message {
@@ -1954,10 +1920,6 @@ impl GitPanel {
                 this.askpass_delegate(format!("git push {}", remote.name), window, cx)
             })?;
 
-            let guard = this
-                .update(cx, |this, _| this.start_remote_operation())
-                .ok();
-
             let push = repo.update(cx, |repo, cx| {
                 repo.push(
                     branch.name.clone(),
@@ -1969,7 +1931,6 @@ impl GitPanel {
             })?;
 
             let remote_output = push.await?;
-            drop(guard);
 
             let action = RemoteAction::Push(branch.name, remote);
             this.update(cx, |this, cx| match remote_output {
@@ -2590,20 +2551,6 @@ impl GitPanel {
         workspace.add_item_to_center(Box::new(editor), window, cx);
     }
 
-    pub fn render_spinner(&self) -> Option<impl IntoElement> {
-        (!self.pending_remote_operations.borrow().is_empty()).then(|| {
-            Icon::new(IconName::ArrowCircle)
-                .size(IconSize::XSmall)
-                .color(Color::Info)
-                .with_animation(
-                    "arrow-circle",
-                    Animation::new(Duration::from_secs(2)).repeat(),
-                    |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
-                )
-                .into_any_element()
-        })
-    }
-
     pub fn can_commit(&self) -> bool {
         (self.has_staged_changes() || self.has_tracked_changes()) && !self.has_unstaged_conflicts()
     }
@@ -2832,12 +2779,10 @@ impl GitPanel {
         if !self.can_push_and_pull(cx) {
             return None;
         }
-        let spinner = self.render_spinner();
         Some(
             h_flex()
                 .gap_1()
                 .flex_shrink_0()
-                .children(spinner)
                 .when_some(branch, |this, branch| {
                     let focus_handle = Some(self.focus_handle(cx));
 
@@ -4129,17 +4074,17 @@ impl RenderOnce for PanelRepoFooter {
             .truncate(true)
             .tooltip(Tooltip::for_action_title(
                 "Switch Branch",
-                &zed_actions::git::Branch,
+                &zed_actions::git::Switch,
             ))
             .on_click(|_, window, cx| {
-                window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
+                window.dispatch_action(zed_actions::git::Switch.boxed_clone(), cx);
             });
 
         let branch_selector = PopoverMenu::new("popover-button")
             .menu(move |window, cx| Some(branch_picker::popover(repo.clone(), window, cx)))
             .trigger_with_tooltip(
                 branch_selector_button,
-                Tooltip::for_action_title("Switch Branch", &zed_actions::git::Branch),
+                Tooltip::for_action_title("Switch Branch", &zed_actions::git::Switch),
             )
             .anchor(Corner::BottomLeft)
             .offset(gpui::Point {

crates/project/src/git_store.rs 🔗

@@ -54,10 +54,11 @@ use std::{
         Arc,
         atomic::{self, AtomicU64},
     },
+    time::Instant,
 };
 use sum_tree::{Edit, SumTree, TreeSet};
 use text::{Bias, BufferId};
-use util::{ResultExt, debug_panic};
+use util::{ResultExt, debug_panic, post_inc};
 use worktree::{
     File, PathKey, PathProgress, PathSummary, PathTarget, UpdatedGitRepositoriesSet, Worktree,
 };
@@ -224,7 +225,16 @@ pub struct RepositorySnapshot {
     pub scan_id: u64,
 }
 
+type JobId = u64;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct JobInfo {
+    pub start: Instant,
+    pub message: SharedString,
+}
+
 pub struct Repository {
+    this: WeakEntity<Self>,
     snapshot: RepositorySnapshot,
     commit_message_buffer: Option<Entity<Buffer>>,
     git_store: WeakEntity<GitStore>,
@@ -232,6 +242,8 @@ pub struct Repository {
     // and that should be examined during the next status scan.
     paths_needing_status_update: BTreeSet<RepoPath>,
     job_sender: mpsc::UnboundedSender<GitJob>,
+    active_jobs: HashMap<JobId, JobInfo>,
+    job_id: JobId,
     askpass_delegates: Arc<Mutex<HashMap<u64, AskPassDelegate>>>,
     latest_askpass_id: u64,
 }
@@ -262,6 +274,9 @@ pub enum RepositoryEvent {
     MergeHeadsChanged,
 }
 
+#[derive(Clone, Debug)]
+pub struct JobsUpdated;
+
 #[derive(Debug)]
 pub enum GitStoreEvent {
     ActiveRepositoryChanged(Option<RepositoryId>),
@@ -269,12 +284,14 @@ pub enum GitStoreEvent {
     RepositoryAdded(RepositoryId),
     RepositoryRemoved(RepositoryId),
     IndexWriteError(anyhow::Error),
+    JobsUpdated,
 }
 
 impl EventEmitter<RepositoryEvent> for Repository {}
+impl EventEmitter<JobsUpdated> for Repository {}
 impl EventEmitter<GitStoreEvent> for GitStore {}
 
-struct GitJob {
+pub struct GitJob {
     job: Box<dyn FnOnce(RepositoryState, &mut AsyncApp) -> Task<()>>,
     key: Option<GitJobKey>,
 }
@@ -552,7 +569,9 @@ impl GitStore {
             .loading_diffs
             .entry((buffer_id, DiffKind::Unstaged))
             .or_insert_with(|| {
-                let staged_text = repo.read(cx).load_staged_text(buffer_id, repo_path, cx);
+                let staged_text = repo.update(cx, |repo, cx| {
+                    repo.load_staged_text(buffer_id, repo_path, cx)
+                });
                 cx.spawn(async move |this, cx| {
                     Self::open_diff_internal(
                         this,
@@ -607,7 +626,10 @@ impl GitStore {
             .loading_diffs
             .entry((buffer_id, DiffKind::Uncommitted))
             .or_insert_with(|| {
-                let changes = repo.read(cx).load_committed_text(buffer_id, repo_path, cx);
+                let changes = repo.update(cx, |repo, cx| {
+                    repo.load_committed_text(buffer_id, repo_path, cx)
+                });
+
                 cx.spawn(async move |this, cx| {
                     Self::open_diff_internal(this, DiffKind::Uncommitted, changes.await, buffer, cx)
                         .await
@@ -709,13 +731,14 @@ impl GitStore {
         Some(repo.read(cx).status_for_path(&repo_path)?.status)
     }
 
-    pub fn checkpoint(&self, cx: &App) -> Task<Result<GitStoreCheckpoint>> {
+    pub fn checkpoint(&self, cx: &mut App) -> Task<Result<GitStoreCheckpoint>> {
         let mut work_directory_abs_paths = Vec::new();
         let mut checkpoints = Vec::new();
         for repository in self.repositories.values() {
-            let repository = repository.read(cx);
-            work_directory_abs_paths.push(repository.snapshot.work_directory_abs_path.clone());
-            checkpoints.push(repository.checkpoint().map(|checkpoint| checkpoint?));
+            repository.update(cx, |repository, _| {
+                work_directory_abs_paths.push(repository.snapshot.work_directory_abs_path.clone());
+                checkpoints.push(repository.checkpoint().map(|checkpoint| checkpoint?));
+            });
         }
 
         cx.background_executor().spawn(async move {
@@ -729,7 +752,11 @@ impl GitStore {
         })
     }
 
-    pub fn restore_checkpoint(&self, checkpoint: GitStoreCheckpoint, cx: &App) -> Task<Result<()>> {
+    pub fn restore_checkpoint(
+        &self,
+        checkpoint: GitStoreCheckpoint,
+        cx: &mut App,
+    ) -> Task<Result<()>> {
         let repositories_by_work_dir_abs_path = self
             .repositories
             .values()
@@ -739,7 +766,9 @@ impl GitStore {
         let mut tasks = Vec::new();
         for (work_dir_abs_path, checkpoint) in checkpoint.checkpoints_by_work_dir_abs_path {
             if let Some(repository) = repositories_by_work_dir_abs_path.get(&work_dir_abs_path) {
-                let restore = repository.read(cx).restore_checkpoint(checkpoint);
+                let restore = repository.update(cx, |repository, _| {
+                    repository.restore_checkpoint(checkpoint)
+                });
                 tasks.push(async move { restore.await? });
             }
         }
@@ -754,7 +783,7 @@ impl GitStore {
         &self,
         left: GitStoreCheckpoint,
         mut right: GitStoreCheckpoint,
-        cx: &App,
+        cx: &mut App,
     ) -> Task<Result<bool>> {
         let repositories_by_work_dir_abs_path = self
             .repositories
@@ -770,9 +799,10 @@ impl GitStore {
             {
                 if let Some(repository) = repositories_by_work_dir_abs_path.get(&work_dir_abs_path)
                 {
-                    let compare = repository
-                        .read(cx)
-                        .compare_checkpoints(left_checkpoint, right_checkpoint);
+                    let compare = repository.update(cx, |repository, _| {
+                        repository.compare_checkpoints(left_checkpoint, right_checkpoint)
+                    });
+
                     tasks.push(async move { compare.await? });
                 }
             } else {
@@ -787,7 +817,11 @@ impl GitStore {
         })
     }
 
-    pub fn delete_checkpoint(&self, checkpoint: GitStoreCheckpoint, cx: &App) -> Task<Result<()>> {
+    pub fn delete_checkpoint(
+        &self,
+        checkpoint: GitStoreCheckpoint,
+        cx: &mut App,
+    ) -> Task<Result<()>> {
         let repositories_by_work_directory_abs_path = self
             .repositories
             .values()
@@ -799,7 +833,7 @@ impl GitStore {
             if let Some(repository) =
                 repositories_by_work_directory_abs_path.get(&work_dir_abs_path)
             {
-                let delete = repository.read(cx).delete_checkpoint(checkpoint);
+                let delete = repository.update(cx, |this, _| this.delete_checkpoint(checkpoint));
                 tasks.push(async move { delete.await? });
             }
         }
@@ -814,7 +848,7 @@ impl GitStore {
         &self,
         buffer: &Entity<Buffer>,
         version: Option<clock::Global>,
-        cx: &App,
+        cx: &mut App,
     ) -> Task<Result<Option<Blame>>> {
         let buffer = buffer.read(cx);
         let Some((repo, repo_path)) =
@@ -829,24 +863,26 @@ impl GitStore {
         let version = version.unwrap_or(buffer.version());
         let buffer_id = buffer.remote_id();
 
-        let rx = repo.read(cx).send_job(move |state, _| async move {
-            match state {
-                RepositoryState::Local { backend, .. } => backend
-                    .blame(repo_path.clone(), content)
-                    .await
-                    .with_context(|| format!("Failed to blame {:?}", repo_path.0))
-                    .map(Some),
-                RepositoryState::Remote { project_id, client } => {
-                    let response = client
-                        .request(proto::BlameBuffer {
-                            project_id: project_id.to_proto(),
-                            buffer_id: buffer_id.into(),
-                            version: serialize_version(&version),
-                        })
-                        .await?;
-                    Ok(deserialize_blame_buffer_response(response))
+        let rx = repo.update(cx, |repo, _| {
+            repo.send_job(None, move |state, _| async move {
+                match state {
+                    RepositoryState::Local { backend, .. } => backend
+                        .blame(repo_path.clone(), content)
+                        .await
+                        .with_context(|| format!("Failed to blame {:?}", repo_path.0))
+                        .map(Some),
+                    RepositoryState::Remote { project_id, client } => {
+                        let response = client
+                            .request(proto::BlameBuffer {
+                                project_id: project_id.to_proto(),
+                                buffer_id: buffer_id.into(),
+                                version: serialize_version(&version),
+                            })
+                            .await?;
+                        Ok(deserialize_blame_buffer_response(response))
+                    }
                 }
-            }
+            })
         });
 
         cx.spawn(|_: &mut AsyncApp| async move { rx.await? })
@@ -856,7 +892,7 @@ impl GitStore {
         &self,
         buffer: &Entity<Buffer>,
         selection: Range<u32>,
-        cx: &App,
+        cx: &mut App,
     ) -> Task<Result<url::Url>> {
         let Some(file) = File::from_dyn(buffer.read(cx).file()) else {
             return Task::ready(Err(anyhow!("buffer has no file")));
@@ -897,52 +933,55 @@ impl GitStore {
             .and_then(|b| b.remote_name())
             .unwrap_or("origin")
             .to_string();
-        let rx = repo.read(cx).send_job(move |state, cx| async move {
-            match state {
-                RepositoryState::Local { backend, .. } => {
-                    let origin_url = backend
-                        .remote_url(&remote)
-                        .ok_or_else(|| anyhow!("remote \"{remote}\" not found"))?;
-
-                    let sha = backend
-                        .head_sha()
-                        .ok_or_else(|| anyhow!("failed to read HEAD SHA"))?;
-
-                    let provider_registry =
-                        cx.update(GitHostingProviderRegistry::default_global)?;
-
-                    let (provider, remote) =
-                        parse_git_remote_url(provider_registry, &origin_url)
-                            .ok_or_else(|| anyhow!("failed to parse Git remote URL"))?;
-
-                    let path = repo_path
-                        .to_str()
-                        .ok_or_else(|| anyhow!("failed to convert path to string"))?;
-
-                    Ok(provider.build_permalink(
-                        remote,
-                        BuildPermalinkParams {
-                            sha: &sha,
-                            path,
-                            selection: Some(selection),
-                        },
-                    ))
-                }
-                RepositoryState::Remote { project_id, client } => {
-                    let response = client
-                        .request(proto::GetPermalinkToLine {
-                            project_id: project_id.to_proto(),
-                            buffer_id: buffer_id.into(),
-                            selection: Some(proto::Range {
-                                start: selection.start as u64,
-                                end: selection.end as u64,
-                            }),
-                        })
-                        .await?;
 
-                    url::Url::parse(&response.permalink).context("failed to parse permalink")
+        let rx = repo.update(cx, |repo, _| {
+            repo.send_job(None, move |state, cx| async move {
+                match state {
+                    RepositoryState::Local { backend, .. } => {
+                        let origin_url = backend
+                            .remote_url(&remote)
+                            .ok_or_else(|| anyhow!("remote \"{remote}\" not found"))?;
+
+                        let sha = backend
+                            .head_sha()
+                            .ok_or_else(|| anyhow!("failed to read HEAD SHA"))?;
+
+                        let provider_registry =
+                            cx.update(GitHostingProviderRegistry::default_global)?;
+
+                        let (provider, remote) =
+                            parse_git_remote_url(provider_registry, &origin_url)
+                                .ok_or_else(|| anyhow!("failed to parse Git remote URL"))?;
+
+                        let path = repo_path
+                            .to_str()
+                            .ok_or_else(|| anyhow!("failed to convert path to string"))?;
+
+                        Ok(provider.build_permalink(
+                            remote,
+                            BuildPermalinkParams {
+                                sha: &sha,
+                                path,
+                                selection: Some(selection),
+                            },
+                        ))
+                    }
+                    RepositoryState::Remote { project_id, client } => {
+                        let response = client
+                            .request(proto::GetPermalinkToLine {
+                                project_id: project_id.to_proto(),
+                                buffer_id: buffer_id.into(),
+                                selection: Some(proto::Range {
+                                    start: selection.start as u64,
+                                    end: selection.end as u64,
+                                }),
+                            })
+                            .await?;
+
+                        url::Url::parse(&response.permalink).context("failed to parse permalink")
+                    }
                 }
-            }
+            })
         });
         cx.spawn(|_: &mut AsyncApp| async move { rx.await? })
     }
@@ -1058,6 +1097,10 @@ impl GitStore {
         ))
     }
 
+    fn on_jobs_updated(&mut self, _: Entity<Repository>, _: &JobsUpdated, cx: &mut Context<Self>) {
+        cx.emit(GitStoreEvent::JobsUpdated)
+    }
+
     /// Update our list of repositories and schedule git scans in response to a notification from a worktree,
     fn update_repositories_from_worktree(
         &mut self,
@@ -1110,6 +1153,8 @@ impl GitStore {
                 });
                 self._subscriptions
                     .push(cx.subscribe(&repo, Self::on_repository_event));
+                self._subscriptions
+                    .push(cx.subscribe(&repo, Self::on_jobs_updated));
                 self.repositories.insert(id, repo);
                 cx.emit(GitStoreEvent::RepositoryAdded(id));
                 self.active_repo_id.get_or_insert_with(|| {
@@ -1291,124 +1336,127 @@ impl GitStore {
         for (repo, repo_diff_state_updates) in diff_state_updates.into_iter() {
             let git_store = cx.weak_entity();
 
-            let _ = repo.read(cx).send_keyed_job(
-                Some(GitJobKey::BatchReadIndex),
-                |state, mut cx| async move {
-                    let RepositoryState::Local { backend, .. } = state else {
-                        log::error!("tried to recompute diffs for a non-local repository");
-                        return;
-                    };
-                    let mut diff_bases_changes_by_buffer = Vec::new();
-                    for (
-                        buffer,
-                        repo_path,
-                        current_index_text,
-                        current_head_text,
-                        hunk_staging_operation_count,
-                    ) in &repo_diff_state_updates
-                    {
-                        let index_text = if current_index_text.is_some() {
-                            backend.load_index_text(repo_path.clone()).await
-                        } else {
-                            None
-                        };
-                        let head_text = if current_head_text.is_some() {
-                            backend.load_committed_text(repo_path.clone()).await
-                        } else {
-                            None
+            let _ = repo.update(cx, |repo, _| {
+                repo.send_keyed_job(
+                    Some(GitJobKey::BatchReadIndex),
+                    None,
+                    |state, mut cx| async move {
+                        let RepositoryState::Local { backend, .. } = state else {
+                            log::error!("tried to recompute diffs for a non-local repository");
+                            return;
                         };
-
-                        // Avoid triggering a diff update if the base text has not changed.
-                        if let Some((current_index, current_head)) =
-                            current_index_text.as_ref().zip(current_head_text.as_ref())
+                        let mut diff_bases_changes_by_buffer = Vec::new();
+                        for (
+                            buffer,
+                            repo_path,
+                            current_index_text,
+                            current_head_text,
+                            hunk_staging_operation_count,
+                        ) in &repo_diff_state_updates
                         {
-                            if current_index.as_deref() == index_text.as_ref()
-                                && current_head.as_deref() == head_text.as_ref()
-                            {
-                                continue;
-                            }
-                        }
-
-                        let diff_bases_change =
-                            match (current_index_text.is_some(), current_head_text.is_some()) {
-                                (true, true) => Some(if index_text == head_text {
-                                    DiffBasesChange::SetBoth(head_text)
-                                } else {
-                                    DiffBasesChange::SetEach {
-                                        index: index_text,
-                                        head: head_text,
-                                    }
-                                }),
-                                (true, false) => Some(DiffBasesChange::SetIndex(index_text)),
-                                (false, true) => Some(DiffBasesChange::SetHead(head_text)),
-                                (false, false) => None,
+                            let index_text = if current_index_text.is_some() {
+                                backend.load_index_text(repo_path.clone()).await
+                            } else {
+                                None
+                            };
+                            let head_text = if current_head_text.is_some() {
+                                backend.load_committed_text(repo_path.clone()).await
+                            } else {
+                                None
                             };
 
-                        diff_bases_changes_by_buffer.push((
-                            buffer,
-                            diff_bases_change,
-                            *hunk_staging_operation_count,
-                        ))
-                    }
-
-                    git_store
-                        .update(&mut cx, |git_store, cx| {
-                            for (buffer, diff_bases_change, hunk_staging_operation_count) in
-                                diff_bases_changes_by_buffer
+                            // Avoid triggering a diff update if the base text has not changed.
+                            if let Some((current_index, current_head)) =
+                                current_index_text.as_ref().zip(current_head_text.as_ref())
                             {
-                                let Some(diff_state) =
-                                    git_store.diffs.get(&buffer.read(cx).remote_id())
-                                else {
-                                    continue;
-                                };
-                                let Some(diff_bases_change) = diff_bases_change else {
+                                if current_index.as_deref() == index_text.as_ref()
+                                    && current_head.as_deref() == head_text.as_ref()
+                                {
                                     continue;
+                                }
+                            }
+
+                            let diff_bases_change =
+                                match (current_index_text.is_some(), current_head_text.is_some()) {
+                                    (true, true) => Some(if index_text == head_text {
+                                        DiffBasesChange::SetBoth(head_text)
+                                    } else {
+                                        DiffBasesChange::SetEach {
+                                            index: index_text,
+                                            head: head_text,
+                                        }
+                                    }),
+                                    (true, false) => Some(DiffBasesChange::SetIndex(index_text)),
+                                    (false, true) => Some(DiffBasesChange::SetHead(head_text)),
+                                    (false, false) => None,
                                 };
 
-                                let downstream_client = git_store.downstream_client();
-                                diff_state.update(cx, |diff_state, cx| {
-                                    use proto::update_diff_bases::Mode;
-
-                                    let buffer = buffer.read(cx);
-                                    if let Some((client, project_id)) = downstream_client {
-                                        let (staged_text, committed_text, mode) =
-                                            match diff_bases_change.clone() {
-                                                DiffBasesChange::SetIndex(index) => {
-                                                    (index, None, Mode::IndexOnly)
-                                                }
-                                                DiffBasesChange::SetHead(head) => {
-                                                    (None, head, Mode::HeadOnly)
-                                                }
-                                                DiffBasesChange::SetEach { index, head } => {
-                                                    (index, head, Mode::IndexAndHead)
-                                                }
-                                                DiffBasesChange::SetBoth(text) => {
-                                                    (None, text, Mode::IndexMatchesHead)
-                                                }
+                            diff_bases_changes_by_buffer.push((
+                                buffer,
+                                diff_bases_change,
+                                *hunk_staging_operation_count,
+                            ))
+                        }
+
+                        git_store
+                            .update(&mut cx, |git_store, cx| {
+                                for (buffer, diff_bases_change, hunk_staging_operation_count) in
+                                    diff_bases_changes_by_buffer
+                                {
+                                    let Some(diff_state) =
+                                        git_store.diffs.get(&buffer.read(cx).remote_id())
+                                    else {
+                                        continue;
+                                    };
+                                    let Some(diff_bases_change) = diff_bases_change else {
+                                        continue;
+                                    };
+
+                                    let downstream_client = git_store.downstream_client();
+                                    diff_state.update(cx, |diff_state, cx| {
+                                        use proto::update_diff_bases::Mode;
+
+                                        let buffer = buffer.read(cx);
+                                        if let Some((client, project_id)) = downstream_client {
+                                            let (staged_text, committed_text, mode) =
+                                                match diff_bases_change.clone() {
+                                                    DiffBasesChange::SetIndex(index) => {
+                                                        (index, None, Mode::IndexOnly)
+                                                    }
+                                                    DiffBasesChange::SetHead(head) => {
+                                                        (None, head, Mode::HeadOnly)
+                                                    }
+                                                    DiffBasesChange::SetEach { index, head } => {
+                                                        (index, head, Mode::IndexAndHead)
+                                                    }
+                                                    DiffBasesChange::SetBoth(text) => {
+                                                        (None, text, Mode::IndexMatchesHead)
+                                                    }
+                                                };
+                                            let message = proto::UpdateDiffBases {
+                                                project_id: project_id.to_proto(),
+                                                buffer_id: buffer.remote_id().to_proto(),
+                                                staged_text,
+                                                committed_text,
+                                                mode: mode as i32,
                                             };
-                                        let message = proto::UpdateDiffBases {
-                                            project_id: project_id.to_proto(),
-                                            buffer_id: buffer.remote_id().to_proto(),
-                                            staged_text,
-                                            committed_text,
-                                            mode: mode as i32,
-                                        };
-
-                                        client.send(message).log_err();
-                                    }
 
-                                    let _ = diff_state.diff_bases_changed(
-                                        buffer.text_snapshot(),
-                                        diff_bases_change,
-                                        hunk_staging_operation_count,
-                                        cx,
-                                    );
-                                });
-                            }
-                        })
-                        .ok();
-                },
-            );
+                                            client.send(message).log_err();
+                                        }
+
+                                        let _ = diff_state.diff_bases_changed(
+                                            buffer.text_snapshot(),
+                                            diff_bases_change,
+                                            hunk_staging_operation_count,
+                                            cx,
+                                        );
+                                    });
+                                }
+                            })
+                            .ok();
+                    },
+                )
+            });
         }
     }
 
@@ -2610,6 +2658,7 @@ impl Repository {
     ) -> Self {
         let snapshot = RepositorySnapshot::empty(id, work_directory_abs_path.clone());
         Repository {
+            this: cx.weak_entity(),
             git_store,
             snapshot,
             commit_message_buffer: None,
@@ -2623,6 +2672,8 @@ impl Repository {
                 fs,
                 cx,
             ),
+            job_id: 0,
+            active_jobs: Default::default(),
         }
     }
 
@@ -2636,6 +2687,7 @@ impl Repository {
     ) -> Self {
         let snapshot = RepositorySnapshot::empty(id, work_directory_abs_path);
         Self {
+            this: cx.weak_entity(),
             snapshot,
             commit_message_buffer: None,
             git_store,
@@ -2643,6 +2695,8 @@ impl Repository {
             job_sender: Self::spawn_remote_git_worker(project_id, client, cx),
             askpass_delegates: Default::default(),
             latest_askpass_id: 0,
+            active_jobs: Default::default(),
+            job_id: 0,
         }
     }
 
@@ -2650,29 +2704,61 @@ impl Repository {
         self.git_store.upgrade()
     }
 
-    pub fn send_job<F, Fut, R>(&self, job: F) -> oneshot::Receiver<R>
+    pub fn send_job<F, Fut, R>(
+        &mut self,
+        status: Option<SharedString>,
+        job: F,
+    ) -> oneshot::Receiver<R>
     where
         F: FnOnce(RepositoryState, AsyncApp) -> Fut + 'static,
         Fut: Future<Output = R> + 'static,
         R: Send + 'static,
     {
-        self.send_keyed_job(None, job)
+        self.send_keyed_job(None, status, job)
     }
 
-    fn send_keyed_job<F, Fut, R>(&self, key: Option<GitJobKey>, job: F) -> oneshot::Receiver<R>
+    fn send_keyed_job<F, Fut, R>(
+        &mut self,
+        key: Option<GitJobKey>,
+        status: Option<SharedString>,
+        job: F,
+    ) -> oneshot::Receiver<R>
     where
         F: FnOnce(RepositoryState, AsyncApp) -> Fut + 'static,
         Fut: Future<Output = R> + 'static,
         R: Send + 'static,
     {
         let (result_tx, result_rx) = futures::channel::oneshot::channel();
+        let job_id = post_inc(&mut self.job_id);
+        let this = self.this.clone();
         self.job_sender
             .unbounded_send(GitJob {
                 key,
-                job: Box::new(|state, cx: &mut AsyncApp| {
+                job: Box::new(move |state, cx: &mut AsyncApp| {
                     let job = job(state, cx.clone());
-                    cx.spawn(async move |_| {
+                    cx.spawn(async move |cx| {
+                        if let Some(s) = status.clone() {
+                            this.update(cx, |this, cx| {
+                                this.active_jobs.insert(
+                                    job_id,
+                                    JobInfo {
+                                        start: Instant::now(),
+                                        message: s.clone(),
+                                    },
+                                );
+
+                                cx.notify();
+                            })
+                            .ok();
+                        }
                         let result = job.await;
+
+                        this.update(cx, |this, cx| {
+                            this.active_jobs.remove(&job_id);
+                            cx.notify();
+                        })
+                        .ok();
+
                         result_tx.send(result).ok();
                     })
                 }),
@@ -2741,7 +2827,7 @@ impl Repository {
         }
         let this = cx.weak_entity();
 
-        let rx = self.send_job(move |state, mut cx| async move {
+        let rx = self.send_job(None, move |state, mut cx| async move {
             let Some(this) = this.upgrade() else {
                 bail!("git store was dropped");
             };
@@ -2807,7 +2893,7 @@ impl Repository {
     }
 
     pub fn checkout_files(
-        &self,
+        &mut self,
         commit: &str,
         paths: Vec<RepoPath>,
         _cx: &mut App,
@@ -2815,38 +2901,41 @@ impl Repository {
         let commit = commit.to_string();
         let id = self.id;
 
-        self.send_job(move |git_repo, _| async move {
-            match git_repo {
-                RepositoryState::Local {
-                    backend,
-                    environment,
-                    ..
-                } => {
-                    backend
-                        .checkout_files(commit, paths, environment.clone())
-                        .await
-                }
-                RepositoryState::Remote { project_id, client } => {
-                    client
-                        .request(proto::GitCheckoutFiles {
-                            project_id: project_id.0,
-                            repository_id: id.to_proto(),
-                            commit,
-                            paths: paths
-                                .into_iter()
-                                .map(|p| p.to_string_lossy().to_string())
-                                .collect(),
-                        })
-                        .await?;
+        self.send_job(
+            Some(format!("git checkout {}", commit).into()),
+            move |git_repo, _| async move {
+                match git_repo {
+                    RepositoryState::Local {
+                        backend,
+                        environment,
+                        ..
+                    } => {
+                        backend
+                            .checkout_files(commit, paths, environment.clone())
+                            .await
+                    }
+                    RepositoryState::Remote { project_id, client } => {
+                        client
+                            .request(proto::GitCheckoutFiles {
+                                project_id: project_id.0,
+                                repository_id: id.to_proto(),
+                                commit,
+                                paths: paths
+                                    .into_iter()
+                                    .map(|p| p.to_string_lossy().to_string())
+                                    .collect(),
+                            })
+                            .await?;
 
-                    Ok(())
+                        Ok(())
+                    }
                 }
-            }
-        })
+            },
+        )
     }
 
     pub fn reset(
-        &self,
+        &mut self,
         commit: String,
         reset_mode: ResetMode,
         _cx: &mut App,
@@ -2854,7 +2943,7 @@ impl Repository {
         let commit = commit.to_string();
         let id = self.id;
 
-        self.send_job(move |git_repo, _| async move {
+        self.send_job(None, move |git_repo, _| async move {
             match git_repo {
                 RepositoryState::Local {
                     backend,
@@ -2880,9 +2969,9 @@ impl Repository {
         })
     }
 
-    pub fn show(&self, commit: String) -> oneshot::Receiver<Result<CommitDetails>> {
+    pub fn show(&mut self, commit: String) -> oneshot::Receiver<Result<CommitDetails>> {
         let id = self.id;
-        self.send_job(move |git_repo, _cx| async move {
+        self.send_job(None, move |git_repo, _cx| async move {
             match git_repo {
                 RepositoryState::Local { backend, .. } => backend.show(commit).await,
                 RepositoryState::Remote { project_id, client } => {
@@ -2906,9 +2995,9 @@ impl Repository {
         })
     }
 
-    pub fn load_commit_diff(&self, commit: String) -> oneshot::Receiver<Result<CommitDiff>> {
+    pub fn load_commit_diff(&mut self, commit: String) -> oneshot::Receiver<Result<CommitDiff>> {
         let id = self.id;
-        self.send_job(move |git_repo, cx| async move {
+        self.send_job(None, move |git_repo, cx| async move {
             match git_repo {
                 RepositoryState::Local { backend, .. } => backend.load_commit(commit, cx).await,
                 RepositoryState::Remote {
@@ -2977,7 +3066,7 @@ impl Repository {
             }
 
             this.update(cx, |this, _| {
-                this.send_job(move |git_repo, _cx| async move {
+                this.send_job(None, move |git_repo, _cx| async move {
                     match git_repo {
                         RepositoryState::Local {
                             backend,
@@ -3044,7 +3133,7 @@ impl Repository {
             }
 
             this.update(cx, |this, _| {
-                this.send_job(move |git_repo, _cx| async move {
+                this.send_job(None, move |git_repo, _cx| async move {
                     match git_repo {
                         RepositoryState::Local {
                             backend,
@@ -3094,14 +3183,14 @@ impl Repository {
     }
 
     pub fn commit(
-        &self,
+        &mut self,
         message: SharedString,
         name_and_email: Option<(SharedString, SharedString)>,
         _cx: &mut App,
     ) -> oneshot::Receiver<Result<()>> {
         let id = self.id;
 
-        self.send_job(move |git_repo, _cx| async move {
+        self.send_job(Some("git commit".into()), move |git_repo, _cx| async move {
             match git_repo {
                 RepositoryState::Local {
                     backend,
@@ -3136,7 +3225,7 @@ impl Repository {
         let askpass_id = util::post_inc(&mut self.latest_askpass_id);
         let id = self.id;
 
-        self.send_job(move |git_repo, cx| async move {
+        self.send_job(Some("git fetch".into()), move |git_repo, cx| async move {
             match git_repo {
                 RepositoryState::Local {
                     backend,
@@ -3180,52 +3269,65 @@ impl Repository {
         let askpass_id = util::post_inc(&mut self.latest_askpass_id);
         let id = self.id;
 
-        self.send_job(move |git_repo, cx| async move {
-            match git_repo {
-                RepositoryState::Local {
-                    backend,
-                    environment,
-                    ..
-                } => {
-                    backend
-                        .push(
-                            branch.to_string(),
-                            remote.to_string(),
-                            options,
-                            askpass,
-                            environment.clone(),
-                            cx,
-                        )
-                        .await
-                }
-                RepositoryState::Remote { project_id, client } => {
-                    askpass_delegates.lock().insert(askpass_id, askpass);
-                    let _defer = util::defer(|| {
-                        let askpass_delegate = askpass_delegates.lock().remove(&askpass_id);
-                        debug_assert!(askpass_delegate.is_some());
-                    });
-                    let response = client
-                        .request(proto::Push {
-                            project_id: project_id.0,
-                            repository_id: id.to_proto(),
-                            askpass_id,
-                            branch_name: branch.to_string(),
-                            remote_name: remote.to_string(),
-                            options: options.map(|options| match options {
-                                PushOptions::Force => proto::push::PushOptions::Force,
-                                PushOptions::SetUpstream => proto::push::PushOptions::SetUpstream,
-                            } as i32),
-                        })
-                        .await
-                        .context("sending push request")?;
+        let args = options
+            .map(|option| match option {
+                PushOptions::SetUpstream => " --set-upstream",
+                PushOptions::Force => " --force",
+            })
+            .unwrap_or("");
 
-                    Ok(RemoteCommandOutput {
-                        stdout: response.stdout,
-                        stderr: response.stderr,
-                    })
+        self.send_job(
+            Some(format!("git push{} {} {}", args, branch, remote).into()),
+            move |git_repo, cx| async move {
+                match git_repo {
+                    RepositoryState::Local {
+                        backend,
+                        environment,
+                        ..
+                    } => {
+                        backend
+                            .push(
+                                branch.to_string(),
+                                remote.to_string(),
+                                options,
+                                askpass,
+                                environment.clone(),
+                                cx,
+                            )
+                            .await
+                    }
+                    RepositoryState::Remote { project_id, client } => {
+                        askpass_delegates.lock().insert(askpass_id, askpass);
+                        let _defer = util::defer(|| {
+                            let askpass_delegate = askpass_delegates.lock().remove(&askpass_id);
+                            debug_assert!(askpass_delegate.is_some());
+                        });
+                        let response = client
+                            .request(proto::Push {
+                                project_id: project_id.0,
+                                repository_id: id.to_proto(),
+                                askpass_id,
+                                branch_name: branch.to_string(),
+                                remote_name: remote.to_string(),
+                                options: options.map(|options| match options {
+                                    PushOptions::Force => proto::push::PushOptions::Force,
+                                    PushOptions::SetUpstream => {
+                                        proto::push::PushOptions::SetUpstream
+                                    }
+                                }
+                                    as i32),
+                            })
+                            .await
+                            .context("sending push request")?;
+
+                        Ok(RemoteCommandOutput {
+                            stdout: response.stdout,
+                            stderr: response.stderr,
+                        })
+                    }
                 }
-            }
-        })
+            },
+        )
     }
 
     pub fn pull(
@@ -3239,51 +3341,54 @@ impl Repository {
         let askpass_id = util::post_inc(&mut self.latest_askpass_id);
         let id = self.id;
 
-        self.send_job(move |git_repo, cx| async move {
-            match git_repo {
-                RepositoryState::Local {
-                    backend,
-                    environment,
-                    ..
-                } => {
-                    backend
-                        .pull(
-                            branch.to_string(),
-                            remote.to_string(),
-                            askpass,
-                            environment.clone(),
-                            cx,
-                        )
-                        .await
-                }
-                RepositoryState::Remote { project_id, client } => {
-                    askpass_delegates.lock().insert(askpass_id, askpass);
-                    let _defer = util::defer(|| {
-                        let askpass_delegate = askpass_delegates.lock().remove(&askpass_id);
-                        debug_assert!(askpass_delegate.is_some());
-                    });
-                    let response = client
-                        .request(proto::Pull {
-                            project_id: project_id.0,
-                            repository_id: id.to_proto(),
-                            askpass_id,
-                            branch_name: branch.to_string(),
-                            remote_name: remote.to_string(),
-                        })
-                        .await
-                        .context("sending pull request")?;
+        self.send_job(
+            Some(format!("git pull {} {}", remote, branch).into()),
+            move |git_repo, cx| async move {
+                match git_repo {
+                    RepositoryState::Local {
+                        backend,
+                        environment,
+                        ..
+                    } => {
+                        backend
+                            .pull(
+                                branch.to_string(),
+                                remote.to_string(),
+                                askpass,
+                                environment.clone(),
+                                cx,
+                            )
+                            .await
+                    }
+                    RepositoryState::Remote { project_id, client } => {
+                        askpass_delegates.lock().insert(askpass_id, askpass);
+                        let _defer = util::defer(|| {
+                            let askpass_delegate = askpass_delegates.lock().remove(&askpass_id);
+                            debug_assert!(askpass_delegate.is_some());
+                        });
+                        let response = client
+                            .request(proto::Pull {
+                                project_id: project_id.0,
+                                repository_id: id.to_proto(),
+                                askpass_id,
+                                branch_name: branch.to_string(),
+                                remote_name: remote.to_string(),
+                            })
+                            .await
+                            .context("sending pull request")?;
 
-                    Ok(RemoteCommandOutput {
-                        stdout: response.stdout,
-                        stderr: response.stderr,
-                    })
+                        Ok(RemoteCommandOutput {
+                            stdout: response.stdout,
+                            stderr: response.stderr,
+                        })
+                    }
                 }
-            }
-        })
+            },
+        )
     }
 
     fn spawn_set_index_text_job(
-        &self,
+        &mut self,
         path: RepoPath,
         content: Option<String>,
         _cx: &mut App,

crates/project/src/project.rs 🔗

@@ -4135,20 +4135,22 @@ impl Project {
         &self,
         buffer: &Entity<Buffer>,
         version: Option<clock::Global>,
-        cx: &App,
+        cx: &mut App,
     ) -> Task<Result<Option<Blame>>> {
-        self.git_store.read(cx).blame_buffer(buffer, version, cx)
+        self.git_store.update(cx, |git_store, cx| {
+            git_store.blame_buffer(buffer, version, cx)
+        })
     }
 
     pub fn get_permalink_to_line(
         &self,
         buffer: &Entity<Buffer>,
         selection: Range<u32>,
-        cx: &App,
+        cx: &mut App,
     ) -> Task<Result<url::Url>> {
-        self.git_store
-            .read(cx)
-            .get_permalink_to_line(buffer, selection, cx)
+        self.git_store.update(cx, |git_store, cx| {
+            git_store.get_permalink_to_line(buffer, selection, cx)
+        })
     }
 
     // RPC message handlers

crates/remote_server/src/remote_editing_tests.rs 🔗

@@ -1366,10 +1366,14 @@ async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestA
 
     assert_eq!(&remote_branches, &branches_set);
 
-    cx.update(|cx| repository.read(cx).change_branch(new_branch.to_string()))
-        .await
-        .unwrap()
-        .unwrap();
+    cx.update(|cx| {
+        repository.update(cx, |repository, _cx| {
+            repository.change_branch(new_branch.to_string())
+        })
+    })
+    .await
+    .unwrap()
+    .unwrap();
 
     cx.run_until_parked();
 
@@ -1394,18 +1398,18 @@ async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestA
 
     // Also try creating a new branch
     cx.update(|cx| {
-        repository
-            .read(cx)
-            .create_branch("totally-new-branch".to_string())
+        repository.update(cx, |repo, _cx| {
+            repo.create_branch("totally-new-branch".to_string())
+        })
     })
     .await
     .unwrap()
     .unwrap();
 
     cx.update(|cx| {
-        repository
-            .read(cx)
-            .change_branch("totally-new-branch".to_string())
+        repository.update(cx, |repo, _cx| {
+            repo.change_branch("totally-new-branch".to_string())
+        })
     })
     .await
     .unwrap()