use super::path_utils::{canonicalize_path, is_ignored_dir};
use super::types::RepoId;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[must_use]
pub fn detect_repos_under(index_root: &Path) -> HashMap<PathBuf, RepoId> {
let mut repos = HashMap::new();
log::debug!(
"Scanning for git repositories under '{}'",
index_root.display()
);
let mut iter = WalkDir::new(index_root)
.follow_links(false) .into_iter();
while let Some(result) = iter.next() {
let entry = match result {
Ok(e) => e,
Err(err) => {
log::warn!("Error walking directory: {err}");
continue;
}
};
let name = entry.file_name();
if name == ".git" {
let file_type = entry.file_type();
if file_type.is_dir() {
iter.skip_current_dir();
process_git_dir(entry.path(), &mut repos);
} else if file_type.is_file() {
process_git_file(entry.path(), &mut repos);
}
} else if entry.file_type().is_dir() && is_ignored_dir(name) {
iter.skip_current_dir();
}
}
log::debug!(
"Found {} git repositor{} under '{}'",
repos.len(),
if repos.len() == 1 { "y" } else { "ies" },
index_root.display()
);
repos
}
fn process_git_dir(git_dir: &Path, repos: &mut HashMap<PathBuf, RepoId>) {
let Some(git_root) = git_dir.parent() else {
log::warn!("Found .git at filesystem root, skipping");
return;
};
match canonicalize_path(git_root) {
Ok(canonical) => {
let repo_id = RepoId::from_git_root(&canonical);
log::trace!(
"Detected repository: {} -> {}",
canonical.display(),
repo_id
);
repos.insert(canonical, repo_id);
}
Err(err) => {
log::warn!(
"Cannot canonicalize git root {}: {}",
git_root.display(),
err
);
}
}
}
fn process_git_file(git_file: &Path, repos: &mut HashMap<PathBuf, RepoId>) {
let Some(git_root) = git_file.parent() else {
log::warn!("Found .git file at filesystem root, skipping");
return;
};
let content = match std::fs::read_to_string(git_file) {
Ok(c) => c,
Err(err) => {
log::warn!("Cannot read .git file {}: {}", git_file.display(), err);
return;
}
};
let content = content.trim();
let Some(gitdir_value) = content.strip_prefix("gitdir:") else {
log::debug!(
"Ignoring .git file without gitdir reference: {}",
git_file.display()
);
return;
};
let gitdir_path_str = gitdir_value.trim();
if gitdir_path_str.is_empty() {
log::warn!("Empty gitdir reference in {}", git_file.display());
return;
}
let gitdir_path = Path::new(gitdir_path_str);
let resolved_gitdir = if gitdir_path.is_absolute() {
gitdir_path.to_path_buf()
} else {
git_root.join(gitdir_path)
};
if !resolved_gitdir.exists() {
log::warn!(
"Stale .git file {}: gitdir reference '{}' does not exist",
git_file.display(),
resolved_gitdir.display()
);
return;
}
let head_file = resolved_gitdir.join("HEAD");
if !head_file.exists() {
log::warn!(
"Invalid gitdir reference in {}: '{}' missing HEAD file",
git_file.display(),
resolved_gitdir.display()
);
return;
}
match canonicalize_path(git_root) {
Ok(canonical) => {
let repo_id = RepoId::from_git_root(&canonical);
log::trace!(
"Detected submodule/worktree: {} -> {} (gitdir: {})",
canonical.display(),
repo_id,
resolved_gitdir.display()
);
repos.insert(canonical, repo_id);
}
Err(err) => {
log::warn!(
"Cannot canonicalize submodule root {}: {}",
git_root.display(),
err
);
}
}
}
#[must_use]
pub fn lookup_repo_id<S: std::hash::BuildHasher>(
file_path: &Path,
repos: &HashMap<PathBuf, RepoId, S>,
) -> RepoId {
let mut current = file_path;
loop {
if let Some(&repo_id) = repos.get(current) {
return repo_id;
}
match current.parent() {
Some(parent) => current = parent,
None => break, }
}
RepoId::NONE
}
#[must_use]
pub fn lookup_git_root<'a, S: std::hash::BuildHasher>(
file_path: &Path,
repos: &'a HashMap<PathBuf, RepoId, S>,
) -> Option<&'a PathBuf> {
let mut current = file_path;
loop {
if let Some((key, _value)) = repos.get_key_value(current) {
return Some(key);
}
match current.parent() {
Some(parent) => current = parent,
None => break,
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_git_repo(path: &Path) {
std::fs::create_dir_all(path).unwrap();
let git_dir = path.join(".git");
std::fs::create_dir(&git_dir).unwrap();
std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap();
}
fn create_git_submodule(path: &Path, gitdir_path: &str) {
std::fs::create_dir_all(path).unwrap();
std::fs::write(path.join(".git"), format!("gitdir: {gitdir_path}\n")).unwrap();
let gitdir_target = if Path::new(gitdir_path).is_absolute() {
PathBuf::from(gitdir_path)
} else {
path.join(gitdir_path)
};
std::fs::create_dir_all(&gitdir_target).unwrap();
std::fs::write(gitdir_target.join("HEAD"), "ref: refs/heads/main\n").unwrap();
}
fn create_stale_git_file(path: &Path, gitdir_path: &str) {
std::fs::create_dir_all(path).unwrap();
std::fs::write(path.join(".git"), format!("gitdir: {gitdir_path}\n")).unwrap();
}
#[test]
fn test_detect_single_repo() {
let temp = TempDir::new().unwrap();
let repo_root = temp.path().join("myrepo");
create_git_repo(&repo_root);
let repos = detect_repos_under(temp.path());
assert_eq!(repos.len(), 1);
let canonical_root = canonicalize_path(&repo_root).unwrap();
assert!(repos.contains_key(&canonical_root));
let repo_id = repos.get(&canonical_root).unwrap();
assert!(repo_id.is_some());
}
#[test]
fn test_detect_multiple_repos() {
let temp = TempDir::new().unwrap();
create_git_repo(&temp.path().join("repo1"));
create_git_repo(&temp.path().join("repo2"));
create_git_repo(&temp.path().join("subdir/repo3"));
let repos = detect_repos_under(temp.path());
assert_eq!(repos.len(), 3);
}
#[test]
fn test_detect_nested_repos() {
let temp = TempDir::new().unwrap();
let outer = temp.path().join("outer");
create_git_repo(&outer);
let inner = outer.join("packages/inner");
create_git_repo(&inner);
let repos = detect_repos_under(temp.path());
assert_eq!(repos.len(), 2);
let outer_canonical = canonicalize_path(&outer).unwrap();
let inner_canonical = canonicalize_path(&inner).unwrap();
assert!(repos.contains_key(&outer_canonical));
assert!(repos.contains_key(&inner_canonical));
let outer_id = repos.get(&outer_canonical).unwrap();
let inner_id = repos.get(&inner_canonical).unwrap();
assert_ne!(outer_id, inner_id);
}
#[test]
fn test_detect_submodule() {
let temp = TempDir::new().unwrap();
let main_repo = temp.path().join("main");
create_git_repo(&main_repo);
let submodule = main_repo.join("deps/lib");
create_git_submodule(&submodule, "../../.git/modules/deps/lib");
let repos = detect_repos_under(temp.path());
assert_eq!(repos.len(), 2);
let main_canonical = canonicalize_path(&main_repo).unwrap();
let submodule_canonical = canonicalize_path(&submodule).unwrap();
assert!(repos.contains_key(&main_canonical));
assert!(repos.contains_key(&submodule_canonical));
}
#[test]
fn test_detect_skips_ignored_dirs() {
let temp = TempDir::new().unwrap();
let root = temp.path().join("project");
create_git_repo(&root);
let node_modules_repo = root.join("node_modules/some-package");
create_git_repo(&node_modules_repo);
let target_repo = root.join("target/debug/some-crate");
create_git_repo(&target_repo);
let repos = detect_repos_under(temp.path());
assert_eq!(repos.len(), 1);
let root_canonical = canonicalize_path(&root).unwrap();
assert!(repos.contains_key(&root_canonical));
}
#[test]
fn test_detect_no_repos() {
let temp = TempDir::new().unwrap();
std::fs::create_dir(temp.path().join("src")).unwrap();
std::fs::create_dir(temp.path().join("lib")).unwrap();
let repos = detect_repos_under(temp.path());
assert!(repos.is_empty());
}
#[test]
fn test_detect_repo_at_root() {
let temp = TempDir::new().unwrap();
std::fs::create_dir(temp.path().join(".git")).unwrap();
let repos = detect_repos_under(temp.path());
assert_eq!(repos.len(), 1);
let root_canonical = canonicalize_path(temp.path()).unwrap();
assert!(repos.contains_key(&root_canonical));
}
#[test]
fn test_lookup_repo_id_simple() {
let temp = TempDir::new().unwrap();
let repo_root = temp.path().join("repo");
create_git_repo(&repo_root);
let src_dir = repo_root.join("src");
std::fs::create_dir(&src_dir).unwrap();
let file = src_dir.join("main.rs");
std::fs::write(&file, "fn main() {}").unwrap();
let repos = detect_repos_under(temp.path());
let file_canonical = canonicalize_path(&file).unwrap();
let repo_id = lookup_repo_id(&file_canonical, &repos);
assert!(repo_id.is_some());
let repo_canonical = canonicalize_path(&repo_root).unwrap();
assert_eq!(repo_id, *repos.get(&repo_canonical).unwrap());
}
#[test]
fn test_lookup_repo_id_nested_nearest_wins() {
let temp = TempDir::new().unwrap();
let outer = temp.path().join("outer");
create_git_repo(&outer);
let inner = outer.join("packages/inner");
create_git_repo(&inner);
let file = inner.join("src/lib.rs");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(&file, "").unwrap();
let repos = detect_repos_under(temp.path());
let file_canonical = canonicalize_path(&file).unwrap();
let repo_id = lookup_repo_id(&file_canonical, &repos);
let inner_canonical = canonicalize_path(&inner).unwrap();
assert_eq!(repo_id, *repos.get(&inner_canonical).unwrap());
let outer_canonical = canonicalize_path(&outer).unwrap();
assert_ne!(repo_id, *repos.get(&outer_canonical).unwrap());
}
#[test]
fn test_lookup_repo_id_file_in_outer_repo() {
let temp = TempDir::new().unwrap();
let outer = temp.path().join("outer");
create_git_repo(&outer);
let inner = outer.join("packages/inner");
create_git_repo(&inner);
let file = outer.join("src/main.rs");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(&file, "").unwrap();
let repos = detect_repos_under(temp.path());
let file_canonical = canonicalize_path(&file).unwrap();
let repo_id = lookup_repo_id(&file_canonical, &repos);
let outer_canonical = canonicalize_path(&outer).unwrap();
assert_eq!(repo_id, *repos.get(&outer_canonical).unwrap());
}
#[test]
fn test_lookup_repo_id_no_repo() {
let temp = TempDir::new().unwrap();
let file = temp.path().join("loose/file.rs");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(&file, "").unwrap();
let repos = HashMap::new();
let file_canonical = canonicalize_path(&file).unwrap();
let repo_id = lookup_repo_id(&file_canonical, &repos);
assert!(repo_id.is_none());
assert_eq!(repo_id, RepoId::NONE);
}
#[test]
fn test_lookup_git_root() {
let temp = TempDir::new().unwrap();
let repo_root = temp.path().join("repo");
create_git_repo(&repo_root);
let file = repo_root.join("src/main.rs");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(&file, "").unwrap();
let repos = detect_repos_under(temp.path());
let file_canonical = canonicalize_path(&file).unwrap();
let git_root = lookup_git_root(&file_canonical, &repos);
assert!(git_root.is_some());
let expected = canonicalize_path(&repo_root).unwrap();
assert_eq!(git_root.unwrap(), &expected);
}
#[test]
fn test_lookup_git_root_none() {
let temp = TempDir::new().unwrap();
let file = temp.path().join("file.rs");
std::fs::write(&file, "").unwrap();
let repos = HashMap::new();
let file_canonical = canonicalize_path(&file).unwrap();
let git_root = lookup_git_root(&file_canonical, &repos);
assert!(git_root.is_none());
}
#[test]
fn test_detect_invalid_git_file() {
let temp = TempDir::new().unwrap();
let dir = temp.path().join("notarepo");
std::fs::create_dir(&dir).unwrap();
std::fs::write(dir.join(".git"), "invalid content").unwrap();
let repos = detect_repos_under(temp.path());
assert!(repos.is_empty());
}
#[test]
fn test_detect_handles_permission_errors_gracefully() {
let temp = TempDir::new().unwrap();
create_git_repo(temp.path());
let repos = detect_repos_under(temp.path());
assert_eq!(repos.len(), 1);
}
#[cfg(unix)]
#[test]
fn test_detect_symlink_not_followed() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let real_repo = temp.path().join("real");
create_git_repo(&real_repo);
let link = temp.path().join("link");
symlink(&real_repo, &link).unwrap();
let repos = detect_repos_under(temp.path());
assert_eq!(repos.len(), 1);
let real_canonical = canonicalize_path(&real_repo).unwrap();
assert!(repos.contains_key(&real_canonical));
}
#[cfg(unix)]
#[test]
fn test_detect_circular_symlink_handled() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let a = temp.path().join("a");
let b = temp.path().join("b");
symlink(&b, &a).unwrap();
symlink(&a, &b).unwrap();
create_git_repo(&temp.path().join("repo"));
let repos = detect_repos_under(temp.path());
assert_eq!(repos.len(), 1);
}
#[test]
fn test_detect_git_modules_not_false_positive() {
let temp = TempDir::new().unwrap();
let main_repo = temp.path().join("main");
create_git_repo(&main_repo);
let modules_dir = main_repo.join(".git/modules/lib");
std::fs::create_dir_all(&modules_dir).unwrap();
std::fs::create_dir(modules_dir.join(".git")).unwrap();
std::fs::write(modules_dir.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
let repos = detect_repos_under(temp.path());
assert_eq!(
repos.len(),
1,
"Should not detect repos inside .git/modules"
);
let main_canonical = canonicalize_path(&main_repo).unwrap();
assert!(repos.contains_key(&main_canonical));
}
#[test]
fn test_detect_stale_gitdir_reference_skipped() {
let temp = TempDir::new().unwrap();
let main_repo = temp.path().join("main");
create_git_repo(&main_repo);
let stale_submodule = main_repo.join("deps/stale");
create_stale_git_file(&stale_submodule, "../.git/modules/nonexistent");
let repos = detect_repos_under(temp.path());
assert_eq!(repos.len(), 1, "Stale gitdir reference should be skipped");
let main_canonical = canonicalize_path(&main_repo).unwrap();
assert!(repos.contains_key(&main_canonical));
let stale_canonical = canonicalize_path(&stale_submodule).unwrap();
assert!(
!repos.contains_key(&stale_canonical),
"Stale submodule should not be detected"
);
}
#[test]
fn test_detect_gitdir_missing_head_skipped() {
let temp = TempDir::new().unwrap();
let main_repo = temp.path().join("main");
create_git_repo(&main_repo);
let submodule = main_repo.join("deps/invalid");
std::fs::create_dir_all(&submodule).unwrap();
let gitdir_target = main_repo.join(".git/modules/invalid");
std::fs::create_dir_all(&gitdir_target).unwrap();
std::fs::write(
submodule.join(".git"),
format!("gitdir: {}\n", gitdir_target.display()),
)
.unwrap();
let repos = detect_repos_under(temp.path());
assert_eq!(repos.len(), 1, "Gitdir without HEAD should be skipped");
let main_canonical = canonicalize_path(&main_repo).unwrap();
assert!(repos.contains_key(&main_canonical));
}
}