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