1use std::io;
16use std::io::BufReader;
17use std::io::Read;
18use std::num::NonZeroU32;
19use std::path::Path;
20use std::path::PathBuf;
21use std::process::Child;
22use std::process::Command;
23use std::process::Output;
24use std::process::Stdio;
25use std::thread;
26
27use bstr::BStr;
28use bstr::ByteSlice as _;
29use itertools::Itertools as _;
30use thiserror::Error;
31
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;
43use crate::subprocess_util::suppress_console_window;
44
45const MINIMUM_GIT_VERSION: &str = "2.42.0";
51
52#[derive(Error, Debug)]
54pub enum GitSubprocessError {
55 #[error("Could not find repository at '{0}'")]
56 NoSuchRepository(String),
57 #[error("Could not execute the git process, found in the OS path '{path}'")]
58 SpawnInPath {
59 path: PathBuf,
60 #[source]
61 error: std::io::Error,
62 },
63 #[error("Could not execute git process at specified path '{path}'")]
64 Spawn {
65 path: PathBuf,
66 #[source]
67 error: std::io::Error,
68 },
69 #[error("Failed to wait for the git process")]
70 Wait(std::io::Error),
71 #[error(
72 "Git does not recognize required option: {0} (note: Jujutsu requires git >= \
73 {MINIMUM_GIT_VERSION})"
74 )]
75 UnsupportedGitOption(String),
76 #[error("Git process failed: {0}")]
77 External(String),
78}
79
80pub(crate) struct GitSubprocessContext {
82 git_dir: PathBuf,
83 options: GitSubprocessOptions,
84}
85
86impl GitSubprocessContext {
87 pub(crate) fn new(git_dir: impl Into<PathBuf>, options: GitSubprocessOptions) -> Self {
88 Self {
89 git_dir: git_dir.into(),
90 options,
91 }
92 }
93
94 pub(crate) fn from_git_backend(
95 git_backend: &GitBackend,
96 options: GitSubprocessOptions,
97 ) -> Self {
98 Self::new(git_backend.git_repo_path(), options)
99 }
100
101 fn create_command(&self) -> Command {
103 let mut git_cmd = Command::new(&self.options.executable_path);
104 suppress_console_window(&mut git_cmd);
105
106 git_cmd
111 .args(["-c", "core.fsmonitor=false"])
122 .args(["-c", "submodule.recurse=false"])
126 .arg("--git-dir")
127 .arg(&self.git_dir)
128 .env_remove("LC_ALL")
132 .env_remove("LANGUAGE")
133 .env("LC_MESSAGES", "C")
134 .stdin(Stdio::null())
135 .stderr(Stdio::piped());
136
137 git_cmd.envs(&self.options.environment);
138
139 git_cmd
140 }
141
142 fn spawn_cmd(&self, mut git_cmd: Command) -> Result<Child, GitSubprocessError> {
144 tracing::debug!(cmd = ?git_cmd, "spawning a git subprocess");
145
146 git_cmd.spawn().map_err(|error| {
147 if self.options.executable_path.is_absolute() {
148 GitSubprocessError::Spawn {
149 path: self.options.executable_path.clone(),
150 error,
151 }
152 } else {
153 GitSubprocessError::SpawnInPath {
154 path: self.options.executable_path.clone(),
155 error,
156 }
157 }
158 })
159 }
160
161 pub(crate) fn spawn_fetch(
166 &self,
167 remote_name: &RemoteName,
168 refspecs: &[RefSpec],
169 negative_refspecs: &[NegativeRefSpec],
170 callback: &mut dyn GitSubprocessCallback,
171 depth: Option<NonZeroU32>,
172 ) -> 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 command.arg("--no-tags");
189 command.arg("--").arg(remote_name.as_str());
190 command.args(
191 refspecs
192 .iter()
193 .map(|x| x.to_git_format())
194 .chain(negative_refspecs.iter().map(|x| x.to_git_format())),
195 );
196
197 let output = wait_with_progress(self.spawn_cmd(command)?, callback)?;
198
199 parse_git_fetch_output(&output)
200 }
201
202 pub(crate) fn spawn_branch_prune(
204 &self,
205 branches_to_prune: &[String],
206 ) -> Result<(), GitSubprocessError> {
207 if branches_to_prune.is_empty() {
208 return Ok(());
209 }
210 tracing::debug!(?branches_to_prune, "pruning branches");
211 let mut command = self.create_command();
212 command.stdout(Stdio::null());
213 command.args(["branch", "--remotes", "--delete", "--"]);
214 command.args(branches_to_prune);
215
216 let output = wait_with_output(self.spawn_cmd(command)?)?;
217
218 let () = parse_git_branch_prune_output(output)?;
220
221 Ok(())
222 }
223
224 pub(crate) fn spawn_remote_show(
231 &self,
232 remote_name: &RemoteName,
233 ) -> Result<Option<RefNameBuf>, GitSubprocessError> {
234 let mut command = self.create_command();
235 command.stdout(Stdio::piped());
236 command.args(["remote", "show", "--", remote_name.as_str()]);
237 let output = wait_with_output(self.spawn_cmd(command)?)?;
238
239 let output = parse_git_remote_show_output(output)?;
240
241 let maybe_branch = parse_git_remote_show_default_branch(&output.stdout)?;
243 Ok(maybe_branch.map(Into::into))
244 }
245
246 pub(crate) fn spawn_push(
255 &self,
256 remote_name: &RemoteName,
257 references: &[RefToPush],
258 callback: &mut dyn GitSubprocessCallback,
259 options: &GitPushOptions,
260 ) -> Result<GitPushStats, GitSubprocessError> {
261 let mut command = self.create_command();
262 command.stdout(Stdio::piped());
263 command.args(["push", "--porcelain", "--no-verify"]);
269 if callback.needs_progress() {
270 command.arg("--progress");
271 }
272 command.args(
273 options
274 .remote_push_options
275 .iter()
276 .map(|option| format!("--push-option={option}")),
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 pub(crate) fn spawn_worktree_add(
306 &self,
307 destination: &Path,
308 branch_name: &str,
309 ) -> Result<(), GitSubprocessError> {
310 let mut command = self.create_command();
311 command.stdout(Stdio::null());
312 command.args(["-c", "worktree.useRelativePaths=true"]);
316 command.args(["worktree", "add", "--orphan", "-b", branch_name, "--"]);
317 command.arg(destination);
318
319 let output = wait_with_output(self.spawn_cmd(command)?)?;
320
321 parse_git_worktree_output(output)
322 }
323
324 pub(crate) fn spawn_worktree_repair(
327 &self,
328 worktree_path: &Path,
329 ) -> Result<(), GitSubprocessError> {
330 let mut command = self.create_command();
331 command.stdout(Stdio::null());
332 command.args(["-c", "worktree.useRelativePaths=true"]);
333 command.args(["worktree", "repair", "--"]);
334 command.arg(worktree_path);
335
336 let output = wait_with_output(self.spawn_cmd(command)?)?;
337
338 parse_git_worktree_output(output)
339 }
340
341 pub(crate) fn spawn_worktree_prune(&self) -> Result<(), GitSubprocessError> {
343 let mut command = self.create_command();
344 command.stdout(Stdio::null());
345 command.args(["worktree", "prune"]);
346
347 let output = wait_with_output(self.spawn_cmd(command)?)?;
348
349 parse_git_worktree_output(output)
350 }
351}
352
353fn external_git_error(stderr: &[u8]) -> GitSubprocessError {
356 GitSubprocessError::External(format!(
357 "External git program failed:\n{}",
358 stderr.to_str_lossy()
359 ))
360}
361
362const ERROR_PREFIXES: &[&[u8]] = &[
363 b"error: ",
365 b"fatal: ",
367 b"usage: ",
369 b"unknown option: ",
371];
372
373fn parse_no_such_remote(stderr: &[u8]) -> Option<String> {
383 let first_line = stderr.lines().next()?;
384 let suffix = first_line
385 .strip_prefix(b"fatal: '")
386 .or_else(|| first_line.strip_prefix(b"fatal: unable to access '"))?;
387
388 suffix
389 .strip_suffix(b"' does not appear to be a git repository")
390 .or_else(|| suffix.strip_suffix(b"': Could not resolve host: invalid-remote"))
391 .map(|remote| remote.to_str_lossy().into_owned())
392}
393
394fn parse_no_remote_ref(stderr: &[u8]) -> Option<String> {
409 let first_line = stderr.lines().next()?;
410 first_line
411 .strip_prefix(b"fatal: couldn't find remote ref ")
412 .map(|refname| refname.to_str_lossy().into_owned())
413}
414
415fn parse_no_remote_tracking_branch(stderr: &[u8]) -> Option<String> {
425 let first_line = stderr.lines().next()?;
426
427 let suffix = first_line.strip_prefix(b"error: remote-tracking branch '")?;
428
429 suffix
430 .strip_suffix(b"' not found.")
431 .or_else(|| suffix.strip_suffix(b"' not found"))
432 .map(|branch| branch.to_str_lossy().into_owned())
433}
434
435fn parse_unknown_option(stderr: &[u8]) -> Option<String> {
442 let first_line = stderr.lines().next()?;
443 first_line
444 .strip_prefix(b"unknown option: --")
445 .or(first_line
446 .strip_prefix(b"error: unknown option `")
447 .and_then(|s| s.strip_suffix(b"'")))
448 .map(|s| s.to_str_lossy().into())
449}
450
451#[derive(Clone, Debug)]
453pub enum GitFetchStatus {
454 Updates(GitRefUpdates),
456 NoRemoteRef(String),
460}
461
462fn parse_git_fetch_output(output: &Output) -> Result<GitFetchStatus, GitSubprocessError> {
463 if output.status.success() {
464 let updates = parse_ref_updates(&output.stdout)?;
465 return Ok(GitFetchStatus::Updates(updates));
466 }
467
468 if let Some(option) = parse_unknown_option(&output.stderr) {
470 return Err(GitSubprocessError::UnsupportedGitOption(option));
471 }
472
473 if let Some(remote) = parse_no_such_remote(&output.stderr) {
474 return Err(GitSubprocessError::NoSuchRepository(remote));
475 }
476
477 if let Some(refspec) = parse_no_remote_ref(&output.stderr) {
478 return Ok(GitFetchStatus::NoRemoteRef(refspec));
479 }
480
481 let updates = parse_ref_updates(&output.stdout)?;
482 if !updates.rejected.is_empty() || parse_no_remote_tracking_branch(&output.stderr).is_some() {
483 Ok(GitFetchStatus::Updates(updates))
484 } else {
485 Err(external_git_error(&output.stderr))
486 }
487}
488
489#[derive(Clone, Debug, Default)]
491pub struct GitRefUpdates {
492 #[cfg_attr(not(test), expect(dead_code))] pub updated: Vec<(GitRefNameBuf, Diff<gix::ObjectId>)>,
498 pub rejected: Vec<(GitRefNameBuf, Diff<gix::ObjectId>)>,
501}
502
503fn parse_ref_updates(stdout: &[u8]) -> Result<GitRefUpdates, GitSubprocessError> {
505 let mut updated = vec![];
506 let mut rejected = vec![];
507 for (i, line) in stdout.lines().enumerate() {
508 let parse_err = |message: &str| {
509 GitSubprocessError::External(format!(
510 "Line {line_no}: {message}: {line}",
511 line_no = i + 1,
512 line = BStr::new(line)
513 ))
514 };
515 let mut line_bytes = line.iter();
518 let flag = *line_bytes.next().ok_or_else(|| parse_err("empty line"))?;
519 if line_bytes.next() != Some(&b' ') {
520 return Err(parse_err("no flag separator found"));
521 }
522 let [old_oid, new_oid, name] = line_bytes
523 .as_slice()
524 .splitn(3, |&b| b == b' ')
525 .collect_array()
526 .ok_or_else(|| parse_err("unexpected number of columns"))?;
527 let name: GitRefNameBuf = str::from_utf8(name)
528 .map_err(|_| parse_err("non-UTF-8 ref name"))?
529 .into();
530 let old_oid = gix::ObjectId::from_hex(old_oid).map_err(|_| parse_err("invalid old oid"))?;
531 let new_oid = gix::ObjectId::from_hex(new_oid).map_err(|_| parse_err("invalid new oid"))?;
532 let oid_diff = Diff::new(old_oid, new_oid);
533 match flag {
534 b' ' | b'+' | b'-' | b't' | b'*' => updated.push((name, oid_diff)),
540 b'!' => rejected.push((name, oid_diff)),
542 b'=' => {}
545 _ => return Err(parse_err("unknown flag")),
546 }
547 }
548 Ok(GitRefUpdates { updated, rejected })
549}
550
551fn parse_git_branch_prune_output(output: Output) -> Result<(), GitSubprocessError> {
552 if output.status.success() {
553 return Ok(());
554 }
555
556 if let Some(option) = parse_unknown_option(&output.stderr) {
558 return Err(GitSubprocessError::UnsupportedGitOption(option));
559 }
560
561 if parse_no_remote_tracking_branch(&output.stderr).is_some() {
562 return Ok(());
563 }
564
565 Err(external_git_error(&output.stderr))
566}
567
568fn parse_git_worktree_output(output: Output) -> Result<(), GitSubprocessError> {
569 if output.status.success() {
570 return Ok(());
571 }
572
573 if let Some(option) = parse_unknown_option(&output.stderr) {
574 return Err(GitSubprocessError::UnsupportedGitOption(option));
575 }
576
577 Err(external_git_error(&output.stderr))
578}
579
580fn parse_git_remote_show_output(output: Output) -> Result<Output, GitSubprocessError> {
581 if output.status.success() {
582 return Ok(output);
583 }
584
585 if let Some(option) = parse_unknown_option(&output.stderr) {
587 return Err(GitSubprocessError::UnsupportedGitOption(option));
588 }
589
590 if let Some(remote) = parse_no_such_remote(&output.stderr) {
591 return Err(GitSubprocessError::NoSuchRepository(remote));
592 }
593
594 Err(external_git_error(&output.stderr))
595}
596
597fn parse_git_remote_show_default_branch(
598 stdout: &[u8],
599) -> Result<Option<String>, GitSubprocessError> {
600 stdout
601 .lines()
602 .map(|x| x.trim())
603 .find(|x| x.starts_with_str("HEAD branch:"))
604 .inspect(|x| tracing::debug!(line = ?x.to_str_lossy(), "default branch"))
605 .and_then(|x| x.split_str(" ").last().map(|y| y.trim()))
606 .filter(|branch_name| branch_name != b"(unknown)")
607 .map(|branch_name| branch_name.to_str())
608 .transpose()
609 .map_err(|e| GitSubprocessError::External(format!("git remote output is not utf-8: {e:?}")))
610 .map(|b| b.map(|x| x.to_string()))
611}
612
613fn parse_ref_pushes(stdout: &[u8]) -> Result<GitPushStats, GitSubprocessError> {
630 if !stdout.starts_with(b"To ") {
631 return Err(GitSubprocessError::External(format!(
632 "Git push output unfamiliar:\n{}",
633 stdout.to_str_lossy()
634 )));
635 }
636
637 let mut push_stats = GitPushStats::default();
638 for (idx, line) in stdout
639 .lines()
640 .skip(1)
641 .take_while(|line| line != b"Done")
642 .enumerate()
643 {
644 tracing::debug!("response #{idx}: {}", line.to_str_lossy());
645 let [flag, reference, summary] = line.split_str("\t").collect_array().ok_or_else(|| {
646 GitSubprocessError::External(format!(
647 "Line #{idx} of git-push has unknown format: {}",
648 line.to_str_lossy()
649 ))
650 })?;
651 let full_refspec = reference
652 .to_str()
653 .map_err(|e| {
654 format!(
655 "Line #{} of git-push has non-utf8 refspec {}: {}",
656 idx,
657 reference.to_str_lossy(),
658 e
659 )
660 })
661 .map_err(GitSubprocessError::External)?;
662
663 let reference: GitRefNameBuf = full_refspec
664 .split_once(':')
665 .map(|(_refname, reference)| reference.into())
666 .ok_or_else(|| {
667 GitSubprocessError::External(format!(
668 "Line #{idx} of git-push has full refspec without named ref: {full_refspec}"
669 ))
670 })?;
671
672 match flag {
673 b"+" | b"-" | b"*" | b"=" | b" " => {
679 push_stats.pushed.push(reference);
680 }
681 b"!" => {
683 if let Some(reason) = summary.strip_prefix(b"[remote rejected]") {
684 let reason = reason
685 .strip_prefix(b" (")
686 .and_then(|r| r.strip_suffix(b")"))
687 .map(|x| x.to_str_lossy().into_owned());
688 push_stats.remote_rejected.push((reference, reason));
689 } else {
690 let reason = summary
691 .split_once_str("]")
692 .and_then(|(_, reason)| reason.strip_prefix(b" ("))
693 .and_then(|r| r.strip_suffix(b")"))
694 .map(|x| x.to_str_lossy().into_owned());
695 push_stats.rejected.push((reference, reason));
696 }
697 }
698 unknown => {
699 return Err(GitSubprocessError::External(format!(
700 "Line #{} of git-push starts with an unknown flag '{}': '{}'",
701 idx,
702 unknown.to_str_lossy(),
703 line.to_str_lossy()
704 )));
705 }
706 }
707 }
708
709 Ok(push_stats)
710}
711
712fn parse_git_push_output(output: Output) -> Result<GitPushStats, GitSubprocessError> {
716 if output.status.success() {
717 let ref_pushes = parse_ref_pushes(&output.stdout)?;
718 return Ok(ref_pushes);
719 }
720
721 if let Some(option) = parse_unknown_option(&output.stderr) {
722 return Err(GitSubprocessError::UnsupportedGitOption(option));
723 }
724
725 if let Some(remote) = parse_no_such_remote(&output.stderr) {
726 return Err(GitSubprocessError::NoSuchRepository(remote));
727 }
728
729 if output
730 .stderr
731 .lines()
732 .any(|line| line.starts_with(b"error: failed to push some refs to "))
733 {
734 parse_ref_pushes(&output.stdout)
735 } else {
736 Err(external_git_error(&output.stderr))
737 }
738}
739
740pub trait GitSubprocessCallback {
742 fn needs_progress(&self) -> bool;
744
745 fn progress(&mut self, progress: &GitProgress) -> io::Result<()>;
747
748 fn local_sideband(
752 &mut self,
753 message: &[u8],
754 term: Option<GitSidebandLineTerminator>,
755 ) -> io::Result<()>;
756
757 fn remote_sideband(
759 &mut self,
760 message: &[u8],
761 term: Option<GitSidebandLineTerminator>,
762 ) -> io::Result<()>;
763}
764
765#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
767#[repr(u8)]
768pub enum GitSidebandLineTerminator {
769 Cr = b'\r',
771 Lf = b'\n',
773}
774
775impl GitSidebandLineTerminator {
776 pub fn as_byte(self) -> u8 {
778 self as u8
779 }
780}
781
782fn wait_with_output(child: Child) -> Result<Output, GitSubprocessError> {
783 child.wait_with_output().map_err(GitSubprocessError::Wait)
784}
785
786fn wait_with_progress(
804 mut child: Child,
805 callback: &mut dyn GitSubprocessCallback,
806) -> Result<Output, GitSubprocessError> {
807 let (stdout, stderr) = thread::scope(|s| -> io::Result<_> {
808 drop(child.stdin.take());
809 let mut child_stdout = child.stdout.take().expect("stdout should be piped");
810 let mut child_stderr = child.stderr.take().expect("stderr should be piped");
811 let thread = s.spawn(move || -> io::Result<_> {
812 let mut buf = Vec::new();
813 child_stdout.read_to_end(&mut buf)?;
814 Ok(buf)
815 });
816 let stderr = read_to_end_with_progress(&mut child_stderr, callback)?;
817 let stdout = thread.join().expect("reader thread wouldn't panic")?;
818 Ok((stdout, stderr))
819 })
820 .map_err(GitSubprocessError::Wait)?;
821 let status = child.wait().map_err(GitSubprocessError::Wait)?;
822 Ok(Output {
823 status,
824 stdout,
825 stderr,
826 })
827}
828
829#[derive(Clone, Debug, Default)]
831pub struct GitProgress {
832 pub deltas: (u64, u64),
834 pub objects: (u64, u64),
836 pub counted_objects: (u64, u64),
838 pub compressed_objects: (u64, u64),
840}
841
842impl GitProgress {
844 pub fn overall(&self) -> f32 {
846 if self.total() != 0 {
847 self.fraction() as f32 / self.total() as f32
848 } else {
849 0.0
850 }
851 }
852
853 fn fraction(&self) -> u64 {
854 self.objects.0 + self.deltas.0 + self.counted_objects.0 + self.compressed_objects.0
855 }
856
857 fn total(&self) -> u64 {
858 self.objects.1 + self.deltas.1 + self.counted_objects.1 + self.compressed_objects.1
859 }
860}
861
862fn read_to_end_with_progress<R: Read>(
863 src: R,
864 callback: &mut dyn GitSubprocessCallback,
865) -> io::Result<Vec<u8>> {
866 let mut reader = BufReader::new(src);
867 let mut data = Vec::new();
868 let mut progress = GitProgress::default();
869
870 loop {
871 let start = data.len();
873 read_until_cr_or_lf(&mut reader, &mut data)?;
874 let line = &data[start..];
875 if line.is_empty() {
876 break;
877 }
878
879 if ERROR_PREFIXES.iter().any(|prefix| line.starts_with(prefix)) {
881 reader.read_to_end(&mut data)?;
882 break;
883 }
884
885 if update_progress(line, &mut progress.objects, b"Receiving objects:")
889 || update_progress(line, &mut progress.deltas, b"Resolving deltas:")
890 || update_progress(
891 line,
892 &mut progress.counted_objects,
893 b"remote: Counting objects:",
894 )
895 || update_progress(
896 line,
897 &mut progress.compressed_objects,
898 b"remote: Compressing objects:",
899 )
900 {
901 callback.progress(&progress).ok();
902 data.truncate(start);
903 } else if let Some(message) = line.strip_prefix(b"remote: ") {
904 let (body, term) = trim_sideband_line(message);
905 callback.remote_sideband(body, term).ok();
906 data.truncate(start);
907 } else {
908 let (body, term) = trim_sideband_line(line);
909 callback.local_sideband(body, term).ok();
910 data.truncate(start);
911 }
912 }
913 Ok(data)
914}
915
916fn update_progress(line: &[u8], progress: &mut (u64, u64), prefix: &[u8]) -> bool {
917 if let Some(line) = line.strip_prefix(prefix) {
918 if let Some((frac, total)) = read_progress_line(line) {
919 *progress = (frac, total);
920 }
921
922 true
923 } else {
924 false
925 }
926}
927
928fn read_until_cr_or_lf<R: io::BufRead + ?Sized>(
929 reader: &mut R,
930 dest_buf: &mut Vec<u8>,
931) -> io::Result<()> {
932 loop {
933 let data = match reader.fill_buf() {
934 Ok(data) => data,
935 Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
936 Err(err) => return Err(err),
937 };
938 let (n, found) = match data.iter().position(|&b| matches!(b, b'\r' | b'\n')) {
939 Some(i) => (i + 1, true),
940 None => (data.len(), false),
941 };
942
943 dest_buf.extend_from_slice(&data[..n]);
944 reader.consume(n);
945
946 if found || n == 0 {
947 return Ok(());
948 }
949 }
950}
951
952fn read_progress_line(line: &[u8]) -> Option<(u64, u64)> {
955 let (_prefix, suffix) = line.split_once_str("(")?;
957 let (fraction, _suffix) = suffix.split_once_str(")")?;
958
959 let (frac_str, total_str) = fraction.split_once_str("/")?;
961
962 let frac = frac_str.to_str().ok()?.parse().ok()?;
964 let total = total_str.to_str().ok()?.parse().ok()?;
965 (frac <= total).then_some((frac, total))
966}
967
968fn trim_sideband_line(line: &[u8]) -> (&[u8], Option<GitSidebandLineTerminator>) {
971 let (body, term) = match line {
972 [body @ .., b'\r'] => (body, Some(GitSidebandLineTerminator::Cr)),
973 [body @ .., b'\n'] => (body, Some(GitSidebandLineTerminator::Lf)),
974 _ => (line, None),
975 };
976 let n = body.iter().rev().take_while(|&&b| b == b' ').count();
977 (&body[..body.len() - n], term)
978}
979
980#[cfg(test)]
981mod test {
982 use std::assert_matches;
983 use std::process::ExitStatus;
984
985 use bstr::BString;
986 use indoc::formatdoc;
987 use indoc::indoc;
988
989 use super::*;
990
991 const SAMPLE_NO_SUCH_REPOSITORY_ERROR: &[u8] =
992 br###"fatal: unable to access 'origin': Could not resolve host: invalid-remote
993fatal: Could not read from remote repository.
994
995Please make sure you have the correct access rights
996and the repository exists. "###;
997 const SAMPLE_NO_SUCH_REMOTE_ERROR: &[u8] =
998 br###"fatal: 'origin' does not appear to be a git repository
999fatal: Could not read from remote repository.
1000
1001Please make sure you have the correct access rights
1002and the repository exists. "###;
1003 const SAMPLE_NO_REMOTE_REF_ERROR: &[u8] = b"fatal: couldn't find remote ref refs/heads/noexist";
1004 const SAMPLE_NO_REMOTE_TRACKING_BRANCH_ERROR: &[u8] =
1005 b"error: remote-tracking branch 'bookmark' not found";
1006 const SAMPLE_PUSH_REFS_PORCELAIN_OUTPUT: &[u8] = b"To origin
1007*\tdeadbeef:refs/heads/bookmark1\t[new branch]
1008+\tdeadbeef:refs/heads/bookmark2\tabcd..dead
1009-\tdeadbeef:refs/heads/bookmark3\t[deleted branch]
1010 \tdeadbeef:refs/heads/bookmark4\tabcd..dead
1011=\tdeadbeef:refs/heads/bookmark5\tabcd..abcd
1012!\tdeadbeef:refs/heads/bookmark6\t[rejected] (failure lease)
1013!\tdeadbeef:refs/heads/bookmark7\t[rejected]
1014!\tdeadbeef:refs/heads/bookmark8\t[remote rejected] (hook failure)
1015!\tdeadbeef:refs/heads/bookmark9\t[remote rejected]
1016Done";
1017 const SAMPLE_OK_STDERR: &[u8] = b"";
1018
1019 #[derive(Debug, Default)]
1020 struct GitSubprocessCapture {
1021 progress: Vec<GitProgress>,
1022 local_sideband: Vec<BString>,
1023 remote_sideband: Vec<BString>,
1024 }
1025
1026 impl GitSubprocessCallback for GitSubprocessCapture {
1027 fn needs_progress(&self) -> bool {
1028 true
1029 }
1030
1031 fn progress(&mut self, progress: &GitProgress) -> io::Result<()> {
1032 self.progress.push(progress.clone());
1033 Ok(())
1034 }
1035
1036 fn local_sideband(
1037 &mut self,
1038 message: &[u8],
1039 term: Option<GitSidebandLineTerminator>,
1040 ) -> io::Result<()> {
1041 self.local_sideband.push(message.into());
1042 if let Some(term) = term {
1043 self.local_sideband.push([term.as_byte()].into());
1044 }
1045 Ok(())
1046 }
1047
1048 fn remote_sideband(
1049 &mut self,
1050 message: &[u8],
1051 term: Option<GitSidebandLineTerminator>,
1052 ) -> io::Result<()> {
1053 self.remote_sideband.push(message.into());
1054 if let Some(term) = term {
1055 self.remote_sideband.push([term.as_byte()].into());
1056 }
1057 Ok(())
1058 }
1059 }
1060
1061 fn exit_status_from_code(code: u8) -> ExitStatus {
1062 #[cfg(unix)]
1063 use std::os::unix::process::ExitStatusExt as _; #[cfg(windows)]
1065 use std::os::windows::process::ExitStatusExt as _; ExitStatus::from_raw(code.into())
1067 }
1068
1069 #[test]
1070 fn test_parse_no_such_remote() {
1071 assert_eq!(
1072 parse_no_such_remote(SAMPLE_NO_SUCH_REPOSITORY_ERROR),
1073 Some("origin".to_string())
1074 );
1075 assert_eq!(
1076 parse_no_such_remote(SAMPLE_NO_SUCH_REMOTE_ERROR),
1077 Some("origin".to_string())
1078 );
1079 assert_eq!(parse_no_such_remote(SAMPLE_NO_REMOTE_REF_ERROR), None);
1080 assert_eq!(
1081 parse_no_such_remote(SAMPLE_NO_REMOTE_TRACKING_BRANCH_ERROR),
1082 None
1083 );
1084 assert_eq!(
1085 parse_no_such_remote(SAMPLE_PUSH_REFS_PORCELAIN_OUTPUT),
1086 None
1087 );
1088 assert_eq!(parse_no_such_remote(SAMPLE_OK_STDERR), None);
1089 }
1090
1091 #[test]
1092 fn test_parse_no_remote_ref() {
1093 assert_eq!(parse_no_remote_ref(SAMPLE_NO_SUCH_REPOSITORY_ERROR), None);
1094 assert_eq!(parse_no_remote_ref(SAMPLE_NO_SUCH_REMOTE_ERROR), None);
1095 assert_eq!(
1096 parse_no_remote_ref(SAMPLE_NO_REMOTE_REF_ERROR),
1097 Some("refs/heads/noexist".to_string())
1098 );
1099 assert_eq!(
1100 parse_no_remote_ref(SAMPLE_NO_REMOTE_TRACKING_BRANCH_ERROR),
1101 None
1102 );
1103 assert_eq!(parse_no_remote_ref(SAMPLE_PUSH_REFS_PORCELAIN_OUTPUT), None);
1104 assert_eq!(parse_no_remote_ref(SAMPLE_OK_STDERR), None);
1105 }
1106
1107 #[test]
1108 fn test_parse_no_remote_tracking_branch() {
1109 assert_eq!(
1110 parse_no_remote_tracking_branch(SAMPLE_NO_SUCH_REPOSITORY_ERROR),
1111 None
1112 );
1113 assert_eq!(
1114 parse_no_remote_tracking_branch(SAMPLE_NO_SUCH_REMOTE_ERROR),
1115 None
1116 );
1117 assert_eq!(
1118 parse_no_remote_tracking_branch(SAMPLE_NO_REMOTE_REF_ERROR),
1119 None
1120 );
1121 assert_eq!(
1122 parse_no_remote_tracking_branch(SAMPLE_NO_REMOTE_TRACKING_BRANCH_ERROR),
1123 Some("bookmark".to_string())
1124 );
1125 assert_eq!(
1126 parse_no_remote_tracking_branch(SAMPLE_PUSH_REFS_PORCELAIN_OUTPUT),
1127 None
1128 );
1129 assert_eq!(parse_no_remote_tracking_branch(SAMPLE_OK_STDERR), None);
1130 }
1131
1132 #[test]
1133 fn test_parse_git_fetch_output_rejected() {
1134 let output = Output {
1136 status: exit_status_from_code(1),
1137 stdout: b"! d4d535f1d5795c6027f2872b24b7268ece294209 baad96fead6cdc20d47c55a4069c82952f9ac62c refs/remotes/origin/b\n".to_vec(),
1138 stderr: b"".to_vec(),
1139 };
1140 assert_matches!(
1141 parse_git_fetch_output(&output),
1142 Ok(GitFetchStatus::Updates(updates))
1143 if updates.updated.is_empty() && updates.rejected.len() == 1
1144 );
1145 }
1146
1147 #[test]
1148 fn test_parse_ref_updates_sample() {
1149 let sample = indoc! {b"
1150 * 0000000000000000000000000000000000000000 e80d998ab04be7caeac3a732d74b1708aa3d8b26 refs/remotes/origin/a1
1151 ebeb70d8c5f972275f0a22f7af6bc9ddb175ebd9 9175cb3250fd266fe46dcc13664b255a19234286 refs/remotes/origin/a2
1152 + c8303692b8e2f0326cd33873a157b4fa69d54774 798c5e2435e1442946db90a50d47ab90f40c60b7 refs/remotes/origin/a3
1153 - b2ea51c027e11c0f2871cce2a52e648e194df771 0000000000000000000000000000000000000000 refs/remotes/origin/a4
1154 ! d4d535f1d5795c6027f2872b24b7268ece294209 baad96fead6cdc20d47c55a4069c82952f9ac62c refs/remotes/origin/b
1155 = f8e7139764d76132234c13210b6f0abe6b1d9bf6 f8e7139764d76132234c13210b6f0abe6b1d9bf6 refs/remotes/upstream/c
1156 * 0000000000000000000000000000000000000000 fd5b6a095a77575c94fad4164ab580331316c374 refs/tags/v1.0
1157 t 0000000000000000000000000000000000000000 3262fedde0224462bb6ac3015dabc427a4f98316 refs/tags/v2.0
1158 "};
1159 insta::assert_debug_snapshot!(parse_ref_updates(sample).unwrap(), @r#"
1160 GitRefUpdates {
1161 updated: [
1162 (
1163 GitRefNameBuf(
1164 "refs/remotes/origin/a1",
1165 ),
1166 Diff {
1167 before: Sha1(0000000000000000000000000000000000000000),
1168 after: Sha1(e80d998ab04be7caeac3a732d74b1708aa3d8b26),
1169 },
1170 ),
1171 (
1172 GitRefNameBuf(
1173 "refs/remotes/origin/a2",
1174 ),
1175 Diff {
1176 before: Sha1(ebeb70d8c5f972275f0a22f7af6bc9ddb175ebd9),
1177 after: Sha1(9175cb3250fd266fe46dcc13664b255a19234286),
1178 },
1179 ),
1180 (
1181 GitRefNameBuf(
1182 "refs/remotes/origin/a3",
1183 ),
1184 Diff {
1185 before: Sha1(c8303692b8e2f0326cd33873a157b4fa69d54774),
1186 after: Sha1(798c5e2435e1442946db90a50d47ab90f40c60b7),
1187 },
1188 ),
1189 (
1190 GitRefNameBuf(
1191 "refs/remotes/origin/a4",
1192 ),
1193 Diff {
1194 before: Sha1(b2ea51c027e11c0f2871cce2a52e648e194df771),
1195 after: Sha1(0000000000000000000000000000000000000000),
1196 },
1197 ),
1198 (
1199 GitRefNameBuf(
1200 "refs/tags/v1.0",
1201 ),
1202 Diff {
1203 before: Sha1(0000000000000000000000000000000000000000),
1204 after: Sha1(fd5b6a095a77575c94fad4164ab580331316c374),
1205 },
1206 ),
1207 (
1208 GitRefNameBuf(
1209 "refs/tags/v2.0",
1210 ),
1211 Diff {
1212 before: Sha1(0000000000000000000000000000000000000000),
1213 after: Sha1(3262fedde0224462bb6ac3015dabc427a4f98316),
1214 },
1215 ),
1216 ],
1217 rejected: [
1218 (
1219 GitRefNameBuf(
1220 "refs/remotes/origin/b",
1221 ),
1222 Diff {
1223 before: Sha1(d4d535f1d5795c6027f2872b24b7268ece294209),
1224 after: Sha1(baad96fead6cdc20d47c55a4069c82952f9ac62c),
1225 },
1226 ),
1227 ],
1228 }
1229 "#);
1230 }
1231
1232 #[test]
1233 fn test_parse_ref_updates_malformed() {
1234 assert!(parse_ref_updates(b"").is_ok());
1235 assert!(parse_ref_updates(b"\n").is_err());
1236 assert!(parse_ref_updates(b"*\n").is_err());
1237 let oid = "0000000000000000000000000000000000000000";
1238 assert!(parse_ref_updates(format!("**{oid} {oid} name\n").as_bytes()).is_err());
1239 }
1240
1241 #[test]
1242 fn test_parse_ref_pushes() {
1243 assert!(parse_ref_pushes(SAMPLE_NO_SUCH_REPOSITORY_ERROR).is_err());
1244 assert!(parse_ref_pushes(SAMPLE_NO_SUCH_REMOTE_ERROR).is_err());
1245 assert!(parse_ref_pushes(SAMPLE_NO_REMOTE_REF_ERROR).is_err());
1246 assert!(parse_ref_pushes(SAMPLE_NO_REMOTE_TRACKING_BRANCH_ERROR).is_err());
1247 let GitPushStats {
1248 pushed,
1249 rejected,
1250 remote_rejected,
1251 unexported_bookmarks: _,
1252 } = parse_ref_pushes(SAMPLE_PUSH_REFS_PORCELAIN_OUTPUT).unwrap();
1253 assert_eq!(
1254 pushed,
1255 [
1256 "refs/heads/bookmark1",
1257 "refs/heads/bookmark2",
1258 "refs/heads/bookmark3",
1259 "refs/heads/bookmark4",
1260 "refs/heads/bookmark5",
1261 ]
1262 .map(GitRefNameBuf::from)
1263 );
1264 assert_eq!(
1265 rejected,
1266 vec![
1267 (
1268 "refs/heads/bookmark6".into(),
1269 Some("failure lease".to_string())
1270 ),
1271 ("refs/heads/bookmark7".into(), None),
1272 ]
1273 );
1274 assert_eq!(
1275 remote_rejected,
1276 vec![
1277 (
1278 "refs/heads/bookmark8".into(),
1279 Some("hook failure".to_string())
1280 ),
1281 ("refs/heads/bookmark9".into(), None)
1282 ]
1283 );
1284 assert!(parse_ref_pushes(SAMPLE_OK_STDERR).is_err());
1285 }
1286
1287 #[test]
1288 fn test_read_to_end_with_progress() {
1289 let read = |sample: &[u8]| {
1290 let mut callback = GitSubprocessCapture::default();
1291 let output = read_to_end_with_progress(&mut &sample[..], &mut callback).unwrap();
1292 (output, callback)
1293 };
1294 const DUMB_SUFFIX: &str = " ";
1295 let sample = formatdoc! {"
1296 remote: line1{DUMB_SUFFIX}
1297 blah blah
1298 remote: line2.0{DUMB_SUFFIX}\rremote: line2.1{DUMB_SUFFIX}
1299 remote: line3{DUMB_SUFFIX}
1300 Resolving deltas: (12/24)
1301 fatal: some error message
1302 continues
1303 "};
1304
1305 let (output, callback) = read(sample.as_bytes());
1306 assert_eq!(callback.local_sideband, ["blah blah", "\n"]);
1307 assert_eq!(
1308 callback.remote_sideband,
1309 [
1310 "line1", "\n", "line2.0", "\r", "line2.1", "\n", "line3", "\n"
1311 ]
1312 );
1313 assert_eq!(output, b"fatal: some error message\ncontinues\n");
1314 insta::assert_debug_snapshot!(callback.progress, @"
1315 [
1316 GitProgress {
1317 deltas: (
1318 12,
1319 24,
1320 ),
1321 objects: (
1322 0,
1323 0,
1324 ),
1325 counted_objects: (
1326 0,
1327 0,
1328 ),
1329 compressed_objects: (
1330 0,
1331 0,
1332 ),
1333 },
1334 ]
1335 ");
1336
1337 let (output, callback) = read(sample.as_bytes().trim_end());
1339 assert_eq!(
1340 callback.remote_sideband,
1341 [
1342 "line1", "\n", "line2.0", "\r", "line2.1", "\n", "line3", "\n"
1343 ]
1344 );
1345 assert_eq!(output, b"fatal: some error message\ncontinues");
1346 }
1347
1348 #[test]
1349 fn test_read_progress_line() {
1350 assert_eq!(
1351 read_progress_line(b"Receiving objects: (42/100)\r"),
1352 Some((42, 100))
1353 );
1354 assert_eq!(
1355 read_progress_line(b"Resolving deltas: (0/1000)\r"),
1356 Some((0, 1000))
1357 );
1358 assert_eq!(read_progress_line(b"Receiving objects: (420/100)\r"), None);
1359 assert_eq!(
1360 read_progress_line(b"remote: this is something else\n"),
1361 None
1362 );
1363 assert_eq!(read_progress_line(b"fatal: this is a git error\n"), None);
1364 }
1365
1366 #[test]
1367 fn test_parse_unknown_option() {
1368 assert_eq!(
1369 parse_unknown_option(b"unknown option: --abc").unwrap(),
1370 "abc".to_string()
1371 );
1372 assert_eq!(
1373 parse_unknown_option(b"error: unknown option `abc'").unwrap(),
1374 "abc".to_string()
1375 );
1376 assert!(parse_unknown_option(b"error: unknown option: 'abc'").is_none());
1377 }
1378
1379 #[test]
1380 fn test_initial_overall_progress_is_zero() {
1381 assert_eq!(GitProgress::default().overall(), 0.0);
1382 }
1383}