ralph-workflow 0.7.18

PROMPT-driven multi-agent orchestrator for git repos
Documentation
// git_helpers/install/io.rs — boundary module for hook installation logic.
// File stem is `io` — recognized as boundary module by forbid_io_effects lint.

// Hook installation logic.

use crate::files::file_contains_marker;
use crate::git_helpers::hooks_dir;
use crate::git_helpers::repo::resolve_protection_scope_from;
use crate::git_helpers::worktree;
use std::fs::{self, File};
use std::path::{Path, PathBuf};

pub const HOOK_MARKER: &str = "RALPH_RUST_MANAGED_HOOK";

pub const RALPH_HOOK_NAMES: &[&str] = &["pre-commit", "pre-push", "pre-merge-commit", "commit-msg"];

fn bash_single_quote_literal(s: &str) -> String {
    format!("'{}'", s.replace('\'', "'\\''"))
}

fn make_hook_content(
    hook_name: &str,
    marker_path_bash: &str,
    track_file_path_bash: &str,
    orig_path_bash: &str,
) -> String {
    format!(
        r#"#!/usr/bin/env bash
set -euo pipefail
# {HOOK_MARKER} - generated by ralph

marker={marker_path_bash}
track_file={track_file_path_bash}

if [[ -f "$marker" ]] || [[ -f "$track_file" ]]; then
  echo "{hook_name} blocked: agent phase protections active."
  exit 1
fi

orig={orig_path_bash}
if [[ -f "$orig" ]]; then
  exec "$orig" "$@"
fi

exit 0
"#
    )
}

#[cfg(unix)]
fn make_writable_for_removal(path: &Path) {
    use std::os::unix::fs::PermissionsExt;
    if let Ok(meta) = fs::metadata(path) {
        let mut perms = meta.permissions();
        perms.set_mode(perms.mode() | 0o200);
        let _ = fs::set_permissions(path, perms);
    }
}

#[cfg(not(unix))]
fn make_writable_for_removal(_path: &Path) {}

fn validate_hook_path_in_hooks_dir(hook_path: &Path, hooks_dir: &Path) -> std::io::Result<()> {
    let hook_parent_dir = hook_path.parent().ok_or_else(|| {
        std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            "Hook path has no parent directory",
        )
    })?;
    if hook_parent_dir == hooks_dir {
        return Ok(());
    }
    Err(std::io::Error::new(
        std::io::ErrorKind::PermissionDenied,
        format!(
            "refusing to install hook outside scoped hooks dir: {}",
            hook_path.display()
        ),
    ))
}

fn resolve_orig_path(hooks_dir: &Path, hook_path: &Path) -> std::io::Result<PathBuf> {
    let resolved_hook_dir = fs::canonicalize(hooks_dir)?;
    let hook_file_name = hook_path.file_name().ok_or_else(|| {
        std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            "Hook path has no file name",
        )
    })?;
    let hook_path_abs = resolved_hook_dir.join(hook_file_name);
    Ok(PathBuf::from(format!("{}.ralph.orig", hook_path_abs.display())))
}

fn backup_existing_hook(hook_path: &Path, orig_path: &Path) -> std::io::Result<()> {
    if !hook_path.exists() || file_contains_marker(hook_path, HOOK_MARKER)? {
        return Ok(());
    }
    fs::copy(hook_path, orig_path)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut perms = fs::metadata(orig_path)?.permissions();
        perms.set_mode(0o755);
        fs::set_permissions(orig_path, perms)?;
    }
    Ok(())
}

fn write_hook_file(hook_path: &Path, hook_content: &str) -> std::io::Result<()> {
    #[cfg(unix)]
    if hook_path.exists() {
        make_writable_for_removal(hook_path);
    }
    let mut file = File::create(hook_path)?;
    std::io::Write::write_all(&mut file, hook_content.as_bytes())?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut perms = fs::metadata(hook_path)?.permissions();
        perms.set_mode(0o555);
        fs::set_permissions(hook_path, perms)?;
    }
    Ok(())
}

pub(crate) fn install_hook_with_repo_root(
    hook_name: &str,
    ralph_dir: &Path,
    hooks_dir: &Path,
    hook_path: &Path,
) -> std::io::Result<()> {
    validate_hook_path_in_hooks_dir(hook_path, hooks_dir)?;

    let marker_path = ralph_dir.join("no_agent_commit");
    let track_file_path = ralph_dir.join("git-wrapper-dir.txt");
    let orig_path_abs = resolve_orig_path(hooks_dir, hook_path)?;

    backup_existing_hook(hook_path, &orig_path_abs)?;

    let hook_content = make_hook_content(
        hook_name,
        &bash_single_quote_literal(&marker_path.display().to_string()),
        &bash_single_quote_literal(&track_file_path.display().to_string()),
        &bash_single_quote_literal(&orig_path_abs.display().to_string()),
    );
    write_hook_file(hook_path, &hook_content)
}

fn hook_display_label(hook_name: &str) -> &str {
    match hook_name {
        "pre-commit" => "Commit",
        "pre-push" => "Push",
        "pre-merge-commit" => "Merge commit",
        "commit-msg" => "Commit message",
        _ => hook_name,
    }
}

fn install_hooks_for_hook_names(
    ralph_dir: &Path,
    hooks_dir: &Path,
    hook_names: &[&str],
) -> std::io::Result<()> {
    hook_names.iter().try_for_each(|hook_name| {
        let label = hook_display_label(hook_name);
        install_hook_with_repo_root(label, ralph_dir, hooks_dir, &hooks_dir.join(hook_name))
    })
}

pub fn install_hooks_in_repo(repo_root: &Path) -> std::io::Result<()> {
    let scope = resolve_protection_scope_from(repo_root)?;

    let ralph_dir = crate::git_helpers::repo::ensure_ralph_git_dir(repo_root)?;
    let hooks_dir = scope.hooks_dir.clone();
    hooks_dir::ensure_scoped_hooks_dir_is_owned(&scope)?;
    worktree::ensure_worktree_hook_scoping(&scope)?;

    install_hooks_for_hook_names(&ralph_dir, &hooks_dir, RALPH_HOOK_NAMES)
}

#[cfg(any(test, feature = "test-utils"))]
pub fn install_hook(hook_name: &str, hook_path: &Path) -> std::io::Result<()> {
    let repo_root = crate::git_helpers::repo::get_repo_root()?;
    let scope = resolve_protection_scope_from(&repo_root)?;
    let ralph_dir = crate::git_helpers::repo::ensure_ralph_git_dir(&repo_root)?;
    let hooks_dir = hook_path.parent().ok_or_else(|| {
        std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            "Hook path has no parent directory",
        )
    })?;
    if hooks_dir != scope.hooks_dir {
        return Err(std::io::Error::new(
            std::io::ErrorKind::PermissionDenied,
            format!(
                "refusing to install hook outside resolved hooks dir: {}",
                hook_path.display()
            ),
        ));
    }
    hooks_dir::validate_hooks_dir_for_scope(&scope, true)?;
    install_hook_with_repo_root(hook_name, &ralph_dir, hooks_dir, hook_path)
}

#[cfg(any(test, feature = "test-utils"))]
pub fn install_hooks() -> std::io::Result<()> {
    let repo_root = crate::git_helpers::repo::get_repo_root()?;
    install_hooks_in_repo(&repo_root)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn generate_hook_content_for_test(
        hook_label: &str,
        hook_filename: &str,
        ralph_dir: &str,
    ) -> String {
        let marker_path_bash = bash_single_quote_literal(&format!("{ralph_dir}/no_agent_commit"));
        let track_file_path_bash =
            bash_single_quote_literal(&format!("{ralph_dir}/git-wrapper-dir.txt"));
        let orig_path_bash =
            bash_single_quote_literal(&format!("{ralph_dir}/../hooks/{hook_filename}.ralph.orig"));
        make_hook_content(
            hook_label,
            &marker_path_bash,
            &track_file_path_bash,
            &orig_path_bash,
        )
    }

    #[test]
    fn test_ralph_hook_names_contains_all_hooks() {
        assert_eq!(RALPH_HOOK_NAMES.len(), 4);
        assert!(RALPH_HOOK_NAMES.contains(&"pre-commit"));
        assert!(RALPH_HOOK_NAMES.contains(&"pre-push"));
        assert!(RALPH_HOOK_NAMES.contains(&"pre-merge-commit"));
        assert!(RALPH_HOOK_NAMES.contains(&"commit-msg"));
    }

    #[test]
    fn test_generate_hook_content_for_test_uses_hook_filename_for_orig_path() {
        let hook_content =
            generate_hook_content_for_test("Commit", "pre-commit", "/tmp/test-repo/.git/ralph");
        assert!(
            hook_content.contains("pre-commit.ralph.orig"),
            "orig hook path should use hook filename (pre-commit); got:\n{hook_content}"
        );
    }

    #[test]
    fn test_hook_blocking_message_is_ascii_only() {
        let hook_content =
            generate_hook_content_for_test("Commit", "pre-commit", "/tmp/test-repo/.git/ralph");
        assert!(
            hook_content.is_ascii(),
            "hook content must be ASCII-only; got:\n{hook_content}"
        );
    }

    #[test]
    fn test_hook_content_contains_track_file_check() {
        let hook_content =
            generate_hook_content_for_test("Commit", "pre-commit", "/tmp/test-repo/.git/ralph");
        assert!(
            hook_content.contains("git-wrapper-dir.txt"),
            "hook must check track file (git-wrapper-dir.txt); got:\n{hook_content}"
        );
        assert!(
            hook_content.contains("no_agent_commit"),
            "hook must check enforcement marker (no_agent_commit); got:\n{hook_content}"
        );
    }

    #[test]
    fn test_hook_blocks_when_only_track_file_exists() {
        let hook_content =
            generate_hook_content_for_test("Commit", "pre-commit", "/tmp/test-repo/.git/ralph");
        assert!(
            hook_content.contains("||"),
            "hook must use OR logic to check marker OR track file; got:\n{hook_content}"
        );
    }

    #[test]
    fn test_hook_blocking_message_is_generic() {
        let hook_content =
            generate_hook_content_for_test("Commit", "pre-commit", "/tmp/test-repo/.git/ralph");
        assert!(
            hook_content.contains("agent phase"),
            "hook blocking message should mention 'agent phase'; got:\n{hook_content}"
        );
    }

    #[test]
    fn test_commit_msg_hook_content_generated() {
        let hook_content = generate_hook_content_for_test(
            "Commit message",
            "commit-msg",
            "/tmp/test-repo/.git/ralph",
        );
        assert!(
            hook_content.contains(HOOK_MARKER),
            "commit-msg hook must contain the Ralph marker; got:\n{hook_content}"
        );
        assert!(
            hook_content.contains("Commit message blocked"),
            "commit-msg hook must reference 'Commit message' in blocking msg; got:\n{hook_content}"
        );
    }

    #[test]
    fn test_verify_hooks_removed_with_workspace() {
        let expected_hooks = ["pre-commit", "pre-push", "pre-merge-commit", "commit-msg"];
        expected_hooks.iter().for_each(|hook| {
            assert!(
                RALPH_HOOK_NAMES.contains(hook),
                "verify_hooks_removed checks RALPH_HOOK_NAMES which must contain {hook}"
            );
        });
    }
}