use crate::config::Config;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
fn is_real_git_dir(dot_git: &Path) -> bool {
dot_git.join("HEAD").is_file()
}
pub(crate) fn discover_repos(config: &Config) -> Vec<PathBuf> {
let mut seen = HashSet::new();
let mut repos = Vec::new();
for pinned in &config.pinned_repos {
let canonical = pinned.canonicalize().unwrap_or_else(|_| pinned.clone());
if is_real_git_dir(&canonical.join(".git")) && seen.insert(canonical.clone()) {
repos.push(canonical);
}
}
for root in &config.root_dirs {
if !root.exists() {
continue;
}
for entry in WalkDir::new(root)
.max_depth(config.scan_depth)
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
{
if entry.file_name() == ".git"
&& entry.file_type().is_dir()
&& is_real_git_dir(entry.path())
{
let repo_path = entry
.path()
.parent()
.unwrap()
.canonicalize()
.unwrap_or_else(|_| entry.path().parent().unwrap().to_path_buf());
let repo_name = repo_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let path_str = repo_path.to_string_lossy();
let excluded = config
.excluded_repos
.iter()
.any(|pattern| repo_name == *pattern || path_str.contains(pattern));
if !excluded && seen.insert(repo_path.clone()) {
repos.push(repo_path);
}
}
}
}
repos.sort_by(|a, b| {
a.file_name()
.unwrap_or_default()
.to_ascii_lowercase()
.cmp(&b.file_name().unwrap_or_default().to_ascii_lowercase())
});
let pinned_set: HashSet<PathBuf> = config
.pinned_repos
.iter()
.filter_map(|p| p.canonicalize().ok())
.collect();
if !pinned_set.is_empty() {
let mut pinned: Vec<PathBuf> = repos
.iter()
.filter(|r| pinned_set.contains(*r))
.cloned()
.collect();
let rest: Vec<PathBuf> = repos
.into_iter()
.filter(|r| !pinned_set.contains(r))
.collect();
pinned.extend(rest);
repos = pinned;
}
repos
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn make_repo(parent: &std::path::Path, name: &str) -> PathBuf {
let repo_dir = parent.join(name);
let dot_git = repo_dir.join(".git");
fs::create_dir_all(&dot_git).unwrap();
fs::write(dot_git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
repo_dir
}
fn make_phantom_git_dir(parent: &std::path::Path, name: &str) -> PathBuf {
let repo_dir = parent.join(name);
fs::create_dir_all(repo_dir.join(".git")).unwrap();
repo_dir
}
#[test]
fn test_discover_finds_git_repos() {
let tmp = TempDir::new().unwrap();
make_repo(tmp.path(), "alpha");
make_repo(tmp.path(), "beta");
let config = Config {
root_dirs: vec![tmp.path().to_path_buf()],
scan_depth: 2,
..Config::default()
};
let repos = discover_repos(&config);
assert_eq!(repos.len(), 2);
}
#[test]
fn test_excluded_repos_are_filtered() {
let tmp = TempDir::new().unwrap();
make_repo(tmp.path(), "good-repo");
make_repo(tmp.path(), "node_modules");
let config = Config {
root_dirs: vec![tmp.path().to_path_buf()],
excluded_repos: vec!["node_modules".into()],
scan_depth: 2,
..Config::default()
};
let repos = discover_repos(&config);
assert_eq!(repos.len(), 1);
assert!(repos[0].ends_with("good-repo"));
}
#[test]
fn test_discover_skips_phantom_dot_git_at_root() {
let tmp = TempDir::new().unwrap();
make_phantom_git_dir(tmp.path(), ""); make_repo(tmp.path(), "real-repo");
let config = Config {
root_dirs: vec![tmp.path().to_path_buf()],
scan_depth: 2,
..Config::default()
};
let repos = discover_repos(&config);
assert_eq!(repos.len(), 1, "got {repos:?}");
assert!(repos[0].ends_with("real-repo"));
}
#[test]
fn test_discover_skips_phantom_dot_git_in_child() {
let tmp = TempDir::new().unwrap();
make_phantom_git_dir(tmp.path(), "broken");
make_repo(tmp.path(), "ok");
let config = Config {
root_dirs: vec![tmp.path().to_path_buf()],
scan_depth: 2,
..Config::default()
};
let repos = discover_repos(&config);
assert_eq!(repos.len(), 1, "got {repos:?}");
assert!(repos[0].ends_with("ok"));
}
#[test]
fn test_pinned_phantom_repo_is_skipped() {
let tmp = TempDir::new().unwrap();
let phantom = make_phantom_git_dir(tmp.path(), "phantom");
let real = make_repo(tmp.path(), "real");
let config = Config {
root_dirs: vec![],
pinned_repos: vec![phantom, real.clone()],
scan_depth: 2,
..Config::default()
};
let repos = discover_repos(&config);
assert_eq!(repos.len(), 1, "got {repos:?}");
assert!(repos[0].ends_with("real"));
}
#[test]
fn test_pinned_repos_appear_first() {
let tmp = TempDir::new().unwrap();
let z_repo = make_repo(tmp.path(), "z-repo");
make_repo(tmp.path(), "a-repo");
let config = Config {
root_dirs: vec![tmp.path().to_path_buf()],
pinned_repos: vec![z_repo.clone()],
scan_depth: 2,
..Config::default()
};
let repos = discover_repos(&config);
assert_eq!(repos.len(), 2);
assert!(repos[0].ends_with("z-repo"));
}
}