// Copyright 2024 The Jujutsu Authors//// Licensed under the Apache License, Version 2.0 (the "License");// you may not use this file except in compliance with the License.// You may obtain a copy of the License at//// https://www.apache.org/licenses/LICENSE-2.0//// Unless required by applicable law or agreed to in writing, software// distributed under the License is distributed on an "AS IS" BASIS,// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.// See the License for the specific language governing permissions and// limitations under the License. //! Git utilities shared by various commands. use std::error;use std::io;use std::io::Read as _;use std::io::Write as _;use std::iter;use std::mem;use std::path::Path;use std::path::PathBuf;use std::process::Stdio;use std::time::Duration;use std::time::Instant; use crossterm::terminal::Clear;use crossterm::terminal::ClearType;use indoc::writedoc;use itertools::Itertools as _;use jj_lib::fmt_util::binary_prefix;use jj_lib::git;use jj_lib::git::FailedRefExportReason;use jj_lib::git::GitExportStats;use jj_lib::git::GitImportStats;use jj_lib::git::GitRefKind;use jj_lib::op_store::RefTarget;use jj_lib::op_store::RemoteRef;use jj_lib::ref_name::RemoteRefSymbol;use jj_lib::repo::ReadonlyRepo;use jj_lib::repo::Repo;use jj_lib::workspace::Workspace;use unicode_width::UnicodeWidthStr as _; use crate::cleanup_guard::CleanupGuard;use crate::command_error::CommandError;use crate::command_error::cli_error;use crate::command_error::user_error;use crate::formatter::Formatter;use crate::formatter::FormatterExt as _;use crate::ui::ProgressOutput;use crate::ui::Ui; pub fn is_colocated_git_workspace(workspace: &Workspace, repo: &ReadonlyRepo) -> bool { let Ok(git_backend) = git::get_git_backend(repo.store()) else { return false; }; let Some(git_workdir) = git_backend.git_workdir() else { return false; // Bare repository }; if git_workdir == workspace.workspace_root() { return true; } // Colocated workspace should have ".git" directory, file, or symlink. Compare // its parent as the git_workdir might be resolved from the real ".git" path. let Ok(dot_git_path) = dunce::canonicalize(workspace.workspace_root().join(".git")) else { return false; }; dunce::canonicalize(git_workdir).ok().as_deref() == dot_git_path.parent()} /// Parses user-specified remote URL or path to absolute form.pub fn absolute_git_url(cwd: &Path, source: &str) -> Result<String, CommandError> { // Git appears to turn URL-like source to absolute path if local git directory // exits, and fails because '$PWD/https' is unsupported protocol. Since it would // be tedious to copy the exact git (or libgit2) behavior, we simply let gix // parse the input as URL, rcp-like, or local path. let mut url = gix::url::parse(source.as_ref()).map_err(cli_error)?; url.canonicalize(cwd).map_err(user_error)?; // As of gix 0.68.0, the canonicalized path uses platform-native directory // separator, which isn't compatible with libgit2 on Windows. if url.scheme == gix::url::Scheme::File { url.path = gix::path::to_unix_separators_on_windows(mem::take(&mut url.path)).into_owned(); } // It's less likely that cwd isn't utf-8, so just fall back to original source. Ok(String::from_utf8(url.to_bstring().into()).unwrap_or_else(|_| source.to_owned()))} fn terminal_get_username(ui: &Ui, url: &str) -> Option<String> { ui.prompt(&format!("Username for {url}")).ok()} fn terminal_get_pw(ui: &Ui, url: &str) -> Option<String> { ui.prompt_password(&format!("Passphrase for {url}")).ok()} fn pinentry_get_pw(url: &str) -> Option<String> { // https://www.gnupg.org/documentation/manuals/assuan/Server-responses.html#Server-responses fn decode_assuan_data(encoded: &str) -> Option<String> { let encoded = encoded.as_bytes(); let mut decoded = Vec::with_capacity(encoded.len()); let mut i = 0; while i < encoded.len() { if encoded[i] != b'%' { decoded.push(encoded[i]); i += 1; continue; } i += 1; let byte = u8::from_str_radix(str::from_utf8(encoded.get(i..i + 2)?).ok()?, 16).ok()?; decoded.push(byte); i += 2; } String::from_utf8(decoded).ok() } let mut pinentry = std::process::Command::new("pinentry") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() .ok()?; let mut interact = || -> std::io::Result<_> { #[rustfmt::skip] let req = format!( "SETTITLE jj passphrase\n\ SETDESC Enter passphrase for {url}\n\ SETPROMPT Passphrase:\n\ GETPIN\n" ); pinentry.stdin.take().unwrap().write_all(req.as_bytes())?; let mut out = String::new(); pinentry.stdout.take().unwrap().read_to_string(&mut out)?; Ok(out) }; let maybe_out = interact(); _ = pinentry.wait(); for line in maybe_out.ok()?.split('\n') { if !line.starts_with("D ") { continue; } let (_, encoded) = line.split_at(2); return decode_assuan_data(encoded); } None} #[tracing::instrument]fn get_ssh_keys(_username: &str) -> Vec<PathBuf> { let mut paths = vec![]; if let Ok(home_dir) = etcetera::home_dir() { let ssh_dir = home_dir.join(".ssh"); for filename in ["id_ed25519_sk", "id_ed25519", "id_rsa"] { let key_path = ssh_dir.join(filename); if key_path.is_file() { tracing::info!(path = ?key_path, "found ssh key"); paths.push(key_path); } } } if paths.is_empty() { tracing::info!("no ssh key found"); } paths} // Based on Git's implementation: https://github.com/git/git/blob/43072b4ca132437f21975ac6acc6b72dc22fd398/sideband.c#L178pub struct GitSidebandProgressMessageWriter { display_prefix: &'static [u8], suffix: &'static [u8], scratch: Vec<u8>,} impl GitSidebandProgressMessageWriter { pub fn new(ui: &Ui) -> Self { let is_terminal = ui.use_progress_indicator(); Self { display_prefix: "remote: ".as_bytes(), suffix: if is_terminal { "\x1B[K" } else { " " }.as_bytes(), scratch: Vec::new(), } } pub fn write(&mut self, ui: &Ui, progress_message: &[u8]) -> std::io::Result<()> { let mut index = 0; // Append a suffix to each nonempty line to clear the end of the screen line. loop { let Some(i) = progress_message[index..] .iter() .position(|&c| c == b'\r' || c == b'\n') .map(|i| index + i) else { break; }; let line_length = i - index; // For messages sent across the packet boundary, there would be a nonempty // "scratch" buffer from last call of this function, and there may be a leading // CR/LF in this message. For this case we should add a clear-to-eol suffix to // clean leftover letters we previously have written on the same line. if !self.scratch.is_empty() && line_length == 0 { self.scratch.extend_from_slice(self.suffix); } if self.scratch.is_empty() { self.scratch.extend_from_slice(self.display_prefix); } // Do not add the clear-to-eol suffix to empty lines: // For progress reporting we may receive a bunch of percentage updates // followed by '\r' to remain on the same line, and at the end receive a single // '\n' to move to the next line. We should preserve the final // status report line by not appending clear-to-eol suffix to this single line // break. if line_length > 0 { self.scratch.extend_from_slice(&progress_message[index..i]); self.scratch.extend_from_slice(self.suffix); } self.scratch.extend_from_slice(&progress_message[i..i + 1]); ui.status().write_all(&self.scratch)?; self.scratch.clear(); index = i + 1; } // Add leftover message to "scratch" buffer to be printed in next call. if index < progress_message.len() { if self.scratch.is_empty() { self.scratch.extend_from_slice(self.display_prefix); } self.scratch.extend_from_slice(&progress_message[index..]); } Ok(()) } pub fn flush(&mut self, ui: &Ui) -> std::io::Result<()> { if !self.scratch.is_empty() { self.scratch.push(b'\n'); ui.status().write_all(&self.scratch)?; self.scratch.clear(); } Ok(()) }} pub fn with_remote_git_callbacks<T>(ui: &Ui, f: impl FnOnce(git::RemoteCallbacks<'_>) -> T) -> T { let mut callbacks = git::RemoteCallbacks::default(); let mut progress_callback; if let Some(mut output) = ui.progress_output() { let mut progress = Progress::new(Instant::now()); progress_callback = move |x: &git::Progress| { _ = progress.update(Instant::now(), x, &mut output); }; callbacks.progress = Some(&mut progress_callback); } let mut sideband_progress_writer = GitSidebandProgressMessageWriter::new(ui); let mut sideband_progress_callback = |progress_message: &[u8]| { _ = sideband_progress_writer.write(ui, progress_message); }; callbacks.sideband_progress = Some(&mut sideband_progress_callback); let mut get_ssh_keys = get_ssh_keys; // Coerce to unit fn type callbacks.get_ssh_keys = Some(&mut get_ssh_keys); let mut get_pw = |url: &str, _username: &str| pinentry_get_pw(url).or_else(|| terminal_get_pw(ui, url)); callbacks.get_password = Some(&mut get_pw); let mut get_user_pw = |url: &str| Some((terminal_get_username(ui, url)?, terminal_get_pw(ui, url)?)); callbacks.get_username_password = Some(&mut get_user_pw); let result = f(callbacks); _ = sideband_progress_writer.flush(ui); result} pub fn print_git_import_stats( ui: &Ui, repo: &dyn Repo, stats: &GitImportStats, show_ref_stats: bool,) -> Result<(), CommandError> { let Some(mut formatter) = ui.status_formatter() else { return Ok(()); }; if show_ref_stats { for (kind, changes) in [ (GitRefKind::Bookmark, &stats.changed_remote_bookmarks), (GitRefKind::Tag, &stats.changed_remote_tags), ] { let refs_stats = changes .iter() .map(|(symbol, (remote_ref, ref_target))| { RefStatus::new(kind, symbol.as_ref(), remote_ref, ref_target, repo) }) .collect_vec(); let Some(max_width) = refs_stats.iter().map(|x| x.symbol.width()).max() else { continue; }; for status in refs_stats { status.output(max_width, &mut *formatter)?; } } } if !stats.abandoned_commits.is_empty() { writeln!( formatter, "Abandoned {} commits that are no longer reachable.", stats.abandoned_commits.len() )?; } if !stats.failed_ref_names.is_empty() { writeln!(ui.warning_default(), "Failed to import some Git refs:")?; let mut formatter = ui.stderr_formatter(); for name in &stats.failed_ref_names { write!(formatter, " ")?; write!(formatter.labeled("git_ref"), "{name}")?; writeln!(formatter)?; } } if stats .failed_ref_names .iter() .any(|name| name.starts_with(git::RESERVED_REMOTE_REF_NAMESPACE.as_bytes())) { writedoc!( ui.hint_default(), " Git remote named '{name}' is reserved for local Git repository. Use `jj git remote rename` to give a different name. ", name = git::REMOTE_NAME_FOR_LOCAL_GIT_REPO.as_symbol(), )?; } Ok(())} pub struct Progress { next_print: Instant, rate: RateEstimate, buffer: String, guard: Option<CleanupGuard>,} impl Progress { pub fn new(now: Instant) -> Self { Self { next_print: now + crate::progress::INITIAL_DELAY, rate: RateEstimate::new(), buffer: String::new(), guard: None, } } pub fn update<W: std::io::Write>( &mut self, now: Instant, progress: &git::Progress, output: &mut ProgressOutput<W>, ) -> io::Result<()> { use std::fmt::Write as _; if progress.overall == 1.0 { write!(output, "\r{}", Clear(ClearType::CurrentLine))?; output.flush()?; return Ok(()); } let rate = progress .bytes_downloaded .and_then(|x| self.rate.update(now, x)); if now < self.next_print { return Ok(()); } self.next_print = now + Duration::from_secs(1) / crate::progress::UPDATE_HZ; if self.guard.is_none() { let guard = output.output_guard(crossterm::cursor::Show.to_string()); let guard = CleanupGuard::new(move || { drop(guard); }); _ = write!(output, "{}", crossterm::cursor::Hide); self.guard = Some(guard); } self.buffer.clear(); // Overwrite the current local or sideband progress line if any. self.buffer.push('\r'); let control_chars = self.buffer.len(); write!(self.buffer, "{: >3.0}% ", 100.0 * progress.overall).unwrap(); if let Some(total) = progress.bytes_downloaded { let (scaled, prefix) = binary_prefix(total as f32); write!(self.buffer, "{scaled: >5.1} {prefix}B ").unwrap(); } if let Some(estimate) = rate { let (scaled, prefix) = binary_prefix(estimate); write!(self.buffer, "at {scaled: >5.1} {prefix}B/s ").unwrap(); } let bar_width = output .term_width() .map(usize::from) .unwrap_or(0) .saturating_sub(self.buffer.len() - control_chars + 2); self.buffer.push('['); draw_progress(progress.overall, &mut self.buffer, bar_width); self.buffer.push(']'); write!(self.buffer, "{}", Clear(ClearType::UntilNewLine)).unwrap(); // Move cursor back to the first column so the next sideband message // will overwrite the current progress. self.buffer.push('\r'); write!(output, "{}", self.buffer)?; output.flush()?; Ok(()) }} fn draw_progress(progress: f32, buffer: &mut String, width: usize) { const CHARS: [char; 9] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█']; const RESOLUTION: usize = CHARS.len() - 1; let ticks = (width as f32 * progress.clamp(0.0, 1.0) * RESOLUTION as f32).round() as usize; let whole = ticks / RESOLUTION; for _ in 0..whole { buffer.push(CHARS[CHARS.len() - 1]); } if whole < width { let fraction = ticks % RESOLUTION; buffer.push(CHARS[fraction]); } for _ in (whole + 1)..width { buffer.push(CHARS[0]); }} struct RateEstimate { state: Option<RateEstimateState>,} impl RateEstimate { pub fn new() -> Self { Self { state: None } } /// Compute smoothed rate from an update pub fn update(&mut self, now: Instant, total: u64) -> Option<f32> { if let Some(ref mut state) = self.state { return Some(state.update(now, total)); } self.state = Some(RateEstimateState { total, avg_rate: None, last_sample: now, }); None }} struct RateEstimateState { total: u64, avg_rate: Option<f32>, last_sample: Instant,} impl RateEstimateState { fn update(&mut self, now: Instant, total: u64) -> f32 { let delta = total - self.total; self.total = total; let dt = now - self.last_sample; self.last_sample = now; let sample = delta as f32 / dt.as_secs_f32(); match self.avg_rate { None => *self.avg_rate.insert(sample), Some(ref mut avg_rate) => { // From Algorithms for Unevenly Spaced Time Series: Moving // Averages and Other Rolling Operators (Andreas Eckner, 2019) const TIME_WINDOW: f32 = 2.0; let alpha = 1.0 - (-dt.as_secs_f32() / TIME_WINDOW).exp(); *avg_rate += alpha * (sample - *avg_rate); *avg_rate } } }} struct RefStatus { ref_kind: GitRefKind, symbol: String, tracking_status: TrackingStatus, import_status: ImportStatus,}Lines 1–500 of 722. Load more
// Copyright 2024 The Jujutsu Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //! Git utilities shared by various commands. use std::error; use std::io; use std::io::Read as _; use std::io::Write as _; use std::iter; use std::mem; use std::path::Path; use std::path::PathBuf; use std::process::Stdio; use std::time::Duration; use std::time::Instant; use crossterm::terminal::Clear; use crossterm::terminal::ClearType; use indoc::writedoc; use itertools::Itertools as _; use jj_lib::fmt_util::binary_prefix; use jj_lib::git; use jj_lib::git::FailedRefExportReason; use jj_lib::git::GitExportStats; use jj_lib::git::GitImportStats; use jj_lib::git::GitRefKind; use jj_lib::op_store::RefTarget; use jj_lib::op_store::RemoteRef; use jj_lib::ref_name::RemoteRefSymbol; use jj_lib::repo::ReadonlyRepo; use jj_lib::repo::Repo; use jj_lib::workspace::Workspace; use unicode_width::UnicodeWidthStr as _; use crate::cleanup_guard::CleanupGuard; use crate::command_error::CommandError; use crate::command_error::cli_error; use crate::command_error::user_error; use crate::formatter::Formatter; use crate::formatter::FormatterExt as _; use crate::ui::ProgressOutput; use crate::ui::Ui; pub fn is_colocated_git_workspace(workspace: &Workspace, repo: &ReadonlyRepo) -> bool { let Ok(git_backend) = git::get_git_backend(repo.store()) else { return false; }; let Some(git_workdir) = git_backend.git_workdir() else { return false; // Bare repository }; if git_workdir == workspace.workspace_root() { return true; } // Colocated workspace should have ".git" directory, file, or symlink. Compare // its parent as the git_workdir might be resolved from the real ".git" path. let Ok(dot_git_path) = dunce::canonicalize(workspace.workspace_root().join(".git")) else { return false; }; dunce::canonicalize(git_workdir).ok().as_deref() == dot_git_path.parent() } /// Parses user-specified remote URL or path to absolute form. pub fn absolute_git_url(cwd: &Path, source: &str) -> Result<String, CommandError> { // Git appears to turn URL-like source to absolute path if local git directory // exits, and fails because '$PWD/https' is unsupported protocol. Since it would // be tedious to copy the exact git (or libgit2) behavior, we simply let gix // parse the input as URL, rcp-like, or local path. let mut url = gix::url::parse(source.as_ref()).map_err(cli_error)?; url.canonicalize(cwd).map_err(user_error)?; // As of gix 0.68.0, the canonicalized path uses platform-native directory // separator, which isn't compatible with libgit2 on Windows. if url.scheme == gix::url::Scheme::File { url.path = gix::path::to_unix_separators_on_windows(mem::take(&mut url.path)).into_owned(); } // It's less likely that cwd isn't utf-8, so just fall back to original source. Ok(String::from_utf8(url.to_bstring().into()).unwrap_or_else(|_| source.to_owned())) } fn terminal_get_username(ui: &Ui, url: &str) -> Option<String> { ui.prompt(&format!("Username for {url}")).ok() } fn terminal_get_pw(ui: &Ui, url: &str) -> Option<String> { ui.prompt_password(&format!("Passphrase for {url}")).ok() } fn pinentry_get_pw(url: &str) -> Option<String> { // https://www.gnupg.org/documentation/manuals/assuan/Server-responses.html#Server-responses fn decode_assuan_data(encoded: &str) -> Option<String> { let encoded = encoded.as_bytes(); let mut decoded = Vec::with_capacity(encoded.len()); let mut i = 0; while i < encoded.len() { if encoded[i] != b'%' { decoded.push(encoded[i]); i += 1; continue; } i += 1; let byte = u8::from_str_radix(str::from_utf8(encoded.get(i..i + 2)?).ok()?, 16).ok()?; decoded.push(byte); i += 2; } String::from_utf8(decoded).ok() } let mut pinentry = std::process::Command::new("pinentry") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() .ok()?; let mut interact = || -> std::io::Result<_> { #[rustfmt::skip] let req = format!( "SETTITLE jj passphrase\n\ SETDESC Enter passphrase for {url}\n\ SETPROMPT Passphrase:\n\ GETPIN\n" ); pinentry.stdin.take().unwrap().write_all(req.as_bytes())?; let mut out = String::new(); pinentry.stdout.take().unwrap().read_to_string(&mut out)?; Ok(out) }; let maybe_out = interact(); _ = pinentry.wait(); for line in maybe_out.ok()?.split('\n') { if !line.starts_with("D ") { continue; } let (_, encoded) = line.split_at(2); return decode_assuan_data(encoded); } None } #[tracing::instrument] fn get_ssh_keys(_username: &str) -> Vec<PathBuf> { let mut paths = vec![]; if let Ok(home_dir) = etcetera::home_dir() { let ssh_dir = home_dir.join(".ssh"); for filename in ["id_ed25519_sk", "id_ed25519", "id_rsa"] { let key_path = ssh_dir.join(filename); if key_path.is_file() { tracing::info!(path = ?key_path, "found ssh key"); paths.push(key_path); } } } if paths.is_empty() { tracing::info!("no ssh key found"); } paths } // Based on Git's implementation: https://github.com/git/git/blob/43072b4ca132437f21975ac6acc6b72dc22fd398/sideband.c#L178 pub struct GitSidebandProgressMessageWriter { display_prefix: &'static [u8], suffix: &'static [u8], scratch: Vec<u8>, } impl GitSidebandProgressMessageWriter { pub fn new(ui: &Ui) -> Self { let is_terminal = ui.use_progress_indicator(); Self { display_prefix: "remote: ".as_bytes(), suffix: if is_terminal { "\x1B[K" } else { " " }.as_bytes(), scratch: Vec::new(), } } pub fn write(&mut self, ui: &Ui, progress_message: &[u8]) -> std::io::Result<()> { let mut index = 0; // Append a suffix to each nonempty line to clear the end of the screen line. loop { let Some(i) = progress_message[index..] .iter() .position(|&c| c == b'\r' || c == b'\n') .map(|i| index + i) else { break; }; let line_length = i - index; // For messages sent across the packet boundary, there would be a nonempty // "scratch" buffer from last call of this function, and there may be a leading // CR/LF in this message. For this case we should add a clear-to-eol suffix to // clean leftover letters we previously have written on the same line. if !self.scratch.is_empty() && line_length == 0 { self.scratch.extend_from_slice(self.suffix); } if self.scratch.is_empty() { self.scratch.extend_from_slice(self.display_prefix); } // Do not add the clear-to-eol suffix to empty lines: // For progress reporting we may receive a bunch of percentage updates // followed by '\r' to remain on the same line, and at the end receive a single // '\n' to move to the next line. We should preserve the final // status report line by not appending clear-to-eol suffix to this single line // break. if line_length > 0 { self.scratch.extend_from_slice(&progress_message[index..i]); self.scratch.extend_from_slice(self.suffix); } self.scratch.extend_from_slice(&progress_message[i..i + 1]); ui.status().write_all(&self.scratch)?; self.scratch.clear(); index = i + 1; } // Add leftover message to "scratch" buffer to be printed in next call. if index < progress_message.len() { if self.scratch.is_empty() { self.scratch.extend_from_slice(self.display_prefix); } self.scratch.extend_from_slice(&progress_message[index..]); } Ok(()) } pub fn flush(&mut self, ui: &Ui) -> std::io::Result<()> { if !self.scratch.is_empty() { self.scratch.push(b'\n'); ui.status().write_all(&self.scratch)?; self.scratch.clear(); } Ok(()) } } pub fn with_remote_git_callbacks<T>(ui: &Ui, f: impl FnOnce(git::RemoteCallbacks<'_>) -> T) -> T { let mut callbacks = git::RemoteCallbacks::default(); let mut progress_callback; if let Some(mut output) = ui.progress_output() { let mut progress = Progress::new(Instant::now()); progress_callback = move |x: &git::Progress| { _ = progress.update(Instant::now(), x, &mut output); }; callbacks.progress = Some(&mut progress_callback); } let mut sideband_progress_writer = GitSidebandProgressMessageWriter::new(ui); let mut sideband_progress_callback = |progress_message: &[u8]| { _ = sideband_progress_writer.write(ui, progress_message); }; callbacks.sideband_progress = Some(&mut sideband_progress_callback); let mut get_ssh_keys = get_ssh_keys; // Coerce to unit fn type callbacks.get_ssh_keys = Some(&mut get_ssh_keys); let mut get_pw = |url: &str, _username: &str| pinentry_get_pw(url).or_else(|| terminal_get_pw(ui, url)); callbacks.get_password = Some(&mut get_pw); let mut get_user_pw = |url: &str| Some((terminal_get_username(ui, url)?, terminal_get_pw(ui, url)?)); callbacks.get_username_password = Some(&mut get_user_pw); let result = f(callbacks); _ = sideband_progress_writer.flush(ui); result } pub fn print_git_import_stats( ui: &Ui, repo: &dyn Repo, stats: &GitImportStats, show_ref_stats: bool, ) -> Result<(), CommandError> { let Some(mut formatter) = ui.status_formatter() else { return Ok(()); }; if show_ref_stats { for (kind, changes) in [ (GitRefKind::Bookmark, &stats.changed_remote_bookmarks), (GitRefKind::Tag, &stats.changed_remote_tags), ] { let refs_stats = changes .iter() .map(|(symbol, (remote_ref, ref_target))| { RefStatus::new(kind, symbol.as_ref(), remote_ref, ref_target, repo) }) .collect_vec(); let Some(max_width) = refs_stats.iter().map(|x| x.symbol.width()).max() else { continue; }; for status in refs_stats { status.output(max_width, &mut *formatter)?; } } } if !stats.abandoned_commits.is_empty() { writeln!( formatter, "Abandoned {} commits that are no longer reachable.", stats.abandoned_commits.len() )?; } if !stats.failed_ref_names.is_empty() { writeln!(ui.warning_default(), "Failed to import some Git refs:")?; let mut formatter = ui.stderr_formatter(); for name in &stats.failed_ref_names { write!(formatter, " ")?; write!(formatter.labeled("git_ref"), "{name}")?; writeln!(formatter)?; } } if stats .failed_ref_names .iter() .any(|name| name.starts_with(git::RESERVED_REMOTE_REF_NAMESPACE.as_bytes())) { writedoc!( ui.hint_default(), " Git remote named '{name}' is reserved for local Git repository. Use `jj git remote rename` to give a different name. ", name = git::REMOTE_NAME_FOR_LOCAL_GIT_REPO.as_symbol(), )?; } Ok(()) } pub struct Progress { next_print: Instant, rate: RateEstimate, buffer: String, guard: Option<CleanupGuard>, } impl Progress { pub fn new(now: Instant) -> Self { Self { next_print: now + crate::progress::INITIAL_DELAY, rate: RateEstimate::new(), buffer: String::new(), guard: None, } } pub fn update<W: std::io::Write>( &mut self, now: Instant, progress: &git::Progress, output: &mut ProgressOutput<W>, ) -> io::Result<()> { use std::fmt::Write as _; if progress.overall == 1.0 { write!(output, "\r{}", Clear(ClearType::CurrentLine))?; output.flush()?; return Ok(()); } let rate = progress .bytes_downloaded .and_then(|x| self.rate.update(now, x)); if now < self.next_print { return Ok(()); } self.next_print = now + Duration::from_secs(1) / crate::progress::UPDATE_HZ; if self.guard.is_none() { let guard = output.output_guard(crossterm::cursor::Show.to_string()); let guard = CleanupGuard::new(move || { drop(guard); }); _ = write!(output, "{}", crossterm::cursor::Hide); self.guard = Some(guard); } self.buffer.clear(); // Overwrite the current local or sideband progress line if any. self.buffer.push('\r'); let control_chars = self.buffer.len(); write!(self.buffer, "{: >3.0}% ", 100.0 * progress.overall).unwrap(); if let Some(total) = progress.bytes_downloaded { let (scaled, prefix) = binary_prefix(total as f32); write!(self.buffer, "{scaled: >5.1} {prefix}B ").unwrap(); } if let Some(estimate) = rate { let (scaled, prefix) = binary_prefix(estimate); write!(self.buffer, "at {scaled: >5.1} {prefix}B/s ").unwrap(); } let bar_width = output .term_width() .map(usize::from) .unwrap_or(0) .saturating_sub(self.buffer.len() - control_chars + 2); self.buffer.push('['); draw_progress(progress.overall, &mut self.buffer, bar_width); self.buffer.push(']'); write!(self.buffer, "{}", Clear(ClearType::UntilNewLine)).unwrap(); // Move cursor back to the first column so the next sideband message // will overwrite the current progress. self.buffer.push('\r'); write!(output, "{}", self.buffer)?; output.flush()?; Ok(()) } } fn draw_progress(progress: f32, buffer: &mut String, width: usize) { const CHARS: [char; 9] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█']; const RESOLUTION: usize = CHARS.len() - 1; let ticks = (width as f32 * progress.clamp(0.0, 1.0) * RESOLUTION as f32).round() as usize; let whole = ticks / RESOLUTION; for _ in 0..whole { buffer.push(CHARS[CHARS.len() - 1]); } if whole < width { let fraction = ticks % RESOLUTION; buffer.push(CHARS[fraction]); } for _ in (whole + 1)..width { buffer.push(CHARS[0]); } } struct RateEstimate { state: Option<RateEstimateState>, } impl RateEstimate { pub fn new() -> Self { Self { state: None } } /// Compute smoothed rate from an update pub fn update(&mut self, now: Instant, total: u64) -> Option<f32> { if let Some(ref mut state) = self.state { return Some(state.update(now, total)); } self.state = Some(RateEstimateState { total, avg_rate: None, last_sample: now, }); None } } struct RateEstimateState { total: u64, avg_rate: Option<f32>, last_sample: Instant, } impl RateEstimateState { fn update(&mut self, now: Instant, total: u64) -> f32 { let delta = total - self.total; self.total = total; let dt = now - self.last_sample; self.last_sample = now; let sample = delta as f32 / dt.as_secs_f32(); match self.avg_rate { None => *self.avg_rate.insert(sample), Some(ref mut avg_rate) => { // From Algorithms for Unevenly Spaced Time Series: Moving // Averages and Other Rolling Operators (Andreas Eckner, 2019) const TIME_WINDOW: f32 = 2.0; let alpha = 1.0 - (-dt.as_secs_f32() / TIME_WINDOW).exp(); *avg_rate += alpha * (sample - *avg_rate); *avg_rate } } } } struct RefStatus { ref_kind: GitRefKind, symbol: String, tracking_status: TrackingStatus, import_status: ImportStatus, }