1use std::io;
16use std::io::BufReader;
17use std::io::Read;
18use std::num::NonZeroU32;
19use std::path::PathBuf;
20use std::process::Child;
21use std::process::Command;
22use std::process::Output;
23use std::process::Stdio;
24use std::thread;
25
26use bstr::BStr;
27use bstr::ByteSlice as _;
28use itertools::Itertools as _;
29use thiserror::Error;
30
31use crate::git::FetchTagsOverride;
32use crate::git::GitPushOptions;
33use crate::git::GitPushStats;
34use crate::git::GitSubprocessOptions;
35use crate::git::NegativeRefSpec;
36use crate::git::RefSpec;
37use crate::git::RefToPush;
38use crate::git_backend::GitBackend;
39use crate::merge::Diff;
40use crate::ref_name::GitRefNameBuf;
41use crate::ref_name::RefNameBuf;
42use crate::ref_name::RemoteName;
43
44const MINIMUM_GIT_VERSION: &str = "2.41.0";
49
50#[derive(Error, Debug)]
52pub enum GitSubprocessError {
53 #[error("Could not find repository at '{0}'")]
54 NoSuchRepository(String),
55 #[error("Could not execute the git process, found in the OS path '{path}'")]
56 SpawnInPath {
57 path: PathBuf,
58 #[source]
59 error: std::io::Error,
60 },
61 #[error("Could not execute git process at specified path '{path}'")]
62 Spawn {
63 path: PathBuf,
64 #[source]
65 error: std::io::Error,
66 },
67 #[error("Failed to wait for the git process")]
68 Wait(std::io::Error),
69 #[error(
70 "Git does not recognize required option: {0} (note: Jujutsu requires git >= \
71 {MINIMUM_GIT_VERSION})"
72 )]
73 UnsupportedGitOption(String),
74 #[error("Git process failed: {0}")]
75 External(String),
76}
77
78pub(crate) struct GitSubprocessContext {
80 git_dir: PathBuf,
81 options: GitSubprocessOptions,
82}
83
84impl GitSubprocessContext {
85 pub(crate) fn new(git_dir: impl Into<PathBuf>, options: GitSubprocessOptions) -> Self {
86 Self {
87 git_dir: git_dir.into(),
88 options,
89 }
90 }
91
92 pub(crate) fn from_git_backend(
93 git_backend: &GitBackend,
94 options: GitSubprocessOptions,
95 ) -> Self {
96 Self::new(git_backend.git_repo_path(), options)
97 }
98
99 fn create_command(&self) -> Command {
101 let mut git_cmd = Command::new(&self.options.executable_path);
102 #[cfg(windows)]
104 {
105 use std::os::windows::process::CommandExt as _;
106 const CREATE_NO_WINDOW: u32 = 0x08000000;
107 git_cmd.creation_flags(CREATE_NO_WINDOW);
108 }
109
110 git_cmd
115 .args(["-c", "core.fsmonitor=false"])
126 .args(["-c", "submodule.recurse=false"])
130 .arg("--git-dir")
131 .arg(&self.git_dir)
132 .env_remove("LC_ALL")
136 .env_remove("LANGUAGE")
137 .env("LC_MESSAGES", "C")
138 .stdin(Stdio::null())
139 .stderr(Stdio::piped());
140
141 git_cmd.envs(&self.options.environment);
142
143 git_cmd
144 }
145
146 fn spawn_cmd(&self, mut git_cmd: Command) -> Result<Child, GitSubprocessError> {
148 tracing::debug!(cmd = ?git_cmd, "spawning a git subprocess");
149
150 git_cmd.spawn().map_err(|error| {
151 if self.options.executable_path.is_absolute() {
152 GitSubprocessError::Spawn {
153 path: self.options.executable_path.clone(),
154 error,
155 }
156 } else {
157 GitSubprocessError::SpawnInPath {
158 path: self.options.executable_path.clone(),
159 error,
160 }
161 }
162 })
163 }
164
165 pub(crate) fn spawn_fetch(
170 &self,
171 remote_name: &RemoteName,
172 refspecs: &[RefSpec],
173 negative_refspecs: &[NegativeRefSpec],
174 callback: &mut dyn GitSubprocessCallback,
175 depth: Option<NonZeroU32>,
176 fetch_tags_override: Option<FetchTagsOverride>,
177 ) -> Result<GitFetchStatus, GitSubprocessError> {
178 if refspecs.is_empty() {
179 return Ok(GitFetchStatus::Updates(GitRefUpdates::default()));
180 }
181 let mut command = self.create_command();
182 command.stdout(Stdio::piped());
183 command.args(["fetch", "--porcelain", "--prune", "--no-write-fetch-head"]);
186 if callback.needs_progress() {
187 command.arg("--progress");
188 }
189 if let Some(d) = depth {
190 command.arg(format!("--depth={d}"));
191 }
192 match fetch_tags_override {
193 Some(FetchTagsOverride::AllTags) => {
194 command.arg("--tags");
195 }
196 Some(FetchTagsOverride::NoTags) => {
197 command.arg("--no-tags");
198 }
199 None => {}
200 }
201 command.arg("--").arg(remote_name.as_str());
202 command.args(
203 refspecs
204 .iter()
205 .map(|x| x.to_git_format())
206 .chain(negative_refspecs.iter().map(|x| x.to_git_format())),
207 );
208
209 let output = wait_with_progress(self.spawn_cmd(command)?, callback)?;
210
211 parse_git_fetch_output(&output)
212 }
213
214 pub(crate) fn spawn_branch_prune(
216 &self,
217 branches_to_prune: &[String],
218 ) -> Result<(), GitSubprocessError> {
219 if branches_to_prune.is_empty() {
220 return Ok(());
221 }
222 tracing::debug!(?branches_to_prune, "pruning branches");
223 let mut command = self.create_command();
224 command.stdout(Stdio::null());
225 command.args(["branch", "--remotes", "--delete", "--"]);
226 command.args(branches_to_prune);
227
228 let output = wait_with_output(self.spawn_cmd(command)?)?;
229
230 let () = parse_git_branch_prune_output(output)?;
232
233 Ok(())
234 }
235
236 pub(crate) fn spawn_remote_show(
243 &self,
244 remote_name: &RemoteName,
245 ) -> Result<Option<RefNameBuf>, GitSubprocessError> {
246 let mut command = self.create_command();
247 command.stdout(Stdio::piped());
248 command.args(["remote", "show", "--", remote_name.as_str()]);
249 let output = wait_with_output(self.spawn_cmd(command)?)?;
250
251 let output = parse_git_remote_show_output(output)?;
252
253 let maybe_branch = parse_git_remote_show_default_branch(&output.stdout)?;
255 Ok(maybe_branch.map(Into::into))
256 }
257
258 pub(crate) fn spawn_push(
267 &self,
268 remote_name: &RemoteName,
269 references: &[RefToPush],
270 callback: &mut dyn GitSubprocessCallback,
271 options: &GitPushOptions,
272 ) -> Result<GitPushStats, GitSubprocessError> {
273 let mut command = self.create_command();
274 command.stdout(Stdio::piped());
275 command.args(["push", "--porcelain", "--no-verify"]);
281 if callback.needs_progress() {
282 command.arg("--progress");
283 }
284 command.args(
285 options
286 .remote_push_options
287 .iter()
288 .map(|option| format!("--push-option={option}")),
289 );
290 command.args(
291 references
292 .iter()
293 .map(|reference| format!("--force-with-lease={}", reference.to_git_lease())),
294 );
295 command.args(["--", remote_name.as_str()]);
296 command.args(
299 references
300 .iter()
301 .map(|r| r.refspec.to_git_format_not_forced()),
302 );
303
304 let output = wait_with_progress(self.spawn_cmd(command)?, callback)?;
305
306 parse_git_push_output(output)
307 }
308}
309
310fn external_git_error(stderr: &[u8]) -> GitSubprocessError {
313 GitSubprocessError::External(format!(
314 "External git program failed:\n{}",
315 stderr.to_str_lossy()
316 ))
317}
318
319const ERROR_PREFIXES: &[&[u8]] = &[
320 b"error: ",
322 b"fatal: ",
324 b"usage: ",
326 b"unknown option: ",
328];
329
330fn parse_no_such_remote(stderr: &[u8]) -> Option<String> {
340 let first_line = stderr.lines().next()?;
341 let suffix = first_line
342 .strip_prefix(b"fatal: '")
343 .or_else(|| first_line.strip_prefix(b"fatal: unable to access '"))?;
344
345 suffix
346 .strip_suffix(b"' does not appear to be a git repository")
347 .or_else(|| suffix.strip_suffix(b"': Could not resolve host: invalid-remote"))
348 .map(|remote| remote.to_str_lossy().into_owned())
349}
350
351fn parse_no_remote_ref(stderr: &[u8]) -> Option<String> {
366 let first_line = stderr.lines().next()?;
367 first_line
368 .strip_prefix(b"fatal: couldn't find remote ref ")
369 .map(|refname| refname.to_str_lossy().into_owned())
370}
371
372fn parse_no_remote_tracking_branch(stderr: &[u8]) -> Option<String> {
382 let first_line = stderr.lines().next()?;
383
384 let suffix = first_line.strip_prefix(b"error: remote-tracking branch '")?;
385
386 suffix
387 .strip_suffix(b"' not found.")
388 .or_else(|| suffix.strip_suffix(b"' not found"))
389 .map(|branch| branch.to_str_lossy().into_owned())
390}
391
392fn parse_unknown_option(stderr: &[u8]) -> Option<String> {
399 let first_line = stderr.lines().next()?;
400 first_line
401 .strip_prefix(b"unknown option: --")
402 .or(first_line
403 .strip_prefix(b"error: unknown option `")
404 .and_then(|s| s.strip_suffix(b"'")))
405 .map(|s| s.to_str_lossy().into())
406}
407
408#[derive(Clone, Debug)]
410pub enum GitFetchStatus {
411 Updates(GitRefUpdates),
413 NoRemoteRef(String),
417}
418
419fn parse_git_fetch_output(output: &Output) -> Result<GitFetchStatus, GitSubprocessError> {
420 if output.status.success() {
421 let updates = parse_ref_updates(&output.stdout)?;
422 return Ok(GitFetchStatus::Updates(updates));
423 }
424
425 if let Some(option) = parse_unknown_option(&output.stderr) {
427 return Err(GitSubprocessError::UnsupportedGitOption(option));
428 }
429
430 if let Some(remote) = parse_no_such_remote(&output.stderr) {
431 return Err(GitSubprocessError::NoSuchRepository(remote));
432 }
433
434 if let Some(refspec) = parse_no_remote_ref(&output.stderr) {
435 return Ok(GitFetchStatus::NoRemoteRef(refspec));
436 }
437
438 let updates = parse_ref_updates(&output.stdout)?;
439 if !updates.rejected.is_empty() || parse_no_remote_tracking_branch(&output.stderr).is_some() {
440 Ok(GitFetchStatus::Updates(updates))
441 } else {
442 Err(external_git_error(&output.stderr))
443 }
444}
445
446#[derive(Clone, Debug, Default)]
448pub struct GitRefUpdates {
449 #[cfg_attr(not(test), expect(dead_code))] pub updated: Vec<(GitRefNameBuf, Diff<gix::ObjectId>)>,
455 pub rejected: Vec<(GitRefNameBuf, Diff<gix::ObjectId>)>,
458}
459
460fn parse_ref_updates(stdout: &[u8]) -> Result<GitRefUpdates, GitSubprocessError> {
462 let mut updated = vec![];
463 let mut rejected = vec![];
464 for (i, line) in stdout.lines().enumerate() {
465 let parse_err = |message: &str| {
466 GitSubprocessError::External(format!(
467 "Line {line_no}: {message}: {line}",
468 line_no = i + 1,
469 line = BStr::new(line)
470 ))
471 };
472 let mut line_bytes = line.iter();
475 let flag = *line_bytes.next().ok_or_else(|| parse_err("empty line"))?;
476 if line_bytes.next() != Some(&b' ') {
477 return Err(parse_err("no flag separator found"));
478 }
479 let [old_oid, new_oid, name] = line_bytes
480 .as_slice()
481 .splitn(3, |&b| b == b' ')
482 .collect_array()
483 .ok_or_else(|| parse_err("unexpected number of columns"))?;
484 let name: GitRefNameBuf = str::from_utf8(name)
485 .map_err(|_| parse_err("non-UTF-8 ref name"))?
486 .into();
487 let old_oid = gix::ObjectId::from_hex(old_oid).map_err(|_| parse_err("invalid old oid"))?;
488 let new_oid = gix::ObjectId::from_hex(new_oid).map_err(|_| parse_err("invalid new oid"))?;
489 let oid_diff = Diff::new(old_oid, new_oid);
490 match flag {
491 b' ' | b'+' | b'-' | b't' | b'*' => updated.push((name, oid_diff)),
497 b'!' => rejected.push((name, oid_diff)),
499 b'=' => {}
502 _ => return Err(parse_err("unknown flag")),
503 }
504 }
505 Ok(GitRefUpdates { updated, rejected })
506}
507
508fn parse_git_branch_prune_output(output: Output) -> Result<(), GitSubprocessError> {
509 if output.status.success() {
510 return Ok(());
511 }
512
513 if let Some(option) = parse_unknown_option(&output.stderr) {
515 return Err(GitSubprocessError::UnsupportedGitOption(option));
516 }
517
518 if parse_no_remote_tracking_branch(&output.stderr).is_some() {
519 return Ok(());
520 }
521
522 Err(external_git_error(&output.stderr))
523}
524
525fn parse_git_remote_show_output(output: Output) -> Result<Output, GitSubprocessError> {
526 if output.status.success() {
527 return Ok(output);
528 }
529
530 if let Some(option) = parse_unknown_option(&output.stderr) {
532 return Err(GitSubprocessError::UnsupportedGitOption(option));
533 }
534
535 if let Some(remote) = parse_no_such_remote(&output.stderr) {
536 return Err(GitSubprocessError::NoSuchRepository(remote));
537 }
538
539 Err(external_git_error(&output.stderr))
540}
541
542fn parse_git_remote_show_default_branch(
543 stdout: &[u8],
544) -> Result<Option<String>, GitSubprocessError> {
545 stdout
546 .lines()
547 .map(|x| x.trim())
548 .find(|x| x.starts_with_str("HEAD branch:"))
549 .inspect(|x| tracing::debug!(line = ?x.to_str_lossy(), "default branch"))
550 .and_then(|x| x.split_str(" ").last().map(|y| y.trim()))
551 .filter(|branch_name| branch_name != b"(unknown)")
552 .map(|branch_name| branch_name.to_str())
553 .transpose()
554 .map_err(|e| GitSubprocessError::External(format!("git remote output is not utf-8: {e:?}")))
555 .map(|b| b.map(|x| x.to_string()))
556}
557
558fn parse_ref_pushes(stdout: &[u8]) -> Result<GitPushStats, GitSubprocessError> {
575 if !stdout.starts_with(b"To ") {
576 return Err(GitSubprocessError::External(format!(
577 "Git push output unfamiliar:\n{}",
578 stdout.to_str_lossy()
579 )));
580 }
581
582 let mut push_stats = GitPushStats::default();
583 for (idx, line) in stdout
584 .lines()
585 .skip(1)
586 .take_while(|line| line != b"Done")
587 .enumerate()
588 {
589 tracing::debug!("response #{idx}: {}", line.to_str_lossy());
590 let [flag, reference, summary] = line.split_str("\t").collect_array().ok_or_else(|| {
591 GitSubprocessError::External(format!(
592 "Line #{idx} of git-push has unknown format: {}",
593 line.to_str_lossy()
594 ))
595 })?;
596 let full_refspec = reference
597 .to_str()
598 .map_err(|e| {
599 format!(
600 "Line #{} of git-push has non-utf8 refspec {}: {}",
601 idx,
602 reference.to_str_lossy(),
603 e
604 )
605 })
606 .map_err(GitSubprocessError::External)?;
607
608 let reference: GitRefNameBuf = full_refspec
609 .split_once(':')
610 .map(|(_refname, reference)| reference.into())
611 .ok_or_else(|| {
612 GitSubprocessError::External(format!(
613 "Line #{idx} of git-push has full refspec without named ref: {full_refspec}"
614 ))
615 })?;
616
617 match flag {
618 b"+" | b"-" | b"*" | b"=" | b" " => {
624 push_stats.pushed.push(reference);
625 }
626 b"!" => {
628 if let Some(reason) = summary.strip_prefix(b"[remote rejected]") {
629 let reason = reason
630 .strip_prefix(b" (")
631 .and_then(|r| r.strip_suffix(b")"))
632 .map(|x| x.to_str_lossy().into_owned());
633 push_stats.remote_rejected.push((reference, reason));
634 } else {
635 let reason = summary
636 .split_once_str("]")
637 .and_then(|(_, reason)| reason.strip_prefix(b" ("))
638 .and_then(|r| r.strip_suffix(b")"))
639 .map(|x| x.to_str_lossy().into_owned());
640 push_stats.rejected.push((reference, reason));
641 }
642 }
643 unknown => {
644 return Err(GitSubprocessError::External(format!(
645 "Line #{} of git-push starts with an unknown flag '{}': '{}'",
646 idx,
647 unknown.to_str_lossy(),
648 line.to_str_lossy()
649 )));
650 }
651 }
652 }
653
654 Ok(push_stats)
655}
656
657fn parse_git_push_output(output: Output) -> Result<GitPushStats, GitSubprocessError> {
661 if output.status.success() {
662 let ref_pushes = parse_ref_pushes(&output.stdout)?;
663 return Ok(ref_pushes);
664 }
665
666 if let Some(option) = parse_unknown_option(&output.stderr) {
667 return Err(GitSubprocessError::UnsupportedGitOption(option));
668 }
669
670 if let Some(remote) = parse_no_such_remote(&output.stderr) {
671 return Err(GitSubprocessError::NoSuchRepository(remote));
672 }
673
674 if output
675 .stderr
676 .lines()
677 .any(|line| line.starts_with(b"error: failed to push some refs to "))
678 {
679 parse_ref_pushes(&output.stdout)
680 } else {
681 Err(external_git_error(&output.stderr))
682 }
683}
684
685pub trait GitSubprocessCallback {
687 fn needs_progress(&self) -> bool;
689
690 fn progress(&mut self, progress: &GitProgress) -> io::Result<()>;
692
693 fn local_sideband(
697 &mut self,
698 message: &[u8],
699 term: Option<GitSidebandLineTerminator>,
700 ) -> io::Result<()>;
701
702 fn remote_sideband(
704 &mut self,
705 message: &[u8],
706 term: Option<GitSidebandLineTerminator>,
707 ) -> io::Result<()>;
708}
709
710#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
712#[repr(u8)]
713pub enum GitSidebandLineTerminator {
714 Cr = b'\r',
716 Lf = b'\n',
718}
719
720impl GitSidebandLineTerminator {
721 pub fn as_byte(self) -> u8 {
723 self as u8
724 }
725}
726
727fn wait_with_output(child: Child) -> Result<Output, GitSubprocessError> {
728 child.wait_with_output().map_err(GitSubprocessError::Wait)
729}
730
731fn wait_with_progress(
749 mut child: Child,
750 callback: &mut dyn GitSubprocessCallback,
751) -> Result<Output, GitSubprocessError> {
752 let (stdout, stderr) = thread::scope(|s| -> io::Result<_> {
753 drop(child.stdin.take());
754 let mut child_stdout = child.stdout.take().expect("stdout should be piped");
755 let mut child_stderr = child.stderr.take().expect("stderr should be piped");
756 let thread = s.spawn(move || -> io::Result<_> {
757 let mut buf = Vec::new();
758 child_stdout.read_to_end(&mut buf)?;
759 Ok(buf)
760 });
761 let stderr = read_to_end_with_progress(&mut child_stderr, callback)?;
762 let stdout = thread.join().expect("reader thread wouldn't panic")?;
763 Ok((stdout, stderr))
764 })
765 .map_err(GitSubprocessError::Wait)?;
766 let status = child.wait().map_err(GitSubprocessError::Wait)?;
767 Ok(Output {
768 status,
769 stdout,
770 stderr,
771 })
772}
773
774#[derive(Clone, Debug, Default)]
776pub struct GitProgress {
777 pub deltas: (u64, u64),
779 pub objects: (u64, u64),
781 pub counted_objects: (u64, u64),
783 pub compressed_objects: (u64, u64),
785}
786
787impl GitProgress {
789 pub fn overall(&self) -> f32 {
791 if self.total() != 0 {
792 self.fraction() as f32 / self.total() as f32
793 } else {
794 0.0
795 }
796 }
797
798 fn fraction(&self) -> u64 {
799 self.objects.0 + self.deltas.0 + self.counted_objects.0 + self.compressed_objects.0
800 }
801
802 fn total(&self) -> u64 {
803 self.objects.1 + self.deltas.1 + self.counted_objects.1 + self.compressed_objects.1
804 }
805}
806
807fn read_to_end_with_progress<R: Read>(
808 src: R,
809 callback: &mut dyn GitSubprocessCallback,
810) -> io::Result<Vec<u8>> {
811 let mut reader = BufReader::new(src);
812 let mut data = Vec::new();
813 let mut progress = GitProgress::default();
814
815 loop {
816 let start = data.len();
818 read_until_cr_or_lf(&mut reader, &mut data)?;
819 let line = &data[start..];
820 if line.is_empty() {
821 break;
822 }
823
824 if ERROR_PREFIXES.iter().any(|prefix| line.starts_with(prefix)) {
826 reader.read_to_end(&mut data)?;
827 break;
828 }
829
830 if update_progress(line, &mut progress.objects, b"Receiving objects:")
834 || update_progress(line, &mut progress.deltas, b"Resolving deltas:")
835 || update_progress(
836 line,
837 &mut progress.counted_objects,
838 b"remote: Counting objects:",
839 )
840 || update_progress(
841 line,
842 &mut progress.compressed_objects,
843 b"remote: Compressing objects:",
844 )
845 {
846 callback.progress(&progress).ok();
847 data.truncate(start);
848 } else if let Some(message) = line.strip_prefix(b"remote: ") {
849 let (body, term) = trim_sideband_line(message);
850 callback.remote_sideband(body, term).ok();
851 data.truncate(start);
852 } else {
853 let (body, term) = trim_sideband_line(line);
854 callback.local_sideband(body, term).ok();
855 data.truncate(start);
856 }
857 }
858 Ok(data)
859}
860
861fn update_progress(line: &[u8], progress: &mut (u64, u64), prefix: &[u8]) -> bool {
862 if let Some(line) = line.strip_prefix(prefix) {
863 if let Some((frac, total)) = read_progress_line(line) {
864 *progress = (frac, total);
865 }
866
867 true
868 } else {
869 false
870 }
871}
872
873fn read_until_cr_or_lf<R: io::BufRead + ?Sized>(
874 reader: &mut R,
875 dest_buf: &mut Vec<u8>,
876) -> io::Result<()> {
877 loop {
878 let data = match reader.fill_buf() {
879 Ok(data) => data,
880 Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
881 Err(err) => return Err(err),
882 };
883 let (n, found) = match data.iter().position(|&b| matches!(b, b'\r' | b'\n')) {
884 Some(i) => (i + 1, true),
885 None => (data.len(), false),
886 };
887
888 dest_buf.extend_from_slice(&data[..n]);
889 reader.consume(n);
890
891 if found || n == 0 {
892 return Ok(());
893 }
894 }
895}
896
897fn read_progress_line(line: &[u8]) -> Option<(u64, u64)> {
900 let (_prefix, suffix) = line.split_once_str("(")?;
902 let (fraction, _suffix) = suffix.split_once_str(")")?;
903
904 let (frac_str, total_str) = fraction.split_once_str("/")?;
906
907 let frac = frac_str.to_str().ok()?.parse().ok()?;
909 let total = total_str.to_str().ok()?.parse().ok()?;
910 (frac <= total).then_some((frac, total))
911}
912
913fn trim_sideband_line(line: &[u8]) -> (&[u8], Option<GitSidebandLineTerminator>) {
916 let (body, term) = match line {
917 [body @ .., b'\r'] => (body, Some(GitSidebandLineTerminator::Cr)),
918 [body @ .., b'\n'] => (body, Some(GitSidebandLineTerminator::Lf)),
919 _ => (line, None),
920 };
921 let n = body.iter().rev().take_while(|&&b| b == b' ').count();
922 (&body[..body.len() - n], term)
923}
924
925#[cfg(test)]
926mod test {
927 use std::process::ExitStatus;
928
929 use assert_matches::assert_matches;
930 use bstr::BString;
931 use indoc::formatdoc;
932 use indoc::indoc;
933
934 use super::*;
935
936 const SAMPLE_NO_SUCH_REPOSITORY_ERROR: &[u8] =
937 br###"fatal: unable to access 'origin': Could not resolve host: invalid-remote
938fatal: Could not read from remote repository.
939
940Please make sure you have the correct access rights
941and the repository exists. "###;
942 const SAMPLE_NO_SUCH_REMOTE_ERROR: &[u8] =
943 br###"fatal: 'origin' does not appear to be a git repository
944fatal: Could not read from remote repository.
945
946Please make sure you have the correct access rights
947and the repository exists. "###;
948 const SAMPLE_NO_REMOTE_REF_ERROR: &[u8] = b"fatal: couldn't find remote ref refs/heads/noexist";
949 const SAMPLE_NO_REMOTE_TRACKING_BRANCH_ERROR: &[u8] =
950 b"error: remote-tracking branch 'bookmark' not found";
951 const SAMPLE_PUSH_REFS_PORCELAIN_OUTPUT: &[u8] = b"To origin
952*\tdeadbeef:refs/heads/bookmark1\t[new branch]
953+\tdeadbeef:refs/heads/bookmark2\tabcd..dead
954-\tdeadbeef:refs/heads/bookmark3\t[deleted branch]
955 \tdeadbeef:refs/heads/bookmark4\tabcd..dead
956=\tdeadbeef:refs/heads/bookmark5\tabcd..abcd
957!\tdeadbeef:refs/heads/bookmark6\t[rejected] (failure lease)
958!\tdeadbeef:refs/heads/bookmark7\t[rejected]
959!\tdeadbeef:refs/heads/bookmark8\t[remote rejected] (hook failure)
960!\tdeadbeef:refs/heads/bookmark9\t[remote rejected]
961Done";
962 const SAMPLE_OK_STDERR: &[u8] = b"";
963
964 #[derive(Debug, Default)]
965 struct GitSubprocessCapture {
966 progress: Vec<GitProgress>,
967 local_sideband: Vec<BString>,
968 remote_sideband: Vec<BString>,
969 }
970
971 impl GitSubprocessCallback for GitSubprocessCapture {
972 fn needs_progress(&self) -> bool {
973 true
974 }
975
976 fn progress(&mut self, progress: &GitProgress) -> io::Result<()> {
977 self.progress.push(progress.clone());
978 Ok(())
979 }
980
981 fn local_sideband(
982 &mut self,
983 message: &[u8],
984 term: Option<GitSidebandLineTerminator>,
985 ) -> io::Result<()> {
986 self.local_sideband.push(message.into());
987 if let Some(term) = term {
988 self.local_sideband.push([term.as_byte()].into());
989 }
990 Ok(())
991 }
992
993 fn remote_sideband(
994 &mut self,
995 message: &[u8],
996 term: Option<GitSidebandLineTerminator>,
997 ) -> io::Result<()> {
998 self.remote_sideband.push(message.into());
999 if let Some(term) = term {
1000 self.remote_sideband.push([term.as_byte()].into());
1001 }
1002 Ok(())
1003 }
1004 }
1005
1006 fn exit_status_from_code(code: u8) -> ExitStatus {
1007 #[cfg(unix)]
1008 use std::os::unix::process::ExitStatusExt as _; #[cfg(windows)]
1010 use std::os::windows::process::ExitStatusExt as _; ExitStatus::from_raw(code.into())
1012 }
1013
1014 #[test]
1015 fn test_parse_no_such_remote() {
1016 assert_eq!(
1017 parse_no_such_remote(SAMPLE_NO_SUCH_REPOSITORY_ERROR),
1018 Some("origin".to_string())
1019 );
1020 assert_eq!(
1021 parse_no_such_remote(SAMPLE_NO_SUCH_REMOTE_ERROR),
1022 Some("origin".to_string())
1023 );
1024 assert_eq!(parse_no_such_remote(SAMPLE_NO_REMOTE_REF_ERROR), None);
1025 assert_eq!(
1026 parse_no_such_remote(SAMPLE_NO_REMOTE_TRACKING_BRANCH_ERROR),
1027 None
1028 );
1029 assert_eq!(
1030 parse_no_such_remote(SAMPLE_PUSH_REFS_PORCELAIN_OUTPUT),
1031 None
1032 );
1033 assert_eq!(parse_no_such_remote(SAMPLE_OK_STDERR), None);
1034 }
1035
1036 #[test]
1037 fn test_parse_no_remote_ref() {
1038 assert_eq!(parse_no_remote_ref(SAMPLE_NO_SUCH_REPOSITORY_ERROR), None);
1039 assert_eq!(parse_no_remote_ref(SAMPLE_NO_SUCH_REMOTE_ERROR), None);
1040 assert_eq!(
1041 parse_no_remote_ref(SAMPLE_NO_REMOTE_REF_ERROR),
1042 Some("refs/heads/noexist".to_string())
1043 );
1044 assert_eq!(
1045 parse_no_remote_ref(SAMPLE_NO_REMOTE_TRACKING_BRANCH_ERROR),
1046 None
1047 );
1048 assert_eq!(parse_no_remote_ref(SAMPLE_PUSH_REFS_PORCELAIN_OUTPUT), None);
1049 assert_eq!(parse_no_remote_ref(SAMPLE_OK_STDERR), None);
1050 }
1051
1052 #[test]
1053 fn test_parse_no_remote_tracking_branch() {
1054 assert_eq!(
1055 parse_no_remote_tracking_branch(SAMPLE_NO_SUCH_REPOSITORY_ERROR),
1056 None
1057 );
1058 assert_eq!(
1059 parse_no_remote_tracking_branch(SAMPLE_NO_SUCH_REMOTE_ERROR),
1060 None
1061 );
1062 assert_eq!(
1063 parse_no_remote_tracking_branch(SAMPLE_NO_REMOTE_REF_ERROR),
1064 None
1065 );
1066 assert_eq!(
1067 parse_no_remote_tracking_branch(SAMPLE_NO_REMOTE_TRACKING_BRANCH_ERROR),
1068 Some("bookmark".to_string())
1069 );
1070 assert_eq!(
1071 parse_no_remote_tracking_branch(SAMPLE_PUSH_REFS_PORCELAIN_OUTPUT),
1072 None
1073 );
1074 assert_eq!(parse_no_remote_tracking_branch(SAMPLE_OK_STDERR), None);
1075 }
1076
1077 #[test]
1078 fn test_parse_git_fetch_output_rejected() {
1079 let output = Output {
1081 status: exit_status_from_code(1),
1082 stdout: b"! d4d535f1d5795c6027f2872b24b7268ece294209 baad96fead6cdc20d47c55a4069c82952f9ac62c refs/remotes/origin/b\n".to_vec(),
1083 stderr: b"".to_vec(),
1084 };
1085 assert_matches!(
1086 parse_git_fetch_output(&output),
1087 Ok(GitFetchStatus::Updates(updates))
1088 if updates.updated.is_empty() && updates.rejected.len() == 1
1089 );
1090 }
1091
1092 #[test]
1093 fn test_parse_ref_updates_sample() {
1094 let sample = indoc! {b"
1095 * 0000000000000000000000000000000000000000 e80d998ab04be7caeac3a732d74b1708aa3d8b26 refs/remotes/origin/a1
1096 ebeb70d8c5f972275f0a22f7af6bc9ddb175ebd9 9175cb3250fd266fe46dcc13664b255a19234286 refs/remotes/origin/a2
1097 + c8303692b8e2f0326cd33873a157b4fa69d54774 798c5e2435e1442946db90a50d47ab90f40c60b7 refs/remotes/origin/a3
1098 - b2ea51c027e11c0f2871cce2a52e648e194df771 0000000000000000000000000000000000000000 refs/remotes/origin/a4
1099 ! d4d535f1d5795c6027f2872b24b7268ece294209 baad96fead6cdc20d47c55a4069c82952f9ac62c refs/remotes/origin/b
1100 = f8e7139764d76132234c13210b6f0abe6b1d9bf6 f8e7139764d76132234c13210b6f0abe6b1d9bf6 refs/remotes/upstream/c
1101 * 0000000000000000000000000000000000000000 fd5b6a095a77575c94fad4164ab580331316c374 refs/tags/v1.0
1102 t 0000000000000000000000000000000000000000 3262fedde0224462bb6ac3015dabc427a4f98316 refs/tags/v2.0
1103 "};
1104 insta::assert_debug_snapshot!(parse_ref_updates(sample).unwrap(), @r#"
1105 GitRefUpdates {
1106 updated: [
1107 (
1108 GitRefNameBuf(
1109 "refs/remotes/origin/a1",
1110 ),
1111 Diff {
1112 before: Sha1(0000000000000000000000000000000000000000),
1113 after: Sha1(e80d998ab04be7caeac3a732d74b1708aa3d8b26),
1114 },
1115 ),
1116 (
1117 GitRefNameBuf(
1118 "refs/remotes/origin/a2",
1119 ),
1120 Diff {
1121 before: Sha1(ebeb70d8c5f972275f0a22f7af6bc9ddb175ebd9),
1122 after: Sha1(9175cb3250fd266fe46dcc13664b255a19234286),
1123 },
1124 ),
1125 (
1126 GitRefNameBuf(
1127 "refs/remotes/origin/a3",
1128 ),
1129 Diff {
1130 before: Sha1(c8303692b8e2f0326cd33873a157b4fa69d54774),
1131 after: Sha1(798c5e2435e1442946db90a50d47ab90f40c60b7),
1132 },
1133 ),
1134 (
1135 GitRefNameBuf(
1136 "refs/remotes/origin/a4",
1137 ),
1138 Diff {
1139 before: Sha1(b2ea51c027e11c0f2871cce2a52e648e194df771),
1140 after: Sha1(0000000000000000000000000000000000000000),
1141 },
1142 ),
1143 (
1144 GitRefNameBuf(
1145 "refs/tags/v1.0",
1146 ),
1147 Diff {
1148 before: Sha1(0000000000000000000000000000000000000000),
1149 after: Sha1(fd5b6a095a77575c94fad4164ab580331316c374),
1150 },
1151 ),
1152 (
1153 GitRefNameBuf(
1154 "refs/tags/v2.0",
1155 ),
1156 Diff {
1157 before: Sha1(0000000000000000000000000000000000000000),
1158 after: Sha1(3262fedde0224462bb6ac3015dabc427a4f98316),
1159 },
1160 ),
1161 ],
1162 rejected: [
1163 (
1164 GitRefNameBuf(
1165 "refs/remotes/origin/b",
1166 ),
1167 Diff {
1168 before: Sha1(d4d535f1d5795c6027f2872b24b7268ece294209),
1169 after: Sha1(baad96fead6cdc20d47c55a4069c82952f9ac62c),
1170 },
1171 ),
1172 ],
1173 }
1174 "#);
1175 }
1176
1177 #[test]
1178 fn test_parse_ref_updates_malformed() {
1179 assert!(parse_ref_updates(b"").is_ok());
1180 assert!(parse_ref_updates(b"\n").is_err());
1181 assert!(parse_ref_updates(b"*\n").is_err());
1182 let oid = "0000000000000000000000000000000000000000";
1183 assert!(parse_ref_updates(format!("**{oid} {oid} name\n").as_bytes()).is_err());
1184 }
1185
1186 #[test]
1187 fn test_parse_ref_pushes() {
1188 assert!(parse_ref_pushes(SAMPLE_NO_SUCH_REPOSITORY_ERROR).is_err());
1189 assert!(parse_ref_pushes(SAMPLE_NO_SUCH_REMOTE_ERROR).is_err());
1190 assert!(parse_ref_pushes(SAMPLE_NO_REMOTE_REF_ERROR).is_err());
1191 assert!(parse_ref_pushes(SAMPLE_NO_REMOTE_TRACKING_BRANCH_ERROR).is_err());
1192 let GitPushStats {
1193 pushed,
1194 rejected,
1195 remote_rejected,
1196 unexported_bookmarks: _,
1197 } = parse_ref_pushes(SAMPLE_PUSH_REFS_PORCELAIN_OUTPUT).unwrap();
1198 assert_eq!(
1199 pushed,
1200 [
1201 "refs/heads/bookmark1",
1202 "refs/heads/bookmark2",
1203 "refs/heads/bookmark3",
1204 "refs/heads/bookmark4",
1205 "refs/heads/bookmark5",
1206 ]
1207 .map(GitRefNameBuf::from)
1208 );
1209 assert_eq!(
1210 rejected,
1211 vec![
1212 (
1213 "refs/heads/bookmark6".into(),
1214 Some("failure lease".to_string())
1215 ),
1216 ("refs/heads/bookmark7".into(), None),
1217 ]
1218 );
1219 assert_eq!(
1220 remote_rejected,
1221 vec![
1222 (
1223 "refs/heads/bookmark8".into(),
1224 Some("hook failure".to_string())
1225 ),
1226 ("refs/heads/bookmark9".into(), None)
1227 ]
1228 );
1229 assert!(parse_ref_pushes(SAMPLE_OK_STDERR).is_err());
1230 }
1231
1232 #[test]
1233 fn test_read_to_end_with_progress() {
1234 let read = |sample: &[u8]| {
1235 let mut callback = GitSubprocessCapture::default();
1236 let output = read_to_end_with_progress(&mut &sample[..], &mut callback).unwrap();
1237 (output, callback)
1238 };
1239 const DUMB_SUFFIX: &str = " ";
1240 let sample = formatdoc! {"
1241 remote: line1{DUMB_SUFFIX}
1242 blah blah
1243 remote: line2.0{DUMB_SUFFIX}\rremote: line2.1{DUMB_SUFFIX}
1244 remote: line3{DUMB_SUFFIX}
1245 Resolving deltas: (12/24)
1246 fatal: some error message
1247 continues
1248 "};
1249
1250 let (output, callback) = read(sample.as_bytes());
1251 assert_eq!(callback.local_sideband, ["blah blah", "\n"]);
1252 assert_eq!(
1253 callback.remote_sideband,
1254 [
1255 "line1", "\n", "line2.0", "\r", "line2.1", "\n", "line3", "\n"
1256 ]
1257 );
1258 assert_eq!(output, b"fatal: some error message\ncontinues\n");
1259 insta::assert_debug_snapshot!(callback.progress, @"
1260 [
1261 GitProgress {
1262 deltas: (
1263 12,
1264 24,
1265 ),
1266 objects: (
1267 0,
1268 0,
1269 ),
1270 counted_objects: (
1271 0,
1272 0,
1273 ),
1274 compressed_objects: (
1275 0,
1276 0,
1277 ),
1278 },
1279 ]
1280 ");
1281
1282 let (output, callback) = read(sample.as_bytes().trim_end());
1284 assert_eq!(
1285 callback.remote_sideband,
1286 [
1287 "line1", "\n", "line2.0", "\r", "line2.1", "\n", "line3", "\n"
1288 ]
1289 );
1290 assert_eq!(output, b"fatal: some error message\ncontinues");
1291 }
1292
1293 #[test]
1294 fn test_read_progress_line() {
1295 assert_eq!(
1296 read_progress_line(b"Receiving objects: (42/100)\r"),
1297 Some((42, 100))
1298 );
1299 assert_eq!(
1300 read_progress_line(b"Resolving deltas: (0/1000)\r"),
1301 Some((0, 1000))
1302 );
1303 assert_eq!(read_progress_line(b"Receiving objects: (420/100)\r"), None);
1304 assert_eq!(
1305 read_progress_line(b"remote: this is something else\n"),
1306 None
1307 );
1308 assert_eq!(read_progress_line(b"fatal: this is a git error\n"), None);
1309 }
1310
1311 #[test]
1312 fn test_parse_unknown_option() {
1313 assert_eq!(
1314 parse_unknown_option(b"unknown option: --abc").unwrap(),
1315 "abc".to_string()
1316 );
1317 assert_eq!(
1318 parse_unknown_option(b"error: unknown option `abc'").unwrap(),
1319 "abc".to_string()
1320 );
1321 assert!(parse_unknown_option(b"error: unknown option: 'abc'").is_none());
1322 }
1323
1324 #[test]
1325 fn test_initial_overall_progress_is_zero() {
1326 assert_eq!(GitProgress::default().overall(), 0.0);
1327 }
1328}