1316 lines · 45.2 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::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::FetchTagsOverride;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`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: supported version is \         {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 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.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>,        fetch_tags_override: Option<FetchTagsOverride>,    ) -> 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}"));        }        match fetch_tags_override {            Some(FetchTagsOverride::AllTags) => {                command.arg("--tags");            }            Some(FetchTagsOverride::NoTags) => {                command.arg("--no-tags");            }            None => {}        }        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,    ) -> 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(            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() {        return Ok(());    } 

Lines 1–500 of 1316.