use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use owo_colors::OwoColorize;
use teravars::Engine;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
use crate::cli::ExecArgs;
use crate::commands::ShokaContext;
use crate::config::{ResolvedConfig, ShokaConfig};
use crate::state::{Repo, Shelf};
pub async fn run(ctx: &ShokaContext, args: ExecArgs) -> Result<()> {
if args.cmd.is_empty() {
bail!("no command — pass it after `--`, e.g. `shoka exec --tag rust -- cargo check`");
}
if args.filter.is_some() {
bail!(
"`--filter` requires the cache status snapshot which lands in Phase 2; \
for now use `--tag` to narrow the run, or filter externally with `shoka list`"
);
}
let cfg = ShokaConfig::load(&ctx.paths)?;
let resolved = cfg.resolve(ctx.profile_override.as_deref())?;
let shelf = Shelf::load(&ctx.paths)?;
if shelf.is_empty() {
bail!(
"shelf is empty — nothing to run against. `shoka clone <url>` or \
`shoka import <dir>` first"
);
}
let targets = resolve_targets(&shelf, &resolved, &args)?;
if targets.is_empty() {
bail!(
"no repos matched the tag filter ({} on the shelf total)",
shelf.len()
);
}
let semaphore = Arc::new(Semaphore::new(resolved.exec_concurrency));
let prog = Arc::new(args.cmd[0].clone());
let argv: Arc<Vec<String>> = Arc::new(args.cmd[1..].to_vec());
let total = targets.len();
println!(
"{} running `{}` across {} repo{} (concurrency {})",
"exec:".bold(),
format_cmdline(&args.cmd).cyan(),
total,
if total == 1 { "" } else { "s" },
resolved.exec_concurrency
);
let mut joinset: JoinSet<Outcome> = JoinSet::new();
for target in targets {
let sem = semaphore.clone();
let prog = prog.clone();
let argv = argv.clone();
joinset.spawn(async move {
let _permit = match sem.acquire_owned().await {
Ok(p) => p,
Err(_) => {
return Outcome::with_setup_error(target, "concurrency semaphore was closed");
}
};
run_one(target, &prog, &argv).await
});
}
let mut failures = 0usize;
while let Some(joined) = joinset.join_next().await {
let outcome = match joined {
Ok(o) => o,
Err(e) => {
eprintln!("{} exec task panicked: {e}", "!".red());
failures += 1;
continue;
}
};
if !outcome.is_success() {
failures += 1;
}
print_outcome(&outcome);
}
println!();
if failures == 0 {
println!("{} all {total} repo(s) succeeded", "exec:".bold().green());
Ok(())
} else {
bail!("{failures} of {total} repo(s) failed — see the per-repo blocks above")
}
}
fn resolve_targets(
shelf: &Shelf,
resolved: &ResolvedConfig,
args: &ExecArgs,
) -> Result<Vec<Target>> {
let mut engine = Engine::new();
let mut out = Vec::new();
for repo in &shelf.repos {
if !args.tags.is_empty() && !has_all_tags(repo, &args.tags) {
continue;
}
let path = resolved
.clone_path_for(repo, &mut engine)
.with_context(|| format!("resolving clone path for {}", repo.slug()))?;
out.push(Target {
slug: repo.slug(),
path,
});
}
Ok(out)
}
fn has_all_tags(repo: &Repo, wanted: &[String]) -> bool {
wanted.iter().all(|w| repo.tags.iter().any(|t| t == w))
}
async fn run_one(target: Target, prog: &str, argv: &[String]) -> Outcome {
if !target.path.is_dir() {
return Outcome::with_setup_error(
target,
"clone path does not exist (stale shelf entry — try `shoka prune`)",
);
}
let mut cmd = Command::new(prog);
cmd.args(argv)
.current_dir(&target.path)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => return Outcome::with_setup_error(target, &format!("spawn failed: {e}")),
};
let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr");
let combined = match interleave_pipes(stdout, stderr).await {
Ok(buf) => buf,
Err(e) => {
let _ = child.wait().await;
return Outcome::with_setup_error(target, &format!("reading pipes: {e}"));
}
};
let status = match child.wait().await {
Ok(s) => s,
Err(e) => return Outcome::with_setup_error(target, &format!("waiting on child: {e}")),
};
Outcome {
target,
exit_code: status.code(),
combined,
setup_error: None,
}
}
async fn interleave_pipes<R1, R2>(stdout: R1, stderr: R2) -> std::io::Result<Vec<u8>>
where
R1: tokio::io::AsyncRead + Unpin,
R2: tokio::io::AsyncRead + Unpin,
{
let mut out = BufReader::new(stdout);
let mut err = BufReader::new(stderr);
let mut out_buf = Vec::new();
let mut err_buf = Vec::new();
let mut out_done = false;
let mut err_done = false;
let mut combined = Vec::new();
loop {
if out_done && err_done {
return Ok(combined);
}
tokio::select! {
biased;
res = out.read_until(b'\n', &mut out_buf), if !out_done => match res? {
0 => out_done = true,
_ => {
combined.extend_from_slice(&out_buf);
out_buf.clear();
}
},
res = err.read_until(b'\n', &mut err_buf), if !err_done => match res? {
0 => err_done = true,
_ => {
combined.extend_from_slice(&err_buf);
err_buf.clear();
}
},
}
}
}
struct Outcome {
target: Target,
exit_code: Option<i32>,
combined: Vec<u8>,
setup_error: Option<String>,
}
impl Outcome {
fn with_setup_error(target: Target, msg: &str) -> Self {
Self {
target,
exit_code: None,
combined: Vec::new(),
setup_error: Some(msg.to_string()),
}
}
fn is_success(&self) -> bool {
self.setup_error.is_none() && self.exit_code == Some(0)
}
}
#[derive(Debug, Clone)]
struct Target {
slug: String,
path: PathBuf,
}
fn print_outcome(o: &Outcome) {
println!();
let status = match (&o.setup_error, o.exit_code) {
(Some(_), _) => "ERR".red().to_string(),
(None, Some(0)) => "ok".green().to_string(),
(None, Some(code)) => format!("exit {code}").red().to_string(),
(None, None) => "signal".red().to_string(),
};
println!("{} {} [{}]", "───".dimmed(), o.target.slug.bold(), status);
if let Some(err) = &o.setup_error {
println!(" {} {}", "!".red(), err);
return;
}
if !o.combined.is_empty() {
let s = String::from_utf8_lossy(&o.combined);
let trimmed = s.trim_end_matches('\n');
if !trimmed.is_empty() {
println!("{trimmed}");
}
}
}
fn format_cmdline(cmd: &[String]) -> String {
cmd.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::GlobalConfig;
use std::collections::BTreeMap;
fn make_resolved(root: &str) -> ResolvedConfig {
ShokaConfig {
global: GlobalConfig {
root: Some(root.into()),
layout: "{{ root }}/{{ name }}".into(),
exec_concurrency: 4,
..Default::default()
},
routes: vec![],
profiles: BTreeMap::new(),
}
.resolve(None)
.expect("resolve")
}
#[test]
fn resolve_targets_filters_by_tag() {
let resolved = make_resolved("/data");
let mut shelf = Shelf::default();
let mut rust = Repo::new("github.com", "u", "rust-tool");
rust.tags = vec!["rust".into()];
let mut go = Repo::new("github.com", "u", "go-tool");
go.tags = vec!["go".into()];
shelf.add(rust).unwrap();
shelf.add(go).unwrap();
let targets = resolve_targets(
&shelf,
&resolved,
&ExecArgs {
tags: vec!["rust".into()],
filter: None,
cmd: vec!["true".into()],
},
)
.unwrap();
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].slug, "github.com/u/rust-tool");
}
#[test]
fn resolve_targets_no_tag_returns_everything() {
let resolved = make_resolved("/data");
let mut shelf = Shelf::default();
shelf.add(Repo::new("github.com", "u", "a")).unwrap();
shelf.add(Repo::new("github.com", "u", "b")).unwrap();
let targets = resolve_targets(
&shelf,
&resolved,
&ExecArgs {
tags: vec![],
filter: None,
cmd: vec!["true".into()],
},
)
.unwrap();
assert_eq!(targets.len(), 2);
}
#[tokio::test]
async fn interleave_pipes_preserves_per_stream_ordering() {
use std::io::Cursor;
let out_data: Vec<u8> = b"out-1\nout-2\n".to_vec();
let err_data: Vec<u8> = b"err-1\nerr-2\n".to_vec();
let combined = interleave_pipes(Cursor::new(out_data), Cursor::new(err_data))
.await
.unwrap();
let s = String::from_utf8(combined).unwrap();
let i_out1 = s.find("out-1").unwrap();
let i_out2 = s.find("out-2").unwrap();
let i_err1 = s.find("err-1").unwrap();
let i_err2 = s.find("err-2").unwrap();
assert!(i_out1 < i_out2, "stdout order broken: {s}");
assert!(i_err1 < i_err2, "stderr order broken: {s}");
assert_eq!(s.len(), "out-1\nout-2\nerr-1\nerr-2\n".len());
}
#[tokio::test]
async fn interleave_pipes_handles_partial_final_line() {
use std::io::Cursor;
let combined = interleave_pipes(Cursor::new(b"no-nl".to_vec()), Cursor::new(b"".to_vec()))
.await
.unwrap();
assert_eq!(combined, b"no-nl");
}
#[test]
fn outcome_success_only_when_exit_zero_and_no_setup_error() {
let t = Target {
slug: "x".into(),
path: PathBuf::from("."),
};
let mut ok = Outcome {
target: t.clone(),
exit_code: Some(0),
combined: Vec::new(),
setup_error: None,
};
assert!(ok.is_success());
ok.exit_code = Some(1);
assert!(!ok.is_success());
ok.exit_code = Some(0);
ok.setup_error = Some("nope".into());
assert!(!ok.is_success());
ok.setup_error = None;
ok.exit_code = None;
assert!(!ok.is_success());
}
}