just playing with tangled
at gvimdiff 25 kB view raw
1// Copyright 2024 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 15//! Git utilities shared by various commands. 16 17use std::error; 18use std::io; 19use std::io::Read as _; 20use std::io::Write as _; 21use std::iter; 22use std::mem; 23use std::path::Path; 24use std::path::PathBuf; 25use std::process::Stdio; 26use std::time::Duration; 27use std::time::Instant; 28 29use crossterm::terminal::Clear; 30use crossterm::terminal::ClearType; 31use indoc::writedoc; 32use itertools::Itertools as _; 33use jj_lib::fmt_util::binary_prefix; 34use jj_lib::git; 35use jj_lib::git::FailedRefExportReason; 36use jj_lib::git::GitExportStats; 37use jj_lib::git::GitImportStats; 38use jj_lib::git::GitRefKind; 39use jj_lib::op_store::RefTarget; 40use jj_lib::op_store::RemoteRef; 41use jj_lib::ref_name::RemoteRefSymbol; 42use jj_lib::repo::ReadonlyRepo; 43use jj_lib::repo::Repo; 44use jj_lib::workspace::Workspace; 45use unicode_width::UnicodeWidthStr as _; 46 47use crate::cleanup_guard::CleanupGuard; 48use crate::command_error::cli_error; 49use crate::command_error::user_error; 50use crate::command_error::CommandError; 51use crate::formatter::Formatter; 52use crate::ui::ProgressOutput; 53use crate::ui::Ui; 54 55pub fn is_colocated_git_workspace(workspace: &Workspace, repo: &ReadonlyRepo) -> bool { 56 let Ok(git_backend) = git::get_git_backend(repo.store()) else { 57 return false; 58 }; 59 let Some(git_workdir) = git_backend.git_workdir() else { 60 return false; // Bare repository 61 }; 62 if git_workdir == workspace.workspace_root() { 63 return true; 64 } 65 // Colocated workspace should have ".git" directory, file, or symlink. Compare 66 // its parent as the git_workdir might be resolved from the real ".git" path. 67 let Ok(dot_git_path) = dunce::canonicalize(workspace.workspace_root().join(".git")) else { 68 return false; 69 }; 70 dunce::canonicalize(git_workdir).ok().as_deref() == dot_git_path.parent() 71} 72 73/// Parses user-specified remote URL or path to absolute form. 74pub fn absolute_git_url(cwd: &Path, source: &str) -> Result<String, CommandError> { 75 // Git appears to turn URL-like source to absolute path if local git directory 76 // exits, and fails because '$PWD/https' is unsupported protocol. Since it would 77 // be tedious to copy the exact git (or libgit2) behavior, we simply let gix 78 // parse the input as URL, rcp-like, or local path. 79 let mut url = gix::url::parse(source.as_ref()).map_err(cli_error)?; 80 url.canonicalize(cwd).map_err(user_error)?; 81 // As of gix 0.68.0, the canonicalized path uses platform-native directory 82 // separator, which isn't compatible with libgit2 on Windows. 83 if url.scheme == gix::url::Scheme::File { 84 url.path = gix::path::to_unix_separators_on_windows(mem::take(&mut url.path)).into_owned(); 85 } 86 // It's less likely that cwd isn't utf-8, so just fall back to original source. 87 Ok(String::from_utf8(url.to_bstring().into()).unwrap_or_else(|_| source.to_owned())) 88} 89 90fn terminal_get_username(ui: &Ui, url: &str) -> Option<String> { 91 ui.prompt(&format!("Username for {url}")).ok() 92} 93 94fn terminal_get_pw(ui: &Ui, url: &str) -> Option<String> { 95 ui.prompt_password(&format!("Passphrase for {url}")).ok() 96} 97 98fn pinentry_get_pw(url: &str) -> Option<String> { 99 // https://www.gnupg.org/documentation/manuals/assuan/Server-responses.html#Server-responses 100 fn decode_assuan_data(encoded: &str) -> Option<String> { 101 let encoded = encoded.as_bytes(); 102 let mut decoded = Vec::with_capacity(encoded.len()); 103 let mut i = 0; 104 while i < encoded.len() { 105 if encoded[i] != b'%' { 106 decoded.push(encoded[i]); 107 i += 1; 108 continue; 109 } 110 i += 1; 111 let byte = 112 u8::from_str_radix(std::str::from_utf8(encoded.get(i..i + 2)?).ok()?, 16).ok()?; 113 decoded.push(byte); 114 i += 2; 115 } 116 String::from_utf8(decoded).ok() 117 } 118 119 let mut pinentry = std::process::Command::new("pinentry") 120 .stdin(Stdio::piped()) 121 .stdout(Stdio::piped()) 122 .spawn() 123 .ok()?; 124 let mut interact = || -> std::io::Result<_> { 125 #[rustfmt::skip] 126 let req = format!( 127 "SETTITLE jj passphrase\n\ 128 SETDESC Enter passphrase for {url}\n\ 129 SETPROMPT Passphrase:\n\ 130 GETPIN\n" 131 ); 132 pinentry.stdin.take().unwrap().write_all(req.as_bytes())?; 133 let mut out = String::new(); 134 pinentry.stdout.take().unwrap().read_to_string(&mut out)?; 135 Ok(out) 136 }; 137 let maybe_out = interact(); 138 _ = pinentry.wait(); 139 for line in maybe_out.ok()?.split('\n') { 140 if !line.starts_with("D ") { 141 continue; 142 } 143 let (_, encoded) = line.split_at(2); 144 return decode_assuan_data(encoded); 145 } 146 None 147} 148 149#[tracing::instrument] 150fn get_ssh_keys(_username: &str) -> Vec<PathBuf> { 151 let mut paths = vec![]; 152 if let Some(home_dir) = dirs::home_dir() { 153 let ssh_dir = Path::new(&home_dir).join(".ssh"); 154 for filename in ["id_ed25519_sk", "id_ed25519", "id_rsa"] { 155 let key_path = ssh_dir.join(filename); 156 if key_path.is_file() { 157 tracing::info!(path = ?key_path, "found ssh key"); 158 paths.push(key_path); 159 } 160 } 161 } 162 if paths.is_empty() { 163 tracing::info!("no ssh key found"); 164 } 165 paths 166} 167 168// Based on Git's implementation: https://github.com/git/git/blob/43072b4ca132437f21975ac6acc6b72dc22fd398/sideband.c#L178 169pub struct GitSidebandProgressMessageWriter { 170 display_prefix: &'static [u8], 171 suffix: &'static [u8], 172 scratch: Vec<u8>, 173} 174 175impl GitSidebandProgressMessageWriter { 176 pub fn new(ui: &Ui) -> Self { 177 let is_terminal = ui.use_progress_indicator(); 178 179 GitSidebandProgressMessageWriter { 180 display_prefix: "remote: ".as_bytes(), 181 suffix: if is_terminal { "\x1B[K" } else { " " }.as_bytes(), 182 scratch: Vec::new(), 183 } 184 } 185 186 pub fn write(&mut self, ui: &Ui, progress_message: &[u8]) -> std::io::Result<()> { 187 let mut index = 0; 188 // Append a suffix to each nonempty line to clear the end of the screen line. 189 loop { 190 let Some(i) = progress_message[index..] 191 .iter() 192 .position(|&c| c == b'\r' || c == b'\n') 193 .map(|i| index + i) 194 else { 195 break; 196 }; 197 let line_length = i - index; 198 199 // For messages sent across the packet boundary, there would be a nonempty 200 // "scratch" buffer from last call of this function, and there may be a leading 201 // CR/LF in this message. For this case we should add a clear-to-eol suffix to 202 // clean leftover letters we previously have written on the same line. 203 if !self.scratch.is_empty() && line_length == 0 { 204 self.scratch.extend_from_slice(self.suffix); 205 } 206 207 if self.scratch.is_empty() { 208 self.scratch.extend_from_slice(self.display_prefix); 209 } 210 211 // Do not add the clear-to-eol suffix to empty lines: 212 // For progress reporting we may receive a bunch of percentage updates 213 // followed by '\r' to remain on the same line, and at the end receive a single 214 // '\n' to move to the next line. We should preserve the final 215 // status report line by not appending clear-to-eol suffix to this single line 216 // break. 217 if line_length > 0 { 218 self.scratch.extend_from_slice(&progress_message[index..i]); 219 self.scratch.extend_from_slice(self.suffix); 220 } 221 self.scratch.extend_from_slice(&progress_message[i..i + 1]); 222 223 ui.status().write_all(&self.scratch)?; 224 self.scratch.clear(); 225 226 index = i + 1; 227 } 228 229 // Add leftover message to "scratch" buffer to be printed in next call. 230 if index < progress_message.len() { 231 if self.scratch.is_empty() { 232 self.scratch.extend_from_slice(self.display_prefix); 233 } 234 self.scratch.extend_from_slice(&progress_message[index..]); 235 } 236 237 Ok(()) 238 } 239 240 pub fn flush(&mut self, ui: &Ui) -> std::io::Result<()> { 241 if !self.scratch.is_empty() { 242 self.scratch.push(b'\n'); 243 ui.status().write_all(&self.scratch)?; 244 self.scratch.clear(); 245 } 246 247 Ok(()) 248 } 249} 250 251pub fn with_remote_git_callbacks<T>(ui: &Ui, f: impl FnOnce(git::RemoteCallbacks<'_>) -> T) -> T { 252 let mut callbacks = git::RemoteCallbacks::default(); 253 254 let mut progress_callback; 255 if let Some(mut output) = ui.progress_output() { 256 let mut progress = Progress::new(Instant::now()); 257 progress_callback = move |x: &git::Progress| { 258 _ = progress.update(Instant::now(), x, &mut output); 259 }; 260 callbacks.progress = Some(&mut progress_callback); 261 } 262 263 let mut sideband_progress_writer = GitSidebandProgressMessageWriter::new(ui); 264 let mut sideband_progress_callback = |progress_message: &[u8]| { 265 _ = sideband_progress_writer.write(ui, progress_message); 266 }; 267 callbacks.sideband_progress = Some(&mut sideband_progress_callback); 268 269 let mut get_ssh_keys = get_ssh_keys; // Coerce to unit fn type 270 callbacks.get_ssh_keys = Some(&mut get_ssh_keys); 271 let mut get_pw = 272 |url: &str, _username: &str| pinentry_get_pw(url).or_else(|| terminal_get_pw(ui, url)); 273 callbacks.get_password = Some(&mut get_pw); 274 let mut get_user_pw = 275 |url: &str| Some((terminal_get_username(ui, url)?, terminal_get_pw(ui, url)?)); 276 callbacks.get_username_password = Some(&mut get_user_pw); 277 278 let result = f(callbacks); 279 _ = sideband_progress_writer.flush(ui); 280 result 281} 282 283pub fn print_git_import_stats( 284 ui: &Ui, 285 repo: &dyn Repo, 286 stats: &GitImportStats, 287 show_ref_stats: bool, 288) -> Result<(), CommandError> { 289 let Some(mut formatter) = ui.status_formatter() else { 290 return Ok(()); 291 }; 292 if show_ref_stats { 293 let refs_stats = [ 294 (GitRefKind::Bookmark, &stats.changed_remote_bookmarks), 295 (GitRefKind::Tag, &stats.changed_remote_tags), 296 ] 297 .into_iter() 298 .flat_map(|(kind, changes)| { 299 changes 300 .iter() 301 .map(move |(symbol, (remote_ref, ref_target))| { 302 RefStatus::new(kind, symbol.as_ref(), remote_ref, ref_target, repo) 303 }) 304 }) 305 .collect_vec(); 306 307 let has_both_ref_kinds = 308 !stats.changed_remote_bookmarks.is_empty() && !stats.changed_remote_tags.is_empty(); 309 let max_width = refs_stats.iter().map(|x| x.symbol.width()).max(); 310 if let Some(max_width) = max_width { 311 for status in refs_stats { 312 status.output(max_width, has_both_ref_kinds, &mut *formatter)?; 313 } 314 } 315 } 316 317 if !stats.abandoned_commits.is_empty() { 318 writeln!( 319 formatter, 320 "Abandoned {} commits that are no longer reachable.", 321 stats.abandoned_commits.len() 322 )?; 323 } 324 325 if !stats.failed_ref_names.is_empty() { 326 writeln!(ui.warning_default(), "Failed to import some Git refs:")?; 327 let mut formatter = ui.stderr_formatter(); 328 for name in &stats.failed_ref_names { 329 write!(formatter, " ")?; 330 write!(formatter.labeled("git_ref"), "{name}")?; 331 writeln!(formatter)?; 332 } 333 } 334 if stats 335 .failed_ref_names 336 .iter() 337 .any(|name| name.starts_with(git::RESERVED_REMOTE_REF_NAMESPACE.as_bytes())) 338 { 339 writedoc!( 340 ui.hint_default(), 341 " 342 Git remote named '{name}' is reserved for local Git repository. 343 Use `jj git remote rename` to give a different name. 344 ", 345 name = git::REMOTE_NAME_FOR_LOCAL_GIT_REPO.as_symbol(), 346 )?; 347 } 348 349 Ok(()) 350} 351 352pub struct Progress { 353 next_print: Instant, 354 rate: RateEstimate, 355 buffer: String, 356 guard: Option<CleanupGuard>, 357} 358 359impl Progress { 360 pub fn new(now: Instant) -> Self { 361 Self { 362 next_print: now + crate::progress::INITIAL_DELAY, 363 rate: RateEstimate::new(), 364 buffer: String::new(), 365 guard: None, 366 } 367 } 368 369 pub fn update<W: std::io::Write>( 370 &mut self, 371 now: Instant, 372 progress: &git::Progress, 373 output: &mut ProgressOutput<W>, 374 ) -> io::Result<()> { 375 use std::fmt::Write as _; 376 377 if progress.overall == 1.0 { 378 write!(output, "\r{}", Clear(ClearType::CurrentLine))?; 379 output.flush()?; 380 return Ok(()); 381 } 382 383 let rate = progress 384 .bytes_downloaded 385 .and_then(|x| self.rate.update(now, x)); 386 if now < self.next_print { 387 return Ok(()); 388 } 389 self.next_print = now + Duration::from_secs(1) / crate::progress::UPDATE_HZ; 390 if self.guard.is_none() { 391 let guard = output.output_guard(crossterm::cursor::Show.to_string()); 392 let guard = CleanupGuard::new(move || { 393 drop(guard); 394 }); 395 _ = write!(output, "{}", crossterm::cursor::Hide); 396 self.guard = Some(guard); 397 } 398 399 self.buffer.clear(); 400 // Overwrite the current local or sideband progress line if any. 401 self.buffer.push('\r'); 402 let control_chars = self.buffer.len(); 403 write!(self.buffer, "{: >3.0}% ", 100.0 * progress.overall).unwrap(); 404 if let Some(total) = progress.bytes_downloaded { 405 let (scaled, prefix) = binary_prefix(total as f32); 406 write!(self.buffer, "{scaled: >5.1} {prefix}B ").unwrap(); 407 } 408 if let Some(estimate) = rate { 409 let (scaled, prefix) = binary_prefix(estimate); 410 write!(self.buffer, "at {scaled: >5.1} {prefix}B/s ").unwrap(); 411 } 412 413 let bar_width = output 414 .term_width() 415 .map(usize::from) 416 .unwrap_or(0) 417 .saturating_sub(self.buffer.len() - control_chars + 2); 418 self.buffer.push('['); 419 draw_progress(progress.overall, &mut self.buffer, bar_width); 420 self.buffer.push(']'); 421 422 write!(self.buffer, "{}", Clear(ClearType::UntilNewLine)).unwrap(); 423 // Move cursor back to the first column so the next sideband message 424 // will overwrite the current progress. 425 self.buffer.push('\r'); 426 write!(output, "{}", self.buffer)?; 427 output.flush()?; 428 Ok(()) 429 } 430} 431 432fn draw_progress(progress: f32, buffer: &mut String, width: usize) { 433 const CHARS: [char; 9] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█']; 434 const RESOLUTION: usize = CHARS.len() - 1; 435 let ticks = (width as f32 * progress.clamp(0.0, 1.0) * RESOLUTION as f32).round() as usize; 436 let whole = ticks / RESOLUTION; 437 for _ in 0..whole { 438 buffer.push(CHARS[CHARS.len() - 1]); 439 } 440 if whole < width { 441 let fraction = ticks % RESOLUTION; 442 buffer.push(CHARS[fraction]); 443 } 444 for _ in (whole + 1)..width { 445 buffer.push(CHARS[0]); 446 } 447} 448 449struct RateEstimate { 450 state: Option<RateEstimateState>, 451} 452 453impl RateEstimate { 454 pub fn new() -> Self { 455 RateEstimate { state: None } 456 } 457 458 /// Compute smoothed rate from an update 459 pub fn update(&mut self, now: Instant, total: u64) -> Option<f32> { 460 if let Some(ref mut state) = self.state { 461 return Some(state.update(now, total)); 462 } 463 464 self.state = Some(RateEstimateState { 465 total, 466 avg_rate: None, 467 last_sample: now, 468 }); 469 None 470 } 471} 472 473struct RateEstimateState { 474 total: u64, 475 avg_rate: Option<f32>, 476 last_sample: Instant, 477} 478 479impl RateEstimateState { 480 fn update(&mut self, now: Instant, total: u64) -> f32 { 481 let delta = total - self.total; 482 self.total = total; 483 let dt = now - self.last_sample; 484 self.last_sample = now; 485 let sample = delta as f32 / dt.as_secs_f32(); 486 match self.avg_rate { 487 None => *self.avg_rate.insert(sample), 488 Some(ref mut avg_rate) => { 489 // From Algorithms for Unevenly Spaced Time Series: Moving 490 // Averages and Other Rolling Operators (Andreas Eckner, 2019) 491 const TIME_WINDOW: f32 = 2.0; 492 let alpha = 1.0 - (-dt.as_secs_f32() / TIME_WINDOW).exp(); 493 *avg_rate += alpha * (sample - *avg_rate); 494 *avg_rate 495 } 496 } 497 } 498} 499 500struct RefStatus { 501 ref_kind: GitRefKind, 502 symbol: String, 503 tracking_status: TrackingStatus, 504 import_status: ImportStatus, 505} 506 507impl RefStatus { 508 fn new( 509 ref_kind: GitRefKind, 510 symbol: RemoteRefSymbol<'_>, 511 remote_ref: &RemoteRef, 512 ref_target: &RefTarget, 513 repo: &dyn Repo, 514 ) -> Self { 515 let tracking_status = match ref_kind { 516 GitRefKind::Bookmark => { 517 if repo.view().get_remote_bookmark(symbol).is_tracked() { 518 TrackingStatus::Tracked 519 } else { 520 TrackingStatus::Untracked 521 } 522 } 523 GitRefKind::Tag => TrackingStatus::NotApplicable, 524 }; 525 526 let import_status = match (remote_ref.target.is_absent(), ref_target.is_absent()) { 527 (true, false) => ImportStatus::New, 528 (false, true) => ImportStatus::Deleted, 529 _ => ImportStatus::Updated, 530 }; 531 532 Self { 533 symbol: symbol.to_string(), 534 tracking_status, 535 import_status, 536 ref_kind, 537 } 538 } 539 540 fn output( 541 &self, 542 max_symbol_width: usize, 543 has_both_ref_kinds: bool, 544 out: &mut dyn Formatter, 545 ) -> std::io::Result<()> { 546 let tracking_status = match self.tracking_status { 547 TrackingStatus::Tracked => "tracked", 548 TrackingStatus::Untracked => "untracked", 549 TrackingStatus::NotApplicable => "", 550 }; 551 552 let import_status = match self.import_status { 553 ImportStatus::New => "new", 554 ImportStatus::Deleted => "deleted", 555 ImportStatus::Updated => "updated", 556 }; 557 558 let symbol_width = self.symbol.width(); 559 let pad_width = max_symbol_width.saturating_sub(symbol_width); 560 let padded_symbol = format!("{}{:>pad_width$}", self.symbol, "", pad_width = pad_width); 561 562 let ref_kind = match self.ref_kind { 563 GitRefKind::Bookmark => "bookmark: ", 564 GitRefKind::Tag if !has_both_ref_kinds => "tag: ", 565 GitRefKind::Tag => "tag: ", 566 }; 567 568 write!(out, "{ref_kind}")?; 569 write!(out.labeled("bookmark"), "{padded_symbol}")?; 570 writeln!(out, " [{import_status}] {tracking_status}") 571 } 572} 573 574enum TrackingStatus { 575 Tracked, 576 Untracked, 577 NotApplicable, // for tags 578} 579 580enum ImportStatus { 581 New, 582 Deleted, 583 Updated, 584} 585 586pub fn print_git_export_stats(ui: &Ui, stats: &GitExportStats) -> Result<(), std::io::Error> { 587 if !stats.failed_bookmarks.is_empty() { 588 writeln!(ui.warning_default(), "Failed to export some bookmarks:")?; 589 let mut formatter = ui.stderr_formatter(); 590 for (symbol, reason) in &stats.failed_bookmarks { 591 write!(formatter, " ")?; 592 write!(formatter.labeled("bookmark"), "{symbol}")?; 593 for err in iter::successors(Some(reason as &dyn error::Error), |err| err.source()) { 594 write!(formatter, ": {err}")?; 595 } 596 writeln!(formatter)?; 597 } 598 drop(formatter); 599 if stats 600 .failed_bookmarks 601 .iter() 602 .any(|(_, reason)| matches!(reason, FailedRefExportReason::FailedToSet(_))) 603 { 604 writeln!( 605 ui.hint_default(), 606 r#"Git doesn't allow a branch name that looks like a parent directory of 607another (e.g. `foo` and `foo/bar`). Try to rename the bookmarks that failed to 608export or their "parent" bookmarks."#, 609 )?; 610 } 611 } 612 Ok(()) 613} 614 615#[cfg(test)] 616mod tests { 617 use std::path::MAIN_SEPARATOR; 618 619 use insta::assert_snapshot; 620 621 use super::*; 622 623 #[test] 624 fn test_absolute_git_url() { 625 // gix::Url::canonicalize() works even if the path doesn't exist. 626 // However, we need to ensure that no symlinks exist at the test paths. 627 let temp_dir = testutils::new_temp_dir(); 628 let cwd = dunce::canonicalize(temp_dir.path()).unwrap(); 629 let cwd_slash = cwd.to_str().unwrap().replace(MAIN_SEPARATOR, "/"); 630 631 // Local path 632 assert_eq!( 633 absolute_git_url(&cwd, "foo").unwrap(), 634 format!("{cwd_slash}/foo") 635 ); 636 assert_eq!( 637 absolute_git_url(&cwd, r"foo\bar").unwrap(), 638 if cfg!(windows) { 639 format!("{cwd_slash}/foo/bar") 640 } else { 641 format!(r"{cwd_slash}/foo\bar") 642 } 643 ); 644 assert_eq!( 645 absolute_git_url(&cwd.join("bar"), &format!("{cwd_slash}/foo")).unwrap(), 646 format!("{cwd_slash}/foo") 647 ); 648 649 // rcp-like 650 assert_eq!( 651 absolute_git_url(&cwd, "git@example.org:foo/bar.git").unwrap(), 652 "git@example.org:foo/bar.git" 653 ); 654 // URL 655 assert_eq!( 656 absolute_git_url(&cwd, "https://example.org/foo.git").unwrap(), 657 "https://example.org/foo.git" 658 ); 659 // Custom scheme isn't an error 660 assert_eq!( 661 absolute_git_url(&cwd, "custom://example.org/foo.git").unwrap(), 662 "custom://example.org/foo.git" 663 ); 664 // Password shouldn't be redacted (gix::Url::to_string() would do) 665 assert_eq!( 666 absolute_git_url(&cwd, "https://user:pass@example.org/").unwrap(), 667 "https://user:pass@example.org/" 668 ); 669 } 670 671 #[test] 672 fn test_bar() { 673 let mut buf = String::new(); 674 draw_progress(0.0, &mut buf, 10); 675 assert_eq!(buf, " "); 676 buf.clear(); 677 draw_progress(1.0, &mut buf, 10); 678 assert_eq!(buf, "██████████"); 679 buf.clear(); 680 draw_progress(0.5, &mut buf, 10); 681 assert_eq!(buf, "█████ "); 682 buf.clear(); 683 draw_progress(0.54, &mut buf, 10); 684 assert_eq!(buf, "█████▍ "); 685 buf.clear(); 686 } 687 688 #[test] 689 fn test_update() { 690 let start = Instant::now(); 691 let mut progress = Progress::new(start); 692 let mut current_time = start; 693 let mut update = |duration, overall| -> String { 694 current_time += duration; 695 let mut buf = vec![]; 696 let mut output = ProgressOutput::for_test(&mut buf, 25); 697 progress 698 .update( 699 current_time, 700 &jj_lib::git::Progress { 701 bytes_downloaded: None, 702 overall, 703 }, 704 &mut output, 705 ) 706 .unwrap(); 707 String::from_utf8(buf).unwrap() 708 }; 709 // First output is after the initial delay 710 assert_snapshot!(update(crate::progress::INITIAL_DELAY - Duration::from_millis(1), 0.1), @""); 711 assert_snapshot!(update(Duration::from_millis(1), 0.10), @"\u{1b}[?25l\r 10% [█▊ ]\u{1b}[K"); 712 // No updates for the next 30 milliseconds 713 assert_snapshot!(update(Duration::from_millis(10), 0.11), @""); 714 assert_snapshot!(update(Duration::from_millis(10), 0.12), @""); 715 assert_snapshot!(update(Duration::from_millis(10), 0.13), @""); 716 // We get an update now that we go over the threshold 717 assert_snapshot!(update(Duration::from_millis(100), 0.30), @" 30% [█████▍ ]"); 718 // Even though we went over by quite a bit, the new threshold is relative to the 719 // previous output, so we don't get an update here 720 assert_snapshot!(update(Duration::from_millis(30), 0.40), @""); 721 } 722}