Git push/pull/fetch (#25445)

Release Notes:

- N/A

---------

Co-authored-by: Michael Sloan <mgsloan@gmail.com>
This commit is contained in:
Mikayla Maki 2025-02-24 10:29:52 -08:00 committed by GitHub
parent b1b6401ce7
commit ff6844300e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1242 additions and 180 deletions

View file

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

View file

@ -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,
]
);

View file

@ -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,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<PushOptions>,
) -> 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<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
})