ralph_workflow/git_helpers/
wrapper.rs1use super::hooks::{install_hooks, uninstall_hooks_silent};
15use super::repo::get_repo_root;
16use crate::logger::Logger;
17use crate::workspace::Workspace;
18use std::env;
19use std::fs::{self, File};
20use std::io::{self, Write};
21use std::path::{Path, PathBuf};
22use tempfile::TempDir;
23use which::which;
24
25const WRAPPER_DIR_TRACK_FILE: &str = ".agent/git-wrapper-dir.txt";
26
27const MARKER_FILE: &str = ".no_agent_commit";
29
30pub struct GitHelpers {
32 real_git: Option<PathBuf>,
33 wrapper_dir: Option<TempDir>,
34}
35
36impl GitHelpers {
37 pub(crate) const fn new() -> Self {
38 Self {
39 real_git: None,
40 wrapper_dir: None,
41 }
42 }
43
44 fn init_real_git(&mut self) {
46 if self.real_git.is_none() {
47 self.real_git = which("git").ok();
48 }
49 }
50}
51
52impl Default for GitHelpers {
53 fn default() -> Self {
54 Self::new()
55 }
56}
57
58fn escape_shell_single_quoted(path: &str) -> io::Result<String> {
64 if path.contains('\n') || path.contains('\r') {
66 return Err(io::Error::new(
67 io::ErrorKind::InvalidInput,
68 "git path contains newline characters, cannot create safe shell wrapper",
69 ));
70 }
71 Ok(path.replace('\'', "'\\''"))
73}
74
75pub fn enable_git_wrapper(helpers: &mut GitHelpers) -> io::Result<()> {
77 helpers.init_real_git();
78 let Some(real_git) = helpers.real_git.as_ref() else {
79 return Ok(());
83 };
84
85 let git_path_str = real_git.to_str().ok_or_else(|| {
90 io::Error::new(
91 io::ErrorKind::InvalidData,
92 "git binary path contains invalid UTF-8 characters; cannot create wrapper script",
93 )
94 })?;
95
96 if !real_git.is_absolute() {
100 return Err(io::Error::new(
101 io::ErrorKind::InvalidInput,
102 format!(
103 "git binary path is not absolute: '{git_path_str}'. \
104 Using absolute paths prevents potential security issues."
105 ),
106 ));
107 }
108
109 if !real_git.exists() {
112 return Err(io::Error::new(
113 io::ErrorKind::NotFound,
114 format!("git binary does not exist at path: '{git_path_str}'"),
115 ));
116 }
117
118 #[cfg(unix)]
120 {
121 match fs::metadata(real_git) {
122 Ok(metadata) => {
123 let file_type = metadata.file_type();
124 if file_type.is_dir() {
125 return Err(io::Error::new(
126 io::ErrorKind::InvalidInput,
127 format!("git binary path is a directory, not a file: '{git_path_str}'"),
128 ));
129 }
130 if file_type.is_symlink() {
131 return Err(io::Error::new(
134 io::ErrorKind::InvalidInput,
135 format!("git binary path is a symlink; use the actual binary path: '{git_path_str}'"),
136 ));
137 }
138 }
139 Err(_) => {
140 return Err(io::Error::new(
141 io::ErrorKind::PermissionDenied,
142 format!("cannot access git binary metadata at path: '{git_path_str}'"),
143 ));
144 }
145 }
146 }
147
148 let wrapper_dir = tempfile::tempdir()?;
149 let wrapper_path = wrapper_dir.path().join("git");
150
151 let git_path_escaped = escape_shell_single_quoted(git_path_str)?;
154
155 let wrapper_content = format!(
156 r#"#!/usr/bin/env sh
157set -eu
158repo_root="$('{git_path_escaped}' rev-parse --show-toplevel 2>/dev/null || pwd)"
159if [ -f "$repo_root/.no_agent_commit" ]; then
160 subcmd="${{1-}}"
161 case "$subcmd" in
162 commit|push|tag)
163 echo "Blocked: git $subcmd disabled during agent phase (.no_agent_commit present)." >&2
164 exit 1
165 ;;
166 esac
167fi
168exec '{git_path_escaped}' "$@"
169"#
170 );
171
172 let mut file = File::create(&wrapper_path)?;
173 file.write_all(wrapper_content.as_bytes())?;
174
175 #[cfg(unix)]
176 {
177 use std::os::unix::fs::PermissionsExt;
178 let mut perms = fs::metadata(&wrapper_path)?.permissions();
179 perms.set_mode(0o755);
180 fs::set_permissions(&wrapper_path, perms)?;
181 }
182
183 let current_path = env::var("PATH").unwrap_or_default();
185 env::set_var(
186 "PATH",
187 format!("{}:{}", wrapper_dir.path().display(), current_path),
188 );
189
190 fs::create_dir_all(".agent")?;
191 fs::write(
192 WRAPPER_DIR_TRACK_FILE,
193 wrapper_dir.path().display().to_string(),
194 )?;
195
196 helpers.wrapper_dir = Some(wrapper_dir);
197 Ok(())
198}
199
200pub fn disable_git_wrapper(helpers: &mut GitHelpers) {
210 if let Some(wrapper_dir) = helpers.wrapper_dir.take() {
211 let wrapper_dir_path = wrapper_dir.path().to_path_buf();
212 let _ = fs::remove_dir_all(&wrapper_dir_path);
213 if let Ok(path) = env::var("PATH") {
218 let wrapper_str = wrapper_dir_path.to_string_lossy();
219 let new_path: String = path
220 .split(':')
221 .filter(|p| !p.contains(wrapper_str.as_ref()))
222 .collect::<Vec<_>>()
223 .join(":");
224 env::set_var("PATH", new_path);
225 }
226 }
227 let _ = fs::remove_file(WRAPPER_DIR_TRACK_FILE);
228}
229
230pub fn start_agent_phase(helpers: &mut GitHelpers) -> io::Result<()> {
232 File::create(".no_agent_commit")?;
233 install_hooks()?;
234 enable_git_wrapper(helpers)?;
235 Ok(())
236}
237
238pub fn end_agent_phase() {
240 let repo_root = match crate::git_helpers::get_repo_root() {
241 Ok(root) => root,
242 Err(_) => return,
243 };
244 let marker_path = repo_root.join(".no_agent_commit");
245 let _ = fs::remove_file(marker_path);
246}
247
248fn cleanup_git_wrapper_dir_silent() {
249 let repo_root = match crate::git_helpers::get_repo_root() {
250 Ok(root) => root,
251 Err(_) => return,
252 };
253 let track_file = repo_root.join(WRAPPER_DIR_TRACK_FILE);
254 let wrapper_dir = match fs::read_to_string(&track_file) {
255 Ok(path) => PathBuf::from(path.trim()),
256 Err(_) => return,
257 };
258
259 if !wrapper_dir.as_os_str().is_empty() {
260 let _ = fs::remove_dir_all(&wrapper_dir);
261 }
262 let _ = fs::remove_file(track_file);
263}
264
265pub fn cleanup_agent_phase_silent() {
267 end_agent_phase();
268 cleanup_git_wrapper_dir_silent();
269 uninstall_hooks_silent();
270 cleanup_generated_files_silent();
271}
272
273fn cleanup_generated_files_silent() {
279 let repo_root = match crate::git_helpers::get_repo_root() {
280 Ok(root) => root,
281 Err(_) => return,
282 };
283 for file in crate::files::io::agent_files::GENERATED_FILES {
284 let absolute_path = repo_root.join(file);
285 let _ = std::fs::remove_file(absolute_path);
286 }
287}
288
289pub fn cleanup_orphaned_marker(logger: &Logger) -> io::Result<()> {
291 let repo_root = get_repo_root()?;
292 let marker_path = repo_root.join(".no_agent_commit");
293
294 if marker_path.exists() {
295 fs::remove_file(&marker_path)?;
296 logger.success("Removed orphaned .no_agent_commit marker");
297 } else {
298 logger.info("No orphaned marker found");
299 }
300
301 Ok(())
302}
303
304pub fn create_marker_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
321 workspace.write(Path::new(MARKER_FILE), "")
322}
323
324pub fn remove_marker_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
337 workspace.remove_if_exists(Path::new(MARKER_FILE))
338}
339
340pub fn marker_exists_with_workspace(workspace: &dyn Workspace) -> bool {
353 workspace.exists(Path::new(MARKER_FILE))
354}
355
356pub fn cleanup_orphaned_marker_with_workspace(
370 workspace: &dyn Workspace,
371 logger: &Logger,
372) -> io::Result<()> {
373 let marker_path = Path::new(MARKER_FILE);
374
375 if workspace.exists(marker_path) {
376 workspace.remove(marker_path)?;
377 logger.success("Removed orphaned .no_agent_commit marker");
378 } else {
379 logger.info("No orphaned marker found");
380 }
381
382 Ok(())
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use crate::workspace::MemoryWorkspace;
389
390 #[test]
391 fn test_create_marker_with_workspace() {
392 let workspace = MemoryWorkspace::new_test();
393
394 assert!(!marker_exists_with_workspace(&workspace));
396
397 create_marker_with_workspace(&workspace).unwrap();
399
400 assert!(marker_exists_with_workspace(&workspace));
402 }
403
404 #[test]
405 fn test_remove_marker_with_workspace() {
406 let workspace = MemoryWorkspace::new_test();
407
408 create_marker_with_workspace(&workspace).unwrap();
410 assert!(marker_exists_with_workspace(&workspace));
411
412 remove_marker_with_workspace(&workspace).unwrap();
414
415 assert!(!marker_exists_with_workspace(&workspace));
417 }
418
419 #[test]
420 fn test_remove_marker_with_workspace_nonexistent() {
421 let workspace = MemoryWorkspace::new_test();
422
423 remove_marker_with_workspace(&workspace).unwrap();
425 assert!(!marker_exists_with_workspace(&workspace));
426 }
427
428 #[test]
429 fn test_cleanup_orphaned_marker_with_workspace_exists() {
430 let workspace = MemoryWorkspace::new_test();
431 let logger = Logger::new(crate::logger::Colors { enabled: false });
432
433 create_marker_with_workspace(&workspace).unwrap();
435 assert!(marker_exists_with_workspace(&workspace));
436
437 cleanup_orphaned_marker_with_workspace(&workspace, &logger).unwrap();
439 assert!(!marker_exists_with_workspace(&workspace));
440 }
441
442 #[test]
443 fn test_cleanup_orphaned_marker_with_workspace_not_exists() {
444 let workspace = MemoryWorkspace::new_test();
445 let logger = Logger::new(crate::logger::Colors { enabled: false });
446
447 assert!(!marker_exists_with_workspace(&workspace));
449
450 cleanup_orphaned_marker_with_workspace(&workspace, &logger).unwrap();
452 assert!(!marker_exists_with_workspace(&workspace));
453 }
454
455 #[test]
456 fn test_marker_file_constant() {
457 assert_eq!(MARKER_FILE, ".no_agent_commit");
459 }
460}