mars_agents/platform/
process.rs1use std::path::Path;
6use std::process::Command;
7
8use crate::error::MarsError;
9
10pub fn run_git(args: &[&str], cwd: &Path, context: &str) -> Result<String, MarsError> {
15 let command_display = display_command(args);
16 let output = Command::new("git")
17 .current_dir(cwd)
18 .args(args)
19 .output()
20 .map_err(|e| MarsError::GitCli {
21 command: command_display.clone(),
22 message: format!(
23 "{context} (cwd: {}): failed to execute git: {e}",
24 cwd.display()
25 ),
26 })?;
27
28 if output.status.success() {
29 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
30 } else {
31 let stderr = String::from_utf8_lossy(&output.stderr);
32 let stdout = String::from_utf8_lossy(&output.stdout);
33 let error_output = if stderr.trim().is_empty() {
34 stdout.trim()
35 } else {
36 stderr.trim()
37 };
38
39 Err(MarsError::GitCli {
40 command: command_display,
41 message: format!(
42 "{context}: exit {}: {}",
43 output.status.code().unwrap_or(-1),
44 error_output
45 ),
46 })
47 }
48}
49
50pub fn run_git_with_ref(
54 base_args: &[&str],
55 ref_arg: &str,
56 cwd: &Path,
57 context: &str,
58) -> Result<String, MarsError> {
59 let mut args: Vec<&str> = base_args.to_vec();
60 args.push(ref_arg);
61 run_git(&args, cwd, context)
62}
63
64pub fn display_command(args: &[&str]) -> String {
66 if args.is_empty() {
67 "git".to_string()
68 } else {
69 format!("git {}", args.join(" "))
70 }
71}
72
73pub fn run_git_raw(
78 args: &[&str],
79 cwd: &Path,
80 context: &str,
81) -> Result<std::process::Output, MarsError> {
82 let command_display = display_command(args);
83 Command::new("git")
84 .current_dir(cwd)
85 .args(args)
86 .output()
87 .map_err(|e| MarsError::GitCli {
88 command: command_display,
89 message: format!(
90 "{context} (cwd: {}): failed to execute git: {e}",
91 cwd.display()
92 ),
93 })
94}
95
96#[cfg(test)]
97mod tests {
98 use std::process::Command;
99
100 use tempfile::TempDir;
101
102 use super::*;
103
104 #[test]
105 fn run_git_version_succeeds() {
106 let tmp = TempDir::new().unwrap();
108 let result = run_git(&["--version"], tmp.path(), "test");
109 assert!(result.is_ok(), "git --version should succeed: {:?}", result);
110 assert!(result.unwrap().contains("git version"));
111 }
112
113 #[test]
114 fn run_git_invalid_command_fails() {
115 let tmp = TempDir::new().unwrap();
116 let result = run_git(&["not-a-real-command"], tmp.path(), "test");
117 assert!(result.is_err());
118
119 let err = result.unwrap_err();
120 let err_str = err.to_string();
121 assert!(err_str.contains("test"), "error should include context");
122 assert!(
123 err_str.contains("not-a-real-command"),
124 "error should include command"
125 );
126 }
127
128 #[test]
129 fn run_git_execute_failure_includes_cwd_and_command() {
130 let missing = std::env::temp_dir().join("mars-run-git-missing-cwd");
131 let result = run_git(&["status", "--short"], &missing, "test");
132 let err = result.expect_err("missing cwd should fail before git runs");
133 let message = err.to_string();
134
135 assert!(message.contains("git status --short"));
136 assert!(message.contains("cwd:"));
137 assert!(message.contains(&missing.display().to_string()));
138 }
139
140 #[test]
141 fn display_command_formats_args() {
142 assert_eq!(display_command(&["status", "-s"]), "git status -s");
143 assert_eq!(
144 display_command(&["log", "--oneline", "-5"]),
145 "git log --oneline -5"
146 );
147 }
148
149 #[test]
150 fn run_git_with_ref_passes_ref_without_shell_interpretation() {
151 let tmp = TempDir::new().unwrap();
152 Command::new("git")
153 .current_dir(tmp.path())
154 .args(["init", "."])
155 .output()
156 .expect("git init");
157 Command::new("git")
158 .current_dir(tmp.path())
159 .args(["config", "user.name", "Mars Test"])
160 .output()
161 .expect("git config user.name");
162 Command::new("git")
163 .current_dir(tmp.path())
164 .args(["config", "user.email", "mars@example.com"])
165 .output()
166 .expect("git config user.email");
167 std::fs::write(tmp.path().join("README.md"), "hello").unwrap();
168 Command::new("git")
169 .current_dir(tmp.path())
170 .args(["add", "README.md"])
171 .output()
172 .expect("git add");
173 Command::new("git")
174 .current_dir(tmp.path())
175 .args(["commit", "-m", "init"])
176 .output()
177 .expect("git commit");
178
179 let result = run_git_with_ref(
180 &["rev-parse", "--verify"],
181 "HEAD;echo shell-injected",
182 tmp.path(),
183 "verify ref",
184 );
185
186 let err = result.expect_err("metacharacter ref should be passed as one invalid git ref");
187 let message = err.to_string();
188 assert!(message.contains("HEAD;echo shell-injected"));
189 assert!(!message.contains("shell-injected\n"));
190 }
191
192 #[test]
193 fn run_git_raw_execute_failure_returns_structured_error() {
194 let missing = std::env::temp_dir().join("mars-run-git-raw-missing-cwd");
195 let result = run_git_raw(&["status"], &missing, "raw test");
196
197 let err = result.expect_err("missing cwd should fail before git runs");
198 match err {
199 MarsError::GitCli { command, message } => {
200 assert_eq!(command, "git status");
201 assert!(message.contains("raw test"));
202 assert!(message.contains("cwd:"));
203 assert!(message.contains(&missing.display().to_string()));
204 }
205 other => panic!("expected GitCli error, got {other:?}"),
206 }
207 }
208}