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