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