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::ByteSlice as _;
28use itertools::Itertools as _;
29use thiserror::Error;
30
31use crate::git::GitPushStats;
32use crate::git::Progress;
33use crate::git::RefSpec;
34use crate::git::RefToPush;
35use crate::git::RemoteCallbacks;
36use crate::git_backend::GitBackend;
37use crate::ref_name::GitRefNameBuf;
38use crate::ref_name::RefNameBuf;
39use crate::ref_name::RemoteName;
40
41// This is not the minimum required version, that would be 2.29.0, which
42// introduced the `--no-write-fetch-head` option. However, that by itself
43// is quite old and unsupported, so we don't want to encourage users to
44// update to that.
45//
46// 2.40 still receives security patches (latest one was in Jan/2025)
47const MINIMUM_GIT_VERSION: &str = "2.40.4";
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<'a> {
79    git_dir: PathBuf,
80    git_executable_path: &'a Path,
81}
82
83impl<'a> GitSubprocessContext<'a> {
84    pub(crate) fn new(git_dir: impl Into<PathBuf>, git_executable_path: &'a Path) -> Self {
85        GitSubprocessContext {
86            git_dir: git_dir.into(),
87            git_executable_path,
88        }
89    }
90
91    pub(crate) fn from_git_backend(
92        git_backend: &GitBackend,
93        git_executable_path: &'a Path,
94    ) -> Self {
95        Self::new(git_backend.git_repo_path(), git_executable_path)
96    }
97
98    /// Create the Git command
99    fn create_command(&self) -> Command {
100        let mut git_cmd = Command::new(self.git_executable_path);
101        // Hide console window on Windows (https://stackoverflow.com/a/60958956)
102        #[cfg(windows)]
103        {
104            use std::os::windows::process::CommandExt;
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 repo, the daemon will still get started the first time a `git`
123            // command is run manually if the gitconfigs are set up that way.
124            .args(["-c", "core.fsmonitor=false"])
125            .arg("--git-dir")
126            .arg(&self.git_dir)
127            // Disable translation and other locale-dependent behavior so we can
128            // parse the output. LC_ALL precedes LC_* and LANG.
129            .env("LC_ALL", "C")
130            .stdin(Stdio::null())
131            .stderr(Stdio::piped());
132
133        git_cmd
134    }
135
136    /// Spawn the git command
137    fn spawn_cmd(&self, mut git_cmd: Command) -> Result<Child, GitSubprocessError> {
138        tracing::debug!(cmd = ?git_cmd, "spawning a git subprocess");
139        git_cmd.spawn().map_err(|error| {
140            if self.git_executable_path.is_absolute() {
141                GitSubprocessError::Spawn {
142                    path: self.git_executable_path.to_path_buf(),
143                    error,
144                }
145            } else {
146                GitSubprocessError::SpawnInPath {
147                    path: self.git_executable_path.to_path_buf(),
148                    error,
149                }
150            }
151        })
152    }
153
154    /// Perform a git fetch
155    ///
156    /// This returns a fully qualified ref that wasn't fetched successfully
157    /// Note that git only returns one failed ref at a time
158    pub(crate) fn spawn_fetch(
159        &self,
160        remote_name: &RemoteName,
161        refspecs: &[RefSpec],
162        callbacks: &mut RemoteCallbacks<'_>,
163        depth: Option<NonZeroU32>,
164    ) -> Result<Option<String>, GitSubprocessError> {
165        if refspecs.is_empty() {
166            return Ok(None);
167        }
168        let mut command = self.create_command();
169        command.stdout(Stdio::piped());
170        // attempt to prune stale refs with --prune
171        // --no-write-fetch-head ensures our request is invisible to other parties
172        command.args(["fetch", "--prune", "--no-write-fetch-head"]);
173        if callbacks.progress.is_some() {
174            command.arg("--progress");
175        }
176        if let Some(d) = depth {
177            command.arg(format!("--depth={d}"));
178        }
179        command.arg("--").arg(remote_name.as_str());
180        command.args(refspecs.iter().map(|x| x.to_git_format()));
181
182        let output = wait_with_progress(self.spawn_cmd(command)?, callbacks)?;
183
184        parse_git_fetch_output(output)
185    }
186
187    /// Prune particular branches
188    pub(crate) fn spawn_branch_prune(
189        &self,
190        branches_to_prune: &[String],
191    ) -> Result<(), GitSubprocessError> {
192        if branches_to_prune.is_empty() {
193            return Ok(());
194        }
195        tracing::debug!(?branches_to_prune, "pruning branches");
196        let mut command = self.create_command();
197        command.stdout(Stdio::null());
198        command.args(["branch", "--remotes", "--delete", "--"]);
199        command.args(branches_to_prune);
200
201        let output = wait_with_output(self.spawn_cmd(command)?)?;
202
203        // we name the type to make sure that it is not meant to be used
204        let () = parse_git_branch_prune_output(output)?;
205
206        Ok(())
207    }
208
209    /// How we retrieve the remote's default branch:
210    ///
211    /// `git remote show <remote_name>`
212    ///
213    /// dumps a lot of information about the remote, with a line such as:
214    /// `  HEAD branch: <default_branch>`
215    pub(crate) fn spawn_remote_show(
216        &self,
217        remote_name: &RemoteName,
218    ) -> Result<Option<RefNameBuf>, GitSubprocessError> {
219        let mut command = self.create_command();
220        command.stdout(Stdio::piped());
221        command.args(["remote", "show", "--", remote_name.as_str()]);
222        let output = wait_with_output(self.spawn_cmd(command)?)?;
223
224        let output = parse_git_remote_show_output(output)?;
225
226        // find the HEAD branch line in the output
227        let maybe_branch = parse_git_remote_show_default_branch(&output.stdout)?;
228        Ok(maybe_branch.map(Into::into))
229    }
230
231    /// Push references to git
232    ///
233    /// All pushes are forced, using --force-with-lease to perform a test&set
234    /// operation on the remote repository
235    ///
236    /// Return tuple with
237    ///     1. refs that failed to push
238    ///     2. refs that succeeded to push
239    pub(crate) fn spawn_push(
240        &self,
241        remote_name: &RemoteName,
242        references: &[RefToPush],
243        callbacks: &mut RemoteCallbacks<'_>,
244    ) -> Result<GitPushStats, GitSubprocessError> {
245        let mut command = self.create_command();
246        command.stdout(Stdio::piped());
247        // Currently jj does not support commit hooks, so we prevent git from running
248        // them
249        //
250        // https://github.com/jj-vcs/jj/issues/3577 and https://github.com/jj-vcs/jj/issues/405
251        // offer more context
252        command.args(["push", "--porcelain", "--no-verify"]);
253        if callbacks.progress.is_some() {
254            command.arg("--progress");
255        }
256        command.args(
257            references
258                .iter()
259                .map(|reference| format!("--force-with-lease={}", reference.to_git_lease())),
260        );
261        command.args(["--", remote_name.as_str()]);
262        // with --force-with-lease we cannot have the forced refspec,
263        // as it ignores the lease
264        command.args(
265            references
266                .iter()
267                .map(|r| r.refspec.to_git_format_not_forced()),
268        );
269
270        let output = wait_with_progress(self.spawn_cmd(command)?, callbacks)?;
271
272        parse_git_push_output(output)
273    }
274}
275
276/// Generate a GitSubprocessError::ExternalGitError if the stderr output was not
277/// recognizable
278fn external_git_error(stderr: &[u8]) -> GitSubprocessError {
279    GitSubprocessError::External(format!(
280        "External git program failed:\n{}",
281        stderr.to_str_lossy()
282    ))
283}
284
285/// Parse no such remote errors output from git
286///
287/// Returns the remote that wasn't found
288///
289/// To say this, git prints out a lot of things, but the first line is of the
290/// form:
291/// `fatal: '<remote>' does not appear to be a git repository`
292/// or
293/// `fatal: '<remote>': Could not resolve host: invalid-remote
294fn parse_no_such_remote(stderr: &[u8]) -> Option<String> {
295    let first_line = stderr.lines().next()?;
296    let suffix = first_line
297        .strip_prefix(b"fatal: '")
298        .or_else(|| first_line.strip_prefix(b"fatal: unable to access '"))?;
299
300    suffix
301        .strip_suffix(b"' does not appear to be a git repository")
302        .or_else(|| suffix.strip_suffix(b"': Could not resolve host: invalid-remote"))
303        .map(|remote| remote.to_str_lossy().into_owned())
304}
305
306/// Parse error from refspec not present on the remote
307///
308/// This returns
309///     Some(local_ref) that wasn't found by the remote
310///     None if this wasn't the error
311///
312/// On git fetch even though --prune is specified, if a particular
313/// refspec is asked for but not present in the remote, git will error out.
314///
315/// Git only reports one of these errors at a time, so we only look at the first
316/// line
317///
318/// The first line is of the form:
319/// `fatal: couldn't find remote ref refs/heads/<ref>`
320fn parse_no_remote_ref(stderr: &[u8]) -> Option<String> {
321    let first_line = stderr.lines().next()?;
322    first_line
323        .strip_prefix(b"fatal: couldn't find remote ref ")
324        .map(|refname| refname.to_str_lossy().into_owned())
325}
326
327/// Parse remote tracking branch not found
328///
329/// This returns true if the error was detected
330///
331/// if a branch is asked for but is not present, jj will detect it post-hoc
332/// so, we want to ignore these particular errors with git
333///
334/// The first line is of the form:
335/// `error: remote-tracking branch '<branch>' not found`
336fn parse_no_remote_tracking_branch(stderr: &[u8]) -> Option<String> {
337    let first_line = stderr.lines().next()?;
338
339    let suffix = first_line.strip_prefix(b"error: remote-tracking branch '")?;
340
341    suffix
342        .strip_suffix(b"' not found.")
343        .or_else(|| suffix.strip_suffix(b"' not found"))
344        .map(|branch| branch.to_str_lossy().into_owned())
345}
346
347/// Parse unknown options
348///
349/// Return the unknown option
350///
351/// If a user is running a very old git version, our commands may fail
352/// We want to give a good error in this case
353fn parse_unknown_option(stderr: &[u8]) -> Option<String> {
354    let first_line = stderr.lines().next()?;
355    first_line
356        .strip_prefix(b"unknown option: --")
357        .or(first_line
358            .strip_prefix(b"error: unknown option `")
359            .and_then(|s| s.strip_suffix(b"'")))
360        .map(|s| s.to_str_lossy().into())
361}
362
363// return the fully qualified ref that failed to fetch
364//
365// note that git fetch only returns one error at a time
366fn parse_git_fetch_output(output: Output) -> Result<Option<String>, GitSubprocessError> {
367    if output.status.success() {
368        return Ok(None);
369    }
370
371    // There are some git errors we want to parse out
372    if let Some(option) = parse_unknown_option(&output.stderr) {
373        return Err(GitSubprocessError::UnsupportedGitOption(option));
374    }
375
376    if let Some(remote) = parse_no_such_remote(&output.stderr) {
377        return Err(GitSubprocessError::NoSuchRepository(remote));
378    }
379
380    if let Some(refspec) = parse_no_remote_ref(&output.stderr) {
381        return Ok(Some(refspec));
382    }
383
384    if parse_no_remote_tracking_branch(&output.stderr).is_some() {
385        return Ok(None);
386    }
387
388    Err(external_git_error(&output.stderr))
389}
390
391fn parse_git_branch_prune_output(output: Output) -> Result<(), GitSubprocessError> {
392    if output.status.success() {
393        return Ok(());
394    }
395
396    // There are some git errors we want to parse out
397    if let Some(option) = parse_unknown_option(&output.stderr) {
398        return Err(GitSubprocessError::UnsupportedGitOption(option));
399    }
400
401    if parse_no_remote_tracking_branch(&output.stderr).is_some() {
402        return Ok(());
403    }
404
405    Err(external_git_error(&output.stderr))
406}
407
408fn parse_git_remote_show_output(output: Output) -> Result<Output, GitSubprocessError> {
409    if output.status.success() {
410        return Ok(output);
411    }
412
413    // There are some git errors we want to parse out
414    if let Some(option) = parse_unknown_option(&output.stderr) {
415        return Err(GitSubprocessError::UnsupportedGitOption(option));
416    }
417
418    if let Some(remote) = parse_no_such_remote(&output.stderr) {
419        return Err(GitSubprocessError::NoSuchRepository(remote));
420    }
421
422    Err(external_git_error(&output.stderr))
423}
424
425fn parse_git_remote_show_default_branch(
426    stdout: &[u8],
427) -> Result<Option<String>, GitSubprocessError> {
428    stdout
429        .lines()
430        .map(|x| x.trim())
431        .find(|x| x.starts_with_str("HEAD branch:"))
432        .inspect(|x| tracing::debug!(line = ?x.to_str_lossy(), "default branch"))
433        .and_then(|x| x.split_str(" ").last().map(|y| y.trim()))
434        .filter(|branch_name| branch_name != b"(unknown)")
435        .map(|branch_name| branch_name.to_str())
436        .transpose()
437        .map_err(|e| GitSubprocessError::External(format!("git remote output is not utf-8: {e:?}")))
438        .map(|b| b.map(|x| x.to_string()))
439}
440
441// git-push porcelain has the following format (per line)
442// `<flag>\t<from>:<to>\t<summary> (<reason>)`
443//
444// <flag> is one of:
445//     ' ' for a successfully pushed fast-forward;
446//      + for a successful forced update
447//      - for a successfully deleted ref
448//      * for a successfully pushed new ref
449//      !  for a ref that was rejected or failed to push; and
450//      =  for a ref that was up to date and did not need pushing.
451//
452// <from>:<to> is the refspec
453//
454// <summary> is extra info (commit ranges or reason for rejected)
455//
456// <reason> is a human-readable explanation
457fn parse_ref_pushes(stdout: &[u8]) -> Result<GitPushStats, GitSubprocessError> {
458    if !stdout.starts_with(b"To ") {
459        return Err(GitSubprocessError::External(format!(
460            "Git push output unfamiliar:\n{}",
461            stdout.to_str_lossy()
462        )));
463    }
464
465    let mut push_stats = GitPushStats::default();
466    for (idx, line) in stdout
467        .lines()
468        .skip(1)
469        .take_while(|line| line != b"Done")
470        .enumerate()
471    {
472        tracing::debug!("response #{idx}: {}", line.to_str_lossy());
473        let [flag, reference, summary] = line.split_str("\t").collect_array().ok_or_else(|| {
474            GitSubprocessError::External(format!(
475                "Line #{idx} of git-push has unknown format: {}",
476                line.to_str_lossy()
477            ))
478        })?;
479        let full_refspec = reference
480            .to_str()
481            .map_err(|e| {
482                format!(
483                    "Line #{} of git-push has non-utf8 refspec {}: {}",
484                    idx,
485                    reference.to_str_lossy(),
486                    e
487                )
488            })
489            .map_err(GitSubprocessError::External)?;
490
491        let reference: GitRefNameBuf = full_refspec
492            .split_once(':')
493            .map(|(_refname, reference)| reference.into())
494            .ok_or_else(|| {
495                GitSubprocessError::External(format!(
496                    "Line #{idx} of git-push has full refspec without named ref: {full_refspec}"
497                ))
498            })?;
499
500        match flag {
501            // ' ' for a successfully pushed fast-forward;
502            //  + for a successful forced update
503            //  - for a successfully deleted ref
504            //  * for a successfully pushed new ref
505            //  =  for a ref that was up to date and did not need pushing.
506            b"+" | b"-" | b"*" | b"=" | b" " => {
507                push_stats.pushed.push(reference);
508            }
509            // ! for a ref that was rejected or failed to push; and
510            b"!" => {
511                if let Some(reason) = summary.strip_prefix(b"[remote rejected]") {
512                    let reason = reason
513                        .strip_prefix(b" (")
514                        .and_then(|r| r.strip_suffix(b")"))
515                        .map(|x| x.to_str_lossy().into_owned());
516                    push_stats.remote_rejected.push((reference, reason));
517                } else {
518                    let reason = summary
519                        .split_once_str("]")
520                        .and_then(|(_, reason)| reason.strip_prefix(b" ("))
521                        .and_then(|r| r.strip_suffix(b")"))
522                        .map(|x| x.to_str_lossy().into_owned());
523                    push_stats.rejected.push((reference, reason));
524                }
525            }
526            unknown => {
527                return Err(GitSubprocessError::External(format!(
528                    "Line #{} of git-push starts with an unknown flag '{}': '{}'",
529                    idx,
530                    unknown.to_str_lossy(),
531                    line.to_str_lossy()
532                )));
533            }
534        }
535    }
536
537    Ok(push_stats)
538}
539
540// on Ok, return a tuple with
541//  1. list of failed references from test and set
542//  2. list of successful references pushed
543fn parse_git_push_output(output: Output) -> Result<GitPushStats, GitSubprocessError> {
544    if output.status.success() {
545        let ref_pushes = parse_ref_pushes(&output.stdout)?;
546        return Ok(ref_pushes);
547    }
548
549    if let Some(option) = parse_unknown_option(&output.stderr) {
550        return Err(GitSubprocessError::UnsupportedGitOption(option));
551    }
552
553    if let Some(remote) = parse_no_such_remote(&output.stderr) {
554        return Err(GitSubprocessError::NoSuchRepository(remote));
555    }
556
557    if output
558        .stderr
559        .lines()
560        .any(|line| line.starts_with(b"error: failed to push some refs to "))
561    {
562        parse_ref_pushes(&output.stdout)
563    } else {
564        Err(external_git_error(&output.stderr))
565    }
566}
567
568fn wait_with_output(child: Child) -> Result<Output, GitSubprocessError> {
569    child.wait_with_output().map_err(GitSubprocessError::Wait)
570}
571
572/// Like `wait_with_output()`, but also emits sideband data through callback.
573///
574/// Git remotes can send custom messages on fetch and push, which the `git`
575/// command prepends with `remote: `.
576///
577/// For instance, these messages can provide URLs to create Pull Requests
578/// e.g.:
579/// ```ignore
580/// $ jj git push -c @
581/// [...]
582/// remote:
583/// remote: Create a pull request for 'branch' on GitHub by visiting:
584/// remote:      https://github.com/user/repo/pull/new/branch
585/// remote:
586/// ```
587///
588/// The returned `stderr` content does not include sideband messages.
589fn wait_with_progress(
590    mut child: Child,
591    callbacks: &mut RemoteCallbacks<'_>,
592) -> Result<Output, GitSubprocessError> {
593    let (stdout, stderr) = thread::scope(|s| -> io::Result<_> {
594        drop(child.stdin.take());
595        let mut child_stdout = child.stdout.take().expect("stdout should be piped");
596        let mut child_stderr = child.stderr.take().expect("stderr should be piped");
597        let thread = s.spawn(move || -> io::Result<_> {
598            let mut buf = Vec::new();
599            child_stdout.read_to_end(&mut buf)?;
600            Ok(buf)
601        });
602        let stderr = read_to_end_with_progress(&mut child_stderr, callbacks)?;
603        let stdout = thread.join().expect("reader thread wouldn't panic")?;
604        Ok((stdout, stderr))
605    })
606    .map_err(GitSubprocessError::Wait)?;
607    let status = child.wait().map_err(GitSubprocessError::Wait)?;
608    Ok(Output {
609        status,
610        stdout,
611        stderr,
612    })
613}
614
615#[derive(Default)]
616struct GitProgress {
617    // (frac, total)
618    deltas: (u64, u64),
619    objects: (u64, u64),
620    counted_objects: (u64, u64),
621    compressed_objects: (u64, u64),
622}
623
624impl GitProgress {
625    fn to_progress(&self) -> Progress {
626        Progress {
627            bytes_downloaded: None,
628            overall: self.fraction() as f32 / self.total() as f32,
629        }
630    }
631
632    fn fraction(&self) -> u64 {
633        self.objects.0 + self.deltas.0 + self.counted_objects.0 + self.compressed_objects.0
634    }
635
636    fn total(&self) -> u64 {
637        self.objects.1 + self.deltas.1 + self.counted_objects.1 + self.compressed_objects.1
638    }
639}
640
641fn read_to_end_with_progress<R: Read>(
642    src: R,
643    callbacks: &mut RemoteCallbacks<'_>,
644) -> io::Result<Vec<u8>> {
645    let mut reader = BufReader::new(src);
646    let mut data = Vec::new();
647    let mut git_progress = GitProgress::default();
648
649    loop {
650        // progress sent through sideband channel may be terminated by \r
651        let start = data.len();
652        read_until_cr_or_lf(&mut reader, &mut data)?;
653        let line = &data[start..];
654        if line.is_empty() {
655            break;
656        }
657
658        if update_progress(line, &mut git_progress.objects, b"Receiving objects:")
659            || update_progress(line, &mut git_progress.deltas, b"Resolving deltas:")
660            || update_progress(
661                line,
662                &mut git_progress.counted_objects,
663                b"remote: Counting objects:",
664            )
665            || update_progress(
666                line,
667                &mut git_progress.compressed_objects,
668                b"remote: Compressing objects:",
669            )
670        {
671            if let Some(cb) = callbacks.progress.as_mut() {
672                cb(&git_progress.to_progress());
673            }
674            data.truncate(start);
675        } else if let Some(message) = line.strip_prefix(b"remote: ") {
676            if let Some(cb) = callbacks.sideband_progress.as_mut() {
677                let (body, term) = trim_sideband_line(message);
678                cb(body);
679                if let Some(term) = term {
680                    cb(&[term]);
681                }
682            }
683            data.truncate(start);
684        }
685    }
686    Ok(data)
687}
688
689fn update_progress(line: &[u8], progress: &mut (u64, u64), prefix: &[u8]) -> bool {
690    if let Some(line) = line.strip_prefix(prefix) {
691        if let Some((frac, total)) = read_progress_line(line) {
692            *progress = (frac, total);
693        }
694
695        true
696    } else {
697        false
698    }
699}
700
701fn read_until_cr_or_lf<R: io::BufRead + ?Sized>(
702    reader: &mut R,
703    dest_buf: &mut Vec<u8>,
704) -> io::Result<()> {
705    loop {
706        let data = match reader.fill_buf() {
707            Ok(data) => data,
708            Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
709            Err(err) => return Err(err),
710        };
711        let (n, found) = match data.iter().position(|&b| matches!(b, b'\r' | b'\n')) {
712            Some(i) => (i + 1, true),
713            None => (data.len(), false),
714        };
715
716        dest_buf.extend_from_slice(&data[..n]);
717        reader.consume(n);
718
719        if found || n == 0 {
720            return Ok(());
721        }
722    }
723}
724
725/// Read progress lines of the form: `<text> (<frac>/<total>)`
726/// Ensures that frac < total
727fn read_progress_line(line: &[u8]) -> Option<(u64, u64)> {
728    // isolate the part between parenthesis
729    let (_prefix, suffix) = line.split_once_str("(")?;
730    let (fraction, _suffix) = suffix.split_once_str(")")?;
731
732    // split over the '/'
733    let (frac_str, total_str) = fraction.split_once_str("/")?;
734
735    // parse to integers
736    let frac = frac_str.to_str().ok()?.parse().ok()?;
737    let total = total_str.to_str().ok()?.parse().ok()?;
738    (frac <= total).then_some((frac, total))
739}
740
741/// Removes trailing spaces from sideband line, which may be padded by the `git`
742/// CLI in order to clear the previous progress line.
743fn trim_sideband_line(line: &[u8]) -> (&[u8], Option<u8>) {
744    let (body, term) = match line {
745        [body @ .., term @ (b'\r' | b'\n')] => (body, Some(*term)),
746        _ => (line, None),
747    };
748    let n = body.iter().rev().take_while(|&&b| b == b' ').count();
749    (&body[..body.len() - n], term)
750}
751
752#[cfg(test)]
753mod test {
754    use indoc::formatdoc;
755
756    use super::*;
757
758    const SAMPLE_NO_SUCH_REPOSITORY_ERROR: &[u8] =
759        br###"fatal: unable to access 'origin': Could not resolve host: invalid-remote
760fatal: Could not read from remote repository.
761
762Please make sure you have the correct access rights
763and the repository exists. "###;
764    const SAMPLE_NO_SUCH_REMOTE_ERROR: &[u8] =
765        br###"fatal: 'origin' does not appear to be a git repository
766fatal: Could not read from remote repository.
767
768Please make sure you have the correct access rights
769and the repository exists. "###;
770    const SAMPLE_NO_REMOTE_REF_ERROR: &[u8] = b"fatal: couldn't find remote ref refs/heads/noexist";
771    const SAMPLE_NO_REMOTE_TRACKING_BRANCH_ERROR: &[u8] =
772        b"error: remote-tracking branch 'bookmark' not found";
773    const SAMPLE_PUSH_REFS_PORCELAIN_OUTPUT: &[u8] = b"To origin
774*\tdeadbeef:refs/heads/bookmark1\t[new branch]
775+\tdeadbeef:refs/heads/bookmark2\tabcd..dead
776-\tdeadbeef:refs/heads/bookmark3\t[deleted branch]
777 \tdeadbeef:refs/heads/bookmark4\tabcd..dead
778=\tdeadbeef:refs/heads/bookmark5\tabcd..abcd
779!\tdeadbeef:refs/heads/bookmark6\t[rejected] (failure lease)
780!\tdeadbeef:refs/heads/bookmark7\t[rejected]
781!\tdeadbeef:refs/heads/bookmark8\t[remote rejected] (hook failure)
782!\tdeadbeef:refs/heads/bookmark9\t[remote rejected]
783Done";
784    const SAMPLE_OK_STDERR: &[u8] = b"";
785
786    #[test]
787    fn test_parse_no_such_remote() {
788        assert_eq!(
789            parse_no_such_remote(SAMPLE_NO_SUCH_REPOSITORY_ERROR),
790            Some("origin".to_string())
791        );
792        assert_eq!(
793            parse_no_such_remote(SAMPLE_NO_SUCH_REMOTE_ERROR),
794            Some("origin".to_string())
795        );
796        assert_eq!(parse_no_such_remote(SAMPLE_NO_REMOTE_REF_ERROR), None);
797        assert_eq!(
798            parse_no_such_remote(SAMPLE_NO_REMOTE_TRACKING_BRANCH_ERROR),
799            None
800        );
801        assert_eq!(
802            parse_no_such_remote(SAMPLE_PUSH_REFS_PORCELAIN_OUTPUT),
803            None
804        );
805        assert_eq!(parse_no_such_remote(SAMPLE_OK_STDERR), None);
806    }
807
808    #[test]
809    fn test_parse_no_remote_ref() {
810        assert_eq!(parse_no_remote_ref(SAMPLE_NO_SUCH_REPOSITORY_ERROR), None);
811        assert_eq!(parse_no_remote_ref(SAMPLE_NO_SUCH_REMOTE_ERROR), None);
812        assert_eq!(
813            parse_no_remote_ref(SAMPLE_NO_REMOTE_REF_ERROR),
814            Some("refs/heads/noexist".to_string())
815        );
816        assert_eq!(
817            parse_no_remote_ref(SAMPLE_NO_REMOTE_TRACKING_BRANCH_ERROR),
818            None
819        );
820        assert_eq!(parse_no_remote_ref(SAMPLE_PUSH_REFS_PORCELAIN_OUTPUT), None);
821        assert_eq!(parse_no_remote_ref(SAMPLE_OK_STDERR), None);
822    }
823
824    #[test]
825    fn test_parse_no_remote_tracking_branch() {
826        assert_eq!(
827            parse_no_remote_tracking_branch(SAMPLE_NO_SUCH_REPOSITORY_ERROR),
828            None
829        );
830        assert_eq!(
831            parse_no_remote_tracking_branch(SAMPLE_NO_SUCH_REMOTE_ERROR),
832            None
833        );
834        assert_eq!(
835            parse_no_remote_tracking_branch(SAMPLE_NO_REMOTE_REF_ERROR),
836            None
837        );
838        assert_eq!(
839            parse_no_remote_tracking_branch(SAMPLE_NO_REMOTE_TRACKING_BRANCH_ERROR),
840            Some("bookmark".to_string())
841        );
842        assert_eq!(
843            parse_no_remote_tracking_branch(SAMPLE_PUSH_REFS_PORCELAIN_OUTPUT),
844            None
845        );
846        assert_eq!(parse_no_remote_tracking_branch(SAMPLE_OK_STDERR), None);
847    }
848
849    #[test]
850    fn test_parse_ref_pushes() {
851        assert!(parse_ref_pushes(SAMPLE_NO_SUCH_REPOSITORY_ERROR).is_err());
852        assert!(parse_ref_pushes(SAMPLE_NO_SUCH_REMOTE_ERROR).is_err());
853        assert!(parse_ref_pushes(SAMPLE_NO_REMOTE_REF_ERROR).is_err());
854        assert!(parse_ref_pushes(SAMPLE_NO_REMOTE_TRACKING_BRANCH_ERROR).is_err());
855        let GitPushStats {
856            pushed,
857            rejected,
858            remote_rejected,
859        } = parse_ref_pushes(SAMPLE_PUSH_REFS_PORCELAIN_OUTPUT).unwrap();
860        assert_eq!(
861            pushed,
862            [
863                "refs/heads/bookmark1",
864                "refs/heads/bookmark2",
865                "refs/heads/bookmark3",
866                "refs/heads/bookmark4",
867                "refs/heads/bookmark5",
868            ]
869            .map(GitRefNameBuf::from)
870        );
871        assert_eq!(
872            rejected,
873            vec![
874                (
875                    "refs/heads/bookmark6".into(),
876                    Some("failure lease".to_string())
877                ),
878                ("refs/heads/bookmark7".into(), None),
879            ]
880        );
881        assert_eq!(
882            remote_rejected,
883            vec![
884                (
885                    "refs/heads/bookmark8".into(),
886                    Some("hook failure".to_string())
887                ),
888                ("refs/heads/bookmark9".into(), None)
889            ]
890        );
891        assert!(parse_ref_pushes(SAMPLE_OK_STDERR).is_err());
892    }
893
894    #[test]
895    fn test_read_to_end_with_progress() {
896        let read = |sample: &[u8]| {
897            let mut progress = Vec::new();
898            let mut sideband = Vec::new();
899            let mut callbacks = RemoteCallbacks::default();
900            let mut progress_cb = |p: &Progress| progress.push(p.clone());
901            callbacks.progress = Some(&mut progress_cb);
902            let mut sideband_cb = |s: &[u8]| sideband.push(s.to_owned());
903            callbacks.sideband_progress = Some(&mut sideband_cb);
904            let output = read_to_end_with_progress(&mut &sample[..], &mut callbacks).unwrap();
905            (output, sideband, progress)
906        };
907        const DUMB_SUFFIX: &str = "        ";
908        let sample = formatdoc! {"
909            remote: line1{DUMB_SUFFIX}
910            blah blah
911            remote: line2.0{DUMB_SUFFIX}\rremote: line2.1{DUMB_SUFFIX}
912            remote: line3{DUMB_SUFFIX}
913            Resolving deltas: (12/24)
914            some error message
915        "};
916
917        let (output, sideband, progress) = read(sample.as_bytes());
918        assert_eq!(
919            sideband,
920            ["line1", "\n", "line2.0", "\r", "line2.1", "\n", "line3", "\n"]
921                .map(|s| s.as_bytes().to_owned())
922        );
923        assert_eq!(output, b"blah blah\nsome error message\n");
924        insta::assert_debug_snapshot!(progress, @r"
925        [
926            Progress {
927                bytes_downloaded: None,
928                overall: 0.5,
929            },
930        ]
931        ");
932
933        // without last newline
934        let (output, sideband, _progress) = read(sample.as_bytes().trim_end());
935        assert_eq!(
936            sideband,
937            ["line1", "\n", "line2.0", "\r", "line2.1", "\n", "line3", "\n"]
938                .map(|s| s.as_bytes().to_owned())
939        );
940        assert_eq!(output, b"blah blah\nsome error message");
941    }
942
943    #[test]
944    fn test_read_progress_line() {
945        assert_eq!(
946            read_progress_line(b"Receiving objects: (42/100)\r"),
947            Some((42, 100))
948        );
949        assert_eq!(
950            read_progress_line(b"Resolving deltas: (0/1000)\r"),
951            Some((0, 1000))
952        );
953        assert_eq!(read_progress_line(b"Receiving objects: (420/100)\r"), None);
954        assert_eq!(
955            read_progress_line(b"remote: this is something else\n"),
956            None
957        );
958        assert_eq!(read_progress_line(b"fatal: this is a git error\n"), None);
959    }
960
961    #[test]
962    fn test_parse_unknown_option() {
963        assert_eq!(
964            parse_unknown_option(b"unknown option: --abc").unwrap(),
965            "abc".to_string()
966        );
967        assert_eq!(
968            parse_unknown_option(b"error: unknown option `abc'").unwrap(),
969            "abc".to_string()
970        );
971        assert!(parse_unknown_option(b"error: unknown option: 'abc'").is_none());
972    }
973}