Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-cli/tests/feature_split.rs

//! **The appliance build must link zero gix PORCELAIN**, and that is asserted
//! from a *resolved* dependency graph rather than from a declaration.
//!
//! # 2026-08-30: the subject of this file changed, and it got stricter
//!
//! It used to say *"the appliance build must link zero client code"*, and the
//! test below was named `the_appliance_build_links_no_client_code`. Every word
//! of the reason under it was about **gix**: the client edge was banned because
//! it dragged gix's porcelain and the filesystem assumption into an image with
//! no filesystem. `gunnar-client` was a proxy for gix, not the subject.
//!
//! It was decided on 2026-08-30 that **the appliance replicates** (the
//! drill: 60 ms lag, and after `SIGKILL` of the primary the standby served 5/5
//! repositories `git fsck` clean). Sending a pack to a standby needs
//! `gunnar-client`'s send-pack over its russh transport, and nothing else out
//! of that crate.
//!
//! So `gunnar-client 1.1.4` and `nornir-git 0.1.9` each put their porcelain
//! behind a default-on `porcelain` feature, and `gunnar-server`'s `replication`
//! feature takes the wire half alone. MEASURED, `cargo tree -p gunnar-server
//! -e normal`, unique packages: **365** appliance, **369** appliance +
//! `replication`, **493** appliance + the old all-or-nothing `client`.
//!
//! The assertion below is therefore on `gix` now, and it is stronger than the
//! one it replaces: it fails on a gix that arrives by ANY route, where the two
//! crate names it replaced could each be walked around.
//!
//! The feature-unification hole is why the distinction matters: under
//! resolver 2, `cargo build --workspace` unifies `gunnar-client`'s `gix`
//! features into the server crates' `gix`, so a guard that read declared
//! features would pass while the actual build had already dragged worktree
//! machinery in. `cargo tree -e normal` for the appliance feature set is the
//! graph that is really compiled.
//!
//! The test is asserted in both directions: `gix` must be **absent** from
//! `--no-default-features --features server` and **present** in the default
//! build. Without the second half, a `cargo tree` invocation that silently
//! produced no output at all would pass. And the appliance graph must CONTAIN
//! `gunnar-client`, which is the half of the assertion that makes it mean
//! something: it says the split is real, rather than that replication quietly
//! stopped resolving.

use std::process::Command;

fn tree(args: &[&str]) -> String {
    let manifest = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml");
    let out = Command::new(env!("CARGO"))
        .args(["tree", "--manifest-path", manifest, "-p", "gunnar-cli"])
        .args(args)
        // `-e normal` excludes dev- and build-dependencies. `gunnar-policy` and
        // others carry `gix` as a dev-dependency for fixtures, and those never
        // reach an appliance image; a guard reading raw metadata would trip on
        // them.
        .args(["-e", "normal"])
        .output()
        .expect("cargo tree runs");
    assert!(
        out.status.success(),
        "cargo tree failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let text = String::from_utf8_lossy(&out.stdout).into_owned();
    // Check output, not exit code: a malformed invocation exits zero with empty
    // output, and empty output is shaped exactly like "no forbidden crate found".
    assert!(
        text.contains("gunnar-cli"),
        "cargo tree produced no tree at all, so any 'not found' below would be vacuous:\n{text}"
    );
    text
}

/// The three names here carry `cargo tree`'s trailing ` v` for the same reason
/// [`nothing_forbidden_is_in_either_graph`] does, and they did not until
/// 2026-08-04.
///
/// `cargo tree` prints `<name> v<version> (<path>)`, so a bare substring match
/// searches the **absolute path of the checkout** as well as the package names.
/// A worktree at `.../gunnar-clientdelta/` therefore made this test report
/// *"`--no-default-features --features server` links gunnar-client"* over a tree
/// containing no `gunnar-client` package at all — a red that says nothing about
/// the code, in a file whose whole job is to be believed when it goes red. The
/// convention was already written down two tests below; this one had missed it.
#[test]
fn the_appliance_build_links_no_gix_porcelain() {
    let server_only = tree(&["--no-default-features", "--features", "server"]);
    assert!(
        !server_only.contains("gix v"),
        "`--no-default-features --features server` links the gix facade crate, which is a \
         repository handle on a filesystem with a worktree — and the appliance has \
         neither:\n{server_only}"
    );
    // The porcelain crates the facade brings, spelled out so a failure names the
    // thing that came back rather than only the crate that carried it. These are
    // The no-OS rule's own list: `xtask/src/guard.rs`'s `FORBIDDEN_IN_SERVER_GRAPH`.
    for porcelain in [
        "gix-worktree v",
        "gix-index v",
        "gix-status v",
        "gix-config v",
        "gix-discover v",
        "gix-command v",
    ] {
        assert!(
            !server_only.contains(porcelain),
            "the appliance build links {porcelain}, which is worktree machinery on an image \
             with no worktree:\n{server_only}"
        );
    }

    // ── and the half that makes the absences mean something ─────────────────
    //
    // The appliance MUST link `gunnar-client`. Since 2026-08-30 it replicates
    // (replication), and it does so through that crate's WIRE half — send-pack over
    // the russh transport, `default-features = false`. If replication ever
    // stopped resolving, every assertion above would still pass while saying
    // nothing about a feature split, which is the exact shape of a green nobody
    // has seen red.
    assert!(
        server_only.contains("gunnar-client v"),
        "the appliance build does NOT link gunnar-client, so it cannot replicate and the \
         'no gix' assertions above are about a graph with no client edge in it at \
         all:\n{server_only}"
    );
    assert!(
        server_only.contains("nornir-git v"),
        "the appliance build does NOT link nornir-git, so the replication push has no ssh \
         transport to dial with:\n{server_only}"
    );

    let default = tree(&[]);
    assert!(
        default.contains("gix v"),
        "the default build does NOT link gix, so the assertion above proves nothing about a \
         feature split; it just means gix is nowhere:\n{default}"
    );
    assert!(
        default.contains("gix-worktree v"),
        "the default build does NOT link gix-worktree, so asserting its absence from the \
         appliance is vacuous:\n{default}"
    );
}

/// The pack indexer's threading must be **declared**, not inherited.
///
/// `gix-pack` has no `cfg(feature = "parallel")` of its own: the feature
/// forwards to `gix-parallel/parallel`, and whether
/// `gix_parallel::in_parallel` is the threaded implementation or a
/// plain `for` loop is decided by whatever else in the resolved graph switches
/// it on. Nothing errors and nothing warns when it is off; the receive path
/// simply runs single-threaded, which is exactly the silent-green family the
/// fleet keeps finding.
///
/// **Both feature sets are asserted, and `gunnar-pack` alone is the one that
/// matters.** `gunnar-pack` resolved on its own is the honest question: does
/// *gunnar* ask for it, or is it borrowing somebody else's answer?
///
/// Seen **RED** 2026-08-03: before `parallel` was added to the workspace's
/// `gix-pack` entry, `cargo tree -p gunnar-pack --no-dev-dependencies -e
/// features -i gix-parallel` listed `crc32, default, fs-read-dir, prodash,
/// progress` and no `parallel`.
///
/// # What this guards NOW — it is gunnar's own graph, not gix's
///
/// The second half used to probe `gunnar-cli --no-default-features --features
/// server` and justify itself with *"the appliance build has had `parallel`
/// since the `fs` backend moved onto `gix-odb` (which asks for it), but only by
/// inheritance"*. **Both halves of that sentence are dead.** `gunnar-store` has
/// no `fs` backend and links no `gix-odb`; the appliance is not built from
/// `gunnar-cli` any more but from `gunnar-server` (`locked_on_target.md`, the `gunnar-znippy` collapse, and
/// the `gunnar-server` cell in `crates/gunnar-build-thing/src/cells.rs`).
///
/// The rule survives its justification, and it is worth being explicit about
/// why, because the temptation is to delete it as gix bookkeeping. It is not
/// about gix. `gunnar_pack::index_pack` (and `gix-pack`'s own indexer behind
/// it) degrades to a serial `for` loop with no error, no warning and no runtime
/// signal if the feature is off. What is asserted is that **every engine cell
/// that links a pack indexer** asks for its threading.
///
/// # 2026-10-25 (ODEN-CI round 1): the shipped cell links NO pack indexer
///
/// This half used to assert `parallel` in `gunnar-server --no-default-features
/// --features server,engine-znippy` and went RED on the mains. The feature had
/// not been dropped; the indexer had. Since the engine seam, `receive-pack`
/// hands the bytes to a `PackIngest` whose shipping implementation is
/// `GitOps::put_pack` on znippy's Arrow-IPC engine (`gunnar-wire/src/pack.rs`'s
/// header), znippy-plugin-git 0.2 keeps `gix-pack` as a dev-dependency only,
/// and `gunnar-pack` is a dev-dependency of `gunnar-wire` and `gunnar-store`.
/// MEASURED on master 1fa866f6: `cargo tree -e normal -i gix-pack` matches no
/// package in the engine-znippy cell, and since gix 0.90 that cell links no
/// `gix-parallel` at all (before it, gix-features came in through `gix-hash` /
/// `gix-object` with `default, prodash, progress` only). Turning `parallel` on
/// there would switch on a loop nothing calls. The engine that DOES index with gix, `engine-gix`
/// (`storage-git-gix` → `gix-odb` → `gix-pack`), has it.
///
/// So the rule is now stated per cell, in both directions: a cell that links
/// `gix-pack` must enable `gix-parallel/parallel`; and the shipped
/// engine-znippy cell must link no `gix-pack` at all, so the day a pack indexer
/// comes back into the appliance this test asks for its threading again.
#[test]
fn the_pack_indexer_asks_for_its_own_threading() {
    let alone = features_of("gunnar-pack", &["-e", "no-dev"]);
    assert!(
        has_parallel(&alone),
        "gunnar-pack resolved on its own does NOT enable gix-parallel/parallel, so every \
         gix-pack parallel call site in it — including the `IndexOptions::thread_limit` on the \
         receive path — degrades to a serial `for` loop with no error, no warning and no \
         runtime signal:\n{alone}"
    );

    // Each engine cell of the appliance package, `-p gunnar-server`, its engine named.
    // engine-znippy is the cell every shipped row builds (`crates/gunnar-recipe`);
    // engine-gix is the second backend, the one that indexes through gix-pack.
    for engine in ["engine-znippy", "engine-gix"] {
        let features = format!("server,{engine}");
        let cell = ["--no-default-features", "--features", features.as_str()];
        let links_indexer = links_gix_pack("gunnar-server", &cell);
        // gix 0.90: with no gix-pack in the cell there is no gix-parallel at all
        // (gix-hash and gix-object no longer depend on it), so there is nothing to ask.
        let feats = if links_indexer {
            features_of("gunnar-server", &cell)
        } else {
            String::new()
        };
        if let Err(why) = indexer_threading_rule(engine, links_indexer, &feats) {
            panic!("{why}");
        }
    }
    assert!(
        !links_gix_pack(
            "gunnar-server",
            &[
                "--no-default-features",
                "--features",
                "server,engine-znippy"
            ]
        ),
        "the shipped engine-znippy cell links gix-pack again; the rule above now applies to it"
    );
    assert!(
        links_gix_pack(
            "gunnar-server",
            &["--no-default-features", "--features", "server,engine-gix"]
        ),
        "the engine-gix cell links no gix-pack, so its 'parallel is on' half proves nothing"
    );
}

/// The rule, on `cargo tree` text: a cell that links a pack indexer enables
/// `gix-parallel/parallel`. A cell with no indexer passes whatever its features.
fn indexer_threading_rule(cell: &str, links_indexer: bool, features: &str) -> Result<(), String> {
    if links_indexer && !has_parallel(features) {
        return Err(format!(
            "the {cell} cell links gix-pack but does not enable gix-parallel/parallel, so its \
             pack indexing runs as a serial `for` loop with no error and no signal:\n{features}"
        ));
    }
    Ok(())
}

fn has_parallel(features: &str) -> bool {
    features.contains(r#"gix-parallel feature "parallel""#)
}

/// `cargo tree -e features -i gix-parallel` for one package: one line per enabled
/// feature of that crate, the only view that answers "is `parallel` on **here**".
fn features_of(package: &str, extra: &[&str]) -> String {
    let text = cargo_tree(package, extra, &["-e", "features", "-i", "gix-parallel"]);
    assert!(
        text.contains("gix-parallel v"),
        "cargo tree resolved no gix-parallel for {package}, so any 'feature absent' \
         conclusion would be vacuous:\n{text}"
    );
    text
}

/// Whether the NORMAL graph of `package` under `extra` contains `gix-pack`.
fn links_gix_pack(package: &str, extra: &[&str]) -> bool {
    let manifest = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml");
    let out = Command::new(env!("CARGO"))
        .args(["tree", "--manifest-path", manifest, "-p", package])
        .args(extra)
        .args(["-e", "normal", "-i", "gix-pack"])
        .output()
        .expect("cargo tree runs");
    let stderr = String::from_utf8_lossy(&out.stderr);
    if out.status.success() {
        let text = String::from_utf8_lossy(&out.stdout);
        assert!(
            text.contains("gix-pack v"),
            "cargo tree -i gix-pack succeeded with no gix-pack line:\n{text}"
        );
        return true;
    }
    // Absent: cargo refuses the `-i` spec by name. Anything else is a broken probe.
    assert!(
        stderr.contains("did not match any packages"),
        "cargo tree failed for {package} for a reason other than gix-pack being absent: {stderr}"
    );
    false
}

fn cargo_tree(package: &str, extra: &[&str], view: &[&str]) -> String {
    let manifest = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml");
    let out = Command::new(env!("CARGO"))
        .args(["tree", "--manifest-path", manifest, "-p", package])
        .args(extra)
        .args(view)
        .output()
        .expect("cargo tree runs");
    assert!(
        out.status.success(),
        "cargo tree failed for {package}: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    String::from_utf8_lossy(&out.stdout).into_owned()
}

#[test]
fn the_threading_rule_goes_red_on_an_indexer_without_parallel() {
    let serial = "gix-parallel v0.50.1\n├── gix-parallel feature \"default\"\n";
    let threaded = "gix-parallel v0.50.1\n├── gix-parallel feature \"parallel\"\n";
    assert!(indexer_threading_rule("probe", true, serial).is_err());
    assert!(indexer_threading_rule("probe", true, threaded).is_ok());
    assert!(indexer_threading_rule("probe", false, serial).is_ok());
}

/// No rayon, no libgit2, no OpenSSL — in either graph.
#[test]
fn nothing_forbidden_is_in_either_graph() {
    for (what, text) in [
        (
            "appliance",
            tree(&["--no-default-features", "--features", "server"]),
        ),
        ("default", tree(&[])),
    ] {
        // Matched with the trailing ` v` of `cargo tree`'s `<name> v<version>`,
        // so this catches the crate and not a crate whose name merely contains
        // it. Measured 2026-08-03: the graph does carry `openssl-probe`, which
        // is pure Rust and only locates a CA bundle path. A bare `openssl`
        // substring would fail on it and the test would be retuned rather than
        // trusted.
        for forbidden in [
            "rayon v",
            "libgit2 v",
            "git2 v",
            "openssl v",
            "openssl-sys v",
        ] {
            assert!(
                !text.contains(forbidden),
                "the {what} build links {forbidden}:\n{text}"
            );
        }
    }
}

/// The names the no-OS rule and the crate layout forbid in the **appliance** graph specifically
///
/// Filesystem and worktree machinery, a client-side transport stack, and the
/// crate whose job is spawning `git` subprocesses — in a binary whose
/// specification opens with *"Zero shell, both halves. No `git` subprocess."*
///
/// Deliberately **not** checked in the default graph: `gunnar-client` is a git
/// client and legitimately links the transport stack. The constraint is about
/// what ships as PID 1.
const FORBIDDEN_IN_THE_APPLIANCE: [&str; 13] = [
    "gix-worktree v",
    "gix-index v",
    "gix-filter v",
    "gix-attributes v",
    "gix-ignore v",
    "gix-glob v",
    "gix-config v",
    "gix-discover v",
    "gix-pathspec v",
    "gix-protocol v",
    "gix-transport v",
    "gix-url v",
    "gix-command v",
];

/// The appliance resolves none of the crates the dependency budget forbids it.
///
/// # Why this test exists at all, given the tree is already clean
///
/// It is the half of that budget that was missing. The earlier diagnosis was
/// that `feature_split.rs` was *"a good guard aimed at the wrong half"* — it
/// checked `rayon`, `git2`, `openssl` and passed, while naming **none** of the
/// nine crates the appliance was actually dragging in. The cause was then
/// fixed (350 packages and 47 `gix-*` down to 314 and 24, the `fs` backend
/// moved off gix's porcelain facade onto `gix-odb` + `gix-ref`) and the guard
/// was still never written, so nothing stood between that fix and the next
/// dependency edit that quietly undoes it.
///
/// # It is asserted on a RESOLVED graph, not on a feature declaration
///
/// Through the same `tree()` helper as everything else here, which shells out
/// to `cargo tree -e normal` and refuses an empty answer — because empty output
/// is shaped exactly like "no forbidden crate found", which is this file's own
/// standing lesson.
#[test]
fn the_appliance_links_no_worktree_no_client_transport_and_no_subprocess() {
    let appliance = tree(&["--no-default-features", "--features", "server"]);
    for forbidden in FORBIDDEN_IN_THE_APPLIANCE {
        assert!(
            !appliance.contains(forbidden),
            "the APPLIANCE build links {forbidden}, which the no-OS rule and the crate layout forbid it. \
             This is a distroless image running as PID 1 with no \
             filesystem assumptions:\n{appliance}"
        );
    }
}

/// The guard above is green, and a green nobody has seen red is worth nothing.
///
/// The historical red — the appliance really resolving `gix-worktree` — cannot
/// be reproduced without putting the porcelain facade back, so what is asserted
/// instead is that the **detector** works: a crate that genuinely *is* in the
/// appliance graph must be caught by exactly the matching this test uses. If
/// `contains` were being run over an empty string, a stale file, or a
/// mis-shaped name, this fails and the guard above is revealed as blind.
///
/// The probe was `gix-odb` — "what the `fs` backend moved **onto** in the
/// fix, … not a name anyone will remove by accident" — and it WAS removed,
/// by design rather than by accident: the engine collapse took the appliance
/// off gix's object database entirely, and MEASURED on 2026-08-26 the
/// `--features server` graph resolves exactly three gix crates —
/// `gix-parallel`, `gix-hash`, `gix-trace`. The probe was red for a change
/// that made the appliance's diet *stricter*, which is this test failing at
/// its own job: it must name a crate present by construction TODAY.
///
/// `gix-hash` is that crate now: it is the object-id vocabulary
/// (`gunnar-store`'s `git_storage_trait` speaks it), so it cannot leave the
/// graph without the storage contract itself changing shape — at which point
/// this line failing is the least of what needs re-reading.
#[test]
fn the_forbidden_list_would_actually_catch_a_crate_that_is_there() {
    let appliance = tree(&["--no-default-features", "--features", "server"]);
    assert!(
        appliance.contains("gix-hash v"),
        "the appliance graph does not contain gix-hash, so the forbidden-name \
         matching in this file is not testing what it appears to test:\n{appliance}"
    );
    // And the trailing ` v` really is what stops a substring match: `gix-has`
    // is a prefix of `gix-hash` and must not be reported as present.
    assert!(
        !appliance.contains("gix-has v"),
        "a partial crate name matched, so the ` v` anchor is not doing its job"
    );
}

/// The server half must not link the systemd installer unless asked, and that
/// is a Cargo guarantee rather than a convention. This is where the guarantee
/// is checked.
#[test]
fn the_systemd_feature_is_not_paid_for_unless_it_is_on() {
    let server_only = tree(&["--no-default-features", "--features", "server"]);
    assert!(
        !server_only.contains("skidbladnir"),
        "the appliance build resolved skidbladnir despite the feature being off:\n{server_only}"
    );
    assert!(
        !server_only.contains("zbus"),
        "the appliance build resolved zbus, skidbladnir's non-optional D-Bus stack:\n{server_only}"
    );
    let default = tree(&[]);
    assert!(
        !default.contains("skidbladnir"),
        "the DEFAULT build resolved skidbladnir; the feature is meant to be off by \
         default:\n{default}"
    );
}

/// **The community build links no entitlement code; the metered build does.**
///
/// The metered variant: `metered` is a cargo feature for the same reason
/// `wg-client` is — the self-hoster's appliance does not DECLINE to enforce
/// plans, it does not CONTAIN the code. On the resolved graph that reads as
/// `monetize-embed` (the verified entitlement cache, and the only crate the
/// feature links) being absent from `--features server` and present from
/// `--features server,metered`. Asserted in both directions, like every other
/// split in this file: a `cargo tree` that silently produced no `metered`
/// graph would otherwise pass the absence half for free. The symbol-table
/// half of the same proof — `entitlement` families absent from the community
/// BINARY — is `gunnar-build-thing/tests/metered_symbols.rs`.
#[test]
fn the_community_build_links_no_entitlement_code_and_the_metered_one_does() {
    let community = tree(&["--no-default-features", "--features", "server"]);
    for crate_ in ["monetize-embed v", "monetize-product v"] {
        assert!(
            !community.contains(crate_),
            "`--no-default-features --features server` links {crate_}: the community \
             appliance carries plan enforcement it is not supposed to CONTAIN:\n{community}"
        );
    }
    let metered = tree(&["--no-default-features", "--features", "server,metered"]);
    assert!(
        metered.contains("monetize-embed v"),
        "`--features server,metered` does NOT link monetize-embed, so the absence above is \
         about a feature that resolves to nothing:\n{metered}"
    );
    assert!(
        metered.contains("gunnar-server v"),
        "the metered graph must still be the server:\n{metered}"
    );
}