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    if !stats.rewritten_commit_ids.is_empty() {
263        writeln!(
264            formatter,
265            "Updated {} rewritten commits.",
266            stats.rewritten_commit_ids.len()
267        )?;
268    }
269
270    Ok(())
271}
272
273fn print_failed_git_import(ui: &Ui, stats: &GitImportStats) -> Result<(), CommandError> {
274    if !stats.failed_ref_names.is_empty() {
275        writeln!(ui.warning_default(), "Failed to import some Git refs:")?;
276        let mut formatter = ui.stderr_formatter();
277        for name in &stats.failed_ref_names {
278            write!(formatter, "  ")?;
279            write!(formatter.labeled("git_ref"), "{name}")?;
280            writeln!(formatter)?;
281        }
282    }
283    if stats
284        .failed_ref_names
285        .iter()
286        .any(|name| name.starts_with(git::RESERVED_REMOTE_REF_NAMESPACE.as_bytes()))
287    {
288        writedoc!(
289            ui.hint_default(),
290            "
291            Git remote named '{name}' is reserved for local Git repository.
292            Use `jj git remote rename` to give a different name.
293            ",
294            name = git::REMOTE_NAME_FOR_LOCAL_GIT_REPO.as_symbol(),
295        )?;
296    }
297    Ok(())
298}
299
300/// Prints only the summary of git import stats (abandoned count, failed refs).
301/// Use this when a WorkspaceCommandTransaction is not available.
302pub fn print_git_import_stats_summary(ui: &Ui, stats: &GitImportStats) -> Result<(), CommandError> {
303    if let Some(mut formatter) = ui.status_formatter() {
304        if !stats.abandoned_commits.is_empty() {
305            writeln!(
306                formatter,
307                "Abandoned {} commits that are no longer reachable.",
308                stats.abandoned_commits.len()
309            )?;
310        }
311        if !stats.rewritten_commit_ids.is_empty() {
312            writeln!(
313                formatter,
314                "Updated {} rewritten commits.",
315                stats.rewritten_commit_ids.len()
316            )?;
317        }
318    }
319    print_failed_git_import(ui, stats)?;
320    Ok(())
321}
322
323pub struct Progress {
324    next_print: Instant,
325    buffer: String,
326    guard: Option<CleanupGuard>,
327}
328
329impl Progress {
330    pub fn new(now: Instant) -> Self {
331        Self {
332            next_print: now + crate::progress::INITIAL_DELAY,
333            buffer: String::new(),
334            guard: None,
335        }
336    }
337
338    pub fn update<W: std::io::Write>(
339        &mut self,
340        now: Instant,
341        progress: &GitProgress,
342        output: &mut ProgressOutput<W>,
343    ) -> io::Result<()> {
344        use std::fmt::Write as _;
345
346        if progress.overall() == 1.0 {
347            write!(output, "\r{}", Clear(ClearType::CurrentLine))?;
348            output.flush()?;
349            return Ok(());
350        }
351
352        if now < self.next_print {
353            return Ok(());
354        }
355        self.next_print = now + Duration::from_secs(1) / crate::progress::UPDATE_HZ;
356        if self.guard.is_none() {
357            let guard = output.output_guard(crossterm::cursor::Show.to_string());
358            let guard = CleanupGuard::new(move || {
359                drop(guard);
360            });
361            write!(output, "{}", crossterm::cursor::Hide).ok();
362            self.guard = Some(guard);
363        }
364
365        self.buffer.clear();
366        // Overwrite the current local or sideband progress line if any.
367        self.buffer.push('\r');
368        let control_chars = self.buffer.len();
369        write!(self.buffer, "{: >3.0}% ", 100.0 * progress.overall()).unwrap();
370
371        let bar_width = output
372            .term_width()
373            .map(usize::from)
374            .unwrap_or(0)
375            .saturating_sub(self.buffer.len() - control_chars + 2);
376        self.buffer.push('[');
377        draw_progress(progress.overall(), &mut self.buffer, bar_width);
378        self.buffer.push(']');
379
380        write!(self.buffer, "{}", Clear(ClearType::UntilNewLine)).unwrap();
381        // Move cursor back to the first column so the next sideband message
382        // will overwrite the current progress.
383        self.buffer.push('\r');
384        write!(output, "{}", self.buffer)?;
385        output.flush()?;
386        Ok(())
387    }
388}
389
390fn draw_progress(progress: f32, buffer: &mut String, width: usize) {
391    const CHARS: [char; 9] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
392    const RESOLUTION: usize = CHARS.len() - 1;
393    let ticks = (width as f32 * progress.clamp(0.0, 1.0) * RESOLUTION as f32).round() as usize;
394    let whole = ticks / RESOLUTION;
395    for _ in 0..whole {
396        buffer.push(CHARS[CHARS.len() - 1]);
397    }
398    if whole < width {
399        let fraction = ticks % RESOLUTION;
400        buffer.push(CHARS[fraction]);
401    }
402    for _ in (whole + 1)..width {
403        buffer.push(CHARS[0]);
404    }
405}
406
407struct RefStatus {
408    ref_kind: GitRefKind,
409    symbol: String,
410    tracking_status: TrackingStatus,
411    import_status: ImportStatus,
412}
413
414impl RefStatus {
415    fn new(
416        ref_kind: GitRefKind,
417        symbol: RemoteRefSymbol<'_>,
418        remote_ref: &RemoteRef,
419        ref_target: &RefTarget,
420        repo: &dyn Repo,
421    ) -> Self {
422        let tracking_status = match ref_kind {
423            GitRefKind::Bookmark => {
424                if repo.view().get_remote_bookmark(symbol).is_tracked() {
425                    TrackingStatus::Tracked
426                } else {
427                    TrackingStatus::Untracked
428                }
429            }
430            GitRefKind::Tag => TrackingStatus::NotApplicable,
431        };
432
433        let import_status = match (remote_ref.target.is_absent(), ref_target.is_absent()) {
434            (true, false) => ImportStatus::New,
435            (false, true) => ImportStatus::Deleted,
436            _ => ImportStatus::Updated,
437        };
438
439        Self {
440            symbol: symbol.to_string(),
441            tracking_status,
442            import_status,
443            ref_kind,
444        }
445    }
446
447    fn output(&self, max_symbol_width: usize, out: &mut dyn Formatter) -> std::io::Result<()> {
448        let tracking_status = match self.tracking_status {
449            TrackingStatus::Tracked => "tracked",
450            TrackingStatus::Untracked => "untracked",
451            TrackingStatus::NotApplicable => "",
452        };
453
454        let import_status = match self.import_status {
455            ImportStatus::New => "new",
456            ImportStatus::Deleted => "deleted",
457            ImportStatus::Updated => "updated",
458        };
459
460        let symbol_width = self.symbol.width();
461        let pad_width = max_symbol_width.saturating_sub(symbol_width);
462        let padded_symbol = format!("{}{:>pad_width$}", self.symbol, "", pad_width = pad_width);
463
464        let label = match self.ref_kind {
465            GitRefKind::Bookmark => "bookmark",
466            GitRefKind::Tag => "tag",
467        };
468
469        write!(out, "{label}: ")?;
470        write!(out.labeled(label), "{padded_symbol}")?;
471        writeln!(out, " [{import_status}] {tracking_status}")
472    }
473}
474
475enum TrackingStatus {
476    Tracked,
477    Untracked,
478    NotApplicable, // for tags
479}
480
481enum ImportStatus {
482    New,
483    Deleted,
484    Updated,
485}
486
487pub fn print_git_export_stats(ui: &Ui, stats: &GitExportStats) -> Result<(), std::io::Error> {
488    if !stats.failed_bookmarks.is_empty() {
489        writeln!(ui.warning_default(), "Failed to export some bookmarks:")?;
490        let mut formatter = ui.stderr_formatter();
491        for (symbol, reason) in &stats.failed_bookmarks {
492            write!(formatter, "  ")?;
493            write!(formatter.labeled("bookmark"), "{symbol}")?;
494            for err in iter::successors(Some(reason as &dyn error::Error), |err| err.source()) {
495                write!(formatter, ": {err}")?;
496            }
497            writeln!(formatter)?;
498        }
499    }
500    if !stats.failed_tags.is_empty() {
501        writeln!(ui.warning_default(), "Failed to export some tags:")?;
502        let mut formatter = ui.stderr_formatter();
503        for (symbol, reason) in &stats.failed_tags {
504            write!(formatter, "  ")?;
505            write!(formatter.labeled("tag"), "{symbol}")?;
506            for err in iter::successors(Some(reason as &dyn error::Error), |err| err.source()) {
507                write!(formatter, ": {err}")?;
508            }
509            writeln!(formatter)?;
510        }
511    }
512    if itertools::chain(&stats.failed_bookmarks, &stats.failed_tags)
513        .any(|(_, reason)| matches!(reason, FailedRefExportReason::FailedToSet(_)))
514    {
515        writedoc!(
516            ui.hint_default(),
517            r#"
518            Git doesn't allow a branch/tag name that looks like a parent directory of
519            another (e.g. `foo` and `foo/bar`). Try to rename the bookmarks/tags that failed
520            to export or their "parent" bookmarks/tags.
521            "#,
522        )?;
523    }
524    Ok(())
525}
526
527pub fn print_push_stats(ui: &Ui, stats: &GitPushStats) -> io::Result<()> {
528    if !stats.rejected.is_empty() {
529        writeln!(
530            ui.warning_default(),
531            "The following references unexpectedly moved on the remote:"
532        )?;
533        let mut formatter = ui.stderr_formatter();
534        for (reference, reason) in &stats.rejected {
535            write!(formatter, "  ")?;
536            write!(formatter.labeled("git_ref"), "{}", reference.as_symbol())?;
537            if let Some(r) = reason {
538                write!(formatter, " (reason: {r})")?;
539            }
540            writeln!(formatter)?;
541        }
542        drop(formatter);
543        writeln!(
544            ui.hint_default(),
545            "Try fetching from the remote, then make the bookmark point to where you want it to \
546             be, and push again.",
547        )?;
548    }
549    if !stats.remote_rejected.is_empty() {
550        writeln!(
551            ui.warning_default(),
552            "The remote rejected the following updates:"
553        )?;
554        let mut formatter = ui.stderr_formatter();
555        for (reference, reason) in &stats.remote_rejected {
556            write!(formatter, "  ")?;
557            write!(formatter.labeled("git_ref"), "{}", reference.as_symbol())?;
558            if let Some(r) = reason {
559                write!(formatter, " (reason: {r})")?;
560            }
561            writeln!(formatter)?;
562        }
563        drop(formatter);
564        writeln!(
565            ui.hint_default(),
566            "Try checking if you have permission to push to all the bookmarks."
567        )?;
568    }
569    if !stats.unexported_bookmarks.is_empty() {
570        writeln!(
571            ui.warning_default(),
572            "The following bookmarks couldn't be updated locally:"
573        )?;
574        let mut formatter = ui.stderr_formatter();
575        for (symbol, reason) in &stats.unexported_bookmarks {
576            write!(formatter, "  ")?;
577            write!(formatter.labeled("bookmark"), "{symbol}")?;
578            for err in iter::successors(Some(reason as &dyn error::Error), |err| err.source()) {
579                write!(formatter, ": {err}")?;
580            }
581            writeln!(formatter)?;
582        }
583    }
584    Ok(())
585}
586
587#[cfg(test)]
588mod tests {
589    use std::path::MAIN_SEPARATOR;
590
591    use insta::assert_snapshot;
592
593    use super::*;
594
595    #[test]
596    fn test_absolute_git_url() {
597        // gix::Url::canonicalize() works even if the path doesn't exist.
598        // However, we need to ensure that no symlinks exist at the test paths.
599        let temp_dir = testutils::new_temp_dir();
600        let cwd = dunce::canonicalize(temp_dir.path()).unwrap();
601        let cwd_slash = cwd.to_str().unwrap().replace(MAIN_SEPARATOR, "/");
602
603        // Local path
604        assert_eq!(
605            absolute_git_url(&cwd, "foo").unwrap(),
606            format!("{cwd_slash}/foo")
607        );
608        assert_eq!(
609            absolute_git_url(&cwd, r"foo\bar").unwrap(),
610            if cfg!(windows) {
611                format!("{cwd_slash}/foo/bar")
612            } else {
613                format!(r"{cwd_slash}/foo\bar")
614            }
615        );
616        assert_eq!(
617            absolute_git_url(&cwd.join("bar"), &format!("{cwd_slash}/foo")).unwrap(),
618            format!("{cwd_slash}/foo")
619        );
620
621        // rcp-like
622        assert_eq!(
623            absolute_git_url(&cwd, "git@example.org:foo/bar.git").unwrap(),
624            "git@example.org:foo/bar.git"
625        );
626        // URL
627        assert_eq!(
628            absolute_git_url(&cwd, "https://example.org/foo.git").unwrap(),
629            "https://example.org/foo.git"
630        );
631        // Custom scheme isn't an error
632        assert_eq!(
633            absolute_git_url(&cwd, "custom://example.org/foo.git").unwrap(),
634            "custom://example.org/foo.git"
635        );
636        // Password shouldn't be redacted (gix::Url::to_string() would do)
637        assert_eq!(
638            absolute_git_url(&cwd, "https://user:pass@example.org/").unwrap(),
639            "https://user:pass@example.org/"
640        );
641
642        // %-encoded paths: %20 ' ', %25 '%'
643        assert_eq!(
644            absolute_git_url(&cwd, "https://example.org/%20%25").unwrap(),
645            "https://example.org/%20%25"
646        );
647        // No exact match because "/" isn't an absolute path on Windows
648        assert!(
649            absolute_git_url(&cwd, "file:///%20%25")
650                .unwrap()
651                .ends_with("/%20%25")
652        );
653    }
654
655    #[test]
656    fn test_git_remote_url_to_web() {
657        let to_web = |s| git_remote_url_to_web(&gix::Url::try_from(s).unwrap());
658
659        // SSH URL
660        assert_eq!(
661            to_web("git@github.com:owner/repo"),
662            Some("https://github.com/owner/repo".to_owned())
663        );
664        // HTTPS URL with .git suffix
665        assert_eq!(
666            to_web("https://github.com/owner/repo.git"),
667            Some("https://github.com/owner/repo".to_owned())
668        );
669        // SSH URL with ssh:// scheme
670        assert_eq!(
671            to_web("ssh://git@github.com/owner/repo"),
672            Some("https://github.com/owner/repo".to_owned())
673        );
674        // git:// protocol
675        assert_eq!(
676            to_web("git://github.com/owner/repo.git"),
677            Some("https://github.com/owner/repo".to_owned())
678        );
679        // File URL returns None
680        assert_eq!(to_web("file:///path/to/repo"), None);
681        // Local path returns None
682        assert_eq!(to_web("/path/to/repo"), None);
683    }
684
685    #[test]
686    fn test_bar() {
687        let mut buf = String::new();
688        draw_progress(0.0, &mut buf, 10);
689        assert_eq!(buf, "          ");
690        buf.clear();
691        draw_progress(1.0, &mut buf, 10);
692        assert_eq!(buf, "██████████");
693        buf.clear();
694        draw_progress(0.5, &mut buf, 10);
695        assert_eq!(buf, "█████     ");
696        buf.clear();
697        draw_progress(0.54, &mut buf, 10);
698        assert_eq!(buf, "█████▍    ");
699        buf.clear();
700    }
701
702    #[test]
703    fn test_update() {
704        let start = Instant::now();
705        let mut progress = Progress::new(start);
706        let mut current_time = start;
707        let mut update = |duration, overall: u64| -> String {
708            current_time += duration;
709            let mut buf = vec![];
710            let mut output = ProgressOutput::for_test(&mut buf, 25);
711            progress
712                .update(
713                    current_time,
714                    &GitProgress {
715                        deltas: (overall, 100),
716                        objects: (0, 0),
717                        counted_objects: (0, 0),
718                        compressed_objects: (0, 0),
719                    },
720                    &mut output,
721                )
722                .unwrap();
723            String::from_utf8(buf).unwrap()
724        };
725        // First output is after the initial delay
726        assert_snapshot!(update(crate::progress::INITIAL_DELAY - Duration::from_millis(1), 1), @"");
727        assert_snapshot!(update(Duration::from_millis(1), 10), @"\u{1b}[?25l\r 10% [█▊                ]\u{1b}[K");
728        // No updates for the next 30 milliseconds
729        assert_snapshot!(update(Duration::from_millis(10), 11), @"");
730        assert_snapshot!(update(Duration::from_millis(10), 12), @"");
731        assert_snapshot!(update(Duration::from_millis(10), 13), @"");
732        // We get an update now that we go over the threshold
733        assert_snapshot!(update(Duration::from_millis(100), 30), @"\r 30% [█████▍            ]\u{1b}[K");
734        // Even though we went over by quite a bit, the new threshold is relative to the
735        // previous output, so we don't get an update here
736        assert_snapshot!(update(Duration::from_millis(30), 40), @"");
737    }
738}