Skip to main content

edda_core/
git.rs

1use std::path::{Path, PathBuf};
2
3/// Resolve the git repository root, handling worktrees.
4///
5/// Walks up from `start` looking for `.git`:
6/// - **Directory** → parent is the repo root (normal repo).
7/// - **File** with `gitdir: .../worktrees/{name}` → strip to find the common
8///   `.git` directory, then return its parent (the main working tree root).
9/// - **File** without `/worktrees/` (e.g. submodule) → return that directory.
10/// - **Not found** → returns `None` (non-git directory; caller uses original path).
11pub fn resolve_git_root(start: &Path) -> Option<PathBuf> {
12    let abs = start.canonicalize().unwrap_or_else(|_| start.to_path_buf());
13    let mut cur = abs.as_path();
14    loop {
15        let dot_git = cur.join(".git");
16        if dot_git.is_dir() {
17            return Some(cur.to_path_buf());
18        }
19        if dot_git.is_file() {
20            if let Ok(content) = std::fs::read_to_string(&dot_git) {
21                let content = content.trim();
22                if let Some(gitdir) = content.strip_prefix("gitdir:") {
23                    let gitdir = gitdir.trim().replace('\\', "/");
24                    if let Some(pos) = gitdir.find("/worktrees/") {
25                        // Worktree: gitdir points to .git/worktrees/{name}
26                        // Strip /worktrees/{name} to get the common .git dir,
27                        // then take its parent as the repo root.
28                        let common_git = &gitdir[..pos];
29                        return Path::new(common_git).parent().map(|p| p.to_path_buf());
30                    }
31                }
32            }
33            // .git file but not a worktree (e.g. submodule) → use this dir
34            return Some(cur.to_path_buf());
35        }
36        cur = cur.parent()?;
37    }
38}
39
40#[cfg(test)]
41mod tests {
42    use super::*;
43
44    #[test]
45    fn resolve_git_root_normal_repo() {
46        let tmp = tempfile::tempdir().unwrap();
47        let repo = tmp.path().join("my-repo");
48        std::fs::create_dir_all(repo.join(".git")).unwrap();
49
50        let result = resolve_git_root(&repo);
51        assert_eq!(result.unwrap(), repo.canonicalize().unwrap());
52    }
53
54    #[test]
55    fn resolve_git_root_worktree() {
56        let tmp = tempfile::tempdir().unwrap();
57        let repo = tmp.path().join("repo");
58        std::fs::create_dir_all(repo.join(".git").join("worktrees").join("feat-x")).unwrap();
59
60        let wt = repo.join(".claude").join("worktrees").join("feat-x");
61        std::fs::create_dir_all(&wt).unwrap();
62
63        let gitdir = repo.join(".git").join("worktrees").join("feat-x");
64        let gitdir_str = gitdir
65            .canonicalize()
66            .unwrap()
67            .to_string_lossy()
68            .replace('\\', "/");
69        std::fs::write(wt.join(".git"), format!("gitdir: {gitdir_str}")).unwrap();
70
71        let resolved = resolve_git_root(&wt).unwrap();
72        let repo_canon = repo.canonicalize().unwrap();
73        let norm = |p: &Path| p.to_string_lossy().replace('\\', "/").to_lowercase();
74        assert_eq!(
75            norm(&resolved),
76            norm(&repo_canon),
77            "worktree should resolve to repo root"
78        );
79    }
80
81    #[test]
82    fn resolve_git_root_submodule() {
83        let tmp = tempfile::tempdir().unwrap();
84        let sub = tmp.path().join("parent").join("submodule");
85        std::fs::create_dir_all(&sub).unwrap();
86        std::fs::write(sub.join(".git"), "gitdir: ../../.git/modules/submodule").unwrap();
87
88        let resolved = resolve_git_root(&sub).unwrap();
89        assert_eq!(resolved, sub.canonicalize().unwrap());
90    }
91
92    #[test]
93    fn resolve_git_root_non_git_returns_none() {
94        let tmp = tempfile::tempdir().unwrap();
95        let dir = tmp.path().join("not-a-repo");
96        std::fs::create_dir_all(&dir).unwrap();
97
98        assert!(resolve_git_root(&dir).is_none());
99    }
100}