Move git state to Project (#23208)

Cole Miller created

This restores its visibility outside of git_ui, which we'll need soon,
while preserving its per-project character.

Release Notes:

- N/A

Change summary

Cargo.lock                            |   2 
crates/editor/src/inlay_hint_cache.rs |   2 
crates/git/src/git.rs                 |  19 +
crates/git_ui/Cargo.toml              |   2 
crates/git_ui/src/git_panel.rs        | 292 ++++++++++++++++++----------
crates/git_ui/src/git_ui.rs           | 197 -------------------
crates/project/src/git.rs             | 124 ++++++++++++
crates/project/src/project.rs         |  21 +
crates/project/src/project_tests.rs   |   2 
crates/worktree/src/worktree.rs       |  11 +
10 files changed, 359 insertions(+), 313 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5129,7 +5129,6 @@ dependencies = [
  "collections",
  "db",
  "editor",
- "futures 0.3.31",
  "git",
  "gpui",
  "language",
@@ -5140,7 +5139,6 @@ dependencies = [
  "serde_derive",
  "serde_json",
  "settings",
- "sum_tree",
  "theme",
  "ui",
  "util",

crates/editor/src/inlay_hint_cache.rs 🔗

@@ -2942,7 +2942,7 @@ pub mod tests {
             .update(cx, |editor, cx| {
                 assert_eq!(
                     vec!["main hint #0".to_string(), "other hint #0".to_string()],
-                    cached_hint_labels(editor),
+                    sorted_cached_hint_labels(editor),
                     "Cache should update for both excerpts despite hints display was disabled"
                 );
                 assert!(

crates/git/src/git.rs 🔗

@@ -7,6 +7,7 @@ pub mod repository;
 pub mod status;
 
 use anyhow::{anyhow, Context, Result};
+use gpui::actions;
 use serde::{Deserialize, Serialize};
 use std::ffi::OsStr;
 use std::fmt;
@@ -24,6 +25,24 @@ pub static FSMONITOR_DAEMON: LazyLock<&'static OsStr> =
     LazyLock::new(|| OsStr::new("fsmonitor--daemon"));
 pub static GITIGNORE: LazyLock<&'static OsStr> = LazyLock::new(|| OsStr::new(".gitignore"));
 
+actions!(
+    git,
+    [
+        StageFile,
+        UnstageFile,
+        ToggleStaged,
+        // Revert actions are currently in the editor crate:
+        // editor::RevertFile,
+        // editor::RevertSelectedHunks
+        StageAll,
+        UnstageAll,
+        RevertAll,
+        CommitChanges,
+        CommitAllChanges,
+        ClearCommitMessage
+    ]
+);
+
 #[derive(Clone, Copy, Eq, Hash, PartialEq)]
 pub struct Oid(libgit::Oid);
 

crates/git_ui/Cargo.toml 🔗

@@ -17,7 +17,6 @@ anyhow.workspace = true
 collections.workspace = true
 db.workspace = true
 editor.workspace = true
-futures.workspace = true
 git.workspace = true
 gpui.workspace = true
 language.workspace = true
@@ -28,7 +27,6 @@ serde.workspace = true
 serde_derive.workspace = true
 serde_json.workspace = true
 settings.workspace = true
-sum_tree.workspace = true
 theme.workspace = true
 ui.workspace = true
 util.workspace = true

crates/git_ui/src/git_panel.rs 🔗

@@ -1,18 +1,17 @@
 use crate::git_panel_settings::StatusStyle;
-use crate::{first_repository_in_project, first_worktree_repository};
-use crate::{
-    git_panel_settings::GitPanelSettings, git_status_icon, CommitAllChanges, CommitChanges,
-    GitState, GitViewMode, RevertAll, StageAll, ToggleStaged, UnstageAll,
-};
+use crate::{git_panel_settings::GitPanelSettings, git_status_icon};
 use anyhow::{Context as _, Result};
 use db::kvp::KEY_VALUE_STORE;
 use editor::scroll::ScrollbarAutoHide;
 use editor::{Editor, EditorSettings, ShowScrollbar};
-use git::{repository::RepoPath, status::FileStatus};
+use git::repository::{GitRepository, RepoPath};
+use git::status::FileStatus;
+use git::{CommitAllChanges, CommitChanges, RevertAll, StageAll, ToggleStaged, UnstageAll};
 use gpui::*;
 use language::Buffer;
 use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev};
-use project::{Fs, Project, ProjectPath};
+use project::git::GitState;
+use project::{Fs, Project, ProjectPath, WorktreeId};
 use serde::{Deserialize, Serialize};
 use settings::Settings as _;
 use std::sync::atomic::{AtomicBool, Ordering};
@@ -21,12 +20,13 @@ use theme::ThemeSettings;
 use ui::{
     prelude::*, Checkbox, Divider, DividerColor, ElevationIndex, Scrollbar, ScrollbarState, Tooltip,
 };
-use util::{ResultExt, TryFutureExt};
+use util::{maybe, ResultExt, TryFutureExt};
 use workspace::notifications::DetachAndPromptErr;
 use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
     Workspace,
 };
+use worktree::RepositoryEntry;
 
 actions!(
     git_panel,
@@ -87,7 +87,6 @@ pub struct GitPanel {
     selected_entry: Option<usize>,
     show_scrollbar: bool,
     rebuild_requested: Arc<AtomicBool>,
-    git_state: GitState,
     commit_editor: View<Editor>,
     /// The visible entries in the list, accounting for folding & expanded state.
     ///
@@ -99,6 +98,44 @@ pub struct GitPanel {
     reveal_in_editor: Task<()>,
 }
 
+fn first_worktree_repository(
+    project: &Model<Project>,
+    worktree_id: WorktreeId,
+    cx: &mut AppContext,
+) -> Option<(RepositoryEntry, Arc<dyn GitRepository>)> {
+    project
+        .read(cx)
+        .worktree_for_id(worktree_id, cx)
+        .and_then(|worktree| {
+            let snapshot = worktree.read(cx).snapshot();
+            let repo = snapshot.repositories().iter().next()?.clone();
+            let git_repo = worktree
+                .read(cx)
+                .as_local()?
+                .get_local_repo(&repo)?
+                .repo()
+                .clone();
+            Some((repo, git_repo))
+        })
+}
+
+fn first_repository_in_project(
+    project: &Model<Project>,
+    cx: &mut AppContext,
+) -> Option<(WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)> {
+    project.read(cx).worktrees(cx).next().and_then(|worktree| {
+        let snapshot = worktree.read(cx).snapshot();
+        let repo = snapshot.repositories().iter().next()?.clone();
+        let git_repo = worktree
+            .read(cx)
+            .as_local()?
+            .get_local_repo(&repo)?
+            .repo()
+            .clone();
+        Some((snapshot.id(), repo, git_repo))
+    })
+}
+
 impl GitPanel {
     pub fn load(
         workspace: WeakView<Workspace>,
@@ -110,9 +147,11 @@ impl GitPanel {
     pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
         let fs = workspace.app_state().fs.clone();
         let project = workspace.project().clone();
+        let git_state = project.read(cx).git_state().cloned();
         let language_registry = workspace.app_state().languages.clone();
-        let mut git_state = GitState::new(cx);
-        let current_commit_message = git_state.commit_message.clone();
+        let current_commit_message = git_state
+            .as_ref()
+            .and_then(|git_state| git_state.read(cx).commit_message.clone());
 
         let git_panel = cx.new_view(|cx: &mut ViewContext<Self>| {
             let focus_handle = cx.focus_handle();
@@ -124,82 +163,78 @@ impl GitPanel {
             cx.subscribe(&project, move |this, project, event, cx| {
                 use project::Event;
 
-                let git_state = &mut this.git_state;
                 let first_worktree_id = project.read(cx).worktrees(cx).next().map(|worktree| {
                     let snapshot = worktree.read(cx).snapshot();
                     snapshot.id()
                 });
                 let first_repo_in_project = first_repository_in_project(&project, cx);
 
-                match event {
-                    project::Event::WorktreeRemoved(id) => {
-                        git_state.all_repositories.remove(id);
-                        let Some((worktree_id, _, _)) = git_state.active_repository.as_ref() else {
-                            return;
-                        };
-                        if worktree_id == id {
-                            git_state.active_repository = first_repo_in_project;
-                            this.schedule_update();
+                let Some(git_state) = project.read(cx).git_state().cloned() else {
+                    return;
+                };
+                git_state.update(cx, |git_state, _| {
+                    match event {
+                        project::Event::WorktreeRemoved(id) => {
+                            let Some((worktree_id, _, _)) = git_state.active_repository.as_ref()
+                            else {
+                                return;
+                            };
+                            if worktree_id == id {
+                                git_state.active_repository = first_repo_in_project;
+                                this.schedule_update();
+                            }
                         }
-                    }
-                    project::Event::WorktreeOrderChanged => {
-                        // activate the new first worktree if the first was moved
-                        let Some(first_id) = first_worktree_id else {
-                            return;
-                        };
-                        if !git_state
-                            .active_repository
-                            .as_ref()
-                            .is_some_and(|(id, _, _)| id == &first_id)
-                        {
-                            git_state.active_repository = first_repo_in_project;
-                            this.schedule_update();
+                        project::Event::WorktreeOrderChanged => {
+                            // activate the new first worktree if the first was moved
+                            let Some(first_id) = first_worktree_id else {
+                                return;
+                            };
+                            if !git_state
+                                .active_repository
+                                .as_ref()
+                                .is_some_and(|(id, _, _)| id == &first_id)
+                            {
+                                git_state.active_repository = first_repo_in_project;
+                                this.schedule_update();
+                            }
                         }
-                    }
-                    Event::WorktreeAdded(id) => {
-                        let Some(worktree) = project.read(cx).worktree_for_id(*id, cx) else {
-                            return;
-                        };
-                        let snapshot = worktree.read(cx).snapshot();
-                        git_state
-                            .all_repositories
-                            .insert(*id, snapshot.repositories().clone());
-                        let Some(first_id) = first_worktree_id else {
-                            return;
-                        };
-                        if !git_state
-                            .active_repository
-                            .as_ref()
-                            .is_some_and(|(id, _, _)| id == &first_id)
-                        {
-                            git_state.active_repository = first_repo_in_project;
-                            this.schedule_update();
+                        Event::WorktreeAdded(_) => {
+                            let Some(first_id) = first_worktree_id else {
+                                return;
+                            };
+                            if !git_state
+                                .active_repository
+                                .as_ref()
+                                .is_some_and(|(id, _, _)| id == &first_id)
+                            {
+                                git_state.active_repository = first_repo_in_project;
+                                this.schedule_update();
+                            }
                         }
-                    }
-                    project::Event::WorktreeUpdatedEntries(id, _) => {
-                        if git_state
-                            .active_repository
-                            .as_ref()
-                            .is_some_and(|(active_id, _, _)| active_id == id)
-                        {
-                            git_state.active_repository = first_repo_in_project;
+                        project::Event::WorktreeUpdatedEntries(id, _) => {
+                            if git_state
+                                .active_repository
+                                .as_ref()
+                                .is_some_and(|(active_id, _, _)| active_id == id)
+                            {
+                                git_state.active_repository = first_repo_in_project;
+                                this.schedule_update();
+                            }
+                        }
+                        project::Event::WorktreeUpdatedGitRepositories(_) => {
+                            let Some(first) = first_repo_in_project else {
+                                return;
+                            };
+                            git_state.active_repository = Some(first);
                             this.schedule_update();
                         }
-                    }
-                    project::Event::WorktreeUpdatedGitRepositories(_) => {
-                        let Some(first) = first_repo_in_project else {
-                            return;
-                        };
-                        git_state.active_repository = Some(first);
-                        this.schedule_update();
-                    }
-                    project::Event::Closed => {
-                        this.reveal_in_editor = Task::ready(());
-                        this.visible_entries.clear();
-                        // TODO cancel/clear task?
-                    }
-                    _ => {}
-                };
+                        project::Event::Closed => {
+                            this.reveal_in_editor = Task::ready(());
+                            this.visible_entries.clear();
+                        }
+                        _ => {}
+                    };
+                });
             })
             .detach();
 
@@ -259,10 +294,12 @@ impl GitPanel {
             if let Some(first_worktree) = first_worktree {
                 let snapshot = first_worktree.read(cx).snapshot();
 
-                if let Some((repo, git_repo)) =
-                    first_worktree_repository(&project, snapshot.id(), cx)
+                if let Some(((repo, git_repo), git_state)) =
+                    first_worktree_repository(&project, snapshot.id(), cx).zip(git_state)
                 {
-                    git_state.activate_repository(snapshot.id(), repo, git_repo);
+                    git_state.update(cx, |git_state, _| {
+                        git_state.activate_repository(snapshot.id(), repo, git_repo);
+                    });
                 }
             };
 
@@ -300,7 +337,6 @@ impl GitPanel {
                 hide_scrollbar_task: None,
                 rebuild_requested,
                 commit_editor,
-                git_state,
                 reveal_in_editor: Task::ready(()),
                 project,
             };
@@ -327,6 +363,19 @@ impl GitPanel {
         git_panel
     }
 
+    fn git_state<'a>(&self, cx: &'a AppContext) -> Option<&'a Model<GitState>> {
+        self.project.read(cx).git_state()
+    }
+
+    fn active_repository<'a>(
+        &self,
+        cx: &'a AppContext,
+    ) -> Option<&'a (WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)> {
+        let git_state = self.git_state(cx)?;
+        let active_repository = git_state.read(cx).active_repository.as_ref()?;
+        Some(active_repository)
+    }
+
     fn serialize(&mut self, cx: &mut ViewContext<Self>) {
         // TODO: we can store stage status here
         let width = self.width;
@@ -549,14 +598,20 @@ impl GitPanel {
     }
 
     fn toggle_staged_for_entry(&mut self, entry: &GitListEntry, cx: &mut ViewContext<Self>) {
-        match entry.status.is_staged() {
-            Some(true) | None => self.git_state.unstage_entry(entry.repo_path.clone()),
-            Some(false) => self.git_state.stage_entry(entry.repo_path.clone()),
-        }
+        let Some(git_state) = self.git_state(cx).cloned() else {
+            return;
+        };
+        git_state.update(cx, |git_state, _| {
+            if entry.status.is_staged().unwrap_or(false) {
+                git_state.unstage_entry(entry.repo_path.clone());
+            } else {
+                git_state.stage_entry(entry.repo_path.clone());
+            }
+        });
         cx.notify();
     }
 
-    fn toggle_staged_for_selected(&mut self, _: &ToggleStaged, cx: &mut ViewContext<Self>) {
+    fn toggle_staged_for_selected(&mut self, _: &git::ToggleStaged, cx: &mut ViewContext<Self>) {
         if let Some(selected_entry) = self.get_selected_entry().cloned() {
             self.toggle_staged_for_entry(&selected_entry, cx);
         }
@@ -572,14 +627,12 @@ impl GitPanel {
     }
 
     fn open_entry(&self, entry: &GitListEntry, cx: &mut ViewContext<Self>) {
-        let Some((worktree_id, path)) =
-            self.git_state
-                .active_repository
-                .as_ref()
-                .and_then(|(id, repo, _)| {
-                    Some((*id, repo.work_directory.unrelativize(&entry.repo_path)?))
-                })
-        else {
+        let Some((worktree_id, path)) = maybe!({
+            let git_state = self.git_state(cx)?;
+            let (id, repo, _) = git_state.read(cx).active_repository.as_ref()?;
+            let path = repo.work_directory.unrelativize(&entry.repo_path)?;
+            Some((*id, path))
+        }) else {
             return;
         };
         let path = (worktree_id, path).into();
@@ -592,7 +645,7 @@ impl GitPanel {
         cx.emit(Event::OpenedEntry { path });
     }
 
-    fn stage_all(&mut self, _: &StageAll, _cx: &mut ViewContext<Self>) {
+    fn stage_all(&mut self, _: &git::StageAll, cx: &mut ViewContext<Self>) {
         let to_stage = self
             .visible_entries
             .iter_mut()
@@ -603,31 +656,42 @@ impl GitPanel {
             })
             .collect();
         self.all_staged = Some(true);
-        self.git_state.stage_entries(to_stage);
+        let Some(git_state) = self.git_state(cx).cloned() else {
+            return;
+        };
+        git_state.update(cx, |git_state, _| git_state.stage_entries(to_stage));
     }
 
-    fn unstage_all(&mut self, _: &UnstageAll, _cx: &mut ViewContext<Self>) {
+    fn unstage_all(&mut self, _: &git::UnstageAll, cx: &mut ViewContext<Self>) {
         // This should only be called when all entries are staged.
         for entry in &mut self.visible_entries {
             entry.is_staged = Some(false);
         }
         self.all_staged = Some(false);
-        self.git_state.unstage_all();
+        let Some(git_state) = self.git_state(cx).cloned() else {
+            return;
+        };
+        git_state.update(cx, |git_state, _| git_state.unstage_all());
     }
 
-    fn discard_all(&mut self, _: &RevertAll, _cx: &mut ViewContext<Self>) {
+    fn discard_all(&mut self, _: &git::RevertAll, _cx: &mut ViewContext<Self>) {
         // TODO: Implement discard all
         println!("Discard all triggered");
     }
 
     fn clear_message(&mut self, cx: &mut ViewContext<Self>) {
-        self.git_state.clear_commit_message();
+        let Some(git_state) = self.git_state(cx).cloned() else {
+            return;
+        };
+        git_state.update(cx, |git_state, _| {
+            git_state.clear_commit_message();
+        });
         self.commit_editor
             .update(cx, |editor, cx| editor.set_text("", cx));
     }
 
     /// Commit all staged changes
-    fn commit_changes(&mut self, _: &CommitChanges, cx: &mut ViewContext<Self>) {
+    fn commit_changes(&mut self, _: &git::CommitChanges, cx: &mut ViewContext<Self>) {
         self.clear_message(cx);
 
         // TODO: Implement commit all staged
@@ -635,7 +699,7 @@ impl GitPanel {
     }
 
     /// Commit all changes, regardless of whether they are staged or not
-    fn commit_all_changes(&mut self, _: &CommitAllChanges, cx: &mut ViewContext<Self>) {
+    fn commit_all_changes(&mut self, _: &git::CommitAllChanges, cx: &mut ViewContext<Self>) {
         self.clear_message(cx);
 
         // TODO: Implement commit all changes
@@ -691,7 +755,7 @@ impl GitPanel {
     fn update_visible_entries(&mut self, cx: &mut ViewContext<Self>) {
         self.visible_entries.clear();
 
-        let Some((_, repo, _)) = self.git_state.active_repository().as_ref() else {
+        let Some((_, repo, _)) = self.active_repository(cx) else {
             // Just clear entries if no repository is active.
             cx.notify();
             return;
@@ -764,7 +828,12 @@ impl GitPanel {
         if let language::BufferEvent::Reparsed | language::BufferEvent::Edited = event {
             let commit_message = self.commit_editor.update(cx, |editor, cx| editor.text(cx));
 
-            self.git_state.commit_message = Some(commit_message.into());
+            let Some(git_state) = self.git_state(cx).cloned() else {
+                return;
+            };
+            git_state.update(cx, |git_state, _| {
+                git_state.commit_message = Some(commit_message.into())
+            });
 
             cx.notify();
         }
@@ -1094,7 +1163,7 @@ impl GitPanel {
         let entry_id = ElementId::Name(format!("entry_{}", entry_details.display_name).into());
         let checkbox_id =
             ElementId::Name(format!("checkbox_{}", entry_details.display_name).into());
-        let view_mode = self.git_state.list_view_mode;
+        let is_tree_view = false;
         let handle = cx.view().downgrade();
 
         let end_slot = h_flex()
@@ -1125,7 +1194,7 @@ impl GitPanel {
                 this.hover(|this| this.bg(cx.theme().colors().ghost_element_hover))
             });
 
-        if view_mode == GitViewMode::Tree {
+        if is_tree_view {
             entry = entry.pl(px(8. + 12. * entry_details.depth as f32))
         } else {
             entry = entry.pl(px(8.))
@@ -1152,19 +1221,22 @@ impl GitPanel {
                         let Some(this) = handle.upgrade() else {
                             return;
                         };
-                        this.update(cx, |this, _| {
+                        this.update(cx, |this, cx| {
                             this.visible_entries[ix].is_staged = match *toggle {
                                 ToggleState::Selected => Some(true),
                                 ToggleState::Unselected => Some(false),
                                 ToggleState::Indeterminate => None,
                             };
                             let repo_path = repo_path.clone();
-                            match toggle {
+                            let Some(git_state) = this.git_state(cx).cloned() else {
+                                return;
+                            };
+                            git_state.update(cx, |git_state, _| match toggle {
                                 ToggleState::Selected | ToggleState::Indeterminate => {
-                                    this.git_state.stage_entry(repo_path);
+                                    git_state.stage_entry(repo_path);
                                 }
-                                ToggleState::Unselected => this.git_state.unstage_entry(repo_path),
-                            }
+                                ToggleState::Unselected => git_state.unstage_entry(repo_path),
+                            })
                         });
                     }
                 }),

crates/git_ui/src/git_ui.rs 🔗

@@ -1,209 +1,16 @@
 use ::settings::Settings;
-use collections::HashMap;
-use futures::channel::mpsc;
-use futures::StreamExt as _;
-use git::repository::{GitRepository, RepoPath};
 use git::status::FileStatus;
 use git_panel_settings::GitPanelSettings;
-use gpui::{actions, AppContext, Hsla, Model};
-use project::{Project, WorktreeId};
-use std::sync::Arc;
-use sum_tree::SumTree;
-use ui::{Color, Icon, IconName, IntoElement, SharedString};
-use util::ResultExt as _;
-use worktree::RepositoryEntry;
+use gpui::{AppContext, Hsla};
+use ui::{Color, Icon, IconName, IntoElement};
 
 pub mod git_panel;
 mod git_panel_settings;
 
-actions!(
-    git,
-    [
-        StageFile,
-        UnstageFile,
-        ToggleStaged,
-        // Revert actions are currently in the editor crate:
-        // editor::RevertFile,
-        // editor::RevertSelectedHunks
-        StageAll,
-        UnstageAll,
-        RevertAll,
-        CommitChanges,
-        CommitAllChanges,
-        ClearCommitMessage
-    ]
-);
-
 pub fn init(cx: &mut AppContext) {
     GitPanelSettings::register(cx);
 }
 
-#[derive(Default, Debug, PartialEq, Eq, Clone, Copy)]
-pub enum GitViewMode {
-    #[default]
-    List,
-    Tree,
-}
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-enum StatusAction {
-    Stage,
-    Unstage,
-}
-
-pub struct GitState {
-    /// The current commit message being composed.
-    commit_message: Option<SharedString>,
-
-    /// When a git repository is selected, this is used to track which repository's changes
-    /// are currently being viewed or modified in the UI.
-    active_repository: Option<(WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)>,
-
-    updater_tx: mpsc::UnboundedSender<(Arc<dyn GitRepository>, Vec<RepoPath>, StatusAction)>,
-
-    all_repositories: HashMap<WorktreeId, SumTree<RepositoryEntry>>,
-
-    list_view_mode: GitViewMode,
-}
-
-impl GitState {
-    pub fn new(cx: &AppContext) -> Self {
-        let (updater_tx, mut updater_rx) =
-            mpsc::unbounded::<(Arc<dyn GitRepository>, Vec<RepoPath>, StatusAction)>();
-        cx.spawn(|cx| async move {
-            while let Some((git_repo, paths, action)) = updater_rx.next().await {
-                cx.background_executor()
-                    .spawn(async move {
-                        match action {
-                            StatusAction::Stage => git_repo.stage_paths(&paths),
-                            StatusAction::Unstage => git_repo.unstage_paths(&paths),
-                        }
-                    })
-                    .await
-                    .log_err();
-            }
-        })
-        .detach();
-        GitState {
-            commit_message: None,
-            active_repository: None,
-            updater_tx,
-            list_view_mode: GitViewMode::default(),
-            all_repositories: HashMap::default(),
-        }
-    }
-
-    pub fn activate_repository(
-        &mut self,
-        worktree_id: WorktreeId,
-        active_repository: RepositoryEntry,
-        git_repo: Arc<dyn GitRepository>,
-    ) {
-        self.active_repository = Some((worktree_id, active_repository, git_repo));
-    }
-
-    pub fn active_repository(
-        &self,
-    ) -> Option<&(WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)> {
-        self.active_repository.as_ref()
-    }
-
-    pub fn commit_message(&mut self, message: Option<SharedString>) {
-        self.commit_message = message;
-    }
-
-    pub fn clear_commit_message(&mut self) {
-        self.commit_message = None;
-    }
-
-    pub fn stage_entry(&mut self, repo_path: RepoPath) {
-        if let Some((_, _, git_repo)) = self.active_repository.as_ref() {
-            let _ = self.updater_tx.unbounded_send((
-                git_repo.clone(),
-                vec![repo_path],
-                StatusAction::Stage,
-            ));
-        }
-    }
-
-    pub fn unstage_entry(&mut self, repo_path: RepoPath) {
-        if let Some((_, _, git_repo)) = self.active_repository.as_ref() {
-            let _ = self.updater_tx.unbounded_send((
-                git_repo.clone(),
-                vec![repo_path],
-                StatusAction::Unstage,
-            ));
-        }
-    }
-
-    pub fn stage_entries(&mut self, entries: Vec<RepoPath>) {
-        if let Some((_, _, git_repo)) = self.active_repository.as_ref() {
-            let _ =
-                self.updater_tx
-                    .unbounded_send((git_repo.clone(), entries, StatusAction::Stage));
-        }
-    }
-
-    fn act_on_all(&mut self, action: StatusAction) {
-        if let Some((_, active_repository, git_repo)) = self.active_repository.as_ref() {
-            let _ = self.updater_tx.unbounded_send((
-                git_repo.clone(),
-                active_repository
-                    .status()
-                    .map(|entry| entry.repo_path)
-                    .collect(),
-                action,
-            ));
-        }
-    }
-
-    pub fn stage_all(&mut self) {
-        self.act_on_all(StatusAction::Stage);
-    }
-
-    pub fn unstage_all(&mut self) {
-        self.act_on_all(StatusAction::Unstage);
-    }
-}
-
-pub fn first_worktree_repository(
-    project: &Model<Project>,
-    worktree_id: WorktreeId,
-    cx: &mut AppContext,
-) -> Option<(RepositoryEntry, Arc<dyn GitRepository>)> {
-    project
-        .read(cx)
-        .worktree_for_id(worktree_id, cx)
-        .and_then(|worktree| {
-            let snapshot = worktree.read(cx).snapshot();
-            let repo = snapshot.repositories().iter().next()?.clone();
-            let git_repo = worktree
-                .read(cx)
-                .as_local()?
-                .get_local_repo(&repo)?
-                .repo()
-                .clone();
-            Some((repo, git_repo))
-        })
-}
-
-pub fn first_repository_in_project(
-    project: &Model<Project>,
-    cx: &mut AppContext,
-) -> Option<(WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)> {
-    project.read(cx).worktrees(cx).next().and_then(|worktree| {
-        let snapshot = worktree.read(cx).snapshot();
-        let repo = snapshot.repositories().iter().next()?.clone();
-        let git_repo = worktree
-            .read(cx)
-            .as_local()?
-            .get_local_repo(&repo)?
-            .repo()
-            .clone();
-        Some((snapshot.id(), repo, git_repo))
-    })
-}
-
 const ADDED_COLOR: Hsla = Hsla {
     h: 142. / 360.,
     s: 0.68,

crates/project/src/git.rs 🔗

@@ -0,0 +1,124 @@
+use std::sync::Arc;
+
+use futures::channel::mpsc;
+use futures::StreamExt as _;
+use git::repository::{GitRepository, RepoPath};
+use gpui::{AppContext, SharedString};
+use settings::WorktreeId;
+use util::ResultExt as _;
+use worktree::RepositoryEntry;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum StatusAction {
+    Stage,
+    Unstage,
+}
+
+pub struct GitState {
+    /// The current commit message being composed.
+    pub commit_message: Option<SharedString>,
+
+    /// When a git repository is selected, this is used to track which repository's changes
+    /// are currently being viewed or modified in the UI.
+    pub active_repository: Option<(WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)>,
+
+    pub update_sender: mpsc::UnboundedSender<(Arc<dyn GitRepository>, Vec<RepoPath>, StatusAction)>,
+}
+
+impl GitState {
+    pub fn new(cx: &AppContext) -> Self {
+        let (tx, mut rx) =
+            mpsc::unbounded::<(Arc<dyn GitRepository>, Vec<RepoPath>, StatusAction)>();
+        cx.spawn(|cx| async move {
+            while let Some((git_repo, paths, action)) = rx.next().await {
+                cx.background_executor()
+                    .spawn(async move {
+                        match action {
+                            StatusAction::Stage => git_repo.stage_paths(&paths),
+                            StatusAction::Unstage => git_repo.unstage_paths(&paths),
+                        }
+                    })
+                    .await
+                    .log_err();
+            }
+        })
+        .detach();
+        GitState {
+            commit_message: None,
+            active_repository: None,
+            update_sender: tx,
+        }
+    }
+
+    pub fn activate_repository(
+        &mut self,
+        worktree_id: WorktreeId,
+        active_repository: RepositoryEntry,
+        git_repo: Arc<dyn GitRepository>,
+    ) {
+        self.active_repository = Some((worktree_id, active_repository, git_repo));
+    }
+
+    pub fn active_repository(
+        &self,
+    ) -> Option<&(WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)> {
+        self.active_repository.as_ref()
+    }
+
+    pub fn commit_message(&mut self, message: Option<SharedString>) {
+        self.commit_message = message;
+    }
+
+    pub fn clear_commit_message(&mut self) {
+        self.commit_message = None;
+    }
+
+    pub fn stage_entry(&mut self, repo_path: RepoPath) {
+        if let Some((_, _, git_repo)) = self.active_repository.as_ref() {
+            let _ = self.update_sender.unbounded_send((
+                git_repo.clone(),
+                vec![repo_path],
+                StatusAction::Stage,
+            ));
+        }
+    }
+
+    pub fn unstage_entry(&mut self, repo_path: RepoPath) {
+        if let Some((_, _, git_repo)) = self.active_repository.as_ref() {
+            let _ = self.update_sender.unbounded_send((
+                git_repo.clone(),
+                vec![repo_path],
+                StatusAction::Unstage,
+            ));
+        }
+    }
+
+    pub fn stage_entries(&mut self, entries: Vec<RepoPath>) {
+        if let Some((_, _, git_repo)) = self.active_repository.as_ref() {
+            let _ =
+                self.update_sender
+                    .unbounded_send((git_repo.clone(), entries, StatusAction::Stage));
+        }
+    }
+
+    fn act_on_all(&mut self, action: StatusAction) {
+        if let Some((_, active_repository, git_repo)) = self.active_repository.as_ref() {
+            let _ = self.update_sender.unbounded_send((
+                git_repo.clone(),
+                active_repository
+                    .status()
+                    .map(|entry| entry.repo_path)
+                    .collect(),
+                action,
+            ));
+        }
+    }
+
+    pub fn stage_all(&mut self) {
+        self.act_on_all(StatusAction::Stage);
+    }
+
+    pub fn unstage_all(&mut self) {
+        self.act_on_all(StatusAction::Unstage);
+    }
+}

crates/project/src/project.rs 🔗

@@ -2,6 +2,7 @@ pub mod buffer_store;
 mod color_extractor;
 pub mod connection_manager;
 pub mod debounced_delay;
+pub mod git;
 pub mod image_store;
 pub mod lsp_command;
 pub mod lsp_ext_command;
@@ -24,6 +25,7 @@ pub use environment::EnvironmentErrorMessage;
 pub mod search_history;
 mod yarn;
 
+use crate::git::GitState;
 use anyhow::{anyhow, Context as _, Result};
 use buffer_store::{BufferChangeSet, BufferStore, BufferStoreEvent};
 use client::{proto, Client, Collaborator, PendingEntitySubscription, TypedEnvelope, UserStore};
@@ -39,7 +41,11 @@ use futures::{
 pub use image_store::{ImageItem, ImageStore};
 use image_store::{ImageItemEvent, ImageStoreEvent};
 
-use git::{blame::Blame, repository::GitRepository, status::FileStatus};
+use ::git::{
+    blame::Blame,
+    repository::{Branch, GitRepository},
+    status::FileStatus,
+};
 use gpui::{
     AnyModel, AppContext, AsyncAppContext, BorrowAppContext, Context as _, EventEmitter, Hsla,
     Model, ModelContext, SharedString, Task, WeakModel, WindowContext,
@@ -148,6 +154,7 @@ pub struct Project {
     fs: Arc<dyn Fs>,
     ssh_client: Option<Model<SshRemoteClient>>,
     client_state: ProjectClientState,
+    git_state: Option<Model<GitState>>,
     collaborators: HashMap<proto::PeerId, Collaborator>,
     client_subscriptions: Vec<client::Subscription>,
     worktree_store: Model<WorktreeStore>,
@@ -685,6 +692,9 @@ impl Project {
                     cx,
                 )
             });
+
+            let git_state = Some(cx.new_model(|cx| GitState::new(cx)));
+
             cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach();
 
             Self {
@@ -696,6 +706,7 @@ impl Project {
                 lsp_store,
                 join_project_response_message_id: 0,
                 client_state: ProjectClientState::Local,
+                git_state,
                 client_subscriptions: Vec::new(),
                 _subscriptions: vec![cx.on_release(Self::release)],
                 active_entry: None,
@@ -814,6 +825,7 @@ impl Project {
                 lsp_store,
                 join_project_response_message_id: 0,
                 client_state: ProjectClientState::Local,
+                git_state: None,
                 client_subscriptions: Vec::new(),
                 _subscriptions: vec![
                     cx.on_release(Self::release),
@@ -1045,6 +1057,7 @@ impl Project {
                     remote_id,
                     replica_id,
                 },
+                git_state: None,
                 buffers_needing_diff: Default::default(),
                 git_diff_debouncer: DebouncedDelay::new(),
                 terminals: Terminals {
@@ -3534,7 +3547,7 @@ impl Project {
         &self,
         project_path: ProjectPath,
         cx: &AppContext,
-    ) -> Task<Result<Vec<git::repository::Branch>>> {
+    ) -> Task<Result<Vec<Branch>>> {
         self.worktree_store().read(cx).branches(project_path, cx)
     }
 
@@ -4154,6 +4167,10 @@ impl Project {
     pub fn buffer_store(&self) -> &Model<BufferStore> {
         &self.buffer_store
     }
+
+    pub fn git_state(&self) -> Option<&Model<GitState>> {
+        self.git_state.as_ref()
+    }
 }
 
 fn deserialize_code_actions(code_actions: &HashMap<String, bool>) -> Vec<lsp::CodeActionKind> {

crates/project/src/project_tests.rs 🔗

@@ -1,7 +1,7 @@
 use crate::{Event, *};
+use ::git::diff::assert_hunks;
 use fs::FakeFs;
 use futures::{future, StreamExt};
-use git::diff::assert_hunks;
 use gpui::{AppContext, SemanticVersion, UpdateGlobal};
 use http_client::Url;
 use language::{

crates/worktree/src/worktree.rs 🔗

@@ -2583,6 +2583,17 @@ impl Snapshot {
         &self.repositories
     }
 
+    pub fn repositories_with_abs_paths(
+        &self,
+    ) -> impl '_ + Iterator<Item = (&RepositoryEntry, PathBuf)> {
+        let base = self.abs_path();
+        self.repositories.iter().map(|repo| {
+            let path = repo.work_directory.location_in_repo.as_deref();
+            let path = path.unwrap_or(repo.work_directory.as_ref());
+            (repo, base.join(path))
+        })
+    }
+
     /// Get the repository whose work directory corresponds to the given path.
     pub(crate) fn repository(&self, work_directory: PathKey) -> Option<RepositoryEntry> {
         self.repositories.get(&work_directory, &()).cloned()