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 _ = fs::remove_file(".no_agent_commit");
245}
246
247fn cleanup_git_wrapper_dir_silent() {
248 let wrapper_dir = match fs::read_to_string(WRAPPER_DIR_TRACK_FILE) {
249 Ok(path) => PathBuf::from(path.trim()),
250 Err(_) => return,
251 };
252
253 if !wrapper_dir.as_os_str().is_empty() {
254 let _ = fs::remove_dir_all(&wrapper_dir);
255 }
256 let _ = fs::remove_file(WRAPPER_DIR_TRACK_FILE);
257}
258
259pub fn cleanup_agent_phase_silent() {
261 end_agent_phase();
262 cleanup_git_wrapper_dir_silent();
263 uninstall_hooks_silent();
264 crate::files::cleanup_generated_files();
265}
266
267pub fn cleanup_orphaned_marker(logger: &Logger) -> io::Result<()> {
269 let repo_root = get_repo_root()?;
270 let marker_path = repo_root.join(".no_agent_commit");
271
272 if marker_path.exists() {
273 fs::remove_file(&marker_path)?;
274 logger.success("Removed orphaned .no_agent_commit marker");
275 } else {
276 logger.info("No orphaned marker found");
277 }
278
279 Ok(())
280}
281
282#[cfg(any(test, feature = "test-utils"))]
299pub fn create_marker_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
300 workspace.write(Path::new(MARKER_FILE), "")
301}
302
303#[cfg(any(test, feature = "test-utils"))]
316pub fn remove_marker_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
317 workspace.remove_if_exists(Path::new(MARKER_FILE))
318}
319
320#[cfg(any(test, feature = "test-utils"))]
333pub fn marker_exists_with_workspace(workspace: &dyn Workspace) -> bool {
334 workspace.exists(Path::new(MARKER_FILE))
335}
336
337#[cfg(any(test, feature = "test-utils"))]
351pub fn cleanup_orphaned_marker_with_workspace(
352 workspace: &dyn Workspace,
353 logger: &Logger,
354) -> io::Result<()> {
355 let marker_path = Path::new(MARKER_FILE);
356
357 if workspace.exists(marker_path) {
358 workspace.remove(marker_path)?;
359 logger.success("Removed orphaned .no_agent_commit marker");
360 } else {
361 logger.info("No orphaned marker found");
362 }
363
364 Ok(())
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370 use crate::workspace::MemoryWorkspace;
371
372 #[test]
373 fn test_create_marker_with_workspace() {
374 let workspace = MemoryWorkspace::new_test();
375
376 assert!(!marker_exists_with_workspace(&workspace));
378
379 create_marker_with_workspace(&workspace).unwrap();
381
382 assert!(marker_exists_with_workspace(&workspace));
384 }
385
386 #[test]
387 fn test_remove_marker_with_workspace() {
388 let workspace = MemoryWorkspace::new_test();
389
390 create_marker_with_workspace(&workspace).unwrap();
392 assert!(marker_exists_with_workspace(&workspace));
393
394 remove_marker_with_workspace(&workspace).unwrap();
396
397 assert!(!marker_exists_with_workspace(&workspace));
399 }
400
401 #[test]
402 fn test_remove_marker_with_workspace_nonexistent() {
403 let workspace = MemoryWorkspace::new_test();
404
405 remove_marker_with_workspace(&workspace).unwrap();
407 assert!(!marker_exists_with_workspace(&workspace));
408 }
409
410 #[test]
411 fn test_cleanup_orphaned_marker_with_workspace_exists() {
412 let workspace = MemoryWorkspace::new_test();
413 let logger = Logger::new(crate::logger::Colors { enabled: false });
414
415 create_marker_with_workspace(&workspace).unwrap();
417 assert!(marker_exists_with_workspace(&workspace));
418
419 cleanup_orphaned_marker_with_workspace(&workspace, &logger).unwrap();
421 assert!(!marker_exists_with_workspace(&workspace));
422 }
423
424 #[test]
425 fn test_cleanup_orphaned_marker_with_workspace_not_exists() {
426 let workspace = MemoryWorkspace::new_test();
427 let logger = Logger::new(crate::logger::Colors { enabled: false });
428
429 assert!(!marker_exists_with_workspace(&workspace));
431
432 cleanup_orphaned_marker_with_workspace(&workspace, &logger).unwrap();
434 assert!(!marker_exists_with_workspace(&workspace));
435 }
436
437 #[test]
438 fn test_marker_file_constant() {
439 assert_eq!(MARKER_FILE, ".no_agent_commit");
441 }
442}