gunnar-server 3.0.0

The gunnar git server: SSH and smart-HTTP git transports and the gRPC control plane. The appliance half, extracted from gunnar-cli so the server code lives in its own crate.
Documentation
//! **`Repos.Gc` against a real store: enqueue, run, and read the bytes back.**
//!
//! `tests/gc_queue.rs` proves the queue's two invariants against a runner that
//! never touches a disk — one gc at a time, one job per double press. This
//! proves the other half: that the runner wired into `CoreControlPlane` reaches
//! the same maintenance `gunnar gc` runs, on a real repository with real
//! objects in it, and that a console polling `gc_status` sees the outcome.
//!
//! **Applied output, not a round-trip.** The assertions are on a store that was
//! actually maintained: a DONE job, a `MaintenanceReport` from `admin::gc_one`
//! over the same repository agreeing that the archive is consolidated, and the
//! two failure modes coming back DIFFERENT — a name that was never a repository
//! is refused on the request that made it, while a repository whose store
//! cannot be maintained comes back as a FAILED job carrying the reason. A
//! control plane that answered "queued" to both would be green on a
//! state-machine test and useless on a box.

use std::sync::Arc;
use std::time::{Duration, Instant};

use gix_object::Kind;
use gunnar_core::{HashKind, NewRepo, RepoRef};
use gunnar_grpc::seam::{ControlPlane as _, GcState};
use gunnar_proto::RepoPath;
use gunnar_server::control_plane::CoreControlPlane;
use gunnar_server::stores::ServerStores;
use gunnar_store::conformance::pack::PackBuilder;
use gunnar_store::git_storage_trait::GitServe;

type Oid = gix_hash::ObjectId;

fn compute(hash: gix_hash::Kind, kind: Kind, content: &[u8]) -> Oid {
    gix_object::compute_hash(hash, kind, content).expect("hashing an object")
}

fn tree_bytes(entries: &[(&str, &str, Oid)]) -> Vec<u8> {
    let mut body = Vec::new();
    for (mode, name, oid) in entries {
        body.extend_from_slice(mode.as_bytes());
        body.push(b' ');
        body.extend_from_slice(name.as_bytes());
        body.push(0);
        body.extend_from_slice(oid.as_bytes());
    }
    body
}

fn commit_bytes(tree: Oid, message: &str) -> Vec<u8> {
    let who = "Alice <alice@example.com> 1700000000 +0000";
    let mut body = format!("tree {}\n", tree.to_hex());
    body.push_str(&format!("author {who}\n"));
    body.push_str(&format!("committer {who}\n\n"));
    body.push_str(message);
    body.push('\n');
    body.into_bytes()
}

/// One commit with one file, stored and pointed at by `refs/heads/main`.
///
/// The ref matters: the engine refuses to gc a repository with no ref at all,
/// because every object in it would be dead and a gc that deleted everything is
/// not a gc. That refusal is asserted in its own right below.
fn plant_a_commit(git: &dyn GitServe, hash: gix_hash::Kind) -> Oid {
    let blob = b"the quick brown fox\n".to_vec();
    let blob_id = compute(hash, Kind::Blob, &blob);
    let tree = tree_bytes(&[("100644", "fox.txt", blob_id)]);
    let tree_id = compute(hash, Kind::Tree, &tree);
    let commit = commit_bytes(tree_id, "first");
    let commit_id = compute(hash, Kind::Commit, &commit);

    let mut pb = PackBuilder::new(hash);
    for (kind, content) in [
        (Kind::Blob, blob),
        (Kind::Tree, tree),
        (Kind::Commit, commit),
    ] {
        pb.object(kind, content);
    }
    let fixture = pb.build().expect("building the pack");
    git.put_pack(&fixture.bytes).expect("storing the pack");
    git.update_ref("refs/heads/main", None, Some(commit_id.as_bytes()))
        .expect("pointing main at the commit");
    commit_id
}

/// The sealed generations on disk under `git_dir`.
///
/// What maintenance LEAVES BEHIND, and therefore the only thing here that can
/// tell a gc that ran from a job that merely says it did. `repository.gN.znippy`
/// is the engine's own name for a sealed generation — the same file its
/// "already exists" refusal names when a second gc trips over one.
fn generations(git_dir: &std::path::Path) -> Vec<String> {
    fn walk(dir: &std::path::Path, found: &mut Vec<String>) {
        let Ok(entries) = std::fs::read_dir(dir) else {
            return;
        };
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                walk(&path, found);
            } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
                if name.starts_with("repository.g") && name.ends_with(".znippy") {
                    found.push(name.to_owned());
                }
            }
        }
    }
    let mut found = Vec::new();
    walk(git_dir, &mut found);
    found.sort();
    found
}

/// Poll `gc_status` until the job settles, failing rather than hanging.
///
/// A bounded wait around an asynchronous verb, not a fixed sleep: the
/// assertions after it decide the test, and this only turns "it never
/// finished" into a message.
fn settle(plane: &CoreControlPlane, path: &RepoPath) -> gunnar_grpc::seam::GcJob {
    let deadline = Instant::now() + Duration::from_secs(60);
    while Instant::now() < deadline {
        let job = plane
            .gc_status(path)
            .expect("gc_status is implemented on the real plane")
            .expect("a job that was just enqueued is still known");
        if !matches!(job.state, GcState::Queued | GcState::Running) {
            return job;
        }
        std::thread::sleep(Duration::from_millis(20));
    }
    panic!("the gc never settled");
}

/// **The whole path: enqueue, the worker runs the same maintenance the CLI
/// runs, and the finished job carries what it reclaimed.**
#[test]
fn gc_over_the_control_plane_maintains_a_real_repository() {
    let dir = tempfile::tempdir().unwrap();
    let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
    stores
        .catalog()
        .create_namespace("alice")
        .expect("namespace");
    let repo_ref = RepoRef::new("alice", "swept").expect("legal repo path");
    let repo = stores
        .create_repo(NewRepo::new(repo_ref).hash_kind(HashKind::Sha256), false)
        .expect("create repo");
    let store = stores.repo_store(&repo).expect("open repo store");
    plant_a_commit(store.serve(), store.hash_kind());
    let git_dir = store.git_dir().to_path_buf();
    let before = generations(&git_dir);
    assert!(
        before.is_empty(),
        "a repository that has never been maintained has no sealed generation: {before:?}"
    );

    let path = RepoPath::new("alice", "swept");
    let plane = CoreControlPlane::new(Arc::clone(&stores));

    // Nothing has been asked for. "No job" and "a job that did nothing" must be
    // different answers, or a console button lights up for a run nobody asked
    // for.
    assert!(
        plane.gc_status(&path).expect("implemented").is_none(),
        "a repository nobody has gc'd must have no job at all"
    );

    // **Enqueue returns before the gc has run** — that is the design, and it is
    // asserted rather than assumed: a handler that blocked for the length of a
    // compaction is what makes a console button feel broken.
    let accepted = plane.gc_repo(&path).expect("the gc is accepted");
    assert!(
        matches!(accepted.state, GcState::Queued | GcState::Running),
        "Repos.Gc must return with the job still live, not after running it; got {:?}",
        accepted.state
    );
    assert!(!accepted.job_id.is_empty(), "an accepted job has an id");

    let done = settle(&plane, &path);
    assert_eq!(done.state, GcState::Done, "the gc failed: {:?}", done.error);
    assert_eq!(
        done.job_id, accepted.job_id,
        "the finished job must be the one that was accepted, not a later run"
    );
    assert!(
        done.finished_at_unix_ms >= done.enqueued_at_unix_ms,
        "a job cannot have finished before it was enqueued"
    );
    assert_eq!(done.queue_position, 0, "a finished job is not waiting");

    // **The applied output, on the disk.** Maintenance seals the live logs into
    // a numbered generation, so a store that was actually compacted has one and
    // a store that was not has none. Every assertion above this line is about
    // the job's own bookkeeping and a control plane that marked a job DONE
    // without touching anything would pass all of them; this is the one it
    // cannot.
    let after = generations(&git_dir);
    assert!(
        !after.is_empty(),
        "the gc reported DONE and left no sealed generation under {} — nothing was maintained",
        git_dir.display()
    );

    // The result outlives the run: this is what a polling console reads.
    let still = plane
        .gc_status(&path)
        .expect("implemented")
        .expect("a finished job stays readable");
    assert_eq!(still.state, GcState::Done);
    assert_eq!(still.bytes_reclaimed, done.bytes_reclaimed);
}

/// **The two failure modes are different, and both say which.**
///
/// A name that was never a repository is refused on the request that named it,
/// because a typo should come back to the person who made it — not sit in a
/// queue and fail minutes later inside a job nobody is watching. A repository
/// the engine refuses to maintain (here: no ref, so every object in it would be
/// dead) is a FAILED job carrying the engine's own sentence.
#[test]
fn an_unknown_repository_is_refused_and_an_unmaintainable_one_fails_its_job() {
    let dir = tempfile::tempdir().unwrap();
    let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
    stores
        .catalog()
        .create_namespace("alice")
        .expect("namespace");
    let repo_ref = RepoRef::new("alice", "empty").expect("legal repo path");
    stores
        .create_repo(NewRepo::new(repo_ref).hash_kind(HashKind::Sha256), false)
        .expect("create repo");
    let plane = CoreControlPlane::new(Arc::clone(&stores));

    // 1. Never a repository → refused now, by name.
    let refused = plane
        .gc_repo(&RepoPath::new("alice", "no-such-thing"))
        .expect_err("a gc of a repository that does not exist must be refused");
    let refused = format!("{refused:?}");
    assert!(
        refused.contains("NotFound"),
        "a gc of an unknown repository must be NOT_FOUND, not accepted: {refused}"
    );

    // 2. A real repository the engine will not maintain → accepted, then FAILED
    //    with the reason. The distinction is the point: one is the caller's
    //    mistake, the other is the box's state.
    let path = RepoPath::new("alice", "empty");
    plane.gc_repo(&path).expect("a live repository is accepted");
    let job = settle(&plane, &path);
    assert_eq!(
        job.state,
        GcState::Failed,
        "a repository with no ref cannot be gc'd and the job must say so"
    );
    let reason = job.error.clone().unwrap_or_default();
    assert!(
        !reason.is_empty(),
        "a FAILED job must carry the reason; 'failed' alone is not actionable"
    );
    assert!(
        reason.contains("alice/empty"),
        "the reason must name the repository it is about: {reason}"
    );
    assert_eq!(job.bytes_reclaimed, 0, "a failed gc reclaimed nothing");

    // The failure is readable afterwards, exactly as a success is — a console
    // must be able to show why the button went red.
    let still = plane
        .gc_status(&path)
        .expect("implemented")
        .expect("a failed job stays readable too");
    assert_eq!(still.state, GcState::Failed);
    assert_eq!(still.error, job.error);
}