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

nordisk/edda

crates/storage-git-gix/parked-gix-2/gunnar-store-gix/tests/repack.rs

#![cfg(feature = "fs")]

//! `repack()`: S-006, the compaction that never existed.
//!
//! LAW 2 governs this file. A repack is a **destructive** operation whose whole
//! promise is that nothing is lost, and a test that only ever runs it to
//! completion on a healthy repository cannot tell that promise from luck. So:
//!
//! * the interrupted repack is asserted to have been *actually* interrupted:
//!   if the cancellation never fired, the test fails loudly rather than passing
//!   as if it had proved something,
//! * the killed repack is a **real `SIGKILL` of a real second process** in the
//!   middle of the swap, after which the old state is asserted to still serve,
//! * the pruning flag is asserted **both ways**: the object it drops is asserted
//!   to *survive* without it, so the flag is seen to be the thing that did it,
//! * the closure walk this file leans on is itself asserted to go **red** on a
//!   deliberately absent object, in `src/connectivity.rs`, over the mem backend
//!   so the object is genuinely gone rather than merely unlinked,
//! * every object is compared **byte for byte**, not by count. A repack that
//!   returned different bytes under the right ids would pass any count.
//!
//! And the interoperability check is the real `git` binary cloning the result,
//! because gunnar reading back what gunnar wrote can be wrong at both ends.

use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};

use gix_hash::{Kind, ObjectId};
use gunnar_store::connectivity;
use gunnar_store::fs::FsStore;
use gunnar_store::repack::{DeltaPolicy, RepackOptions, RepackOutcome};
use gunnar_store::{ObjectStore, RefStore};

mod common;
use common::{fold_into_pack, pack_dir, packs, read_everything};

// ── the `git` binary ─────────────────────────────────────────────────────────

fn git_available() -> bool {
    Command::new("git")
        .arg("--version")
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
}

/// **Everything about a `git` invocation that failed** — S-046.
///
/// `real_git_clones_a_repacked_repository` failed once under a saturated
/// workspace run and has not reproduced since: 399 isolated runs and 5 whole-suite
/// runs at load average 31–176, zero failures. The entry's own instruction is
/// therefore to stop running loops and **capture the failure text for when it next
/// happens**, because the one observed failure left none and so *what* broke is
/// still unknown.
///
/// What the previous report dropped, all of which narrows that question:
///
/// * the **exit status**. `128` (a git usage/repo error), `141` (`SIGPIPE`) and
///   `137` (`SIGKILL`, i.e. the OOM killer, which is a live hypothesis under a
///   saturated box) are four different diagnoses and the old message told them
///   apart not at all — it printed only stderr, and a process killed by a signal
///   frequently has none.
/// * **stdout**. `git fsck` reports corruption there, not on stderr, so the single
///   most likely genuine failure in this test had its evidence discarded.
/// * the **working directory**, which is a `tempfile` path and the only way to
///   correlate a failure with what was on disk.
///
/// Split out from [`git`] so it is reachable without `catch_unwind`:
/// `a_failing_git_reports_its_status_stdout_and_stderr` calls this directly, which
/// is the only way a panic-formatting path gets to be seen red.
fn git_report(cwd: &Path, args: &[&str], out: &std::process::Output) -> String {
    use std::os::unix::process::ExitStatusExt;
    let status = match (out.status.code(), out.status.signal()) {
        (Some(c), _) => format!("exit status {c}"),
        (None, Some(s)) => format!("killed by signal {s}"),
        (None, None) => "neither an exit code nor a signal".to_string(),
    };
    format!(
        "git {args:?} in {} failed: {status}\n--- stdout ---\n{}\n--- stderr ---\n{}",
        cwd.display(),
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr),
    )
}

fn git(cwd: &Path, args: &[&str]) -> String {
    let out = Command::new("git")
        .current_dir(cwd)
        .args(args)
        // A committer identity and a deterministic clock, so nothing here
        // depends on the machine's git configuration.
        .env("GIT_AUTHOR_NAME", "Gunnar Test")
        .env("GIT_AUTHOR_EMAIL", "gunnar@example.invalid")
        .env("GIT_AUTHOR_DATE", "1700000000 +0000")
        .env("GIT_COMMITTER_NAME", "Gunnar Test")
        .env("GIT_COMMITTER_EMAIL", "gunnar@example.invalid")
        .env("GIT_COMMITTER_DATE", "1700000000 +0000")
        .env("GIT_CONFIG_GLOBAL", "/dev/null")
        .env("GIT_CONFIG_SYSTEM", "/dev/null")
        .output()
        .expect("could not run git");
    assert!(out.status.success(), "{}", git_report(cwd, args, &out));
    String::from_utf8_lossy(&out.stdout).trim_end().to_owned()
}

/// S-046's capture, seen red on its own terms.
///
/// A real failing `git` — `rev-parse` in a directory that is not a repository,
/// which exits 128 — and the report must name the status and carry both streams.
/// The old report printed stderr alone, so it went red here on the status.
#[test]
fn a_failing_git_reports_its_status_stdout_and_stderr() {
    assert!(git_available(), "this test requires `git` on PATH");
    let dir = tempfile::tempdir().expect("temporary directory");
    let args = ["rev-parse", "--verify", "HEAD"];
    let out = Command::new("git")
        .current_dir(dir.path())
        .args(args)
        .env("GIT_CONFIG_GLOBAL", "/dev/null")
        .env("GIT_CONFIG_SYSTEM", "/dev/null")
        .output()
        .expect("could not run git");
    // The premise. If a future git resolved HEAD outside a repository this test
    // would be exercising the success path, so it says so rather than passing
    // having proved nothing.
    assert!(
        !out.status.success(),
        "premise: `git rev-parse` outside a repository must fail, or this proves nothing"
    );

    let report = git_report(dir.path(), &args, &out);
    assert!(
        report.contains("exit status 128"),
        "the report must name the exit status — 128, 137 and 141 are different diagnoses: {report}"
    );
    assert!(
        report.contains(&dir.path().display().to_string()),
        "and the working directory, which is the only handle on the fixture: {report}"
    );
    assert!(
        report.contains("--- stdout ---") && report.contains("--- stderr ---"),
        "and BOTH streams — `git fsck` reports corruption on stdout: {report}"
    );
    assert!(
        report.contains("not a git repository") || report.contains("Not a git repository"),
        "and git's own words are carried, not just the frame: {report}"
    );
}

fn object_format(hash: Kind) -> &'static str {
    match hash {
        Kind::Sha256 => "sha256",
        _ => "sha1",
    }
}

// ── looking at the pack directory ────────────────────────────────────────────

/// Scratch files a repack left behind, which a completed one must not have.
fn scratch_files(git_dir: &Path) -> Vec<PathBuf> {
    std::fs::read_dir(pack_dir(git_dir))
        .map(|entries| {
            entries
                .flatten()
                .map(|e| e.path())
                .filter(|p| {
                    p.file_name()
                        .and_then(|n| n.to_str())
                        .is_some_and(|n| n.starts_with("gunnar-repack-"))
                })
                .collect()
        })
        .unwrap_or_default()
}

fn refs_of(store: &FsStore) -> Vec<(String, String)> {
    RefStore::iter(&store.refs(), None)
        .expect("read the refs")
        .into_iter()
        .map(|(name, target)| (name.to_string(), format!("{target:?}")))
        .collect()
}

// ── fixtures ─────────────────────────────────────────────────────────────────

/// A bare repository that three **real `git push`es** have filled, so it holds
/// three separate packs, precisely the state S-006 says accumulates for ever.
struct GitFixture {
    _dir: tempfile::TempDir,
    git_dir: PathBuf,
    work: PathBuf,
    store: FsStore,
    /// The blob written straight into the store and pointed at by nothing.
    unreachable: ObjectId,
    /// The content of the file at the tip, for the clone check.
    tip_content: String,
    tip: String,
}

fn git_fixture(hash: Kind) -> GitFixture {
    let dir = tempfile::tempdir().expect("temporary directory");
    let git_dir = dir.path().join("repo.git");
    let store = FsStore::open_or_init(&git_dir, hash).expect("create the bare repository");

    // Without this, git unpacks a small push into loose objects and the
    // multi-pack state this whole test file is about never arises.
    git(
        dir.path(),
        &[
            "--git-dir",
            git_dir.to_str().unwrap(),
            "config",
            "receive.unpackLimit",
            "1",
        ],
    );

    let work = dir.path().join("work");
    std::fs::create_dir_all(&work).expect("create the working tree");
    git(
        &work,
        &[
            "init",
            "--quiet",
            &format!("--object-format={}", object_format(hash)),
            "--initial-branch=main",
            ".",
        ],
    );

    let mut tip_content = String::new();
    for round in 0..3 {
        tip_content = format!("gunnar repack round {round} over {hash}\n");
        std::fs::write(work.join("hello.txt"), &tip_content).expect("write the file");
        // A second, larger file per round, so the packs hold trees worth
        // deltifying rather than one blob each.
        std::fs::write(work.join("big.txt"), "x".repeat(4096 * (round + 1)))
            .expect("write the file");
        git(&work, &["add", "hello.txt", "big.txt"]);
        git(
            &work,
            &["commit", "--quiet", "-m", &format!("commit {round}")],
        );
        git(
            &work,
            &[
                "push",
                "--quiet",
                git_dir.to_str().unwrap(),
                "HEAD:refs/heads/main",
            ],
        );
    }
    let tip = git(&work, &["rev-parse", "HEAD"]);

    // One loose object nothing points at: what `prune_unreachable` is for, and
    // what it must NOT remove when it is off.
    let unreachable = store
        .objects()
        .write(gix_object::Kind::Blob, b"nothing points at me\n")
        .expect("write a loose object");

    assert_eq!(
        packs(&git_dir).len(),
        3,
        "the fixture did not produce three packs, so nothing after this proves \
         anything about folding several packs into one"
    );

    GitFixture {
        _dir: dir,
        git_dir,
        work,
        store,
        unreachable,
        tip_content,
        tip,
    }
}

/// Every object id the store holds, in a stable order.
fn all_ids(git_dir: &Path) -> Vec<ObjectId> {
    // Read through the real `git` binary rather than through gunnar's own
    // iteration: the set a repack must preserve is the set git can see, and
    // asking gunnar for it would let one bug hide another.
    let listing = git(
        git_dir,
        &[
            "--git-dir",
            git_dir.to_str().unwrap(),
            "cat-file",
            "--batch-check=%(objectname)",
            "--batch-all-objects",
        ],
    );
    listing
        .lines()
        .filter(|l| !l.trim().is_empty())
        .map(|l| ObjectId::from_hex(l.trim().as_bytes()).expect("git printed an object id"))
        .collect()
}

// ── 1. the consolidation itself ──────────────────────────────────────────────

fn folds_every_pack_and_loose_object_into_one(hash: Kind) {
    let f = git_fixture(hash);
    let ids = all_ids(&f.git_dir);
    assert!(
        ids.len() > 5,
        "the fixture has too few objects to be interesting: {}",
        ids.len()
    );
    assert!(
        ids.contains(&f.unreachable),
        "the unreachable blob is not in the store, so the prune tests below \
         would prove nothing"
    );

    let before = read_everything(&f.store, &ids);
    let refs_before = refs_of(&f.store);
    let old_packs = packs(&f.git_dir);
    let reachable_before =
        connectivity::check(&f.store.objects(), &f.store.refs()).expect("connectivity before");

    // `Preserve`, explicitly, and that is the point of this test rather than an
    // inconvenience worked around. It is `P-001`'s guard: it asserts that entries
    // sitting in a pack are **copied** rather than re-deflated. The default
    // policy looks at how deltified the packs are and re-encodes when they are
    // poor, which this fixture's are (7.1%), so a default here would quietly
    // stop testing pack-copy at all while staying green.
    let outcome = f
        .store
        .repack(&RepackOptions::new().delta_policy(DeltaPolicy::Preserve))
        .expect("the repack succeeds");

    assert!(outcome.rewrote, "the repack reported nothing to do");
    assert_eq!(
        outcome.packs_folded, 3,
        "the three pushed packs were not all folded: {outcome:?}"
    );
    assert!(
        outcome.loose_folded >= 1,
        "the loose object was not folded: {outcome:?}"
    );
    assert_eq!(
        outcome.objects_pruned, 0,
        "nothing was asked to be pruned, so nothing may be"
    );

    // ── the incoming tier is gone ────────────────────────────────────────────
    let new_packs = packs(&f.git_dir);
    assert_eq!(
        new_packs.len(),
        1,
        "the repository still has {} packs: {new_packs:?}",
        new_packs.len()
    );
    for old in &old_packs {
        assert!(
            !old.exists(),
            "a folded pack is still on disk: {}",
            old.display()
        );
        assert!(
            !old.with_extension("idx").exists(),
            "a folded pack index is still on disk: {}",
            old.display()
        );
    }
    assert!(
        scratch_files(&f.git_dir).is_empty(),
        "the repack left its scratch file behind"
    );
    assert_eq!(
        outcome
            .pack_id
            .map(|id| format!("pack-{}.pack", id.to_hex())),
        new_packs[0]
            .file_name()
            .map(|n| n.to_string_lossy().into_owned()),
        "the reported pack id is not the pack that is on disk"
    );

    // ── nothing was lost, byte for byte ──────────────────────────────────────
    let after = read_everything(&f.store, &ids);
    assert_eq!(
        after.len(),
        before.len(),
        "the object count changed across the repack"
    );
    for (id, (kind, bytes)) in &before {
        let (kind_after, bytes_after) = after
            .get(id)
            .unwrap_or_else(|| panic!("object {id} disappeared across the repack"));
        assert_eq!(
            kind_after, kind,
            "object {id} changed kind across the repack"
        );
        assert_eq!(
            bytes_after, bytes,
            "object {id} came back with different bytes across the repack"
        );
    }

    // ── the refs, and their whole closure, still resolve ─────────────────────
    assert_eq!(
        refs_of(&f.store),
        refs_before,
        "the refs moved during a repack"
    );
    let reachable_after =
        connectivity::check(&f.store.objects(), &f.store.refs()).expect("connectivity after");
    assert_eq!(
        reachable_after, reachable_before,
        "the closure of the refs changed size across the repack"
    );
    assert!(reachable_before > 0, "the fixture has no reachable objects");

    // ── P-001: the deltas and the client's deflate survived ──────────────────
    //
    // Without this, a repack that inflated and recompressed every object would
    // pass every assertion above while quietly costing what P-001 exists to
    // recover. A byte count cannot see the difference; this can.
    assert_eq!(
        outcome.objects_copied_from_pack + outcome.objects_recompressed + outcome.objects_deltified,
        outcome.objects as usize,
        "the generator's own accounting does not add up: {outcome:?}"
    );
    assert!(
        outcome.objects_copied_from_pack > 0,
        "not one object was pack-copied. Every object was inflated and \
         re-deflated, which is exactly what P-001 says must not happen: {outcome:?}"
    );
    assert_eq!(
        outcome.objects_deltified, 0,
        "the copying producer computed a delta, which it has no encoder to \
         compute with: {outcome:?}"
    );
}

#[test]
fn a_repack_folds_every_pack_and_loose_object_into_one_sha1() {
    assert!(git_available(), "this test requires `git` on PATH");
    folds_every_pack_and_loose_object_into_one(Kind::Sha1);
}

#[test]
fn a_repack_folds_every_pack_and_loose_object_into_one_sha256() {
    assert!(git_available(), "this test requires `git` on PATH");
    folds_every_pack_and_loose_object_into_one(Kind::Sha256);
}

// ── 2. the second repack is a no-op ──────────────────────────────────────────

#[test]
fn a_repack_with_nothing_to_do_does_not_rewrite_the_pack() {
    assert!(git_available(), "this test requires `git` on PATH");
    let f = git_fixture(Kind::Sha256);
    // `Preserve` on both runs. This test is about the *consolidation* no-op —
    // one pack in, one pack out, not a byte moved — and letting the delta policy
    // decide would make it a test of the policy instead, green or red depending
    // on how deltified `git` happened to leave this fixture.
    let quiet = || RepackOptions::new().delta_policy(DeltaPolicy::Preserve);
    let first = f.store.repack(&quiet()).expect("first repack");
    assert!(first.rewrote);

    let packs_after_first = packs(&f.git_dir);
    let stamp = |p: &Path| {
        let meta = std::fs::metadata(p).expect("the pack is on disk");
        (meta.len(), meta.modified().expect("mtime"))
    };
    let before: Vec<_> = packs_after_first.iter().map(|p| stamp(p)).collect();

    let second = f.store.repack(&quiet()).expect("second repack");
    assert!(
        !second.rewrote,
        "a repack of an already-consolidated repository rewrote it: {second:?}"
    );
    assert_eq!(
        RepackOutcome {
            // The composition of the pack is *observed* on every run, including
            // one that changes nothing, so these two are not part of "reported
            // no work". They are asserted on their own terms below.
            delta_fraction_before: None,
            delta_fraction_after: None,
            ..second.clone()
        },
        RepackOutcome::default(),
        "a no-op reported work: {second:?}"
    );
    assert_eq!(
        second.delta_fraction_before, second.delta_fraction_after,
        "a repack that changed nothing changed the pack's delta composition: {second:?}"
    );
    assert!(
        second.delta_fraction_before.is_some(),
        "the pack composition was not measured at all, so `DeltaPolicy::Auto` \
         had nothing to decide on: {second:?}"
    );
    assert_eq!(
        packs(&f.git_dir),
        packs_after_first,
        "the no-op repack changed which packs exist"
    );
    let after: Vec<_> = packs_after_first.iter().map(|p| stamp(p)).collect();
    assert_eq!(
        after, before,
        "the no-op repack rewrote the pack file: same name, different bytes or time"
    );
    assert!(
        scratch_files(&f.git_dir).is_empty(),
        "the no-op repack created a scratch file"
    );
}

// ── 3. real git still clones it ──────────────────────────────────────────────

#[test]
fn real_git_clones_a_repacked_repository() {
    assert!(git_available(), "this test requires `git` on PATH");
    for hash in [Kind::Sha1, Kind::Sha256] {
        // S-046: the loop runs twice and the one observed failure did not say
        // which half it was in. Every assertion below names the hash kind, because
        // "sha1 only" and "both" are different bugs.
        let which = object_format(hash);
        let f = git_fixture(hash);
        let outcome = f
            .store
            .repack(&RepackOptions::new())
            .unwrap_or_else(|e| panic!("{which}: repack failed: {e}"));
        assert!(
            outcome.rewrote,
            "{which}: repack reported it rewrote nothing"
        );

        let dest = f.work.parent().expect("the temp dir").join("clone");
        git(
            f.work.parent().expect("the temp dir"),
            &[
                "clone",
                "--quiet",
                f.git_dir.to_str().unwrap(),
                dest.to_str().unwrap(),
            ],
        );
        assert_eq!(
            git(&dest, &["rev-parse", "HEAD"]),
            f.tip,
            "{which}: the clone of a repacked repository is at a different commit"
        );
        assert_eq!(
            std::fs::read_to_string(dest.join("hello.txt")).expect("the cloned file"),
            f.tip_content,
            "{which}: the clone of a repacked repository has different content"
        );
        // git's own verdict on the pack gunnar wrote, not gunnar's. Run
        // against the SERVER's repository, which is where that pack is.
        git(
            &f.git_dir,
            &[
                "--git-dir",
                f.git_dir.to_str().unwrap(),
                "fsck",
                "--no-progress",
                "--no-dangling",
            ],
        );
    }
}

// ── 4. cancelling mid-repack ─────────────────────────────────────────────────

/// A repository with enough objects that the generation phase lasts long enough
/// to be caught in the act, and two packs, so there is something to fold.
struct BulkFixture {
    _dir: tempfile::TempDir,
    git_dir: PathBuf,
    store: FsStore,
}

fn bulk_fixture(blobs_per_pack: usize) -> BulkFixture {
    let hash = Kind::Sha256;
    let dir = tempfile::tempdir().expect("temporary directory");
    let git_dir = dir.path().join("bulk.git");
    let store = FsStore::open_or_init(&git_dir, hash).expect("create");
    build_bulk(&store, &git_dir, blobs_per_pack);
    BulkFixture {
        _dir: dir,
        git_dir,
        store,
    }
}

/// Two packs of `blobs_per_pack` blobs each, plus a tree and a commit that
/// reach all of them, and a ref at the commit.
fn build_bulk(store: &FsStore, git_dir: &Path, blobs_per_pack: usize) {
    let objects = store.objects();
    let hash = store.hash_kind();
    let mut all = Vec::new();
    let mut entries: Vec<(String, ObjectId)> = Vec::new();

    for round in 0..2 {
        let mut batch = Vec::new();
        for n in 0..blobs_per_pack {
            let name = format!("f{round}-{n:05}");
            // Compressible, and distinct per blob, so the pack is big enough to
            // take real time to write.
            let content = format!("{name}\n{}\n", "gunnar ".repeat(512));
            let id = objects
                .write(gix_object::Kind::Blob, content.as_bytes())
                .expect("write a blob");
            batch.push(id);
            entries.push((name, id));
        }
        all.extend_from_slice(&batch);
        fold_into_pack(git_dir, &objects, hash, &batch);
    }

    // A tree and a commit over every blob, so the closure covers them all.
    entries.sort();
    let mut tree = Vec::new();
    for (name, id) in &entries {
        tree.extend_from_slice(b"100644 ");
        tree.extend_from_slice(name.as_bytes());
        tree.push(0);
        tree.extend_from_slice(id.as_bytes());
    }
    let tree_id = objects
        .write(gix_object::Kind::Tree, &tree)
        .expect("write the tree");
    let commit = format!(
        "tree {}\nauthor Gunnar Test <gunnar@example.invalid> 1700000000 +0000\n\
         committer Gunnar Test <gunnar@example.invalid> 1700000000 +0000\n\nbulk\n",
        tree_id.to_hex()
    );
    let commit_id = objects
        .write(gix_object::Kind::Commit, commit.as_bytes())
        .expect("write the commit");
    all.push(tree_id);
    all.push(commit_id);

    store
        .refs()
        .transaction(vec![gunnar_store::RefEdit::force(
            gunnar_store::FullName::new("refs/heads/main").expect("a valid ref name"),
            gunnar_store::Target::Object(commit_id),
        )])
        .expect("point a ref at the commit");
}

/// Watch for the scratch file and run `on_seen` the instant it appears.
///
/// Returns whether it ever appeared. A caller that gets `false` has proved
/// nothing and must say so.
fn when_repack_starts_writing(git_dir: &Path, deadline: Duration, on_seen: impl FnOnce()) -> bool {
    let started = Instant::now();
    while started.elapsed() < deadline {
        if !scratch_files(git_dir).is_empty() {
            on_seen();
            return true;
        }
        std::thread::sleep(Duration::from_micros(100));
    }
    false
}

#[test]
fn an_interrupted_repack_leaves_the_old_state_serving() {
    let f = bulk_fixture(600);
    let ids: Vec<ObjectId> = {
        // Through gunnar here; this fixture is built without `git`.
        let mut v: Vec<ObjectId> = Vec::new();
        for (_, target) in RefStore::iter(&f.store.refs(), None).expect("refs") {
            if let gunnar_store::Target::Object(id) = target {
                v.push(id);
            }
        }
        let closure = connectivity::closure(&f.store.objects(), &v).expect("closure");
        closure.into_iter().collect()
    };
    let before = read_everything(&f.store, &ids);
    let old_packs = packs(&f.git_dir);
    assert_eq!(old_packs.len(), 2, "the fixture should hold two packs");

    let flag = Arc::new(AtomicBool::new(false));
    let watcher = {
        let flag = Arc::clone(&flag);
        let git_dir = f.git_dir.clone();
        std::thread::spawn(move || {
            when_repack_starts_writing(&git_dir, Duration::from_secs(30), || {
                flag.store(true, Ordering::SeqCst)
            })
        })
    };

    let err = f
        .store
        .repack(&RepackOptions::new().should_interrupt(Arc::clone(&flag)))
        .expect_err("the repack was cancelled and must report it");
    let fired = watcher.join().expect("the watcher thread");
    assert!(
        fired,
        "the cancellation never fired, so this test did not interrupt anything \
         and proves nothing about a cancelled repack"
    );
    assert!(
        err.is_interrupted(),
        "a cancelled repack reported a fault instead: {err:?}"
    );

    // ── the old state is exactly as it was ───────────────────────────────────
    assert_eq!(
        packs(&f.git_dir),
        old_packs,
        "a cancelled repack changed which packs exist"
    );
    assert!(
        scratch_files(&f.git_dir).is_empty(),
        "a cancelled repack left its scratch file behind"
    );
    assert_eq!(
        read_everything(&f.store, &ids),
        before,
        "objects changed across a cancelled repack"
    );
    connectivity::check(&f.store.objects(), &f.store.refs())
        .expect("the closure still resolves after a cancelled repack");

    // ── and the retry works ──────────────────────────────────────────────────
    let outcome = f
        .store
        .repack(&RepackOptions::new())
        .expect("the retried repack succeeds");
    assert!(outcome.rewrote);
    assert_eq!(packs(&f.git_dir).len(), 1);
    assert_eq!(
        read_everything(&f.store, &ids),
        before,
        "objects changed across the retried repack"
    );
}

// ── 5. a real kill, in the middle of the swap ────────────────────────────────

/// The environment variable that turns the helper below into the child process.
const CHILD_ENV: &str = "GUNNAR_REPACK_CRASH_CHILD";
/// The child writes this the instant it finishes a repack. Its **absence** is
/// what says the kill landed mid-flight.
const COMPLETED_MARKER: &str = "child-completed";

/// The child of [`a_repack_killed_mid_flight_leaves_the_old_state_serving`].
///
/// `#[ignore]`d on purpose: it is not a test, it is the other half of one, and
/// the normal run must not execute it. It is reached only by the parent
/// re-executing this binary with `--ignored --exact`, which is also why it
/// asserts nothing itself. Everything is asserted by the parent, on the
/// corpse.
#[test]
#[ignore = "the child half of a_repack_killed_mid_flight_leaves_the_old_state_serving"]
fn repack_crash_child() {
    let Ok(git_dir) = std::env::var(CHILD_ENV) else {
        panic!("{CHILD_ENV} is not set; this is not a test, it is a child process body");
    };
    let git_dir = PathBuf::from(git_dir);
    let store = FsStore::open_or_init(&git_dir, Kind::Sha256).expect("open the repository");
    let outcome = store.repack(&RepackOptions::new()).expect("repack");
    std::fs::write(
        git_dir.join(COMPLETED_MARKER),
        format!("{outcome:?}").as_bytes(),
    )
    .expect("write the completion marker");
}

#[test]
fn a_repack_killed_mid_flight_leaves_the_old_state_serving() {
    // The fixture is built here, then handed over: the parent must know the
    // state the child is about to be killed in the middle of.
    let dir = tempfile::tempdir().expect("temporary directory");
    let git_dir = dir.path().join("killed.git");
    let ids: Vec<ObjectId>;
    let before;
    let old_packs;
    {
        let store = FsStore::open_or_init(&git_dir, Kind::Sha256).expect("create");
        build_bulk(&store, &git_dir, 600);
        let tips = connectivity::ref_tips(&store.refs()).expect("tips");
        ids = connectivity::closure(&store.objects(), &tips)
            .expect("closure")
            .into_iter()
            .collect();
        before = read_everything(&store, &ids);
        old_packs = packs(&git_dir);
        assert_eq!(old_packs.len(), 2);
        // The child needs the store's exclusive lock, so this handle goes first.
    }

    let mut child = Command::new(std::env::current_exe().expect("the test binary"))
        .args(["--exact", "repack_crash_child", "--ignored", "--nocapture"])
        .env(CHILD_ENV, &git_dir)
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .spawn()
        .expect("spawn the child that will be killed");

    let killed = when_repack_starts_writing(&git_dir, Duration::from_secs(60), || {
        // SIGKILL. Not a cancellation, not a graceful stop: the case §4.4 says
        // must leave the old archive intact.
        child.kill().expect("kill the child mid-repack");
    });
    let status = child.wait().expect("reap the child");
    assert!(
        killed,
        "the child finished its repack before it could be killed, so nothing \
         about a crash mid-repack was exercised (child exited {status:?})"
    );
    assert!(
        !git_dir.join(COMPLETED_MARKER).exists(),
        "the child reported a completed repack despite being killed; this test \
         did not catch it mid-flight and proves nothing"
    );
    assert!(
        !status.success(),
        "the killed child exited successfully: {status:?}"
    );

    // No `force_unlock` here, deliberately (S-019). The `O_EXCL` lock the
    // SIGKILLed child took does not release itself, and until S-019 this line
    // was a call to `force_unlock` — the verb an appliance running as PID 1 in
    // a distroless image has nobody to type. `open` now reclaims a lock whose
    // owner is provably gone, and this reopen is the applied-output proof of
    // it: a real process, a real SIGKILL, a real stale lock file on disk.
    let store = FsStore::open_or_init(&git_dir, Kind::Sha256)
        .expect("reopen after the crash: the killed process's lock was not reclaimed");

    // ── the old state survived the kill ──────────────────────────────────────
    for old in &old_packs {
        assert!(
            old.exists(),
            "a pack the repack had not finished replacing was already deleted: {}",
            old.display()
        );
    }
    assert_eq!(
        read_everything(&store, &ids),
        before,
        "objects changed across a repack that was killed"
    );
    connectivity::check(&store.objects(), &store.refs())
        .expect("the closure still resolves after a killed repack");

    // ── and the retry cleans up after it ─────────────────────────────────────
    let outcome = store
        .repack(&RepackOptions::new())
        .expect("the retried repack succeeds");
    assert!(
        outcome.rewrote,
        "the retry found nothing to do: {outcome:?}"
    );
    assert_eq!(packs(&git_dir).len(), 1);
    assert!(
        scratch_files(&git_dir).is_empty(),
        "the retry did not sweep the scratch file the killed run left behind"
    );
    assert_eq!(
        read_everything(&store, &ids),
        before,
        "objects changed across the retried repack"
    );
}

// ── 6. pruning, asserted both ways ───────────────────────────────────────────

#[test]
fn pruning_drops_only_what_no_reference_reaches() {
    assert!(git_available(), "this test requires `git` on PATH");
    // ── red: without the flag, the unreachable object stays ──────────────────
    {
        let f = git_fixture(Kind::Sha256);
        f.store.repack(&RepackOptions::new()).expect("repack");
        assert!(
            f.store.objects().contains(&f.unreachable),
            "an unreachable object was dropped by a repack that was NOT asked \
             to prune, so the default is not safe"
        );
    }

    // ── red, the other way: with the flag but the DEFAULT grace, it stays ────
    //
    // Not an aside. The grace is what makes `--prune` safe against a live push
    // (see `RepackOptions::prune_grace`), and a fixture built a millisecond ago
    // is entirely inside it — so a prune that dropped this blob would be a
    // prune that had lost its grace period, and every other assertion in this
    // test would still pass.
    {
        let f = git_fixture(Kind::Sha256);
        let outcome = f
            .store
            .repack(&RepackOptions::new().prune_unreachable(true))
            .expect("the pruning repack under the default grace");
        assert_eq!(
            outcome.objects_pruned, 0,
            "`--prune` under the default two-week grace dropped an object written a \
             moment ago: {outcome:?}"
        );
        assert!(
            f.store.objects().contains(&f.unreachable),
            "an object written a moment ago was pruned; the grace period is not being \
             applied, and a concurrent push would lose its payload"
        );
    }

    // ── green: with the flag and no grace, it goes, and nothing reachable does
    let f = git_fixture(Kind::Sha256);
    let reachable_before = connectivity::reachable(&f.store.objects(), &f.store.refs())
        .expect("the reachable set before");
    assert!(
        !reachable_before.contains(&f.unreachable),
        "the fixture's 'unreachable' blob is reachable, so this test would pass \
         for the wrong reason"
    );
    let all_before = all_ids(&f.git_dir).len();

    let outcome = f
        .store
        .repack(
            &RepackOptions::new()
                .prune_unreachable(true)
                .prune_grace(std::time::Duration::ZERO),
        )
        .expect("the pruning repack");

    assert!(outcome.rewrote);
    assert_eq!(
        outcome.objects_pruned, 1,
        "exactly the one unreachable blob should have been pruned: {outcome:?}"
    );
    assert!(
        !f.store.objects().contains(&f.unreachable),
        "the unreachable blob survived a pruning repack"
    );
    assert_eq!(
        outcome.objects as usize,
        all_before - 1,
        "the pruning repack kept the wrong number of objects: {outcome:?}"
    );
    for id in &reachable_before {
        assert!(
            f.store.objects().contains(id),
            "the pruning repack dropped {id}, which a reference reaches"
        );
    }
    let reachable_after = connectivity::reachable(&f.store.objects(), &f.store.refs())
        .expect("the reachable set after");
    assert_eq!(
        reachable_after, reachable_before,
        "the reachable closure changed across a pruning repack"
    );

    // git's own verdict, after the destructive one.
    let dest = f.work.parent().expect("the temp dir").join("pruned-clone");
    git(
        f.work.parent().expect("the temp dir"),
        &[
            "clone",
            "--quiet",
            f.git_dir.to_str().unwrap(),
            dest.to_str().unwrap(),
        ],
    );
    assert_eq!(git(&dest, &["rev-parse", "HEAD"]), f.tip);
}