use std::path::Path;
use std::process::Command;
use crate::error::MarsError;
const GIT_LOCAL_ENV_VARS: &[&str] = &[
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_CONFIG",
"GIT_CONFIG_PARAMETERS",
"GIT_CONFIG_COUNT",
"GIT_OBJECT_DIRECTORY",
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_IMPLICIT_WORK_TREE",
"GIT_GRAFT_FILE",
"GIT_INDEX_FILE",
"GIT_NO_REPLACE_OBJECTS",
"GIT_REPLACE_REF_BASE",
"GIT_PREFIX",
"GIT_SHALLOW_FILE",
"GIT_COMMON_DIR",
];
pub(crate) fn remove_git_local_env(command: &mut Command) {
for var in GIT_LOCAL_ENV_VARS {
command.env_remove(var);
}
}
pub fn run_git(args: &[&str], cwd: &Path, context: &str) -> Result<String, MarsError> {
let command_display = display_command(args);
let mut command = Command::new("git");
remove_git_local_env(&mut command);
let output = command
.current_dir(cwd)
.args(args)
.output()
.map_err(|e| MarsError::GitCli {
command: command_display.clone(),
message: format!(
"{context} (cwd: {}): failed to execute git: {e}",
cwd.display()
),
})?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let error_output = if stderr.trim().is_empty() {
stdout.trim()
} else {
stderr.trim()
};
Err(MarsError::GitCli {
command: command_display,
message: format!(
"{context}: exit {}: {}",
output.status.code().unwrap_or(-1),
error_output
),
})
}
}
pub fn run_git_with_ref(
base_args: &[&str],
ref_arg: &str,
cwd: &Path,
context: &str,
) -> Result<String, MarsError> {
let mut args: Vec<&str> = base_args.to_vec();
args.push(ref_arg);
run_git(&args, cwd, context)
}
pub fn display_command(args: &[&str]) -> String {
if args.is_empty() {
"git".to_string()
} else {
format!("git {}", args.join(" "))
}
}
pub fn run_git_raw(
args: &[&str],
cwd: &Path,
context: &str,
) -> Result<std::process::Output, MarsError> {
let command_display = display_command(args);
let mut command = Command::new("git");
remove_git_local_env(&mut command);
command
.current_dir(cwd)
.args(args)
.output()
.map_err(|e| MarsError::GitCli {
command: command_display,
message: format!(
"{context} (cwd: {}): failed to execute git: {e}",
cwd.display()
),
})
}
#[cfg(test)]
mod tests {
use std::process::Command;
use tempfile::TempDir;
use super::*;
fn test_git_command() -> Command {
let mut command = Command::new("git");
remove_git_local_env(&mut command);
command.env("GIT_AUTHOR_NAME", "Mars Test");
command.env("GIT_AUTHOR_EMAIL", "mars@example.com");
command.env("GIT_COMMITTER_NAME", "Mars Test");
command.env("GIT_COMMITTER_EMAIL", "mars@example.com");
command
}
#[test]
fn run_git_version_succeeds() {
let tmp = TempDir::new().unwrap();
let result = run_git(&["--version"], tmp.path(), "test");
assert!(result.is_ok(), "git --version should succeed: {:?}", result);
assert!(result.unwrap().contains("git version"));
}
#[test]
fn run_git_invalid_command_fails() {
let tmp = TempDir::new().unwrap();
let result = run_git(&["not-a-real-command"], tmp.path(), "test");
assert!(result.is_err());
let err = result.unwrap_err();
let err_str = err.to_string();
assert!(err_str.contains("test"), "error should include context");
assert!(
err_str.contains("not-a-real-command"),
"error should include command"
);
}
#[test]
fn run_git_execute_failure_includes_cwd_and_command() {
let missing = std::env::temp_dir().join("mars-run-git-missing-cwd");
let result = run_git(&["status", "--short"], &missing, "test");
let err = result.expect_err("missing cwd should fail before git runs");
let message = err.to_string();
assert!(message.contains("git status --short"));
assert!(message.contains("cwd:"));
assert!(message.contains(&missing.display().to_string()));
}
#[test]
fn display_command_formats_args() {
assert_eq!(display_command(&["status", "-s"]), "git status -s");
assert_eq!(
display_command(&["log", "--oneline", "-5"]),
"git log --oneline -5"
);
}
#[test]
fn run_git_with_ref_passes_ref_without_shell_interpretation() {
let tmp = TempDir::new().unwrap();
test_git_command()
.current_dir(tmp.path())
.args(["init", "."])
.output()
.expect("git init");
std::fs::write(tmp.path().join("README.md"), "hello").unwrap();
test_git_command()
.current_dir(tmp.path())
.args(["add", "README.md"])
.output()
.expect("git add");
test_git_command()
.current_dir(tmp.path())
.args(["commit", "-m", "init"])
.output()
.expect("git commit");
let result = run_git_with_ref(
&["rev-parse", "--verify"],
"HEAD;echo shell-injected",
tmp.path(),
"verify ref",
);
let err = result.expect_err("metacharacter ref should be passed as one invalid git ref");
let message = err.to_string();
assert!(message.contains("HEAD;echo shell-injected"));
assert!(!message.contains("shell-injected\n"));
}
#[test]
fn run_git_raw_execute_failure_returns_structured_error() {
let missing = std::env::temp_dir().join("mars-run-git-raw-missing-cwd");
let result = run_git_raw(&["status"], &missing, "raw test");
let err = result.expect_err("missing cwd should fail before git runs");
match err {
MarsError::GitCli { command, message } => {
assert_eq!(command, "git status");
assert!(message.contains("raw test"));
assert!(message.contains("cwd:"));
assert!(message.contains(&missing.display().to_string()));
}
other => panic!("expected GitCli error, got {other:?}"),
}
}
}