// Copyright 2025 The Jujutsu Authors//// Licensed under the Apache License, Version 2.0 (the "License");// you may not use this file except in compliance with the License.// You may obtain a copy of the License at//// https://www.apache.org/licenses/LICENSE-2.0//// Unless required by applicable law or agreed to in writing, software// distributed under the License is distributed on an "AS IS" BASIS,// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.// See the License for the specific language governing permissions and// limitations under the License. use std::io;use std::io::BufReader;use std::io::Read;use std::num::NonZeroU32;use std::path::Path;use std::path::PathBuf;use std::process::Child;use std::process::Command;use std::process::Output;use std::process::Stdio;use std::thread; use bstr::ByteSlice as _;use itertools::Itertools as _;use thiserror::Error; use crate::git::Progress;use crate::git::RefSpec;use crate::git::RefToPush;use crate::git::RemoteCallbacks;use crate::git_backend::GitBackend; // This is not the minimum required version, that would be 2.29.0, which// introduced the `--no-write-fetch-head` option. However, that by itself// is quite old and unsupported, so we don't want to encourage users to// update to that.//// 2.40 still receives security patches (latest one was in Jan/2025)const MINIMUM_GIT_VERSION: &str = "2.40.4"; /// Error originating by a Git subprocess#[derive(Error, Debug)]pub enum GitSubprocessError { #[error("Could not find repository at '{0}'")] NoSuchRepository(String), #[error("Could not execute the git process, found in the OS path '{path}'")] SpawnInPath { path: PathBuf, #[source] error: std::io::Error, }, #[error("Could not execute git process at specified path '{path}'")] Spawn { path: PathBuf, #[source] error: std::io::Error, }, #[error("Failed to wait for the git process")] Wait(std::io::Error), #[error( "Git does not recognize required option: {0} (note: supported version is \ {MINIMUM_GIT_VERSION})" )] UnsupportedGitOption(String), #[error("Git process failed: {0}")] External(String),} /// Stats from a git push#[derive(Debug, Default)]pub(crate) struct GitPushStats { /// reference accepted by the remote pub pushed: Vec<String>, /// rejected reference, due to lease failure pub rejected: Vec<String>, /// reference rejected by the remote pub remote_rejected: Vec<String>,} /// Context for creating Git subprocessespub(crate) struct GitSubprocessContext<'a> { git_dir: PathBuf, git_executable_path: &'a Path,} impl<'a> GitSubprocessContext<'a> { pub(crate) fn new(git_dir: impl Into<PathBuf>, git_executable_path: &'a Path) -> Self { GitSubprocessContext { git_dir: git_dir.into(), git_executable_path, } } pub(crate) fn from_git_backend( git_backend: &GitBackend, git_executable_path: &'a Path, ) -> Self { Self::new(git_backend.git_repo_path(), git_executable_path) } /// Create the Git command fn create_command(&self) -> Command { let mut git_cmd = Command::new(self.git_executable_path); // Hide console window on Windows (https://stackoverflow.com/a/60958956) #[cfg(windows)] { use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x08000000; git_cmd.creation_flags(CREATE_NO_WINDOW); } // TODO: here we are passing the full path to the git_dir, which can lead to UNC // bugs in Windows. The ideal way to do this is to pass the workspace // root to Command::current_dir and then pass a relative path to the git // dir git_cmd .arg("--git-dir") .arg(&self.git_dir) // Disable translation and other locale-dependent behavior so we can // parse the output. LC_ALL precedes LC_* and LANG. .env("LC_ALL", "C") .stdin(Stdio::null()) .stderr(Stdio::piped()); git_cmd } /// Spawn the git command fn spawn_cmd(&self, mut git_cmd: Command) -> Result<Child, GitSubprocessError> { tracing::debug!(cmd = ?git_cmd, "spawning a git subprocess"); git_cmd.spawn().map_err(|error| { if self.git_executable_path.is_absolute() { GitSubprocessError::Spawn { path: self.git_executable_path.to_path_buf(), error, } } else { GitSubprocessError::SpawnInPath { path: self.git_executable_path.to_path_buf(), error, } } }) } /// Perform a git fetch /// /// This returns a fully qualified ref that wasn't fetched successfully /// Note that git only returns one failed ref at a time pub(crate) fn spawn_fetch( &self, remote_name: &str, refspecs: &[RefSpec], callbacks: &mut RemoteCallbacks<'_>, depth: Option<NonZeroU32>, ) -> Result<Option<String>, GitSubprocessError> { if refspecs.is_empty() { return Ok(None); } let mut command = self.create_command(); command.stdout(Stdio::piped()); // attempt to prune stale refs with --prune // --no-write-fetch-head ensures our request is invisible to other parties command.args(["fetch", "--prune", "--no-write-fetch-head"]); if callbacks.progress.is_some() { command.arg("--progress"); } if let Some(d) = depth { command.arg(format!("--depth={d}")); } command.arg("--").arg(remote_name); command.args(refspecs.iter().map(|x| x.to_git_format())); let output = wait_with_progress(self.spawn_cmd(command)?, callbacks)?; parse_git_fetch_output(output) } /// Prune particular branches pub(crate) fn spawn_branch_prune( &self, branches_to_prune: &[String], ) -> Result<(), GitSubprocessError> { if branches_to_prune.is_empty() { return Ok(()); } tracing::debug!(?branches_to_prune, "pruning branches"); let mut command = self.create_command(); command.stdout(Stdio::null()); command.args(["branch", "--remotes", "--delete", "--"]); command.args(branches_to_prune); let output = wait_with_output(self.spawn_cmd(command)?)?; // we name the type to make sure that it is not meant to be used let () = parse_git_branch_prune_output(output)?; Ok(()) } /// How we retrieve the remote's default branch: /// /// `git remote show <remote_name>` /// /// dumps a lot of information about the remote, with a line such as: /// ` HEAD branch: <default_branch>` pub(crate) fn spawn_remote_show( &self, remote_name: &str, ) -> Result<Option<String>, GitSubprocessError> { let mut command = self.create_command(); command.stdout(Stdio::piped()); command.args(["remote", "show", "--", remote_name]); let output = wait_with_output(self.spawn_cmd(command)?)?; let output = parse_git_remote_show_output(output)?; // find the HEAD branch line in the output parse_git_remote_show_default_branch(&output.stdout) } /// Push references to git /// /// All pushes are forced, using --force-with-lease to perform a test&set /// operation on the remote repository /// /// Return tuple with /// 1. refs that failed to push /// 2. refs that succeeded to push pub(crate) fn spawn_push( &self, remote_name: &str, references: &[RefToPush], callbacks: &mut RemoteCallbacks<'_>, ) -> Result<GitPushStats, GitSubprocessError> { let mut command = self.create_command(); command.stdout(Stdio::piped()); // Currently jj does not support commit hooks, so we prevent git from running // them // // https://github.com/jj-vcs/jj/issues/3577 and https://github.com/jj-vcs/jj/issues/405 // offer more context command.args(["push", "--porcelain", "--no-verify"]); if callbacks.progress.is_some() { command.arg("--progress"); } command.args( references .iter() .map(|reference| format!("--force-with-lease={}", reference.to_git_lease())), ); command.args(["--", remote_name]); // with --force-with-lease we cannot have the forced refspec, // as it ignores the lease command.args( references .iter() .map(|r| r.refspec.to_git_format_not_forced()), ); let output = wait_with_progress(self.spawn_cmd(command)?, callbacks)?; parse_git_push_output(output) }} /// Generate a GitSubprocessError::ExternalGitError if the stderr output was not/// recognizablefn external_git_error(stderr: &[u8]) -> GitSubprocessError { GitSubprocessError::External(format!( "External git program failed:\n{}", stderr.to_str_lossy() ))} /// Parse no such remote errors output from git////// Returns the remote that wasn't found////// To say this, git prints out a lot of things, but the first line is of the/// form:/// `fatal: '<remote>' does not appear to be a git repository`/// or/// `fatal: '<remote>': Could not resolve host: invalid-remotefn parse_no_such_remote(stderr: &[u8]) -> Option<String> { let first_line = stderr.lines().next()?; let suffix = first_line .strip_prefix(b"fatal: '") .or_else(|| first_line.strip_prefix(b"fatal: unable to access '"))?; suffix .strip_suffix(b"' does not appear to be a git repository") .or_else(|| suffix.strip_suffix(b"': Could not resolve host: invalid-remote")) .map(|remote| remote.to_str_lossy().into_owned())} /// Parse error from refspec not present on the remote////// This returns/// Some(local_ref) that wasn't found by the remote/// None if this wasn't the error////// On git fetch even though --prune is specified, if a particular/// refspec is asked for but not present in the remote, git will error out.////// Git only reports one of these errors at a time, so we only look at the first/// line////// The first line is of the form:/// `fatal: couldn't find remote ref refs/heads/<ref>`fn parse_no_remote_ref(stderr: &[u8]) -> Option<String> { let first_line = stderr.lines().next()?; first_line .strip_prefix(b"fatal: couldn't find remote ref ") .map(|refname| refname.to_str_lossy().into_owned())} /// Parse remote tracking branch not found////// This returns true if the error was detected////// if a branch is asked for but is not present, jj will detect it post-hoc/// so, we want to ignore these particular errors with git////// The first line is of the form:/// `error: remote-tracking branch '<branch>' not found`fn parse_no_remote_tracking_branch(stderr: &[u8]) -> Option<String> { let first_line = stderr.lines().next()?; let suffix = first_line.strip_prefix(b"error: remote-tracking branch '")?; suffix .strip_suffix(b"' not found.") .or_else(|| suffix.strip_suffix(b"' not found")) .map(|branch| branch.to_str_lossy().into_owned())} /// Parse unknown options////// Return the unknown option////// If a user is running a very old git version, our commands may fail/// We want to give a good error in this casefn parse_unknown_option(stderr: &[u8]) -> Option<String> { let first_line = stderr.lines().next()?; first_line .strip_prefix(b"unknown option: --") .or(first_line .strip_prefix(b"error: unknown option `") .and_then(|s| s.strip_suffix(b"'"))) .map(|s| s.to_str_lossy().into())} // return the fully qualified ref that failed to fetch//// note that git fetch only returns one error at a timefn parse_git_fetch_output(output: Output) -> Result<Option<String>, GitSubprocessError> { if output.status.success() { return Ok(None); } // There are some git errors we want to parse out if let Some(option) = parse_unknown_option(&output.stderr) { return Err(GitSubprocessError::UnsupportedGitOption(option)); } if let Some(remote) = parse_no_such_remote(&output.stderr) { return Err(GitSubprocessError::NoSuchRepository(remote)); } if let Some(refspec) = parse_no_remote_ref(&output.stderr) { return Ok(Some(refspec)); } if parse_no_remote_tracking_branch(&output.stderr).is_some() { return Ok(None); } Err(external_git_error(&output.stderr))} fn parse_git_branch_prune_output(output: Output) -> Result<(), GitSubprocessError> { if output.status.success() { return Ok(()); } // There are some git errors we want to parse out if let Some(option) = parse_unknown_option(&output.stderr) { return Err(GitSubprocessError::UnsupportedGitOption(option)); } if parse_no_remote_tracking_branch(&output.stderr).is_some() { return Ok(()); } Err(external_git_error(&output.stderr))} fn parse_git_remote_show_output(output: Output) -> Result<Output, GitSubprocessError> { if output.status.success() { return Ok(output); } // There are some git errors we want to parse out if let Some(option) = parse_unknown_option(&output.stderr) { return Err(GitSubprocessError::UnsupportedGitOption(option)); } if let Some(remote) = parse_no_such_remote(&output.stderr) { return Err(GitSubprocessError::NoSuchRepository(remote)); } Err(external_git_error(&output.stderr))} fn parse_git_remote_show_default_branch( stdout: &[u8],) -> Result<Option<String>, GitSubprocessError> { stdout .lines() .map(|x| x.trim()) .find(|x| x.starts_with_str("HEAD branch:")) .inspect(|x| tracing::debug!(line = ?x.to_str_lossy(), "default branch")) .and_then(|x| x.split_str(" ").last().map(|y| y.trim())) .filter(|branch_name| branch_name != b"(unknown)") .map(|branch_name| branch_name.to_str()) .transpose() .map_err(|e| GitSubprocessError::External(format!("git remote output is not utf-8: {e:?}"))) .map(|b| b.map(|x| x.to_string()))} // git-push porcelain has the following format (per line)// `<flag>\t<from>:<to>\t<summary> (<reason>)`//// <flag> is one of:// ' ' for a successfully pushed fast-forward;// + for a successful forced update// - for a successfully deleted ref// * for a successfully pushed new ref// ! for a ref that was rejected or failed to push; and// = for a ref that was up to date and did not need pushing.//// <from>:<to> is the refspec//// <summary> is extra info (commit ranges or reason for rejected)//// <reason> is a human-readable explanationfn parse_ref_pushes(stdout: &[u8]) -> Result<GitPushStats, GitSubprocessError> { if !stdout.starts_with(b"To ") { return Err(GitSubprocessError::External(format!( "Git push output unfamiliar:\n{}", stdout.to_str_lossy() ))); } let mut push_result = GitPushStats::default(); for (idx, line) in stdout .lines() .skip(1) .take_while(|line| line != b"Done") .enumerate() { tracing::debug!("response #{idx}: {}", line.to_str_lossy()); let (flag, reference, summary) = line.split_str("\t").collect_tuple().ok_or_else(|| { GitSubprocessError::External(format!( "Line #{idx} of git-push has unknown format: {}", line.to_str_lossy() )) })?; let full_refspec = reference .to_str() .map_err(|e| { format!( "Line #{} of git-push has non-utf8 refspec {}: {}", idx, reference.to_str_lossy(), e ) }) .map_err(GitSubprocessError::External)?; let reference = full_refspec .split_once(':') .map(|(_refname, reference)| reference.to_string()) .ok_or_else(|| { GitSubprocessError::External(format!( "Line #{idx} of git-push has full refspec without named ref: {full_refspec}" )) })?; match flag { // ' ' for a successfully pushed fast-forward; // + for a successful forced update // - for a successfully deleted ref // * for a successfully pushed new ref // = for a ref that was up to date and did not need pushing.Lines 1–500 of 938. Load more
// Copyright 2025 The Jujutsu Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use std::io; use std::io::BufReader; use std::io::Read; use std::num::NonZeroU32; use std::path::Path; use std::path::PathBuf; use std::process::Child; use std::process::Command; use std::process::Output; use std::process::Stdio; use std::thread; use bstr::ByteSlice as _; use itertools::Itertools as _; use thiserror::Error; use crate::git::Progress; use crate::git::RefSpec; use crate::git::RefToPush; use crate::git::RemoteCallbacks; use crate::git_backend::GitBackend; // This is not the minimum required version, that would be 2.29.0, which // introduced the `--no-write-fetch-head` option. However, that by itself // is quite old and unsupported, so we don't want to encourage users to // update to that. // // 2.40 still receives security patches (latest one was in Jan/2025) const MINIMUM_GIT_VERSION: &str = "2.40.4"; /// Error originating by a Git subprocess #[derive(Error, Debug)] pub enum GitSubprocessError { #[error("Could not find repository at '{0}'")] NoSuchRepository(String), #[error("Could not execute the git process, found in the OS path '{path}'")] SpawnInPath { path: PathBuf, #[source] error: std::io::Error, }, #[error("Could not execute git process at specified path '{path}'")] Spawn { path: PathBuf, #[source] error: std::io::Error, }, #[error("Failed to wait for the git process")] Wait(std::io::Error), #[error( "Git does not recognize required option: {0} (note: supported version is \ {MINIMUM_GIT_VERSION})" )] UnsupportedGitOption(String), #[error("Git process failed: {0}")] External(String), } /// Stats from a git push #[derive(Debug, Default)] pub(crate) struct GitPushStats { /// reference accepted by the remote pub pushed: Vec<String>, /// rejected reference, due to lease failure pub rejected: Vec<String>, /// reference rejected by the remote pub remote_rejected: Vec<String>, } /// Context for creating Git subprocesses pub(crate) struct GitSubprocessContext<'a> { git_dir: PathBuf, git_executable_path: &'a Path, } impl<'a> GitSubprocessContext<'a> { pub(crate) fn new(git_dir: impl Into<PathBuf>, git_executable_path: &'a Path) -> Self { GitSubprocessContext { git_dir: git_dir.into(), git_executable_path, } } pub(crate) fn from_git_backend( git_backend: &GitBackend, git_executable_path: &'a Path, ) -> Self { Self::new(git_backend.git_repo_path(), git_executable_path) } /// Create the Git command fn create_command(&self) -> Command { let mut git_cmd = Command::new(self.git_executable_path); // Hide console window on Windows (https://stackoverflow.com/a/60958956) #[cfg(windows)] { use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x08000000; git_cmd.creation_flags(CREATE_NO_WINDOW); } // TODO: here we are passing the full path to the git_dir, which can lead to UNC // bugs in Windows. The ideal way to do this is to pass the workspace // root to Command::current_dir and then pass a relative path to the git // dir git_cmd .arg("--git-dir") .arg(&self.git_dir) // Disable translation and other locale-dependent behavior so we can // parse the output. LC_ALL precedes LC_* and LANG. .env("LC_ALL", "C") .stdin(Stdio::null()) .stderr(Stdio::piped()); git_cmd } /// Spawn the git command fn spawn_cmd(&self, mut git_cmd: Command) -> Result<Child, GitSubprocessError> { tracing::debug!(cmd = ?git_cmd, "spawning a git subprocess"); git_cmd.spawn().map_err(|error| { if self.git_executable_path.is_absolute() { GitSubprocessError::Spawn { path: self.git_executable_path.to_path_buf(), error, } } else { GitSubprocessError::SpawnInPath { path: self.git_executable_path.to_path_buf(), error, } } }) } /// Perform a git fetch /// /// This returns a fully qualified ref that wasn't fetched successfully /// Note that git only returns one failed ref at a time pub(crate) fn spawn_fetch( &self, remote_name: &str, refspecs: &[RefSpec], callbacks: &mut RemoteCallbacks<'_>, depth: Option<NonZeroU32>, ) -> Result<Option<String>, GitSubprocessError> { if refspecs.is_empty() { return Ok(None); } let mut command = self.create_command(); command.stdout(Stdio::piped()); // attempt to prune stale refs with --prune // --no-write-fetch-head ensures our request is invisible to other parties command.args(["fetch", "--prune", "--no-write-fetch-head"]); if callbacks.progress.is_some() { command.arg("--progress"); } if let Some(d) = depth { command.arg(format!("--depth={d}")); } command.arg("--").arg(remote_name); command.args(refspecs.iter().map(|x| x.to_git_format())); let output = wait_with_progress(self.spawn_cmd(command)?, callbacks)?; parse_git_fetch_output(output) } /// Prune particular branches pub(crate) fn spawn_branch_prune( &self, branches_to_prune: &[String], ) -> Result<(), GitSubprocessError> { if branches_to_prune.is_empty() { return Ok(()); } tracing::debug!(?branches_to_prune, "pruning branches"); let mut command = self.create_command(); command.stdout(Stdio::null()); command.args(["branch", "--remotes", "--delete", "--"]); command.args(branches_to_prune); let output = wait_with_output(self.spawn_cmd(command)?)?; // we name the type to make sure that it is not meant to be used let () = parse_git_branch_prune_output(output)?; Ok(()) } /// How we retrieve the remote's default branch: /// /// `git remote show <remote_name>` /// /// dumps a lot of information about the remote, with a line such as: /// ` HEAD branch: <default_branch>` pub(crate) fn spawn_remote_show( &self, remote_name: &str, ) -> Result<Option<String>, GitSubprocessError> { let mut command = self.create_command(); command.stdout(Stdio::piped()); command.args(["remote", "show", "--", remote_name]); let output = wait_with_output(self.spawn_cmd(command)?)?; let output = parse_git_remote_show_output(output)?; // find the HEAD branch line in the output parse_git_remote_show_default_branch(&output.stdout) } /// Push references to git /// /// All pushes are forced, using --force-with-lease to perform a test&set /// operation on the remote repository /// /// Return tuple with /// 1. refs that failed to push /// 2. refs that succeeded to push pub(crate) fn spawn_push( &self, remote_name: &str, references: &[RefToPush], callbacks: &mut RemoteCallbacks<'_>, ) -> Result<GitPushStats, GitSubprocessError> { let mut command = self.create_command(); command.stdout(Stdio::piped()); // Currently jj does not support commit hooks, so we prevent git from running // them // // https://github.com/jj-vcs/jj/issues/3577 and https://github.com/jj-vcs/jj/issues/405 // offer more context command.args(["push", "--porcelain", "--no-verify"]); if callbacks.progress.is_some() { command.arg("--progress"); } command.args( references .iter() .map(|reference| format!("--force-with-lease={}", reference.to_git_lease())), ); command.args(["--", remote_name]); // with --force-with-lease we cannot have the forced refspec, // as it ignores the lease command.args( references .iter() .map(|r| r.refspec.to_git_format_not_forced()), ); let output = wait_with_progress(self.spawn_cmd(command)?, callbacks)?; parse_git_push_output(output) } } /// Generate a GitSubprocessError::ExternalGitError if the stderr output was not /// recognizable fn external_git_error(stderr: &[u8]) -> GitSubprocessError { GitSubprocessError::External(format!( "External git program failed:\n{}", stderr.to_str_lossy() )) } /// Parse no such remote errors output from git /// /// Returns the remote that wasn't found /// /// To say this, git prints out a lot of things, but the first line is of the /// form: /// `fatal: '<remote>' does not appear to be a git repository` /// or /// `fatal: '<remote>': Could not resolve host: invalid-remote fn parse_no_such_remote(stderr: &[u8]) -> Option<String> { let first_line = stderr.lines().next()?; let suffix = first_line .strip_prefix(b"fatal: '") .or_else(|| first_line.strip_prefix(b"fatal: unable to access '"))?; suffix .strip_suffix(b"' does not appear to be a git repository") .or_else(|| suffix.strip_suffix(b"': Could not resolve host: invalid-remote")) .map(|remote| remote.to_str_lossy().into_owned()) } /// Parse error from refspec not present on the remote /// /// This returns /// Some(local_ref) that wasn't found by the remote /// None if this wasn't the error /// /// On git fetch even though --prune is specified, if a particular /// refspec is asked for but not present in the remote, git will error out. /// /// Git only reports one of these errors at a time, so we only look at the first /// line /// /// The first line is of the form: /// `fatal: couldn't find remote ref refs/heads/<ref>` fn parse_no_remote_ref(stderr: &[u8]) -> Option<String> { let first_line = stderr.lines().next()?; first_line .strip_prefix(b"fatal: couldn't find remote ref ") .map(|refname| refname.to_str_lossy().into_owned()) } /// Parse remote tracking branch not found /// /// This returns true if the error was detected /// /// if a branch is asked for but is not present, jj will detect it post-hoc /// so, we want to ignore these particular errors with git /// /// The first line is of the form: /// `error: remote-tracking branch '<branch>' not found` fn parse_no_remote_tracking_branch(stderr: &[u8]) -> Option<String> { let first_line = stderr.lines().next()?; let suffix = first_line.strip_prefix(b"error: remote-tracking branch '")?; suffix .strip_suffix(b"' not found.") .or_else(|| suffix.strip_suffix(b"' not found")) .map(|branch| branch.to_str_lossy().into_owned()) } /// Parse unknown options /// /// Return the unknown option /// /// If a user is running a very old git version, our commands may fail /// We want to give a good error in this case fn parse_unknown_option(stderr: &[u8]) -> Option<String> { let first_line = stderr.lines().next()?; first_line .strip_prefix(b"unknown option: --") .or(first_line .strip_prefix(b"error: unknown option `") .and_then(|s| s.strip_suffix(b"'"))) .map(|s| s.to_str_lossy().into()) } // return the fully qualified ref that failed to fetch // // note that git fetch only returns one error at a time fn parse_git_fetch_output(output: Output) -> Result<Option<String>, GitSubprocessError> { if output.status.success() { return Ok(None); } // There are some git errors we want to parse out if let Some(option) = parse_unknown_option(&output.stderr) { return Err(GitSubprocessError::UnsupportedGitOption(option)); } if let Some(remote) = parse_no_such_remote(&output.stderr) { return Err(GitSubprocessError::NoSuchRepository(remote)); } if let Some(refspec) = parse_no_remote_ref(&output.stderr) { return Ok(Some(refspec)); } if parse_no_remote_tracking_branch(&output.stderr).is_some() { return Ok(None); } Err(external_git_error(&output.stderr)) } fn parse_git_branch_prune_output(output: Output) -> Result<(), GitSubprocessError> { if output.status.success() { return Ok(()); } // There are some git errors we want to parse out if let Some(option) = parse_unknown_option(&output.stderr) { return Err(GitSubprocessError::UnsupportedGitOption(option)); } if parse_no_remote_tracking_branch(&output.stderr).is_some() { return Ok(()); } Err(external_git_error(&output.stderr)) } fn parse_git_remote_show_output(output: Output) -> Result<Output, GitSubprocessError> { if output.status.success() { return Ok(output); } // There are some git errors we want to parse out if let Some(option) = parse_unknown_option(&output.stderr) { return Err(GitSubprocessError::UnsupportedGitOption(option)); } if let Some(remote) = parse_no_such_remote(&output.stderr) { return Err(GitSubprocessError::NoSuchRepository(remote)); } Err(external_git_error(&output.stderr)) } fn parse_git_remote_show_default_branch( stdout: &[u8], ) -> Result<Option<String>, GitSubprocessError> { stdout .lines() .map(|x| x.trim()) .find(|x| x.starts_with_str("HEAD branch:")) .inspect(|x| tracing::debug!(line = ?x.to_str_lossy(), "default branch")) .and_then(|x| x.split_str(" ").last().map(|y| y.trim())) .filter(|branch_name| branch_name != b"(unknown)") .map(|branch_name| branch_name.to_str()) .transpose() .map_err(|e| GitSubprocessError::External(format!("git remote output is not utf-8: {e:?}"))) .map(|b| b.map(|x| x.to_string())) } // git-push porcelain has the following format (per line) // `<flag>\t<from>:<to>\t<summary> (<reason>)` // // <flag> is one of: // ' ' for a successfully pushed fast-forward; // + for a successful forced update // - for a successfully deleted ref // * for a successfully pushed new ref // ! for a ref that was rejected or failed to push; and // = for a ref that was up to date and did not need pushing. // // <from>:<to> is the refspec // // <summary> is extra info (commit ranges or reason for rejected) // // <reason> is a human-readable explanation fn parse_ref_pushes(stdout: &[u8]) -> Result<GitPushStats, GitSubprocessError> { if !stdout.starts_with(b"To ") { return Err(GitSubprocessError::External(format!( "Git push output unfamiliar:\n{}", stdout.to_str_lossy() ))); } let mut push_result = GitPushStats::default(); for (idx, line) in stdout .lines() .skip(1) .take_while(|line| line != b"Done") .enumerate() { tracing::debug!("response #{idx}: {}", line.to_str_lossy()); let (flag, reference, summary) = line.split_str("\t").collect_tuple().ok_or_else(|| { GitSubprocessError::External(format!( "Line #{idx} of git-push has unknown format: {}", line.to_str_lossy() )) })?; let full_refspec = reference .to_str() .map_err(|e| { format!( "Line #{} of git-push has non-utf8 refspec {}: {}", idx, reference.to_str_lossy(), e ) }) .map_err(GitSubprocessError::External)?; let reference = full_refspec .split_once(':') .map(|(_refname, reference)| reference.to_string()) .ok_or_else(|| { GitSubprocessError::External(format!( "Line #{idx} of git-push has full refspec without named ref: {full_refspec}" )) })?; match flag { // ' ' for a successfully pushed fast-forward; // + for a successful forced update // - for a successfully deleted ref // * for a successfully pushed new ref // = for a ref that was up to date and did not need pushing.