use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; #[derive(Clone, Debug, Eq, PartialEq)] pub struct Repo { pub user: String, pub name: String, pub path: PathBuf, pub description: Option, pub head: Option, } impl Repo { pub fn id(&self) -> String { format!("{}/{}", self.user, self.name) } } 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(|name| name.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(|name| name.to_str()) .map(str::to_owned) else { continue; }; if file_name.starts_with('.') || !path.join("HEAD").is_file() { continue; } let name = file_name .strip_suffix(".git") .unwrap_or(&file_name) .to_owned(); if name.is_empty() { continue; } found .entry((user_name.to_owned(), name.clone())) .or_insert_with(|| Repo { user: user_name.to_owned(), name, description: description(&path), head: head(&path), path, }); } } Ok(found.into_values().collect()) } pub fn find(root: &Path, id: &str) -> Result { let (user, name) = parse_id(id)?; discover(root)? .into_iter() .find(|repo| repo.user == user && repo.name == name) .with_context(|| format!("repository {user}/{name} not found")) } pub fn set_description(repo: &Repo, text: &str) -> Result<()> { if text.contains(['\r', '\n']) { bail!("descriptions must fit on one line"); } let text = text.trim(); let path = repo.path.join("description"); if text.is_empty() { match fs::remove_file(&path) { Ok(()) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => { return Err(error).with_context(|| format!("removing {}", path.display())); } } } else { fs::write(&path, format!("{text}\n")) .with_context(|| format!("writing {}", path.display()))?; } Ok(()) } fn parse_id(id: &str) -> Result<(&str, &str)> { let (user, name) = id .split_once('/') .with_context(|| format!("repository must be written as user/repo, got {id:?}"))?; let name = name.strip_suffix(".git").unwrap_or(name); if user.is_empty() || name.is_empty() || user.starts_with('.') || name.starts_with('.') || name.contains('/') { bail!("repository must be written as user/repo, got {id:?}"); } Ok((user, name)) } fn description(path: &Path) -> Option { fs::read_to_string(path.join("description")) .ok() .map(|description| description.trim().to_owned()) .filter(|description| { !description.is_empty() && !description.starts_with("Unnamed repository") }) } fn head(path: &Path) -> Option { let head = fs::read_to_string(path.join("HEAD")).ok()?; let head = head.trim(); Some( head.strip_prefix("ref: refs/heads/") .unwrap_or(head) .to_owned(), ) .filter(|head| !head.is_empty()) } 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 std::fs; use tempfile::TempDir; use super::*; fn bare_repo(root: &Path, user: &str, name: &str) -> PathBuf { let path = root.join(user).join(name); fs::create_dir_all(&path).unwrap(); fs::write(path.join("HEAD"), "ref: refs/heads/main\n").unwrap(); path } #[test] fn discovers_bare_repositories_and_descriptions() { let temp = TempDir::new().unwrap(); let first = bare_repo(temp.path(), "alice", "first.git"); fs::write(first.join("description"), "first repo\n").unwrap(); bare_repo(temp.path(), "alice", ".hidden"); bare_repo(temp.path(), ".hidden", "repo"); fs::create_dir_all(temp.path().join("alice/not-a-repo")).unwrap(); let repos = discover(temp.path()).unwrap(); assert_eq!(repos.len(), 1); assert_eq!(repos[0].id(), "alice/first"); assert_eq!(repos[0].description.as_deref(), Some("first repo")); assert_eq!(repos[0].head.as_deref(), Some("main")); } #[test] fn description_round_trip_and_removal() { let temp = TempDir::new().unwrap(); bare_repo(temp.path(), "alice", "first"); let repo = find(temp.path(), "alice/first.git").unwrap(); set_description(&repo, " hello ").unwrap(); assert_eq!( find(temp.path(), "alice/first") .unwrap() .description .as_deref(), Some("hello") ); set_description(&repo, "").unwrap(); assert_eq!(find(temp.path(), "alice/first").unwrap().description, None); } #[test] fn rejects_paths_and_multiline_descriptions() { let temp = TempDir::new().unwrap(); bare_repo(temp.path(), "alice", "first"); let repo = find(temp.path(), "alice/first").unwrap(); assert!(find(temp.path(), "../alice/first").is_err()); assert!(find(temp.path(), "alice/first/other").is_err()); assert!(set_description(&repo, "one\ntwo").is_err()); } }