just playing with tangled
at globpattern 930 lines 32 kB view raw
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}