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