use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use crate::render; #[derive(Clone, Debug)] pub struct Repository { pub user: String, pub name: String, pub path: PathBuf, pub description: Option, /// Author time of the HEAD tip; `None` until the repository has commits. pub updated: Option, } /// Discover git repositories exactly two levels beneath `root`: /// `root/user/repo` or `root/user/repo.git`. /// Grouped by user, most recently updated first within each group. pub fn discover(root: &Path) -> Result> { let root = root .canonicalize() .with_context(|| format!("canonicalizing repository root {}", root.display()))?; let mut found = BTreeMap::new(); for user in sorted_dirs(&root)? { let Some(user_name) = user.file_name().and_then(|n| n.to_str()) else { continue; }; if user_name.starts_with('.') { continue; } for path in sorted_dirs(&user)? { let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else { continue; }; if file_name.starts_with('.') { continue; } let name = file_name.strip_suffix(".git").unwrap_or(file_name); let Ok(repo) = gix::open(&path) else { continue; }; if name.is_empty() { continue; } let description = crate::generate::description(&repo); let updated = render::list_refs(&repo).ok().and_then(|tips| { let tip = render::head_tip(&repo, &tips)?; render::author_time(&repo.find_commit(tip.commit_id).ok()?) }); found.entry((user_name.to_owned(), name.to_owned())).or_insert(Repository { user: user_name.to_owned(), name: name.to_owned(), path, description, updated, }); } } let mut repositories = found.into_values().collect::>(); repositories.sort_by(|a, b| { a.user .cmp(&b.user) .then_with(|| b.updated.map(|t| t.seconds).cmp(&a.updated.map(|t| t.seconds))) .then_with(|| a.name.cmp(&b.name)) }); Ok(repositories) } fn sorted_dirs(path: &Path) -> Result> { let mut dirs = fs::read_dir(path) .with_context(|| format!("reading {}", path.display()))? .filter_map(|entry| entry.ok()) .filter_map(|entry| entry.file_type().ok()?.is_dir().then_some(entry.path())) .collect::>(); dirs.sort(); Ok(dirs) } #[cfg(test)] mod tests { use anyhow::Result; use crate::testutil::{TempDir, git, init_repo}; use super::discover; fn commit_at(repo: &std::path::Path, seconds: u64) -> Result<()> { git(repo, &["commit", "-q", "--allow-empty", "-m", "x", &format!("--date=@{seconds} +0000")])?; Ok(()) } #[test] fn orders_by_head_author_time_within_each_user() -> Result<()> { let root = TempDir::new("catalog-order"); for repo in ["alice/old", "alice/new", "alice/empty", "bob/repo"] { init_repo(&root.join(repo))?; } commit_at(&root.join("alice/new"), 2_000_000_000)?; commit_at(&root.join("bob/repo"), 3_000_000_000)?; let old = root.join("alice/old"); commit_at(&old, 1_000_000_000)?; // only HEAD counts, not newer work on other branches git(&old, &["switch", "-qc", "side"])?; commit_at(&old, 4_000_000_000)?; git(&old, &["switch", "-q", "main"])?; let order = discover(&root)? .into_iter() .map(|r| format!("{}/{}", r.user, r.name)) .collect::>(); assert_eq!(order, ["alice/new", "alice/old", "alice/empty", "bob/repo"]); Ok(()) } }