From cc272bc305006142220a1ce9ebaca3d6922ecba0 Mon Sep 17 00:00:00 2001 From: Rob Sanheim Date: Tue, 10 Feb 2026 07:05:42 -0600 Subject: [PATCH 1/7] Implement pipe-delimited output format and integration test suite Update Rust implementation to conform to SPEC.md v0.2.2 three-column pipe-delimited output format: `repo | branch | message`. Rust changes: - Add FormattedResult struct and update OutputFormatter trait - Replace bracket-wrapped format_repo_name with format_column using `...` truncation - Add branch resolution via rev-parse in worker threads - Parse branch line from `git status --porcelain -b` with ahead/behind support - Handle detached HEAD as `HEAD (detached)` - Add --no-optional-locks as global git option for status - Collect-then-print in run_parallel for aligned column widths - Exit code 9 when no repositories found Integration test suite (Ruby/RSpec): - 26 specs covering full Section 7.2.3 test matrix - Real git repos in tmpdirs for each test - Tests for pipe-delimited format, column alignment, ordering, truncation --- .gitignore | 3 + .rspec | 3 + Gemfile | 3 + Gemfile.lock | 35 ++++ mise.toml | 1 + rust/Cargo.lock | 2 +- rust/src/commands/fetch.rs | 47 +++-- rust/src/commands/passthrough.rs | 18 +- rust/src/commands/pull.rs | 33 ++- rust/src/commands/status.rs | 280 ++++++++++++++++++++++++-- rust/src/main.rs | 2 +- rust/src/runner.rs | 211 ++++++++++++------- script/test | 27 ++- spec/output_format_spec.rb | 76 +++++++ spec/spec_helper.rb | 17 ++ spec/status_spec.rb | 335 +++++++++++++++++++++++++++++++ spec/support/git_all_runner.rb | 16 ++ spec/support/output_parser.rb | 22 ++ spec/support/repo_builder.rb | 128 ++++++++++++ 19 files changed, 1146 insertions(+), 113 deletions(-) create mode 100644 .rspec create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 spec/output_format_spec.rb create mode 100644 spec/spec_helper.rb create mode 100644 spec/status_spec.rb create mode 100644 spec/support/git_all_runner.rb create mode 100644 spec/support/output_parser.rb create mode 100644 spec/support/repo_builder.rb diff --git a/.gitignore b/.gitignore index c954cbf..f4f9e42 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ crystal/lib/ *.swp *~ +# Ruby +vendor/bundle/ + # macOS .DS_Store diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..34c5164 --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--format documentation +--color +--require spec_helper diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..c791a2a --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "rspec", "~> 3.13" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..2c9af4d --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,35 @@ +GEM + remote: https://rubygems.org/ + specs: + diff-lcs (1.6.2) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.7) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + +PLATFORMS + arm64-darwin-24 + ruby + +DEPENDENCIES + rspec (~> 3.13) + +CHECKSUMS + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.7) sha256=0979034e64b1d7a838aaaddf12bf065ea4dc40ef3d4c39f01f93ae2c66c62b1c + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + +BUNDLED WITH + 4.0.5 diff --git a/mise.toml b/mise.toml index 34d9cab..8512129 100644 --- a/mise.toml +++ b/mise.toml @@ -1,3 +1,4 @@ [tools] +ruby = "latest" rust = "latest" zig = "latest" diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 0f488a9..79216e1 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -159,7 +159,7 @@ dependencies = [ [[package]] name = "git-all" -version = "0.6.1" +version = "0.7.0" dependencies = [ "anyhow", "clap", diff --git a/rust/src/commands/fetch.rs b/rust/src/commands/fetch.rs index 91214cc..2cbe24d 100644 --- a/rust/src/commands/fetch.rs +++ b/rust/src/commands/fetch.rs @@ -2,24 +2,30 @@ use anyhow::Result; use std::path::PathBuf; use std::process::Output; -use crate::runner::{run_parallel, ExecutionContext, GitCommand, OutputFormatter}; +use crate::runner::{run_parallel, ExecutionContext, FormattedResult, GitCommand, OutputFormatter}; struct FetchFormatter; impl OutputFormatter for FetchFormatter { - fn format(&self, output: &Output) -> String { + fn format(&self, output: &Output) -> FormattedResult { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); if !output.status.success() { - return stderr.lines().next().unwrap_or("unknown error").to_string(); + return FormattedResult { + branch: String::new(), + message: stderr.lines().next().unwrap_or("unknown error").to_string(), + }; } let has_output = stdout.lines().any(|l| !l.trim().is_empty()) || stderr.lines().any(|l| !l.trim().is_empty() && !l.starts_with("From")); if !has_output { - return "no new commits".to_string(); + return FormattedResult { + branch: String::new(), + message: "no new commits".to_string(), + }; } let (branch_count, tag_count) = stdout @@ -37,10 +43,16 @@ impl OutputFormatter for FetchFormatter { if tag_count > 0 { parts.push(format!("{} tag{}", tag_count, if tag_count == 1 { "" } else { "s" })); } - return format!("{} updated", parts.join(", ")); + return FormattedResult { + branch: String::new(), + message: format!("{} updated", parts.join(", ")), + }; } - "fetched".to_string() + FormattedResult { + branch: String::new(), + message: "fetched".to_string(), + } } } @@ -77,28 +89,30 @@ mod tests { fn test_error_returns_first_stderr_line() { let formatter = FetchFormatter; let output = make_output("", "fatal: not a git repository", false); - assert_eq!(formatter.format(&output), "fatal: not a git repository"); + let result = formatter.format(&output); + assert_eq!(result.message, "fatal: not a git repository"); + assert!(result.branch.is_empty()); } #[test] fn test_empty_output_returns_no_new_commits() { let formatter = FetchFormatter; let output = make_output("", "", true); - assert_eq!(formatter.format(&output), "no new commits"); + assert_eq!(formatter.format(&output).message, "no new commits"); } #[test] fn test_only_from_line_returns_no_new_commits() { let formatter = FetchFormatter; let output = make_output("", "From github.com:user/repo", true); - assert_eq!(formatter.format(&output), "no new commits"); + assert_eq!(formatter.format(&output).message, "no new commits"); } #[test] fn test_single_branch_update() { let formatter = FetchFormatter; let output = make_output(" abc123..def456 main -> origin/main\n", "", true); - assert_eq!(formatter.format(&output), "1 branch updated"); + assert_eq!(formatter.format(&output).message, "1 branch updated"); } #[test] @@ -106,14 +120,14 @@ mod tests { let formatter = FetchFormatter; let stdout = " abc123..def456 main -> origin/main\n 111222..333444 develop -> origin/develop\n"; let output = make_output(stdout, "", true); - assert_eq!(formatter.format(&output), "2 branches updated"); + assert_eq!(formatter.format(&output).message, "2 branches updated"); } #[test] fn test_single_tag() { let formatter = FetchFormatter; let output = make_output(" * [new tag] v1.0.0 -> v1.0.0\n", "", true); - assert_eq!(formatter.format(&output), "1 tag updated"); + assert_eq!(formatter.format(&output).message, "1 tag updated"); } #[test] @@ -121,7 +135,7 @@ mod tests { let formatter = FetchFormatter; let stdout = " * [new tag] v1.0.0 -> v1.0.0\n * [new tag] v1.0.1 -> v1.0.1\n"; let output = make_output(stdout, "", true); - assert_eq!(formatter.format(&output), "2 tags updated"); + assert_eq!(formatter.format(&output).message, "2 tags updated"); } #[test] @@ -129,13 +143,16 @@ mod tests { let formatter = FetchFormatter; let stdout = " abc123..def456 main -> origin/main\n * [new tag] v1.0.0 -> v1.0.0\n"; let output = make_output(stdout, "", true); - assert_eq!(formatter.format(&output), "1 branch, 1 tag updated"); + assert_eq!( + formatter.format(&output).message, + "1 branch, 1 tag updated" + ); } #[test] fn test_fallback_to_fetched() { let formatter = FetchFormatter; let output = make_output("some other output\n", "", true); - assert_eq!(formatter.format(&output), "fetched"); + assert_eq!(formatter.format(&output).message, "fetched"); } } diff --git a/rust/src/commands/passthrough.rs b/rust/src/commands/passthrough.rs index 944df0d..dd50ca6 100644 --- a/rust/src/commands/passthrough.rs +++ b/rust/src/commands/passthrough.rs @@ -2,12 +2,12 @@ use anyhow::Result; use std::path::PathBuf; use std::process::Output; -use crate::runner::{run_parallel, ExecutionContext, GitCommand, OutputFormatter}; +use crate::runner::{run_parallel, ExecutionContext, FormattedResult, GitCommand, OutputFormatter}; struct PassthroughFormatter; impl OutputFormatter for PassthroughFormatter { - fn format(&self, output: &Output) -> String { + fn format(&self, output: &Output) -> FormattedResult { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); @@ -16,16 +16,24 @@ impl OutputFormatter for PassthroughFormatter { .lines() .find(|l| !l.trim().is_empty()) .unwrap_or("unknown error"); - return format!("ERROR: {}", error_line); + return FormattedResult { + branch: String::new(), + message: format!("ERROR: {}", error_line), + }; } - stdout + let message = stdout .lines() .chain(stderr.lines()) .find(|l| !l.trim().is_empty()) .unwrap_or("ok") .trim() - .to_string() + .to_string(); + + FormattedResult { + branch: String::new(), + message, + } } } diff --git a/rust/src/commands/pull.rs b/rust/src/commands/pull.rs index d5b5052..4883f29 100644 --- a/rust/src/commands/pull.rs +++ b/rust/src/commands/pull.rs @@ -2,27 +2,36 @@ use anyhow::Result; use std::path::PathBuf; use std::process::Output; -use crate::runner::{run_parallel, ExecutionContext, GitCommand, OutputFormatter}; +use crate::runner::{run_parallel, ExecutionContext, FormattedResult, GitCommand, OutputFormatter}; struct PullFormatter; impl OutputFormatter for PullFormatter { - fn format(&self, output: &Output) -> String { + fn format(&self, output: &Output) -> FormattedResult { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); if !output.status.success() { - return stderr.lines().next().unwrap_or("unknown error").to_string(); + return FormattedResult { + branch: String::new(), + message: stderr.lines().next().unwrap_or("unknown error").to_string(), + }; } // Check for "Already up to date" if stdout.contains("Already up to date") { - return "Already up to date".to_string(); + return FormattedResult { + branch: String::new(), + message: "Already up to date".to_string(), + }; } // Try to extract summary from stdout (e.g., "3 files changed, 10 insertions(+), 5 deletions(-)") if let Some(summary_line) = stdout.lines().find(|l| l.contains("files changed")) { - return summary_line.trim().to_string(); + return FormattedResult { + branch: String::new(), + message: summary_line.trim().to_string(), + }; } // Check for fast-forward or merge info in stdout @@ -30,17 +39,25 @@ impl OutputFormatter for PullFormatter { .lines() .find(|l| l.contains("..") || l.contains("Updating")) { - return line.trim().to_string(); + return FormattedResult { + branch: String::new(), + message: line.trim().to_string(), + }; } // Fallback: first non-empty line of stdout, or stderr - stdout + let message = stdout .lines() .chain(stderr.lines()) .find(|l| !l.trim().is_empty()) .unwrap_or("completed") .trim() - .to_string() + .to_string(); + + FormattedResult { + branch: String::new(), + message, + } } } diff --git a/rust/src/commands/status.rs b/rust/src/commands/status.rs index 26360a1..062267b 100644 --- a/rust/src/commands/status.rs +++ b/rust/src/commands/status.rs @@ -2,19 +2,75 @@ use anyhow::Result; use std::path::PathBuf; use std::process::Output; -use crate::runner::{run_parallel, ExecutionContext, GitCommand, OutputFormatter}; +use crate::runner::{run_parallel, ExecutionContext, FormattedResult, GitCommand, OutputFormatter}; struct StatusFormatter; +/// Parse the `## branch...remote [ahead N, behind M]` header line from porcelain -b output. +/// Returns (branch_name, ahead_count, behind_count). +fn parse_branch_line(line: &str) -> (String, usize, usize) { + let mut ahead = 0usize; + let mut behind = 0usize; + + if !line.starts_with("## ") { + return (String::new(), ahead, behind); + } + + let content = &line[3..]; + + if content.starts_with("HEAD (no branch)") { + return ("HEAD (detached)".to_string(), ahead, behind); + } + + if content.starts_with("No commits yet on ") { + return (content[18..].to_string(), ahead, behind); + } + + if content.starts_with("Initial commit on ") { + return (content[18..].to_string(), ahead, behind); + } + + let (branch_part, tracking_info) = if let Some(dots_pos) = content.find("...") { + (&content[..dots_pos], &content[dots_pos + 3..]) + } else { + (content.trim(), "") + }; + + let branch = branch_part.to_string(); + + if let Some(bracket_start) = tracking_info.find('[') { + if let Some(bracket_end) = tracking_info.find(']') { + let info = &tracking_info[bracket_start + 1..bracket_end]; + for part in info.split(',') { + let part = part.trim(); + if let Some(n) = part.strip_prefix("ahead ") { + ahead = n.parse().unwrap_or(0); + } else if let Some(n) = part.strip_prefix("behind ") { + behind = n.parse().unwrap_or(0); + } + } + } + } + + (branch, ahead, behind) +} + impl OutputFormatter for StatusFormatter { - fn format(&self, output: &Output) -> String { + fn format(&self, output: &Output) -> FormattedResult { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); if !output.status.success() { - return stderr.lines().next().unwrap_or("unknown error").to_string(); + return FormattedResult { + branch: String::new(), + message: stderr.lines().next().unwrap_or("unknown error").to_string(), + }; } + let mut branch = String::new(); + let mut ahead = 0usize; + let mut behind = 0usize; + let mut modified = 0; let mut added = 0; let mut deleted = 0; @@ -22,6 +78,14 @@ impl OutputFormatter for StatusFormatter { let mut renamed = 0; for line in stdout.lines() { + if line.starts_with("## ") { + let (b, a, bh) = parse_branch_line(line); + branch = b; + ahead = a; + behind = bh; + continue; + } + if line.len() < 2 { continue; } @@ -42,7 +106,7 @@ impl OutputFormatter for StatusFormatter { _ => {} } - // Check worktree status (unstaged changes) - only if not already counted + // Worktree status only counted when index status is a space (no staged change) if index_status == ' ' { match worktree_status { 'M' => modified += 1, @@ -52,10 +116,6 @@ impl OutputFormatter for StatusFormatter { } } - if modified == 0 && added == 0 && deleted == 0 && untracked == 0 && renamed == 0 { - return "clean".to_string(); - } - let mut parts = Vec::new(); if modified > 0 { @@ -74,7 +134,24 @@ impl OutputFormatter for StatusFormatter { parts.push(format!("{} untracked", untracked)); } - parts.join(", ") + let has_file_changes = !parts.is_empty(); + + if ahead > 0 { + parts.push(format!("{} ahead", ahead)); + } + if behind > 0 { + parts.push(format!("{} behind", behind)); + } + + let message = if parts.is_empty() { + "clean".to_string() + } else if !has_file_changes { + format!("clean, {}", parts.join(", ")) + } else { + parts.join(", ") + }; + + FormattedResult { branch, message } } } @@ -85,11 +162,190 @@ pub fn run(ctx: &ExecutionContext, repos: &[PathBuf], extra_args: &[String]) -> ctx, repos, |repo| { - // Always use --porcelain for machine-readable output - let mut args = vec!["status".to_string(), "--porcelain".to_string()]; + let global_args = vec!["--no-optional-locks".to_string()]; + let mut args = vec![ + "status".to_string(), + "--porcelain".to_string(), + "-b".to_string(), + ]; args.extend(extra_args.iter().cloned()); - GitCommand::new(repo.clone(), args) + GitCommand::with_global_args(repo.clone(), global_args, args) }, &formatter, ) } + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::process::ExitStatusExt; + use std::process::ExitStatus; + + fn make_output(stdout: &str, stderr: &str, success: bool) -> Output { + Output { + status: ExitStatus::from_raw(if success { 0 } else { 256 }), + stdout: stdout.as_bytes().to_vec(), + stderr: stderr.as_bytes().to_vec(), + } + } + + #[test] + fn test_parse_branch_simple() { + let (branch, ahead, behind) = parse_branch_line("## main"); + assert_eq!(branch, "main"); + assert_eq!(ahead, 0); + assert_eq!(behind, 0); + } + + #[test] + fn test_parse_branch_with_tracking() { + let (branch, ahead, behind) = parse_branch_line("## main...origin/main"); + assert_eq!(branch, "main"); + assert_eq!(ahead, 0); + assert_eq!(behind, 0); + } + + #[test] + fn test_parse_branch_ahead() { + let (branch, ahead, behind) = parse_branch_line("## main...origin/main [ahead 2]"); + assert_eq!(branch, "main"); + assert_eq!(ahead, 2); + assert_eq!(behind, 0); + } + + #[test] + fn test_parse_branch_behind() { + let (branch, ahead, behind) = parse_branch_line("## main...origin/main [behind 3]"); + assert_eq!(branch, "main"); + assert_eq!(ahead, 0); + assert_eq!(behind, 3); + } + + #[test] + fn test_parse_branch_diverged() { + let (branch, ahead, behind) = + parse_branch_line("## main...origin/main [ahead 2, behind 3]"); + assert_eq!(branch, "main"); + assert_eq!(ahead, 2); + assert_eq!(behind, 3); + } + + #[test] + fn test_parse_branch_detached() { + let (branch, ahead, behind) = parse_branch_line("## HEAD (no branch)"); + assert_eq!(branch, "HEAD (detached)"); + assert_eq!(ahead, 0); + assert_eq!(behind, 0); + } + + #[test] + fn test_clean_repo() { + let formatter = StatusFormatter; + let output = make_output("## main\n", "", true); + let result = formatter.format(&output); + assert_eq!(result.branch, "main"); + assert_eq!(result.message, "clean"); + } + + #[test] + fn test_one_unstaged_modification() { + let formatter = StatusFormatter; + let output = make_output("## main\n M file.txt\n", "", true); + let result = formatter.format(&output); + assert_eq!(result.branch, "main"); + assert_eq!(result.message, "1 modified"); + } + + #[test] + fn test_one_staged_modification() { + let formatter = StatusFormatter; + let output = make_output("## main\nM file.txt\n", "", true); + let result = formatter.format(&output); + assert_eq!(result.message, "1 modified"); + } + + #[test] + fn test_mm_counts_once() { + let formatter = StatusFormatter; + let output = make_output("## main\nMM file.txt\n", "", true); + let result = formatter.format(&output); + assert_eq!(result.message, "1 modified"); + } + + #[test] + fn test_staged_add() { + let formatter = StatusFormatter; + let output = make_output("## main\nA file.txt\n", "", true); + let result = formatter.format(&output); + assert_eq!(result.message, "1 added"); + } + + #[test] + fn test_am_counts_as_added() { + let formatter = StatusFormatter; + let output = make_output("## main\nAM file.txt\n", "", true); + let result = formatter.format(&output); + assert_eq!(result.message, "1 added"); + } + + #[test] + fn test_all_types() { + let formatter = StatusFormatter; + let output = make_output( + "## main\nM a.txt\nA b.txt\nD c.txt\nR d.txt -> e.txt\n?? f.txt\n", + "", + true, + ); + let result = formatter.format(&output); + assert_eq!( + result.message, + "1 modified, 1 added, 1 deleted, 1 renamed, 1 untracked" + ); + } + + #[test] + fn test_clean_ahead() { + let formatter = StatusFormatter; + let output = make_output("## main...origin/main [ahead 2]\n", "", true); + let result = formatter.format(&output); + assert_eq!(result.message, "clean, 2 ahead"); + } + + #[test] + fn test_clean_behind() { + let formatter = StatusFormatter; + let output = make_output("## main...origin/main [behind 3]\n", "", true); + let result = formatter.format(&output); + assert_eq!(result.message, "clean, 3 behind"); + } + + #[test] + fn test_clean_diverged() { + let formatter = StatusFormatter; + let output = + make_output("## main...origin/main [ahead 2, behind 3]\n", "", true); + let result = formatter.format(&output); + assert_eq!(result.message, "clean, 2 ahead, 3 behind"); + } + + #[test] + fn test_modified_and_ahead() { + let formatter = StatusFormatter; + let output = make_output( + "## main...origin/main [ahead 1]\n M file.txt\n", + "", + true, + ); + let result = formatter.format(&output); + assert_eq!(result.message, "1 modified, 1 ahead"); + } + + #[test] + fn test_error_returns_stderr() { + let formatter = StatusFormatter; + let output = make_output("", "fatal: not a git repository", false); + let result = formatter.format(&output); + assert_eq!(result.message, "fatal: not a git repository"); + assert!(result.branch.is_empty()); + } +} diff --git a/rust/src/main.rs b/rust/src/main.rs index 2e34280..a533ecc 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -112,7 +112,7 @@ fn main() -> Result<()> { let repos = find_git_repos_in(&cwd, cli.scan_depth)?; if repos.is_empty() { println!("No git repositories found in current directory"); - return Ok(()); + std::process::exit(9); } let url_scheme = if cli.ssh { diff --git a/rust/src/runner.rs b/rust/src/runner.rs index a7ebe72..c565878 100644 --- a/rust/src/runner.rs +++ b/rust/src/runner.rs @@ -40,6 +40,8 @@ impl Semaphore { const MIN_REPO_NAME_WIDTH: usize = 4; const MAX_REPO_NAME_WIDTH_CAP: usize = 48; +const MIN_BRANCH_WIDTH: usize = 4; +const MAX_BRANCH_WIDTH_CAP: usize = 24; /// URL scheme to force for git operations #[derive(Clone, Copy)] @@ -50,7 +52,7 @@ pub enum UrlScheme { Https, } -/// Format repo name with fixed width: truncate long names, pad short ones +/// Compute the display width for the repo name column fn compute_name_width(repos: &[PathBuf], display_root: &Path) -> usize { let mut max_len = 0usize; for repo in repos { @@ -62,18 +64,54 @@ fn compute_name_width(repos: &[PathBuf], display_root: &Path) -> usize { capped.max(MIN_REPO_NAME_WIDTH) } -/// Format repo name with fixed width: truncate long names, pad short ones -fn format_repo_name(name: &str, width: usize) -> String { - let display_name = if name.len() > width { - if width <= 4 { - name.chars().take(width).collect() +/// Compute the display width for the branch column +fn compute_branch_width(results: &[(String, String, String)]) -> usize { + let max_len = results + .iter() + .map(|(_, branch, _)| branch.len()) + .max() + .unwrap_or(0); + let capped = max_len.min(MAX_BRANCH_WIDTH_CAP); + capped.max(MIN_BRANCH_WIDTH) +} + +/// Format a value into a fixed-width column: truncate with `...`, pad short values +fn format_column(value: &str, width: usize) -> String { + if value.len() > width { + if width <= 3 { + value.chars().take(width).collect() } else { - format!("{}-...", &name[..width - 4]) + let truncated: String = value.chars().take(width - 3).collect(); + format!("{truncated}...") } } else { - name.to_string() - }; - format!("[{: String { + let output = Command::new("git") + .arg("-C") + .arg(repo_path) + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .env("GIT_TERMINAL_PROMPT", "0") + .output(); + + match output { + Ok(output) if output.status.success() => { + let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if branch == "HEAD" { + "HEAD (detached)".to_string() + } else { + branch + } + } + _ => "unknown".to_string(), + } } /// Execution context holding configuration for running git commands @@ -119,12 +157,31 @@ impl ExecutionContext { /// A git command ready to be executed against a repository pub struct GitCommand { pub repo_path: PathBuf, + /// Global git options placed before `-C` (e.g., `--no-optional-locks`) + pub global_args: Vec, + /// Subcommand and its arguments placed after `-C ` pub args: Vec, } impl GitCommand { pub fn new(repo_path: PathBuf, args: Vec) -> Self { - Self { repo_path, args } + Self { + repo_path, + global_args: Vec::new(), + args, + } + } + + pub fn with_global_args( + repo_path: PathBuf, + global_args: Vec, + args: Vec, + ) -> Self { + Self { + repo_path, + global_args, + args, + } } /// Spawn the git command without waiting for completion. @@ -146,6 +203,9 @@ impl GitCommand { } } + // Global git options before -C + cmd.args(&self.global_args); + cmd.arg("-C") .arg(&self.repo_path) .args(&self.args) @@ -163,25 +223,37 @@ impl GitCommand { Some(UrlScheme::Https) => "-c \"url.https://github.com/.insteadOf=git@github.com:\" ", None => "", }; + let global = if self.global_args.is_empty() { + String::new() + } else { + format!("{} ", self.global_args.join(" ")) + }; format!( - "git {}-C {} {}", + "git {}{}-C {} {}", scheme_args, + global, self.repo_path.display(), self.args.join(" ") ) } } -/// Trait for formatting command output into one line +/// Result of formatting command output for display +pub struct FormattedResult { + pub branch: String, + pub message: String, +} + +/// Trait for formatting command output into a structured result pub trait OutputFormatter: Sync { - fn format(&self, output: &Output) -> String; + fn format(&self, output: &Output) -> FormattedResult; } -/// Run commands in parallel across all repos with streaming output. +/// Run commands in parallel across all repos, collecting results for aligned output. /// -/// Results are printed in alphabetical order (repos are pre-sorted) as soon as -/// contiguous results are available. Uses head-of-line blocking: if repo "aaa" -/// is slow, "bbb" and "ccc" won't print until "aaa" completes. +/// All results are collected before printing to compute column widths. +/// Each worker thread also resolves the current branch via rev-parse. +/// Output is printed in alphabetical order (repos are pre-sorted). /// /// Uses thread-per-process pattern with `wait_with_output()` which is deadlock-safe /// (stdlib internally spawns threads to drain stdout/stderr concurrently). @@ -214,10 +286,6 @@ where None }; - let mut results: Vec)>> = - (0..repos.len()).map(|_| None).collect(); - let mut next_to_print: usize = 0; - let (tx, rx) = mpsc::channel(); std::thread::scope(|s| { @@ -232,81 +300,86 @@ where sem.acquire(); } + let branch = resolve_branch(&repo); let result = cmd.spawn(url_scheme).and_then(|c| c.wait_with_output()); if let Some(ref sem) = sem { sem.release(); } - let _ = tx.send((idx, repo, result)); + let _ = tx.send((idx, repo, branch, result)); }); } drop(tx); - for (idx, repo, result) in rx { - results[idx] = Some((repo, result)); - - while next_to_print < results.len() { - if let Some((ref repo_path, ref res)) = results[next_to_print] { - print_result( - repo_path, - res, - formatter, - ctx.display_root(), - name_width, - ); - next_to_print += 1; - } else { - break; + // Collect all results + let mut results: Vec)>> = + (0..repos.len()).map(|_| None).collect(); + + for (idx, repo, branch, result) in rx { + results[idx] = Some((repo, branch, result)); + } + + // Format all results into (name, branch, message) tuples + let formatted: Vec<(String, String, String)> = results + .into_iter() + .map(|r| { + let (repo_path, pre_branch, output_result) = r.unwrap(); + let name = repo_display_name(&repo_path, ctx.display_root()); + + match output_result { + Ok(ref output) => { + let fr = formatter.format(output); + let branch = if fr.branch.is_empty() { + pre_branch + } else { + fr.branch + }; + (name, branch, fr.message) + } + Err(ref e) => (name, pre_branch, format!("ERROR: {e}")), } - } + }) + .collect(); + + let branch_width = compute_branch_width(&formatted); + + for (name, branch, message) in &formatted { + println!( + "{} | {} | {}", + format_column(name, name_width), + format_column(branch, branch_width), + message + ); } }); Ok(()) } -/// Print result for a single repository -fn print_result( - repo_path: &std::path::Path, - result: &Result, - formatter: &dyn OutputFormatter, - display_root: &std::path::Path, - name_width: usize, -) { - let name = repo_display_name(repo_path, display_root); - let output_line = match result { - Ok(output) => { - let formatted = formatter.format(output); - format!("{} {}", format_repo_name(&name, name_width), formatted) - } - Err(e) => format!("{} ERROR: {}", format_repo_name(&name, name_width), e), - }; - println!("{}", output_line); -} - #[cfg(test)] mod tests { use super::*; #[test] - fn test_format_repo_name_short() { - let result = format_repo_name("my-repo", 24); - assert_eq!(result, "[my-repo ]"); - assert_eq!(result.len(), 26); // [ + 24 + ] + fn test_format_column_short() { + let result = format_column("my-repo", 24); + assert_eq!(result, "my-repo "); + assert_eq!(result.len(), 24); } #[test] - fn test_format_repo_name_exact_length() { - let result = format_repo_name("exactly-twenty-four-chr", 24); - assert_eq!(result.len(), 26); + fn test_format_column_exact_length() { + let result = format_column("exactly-twenty-four-chr!", 24); + assert_eq!(result, "exactly-twenty-four-chr!"); + assert_eq!(result.len(), 24); } #[test] - fn test_format_repo_name_truncated() { - let result = format_repo_name("this-is-a-very-long-repository-name", 24); - assert_eq!(result, "[this-is-a-very-long--...]"); - assert_eq!(result.len(), 26); + fn test_format_column_truncated() { + let result = format_column("this-is-a-very-long-repository-name", 24); + assert_eq!(result, "this-is-a-very-long-r..."); + assert_eq!(result.len(), 24); } #[test] diff --git a/script/test b/script/test index 437b4d9..a901461 100755 --- a/script/test +++ b/script/test @@ -13,13 +13,14 @@ Usage: test [options] Options: -h, --help Show this help message - -t, --type TYPE Run tests for specific type only (rust, zig, crystal) + -t, --type TYPE Run tests for specific type only (rust, zig, crystal, spec) -v, --verbose Show verbose test output Examples: test Run all implementation tests test -t rust Run only Rust tests test --type zig Run only Zig tests + test -t spec Run integration specs (Ruby/RSpec) test -v Run all tests with verbose output EOF } @@ -78,6 +79,24 @@ run_tests() { return $status } +run_spec_tests() { + echo "=== Running integration specs ===" + echo + local rspec_opts=() + if $VERBOSE; then + rspec_opts+=("--format" "documentation") + fi + (cd "$GIT_ALL_ROOT" && bundle exec rspec "${rspec_opts[@]}") + local status=$? + echo + if [[ $status -eq 0 ]]; then + echo "✓ integration specs passed" + else + echo "✗ integration specs failed" + fi + return $status +} + main() { while [[ $# -gt 0 ]]; do case "$1" in @@ -108,6 +127,10 @@ main() { # Validate type if specified if [[ -n "$TYPE" ]]; then + if [[ "$TYPE" == "spec" ]]; then + run_spec_tests + exit $? + fi local valid=false for impl in $(discover_impl_names); do if [[ "$impl" == "$TYPE" ]]; then @@ -117,7 +140,7 @@ main() { done if ! $valid; then echo "Unknown type: $TYPE" - echo "Available: $(discover_impl_names | tr '\n' ' ')" + echo "Available: $(discover_impl_names | tr '\n' ' ') spec" exit 1 fi fi diff --git a/spec/output_format_spec.rb b/spec/output_format_spec.rb new file mode 100644 index 0000000..7273d98 --- /dev/null +++ b/spec/output_format_spec.rb @@ -0,0 +1,76 @@ +RSpec.describe "git-all output format" do + context "pipe-delimited format" do + it "outputs three columns separated by pipes" do + create_repo("repo-a") + create_repo("repo-b") + + result = run_git_all("status") + lines = result.stdout.lines.map(&:chomp).reject(&:empty?) + + lines.each do |line| + parts = line.split("|") + expect(parts.length).to eq(3), "Expected 3 columns in: #{line.inspect}" + end + end + end + + context "column alignment" do + it "has pipe characters at the same positions across all rows" do + create_repo("short") + create_repo("a-longer-repo-name") + create_repo("mid-length") + + result = run_git_all("status") + lines = result.stdout.lines.map(&:chomp).reject(&:empty?) + + first_pipe_positions = lines.map { |l| l.index("|") } + expect(first_pipe_positions.uniq.length).to eq(1), + "First pipe positions differ: #{first_pipe_positions}" + + second_pipe_positions = lines.map { |l| l.index("|", l.index("|") + 1) } + expect(second_pipe_positions.uniq.length).to eq(1), + "Second pipe positions differ: #{second_pipe_positions}" + end + end + + context "alphabetical ordering" do + it "sorts repos alphabetically by name" do + create_repo("zeta-repo") + create_repo("alpha-repo") + create_repo("mid-repo") + + result = run_git_all("status") + rows = parse_output(result.stdout) + repo_names = rows.map(&:repo) + + expect(repo_names).to eq(repo_names.sort) + expect(repo_names.first).to eq("alpha-repo") + expect(repo_names.last).to eq("zeta-repo") + end + end + + context "truncation" do + it "truncates long repo names with trailing ellipsis" do + long_name = "this-is-an-extremely-long-repository-name-that-should-be-truncated" + create_repo(long_name) + + result = run_git_all("status") + rows = parse_output(result.stdout) + + row = rows.first + expect(row.repo).to end_with("...") + expect(row.repo.length).to be < long_name.length + end + end + + context "no repositories found" do + it "prints a message and exits with code 9" do + Dir.mktmpdir("empty-workspace-") do |empty_dir| + result = run_git_all("status", dir: empty_dir) + + expect(result.stdout).to include("No git repositories found") + expect(result.exit_code).to eq(9) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..9f24f43 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,17 @@ +require "tmpdir" +require "fileutils" + +Dir[File.join(__dir__, "support", "**", "*.rb")].each { |f| require f } + +RSpec.configure do |config| + config.include GitAllRunner + config.include RepoBuilder + config.include OutputParser + + config.around(:each) do |example| + Dir.mktmpdir("git-all-test-") do |workspace| + @workspace = workspace + example.run + end + end +end diff --git a/spec/status_spec.rb b/spec/status_spec.rb new file mode 100644 index 0000000..447a329 --- /dev/null +++ b/spec/status_spec.rb @@ -0,0 +1,335 @@ +RSpec.describe "git-all status" do + context "clean repositories" do + it "shows clean for a repo with no changes and no tracking branch" do + create_repo("my-repo") + + result = run_git_all("status") + rows = parse_output(result.stdout) + + row = find_repo(rows, "my-repo") + expect(row).not_to be_nil + expect(row.branch).to eq("main") + expect(row.message).to eq("clean") + end + + it "shows clean for a repo with a tracking branch" do + upstream = Dir.mktmpdir("upstream-") + begin + create_upstream_repo(upstream) + repo = create_tracking_repo("tracked-repo", upstream) + + result = run_git_all("status") + rows = parse_output(result.stdout) + + row = find_repo(rows, "tracked-repo") + expect(row).not_to be_nil + expect(row.branch).to eq("main") + expect(row.message).to eq("clean") + ensure + FileUtils.rm_rf(upstream) + end + end + + it "shows HEAD (detached) for detached HEAD" do + repo = create_repo("detached-repo") + detach_head(repo) + + result = run_git_all("status") + rows = parse_output(result.stdout) + + row = find_repo(rows, "detached-repo") + expect(row).not_to be_nil + expect(row.branch).to eq("HEAD (detached)") + expect(row.message).to eq("clean") + end + end + + context "unstaged modifications" do + it "shows 1 modified for one unstaged modification" do + repo = create_repo("mod-repo") + add_modified_file(repo) + + result = run_git_all("status") + rows = parse_output(result.stdout) + + row = find_repo(rows, "mod-repo") + expect(row.branch).to eq("main") + expect(row.message).to eq("1 modified") + end + + it "shows 3 modified for multiple unstaged modifications" do + repo = create_repo("multi-mod-repo") + add_modified_file(repo, "a.txt") + add_modified_file(repo, "b.txt") + add_modified_file(repo, "c.txt") + + result = run_git_all("status") + rows = parse_output(result.stdout) + + row = find_repo(rows, "multi-mod-repo") + expect(row.message).to eq("3 modified") + end + end + + context "staged modifications" do + it "shows 1 modified for one staged modification" do + repo = create_repo("staged-mod-repo") + add_staged_modification(repo) + + result = run_git_all("status") + rows = parse_output(result.stdout) + + row = find_repo(rows, "staged-mod-repo") + expect(row.message).to eq("1 modified") + end + + it "counts staged + unstaged mod on same file once (MM)" do + repo = create_repo("mm-repo") + filepath = File.join(repo, "both.txt") + File.write(filepath, "original\n") + git_in(repo, "add", "both.txt") + git_in(repo, "commit", "-m", "Add both.txt") + File.write(filepath, "staged change\n") + git_in(repo, "add", "both.txt") + File.write(filepath, "unstaged change on top\n") + + result = run_git_all("status") + rows = parse_output(result.stdout) + + row = find_repo(rows, "mm-repo") + expect(row.message).to eq("1 modified") + end + end + + context "staged new files" do + it "shows 1 added for one staged new file" do + repo = create_repo("added-repo") + add_staged_new_file(repo) + + result = run_git_all("status") + rows = parse_output(result.stdout) + + row = find_repo(rows, "added-repo") + expect(row.message).to eq("1 added") + end + + it "counts staged add with worktree mod as added (AM)" do + repo = create_repo("am-repo") + filepath = File.join(repo, "am-file.txt") + File.write(filepath, "new file\n") + git_in(repo, "add", "am-file.txt") + File.write(filepath, "modified after staging\n") + + result = run_git_all("status") + rows = parse_output(result.stdout) + + row = find_repo(rows, "am-repo") + expect(row.message).to eq("1 added") + end + end + + context "deletions" do + it "shows 1 deleted for one staged deletion" do + repo = create_repo("staged-del-repo") + stage_deletion(repo) + + result = run_git_all("status") + rows = parse_output(result.stdout) + + row = find_repo(rows, "staged-del-repo") + expect(row.message).to eq("1 deleted") + end + + it "shows 1 deleted for one unstaged deletion" do + repo = create_repo("unstaged-del-repo") + unstaged_deletion(repo) + + result = run_git_all("status") + rows = parse_output(result.stdout) + + row = find_repo(rows, "unstaged-del-repo") + expect(row.message).to eq("1 deleted") + end + end + + context "renames" do + it "shows 1 renamed for a rename" do + repo = create_repo("rename-repo") + stage_rename(repo) + + result = run_git_all("status") + rows = parse_output(result.stdout) + + row = find_repo(rows, "rename-repo") + expect(row.message).to eq("1 renamed") + end + end + + context "untracked files" do + it "shows 1 untracked for one untracked file" do + repo = create_repo("untracked-repo") + add_untracked_file(repo) + + result = run_git_all("status") + rows = parse_output(result.stdout) + + row = find_repo(rows, "untracked-repo") + expect(row.message).to eq("1 untracked") + end + + it "shows 2 untracked for multiple untracked files" do + repo = create_repo("multi-untracked-repo") + add_untracked_file(repo, "a.txt") + add_untracked_file(repo, "b.txt") + + result = run_git_all("status") + rows = parse_output(result.stdout) + + row = find_repo(rows, "multi-untracked-repo") + expect(row.message).to eq("2 untracked") + end + end + + context "mixed changes" do + it "shows modified + untracked" do + repo = create_repo("mixed-repo") + add_modified_file(repo, "mod.txt") + add_untracked_file(repo, "new.txt") + + result = run_git_all("status") + rows = parse_output(result.stdout) + + row = find_repo(rows, "mixed-repo") + expect(row.message).to eq("1 modified, 1 untracked") + end + + it "shows all types present in correct order" do + repo = create_repo("all-types-repo") + + # Commit all files we'll modify/delete/rename in a single commit + File.write(File.join(repo, "mod.txt"), "original\n") + File.write(File.join(repo, "del.txt"), "will be deleted\n") + File.write(File.join(repo, "old.txt"), "will be renamed\n") + git_in(repo, "add", "mod.txt", "del.txt", "old.txt") + git_in(repo, "commit", "-m", "Add files") + + # Now create all states at once (no intermediate commits) + File.write(File.join(repo, "mod.txt"), "modified\n") + git_in(repo, "rm", "del.txt") + git_in(repo, "mv", "old.txt", "new.txt") + File.write(File.join(repo, "add.txt"), "new file\n") + git_in(repo, "add", "add.txt") + File.write(File.join(repo, "untracked.txt"), "untracked\n") + + result = run_git_all("status") + rows = parse_output(result.stdout) + + row = find_repo(rows, "all-types-repo") + expect(row.message).to eq("1 modified, 1 added, 1 deleted, 1 renamed, 1 untracked") + end + end + + context "ahead/behind tracking" do + it "shows clean, 2 ahead when ahead of remote" do + upstream = Dir.mktmpdir("upstream-") + begin + create_upstream_repo(upstream) + repo = create_tracking_repo("ahead-repo", upstream) + make_ahead(repo, 2) + + result = run_git_all("status") + rows = parse_output(result.stdout) + + row = find_repo(rows, "ahead-repo") + expect(row.message).to eq("clean, 2 ahead") + ensure + FileUtils.rm_rf(upstream) + end + end + + it "shows clean, 3 behind when behind remote" do + upstream = Dir.mktmpdir("upstream-") + begin + create_upstream_repo(upstream) + repo = create_tracking_repo("behind-repo", upstream) + make_behind(upstream, repo, 3) + + result = run_git_all("status") + rows = parse_output(result.stdout) + + row = find_repo(rows, "behind-repo") + expect(row.message).to eq("clean, 3 behind") + ensure + FileUtils.rm_rf(upstream) + end + end + + it "shows clean, 2 ahead, 3 behind when diverged" do + upstream = Dir.mktmpdir("upstream-") + begin + create_upstream_repo(upstream) + repo = create_tracking_repo("diverged-repo", upstream) + make_ahead(repo, 2) + make_behind(upstream, repo, 3) + + result = run_git_all("status") + rows = parse_output(result.stdout) + + row = find_repo(rows, "diverged-repo") + expect(row.message).to eq("clean, 2 ahead, 3 behind") + ensure + FileUtils.rm_rf(upstream) + end + end + + it "shows 1 modified, 1 ahead when modified and ahead" do + upstream = Dir.mktmpdir("upstream-") + begin + create_upstream_repo(upstream) + repo = create_tracking_repo("mod-ahead-repo", upstream) + make_ahead(repo, 1) + # Modify an existing file (no new commit) to avoid incrementing ahead count + File.write(File.join(repo, "README.md"), "modified\n") + + result = run_git_all("status") + rows = parse_output(result.stdout) + + row = find_repo(rows, "mod-ahead-repo") + expect(row.message).to eq("1 modified, 1 ahead") + ensure + FileUtils.rm_rf(upstream) + end + end + + it "shows mixed changes + diverged" do + upstream = Dir.mktmpdir("upstream-") + begin + create_upstream_repo(upstream) + repo = create_tracking_repo("mixed-diverged-repo", upstream) + make_ahead(repo, 2) + make_behind(upstream, repo, 1) + # Modify an existing file (no new commit) to avoid incrementing ahead count + File.write(File.join(repo, "README.md"), "modified\n") + add_untracked_file(repo, "b.txt") + + result = run_git_all("status") + rows = parse_output(result.stdout) + + row = find_repo(rows, "mixed-diverged-repo") + expect(row.message).to eq("1 modified, 1 untracked, 2 ahead, 1 behind") + ensure + FileUtils.rm_rf(upstream) + end + end + end + + private + + def git_in(repo, *args) + stdout, stderr, status = Open3.capture3("git", "-C", repo, *args) + unless status.success? + raise "git -C #{repo} #{args.join(" ")} failed:\nstdout: #{stdout}\nstderr: #{stderr}" + end + stdout + end +end diff --git a/spec/support/git_all_runner.rb b/spec/support/git_all_runner.rb new file mode 100644 index 0000000..3d7da19 --- /dev/null +++ b/spec/support/git_all_runner.rb @@ -0,0 +1,16 @@ +require "open3" + +module GitAllRunner + RunResult = Struct.new(:stdout, :stderr, :status, :exit_code, keyword_init: true) + + def git_all_bin + ENV.fetch("GIT_ALL_BIN") { + File.expand_path("../../bin/git-all-rust", __dir__) + } + end + + def run_git_all(*args, dir: @workspace) + stdout, stderr, status = Open3.capture3(git_all_bin, *args, chdir: dir) + RunResult.new(stdout: stdout, stderr: stderr, status: status, exit_code: status.exitstatus) + end +end diff --git a/spec/support/output_parser.rb b/spec/support/output_parser.rb new file mode 100644 index 0000000..e209b0d --- /dev/null +++ b/spec/support/output_parser.rb @@ -0,0 +1,22 @@ +module OutputParser + OutputRow = Struct.new(:repo, :branch, :message, keyword_init: true) + + def parse_output_line(line) + parts = line.split("|").map(&:strip) + return nil unless parts.length == 3 + + OutputRow.new( + repo: parts[0], + branch: parts[1], + message: parts[2] + ) + end + + def parse_output(stdout) + stdout.lines.filter_map { |line| parse_output_line(line) } + end + + def find_repo(rows, name) + rows.find { |r| r.repo == name || r.repo.start_with?(name) } + end +end diff --git a/spec/support/repo_builder.rb b/spec/support/repo_builder.rb new file mode 100644 index 0000000..68ff21e --- /dev/null +++ b/spec/support/repo_builder.rb @@ -0,0 +1,128 @@ +require "open3" + +module RepoBuilder + def create_repo(name, branch: "main") + path = File.join(@workspace, name) + FileUtils.mkdir_p(path) + git(path, "init", "-b", branch) + git(path, "config", "user.email", "test@example.com") + git(path, "config", "user.name", "Test User") + # Create an initial commit so HEAD exists + File.write(File.join(path, "README.md"), "# #{name}\n") + git(path, "add", "README.md") + git(path, "commit", "-m", "Initial commit") + path + end + + # Create a regular repo suitable for use as an upstream/remote. + # Returns the path. The caller is responsible for cleanup. + def create_upstream_repo(tmpdir_path) + git_raw("init", "-b", "main", tmpdir_path) + git(tmpdir_path, "config", "user.email", "test@example.com") + git(tmpdir_path, "config", "user.name", "Test User") + File.write(File.join(tmpdir_path, "README.md"), "# upstream\n") + git(tmpdir_path, "add", "README.md") + git(tmpdir_path, "commit", "-m", "Initial commit") + tmpdir_path + end + + def create_tracking_repo(name, upstream_path) + path = File.join(@workspace, name) + git_raw("clone", upstream_path, path) + git(path, "config", "user.email", "test@example.com") + git(path, "config", "user.name", "Test User") + path + end + + def add_modified_file(repo, filename = "modified.txt") + filepath = File.join(repo, filename) + File.write(filepath, "original content\n") + git(repo, "add", filename) + git(repo, "commit", "-m", "Add #{filename}") + File.write(filepath, "modified content\n") + end + + def add_staged_modification(repo, filename = "staged.txt") + filepath = File.join(repo, filename) + File.write(filepath, "original content\n") + git(repo, "add", filename) + git(repo, "commit", "-m", "Add #{filename}") + File.write(filepath, "staged modification\n") + git(repo, "add", filename) + end + + def add_untracked_file(repo, filename = "untracked.txt") + File.write(File.join(repo, filename), "untracked content\n") + end + + def add_staged_new_file(repo, filename = "new-file.txt") + File.write(File.join(repo, filename), "new file content\n") + git(repo, "add", filename) + end + + def stage_deletion(repo, filename = "to-delete.txt") + filepath = File.join(repo, filename) + File.write(filepath, "will be deleted\n") + git(repo, "add", filename) + git(repo, "commit", "-m", "Add #{filename}") + git(repo, "rm", filename) + end + + def unstaged_deletion(repo, filename = "will-vanish.txt") + filepath = File.join(repo, filename) + File.write(filepath, "will vanish\n") + git(repo, "add", filename) + git(repo, "commit", "-m", "Add #{filename}") + FileUtils.rm(filepath) + end + + def stage_rename(repo, old_name = "old-name.txt", new_name = "new-name.txt") + filepath = File.join(repo, old_name) + File.write(filepath, "will be renamed\n") + git(repo, "add", old_name) + git(repo, "commit", "-m", "Add #{old_name}") + git(repo, "mv", old_name, new_name) + end + + def detach_head(repo) + sha = git(repo, "rev-parse", "HEAD").strip + git(repo, "checkout", sha) + end + + def make_ahead(repo, count) + count.times do |i| + File.write(File.join(repo, "ahead-#{i}.txt"), "ahead #{i}\n") + git(repo, "add", "ahead-#{i}.txt") + git(repo, "commit", "-m", "Ahead commit #{i}") + end + end + + # Create commits in the upstream that the clone doesn't have yet, + # then fetch in the clone so it sees the behind state. + def make_behind(upstream, clone, count) + count.times do |i| + File.write(File.join(upstream, "behind-#{i}.txt"), "behind #{i}\n") + git(upstream, "add", "behind-#{i}.txt") + git(upstream, "commit", "-m", "Behind commit #{i}") + end + git(clone, "fetch") + end + + private + + def git(repo, *args) + stdout, stderr, status = Open3.capture3("git", "-C", repo, *args) + unless status.success? + raise "git -C #{repo} #{args.join(" ")} failed:\nstdout: #{stdout}\nstderr: #{stderr}" + end + stdout + end + + def git_raw(*args) + stdout, stderr, status = Open3.capture3("git", *args) + unless status.success? + raise "git #{args.join(" ")} failed:\nstdout: #{stdout}\nstderr: #{stderr}" + end + stdout + end +end From 6af18cac17e69a062e333de0c20f23cd2cf20962 Mon Sep 17 00:00:00 2001 From: Rob Sanheim Date: Tue, 10 Feb 2026 07:15:29 -0600 Subject: [PATCH 2/7] Fix passthrough output and add spec --- rust/src/commands/passthrough.rs | 45 +----------------- rust/src/runner.rs | 81 ++++++++++++++++++++++++++++++++ spec/passthrough_spec.rb | 43 +++++++++++++++++ 3 files changed, 126 insertions(+), 43 deletions(-) create mode 100644 spec/passthrough_spec.rb diff --git a/rust/src/commands/passthrough.rs b/rust/src/commands/passthrough.rs index dd50ca6..a7c1c34 100644 --- a/rust/src/commands/passthrough.rs +++ b/rust/src/commands/passthrough.rs @@ -1,53 +1,12 @@ use anyhow::Result; use std::path::PathBuf; -use std::process::Output; -use crate::runner::{run_parallel, ExecutionContext, FormattedResult, GitCommand, OutputFormatter}; - -struct PassthroughFormatter; - -impl OutputFormatter for PassthroughFormatter { - fn format(&self, output: &Output) -> FormattedResult { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - - if !output.status.success() { - let error_line = stderr - .lines() - .find(|l| !l.trim().is_empty()) - .unwrap_or("unknown error"); - return FormattedResult { - branch: String::new(), - message: format!("ERROR: {}", error_line), - }; - } - - let message = stdout - .lines() - .chain(stderr.lines()) - .find(|l| !l.trim().is_empty()) - .unwrap_or("ok") - .trim() - .to_string(); - - FormattedResult { - branch: String::new(), - message, - } - } -} +use crate::runner::{run_passthrough, ExecutionContext, GitCommand}; pub fn run(ctx: &ExecutionContext, repos: &[PathBuf], args: &[String]) -> Result<()> { if args.is_empty() { anyhow::bail!("No git command specified"); } - let formatter = PassthroughFormatter; - - run_parallel( - ctx, - repos, - |repo| GitCommand::new(repo.clone(), args.to_vec()), - &formatter, - ) + run_passthrough(ctx, repos, |repo| GitCommand::new(repo.clone(), args.to_vec())) } diff --git a/rust/src/runner.rs b/rust/src/runner.rs index c565878..106aadb 100644 --- a/rust/src/runner.rs +++ b/rust/src/runner.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; use std::sync::mpsc; @@ -357,6 +358,86 @@ where Ok(()) } +/// Run passthrough commands across all repos, preserving git stdout/stderr output. +pub fn run_passthrough( + ctx: &ExecutionContext, + repos: &[PathBuf], + build_command: F, +) -> Result<()> +where + F: Fn(&PathBuf) -> GitCommand + Sync, +{ + let url_scheme = ctx.url_scheme(); + + if ctx.is_dry_run() { + for repo in repos { + let cmd = build_command(repo); + println!("{}", cmd.command_string_with_scheme(url_scheme)); + } + return Ok(()); + } + + let max_workers = ctx.max_connections(); + let semaphore = if max_workers > 0 && max_workers < repos.len() { + Some(Arc::new(Semaphore::new(max_workers))) + } else { + None + }; + + let (tx, rx) = mpsc::channel(); + + std::thread::scope(|s| { + for (idx, repo) in repos.iter().enumerate() { + let tx = tx.clone(); + let cmd = build_command(repo); + let repo = repo.clone(); + let sem = semaphore.clone(); + + s.spawn(move || { + if let Some(ref sem) = sem { + sem.acquire(); + } + + let result = cmd.spawn(url_scheme).and_then(|c| c.wait_with_output()); + + if let Some(ref sem) = sem { + sem.release(); + } + + let _ = tx.send((idx, repo, result)); + }); + } + drop(tx); + + let mut results: Vec)>> = + (0..repos.len()).map(|_| None).collect(); + + for (idx, repo, result) in rx { + results[idx] = Some((repo, result)); + } + + for item in results { + let (repo, result) = item.unwrap(); + match result { + Ok(output) => { + let _ = std::io::stdout().write_all(&output.stdout); + let _ = std::io::stderr().write_all(&output.stderr); + } + Err(err) => { + let _ = writeln!( + std::io::stderr(), + "git-all: failed to run git in {}: {}", + repo_display_name(&repo, ctx.display_root()), + err + ); + } + } + } + }); + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/spec/passthrough_spec.rb b/spec/passthrough_spec.rb new file mode 100644 index 0000000..ee804d4 --- /dev/null +++ b/spec/passthrough_spec.rb @@ -0,0 +1,43 @@ +require "open3" + +RSpec.describe "git-all passthrough" do + it "preserves full git output for passthrough commands" do + repo_a = create_repo("alpha") + repo_b = create_repo("beta") + + File.write(File.join(repo_a, "alpha.txt"), "alpha\n") + git_in(repo_a, "add", "alpha.txt") + git_in(repo_a, "commit", "-m", "alpha-commit") + + File.write(File.join(repo_b, "beta.txt"), "beta\n") + git_in(repo_b, "add", "beta.txt") + git_in(repo_b, "commit", "-m", "beta-commit") + + expected_stdout = +"" + expected_stderr = +"" + + ["alpha", "beta"].each do |name| + repo = File.join(@workspace, name) + stdout, stderr, status = Open3.capture3("git", "-C", repo, "log", "--oneline", "-1") + unless status.success? + raise "git -C #{repo} log --oneline -1 failed:\nstdout: #{stdout}\nstderr: #{stderr}" + end + expected_stdout << stdout + expected_stderr << stderr + end + + result = run_git_all("log", "--oneline", "-1") + expect(result.stdout).to eq(expected_stdout) + expect(result.stderr).to eq(expected_stderr) + end + + private + + def git_in(repo, *args) + stdout, stderr, status = Open3.capture3("git", "-C", repo, *args) + unless status.success? + raise "git -C #{repo} #{args.join(" ")} failed:\nstdout: #{stdout}\nstderr: #{stderr}" + end + stdout + end +end From 469158620ef2afa2927c6effa9c9b440f86842b8 Mon Sep 17 00:00:00 2001 From: Rob Sanheim Date: Tue, 10 Feb 2026 12:12:04 -0600 Subject: [PATCH 3/7] DRY up tests and add FormattedResult::message_only * Make RepoBuilder#git public, remove duplicated git_in from specs * Extract with_tracking_repo helper for upstream/clone boilerplate * Add FormattedResult::message_only constructor for fetch/pull/status --- rust/src/commands/fetch.rs | 22 +++-------- rust/src/commands/pull.rs | 27 ++++---------- rust/src/commands/status.rs | 7 ++-- rust/src/runner.rs | 11 ++++++ spec/passthrough_spec.rb | 19 ++-------- spec/status_spec.rb | 72 ++++++++---------------------------- spec/support/repo_builder.rb | 13 ++++++- 7 files changed, 57 insertions(+), 114 deletions(-) diff --git a/rust/src/commands/fetch.rs b/rust/src/commands/fetch.rs index 2cbe24d..f0fc374 100644 --- a/rust/src/commands/fetch.rs +++ b/rust/src/commands/fetch.rs @@ -12,20 +12,16 @@ impl OutputFormatter for FetchFormatter { let stderr = String::from_utf8_lossy(&output.stderr); if !output.status.success() { - return FormattedResult { - branch: String::new(), - message: stderr.lines().next().unwrap_or("unknown error").to_string(), - }; + return FormattedResult::message_only( + stderr.lines().next().unwrap_or("unknown error").to_string(), + ); } let has_output = stdout.lines().any(|l| !l.trim().is_empty()) || stderr.lines().any(|l| !l.trim().is_empty() && !l.starts_with("From")); if !has_output { - return FormattedResult { - branch: String::new(), - message: "no new commits".to_string(), - }; + return FormattedResult::message_only("no new commits".to_string()); } let (branch_count, tag_count) = stdout @@ -43,16 +39,10 @@ impl OutputFormatter for FetchFormatter { if tag_count > 0 { parts.push(format!("{} tag{}", tag_count, if tag_count == 1 { "" } else { "s" })); } - return FormattedResult { - branch: String::new(), - message: format!("{} updated", parts.join(", ")), - }; + return FormattedResult::message_only(format!("{} updated", parts.join(", "))); } - FormattedResult { - branch: String::new(), - message: "fetched".to_string(), - } + FormattedResult::message_only("fetched".to_string()) } } diff --git a/rust/src/commands/pull.rs b/rust/src/commands/pull.rs index 4883f29..99e0cca 100644 --- a/rust/src/commands/pull.rs +++ b/rust/src/commands/pull.rs @@ -12,26 +12,19 @@ impl OutputFormatter for PullFormatter { let stderr = String::from_utf8_lossy(&output.stderr); if !output.status.success() { - return FormattedResult { - branch: String::new(), - message: stderr.lines().next().unwrap_or("unknown error").to_string(), - }; + return FormattedResult::message_only( + stderr.lines().next().unwrap_or("unknown error").to_string(), + ); } // Check for "Already up to date" if stdout.contains("Already up to date") { - return FormattedResult { - branch: String::new(), - message: "Already up to date".to_string(), - }; + return FormattedResult::message_only("Already up to date".to_string()); } // Try to extract summary from stdout (e.g., "3 files changed, 10 insertions(+), 5 deletions(-)") if let Some(summary_line) = stdout.lines().find(|l| l.contains("files changed")) { - return FormattedResult { - branch: String::new(), - message: summary_line.trim().to_string(), - }; + return FormattedResult::message_only(summary_line.trim().to_string()); } // Check for fast-forward or merge info in stdout @@ -39,10 +32,7 @@ impl OutputFormatter for PullFormatter { .lines() .find(|l| l.contains("..") || l.contains("Updating")) { - return FormattedResult { - branch: String::new(), - message: line.trim().to_string(), - }; + return FormattedResult::message_only(line.trim().to_string()); } // Fallback: first non-empty line of stdout, or stderr @@ -54,10 +44,7 @@ impl OutputFormatter for PullFormatter { .trim() .to_string(); - FormattedResult { - branch: String::new(), - message, - } + FormattedResult::message_only(message) } } diff --git a/rust/src/commands/status.rs b/rust/src/commands/status.rs index 062267b..58714f5 100644 --- a/rust/src/commands/status.rs +++ b/rust/src/commands/status.rs @@ -61,10 +61,9 @@ impl OutputFormatter for StatusFormatter { let stderr = String::from_utf8_lossy(&output.stderr); if !output.status.success() { - return FormattedResult { - branch: String::new(), - message: stderr.lines().next().unwrap_or("unknown error").to_string(), - }; + return FormattedResult::message_only( + stderr.lines().next().unwrap_or("unknown error").to_string(), + ); } let mut branch = String::new(); diff --git a/rust/src/runner.rs b/rust/src/runner.rs index 106aadb..9f52712 100644 --- a/rust/src/runner.rs +++ b/rust/src/runner.rs @@ -245,6 +245,17 @@ pub struct FormattedResult { pub message: String, } +impl FormattedResult { + /// Create a result with only a message, leaving branch empty. + /// Used by formatters (fetch, pull) that don't extract branch info from output. + pub fn message_only(message: String) -> Self { + Self { + branch: String::new(), + message, + } + } +} + /// Trait for formatting command output into a structured result pub trait OutputFormatter: Sync { fn format(&self, output: &Output) -> FormattedResult; diff --git a/spec/passthrough_spec.rb b/spec/passthrough_spec.rb index ee804d4..11d328d 100644 --- a/spec/passthrough_spec.rb +++ b/spec/passthrough_spec.rb @@ -1,17 +1,15 @@ -require "open3" - RSpec.describe "git-all passthrough" do it "preserves full git output for passthrough commands" do repo_a = create_repo("alpha") repo_b = create_repo("beta") File.write(File.join(repo_a, "alpha.txt"), "alpha\n") - git_in(repo_a, "add", "alpha.txt") - git_in(repo_a, "commit", "-m", "alpha-commit") + git(repo_a, "add", "alpha.txt") + git(repo_a, "commit", "-m", "alpha-commit") File.write(File.join(repo_b, "beta.txt"), "beta\n") - git_in(repo_b, "add", "beta.txt") - git_in(repo_b, "commit", "-m", "beta-commit") + git(repo_b, "add", "beta.txt") + git(repo_b, "commit", "-m", "beta-commit") expected_stdout = +"" expected_stderr = +"" @@ -31,13 +29,4 @@ expect(result.stderr).to eq(expected_stderr) end - private - - def git_in(repo, *args) - stdout, stderr, status = Open3.capture3("git", "-C", repo, *args) - unless status.success? - raise "git -C #{repo} #{args.join(" ")} failed:\nstdout: #{stdout}\nstderr: #{stderr}" - end - stdout - end end diff --git a/spec/status_spec.rb b/spec/status_spec.rb index 447a329..f06aeb7 100644 --- a/spec/status_spec.rb +++ b/spec/status_spec.rb @@ -13,11 +13,7 @@ end it "shows clean for a repo with a tracking branch" do - upstream = Dir.mktmpdir("upstream-") - begin - create_upstream_repo(upstream) - repo = create_tracking_repo("tracked-repo", upstream) - + with_tracking_repo("tracked-repo") do |_repo, _upstream| result = run_git_all("status") rows = parse_output(result.stdout) @@ -25,8 +21,6 @@ expect(row).not_to be_nil expect(row.branch).to eq("main") expect(row.message).to eq("clean") - ensure - FileUtils.rm_rf(upstream) end end @@ -87,10 +81,10 @@ repo = create_repo("mm-repo") filepath = File.join(repo, "both.txt") File.write(filepath, "original\n") - git_in(repo, "add", "both.txt") - git_in(repo, "commit", "-m", "Add both.txt") + git(repo, "add", "both.txt") + git(repo, "commit", "-m", "Add both.txt") File.write(filepath, "staged change\n") - git_in(repo, "add", "both.txt") + git(repo, "add", "both.txt") File.write(filepath, "unstaged change on top\n") result = run_git_all("status") @@ -117,7 +111,7 @@ repo = create_repo("am-repo") filepath = File.join(repo, "am-file.txt") File.write(filepath, "new file\n") - git_in(repo, "add", "am-file.txt") + git(repo, "add", "am-file.txt") File.write(filepath, "modified after staging\n") result = run_git_all("status") @@ -210,15 +204,15 @@ File.write(File.join(repo, "mod.txt"), "original\n") File.write(File.join(repo, "del.txt"), "will be deleted\n") File.write(File.join(repo, "old.txt"), "will be renamed\n") - git_in(repo, "add", "mod.txt", "del.txt", "old.txt") - git_in(repo, "commit", "-m", "Add files") + git(repo, "add", "mod.txt", "del.txt", "old.txt") + git(repo, "commit", "-m", "Add files") # Now create all states at once (no intermediate commits) File.write(File.join(repo, "mod.txt"), "modified\n") - git_in(repo, "rm", "del.txt") - git_in(repo, "mv", "old.txt", "new.txt") + git(repo, "rm", "del.txt") + git(repo, "mv", "old.txt", "new.txt") File.write(File.join(repo, "add.txt"), "new file\n") - git_in(repo, "add", "add.txt") + git(repo, "add", "add.txt") File.write(File.join(repo, "untracked.txt"), "untracked\n") result = run_git_all("status") @@ -231,10 +225,7 @@ context "ahead/behind tracking" do it "shows clean, 2 ahead when ahead of remote" do - upstream = Dir.mktmpdir("upstream-") - begin - create_upstream_repo(upstream) - repo = create_tracking_repo("ahead-repo", upstream) + with_tracking_repo("ahead-repo") do |repo, _upstream| make_ahead(repo, 2) result = run_git_all("status") @@ -242,16 +233,11 @@ row = find_repo(rows, "ahead-repo") expect(row.message).to eq("clean, 2 ahead") - ensure - FileUtils.rm_rf(upstream) end end it "shows clean, 3 behind when behind remote" do - upstream = Dir.mktmpdir("upstream-") - begin - create_upstream_repo(upstream) - repo = create_tracking_repo("behind-repo", upstream) + with_tracking_repo("behind-repo") do |repo, upstream| make_behind(upstream, repo, 3) result = run_git_all("status") @@ -259,16 +245,11 @@ row = find_repo(rows, "behind-repo") expect(row.message).to eq("clean, 3 behind") - ensure - FileUtils.rm_rf(upstream) end end it "shows clean, 2 ahead, 3 behind when diverged" do - upstream = Dir.mktmpdir("upstream-") - begin - create_upstream_repo(upstream) - repo = create_tracking_repo("diverged-repo", upstream) + with_tracking_repo("diverged-repo") do |repo, upstream| make_ahead(repo, 2) make_behind(upstream, repo, 3) @@ -277,18 +258,12 @@ row = find_repo(rows, "diverged-repo") expect(row.message).to eq("clean, 2 ahead, 3 behind") - ensure - FileUtils.rm_rf(upstream) end end it "shows 1 modified, 1 ahead when modified and ahead" do - upstream = Dir.mktmpdir("upstream-") - begin - create_upstream_repo(upstream) - repo = create_tracking_repo("mod-ahead-repo", upstream) + with_tracking_repo("mod-ahead-repo") do |repo, _upstream| make_ahead(repo, 1) - # Modify an existing file (no new commit) to avoid incrementing ahead count File.write(File.join(repo, "README.md"), "modified\n") result = run_git_all("status") @@ -296,19 +271,13 @@ row = find_repo(rows, "mod-ahead-repo") expect(row.message).to eq("1 modified, 1 ahead") - ensure - FileUtils.rm_rf(upstream) end end it "shows mixed changes + diverged" do - upstream = Dir.mktmpdir("upstream-") - begin - create_upstream_repo(upstream) - repo = create_tracking_repo("mixed-diverged-repo", upstream) + with_tracking_repo("mixed-diverged-repo") do |repo, upstream| make_ahead(repo, 2) make_behind(upstream, repo, 1) - # Modify an existing file (no new commit) to avoid incrementing ahead count File.write(File.join(repo, "README.md"), "modified\n") add_untracked_file(repo, "b.txt") @@ -317,19 +286,8 @@ row = find_repo(rows, "mixed-diverged-repo") expect(row.message).to eq("1 modified, 1 untracked, 2 ahead, 1 behind") - ensure - FileUtils.rm_rf(upstream) end end end - private - - def git_in(repo, *args) - stdout, stderr, status = Open3.capture3("git", "-C", repo, *args) - unless status.success? - raise "git -C #{repo} #{args.join(" ")} failed:\nstdout: #{stdout}\nstderr: #{stderr}" - end - stdout - end end diff --git a/spec/support/repo_builder.rb b/spec/support/repo_builder.rb index 68ff21e..48bee90 100644 --- a/spec/support/repo_builder.rb +++ b/spec/support/repo_builder.rb @@ -34,6 +34,17 @@ def create_tracking_repo(name, upstream_path) path end + def with_tracking_repo(name) + upstream = Dir.mktmpdir("upstream-") + begin + create_upstream_repo(upstream) + repo = create_tracking_repo(name, upstream) + yield repo, upstream + ensure + FileUtils.rm_rf(upstream) + end + end + def add_modified_file(repo, filename = "modified.txt") filepath = File.join(repo, filename) File.write(filepath, "original content\n") @@ -108,8 +119,6 @@ def make_behind(upstream, clone, count) git(clone, "fetch") end - private - def git(repo, *args) stdout, stderr, status = Open3.capture3("git", "-C", repo, *args) unless status.success? From dc5968d9dda6e5fbb3427e7e1d115e76aaa20214 Mon Sep 17 00:00:00 2001 From: Rob Sanheim Date: Wed, 11 Feb 2026 03:25:46 -0600 Subject: [PATCH 4/7] Resolve git aliases to optimized commands When an external subcommand matches a git alias (e.g. `st` -> `status`), route it to the optimized handler instead of falling through to passthrough. --- rust/src/main.rs | 64 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/rust/src/main.rs b/rust/src/main.rs index a533ecc..a4be371 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -136,7 +136,16 @@ fn main() -> Result<()> { Some(Commands::Pull { args }) => pull::run(&ctx, &repos, &args), Some(Commands::Fetch { args }) => fetch::run(&ctx, &repos, &args), Some(Commands::Status { args }) => status::run(&ctx, &repos, &args), - Some(Commands::External(args)) => passthrough::run(&ctx, &repos, &args), + Some(Commands::External(args)) => { + let resolved = + resolve_git_alias(&args[0]).and_then(|v| optimized_command_for(&v)); + match resolved { + Some("status") => status::run(&ctx, &repos, &args[1..]), + Some("pull") => pull::run(&ctx, &repos, &args[1..]), + Some("fetch") => fetch::run(&ctx, &repos, &args[1..]), + _ => passthrough::run(&ctx, &repos, &args), + } + } Some(Commands::Meta { .. }) => unreachable!(), // handled above None => { // No command given - show help @@ -145,3 +154,56 @@ fn main() -> Result<()> { } } } + +/// Ask git for the alias value of `name`. Returns `None` if the alias +/// doesn't exist, git isn't installed, or any other error occurs. +fn resolve_git_alias(name: &str) -> Option { + let output = Command::new("git") + .args(["config", "--get", &format!("alias.{name}")]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let value = String::from_utf8(output.stdout).ok()?; + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +/// If `alias_value` resolves to one of our optimized commands, return +/// the canonical command name. Only the first word is considered; shell +/// aliases (starting with `!`) are never matched. +fn optimized_command_for(alias_value: &str) -> Option<&'static str> { + let first_word = alias_value.split_whitespace().next()?; + match first_word { + "status" => Some("status"), + "pull" => Some("pull"), + "fetch" => Some("fetch"), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn optimized_command_for_resolves_aliases() { + let cases = [ + ("status", Some("status")), + ("status -sb", Some("status")), + ("pull --rebase", Some("pull")), + ("fetch --all", Some("fetch")), + ("!git log --oneline", None), + ("log --oneline", None), + ("", None), + ]; + for (input, expected) in cases { + assert_eq!(optimized_command_for(input), expected, "input: {input:?}"); + } + } +} From aca9ca2a3a38da246b66f2625870cd1c69f4e722 Mon Sep 17 00:00:00 2001 From: Rob Sanheim Date: Wed, 11 Feb 2026 03:26:19 -0600 Subject: [PATCH 5/7] Stream output progressively with head-of-line blocking Replace collect-then-print with streaming: as each result arrives, print all contiguous results from the front of the queue immediately. Use a fixed branch column width (14 chars) so output can stream without waiting for all results to compute column widths. --- rust/src/runner.rs | 94 ++++++++++++++++++++-------------------------- 1 file changed, 40 insertions(+), 54 deletions(-) diff --git a/rust/src/runner.rs b/rust/src/runner.rs index 9f52712..cd40614 100644 --- a/rust/src/runner.rs +++ b/rust/src/runner.rs @@ -41,8 +41,7 @@ impl Semaphore { const MIN_REPO_NAME_WIDTH: usize = 4; const MAX_REPO_NAME_WIDTH_CAP: usize = 48; -const MIN_BRANCH_WIDTH: usize = 4; -const MAX_BRANCH_WIDTH_CAP: usize = 24; +const MAX_BRANCH_WIDTH_CAP: usize = 14; /// URL scheme to force for git operations #[derive(Clone, Copy)] @@ -65,17 +64,6 @@ fn compute_name_width(repos: &[PathBuf], display_root: &Path) -> usize { capped.max(MIN_REPO_NAME_WIDTH) } -/// Compute the display width for the branch column -fn compute_branch_width(results: &[(String, String, String)]) -> usize { - let max_len = results - .iter() - .map(|(_, branch, _)| branch.len()) - .max() - .unwrap_or(0); - let capped = max_len.min(MAX_BRANCH_WIDTH_CAP); - capped.max(MIN_BRANCH_WIDTH) -} - /// Format a value into a fixed-width column: truncate with `...`, pad short values fn format_column(value: &str, width: usize) -> String { if value.len() > width { @@ -261,10 +249,11 @@ pub trait OutputFormatter: Sync { fn format(&self, output: &Output) -> FormattedResult; } -/// Run commands in parallel across all repos, collecting results for aligned output. +/// Run commands in parallel across all repos with streaming output. /// -/// All results are collected before printing to compute column widths. -/// Each worker thread also resolves the current branch via rev-parse. +/// Results are printed progressively using head-of-line blocking: as each result +/// arrives, all contiguous results from the front are printed immediately. +/// Uses a fixed branch column width so output can stream without waiting for all results. /// Output is printed in alphabetical order (repos are pre-sorted). /// /// Uses thread-per-process pattern with `wait_with_output()` which is deadlock-safe @@ -289,6 +278,7 @@ where } let name_width = compute_name_width(repos, ctx.display_root()); + let branch_width = MAX_BRANCH_WIDTH_CAP; let max_workers = ctx.max_connections(); @@ -324,22 +314,19 @@ where } drop(tx); - // Collect all results let mut results: Vec)>> = (0..repos.len()).map(|_| None).collect(); + let mut next_to_print = 0; for (idx, repo, branch, result) in rx { results[idx] = Some((repo, branch, result)); - } - // Format all results into (name, branch, message) tuples - let formatted: Vec<(String, String, String)> = results - .into_iter() - .map(|r| { - let (repo_path, pre_branch, output_result) = r.unwrap(); + while next_to_print < results.len() && results[next_to_print].is_some() { + let (repo_path, pre_branch, output_result) = + results[next_to_print].take().unwrap(); let name = repo_display_name(&repo_path, ctx.display_root()); - match output_result { + let (branch, message) = match output_result { Ok(ref output) => { let fr = formatter.format(output); let branch = if fr.branch.is_empty() { @@ -347,22 +334,19 @@ where } else { fr.branch }; - (name, branch, fr.message) + (branch, fr.message) } - Err(ref e) => (name, pre_branch, format!("ERROR: {e}")), - } - }) - .collect(); - - let branch_width = compute_branch_width(&formatted); - - for (name, branch, message) in &formatted { - println!( - "{} | {} | {}", - format_column(name, name_width), - format_column(branch, branch_width), - message - ); + Err(ref e) => (pre_branch, format!("ERROR: {e}")), + }; + + println!( + "{} | {} | {}", + format_column(&name, name_width), + format_column(&branch, branch_width), + message + ); + next_to_print += 1; + } } }); @@ -422,26 +406,28 @@ where let mut results: Vec)>> = (0..repos.len()).map(|_| None).collect(); + let mut next_to_print = 0; for (idx, repo, result) in rx { results[idx] = Some((repo, result)); - } - for item in results { - let (repo, result) = item.unwrap(); - match result { - Ok(output) => { - let _ = std::io::stdout().write_all(&output.stdout); - let _ = std::io::stderr().write_all(&output.stderr); - } - Err(err) => { - let _ = writeln!( - std::io::stderr(), - "git-all: failed to run git in {}: {}", - repo_display_name(&repo, ctx.display_root()), - err - ); + while next_to_print < results.len() && results[next_to_print].is_some() { + let (repo, result) = results[next_to_print].take().unwrap(); + match result { + Ok(output) => { + let _ = std::io::stdout().write_all(&output.stdout); + let _ = std::io::stderr().write_all(&output.stderr); + } + Err(err) => { + let _ = writeln!( + std::io::stderr(), + "git-all: failed to run git in {}: {}", + repo_display_name(&repo, ctx.display_root()), + err + ); + } } + next_to_print += 1; } } }); From 1546b0a709ab44e7778642d7f41d107f24f59e74 Mon Sep 17 00:00:00 2001 From: Rob Sanheim Date: Wed, 11 Feb 2026 03:26:24 -0600 Subject: [PATCH 6/7] Update spec to v0.2.3: fixed branch width, streaming output Fixed branch column at 14 chars and added streaming output via head-of-line blocking as RECOMMENDED behavior. --- docs/SPEC.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 82c9457..ed6096a 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1,6 +1,6 @@ # git-all Specification -Version: 0.2.2 +Version: 0.2.3 Status: Draft ## Abstract @@ -98,6 +98,8 @@ If `git-all meta` is not found, the implementation MUST continue with the next o 4. Output MUST be printed in a deterministic order (repository discovery order), regardless of process completion order. +5. Output SHOULD stream progressively as results become available, using head-of-line blocking: when a result arrives, all contiguous results from the front of the queue SHOULD be printed immediately rather than waiting for all results to complete. + ### 3.3 Error Handling 1. If any repository command fails, the implementation MUST continue processing remaining repositories. @@ -119,7 +121,7 @@ If `git-all meta` is not found, the implementation MUST continue with the next o 3. The output format MUST use three pipe-delimited columns: ` | | `. See Section 7.1 for full formatting rules. -4. Column widths SHOULD be computed from the actual values and consistent across all rows. See Section 7.1 for width and truncation rules. +4. Column widths MUST be consistent across all rows. See Section 7.1 for width and truncation rules. ### 4.2 status Command @@ -234,8 +236,9 @@ infra-services-dock... | develop | clean, 1 ahead Column rules: 1. Each column MUST be left-aligned and padded to a consistent width across all rows. -2. Column widths SHOULD be computed from the actual values in the current run, not a fixed size. -3. Repo and branch columns SHOULD have a maximum width cap to prevent overly wide output. Values exceeding the cap SHOULD be truncated with a trailing ellipsis (e.g. `...`). +2. The repo name column width SHOULD be computed from the actual values in the current run. +3. The branch column MUST use a fixed width of 14 characters. This enables streaming output without waiting for all results. +4. Repo and branch columns MUST have a maximum width cap to prevent overly wide output. Values exceeding the cap MUST be truncated with a trailing ellipsis (e.g. `...`). 4. When scan depth is greater than 1, the repo column MUST display paths relative to the current directory rather than just the leaf directory name. 5. When scan depth is the default (1), implementations MAY display paths instead of just the leaf name. 6. Detached HEAD state MUST be displayed as `HEAD (detached)` in the branch column. @@ -347,6 +350,11 @@ ARGS: ## Appendix C: Changelog +### v0.2.3 (2026-02-11) + +* Changed branch column to fixed width of 14 characters (Section 7.1.1) +* Added streaming output via head-of-line blocking as RECOMMENDED behavior (Section 3.2) + ### v0.2.2 (2026-02-10) * Changed optimized command output to three-column pipe-delimited format: `repo | branch | message` (Section 7.1.1) From 5c6394545594d264d79c073d64a5a7658ccddc84 Mon Sep 17 00:00:00 2001 From: Rob Sanheim Date: Wed, 11 Feb 2026 03:56:30 -0600 Subject: [PATCH 7/7] Fix branch column width to 16 to fit HEAD (detached) 14 chars truncated "HEAD (detached)" (15 chars). Bump to 16 so detached HEAD displays without truncation. --- docs/SPEC.md | 4 ++-- rust/src/runner.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index ed6096a..7929dfe 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -237,7 +237,7 @@ Column rules: 1. Each column MUST be left-aligned and padded to a consistent width across all rows. 2. The repo name column width SHOULD be computed from the actual values in the current run. -3. The branch column MUST use a fixed width of 14 characters. This enables streaming output without waiting for all results. +3. The branch column MUST use a fixed width of 16 characters. This enables streaming output without waiting for all results. 4. Repo and branch columns MUST have a maximum width cap to prevent overly wide output. Values exceeding the cap MUST be truncated with a trailing ellipsis (e.g. `...`). 4. When scan depth is greater than 1, the repo column MUST display paths relative to the current directory rather than just the leaf directory name. 5. When scan depth is the default (1), implementations MAY display paths instead of just the leaf name. @@ -352,7 +352,7 @@ ARGS: ### v0.2.3 (2026-02-11) -* Changed branch column to fixed width of 14 characters (Section 7.1.1) +* Changed branch column to fixed width of 16 characters (Section 7.1.1) * Added streaming output via head-of-line blocking as RECOMMENDED behavior (Section 3.2) ### v0.2.2 (2026-02-10) diff --git a/rust/src/runner.rs b/rust/src/runner.rs index cd40614..4392989 100644 --- a/rust/src/runner.rs +++ b/rust/src/runner.rs @@ -41,7 +41,7 @@ impl Semaphore { const MIN_REPO_NAME_WIDTH: usize = 4; const MAX_REPO_NAME_WIDTH_CAP: usize = 48; -const MAX_BRANCH_WIDTH_CAP: usize = 14; +const MAX_BRANCH_WIDTH_CAP: usize = 16; /// URL scheme to force for git operations #[derive(Clone, Copy)]