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