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::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
43const MINIMUM_GIT_VERSION: &str = "2.41.0";
47
48#[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
76pub(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 fn create_command(&self) -> Command {
99 let mut git_cmd = Command::new(&self.options.executable_path);
100 #[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 git_cmd
113 .args(["-c", "core.fsmonitor=false"])
124 .args(["-c", "submodule.recurse=false"])
128 .arg("--git-dir")
129 .arg(&self.git_dir)
130 .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 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 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 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 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 let () = parse_git_branch_prune_output(output)?;
227
228 Ok(())
229 }
230
231 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 let maybe_branch = parse_git_remote_show_default_branch(&output.stdout)?;
250 Ok(maybe_branch.map(Into::into))
251 }
252
253 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 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 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
298fn 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 b"error: ",
310 b"fatal: ",
312 b"usage: ",
314 b"unknown option: ",
316];
317
318fn 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
339fn 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
360fn 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
380fn 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#[derive(Clone, Debug)]
398pub enum GitFetchStatus {
399 Updates(GitRefUpdates),
401 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 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#[derive(Clone, Debug, Default)]
436pub struct GitRefUpdates {
437 #[cfg_attr(not(test), expect(dead_code))] pub updated: Vec<(GitRefNameBuf, Diff<gix::ObjectId>)>,
443 pub rejected: Vec<(GitRefNameBuf, Diff<gix::ObjectId>)>,
446}
447
448fn 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 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 b' ' | b'+' | b'-' | b't' | b'*' => updated.push((name, oid_diff)),
485 b'!' => rejected.push((name, oid_diff)),
487 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 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 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
546fn 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 b"+" | b"-" | b"*" | b"=" | b" " => {
612 push_stats.pushed.push(reference);
613 }
614 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
645fn 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
673pub trait GitSubprocessCallback {
675 fn needs_progress(&self) -> bool;
677
678 fn progress(&mut self, progress: &GitProgress) -> io::Result<()>;
680
681 fn local_sideband(
685 &mut self,
686 message: &[u8],
687 term: Option<GitSidebandLineTerminator>,
688 ) -> io::Result<()>;
689
690 fn remote_sideband(
692 &mut self,
693 message: &[u8],
694 term: Option<GitSidebandLineTerminator>,
695 ) -> io::Result<()>;
696}
697
698#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
700#[repr(u8)]
701pub enum GitSidebandLineTerminator {
702 Cr = b'\r',
704 Lf = b'\n',
706}
707
708impl GitSidebandLineTerminator {
709 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
719fn 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#[derive(Clone, Debug, Default)]
764pub struct GitProgress {
765 pub deltas: (u64, u64),
767 pub objects: (u64, u64),
769 pub counted_objects: (u64, u64),
771 pub compressed_objects: (u64, u64),
773}
774
775impl GitProgress {
777 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 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 if ERROR_PREFIXES.iter().any(|prefix| line.starts_with(prefix)) {
814 reader.read_to_end(&mut data)?;
815 break;
816 }
817
818 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
885fn read_progress_line(line: &[u8]) -> Option<(u64, u64)> {
888 let (_prefix, suffix) = line.split_once_str("(")?;
890 let (fraction, _suffix) = suffix.split_once_str(")")?;
891
892 let (frac_str, total_str) = fraction.split_once_str("/")?;
894
895 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
901fn 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 _; #[cfg(windows)]
998 use std::os::windows::process::ExitStatusExt as _; 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 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 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}