Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ crystal/lib/
*.swp
*~

# Ruby
vendor/bundle/

# macOS
.DS_Store

Expand Down
3 changes: 3 additions & 0 deletions .rspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
--format documentation
--color
--require spec_helper
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
source "https://rubygems.org"

gem "rspec", "~> 3.13"
35 changes: 35 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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
16 changes: 12 additions & 4 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# git-all Specification

Version: 0.2.2
Version: 0.2.3
Status: Draft

## Abstract
Expand Down Expand Up @@ -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.
Expand All @@ -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: `<repo> | <branch> | <message>`. 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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions mise.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
[tools]
ruby = "latest"
rust = "latest"
zig = "latest"
2 changes: 1 addition & 1 deletion rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 22 additions & 15 deletions rust/src/commands/fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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())
}
}

Expand Down Expand Up @@ -77,65 +79,70 @@ 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]
fn test_multiple_branch_updates() {
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]
fn test_multiple_tags() {
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]
fn test_mixed_branches_and_tags() {
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");
}
}
37 changes: 2 additions & 35 deletions rust/src/commands/passthrough.rs
Original file line number Diff line number Diff line change
@@ -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()))
}
20 changes: 12 additions & 8 deletions rust/src/commands/pull.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,45 +2,49 @@ 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
if let Some(line) = stdout
.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)
}
}

Expand Down
Loading