977 lines · 32.8 KB · Rust
RawDownload
// 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::GitPushStats;use crate::git::Progress;use crate::git::RefSpec;use crate::git::RefToPush;use crate::git::RemoteCallbacks;use crate::git_backend::GitBackend;use crate::ref_name::GitRefNameBuf;use crate::ref_name::RefNameBuf;use crate::ref_name::RemoteName; // 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),} /// 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 {        Self {            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 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 repo, 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"])            .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: &RemoteName,        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.as_str());        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: &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],        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.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)?, 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_stats = 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_array().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: GitRefNameBuf = full_refspec            .split_once(':')            .map(|(_refname, reference)| reference.into())            .ok_or_else(|| {                GitSubprocessError::External(format!(                    "Line #{idx} of git-push has full refspec without named ref: {full_refspec}"                ))            })?;         match flag {

Lines 1–500 of 977.