a73x

3f5e9424

add repo discovery module for scanning repos directory

a73x   2026-03-30 18:39

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

diff --git a/src/server/main.rs b/src/server/main.rs
index 779073e..77871b0 100644
--- a/src/server/main.rs
+++ b/src/server/main.rs
@@ -1,4 +1,5 @@
mod config;
mod repos;

fn main() {
    println!("git-collab-server: not yet implemented");
diff --git a/src/server/repos.rs b/src/server/repos.rs
new file mode 100644
index 0000000..9b23aec
--- /dev/null
+++ b/src/server/repos.rs
@@ -0,0 +1,128 @@
use std::path::{Path, PathBuf};

/// A discovered git repository on disk.
#[derive(Debug, Clone)]
pub struct RepoEntry {
    pub name: String,
    pub path: PathBuf,
    pub bare: bool,
}

/// Scan a directory for git repositories.
pub fn discover(repos_dir: &Path) -> Result<Vec<RepoEntry>, std::io::Error> {
    let mut entries = Vec::new();
    let read_dir = std::fs::read_dir(repos_dir)?;

    for entry in read_dir {
        let entry = entry?;
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }

        let dir_name = entry.file_name().to_string_lossy().to_string();

        if path.join("HEAD").is_file() {
            let name = dir_name.strip_suffix(".git").unwrap_or(&dir_name).to_string();
            entries.push(RepoEntry { name, path, bare: true });
        } else if path.join(".git").is_dir() {
            entries.push(RepoEntry { name: dir_name, path, bare: false });
        }
    }

    entries.sort_by(|a, b| a.name.cmp(&b.name));
    Ok(entries)
}

/// Resolve a repo name to its on-disk entry.
pub fn resolve(repos_dir: &Path, name: &str) -> Option<RepoEntry> {
    let entries = discover(repos_dir).ok()?;
    entries.into_iter().find(|e| e.name == name)
}

/// Open a git2::Repository from a RepoEntry.
pub fn open(entry: &RepoEntry) -> Result<git2::Repository, git2::Error> {
    if entry.bare {
        git2::Repository::open_bare(&entry.path)
    } else {
        git2::Repository::open(&entry.path)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;
    use std::process::Command;

    fn init_bare(parent: &Path, name: &str) {
        Command::new("git")
            .args(["init", "--bare", name])
            .current_dir(parent)
            .output()
            .expect("git init --bare failed");
    }

    fn init_non_bare(parent: &Path, name: &str) {
        Command::new("git")
            .args(["init", name])
            .current_dir(parent)
            .output()
            .expect("git init failed");
    }

    #[test]
    fn discover_bare_repos() {
        let tmp = TempDir::new().unwrap();
        init_bare(tmp.path(), "alpha.git");
        init_bare(tmp.path(), "beta.git");
        let repos = discover(tmp.path()).unwrap();
        assert_eq!(repos.len(), 2);
        assert_eq!(repos[0].name, "alpha");
        assert_eq!(repos[1].name, "beta");
        assert!(repos[0].bare);
    }

    #[test]
    fn discover_non_bare_repos() {
        let tmp = TempDir::new().unwrap();
        init_non_bare(tmp.path(), "myproject");
        let repos = discover(tmp.path()).unwrap();
        assert_eq!(repos.len(), 1);
        assert_eq!(repos[0].name, "myproject");
        assert!(!repos[0].bare);
    }

    #[test]
    fn discover_skips_non_repo_dirs() {
        let tmp = TempDir::new().unwrap();
        std::fs::create_dir(tmp.path().join("not-a-repo")).unwrap();
        init_bare(tmp.path(), "real.git");
        let repos = discover(tmp.path()).unwrap();
        assert_eq!(repos.len(), 1);
        assert_eq!(repos[0].name, "real");
    }

    #[test]
    fn resolve_finds_repo_by_name() {
        let tmp = TempDir::new().unwrap();
        init_bare(tmp.path(), "myrepo.git");
        let entry = resolve(tmp.path(), "myrepo").unwrap();
        assert_eq!(entry.name, "myrepo");
    }

    #[test]
    fn resolve_returns_none_for_unknown() {
        let tmp = TempDir::new().unwrap();
        assert!(resolve(tmp.path(), "nope").is_none());
    }

    #[test]
    fn open_bare_repo() {
        let tmp = TempDir::new().unwrap();
        init_bare(tmp.path(), "test.git");
        let entry = resolve(tmp.path(), "test").unwrap();
        let repo = open(&entry).unwrap();
        assert!(repo.is_bare());
    }
}