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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
50 changes: 47 additions & 3 deletions src/exec.rs
Original file line number Diff line number Diff line change
@@ -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<String>, repos: Vec<Repo>, oneline: bool) {
let mut error_count = 0;
Expand Down Expand Up @@ -100,6 +101,9 @@ fn repo_exec(path: &str, exec_args: &[String]) -> Result<ExitStatus, Error> {
.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")
Expand All @@ -108,6 +112,9 @@ fn repo_exec(path: &str, exec_args: &[String]) -> Result<ExitStatus, Error> {
.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()?
};

Expand All @@ -117,6 +124,9 @@ fn repo_exec(path: &str, exec_args: &[String]) -> Result<ExitStatus, Error> {
.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 "$@"
Expand All @@ -138,17 +148,47 @@ fn repo_exec(path: &str, exec_args: &[String]) -> Result<ExitStatus, Error> {
.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<String>, bool), Error> {
Expand All @@ -160,6 +200,7 @@ fn repo_exec_oneline(path: &str, exec_args: &[String]) -> Result<(Option<String>
.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()?
Expand All @@ -170,6 +211,7 @@ fn repo_exec_oneline(path: &str, exec_args: &[String]) -> Result<(Option<String>
.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()?
Expand All @@ -181,6 +223,7 @@ fn repo_exec_oneline(path: &str, exec_args: &[String]) -> Result<(Option<String>
.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()?
Expand All @@ -204,6 +247,7 @@ fn repo_exec_oneline(path: &str, exec_args: &[String]) -> Result<(Option<String>
.arg("/C")
.arg(command_string)
.current_dir(path)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?
Expand Down
26 changes: 17 additions & 9 deletions tests/end_to_end_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down