mars-agents 0.6.2

Agent package manager for .agents/ directories
Documentation
//! External process invocation.
//!
//! Centralizes git and other external tool execution.

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",
];

/// Remove repository-scoped Git environment inherited from hooks or parent git commands.
///
/// Git honors variables like `GIT_DIR` over `current_dir`. Leaving them set can make a
/// command intended for a temp repo mutate the caller's checkout instead.
pub(crate) fn remove_git_local_env(command: &mut Command) {
    for var in GIT_LOCAL_ENV_VARS {
        command.env_remove(var);
    }
}

/// Run a git command and return stdout on success.
///
/// Arguments are passed as an explicit argv array, never through a shell.
/// Errors include context, arguments, exit code, and stderr.
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
            ),
        })
    }
}

/// Run a git command with a specific ref argument that may contain special characters.
///
/// Wraps `run_git` but takes the ref as a separate argument to ensure it's passed correctly.
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)
}

/// Display a command for error messages (not for execution).
pub fn display_command(args: &[&str]) -> String {
    if args.is_empty() {
        "git".to_string()
    } else {
        format!("git {}", args.join(" "))
    }
}

/// Run a git command and return the raw `Output`, allowing caller to handle exit codes.
///
/// Unlike `run_git`, this does not treat non-zero exit codes as errors.
/// Use this for commands like `git merge-file` where positive exit codes have meaning.
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() {
        // git --version should work in any environment with git
        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:?}"),
        }
    }
}