just playing with tangled
at splittmp 711 lines 24 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 Ok(home_dir) = etcetera::home_dir() { 153 let ssh_dir = 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 for (kind, changes) in [ 294 (GitRefKind::Bookmark, &stats.changed_remote_bookmarks), 295 (GitRefKind::Tag, &stats.changed_remote_tags), 296 ] { 297 let refs_stats = changes 298 .iter() 299 .map(|(symbol, (remote_ref, ref_target))| { 300 RefStatus::new(kind, symbol.as_ref(), remote_ref, ref_target, repo) 301 }) 302 .collect_vec(); 303 let Some(max_width) = refs_stats.iter().map(|x| x.symbol.width()).max() else { 304 continue; 305 }; 306 for status in refs_stats { 307 status.output(max_width, &mut *formatter)?; 308 } 309 } 310 } 311 312 if !stats.abandoned_commits.is_empty() { 313 writeln!( 314 formatter, 315 "Abandoned {} commits that are no longer reachable.", 316 stats.abandoned_commits.len() 317 )?; 318 } 319 320 if !stats.failed_ref_names.is_empty() { 321 writeln!(ui.warning_default(), "Failed to import some Git refs:")?; 322 let mut formatter = ui.stderr_formatter(); 323 for name in &stats.failed_ref_names { 324 write!(formatter, " ")?; 325 write!(formatter.labeled("git_ref"), "{name}")?; 326 writeln!(formatter)?; 327 } 328 } 329 if stats 330 .failed_ref_names 331 .iter() 332 .any(|name| name.starts_with(git::RESERVED_REMOTE_REF_NAMESPACE.as_bytes())) 333 { 334 writedoc!( 335 ui.hint_default(), 336 " 337 Git remote named '{name}' is reserved for local Git repository. 338 Use `jj git remote rename` to give a different name. 339 ", 340 name = git::REMOTE_NAME_FOR_LOCAL_GIT_REPO.as_symbol(), 341 )?; 342 } 343 344 Ok(()) 345} 346 347pub struct Progress { 348 next_print: Instant, 349 rate: RateEstimate, 350 buffer: String, 351 guard: Option<CleanupGuard>, 352} 353 354impl Progress { 355 pub fn new(now: Instant) -> Self { 356 Self { 357 next_print: now + crate::progress::INITIAL_DELAY, 358 rate: RateEstimate::new(), 359 buffer: String::new(), 360 guard: None, 361 } 362 } 363 364 pub fn update<W: std::io::Write>( 365 &mut self, 366 now: Instant, 367 progress: &git::Progress, 368 output: &mut ProgressOutput<W>, 369 ) -> io::Result<()> { 370 use std::fmt::Write as _; 371 372 if progress.overall == 1.0 { 373 write!(output, "\r{}", Clear(ClearType::CurrentLine))?; 374 output.flush()?; 375 return Ok(()); 376 } 377 378 let rate = progress 379 .bytes_downloaded 380 .and_then(|x| self.rate.update(now, x)); 381 if now < self.next_print { 382 return Ok(()); 383 } 384 self.next_print = now + Duration::from_secs(1) / crate::progress::UPDATE_HZ; 385 if self.guard.is_none() { 386 let guard = output.output_guard(crossterm::cursor::Show.to_string()); 387 let guard = CleanupGuard::new(move || { 388 drop(guard); 389 }); 390 _ = write!(output, "{}", crossterm::cursor::Hide); 391 self.guard = Some(guard); 392 } 393 394 self.buffer.clear(); 395 // Overwrite the current local or sideband progress line if any. 396 self.buffer.push('\r'); 397 let control_chars = self.buffer.len(); 398 write!(self.buffer, "{: >3.0}% ", 100.0 * progress.overall).unwrap(); 399 if let Some(total) = progress.bytes_downloaded { 400 let (scaled, prefix) = binary_prefix(total as f32); 401 write!(self.buffer, "{scaled: >5.1} {prefix}B ").unwrap(); 402 } 403 if let Some(estimate) = rate { 404 let (scaled, prefix) = binary_prefix(estimate); 405 write!(self.buffer, "at {scaled: >5.1} {prefix}B/s ").unwrap(); 406 } 407 408 let bar_width = output 409 .term_width() 410 .map(usize::from) 411 .unwrap_or(0) 412 .saturating_sub(self.buffer.len() - control_chars + 2); 413 self.buffer.push('['); 414 draw_progress(progress.overall, &mut self.buffer, bar_width); 415 self.buffer.push(']'); 416 417 write!(self.buffer, "{}", Clear(ClearType::UntilNewLine)).unwrap(); 418 // Move cursor back to the first column so the next sideband message 419 // will overwrite the current progress. 420 self.buffer.push('\r'); 421 write!(output, "{}", self.buffer)?; 422 output.flush()?; 423 Ok(()) 424 } 425} 426 427fn draw_progress(progress: f32, buffer: &mut String, width: usize) { 428 const CHARS: [char; 9] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█']; 429 const RESOLUTION: usize = CHARS.len() - 1; 430 let ticks = (width as f32 * progress.clamp(0.0, 1.0) * RESOLUTION as f32).round() as usize; 431 let whole = ticks / RESOLUTION; 432 for _ in 0..whole { 433 buffer.push(CHARS[CHARS.len() - 1]); 434 } 435 if whole < width { 436 let fraction = ticks % RESOLUTION; 437 buffer.push(CHARS[fraction]); 438 } 439 for _ in (whole + 1)..width { 440 buffer.push(CHARS[0]); 441 } 442} 443 444struct RateEstimate { 445 state: Option<RateEstimateState>, 446} 447 448impl RateEstimate { 449 pub fn new() -> Self { 450 RateEstimate { state: None } 451 } 452 453 /// Compute smoothed rate from an update 454 pub fn update(&mut self, now: Instant, total: u64) -> Option<f32> { 455 if let Some(ref mut state) = self.state { 456 return Some(state.update(now, total)); 457 } 458 459 self.state = Some(RateEstimateState { 460 total, 461 avg_rate: None, 462 last_sample: now, 463 }); 464 None 465 } 466} 467 468struct RateEstimateState { 469 total: u64, 470 avg_rate: Option<f32>, 471 last_sample: Instant, 472} 473 474impl RateEstimateState { 475 fn update(&mut self, now: Instant, total: u64) -> f32 { 476 let delta = total - self.total; 477 self.total = total; 478 let dt = now - self.last_sample; 479 self.last_sample = now; 480 let sample = delta as f32 / dt.as_secs_f32(); 481 match self.avg_rate { 482 None => *self.avg_rate.insert(sample), 483 Some(ref mut avg_rate) => { 484 // From Algorithms for Unevenly Spaced Time Series: Moving 485 // Averages and Other Rolling Operators (Andreas Eckner, 2019) 486 const TIME_WINDOW: f32 = 2.0; 487 let alpha = 1.0 - (-dt.as_secs_f32() / TIME_WINDOW).exp(); 488 *avg_rate += alpha * (sample - *avg_rate); 489 *avg_rate 490 } 491 } 492 } 493} 494 495struct RefStatus { 496 ref_kind: GitRefKind, 497 symbol: String, 498 tracking_status: TrackingStatus, 499 import_status: ImportStatus, 500} 501 502impl RefStatus { 503 fn new( 504 ref_kind: GitRefKind, 505 symbol: RemoteRefSymbol<'_>, 506 remote_ref: &RemoteRef, 507 ref_target: &RefTarget, 508 repo: &dyn Repo, 509 ) -> Self { 510 let tracking_status = match ref_kind { 511 GitRefKind::Bookmark => { 512 if repo.view().get_remote_bookmark(symbol).is_tracked() { 513 TrackingStatus::Tracked 514 } else { 515 TrackingStatus::Untracked 516 } 517 } 518 GitRefKind::Tag => TrackingStatus::NotApplicable, 519 }; 520 521 let import_status = match (remote_ref.target.is_absent(), ref_target.is_absent()) { 522 (true, false) => ImportStatus::New, 523 (false, true) => ImportStatus::Deleted, 524 _ => ImportStatus::Updated, 525 }; 526 527 Self { 528 symbol: symbol.to_string(), 529 tracking_status, 530 import_status, 531 ref_kind, 532 } 533 } 534 535 fn output(&self, max_symbol_width: usize, out: &mut dyn Formatter) -> std::io::Result<()> { 536 let tracking_status = match self.tracking_status { 537 TrackingStatus::Tracked => "tracked", 538 TrackingStatus::Untracked => "untracked", 539 TrackingStatus::NotApplicable => "", 540 }; 541 542 let import_status = match self.import_status { 543 ImportStatus::New => "new", 544 ImportStatus::Deleted => "deleted", 545 ImportStatus::Updated => "updated", 546 }; 547 548 let symbol_width = self.symbol.width(); 549 let pad_width = max_symbol_width.saturating_sub(symbol_width); 550 let padded_symbol = format!("{}{:>pad_width$}", self.symbol, "", pad_width = pad_width); 551 552 let label = match self.ref_kind { 553 GitRefKind::Bookmark => "bookmark", 554 GitRefKind::Tag => "tag", 555 }; 556 557 write!(out, "{label}: ")?; 558 write!(out.labeled(label), "{padded_symbol}")?; 559 writeln!(out, " [{import_status}] {tracking_status}") 560 } 561} 562 563enum TrackingStatus { 564 Tracked, 565 Untracked, 566 NotApplicable, // for tags 567} 568 569enum ImportStatus { 570 New, 571 Deleted, 572 Updated, 573} 574 575pub fn print_git_export_stats(ui: &Ui, stats: &GitExportStats) -> Result<(), std::io::Error> { 576 if !stats.failed_bookmarks.is_empty() { 577 writeln!(ui.warning_default(), "Failed to export some bookmarks:")?; 578 let mut formatter = ui.stderr_formatter(); 579 for (symbol, reason) in &stats.failed_bookmarks { 580 write!(formatter, " ")?; 581 write!(formatter.labeled("bookmark"), "{symbol}")?; 582 for err in iter::successors(Some(reason as &dyn error::Error), |err| err.source()) { 583 write!(formatter, ": {err}")?; 584 } 585 writeln!(formatter)?; 586 } 587 drop(formatter); 588 if stats 589 .failed_bookmarks 590 .iter() 591 .any(|(_, reason)| matches!(reason, FailedRefExportReason::FailedToSet(_))) 592 { 593 writeln!( 594 ui.hint_default(), 595 r#"Git doesn't allow a branch name that looks like a parent directory of 596another (e.g. `foo` and `foo/bar`). Try to rename the bookmarks that failed to 597export or their "parent" bookmarks."#, 598 )?; 599 } 600 } 601 Ok(()) 602} 603 604#[cfg(test)] 605mod tests { 606 use std::path::MAIN_SEPARATOR; 607 608 use insta::assert_snapshot; 609 610 use super::*; 611 612 #[test] 613 fn test_absolute_git_url() { 614 // gix::Url::canonicalize() works even if the path doesn't exist. 615 // However, we need to ensure that no symlinks exist at the test paths. 616 let temp_dir = testutils::new_temp_dir(); 617 let cwd = dunce::canonicalize(temp_dir.path()).unwrap(); 618 let cwd_slash = cwd.to_str().unwrap().replace(MAIN_SEPARATOR, "/"); 619 620 // Local path 621 assert_eq!( 622 absolute_git_url(&cwd, "foo").unwrap(), 623 format!("{cwd_slash}/foo") 624 ); 625 assert_eq!( 626 absolute_git_url(&cwd, r"foo\bar").unwrap(), 627 if cfg!(windows) { 628 format!("{cwd_slash}/foo/bar") 629 } else { 630 format!(r"{cwd_slash}/foo\bar") 631 } 632 ); 633 assert_eq!( 634 absolute_git_url(&cwd.join("bar"), &format!("{cwd_slash}/foo")).unwrap(), 635 format!("{cwd_slash}/foo") 636 ); 637 638 // rcp-like 639 assert_eq!( 640 absolute_git_url(&cwd, "git@example.org:foo/bar.git").unwrap(), 641 "git@example.org:foo/bar.git" 642 ); 643 // URL 644 assert_eq!( 645 absolute_git_url(&cwd, "https://example.org/foo.git").unwrap(), 646 "https://example.org/foo.git" 647 ); 648 // Custom scheme isn't an error 649 assert_eq!( 650 absolute_git_url(&cwd, "custom://example.org/foo.git").unwrap(), 651 "custom://example.org/foo.git" 652 ); 653 // Password shouldn't be redacted (gix::Url::to_string() would do) 654 assert_eq!( 655 absolute_git_url(&cwd, "https://user:pass@example.org/").unwrap(), 656 "https://user:pass@example.org/" 657 ); 658 } 659 660 #[test] 661 fn test_bar() { 662 let mut buf = String::new(); 663 draw_progress(0.0, &mut buf, 10); 664 assert_eq!(buf, " "); 665 buf.clear(); 666 draw_progress(1.0, &mut buf, 10); 667 assert_eq!(buf, "██████████"); 668 buf.clear(); 669 draw_progress(0.5, &mut buf, 10); 670 assert_eq!(buf, "█████ "); 671 buf.clear(); 672 draw_progress(0.54, &mut buf, 10); 673 assert_eq!(buf, "█████▍ "); 674 buf.clear(); 675 } 676 677 #[test] 678 fn test_update() { 679 let start = Instant::now(); 680 let mut progress = Progress::new(start); 681 let mut current_time = start; 682 let mut update = |duration, overall| -> String { 683 current_time += duration; 684 let mut buf = vec![]; 685 let mut output = ProgressOutput::for_test(&mut buf, 25); 686 progress 687 .update( 688 current_time, 689 &jj_lib::git::Progress { 690 bytes_downloaded: None, 691 overall, 692 }, 693 &mut output, 694 ) 695 .unwrap(); 696 String::from_utf8(buf).unwrap() 697 }; 698 // First output is after the initial delay 699 assert_snapshot!(update(crate::progress::INITIAL_DELAY - Duration::from_millis(1), 0.1), @""); 700 assert_snapshot!(update(Duration::from_millis(1), 0.10), @"\u{1b}[?25l\r 10% [█▊ ]\u{1b}[K"); 701 // No updates for the next 30 milliseconds 702 assert_snapshot!(update(Duration::from_millis(10), 0.11), @""); 703 assert_snapshot!(update(Duration::from_millis(10), 0.12), @""); 704 assert_snapshot!(update(Duration::from_millis(10), 0.13), @""); 705 // We get an update now that we go over the threshold 706 assert_snapshot!(update(Duration::from_millis(100), 0.30), @" 30% [█████▍ ]"); 707 // Even though we went over by quite a bit, the new threshold is relative to the 708 // previous output, so we don't get an update here 709 assert_snapshot!(update(Duration::from_millis(30), 0.40), @""); 710 } 711}