Skip to main content

ralph_workflow/git_helpers/repo/discovery/
io.rs

1// git_helpers/repo/discovery/io.rs — boundary module for git repository discovery and protection scope.
2// File stem is `io` — recognized as boundary module by forbid_io_effects lint.
3
4use 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
21/// Resolve the active git-protection scope for the current repository context.
22///
23/// # Errors
24///
25/// Returns an error when the current directory is not inside a git worktree or
26/// when the repository workdir cannot be determined.
27pub fn resolve_protection_scope() -> std::io::Result<ProtectionScope> {
28    resolve_protection_scope_from(Path::new("."))
29}
30
31/// Resolve the active git-protection scope for an explicit discovery root.
32///
33/// # Errors
34///
35/// Returns an error when `discovery_root` is not inside a git worktree or when
36/// the repository workdir cannot be determined.
37fn 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
103/// Returns the path to the `ralph` subdirectory inside the git metadata directory.
104///
105/// This directory holds Ralph's runtime enforcement state (marker, track file, head-oid).
106/// It is inside the active git dir for the current repository context and is therefore
107/// invisible to working-tree scans.
108///
109/// Falls back to `repo_root/.git/ralph` if libgit2 discovery fails (e.g., plain temp
110/// directories used in unit tests).
111pub 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    // Fallback: assume standard .git directory layout.
116    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
245/// Check if we're in a git repository.
246///
247/// # Errors
248///
249/// Returns error if the operation fails.
250pub fn require_git_repo() -> std::io::Result<()> {
251    git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
252    Ok(())
253}
254
255/// Get the git repository root.
256///
257/// # Errors
258///
259/// Returns error if the operation fails.
260pub 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
271/// Returns the common git directory for a repository.
272///
273/// For main worktrees, this is the same as `repo.path()`.
274/// For linked worktrees, this navigates from `.git/worktrees/<name>/`
275/// up to the shared `.git/` directory.
276///
277/// This is needed because git2 0.18 does not expose `Repository::commondir()`.
278fn 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    /// Helper: create a git repo with an initial commit (required for worktree creation).
301    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}