Skip to main content

ralph_workflow/git_helpers/
wrapper.rs

1//! Git wrapper for blocking commits during agent phase.
2//!
3//! This module provides safety mechanisms to prevent accidental commits while
4//! an AI agent is actively modifying files. It works through two mechanisms:
5//!
6//! - **Marker file**: Creates `<git-dir>/ralph/no_agent_commit` during agent
7//!   execution. Both the git wrapper and hooks check for this file.
8//! - **PATH wrapper**: Installs a temporary `git` wrapper script that intercepts
9//!   `commit`, `push`, and `tag` commands when the marker file exists.
10//!
11//! All enforcement state files live inside the git metadata directory (`<git-dir>/ralph/`)
12//! so they are invisible to working-tree scans and cannot be confused with product code.
13//!
14//! The wrapper is automatically cleaned up when the agent phase ends, even on
15//! unexpected exits (Ctrl+C, panics) via [`cleanup_agent_phase_silent`].
16
17use super::hooks::{reinstall_hooks_if_tampered, uninstall_hooks_silent_at};
18use super::repo::{get_repo_root, normalize_protection_scope_path};
19use crate::logger::Logger;
20use crate::workspace::Workspace;
21use std::env;
22use std::fs::{self, OpenOptions};
23use std::io::{self, Write};
24use std::path::{Path, PathBuf};
25use std::sync::Mutex;
26use which::which;
27
28/// Filename (leaf only) for the enforcement marker inside the ralph git dir.
29const MARKER_FILE_NAME: &str = "no_agent_commit";
30/// Filename (leaf only) for the wrapper tracking file inside the ralph git dir.
31const WRAPPER_TRACK_FILE_NAME: &str = "git-wrapper-dir.txt";
32/// Filename (leaf only) for the HEAD OID baseline file inside the ralph git dir.
33const HEAD_OID_FILE_NAME: &str = "head-oid.txt";
34const WRAPPER_DIR_PREFIX: &str = "ralph-git-wrapper-";
35const WRAPPER_MARKER: &str = "RALPH_AGENT_PHASE_GIT_WRAPPER";
36
37/// Process-global repo root set during `start_agent_phase` for signal handler fallback.
38///
39/// The signal handler needs a reliable repo root when CWD-based discovery may fail.
40/// This is set in `start_agent_phase` and cleared in `end_agent_phase_in_repo`.
41/// The signal handler uses `try_lock` to avoid deadlock risk.
42static AGENT_PHASE_REPO_ROOT: Mutex<Option<PathBuf>> = Mutex::new(None);
43
44/// Process-global ralph git dir set during `start_agent_phase_in_repo`.
45///
46/// Signal handlers cannot call libgit2, so we pre-compute the ralph dir path
47/// on the main thread and store it here. Signal handlers read via `try_lock`.
48static AGENT_PHASE_RALPH_DIR: Mutex<Option<PathBuf>> = Mutex::new(None);
49
50/// Process-global hooks dir set during `start_agent_phase_in_repo`.
51///
52/// Used by signal handler cleanup to avoid recomputation via libgit2.
53/// For linked worktrees, hooks are worktree-scoped, so this ensures the signal
54/// handler cleans the active worktree's hooks instead of touching siblings.
55static AGENT_PHASE_HOOKS_DIR: Mutex<Option<PathBuf>> = Mutex::new(None);
56
57/// Result of checking and self-healing agent-phase protections.
58///
59/// When `ensure_agent_phase_protections` detects and repairs tampering,
60/// this struct records what was found so the caller can take action
61/// (e.g., log a stronger warning or flag the agent run as compromised).
62#[derive(Debug, Clone, Default)]
63pub struct ProtectionCheckResult {
64    /// Whether any tampering was detected and self-healed.
65    pub tampering_detected: bool,
66    /// Human-readable descriptions of each self-healing action taken.
67    pub details: Vec<String>,
68}
69
70fn legacy_marker_path(repo_root: &Path) -> PathBuf {
71    repo_root.join(".no_agent_commit")
72}
73
74fn repair_marker_path_if_tampered(repo_root: &Path) -> io::Result<()> {
75    let ralph_dir = super::repo::ralph_git_dir(repo_root);
76    let marker_path = ralph_dir.join(MARKER_FILE_NAME);
77
78    if let Ok(meta) = fs::symlink_metadata(&marker_path) {
79        let ft = meta.file_type();
80        let is_regular_file = ft.is_file() && !ft.is_symlink();
81        if !is_regular_file {
82            super::repo::quarantine_path_in_place(&marker_path, "marker")?;
83        }
84    }
85
86    create_marker_in_repo_root(repo_root)
87}
88
89fn create_marker_in_repo_root(repo_root: &Path) -> io::Result<()> {
90    let ralph_dir = super::repo::ensure_ralph_git_dir(repo_root)?;
91    let marker_path = ralph_dir.join(MARKER_FILE_NAME);
92
93    if let Ok(meta) = fs::symlink_metadata(&marker_path) {
94        let ft = meta.file_type();
95        let is_regular_file = ft.is_file() && !ft.is_symlink();
96        if is_regular_file {
97            return Ok(());
98        }
99
100        // Any non-regular marker path (symlink/dir/socket/FIFO/device/etc) can bypass
101        // hook/wrapper `-f` checks. Quarantine it and recreate a regular file marker.
102        super::repo::quarantine_path_in_place(&marker_path, "marker")?;
103    }
104
105    let open_res = {
106        #[cfg(unix)]
107        {
108            use std::os::unix::fs::OpenOptionsExt;
109            OpenOptions::new()
110                .write(true)
111                .create_new(true)
112                .custom_flags(libc::O_NOFOLLOW)
113                .open(&marker_path)
114        }
115        #[cfg(not(unix))]
116        {
117            OpenOptions::new()
118                .write(true)
119                .create_new(true)
120                .open(&marker_path)
121        }
122    };
123
124    match open_res {
125        Ok(mut f) => {
126            f.write_all(b"")?;
127            f.flush()?;
128            let _ = f.sync_all();
129        }
130        Err(ref e) if e.kind() == io::ErrorKind::AlreadyExists => {}
131        Err(e) => return Err(e),
132    }
133
134    Ok(())
135}
136
137#[cfg(unix)]
138fn add_owner_write_if_not_symlink(path: &Path) {
139    use std::os::unix::fs::PermissionsExt;
140    if matches!(fs::symlink_metadata(path), Ok(meta) if meta.file_type().is_symlink()) {
141        return;
142    }
143    if let Ok(meta) = fs::metadata(path) {
144        let mut perms = meta.permissions();
145        perms.set_mode(perms.mode() | 0o200);
146        let _ = fs::set_permissions(path, perms);
147    }
148}
149
150#[cfg(unix)]
151fn set_readonly_mode_if_not_symlink(path: &Path, mode: u32) {
152    use std::os::unix::fs::PermissionsExt;
153    if matches!(fs::symlink_metadata(path), Ok(meta) if meta.file_type().is_symlink()) {
154        return;
155    }
156    if let Ok(meta) = fs::metadata(path) {
157        let mut perms = meta.permissions();
158        perms.set_mode(mode);
159        let _ = fs::set_permissions(path, perms);
160    }
161}
162
163fn relax_temp_cleanup_permissions_if_regular_file(path: &Path) {
164    let Ok(meta) = fs::symlink_metadata(path) else {
165        return;
166    };
167    let file_type = meta.file_type();
168    if !file_type.is_file() || file_type.is_symlink() {
169        return;
170    }
171
172    #[cfg(unix)]
173    {
174        use std::os::unix::fs::PermissionsExt;
175        let mut perms = meta.permissions();
176        perms.set_mode(perms.mode() | 0o200);
177        let _ = fs::set_permissions(path, perms);
178    }
179
180    #[cfg(windows)]
181    {
182        let mut perms = meta.permissions();
183        perms.set_readonly(false);
184        let _ = fs::set_permissions(path, perms);
185    }
186}
187
188/// Git helper state.
189pub struct GitHelpers {
190    real_git: Option<PathBuf>,
191    wrapper_dir: Option<PathBuf>,
192    wrapper_repo_root: Option<PathBuf>,
193}
194
195impl GitHelpers {
196    pub(crate) const fn new() -> Self {
197        Self {
198            real_git: None,
199            wrapper_dir: None,
200            wrapper_repo_root: None,
201        }
202    }
203
204    /// Find the real git binary path.
205    fn init_real_git(&mut self) {
206        if self.real_git.is_none() {
207            self.real_git = which("git").ok();
208        }
209    }
210}
211
212impl Default for GitHelpers {
213    fn default() -> Self {
214        Self::new()
215    }
216}
217
218/// Escape a path for safe use in a POSIX shell single-quoted string.
219///
220/// Single quotes in POSIX shells cannot contain literal single quotes.
221/// The standard workaround is to end the quote, add an escaped quote, and restart the quote.
222/// This function rejects paths with newlines since they can't be safely handled.
223fn escape_shell_single_quoted(path: &str) -> io::Result<String> {
224    // Reject newlines - they cannot be safely handled in shell scripts
225    if path.contains('\n') || path.contains('\r') {
226        return Err(io::Error::new(
227            io::ErrorKind::InvalidInput,
228            "git path contains newline characters, cannot create safe shell wrapper",
229        ));
230    }
231    // Replace ' with '\' (end literal string, escaped quote, restart literal string)
232    Ok(path.replace('\'', "'\\''"))
233}
234
235fn find_git_in_path_excluding_dir(exclude_dir: &Path) -> Option<PathBuf> {
236    let path_var = env::var("PATH").ok()?;
237    let exclude = exclude_dir.to_string_lossy();
238    let wrapper_path = exclude_dir.join("git");
239    for entry in path_var.split(':') {
240        if entry.is_empty() {
241            continue;
242        }
243        // Exclude the wrapper dir so we don't resolve the wrapper as "real git".
244        if entry == exclude {
245            continue;
246        }
247        let candidate = Path::new(entry).join("git");
248        if candidate == wrapper_path {
249            continue;
250        }
251        if !candidate.exists() {
252            continue;
253        }
254        if matches!(fs::metadata(&candidate), Ok(meta) if meta.file_type().is_file()) {
255            #[cfg(unix)]
256            {
257                use std::os::unix::fs::PermissionsExt;
258                if let Ok(meta) = fs::metadata(&candidate) {
259                    let mode = meta.permissions().mode() & 0o777;
260                    if (mode & 0o111) == 0 {
261                        continue;
262                    }
263                }
264            }
265            return Some(candidate);
266        }
267    }
268    None
269}
270
271fn path_has_parent_dir_component(path: &Path) -> bool {
272    path.components()
273        .any(|c| matches!(c, std::path::Component::ParentDir))
274}
275
276fn wrapper_dir_is_reasonable_temp_path(path: &Path) -> bool {
277    if !path.is_absolute() {
278        return false;
279    }
280    if path_has_parent_dir_component(path) {
281        return false;
282    }
283    let temp_dir = env::temp_dir();
284    if !path.starts_with(&temp_dir) {
285        // On macOS, `env::temp_dir()` can be under a symlinked prefix.
286        // Accept the canonicalized temp dir prefix as well.
287        let Ok(temp_dir_canon) = fs::canonicalize(&temp_dir) else {
288            return false;
289        };
290        if !path.starts_with(&temp_dir_canon) {
291            return false;
292        }
293    }
294    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
295        return false;
296    };
297    name.starts_with(WRAPPER_DIR_PREFIX)
298}
299
300fn wrapper_dir_is_safe_existing_dir(path: &Path) -> bool {
301    if !wrapper_dir_is_reasonable_temp_path(path) {
302        return false;
303    }
304    let Ok(meta) = fs::symlink_metadata(path) else {
305        return false;
306    };
307    if meta.file_type().is_symlink() {
308        return false;
309    }
310    meta.is_dir()
311}
312
313fn wrapper_dir_is_on_path(path: &Path) -> bool {
314    let Ok(path_var) = env::var("PATH") else {
315        return false;
316    };
317    path_var
318        .split(':')
319        .any(|entry| !entry.is_empty() && Path::new(entry) == path)
320}
321
322fn find_wrapper_dir_on_path() -> Option<PathBuf> {
323    let path_var = env::var("PATH").ok()?;
324    for entry in path_var.split(':') {
325        if entry.is_empty() {
326            continue;
327        }
328        let p = PathBuf::from(entry);
329        if wrapper_dir_is_reasonable_temp_path(&p) {
330            return Some(p);
331        }
332    }
333    None
334}
335
336fn ensure_wrapper_dir_prepended_to_path(wrapper_dir: &Path) {
337    let current_path = env::var("PATH").unwrap_or_default();
338    if current_path
339        .split(':')
340        .next()
341        .is_some_and(|first| !first.is_empty() && Path::new(first) == wrapper_dir)
342    {
343        return;
344    }
345    env::set_var(
346        "PATH",
347        format!("{}:{}", wrapper_dir.display(), current_path),
348    );
349}
350
351fn write_wrapper_track_file_atomic(repo_root: &Path, wrapper_dir: &Path) -> io::Result<()> {
352    let ralph_dir = super::repo::ensure_ralph_git_dir(repo_root)?;
353
354    let track_file_path = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
355
356    // If the track file path is a directory/symlink/special file, treat it as tampering.
357    // Quarantine it so we can atomically replace it with a regular file.
358    if let Ok(meta) = fs::symlink_metadata(&track_file_path) {
359        let ft = meta.file_type();
360        let is_regular_file = ft.is_file() && !ft.is_symlink();
361        if !is_regular_file {
362            super::repo::quarantine_path_in_place(&track_file_path, "track")?;
363        }
364    }
365
366    let tmp_track = ralph_dir.join(format!(
367        ".git-wrapper-dir.tmp.{}.{}",
368        std::process::id(),
369        std::time::SystemTime::now()
370            .duration_since(std::time::UNIX_EPOCH)
371            .unwrap_or_default()
372            .as_nanos()
373    ));
374
375    {
376        let mut tf = OpenOptions::new()
377            .write(true)
378            .create_new(true)
379            .open(&tmp_track)?;
380        tf.write_all(wrapper_dir.display().to_string().as_bytes())?;
381        tf.write_all(b"\n")?;
382        tf.flush()?;
383        let _ = tf.sync_all();
384    }
385
386    #[cfg(unix)]
387    {
388        use std::os::unix::fs::PermissionsExt;
389        let mut perms = fs::metadata(&tmp_track)?.permissions();
390        perms.set_mode(0o444);
391        fs::set_permissions(&tmp_track, perms)?;
392    }
393    #[cfg(windows)]
394    {
395        let mut perms = fs::metadata(&tmp_track)?.permissions();
396        perms.set_readonly(true);
397        fs::set_permissions(&tmp_track, perms)?;
398    }
399
400    // Rename is symlink-safe: it replaces the directory entry.
401    #[cfg(windows)]
402    {
403        if track_file_path.exists() {
404            let _ = fs::remove_file(&track_file_path);
405        }
406    }
407    fs::rename(&tmp_track, &track_file_path)
408}
409
410/// Generate the git wrapper script content.
411///
412/// When protections are active, the wrapper enforces a strict allowlist of
413/// read-only subcommands and blocks everything else.
414///
415/// Protections are considered active when either:
416/// - `<git-dir>/ralph/no_agent_commit` exists (absolute path embedded at install time), OR
417/// - `<git-dir>/ralph/git-wrapper-dir.txt` exists (defense-in-depth against marker deletion).
418///
419/// `git_path_escaped`, `marker_path_escaped`, and `track_file_path_escaped` must already be
420/// shell-single-quote-escaped absolute paths.
421fn make_wrapper_content(
422    git_path_escaped: &str,
423    marker_path_escaped: &str,
424    track_file_path_escaped: &str,
425    active_repo_root_escaped: &str,
426    active_git_dir_escaped: &str,
427) -> String {
428    format!(
429        r#"#!/usr/bin/env bash
430 set -euo pipefail
431  # {WRAPPER_MARKER} - generated by ralph
432  # NOTE: `command git` still routes through this PATH wrapper because `command`
433  # only skips shell functions and aliases, not PATH entries. This wrapper is a
434  # real file in PATH, so it is always invoked for any `git` command.
435  marker='{marker_path_escaped}'
436  track_file='{track_file_path_escaped}'
437  active_repo_root='{active_repo_root_escaped}'
438  active_git_dir='{active_git_dir_escaped}'
439  path_is_within() {{
440    local candidate="$1"
441    local scope_root="$2"
442    [[ "$candidate" == "$scope_root" || "$candidate" == "$scope_root"/* ]]
443  }}
444  normalize_scope_dir() {{
445    local candidate="$1"
446    if [ -z "$candidate" ] || [ ! -d "$candidate" ]; then
447      printf '%s\n' "$candidate"
448      return
449    fi
450    if canonical=$(cd "$candidate" 2>/dev/null && pwd -P); then
451      printf '%s\n' "$canonical"
452    else
453      printf '%s\n' "$candidate"
454    fi
455  }}
456  # Treat either the marker or the wrapper track file as an active agent-phase signal.
457  # This makes the wrapper resilient if an agent deletes the marker mid-run.
458  if [ -f "$marker" ] || [ -f "$track_file" ]; then
459    # Unset environment variables that could be used to bypass the wrapper
460    # by pointing git at a different repository or exec path.
461    unset GIT_DIR
462    unset GIT_WORK_TREE
463    unset GIT_EXEC_PATH
464    subcmd=""
465   repo_args=()
466   repo_arg_pending=0
467   skip_next=0
468    for arg in "$@"; do
469      if [ "$repo_arg_pending" = "1" ]; then
470        repo_args+=("$arg")
471        repo_arg_pending=0
472        continue
473      fi
474      if [ "$skip_next" = "1" ]; then
475        skip_next=0
476        continue
477      fi
478      case "$arg" in
479       -C|--git-dir|--work-tree)
480         repo_args+=("$arg")
481         repo_arg_pending=1
482         ;;
483      --git-dir=*|--work-tree=*|-C=*)
484        repo_args+=("$arg")
485        ;;
486      --namespace|-c|--config|--exec-path)
487        skip_next=1
488        ;;
489      --namespace=*|--exec-path=*|-c=*|--config=*)
490        ;;
491      -*)
492        ;;
493      *)
494        subcmd="$arg"
495        break
496        ;;
497     esac
498    done
499    target_repo_root=""
500    target_git_dir=""
501    if [ "${{#repo_args[@]}}" -gt 0 ]; then
502      target_repo_root=$( '{git_path_escaped}' "${{repo_args[@]}}" rev-parse --path-format=absolute --show-toplevel 2>/dev/null || true )
503      target_git_dir=$( '{git_path_escaped}' "${{repo_args[@]}}" rev-parse --path-format=absolute --git-dir 2>/dev/null || true )
504    else
505      target_repo_root=$( '{git_path_escaped}' rev-parse --path-format=absolute --show-toplevel 2>/dev/null || true )
506      target_git_dir=$( '{git_path_escaped}' rev-parse --path-format=absolute --git-dir 2>/dev/null || true )
507    fi
508    if [ -z "$target_repo_root" ] && [ -z "$target_git_dir" ] && path_is_within "$PWD" "$active_repo_root"; then
509      target_repo_root="$active_repo_root"
510      target_git_dir="$active_git_dir"
511    fi
512    protection_scope_active=0
513    normalized_target_repo_root=$(normalize_scope_dir "$target_repo_root")
514    normalized_target_git_dir=$(normalize_scope_dir "$target_git_dir")
515    if [ "$normalized_target_git_dir" = "$active_git_dir" ]; then
516      protection_scope_active=1
517    elif [ -n "$target_repo_root" ] && [ "$normalized_target_repo_root" = "$active_repo_root" ]; then
518      protection_scope_active=1
519    fi
520    if [ "$protection_scope_active" = "1" ]; then
521    case "$subcmd" in
522      "")
523        # `git` with no subcommand is effectively help/version output.
524       ;;
525     status|log|diff|show|rev-parse|ls-files|describe)
526       # Explicitly allowed read-only lookup commands.
527       ;;
528     stash)
529       # Allow only `git stash list`.
530       stash_sub=""
531       found_stash=0
532       for a2 in "$@"; do
533         if [ "$found_stash" = "1" ]; then
534           case "$a2" in
535             -*) ;;
536             *) stash_sub="$a2"; break ;;
537           esac
538         fi
539         if [ "$a2" = "stash" ]; then found_stash=1; fi
540       done
541       if [ "$stash_sub" != "list" ]; then
542         echo "Blocked: git stash disabled during agent phase (only 'stash list' allowed)." >&2
543         exit 1
544       fi
545       ;;
546     branch)
547       # Allow only explicit read-only `git branch` forms.
548       found_branch=0
549       branch_allows_value=0
550       for a2 in "$@"; do
551         if [ "$branch_allows_value" = "1" ]; then
552           branch_allows_value=0
553           continue
554         fi
555         if [ "$found_branch" = "1" ]; then
556           case "$a2" in
557             --list|-l|--all|-a|--remotes|-r|--verbose|-v|--vv|--show-current|--column|--no-column|--color|--no-color|--ignore-case|--omit-empty)
558               ;;
559             --contains|--no-contains|--merged|--no-merged|--points-at|--sort|--format|--abbrev)
560               branch_allows_value=1
561               ;;
562             --contains=*|--no-contains=*|--merged=*|--no-merged=*|--points-at=*|--sort=*|--format=*|--abbrev=*)
563               ;;
564             *)
565               echo "Blocked: git branch disabled during agent phase (read-only forms only; mutating flags like --unset-upstream are blocked)." >&2
566               exit 1
567               ;;
568           esac
569         fi
570         if [ "$a2" = "branch" ]; then found_branch=1; fi
571       done
572       ;;
573     remote)
574       # Allow only list-only forms of `git remote` (no positional args).
575       found_remote=0
576       for a2 in "$@"; do
577         if [ "$found_remote" = "1" ]; then
578           case "$a2" in
579             -*) ;;
580             *)
581               echo "Blocked: git remote <subcommand> disabled during agent phase (list-only allowed)." >&2
582               exit 1
583               ;;
584           esac
585         fi
586         if [ "$a2" = "remote" ]; then found_remote=1; fi
587       done
588       ;;
589      *)
590        echo "Blocked: git $subcmd disabled during agent phase (read-only allowlist)." >&2
591        exit 1
592        ;;
593    esac
594    fi
595  fi
596  exec '{git_path_escaped}' "$@"
597  "#
598    )
599}
600
601/// Enable git wrapper that blocks commits during agent phase.
602fn enable_git_wrapper_at(repo_root: &Path, helpers: &mut GitHelpers) -> io::Result<()> {
603    // Clean up orphaned wrapper dir from a prior crashed run before creating a new one.
604    // This prevents /tmp leaks on every crash-restart cycle.
605    cleanup_prior_wrapper_from_track_file(repo_root);
606
607    helpers.init_real_git();
608    let Some(real_git) = helpers.real_git.as_ref() else {
609        // Ralph's git operations use libgit2 and should work without the `git` CLI installed.
610        // The wrapper is only a safety feature for intercepting `git commit/push/tag`.
611        // If no `git` binary is available, there's nothing to wrap, so we no-op.
612        return Ok(());
613    };
614
615    // Validate git path is valid UTF-8 for shell script generation.
616    // On Unix systems, paths are typically valid UTF-8, but some filesystems
617    // may contain invalid UTF-8 sequences. In such cases, we cannot safely
618    // generate a shell wrapper and should return an error.
619    let git_path_str = real_git.to_str().ok_or_else(|| {
620        io::Error::new(
621            io::ErrorKind::InvalidData,
622            "git binary path contains invalid UTF-8 characters; cannot create wrapper script",
623        )
624    })?;
625
626    // Validate that the git path is an absolute path.
627    // This prevents potential issues with relative paths and ensures
628    // we're using a known, trusted git binary location.
629    if !real_git.is_absolute() {
630        return Err(io::Error::new(
631            io::ErrorKind::InvalidInput,
632            format!(
633                "git binary path is not absolute: '{git_path_str}'. \
634                 Using absolute paths prevents potential security issues."
635            ),
636        ));
637    }
638
639    // Additional validation: ensure the git binary exists and is executable.
640    // This prevents following symlinks to non-executable files or directories.
641    if !real_git.exists() {
642        return Err(io::Error::new(
643            io::ErrorKind::NotFound,
644            format!("git binary does not exist at path: '{git_path_str}'"),
645        ));
646    }
647
648    // On Unix systems, verify it's not a directory (a directory is not executable as a binary).
649    // Note: fs::metadata() follows symlinks, so this correctly validates the resolved target.
650    // Many package managers (Homebrew, apt) install git as a symlink; that is fine.
651    #[cfg(unix)]
652    {
653        match fs::metadata(real_git) {
654            Ok(metadata) => {
655                if metadata.file_type().is_dir() {
656                    return Err(io::Error::new(
657                        io::ErrorKind::InvalidInput,
658                        format!("git binary path is a directory, not a file: '{git_path_str}'"),
659                    ));
660                }
661            }
662            Err(_) => {
663                return Err(io::Error::new(
664                    io::ErrorKind::PermissionDenied,
665                    format!("cannot access git binary metadata at path: '{git_path_str}'"),
666                ));
667            }
668        }
669    }
670
671    let wrapper_dir = tempfile::Builder::new()
672        .prefix(WRAPPER_DIR_PREFIX)
673        .tempdir()?;
674    let wrapper_dir_path = wrapper_dir.keep();
675    let wrapper_path = wrapper_dir_path.join("git");
676
677    // Escape the git path for shell script to prevent command injection.
678    // Use a helper function to properly handle edge cases and reject unsafe paths.
679    let git_path_escaped = escape_shell_single_quoted(git_path_str)?;
680
681    helpers.wrapper_repo_root = Some(repo_root.to_path_buf());
682
683    let scope = super::repo::resolve_protection_scope_from(repo_root)?;
684    let ralph_dir = scope.ralph_dir.clone();
685    let marker_path = ralph_dir.join(MARKER_FILE_NAME);
686    let track_file_path = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
687    let normalized_repo_root = normalize_protection_scope_path(&scope.repo_root);
688    let normalized_git_dir = normalize_protection_scope_path(&scope.git_dir);
689    let repo_root_str = normalized_repo_root.to_str().ok_or_else(|| {
690        io::Error::new(
691            io::ErrorKind::InvalidData,
692            "repo root contains invalid UTF-8 characters; cannot create wrapper script",
693        )
694    })?;
695    let git_dir_str = normalized_git_dir.to_str().ok_or_else(|| {
696        io::Error::new(
697            io::ErrorKind::InvalidData,
698            "git dir contains invalid UTF-8 characters; cannot create wrapper script",
699        )
700    })?;
701
702    let marker_path_str = marker_path.to_str().ok_or_else(|| {
703        io::Error::new(
704            io::ErrorKind::InvalidData,
705            "marker path contains invalid UTF-8 characters; cannot create wrapper script",
706        )
707    })?;
708    let track_file_path_str = track_file_path.to_str().ok_or_else(|| {
709        io::Error::new(
710            io::ErrorKind::InvalidData,
711            "track file path contains invalid UTF-8 characters; cannot create wrapper script",
712        )
713    })?;
714    let marker_path_escaped = escape_shell_single_quoted(marker_path_str)?;
715    let track_file_path_escaped = escape_shell_single_quoted(track_file_path_str)?;
716    let repo_root_escaped = escape_shell_single_quoted(repo_root_str)?;
717    let git_dir_escaped = escape_shell_single_quoted(git_dir_str)?;
718
719    let wrapper_content = make_wrapper_content(
720        &git_path_escaped,
721        &marker_path_escaped,
722        &track_file_path_escaped,
723        &repo_root_escaped,
724        &git_dir_escaped,
725    );
726
727    // Create wrapper file; wrapper dir is freshly created under temp.
728    let mut file = OpenOptions::new()
729        .write(true)
730        .create_new(true)
731        .open(&wrapper_path)?;
732    file.write_all(wrapper_content.as_bytes())?;
733
734    // Make read-only executable (0o555) to deter agent overwriting, matching
735    // the pattern used for hooks.
736    #[cfg(unix)]
737    {
738        use std::os::unix::fs::PermissionsExt;
739        let mut perms = fs::metadata(&wrapper_path)?.permissions();
740        perms.set_mode(0o555);
741        fs::set_permissions(&wrapper_path, perms)?;
742    }
743
744    // Prepend wrapper dir to PATH.
745    let current_path = env::var("PATH").unwrap_or_default();
746    env::set_var(
747        "PATH",
748        format!("{}:{}", wrapper_dir_path.display(), current_path),
749    );
750
751    // Persist wrapper dir location for best-effort cleanup and self-heal.
752    write_wrapper_track_file_atomic(repo_root, &wrapper_dir_path)?;
753
754    helpers.wrapper_dir = Some(wrapper_dir_path);
755    Ok(())
756}
757
758/// Disable git wrapper.
759///
760/// # Thread Safety
761///
762/// This function modifies the process-wide `PATH` environment variable, which is
763/// inherently not thread-safe. If multiple threads were concurrently modifying PATH,
764/// there could be a TOCTOU (time-of-check-time-of-use) race condition. However,
765/// in Ralph's usage, this function is only called from the main thread during
766/// controlled shutdown sequences, so this is acceptable in practice.
767pub fn disable_git_wrapper(helpers: &mut GitHelpers) {
768    let removed_wrapper_dir = helpers.wrapper_dir.take();
769    removed_wrapper_dir.as_ref().inspect(|wrapper_dir_path| {
770        remove_wrapper_dir_and_path_entry(wrapper_dir_path);
771    });
772
773    // IMPORTANT: remove the tracking file using an absolute repo root path.
774    // The process CWD may not be the repo root (e.g., tests or effects that change CWD).
775    let repo_root = helpers
776        .wrapper_repo_root
777        .take()
778        .or_else(|| crate::git_helpers::get_repo_root().ok());
779
780    let track_file = repo_root.as_ref().map_or_else(
781        || {
782            // Last-resort fallback when repo root is unknown: use CWD-relative guess.
783            PathBuf::from(".git/ralph").join(WRAPPER_TRACK_FILE_NAME)
784        },
785        |r| super::repo::ralph_git_dir(r).join(WRAPPER_TRACK_FILE_NAME),
786    );
787
788    // If we didn't have in-memory wrapper state (or it was out of date), fall back
789    // to the track file for best-effort cleanup of the wrapper dir in /tmp.
790    if let Ok(content) = fs::read_to_string(&track_file) {
791        let wrapper_dir = PathBuf::from(content.trim());
792        let same_as_removed = removed_wrapper_dir
793            .as_ref()
794            .is_some_and(|p| p == &wrapper_dir);
795        if !same_as_removed {
796            remove_wrapper_dir_and_path_entry(&wrapper_dir);
797        }
798    }
799
800    // ALWAYS remove the track file. Hooks check marker OR track_file with ||
801    // logic, so a surviving track file blocks commits even after the marker is
802    // removed. The wrapper dir in /tmp is harmless and will be cleaned by the OS.
803    #[cfg(unix)]
804    add_owner_write_if_not_symlink(&track_file);
805    let _ = fs::remove_file(&track_file);
806}
807
808fn remove_path_entry(path_to_remove: &Path) {
809    // Note: This read-modify-write sequence on PATH has a theoretical TOCTOU race,
810    // but in practice it's safe because Ralph only calls this from the main thread
811    // during controlled shutdown.
812    if let Ok(path) = env::var("PATH") {
813        let new_path: String = path
814            .split(':')
815            .filter(|p| !p.is_empty() && Path::new(p) != path_to_remove)
816            .collect::<Vec<_>>()
817            .join(":");
818        env::set_var("PATH", new_path);
819    }
820}
821
822fn remove_wrapper_dir_and_path_entry(wrapper_dir: &Path) -> bool {
823    remove_path_entry(wrapper_dir);
824
825    if wrapper_dir_is_safe_existing_dir(wrapper_dir) {
826        make_wrapper_script_writable(wrapper_dir);
827        let _ = fs::remove_dir_all(wrapper_dir);
828    }
829
830    !wrapper_dir.exists()
831}
832
833fn make_wrapper_script_writable(wrapper_dir_path: &Path) {
834    #[cfg(unix)]
835    {
836        use std::os::unix::fs::PermissionsExt;
837        let wrapper_path = wrapper_dir_path.join("git");
838        if let Ok(meta) = fs::metadata(&wrapper_path) {
839            let mut perms = meta.permissions();
840            perms.set_mode(perms.mode() | 0o200);
841            let _ = fs::set_permissions(&wrapper_path, perms);
842        }
843    }
844}
845
846/// Start agent phase (creates marker file, installs hooks, enables wrapper).
847///
848/// # Errors
849///
850/// Returns error if the operation fails.
851pub fn start_agent_phase(helpers: &mut GitHelpers) -> io::Result<()> {
852    let repo_root = get_repo_root()?;
853    start_agent_phase_in_repo(&repo_root, helpers)
854}
855
856/// Start agent phase for an explicit repository root.
857///
858/// # Errors
859///
860/// Returns error if the operation fails.
861pub fn start_agent_phase_in_repo(repo_root: &Path, helpers: &mut GitHelpers) -> io::Result<()> {
862    helpers.wrapper_repo_root = Some(repo_root.to_path_buf());
863
864    // Compute ralph dir once on the main thread (libgit2 is safe here).
865    let ralph_dir = super::repo::ralph_git_dir(repo_root);
866
867    // Store repo root and ralph dir for signal handler fallback.
868    if let Ok(mut guard) = AGENT_PHASE_REPO_ROOT.lock() {
869        *guard = Some(repo_root.to_path_buf());
870    }
871    if let Ok(mut guard) = AGENT_PHASE_RALPH_DIR.lock() {
872        *guard = Some(ralph_dir.clone());
873    }
874
875    // Store hooks dir for signal handler cleanup reliability.
876    if let Ok(hooks_dir) = super::repo::get_hooks_dir_from(repo_root) {
877        if let Ok(mut guard) = AGENT_PHASE_HOOKS_DIR.lock() {
878            *guard = Some(hooks_dir);
879        }
880    }
881
882    // Self-heal: treat non-regular marker path as tampering and recover.
883    repair_marker_path_if_tampered(repo_root)?;
884    // Make marker read-only (0o444) to deter agent deletion.
885    #[cfg(unix)]
886    set_readonly_mode_if_not_symlink(&ralph_dir.join(MARKER_FILE_NAME), 0o444);
887    super::hooks::install_hooks_in_repo(repo_root)?;
888    enable_git_wrapper_at(repo_root, helpers)?;
889
890    // Capture HEAD OID baseline for unauthorized commit detection.
891    capture_head_oid(repo_root);
892    Ok(())
893}
894
895/// End agent phase (removes marker file).
896pub fn end_agent_phase() {
897    let Ok(repo_root) = crate::git_helpers::get_repo_root() else {
898        return;
899    };
900    end_agent_phase_in_repo(&repo_root);
901}
902
903/// End agent phase for an explicit repository.
904///
905/// This avoids relying on the process current working directory to locate the repo.
906///
907/// **Note:** This function does NOT clear the process-global mutexes
908/// (`AGENT_PHASE_REPO_ROOT`, `AGENT_PHASE_RALPH_DIR`, `AGENT_PHASE_HOOKS_DIR`).
909/// Callers must invoke [`clear_agent_phase_global_state`] after ALL cleanup steps
910/// (wrapper removal, hook uninstallation) are complete. This prevents a race
911/// where SIGINT arrives between mutex clearing and hook cleanup, causing the
912/// signal handler to find empty mutexes and skip cleanup.
913pub fn end_agent_phase_in_repo(repo_root: &Path) {
914    let ralph_dir = super::repo::ralph_git_dir(repo_root);
915    end_agent_phase_in_repo_at_ralph_dir(repo_root, &ralph_dir);
916}
917
918/// Clear the process-global agent-phase state mutexes.
919///
920/// Must be called after ALL cleanup steps (marker removal, wrapper removal,
921/// hook uninstallation) are complete. Clearing earlier creates a race window
922/// where SIGINT can arrive with empty mutexes, causing incomplete cleanup.
923pub fn clear_agent_phase_global_state() {
924    if let Ok(mut guard) = AGENT_PHASE_REPO_ROOT.lock() {
925        *guard = None;
926    }
927    if let Ok(mut guard) = AGENT_PHASE_RALPH_DIR.lock() {
928        *guard = None;
929    }
930    if let Ok(mut guard) = AGENT_PHASE_HOOKS_DIR.lock() {
931        *guard = None;
932    }
933}
934
935fn end_agent_phase_in_repo_at_ralph_dir(repo_root: &Path, ralph_dir: &Path) {
936    // Legacy marker cleanup (always attempt).
937    let legacy_marker = legacy_marker_path(repo_root);
938    #[cfg(unix)]
939    add_owner_write_if_not_symlink(&legacy_marker);
940    let _ = fs::remove_file(&legacy_marker);
941
942    // Always attempt marker removal regardless of sanitize result.
943    // sanitize may fail due to transient metadata issues, but the
944    // marker file itself may still be removable.
945    let ralph_dir_ok = super::repo::sanitize_ralph_git_dir_at(ralph_dir).unwrap_or(false);
946
947    let marker_path = ralph_dir.join(MARKER_FILE_NAME);
948    // Make writable before removal (marker is created as read-only 0o444).
949    #[cfg(unix)]
950    add_owner_write_if_not_symlink(&marker_path);
951    let _ = fs::remove_file(&marker_path);
952
953    // Only attempt head-oid and dir cleanup if sanitize confirmed dir exists.
954    if ralph_dir_ok {
955        remove_head_oid_file_at(ralph_dir);
956        cleanup_stray_tmp_files_in_ralph_dir(ralph_dir);
957        let _ = fs::remove_dir(ralph_dir);
958    }
959}
960
961/// Verify and restore agent-phase commit protections before each agent invocation.
962///
963/// This is the composite integrity check that self-heals against a prior agent
964/// that deleted the enforcement marker or tampered with git hooks during
965/// its run. It is designed to be called from `run_with_prompt` before every
966/// agent spawn.
967///
968/// The `run_with_prompt` call site is authoritative that agent-phase protections
969/// should be active. Missing protections are treated as tampering (or corruption)
970/// and will be self-healed.
971///
972/// # Limitations
973///
974/// This check protects the *next* agent invocation.
975///
976/// Within a single invocation, defense-in-depth depends on multiple layers:
977/// hooks and the PATH wrapper. The wrapper additionally treats the wrapper track
978/// file as an agent-phase signal to remain effective if the marker is deleted.
979///
980/// If an agent deletes the marker, hooks, and wrapper track file and then invokes
981/// a real `git` binary via an absolute path, protections can be bypassed until
982/// this check runs again.
983///
984/// Errors are logged as warnings only — a missing git repo (e.g., in tests
985/// without a real repo) should not crash the pipeline.
986#[must_use]
987pub fn ensure_agent_phase_protections(logger: &Logger) -> ProtectionCheckResult {
988    let mut result = ProtectionCheckResult::default();
989
990    let Ok(scope) = super::repo::resolve_protection_scope() else {
991        return result;
992    };
993    let repo_root = scope.repo_root.clone();
994
995    let ralph_dir = scope.ralph_dir.clone();
996    let marker_path = ralph_dir.join(MARKER_FILE_NAME);
997    if let Ok(meta) = fs::symlink_metadata(&marker_path) {
998        let ft = meta.file_type();
999        let is_regular_file = ft.is_file() && !ft.is_symlink();
1000        if !is_regular_file {
1001            logger.warn("Enforcement marker is not a regular file — quarantining and recreating");
1002            result.tampering_detected = true;
1003            result
1004                .details
1005                .push("Enforcement marker was not a regular file — quarantined".to_string());
1006            if let Err(e) = super::repo::quarantine_path_in_place(&marker_path, "marker") {
1007                logger.warn(&format!("Failed to quarantine marker path: {e}"));
1008                result
1009                    .details
1010                    .push("Marker path quarantine failed".to_string());
1011            }
1012        }
1013    }
1014
1015    let marker_meta = fs::symlink_metadata(&marker_path).ok();
1016    let marker_exists = marker_meta
1017        .as_ref()
1018        .is_some_and(|m| m.file_type().is_file() && !m.file_type().is_symlink());
1019
1020    // Ensure the PATH wrapper is present and intact.
1021    //
1022    // CRITICAL: Treat the track file as untrusted input.
1023    // We only use it if it points to a plausible temp directory AND that directory is
1024    // already present on PATH (meaning it was installed by Ralph).
1025    let track_file_path = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
1026    if let Ok(meta) = fs::symlink_metadata(&track_file_path) {
1027        let ft = meta.file_type();
1028        let is_regular_file = ft.is_file() && !ft.is_symlink();
1029        if !is_regular_file {
1030            logger.warn("Git wrapper tracking path is not a regular file — quarantining");
1031            result.tampering_detected = true;
1032            result
1033                .details
1034                .push("Git wrapper tracking path was not a regular file — quarantined".to_string());
1035            if let Err(e) = super::repo::quarantine_path_in_place(&track_file_path, "track") {
1036                logger.warn(&format!("Failed to quarantine wrapper tracking path: {e}"));
1037                result
1038                    .details
1039                    .push("Wrapper tracking path quarantine failed".to_string());
1040            }
1041        }
1042    }
1043
1044    let tracked_wrapper_dir = fs::read_to_string(&track_file_path).ok().and_then(|s| {
1045        let p = PathBuf::from(s.trim());
1046        if wrapper_dir_is_safe_existing_dir(&p) && wrapper_dir_is_on_path(&p) {
1047            Some(p)
1048        } else {
1049            None
1050        }
1051    });
1052
1053    let path_wrapper_dir =
1054        find_wrapper_dir_on_path().filter(|p| wrapper_dir_is_safe_existing_dir(p));
1055
1056    let wrapper_dir = tracked_wrapper_dir.clone().or(path_wrapper_dir);
1057
1058    // Ensure the wrapper dir is first on PATH to defend against PATH reordering.
1059    if let Some(ref dir) = wrapper_dir {
1060        ensure_wrapper_dir_prepended_to_path(dir);
1061    }
1062
1063    // If the track file is missing or points elsewhere, rewrite it to the PATH wrapper dir.
1064    if tracked_wrapper_dir.is_none() {
1065        if let Some(ref dir) = wrapper_dir {
1066            logger.warn("Git wrapper tracking file missing or invalid — restoring");
1067            result.tampering_detected = true;
1068            result
1069                .details
1070                .push("Git wrapper tracking file missing or invalid — restored".to_string());
1071
1072            // Best-effort rewrite: failures here should not crash the pipeline.
1073            if let Err(e) = write_wrapper_track_file_atomic(&repo_root, dir) {
1074                logger.warn(&format!("Failed to restore wrapper tracking file: {e}"));
1075            }
1076        }
1077    }
1078
1079    // Restore wrapper script content/permissions if missing or tampered.
1080    if let Some(wrapper_dir) = wrapper_dir {
1081        let wrapper_path = wrapper_dir.join("git");
1082        let wrapper_needs_restore = fs::read_to_string(&wrapper_path).map_or(true, |content| {
1083            !content.contains(WRAPPER_MARKER) || !content.contains("unset GIT_EXEC_PATH")
1084        });
1085
1086        if wrapper_needs_restore {
1087            logger.warn("Git wrapper script missing or tampered — restoring");
1088            result.tampering_detected = true;
1089            result
1090                .details
1091                .push("Git wrapper script missing or tampered — restored".to_string());
1092
1093            // Resolve the real git binary by searching PATH excluding the wrapper dir.
1094            let real_git =
1095                find_git_in_path_excluding_dir(&wrapper_dir).or_else(|| which("git").ok());
1096
1097            match real_git {
1098                Some(real_git_path) => {
1099                    let Some(real_git_str) = real_git_path.to_str() else {
1100                        logger.warn(
1101                            "Resolved git binary path is not valid UTF-8; cannot restore wrapper",
1102                        );
1103                        return result;
1104                    };
1105                    let Ok(git_path_escaped) = escape_shell_single_quoted(real_git_str) else {
1106                        logger.warn("Failed to generate safe wrapper script (git path)");
1107                        return result;
1108                    };
1109                    let marker_p = ralph_dir.join(MARKER_FILE_NAME);
1110                    let track_p = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
1111                    let Some(marker_str) = marker_p.to_str() else {
1112                        logger.warn("Marker path is not valid UTF-8; cannot restore wrapper");
1113                        return result;
1114                    };
1115                    let Some(track_str) = track_p.to_str() else {
1116                        logger.warn("Track file path is not valid UTF-8; cannot restore wrapper");
1117                        return result;
1118                    };
1119                    let Ok(marker_escaped) = escape_shell_single_quoted(marker_str) else {
1120                        logger.warn("Failed to generate safe wrapper script (marker path)");
1121                        return result;
1122                    };
1123                    let Ok(track_escaped) = escape_shell_single_quoted(track_str) else {
1124                        logger.warn("Failed to generate safe wrapper script (track file path)");
1125                        return result;
1126                    };
1127                    let normalized_repo_root = normalize_protection_scope_path(&repo_root);
1128                    let normalized_git_dir = normalize_protection_scope_path(&scope.git_dir);
1129                    let Some(repo_root_str) = normalized_repo_root.to_str() else {
1130                        logger.warn("Repo root is not valid UTF-8; cannot restore wrapper");
1131                        return result;
1132                    };
1133                    let Some(git_dir_str) = normalized_git_dir.to_str() else {
1134                        logger.warn("Git dir is not valid UTF-8; cannot restore wrapper");
1135                        return result;
1136                    };
1137                    let Ok(repo_root_escaped) = escape_shell_single_quoted(repo_root_str) else {
1138                        logger.warn("Failed to generate safe wrapper script (repo root)");
1139                        return result;
1140                    };
1141                    let Ok(git_dir_escaped) = escape_shell_single_quoted(git_dir_str) else {
1142                        logger.warn("Failed to generate safe wrapper script (git dir)");
1143                        return result;
1144                    };
1145
1146                    let wrapper_content = make_wrapper_content(
1147                        &git_path_escaped,
1148                        &marker_escaped,
1149                        &track_escaped,
1150                        &repo_root_escaped,
1151                        &git_dir_escaped,
1152                    );
1153
1154                    let tmp_path = wrapper_dir.join(format!(
1155                        ".git-wrapper.tmp.{}.{}",
1156                        std::process::id(),
1157                        std::time::SystemTime::now()
1158                            .duration_since(std::time::UNIX_EPOCH)
1159                            .unwrap_or_default()
1160                            .as_nanos()
1161                    ));
1162
1163                    let open_tmp = {
1164                        #[cfg(unix)]
1165                        {
1166                            use std::os::unix::fs::OpenOptionsExt;
1167                            OpenOptions::new()
1168                                .write(true)
1169                                .create_new(true)
1170                                .custom_flags(libc::O_NOFOLLOW)
1171                                .open(&tmp_path)
1172                        }
1173                        #[cfg(not(unix))]
1174                        {
1175                            OpenOptions::new()
1176                                .write(true)
1177                                .create_new(true)
1178                                .open(&tmp_path)
1179                        }
1180                    };
1181
1182                    match open_tmp.and_then(|mut f| {
1183                        f.write_all(wrapper_content.as_bytes())?;
1184                        f.flush()?;
1185                        let _ = f.sync_all();
1186                        Ok(())
1187                    }) {
1188                        Ok(()) => {
1189                            #[cfg(unix)]
1190                            {
1191                                use std::os::unix::fs::PermissionsExt;
1192                                if let Ok(meta) = fs::metadata(&tmp_path) {
1193                                    let mut perms = meta.permissions();
1194                                    perms.set_mode(0o555);
1195                                    let _ = fs::set_permissions(&tmp_path, perms);
1196                                }
1197                            }
1198                            #[cfg(windows)]
1199                            {
1200                                if let Ok(meta) = fs::metadata(&tmp_path) {
1201                                    let mut perms = meta.permissions();
1202                                    perms.set_readonly(true);
1203                                    let _ = fs::set_permissions(&tmp_path, perms);
1204                                }
1205                                if wrapper_path.exists() {
1206                                    let _ = fs::remove_file(&wrapper_path);
1207                                }
1208                            }
1209                            if let Err(e) = fs::rename(&tmp_path, &wrapper_path) {
1210                                let _ = fs::remove_file(&tmp_path);
1211                                logger.warn(&format!("Failed to restore wrapper script: {e}"));
1212                            }
1213                        }
1214                        Err(e) => {
1215                            logger.warn(&format!("Failed to write wrapper temp file: {e}"));
1216                        }
1217                    }
1218
1219                    // Defense-in-depth: validate we didn't resolve the wrapper itself.
1220                    if real_git_path == wrapper_path {
1221                        logger.warn(
1222                            "Resolved git binary points to wrapper; wrapper restore may be incomplete",
1223                        );
1224                    }
1225                }
1226                None => {
1227                    logger.warn("Failed to resolve real git binary; cannot restore wrapper");
1228                }
1229            }
1230        }
1231
1232        // Restore wrapper permissions (0o555) if loosened.
1233        #[cfg(unix)]
1234        {
1235            use std::os::unix::fs::PermissionsExt;
1236            if let Ok(meta) = fs::metadata(&wrapper_path) {
1237                let mode = meta.permissions().mode() & 0o777;
1238                if mode != 0o555 {
1239                    logger.warn(&format!(
1240                        "Git wrapper permissions loosened ({mode:#o}) — restoring to 0o555"
1241                    ));
1242                    let mut perms = meta.permissions();
1243                    perms.set_mode(0o555);
1244                    let _ = fs::set_permissions(&wrapper_path, perms);
1245                    result.tampering_detected = true;
1246                    result.details.push(format!(
1247                        "Git wrapper permissions loosened ({mode:#o}) — restored to 0o555"
1248                    ));
1249                }
1250            }
1251        }
1252    } else {
1253        // Wrapper missing from PATH and no valid track file — re-enable.
1254        logger.warn("Git wrapper missing — reinstalling");
1255        result.tampering_detected = true;
1256        result
1257            .details
1258            .push("Git wrapper missing before agent spawn — reinstalling".to_string());
1259
1260        let wrapper_dir = match tempfile::Builder::new()
1261            .prefix(WRAPPER_DIR_PREFIX)
1262            .tempdir()
1263        {
1264            Ok(d) => d.keep(),
1265            Err(e) => {
1266                logger.warn(&format!("Failed to create wrapper dir: {e}"));
1267                // Continue with hooks/marker self-heal; wrapper is defense-in-depth.
1268                return result;
1269            }
1270        };
1271        ensure_wrapper_dir_prepended_to_path(&wrapper_dir);
1272
1273        let real_git = find_git_in_path_excluding_dir(&wrapper_dir).or_else(|| which("git").ok());
1274        if let Some(real_git_path) = real_git {
1275            if let Some(real_git_str) = real_git_path.to_str() {
1276                let marker_p = ralph_dir.join(MARKER_FILE_NAME);
1277                let track_p = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
1278                if let (Ok(git_path_escaped), Some(marker_str), Some(track_str)) = (
1279                    escape_shell_single_quoted(real_git_str),
1280                    marker_p.to_str(),
1281                    track_p.to_str(),
1282                ) {
1283                    if let (Ok(marker_escaped), Ok(track_escaped)) = (
1284                        escape_shell_single_quoted(marker_str),
1285                        escape_shell_single_quoted(track_str),
1286                    ) {
1287                        let normalized_repo_root = normalize_protection_scope_path(&repo_root);
1288                        let normalized_git_dir = normalize_protection_scope_path(&scope.git_dir);
1289                        let Some(repo_root_str) = normalized_repo_root.to_str() else {
1290                            logger.warn("Repo root is not valid UTF-8; cannot restore wrapper");
1291                            return result;
1292                        };
1293                        let Some(git_dir_str) = normalized_git_dir.to_str() else {
1294                            logger.warn("Git dir is not valid UTF-8; cannot restore wrapper");
1295                            return result;
1296                        };
1297                        let Ok(repo_root_escaped) = escape_shell_single_quoted(repo_root_str)
1298                        else {
1299                            logger.warn("Failed to generate safe wrapper script (repo root)");
1300                            return result;
1301                        };
1302                        let Ok(git_dir_escaped) = escape_shell_single_quoted(git_dir_str) else {
1303                            logger.warn("Failed to generate safe wrapper script (git dir)");
1304                            return result;
1305                        };
1306                        let wrapper_content = make_wrapper_content(
1307                            &git_path_escaped,
1308                            &marker_escaped,
1309                            &track_escaped,
1310                            &repo_root_escaped,
1311                            &git_dir_escaped,
1312                        );
1313                        let wrapper_path = wrapper_dir.join("git");
1314                        if OpenOptions::new()
1315                            .write(true)
1316                            .create_new(true)
1317                            .open(&wrapper_path)
1318                            .and_then(|mut f| {
1319                                f.write_all(wrapper_content.as_bytes())?;
1320                                f.flush()?;
1321                                let _ = f.sync_all();
1322                                Ok(())
1323                            })
1324                            .is_ok()
1325                        {
1326                            #[cfg(unix)]
1327                            {
1328                                use std::os::unix::fs::PermissionsExt;
1329                                if let Ok(meta) = fs::metadata(&wrapper_path) {
1330                                    let mut perms = meta.permissions();
1331                                    perms.set_mode(0o555);
1332                                    let _ = fs::set_permissions(&wrapper_path, perms);
1333                                }
1334                            }
1335                        }
1336                    }
1337                }
1338            }
1339        }
1340
1341        // Best-effort track file write.
1342        if let Err(e) = write_wrapper_track_file_atomic(&repo_root, &wrapper_dir) {
1343            logger.warn(&format!("Failed to write wrapper tracking file: {e}"));
1344        }
1345    }
1346
1347    // Check if hooks exist (any Ralph hook present means we're in agent phase).
1348    let hooks_present = super::repo::get_hooks_dir_from(&repo_root)
1349        .ok()
1350        .is_some_and(|hooks_dir| {
1351            super::hooks::RALPH_HOOK_NAMES.iter().any(|name| {
1352                let path = hooks_dir.join(name);
1353                path.exists()
1354                    && matches!(
1355                        crate::files::file_contains_marker(&path, super::hooks::HOOK_MARKER),
1356                        Ok(true)
1357                    )
1358            })
1359        });
1360
1361    // Missing protections before an agent spawn is treated as tampering.
1362    if !marker_exists && !hooks_present {
1363        logger.warn("Agent-phase git protections missing — reinstalling");
1364        result.tampering_detected = true;
1365        result
1366            .details
1367            .push("Marker and hooks missing before agent spawn — reinstalling".to_string());
1368    }
1369
1370    let marker_meta = fs::symlink_metadata(&marker_path).ok();
1371    let marker_is_symlink = marker_meta
1372        .as_ref()
1373        .is_some_and(|meta| meta.file_type().is_symlink());
1374    let marker_exists = marker_meta
1375        .as_ref()
1376        .is_some_and(|meta| meta.file_type().is_file() && !meta.file_type().is_symlink());
1377
1378    // Repair marker if missing or replaced with a symlink.
1379    if marker_is_symlink {
1380        logger.warn("Enforcement marker is a symlink — removing and recreating");
1381        let _ = fs::remove_file(&marker_path);
1382        result.tampering_detected = true;
1383        result
1384            .details
1385            .push("Enforcement marker was a symlink — removed".to_string());
1386    }
1387    if !marker_exists {
1388        logger.warn("Enforcement marker missing — recreating");
1389        if let Err(e) = create_marker_in_repo_root(&repo_root) {
1390            logger.warn(&format!("Failed to recreate enforcement marker: {e}"));
1391        } else {
1392            #[cfg(unix)]
1393            set_readonly_mode_if_not_symlink(&marker_path, 0o444);
1394        }
1395        result.tampering_detected = true;
1396        result
1397            .details
1398            .push("Enforcement marker was missing — recreated".to_string());
1399    }
1400
1401    // Verify/restore marker permissions (read-only 0o444).
1402    #[cfg(unix)]
1403    {
1404        use std::os::unix::fs::PermissionsExt;
1405        if marker_is_symlink {
1406            // Never chmod through a symlink.
1407        } else if let Ok(meta) = fs::metadata(&marker_path) {
1408            if meta.is_file() {
1409                let mode = meta.permissions().mode() & 0o777;
1410                if mode != 0o444 {
1411                    logger.warn(&format!(
1412                        "Enforcement marker permissions loosened ({mode:#o}) — restoring to 0o444"
1413                    ));
1414                    let mut perms = meta.permissions();
1415                    perms.set_mode(0o444);
1416                    let _ = fs::set_permissions(&marker_path, perms);
1417                    result.tampering_detected = true;
1418                    result.details.push(format!(
1419                        "Enforcement marker permissions loosened ({mode:#o}) — restored to 0o444"
1420                    ));
1421                }
1422            } else {
1423                // A non-file marker path would bypass hook/wrapper `-f` checks.
1424                // Quarantine and recreate a file marker.
1425                logger.warn("Enforcement marker is not a regular file — quarantining");
1426                result.tampering_detected = true;
1427                result
1428                    .details
1429                    .push("Enforcement marker was not a regular file — quarantined".to_string());
1430                if let Err(e) = super::repo::quarantine_path_in_place(&marker_path, "marker-perms")
1431                {
1432                    logger.warn(&format!("Failed to quarantine marker path: {e}"));
1433                } else if let Err(e) = create_marker_in_repo_root(&repo_root) {
1434                    logger.warn(&format!(
1435                        "Failed to recreate enforcement marker after quarantine: {e}"
1436                    ));
1437                } else {
1438                    #[cfg(unix)]
1439                    set_readonly_mode_if_not_symlink(&marker_path, 0o444);
1440                }
1441            }
1442        }
1443    }
1444
1445    // Reinstall hooks if tampered (best-effort).
1446    match reinstall_hooks_if_tampered(logger) {
1447        Ok(true) => {
1448            result.tampering_detected = true;
1449            result
1450                .details
1451                .push("Git hooks tampered with or missing — reinstalled".to_string());
1452        }
1453        Err(e) => {
1454            logger.warn(&format!("Failed to verify/reinstall hooks: {e}"));
1455        }
1456        Ok(false) => {}
1457    }
1458
1459    // Verify/restore hook permissions (read-only executable 0o555).
1460    #[cfg(unix)]
1461    super::hooks::enforce_hook_permissions(&repo_root, logger);
1462
1463    // Verify/restore track file permissions (read-only 0o444).
1464    #[cfg(unix)]
1465    {
1466        use std::os::unix::fs::PermissionsExt;
1467        if matches!(fs::symlink_metadata(&track_file_path), Ok(m) if m.file_type().is_symlink()) {
1468            logger.warn("Track file path is a symlink — refusing to chmod and attempting repair");
1469            result.tampering_detected = true;
1470            result
1471                .details
1472                .push("Track file was a symlink — refused chmod".to_string());
1473            let _ = fs::remove_file(&track_file_path);
1474            if let Some(dir) =
1475                find_wrapper_dir_on_path().filter(|p| wrapper_dir_is_safe_existing_dir(p))
1476            {
1477                let _ = write_wrapper_track_file_atomic(&repo_root, &dir);
1478            }
1479        } else if let Ok(meta) = fs::metadata(&track_file_path) {
1480            if meta.is_dir() {
1481                logger.warn("Track file path is a directory — quarantining");
1482                result.tampering_detected = true;
1483                result
1484                    .details
1485                    .push("Track file was a directory — quarantined".to_string());
1486                if let Err(e) =
1487                    super::repo::quarantine_path_in_place(&track_file_path, "track-perms")
1488                {
1489                    logger.warn(&format!("Failed to quarantine track file path: {e}"));
1490                }
1491            }
1492            if meta.is_file() {
1493                let mode = meta.permissions().mode() & 0o777;
1494                if mode != 0o444 {
1495                    logger.warn(&format!(
1496                        "Track file permissions loosened ({mode:#o}) — restoring to 0o444"
1497                    ));
1498                    let mut perms = meta.permissions();
1499                    perms.set_mode(0o444);
1500                    let _ = fs::set_permissions(&track_file_path, perms);
1501                    result.tampering_detected = true;
1502                    result.details.push(format!(
1503                        "Track file permissions loosened ({mode:#o}) — restored to 0o444"
1504                    ));
1505                }
1506            }
1507        }
1508    }
1509
1510    // Detect unauthorized commits by comparing HEAD OID against baseline.
1511    if detect_unauthorized_commit(&repo_root) {
1512        logger.warn("CRITICAL: HEAD OID changed — unauthorized commit detected!");
1513        result.tampering_detected = true;
1514        result
1515            .details
1516            .push("HEAD OID changed since last check — unauthorized commit detected".to_string());
1517        // Update stored OID to current HEAD so Ralph's own subsequent commits
1518        // don't trigger false positives.
1519        capture_head_oid(&repo_root);
1520    }
1521
1522    result
1523}
1524
1525/// Remove the git wrapper temp directory using an explicit Ralph metadata dir.
1526fn cleanup_git_wrapper_dir_silent_at(ralph_dir: &Path) {
1527    let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
1528
1529    // Read the track file to find wrapper dir (best-effort).
1530    // Do not gate on sanitize — we want to clean up even if the ralph dir
1531    // has unexpected metadata (sanitize failure should not block cleanup).
1532    if let Some(wrapper_dir) = fs::read_to_string(&track_file)
1533        .ok()
1534        .map(|s| PathBuf::from(s.trim()))
1535    {
1536        // Treat track file as untrusted; only remove plausible wrapper dirs under temp.
1537        remove_wrapper_dir_and_path_entry(&wrapper_dir);
1538    }
1539
1540    // ALWAYS remove the track file. Hooks check marker OR track_file with ||
1541    // logic, so a surviving track file blocks commits even after the marker is
1542    // removed. The wrapper dir in /tmp is harmless and will be cleaned by the OS.
1543    #[cfg(unix)]
1544    add_owner_write_if_not_symlink(&track_file);
1545    let _ = fs::remove_file(&track_file);
1546}
1547
1548/// Clean up a prior wrapper dir tracked in the track file.
1549///
1550/// This prevents /tmp leaks when a prior run was SIGKILL'd and the
1551/// track file still points to an orphaned wrapper dir. It also removes
1552/// stale PATH entries for the old wrapper dir.
1553fn cleanup_prior_wrapper_from_track_file(repo_root: &Path) {
1554    let ralph_dir = super::repo::ralph_git_dir(repo_root);
1555    let Ok(ralph_dir_exists) = super::repo::sanitize_ralph_git_dir_at(&ralph_dir) else {
1556        return;
1557    };
1558    if !ralph_dir_exists {
1559        return;
1560    }
1561
1562    let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
1563    let Ok(content) = fs::read_to_string(&track_file) else {
1564        return;
1565    };
1566    let wrapper_dir = PathBuf::from(content.trim());
1567    if remove_wrapper_dir_and_path_entry(&wrapper_dir) {
1568        let _ = fs::remove_file(&track_file);
1569    }
1570}
1571
1572/// Clean up orphaned wrapper temp dir from a prior crashed run.
1573///
1574/// This is the public entry point for startup-time cleanup in
1575/// `prepare_agent_phase`. It delegates to `cleanup_prior_wrapper_from_track_file`.
1576pub fn cleanup_orphaned_wrapper_at(repo_root: &Path) {
1577    cleanup_prior_wrapper_from_track_file(repo_root);
1578}
1579
1580/// Verify that the wrapper temp dir and track file have been cleaned up.
1581///
1582/// Returns a list of remaining artifacts for diagnostic purposes.
1583/// An empty list means cleanup was successful.
1584#[must_use]
1585pub fn verify_wrapper_cleaned(repo_root: &Path) -> Vec<String> {
1586    let mut remaining = Vec::new();
1587    let track_file = super::repo::ralph_git_dir(repo_root).join(WRAPPER_TRACK_FILE_NAME);
1588    if track_file.exists() {
1589        remaining.push(format!("track file still exists: {}", track_file.display()));
1590        // Also check if the tracked dir still exists.
1591        if let Ok(content) = fs::read_to_string(&track_file) {
1592            let dir = PathBuf::from(content.trim());
1593            if dir.exists() {
1594                remaining.push(format!("wrapper temp dir still exists: {}", dir.display()));
1595            }
1596        }
1597    }
1598    remaining
1599}
1600
1601/// Best-effort cleanup for unexpected exits (Ctrl+C, early-return, panics).
1602///
1603/// Prefers the stored repo root (set during `start_agent_phase`) over CWD-based
1604/// discovery. Falls back to CWD-based `get_repo_root()` if the stored root is
1605/// unavailable.
1606pub fn cleanup_agent_phase_silent() {
1607    // Prefer stored repo root over CWD-based discovery.
1608    // Use try_lock to avoid deadlock if the main thread holds the lock
1609    // when SIGINT arrives.
1610    let repo_root = AGENT_PHASE_REPO_ROOT
1611        .try_lock()
1612        .ok()
1613        .and_then(|guard| guard.clone())
1614        .or_else(|| crate::git_helpers::get_repo_root().ok());
1615
1616    let Some(repo_root) = repo_root else {
1617        return;
1618    };
1619
1620    let stored_ralph_dir = AGENT_PHASE_RALPH_DIR
1621        .try_lock()
1622        .ok()
1623        .and_then(|guard| guard.clone());
1624    let stored_hooks_dir = AGENT_PHASE_HOOKS_DIR
1625        .try_lock()
1626        .ok()
1627        .and_then(|guard| guard.clone());
1628    cleanup_agent_phase_silent_at_internal(
1629        &repo_root,
1630        stored_ralph_dir.as_deref(),
1631        stored_hooks_dir.as_deref(),
1632    );
1633}
1634
1635/// Best-effort cleanup using an explicit repo root.
1636///
1637/// This is the consolidated cleanup function that removes all agent-phase
1638/// artifacts. All sub-operations use the provided repo root instead of
1639/// CWD-based discovery, ensuring reliability even if CWD has changed.
1640///
1641/// Unlike [`cleanup_agent_phase_silent`], this function does NOT read the
1642/// process-global hooks dir mutex. It derives the hooks directory from the
1643/// repo root, making it safe to call from parallel tests without global
1644/// state interference.
1645pub fn cleanup_agent_phase_silent_at(repo_root: &Path) {
1646    cleanup_agent_phase_silent_at_internal(repo_root, None, None);
1647}
1648
1649/// Best-effort cleanup for command exit paths that should preserve command outputs.
1650///
1651/// Removes only agent-phase protection artifacts (marker, wrapper, hooks, `.git/ralph`
1652/// metadata) and leaves working-tree outputs such as `.agent/commit-message.txt` intact.
1653pub fn cleanup_agent_phase_protections_silent_at(repo_root: &Path) {
1654    cleanup_agent_phase_protections_silent_at_internal(repo_root, None, None);
1655}
1656
1657#[cfg(any(test, feature = "test-utils"))]
1658pub fn set_agent_phase_paths_for_test(
1659    repo_root: Option<PathBuf>,
1660    ralph_dir: Option<PathBuf>,
1661    hooks_dir: Option<PathBuf>,
1662) {
1663    if let Ok(mut guard) = AGENT_PHASE_REPO_ROOT.lock() {
1664        *guard = repo_root;
1665    }
1666    if let Ok(mut guard) = AGENT_PHASE_RALPH_DIR.lock() {
1667        *guard = ralph_dir;
1668    }
1669    if let Ok(mut guard) = AGENT_PHASE_HOOKS_DIR.lock() {
1670        *guard = hooks_dir;
1671    }
1672}
1673
1674#[cfg(any(test, feature = "test-utils"))]
1675#[must_use]
1676pub fn get_agent_phase_paths_for_test() -> (Option<PathBuf>, Option<PathBuf>, Option<PathBuf>) {
1677    let repo_root = AGENT_PHASE_REPO_ROOT
1678        .lock()
1679        .ok()
1680        .and_then(|guard| guard.clone());
1681    let ralph_dir = AGENT_PHASE_RALPH_DIR
1682        .lock()
1683        .ok()
1684        .and_then(|guard| guard.clone());
1685    let hooks_dir = AGENT_PHASE_HOOKS_DIR
1686        .lock()
1687        .ok()
1688        .and_then(|guard| guard.clone());
1689    (repo_root, ralph_dir, hooks_dir)
1690}
1691
1692#[cfg(any(test, feature = "test-utils"))]
1693#[must_use]
1694pub fn agent_phase_test_lock() -> &'static Mutex<()> {
1695    static TEST_LOCK: Mutex<()> = Mutex::new(());
1696    &TEST_LOCK
1697}
1698
1699fn cleanup_agent_phase_silent_at_internal(
1700    repo_root: &Path,
1701    stored_ralph_dir: Option<&Path>,
1702    stored_hooks_dir: Option<&Path>,
1703) {
1704    cleanup_agent_phase_protections_silent_at_internal(
1705        repo_root,
1706        stored_ralph_dir,
1707        stored_hooks_dir,
1708    );
1709    cleanup_generated_files_silent_at(repo_root);
1710}
1711
1712fn cleanup_agent_phase_protections_silent_at_internal(
1713    repo_root: &Path,
1714    stored_ralph_dir: Option<&Path>,
1715    stored_hooks_dir: Option<&Path>,
1716) {
1717    let computed_ralph_dir;
1718    let ralph_dir = if let Some(ralph_dir) = stored_ralph_dir {
1719        ralph_dir
1720    } else {
1721        computed_ralph_dir = super::repo::ralph_git_dir(repo_root);
1722        &computed_ralph_dir
1723    };
1724    let resolved_hooks_dir = stored_hooks_dir.map(PathBuf::from).or_else(|| {
1725        super::repo::resolve_protection_scope_from(repo_root)
1726            .ok()
1727            .map(|scope| scope.hooks_dir)
1728    });
1729
1730    end_agent_phase_in_repo_at_ralph_dir(repo_root, ralph_dir);
1731    cleanup_git_wrapper_dir_silent_at(ralph_dir);
1732
1733    // Prefer repo-aware cleanup when discovery still works so worktree config
1734    // overrides and shared worktreeConfig state are restored alongside hooks.
1735    if super::repo::resolve_protection_scope_from(repo_root).is_ok() {
1736        super::hooks::uninstall_hooks_silent_at(repo_root);
1737    } else if let Some(hooks_dir) = resolved_hooks_dir.as_deref() {
1738        super::hooks::uninstall_hooks_silent_in_hooks_dir(hooks_dir);
1739    } else {
1740        uninstall_hooks_silent_at(repo_root);
1741    }
1742
1743    // Clean up any stray tmp files not yet removed before attempting remove_dir.
1744    // This handles .git-wrapper-dir.tmp.* files that were not present during
1745    // the earlier end_agent_phase_in_repo_at_ralph_dir call.
1746    cleanup_hook_scoping_state_files(ralph_dir);
1747    remove_scoped_hooks_dir_if_empty(ralph_dir);
1748    cleanup_stray_tmp_files_in_ralph_dir(ralph_dir);
1749    // Best-effort: remove the ralph dir itself now that all artifacts are gone.
1750    // end_agent_phase_in_repo_at_ralph_dir removed marker + head-oid and tried
1751    // remove_dir too early (track file was still present). Now the track file has
1752    // been cleaned by cleanup_git_wrapper_dir_silent_at, so the dir is empty.
1753    remove_ralph_dir_best_effort(ralph_dir);
1754
1755    cleanup_repo_root_ralph_dir_if_empty(repo_root);
1756
1757    clear_agent_phase_global_state();
1758}
1759
1760/// Best-effort removal of the ralph git directory after all artifacts are cleaned.
1761///
1762/// Called after [`end_agent_phase_in_repo`] and [`disable_git_wrapper`] have removed
1763/// all files from `.git/ralph/`. The directory should be empty at this point; this
1764/// call removes it so no empty directory is left behind.
1765///
1766/// Returns `true` when `.git/ralph` no longer exists after cleanup. Uses
1767/// [`fs::remove_dir`] (not `remove_dir_all`) — if the directory is non-empty
1768/// for any reason (e.g., a quarantine file from tamper detection), the call
1769/// leaves it in place for inspection and returns `false`.
1770#[must_use]
1771pub fn try_remove_ralph_dir(repo_root: &Path) -> bool {
1772    let ralph_dir = super::repo::ralph_git_dir(repo_root);
1773    let Ok(ralph_dir_exists) = super::repo::sanitize_ralph_git_dir_at(&ralph_dir) else {
1774        return !ralph_dir.exists();
1775    };
1776    if !ralph_dir_exists {
1777        return true;
1778    }
1779
1780    // Clean up stray temp files from interrupted atomic writes before attempting
1781    // remove_dir. Without this, remove_dir silently fails and the directory is
1782    // left behind across restarts.
1783    cleanup_stray_tmp_files_in_sanitized_ralph_dir(&ralph_dir);
1784    remove_scoped_hooks_dir_if_empty(&ralph_dir);
1785    match fs::remove_dir(&ralph_dir) {
1786        Ok(()) => true,
1787        Err(err) if err.kind() == io::ErrorKind::NotFound => true,
1788        Err(_) => !ralph_dir.exists(),
1789    }
1790}
1791
1792/// Verify that the Ralph metadata dir itself has been removed.
1793///
1794/// Returns a list of remaining artifacts for diagnostic purposes.
1795/// An empty list means cleanup was successful.
1796#[must_use]
1797pub fn verify_ralph_dir_removed(repo_root: &Path) -> Vec<String> {
1798    let ralph_dir = super::repo::ralph_git_dir(repo_root);
1799    let Ok(ralph_dir_exists) = super::repo::sanitize_ralph_git_dir_at(&ralph_dir) else {
1800        return vec![format!(
1801            "could not sanitize ralph directory before verification: {}",
1802            ralph_dir.display()
1803        )];
1804    };
1805    if !ralph_dir_exists {
1806        return Vec::new();
1807    }
1808
1809    let mut remaining = vec![format!("directory still exists: {}", ralph_dir.display())];
1810    match fs::read_dir(&ralph_dir) {
1811        Ok(entries) => {
1812            let mut names = entries
1813                .filter_map(Result::ok)
1814                .map(|entry| entry.file_name().to_string_lossy().into_owned())
1815                .collect::<Vec<_>>();
1816            names.sort();
1817            if !names.is_empty() {
1818                remaining.push(format!("remaining entries: {}", names.join(", ")));
1819            }
1820        }
1821        Err(err) => remaining.push(format!("could not inspect directory contents: {err}")),
1822    }
1823    remaining
1824}
1825
1826/// Remove generated files silently using an explicit repo root.
1827fn cleanup_generated_files_silent_at(repo_root: &Path) {
1828    for file in crate::files::io::agent_files::GENERATED_FILES {
1829        let absolute_path = repo_root.join(file);
1830        let _ = std::fs::remove_file(absolute_path);
1831    }
1832}
1833
1834fn cleanup_hook_scoping_state_files(ralph_dir: &Path) {
1835    for file_name in ["hooks-path.previous", "worktree-config.previous"] {
1836        let path = ralph_dir.join(file_name);
1837        #[cfg(unix)]
1838        add_owner_write_if_not_symlink(&path);
1839        let _ = fs::remove_file(path);
1840    }
1841}
1842
1843fn remove_scoped_hooks_dir_if_empty(ralph_dir: &Path) {
1844    let _ = fs::remove_dir(ralph_dir.join("hooks"));
1845}
1846
1847fn cleanup_repo_root_ralph_dir_if_empty(repo_root: &Path) {
1848    let fallback_ralph_dir = repo_root.join(".git/ralph");
1849    cleanup_hook_scoping_state_files(&fallback_ralph_dir);
1850    remove_scoped_hooks_dir_if_empty(&fallback_ralph_dir);
1851    cleanup_stray_tmp_files_in_ralph_dir(&fallback_ralph_dir);
1852    remove_ralph_dir_best_effort(&fallback_ralph_dir);
1853}
1854
1855fn remove_ralph_dir_best_effort(ralph_dir: &Path) {
1856    if fs::remove_dir(ralph_dir).is_ok() {
1857        return;
1858    }
1859    let Ok(meta) = fs::symlink_metadata(ralph_dir) else {
1860        return;
1861    };
1862    if meta.file_type().is_symlink() || !meta.is_dir() {
1863        return;
1864    }
1865    let _ = fs::remove_dir_all(ralph_dir);
1866}
1867
1868/// Clean up orphaned enforcement marker.
1869///
1870/// # Errors
1871///
1872/// Returns error if the operation fails.
1873pub fn cleanup_orphaned_marker(logger: &Logger) -> io::Result<()> {
1874    let repo_root = get_repo_root()?;
1875    let legacy_marker = legacy_marker_path(&repo_root);
1876    if fs::symlink_metadata(&legacy_marker).is_ok() {
1877        #[cfg(unix)]
1878        {
1879            add_owner_write_if_not_symlink(&legacy_marker);
1880        }
1881        fs::remove_file(&legacy_marker)?;
1882        logger.success("Removed orphaned enforcement marker");
1883        return Ok(());
1884    }
1885
1886    let ralph_dir = super::repo::ralph_git_dir(&repo_root);
1887    if !super::repo::sanitize_ralph_git_dir_at(&ralph_dir)? {
1888        logger.info("No orphaned marker found");
1889        return Ok(());
1890    }
1891    let marker_path = ralph_dir.join(MARKER_FILE_NAME);
1892
1893    if fs::symlink_metadata(&marker_path).is_ok() {
1894        // Make writable before removal (marker is created as read-only 0o444).
1895        #[cfg(unix)]
1896        {
1897            add_owner_write_if_not_symlink(&marker_path);
1898        }
1899        fs::remove_file(&marker_path)?;
1900        logger.success("Removed orphaned enforcement marker");
1901    } else {
1902        logger.info("No orphaned marker found");
1903    }
1904
1905    Ok(())
1906}
1907
1908/// Capture the current HEAD OID and write it to `<git-dir>/ralph/head-oid.txt`.
1909///
1910/// This is called at agent-phase start and after each Ralph-orchestrated commit
1911/// to establish the baseline for unauthorized commit detection.
1912pub fn capture_head_oid(repo_root: &Path) {
1913    let Ok(head_oid) = crate::git_helpers::get_current_head_oid_at(repo_root) else {
1914        return; // No HEAD yet (empty repo) — nothing to capture
1915    };
1916    let _ = write_head_oid_file_atomic(repo_root, head_oid.trim());
1917}
1918
1919fn write_head_oid_file_atomic(repo_root: &Path, oid: &str) -> io::Result<()> {
1920    let ralph_dir = super::repo::ensure_ralph_git_dir(repo_root)?;
1921
1922    let head_oid_path = ralph_dir.join(HEAD_OID_FILE_NAME);
1923    if matches!(fs::symlink_metadata(&head_oid_path), Ok(m) if m.file_type().is_symlink()) {
1924        return Err(io::Error::new(
1925            io::ErrorKind::InvalidData,
1926            "head-oid path is a symlink; refusing to write baseline",
1927        ));
1928    }
1929
1930    let tmp_path = ralph_dir.join(format!(
1931        ".head-oid.tmp.{}.{}",
1932        std::process::id(),
1933        std::time::SystemTime::now()
1934            .duration_since(std::time::UNIX_EPOCH)
1935            .unwrap_or_default()
1936            .as_nanos()
1937    ));
1938
1939    {
1940        let mut tf = OpenOptions::new()
1941            .write(true)
1942            .create_new(true)
1943            .open(&tmp_path)?;
1944        tf.write_all(oid.as_bytes())?;
1945        tf.write_all(b"\n")?;
1946        tf.flush()?;
1947        let _ = tf.sync_all();
1948    }
1949
1950    #[cfg(unix)]
1951    set_readonly_mode_if_not_symlink(&tmp_path, 0o444);
1952    #[cfg(windows)]
1953    {
1954        let mut perms = fs::metadata(&tmp_path)?.permissions();
1955        perms.set_readonly(true);
1956        fs::set_permissions(&tmp_path, perms)?;
1957    }
1958
1959    // Rename is symlink-safe: it replaces the directory entry.
1960    #[cfg(windows)]
1961    {
1962        if head_oid_path.exists() {
1963            let _ = fs::remove_file(&head_oid_path);
1964        }
1965    }
1966    fs::rename(&tmp_path, &head_oid_path)
1967}
1968
1969/// Detect unauthorized commits by comparing current HEAD against stored OID.
1970///
1971/// Returns `true` if HEAD has changed (indicating an unauthorized commit),
1972/// `false` if HEAD matches or comparison is not possible.
1973#[must_use]
1974pub fn detect_unauthorized_commit(repo_root: &Path) -> bool {
1975    let head_oid_path = super::repo::ralph_git_dir(repo_root).join(HEAD_OID_FILE_NAME);
1976    if matches!(fs::symlink_metadata(&head_oid_path), Ok(m) if m.file_type().is_symlink()) {
1977        return false;
1978    }
1979    let Ok(stored_oid) = fs::read_to_string(&head_oid_path) else {
1980        return false; // No stored OID — cannot compare
1981    };
1982    let stored_oid = stored_oid.trim();
1983    if stored_oid.is_empty() {
1984        return false;
1985    }
1986
1987    let Ok(current_oid) = crate::git_helpers::get_current_head_oid_at(repo_root) else {
1988        return false; // Cannot determine current HEAD
1989    };
1990
1991    current_oid.trim() != stored_oid
1992}
1993
1994/// Remove stray atomic-write temp files from the ralph git directory.
1995///
1996/// `write_head_oid_file_atomic` and `write_wrapper_track_file_atomic` create
1997/// temp files (`.head-oid.tmp.PID.NANOS` and `.git-wrapper-dir.tmp.PID.NANOS`)
1998/// and rename them atomically. If the process is killed or the rename fails the
1999/// temp file is left behind and blocks [`fs::remove_dir`] from removing the
2000/// directory.
2001///
2002/// Only files whose names start with the known temp prefixes are removed.
2003/// Quarantine files (`*.ralph.tampered.*`) and other unexpected files are
2004/// intentionally left in place.
2005fn cleanup_stray_tmp_files_in_ralph_dir(ralph_dir: &Path) {
2006    let Ok(ralph_dir_exists) = super::repo::sanitize_ralph_git_dir_at(ralph_dir) else {
2007        return;
2008    };
2009    if !ralph_dir_exists {
2010        return;
2011    }
2012
2013    cleanup_stray_tmp_files_in_sanitized_ralph_dir(ralph_dir);
2014}
2015
2016fn cleanup_stray_tmp_files_in_sanitized_ralph_dir(ralph_dir: &Path) {
2017    let Ok(entries) = fs::read_dir(ralph_dir) else {
2018        return;
2019    };
2020    for entry in entries.flatten() {
2021        let name = entry.file_name();
2022        let name_str = name.to_string_lossy();
2023        if name_str.starts_with(".head-oid.tmp.") || name_str.starts_with(".git-wrapper-dir.tmp.") {
2024            let path = entry.path();
2025            let Ok(meta) = fs::symlink_metadata(&path) else {
2026                continue;
2027            };
2028            let file_type = meta.file_type();
2029            if !file_type.is_file() || file_type.is_symlink() {
2030                continue;
2031            }
2032
2033            // Make writable before removal; temp files may have been set read-only
2034            // by write_head_oid_file_atomic (0o444 / readonly) or
2035            // write_wrapper_track_file_atomic (0o444 / readonly).
2036            relax_temp_cleanup_permissions_if_regular_file(&path);
2037            let _ = fs::remove_file(&path);
2038        }
2039    }
2040}
2041
2042/// Remove the head-oid tracking file, making it writable first if needed.
2043fn remove_head_oid_file_at(ralph_dir: &Path) {
2044    let head_oid_path = ralph_dir.join(HEAD_OID_FILE_NAME);
2045    if fs::symlink_metadata(&head_oid_path).is_err() {
2046        return;
2047    }
2048    #[cfg(unix)]
2049    {
2050        add_owner_write_if_not_symlink(&head_oid_path);
2051    }
2052    let _ = fs::remove_file(&head_oid_path);
2053}
2054
2055// ============================================================================
2056// Workspace-aware variants
2057// ============================================================================
2058
2059/// Relative path used by workspace-aware marker functions.
2060///
2061/// The marker lives at `<git-dir>/ralph/no_agent_commit` on the real filesystem.
2062/// The workspace abstraction represents this as a relative path so `MemoryWorkspace`
2063/// can test the create/remove/exists operations without a real git repository.
2064const MARKER_WORKSPACE_PATH: &str = ".git/ralph/no_agent_commit";
2065const LEGACY_MARKER_WORKSPACE_PATH: &str = ".no_agent_commit";
2066
2067/// Create the agent phase marker file using workspace abstraction.
2068///
2069/// This is a workspace-aware version of the marker file creation that uses
2070/// the Workspace trait for file I/O, making it testable with `MemoryWorkspace`.
2071///
2072/// # Arguments
2073///
2074/// * `workspace` - The workspace to write to
2075///
2076/// # Returns
2077///
2078/// Returns `Ok(())` on success, or an error if the file cannot be created.
2079///
2080/// # Errors
2081///
2082/// Returns error if the operation fails.
2083pub fn create_marker_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
2084    workspace.write(Path::new(MARKER_WORKSPACE_PATH), "")
2085}
2086
2087/// Remove the agent phase marker file using workspace abstraction.
2088///
2089/// This is a workspace-aware version of the marker file removal that uses
2090/// the Workspace trait for file I/O, making it testable with `MemoryWorkspace`.
2091///
2092/// # Arguments
2093///
2094/// * `workspace` - The workspace to operate on
2095///
2096/// # Returns
2097///
2098/// Returns `Ok(())` on success (including if file doesn't exist).
2099///
2100/// # Errors
2101///
2102/// Returns error if the operation fails.
2103pub fn remove_marker_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
2104    workspace.remove_if_exists(Path::new(MARKER_WORKSPACE_PATH))
2105}
2106
2107/// Check if the agent phase marker file exists using workspace abstraction.
2108///
2109/// This is a workspace-aware version that uses the Workspace trait for file I/O,
2110/// making it testable with `MemoryWorkspace`.
2111///
2112/// # Arguments
2113///
2114/// * `workspace` - The workspace to check
2115///
2116/// # Returns
2117///
2118/// Returns `true` if the marker file exists, `false` otherwise.
2119pub fn marker_exists_with_workspace(workspace: &dyn Workspace) -> bool {
2120    workspace.exists(Path::new(MARKER_WORKSPACE_PATH))
2121}
2122
2123/// Clean up orphaned marker file using workspace abstraction.
2124///
2125/// This is a workspace-aware version of `cleanup_orphaned_marker` that uses
2126/// the Workspace trait for file I/O, making it testable with `MemoryWorkspace`.
2127///
2128/// # Arguments
2129///
2130/// * `workspace` - The workspace to operate on
2131/// * `logger` - Logger for output messages
2132///
2133/// # Returns
2134///
2135/// Returns `Ok(())` on success.
2136///
2137/// # Errors
2138///
2139/// Returns error if the operation fails.
2140pub fn cleanup_orphaned_marker_with_workspace(
2141    workspace: &dyn Workspace,
2142    logger: &Logger,
2143) -> io::Result<()> {
2144    let marker_path = Path::new(MARKER_WORKSPACE_PATH);
2145    let legacy_marker_path = Path::new(LEGACY_MARKER_WORKSPACE_PATH);
2146    let removed_marker = if workspace.exists(marker_path) {
2147        workspace.remove(marker_path)?;
2148        true
2149    } else {
2150        false
2151    };
2152    let removed_legacy_marker = if workspace.exists(legacy_marker_path) {
2153        workspace.remove(legacy_marker_path)?;
2154        true
2155    } else {
2156        false
2157    };
2158
2159    if removed_marker || removed_legacy_marker {
2160        logger.success("Removed orphaned enforcement marker");
2161    } else {
2162        logger.info("No orphaned marker found");
2163    }
2164
2165    Ok(())
2166}
2167
2168#[cfg(test)]
2169mod tests {
2170    use super::*;
2171    use crate::workspace::MemoryWorkspace;
2172    use std::sync::Mutex;
2173
2174    static ENV_LOCK: Mutex<()> = Mutex::new(());
2175
2176    struct RestoreEnv {
2177        original_cwd: PathBuf,
2178        original_path: String,
2179    }
2180
2181    struct ClearAgentPhaseStateOnDrop;
2182
2183    impl Drop for ClearAgentPhaseStateOnDrop {
2184        fn drop(&mut self) {
2185            clear_agent_phase_global_state();
2186        }
2187    }
2188
2189    impl Drop for RestoreEnv {
2190        fn drop(&mut self) {
2191            let _ = std::env::set_current_dir(&self.original_cwd);
2192            std::env::set_var("PATH", &self.original_path);
2193        }
2194    }
2195
2196    fn test_wrapper_content() -> String {
2197        make_wrapper_content(
2198            "git",
2199            "/tmp/.git/ralph/no_agent_commit",
2200            "/tmp/.git/ralph/git-wrapper-dir.txt",
2201            "/tmp/repo",
2202            "/tmp/repo/.git",
2203        )
2204    }
2205
2206    #[test]
2207    fn test_wrapper_script_handles_c_flag_before_subcommand() {
2208        // Verify the wrapper script iterates through arguments to skip global flags
2209        // like `-C /path`, `--git-dir=.git`, etc. before identifying the subcommand.
2210        // This ensures `git -C /path commit` is correctly blocked, not just `git commit`.
2211        let content = test_wrapper_content();
2212
2213        assert!(
2214            content.contains("skip_next"),
2215            "wrapper must implement skip_next logic for global flags; got:\n{content}"
2216        );
2217        assert!(
2218            content.contains("-C|--git-dir|--work-tree"),
2219            "wrapper must recognize -C and --git-dir global flags; got:\n{content}"
2220        );
2221        assert!(
2222            content.contains("for arg in"),
2223            "wrapper must iterate arguments to find subcommand; got:\n{content}"
2224        );
2225    }
2226
2227    #[test]
2228    fn test_wrapper_script_treats_git_dir_match_as_sufficient_scope_activation() {
2229        let content = test_wrapper_content();
2230
2231        assert!(
2232            content.contains("target_git_dir=$( 'git' \"${repo_args[@]}\" rev-parse --path-format=absolute --git-dir 2>/dev/null || true )"),
2233            "wrapper must resolve target git-dir independently of show-toplevel; got:\n{content}"
2234        );
2235        assert!(
2236            content.contains("[ \"$normalized_target_git_dir\" = \"$active_git_dir\" ]"),
2237            "wrapper must activate protection when git-dir matches even without a repo root; got:\n{content}"
2238        );
2239    }
2240
2241    #[test]
2242    fn test_create_marker_with_workspace() {
2243        let workspace = MemoryWorkspace::new_test();
2244
2245        // Marker should not exist initially
2246        assert!(!marker_exists_with_workspace(&workspace));
2247
2248        // Create marker
2249        create_marker_with_workspace(&workspace).unwrap();
2250
2251        // Marker should now exist
2252        assert!(marker_exists_with_workspace(&workspace));
2253    }
2254
2255    #[test]
2256    fn test_remove_marker_with_workspace() {
2257        let workspace = MemoryWorkspace::new_test();
2258
2259        // Create marker first
2260        create_marker_with_workspace(&workspace).unwrap();
2261        assert!(marker_exists_with_workspace(&workspace));
2262
2263        // Remove marker
2264        remove_marker_with_workspace(&workspace).unwrap();
2265
2266        // Marker should no longer exist
2267        assert!(!marker_exists_with_workspace(&workspace));
2268    }
2269
2270    #[test]
2271    fn test_remove_marker_with_workspace_nonexistent() {
2272        let workspace = MemoryWorkspace::new_test();
2273
2274        // Removing non-existent marker should succeed silently
2275        remove_marker_with_workspace(&workspace).unwrap();
2276        assert!(!marker_exists_with_workspace(&workspace));
2277    }
2278
2279    #[test]
2280    fn test_cleanup_orphaned_marker_with_workspace_exists() {
2281        let workspace = MemoryWorkspace::new_test().with_file(".no_agent_commit", "");
2282        let logger = Logger::new(crate::logger::Colors { enabled: false });
2283
2284        // Create an orphaned marker
2285        create_marker_with_workspace(&workspace).unwrap();
2286        assert!(marker_exists_with_workspace(&workspace));
2287        assert!(workspace.exists(Path::new(".no_agent_commit")));
2288
2289        // Clean up should remove it
2290        cleanup_orphaned_marker_with_workspace(&workspace, &logger).unwrap();
2291        assert!(!marker_exists_with_workspace(&workspace));
2292        assert!(!workspace.exists(Path::new(".no_agent_commit")));
2293    }
2294
2295    #[test]
2296    fn test_cleanup_orphaned_marker_with_workspace_not_exists() {
2297        let workspace = MemoryWorkspace::new_test();
2298        let logger = Logger::new(crate::logger::Colors { enabled: false });
2299
2300        // No marker exists
2301        assert!(!marker_exists_with_workspace(&workspace));
2302
2303        // Clean up should succeed without error
2304        cleanup_orphaned_marker_with_workspace(&workspace, &logger).unwrap();
2305        assert!(!marker_exists_with_workspace(&workspace));
2306        assert_eq!(MARKER_FILE_NAME, "no_agent_commit");
2307        assert_eq!(WRAPPER_TRACK_FILE_NAME, "git-wrapper-dir.txt");
2308        assert_eq!(HEAD_OID_FILE_NAME, "head-oid.txt");
2309    }
2310
2311    #[test]
2312    fn test_wrapper_script_handles_config_flag_before_subcommand() {
2313        // Verify the wrapper script handles --config (git 2.46+ alias for -c)
2314        // as a flag that takes a value argument, so that
2315        // `git --config core.hooksPath=/dev/null commit` correctly identifies
2316        // "commit" as the subcommand (by skipping the --config value argument).
2317        let content = test_wrapper_content();
2318
2319        assert!(
2320            content.contains("--config|"),
2321            "wrapper must recognize --config as a flag with separate value; got:\n{content}"
2322        );
2323        assert!(
2324            content.contains("--config=*"),
2325            "wrapper must handle --config=value syntax; got:\n{content}"
2326        );
2327    }
2328
2329    #[test]
2330    fn test_wrapper_script_enforces_read_only_allowlist() {
2331        let content = test_wrapper_content();
2332
2333        assert!(
2334            content.contains("read-only allowlist"),
2335            "wrapper should describe allowlist behavior; got:\n{content}"
2336        );
2337        assert!(
2338            content.contains("status|log|diff|show|rev-parse|ls-files|describe"),
2339            "wrapper should explicitly allow read-only lookup commands; got:\n{content}"
2340        );
2341    }
2342
2343    #[test]
2344    fn test_wrapper_script_blocks_stash_except_list() {
2345        let content = test_wrapper_content();
2346        assert!(
2347            content.contains("only 'stash list' allowed"),
2348            "wrapper should only allow stash list; got:\n{content}"
2349        );
2350    }
2351
2352    #[test]
2353    fn test_wrapper_script_blocks_branch_positional_args() {
2354        let content = test_wrapper_content();
2355        assert!(
2356            content.contains("git branch disabled during agent phase"),
2357            "wrapper should block positional git branch invocations via the branch allowlist; got:\n{content}"
2358        );
2359    }
2360
2361    #[test]
2362    fn test_wrapper_script_blocks_flag_only_mutating_branch_forms() {
2363        let content = test_wrapper_content();
2364        assert!(
2365            content.contains("--unset-upstream"),
2366            "wrapper branch allowlist should explicitly reject mutating flag-only forms; got:\n{content}"
2367        );
2368    }
2369
2370    #[test]
2371    fn test_wrapper_script_uses_absolute_marker_paths() {
2372        let content = test_wrapper_content();
2373        // Wrapper now embeds absolute paths to marker and track file, not a repo root variable.
2374        assert!(
2375            !content.contains("protected_repo_root"),
2376            "wrapper should not use protected_repo_root variable; got:\n{content}"
2377        );
2378        assert!(
2379            content.contains("marker='/tmp/.git/ralph/no_agent_commit'"),
2380            "wrapper should embed absolute marker path; got:\n{content}"
2381        );
2382        assert!(
2383            content.contains("track_file='/tmp/.git/ralph/git-wrapper-dir.txt'"),
2384            "wrapper should embed absolute track file path; got:\n{content}"
2385        );
2386    }
2387
2388    #[test]
2389    fn test_protection_check_result_default_is_no_tampering() {
2390        let result = ProtectionCheckResult::default();
2391        assert!(!result.tampering_detected);
2392        assert!(result.details.is_empty());
2393    }
2394
2395    #[test]
2396    fn test_wrapper_script_unsets_git_env_vars() {
2397        let content = test_wrapper_content();
2398        // Wrapper must unset GIT_DIR, GIT_WORK_TREE, and GIT_EXEC_PATH
2399        // when agent-phase protections are active to prevent env var bypass.
2400        for var in &["GIT_DIR", "GIT_WORK_TREE", "GIT_EXEC_PATH"] {
2401            assert!(
2402                content.contains(&format!("unset {var}")),
2403                "wrapper must unset {var} when marker exists; got:\n{content}"
2404            );
2405        }
2406    }
2407
2408    #[test]
2409    fn test_wrapper_script_documents_command_builtin_behavior() {
2410        let content = test_wrapper_content();
2411        // Wrapper should document that `command git` still routes through
2412        // the PATH wrapper (command only skips shell functions/aliases, not PATH entries).
2413        assert!(
2414            content.contains("command") && content.contains("PATH"),
2415            "wrapper must document that `command` builtin still routes through PATH wrapper; got:\n{content}"
2416        );
2417    }
2418
2419    // =========================================================================
2420    // HEAD OID comparison tests
2421    // =========================================================================
2422
2423    #[test]
2424    fn test_detect_unauthorized_commit_no_stored_oid() {
2425        // When no head-oid.txt exists, detection should return false (no panic).
2426        let tmp = tempfile::tempdir().unwrap();
2427        assert!(!detect_unauthorized_commit(tmp.path()));
2428    }
2429
2430    #[test]
2431    fn test_detect_unauthorized_commit_empty_stored_oid() {
2432        let tmp = tempfile::tempdir().unwrap();
2433        // Head OID now lives in <git-dir>/ralph/ (fallback: .git/ralph/ for plain temp dirs)
2434        let ralph_dir = tmp.path().join(".git").join("ralph");
2435        fs::create_dir_all(&ralph_dir).unwrap();
2436        fs::write(ralph_dir.join("head-oid.txt"), "").unwrap();
2437        assert!(!detect_unauthorized_commit(tmp.path()));
2438    }
2439
2440    #[test]
2441    fn test_write_wrapper_track_file_atomic_repairs_directory_tamper() {
2442        // If the wrapper track file path exists as a directory, treat it as tampering.
2443        // The wrapper must recover (quarantine/remove the directory) and write a real file.
2444        let tmp = tempfile::tempdir().unwrap();
2445        let repo_root = tmp.path();
2446
2447        // Track file now lives in .git/ralph/ (fallback for plain temp dirs).
2448        let ralph_dir = repo_root.join(".git").join("ralph");
2449        fs::create_dir_all(&ralph_dir).unwrap();
2450
2451        // Create a directory at the track file path.
2452        let track_dir_path = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
2453        fs::create_dir_all(&track_dir_path).unwrap();
2454        fs::write(track_dir_path.join("payload.txt"), "do not delete").unwrap();
2455
2456        let wrapper_dir = repo_root.join("some-wrapper-dir");
2457        fs::create_dir_all(&wrapper_dir).unwrap();
2458
2459        write_wrapper_track_file_atomic(repo_root, &wrapper_dir).unwrap();
2460
2461        let track_file_path = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
2462        let meta = fs::metadata(&track_file_path).unwrap();
2463        assert!(meta.is_file(), "track file path should be a file");
2464        let content = fs::read_to_string(&track_file_path).unwrap();
2465        assert!(
2466            content.contains(&wrapper_dir.display().to_string()),
2467            "track file should contain wrapper dir path; got: {content}"
2468        );
2469
2470        // Quarantine should preserve prior directory contents by renaming in-place.
2471        let quarantined = fs::read_dir(&ralph_dir)
2472            .unwrap()
2473            .filter_map(Result::ok)
2474            .any(|e| {
2475                e.file_name()
2476                    .to_string_lossy()
2477                    .starts_with("git-wrapper-dir.txt.ralph.tampered.track")
2478            });
2479        assert!(
2480            quarantined,
2481            "expected quarantined track dir entry in .git/ralph/"
2482        );
2483    }
2484
2485    #[test]
2486    fn test_repair_marker_path_converts_directory_to_regular_file() {
2487        // If the marker path exists as a directory, treat it as tampering and
2488        // recover by quarantining it and creating a regular file marker.
2489        let tmp = tempfile::tempdir().unwrap();
2490        let repo_root = tmp.path();
2491
2492        // Marker now lives in .git/ralph/ (fallback for plain temp dirs).
2493        let ralph_dir = repo_root.join(".git").join("ralph");
2494        fs::create_dir_all(&ralph_dir).unwrap();
2495        let marker_path = ralph_dir.join(MARKER_FILE_NAME);
2496        fs::create_dir_all(&marker_path).unwrap();
2497
2498        // Function under test: must quarantine the directory and create a file marker.
2499        repair_marker_path_if_tampered(repo_root).unwrap();
2500
2501        let meta = fs::metadata(&marker_path).unwrap();
2502        assert!(meta.is_file(), "marker path should be a regular file");
2503
2504        let quarantined = fs::read_dir(&ralph_dir)
2505            .unwrap()
2506            .filter_map(Result::ok)
2507            .any(|e| {
2508                e.file_name()
2509                    .to_string_lossy()
2510                    .starts_with("no_agent_commit.ralph.tampered.marker")
2511            });
2512        assert!(
2513            quarantined,
2514            "expected quarantined marker dir entry in .git/ralph/"
2515        );
2516    }
2517
2518    #[cfg(unix)]
2519    #[test]
2520    fn test_create_marker_in_repo_root_quarantines_special_file() {
2521        use std::os::unix::fs::symlink;
2522        use std::os::unix::fs::FileTypeExt;
2523        use std::os::unix::net::UnixListener;
2524
2525        // If the marker path exists as a special file (e.g., socket/FIFO),
2526        // we must not treat it as a valid marker. Quarantine/replace it with
2527        // a regular file so the `-f` checks used by hooks/wrapper cannot be bypassed.
2528        let tmp = tempfile::tempdir().unwrap();
2529        let repo_root = tmp.path();
2530
2531        // Marker now lives in .git/ralph/ (fallback for plain temp dirs).
2532        let ralph_dir = repo_root.join(".git").join("ralph");
2533        fs::create_dir_all(&ralph_dir).unwrap();
2534        let marker_path = ralph_dir.join(MARKER_FILE_NAME);
2535        let created_socket = match UnixListener::bind(&marker_path) {
2536            Ok(listener) => {
2537                drop(listener);
2538                true
2539            }
2540            Err(err) if err.kind() == io::ErrorKind::PermissionDenied => {
2541                let fallback_target = ralph_dir.join("marker-symlink-target");
2542                fs::write(&fallback_target, b"blocked special file fallback").unwrap();
2543                symlink(&fallback_target, &marker_path).unwrap();
2544                false
2545            }
2546            Err(err) => panic!("failed to create non-regular marker path: {err}"),
2547        };
2548
2549        let ft = fs::symlink_metadata(&marker_path).unwrap().file_type();
2550        assert!(
2551            ft.is_socket() || (!created_socket && ft.is_symlink()),
2552            "precondition: marker path should be a socket or fallback symlink"
2553        );
2554
2555        create_marker_in_repo_root(repo_root).unwrap();
2556
2557        let meta = fs::symlink_metadata(&marker_path).unwrap();
2558        assert!(meta.is_file(), "marker path should be a regular file");
2559
2560        let quarantined = fs::read_dir(&ralph_dir)
2561            .unwrap()
2562            .filter_map(Result::ok)
2563            .any(|e| {
2564                e.file_name()
2565                    .to_string_lossy()
2566                    .starts_with("no_agent_commit.ralph.tampered.marker")
2567            });
2568        assert!(
2569            quarantined,
2570            "expected quarantined special marker entry in .git/ralph/"
2571        );
2572    }
2573
2574    #[cfg(unix)]
2575    #[test]
2576    fn test_ensure_agent_phase_protections_recreates_marker_after_permissions_quarantine() {
2577        use std::time::Duration;
2578
2579        let _lock = ENV_LOCK.lock().unwrap();
2580
2581        let repo_dir = tempfile::tempdir().unwrap();
2582        let _repo = git2::Repository::init(repo_dir.path()).unwrap();
2583
2584        let original_cwd = std::env::current_dir().unwrap();
2585        let original_path = std::env::var("PATH").unwrap_or_default();
2586        let _restore = RestoreEnv {
2587            original_cwd,
2588            original_path: original_path.clone(),
2589        };
2590
2591        std::env::set_current_dir(repo_dir.path()).unwrap();
2592
2593        // Seed a valid marker so marker_exists is computed true.
2594        // Marker lives in <git-dir>/ralph/ — for a real repo that is .git/ralph/.
2595        let ralph_dir = repo_dir.path().join(".git").join("ralph");
2596        fs::create_dir_all(&ralph_dir).unwrap();
2597        let marker_path = ralph_dir.join(MARKER_FILE_NAME);
2598        fs::write(&marker_path, b"").unwrap();
2599        assert!(fs::symlink_metadata(&marker_path).unwrap().is_file());
2600
2601        // Provide a plausible wrapper dir on PATH so the protection check does enough work
2602        // to let us deterministically tamper with the marker after marker_exists is computed.
2603        let wrapper_dir = tempfile::Builder::new()
2604            .prefix(WRAPPER_DIR_PREFIX)
2605            .tempdir_in(std::env::temp_dir())
2606            .unwrap();
2607        // Intentionally bloat PATH to slow down the protection check between the
2608        // marker_exists snapshot and the marker-permissions verification block.
2609        let slow_paths = (0..1000)
2610            .map(|i| {
2611                format!(
2612                    "{}/ralph-nonexistent-path-{i}",
2613                    std::env::temp_dir().display()
2614                )
2615            })
2616            .collect::<Vec<_>>()
2617            .join(":");
2618        let new_path = format!(
2619            "{}:{slow_paths}:{original_path}",
2620            wrapper_dir.path().display()
2621        );
2622        std::env::set_var("PATH", new_path);
2623
2624        // Run the protection check in another thread so this thread can reliably
2625        // perform the mid-check tampering before the marker-permissions block runs.
2626        let ensure_thread = std::thread::spawn(|| {
2627            let logger = Logger::new(crate::logger::Colors { enabled: false });
2628            ensure_agent_phase_protections(&logger)
2629        });
2630
2631        // Ensure the protection check has taken its marker_exists snapshot.
2632        std::thread::sleep(Duration::from_millis(10));
2633
2634        let _ = fs::remove_file(&marker_path);
2635        let _ = fs::remove_dir_all(&marker_path);
2636        fs::create_dir_all(marker_path.parent().unwrap()).unwrap();
2637        fs::create_dir(&marker_path).unwrap();
2638
2639        let _result = ensure_thread.join().unwrap();
2640
2641        // Regression: even if the marker is swapped to a non-file mid-check, the final state
2642        // must include a regular file marker.
2643        let meta = fs::symlink_metadata(&marker_path).unwrap();
2644        assert!(
2645            meta.is_file(),
2646            "marker should be recreated as a regular file"
2647        );
2648    }
2649
2650    // =========================================================================
2651    // cleanup_agent_phase_silent_at tests
2652    // =========================================================================
2653
2654    #[test]
2655    fn test_cleanup_agent_phase_silent_at_removes_marker() {
2656        let tmp = tempfile::tempdir().unwrap();
2657        let repo_root = tmp.path();
2658        // Marker lives in .git/ralph/ (fallback for plain temp dirs).
2659        let ralph_dir = repo_root.join(".git").join("ralph");
2660        fs::create_dir_all(&ralph_dir).unwrap();
2661        let marker = ralph_dir.join(MARKER_FILE_NAME);
2662        fs::write(&marker, "").unwrap();
2663
2664        cleanup_agent_phase_silent_at(repo_root);
2665
2666        assert!(
2667            !marker.exists(),
2668            "marker should be removed by cleanup_agent_phase_silent_at"
2669        );
2670    }
2671
2672    #[test]
2673    fn test_cleanup_agent_phase_silent_at_removes_head_oid() {
2674        let tmp = tempfile::tempdir().unwrap();
2675        let repo_root = tmp.path();
2676        // Head OID lives in .git/ralph/ (fallback for plain temp dirs).
2677        let ralph_dir = repo_root.join(".git").join("ralph");
2678        fs::create_dir_all(&ralph_dir).unwrap();
2679        let head_oid = ralph_dir.join(HEAD_OID_FILE_NAME);
2680        fs::write(&head_oid, "abc123\n").unwrap();
2681
2682        cleanup_agent_phase_silent_at(repo_root);
2683
2684        assert!(
2685            !head_oid.exists(),
2686            "head-oid.txt should be removed by cleanup_agent_phase_silent_at"
2687        );
2688    }
2689
2690    #[test]
2691    fn test_cleanup_agent_phase_silent_at_removes_generated_files() {
2692        let tmp = tempfile::tempdir().unwrap();
2693        let repo_root = tmp.path();
2694        let agent_dir = repo_root.join(".agent");
2695        fs::create_dir_all(&agent_dir).unwrap();
2696        // Enforcement state is now in .git/ralph/ — NOT in working tree.
2697        let ralph_dir = repo_root.join(".git").join("ralph");
2698        fs::create_dir_all(&ralph_dir).unwrap();
2699        fs::write(ralph_dir.join(MARKER_FILE_NAME), "").unwrap();
2700
2701        // Working-tree generated files.
2702        fs::write(agent_dir.join("PLAN.md"), "plan").unwrap();
2703        fs::write(agent_dir.join("commit-message.txt"), "msg").unwrap();
2704        fs::write(agent_dir.join("checkpoint.json.tmp"), "{}").unwrap();
2705
2706        cleanup_agent_phase_silent_at(repo_root);
2707
2708        // Enforcement-state files removed via end_agent_phase_in_repo.
2709        assert!(
2710            !ralph_dir.join(MARKER_FILE_NAME).exists(),
2711            "marker should be removed by cleanup_agent_phase_silent_at"
2712        );
2713
2714        // Working-tree GENERATED_FILES also removed.
2715        for file in crate::files::io::agent_files::GENERATED_FILES {
2716            let path = repo_root.join(file);
2717            assert!(
2718                !path.exists(),
2719                "{file} should be removed by cleanup_agent_phase_silent_at"
2720            );
2721        }
2722    }
2723
2724    #[test]
2725    fn test_cleanup_agent_phase_silent_at_removes_wrapper_track_file() {
2726        let tmp = tempfile::tempdir().unwrap();
2727        let repo_root = tmp.path();
2728        // Track file lives in .git/ralph/ (fallback for plain temp dirs).
2729        let ralph_dir = repo_root.join(".git").join("ralph");
2730        fs::create_dir_all(&ralph_dir).unwrap();
2731
2732        // Create a track file pointing to a non-existent wrapper dir (safe to clean)
2733        let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
2734        fs::write(&track_file, "/nonexistent/wrapper/dir\n").unwrap();
2735
2736        cleanup_agent_phase_silent_at(repo_root);
2737
2738        assert!(
2739            !track_file.exists(),
2740            "wrapper track file should be removed by cleanup_agent_phase_silent_at"
2741        );
2742    }
2743
2744    #[test]
2745    fn test_cleanup_agent_phase_silent_at_idempotent() {
2746        let tmp = tempfile::tempdir().unwrap();
2747        let repo_root = tmp.path();
2748
2749        // Running on an empty directory should not panic or error
2750        cleanup_agent_phase_silent_at(repo_root);
2751        cleanup_agent_phase_silent_at(repo_root);
2752    }
2753
2754    // =========================================================================
2755    // AGENT_PHASE_REPO_ROOT global tests
2756    // =========================================================================
2757
2758    #[test]
2759    fn test_agent_phase_repo_root_mutex_is_accessible() {
2760        // Verify the global Mutex is lockable (not poisoned or stuck).
2761        assert!(
2762            AGENT_PHASE_REPO_ROOT.try_lock().is_ok(),
2763            "AGENT_PHASE_REPO_ROOT mutex should be lockable"
2764        );
2765    }
2766
2767    // =========================================================================
2768    // cleanup_prior_wrapper / cleanup_orphaned_wrapper_at tests
2769    // =========================================================================
2770
2771    #[test]
2772    fn test_cleanup_prior_wrapper_removes_tracked_dir() {
2773        let _lock = ENV_LOCK.lock().unwrap();
2774        let tmp = tempfile::tempdir().unwrap();
2775        let repo_root = tmp.path();
2776        // Track file now lives in .git/ralph/ (fallback for plain temp dirs).
2777        let ralph_dir = repo_root.join(".git").join("ralph");
2778        fs::create_dir_all(&ralph_dir).unwrap();
2779
2780        // Create a fake wrapper dir in temp with the correct prefix.
2781        let wrapper_dir = tempfile::Builder::new()
2782            .prefix(WRAPPER_DIR_PREFIX)
2783            .tempdir()
2784            .unwrap();
2785        let wrapper_dir_path = wrapper_dir.keep();
2786
2787        // Write track file pointing to the wrapper dir.
2788        let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
2789        fs::write(&track_file, format!("{}\n", wrapper_dir_path.display())).unwrap();
2790
2791        assert!(
2792            wrapper_dir_path.exists(),
2793            "precondition: wrapper dir exists"
2794        );
2795
2796        cleanup_orphaned_wrapper_at(repo_root);
2797
2798        assert!(
2799            !wrapper_dir_path.exists(),
2800            "wrapper dir should be removed by cleanup"
2801        );
2802        assert!(
2803            !track_file.exists(),
2804            "track file should be removed by cleanup"
2805        );
2806    }
2807
2808    #[test]
2809    fn test_cleanup_prior_wrapper_no_track_file() {
2810        let tmp = tempfile::tempdir().unwrap();
2811        let repo_root = tmp.path();
2812
2813        // No track file exists — cleanup should be a no-op.
2814        cleanup_orphaned_wrapper_at(repo_root);
2815
2816        // No panic, no error.
2817    }
2818
2819    #[test]
2820    fn test_cleanup_prior_wrapper_stale_track_file() {
2821        let tmp = tempfile::tempdir().unwrap();
2822        let repo_root = tmp.path();
2823        // Track file now lives in .git/ralph/ (fallback for plain temp dirs).
2824        let ralph_dir = repo_root.join(".git").join("ralph");
2825        fs::create_dir_all(&ralph_dir).unwrap();
2826
2827        // Track file points to a non-existent dir.
2828        let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
2829        fs::write(&track_file, "/nonexistent/ralph-git-wrapper-stale\n").unwrap();
2830
2831        cleanup_orphaned_wrapper_at(repo_root);
2832
2833        assert!(
2834            !track_file.exists(),
2835            "stale track file should be removed by cleanup"
2836        );
2837    }
2838
2839    // =========================================================================
2840    // verify_wrapper_cleaned tests
2841    // =========================================================================
2842
2843    #[test]
2844    fn test_verify_wrapper_cleaned_empty_when_clean() {
2845        let tmp = tempfile::tempdir().unwrap();
2846        let repo_root = tmp.path();
2847
2848        let remaining = verify_wrapper_cleaned(repo_root);
2849        assert!(
2850            remaining.is_empty(),
2851            "verify_wrapper_cleaned should return empty when no artifacts remain"
2852        );
2853    }
2854
2855    #[test]
2856    fn test_verify_wrapper_cleaned_reports_remaining_track_file() {
2857        let tmp = tempfile::tempdir().unwrap();
2858        let repo_root = tmp.path();
2859        // Track file now lives in .git/ralph/ (fallback for plain temp dirs).
2860        let ralph_dir = repo_root.join(".git").join("ralph");
2861        fs::create_dir_all(&ralph_dir).unwrap();
2862
2863        let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
2864        fs::write(&track_file, "/nonexistent/dir\n").unwrap();
2865
2866        let remaining = verify_wrapper_cleaned(repo_root);
2867        assert!(
2868            !remaining.is_empty(),
2869            "verify_wrapper_cleaned should report remaining track file"
2870        );
2871        assert!(
2872            remaining[0].contains("track file still exists"),
2873            "should mention track file: {remaining:?}"
2874        );
2875    }
2876
2877    #[test]
2878    fn test_verify_wrapper_cleaned_reports_remaining_dir() {
2879        let tmp = tempfile::tempdir().unwrap();
2880        let repo_root = tmp.path();
2881        // Track file now lives in .git/ralph/ (fallback for plain temp dirs).
2882        let ralph_dir = repo_root.join(".git").join("ralph");
2883        fs::create_dir_all(&ralph_dir).unwrap();
2884
2885        // Create a real wrapper dir that still exists.
2886        let wrapper_dir = tempfile::Builder::new()
2887            .prefix(WRAPPER_DIR_PREFIX)
2888            .tempdir()
2889            .unwrap();
2890        let wrapper_dir_path = wrapper_dir.keep();
2891
2892        let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
2893        fs::write(&track_file, format!("{}\n", wrapper_dir_path.display())).unwrap();
2894
2895        let remaining = verify_wrapper_cleaned(repo_root);
2896        assert!(
2897            remaining.len() >= 2,
2898            "should report both track file and wrapper dir: {remaining:?}"
2899        );
2900
2901        // Clean up the wrapper dir manually.
2902        let _ = fs::remove_dir_all(&wrapper_dir_path);
2903    }
2904
2905    #[cfg(unix)]
2906    #[test]
2907    fn test_disable_git_wrapper_removes_track_file_even_when_dir_removal_fails() {
2908        use std::os::unix::fs::PermissionsExt;
2909
2910        let _lock = ENV_LOCK.lock().unwrap();
2911        let repo_root_tmp = tempfile::tempdir().unwrap();
2912        let repo_root = repo_root_tmp.path();
2913        // Track file now lives in .git/ralph/ (fallback for plain temp dirs).
2914        let ralph_dir = repo_root.join(".git").join("ralph");
2915        fs::create_dir_all(&ralph_dir).unwrap();
2916
2917        let blocked_parent = tempfile::tempdir_in(env::temp_dir()).unwrap();
2918        let wrapper_dir_path = blocked_parent.path().join("ralph-git-wrapper-blocked");
2919        fs::create_dir(&wrapper_dir_path).unwrap();
2920        fs::write(wrapper_dir_path.join("git"), "#!/bin/sh\n").unwrap();
2921
2922        let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
2923        fs::write(&track_file, format!("{}\n", wrapper_dir_path.display())).unwrap();
2924
2925        let original_parent_mode = fs::metadata(blocked_parent.path())
2926            .unwrap()
2927            .permissions()
2928            .mode();
2929        let mut parent_permissions = fs::metadata(blocked_parent.path()).unwrap().permissions();
2930        parent_permissions.set_mode(0o555);
2931        fs::set_permissions(blocked_parent.path(), parent_permissions).unwrap();
2932
2933        let mut helpers = GitHelpers {
2934            wrapper_dir: Some(wrapper_dir_path.clone()),
2935            wrapper_repo_root: Some(repo_root.to_path_buf()),
2936            ..GitHelpers::default()
2937        };
2938
2939        disable_git_wrapper(&mut helpers);
2940
2941        // Track file MUST be removed even when the wrapper dir removal fails.
2942        // Hooks check marker OR track_file, so leaving it behind blocks commits.
2943        assert!(
2944            !track_file.exists(),
2945            "track file must be removed unconditionally, even when wrapper dir removal fails"
2946        );
2947
2948        let mut restore_permissions = fs::metadata(blocked_parent.path()).unwrap().permissions();
2949        restore_permissions.set_mode(original_parent_mode);
2950        fs::set_permissions(blocked_parent.path(), restore_permissions).unwrap();
2951        fs::remove_dir_all(&wrapper_dir_path).unwrap();
2952    }
2953
2954    // =========================================================================
2955    // ralph dir removal tests (TDD: these fail until try_remove_ralph_dir fix lands)
2956    // =========================================================================
2957
2958    #[test]
2959    fn test_cleanup_agent_phase_silent_at_removes_ralph_dir_when_all_artifacts_present() {
2960        // Simulates active agent phase state: marker, head-oid, and track file all present.
2961        // After cleanup, all files AND the directory itself must be gone.
2962        let tmp = tempfile::tempdir().unwrap();
2963        let repo_root = tmp.path();
2964        let ralph_dir = repo_root.join(".git").join("ralph");
2965        fs::create_dir_all(&ralph_dir).unwrap();
2966        fs::write(ralph_dir.join(MARKER_FILE_NAME), "").unwrap();
2967        fs::write(ralph_dir.join(HEAD_OID_FILE_NAME), "abc123\n").unwrap();
2968        // Track file pointing to a non-existent wrapper dir (safe to clean).
2969        fs::write(
2970            ralph_dir.join(WRAPPER_TRACK_FILE_NAME),
2971            "/nonexistent/wrapper\n",
2972        )
2973        .unwrap();
2974
2975        cleanup_agent_phase_silent_at(repo_root);
2976
2977        assert!(
2978            !ralph_dir.exists(),
2979            ".git/ralph/ should be fully removed after cleanup_agent_phase_silent_at; \
2980             all artifacts were removed but the directory still exists"
2981        );
2982    }
2983
2984    #[test]
2985    fn test_cleanup_removes_ralph_dir_when_stray_head_oid_tmp_file_exists() {
2986        // Simulates a crash mid-write_head_oid_file_atomic that left a temp file.
2987        // cleanup_agent_phase_silent_at must remove the stray temp file and the directory.
2988        let tmp = tempfile::tempdir().unwrap();
2989        let repo_root = tmp.path();
2990        let ralph_dir = repo_root.join(".git").join("ralph");
2991        fs::create_dir_all(&ralph_dir).unwrap();
2992        fs::write(ralph_dir.join(MARKER_FILE_NAME), "").unwrap();
2993        // Stray temp file left by an interrupted write_head_oid_file_atomic.
2994        let stray = ralph_dir.join(format!(".head-oid.tmp.{}.123456789", std::process::id()));
2995        fs::write(&stray, "deadbeef\n").unwrap();
2996        // Make it read-only as the atomic writer does.
2997        #[cfg(unix)]
2998        {
2999            use std::os::unix::fs::PermissionsExt;
3000            let mut perms = fs::metadata(&stray).unwrap().permissions();
3001            perms.set_mode(0o444);
3002            fs::set_permissions(&stray, perms).unwrap();
3003        }
3004
3005        cleanup_agent_phase_silent_at(repo_root);
3006
3007        assert!(
3008            !ralph_dir.exists(),
3009            ".git/ralph/ should be fully removed even when a stray .head-oid.tmp.* file exists"
3010        );
3011    }
3012
3013    #[test]
3014    fn test_cleanup_removes_ralph_dir_when_stray_wrapper_track_tmp_file_exists() {
3015        // Simulates a crash mid-write_wrapper_track_file_atomic that left a temp file.
3016        // cleanup_agent_phase_silent_at must remove the stray temp file and the directory.
3017        let tmp = tempfile::tempdir().unwrap();
3018        let repo_root = tmp.path();
3019        let ralph_dir = repo_root.join(".git").join("ralph");
3020        fs::create_dir_all(&ralph_dir).unwrap();
3021        fs::write(ralph_dir.join(MARKER_FILE_NAME), "").unwrap();
3022        // Stray temp file left by an interrupted write_wrapper_track_file_atomic.
3023        let stray = ralph_dir.join(format!(
3024            ".git-wrapper-dir.tmp.{}.987654321",
3025            std::process::id()
3026        ));
3027        fs::write(&stray, "/tmp/some-wrapper-dir\n").unwrap();
3028        // Make it read-only as the atomic writer does.
3029        #[cfg(unix)]
3030        {
3031            use std::os::unix::fs::PermissionsExt;
3032            let mut perms = fs::metadata(&stray).unwrap().permissions();
3033            perms.set_mode(0o444);
3034            fs::set_permissions(&stray, perms).unwrap();
3035        }
3036
3037        cleanup_agent_phase_silent_at(repo_root);
3038
3039        assert!(
3040            !ralph_dir.exists(),
3041            ".git/ralph/ should be fully removed even when a stray .git-wrapper-dir.tmp.* file exists"
3042        );
3043    }
3044
3045    #[test]
3046    fn test_try_remove_ralph_dir_removes_dir_containing_only_stray_tmp_files() {
3047        // try_remove_ralph_dir must handle a directory containing only stray temp files.
3048        let tmp = tempfile::tempdir().unwrap();
3049        let repo_root = tmp.path();
3050        let ralph_dir = repo_root.join(".git").join("ralph");
3051        fs::create_dir_all(&ralph_dir).unwrap();
3052        // Stray head-oid temp file only (no other artifacts).
3053        let stray = ralph_dir.join(".head-oid.tmp.99999.111111111");
3054        fs::write(&stray, "cafebabe\n").unwrap();
3055        #[cfg(unix)]
3056        {
3057            use std::os::unix::fs::PermissionsExt;
3058            let mut perms = fs::metadata(&stray).unwrap().permissions();
3059            perms.set_mode(0o444);
3060            fs::set_permissions(&stray, perms).unwrap();
3061        }
3062
3063        let removed = try_remove_ralph_dir(repo_root);
3064
3065        assert!(
3066            removed,
3067            "try_remove_ralph_dir should report success when the directory is gone"
3068        );
3069        assert!(
3070            !ralph_dir.exists(),
3071            ".git/ralph/ should be fully removed by try_remove_ralph_dir when only stray tmp files remain"
3072        );
3073    }
3074
3075    #[test]
3076    fn test_try_remove_ralph_dir_reports_failure_when_unexpected_artifact_remains() {
3077        let tmp = tempfile::tempdir().unwrap();
3078        let repo_root = tmp.path();
3079        let ralph_dir = repo_root.join(".git").join("ralph");
3080        fs::create_dir_all(&ralph_dir).unwrap();
3081        fs::write(ralph_dir.join("quarantine.bin"), "keep").unwrap();
3082
3083        let removed = try_remove_ralph_dir(repo_root);
3084
3085        assert!(
3086            !removed,
3087            "try_remove_ralph_dir should report failure when .git/ralph remains on disk"
3088        );
3089        let remaining = verify_ralph_dir_removed(repo_root);
3090        assert!(
3091            remaining
3092                .iter()
3093                .any(|entry| entry.contains("directory still exists")),
3094            "verification should report that the directory still exists: {remaining:?}"
3095        );
3096        assert!(
3097            remaining
3098                .iter()
3099                .any(|entry| entry.contains("quarantine.bin")),
3100            "verification should report the unexpected artifact that blocked removal: {remaining:?}"
3101        );
3102    }
3103
3104    #[test]
3105    #[cfg(unix)]
3106    fn test_try_remove_ralph_dir_quarantines_symlinked_ralph_dir_without_touching_target() {
3107        use std::os::unix::fs::symlink;
3108
3109        let tmp = tempfile::tempdir().unwrap();
3110        let repo_root = tmp.path();
3111        let git_dir = repo_root.join(".git");
3112        fs::create_dir_all(&git_dir).unwrap();
3113
3114        let outside_dir = repo_root.join("outside-ralph-target");
3115        fs::create_dir_all(&outside_dir).unwrap();
3116        let outside_tmp = outside_dir.join(".head-oid.tmp.12345.999");
3117        fs::write(&outside_tmp, "keep me\n").unwrap();
3118
3119        let ralph_dir = git_dir.join("ralph");
3120        symlink(&outside_dir, &ralph_dir).unwrap();
3121
3122        let removed = try_remove_ralph_dir(repo_root);
3123
3124        assert!(
3125            removed,
3126            "try_remove_ralph_dir should treat a quarantined symlink as cleaned up"
3127        );
3128        assert!(
3129            !ralph_dir.exists(),
3130            ".git/ralph path should be removed after quarantining the symlink"
3131        );
3132        assert!(
3133            outside_tmp.exists(),
3134            "cleanup must not follow a symlinked .git/ralph and delete temp-like files in the target directory"
3135        );
3136
3137        let quarantined = fs::read_dir(&git_dir)
3138            .unwrap()
3139            .filter_map(Result::ok)
3140            .map(|entry| entry.file_name().to_string_lossy().into_owned())
3141            .find(|name| name.starts_with("ralph.ralph.tampered.dir."))
3142            .unwrap_or_default();
3143        assert!(
3144            !quarantined.is_empty(),
3145            "symlinked .git/ralph should be quarantined for inspection"
3146        );
3147    }
3148
3149    #[test]
3150    #[cfg(unix)]
3151    fn test_verify_ralph_dir_removed_quarantines_symlinked_ralph_dir_without_touching_target() {
3152        use std::os::unix::fs::symlink;
3153
3154        let tmp = tempfile::tempdir().unwrap();
3155        let repo_root = tmp.path();
3156        let git_dir = repo_root.join(".git");
3157        fs::create_dir_all(&git_dir).unwrap();
3158
3159        let outside_dir = repo_root.join("outside-verify-target");
3160        fs::create_dir_all(&outside_dir).unwrap();
3161        let outside_tmp = outside_dir.join(".git-wrapper-dir.tmp.12345.999");
3162        fs::write(&outside_tmp, "keep me too\n").unwrap();
3163
3164        let ralph_dir = git_dir.join("ralph");
3165        symlink(&outside_dir, &ralph_dir).unwrap();
3166
3167        let remaining = verify_ralph_dir_removed(repo_root);
3168
3169        assert!(
3170            remaining.is_empty(),
3171            "verification should report .git/ralph as removed after quarantining the symlink: {remaining:?}"
3172        );
3173        assert!(
3174            !ralph_dir.exists(),
3175            ".git/ralph path should no longer exist after verification sanitizes it"
3176        );
3177        assert!(
3178            outside_tmp.exists(),
3179            "verification must not follow a symlinked .git/ralph and inspect/delete the target directory"
3180        );
3181
3182        let quarantined = fs::read_dir(&git_dir)
3183            .unwrap()
3184            .filter_map(Result::ok)
3185            .map(|entry| entry.file_name().to_string_lossy().into_owned())
3186            .find(|name| name.starts_with("ralph.ralph.tampered.dir."))
3187            .unwrap_or_default();
3188        assert!(
3189            !quarantined.is_empty(),
3190            "verification should quarantine a symlinked .git/ralph path for inspection"
3191        );
3192    }
3193
3194    #[test]
3195    #[cfg(unix)]
3196    fn test_cleanup_stray_tmp_files_in_ralph_dir_ignores_symlink_entries() {
3197        use std::os::unix::fs::symlink;
3198
3199        let tmp = tempfile::tempdir().unwrap();
3200        let repo_root = tmp.path();
3201        let ralph_dir = repo_root.join(".git").join("ralph");
3202        fs::create_dir_all(&ralph_dir).unwrap();
3203
3204        let outside_target = repo_root.join("outside-target.txt");
3205        fs::write(&outside_target, "keep readonly mode untouched\n").unwrap();
3206        let symlink_path = ralph_dir.join(".head-oid.tmp.99999.222222222");
3207        symlink(&outside_target, &symlink_path).unwrap();
3208
3209        cleanup_stray_tmp_files_in_ralph_dir(&ralph_dir);
3210
3211        assert!(
3212            symlink_path.exists(),
3213            "cleanup must skip temp-name symlinks instead of deleting them"
3214        );
3215        let target_contents = fs::read_to_string(&outside_target).unwrap();
3216        assert_eq!(target_contents, "keep readonly mode untouched\n");
3217    }
3218
3219    #[test]
3220    #[cfg(windows)]
3221    fn test_cleanup_stray_tmp_files_in_ralph_dir_removes_readonly_files_on_windows() {
3222        let tmp = tempfile::tempdir().unwrap();
3223        let repo_root = tmp.path();
3224        let ralph_dir = repo_root.join(".git").join("ralph");
3225        fs::create_dir_all(&ralph_dir).unwrap();
3226
3227        let stray = ralph_dir.join(".head-oid.tmp.99999.333333333");
3228        fs::write(&stray, "deadbeef\n").unwrap();
3229        let mut perms = fs::metadata(&stray).unwrap().permissions();
3230        perms.set_readonly(true);
3231        fs::set_permissions(&stray, perms).unwrap();
3232
3233        cleanup_stray_tmp_files_in_ralph_dir(&ralph_dir);
3234
3235        assert!(
3236            !stray.exists(),
3237            "cleanup must clear the readonly attribute before removing stray temp files on Windows"
3238        );
3239    }
3240
3241    #[test]
3242    fn test_cleanup_agent_phase_silent_at_removes_ralph_hooks() {
3243        // Verifies that Ralph-managed hooks are removed when cleanup uses the precomputed
3244        // hooks dir derived from ralph_dir.parent() (bypasses extra libgit2 discovery).
3245        use crate::git_helpers::hooks;
3246
3247        let tmp = tempfile::tempdir().unwrap();
3248        let repo_root = tmp.path();
3249        // Use a real git repo so libgit2 discovery succeeds for all code paths.
3250        let _repo = git2::Repository::init(repo_root).unwrap();
3251
3252        let hooks_dir = repo_root.join(".git").join("hooks");
3253        fs::create_dir_all(&hooks_dir).unwrap();
3254        let ralph_dir = repo_root.join(".git").join("ralph");
3255        fs::create_dir_all(&ralph_dir).unwrap();
3256
3257        // Install read-only Ralph-managed hooks.
3258        let hook_content = format!("#!/bin/bash\n# {}\nexit 0\n", hooks::HOOK_MARKER);
3259        for hook_name in hooks::RALPH_HOOK_NAMES {
3260            let hook_path = hooks_dir.join(hook_name);
3261            fs::write(&hook_path, &hook_content).unwrap();
3262            #[cfg(unix)]
3263            {
3264                use std::os::unix::fs::PermissionsExt;
3265                let mut perms = fs::metadata(&hook_path).unwrap().permissions();
3266                perms.set_mode(0o555);
3267                fs::set_permissions(&hook_path, perms).unwrap();
3268            }
3269        }
3270
3271        cleanup_agent_phase_silent_at(repo_root);
3272
3273        for hook_name in hooks::RALPH_HOOK_NAMES {
3274            let hook_path = hooks_dir.join(hook_name);
3275            let still_has_marker = hook_path.exists()
3276                && crate::files::file_contains_marker(&hook_path, hooks::HOOK_MARKER)
3277                    .unwrap_or(false);
3278            assert!(
3279                !still_has_marker,
3280                "Ralph hook {hook_name} should be removed by cleanup_agent_phase_silent_at"
3281            );
3282        }
3283    }
3284
3285    #[cfg(unix)]
3286    #[test]
3287    fn test_cleanup_removes_readonly_marker_and_track_file() {
3288        use std::os::unix::fs::PermissionsExt;
3289
3290        let tmp = tempfile::tempdir().unwrap();
3291        let repo_root = tmp.path();
3292        let ralph_dir = repo_root.join(".git/ralph");
3293        fs::create_dir_all(&ralph_dir).unwrap();
3294
3295        // Create read-only marker (0o444).
3296        let marker = ralph_dir.join(MARKER_FILE_NAME);
3297        fs::write(&marker, "").unwrap();
3298        fs::set_permissions(&marker, fs::Permissions::from_mode(0o444)).unwrap();
3299
3300        // Create read-only track file (0o444).
3301        let track = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
3302        fs::write(&track, "/nonexistent\n").unwrap();
3303        fs::set_permissions(&track, fs::Permissions::from_mode(0o444)).unwrap();
3304
3305        end_agent_phase_in_repo_at_ralph_dir(repo_root, &ralph_dir);
3306        cleanup_git_wrapper_dir_silent_at(&ralph_dir);
3307
3308        assert!(!marker.exists(), "read-only marker should be removed");
3309        assert!(!track.exists(), "read-only track file should be removed");
3310    }
3311
3312    #[test]
3313    fn test_cleanup_is_idempotent_called_twice() {
3314        let tmp = tempfile::tempdir().unwrap();
3315        let repo_root = tmp.path();
3316        let ralph_dir = repo_root.join(".git/ralph");
3317        fs::create_dir_all(&ralph_dir).unwrap();
3318
3319        let marker = ralph_dir.join(MARKER_FILE_NAME);
3320        fs::write(&marker, "").unwrap();
3321
3322        // First cleanup.
3323        cleanup_agent_phase_silent_at(repo_root);
3324        assert!(!marker.exists());
3325
3326        // Second cleanup — should not panic or error.
3327        cleanup_agent_phase_silent_at(repo_root);
3328        assert!(!marker.exists());
3329    }
3330
3331    #[test]
3332    fn test_cleanup_agent_phase_silent_at_from_worktree_root() {
3333        let tmp = tempfile::tempdir().unwrap();
3334        let main_repo = git2::Repository::init(tmp.path()).unwrap();
3335        {
3336            let mut index = main_repo.index().unwrap();
3337            let tree_oid = index.write_tree().unwrap();
3338            let tree = main_repo.find_tree(tree_oid).unwrap();
3339            let sig = git2::Signature::now("test", "test@test.com").unwrap();
3340            main_repo
3341                .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
3342                .unwrap();
3343        }
3344
3345        let wt_path = tmp.path().join("wt-cleanup");
3346        let _wt = main_repo.worktree("wt-cleanup", &wt_path, None).unwrap();
3347
3348        let worktree_scope = crate::git_helpers::resolve_protection_scope_from(&wt_path).unwrap();
3349        let worktree_ralph_dir = worktree_scope.git_dir.join("ralph");
3350        crate::git_helpers::hooks::install_hooks_in_repo(&wt_path).unwrap();
3351        fs::write(worktree_ralph_dir.join(MARKER_FILE_NAME), "").unwrap();
3352        // Also create a track file pointing to a nonexistent wrapper dir.
3353        fs::write(
3354            worktree_ralph_dir.join(WRAPPER_TRACK_FILE_NAME),
3355            "/nonexistent/wrapper\n",
3356        )
3357        .unwrap();
3358
3359        assert!(
3360            worktree_scope
3361                .worktree_config_path
3362                .as_ref()
3363                .unwrap()
3364                .exists(),
3365            "precondition: linked worktree cleanup test must start with config.worktree present"
3366        );
3367
3368        // Cleanup from the WORKTREE root — must clean only worktree-local artifacts.
3369        cleanup_agent_phase_silent_at(&wt_path);
3370
3371        assert!(
3372            !worktree_ralph_dir.join(MARKER_FILE_NAME).exists(),
3373            "marker at worktree git dir should be removed when cleaning from worktree root"
3374        );
3375        assert!(
3376            !worktree_ralph_dir.join(WRAPPER_TRACK_FILE_NAME).exists(),
3377            "track file at worktree git dir should be removed when cleaning from worktree root"
3378        );
3379        for name in crate::git_helpers::hooks::RALPH_HOOK_NAMES {
3380            let hook_path = worktree_scope.hooks_dir.join(name);
3381            let still_ralph = hook_path.exists()
3382                && crate::files::file_contains_marker(
3383                    &hook_path,
3384                    crate::git_helpers::hooks::HOOK_MARKER,
3385                )
3386                .unwrap_or(false);
3387            assert!(
3388                !still_ralph,
3389                "hook {name} at worktree hooks dir should be removed from worktree root"
3390            );
3391        }
3392
3393        assert!(
3394            !worktree_scope
3395                .worktree_config_path
3396                .as_ref()
3397                .unwrap()
3398                .exists(),
3399            "silent cleanup should remove the worktree-local config override"
3400        );
3401        let common_config = crate::git_helpers::resolve_protection_scope_from(&wt_path)
3402            .unwrap()
3403            .common_git_dir
3404            .join("config");
3405        assert!(
3406            common_config.exists(),
3407            "common config should remain inspectable after cleanup"
3408        );
3409        assert_eq!(
3410            git2::Config::open(&common_config)
3411                .unwrap()
3412                .get_string("extensions.worktreeConfig")
3413                .ok(),
3414            None,
3415            "silent cleanup should restore shared worktreeConfig state for a linked worktree run"
3416        );
3417    }
3418
3419    #[test]
3420    fn test_cleanup_agent_phase_silent_at_from_root_repo_restores_root_worktree_scoping() {
3421        let tmp = tempfile::tempdir().unwrap();
3422        let main_repo_path = tmp.path().join("main");
3423        fs::create_dir_all(&main_repo_path).unwrap();
3424        let main_repo = git2::Repository::init(&main_repo_path).unwrap();
3425        {
3426            let mut index = main_repo.index().unwrap();
3427            let tree_oid = index.write_tree().unwrap();
3428            let tree = main_repo.find_tree(tree_oid).unwrap();
3429            let sig = git2::Signature::now("test", "test@test.com").unwrap();
3430            main_repo
3431                .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
3432                .unwrap();
3433        }
3434
3435        let sibling_path = tmp.path().join("wt-sibling");
3436        let _sibling = main_repo
3437            .worktree("wt-sibling", &sibling_path, None)
3438            .unwrap();
3439
3440        let root_scope =
3441            crate::git_helpers::resolve_protection_scope_from(&main_repo_path).unwrap();
3442        let common_config = root_scope.common_git_dir.join("config");
3443        let root_config = root_scope.worktree_config_path.unwrap();
3444
3445        crate::git_helpers::hooks::install_hooks_in_repo(&main_repo_path).unwrap();
3446        assert!(
3447            root_config.exists(),
3448            "precondition: root config.worktree must exist"
3449        );
3450        assert_eq!(
3451            git2::Config::open(&common_config)
3452                .unwrap()
3453                .get_string("extensions.worktreeConfig")
3454                .ok(),
3455            Some("true".to_string()),
3456            "precondition: root worktree install should enable shared worktreeConfig"
3457        );
3458
3459        cleanup_agent_phase_silent_at(&main_repo_path);
3460
3461        assert!(
3462            !root_config.exists(),
3463            "silent cleanup should remove the root worktree config override"
3464        );
3465        assert_eq!(
3466            git2::Config::open(&common_config)
3467                .unwrap()
3468                .get_string("extensions.worktreeConfig")
3469                .ok(),
3470            None,
3471            "silent cleanup should restore shared worktreeConfig state for a root run"
3472        );
3473    }
3474
3475    // =========================================================================
3476    // Unconditional track file removal tests
3477    // =========================================================================
3478
3479    #[test]
3480    fn test_track_file_removed_even_when_wrapper_dir_cleanup_fails() {
3481        // The track file must be removed unconditionally because hooks use OR logic
3482        // (marker OR track_file) to block commits. If the wrapper dir in /tmp can't
3483        // be removed, the track file should still be cleaned so hooks don't block.
3484        let tmp = tempfile::tempdir().unwrap();
3485        let ralph_dir = tmp.path().join(".git").join("ralph");
3486        fs::create_dir_all(&ralph_dir).unwrap();
3487
3488        // Write a track file pointing to a path that does NOT exist under system
3489        // temp dir (so wrapper_dir_is_safe_existing_dir returns false and the
3490        // wrapper dir cleanup "fails" in the sense that the dir wasn't cleaned).
3491        let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
3492        fs::write(&track_file, "/not-a-real-temp-dir/ralph-git-wrapper-fake\n").unwrap();
3493
3494        cleanup_git_wrapper_dir_silent_at(&ralph_dir);
3495
3496        assert!(
3497            !track_file.exists(),
3498            "track file must be removed unconditionally, even when wrapper dir cleanup fails"
3499        );
3500    }
3501
3502    #[test]
3503    fn test_disable_git_wrapper_always_removes_track_file() {
3504        // disable_git_wrapper must always remove the track file regardless of
3505        // whether the wrapper dir could be cleaned up.
3506        let tmp = tempfile::tempdir().unwrap();
3507        let repo_root = tmp.path();
3508        let ralph_dir = repo_root.join(".git").join("ralph");
3509        fs::create_dir_all(&ralph_dir).unwrap();
3510
3511        // Write a read-only track file pointing to a non-existent path.
3512        let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
3513        fs::write(&track_file, "/nonexistent/not-temp-prefixed\n").unwrap();
3514        #[cfg(unix)]
3515        {
3516            use std::os::unix::fs::PermissionsExt;
3517            let mut perms = fs::metadata(&track_file).unwrap().permissions();
3518            perms.set_mode(0o444);
3519            fs::set_permissions(&track_file, perms).unwrap();
3520        }
3521
3522        let mut helpers = GitHelpers {
3523            wrapper_dir: None,
3524            wrapper_repo_root: Some(repo_root.to_path_buf()),
3525            ..GitHelpers::default()
3526        };
3527
3528        disable_git_wrapper(&mut helpers);
3529
3530        assert!(
3531            !track_file.exists(),
3532            "track file must be removed unconditionally by disable_git_wrapper"
3533        );
3534    }
3535
3536    // =========================================================================
3537    // Global mutex clearing tests
3538    // =========================================================================
3539
3540    #[test]
3541    fn test_global_mutexes_not_cleared_by_end_agent_phase_in_repo() {
3542        let _lock = ENV_LOCK.lock().unwrap();
3543        // end_agent_phase_in_repo must NOT clear global mutexes — callers are
3544        // responsible for calling clear_agent_phase_global_state after ALL cleanup.
3545        //
3546        // IMPORTANT: This test modifies process-global mutexes, so we must:
3547        // 1. Use ClearOnDrop guard to ensure cleanup even on panic.
3548        // 2. Only set REPO_ROOT and RALPH_DIR (not HOOKS_DIR) to avoid
3549        //    poisoning parallel tests that call cleanup_agent_phase_silent_at,
3550        //    which reads HOOKS_DIR to determine the hooks cleanup location.
3551        let _guard = ClearAgentPhaseStateOnDrop;
3552        let _test_lock = agent_phase_test_lock().lock().unwrap();
3553
3554        let tmp = tempfile::tempdir().unwrap();
3555        let repo_root = tmp.path();
3556        let ralph_dir = repo_root.join(".git").join("ralph");
3557        fs::create_dir_all(&ralph_dir).unwrap();
3558        fs::write(ralph_dir.join(MARKER_FILE_NAME), "").unwrap();
3559
3560        // Only set REPO_ROOT and RALPH_DIR. Skip HOOKS_DIR to avoid
3561        // interfering with parallel tests that read it for cleanup.
3562        set_agent_phase_paths_for_test(Some(repo_root.to_path_buf()), Some(ralph_dir), None);
3563
3564        end_agent_phase_in_repo(repo_root);
3565
3566        // REPO_ROOT and RALPH_DIR should still hold their values.
3567        let repo_root_val = AGENT_PHASE_REPO_ROOT.lock().unwrap().clone();
3568        assert!(
3569            repo_root_val.is_some(),
3570            "AGENT_PHASE_REPO_ROOT should NOT be cleared by end_agent_phase_in_repo"
3571        );
3572
3573        let ralph_dir_val = AGENT_PHASE_RALPH_DIR.lock().unwrap().clone();
3574        assert!(
3575            ralph_dir_val.is_some(),
3576            "AGENT_PHASE_RALPH_DIR should NOT be cleared by end_agent_phase_in_repo"
3577        );
3578        // _guard's Drop clears mutexes even on panic.
3579    }
3580
3581    #[test]
3582    fn test_clear_agent_phase_global_state_clears_all_mutexes() {
3583        let _lock = ENV_LOCK.lock().unwrap();
3584        set_agent_phase_paths_for_test(
3585            Some(PathBuf::from("/test/repo")),
3586            Some(PathBuf::from("/test/repo/.git/ralph")),
3587            Some(PathBuf::from("/test/repo/.git/hooks")),
3588        );
3589
3590        clear_agent_phase_global_state();
3591
3592        assert!(
3593            AGENT_PHASE_REPO_ROOT.lock().unwrap().is_none(),
3594            "AGENT_PHASE_REPO_ROOT should be cleared"
3595        );
3596        assert!(
3597            AGENT_PHASE_RALPH_DIR.lock().unwrap().is_none(),
3598            "AGENT_PHASE_RALPH_DIR should be cleared"
3599        );
3600        assert!(
3601            AGENT_PHASE_HOOKS_DIR.lock().unwrap().is_none(),
3602            "AGENT_PHASE_HOOKS_DIR should be cleared"
3603        );
3604    }
3605
3606    // =========================================================================
3607    // Comprehensive cleanup test
3608    // =========================================================================
3609
3610    /// Helper: install all agent-phase artifacts for comprehensive cleanup tests.
3611    fn install_all_agent_phase_artifacts(repo_root: &Path) {
3612        use crate::git_helpers::hooks;
3613
3614        let ralph_dir = repo_root.join(".git").join("ralph");
3615        fs::create_dir_all(&ralph_dir).unwrap();
3616        let hooks_dir = repo_root.join(".git").join("hooks");
3617        fs::create_dir_all(&hooks_dir).unwrap();
3618
3619        let marker = ralph_dir.join(MARKER_FILE_NAME);
3620        fs::write(&marker, "").unwrap();
3621        #[cfg(unix)]
3622        {
3623            use std::os::unix::fs::PermissionsExt;
3624            fs::set_permissions(&marker, fs::Permissions::from_mode(0o444)).unwrap();
3625        }
3626
3627        let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
3628        fs::write(&track_file, "/nonexistent/wrapper\n").unwrap();
3629        #[cfg(unix)]
3630        {
3631            use std::os::unix::fs::PermissionsExt;
3632            fs::set_permissions(&track_file, fs::Permissions::from_mode(0o444)).unwrap();
3633        }
3634
3635        fs::write(ralph_dir.join(HEAD_OID_FILE_NAME), "abc123\n").unwrap();
3636
3637        let hook_content = format!("#!/bin/bash\n# {}\nexit 0\n", hooks::HOOK_MARKER);
3638        for name in hooks::RALPH_HOOK_NAMES {
3639            let hook_path = hooks_dir.join(name);
3640            fs::write(&hook_path, &hook_content).unwrap();
3641            #[cfg(unix)]
3642            {
3643                use std::os::unix::fs::PermissionsExt;
3644                fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o555)).unwrap();
3645            }
3646        }
3647
3648        set_agent_phase_paths_for_test(
3649            Some(repo_root.to_path_buf()),
3650            Some(ralph_dir),
3651            Some(hooks_dir),
3652        );
3653    }
3654
3655    #[test]
3656    fn test_cleanup_agent_phase_silent_at_removes_all_artifacts_including_track_file() {
3657        use crate::git_helpers::hooks;
3658
3659        let _lock = ENV_LOCK.lock().unwrap();
3660        let tmp = tempfile::tempdir().unwrap();
3661        let repo_root = tmp.path();
3662        let _repo = git2::Repository::init(repo_root).unwrap();
3663
3664        install_all_agent_phase_artifacts(repo_root);
3665        cleanup_agent_phase_silent_at(repo_root);
3666
3667        let ralph_dir = repo_root.join(".git").join("ralph");
3668        let hooks_dir = repo_root.join(".git").join("hooks");
3669
3670        assert!(
3671            !ralph_dir.join(MARKER_FILE_NAME).exists(),
3672            "marker should be removed"
3673        );
3674        assert!(
3675            !ralph_dir.join(WRAPPER_TRACK_FILE_NAME).exists(),
3676            "track file should be removed"
3677        );
3678        assert!(
3679            !ralph_dir.join(HEAD_OID_FILE_NAME).exists(),
3680            "head-oid should be removed"
3681        );
3682        assert!(!ralph_dir.exists(), "ralph dir should be removed");
3683        for name in hooks::RALPH_HOOK_NAMES {
3684            let hook_path = hooks_dir.join(name);
3685            let still_ralph = hook_path.exists()
3686                && crate::files::file_contains_marker(&hook_path, hooks::HOOK_MARKER)
3687                    .unwrap_or(false);
3688            assert!(!still_ralph, "hook {name} should be removed");
3689        }
3690
3691        // Global mutexes should be cleared.
3692        assert!(AGENT_PHASE_REPO_ROOT.lock().unwrap().is_none());
3693        assert!(AGENT_PHASE_RALPH_DIR.lock().unwrap().is_none());
3694        assert!(AGENT_PHASE_HOOKS_DIR.lock().unwrap().is_none());
3695    }
3696
3697    #[test]
3698    fn test_cleanup_agent_phase_protections_silent_at_preserves_commit_message_output() {
3699        let _lock = ENV_LOCK.lock().unwrap();
3700        let tmp = tempfile::tempdir().unwrap();
3701        let repo_root = tmp.path();
3702        let _repo = git2::Repository::init(repo_root).unwrap();
3703
3704        install_all_agent_phase_artifacts(repo_root);
3705        let commit_message_path = repo_root.join(".agent/commit-message.txt");
3706        fs::create_dir_all(commit_message_path.parent().unwrap()).unwrap();
3707        fs::write(&commit_message_path, "feat: keep generated message\n").unwrap();
3708
3709        cleanup_agent_phase_protections_silent_at(repo_root);
3710
3711        assert!(
3712            commit_message_path.exists(),
3713            "command-exit cleanup must preserve generated command output files"
3714        );
3715        assert!(
3716            !repo_root.join(".git/ralph").exists(),
3717            "command-exit cleanup must still remove git protection artifacts"
3718        );
3719    }
3720}