Skip to main content

mars_agents/platform/
process.rs

1//! External process invocation.
2//!
3//! Centralizes git and other external tool execution.
4
5use std::path::Path;
6use std::process::Command;
7
8use crate::error::MarsError;
9
10const GIT_LOCAL_ENV_VARS: &[&str] = &[
11    "GIT_ALTERNATE_OBJECT_DIRECTORIES",
12    "GIT_CONFIG",
13    "GIT_CONFIG_PARAMETERS",
14    "GIT_CONFIG_COUNT",
15    "GIT_OBJECT_DIRECTORY",
16    "GIT_DIR",
17    "GIT_WORK_TREE",
18    "GIT_IMPLICIT_WORK_TREE",
19    "GIT_GRAFT_FILE",
20    "GIT_INDEX_FILE",
21    "GIT_NO_REPLACE_OBJECTS",
22    "GIT_REPLACE_REF_BASE",
23    "GIT_PREFIX",
24    "GIT_SHALLOW_FILE",
25    "GIT_COMMON_DIR",
26];
27
28/// Remove repository-scoped Git environment inherited from hooks or parent git commands.
29///
30/// Git honors variables like `GIT_DIR` over `current_dir`. Leaving them set can make a
31/// command intended for a temp repo mutate the caller's checkout instead.
32pub(crate) fn remove_git_local_env(command: &mut Command) {
33    for var in GIT_LOCAL_ENV_VARS {
34        command.env_remove(var);
35    }
36}
37
38/// Run a git command and return stdout on success.
39///
40/// Arguments are passed as an explicit argv array, never through a shell.
41/// Errors include context, arguments, exit code, and stderr.
42pub fn run_git(args: &[&str], cwd: &Path, context: &str) -> Result<String, MarsError> {
43    let command_display = display_command(args);
44    let mut command = Command::new("git");
45    remove_git_local_env(&mut command);
46    let output = command
47        .current_dir(cwd)
48        .args(args)
49        .output()
50        .map_err(|e| MarsError::GitCli {
51            command: command_display.clone(),
52            message: format!(
53                "{context} (cwd: {}): failed to execute git: {e}",
54                cwd.display()
55            ),
56        })?;
57
58    if output.status.success() {
59        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
60    } else {
61        let stderr = String::from_utf8_lossy(&output.stderr);
62        let stdout = String::from_utf8_lossy(&output.stdout);
63        let error_output = if stderr.trim().is_empty() {
64            stdout.trim()
65        } else {
66            stderr.trim()
67        };
68
69        Err(MarsError::GitCli {
70            command: command_display,
71            message: format!(
72                "{context}: exit {}: {}",
73                output.status.code().unwrap_or(-1),
74                error_output
75            ),
76        })
77    }
78}
79
80/// Run a git command with a specific ref argument that may contain special characters.
81///
82/// Wraps `run_git` but takes the ref as a separate argument to ensure it's passed correctly.
83pub fn run_git_with_ref(
84    base_args: &[&str],
85    ref_arg: &str,
86    cwd: &Path,
87    context: &str,
88) -> Result<String, MarsError> {
89    let mut args: Vec<&str> = base_args.to_vec();
90    args.push(ref_arg);
91    run_git(&args, cwd, context)
92}
93
94/// Display a command for error messages (not for execution).
95pub fn display_command(args: &[&str]) -> String {
96    if args.is_empty() {
97        "git".to_string()
98    } else {
99        format!("git {}", args.join(" "))
100    }
101}
102
103/// Run a git command and return the raw `Output`, allowing caller to handle exit codes.
104///
105/// Unlike `run_git`, this does not treat non-zero exit codes as errors.
106/// Use this for commands like `git merge-file` where positive exit codes have meaning.
107pub fn run_git_raw(
108    args: &[&str],
109    cwd: &Path,
110    context: &str,
111) -> Result<std::process::Output, MarsError> {
112    let command_display = display_command(args);
113    let mut command = Command::new("git");
114    remove_git_local_env(&mut command);
115    command
116        .current_dir(cwd)
117        .args(args)
118        .output()
119        .map_err(|e| MarsError::GitCli {
120            command: command_display,
121            message: format!(
122                "{context} (cwd: {}): failed to execute git: {e}",
123                cwd.display()
124            ),
125        })
126}
127
128#[cfg(test)]
129mod tests {
130    use std::process::Command;
131
132    use tempfile::TempDir;
133
134    use super::*;
135
136    fn test_git_command() -> Command {
137        let mut command = Command::new("git");
138        remove_git_local_env(&mut command);
139        command.env("GIT_AUTHOR_NAME", "Mars Test");
140        command.env("GIT_AUTHOR_EMAIL", "mars@example.com");
141        command.env("GIT_COMMITTER_NAME", "Mars Test");
142        command.env("GIT_COMMITTER_EMAIL", "mars@example.com");
143        command
144    }
145
146    #[test]
147    fn run_git_version_succeeds() {
148        // git --version should work in any environment with git
149        let tmp = TempDir::new().unwrap();
150        let result = run_git(&["--version"], tmp.path(), "test");
151        assert!(result.is_ok(), "git --version should succeed: {:?}", result);
152        assert!(result.unwrap().contains("git version"));
153    }
154
155    #[test]
156    fn run_git_invalid_command_fails() {
157        let tmp = TempDir::new().unwrap();
158        let result = run_git(&["not-a-real-command"], tmp.path(), "test");
159        assert!(result.is_err());
160
161        let err = result.unwrap_err();
162        let err_str = err.to_string();
163        assert!(err_str.contains("test"), "error should include context");
164        assert!(
165            err_str.contains("not-a-real-command"),
166            "error should include command"
167        );
168    }
169
170    #[test]
171    fn run_git_execute_failure_includes_cwd_and_command() {
172        let missing = std::env::temp_dir().join("mars-run-git-missing-cwd");
173        let result = run_git(&["status", "--short"], &missing, "test");
174        let err = result.expect_err("missing cwd should fail before git runs");
175        let message = err.to_string();
176
177        assert!(message.contains("git status --short"));
178        assert!(message.contains("cwd:"));
179        assert!(message.contains(&missing.display().to_string()));
180    }
181
182    #[test]
183    fn display_command_formats_args() {
184        assert_eq!(display_command(&["status", "-s"]), "git status -s");
185        assert_eq!(
186            display_command(&["log", "--oneline", "-5"]),
187            "git log --oneline -5"
188        );
189    }
190
191    #[test]
192    fn run_git_with_ref_passes_ref_without_shell_interpretation() {
193        let tmp = TempDir::new().unwrap();
194        test_git_command()
195            .current_dir(tmp.path())
196            .args(["init", "."])
197            .output()
198            .expect("git init");
199        std::fs::write(tmp.path().join("README.md"), "hello").unwrap();
200        test_git_command()
201            .current_dir(tmp.path())
202            .args(["add", "README.md"])
203            .output()
204            .expect("git add");
205        test_git_command()
206            .current_dir(tmp.path())
207            .args(["commit", "-m", "init"])
208            .output()
209            .expect("git commit");
210
211        let result = run_git_with_ref(
212            &["rev-parse", "--verify"],
213            "HEAD;echo shell-injected",
214            tmp.path(),
215            "verify ref",
216        );
217
218        let err = result.expect_err("metacharacter ref should be passed as one invalid git ref");
219        let message = err.to_string();
220        assert!(message.contains("HEAD;echo shell-injected"));
221        assert!(!message.contains("shell-injected\n"));
222    }
223
224    #[test]
225    fn run_git_raw_execute_failure_returns_structured_error() {
226        let missing = std::env::temp_dir().join("mars-run-git-raw-missing-cwd");
227        let result = run_git_raw(&["status"], &missing, "raw test");
228
229        let err = result.expect_err("missing cwd should fail before git runs");
230        match err {
231            MarsError::GitCli { command, message } => {
232                assert_eq!(command, "git status");
233                assert!(message.contains("raw test"));
234                assert!(message.contains("cwd:"));
235                assert!(message.contains(&missing.display().to_string()));
236            }
237            other => panic!("expected GitCli error, got {other:?}"),
238        }
239    }
240}