1use 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
28const MARKER_FILE_NAME: &str = "no_agent_commit";
30const WRAPPER_TRACK_FILE_NAME: &str = "git-wrapper-dir.txt";
32const 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
37static AGENT_PHASE_REPO_ROOT: Mutex<Option<PathBuf>> = Mutex::new(None);
43
44static AGENT_PHASE_RALPH_DIR: Mutex<Option<PathBuf>> = Mutex::new(None);
49
50static AGENT_PHASE_HOOKS_DIR: Mutex<Option<PathBuf>> = Mutex::new(None);
56
57#[derive(Debug, Clone, Default)]
63pub struct ProtectionCheckResult {
64 pub tampering_detected: bool,
66 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 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
188pub 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 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
218fn escape_shell_single_quoted(path: &str) -> io::Result<String> {
224 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 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 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 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 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 #[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
410fn 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
601fn enable_git_wrapper_at(repo_root: &Path, helpers: &mut GitHelpers) -> io::Result<()> {
603 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 return Ok(());
613 };
614
615 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 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 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 #[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 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 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 #[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 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 write_wrapper_track_file_atomic(repo_root, &wrapper_dir_path)?;
753
754 helpers.wrapper_dir = Some(wrapper_dir_path);
755 Ok(())
756}
757
758pub 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 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 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 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 #[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 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
846pub 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
856pub 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 let ralph_dir = super::repo::ralph_git_dir(repo_root);
866
867 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 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 repair_marker_path_if_tampered(repo_root)?;
884 #[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(repo_root);
892 Ok(())
893}
894
895pub 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
903pub 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
918pub 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 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 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 #[cfg(unix)]
950 add_owner_write_if_not_symlink(&marker_path);
951 let _ = fs::remove_file(&marker_path);
952
953 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#[must_use]
987pub fn ensure_agent_phase_protections(logger: &Logger) -> ProtectionCheckResult {
988 let mut result = ProtectionCheckResult::default();
989
990 let Ok(scope) = super::repo::resolve_protection_scope() else {
991 return result;
992 };
993 let repo_root = scope.repo_root.clone();
994
995 let ralph_dir = scope.ralph_dir.clone();
996 let marker_path = ralph_dir.join(MARKER_FILE_NAME);
997 if let Ok(meta) = fs::symlink_metadata(&marker_path) {
998 let ft = meta.file_type();
999 let is_regular_file = ft.is_file() && !ft.is_symlink();
1000 if !is_regular_file {
1001 logger.warn("Enforcement marker is not a regular file — quarantining and recreating");
1002 result.tampering_detected = true;
1003 result
1004 .details
1005 .push("Enforcement marker was not a regular file — quarantined".to_string());
1006 if let Err(e) = super::repo::quarantine_path_in_place(&marker_path, "marker") {
1007 logger.warn(&format!("Failed to quarantine marker path: {e}"));
1008 result
1009 .details
1010 .push("Marker path quarantine failed".to_string());
1011 }
1012 }
1013 }
1014
1015 let marker_meta = fs::symlink_metadata(&marker_path).ok();
1016 let marker_exists = marker_meta
1017 .as_ref()
1018 .is_some_and(|m| m.file_type().is_file() && !m.file_type().is_symlink());
1019
1020 let track_file_path = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
1026 if let Ok(meta) = fs::symlink_metadata(&track_file_path) {
1027 let ft = meta.file_type();
1028 let is_regular_file = ft.is_file() && !ft.is_symlink();
1029 if !is_regular_file {
1030 logger.warn("Git wrapper tracking path is not a regular file — quarantining");
1031 result.tampering_detected = true;
1032 result
1033 .details
1034 .push("Git wrapper tracking path was not a regular file — quarantined".to_string());
1035 if let Err(e) = super::repo::quarantine_path_in_place(&track_file_path, "track") {
1036 logger.warn(&format!("Failed to quarantine wrapper tracking path: {e}"));
1037 result
1038 .details
1039 .push("Wrapper tracking path quarantine failed".to_string());
1040 }
1041 }
1042 }
1043
1044 let tracked_wrapper_dir = fs::read_to_string(&track_file_path).ok().and_then(|s| {
1045 let p = PathBuf::from(s.trim());
1046 if wrapper_dir_is_safe_existing_dir(&p) && wrapper_dir_is_on_path(&p) {
1047 Some(p)
1048 } else {
1049 None
1050 }
1051 });
1052
1053 let path_wrapper_dir =
1054 find_wrapper_dir_on_path().filter(|p| wrapper_dir_is_safe_existing_dir(p));
1055
1056 let wrapper_dir = tracked_wrapper_dir.clone().or(path_wrapper_dir);
1057
1058 if let Some(ref dir) = wrapper_dir {
1060 ensure_wrapper_dir_prepended_to_path(dir);
1061 }
1062
1063 if tracked_wrapper_dir.is_none() {
1065 if let Some(ref dir) = wrapper_dir {
1066 logger.warn("Git wrapper tracking file missing or invalid — restoring");
1067 result.tampering_detected = true;
1068 result
1069 .details
1070 .push("Git wrapper tracking file missing or invalid — restored".to_string());
1071
1072 if let Err(e) = write_wrapper_track_file_atomic(&repo_root, dir) {
1074 logger.warn(&format!("Failed to restore wrapper tracking file: {e}"));
1075 }
1076 }
1077 }
1078
1079 if let Some(wrapper_dir) = wrapper_dir {
1081 let wrapper_path = wrapper_dir.join("git");
1082 let wrapper_needs_restore = fs::read_to_string(&wrapper_path).map_or(true, |content| {
1083 !content.contains(WRAPPER_MARKER) || !content.contains("unset GIT_EXEC_PATH")
1084 });
1085
1086 if wrapper_needs_restore {
1087 logger.warn("Git wrapper script missing or tampered — restoring");
1088 result.tampering_detected = true;
1089 result
1090 .details
1091 .push("Git wrapper script missing or tampered — restored".to_string());
1092
1093 let real_git =
1095 find_git_in_path_excluding_dir(&wrapper_dir).or_else(|| which("git").ok());
1096
1097 match real_git {
1098 Some(real_git_path) => {
1099 let Some(real_git_str) = real_git_path.to_str() else {
1100 logger.warn(
1101 "Resolved git binary path is not valid UTF-8; cannot restore wrapper",
1102 );
1103 return result;
1104 };
1105 let Ok(git_path_escaped) = escape_shell_single_quoted(real_git_str) else {
1106 logger.warn("Failed to generate safe wrapper script (git path)");
1107 return result;
1108 };
1109 let marker_p = ralph_dir.join(MARKER_FILE_NAME);
1110 let track_p = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
1111 let Some(marker_str) = marker_p.to_str() else {
1112 logger.warn("Marker path is not valid UTF-8; cannot restore wrapper");
1113 return result;
1114 };
1115 let Some(track_str) = track_p.to_str() else {
1116 logger.warn("Track file path is not valid UTF-8; cannot restore wrapper");
1117 return result;
1118 };
1119 let Ok(marker_escaped) = escape_shell_single_quoted(marker_str) else {
1120 logger.warn("Failed to generate safe wrapper script (marker path)");
1121 return result;
1122 };
1123 let Ok(track_escaped) = escape_shell_single_quoted(track_str) else {
1124 logger.warn("Failed to generate safe wrapper script (track file path)");
1125 return result;
1126 };
1127 let normalized_repo_root = normalize_protection_scope_path(&repo_root);
1128 let normalized_git_dir = normalize_protection_scope_path(&scope.git_dir);
1129 let Some(repo_root_str) = normalized_repo_root.to_str() else {
1130 logger.warn("Repo root is not valid UTF-8; cannot restore wrapper");
1131 return result;
1132 };
1133 let Some(git_dir_str) = normalized_git_dir.to_str() else {
1134 logger.warn("Git dir is not valid UTF-8; cannot restore wrapper");
1135 return result;
1136 };
1137 let Ok(repo_root_escaped) = escape_shell_single_quoted(repo_root_str) else {
1138 logger.warn("Failed to generate safe wrapper script (repo root)");
1139 return result;
1140 };
1141 let Ok(git_dir_escaped) = escape_shell_single_quoted(git_dir_str) else {
1142 logger.warn("Failed to generate safe wrapper script (git dir)");
1143 return result;
1144 };
1145
1146 let wrapper_content = make_wrapper_content(
1147 &git_path_escaped,
1148 &marker_escaped,
1149 &track_escaped,
1150 &repo_root_escaped,
1151 &git_dir_escaped,
1152 );
1153
1154 let tmp_path = wrapper_dir.join(format!(
1155 ".git-wrapper.tmp.{}.{}",
1156 std::process::id(),
1157 std::time::SystemTime::now()
1158 .duration_since(std::time::UNIX_EPOCH)
1159 .unwrap_or_default()
1160 .as_nanos()
1161 ));
1162
1163 let open_tmp = {
1164 #[cfg(unix)]
1165 {
1166 use std::os::unix::fs::OpenOptionsExt;
1167 OpenOptions::new()
1168 .write(true)
1169 .create_new(true)
1170 .custom_flags(libc::O_NOFOLLOW)
1171 .open(&tmp_path)
1172 }
1173 #[cfg(not(unix))]
1174 {
1175 OpenOptions::new()
1176 .write(true)
1177 .create_new(true)
1178 .open(&tmp_path)
1179 }
1180 };
1181
1182 match open_tmp.and_then(|mut f| {
1183 f.write_all(wrapper_content.as_bytes())?;
1184 f.flush()?;
1185 let _ = f.sync_all();
1186 Ok(())
1187 }) {
1188 Ok(()) => {
1189 #[cfg(unix)]
1190 {
1191 use std::os::unix::fs::PermissionsExt;
1192 if let Ok(meta) = fs::metadata(&tmp_path) {
1193 let mut perms = meta.permissions();
1194 perms.set_mode(0o555);
1195 let _ = fs::set_permissions(&tmp_path, perms);
1196 }
1197 }
1198 #[cfg(windows)]
1199 {
1200 if let Ok(meta) = fs::metadata(&tmp_path) {
1201 let mut perms = meta.permissions();
1202 perms.set_readonly(true);
1203 let _ = fs::set_permissions(&tmp_path, perms);
1204 }
1205 if wrapper_path.exists() {
1206 let _ = fs::remove_file(&wrapper_path);
1207 }
1208 }
1209 if let Err(e) = fs::rename(&tmp_path, &wrapper_path) {
1210 let _ = fs::remove_file(&tmp_path);
1211 logger.warn(&format!("Failed to restore wrapper script: {e}"));
1212 }
1213 }
1214 Err(e) => {
1215 logger.warn(&format!("Failed to write wrapper temp file: {e}"));
1216 }
1217 }
1218
1219 if real_git_path == wrapper_path {
1221 logger.warn(
1222 "Resolved git binary points to wrapper; wrapper restore may be incomplete",
1223 );
1224 }
1225 }
1226 None => {
1227 logger.warn("Failed to resolve real git binary; cannot restore wrapper");
1228 }
1229 }
1230 }
1231
1232 #[cfg(unix)]
1234 {
1235 use std::os::unix::fs::PermissionsExt;
1236 if let Ok(meta) = fs::metadata(&wrapper_path) {
1237 let mode = meta.permissions().mode() & 0o777;
1238 if mode != 0o555 {
1239 logger.warn(&format!(
1240 "Git wrapper permissions loosened ({mode:#o}) — restoring to 0o555"
1241 ));
1242 let mut perms = meta.permissions();
1243 perms.set_mode(0o555);
1244 let _ = fs::set_permissions(&wrapper_path, perms);
1245 result.tampering_detected = true;
1246 result.details.push(format!(
1247 "Git wrapper permissions loosened ({mode:#o}) — restored to 0o555"
1248 ));
1249 }
1250 }
1251 }
1252 } else {
1253 logger.warn("Git wrapper missing — reinstalling");
1255 result.tampering_detected = true;
1256 result
1257 .details
1258 .push("Git wrapper missing before agent spawn — reinstalling".to_string());
1259
1260 let wrapper_dir = match tempfile::Builder::new()
1261 .prefix(WRAPPER_DIR_PREFIX)
1262 .tempdir()
1263 {
1264 Ok(d) => d.keep(),
1265 Err(e) => {
1266 logger.warn(&format!("Failed to create wrapper dir: {e}"));
1267 return result;
1269 }
1270 };
1271 ensure_wrapper_dir_prepended_to_path(&wrapper_dir);
1272
1273 let real_git = find_git_in_path_excluding_dir(&wrapper_dir).or_else(|| which("git").ok());
1274 if let Some(real_git_path) = real_git {
1275 if let Some(real_git_str) = real_git_path.to_str() {
1276 let marker_p = ralph_dir.join(MARKER_FILE_NAME);
1277 let track_p = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
1278 if let (Ok(git_path_escaped), Some(marker_str), Some(track_str)) = (
1279 escape_shell_single_quoted(real_git_str),
1280 marker_p.to_str(),
1281 track_p.to_str(),
1282 ) {
1283 if let (Ok(marker_escaped), Ok(track_escaped)) = (
1284 escape_shell_single_quoted(marker_str),
1285 escape_shell_single_quoted(track_str),
1286 ) {
1287 let normalized_repo_root = normalize_protection_scope_path(&repo_root);
1288 let normalized_git_dir = normalize_protection_scope_path(&scope.git_dir);
1289 let Some(repo_root_str) = normalized_repo_root.to_str() else {
1290 logger.warn("Repo root is not valid UTF-8; cannot restore wrapper");
1291 return result;
1292 };
1293 let Some(git_dir_str) = normalized_git_dir.to_str() else {
1294 logger.warn("Git dir is not valid UTF-8; cannot restore wrapper");
1295 return result;
1296 };
1297 let Ok(repo_root_escaped) = escape_shell_single_quoted(repo_root_str)
1298 else {
1299 logger.warn("Failed to generate safe wrapper script (repo root)");
1300 return result;
1301 };
1302 let Ok(git_dir_escaped) = escape_shell_single_quoted(git_dir_str) else {
1303 logger.warn("Failed to generate safe wrapper script (git dir)");
1304 return result;
1305 };
1306 let wrapper_content = make_wrapper_content(
1307 &git_path_escaped,
1308 &marker_escaped,
1309 &track_escaped,
1310 &repo_root_escaped,
1311 &git_dir_escaped,
1312 );
1313 let wrapper_path = wrapper_dir.join("git");
1314 if OpenOptions::new()
1315 .write(true)
1316 .create_new(true)
1317 .open(&wrapper_path)
1318 .and_then(|mut f| {
1319 f.write_all(wrapper_content.as_bytes())?;
1320 f.flush()?;
1321 let _ = f.sync_all();
1322 Ok(())
1323 })
1324 .is_ok()
1325 {
1326 #[cfg(unix)]
1327 {
1328 use std::os::unix::fs::PermissionsExt;
1329 if let Ok(meta) = fs::metadata(&wrapper_path) {
1330 let mut perms = meta.permissions();
1331 perms.set_mode(0o555);
1332 let _ = fs::set_permissions(&wrapper_path, perms);
1333 }
1334 }
1335 }
1336 }
1337 }
1338 }
1339 }
1340
1341 if let Err(e) = write_wrapper_track_file_atomic(&repo_root, &wrapper_dir) {
1343 logger.warn(&format!("Failed to write wrapper tracking file: {e}"));
1344 }
1345 }
1346
1347 let hooks_present = super::repo::get_hooks_dir_from(&repo_root)
1349 .ok()
1350 .is_some_and(|hooks_dir| {
1351 super::hooks::RALPH_HOOK_NAMES.iter().any(|name| {
1352 let path = hooks_dir.join(name);
1353 path.exists()
1354 && matches!(
1355 crate::files::file_contains_marker(&path, super::hooks::HOOK_MARKER),
1356 Ok(true)
1357 )
1358 })
1359 });
1360
1361 if !marker_exists && !hooks_present {
1363 logger.warn("Agent-phase git protections missing — reinstalling");
1364 result.tampering_detected = true;
1365 result
1366 .details
1367 .push("Marker and hooks missing before agent spawn — reinstalling".to_string());
1368 }
1369
1370 let marker_meta = fs::symlink_metadata(&marker_path).ok();
1371 let marker_is_symlink = marker_meta
1372 .as_ref()
1373 .is_some_and(|meta| meta.file_type().is_symlink());
1374 let marker_exists = marker_meta
1375 .as_ref()
1376 .is_some_and(|meta| meta.file_type().is_file() && !meta.file_type().is_symlink());
1377
1378 if marker_is_symlink {
1380 logger.warn("Enforcement marker is a symlink — removing and recreating");
1381 let _ = fs::remove_file(&marker_path);
1382 result.tampering_detected = true;
1383 result
1384 .details
1385 .push("Enforcement marker was a symlink — removed".to_string());
1386 }
1387 if !marker_exists {
1388 logger.warn("Enforcement marker missing — recreating");
1389 if let Err(e) = create_marker_in_repo_root(&repo_root) {
1390 logger.warn(&format!("Failed to recreate enforcement marker: {e}"));
1391 } else {
1392 #[cfg(unix)]
1393 set_readonly_mode_if_not_symlink(&marker_path, 0o444);
1394 }
1395 result.tampering_detected = true;
1396 result
1397 .details
1398 .push("Enforcement marker was missing — recreated".to_string());
1399 }
1400
1401 #[cfg(unix)]
1403 {
1404 use std::os::unix::fs::PermissionsExt;
1405 if marker_is_symlink {
1406 } else if let Ok(meta) = fs::metadata(&marker_path) {
1408 if meta.is_file() {
1409 let mode = meta.permissions().mode() & 0o777;
1410 if mode != 0o444 {
1411 logger.warn(&format!(
1412 "Enforcement marker permissions loosened ({mode:#o}) — restoring to 0o444"
1413 ));
1414 let mut perms = meta.permissions();
1415 perms.set_mode(0o444);
1416 let _ = fs::set_permissions(&marker_path, perms);
1417 result.tampering_detected = true;
1418 result.details.push(format!(
1419 "Enforcement marker permissions loosened ({mode:#o}) — restored to 0o444"
1420 ));
1421 }
1422 } else {
1423 logger.warn("Enforcement marker is not a regular file — quarantining");
1426 result.tampering_detected = true;
1427 result
1428 .details
1429 .push("Enforcement marker was not a regular file — quarantined".to_string());
1430 if let Err(e) = super::repo::quarantine_path_in_place(&marker_path, "marker-perms")
1431 {
1432 logger.warn(&format!("Failed to quarantine marker path: {e}"));
1433 } else if let Err(e) = create_marker_in_repo_root(&repo_root) {
1434 logger.warn(&format!(
1435 "Failed to recreate enforcement marker after quarantine: {e}"
1436 ));
1437 } else {
1438 #[cfg(unix)]
1439 set_readonly_mode_if_not_symlink(&marker_path, 0o444);
1440 }
1441 }
1442 }
1443 }
1444
1445 match reinstall_hooks_if_tampered(logger) {
1447 Ok(true) => {
1448 result.tampering_detected = true;
1449 result
1450 .details
1451 .push("Git hooks tampered with or missing — reinstalled".to_string());
1452 }
1453 Err(e) => {
1454 logger.warn(&format!("Failed to verify/reinstall hooks: {e}"));
1455 }
1456 Ok(false) => {}
1457 }
1458
1459 #[cfg(unix)]
1461 super::hooks::enforce_hook_permissions(&repo_root, logger);
1462
1463 #[cfg(unix)]
1465 {
1466 use std::os::unix::fs::PermissionsExt;
1467 if matches!(fs::symlink_metadata(&track_file_path), Ok(m) if m.file_type().is_symlink()) {
1468 logger.warn("Track file path is a symlink — refusing to chmod and attempting repair");
1469 result.tampering_detected = true;
1470 result
1471 .details
1472 .push("Track file was a symlink — refused chmod".to_string());
1473 let _ = fs::remove_file(&track_file_path);
1474 if let Some(dir) =
1475 find_wrapper_dir_on_path().filter(|p| wrapper_dir_is_safe_existing_dir(p))
1476 {
1477 let _ = write_wrapper_track_file_atomic(&repo_root, &dir);
1478 }
1479 } else if let Ok(meta) = fs::metadata(&track_file_path) {
1480 if meta.is_dir() {
1481 logger.warn("Track file path is a directory — quarantining");
1482 result.tampering_detected = true;
1483 result
1484 .details
1485 .push("Track file was a directory — quarantined".to_string());
1486 if let Err(e) =
1487 super::repo::quarantine_path_in_place(&track_file_path, "track-perms")
1488 {
1489 logger.warn(&format!("Failed to quarantine track file path: {e}"));
1490 }
1491 }
1492 if meta.is_file() {
1493 let mode = meta.permissions().mode() & 0o777;
1494 if mode != 0o444 {
1495 logger.warn(&format!(
1496 "Track file permissions loosened ({mode:#o}) — restoring to 0o444"
1497 ));
1498 let mut perms = meta.permissions();
1499 perms.set_mode(0o444);
1500 let _ = fs::set_permissions(&track_file_path, perms);
1501 result.tampering_detected = true;
1502 result.details.push(format!(
1503 "Track file permissions loosened ({mode:#o}) — restored to 0o444"
1504 ));
1505 }
1506 }
1507 }
1508 }
1509
1510 if detect_unauthorized_commit(&repo_root) {
1512 logger.warn("CRITICAL: HEAD OID changed — unauthorized commit detected!");
1513 result.tampering_detected = true;
1514 result
1515 .details
1516 .push("HEAD OID changed since last check — unauthorized commit detected".to_string());
1517 capture_head_oid(&repo_root);
1520 }
1521
1522 result
1523}
1524
1525fn cleanup_git_wrapper_dir_silent_at(ralph_dir: &Path) {
1527 let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
1528
1529 if let Some(wrapper_dir) = fs::read_to_string(&track_file)
1533 .ok()
1534 .map(|s| PathBuf::from(s.trim()))
1535 {
1536 remove_wrapper_dir_and_path_entry(&wrapper_dir);
1538 }
1539
1540 #[cfg(unix)]
1544 add_owner_write_if_not_symlink(&track_file);
1545 let _ = fs::remove_file(&track_file);
1546}
1547
1548fn cleanup_prior_wrapper_from_track_file(repo_root: &Path) {
1554 let ralph_dir = super::repo::ralph_git_dir(repo_root);
1555 let Ok(ralph_dir_exists) = super::repo::sanitize_ralph_git_dir_at(&ralph_dir) else {
1556 return;
1557 };
1558 if !ralph_dir_exists {
1559 return;
1560 }
1561
1562 let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
1563 let Ok(content) = fs::read_to_string(&track_file) else {
1564 return;
1565 };
1566 let wrapper_dir = PathBuf::from(content.trim());
1567 if remove_wrapper_dir_and_path_entry(&wrapper_dir) {
1568 let _ = fs::remove_file(&track_file);
1569 }
1570}
1571
1572pub fn cleanup_orphaned_wrapper_at(repo_root: &Path) {
1577 cleanup_prior_wrapper_from_track_file(repo_root);
1578}
1579
1580#[must_use]
1585pub fn verify_wrapper_cleaned(repo_root: &Path) -> Vec<String> {
1586 let mut remaining = Vec::new();
1587 let track_file = super::repo::ralph_git_dir(repo_root).join(WRAPPER_TRACK_FILE_NAME);
1588 if track_file.exists() {
1589 remaining.push(format!("track file still exists: {}", track_file.display()));
1590 if let Ok(content) = fs::read_to_string(&track_file) {
1592 let dir = PathBuf::from(content.trim());
1593 if dir.exists() {
1594 remaining.push(format!("wrapper temp dir still exists: {}", dir.display()));
1595 }
1596 }
1597 }
1598 remaining
1599}
1600
1601pub fn cleanup_agent_phase_silent() {
1607 let repo_root = AGENT_PHASE_REPO_ROOT
1611 .try_lock()
1612 .ok()
1613 .and_then(|guard| guard.clone())
1614 .or_else(|| crate::git_helpers::get_repo_root().ok());
1615
1616 let Some(repo_root) = repo_root else {
1617 return;
1618 };
1619
1620 let stored_ralph_dir = AGENT_PHASE_RALPH_DIR
1621 .try_lock()
1622 .ok()
1623 .and_then(|guard| guard.clone());
1624 let stored_hooks_dir = AGENT_PHASE_HOOKS_DIR
1625 .try_lock()
1626 .ok()
1627 .and_then(|guard| guard.clone());
1628 cleanup_agent_phase_silent_at_internal(
1629 &repo_root,
1630 stored_ralph_dir.as_deref(),
1631 stored_hooks_dir.as_deref(),
1632 );
1633}
1634
1635pub fn cleanup_agent_phase_silent_at(repo_root: &Path) {
1646 cleanup_agent_phase_silent_at_internal(repo_root, None, None);
1647}
1648
1649pub fn cleanup_agent_phase_protections_silent_at(repo_root: &Path) {
1654 cleanup_agent_phase_protections_silent_at_internal(repo_root, None, None);
1655}
1656
1657#[cfg(any(test, feature = "test-utils"))]
1658pub fn set_agent_phase_paths_for_test(
1659 repo_root: Option<PathBuf>,
1660 ralph_dir: Option<PathBuf>,
1661 hooks_dir: Option<PathBuf>,
1662) {
1663 if let Ok(mut guard) = AGENT_PHASE_REPO_ROOT.lock() {
1664 *guard = repo_root;
1665 }
1666 if let Ok(mut guard) = AGENT_PHASE_RALPH_DIR.lock() {
1667 *guard = ralph_dir;
1668 }
1669 if let Ok(mut guard) = AGENT_PHASE_HOOKS_DIR.lock() {
1670 *guard = hooks_dir;
1671 }
1672}
1673
1674#[cfg(any(test, feature = "test-utils"))]
1675#[must_use]
1676pub fn get_agent_phase_paths_for_test() -> (Option<PathBuf>, Option<PathBuf>, Option<PathBuf>) {
1677 let repo_root = AGENT_PHASE_REPO_ROOT
1678 .lock()
1679 .ok()
1680 .and_then(|guard| guard.clone());
1681 let ralph_dir = AGENT_PHASE_RALPH_DIR
1682 .lock()
1683 .ok()
1684 .and_then(|guard| guard.clone());
1685 let hooks_dir = AGENT_PHASE_HOOKS_DIR
1686 .lock()
1687 .ok()
1688 .and_then(|guard| guard.clone());
1689 (repo_root, ralph_dir, hooks_dir)
1690}
1691
1692#[cfg(any(test, feature = "test-utils"))]
1693#[must_use]
1694pub fn agent_phase_test_lock() -> &'static Mutex<()> {
1695 static TEST_LOCK: Mutex<()> = Mutex::new(());
1696 &TEST_LOCK
1697}
1698
1699fn cleanup_agent_phase_silent_at_internal(
1700 repo_root: &Path,
1701 stored_ralph_dir: Option<&Path>,
1702 stored_hooks_dir: Option<&Path>,
1703) {
1704 cleanup_agent_phase_protections_silent_at_internal(
1705 repo_root,
1706 stored_ralph_dir,
1707 stored_hooks_dir,
1708 );
1709 cleanup_generated_files_silent_at(repo_root);
1710}
1711
1712fn cleanup_agent_phase_protections_silent_at_internal(
1713 repo_root: &Path,
1714 stored_ralph_dir: Option<&Path>,
1715 stored_hooks_dir: Option<&Path>,
1716) {
1717 let computed_ralph_dir;
1718 let ralph_dir = if let Some(ralph_dir) = stored_ralph_dir {
1719 ralph_dir
1720 } else {
1721 computed_ralph_dir = super::repo::ralph_git_dir(repo_root);
1722 &computed_ralph_dir
1723 };
1724 let resolved_hooks_dir = stored_hooks_dir.map(PathBuf::from).or_else(|| {
1725 super::repo::resolve_protection_scope_from(repo_root)
1726 .ok()
1727 .map(|scope| scope.hooks_dir)
1728 });
1729
1730 end_agent_phase_in_repo_at_ralph_dir(repo_root, ralph_dir);
1731 cleanup_git_wrapper_dir_silent_at(ralph_dir);
1732
1733 if super::repo::resolve_protection_scope_from(repo_root).is_ok() {
1736 super::hooks::uninstall_hooks_silent_at(repo_root);
1737 } else if let Some(hooks_dir) = resolved_hooks_dir.as_deref() {
1738 super::hooks::uninstall_hooks_silent_in_hooks_dir(hooks_dir);
1739 } else {
1740 uninstall_hooks_silent_at(repo_root);
1741 }
1742
1743 cleanup_hook_scoping_state_files(ralph_dir);
1747 remove_scoped_hooks_dir_if_empty(ralph_dir);
1748 cleanup_stray_tmp_files_in_ralph_dir(ralph_dir);
1749 remove_ralph_dir_best_effort(ralph_dir);
1754
1755 cleanup_repo_root_ralph_dir_if_empty(repo_root);
1756
1757 clear_agent_phase_global_state();
1758}
1759
1760#[must_use]
1771pub fn try_remove_ralph_dir(repo_root: &Path) -> bool {
1772 let ralph_dir = super::repo::ralph_git_dir(repo_root);
1773 let Ok(ralph_dir_exists) = super::repo::sanitize_ralph_git_dir_at(&ralph_dir) else {
1774 return !ralph_dir.exists();
1775 };
1776 if !ralph_dir_exists {
1777 return true;
1778 }
1779
1780 cleanup_stray_tmp_files_in_sanitized_ralph_dir(&ralph_dir);
1784 remove_scoped_hooks_dir_if_empty(&ralph_dir);
1785 match fs::remove_dir(&ralph_dir) {
1786 Ok(()) => true,
1787 Err(err) if err.kind() == io::ErrorKind::NotFound => true,
1788 Err(_) => !ralph_dir.exists(),
1789 }
1790}
1791
1792#[must_use]
1797pub fn verify_ralph_dir_removed(repo_root: &Path) -> Vec<String> {
1798 let ralph_dir = super::repo::ralph_git_dir(repo_root);
1799 let Ok(ralph_dir_exists) = super::repo::sanitize_ralph_git_dir_at(&ralph_dir) else {
1800 return vec![format!(
1801 "could not sanitize ralph directory before verification: {}",
1802 ralph_dir.display()
1803 )];
1804 };
1805 if !ralph_dir_exists {
1806 return Vec::new();
1807 }
1808
1809 let mut remaining = vec![format!("directory still exists: {}", ralph_dir.display())];
1810 match fs::read_dir(&ralph_dir) {
1811 Ok(entries) => {
1812 let mut names = entries
1813 .filter_map(Result::ok)
1814 .map(|entry| entry.file_name().to_string_lossy().into_owned())
1815 .collect::<Vec<_>>();
1816 names.sort();
1817 if !names.is_empty() {
1818 remaining.push(format!("remaining entries: {}", names.join(", ")));
1819 }
1820 }
1821 Err(err) => remaining.push(format!("could not inspect directory contents: {err}")),
1822 }
1823 remaining
1824}
1825
1826fn cleanup_generated_files_silent_at(repo_root: &Path) {
1828 for file in crate::files::io::agent_files::GENERATED_FILES {
1829 let absolute_path = repo_root.join(file);
1830 let _ = std::fs::remove_file(absolute_path);
1831 }
1832}
1833
1834fn cleanup_hook_scoping_state_files(ralph_dir: &Path) {
1835 for file_name in ["hooks-path.previous", "worktree-config.previous"] {
1836 let path = ralph_dir.join(file_name);
1837 #[cfg(unix)]
1838 add_owner_write_if_not_symlink(&path);
1839 let _ = fs::remove_file(path);
1840 }
1841}
1842
1843fn remove_scoped_hooks_dir_if_empty(ralph_dir: &Path) {
1844 let _ = fs::remove_dir(ralph_dir.join("hooks"));
1845}
1846
1847fn cleanup_repo_root_ralph_dir_if_empty(repo_root: &Path) {
1848 let fallback_ralph_dir = repo_root.join(".git/ralph");
1849 cleanup_hook_scoping_state_files(&fallback_ralph_dir);
1850 remove_scoped_hooks_dir_if_empty(&fallback_ralph_dir);
1851 cleanup_stray_tmp_files_in_ralph_dir(&fallback_ralph_dir);
1852 remove_ralph_dir_best_effort(&fallback_ralph_dir);
1853}
1854
1855fn remove_ralph_dir_best_effort(ralph_dir: &Path) {
1856 if fs::remove_dir(ralph_dir).is_ok() {
1857 return;
1858 }
1859 let Ok(meta) = fs::symlink_metadata(ralph_dir) else {
1860 return;
1861 };
1862 if meta.file_type().is_symlink() || !meta.is_dir() {
1863 return;
1864 }
1865 let _ = fs::remove_dir_all(ralph_dir);
1866}
1867
1868pub fn cleanup_orphaned_marker(logger: &Logger) -> io::Result<()> {
1874 let repo_root = get_repo_root()?;
1875 let legacy_marker = legacy_marker_path(&repo_root);
1876 if fs::symlink_metadata(&legacy_marker).is_ok() {
1877 #[cfg(unix)]
1878 {
1879 add_owner_write_if_not_symlink(&legacy_marker);
1880 }
1881 fs::remove_file(&legacy_marker)?;
1882 logger.success("Removed orphaned enforcement marker");
1883 return Ok(());
1884 }
1885
1886 let ralph_dir = super::repo::ralph_git_dir(&repo_root);
1887 if !super::repo::sanitize_ralph_git_dir_at(&ralph_dir)? {
1888 logger.info("No orphaned marker found");
1889 return Ok(());
1890 }
1891 let marker_path = ralph_dir.join(MARKER_FILE_NAME);
1892
1893 if fs::symlink_metadata(&marker_path).is_ok() {
1894 #[cfg(unix)]
1896 {
1897 add_owner_write_if_not_symlink(&marker_path);
1898 }
1899 fs::remove_file(&marker_path)?;
1900 logger.success("Removed orphaned enforcement marker");
1901 } else {
1902 logger.info("No orphaned marker found");
1903 }
1904
1905 Ok(())
1906}
1907
1908pub fn capture_head_oid(repo_root: &Path) {
1913 let Ok(head_oid) = crate::git_helpers::get_current_head_oid_at(repo_root) else {
1914 return; };
1916 let _ = write_head_oid_file_atomic(repo_root, head_oid.trim());
1917}
1918
1919fn write_head_oid_file_atomic(repo_root: &Path, oid: &str) -> io::Result<()> {
1920 let ralph_dir = super::repo::ensure_ralph_git_dir(repo_root)?;
1921
1922 let head_oid_path = ralph_dir.join(HEAD_OID_FILE_NAME);
1923 if matches!(fs::symlink_metadata(&head_oid_path), Ok(m) if m.file_type().is_symlink()) {
1924 return Err(io::Error::new(
1925 io::ErrorKind::InvalidData,
1926 "head-oid path is a symlink; refusing to write baseline",
1927 ));
1928 }
1929
1930 let tmp_path = ralph_dir.join(format!(
1931 ".head-oid.tmp.{}.{}",
1932 std::process::id(),
1933 std::time::SystemTime::now()
1934 .duration_since(std::time::UNIX_EPOCH)
1935 .unwrap_or_default()
1936 .as_nanos()
1937 ));
1938
1939 {
1940 let mut tf = OpenOptions::new()
1941 .write(true)
1942 .create_new(true)
1943 .open(&tmp_path)?;
1944 tf.write_all(oid.as_bytes())?;
1945 tf.write_all(b"\n")?;
1946 tf.flush()?;
1947 let _ = tf.sync_all();
1948 }
1949
1950 #[cfg(unix)]
1951 set_readonly_mode_if_not_symlink(&tmp_path, 0o444);
1952 #[cfg(windows)]
1953 {
1954 let mut perms = fs::metadata(&tmp_path)?.permissions();
1955 perms.set_readonly(true);
1956 fs::set_permissions(&tmp_path, perms)?;
1957 }
1958
1959 #[cfg(windows)]
1961 {
1962 if head_oid_path.exists() {
1963 let _ = fs::remove_file(&head_oid_path);
1964 }
1965 }
1966 fs::rename(&tmp_path, &head_oid_path)
1967}
1968
1969#[must_use]
1974pub fn detect_unauthorized_commit(repo_root: &Path) -> bool {
1975 let head_oid_path = super::repo::ralph_git_dir(repo_root).join(HEAD_OID_FILE_NAME);
1976 if matches!(fs::symlink_metadata(&head_oid_path), Ok(m) if m.file_type().is_symlink()) {
1977 return false;
1978 }
1979 let Ok(stored_oid) = fs::read_to_string(&head_oid_path) else {
1980 return false; };
1982 let stored_oid = stored_oid.trim();
1983 if stored_oid.is_empty() {
1984 return false;
1985 }
1986
1987 let Ok(current_oid) = crate::git_helpers::get_current_head_oid_at(repo_root) else {
1988 return false; };
1990
1991 current_oid.trim() != stored_oid
1992}
1993
1994fn cleanup_stray_tmp_files_in_ralph_dir(ralph_dir: &Path) {
2006 let Ok(ralph_dir_exists) = super::repo::sanitize_ralph_git_dir_at(ralph_dir) else {
2007 return;
2008 };
2009 if !ralph_dir_exists {
2010 return;
2011 }
2012
2013 cleanup_stray_tmp_files_in_sanitized_ralph_dir(ralph_dir);
2014}
2015
2016fn cleanup_stray_tmp_files_in_sanitized_ralph_dir(ralph_dir: &Path) {
2017 let Ok(entries) = fs::read_dir(ralph_dir) else {
2018 return;
2019 };
2020 for entry in entries.flatten() {
2021 let name = entry.file_name();
2022 let name_str = name.to_string_lossy();
2023 if name_str.starts_with(".head-oid.tmp.") || name_str.starts_with(".git-wrapper-dir.tmp.") {
2024 let path = entry.path();
2025 let Ok(meta) = fs::symlink_metadata(&path) else {
2026 continue;
2027 };
2028 let file_type = meta.file_type();
2029 if !file_type.is_file() || file_type.is_symlink() {
2030 continue;
2031 }
2032
2033 relax_temp_cleanup_permissions_if_regular_file(&path);
2037 let _ = fs::remove_file(&path);
2038 }
2039 }
2040}
2041
2042fn remove_head_oid_file_at(ralph_dir: &Path) {
2044 let head_oid_path = ralph_dir.join(HEAD_OID_FILE_NAME);
2045 if fs::symlink_metadata(&head_oid_path).is_err() {
2046 return;
2047 }
2048 #[cfg(unix)]
2049 {
2050 add_owner_write_if_not_symlink(&head_oid_path);
2051 }
2052 let _ = fs::remove_file(&head_oid_path);
2053}
2054
2055const MARKER_WORKSPACE_PATH: &str = ".git/ralph/no_agent_commit";
2065const LEGACY_MARKER_WORKSPACE_PATH: &str = ".no_agent_commit";
2066
2067pub fn create_marker_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
2084 workspace.write(Path::new(MARKER_WORKSPACE_PATH), "")
2085}
2086
2087pub fn remove_marker_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
2104 workspace.remove_if_exists(Path::new(MARKER_WORKSPACE_PATH))
2105}
2106
2107pub fn marker_exists_with_workspace(workspace: &dyn Workspace) -> bool {
2120 workspace.exists(Path::new(MARKER_WORKSPACE_PATH))
2121}
2122
2123pub fn cleanup_orphaned_marker_with_workspace(
2141 workspace: &dyn Workspace,
2142 logger: &Logger,
2143) -> io::Result<()> {
2144 let marker_path = Path::new(MARKER_WORKSPACE_PATH);
2145 let legacy_marker_path = Path::new(LEGACY_MARKER_WORKSPACE_PATH);
2146 let removed_marker = if workspace.exists(marker_path) {
2147 workspace.remove(marker_path)?;
2148 true
2149 } else {
2150 false
2151 };
2152 let removed_legacy_marker = if workspace.exists(legacy_marker_path) {
2153 workspace.remove(legacy_marker_path)?;
2154 true
2155 } else {
2156 false
2157 };
2158
2159 if removed_marker || removed_legacy_marker {
2160 logger.success("Removed orphaned enforcement marker");
2161 } else {
2162 logger.info("No orphaned marker found");
2163 }
2164
2165 Ok(())
2166}
2167
2168#[cfg(test)]
2169mod tests {
2170 use super::*;
2171 use crate::workspace::MemoryWorkspace;
2172 use std::sync::Mutex;
2173
2174 static ENV_LOCK: Mutex<()> = Mutex::new(());
2175
2176 struct RestoreEnv {
2177 original_cwd: PathBuf,
2178 original_path: String,
2179 }
2180
2181 struct ClearAgentPhaseStateOnDrop;
2182
2183 impl Drop for ClearAgentPhaseStateOnDrop {
2184 fn drop(&mut self) {
2185 clear_agent_phase_global_state();
2186 }
2187 }
2188
2189 impl Drop for RestoreEnv {
2190 fn drop(&mut self) {
2191 let _ = std::env::set_current_dir(&self.original_cwd);
2192 std::env::set_var("PATH", &self.original_path);
2193 }
2194 }
2195
2196 fn test_wrapper_content() -> String {
2197 make_wrapper_content(
2198 "git",
2199 "/tmp/.git/ralph/no_agent_commit",
2200 "/tmp/.git/ralph/git-wrapper-dir.txt",
2201 "/tmp/repo",
2202 "/tmp/repo/.git",
2203 )
2204 }
2205
2206 #[test]
2207 fn test_wrapper_script_handles_c_flag_before_subcommand() {
2208 let content = test_wrapper_content();
2212
2213 assert!(
2214 content.contains("skip_next"),
2215 "wrapper must implement skip_next logic for global flags; got:\n{content}"
2216 );
2217 assert!(
2218 content.contains("-C|--git-dir|--work-tree"),
2219 "wrapper must recognize -C and --git-dir global flags; got:\n{content}"
2220 );
2221 assert!(
2222 content.contains("for arg in"),
2223 "wrapper must iterate arguments to find subcommand; got:\n{content}"
2224 );
2225 }
2226
2227 #[test]
2228 fn test_wrapper_script_treats_git_dir_match_as_sufficient_scope_activation() {
2229 let content = test_wrapper_content();
2230
2231 assert!(
2232 content.contains("target_git_dir=$( 'git' \"${repo_args[@]}\" rev-parse --path-format=absolute --git-dir 2>/dev/null || true )"),
2233 "wrapper must resolve target git-dir independently of show-toplevel; got:\n{content}"
2234 );
2235 assert!(
2236 content.contains("[ \"$normalized_target_git_dir\" = \"$active_git_dir\" ]"),
2237 "wrapper must activate protection when git-dir matches even without a repo root; got:\n{content}"
2238 );
2239 }
2240
2241 #[test]
2242 fn test_create_marker_with_workspace() {
2243 let workspace = MemoryWorkspace::new_test();
2244
2245 assert!(!marker_exists_with_workspace(&workspace));
2247
2248 create_marker_with_workspace(&workspace).unwrap();
2250
2251 assert!(marker_exists_with_workspace(&workspace));
2253 }
2254
2255 #[test]
2256 fn test_remove_marker_with_workspace() {
2257 let workspace = MemoryWorkspace::new_test();
2258
2259 create_marker_with_workspace(&workspace).unwrap();
2261 assert!(marker_exists_with_workspace(&workspace));
2262
2263 remove_marker_with_workspace(&workspace).unwrap();
2265
2266 assert!(!marker_exists_with_workspace(&workspace));
2268 }
2269
2270 #[test]
2271 fn test_remove_marker_with_workspace_nonexistent() {
2272 let workspace = MemoryWorkspace::new_test();
2273
2274 remove_marker_with_workspace(&workspace).unwrap();
2276 assert!(!marker_exists_with_workspace(&workspace));
2277 }
2278
2279 #[test]
2280 fn test_cleanup_orphaned_marker_with_workspace_exists() {
2281 let workspace = MemoryWorkspace::new_test().with_file(".no_agent_commit", "");
2282 let logger = Logger::new(crate::logger::Colors { enabled: false });
2283
2284 create_marker_with_workspace(&workspace).unwrap();
2286 assert!(marker_exists_with_workspace(&workspace));
2287 assert!(workspace.exists(Path::new(".no_agent_commit")));
2288
2289 cleanup_orphaned_marker_with_workspace(&workspace, &logger).unwrap();
2291 assert!(!marker_exists_with_workspace(&workspace));
2292 assert!(!workspace.exists(Path::new(".no_agent_commit")));
2293 }
2294
2295 #[test]
2296 fn test_cleanup_orphaned_marker_with_workspace_not_exists() {
2297 let workspace = MemoryWorkspace::new_test();
2298 let logger = Logger::new(crate::logger::Colors { enabled: false });
2299
2300 assert!(!marker_exists_with_workspace(&workspace));
2302
2303 cleanup_orphaned_marker_with_workspace(&workspace, &logger).unwrap();
2305 assert!(!marker_exists_with_workspace(&workspace));
2306 assert_eq!(MARKER_FILE_NAME, "no_agent_commit");
2307 assert_eq!(WRAPPER_TRACK_FILE_NAME, "git-wrapper-dir.txt");
2308 assert_eq!(HEAD_OID_FILE_NAME, "head-oid.txt");
2309 }
2310
2311 #[test]
2312 fn test_wrapper_script_handles_config_flag_before_subcommand() {
2313 let content = test_wrapper_content();
2318
2319 assert!(
2320 content.contains("--config|"),
2321 "wrapper must recognize --config as a flag with separate value; got:\n{content}"
2322 );
2323 assert!(
2324 content.contains("--config=*"),
2325 "wrapper must handle --config=value syntax; got:\n{content}"
2326 );
2327 }
2328
2329 #[test]
2330 fn test_wrapper_script_enforces_read_only_allowlist() {
2331 let content = test_wrapper_content();
2332
2333 assert!(
2334 content.contains("read-only allowlist"),
2335 "wrapper should describe allowlist behavior; got:\n{content}"
2336 );
2337 assert!(
2338 content.contains("status|log|diff|show|rev-parse|ls-files|describe"),
2339 "wrapper should explicitly allow read-only lookup commands; got:\n{content}"
2340 );
2341 }
2342
2343 #[test]
2344 fn test_wrapper_script_blocks_stash_except_list() {
2345 let content = test_wrapper_content();
2346 assert!(
2347 content.contains("only 'stash list' allowed"),
2348 "wrapper should only allow stash list; got:\n{content}"
2349 );
2350 }
2351
2352 #[test]
2353 fn test_wrapper_script_blocks_branch_positional_args() {
2354 let content = test_wrapper_content();
2355 assert!(
2356 content.contains("git branch disabled during agent phase"),
2357 "wrapper should block positional git branch invocations via the branch allowlist; got:\n{content}"
2358 );
2359 }
2360
2361 #[test]
2362 fn test_wrapper_script_blocks_flag_only_mutating_branch_forms() {
2363 let content = test_wrapper_content();
2364 assert!(
2365 content.contains("--unset-upstream"),
2366 "wrapper branch allowlist should explicitly reject mutating flag-only forms; got:\n{content}"
2367 );
2368 }
2369
2370 #[test]
2371 fn test_wrapper_script_uses_absolute_marker_paths() {
2372 let content = test_wrapper_content();
2373 assert!(
2375 !content.contains("protected_repo_root"),
2376 "wrapper should not use protected_repo_root variable; got:\n{content}"
2377 );
2378 assert!(
2379 content.contains("marker='/tmp/.git/ralph/no_agent_commit'"),
2380 "wrapper should embed absolute marker path; got:\n{content}"
2381 );
2382 assert!(
2383 content.contains("track_file='/tmp/.git/ralph/git-wrapper-dir.txt'"),
2384 "wrapper should embed absolute track file path; got:\n{content}"
2385 );
2386 }
2387
2388 #[test]
2389 fn test_protection_check_result_default_is_no_tampering() {
2390 let result = ProtectionCheckResult::default();
2391 assert!(!result.tampering_detected);
2392 assert!(result.details.is_empty());
2393 }
2394
2395 #[test]
2396 fn test_wrapper_script_unsets_git_env_vars() {
2397 let content = test_wrapper_content();
2398 for var in &["GIT_DIR", "GIT_WORK_TREE", "GIT_EXEC_PATH"] {
2401 assert!(
2402 content.contains(&format!("unset {var}")),
2403 "wrapper must unset {var} when marker exists; got:\n{content}"
2404 );
2405 }
2406 }
2407
2408 #[test]
2409 fn test_wrapper_script_documents_command_builtin_behavior() {
2410 let content = test_wrapper_content();
2411 assert!(
2414 content.contains("command") && content.contains("PATH"),
2415 "wrapper must document that `command` builtin still routes through PATH wrapper; got:\n{content}"
2416 );
2417 }
2418
2419 #[test]
2424 fn test_detect_unauthorized_commit_no_stored_oid() {
2425 let tmp = tempfile::tempdir().unwrap();
2427 assert!(!detect_unauthorized_commit(tmp.path()));
2428 }
2429
2430 #[test]
2431 fn test_detect_unauthorized_commit_empty_stored_oid() {
2432 let tmp = tempfile::tempdir().unwrap();
2433 let ralph_dir = tmp.path().join(".git").join("ralph");
2435 fs::create_dir_all(&ralph_dir).unwrap();
2436 fs::write(ralph_dir.join("head-oid.txt"), "").unwrap();
2437 assert!(!detect_unauthorized_commit(tmp.path()));
2438 }
2439
2440 #[test]
2441 fn test_write_wrapper_track_file_atomic_repairs_directory_tamper() {
2442 let tmp = tempfile::tempdir().unwrap();
2445 let repo_root = tmp.path();
2446
2447 let ralph_dir = repo_root.join(".git").join("ralph");
2449 fs::create_dir_all(&ralph_dir).unwrap();
2450
2451 let track_dir_path = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
2453 fs::create_dir_all(&track_dir_path).unwrap();
2454 fs::write(track_dir_path.join("payload.txt"), "do not delete").unwrap();
2455
2456 let wrapper_dir = repo_root.join("some-wrapper-dir");
2457 fs::create_dir_all(&wrapper_dir).unwrap();
2458
2459 write_wrapper_track_file_atomic(repo_root, &wrapper_dir).unwrap();
2460
2461 let track_file_path = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
2462 let meta = fs::metadata(&track_file_path).unwrap();
2463 assert!(meta.is_file(), "track file path should be a file");
2464 let content = fs::read_to_string(&track_file_path).unwrap();
2465 assert!(
2466 content.contains(&wrapper_dir.display().to_string()),
2467 "track file should contain wrapper dir path; got: {content}"
2468 );
2469
2470 let quarantined = fs::read_dir(&ralph_dir)
2472 .unwrap()
2473 .filter_map(Result::ok)
2474 .any(|e| {
2475 e.file_name()
2476 .to_string_lossy()
2477 .starts_with("git-wrapper-dir.txt.ralph.tampered.track")
2478 });
2479 assert!(
2480 quarantined,
2481 "expected quarantined track dir entry in .git/ralph/"
2482 );
2483 }
2484
2485 #[test]
2486 fn test_repair_marker_path_converts_directory_to_regular_file() {
2487 let tmp = tempfile::tempdir().unwrap();
2490 let repo_root = tmp.path();
2491
2492 let ralph_dir = repo_root.join(".git").join("ralph");
2494 fs::create_dir_all(&ralph_dir).unwrap();
2495 let marker_path = ralph_dir.join(MARKER_FILE_NAME);
2496 fs::create_dir_all(&marker_path).unwrap();
2497
2498 repair_marker_path_if_tampered(repo_root).unwrap();
2500
2501 let meta = fs::metadata(&marker_path).unwrap();
2502 assert!(meta.is_file(), "marker path should be a regular file");
2503
2504 let quarantined = fs::read_dir(&ralph_dir)
2505 .unwrap()
2506 .filter_map(Result::ok)
2507 .any(|e| {
2508 e.file_name()
2509 .to_string_lossy()
2510 .starts_with("no_agent_commit.ralph.tampered.marker")
2511 });
2512 assert!(
2513 quarantined,
2514 "expected quarantined marker dir entry in .git/ralph/"
2515 );
2516 }
2517
2518 #[cfg(unix)]
2519 #[test]
2520 fn test_create_marker_in_repo_root_quarantines_special_file() {
2521 use std::os::unix::fs::symlink;
2522 use std::os::unix::fs::FileTypeExt;
2523 use std::os::unix::net::UnixListener;
2524
2525 let tmp = tempfile::tempdir().unwrap();
2529 let repo_root = tmp.path();
2530
2531 let ralph_dir = repo_root.join(".git").join("ralph");
2533 fs::create_dir_all(&ralph_dir).unwrap();
2534 let marker_path = ralph_dir.join(MARKER_FILE_NAME);
2535 let created_socket = match UnixListener::bind(&marker_path) {
2536 Ok(listener) => {
2537 drop(listener);
2538 true
2539 }
2540 Err(err) if err.kind() == io::ErrorKind::PermissionDenied => {
2541 let fallback_target = ralph_dir.join("marker-symlink-target");
2542 fs::write(&fallback_target, b"blocked special file fallback").unwrap();
2543 symlink(&fallback_target, &marker_path).unwrap();
2544 false
2545 }
2546 Err(err) => panic!("failed to create non-regular marker path: {err}"),
2547 };
2548
2549 let ft = fs::symlink_metadata(&marker_path).unwrap().file_type();
2550 assert!(
2551 ft.is_socket() || (!created_socket && ft.is_symlink()),
2552 "precondition: marker path should be a socket or fallback symlink"
2553 );
2554
2555 create_marker_in_repo_root(repo_root).unwrap();
2556
2557 let meta = fs::symlink_metadata(&marker_path).unwrap();
2558 assert!(meta.is_file(), "marker path should be a regular file");
2559
2560 let quarantined = fs::read_dir(&ralph_dir)
2561 .unwrap()
2562 .filter_map(Result::ok)
2563 .any(|e| {
2564 e.file_name()
2565 .to_string_lossy()
2566 .starts_with("no_agent_commit.ralph.tampered.marker")
2567 });
2568 assert!(
2569 quarantined,
2570 "expected quarantined special marker entry in .git/ralph/"
2571 );
2572 }
2573
2574 #[cfg(unix)]
2575 #[test]
2576 fn test_ensure_agent_phase_protections_recreates_marker_after_permissions_quarantine() {
2577 use std::time::Duration;
2578
2579 let _lock = ENV_LOCK.lock().unwrap();
2580
2581 let repo_dir = tempfile::tempdir().unwrap();
2582 let _repo = git2::Repository::init(repo_dir.path()).unwrap();
2583
2584 let original_cwd = std::env::current_dir().unwrap();
2585 let original_path = std::env::var("PATH").unwrap_or_default();
2586 let _restore = RestoreEnv {
2587 original_cwd,
2588 original_path: original_path.clone(),
2589 };
2590
2591 std::env::set_current_dir(repo_dir.path()).unwrap();
2592
2593 let ralph_dir = repo_dir.path().join(".git").join("ralph");
2596 fs::create_dir_all(&ralph_dir).unwrap();
2597 let marker_path = ralph_dir.join(MARKER_FILE_NAME);
2598 fs::write(&marker_path, b"").unwrap();
2599 assert!(fs::symlink_metadata(&marker_path).unwrap().is_file());
2600
2601 let wrapper_dir = tempfile::Builder::new()
2604 .prefix(WRAPPER_DIR_PREFIX)
2605 .tempdir_in(std::env::temp_dir())
2606 .unwrap();
2607 let slow_paths = (0..1000)
2610 .map(|i| {
2611 format!(
2612 "{}/ralph-nonexistent-path-{i}",
2613 std::env::temp_dir().display()
2614 )
2615 })
2616 .collect::<Vec<_>>()
2617 .join(":");
2618 let new_path = format!(
2619 "{}:{slow_paths}:{original_path}",
2620 wrapper_dir.path().display()
2621 );
2622 std::env::set_var("PATH", new_path);
2623
2624 let ensure_thread = std::thread::spawn(|| {
2627 let logger = Logger::new(crate::logger::Colors { enabled: false });
2628 ensure_agent_phase_protections(&logger)
2629 });
2630
2631 std::thread::sleep(Duration::from_millis(10));
2633
2634 let _ = fs::remove_file(&marker_path);
2635 let _ = fs::remove_dir_all(&marker_path);
2636 fs::create_dir_all(marker_path.parent().unwrap()).unwrap();
2637 fs::create_dir(&marker_path).unwrap();
2638
2639 let _result = ensure_thread.join().unwrap();
2640
2641 let meta = fs::symlink_metadata(&marker_path).unwrap();
2644 assert!(
2645 meta.is_file(),
2646 "marker should be recreated as a regular file"
2647 );
2648 }
2649
2650 #[test]
2655 fn test_cleanup_agent_phase_silent_at_removes_marker() {
2656 let tmp = tempfile::tempdir().unwrap();
2657 let repo_root = tmp.path();
2658 let ralph_dir = repo_root.join(".git").join("ralph");
2660 fs::create_dir_all(&ralph_dir).unwrap();
2661 let marker = ralph_dir.join(MARKER_FILE_NAME);
2662 fs::write(&marker, "").unwrap();
2663
2664 cleanup_agent_phase_silent_at(repo_root);
2665
2666 assert!(
2667 !marker.exists(),
2668 "marker should be removed by cleanup_agent_phase_silent_at"
2669 );
2670 }
2671
2672 #[test]
2673 fn test_cleanup_agent_phase_silent_at_removes_head_oid() {
2674 let tmp = tempfile::tempdir().unwrap();
2675 let repo_root = tmp.path();
2676 let ralph_dir = repo_root.join(".git").join("ralph");
2678 fs::create_dir_all(&ralph_dir).unwrap();
2679 let head_oid = ralph_dir.join(HEAD_OID_FILE_NAME);
2680 fs::write(&head_oid, "abc123\n").unwrap();
2681
2682 cleanup_agent_phase_silent_at(repo_root);
2683
2684 assert!(
2685 !head_oid.exists(),
2686 "head-oid.txt should be removed by cleanup_agent_phase_silent_at"
2687 );
2688 }
2689
2690 #[test]
2691 fn test_cleanup_agent_phase_silent_at_removes_generated_files() {
2692 let tmp = tempfile::tempdir().unwrap();
2693 let repo_root = tmp.path();
2694 let agent_dir = repo_root.join(".agent");
2695 fs::create_dir_all(&agent_dir).unwrap();
2696 let ralph_dir = repo_root.join(".git").join("ralph");
2698 fs::create_dir_all(&ralph_dir).unwrap();
2699 fs::write(ralph_dir.join(MARKER_FILE_NAME), "").unwrap();
2700
2701 fs::write(agent_dir.join("PLAN.md"), "plan").unwrap();
2703 fs::write(agent_dir.join("commit-message.txt"), "msg").unwrap();
2704 fs::write(agent_dir.join("checkpoint.json.tmp"), "{}").unwrap();
2705
2706 cleanup_agent_phase_silent_at(repo_root);
2707
2708 assert!(
2710 !ralph_dir.join(MARKER_FILE_NAME).exists(),
2711 "marker should be removed by cleanup_agent_phase_silent_at"
2712 );
2713
2714 for file in crate::files::io::agent_files::GENERATED_FILES {
2716 let path = repo_root.join(file);
2717 assert!(
2718 !path.exists(),
2719 "{file} should be removed by cleanup_agent_phase_silent_at"
2720 );
2721 }
2722 }
2723
2724 #[test]
2725 fn test_cleanup_agent_phase_silent_at_removes_wrapper_track_file() {
2726 let tmp = tempfile::tempdir().unwrap();
2727 let repo_root = tmp.path();
2728 let ralph_dir = repo_root.join(".git").join("ralph");
2730 fs::create_dir_all(&ralph_dir).unwrap();
2731
2732 let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
2734 fs::write(&track_file, "/nonexistent/wrapper/dir\n").unwrap();
2735
2736 cleanup_agent_phase_silent_at(repo_root);
2737
2738 assert!(
2739 !track_file.exists(),
2740 "wrapper track file should be removed by cleanup_agent_phase_silent_at"
2741 );
2742 }
2743
2744 #[test]
2745 fn test_cleanup_agent_phase_silent_at_idempotent() {
2746 let tmp = tempfile::tempdir().unwrap();
2747 let repo_root = tmp.path();
2748
2749 cleanup_agent_phase_silent_at(repo_root);
2751 cleanup_agent_phase_silent_at(repo_root);
2752 }
2753
2754 #[test]
2759 fn test_agent_phase_repo_root_mutex_is_accessible() {
2760 assert!(
2762 AGENT_PHASE_REPO_ROOT.try_lock().is_ok(),
2763 "AGENT_PHASE_REPO_ROOT mutex should be lockable"
2764 );
2765 }
2766
2767 #[test]
2772 fn test_cleanup_prior_wrapper_removes_tracked_dir() {
2773 let _lock = ENV_LOCK.lock().unwrap();
2774 let tmp = tempfile::tempdir().unwrap();
2775 let repo_root = tmp.path();
2776 let ralph_dir = repo_root.join(".git").join("ralph");
2778 fs::create_dir_all(&ralph_dir).unwrap();
2779
2780 let wrapper_dir = tempfile::Builder::new()
2782 .prefix(WRAPPER_DIR_PREFIX)
2783 .tempdir()
2784 .unwrap();
2785 let wrapper_dir_path = wrapper_dir.keep();
2786
2787 let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
2789 fs::write(&track_file, format!("{}\n", wrapper_dir_path.display())).unwrap();
2790
2791 assert!(
2792 wrapper_dir_path.exists(),
2793 "precondition: wrapper dir exists"
2794 );
2795
2796 cleanup_orphaned_wrapper_at(repo_root);
2797
2798 assert!(
2799 !wrapper_dir_path.exists(),
2800 "wrapper dir should be removed by cleanup"
2801 );
2802 assert!(
2803 !track_file.exists(),
2804 "track file should be removed by cleanup"
2805 );
2806 }
2807
2808 #[test]
2809 fn test_cleanup_prior_wrapper_no_track_file() {
2810 let tmp = tempfile::tempdir().unwrap();
2811 let repo_root = tmp.path();
2812
2813 cleanup_orphaned_wrapper_at(repo_root);
2815
2816 }
2818
2819 #[test]
2820 fn test_cleanup_prior_wrapper_stale_track_file() {
2821 let tmp = tempfile::tempdir().unwrap();
2822 let repo_root = tmp.path();
2823 let ralph_dir = repo_root.join(".git").join("ralph");
2825 fs::create_dir_all(&ralph_dir).unwrap();
2826
2827 let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
2829 fs::write(&track_file, "/nonexistent/ralph-git-wrapper-stale\n").unwrap();
2830
2831 cleanup_orphaned_wrapper_at(repo_root);
2832
2833 assert!(
2834 !track_file.exists(),
2835 "stale track file should be removed by cleanup"
2836 );
2837 }
2838
2839 #[test]
2844 fn test_verify_wrapper_cleaned_empty_when_clean() {
2845 let tmp = tempfile::tempdir().unwrap();
2846 let repo_root = tmp.path();
2847
2848 let remaining = verify_wrapper_cleaned(repo_root);
2849 assert!(
2850 remaining.is_empty(),
2851 "verify_wrapper_cleaned should return empty when no artifacts remain"
2852 );
2853 }
2854
2855 #[test]
2856 fn test_verify_wrapper_cleaned_reports_remaining_track_file() {
2857 let tmp = tempfile::tempdir().unwrap();
2858 let repo_root = tmp.path();
2859 let ralph_dir = repo_root.join(".git").join("ralph");
2861 fs::create_dir_all(&ralph_dir).unwrap();
2862
2863 let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
2864 fs::write(&track_file, "/nonexistent/dir\n").unwrap();
2865
2866 let remaining = verify_wrapper_cleaned(repo_root);
2867 assert!(
2868 !remaining.is_empty(),
2869 "verify_wrapper_cleaned should report remaining track file"
2870 );
2871 assert!(
2872 remaining[0].contains("track file still exists"),
2873 "should mention track file: {remaining:?}"
2874 );
2875 }
2876
2877 #[test]
2878 fn test_verify_wrapper_cleaned_reports_remaining_dir() {
2879 let tmp = tempfile::tempdir().unwrap();
2880 let repo_root = tmp.path();
2881 let ralph_dir = repo_root.join(".git").join("ralph");
2883 fs::create_dir_all(&ralph_dir).unwrap();
2884
2885 let wrapper_dir = tempfile::Builder::new()
2887 .prefix(WRAPPER_DIR_PREFIX)
2888 .tempdir()
2889 .unwrap();
2890 let wrapper_dir_path = wrapper_dir.keep();
2891
2892 let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
2893 fs::write(&track_file, format!("{}\n", wrapper_dir_path.display())).unwrap();
2894
2895 let remaining = verify_wrapper_cleaned(repo_root);
2896 assert!(
2897 remaining.len() >= 2,
2898 "should report both track file and wrapper dir: {remaining:?}"
2899 );
2900
2901 let _ = fs::remove_dir_all(&wrapper_dir_path);
2903 }
2904
2905 #[cfg(unix)]
2906 #[test]
2907 fn test_disable_git_wrapper_removes_track_file_even_when_dir_removal_fails() {
2908 use std::os::unix::fs::PermissionsExt;
2909
2910 let _lock = ENV_LOCK.lock().unwrap();
2911 let repo_root_tmp = tempfile::tempdir().unwrap();
2912 let repo_root = repo_root_tmp.path();
2913 let ralph_dir = repo_root.join(".git").join("ralph");
2915 fs::create_dir_all(&ralph_dir).unwrap();
2916
2917 let blocked_parent = tempfile::tempdir_in(env::temp_dir()).unwrap();
2918 let wrapper_dir_path = blocked_parent.path().join("ralph-git-wrapper-blocked");
2919 fs::create_dir(&wrapper_dir_path).unwrap();
2920 fs::write(wrapper_dir_path.join("git"), "#!/bin/sh\n").unwrap();
2921
2922 let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
2923 fs::write(&track_file, format!("{}\n", wrapper_dir_path.display())).unwrap();
2924
2925 let original_parent_mode = fs::metadata(blocked_parent.path())
2926 .unwrap()
2927 .permissions()
2928 .mode();
2929 let mut parent_permissions = fs::metadata(blocked_parent.path()).unwrap().permissions();
2930 parent_permissions.set_mode(0o555);
2931 fs::set_permissions(blocked_parent.path(), parent_permissions).unwrap();
2932
2933 let mut helpers = GitHelpers {
2934 wrapper_dir: Some(wrapper_dir_path.clone()),
2935 wrapper_repo_root: Some(repo_root.to_path_buf()),
2936 ..GitHelpers::default()
2937 };
2938
2939 disable_git_wrapper(&mut helpers);
2940
2941 assert!(
2944 !track_file.exists(),
2945 "track file must be removed unconditionally, even when wrapper dir removal fails"
2946 );
2947
2948 let mut restore_permissions = fs::metadata(blocked_parent.path()).unwrap().permissions();
2949 restore_permissions.set_mode(original_parent_mode);
2950 fs::set_permissions(blocked_parent.path(), restore_permissions).unwrap();
2951 fs::remove_dir_all(&wrapper_dir_path).unwrap();
2952 }
2953
2954 #[test]
2959 fn test_cleanup_agent_phase_silent_at_removes_ralph_dir_when_all_artifacts_present() {
2960 let tmp = tempfile::tempdir().unwrap();
2963 let repo_root = tmp.path();
2964 let ralph_dir = repo_root.join(".git").join("ralph");
2965 fs::create_dir_all(&ralph_dir).unwrap();
2966 fs::write(ralph_dir.join(MARKER_FILE_NAME), "").unwrap();
2967 fs::write(ralph_dir.join(HEAD_OID_FILE_NAME), "abc123\n").unwrap();
2968 fs::write(
2970 ralph_dir.join(WRAPPER_TRACK_FILE_NAME),
2971 "/nonexistent/wrapper\n",
2972 )
2973 .unwrap();
2974
2975 cleanup_agent_phase_silent_at(repo_root);
2976
2977 assert!(
2978 !ralph_dir.exists(),
2979 ".git/ralph/ should be fully removed after cleanup_agent_phase_silent_at; \
2980 all artifacts were removed but the directory still exists"
2981 );
2982 }
2983
2984 #[test]
2985 fn test_cleanup_removes_ralph_dir_when_stray_head_oid_tmp_file_exists() {
2986 let tmp = tempfile::tempdir().unwrap();
2989 let repo_root = tmp.path();
2990 let ralph_dir = repo_root.join(".git").join("ralph");
2991 fs::create_dir_all(&ralph_dir).unwrap();
2992 fs::write(ralph_dir.join(MARKER_FILE_NAME), "").unwrap();
2993 let stray = ralph_dir.join(format!(".head-oid.tmp.{}.123456789", std::process::id()));
2995 fs::write(&stray, "deadbeef\n").unwrap();
2996 #[cfg(unix)]
2998 {
2999 use std::os::unix::fs::PermissionsExt;
3000 let mut perms = fs::metadata(&stray).unwrap().permissions();
3001 perms.set_mode(0o444);
3002 fs::set_permissions(&stray, perms).unwrap();
3003 }
3004
3005 cleanup_agent_phase_silent_at(repo_root);
3006
3007 assert!(
3008 !ralph_dir.exists(),
3009 ".git/ralph/ should be fully removed even when a stray .head-oid.tmp.* file exists"
3010 );
3011 }
3012
3013 #[test]
3014 fn test_cleanup_removes_ralph_dir_when_stray_wrapper_track_tmp_file_exists() {
3015 let tmp = tempfile::tempdir().unwrap();
3018 let repo_root = tmp.path();
3019 let ralph_dir = repo_root.join(".git").join("ralph");
3020 fs::create_dir_all(&ralph_dir).unwrap();
3021 fs::write(ralph_dir.join(MARKER_FILE_NAME), "").unwrap();
3022 let stray = ralph_dir.join(format!(
3024 ".git-wrapper-dir.tmp.{}.987654321",
3025 std::process::id()
3026 ));
3027 fs::write(&stray, "/tmp/some-wrapper-dir\n").unwrap();
3028 #[cfg(unix)]
3030 {
3031 use std::os::unix::fs::PermissionsExt;
3032 let mut perms = fs::metadata(&stray).unwrap().permissions();
3033 perms.set_mode(0o444);
3034 fs::set_permissions(&stray, perms).unwrap();
3035 }
3036
3037 cleanup_agent_phase_silent_at(repo_root);
3038
3039 assert!(
3040 !ralph_dir.exists(),
3041 ".git/ralph/ should be fully removed even when a stray .git-wrapper-dir.tmp.* file exists"
3042 );
3043 }
3044
3045 #[test]
3046 fn test_try_remove_ralph_dir_removes_dir_containing_only_stray_tmp_files() {
3047 let tmp = tempfile::tempdir().unwrap();
3049 let repo_root = tmp.path();
3050 let ralph_dir = repo_root.join(".git").join("ralph");
3051 fs::create_dir_all(&ralph_dir).unwrap();
3052 let stray = ralph_dir.join(".head-oid.tmp.99999.111111111");
3054 fs::write(&stray, "cafebabe\n").unwrap();
3055 #[cfg(unix)]
3056 {
3057 use std::os::unix::fs::PermissionsExt;
3058 let mut perms = fs::metadata(&stray).unwrap().permissions();
3059 perms.set_mode(0o444);
3060 fs::set_permissions(&stray, perms).unwrap();
3061 }
3062
3063 let removed = try_remove_ralph_dir(repo_root);
3064
3065 assert!(
3066 removed,
3067 "try_remove_ralph_dir should report success when the directory is gone"
3068 );
3069 assert!(
3070 !ralph_dir.exists(),
3071 ".git/ralph/ should be fully removed by try_remove_ralph_dir when only stray tmp files remain"
3072 );
3073 }
3074
3075 #[test]
3076 fn test_try_remove_ralph_dir_reports_failure_when_unexpected_artifact_remains() {
3077 let tmp = tempfile::tempdir().unwrap();
3078 let repo_root = tmp.path();
3079 let ralph_dir = repo_root.join(".git").join("ralph");
3080 fs::create_dir_all(&ralph_dir).unwrap();
3081 fs::write(ralph_dir.join("quarantine.bin"), "keep").unwrap();
3082
3083 let removed = try_remove_ralph_dir(repo_root);
3084
3085 assert!(
3086 !removed,
3087 "try_remove_ralph_dir should report failure when .git/ralph remains on disk"
3088 );
3089 let remaining = verify_ralph_dir_removed(repo_root);
3090 assert!(
3091 remaining
3092 .iter()
3093 .any(|entry| entry.contains("directory still exists")),
3094 "verification should report that the directory still exists: {remaining:?}"
3095 );
3096 assert!(
3097 remaining
3098 .iter()
3099 .any(|entry| entry.contains("quarantine.bin")),
3100 "verification should report the unexpected artifact that blocked removal: {remaining:?}"
3101 );
3102 }
3103
3104 #[test]
3105 #[cfg(unix)]
3106 fn test_try_remove_ralph_dir_quarantines_symlinked_ralph_dir_without_touching_target() {
3107 use std::os::unix::fs::symlink;
3108
3109 let tmp = tempfile::tempdir().unwrap();
3110 let repo_root = tmp.path();
3111 let git_dir = repo_root.join(".git");
3112 fs::create_dir_all(&git_dir).unwrap();
3113
3114 let outside_dir = repo_root.join("outside-ralph-target");
3115 fs::create_dir_all(&outside_dir).unwrap();
3116 let outside_tmp = outside_dir.join(".head-oid.tmp.12345.999");
3117 fs::write(&outside_tmp, "keep me\n").unwrap();
3118
3119 let ralph_dir = git_dir.join("ralph");
3120 symlink(&outside_dir, &ralph_dir).unwrap();
3121
3122 let removed = try_remove_ralph_dir(repo_root);
3123
3124 assert!(
3125 removed,
3126 "try_remove_ralph_dir should treat a quarantined symlink as cleaned up"
3127 );
3128 assert!(
3129 !ralph_dir.exists(),
3130 ".git/ralph path should be removed after quarantining the symlink"
3131 );
3132 assert!(
3133 outside_tmp.exists(),
3134 "cleanup must not follow a symlinked .git/ralph and delete temp-like files in the target directory"
3135 );
3136
3137 let quarantined = fs::read_dir(&git_dir)
3138 .unwrap()
3139 .filter_map(Result::ok)
3140 .map(|entry| entry.file_name().to_string_lossy().into_owned())
3141 .find(|name| name.starts_with("ralph.ralph.tampered.dir."))
3142 .unwrap_or_default();
3143 assert!(
3144 !quarantined.is_empty(),
3145 "symlinked .git/ralph should be quarantined for inspection"
3146 );
3147 }
3148
3149 #[test]
3150 #[cfg(unix)]
3151 fn test_verify_ralph_dir_removed_quarantines_symlinked_ralph_dir_without_touching_target() {
3152 use std::os::unix::fs::symlink;
3153
3154 let tmp = tempfile::tempdir().unwrap();
3155 let repo_root = tmp.path();
3156 let git_dir = repo_root.join(".git");
3157 fs::create_dir_all(&git_dir).unwrap();
3158
3159 let outside_dir = repo_root.join("outside-verify-target");
3160 fs::create_dir_all(&outside_dir).unwrap();
3161 let outside_tmp = outside_dir.join(".git-wrapper-dir.tmp.12345.999");
3162 fs::write(&outside_tmp, "keep me too\n").unwrap();
3163
3164 let ralph_dir = git_dir.join("ralph");
3165 symlink(&outside_dir, &ralph_dir).unwrap();
3166
3167 let remaining = verify_ralph_dir_removed(repo_root);
3168
3169 assert!(
3170 remaining.is_empty(),
3171 "verification should report .git/ralph as removed after quarantining the symlink: {remaining:?}"
3172 );
3173 assert!(
3174 !ralph_dir.exists(),
3175 ".git/ralph path should no longer exist after verification sanitizes it"
3176 );
3177 assert!(
3178 outside_tmp.exists(),
3179 "verification must not follow a symlinked .git/ralph and inspect/delete the target directory"
3180 );
3181
3182 let quarantined = fs::read_dir(&git_dir)
3183 .unwrap()
3184 .filter_map(Result::ok)
3185 .map(|entry| entry.file_name().to_string_lossy().into_owned())
3186 .find(|name| name.starts_with("ralph.ralph.tampered.dir."))
3187 .unwrap_or_default();
3188 assert!(
3189 !quarantined.is_empty(),
3190 "verification should quarantine a symlinked .git/ralph path for inspection"
3191 );
3192 }
3193
3194 #[test]
3195 #[cfg(unix)]
3196 fn test_cleanup_stray_tmp_files_in_ralph_dir_ignores_symlink_entries() {
3197 use std::os::unix::fs::symlink;
3198
3199 let tmp = tempfile::tempdir().unwrap();
3200 let repo_root = tmp.path();
3201 let ralph_dir = repo_root.join(".git").join("ralph");
3202 fs::create_dir_all(&ralph_dir).unwrap();
3203
3204 let outside_target = repo_root.join("outside-target.txt");
3205 fs::write(&outside_target, "keep readonly mode untouched\n").unwrap();
3206 let symlink_path = ralph_dir.join(".head-oid.tmp.99999.222222222");
3207 symlink(&outside_target, &symlink_path).unwrap();
3208
3209 cleanup_stray_tmp_files_in_ralph_dir(&ralph_dir);
3210
3211 assert!(
3212 symlink_path.exists(),
3213 "cleanup must skip temp-name symlinks instead of deleting them"
3214 );
3215 let target_contents = fs::read_to_string(&outside_target).unwrap();
3216 assert_eq!(target_contents, "keep readonly mode untouched\n");
3217 }
3218
3219 #[test]
3220 #[cfg(windows)]
3221 fn test_cleanup_stray_tmp_files_in_ralph_dir_removes_readonly_files_on_windows() {
3222 let tmp = tempfile::tempdir().unwrap();
3223 let repo_root = tmp.path();
3224 let ralph_dir = repo_root.join(".git").join("ralph");
3225 fs::create_dir_all(&ralph_dir).unwrap();
3226
3227 let stray = ralph_dir.join(".head-oid.tmp.99999.333333333");
3228 fs::write(&stray, "deadbeef\n").unwrap();
3229 let mut perms = fs::metadata(&stray).unwrap().permissions();
3230 perms.set_readonly(true);
3231 fs::set_permissions(&stray, perms).unwrap();
3232
3233 cleanup_stray_tmp_files_in_ralph_dir(&ralph_dir);
3234
3235 assert!(
3236 !stray.exists(),
3237 "cleanup must clear the readonly attribute before removing stray temp files on Windows"
3238 );
3239 }
3240
3241 #[test]
3242 fn test_cleanup_agent_phase_silent_at_removes_ralph_hooks() {
3243 use crate::git_helpers::hooks;
3246
3247 let tmp = tempfile::tempdir().unwrap();
3248 let repo_root = tmp.path();
3249 let _repo = git2::Repository::init(repo_root).unwrap();
3251
3252 let hooks_dir = repo_root.join(".git").join("hooks");
3253 fs::create_dir_all(&hooks_dir).unwrap();
3254 let ralph_dir = repo_root.join(".git").join("ralph");
3255 fs::create_dir_all(&ralph_dir).unwrap();
3256
3257 let hook_content = format!("#!/bin/bash\n# {}\nexit 0\n", hooks::HOOK_MARKER);
3259 for hook_name in hooks::RALPH_HOOK_NAMES {
3260 let hook_path = hooks_dir.join(hook_name);
3261 fs::write(&hook_path, &hook_content).unwrap();
3262 #[cfg(unix)]
3263 {
3264 use std::os::unix::fs::PermissionsExt;
3265 let mut perms = fs::metadata(&hook_path).unwrap().permissions();
3266 perms.set_mode(0o555);
3267 fs::set_permissions(&hook_path, perms).unwrap();
3268 }
3269 }
3270
3271 cleanup_agent_phase_silent_at(repo_root);
3272
3273 for hook_name in hooks::RALPH_HOOK_NAMES {
3274 let hook_path = hooks_dir.join(hook_name);
3275 let still_has_marker = hook_path.exists()
3276 && crate::files::file_contains_marker(&hook_path, hooks::HOOK_MARKER)
3277 .unwrap_or(false);
3278 assert!(
3279 !still_has_marker,
3280 "Ralph hook {hook_name} should be removed by cleanup_agent_phase_silent_at"
3281 );
3282 }
3283 }
3284
3285 #[cfg(unix)]
3286 #[test]
3287 fn test_cleanup_removes_readonly_marker_and_track_file() {
3288 use std::os::unix::fs::PermissionsExt;
3289
3290 let tmp = tempfile::tempdir().unwrap();
3291 let repo_root = tmp.path();
3292 let ralph_dir = repo_root.join(".git/ralph");
3293 fs::create_dir_all(&ralph_dir).unwrap();
3294
3295 let marker = ralph_dir.join(MARKER_FILE_NAME);
3297 fs::write(&marker, "").unwrap();
3298 fs::set_permissions(&marker, fs::Permissions::from_mode(0o444)).unwrap();
3299
3300 let track = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
3302 fs::write(&track, "/nonexistent\n").unwrap();
3303 fs::set_permissions(&track, fs::Permissions::from_mode(0o444)).unwrap();
3304
3305 end_agent_phase_in_repo_at_ralph_dir(repo_root, &ralph_dir);
3306 cleanup_git_wrapper_dir_silent_at(&ralph_dir);
3307
3308 assert!(!marker.exists(), "read-only marker should be removed");
3309 assert!(!track.exists(), "read-only track file should be removed");
3310 }
3311
3312 #[test]
3313 fn test_cleanup_is_idempotent_called_twice() {
3314 let tmp = tempfile::tempdir().unwrap();
3315 let repo_root = tmp.path();
3316 let ralph_dir = repo_root.join(".git/ralph");
3317 fs::create_dir_all(&ralph_dir).unwrap();
3318
3319 let marker = ralph_dir.join(MARKER_FILE_NAME);
3320 fs::write(&marker, "").unwrap();
3321
3322 cleanup_agent_phase_silent_at(repo_root);
3324 assert!(!marker.exists());
3325
3326 cleanup_agent_phase_silent_at(repo_root);
3328 assert!(!marker.exists());
3329 }
3330
3331 #[test]
3332 fn test_cleanup_agent_phase_silent_at_from_worktree_root() {
3333 let tmp = tempfile::tempdir().unwrap();
3334 let main_repo = git2::Repository::init(tmp.path()).unwrap();
3335 {
3336 let mut index = main_repo.index().unwrap();
3337 let tree_oid = index.write_tree().unwrap();
3338 let tree = main_repo.find_tree(tree_oid).unwrap();
3339 let sig = git2::Signature::now("test", "test@test.com").unwrap();
3340 main_repo
3341 .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
3342 .unwrap();
3343 }
3344
3345 let wt_path = tmp.path().join("wt-cleanup");
3346 let _wt = main_repo.worktree("wt-cleanup", &wt_path, None).unwrap();
3347
3348 let worktree_scope = crate::git_helpers::resolve_protection_scope_from(&wt_path).unwrap();
3349 let worktree_ralph_dir = worktree_scope.git_dir.join("ralph");
3350 crate::git_helpers::hooks::install_hooks_in_repo(&wt_path).unwrap();
3351 fs::write(worktree_ralph_dir.join(MARKER_FILE_NAME), "").unwrap();
3352 fs::write(
3354 worktree_ralph_dir.join(WRAPPER_TRACK_FILE_NAME),
3355 "/nonexistent/wrapper\n",
3356 )
3357 .unwrap();
3358
3359 assert!(
3360 worktree_scope
3361 .worktree_config_path
3362 .as_ref()
3363 .unwrap()
3364 .exists(),
3365 "precondition: linked worktree cleanup test must start with config.worktree present"
3366 );
3367
3368 cleanup_agent_phase_silent_at(&wt_path);
3370
3371 assert!(
3372 !worktree_ralph_dir.join(MARKER_FILE_NAME).exists(),
3373 "marker at worktree git dir should be removed when cleaning from worktree root"
3374 );
3375 assert!(
3376 !worktree_ralph_dir.join(WRAPPER_TRACK_FILE_NAME).exists(),
3377 "track file at worktree git dir should be removed when cleaning from worktree root"
3378 );
3379 for name in crate::git_helpers::hooks::RALPH_HOOK_NAMES {
3380 let hook_path = worktree_scope.hooks_dir.join(name);
3381 let still_ralph = hook_path.exists()
3382 && crate::files::file_contains_marker(
3383 &hook_path,
3384 crate::git_helpers::hooks::HOOK_MARKER,
3385 )
3386 .unwrap_or(false);
3387 assert!(
3388 !still_ralph,
3389 "hook {name} at worktree hooks dir should be removed from worktree root"
3390 );
3391 }
3392
3393 assert!(
3394 !worktree_scope
3395 .worktree_config_path
3396 .as_ref()
3397 .unwrap()
3398 .exists(),
3399 "silent cleanup should remove the worktree-local config override"
3400 );
3401 let common_config = crate::git_helpers::resolve_protection_scope_from(&wt_path)
3402 .unwrap()
3403 .common_git_dir
3404 .join("config");
3405 assert!(
3406 common_config.exists(),
3407 "common config should remain inspectable after cleanup"
3408 );
3409 assert_eq!(
3410 git2::Config::open(&common_config)
3411 .unwrap()
3412 .get_string("extensions.worktreeConfig")
3413 .ok(),
3414 None,
3415 "silent cleanup should restore shared worktreeConfig state for a linked worktree run"
3416 );
3417 }
3418
3419 #[test]
3420 fn test_cleanup_agent_phase_silent_at_from_root_repo_restores_root_worktree_scoping() {
3421 let tmp = tempfile::tempdir().unwrap();
3422 let main_repo_path = tmp.path().join("main");
3423 fs::create_dir_all(&main_repo_path).unwrap();
3424 let main_repo = git2::Repository::init(&main_repo_path).unwrap();
3425 {
3426 let mut index = main_repo.index().unwrap();
3427 let tree_oid = index.write_tree().unwrap();
3428 let tree = main_repo.find_tree(tree_oid).unwrap();
3429 let sig = git2::Signature::now("test", "test@test.com").unwrap();
3430 main_repo
3431 .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
3432 .unwrap();
3433 }
3434
3435 let sibling_path = tmp.path().join("wt-sibling");
3436 let _sibling = main_repo
3437 .worktree("wt-sibling", &sibling_path, None)
3438 .unwrap();
3439
3440 let root_scope =
3441 crate::git_helpers::resolve_protection_scope_from(&main_repo_path).unwrap();
3442 let common_config = root_scope.common_git_dir.join("config");
3443 let root_config = root_scope.worktree_config_path.unwrap();
3444
3445 crate::git_helpers::hooks::install_hooks_in_repo(&main_repo_path).unwrap();
3446 assert!(
3447 root_config.exists(),
3448 "precondition: root config.worktree must exist"
3449 );
3450 assert_eq!(
3451 git2::Config::open(&common_config)
3452 .unwrap()
3453 .get_string("extensions.worktreeConfig")
3454 .ok(),
3455 Some("true".to_string()),
3456 "precondition: root worktree install should enable shared worktreeConfig"
3457 );
3458
3459 cleanup_agent_phase_silent_at(&main_repo_path);
3460
3461 assert!(
3462 !root_config.exists(),
3463 "silent cleanup should remove the root worktree config override"
3464 );
3465 assert_eq!(
3466 git2::Config::open(&common_config)
3467 .unwrap()
3468 .get_string("extensions.worktreeConfig")
3469 .ok(),
3470 None,
3471 "silent cleanup should restore shared worktreeConfig state for a root run"
3472 );
3473 }
3474
3475 #[test]
3480 fn test_track_file_removed_even_when_wrapper_dir_cleanup_fails() {
3481 let tmp = tempfile::tempdir().unwrap();
3485 let ralph_dir = tmp.path().join(".git").join("ralph");
3486 fs::create_dir_all(&ralph_dir).unwrap();
3487
3488 let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
3492 fs::write(&track_file, "/not-a-real-temp-dir/ralph-git-wrapper-fake\n").unwrap();
3493
3494 cleanup_git_wrapper_dir_silent_at(&ralph_dir);
3495
3496 assert!(
3497 !track_file.exists(),
3498 "track file must be removed unconditionally, even when wrapper dir cleanup fails"
3499 );
3500 }
3501
3502 #[test]
3503 fn test_disable_git_wrapper_always_removes_track_file() {
3504 let tmp = tempfile::tempdir().unwrap();
3507 let repo_root = tmp.path();
3508 let ralph_dir = repo_root.join(".git").join("ralph");
3509 fs::create_dir_all(&ralph_dir).unwrap();
3510
3511 let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
3513 fs::write(&track_file, "/nonexistent/not-temp-prefixed\n").unwrap();
3514 #[cfg(unix)]
3515 {
3516 use std::os::unix::fs::PermissionsExt;
3517 let mut perms = fs::metadata(&track_file).unwrap().permissions();
3518 perms.set_mode(0o444);
3519 fs::set_permissions(&track_file, perms).unwrap();
3520 }
3521
3522 let mut helpers = GitHelpers {
3523 wrapper_dir: None,
3524 wrapper_repo_root: Some(repo_root.to_path_buf()),
3525 ..GitHelpers::default()
3526 };
3527
3528 disable_git_wrapper(&mut helpers);
3529
3530 assert!(
3531 !track_file.exists(),
3532 "track file must be removed unconditionally by disable_git_wrapper"
3533 );
3534 }
3535
3536 #[test]
3541 fn test_global_mutexes_not_cleared_by_end_agent_phase_in_repo() {
3542 let _lock = ENV_LOCK.lock().unwrap();
3543 let _guard = ClearAgentPhaseStateOnDrop;
3552 let _test_lock = agent_phase_test_lock().lock().unwrap();
3553
3554 let tmp = tempfile::tempdir().unwrap();
3555 let repo_root = tmp.path();
3556 let ralph_dir = repo_root.join(".git").join("ralph");
3557 fs::create_dir_all(&ralph_dir).unwrap();
3558 fs::write(ralph_dir.join(MARKER_FILE_NAME), "").unwrap();
3559
3560 set_agent_phase_paths_for_test(Some(repo_root.to_path_buf()), Some(ralph_dir), None);
3563
3564 end_agent_phase_in_repo(repo_root);
3565
3566 let repo_root_val = AGENT_PHASE_REPO_ROOT.lock().unwrap().clone();
3568 assert!(
3569 repo_root_val.is_some(),
3570 "AGENT_PHASE_REPO_ROOT should NOT be cleared by end_agent_phase_in_repo"
3571 );
3572
3573 let ralph_dir_val = AGENT_PHASE_RALPH_DIR.lock().unwrap().clone();
3574 assert!(
3575 ralph_dir_val.is_some(),
3576 "AGENT_PHASE_RALPH_DIR should NOT be cleared by end_agent_phase_in_repo"
3577 );
3578 }
3580
3581 #[test]
3582 fn test_clear_agent_phase_global_state_clears_all_mutexes() {
3583 let _lock = ENV_LOCK.lock().unwrap();
3584 set_agent_phase_paths_for_test(
3585 Some(PathBuf::from("/test/repo")),
3586 Some(PathBuf::from("/test/repo/.git/ralph")),
3587 Some(PathBuf::from("/test/repo/.git/hooks")),
3588 );
3589
3590 clear_agent_phase_global_state();
3591
3592 assert!(
3593 AGENT_PHASE_REPO_ROOT.lock().unwrap().is_none(),
3594 "AGENT_PHASE_REPO_ROOT should be cleared"
3595 );
3596 assert!(
3597 AGENT_PHASE_RALPH_DIR.lock().unwrap().is_none(),
3598 "AGENT_PHASE_RALPH_DIR should be cleared"
3599 );
3600 assert!(
3601 AGENT_PHASE_HOOKS_DIR.lock().unwrap().is_none(),
3602 "AGENT_PHASE_HOOKS_DIR should be cleared"
3603 );
3604 }
3605
3606 fn install_all_agent_phase_artifacts(repo_root: &Path) {
3612 use crate::git_helpers::hooks;
3613
3614 let ralph_dir = repo_root.join(".git").join("ralph");
3615 fs::create_dir_all(&ralph_dir).unwrap();
3616 let hooks_dir = repo_root.join(".git").join("hooks");
3617 fs::create_dir_all(&hooks_dir).unwrap();
3618
3619 let marker = ralph_dir.join(MARKER_FILE_NAME);
3620 fs::write(&marker, "").unwrap();
3621 #[cfg(unix)]
3622 {
3623 use std::os::unix::fs::PermissionsExt;
3624 fs::set_permissions(&marker, fs::Permissions::from_mode(0o444)).unwrap();
3625 }
3626
3627 let track_file = ralph_dir.join(WRAPPER_TRACK_FILE_NAME);
3628 fs::write(&track_file, "/nonexistent/wrapper\n").unwrap();
3629 #[cfg(unix)]
3630 {
3631 use std::os::unix::fs::PermissionsExt;
3632 fs::set_permissions(&track_file, fs::Permissions::from_mode(0o444)).unwrap();
3633 }
3634
3635 fs::write(ralph_dir.join(HEAD_OID_FILE_NAME), "abc123\n").unwrap();
3636
3637 let hook_content = format!("#!/bin/bash\n# {}\nexit 0\n", hooks::HOOK_MARKER);
3638 for name in hooks::RALPH_HOOK_NAMES {
3639 let hook_path = hooks_dir.join(name);
3640 fs::write(&hook_path, &hook_content).unwrap();
3641 #[cfg(unix)]
3642 {
3643 use std::os::unix::fs::PermissionsExt;
3644 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o555)).unwrap();
3645 }
3646 }
3647
3648 set_agent_phase_paths_for_test(
3649 Some(repo_root.to_path_buf()),
3650 Some(ralph_dir),
3651 Some(hooks_dir),
3652 );
3653 }
3654
3655 #[test]
3656 fn test_cleanup_agent_phase_silent_at_removes_all_artifacts_including_track_file() {
3657 use crate::git_helpers::hooks;
3658
3659 let _lock = ENV_LOCK.lock().unwrap();
3660 let tmp = tempfile::tempdir().unwrap();
3661 let repo_root = tmp.path();
3662 let _repo = git2::Repository::init(repo_root).unwrap();
3663
3664 install_all_agent_phase_artifacts(repo_root);
3665 cleanup_agent_phase_silent_at(repo_root);
3666
3667 let ralph_dir = repo_root.join(".git").join("ralph");
3668 let hooks_dir = repo_root.join(".git").join("hooks");
3669
3670 assert!(
3671 !ralph_dir.join(MARKER_FILE_NAME).exists(),
3672 "marker should be removed"
3673 );
3674 assert!(
3675 !ralph_dir.join(WRAPPER_TRACK_FILE_NAME).exists(),
3676 "track file should be removed"
3677 );
3678 assert!(
3679 !ralph_dir.join(HEAD_OID_FILE_NAME).exists(),
3680 "head-oid should be removed"
3681 );
3682 assert!(!ralph_dir.exists(), "ralph dir should be removed");
3683 for name in hooks::RALPH_HOOK_NAMES {
3684 let hook_path = hooks_dir.join(name);
3685 let still_ralph = hook_path.exists()
3686 && crate::files::file_contains_marker(&hook_path, hooks::HOOK_MARKER)
3687 .unwrap_or(false);
3688 assert!(!still_ralph, "hook {name} should be removed");
3689 }
3690
3691 assert!(AGENT_PHASE_REPO_ROOT.lock().unwrap().is_none());
3693 assert!(AGENT_PHASE_RALPH_DIR.lock().unwrap().is_none());
3694 assert!(AGENT_PHASE_HOOKS_DIR.lock().unwrap().is_none());
3695 }
3696
3697 #[test]
3698 fn test_cleanup_agent_phase_protections_silent_at_preserves_commit_message_output() {
3699 let _lock = ENV_LOCK.lock().unwrap();
3700 let tmp = tempfile::tempdir().unwrap();
3701 let repo_root = tmp.path();
3702 let _repo = git2::Repository::init(repo_root).unwrap();
3703
3704 install_all_agent_phase_artifacts(repo_root);
3705 let commit_message_path = repo_root.join(".agent/commit-message.txt");
3706 fs::create_dir_all(commit_message_path.parent().unwrap()).unwrap();
3707 fs::write(&commit_message_path, "feat: keep generated message\n").unwrap();
3708
3709 cleanup_agent_phase_protections_silent_at(repo_root);
3710
3711 assert!(
3712 commit_message_path.exists(),
3713 "command-exit cleanup must preserve generated command output files"
3714 );
3715 assert!(
3716 !repo_root.join(".git/ralph").exists(),
3717 "command-exit cleanup must still remove git protection artifacts"
3718 );
3719 }
3720}