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