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);
}