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