git: Implement commit creation (#23263)

- [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>
This commit is contained in:
Cole Miller 2025-01-17 13:51:20 -05:00 committed by GitHub
parent 3767e7e5f0
commit 5da67899b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 387 additions and 198 deletions

1
Cargo.lock generated
View file

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

View file

@ -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 { 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 { if ignored {
Color::Ignored Color::Ignored
} else if git_status.conflict > 0 { } else if git_status.conflict > 0 {
Color::Conflict Color::Conflict
} else if git_status.modified > 0 { } else if tracked.modified > 0 {
Color::Modified Color::Modified
} else if git_status.added > 0 || git_status.untracked > 0 { } else if tracked.added > 0 || git_status.untracked > 0 {
Color::Created Color::Created
} else { } else {
entry_label_color(selected) entry_label_color(selected)

View file

@ -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. /// 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 unstage_paths(&self, paths: &[RepoPath]) -> Result<()>;
fn commit(&self, message: &str) -> Result<()>;
} }
impl std::fmt::Debug for dyn GitRepository { impl std::fmt::Debug for dyn GitRepository {
@ -280,6 +282,24 @@ impl GitRepository for RealGitRepository {
} }
Ok(()) 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)] #[derive(Debug, Clone)]
@ -423,6 +443,10 @@ impl GitRepository for FakeGitRepository {
fn unstage_paths(&self, _paths: &[RepoPath]) -> Result<()> { fn unstage_paths(&self, _paths: &[RepoPath]) -> Result<()> {
unimplemented!() unimplemented!()
} }
fn commit(&self, _message: &str) -> Result<()> {
unimplemented!()
}
} }
fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> { fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {

View file

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

View file

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

View file

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

View file

@ -1,52 +1,65 @@
use std::sync::Arc; use std::sync::Arc;
use anyhow::anyhow;
use futures::channel::mpsc; use futures::channel::mpsc;
use futures::StreamExt as _; use futures::{SinkExt as _, StreamExt as _};
use git::repository::{GitRepository, RepoPath}; use git::{
repository::{GitRepository, RepoPath},
status::{GitSummary, TrackedSummary},
};
use gpui::{AppContext, SharedString}; use gpui::{AppContext, SharedString};
use settings::WorktreeId; use settings::WorktreeId;
use util::ResultExt as _;
use worktree::RepositoryEntry; use worktree::RepositoryEntry;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StatusAction {
Stage,
Unstage,
}
pub struct GitState { pub struct GitState {
/// The current commit message being composed. /// 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 /// When a git repository is selected, this is used to track which repository's changes
/// are currently being viewed or modified in the UI. /// are currently being viewed or modified in the UI.
pub active_repository: Option<(WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)>, 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 { impl GitState {
pub fn new(cx: &AppContext) -> Self { pub fn new(cx: &AppContext) -> Self {
let (tx, mut rx) = let (update_sender, mut update_receiver) =
mpsc::unbounded::<(Arc<dyn GitRepository>, Vec<RepoPath>, StatusAction)>(); mpsc::unbounded::<(Message, mpsc::Sender<anyhow::Error>)>();
cx.spawn(|cx| async move { cx.spawn(|cx| async move {
while let Some((git_repo, paths, action)) = rx.next().await { while let Some((msg, mut err_sender)) = update_receiver.next().await {
cx.background_executor() let result = cx
.background_executor()
.spawn(async move { .spawn(async move {
match action { match msg {
StatusAction::Stage => git_repo.stage_paths(&paths), Message::StageAndCommit(repo, message, paths) => {
StatusAction::Unstage => git_repo.unstage_paths(&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 .await;
.log_err(); if let Err(e) = result {
err_sender.send(e).await.ok();
}
} }
}) })
.detach(); .detach();
GitState { GitState {
commit_message: None, commit_message: SharedString::default(),
active_repository: None, active_repository: None,
update_sender: tx, update_sender,
} }
} }
@ -65,55 +78,64 @@ impl GitState {
self.active_repository.as_ref() self.active_repository.as_ref()
} }
pub fn commit_message(&mut self, message: Option<SharedString>) { pub fn stage_entries(
self.commit_message = message; &self,
} entries: Vec<RepoPath>,
err_sender: mpsc::Sender<anyhow::Error>,
pub fn clear_commit_message(&mut self) { ) -> anyhow::Result<()> {
self.commit_message = None;
}
fn act_on_entries(&self, entries: Vec<RepoPath>, action: StatusAction) {
if entries.is_empty() { if entries.is_empty() {
return; return Ok(());
} }
if let Some((_, _, git_repo)) = self.active_repository.as_ref() { let Some((_, _, git_repo)) = self.active_repository.as_ref() else {
let _ = self return Err(anyhow!("No active repository"));
.update_sender };
.unbounded_send((git_repo.clone(), entries, action)); self.update_sender
.unbounded_send((Message::Stage(git_repo.clone(), entries), err_sender))
.map_err(|_| anyhow!("Failed to submit stage operation"))?;
Ok(())
}
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_entries(&self, entries: Vec<RepoPath>) { pub fn stage_all(&self, err_sender: mpsc::Sender<anyhow::Error>) -> anyhow::Result<()> {
self.act_on_entries(entries, StatusAction::Stage);
}
pub fn unstage_entries(&self, entries: Vec<RepoPath>) {
self.act_on_entries(entries, StatusAction::Unstage);
}
pub fn stage_all(&self) {
let Some((_, entry, _)) = self.active_repository.as_ref() else { let Some((_, entry, _)) = self.active_repository.as_ref() else {
return; return Err(anyhow!("No active repository"));
}; };
let to_stage = entry let to_stage = entry
.status() .status()
.filter(|entry| !entry.status.is_staged().unwrap_or(false)) .filter(|entry| !entry.status.is_staged().unwrap_or(false))
.map(|entry| entry.repo_path.clone()) .map(|entry| entry.repo_path.clone())
.collect(); .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 { let Some((_, entry, _)) = self.active_repository.as_ref() else {
return; return Err(anyhow!("No active repository"));
}; };
let to_unstage = entry let to_unstage = entry
.status() .status()
.filter(|entry| entry.status.is_staged().unwrap_or(true)) .filter(|entry| entry.status.is_staged().unwrap_or(true))
.map(|entry| entry.repo_path.clone()) .map(|entry| entry.repo_path.clone())
.collect(); .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 /// Get a count of all entries in the active repository, including
@ -123,4 +145,61 @@ impl GitState {
.as_ref() .as_ref()
.map_or(0, |(_, entry, _)| entry.status_len()) .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(())
}
} }

View file

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

View file

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

View file

@ -6,7 +6,8 @@ use anyhow::Result;
use fs::{FakeFs, Fs, RealFs, RemoveOptions}; use fs::{FakeFs, Fs, RealFs, RemoveOptions};
use git::{ use git::{
status::{ status::{
FileStatus, GitSummary, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode, FileStatus, GitSummary, StatusCode, TrackedStatus, TrackedSummary, UnmergedStatus,
UnmergedStatusCode,
}, },
GITIGNORE, GITIGNORE,
}; };
@ -745,7 +746,7 @@ async fn test_rescan_with_gitignore(cx: &mut TestAppContext) {
Path::new("/root/tree/.git"), Path::new("/root/tree/.git"),
&[( &[(
Path::new("tracked-dir/tracked-file2"), 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( fs.set_status_for_repo_via_working_copy_change(
Path::new("/root/.git"), Path::new("/root/.git"),
&[(Path::new("b.txt"), FileStatus::worktree(StatusCode::Added))], &[(Path::new("b.txt"), StatusCode::Added.index())],
); );
cx.executor().run_until_parked(); cx.executor().run_until_parked();
@ -1500,10 +1501,7 @@ async fn test_bump_mtime_of_git_repo_workdir(cx: &mut TestAppContext) {
// detected. // detected.
fs.set_status_for_repo_via_git_operation( fs.set_status_for_repo_via_git_operation(
Path::new("/root/.git"), Path::new("/root/.git"),
&[( &[(Path::new("b/c.txt"), StatusCode::Modified.index())],
Path::new("b/c.txt"),
FileStatus::worktree(StatusCode::Modified),
)],
); );
cx.executor().run_until_parked(); 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!(repo.path.as_ref(), Path::new("projects/project1"));
assert_eq!( assert_eq!(
tree.status_for_file(Path::new("projects/project1/a")), tree.status_for_file(Path::new("projects/project1/a")),
Some(FileStatus::worktree(StatusCode::Modified)), Some(StatusCode::Modified.worktree()),
); );
assert_eq!( assert_eq!(
tree.status_for_file(Path::new("projects/project1/b")), 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!(repo.path.as_ref(), Path::new("projects/project2"));
assert_eq!( assert_eq!(
tree.status_for_file(Path::new("projects/project2/a")), tree.status_for_file(Path::new("projects/project2/a")),
Some(FileStatus::worktree(StatusCode::Modified)), Some(StatusCode::Modified.worktree()),
); );
assert_eq!( assert_eq!(
tree.status_for_file(Path::new("projects/project2/b")), 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(); let snapshot = tree.snapshot();
assert_eq!( assert_eq!(
snapshot.status_for_file(project_path.join(A_TXT)), 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!( assert_eq!(
snapshot.status_for_file(project_path.join(E_TXT)), 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.len(), 3);
assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt")); assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt"));
assert_eq!( assert_eq!(entries[0].status, StatusCode::Modified.worktree());
entries[0].status,
FileStatus::worktree(StatusCode::Modified)
);
assert_eq!(entries[1].repo_path.as_ref(), Path::new("b.txt")); assert_eq!(entries[1].repo_path.as_ref(), Path::new("b.txt"));
assert_eq!(entries[1].status, FileStatus::Untracked); assert_eq!(entries[1].status, FileStatus::Untracked);
assert_eq!(entries[2].repo_path.as_ref(), Path::new("d.txt")); 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(); 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:?}"); std::assert_eq!(entries.len(), 4, "entries: {entries:?}");
assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt")); assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt"));
assert_eq!( assert_eq!(entries[0].status, StatusCode::Modified.worktree());
entries[0].status,
FileStatus::worktree(StatusCode::Modified)
);
assert_eq!(entries[1].repo_path.as_ref(), Path::new("b.txt")); assert_eq!(entries[1].repo_path.as_ref(), Path::new("b.txt"));
assert_eq!(entries[1].status, FileStatus::Untracked); assert_eq!(entries[1].status, FileStatus::Untracked);
// Status updated // Status updated
assert_eq!(entries[2].repo_path.as_ref(), Path::new("c.txt")); assert_eq!(entries[2].repo_path.as_ref(), Path::new("c.txt"));
assert_eq!( assert_eq!(entries[2].status, StatusCode::Modified.worktree());
entries[2].status,
FileStatus::worktree(StatusCode::Modified)
);
assert_eq!(entries[3].repo_path.as_ref(), Path::new("d.txt")); 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); git_add("a.txt", &repo);
@ -2646,7 +2635,7 @@ async fn test_git_repository_status(cx: &mut TestAppContext) {
&entries &entries
); );
assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt")); 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( fs.set_status_for_repo_via_git_operation(
Path::new("/root/x/.git"), Path::new("/root/x/.git"),
&[ &[
( (Path::new("x2.txt"), StatusCode::Modified.index()),
Path::new("x2.txt"), (Path::new("z.txt"), StatusCode::Added.index()),
FileStatus::worktree(StatusCode::Modified),
),
(Path::new("z.txt"), FileStatus::worktree(StatusCode::Added)),
], ],
); );
fs.set_status_for_repo_via_git_operation( 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( fs.set_status_for_repo_via_git_operation(
Path::new("/root/z/.git"), Path::new("/root/z/.git"),
&[(Path::new("z2.txt"), FileStatus::worktree(StatusCode::Added))], &[(Path::new("z2.txt"), StatusCode::Added.index())],
); );
let tree = Worktree::local( 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( fs.set_status_for_repo_via_git_operation(
Path::new("/root/.git"), Path::new("/root/.git"),
&[ &[
( (Path::new("a/b/c1.txt"), StatusCode::Added.index()),
Path::new("a/b/c1.txt"), (Path::new("a/d/e2.txt"), StatusCode::Modified.index()),
FileStatus::worktree(StatusCode::Added),
),
(
Path::new("a/d/e2.txt"),
FileStatus::worktree(StatusCode::Modified),
),
(Path::new("g/h2.txt"), CONFLICT), (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( fs.set_status_for_repo_via_git_operation(
Path::new("/root/x/.git"), 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( fs.set_status_for_repo_via_git_operation(
Path::new("/root/y/.git"), Path::new("/root/y/.git"),
&[ &[
(Path::new("y1.txt"), CONFLICT), (Path::new("y1.txt"), CONFLICT),
( (Path::new("y2.txt"), StatusCode::Modified.index()),
Path::new("y2.txt"),
FileStatus::worktree(StatusCode::Modified),
),
], ],
); );
fs.set_status_for_repo_via_git_operation( fs.set_status_for_repo_via_git_operation(
Path::new("/root/z/.git"), Path::new("/root/z/.git"),
&[( &[(Path::new("z2.txt"), StatusCode::Modified.index())],
Path::new("z2.txt"),
FileStatus::worktree(StatusCode::Modified),
)],
); );
let tree = Worktree::local( 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( fs.set_status_for_repo_via_git_operation(
Path::new("/root/x/.git"), Path::new("/root/x/.git"),
&[ &[
( (Path::new("x2.txt"), StatusCode::Modified.index()),
Path::new("x2.txt"), (Path::new("z.txt"), StatusCode::Added.index()),
FileStatus::worktree(StatusCode::Modified),
),
(Path::new("z.txt"), FileStatus::worktree(StatusCode::Added)),
], ],
); );
fs.set_status_for_repo_via_git_operation( 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( fs.set_status_for_repo_via_git_operation(
Path::new("/root/z/.git"), Path::new("/root/z/.git"),
&[(Path::new("z2.txt"), FileStatus::worktree(StatusCode::Added))], &[(Path::new("z2.txt"), StatusCode::Added.index())],
); );
let tree = Worktree::local( let tree = Worktree::local(
@ -3227,12 +3198,12 @@ fn check_git_statuses(snapshot: &Snapshot, expected_statuses: &[(&Path, GitSumma
} }
const ADDED: GitSummary = GitSummary { const ADDED: GitSummary = GitSummary {
added: 1, index: TrackedSummary::ADDED,
count: 1, count: 1,
..GitSummary::UNCHANGED ..GitSummary::UNCHANGED
}; };
const MODIFIED: GitSummary = GitSummary { const MODIFIED: GitSummary = GitSummary {
modified: 1, index: TrackedSummary::MODIFIED,
count: 1, count: 1,
..GitSummary::UNCHANGED ..GitSummary::UNCHANGED
}; };
@ -3378,15 +3349,15 @@ fn init_test(cx: &mut gpui::TestAppContext) {
fn assert_entry_git_state( fn assert_entry_git_state(
tree: &Worktree, tree: &Worktree,
path: &str, path: &str,
worktree_status: Option<StatusCode>, index_status: Option<StatusCode>,
is_ignored: bool, is_ignored: bool,
) { ) {
let entry = tree.entry_for_path(path).expect("entry {path} not found"); let entry = tree.entry_for_path(path).expect("entry {path} not found");
let status = tree.status_for_file(Path::new(path)); let status = tree.status_for_file(Path::new(path));
let expected = worktree_status.map(|worktree_status| { let expected = index_status.map(|index_status| {
TrackedStatus { TrackedStatus {
worktree_status, index_status,
index_status: StatusCode::Unmodified, worktree_status: StatusCode::Unmodified,
} }
.into() .into()
}); });