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