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