git: Implement commit creation (#23263)

Cole Miller and Nate created

- [x] Basic implementation
- [x] Disable commit buttons when committing is not possible (empty
message, no changes)
- [x] Upgrade GitSummary to efficiently figure out whether there are any
staged changes
- [x] Make CommitAll work
- [x] Surface errors with toasts
  - [x] Channel shutdown
  - [x] Empty commit message or no changes
  - [x] Failed git operations
- [x] Fix added files no longer appearing correctly in the project panel
(GitSummary breakage)
- [x] Fix handling of commit message

Release Notes:

- N/A

---------

Co-authored-by: Nate <nate@zed.dev>

Change summary

Cargo.lock                                |   1 
crates/editor/src/items.rs                |   5 
crates/git/src/repository.rs              |  24 +++
crates/git/src/status.rs                  | 130 ++++++++++++++----
crates/git_ui/Cargo.toml                  |   1 
crates/git_ui/src/git_panel.rs            | 144 ++++++++++++-------
crates/project/src/git.rs                 | 177 ++++++++++++++++++------
crates/project_panel/src/project_panel.rs |   4 
crates/worktree/src/worktree.rs           |   4 
crates/worktree/src/worktree_tests.rs     |  93 ++++--------
10 files changed, 386 insertions(+), 197 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5192,6 +5192,7 @@ dependencies = [
  "collections",
  "db",
  "editor",
+ "futures 0.3.31",
  "git",
  "gpui",
  "language",

crates/editor/src/items.rs 🔗

@@ -1560,13 +1560,14 @@ pub fn entry_diagnostic_aware_icon_decoration_and_color(
 }
 
 pub fn entry_git_aware_label_color(git_status: GitSummary, ignored: bool, selected: bool) -> Color {
+    let tracked = git_status.index + git_status.worktree;
     if ignored {
         Color::Ignored
     } else if git_status.conflict > 0 {
         Color::Conflict
-    } else if git_status.modified > 0 {
+    } else if tracked.modified > 0 {
         Color::Modified
-    } else if git_status.added > 0 || git_status.untracked > 0 {
+    } else if tracked.added > 0 || git_status.untracked > 0 {
         Color::Created
     } else {
         entry_label_color(selected)

crates/git/src/repository.rs 🔗

@@ -61,6 +61,8 @@ pub trait GitRepository: Send + Sync {
     ///
     /// If any of the paths were previously staged but do not exist in HEAD, they will be removed from the index.
     fn unstage_paths(&self, paths: &[RepoPath]) -> Result<()>;
+
+    fn commit(&self, message: &str) -> Result<()>;
 }
 
 impl std::fmt::Debug for dyn GitRepository {
@@ -280,6 +282,24 @@ impl GitRepository for RealGitRepository {
         }
         Ok(())
     }
+
+    fn commit(&self, message: &str) -> Result<()> {
+        let working_directory = self
+            .repository
+            .lock()
+            .workdir()
+            .context("failed to read git work directory")?
+            .to_path_buf();
+
+        let cmd = new_std_command(&self.git_binary_path)
+            .current_dir(&working_directory)
+            .args(["commit", "--quiet", "-m", message])
+            .status()?;
+        if !cmd.success() {
+            return Err(anyhow!("Failed to commit: {cmd}"));
+        }
+        Ok(())
+    }
 }
 
 #[derive(Debug, Clone)]
@@ -423,6 +443,10 @@ impl GitRepository for FakeGitRepository {
     fn unstage_paths(&self, _paths: &[RepoPath]) -> Result<()> {
         unimplemented!()
     }
+
+    fn commit(&self, _message: &str) -> Result<()> {
+        unimplemented!()
+    }
 }
 
 fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {

crates/git/src/status.rs 🔗

@@ -171,13 +171,13 @@ impl FileStatus {
             FileStatus::Tracked(TrackedStatus {
                 index_status,
                 worktree_status,
-            }) => {
-                let mut summary = index_status.to_summary() + worktree_status.to_summary();
-                if summary != GitSummary::UNCHANGED {
-                    summary.count = 1;
-                };
-                summary
-            }
+            }) => GitSummary {
+                index: index_status.to_summary(),
+                worktree: worktree_status.to_summary(),
+                conflict: 0,
+                untracked: 0,
+                count: 1,
+            },
         }
     }
 }
@@ -196,28 +196,39 @@ impl StatusCode {
         }
     }
 
-    /// Returns the contribution of this status code to the Git summary.
-    ///
-    /// Note that this does not include the count field, which must be set manually.
-    fn to_summary(self) -> GitSummary {
+    fn to_summary(self) -> TrackedSummary {
         match self {
-            StatusCode::Modified | StatusCode::TypeChanged => GitSummary {
+            StatusCode::Modified | StatusCode::TypeChanged => TrackedSummary {
                 modified: 1,
-                ..GitSummary::UNCHANGED
+                ..TrackedSummary::UNCHANGED
             },
-            StatusCode::Added => GitSummary {
+            StatusCode::Added => TrackedSummary {
                 added: 1,
-                ..GitSummary::UNCHANGED
+                ..TrackedSummary::UNCHANGED
             },
-            StatusCode::Deleted => GitSummary {
+            StatusCode::Deleted => TrackedSummary {
                 deleted: 1,
-                ..GitSummary::UNCHANGED
+                ..TrackedSummary::UNCHANGED
             },
             StatusCode::Renamed | StatusCode::Copied | StatusCode::Unmodified => {
-                GitSummary::UNCHANGED
+                TrackedSummary::UNCHANGED
             }
         }
     }
+
+    pub fn index(self) -> FileStatus {
+        FileStatus::Tracked(TrackedStatus {
+            index_status: self,
+            worktree_status: StatusCode::Unmodified,
+        })
+    }
+
+    pub fn worktree(self) -> FileStatus {
+        FileStatus::Tracked(TrackedStatus {
+            index_status: StatusCode::Unmodified,
+            worktree_status: self,
+        })
+    }
 }
 
 impl UnmergedStatusCode {
@@ -232,12 +243,76 @@ impl UnmergedStatusCode {
 }
 
 #[derive(Clone, Debug, Default, Copy, PartialEq, Eq)]
-pub struct GitSummary {
+pub struct TrackedSummary {
     pub added: usize,
     pub modified: usize,
+    pub deleted: usize,
+}
+
+impl TrackedSummary {
+    pub const UNCHANGED: Self = Self {
+        added: 0,
+        modified: 0,
+        deleted: 0,
+    };
+
+    pub const ADDED: Self = Self {
+        added: 1,
+        modified: 0,
+        deleted: 0,
+    };
+
+    pub const MODIFIED: Self = Self {
+        added: 0,
+        modified: 1,
+        deleted: 0,
+    };
+
+    pub const DELETED: Self = Self {
+        added: 0,
+        modified: 0,
+        deleted: 1,
+    };
+}
+
+impl std::ops::AddAssign for TrackedSummary {
+    fn add_assign(&mut self, rhs: Self) {
+        self.added += rhs.added;
+        self.modified += rhs.modified;
+        self.deleted += rhs.deleted;
+    }
+}
+
+impl std::ops::Add for TrackedSummary {
+    type Output = Self;
+
+    fn add(self, rhs: Self) -> Self::Output {
+        TrackedSummary {
+            added: self.added + rhs.added,
+            modified: self.modified + rhs.modified,
+            deleted: self.deleted + rhs.deleted,
+        }
+    }
+}
+
+impl std::ops::Sub for TrackedSummary {
+    type Output = Self;
+
+    fn sub(self, rhs: Self) -> Self::Output {
+        TrackedSummary {
+            added: self.added - rhs.added,
+            modified: self.modified - rhs.modified,
+            deleted: self.deleted - rhs.deleted,
+        }
+    }
+}
+
+#[derive(Clone, Debug, Default, Copy, PartialEq, Eq)]
+pub struct GitSummary {
+    pub index: TrackedSummary,
+    pub worktree: TrackedSummary,
     pub conflict: usize,
     pub untracked: usize,
-    pub deleted: usize,
     pub count: usize,
 }
 
@@ -255,11 +330,10 @@ impl GitSummary {
     };
 
     pub const UNCHANGED: Self = Self {
-        added: 0,
-        modified: 0,
+        index: TrackedSummary::UNCHANGED,
+        worktree: TrackedSummary::UNCHANGED,
         conflict: 0,
         untracked: 0,
-        deleted: 0,
         count: 0,
     };
 }
@@ -293,11 +367,10 @@ impl std::ops::Add<Self> for GitSummary {
 
 impl std::ops::AddAssign for GitSummary {
     fn add_assign(&mut self, rhs: Self) {
-        self.added += rhs.added;
-        self.modified += rhs.modified;
+        self.index += rhs.index;
+        self.worktree += rhs.worktree;
         self.conflict += rhs.conflict;
         self.untracked += rhs.untracked;
-        self.deleted += rhs.deleted;
         self.count += rhs.count;
     }
 }
@@ -307,11 +380,10 @@ impl std::ops::Sub for GitSummary {
 
     fn sub(self, rhs: Self) -> Self::Output {
         GitSummary {
-            added: self.added - rhs.added,
-            modified: self.modified - rhs.modified,
+            index: self.index - rhs.index,
+            worktree: self.worktree - rhs.worktree,
             conflict: self.conflict - rhs.conflict,
             untracked: self.untracked - rhs.untracked,
-            deleted: self.deleted - rhs.deleted,
             count: self.count - rhs.count,
         }
     }

crates/git_ui/Cargo.toml 🔗

@@ -17,6 +17,7 @@ anyhow.workspace = true
 collections.workspace = true
 db.workspace = true
 editor.workspace = true
+futures.workspace = true
 git.workspace = true
 gpui.workspace = true
 language.workspace = true

crates/git_ui/src/git_panel.rs 🔗

@@ -4,6 +4,8 @@ use anyhow::{Context as _, Result};
 use db::kvp::KEY_VALUE_STORE;
 use editor::scroll::ScrollbarAutoHide;
 use editor::{Editor, EditorSettings, ShowScrollbar};
+use futures::channel::mpsc;
+use futures::StreamExt as _;
 use git::repository::{GitRepository, RepoPath};
 use git::status::FileStatus;
 use git::{CommitAllChanges, CommitChanges, RevertAll, StageAll, ToggleStaged, UnstageAll};
@@ -21,7 +23,8 @@ use ui::{
     prelude::*, Checkbox, Divider, DividerColor, ElevationIndex, Scrollbar, ScrollbarState, Tooltip,
 };
 use util::{maybe, ResultExt, TryFutureExt};
-use workspace::notifications::DetachAndPromptErr;
+use workspace::notifications::{DetachAndPromptErr, NotificationId};
+use workspace::Toast;
 use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
     Workspace,
@@ -76,6 +79,7 @@ pub struct GitListEntry {
 }
 
 pub struct GitPanel {
+    weak_workspace: WeakView<Workspace>,
     current_modifiers: Modifiers,
     focus_handle: FocusHandle,
     fs: Arc<dyn Fs>,
@@ -92,6 +96,7 @@ pub struct GitPanel {
     all_staged: Option<bool>,
     width: Option<Pixels>,
     reveal_in_editor: Task<()>,
+    err_sender: mpsc::Sender<anyhow::Error>,
 }
 
 fn first_worktree_repository(
@@ -143,11 +148,14 @@ 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 weak_workspace = cx.view().downgrade();
         let git_state = project.read(cx).git_state().cloned();
         let language_registry = workspace.app_state().languages.clone();
         let current_commit_message = git_state
             .as_ref()
-            .and_then(|git_state| git_state.read(cx).commit_message.clone());
+            .map(|git_state| git_state.read(cx).commit_message.clone());
+
+        let (err_sender, mut err_receiver) = mpsc::channel(1);
 
         let git_panel = cx.new_view(|cx: &mut ViewContext<Self>| {
             let focus_handle = cx.focus_handle();
@@ -319,6 +327,7 @@ impl GitPanel {
             .detach();
 
             let mut git_panel = Self {
+                weak_workspace,
                 focus_handle: cx.focus_handle(),
                 fs,
                 pending_serialization: Task::ready(None),
@@ -333,14 +342,33 @@ impl GitPanel {
                 hide_scrollbar_task: None,
                 rebuild_requested,
                 commit_editor,
-                reveal_in_editor: Task::ready(()),
                 project,
+                reveal_in_editor: Task::ready(()),
+                err_sender,
             };
             git_panel.schedule_update();
             git_panel.show_scrollbar = git_panel.should_show_scrollbar(cx);
             git_panel
         });
 
+        let handle = git_panel.downgrade();
+        cx.spawn(|_, mut cx| async move {
+            while let Some(e) = err_receiver.next().await {
+                let Some(this) = handle.upgrade() else {
+                    break;
+                };
+                if this
+                    .update(&mut cx, |this, cx| {
+                        this.show_err_toast("git operation error", e, cx);
+                    })
+                    .is_err()
+                {
+                    break;
+                }
+            }
+        })
+        .detach();
+
         cx.subscribe(
             &git_panel,
             move |workspace, _, event: &Event, cx| match event.clone() {
@@ -606,13 +634,16 @@ impl GitPanel {
         let Some(git_state) = self.git_state(cx) else {
             return;
         };
-        git_state.update(cx, |git_state, _| {
+        let result = git_state.update(cx, |git_state, _| {
             if entry.status.is_staged().unwrap_or(false) {
-                git_state.stage_entries(vec![entry.repo_path.clone()]);
+                git_state.unstage_entries(vec![entry.repo_path.clone()], self.err_sender.clone())
             } else {
-                git_state.stage_entries(vec![entry.repo_path.clone()]);
+                git_state.stage_entries(vec![entry.repo_path.clone()], self.err_sender.clone())
             }
         });
+        if let Err(e) = result {
+            self.show_err_toast("toggle staged error", e, cx);
+        }
         cx.notify();
     }
 
@@ -649,7 +680,10 @@ impl GitPanel {
             entry.is_staged = Some(true);
         }
         self.all_staged = Some(true);
-        git_state.read(cx).stage_all();
+
+        if let Err(e) = git_state.read(cx).stage_all(self.err_sender.clone()) {
+            self.show_err_toast("stage all error", e, cx);
+        };
     }
 
     fn unstage_all(&mut self, _: &git::UnstageAll, cx: &mut ViewContext<Self>) {
@@ -660,7 +694,9 @@ impl GitPanel {
             entry.is_staged = Some(false);
         }
         self.all_staged = Some(false);
-        git_state.read(cx).unstage_all();
+        if let Err(e) = git_state.read(cx).unstage_all(self.err_sender.clone()) {
+            self.show_err_toast("unstage all error", e, cx);
+        };
     }
 
     fn discard_all(&mut self, _: &git::RevertAll, _cx: &mut ViewContext<Self>) {
@@ -668,53 +704,32 @@ impl GitPanel {
         println!("Discard all triggered");
     }
 
-    fn clear_message(&mut self, cx: &mut ViewContext<Self>) {
+    /// Commit all staged changes
+    fn commit_changes(&mut self, _: &git::CommitChanges, cx: &mut ViewContext<Self>) {
         let Some(git_state) = self.git_state(cx) else {
             return;
         };
-        git_state.update(cx, |git_state, _| {
-            git_state.clear_commit_message();
-        });
+        if let Err(e) =
+            git_state.update(cx, |git_state, _| git_state.commit(self.err_sender.clone()))
+        {
+            self.show_err_toast("commit error", e, cx);
+        };
         self.commit_editor
             .update(cx, |editor, cx| editor.set_text("", cx));
     }
 
-    fn can_commit(&self, commit_all: bool, cx: &AppContext) -> bool {
-        let Some(git_state) = self.git_state(cx) else {
-            return false;
-        };
-        let has_message = !self.commit_editor.read(cx).text(cx).is_empty();
-        let has_changes = git_state.read(cx).entry_count() > 0;
-        let has_staged_changes = self
-            .visible_entries
-            .iter()
-            .any(|entry| entry.is_staged == Some(true));
-
-        has_message && (commit_all || has_staged_changes) && has_changes
-    }
-
-    /// Commit all staged changes
-    fn commit_changes(&mut self, _: &git::CommitChanges, cx: &mut ViewContext<Self>) {
-        self.clear_message(cx);
-
-        if !self.can_commit(false, cx) {
-            return;
-        }
-
-        // TODO: Implement commit all staged
-        println!("Commit staged changes triggered");
-    }
-
     /// Commit all changes, regardless of whether they are staged or not
     fn commit_all_changes(&mut self, _: &git::CommitAllChanges, cx: &mut ViewContext<Self>) {
-        self.clear_message(cx);
-
-        if !self.can_commit(true, cx) {
+        let Some(git_state) = self.git_state(cx) else {
             return;
-        }
-
-        // TODO: Implement commit all changes
-        println!("Commit all changes triggered");
+        };
+        if let Err(e) = git_state.update(cx, |git_state, _| {
+            git_state.commit_all(self.err_sender.clone())
+        }) {
+            self.show_err_toast("commit all error", e, cx);
+        };
+        self.commit_editor
+            .update(cx, |editor, cx| editor.set_text("", cx));
     }
 
     fn no_entries(&self, cx: &mut ViewContext<Self>) -> bool {
@@ -840,12 +855,26 @@ impl GitPanel {
                 return;
             };
             git_state.update(cx, |git_state, _| {
-                git_state.commit_message = Some(commit_message.into())
+                git_state.commit_message = commit_message.into();
             });
 
             cx.notify();
         }
     }
+
+    fn show_err_toast(&self, id: &'static str, e: anyhow::Error, cx: &mut ViewContext<Self>) {
+        let Some(workspace) = self.weak_workspace.upgrade() else {
+            return;
+        };
+        let notif_id = NotificationId::Named(id.into());
+        let message = e.to_string();
+        workspace.update(cx, |workspace, cx| {
+            let toast = Toast::new(notif_id, message).on_click("Open Zed Log", |cx| {
+                cx.dispatch_action(workspace::OpenLog.boxed_clone());
+            });
+            workspace.show_toast(toast, cx);
+        });
+    }
 }
 
 // GitPanel –– Render
@@ -989,6 +1018,10 @@ impl GitPanel {
     pub fn render_commit_editor(&self, cx: &ViewContext<Self>) -> impl IntoElement {
         let editor = self.commit_editor.clone();
         let editor_focus_handle = editor.read(cx).focus_handle(cx).clone();
+        let (can_commit, can_commit_all) = self.git_state(cx).map_or((false, false), |git_state| {
+            let git_state = git_state.read(cx);
+            (git_state.can_commit(false), git_state.can_commit(true))
+        });
 
         let focus_handle_1 = self.focus_handle(cx).clone();
         let focus_handle_2 = self.focus_handle(cx).clone();
@@ -1004,6 +1037,7 @@ impl GitPanel {
                     cx,
                 )
             })
+            .disabled(!can_commit)
             .on_click(
                 cx.listener(|this, _: &ClickEvent, cx| this.commit_changes(&CommitChanges, cx)),
             );
@@ -1019,6 +1053,7 @@ impl GitPanel {
                     cx,
                 )
             })
+            .disabled(!can_commit_all)
             .on_click(cx.listener(|this, _: &ClickEvent, cx| {
                 this.commit_all_changes(&CommitAllChanges, cx)
             }));
@@ -1243,14 +1278,15 @@ impl GitPanel {
                             let Some(git_state) = this.git_state(cx) else {
                                 return;
                             };
-                            git_state.update(cx, |git_state, _| match toggle {
-                                ToggleState::Selected | ToggleState::Indeterminate => {
-                                    git_state.stage_entries(vec![repo_path]);
-                                }
-                                ToggleState::Unselected => {
-                                    git_state.unstage_entries(vec![repo_path])
-                                }
-                            })
+                            let result = git_state.update(cx, |git_state, _| match toggle {
+                                ToggleState::Selected | ToggleState::Indeterminate => git_state
+                                    .stage_entries(vec![repo_path], this.err_sender.clone()),
+                                ToggleState::Unselected => git_state
+                                    .unstage_entries(vec![repo_path], this.err_sender.clone()),
+                            });
+                            if let Err(e) = result {
+                                this.show_err_toast("toggle staged error", e, cx);
+                            }
                         });
                     }
                 }),

crates/project/src/git.rs 🔗

@@ -1,52 +1,65 @@
 use std::sync::Arc;
 
+use anyhow::anyhow;
 use futures::channel::mpsc;
-use futures::StreamExt as _;
-use git::repository::{GitRepository, RepoPath};
+use futures::{SinkExt as _, StreamExt as _};
+use git::{
+    repository::{GitRepository, RepoPath},
+    status::{GitSummary, TrackedSummary},
+};
 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>,
+    pub commit_message: 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)>,
+    update_sender: mpsc::UnboundedSender<(Message, mpsc::Sender<anyhow::Error>)>,
+}
+
+enum Message {
+    StageAndCommit(Arc<dyn GitRepository>, SharedString, Vec<RepoPath>),
+    Commit(Arc<dyn GitRepository>, SharedString),
+    Stage(Arc<dyn GitRepository>, Vec<RepoPath>),
+    Unstage(Arc<dyn GitRepository>, Vec<RepoPath>),
 }
 
 impl GitState {
     pub fn new(cx: &AppContext) -> Self {
-        let (tx, mut rx) =
-            mpsc::unbounded::<(Arc<dyn GitRepository>, Vec<RepoPath>, StatusAction)>();
+        let (update_sender, mut update_receiver) =
+            mpsc::unbounded::<(Message, mpsc::Sender<anyhow::Error>)>();
         cx.spawn(|cx| async move {
-            while let Some((git_repo, paths, action)) = rx.next().await {
-                cx.background_executor()
+            while let Some((msg, mut err_sender)) = update_receiver.next().await {
+                let result = cx
+                    .background_executor()
                     .spawn(async move {
-                        match action {
-                            StatusAction::Stage => git_repo.stage_paths(&paths),
-                            StatusAction::Unstage => git_repo.unstage_paths(&paths),
+                        match msg {
+                            Message::StageAndCommit(repo, message, paths) => {
+                                repo.stage_paths(&paths)?;
+                                repo.commit(&message)?;
+                                Ok(())
+                            }
+                            Message::Stage(repo, paths) => repo.stage_paths(&paths),
+                            Message::Unstage(repo, paths) => repo.unstage_paths(&paths),
+                            Message::Commit(repo, message) => repo.commit(&message),
                         }
                     })
-                    .await
-                    .log_err();
+                    .await;
+                if let Err(e) = result {
+                    err_sender.send(e).await.ok();
+                }
             }
         })
         .detach();
         GitState {
-            commit_message: None,
+            commit_message: SharedString::default(),
             active_repository: None,
-            update_sender: tx,
+            update_sender,
         }
     }
 
@@ -65,55 +78,64 @@ impl GitState {
         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;
-    }
-
-    fn act_on_entries(&self, entries: Vec<RepoPath>, action: StatusAction) {
+    pub fn stage_entries(
+        &self,
+        entries: Vec<RepoPath>,
+        err_sender: mpsc::Sender<anyhow::Error>,
+    ) -> anyhow::Result<()> {
         if entries.is_empty() {
-            return;
-        }
-        if let Some((_, _, git_repo)) = self.active_repository.as_ref() {
-            let _ = self
-                .update_sender
-                .unbounded_send((git_repo.clone(), entries, action));
+            return Ok(());
         }
+        let Some((_, _, git_repo)) = self.active_repository.as_ref() else {
+            return Err(anyhow!("No active repository"));
+        };
+        self.update_sender
+            .unbounded_send((Message::Stage(git_repo.clone(), entries), err_sender))
+            .map_err(|_| anyhow!("Failed to submit stage operation"))?;
+        Ok(())
     }
 
-    pub fn stage_entries(&self, entries: Vec<RepoPath>) {
-        self.act_on_entries(entries, StatusAction::Stage);
-    }
-
-    pub fn unstage_entries(&self, entries: Vec<RepoPath>) {
-        self.act_on_entries(entries, StatusAction::Unstage);
+    pub fn unstage_entries(
+        &self,
+        entries: Vec<RepoPath>,
+        err_sender: mpsc::Sender<anyhow::Error>,
+    ) -> anyhow::Result<()> {
+        if entries.is_empty() {
+            return Ok(());
+        }
+        let Some((_, _, git_repo)) = self.active_repository.as_ref() else {
+            return Err(anyhow!("No active repository"));
+        };
+        self.update_sender
+            .unbounded_send((Message::Unstage(git_repo.clone(), entries), err_sender))
+            .map_err(|_| anyhow!("Failed to submit unstage operation"))?;
+        Ok(())
     }
 
-    pub fn stage_all(&self) {
+    pub fn stage_all(&self, err_sender: mpsc::Sender<anyhow::Error>) -> anyhow::Result<()> {
         let Some((_, entry, _)) = self.active_repository.as_ref() else {
-            return;
+            return Err(anyhow!("No active repository"));
         };
         let to_stage = entry
             .status()
             .filter(|entry| !entry.status.is_staged().unwrap_or(false))
             .map(|entry| entry.repo_path.clone())
             .collect();
-        self.stage_entries(to_stage);
+        self.stage_entries(to_stage, err_sender)?;
+        Ok(())
     }
 
-    pub fn unstage_all(&self) {
+    pub fn unstage_all(&self, err_sender: mpsc::Sender<anyhow::Error>) -> anyhow::Result<()> {
         let Some((_, entry, _)) = self.active_repository.as_ref() else {
-            return;
+            return Err(anyhow!("No active repository"));
         };
         let to_unstage = entry
             .status()
             .filter(|entry| entry.status.is_staged().unwrap_or(true))
             .map(|entry| entry.repo_path.clone())
             .collect();
-        self.unstage_entries(to_unstage);
+        self.unstage_entries(to_unstage, err_sender)?;
+        Ok(())
     }
 
     /// Get a count of all entries in the active repository, including
@@ -123,4 +145,61 @@ impl GitState {
             .as_ref()
             .map_or(0, |(_, entry, _)| entry.status_len())
     }
+
+    fn have_changes(&self) -> bool {
+        let Some((_, entry, _)) = self.active_repository.as_ref() else {
+            return false;
+        };
+        entry.status_summary() != GitSummary::UNCHANGED
+    }
+
+    fn have_staged_changes(&self) -> bool {
+        let Some((_, entry, _)) = self.active_repository.as_ref() else {
+            return false;
+        };
+        entry.status_summary().index != TrackedSummary::UNCHANGED
+    }
+
+    pub fn can_commit(&self, commit_all: bool) -> bool {
+        return !self.commit_message.trim().is_empty()
+            && self.have_changes()
+            && (commit_all || self.have_staged_changes());
+    }
+
+    pub fn commit(&mut self, err_sender: mpsc::Sender<anyhow::Error>) -> anyhow::Result<()> {
+        if !self.can_commit(false) {
+            return Err(anyhow!("Unable to commit"));
+        }
+        let Some((_, _, git_repo)) = self.active_repository() else {
+            return Err(anyhow!("No active repository"));
+        };
+        let git_repo = git_repo.clone();
+        let message = std::mem::take(&mut self.commit_message);
+        self.update_sender
+            .unbounded_send((Message::Commit(git_repo, message), err_sender))
+            .map_err(|_| anyhow!("Failed to submit commit operation"))?;
+        Ok(())
+    }
+
+    pub fn commit_all(&mut self, err_sender: mpsc::Sender<anyhow::Error>) -> anyhow::Result<()> {
+        if !self.can_commit(true) {
+            return Err(anyhow!("Unable to commit"));
+        }
+        let Some((_, entry, git_repo)) = self.active_repository.as_ref() else {
+            return Err(anyhow!("No active repository"));
+        };
+        let to_stage = entry
+            .status()
+            .filter(|entry| !entry.status.is_staged().unwrap_or(false))
+            .map(|entry| entry.repo_path.clone())
+            .collect::<Vec<_>>();
+        let message = std::mem::take(&mut self.commit_message);
+        self.update_sender
+            .unbounded_send((
+                Message::StageAndCommit(git_repo.clone(), message, to_stage),
+                err_sender,
+            ))
+            .map_err(|_| anyhow!("Failed to submit commit operation"))?;
+        Ok(())
+    }
 }

crates/project_panel/src/project_panel.rs 🔗

@@ -1588,7 +1588,7 @@ impl ProjectPanel {
                         }
                     }))
                     && entry.is_file()
-                    && entry.git_summary.modified > 0
+                    && entry.git_summary.index.modified + entry.git_summary.worktree.modified > 0
             },
             cx,
         );
@@ -1666,7 +1666,7 @@ impl ProjectPanel {
                         }
                     }))
                     && entry.is_file()
-                    && entry.git_summary.modified > 0
+                    && entry.git_summary.index.modified + entry.git_summary.worktree.modified > 0
             },
             cx,
         );

crates/worktree/src/worktree.rs 🔗

@@ -231,6 +231,10 @@ impl RepositoryEntry {
         self.statuses_by_path.summary().item_summary.count
     }
 
+    pub fn status_summary(&self) -> GitSummary {
+        self.statuses_by_path.summary().item_summary
+    }
+
     pub fn status_for_path(&self, path: &RepoPath) -> Option<StatusEntry> {
         self.statuses_by_path
             .get(&PathKey(path.0.clone()), &())

crates/worktree/src/worktree_tests.rs 🔗

@@ -6,7 +6,8 @@ use anyhow::Result;
 use fs::{FakeFs, Fs, RealFs, RemoveOptions};
 use git::{
     status::{
-        FileStatus, GitSummary, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode,
+        FileStatus, GitSummary, StatusCode, TrackedStatus, TrackedSummary, UnmergedStatus,
+        UnmergedStatusCode,
     },
     GITIGNORE,
 };
@@ -745,7 +746,7 @@ async fn test_rescan_with_gitignore(cx: &mut TestAppContext) {
         Path::new("/root/tree/.git"),
         &[(
             Path::new("tracked-dir/tracked-file2"),
-            FileStatus::worktree(StatusCode::Added),
+            StatusCode::Added.index(),
         )],
     );
 
@@ -830,7 +831,7 @@ async fn test_update_gitignore(cx: &mut TestAppContext) {
 
     fs.set_status_for_repo_via_working_copy_change(
         Path::new("/root/.git"),
-        &[(Path::new("b.txt"), FileStatus::worktree(StatusCode::Added))],
+        &[(Path::new("b.txt"), StatusCode::Added.index())],
     );
 
     cx.executor().run_until_parked();
@@ -1500,10 +1501,7 @@ async fn test_bump_mtime_of_git_repo_workdir(cx: &mut TestAppContext) {
     // detected.
     fs.set_status_for_repo_via_git_operation(
         Path::new("/root/.git"),
-        &[(
-            Path::new("b/c.txt"),
-            FileStatus::worktree(StatusCode::Modified),
-        )],
+        &[(Path::new("b/c.txt"), StatusCode::Modified.index())],
     );
     cx.executor().run_until_parked();
 
@@ -2199,7 +2197,7 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) {
         assert_eq!(repo.path.as_ref(), Path::new("projects/project1"));
         assert_eq!(
             tree.status_for_file(Path::new("projects/project1/a")),
-            Some(FileStatus::worktree(StatusCode::Modified)),
+            Some(StatusCode::Modified.worktree()),
         );
         assert_eq!(
             tree.status_for_file(Path::new("projects/project1/b")),
@@ -2220,7 +2218,7 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) {
         assert_eq!(repo.path.as_ref(), Path::new("projects/project2"));
         assert_eq!(
             tree.status_for_file(Path::new("projects/project2/a")),
-            Some(FileStatus::worktree(StatusCode::Modified)),
+            Some(StatusCode::Modified.worktree()),
         );
         assert_eq!(
             tree.status_for_file(Path::new("projects/project2/b")),
@@ -2421,7 +2419,7 @@ async fn test_file_status(cx: &mut TestAppContext) {
         let snapshot = tree.snapshot();
         assert_eq!(
             snapshot.status_for_file(project_path.join(A_TXT)),
-            Some(FileStatus::worktree(StatusCode::Modified)),
+            Some(StatusCode::Modified.worktree()),
         );
     });
 
@@ -2463,7 +2461,7 @@ async fn test_file_status(cx: &mut TestAppContext) {
         );
         assert_eq!(
             snapshot.status_for_file(project_path.join(E_TXT)),
-            Some(FileStatus::worktree(StatusCode::Modified)),
+            Some(StatusCode::Modified.worktree()),
         );
     });
 
@@ -2575,14 +2573,11 @@ async fn test_git_repository_status(cx: &mut TestAppContext) {
 
         assert_eq!(entries.len(), 3);
         assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt"));
-        assert_eq!(
-            entries[0].status,
-            FileStatus::worktree(StatusCode::Modified)
-        );
+        assert_eq!(entries[0].status, StatusCode::Modified.worktree());
         assert_eq!(entries[1].repo_path.as_ref(), Path::new("b.txt"));
         assert_eq!(entries[1].status, FileStatus::Untracked);
         assert_eq!(entries[2].repo_path.as_ref(), Path::new("d.txt"));
-        assert_eq!(entries[2].status, FileStatus::worktree(StatusCode::Deleted));
+        assert_eq!(entries[2].status, StatusCode::Deleted.worktree());
     });
 
     std::fs::write(work_dir.join("c.txt"), "some changes").unwrap();
@@ -2600,20 +2595,14 @@ async fn test_git_repository_status(cx: &mut TestAppContext) {
 
         std::assert_eq!(entries.len(), 4, "entries: {entries:?}");
         assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt"));
-        assert_eq!(
-            entries[0].status,
-            FileStatus::worktree(StatusCode::Modified)
-        );
+        assert_eq!(entries[0].status, StatusCode::Modified.worktree());
         assert_eq!(entries[1].repo_path.as_ref(), Path::new("b.txt"));
         assert_eq!(entries[1].status, FileStatus::Untracked);
         // Status updated
         assert_eq!(entries[2].repo_path.as_ref(), Path::new("c.txt"));
-        assert_eq!(
-            entries[2].status,
-            FileStatus::worktree(StatusCode::Modified)
-        );
+        assert_eq!(entries[2].status, StatusCode::Modified.worktree());
         assert_eq!(entries[3].repo_path.as_ref(), Path::new("d.txt"));
-        assert_eq!(entries[3].status, FileStatus::worktree(StatusCode::Deleted));
+        assert_eq!(entries[3].status, StatusCode::Deleted.worktree());
     });
 
     git_add("a.txt", &repo);
@@ -2646,7 +2635,7 @@ async fn test_git_repository_status(cx: &mut TestAppContext) {
             &entries
         );
         assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt"));
-        assert_eq!(entries[0].status, FileStatus::worktree(StatusCode::Deleted));
+        assert_eq!(entries[0].status, StatusCode::Deleted.worktree());
     });
 }
 
@@ -2769,11 +2758,8 @@ async fn test_traverse_with_git_status(cx: &mut TestAppContext) {
     fs.set_status_for_repo_via_git_operation(
         Path::new("/root/x/.git"),
         &[
-            (
-                Path::new("x2.txt"),
-                FileStatus::worktree(StatusCode::Modified),
-            ),
-            (Path::new("z.txt"), FileStatus::worktree(StatusCode::Added)),
+            (Path::new("x2.txt"), StatusCode::Modified.index()),
+            (Path::new("z.txt"), StatusCode::Added.index()),
         ],
     );
     fs.set_status_for_repo_via_git_operation(
@@ -2782,7 +2768,7 @@ async fn test_traverse_with_git_status(cx: &mut TestAppContext) {
     );
     fs.set_status_for_repo_via_git_operation(
         Path::new("/root/z/.git"),
-        &[(Path::new("z2.txt"), FileStatus::worktree(StatusCode::Added))],
+        &[(Path::new("z2.txt"), StatusCode::Added.index())],
     );
 
     let tree = Worktree::local(
@@ -2862,14 +2848,8 @@ async fn test_propagate_git_statuses(cx: &mut TestAppContext) {
     fs.set_status_for_repo_via_git_operation(
         Path::new("/root/.git"),
         &[
-            (
-                Path::new("a/b/c1.txt"),
-                FileStatus::worktree(StatusCode::Added),
-            ),
-            (
-                Path::new("a/d/e2.txt"),
-                FileStatus::worktree(StatusCode::Modified),
-            ),
+            (Path::new("a/b/c1.txt"), StatusCode::Added.index()),
+            (Path::new("a/d/e2.txt"), StatusCode::Modified.index()),
             (Path::new("g/h2.txt"), CONFLICT),
         ],
     );
@@ -2971,24 +2951,18 @@ async fn test_propagate_statuses_for_repos_under_project(cx: &mut TestAppContext
 
     fs.set_status_for_repo_via_git_operation(
         Path::new("/root/x/.git"),
-        &[(Path::new("x1.txt"), FileStatus::worktree(StatusCode::Added))],
+        &[(Path::new("x1.txt"), StatusCode::Added.index())],
     );
     fs.set_status_for_repo_via_git_operation(
         Path::new("/root/y/.git"),
         &[
             (Path::new("y1.txt"), CONFLICT),
-            (
-                Path::new("y2.txt"),
-                FileStatus::worktree(StatusCode::Modified),
-            ),
+            (Path::new("y2.txt"), StatusCode::Modified.index()),
         ],
     );
     fs.set_status_for_repo_via_git_operation(
         Path::new("/root/z/.git"),
-        &[(
-            Path::new("z2.txt"),
-            FileStatus::worktree(StatusCode::Modified),
-        )],
+        &[(Path::new("z2.txt"), StatusCode::Modified.index())],
     );
 
     let tree = Worktree::local(
@@ -3081,11 +3055,8 @@ async fn test_propagate_statuses_for_nested_repos(cx: &mut TestAppContext) {
     fs.set_status_for_repo_via_git_operation(
         Path::new("/root/x/.git"),
         &[
-            (
-                Path::new("x2.txt"),
-                FileStatus::worktree(StatusCode::Modified),
-            ),
-            (Path::new("z.txt"), FileStatus::worktree(StatusCode::Added)),
+            (Path::new("x2.txt"), StatusCode::Modified.index()),
+            (Path::new("z.txt"), StatusCode::Added.index()),
         ],
     );
     fs.set_status_for_repo_via_git_operation(
@@ -3095,7 +3066,7 @@ async fn test_propagate_statuses_for_nested_repos(cx: &mut TestAppContext) {
 
     fs.set_status_for_repo_via_git_operation(
         Path::new("/root/z/.git"),
-        &[(Path::new("z2.txt"), FileStatus::worktree(StatusCode::Added))],
+        &[(Path::new("z2.txt"), StatusCode::Added.index())],
     );
 
     let tree = Worktree::local(
@@ -3227,12 +3198,12 @@ fn check_git_statuses(snapshot: &Snapshot, expected_statuses: &[(&Path, GitSumma
 }
 
 const ADDED: GitSummary = GitSummary {
-    added: 1,
+    index: TrackedSummary::ADDED,
     count: 1,
     ..GitSummary::UNCHANGED
 };
 const MODIFIED: GitSummary = GitSummary {
-    modified: 1,
+    index: TrackedSummary::MODIFIED,
     count: 1,
     ..GitSummary::UNCHANGED
 };
@@ -3378,15 +3349,15 @@ fn init_test(cx: &mut gpui::TestAppContext) {
 fn assert_entry_git_state(
     tree: &Worktree,
     path: &str,
-    worktree_status: Option<StatusCode>,
+    index_status: Option<StatusCode>,
     is_ignored: bool,
 ) {
     let entry = tree.entry_for_path(path).expect("entry {path} not found");
     let status = tree.status_for_file(Path::new(path));
-    let expected = worktree_status.map(|worktree_status| {
+    let expected = index_status.map(|index_status| {
         TrackedStatus {
-            worktree_status,
-            index_status: StatusCode::Unmodified,
+            index_status,
+            worktree_status: StatusCode::Unmodified,
         }
         .into()
     });