use std::{
collections::HashSet,
fs,
path::{Path, PathBuf},
};
use anyhow::Result;
use tracing::debug;
pub(super) fn expand_repo_roots(
input_roots: &[PathBuf],
exclude_globset: Option<&std::sync::Arc<globset::GlobSet>>,
) -> Result<(Vec<PathBuf>, Vec<PathBuf>)> {
let mut scan_roots = Vec::new();
let mut repo_roots = Vec::new();
for root in input_roots {
if is_symlink(root) {
scan_roots.push(root.clone());
continue;
}
if is_git_repository_root(root) {
scan_roots.push(root.clone());
repo_roots.push(root.clone());
continue;
}
if !root.is_dir() {
scan_roots.push(root.clone());
continue;
}
let (mut found, non_repo_content) = find_repo_roots_in_dir(root, exclude_globset)?;
if found.is_empty() {
scan_roots.push(root.clone());
continue;
}
repo_roots.extend(found.iter().cloned());
scan_roots.append(&mut found);
if non_repo_content {
scan_roots.push(root.clone());
}
}
Ok((deduplicate_paths(scan_roots), deduplicate_paths(repo_roots)))
}
fn deduplicate_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
let mut seen = HashSet::with_capacity(paths.len());
paths.into_iter().filter(|path| seen.insert(path.clone())).collect()
}
fn find_repo_roots_in_dir(
dir: &Path,
exclude_globset: Option<&std::sync::Arc<globset::GlobSet>>,
) -> Result<(Vec<PathBuf>, bool)> {
let mut repos = Vec::new();
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(error) => {
debug!(
"Skipping unreadable directory while expanding repo roots: {}: {error}",
dir.display()
);
return Ok((repos, true));
}
};
let mut non_repo_content = false;
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
debug!("Skipping entry while expanding repo roots: {error}");
continue;
}
};
let child_path = entry.path();
if exclude_globset.as_ref().is_some_and(|globset| globset.is_match(&child_path)) {
debug!("Skipping {} due to --exclude while expanding repo roots", child_path.display());
continue;
}
if is_symlink(&child_path) {
non_repo_content = true;
continue;
}
if is_git_repository_root(&child_path) {
repos.push(child_path);
} else if child_path.is_dir() {
let (mut child_repos, child_content) =
find_repo_roots_in_dir(&child_path, exclude_globset)?;
repos.append(&mut child_repos);
non_repo_content |= child_content;
} else {
non_repo_content = true;
}
}
repos.sort();
Ok((repos, non_repo_content))
}
pub(super) fn is_git_repository_root(root: &Path) -> bool {
!is_symlink(root)
&& (root.join(".git").exists()
|| (root.join("HEAD").is_file() && root.join("objects").is_dir()))
}
fn is_symlink(path: &Path) -> bool {
fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_symlink())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expand_repo_roots_discovers_nested_repositories() {
let temp = tempfile::tempdir().unwrap();
let base = temp.path();
std::fs::create_dir_all(base.join("workspace/services/api/.git")).unwrap();
std::fs::create_dir_all(base.join("workspace/docs")).unwrap();
std::fs::write(base.join("workspace/docs/README.md"), b"x").unwrap();
std::fs::create_dir_all(base.join("plain")).unwrap();
std::fs::write(base.join("plain/notes.txt"), b"x").unwrap();
let (roots, repos) = expand_repo_roots(&[base.to_path_buf()], None).unwrap();
let nested = base.join("workspace/services/api");
assert!(
roots.contains(&nested),
"nested repository must become its own scan root: {roots:?}"
);
assert!(repos.contains(&nested));
assert!(roots.contains(&base.to_path_buf()));
assert!(!roots.contains(&base.join("workspace")));
assert!(!roots.contains(&base.join("workspace/services")));
assert_eq!(roots.len(), roots.iter().collect::<std::collections::HashSet<_>>().len());
}
#[test]
fn expand_repo_roots_keeps_repository_free_tree_as_one_root() {
let temp = tempfile::tempdir().unwrap();
let base = temp.path();
std::fs::create_dir_all(base.join("a/b/c")).unwrap();
std::fs::write(base.join("a/b/c/file.txt"), b"x").unwrap();
let (roots, repos) = expand_repo_roots(&[base.to_path_buf()], None).unwrap();
assert_eq!(roots, vec![base.to_path_buf()]);
assert!(repos.is_empty());
}
#[test]
fn expand_repo_roots_skips_excluded_subtrees() {
let temp = tempfile::tempdir().unwrap();
let base = temp.path();
std::fs::create_dir_all(base.join("deps/vendor/repo/.git")).unwrap();
std::fs::create_dir_all(base.join("src/api/.git")).unwrap();
let exclude_globset = crate::build_exclude_globset(&["deps".to_string()]).unwrap().unwrap();
let (roots, repos) =
expand_repo_roots(&[base.to_path_buf()], Some(&exclude_globset)).unwrap();
assert!(!roots.contains(&base.join("deps/vendor/repo")));
assert!(!roots.iter().any(|root| root.starts_with(base.join("deps"))));
assert!(roots.contains(&base.join("src/api")));
assert!(repos.contains(&base.join("src/api")));
}
#[test]
fn expand_repo_roots_does_not_descend_into_found_repository() {
let temp = tempfile::tempdir().unwrap();
let base = temp.path();
std::fs::create_dir_all(base.join("outer/.git")).unwrap();
std::fs::create_dir_all(base.join("outer/vendor/inner/.git")).unwrap();
let (roots, repos) = expand_repo_roots(&[base.to_path_buf()], None).unwrap();
assert_eq!(roots, vec![base.join("outer")]);
assert_eq!(repos, vec![base.join("outer")]);
}
#[test]
fn expand_repo_roots_deduplicates_overlapping_inputs() {
let temp = tempfile::tempdir().unwrap();
let base = temp.path();
let repo = base.join("workspace/repo");
std::fs::create_dir_all(repo.join(".git")).unwrap();
std::fs::write(base.join("notes.txt"), b"notes").unwrap();
let (roots, repos) = expand_repo_roots(&[base.to_path_buf(), repo.clone()], None).unwrap();
assert_eq!(roots, vec![repo.clone(), base.to_path_buf()]);
assert_eq!(repos, vec![repo]);
}
#[test]
fn scan_nested_repositories_can_be_disabled() {
let temp = tempfile::tempdir().unwrap();
let base = temp.path();
let nested = base.join("workspace/repo");
std::fs::create_dir_all(nested.join(".git")).unwrap();
let roots = vec![base.to_path_buf()];
let repos = roots
.iter()
.filter(|root| super::is_git_repository_root(root))
.cloned()
.collect::<Vec<_>>();
assert_eq!(roots, vec![base.to_path_buf()]);
assert!(repos.is_empty());
}
#[cfg(unix)]
#[test]
fn expand_repo_roots_does_not_recurse_into_symlinked_directories() {
let temp = tempfile::tempdir().unwrap();
let base = temp.path();
std::fs::create_dir_all(base.join("real/repo/.git")).unwrap();
std::os::unix::fs::symlink(base.join("real"), base.join("link")).unwrap();
let (roots, repos) = expand_repo_roots(&[base.to_path_buf()], None).unwrap();
assert!(roots.contains(&base.join("real/repo")));
assert!(!roots.contains(&base.join("link")));
assert!(!repos.contains(&base.join("link")));
assert!(repos.contains(&base.join("real/repo")));
}
#[cfg(unix)]
#[test]
fn expand_repo_roots_skips_unreadable_directories() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().unwrap();
let base = temp.path();
std::fs::create_dir_all(base.join("locked")).unwrap();
std::fs::create_dir_all(base.join("open/repo/.git")).unwrap();
std::fs::set_permissions(base.join("locked"), std::fs::Permissions::from_mode(0o000))
.unwrap();
let (roots, _repos) = expand_repo_roots(&[base.to_path_buf()], None).unwrap();
assert!(roots.contains(&base.join("open/repo")));
assert!(roots.contains(&base.to_path_buf()));
std::fs::set_permissions(base.join("locked"), std::fs::Permissions::from_mode(0o755))
.unwrap();
}
}