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_is_symlink = marker_meta
1017        .as_ref()
1018        .is_some_and(|m| m.file_type().is_symlink());
1019    let marker_exists = marker_meta
1020        .as_ref()
1021        .is_some_and(|m| m.file_type().is_file() && !m.file_type().is_symlink());
1022
1023    // Ensure the PATH wrapper is present and intact.
1024    //
1025    // CRITICAL: Treat the track file as untrusted input.
1026    // We only use it if it points to a plausible temp directory AND that directory is
1027    // already present on PATH (meaning it was installed by Ralph).
1028    let track_file_path = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
1029    if let Ok(meta) = fs::symlink_metadata(&track_file_path) {
1030        let ft = meta.file_type();
1031        let is_regular_file = ft.is_file() && !ft.is_symlink();
1032        if !is_regular_file {
1033            logger.warn("Git wrapper tracking path is not a regular file — quarantining");
1034            result.tampering_detected = true;
1035            result
1036                .details
1037                .push("Git wrapper tracking path was not a regular file — quarantined".to_string());
1038            if let Err(e) = super::repo::quarantine_path_in_place(&track_file_path, "track") {
1039                logger.warn(&format!("Failed to quarantine wrapper tracking path: {e}"));
1040                result
1041                    .details
1042                    .push("Wrapper tracking path quarantine failed".to_string());
1043            }
1044        }
1045    }
1046
1047    let tracked_wrapper_dir = fs::read_to_string(&track_file_path).ok().and_then(|s| {
1048        let p = PathBuf::from(s.trim());
1049        if wrapper_dir_is_safe_existing_dir(&p) && wrapper_dir_is_on_path(&p) {
1050            Some(p)
1051        } else {
1052            None
1053        }
1054    });
1055
1056    let path_wrapper_dir =
1057        find_wrapper_dir_on_path().filter(|p| wrapper_dir_is_safe_existing_dir(p));
1058
1059    let wrapper_dir = tracked_wrapper_dir.clone().or(path_wrapper_dir);
1060
1061    // Ensure the wrapper dir is first on PATH to defend against PATH reordering.
1062    if let Some(ref dir) = wrapper_dir {
1063        ensure_wrapper_dir_prepended_to_path(dir);
1064    }
1065
1066    // If the track file is missing or points elsewhere, rewrite it to the PATH wrapper dir.
1067    if tracked_wrapper_dir.is_none() {
1068        if let Some(ref dir) = wrapper_dir {
1069            logger.warn("Git wrapper tracking file missing or invalid — restoring");
1070            result.tampering_detected = true;
1071            result
1072                .details
1073                .push("Git wrapper tracking file missing or invalid — restored".to_string());
1074
1075            // Best-effort rewrite: failures here should not crash the pipeline.
1076            if let Err(e) = write_wrapper_track_file_atomic(&repo_root, dir) {
1077                logger.warn(&format!("Failed to restore wrapper tracking file: {e}"));
1078            }
1079        }
1080    }
1081
1082    // Restore wrapper script content/permissions if missing or tampered.
1083    if let Some(wrapper_dir) = wrapper_dir {
1084        let wrapper_path = wrapper_dir.join("git");
1085        let wrapper_needs_restore = fs::read_to_string(&wrapper_path).map_or(true, |content| {
1086            !content.contains(WRAPPER_MARKER) || !content.contains("unset GIT_EXEC_PATH")
1087        });
1088
1089        if wrapper_needs_restore {
1090            logger.warn("Git wrapper script missing or tampered — restoring");
1091            result.tampering_detected = true;
1092            result
1093                .details
1094                .push("Git wrapper script missing or tampered — restored".to_string());
1095
1096            // Resolve the real git binary by searching PATH excluding the wrapper dir.
1097            let real_git =
1098                find_git_in_path_excluding_dir(&wrapper_dir).or_else(|| which("git").ok());
1099
1100            match real_git {
1101                Some(real_git_path) => {
1102                    let Some(real_git_str) = real_git_path.to_str() else {
1103                        logger.warn(
1104                            "Resolved git binary path is not valid UTF-8; cannot restore wrapper",
1105                        );
1106                        return result;
1107                    };
1108                    let Ok(git_path_escaped) = escape_shell_single_quoted(real_git_str) else {
1109                        logger.warn("Failed to generate safe wrapper script (git path)");
1110                        return result;
1111                    };
1112                    let marker_p = ralph_dir.join(MARKER_FILE_NAME);
1113                    let track_p = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
1114                    let Some(marker_str) = marker_p.to_str() else {
1115                        logger.warn("Marker path is not valid UTF-8; cannot restore wrapper");
1116                        return result;
1117                    };
1118                    let Some(track_str) = track_p.to_str() else {
1119                        logger.warn("Track file path is not valid UTF-8; cannot restore wrapper");
1120                        return result;
1121                    };
1122                    let Ok(marker_escaped) = escape_shell_single_quoted(marker_str) else {
1123                        logger.warn("Failed to generate safe wrapper script (marker path)");
1124                        return result;
1125                    };
1126                    let Ok(track_escaped) = escape_shell_single_quoted(track_str) else {
1127                        logger.warn("Failed to generate safe wrapper script (track file path)");
1128                        return result;
1129                    };
1130                    let normalized_repo_root = normalize_protection_scope_path(&repo_root);
1131                    let normalized_git_dir = normalize_protection_scope_path(&scope.git_dir);
1132                    let Some(repo_root_str) = normalized_repo_root.to_str() else {
1133                        logger.warn("Repo root is not valid UTF-8; cannot restore wrapper");
1134                        return result;
1135                    };
1136                    let Some(git_dir_str) = normalized_git_dir.to_str() else {
1137                        logger.warn("Git dir is not valid UTF-8; cannot restore wrapper");
1138                        return result;
1139                    };
1140                    let Ok(repo_root_escaped) = escape_shell_single_quoted(repo_root_str) else {
1141                        logger.warn("Failed to generate safe wrapper script (repo root)");
1142                        return result;
1143                    };
1144                    let Ok(git_dir_escaped) = escape_shell_single_quoted(git_dir_str) else {
1145                        logger.warn("Failed to generate safe wrapper script (git dir)");
1146                        return result;
1147                    };
1148
1149                    let wrapper_content = make_wrapper_content(
1150                        &git_path_escaped,
1151                        &marker_escaped,
1152                        &track_escaped,
1153                        &repo_root_escaped,
1154                        &git_dir_escaped,
1155                    );
1156
1157                    let tmp_path = wrapper_dir.join(format!(
1158                        ".git-wrapper.tmp.{}.{}",
1159                        std::process::id(),
1160                        std::time::SystemTime::now()
1161                            .duration_since(std::time::UNIX_EPOCH)
1162                            .unwrap_or_default()
1163                            .as_nanos()
1164                    ));
1165
1166                    let open_tmp = {
1167                        #[cfg(unix)]
1168                        {
1169                            use std::os::unix::fs::OpenOptionsExt;
1170                            OpenOptions::new()
1171                                .write(true)
1172                                .create_new(true)
1173                                .custom_flags(libc::O_NOFOLLOW)
1174                                .open(&tmp_path)
1175                        }
1176                        #[cfg(not(unix))]
1177                        {
1178                            OpenOptions::new()
1179                                .write(true)
1180                                .create_new(true)
1181                                .open(&tmp_path)
1182                        }
1183                    };
1184
1185                    match open_tmp.and_then(|mut f| {
1186                        f.write_all(wrapper_content.as_bytes())?;
1187                        f.flush()?;
1188                        let _ = f.sync_all();
1189                        Ok(())
1190                    }) {
1191                        Ok(()) => {
1192                            #[cfg(unix)]
1193                            {
1194                                use std::os::unix::fs::PermissionsExt;
1195                                if let Ok(meta) = fs::metadata(&tmp_path) {
1196                                    let mut perms = meta.permissions();
1197                                    perms.set_mode(0o555);
1198                                    let _ = fs::set_permissions(&tmp_path, perms);
1199                                }
1200                            }
1201                            #[cfg(windows)]
1202                            {
1203                                if let Ok(meta) = fs::metadata(&tmp_path) {
1204                                    let mut perms = meta.permissions();
1205                                    perms.set_readonly(true);
1206                                    let _ = fs::set_permissions(&tmp_path, perms);
1207                                }
1208                                if wrapper_path.exists() {
1209                                    let _ = fs::remove_file(&wrapper_path);
1210                                }
1211                            }
1212                            if let Err(e) = fs::rename(&tmp_path, &wrapper_path) {
1213                                let _ = fs::remove_file(&tmp_path);
1214                                logger.warn(&format!("Failed to restore wrapper script: {e}"));
1215                            }
1216                        }
1217                        Err(e) => {
1218                            logger.warn(&format!("Failed to write wrapper temp file: {e}"));
1219                        }
1220                    }
1221
1222                    // Defense-in-depth: validate we didn't resolve the wrapper itself.
1223                    if real_git_path == wrapper_path {
1224                        logger.warn(
1225                            "Resolved git binary points to wrapper; wrapper restore may be incomplete",
1226                        );
1227                    }
1228                }
1229                None => {
1230                    logger.warn("Failed to resolve real git binary; cannot restore wrapper");
1231                }
1232            }
1233        }
1234
1235        // Restore wrapper permissions (0o555) if loosened.
1236        #[cfg(unix)]
1237        {
1238            use std::os::unix::fs::PermissionsExt;
1239            if let Ok(meta) = fs::metadata(&wrapper_path) {
1240                let mode = meta.permissions().mode() & 0o777;
1241                if mode != 0o555 {
1242                    logger.warn(&format!(
1243                        "Git wrapper permissions loosened ({mode:#o}) — restoring to 0o555"
1244                    ));
1245                    let mut perms = meta.permissions();
1246                    perms.set_mode(0o555);
1247                    let _ = fs::set_permissions(&wrapper_path, perms);
1248                    result.tampering_detected = true;
1249                    result.details.push(format!(
1250                        "Git wrapper permissions loosened ({mode:#o}) — restored to 0o555"
1251                    ));
1252                }
1253            }
1254        }
1255    } else {
1256        // Wrapper missing from PATH and no valid track file — re-enable.
1257        logger.warn("Git wrapper missing — reinstalling");
1258        result.tampering_detected = true;
1259        result
1260            .details
1261            .push("Git wrapper missing before agent spawn — reinstalling".to_string());
1262
1263        let wrapper_dir = match tempfile::Builder::new()
1264            .prefix(WRAPPER_DIR_PREFIX)
1265            .tempdir()
1266        {
1267            Ok(d) => d.keep(),
1268            Err(e) => {
1269                logger.warn(&format!("Failed to create wrapper dir: {e}"));
1270                // Continue with hooks/marker self-heal; wrapper is defense-in-depth.
1271                return result;
1272            }
1273        };
1274        ensure_wrapper_dir_prepended_to_path(&wrapper_dir);
1275
1276        let real_git = find_git_in_path_excluding_dir(&wrapper_dir).or_else(|| which("git").ok());
1277        if let Some(real_git_path) = real_git {
1278            if let Some(real_git_str) = real_git_path.to_str() {
1279                let marker_p = ralph_dir.join(MARKER_FILE_NAME);
1280                let track_p = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
1281                if let (Ok(git_path_escaped), Some(marker_str), Some(track_str)) = (
1282                    escape_shell_single_quoted(real_git_str),
1283                    marker_p.to_str(),
1284                    track_p.to_str(),
1285                ) {
1286                    if let (Ok(marker_escaped), Ok(track_escaped)) = (
1287                        escape_shell_single_quoted(marker_str),
1288                        escape_shell_single_quoted(track_str),
1289                    ) {
1290                        let normalized_repo_root = normalize_protection_scope_path(&repo_root);
1291                        let normalized_git_dir = normalize_protection_scope_path(&scope.git_dir);
1292                        let Some(repo_root_str) = normalized_repo_root.to_str() else {
1293                            logger.warn("Repo root is not valid UTF-8; cannot restore wrapper");
1294                            return result;
1295                        };
1296                        let Some(git_dir_str) = normalized_git_dir.to_str() else {
1297                            logger.warn("Git dir is not valid UTF-8; cannot restore wrapper");
1298                            return result;
1299                        };
1300                        let Ok(repo_root_escaped) = escape_shell_single_quoted(repo_root_str)
1301                        else {
1302                            logger.warn("Failed to generate safe wrapper script (repo root)");
1303                            return result;
1304                        };
1305                        let Ok(git_dir_escaped) = escape_shell_single_quoted(git_dir_str) else {
1306                            logger.warn("Failed to generate safe wrapper script (git dir)");
1307                            return result;
1308                        };
1309                        let wrapper_content = make_wrapper_content(
1310                            &git_path_escaped,
1311                            &marker_escaped,
1312                            &track_escaped,
1313                            &repo_root_escaped,
1314                            &git_dir_escaped,
1315                        );
1316                        let wrapper_path = wrapper_dir.join("git");
1317                        if OpenOptions::new()
1318                            .write(true)
1319                            .create_new(true)
1320                            .open(&wrapper_path)
1321                            .and_then(|mut f| {
1322                                f.write_all(wrapper_content.as_bytes())?;
1323                                f.flush()?;
1324                                let _ = f.sync_all();
1325                                Ok(())
1326                            })
1327                            .is_ok()
1328                        {
1329                            #[cfg(unix)]
1330                            {
1331                                use std::os::unix::fs::PermissionsExt;
1332                                if let Ok(meta) = fs::metadata(&wrapper_path) {
1333                                    let mut perms = meta.permissions();
1334                                    perms.set_mode(0o555);
1335                                    let _ = fs::set_permissions(&wrapper_path, perms);
1336                                }
1337                            }
1338                        }
1339                    }
1340                }
1341            }
1342        }
1343
1344        // Best-effort track file write.
1345        if let Err(e) = write_wrapper_track_file_atomic(&repo_root, &wrapper_dir) {
1346            logger.warn(&format!("Failed to write wrapper tracking file: {e}"));
1347        }
1348    }
1349
1350    // Check if hooks exist (any Ralph hook present means we're in agent phase).
1351    let hooks_present = super::repo::get_hooks_dir_from(&repo_root)
1352        .ok()
1353        .is_some_and(|hooks_dir| {
1354            super::hooks::RALPH_HOOK_NAMES.iter().any(|name| {
1355                let path = hooks_dir.join(name);
1356                path.exists()
1357                    && matches!(
1358                        crate::files::file_contains_marker(&path, super::hooks::HOOK_MARKER),
1359                        Ok(true)
1360                    )
1361            })
1362        });
1363
1364    // Missing protections before an agent spawn is treated as tampering.
1365    if !marker_exists && !hooks_present {
1366        logger.warn("Agent-phase git protections missing — reinstalling");
1367        result.tampering_detected = true;
1368        result
1369            .details
1370            .push("Marker and hooks missing before agent spawn — reinstalling".to_string());
1371    }
1372
1373    // Repair marker if missing or replaced with a symlink.
1374    if marker_is_symlink {
1375        logger.warn("Enforcement marker is a symlink — removing and recreating");
1376        let _ = fs::remove_file(&marker_path);
1377        result.tampering_detected = true;
1378        result
1379            .details
1380            .push("Enforcement marker was a symlink — removed".to_string());
1381    }
1382    if !marker_exists {
1383        logger.warn("Enforcement marker missing — recreating");
1384        if let Err(e) = create_marker_in_repo_root(&repo_root) {
1385            logger.warn(&format!("Failed to recreate enforcement marker: {e}"));
1386        } else {
1387            #[cfg(unix)]
1388            set_readonly_mode_if_not_symlink(&marker_path, 0o444);
1389        }
1390        result.tampering_detected = true;
1391        result
1392            .details
1393            .push("Enforcement marker was missing — recreated".to_string());
1394    }
1395
1396    // Verify/restore marker permissions (read-only 0o444).
1397    #[cfg(unix)]
1398    {
1399        use std::os::unix::fs::PermissionsExt;
1400        if marker_is_symlink {
1401            // Never chmod through a symlink.
1402        } else if let Ok(meta) = fs::metadata(&marker_path) {
1403            if meta.is_file() {
1404                let mode = meta.permissions().mode() & 0o777;
1405                if mode != 0o444 {
1406                    logger.warn(&format!(
1407                        "Enforcement marker permissions loosened ({mode:#o}) — restoring to 0o444"
1408                    ));
1409                    let mut perms = meta.permissions();
1410                    perms.set_mode(0o444);
1411                    let _ = fs::set_permissions(&marker_path, perms);
1412                    result.tampering_detected = true;
1413                    result.details.push(format!(
1414                        "Enforcement marker permissions loosened ({mode:#o}) — restored to 0o444"
1415                    ));
1416                }
1417            } else {
1418                // A non-file marker path would bypass hook/wrapper `-f` checks.
1419                // Quarantine and recreate a file marker.
1420                logger.warn("Enforcement marker is not a regular file — quarantining");
1421                result.tampering_detected = true;
1422                result
1423                    .details
1424                    .push("Enforcement marker was not a regular file — quarantined".to_string());
1425                if let Err(e) = super::repo::quarantine_path_in_place(&marker_path, "marker-perms")
1426                {
1427                    logger.warn(&format!("Failed to quarantine marker path: {e}"));
1428                } else if let Err(e) = create_marker_in_repo_root(&repo_root) {
1429                    logger.warn(&format!(
1430                        "Failed to recreate enforcement marker after quarantine: {e}"
1431                    ));
1432                } else {
1433                    #[cfg(unix)]
1434                    set_readonly_mode_if_not_symlink(&marker_path, 0o444);
1435                }
1436            }
1437        }
1438    }
1439
1440    // Reinstall hooks if tampered (best-effort).
1441    match reinstall_hooks_if_tampered(logger) {
1442        Ok(true) => {
1443            result.tampering_detected = true;
1444            result
1445                .details
1446                .push("Git hooks tampered with or missing — reinstalled".to_string());
1447        }
1448        Err(e) => {
1449            logger.warn(&format!("Failed to verify/reinstall hooks: {e}"));
1450        }
1451        Ok(false) => {}
1452    }
1453
1454    // Verify/restore hook permissions (read-only executable 0o555).
1455    #[cfg(unix)]
1456    super::hooks::enforce_hook_permissions(&repo_root, logger);
1457
1458    // Verify/restore track file permissions (read-only 0o444).
1459    #[cfg(unix)]
1460    {
1461        use std::os::unix::fs::PermissionsExt;
1462        if matches!(fs::symlink_metadata(&track_file_path), Ok(m) if m.file_type().is_symlink()) {
1463            logger.warn("Track file path is a symlink — refusing to chmod and attempting repair");
1464            result.tampering_detected = true;
1465            result
1466                .details
1467                .push("Track file was a symlink — refused chmod".to_string());
1468            let _ = fs::remove_file(&track_file_path);
1469            if let Some(dir) =
1470                find_wrapper_dir_on_path().filter(|p| wrapper_dir_is_safe_existing_dir(p))
1471            {
1472                let _ = write_wrapper_track_file_atomic(&repo_root, &dir);
1473            }
1474        } else if let Ok(meta) = fs::metadata(&track_file_path) {
1475            if meta.is_dir() {
1476                logger.warn("Track file path is a directory — quarantining");
1477                result.tampering_detected = true;
1478                result
1479                    .details
1480                    .push("Track file was a directory — quarantined".to_string());
1481                if let Err(e) =
1482                    super::repo::quarantine_path_in_place(&track_file_path, "track-perms")
1483                {
1484                    logger.warn(&format!("Failed to quarantine track file path: {e}"));
1485                }
1486            }
1487            if meta.is_file() {
1488                let mode = meta.permissions().mode() & 0o777;
1489                if mode != 0o444 {
1490                    logger.warn(&format!(
1491                        "Track file permissions loosened ({mode:#o}) — restoring to 0o444"
1492                    ));
1493                    let mut perms = meta.permissions();
1494                    perms.set_mode(0o444);
1495                    let _ = fs::set_permissions(&track_file_path, perms);
1496                    result.tampering_detected = true;
1497                    result.details.push(format!(
1498                        "Track file permissions loosened ({mode:#o}) — restored to 0o444"
1499                    ));
1500                }
1501            }
1502        }
1503    }
1504
1505    // Detect unauthorized commits by comparing HEAD OID against baseline.
1506    if detect_unauthorized_commit(&repo_root) {
1507        logger.warn("CRITICAL: HEAD OID changed — unauthorized commit detected!");
1508        result.tampering_detected = true;
1509        result
1510            .details
1511            .push("HEAD OID changed since last check — unauthorized commit detected".to_string());
1512        // Update stored OID to current HEAD so Ralph's own subsequent commits
1513        // don't trigger false positives.
1514        capture_head_oid(&repo_root);
1515    }
1516
1517    result
1518}
1519
1520/// Remove the git wrapper temp directory using an explicit Ralph metadata dir.
1521fn cleanup_git_wrapper_dir_silent_at(ralph_dir: &Path) {
1522    let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
1523
1524    // Read the track file to find wrapper dir (best-effort).
1525    // Do not gate on sanitize — we want to clean up even if the ralph dir
1526    // has unexpected metadata (sanitize failure should not block cleanup).
1527    if let Some(wrapper_dir) = fs::read_to_string(&track_file)
1528        .ok()
1529        .map(|s| PathBuf::from(s.trim()))
1530    {
1531        // Treat track file as untrusted; only remove plausible wrapper dirs under temp.
1532        remove_wrapper_dir_and_path_entry(&wrapper_dir);
1533    }
1534
1535    // ALWAYS remove the track file. Hooks check marker OR track_file with ||
1536    // logic, so a surviving track file blocks commits even after the marker is
1537    // removed. The wrapper dir in /tmp is harmless and will be cleaned by the OS.
1538    #[cfg(unix)]
1539    add_owner_write_if_not_symlink(&track_file);
1540    let _ = fs::remove_file(&track_file);
1541}
1542
1543/// Clean up a prior wrapper dir tracked in the track file.
1544///
1545/// This prevents /tmp leaks when a prior run was SIGKILL'd and the
1546/// track file still points to an orphaned wrapper dir. It also removes
1547/// stale PATH entries for the old wrapper dir.
1548fn cleanup_prior_wrapper_from_track_file(repo_root: &Path) {
1549    let ralph_dir = super::repo::ralph_git_dir(repo_root);
1550    let Ok(ralph_dir_exists) = super::repo::sanitize_ralph_git_dir_at(&ralph_dir) else {
1551        return;
1552    };
1553    if !ralph_dir_exists {
1554        return;
1555    }
1556
1557    let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
1558    let Ok(content) = fs::read_to_string(&track_file) else {
1559        return;
1560    };
1561    let wrapper_dir = PathBuf::from(content.trim());
1562    if remove_wrapper_dir_and_path_entry(&wrapper_dir) {
1563        let _ = fs::remove_file(&track_file);
1564    }
1565}
1566
1567/// Clean up orphaned wrapper temp dir from a prior crashed run.
1568///
1569/// This is the public entry point for startup-time cleanup in
1570/// `prepare_agent_phase`. It delegates to `cleanup_prior_wrapper_from_track_file`.
1571pub fn cleanup_orphaned_wrapper_at(repo_root: &Path) {
1572    cleanup_prior_wrapper_from_track_file(repo_root);
1573}
1574
1575/// Verify that the wrapper temp dir and track file have been cleaned up.
1576///
1577/// Returns a list of remaining artifacts for diagnostic purposes.
1578/// An empty list means cleanup was successful.
1579#[must_use]
1580pub fn verify_wrapper_cleaned(repo_root: &Path) -> Vec<String> {
1581    let mut remaining = Vec::new();
1582    let track_file = super::repo::ralph_git_dir(repo_root).join(WRAPPER_TRACK_FILE_NAME);
1583    if track_file.exists() {
1584        remaining.push(format!("track file still exists: {}", track_file.display()));
1585        // Also check if the tracked dir still exists.
1586        if let Ok(content) = fs::read_to_string(&track_file) {
1587            let dir = PathBuf::from(content.trim());
1588            if dir.exists() {
1589                remaining.push(format!("wrapper temp dir still exists: {}", dir.display()));
1590            }
1591        }
1592    }
1593    remaining
1594}
1595
1596/// Best-effort cleanup for unexpected exits (Ctrl+C, early-return, panics).
1597///
1598/// Prefers the stored repo root (set during `start_agent_phase`) over CWD-based
1599/// discovery. Falls back to CWD-based `get_repo_root()` if the stored root is
1600/// unavailable.
1601pub fn cleanup_agent_phase_silent() {
1602    // Prefer stored repo root over CWD-based discovery.
1603    // Use try_lock to avoid deadlock if the main thread holds the lock
1604    // when SIGINT arrives.
1605    let repo_root = AGENT_PHASE_REPO_ROOT
1606        .try_lock()
1607        .ok()
1608        .and_then(|guard| guard.clone())
1609        .or_else(|| crate::git_helpers::get_repo_root().ok());
1610
1611    let Some(repo_root) = repo_root else {
1612        return;
1613    };
1614
1615    let stored_ralph_dir = AGENT_PHASE_RALPH_DIR
1616        .try_lock()
1617        .ok()
1618        .and_then(|guard| guard.clone());
1619    let stored_hooks_dir = AGENT_PHASE_HOOKS_DIR
1620        .try_lock()
1621        .ok()
1622        .and_then(|guard| guard.clone());
1623    cleanup_agent_phase_silent_at_internal(
1624        &repo_root,
1625        stored_ralph_dir.as_deref(),
1626        stored_hooks_dir.as_deref(),
1627    );
1628}
1629
1630/// Best-effort cleanup using an explicit repo root.
1631///
1632/// This is the consolidated cleanup function that removes all agent-phase
1633/// artifacts. All sub-operations use the provided repo root instead of
1634/// CWD-based discovery, ensuring reliability even if CWD has changed.
1635///
1636/// Unlike [`cleanup_agent_phase_silent`], this function does NOT read the
1637/// process-global hooks dir mutex. It derives the hooks directory from the
1638/// repo root, making it safe to call from parallel tests without global
1639/// state interference.
1640pub fn cleanup_agent_phase_silent_at(repo_root: &Path) {
1641    cleanup_agent_phase_silent_at_internal(repo_root, None, None);
1642}
1643
1644#[cfg(any(test, feature = "test-utils"))]
1645pub fn set_agent_phase_paths_for_test(
1646    repo_root: Option<PathBuf>,
1647    ralph_dir: Option<PathBuf>,
1648    hooks_dir: Option<PathBuf>,
1649) {
1650    if let Ok(mut guard) = AGENT_PHASE_REPO_ROOT.lock() {
1651        *guard = repo_root;
1652    }
1653    if let Ok(mut guard) = AGENT_PHASE_RALPH_DIR.lock() {
1654        *guard = ralph_dir;
1655    }
1656    if let Ok(mut guard) = AGENT_PHASE_HOOKS_DIR.lock() {
1657        *guard = hooks_dir;
1658    }
1659}
1660
1661#[cfg(any(test, feature = "test-utils"))]
1662#[must_use]
1663pub fn get_agent_phase_paths_for_test() -> (Option<PathBuf>, Option<PathBuf>, Option<PathBuf>) {
1664    let repo_root = AGENT_PHASE_REPO_ROOT
1665        .lock()
1666        .ok()
1667        .and_then(|guard| guard.clone());
1668    let ralph_dir = AGENT_PHASE_RALPH_DIR
1669        .lock()
1670        .ok()
1671        .and_then(|guard| guard.clone());
1672    let hooks_dir = AGENT_PHASE_HOOKS_DIR
1673        .lock()
1674        .ok()
1675        .and_then(|guard| guard.clone());
1676    (repo_root, ralph_dir, hooks_dir)
1677}
1678
1679#[cfg(any(test, feature = "test-utils"))]
1680#[must_use]
1681pub fn agent_phase_test_lock() -> &'static Mutex<()> {
1682    static TEST_LOCK: Mutex<()> = Mutex::new(());
1683    &TEST_LOCK
1684}
1685
1686fn cleanup_agent_phase_silent_at_internal(
1687    repo_root: &Path,
1688    stored_ralph_dir: Option<&Path>,
1689    stored_hooks_dir: Option<&Path>,
1690) {
1691    let computed_ralph_dir;
1692    let ralph_dir = if let Some(ralph_dir) = stored_ralph_dir {
1693        ralph_dir
1694    } else {
1695        computed_ralph_dir = super::repo::ralph_git_dir(repo_root);
1696        &computed_ralph_dir
1697    };
1698    let resolved_hooks_dir = stored_hooks_dir.map(PathBuf::from).or_else(|| {
1699        super::repo::resolve_protection_scope_from(repo_root)
1700            .ok()
1701            .map(|scope| scope.hooks_dir)
1702    });
1703
1704    end_agent_phase_in_repo_at_ralph_dir(repo_root, ralph_dir);
1705    cleanup_git_wrapper_dir_silent_at(ralph_dir);
1706
1707    // Prefer repo-aware cleanup when discovery still works so worktree config
1708    // overrides and shared worktreeConfig state are restored alongside hooks.
1709    if super::repo::resolve_protection_scope_from(repo_root).is_ok() {
1710        super::hooks::uninstall_hooks_silent_at(repo_root);
1711    } else if let Some(hooks_dir) = resolved_hooks_dir.as_deref() {
1712        super::hooks::uninstall_hooks_silent_in_hooks_dir(hooks_dir);
1713    } else {
1714        uninstall_hooks_silent_at(repo_root);
1715    }
1716
1717    // Clean up any stray tmp files not yet removed before attempting remove_dir.
1718    // This handles .git-wrapper-dir.tmp.* files that were not present during
1719    // the earlier end_agent_phase_in_repo_at_ralph_dir call.
1720    cleanup_hook_scoping_state_files(ralph_dir);
1721    remove_scoped_hooks_dir_if_empty(ralph_dir);
1722    cleanup_stray_tmp_files_in_ralph_dir(ralph_dir);
1723    // Best-effort: remove the ralph dir itself now that all artifacts are gone.
1724    // end_agent_phase_in_repo_at_ralph_dir removed marker + head-oid and tried
1725    // remove_dir too early (track file was still present). Now the track file has
1726    // been cleaned by cleanup_git_wrapper_dir_silent_at, so the dir is empty.
1727    remove_ralph_dir_best_effort(ralph_dir);
1728
1729    cleanup_generated_files_silent_at(repo_root);
1730    cleanup_repo_root_ralph_dir_if_empty(repo_root);
1731
1732    clear_agent_phase_global_state();
1733}
1734
1735/// Best-effort removal of the ralph git directory after all artifacts are cleaned.
1736///
1737/// Called after [`end_agent_phase_in_repo`] and [`disable_git_wrapper`] have removed
1738/// all files from `.git/ralph/`. The directory should be empty at this point; this
1739/// call removes it so no empty directory is left behind.
1740///
1741/// Returns `true` when `.git/ralph` no longer exists after cleanup. Uses
1742/// [`fs::remove_dir`] (not `remove_dir_all`) — if the directory is non-empty
1743/// for any reason (e.g., a quarantine file from tamper detection), the call
1744/// leaves it in place for inspection and returns `false`.
1745#[must_use]
1746pub fn try_remove_ralph_dir(repo_root: &Path) -> bool {
1747    let ralph_dir = super::repo::ralph_git_dir(repo_root);
1748    let Ok(ralph_dir_exists) = super::repo::sanitize_ralph_git_dir_at(&ralph_dir) else {
1749        return !ralph_dir.exists();
1750    };
1751    if !ralph_dir_exists {
1752        return true;
1753    }
1754
1755    // Clean up stray temp files from interrupted atomic writes before attempting
1756    // remove_dir. Without this, remove_dir silently fails and the directory is
1757    // left behind across restarts.
1758    cleanup_stray_tmp_files_in_sanitized_ralph_dir(&ralph_dir);
1759    remove_scoped_hooks_dir_if_empty(&ralph_dir);
1760    match fs::remove_dir(&ralph_dir) {
1761        Ok(()) => true,
1762        Err(err) if err.kind() == io::ErrorKind::NotFound => true,
1763        Err(_) => !ralph_dir.exists(),
1764    }
1765}
1766
1767/// Verify that the Ralph metadata dir itself has been removed.
1768///
1769/// Returns a list of remaining artifacts for diagnostic purposes.
1770/// An empty list means cleanup was successful.
1771#[must_use]
1772pub fn verify_ralph_dir_removed(repo_root: &Path) -> Vec<String> {
1773    let ralph_dir = super::repo::ralph_git_dir(repo_root);
1774    let Ok(ralph_dir_exists) = super::repo::sanitize_ralph_git_dir_at(&ralph_dir) else {
1775        return vec![format!(
1776            "could not sanitize ralph directory before verification: {}",
1777            ralph_dir.display()
1778        )];
1779    };
1780    if !ralph_dir_exists {
1781        return Vec::new();
1782    }
1783
1784    let mut remaining = vec![format!("directory still exists: {}", ralph_dir.display())];
1785    match fs::read_dir(&ralph_dir) {
1786        Ok(entries) => {
1787            let mut names = entries
1788                .filter_map(Result::ok)
1789                .map(|entry| entry.file_name().to_string_lossy().into_owned())
1790                .collect::<Vec<_>>();
1791            names.sort();
1792            if !names.is_empty() {
1793                remaining.push(format!("remaining entries: {}", names.join(", ")));
1794            }
1795        }
1796        Err(err) => remaining.push(format!("could not inspect directory contents: {err}")),
1797    }
1798    remaining
1799}
1800
1801/// Remove generated files silently using an explicit repo root.
1802fn cleanup_generated_files_silent_at(repo_root: &Path) {
1803    for file in crate::files::io::agent_files::GENERATED_FILES {
1804        let absolute_path = repo_root.join(file);
1805        let _ = std::fs::remove_file(absolute_path);
1806    }
1807}
1808
1809fn cleanup_hook_scoping_state_files(ralph_dir: &Path) {
1810    for file_name in ["hooks-path.previous", "worktree-config.previous"] {
1811        let path = ralph_dir.join(file_name);
1812        #[cfg(unix)]
1813        add_owner_write_if_not_symlink(&path);
1814        let _ = fs::remove_file(path);
1815    }
1816}
1817
1818fn remove_scoped_hooks_dir_if_empty(ralph_dir: &Path) {
1819    let _ = fs::remove_dir(ralph_dir.join("hooks"));
1820}
1821
1822fn cleanup_repo_root_ralph_dir_if_empty(repo_root: &Path) {
1823    let fallback_ralph_dir = repo_root.join(".git/ralph");
1824    cleanup_hook_scoping_state_files(&fallback_ralph_dir);
1825    remove_scoped_hooks_dir_if_empty(&fallback_ralph_dir);
1826    cleanup_stray_tmp_files_in_ralph_dir(&fallback_ralph_dir);
1827    remove_ralph_dir_best_effort(&fallback_ralph_dir);
1828}
1829
1830fn remove_ralph_dir_best_effort(ralph_dir: &Path) {
1831    if fs::remove_dir(ralph_dir).is_ok() {
1832        return;
1833    }
1834    let Ok(meta) = fs::symlink_metadata(ralph_dir) else {
1835        return;
1836    };
1837    if meta.file_type().is_symlink() || !meta.is_dir() {
1838        return;
1839    }
1840    let _ = fs::remove_dir_all(ralph_dir);
1841}
1842
1843/// Clean up orphaned enforcement marker.
1844///
1845/// # Errors
1846///
1847/// Returns error if the operation fails.
1848pub fn cleanup_orphaned_marker(logger: &Logger) -> io::Result<()> {
1849    let repo_root = get_repo_root()?;
1850    let legacy_marker = legacy_marker_path(&repo_root);
1851    if fs::symlink_metadata(&legacy_marker).is_ok() {
1852        #[cfg(unix)]
1853        {
1854            add_owner_write_if_not_symlink(&legacy_marker);
1855        }
1856        fs::remove_file(&legacy_marker)?;
1857        logger.success("Removed orphaned enforcement marker");
1858        return Ok(());
1859    }
1860
1861    let ralph_dir = super::repo::ralph_git_dir(&repo_root);
1862    if !super::repo::sanitize_ralph_git_dir_at(&ralph_dir)? {
1863        logger.info("No orphaned marker found");
1864        return Ok(());
1865    }
1866    let marker_path = ralph_dir.join(MARKER_FILE_NAME);
1867
1868    if fs::symlink_metadata(&marker_path).is_ok() {
1869        // Make writable before removal (marker is created as read-only 0o444).
1870        #[cfg(unix)]
1871        {
1872            add_owner_write_if_not_symlink(&marker_path);
1873        }
1874        fs::remove_file(&marker_path)?;
1875        logger.success("Removed orphaned enforcement marker");
1876    } else {
1877        logger.info("No orphaned marker found");
1878    }
1879
1880    Ok(())
1881}
1882
1883/// Capture the current HEAD OID and write it to `<git-dir>/ralph/head-oid.txt`.
1884///
1885/// This is called at agent-phase start and after each Ralph-orchestrated commit
1886/// to establish the baseline for unauthorized commit detection.
1887pub fn capture_head_oid(repo_root: &Path) {
1888    let Ok(head_oid) = crate::git_helpers::get_current_head_oid_at(repo_root) else {
1889        return; // No HEAD yet (empty repo) — nothing to capture
1890    };
1891    let _ = write_head_oid_file_atomic(repo_root, head_oid.trim());
1892}
1893
1894fn write_head_oid_file_atomic(repo_root: &Path, oid: &str) -> io::Result<()> {
1895    let ralph_dir = super::repo::ensure_ralph_git_dir(repo_root)?;
1896
1897    let head_oid_path = ralph_dir.join(HEAD_OID_FILE_NAME);
1898    if matches!(fs::symlink_metadata(&head_oid_path), Ok(m) if m.file_type().is_symlink()) {
1899        return Err(io::Error::new(
1900            io::ErrorKind::InvalidData,
1901            "head-oid path is a symlink; refusing to write baseline",
1902        ));
1903    }
1904
1905    let tmp_path = ralph_dir.join(format!(
1906        ".head-oid.tmp.{}.{}",
1907        std::process::id(),
1908        std::time::SystemTime::now()
1909            .duration_since(std::time::UNIX_EPOCH)
1910            .unwrap_or_default()
1911            .as_nanos()
1912    ));
1913
1914    {
1915        let mut tf = OpenOptions::new()
1916            .write(true)
1917            .create_new(true)
1918            .open(&tmp_path)?;
1919        tf.write_all(oid.as_bytes())?;
1920        tf.write_all(b"\n")?;
1921        tf.flush()?;
1922        let _ = tf.sync_all();
1923    }
1924
1925    #[cfg(unix)]
1926    set_readonly_mode_if_not_symlink(&tmp_path, 0o444);
1927    #[cfg(windows)]
1928    {
1929        let mut perms = fs::metadata(&tmp_path)?.permissions();
1930        perms.set_readonly(true);
1931        fs::set_permissions(&tmp_path, perms)?;
1932    }
1933
1934    // Rename is symlink-safe: it replaces the directory entry.
1935    #[cfg(windows)]
1936    {
1937        if head_oid_path.exists() {
1938            let _ = fs::remove_file(&head_oid_path);
1939        }
1940    }
1941    fs::rename(&tmp_path, &head_oid_path)
1942}
1943
1944/// Detect unauthorized commits by comparing current HEAD against stored OID.
1945///
1946/// Returns `true` if HEAD has changed (indicating an unauthorized commit),
1947/// `false` if HEAD matches or comparison is not possible.
1948#[must_use]
1949pub fn detect_unauthorized_commit(repo_root: &Path) -> bool {
1950    let head_oid_path = super::repo::ralph_git_dir(repo_root).join(HEAD_OID_FILE_NAME);
1951    if matches!(fs::symlink_metadata(&head_oid_path), Ok(m) if m.file_type().is_symlink()) {
1952        return false;
1953    }
1954    let Ok(stored_oid) = fs::read_to_string(&head_oid_path) else {
1955        return false; // No stored OID — cannot compare
1956    };
1957    let stored_oid = stored_oid.trim();
1958    if stored_oid.is_empty() {
1959        return false;
1960    }
1961
1962    let Ok(current_oid) = crate::git_helpers::get_current_head_oid_at(repo_root) else {
1963        return false; // Cannot determine current HEAD
1964    };
1965
1966    current_oid.trim() != stored_oid
1967}
1968
1969/// Remove stray atomic-write temp files from the ralph git directory.
1970///
1971/// `write_head_oid_file_atomic` and `write_wrapper_track_file_atomic` create
1972/// temp files (`.head-oid.tmp.PID.NANOS` and `.git-wrapper-dir.tmp.PID.NANOS`)
1973/// and rename them atomically. If the process is killed or the rename fails the
1974/// temp file is left behind and blocks [`fs::remove_dir`] from removing the
1975/// directory.
1976///
1977/// Only files whose names start with the known temp prefixes are removed.
1978/// Quarantine files (`*.ralph.tampered.*`) and other unexpected files are
1979/// intentionally left in place.
1980fn cleanup_stray_tmp_files_in_ralph_dir(ralph_dir: &Path) {
1981    let Ok(ralph_dir_exists) = super::repo::sanitize_ralph_git_dir_at(ralph_dir) else {
1982        return;
1983    };
1984    if !ralph_dir_exists {
1985        return;
1986    }
1987
1988    cleanup_stray_tmp_files_in_sanitized_ralph_dir(ralph_dir);
1989}
1990
1991fn cleanup_stray_tmp_files_in_sanitized_ralph_dir(ralph_dir: &Path) {
1992    let Ok(entries) = fs::read_dir(ralph_dir) else {
1993        return;
1994    };
1995    for entry in entries.flatten() {
1996        let name = entry.file_name();
1997        let name_str = name.to_string_lossy();
1998        if name_str.starts_with(".head-oid.tmp.") || name_str.starts_with(".git-wrapper-dir.tmp.") {
1999            let path = entry.path();
2000            let Ok(meta) = fs::symlink_metadata(&path) else {
2001                continue;
2002            };
2003            let file_type = meta.file_type();
2004            if !file_type.is_file() || file_type.is_symlink() {
2005                continue;
2006            }
2007
2008            // Make writable before removal; temp files may have been set read-only
2009            // by write_head_oid_file_atomic (0o444 / readonly) or
2010            // write_wrapper_track_file_atomic (0o444 / readonly).
2011            relax_temp_cleanup_permissions_if_regular_file(&path);
2012            let _ = fs::remove_file(&path);
2013        }
2014    }
2015}
2016
2017/// Remove the head-oid tracking file, making it writable first if needed.
2018fn remove_head_oid_file_at(ralph_dir: &Path) {
2019    let head_oid_path = ralph_dir.join(HEAD_OID_FILE_NAME);
2020    if fs::symlink_metadata(&head_oid_path).is_err() {
2021        return;
2022    }
2023    #[cfg(unix)]
2024    {
2025        add_owner_write_if_not_symlink(&head_oid_path);
2026    }
2027    let _ = fs::remove_file(&head_oid_path);
2028}
2029
2030// ============================================================================
2031// Workspace-aware variants
2032// ============================================================================
2033
2034/// Relative path used by workspace-aware marker functions.
2035///
2036/// The marker lives at `<git-dir>/ralph/no_agent_commit` on the real filesystem.
2037/// The workspace abstraction represents this as a relative path so `MemoryWorkspace`
2038/// can test the create/remove/exists operations without a real git repository.
2039const MARKER_WORKSPACE_PATH: &str = ".git/ralph/no_agent_commit";
2040const LEGACY_MARKER_WORKSPACE_PATH: &str = ".no_agent_commit";
2041
2042/// Create the agent phase marker file using workspace abstraction.
2043///
2044/// This is a workspace-aware version of the marker file creation that uses
2045/// the Workspace trait for file I/O, making it testable with `MemoryWorkspace`.
2046///
2047/// # Arguments
2048///
2049/// * `workspace` - The workspace to write to
2050///
2051/// # Returns
2052///
2053/// Returns `Ok(())` on success, or an error if the file cannot be created.
2054///
2055/// # Errors
2056///
2057/// Returns error if the operation fails.
2058pub fn create_marker_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
2059    workspace.write(Path::new(MARKER_WORKSPACE_PATH), "")
2060}
2061
2062/// Remove the agent phase marker file using workspace abstraction.
2063///
2064/// This is a workspace-aware version of the marker file removal that uses
2065/// the Workspace trait for file I/O, making it testable with `MemoryWorkspace`.
2066///
2067/// # Arguments
2068///
2069/// * `workspace` - The workspace to operate on
2070///
2071/// # Returns
2072///
2073/// Returns `Ok(())` on success (including if file doesn't exist).
2074///
2075/// # Errors
2076///
2077/// Returns error if the operation fails.
2078pub fn remove_marker_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
2079    workspace.remove_if_exists(Path::new(MARKER_WORKSPACE_PATH))
2080}
2081
2082/// Check if the agent phase marker file exists using workspace abstraction.
2083///
2084/// This is a workspace-aware version that uses the Workspace trait for file I/O,
2085/// making it testable with `MemoryWorkspace`.
2086///
2087/// # Arguments
2088///
2089/// * `workspace` - The workspace to check
2090///
2091/// # Returns
2092///
2093/// Returns `true` if the marker file exists, `false` otherwise.
2094pub fn marker_exists_with_workspace(workspace: &dyn Workspace) -> bool {
2095    workspace.exists(Path::new(MARKER_WORKSPACE_PATH))
2096}
2097
2098/// Clean up orphaned marker file using workspace abstraction.
2099///
2100/// This is a workspace-aware version of `cleanup_orphaned_marker` that uses
2101/// the Workspace trait for file I/O, making it testable with `MemoryWorkspace`.
2102///
2103/// # Arguments
2104///
2105/// * `workspace` - The workspace to operate on
2106/// * `logger` - Logger for output messages
2107///
2108/// # Returns
2109///
2110/// Returns `Ok(())` on success.
2111///
2112/// # Errors
2113///
2114/// Returns error if the operation fails.
2115pub fn cleanup_orphaned_marker_with_workspace(
2116    workspace: &dyn Workspace,
2117    logger: &Logger,
2118) -> io::Result<()> {
2119    let marker_path = Path::new(MARKER_WORKSPACE_PATH);
2120    let legacy_marker_path = Path::new(LEGACY_MARKER_WORKSPACE_PATH);
2121    let removed_marker = if workspace.exists(marker_path) {
2122        workspace.remove(marker_path)?;
2123        true
2124    } else {
2125        false
2126    };
2127    let removed_legacy_marker = if workspace.exists(legacy_marker_path) {
2128        workspace.remove(legacy_marker_path)?;
2129        true
2130    } else {
2131        false
2132    };
2133
2134    if removed_marker || removed_legacy_marker {
2135        logger.success("Removed orphaned enforcement marker");
2136    } else {
2137        logger.info("No orphaned marker found");
2138    }
2139
2140    Ok(())
2141}
2142
2143#[cfg(test)]
2144mod tests {
2145    use super::*;
2146    use crate::workspace::MemoryWorkspace;
2147    use std::sync::Mutex;
2148
2149    static ENV_LOCK: Mutex<()> = Mutex::new(());
2150
2151    struct RestoreEnv {
2152        original_cwd: PathBuf,
2153        original_path: String,
2154    }
2155
2156    struct ClearAgentPhaseStateOnDrop;
2157
2158    impl Drop for ClearAgentPhaseStateOnDrop {
2159        fn drop(&mut self) {
2160            clear_agent_phase_global_state();
2161        }
2162    }
2163
2164    impl Drop for RestoreEnv {
2165        fn drop(&mut self) {
2166            let _ = std::env::set_current_dir(&self.original_cwd);
2167            std::env::set_var("PATH", &self.original_path);
2168        }
2169    }
2170
2171    fn test_wrapper_content() -> String {
2172        make_wrapper_content(
2173            "git",
2174            "/tmp/.git/ralph/no_agent_commit",
2175            "/tmp/.git/ralph/git-wrapper-dir.txt",
2176            "/tmp/repo",
2177            "/tmp/repo/.git",
2178        )
2179    }
2180
2181    #[test]
2182    fn test_wrapper_script_handles_c_flag_before_subcommand() {
2183        // Verify the wrapper script iterates through arguments to skip global flags
2184        // like `-C /path`, `--git-dir=.git`, etc. before identifying the subcommand.
2185        // This ensures `git -C /path commit` is correctly blocked, not just `git commit`.
2186        let content = test_wrapper_content();
2187
2188        assert!(
2189            content.contains("skip_next"),
2190            "wrapper must implement skip_next logic for global flags; got:\n{content}"
2191        );
2192        assert!(
2193            content.contains("-C|--git-dir|--work-tree"),
2194            "wrapper must recognize -C and --git-dir global flags; got:\n{content}"
2195        );
2196        assert!(
2197            content.contains("for arg in"),
2198            "wrapper must iterate arguments to find subcommand; got:\n{content}"
2199        );
2200    }
2201
2202    #[test]
2203    fn test_wrapper_script_treats_git_dir_match_as_sufficient_scope_activation() {
2204        let content = test_wrapper_content();
2205
2206        assert!(
2207            content.contains("target_git_dir=$( 'git' \"${repo_args[@]}\" rev-parse --path-format=absolute --git-dir 2>/dev/null || true )"),
2208            "wrapper must resolve target git-dir independently of show-toplevel; got:\n{content}"
2209        );
2210        assert!(
2211            content.contains("[ \"$normalized_target_git_dir\" = \"$active_git_dir\" ]"),
2212            "wrapper must activate protection when git-dir matches even without a repo root; got:\n{content}"
2213        );
2214    }
2215
2216    #[test]
2217    fn test_create_marker_with_workspace() {
2218        let workspace = MemoryWorkspace::new_test();
2219
2220        // Marker should not exist initially
2221        assert!(!marker_exists_with_workspace(&workspace));
2222
2223        // Create marker
2224        create_marker_with_workspace(&workspace).unwrap();
2225
2226        // Marker should now exist
2227        assert!(marker_exists_with_workspace(&workspace));
2228    }
2229
2230    #[test]
2231    fn test_remove_marker_with_workspace() {
2232        let workspace = MemoryWorkspace::new_test();
2233
2234        // Create marker first
2235        create_marker_with_workspace(&workspace).unwrap();
2236        assert!(marker_exists_with_workspace(&workspace));
2237
2238        // Remove marker
2239        remove_marker_with_workspace(&workspace).unwrap();
2240
2241        // Marker should no longer exist
2242        assert!(!marker_exists_with_workspace(&workspace));
2243    }
2244
2245    #[test]
2246    fn test_remove_marker_with_workspace_nonexistent() {
2247        let workspace = MemoryWorkspace::new_test();
2248
2249        // Removing non-existent marker should succeed silently
2250        remove_marker_with_workspace(&workspace).unwrap();
2251        assert!(!marker_exists_with_workspace(&workspace));
2252    }
2253
2254    #[test]
2255    fn test_cleanup_orphaned_marker_with_workspace_exists() {
2256        let workspace = MemoryWorkspace::new_test().with_file(".no_agent_commit", "");
2257        let logger = Logger::new(crate::logger::Colors { enabled: false });
2258
2259        // Create an orphaned marker
2260        create_marker_with_workspace(&workspace).unwrap();
2261        assert!(marker_exists_with_workspace(&workspace));
2262        assert!(workspace.exists(Path::new(".no_agent_commit")));
2263
2264        // Clean up should remove it
2265        cleanup_orphaned_marker_with_workspace(&workspace, &logger).unwrap();
2266        assert!(!marker_exists_with_workspace(&workspace));
2267        assert!(!workspace.exists(Path::new(".no_agent_commit")));
2268    }
2269
2270    #[test]
2271    fn test_cleanup_orphaned_marker_with_workspace_not_exists() {
2272        let workspace = MemoryWorkspace::new_test();
2273        let logger = Logger::new(crate::logger::Colors { enabled: false });
2274
2275        // No marker exists
2276        assert!(!marker_exists_with_workspace(&workspace));
2277
2278        // Clean up should succeed without error
2279        cleanup_orphaned_marker_with_workspace(&workspace, &logger).unwrap();
2280        assert!(!marker_exists_with_workspace(&workspace));
2281        assert_eq!(MARKER_FILE_NAME, "no_agent_commit");
2282        assert_eq!(WRAPPER_TRACK_FILE_NAME, "git-wrapper-dir.txt");
2283        assert_eq!(HEAD_OID_FILE_NAME, "head-oid.txt");
2284    }
2285
2286    #[test]
2287    fn test_wrapper_script_handles_config_flag_before_subcommand() {
2288        // Verify the wrapper script handles --config (git 2.46+ alias for -c)
2289        // as a flag that takes a value argument, so that
2290        // `git --config core.hooksPath=/dev/null commit` correctly identifies
2291        // "commit" as the subcommand (by skipping the --config value argument).
2292        let content = test_wrapper_content();
2293
2294        assert!(
2295            content.contains("--config|"),
2296            "wrapper must recognize --config as a flag with separate value; got:\n{content}"
2297        );
2298        assert!(
2299            content.contains("--config=*"),
2300            "wrapper must handle --config=value syntax; got:\n{content}"
2301        );
2302    }
2303
2304    #[test]
2305    fn test_wrapper_script_enforces_read_only_allowlist() {
2306        let content = test_wrapper_content();
2307
2308        assert!(
2309            content.contains("read-only allowlist"),
2310            "wrapper should describe allowlist behavior; got:\n{content}"
2311        );
2312        assert!(
2313            content.contains("status|log|diff|show|rev-parse|ls-files|describe"),
2314            "wrapper should explicitly allow read-only lookup commands; got:\n{content}"
2315        );
2316    }
2317
2318    #[test]
2319    fn test_wrapper_script_blocks_stash_except_list() {
2320        let content = test_wrapper_content();
2321        assert!(
2322            content.contains("only 'stash list' allowed"),
2323            "wrapper should only allow stash list; got:\n{content}"
2324        );
2325    }
2326
2327    #[test]
2328    fn test_wrapper_script_blocks_branch_positional_args() {
2329        let content = test_wrapper_content();
2330        assert!(
2331            content.contains("git branch disabled during agent phase"),
2332            "wrapper should block positional git branch invocations via the branch allowlist; got:\n{content}"
2333        );
2334    }
2335
2336    #[test]
2337    fn test_wrapper_script_blocks_flag_only_mutating_branch_forms() {
2338        let content = test_wrapper_content();
2339        assert!(
2340            content.contains("--unset-upstream"),
2341            "wrapper branch allowlist should explicitly reject mutating flag-only forms; got:\n{content}"
2342        );
2343    }
2344
2345    #[test]
2346    fn test_wrapper_script_uses_absolute_marker_paths() {
2347        let content = test_wrapper_content();
2348        // Wrapper now embeds absolute paths to marker and track file, not a repo root variable.
2349        assert!(
2350            !content.contains("protected_repo_root"),
2351            "wrapper should not use protected_repo_root variable; got:\n{content}"
2352        );
2353        assert!(
2354            content.contains("marker='/tmp/.git/ralph/no_agent_commit'"),
2355            "wrapper should embed absolute marker path; got:\n{content}"
2356        );
2357        assert!(
2358            content.contains("track_file='/tmp/.git/ralph/git-wrapper-dir.txt'"),
2359            "wrapper should embed absolute track file path; got:\n{content}"
2360        );
2361    }
2362
2363    #[test]
2364    fn test_protection_check_result_default_is_no_tampering() {
2365        let result = ProtectionCheckResult::default();
2366        assert!(!result.tampering_detected);
2367        assert!(result.details.is_empty());
2368    }
2369
2370    #[test]
2371    fn test_wrapper_script_unsets_git_env_vars() {
2372        let content = test_wrapper_content();
2373        // Wrapper must unset GIT_DIR, GIT_WORK_TREE, and GIT_EXEC_PATH
2374        // when agent-phase protections are active to prevent env var bypass.
2375        for var in &["GIT_DIR", "GIT_WORK_TREE", "GIT_EXEC_PATH"] {
2376            assert!(
2377                content.contains(&format!("unset {var}")),
2378                "wrapper must unset {var} when marker exists; got:\n{content}"
2379            );
2380        }
2381    }
2382
2383    #[test]
2384    fn test_wrapper_script_documents_command_builtin_behavior() {
2385        let content = test_wrapper_content();
2386        // Wrapper should document that `command git` still routes through
2387        // the PATH wrapper (command only skips shell functions/aliases, not PATH entries).
2388        assert!(
2389            content.contains("command") && content.contains("PATH"),
2390            "wrapper must document that `command` builtin still routes through PATH wrapper; got:\n{content}"
2391        );
2392    }
2393
2394    // =========================================================================
2395    // HEAD OID comparison tests
2396    // =========================================================================
2397
2398    #[test]
2399    fn test_detect_unauthorized_commit_no_stored_oid() {
2400        // When no head-oid.txt exists, detection should return false (no panic).
2401        let tmp = tempfile::tempdir().unwrap();
2402        assert!(!detect_unauthorized_commit(tmp.path()));
2403    }
2404
2405    #[test]
2406    fn test_detect_unauthorized_commit_empty_stored_oid() {
2407        let tmp = tempfile::tempdir().unwrap();
2408        // Head OID now lives in <git-dir>/ralph/ (fallback: .git/ralph/ for plain temp dirs)
2409        let ralph_dir = tmp.path().join(".git").join("ralph");
2410        fs::create_dir_all(&ralph_dir).unwrap();
2411        fs::write(ralph_dir.join("head-oid.txt"), "").unwrap();
2412        assert!(!detect_unauthorized_commit(tmp.path()));
2413    }
2414
2415    #[test]
2416    fn test_write_wrapper_track_file_atomic_repairs_directory_tamper() {
2417        // If the wrapper track file path exists as a directory, treat it as tampering.
2418        // The wrapper must recover (quarantine/remove the directory) and write a real file.
2419        let tmp = tempfile::tempdir().unwrap();
2420        let repo_root = tmp.path();
2421
2422        // Track file now lives in .git/ralph/ (fallback for plain temp dirs).
2423        let ralph_dir = repo_root.join(".git").join("ralph");
2424        fs::create_dir_all(&ralph_dir).unwrap();
2425
2426        // Create a directory at the track file path.
2427        let track_dir_path = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
2428        fs::create_dir_all(&track_dir_path).unwrap();
2429        fs::write(track_dir_path.join("payload.txt"), "do not delete").unwrap();
2430
2431        let wrapper_dir = repo_root.join("some-wrapper-dir");
2432        fs::create_dir_all(&wrapper_dir).unwrap();
2433
2434        write_wrapper_track_file_atomic(repo_root, &wrapper_dir).unwrap();
2435
2436        let track_file_path = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
2437        let meta = fs::metadata(&track_file_path).unwrap();
2438        assert!(meta.is_file(), "track file path should be a file");
2439        let content = fs::read_to_string(&track_file_path).unwrap();
2440        assert!(
2441            content.contains(&wrapper_dir.display().to_string()),
2442            "track file should contain wrapper dir path; got: {content}"
2443        );
2444
2445        // Quarantine should preserve prior directory contents by renaming in-place.
2446        let quarantined = fs::read_dir(&ralph_dir)
2447            .unwrap()
2448            .filter_map(Result::ok)
2449            .any(|e| {
2450                e.file_name()
2451                    .to_string_lossy()
2452                    .starts_with("git-wrapper-dir.txt.ralph.tampered.track")
2453            });
2454        assert!(
2455            quarantined,
2456            "expected quarantined track dir entry in .git/ralph/"
2457        );
2458    }
2459
2460    #[test]
2461    fn test_repair_marker_path_converts_directory_to_regular_file() {
2462        // If the marker path exists as a directory, treat it as tampering and
2463        // recover by quarantining it and creating a regular file marker.
2464        let tmp = tempfile::tempdir().unwrap();
2465        let repo_root = tmp.path();
2466
2467        // Marker now lives in .git/ralph/ (fallback for plain temp dirs).
2468        let ralph_dir = repo_root.join(".git").join("ralph");
2469        fs::create_dir_all(&ralph_dir).unwrap();
2470        let marker_path = ralph_dir.join(MARKER_FILE_NAME);
2471        fs::create_dir_all(&marker_path).unwrap();
2472
2473        // Function under test: must quarantine the directory and create a file marker.
2474        repair_marker_path_if_tampered(repo_root).unwrap();
2475
2476        let meta = fs::metadata(&marker_path).unwrap();
2477        assert!(meta.is_file(), "marker path should be a regular file");
2478
2479        let quarantined = fs::read_dir(&ralph_dir)
2480            .unwrap()
2481            .filter_map(Result::ok)
2482            .any(|e| {
2483                e.file_name()
2484                    .to_string_lossy()
2485                    .starts_with("no_agent_commit.ralph.tampered.marker")
2486            });
2487        assert!(
2488            quarantined,
2489            "expected quarantined marker dir entry in .git/ralph/"
2490        );
2491    }
2492
2493    #[cfg(unix)]
2494    #[test]
2495    fn test_create_marker_in_repo_root_quarantines_special_file() {
2496        use std::os::unix::fs::symlink;
2497        use std::os::unix::fs::FileTypeExt;
2498        use std::os::unix::net::UnixListener;
2499
2500        // If the marker path exists as a special file (e.g., socket/FIFO),
2501        // we must not treat it as a valid marker. Quarantine/replace it with
2502        // a regular file so the `-f` checks used by hooks/wrapper cannot be bypassed.
2503        let tmp = tempfile::tempdir().unwrap();
2504        let repo_root = tmp.path();
2505
2506        // Marker now lives in .git/ralph/ (fallback for plain temp dirs).
2507        let ralph_dir = repo_root.join(".git").join("ralph");
2508        fs::create_dir_all(&ralph_dir).unwrap();
2509        let marker_path = ralph_dir.join(MARKER_FILE_NAME);
2510        let created_socket = match UnixListener::bind(&marker_path) {
2511            Ok(listener) => {
2512                drop(listener);
2513                true
2514            }
2515            Err(err) if err.kind() == io::ErrorKind::PermissionDenied => {
2516                let fallback_target = ralph_dir.join("marker-symlink-target");
2517                fs::write(&fallback_target, b"blocked special file fallback").unwrap();
2518                symlink(&fallback_target, &marker_path).unwrap();
2519                false
2520            }
2521            Err(err) => panic!("failed to create non-regular marker path: {err}"),
2522        };
2523
2524        let ft = fs::symlink_metadata(&marker_path).unwrap().file_type();
2525        assert!(
2526            ft.is_socket() || (!created_socket && ft.is_symlink()),
2527            "precondition: marker path should be a socket or fallback symlink"
2528        );
2529
2530        create_marker_in_repo_root(repo_root).unwrap();
2531
2532        let meta = fs::symlink_metadata(&marker_path).unwrap();
2533        assert!(meta.is_file(), "marker path should be a regular file");
2534
2535        let quarantined = fs::read_dir(&ralph_dir)
2536            .unwrap()
2537            .filter_map(Result::ok)
2538            .any(|e| {
2539                e.file_name()
2540                    .to_string_lossy()
2541                    .starts_with("no_agent_commit.ralph.tampered.marker")
2542            });
2543        assert!(
2544            quarantined,
2545            "expected quarantined special marker entry in .git/ralph/"
2546        );
2547    }
2548
2549    #[cfg(unix)]
2550    #[test]
2551    fn test_ensure_agent_phase_protections_recreates_marker_after_permissions_quarantine() {
2552        use std::time::Duration;
2553
2554        let _lock = ENV_LOCK.lock().unwrap();
2555
2556        let repo_dir = tempfile::tempdir().unwrap();
2557        let _repo = git2::Repository::init(repo_dir.path()).unwrap();
2558
2559        let original_cwd = std::env::current_dir().unwrap();
2560        let original_path = std::env::var("PATH").unwrap_or_default();
2561        let _restore = RestoreEnv {
2562            original_cwd,
2563            original_path: original_path.clone(),
2564        };
2565
2566        std::env::set_current_dir(repo_dir.path()).unwrap();
2567
2568        // Seed a valid marker so marker_exists is computed true.
2569        // Marker lives in <git-dir>/ralph/ — for a real repo that is .git/ralph/.
2570        let ralph_dir = repo_dir.path().join(".git").join("ralph");
2571        fs::create_dir_all(&ralph_dir).unwrap();
2572        let marker_path = ralph_dir.join(MARKER_FILE_NAME);
2573        fs::write(&marker_path, b"").unwrap();
2574        assert!(fs::symlink_metadata(&marker_path).unwrap().is_file());
2575
2576        // Provide a plausible wrapper dir on PATH so the protection check does enough work
2577        // to let us deterministically tamper with the marker after marker_exists is computed.
2578        let wrapper_dir = tempfile::Builder::new()
2579            .prefix(WRAPPER_DIR_PREFIX)
2580            .tempdir_in(std::env::temp_dir())
2581            .unwrap();
2582        // Intentionally bloat PATH to slow down the protection check between the
2583        // marker_exists snapshot and the marker-permissions verification block.
2584        let slow_paths = (0..1000)
2585            .map(|i| {
2586                format!(
2587                    "{}/ralph-nonexistent-path-{i}",
2588                    std::env::temp_dir().display()
2589                )
2590            })
2591            .collect::<Vec<_>>()
2592            .join(":");
2593        let new_path = format!(
2594            "{}:{slow_paths}:{original_path}",
2595            wrapper_dir.path().display()
2596        );
2597        std::env::set_var("PATH", new_path);
2598
2599        // Run the protection check in another thread so this thread can reliably
2600        // perform the mid-check tampering before the marker-permissions block runs.
2601        let ensure_thread = std::thread::spawn(|| {
2602            let logger = Logger::new(crate::logger::Colors { enabled: false });
2603            ensure_agent_phase_protections(&logger)
2604        });
2605
2606        // Ensure the protection check has taken its marker_exists snapshot.
2607        std::thread::sleep(Duration::from_millis(10));
2608
2609        let _ = fs::remove_file(&marker_path);
2610        let _ = fs::remove_dir_all(&marker_path);
2611        fs::create_dir(&marker_path).unwrap();
2612
2613        let _result = ensure_thread.join().unwrap();
2614
2615        // Regression: even if the marker is swapped to a non-file mid-check, the final state
2616        // must include a regular file marker.
2617        let meta = fs::symlink_metadata(&marker_path).unwrap();
2618        assert!(
2619            meta.is_file(),
2620            "marker should be recreated as a regular file"
2621        );
2622    }
2623
2624    // =========================================================================
2625    // cleanup_agent_phase_silent_at tests
2626    // =========================================================================
2627
2628    #[test]
2629    fn test_cleanup_agent_phase_silent_at_removes_marker() {
2630        let tmp = tempfile::tempdir().unwrap();
2631        let repo_root = tmp.path();
2632        // Marker lives in .git/ralph/ (fallback for plain temp dirs).
2633        let ralph_dir = repo_root.join(".git").join("ralph");
2634        fs::create_dir_all(&ralph_dir).unwrap();
2635        let marker = ralph_dir.join(MARKER_FILE_NAME);
2636        fs::write(&marker, "").unwrap();
2637
2638        cleanup_agent_phase_silent_at(repo_root);
2639
2640        assert!(
2641            !marker.exists(),
2642            "marker should be removed by cleanup_agent_phase_silent_at"
2643        );
2644    }
2645
2646    #[test]
2647    fn test_cleanup_agent_phase_silent_at_removes_head_oid() {
2648        let tmp = tempfile::tempdir().unwrap();
2649        let repo_root = tmp.path();
2650        // Head OID lives in .git/ralph/ (fallback for plain temp dirs).
2651        let ralph_dir = repo_root.join(".git").join("ralph");
2652        fs::create_dir_all(&ralph_dir).unwrap();
2653        let head_oid = ralph_dir.join(HEAD_OID_FILE_NAME);
2654        fs::write(&head_oid, "abc123\n").unwrap();
2655
2656        cleanup_agent_phase_silent_at(repo_root);
2657
2658        assert!(
2659            !head_oid.exists(),
2660            "head-oid.txt should be removed by cleanup_agent_phase_silent_at"
2661        );
2662    }
2663
2664    #[test]
2665    fn test_cleanup_agent_phase_silent_at_removes_generated_files() {
2666        let tmp = tempfile::tempdir().unwrap();
2667        let repo_root = tmp.path();
2668        let agent_dir = repo_root.join(".agent");
2669        fs::create_dir_all(&agent_dir).unwrap();
2670        // Enforcement state is now in .git/ralph/ — NOT in working tree.
2671        let ralph_dir = repo_root.join(".git").join("ralph");
2672        fs::create_dir_all(&ralph_dir).unwrap();
2673        fs::write(ralph_dir.join(MARKER_FILE_NAME), "").unwrap();
2674
2675        // Working-tree generated files.
2676        fs::write(agent_dir.join("PLAN.md"), "plan").unwrap();
2677        fs::write(agent_dir.join("commit-message.txt"), "msg").unwrap();
2678        fs::write(agent_dir.join("checkpoint.json.tmp"), "{}").unwrap();
2679
2680        cleanup_agent_phase_silent_at(repo_root);
2681
2682        // Enforcement-state files removed via end_agent_phase_in_repo.
2683        assert!(
2684            !ralph_dir.join(MARKER_FILE_NAME).exists(),
2685            "marker should be removed by cleanup_agent_phase_silent_at"
2686        );
2687
2688        // Working-tree GENERATED_FILES also removed.
2689        for file in crate::files::io::agent_files::GENERATED_FILES {
2690            let path = repo_root.join(file);
2691            assert!(
2692                !path.exists(),
2693                "{file} should be removed by cleanup_agent_phase_silent_at"
2694            );
2695        }
2696    }
2697
2698    #[test]
2699    fn test_cleanup_agent_phase_silent_at_removes_wrapper_track_file() {
2700        let tmp = tempfile::tempdir().unwrap();
2701        let repo_root = tmp.path();
2702        // Track file lives in .git/ralph/ (fallback for plain temp dirs).
2703        let ralph_dir = repo_root.join(".git").join("ralph");
2704        fs::create_dir_all(&ralph_dir).unwrap();
2705
2706        // Create a track file pointing to a non-existent wrapper dir (safe to clean)
2707        let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
2708        fs::write(&track_file, "/nonexistent/wrapper/dir\n").unwrap();
2709
2710        cleanup_agent_phase_silent_at(repo_root);
2711
2712        assert!(
2713            !track_file.exists(),
2714            "wrapper track file should be removed by cleanup_agent_phase_silent_at"
2715        );
2716    }
2717
2718    #[test]
2719    fn test_cleanup_agent_phase_silent_at_idempotent() {
2720        let tmp = tempfile::tempdir().unwrap();
2721        let repo_root = tmp.path();
2722
2723        // Running on an empty directory should not panic or error
2724        cleanup_agent_phase_silent_at(repo_root);
2725        cleanup_agent_phase_silent_at(repo_root);
2726    }
2727
2728    // =========================================================================
2729    // AGENT_PHASE_REPO_ROOT global tests
2730    // =========================================================================
2731
2732    #[test]
2733    fn test_agent_phase_repo_root_mutex_is_accessible() {
2734        // Verify the global Mutex is lockable (not poisoned or stuck).
2735        assert!(
2736            AGENT_PHASE_REPO_ROOT.try_lock().is_ok(),
2737            "AGENT_PHASE_REPO_ROOT mutex should be lockable"
2738        );
2739    }
2740
2741    // =========================================================================
2742    // cleanup_prior_wrapper / cleanup_orphaned_wrapper_at tests
2743    // =========================================================================
2744
2745    #[test]
2746    fn test_cleanup_prior_wrapper_removes_tracked_dir() {
2747        let _lock = ENV_LOCK.lock().unwrap();
2748        let tmp = tempfile::tempdir().unwrap();
2749        let repo_root = tmp.path();
2750        // Track file now lives in .git/ralph/ (fallback for plain temp dirs).
2751        let ralph_dir = repo_root.join(".git").join("ralph");
2752        fs::create_dir_all(&ralph_dir).unwrap();
2753
2754        // Create a fake wrapper dir in temp with the correct prefix.
2755        let wrapper_dir = tempfile::Builder::new()
2756            .prefix(WRAPPER_DIR_PREFIX)
2757            .tempdir()
2758            .unwrap();
2759        let wrapper_dir_path = wrapper_dir.keep();
2760
2761        // Write track file pointing to the wrapper dir.
2762        let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
2763        fs::write(&track_file, format!("{}\n", wrapper_dir_path.display())).unwrap();
2764
2765        assert!(
2766            wrapper_dir_path.exists(),
2767            "precondition: wrapper dir exists"
2768        );
2769
2770        cleanup_orphaned_wrapper_at(repo_root);
2771
2772        assert!(
2773            !wrapper_dir_path.exists(),
2774            "wrapper dir should be removed by cleanup"
2775        );
2776        assert!(
2777            !track_file.exists(),
2778            "track file should be removed by cleanup"
2779        );
2780    }
2781
2782    #[test]
2783    fn test_cleanup_prior_wrapper_no_track_file() {
2784        let tmp = tempfile::tempdir().unwrap();
2785        let repo_root = tmp.path();
2786
2787        // No track file exists — cleanup should be a no-op.
2788        cleanup_orphaned_wrapper_at(repo_root);
2789
2790        // No panic, no error.
2791    }
2792
2793    #[test]
2794    fn test_cleanup_prior_wrapper_stale_track_file() {
2795        let tmp = tempfile::tempdir().unwrap();
2796        let repo_root = tmp.path();
2797        // Track file now lives in .git/ralph/ (fallback for plain temp dirs).
2798        let ralph_dir = repo_root.join(".git").join("ralph");
2799        fs::create_dir_all(&ralph_dir).unwrap();
2800
2801        // Track file points to a non-existent dir.
2802        let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
2803        fs::write(&track_file, "/nonexistent/ralph-git-wrapper-stale\n").unwrap();
2804
2805        cleanup_orphaned_wrapper_at(repo_root);
2806
2807        assert!(
2808            !track_file.exists(),
2809            "stale track file should be removed by cleanup"
2810        );
2811    }
2812
2813    // =========================================================================
2814    // verify_wrapper_cleaned tests
2815    // =========================================================================
2816
2817    #[test]
2818    fn test_verify_wrapper_cleaned_empty_when_clean() {
2819        let tmp = tempfile::tempdir().unwrap();
2820        let repo_root = tmp.path();
2821
2822        let remaining = verify_wrapper_cleaned(repo_root);
2823        assert!(
2824            remaining.is_empty(),
2825            "verify_wrapper_cleaned should return empty when no artifacts remain"
2826        );
2827    }
2828
2829    #[test]
2830    fn test_verify_wrapper_cleaned_reports_remaining_track_file() {
2831        let tmp = tempfile::tempdir().unwrap();
2832        let repo_root = tmp.path();
2833        // Track file now lives in .git/ralph/ (fallback for plain temp dirs).
2834        let ralph_dir = repo_root.join(".git").join("ralph");
2835        fs::create_dir_all(&ralph_dir).unwrap();
2836
2837        let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
2838        fs::write(&track_file, "/nonexistent/dir\n").unwrap();
2839
2840        let remaining = verify_wrapper_cleaned(repo_root);
2841        assert!(
2842            !remaining.is_empty(),
2843            "verify_wrapper_cleaned should report remaining track file"
2844        );
2845        assert!(
2846            remaining[0].contains("track file still exists"),
2847            "should mention track file: {remaining:?}"
2848        );
2849    }
2850
2851    #[test]
2852    fn test_verify_wrapper_cleaned_reports_remaining_dir() {
2853        let tmp = tempfile::tempdir().unwrap();
2854        let repo_root = tmp.path();
2855        // Track file now lives in .git/ralph/ (fallback for plain temp dirs).
2856        let ralph_dir = repo_root.join(".git").join("ralph");
2857        fs::create_dir_all(&ralph_dir).unwrap();
2858
2859        // Create a real wrapper dir that still exists.
2860        let wrapper_dir = tempfile::Builder::new()
2861            .prefix(WRAPPER_DIR_PREFIX)
2862            .tempdir()
2863            .unwrap();
2864        let wrapper_dir_path = wrapper_dir.keep();
2865
2866        let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
2867        fs::write(&track_file, format!("{}\n", wrapper_dir_path.display())).unwrap();
2868
2869        let remaining = verify_wrapper_cleaned(repo_root);
2870        assert!(
2871            remaining.len() >= 2,
2872            "should report both track file and wrapper dir: {remaining:?}"
2873        );
2874
2875        // Clean up the wrapper dir manually.
2876        let _ = fs::remove_dir_all(&wrapper_dir_path);
2877    }
2878
2879    #[cfg(unix)]
2880    #[test]
2881    fn test_disable_git_wrapper_removes_track_file_even_when_dir_removal_fails() {
2882        use std::os::unix::fs::PermissionsExt;
2883
2884        let _lock = ENV_LOCK.lock().unwrap();
2885        let repo_root_tmp = tempfile::tempdir().unwrap();
2886        let repo_root = repo_root_tmp.path();
2887        // Track file now lives in .git/ralph/ (fallback for plain temp dirs).
2888        let ralph_dir = repo_root.join(".git").join("ralph");
2889        fs::create_dir_all(&ralph_dir).unwrap();
2890
2891        let blocked_parent = tempfile::tempdir_in(env::temp_dir()).unwrap();
2892        let wrapper_dir_path = blocked_parent.path().join("ralph-git-wrapper-blocked");
2893        fs::create_dir(&wrapper_dir_path).unwrap();
2894        fs::write(wrapper_dir_path.join("git"), "#!/bin/sh\n").unwrap();
2895
2896        let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
2897        fs::write(&track_file, format!("{}\n", wrapper_dir_path.display())).unwrap();
2898
2899        let original_parent_mode = fs::metadata(blocked_parent.path())
2900            .unwrap()
2901            .permissions()
2902            .mode();
2903        let mut parent_permissions = fs::metadata(blocked_parent.path()).unwrap().permissions();
2904        parent_permissions.set_mode(0o555);
2905        fs::set_permissions(blocked_parent.path(), parent_permissions).unwrap();
2906
2907        let mut helpers = GitHelpers {
2908            wrapper_dir: Some(wrapper_dir_path.clone()),
2909            wrapper_repo_root: Some(repo_root.to_path_buf()),
2910            ..GitHelpers::default()
2911        };
2912
2913        disable_git_wrapper(&mut helpers);
2914
2915        // Track file MUST be removed even when the wrapper dir removal fails.
2916        // Hooks check marker OR track_file, so leaving it behind blocks commits.
2917        assert!(
2918            !track_file.exists(),
2919            "track file must be removed unconditionally, even when wrapper dir removal fails"
2920        );
2921
2922        let mut restore_permissions = fs::metadata(blocked_parent.path()).unwrap().permissions();
2923        restore_permissions.set_mode(original_parent_mode);
2924        fs::set_permissions(blocked_parent.path(), restore_permissions).unwrap();
2925        fs::remove_dir_all(&wrapper_dir_path).unwrap();
2926    }
2927
2928    // =========================================================================
2929    // ralph dir removal tests (TDD: these fail until try_remove_ralph_dir fix lands)
2930    // =========================================================================
2931
2932    #[test]
2933    fn test_cleanup_agent_phase_silent_at_removes_ralph_dir_when_all_artifacts_present() {
2934        // Simulates active agent phase state: marker, head-oid, and track file all present.
2935        // After cleanup, all files AND the directory itself must be gone.
2936        let tmp = tempfile::tempdir().unwrap();
2937        let repo_root = tmp.path();
2938        let ralph_dir = repo_root.join(".git").join("ralph");
2939        fs::create_dir_all(&ralph_dir).unwrap();
2940        fs::write(ralph_dir.join(MARKER_FILE_NAME), "").unwrap();
2941        fs::write(ralph_dir.join(HEAD_OID_FILE_NAME), "abc123\n").unwrap();
2942        // Track file pointing to a non-existent wrapper dir (safe to clean).
2943        fs::write(
2944            ralph_dir.join(WRAPPER_TRACK_FILE_NAME),
2945            "/nonexistent/wrapper\n",
2946        )
2947        .unwrap();
2948
2949        cleanup_agent_phase_silent_at(repo_root);
2950
2951        assert!(
2952            !ralph_dir.exists(),
2953            ".git/ralph/ should be fully removed after cleanup_agent_phase_silent_at; \
2954             all artifacts were removed but the directory still exists"
2955        );
2956    }
2957
2958    #[test]
2959    fn test_cleanup_removes_ralph_dir_when_stray_head_oid_tmp_file_exists() {
2960        // Simulates a crash mid-write_head_oid_file_atomic that left a temp file.
2961        // cleanup_agent_phase_silent_at must remove the stray temp file and the directory.
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        // Stray temp file left by an interrupted write_head_oid_file_atomic.
2968        let stray = ralph_dir.join(format!(".head-oid.tmp.{}.123456789", std::process::id()));
2969        fs::write(&stray, "deadbeef\n").unwrap();
2970        // Make it read-only as the atomic writer does.
2971        #[cfg(unix)]
2972        {
2973            use std::os::unix::fs::PermissionsExt;
2974            let mut perms = fs::metadata(&stray).unwrap().permissions();
2975            perms.set_mode(0o444);
2976            fs::set_permissions(&stray, perms).unwrap();
2977        }
2978
2979        cleanup_agent_phase_silent_at(repo_root);
2980
2981        assert!(
2982            !ralph_dir.exists(),
2983            ".git/ralph/ should be fully removed even when a stray .head-oid.tmp.* file exists"
2984        );
2985    }
2986
2987    #[test]
2988    fn test_cleanup_removes_ralph_dir_when_stray_wrapper_track_tmp_file_exists() {
2989        // Simulates a crash mid-write_wrapper_track_file_atomic that left a temp file.
2990        // cleanup_agent_phase_silent_at must remove the stray temp file and the directory.
2991        let tmp = tempfile::tempdir().unwrap();
2992        let repo_root = tmp.path();
2993        let ralph_dir = repo_root.join(".git").join("ralph");
2994        fs::create_dir_all(&ralph_dir).unwrap();
2995        fs::write(ralph_dir.join(MARKER_FILE_NAME), "").unwrap();
2996        // Stray temp file left by an interrupted write_wrapper_track_file_atomic.
2997        let stray = ralph_dir.join(format!(
2998            ".git-wrapper-dir.tmp.{}.987654321",
2999            std::process::id()
3000        ));
3001        fs::write(&stray, "/tmp/some-wrapper-dir\n").unwrap();
3002        // Make it read-only as the atomic writer does.
3003        #[cfg(unix)]
3004        {
3005            use std::os::unix::fs::PermissionsExt;
3006            let mut perms = fs::metadata(&stray).unwrap().permissions();
3007            perms.set_mode(0o444);
3008            fs::set_permissions(&stray, perms).unwrap();
3009        }
3010
3011        cleanup_agent_phase_silent_at(repo_root);
3012
3013        assert!(
3014            !ralph_dir.exists(),
3015            ".git/ralph/ should be fully removed even when a stray .git-wrapper-dir.tmp.* file exists"
3016        );
3017    }
3018
3019    #[test]
3020    fn test_try_remove_ralph_dir_removes_dir_containing_only_stray_tmp_files() {
3021        // try_remove_ralph_dir must handle a directory containing only stray temp files.
3022        let tmp = tempfile::tempdir().unwrap();
3023        let repo_root = tmp.path();
3024        let ralph_dir = repo_root.join(".git").join("ralph");
3025        fs::create_dir_all(&ralph_dir).unwrap();
3026        // Stray head-oid temp file only (no other artifacts).
3027        let stray = ralph_dir.join(".head-oid.tmp.99999.111111111");
3028        fs::write(&stray, "cafebabe\n").unwrap();
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        let removed = try_remove_ralph_dir(repo_root);
3038
3039        assert!(
3040            removed,
3041            "try_remove_ralph_dir should report success when the directory is gone"
3042        );
3043        assert!(
3044            !ralph_dir.exists(),
3045            ".git/ralph/ should be fully removed by try_remove_ralph_dir when only stray tmp files remain"
3046        );
3047    }
3048
3049    #[test]
3050    fn test_try_remove_ralph_dir_reports_failure_when_unexpected_artifact_remains() {
3051        let tmp = tempfile::tempdir().unwrap();
3052        let repo_root = tmp.path();
3053        let ralph_dir = repo_root.join(".git").join("ralph");
3054        fs::create_dir_all(&ralph_dir).unwrap();
3055        fs::write(ralph_dir.join("quarantine.bin"), "keep").unwrap();
3056
3057        let removed = try_remove_ralph_dir(repo_root);
3058
3059        assert!(
3060            !removed,
3061            "try_remove_ralph_dir should report failure when .git/ralph remains on disk"
3062        );
3063        let remaining = verify_ralph_dir_removed(repo_root);
3064        assert!(
3065            remaining
3066                .iter()
3067                .any(|entry| entry.contains("directory still exists")),
3068            "verification should report that the directory still exists: {remaining:?}"
3069        );
3070        assert!(
3071            remaining
3072                .iter()
3073                .any(|entry| entry.contains("quarantine.bin")),
3074            "verification should report the unexpected artifact that blocked removal: {remaining:?}"
3075        );
3076    }
3077
3078    #[test]
3079    #[cfg(unix)]
3080    fn test_try_remove_ralph_dir_quarantines_symlinked_ralph_dir_without_touching_target() {
3081        use std::os::unix::fs::symlink;
3082
3083        let tmp = tempfile::tempdir().unwrap();
3084        let repo_root = tmp.path();
3085        let git_dir = repo_root.join(".git");
3086        fs::create_dir_all(&git_dir).unwrap();
3087
3088        let outside_dir = repo_root.join("outside-ralph-target");
3089        fs::create_dir_all(&outside_dir).unwrap();
3090        let outside_tmp = outside_dir.join(".head-oid.tmp.12345.999");
3091        fs::write(&outside_tmp, "keep me\n").unwrap();
3092
3093        let ralph_dir = git_dir.join("ralph");
3094        symlink(&outside_dir, &ralph_dir).unwrap();
3095
3096        let removed = try_remove_ralph_dir(repo_root);
3097
3098        assert!(
3099            removed,
3100            "try_remove_ralph_dir should treat a quarantined symlink as cleaned up"
3101        );
3102        assert!(
3103            !ralph_dir.exists(),
3104            ".git/ralph path should be removed after quarantining the symlink"
3105        );
3106        assert!(
3107            outside_tmp.exists(),
3108            "cleanup must not follow a symlinked .git/ralph and delete temp-like files in the target directory"
3109        );
3110
3111        let quarantined = fs::read_dir(&git_dir)
3112            .unwrap()
3113            .filter_map(Result::ok)
3114            .map(|entry| entry.file_name().to_string_lossy().into_owned())
3115            .find(|name| name.starts_with("ralph.ralph.tampered.dir."))
3116            .unwrap_or_default();
3117        assert!(
3118            !quarantined.is_empty(),
3119            "symlinked .git/ralph should be quarantined for inspection"
3120        );
3121    }
3122
3123    #[test]
3124    #[cfg(unix)]
3125    fn test_verify_ralph_dir_removed_quarantines_symlinked_ralph_dir_without_touching_target() {
3126        use std::os::unix::fs::symlink;
3127
3128        let tmp = tempfile::tempdir().unwrap();
3129        let repo_root = tmp.path();
3130        let git_dir = repo_root.join(".git");
3131        fs::create_dir_all(&git_dir).unwrap();
3132
3133        let outside_dir = repo_root.join("outside-verify-target");
3134        fs::create_dir_all(&outside_dir).unwrap();
3135        let outside_tmp = outside_dir.join(".git-wrapper-dir.tmp.12345.999");
3136        fs::write(&outside_tmp, "keep me too\n").unwrap();
3137
3138        let ralph_dir = git_dir.join("ralph");
3139        symlink(&outside_dir, &ralph_dir).unwrap();
3140
3141        let remaining = verify_ralph_dir_removed(repo_root);
3142
3143        assert!(
3144            remaining.is_empty(),
3145            "verification should report .git/ralph as removed after quarantining the symlink: {remaining:?}"
3146        );
3147        assert!(
3148            !ralph_dir.exists(),
3149            ".git/ralph path should no longer exist after verification sanitizes it"
3150        );
3151        assert!(
3152            outside_tmp.exists(),
3153            "verification must not follow a symlinked .git/ralph and inspect/delete the target directory"
3154        );
3155
3156        let quarantined = fs::read_dir(&git_dir)
3157            .unwrap()
3158            .filter_map(Result::ok)
3159            .map(|entry| entry.file_name().to_string_lossy().into_owned())
3160            .find(|name| name.starts_with("ralph.ralph.tampered.dir."))
3161            .unwrap_or_default();
3162        assert!(
3163            !quarantined.is_empty(),
3164            "verification should quarantine a symlinked .git/ralph path for inspection"
3165        );
3166    }
3167
3168    #[test]
3169    #[cfg(unix)]
3170    fn test_cleanup_stray_tmp_files_in_ralph_dir_ignores_symlink_entries() {
3171        use std::os::unix::fs::symlink;
3172
3173        let tmp = tempfile::tempdir().unwrap();
3174        let repo_root = tmp.path();
3175        let ralph_dir = repo_root.join(".git").join("ralph");
3176        fs::create_dir_all(&ralph_dir).unwrap();
3177
3178        let outside_target = repo_root.join("outside-target.txt");
3179        fs::write(&outside_target, "keep readonly mode untouched\n").unwrap();
3180        let symlink_path = ralph_dir.join(".head-oid.tmp.99999.222222222");
3181        symlink(&outside_target, &symlink_path).unwrap();
3182
3183        cleanup_stray_tmp_files_in_ralph_dir(&ralph_dir);
3184
3185        assert!(
3186            symlink_path.exists(),
3187            "cleanup must skip temp-name symlinks instead of deleting them"
3188        );
3189        let target_contents = fs::read_to_string(&outside_target).unwrap();
3190        assert_eq!(target_contents, "keep readonly mode untouched\n");
3191    }
3192
3193    #[test]
3194    #[cfg(windows)]
3195    fn test_cleanup_stray_tmp_files_in_ralph_dir_removes_readonly_files_on_windows() {
3196        let tmp = tempfile::tempdir().unwrap();
3197        let repo_root = tmp.path();
3198        let ralph_dir = repo_root.join(".git").join("ralph");
3199        fs::create_dir_all(&ralph_dir).unwrap();
3200
3201        let stray = ralph_dir.join(".head-oid.tmp.99999.333333333");
3202        fs::write(&stray, "deadbeef\n").unwrap();
3203        let mut perms = fs::metadata(&stray).unwrap().permissions();
3204        perms.set_readonly(true);
3205        fs::set_permissions(&stray, perms).unwrap();
3206
3207        cleanup_stray_tmp_files_in_ralph_dir(&ralph_dir);
3208
3209        assert!(
3210            !stray.exists(),
3211            "cleanup must clear the readonly attribute before removing stray temp files on Windows"
3212        );
3213    }
3214
3215    #[test]
3216    fn test_cleanup_agent_phase_silent_at_removes_ralph_hooks() {
3217        // Verifies that Ralph-managed hooks are removed when cleanup uses the precomputed
3218        // hooks dir derived from ralph_dir.parent() (bypasses extra libgit2 discovery).
3219        use crate::git_helpers::hooks;
3220
3221        let tmp = tempfile::tempdir().unwrap();
3222        let repo_root = tmp.path();
3223        // Use a real git repo so libgit2 discovery succeeds for all code paths.
3224        let _repo = git2::Repository::init(repo_root).unwrap();
3225
3226        let hooks_dir = repo_root.join(".git").join("hooks");
3227        fs::create_dir_all(&hooks_dir).unwrap();
3228        let ralph_dir = repo_root.join(".git").join("ralph");
3229        fs::create_dir_all(&ralph_dir).unwrap();
3230
3231        // Install read-only Ralph-managed hooks.
3232        let hook_content = format!("#!/bin/bash\n# {}\nexit 0\n", hooks::HOOK_MARKER);
3233        for hook_name in hooks::RALPH_HOOK_NAMES {
3234            let hook_path = hooks_dir.join(hook_name);
3235            fs::write(&hook_path, &hook_content).unwrap();
3236            #[cfg(unix)]
3237            {
3238                use std::os::unix::fs::PermissionsExt;
3239                let mut perms = fs::metadata(&hook_path).unwrap().permissions();
3240                perms.set_mode(0o555);
3241                fs::set_permissions(&hook_path, perms).unwrap();
3242            }
3243        }
3244
3245        cleanup_agent_phase_silent_at(repo_root);
3246
3247        for hook_name in hooks::RALPH_HOOK_NAMES {
3248            let hook_path = hooks_dir.join(hook_name);
3249            let still_has_marker = hook_path.exists()
3250                && crate::files::file_contains_marker(&hook_path, hooks::HOOK_MARKER)
3251                    .unwrap_or(false);
3252            assert!(
3253                !still_has_marker,
3254                "Ralph hook {hook_name} should be removed by cleanup_agent_phase_silent_at"
3255            );
3256        }
3257    }
3258
3259    #[cfg(unix)]
3260    #[test]
3261    fn test_cleanup_removes_readonly_marker_and_track_file() {
3262        use std::os::unix::fs::PermissionsExt;
3263
3264        let tmp = tempfile::tempdir().unwrap();
3265        let repo_root = tmp.path();
3266        let ralph_dir = repo_root.join(".git/ralph");
3267        fs::create_dir_all(&ralph_dir).unwrap();
3268
3269        // Create read-only marker (0o444).
3270        let marker = ralph_dir.join(MARKER_FILE_NAME);
3271        fs::write(&marker, "").unwrap();
3272        fs::set_permissions(&marker, fs::Permissions::from_mode(0o444)).unwrap();
3273
3274        // Create read-only track file (0o444).
3275        let track = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
3276        fs::write(&track, "/nonexistent\n").unwrap();
3277        fs::set_permissions(&track, fs::Permissions::from_mode(0o444)).unwrap();
3278
3279        end_agent_phase_in_repo_at_ralph_dir(repo_root, &ralph_dir);
3280        cleanup_git_wrapper_dir_silent_at(&ralph_dir);
3281
3282        assert!(!marker.exists(), "read-only marker should be removed");
3283        assert!(!track.exists(), "read-only track file should be removed");
3284    }
3285
3286    #[test]
3287    fn test_cleanup_is_idempotent_called_twice() {
3288        let tmp = tempfile::tempdir().unwrap();
3289        let repo_root = tmp.path();
3290        let ralph_dir = repo_root.join(".git/ralph");
3291        fs::create_dir_all(&ralph_dir).unwrap();
3292
3293        let marker = ralph_dir.join(MARKER_FILE_NAME);
3294        fs::write(&marker, "").unwrap();
3295
3296        // First cleanup.
3297        cleanup_agent_phase_silent_at(repo_root);
3298        assert!(!marker.exists());
3299
3300        // Second cleanup — should not panic or error.
3301        cleanup_agent_phase_silent_at(repo_root);
3302        assert!(!marker.exists());
3303    }
3304
3305    #[test]
3306    fn test_cleanup_agent_phase_silent_at_from_worktree_root() {
3307        let tmp = tempfile::tempdir().unwrap();
3308        let main_repo = git2::Repository::init(tmp.path()).unwrap();
3309        {
3310            let mut index = main_repo.index().unwrap();
3311            let tree_oid = index.write_tree().unwrap();
3312            let tree = main_repo.find_tree(tree_oid).unwrap();
3313            let sig = git2::Signature::now("test", "test@test.com").unwrap();
3314            main_repo
3315                .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
3316                .unwrap();
3317        }
3318
3319        let wt_path = tmp.path().join("wt-cleanup");
3320        let _wt = main_repo.worktree("wt-cleanup", &wt_path, None).unwrap();
3321
3322        let worktree_scope = crate::git_helpers::resolve_protection_scope_from(&wt_path).unwrap();
3323        let worktree_ralph_dir = worktree_scope.git_dir.join("ralph");
3324        crate::git_helpers::hooks::install_hooks_in_repo(&wt_path).unwrap();
3325        fs::write(worktree_ralph_dir.join(MARKER_FILE_NAME), "").unwrap();
3326        // Also create a track file pointing to a nonexistent wrapper dir.
3327        fs::write(
3328            worktree_ralph_dir.join(WRAPPER_TRACK_FILE_NAME),
3329            "/nonexistent/wrapper\n",
3330        )
3331        .unwrap();
3332
3333        assert!(
3334            worktree_scope
3335                .worktree_config_path
3336                .as_ref()
3337                .unwrap()
3338                .exists(),
3339            "precondition: linked worktree cleanup test must start with config.worktree present"
3340        );
3341
3342        // Cleanup from the WORKTREE root — must clean only worktree-local artifacts.
3343        cleanup_agent_phase_silent_at(&wt_path);
3344
3345        assert!(
3346            !worktree_ralph_dir.join(MARKER_FILE_NAME).exists(),
3347            "marker at worktree git dir should be removed when cleaning from worktree root"
3348        );
3349        assert!(
3350            !worktree_ralph_dir.join(WRAPPER_TRACK_FILE_NAME).exists(),
3351            "track file at worktree git dir should be removed when cleaning from worktree root"
3352        );
3353        for name in crate::git_helpers::hooks::RALPH_HOOK_NAMES {
3354            let hook_path = worktree_scope.hooks_dir.join(name);
3355            let still_ralph = hook_path.exists()
3356                && crate::files::file_contains_marker(
3357                    &hook_path,
3358                    crate::git_helpers::hooks::HOOK_MARKER,
3359                )
3360                .unwrap_or(false);
3361            assert!(
3362                !still_ralph,
3363                "hook {name} at worktree hooks dir should be removed from worktree root"
3364            );
3365        }
3366
3367        assert!(
3368            !worktree_scope
3369                .worktree_config_path
3370                .as_ref()
3371                .unwrap()
3372                .exists(),
3373            "silent cleanup should remove the worktree-local config override"
3374        );
3375        let common_config = crate::git_helpers::resolve_protection_scope_from(&wt_path)
3376            .unwrap()
3377            .common_git_dir
3378            .join("config");
3379        assert!(
3380            common_config.exists(),
3381            "common config should remain inspectable after cleanup"
3382        );
3383        assert_eq!(
3384            git2::Config::open(&common_config)
3385                .unwrap()
3386                .get_string("extensions.worktreeConfig")
3387                .ok(),
3388            None,
3389            "silent cleanup should restore shared worktreeConfig state for a linked worktree run"
3390        );
3391    }
3392
3393    #[test]
3394    fn test_cleanup_agent_phase_silent_at_from_root_repo_restores_root_worktree_scoping() {
3395        let tmp = tempfile::tempdir().unwrap();
3396        let main_repo_path = tmp.path().join("main");
3397        fs::create_dir_all(&main_repo_path).unwrap();
3398        let main_repo = git2::Repository::init(&main_repo_path).unwrap();
3399        {
3400            let mut index = main_repo.index().unwrap();
3401            let tree_oid = index.write_tree().unwrap();
3402            let tree = main_repo.find_tree(tree_oid).unwrap();
3403            let sig = git2::Signature::now("test", "test@test.com").unwrap();
3404            main_repo
3405                .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
3406                .unwrap();
3407        }
3408
3409        let sibling_path = tmp.path().join("wt-sibling");
3410        let _sibling = main_repo
3411            .worktree("wt-sibling", &sibling_path, None)
3412            .unwrap();
3413
3414        let root_scope =
3415            crate::git_helpers::resolve_protection_scope_from(&main_repo_path).unwrap();
3416        let common_config = root_scope.common_git_dir.join("config");
3417        let root_config = root_scope.worktree_config_path.unwrap();
3418
3419        crate::git_helpers::hooks::install_hooks_in_repo(&main_repo_path).unwrap();
3420        assert!(
3421            root_config.exists(),
3422            "precondition: root config.worktree must exist"
3423        );
3424        assert_eq!(
3425            git2::Config::open(&common_config)
3426                .unwrap()
3427                .get_string("extensions.worktreeConfig")
3428                .ok(),
3429            Some("true".to_string()),
3430            "precondition: root worktree install should enable shared worktreeConfig"
3431        );
3432
3433        cleanup_agent_phase_silent_at(&main_repo_path);
3434
3435        assert!(
3436            !root_config.exists(),
3437            "silent cleanup should remove the root worktree config override"
3438        );
3439        assert_eq!(
3440            git2::Config::open(&common_config)
3441                .unwrap()
3442                .get_string("extensions.worktreeConfig")
3443                .ok(),
3444            None,
3445            "silent cleanup should restore shared worktreeConfig state for a root run"
3446        );
3447    }
3448
3449    // =========================================================================
3450    // Unconditional track file removal tests
3451    // =========================================================================
3452
3453    #[test]
3454    fn test_track_file_removed_even_when_wrapper_dir_cleanup_fails() {
3455        // The track file must be removed unconditionally because hooks use OR logic
3456        // (marker OR track_file) to block commits. If the wrapper dir in /tmp can't
3457        // be removed, the track file should still be cleaned so hooks don't block.
3458        let tmp = tempfile::tempdir().unwrap();
3459        let ralph_dir = tmp.path().join(".git").join("ralph");
3460        fs::create_dir_all(&ralph_dir).unwrap();
3461
3462        // Write a track file pointing to a path that does NOT exist under system
3463        // temp dir (so wrapper_dir_is_safe_existing_dir returns false and the
3464        // wrapper dir cleanup "fails" in the sense that the dir wasn't cleaned).
3465        let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
3466        fs::write(&track_file, "/not-a-real-temp-dir/ralph-git-wrapper-fake\n").unwrap();
3467
3468        cleanup_git_wrapper_dir_silent_at(&ralph_dir);
3469
3470        assert!(
3471            !track_file.exists(),
3472            "track file must be removed unconditionally, even when wrapper dir cleanup fails"
3473        );
3474    }
3475
3476    #[test]
3477    fn test_disable_git_wrapper_always_removes_track_file() {
3478        // disable_git_wrapper must always remove the track file regardless of
3479        // whether the wrapper dir could be cleaned up.
3480        let tmp = tempfile::tempdir().unwrap();
3481        let repo_root = tmp.path();
3482        let ralph_dir = repo_root.join(".git").join("ralph");
3483        fs::create_dir_all(&ralph_dir).unwrap();
3484
3485        // Write a read-only track file pointing to a non-existent path.
3486        let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
3487        fs::write(&track_file, "/nonexistent/not-temp-prefixed\n").unwrap();
3488        #[cfg(unix)]
3489        {
3490            use std::os::unix::fs::PermissionsExt;
3491            let mut perms = fs::metadata(&track_file).unwrap().permissions();
3492            perms.set_mode(0o444);
3493            fs::set_permissions(&track_file, perms).unwrap();
3494        }
3495
3496        let mut helpers = GitHelpers {
3497            wrapper_dir: None,
3498            wrapper_repo_root: Some(repo_root.to_path_buf()),
3499            ..GitHelpers::default()
3500        };
3501
3502        disable_git_wrapper(&mut helpers);
3503
3504        assert!(
3505            !track_file.exists(),
3506            "track file must be removed unconditionally by disable_git_wrapper"
3507        );
3508    }
3509
3510    // =========================================================================
3511    // Global mutex clearing tests
3512    // =========================================================================
3513
3514    #[test]
3515    fn test_global_mutexes_not_cleared_by_end_agent_phase_in_repo() {
3516        let _lock = ENV_LOCK.lock().unwrap();
3517        // end_agent_phase_in_repo must NOT clear global mutexes — callers are
3518        // responsible for calling clear_agent_phase_global_state after ALL cleanup.
3519        //
3520        // IMPORTANT: This test modifies process-global mutexes, so we must:
3521        // 1. Use ClearOnDrop guard to ensure cleanup even on panic.
3522        // 2. Only set REPO_ROOT and RALPH_DIR (not HOOKS_DIR) to avoid
3523        //    poisoning parallel tests that call cleanup_agent_phase_silent_at,
3524        //    which reads HOOKS_DIR to determine the hooks cleanup location.
3525        let _guard = ClearAgentPhaseStateOnDrop;
3526        let _test_lock = agent_phase_test_lock().lock().unwrap();
3527
3528        let tmp = tempfile::tempdir().unwrap();
3529        let repo_root = tmp.path();
3530        let ralph_dir = repo_root.join(".git").join("ralph");
3531        fs::create_dir_all(&ralph_dir).unwrap();
3532        fs::write(ralph_dir.join(MARKER_FILE_NAME), "").unwrap();
3533
3534        // Only set REPO_ROOT and RALPH_DIR. Skip HOOKS_DIR to avoid
3535        // interfering with parallel tests that read it for cleanup.
3536        set_agent_phase_paths_for_test(Some(repo_root.to_path_buf()), Some(ralph_dir), None);
3537
3538        end_agent_phase_in_repo(repo_root);
3539
3540        // REPO_ROOT and RALPH_DIR should still hold their values.
3541        let repo_root_val = AGENT_PHASE_REPO_ROOT.lock().unwrap().clone();
3542        assert!(
3543            repo_root_val.is_some(),
3544            "AGENT_PHASE_REPO_ROOT should NOT be cleared by end_agent_phase_in_repo"
3545        );
3546
3547        let ralph_dir_val = AGENT_PHASE_RALPH_DIR.lock().unwrap().clone();
3548        assert!(
3549            ralph_dir_val.is_some(),
3550            "AGENT_PHASE_RALPH_DIR should NOT be cleared by end_agent_phase_in_repo"
3551        );
3552        // _guard's Drop clears mutexes even on panic.
3553    }
3554
3555    #[test]
3556    fn test_clear_agent_phase_global_state_clears_all_mutexes() {
3557        let _lock = ENV_LOCK.lock().unwrap();
3558        set_agent_phase_paths_for_test(
3559            Some(PathBuf::from("/test/repo")),
3560            Some(PathBuf::from("/test/repo/.git/ralph")),
3561            Some(PathBuf::from("/test/repo/.git/hooks")),
3562        );
3563
3564        clear_agent_phase_global_state();
3565
3566        assert!(
3567            AGENT_PHASE_REPO_ROOT.lock().unwrap().is_none(),
3568            "AGENT_PHASE_REPO_ROOT should be cleared"
3569        );
3570        assert!(
3571            AGENT_PHASE_RALPH_DIR.lock().unwrap().is_none(),
3572            "AGENT_PHASE_RALPH_DIR should be cleared"
3573        );
3574        assert!(
3575            AGENT_PHASE_HOOKS_DIR.lock().unwrap().is_none(),
3576            "AGENT_PHASE_HOOKS_DIR should be cleared"
3577        );
3578    }
3579
3580    // =========================================================================
3581    // Comprehensive cleanup test
3582    // =========================================================================
3583
3584    /// Helper: install all agent-phase artifacts for comprehensive cleanup tests.
3585    fn install_all_agent_phase_artifacts(repo_root: &Path) {
3586        use crate::git_helpers::hooks;
3587
3588        let ralph_dir = repo_root.join(".git").join("ralph");
3589        fs::create_dir_all(&ralph_dir).unwrap();
3590        let hooks_dir = repo_root.join(".git").join("hooks");
3591        fs::create_dir_all(&hooks_dir).unwrap();
3592
3593        let marker = ralph_dir.join(MARKER_FILE_NAME);
3594        fs::write(&marker, "").unwrap();
3595        #[cfg(unix)]
3596        {
3597            use std::os::unix::fs::PermissionsExt;
3598            fs::set_permissions(&marker, fs::Permissions::from_mode(0o444)).unwrap();
3599        }
3600
3601        let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
3602        fs::write(&track_file, "/nonexistent/wrapper\n").unwrap();
3603        #[cfg(unix)]
3604        {
3605            use std::os::unix::fs::PermissionsExt;
3606            fs::set_permissions(&track_file, fs::Permissions::from_mode(0o444)).unwrap();
3607        }
3608
3609        fs::write(ralph_dir.join(HEAD_OID_FILE_NAME), "abc123\n").unwrap();
3610
3611        let hook_content = format!("#!/bin/bash\n# {}\nexit 0\n", hooks::HOOK_MARKER);
3612        for name in hooks::RALPH_HOOK_NAMES {
3613            let hook_path = hooks_dir.join(name);
3614            fs::write(&hook_path, &hook_content).unwrap();
3615            #[cfg(unix)]
3616            {
3617                use std::os::unix::fs::PermissionsExt;
3618                fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o555)).unwrap();
3619            }
3620        }
3621
3622        set_agent_phase_paths_for_test(
3623            Some(repo_root.to_path_buf()),
3624            Some(ralph_dir),
3625            Some(hooks_dir),
3626        );
3627    }
3628
3629    #[test]
3630    fn test_cleanup_agent_phase_silent_at_removes_all_artifacts_including_track_file() {
3631        use crate::git_helpers::hooks;
3632
3633        let _lock = ENV_LOCK.lock().unwrap();
3634        let tmp = tempfile::tempdir().unwrap();
3635        let repo_root = tmp.path();
3636        let _repo = git2::Repository::init(repo_root).unwrap();
3637
3638        install_all_agent_phase_artifacts(repo_root);
3639        cleanup_agent_phase_silent_at(repo_root);
3640
3641        let ralph_dir = repo_root.join(".git").join("ralph");
3642        let hooks_dir = repo_root.join(".git").join("hooks");
3643
3644        assert!(
3645            !ralph_dir.join(MARKER_FILE_NAME).exists(),
3646            "marker should be removed"
3647        );
3648        assert!(
3649            !ralph_dir.join(WRAPPER_TRACK_FILE_NAME).exists(),
3650            "track file should be removed"
3651        );
3652        assert!(
3653            !ralph_dir.join(HEAD_OID_FILE_NAME).exists(),
3654            "head-oid should be removed"
3655        );
3656        assert!(!ralph_dir.exists(), "ralph dir should be removed");
3657        for name in hooks::RALPH_HOOK_NAMES {
3658            let hook_path = hooks_dir.join(name);
3659            let still_ralph = hook_path.exists()
3660                && crate::files::file_contains_marker(&hook_path, hooks::HOOK_MARKER)
3661                    .unwrap_or(false);
3662            assert!(!still_ralph, "hook {name} should be removed");
3663        }
3664
3665        // Global mutexes should be cleared.
3666        assert!(AGENT_PHASE_REPO_ROOT.lock().unwrap().is_none());
3667        assert!(AGENT_PHASE_RALPH_DIR.lock().unwrap().is_none());
3668        assert!(AGENT_PHASE_HOOKS_DIR.lock().unwrap().is_none());
3669    }
3670}