Skip to content

Commit 37dbaab

Browse files
committed
feat: run commands in all repositories despite failures
1 parent 8417c5e commit 37dbaab

4 files changed

Lines changed: 88 additions & 90 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ All notable changes to this project will be documented in this file.
88

99
- Simplified the project from a two-crate workspace into a single binary crate.
1010
- Prevented repository discovery from traversing `.git` metadata directories.
11+
- Reported all parallel command failures in a summary.
1112

1213
### Fixed
1314

1415
- Corrected `--hidden` to include hidden directories and `--no-ignore` to disable all standard ignore rules.
16+
- Preserved process startup errors and let clap handle help, version, and usage output.
1517

1618
## Version 0.4.5 - 2025-07-02
1719

README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,16 @@ For instance, if you want to execute git pull in each repository, you would use:
1919
git-foreach "git pull"
2020
```
2121

22-
By default, git-foreach will stop executing if the command fails in one of the repositories. If you want the execution
23-
to continue despite failures, you can append `|| :` to the command. This will mask the error and allows git-foreach to
24-
continue processing the remaining repositories.
22+
Commands run in parallel in every discovered repository. A failure in one repository does not stop commands that run
23+
in the others. After all commands finish, failures are reported and `git-foreach` exits unsuccessfully. To mask failures,
24+
you can append `|| :` to the command.
2525

2626
```shell
27-
git-foreach "git pull || :"
27+
$ git-foreach "git pull"
28+
encountered 1 error(s):
29+
- command exited unsuccessfully in ./example-repository: exit status: 1
30+
31+
$ git-foreach "git pull || :"
2832
```
2933

3034
## Installation

src/error.rs

Lines changed: 12 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,38 @@
1+
use std::io;
12
use std::path::PathBuf;
3+
use std::process::ExitStatus;
24

35
#[derive(Debug)]
46
pub enum Error {
5-
CommandExecutionFailed { path: PathBuf },
6-
CommandExecutionFailedWithNonZeroExitCode { path: PathBuf, exit_code: i32 },
7+
CommandExecutionFailed { path: PathBuf, source: io::Error },
8+
CommandExitedUnsuccessfully { path: PathBuf, status: ExitStatus },
79
Walk(ignore::Error),
8-
InvalidUsage(clap::Error),
9-
}
10-
11-
impl Error {
12-
/// Get the exit code for the error.
13-
fn get_exit_code(&self) -> i32 {
14-
match self {
15-
Self::CommandExecutionFailedWithNonZeroExitCode { exit_code, .. } => *exit_code,
16-
Self::InvalidUsage(source) => source.exit_code(),
17-
_ => 1,
18-
}
19-
}
20-
21-
/// Print the error message and exit with the appropriate exit code.
22-
pub fn print_and_exit(&self) {
23-
eprintln!("{self}");
24-
std::process::exit(self.get_exit_code());
25-
}
2610
}
2711

2812
impl std::fmt::Display for Error {
2913
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
3014
match self {
31-
Self::CommandExecutionFailed { path } => {
32-
write!(f, "run_command failed for {}", path.display())
15+
Self::CommandExecutionFailed { path, source } => {
16+
write!(f, "failed to start command in {}: {source}", path.display())
3317
}
34-
Self::CommandExecutionFailedWithNonZeroExitCode { path, exit_code } => {
18+
Self::CommandExitedUnsuccessfully { path, status } => {
3519
write!(
3620
f,
37-
"run_command failed with non-zero exit code {} for {}",
38-
exit_code,
39-
path.display()
21+
"command exited unsuccessfully in {}: {status}",
22+
path.display(),
4023
)
4124
}
42-
Self::Walk(source) => write!(f, "error walking directory: {source}"),
43-
Self::InvalidUsage(source) => write!(f, "{source}"),
25+
Self::Walk(source) => write!(f, "failed to walk directory: {source}"),
4426
}
4527
}
4628
}
4729

4830
impl std::error::Error for Error {
4931
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
5032
match self {
33+
Self::CommandExecutionFailed { source, .. } => Some(source),
5134
Self::Walk(source) => Some(source),
52-
Self::InvalidUsage(source) => Some(source),
53-
_ => None,
35+
Self::CommandExitedUnsuccessfully { .. } => None,
5436
}
5537
}
5638
}
@@ -60,9 +42,3 @@ impl From<ignore::Error> for Error {
6042
Self::Walk(source)
6143
}
6244
}
63-
64-
impl From<clap::Error> for Error {
65-
fn from(source: clap::Error) -> Self {
66-
Self::InvalidUsage(source)
67-
}
68-
}

src/main.rs

Lines changed: 66 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
#![forbid(unsafe_code)]
22
#![deny(clippy::pedantic)]
33

4-
use std::env;
54
use std::ffi::OsStr;
65
use std::path::{Path, PathBuf};
6+
use std::process::ExitCode;
77

88
use clap::Parser;
99
use ignore::WalkBuilder;
@@ -48,24 +48,46 @@ struct Options {
4848
command: Vec<String>,
4949
}
5050

51-
fn main() {
52-
repository_foreach(env::args()).unwrap_or_else(|err| err.print_and_exit());
51+
fn main() -> ExitCode {
52+
let options = Options::parse();
53+
54+
match repository_foreach(&options) {
55+
Ok(()) => ExitCode::SUCCESS,
56+
Err(errors) => {
57+
eprintln!("encountered {} error(s):", errors.len());
58+
for error in errors {
59+
eprintln!("- {error}");
60+
}
61+
ExitCode::FAILURE
62+
}
63+
}
5364
}
5465

55-
/// Run a command in each git repository in the current directory and its
66+
/// Run a command in each git repository in the given directory and its
5667
/// subdirectories.
57-
fn repository_foreach<T: Iterator<Item = String>>(args: T) -> Result<(), Error> {
58-
let options = parse_options(args)?;
59-
60-
repository_walk(&options)
68+
fn repository_foreach(options: &Options) -> Result<(), Vec<Error>> {
69+
let mut errors: Vec<_> = repository_walk(options)
6170
.par_bridge()
62-
.try_for_each(|entry| {
63-
let path = entry?.into_path();
71+
.filter_map(|entry| {
72+
let path = match entry {
73+
Ok(entry) => entry.into_path(),
74+
Err(source) => return Some(Error::from(source)),
75+
};
76+
6477
if path.is_dir() && path.join(".git").exists() {
65-
run_command_in_directory(&options, &path)?;
78+
run_command_in_directory(options, &path).err()
79+
} else {
80+
None
6681
}
67-
Ok(())
6882
})
83+
.collect();
84+
85+
errors.sort_by_key(ToString::to_string);
86+
if errors.is_empty() {
87+
Ok(())
88+
} else {
89+
Err(errors)
90+
}
6991
}
7092

7193
fn repository_walk(options: &Options) -> ignore::Walk {
@@ -76,11 +98,6 @@ fn repository_walk(options: &Options) -> ignore::Walk {
7698
.build()
7799
}
78100

79-
/// Parse the command line options.
80-
fn parse_options<T: Iterator<Item = String>>(args: T) -> Result<Options, Error> {
81-
Options::try_parse_from(args).map_err(Error::from)
82-
}
83-
84101
/// Run a command in a directory.
85102
fn run_command_in_directory(options: &Options, path: &Path) -> Result<(), Error> {
86103
if !options.quiet {
@@ -109,60 +126,60 @@ fn run_command_in_directory(options: &Options, path: &Path) -> Result<(), Error>
109126
let status = std::process::Command::new(shell_binary)
110127
.args([shell_arg, &shell_command])
111128
.current_dir(path)
112-
.status();
113-
114-
match status {
115-
Ok(exit_status) if exit_status.success() => Ok(()),
116-
Ok(exit_status) => Err(Error::CommandExecutionFailedWithNonZeroExitCode {
129+
.status()
130+
.map_err(|source| Error::CommandExecutionFailed {
117131
path: path.to_path_buf(),
118-
exit_code: exit_status.code().unwrap_or(1),
119-
}),
120-
Err(_) => Err(Error::CommandExecutionFailed {
132+
source,
133+
})?;
134+
135+
if status.success() {
136+
Ok(())
137+
} else {
138+
Err(Error::CommandExitedUnsuccessfully {
121139
path: path.to_path_buf(),
122-
}),
140+
status,
141+
})
123142
}
124143
}
125144

126145
#[cfg(test)]
127146
mod test {
128147
use super::*;
148+
use clap::error::ErrorKind;
129149
use std::fs;
130150
use tempfile::tempdir;
131151

132-
macro_rules! parse_options_tests {
133-
($($name:ident: $args:expr => $foo:expr,)*) => {
152+
macro_rules! parse_error_tests {
153+
($($name:ident: $args:expr => $kind:expr,)*) => {
134154
$(
135155
#[test]
136156
fn $name() {
137157
let args = $args.iter().map(ToString::to_string);
138-
let result = parse_options(args);
139-
$foo(result);
158+
let error = Options::try_parse_from(args).expect_err("expected parsing to fail");
159+
assert_eq!(error.kind(), $kind);
140160
}
141161
)*
142162
}
143163
}
144164

145-
parse_options_tests!(
146-
parse_options_empty: [] as [&str; 0] => |result: Result<_, _>| assert!(matches!(result, Err(Error::InvalidUsage { .. }))),
147-
parse_options_help: ["git-foreach", "--help"] => |result: Result<_, _>| assert!(matches!(result, Err(Error::InvalidUsage { .. }))),
148-
parse_options_version: ["git-foreach", "--version"] => |result: Result<_, _>| assert!(matches!(result, Err(Error::InvalidUsage { .. }))),
149-
parse_options_invalid: ["git-foreach", "--invalid"] => |result: Result<_, _>| assert!(matches!(result, Err(Error::InvalidUsage { .. }))),
150-
parse_options_valid: ["git-foreach", "echo", "hello"] => |result: Result<Options, _>| {
151-
let options = result.expect("Expected Ok(_)");
152-
assert_eq!(options.command, vec!["echo".to_string(), "hello".to_string()]);
153-
},
154-
parse_options_dry_run: ["git-foreach", "--dry-run", "echo", "hello"] => |result: Result<Options, _>| {
155-
let options = result.expect("Expected Ok(_)");
156-
assert!(options.dry_run);
157-
assert_eq!(options.command, vec!["echo".to_string(), "hello".to_string()]);
158-
},
159-
parse_options_quiet: ["git-foreach", "--quiet", "echo", "hello"] => |result: Result<Options, _>| {
160-
let options = result.expect("Expected Ok(_)");
161-
assert!(options.quiet);
162-
assert_eq!(options.command, vec!["echo".to_string(), "hello".to_string()]);
163-
},
165+
parse_error_tests!(
166+
parse_options_empty: [] as [&str; 0] => ErrorKind::MissingRequiredArgument,
167+
parse_options_help: ["git-foreach", "--help"] => ErrorKind::DisplayHelp,
168+
parse_options_version: ["git-foreach", "--version"] => ErrorKind::DisplayVersion,
169+
parse_options_invalid: ["git-foreach", "--invalid"] => ErrorKind::UnknownArgument,
164170
);
165171

172+
#[test]
173+
fn parse_options() {
174+
let options =
175+
Options::try_parse_from(["git-foreach", "--dry-run", "--quiet", "echo", "hello"])
176+
.expect("expected parsing to succeed");
177+
178+
assert!(options.dry_run);
179+
assert!(options.quiet);
180+
assert_eq!(options.command, ["echo", "hello"]);
181+
}
182+
166183
#[test]
167184
fn hidden_repositories_are_only_included_when_requested() {
168185
let directory = tempdir().expect("failed to create temporary directory");
@@ -208,7 +225,6 @@ mod test {
208225

209226
repository_walk(&options)
210227
.map(|entry| entry.expect("failed to walk test directory").into_path())
211-
.into_iter()
212228
.filter(|path| path.is_dir() && path.join(".git").exists())
213229
.collect()
214230
}

0 commit comments

Comments
 (0)