ralph_workflow/git_helpers/repo/
discovery.rs1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use crate::git_helpers::git2_to_io_error;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct ProtectionScope {
9 pub repo_root: PathBuf,
10 pub git_dir: PathBuf,
11 pub common_git_dir: PathBuf,
12 pub hooks_dir: PathBuf,
13 pub ralph_dir: PathBuf,
14 pub is_linked_worktree: bool,
15 pub uses_worktree_scoped_hooks: bool,
16 pub worktree_config_path: Option<PathBuf>,
17}
18
19pub fn resolve_protection_scope() -> io::Result<ProtectionScope> {
26 resolve_protection_scope_from(Path::new("."))
27}
28
29pub fn resolve_protection_scope_from(discovery_root: &Path) -> io::Result<ProtectionScope> {
36 let repo = git2::Repository::discover(discovery_root).map_err(|e| git2_to_io_error(&e))?;
37 let repo_root = repo
38 .workdir()
39 .map(PathBuf::from)
40 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))?;
41 let git_dir = repo.path().to_path_buf();
42 let common_git_dir = common_git_dir(&repo);
43 let is_linked_worktree = repo.is_worktree() && git_dir != common_git_dir;
44 let has_linked_worktrees = common_git_dir.join("worktrees").is_dir();
45 let uses_worktree_scoped_hooks = is_linked_worktree || has_linked_worktrees;
46 let worktree_config_path = uses_worktree_scoped_hooks.then(|| {
47 if is_linked_worktree {
48 git_dir.join("config.worktree")
49 } else {
50 common_git_dir.join("config.worktree")
51 }
52 });
53 let ralph_dir = git_dir.join("ralph");
54 let hooks_dir = if uses_worktree_scoped_hooks {
55 ralph_dir.join("hooks")
56 } else {
57 git_dir.join("hooks")
58 };
59
60 Ok(ProtectionScope {
61 repo_root,
62 git_dir,
63 common_git_dir,
64 hooks_dir,
65 ralph_dir,
66 is_linked_worktree,
67 uses_worktree_scoped_hooks,
68 worktree_config_path,
69 })
70}
71
72pub fn ralph_git_dir(repo_root: &Path) -> PathBuf {
81 if let Ok(scope) = resolve_protection_scope_from(repo_root) {
82 return scope.ralph_dir;
83 }
84 repo_root.join(".git").join("ralph")
86}
87
88pub fn normalize_protection_scope_path(path: &Path) -> PathBuf {
89 if let Ok(canonical) = fs::canonicalize(path) {
90 return canonical;
91 }
92
93 let mut existing_ancestor = path;
94 while !existing_ancestor.exists() {
95 let Some(parent) = existing_ancestor.parent() else {
96 return path.to_path_buf();
97 };
98 existing_ancestor = parent;
99 }
100
101 let Ok(canonical_ancestor) = fs::canonicalize(existing_ancestor) else {
102 return path.to_path_buf();
103 };
104
105 let suffix = path
106 .strip_prefix(existing_ancestor)
107 .unwrap_or_else(|_| Path::new(""));
108 canonical_ancestor.join(suffix)
109}
110
111pub fn quarantine_path_in_place(path: &Path, label: &str) -> io::Result<PathBuf> {
112 let parent = path.parent().ok_or_else(|| {
113 io::Error::new(io::ErrorKind::InvalidInput, "path has no parent directory")
114 })?;
115 let file_name = path
116 .file_name()
117 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))?;
118
119 let suffix = format!(
120 "ralph.tampered.{label}.{}.{}",
121 std::process::id(),
122 std::time::SystemTime::now()
123 .duration_since(std::time::UNIX_EPOCH)
124 .unwrap_or_default()
125 .as_nanos()
126 );
127 let tampered_name = format!("{}.{}", file_name.to_string_lossy(), suffix);
128 let tampered_path = parent.join(tampered_name);
129
130 match fs::rename(path, &tampered_path) {
131 Ok(()) => Ok(tampered_path),
132 Err(e) => {
133 let is_empty_dir = fs::symlink_metadata(path).ok().is_some_and(|m| m.is_dir())
134 && fs::read_dir(path)
135 .ok()
136 .is_some_and(|mut it| it.next().is_none());
137 if is_empty_dir {
138 fs::remove_dir(path)?;
139 Ok(path.to_path_buf())
140 } else {
141 Err(e)
142 }
143 }
144 }
145}
146
147fn prepare_ralph_git_dir_internal(ralph_dir: &Path, create_if_missing: bool) -> io::Result<bool> {
148 match fs::symlink_metadata(ralph_dir) {
149 Ok(meta) => {
150 let ft = meta.file_type();
151 if ft.is_symlink() || !meta.is_dir() {
152 quarantine_path_in_place(ralph_dir, "dir")?;
153 if !create_if_missing {
154 return Ok(false);
155 }
156 } else {
157 return Ok(true);
158 }
159 }
160 Err(e) if e.kind() == io::ErrorKind::NotFound => {
161 if !create_if_missing {
162 return Ok(false);
163 }
164 }
165 Err(e) => return Err(e),
166 }
167
168 fs::create_dir_all(ralph_dir)?;
169 let meta = fs::symlink_metadata(ralph_dir)?;
170 let ft = meta.file_type();
171 if ft.is_symlink() || !meta.is_dir() {
172 return Err(io::Error::new(
173 io::ErrorKind::InvalidData,
174 "ralph git dir is not a regular directory",
175 ));
176 }
177
178 Ok(true)
179}
180
181pub fn ensure_ralph_git_dir(repo_root: &Path) -> io::Result<PathBuf> {
182 let ralph_dir = ralph_git_dir(repo_root);
183 prepare_ralph_git_dir_internal(&ralph_dir, true)?;
184 Ok(ralph_dir)
185}
186
187pub fn sanitize_ralph_git_dir_at(ralph_dir: &Path) -> io::Result<bool> {
188 prepare_ralph_git_dir_internal(ralph_dir, false)
189}
190
191pub fn require_git_repo() -> io::Result<()> {
197 git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
198 Ok(())
199}
200
201pub fn get_repo_root() -> io::Result<PathBuf> {
207 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
208 repo.workdir()
209 .map(PathBuf::from)
210 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))
211}
212
213pub fn get_hooks_dir_from(discovery_root: &Path) -> io::Result<PathBuf> {
214 Ok(resolve_protection_scope_from(discovery_root)?.hooks_dir)
215}
216
217fn common_git_dir(repo: &git2::Repository) -> PathBuf {
225 let path = repo.path();
226 if repo.is_worktree() {
227 if let Some(worktrees_dir) = path.parent() {
230 if worktrees_dir.file_name().and_then(|n| n.to_str()) == Some("worktrees") {
231 if let Some(common) = worktrees_dir.parent() {
232 return common.to_path_buf();
233 }
234 }
235 }
236 }
237 path.to_path_buf()
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 fn init_repo_with_commit(path: &Path) -> git2::Repository {
246 let repo = git2::Repository::init(path).unwrap();
247 {
248 let mut index = repo.index().unwrap();
249 let tree_oid = index.write_tree().unwrap();
250 let tree = repo.find_tree(tree_oid).unwrap();
251 let sig = git2::Signature::now("test", "test@test.com").unwrap();
252 repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
253 .unwrap();
254 }
255 repo
256 }
257
258 fn canon(path: &Path) -> PathBuf {
259 normalize_protection_scope_path(path)
260 }
261
262 #[test]
263 fn resolve_protection_scope_for_regular_repo_uses_main_git_dir_for_all_paths() {
264 let tmp = tempfile::tempdir().unwrap();
265 let repo = git2::Repository::init(tmp.path()).unwrap();
266
267 let scope = resolve_protection_scope_from(tmp.path()).unwrap();
268
269 assert!(!scope.is_linked_worktree);
270 assert_eq!(canon(&scope.git_dir), canon(repo.path()));
271 assert_eq!(canon(&scope.common_git_dir), canon(repo.path()));
272 assert_eq!(
273 canon(&scope.hooks_dir),
274 canon(&tmp.path().join(".git/hooks"))
275 );
276 assert_eq!(
277 canon(&scope.ralph_dir),
278 canon(&tmp.path().join(".git/ralph"))
279 );
280 assert!(!scope.uses_worktree_scoped_hooks);
281 assert_eq!(scope.worktree_config_path, None);
282 }
283
284 #[test]
285 fn resolve_protection_scope_for_linked_worktree_keeps_common_and_active_git_dirs_distinct() {
286 let tmp = tempfile::tempdir().unwrap();
287 let main_repo = init_repo_with_commit(tmp.path());
288 let wt_path = tmp.path().join("wt-test");
289 let _wt = main_repo.worktree("wt-test", &wt_path, None).unwrap();
290 let wt_repo = git2::Repository::open(&wt_path).unwrap();
291
292 let scope = resolve_protection_scope_from(&wt_path).unwrap();
293
294 assert!(scope.is_linked_worktree);
295 assert!(scope.uses_worktree_scoped_hooks);
296 assert_eq!(canon(&scope.git_dir), canon(wt_repo.path()));
297 assert_eq!(canon(&scope.common_git_dir), canon(main_repo.path()));
298 assert_ne!(canon(&scope.git_dir), canon(&scope.common_git_dir));
299 assert_eq!(
300 scope.worktree_config_path.as_deref().map(canon),
301 Some(canon(&wt_repo.path().join("config.worktree")))
302 );
303 }
304
305 #[test]
306 fn resolve_protection_scope_for_linked_worktree_uses_worktree_local_hook_and_ralph_dirs() {
307 let tmp = tempfile::tempdir().unwrap();
308 let main_repo = init_repo_with_commit(tmp.path());
309 let wt_path = tmp.path().join("wt-test");
310 let _wt = main_repo.worktree("wt-test", &wt_path, None).unwrap();
311 let wt_repo = git2::Repository::open(&wt_path).unwrap();
312
313 let scope = resolve_protection_scope_from(&wt_path).unwrap();
314
315 assert_eq!(
316 canon(&scope.hooks_dir),
317 canon(&wt_repo.path().join("ralph/hooks"))
318 );
319 assert_eq!(
320 canon(&scope.ralph_dir),
321 canon(&wt_repo.path().join("ralph"))
322 );
323 assert_ne!(
324 canon(&scope.hooks_dir),
325 canon(&tmp.path().join(".git/hooks"))
326 );
327 assert_ne!(
328 canon(&scope.ralph_dir),
329 canon(&tmp.path().join(".git/ralph"))
330 );
331 }
332
333 #[test]
334 fn resolve_protection_scope_for_main_worktree_with_linked_siblings_uses_main_worktree_config() {
335 let tmp = tempfile::tempdir().unwrap();
336 let main_repo = init_repo_with_commit(tmp.path());
337 let wt_path = tmp.path().join("wt-test");
338 let _wt = main_repo.worktree("wt-test", &wt_path, None).unwrap();
339
340 let scope = resolve_protection_scope_from(tmp.path()).unwrap();
341
342 assert!(!scope.is_linked_worktree);
343 assert!(scope.uses_worktree_scoped_hooks);
344 assert_eq!(canon(&scope.git_dir), canon(&scope.common_git_dir));
345 assert_eq!(
346 canon(&scope.hooks_dir),
347 canon(&tmp.path().join(".git/ralph/hooks"))
348 );
349 assert_eq!(
350 scope.worktree_config_path.as_deref().map(canon),
351 Some(canon(&tmp.path().join(".git/config.worktree")))
352 );
353 }
354
355 #[cfg(unix)]
356 #[test]
357 fn normalize_protection_scope_path_collapses_symlink_aliases_for_scope_comparison() {
358 use std::os::unix::fs::symlink;
359
360 let tmp = tempfile::tempdir().unwrap();
361 let repo_path = tmp.path().join("repo");
362 fs::create_dir_all(&repo_path).unwrap();
363
364 let alias_parent = tmp.path().join("aliases");
365 fs::create_dir_all(&alias_parent).unwrap();
366 let alias_path = alias_parent.join("repo-link");
367 symlink(&repo_path, &alias_path).unwrap();
368
369 assert_eq!(
370 normalize_protection_scope_path(&repo_path),
371 normalize_protection_scope_path(&alias_path),
372 "scope comparison should treat symlink aliases as the same repository path"
373 );
374
375 let real_git_dir = repo_path.join(".git");
376 let alias_git_dir = alias_path.join(".git");
377 assert_eq!(
378 normalize_protection_scope_path(&real_git_dir),
379 normalize_protection_scope_path(&alias_git_dir),
380 "scope comparison should normalize git-dir aliases too"
381 );
382 }
383}