ralph_workflow/git_helpers/repo/discovery/
io.rs1use std::fs;
5use std::path::{Path, PathBuf};
6
7use crate::git_helpers::git2_to_io_error;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ProtectionScope {
11 pub repo_root: PathBuf,
12 pub git_dir: PathBuf,
13 pub common_git_dir: PathBuf,
14 pub hooks_dir: PathBuf,
15 pub ralph_dir: PathBuf,
16 pub is_linked_worktree: bool,
17 pub uses_worktree_scoped_hooks: bool,
18 pub worktree_config_path: Option<PathBuf>,
19}
20
21pub fn resolve_protection_scope() -> std::io::Result<ProtectionScope> {
28 resolve_protection_scope_from(Path::new("."))
29}
30
31fn worktree_config_path_for(
38 uses_worktree_scoped_hooks: bool,
39 is_linked_worktree: bool,
40 git_dir: &Path,
41 common_git_dir: &Path,
42) -> Option<PathBuf> {
43 uses_worktree_scoped_hooks.then(|| {
44 if is_linked_worktree {
45 git_dir.join("config.worktree")
46 } else {
47 common_git_dir.join("config.worktree")
48 }
49 })
50}
51
52fn hooks_dir_for_scope(
53 uses_worktree_scoped_hooks: bool,
54 ralph_dir: &Path,
55 git_dir: &Path,
56) -> PathBuf {
57 if uses_worktree_scoped_hooks {
58 ralph_dir.join("hooks")
59 } else {
60 git_dir.join("hooks")
61 }
62}
63
64fn compute_worktree_flags(
65 repo: &git2::Repository,
66 git_dir: &std::path::Path,
67 common_git_dir: &std::path::Path,
68) -> (bool, bool) {
69 let is_linked_worktree = repo.is_worktree() && git_dir != common_git_dir;
70 let has_linked_worktrees = common_git_dir.join("worktrees").is_dir();
71 (is_linked_worktree, is_linked_worktree || has_linked_worktrees)
72}
73
74fn build_protection_scope(repo: &git2::Repository) -> std::io::Result<ProtectionScope> {
75 let repo_root = repo.workdir().map(PathBuf::from).ok_or_else(|| {
76 std::io::Error::new(std::io::ErrorKind::NotFound, "No workdir for repository")
77 })?;
78 let git_dir = repo.path().to_path_buf();
79 let common_git_dir = common_git_dir(repo);
80 let (is_linked_worktree, uses_worktree_scoped_hooks) =
81 compute_worktree_flags(repo, &git_dir, &common_git_dir);
82 let worktree_config_path =
83 worktree_config_path_for(uses_worktree_scoped_hooks, is_linked_worktree, &git_dir, &common_git_dir);
84 let ralph_dir = git_dir.join("ralph");
85 let hooks_dir = hooks_dir_for_scope(uses_worktree_scoped_hooks, &ralph_dir, &git_dir);
86 Ok(ProtectionScope {
87 repo_root,
88 git_dir,
89 common_git_dir,
90 hooks_dir,
91 ralph_dir,
92 is_linked_worktree,
93 uses_worktree_scoped_hooks,
94 worktree_config_path,
95 })
96}
97
98pub fn resolve_protection_scope_from(discovery_root: &Path) -> std::io::Result<ProtectionScope> {
99 let repo = git2::Repository::discover(discovery_root).map_err(|e| git2_to_io_error(&e))?;
100 build_protection_scope(&repo)
101}
102
103pub fn ralph_git_dir(repo_root: &Path) -> PathBuf {
112 if let Ok(scope) = resolve_protection_scope_from(repo_root) {
113 return scope.ralph_dir;
114 }
115 repo_root.join(".git").join("ralph")
117}
118
119pub fn normalize_protection_scope_path(path: &Path) -> PathBuf {
120 if let Ok(canonical) = fs::canonicalize(path) {
121 return canonical;
122 }
123
124 let existing_ancestor = find_existing_ancestor(path);
125 if existing_ancestor == path {
126 return path.to_path_buf();
127 }
128
129 build_normalized_path(path, &existing_ancestor)
130}
131
132fn find_existing_ancestor(path: &Path) -> PathBuf {
133 path.ancestors()
134 .find(|ancestor| ancestor.exists())
135 .map(PathBuf::from)
136 .unwrap_or_else(|| path.to_path_buf())
137}
138
139fn build_normalized_path(path: &Path, existing_ancestor: &Path) -> PathBuf {
140 let Ok(canonical_ancestor) = fs::canonicalize(existing_ancestor) else {
141 return path.to_path_buf();
142 };
143
144 let suffix = path
145 .strip_prefix(existing_ancestor)
146 .unwrap_or_else(|_| Path::new(""));
147 canonical_ancestor.join(suffix)
148}
149
150fn build_tampered_path(path: &Path, label: &str) -> std::io::Result<PathBuf> {
151 let parent = path.parent().ok_or_else(|| {
152 std::io::Error::new(
153 std::io::ErrorKind::InvalidInput,
154 "path has no parent directory",
155 )
156 })?;
157 let file_name = path.file_name().ok_or_else(|| {
158 std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no file name")
159 })?;
160 let suffix = format!(
161 "ralph.tampered.{label}.{}.{}",
162 std::process::id(),
163 std::time::SystemTime::now()
164 .duration_since(std::time::UNIX_EPOCH)
165 .unwrap_or_default()
166 .as_nanos()
167 );
168 Ok(parent.join(format!("{}.{}", file_name.to_string_lossy(), suffix)))
169}
170
171fn is_empty_dir(path: &Path) -> bool {
172 fs::symlink_metadata(path).ok().is_some_and(|m| m.is_dir())
173 && fs::read_dir(path).ok().is_some_and(|it| it.count() == 0)
174}
175
176pub fn quarantine_path_in_place(path: &Path, label: &str) -> std::io::Result<PathBuf> {
177 let tampered_path = build_tampered_path(path, label)?;
178 match fs::rename(path, &tampered_path) {
179 Ok(()) => Ok(tampered_path),
180 Err(_) if is_empty_dir(path) => {
181 fs::remove_dir(path)?;
182 Ok(path.to_path_buf())
183 }
184 Err(e) => Err(e),
185 }
186}
187
188fn prepare_ralph_git_dir_internal(
189 ralph_dir: &Path,
190 create_if_missing: bool,
191) -> std::io::Result<bool> {
192 match fs::symlink_metadata(ralph_dir) {
193 Ok(meta) => handle_existing_ralph_dir(ralph_dir, &meta, create_if_missing),
194 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
195 if !create_if_missing {
196 return Ok(false);
197 }
198 fs::create_dir_all(ralph_dir)?;
199 verify_created_ralph_dir(ralph_dir)
200 }
201 Err(e) => Err(e),
202 }
203}
204
205fn handle_existing_ralph_dir(
206 ralph_dir: &Path,
207 meta: &fs::Metadata,
208 create_if_missing: bool,
209) -> std::io::Result<bool> {
210 let ft = meta.file_type();
211 if ft.is_symlink() || !meta.is_dir() {
212 quarantine_path_in_place(ralph_dir, "dir")?;
213 if !create_if_missing {
214 return Ok(false);
215 }
216 fs::create_dir_all(ralph_dir)?;
217 verify_created_ralph_dir(ralph_dir)
218 } else {
219 Ok(true)
220 }
221}
222
223fn verify_created_ralph_dir(ralph_dir: &Path) -> std::io::Result<bool> {
224 let meta = fs::symlink_metadata(ralph_dir)?;
225 let ft = meta.file_type();
226 if ft.is_symlink() || !meta.is_dir() {
227 return Err(std::io::Error::new(
228 std::io::ErrorKind::InvalidData,
229 "ralph git dir is not a regular directory",
230 ));
231 }
232 Ok(true)
233}
234
235pub fn ensure_ralph_git_dir(repo_root: &Path) -> std::io::Result<PathBuf> {
236 let ralph_dir = ralph_git_dir(repo_root);
237 prepare_ralph_git_dir_internal(&ralph_dir, true)?;
238 Ok(ralph_dir)
239}
240
241pub fn sanitize_ralph_git_dir_at(ralph_dir: &Path) -> std::io::Result<bool> {
242 prepare_ralph_git_dir_internal(ralph_dir, false)
243}
244
245pub fn require_git_repo() -> std::io::Result<()> {
251 git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
252 Ok(())
253}
254
255pub fn get_repo_root() -> std::io::Result<PathBuf> {
261 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
262 repo.workdir().map(PathBuf::from).ok_or_else(|| {
263 std::io::Error::new(std::io::ErrorKind::NotFound, "No workdir for repository")
264 })
265}
266
267pub fn get_hooks_dir_from(discovery_root: &Path) -> std::io::Result<PathBuf> {
268 Ok(resolve_protection_scope_from(discovery_root)?.hooks_dir)
269}
270
271fn linked_worktree_common_git_dir(git_dir: &Path) -> Option<PathBuf> {
279 let worktrees_dir = git_dir.parent()?;
280 if worktrees_dir.file_name().and_then(|n| n.to_str()) != Some("worktrees") {
281 return None;
282 }
283 worktrees_dir.parent().map(Path::to_path_buf)
284}
285
286fn common_git_dir(repo: &git2::Repository) -> PathBuf {
287 let path = repo.path();
288 if repo.is_worktree() {
289 if let Some(common) = linked_worktree_common_git_dir(path) {
290 return common;
291 }
292 }
293 path.to_path_buf()
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 fn init_repo_with_commit(path: &Path) -> git2::Repository {
302 let repo = git2::Repository::init(path).unwrap();
303 {
304 let mut index = repo.index().unwrap();
305 let tree_oid = index.write_tree().unwrap();
306 let tree = repo.find_tree(tree_oid).unwrap();
307 let sig = git2::Signature::now("test", "test@test.com").unwrap();
308 repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
309 .unwrap();
310 }
311 repo
312 }
313
314 fn canon(path: &Path) -> PathBuf {
315 normalize_protection_scope_path(path)
316 }
317
318 #[test]
319 fn resolve_protection_scope_for_regular_repo_uses_main_git_dir_for_all_paths() {
320 let tmp = tempfile::tempdir().unwrap();
321 let repo = git2::Repository::init(tmp.path()).unwrap();
322
323 let scope = resolve_protection_scope_from(tmp.path()).unwrap();
324
325 assert!(!scope.is_linked_worktree);
326 assert_eq!(canon(&scope.git_dir), canon(repo.path()));
327 assert_eq!(canon(&scope.common_git_dir), canon(repo.path()));
328 assert_eq!(
329 canon(&scope.hooks_dir),
330 canon(&tmp.path().join(".git/hooks"))
331 );
332 assert_eq!(
333 canon(&scope.ralph_dir),
334 canon(&tmp.path().join(".git/ralph"))
335 );
336 assert!(!scope.uses_worktree_scoped_hooks);
337 assert_eq!(scope.worktree_config_path, None);
338 }
339
340 #[test]
341 fn resolve_protection_scope_for_linked_worktree_keeps_common_and_active_git_dirs_distinct() {
342 let tmp = tempfile::tempdir().unwrap();
343 let main_repo = init_repo_with_commit(tmp.path());
344 let wt_path = tmp.path().join("wt-test");
345 let _wt = main_repo.worktree("wt-test", &wt_path, None).unwrap();
346 let wt_repo = git2::Repository::open(&wt_path).unwrap();
347
348 let scope = resolve_protection_scope_from(&wt_path).unwrap();
349
350 assert!(scope.is_linked_worktree);
351 assert!(scope.uses_worktree_scoped_hooks);
352 assert_eq!(canon(&scope.git_dir), canon(wt_repo.path()));
353 assert_eq!(canon(&scope.common_git_dir), canon(main_repo.path()));
354 assert_ne!(canon(&scope.git_dir), canon(&scope.common_git_dir));
355 assert_eq!(
356 scope.worktree_config_path.as_deref().map(canon),
357 Some(canon(&wt_repo.path().join("config.worktree")))
358 );
359 }
360
361 #[test]
362 fn resolve_protection_scope_for_linked_worktree_uses_worktree_local_hook_and_ralph_dirs() {
363 let tmp = tempfile::tempdir().unwrap();
364 let main_repo = init_repo_with_commit(tmp.path());
365 let wt_path = tmp.path().join("wt-test");
366 let _wt = main_repo.worktree("wt-test", &wt_path, None).unwrap();
367 let wt_repo = git2::Repository::open(&wt_path).unwrap();
368
369 let scope = resolve_protection_scope_from(&wt_path).unwrap();
370
371 assert_eq!(
372 canon(&scope.hooks_dir),
373 canon(&wt_repo.path().join("ralph/hooks"))
374 );
375 assert_eq!(
376 canon(&scope.ralph_dir),
377 canon(&wt_repo.path().join("ralph"))
378 );
379 assert_ne!(
380 canon(&scope.hooks_dir),
381 canon(&tmp.path().join(".git/hooks"))
382 );
383 assert_ne!(
384 canon(&scope.ralph_dir),
385 canon(&tmp.path().join(".git/ralph"))
386 );
387 }
388
389 #[test]
390 fn resolve_protection_scope_for_main_worktree_with_linked_siblings_uses_main_worktree_config() {
391 let tmp = tempfile::tempdir().unwrap();
392 let main_repo = init_repo_with_commit(tmp.path());
393 let wt_path = tmp.path().join("wt-test");
394 let _wt = main_repo.worktree("wt-test", &wt_path, None).unwrap();
395
396 let scope = resolve_protection_scope_from(tmp.path()).unwrap();
397
398 assert!(!scope.is_linked_worktree);
399 assert!(scope.uses_worktree_scoped_hooks);
400 assert_eq!(canon(&scope.git_dir), canon(&scope.common_git_dir));
401 assert_eq!(
402 canon(&scope.hooks_dir),
403 canon(&tmp.path().join(".git/ralph/hooks"))
404 );
405 assert_eq!(
406 scope.worktree_config_path.as_deref().map(canon),
407 Some(canon(&tmp.path().join(".git/config.worktree")))
408 );
409 }
410
411 #[cfg(unix)]
412 #[test]
413 fn normalize_protection_scope_path_collapses_symlink_aliases_for_scope_comparison() {
414 use std::os::unix::fs::symlink;
415
416 let tmp = tempfile::tempdir().unwrap();
417 let repo_path = tmp.path().join("repo");
418 fs::create_dir_all(&repo_path).unwrap();
419
420 let alias_parent = tmp.path().join("aliases");
421 fs::create_dir_all(&alias_parent).unwrap();
422 let alias_path = alias_parent.join("repo-link");
423 symlink(&repo_path, &alias_path).unwrap();
424
425 assert_eq!(
426 normalize_protection_scope_path(&repo_path),
427 normalize_protection_scope_path(&alias_path),
428 "scope comparison should treat symlink aliases as the same repository path"
429 );
430
431 let real_git_dir = repo_path.join(".git");
432 let alias_git_dir = alias_path.join(".git");
433 assert_eq!(
434 normalize_protection_scope_path(&real_git_dir),
435 normalize_protection_scope_path(&alias_git_dir),
436 "scope comparison should normalize git-dir aliases too"
437 );
438 }
439}