Skip to content
Merged
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 @@ -27,3 +27,6 @@ Thumbs.db
# Project specific
cloned_repos*/
coverage/
logs/
config.yaml
tarpaulin-report.html
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,19 @@ repos run -p "cargo test"

# Specify a custom log directory
repos run -l custom/logs "make build"

# Default behavior (persistent logs)
repos run "mvn compile"

# Disable persistence
repos run --no-save "echo test"

# Custom log directory
repos run --output-dir=/tmp/build-logs "gradle build"

# Search logs
grep -n "ClassNotFound" logs/runs/*/combined.out
rg "BUILD FAILURE" logs/runs/
```

#### Example commands
Expand Down
200 changes: 138 additions & 62 deletions src/commands/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ use super::{Command, CommandContext};
use crate::runner::CommandRunner;
use anyhow::Result;
use async_trait::async_trait;
use colored::*;

use std::fs::create_dir_all;
use std::path::{Path, PathBuf};

/// Run command for executing commands in repositories
pub struct RunCommand {
pub command: String,
pub log_dir: String,
pub no_save: bool,
pub output_dir: Option<PathBuf>,
}

#[async_trait]
Expand All @@ -20,31 +23,30 @@ impl Command for RunCommand {
.filter_repositories(context.tag.as_deref(), context.repos.as_deref());

if repositories.is_empty() {
let filter_desc = match (&context.tag, &context.repos) {
(Some(tag), Some(repos)) => format!("tag '{tag}' and repositories {repos:?}"),
(Some(tag), None) => format!("tag '{tag}'"),
(None, Some(repos)) => format!("repositories {repos:?}"),
(None, None) => "no repositories found".to_string(),
};
println!(
"{}",
format!("No repositories found with {filter_desc}").yellow()
);
return Ok(());
}

println!(
"{}",
format!(
"Running '{}' in {} repositories...",
self.command,
repositories.len()
)
.green()
);

let runner = CommandRunner::new();

// Setup persistent output directory if saving is enabled
let run_root = if !self.no_save {
// Use local time instead of UTC
let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string();
// Sanitize command for directory name
let command_suffix = Self::sanitize_command_for_filename(&self.command);
// Use provided output directory or default to "output"
let base_dir = self
.output_dir
.as_ref()
.unwrap_or(&PathBuf::from("output"))
.join("runs");
let run_dir = base_dir.join(format!("{}_{}", timestamp, command_suffix));
create_dir_all(&run_dir)?;
Some(run_dir)
} else {
None
};

let mut errors = Vec::new();
let mut successful = 0;

Expand All @@ -54,66 +56,140 @@ impl Command for RunCommand {
.map(|repo| {
let runner = &runner;
let command = self.command.clone();
let log_dir = self.log_dir.clone();
let no_save = self.no_save;
async move {
(
repo.name.clone(),
runner.run_command(&repo, &command, Some(&log_dir)).await,
)
let result = if !no_save {
runner
.run_command_with_capture_no_logs(&repo, &command, None)
.await
} else {
runner
.run_command(&repo, &command, None)
.await
.map(|_| (String::new(), String::new(), 0))
};
(repo, result)
}
})
.collect();

for task in tasks {
let (repo_name, result) = task.await;
let (repo, result) = task.await;
match result {
Ok(_) => successful += 1,
Ok((stdout, stderr, exit_code)) => {
if exit_code == 0 {
successful += 1;
} else {
errors.push((
repo.name.clone(),
anyhow::anyhow!("Command failed with exit code: {}", exit_code),
));
}

// Save output to individual files
if let Some(ref run_dir) = run_root {
self.save_repo_output(&repo, &stdout, &stderr, run_dir)?;
}
}
Err(e) => {
eprintln!("{}", format!("Error: {e}").red());
errors.push((repo_name, e));
errors.push((repo.name.clone(), e));
}
}
}
} else {
for repo in repositories {
match runner
.run_command(&repo, &self.command, Some(&self.log_dir))
.await
{
Ok(_) => successful += 1,
let result = if !self.no_save {
runner
.run_command_with_capture_no_logs(&repo, &self.command, None)
.await
} else {
runner
.run_command(&repo, &self.command, None)
.await
.map(|_| (String::new(), String::new(), 0))
};

match result {
Ok((stdout, stderr, exit_code)) => {
if exit_code == 0 {
successful += 1;
} else {
errors.push((
repo.name.clone(),
anyhow::anyhow!("Command failed with exit code: {}", exit_code),
));
}

// Save output to individual files
if let Some(ref run_dir) = run_root {
self.save_repo_output(&repo, &stdout, &stderr, run_dir)?;
}
}
Err(e) => {
eprintln!(
"{} | {}",
repo.name.cyan().bold(),
format!("Error: {e}").red()
);
errors.push((repo.name.clone(), e));
}
}
}
}

// Report summary
if errors.is_empty() {
println!("{}", "Done running commands".green());
} else {
println!(
"{}",
format!(
"Completed with {} successful, {} failed",
successful,
errors.len()
)
.yellow()
);

// If all operations failed, return an error to propagate to main
if successful == 0 {
return Err(anyhow::anyhow!(
"All command executions failed. First error: {}",
errors[0].1
));
}
// Check if all operations failed
if !errors.is_empty() && successful == 0 {
return Err(anyhow::anyhow!(
"All command executions failed. First error: {}",
errors[0].1
));
}

Ok(())
}
}

impl RunCommand {
/// Create a new RunCommand with default settings for testing
pub fn new_for_test(command: String, output_dir: String) -> Self {
Self {
command,
no_save: false,
output_dir: Some(PathBuf::from(output_dir)),
}
}

/// Sanitize command string for use in directory names
fn sanitize_command_for_filename(command: &str) -> String {
command
.chars()
.map(|c| match c {
' ' => '_',
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
c if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' => c,
_ => '_',
})
.collect::<String>()
.chars()
.take(50) // Limit length to avoid overly long directory names
.collect()
}

/// Save individual repository output to separate files
fn save_repo_output(
&self,
repo: &crate::config::Repository,
stdout: &str,
stderr: &str,
run_dir: &Path,
) -> Result<()> {
let safe_name = repo.name.replace(['/', '\\', ':'], "_");

// Save stdout
if !stdout.is_empty() {
let stdout_path = run_dir.join(format!("{}.stdout", safe_name));
std::fs::write(stdout_path, stdout)?;
}

// Save stderr
if !stderr.is_empty() {
let stderr_path = run_dir.join(format!("{}.stderr", safe_name));
std::fs::write(stderr_path, stderr)?;
}

Ok(())
Expand Down
4 changes: 2 additions & 2 deletions src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,6 @@ pub mod config {
/// Default configuration file name
pub const DEFAULT_CONFIG_FILE: &str = "config.yaml";

/// Default logs directory
pub const DEFAULT_LOGS_DIR: &str = "logs";
/// Default output directory
pub const DEFAULT_LOGS_DIR: &str = "output";
}
20 changes: 13 additions & 7 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use anyhow::Result;
use clap::{Parser, Subcommand};
use repos::{commands::*, config::Config, constants};
use std::env;
use std::{env, path::PathBuf};

#[derive(Parser)]
#[command(name = "repos")]
Expand Down Expand Up @@ -40,10 +40,6 @@ enum Commands {
/// Specific repository names to run command in (if not provided, uses tag filter or all repos)
repos: Vec<String>,

/// Directory to store log files
#[arg(short, long, default_value_t = constants::config::DEFAULT_LOGS_DIR.to_string())]
logs: String,

/// Configuration file path
#[arg(short, long, default_value_t = constants::config::DEFAULT_CONFIG_FILE.to_string())]
config: String,
Expand All @@ -55,6 +51,14 @@ enum Commands {
/// Execute operations in parallel
#[arg(short, long)]
parallel: bool,

/// Don't save command outputs to files
#[arg(long)]
no_save: bool,

/// Custom directory for output files (default: output)
#[arg(long)]
output_dir: Option<String>,
},

/// Create pull requests for repositories with changes
Expand Down Expand Up @@ -165,10 +169,11 @@ async fn main() -> Result<()> {
Commands::Run {
command,
repos,
logs,
config,
tag,
parallel,
no_save,
output_dir,
} => {
let config = Config::load_config(&config)?;
let context = CommandContext {
Expand All @@ -179,7 +184,8 @@ async fn main() -> Result<()> {
};
RunCommand {
command,
log_dir: logs,
no_save,
output_dir: output_dir.map(PathBuf::from),
}
.execute(&context)
.await?;
Expand Down
Loading
Loading