Git push/pull/fetch (#25445)

Mikayla Maki and Michael Sloan created

Release Notes:

- N/A

---------

Co-authored-by: Michael Sloan <mgsloan@gmail.com>

Change summary

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                  | 267 +++++++++++--
crates/git_ui/src/branch_picker.rs            |   2 
crates/git_ui/src/commit_modal.rs             |   7 
crates/git_ui/src/git_panel.rs                | 408 ++++++++++++++++++--
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, 1,238 insertions(+), 176 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5298,6 +5298,7 @@ dependencies = [
  "pretty_assertions",
  "regex",
  "rope",
+ "schemars",
  "serde",
  "serde_json",
  "smol",

crates/collab/src/rpc.rs 🔗

@@ -392,9 +392,13 @@ impl Server {
             .add_request_handler(forward_mutating_project_request::<proto::OpenContext>)
             .add_request_handler(forward_mutating_project_request::<proto::CreateContext>)
             .add_request_handler(forward_mutating_project_request::<proto::SynchronizeContexts>)
+            .add_request_handler(forward_mutating_project_request::<proto::Push>)
+            .add_request_handler(forward_mutating_project_request::<proto::Pull>)
+            .add_request_handler(forward_mutating_project_request::<proto::Fetch>)
             .add_request_handler(forward_mutating_project_request::<proto::Stage>)
             .add_request_handler(forward_mutating_project_request::<proto::Unstage>)
             .add_request_handler(forward_mutating_project_request::<proto::Commit>)
+            .add_request_handler(forward_read_only_project_request::<proto::GetRemotes>)
             .add_request_handler(forward_read_only_project_request::<proto::GitShow>)
             .add_request_handler(forward_read_only_project_request::<proto::GitReset>)
             .add_request_handler(forward_read_only_project_request::<proto::GitCheckoutFiles>)

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<Self>) {

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()

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

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<PushOptions>,
+}
+
+impl_actions!(git, [Push]);
+
 actions!(
     git,
     [
@@ -43,6 +53,8 @@ actions!(
         RestoreTrackedFiles,
         TrashUntrackedFiles,
         Uncommit,
+        Pull,
+        Fetch,
         Commit,
     ]
 );

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<UpstreamTrackingStatus> {
+        self.upstream
+            .as_ref()
+            .and_then(|upstream| upstream.tracking.status())
+    }
+
     pub fn priority_key(&self) -> (bool, Option<i64>) {
         (
             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<UpstreamTracking>,
+    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<UpstreamTrackingStatus> {
+        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<PushOptions>,
+    ) -> Result<()>;
+    fn pull(&self, branch_name: &str, upstream_name: &str) -> Result<()>;
+    fn get_remotes(&self, branch_name: Option<&str>) -> Result<Vec<Remote>>;
+    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<PathBuf> {
+        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<String>) -> 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,32 +570,141 @@ 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 = 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{}",
+                String::from_utf8_lossy(&output.stderr)
+            ));
+        }
+        Ok(())
+    }
+
+    fn push(
+        &self,
+        branch_name: &str,
+        remote_name: &str,
+        options: Option<PushOptions>,
+    ) -> Result<()> {
+        let working_directory = self.working_directory()?;
+
         let output = new_std_command(&self.git_binary_path)
             .current_dir(&working_directory)
-            .args(args)
+            .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 commit:\n{}",
+                "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<Vec<Remote>> {
+        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<PushOptions>) -> Result<()> {
+        unimplemented!()
+    }
+
+    fn pull(&self, _branch: &str, _remote: &str) -> Result<()> {
+        unimplemented!()
+    }
+
+    fn fetch(&self) -> Result<()> {
+        unimplemented!()
+    }
+
+    fn get_remotes(&self, _branch: Option<&str>) -> Result<Vec<Remote>> {
+        unimplemented!()
+    }
 }
 
 fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
@@ -911,9 +1073,9 @@ fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
     Ok(branches)
 }
 
-fn parse_upstream_track(upstream_track: &str) -> Result<Option<UpstreamTracking>> {
+fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
     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<Option<UpstreamTracking>
     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::<u32>()?;
@@ -938,7 +1100,10 @@ fn parse_upstream_track(upstream_track: &str) -> Result<Option<UpstreamTracking>
             behind = behind_num.parse::<u32>()?;
         }
     }
-    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
                 })

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()

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(|| "<no branch>".into());
             let tooltip = if git_panel.has_staged_changes() {
                 "Commit staged changes"

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<RefCell<HashSet<u32>>>;
+
 pub struct GitPanel {
+    remote_operation_id: u32,
+    pending_remote_operations: RemoteOperations,
     pub(crate) active_repository: Option<Entity<Repository>>,
     commit_editor: Entity<Editor>,
     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<Buffer>,
     project: Entity<Project>,
@@ -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<Self>) {
         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?;
+        let task = cx.spawn_in(window, |this, mut cx| async move {
+            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??;
+                    repo.update(&mut cx, |repo, _| repo.reset("HEAD^", ResetMode::Soft))?
+                        .await??;
 
-            Ok(prior_head)
-        });
+                    Ok(Some(prior_head))
+                }
+            })
+            .await;
 
-        let task = cx.spawn_in(window, |this, mut cx| async move {
-            let result = task.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<Self>) {
+        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<Self>) {
+        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<Self>) {
+        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<Self>,
+    ) -> impl Future<Output = Result<Remote>> {
+        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<Remote> = 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<Self>) -> Option<impl IntoElement> {
+        (!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<Self>) -> Option<impl IntoElement> {
+        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<Self>) -> Option<impl IntoElement> {
+        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 <origin> and <branch> 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<Self>) -> 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(|| "<no branch>".into());
 
         let branch_selector = Button::new("branch-selector", branch)
@@ -1769,24 +2016,9 @@ impl GitPanel {
 
     fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
         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<Self>) -> AnyElement {
+        let mut disabled = false;
+
+        // TODO: Add <origin> and <branch> argument substitutions to this
+        let button: SharedString;
+        let tooltip: SharedString;
+        let action: Option<Push>;
+        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()

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;
 

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<Picker<PickerPromptDelegate>>,
+    rem_width: f32,
+    _subscription: Subscription,
+}
+
+pub fn prompt(
+    prompt: &str,
+    options: Vec<SharedString>,
+    workspace: WeakEntity<Workspace>,
+    window: &mut Window,
+    cx: &mut App,
+) -> Task<Result<usize>> {
+    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>,
+    ) -> 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<DismissEvent> 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<Self>) -> 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<str>,
+    matches: Vec<StringMatch>,
+    all_options: Vec<SharedString>,
+    selected_index: usize,
+    max_match_length: usize,
+    tx: Option<oneshot::Sender<Result<usize>>>,
+}
+
+impl PickerPromptDelegate {
+    pub fn new(
+        prompt: Arc<str>,
+        options: Vec<SharedString>,
+        tx: oneshot::Sender<Result<usize>>,
+        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<str> {
+        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<Picker<Self>>,
+    ) {
+        self.selected_index = ix;
+    }
+
+    fn update_matches(
+        &mut self,
+        query: String,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> 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::<Vec<StringMatchCandidate>>()
+            });
+            let Some(candidates) = candidates.log_err() else {
+                return;
+            };
+            let matches: Vec<StringMatch> = 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<Picker<Self>>) {
+        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<Picker<Self>>) {
+        cx.emit(DismissEvent);
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        selected: bool,
+        _window: &mut Window,
+        _cx: &mut Context<Picker<Self>>,
+    ) -> Option<Self::ListItem> {
+        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))
+                }),
+        )
+    }
+}

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<RepoPath>),
     Unstage(GitRepo, Vec<RepoPath>),
     SetIndexText(GitRepo, RepoPath, Option<String>),
+    Push {
+        repo: GitRepo,
+        branch_name: SharedString,
+        remote_name: SharedString,
+        options: Option<PushOptions>,
+    },
+    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<Result<()>>)>();
         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<Self>,
+        envelope: TypedEnvelope<proto::Fetch>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::Ack> {
+        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<Self>,
+        envelope: TypedEnvelope<proto::Push>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::Ack> {
+        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<Self>,
+        envelope: TypedEnvelope<proto::Pull>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::Ack> {
+        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<Self>,
         envelope: TypedEnvelope<proto::Stage>,
@@ -509,6 +682,34 @@ impl GitStore {
         Ok(proto::Ack {})
     }
 
+    async fn handle_get_remotes(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::GetRemotes>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::GetRemotesResponse> {
+        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::<Vec<_>>(),
+        })
+    }
+
     async fn handle_show(
         this: Entity<Self>,
         envelope: TypedEnvelope<proto::GitShow>,
@@ -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<RepoPath>,
     ) -> oneshot::Receiver<Result<()>> {
-        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<Result<()>> {
-        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<Self>) -> Task<Result<CommitDetails>> {
@@ -987,18 +1172,41 @@ impl Repository {
         message: SharedString,
         name_and_email: Option<(SharedString, SharedString)>,
     ) -> oneshot::Receiver<Result<()>> {
-        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<Result<()>> {
+        self.send_message(Message::Fetch(self.git_repo.clone()))
+    }
+
+    pub fn push(
+        &self,
+        branch: SharedString,
+        remote: SharedString,
+        options: Option<PushOptions>,
+    ) -> oneshot::Receiver<Result<()>> {
+        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<Result<()>> {
+        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<String>,
     ) -> oneshot::Receiver<anyhow::Result<()>> {
+        self.send_message(Message::SetIndexText(
+            self.git_repo.clone(),
+            path.clone(),
+            content,
+        ))
+    }
+
+    pub fn get_remotes(&self, branch_name: Option<String>, cx: &App) -> Task<Result<Vec<Remote>>> {
+        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<anyhow::Result<()>> {
         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
     }
 }

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| {

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;
+}

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!(

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<StatusEntry>,
     work_directory_id: ProjectEntryId,
     pub work_directory: WorkDirectory,
-    pub(crate) branch: Option<Branch>,
+    pub(crate) current_branch: Option<Branch>,
     pub current_merge_conflicts: TreeSet<RepoPath>,
 }
 
@@ -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