jj_cli/
git_util.rs

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}