use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use crate::domain::Repo;
use crate::store::Store;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("scan root {0} does not exist")]
MissingRoot(PathBuf),
#[error("scan root {0} is not a directory")]
NotADirectory(PathBuf),
#[error("io error at {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error(transparent)]
Store(#[from] crate::store::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
const DEFAULT_SKIP: &[&str] = &[
"node_modules",
"target",
"vendor",
"dist",
"build",
"__pycache__",
];
const DEFAULT_MAX_DEPTH: usize = 4;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Discovered {
pub path: PathBuf,
pub name: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Scan {
pub repos: Vec<Discovered>,
pub unreadable: Vec<PathBuf>,
}
#[derive(Debug, Clone, Default)]
pub struct SyncOutcome {
pub present: Vec<Repo>,
pub vanished: Vec<Repo>,
pub unreadable: Vec<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GitKind {
Repo,
Linked,
None,
}
fn git_kind(dir: &Path) -> GitKind {
match fs::symlink_metadata(dir.join(".git")) {
Ok(meta) if meta.is_dir() => GitKind::Repo,
Ok(_) => GitKind::Linked,
Err(_) => GitKind::None,
}
}
pub struct Scanner {
root: PathBuf,
max_depth: usize,
skip: BTreeSet<String>,
skip_hidden: bool,
}
impl Scanner {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
max_depth: DEFAULT_MAX_DEPTH,
skip: DEFAULT_SKIP.iter().map(|s| s.to_string()).collect(),
skip_hidden: true,
}
}
pub fn max_depth(mut self, depth: usize) -> Self {
self.max_depth = depth;
self
}
pub fn include_hidden(mut self, include: bool) -> Self {
self.skip_hidden = !include;
self
}
pub fn skip_dir(mut self, name: impl Into<String>) -> Self {
self.skip.insert(name.into());
self
}
pub fn root(&self) -> &Path {
&self.root
}
fn should_skip(&self, name: &str) -> bool {
if self.skip_hidden && name.starts_with('.') {
return true;
}
self.skip.contains(name)
}
pub fn walk(&self) -> Result<Scan> {
let meta = fs::metadata(&self.root).map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
Error::MissingRoot(self.root.clone())
} else {
Error::Io {
path: self.root.clone(),
source,
}
}
})?;
if !meta.is_dir() {
return Err(Error::NotADirectory(self.root.clone()));
}
let mut scan = Scan::default();
if git_kind(&self.root) == GitKind::Repo {
if let Some(name) = dir_name(&self.root) {
scan.repos.push(Discovered {
path: self.root.clone(),
name,
});
}
return Ok(scan);
}
let mut stack = vec![(self.root.clone(), 0usize)];
while let Some((dir, depth)) = stack.pop() {
let entries = match fs::read_dir(&dir) {
Ok(entries) => entries,
Err(_) => {
scan.unreadable.push(dir);
continue;
}
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
if self.should_skip(&name) {
continue;
}
let path = entry.path();
match git_kind(&path) {
GitKind::Repo => scan.repos.push(Discovered { path, name }),
GitKind::Linked => {}
GitKind::None => {
if depth + 1 < self.max_depth {
stack.push((path, depth + 1));
}
}
}
}
}
scan.repos.sort();
scan.unreadable.sort();
Ok(scan)
}
pub fn sync(&self, store: &Store, now: DateTime<Utc>) -> Result<SyncOutcome> {
let scan = self.walk()?;
let mut present = Vec::with_capacity(scan.repos.len());
for found in &scan.repos {
present.push(store.upsert_repo(&found.path, &found.name, now)?);
}
Ok(SyncOutcome {
present,
vanished: store.list_repos_last_seen_before(now)?,
unreadable: scan.unreadable,
})
}
}
fn dir_name(path: &Path) -> Option<String> {
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn at(secs: i64) -> DateTime<Utc> {
DateTime::from_timestamp(secs, 0).expect("valid timestamp")
}
fn repo(root: &Path, rel: &str) -> PathBuf {
let path = root.join(rel);
fs::create_dir_all(path.join(".git")).expect("create repo");
path
}
fn linked(root: &Path, rel: &str) -> PathBuf {
let path = root.join(rel);
fs::create_dir_all(&path).expect("create dir");
fs::write(path.join(".git"), "gitdir: /elsewhere/.git/worktrees/x").expect("write .git");
path
}
fn plain(root: &Path, rel: &str) -> PathBuf {
let path = root.join(rel);
fs::create_dir_all(&path).expect("create dir");
path
}
fn names(scan: &Scan) -> Vec<&str> {
scan.repos.iter().map(|r| r.name.as_str()).collect()
}
#[test]
fn finds_repos_at_the_top_level() {
let tmp = TempDir::new().unwrap();
repo(tmp.path(), "alpha");
repo(tmp.path(), "beta");
plain(tmp.path(), "not-a-repo");
let scan = Scanner::new(tmp.path()).walk().unwrap();
assert_eq!(names(&scan), ["alpha", "beta"]);
}
#[test]
fn finds_repos_nested_under_an_org_directory() {
let tmp = TempDir::new().unwrap();
repo(tmp.path(), "acme/api");
repo(tmp.path(), "acme/web");
let scan = Scanner::new(tmp.path()).walk().unwrap();
assert_eq!(names(&scan), ["api", "web"]);
}
#[test]
fn does_not_descend_into_a_repo() {
let tmp = TempDir::new().unwrap();
repo(tmp.path(), "outer");
repo(tmp.path(), "outer/nested");
let scan = Scanner::new(tmp.path()).walk().unwrap();
assert_eq!(names(&scan), ["outer"]);
}
#[test]
fn worktrees_are_not_repos() {
let tmp = TempDir::new().unwrap();
repo(tmp.path(), "real");
linked(tmp.path(), "tasks/7/real");
let scan = Scanner::new(tmp.path()).walk().unwrap();
assert_eq!(
names(&scan),
["real"],
"a .git file marks a worktree, not a repo"
);
}
#[test]
fn skips_hidden_and_noise_directories() {
let tmp = TempDir::new().unwrap();
repo(tmp.path(), ".hidden/secret");
repo(tmp.path(), "node_modules/pkg");
repo(tmp.path(), "app/target/thing");
repo(tmp.path(), "visible");
let scan = Scanner::new(tmp.path()).walk().unwrap();
assert_eq!(names(&scan), ["visible"]);
}
#[test]
fn hidden_directories_can_be_opted_back_in() {
let tmp = TempDir::new().unwrap();
repo(tmp.path(), ".dotfiles");
let scan = Scanner::new(tmp.path())
.include_hidden(true)
.walk()
.unwrap();
assert_eq!(names(&scan), [".dotfiles"]);
}
#[test]
fn depth_is_bounded() {
let tmp = TempDir::new().unwrap();
repo(tmp.path(), "a/b/c/deep");
let shallow = Scanner::new(tmp.path()).max_depth(2).walk().unwrap();
assert!(shallow.repos.is_empty(), "should not reach depth 4");
let deep = Scanner::new(tmp.path()).max_depth(4).walk().unwrap();
assert_eq!(names(&deep), ["deep"]);
}
#[test]
fn a_root_that_is_itself_a_repo_is_the_only_result() {
let tmp = TempDir::new().unwrap();
let root = repo(tmp.path(), "solo");
repo(&root, "vendored");
let scan = Scanner::new(&root).walk().unwrap();
assert_eq!(names(&scan), ["solo"]);
}
#[test]
fn symlinks_are_not_followed() {
let tmp = TempDir::new().unwrap();
let target = repo(tmp.path(), "real");
let link = tmp.path().join("link");
std::os::unix::fs::symlink(&target, &link).unwrap();
let scan = Scanner::new(tmp.path()).walk().unwrap();
assert_eq!(names(&scan), ["real"], "the symlink must not double-count");
}
#[test]
fn results_are_sorted_and_stable() {
let tmp = TempDir::new().unwrap();
for name in ["zulu", "alpha", "mike"] {
repo(tmp.path(), name);
}
let first = Scanner::new(tmp.path()).walk().unwrap();
let second = Scanner::new(tmp.path()).walk().unwrap();
assert_eq!(names(&first), ["alpha", "mike", "zulu"]);
assert_eq!(first, second);
}
#[test]
fn an_empty_root_finds_nothing() {
let tmp = TempDir::new().unwrap();
let scan = Scanner::new(tmp.path()).walk().unwrap();
assert!(scan.repos.is_empty());
assert!(scan.unreadable.is_empty());
}
#[test]
fn a_missing_root_is_an_error() {
let err = Scanner::new("/definitely/not/here").walk().unwrap_err();
assert!(matches!(err, Error::MissingRoot(_)));
}
#[test]
fn a_file_as_root_is_an_error() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("f");
fs::write(&file, "x").unwrap();
assert!(matches!(
Scanner::new(&file).walk().unwrap_err(),
Error::NotADirectory(_)
));
}
#[test]
fn unreadable_directories_are_reported_not_swallowed() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
repo(tmp.path(), "readable");
let locked = plain(tmp.path(), "locked");
fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
let scan = Scanner::new(tmp.path()).walk().unwrap();
fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(names(&scan), ["readable"]);
assert_eq!(scan.unreadable, [locked]);
}
#[test]
fn sync_records_what_it_finds() {
let tmp = TempDir::new().unwrap();
repo(tmp.path(), "alpha");
repo(tmp.path(), "beta");
let store = Store::open_in_memory().unwrap();
let outcome = Scanner::new(tmp.path()).sync(&store, at(100)).unwrap();
assert_eq!(outcome.present.len(), 2);
assert!(outcome.vanished.is_empty());
let stored = store.list_repos(false).unwrap();
assert_eq!(
stored.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
["alpha", "beta"]
);
}
#[test]
fn rescanning_is_idempotent_and_preserves_ignore_flags() {
let tmp = TempDir::new().unwrap();
repo(tmp.path(), "alpha");
let store = Store::open_in_memory().unwrap();
let scanner = Scanner::new(tmp.path());
let first = scanner.sync(&store, at(100)).unwrap();
store.set_repo_ignored(first.present[0].id, true).unwrap();
let second = scanner.sync(&store, at(200)).unwrap();
assert_eq!(second.present.len(), 1, "no duplicate row");
assert_eq!(second.present[0].id, first.present[0].id);
assert!(second.present[0].ignored, "ignore flag survives a rescan");
assert_eq!(second.present[0].last_seen_at, at(200));
assert!(second.vanished.is_empty());
}
#[test]
fn a_deleted_repo_is_reported_as_vanished_not_removed() {
let tmp = TempDir::new().unwrap();
repo(tmp.path(), "keeper");
let doomed = repo(tmp.path(), "doomed");
let store = Store::open_in_memory().unwrap();
let scanner = Scanner::new(tmp.path());
scanner.sync(&store, at(100)).unwrap();
fs::remove_dir_all(&doomed).unwrap();
let outcome = scanner.sync(&store, at(200)).unwrap();
assert_eq!(
outcome
.present
.iter()
.map(|r| r.name.as_str())
.collect::<Vec<_>>(),
["keeper"]
);
assert_eq!(
outcome
.vanished
.iter()
.map(|r| r.name.as_str())
.collect::<Vec<_>>(),
["doomed"]
);
assert_eq!(
store.list_repos(true).unwrap().len(),
2,
"vanished repos stay in the store; a task may still reference them"
);
}
}