ralph_workflow/git_helpers/
wrapper.rs1use super::hooks::{install_hooks, uninstall_hooks_silent};
15use super::repo::get_repo_root;
16use crate::logger::Logger;
17use std::env;
18use std::fs::{self, File};
19use std::io::{self, Write};
20use std::path::PathBuf;
21use tempfile::TempDir;
22use which::which;
23
24const WRAPPER_DIR_TRACK_FILE: &str = ".agent/git-wrapper-dir.txt";
25
26pub struct GitHelpers {
28 real_git: Option<PathBuf>,
29 wrapper_dir: Option<TempDir>,
30}
31
32impl GitHelpers {
33 pub(crate) const fn new() -> Self {
34 Self {
35 real_git: None,
36 wrapper_dir: None,
37 }
38 }
39
40 fn init_real_git(&mut self) {
42 if self.real_git.is_none() {
43 self.real_git = which("git").ok();
44 }
45 }
46}
47
48impl Default for GitHelpers {
49 fn default() -> Self {
50 Self::new()
51 }
52}
53
54fn escape_shell_single_quoted(path: &str) -> io::Result<String> {
60 if path.contains('\n') || path.contains('\r') {
62 return Err(io::Error::new(
63 io::ErrorKind::InvalidInput,
64 "git path contains newline characters, cannot create safe shell wrapper",
65 ));
66 }
67 Ok(path.replace('\'', "'\\''"))
69}
70
71pub fn enable_git_wrapper(helpers: &mut GitHelpers) -> io::Result<()> {
73 helpers.init_real_git();
74 let Some(real_git) = helpers.real_git.as_ref() else {
75 return Ok(());
79 };
80
81 let git_path_str = real_git.to_str().ok_or_else(|| {
86 io::Error::new(
87 io::ErrorKind::InvalidData,
88 "git binary path contains invalid UTF-8 characters; cannot create wrapper script",
89 )
90 })?;
91
92 if !real_git.is_absolute() {
96 return Err(io::Error::new(
97 io::ErrorKind::InvalidInput,
98 format!(
99 "git binary path is not absolute: '{git_path_str}'. \
100 Using absolute paths prevents potential security issues."
101 ),
102 ));
103 }
104
105 if !real_git.exists() {
108 return Err(io::Error::new(
109 io::ErrorKind::NotFound,
110 format!("git binary does not exist at path: '{git_path_str}'"),
111 ));
112 }
113
114 #[cfg(unix)]
116 {
117 match fs::metadata(real_git) {
118 Ok(metadata) => {
119 let file_type = metadata.file_type();
120 if file_type.is_dir() {
121 return Err(io::Error::new(
122 io::ErrorKind::InvalidInput,
123 format!("git binary path is a directory, not a file: '{git_path_str}'"),
124 ));
125 }
126 if file_type.is_symlink() {
127 return Err(io::Error::new(
130 io::ErrorKind::InvalidInput,
131 format!("git binary path is a symlink; use the actual binary path: '{git_path_str}'"),
132 ));
133 }
134 }
135 Err(_) => {
136 return Err(io::Error::new(
137 io::ErrorKind::PermissionDenied,
138 format!("cannot access git binary metadata at path: '{git_path_str}'"),
139 ));
140 }
141 }
142 }
143
144 let wrapper_dir = tempfile::tempdir()?;
145 let wrapper_path = wrapper_dir.path().join("git");
146
147 let git_path_escaped = escape_shell_single_quoted(git_path_str)?;
150
151 let wrapper_content = format!(
152 r#"#!/usr/bin/env sh
153set -eu
154repo_root="$('{git_path_escaped}' rev-parse --show-toplevel 2>/dev/null || pwd)"
155if [ -f "$repo_root/.no_agent_commit" ]; then
156 subcmd="${{1-}}"
157 case "$subcmd" in
158 commit|push|tag)
159 echo "Blocked: git $subcmd disabled during agent phase (.no_agent_commit present)." >&2
160 exit 1
161 ;;
162 esac
163fi
164exec '{git_path_escaped}' "$@"
165"#
166 );
167
168 let mut file = File::create(&wrapper_path)?;
169 file.write_all(wrapper_content.as_bytes())?;
170
171 #[cfg(unix)]
172 {
173 use std::os::unix::fs::PermissionsExt;
174 let mut perms = fs::metadata(&wrapper_path)?.permissions();
175 perms.set_mode(0o755);
176 fs::set_permissions(&wrapper_path, perms)?;
177 }
178
179 let current_path = env::var("PATH").unwrap_or_default();
181 env::set_var(
182 "PATH",
183 format!("{}:{}", wrapper_dir.path().display(), current_path),
184 );
185
186 fs::create_dir_all(".agent")?;
187 fs::write(
188 WRAPPER_DIR_TRACK_FILE,
189 wrapper_dir.path().display().to_string(),
190 )?;
191
192 helpers.wrapper_dir = Some(wrapper_dir);
193 Ok(())
194}
195
196pub fn disable_git_wrapper(helpers: &mut GitHelpers) {
206 if let Some(wrapper_dir) = helpers.wrapper_dir.take() {
207 let wrapper_dir_path = wrapper_dir.path().to_path_buf();
208 let _ = fs::remove_dir_all(&wrapper_dir_path);
209 if let Ok(path) = env::var("PATH") {
214 let wrapper_str = wrapper_dir_path.to_string_lossy();
215 let new_path: String = path
216 .split(':')
217 .filter(|p| !p.contains(wrapper_str.as_ref()))
218 .collect::<Vec<_>>()
219 .join(":");
220 env::set_var("PATH", new_path);
221 }
222 }
223 let _ = fs::remove_file(WRAPPER_DIR_TRACK_FILE);
224}
225
226pub fn start_agent_phase(helpers: &mut GitHelpers) -> io::Result<()> {
228 File::create(".no_agent_commit")?;
229 install_hooks()?;
230 enable_git_wrapper(helpers)?;
231 Ok(())
232}
233
234pub fn end_agent_phase() {
236 let _ = fs::remove_file(".no_agent_commit");
237}
238
239fn cleanup_git_wrapper_dir_silent() {
240 let wrapper_dir = match fs::read_to_string(WRAPPER_DIR_TRACK_FILE) {
241 Ok(path) => PathBuf::from(path.trim()),
242 Err(_) => return,
243 };
244
245 if !wrapper_dir.as_os_str().is_empty() {
246 let _ = fs::remove_dir_all(&wrapper_dir);
247 }
248 let _ = fs::remove_file(WRAPPER_DIR_TRACK_FILE);
249}
250
251pub fn cleanup_agent_phase_silent() {
253 end_agent_phase();
254 cleanup_git_wrapper_dir_silent();
255 uninstall_hooks_silent();
256 crate::files::cleanup_generated_files();
257}
258
259pub fn cleanup_orphaned_marker(logger: &Logger) -> io::Result<()> {
261 let repo_root = get_repo_root()?;
262 let marker_path = repo_root.join(".no_agent_commit");
263
264 if marker_path.exists() {
265 fs::remove_file(&marker_path)?;
266 logger.success("Removed orphaned .no_agent_commit marker");
267 } else {
268 logger.info("No orphaned marker found");
269 }
270
271 Ok(())
272}