mars_agents/platform/
process.rs1use 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
28pub(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
38pub 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
80pub 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
94pub 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
103pub 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 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}