Skip to content

Commit 836642c

Browse files
authored
Merge pull request #42 from codcod/22-improve-capturing-output
refactor: improve and streamline capturing output
2 parents 920802f + dfa1b23 commit 836642c

File tree

8 files changed

+459
-199
lines changed

8 files changed

+459
-199
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,6 @@ Thumbs.db
2727
# Project specific
2828
cloned_repos*/
2929
coverage/
30+
logs/
31+
config.yaml
32+
tarpaulin-report.html

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,19 @@ repos run -p "cargo test"
165165

166166
# Specify a custom log directory
167167
repos run -l custom/logs "make build"
168+
169+
# Default behavior (persistent logs)
170+
repos run "mvn compile"
171+
172+
# Disable persistence
173+
repos run --no-save "echo test"
174+
175+
# Custom log directory
176+
repos run --output-dir=/tmp/build-logs "gradle build"
177+
178+
# Search logs
179+
grep -n "ClassNotFound" logs/runs/*/combined.out
180+
rg "BUILD FAILURE" logs/runs/
168181
```
169182

170183
#### Example commands

src/commands/run.rs

Lines changed: 138 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@ use super::{Command, CommandContext};
44
use crate::runner::CommandRunner;
55
use anyhow::Result;
66
use async_trait::async_trait;
7-
use colored::*;
7+
8+
use std::fs::create_dir_all;
9+
use std::path::{Path, PathBuf};
810

911
/// Run command for executing commands in repositories
1012
pub struct RunCommand {
1113
pub command: String,
12-
pub log_dir: String,
14+
pub no_save: bool,
15+
pub output_dir: Option<PathBuf>,
1316
}
1417

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

2225
if repositories.is_empty() {
23-
let filter_desc = match (&context.tag, &context.repos) {
24-
(Some(tag), Some(repos)) => format!("tag '{tag}' and repositories {repos:?}"),
25-
(Some(tag), None) => format!("tag '{tag}'"),
26-
(None, Some(repos)) => format!("repositories {repos:?}"),
27-
(None, None) => "no repositories found".to_string(),
28-
};
29-
println!(
30-
"{}",
31-
format!("No repositories found with {filter_desc}").yellow()
32-
);
3326
return Ok(());
3427
}
3528

36-
println!(
37-
"{}",
38-
format!(
39-
"Running '{}' in {} repositories...",
40-
self.command,
41-
repositories.len()
42-
)
43-
.green()
44-
);
45-
4629
let runner = CommandRunner::new();
4730

31+
// Setup persistent output directory if saving is enabled
32+
let run_root = if !self.no_save {
33+
// Use local time instead of UTC
34+
let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string();
35+
// Sanitize command for directory name
36+
let command_suffix = Self::sanitize_command_for_filename(&self.command);
37+
// Use provided output directory or default to "output"
38+
let base_dir = self
39+
.output_dir
40+
.as_ref()
41+
.unwrap_or(&PathBuf::from("output"))
42+
.join("runs");
43+
let run_dir = base_dir.join(format!("{}_{}", timestamp, command_suffix));
44+
create_dir_all(&run_dir)?;
45+
Some(run_dir)
46+
} else {
47+
None
48+
};
49+
4850
let mut errors = Vec::new();
4951
let mut successful = 0;
5052

@@ -54,66 +56,140 @@ impl Command for RunCommand {
5456
.map(|repo| {
5557
let runner = &runner;
5658
let command = self.command.clone();
57-
let log_dir = self.log_dir.clone();
59+
let no_save = self.no_save;
5860
async move {
59-
(
60-
repo.name.clone(),
61-
runner.run_command(&repo, &command, Some(&log_dir)).await,
62-
)
61+
let result = if !no_save {
62+
runner
63+
.run_command_with_capture_no_logs(&repo, &command, None)
64+
.await
65+
} else {
66+
runner
67+
.run_command(&repo, &command, None)
68+
.await
69+
.map(|_| (String::new(), String::new(), 0))
70+
};
71+
(repo, result)
6372
}
6473
})
6574
.collect();
6675

6776
for task in tasks {
68-
let (repo_name, result) = task.await;
77+
let (repo, result) = task.await;
6978
match result {
70-
Ok(_) => successful += 1,
79+
Ok((stdout, stderr, exit_code)) => {
80+
if exit_code == 0 {
81+
successful += 1;
82+
} else {
83+
errors.push((
84+
repo.name.clone(),
85+
anyhow::anyhow!("Command failed with exit code: {}", exit_code),
86+
));
87+
}
88+
89+
// Save output to individual files
90+
if let Some(ref run_dir) = run_root {
91+
self.save_repo_output(&repo, &stdout, &stderr, run_dir)?;
92+
}
93+
}
7194
Err(e) => {
72-
eprintln!("{}", format!("Error: {e}").red());
73-
errors.push((repo_name, e));
95+
errors.push((repo.name.clone(), e));
7496
}
7597
}
7698
}
7799
} else {
78100
for repo in repositories {
79-
match runner
80-
.run_command(&repo, &self.command, Some(&self.log_dir))
81-
.await
82-
{
83-
Ok(_) => successful += 1,
101+
let result = if !self.no_save {
102+
runner
103+
.run_command_with_capture_no_logs(&repo, &self.command, None)
104+
.await
105+
} else {
106+
runner
107+
.run_command(&repo, &self.command, None)
108+
.await
109+
.map(|_| (String::new(), String::new(), 0))
110+
};
111+
112+
match result {
113+
Ok((stdout, stderr, exit_code)) => {
114+
if exit_code == 0 {
115+
successful += 1;
116+
} else {
117+
errors.push((
118+
repo.name.clone(),
119+
anyhow::anyhow!("Command failed with exit code: {}", exit_code),
120+
));
121+
}
122+
123+
// Save output to individual files
124+
if let Some(ref run_dir) = run_root {
125+
self.save_repo_output(&repo, &stdout, &stderr, run_dir)?;
126+
}
127+
}
84128
Err(e) => {
85-
eprintln!(
86-
"{} | {}",
87-
repo.name.cyan().bold(),
88-
format!("Error: {e}").red()
89-
);
90129
errors.push((repo.name.clone(), e));
91130
}
92131
}
93132
}
94133
}
95134

96-
// Report summary
97-
if errors.is_empty() {
98-
println!("{}", "Done running commands".green());
99-
} else {
100-
println!(
101-
"{}",
102-
format!(
103-
"Completed with {} successful, {} failed",
104-
successful,
105-
errors.len()
106-
)
107-
.yellow()
108-
);
109-
110-
// If all operations failed, return an error to propagate to main
111-
if successful == 0 {
112-
return Err(anyhow::anyhow!(
113-
"All command executions failed. First error: {}",
114-
errors[0].1
115-
));
116-
}
135+
// Check if all operations failed
136+
if !errors.is_empty() && successful == 0 {
137+
return Err(anyhow::anyhow!(
138+
"All command executions failed. First error: {}",
139+
errors[0].1
140+
));
141+
}
142+
143+
Ok(())
144+
}
145+
}
146+
147+
impl RunCommand {
148+
/// Create a new RunCommand with default settings for testing
149+
pub fn new_for_test(command: String, output_dir: String) -> Self {
150+
Self {
151+
command,
152+
no_save: false,
153+
output_dir: Some(PathBuf::from(output_dir)),
154+
}
155+
}
156+
157+
/// Sanitize command string for use in directory names
158+
fn sanitize_command_for_filename(command: &str) -> String {
159+
command
160+
.chars()
161+
.map(|c| match c {
162+
' ' => '_',
163+
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
164+
c if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' => c,
165+
_ => '_',
166+
})
167+
.collect::<String>()
168+
.chars()
169+
.take(50) // Limit length to avoid overly long directory names
170+
.collect()
171+
}
172+
173+
/// Save individual repository output to separate files
174+
fn save_repo_output(
175+
&self,
176+
repo: &crate::config::Repository,
177+
stdout: &str,
178+
stderr: &str,
179+
run_dir: &Path,
180+
) -> Result<()> {
181+
let safe_name = repo.name.replace(['/', '\\', ':'], "_");
182+
183+
// Save stdout
184+
if !stdout.is_empty() {
185+
let stdout_path = run_dir.join(format!("{}.stdout", safe_name));
186+
std::fs::write(stdout_path, stdout)?;
187+
}
188+
189+
// Save stderr
190+
if !stderr.is_empty() {
191+
let stderr_path = run_dir.join(format!("{}.stderr", safe_name));
192+
std::fs::write(stderr_path, stderr)?;
117193
}
118194

119195
Ok(())

src/constants.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,6 @@ pub mod config {
2929
/// Default configuration file name
3030
pub const DEFAULT_CONFIG_FILE: &str = "config.yaml";
3131

32-
/// Default logs directory
33-
pub const DEFAULT_LOGS_DIR: &str = "logs";
32+
/// Default output directory
33+
pub const DEFAULT_LOGS_DIR: &str = "output";
3434
}

src/main.rs

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use anyhow::Result;
22
use clap::{Parser, Subcommand};
33
use repos::{commands::*, config::Config, constants};
4-
use std::env;
4+
use std::{env, path::PathBuf};
55

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

43-
/// Directory to store log files
44-
#[arg(short, long, default_value_t = constants::config::DEFAULT_LOGS_DIR.to_string())]
45-
logs: String,
46-
4743
/// Configuration file path
4844
#[arg(short, long, default_value_t = constants::config::DEFAULT_CONFIG_FILE.to_string())]
4945
config: String,
@@ -55,6 +51,14 @@ enum Commands {
5551
/// Execute operations in parallel
5652
#[arg(short, long)]
5753
parallel: bool,
54+
55+
/// Don't save command outputs to files
56+
#[arg(long)]
57+
no_save: bool,
58+
59+
/// Custom directory for output files (default: output)
60+
#[arg(long)]
61+
output_dir: Option<String>,
5862
},
5963

6064
/// Create pull requests for repositories with changes
@@ -165,10 +169,11 @@ async fn main() -> Result<()> {
165169
Commands::Run {
166170
command,
167171
repos,
168-
logs,
169172
config,
170173
tag,
171174
parallel,
175+
no_save,
176+
output_dir,
172177
} => {
173178
let config = Config::load_config(&config)?;
174179
let context = CommandContext {
@@ -179,7 +184,8 @@ async fn main() -> Result<()> {
179184
};
180185
RunCommand {
181186
command,
182-
log_dir: logs,
187+
no_save,
188+
output_dir: output_dir.map(PathBuf::from),
183189
}
184190
.execute(&context)
185191
.await?;

0 commit comments

Comments
 (0)