Skip to main content

jj_lib/
git_subprocess.rs

1// Copyright 2025 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::io;
16use std::io::BufReader;
17use std::io::Read;
18use std::num::NonZeroU32;
19use std::path::PathBuf;
20use std::process::Child;
21use std::process::Command;
22use std::process::Output;
23use std::process::Stdio;
24use std::thread;
25
26use bstr::BStr;
27use bstr::ByteSlice as _;
28use itertools::Itertools as _;
29use thiserror::Error;
30
31use crate::git::FetchTagsOverride;
32use crate::git::GitPushOptions;
33use crate::git::GitPushStats;
34use crate::git::GitSubprocessOptions;
35use crate::git::NegativeRefSpec;
36use crate::git::RefSpec;
37use crate::git::RefToPush;
38use crate::git_backend::GitBackend;
39use crate::merge::Diff;
40use crate::ref_name::GitRefNameBuf;
41use crate::ref_name::RefNameBuf;
42use crate::ref_name::RemoteName;
43
44// * 2.29.0 introduced `git fetch --no-write-fetch-head`
45// * 2.40 still receives security patches (latest one was in Jan/2025)
46// * 2.41.0 introduced `git fetch --porcelain`
47// If bumped, please update ../../docs/install-and-setup.md
48const MINIMUM_GIT_VERSION: &str = "2.41.0";
49
50/// Error originating by a Git subprocess
51#[derive(Error, Debug)]
52pub enum GitSubprocessError {
53    #[error("Could not find repository at '{0}'")]
54    NoSuchRepository(String),
55    #[error("Could not execute the git process, found in the OS path '{path}'")]
56    SpawnInPath {
57        path: PathBuf,
58        #[source]
59        error: std::io::Error,
60    },
61    #[error("Could not execute git process at specified path '{path}'")]
62    Spawn {
63        path: PathBuf,
64        #[source]
65        error: std::io::Error,
66    },
67    #[error("Failed to wait for the git process")]
68    Wait(std::io::Error),
69    #[error(
70        "Git does not recognize required option: {0} (note: Jujutsu requires git >= \
71         {MINIMUM_GIT_VERSION})"
72    )]
73    UnsupportedGitOption(String),
74    #[error("Git process failed: {0}")]
75    External(String),
76}
77
78/// Context for creating Git subprocesses
79pub(crate) struct GitSubprocessContext {
80    git_dir: PathBuf,
81    options: GitSubprocessOptions,
82}
83
84impl GitSubprocessContext {
85    pub(crate) fn new(git_dir: impl Into<PathBuf>, options: GitSubprocessOptions) -> Self {
86        Self {
87            git_dir: git_dir.into(),
88            options,
89        }
90    }
91
92    pub(crate) fn from_git_backend(
93        git_backend: &GitBackend,
94        options: GitSubprocessOptions,
95    ) -> Self {
96        Self::new(git_backend.git_repo_path(), options)
97    }
98
99    /// Create the Git command
100    fn create_command(&self) -> Command {
101        let mut git_cmd = Command::new(&self.options.executable_path);
102        // Hide console window on Windows (https://stackoverflow.com/a/60958956)
103        #[cfg(windows)]
104        {
105            use std::os::windows::process::CommandExt as _;
106            const CREATE_NO_WINDOW: u32 = 0x08000000;
107            git_cmd.creation_flags(CREATE_NO_WINDOW);
108        }
109
110        // TODO: here we are passing the full path to the git_dir, which can lead to UNC
111        // bugs in Windows. The ideal way to do this is to pass the workspace
112        // root to Command::current_dir and then pass a relative path to the git
113        // dir
114        git_cmd
115            // The gitconfig-controlled automated spawning of the macOS `fsmonitor--daemon`
116            // can cause strange behavior with certain subprocess operations.
117            // For example: https://github.com/jj-vcs/jj/issues/6440.
118            //
119            // Nothing we're doing in `jj` interacts with this daemon, so we force the
120            // config to be false for subprocess operations in order to avoid these
121            // interactions.
122            //
123            // In a colocated workspace, the daemon will still get started the first
124            // time a `git` command is run manually if the gitconfigs are set up that way.
125            .args(["-c", "core.fsmonitor=false"])
126            // Avoids an error message when fetching repos with submodules if
127            // user has `submodule.recurse` configured to true in their Git
128            // config (#7565).
129            .args(["-c", "submodule.recurse=false"])
130            .arg("--git-dir")
131            .arg(&self.git_dir)
132            // Disable translation and other locale-dependent behavior so we can
133            // parse the output. LC_ALL precedes LC_* and LANG.
134            .env("LC_ALL", "C")
135            .stdin(Stdio::null())
136            .stderr(Stdio::piped());
137
138        git_cmd.envs(&self.options.environment);
139
140        git_cmd
141    }
142
143    /// Spawn the git command
144    fn spawn_cmd(&self, mut git_cmd: Command) -> Result<Child, GitSubprocessError> {
145        tracing::debug!(cmd = ?git_cmd, "spawning a git subprocess");
146
147        git_cmd.spawn().map_err(|error| {
148            if self.options.executable_path.is_absolute() {
149                GitSubprocessError::Spawn {
150                    path: self.options.executable_path.clone(),
151                    error,
152                }
153            } else {
154                GitSubprocessError::SpawnInPath {
155                    path: self.options.executable_path.clone(),
156                    error,
157                }
158            }
159        })
160    }
161
162    /// Perform a git fetch
163    ///
164    /// [`GitFetchStatus::NoRemoteRef`] is returned if ref doesn't exist. Note
165    /// that `git` only returns one failed ref at a time.
166    pub(crate) fn spawn_fetch(
167        &self,
168        remote_name: &RemoteName,
169        refspecs: &[RefSpec],
170        negative_refspecs: &[NegativeRefSpec],
171        callback: &mut dyn GitSubprocessCallback,
172        depth: Option<NonZeroU32>,
173        fetch_tags_override: Option<FetchTagsOverride>,
174    ) -> Result<GitFetchStatus, GitSubprocessError> {
175        if refspecs.is_empty() {
176            return Ok(GitFetchStatus::Updates(GitRefUpdates::default()));
177        }
178        let mut command = self.create_command();
179        command.stdout(Stdio::piped());
180        // attempt to prune stale refs with --prune
181        // --no-write-fetch-head ensures our request is invisible to other parties
182        command.args(["fetch", "--porcelain", "--prune", "--no-write-fetch-head"]);
183        if callback.needs_progress() {
184            command.arg("--progress");
185        }
186        if let Some(d) = depth {
187            command.arg(format!("--depth={d}"));
188        }
189        match fetch_tags_override {
190            Some(FetchTagsOverride::AllTags) => {
191                command.arg("--tags");
192            }
193            Some(FetchTagsOverride::NoTags) => {
194                command.arg("--no-tags");
195            }
196            None => {}
197        }
198        command.arg("--").arg(remote_name.as_str());
199        command.args(
200            refspecs
201                .iter()
202                .map(|x| x.to_git_format())
203                .chain(negative_refspecs.iter().map(|x| x.to_git_format())),
204        );
205
206        let output = wait_with_progress(self.spawn_cmd(command)?, callback)?;
207
208        parse_git_fetch_output(&output)
209    }
210
211    /// Prune particular branches
212    pub(crate) fn spawn_branch_prune(
213        &self,
214        branches_to_prune: &[String],
215    ) -> Result<(), GitSubprocessError> {
216        if branches_to_prune.is_empty() {
217            return Ok(());
218        }
219        tracing::debug!(?branches_to_prune, "pruning branches");
220        let mut command = self.create_command();
221        command.stdout(Stdio::null());
222        command.args(["branch", "--remotes", "--delete", "--"]);
223        command.args(branches_to_prune);
224
225        let output = wait_with_output(self.spawn_cmd(command)?)?;
226
227        // we name the type to make sure that it is not meant to be used
228        let () = parse_git_branch_prune_output(output)?;
229
230        Ok(())
231    }
232
233    /// How we retrieve the remote's default branch:
234    ///
235    /// `git remote show <remote_name>`
236    ///
237    /// dumps a lot of information about the remote, with a line such as:
238    /// `  HEAD branch: <default_branch>`
239    pub(crate) fn spawn_remote_show(
240        &self,
241        remote_name: &RemoteName,
242    ) -> Result<Option<RefNameBuf>, GitSubprocessError> {
243        let mut command = self.create_command();
244        command.stdout(Stdio::piped());
245        command.args(["remote", "show", "--", remote_name.as_str()]);
246        let output = wait_with_output(self.spawn_cmd(command)?)?;
247
248        let output = parse_git_remote_show_output(output)?;
249
250        // find the HEAD branch line in the output
251        let maybe_branch = parse_git_remote_show_default_branch(&output.stdout)?;
252        Ok(maybe_branch.map(Into::into))
253    }
254
255    /// Push references to git
256    ///
257    /// All pushes are forced, using --force-with-lease to perform a test&set
258    /// operation on the remote repository
259    ///
260    /// Return tuple with
261    ///     1. refs that failed to push
262    ///     2. refs that succeeded to push
263    pub(crate) fn spawn_push(
264        &self,
265        remote_name: &RemoteName,
266        references: &[RefToPush],
267        callback: &mut dyn GitSubprocessCallback,
268        options: &GitPushOptions,
269    ) -> Result<GitPushStats, GitSubprocessError> {
270        let mut command = self.create_command();
271        command.stdout(Stdio::piped());
272        // Currently jj does not support commit hooks, so we prevent git from running
273        // them
274        //
275        // https://github.com/jj-vcs/jj/issues/3577 and https://github.com/jj-vcs/jj/issues/405
276        // offer more context
277        command.args(["push", "--porcelain", "--no-verify"]);
278        if callback.needs_progress() {
279            command.arg("--progress");
280        }
281        command.args(
282            options
283                .remote_push_options
284                .iter()
285                .map(|option| format!("--push-option={option}")),
286        );
287        command.args(
288            references
289                .iter()
290                .map(|reference| format!("--force-with-lease={}", reference.to_git_lease())),
291        );
292        command.args(&options.extra_args);
293        command.args(["--", remote_name.as_str()]);
294        // with --force-with-lease we cannot have the forced refspec,
295        // as it ignores the lease
296        command.args(
297            references
298                .iter()
299                .map(|r| r.refspec.to_git_format_not_forced()),
300        );
301
302        let output = wait_with_progress(self.spawn_cmd(command)?, callback)?;
303
304        parse_git_push_output(output)
305    }
306}
307
308/// Generate a GitSubprocessError::ExternalGitError if the stderr output was not
309/// recognizable
310fn external_git_error(stderr: &[u8]) -> GitSubprocessError {
311    GitSubprocessError::External(format!(
312        "External git program failed:\n{}",
313        stderr.to_str_lossy()
314    ))
315}
316
317const ERROR_PREFIXES: &[&[u8]] = &[
318    // error_builtin() in usage.c
319    b"error: ",
320    // die_message_builtin() in usage.c
321    b"fatal: ",
322    // usage_builtin() in usage.c
323    b"usage: ",
324    // handle_option() in git.c
325    b"unknown option: ",
326];
327
328/// Parse no such remote errors output from git
329///
330/// Returns the remote that wasn't found
331///
332/// To say this, git prints out a lot of things, but the first line is of the
333/// form:
334/// `fatal: '<remote>' does not appear to be a git repository`
335/// or
336/// `fatal: '<remote>': Could not resolve host: invalid-remote`
337fn parse_no_such_remote(stderr: &[u8]) -> Option<String> {
338    let first_line = stderr.lines().next()?;
339    let suffix = first_line
340        .strip_prefix(b"fatal: '")
341        .or_else(|| first_line.strip_prefix(b"fatal: unable to access '"))?;
342
343    suffix
344        .strip_suffix(b"' does not appear to be a git repository")
345        .or_else(|| suffix.strip_suffix(b"': Could not resolve host: invalid-remote"))
346        .map(|remote| remote.to_str_lossy().into_owned())
347}
348
349/// Parse error from refspec not present on the remote
350///
351/// This returns
352///     Some(local_ref) that wasn't found by the remote
353///     None if this wasn't the error
354///
355/// On git fetch even though --prune is specified, if a particular
356/// refspec is asked for but not present in the remote, git will error out.
357///
358/// Git only reports one of these errors at a time, so we only look at the first
359/// line
360///
361/// The first line is of the form:
362/// `fatal: couldn't find remote ref refs/heads/<ref>`
363fn parse_no_remote_ref(stderr: &[u8]) -> Option<String> {
364    let first_line = stderr.lines().next()?;
365    first_line
366        .strip_prefix(b"fatal: couldn't find remote ref ")
367        .map(|refname| refname.to_str_lossy().into_owned())
368}
369
370/// Parse remote tracking branch not found
371///
372/// This returns true if the error was detected
373///
374/// if a branch is asked for but is not present, jj will detect it post-hoc
375/// so, we want to ignore these particular errors with git
376///
377/// The first line is of the form:
378/// `error: remote-tracking branch '<branch>' not found`
379fn parse_no_remote_tracking_branch(stderr: &[u8]) -> Option<String> {
380    let first_line = stderr.lines().next()?;
381
382    let suffix = first_line.strip_prefix(b"error: remote-tracking branch '")?;
383
384    suffix
385        .strip_suffix(b"' not found.")
386        .or_else(|| suffix.strip_suffix(b"' not found"))
387        .map(|branch| branch.to_str_lossy().into_owned())
388}
389
390/// Parse unknown options
391///
392/// Return the unknown option
393///
394/// If a user is running a very old git version, our commands may fail
395/// We want to give a good error in this case
396fn parse_unknown_option(stderr: &[u8]) -> Option<String> {
397    let first_line = stderr.lines().next()?;
398    first_line
399        .strip_prefix(b"unknown option: --")
400        .or(first_line
401            .strip_prefix(b"error: unknown option `")
402            .and_then(|s| s.strip_suffix(b"'")))
403        .map(|s| s.to_str_lossy().into())
404}
405
406/// Status of underlying `git fetch` operation.
407#[derive(Clone, Debug)]
408pub enum GitFetchStatus {
409    /// Successfully fetched refs. There may be refs that couldn't be updated.
410    Updates(GitRefUpdates),
411    /// Fully-qualified ref that failed to fetch.
412    ///
413    /// Note that `git fetch` only returns one error at a time.
414    NoRemoteRef(String),
415}
416
417fn parse_git_fetch_output(output: &Output) -> Result<GitFetchStatus, GitSubprocessError> {
418    if output.status.success() {
419        let updates = parse_ref_updates(&output.stdout)?;
420        return Ok(GitFetchStatus::Updates(updates));
421    }
422
423    // There are some git errors we want to parse out
424    if let Some(option) = parse_unknown_option(&output.stderr) {
425        return Err(GitSubprocessError::UnsupportedGitOption(option));
426    }
427
428    if let Some(remote) = parse_no_such_remote(&output.stderr) {
429        return Err(GitSubprocessError::NoSuchRepository(remote));
430    }
431
432    if let Some(refspec) = parse_no_remote_ref(&output.stderr) {
433        return Ok(GitFetchStatus::NoRemoteRef(refspec));
434    }
435
436    let updates = parse_ref_updates(&output.stdout)?;
437    if !updates.rejected.is_empty() || parse_no_remote_tracking_branch(&output.stderr).is_some() {
438        Ok(GitFetchStatus::Updates(updates))
439    } else {
440        Err(external_git_error(&output.stderr))
441    }
442}
443
444/// Local changes made by `git fetch`.
445#[derive(Clone, Debug, Default)]
446pub struct GitRefUpdates {
447    /// Git ref `(name, (old_oid, new_oid))`s that are successfully updated.
448    ///
449    /// `old_oid`/`new_oid` may be null or point to non-commit objects such as
450    /// tags.
451    #[cfg_attr(not(test), expect(dead_code))] // unused as of now
452    pub updated: Vec<(GitRefNameBuf, Diff<gix::ObjectId>)>,
453    /// Git ref `(name, (old_oid, new_oid)`s that are rejected or failed to
454    /// update.
455    pub rejected: Vec<(GitRefNameBuf, Diff<gix::ObjectId>)>,
456}
457
458/// Parses porcelain output of `git fetch`.
459fn parse_ref_updates(stdout: &[u8]) -> Result<GitRefUpdates, GitSubprocessError> {
460    let mut updated = vec![];
461    let mut rejected = vec![];
462    for (i, line) in stdout.lines().enumerate() {
463        let parse_err = |message: &str| {
464            GitSubprocessError::External(format!(
465                "Line {line_no}: {message}: {line}",
466                line_no = i + 1,
467                line = BStr::new(line)
468            ))
469        };
470        // <flag> <old-object-id> <new-object-id> <local-reference>
471        // (<flag> may be space)
472        let mut line_bytes = line.iter();
473        let flag = *line_bytes.next().ok_or_else(|| parse_err("empty line"))?;
474        if line_bytes.next() != Some(&b' ') {
475            return Err(parse_err("no flag separator found"));
476        }
477        let [old_oid, new_oid, name] = line_bytes
478            .as_slice()
479            .splitn(3, |&b| b == b' ')
480            .collect_array()
481            .ok_or_else(|| parse_err("unexpected number of columns"))?;
482        let name: GitRefNameBuf = str::from_utf8(name)
483            .map_err(|_| parse_err("non-UTF-8 ref name"))?
484            .into();
485        let old_oid = gix::ObjectId::from_hex(old_oid).map_err(|_| parse_err("invalid old oid"))?;
486        let new_oid = gix::ObjectId::from_hex(new_oid).map_err(|_| parse_err("invalid new oid"))?;
487        let oid_diff = Diff::new(old_oid, new_oid);
488        match flag {
489            // ' ' for a successfully fetched fast-forward
490            // '+' for a successful forced update
491            // '-' for a successfully pruned ref
492            // 't' for a successful tag update
493            // '*' for a successfully fetched new ref
494            b' ' | b'+' | b'-' | b't' | b'*' => updated.push((name, oid_diff)),
495            // '!' for a ref that was rejected or failed to update
496            b'!' => rejected.push((name, oid_diff)),
497            // '=' for a ref that was up to date and did not need fetching
498            // (included when --verbose)
499            b'=' => {}
500            _ => return Err(parse_err("unknown flag")),
501        }
502    }
503    Ok(GitRefUpdates { updated, rejected })
504}
505
506fn parse_git_branch_prune_output(output: Output) -> Result<(), GitSubprocessError> {
507    if output.status.success() {
508        return Ok(());
509    }
510
511    // There are some git errors we want to parse out
512    if let Some(option) = parse_unknown_option(&output.stderr) {
513        return Err(GitSubprocessError::UnsupportedGitOption(option));
514    }
515
516    if parse_no_remote_tracking_branch(&output.stderr).is_some() {
517        return Ok(());
518    }
519
520    Err(external_git_error(&output.stderr))
521}
522
523fn parse_git_remote_show_output(output: Output) -> Result<Output, GitSubprocessError> {
524    if output.status.success() {
525        return Ok(output);
526    }
527
528    // There are some git errors we want to parse out
529    if let Some(option) = parse_unknown_option(&output.stderr) {
530        return Err(GitSubprocessError::UnsupportedGitOption(option));
531    }
532
533    if let Some(remote) = parse_no_such_remote(&output.stderr) {
534        return Err(GitSubprocessError::NoSuchRepository(remote));
535    }
536
537    Err(external_git_error(&output.stderr))
538}
539
540fn parse_git_remote_show_default_branch(
541    stdout: &[u8],
542) -> Result<Option<String>, GitSubprocessError> {
543    stdout
544        .lines()
545        .map(|x| x.trim())
546        .find(|x| x.starts_with_str("HEAD branch:"))
547        .inspect(|x| tracing::debug!(line = ?x.to_str_lossy(), "default branch"))
548        .and_then(|x| x.split_str(" ").last().map(|y| y.trim()))
549        .filter(|branch_name| branch_name != b"(unknown)")
550        .map(|branch_name| branch_name.to_str())
551        .transpose()
552        .map_err(|e| GitSubprocessError::External(format!("git remote output is not utf-8: {e:?}")))
553        .map(|b| b.map(|x| x.to_string()))
554}
555
556// git-push porcelain has the following format (per line)
557// `<flag>\t<from>:<to>\t<summary> (<reason>)`
558//
559// <flag> is one of:
560//     ' ' for a successfully pushed fast-forward;
561//      + for a successful forced update
562//      - for a successfully deleted ref
563//      * for a successfully pushed new ref
564//      !  for a ref that was rejected or failed to push; and
565//      =  for a ref that was up to date and did not need pushing.
566//
567// <from>:<to> is the refspec
568//
569// <summary> is extra info (commit ranges or reason for rejected)
570//
571// <reason> is a human-readable explanation
572fn parse_ref_pushes(stdout: &[u8]) -> Result<GitPushStats, GitSubprocessError> {
573    if !stdout.starts_with(b"To ") {
574        return Err(GitSubprocessError::External(format!(
575            "Git push output unfamiliar:\n{}",
576            stdout.to_str_lossy()
577        )));
578    }
579
580    let mut push_stats = GitPushStats::default();
581    for (idx, line) in stdout
582        .lines()
583        .skip(1)
584        .take_while(|line| line != b"Done")
585        .enumerate()
586    {
587        tracing::debug!("response #{idx}: {}", line.to_str_lossy());
588        let [flag, reference, summary] = line.split_str("\t").collect_array().ok_or_else(|| {
589            GitSubprocessError::External(format!(
590                "Line #{idx} of git-push has unknown format: {}",
591                line.to_str_lossy()
592            ))
593        })?;
594        let full_refspec = reference
595            .to_str()
596            .map_err(|e| {
597                format!(
598                    "Line #{} of git-push has non-utf8 refspec {}: {}",
599                    idx,
600                    reference.to_str_lossy(),
601                    e
602                )
603            })
604            .map_err(GitSubprocessError::External)?;
605
606        let reference: GitRefNameBuf = full_refspec
607            .split_once(':')
608            .map(|(_refname, reference)| reference.into())
609            .ok_or_else(|| {
610                GitSubprocessError::External(format!(
611                    "Line #{idx} of git-push has full refspec without named ref: {full_refspec}"
612                ))
613            })?;
614
615        match flag {
616            // ' ' for a successfully pushed fast-forward;
617            //  + for a successful forced update
618            //  - for a successfully deleted ref
619            //  * for a successfully pushed new ref
620            //  =  for a ref that was up to date and did not need pushing.
621            b"+" | b"-" | b"*" | b"=" | b" " => {
622                push_stats.pushed.push(reference);
623            }
624            // ! for a ref that was rejected or failed to push; and
625            b"!" => {
626                if let Some(reason) = summary.strip_prefix(b"[remote rejected]") {
627                    let reason = reason
628                        .strip_prefix(b" (")
629                        .and_then(|r| r.strip_suffix(b")"))
630                        .map(|x| x.to_str_lossy().into_owned());
631                    push_stats.remote_rejected.push((reference, reason));
632                } else {
633                    let reason = summary
634                        .split_once_str("]")
635                        .and_then(|(_, reason)| reason.strip_prefix(b" ("))
636                        .and_then(|r| r.strip_suffix(b")"))
637                        .map(|x| x.to_str_lossy().into_owned());
638                    push_stats.rejected.push((reference, reason));
639                }
640            }
641            unknown => {
642                return Err(GitSubprocessError::External(format!(
643                    "Line #{} of git-push starts with an unknown flag '{}': '{}'",
644                    idx,
645                    unknown.to_str_lossy(),
646                    line.to_str_lossy()
647                )));
648            }
649        }
650    }
651
652    Ok(push_stats)
653}
654
655// on Ok, return a tuple with
656//  1. list of failed references from test and set
657//  2. list of successful references pushed
658fn parse_git_push_output(output: Output) -> Result<GitPushStats, GitSubprocessError> {
659    if output.status.success() {
660        let ref_pushes = parse_ref_pushes(&output.stdout)?;
661        return Ok(ref_pushes);
662    }
663
664    if let Some(option) = parse_unknown_option(&output.stderr) {
665        return Err(GitSubprocessError::UnsupportedGitOption(option));
666    }
667
668    if let Some(remote) = parse_no_such_remote(&output.stderr) {
669        return Err(GitSubprocessError::NoSuchRepository(remote));
670    }
671
672    if output
673        .stderr
674        .lines()
675        .any(|line| line.starts_with(b"error: failed to push some refs to "))
676    {
677        parse_ref_pushes(&output.stdout)
678    } else {
679        Err(external_git_error(&output.stderr))
680    }
681}
682
683/// Handles Git command outputs.
684pub trait GitSubprocessCallback {
685    /// Whether to request progress information.
686    fn needs_progress(&self) -> bool;
687
688    /// Progress of local and remote operations.
689    fn progress(&mut self, progress: &GitProgress) -> io::Result<()>;
690
691    /// Single-line message that doesn't look like remote sideband or error.
692    ///
693    /// This may include authentication request from credential helpers.
694    fn local_sideband(
695        &mut self,
696        message: &[u8],
697        term: Option<GitSidebandLineTerminator>,
698    ) -> io::Result<()>;
699
700    /// Single-line sideband message received from remote.
701    fn remote_sideband(
702        &mut self,
703        message: &[u8],
704        term: Option<GitSidebandLineTerminator>,
705    ) -> io::Result<()>;
706}
707
708/// Newline character that terminates sideband message line.
709#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
710#[repr(u8)]
711pub enum GitSidebandLineTerminator {
712    /// CR to remain on the same line.
713    Cr = b'\r',
714    /// LF to move to the next line.
715    Lf = b'\n',
716}
717
718impl GitSidebandLineTerminator {
719    /// Returns byte representation.
720    pub fn as_byte(self) -> u8 {
721        self as u8
722    }
723}
724
725fn wait_with_output(child: Child) -> Result<Output, GitSubprocessError> {
726    child.wait_with_output().map_err(GitSubprocessError::Wait)
727}
728
729/// Like `wait_with_output()`, but also emits sideband data through callback.
730///
731/// Git remotes can send custom messages on fetch and push, which the `git`
732/// command prepends with `remote: `.
733///
734/// For instance, these messages can provide URLs to create Pull Requests
735/// e.g.:
736/// ```ignore
737/// $ jj git push -c @
738/// [...]
739/// remote:
740/// remote: Create a pull request for 'branch' on GitHub by visiting:
741/// remote:      https://github.com/user/repo/pull/new/branch
742/// remote:
743/// ```
744///
745/// The returned `stderr` content does not include sideband messages.
746fn wait_with_progress(
747    mut child: Child,
748    callback: &mut dyn GitSubprocessCallback,
749) -> Result<Output, GitSubprocessError> {
750    let (stdout, stderr) = thread::scope(|s| -> io::Result<_> {
751        drop(child.stdin.take());
752        let mut child_stdout = child.stdout.take().expect("stdout should be piped");
753        let mut child_stderr = child.stderr.take().expect("stderr should be piped");
754        let thread = s.spawn(move || -> io::Result<_> {
755            let mut buf = Vec::new();
756            child_stdout.read_to_end(&mut buf)?;
757            Ok(buf)
758        });
759        let stderr = read_to_end_with_progress(&mut child_stderr, callback)?;
760        let stdout = thread.join().expect("reader thread wouldn't panic")?;
761        Ok((stdout, stderr))
762    })
763    .map_err(GitSubprocessError::Wait)?;
764    let status = child.wait().map_err(GitSubprocessError::Wait)?;
765    Ok(Output {
766        status,
767        stdout,
768        stderr,
769    })
770}
771
772/// Progress of underlying `git` command operation.
773#[derive(Clone, Debug, Default)]
774pub struct GitProgress {
775    /// `(frac, total)` of "Resolving deltas".
776    pub deltas: (u64, u64),
777    /// `(frac, total)` of "Receiving objects".
778    pub objects: (u64, u64),
779    /// `(frac, total)` of remote "Counting objects".
780    pub counted_objects: (u64, u64),
781    /// `(frac, total)` of remote "Compressing objects".
782    pub compressed_objects: (u64, u64),
783}
784
785// TODO: maybe let callers print each field separately and remove overall()?
786impl GitProgress {
787    /// Overall progress normalized to 0 to 1 range.
788    pub fn overall(&self) -> f32 {
789        if self.total() != 0 {
790            self.fraction() as f32 / self.total() as f32
791        } else {
792            0.0
793        }
794    }
795
796    fn fraction(&self) -> u64 {
797        self.objects.0 + self.deltas.0 + self.counted_objects.0 + self.compressed_objects.0
798    }
799
800    fn total(&self) -> u64 {
801        self.objects.1 + self.deltas.1 + self.counted_objects.1 + self.compressed_objects.1
802    }
803}
804
805fn read_to_end_with_progress<R: Read>(
806    src: R,
807    callback: &mut dyn GitSubprocessCallback,
808) -> io::Result<Vec<u8>> {
809    let mut reader = BufReader::new(src);
810    let mut data = Vec::new();
811    let mut progress = GitProgress::default();
812
813    loop {
814        // progress sent through sideband channel may be terminated by \r
815        let start = data.len();
816        read_until_cr_or_lf(&mut reader, &mut data)?;
817        let line = &data[start..];
818        if line.is_empty() {
819            break;
820        }
821
822        // capture error messages which will be interpreted by caller
823        if ERROR_PREFIXES.iter().any(|prefix| line.starts_with(prefix)) {
824            reader.read_to_end(&mut data)?;
825            break;
826        }
827
828        // io::Error coming from callback shouldn't be propagated as an error of
829        // "read" operation. The error is suppressed for now.
830        // TODO: maybe intercept "push" progress? (see builtin/pack-objects.c)
831        if update_progress(line, &mut progress.objects, b"Receiving objects:")
832            || update_progress(line, &mut progress.deltas, b"Resolving deltas:")
833            || update_progress(
834                line,
835                &mut progress.counted_objects,
836                b"remote: Counting objects:",
837            )
838            || update_progress(
839                line,
840                &mut progress.compressed_objects,
841                b"remote: Compressing objects:",
842            )
843        {
844            callback.progress(&progress).ok();
845            data.truncate(start);
846        } else if let Some(message) = line.strip_prefix(b"remote: ") {
847            let (body, term) = trim_sideband_line(message);
848            callback.remote_sideband(body, term).ok();
849            data.truncate(start);
850        } else {
851            let (body, term) = trim_sideband_line(line);
852            callback.local_sideband(body, term).ok();
853            data.truncate(start);
854        }
855    }
856    Ok(data)
857}
858
859fn update_progress(line: &[u8], progress: &mut (u64, u64), prefix: &[u8]) -> bool {
860    if let Some(line) = line.strip_prefix(prefix) {
861        if let Some((frac, total)) = read_progress_line(line) {
862            *progress = (frac, total);
863        }
864
865        true
866    } else {
867        false
868    }
869}
870
871fn read_until_cr_or_lf<R: io::BufRead + ?Sized>(
872    reader: &mut R,
873    dest_buf: &mut Vec<u8>,
874) -> io::Result<()> {
875    loop {
876        let data = match reader.fill_buf() {
877            Ok(data) => data,
878            Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
879            Err(err) => return Err(err),
880        };
881        let (n, found) = match data.iter().position(|&b| matches!(b, b'\r' | b'\n')) {
882            Some(i) => (i + 1, true),
883            None => (data.len(), false),
884        };
885
886        dest_buf.extend_from_slice(&data[..n]);
887        reader.consume(n);
888
889        if found || n == 0 {
890            return Ok(());
891        }
892    }
893}
894
895/// Read progress lines of the form: `<text> (<frac>/<total>)`
896/// Ensures that frac < total
897fn read_progress_line(line: &[u8]) -> Option<(u64, u64)> {
898    // isolate the part between parenthesis
899    let (_prefix, suffix) = line.split_once_str("(")?;
900    let (fraction, _suffix) = suffix.split_once_str(")")?;
901
902    // split over the '/'
903    let (frac_str, total_str) = fraction.split_once_str("/")?;
904
905    // parse to integers
906    let frac = frac_str.to_str().ok()?.parse().ok()?;
907    let total = total_str.to_str().ok()?.parse().ok()?;
908    (frac <= total).then_some((frac, total))
909}
910
911/// Removes trailing spaces from sideband line, which may be padded by the `git`
912/// CLI in order to clear the previous progress line.
913fn trim_sideband_line(line: &[u8]) -> (&[u8], Option<GitSidebandLineTerminator>) {
914    let (body, term) = match line {
915        [body @ .., b'\r'] => (body, Some(GitSidebandLineTerminator::Cr)),
916        [body @ .., b'\n'] => (body, Some(GitSidebandLineTerminator::Lf)),
917        _ => (line, None),
918    };
919    let n = body.iter().rev().take_while(|&&b| b == b' ').count();
920    (&body[..body.len() - n], term)
921}
922
923#[cfg(test)]
924mod test {
925    use std::process::ExitStatus;
926
927    use assert_matches::assert_matches;
928    use bstr::BString;
929    use indoc::formatdoc;
930    use indoc::indoc;
931
932    use super::*;
933
934    const SAMPLE_NO_SUCH_REPOSITORY_ERROR: &[u8] =
935        br###"fatal: unable to access 'origin': Could not resolve host: invalid-remote
936fatal: Could not read from remote repository.
937
938Please make sure you have the correct access rights
939and the repository exists. "###;
940    const SAMPLE_NO_SUCH_REMOTE_ERROR: &[u8] =
941        br###"fatal: 'origin' does not appear to be a git repository
942fatal: Could not read from remote repository.
943
944Please make sure you have the correct access rights
945and the repository exists. "###;
946    const SAMPLE_NO_REMOTE_REF_ERROR: &[u8] = b"fatal: couldn't find remote ref refs/heads/noexist";
947    const SAMPLE_NO_REMOTE_TRACKING_BRANCH_ERROR: &[u8] =
948        b"error: remote-tracking branch 'bookmark' not found";
949    const SAMPLE_PUSH_REFS_PORCELAIN_OUTPUT: &[u8] = b"To origin
950*\tdeadbeef:refs/heads/bookmark1\t[new branch]
951+\tdeadbeef:refs/heads/bookmark2\tabcd..dead
952-\tdeadbeef:refs/heads/bookmark3\t[deleted branch]
953 \tdeadbeef:refs/heads/bookmark4\tabcd..dead
954=\tdeadbeef:refs/heads/bookmark5\tabcd..abcd
955!\tdeadbeef:refs/heads/bookmark6\t[rejected] (failure lease)
956!\tdeadbeef:refs/heads/bookmark7\t[rejected]
957!\tdeadbeef:refs/heads/bookmark8\t[remote rejected] (hook failure)
958!\tdeadbeef:refs/heads/bookmark9\t[remote rejected]
959Done";
960    const SAMPLE_OK_STDERR: &[u8] = b"";
961
962    #[derive(Debug, Default)]
963    struct GitSubprocessCapture {
964        progress: Vec<GitProgress>,
965        local_sideband: Vec<BString>,
966        remote_sideband: Vec<BString>,
967    }
968
969    impl GitSubprocessCallback for GitSubprocessCapture {
970        fn needs_progress(&self) -> bool {
971            true
972        }
973
974        fn progress(&mut self, progress: &GitProgress) -> io::Result<()> {
975            self.progress.push(progress.clone());
976            Ok(())
977        }
978
979        fn local_sideband(
980            &mut self,
981            message: &[u8],
982            term: Option<GitSidebandLineTerminator>,
983        ) -> io::Result<()> {
984            self.local_sideband.push(message.into());
985            if let Some(term) = term {
986                self.local_sideband.push([term.as_byte()].into());
987            }
988            Ok(())
989        }
990
991        fn remote_sideband(
992            &mut self,
993            message: &[u8],
994            term: Option<GitSidebandLineTerminator>,
995        ) -> io::Result<()> {
996            self.remote_sideband.push(message.into());
997            if let Some(term) = term {
998                self.remote_sideband.push([term.as_byte()].into());
999            }
1000            Ok(())
1001        }
1002    }
1003
1004    fn exit_status_from_code(code: u8) -> ExitStatus {
1005        #[cfg(unix)]
1006        use std::os::unix::process::ExitStatusExt as _; // i32
1007        #[cfg(windows)]
1008        use std::os::windows::process::ExitStatusExt as _; // u32
1009        ExitStatus::from_raw(code.into())
1010    }
1011
1012    #[test]
1013    fn test_parse_no_such_remote() {
1014        assert_eq!(
1015            parse_no_such_remote(SAMPLE_NO_SUCH_REPOSITORY_ERROR),
1016            Some("origin".to_string())
1017        );
1018        assert_eq!(
1019            parse_no_such_remote(SAMPLE_NO_SUCH_REMOTE_ERROR),
1020            Some("origin".to_string())
1021        );
1022        assert_eq!(parse_no_such_remote(SAMPLE_NO_REMOTE_REF_ERROR), None);
1023        assert_eq!(
1024            parse_no_such_remote(SAMPLE_NO_REMOTE_TRACKING_BRANCH_ERROR),
1025            None
1026        );
1027        assert_eq!(
1028            parse_no_such_remote(SAMPLE_PUSH_REFS_PORCELAIN_OUTPUT),
1029            None
1030        );
1031        assert_eq!(parse_no_such_remote(SAMPLE_OK_STDERR), None);
1032    }
1033
1034    #[test]
1035    fn test_parse_no_remote_ref() {
1036        assert_eq!(parse_no_remote_ref(SAMPLE_NO_SUCH_REPOSITORY_ERROR), None);
1037        assert_eq!(parse_no_remote_ref(SAMPLE_NO_SUCH_REMOTE_ERROR), None);
1038        assert_eq!(
1039            parse_no_remote_ref(SAMPLE_NO_REMOTE_REF_ERROR),
1040            Some("refs/heads/noexist".to_string())
1041        );
1042        assert_eq!(
1043            parse_no_remote_ref(SAMPLE_NO_REMOTE_TRACKING_BRANCH_ERROR),
1044            None
1045        );
1046        assert_eq!(parse_no_remote_ref(SAMPLE_PUSH_REFS_PORCELAIN_OUTPUT), None);
1047        assert_eq!(parse_no_remote_ref(SAMPLE_OK_STDERR), None);
1048    }
1049
1050    #[test]
1051    fn test_parse_no_remote_tracking_branch() {
1052        assert_eq!(
1053            parse_no_remote_tracking_branch(SAMPLE_NO_SUCH_REPOSITORY_ERROR),
1054            None
1055        );
1056        assert_eq!(
1057            parse_no_remote_tracking_branch(SAMPLE_NO_SUCH_REMOTE_ERROR),
1058            None
1059        );
1060        assert_eq!(
1061            parse_no_remote_tracking_branch(SAMPLE_NO_REMOTE_REF_ERROR),
1062            None
1063        );
1064        assert_eq!(
1065            parse_no_remote_tracking_branch(SAMPLE_NO_REMOTE_TRACKING_BRANCH_ERROR),
1066            Some("bookmark".to_string())
1067        );
1068        assert_eq!(
1069            parse_no_remote_tracking_branch(SAMPLE_PUSH_REFS_PORCELAIN_OUTPUT),
1070            None
1071        );
1072        assert_eq!(parse_no_remote_tracking_branch(SAMPLE_OK_STDERR), None);
1073    }
1074
1075    #[test]
1076    fn test_parse_git_fetch_output_rejected() {
1077        // `git fetch` exists with 1 if there are rejected updates.
1078        let output = Output {
1079            status: exit_status_from_code(1),
1080            stdout: b"! d4d535f1d5795c6027f2872b24b7268ece294209 baad96fead6cdc20d47c55a4069c82952f9ac62c refs/remotes/origin/b\n".to_vec(),
1081            stderr: b"".to_vec(),
1082        };
1083        assert_matches!(
1084            parse_git_fetch_output(&output),
1085            Ok(GitFetchStatus::Updates(updates))
1086                if updates.updated.is_empty() && updates.rejected.len() == 1
1087        );
1088    }
1089
1090    #[test]
1091    fn test_parse_ref_updates_sample() {
1092        let sample = indoc! {b"
1093            * 0000000000000000000000000000000000000000 e80d998ab04be7caeac3a732d74b1708aa3d8b26 refs/remotes/origin/a1
1094              ebeb70d8c5f972275f0a22f7af6bc9ddb175ebd9 9175cb3250fd266fe46dcc13664b255a19234286 refs/remotes/origin/a2
1095            + c8303692b8e2f0326cd33873a157b4fa69d54774 798c5e2435e1442946db90a50d47ab90f40c60b7 refs/remotes/origin/a3
1096            - b2ea51c027e11c0f2871cce2a52e648e194df771 0000000000000000000000000000000000000000 refs/remotes/origin/a4
1097            ! d4d535f1d5795c6027f2872b24b7268ece294209 baad96fead6cdc20d47c55a4069c82952f9ac62c refs/remotes/origin/b
1098            = f8e7139764d76132234c13210b6f0abe6b1d9bf6 f8e7139764d76132234c13210b6f0abe6b1d9bf6 refs/remotes/upstream/c
1099            * 0000000000000000000000000000000000000000 fd5b6a095a77575c94fad4164ab580331316c374 refs/tags/v1.0
1100            t 0000000000000000000000000000000000000000 3262fedde0224462bb6ac3015dabc427a4f98316 refs/tags/v2.0
1101        "};
1102        insta::assert_debug_snapshot!(parse_ref_updates(sample).unwrap(), @r#"
1103        GitRefUpdates {
1104            updated: [
1105                (
1106                    GitRefNameBuf(
1107                        "refs/remotes/origin/a1",
1108                    ),
1109                    Diff {
1110                        before: Sha1(0000000000000000000000000000000000000000),
1111                        after: Sha1(e80d998ab04be7caeac3a732d74b1708aa3d8b26),
1112                    },
1113                ),
1114                (
1115                    GitRefNameBuf(
1116                        "refs/remotes/origin/a2",
1117                    ),
1118                    Diff {
1119                        before: Sha1(ebeb70d8c5f972275f0a22f7af6bc9ddb175ebd9),
1120                        after: Sha1(9175cb3250fd266fe46dcc13664b255a19234286),
1121                    },
1122                ),
1123                (
1124                    GitRefNameBuf(
1125                        "refs/remotes/origin/a3",
1126                    ),
1127                    Diff {
1128                        before: Sha1(c8303692b8e2f0326cd33873a157b4fa69d54774),
1129                        after: Sha1(798c5e2435e1442946db90a50d47ab90f40c60b7),
1130                    },
1131                ),
1132                (
1133                    GitRefNameBuf(
1134                        "refs/remotes/origin/a4",
1135                    ),
1136                    Diff {
1137                        before: Sha1(b2ea51c027e11c0f2871cce2a52e648e194df771),
1138                        after: Sha1(0000000000000000000000000000000000000000),
1139                    },
1140                ),
1141                (
1142                    GitRefNameBuf(
1143                        "refs/tags/v1.0",
1144                    ),
1145                    Diff {
1146                        before: Sha1(0000000000000000000000000000000000000000),
1147                        after: Sha1(fd5b6a095a77575c94fad4164ab580331316c374),
1148                    },
1149                ),
1150                (
1151                    GitRefNameBuf(
1152                        "refs/tags/v2.0",
1153                    ),
1154                    Diff {
1155                        before: Sha1(0000000000000000000000000000000000000000),
1156                        after: Sha1(3262fedde0224462bb6ac3015dabc427a4f98316),
1157                    },
1158                ),
1159            ],
1160            rejected: [
1161                (
1162                    GitRefNameBuf(
1163                        "refs/remotes/origin/b",
1164                    ),
1165                    Diff {
1166                        before: Sha1(d4d535f1d5795c6027f2872b24b7268ece294209),
1167                        after: Sha1(baad96fead6cdc20d47c55a4069c82952f9ac62c),
1168                    },
1169                ),
1170            ],
1171        }
1172        "#);
1173    }
1174
1175    #[test]
1176    fn test_parse_ref_updates_malformed() {
1177        assert!(parse_ref_updates(b"").is_ok());
1178        assert!(parse_ref_updates(b"\n").is_err());
1179        assert!(parse_ref_updates(b"*\n").is_err());
1180        let oid = "0000000000000000000000000000000000000000";
1181        assert!(parse_ref_updates(format!("**{oid} {oid} name\n").as_bytes()).is_err());
1182    }
1183
1184    #[test]
1185    fn test_parse_ref_pushes() {
1186        assert!(parse_ref_pushes(SAMPLE_NO_SUCH_REPOSITORY_ERROR).is_err());
1187        assert!(parse_ref_pushes(SAMPLE_NO_SUCH_REMOTE_ERROR).is_err());
1188        assert!(parse_ref_pushes(SAMPLE_NO_REMOTE_REF_ERROR).is_err());
1189        assert!(parse_ref_pushes(SAMPLE_NO_REMOTE_TRACKING_BRANCH_ERROR).is_err());
1190        let GitPushStats {
1191            pushed,
1192            rejected,
1193            remote_rejected,
1194            unexported_bookmarks: _,
1195        } = parse_ref_pushes(SAMPLE_PUSH_REFS_PORCELAIN_OUTPUT).unwrap();
1196        assert_eq!(
1197            pushed,
1198            [
1199                "refs/heads/bookmark1",
1200                "refs/heads/bookmark2",
1201                "refs/heads/bookmark3",
1202                "refs/heads/bookmark4",
1203                "refs/heads/bookmark5",
1204            ]
1205            .map(GitRefNameBuf::from)
1206        );
1207        assert_eq!(
1208            rejected,
1209            vec![
1210                (
1211                    "refs/heads/bookmark6".into(),
1212                    Some("failure lease".to_string())
1213                ),
1214                ("refs/heads/bookmark7".into(), None),
1215            ]
1216        );
1217        assert_eq!(
1218            remote_rejected,
1219            vec![
1220                (
1221                    "refs/heads/bookmark8".into(),
1222                    Some("hook failure".to_string())
1223                ),
1224                ("refs/heads/bookmark9".into(), None)
1225            ]
1226        );
1227        assert!(parse_ref_pushes(SAMPLE_OK_STDERR).is_err());
1228    }
1229
1230    #[test]
1231    fn test_read_to_end_with_progress() {
1232        let read = |sample: &[u8]| {
1233            let mut callback = GitSubprocessCapture::default();
1234            let output = read_to_end_with_progress(&mut &sample[..], &mut callback).unwrap();
1235            (output, callback)
1236        };
1237        const DUMB_SUFFIX: &str = "        ";
1238        let sample = formatdoc! {"
1239            remote: line1{DUMB_SUFFIX}
1240            blah blah
1241            remote: line2.0{DUMB_SUFFIX}\rremote: line2.1{DUMB_SUFFIX}
1242            remote: line3{DUMB_SUFFIX}
1243            Resolving deltas: (12/24)
1244            fatal: some error message
1245            continues
1246        "};
1247
1248        let (output, callback) = read(sample.as_bytes());
1249        assert_eq!(callback.local_sideband, ["blah blah", "\n"]);
1250        assert_eq!(
1251            callback.remote_sideband,
1252            [
1253                "line1", "\n", "line2.0", "\r", "line2.1", "\n", "line3", "\n"
1254            ]
1255        );
1256        assert_eq!(output, b"fatal: some error message\ncontinues\n");
1257        insta::assert_debug_snapshot!(callback.progress, @"
1258        [
1259            GitProgress {
1260                deltas: (
1261                    12,
1262                    24,
1263                ),
1264                objects: (
1265                    0,
1266                    0,
1267                ),
1268                counted_objects: (
1269                    0,
1270                    0,
1271                ),
1272                compressed_objects: (
1273                    0,
1274                    0,
1275                ),
1276            },
1277        ]
1278        ");
1279
1280        // without last newline
1281        let (output, callback) = read(sample.as_bytes().trim_end());
1282        assert_eq!(
1283            callback.remote_sideband,
1284            [
1285                "line1", "\n", "line2.0", "\r", "line2.1", "\n", "line3", "\n"
1286            ]
1287        );
1288        assert_eq!(output, b"fatal: some error message\ncontinues");
1289    }
1290
1291    #[test]
1292    fn test_read_progress_line() {
1293        assert_eq!(
1294            read_progress_line(b"Receiving objects: (42/100)\r"),
1295            Some((42, 100))
1296        );
1297        assert_eq!(
1298            read_progress_line(b"Resolving deltas: (0/1000)\r"),
1299            Some((0, 1000))
1300        );
1301        assert_eq!(read_progress_line(b"Receiving objects: (420/100)\r"), None);
1302        assert_eq!(
1303            read_progress_line(b"remote: this is something else\n"),
1304            None
1305        );
1306        assert_eq!(read_progress_line(b"fatal: this is a git error\n"), None);
1307    }
1308
1309    #[test]
1310    fn test_parse_unknown_option() {
1311        assert_eq!(
1312            parse_unknown_option(b"unknown option: --abc").unwrap(),
1313            "abc".to_string()
1314        );
1315        assert_eq!(
1316            parse_unknown_option(b"error: unknown option `abc'").unwrap(),
1317            "abc".to_string()
1318        );
1319        assert!(parse_unknown_option(b"error: unknown option: 'abc'").is_none());
1320    }
1321
1322    #[test]
1323    fn test_initial_overall_progress_is_zero() {
1324        assert_eq!(GitProgress::default().overall(), 0.0);
1325    }
1326}