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
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 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}