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