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::Read as _;
20use std::io::Write as _;
21use std::iter;
22use std::mem;
23use std::path::Path;
24use std::path::PathBuf;
25use std::process::Stdio;
26use std::time::Duration;
27use std::time::Instant;
28
29use crossterm::terminal::Clear;
30use crossterm::terminal::ClearType;
31use indoc::writedoc;
32use itertools::Itertools as _;
33#[cfg(feature = "git2")]
34use jj_lib::config::ConfigGetResultExt as _;
35use jj_lib::fmt_util::binary_prefix;
36use jj_lib::git;
37use jj_lib::git::FailedRefExportReason;
38use jj_lib::git::GitExportStats;
39use jj_lib::git::GitImportStats;
40use jj_lib::git::GitRefKind;
41use jj_lib::op_store::RefTarget;
42use jj_lib::op_store::RemoteRef;
43use jj_lib::ref_name::RemoteRefSymbol;
44use jj_lib::repo::ReadonlyRepo;
45use jj_lib::repo::Repo;
46#[cfg(feature = "git2")]
47use jj_lib::settings::UserSettings;
48use jj_lib::workspace::Workspace;
49use unicode_width::UnicodeWidthStr as _;
50
51use crate::cleanup_guard::CleanupGuard;
52use crate::command_error::cli_error;
53use crate::command_error::user_error;
54use crate::command_error::CommandError;
55use crate::formatter::Formatter;
56use crate::ui::ProgressOutput;
57use crate::ui::Ui;
58
59pub fn is_colocated_git_workspace(workspace: &Workspace, repo: &ReadonlyRepo) -> bool {
60    let Ok(git_backend) = git::get_git_backend(repo.store()) else {
61        return false;
62    };
63    let Some(git_workdir) = git_backend.git_workdir() else {
64        return false; // Bare repository
65    };
66    if git_workdir == workspace.workspace_root() {
67        return true;
68    }
69    // Colocated workspace should have ".git" directory, file, or symlink. Compare
70    // its parent as the git_workdir might be resolved from the real ".git" path.
71    let Ok(dot_git_path) = dunce::canonicalize(workspace.workspace_root().join(".git")) else {
72        return false;
73    };
74    dunce::canonicalize(git_workdir).ok().as_deref() == dot_git_path.parent()
75}
76
77/// Parses user-specified remote URL or path to absolute form.
78pub fn absolute_git_url(cwd: &Path, source: &str) -> Result<String, CommandError> {
79    // Git appears to turn URL-like source to absolute path if local git directory
80    // exits, and fails because '$PWD/https' is unsupported protocol. Since it would
81    // be tedious to copy the exact git (or libgit2) behavior, we simply let gix
82    // parse the input as URL, rcp-like, or local path.
83    let mut url = gix::url::parse(source.as_ref()).map_err(cli_error)?;
84    url.canonicalize(cwd).map_err(user_error)?;
85    // As of gix 0.68.0, the canonicalized path uses platform-native directory
86    // separator, which isn't compatible with libgit2 on Windows.
87    if url.scheme == gix::url::Scheme::File {
88        url.path = gix::path::to_unix_separators_on_windows(mem::take(&mut url.path)).into_owned();
89    }
90    // It's less likely that cwd isn't utf-8, so just fall back to original source.
91    Ok(String::from_utf8(url.to_bstring().into()).unwrap_or_else(|_| source.to_owned()))
92}
93
94fn terminal_get_username(ui: &Ui, url: &str) -> Option<String> {
95    ui.prompt(&format!("Username for {url}")).ok()
96}
97
98fn terminal_get_pw(ui: &Ui, url: &str) -> Option<String> {
99    ui.prompt_password(&format!("Passphrase for {url}")).ok()
100}
101
102fn pinentry_get_pw(url: &str) -> Option<String> {
103    // https://www.gnupg.org/documentation/manuals/assuan/Server-responses.html#Server-responses
104    fn decode_assuan_data(encoded: &str) -> Option<String> {
105        let encoded = encoded.as_bytes();
106        let mut decoded = Vec::with_capacity(encoded.len());
107        let mut i = 0;
108        while i < encoded.len() {
109            if encoded[i] != b'%' {
110                decoded.push(encoded[i]);
111                i += 1;
112                continue;
113            }
114            i += 1;
115            let byte =
116                u8::from_str_radix(std::str::from_utf8(encoded.get(i..i + 2)?).ok()?, 16).ok()?;
117            decoded.push(byte);
118            i += 2;
119        }
120        String::from_utf8(decoded).ok()
121    }
122
123    let mut pinentry = std::process::Command::new("pinentry")
124        .stdin(Stdio::piped())
125        .stdout(Stdio::piped())
126        .spawn()
127        .ok()?;
128    let mut interact = || -> std::io::Result<_> {
129        #[rustfmt::skip]
130        let req = format!(
131            "SETTITLE jj passphrase\n\
132             SETDESC Enter passphrase for {url}\n\
133             SETPROMPT Passphrase:\n\
134             GETPIN\n"
135        );
136        pinentry.stdin.take().unwrap().write_all(req.as_bytes())?;
137        let mut out = String::new();
138        pinentry.stdout.take().unwrap().read_to_string(&mut out)?;
139        Ok(out)
140    };
141    let maybe_out = interact();
142    _ = pinentry.wait();
143    for line in maybe_out.ok()?.split('\n') {
144        if !line.starts_with("D ") {
145            continue;
146        }
147        let (_, encoded) = line.split_at(2);
148        return decode_assuan_data(encoded);
149    }
150    None
151}
152
153#[tracing::instrument]
154fn get_ssh_keys(_username: &str) -> Vec<PathBuf> {
155    let mut paths = vec![];
156    if let Ok(home_dir) = etcetera::home_dir() {
157        let ssh_dir = home_dir.join(".ssh");
158        for filename in ["id_ed25519_sk", "id_ed25519", "id_rsa"] {
159            let key_path = ssh_dir.join(filename);
160            if key_path.is_file() {
161                tracing::info!(path = ?key_path, "found ssh key");
162                paths.push(key_path);
163            }
164        }
165    }
166    if paths.is_empty() {
167        tracing::info!("no ssh key found");
168    }
169    paths
170}
171
172// Based on Git's implementation: https://github.com/git/git/blob/43072b4ca132437f21975ac6acc6b72dc22fd398/sideband.c#L178
173pub struct GitSidebandProgressMessageWriter {
174    display_prefix: &'static [u8],
175    suffix: &'static [u8],
176    scratch: Vec<u8>,
177}
178
179impl GitSidebandProgressMessageWriter {
180    pub fn new(ui: &Ui) -> Self {
181        let is_terminal = ui.use_progress_indicator();
182
183        GitSidebandProgressMessageWriter {
184            display_prefix: "remote: ".as_bytes(),
185            suffix: if is_terminal { "\x1B[K" } else { "        " }.as_bytes(),
186            scratch: Vec::new(),
187        }
188    }
189
190    pub fn write(&mut self, ui: &Ui, progress_message: &[u8]) -> std::io::Result<()> {
191        let mut index = 0;
192        // Append a suffix to each nonempty line to clear the end of the screen line.
193        loop {
194            let Some(i) = progress_message[index..]
195                .iter()
196                .position(|&c| c == b'\r' || c == b'\n')
197                .map(|i| index + i)
198            else {
199                break;
200            };
201            let line_length = i - index;
202
203            // For messages sent across the packet boundary, there would be a nonempty
204            // "scratch" buffer from last call of this function, and there may be a leading
205            // CR/LF in this message. For this case we should add a clear-to-eol suffix to
206            // clean leftover letters we previously have written on the same line.
207            if !self.scratch.is_empty() && line_length == 0 {
208                self.scratch.extend_from_slice(self.suffix);
209            }
210
211            if self.scratch.is_empty() {
212                self.scratch.extend_from_slice(self.display_prefix);
213            }
214
215            // Do not add the clear-to-eol suffix to empty lines:
216            // For progress reporting we may receive a bunch of percentage updates
217            // followed by '\r' to remain on the same line, and at the end receive a single
218            // '\n' to move to the next line. We should preserve the final
219            // status report line by not appending clear-to-eol suffix to this single line
220            // break.
221            if line_length > 0 {
222                self.scratch.extend_from_slice(&progress_message[index..i]);
223                self.scratch.extend_from_slice(self.suffix);
224            }
225            self.scratch.extend_from_slice(&progress_message[i..i + 1]);
226
227            ui.status().write_all(&self.scratch)?;
228            self.scratch.clear();
229
230            index = i + 1;
231        }
232
233        // Add leftover message to "scratch" buffer to be printed in next call.
234        if index < progress_message.len() {
235            if self.scratch.is_empty() {
236                self.scratch.extend_from_slice(self.display_prefix);
237            }
238            self.scratch.extend_from_slice(&progress_message[index..]);
239        }
240
241        Ok(())
242    }
243
244    pub fn flush(&mut self, ui: &Ui) -> std::io::Result<()> {
245        if !self.scratch.is_empty() {
246            self.scratch.push(b'\n');
247            ui.status().write_all(&self.scratch)?;
248            self.scratch.clear();
249        }
250
251        Ok(())
252    }
253}
254
255pub fn with_remote_git_callbacks<T>(ui: &Ui, f: impl FnOnce(git::RemoteCallbacks<'_>) -> T) -> T {
256    let mut callbacks = git::RemoteCallbacks::default();
257
258    let mut progress_callback;
259    if let Some(mut output) = ui.progress_output() {
260        let mut progress = Progress::new(Instant::now());
261        progress_callback = move |x: &git::Progress| {
262            _ = progress.update(Instant::now(), x, &mut output);
263        };
264        callbacks.progress = Some(&mut progress_callback);
265    }
266
267    let mut sideband_progress_writer = GitSidebandProgressMessageWriter::new(ui);
268    let mut sideband_progress_callback = |progress_message: &[u8]| {
269        _ = sideband_progress_writer.write(ui, progress_message);
270    };
271    callbacks.sideband_progress = Some(&mut sideband_progress_callback);
272
273    let mut get_ssh_keys = get_ssh_keys; // Coerce to unit fn type
274    callbacks.get_ssh_keys = Some(&mut get_ssh_keys);
275    let mut get_pw =
276        |url: &str, _username: &str| pinentry_get_pw(url).or_else(|| terminal_get_pw(ui, url));
277    callbacks.get_password = Some(&mut get_pw);
278    let mut get_user_pw =
279        |url: &str| Some((terminal_get_username(ui, url)?, terminal_get_pw(ui, url)?));
280    callbacks.get_username_password = Some(&mut get_user_pw);
281
282    let result = f(callbacks);
283    _ = sideband_progress_writer.flush(ui);
284    result
285}
286
287pub fn print_git_import_stats(
288    ui: &Ui,
289    repo: &dyn Repo,
290    stats: &GitImportStats,
291    show_ref_stats: bool,
292) -> Result<(), CommandError> {
293    let Some(mut formatter) = ui.status_formatter() else {
294        return Ok(());
295    };
296    if show_ref_stats {
297        let refs_stats = [
298            (GitRefKind::Bookmark, &stats.changed_remote_bookmarks),
299            (GitRefKind::Tag, &stats.changed_remote_tags),
300        ]
301        .into_iter()
302        .flat_map(|(kind, changes)| {
303            changes
304                .iter()
305                .map(move |(symbol, (remote_ref, ref_target))| {
306                    RefStatus::new(kind, symbol.as_ref(), remote_ref, ref_target, repo)
307                })
308        })
309        .collect_vec();
310
311        let has_both_ref_kinds =
312            !stats.changed_remote_bookmarks.is_empty() && !stats.changed_remote_tags.is_empty();
313        let max_width = refs_stats.iter().map(|x| x.symbol.width()).max();
314        if let Some(max_width) = max_width {
315            for status in refs_stats {
316                status.output(max_width, has_both_ref_kinds, &mut *formatter)?;
317            }
318        }
319    }
320
321    if !stats.abandoned_commits.is_empty() {
322        writeln!(
323            formatter,
324            "Abandoned {} commits that are no longer reachable.",
325            stats.abandoned_commits.len()
326        )?;
327    }
328
329    if !stats.failed_ref_names.is_empty() {
330        writeln!(ui.warning_default(), "Failed to import some Git refs:")?;
331        let mut formatter = ui.stderr_formatter();
332        for name in &stats.failed_ref_names {
333            write!(formatter, "  ")?;
334            write!(formatter.labeled("git_ref"), "{name}")?;
335            writeln!(formatter)?;
336        }
337    }
338    if stats
339        .failed_ref_names
340        .iter()
341        .any(|name| name.starts_with(git::RESERVED_REMOTE_REF_NAMESPACE.as_bytes()))
342    {
343        writedoc!(
344            ui.hint_default(),
345            "
346            Git remote named '{name}' is reserved for local Git repository.
347            Use `jj git remote rename` to give a different name.
348            ",
349            name = git::REMOTE_NAME_FOR_LOCAL_GIT_REPO.as_symbol(),
350        )?;
351    }
352
353    Ok(())
354}
355
356pub struct Progress {
357    next_print: Instant,
358    rate: RateEstimate,
359    buffer: String,
360    guard: Option<CleanupGuard>,
361}
362
363impl Progress {
364    pub fn new(now: Instant) -> Self {
365        Self {
366            next_print: now + crate::progress::INITIAL_DELAY,
367            rate: RateEstimate::new(),
368            buffer: String::new(),
369            guard: None,
370        }
371    }
372
373    pub fn update<W: std::io::Write>(
374        &mut self,
375        now: Instant,
376        progress: &git::Progress,
377        output: &mut ProgressOutput<W>,
378    ) -> io::Result<()> {
379        use std::fmt::Write as _;
380
381        if progress.overall == 1.0 {
382            write!(output, "\r{}", Clear(ClearType::CurrentLine))?;
383            output.flush()?;
384            return Ok(());
385        }
386
387        let rate = progress
388            .bytes_downloaded
389            .and_then(|x| self.rate.update(now, x));
390        if now < self.next_print {
391            return Ok(());
392        }
393        self.next_print = now + Duration::from_secs(1) / crate::progress::UPDATE_HZ;
394        if self.guard.is_none() {
395            let guard = output.output_guard(crossterm::cursor::Show.to_string());
396            let guard = CleanupGuard::new(move || {
397                drop(guard);
398            });
399            _ = write!(output, "{}", crossterm::cursor::Hide);
400            self.guard = Some(guard);
401        }
402
403        self.buffer.clear();
404        // Overwrite the current local or sideband progress line if any.
405        self.buffer.push('\r');
406        let control_chars = self.buffer.len();
407        write!(self.buffer, "{: >3.0}% ", 100.0 * progress.overall).unwrap();
408        if let Some(total) = progress.bytes_downloaded {
409            let (scaled, prefix) = binary_prefix(total as f32);
410            write!(self.buffer, "{scaled: >5.1} {prefix}B ").unwrap();
411        }
412        if let Some(estimate) = rate {
413            let (scaled, prefix) = binary_prefix(estimate);
414            write!(self.buffer, "at {scaled: >5.1} {prefix}B/s ").unwrap();
415        }
416
417        let bar_width = output
418            .term_width()
419            .map(usize::from)
420            .unwrap_or(0)
421            .saturating_sub(self.buffer.len() - control_chars + 2);
422        self.buffer.push('[');
423        draw_progress(progress.overall, &mut self.buffer, bar_width);
424        self.buffer.push(']');
425
426        write!(self.buffer, "{}", Clear(ClearType::UntilNewLine)).unwrap();
427        // Move cursor back to the first column so the next sideband message
428        // will overwrite the current progress.
429        self.buffer.push('\r');
430        write!(output, "{}", self.buffer)?;
431        output.flush()?;
432        Ok(())
433    }
434}
435
436fn draw_progress(progress: f32, buffer: &mut String, width: usize) {
437    const CHARS: [char; 9] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
438    const RESOLUTION: usize = CHARS.len() - 1;
439    let ticks = (width as f32 * progress.clamp(0.0, 1.0) * RESOLUTION as f32).round() as usize;
440    let whole = ticks / RESOLUTION;
441    for _ in 0..whole {
442        buffer.push(CHARS[CHARS.len() - 1]);
443    }
444    if whole < width {
445        let fraction = ticks % RESOLUTION;
446        buffer.push(CHARS[fraction]);
447    }
448    for _ in (whole + 1)..width {
449        buffer.push(CHARS[0]);
450    }
451}
452
453struct RateEstimate {
454    state: Option<RateEstimateState>,
455}
456
457impl RateEstimate {
458    pub fn new() -> Self {
459        RateEstimate { state: None }
460    }
461
462    /// Compute smoothed rate from an update
463    pub fn update(&mut self, now: Instant, total: u64) -> Option<f32> {
464        if let Some(ref mut state) = self.state {
465            return Some(state.update(now, total));
466        }
467
468        self.state = Some(RateEstimateState {
469            total,
470            avg_rate: None,
471            last_sample: now,
472        });
473        None
474    }
475}
476
477struct RateEstimateState {
478    total: u64,
479    avg_rate: Option<f32>,
480    last_sample: Instant,
481}
482
483impl RateEstimateState {
484    fn update(&mut self, now: Instant, total: u64) -> f32 {
485        let delta = total - self.total;
486        self.total = total;
487        let dt = now - self.last_sample;
488        self.last_sample = now;
489        let sample = delta as f32 / dt.as_secs_f32();
490        match self.avg_rate {
491            None => *self.avg_rate.insert(sample),
492            Some(ref mut avg_rate) => {
493                // From Algorithms for Unevenly Spaced Time Series: Moving
494                // Averages and Other Rolling Operators (Andreas Eckner, 2019)
495                const TIME_WINDOW: f32 = 2.0;
496                let alpha = 1.0 - (-dt.as_secs_f32() / TIME_WINDOW).exp();
497                *avg_rate += alpha * (sample - *avg_rate);
498                *avg_rate
499            }
500        }
501    }
502}
503
504struct RefStatus {
505    ref_kind: GitRefKind,
506    symbol: String,
507    tracking_status: TrackingStatus,
508    import_status: ImportStatus,
509}
510
511impl RefStatus {
512    fn new(
513        ref_kind: GitRefKind,
514        symbol: RemoteRefSymbol<'_>,
515        remote_ref: &RemoteRef,
516        ref_target: &RefTarget,
517        repo: &dyn Repo,
518    ) -> Self {
519        let tracking_status = match ref_kind {
520            GitRefKind::Bookmark => {
521                if repo.view().get_remote_bookmark(symbol).is_tracked() {
522                    TrackingStatus::Tracked
523                } else {
524                    TrackingStatus::Untracked
525                }
526            }
527            GitRefKind::Tag => TrackingStatus::NotApplicable,
528        };
529
530        let import_status = match (remote_ref.target.is_absent(), ref_target.is_absent()) {
531            (true, false) => ImportStatus::New,
532            (false, true) => ImportStatus::Deleted,
533            _ => ImportStatus::Updated,
534        };
535
536        Self {
537            symbol: symbol.to_string(),
538            tracking_status,
539            import_status,
540            ref_kind,
541        }
542    }
543
544    fn output(
545        &self,
546        max_symbol_width: usize,
547        has_both_ref_kinds: bool,
548        out: &mut dyn Formatter,
549    ) -> std::io::Result<()> {
550        let tracking_status = match self.tracking_status {
551            TrackingStatus::Tracked => "tracked",
552            TrackingStatus::Untracked => "untracked",
553            TrackingStatus::NotApplicable => "",
554        };
555
556        let import_status = match self.import_status {
557            ImportStatus::New => "new",
558            ImportStatus::Deleted => "deleted",
559            ImportStatus::Updated => "updated",
560        };
561
562        let symbol_width = self.symbol.width();
563        let pad_width = max_symbol_width.saturating_sub(symbol_width);
564        let padded_symbol = format!("{}{:>pad_width$}", self.symbol, "", pad_width = pad_width);
565
566        let ref_kind = match self.ref_kind {
567            GitRefKind::Bookmark => "bookmark: ",
568            GitRefKind::Tag if !has_both_ref_kinds => "tag: ",
569            GitRefKind::Tag => "tag:    ",
570        };
571
572        write!(out, "{ref_kind}")?;
573        write!(out.labeled("bookmark"), "{padded_symbol}")?;
574        writeln!(out, " [{import_status}] {tracking_status}")
575    }
576}
577
578enum TrackingStatus {
579    Tracked,
580    Untracked,
581    NotApplicable, // for tags
582}
583
584enum ImportStatus {
585    New,
586    Deleted,
587    Updated,
588}
589
590pub fn print_git_export_stats(ui: &Ui, stats: &GitExportStats) -> Result<(), std::io::Error> {
591    if !stats.failed_bookmarks.is_empty() {
592        writeln!(ui.warning_default(), "Failed to export some bookmarks:")?;
593        let mut formatter = ui.stderr_formatter();
594        for (symbol, reason) in &stats.failed_bookmarks {
595            write!(formatter, "  ")?;
596            write!(formatter.labeled("bookmark"), "{symbol}")?;
597            for err in iter::successors(Some(reason as &dyn error::Error), |err| err.source()) {
598                write!(formatter, ": {err}")?;
599            }
600            writeln!(formatter)?;
601        }
602        drop(formatter);
603        if stats
604            .failed_bookmarks
605            .iter()
606            .any(|(_, reason)| matches!(reason, FailedRefExportReason::FailedToSet(_)))
607        {
608            writeln!(
609                ui.hint_default(),
610                r#"Git doesn't allow a branch name that looks like a parent directory of
611another (e.g. `foo` and `foo/bar`). Try to rename the bookmarks that failed to
612export or their "parent" bookmarks."#,
613            )?;
614        }
615    }
616    Ok(())
617}
618
619#[cfg(feature = "git2")]
620pub fn print_git2_deprecation_warning(
621    ui: &Ui,
622    settings: &UserSettings,
623) -> Result<(), CommandError> {
624    if !settings.git_settings()?.subprocess
625        && !settings
626            .get("debug.suppress-git2-deprecation-warning")
627            .optional()?
628            .unwrap_or(false)
629    {
630        writeln!(
631            ui.warning_default(),
632            "`git.subprocess = false` will be removed in 0.30; please report any issues you have \
633             with the default.",
634        )?;
635    }
636    Ok(())
637}
638
639#[cfg(test)]
640mod tests {
641    use std::path::MAIN_SEPARATOR;
642
643    use insta::assert_snapshot;
644
645    use super::*;
646
647    #[test]
648    fn test_absolute_git_url() {
649        // gix::Url::canonicalize() works even if the path doesn't exist.
650        // However, we need to ensure that no symlinks exist at the test paths.
651        let temp_dir = testutils::new_temp_dir();
652        let cwd = dunce::canonicalize(temp_dir.path()).unwrap();
653        let cwd_slash = cwd.to_str().unwrap().replace(MAIN_SEPARATOR, "/");
654
655        // Local path
656        assert_eq!(
657            absolute_git_url(&cwd, "foo").unwrap(),
658            format!("{cwd_slash}/foo")
659        );
660        assert_eq!(
661            absolute_git_url(&cwd, r"foo\bar").unwrap(),
662            if cfg!(windows) {
663                format!("{cwd_slash}/foo/bar")
664            } else {
665                format!(r"{cwd_slash}/foo\bar")
666            }
667        );
668        assert_eq!(
669            absolute_git_url(&cwd.join("bar"), &format!("{cwd_slash}/foo")).unwrap(),
670            format!("{cwd_slash}/foo")
671        );
672
673        // rcp-like
674        assert_eq!(
675            absolute_git_url(&cwd, "git@example.org:foo/bar.git").unwrap(),
676            "git@example.org:foo/bar.git"
677        );
678        // URL
679        assert_eq!(
680            absolute_git_url(&cwd, "https://example.org/foo.git").unwrap(),
681            "https://example.org/foo.git"
682        );
683        // Custom scheme isn't an error
684        assert_eq!(
685            absolute_git_url(&cwd, "custom://example.org/foo.git").unwrap(),
686            "custom://example.org/foo.git"
687        );
688        // Password shouldn't be redacted (gix::Url::to_string() would do)
689        assert_eq!(
690            absolute_git_url(&cwd, "https://user:pass@example.org/").unwrap(),
691            "https://user:pass@example.org/"
692        );
693    }
694
695    #[test]
696    fn test_bar() {
697        let mut buf = String::new();
698        draw_progress(0.0, &mut buf, 10);
699        assert_eq!(buf, "          ");
700        buf.clear();
701        draw_progress(1.0, &mut buf, 10);
702        assert_eq!(buf, "██████████");
703        buf.clear();
704        draw_progress(0.5, &mut buf, 10);
705        assert_eq!(buf, "█████     ");
706        buf.clear();
707        draw_progress(0.54, &mut buf, 10);
708        assert_eq!(buf, "█████▍    ");
709        buf.clear();
710    }
711
712    #[test]
713    fn test_update() {
714        let start = Instant::now();
715        let mut progress = Progress::new(start);
716        let mut current_time = start;
717        let mut update = |duration, overall| -> String {
718            current_time += duration;
719            let mut buf = vec![];
720            let mut output = ProgressOutput::for_test(&mut buf, 25);
721            progress
722                .update(
723                    current_time,
724                    &jj_lib::git::Progress {
725                        bytes_downloaded: None,
726                        overall,
727                    },
728                    &mut output,
729                )
730                .unwrap();
731            String::from_utf8(buf).unwrap()
732        };
733        // First output is after the initial delay
734        assert_snapshot!(update(crate::progress::INITIAL_DELAY - Duration::from_millis(1), 0.1), @"");
735        assert_snapshot!(update(Duration::from_millis(1), 0.10), @"\u{1b}[?25l\r 10% [█▊                ]\u{1b}[K");
736        // No updates for the next 30 milliseconds
737        assert_snapshot!(update(Duration::from_millis(10), 0.11), @"");
738        assert_snapshot!(update(Duration::from_millis(10), 0.12), @"");
739        assert_snapshot!(update(Duration::from_millis(10), 0.13), @"");
740        // We get an update now that we go over the threshold
741        assert_snapshot!(update(Duration::from_millis(100), 0.30), @" 30% [█████▍            ]");
742        // Even though we went over by quite a bit, the new threshold is relative to the
743        // previous output, so we don't get an update here
744        assert_snapshot!(update(Duration::from_millis(30), 0.40), @"");
745    }
746}