mod.rs raw

//! Fixtures: a temporary store of bare repositories built with git2, and
//! an App configured to serve it.

use std::{fs, path::Path, sync::Arc};

use clap::Parser;
use git2::{Repository, RepositoryInitOptions, Signature};
use mugit::{App, Config};
use tempfile::TempDir;

pub const PUBLIC_URL: &str = "http://git.test";

pub struct Fixture {
    pub dir: TempDir,
    pub app: Arc<App>,
}

impl Fixture {
    pub fn new() -> Self {
        Self::with_page_size(50)
    }

    pub fn with_page_size(page_size: u32) -> Self {
        Self::with_args(&["--page-size", &page_size.to_string()])
    }

    /// A fixture whose app is configured with further command line
    /// arguments on top of the usual ones.
    pub fn with_args(args: &[&str]) -> Self {
        let dir = tempfile::tempdir().expect("temp dir");
        let usual = [
            "mugit",
            "--repos",
            dir.path().to_str().unwrap(),
            "--listen",
            "127.0.0.1:0",
            "--public-url",
            PUBLIC_URL,
            "--site-name",
            "Test Site",
        ];
        let config = Config::parse_from(usual.iter().chain(args));
        let app = App::new(config).expect("app");
        Fixture { dir, app }
    }

    /// Creates a bare repository with one commit containing `files`,
    /// on branch `main`. File names may contain `/` to build directories.
    pub fn repo(&self, name: &str, public: bool, description: &str, files: &[(&str, &str)]) {
        let byte_files: Vec<(&str, &[u8])> =
            files.iter().map(|(f, c)| (*f, c.as_bytes())).collect();
        self.repo_bytes(name, public, description, &byte_files);
    }

    /// As `repo`, with arbitrary file contents.
    pub fn repo_bytes(&self, name: &str, public: bool, description: &str, files: &[(&str, &[u8])]) {
        let path = self.dir.path().join(format!("{name}.git"));
        let mut options = RepositoryInitOptions::new();
        options.bare(true).initial_head("main");
        let repo = Repository::init_opts(&path, &options).expect("init bare repo");
        if !files.is_empty() {
            let tree = repo.find_tree(build_tree(&repo, files)).expect("find tree");
            let sig = Signature::now("Test", "test@example.com").expect("signature");
            repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
                .expect("commit");
        }
        fs::write(path.join("description"), format!("{description}\n")).expect("description");
        set_public(&path, public);
    }
}

// Writes a tree for files whose names may contain directory separators.
fn build_tree(repo: &Repository, files: &[(&str, &[u8])]) -> git2::Oid {
    let mut builder = repo.treebuilder(None).expect("tree builder");
    let mut subdirs: Vec<(&str, Vec<(&str, &[u8])>)> = Vec::new();
    for (file, content) in files {
        match file.split_once('/') {
            None => {
                let oid = repo.blob(content).expect("blob");
                builder.insert(file, oid, 0o100644).expect("tree entry");
            }
            Some((dir, rest)) => match subdirs.iter_mut().find(|(d, _)| *d == dir) {
                Some((_, entries)) => entries.push((rest, content)),
                None => subdirs.push((dir, vec![(rest, content)])),
            },
        }
    }
    for (dir, entries) in subdirs {
        let oid = build_tree(repo, &entries);
        builder.insert(dir, oid, 0o040000).expect("subtree entry");
    }
    builder.write().expect("tree")
}

pub fn set_public(repo_path: &Path, public: bool) {
    let marker = repo_path.join(mugit::repos::EXPORT_MARKER);
    if public {
        fs::write(&marker, "").expect("export marker");
    } else if marker.exists() {
        fs::remove_file(&marker).expect("remove export marker");
    }
}