Skip to main content

ralph_workflow/git_helpers/repo/
discovery.rs

1use 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
19/// Resolve the active git-protection scope for the current repository context.
20///
21/// # Errors
22///
23/// Returns an error when the current directory is not inside a git worktree or
24/// when the repository workdir cannot be determined.
25pub fn resolve_protection_scope() -> io::Result<ProtectionScope> {
26    resolve_protection_scope_from(Path::new("."))
27}
28
29/// Resolve the active git-protection scope for an explicit discovery root.
30///
31/// # Errors
32///
33/// Returns an error when `discovery_root` is not inside a git worktree or when
34/// the repository workdir cannot be determined.
35pub 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
72/// Returns the path to the `ralph` subdirectory inside the git metadata directory.
73///
74/// This directory holds Ralph's runtime enforcement state (marker, track file, head-oid).
75/// It is inside the active git dir for the current repository context and is therefore
76/// invisible to working-tree scans.
77///
78/// Falls back to `repo_root/.git/ralph` if libgit2 discovery fails (e.g., plain temp
79/// directories used in unit tests).
80pub 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    // Fallback: assume standard .git directory layout.
85    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
191/// Check if we're in a git repository.
192///
193/// # Errors
194///
195/// Returns error if the operation fails.
196pub fn require_git_repo() -> io::Result<()> {
197    git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
198    Ok(())
199}
200
201/// Get the git repository root.
202///
203/// # Errors
204///
205/// Returns error if the operation fails.
206pub 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
217/// Returns the common git directory for a repository.
218///
219/// For main worktrees, this is the same as `repo.path()`.
220/// For linked worktrees, this navigates from `.git/worktrees/<name>/`
221/// up to the shared `.git/` directory.
222///
223/// This is needed because git2 0.18 does not expose `Repository::commondir()`.
224fn common_git_dir(repo: &git2::Repository) -> PathBuf {
225    let path = repo.path();
226    if repo.is_worktree() {
227        // For linked worktrees, path() returns .git/worktrees/<name>/
228        // Common dir is the grandparent: .git/
229        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    /// Helper: create a git repo with an initial commit (required for worktree creation).
245    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}