AkurAI Build
Menu

AkurAI-Build

public

Latest change 9b227aaaf72c1a75fca3639c9c46acf437838cb2 - Add repo remove: unregister a repository and delete its hosted mirror by Ólafur Búi Ólafsson

use std::{
    fs,
    path::{Path, PathBuf},
    process::Command,
};

use anyhow::{Context, Result, ensure};

use crate::config;

pub fn repository_path(root: &Path, name: &str) -> Result<PathBuf> {
    config::validate_repo_name(name)?;
    Ok(root.join("hosted").join(format!("{name}.git")))
}

pub fn host(root: &Path, name: &str, source: &Path) -> Result<PathBuf> {
    let source = source
        .canonicalize()
        .with_context(|| format!("source repository does not exist: {}", source.display()))?;
    let destination = repository_path(root, name)?;
    ensure!(
        !destination.exists(),
        "hosted repository already exists: {name}"
    );
    let parent = destination
        .parent()
        .context("hosted repository has no parent")?;
    fs::create_dir_all(parent)?;
    run(
        Command::new("git")
            .args(["clone", "--mirror"])
            .arg(&source)
            .arg(&destination),
        "git mirror clone",
    )?;
    if let Err(error) = run(
        Command::new("git").arg("-C").arg(&destination).args([
            "config",
            "http.receivepack",
            "true",
        ]),
        "enable Git Smart HTTP writes",
    ) {
        let _ = fs::remove_dir_all(&destination);
        return Err(error);
    }
    Ok(destination)
}

pub fn sync(root: &Path, name: &str, source: &Path) -> Result<PathBuf> {
    let source = source
        .canonicalize()
        .with_context(|| format!("source repository does not exist: {}", source.display()))?;
    let destination = repository_path(root, name)?;
    ensure!(destination.is_dir(), "unknown hosted repository: {name}");
    run(
        Command::new("git")
            .arg("-C")
            .arg(&destination)
            .args(["fetch", "--prune"])
            .arg(&source)
            .arg("+refs/*:refs/*"),
        "sync Git mirror",
    )?;
    Ok(destination)
}

/// Delete a hosted bare mirror if it exists. Returns the removed path, or
/// `None` when there was no local mirror (e.g. a repository registered by URL).
pub fn remove(root: &Path, name: &str) -> Result<Option<PathBuf>> {
    let destination = repository_path(root, name)?;
    if destination.is_dir() {
        fs::remove_dir_all(&destination).with_context(|| {
            format!("failed to delete hosted mirror: {}", destination.display())
        })?;
        Ok(Some(destination))
    } else {
        Ok(None)
    }
}

fn run(command: &mut Command, operation: &str) -> Result<()> {
    let output = command
        .output()
        .with_context(|| format!("failed to start {operation}"))?;
    ensure!(
        output.status.success(),
        "{operation} failed: {}",
        String::from_utf8_lossy(&output.stderr).trim()
    );
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::tempdir;

    fn init_source(work: &Path) -> Result<PathBuf> {
        let source = work.join("source");
        fs::create_dir(&source)?;
        git(&source, &["init", "-b", "main"])?;
        git(&source, &["config", "user.name", "AkurAI Build"])?;
        git(&source, &["config", "user.email", "build@localhost"])?;
        Ok(source)
    }

    fn commit(repository: &Path, message: &str) -> Result<()> {
        fs::write(repository.join("README.md"), format!("{message}\n"))?;
        git(repository, &["add", "."])?;
        git(repository, &["commit", "-m", message])
    }

    fn head(repository: &Path) -> Result<String> {
        let output = Command::new("git")
            .arg("-C")
            .arg(repository)
            .args(["rev-parse", "refs/heads/main"])
            .output()?;
        ensure!(output.status.success(), "read hosted head failed");
        Ok(String::from_utf8(output.stdout)?.trim().to_owned())
    }

    fn git(repository: &Path, arguments: &[&str]) -> Result<()> {
        run(
            Command::new("git")
                .arg("-C")
                .arg(repository)
                .args(arguments),
            &format!("git {}", arguments.join(" ")),
        )
    }

    fn has_config(repository: &Path, key: &str, expected: &str) -> bool {
        let output = Command::new("git")
            .arg("-C")
            .arg(repository)
            .args(["config", key])
            .output();
        output
            .ok()
            .filter(|out| out.status.success())
            .and_then(|out| String::from_utf8(out.stdout).ok())
            .is_some_and(|v| v.trim() == expected)
    }

    #[test]
    fn host_creates_bare_mirror_with_matching_head() -> Result<()> {
        let work = tempdir()?;
        let source = init_source(work.path())?;
        commit(&source, "initial")?;
        let source_head = head(&source)?;

        let root = work.path().join("data");
        let hosted = host(&root, "app", &source)?;
        let hosted_head = head(&hosted)?;

        assert_eq!(
            source_head, hosted_head,
            "mirror HEAD must equal source HEAD"
        );
        assert!(
            hosted.join("HEAD").is_file(),
            "bare mirror must have a HEAD file"
        );
        assert!(
            !hosted.join("index").exists(),
            "bare mirror should not have a working-tree index"
        );
        assert!(
            has_config(&hosted, "http.receivepack", "true"),
            "mirror must have Smart-HTTP receive enabled"
        );
        Ok(())
    }

    #[test]
    fn sync_propagates_new_commits_branches_and_tags() -> Result<()> {
        let work = tempdir()?;
        let source = init_source(work.path())?;
        commit(&source, "first")?;
        git(&source, &["tag", "-a", "v1.0", "-m", "first release"])?;

        let root = work.path().join("data");
        let hosted = host(&root, "app", &source)?;

        commit(&source, "second")?;
        git(&source, &["branch", "feature"])?;
        git(&source, &["tag", "-a", "v2.0", "-m", "second release"])?;

        sync(&root, "app", &source)?;

        let mirror_head = head(&hosted)?;
        let source_head = head(&source)?;
        assert_eq!(
            mirror_head, source_head,
            "mirror HEAD should reflect new commits"
        );

        let branches = Command::new("git")
            .arg("-C")
            .arg(&hosted)
            .args(["branch", "--list", "feature"])
            .output()?;
        assert!(
            branches.status.success()
                && !String::from_utf8_lossy(&branches.stdout).trim().is_empty(),
            "feature branch must be present in mirror"
        );

        let tags = Command::new("git")
            .arg("-C")
            .arg(&hosted)
            .args(["tag", "--list"])
            .output()?;
        let tag_list = String::from_utf8_lossy(&tags.stdout);
        assert!(
            tag_list.contains("v1.0") && tag_list.contains("v2.0"),
            "both tags must be present in mirror: {tag_list}"
        );
        Ok(())
    }

    #[test]
    fn repository_name_rejects_traversal() {
        for name in ["", "../escape", "repo/hack", "has space", "\x00null"] {
            let result = repository_path(Path::new("root"), name);
            assert!(
                result.is_err(),
                "repository name {name:?} should be rejected"
            );
        }
    }

    #[test]
    fn repository_name_accepts_valid() -> Result<()> {
        for name in ["app", "my-repo", "repo_v2", "test.repo", "a"] {
            let path = repository_path(Path::new("root"), name)?;
            assert!(
                path.ends_with(format!("{name}.git")),
                "valid name {name:?} should produce {name}.git suffix"
            );
        }
        Ok(())
    }

    #[test]
    fn sync_unknown_repository_errors() -> Result<()> {
        let work = tempdir()?;
        let source = init_source(work.path())?;
        commit(&source, "first")?;
        let root = work.path().join("data");
        let result = sync(&root, "nonexistent", &source);
        assert!(result.is_err(), "sync of unknown repo should error");
        Ok(())
    }

    #[test]
    fn host_missing_source_errors() {
        let work = tempdir().expect("create temp dir");
        let missing = work.path().join("does-not-exist");
        let root = work.path().join("data");
        let result = host(&root, "app", &missing);
        assert!(result.is_err(), "host of missing source should error");
    }

    #[test]
    fn rehost_existing_name_errors() -> Result<()> {
        let work = tempdir()?;
        let source = init_source(work.path())?;
        commit(&source, "first")?;
        let root = work.path().join("data");
        host(&root, "app", &source)?;
        let result = host(&root, "app", &source);
        assert!(result.is_err(), "re-hosting existing name should error");
        Ok(())
    }

    #[test]
    fn host_mirror_is_bare() -> Result<()> {
        let work = tempdir()?;
        let source = init_source(work.path())?;
        commit(&source, "first")?;
        let root = work.path().join("data");
        let hosted = host(&root, "app", &source)?;
        let is_bare = Command::new("git")
            .arg("-C")
            .arg(&hosted)
            .args(["rev-parse", "--is-bare-repository"])
            .output()
            .map(|out| String::from_utf8_lossy(&out.stdout).trim() == "true")
            .unwrap_or(false);
        assert!(is_bare, "hosted mirror must be a bare repository");
        Ok(())
    }

    #[test]
    fn remove_deletes_hosted_mirror() -> Result<()> {
        let work = tempdir()?;
        let source = init_source(work.path())?;
        commit(&source, "first")?;
        let root = work.path().join("data");
        let hosted = host(&root, "app", &source)?;
        assert!(hosted.is_dir());
        let deleted = remove(&root, "app")?;
        assert_eq!(deleted.as_deref(), Some(hosted.as_path()));
        assert!(!hosted.exists(), "mirror dir must be gone after remove");
        Ok(())
    }

    #[test]
    fn remove_absent_mirror_returns_none() -> Result<()> {
        let work = tempdir()?;
        let root = work.path().join("data");
        assert!(remove(&root, "nope")?.is_none());
        Ok(())
    }
}