1use 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
41const MINIMUM_GIT_VERSION: &str = "2.40.4";
48
49#[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
77pub(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 fn create_command(&self) -> Command {
100 let mut git_cmd = Command::new(self.git_executable_path);
101 #[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 git_cmd
114 .args(["-c", "core.fsmonitor=false"])
125 .arg("--git-dir")
126 .arg(&self.git_dir)
127 .env("LC_ALL", "C")
130 .stdin(Stdio::null())
131 .stderr(Stdio::piped());
132
133 git_cmd
134 }
135
136 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 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 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 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 let () = parse_git_branch_prune_output(output)?;
205
206 Ok(())
207 }
208
209 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 let maybe_branch = parse_git_remote_show_default_branch(&output.stdout)?;
228 Ok(maybe_branch.map(Into::into))
229 }
230
231 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 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 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
276fn external_git_error(stderr: &[u8]) -> GitSubprocessError {
279 GitSubprocessError::External(format!(
280 "External git program failed:\n{}",
281 stderr.to_str_lossy()
282 ))
283}
284
285fn 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
306fn 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
327fn 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
347fn 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
363fn parse_git_fetch_output(output: Output) -> Result<Option<String>, GitSubprocessError> {
367 if output.status.success() {
368 return Ok(None);
369 }
370
371 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 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 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
441fn 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 b"+" | b"-" | b"*" | b"=" | b" " => {
507 push_stats.pushed.push(reference);
508 }
509 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
540fn 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
572fn 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 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 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
725fn read_progress_line(line: &[u8]) -> Option<(u64, u64)> {
728 let (_prefix, suffix) = line.split_once_str("(")?;
730 let (fraction, _suffix) = suffix.split_once_str(")")?;
731
732 let (frac_str, total_str) = fraction.split_once_str("/")?;
734
735 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
741fn 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 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}