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#[cfg(test)]
104mod tests {
105    use std::process::Command;
106
107    use tempfile::TempDir;
108
109    use super::*;
110
111    fn test_git_command() -> Command {
112        let mut command = Command::new("git");
113        remove_git_local_env(&mut command);
114        command.env("GIT_AUTHOR_NAME", "Mars Test");
115        command.env("GIT_AUTHOR_EMAIL", "mars@example.com");
116        command.env("GIT_COMMITTER_NAME", "Mars Test");
117        command.env("GIT_COMMITTER_EMAIL", "mars@example.com");
118        command
119    }
120
121    #[test]
122    fn run_git_version_succeeds() {
123        // git --version should work in any environment with git
124        let tmp = TempDir::new().unwrap();
125        let result = run_git(&["--version"], tmp.path(), "test");
126        assert!(result.is_ok(), "git --version should succeed: {:?}", result);
127        assert!(result.unwrap().contains("git version"));
128    }
129
130    #[test]
131    fn run_git_invalid_command_fails() {
132        let tmp = TempDir::new().unwrap();
133        let result = run_git(&["not-a-real-command"], tmp.path(), "test");
134        assert!(result.is_err());
135
136        let err = result.unwrap_err();
137        let err_str = err.to_string();
138        assert!(err_str.contains("test"), "error should include context");
139        assert!(
140            err_str.contains("not-a-real-command"),
141            "error should include command"
142        );
143    }
144
145    #[test]
146    fn run_git_execute_failure_includes_cwd_and_command() {
147        let missing = std::env::temp_dir().join("mars-run-git-missing-cwd");
148        let result = run_git(&["status", "--short"], &missing, "test");
149        let err = result.expect_err("missing cwd should fail before git runs");
150        let message = err.to_string();
151
152        assert!(message.contains("git status --short"));
153        assert!(message.contains("cwd:"));
154        assert!(message.contains(&missing.display().to_string()));
155    }
156
157    #[test]
158    fn display_command_formats_args() {
159        assert_eq!(display_command(&["status", "-s"]), "git status -s");
160        assert_eq!(
161            display_command(&["log", "--oneline", "-5"]),
162            "git log --oneline -5"
163        );
164    }
165
166    #[test]
167    fn run_git_with_ref_passes_ref_without_shell_interpretation() {
168        let tmp = TempDir::new().unwrap();
169        test_git_command()
170            .current_dir(tmp.path())
171            .args(["init", "."])
172            .output()
173            .expect("git init");
174        std::fs::write(tmp.path().join("README.md"), "hello").unwrap();
175        test_git_command()
176            .current_dir(tmp.path())
177            .args(["add", "README.md"])
178            .output()
179            .expect("git add");
180        test_git_command()
181            .current_dir(tmp.path())
182            .args(["commit", "-m", "init"])
183            .output()
184            .expect("git commit");
185
186        let result = run_git_with_ref(
187            &["rev-parse", "--verify"],
188            "HEAD;echo shell-injected",
189            tmp.path(),
190            "verify ref",
191        );
192
193        let err = result.expect_err("metacharacter ref should be passed as one invalid git ref");
194        let message = err.to_string();
195        assert!(message.contains("HEAD;echo shell-injected"));
196        assert!(!message.contains("shell-injected\n"));
197    }
198}