diff --git a/README.md b/README.md index f2aa994..9949325 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,14 @@ gitopolis exec -- git pull gitopolis exec -- git status ``` +**Note:** Commands executed with `exec` run in a non-interactive (non-TTY) environment to prevent hanging on prompts or pagers. This means: +- Git commands won't pause for pagers (like `less` for `git log`) +- Git will default to no-color output, but you can re-enable it with `--color` (e.g., `gitopolis exec -- git log --color`) +- Commands won't prompt for interactive input +- SSH/GPG keys must already be loaded and unlocked in ssh-agent or similar +- Remote SSH host keys must already be accepted (in `~/.ssh/known_hosts`) +- The easiest way to ensure keys and hosts are all setup and ready if you run into this problem is to run a single git fetch/clone outside gitopolis first. If this proves to be a regular hassle for new users then we could look at doing something about it so add your experience to [issue #236](https://github.com/timabell/gitopolis/issues/236). + #### Getting output as single lines For compact, parsable output that's easy to sort and analyze use `--oneline`, this will put all the output on a single line for each repo (removing newlines). diff --git a/src/exec.rs b/src/exec.rs index 0654d26..07747b8 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -1,7 +1,8 @@ use crate::repos::Repo; use std::env; -use std::io::{Error, Read}; +use std::io::{BufRead, BufReader, Error, Read}; use std::process::{Child, Command, ExitStatus, Stdio}; +use std::thread; pub fn exec(exec_args: Vec, repos: Vec, oneline: bool) { let mut error_count = 0; @@ -100,6 +101,9 @@ fn repo_exec(path: &str, exec_args: &[String]) -> Result { .arg("-c") .arg(&exec_args[0]) // Single arg passed directly for shell interpretation .current_dir(path) + .stdin(Stdio::null()) // Prevent interactive prompts/pagers + .stdout(Stdio::piped()) // Prevent TTY detection for pagers + .stderr(Stdio::piped()) .spawn()? } else { Command::new("sh") @@ -108,6 +112,9 @@ fn repo_exec(path: &str, exec_args: &[String]) -> Result { .arg("--") // $0 placeholder (ignored) .args(exec_args) // These become $1, $2, $3, etc. .current_dir(path) + .stdin(Stdio::null()) // Prevent interactive prompts/pagers + .stdout(Stdio::piped()) // Prevent TTY detection for pagers + .stderr(Stdio::piped()) .spawn()? }; @@ -117,6 +124,9 @@ fn repo_exec(path: &str, exec_args: &[String]) -> Result { .arg("/C") .arg(&exec_args[0]) // Single arg passed directly for shell interpretation .current_dir(path) + .stdin(Stdio::null()) // Prevent interactive prompts/pagers + .stdout(Stdio::piped()) // Prevent TTY detection for pagers + .stderr(Stdio::piped()) .spawn()? } else { // Windows cmd doesn't have an equivalent to sh -c "$@" @@ -138,17 +148,47 @@ fn repo_exec(path: &str, exec_args: &[String]) -> Result { .arg("/C") .arg(command_string) .current_dir(path) + .stdin(Stdio::null()) // Prevent interactive prompts/pagers + .stdout(Stdio::piped()) // Prevent TTY detection for pagers + .stderr(Stdio::piped()) .spawn()? }; - let exit_code = &child_process.wait()?; + // Stream stdout and stderr in real-time using threads + let stdout = child_process.stdout.take().expect("Failed to capture stdout"); + let stderr = child_process.stderr.take().expect("Failed to capture stderr"); + + let stdout_thread = thread::spawn(move || { + let reader = BufReader::new(stdout); + for line in reader.lines() { + if let Ok(line) = line { + println!("{}", line); + } + } + }); + + let stderr_thread = thread::spawn(move || { + let reader = BufReader::new(stderr); + for line in reader.lines() { + if let Ok(line) = line { + eprintln!("{}", line); + } + } + }); + + let exit_code = child_process.wait()?; + + // Wait for output threads to finish + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + if !exit_code.success() { eprintln!( "Command exited with code {}", exit_code.code().expect("exit code missing") ); } - Ok(*exit_code) + Ok(exit_code) } fn repo_exec_oneline(path: &str, exec_args: &[String]) -> Result<(Option, bool), Error> { @@ -160,6 +200,7 @@ fn repo_exec_oneline(path: &str, exec_args: &[String]) -> Result<(Option .arg("-c") .arg(&exec_args[0]) // Single arg passed directly for shell interpretation .current_dir(path) + .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn()? @@ -170,6 +211,7 @@ fn repo_exec_oneline(path: &str, exec_args: &[String]) -> Result<(Option .arg("--") // $0 placeholder (ignored) .args(exec_args) // These become $1, $2, $3, etc. .current_dir(path) + .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn()? @@ -181,6 +223,7 @@ fn repo_exec_oneline(path: &str, exec_args: &[String]) -> Result<(Option .arg("/C") .arg(&exec_args[0]) // Single arg passed directly for shell interpretation .current_dir(path) + .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn()? @@ -204,6 +247,7 @@ fn repo_exec_oneline(path: &str, exec_args: &[String]) -> Result<(Option .arg("/C") .arg(command_string) .current_dir(path) + .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn()? diff --git a/tests/end_to_end_tests.rs b/tests/end_to_end_tests.rs index 4d00b27..704c45b 100644 --- a/tests/end_to_end_tests.rs +++ b/tests/end_to_end_tests.rs @@ -1925,10 +1925,14 @@ fn exec_with_special_chars() { // Test with all special characters that need quoting: whitespace, quotes, and shell metacharacters // From needs_quoting(): | & ; < > ( ) $ ` \ " ' * ? [ ] { } ! # // Windows cmd.exe echo outputs escaped quotes for the outer quotes - // Windows has mixed line endings: LF from println!(), CRLF from cmd.exe output + // Note: BufReader::lines() normalizes line endings to LF on all platforms let expected_stdout = if cfg!(windows) { - // Manual construction to match actual output with mixed line endings - "\nšŸ¢ repo_a> echo \"test \\\" ' | & ; < > ( ) $ ` \\\\ * ? [ ] { } ! # chars\"\n\\\"test \\\"\\\" ' | & ; < > ( ) $ ` \\ * ? [ ] { } ! # chars\\\"\r\n\n".to_string() + r#" +šŸ¢ repo_a> echo "test \" ' | & ; < > ( ) $ ` \\ * ? [ ] { } ! # chars" +\"test \"\" ' | & ; < > ( ) $ ` \ * ? [ ] { } ! # chars\" + +"# + .to_string() } else { r#" šŸ¢ repo_a> echo "test \" ' | & ; < > ( ) $ ` \\ * ? [ ] { } ! # chars" @@ -2254,12 +2258,16 @@ fn exec_with_multiple_tag_groups() { ); add_a_repo_with_tags(&temp, "repo3", "git://example.org/repo3", vec!["foo"]); - // Windows cmd.exe echo outputs CRLF line endings - let expected_stdout = if cfg!(windows) { - "\nšŸ¢ repo1> echo hello\nhello\r\n\n\nšŸ¢ repo2> echo hello\nhello\r\n\n" - } else { - "\nšŸ¢ repo1> echo hello\nhello\n\n\nšŸ¢ repo2> echo hello\nhello\n\n" - }; + // Note: BufReader::lines() normalizes line endings to LF on all platforms + let expected_stdout = r#" +šŸ¢ repo1> echo hello +hello + + +šŸ¢ repo2> echo hello +hello + +"#; gitopolis_executable() .current_dir(&temp)