agentree 0.1.0

A safety-oriented local CLI for isolated Git worktree tasks
Documentation
use std::{
    collections::BTreeMap,
    ffi::OsString,
    path::{Path, PathBuf},
    process::{Command, Stdio},
};

use crate::domain::{AppError, GitError, InternalGitProfile, Oid};

#[derive(Debug, Clone)]
pub struct GitOutput {
    pub stdout: Vec<u8>,
    pub stderr: Vec<u8>,
    pub status: std::process::ExitStatus,
}

#[derive(Debug, Clone)]
pub struct GitRunner {
    pub executable: PathBuf,
    pub hooks_dir: PathBuf,
}

impl GitRunner {
    pub fn resolve() -> Result<Self, AppError> {
        let current = std::env::current_exe()
            .ok()
            .and_then(|path| std::fs::canonicalize(path).ok());
        let path_var = std::env::var_os("PATH").unwrap_or_default();
        for directory in std::env::split_paths(&path_var) {
            let candidate = directory.join(if cfg!(windows) { "git.exe" } else { "git" });
            if !candidate.is_file() {
                continue;
            }
            let canonical = std::fs::canonicalize(&candidate).ok();
            if current.is_some() && canonical == current {
                continue;
            }
            return Ok(Self {
                executable: candidate,
                hooks_dir: std::env::temp_dir()
                    .join(format!("agentree-hooks-{}", std::process::id())),
            });
        }
        Err(AppError::diagnostic(
            "AGT-0201",
            "system Git executable was not found",
            crate::domain::ErrorKind::Unsupported,
        ))
    }

    pub fn prepare_hooks(&self) -> Result<(), AppError> {
        std::fs::create_dir_all(&self.hooks_dir)?;
        if std::fs::read_dir(&self.hooks_dir)?.next().is_some() {
            return Err(AppError::diagnostic(
                "AGT-0209",
                "trusted hooks directory is not empty",
                crate::domain::ErrorKind::StateInconsistent,
            ));
        }
        Ok(())
    }

    pub fn require_options(
        &self,
        cwd: &Path,
        profile: InternalGitProfile,
        command_name: &str,
        options: &[&str],
    ) -> Result<(), AppError> {
        let mut command = Command::new(&self.executable);
        command.current_dir(cwd);
        command.args(self.profile_args(profile));
        command.args([OsString::from(command_name), OsString::from("-h")]);
        command.stdin(Stdio::null());
        command.stdout(Stdio::piped());
        command.stderr(Stdio::piped());
        sanitize_git_environment(&mut command);
        let output = command.output()?;
        let mut help = output.stdout;
        help.extend(output.stderr);
        let help = String::from_utf8_lossy(&help);
        let missing = options
            .iter()
            .filter(|option| !help.contains(**option))
            .copied()
            .collect::<Vec<_>>();
        if missing.is_empty() {
            return Ok(());
        }
        Err(AppError::diagnostic(
            "AGT-0210",
            format!(
                "Git {command_name} lacks required capabilities: {}",
                missing.join(", ")
            ),
            crate::domain::ErrorKind::Unsupported,
        ))
    }

    pub fn run(
        &self,
        cwd: &Path,
        profile: InternalGitProfile,
        args: &[OsString],
    ) -> Result<GitOutput, AppError> {
        self.run_with_env(cwd, profile, args, &BTreeMap::new(), None)
    }

    pub fn run_with_env(
        &self,
        cwd: &Path,
        profile: InternalGitProfile,
        args: &[OsString],
        overrides: &BTreeMap<OsString, OsString>,
        input: Option<&[u8]>,
    ) -> Result<GitOutput, AppError> {
        self.prepare_hooks()?;
        let mut command = Command::new(&self.executable);
        command.current_dir(cwd);
        command.args(self.profile_args(profile));
        command.args(args);
        command.stdin(if input.is_some() {
            Stdio::piped()
        } else {
            Stdio::null()
        });
        command.stdout(Stdio::piped()).stderr(Stdio::piped());
        sanitize_git_environment(&mut command);
        for (key, value) in overrides {
            command.env(key, value);
        }
        let mut child = command.spawn()?;
        if let Some(input) = input {
            use std::io::Write;
            if let Some(mut stdin) = child.stdin.take() {
                stdin.write_all(input)?;
            }
        }
        let output = child.wait_with_output()?;
        if !output.status.success() {
            return Err(AppError::Git(GitError {
                status: output.status.to_string(),
                stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
                stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
            }));
        }
        Ok(GitOutput {
            stdout: output.stdout,
            stderr: output.stderr,
            status: output.status,
        })
    }

    pub fn text(
        &self,
        cwd: &Path,
        profile: InternalGitProfile,
        args: &[OsString],
    ) -> Result<String, AppError> {
        let output = self.run(cwd, profile, args)?;
        Ok(String::from_utf8(output.stdout)
            .map_err(|_| {
                AppError::diagnostic(
                    "AGT-0202",
                    "Git returned unsupported non-UTF-8 output",
                    crate::domain::ErrorKind::Unsupported,
                )
            })?
            .trim()
            .to_owned())
    }

    pub fn text_with_env(
        &self,
        cwd: &Path,
        profile: InternalGitProfile,
        args: &[OsString],
        overrides: &BTreeMap<OsString, OsString>,
    ) -> Result<String, AppError> {
        let output = self.run_with_env(cwd, profile, args, overrides, None)?;
        Ok(String::from_utf8(output.stdout)
            .map_err(|_| {
                AppError::diagnostic(
                    "AGT-0202",
                    "Git returned unsupported non-UTF-8 output",
                    crate::domain::ErrorKind::Unsupported,
                )
            })?
            .trim()
            .to_owned())
    }

    pub fn oid(&self, cwd: &Path, args: &[OsString]) -> Result<Oid, AppError> {
        Oid::parse(self.text(cwd, InternalGitProfile::Discovery, args)?)
    }

    fn profile_args(&self, profile: InternalGitProfile) -> Vec<OsString> {
        let mut args = Vec::new();
        let add = |args: &mut Vec<OsString>, key: &str, value: &str| {
            args.push(OsString::from("-c"));
            args.push(OsString::from(format!("{key}={value}")));
        };
        add(&mut args, "core.fsmonitor", "false");
        add(
            &mut args,
            "core.hooksPath",
            &self.hooks_dir.to_string_lossy(),
        );
        add(&mut args, "gc.auto", "0");
        add(&mut args, "maintenance.auto", "false");
        match profile {
            InternalGitProfile::Agent => {
                add(&mut args, "diff.external", "");
            }
            InternalGitProfile::WorktreeManagement => add(&mut args, "submodule.recurse", "false"),
            InternalGitProfile::Checkpoint => add(&mut args, "add.ignoreErrors", "false"),
            InternalGitProfile::Fetch => {
                add(&mut args, "fetch.prune", "false");
                add(&mut args, "fetch.pruneTags", "false");
                add(&mut args, "fetch.recurseSubmodules", "false");
                add(&mut args, "fetch.writeCommitGraph", "false");
            }
            InternalGitProfile::Sync => {
                add(&mut args, "rebase.updateRefs", "false");
                add(&mut args, "rebase.autoStash", "false");
                add(&mut args, "rerere.enabled", "false");
                add(&mut args, "rerere.autoupdate", "false");
                add(&mut args, "commit.gpgSign", "false");
            }
            InternalGitProfile::Land => add(&mut args, "merge.autoStash", "false"),
            InternalGitProfile::Discovery | InternalGitProfile::RepairReadOnly => {}
        }
        args
    }
}

fn sanitize_git_environment(command: &mut Command) {
    const KEYS: &[&str] = &[
        "GIT_DIR",
        "GIT_WORK_TREE",
        "GIT_COMMON_DIR",
        "GIT_INDEX_FILE",
        "GIT_OBJECT_DIRECTORY",
        "GIT_ALTERNATE_OBJECT_DIRECTORIES",
        "GIT_NAMESPACE",
        "GIT_CONFIG_COUNT",
        "GIT_CONFIG_GLOBAL",
        "GIT_CONFIG_SYSTEM",
        "GIT_CONFIG_NOSYSTEM",
        "GIT_EXTERNAL_DIFF",
        "GIT_DIFF_OPTS",
        "GIT_QUARANTINE_PATH",
        "GIT_SHALLOW_FILE",
        "GIT_EXEC_PATH",
        "GIT_PREFIX",
    ];
    for key in KEYS {
        command.env_remove(key);
    }
    for (key, _) in std::env::vars_os().filter(|(key, _)| {
        key.to_string_lossy().starts_with("GIT_CONFIG_KEY_")
            || key.to_string_lossy().starts_with("GIT_CONFIG_VALUE_")
    }) {
        command.env_remove(key);
    }
    command.env("GIT_TERMINAL_PROMPT", "0");
    command.env("GIT_PAGER", "cat");
    command.env("GIT_EDITOR", "true");
    command.env("GIT_SEQUENCE_EDITOR", "true");
}

pub fn nul_records(bytes: &[u8]) -> Result<Vec<String>, AppError> {
    bytes
        .split(|byte| *byte == 0)
        .filter(|part| !part.is_empty())
        .map(|part| {
            String::from_utf8(part.to_vec()).map_err(|_| {
                AppError::diagnostic(
                    "AGT-0203",
                    "Git path is not valid UTF-8; this release refuses it",
                    crate::domain::ErrorKind::Unsupported,
                )
            })
        })
        .collect()
}

pub fn args(values: &[&str]) -> Vec<OsString> {
    values.iter().map(OsString::from).collect()
}