ralph_workflow/git_helpers/
wrapper.rs1use super::hooks::{install_hooks, uninstall_hooks_silent};
15use super::repo::get_repo_root;
16use crate::logger::Logger;
17#[cfg(any(test, feature = "test-utils"))]
18use crate::workspace::Workspace;
19use std::env;
20use std::fs::{self, File};
21use std::io::{self, Write};
22#[cfg(any(test, feature = "test-utils"))]
23use std::path::Path;
24use std::path::PathBuf;
25use tempfile::TempDir;
26use which::which;
27
28const WRAPPER_DIR_TRACK_FILE: &str = ".agent/git-wrapper-dir.txt";
29
30#[cfg(any(test, feature = "test-utils"))]
32const MARKER_FILE: &str = ".no_agent_commit";
33
34pub struct GitHelpers {
36 real_git: Option<PathBuf>,
37 wrapper_dir: Option<TempDir>,
38}
39
40impl GitHelpers {
41 pub(crate) const fn new() -> Self {
42 Self {
43 real_git: None,
44 wrapper_dir: None,
45 }
46 }
47
48 fn init_real_git(&mut self) {
50 if self.real_git.is_none() {
51 self.real_git = which("git").ok();
52 }
53 }
54}
55
56impl Default for GitHelpers {
57 fn default() -> Self {
58 Self::new()
59 }
60}
61
62fn escape_shell_single_quoted(path: &str) -> io::Result<String> {
68 if path.contains('\n') || path.contains('\r') {
70 return Err(io::Error::new(
71 io::ErrorKind::InvalidInput,
72 "git path contains newline characters, cannot create safe shell wrapper",
73 ));
74 }
75 Ok(path.replace('\'', "'\\''"))
77}
78
79pub fn enable_git_wrapper(helpers: &mut GitHelpers) -> io::Result<()> {
81 helpers.init_real_git();
82 let Some(real_git) = helpers.real_git.as_ref() else {
83 return Ok(());
87 };
88
89 let git_path_str = real_git.to_str().ok_or_else(|| {
94 io::Error::new(
95 io::ErrorKind::InvalidData,
96 "git binary path contains invalid UTF-8 characters; cannot create wrapper script",
97 )
98 })?;
99
100 if !real_git.is_absolute() {
104 return Err(io::Error::new(
105 io::ErrorKind::InvalidInput,
106 format!(
107 "git binary path is not absolute: '{git_path_str}'. \
108 Using absolute paths prevents potential security issues."
109 ),
110 ));
111 }
112
113 if !real_git.exists() {
116 return Err(io::Error::new(
117 io::ErrorKind::NotFound,
118 format!("git binary does not exist at path: '{git_path_str}'"),
119 ));
120 }
121
122 #[cfg(unix)]
124 {
125 match fs::metadata(real_git) {
126 Ok(metadata) => {
127 let file_type = metadata.file_type();
128 if file_type.is_dir() {
129 return Err(io::Error::new(
130 io::ErrorKind::InvalidInput,
131 format!("git binary path is a directory, not a file: '{git_path_str}'"),
132 ));
133 }
134 if file_type.is_symlink() {
135 return Err(io::Error::new(
138 io::ErrorKind::InvalidInput,
139 format!("git binary path is a symlink; use the actual binary path: '{git_path_str}'"),
140 ));
141 }
142 }
143 Err(_) => {
144 return Err(io::Error::new(
145 io::ErrorKind::PermissionDenied,
146 format!("cannot access git binary metadata at path: '{git_path_str}'"),
147 ));
148 }
149 }
150 }
151
152 let wrapper_dir = tempfile::tempdir()?;
153 let wrapper_path = wrapper_dir.path().join("git");
154
155 let git_path_escaped = escape_shell_single_quoted(git_path_str)?;
158
159 let wrapper_content = format!(
160 r#"#!/usr/bin/env sh
161set -eu
162repo_root="$('{git_path_escaped}' rev-parse --show-toplevel 2>/dev/null || pwd)"
163if [ -f "$repo_root/.no_agent_commit" ]; then
164 subcmd="${{1-}}"
165 case "$subcmd" in
166 commit|push|tag)
167 echo "Blocked: git $subcmd disabled during agent phase (.no_agent_commit present)." >&2
168 exit 1
169 ;;
170 esac
171fi
172exec '{git_path_escaped}' "$@"
173"#
174 );
175
176 let mut file = File::create(&wrapper_path)?;
177 file.write_all(wrapper_content.as_bytes())?;
178
179 #[cfg(unix)]
180 {
181 use std::os::unix::fs::PermissionsExt;
182 let mut perms = fs::metadata(&wrapper_path)?.permissions();
183 perms.set_mode(0o755);
184 fs::set_permissions(&wrapper_path, perms)?;
185 }
186
187 let current_path = env::var("PATH").unwrap_or_default();
189 env::set_var(
190 "PATH",
191 format!("{}:{}", wrapper_dir.path().display(), current_path),
192 );
193
194 fs::create_dir_all(".agent")?;
195 fs::write(
196 WRAPPER_DIR_TRACK_FILE,
197 wrapper_dir.path().display().to_string(),
198 )?;
199
200 helpers.wrapper_dir = Some(wrapper_dir);
201 Ok(())
202}
203
204pub fn disable_git_wrapper(helpers: &mut GitHelpers) {
214 if let Some(wrapper_dir) = helpers.wrapper_dir.take() {
215 let wrapper_dir_path = wrapper_dir.path().to_path_buf();
216 let _ = fs::remove_dir_all(&wrapper_dir_path);
217 if let Ok(path) = env::var("PATH") {
222 let wrapper_str = wrapper_dir_path.to_string_lossy();
223 let new_path: String = path
224 .split(':')
225 .filter(|p| !p.contains(wrapper_str.as_ref()))
226 .collect::<Vec<_>>()
227 .join(":");
228 env::set_var("PATH", new_path);
229 }
230 }
231 let _ = fs::remove_file(WRAPPER_DIR_TRACK_FILE);
232}
233
234pub fn start_agent_phase(helpers: &mut GitHelpers) -> io::Result<()> {
236 File::create(".no_agent_commit")?;
237 install_hooks()?;
238 enable_git_wrapper(helpers)?;
239 Ok(())
240}
241
242pub fn end_agent_phase() {
244 let repo_root = match crate::git_helpers::get_repo_root() {
245 Ok(root) => root,
246 Err(_) => return,
247 };
248 let marker_path = repo_root.join(".no_agent_commit");
249 let _ = fs::remove_file(marker_path);
250}
251
252fn cleanup_git_wrapper_dir_silent() {
253 let repo_root = match crate::git_helpers::get_repo_root() {
254 Ok(root) => root,
255 Err(_) => return,
256 };
257 let track_file = repo_root.join(WRAPPER_DIR_TRACK_FILE);
258 let wrapper_dir = match fs::read_to_string(&track_file) {
259 Ok(path) => PathBuf::from(path.trim()),
260 Err(_) => return,
261 };
262
263 if !wrapper_dir.as_os_str().is_empty() {
264 let _ = fs::remove_dir_all(&wrapper_dir);
265 }
266 let _ = fs::remove_file(track_file);
267}
268
269pub fn cleanup_agent_phase_silent() {
271 end_agent_phase();
272 cleanup_git_wrapper_dir_silent();
273 uninstall_hooks_silent();
274 cleanup_generated_files_silent();
275}
276
277fn cleanup_generated_files_silent() {
283 let repo_root = match crate::git_helpers::get_repo_root() {
284 Ok(root) => root,
285 Err(_) => return,
286 };
287 for file in crate::files::io::agent_files::GENERATED_FILES {
288 let absolute_path = repo_root.join(file);
289 let _ = std::fs::remove_file(absolute_path);
290 }
291}
292
293pub fn cleanup_orphaned_marker(logger: &Logger) -> io::Result<()> {
295 let repo_root = get_repo_root()?;
296 let marker_path = repo_root.join(".no_agent_commit");
297
298 if marker_path.exists() {
299 fs::remove_file(&marker_path)?;
300 logger.success("Removed orphaned .no_agent_commit marker");
301 } else {
302 logger.info("No orphaned marker found");
303 }
304
305 Ok(())
306}
307
308#[cfg(any(test, feature = "test-utils"))]
325pub fn create_marker_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
326 workspace.write(Path::new(MARKER_FILE), "")
327}
328
329#[cfg(any(test, feature = "test-utils"))]
342pub fn remove_marker_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
343 workspace.remove_if_exists(Path::new(MARKER_FILE))
344}
345
346#[cfg(any(test, feature = "test-utils"))]
359pub fn marker_exists_with_workspace(workspace: &dyn Workspace) -> bool {
360 workspace.exists(Path::new(MARKER_FILE))
361}
362
363#[cfg(any(test, feature = "test-utils"))]
377pub fn cleanup_orphaned_marker_with_workspace(
378 workspace: &dyn Workspace,
379 logger: &Logger,
380) -> io::Result<()> {
381 let marker_path = Path::new(MARKER_FILE);
382
383 if workspace.exists(marker_path) {
384 workspace.remove(marker_path)?;
385 logger.success("Removed orphaned .no_agent_commit marker");
386 } else {
387 logger.info("No orphaned marker found");
388 }
389
390 Ok(())
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396 use crate::workspace::MemoryWorkspace;
397
398 #[test]
399 fn test_create_marker_with_workspace() {
400 let workspace = MemoryWorkspace::new_test();
401
402 assert!(!marker_exists_with_workspace(&workspace));
404
405 create_marker_with_workspace(&workspace).unwrap();
407
408 assert!(marker_exists_with_workspace(&workspace));
410 }
411
412 #[test]
413 fn test_remove_marker_with_workspace() {
414 let workspace = MemoryWorkspace::new_test();
415
416 create_marker_with_workspace(&workspace).unwrap();
418 assert!(marker_exists_with_workspace(&workspace));
419
420 remove_marker_with_workspace(&workspace).unwrap();
422
423 assert!(!marker_exists_with_workspace(&workspace));
425 }
426
427 #[test]
428 fn test_remove_marker_with_workspace_nonexistent() {
429 let workspace = MemoryWorkspace::new_test();
430
431 remove_marker_with_workspace(&workspace).unwrap();
433 assert!(!marker_exists_with_workspace(&workspace));
434 }
435
436 #[test]
437 fn test_cleanup_orphaned_marker_with_workspace_exists() {
438 let workspace = MemoryWorkspace::new_test();
439 let logger = Logger::new(crate::logger::Colors { enabled: false });
440
441 create_marker_with_workspace(&workspace).unwrap();
443 assert!(marker_exists_with_workspace(&workspace));
444
445 cleanup_orphaned_marker_with_workspace(&workspace, &logger).unwrap();
447 assert!(!marker_exists_with_workspace(&workspace));
448 }
449
450 #[test]
451 fn test_cleanup_orphaned_marker_with_workspace_not_exists() {
452 let workspace = MemoryWorkspace::new_test();
453 let logger = Logger::new(crate::logger::Colors { enabled: false });
454
455 assert!(!marker_exists_with_workspace(&workspace));
457
458 cleanup_orphaned_marker_with_workspace(&workspace, &logger).unwrap();
460 assert!(!marker_exists_with_workspace(&workspace));
461 }
462
463 #[test]
464 fn test_marker_file_constant() {
465 assert_eq!(MARKER_FILE, ".no_agent_commit");
467 }
468}