just playing with tangled
1// Copyright 2025 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use 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::ByteSlice as _;
28use itertools::Itertools as _;
29use thiserror::Error;
30
31use crate::git::Progress;
32use crate::git::RefSpec;
33use crate::git::RefToPush;
34use crate::git::RemoteCallbacks;
35use crate::git_backend::GitBackend;
36
37// This is not the minimum required version, that would be 2.29.0, which
38// introduced the `--no-write-fetch-head` option. However, that by itself
39// is quite old and unsupported, so we don't want to encourage users to
40// update to that.
41//
42// 2.40 still receives security patches (latest one was in Jan/2025)
43const MINIMUM_GIT_VERSION: &str = "2.40.4";
44
45/// Error originating by a Git subprocess
46#[derive(Error, Debug)]
47pub enum GitSubprocessError {
48 #[error("Could not find repository at '{0}'")]
49 NoSuchRepository(String),
50 #[error("Could not execute the git process, found in the OS path '{path}'")]
51 SpawnInPath {
52 path: PathBuf,
53 #[source]
54 error: std::io::Error,
55 },
56 #[error("Could not execute git process at specified path '{path}'")]
57 Spawn {
58 path: PathBuf,
59 #[source]
60 error: std::io::Error,
61 },
62 #[error("Failed to wait for the git process")]
63 Wait(std::io::Error),
64 #[error(
65 "Git does not recognize required option: {0} (note: supported version is \
66 {MINIMUM_GIT_VERSION})"
67 )]
68 UnsupportedGitOption(String),
69 #[error("Git process failed: {0}")]
70 External(String),
71}
72
73/// Stats from a git push
74#[derive(Debug, Default)]
75pub(crate) struct GitPushStats {
76 /// reference accepted by the remote
77 pub pushed: Vec<String>,
78 /// rejected reference, due to lease failure
79 pub rejected: Vec<String>,
80 /// reference rejected by the remote
81 pub remote_rejected: Vec<String>,
82}
83
84/// Context for creating Git subprocesses
85pub(crate) struct GitSubprocessContext<'a> {
86 git_dir: PathBuf,
87 git_executable_path: &'a Path,
88}
89
90impl<'a> GitSubprocessContext<'a> {
91 pub(crate) fn new(git_dir: impl Into<PathBuf>, git_executable_path: &'a Path) -> Self {
92 GitSubprocessContext {
93 git_dir: git_dir.into(),
94 git_executable_path,
95 }
96 }
97
98 pub(crate) fn from_git_backend(
99 git_backend: &GitBackend,
100 git_executable_path: &'a Path,
101 ) -> Self {
102 Self::new(git_backend.git_repo_path(), git_executable_path)
103 }
104
105 /// Create the Git command
106 fn create_command(&self) -> Command {
107 let mut git_cmd = Command::new(self.git_executable_path);
108 // TODO: here we are passing the full path to the git_dir, which can lead to UNC
109 // bugs in Windows. The ideal way to do this is to pass the workspace
110 // root to Command::current_dir and then pass a relative path to the git
111 // dir
112 git_cmd
113 .arg("--git-dir")
114 .arg(&self.git_dir)
115 // Disable translation and other locale-dependent behavior so we can
116 // parse the output. LC_ALL precedes LC_* and LANG.
117 .env("LC_ALL", "C")
118 .stdin(Stdio::null())
119 .stderr(Stdio::piped());
120
121 git_cmd
122 }
123
124 /// Spawn the git command
125 fn spawn_cmd(&self, mut git_cmd: Command) -> Result<Child, GitSubprocessError> {
126 tracing::debug!(cmd = ?git_cmd, "spawning a git subprocess");
127 git_cmd.spawn().map_err(|error| {
128 if self.git_executable_path.is_absolute() {
129 GitSubprocessError::Spawn {
130 path: self.git_executable_path.to_path_buf(),
131 error,
132 }
133 } else {
134 GitSubprocessError::SpawnInPath {
135 path: self.git_executable_path.to_path_buf(),
136 error,
137 }
138 }
139 })
140 }
141
142 /// Perform a git fetch
143 ///
144 /// This returns a fully qualified ref that wasn't fetched successfully
145 /// Note that git only returns one failed ref at a time
146 pub(crate) fn spawn_fetch(
147 &self,
148 remote_name: &str,
149 refspecs: &[RefSpec],
150 callbacks: &mut RemoteCallbacks<'_>,
151 depth: Option<NonZeroU32>,
152 ) -> Result<Option<String>, GitSubprocessError> {
153 if refspecs.is_empty() {
154 return Ok(None);
155 }
156 let mut command = self.create_command();
157 command.stdout(Stdio::piped());
158 // attempt to prune stale refs with --prune
159 // --no-write-fetch-head ensures our request is invisible to other parties
160 command.args(["fetch", "--prune", "--no-write-fetch-head"]);
161 if callbacks.progress.is_some() {
162 command.arg("--progress");
163 }
164 if let Some(d) = depth {
165 command.arg(format!("--depth={d}"));
166 }
167 command.arg("--").arg(remote_name);
168 command.args(refspecs.iter().map(|x| x.to_git_format()));
169
170 let output = wait_with_progress(self.spawn_cmd(command)?, callbacks)?;
171
172 parse_git_fetch_output(output)
173 }
174
175 /// Prune particular branches
176 pub(crate) fn spawn_branch_prune(
177 &self,
178 branches_to_prune: &[String],
179 ) -> Result<(), GitSubprocessError> {
180 if branches_to_prune.is_empty() {
181 return Ok(());
182 }
183 tracing::debug!(?branches_to_prune, "pruning branches");
184 let mut command = self.create_command();
185 command.stdout(Stdio::null());
186 command.args(["branch", "--remotes", "--delete", "--"]);
187 command.args(branches_to_prune);
188
189 let output = wait_with_output(self.spawn_cmd(command)?)?;
190
191 // we name the type to make sure that it is not meant to be used
192 let () = parse_git_branch_prune_output(output)?;
193
194 Ok(())
195 }
196
197 /// How we retrieve the remote's default branch:
198 ///
199 /// `git remote show <remote_name>`
200 ///
201 /// dumps a lot of information about the remote, with a line such as:
202 /// ` HEAD branch: <default_branch>`
203 pub(crate) fn spawn_remote_show(
204 &self,
205 remote_name: &str,
206 ) -> Result<Option<String>, GitSubprocessError> {
207 let mut command = self.create_command();
208 command.stdout(Stdio::piped());
209 command.args(["remote", "show", "--", remote_name]);
210 let output = wait_with_output(self.spawn_cmd(command)?)?;
211
212 let output = parse_git_remote_show_output(output)?;
213
214 // find the HEAD branch line in the output
215 parse_git_remote_show_default_branch(&output.stdout)
216 }
217
218 /// Push references to git
219 ///
220 /// All pushes are forced, using --force-with-lease to perform a test&set
221 /// operation on the remote repository
222 ///
223 /// Return tuple with
224 /// 1. refs that failed to push
225 /// 2. refs that succeeded to push
226 pub(crate) fn spawn_push(
227 &self,
228 remote_name: &str,
229 references: &[RefToPush],
230 callbacks: &mut RemoteCallbacks<'_>,
231 ) -> Result<GitPushStats, GitSubprocessError> {
232 let mut command = self.create_command();
233 command.stdout(Stdio::piped());
234 // Currently jj does not support commit hooks, so we prevent git from running
235 // them
236 //
237 // https://github.com/jj-vcs/jj/issues/3577 and https://github.com/jj-vcs/jj/issues/405
238 // offer more context
239 command.args(["push", "--porcelain", "--no-verify"]);
240 if callbacks.progress.is_some() {
241 command.arg("--progress");
242 }
243 command.args(
244 references
245 .iter()
246 .map(|reference| format!("--force-with-lease={}", reference.to_git_lease())),
247 );
248 command.args(["--", remote_name]);
249 // with --force-with-lease we cannot have the forced refspec,
250 // as it ignores the lease
251 command.args(
252 references
253 .iter()
254 .map(|r| r.refspec.to_git_format_not_forced()),
255 );
256
257 let output = wait_with_progress(self.spawn_cmd(command)?, callbacks)?;
258
259 parse_git_push_output(output)
260 }
261}
262
263/// Generate a GitSubprocessError::ExternalGitError if the stderr output was not
264/// recognizable
265fn external_git_error(stderr: &[u8]) -> GitSubprocessError {
266 GitSubprocessError::External(format!(
267 "External git program failed:\n{}",
268 stderr.to_str_lossy()
269 ))
270}
271
272/// Parse no such remote errors output from git
273///
274/// Returns the remote that wasn't found
275///
276/// To say this, git prints out a lot of things, but the first line is of the
277/// form:
278/// `fatal: '<remote>' does not appear to be a git repository`
279/// or
280/// `fatal: '<remote>': Could not resolve host: invalid-remote
281fn parse_no_such_remote(stderr: &[u8]) -> Option<String> {
282 let first_line = stderr.lines().next()?;
283 let suffix = first_line
284 .strip_prefix(b"fatal: '")
285 .or_else(|| first_line.strip_prefix(b"fatal: unable to access '"))?;
286
287 suffix
288 .strip_suffix(b"' does not appear to be a git repository")
289 .or_else(|| suffix.strip_suffix(b"': Could not resolve host: invalid-remote"))
290 .map(|remote| remote.to_str_lossy().into_owned())
291}
292
293/// Parse error from refspec not present on the remote
294///
295/// This returns
296/// Some(local_ref) that wasn't found by the remote
297/// None if this wasn't the error
298///
299/// On git fetch even though --prune is specified, if a particular
300/// refspec is asked for but not present in the remote, git will error out.
301///
302/// Git only reports one of these errors at a time, so we only look at the first
303/// line
304///
305/// The first line is of the form:
306/// `fatal: couldn't find remote ref refs/heads/<ref>`
307fn parse_no_remote_ref(stderr: &[u8]) -> Option<String> {
308 let first_line = stderr.lines().next()?;
309 first_line
310 .strip_prefix(b"fatal: couldn't find remote ref ")
311 .map(|refname| refname.to_str_lossy().into_owned())
312}
313
314/// Parse remote tracking branch not found
315///
316/// This returns true if the error was detected
317///
318/// if a branch is asked for but is not present, jj will detect it post-hoc
319/// so, we want to ignore these particular errors with git
320///
321/// The first line is of the form:
322/// `error: remote-tracking branch '<branch>' not found`
323fn parse_no_remote_tracking_branch(stderr: &[u8]) -> Option<String> {
324 let first_line = stderr.lines().next()?;
325
326 let suffix = first_line.strip_prefix(b"error: remote-tracking branch '")?;
327
328 suffix
329 .strip_suffix(b"' not found.")
330 .or_else(|| suffix.strip_suffix(b"' not found"))
331 .map(|branch| branch.to_str_lossy().into_owned())
332}
333
334/// Parse unknown options
335///
336/// Return the unknown option
337///
338/// If a user is running a very old git version, our commands may fail
339/// We want to give a good error in this case
340fn parse_unknown_option(stderr: &[u8]) -> Option<String> {
341 let first_line = stderr.lines().next()?;
342 first_line
343 .strip_prefix(b"unknown option: --")
344 .or(first_line
345 .strip_prefix(b"error: unknown option `")
346 .and_then(|s| s.strip_suffix(b"'")))
347 .map(|s| s.to_str_lossy().into())
348}
349
350// return the fully qualified ref that failed to fetch
351//
352// note that git fetch only returns one error at a time
353fn parse_git_fetch_output(output: Output) -> Result<Option<String>, GitSubprocessError> {
354 if output.status.success() {
355 return Ok(None);
356 }
357
358 // There are some git errors we want to parse out
359 if let Some(option) = parse_unknown_option(&output.stderr) {
360 return Err(GitSubprocessError::UnsupportedGitOption(option));
361 }
362
363 if let Some(remote) = parse_no_such_remote(&output.stderr) {
364 return Err(GitSubprocessError::NoSuchRepository(remote));
365 }
366
367 if let Some(refspec) = parse_no_remote_ref(&output.stderr) {
368 return Ok(Some(refspec));
369 }
370
371 if parse_no_remote_tracking_branch(&output.stderr).is_some() {
372 return Ok(None);
373 }
374
375 Err(external_git_error(&output.stderr))
376}
377
378fn parse_git_branch_prune_output(output: Output) -> Result<(), GitSubprocessError> {
379 if output.status.success() {
380 return Ok(());
381 }
382
383 // There are some git errors we want to parse out
384 if let Some(option) = parse_unknown_option(&output.stderr) {
385 return Err(GitSubprocessError::UnsupportedGitOption(option));
386 }
387
388 if parse_no_remote_tracking_branch(&output.stderr).is_some() {
389 return Ok(());
390 }
391
392 Err(external_git_error(&output.stderr))
393}
394
395fn parse_git_remote_show_output(output: Output) -> Result<Output, GitSubprocessError> {
396 if output.status.success() {
397 return Ok(output);
398 }
399
400 // There are some git errors we want to parse out
401 if let Some(option) = parse_unknown_option(&output.stderr) {
402 return Err(GitSubprocessError::UnsupportedGitOption(option));
403 }
404
405 if let Some(remote) = parse_no_such_remote(&output.stderr) {
406 return Err(GitSubprocessError::NoSuchRepository(remote));
407 }
408
409 Err(external_git_error(&output.stderr))
410}
411
412fn parse_git_remote_show_default_branch(
413 stdout: &[u8],
414) -> Result<Option<String>, GitSubprocessError> {
415 stdout
416 .lines()
417 .map(|x| x.trim())
418 .find(|x| x.starts_with_str("HEAD branch:"))
419 .inspect(|x| tracing::debug!(line = ?x.to_str_lossy(), "default branch"))
420 .and_then(|x| x.split_str(" ").last().map(|y| y.trim()))
421 .filter(|branch_name| branch_name != b"(unknown)")
422 .map(|branch_name| branch_name.to_str())
423 .transpose()
424 .map_err(|e| GitSubprocessError::External(format!("git remote output is not utf-8: {e:?}")))
425 .map(|b| b.map(|x| x.to_string()))
426}
427
428// git-push porcelain has the following format (per line)
429// `<flag>\t<from>:<to>\t<summary> (<reason>)`
430//
431// <flag> is one of:
432// ' ' for a successfully pushed fast-forward;
433// + for a successful forced update
434// - for a successfully deleted ref
435// * for a successfully pushed new ref
436// ! for a ref that was rejected or failed to push; and
437// = for a ref that was up to date and did not need pushing.
438//
439// <from>:<to> is the refspec
440//
441// <summary> is extra info (commit ranges or reason for rejected)
442//
443// <reason> is a human-readable explanation
444fn parse_ref_pushes(stdout: &[u8]) -> Result<GitPushStats, GitSubprocessError> {
445 if !stdout.starts_with(b"To ") {
446 return Err(GitSubprocessError::External(format!(
447 "Git push output unfamiliar:\n{}",
448 stdout.to_str_lossy()
449 )));
450 }
451
452 let mut push_result = GitPushStats::default();
453 for (idx, line) in stdout
454 .lines()
455 .skip(1)
456 .take_while(|line| line != b"Done")
457 .enumerate()
458 {
459 tracing::debug!("response #{idx}: {}", line.to_str_lossy());
460 let (flag, reference, summary) = line.split_str("\t").collect_tuple().ok_or_else(|| {
461 GitSubprocessError::External(format!(
462 "Line #{idx} of git-push has unknown format: {}",
463 line.to_str_lossy()
464 ))
465 })?;
466 let full_refspec = reference
467 .to_str()
468 .map_err(|e| {
469 format!(
470 "Line #{} of git-push has non-utf8 refspec {}: {}",
471 idx,
472 reference.to_str_lossy(),
473 e
474 )
475 })
476 .map_err(GitSubprocessError::External)?;
477
478 let reference = full_refspec
479 .split_once(':')
480 .map(|(_refname, reference)| reference.to_string())
481 .ok_or_else(|| {
482 GitSubprocessError::External(format!(
483 "Line #{idx} of git-push has full refspec without named ref: {full_refspec}"
484 ))
485 })?;
486
487 match flag {
488 // ' ' for a successfully pushed fast-forward;
489 // + for a successful forced update
490 // - for a successfully deleted ref
491 // * for a successfully pushed new ref
492 // = for a ref that was up to date and did not need pushing.
493 b"+" | b"-" | b"*" | b"=" | b" " => {
494 push_result.pushed.push(reference);
495 }
496 // ! for a ref that was rejected or failed to push; and
497 b"!" => {
498 if summary.starts_with_str("[remote rejected]") {
499 push_result.remote_rejected.push(reference);
500 } else {
501 push_result.rejected.push(reference);
502 }
503 }
504 unknown => {
505 return Err(GitSubprocessError::External(format!(
506 "Line #{} of git-push starts with an unknown flag '{}': '{}'",
507 idx,
508 unknown.to_str_lossy(),
509 line.to_str_lossy()
510 )));
511 }
512 }
513 }
514
515 Ok(push_result)
516}
517
518// on Ok, return a tuple with
519// 1. list of failed references from test and set
520// 2. list of successful references pushed
521fn parse_git_push_output(output: Output) -> Result<GitPushStats, GitSubprocessError> {
522 if output.status.success() {
523 let ref_pushes = parse_ref_pushes(&output.stdout)?;
524 return Ok(ref_pushes);
525 }
526
527 if let Some(option) = parse_unknown_option(&output.stderr) {
528 return Err(GitSubprocessError::UnsupportedGitOption(option));
529 }
530
531 if let Some(remote) = parse_no_such_remote(&output.stderr) {
532 return Err(GitSubprocessError::NoSuchRepository(remote));
533 }
534
535 if output
536 .stderr
537 .lines()
538 .any(|line| line.starts_with(b"error: failed to push some refs to "))
539 {
540 parse_ref_pushes(&output.stdout)
541 } else {
542 Err(external_git_error(&output.stderr))
543 }
544}
545
546fn wait_with_output(child: Child) -> Result<Output, GitSubprocessError> {
547 child.wait_with_output().map_err(GitSubprocessError::Wait)
548}
549
550/// Like `wait_with_output()`, but also emits sideband data through callback.
551///
552/// Git remotes can send custom messages on fetch and push, which the `git`
553/// command prepends with `remote: `.
554///
555/// For instance, these messages can provide URLs to create Pull Requests
556/// e.g.:
557/// ```ignore
558/// $ jj git push -c @
559/// [...]
560/// remote:
561/// remote: Create a pull request for 'branch' on GitHub by visiting:
562/// remote: https://github.com/user/repo/pull/new/branch
563/// remote:
564/// ```
565///
566/// The returned `stderr` content does not include sideband messages.
567fn wait_with_progress(
568 mut child: Child,
569 callbacks: &mut RemoteCallbacks<'_>,
570) -> Result<Output, GitSubprocessError> {
571 let (stdout, stderr) = thread::scope(|s| -> io::Result<_> {
572 drop(child.stdin.take());
573 let mut child_stdout = child.stdout.take().expect("stdout should be piped");
574 let mut child_stderr = child.stderr.take().expect("stderr should be piped");
575 let thread = s.spawn(move || -> io::Result<_> {
576 let mut buf = Vec::new();
577 child_stdout.read_to_end(&mut buf)?;
578 Ok(buf)
579 });
580 let stderr = read_to_end_with_progress(&mut child_stderr, callbacks)?;
581 let stdout = thread.join().expect("reader thread wouldn't panic")?;
582 Ok((stdout, stderr))
583 })
584 .map_err(GitSubprocessError::Wait)?;
585 let status = child.wait().map_err(GitSubprocessError::Wait)?;
586 Ok(Output {
587 status,
588 stdout,
589 stderr,
590 })
591}
592
593#[derive(Default)]
594struct GitProgress {
595 // (frac, total)
596 deltas: (u64, u64),
597 objects: (u64, u64),
598 counted_objects: (u64, u64),
599 compressed_objects: (u64, u64),
600}
601
602impl GitProgress {
603 fn to_progress(&self) -> Progress {
604 Progress {
605 bytes_downloaded: None,
606 overall: self.fraction() as f32 / self.total() as f32,
607 }
608 }
609
610 fn fraction(&self) -> u64 {
611 self.objects.0 + self.deltas.0 + self.counted_objects.0 + self.compressed_objects.0
612 }
613
614 fn total(&self) -> u64 {
615 self.objects.1 + self.deltas.1 + self.counted_objects.1 + self.compressed_objects.1
616 }
617}
618
619fn read_to_end_with_progress<R: Read>(
620 src: R,
621 callbacks: &mut RemoteCallbacks<'_>,
622) -> io::Result<Vec<u8>> {
623 let mut reader = BufReader::new(src);
624 let mut data = Vec::new();
625 let mut git_progress = GitProgress::default();
626
627 loop {
628 // progress sent through sideband channel may be terminated by \r
629 let start = data.len();
630 read_until_cr_or_lf(&mut reader, &mut data)?;
631 let line = &data[start..];
632 if line.is_empty() {
633 break;
634 }
635
636 if update_progress(line, &mut git_progress.objects, b"Receiving objects:")
637 || update_progress(line, &mut git_progress.deltas, b"Resolving deltas:")
638 || update_progress(
639 line,
640 &mut git_progress.counted_objects,
641 b"remote: Counting objects:",
642 )
643 || update_progress(
644 line,
645 &mut git_progress.compressed_objects,
646 b"remote: Compressing objects:",
647 )
648 {
649 if let Some(cb) = callbacks.progress.as_mut() {
650 cb(&git_progress.to_progress());
651 }
652 data.truncate(start);
653 } else if let Some(message) = line.strip_prefix(b"remote: ") {
654 if let Some(cb) = callbacks.sideband_progress.as_mut() {
655 let (body, term) = trim_sideband_line(message);
656 cb(body);
657 if let Some(term) = term {
658 cb(&[term]);
659 }
660 }
661 data.truncate(start);
662 }
663 }
664 Ok(data)
665}
666
667fn update_progress(line: &[u8], progress: &mut (u64, u64), prefix: &[u8]) -> bool {
668 if let Some(line) = line.strip_prefix(prefix) {
669 if let Some((frac, total)) = read_progress_line(line) {
670 *progress = (frac, total);
671 }
672
673 true
674 } else {
675 false
676 }
677}
678
679fn read_until_cr_or_lf<R: io::BufRead + ?Sized>(
680 reader: &mut R,
681 dest_buf: &mut Vec<u8>,
682) -> io::Result<()> {
683 loop {
684 let data = match reader.fill_buf() {
685 Ok(data) => data,
686 Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
687 Err(err) => return Err(err),
688 };
689 let (n, found) = match data.iter().position(|&b| matches!(b, b'\r' | b'\n')) {
690 Some(i) => (i + 1, true),
691 None => (data.len(), false),
692 };
693
694 dest_buf.extend_from_slice(&data[..n]);
695 reader.consume(n);
696
697 if found || n == 0 {
698 return Ok(());
699 }
700 }
701}
702
703/// Read progress lines of the form: `<text> (<frac>/<total>)`
704/// Ensures that frac < total
705fn read_progress_line(line: &[u8]) -> Option<(u64, u64)> {
706 // isolate the part between parenthesis
707 let (_prefix, suffix) = line.split_once_str("(")?;
708 let (fraction, _suffix) = suffix.split_once_str(")")?;
709
710 // split over the '/'
711 let (frac_str, total_str) = fraction.split_once_str("/")?;
712
713 // parse to integers
714 let frac = frac_str.to_str().ok()?.parse().ok()?;
715 let total = total_str.to_str().ok()?.parse().ok()?;
716 (frac <= total).then_some((frac, total))
717}
718
719/// Removes trailing spaces from sideband line, which may be padded by the `git`
720/// CLI in order to clear the previous progress line.
721fn trim_sideband_line(line: &[u8]) -> (&[u8], Option<u8>) {
722 let (body, term) = match line {
723 [body @ .., term @ (b'\r' | b'\n')] => (body, Some(*term)),
724 _ => (line, None),
725 };
726 let n = body.iter().rev().take_while(|&&b| b == b' ').count();
727 (&body[..body.len() - n], term)
728}
729
730#[cfg(test)]
731mod test {
732 use indoc::formatdoc;
733
734 use super::*;
735
736 const SAMPLE_NO_SUCH_REPOSITORY_ERROR: &[u8] =
737 br###"fatal: unable to access 'origin': Could not resolve host: invalid-remote
738fatal: Could not read from remote repository.
739
740Please make sure you have the correct access rights
741and the repository exists. "###;
742 const SAMPLE_NO_SUCH_REMOTE_ERROR: &[u8] =
743 br###"fatal: 'origin' does not appear to be a git repository
744fatal: Could not read from remote repository.
745
746Please make sure you have the correct access rights
747and the repository exists. "###;
748 const SAMPLE_NO_REMOTE_REF_ERROR: &[u8] = b"fatal: couldn't find remote ref refs/heads/noexist";
749 const SAMPLE_NO_REMOTE_TRACKING_BRANCH_ERROR: &[u8] =
750 b"error: remote-tracking branch 'bookmark' not found";
751 const SAMPLE_PUSH_REFS_PORCELAIN_OUTPUT: &[u8] = b"To origin
752*\tdeadbeef:refs/heads/bookmark1\t[new branch]
753+\tdeadbeef:refs/heads/bookmark2\tabcd..dead
754-\tdeadbeef:refs/heads/bookmark3\t[deleted branch]
755 \tdeadbeef:refs/heads/bookmark4\tabcd..dead
756=\tdeadbeef:refs/heads/bookmark5\tabcd..abcd
757!\tdeadbeef:refs/heads/bookmark6\t[rejected] (failure lease)
758!\tdeadbeef:refs/heads/bookmark7\t[remote rejected] (hook failure)
759Done";
760 const SAMPLE_OK_STDERR: &[u8] = b"";
761
762 #[test]
763 fn test_parse_no_such_remote() {
764 assert_eq!(
765 parse_no_such_remote(SAMPLE_NO_SUCH_REPOSITORY_ERROR),
766 Some("origin".to_string())
767 );
768 assert_eq!(
769 parse_no_such_remote(SAMPLE_NO_SUCH_REMOTE_ERROR),
770 Some("origin".to_string())
771 );
772 assert_eq!(parse_no_such_remote(SAMPLE_NO_REMOTE_REF_ERROR), None);
773 assert_eq!(
774 parse_no_such_remote(SAMPLE_NO_REMOTE_TRACKING_BRANCH_ERROR),
775 None
776 );
777 assert_eq!(
778 parse_no_such_remote(SAMPLE_PUSH_REFS_PORCELAIN_OUTPUT),
779 None
780 );
781 assert_eq!(parse_no_such_remote(SAMPLE_OK_STDERR), None);
782 }
783
784 #[test]
785 fn test_parse_no_remote_ref() {
786 assert_eq!(parse_no_remote_ref(SAMPLE_NO_SUCH_REPOSITORY_ERROR), None);
787 assert_eq!(parse_no_remote_ref(SAMPLE_NO_SUCH_REMOTE_ERROR), None);
788 assert_eq!(
789 parse_no_remote_ref(SAMPLE_NO_REMOTE_REF_ERROR),
790 Some("refs/heads/noexist".to_string())
791 );
792 assert_eq!(
793 parse_no_remote_ref(SAMPLE_NO_REMOTE_TRACKING_BRANCH_ERROR),
794 None
795 );
796 assert_eq!(parse_no_remote_ref(SAMPLE_PUSH_REFS_PORCELAIN_OUTPUT), None);
797 assert_eq!(parse_no_remote_ref(SAMPLE_OK_STDERR), None);
798 }
799
800 #[test]
801 fn test_parse_no_remote_tracking_branch() {
802 assert_eq!(
803 parse_no_remote_tracking_branch(SAMPLE_NO_SUCH_REPOSITORY_ERROR),
804 None
805 );
806 assert_eq!(
807 parse_no_remote_tracking_branch(SAMPLE_NO_SUCH_REMOTE_ERROR),
808 None
809 );
810 assert_eq!(
811 parse_no_remote_tracking_branch(SAMPLE_NO_REMOTE_REF_ERROR),
812 None
813 );
814 assert_eq!(
815 parse_no_remote_tracking_branch(SAMPLE_NO_REMOTE_TRACKING_BRANCH_ERROR),
816 Some("bookmark".to_string())
817 );
818 assert_eq!(
819 parse_no_remote_tracking_branch(SAMPLE_PUSH_REFS_PORCELAIN_OUTPUT),
820 None
821 );
822 assert_eq!(parse_no_remote_tracking_branch(SAMPLE_OK_STDERR), None);
823 }
824
825 #[test]
826 fn test_parse_ref_pushes() {
827 assert!(parse_ref_pushes(SAMPLE_NO_SUCH_REPOSITORY_ERROR).is_err());
828 assert!(parse_ref_pushes(SAMPLE_NO_SUCH_REMOTE_ERROR).is_err());
829 assert!(parse_ref_pushes(SAMPLE_NO_REMOTE_REF_ERROR).is_err());
830 assert!(parse_ref_pushes(SAMPLE_NO_REMOTE_TRACKING_BRANCH_ERROR).is_err());
831 let GitPushStats {
832 pushed,
833 rejected,
834 remote_rejected,
835 } = parse_ref_pushes(SAMPLE_PUSH_REFS_PORCELAIN_OUTPUT).unwrap();
836 assert_eq!(
837 pushed,
838 vec![
839 "refs/heads/bookmark1".to_string(),
840 "refs/heads/bookmark2".to_string(),
841 "refs/heads/bookmark3".to_string(),
842 "refs/heads/bookmark4".to_string(),
843 "refs/heads/bookmark5".to_string(),
844 ]
845 );
846 assert_eq!(rejected, vec!["refs/heads/bookmark6".to_string()]);
847 assert_eq!(remote_rejected, vec!["refs/heads/bookmark7".to_string()]);
848 assert!(parse_ref_pushes(SAMPLE_OK_STDERR).is_err());
849 }
850
851 #[test]
852 fn test_read_to_end_with_progress() {
853 let read = |sample: &[u8]| {
854 let mut progress = Vec::new();
855 let mut sideband = Vec::new();
856 let mut callbacks = RemoteCallbacks::default();
857 let mut progress_cb = |p: &Progress| progress.push(p.clone());
858 callbacks.progress = Some(&mut progress_cb);
859 let mut sideband_cb = |s: &[u8]| sideband.push(s.to_owned());
860 callbacks.sideband_progress = Some(&mut sideband_cb);
861 let output = read_to_end_with_progress(&mut &sample[..], &mut callbacks).unwrap();
862 (output, sideband, progress)
863 };
864 const DUMB_SUFFIX: &str = " ";
865 let sample = formatdoc! {"
866 remote: line1{DUMB_SUFFIX}
867 blah blah
868 remote: line2.0{DUMB_SUFFIX}\rremote: line2.1{DUMB_SUFFIX}
869 remote: line3{DUMB_SUFFIX}
870 Resolving deltas: (12/24)
871 some error message
872 "};
873
874 let (output, sideband, progress) = read(sample.as_bytes());
875 assert_eq!(
876 sideband,
877 ["line1", "\n", "line2.0", "\r", "line2.1", "\n", "line3", "\n"]
878 .map(|s| s.as_bytes().to_owned())
879 );
880 assert_eq!(output, b"blah blah\nsome error message\n");
881 insta::assert_debug_snapshot!(progress, @r"
882 [
883 Progress {
884 bytes_downloaded: None,
885 overall: 0.5,
886 },
887 ]
888 ");
889
890 // without last newline
891 let (output, sideband, _progress) = read(sample.as_bytes().trim_end());
892 assert_eq!(
893 sideband,
894 ["line1", "\n", "line2.0", "\r", "line2.1", "\n", "line3", "\n"]
895 .map(|s| s.as_bytes().to_owned())
896 );
897 assert_eq!(output, b"blah blah\nsome error message");
898 }
899
900 #[test]
901 fn test_read_progress_line() {
902 assert_eq!(
903 read_progress_line(b"Receiving objects: (42/100)\r"),
904 Some((42, 100))
905 );
906 assert_eq!(
907 read_progress_line(b"Resolving deltas: (0/1000)\r"),
908 Some((0, 1000))
909 );
910 assert_eq!(read_progress_line(b"Receiving objects: (420/100)\r"), None);
911 assert_eq!(
912 read_progress_line(b"remote: this is something else\n"),
913 None
914 );
915 assert_eq!(read_progress_line(b"fatal: this is a git error\n"), None);
916 }
917
918 #[test]
919 fn test_parse_unknown_option() {
920 assert_eq!(
921 parse_unknown_option(b"unknown option: --abc").unwrap(),
922 "abc".to_string()
923 );
924 assert_eq!(
925 parse_unknown_option(b"error: unknown option `abc'").unwrap(),
926 "abc".to_string()
927 );
928 assert!(parse_unknown_option(b"error: unknown option: 'abc'").is_none());
929 }
930}