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 {
if dot_git.join("HEAD").is_file() {
return true;
}
if dot_git.is_file()
&& let Some(gitdir) = read_gitdir_pointer(dot_git)
{
return gitdir.join("HEAD").is_file();
}
false
}
fn is_excluded(repo_path: &Path, config: &Config) -> bool {
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();
config
.excluded_repos
.iter()
.any(|pattern| repo_name == *pattern || path_str.contains(pattern))
}
fn discover_repo_workspace_projects(
root: &Path,
config: &Config,
seen: &mut HashSet<PathBuf>,
out: &mut Vec<PathBuf>,
) {
let contents = match std::fs::read_to_string(root.join(".repo").join("project.list")) {
Ok(c) => c,
Err(_) => return, };
let Ok(canonical_root) = root.canonicalize() else {
return;
};
for line in contents.lines() {
let rel = line.trim();
if rel.is_empty() {
continue;
}
let Ok(canonical) = root.join(rel).canonicalize() else {
continue;
};
if !canonical.starts_with(&canonical_root) {
continue;
}
if is_real_git_dir(&canonical.join(".git"))
&& !is_excluded(&canonical, config)
&& seen.insert(canonical.clone())
{
out.push(canonical);
}
}
}
fn read_gitdir_pointer(dot_git_file: &Path) -> Option<PathBuf> {
let contents = std::fs::read_to_string(dot_git_file).ok()?;
let target = Path::new(contents.trim().strip_prefix("gitdir:")?.trim());
if target.is_absolute() {
Some(target.to_path_buf())
} else {
Some(dot_git_file.parent()?.join(target))
}
}
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_entry(|e| e.file_name() != ".repo")
.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());
if !is_excluded(&repo_path, config) && seen.insert(repo_path.clone()) {
repos.push(repo_path);
}
}
}
discover_repo_workspace_projects(root, config, &mut seen, &mut repos);
}
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_submodule(
parent: &std::path::Path,
name: &str,
super_git: &std::path::Path,
) -> PathBuf {
let repo_dir = parent.join(name);
fs::create_dir_all(&repo_dir).unwrap();
let module_git = super_git.join("modules").join(name);
fs::create_dir_all(&module_git).unwrap();
fs::write(module_git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
fs::write(
repo_dir.join(".git"),
format!("gitdir: {}\n", module_git.display()),
)
.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_submodule_is_discovered() {
let tmp = TempDir::new().unwrap();
let super_git = tmp.path().join("superproject").join(".git");
fs::create_dir_all(&super_git).unwrap();
let submodule = make_submodule(tmp.path(), "vendored-lib", &super_git);
let config = Config {
root_dirs: vec![],
pinned_repos: vec![submodule.clone()],
scan_depth: 2,
..Config::default()
};
let repos = discover_repos(&config);
assert_eq!(repos.len(), 1, "got {repos:?}");
assert!(repos[0].ends_with("vendored-lib"));
}
#[test]
fn test_pinned_submodule_relative_gitdir_is_discovered() {
let tmp = TempDir::new().unwrap();
let super_dir = tmp.path().join("super");
let module_git = super_dir.join(".git").join("modules").join("sub");
fs::create_dir_all(&module_git).unwrap();
fs::write(module_git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
let work = super_dir.join("deps").join("sub");
fs::create_dir_all(&work).unwrap();
fs::write(work.join(".git"), "gitdir: ../../.git/modules/sub\n").unwrap();
let config = Config {
root_dirs: vec![],
pinned_repos: vec![work.clone()],
scan_depth: 2,
..Config::default()
};
let repos = discover_repos(&config);
assert_eq!(repos.len(), 1, "got {repos:?}");
assert!(repos[0].ends_with("sub"));
}
#[test]
fn test_sibling_worktree_is_not_a_top_level_repo() {
let tmp = TempDir::new().unwrap();
let repo = make_repo(tmp.path(), "proj");
let admin = repo.join(".git").join("worktrees").join("feature");
fs::create_dir_all(&admin).unwrap();
fs::write(admin.join("HEAD"), "ref: refs/heads/feature\n").unwrap();
let worktree = tmp.path().join("proj-feature");
fs::create_dir_all(&worktree).unwrap();
fs::write(
worktree.join(".git"),
format!("gitdir: {}\n", admin.display()),
)
.unwrap();
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("proj"));
}
#[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"));
}
fn make_repo_workspace(root: &Path, projects: &[&str], link: impl Fn(&Path, &Path)) {
fs::create_dir_all(root.join(".repo/projects")).unwrap();
for name in projects {
let work = root.join(name);
fs::create_dir_all(&work).unwrap();
let gitdir = root.join(".repo/projects").join(format!("{name}.git"));
fs::create_dir_all(&gitdir).unwrap();
fs::write(gitdir.join("HEAD"), "ref: refs/heads/main\n").unwrap();
link(&work, &gitdir);
}
fs::write(
root.join(".repo").join("project.list"),
format!("{}\n", projects.join("\n")),
)
.unwrap();
}
fn found(repos: &[PathBuf], suffix: &str) -> bool {
repos.iter().any(|r| r.ends_with(suffix))
}
#[cfg(unix)]
#[test]
fn test_repo_workspace_symlink_projects_are_discovered() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let projects = ["kernel-6.12", "hbre/libmm", "app/qualitytest"];
make_repo_workspace(root, &projects, |work, gitdir| {
std::os::unix::fs::symlink(gitdir, work.join(".git")).unwrap()
});
let config = Config {
root_dirs: vec![root.to_path_buf()],
scan_depth: 1, ..Config::default()
};
let repos = discover_repos(&config);
assert_eq!(repos.len(), 3, "got {repos:?}");
for name in projects {
assert!(found(&repos, name), "missing {name}, got {repos:?}");
}
}
#[test]
fn test_repo_workspace_gitdir_file_projects_are_discovered() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let projects = ["kernel-6.12", "hbre/libmm"];
make_repo_workspace(root, &projects, |work, gitdir| {
fs::write(work.join(".git"), format!("gitdir: {}\n", gitdir.display())).unwrap()
});
let config = Config {
root_dirs: vec![root.to_path_buf()],
scan_depth: 1,
..Config::default()
};
let repos = discover_repos(&config);
assert_eq!(repos.len(), 2, "got {repos:?}");
for name in projects {
assert!(found(&repos, name), "missing {name}, got {repos:?}");
}
}
#[test]
fn test_repo_workspace_skips_missing_worktrees() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
make_repo_workspace(root, &["synced"], |work, gitdir| {
fs::write(work.join(".git"), format!("gitdir: {}\n", gitdir.display())).unwrap()
});
fs::write(
root.join(".repo").join("project.list"),
"synced\nnot-synced-yet\n",
)
.unwrap();
let config = Config {
root_dirs: vec![root.to_path_buf()],
scan_depth: 1,
..Config::default()
};
let repos = discover_repos(&config);
assert_eq!(repos.len(), 1, "got {repos:?}");
assert!(found(&repos, "synced"), "got {repos:?}");
}
#[test]
fn test_repo_workspace_rejects_paths_escaping_the_root() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("ws");
fs::create_dir_all(&root).unwrap();
make_repo(tmp.path(), "outside");
make_repo_workspace(&root, &["inside"], |work, gitdir| {
fs::write(work.join(".git"), format!("gitdir: {}\n", gitdir.display())).unwrap()
});
fs::write(
root.join(".repo").join("project.list"),
"inside\n../outside\n",
)
.unwrap();
let config = Config {
root_dirs: vec![root.clone()],
scan_depth: 1,
..Config::default()
};
let repos = discover_repos(&config);
assert_eq!(repos.len(), 1, "got {repos:?}");
assert!(found(&repos, "inside"), "got {repos:?}");
assert!(!found(&repos, "outside"), "got {repos:?}");
}
#[cfg(unix)]
#[test]
fn test_repo_workspace_mixes_native_and_managed() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
make_repo(root, "standalone");
make_repo_workspace(root, &["managed"], |work, gitdir| {
std::os::unix::fs::symlink(gitdir, work.join(".git")).unwrap()
});
fs::create_dir_all(root.join(".repo/manifests")).unwrap();
fs::create_dir_all(root.join(".repo/manifests-git")).unwrap();
fs::write(
root.join(".repo/manifests-git/HEAD"),
"ref: refs/heads/main\n",
)
.unwrap();
std::os::unix::fs::symlink(
root.join(".repo/manifests-git"),
root.join(".repo/manifests").join(".git"),
)
.unwrap();
let config = Config {
root_dirs: vec![root.to_path_buf()],
scan_depth: 2,
..Config::default()
};
let repos = discover_repos(&config);
assert_eq!(repos.len(), 2, "got {repos:?}");
assert!(found(&repos, "standalone"), "got {repos:?}");
assert!(found(&repos, "managed"), "got {repos:?}");
assert!(!found(&repos, "manifests"), "got {repos:?}");
}
#[cfg(unix)]
#[test]
fn test_repo_workspace_symlink_root_has_no_duplicates() {
let tmp = TempDir::new().unwrap();
let real_root = tmp.path().join("real");
fs::create_dir_all(real_root.join(".repo/projects")).unwrap();
let mut worktrees = Vec::new();
for name in ["build", "kernel-6.12", "hbre/libmm"] {
let work = real_root.join(name);
fs::create_dir_all(&work).unwrap();
let gitdir = real_root.join(".repo/projects").join(format!("{name}.git"));
fs::create_dir_all(&gitdir).unwrap();
fs::write(gitdir.join("HEAD"), "ref: refs/heads/main\n").unwrap();
std::os::unix::fs::symlink(&gitdir, work.join(".git")).unwrap();
worktrees.push(work);
}
fs::write(
real_root.join(".repo").join("project.list"),
"build\nkernel-6.12\nhbre/libmm\n",
)
.unwrap();
let alias = tmp.path().join("alias");
std::os::unix::fs::symlink(&real_root, &alias).unwrap();
let config = Config {
root_dirs: vec![alias],
scan_depth: 2,
..Config::default()
};
let repos = discover_repos(&config);
let unique: HashSet<&PathBuf> = repos.iter().collect();
assert_eq!(repos.len(), unique.len(), "duplicates: {repos:?}");
assert_eq!(repos.len(), 3, "got {repos:?}");
let canonical_root = real_root.canonicalize().unwrap();
assert!(
repos.iter().all(|p| p.starts_with(&canonical_root)),
"non-canonical paths: {repos:?}"
);
}
}