From ff6844300ee771de69a173ad699c42feaf759ddd Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Mon, 24 Feb 2025 10:29:52 -0800 Subject: [PATCH] Git push/pull/fetch (#25445) Release Notes: - N/A --------- Co-authored-by: Michael Sloan --- Cargo.lock | 1 + crates/collab/src/rpc.rs | 4 + crates/editor/src/editor.rs | 7 +- crates/editor/src/test/editor_test_context.rs | 1 + crates/git/Cargo.toml | 1 + crates/git/src/git.rs | 12 + crates/git/src/repository.rs | 271 +++++++++--- crates/git_ui/src/branch_picker.rs | 2 +- crates/git_ui/src/commit_modal.rs | 7 +- crates/git_ui/src/git_panel.rs | 412 +++++++++++++++--- crates/git_ui/src/git_ui.rs | 1 + crates/git_ui/src/picker_prompt.rs | 235 ++++++++++ crates/project/src/git.rs | 340 +++++++++++++-- crates/project/src/worktree_store.rs | 17 +- crates/proto/proto/zed.proto | 51 ++- crates/proto/src/proto.rs | 13 + crates/worktree/src/worktree.rs | 47 +- 17 files changed, 1242 insertions(+), 180 deletions(-) create mode 100644 crates/git_ui/src/picker_prompt.rs diff --git a/Cargo.lock b/Cargo.lock index 537c8f9e67..76a3fdcc4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5298,6 +5298,7 @@ dependencies = [ "pretty_assertions", "regex", "rope", + "schemars", "serde", "serde_json", "smol", diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 189a5e5471..53bf63165e 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -392,9 +392,13 @@ impl Server { .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 54543a6782..79372f732b 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -12979,7 +12979,12 @@ impl Editor { .update(cx, |buffer_store, cx| buffer_store.save_buffer(buffer, cx)) .detach_and_log_err(cx); - let _ = repo.read(cx).set_index_text(&path, new_index_text); + cx.background_spawn( + repo.read(cx) + .set_index_text(&path, new_index_text) + .log_err(), + ) + .detach(); } pub fn expand_selected_diff_hunks(&mut self, cx: &mut Context) { diff --git a/crates/editor/src/test/editor_test_context.rs b/crates/editor/src/test/editor_test_context.rs index fb63d21151..1ace560e57 100644 --- a/crates/editor/src/test/editor_test_context.rs +++ b/crates/editor/src/test/editor_test_context.rs @@ -298,6 +298,7 @@ impl EditorTestContext { self.cx.run_until_parked(); } + #[track_caller] pub fn assert_index_text(&mut self, expected: Option<&str>) { let fs = self.update_editor(|editor, _, cx| { editor.project.as_ref().unwrap().read(cx).fs().as_fake() diff --git a/crates/git/Cargo.toml b/crates/git/Cargo.toml index 4eefe6c262..0473b1dd57 100644 --- a/crates/git/Cargo.toml +++ b/crates/git/Cargo.toml @@ -26,6 +26,7 @@ log.workspace = true parking_lot.workspace = true regex.workspace = true rope.workspace = true +schemars.workspace = true serde.workspace = true smol.workspace = true sum_tree.workspace = true diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index 21cd982b09..d68d9f7b65 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -8,6 +8,9 @@ pub mod status; use anyhow::{anyhow, Context as _, Result}; use gpui::action_with_deprecated_aliases; use gpui::actions; +use gpui::impl_actions; +use repository::PushOptions; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::ffi::OsStr; use std::fmt; @@ -27,6 +30,13 @@ pub static COMMIT_MESSAGE: LazyLock<&'static OsStr> = LazyLock::new(|| OsStr::new("COMMIT_EDITMSG")); pub static INDEX_LOCK: LazyLock<&'static OsStr> = LazyLock::new(|| OsStr::new("index.lock")); +#[derive(Debug, Copy, Clone, PartialEq, Deserialize, JsonSchema)] +pub struct Push { + pub options: Option, +} + +impl_actions!(git, [Push]); + actions!( git, [ @@ -43,6 +53,8 @@ actions!( RestoreTrackedFiles, TrashUntrackedFiles, Uncommit, + Pull, + Fetch, Commit, ] ); diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index b29d4b226d..7b68507eca 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -7,6 +7,8 @@ use git2::BranchType; use gpui::SharedString; use parking_lot::Mutex; use rope::Rope; +use schemars::JsonSchema; +use serde::Deserialize; use std::borrow::Borrow; use std::io::Write as _; use std::process::Stdio; @@ -29,6 +31,12 @@ pub struct Branch { } impl Branch { + pub fn tracking_status(&self) -> Option { + self.upstream + .as_ref() + .and_then(|upstream| upstream.tracking.status()) + } + pub fn priority_key(&self) -> (bool, Option) { ( self.is_head, @@ -42,11 +50,32 @@ impl Branch { #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub struct Upstream { pub ref_name: SharedString, - pub tracking: Option, + pub tracking: UpstreamTracking, } -#[derive(Clone, Debug, Hash, PartialEq, Eq)] -pub struct UpstreamTracking { +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +pub enum UpstreamTracking { + /// Remote ref not present in local repository. + Gone, + /// Remote ref present in local repository (fetched from remote). + Tracked(UpstreamTrackingStatus), +} + +impl UpstreamTracking { + pub fn is_gone(&self) -> bool { + matches!(self, UpstreamTracking::Gone) + } + + pub fn status(&self) -> Option { + match self { + UpstreamTracking::Gone => None, + UpstreamTracking::Tracked(status) => Some(*status), + } + } +} + +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +pub struct UpstreamTrackingStatus { pub ahead: u32, pub behind: u32, } @@ -68,6 +97,11 @@ pub struct CommitDetails { pub committer_name: SharedString, } +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub struct Remote { + pub name: SharedString, +} + pub enum ResetMode { // reset the branch pointer, leave index and worktree unchanged // (this will make it look like things that were committed are now @@ -139,6 +173,22 @@ pub trait GitRepository: Send + Sync { fn unstage_paths(&self, paths: &[RepoPath]) -> Result<()>; fn commit(&self, message: &str, name_and_email: Option<(&str, &str)>) -> Result<()>; + + fn push( + &self, + branch_name: &str, + upstream_name: &str, + options: Option, + ) -> Result<()>; + fn pull(&self, branch_name: &str, upstream_name: &str) -> Result<()>; + fn get_remotes(&self, branch_name: Option<&str>) -> Result>; + fn fetch(&self) -> Result<()>; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)] +pub enum PushOptions { + SetUpstream, + Force, } impl std::fmt::Debug for dyn GitRepository { @@ -165,6 +215,14 @@ impl RealGitRepository { hosting_provider_registry, } } + + fn working_directory(&self) -> Result { + self.repository + .lock() + .workdir() + .context("failed to read git work directory") + .map(Path::to_path_buf) + } } // https://git-scm.com/book/en/v2/Git-Internals-Git-Objects @@ -209,12 +267,7 @@ impl GitRepository for RealGitRepository { } fn reset(&self, commit: &str, mode: ResetMode) -> Result<()> { - let working_directory = self - .repository - .lock() - .workdir() - .context("failed to read git work directory")? - .to_path_buf(); + let working_directory = self.working_directory()?; let mode_flag = match mode { ResetMode::Mixed => "--mixed", @@ -238,12 +291,7 @@ impl GitRepository for RealGitRepository { if paths.is_empty() { return Ok(()); } - let working_directory = self - .repository - .lock() - .workdir() - .context("failed to read git work directory")? - .to_path_buf(); + let working_directory = self.working_directory()?; let output = new_std_command(&self.git_binary_path) .current_dir(&working_directory) @@ -296,12 +344,7 @@ impl GitRepository for RealGitRepository { } fn set_index_text(&self, path: &RepoPath, content: Option) -> anyhow::Result<()> { - let working_directory = self - .repository - .lock() - .workdir() - .context("failed to read git work directory")? - .to_path_buf(); + let working_directory = self.working_directory()?; if let Some(content) = content { let mut child = new_std_command(&self.git_binary_path) .current_dir(&working_directory) @@ -485,12 +528,7 @@ impl GitRepository for RealGitRepository { } fn stage_paths(&self, paths: &[RepoPath]) -> Result<()> { - let working_directory = self - .repository - .lock() - .workdir() - .context("failed to read git work directory")? - .to_path_buf(); + let working_directory = self.working_directory()?; if !paths.is_empty() { let output = new_std_command(&self.git_binary_path) @@ -498,6 +536,8 @@ impl GitRepository for RealGitRepository { .args(["update-index", "--add", "--remove", "--"]) .args(paths.iter().map(|p| p.as_ref())) .output()?; + + // TODO: Get remote response out of this and show it to the user if !output.status.success() { return Err(anyhow!( "Failed to stage paths:\n{}", @@ -509,12 +549,7 @@ impl GitRepository for RealGitRepository { } fn unstage_paths(&self, paths: &[RepoPath]) -> Result<()> { - let working_directory = self - .repository - .lock() - .workdir() - .context("failed to read git work directory")? - .to_path_buf(); + let working_directory = self.working_directory()?; if !paths.is_empty() { let output = new_std_command(&self.git_binary_path) @@ -522,6 +557,8 @@ impl GitRepository for RealGitRepository { .args(["reset", "--quiet", "--"]) .args(paths.iter().map(|p| p.as_ref())) .output()?; + + // TODO: Get remote response out of this and show it to the user if !output.status.success() { return Err(anyhow!( "Failed to unstage:\n{}", @@ -533,24 +570,21 @@ impl GitRepository for RealGitRepository { } 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 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"); - args.push(author); + let working_directory = self.working_directory()?; + + let mut cmd = new_std_command(&self.git_binary_path); + cmd.current_dir(&working_directory) + .args(["commit", "--quiet", "-m"]) + .arg(message) + .arg("--cleanup=strip"); + + if let Some((name, email)) = name_and_email { + cmd.arg("--author").arg(&format!("{name} <{email}>")); } - let output = new_std_command(&self.git_binary_path) - .current_dir(&working_directory) - .args(args) - .output()?; + let output = cmd.output()?; + // TODO: Get remote response out of this and show it to the user if !output.status.success() { return Err(anyhow!( "Failed to commit:\n{}", @@ -559,6 +593,118 @@ impl GitRepository for RealGitRepository { } Ok(()) } + + fn push( + &self, + branch_name: &str, + remote_name: &str, + options: Option, + ) -> Result<()> { + let working_directory = self.working_directory()?; + + let output = new_std_command(&self.git_binary_path) + .current_dir(&working_directory) + .args(["push", "--quiet"]) + .args(options.map(|option| match option { + PushOptions::SetUpstream => "--set-upstream", + PushOptions::Force => "--force-with-lease", + })) + .arg(remote_name) + .arg(format!("{}:{}", branch_name, branch_name)) + .output()?; + + if !output.status.success() { + return Err(anyhow!( + "Failed to push:\n{}", + String::from_utf8_lossy(&output.stderr) + )); + } + + // TODO: Get remote response out of this and show it to the user + Ok(()) + } + + fn pull(&self, branch_name: &str, remote_name: &str) -> Result<()> { + let working_directory = self.working_directory()?; + + let output = new_std_command(&self.git_binary_path) + .current_dir(&working_directory) + .args(["pull", "--quiet"]) + .arg(remote_name) + .arg(branch_name) + .output()?; + + if !output.status.success() { + return Err(anyhow!( + "Failed to pull:\n{}", + String::from_utf8_lossy(&output.stderr) + )); + } + + // TODO: Get remote response out of this and show it to the user + Ok(()) + } + + fn fetch(&self) -> Result<()> { + let working_directory = self.working_directory()?; + + let output = new_std_command(&self.git_binary_path) + .current_dir(&working_directory) + .args(["fetch", "--quiet", "--all"]) + .output()?; + + if !output.status.success() { + return Err(anyhow!( + "Failed to fetch:\n{}", + String::from_utf8_lossy(&output.stderr) + )); + } + + // TODO: Get remote response out of this and show it to the user + Ok(()) + } + + fn get_remotes(&self, branch_name: Option<&str>) -> Result> { + let working_directory = self.working_directory()?; + + if let Some(branch_name) = branch_name { + let output = new_std_command(&self.git_binary_path) + .current_dir(&working_directory) + .args(["config", "--get"]) + .arg(format!("branch.{}.remote", branch_name)) + .output()?; + + if output.status.success() { + let remote_name = String::from_utf8_lossy(&output.stdout); + + return Ok(vec![Remote { + name: remote_name.trim().to_string().into(), + }]); + } + } + + let output = new_std_command(&self.git_binary_path) + .current_dir(&working_directory) + .args(["remote"]) + .output()?; + + if output.status.success() { + let remote_names = String::from_utf8_lossy(&output.stdout) + .split('\n') + .filter(|name| !name.is_empty()) + .map(|name| Remote { + name: name.trim().to_string().into(), + }) + .collect(); + + return Ok(remote_names); + } else { + return Err(anyhow!( + "Failed to get remotes:\n{}", + String::from_utf8_lossy(&output.stderr) + )); + } + } } #[derive(Debug, Clone)] @@ -743,6 +889,22 @@ impl GitRepository for FakeGitRepository { fn commit(&self, _message: &str, _name_and_email: Option<(&str, &str)>) -> Result<()> { unimplemented!() } + + fn push(&self, _branch: &str, _remote: &str, _options: Option) -> Result<()> { + unimplemented!() + } + + fn pull(&self, _branch: &str, _remote: &str) -> Result<()> { + unimplemented!() + } + + fn fetch(&self) -> Result<()> { + unimplemented!() + } + + fn get_remotes(&self, _branch: Option<&str>) -> Result> { + unimplemented!() + } } fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> { @@ -911,9 +1073,9 @@ fn parse_branch_input(input: &str) -> Result> { Ok(branches) } -fn parse_upstream_track(upstream_track: &str) -> Result> { +fn parse_upstream_track(upstream_track: &str) -> Result { if upstream_track == "" { - return Ok(Some(UpstreamTracking { + return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead: 0, behind: 0, })); @@ -929,7 +1091,7 @@ fn parse_upstream_track(upstream_track: &str) -> Result let mut behind: u32 = 0; for component in upstream_track.split(", ") { if component == "gone" { - return Ok(None); + return Ok(UpstreamTracking::Gone); } if let Some(ahead_num) = component.strip_prefix("ahead ") { ahead = ahead_num.parse::()?; @@ -938,7 +1100,10 @@ fn parse_upstream_track(upstream_track: &str) -> Result behind = behind_num.parse::()?; } } - Ok(Some(UpstreamTracking { ahead, behind })) + Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus { + ahead, + behind, + })) } #[test] @@ -953,7 +1118,7 @@ fn test_branches_parsing() { name: "zed-patches".into(), upstream: Some(Upstream { ref_name: "refs/remotes/origin/zed-patches".into(), - tracking: Some(UpstreamTracking { + tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead: 0, behind: 0 }) diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index d6233dd823..33febb7af5 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -261,7 +261,7 @@ impl PickerDelegate for BranchListDelegate { .project() .read(cx) .active_repository(cx) - .and_then(|repo| repo.read(cx).branch()) + .and_then(|repo| repo.read(cx).current_branch()) .map(|branch| branch.name.to_string()) }) .ok() diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index 37125f7008..2ead1cb37b 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -159,7 +159,12 @@ impl CommitModal { let branch = git_panel .active_repository .as_ref() - .and_then(|repo| repo.read(cx).branch().map(|b| b.name.clone())) + .and_then(|repo| { + repo.read(cx) + .repository_entry + .branch() + .map(|b| b.name.clone()) + }) .unwrap_or_else(|| "".into()); let tooltip = if git_panel.has_staged_changes() { "Commit staged changes" diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 0ec3b93a2d..77700d4bda 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -4,7 +4,7 @@ use crate::repository_selector::RepositorySelectorPopoverMenu; use crate::{ git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector, }; -use crate::{project_diff, ProjectDiff}; +use crate::{picker_prompt, project_diff, ProjectDiff}; use collections::HashMap; use db::kvp::KEY_VALUE_STORE; use editor::commit_tooltip::CommitTooltip; @@ -12,9 +12,9 @@ use editor::{ scroll::ScrollbarAutoHide, Editor, EditorElement, EditorMode, EditorSettings, MultiBuffer, ShowScrollbar, }; -use git::repository::{CommitDetails, ResetMode}; +use git::repository::{Branch, CommitDetails, PushOptions, Remote, ResetMode, UpstreamTracking}; use git::{repository::RepoPath, status::FileStatus, Commit, ToggleStaged}; -use git::{RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll}; +use git::{Push, RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll}; use gpui::*; use itertools::Itertools; use language::{Buffer, File}; @@ -27,6 +27,9 @@ use project::{ }; use serde::{Deserialize, Serialize}; use settings::Settings as _; +use std::cell::RefCell; +use std::future::Future; +use std::rc::Rc; use std::{collections::HashSet, path::PathBuf, sync::Arc, time::Duration, usize}; use strum::{IntoEnumIterator, VariantNames}; use time::OffsetDateTime; @@ -34,7 +37,7 @@ use ui::{ prelude::*, ButtonLike, Checkbox, ContextMenu, Divider, DividerColor, ElevationIndex, ListItem, ListItemSpacing, Scrollbar, ScrollbarState, Tooltip, }; -use util::{maybe, ResultExt, TryFutureExt}; +use util::{maybe, post_inc, ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, notifications::{DetachAndPromptErr, NotificationId}, @@ -174,7 +177,11 @@ struct PendingOperation { op_id: usize, } +type RemoteOperations = Rc>>; + pub struct GitPanel { + remote_operation_id: u32, + pending_remote_operations: RemoteOperations, pub(crate) active_repository: Option>, commit_editor: Entity, conflicted_count: usize, @@ -206,6 +213,17 @@ pub struct GitPanel { modal_open: bool, } +struct RemoteOperationGuard { + id: u32, + pending_remote_operations: RemoteOperations, +} + +impl Drop for RemoteOperationGuard { + fn drop(&mut self) { + self.pending_remote_operations.borrow_mut().remove(&self.id); + } +} + pub(crate) fn commit_message_editor( commit_message_buffer: Entity, project: Entity, @@ -286,6 +304,8 @@ impl GitPanel { cx.new(|cx| RepositorySelector::new(project.clone(), window, cx)); let mut git_panel = Self { + pending_remote_operations: Default::default(), + remote_operation_id: 0, active_repository, commit_editor, conflicted_count: 0, @@ -341,6 +361,16 @@ impl GitPanel { cx.notify(); } + fn start_remote_operation(&mut self) -> RemoteOperationGuard { + let id = post_inc(&mut self.remote_operation_id); + self.pending_remote_operations.borrow_mut().insert(id); + + RemoteOperationGuard { + id, + pending_remote_operations: self.pending_remote_operations.clone(), + } + } + fn serialize(&mut self, cx: &mut Context) { let width = self.width; self.pending_serialization = cx.background_spawn( @@ -1121,23 +1151,44 @@ impl GitPanel { let Some(repo) = self.active_repository.clone() else { return; }; + + // TODO: Use git merge-base to find the upstream and main branch split + let confirmation = Task::ready(true); + // let confirmation = if self.commit_editor.read(cx).is_empty(cx) { + // Task::ready(true) + // } else { + // let prompt = window.prompt( + // PromptLevel::Warning, + // "Uncomitting will replace the current commit message with the previous commit's message", + // None, + // &["Ok", "Cancel"], + // cx, + // ); + // cx.spawn(|_, _| async move { prompt.await.is_ok_and(|i| i == 0) }) + // }; + let prior_head = self.load_commit_details("HEAD", cx); - let task = cx.spawn(|_, mut cx| async move { - let prior_head = prior_head.await?; - - repo.update(&mut cx, |repo, _| repo.reset("HEAD^", ResetMode::Soft))? - .await??; - - Ok(prior_head) - }); - let task = cx.spawn_in(window, |this, mut cx| async move { - let result = task.await; + let result = maybe!(async { + if !confirmation.await { + Ok(None) + } else { + let prior_head = prior_head.await?; + + repo.update(&mut cx, |repo, _| repo.reset("HEAD^", ResetMode::Soft))? + .await??; + + Ok(Some(prior_head)) + } + }) + .await; + this.update_in(&mut cx, |this, window, cx| { this.pending_commit.take(); match result { - Ok(prior_commit) => { + Ok(None) => {} + Ok(Some(prior_commit)) => { this.commit_editor.update(cx, |editor, cx| { editor.set_text(prior_commit.message, window, cx) }); @@ -1151,6 +1202,125 @@ impl GitPanel { self.pending_commit = Some(task); } + fn fetch(&mut self, _: &git::Fetch, _window: &mut Window, cx: &mut Context) { + let Some(repo) = self.active_repository.clone() else { + return; + }; + let guard = self.start_remote_operation(); + let fetch = repo.read(cx).fetch(); + cx.spawn(|_, _| async move { + fetch.await??; + drop(guard); + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn pull(&mut self, _: &git::Pull, window: &mut Window, cx: &mut Context) { + let guard = self.start_remote_operation(); + let remote = self.get_current_remote(window, cx); + cx.spawn(move |this, mut cx| async move { + let remote = remote.await?; + + this.update(&mut cx, |this, cx| { + let Some(repo) = this.active_repository.clone() else { + return Err(anyhow::anyhow!("No active repository")); + }; + + let Some(branch) = repo.read(cx).current_branch() else { + return Err(anyhow::anyhow!("No active branch")); + }; + + Ok(repo.read(cx).pull(branch.name.clone(), remote.name)) + })?? + .await??; + + drop(guard); + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn push(&mut self, action: &git::Push, window: &mut Window, cx: &mut Context) { + let guard = self.start_remote_operation(); + let options = action.options; + let remote = self.get_current_remote(window, cx); + cx.spawn(move |this, mut cx| async move { + let remote = remote.await?; + + this.update(&mut cx, |this, cx| { + let Some(repo) = this.active_repository.clone() else { + return Err(anyhow::anyhow!("No active repository")); + }; + + let Some(branch) = repo.read(cx).current_branch() else { + return Err(anyhow::anyhow!("No active branch")); + }; + + Ok(repo + .read(cx) + .push(branch.name.clone(), remote.name, options)) + })?? + .await??; + + drop(guard); + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn get_current_remote( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> impl Future> { + let repo = self.active_repository.clone(); + let workspace = self.workspace.clone(); + let mut cx = window.to_async(cx); + + async move { + let Some(repo) = repo else { + return Err(anyhow::anyhow!("No active repository")); + }; + + let mut current_remotes: Vec = repo + .update(&mut cx, |repo, cx| { + let Some(current_branch) = repo.current_branch() else { + return Err(anyhow::anyhow!("No active branch")); + }; + + Ok(repo.get_remotes(Some(current_branch.name.to_string()), cx)) + })?? + .await?; + + if current_remotes.len() == 0 { + return Err(anyhow::anyhow!("No active remote")); + } else if current_remotes.len() == 1 { + return Ok(current_remotes.pop().unwrap()); + } else { + let current_remotes: Vec<_> = current_remotes + .into_iter() + .map(|remotes| remotes.name) + .collect(); + let selection = cx + .update(|window, cx| { + picker_prompt::prompt( + "Pick which remote to push to", + current_remotes.clone(), + workspace, + window, + cx, + ) + })? + .await?; + + return Ok(Remote { + name: current_remotes[selection].clone(), + }); + } + } + } + fn potential_co_authors(&self, cx: &App) -> Vec<(String, String)> { let mut new_co_authors = Vec::new(); let project = self.project.read(cx); @@ -1591,15 +1761,23 @@ impl GitPanel { .color(Color::Muted), ) .child(self.render_repository_selector(cx)) - .child(div().flex_grow()) + .child(div().flex_grow()) // spacer .child( - Button::new("diff", "+/-") - .tooltip(Tooltip::for_action_title("Open diff", &Diff)) - .on_click(|_, _, cx| { - cx.defer(|cx| { - cx.dispatch_action(&Diff); - }) - }), + div() + .h_flex() + .gap_1() + .children(self.render_spinner(cx)) + .children(self.render_sync_button(cx)) + .children(self.render_pull_button(cx)) + .child( + Button::new("diff", "+/-") + .tooltip(Tooltip::for_action_title("Open diff", &Diff)) + .on_click(|_, _, cx| { + cx.defer(|cx| { + cx.dispatch_action(&Diff); + }) + }), + ), ), ) } else { @@ -1607,6 +1785,74 @@ impl GitPanel { } } + pub fn render_spinner(&self, _cx: &mut Context) -> Option { + (!self.pending_remote_operations.borrow().is_empty()).then(|| { + Icon::new(IconName::ArrowCircle) + .size(IconSize::XSmall) + .color(Color::Info) + .with_animation( + "arrow-circle", + Animation::new(Duration::from_secs(2)).repeat(), + |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), + ) + .into_any_element() + }) + } + + pub fn render_sync_button(&self, cx: &mut Context) -> Option { + let active_repository = self.project.read(cx).active_repository(cx); + active_repository.as_ref().map(|_| { + panel_filled_button("Fetch") + .icon(IconName::ArrowCircle) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .tooltip(Tooltip::for_action_title("git fetch", &git::Fetch)) + .on_click( + cx.listener(move |this, _, window, cx| this.fetch(&git::Fetch, window, cx)), + ) + .into_any_element() + }) + } + + pub fn render_pull_button(&self, cx: &mut Context) -> Option { + let active_repository = self.project.read(cx).active_repository(cx); + active_repository + .as_ref() + .and_then(|repo| repo.read(cx).current_branch()) + .and_then(|branch| { + branch.upstream.as_ref().map(|upstream| { + let status = &upstream.tracking; + + let disabled = status.is_gone(); + + panel_filled_button(match status { + git::repository::UpstreamTracking::Tracked(status) if status.behind > 0 => { + format!("Pull ({})", status.behind) + } + _ => "Pull".to_string(), + }) + .icon(IconName::ArrowDown) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .disabled(status.is_gone()) + .tooltip(move |window, cx| { + if disabled { + Tooltip::simple("Upstream is gone", cx) + } else { + // TODO: Add and argument substitutions to this + Tooltip::for_action("git pull", &git::Pull, window, cx) + } + }) + .on_click( + cx.listener(move |this, _, window, cx| this.pull(&git::Pull, window, cx)), + ) + .into_any_element() + }) + }) + } + pub fn render_repository_selector(&self, cx: &mut Context) -> impl IntoElement { let active_repository = self.project.read(cx).active_repository(cx); let repository_display_name = active_repository @@ -1679,18 +1925,19 @@ impl GitPanel { && self.pending_commit.is_none() && !editor.read(cx).is_empty(cx) && self.has_write_access(cx); + let panel_editor_style = panel_editor_style(true, window, cx); let enable_coauthors = self.render_co_authors(cx); let tooltip = if self.has_staged_changes() { - "Commit staged changes" + "git commit" } else { - "Commit changes to tracked files" + "git commit --all" }; let title = if self.has_staged_changes() { "Commit" } else { - "Commit All" + "Commit Tracked" }; let editor_focus_handle = self.commit_editor.focus_handle(cx); @@ -1706,7 +1953,7 @@ impl GitPanel { let branch = self .active_repository .as_ref() - .and_then(|repo| repo.read(cx).branch().map(|b| b.name.clone())) + .and_then(|repo| repo.read(cx).current_branch().map(|b| b.name.clone())) .unwrap_or_else(|| "".into()); let branch_selector = Button::new("branch-selector", branch) @@ -1769,24 +2016,9 @@ impl GitPanel { fn render_previous_commit(&self, cx: &mut Context) -> Option { let active_repository = self.active_repository.as_ref()?; - let branch = active_repository.read(cx).branch()?; + let branch = active_repository.read(cx).current_branch()?; let commit = branch.most_recent_commit.as_ref()?.clone(); - if branch.upstream.as_ref().is_some_and(|upstream| { - if let Some(tracking) = &upstream.tracking { - tracking.ahead == 0 - } else { - true - } - }) { - return None; - } - let tooltip = if self.has_staged_changes() { - "git reset HEAD^ --soft" - } else { - "git reset HEAD^" - }; - let this = cx.entity(); Some( h_flex() @@ -1826,9 +2058,17 @@ impl GitPanel { .icon_size(IconSize::Small) .icon_color(Color::Muted) .icon_position(IconPosition::Start) - .tooltip(Tooltip::for_action_title(tooltip, &git::Uncommit)) + .tooltip(Tooltip::for_action_title( + if self.has_staged_changes() { + "git reset HEAD^ --soft" + } else { + "git reset HEAD^" + }, + &git::Uncommit, + )) .on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))), - ), + ) + .child(self.render_push_button(branch, cx)), ) } @@ -2249,6 +2489,69 @@ impl GitPanel { .into_any_element() } + fn render_push_button(&self, branch: &Branch, cx: &Context) -> AnyElement { + let mut disabled = false; + + // TODO: Add and argument substitutions to this + let button: SharedString; + let tooltip: SharedString; + let action: Option; + if let Some(upstream) = &branch.upstream { + match upstream.tracking { + UpstreamTracking::Gone => { + button = "Republish".into(); + tooltip = "git push --set-upstream".into(); + action = Some(git::Push { + options: Some(PushOptions::SetUpstream), + }); + } + UpstreamTracking::Tracked(tracking) => { + if tracking.behind > 0 { + disabled = true; + button = "Push".into(); + tooltip = "Upstream is ahead of local branch".into(); + action = None; + } else if tracking.ahead > 0 { + button = format!("Push ({})", tracking.ahead).into(); + tooltip = "git push".into(); + action = Some(git::Push { options: None }); + } else { + disabled = true; + button = "Push".into(); + tooltip = "Upstream matches local branch".into(); + action = None; + } + } + } + } else { + button = "Publish".into(); + tooltip = "git push --set-upstream".into(); + action = Some(git::Push { + options: Some(PushOptions::SetUpstream), + }); + }; + + panel_filled_button(button) + .icon(IconName::ArrowUp) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .disabled(disabled) + .when_some(action, |this, action| { + this.on_click( + cx.listener(move |this, _, window, cx| this.push(&action, window, cx)), + ) + }) + .tooltip(move |window, cx| { + if let Some(action) = action.as_ref() { + Tooltip::for_action(tooltip.clone(), action, window, cx) + } else { + Tooltip::simple(tooltip.clone(), cx) + } + }) + .into_any_element() + } + fn has_write_access(&self, cx: &App) -> bool { !self.project.read(cx).is_read_only(cx) } @@ -2298,6 +2601,9 @@ impl Render for GitPanel { .on_action(cx.listener(Self::unstage_all)) .on_action(cx.listener(Self::discard_tracked_changes)) .on_action(cx.listener(Self::clean_all)) + .on_action(cx.listener(Self::fetch)) + .on_action(cx.listener(Self::pull)) + .on_action(cx.listener(Self::push)) .when(has_write_access && has_co_authors, |git_panel| { git_panel.on_action(cx.listener(Self::toggle_fill_co_authors)) }) @@ -2314,17 +2620,21 @@ impl Render for GitPanel { .size_full() .overflow_hidden() .bg(ElevationIndex::Surface.bg(cx)) - .child(if has_entries { + .child( v_flex() .size_full() .children(self.render_panel_header(window, cx)) - .child(self.render_entries(has_write_access, window, cx)) + .map(|this| { + if has_entries { + this.child(self.render_entries(has_write_access, window, cx)) + } else { + this.child(self.render_empty_state(cx).into_any_element()) + } + }) .children(self.render_previous_commit(cx)) .child(self.render_commit_editor(window, cx)) - .into_any_element() - } else { - self.render_empty_state(cx).into_any_element() - }) + .into_any_element(), + ) .children(self.context_menu.as_ref().map(|(menu, position, _)| { deferred( anchored() diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 7cca2b23a5..7e74fa788c 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -9,6 +9,7 @@ pub mod branch_picker; mod commit_modal; pub mod git_panel; mod git_panel_settings; +pub mod picker_prompt; pub mod project_diff; pub mod repository_selector; diff --git a/crates/git_ui/src/picker_prompt.rs b/crates/git_ui/src/picker_prompt.rs new file mode 100644 index 0000000000..f565b1a768 --- /dev/null +++ b/crates/git_ui/src/picker_prompt.rs @@ -0,0 +1,235 @@ +use anyhow::{anyhow, Result}; +use futures::channel::oneshot; +use fuzzy::{StringMatch, StringMatchCandidate}; + +use core::cmp; +use gpui::{ + rems, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, + InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, + Task, WeakEntity, Window, +}; +use picker::{Picker, PickerDelegate}; +use std::sync::Arc; +use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing}; +use util::ResultExt; +use workspace::{ModalView, Workspace}; + +pub struct PickerPrompt { + pub picker: Entity>, + rem_width: f32, + _subscription: Subscription, +} + +pub fn prompt( + prompt: &str, + options: Vec, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, +) -> Task> { + if options.is_empty() { + return Task::ready(Err(anyhow!("No options"))); + } + let prompt = prompt.to_string().into(); + + window.spawn(cx, |mut cx| async move { + // Modal branch picker has a longer trailoff than a popover one. + let (tx, rx) = oneshot::channel(); + let delegate = PickerPromptDelegate::new(prompt, options, tx, 70); + + workspace.update_in(&mut cx, |workspace, window, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + PickerPrompt::new(delegate, 34., window, cx) + }) + })?; + + rx.await? + }) +} + +impl PickerPrompt { + fn new( + delegate: PickerPromptDelegate, + rem_width: f32, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); + let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent)); + Self { + picker, + rem_width, + _subscription, + } + } +} +impl ModalView for PickerPrompt {} +impl EventEmitter for PickerPrompt {} + +impl Focusable for PickerPrompt { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.picker.focus_handle(cx) + } +} + +impl Render for PickerPrompt { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .w(rems(self.rem_width)) + .child(self.picker.clone()) + .on_mouse_down_out(cx.listener(|this, _, window, cx| { + this.picker.update(cx, |this, cx| { + this.cancel(&Default::default(), window, cx); + }) + })) + } +} + +pub struct PickerPromptDelegate { + prompt: Arc, + matches: Vec, + all_options: Vec, + selected_index: usize, + max_match_length: usize, + tx: Option>>, +} + +impl PickerPromptDelegate { + pub fn new( + prompt: Arc, + options: Vec, + tx: oneshot::Sender>, + max_chars: usize, + ) -> Self { + Self { + prompt, + all_options: options, + matches: vec![], + selected_index: 0, + max_match_length: max_chars, + tx: Some(tx), + } + } +} + +impl PickerDelegate for PickerPromptDelegate { + type ListItem = ListItem; + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + self.prompt.clone() + } + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index( + &mut self, + ix: usize, + _window: &mut Window, + _: &mut Context>, + ) { + self.selected_index = ix; + } + + fn update_matches( + &mut self, + query: String, + window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + cx.spawn_in(window, move |picker, mut cx| async move { + let candidates = picker.update(&mut cx, |picker, _| { + picker + .delegate + .all_options + .iter() + .enumerate() + .map(|(ix, option)| StringMatchCandidate::new(ix, &option)) + .collect::>() + }); + let Some(candidates) = candidates.log_err() else { + return; + }; + let matches: Vec = if query.is_empty() { + candidates + .into_iter() + .enumerate() + .map(|(index, candidate)| StringMatch { + candidate_id: index, + string: candidate.string, + positions: Vec::new(), + score: 0.0, + }) + .collect() + } else { + fuzzy::match_strings( + &candidates, + &query, + true, + 10000, + &Default::default(), + cx.background_executor().clone(), + ) + .await + }; + picker + .update(&mut cx, |picker, _| { + let delegate = &mut picker.delegate; + delegate.matches = matches; + if delegate.matches.is_empty() { + delegate.selected_index = 0; + } else { + delegate.selected_index = + cmp::min(delegate.selected_index, delegate.matches.len() - 1); + } + }) + .log_err(); + }) + } + + fn confirm(&mut self, _: bool, _window: &mut Window, cx: &mut Context>) { + let Some(option) = self.matches.get(self.selected_index()) else { + return; + }; + + self.tx.take().map(|tx| tx.send(Ok(option.candidate_id))); + cx.emit(DismissEvent); + } + + fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { + cx.emit(DismissEvent); + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _window: &mut Window, + _cx: &mut Context>, + ) -> Option { + let hit = &self.matches[ix]; + let shortened_option = util::truncate_and_trailoff(&hit.string, self.max_match_length); + + Some( + ListItem::new(SharedString::from(format!("picker-prompt-menu-{ix}"))) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .map(|el| { + let highlights: Vec<_> = hit + .positions + .iter() + .filter(|index| index < &&self.max_match_length) + .copied() + .collect(); + + el.child(HighlightedLabel::new(shortened_option, highlights)) + }), + ) + } +} diff --git a/crates/project/src/git.rs b/crates/project/src/git.rs index 505faba60c..84b4dd9ff9 100644 --- a/crates/project/src/git.rs +++ b/crates/project/src/git.rs @@ -5,7 +5,7 @@ use anyhow::{Context as _, Result}; use client::ProjectId; use futures::channel::{mpsc, oneshot}; use futures::StreamExt as _; -use git::repository::{Branch, CommitDetails, ResetMode}; +use git::repository::{Branch, CommitDetails, PushOptions, Remote, ResetMode}; use git::{ repository::{GitRepository, RepoPath}, status::{GitSummary, TrackedSummary}, @@ -74,6 +74,18 @@ pub enum Message { Stage(GitRepo, Vec), Unstage(GitRepo, Vec), SetIndexText(GitRepo, RepoPath, Option), + Push { + repo: GitRepo, + branch_name: SharedString, + remote_name: SharedString, + options: Option, + }, + Pull { + repo: GitRepo, + branch_name: SharedString, + remote_name: SharedString, + }, + Fetch(GitRepo), } pub enum GitEvent { @@ -107,6 +119,10 @@ impl GitStore { } pub fn init(client: &AnyProtoClient) { + client.add_entity_request_handler(Self::handle_get_remotes); + client.add_entity_request_handler(Self::handle_push); + client.add_entity_request_handler(Self::handle_pull); + client.add_entity_request_handler(Self::handle_fetch); client.add_entity_request_handler(Self::handle_stage); client.add_entity_request_handler(Self::handle_unstage); client.add_entity_request_handler(Self::handle_commit); @@ -242,8 +258,10 @@ impl GitStore { mpsc::unbounded::<(Message, oneshot::Sender>)>(); cx.spawn(|_, cx| async move { while let Some((msg, respond)) = update_receiver.next().await { - let result = cx.background_spawn(Self::process_git_msg(msg)).await; - respond.send(result).ok(); + if !respond.is_canceled() { + let result = cx.background_spawn(Self::process_git_msg(msg)).await; + respond.send(result).ok(); + } } }) .detach(); @@ -252,6 +270,94 @@ impl GitStore { async fn process_git_msg(msg: Message) -> Result<()> { match msg { + Message::Fetch(repo) => { + match repo { + GitRepo::Local(git_repository) => git_repository.fetch()?, + GitRepo::Remote { + project_id, + client, + worktree_id, + work_directory_id, + } => { + client + .request(proto::Fetch { + project_id: project_id.0, + worktree_id: worktree_id.to_proto(), + work_directory_id: work_directory_id.to_proto(), + }) + .await + .context("sending fetch request")?; + } + } + Ok(()) + } + + Message::Pull { + repo, + branch_name, + remote_name, + } => { + match repo { + GitRepo::Local(git_repository) => { + git_repository.pull(&branch_name, &remote_name)? + } + GitRepo::Remote { + project_id, + client, + worktree_id, + work_directory_id, + } => { + client + .request(proto::Pull { + project_id: project_id.0, + worktree_id: worktree_id.to_proto(), + work_directory_id: work_directory_id.to_proto(), + branch_name: branch_name.to_string(), + remote_name: remote_name.to_string(), + }) + .await + .context("sending pull request")?; + } + } + Ok(()) + } + Message::Push { + repo, + branch_name, + remote_name, + options, + } => { + match repo { + GitRepo::Local(git_repository) => { + git_repository.push(&branch_name, &remote_name, options)? + } + GitRepo::Remote { + project_id, + client, + worktree_id, + work_directory_id, + } => { + client + .request(proto::Push { + project_id: project_id.0, + worktree_id: worktree_id.to_proto(), + work_directory_id: work_directory_id.to_proto(), + branch_name: branch_name.to_string(), + remote_name: remote_name.to_string(), + options: options.map(|options| match options { + PushOptions::Force => proto::push::PushOptions::Force, + PushOptions::SetUpstream => { + proto::push::PushOptions::SetUpstream + } + } + as i32), + }) + .await + .context("sending push request")?; + } + } + Ok(()) + } Message::Stage(repo, paths) => { match repo { GitRepo::Local(repo) => repo.stage_paths(&paths)?, @@ -413,6 +519,73 @@ impl GitStore { } } + async fn handle_fetch( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> 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 = + Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; + + repository_handle + .update(&mut cx, |repository_handle, _cx| repository_handle.fetch())? + .await??; + Ok(proto::Ack {}) + } + + async fn handle_push( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> 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 = + Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; + + let options = envelope + .payload + .options + .as_ref() + .map(|_| match envelope.payload.options() { + proto::push::PushOptions::SetUpstream => git::repository::PushOptions::SetUpstream, + proto::push::PushOptions::Force => git::repository::PushOptions::Force, + }); + + let branch_name = envelope.payload.branch_name.into(); + let remote_name = envelope.payload.remote_name.into(); + + repository_handle + .update(&mut cx, |repository_handle, _cx| { + repository_handle.push(branch_name, remote_name, options) + })? + .await??; + Ok(proto::Ack {}) + } + + async fn handle_pull( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> 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 = + Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; + + let branch_name = envelope.payload.branch_name.into(); + let remote_name = envelope.payload.remote_name.into(); + + repository_handle + .update(&mut cx, |repository_handle, _cx| { + repository_handle.pull(branch_name, remote_name) + })? + .await??; + Ok(proto::Ack {}) + } + async fn handle_stage( this: Entity, envelope: TypedEnvelope, @@ -509,6 +682,34 @@ impl GitStore { Ok(proto::Ack {}) } + async fn handle_get_remotes( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> 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 = + Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?; + + let branch_name = envelope.payload.branch_name; + + let remotes = repository_handle + .update(&mut cx, |repository_handle, cx| { + repository_handle.get_remotes(branch_name, cx) + })? + .await?; + + Ok(proto::GetRemotesResponse { + remotes: remotes + .into_iter() + .map(|remotes| proto::get_remotes_response::Remote { + name: remotes.name.to_string(), + }) + .collect::>(), + }) + } + async fn handle_show( this: Entity, envelope: TypedEnvelope, @@ -648,7 +849,7 @@ impl Repository { (self.worktree_id, self.repository_entry.work_directory_id()) } - pub fn branch(&self) -> Option<&Branch> { + pub fn current_branch(&self) -> Option<&Branch> { self.repository_entry.branch() } @@ -802,35 +1003,19 @@ impl Repository { commit: &str, paths: Vec, ) -> oneshot::Receiver> { - let (result_tx, result_rx) = futures::channel::oneshot::channel(); - let commit = commit.to_string().into(); - self.update_sender - .unbounded_send(( - Message::CheckoutFiles { - repo: self.git_repo.clone(), - commit, - paths, - }, - result_tx, - )) - .ok(); - result_rx + self.send_message(Message::CheckoutFiles { + repo: self.git_repo.clone(), + commit: commit.to_string().into(), + paths, + }) } pub fn reset(&self, commit: &str, reset_mode: ResetMode) -> oneshot::Receiver> { - let (result_tx, result_rx) = futures::channel::oneshot::channel(); - let commit = commit.to_string().into(); - self.update_sender - .unbounded_send(( - Message::Reset { - repo: self.git_repo.clone(), - commit, - reset_mode, - }, - result_tx, - )) - .ok(); - result_rx + self.send_message(Message::Reset { + repo: self.git_repo.clone(), + commit: commit.to_string().into(), + reset_mode, + }) } pub fn show(&self, commit: &str, cx: &Context) -> Task> { @@ -987,18 +1172,41 @@ impl Repository { message: SharedString, name_and_email: Option<(SharedString, SharedString)>, ) -> oneshot::Receiver> { - let (result_tx, result_rx) = futures::channel::oneshot::channel(); - self.update_sender - .unbounded_send(( - Message::Commit { - git_repo: self.git_repo.clone(), - message, - name_and_email, - }, - result_tx, - )) - .ok(); - result_rx + self.send_message(Message::Commit { + git_repo: self.git_repo.clone(), + message, + name_and_email, + }) + } + + pub fn fetch(&self) -> oneshot::Receiver> { + self.send_message(Message::Fetch(self.git_repo.clone())) + } + + pub fn push( + &self, + branch: SharedString, + remote: SharedString, + options: Option, + ) -> oneshot::Receiver> { + self.send_message(Message::Push { + repo: self.git_repo.clone(), + branch_name: branch, + remote_name: remote, + options, + }) + } + + pub fn pull( + &self, + branch: SharedString, + remote: SharedString, + ) -> oneshot::Receiver> { + self.send_message(Message::Pull { + repo: self.git_repo.clone(), + branch_name: branch, + remote_name: remote, + }) } pub fn set_index_text( @@ -1006,13 +1214,49 @@ impl Repository { path: &RepoPath, content: Option, ) -> oneshot::Receiver> { + self.send_message(Message::SetIndexText( + self.git_repo.clone(), + path.clone(), + content, + )) + } + + pub fn get_remotes(&self, branch_name: Option, cx: &App) -> Task>> { + match self.git_repo.clone() { + GitRepo::Local(git_repository) => { + cx.background_spawn( + async move { git_repository.get_remotes(branch_name.as_deref()) }, + ) + } + GitRepo::Remote { + project_id, + client, + worktree_id, + work_directory_id, + } => cx.background_spawn(async move { + let response = client + .request(proto::GetRemotes { + project_id: project_id.0, + worktree_id: worktree_id.to_proto(), + work_directory_id: work_directory_id.to_proto(), + branch_name, + }) + .await?; + + Ok(response + .remotes + .into_iter() + .map(|remotes| git::repository::Remote { + name: remotes.name.into(), + }) + .collect()) + }), + } + } + + fn send_message(&self, message: Message) -> oneshot::Receiver> { let (result_tx, result_rx) = futures::channel::oneshot::channel(); - self.update_sender - .unbounded_send(( - Message::SetIndexText(self.git_repo.clone(), path.clone(), content), - result_tx, - )) - .ok(); + self.update_sender.unbounded_send((message, result_tx)).ok(); result_rx } } diff --git a/crates/project/src/worktree_store.rs b/crates/project/src/worktree_store.rs index e2f8541161..dd4a79edd3 100644 --- a/crates/project/src/worktree_store.rs +++ b/crates/project/src/worktree_store.rs @@ -946,12 +946,17 @@ impl WorktreeStore { upstream: proto_branch.upstream.map(|upstream| { git::repository::Upstream { ref_name: upstream.ref_name.into(), - tracking: upstream.tracking.map(|tracking| { - git::repository::UpstreamTracking { - ahead: tracking.ahead as u32, - behind: tracking.behind as u32, - } - }), + tracking: upstream + .tracking + .map(|tracking| { + git::repository::UpstreamTracking::Tracked( + git::repository::UpstreamTrackingStatus { + ahead: tracking.ahead as u32, + behind: tracking.behind as u32, + }, + ) + }) + .unwrap_or(git::repository::UpstreamTracking::Gone), } }), most_recent_commit: proto_branch.most_recent_commit.map(|commit| { diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 2a708a7e31..fc773cccd3 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -321,7 +321,13 @@ message Envelope { GitCommitDetails git_commit_details = 302; SetIndexText set_index_text = 299; - GitCheckoutFiles git_checkout_files = 303; // current max + GitCheckoutFiles git_checkout_files = 303; + + Push push = 304; + Fetch fetch = 305; + GetRemotes get_remotes = 306; + GetRemotesResponse get_remotes_response = 307; + Pull pull = 308; // current max } reserved 87 to 88; @@ -2772,3 +2778,46 @@ message OpenCommitMessageBuffer { uint64 worktree_id = 2; uint64 work_directory_id = 3; } + +message Push { + uint64 project_id = 1; + uint64 worktree_id = 2; + uint64 work_directory_id = 3; + string remote_name = 4; + string branch_name = 5; + optional PushOptions options = 6; + + enum PushOptions { + SET_UPSTREAM = 0; + FORCE = 1; + } +} + +message Fetch { + uint64 project_id = 1; + uint64 worktree_id = 2; + uint64 work_directory_id = 3; +} + +message GetRemotes { + uint64 project_id = 1; + uint64 worktree_id = 2; + uint64 work_directory_id = 3; + optional string branch_name = 4; +} + +message GetRemotesResponse { + repeated Remote remotes = 1; + + message Remote { + string name = 1; + } +} + +message Pull { + uint64 project_id = 1; + uint64 worktree_id = 2; + uint64 work_directory_id = 3; + string remote_name = 4; + string branch_name = 5; +} diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 88737ed50e..13c5229ec3 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -445,6 +445,11 @@ messages!( (GitShow, Background), (GitCommitDetails, Background), (SetIndexText, Background), + (Push, Background), + (Fetch, Background), + (GetRemotes, Background), + (GetRemotesResponse, Background), + (Pull, Background), ); request_messages!( @@ -582,6 +587,10 @@ request_messages!( (GitReset, Ack), (GitCheckoutFiles, Ack), (SetIndexText, Ack), + (Push, Ack), + (Fetch, Ack), + (GetRemotes, GetRemotesResponse), + (Pull, Ack), ); entity_messages!( @@ -678,6 +687,10 @@ entity_messages!( GitReset, GitCheckoutFiles, SetIndexText, + Push, + Fetch, + GetRemotes, + Pull, ); entity_messages!( diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index de8b052bd1..52e2ae5554 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -20,7 +20,7 @@ use futures::{ }; use fuzzy::CharBag; use git::{ - repository::{Branch, GitRepository, RepoPath}, + repository::{Branch, GitRepository, RepoPath, UpstreamTrackingStatus}, status::{ FileStatus, GitSummary, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode, }, @@ -202,7 +202,7 @@ pub struct RepositoryEntry { pub(crate) statuses_by_path: SumTree, work_directory_id: ProjectEntryId, pub work_directory: WorkDirectory, - pub(crate) branch: Option, + pub(crate) current_branch: Option, pub current_merge_conflicts: TreeSet, } @@ -216,7 +216,7 @@ impl Deref for RepositoryEntry { impl RepositoryEntry { pub fn branch(&self) -> Option<&Branch> { - self.branch.as_ref() + self.current_branch.as_ref() } pub fn work_directory_id(&self) -> ProjectEntryId { @@ -244,8 +244,11 @@ impl RepositoryEntry { pub fn initial_update(&self) -> proto::RepositoryEntry { proto::RepositoryEntry { work_directory_id: self.work_directory_id.to_proto(), - branch: self.branch.as_ref().map(|branch| branch.name.to_string()), - branch_summary: self.branch.as_ref().map(branch_to_proto), + branch: self + .current_branch + .as_ref() + .map(|branch| branch.name.to_string()), + branch_summary: self.current_branch.as_ref().map(branch_to_proto), updated_statuses: self .statuses_by_path .iter() @@ -304,8 +307,11 @@ impl RepositoryEntry { proto::RepositoryEntry { work_directory_id: self.work_directory_id.to_proto(), - branch: self.branch.as_ref().map(|branch| branch.name.to_string()), - branch_summary: self.branch.as_ref().map(branch_to_proto), + branch: self + .current_branch + .as_ref() + .map(|branch| branch.name.to_string()), + branch_summary: self.current_branch.as_ref().map(branch_to_proto), updated_statuses, removed_statuses, current_merge_conflicts: self @@ -329,7 +335,7 @@ pub fn branch_to_proto(branch: &git::repository::Branch) -> proto::Branch { ref_name: upstream.ref_name.to_string(), tracking: upstream .tracking - .as_ref() + .status() .map(|upstream| proto::UpstreamTracking { ahead: upstream.ahead as u64, behind: upstream.behind as u64, @@ -355,12 +361,16 @@ pub fn proto_to_branch(proto: &proto::Branch) -> git::repository::Branch { .as_ref() .map(|upstream| git::repository::Upstream { ref_name: upstream.ref_name.to_string().into(), - tracking: upstream.tracking.as_ref().map(|tracking| { - git::repository::UpstreamTracking { - ahead: tracking.ahead as u32, - behind: tracking.behind as u32, - } - }), + tracking: upstream + .tracking + .as_ref() + .map(|tracking| { + git::repository::UpstreamTracking::Tracked(UpstreamTrackingStatus { + ahead: tracking.ahead as u32, + behind: tracking.behind as u32, + }) + }) + .unwrap_or(git::repository::UpstreamTracking::Gone), }), most_recent_commit: proto.most_recent_commit.as_ref().map(|commit| { git::repository::CommitSummary { @@ -2682,7 +2692,8 @@ impl Snapshot { self.repositories .update(&PathKey(work_dir_entry.path.clone()), &(), |repo| { - repo.branch = repository.branch_summary.as_ref().map(proto_to_branch); + repo.current_branch = + repository.branch_summary.as_ref().map(proto_to_branch); repo.statuses_by_path.edit(edits, &()); repo.current_merge_conflicts = conflicted_paths }); @@ -2704,7 +2715,7 @@ impl Snapshot { work_directory: WorkDirectory::InProject { relative_path: work_dir_entry.path.clone(), }, - branch: repository.branch_summary.as_ref().map(proto_to_branch), + current_branch: repository.branch_summary.as_ref().map(proto_to_branch), statuses_by_path: statuses, current_merge_conflicts: conflicted_paths, }, @@ -3506,7 +3517,7 @@ impl BackgroundScannerState { RepositoryEntry { work_directory_id: work_dir_id, work_directory: work_directory.clone(), - branch: None, + current_branch: None, statuses_by_path: Default::default(), current_merge_conflicts: Default::default(), }, @@ -5580,7 +5591,7 @@ fn update_branches( let mut repository = snapshot .repository(repository.work_directory.path_key()) .context("Missing repository")?; - repository.branch = branches.into_iter().find(|branch| branch.is_head); + repository.current_branch = branches.into_iter().find(|branch| branch.is_head); let mut state = state.lock(); state