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/docs/SPEC.md b/docs/SPEC.md index 82c9457..7929dfe 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 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. 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 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) * Changed optimized command output to three-column pipe-delimited format: `repo | branch | message` (Section 7.1.1) 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..f0fc374 100644 --- a/rust/src/commands/fetch.rs +++ b/rust/src/commands/fetch.rs @@ -2,24 +2,26 @@ 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::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 "no new commits".to_string(); + return FormattedResult::message_only("no new commits".to_string()); } let (branch_count, tag_count) = stdout @@ -37,10 +39,10 @@ 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::message_only(format!("{} updated", parts.join(", "))); } - "fetched".to_string() + FormattedResult::message_only("fetched".to_string()) } } @@ -77,28 +79,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 +110,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 +125,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 +133,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..a7c1c34 100644 --- a/rust/src/commands/passthrough.rs +++ b/rust/src/commands/passthrough.rs @@ -1,45 +1,12 @@ use anyhow::Result; use std::path::PathBuf; -use std::process::Output; -use crate::runner::{run_parallel, ExecutionContext, GitCommand, OutputFormatter}; - -struct PassthroughFormatter; - -impl OutputFormatter for PassthroughFormatter { - fn format(&self, output: &Output) -> String { - 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 format!("ERROR: {}", error_line); - } - - stdout - .lines() - .chain(stderr.lines()) - .find(|l| !l.trim().is_empty()) - .unwrap_or("ok") - .trim() - .to_string() - } -} +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/commands/pull.rs b/rust/src/commands/pull.rs index d5b5052..99e0cca 100644 --- a/rust/src/commands/pull.rs +++ b/rust/src/commands/pull.rs @@ -2,27 +2,29 @@ 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::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 "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 summary_line.trim().to_string(); + return FormattedResult::message_only(summary_line.trim().to_string()); } // Check for fast-forward or merge info in stdout @@ -30,17 +32,19 @@ impl OutputFormatter for PullFormatter { .lines() .find(|l| l.contains("..") || l.contains("Updating")) { - return line.trim().to_string(); + return FormattedResult::message_only(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::message_only(message) } } diff --git a/rust/src/commands/status.rs b/rust/src/commands/status.rs index 26360a1..58714f5 100644 --- a/rust/src/commands/status.rs +++ b/rust/src/commands/status.rs @@ -2,19 +2,74 @@ 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::message_only( + 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 +77,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 +105,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 +115,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 +133,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 +161,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..a4be371 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 { @@ -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:?}"); + } + } +} diff --git a/rust/src/runner.rs b/rust/src/runner.rs index a7ebe72..4392989 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; @@ -40,6 +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 = 16; /// 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,43 @@ 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() +/// 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 +146,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 +192,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 +212,49 @@ 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, +} + +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) -> String; + fn format(&self, output: &Output) -> FormattedResult; } /// Run commands in parallel across all repos with streaming 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. +/// 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 /// (stdlib internally spawns threads to drain stdout/stderr concurrently). @@ -205,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(); @@ -214,9 +288,96 @@ 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| { + 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 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, branch, result)); + }); + } + drop(tx); + + 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)); + + 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()); + + let (branch, message) = match output_result { + Ok(ref output) => { + let fr = formatter.format(output); + let branch = if fr.branch.is_empty() { + pre_branch + } else { + fr.branch + }; + (branch, fr.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; + } + } + }); + + 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(); @@ -243,22 +404,30 @@ where } drop(tx); + 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)); - 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; + 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; } } }); @@ -266,47 +435,29 @@ where 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/passthrough_spec.rb b/spec/passthrough_spec.rb new file mode 100644 index 0000000..11d328d --- /dev/null +++ b/spec/passthrough_spec.rb @@ -0,0 +1,32 @@ +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(repo_a, "add", "alpha.txt") + git(repo_a, "commit", "-m", "alpha-commit") + + File.write(File.join(repo_b, "beta.txt"), "beta\n") + git(repo_b, "add", "beta.txt") + git(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 + +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..f06aeb7 --- /dev/null +++ b/spec/status_spec.rb @@ -0,0 +1,293 @@ +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 + with_tracking_repo("tracked-repo") do |_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") + 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(repo, "add", "both.txt") + git(repo, "commit", "-m", "Add both.txt") + File.write(filepath, "staged change\n") + git(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(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(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(repo, "rm", "del.txt") + git(repo, "mv", "old.txt", "new.txt") + File.write(File.join(repo, "add.txt"), "new file\n") + git(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 + with_tracking_repo("ahead-repo") do |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") + end + end + + it "shows clean, 3 behind when behind remote" do + with_tracking_repo("behind-repo") do |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") + end + end + + it "shows clean, 2 ahead, 3 behind when diverged" do + with_tracking_repo("diverged-repo") do |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") + end + end + + it "shows 1 modified, 1 ahead when modified and ahead" do + with_tracking_repo("mod-ahead-repo") do |repo, _upstream| + make_ahead(repo, 1) + 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") + end + end + + it "shows mixed changes + diverged" do + with_tracking_repo("mixed-diverged-repo") do |repo, upstream| + make_ahead(repo, 2) + make_behind(upstream, repo, 1) + 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") + end + end + 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..48bee90 --- /dev/null +++ b/spec/support/repo_builder.rb @@ -0,0 +1,137 @@ +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 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") + 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 + + 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