mirror of
https://github.com/zed-industries/zed.git
synced 2025-01-24 02:46:43 +00:00
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:
parent
3767e7e5f0
commit
5da67899b7
10 changed files with 387 additions and 198 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -5192,6 +5192,7 @@ dependencies = [
|
|||
"collections",
|
||||
"db",
|
||||
"editor",
|
||||
"futures 0.3.31",
|
||||
"git",
|
||||
"gpui",
|
||||
"language",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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<()> {
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
|
|
@ -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;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some((_, _, git_repo)) = self.active_repository.as_ref() {
|
||||
let _ = self
|
||||
.update_sender
|
||||
.unbounded_send((git_repo.clone(), entries, action));
|
||||
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 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>) {
|
||||
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) {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
|
|
|
@ -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()), &())
|
||||
|
|
|
@ -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()
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue