From d331347bc7944bd4ec4658826b0ca99e473fb2a3 Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Tue, 30 Dec 2025 16:14:29 +0100 Subject: [PATCH 01/34] cargo: Add new dependencies --- Cargo.lock | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 3 +++ 2 files changed, 60 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index a058fb50..8e2ab2bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,17 +111,20 @@ dependencies = [ "blake2", "clap", "clap_complete", + "console", "dirs", "dunce", "futures", "glob", "indexmap", + "indicatif", "is-terminal", "itertools", "miette", "owo-colors", "pathdiff", "pretty_assertions", + "regex", "semver", "serde", "serde_json", @@ -287,6 +290,19 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "console" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.2", + "windows-sys 0.61.2", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -399,6 +415,12 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -658,6 +680,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" +dependencies = [ + "console", + "portable-atomic", + "unicode-width 0.2.2", + "unit-prefix", + "web-time", +] + [[package]] name = "is-terminal" version = "0.4.17" @@ -958,6 +993,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1451,6 +1492,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -1548,6 +1595,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi-util" version = "0.1.11" diff --git a/Cargo.toml b/Cargo.toml index 5a3126d8..83d73d90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,9 @@ tera = "1.19" miette = "7.6.0" thiserror = "2.0.17" owo-colors = "4.2.3" +indicatif = "0.18.3" +console = "0.16.2" +regex = "1.12.2" [target.'cfg(windows)'.dependencies] dunce = "1.0.4" From 2fb4d9ef98645d026c5a02e02fdf3582357e580d Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Wed, 31 Dec 2025 13:01:09 +0100 Subject: [PATCH 02/34] progress: Implementation and initial integration of progress bars First compiling version sess: Fix compiler warnings progress: Get rid of the `Sub` prefix for submodules progress: Clean up a bit --- src/cmd/vendor.rs | 130 ++++++++++++--------- src/git.rs | 217 +++++++++++++++++++++++----------- src/main.rs | 1 + src/progress.rs | 292 ++++++++++++++++++++++++++++++++++++++++++++++ src/sess.rs | 171 +++++++++++++++++---------- 5 files changed, 625 insertions(+), 186 deletions(-) create mode 100644 src/progress.rs diff --git a/src/cmd/vendor.rs b/src/cmd/vendor.rs index dbb3b4e8..5cb97ca3 100644 --- a/src/cmd/vendor.rs +++ b/src/cmd/vendor.rs @@ -21,6 +21,7 @@ use crate::diagnostic::Warnings; use crate::error::*; use crate::futures::TryFutureExt; use crate::git::Git; +use crate::progress::{GitProgressOps, ProgressHandler}; use crate::sess::{DependencySource, Session}; /// A patch linkage @@ -101,8 +102,13 @@ pub fn run(sess: &Session, args: &VendorArgs) -> Result<()> { DependencySource::Git(ref url) => { let git = Git::new(tmp_path, &sess.config.git, sess.git_throttle.clone()); rt.block_on(async { - stageln!("Cloning", "{} ({})", vendor_package.name, url); - git.clone().spawn_with(|c| c.arg("clone").arg(url).arg(".")) + // stageln!("Cloning", "{} ({})", vendor_package.name, url); + let pb = ProgressHandler::new( + sess.progress.clone(), + GitProgressOps::Clone, + vendor_package.name.as_str(), + ); + git.clone().spawn_with(|c| c.arg("clone").arg(url).arg("."), Some(pb)) .map_err(move |cause| { Warnings::GitInitFailed { is_ssh: url.contains("git@"), @@ -116,8 +122,13 @@ pub fn run(sess: &Session, args: &VendorArgs) -> Result<()> { config::Dependency::GitRevision { ref rev, .. } => Ok(rev), _ => Err(Error::new("Please ensure your vendor reference is a commit hash to avoid upstream changes impacting your checkout")), }?; - git.clone().spawn_with(|c| c.arg("checkout").arg(rev_hash)).await?; - if *rev_hash != git.spawn_with(|c| c.arg("rev-parse").arg("--verify").arg(format!("{}^{{commit}}", rev_hash))).await?.trim_end_matches('\n') { + let pb = ProgressHandler::new( + sess.progress.clone(), + GitProgressOps::Checkout, + vendor_package.name.as_str(), + ); + git.clone().spawn_with(|c| c.arg("checkout").arg(rev_hash), Some(pb)).await?; + if *rev_hash != git.spawn_with(|c| c.arg("rev-parse").arg("--verify").arg(format!("{}^{{commit}}", rev_hash)), None).await?.trim_end_matches('\n') { Err(Error::new("Please ensure your vendor reference is a commit hash to avoid upstream changes impacting your checkout")) } else { Ok(()) @@ -426,34 +437,37 @@ pub fn apply_patches( Ok(()) }) .and_then(|_| { - git.clone().spawn_with(|c| { - let is_file = patch_link - .from_prefix - .clone() - .prefix_paths(git.path) - .unwrap() - .is_file(); - - let current_patch_target = if is_file { - patch_link.from_prefix.parent().unwrap().to_str().unwrap() - } else { - patch_link.from_prefix.as_path().to_str().unwrap() - }; - - c.arg("apply") - .arg("--directory") - .arg(current_patch_target) - .arg("-p1") - .arg(&patch); - - // limit to specific file for file links - if is_file { - let file_path = patch_link.from_prefix.to_str().unwrap(); - c.arg("--include").arg(file_path); - } + git.clone().spawn_with( + |c| { + let is_file = patch_link + .from_prefix + .clone() + .prefix_paths(git.path) + .unwrap() + .is_file(); - c - }) + let current_patch_target = if is_file { + patch_link.from_prefix.parent().unwrap().to_str().unwrap() + } else { + patch_link.from_prefix.as_path().to_str().unwrap() + }; + + c.arg("apply") + .arg("--directory") + .arg(current_patch_target) + .arg("-p1") + .arg(&patch); + + // limit to specific file for file links + if is_file { + let file_path = patch_link.from_prefix.to_str().unwrap(); + c.arg("--include").arg(file_path); + } + + c + }, + None, + ) }) .await .map_err(move |cause| { @@ -523,15 +537,18 @@ pub fn diff( }; // Get diff rt.block_on(async { - git.spawn_with(|c| { - c.arg("diff").arg(format!( - "--relative={}", - patch_link - .from_prefix - .to_str() - .expect("Failed to convert from_prefix to string.") - )) - }) + git.spawn_with( + |c| { + c.arg("diff").arg(format!( + "--relative={}", + patch_link + .from_prefix + .to_str() + .expect("Failed to convert from_prefix to string.") + )) + }, + None, + ) .await }) } @@ -666,7 +683,7 @@ pub fn gen_format_patch( // Get staged changes in dependency let get_diff_cached = rt - .block_on(async { git_parent.spawn_with(|c| c.args(&diff_args)).await }) + .block_on(async { git_parent.spawn_with(|c| c.args(&diff_args), None).await }) .map_err(|cause| Error::chain("Failed to generate diff", cause))?; if !get_diff_cached.is_empty() { @@ -684,8 +701,8 @@ pub fn gen_format_patch( .arg(&from_path_relative) .arg("-p1") .arg(&diff_cached_path) - }) - .and_then(|_| git.clone().spawn_with(|c| c.arg("add").arg("--all"))) + }, None) + .and_then(|_| git.clone().spawn_with(|c| c.arg("add").arg("--all"), None)) .await }).map_err(|cause| Error::chain("Could not apply staged changes on top of patched upstream repository. Did you commit all previously patched modifications?", cause))?; @@ -734,18 +751,21 @@ pub fn gen_format_patch( // Generate format-patch rt.block_on(async { - git.spawn_with(|c| { - c.arg("format-patch") - .arg("-o") - .arg(patch_dir.to_str().unwrap()) - .arg("-1") - .arg(format!("--start-number={}", max_number + 1)) - .arg(format!( - "--relative={}", - from_path_relative.to_str().unwrap() - )) - .arg("HEAD") - }) + git.spawn_with( + |c| { + c.arg("format-patch") + .arg("-o") + .arg(patch_dir.to_str().unwrap()) + .arg("-1") + .arg(format!("--start-number={}", max_number + 1)) + .arg(format!( + "--relative={}", + from_path_relative.to_str().unwrap() + )) + .arg("HEAD") + }, + None, + ) .await })?; } diff --git a/src/git.rs b/src/git.rs index 96182622..936b9749 100644 --- a/src/git.rs +++ b/src/git.rs @@ -7,13 +7,17 @@ use std::ffi::OsStr; use std::path::Path; +use std::process::Stdio; use std::sync::Arc; use futures::TryFutureExt; +use tokio::io::AsyncReadExt; use tokio::process::Command; use tokio::sync::Semaphore; use walkdir::WalkDir; +use crate::progress::{monitor_stderr, ProgressHandler}; + use crate::error::*; /// A git repository. @@ -62,56 +66,94 @@ impl<'ctx> Git<'ctx> { /// If `check` is false, the stdout will be returned regardless of the /// command's exit code. #[allow(clippy::format_push_string)] - pub async fn spawn(self, mut cmd: Command, check: bool) -> Result { - // acquire throttle + pub async fn spawn( + self, + mut cmd: Command, + check: bool, + pb: Option, + ) -> Result { + // Acquire the throttle semaphore let permit = self.throttle.clone().acquire_owned().await.unwrap(); - let output = cmd.output().map_err(|cause| { + + // Configure pipes for streaming + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + // Spawn the child process + let mut child = cmd.spawn().map_err(|cause| { if cause .to_string() .to_lowercase() .contains("too many open files") { - eprintln!( - "Please consider increasing your `ulimit -n`, e.g. by running `ulimit -n 4096`" - ); - eprintln!("This is a known issue (#52)."); + eprintln!("Please consider increasing your `ulimit -n`..."); Error::chain("Failed to spawn child process.", cause) } else { Error::chain("Failed to spawn child process.", cause) } + })?; + + debugln!("git: {:?} in {:?}", cmd, self.path); + + // Setup Streaming for Stderr (Progress + Error Collection) + // We need to capture stderr in case the command fails, so we collect it while parsing. + let stderr = child.stderr.take().unwrap(); + + // Spawn a background task to handle stderr so it doesn't block + let stderr_handle = tokio::spawn(async move { + // We pass the handler clone into the async task + monitor_stderr(stderr, pb).await }); - let result = output.and_then(|output| async move { - debugln!("git: {:?} in {:?}", cmd, self.path); - if output.status.success() || !check { - String::from_utf8(output.stdout).map_err(|cause| { - Error::chain( - format!( - "Output of git command ({:?}) in directory {:?} is not valid UTF-8.", - cmd, self.path - ), - cause, - ) - }) - } else { - let mut msg = format!("Git command ({:?}) in directory {:?}", cmd, self.path); - match output.status.code() { - Some(code) => msg.push_str(&format!(" failed with exit code {}", code)), - None => msg.push_str(" failed"), - }; - match String::from_utf8(output.stderr) { - Ok(txt) => { - msg.push_str(":\n\n"); - msg.push_str(&txt); - } - Err(err) => msg.push_str(&format!(". Stderr is not valid UTF-8, {}.", err)), - }; - Err(Error::new(msg)) + + // Read Stdout (for the success return value) + let mut stdout_buffer = Vec::new(); + if let Some(mut stdout) = child.stdout.take() { + // We just read all of stdout. + if let Err(e) = stdout.read_to_end(&mut stdout_buffer).await { + return Err(Error::chain("Failed to read stdout", e)); } - }); - let result = result.await; - // release throttle + } + + // Wait for child process to finish + let status = child + .wait() + .await + .map_err(|e| Error::chain("Failed to wait on child", e))?; + + // Join the stderr task to get the error log + let collected_stderr = stderr_handle + .await + .unwrap_or_else(|_| String::from("")); + + // We can release the throttle here since we're done with the process drop(permit); - result + + // Process the output based on success and check flag + if status.success() || !check { + String::from_utf8(stdout_buffer).map_err(|cause| { + Error::chain( + format!( + "Output of git command ({:?}) in directory {:?} is not valid UTF-8.", + cmd, self.path + ), + cause, + ) + }) + } else { + let mut msg = format!("Git command ({:?}) in directory {:?}", cmd, self.path); + match status.code() { + Some(code) => msg.push_str(&format!(" failed with exit code {}", code)), + None => msg.push_str(" failed"), + }; + + // Use the stderr we collected in the background task + if !collected_stderr.is_empty() { + msg.push_str(":\n\n"); + msg.push_str(&collected_stderr); + } + + Err(Error::new(msg)) + } } /// Assemble a command and schedule it for execution. @@ -119,28 +161,28 @@ impl<'ctx> Git<'ctx> { /// This is a convenience function that creates a command, passes it to the /// closure `f` for configuration, then passes it to the `spawn` function /// and returns the future. - pub async fn spawn_with(self, f: F) -> Result + pub async fn spawn_with(self, f: F, pb: Option) -> Result where F: FnOnce(&mut Command) -> &mut Command, { let mut cmd = Command::new(self.git); cmd.current_dir(self.path); f(&mut cmd); - self.spawn(cmd, true).await + self.spawn(cmd, true, pb).await } /// Assemble a command and schedule it for execution. /// /// This is the same as `spawn_with()`, but returns the stdout regardless of /// whether the command failed or not. - pub async fn spawn_unchecked_with(self, f: F) -> Result + pub async fn spawn_unchecked_with(self, f: F, pb: Option) -> Result where F: FnOnce(&mut Command) -> &mut Command, { let mut cmd = Command::new(self.git); cmd.current_dir(self.path); f(&mut cmd); - self.spawn(cmd, false).await + self.spawn(cmd, false, pb).await } /// Assemble a command and execute it interactively. @@ -160,7 +202,9 @@ impl<'ctx> Git<'ctx> { /// Check if the repository uses LFS. pub async fn uses_lfs(self) -> Result { - let output = self.spawn_with(|c| c.arg("lfs").arg("ls-files")).await?; + let output = self + .spawn_with(|c| c.arg("lfs").arg("ls-files"), None) + .await?; Ok(!output.trim().is_empty()) } @@ -187,26 +231,48 @@ impl<'ctx> Git<'ctx> { } /// Fetch the tags and refs of a remote. - pub async fn fetch(self, remote: &str) -> Result<()> { + pub async fn fetch(self, remote: &str, pb: Option) -> Result<()> { let r1 = String::from(remote); let r2 = String::from(remote); self.clone() - .spawn_with(|c| c.arg("fetch").arg("--prune").arg(r1)) - .and_then(|_| self.spawn_with(|c| c.arg("fetch").arg("--tags").arg("--prune").arg(r2))) + .spawn_with( + |c| c.arg("fetch").arg("--prune").arg(r1).arg("--progress"), + pb.clone(), + ) + .and_then(|_| { + self.spawn_with( + |c| { + c.arg("fetch") + .arg("--tags") + .arg("--prune") + .arg(r2) + .arg("--progress") + }, + pb, + ) + }) .await .map(|_| ()) } /// Fetch the specified ref of a remote. - pub async fn fetch_ref(self, remote: &str, reference: &str) -> Result<()> { - self.spawn_with(|c| c.arg("fetch").arg(remote).arg(reference)) - .await - .map(|_| ()) + pub async fn fetch_ref( + self, + remote: &str, + reference: &str, + pb: Option, + ) -> Result<()> { + self.spawn_with( + |c| c.arg("fetch").arg(remote).arg(reference).arg("--progress"), + pb, + ) + .await + .map(|_| ()) } /// Stage all local changes. pub async fn add_all(self) -> Result<()> { - self.spawn_with(|c| c.arg("add").arg("--all")) + self.spawn_with(|c| c.arg("add").arg("--all"), None) .await .map(|_| ()) } @@ -217,13 +283,16 @@ impl<'ctx> Git<'ctx> { pub async fn commit(self, message: Option<&String>) -> Result<()> { match message { Some(msg) => self - .spawn_with(|c| { - c.arg("-c") - .arg("commit.gpgsign=false") - .arg("commit") - .arg("-m") - .arg(msg) - }) + .spawn_with( + |c| { + c.arg("-c") + .arg("commit.gpgsign=false") + .arg("commit") + .arg("-m") + .arg(msg) + }, + None, + ) .await .map(|_| ()), @@ -236,7 +305,7 @@ impl<'ctx> Git<'ctx> { /// List all refs and their hashes. pub async fn list_refs(self) -> Result> { - self.spawn_unchecked_with(|c| c.arg("show-ref").arg("--dereference")) + self.spawn_unchecked_with(|c| c.arg("show-ref").arg("--dereference"), None) .and_then(|raw| async move { let mut all_revs = raw .lines() @@ -271,21 +340,24 @@ impl<'ctx> Git<'ctx> { /// List all revisions. pub async fn list_revs(self) -> Result> { - self.spawn_with(|c| c.arg("rev-list").arg("--all").arg("--date-order")) + self.spawn_with(|c| c.arg("rev-list").arg("--all").arg("--date-order"), None) .await .map(|raw| raw.lines().map(String::from).collect()) } /// Determine the currently checked out revision. pub async fn current_checkout(self) -> Result> { - self.spawn_with(|c| c.arg("rev-parse").arg("--revs-only").arg("HEAD^{commit}")) - .await - .map(|raw| raw.lines().take(1).map(String::from).next()) + self.spawn_with( + |c| c.arg("rev-parse").arg("--revs-only").arg("HEAD^{commit}"), + None, + ) + .await + .map(|raw| raw.lines().take(1).map(String::from).next()) } /// Determine the url of a remote. pub async fn remote_url(self, remote: &str) -> Result { - self.spawn_with(|c| c.arg("remote").arg("get-url").arg(remote)) + self.spawn_with(|c| c.arg("remote").arg("get-url").arg(remote), None) .await .map(|raw| raw.lines().take(1).map(String::from).next().unwrap()) } @@ -298,20 +370,23 @@ impl<'ctx> Git<'ctx> { rev: R, path: Option

, ) -> Result> { - self.spawn_with(|c| { - c.arg("ls-tree").arg(rev); - if let Some(p) = path { - c.arg(p); - } - c - }) + self.spawn_with( + |c| { + c.arg("ls-tree").arg(rev); + if let Some(p) = path { + c.arg(p); + } + c + }, + None, + ) .await .map(|raw| raw.lines().map(TreeEntry::parse).collect()) } /// Read the content of a file. pub async fn cat_file>(self, hash: O) -> Result { - self.spawn_with(|c| c.arg("cat-file").arg("blob").arg(hash)) + self.spawn_with(|c| c.arg("cat-file").arg("blob").arg(hash), None) .await } } diff --git a/src/main.rs b/src/main.rs index 314967bf..b96bf92a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,6 +35,7 @@ pub mod config; pub mod diagnostic; pub mod git; pub mod lockfile; +pub mod progress; pub mod resolver; #[allow(clippy::bind_instead_of_map)] pub mod sess; diff --git a/src/progress.rs b/src/progress.rs new file mode 100644 index 00000000..542dfa98 --- /dev/null +++ b/src/progress.rs @@ -0,0 +1,292 @@ +// Copyright (c) 2025 ETH Zurich +// Tim Fischer + +use std::sync::OnceLock; +use std::time::Duration; + +use console::style; +use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; +use regex::Regex; +use tokio::io::{AsyncReadExt, BufReader}; + +/// Parses a line of git output. +/// (Put your `GitProgress` enum and `parse_git_line` function here) +#[derive(Debug, PartialEq, Clone)] +pub enum GitProgress { + CloningInto { + path: String, + }, + SubmoduleEnd { + path: String, + }, + Receiving { + percent: u8, + current: usize, + total: usize, + }, + Resolving { + percent: u8, + current: usize, + total: usize, + }, + Checkout { + percent: u8, + current: usize, + total: usize, + }, + Other, +} + +/// The git operation types that currently support progress reporting. +#[derive(Debug, PartialEq, Clone)] +pub enum GitProgressOps { + Checkout, + Clone, + Fetch, +} + +static RE_GIT: OnceLock = OnceLock::new(); + +pub fn parse_git_line(line: &str) -> GitProgress { + let line = line.trim(); + let re = RE_GIT.get_or_init(|| { + Regex::new(r"(?x) + ^ # Start + (?: + Cloning\ into\ '(?P[^']+)'\.\.\. | + Submodule\ path\ '(?P[^']+)':\ checked\ out\ '.* | + (?PReceiving\ objects|Resolving\ deltas|Checking\ out\ files):\s+(?P\d+)% + (?: \s+ \( (?P\d+) / (?P\d+) \) )? + ) + ").expect("Invalid Regex") + }); + + if let Some(caps) = re.captures(line) { + // Case 1: Cloning into... + if let Some(path) = caps.name("clone_path") { + return GitProgress::CloningInto { + path: path.as_str().to_string(), + }; + } + + // Case 2: Submodule finished + if let Some(path) = caps.name("sub_end_path") { + return GitProgress::SubmoduleEnd { + path: path.as_str().to_string(), + }; + } + + // Case 3: Progress + if let Some(phase) = caps.name("phase") { + let percent = caps.name("percent").unwrap().as_str().parse().unwrap_or(0); + let current = caps + .name("current") + .map(|m| m.as_str().parse().unwrap_or(0)) + .unwrap_or(0); + let total = caps + .name("total") + .map(|m| m.as_str().parse().unwrap_or(0)) + .unwrap_or(0); + + return match phase.as_str() { + "Receiving objects" => GitProgress::Receiving { + percent, + current, + total, + }, + "Resolving deltas" => GitProgress::Resolving { + percent, + current, + total, + }, + "Checking out files" => GitProgress::Checkout { + percent, + current, + total, + }, + _ => GitProgress::Other, + }; + } + } + // Otherwise, we don't care + GitProgress::Other +} + +/// This struct captures (dynamic) state information for a git operation's progress. +/// for instance, the actuall progress bars to update. +pub struct ProgressState { + /// The progress bar of the current package. + pb: ProgressBar, + /// The progress bar for submodules, if any. + sub_pb: Option, + /// Whether the main progress bar is done. + /// This is used to determine when to start submodule progress bars. + main_done: bool, +} + +/// This struct captures (static) information neeed to handle progress updates for a git operation. +#[derive(Clone)] +pub struct ProgressHandler { + /// Reference to the multi-progress bar, which can manage multiple progress bars. + mpb: MultiProgress, + /// The style used for progress bars. + style: ProgressStyle, + /// The type of git operation being performed. + git_op: GitProgressOps, + /// The name of the repository being processed. + name: String, +} + +impl ProgressHandler { + /// Create a new progress handler for a git operation. + pub fn new(mpb: MultiProgress, git_op: GitProgressOps, name: &str) -> Self { + // Set the style for progress bars + let style = ProgressStyle::with_template( + "{spinner:.green} {prefix:<24!} {bar:40.cyan/blue} {percent:>3}% {msg}", + ) + .unwrap() + .progress_chars("-- ") + .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]); + + Self { + mpb, + git_op, + name: name.to_string(), + style, + } + } + + pub fn start(&self) -> ProgressState { + // Add a new progress bar to the multi-progress (with a length of 100) + let pb = self.mpb.add(ProgressBar::new(100)); + pb.set_style(self.style.clone()); + + let prefix = match self.git_op { + GitProgressOps::Clone => "Cloning", + GitProgressOps::Fetch => "Fetching", + GitProgressOps::Checkout => "Checkout", + }; + let prefix = format!( + "{} {}", + console::style(prefix).bold().green(), + console::style(&self.name).bright() + ); + pb.set_prefix(prefix); + // Configure the spinners to automatically tick every 100ms + pb.enable_steady_tick(Duration::from_millis(100)); + + ProgressState { + pb, + sub_pb: None, + main_done: false, + } + } + + pub fn handle_line(&self, line: &str, state: &mut ProgressState) { + let progress = parse_git_line(line); + let target_pb = state.sub_pb.as_ref().unwrap_or(&state.pb); + + match progress { + GitProgress::CloningInto { path } => { + if state.main_done { + state.pb.set_position(100); + state.pb.set_message(style("Done.").dim().to_string()); + + let sub_pb = self.mpb.insert_after(&state.pb, ProgressBar::new(100)); + sub_pb.set_style(self.style.clone()); + + let sub_name = path.split('/').last().unwrap_or(&path); + let sub_prefix = format!(" {} {}", style("└─ ").dim(), style(sub_name).dim()); + sub_pb.set_prefix(sub_prefix); + state.sub_pb = Some(sub_pb); + } + state.main_done = true; + } + GitProgress::SubmoduleEnd { .. } => { + if let Some(sub) = state.sub_pb.take() { + sub.finish_and_clear(); + } + } + GitProgress::Receiving { current, total, .. } => { + target_pb.set_message(style("Receiving objects").dim().to_string()); + target_pb.set_length(total as u64); + target_pb.set_position(current as u64); + } + GitProgress::Resolving { percent, .. } => { + target_pb.set_message(style("Resolving deltas").dim().to_string()); + target_pb.set_length(100); + target_pb.set_position(percent as u64); + } + GitProgress::Checkout { percent, .. } => { + target_pb.set_message(style("Checking out").dim().to_string()); + target_pb.set_length(100); + target_pb.set_position(percent as u64); + } + _ => {} + } + } + + pub fn finish(self, state: &mut ProgressState) { + if let Some(sub) = state.sub_pb.take() { + sub.finish_and_clear(); + } + state.pb.finish_and_clear(); + } +} + +pub async fn monitor_stderr( + stream: impl tokio::io::AsyncRead + Unpin, + handler: Option, +) -> String { + let mut reader = BufReader::new(stream); + let mut buffer = Vec::new(); + let mut collected_stderr = String::new(); + + // Add a new progress bar and state if we have a handler + let mut state = handler.as_ref().map(|h| h.start()); + + loop { + match reader.read_u8().await { + Ok(byte) => { + // Collect raw error output (simplified for brevity) + if byte.is_ascii() { + collected_stderr.push(byte as char); + } + + if byte == b'\r' || byte == b'\n' { + if !buffer.is_empty() { + if let Ok(line) = std::str::from_utf8(&buffer) { + // Update UI if we have a handler + if let Some(h) = &handler { + h.handle_line(line, &mut state.as_mut().unwrap()); + } + } + buffer.clear(); + } + } else { + buffer.push(byte); + } + } + Err(_) => break, + } + } + + handler.map(|h| h.finish(&mut state.unwrap())); + + collected_stderr +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parsing_logic() { + // Copy your existing unit tests here + let p = parse_git_line("Receiving objects: 34% (123/456)"); + match p { + GitProgress::Receiving { percent, .. } => assert_eq!(percent, 34), + _ => panic!("Failed to parse receiving"), + } + } +} diff --git a/src/sess.rs b/src/sess.rs index 3d7b3567..2e8be3ac 100644 --- a/src/sess.rs +++ b/src/sess.rs @@ -26,6 +26,7 @@ use async_recursion::async_recursion; use futures::future::join_all; use futures::TryFutureExt; use indexmap::{IndexMap, IndexSet}; +use indicatif::MultiProgress; use semver::Version; use tokio::sync::Semaphore; use typed_arena::Arena; @@ -35,6 +36,7 @@ use crate::config::{self, Config, Manifest, PartialManifest}; use crate::diagnostic::{Diagnostics, Warnings}; use crate::error::*; use crate::git::Git; +use crate::progress::{GitProgressOps, ProgressHandler}; use crate::src::SourceGroup; use crate::target::TargetSpec; use crate::util::try_modification_time; @@ -79,6 +81,8 @@ pub struct Session<'ctx> { pub git_throttle: Arc, /// A toggle to disable remote fetches & clones pub local_only: bool, + /// The global progress bar manager. + pub progress: MultiProgress, } impl<'ctx> Session<'ctx> { @@ -117,6 +121,7 @@ impl<'ctx> Session<'ctx> { cache: Default::default(), git_throttle: Arc::new(Semaphore::new(git_throttle)), local_only, + progress: MultiProgress::new(), } } @@ -535,10 +540,8 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { &self.sess.config.git, self.sess.git_throttle.clone(), ); - let name2 = String::from(name); let url = String::from(url); let url2 = url.clone(); - let url3 = url.clone(); // Either initialize the repository or update it if needed. if !db_dir.join("config").exists() { @@ -551,18 +554,20 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { // Initialize. self.sess.stats.num_database_init.increment(); // TODO MICHAERO: May need throttle - stageln!("Cloning", "{} ({})", name2, url2); + // stageln!("Cloning", "{} ({})", name2, url2); + // TODO(fischeti): Is this actually the cloning stage? git.clone() - .spawn_with(|c| c.arg("init").arg("--bare")) + .spawn_with(|c| c.arg("init").arg("--bare"), None) .await?; git.clone() - .spawn_with(|c| c.arg("remote").arg("add").arg("origin").arg(url)) + .spawn_with(|c| c.arg("remote").arg("add").arg("origin").arg(url), None) .await?; + let pb = ProgressHandler::new(self.sess.progress.clone(), GitProgressOps::Clone, name); git.clone() - .fetch("origin") + .fetch("origin", Some(pb.clone())) .and_then(|_| async { if let Some(reference) = fetch_ref { - git.clone().fetch_ref("origin", reference).await + git.clone().fetch_ref("origin", reference, Some(pb)).await } else { Ok(()) } @@ -588,12 +593,14 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { } self.sess.stats.num_database_fetch.increment(); // TODO MICHAERO: May need throttle - stageln!("Fetching", "{} ({})", name2, url2); + // stageln!("Fetching", "{} ({})", name2, url2); + // TODO(fischeti): Is this actually the fetching stage? + let pb = ProgressHandler::new(self.sess.progress.clone(), GitProgressOps::Fetch, name); git.clone() - .fetch("origin") + .fetch("origin", Some(pb.clone())) .and_then(|_| async { if let Some(reference) = fetch_ref { - git.clone().fetch_ref("origin", reference).await + git.clone().fetch_ref("origin", reference, Some(pb)).await } else { Ok(()) } @@ -881,7 +888,7 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { if checkout_already_good == CheckoutState::ToCheckout { if local_git .clone() - .spawn_with(|c| c.arg("status").arg("--porcelain")) + .spawn_with(|c| c.arg("status").arg("--porcelain"), None) .await .is_ok() { @@ -915,7 +922,7 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { // Perform the checkout if necessary. if clear != CheckoutState::Clean { - stageln!("Checkout", "{} ({})", name, url); + // stageln!("Checkout", "{} ({})", name, url); // First generate a tag to be cloned in the database. This is // necessary since `git clone` does not accept commits, but only @@ -926,13 +933,16 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { let git = self.git_database(name, url, false, Some(revision)).await?; match git .clone() - .spawn_with(move |c| { - c.arg("tag") - .arg(tag_name_0) - .arg(revision) - .arg("--force") - .arg("--no-sign") - }) + .spawn_with( + move |c| { + c.arg("tag") + .arg(tag_name_0) + .arg(revision) + .arg("--force") + .arg("--no-sign") + }, + None, + ) .await { Ok(r) => Ok(r), @@ -941,18 +951,29 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { "checkout_git: failed to tag commit {:?}, attempting fetch.", cause ); + let pb = ProgressHandler::new( + self.sess.progress.clone(), + GitProgressOps::Checkout, + name, + ); // Attempt to fetch from remote and retry, as commits seem unavailable. git.clone() - .spawn_with(move |c| c.arg("fetch").arg("--all")) + .spawn_with( + move |c| c.arg("fetch").arg("--all").arg("--progress"), + Some(pb), + ) .await?; git.clone() - .spawn_with(move |c| { - c.arg("tag") - .arg(tag_name_1) - .arg(revision) - .arg("--force") - .arg("--no-sign") - }) + .spawn_with( + move |c| { + c.arg("tag") + .arg(tag_name_1) + .arg(revision) + .arg("--force") + .arg("--no-sign") + }, + None, + ) .map_err(|cause| { Warnings::RevisionNotFound(revision.to_string(), name.to_string()) .emit(); @@ -968,39 +989,64 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { } }?; if clear == CheckoutState::ToClone { + let pb = + ProgressHandler::new(self.sess.progress.clone(), GitProgressOps::Clone, name); git.clone() - .spawn_with(move |c| { - c.arg("-c") - .arg("filter.lfs.smudge=") - .arg("-c") - .arg("filter.lfs.process=") - .arg("-c") - .arg("filter.lfs.required=false") - .arg("clone") - .arg(git.path) - .arg(path) - .arg("--branch") - .arg(tag_name_2) - }) + .spawn_with( + move |c| { + c.arg("-c") + .arg("filter.lfs.smudge=") + .arg("-c") + .arg("filter.lfs.process=") + .arg("-c") + .arg("filter.lfs.required=false") + .arg("clone") + .arg(git.path) + .arg(path) + .arg("--branch") + .arg(tag_name_2) + }, + Some(pb), + ) .await?; } else if clear == CheckoutState::ToCheckout { + let pb = + ProgressHandler::new(self.sess.progress.clone(), GitProgressOps::Fetch, name); local_git .clone() - .spawn_with(move |c| c.arg("fetch").arg("--all").arg("--tags").arg("--prune")) + .spawn_with( + move |c| { + c.arg("fetch") + .arg("--all") + .arg("--tags") + .arg("--prune") + .arg("--progress") + }, + Some(pb), + ) .await?; + let pb = ProgressHandler::new( + self.sess.progress.clone(), + GitProgressOps::Checkout, + name, + ); local_git .clone() - .spawn_with(move |c| { - c.arg("-c") - .arg("filter.lfs.smudge=") - .arg("-c") - .arg("filter.lfs.process=") - .arg("-c") - .arg("filter.lfs.required=false") - .arg("checkout") - .arg(tag_name_2) - .arg("--force") - }) + .spawn_with( + move |c| { + c.arg("-c") + .arg("filter.lfs.smudge=") + .arg("-c") + .arg("filter.lfs.process=") + .arg("-c") + .arg("filter.lfs.required=false") + .arg("checkout") + .arg(tag_name_2) + .arg("--force") + .arg("--progress") + }, + Some(pb), + ) .await?; } // Check if the repo uses LFS attributes @@ -1012,11 +1058,11 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { Ok(true) => { local_git .clone() - .spawn_with(move |c| c.arg("config").arg("lfs.url").arg(url)) + .spawn_with(move |c| c.arg("config").arg("lfs.url").arg(url), None) .await?; local_git .clone() - .spawn_with(move |c| c.arg("lfs").arg("pull")) + .spawn_with(move |c| c.arg("lfs").arg("pull"), None) .await?; } Ok(false) => {} @@ -1028,14 +1074,19 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { Warnings::LfsDisabled(name.to_string()).emit(); } } + let pb = ProgressHandler::new(self.sess.progress.clone(), GitProgressOps::Clone, name); local_git .clone() - .spawn_with(move |c| { - c.arg("submodule") - .arg("update") - .arg("--init") - .arg("--recursive") - }) + .spawn_with( + move |c| { + c.arg("submodule") + .arg("update") + .arg("--init") + .arg("--recursive") + .arg("--progress") + }, + Some(pb), + ) .await?; } Ok(path) From 5148a8d7e2e891fc4cc5328cebbb76a98f4da79c Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Wed, 31 Dec 2025 16:36:42 +0100 Subject: [PATCH 03/34] progress: Optimization and refactoring git: Don't report progress when fetching tags progress: Don't set length multiple times progress: Refactor progress: Don't allow clones of Progresshandlers Clones of progress bars don't really work well after they were finished git: Don't clone progress handler Format sources --- src/git.rs | 12 +++------ src/progress.rs | 10 +++---- src/sess.rs | 70 +++++++++++++++++++++++++++---------------------- 3 files changed, 45 insertions(+), 47 deletions(-) diff --git a/src/git.rs b/src/git.rs index 936b9749..0eb29d3f 100644 --- a/src/git.rs +++ b/src/git.rs @@ -237,18 +237,12 @@ impl<'ctx> Git<'ctx> { self.clone() .spawn_with( |c| c.arg("fetch").arg("--prune").arg(r1).arg("--progress"), - pb.clone(), + pb, ) .and_then(|_| { self.spawn_with( - |c| { - c.arg("fetch") - .arg("--tags") - .arg("--prune") - .arg(r2) - .arg("--progress") - }, - pb, + |c| c.arg("fetch").arg("--tags").arg("--prune").arg(r2), + None, ) }) .await diff --git a/src/progress.rs b/src/progress.rs index 542dfa98..f10a6deb 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -125,7 +125,6 @@ pub struct ProgressState { } /// This struct captures (static) information neeed to handle progress updates for a git operation. -#[derive(Clone)] pub struct ProgressHandler { /// Reference to the multi-progress bar, which can manage multiple progress bars. mpb: MultiProgress, @@ -182,7 +181,7 @@ impl ProgressHandler { } } - pub fn handle_line(&self, line: &str, state: &mut ProgressState) { + pub fn update_pb(&self, line: &str, state: &mut ProgressState) { let progress = parse_git_line(line); let target_pb = state.sub_pb.as_ref().unwrap_or(&state.pb); @@ -207,19 +206,16 @@ impl ProgressHandler { sub.finish_and_clear(); } } - GitProgress::Receiving { current, total, .. } => { + GitProgress::Receiving { current, .. } => { target_pb.set_message(style("Receiving objects").dim().to_string()); - target_pb.set_length(total as u64); target_pb.set_position(current as u64); } GitProgress::Resolving { percent, .. } => { target_pb.set_message(style("Resolving deltas").dim().to_string()); - target_pb.set_length(100); target_pb.set_position(percent as u64); } GitProgress::Checkout { percent, .. } => { target_pb.set_message(style("Checking out").dim().to_string()); - target_pb.set_length(100); target_pb.set_position(percent as u64); } _ => {} @@ -258,7 +254,7 @@ pub async fn monitor_stderr( if let Ok(line) = std::str::from_utf8(&buffer) { // Update UI if we have a handler if let Some(h) = &handler { - h.handle_line(line, &mut state.as_mut().unwrap()); + h.update_pb(line, &mut state.as_mut().unwrap()); } } buffer.clear(); diff --git a/src/sess.rs b/src/sess.rs index 2e8be3ac..7cd6825f 100644 --- a/src/sess.rs +++ b/src/sess.rs @@ -553,21 +553,22 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { } // Initialize. self.sess.stats.num_database_init.increment(); - // TODO MICHAERO: May need throttle - // stageln!("Cloning", "{} ({})", name2, url2); - // TODO(fischeti): Is this actually the cloning stage? git.clone() .spawn_with(|c| c.arg("init").arg("--bare"), None) .await?; git.clone() .spawn_with(|c| c.arg("remote").arg("add").arg("origin").arg(url), None) .await?; - let pb = ProgressHandler::new(self.sess.progress.clone(), GitProgressOps::Clone, name); + let pb = Some(ProgressHandler::new( + self.sess.progress.clone(), + GitProgressOps::Clone, + name, + )); git.clone() - .fetch("origin", Some(pb.clone())) + .fetch("origin", pb) .and_then(|_| async { if let Some(reference) = fetch_ref { - git.clone().fetch_ref("origin", reference, Some(pb)).await + git.clone().fetch_ref("origin", reference, None).await } else { Ok(()) } @@ -592,15 +593,16 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { return Ok(git); } self.sess.stats.num_database_fetch.increment(); - // TODO MICHAERO: May need throttle - // stageln!("Fetching", "{} ({})", name2, url2); - // TODO(fischeti): Is this actually the fetching stage? - let pb = ProgressHandler::new(self.sess.progress.clone(), GitProgressOps::Fetch, name); + let pb = Some(ProgressHandler::new( + self.sess.progress.clone(), + GitProgressOps::Fetch, + name, + )); git.clone() - .fetch("origin", Some(pb.clone())) + .fetch("origin", pb) .and_then(|_| async { if let Some(reference) = fetch_ref { - git.clone().fetch_ref("origin", reference, Some(pb)).await + git.clone().fetch_ref("origin", reference, None).await } else { Ok(()) } @@ -922,8 +924,6 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { // Perform the checkout if necessary. if clear != CheckoutState::Clean { - // stageln!("Checkout", "{} ({})", name, url); - // First generate a tag to be cloned in the database. This is // necessary since `git clone` does not accept commits, but only // branches or tags for shallow clones. @@ -951,17 +951,14 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { "checkout_git: failed to tag commit {:?}, attempting fetch.", cause ); - let pb = ProgressHandler::new( + let pb = Some(ProgressHandler::new( self.sess.progress.clone(), GitProgressOps::Checkout, name, - ); + )); // Attempt to fetch from remote and retry, as commits seem unavailable. git.clone() - .spawn_with( - move |c| c.arg("fetch").arg("--all").arg("--progress"), - Some(pb), - ) + .spawn_with(move |c| c.arg("fetch").arg("--all").arg("--progress"), pb) .await?; git.clone() .spawn_with( @@ -989,8 +986,11 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { } }?; if clear == CheckoutState::ToClone { - let pb = - ProgressHandler::new(self.sess.progress.clone(), GitProgressOps::Clone, name); + let pb = Some(ProgressHandler::new( + self.sess.progress.clone(), + GitProgressOps::Clone, + name, + )); git.clone() .spawn_with( move |c| { @@ -1005,13 +1005,17 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { .arg(path) .arg("--branch") .arg(tag_name_2) + .arg("--progress") }, - Some(pb), + pb, ) .await?; } else if clear == CheckoutState::ToCheckout { - let pb = - ProgressHandler::new(self.sess.progress.clone(), GitProgressOps::Fetch, name); + let pb = Some(ProgressHandler::new( + self.sess.progress.clone(), + GitProgressOps::Fetch, + name, + )); local_git .clone() .spawn_with( @@ -1022,14 +1026,14 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { .arg("--prune") .arg("--progress") }, - Some(pb), + pb, ) .await?; - let pb = ProgressHandler::new( + let pb = Some(ProgressHandler::new( self.sess.progress.clone(), GitProgressOps::Checkout, name, - ); + )); local_git .clone() .spawn_with( @@ -1045,7 +1049,7 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { .arg("--force") .arg("--progress") }, - Some(pb), + pb, ) .await?; } @@ -1074,7 +1078,11 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { Warnings::LfsDisabled(name.to_string()).emit(); } } - let pb = ProgressHandler::new(self.sess.progress.clone(), GitProgressOps::Clone, name); + let pb = Some(ProgressHandler::new( + self.sess.progress.clone(), + GitProgressOps::Clone, + name, + )); local_git .clone() .spawn_with( @@ -1085,7 +1093,7 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { .arg("--recursive") .arg("--progress") }, - Some(pb), + pb, ) .await?; } From ab64697b12ddeeba92e8ea96134d597bf690b2ec Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Thu, 1 Jan 2026 22:35:25 +0100 Subject: [PATCH 04/34] error: Suspend progress bars while printing errors and warnings --- src/error.rs | 27 ++++++++++++++++++++++++++- src/sess.rs | 8 ++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/error.rs b/src/error.rs index 0bda72dd..7cdc32a3 100644 --- a/src/error.rs +++ b/src/error.rs @@ -7,9 +7,34 @@ use std; use std::fmt; use std::sync::atomic::AtomicBool; use std::sync::Arc; +use std::sync::{Arc, RwLock}; + +use indicatif::MultiProgress; pub static ENABLE_DEBUG: AtomicBool = AtomicBool::new(false); +/// A global hook for the progress bar +pub static GLOBAL_MULTI_PROGRESS: RwLock> = RwLock::new(None); + +/// Helper function to print diagnostics safely without messing up progress bars. +pub fn print_diagnostic(severity: Severity, msg: &str) { + let text = format!("{} {}", severity, msg); + + // Try to acquire read access to the global progress bar + if let Ok(guard) = GLOBAL_MULTI_PROGRESS.read() { + if let Some(mp) = &*guard { + // SUSPEND: Hides progress bars, prints the message, then redraws bars. + mp.suspend(|| { + eprintln!("{}", text); + }); + return; + } + } + + // Fallback: Just print if no bar is registered or lock is poisoned + eprintln!("{}", text); +} + /// Print an error. #[macro_export] macro_rules! errorln { @@ -45,7 +70,7 @@ macro_rules! debugln { /// Emit a diagnostic message. macro_rules! diagnostic { ($severity:expr; $($arg:tt)*) => { - eprintln!("{} {}", $severity, format!($($arg)*)) + $crate::error::print_diagnostic($severity, &format!($($arg)*)) } } diff --git a/src/sess.rs b/src/sess.rs index 7cd6825f..5ada1629 100644 --- a/src/sess.rs +++ b/src/sess.rs @@ -97,6 +97,14 @@ impl<'ctx> Session<'ctx> { force_fetch: bool, git_throttle: usize, ) -> Session<'ctx> { + + // Initialize the global multi-progress bar + // to handle warning and error messages correctly. + let mpb = MultiProgress::new(); + if let Ok(mut global_mpb) = GLOBAL_MULTI_PROGRESS.write() { + *global_mpb = Some(mpb.clone()); + } + Session { root, manifest, From 03910cc73f9be57eda3fd007642833821eec51be Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Thu, 1 Jan 2026 22:40:13 +0100 Subject: [PATCH 05/34] sess: Format sources --- src/sess.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sess.rs b/src/sess.rs index 5ada1629..6d407149 100644 --- a/src/sess.rs +++ b/src/sess.rs @@ -97,7 +97,6 @@ impl<'ctx> Session<'ctx> { force_fetch: bool, git_throttle: usize, ) -> Session<'ctx> { - // Initialize the global multi-progress bar // to handle warning and error messages correctly. let mpb = MultiProgress::new(); From cace83b8d52c6f9c61b8cd063b54e06c58315625 Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Fri, 2 Jan 2026 15:54:12 +0100 Subject: [PATCH 06/34] sess: Remove progress bars for disk operations --- src/sess.rs | 60 ++++++++++++++++++++++++++--------------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/src/sess.rs b/src/sess.rs index 6d407149..c796d900 100644 --- a/src/sess.rs +++ b/src/sess.rs @@ -560,17 +560,19 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { } // Initialize. self.sess.stats.num_database_init.increment(); + // The progress bar object for cloning. We only use it for the + // last fetch operation, which is the only network operation here. + let pb = Some(ProgressHandler::new( + self.sess.progress.clone(), + GitProgressOps::Clone, + name, + )); git.clone() .spawn_with(|c| c.arg("init").arg("--bare"), None) .await?; git.clone() .spawn_with(|c| c.arg("remote").arg("add").arg("origin").arg(url), None) .await?; - let pb = Some(ProgressHandler::new( - self.sess.progress.clone(), - GitProgressOps::Clone, - name, - )); git.clone() .fetch("origin", pb) .and_then(|_| async { @@ -600,6 +602,7 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { return Ok(git); } self.sess.stats.num_database_fetch.increment(); + // The progress bar object for fetching. let pb = Some(ProgressHandler::new( self.sess.progress.clone(), GitProgressOps::Fetch, @@ -995,7 +998,7 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { if clear == CheckoutState::ToClone { let pb = Some(ProgressHandler::new( self.sess.progress.clone(), - GitProgressOps::Clone, + GitProgressOps::Checkout, name, )); git.clone() @@ -1018,11 +1021,6 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { ) .await?; } else if clear == CheckoutState::ToCheckout { - let pb = Some(ProgressHandler::new( - self.sess.progress.clone(), - GitProgressOps::Fetch, - name, - )); local_git .clone() .spawn_with( @@ -1033,7 +1031,7 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { .arg("--prune") .arg("--progress") }, - pb, + None, ) .await?; let pb = Some(ProgressHandler::new( @@ -1085,24 +1083,26 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { Warnings::LfsDisabled(name.to_string()).emit(); } } - let pb = Some(ProgressHandler::new( - self.sess.progress.clone(), - GitProgressOps::Clone, - name, - )); - local_git - .clone() - .spawn_with( - move |c| { - c.arg("submodule") - .arg("update") - .arg("--init") - .arg("--recursive") - .arg("--progress") - }, - pb, - ) - .await?; + if path.join(".gitmodules").exists() { + let pb = Some(ProgressHandler::new( + self.sess.progress.clone(), + GitProgressOps::Submodule, + name, + )); + local_git + .clone() + .spawn_with( + move |c| { + c.arg("submodule") + .arg("update") + .arg("--init") + .arg("--recursive") + .arg("--progress") + }, + pb, + ) + .await?; + } } Ok(path) } From ed01ef06496791c6b9af0b281f983753d8a1859f Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Fri, 2 Jan 2026 12:55:37 +0100 Subject: [PATCH 07/34] progress: Stylistic changes progress: Print duration of git operation progress: Stylistic changes progress: Add `Submodule` operations progress: Improve time formatting progress: Mention submodules in completed message progress: Improve display for submodules progress: Stylistic changes for submodules --- src/progress.rs | 87 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 61 insertions(+), 26 deletions(-) diff --git a/src/progress.rs b/src/progress.rs index f10a6deb..135f3b03 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -43,6 +43,7 @@ pub enum GitProgressOps { Checkout, Clone, Fetch, + Submodule, } static RE_GIT: OnceLock = OnceLock::new(); @@ -119,17 +120,14 @@ pub struct ProgressState { pb: ProgressBar, /// The progress bar for submodules, if any. sub_pb: Option, - /// Whether the main progress bar is done. - /// This is used to determine when to start submodule progress bars. - main_done: bool, + /// The start time of the operation. + start_time: std::time::Instant, } /// This struct captures (static) information neeed to handle progress updates for a git operation. pub struct ProgressHandler { /// Reference to the multi-progress bar, which can manage multiple progress bars. mpb: MultiProgress, - /// The style used for progress bars. - style: ProgressStyle, /// The type of git operation being performed. git_op: GitProgressOps, /// The name of the repository being processed. @@ -139,36 +137,34 @@ pub struct ProgressHandler { impl ProgressHandler { /// Create a new progress handler for a git operation. pub fn new(mpb: MultiProgress, git_op: GitProgressOps, name: &str) -> Self { - // Set the style for progress bars - let style = ProgressStyle::with_template( - "{spinner:.green} {prefix:<24!} {bar:40.cyan/blue} {percent:>3}% {msg}", - ) - .unwrap() - .progress_chars("-- ") - .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]); - Self { mpb, git_op, name: name.to_string(), - style, } } pub fn start(&self) -> ProgressState { - // Add a new progress bar to the multi-progress (with a length of 100) - let pb = self.mpb.add(ProgressBar::new(100)); - pb.set_style(self.style.clone()); + // Create and configure the main progress bar + let pb_style = ProgressStyle::with_template( + "{spinner:.green} {prefix:<32!} {bar:40.cyan/blue} {percent:>3}% {msg}", + ) + .unwrap() + .progress_chars("-- ") + .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]); + + let pb = self.mpb.add(ProgressBar::new(100).with_style(pb_style)); let prefix = match self.git_op { GitProgressOps::Clone => "Cloning", GitProgressOps::Fetch => "Fetching", GitProgressOps::Checkout => "Checkout", + GitProgressOps::Submodule => "Update Submodules", }; let prefix = format!( "{} {}", console::style(prefix).bold().green(), - console::style(&self.name).bright() + console::style(&self.name).bold() ); pb.set_prefix(prefix); // Configure the spinners to automatically tick every 100ms @@ -177,7 +173,7 @@ impl ProgressHandler { ProgressState { pb, sub_pb: None, - main_done: false, + start_time: std::time::Instant::now(), } } @@ -187,19 +183,36 @@ impl ProgressHandler { match progress { GitProgress::CloningInto { path } => { - if state.main_done { - state.pb.set_position(100); - state.pb.set_message(style("Done.").dim().to_string()); + // Only spawn a sub-bar if we are explicitly running the 'Submodule' op. + // For normal Clone/Checkout, 'Cloning into' is just the main repo header, which we ignore. + if self.git_op == GitProgressOps::Submodule { + if let Some(sub) = state.sub_pb.take() { + sub.finish_and_clear(); + } + // The main simply becomes a spinner since the sub-bar will show progress + // on the subsequent line. + state.pb.set_style( + ProgressStyle::with_template("{spinner:.green} {prefix:<32!}").unwrap(), + ); + + // The submodule style is similar to the main bar, but indented and without spinner + let sub_pb_style = ProgressStyle::with_template( + " {prefix:<32!} {bar:40.cyan/blue} {percent:>3}% {msg}", + ) + .unwrap() + .progress_chars("-- "); - let sub_pb = self.mpb.insert_after(&state.pb, ProgressBar::new(100)); - sub_pb.set_style(self.style.clone()); + // Create the submodule progress bar below the main one + let sub_pb = self + .mpb + .insert_after(&state.pb, ProgressBar::new(100).with_style(sub_pb_style)); + // Set the submodule prefix to the submodule name let sub_name = path.split('/').last().unwrap_or(&path); - let sub_prefix = format!(" {} {}", style("└─ ").dim(), style(sub_name).dim()); + let sub_prefix = format!("{} {}", style("└─ ").dim(), style(sub_name).dim()); sub_pb.set_prefix(sub_prefix); state.sub_pb = Some(sub_pb); } - state.main_done = true; } GitProgress::SubmoduleEnd { .. } => { if let Some(sub) = state.sub_pb.take() { @@ -227,6 +240,28 @@ impl ProgressHandler { sub.finish_and_clear(); } state.pb.finish_and_clear(); + + // Print a final message indicating completion + let op_str = match self.git_op { + GitProgressOps::Clone => "Cloned", + GitProgressOps::Fetch => "Fetched", + GitProgressOps::Checkout => "Checked out", + GitProgressOps::Submodule => "Updated Submodules", + }; + + // Format the duration nicely based on its length + let duration_str = match state.start_time.elapsed().as_millis() { + ms if ms < 1000 => format!("in {}ms", ms), + ms => format!("in {:.1}s", ms as f64 / 1000.0), + }; + self.mpb + .println(format!( + " {} {} {}", + style(op_str).green().bold(), + style(&self.name).bold(), + style(duration_str).dim() + )) + .unwrap(); } } From c113ba08e1390114d6af6d9057ab8e385770c329 Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Fri, 2 Jan 2026 17:53:32 +0100 Subject: [PATCH 08/34] error: Align styling with progress bar error: Add formatting macros error: Align `stageln` to other macros error: Rename `Note` to `Info` --- src/cmd/vendor.rs | 1 - src/error.rs | 64 +++++++++++++++++++++++++++++++---------------- src/progress.rs | 29 +++++++++------------ 3 files changed, 54 insertions(+), 40 deletions(-) diff --git a/src/cmd/vendor.rs b/src/cmd/vendor.rs index 5cb97ca3..333fab5d 100644 --- a/src/cmd/vendor.rs +++ b/src/cmd/vendor.rs @@ -102,7 +102,6 @@ pub fn run(sess: &Session, args: &VendorArgs) -> Result<()> { DependencySource::Git(ref url) => { let git = Git::new(tmp_path, &sess.config.git, sess.git_throttle.clone()); rt.block_on(async { - // stageln!("Cloning", "{} ({})", vendor_package.name, url); let pb = ProgressHandler::new( sess.progress.clone(), GitProgressOps::Clone, diff --git a/src/error.rs b/src/error.rs index 7cdc32a3..593bde8e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -9,6 +9,7 @@ use std::sync::atomic::AtomicBool; use std::sync::Arc; use std::sync::{Arc, RwLock}; +use console::style; use indicatif::MultiProgress; pub static ENABLE_DEBUG: AtomicBool = AtomicBool::new(false); @@ -43,8 +44,8 @@ macro_rules! errorln { /// Print an informational note. #[macro_export] -macro_rules! noteln { - ($($arg:tt)*) => { diagnostic!($crate::error::Severity::Note; $($arg)*); } +macro_rules! infoln { + ($($arg:tt)*) => { diagnostic!($crate::error::Severity::Info; $($arg)*); } } /// Print debug information. Omitted in release builds. @@ -58,6 +59,12 @@ macro_rules! debugln { } } +/// Format and print stage progress. +#[macro_export] +macro_rules! stageln { + ($stage_name:expr, $($arg:tt)*) => { diagnostic!($crate::error::Severity::Stage($stage_name); $($arg)*); } +} + /// Print debug information. Omitted in release builds. #[macro_export] #[cfg(not(debug_assertions))] @@ -78,20 +85,46 @@ macro_rules! diagnostic { #[derive(PartialEq, Eq)] pub enum Severity { Debug, - Note, + Info, Warning, Error, + Stage(&'static str), +} + +/// Style a message in green bold. +#[macro_export] +macro_rules! green_bold { + ($arg:expr) => { + console::style($arg).green().bold() + }; +} + +/// Style a message in dimmed text. +#[macro_export] +macro_rules! dim { + ($arg:expr) => { + console::style($arg).dim() + }; +} + +/// Style a message in bold text. +#[macro_export] +macro_rules! bold { + ($arg:expr) => { + console::style($arg).bold() + }; } impl fmt::Display for Severity { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let (color, prefix) = match *self { - Severity::Error => ("\x1B[31;1m", "error"), - Severity::Warning => ("\x1B[33;1m", "warning"), - Severity::Note => ("\x1B[;1m", "note"), - Severity::Debug => ("\x1B[34;1m", "debug"), + let styled_str = match *self { + Severity::Error => style("Error:").red().bold(), + Severity::Warning => style("Warning:").yellow().bold(), + Severity::Info => style("Info:").white().bold(), + Severity::Debug => style("Debug:").blue().bold(), + Severity::Stage(name) => style(name).green().bold(), }; - write!(f, "{}{}:\x1B[m", color, prefix) + write!(f, " {}", styled_str) } } @@ -163,16 +196,3 @@ impl From for Error { Error::chain("Cannot startup runtime.".to_string(), err) } } - -/// Format and print stage progress. -#[macro_export] -macro_rules! stageln { - ($stage:expr, $($arg:tt)*) => { - $crate::error::println_stage($stage, &format!($($arg)*)) - } -} - -/// Print stage progress. -pub fn println_stage(stage: &str, message: &str) { - eprintln!("\x1B[32;1m{:>12}\x1B[0m {}", stage, message); -} diff --git a/src/progress.rs b/src/progress.rs index 135f3b03..40a53510 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -4,7 +4,6 @@ use std::sync::OnceLock; use std::time::Duration; -use console::style; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use regex::Regex; use tokio::io::{AsyncReadExt, BufReader}; @@ -146,14 +145,14 @@ impl ProgressHandler { pub fn start(&self) -> ProgressState { // Create and configure the main progress bar - let pb_style = ProgressStyle::with_template( + let style = ProgressStyle::with_template( "{spinner:.green} {prefix:<32!} {bar:40.cyan/blue} {percent:>3}% {msg}", ) .unwrap() .progress_chars("-- ") .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]); - let pb = self.mpb.add(ProgressBar::new(100).with_style(pb_style)); + let pb = self.mpb.add(ProgressBar::new(100).with_style(style)); let prefix = match self.git_op { GitProgressOps::Clone => "Cloning", @@ -161,11 +160,7 @@ impl ProgressHandler { GitProgressOps::Checkout => "Checkout", GitProgressOps::Submodule => "Update Submodules", }; - let prefix = format!( - "{} {}", - console::style(prefix).bold().green(), - console::style(&self.name).bold() - ); + let prefix = format!("{} {}", green_bold!(prefix), bold!(&self.name)); pb.set_prefix(prefix); // Configure the spinners to automatically tick every 100ms pb.enable_steady_tick(Duration::from_millis(100)); @@ -196,7 +191,7 @@ impl ProgressHandler { ); // The submodule style is similar to the main bar, but indented and without spinner - let sub_pb_style = ProgressStyle::with_template( + let style = ProgressStyle::with_template( " {prefix:<32!} {bar:40.cyan/blue} {percent:>3}% {msg}", ) .unwrap() @@ -205,11 +200,11 @@ impl ProgressHandler { // Create the submodule progress bar below the main one let sub_pb = self .mpb - .insert_after(&state.pb, ProgressBar::new(100).with_style(sub_pb_style)); + .insert_after(&state.pb, ProgressBar::new(100).with_style(style)); // Set the submodule prefix to the submodule name let sub_name = path.split('/').last().unwrap_or(&path); - let sub_prefix = format!("{} {}", style("└─ ").dim(), style(sub_name).dim()); + let sub_prefix = format!("{} {}", dim!("└─ "), dim!(sub_name)); sub_pb.set_prefix(sub_prefix); state.sub_pb = Some(sub_pb); } @@ -220,15 +215,15 @@ impl ProgressHandler { } } GitProgress::Receiving { current, .. } => { - target_pb.set_message(style("Receiving objects").dim().to_string()); + target_pb.set_message(dim!("Receiving objects").to_string()); target_pb.set_position(current as u64); } GitProgress::Resolving { percent, .. } => { - target_pb.set_message(style("Resolving deltas").dim().to_string()); + target_pb.set_message(dim!("Resolving deltas").to_string()); target_pb.set_position(percent as u64); } GitProgress::Checkout { percent, .. } => { - target_pb.set_message(style("Checking out").dim().to_string()); + target_pb.set_message(dim!("Checking out").to_string()); target_pb.set_position(percent as u64); } _ => {} @@ -257,9 +252,9 @@ impl ProgressHandler { self.mpb .println(format!( " {} {} {}", - style(op_str).green().bold(), - style(&self.name).bold(), - style(duration_str).dim() + green_bold!(op_str), + bold!(&self.name), + dim!(duration_str) )) .unwrap(); } From 79d1c499a4340ecca82e92efc13d0ec0ddff1de4 Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Fri, 2 Jan 2026 18:39:32 +0100 Subject: [PATCH 09/34] util: Move formatting duration to `util` --- src/progress.rs | 9 +++------ src/util.rs | 6 ++++++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/progress.rs b/src/progress.rs index 40a53510..efd28ecc 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -1,6 +1,8 @@ // Copyright (c) 2025 ETH Zurich // Tim Fischer +use crate::util::fmt_duration; + use std::sync::OnceLock; use std::time::Duration; @@ -244,17 +246,12 @@ impl ProgressHandler { GitProgressOps::Submodule => "Updated Submodules", }; - // Format the duration nicely based on its length - let duration_str = match state.start_time.elapsed().as_millis() { - ms if ms < 1000 => format!("in {}ms", ms), - ms => format!("in {:.1}s", ms as f64 / 1000.0), - }; self.mpb .println(format!( " {} {} {}", green_bold!(op_str), bold!(&self.name), - dim!(duration_str) + dim!(fmt_duration(state.start_time.elapsed())) )) .unwrap(); } diff --git a/src/util.rs b/src/util.rs index df840984..c8292a53 100644 --- a/src/util.rs +++ b/src/util.rs @@ -428,6 +428,12 @@ pub fn version_req_bottom_bound(req: &VersionReq) -> Result> { Ok(Some(bottom_bound)) } else { Ok(None) +/// Format time duration with proper units. +pub fn fmt_duration(duration: std::time::Duration) -> String { + match duration.as_millis() { + t if t < 1000 => format!("in {}ms", t), + t if t < 60_000 => format!("in {:.1}s", t as f64 / 1000.0), + t => format!("in {:.1}min", t as f64 / 60000.0), } } From 7e24018280ee89518947f553f863d29b36b65117 Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Fri, 2 Jan 2026 18:40:03 +0100 Subject: [PATCH 10/34] checkout: Print out total elapsed time for checkout --- src/cmd/checkout.rs | 11 ++++++++++- src/sess.rs | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/cmd/checkout.rs b/src/cmd/checkout.rs index 3fa7a144..c176da6d 100644 --- a/src/cmd/checkout.rs +++ b/src/cmd/checkout.rs @@ -8,6 +8,7 @@ use tokio::runtime::Runtime; use crate::error::*; use crate::sess::{Session, SessionIo}; +use crate::util::fmt_duration; /// Checkout all dependencies referenced in the Lock file #[derive(Args, Debug)] @@ -26,7 +27,15 @@ pub fn run(sess: &Session, args: &CheckoutArgs) -> Result<()> { pub fn run_plain(sess: &Session, force: bool, update_list: &[String]) -> Result<()> { let rt = Runtime::new()?; let io = SessionIo::new(sess); - let _srcs = rt.block_on(io.sources(force, update_list))?; + let start_time = std::time::Instant::now(); + let _srcs = rt.block_on(io.sources(forcibly, update_list))?; + let num_dependencies = io.sess.names.lock().unwrap().len(); + infoln!( + "{} {} dependencies {}", + dim!("Checked out"), + num_dependencies, + dim!(fmt_duration(start_time.elapsed())) + ); Ok(()) } diff --git a/src/sess.rs b/src/sess.rs index c796d900..ff93f927 100644 --- a/src/sess.rs +++ b/src/sess.rs @@ -66,7 +66,7 @@ pub struct Session<'ctx> { /// The internalized strings. strings: Mutex>, /// The package name table. - names: Mutex>, + pub names: Mutex>, /// The dependency graph. graph: Mutex>>>, /// The topologically sorted list of packages. From 20d0e2ea4434c806666247959e653d72a5c4dec5 Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Fri, 2 Jan 2026 20:22:43 +0100 Subject: [PATCH 11/34] progress: Print out git error messages --- src/error.rs | 8 ++++++++ src/progress.rs | 18 ++++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/error.rs b/src/error.rs index 593bde8e..e37532f9 100644 --- a/src/error.rs +++ b/src/error.rs @@ -99,6 +99,14 @@ macro_rules! green_bold { }; } +/// Style a message in green bold. +#[macro_export] +macro_rules! red_bold { + ($arg:expr) => { + console::style($arg).red().bold() + }; +} + /// Style a message in dimmed text. #[macro_export] macro_rules! dim { diff --git a/src/progress.rs b/src/progress.rs index efd28ecc..0f96510b 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -35,6 +35,7 @@ pub enum GitProgress { current: usize, total: usize, }, + Error(String), Other, } @@ -57,8 +58,8 @@ pub fn parse_git_line(line: &str) -> GitProgress { (?: Cloning\ into\ '(?P[^']+)'\.\.\. | Submodule\ path\ '(?P[^']+)':\ checked\ out\ '.* | - (?PReceiving\ objects|Resolving\ deltas|Checking\ out\ files):\s+(?P\d+)% - (?: \s+ \( (?P\d+) / (?P\d+) \) )? + (?PReceiving\ objects|Resolving\ deltas|Checking\ out\ files):\s+(?P\d+)% | + (?Pfatal:.*|error:.*|remote:\ aborting.*) # <--- Capture errors ) ").expect("Invalid Regex") }); @@ -109,6 +110,9 @@ pub fn parse_git_line(line: &str) -> GitProgress { _ => GitProgress::Other, }; } + if let Some(err) = caps.name("error") { + return GitProgress::Error(err.as_str().to_string()); + } } // Otherwise, we don't care GitProgress::Other @@ -228,6 +232,16 @@ impl ProgressHandler { target_pb.set_message(dim!("Checking out").to_string()); target_pb.set_position(percent as u64); } + GitProgress::Error(err_msg) => { + target_pb.finish_and_clear(); + // TODO(fischeti): Consider enumerating error + errorln!( + "{} {}: {}", + "Error during git operation of", + bold!(&self.name), + err_msg + ); + } _ => {} } } From f64a447ac7dfe8ba7c8f066faec79c30b4533cd2 Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Fri, 2 Jan 2026 21:24:03 +0100 Subject: [PATCH 12/34] progress: Show all submodules as tree structure --- src/progress.rs | 185 +++++++++++++++++++++++++++--------------------- 1 file changed, 104 insertions(+), 81 deletions(-) diff --git a/src/progress.rs b/src/progress.rs index 0f96510b..eb20f978 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -3,6 +3,7 @@ use crate::util::fmt_duration; +use indexmap::IndexMap; use std::sync::OnceLock; use std::time::Duration; @@ -14,27 +15,12 @@ use tokio::io::{AsyncReadExt, BufReader}; /// (Put your `GitProgress` enum and `parse_git_line` function here) #[derive(Debug, PartialEq, Clone)] pub enum GitProgress { - CloningInto { - path: String, - }, - SubmoduleEnd { - path: String, - }, - Receiving { - percent: u8, - current: usize, - total: usize, - }, - Resolving { - percent: u8, - current: usize, - total: usize, - }, - Checkout { - percent: u8, - current: usize, - total: usize, - }, + SubmoduleRegistered { name: String }, + CloningInto { name: String }, + SubmoduleEnd { name: String }, + Receiving { percent: u8 }, + Resolving { percent: u8 }, + Checkout { percent: u8 }, Error(String), Other, } @@ -50,69 +36,67 @@ pub enum GitProgressOps { static RE_GIT: OnceLock = OnceLock::new(); +/// Helper to extract the name from a git path. +fn path_to_name(path: &str) -> String { + path.trim_end_matches('/') + .split('/') + .last() + .unwrap_or(path) + .to_string() +} + pub fn parse_git_line(line: &str) -> GitProgress { let line = line.trim(); let re = RE_GIT.get_or_init(|| { Regex::new(r"(?x) ^ # Start (?: + # 1. Registration: Capture the path, ignore the descriptive name + Submodule\ '[^']+'\ .*\ registered\ for\ path\ '(?P[^']+)' | + + # 2. Cloning: Capture the path Cloning\ into\ '(?P[^']+)'\.\.\. | - Submodule\ path\ '(?P[^']+)':\ checked\ out\ '.* | + + # 3. Completion: Capture the name + Submodule\ path\ '(?P[^']+)':\ checked\ out\ '.* | + + # 4. Progress (?PReceiving\ objects|Resolving\ deltas|Checking\ out\ files):\s+(?P\d+)% | - (?Pfatal:.*|error:.*|remote:\ aborting.*) # <--- Capture errors + + # 5. Errors + (?Pfatal:.*|error:.*|remote:\ aborting.*) ) ").expect("Invalid Regex") }); if let Some(caps) = re.captures(line) { - // Case 1: Cloning into... + if let Some(path) = caps.name("reg_path") { + return GitProgress::SubmoduleRegistered { + name: path_to_name(path.as_str()), + }; + } if let Some(path) = caps.name("clone_path") { return GitProgress::CloningInto { - path: path.as_str().to_string(), + name: path_to_name(path.as_str()), }; } - - // Case 2: Submodule finished - if let Some(path) = caps.name("sub_end_path") { + if let Some(path) = caps.name("sub_end_name") { return GitProgress::SubmoduleEnd { - path: path.as_str().to_string(), + name: path_to_name(path.as_str()), }; } - - // Case 3: Progress + if let Some(err) = caps.name("error") { + return GitProgress::Error(err.as_str().to_string()); + } if let Some(phase) = caps.name("phase") { let percent = caps.name("percent").unwrap().as_str().parse().unwrap_or(0); - let current = caps - .name("current") - .map(|m| m.as_str().parse().unwrap_or(0)) - .unwrap_or(0); - let total = caps - .name("total") - .map(|m| m.as_str().parse().unwrap_or(0)) - .unwrap_or(0); - return match phase.as_str() { - "Receiving objects" => GitProgress::Receiving { - percent, - current, - total, - }, - "Resolving deltas" => GitProgress::Resolving { - percent, - current, - total, - }, - "Checking out files" => GitProgress::Checkout { - percent, - current, - total, - }, + "Receiving objects" => GitProgress::Receiving { percent }, + "Resolving deltas" => GitProgress::Resolving { percent }, + "Checking out files" => GitProgress::Checkout { percent }, _ => GitProgress::Other, }; } - if let Some(err) = caps.name("error") { - return GitProgress::Error(err.as_str().to_string()); - } } // Otherwise, we don't care GitProgress::Other @@ -123,8 +107,10 @@ pub fn parse_git_line(line: &str) -> GitProgress { pub struct ProgressState { /// The progress bar of the current package. pb: ProgressBar, - /// The progress bar for submodules, if any. - sub_pb: Option, + /// The sub-progress bar (for submodules), if any. + pub sub_bars: IndexMap, + // The currently active submodule, if any. + pub active_sub: Option, /// The start time of the operation. start_time: std::time::Instant, } @@ -173,23 +159,25 @@ impl ProgressHandler { ProgressState { pb, - sub_pb: None, + sub_bars: IndexMap::new(), + active_sub: None, start_time: std::time::Instant::now(), } } pub fn update_pb(&self, line: &str, state: &mut ProgressState) { let progress = parse_git_line(line); - let target_pb = state.sub_pb.as_ref().unwrap_or(&state.pb); + + // Target the active submodule if one exists, otherwise the main bar + let target_pb = if let Some(name) = &state.active_sub { + state.sub_bars.get(name).unwrap_or(&state.pb) + } else { + &state.pb + }; match progress { - GitProgress::CloningInto { path } => { - // Only spawn a sub-bar if we are explicitly running the 'Submodule' op. - // For normal Clone/Checkout, 'Cloning into' is just the main repo header, which we ignore. + GitProgress::SubmoduleRegistered { name } => { if self.git_op == GitProgressOps::Submodule { - if let Some(sub) = state.sub_pb.take() { - sub.finish_and_clear(); - } // The main simply becomes a spinner since the sub-bar will show progress // on the subsequent line. state.pb.set_style( @@ -203,26 +191,60 @@ impl ProgressHandler { .unwrap() .progress_chars("-- "); - // Create the submodule progress bar below the main one + // Tree Logic + let ref_bar = match state.sub_bars.last() { + Some((last_name, last_pb)) => { + // Update the previous last bar to have a "T" connector (├─) + // because it is no longer the last one. + let prev_prefix = format!("{} {}", dim!("├─ "), dim!(last_name)); + last_pb.set_prefix(prev_prefix); + last_pb // Insert the new one after this one + } + None => &state.pb, // Insert the first one after the main bar + }; + + // Create bar immediately let sub_pb = self .mpb - .insert_after(&state.pb, ProgressBar::new(100).with_style(style)); + .insert_after(ref_bar, ProgressBar::new(100).with_style(style)); - // Set the submodule prefix to the submodule name - let sub_name = path.split('/').last().unwrap_or(&path); - let sub_prefix = format!("{} {}", dim!("└─ "), dim!(sub_name)); + let sub_prefix = format!("{} {}", dim!("└─ "), dim!(&name)); sub_pb.set_prefix(sub_prefix); - state.sub_pb = Some(sub_pb); + sub_pb.set_message(dim!("Waiting...").to_string()); + + state.sub_bars.insert(name, sub_pb); + } + } + GitProgress::CloningInto { name } => { + if self.git_op == GitProgressOps::Submodule { + // Logic to handle missing 'checked out' lines: + // If we are activating 'bar', but 'foo' was active, assume 'foo' is done. + if let Some(prev) = &state.active_sub { + if prev != &name { + if let Some(b) = state.sub_bars.get(prev) { + b.finish_and_clear(); + } + } + } + // Activate the new bar + if let Some(bar) = state.sub_bars.get(&name) { + // Switch style to the active progress bar style + bar.set_message(dim!("Cloning...").to_string()); + } + state.active_sub = Some(name); } } - GitProgress::SubmoduleEnd { .. } => { - if let Some(sub) = state.sub_pb.take() { - sub.finish_and_clear(); + GitProgress::SubmoduleEnd { name } => { + if let Some(bar) = state.sub_bars.get(&name) { + bar.finish_and_clear(); + } + if state.active_sub.as_ref() == Some(&name) { + state.active_sub = None; } } - GitProgress::Receiving { current, .. } => { + GitProgress::Receiving { percent, .. } => { target_pb.set_message(dim!("Receiving objects").to_string()); - target_pb.set_position(current as u64); + target_pb.set_position(percent as u64); } GitProgress::Resolving { percent, .. } => { target_pb.set_message(dim!("Resolving deltas").to_string()); @@ -247,8 +269,9 @@ impl ProgressHandler { } pub fn finish(self, state: &mut ProgressState) { - if let Some(sub) = state.sub_pb.take() { - sub.finish_and_clear(); + // Clear all sub bars that might be lingering + for pb in state.sub_bars.values() { + pb.finish_and_clear(); } state.pb.finish_and_clear(); From b151eb6c437d4e9ee32ca46babd915e21799e644 Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Fri, 2 Jan 2026 21:35:03 +0100 Subject: [PATCH 13/34] progress: Add more unit tests --- src/progress.rs | 62 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/src/progress.rs b/src/progress.rs index eb20f978..07deecc3 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -341,7 +341,7 @@ mod tests { use super::*; #[test] - fn test_parsing_logic() { + fn test_parsing_receiving() { // Copy your existing unit tests here let p = parse_git_line("Receiving objects: 34% (123/456)"); match p { @@ -349,4 +349,64 @@ mod tests { _ => panic!("Failed to parse receiving"), } } + #[test] + fn test_parsing_receiving_done() { + // Copy your existing unit tests here + let p = + parse_git_line("Receiving objects: 100% (1955/1955), 1.51 MiB | 45.53 MiB/s, done."); + match p { + GitProgress::Receiving { percent, .. } => assert_eq!(percent, 100), + _ => panic!("Failed to parse receiving"), + } + } + #[test] + fn test_parsing_resolving() { + // Copy your existing unit tests here + let p = parse_git_line("Resolving deltas: 56% (789/1400)"); + match p { + GitProgress::Resolving { percent, .. } => assert_eq!(percent, 56), + _ => panic!("Failed to parse receiving"), + } + } + #[test] + fn test_parsing_resolving_deltas_done() { + // Copy your existing unit tests here + let p = parse_git_line("Resolving deltas: 100% (1122/1122), done."); + match p { + GitProgress::Resolving { percent, .. } => assert_eq!(percent, 100), + _ => panic!("Failed to parse receiving"), + } + } + #[test] + fn test_parsing_cloning_into() { + let p = parse_git_line("Cloning into 'myrepo'..."); + match p { + GitProgress::CloningInto { name } => assert_eq!(name, "myrepo"), + _ => panic!("Failed to parse cloning into"), + } + } + #[test] + fn test_parsing_submodule_registered() { + let p = parse_git_line("Submodule 'libs/mylib' ... registered for path 'libs/mylib'"); + match p { + GitProgress::SubmoduleRegistered { name } => assert_eq!(name, "mylib"), + _ => panic!("Failed to parse submodule registered"), + } + } + #[test] + fn test_parsing_submodule_end() { + let p = parse_git_line("Submodule path 'libs/mylib': checked out 'abc1234'"); + match p { + GitProgress::SubmoduleEnd { name } => assert_eq!(name, "mylib"), + _ => panic!("Failed to parse submodule end"), + } + } + #[test] + fn test_parsing_error() { + let p = parse_git_line("fatal: unable to access 'https://example.com/repo.git/': Could not resolve host: example.com"); + match p { + GitProgress::Error(msg) => assert!(msg.contains("fatal: unable to access")), + _ => panic!("Failed to parse error"), + } + } } From 5defa36eca073981ef2883c02d3ffb3e73e2e10a Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Fri, 2 Jan 2026 21:42:55 +0100 Subject: [PATCH 14/34] progress: Clean up and document progress: Don't check for ASCII characters progress: Change the order of functions, enums and impl blocks progress: Clean up and document --- src/progress.rs | 289 +++++++++++++++++++++++++++--------------------- 1 file changed, 160 insertions(+), 129 deletions(-) diff --git a/src/progress.rs b/src/progress.rs index 07deecc3..f434fe3e 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -11,9 +11,9 @@ use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use regex::Regex; use tokio::io::{AsyncReadExt, BufReader}; -/// Parses a line of git output. -/// (Put your `GitProgress` enum and `parse_git_line` function here) -#[derive(Debug, PartialEq, Clone)] +static RE_GIT: OnceLock = OnceLock::new(); + +/// The result of parsing a git progress line. pub enum GitProgress { SubmoduleRegistered { name: String }, CloningInto { name: String }, @@ -25,84 +25,7 @@ pub enum GitProgress { Other, } -/// The git operation types that currently support progress reporting. -#[derive(Debug, PartialEq, Clone)] -pub enum GitProgressOps { - Checkout, - Clone, - Fetch, - Submodule, -} - -static RE_GIT: OnceLock = OnceLock::new(); - -/// Helper to extract the name from a git path. -fn path_to_name(path: &str) -> String { - path.trim_end_matches('/') - .split('/') - .last() - .unwrap_or(path) - .to_string() -} - -pub fn parse_git_line(line: &str) -> GitProgress { - let line = line.trim(); - let re = RE_GIT.get_or_init(|| { - Regex::new(r"(?x) - ^ # Start - (?: - # 1. Registration: Capture the path, ignore the descriptive name - Submodule\ '[^']+'\ .*\ registered\ for\ path\ '(?P[^']+)' | - - # 2. Cloning: Capture the path - Cloning\ into\ '(?P[^']+)'\.\.\. | - - # 3. Completion: Capture the name - Submodule\ path\ '(?P[^']+)':\ checked\ out\ '.* | - - # 4. Progress - (?PReceiving\ objects|Resolving\ deltas|Checking\ out\ files):\s+(?P\d+)% | - - # 5. Errors - (?Pfatal:.*|error:.*|remote:\ aborting.*) - ) - ").expect("Invalid Regex") - }); - - if let Some(caps) = re.captures(line) { - if let Some(path) = caps.name("reg_path") { - return GitProgress::SubmoduleRegistered { - name: path_to_name(path.as_str()), - }; - } - if let Some(path) = caps.name("clone_path") { - return GitProgress::CloningInto { - name: path_to_name(path.as_str()), - }; - } - if let Some(path) = caps.name("sub_end_name") { - return GitProgress::SubmoduleEnd { - name: path_to_name(path.as_str()), - }; - } - if let Some(err) = caps.name("error") { - return GitProgress::Error(err.as_str().to_string()); - } - if let Some(phase) = caps.name("phase") { - let percent = caps.name("percent").unwrap().as_str().parse().unwrap_or(0); - return match phase.as_str() { - "Receiving objects" => GitProgress::Receiving { percent }, - "Resolving deltas" => GitProgress::Resolving { percent }, - "Checking out files" => GitProgress::Checkout { percent }, - _ => GitProgress::Other, - }; - } - } - // Otherwise, we don't care - GitProgress::Other -} - -/// This struct captures (dynamic) state information for a git operation's progress. +/// Captures (dynamic) state information for a git operation's progress. /// for instance, the actuall progress bars to update. pub struct ProgressState { /// The progress bar of the current package. @@ -115,7 +38,7 @@ pub struct ProgressState { start_time: std::time::Instant, } -/// This struct captures (static) information neeed to handle progress updates for a git operation. +/// Captures (static) information neeed to handle progress updates for a git operation. pub struct ProgressHandler { /// Reference to the multi-progress bar, which can manage multiple progress bars. mpb: MultiProgress, @@ -125,6 +48,66 @@ pub struct ProgressHandler { name: String, } +/// The git operation types that currently support progress reporting. +#[derive(PartialEq)] +pub enum GitProgressOps { + Checkout, + Clone, + Fetch, + Submodule, +} + +/// Monitor the stderr stream of a git process and update progress bars +/// of a given handler accordingly. +pub async fn monitor_stderr( + stream: impl tokio::io::AsyncRead + Unpin, + handler: Option, +) -> String { + let mut reader = BufReader::new(stream); + let mut buffer = Vec::new(); // Buffer for accumulating bytes of a line + let mut raw_log = Vec::new(); // The full raw log output + + // Add a new progress bar and state if we have a handler + let mut state = handler.as_ref().map(|h| h.start()); + + // We loop over the stream reading byte by byte + // and process lines as they are completed. + loop { + match reader.read_u8().await { + Ok(byte) => { + raw_log.push(byte); + + // Git output lines end with either \n or \r + // Every time we encounter one, we process the line. + // Note: \r is used for progress updates, meaning the line + // is overwritten in place. + if byte == b'\r' || byte == b'\n' { + if !buffer.is_empty() { + if let Ok(line) = std::str::from_utf8(&buffer) { + // Update UI if we have a handler + if let Some(h) = &handler { + h.update_pb(line, &mut state.as_mut().unwrap()); + } + } + // Clear the buffer for the next line + buffer.clear(); + } + } else { + buffer.push(byte); + } + } + // We break the loop on EOF or error + Err(_) => break, + } + } + + // Finalize the progress bar if we have a handler + handler.map(|h| h.finish(&mut state.unwrap())); + + // Return the full raw log as a string + String::from_utf8_lossy(&raw_log).to_string() +} + impl ProgressHandler { /// Create a new progress handler for a git operation. pub fn new(mpb: MultiProgress, git_op: GitProgressOps, name: &str) -> Self { @@ -135,6 +118,8 @@ impl ProgressHandler { } } + /// Adds a new progress bar to the multi-progress and returns the initial state + /// that is needed to track progress updates. pub fn start(&self) -> ProgressState { // Create and configure the main progress bar let style = ProgressStyle::with_template( @@ -144,8 +129,10 @@ impl ProgressHandler { .progress_chars("-- ") .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]); + // Create and attach the progress bar to the multi-progress bar. let pb = self.mpb.add(ProgressBar::new(100).with_style(style)); + // Set the prefix based on the git operation let prefix = match self.git_op { GitProgressOps::Clone => "Cloning", GitProgressOps::Fetch => "Fetching", @@ -154,6 +141,7 @@ impl ProgressHandler { }; let prefix = format!("{} {}", green_bold!(prefix), bold!(&self.name)); pb.set_prefix(prefix); + // Configure the spinners to automatically tick every 100ms pb.enable_steady_tick(Duration::from_millis(100)); @@ -165,7 +153,9 @@ impl ProgressHandler { } } + /// Update the progress bar(s) based on a parsed git progress line. pub fn update_pb(&self, line: &str, state: &mut ProgressState) { + // Parse the line to determine the type of progress update let progress = parse_git_line(line); // Target the active submodule if one exists, otherwise the main bar @@ -176,9 +166,11 @@ impl ProgressHandler { }; match progress { + // This case is only relevant for submodule operations i.e. `git submodule update` + // It indicates that a new submodule has been registered, and we create a new progress bar for it. GitProgress::SubmoduleRegistered { name } => { if self.git_op == GitProgressOps::Submodule { - // The main simply becomes a spinner since the sub-bar will show progress + // The main bar simply becomes a spinner since the sub-bar will show progress // on the subsequent line. state.pb.set_style( ProgressStyle::with_template("{spinner:.green} {prefix:<32!}").unwrap(), @@ -191,11 +183,11 @@ impl ProgressHandler { .unwrap() .progress_chars("-- "); - // Tree Logic - let ref_bar = match state.sub_bars.last() { + // We can have multiple sub-bars, and we insert them after the last one. + // In order to maintain proper tree-like structure, we need to update the previous last bar + // to have a "T" connector (├─) instead of an "L" + let prev_bar = match state.sub_bars.last() { Some((last_name, last_pb)) => { - // Update the previous last bar to have a "T" connector (├─) - // because it is no longer the last one. let prev_prefix = format!("{} {}", dim!("├─ "), dim!(last_name)); last_pb.set_prefix(prev_prefix); last_pb // Insert the new one after this one @@ -203,18 +195,23 @@ impl ProgressHandler { None => &state.pb, // Insert the first one after the main bar }; - // Create bar immediately + // Create the new sub-bar and insert it in the multi-progress *after* the previous sub-bar let sub_pb = self .mpb - .insert_after(ref_bar, ProgressBar::new(100).with_style(style)); + .insert_after(prev_bar, ProgressBar::new(100).with_style(style)); + // Set the prefix and initial message let sub_prefix = format!("{} {}", dim!("└─ "), dim!(&name)); sub_pb.set_prefix(sub_prefix); - sub_pb.set_message(dim!("Waiting...").to_string()); + sub_pb.set_message(format!("{}", dim!("Waiting..."))); + // Store the sub-bar in the state for later updates state.sub_bars.insert(name, sub_pb); } } + // This indicates that we are starting to clone a submodule. + // Again, it is only relevant for submodule operations. For normal + // clones, we just update the main bar. GitProgress::CloningInto { name } => { if self.git_op == GitProgressOps::Submodule { // Logic to handle missing 'checked out' lines: @@ -226,34 +223,41 @@ impl ProgressHandler { } } } - // Activate the new bar + // Set the new bar to active if let Some(bar) = state.sub_bars.get(&name) { // Switch style to the active progress bar style - bar.set_message(dim!("Cloning...").to_string()); + bar.set_message(format!("{}", dim!("Cloning..."))); } state.active_sub = Some(name); } } + // Indicates that we have finished processing a submodule. GitProgress::SubmoduleEnd { name } => { + // We finish and clear the sub-bar if let Some(bar) = state.sub_bars.get(&name) { bar.finish_and_clear(); } + // If this was the active submodule, we clear the active state if state.active_sub.as_ref() == Some(&name) { state.active_sub = None; } } + // Update the progress percentage for receiving objects GitProgress::Receiving { percent, .. } => { - target_pb.set_message(dim!("Receiving objects").to_string()); + target_pb.set_message(format!("{}", dim!("Receiving objects"))); target_pb.set_position(percent as u64); } + // Update the progress percentage for resolving deltas GitProgress::Resolving { percent, .. } => { - target_pb.set_message(dim!("Resolving deltas").to_string()); + target_pb.set_message(format!("{}", dim!("Resolving deltas"))); target_pb.set_position(percent as u64); } + // Update the progress percentage for checking out files GitProgress::Checkout { percent, .. } => { - target_pb.set_message(dim!("Checking out").to_string()); + target_pb.set_message(format!("{}", dim!("Checking out"))); target_pb.set_position(percent as u64); } + // Handle errors by finishing and clearing the target bar, then logging the error GitProgress::Error(err_msg) => { target_pb.finish_and_clear(); // TODO(fischeti): Consider enumerating error @@ -268,6 +272,7 @@ impl ProgressHandler { } } + // Finalize the progress bars and print a completion message. pub fn finish(self, state: &mut ProgressState) { // Clear all sub bars that might be lingering for pb in state.sub_bars.values() { @@ -283,6 +288,7 @@ impl ProgressHandler { GitProgressOps::Submodule => "Updated Submodules", }; + // Print a completion message on top of active progress bars self.mpb .println(format!( " {} {} {}", @@ -294,46 +300,71 @@ impl ProgressHandler { } } -pub async fn monitor_stderr( - stream: impl tokio::io::AsyncRead + Unpin, - handler: Option, -) -> String { - let mut reader = BufReader::new(stream); - let mut buffer = Vec::new(); - let mut collected_stderr = String::new(); +/// Parse a git progress line and return the corresponding `GitProgress` enum. +pub fn parse_git_line(line: &str) -> GitProgress { + let line = line.trim(); + let re = RE_GIT.get_or_init(|| { + Regex::new(r"(?x) + ^ # Start + (?: + # 1. Registration: Capture the path, ignore the descriptive name + Submodule\ '[^']+'\ .*\ registered\ for\ path\ '(?P[^']+)' | - // Add a new progress bar and state if we have a handler - let mut state = handler.as_ref().map(|h| h.start()); + # 2. Cloning: Capture the path + Cloning\ into\ '(?P[^']+)'\.\.\. | - loop { - match reader.read_u8().await { - Ok(byte) => { - // Collect raw error output (simplified for brevity) - if byte.is_ascii() { - collected_stderr.push(byte as char); - } + # 3. Completion: Capture the name + Submodule\ path\ '(?P[^']+)':\ checked\ out\ '.* | - if byte == b'\r' || byte == b'\n' { - if !buffer.is_empty() { - if let Ok(line) = std::str::from_utf8(&buffer) { - // Update UI if we have a handler - if let Some(h) = &handler { - h.update_pb(line, &mut state.as_mut().unwrap()); - } - } - buffer.clear(); - } - } else { - buffer.push(byte); - } - } - Err(_) => break, + # 4. Progress + (?PReceiving\ objects|Resolving\ deltas|Checking\ out\ files):\s+(?P\d+)% | + + # 5. Errors + (?Pfatal:.*|error:.*|remote:\ aborting.*) + ) + ").expect("Invalid Regex") + }); + + if let Some(caps) = re.captures(line) { + if let Some(path) = caps.name("reg_path") { + return GitProgress::SubmoduleRegistered { + name: path_to_name(path.as_str()), + }; + } + if let Some(path) = caps.name("clone_path") { + return GitProgress::CloningInto { + name: path_to_name(path.as_str()), + }; + } + if let Some(path) = caps.name("sub_end_name") { + return GitProgress::SubmoduleEnd { + name: path_to_name(path.as_str()), + }; + } + if let Some(err) = caps.name("error") { + return GitProgress::Error(err.as_str().to_string()); + } + if let Some(phase) = caps.name("phase") { + let percent = caps.name("percent").unwrap().as_str().parse().unwrap_or(0); + return match phase.as_str() { + "Receiving objects" => GitProgress::Receiving { percent }, + "Resolving deltas" => GitProgress::Resolving { percent }, + "Checking out files" => GitProgress::Checkout { percent }, + _ => GitProgress::Other, + }; } } + // Otherwise, we don't care + GitProgress::Other +} - handler.map(|h| h.finish(&mut state.unwrap())); - - collected_stderr +/// Helper to extract the name from a git path. +fn path_to_name(path: &str) -> String { + path.trim_end_matches('/') + .split('/') + .last() + .unwrap_or(path) + .to_string() } #[cfg(test)] From 6336a57843b9d570110b1ee462cb935e264b4dd9 Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Tue, 6 Jan 2026 19:34:40 +0100 Subject: [PATCH 15/34] cmd(checkout): Use public function to determine number of packages --- src/cmd/checkout.rs | 2 +- src/sess.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cmd/checkout.rs b/src/cmd/checkout.rs index c176da6d..f03b32be 100644 --- a/src/cmd/checkout.rs +++ b/src/cmd/checkout.rs @@ -29,7 +29,7 @@ pub fn run_plain(sess: &Session, force: bool, update_list: &[String]) -> Result< let io = SessionIo::new(sess); let start_time = std::time::Instant::now(); let _srcs = rt.block_on(io.sources(forcibly, update_list))?; - let num_dependencies = io.sess.names.lock().unwrap().len(); + let num_dependencies = io.sess.packages().iter().flatten().count(); infoln!( "{} {} dependencies {}", dim!("Checked out"), diff --git a/src/sess.rs b/src/sess.rs index ff93f927..c796d900 100644 --- a/src/sess.rs +++ b/src/sess.rs @@ -66,7 +66,7 @@ pub struct Session<'ctx> { /// The internalized strings. strings: Mutex>, /// The package name table. - pub names: Mutex>, + names: Mutex>, /// The dependency graph. graph: Mutex>>>, /// The topologically sorted list of packages. From 0c785144894f24175e9d8c9be13ec24918ca4f34 Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Tue, 6 Jan 2026 19:37:55 +0100 Subject: [PATCH 16/34] util: Fix merge conflict mistake --- src/util.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/util.rs b/src/util.rs index c8292a53..c450b801 100644 --- a/src/util.rs +++ b/src/util.rs @@ -428,6 +428,9 @@ pub fn version_req_bottom_bound(req: &VersionReq) -> Result> { Ok(Some(bottom_bound)) } else { Ok(None) + } +} + /// Format time duration with proper units. pub fn fmt_duration(duration: std::time::Duration) -> String { match duration.as_millis() { From bdc6bc62572c1dce989776eddc40794d60fa5739 Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Fri, 9 Jan 2026 00:08:16 +0100 Subject: [PATCH 17/34] progress: Small style changes progress: Color in progress operations in cyan progress: Reword in progress messages --- src/error.rs | 8 ++++++++ src/progress.rs | 15 +++++++-------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/error.rs b/src/error.rs index e37532f9..4cc6ee35 100644 --- a/src/error.rs +++ b/src/error.rs @@ -99,6 +99,14 @@ macro_rules! green_bold { }; } +/// Style a message in cyan bold. +#[macro_export] +macro_rules! cyan_bold { + ($arg:expr) => { + console::style($arg).cyan().bold() + }; +} + /// Style a message in green bold. #[macro_export] macro_rules! red_bold { diff --git a/src/progress.rs b/src/progress.rs index f434fe3e..de4cec5b 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -123,7 +123,7 @@ impl ProgressHandler { pub fn start(&self) -> ProgressState { // Create and configure the main progress bar let style = ProgressStyle::with_template( - "{spinner:.green} {prefix:<32!} {bar:40.cyan/blue} {percent:>3}% {msg}", + "{spinner:.cyan} {prefix:<32!} {bar:40.cyan/blue} {percent:>3}% {msg}", ) .unwrap() .progress_chars("-- ") @@ -136,10 +136,10 @@ impl ProgressHandler { let prefix = match self.git_op { GitProgressOps::Clone => "Cloning", GitProgressOps::Fetch => "Fetching", - GitProgressOps::Checkout => "Checkout", - GitProgressOps::Submodule => "Update Submodules", + GitProgressOps::Checkout => "Checking out", + GitProgressOps::Submodule => "Updating Submodules", }; - let prefix = format!("{} {}", green_bold!(prefix), bold!(&self.name)); + let prefix = format!("{} {}", cyan_bold!(prefix), bold!(&self.name)); pb.set_prefix(prefix); // Configure the spinners to automatically tick every 100ms @@ -173,7 +173,7 @@ impl ProgressHandler { // The main bar simply becomes a spinner since the sub-bar will show progress // on the subsequent line. state.pb.set_style( - ProgressStyle::with_template("{spinner:.green} {prefix:<32!}").unwrap(), + ProgressStyle::with_template("{spinner:.cyan} {prefix:<40!}").unwrap(), ); // The submodule style is similar to the main bar, but indented and without spinner @@ -188,7 +188,7 @@ impl ProgressHandler { // to have a "T" connector (├─) instead of an "L" let prev_bar = match state.sub_bars.last() { Some((last_name, last_pb)) => { - let prev_prefix = format!("{} {}", dim!("├─ "), dim!(last_name)); + let prev_prefix = format!("{} {}", dim!("├─"), last_name); last_pb.set_prefix(prev_prefix); last_pb // Insert the new one after this one } @@ -199,9 +199,8 @@ impl ProgressHandler { let sub_pb = self .mpb .insert_after(prev_bar, ProgressBar::new(100).with_style(style)); - // Set the prefix and initial message - let sub_prefix = format!("{} {}", dim!("└─ "), dim!(&name)); + let sub_prefix = format!("{} {}", dim!("╰─"), &name); sub_pb.set_prefix(sub_prefix); sub_pb.set_message(format!("{}", dim!("Waiting..."))); From fe6ab3548e6acacc97d934d893dc4b5af836891e Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Wed, 14 Jan 2026 23:56:56 +0100 Subject: [PATCH 18/34] Fix merge conflicts --- src/cmd/checkout.rs | 2 +- src/error.rs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/cmd/checkout.rs b/src/cmd/checkout.rs index f03b32be..29264ec2 100644 --- a/src/cmd/checkout.rs +++ b/src/cmd/checkout.rs @@ -28,7 +28,7 @@ pub fn run_plain(sess: &Session, force: bool, update_list: &[String]) -> Result< let rt = Runtime::new()?; let io = SessionIo::new(sess); let start_time = std::time::Instant::now(); - let _srcs = rt.block_on(io.sources(forcibly, update_list))?; + let _srcs = rt.block_on(io.sources(force, update_list))?; let num_dependencies = io.sess.packages().iter().flatten().count(); infoln!( "{} {} dependencies {}", diff --git a/src/error.rs b/src/error.rs index 4cc6ee35..1d8a4f7b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -6,7 +6,6 @@ use std; use std::fmt; use std::sync::atomic::AtomicBool; -use std::sync::Arc; use std::sync::{Arc, RwLock}; use console::style; From 4f3e6fd0afb80e8854def8a763be77742f7dc0f5 Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Thu, 15 Jan 2026 21:09:20 +0100 Subject: [PATCH 19/34] diagnostic: Register multiprogressbar in `Diagnostic` --- src/cmd/vendor.rs | 4 ++-- src/diagnostic.rs | 29 +++++++++++++++++++++++++++-- src/error.rs | 27 ++------------------------- src/progress.rs | 14 ++++++++------ src/sess.rs | 27 +++++++++++++-------------- 5 files changed, 52 insertions(+), 49 deletions(-) diff --git a/src/cmd/vendor.rs b/src/cmd/vendor.rs index 333fab5d..fc27326e 100644 --- a/src/cmd/vendor.rs +++ b/src/cmd/vendor.rs @@ -103,7 +103,7 @@ pub fn run(sess: &Session, args: &VendorArgs) -> Result<()> { let git = Git::new(tmp_path, &sess.config.git, sess.git_throttle.clone()); rt.block_on(async { let pb = ProgressHandler::new( - sess.progress.clone(), + sess.multiprogress.clone(), GitProgressOps::Clone, vendor_package.name.as_str(), ); @@ -122,7 +122,7 @@ pub fn run(sess: &Session, args: &VendorArgs) -> Result<()> { _ => Err(Error::new("Please ensure your vendor reference is a commit hash to avoid upstream changes impacting your checkout")), }?; let pb = ProgressHandler::new( - sess.progress.clone(), + sess.multiprogress.clone(), GitProgressOps::Checkout, vendor_package.name.as_str(), ); diff --git a/src/diagnostic.rs b/src/diagnostic.rs index 73ac1af7..97230c94 100644 --- a/src/diagnostic.rs +++ b/src/diagnostic.rs @@ -6,6 +6,7 @@ use std::fmt; use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; +use indicatif::MultiProgress; use miette::{Diagnostic, ReportHandler}; use owo_colors::OwoColorize; use thiserror::Error; @@ -24,6 +25,8 @@ pub struct Diagnostics { /// A set of already emitted warnings. /// Requires synchronization as warnings may be emitted from multiple threads. emitted: Mutex>, + /// The active multi-progress bar (if any). + multiprogress: Mutex>, } impl Diagnostics { @@ -35,6 +38,7 @@ impl Diagnostics { all_suppressed: suppressed.contains("all") || suppressed.contains("Wall"), suppressed, emitted: Mutex::new(HashSet::new()), + multiprogress: Mutex::new(None), }; GLOBAL_DIAGNOSTICS @@ -42,6 +46,12 @@ impl Diagnostics { .expect("Diagnostics already initialized!"); } + pub fn set_multiprogress(multiprogress: Option) { + let diag = Diagnostics::get(); + let mut guard = diag.multiprogress.lock().unwrap(); + *guard = multiprogress; + } + /// Get the global diagnostics manager. fn get() -> &'static Diagnostics { GLOBAL_DIAGNOSTICS @@ -76,8 +86,22 @@ impl Warnings { emitted.insert(self.clone()); drop(emitted); - // Print the warning report (consumes self i.e. the warning) - eprintln!("{:?}", miette::Report::new(self)); + // Prepare the report + let report = miette::Report::new(self.clone()); + + // Print cleanly (using suspend if a bar exists) + let mp_guard = diag.multiprogress.lock().unwrap(); + + if let Some(mp) = &*mp_guard { + // If we have progress bars, hide them momentarily + mp.suspend(|| { + eprintln!("{:?}", report); + }); + } else { + eprintln!("No multiprogress bar available."); + // Otherwise just print + eprintln!("{:?}", report); + } } } @@ -385,6 +409,7 @@ mod tests { suppressed: HashSet::new(), all_suppressed: true, emitted: Mutex::new(HashSet::new()), + multiprogress: Mutex::new(None), }; // Manual check of the logic inside emit() diff --git a/src/error.rs b/src/error.rs index 1d8a4f7b..7600b372 100644 --- a/src/error.rs +++ b/src/error.rs @@ -6,35 +6,12 @@ use std; use std::fmt; use std::sync::atomic::AtomicBool; -use std::sync::{Arc, RwLock}; +use std::sync::Arc; use console::style; -use indicatif::MultiProgress; pub static ENABLE_DEBUG: AtomicBool = AtomicBool::new(false); -/// A global hook for the progress bar -pub static GLOBAL_MULTI_PROGRESS: RwLock> = RwLock::new(None); - -/// Helper function to print diagnostics safely without messing up progress bars. -pub fn print_diagnostic(severity: Severity, msg: &str) { - let text = format!("{} {}", severity, msg); - - // Try to acquire read access to the global progress bar - if let Ok(guard) = GLOBAL_MULTI_PROGRESS.read() { - if let Some(mp) = &*guard { - // SUSPEND: Hides progress bars, prints the message, then redraws bars. - mp.suspend(|| { - eprintln!("{}", text); - }); - return; - } - } - - // Fallback: Just print if no bar is registered or lock is poisoned - eprintln!("{}", text); -} - /// Print an error. #[macro_export] macro_rules! errorln { @@ -76,7 +53,7 @@ macro_rules! debugln { /// Emit a diagnostic message. macro_rules! diagnostic { ($severity:expr; $($arg:tt)*) => { - $crate::error::print_diagnostic($severity, &format!($($arg)*)) + eprintln!("{} {}", $severity, format!($($arg)*)) } } diff --git a/src/progress.rs b/src/progress.rs index de4cec5b..bfdaf4cf 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -41,7 +41,7 @@ pub struct ProgressState { /// Captures (static) information neeed to handle progress updates for a git operation. pub struct ProgressHandler { /// Reference to the multi-progress bar, which can manage multiple progress bars. - mpb: MultiProgress, + multiprogress: MultiProgress, /// The type of git operation being performed. git_op: GitProgressOps, /// The name of the repository being processed. @@ -110,9 +110,9 @@ pub async fn monitor_stderr( impl ProgressHandler { /// Create a new progress handler for a git operation. - pub fn new(mpb: MultiProgress, git_op: GitProgressOps, name: &str) -> Self { + pub fn new(multiprogress: MultiProgress, git_op: GitProgressOps, name: &str) -> Self { Self { - mpb, + multiprogress, git_op, name: name.to_string(), } @@ -130,7 +130,9 @@ impl ProgressHandler { .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]); // Create and attach the progress bar to the multi-progress bar. - let pb = self.mpb.add(ProgressBar::new(100).with_style(style)); + let pb = self + .multiprogress + .add(ProgressBar::new(100).with_style(style)); // Set the prefix based on the git operation let prefix = match self.git_op { @@ -197,7 +199,7 @@ impl ProgressHandler { // Create the new sub-bar and insert it in the multi-progress *after* the previous sub-bar let sub_pb = self - .mpb + .multiprogress .insert_after(prev_bar, ProgressBar::new(100).with_style(style)); // Set the prefix and initial message let sub_prefix = format!("{} {}", dim!("╰─"), &name); @@ -288,7 +290,7 @@ impl ProgressHandler { }; // Print a completion message on top of active progress bars - self.mpb + self.multiprogress .println(format!( " {} {} {}", green_bold!(op_str), diff --git a/src/sess.rs b/src/sess.rs index c796d900..9a29f6b3 100644 --- a/src/sess.rs +++ b/src/sess.rs @@ -82,7 +82,7 @@ pub struct Session<'ctx> { /// A toggle to disable remote fetches & clones pub local_only: bool, /// The global progress bar manager. - pub progress: MultiProgress, + pub multiprogress: MultiProgress, } impl<'ctx> Session<'ctx> { @@ -97,12 +97,11 @@ impl<'ctx> Session<'ctx> { force_fetch: bool, git_throttle: usize, ) -> Session<'ctx> { - // Initialize the global multi-progress bar - // to handle warning and error messages correctly. - let mpb = MultiProgress::new(); - if let Ok(mut global_mpb) = GLOBAL_MULTI_PROGRESS.write() { - *global_mpb = Some(mpb.clone()); - } + // Create the global multi-progress bar manager. + let multiprogress = MultiProgress::new(); + + // Register it with the global diagnostics system + Diagnostics::set_multiprogress(Some(multiprogress.clone())); Session { root, @@ -128,7 +127,7 @@ impl<'ctx> Session<'ctx> { cache: Default::default(), git_throttle: Arc::new(Semaphore::new(git_throttle)), local_only, - progress: MultiProgress::new(), + multiprogress, } } @@ -563,7 +562,7 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { // The progress bar object for cloning. We only use it for the // last fetch operation, which is the only network operation here. let pb = Some(ProgressHandler::new( - self.sess.progress.clone(), + self.sess.multiprogress.clone(), GitProgressOps::Clone, name, )); @@ -604,7 +603,7 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { self.sess.stats.num_database_fetch.increment(); // The progress bar object for fetching. let pb = Some(ProgressHandler::new( - self.sess.progress.clone(), + self.sess.multiprogress.clone(), GitProgressOps::Fetch, name, )); @@ -962,7 +961,7 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { cause ); let pb = Some(ProgressHandler::new( - self.sess.progress.clone(), + self.sess.multiprogress.clone(), GitProgressOps::Checkout, name, )); @@ -997,7 +996,7 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { }?; if clear == CheckoutState::ToClone { let pb = Some(ProgressHandler::new( - self.sess.progress.clone(), + self.sess.multiprogress.clone(), GitProgressOps::Checkout, name, )); @@ -1035,7 +1034,7 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { ) .await?; let pb = Some(ProgressHandler::new( - self.sess.progress.clone(), + self.sess.multiprogress.clone(), GitProgressOps::Checkout, name, )); @@ -1085,7 +1084,7 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { } if path.join(".gitmodules").exists() { let pb = Some(ProgressHandler::new( - self.sess.progress.clone(), + self.sess.multiprogress.clone(), GitProgressOps::Submodule, name, )); From d316a410c00626e3190771209361469252f96a9f Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Thu, 15 Jan 2026 21:33:01 +0100 Subject: [PATCH 20/34] progress: Replace `console` with `owo_colors` --- Cargo.lock | 1 - Cargo.toml | 1 - src/cmd/checkout.rs | 5 ++-- src/diagnostic.rs | 8 +++---- src/error.rs | 56 ++++++--------------------------------------- src/progress.rs | 28 ++++++++++++----------- src/util.rs | 16 +++++++++++++ 7 files changed, 45 insertions(+), 70 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8e2ab2bd..5075718b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,7 +111,6 @@ dependencies = [ "blake2", "clap", "clap_complete", - "console", "dirs", "dunce", "futures", diff --git a/Cargo.toml b/Cargo.toml index 83d73d90..bdfe33bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,6 @@ miette = "7.6.0" thiserror = "2.0.17" owo-colors = "4.2.3" indicatif = "0.18.3" -console = "0.16.2" regex = "1.12.2" [target.'cfg(windows)'.dependencies] diff --git a/src/cmd/checkout.rs b/src/cmd/checkout.rs index 29264ec2..fa6c570f 100644 --- a/src/cmd/checkout.rs +++ b/src/cmd/checkout.rs @@ -4,6 +4,7 @@ //! The `checkout` subcommand. use clap::Args; +use owo_colors::OwoColorize; use tokio::runtime::Runtime; use crate::error::*; @@ -32,9 +33,9 @@ pub fn run_plain(sess: &Session, force: bool, update_list: &[String]) -> Result< let num_dependencies = io.sess.packages().iter().flatten().count(); infoln!( "{} {} dependencies {}", - dim!("Checked out"), + "Checked out".dimmed(), num_dependencies, - dim!(fmt_duration(start_time.elapsed())) + fmt_duration(start_time.elapsed()).dimmed() ); Ok(()) diff --git a/src/diagnostic.rs b/src/diagnostic.rs index 97230c94..72f3e9a4 100644 --- a/src/diagnostic.rs +++ b/src/diagnostic.rs @@ -8,7 +8,7 @@ use std::sync::{Mutex, OnceLock}; use indicatif::MultiProgress; use miette::{Diagnostic, ReportHandler}; -use owo_colors::OwoColorize; +use owo_colors::{OwoColorize, Style}; use thiserror::Error; use crate::{fmt_field, fmt_path, fmt_pkg, fmt_version}; @@ -111,9 +111,9 @@ impl ReportHandler for DiagnosticRenderer { fn debug(&self, diagnostic: &dyn Diagnostic, f: &mut fmt::Formatter<'_>) -> fmt::Result { // Determine severity and the resulting style let (severity, style) = match diagnostic.severity().unwrap_or_default() { - miette::Severity::Error => ("error", owo_colors::Style::new().red().bold()), - miette::Severity::Warning => ("warning", owo_colors::Style::new().yellow().bold()), - miette::Severity::Advice => ("advice", owo_colors::Style::new().cyan().bold()), + miette::Severity::Error => ("error", Style::new().red().bold()), + miette::Severity::Warning => ("warning", Style::new().yellow().bold()), + miette::Severity::Advice => ("advice", Style::new().cyan().bold()), }; // Write the severity prefix diff --git a/src/error.rs b/src/error.rs index 7600b372..0980c1c5 100644 --- a/src/error.rs +++ b/src/error.rs @@ -8,7 +8,7 @@ use std::fmt; use std::sync::atomic::AtomicBool; use std::sync::Arc; -use console::style; +use owo_colors::{OwoColorize, Style}; pub static ENABLE_DEBUG: AtomicBool = AtomicBool::new(false); @@ -62,61 +62,19 @@ macro_rules! diagnostic { pub enum Severity { Debug, Info, - Warning, Error, Stage(&'static str), } -/// Style a message in green bold. -#[macro_export] -macro_rules! green_bold { - ($arg:expr) => { - console::style($arg).green().bold() - }; -} - -/// Style a message in cyan bold. -#[macro_export] -macro_rules! cyan_bold { - ($arg:expr) => { - console::style($arg).cyan().bold() - }; -} - -/// Style a message in green bold. -#[macro_export] -macro_rules! red_bold { - ($arg:expr) => { - console::style($arg).red().bold() - }; -} - -/// Style a message in dimmed text. -#[macro_export] -macro_rules! dim { - ($arg:expr) => { - console::style($arg).dim() - }; -} - -/// Style a message in bold text. -#[macro_export] -macro_rules! bold { - ($arg:expr) => { - console::style($arg).bold() - }; -} - impl fmt::Display for Severity { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let styled_str = match *self { - Severity::Error => style("Error:").red().bold(), - Severity::Warning => style("Warning:").yellow().bold(), - Severity::Info => style("Info:").white().bold(), - Severity::Debug => style("Debug:").blue().bold(), - Severity::Stage(name) => style(name).green().bold(), + let (severity, style) = match *self { + Severity::Error => ("Error:", Style::new().red().bold()), + Severity::Info => ("Info:", Style::new().white().bold()), + Severity::Debug => ("Debug:", Style::new().blue().bold()), + Severity::Stage(name) => (name, Style::new().green().bold()), }; - write!(f, " {}", styled_str) + write!(f, " {}", severity.style(style)) } } diff --git a/src/progress.rs b/src/progress.rs index bfdaf4cf..b97b65b2 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -4,6 +4,7 @@ use crate::util::fmt_duration; use indexmap::IndexMap; +use owo_colors::OwoColorize; use std::sync::OnceLock; use std::time::Duration; @@ -11,6 +12,8 @@ use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use regex::Regex; use tokio::io::{AsyncReadExt, BufReader}; +use crate::{fmt_completed, fmt_pkg, fmt_stage}; + static RE_GIT: OnceLock = OnceLock::new(); /// The result of parsing a git progress line. @@ -141,7 +144,7 @@ impl ProgressHandler { GitProgressOps::Checkout => "Checking out", GitProgressOps::Submodule => "Updating Submodules", }; - let prefix = format!("{} {}", cyan_bold!(prefix), bold!(&self.name)); + let prefix = format!("{} {}", fmt_stage!(prefix), fmt_pkg!(&self.name)); pb.set_prefix(prefix); // Configure the spinners to automatically tick every 100ms @@ -190,7 +193,7 @@ impl ProgressHandler { // to have a "T" connector (├─) instead of an "L" let prev_bar = match state.sub_bars.last() { Some((last_name, last_pb)) => { - let prev_prefix = format!("{} {}", dim!("├─"), last_name); + let prev_prefix = format!("{} {}", "├─".dimmed(), last_name); last_pb.set_prefix(prev_prefix); last_pb // Insert the new one after this one } @@ -202,9 +205,9 @@ impl ProgressHandler { .multiprogress .insert_after(prev_bar, ProgressBar::new(100).with_style(style)); // Set the prefix and initial message - let sub_prefix = format!("{} {}", dim!("╰─"), &name); + let sub_prefix = format!("{} {}", "╰─".dimmed(), &name); sub_pb.set_prefix(sub_prefix); - sub_pb.set_message(format!("{}", dim!("Waiting..."))); + sub_pb.set_message(format!("{}", "Waiting...".dimmed())); // Store the sub-bar in the state for later updates state.sub_bars.insert(name, sub_pb); @@ -227,7 +230,7 @@ impl ProgressHandler { // Set the new bar to active if let Some(bar) = state.sub_bars.get(&name) { // Switch style to the active progress bar style - bar.set_message(format!("{}", dim!("Cloning..."))); + bar.set_message(format!("{}", "Cloning...".dimmed())); } state.active_sub = Some(name); } @@ -245,27 +248,26 @@ impl ProgressHandler { } // Update the progress percentage for receiving objects GitProgress::Receiving { percent, .. } => { - target_pb.set_message(format!("{}", dim!("Receiving objects"))); + target_pb.set_message(format!("{}", "Receiving objects".dimmed())); target_pb.set_position(percent as u64); } // Update the progress percentage for resolving deltas GitProgress::Resolving { percent, .. } => { - target_pb.set_message(format!("{}", dim!("Resolving deltas"))); + target_pb.set_message(format!("{}", "Resolving deltas".dimmed())); target_pb.set_position(percent as u64); } // Update the progress percentage for checking out files GitProgress::Checkout { percent, .. } => { - target_pb.set_message(format!("{}", dim!("Checking out"))); + target_pb.set_message(format!("{}", "Checking out".dimmed())); target_pb.set_position(percent as u64); } // Handle errors by finishing and clearing the target bar, then logging the error GitProgress::Error(err_msg) => { target_pb.finish_and_clear(); - // TODO(fischeti): Consider enumerating error errorln!( "{} {}: {}", "Error during git operation of", - bold!(&self.name), + fmt_pkg!(&self.name), err_msg ); } @@ -293,9 +295,9 @@ impl ProgressHandler { self.multiprogress .println(format!( " {} {} {}", - green_bold!(op_str), - bold!(&self.name), - dim!(fmt_duration(state.start_time.elapsed())) + fmt_completed!(op_str), + fmt_pkg!(&self.name), + fmt_duration(state.start_time.elapsed()).dimmed() )) .unwrap(); } diff --git a/src/util.rs b/src/util.rs index c450b801..70389037 100644 --- a/src/util.rs +++ b/src/util.rs @@ -471,3 +471,19 @@ macro_rules! fmt_version { $crate::util::OwoColorize::bold(&$ver) }; } + +/// Format for an ongoing progress stage in diagnostic messages. +#[macro_export] +macro_rules! fmt_stage { + ($stage:expr) => { + $crate::util::OwoColorize::cyan(&$stage).bold() + }; +} + +/// Format a completed progress stage in diagnostic messages. +#[macro_export] +macro_rules! fmt_completed { + ($stage:expr) => { + $crate::util::OwoColorize::green(&$stage).bold() + }; +} From 05c72ff8a5eaaa5466663c3f96f9abe5b3bda18f Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Thu, 15 Jan 2026 21:43:48 +0100 Subject: [PATCH 21/34] progress: Fix clippy warnings and clean up --- src/progress.rs | 54 ++++++++++++++++++++----------------------------- 1 file changed, 22 insertions(+), 32 deletions(-) diff --git a/src/progress.rs b/src/progress.rs index b97b65b2..62bdec78 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -75,37 +75,30 @@ pub async fn monitor_stderr( // We loop over the stream reading byte by byte // and process lines as they are completed. - loop { - match reader.read_u8().await { - Ok(byte) => { - raw_log.push(byte); - - // Git output lines end with either \n or \r - // Every time we encounter one, we process the line. - // Note: \r is used for progress updates, meaning the line - // is overwritten in place. - if byte == b'\r' || byte == b'\n' { - if !buffer.is_empty() { - if let Ok(line) = std::str::from_utf8(&buffer) { - // Update UI if we have a handler - if let Some(h) = &handler { - h.update_pb(line, &mut state.as_mut().unwrap()); - } - } - // Clear the buffer for the next line - buffer.clear(); - } - } else { - buffer.push(byte); - } - } - // We break the loop on EOF or error - Err(_) => break, + while let Ok(byte) = reader.read_u8().await { + raw_log.push(byte); + + // We push bytes into the buffer until we hit a delimiter + if byte != b'\r' && byte != b'\n' { + buffer.push(byte); + continue; } + + // Process the line, if we can parse it and have a handler + if let (Ok(line), Some(h)) = (std::str::from_utf8(&buffer), &handler) { + // Parse the line and update the progress bar accordingly + let progress = parse_git_line(line); + h.update_pb(progress, state.as_mut().unwrap()); + } + + // Always clear buffer after a delimiter + buffer.clear(); } // Finalize the progress bar if we have a handler - handler.map(|h| h.finish(&mut state.unwrap())); + if let Some(handler) = handler { + handler.finish(&mut state.unwrap()); + } // Return the full raw log as a string String::from_utf8_lossy(&raw_log).to_string() @@ -159,10 +152,7 @@ impl ProgressHandler { } /// Update the progress bar(s) based on a parsed git progress line. - pub fn update_pb(&self, line: &str, state: &mut ProgressState) { - // Parse the line to determine the type of progress update - let progress = parse_git_line(line); - + fn update_pb(&self, progress: GitProgress, state: &mut ProgressState) { // Target the active submodule if one exists, otherwise the main bar let target_pb = if let Some(name) = &state.active_sub { state.sub_bars.get(name).unwrap_or(&state.pb) @@ -365,7 +355,7 @@ pub fn parse_git_line(line: &str) -> GitProgress { fn path_to_name(path: &str) -> String { path.trim_end_matches('/') .split('/') - .last() + .next_back() .unwrap_or(path) .to_string() } From 82bfde1b166ed0f05ab462192ad29af454836851 Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Mon, 19 Jan 2026 16:20:33 +0100 Subject: [PATCH 22/34] sess: Remove redundant url clones --- src/sess.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/sess.rs b/src/sess.rs index 9a29f6b3..470e0d1a 100644 --- a/src/sess.rs +++ b/src/sess.rs @@ -546,8 +546,6 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { &self.sess.config.git, self.sess.git_throttle.clone(), ); - let url = String::from(url); - let url2 = url.clone(); // Either initialize the repository or update it if needed. if !db_dir.join("config").exists() { @@ -584,7 +582,7 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { .await .map_err(move |cause| { Warnings::GitInitFailed { - is_ssh: url3.contains("git@"), + is_ssh: url.contains("git@"), } .emit(); Error::chain( @@ -619,7 +617,7 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { .await .map_err(move |cause| { Warnings::GitInitFailed { - is_ssh: url3.contains("git@"), + is_ssh: url.contains("git@"), } .emit(); Error::chain( From 2d9507d3e0379d47022b6fee2de825fa9b763252 Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Mon, 19 Jan 2026 17:03:25 +0100 Subject: [PATCH 23/34] Align `stageln` calls with progress bar style --- src/cli.rs | 8 +++- src/cmd/clone.rs | 8 +++- src/cmd/snapshot.rs | 9 +++- src/cmd/vendor.rs | 107 +++++++++++++++++++++++--------------------- 4 files changed, 78 insertions(+), 54 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 74fdbdeb..7cea8565 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -27,6 +27,7 @@ use crate::diagnostic::{Diagnostics, Warnings}; use crate::error::*; use crate::lockfile::*; use crate::sess::{Session, SessionArenas, SessionIo}; +use crate::{fmt_path, fmt_pkg}; #[derive(Parser, Debug)] #[command(name = "bender")] @@ -265,7 +266,6 @@ pub fn main() -> Result<()> { // Create the symlink if there is nothing at the destination. if !path.exists() { - stageln!("Linking", "{} ({:?})", pkg_name, path); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|cause| { Error::chain(format!("Failed to create directory {:?}.", parent), cause) @@ -291,6 +291,12 @@ pub fn main() -> Result<()> { if let Some(d) = previous_dir { std::env::set_current_dir(d).unwrap(); } + stageln!( + "Linked", + "{} to {}", + fmt_pkg!(pkg_name), + fmt_path!(path.display()) + ); } } } diff --git a/src/cmd/clone.rs b/src/cmd/clone.rs index 678e85dc..c8c2548d 100644 --- a/src/cmd/clone.rs +++ b/src/cmd/clone.rs @@ -16,6 +16,7 @@ use crate::config::{Locked, LockedSource}; use crate::diagnostic::Warnings; use crate::error::*; use crate::sess::{DependencyRef, DependencySource, Session, SessionIo}; +use crate::{fmt_path, fmt_pkg}; /// Clone dependency to a working directory #[derive(Args, Debug)] @@ -281,7 +282,6 @@ pub fn run(sess: &Session, path: &Path, args: &CloneArgs) -> Result<()> { // Create the symlink if there is nothing at the destination. if !link_path.exists() { - stageln!("Linking", "{} ({:?})", pkg_name, link_path); if let Some(parent) = link_path.parent() { std::fs::create_dir_all(parent).map_err(|cause| { Error::chain(format!("Failed to create directory {:?}.", parent), cause) @@ -307,6 +307,12 @@ pub fn run(sess: &Session, path: &Path, args: &CloneArgs) -> Result<()> { if let Some(d) = previous_dir { std::env::set_current_dir(d).unwrap(); } + stageln!( + "Linked", + "{} to {}", + fmt_pkg!(pkg_name), + fmt_path!(path.display()) + ); } eprintln!("{} symlink updated", dep); } diff --git a/src/cmd/snapshot.rs b/src/cmd/snapshot.rs index fbb2cc07..ac24b32a 100644 --- a/src/cmd/snapshot.rs +++ b/src/cmd/snapshot.rs @@ -16,6 +16,7 @@ use crate::config::{Dependency, Locked, LockedSource}; use crate::diagnostic::Warnings; use crate::error::*; use crate::sess::{DependencySource, Session, SessionIo}; +use crate::{fmt_path, fmt_pkg}; /// Snapshot the cloned IPs from the working directory into the Bender.lock file #[derive(Args, Debug)] @@ -273,7 +274,6 @@ pub fn run(sess: &Session, args: &SnapshotArgs) -> Result<()> { // Create the symlink if there is nothing at the destination. if !link_path.exists() { - stageln!("Linking", "{} ({:?})", pkg_name, link_path); if let Some(parent) = link_path.parent() { std::fs::create_dir_all(parent).map_err(|cause| { Error::chain(format!("Failed to create directory {:?}.", parent), cause) @@ -299,8 +299,13 @@ pub fn run(sess: &Session, args: &SnapshotArgs) -> Result<()> { if let Some(d) = previous_dir { std::env::set_current_dir(d).unwrap(); } + stageln!( + "Linked", + "{} to {}", + fmt_pkg!(pkg_name), + fmt_path!(link_path.display()) + ); } - eprintln!("{} symlink updated", pkg_name); } } diff --git a/src/cmd/vendor.rs b/src/cmd/vendor.rs index fc27326e..e71ddd4f 100644 --- a/src/cmd/vendor.rs +++ b/src/cmd/vendor.rs @@ -23,6 +23,7 @@ use crate::futures::TryFutureExt; use crate::git::Git; use crate::progress::{GitProgressOps, ProgressHandler}; use crate::sess::{DependencySource, Session}; +use crate::{fmt_path, fmt_pkg}; /// A patch linkage #[derive(Clone)] @@ -231,7 +232,6 @@ pub fn run(sess: &Session, args: &VendorArgs) -> Result<()> { VendorSubcommand::Init { no_patch } => { sorted_links.into_iter().rev().try_for_each(|patch_link| { - stageln!("Copying", "{} files from upstream", vendor_package.name); // Remove existing directories before importing them again let target_path = patch_link .clone() @@ -249,14 +249,22 @@ pub fn run(sess: &Session, args: &VendorArgs) -> Result<()> { } // init - init( + let result = init( &rt, git.clone(), vendor_package, patch_link, dep_path.clone(), *no_patch, - ) + ); + + stageln!( + "Copied", + "{} files from upstream", + fmt_pkg!(vendor_package.name) + ); + + result }) } @@ -426,53 +434,52 @@ pub fn apply_patches( for patch in patches.clone() { rt.block_on(async { - future::lazy(|_| { - stageln!( - "Patching", - "{} with {}", - package_name, - patch.file_name().unwrap().to_str().unwrap() - ); - Ok(()) - }) - .and_then(|_| { - git.clone().spawn_with( - |c| { - let is_file = patch_link - .from_prefix - .clone() - .prefix_paths(git.path) - .unwrap() - .is_file(); - - let current_patch_target = if is_file { - patch_link.from_prefix.parent().unwrap().to_str().unwrap() - } else { - patch_link.from_prefix.as_path().to_str().unwrap() - }; - - c.arg("apply") - .arg("--directory") - .arg(current_patch_target) - .arg("-p1") - .arg(&patch); - - // limit to specific file for file links - if is_file { - let file_path = patch_link.from_prefix.to_str().unwrap(); - c.arg("--include").arg(file_path); - } - - c - }, - None, - ) - }) - .await - .map_err(move |cause| { - Error::chain(format!("Failed to apply patch {:?}.", patch), cause) - }) - .map(|_| git.clone()) + future::lazy(|_| Ok(())) + .and_then(|_| { + git.clone().spawn_with( + |c| { + let is_file = patch_link + .from_prefix + .clone() + .prefix_paths(git.path) + .unwrap() + .is_file(); + + let current_patch_target = if is_file { + patch_link.from_prefix.parent().unwrap().to_str().unwrap() + } else { + patch_link.from_prefix.as_path().to_str().unwrap() + }; + + c.arg("apply") + .arg("--directory") + .arg(current_patch_target) + .arg("-p1") + .arg(&patch); + + // limit to specific file for file links + if is_file { + let file_path = patch_link.from_prefix.to_str().unwrap(); + c.arg("--include").arg(file_path); + } + c + }, + None, + ) + }) + .await + .map_err(|cause| { + Error::chain(format!("Failed to apply patch {:?}.", patch.clone()), cause) + }) + .map(|_| { + stageln!( + "Patched", + "{} with {}", + fmt_pkg!(package_name), + fmt_path!(patch.display()) + ); + }) + .map(|_| git.clone()) })?; } Ok(patches.len()) From 2e0947debf0e6b7f468c8e0781791bb229880a92 Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Mon, 19 Jan 2026 18:22:45 +0100 Subject: [PATCH 24/34] progress: Handle non-TTY mode Whenever we are not in a TTY/terminal (e.g. a CI), we don't actually want to render the progress bar, but just print out the completion messages in the end for the log. --- src/progress.rs | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/src/progress.rs b/src/progress.rs index 62bdec78..a6a00636 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -5,6 +5,7 @@ use crate::util::fmt_duration; use indexmap::IndexMap; use owo_colors::OwoColorize; +use std::io::IsTerminal; use std::sync::OnceLock; use std::time::Duration; @@ -49,6 +50,8 @@ pub struct ProgressHandler { git_op: GitProgressOps, /// The name of the repository being processed. name: String, + /// Whether we are running in a TTY. + tty: bool, } /// The git operation types that currently support progress reporting. @@ -84,8 +87,13 @@ pub async fn monitor_stderr( continue; } - // Process the line, if we can parse it and have a handler - if let (Ok(line), Some(h)) = (std::str::from_utf8(&buffer), &handler) { + // Process the line, if: + // - it is valid UTF-8 + // - we have a progress handler + // - we are in a TTY + if let (Ok(line), Some(h @ ProgressHandler { tty: true, .. })) = + (std::str::from_utf8(&buffer), &handler) + { // Parse the line and update the progress bar accordingly let progress = parse_git_line(line); h.update_pb(progress, state.as_mut().unwrap()); @@ -111,6 +119,7 @@ impl ProgressHandler { multiprogress, git_op, name: name.to_string(), + tty: std::io::stderr().is_terminal(), } } @@ -281,15 +290,21 @@ impl ProgressHandler { GitProgressOps::Submodule => "Updated Submodules", }; - // Print a completion message on top of active progress bars - self.multiprogress - .println(format!( - " {} {} {}", - fmt_completed!(op_str), - fmt_pkg!(&self.name), - fmt_duration(state.start_time.elapsed()).dimmed() - )) - .unwrap(); + let finish_msg = format!( + " {} {} {}", + fmt_completed!(op_str), + fmt_pkg!(&self.name), + fmt_duration(state.start_time.elapsed()).dimmed() + ); + + // In TTY mode, we can print on top of the progress bars + // otherwise, we just print to stderr. + if self.tty { + // Print a completion message on top of active progress bars + self.multiprogress.println(finish_msg).unwrap(); + } else { + eprintln!("{}", finish_msg); + } } } From b061e1ae5d21097efbb71a12918f1e7218b1094c Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Mon, 19 Jan 2026 18:40:44 +0100 Subject: [PATCH 25/34] deps: Remove `is-terminal` dependency Is now in the standard library --- Cargo.lock | 18 ------------------ Cargo.toml | 1 - src/cmd/fusesoc.rs | 2 +- src/main.rs | 1 - src/resolver.rs | 4 +--- 5 files changed, 2 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5075718b..3a993e93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,7 +117,6 @@ dependencies = [ "glob", "indexmap", "indicatif", - "is-terminal", "itertools", "miette", "owo-colors", @@ -612,12 +611,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - [[package]] name = "humansize" version = "2.1.3" @@ -692,17 +685,6 @@ dependencies = [ "web-time", ] -[[package]] -name = "is-terminal" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.60.2", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" diff --git a/Cargo.toml b/Cargo.toml index bdfe33bd..91bd4ffa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,6 @@ typed-arena = "2" dirs = "6" pathdiff = "0.2" itertools = "0.14" -is-terminal = "0.4" tabwriter = "1.2.1" indexmap = { version = "2", features = ["serde"] } tempfile = "3.5" diff --git a/src/cmd/fusesoc.rs b/src/cmd/fusesoc.rs index d15ece09..cef22a37 100644 --- a/src/cmd/fusesoc.rs +++ b/src/cmd/fusesoc.rs @@ -8,12 +8,12 @@ use std::ffi::OsStr; use std::fmt::Write as _; use std::fs; use std::fs::read_to_string; +use std::io::IsTerminal; use std::io::{self, Write}; use std::path::PathBuf; use clap::{ArgAction, Args}; use indexmap::{IndexMap, IndexSet}; -use is_terminal::IsTerminal; use itertools::Itertools; use tokio::runtime::Runtime; use walkdir::{DirEntry, WalkDir}; diff --git a/src/main.rs b/src/main.rs index b96bf92a..9b7ff9d8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,7 +16,6 @@ extern crate blake2; extern crate clap; extern crate dirs; extern crate glob; -extern crate is_terminal; extern crate itertools; extern crate pathdiff; extern crate semver; diff --git a/src/resolver.rs b/src/resolver.rs index 6f8e56f1..0b27d75e 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -6,20 +6,18 @@ #![deny(missing_docs)] use std::collections::HashMap; -// use std::f32::consts::E; use std::fmt; use std::fmt::Write as _; use std::fs; +use std::io::IsTerminal; use std::io::{self, Write}; use std::mem; use std::process::Command as SysCommand; use futures::future::join_all; use indexmap::{IndexMap, IndexSet}; -use is_terminal::IsTerminal; use itertools::Itertools; use semver::{Version, VersionReq}; -// use serde_json::value::Index; use tabwriter::TabWriter; use tokio::runtime::Runtime; From e39510b45fcc6957664853dc23ae5f998fbbe2db Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Wed, 21 Jan 2026 17:55:51 +0100 Subject: [PATCH 26/34] Cargo.toml: Bump dependencies --- Cargo.lock | 22 +++++++++++----------- Cargo.toml | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3a993e93..c49a0b11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -56,7 +56,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -67,7 +67,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -398,7 +398,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -432,7 +432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1139,7 +1139,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1352,7 +1352,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1385,18 +1385,18 @@ checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -1592,7 +1592,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 91bd4ffa..66d295db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,7 @@ walkdir = "2" subst = "0.3" tera = "1.19" miette = "7.6.0" -thiserror = "2.0.17" +thiserror = "2.0.18" owo-colors = "4.2.3" indicatif = "0.18.3" regex = "1.12.2" From 713ddb96d673283c49141aa1da78726daf60cac6 Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Wed, 21 Jan 2026 22:44:58 +0100 Subject: [PATCH 27/34] progress: Right-align stage messages --- src/cmd/update.rs | 8 +++++- src/error.rs | 4 +-- src/progress.rs | 69 ++++++++++++++++++++++++++++------------------- 3 files changed, 51 insertions(+), 30 deletions(-) diff --git a/src/cmd/update.rs b/src/cmd/update.rs index 3d8999e1..073432d0 100644 --- a/src/cmd/update.rs +++ b/src/cmd/update.rs @@ -8,6 +8,7 @@ use std::io::Write; use clap::Args; use indexmap::IndexSet; +use owo_colors::OwoColorize; use tabwriter::TabWriter; use crate::cmd; @@ -17,6 +18,7 @@ use crate::error::*; use crate::lockfile::*; use crate::resolver::DependencyResolver; use crate::sess::Session; +use crate::{fmt_completed, fmt_pkg}; /// Update the dependencies #[derive(Args, Debug)] @@ -162,7 +164,11 @@ pub fn run_plain<'ctx>( update_map.into_iter().chain(removed_map).collect(); let mut update_str = String::from(""); for (name, (existing_dep, new_dep)) in update_map.clone() { - update_str.push_str(&format!("\x1B[32;1m{:>12}\x1B[0m {}:\t", "Updating", name)); + update_str.push_str(&format!( + "{:>14} {}:\t", + fmt_completed!("Updating"), + fmt_pkg!(name) + )); if let Some(existing_dep) = existing_dep { update_str.push_str( &existing_dep diff --git a/src/error.rs b/src/error.rs index 0980c1c5..f4763c5f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -53,7 +53,7 @@ macro_rules! debugln { /// Emit a diagnostic message. macro_rules! diagnostic { ($severity:expr; $($arg:tt)*) => { - eprintln!("{} {}", $severity, format!($($arg)*)) + eprintln!("{:>14} {}", $severity, format!($($arg)*)) } } @@ -74,7 +74,7 @@ impl fmt::Display for Severity { Severity::Debug => ("Debug:", Style::new().blue().bold()), Severity::Stage(name) => (name, Style::new().green().bold()), }; - write!(f, " {}", severity.style(style)) + write!(f, "{:>14}", severity.style(style)) } } diff --git a/src/progress.rs b/src/progress.rs index a6a00636..99ef0209 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -17,6 +17,9 @@ use crate::{fmt_completed, fmt_pkg, fmt_stage}; static RE_GIT: OnceLock = OnceLock::new(); +// The alignment of the operation strings +const OP_ALIGN: usize = 12; + /// The result of parsing a git progress line. pub enum GitProgress { SubmoduleRegistered { name: String }, @@ -29,6 +32,29 @@ pub enum GitProgress { Other, } +impl GitProgressOps { + /// Returns the present-tense name (for active bars) and the padding needed for the spinner. + fn active_fmt(&self) -> (&'static str, usize) { + let name = match self { + Self::Clone => "Cloning", + Self::Fetch => "Fetching", + Self::Checkout => "Checking out", + Self::Submodule => "Submodules", + }; + (name, (OP_ALIGN - name.len()) + 1) + } + + /// Returns the past-tense name (for finished lines). + fn past_fmt(&self) -> &'static str { + match self { + Self::Clone => "Cloned", + Self::Fetch => "Fetched", + Self::Checkout => "Checked out", + Self::Submodule => "Submodules", + } + } +} + /// Captures (dynamic) state information for a git operation's progress. /// for instance, the actuall progress bars to update. pub struct ProgressState { @@ -126,27 +152,24 @@ impl ProgressHandler { /// Adds a new progress bar to the multi-progress and returns the initial state /// that is needed to track progress updates. pub fn start(&self) -> ProgressState { - // Create and configure the main progress bar - let style = ProgressStyle::with_template( - "{spinner:.cyan} {prefix:<32!} {bar:40.cyan/blue} {percent:>3}% {msg}", - ) - .unwrap() - .progress_chars("-- ") - .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]); + let (op_name, spinner_pad) = self.git_op.active_fmt(); + + // Set the prefix based on the git operation + let template = format!( + "{{spinner:>{spinner_pad}.cyan}} {{prefix:<32}} {{bar:40.cyan/blue}} {{percent:>3}}% {{msg}}" + ); + + let style = ProgressStyle::with_template(template.as_str()) + .unwrap() + .progress_chars("-- ") + .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]); // Create and attach the progress bar to the multi-progress bar. let pb = self .multiprogress .add(ProgressBar::new(100).with_style(style)); - // Set the prefix based on the git operation - let prefix = match self.git_op { - GitProgressOps::Clone => "Cloning", - GitProgressOps::Fetch => "Fetching", - GitProgressOps::Checkout => "Checking out", - GitProgressOps::Submodule => "Updating Submodules", - }; - let prefix = format!("{} {}", fmt_stage!(prefix), fmt_pkg!(&self.name)); + let prefix = format!("{} {}", fmt_stage!(op_name), fmt_pkg!(&self.name)); pb.set_prefix(prefix); // Configure the spinners to automatically tick every 100ms @@ -177,12 +200,12 @@ impl ProgressHandler { // The main bar simply becomes a spinner since the sub-bar will show progress // on the subsequent line. state.pb.set_style( - ProgressStyle::with_template("{spinner:.cyan} {prefix:<40!}").unwrap(), + ProgressStyle::with_template("{spinner:>3.cyan} {prefix:<40!}").unwrap(), ); // The submodule style is similar to the main bar, but indented and without spinner let style = ProgressStyle::with_template( - " {prefix:<32!} {bar:40.cyan/blue} {percent:>3}% {msg}", + " {prefix:<24!} {bar:40.cyan/blue} {percent:>3}% {msg}", ) .unwrap() .progress_chars("-- "); @@ -282,17 +305,9 @@ impl ProgressHandler { } state.pb.finish_and_clear(); - // Print a final message indicating completion - let op_str = match self.git_op { - GitProgressOps::Clone => "Cloned", - GitProgressOps::Fetch => "Fetched", - GitProgressOps::Checkout => "Checked out", - GitProgressOps::Submodule => "Updated Submodules", - }; - let finish_msg = format!( - " {} {} {}", - fmt_completed!(op_str), + "{:>14} {} {}", + fmt_completed!(self.git_op.past_fmt()), fmt_pkg!(&self.name), fmt_duration(state.start_time.elapsed()).dimmed() ); From 3fcf232502023425d5f8b80c1fe65b2967ac5aed Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Wed, 21 Jan 2026 23:16:57 +0100 Subject: [PATCH 28/34] sess: Add progress bar for more fetch operations --- src/sess.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/sess.rs b/src/sess.rs index 470e0d1a..8695f4ee 100644 --- a/src/sess.rs +++ b/src/sess.rs @@ -1018,6 +1018,11 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { ) .await?; } else if clear == CheckoutState::ToCheckout { + let pb = Some(ProgressHandler::new( + self.sess.multiprogress.clone(), + GitProgressOps::Fetch, + name, + )); local_git .clone() .spawn_with( @@ -1028,7 +1033,7 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { .arg("--prune") .arg("--progress") }, - None, + pb, ) .await?; let pb = Some(ProgressHandler::new( From 1796d80a3a5dc8be04a0979e263b3a57666c2f49 Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Wed, 21 Jan 2026 23:28:21 +0100 Subject: [PATCH 29/34] resolver: Align to new style when manually resolving versions --- src/resolver.rs | 34 ++++++++++++++++++---------------- src/sess.rs | 2 +- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/resolver.rs b/src/resolver.rs index 0b27d75e..cc80bdbb 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -30,6 +30,7 @@ use crate::sess::{ }; use crate::target::TargetSpec; use crate::util::{version_req_bottom_bound, version_req_top_bound}; +use crate::{fmt_path, fmt_pkg, fmt_version}; /// A dependency resolver. pub struct DependencyResolver<'ctx> { @@ -709,26 +710,26 @@ impl<'ctx> DependencyResolver<'ctx> { sources: &IndexMap>, ) -> Result<(DependencyRef, IndexSet)> { let mut msg = format!( - "Dependency requirements conflict with each other on dependency `{}`.\n", - name + "Dependency requirements conflict with each other on dependency {}.\n", + fmt_pkg!(name) ); let mut cons = Vec::new(); let mut constr_align = String::from(""); for &(pkg_name, ref con, ref dsrc) in all_cons { constr_align.push_str(&format!( - "\n- package `{}`\trequires\t`{}`{}\tat `{}`", - pkg_name, - con, + "\n- package {}\trequires\t{}{}\tat {}", + fmt_pkg!(pkg_name), + fmt_version!(con), match con { DependencyConstraint::Version(req) => format!( " ({} <= x < {})", - version_req_bottom_bound(req)?.unwrap(), - version_req_top_bound(req)?.unwrap() + fmt_version!(version_req_bottom_bound(req)?.unwrap()), + fmt_version!(version_req_top_bound(req)?.unwrap()) ), DependencyConstraint::Revision(_) => "".to_string(), DependencyConstraint::Path => "".to_string(), }, - self.sess.dependency_source(*dsrc), + fmt_path!(self.sess.dependency_source(*dsrc)), )); cons.push((con, dsrc)); } @@ -794,9 +795,9 @@ impl<'ctx> DependencyResolver<'ctx> { if let Some((cnstr, src, _, _)) = self.locked.get(name) { let _ = write!( msg, - "\n\nThe previous lockfile required `{}` at `{}`", - cnstr, - self.sess.dependency_source(*src) + "\n\nThe previous lockfile required {} at {}.", + fmt_version!(cnstr), + fmt_path!(self.sess.dependency_source(*src)) ); cons.insert(0, (cnstr, src)); } @@ -807,18 +808,19 @@ impl<'ctx> DependencyResolver<'ctx> { } else { eprintln!( "{}\n\nTo resolve this conflict manually, \ - select a revision for `{}` among:", - msg, name + select a revision for {} among:", + msg, + fmt_pkg!(name) ); let mut tw2 = TabWriter::new(vec![]); for (idx, e) in cons.iter().enumerate() { writeln!( &mut tw2, - "{})\t`{}`\tat `{}`", + "{})\t{}\tat {}", idx, - e.0, - self.sess.dependency_source(*e.1) + fmt_version!(e.0), + fmt_path!(self.sess.dependency_source(*e.1)) ) .unwrap(); } diff --git a/src/sess.rs b/src/sess.rs index 8695f4ee..a6d5b95e 100644 --- a/src/sess.rs +++ b/src/sess.rs @@ -1799,7 +1799,7 @@ impl fmt::Display for DependencySource { match *self { DependencySource::Registry => write!(f, "registry"), DependencySource::Path(ref path) => write!(f, "{:?}", path), - DependencySource::Git(ref url) => write!(f, "`{}`", url), + DependencySource::Git(ref url) => write!(f, "{}", url), } } } From 8be1a075446f9294fa1f629d714da6a04db366bc Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Wed, 21 Jan 2026 23:42:31 +0100 Subject: [PATCH 30/34] util: Change style of version to cyan --- src/util.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/util.rs b/src/util.rs index 70389037..7f33c726 100644 --- a/src/util.rs +++ b/src/util.rs @@ -468,7 +468,7 @@ macro_rules! fmt_field { #[macro_export] macro_rules! fmt_version { ($ver:expr) => { - $crate::util::OwoColorize::bold(&$ver) + $crate::util::OwoColorize::cyan(&$ver) }; } From e635556834d67839a6a0c875b3650a2e112fcbcd Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Thu, 22 Jan 2026 00:00:14 +0100 Subject: [PATCH 31/34] error: Suspend progressbars when printing stage messages --- src/diagnostic.rs | 33 ++++++++++++++++++--------------- src/error.rs | 2 +- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/diagnostic.rs b/src/diagnostic.rs index 72f3e9a4..23d32399 100644 --- a/src/diagnostic.rs +++ b/src/diagnostic.rs @@ -64,6 +64,22 @@ impl Diagnostics { let diag = Diagnostics::get(); diag.all_suppressed || diag.suppressed.contains(code) } + + // Print cleanly (using suspend if a bar exists) + pub fn eprintln(msg: &str) { + let diag = Diagnostics::get(); + let mp_guard = diag.multiprogress.lock().unwrap(); + + if let Some(mp) = &*mp_guard { + // If we have progress bars, hide them momentarily + mp.suspend(|| { + eprintln!("{msg}"); + }); + } else { + // Otherwise just print + eprintln!("{msg}"); + } + } } impl Warnings { @@ -86,22 +102,9 @@ impl Warnings { emitted.insert(self.clone()); drop(emitted); - // Prepare the report + // Prepare and emit the report let report = miette::Report::new(self.clone()); - - // Print cleanly (using suspend if a bar exists) - let mp_guard = diag.multiprogress.lock().unwrap(); - - if let Some(mp) = &*mp_guard { - // If we have progress bars, hide them momentarily - mp.suspend(|| { - eprintln!("{:?}", report); - }); - } else { - eprintln!("No multiprogress bar available."); - // Otherwise just print - eprintln!("{:?}", report); - } + Diagnostics::eprintln(&format!("{report:?}")); } } diff --git a/src/error.rs b/src/error.rs index f4763c5f..f254ffcc 100644 --- a/src/error.rs +++ b/src/error.rs @@ -53,7 +53,7 @@ macro_rules! debugln { /// Emit a diagnostic message. macro_rules! diagnostic { ($severity:expr; $($arg:tt)*) => { - eprintln!("{:>14} {}", $severity, format!($($arg)*)) + $crate::diagnostic::Diagnostics::eprintln(&format!("{:>14} {}", $severity, format!($($arg)*))) } } From 1dc7fccb153155a43cc04ea54e1376a9c6dca5dd Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Thu, 22 Jan 2026 10:09:42 +0100 Subject: [PATCH 32/34] treewide: Respect `NO_COLOR` environments for all stderr output --- Cargo.lock | 46 +++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 2 +- src/cmd/checkout.rs | 6 +++--- src/cmd/update.rs | 1 - src/diagnostic.rs | 14 +++++++------- src/error.rs | 6 +++--- src/progress.rs | 19 +++++++++---------- src/util.rs | 32 ++++++++++++++++++++++++------- 8 files changed, 94 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c49a0b11..859c4d92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -611,6 +611,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "humansize" version = "2.1.3" @@ -685,6 +691,23 @@ dependencies = [ "web-time", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -836,6 +859,10 @@ name = "owo-colors" version = "4.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +dependencies = [ + "supports-color 2.1.0", + "supports-color 3.0.2", +] [[package]] name = "parking_lot" @@ -1322,6 +1349,25 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "supports-color" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89" +dependencies = [ + "is-terminal", + "is_ci", +] + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + [[package]] name = "syn" version = "2.0.114" diff --git a/Cargo.toml b/Cargo.toml index 66d295db..bb1d012d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,7 @@ subst = "0.3" tera = "1.19" miette = "7.6.0" thiserror = "2.0.18" -owo-colors = "4.2.3" +owo-colors = { version = "4.2.3", features = ["supports-colors"] } indicatif = "0.18.3" regex = "1.12.2" diff --git a/src/cmd/checkout.rs b/src/cmd/checkout.rs index fa6c570f..5ebb43bc 100644 --- a/src/cmd/checkout.rs +++ b/src/cmd/checkout.rs @@ -4,10 +4,10 @@ //! The `checkout` subcommand. use clap::Args; -use owo_colors::OwoColorize; use tokio::runtime::Runtime; use crate::error::*; +use crate::fmt_dim; use crate::sess::{Session, SessionIo}; use crate::util::fmt_duration; @@ -33,9 +33,9 @@ pub fn run_plain(sess: &Session, force: bool, update_list: &[String]) -> Result< let num_dependencies = io.sess.packages().iter().flatten().count(); infoln!( "{} {} dependencies {}", - "Checked out".dimmed(), + fmt_dim!("Checked out"), num_dependencies, - fmt_duration(start_time.elapsed()).dimmed() + fmt_dim!(fmt_duration(start_time.elapsed())) ); Ok(()) diff --git a/src/cmd/update.rs b/src/cmd/update.rs index 073432d0..9f133f47 100644 --- a/src/cmd/update.rs +++ b/src/cmd/update.rs @@ -8,7 +8,6 @@ use std::io::Write; use clap::Args; use indexmap::IndexSet; -use owo_colors::OwoColorize; use tabwriter::TabWriter; use crate::cmd; diff --git a/src/diagnostic.rs b/src/diagnostic.rs index 23d32399..69751edb 100644 --- a/src/diagnostic.rs +++ b/src/diagnostic.rs @@ -8,10 +8,10 @@ use std::sync::{Mutex, OnceLock}; use indicatif::MultiProgress; use miette::{Diagnostic, ReportHandler}; -use owo_colors::{OwoColorize, Style}; +use owo_colors::Style; use thiserror::Error; -use crate::{fmt_field, fmt_path, fmt_pkg, fmt_version}; +use crate::{fmt_dim, fmt_field, fmt_path, fmt_pkg, fmt_version, fmt_with_style}; static GLOBAL_DIAGNOSTICS: OnceLock = OnceLock::new(); @@ -120,11 +120,11 @@ impl ReportHandler for DiagnosticRenderer { }; // Write the severity prefix - write!(f, "{}", severity.style(style))?; + write!(f, "{}", fmt_with_style!(severity, style))?; // Write the code, if any if let Some(code) = diagnostic.code() { - write!(f, "{}", format!("[{}]", code).style(style))?; + write!(f, "{}", fmt_with_style!(format!("[{}]", code), style))?; } // Write the main diagnostic message @@ -139,8 +139,8 @@ impl ReportHandler for DiagnosticRenderer { for line in help_str.lines() { annotations.push(format!( "{} {}", - "help:".bold(), - line.replace("\x1b[0m", "\x1b[0m\x1b[2m").dimmed() + fmt_with_style!("help:", Style::new().bold()), + fmt_dim!(line.replace("\x1b[0m", "\x1b[0m\x1b[2m")) )); } } @@ -154,7 +154,7 @@ impl ReportHandler for DiagnosticRenderer { // The last item gets the corner, everyone else gets a branch let is_last = i == annotations.len() - 1; let prefix = if is_last { corner } else { branch }; - write!(f, "\n{} {}", prefix.dimmed(), note)?; + write!(f, "\n{} {}", fmt_dim!(prefix), note)?; } Ok(()) diff --git a/src/error.rs b/src/error.rs index f254ffcc..6a6674a3 100644 --- a/src/error.rs +++ b/src/error.rs @@ -8,7 +8,7 @@ use std::fmt; use std::sync::atomic::AtomicBool; use std::sync::Arc; -use owo_colors::{OwoColorize, Style}; +use owo_colors::Style; pub static ENABLE_DEBUG: AtomicBool = AtomicBool::new(false); @@ -53,7 +53,7 @@ macro_rules! debugln { /// Emit a diagnostic message. macro_rules! diagnostic { ($severity:expr; $($arg:tt)*) => { - $crate::diagnostic::Diagnostics::eprintln(&format!("{:>14} {}", $severity, format!($($arg)*))) + $crate::diagnostic::Diagnostics::eprintln(&format!("{} {}", $severity, format!($($arg)*))) } } @@ -74,7 +74,7 @@ impl fmt::Display for Severity { Severity::Debug => ("Debug:", Style::new().blue().bold()), Severity::Stage(name) => (name, Style::new().green().bold()), }; - write!(f, "{:>14}", severity.style(style)) + write!(f, "{:>14}", crate::fmt_with_style!(severity, style)) } } diff --git a/src/progress.rs b/src/progress.rs index 99ef0209..53a3d8b6 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -4,7 +4,6 @@ use crate::util::fmt_duration; use indexmap::IndexMap; -use owo_colors::OwoColorize; use std::io::IsTerminal; use std::sync::OnceLock; use std::time::Duration; @@ -13,7 +12,7 @@ use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use regex::Regex; use tokio::io::{AsyncReadExt, BufReader}; -use crate::{fmt_completed, fmt_pkg, fmt_stage}; +use crate::{fmt_completed, fmt_dim, fmt_pkg, fmt_stage}; static RE_GIT: OnceLock = OnceLock::new(); @@ -215,7 +214,7 @@ impl ProgressHandler { // to have a "T" connector (├─) instead of an "L" let prev_bar = match state.sub_bars.last() { Some((last_name, last_pb)) => { - let prev_prefix = format!("{} {}", "├─".dimmed(), last_name); + let prev_prefix = format!("{} {}", fmt_dim!("├─"), last_name); last_pb.set_prefix(prev_prefix); last_pb // Insert the new one after this one } @@ -227,9 +226,9 @@ impl ProgressHandler { .multiprogress .insert_after(prev_bar, ProgressBar::new(100).with_style(style)); // Set the prefix and initial message - let sub_prefix = format!("{} {}", "╰─".dimmed(), &name); + let sub_prefix = format!("{} {}", fmt_dim!("╰─"), &name); sub_pb.set_prefix(sub_prefix); - sub_pb.set_message(format!("{}", "Waiting...".dimmed())); + sub_pb.set_message(format!("{}", fmt_dim!("Waiting..."))); // Store the sub-bar in the state for later updates state.sub_bars.insert(name, sub_pb); @@ -252,7 +251,7 @@ impl ProgressHandler { // Set the new bar to active if let Some(bar) = state.sub_bars.get(&name) { // Switch style to the active progress bar style - bar.set_message(format!("{}", "Cloning...".dimmed())); + bar.set_message(format!("{}", fmt_dim!("Cloning..."))); } state.active_sub = Some(name); } @@ -270,17 +269,17 @@ impl ProgressHandler { } // Update the progress percentage for receiving objects GitProgress::Receiving { percent, .. } => { - target_pb.set_message(format!("{}", "Receiving objects".dimmed())); + target_pb.set_message(format!("{}", fmt_dim!("Receiving objects"))); target_pb.set_position(percent as u64); } // Update the progress percentage for resolving deltas GitProgress::Resolving { percent, .. } => { - target_pb.set_message(format!("{}", "Resolving deltas".dimmed())); + target_pb.set_message(format!("{}", fmt_dim!("Resolving deltas"))); target_pb.set_position(percent as u64); } // Update the progress percentage for checking out files GitProgress::Checkout { percent, .. } => { - target_pb.set_message(format!("{}", "Checking out".dimmed())); + target_pb.set_message(format!("{}", fmt_dim!("Checking out"))); target_pb.set_position(percent as u64); } // Handle errors by finishing and clearing the target bar, then logging the error @@ -309,7 +308,7 @@ impl ProgressHandler { "{:>14} {} {}", fmt_completed!(self.git_op.past_fmt()), fmt_pkg!(&self.name), - fmt_duration(state.start_time.elapsed()).dimmed() + fmt_dim!(fmt_duration(state.start_time.elapsed())) ); // In TTY mode, we can print on top of the progress bars diff --git a/src/util.rs b/src/util.rs index 7f33c726..e65eba2d 100644 --- a/src/util.rs +++ b/src/util.rs @@ -19,7 +19,7 @@ use serde::de::{Deserialize, Deserializer}; use serde::ser::{Serialize, Serializer}; /// Re-export owo_colors for use in macros. -pub use owo_colors::OwoColorize; +pub use owo_colors::{OwoColorize, Stream, Style}; use crate::error::*; @@ -440,11 +440,21 @@ pub fn fmt_duration(duration: std::time::Duration) -> String { } } +/// Format with style if supported. +#[macro_export] +macro_rules! fmt_with_style { + ($item:expr, $style:expr) => { + $crate::util::OwoColorize::if_supports_color(&$item, $crate::util::Stream::Stderr, |t| { + $crate::util::OwoColorize::style(t, $style) + }) + }; +} + /// Format for `package` names in diagnostic messages. #[macro_export] macro_rules! fmt_pkg { ($pkg:expr) => { - $crate::util::OwoColorize::bold(&$pkg) + $crate::fmt_with_style!($pkg, $crate::util::Style::new().bold()) }; } @@ -452,7 +462,7 @@ macro_rules! fmt_pkg { #[macro_export] macro_rules! fmt_path { ($pkg:expr) => { - $crate::util::OwoColorize::underline(&$pkg) + $crate::fmt_with_style!($pkg, $crate::util::Style::new().underline()) }; } @@ -460,7 +470,7 @@ macro_rules! fmt_path { #[macro_export] macro_rules! fmt_field { ($field:expr) => { - $crate::util::OwoColorize::italic(&$field) + $crate::fmt_with_style!($field, $crate::util::Style::new().italic()) }; } @@ -468,7 +478,7 @@ macro_rules! fmt_field { #[macro_export] macro_rules! fmt_version { ($ver:expr) => { - $crate::util::OwoColorize::cyan(&$ver) + $crate::fmt_with_style!($ver, $crate::util::Style::new().cyan()) }; } @@ -476,7 +486,7 @@ macro_rules! fmt_version { #[macro_export] macro_rules! fmt_stage { ($stage:expr) => { - $crate::util::OwoColorize::cyan(&$stage).bold() + $crate::fmt_with_style!($stage, $crate::util::Style::new().cyan().bold()) }; } @@ -484,6 +494,14 @@ macro_rules! fmt_stage { #[macro_export] macro_rules! fmt_completed { ($stage:expr) => { - $crate::util::OwoColorize::green(&$stage).bold() + $crate::fmt_with_style!($stage, $crate::util::Style::new().green().bold()) + }; +} + +/// Format for dimmed text in diagnostic messages. +#[macro_export] +macro_rules! fmt_dim { + ($msg:expr) => { + $crate::fmt_with_style!($msg, $crate::util::Style::new().dimmed()) }; } From 1160e9840f1511350b73d154b61e821e6cd49ec4 Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Thu, 22 Jan 2026 12:18:16 +0100 Subject: [PATCH 33/34] vendor: Clean up `apply_patches` function --- src/cmd/vendor.rs | 130 ++++++++++++++++++++++------------------------ 1 file changed, 62 insertions(+), 68 deletions(-) diff --git a/src/cmd/vendor.rs b/src/cmd/vendor.rs index e71ddd4f..cef078d4 100644 --- a/src/cmd/vendor.rs +++ b/src/cmd/vendor.rs @@ -10,7 +10,6 @@ use std::path::Path; use std::path::PathBuf; use clap::{Args, Subcommand}; -use futures::future::{self}; use glob::Pattern; use tempfile::TempDir; use tokio::runtime::Runtime; @@ -416,76 +415,71 @@ pub fn apply_patches( package_name: String, patch_link: PatchLink, ) -> Result { - if let Some(patch_dir) = patch_link.patch_dir.clone() { - // Create directory in case it does not already exist - std::fs::create_dir_all(patch_dir.clone()).map_err(|cause| { - Error::chain( - format!("Failed to create directory {:?}", patch_dir.clone()), - cause, - ) - })?; + let patch_dir = match &patch_link.patch_dir { + Some(patch_dir) => patch_dir, + None => return Ok(0), + }; - let mut patches = std::fs::read_dir(patch_dir)? - .map(move |f| f.unwrap().path()) - .filter(|f| f.extension().is_some()) - .filter(|f| f.extension().unwrap() == "patch") - .collect::>(); - patches.sort_by_key(|patch_path| patch_path.to_str().unwrap().to_lowercase()); + // Create directory in case it does not already exist + std::fs::create_dir_all(patch_dir).map_err(|cause| { + Error::chain(format!("Failed to create directory {patch_dir:?}"), cause) + })?; - for patch in patches.clone() { - rt.block_on(async { - future::lazy(|_| Ok(())) - .and_then(|_| { - git.clone().spawn_with( - |c| { - let is_file = patch_link - .from_prefix - .clone() - .prefix_paths(git.path) - .unwrap() - .is_file(); - - let current_patch_target = if is_file { - patch_link.from_prefix.parent().unwrap().to_str().unwrap() - } else { - patch_link.from_prefix.as_path().to_str().unwrap() - }; - - c.arg("apply") - .arg("--directory") - .arg(current_patch_target) - .arg("-p1") - .arg(&patch); - - // limit to specific file for file links - if is_file { - let file_path = patch_link.from_prefix.to_str().unwrap(); - c.arg("--include").arg(file_path); - } - c - }, - None, - ) - }) - .await - .map_err(|cause| { - Error::chain(format!("Failed to apply patch {:?}.", patch.clone()), cause) - }) - .map(|_| { - stageln!( - "Patched", - "{} with {}", - fmt_pkg!(package_name), - fmt_path!(patch.display()) - ); - }) - .map(|_| git.clone()) - })?; + let mut patches = std::fs::read_dir(patch_dir)? + .map(move |f| f.unwrap().path()) + .filter(|f| f.extension().is_some()) + .filter(|f| f.extension().unwrap() == "patch") + .collect::>(); + patches.sort_by_key(|patch_path| patch_path.to_str().unwrap().to_lowercase()); + + rt.block_on(async { + for patch in &patches { + git.clone() + .spawn_with( + |c| { + let is_file = patch_link + .from_prefix + .clone() + .prefix_paths(git.path) + .unwrap() + .is_file(); + + let current_patch_target = if is_file { + patch_link.from_prefix.parent().unwrap().to_str().unwrap() + } else { + patch_link.from_prefix.as_path().to_str().unwrap() + }; + + c.arg("apply") + .arg("--directory") + .arg(current_patch_target) + .arg("-p1") + .arg(patch); + + // limit to specific file for file links + if is_file { + let file_path = patch_link.from_prefix.to_str().unwrap(); + c.arg("--include").arg(file_path); + } + c + }, + None, + ) + .await + .map_err(|cause| { + Error::chain(format!("Failed to apply patch {patch:?}."), cause) + })?; + + stageln!( + "Patched", + "{} with {}", + fmt_pkg!(package_name), + fmt_path!(patch.display()) + ); } - Ok(patches.len()) - } else { - Ok(0) - } + Ok::<(), Error>(()) + })?; + Ok(patches.len()) } /// Generate diff From e7d37beff11b552f626f3173d90aac3159ca98b4 Mon Sep 17 00:00:00 2001 From: Tim Fischer Date: Thu, 22 Jan 2026 15:09:28 +0100 Subject: [PATCH 34/34] git: Combine fetch operations into one In newer versions of git, tags are automatically fetched if the belong to the history of the reference that is fetched (i.e. a branch). Fetching `--tags` is therefore in most cases a no-op, since tags have been fetched in the first git operation. Only in the case where a specific branch is fetched e.g. `git fetch origin my_branch`, fetching additional tags from other branches could make sense. This is not the case here, since we fetch the entire remote --- src/git.rs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/git.rs b/src/git.rs index 0eb29d3f..890cda73 100644 --- a/src/git.rs +++ b/src/git.rs @@ -232,19 +232,17 @@ impl<'ctx> Git<'ctx> { /// Fetch the tags and refs of a remote. pub async fn fetch(self, remote: &str, pb: Option) -> Result<()> { - let r1 = String::from(remote); - let r2 = String::from(remote); self.clone() .spawn_with( - |c| c.arg("fetch").arg("--prune").arg(r1).arg("--progress"), + |c| { + c.arg("fetch") + .arg("--tags") + .arg("--prune") + .arg(remote) + .arg("--progress") + }, pb, ) - .and_then(|_| { - self.spawn_with( - |c| c.arg("fetch").arg("--tags").arg("--prune").arg(r2), - None, - ) - }) .await .map(|_| ()) }