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