diff --git a/Cargo.lock b/Cargo.lock index 8da23e96fe65075e06e487e9d759720efefe76ce..7b9fed4f37606949e7d48b0df6e7af5e9de3fecb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5254,7 +5254,6 @@ dependencies = [ "picker", "postage", "project", - "rpc", "schemars", "serde", "serde_derive", @@ -7008,6 +7007,7 @@ dependencies = [ "tree-sitter-cpp", "tree-sitter-css", "tree-sitter-diff", + "tree-sitter-gitcommit", "tree-sitter-go", "tree-sitter-gomod", "tree-sitter-gowork", @@ -13944,6 +13944,15 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-gitcommit" +version = "0.0.1" +source = "git+https://github.com/zed-industries/tree-sitter-git-commit?rev=88309716a69dd13ab83443721ba6e0b491d37ee9#88309716a69dd13ab83443721ba6e0b491d37ee9" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-go" version = "0.23.4" diff --git a/Cargo.toml b/Cargo.toml index d70d7b2faf1098ead8cf7e0f1d0ab53dba85e0a4..1b47335b4c47097232c846d94e34f56062abc183 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -522,6 +522,7 @@ tree-sitter-cpp = "0.23" tree-sitter-css = "0.23" tree-sitter-elixir = "0.3" tree-sitter-embedded-template = "0.23.0" +tree-sitter-gitcommit = {git = "https://github.com/zed-industries/tree-sitter-git-commit", rev = "88309716a69dd13ab83443721ba6e0b491d37ee9"} tree-sitter-go = "0.23" tree-sitter-go-mod = { git = "https://github.com/camdencheek/tree-sitter-go-mod", rev = "6efb59652d30e0e9cd5f3b3a669afd6f1a926d3c", package = "tree-sitter-gomod" } tree-sitter-gowork = { git = "https://github.com/zed-industries/tree-sitter-go-work", rev = "acb0617bf7f4fda02c6217676cc64acb89536dc7" } diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index efedb0d461d70fd028af3bad3c0a58d939acefed..50191ea6836dc33a7c3f7d084e2871e1b0877255 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1,6 +1,6 @@ use crate::status::FileStatus; +use crate::GitHostingProviderRegistry; use crate::{blame::Blame, status::GitStatus}; -use crate::{GitHostingProviderRegistry, COMMIT_MESSAGE}; use anyhow::{anyhow, Context as _, Result}; use collections::{HashMap, HashSet}; use git2::BranchType; @@ -68,7 +68,7 @@ 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, name_and_email: Option<(&str, &str)>) -> Result<()>; + fn commit(&self, message: &str, name_and_email: Option<(&str, &str)>) -> Result<()>; } impl std::fmt::Debug for dyn GitRepository { @@ -298,22 +298,14 @@ impl GitRepository for RealGitRepository { Ok(()) } - fn commit(&self, name_and_email: Option<(&str, &str)>) -> Result<()> { + fn commit(&self, message: &str, name_and_email: Option<(&str, &str)>) -> Result<()> { let working_directory = self .repository .lock() .workdir() .context("failed to read git work directory")? .to_path_buf(); - let commit_file = self.dot_git_dir().join(*COMMIT_MESSAGE); - let commit_file_path = commit_file.to_string_lossy(); - let mut args = vec![ - "commit", - "--quiet", - "-F", - commit_file_path.as_ref(), - "--cleanup=strip", - ]; + let mut args = vec!["commit", "--quiet", "-m", message, "--cleanup=strip"]; let author = name_and_email.map(|(name, email)| format!("{name} <{email}>")); if let Some(author) = author.as_deref() { args.push("--author"); @@ -480,7 +472,7 @@ impl GitRepository for FakeGitRepository { unimplemented!() } - fn commit(&self, _name_and_email: Option<(&str, &str)>) -> Result<()> { + fn commit(&self, _message: &str, _name_and_email: Option<(&str, &str)>) -> Result<()> { unimplemented!() } } diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 055410760485f224fc0842ffcb35e5db695329f2..701f9a01d7014ee159d774cefcc33d6e15d76167 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -26,7 +26,6 @@ multi_buffer.workspace = true menu.workspace = true postage.workspace = true project.workspace = true -rpc.workspace = true schemars.workspace = true serde.workspace = true serde_derive.workspace = true diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 1e7ce96cef8defeee09d23ce7c75a42784df94bd..9cf054467d86561ad0d2a38bf7f0c3b043a963ba 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -4,7 +4,7 @@ use crate::ProjectDiff; use crate::{ git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector, }; -use anyhow::{Context as _, Result}; +use anyhow::Result; use collections::HashMap; use db::kvp::KEY_VALUE_STORE; use editor::actions::MoveToEnd; @@ -12,13 +12,12 @@ use editor::scroll::ScrollbarAutoHide; use editor::{Editor, EditorMode, EditorSettings, MultiBuffer, ShowScrollbar}; use git::repository::RepoPath; use git::status::FileStatus; -use git::{CommitAllChanges, CommitChanges, ToggleStaged, COMMIT_MESSAGE}; +use git::{CommitAllChanges, CommitChanges, ToggleStaged}; use gpui::*; -use language::{Buffer, BufferId}; +use language::Buffer; use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev}; -use project::git::{GitEvent, GitRepo, RepositoryHandle}; -use project::{CreateOptions, Fs, Project, ProjectPath}; -use rpc::proto; +use project::git::{GitEvent, Repository}; +use project::{Fs, Project, ProjectPath}; use serde::{Deserialize, Serialize}; use settings::Settings as _; use std::{collections::HashSet, path::PathBuf, sync::Arc, time::Duration, usize}; @@ -32,7 +31,7 @@ use workspace::notifications::{DetachAndPromptErr, NotificationId}; use workspace::Toast; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, - Item, Workspace, + Workspace, }; actions!( @@ -144,7 +143,7 @@ pub struct GitPanel { pending_serialization: Task>, workspace: WeakEntity, project: Entity, - active_repository: Option, + active_repository: Option>, scroll_handle: UniformListScrollHandle, scrollbar_state: ScrollbarState, selected_entry: Option, @@ -162,63 +161,6 @@ pub struct GitPanel { can_commit_all: bool, } -fn commit_message_buffer( - project: &Entity, - active_repository: &RepositoryHandle, - cx: &mut App, -) -> Task>> { - match &active_repository.git_repo { - GitRepo::Local(repo) => { - let commit_message_file = repo.dot_git_dir().join(*COMMIT_MESSAGE); - let fs = project.read(cx).fs().clone(); - let project = project.downgrade(); - cx.spawn(|mut cx| async move { - fs.create_file( - &commit_message_file, - CreateOptions { - overwrite: false, - ignore_if_exists: true, - }, - ) - .await - .with_context(|| format!("creating commit message file {commit_message_file:?}"))?; - let buffer = project - .update(&mut cx, |project, cx| { - project.open_local_buffer(&commit_message_file, cx) - })? - .await - .with_context(|| { - format!("opening commit message buffer at {commit_message_file:?}",) - })?; - Ok(buffer) - }) - } - GitRepo::Remote { - project_id, - client, - worktree_id, - work_directory_id, - } => { - let request = client.request(proto::OpenCommitMessageBuffer { - project_id: project_id.0, - worktree_id: worktree_id.to_proto(), - work_directory_id: work_directory_id.to_proto(), - }); - let project = project.downgrade(); - cx.spawn(|mut cx| async move { - let response = request.await.context("requesting to open commit buffer")?; - let buffer_id = BufferId::new(response.buffer_id)?; - let buffer = project - .update(&mut cx, { - |project, cx| project.wait_for_remote_buffer(buffer_id, cx) - })? - .await?; - Ok(buffer) - }) - } - } -} - fn commit_message_editor( commit_message_buffer: Option>, window: &mut Window, @@ -360,7 +302,7 @@ impl GitPanel { let Some(git_repo) = self.active_repository.as_ref() else { return; }; - let Some(repo_path) = git_repo.project_path_to_repo_path(&path) else { + let Some(repo_path) = git_repo.read(cx).project_path_to_repo_path(&path) else { return; }; let Some(ix) = self.entries_by_path.get(&repo_path) else { @@ -578,7 +520,7 @@ impl GitPanel { .active_repository .as_ref() .map_or(false, |active_repository| { - active_repository.entry_count() > 0 + active_repository.read(cx).entry_count() > 0 }); if have_entries && self.selected_entry.is_none() { self.selected_entry = Some(0); @@ -655,11 +597,17 @@ impl GitPanel { let repo_paths = repo_paths.clone(); let active_repository = active_repository.clone(); |this, mut cx| async move { - let result = if stage { - active_repository.stage_entries(repo_paths.clone()).await - } else { - active_repository.unstage_entries(repo_paths.clone()).await - }; + let result = cx + .update(|cx| { + if stage { + active_repository.read(cx).stage_entries(repo_paths.clone()) + } else { + active_repository + .read(cx) + .unstage_entries(repo_paths.clone()) + } + })? + .await?; this.update(&mut cx, |this, cx| { for pending in this.pending.iter_mut() { @@ -697,7 +645,9 @@ impl GitPanel { let Some(active_repository) = self.active_repository.as_ref() else { return; }; - let Some(path) = active_repository.repo_path_to_project_path(&status_entry.repo_path) + let Some(path) = active_repository + .read(cx) + .repo_path_to_project_path(&status_entry.repo_path) else { return; }; @@ -725,18 +675,18 @@ impl GitPanel { if !self.can_commit { return; } - if self.commit_editor.read(cx).is_empty(cx) { + let message = self.commit_editor.read(cx).text(cx); + if message.trim().is_empty() { return; } self.commit_pending = true; - let save_task = self.commit_editor.update(cx, |editor, cx| { - editor.save(false, self.project.clone(), window, cx) - }); let commit_editor = self.commit_editor.clone(); self.commit_task = cx.spawn_in(window, |git_panel, mut cx| async move { + let commit = active_repository.update(&mut cx, |active_repository, _| { + active_repository.commit(SharedString::from(message), name_and_email) + })?; let result = maybe!(async { - save_task.await?; - active_repository.commit(name_and_email).await?; + commit.await??; cx.update(|window, cx| { commit_editor.update(cx, |editor, cx| editor.clear(window, cx)); }) @@ -768,14 +718,12 @@ impl GitPanel { if !self.can_commit_all { return; } - if self.commit_editor.read(cx).is_empty(cx) { + + let message = self.commit_editor.read(cx).text(cx); + if message.trim().is_empty() { return; } self.commit_pending = true; - let save_task = self.commit_editor.update(cx, |editor, cx| { - editor.save(false, self.project.clone(), window, cx) - }); - let commit_editor = self.commit_editor.clone(); let tracked_files = self .entries @@ -790,9 +738,15 @@ impl GitPanel { self.commit_task = cx.spawn_in(window, |git_panel, mut cx| async move { let result = maybe!(async { - save_task.await?; - active_repository.stage_entries(tracked_files).await?; - active_repository.commit(name_and_email).await + cx.update(|_, cx| active_repository.read(cx).stage_entries(tracked_files))? + .await??; + cx.update(|_, cx| { + active_repository + .read(cx) + .commit(SharedString::from(message), name_and_email) + })? + .await??; + Ok(()) }) .await; cx.update(|window, cx| match result { @@ -886,47 +840,56 @@ impl GitPanel { window: &mut Window, cx: &mut Context, ) { - let project = self.project.clone(); let handle = cx.entity().downgrade(); + self.reopen_commit_buffer(window, cx); self.update_visible_entries_task = cx.spawn_in(window, |_, mut cx| async move { cx.background_executor().timer(UPDATE_DEBOUNCE).await; if let Some(git_panel) = handle.upgrade() { - let Ok(commit_message_buffer) = git_panel.update_in(&mut cx, |git_panel, _, cx| { - git_panel - .active_repository - .as_ref() - .map(|active_repository| { - commit_message_buffer(&project, active_repository, cx) - }) - }) else { - return; - }; - let commit_message_buffer = match commit_message_buffer { - Some(commit_message_buffer) => match commit_message_buffer - .await - .context("opening commit buffer on repo update") - .log_err() - { - Some(buffer) => Some(buffer), - None => return, - }, - None => None, - }; - git_panel - .update_in(&mut cx, |git_panel, window, cx| { - git_panel.update_visible_entries(cx); + .update_in(&mut cx, |git_panel, _, cx| { if clear_pending { git_panel.clear_pending(); } - git_panel.commit_editor = - cx.new(|cx| commit_message_editor(commit_message_buffer, window, cx)); + git_panel.update_visible_entries(cx); }) .ok(); } }); } + fn reopen_commit_buffer(&mut self, window: &mut Window, cx: &mut Context) { + let Some(active_repo) = self.active_repository.as_ref() else { + return; + }; + let load_buffer = active_repo.update(cx, |active_repo, cx| { + let project = self.project.read(cx); + active_repo.open_commit_buffer( + Some(project.languages().clone()), + project.buffer_store().clone(), + cx, + ) + }); + + cx.spawn_in(window, |git_panel, mut cx| async move { + let buffer = load_buffer.await?; + git_panel.update_in(&mut cx, |git_panel, window, cx| { + if git_panel + .commit_editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .as_ref() + != Some(&buffer) + { + git_panel.commit_editor = + cx.new(|cx| commit_message_editor(Some(buffer), window, cx)); + } + }) + }) + .detach_and_log_err(cx); + } + fn clear_pending(&mut self) { self.pending.retain(|v| !v.finished) } @@ -944,6 +907,7 @@ impl GitPanel { }; // First pass - collect all paths + let repo = repo.read(cx); let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path)); let mut has_changed_checked_boxes = false; @@ -1117,7 +1081,7 @@ impl GitPanel { let entry_count = self .active_repository .as_ref() - .map_or(0, RepositoryHandle::entry_count); + .map_or(0, |repo| repo.read(cx).entry_count()); let changes_string = match entry_count { 0 => "No changes".to_string(), @@ -1151,7 +1115,7 @@ impl GitPanel { let active_repository = self.project.read(cx).active_repository(cx); let repository_display_name = active_repository .as_ref() - .map(|repo| repo.display_name(self.project.read(cx), cx)) + .map(|repo| repo.read(cx).display_name(self.project.read(cx), cx)) .unwrap_or_default(); let entry_count = self.entries.len(); @@ -1619,7 +1583,7 @@ impl Render for GitPanel { .active_repository .as_ref() .map_or(false, |active_repository| { - active_repository.entry_count() > 0 + active_repository.read(cx).entry_count() > 0 }); let room = self .workspace diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 789dc8c21dd8260360e040ae5678aafa2e6601f5..a78f097e244d331fbfca1547be1381e35afd83ea 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -163,6 +163,7 @@ impl ProjectDiff { }; let Some(path) = git_repo + .read(cx) .repo_path_to_project_path(&entry.repo_path) .and_then(|project_path| self.project.read(cx).absolute_path(&project_path, cx)) else { @@ -234,43 +235,45 @@ impl ProjectDiff { let mut previous_paths = self.multibuffer.read(cx).paths().collect::>(); let mut result = vec![]; - for entry in repo.status() { - if !entry.status.has_changes() { - continue; + repo.update(cx, |repo, cx| { + for entry in repo.status() { + if !entry.status.has_changes() { + continue; + } + let Some(project_path) = repo.repo_path_to_project_path(&entry.repo_path) else { + continue; + }; + let Some(abs_path) = self.project.read(cx).absolute_path(&project_path, cx) else { + continue; + }; + // Craft some artificial paths so that created entries will appear last. + let path_key = if entry.status.is_created() { + PathKey::namespaced(ADDED_NAMESPACE, &abs_path) + } else { + PathKey::namespaced(CHANGED_NAMESPACE, &abs_path) + }; + + previous_paths.remove(&path_key); + let load_buffer = self + .project + .update(cx, |project, cx| project.open_buffer(project_path, cx)); + + let project = self.project.clone(); + result.push(cx.spawn(|_, mut cx| async move { + let buffer = load_buffer.await?; + let changes = project + .update(&mut cx, |project, cx| { + project.open_uncommitted_changes(buffer.clone(), cx) + })? + .await?; + Ok(DiffBuffer { + path_key, + buffer, + change_set: changes, + }) + })); } - let Some(project_path) = repo.repo_path_to_project_path(&entry.repo_path) else { - continue; - }; - let Some(abs_path) = self.project.read(cx).absolute_path(&project_path, cx) else { - continue; - }; - // Craft some artificial paths so that created entries will appear last. - let path_key = if entry.status.is_created() { - PathKey::namespaced(ADDED_NAMESPACE, &abs_path) - } else { - PathKey::namespaced(CHANGED_NAMESPACE, &abs_path) - }; - - previous_paths.remove(&path_key); - let load_buffer = self - .project - .update(cx, |project, cx| project.open_buffer(project_path, cx)); - - let project = self.project.clone(); - result.push(cx.spawn(|_, mut cx| async move { - let buffer = load_buffer.await?; - let changes = project - .update(&mut cx, |project, cx| { - project.open_uncommitted_changes(buffer.clone(), cx) - })? - .await?; - Ok(DiffBuffer { - path_key, - buffer, - change_set: changes, - }) - })); - } + }); self.multibuffer.update(cx, |multibuffer, cx| { for path in previous_paths { multibuffer.remove_excerpts_for_path(path, cx); diff --git a/crates/git_ui/src/repository_selector.rs b/crates/git_ui/src/repository_selector.rs index 9c7f5f4e077888a0e9f456ea4d6918750365c93a..81d5f06635d6a7c387fb8ad44cf1b3d8c47f02d1 100644 --- a/crates/git_ui/src/repository_selector.rs +++ b/crates/git_ui/src/repository_selector.rs @@ -4,7 +4,7 @@ use gpui::{ }; use picker::{Picker, PickerDelegate}; use project::{ - git::{GitState, RepositoryHandle}, + git::{GitState, Repository}, Project, }; use std::sync::Arc; @@ -117,13 +117,13 @@ impl RenderOnce for RepositorySelectorPopoverMenu { pub struct RepositorySelectorDelegate { project: WeakEntity, repository_selector: WeakEntity, - repository_entries: Vec, - filtered_repositories: Vec, + repository_entries: Vec>, + filtered_repositories: Vec>, selected_index: usize, } impl RepositorySelectorDelegate { - pub fn update_repository_entries(&mut self, all_repositories: Vec) { + pub fn update_repository_entries(&mut self, all_repositories: Vec>) { self.repository_entries = all_repositories.clone(); self.filtered_repositories = all_repositories; self.selected_index = 0; @@ -194,7 +194,7 @@ impl PickerDelegate for RepositorySelectorDelegate { let Some(selected_repo) = self.filtered_repositories.get(self.selected_index) else { return; }; - selected_repo.activate(cx); + selected_repo.update(cx, |selected_repo, cx| selected_repo.activate(cx)); self.dismissed(window, cx); } @@ -222,7 +222,7 @@ impl PickerDelegate for RepositorySelectorDelegate { ) -> Option { let project = self.project.upgrade()?; let repo_info = self.filtered_repositories.get(ix)?; - let display_name = repo_info.display_name(project.read(cx), cx); + let display_name = repo_info.read(cx).display_name(project.read(cx), cx); // TODO: Implement repository item rendering Some( ListItem::new(ix) diff --git a/crates/languages/Cargo.toml b/crates/languages/Cargo.toml index 5665b9b53ab25daba2a4b979e7f19201e8f89436..99ee6997fda822b20d11c360cc3e6fbc9f0ba3a7 100644 --- a/crates/languages/Cargo.toml +++ b/crates/languages/Cargo.toml @@ -19,6 +19,7 @@ load-grammars = [ "tree-sitter-cpp", "tree-sitter-css", "tree-sitter-diff", + "tree-sitter-gitcommit", "tree-sitter-go", "tree-sitter-go-mod", "tree-sitter-gowork", @@ -69,6 +70,7 @@ tree-sitter-c = { workspace = true, optional = true } tree-sitter-cpp = { workspace = true, optional = true } tree-sitter-css = { workspace = true, optional = true } tree-sitter-diff = { workspace = true, optional = true } +tree-sitter-gitcommit = {workspace = true, optional = true } tree-sitter-go = { workspace = true, optional = true } tree-sitter-go-mod = { workspace = true, optional = true } tree-sitter-gowork = { workspace = true, optional = true } diff --git a/crates/languages/src/gitcommit/config.toml b/crates/languages/src/gitcommit/config.toml new file mode 100644 index 0000000000000000000000000000000000000000..c8ffca31056acb0d7245ae42739c521879df2030 --- /dev/null +++ b/crates/languages/src/gitcommit/config.toml @@ -0,0 +1,18 @@ +name = "Git Commit" +grammar = "git_commit" +path_suffixes = [ + "TAG_EDITMSG", + "MERGE_MSG", + "COMMIT_EDITMSG", + "NOTES_EDITMSG", + "EDIT_DESCRIPTION", +] +line_comments = ["#"] +brackets = [ + { start = "(", end = ")", close = true, newline = false }, + { start = "`", end = "`", close = true, newline = false }, + { start = "\"", end = "\"", close = true, newline = false }, + { start = "'", end = "'", close = true, newline = false }, + { start = "{", end = "}", close = true, newline = false }, + { start = "[", end = "]", close = true, newline = false }, +] diff --git a/crates/languages/src/gitcommit/highlights.scm b/crates/languages/src/gitcommit/highlights.scm new file mode 100644 index 0000000000000000000000000000000000000000..319d76569e56f101c7efb33b5ae676db7d51e0ab --- /dev/null +++ b/crates/languages/src/gitcommit/highlights.scm @@ -0,0 +1,18 @@ +(subject) @markup.heading +(path) @string.special.path +(branch) @string.special.symbol +(commit) @constant +(item) @markup.link.url +(header) @tag + +(change kind: "new file" @diff.plus) +(change kind: "deleted" @diff.minus) +(change kind: "modified" @diff.delta) +(change kind: "renamed" @diff.delta.moved) + +(trailer + key: (trailer_key) @variable.other.member + value: (trailer_value) @string) + +[":" "=" "->" (scissors)] @punctuation.delimiter +(comment) @comment diff --git a/crates/languages/src/gitcommit/injections.scm b/crates/languages/src/gitcommit/injections.scm new file mode 100644 index 0000000000000000000000000000000000000000..db0af176578cfe1ba50db0cc7543d9b805ed8163 --- /dev/null +++ b/crates/languages/src/gitcommit/injections.scm @@ -0,0 +1,5 @@ +((scissors) @content + (#set! "language" "diff")) + +((rebase_command) @content + (#set! "language" "git_rebase")) diff --git a/crates/languages/src/lib.rs b/crates/languages/src/lib.rs index 574af6dc23ed690b39c7a39b7b4bc83b8b89db97..fbfe7b371ce1fc3b26a41b464064a342d8d9f34b 100644 --- a/crates/languages/src/lib.rs +++ b/crates/languages/src/lib.rs @@ -31,6 +31,25 @@ mod yaml; #[exclude = "*.rs"] struct LanguageDir; +/// A shared grammar for plain text, exposed for reuse by downstream crates. +#[cfg(feature = "tree-sitter-gitcommit")] +pub static LANGUAGE_GIT_COMMIT: std::sync::LazyLock> = + std::sync::LazyLock::new(|| { + Arc::new(Language::new( + LanguageConfig { + name: "Git Commit".into(), + soft_wrap: Some(language::language_settings::SoftWrap::EditorWidth), + matcher: LanguageMatcher { + path_suffixes: vec!["COMMIT_EDITMSG".to_owned()], + first_line_pattern: None, + }, + line_comments: vec![Arc::from("#")], + ..LanguageConfig::default() + }, + Some(tree_sitter_gitcommit::LANGUAGE.into()), + )) + }); + pub fn init(languages: Arc, node_runtime: NodeRuntime, cx: &mut App) { #[cfg(feature = "load-grammars")] languages.register_native_grammars([ @@ -53,6 +72,7 @@ pub fn init(languages: Arc, node_runtime: NodeRuntime, cx: &mu ("tsx", tree_sitter_typescript::LANGUAGE_TSX), ("typescript", tree_sitter_typescript::LANGUAGE_TYPESCRIPT), ("yaml", tree_sitter_yaml::LANGUAGE), + ("gitcommit", tree_sitter_gitcommit::LANGUAGE), ]); macro_rules! language { diff --git a/crates/project/src/git.rs b/crates/project/src/git.rs index 90dff1ed93c797948fd89726efd3d80ed36f47bb..38a891005916fba97dccc55fef45b04577555ec6 100644 --- a/crates/project/src/git.rs +++ b/crates/project/src/git.rs @@ -1,6 +1,7 @@ +use crate::buffer_store::BufferStore; use crate::worktree_store::{WorktreeStore, WorktreeStoreEvent}; use crate::{Project, ProjectPath}; -use anyhow::{anyhow, Context as _}; +use anyhow::Context as _; use client::ProjectId; use futures::channel::{mpsc, oneshot}; use futures::StreamExt as _; @@ -8,24 +9,28 @@ use git::{ repository::{GitRepository, RepoPath}, status::{GitSummary, TrackedSummary}, }; -use gpui::{App, Context, Entity, EventEmitter, SharedString, Subscription, WeakEntity}; +use gpui::{ + App, AppContext, Context, Entity, EventEmitter, SharedString, Subscription, Task, WeakEntity, +}; +use language::{Buffer, LanguageRegistry}; use rpc::{proto, AnyProtoClient}; use settings::WorktreeId; use std::sync::Arc; +use text::BufferId; use util::{maybe, ResultExt}; use worktree::{ProjectEntryId, RepositoryEntry, StatusEntry}; pub struct GitState { project_id: Option, client: Option, - repositories: Vec, + repositories: Vec>, active_index: Option, update_sender: mpsc::UnboundedSender<(Message, oneshot::Sender>)>, _subscription: Subscription, } -#[derive(Clone)] -pub struct RepositoryHandle { +pub struct Repository { + commit_message_buffer: Option>, git_state: WeakEntity, pub worktree_id: WorktreeId, pub repository_entry: RepositoryEntry, @@ -44,25 +49,10 @@ pub enum GitRepo { }, } -impl PartialEq for RepositoryHandle { - fn eq(&self, other: &Self) -> bool { - self.worktree_id == other.worktree_id - && self.repository_entry.work_directory_id() - == other.repository_entry.work_directory_id() - } -} - -impl Eq for RepositoryHandle {} - -impl PartialEq for RepositoryHandle { - fn eq(&self, other: &RepositoryEntry) -> bool { - self.repository_entry.work_directory_id() == other.work_directory_id() - } -} - enum Message { Commit { git_repo: GitRepo, + message: SharedString, name_and_email: Option<(SharedString, SharedString)>, }, Stage(GitRepo, Vec), @@ -97,7 +87,7 @@ impl GitState { } } - pub fn active_repository(&self) -> Option { + pub fn active_repository(&self) -> Option> { self.active_index .map(|index| self.repositories[index].clone()) } @@ -118,7 +108,7 @@ impl GitState { worktree_store.update(cx, |worktree_store, cx| { for worktree in worktree_store.worktrees() { - worktree.update(cx, |worktree, _| { + worktree.update(cx, |worktree, cx| { let snapshot = worktree.snapshot(); for repo in snapshot.repositories().iter() { let git_repo = worktree @@ -139,27 +129,34 @@ impl GitState { let Some(git_repo) = git_repo else { continue; }; - let existing = self - .repositories - .iter() - .enumerate() - .find(|(_, existing_handle)| existing_handle == &repo); + let worktree_id = worktree.id(); + let existing = + self.repositories + .iter() + .enumerate() + .find(|(_, existing_handle)| { + existing_handle.read(cx).id() + == (worktree_id, repo.work_directory_id()) + }); let handle = if let Some((index, handle)) = existing { if self.active_index == Some(index) { new_active_index = Some(new_repositories.len()); } // Update the statuses but keep everything else. - let mut existing_handle = handle.clone(); - existing_handle.repository_entry = repo.clone(); + let existing_handle = handle.clone(); + existing_handle.update(cx, |existing_handle, _| { + existing_handle.repository_entry = repo.clone(); + }); existing_handle } else { - RepositoryHandle { + cx.new(|_| Repository { git_state: this.clone(), - worktree_id: worktree.id(), + worktree_id, repository_entry: repo.clone(), git_repo, update_sender: self.update_sender.clone(), - } + commit_message_buffer: None, + }) }; new_repositories.push(handle); } @@ -184,7 +181,7 @@ impl GitState { } } - pub fn all_repositories(&self) -> Vec { + pub fn all_repositories(&self) -> Vec> { self.repositories.clone() } @@ -260,10 +257,12 @@ impl GitState { } Message::Commit { git_repo, + message, name_and_email, } => { match git_repo { GitRepo::Local(repo) => repo.commit( + message.as_ref(), name_and_email .as_ref() .map(|(name, email)| (name.as_ref(), email.as_ref())), @@ -280,6 +279,7 @@ impl GitState { project_id: project_id.0, worktree_id: worktree_id.to_proto(), work_directory_id: work_directory_id.to_proto(), + message: String::from(message), name: name.map(String::from), email: email.map(String::from), }) @@ -293,7 +293,11 @@ impl GitState { } } -impl RepositoryHandle { +impl Repository { + fn id(&self) -> (WorktreeId, ProjectEntryId) { + (self.worktree_id, self.repository_entry.work_directory_id()) + } + pub fn display_name(&self, project: &Project, cx: &App) -> SharedString { maybe!({ let path = self.repo_path_to_project_path(&"".into())?; @@ -318,7 +322,7 @@ impl RepositoryHandle { .repositories .iter() .enumerate() - .find(|(_, handle)| handle == &self) + .find(|(_, handle)| handle.read(cx).id() == self.id()) else { return; }; @@ -343,47 +347,121 @@ impl RepositoryHandle { self.repository_entry.relativize(&path.path).log_err() } - pub async fn stage_entries(&self, entries: Vec) -> anyhow::Result<()> { - if entries.is_empty() { - return Ok(()); + pub fn open_commit_buffer( + &mut self, + languages: Option>, + buffer_store: Entity, + cx: &mut Context, + ) -> Task>> { + if let Some(buffer) = self.commit_message_buffer.clone() { + return Task::ready(Ok(buffer)); } + + if let GitRepo::Remote { + project_id, + client, + worktree_id, + work_directory_id, + } = self.git_repo.clone() + { + let client = client.clone(); + cx.spawn(|repository, mut cx| async move { + let request = client.request(proto::OpenCommitMessageBuffer { + project_id: project_id.0, + worktree_id: worktree_id.to_proto(), + work_directory_id: work_directory_id.to_proto(), + }); + let response = request.await.context("requesting to open commit buffer")?; + let buffer_id = BufferId::new(response.buffer_id)?; + let buffer = buffer_store + .update(&mut cx, |buffer_store, cx| { + buffer_store.wait_for_remote_buffer(buffer_id, cx) + })? + .await?; + if let Some(language_registry) = languages { + let git_commit_language = + language_registry.language_for_name("Git Commit").await?; + buffer.update(&mut cx, |buffer, cx| { + buffer.set_language(Some(git_commit_language), cx); + })?; + } + repository.update(&mut cx, |repository, _| { + repository.commit_message_buffer = Some(buffer.clone()); + })?; + Ok(buffer) + }) + } else { + self.open_local_commit_buffer(languages, buffer_store, cx) + } + } + + fn open_local_commit_buffer( + &mut self, + language_registry: Option>, + buffer_store: Entity, + cx: &mut Context, + ) -> Task>> { + cx.spawn(|repository, mut cx| async move { + let buffer = buffer_store + .update(&mut cx, |buffer_store, cx| buffer_store.create_buffer(cx))? + .await?; + + if let Some(language_registry) = language_registry { + let git_commit_language = language_registry.language_for_name("Git Commit").await?; + buffer.update(&mut cx, |buffer, cx| { + buffer.set_language(Some(git_commit_language), cx); + })?; + } + + repository.update(&mut cx, |repository, _| { + repository.commit_message_buffer = Some(buffer.clone()); + })?; + Ok(buffer) + }) + } + + pub fn stage_entries(&self, entries: Vec) -> oneshot::Receiver> { let (result_tx, result_rx) = futures::channel::oneshot::channel(); + if entries.is_empty() { + result_tx.send(Ok(())).ok(); + return result_rx; + } self.update_sender .unbounded_send((Message::Stage(self.git_repo.clone(), entries), result_tx)) - .map_err(|_| anyhow!("Failed to submit stage operation"))?; - - result_rx.await? + .ok(); + result_rx } - pub async fn unstage_entries(&self, entries: Vec) -> anyhow::Result<()> { + pub fn unstage_entries(&self, entries: Vec) -> oneshot::Receiver> { + let (result_tx, result_rx) = futures::channel::oneshot::channel(); if entries.is_empty() { - return Ok(()); + result_tx.send(Ok(())).ok(); + return result_rx; } - let (result_tx, result_rx) = futures::channel::oneshot::channel(); self.update_sender .unbounded_send((Message::Unstage(self.git_repo.clone(), entries), result_tx)) - .map_err(|_| anyhow!("Failed to submit unstage operation"))?; - result_rx.await? + .ok(); + result_rx } - pub async fn stage_all(&self) -> anyhow::Result<()> { + pub fn stage_all(&self) -> oneshot::Receiver> { let to_stage = self .repository_entry .status() .filter(|entry| !entry.status.is_staged().unwrap_or(false)) .map(|entry| entry.repo_path.clone()) .collect(); - self.stage_entries(to_stage).await + self.stage_entries(to_stage) } - pub async fn unstage_all(&self) -> anyhow::Result<()> { + pub fn unstage_all(&self) -> oneshot::Receiver> { let to_unstage = self .repository_entry .status() .filter(|entry| entry.status.is_staged().unwrap_or(true)) .map(|entry| entry.repo_path.clone()) .collect(); - self.unstage_entries(to_unstage).await + self.unstage_entries(to_unstage) } /// Get a count of all entries in the active repository, including @@ -404,18 +482,22 @@ impl RepositoryHandle { return self.have_changes() && (commit_all || self.have_staged_changes()); } - pub async fn commit( + pub fn commit( &self, + message: SharedString, name_and_email: Option<(SharedString, SharedString)>, - ) -> anyhow::Result<()> { + ) -> oneshot::Receiver> { let (result_tx, result_rx) = futures::channel::oneshot::channel(); - self.update_sender.unbounded_send(( - Message::Commit { - git_repo: self.git_repo.clone(), - name_and_email, - }, - result_tx, - ))?; - result_rx.await? + self.update_sender + .unbounded_send(( + Message::Commit { + git_repo: self.git_repo.clone(), + message, + name_and_email, + }, + result_tx, + )) + .ok(); + result_rx } } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 069044bbc42dc9fbadd7239ea289c07e51453b04..2a7759daa4e556d7681d4c98332422323b0ea109 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -22,7 +22,7 @@ mod project_tests; mod direnv; mod environment; pub use environment::EnvironmentErrorMessage; -use git::RepositoryHandle; +use git::Repository; pub mod search_history; mod yarn; @@ -48,7 +48,6 @@ use ::git::{ blame::Blame, repository::{Branch, GitRepository, RepoPath}, status::FileStatus, - COMMIT_MESSAGE, }; use gpui::{ AnyEntity, App, AppContext as _, AsyncApp, BorrowAppContext, Context, Entity, EventEmitter, @@ -1998,12 +1997,15 @@ impl Project { project_id, id: id.into(), }); - cx.spawn(move |this, mut cx| async move { + cx.spawn(move |project, mut cx| async move { let buffer_id = BufferId::new(request.await?.buffer_id)?; - this.update(&mut cx, |this, cx| { - this.wait_for_remote_buffer(buffer_id, cx) - })? - .await + project + .update(&mut cx, |project, cx| { + project.buffer_store.update(cx, |buffer_store, cx| { + buffer_store.wait_for_remote_buffer(buffer_id, cx) + }) + })? + .await }) } else { Task::ready(Err(anyhow!("cannot open buffer while disconnected"))) @@ -2846,16 +2848,21 @@ impl Project { let proto_client = ssh_client.read(cx).proto_client(); - cx.spawn(|this, mut cx| async move { + cx.spawn(|project, mut cx| async move { let buffer = proto_client .request(proto::OpenServerSettings { project_id: SSH_PROJECT_ID, }) .await?; - let buffer = this - .update(&mut cx, |this, cx| { - anyhow::Ok(this.wait_for_remote_buffer(BufferId::new(buffer.buffer_id)?, cx)) + let buffer = project + .update(&mut cx, |project, cx| { + project.buffer_store.update(cx, |buffer_store, cx| { + anyhow::Ok( + buffer_store + .wait_for_remote_buffer(BufferId::new(buffer.buffer_id)?, cx), + ) + }) })?? .await; @@ -3186,13 +3193,15 @@ impl Project { }); let guard = self.retain_remotely_created_models(cx); - cx.spawn(move |this, mut cx| async move { + cx.spawn(move |project, mut cx| async move { let response = request.await?; for buffer_id in response.buffer_ids { let buffer_id = BufferId::new(buffer_id)?; - let buffer = this - .update(&mut cx, |this, cx| { - this.wait_for_remote_buffer(buffer_id, cx) + let buffer = project + .update(&mut cx, |project, cx| { + project.buffer_store.update(cx, |buffer_store, cx| { + buffer_store.wait_for_remote_buffer(buffer_id, cx) + }) })? .await?; let _ = tx.send(buffer).await; @@ -3998,7 +4007,11 @@ impl Project { .map(RepoPath::new) .collect(); - repository_handle.stage_entries(entries).await?; + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.stage_entries(entries) + })? + .await??; Ok(proto::Ack {}) } @@ -4020,7 +4033,11 @@ impl Project { .map(RepoPath::new) .collect(); - repository_handle.unstage_entries(entries).await?; + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.unstage_entries(entries) + })? + .await??; Ok(proto::Ack {}) } @@ -4034,9 +4051,14 @@ impl Project { let repository_handle = Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; + let message = SharedString::from(envelope.payload.message); let name = envelope.payload.name.map(SharedString::from); let email = envelope.payload.email.map(SharedString::from); - repository_handle.commit(name.zip(email)).await?; + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.commit(message, name.zip(email)) + })? + .await??; Ok(proto::Ack {}) } @@ -4049,55 +4071,12 @@ impl Project { let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); let repository_handle = Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; - let git_repository = match &repository_handle.git_repo { - git::GitRepo::Local(git_repository) => git_repository.clone(), - git::GitRepo::Remote { .. } => { - anyhow::bail!("Cannot handle open commit message buffer for remote git repo") - } - }; - let commit_message_file = git_repository.dot_git_dir().join(*COMMIT_MESSAGE); - let fs = this.update(&mut cx, |project, _| project.fs().clone())?; - fs.create_file( - &commit_message_file, - CreateOptions { - overwrite: false, - ignore_if_exists: true, - }, - ) - .await - .with_context(|| format!("creating commit message file {commit_message_file:?}"))?; - - let (worktree, relative_path) = this - .update(&mut cx, |headless_project, cx| { - headless_project - .worktree_store - .update(cx, |worktree_store, cx| { - worktree_store.find_or_create_worktree(&commit_message_file, false, cx) - }) - })? - .await - .with_context(|| { - format!("deriving worktree for commit message file {commit_message_file:?}") - })?; - - let buffer = this - .update(&mut cx, |headless_project, cx| { - headless_project - .buffer_store - .update(cx, |buffer_store, cx| { - buffer_store.open_buffer( - ProjectPath { - worktree_id: worktree.read(cx).id(), - path: Arc::from(relative_path), - }, - cx, - ) - }) - }) - .with_context(|| { - format!("opening buffer for commit message file {commit_message_file:?}") + let buffer = repository_handle + .update(&mut cx, |repository_handle, cx| { + repository_handle.open_commit_buffer(None, this.read(cx).buffer_store.clone(), cx) })? .await?; + let peer_id = envelope.original_sender_id()?; Project::respond_to_open_buffer_request(this, buffer, peer_id, &mut cx) } @@ -4107,7 +4086,7 @@ impl Project { worktree_id: WorktreeId, work_directory_id: ProjectEntryId, cx: &mut AsyncApp, - ) -> Result { + ) -> Result> { this.update(cx, |project, cx| { let repository_handle = project .git_state() @@ -4115,6 +4094,7 @@ impl Project { .all_repositories() .into_iter() .find(|repository_handle| { + let repository_handle = repository_handle.read(cx); repository_handle.worktree_id == worktree_id && repository_handle.repository_entry.work_directory_id() == work_directory_id @@ -4160,16 +4140,6 @@ impl Project { buffer.read(cx).remote_id() } - pub fn wait_for_remote_buffer( - &mut self, - id: BufferId, - cx: &mut Context, - ) -> Task>> { - self.buffer_store.update(cx, |buffer_store, cx| { - buffer_store.wait_for_remote_buffer(id, cx) - }) - } - fn synchronize_remote_buffers(&mut self, cx: &mut Context) -> Task> { let project_id = match self.client_state { ProjectClientState::Remote { @@ -4329,11 +4299,11 @@ impl Project { &self.git_state } - pub fn active_repository(&self, cx: &App) -> Option { + pub fn active_repository(&self, cx: &App) -> Option> { self.git_state.read(cx).active_repository() } - pub fn all_repositories(&self, cx: &App) -> Vec { + pub fn all_repositories(&self, cx: &App) -> Vec> { self.git_state.read(cx).all_repositories() } } diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 976e1e73fd0ce30c63036ca5adb54e3d0ec1610d..195294fe68a015845447c73ad8ab38313f6d020b 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -2693,6 +2693,7 @@ message Commit { uint64 work_directory_id = 3; optional string name = 4; optional string email = 5; + string message = 6; } message OpenCommitMessageBuffer { diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 3accb105ad5ebf1701d0ebaef572dd6a660b09ec..be22a52fa77b71c178a908b729fa652316f6435a 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -1,15 +1,15 @@ use anyhow::{anyhow, Context as _, Result}; use extension::ExtensionHostProxy; use extension_host::headless_host::HeadlessExtensionStore; -use fs::{CreateOptions, Fs}; -use git::{repository::RepoPath, COMMIT_MESSAGE}; +use fs::Fs; +use git::repository::RepoPath; use gpui::{App, AppContext as _, AsyncApp, Context, Entity, PromptLevel, SharedString}; use http_client::HttpClient; use language::{proto::serialize_operation, Buffer, BufferEvent, LanguageRegistry}; use node_runtime::NodeRuntime; use project::{ buffer_store::{BufferStore, BufferStoreEvent}, - git::{GitRepo, GitState, RepositoryHandle}, + git::{GitState, Repository}, project_settings::SettingsObserver, search::SearchQuery, task_store::TaskStore, @@ -635,7 +635,11 @@ impl HeadlessProject { .map(RepoPath::new) .collect(); - repository_handle.stage_entries(entries).await?; + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.stage_entries(entries) + })? + .await??; Ok(proto::Ack {}) } @@ -657,7 +661,11 @@ impl HeadlessProject { .map(RepoPath::new) .collect(); - repository_handle.unstage_entries(entries).await?; + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.unstage_entries(entries) + })? + .await??; Ok(proto::Ack {}) } @@ -672,10 +680,15 @@ impl HeadlessProject { let repository_handle = Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; + let message = SharedString::from(envelope.payload.message); let name = envelope.payload.name.map(SharedString::from); let email = envelope.payload.email.map(SharedString::from); - repository_handle.commit(name.zip(email)).await?; + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.commit(message, name.zip(email)) + })? + .await??; Ok(proto::Ack {}) } @@ -686,55 +699,11 @@ impl HeadlessProject { ) -> Result { let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); - let repository_handle = + let repository = Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; - let git_repository = match &repository_handle.git_repo { - GitRepo::Local(git_repository) => git_repository.clone(), - GitRepo::Remote { .. } => { - anyhow::bail!("Cannot handle open commit message buffer for remote git repo") - } - }; - let commit_message_file = git_repository.dot_git_dir().join(*COMMIT_MESSAGE); - let fs = this.update(&mut cx, |headless_project, _| headless_project.fs.clone())?; - fs.create_file( - &commit_message_file, - CreateOptions { - overwrite: false, - ignore_if_exists: true, - }, - ) - .await - .with_context(|| format!("creating commit message file {commit_message_file:?}"))?; - - let (worktree, relative_path) = this - .update(&mut cx, |headless_project, cx| { - headless_project - .worktree_store - .update(cx, |worktree_store, cx| { - worktree_store.find_or_create_worktree(&commit_message_file, false, cx) - }) - })? - .await - .with_context(|| { - format!("deriving worktree for commit message file {commit_message_file:?}") - })?; - - let buffer = this - .update(&mut cx, |headless_project, cx| { - headless_project - .buffer_store - .update(cx, |buffer_store, cx| { - buffer_store.open_buffer( - ProjectPath { - worktree_id: worktree.read(cx).id(), - path: Arc::from(relative_path), - }, - cx, - ) - }) - }) - .with_context(|| { - format!("opening buffer for commit message file {commit_message_file:?}") + let buffer = repository + .update(&mut cx, |repository, cx| { + repository.open_commit_buffer(None, this.read(cx).buffer_store.clone(), cx) })? .await?; @@ -759,7 +728,7 @@ impl HeadlessProject { worktree_id: WorktreeId, work_directory_id: ProjectEntryId, cx: &mut AsyncApp, - ) -> Result { + ) -> Result> { this.update(cx, |project, cx| { let repository_handle = project .git_state @@ -767,8 +736,11 @@ impl HeadlessProject { .all_repositories() .into_iter() .find(|repository_handle| { - repository_handle.worktree_id == worktree_id - && repository_handle.repository_entry.work_directory_id() + repository_handle.read(cx).worktree_id == worktree_id + && repository_handle + .read(cx) + .repository_entry + .work_directory_id() == work_directory_id }) .context("missing repository handle")?; diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 8ba52747d1f50ad03ad4d39c70e2d2141dfaf503..7084fc7d3bab71787839d56710ac051cc59db5af 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -199,7 +199,7 @@ pub struct RepositoryEntry { /// - my_sub_folder_1/project_root/changed_file_1 /// - my_sub_folder_2/changed_file_2 pub(crate) statuses_by_path: SumTree, - pub work_directory_id: ProjectEntryId, + work_directory_id: ProjectEntryId, pub work_directory: WorkDirectory, pub(crate) branch: Option>, }