scan local repo

This commit is contained in:
2026-08-31 07:54:41 +07:00
parent 767638eda2
commit 6fcc945dae
8 changed files with 366 additions and 34 deletions
+101
View File
@@ -63,6 +63,65 @@ impl GitCache {
}
}
/// Maximum directory nesting depth when scanning for local repositories,
/// so pathological trees can't stall the scan.
const SCAN_MAX_DEPTH: usize = 12;
/// Directories never descended into during a scan: dependency caches that
/// can be enormous without ever containing user repositories.
const SCAN_SKIPPED_DIRS: [&str; 1] = ["node_modules"];
/// Walk `root` recursively and collect the paths of git repositories
/// (directories containing a `.git` entry) below it.
///
/// Hidden entries and symlinks are skipped, and directories that are
/// themselves repositories are not descended into (so nested repositories,
/// like submodule worktrees, are not reported). Results are canonicalized,
/// deduplicated and sorted by path.
pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
let mut repos = Vec::new();
if !root.is_dir() {
return repos;
}
let mut stack = vec![(root.to_path_buf(), 0usize)];
while let Some((dir, depth)) = stack.pop() {
if depth > SCAN_MAX_DEPTH {
continue;
}
// A directory containing a `.git` entry is a repository (a linked
// worktree has a `.git` file instead of a directory); don't descend.
if dir.join(".git").exists() {
if let Ok(path) = dir.canonicalize() {
repos.push(path);
}
continue;
}
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_dir() || file_type.is_symlink() {
continue;
}
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
if name.starts_with('.') || SCAN_SKIPPED_DIRS.contains(&name.as_ref()) {
continue;
}
stack.push((entry.path(), depth + 1));
}
}
repos.sort();
repos.dedup();
repos
}
/// Clone a repository into `path` from the first working URL in
/// `clone_urls` (the announcement's `clone` tag), then fetch the
/// `refs/nostr/*` PR refs like the cache clone does. The destination must
@@ -1552,6 +1611,48 @@ mod tests {
);
}
#[test]
fn find_git_repos_discovers_repositories_recursively() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
// Repositories are found at any depth; a linked worktree (a `.git`
// file instead of a directory) counts too.
let nested = root.join("a/b/project");
std::fs::create_dir_all(nested.join(".git")).unwrap();
let worktree = root.join("wt");
std::fs::create_dir_all(&worktree).unwrap();
std::fs::write(
worktree.join(".git"),
"gitdir: ../a/b/project/.git/worktrees/wt",
)
.unwrap();
// Plain directories are not repositories.
std::fs::create_dir_all(root.join("plain")).unwrap();
// Hidden entries and dependency caches are skipped.
std::fs::create_dir_all(root.join(".hidden/repo/.git")).unwrap();
std::fs::create_dir_all(root.join("node_modules/pkg/.git")).unwrap();
// A repository is not descended into, so repositories inside it
// (submodule worktrees) are not reported.
let outer = root.join("outer");
std::fs::create_dir_all(outer.join(".git")).unwrap();
std::fs::create_dir_all(outer.join("sub/other/.git")).unwrap();
let mut found = find_git_repos(root);
found.sort();
let mut expected = vec![
nested.canonicalize().unwrap(),
worktree.canonicalize().unwrap(),
outer.canonicalize().unwrap(),
];
expected.sort();
assert_eq!(found, expected);
}
#[test]
fn repo_ref_state_lists_branches_tags_and_head() {
let (_dir, repo) = fixture(&[("a.txt", b"hello")]);