Skip to main content

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::Write as _;
20use std::iter;
21use std::mem;
22use std::path::Path;
23use std::time::Duration;
24use std::time::Instant;
25
26use bstr::ByteSlice as _;
27use crossterm::terminal::Clear;
28use crossterm::terminal::ClearType;
29use indoc::writedoc;
30use itertools::Itertools as _;
31use jj_lib::git;
32use jj_lib::git::FailedRefExportReason;
33use jj_lib::git::GitExportStats;
34use jj_lib::git::GitImportOptions;
35use jj_lib::git::GitImportRefUpdate;
36use jj_lib::git::GitImportStats;
37use jj_lib::git::GitProgress;
38use jj_lib::git::GitPushStats;
39use jj_lib::git::GitRefKind;
40use jj_lib::git::GitSettings;
41use jj_lib::git::GitSidebandLineTerminator;
42use jj_lib::git::GitSubprocessCallback;
43use jj_lib::git_backend::GitRepoAtWorkdirError;
44use jj_lib::op_store::RemoteRefState;
45use jj_lib::repo::ReadonlyRepo;
46use jj_lib::repo::Repo;
47use jj_lib::settings::RemoteSettingsMap;
48use jj_lib::workspace::Workspace;
49use unicode_width::UnicodeWidthStr as _;
50
51use crate::cleanup_guard::CleanupGuard;
52use crate::cli_util::WorkspaceCommandTransaction;
53use crate::cli_util::print_updated_commits;
54use crate::command_error::CommandError;
55use crate::command_error::cli_error;
56use crate::command_error::user_error;
57use crate::formatter::Formatter;
58use crate::formatter::FormatterExt as _;
59use crate::revset_util::parse_remote_auto_track_bookmarks_map;
60use crate::ui::ProgressOutput;
61use crate::ui::Ui;
62
63pub fn is_colocated_git_workspace(workspace: &Workspace) -> Result<bool, CommandError> {
64    let Ok(git_backend) = git::get_git_backend(workspace.repo_loader().store()) else {
65        return Ok(false);
66    };
67    match git_backend.open_git_repo_at_workdir(workspace.workspace_root()) {
68        Ok(_) => Ok(true),
69        Err(GitRepoAtWorkdirError::NotFound { .. }) => Ok(false),
70        Err(GitRepoAtWorkdirError::Unrelated { .. }) => Ok(false),
71        Err(err @ GitRepoAtWorkdirError::Other(_)) => Err(user_error(err)),
72    }
73}
74
75/// Parses user-specified remote URL or path to absolute form.
76pub fn absolute_git_url(cwd: &Path, source: &str) -> Result<String, CommandError> {
77    // Git appears to turn URL-like source to absolute path if local git directory
78    // exits, and fails because '$PWD/https' is unsupported protocol. Since it would
79    // be tedious to copy the exact git (or libgit2) behavior, we simply let gix
80    // parse the input as URL, rcp-like, or local path.
81    let mut url = gix::url::parse(source).map_err(cli_error)?;
82    url.canonicalize(cwd).map_err(user_error)?;
83    // As of gix 0.68.0, the canonicalized path uses platform-native directory
84    // separator, which isn't compatible with libgit2 on Windows.
85    if url.scheme == gix::url::Scheme::File {
86        url.path = gix::path::to_unix_separators_on_windows(mem::take(&mut url.path)).into_owned();
87    }
88    // It's less likely that cwd isn't utf-8, so just fall back to original source.
89    Ok(String::from_utf8(url.to_bstring().into()).unwrap_or_else(|_| source.to_owned()))
90}
91
92/// Converts a git remote URL to a normalized HTTPS URL for web browsing.
93///
94/// Returns `None` if the URL cannot be converted.
95fn git_remote_url_to_web(url: &gix::Url) -> Option<String> {
96    if url.scheme == gix::url::Scheme::File || url.host().is_none() {
97        return None;
98    }
99
100    let host = url.host()?;
101    let path = url.path.to_str().ok()?;
102    let path = path.trim_matches('/');
103    let path = path.strip_suffix(".git").unwrap_or(path);
104
105    Some(format!("https://{host}/{path}"))
106}
107
108/// Returns the web URL for a git remote.
109///
110/// Attempts to convert the remote's URL to an HTTPS web URL.
111/// Returns `None` if the remote doesn't exist or its URL cannot be converted.
112pub fn get_remote_web_url(repo: &ReadonlyRepo, remote_name: &str) -> Option<String> {
113    let git_repo = git::get_git_repo(repo.store()).ok()?;
114    let remote = git_repo.try_find_remote(remote_name)?.ok()?;
115    let url = remote
116        .url(gix::remote::Direction::Fetch)
117        .or_else(|| remote.url(gix::remote::Direction::Push))?;
118    git_remote_url_to_web(url)
119}
120
121/// [`Ui`] adapter to forward Git command outputs.
122pub struct GitSubprocessUi<'a> {
123    // Don't hold locked ui.status() which could block tracing output in
124    // different threads.
125    ui: &'a Ui,
126    progress_output: Option<ProgressOutput<io::Stderr>>,
127    progress: Progress,
128    // Sequence to erase line towards end.
129    erase_end: &'static [u8],
130}
131
132impl<'a> GitSubprocessUi<'a> {
133    pub fn new(ui: &'a Ui) -> Self {
134        let progress_output = ui.progress_output();
135        let is_terminal = progress_output.is_some();
136        Self {
137            ui,
138            progress_output,
139            progress: Progress::new(Instant::now()),
140            erase_end: if is_terminal { b"\x1B[K" } else { b"        " },
141        }
142    }
143
144    fn write_sideband(
145        &self,
146        prefix: &[u8],
147        message: &[u8],
148        term: Option<GitSidebandLineTerminator>,
149    ) -> io::Result<()> {
150        // TODO: maybe progress should be temporarily cleared if there are
151        // sideband lines to write.
152        let mut scratch =
153            Vec::with_capacity(prefix.len() + message.len() + self.erase_end.len() + 1);
154        scratch.extend_from_slice(prefix);
155        scratch.extend_from_slice(message);
156        // Do not erase the current line by new empty line: For progress
157        // reporting, we may receive a bunch of percentage updates followed by
158        // '\r' to remain on the same line, and at the end receive a single '\n'
159        // to move to the next line. We should preserve the final status report
160        // line by not appending erase_end sequence to this single line break.
161        if !message.is_empty() {
162            scratch.extend_from_slice(self.erase_end);
163        }
164        // It's unlikely, but don't leave message without newline.
165        scratch.push(term.map_or(b'\n', |t| t.as_byte()));
166        self.ui.status().write_all(&scratch)
167    }
168}
169
170impl GitSubprocessCallback for GitSubprocessUi<'_> {
171    fn needs_progress(&self) -> bool {
172        self.progress_output.is_some()
173    }
174
175    fn progress(&mut self, progress: &GitProgress) -> io::Result<()> {
176        if let Some(output) = &mut self.progress_output {
177            self.progress.update(Instant::now(), progress, output)
178        } else {
179            Ok(())
180        }
181    }
182
183    fn local_sideband(
184        &mut self,
185        message: &[u8],
186        term: Option<GitSidebandLineTerminator>,
187    ) -> io::Result<()> {
188        self.write_sideband(b"git: ", message, term)
189    }
190
191    fn remote_sideband(
192        &mut self,
193        message: &[u8],
194        term: Option<GitSidebandLineTerminator>,
195    ) -> io::Result<()> {
196        self.write_sideband(b"remote: ", message, term)
197    }
198}
199
200pub fn load_git_import_options(
201    ui: &Ui,
202    git_settings: &GitSettings,
203    remote_settings: &RemoteSettingsMap,
204) -> Result<GitImportOptions, CommandError> {
205    Ok(GitImportOptions {
206        abandon_unreachable_commits: git_settings.abandon_unreachable_commits,
207        record_synthetic_predecessors: git_settings.record_synthetic_predecessors,
208        remote_auto_track_bookmarks: parse_remote_auto_track_bookmarks_map(ui, remote_settings)?,
209    })
210}
211
212pub fn print_git_import_stats(
213    ui: &Ui,
214    tx: &WorkspaceCommandTransaction<'_>,
215    stats: &GitImportStats,
216) -> Result<(), CommandError> {
217    if let Some(mut formatter) = ui.status_formatter() {
218        print_imported_changes(formatter.as_mut(), tx, stats)?;
219    }
220    print_failed_git_import(ui, stats)?;
221    Ok(())
222}
223
224fn print_imported_changes(
225    formatter: &mut dyn Formatter,
226    tx: &WorkspaceCommandTransaction<'_>,
227    stats: &GitImportStats,
228) -> Result<(), CommandError> {
229    for (kind, changes) in [
230        (GitRefKind::Bookmark, &stats.changed_remote_bookmarks),
231        (GitRefKind::Tag, &stats.changed_remote_tags),
232    ] {
233        let refs_stats = changes
234            .iter()
235            .map(|update| RefStatus::new(kind, update, tx.repo()))
236            .collect_vec();
237        let Some(max_width) = refs_stats.iter().map(|x| x.symbol.width()).max() else {
238            continue;
239        };
240        for status in refs_stats {
241            status.output(max_width, formatter)?;
242        }
243    }
244
245    if !stats.abandoned_commits.is_empty() {
246        writeln!(
247            formatter,
248            "Abandoned {} commits that are no longer reachable:",
249            stats.abandoned_commits.len()
250        )?;
251        let template = tx.commit_summary_template();
252        print_updated_commits(formatter, &template, &stats.abandoned_commits)?;
253    }
254    if !stats.rewritten_commit_ids.is_empty() {
255        writeln!(
256            formatter,
257            "Updated {} rewritten commits.",
258            stats.rewritten_commit_ids.len()
259        )?;
260    }
261
262    Ok(())
263}
264
265fn print_failed_git_import(ui: &Ui, stats: &GitImportStats) -> Result<(), CommandError> {
266    if !stats.failed_ref_names.is_empty() {
267        writeln!(ui.warning_default(), "Failed to import some Git refs:")?;
268        let mut formatter = ui.stderr_formatter();
269        for name in &stats.failed_ref_names {
270            write!(formatter, "  ")?;
271            write!(formatter.labeled("git_ref"), "{name}")?;
272            writeln!(formatter)?;
273        }
274    }
275    if stats
276        .failed_ref_names
277        .iter()
278        .any(|name| name.starts_with(git::RESERVED_REMOTE_REF_NAMESPACE.as_bytes()))
279    {
280        writedoc!(
281            ui.hint_default(),
282            "
283            Git remote named '{name}' is reserved for local Git repository.
284            Use `jj git remote rename` to give a different name.
285            ",
286            name = git::REMOTE_NAME_FOR_LOCAL_GIT_REPO.as_symbol(),
287        )?;
288    }
289    Ok(())
290}
291
292/// Prints only the summary of git import stats (abandoned count, failed refs).
293/// Use this when a WorkspaceCommandTransaction is not available.
294pub fn print_git_import_stats_summary(ui: &Ui, stats: &GitImportStats) -> Result<(), CommandError> {
295    if let Some(mut formatter) = ui.status_formatter() {
296        if !stats.abandoned_commits.is_empty() {
297            writeln!(
298                formatter,
299                "Abandoned {} commits that are no longer reachable.",
300                stats.abandoned_commits.len()
301            )?;
302        }
303        if !stats.rewritten_commit_ids.is_empty() {
304            writeln!(
305                formatter,
306                "Updated {} rewritten commits.",
307                stats.rewritten_commit_ids.len()
308            )?;
309        }
310    }
311    print_failed_git_import(ui, stats)?;
312    Ok(())
313}
314
315pub struct Progress {
316    next_print: Instant,
317    buffer: String,
318    guard: Option<CleanupGuard>,
319}
320
321impl Progress {
322    pub fn new(now: Instant) -> Self {
323        Self {
324            next_print: now + crate::progress::INITIAL_DELAY,
325            buffer: String::new(),
326            guard: None,
327        }
328    }
329
330    pub fn update<W: std::io::Write>(
331        &mut self,
332        now: Instant,
333        progress: &GitProgress,
334        output: &mut ProgressOutput<W>,
335    ) -> io::Result<()> {
336        use std::fmt::Write as _;
337
338        if progress.overall() == 1.0 {
339            write!(output, "\r{}", Clear(ClearType::CurrentLine))?;
340            output.flush()?;
341            return Ok(());
342        }
343
344        if now < self.next_print {
345            return Ok(());
346        }
347        self.next_print = now + Duration::from_secs(1) / crate::progress::UPDATE_HZ;
348        if self.guard.is_none() {
349            let guard = output.output_guard(crossterm::cursor::Show.to_string());
350            let guard = CleanupGuard::new(move || {
351                drop(guard);
352            });
353            write!(output, "{}", crossterm::cursor::Hide).ok();
354            self.guard = Some(guard);
355        }
356
357        self.buffer.clear();
358        // Overwrite the current local or sideband progress line if any.
359        self.buffer.push('\r');
360        let control_chars = self.buffer.len();
361        write!(self.buffer, "{: >3.0}% ", 100.0 * progress.overall()).unwrap();
362
363        let bar_width = output
364            .term_width()
365            .map(usize::from)
366            .unwrap_or(0)
367            .saturating_sub(self.buffer.len() - control_chars + 2);
368        self.buffer.push('[');
369        draw_progress(progress.overall(), &mut self.buffer, bar_width);
370        self.buffer.push(']');
371
372        write!(self.buffer, "{}", Clear(ClearType::UntilNewLine)).unwrap();
373        // Move cursor back to the first column so the next sideband message
374        // will overwrite the current progress.
375        self.buffer.push('\r');
376        write!(output, "{}", self.buffer)?;
377        output.flush()?;
378        Ok(())
379    }
380}
381
382fn draw_progress(progress: f32, buffer: &mut String, width: usize) {
383    const CHARS: [char; 9] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
384    const RESOLUTION: usize = CHARS.len() - 1;
385    let ticks = (width as f32 * progress.clamp(0.0, 1.0) * RESOLUTION as f32).round() as usize;
386    let whole = ticks / RESOLUTION;
387    for _ in 0..whole {
388        buffer.push(CHARS[CHARS.len() - 1]);
389    }
390    if whole < width {
391        let fraction = ticks % RESOLUTION;
392        buffer.push(CHARS[fraction]);
393    }
394    for _ in (whole + 1)..width {
395        buffer.push(CHARS[0]);
396    }
397}
398
399struct RefStatus {
400    ref_kind: GitRefKind,
401    symbol: String,
402    remote_ref_state: RemoteRefState,
403    import_status: ImportStatus,
404}
405
406impl RefStatus {
407    fn new(ref_kind: GitRefKind, update: &GitImportRefUpdate, repo: &dyn Repo) -> Self {
408        let new_remote_ref = match ref_kind {
409            GitRefKind::Bookmark => repo.view().get_remote_bookmark(update.symbol.as_ref()),
410            GitRefKind::Tag => repo.view().get_remote_tag(update.symbol.as_ref()),
411        };
412
413        let import_status = match (
414            update.old_remote_ref.target.is_absent(),
415            update.new_target.is_absent(),
416        ) {
417            (true, false) => ImportStatus::New,
418            (false, true) => ImportStatus::Deleted,
419            _ => ImportStatus::Updated,
420        };
421
422        Self {
423            symbol: update.symbol.to_string(),
424            remote_ref_state: new_remote_ref.state,
425            import_status,
426            ref_kind,
427        }
428    }
429
430    fn output(&self, max_symbol_width: usize, out: &mut dyn Formatter) -> std::io::Result<()> {
431        let tracking_status = match self.remote_ref_state {
432            RemoteRefState::New => "untracked",
433            RemoteRefState::Tracked => "tracked",
434        };
435
436        let import_status = match self.import_status {
437            ImportStatus::New => "new",
438            ImportStatus::Deleted => "deleted",
439            ImportStatus::Updated => "updated",
440        };
441
442        let symbol_width = self.symbol.width();
443        let pad_width = max_symbol_width.saturating_sub(symbol_width);
444        let padded_symbol = format!("{}{:>pad_width$}", self.symbol, "", pad_width = pad_width);
445
446        let label = match self.ref_kind {
447            GitRefKind::Bookmark => "bookmark",
448            GitRefKind::Tag => "tag",
449        };
450
451        write!(out, "{label}: ")?;
452        write!(out.labeled(label), "{padded_symbol}")?;
453        writeln!(out, " [{import_status}] {tracking_status}")
454    }
455}
456
457enum ImportStatus {
458    New,
459    Deleted,
460    Updated,
461}
462
463pub fn print_git_export_stats(ui: &Ui, stats: &GitExportStats) -> Result<(), std::io::Error> {
464    if !stats.failed_bookmarks.is_empty() {
465        writeln!(ui.warning_default(), "Failed to export some bookmarks:")?;
466        let mut formatter = ui.stderr_formatter();
467        for (symbol, reason) in &stats.failed_bookmarks {
468            write!(formatter, "  ")?;
469            write!(formatter.labeled("bookmark"), "{symbol}")?;
470            for err in iter::successors(Some(reason as &dyn error::Error), |err| err.source()) {
471                write!(formatter, ": {err}")?;
472            }
473            writeln!(formatter)?;
474        }
475    }
476    if !stats.failed_tags.is_empty() {
477        writeln!(ui.warning_default(), "Failed to export some tags:")?;
478        let mut formatter = ui.stderr_formatter();
479        for (symbol, reason) in &stats.failed_tags {
480            write!(formatter, "  ")?;
481            write!(formatter.labeled("tag"), "{symbol}")?;
482            for err in iter::successors(Some(reason as &dyn error::Error), |err| err.source()) {
483                write!(formatter, ": {err}")?;
484            }
485            writeln!(formatter)?;
486        }
487    }
488    if itertools::chain(&stats.failed_bookmarks, &stats.failed_tags)
489        .any(|(_, reason)| matches!(reason, FailedRefExportReason::FailedToSet(_)))
490    {
491        writedoc!(
492            ui.hint_default(),
493            r#"
494            Git doesn't allow a branch/tag name that looks like a parent directory of
495            another (e.g. `foo` and `foo/bar`). Try to rename the bookmarks/tags that failed
496            to export or their "parent" bookmarks/tags.
497            "#,
498        )?;
499    }
500    Ok(())
501}
502
503pub fn print_push_stats(ui: &Ui, stats: &GitPushStats) -> io::Result<()> {
504    if !stats.rejected.is_empty() {
505        writeln!(
506            ui.warning_default(),
507            "The following references unexpectedly moved on the remote:"
508        )?;
509        let mut formatter = ui.stderr_formatter();
510        for (reference, reason) in &stats.rejected {
511            write!(formatter, "  ")?;
512            write!(formatter.labeled("git_ref"), "{}", reference.as_symbol())?;
513            if let Some(r) = reason {
514                write!(formatter, " (reason: {r})")?;
515            }
516            writeln!(formatter)?;
517        }
518        drop(formatter);
519        writeln!(
520            ui.hint_default(),
521            "Try fetching from the remote, then make the bookmark point to where you want it to \
522             be, and push again.",
523        )?;
524    }
525    if !stats.remote_rejected.is_empty() {
526        writeln!(
527            ui.warning_default(),
528            "The remote rejected the following updates:"
529        )?;
530        let mut formatter = ui.stderr_formatter();
531        for (reference, reason) in &stats.remote_rejected {
532            write!(formatter, "  ")?;
533            write!(formatter.labeled("git_ref"), "{}", reference.as_symbol())?;
534            if let Some(r) = reason {
535                write!(formatter, " (reason: {r})")?;
536            }
537            writeln!(formatter)?;
538        }
539        drop(formatter);
540        writeln!(
541            ui.hint_default(),
542            "Try checking if you have permission to push to all the bookmarks."
543        )?;
544    }
545    if !stats.unexported_bookmarks.is_empty() {
546        writeln!(
547            ui.warning_default(),
548            "The following bookmarks couldn't be updated locally:"
549        )?;
550        let mut formatter = ui.stderr_formatter();
551        for (symbol, reason) in &stats.unexported_bookmarks {
552            write!(formatter, "  ")?;
553            write!(formatter.labeled("bookmark"), "{symbol}")?;
554            for err in iter::successors(Some(reason as &dyn error::Error), |err| err.source()) {
555                write!(formatter, ": {err}")?;
556            }
557            writeln!(formatter)?;
558        }
559    }
560    Ok(())
561}
562
563#[cfg(test)]
564mod tests {
565    use std::path::MAIN_SEPARATOR;
566
567    use insta::assert_snapshot;
568
569    use super::*;
570
571    #[test]
572    fn test_absolute_git_url() {
573        // gix::Url::canonicalize() works even if the path doesn't exist.
574        // However, we need to ensure that no symlinks exist at the test paths.
575        let temp_dir = testutils::new_temp_dir();
576        let cwd = dunce::canonicalize(temp_dir.path()).unwrap();
577        let cwd_slash = cwd.to_str().unwrap().replace(MAIN_SEPARATOR, "/");
578
579        // Local path
580        assert_eq!(
581            absolute_git_url(&cwd, "foo").unwrap(),
582            format!("{cwd_slash}/foo")
583        );
584        assert_eq!(
585            absolute_git_url(&cwd, r"foo\bar").unwrap(),
586            if cfg!(windows) {
587                format!("{cwd_slash}/foo/bar")
588            } else {
589                format!(r"{cwd_slash}/foo\bar")
590            }
591        );
592        assert_eq!(
593            absolute_git_url(&cwd.join("bar"), &format!("{cwd_slash}/foo")).unwrap(),
594            format!("{cwd_slash}/foo")
595        );
596
597        // rcp-like
598        assert_eq!(
599            absolute_git_url(&cwd, "git@example.org:foo/bar.git").unwrap(),
600            "git@example.org:foo/bar.git"
601        );
602        // URL
603        assert_eq!(
604            absolute_git_url(&cwd, "https://example.org/foo.git").unwrap(),
605            "https://example.org/foo.git"
606        );
607        // Custom scheme isn't an error
608        assert_eq!(
609            absolute_git_url(&cwd, "custom://example.org/foo.git").unwrap(),
610            "custom://example.org/foo.git"
611        );
612        // Password shouldn't be redacted (gix::Url::to_string() would do)
613        assert_eq!(
614            absolute_git_url(&cwd, "https://user:pass@example.org/").unwrap(),
615            "https://user:pass@example.org/"
616        );
617
618        // %-encoded paths: %20 ' ', %25 '%'
619        assert_eq!(
620            absolute_git_url(&cwd, "https://example.org/%20%25").unwrap(),
621            "https://example.org/%20%25"
622        );
623        // No exact match because "/" isn't an absolute path on Windows
624        assert!(
625            absolute_git_url(&cwd, "file:///%20%25")
626                .unwrap()
627                .ends_with("/%20%25")
628        );
629    }
630
631    #[test]
632    fn test_git_remote_url_to_web() {
633        let to_web = |s| git_remote_url_to_web(&gix::Url::try_from(s).unwrap());
634
635        // SSH URL
636        assert_eq!(
637            to_web("git@github.com:owner/repo"),
638            Some("https://github.com/owner/repo".to_owned())
639        );
640        // HTTPS URL with .git suffix
641        assert_eq!(
642            to_web("https://github.com/owner/repo.git"),
643            Some("https://github.com/owner/repo".to_owned())
644        );
645        // SSH URL with ssh:// scheme
646        assert_eq!(
647            to_web("ssh://git@github.com/owner/repo"),
648            Some("https://github.com/owner/repo".to_owned())
649        );
650        // git:// protocol
651        assert_eq!(
652            to_web("git://github.com/owner/repo.git"),
653            Some("https://github.com/owner/repo".to_owned())
654        );
655        // File URL returns None
656        assert_eq!(to_web("file:///path/to/repo"), None);
657        // Local path returns None
658        assert_eq!(to_web("/path/to/repo"), None);
659    }
660
661    #[test]
662    fn test_bar() {
663        let mut buf = String::new();
664        draw_progress(0.0, &mut buf, 10);
665        assert_eq!(buf, "          ");
666        buf.clear();
667        draw_progress(1.0, &mut buf, 10);
668        assert_eq!(buf, "██████████");
669        buf.clear();
670        draw_progress(0.5, &mut buf, 10);
671        assert_eq!(buf, "█████     ");
672        buf.clear();
673        draw_progress(0.54, &mut buf, 10);
674        assert_eq!(buf, "█████▍    ");
675        buf.clear();
676    }
677
678    #[test]
679    fn test_update() {
680        let start = Instant::now();
681        let mut progress = Progress::new(start);
682        let mut current_time = start;
683        let mut update = |duration, overall: u64| -> String {
684            current_time += duration;
685            let mut buf = vec![];
686            let mut output = ProgressOutput::for_test(&mut buf, 25);
687            progress
688                .update(
689                    current_time,
690                    &GitProgress {
691                        deltas: (overall, 100),
692                        objects: (0, 0),
693                        counted_objects: (0, 0),
694                        compressed_objects: (0, 0),
695                    },
696                    &mut output,
697                )
698                .unwrap();
699            String::from_utf8(buf).unwrap()
700        };
701        // First output is after the initial delay
702        assert_snapshot!(update(crate::progress::INITIAL_DELAY - Duration::from_millis(1), 1), @"");
703        assert_snapshot!(update(Duration::from_millis(1), 10), @"\u{1b}[?25l\r 10% [█▊                ]\u{1b}[K");
704        // No updates for the next 30 milliseconds
705        assert_snapshot!(update(Duration::from_millis(10), 11), @"");
706        assert_snapshot!(update(Duration::from_millis(10), 12), @"");
707        assert_snapshot!(update(Duration::from_millis(10), 13), @"");
708        // We get an update now that we go over the threshold
709        assert_snapshot!(update(Duration::from_millis(100), 30), @"\r 30% [█████▍            ]\u{1b}[K");
710        // Even though we went over by quite a bit, the new threshold is relative to the
711        // previous output, so we don't get an update here
712        assert_snapshot!(update(Duration::from_millis(30), 40), @"");
713    }
714}