just playing with tangled
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% [█████▍ ][K");
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}