// 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::PathBuf;use std::process::Child;use std::process::Command;use std::process::Output;use std::process::Stdio;use std::thread; use bstr::BStr;use bstr::ByteSlice as _;use itertools::Itertools as _;use thiserror::Error; use crate::git::GitPushOptions;use crate::git::GitPushStats;use crate::git::GitSubprocessOptions;use crate::git::NegativeRefSpec;use crate::git::RefSpec;use crate::git::RefToPush;use crate::git_backend::GitBackend;use crate::merge::Diff;use crate::ref_name::GitRefNameBuf;use crate::ref_name::RefNameBuf;use crate::ref_name::RemoteName; // * 2.29.0 introduced `git fetch --no-write-fetch-head`// * 2.40 still receives security patches (latest one was in Jan/2025)// * 2.41.0 introduced `git fetch --porcelain`// If bumped, please update ../../docs/install-and-setup.mdconst MINIMUM_GIT_VERSION: &str = "2.41.0"; /// 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: Jujutsu requires git >= \ {MINIMUM_GIT_VERSION})" )] UnsupportedGitOption(String), #[error("Git process failed: {0}")] External(String),} /// Context for creating Git subprocessespub(crate) struct GitSubprocessContext { git_dir: PathBuf, options: GitSubprocessOptions,} impl GitSubprocessContext { pub(crate) fn new(git_dir: impl Into<PathBuf>, options: GitSubprocessOptions) -> Self { Self { git_dir: git_dir.into(), options, } } pub(crate) fn from_git_backend( git_backend: &GitBackend, options: GitSubprocessOptions, ) -> Self { Self::new(git_backend.git_repo_path(), options) } /// Create the Git command fn create_command(&self) -> Command { let mut git_cmd = Command::new(&self.options.executable_path); // Hide console window on Windows (https://stackoverflow.com/a/60958956) #[cfg(windows)] { use std::os::windows::process::CommandExt as _; 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 // The gitconfig-controlled automated spawning of the macOS `fsmonitor--daemon` // can cause strange behavior with certain subprocess operations. // For example: https://github.com/jj-vcs/jj/issues/6440. // // Nothing we're doing in `jj` interacts with this daemon, so we force the // config to be false for subprocess operations in order to avoid these // interactions. // // In a colocated workspace, the daemon will still get started the first // time a `git` command is run manually if the gitconfigs are set up that way. .args(["-c", "core.fsmonitor=false"]) // Avoids an error message when fetching repos with submodules if // user has `submodule.recurse` configured to true in their Git // config (#7565). .args(["-c", "submodule.recurse=false"]) .arg("--git-dir") .arg(&self.git_dir) // Disable translation so we can parse the output. We don't set // LC_ALL=C because it would change the encoding. Also note that // "C.UTF-8" locale isn't always available. .env_remove("LC_ALL") .env_remove("LANGUAGE") .env("LC_MESSAGES", "C") .stdin(Stdio::null()) .stderr(Stdio::piped()); git_cmd.envs(&self.options.environment); 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.options.executable_path.is_absolute() { GitSubprocessError::Spawn { path: self.options.executable_path.clone(), error, } } else { GitSubprocessError::SpawnInPath { path: self.options.executable_path.clone(), error, } } }) } /// Perform a git fetch /// /// [`GitFetchStatus::NoRemoteRef`] is returned if ref doesn't exist. Note /// that `git` only returns one failed ref at a time. pub(crate) fn spawn_fetch( &self, remote_name: &RemoteName, refspecs: &[RefSpec], negative_refspecs: &[NegativeRefSpec], callback: &mut dyn GitSubprocessCallback, depth: Option<NonZeroU32>, ) -> Result<GitFetchStatus, GitSubprocessError> { if refspecs.is_empty() { return Ok(GitFetchStatus::Updates(GitRefUpdates::default())); } 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", "--porcelain", "--prune", "--no-write-fetch-head"]); if callback.needs_progress() { command.arg("--progress"); } if let Some(d) = depth { command.arg(format!("--depth={d}")); } // Tags should be fetched explicitly by the refspecs command.arg("--no-tags"); command.arg("--").arg(remote_name.as_str()); command.args( refspecs .iter() .map(|x| x.to_git_format()) .chain(negative_refspecs.iter().map(|x| x.to_git_format())), ); let output = wait_with_progress(self.spawn_cmd(command)?, callback)?; 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: &RemoteName, ) -> Result<Option<RefNameBuf>, GitSubprocessError> { let mut command = self.create_command(); command.stdout(Stdio::piped()); command.args(["remote", "show", "--", remote_name.as_str()]); 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 let maybe_branch = parse_git_remote_show_default_branch(&output.stdout)?; Ok(maybe_branch.map(Into::into)) } /// 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: &RemoteName, references: &[RefToPush], callback: &mut dyn GitSubprocessCallback, options: &GitPushOptions, ) -> 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 callback.needs_progress() { command.arg("--progress"); } command.args( options .remote_push_options .iter() .map(|option| format!("--push-option={option}")), ); command.args( references .iter() .map(|reference| format!("--force-with-lease={}", reference.to_git_lease())), ); command.args(["--", remote_name.as_str()]); // 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)?, callback)?; 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() ))} const ERROR_PREFIXES: &[&[u8]] = &[ // error_builtin() in usage.c b"error: ", // die_message_builtin() in usage.c b"fatal: ", // usage_builtin() in usage.c b"usage: ", // handle_option() in git.c b"unknown option: ",]; /// 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 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())} /// Status of underlying `git fetch` operation.#[derive(Clone, Debug)]pub enum GitFetchStatus { /// Successfully fetched refs. There may be refs that couldn't be updated. Updates(GitRefUpdates), /// Fully-qualified ref that failed to fetch. /// /// Note that `git fetch` only returns one error at a time. NoRemoteRef(String),} fn parse_git_fetch_output(output: &Output) -> Result<GitFetchStatus, GitSubprocessError> { if output.status.success() { let updates = parse_ref_updates(&output.stdout)?; return Ok(GitFetchStatus::Updates(updates)); } // 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(GitFetchStatus::NoRemoteRef(refspec)); } let updates = parse_ref_updates(&output.stdout)?; if !updates.rejected.is_empty() || parse_no_remote_tracking_branch(&output.stderr).is_some() { Ok(GitFetchStatus::Updates(updates)) } else { Err(external_git_error(&output.stderr)) }} /// Local changes made by `git fetch`.#[derive(Clone, Debug, Default)]pub struct GitRefUpdates { /// Git ref `(name, (old_oid, new_oid))`s that are successfully updated. /// /// `old_oid`/`new_oid` may be null or point to non-commit objects such as /// tags. #[cfg_attr(not(test), expect(dead_code))] // unused as of now pub updated: Vec<(GitRefNameBuf, Diff<gix::ObjectId>)>, /// Git ref `(name, (old_oid, new_oid)`s that are rejected or failed to /// update. pub rejected: Vec<(GitRefNameBuf, Diff<gix::ObjectId>)>,} /// Parses porcelain output of `git fetch`.fn parse_ref_updates(stdout: &[u8]) -> Result<GitRefUpdates, GitSubprocessError> { let mut updated = vec![]; let mut rejected = vec![]; for (i, line) in stdout.lines().enumerate() { let parse_err = |message: &str| { GitSubprocessError::External(format!( "Line {line_no}: {message}: {line}", line_no = i + 1, line = BStr::new(line) )) }; // <flag> <old-object-id> <new-object-id> <local-reference> // (<flag> may be space) let mut line_bytes = line.iter(); let flag = *line_bytes.next().ok_or_else(|| parse_err("empty line"))?; if line_bytes.next() != Some(&b' ') { return Err(parse_err("no flag separator found")); } let [old_oid, new_oid, name] = line_bytes .as_slice() .splitn(3, |&b| b == b' ') .collect_array() .ok_or_else(|| parse_err("unexpected number of columns"))?; let name: GitRefNameBuf = str::from_utf8(name) .map_err(|_| parse_err("non-UTF-8 ref name"))? .into(); let old_oid = gix::ObjectId::from_hex(old_oid).map_err(|_| parse_err("invalid old oid"))?; let new_oid = gix::ObjectId::from_hex(new_oid).map_err(|_| parse_err("invalid new oid"))?; let oid_diff = Diff::new(old_oid, new_oid); match flag { // ' ' for a successfully fetched fast-forward // '+' for a successful forced update // '-' for a successfully pruned ref // 't' for a successful tag update // '*' for a successfully fetched new ref b' ' | b'+' | b'-' | b't' | b'*' => updated.push((name, oid_diff)), // '!' for a ref that was rejected or failed to update b'!' => rejected.push((name, oid_diff)), // '=' for a ref that was up to date and did not need fetching // (included when --verbose) b'=' => {} _ => return Err(parse_err("unknown flag")), } } Ok(GitRefUpdates { updated, rejected })} fn parse_git_branch_prune_output(output: Output) -> Result<(), GitSubprocessError> { if output.status.success() {Lines 1–500 of 1319. 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::PathBuf; use std::process::Child; use std::process::Command; use std::process::Output; use std::process::Stdio; use std::thread; use bstr::BStr; use bstr::ByteSlice as _; use itertools::Itertools as _; use thiserror::Error; use crate::git::GitPushOptions; use crate::git::GitPushStats; use crate::git::GitSubprocessOptions; use crate::git::NegativeRefSpec; use crate::git::RefSpec; use crate::git::RefToPush; use crate::git_backend::GitBackend; use crate::merge::Diff; use crate::ref_name::GitRefNameBuf; use crate::ref_name::RefNameBuf; use crate::ref_name::RemoteName; // * 2.29.0 introduced `git fetch --no-write-fetch-head` // * 2.40 still receives security patches (latest one was in Jan/2025) // * 2.41.0 introduced `git fetch --porcelain` // If bumped, please update ../../docs/install-and-setup.md const MINIMUM_GIT_VERSION: &str = "2.41.0"; /// 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: Jujutsu requires git >= \ {MINIMUM_GIT_VERSION})" )] UnsupportedGitOption(String), #[error("Git process failed: {0}")] External(String), } /// Context for creating Git subprocesses pub(crate) struct GitSubprocessContext { git_dir: PathBuf, options: GitSubprocessOptions, } impl GitSubprocessContext { pub(crate) fn new(git_dir: impl Into<PathBuf>, options: GitSubprocessOptions) -> Self { Self { git_dir: git_dir.into(), options, } } pub(crate) fn from_git_backend( git_backend: &GitBackend, options: GitSubprocessOptions, ) -> Self { Self::new(git_backend.git_repo_path(), options) } /// Create the Git command fn create_command(&self) -> Command { let mut git_cmd = Command::new(&self.options.executable_path); // Hide console window on Windows (https://stackoverflow.com/a/60958956) #[cfg(windows)] { use std::os::windows::process::CommandExt as _; 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 // The gitconfig-controlled automated spawning of the macOS `fsmonitor--daemon` // can cause strange behavior with certain subprocess operations. // For example: https://github.com/jj-vcs/jj/issues/6440. // // Nothing we're doing in `jj` interacts with this daemon, so we force the // config to be false for subprocess operations in order to avoid these // interactions. // // In a colocated workspace, the daemon will still get started the first // time a `git` command is run manually if the gitconfigs are set up that way. .args(["-c", "core.fsmonitor=false"]) // Avoids an error message when fetching repos with submodules if // user has `submodule.recurse` configured to true in their Git // config (#7565). .args(["-c", "submodule.recurse=false"]) .arg("--git-dir") .arg(&self.git_dir) // Disable translation so we can parse the output. We don't set // LC_ALL=C because it would change the encoding. Also note that // "C.UTF-8" locale isn't always available. .env_remove("LC_ALL") .env_remove("LANGUAGE") .env("LC_MESSAGES", "C") .stdin(Stdio::null()) .stderr(Stdio::piped()); git_cmd.envs(&self.options.environment); 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.options.executable_path.is_absolute() { GitSubprocessError::Spawn { path: self.options.executable_path.clone(), error, } } else { GitSubprocessError::SpawnInPath { path: self.options.executable_path.clone(), error, } } }) } /// Perform a git fetch /// /// [`GitFetchStatus::NoRemoteRef`] is returned if ref doesn't exist. Note /// that `git` only returns one failed ref at a time. pub(crate) fn spawn_fetch( &self, remote_name: &RemoteName, refspecs: &[RefSpec], negative_refspecs: &[NegativeRefSpec], callback: &mut dyn GitSubprocessCallback, depth: Option<NonZeroU32>, ) -> Result<GitFetchStatus, GitSubprocessError> { if refspecs.is_empty() { return Ok(GitFetchStatus::Updates(GitRefUpdates::default())); } 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", "--porcelain", "--prune", "--no-write-fetch-head"]); if callback.needs_progress() { command.arg("--progress"); } if let Some(d) = depth { command.arg(format!("--depth={d}")); } // Tags should be fetched explicitly by the refspecs command.arg("--no-tags"); command.arg("--").arg(remote_name.as_str()); command.args( refspecs .iter() .map(|x| x.to_git_format()) .chain(negative_refspecs.iter().map(|x| x.to_git_format())), ); let output = wait_with_progress(self.spawn_cmd(command)?, callback)?; 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: &RemoteName, ) -> Result<Option<RefNameBuf>, GitSubprocessError> { let mut command = self.create_command(); command.stdout(Stdio::piped()); command.args(["remote", "show", "--", remote_name.as_str()]); 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 let maybe_branch = parse_git_remote_show_default_branch(&output.stdout)?; Ok(maybe_branch.map(Into::into)) } /// 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: &RemoteName, references: &[RefToPush], callback: &mut dyn GitSubprocessCallback, options: &GitPushOptions, ) -> 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 callback.needs_progress() { command.arg("--progress"); } command.args( options .remote_push_options .iter() .map(|option| format!("--push-option={option}")), ); command.args( references .iter() .map(|reference| format!("--force-with-lease={}", reference.to_git_lease())), ); command.args(["--", remote_name.as_str()]); // 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)?, callback)?; 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() )) } const ERROR_PREFIXES: &[&[u8]] = &[ // error_builtin() in usage.c b"error: ", // die_message_builtin() in usage.c b"fatal: ", // usage_builtin() in usage.c b"usage: ", // handle_option() in git.c b"unknown option: ", ]; /// 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()) } /// Status of underlying `git fetch` operation. #[derive(Clone, Debug)] pub enum GitFetchStatus { /// Successfully fetched refs. There may be refs that couldn't be updated. Updates(GitRefUpdates), /// Fully-qualified ref that failed to fetch. /// /// Note that `git fetch` only returns one error at a time. NoRemoteRef(String), } fn parse_git_fetch_output(output: &Output) -> Result<GitFetchStatus, GitSubprocessError> { if output.status.success() { let updates = parse_ref_updates(&output.stdout)?; return Ok(GitFetchStatus::Updates(updates)); } // 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(GitFetchStatus::NoRemoteRef(refspec)); } let updates = parse_ref_updates(&output.stdout)?; if !updates.rejected.is_empty() || parse_no_remote_tracking_branch(&output.stderr).is_some() { Ok(GitFetchStatus::Updates(updates)) } else { Err(external_git_error(&output.stderr)) } } /// Local changes made by `git fetch`. #[derive(Clone, Debug, Default)] pub struct GitRefUpdates { /// Git ref `(name, (old_oid, new_oid))`s that are successfully updated. /// /// `old_oid`/`new_oid` may be null or point to non-commit objects such as /// tags. #[cfg_attr(not(test), expect(dead_code))] // unused as of now pub updated: Vec<(GitRefNameBuf, Diff<gix::ObjectId>)>, /// Git ref `(name, (old_oid, new_oid)`s that are rejected or failed to /// update. pub rejected: Vec<(GitRefNameBuf, Diff<gix::ObjectId>)>, } /// Parses porcelain output of `git fetch`. fn parse_ref_updates(stdout: &[u8]) -> Result<GitRefUpdates, GitSubprocessError> { let mut updated = vec![]; let mut rejected = vec![]; for (i, line) in stdout.lines().enumerate() { let parse_err = |message: &str| { GitSubprocessError::External(format!( "Line {line_no}: {message}: {line}", line_no = i + 1, line = BStr::new(line) )) }; // <flag> <old-object-id> <new-object-id> <local-reference> // (<flag> may be space) let mut line_bytes = line.iter(); let flag = *line_bytes.next().ok_or_else(|| parse_err("empty line"))?; if line_bytes.next() != Some(&b' ') { return Err(parse_err("no flag separator found")); } let [old_oid, new_oid, name] = line_bytes .as_slice() .splitn(3, |&b| b == b' ') .collect_array() .ok_or_else(|| parse_err("unexpected number of columns"))?; let name: GitRefNameBuf = str::from_utf8(name) .map_err(|_| parse_err("non-UTF-8 ref name"))? .into(); let old_oid = gix::ObjectId::from_hex(old_oid).map_err(|_| parse_err("invalid old oid"))?; let new_oid = gix::ObjectId::from_hex(new_oid).map_err(|_| parse_err("invalid new oid"))?; let oid_diff = Diff::new(old_oid, new_oid); match flag { // ' ' for a successfully fetched fast-forward // '+' for a successful forced update // '-' for a successfully pruned ref // 't' for a successful tag update // '*' for a successfully fetched new ref b' ' | b'+' | b'-' | b't' | b'*' => updated.push((name, oid_diff)), // '!' for a ref that was rejected or failed to update b'!' => rejected.push((name, oid_diff)), // '=' for a ref that was up to date and did not need fetching // (included when --verbose) b'=' => {} _ => return Err(parse_err("unknown flag")), } } Ok(GitRefUpdates { updated, rejected }) } fn parse_git_branch_prune_output(output: Output) -> Result<(), GitSubprocessError> { if output.status.success() {