crates/storage-git-gix/src/lib.rs
//! **`GitOps` over `gix`** — the second implementation of the frozen
//! `git-storage-trait` contract, opposite znippy's Arrow-IPC one.
//!
//! | implementer | how it stores |
//! |---|---|
//! | `znippy-plugin-git` | Arrow-IPC in a znippy archive (OpenZL) |
//! | **`storage-git-gix`** (this crate) | `gix-odb` / `gix-ref` on a plain filesystem |
//!
//! The store is an ordinary **bare git repository**. That is the point of it:
//! whatever this crate writes, stock `git` can read, and `tests/` proves it by
//! running real `git` against the directory rather than by asserting against
//! this crate's own reader.
//!
//! # No Arrow, and it is structural
//!
//! `git-storage-trait` depends on `anyhow` and nothing else, deliberately, so
//! that a gix backend can implement it without linking Arrow and a znippy
//! backend without linking gix. This crate's graph honours that: `cargo tree`
//! contains no `arrow*` crate. Nothing here re-exports a znippy type, and there
//! is no `znippy-common` or `znippy-plugin-git` edge.
//!
//! # No rayon (LAW 3)
//!
//! *"Pure `std::thread::scope`; no global pool, no rayon."* Nothing in this
//! crate spawns a thread at all yet, so the law is satisfied trivially rather
//! than carefully — but the two places a fan-out will eventually go are already
//! decided and written down: [`gc`] (batch tier, fan-out wanted) and
//! [`graph`] (serving tier, serial **on purpose**, `P-004`).
//!
//! `gix-features/parallel` **is** enabled, and that is not a violation: it is
//! `crossbeam-channel` + `parking_lot`, not rayon, verified in gix-features
//! 0.49's manifest. Leaving it off is its own defect — `P-002`, *"the appliance
//! build silently uses the serial code path"*.
//!
//! # What this crate does NOT claim
//!
//! - **No delta encoder**, so [`gc`] sweeps whole dead packs and never rewrites
//! a live one. `P-009`.
//! - **No writer lock.** Two processes writing one store root is not defended
//! against here, exactly as `P-014` says of the other backend.
//!
//! Every one of those is a named, bounded absence rather than a stub that
//! returns a plausible wrong answer.
//!
//! # The reachability bitmap, which this crate now has
//!
//! The list above used to open with *"**No reachability bitmap.** gitoxide has
//! none; [`graph`] walks."* The first half is still true and the second is no
//! longer the whole story. gitoxide has no bitmap support at all — no `ewah`, no
//! reader in `gix-pack`, `gix-odb` or `gix-features` — so edda's `gunnar-bitmap`
//! is the format, [`bitmap`] is the half that needs an object database, [`gc`]
//! writes the file and [`graph`] is the fallback for every request a bitmap
//! cannot answer exactly.
//!
//! What the bitmap replaces is not a micro-cost. A clone off a bitmapped pack is
//! **one `OR` per want and no object read**, against a walk that decodes every
//! commit and every tree in the repository.
pub mod bitmap;
pub mod delta_cache;
pub mod fanout;
pub mod gc;
pub mod graph;
pub mod midx;
pub mod odb;
pub mod packs;
pub mod refs;
pub mod serve;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::RwLock;
use anyhow::{anyhow, Context, Result};
use git_storage_trait::{
Extent, GcReport, GitOps, ObjType, Oid, RefCas, RefRow, RefUpdate, Stored, TxId,
};
use gix_hash::ObjectId;
use crate::packs::PackSet;
/// A bare git repository, serving the eleven.
pub struct GixStore {
git_dir: PathBuf,
hash_kind: gix_hash::Kind,
/// The verbatim-read path: `.idx` + `.pack` + the sorted-offset table.
/// Rebuilt after every `put_pack`, which is why it is behind a lock.
packs: RwLock<PackSet>,
/// The decode path. Used for `size` (a delta's resolved size needs the
/// chain walked) and for [`graph`]'s object reads. Never used to answer
/// `get`, because it cannot — see [`packs`]'s module doc on `P-025`.
///
/// A **pool**, not a handle, and that is `P-012`'s answer lifted out of the
/// quarantine wholesale: every pooled handle is `refresh_never()`, so a miss
/// is a binary search rather than a re-scan of `objects/pack` behind the
/// store's global write lock, and the re-scan is put back exactly where it
/// is needed by [`odb::OdbPool::refresh_if_stale`]'s one `statx`. See
/// [`odb`]'s module doc for the numbers, all of which are **oden's**.
odb: odb::OdbPool,
refs: gix_ref::file::Store,
/// Monotonic within one process. The contract calls `push_seq` "the ref
/// log's ordering authority"; this backend keeps no ref log, so the honest
/// thing it can offer is an in-process sequence, and [`GixStore::put_refs`]
/// documents that it does not survive a restart.
seq: AtomicU64,
/// **One writer over `objects/pack`.** Held by [`GixStore::gc`], which
/// unlinks packs, and by [`midx::MULTI_PACK_INDEX`]'s writer, which names
/// them. Those two interleaved produce an index pointing at a pack that is
/// not there, which is a lookup failure the reader cannot distinguish from a
/// genuine absence — so this is a lock and not a comment.
///
/// It is *not* a store lock. Two processes over one directory are still
/// undefended (`P-014`, and the module header says so).
maint_lock: std::sync::Mutex<()>,
/// The `.bitmap` beside the one pack that has one, re-`stat`ed per request
/// and reloaded when it moves. See [`bitmap::BitmapCache`] for why a stale
/// one is a **wrong** answer rather than a slow one.
bitmaps: bitmap::BitmapCache,
}
impl GixStore {
/// Open an existing bare repository, or create one.
///
/// The hash kind is read from the repository's own `config`
/// (`extensions.objectFormat`), which is where git puts it, so a sha256
/// repository is opened as sha256 without the caller having to know.
///
/// # The `P-008` consequence, stated at the door
///
/// `gix-hash` builds its sha1 hasher as
/// `sha1_checked::Builder::default().safe_hash(false).build()` — collision
/// **detection** stays on, so it runs `sha1-checked`'s pure-Rust
/// `compress::compress` and never reaches `sha1`'s SHA-NI path. A **sha1**
/// repository therefore hashes several times slower than a **sha256** one
/// on this hardware. That matches git's own `sha1dc` default, so it is not
/// a gitoxide defect and this crate does not work around it. It is the
/// reason a benchmark must state which hash kind its fixture used.
pub fn open_or_init(git_dir: impl AsRef<Path>, hash_kind: gix_hash::Kind) -> Result<Self> {
let git_dir = git_dir.as_ref().to_path_buf();
if git_dir.join("HEAD").exists() {
let stored = stored_hash_kind(&git_dir)?;
if stored != hash_kind {
return Err(anyhow!(
"repository at {} is {stored:?}, but was opened as {hash_kind:?}",
git_dir.display()
));
}
} else {
init_bare(&git_dir, hash_kind)?;
}
let objects_dir = git_dir.join("objects");
// gix-odb 0.85 (gix 0.88) takes the hash kind as an argument instead of an
// `Options` field.
let store = gix_odb::Store::at_opts(
objects_dir.clone(),
hash_kind,
&mut std::iter::empty(),
gix_odb::store::init::Options::default(),
)
.with_context(|| format!("open odb at {}", objects_dir.display()))?;
// `OdbPool::new` scans once, so the first lookup is neither wrong nor
// slow, and sizes the slot table from what is on disk.
let odb = odb::OdbPool::new(std::sync::Arc::new(store), &git_dir, hash_kind);
let refs = gix_ref::file::Store::at_opts(
git_dir.clone(),
hash_kind,
gix_ref::store::init::Options {
// A git server has no use for a per-ref reflog file tree, and
// `GitOps` exposes none. Stating it here rather than reading
// `core.logallrefupdates` back is part of what keeps
// `gix-config` out of this crate's build.
write_reflog: gix_ref::store::WriteReflog::Disable,
..Default::default()
},
);
let packs = PackSet::load(&objects_dir.join("pack"), hash_kind)?;
let store = GixStore {
git_dir,
hash_kind,
packs: RwLock::new(packs),
odb,
refs,
seq: AtomicU64::new(0),
maint_lock: std::sync::Mutex::new(()),
bitmaps: bitmap::BitmapCache::default(),
};
// Where git puts it too: `git multi-pack-index write` is maintenance,
// and the one moment a server can afford a pass over every pack is
// before it starts serving. Above [`midx::MULTI_PACK_INDEX_THRESHOLD`]
// packs only, never on the read path, and a failure is logged rather
// than propagated — see the method.
store.write_multi_pack_index_if_worthwhile();
Ok(store)
}
pub fn git_dir(&self) -> &Path {
&self.git_dir
}
pub fn hash_kind(&self) -> gix_hash::Kind {
self.hash_kind
}
/// The underlying `gix-odb` store, for the one caller that needs a handle of
/// its own rather than a lease: `P-001`'s pack-copy source.
///
/// A lease will not do there. `gunnar_pack::PackedObjects` clones its handle
/// once per worker and `entry_by_location` hands out slices pointing into a
/// mapped pack, so that handle needs `prevent_pack_unload()` — which a
/// pooled handle deliberately does **not** have, because it would pin every
/// pack a gc replaced for as long as the handle sat idle in the pool.
pub fn odb_store(&self) -> std::sync::Arc<gix_odb::Store> {
self.odb.store()
}
/// `(staleness probes, of those, full consolidations)` — how many times a
/// miss has asked the filesystem whether a pack appeared, and how often the
/// answer was yes.
///
/// **A count, not a clock**, which is what `locked_on_target.md` §6 asks a
/// bench arm to prefer: it does not move with load, and it is the only thing
/// that can tell a `refresh_never()` pool from the default one. Both answer
/// identically; only this says what the answer cost.
pub fn rescan_counts(&self) -> (u64, u64) {
self.odb.rescan_counts()
}
/// `(slots, times grown)` in the odb's index slot table.
///
/// Observable because the failure it guards against is not: a full table
/// makes `gix-odb` report objects in later packs absent, silently, for the
/// life of the process. See [`odb::OdbPool::grow_slots_if_full`].
pub fn slot_table(&self) -> (u64, u64) {
self.odb.slot_table()
}
/// How many `.keep` markers this process failed to remove.
///
/// **A count, not a log line** — see [`drop_keep`] for why the failure
/// matters here specifically (a stale `.keep` is a pack [`gc`] will never
/// reclaim). Process-wide rather than per-store: the counter exists to be
/// asserted and to be read off a bench row, and no caller has two stores.
pub fn keep_removal_failures() -> u64 {
KEEP_REMOVAL_FAILURES.load(Ordering::Relaxed)
}
/// How many separately-searched pack indices a miss walks — the `M` in
/// `P-012`'s `O(M)`. One `.idx` each, or **exactly one** when a
/// `multi-pack-index` folds them. See [`odb::OdbPool::index_slots`] and
/// [`midx`].
pub fn index_slots(&self) -> usize {
self.odb.index_slots()
}
/// `(selections answered from a bitmap, selections that fell back to the
/// walk)`, process-wide.
///
/// **A count, not a clock**, and it is not optional instrumentation: the two
/// paths are *defined* to return the same object set, so no wire byte, no
/// `fsck` and no exit code can tell them apart. `P-027` is that failure
/// already having happened once — *"five instruments measured the walk path
/// and published it as the bitmap path"* — and a benchmark row quoting a
/// bitmap win it never took is worse than no row.
///
/// Process-wide rather than per-store for the same reason
/// [`GixStore::keep_removal_failures`] is: the counter exists to be asserted
/// off a test and read off a bench row, and no caller has two stores.
pub fn bitmap_selection_counts() -> (u64, u64) {
(
BITMAP_SELECTIONS.load(Ordering::Relaxed),
WALK_SELECTIONS.load(Ordering::Relaxed),
)
}
/// Commits opened by the bitmap path's bounded walk, process-wide.
///
/// The one guard that can see a bounded walk that stopped being bounded.
/// ORing a commit's stored bitmap in and then walking its parents anyway
/// changes **no answer** — a bitmap is closed under reachability, so
/// everything the extra walk finds is already set — so every correctness
/// test in the crate stays green while the cost comes back. Only a count
/// fails on it.
pub fn bitmap_commits_walked() -> u64 {
BITMAP_COMMITS_WALKED.load(Ordering::Relaxed)
}
/// Write a reachability bitmap beside the pack best placed to carry one.
///
/// This is git's `repack -b`, minus the repack (`P-009`: gitoxide has no
/// delta encoder, so this backend never consolidates). Called from
/// [`GitOps::gc`], and public so a maintenance command or a benchmark can
/// ask for it without also sweeping.
///
/// # Which pack, and why "the biggest" is the honest rule
///
/// A `.bitmap`'s positions are **one pack's** positions, so a store with
/// many packs can only have a bitmap over one of them — everything else is
/// reached through the extended index and the bounded walk. The biggest pack
/// is the one that leaves the least for that walk to do.
///
/// [`bitmap::write_for_pack`] then refuses on its own terms if that pack
/// cannot carry an exact answer — a commit whose parent is elsewhere, a tree
/// reaching an object that is not there — and `Ok(None)` is the result, not
/// an error. A store that gets no bitmap is served exactly as it was before.
///
/// # Every other `.bitmap` is unlinked first
///
/// [`bitmap::BitmapCache`] declines a directory holding more than one,
/// because two bitmaps address different position spaces and neither covers
/// the other's objects. Leaving a stale one behind would therefore turn the
/// whole feature **off** rather than produce a wrong answer — quietly, and
/// in exactly the shape a benchmark cannot see.
pub fn write_reachability_bitmap(&self) -> Result<Option<PathBuf>> {
let _one_writer = self
.maint_lock
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
self.write_reachability_bitmap_locked()
}
/// [`GixStore::write_reachability_bitmap`], for a caller already holding
/// [`GixStore::maint_lock`] — `std::sync::Mutex` is not reentrant.
fn write_reachability_bitmap_locked(&self) -> Result<Option<PathBuf>> {
let target = {
let packs = self
.packs
.read()
.map_err(|_| anyhow!("pack set lock poisoned"))?;
packs
.packs
.iter()
.max_by_key(|p| p.num_objects())
.map(|p| (p.idx_path.clone(), p.num_objects()))
};
let Some((idx_path, objects)) = target else {
return Ok(None);
};
if objects == 0 {
return Ok(None);
}
// Stale bitmaps go before the new one lands, so the directory never
// holds two — see the doc above for why that is a correctness matter
// and not tidiness.
let keep = bitmap::path_for(&idx_path);
for entry in std::fs::read_dir(self.pack_dir())?.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "bitmap") && path != keep {
let _ = std::fs::remove_file(&path);
}
}
let written = self
.odb
.with_handle(|odb| bitmap::write_for_pack(&idx_path, self.hash_kind, odb))?;
if written.is_none() {
// Nothing was written, so anything already sitting at `keep` now
// describes a pack that may have moved under it. `PackBitmap::at`
// would catch that by checksum, but leaving it costs a parse per
// reload for a file that can never be used.
let _ = std::fs::remove_file(&keep);
}
// The cache keys on `(path, mtime, len)` and cannot see a *removal*, so
// it is told rather than left to notice.
self.bitmaps.clear();
Ok(written)
}
/// Packs carrying a 16-bit fanout, and the bytes they cost. See
/// [`fanout`] — off unless `GIX_FANOUT16=on`.
pub fn fanout16_footprint(&self) -> Result<(usize, usize)> {
Ok(self
.packs
.read()
.map_err(|_| anyhow!("pack set lock poisoned"))?
.fanout16_footprint())
}
fn pack_dir(&self) -> PathBuf {
self.git_dir.join("objects").join("pack")
}
/// Re-scan `objects/pack`. Called after a pack lands.
///
/// Refreshes the odb pool too, and that is not belt-and-braces: every
/// pooled handle is `refresh_never()`, so nothing else would ever tell them
/// a pack had appeared inside the racy one-second window where the mtime
/// watch cannot. This is git's `reprepare_packed_git()`, called from the
/// writer, which is where git calls it.
fn reload_packs(&self) -> Result<()> {
self.odb.refresh();
let fresh = PackSet::load(&self.pack_dir(), self.hash_kind)?;
*self
.packs
.write()
.map_err(|_| anyhow!("pack set lock poisoned"))? = fresh;
Ok(())
}
/// Turn the contract's borrowed raw oid into gix's.
///
/// The contract's `Oid<'a>` is `&[u8]` — raw bytes, not hex — so a wrong
/// width here is a caller passing a sha1 into a sha256 repository, and it
/// must be an error rather than a silent truncation.
fn oid(&self, raw: Oid<'_>) -> Result<ObjectId> {
ObjectId::try_from(raw).map_err(|_| {
anyhow!(
"object id is {} bytes, but this repository is {:?} ({} bytes)",
raw.len(),
self.hash_kind,
self.hash_kind.len_in_bytes()
)
})
}
/// One object's resolved header, through the pool's retry loop.
///
/// `AskTheDisk`, not `Answer`: a header miss is asked by `size()` and by
/// `get()`'s delta path, and both of those are about an object the caller
/// has reason to believe is here. A stale `false` there is a refused fetch,
/// which is the exact failure `gunnar.endurance` saw — so this one pays the
/// `statx` when it misses, and the retry loop covers the interleavings the
/// `statx` cannot see.
///
/// An **error is not a hit**. `gix-odb` reports an unresolvable ref-delta
/// base as `DeltaBaseMissing` rather than as `Ok(None)`, and a base sitting
/// in a pack written after the snapshot is precisely what the retry is for.
fn header(&self, id: &gix_hash::oid) -> Result<Option<gix_odb::find::Header>> {
use gix_odb::Header as _;
self.odb
.lookup(
|odb| odb.try_header(id),
|r: &std::result::Result<_, _>| matches!(r, Ok(Some(_))),
)
.map_err(|e| anyhow!("header for {id}: {e}"))
}
/// The current ref tips, as object ids. Symbolic refs are followed.
fn tip_ids(&self) -> Result<Vec<ObjectId>> {
let mut out = Vec::new();
for row in self.refs()? {
if let Some(raw) = row.oid {
if let Ok(id) = ObjectId::try_from(raw.as_slice()) {
out.push(id);
}
}
}
Ok(out)
}
/// Peel an annotated tag to what it points at, following a chain of tags.
fn peel(&self, id: &gix_hash::oid) -> Option<ObjectId> {
use gix_object::Find;
// One lease for the whole chain, not one per hop: the chain is at most
// sixteen reads and they are the reads most likely to share a delta
// base with each other.
self.odb.with_handle(|odb| {
let mut buf = Vec::new();
let mut current = id.to_owned();
// A tag pointing at a tag is legal; a *cycle* of them is not, and
// this is untrusted input. Bound the walk rather than trusting the
// data.
for _ in 0..16 {
let data = odb.try_find(¤t, &mut buf).ok()??;
if data.kind != gix_object::Kind::Tag {
return (current != id).then_some(current);
}
current = gix_object::TagRefIter::from_bytes(data.data, self.hash_kind)
.target_id()
.ok()?;
}
None
})
}
}
/// How many `.keep` markers could not be removed, process-wide.
///
/// See [`drop_keep`].
static KEEP_REMOVAL_FAILURES: AtomicU64 = AtomicU64::new(0);
/// Selections answered from a `.bitmap`. See
/// [`GixStore::bitmap_selection_counts`].
pub(crate) static BITMAP_SELECTIONS: AtomicU64 = AtomicU64::new(0);
/// Selections that fell back to [`graph`]'s walk.
pub(crate) static WALK_SELECTIONS: AtomicU64 = AtomicU64::new(0);
/// Commits the bitmap path's bounded walk had to open. See
/// [`GixStore::bitmap_commits_walked`].
pub(crate) static BITMAP_COMMITS_WALKED: AtomicU64 = AtomicU64::new(0);
/// Remove a `.keep` marker, and **count it** if it will not go.
///
/// `S-030` in the charred record, and the quarantine's
/// `tests/swallowed_errors.rs` is a whole file about this one shape. Both call
/// sites here were `let _ = std::fs::remove_file(keep)`, which is the swallow
/// that finding is named after — and in *this* crate it has a specific
/// consequence rather than a general untidiness:
///
/// > [`gc::apply`] **skips a pack that has a `.keep` beside it, even when the
/// > pack looks entirely dead**, because that marker is the receive path's
/// > claim on a push whose refs have not landed. A `.keep` that could not be
/// > removed is therefore a pack no garbage collection will ever reclaim, for
/// > the life of the repository, silently.
///
/// # Why it does not return a `Result`
///
/// The pack the marker names is complete and correct; failing a finished push
/// over a leftover marker would turn a cosmetic fault into an operator-visible
/// error. What was wrong before is that nothing was *told*.
///
/// # Why a counter and not only a log line
///
/// A log line can only be asserted with a subscriber, and §6's rule in this
/// crate is to **prefer a count**: [`GixStore::keep_removal_failures`] is
/// readable from a test and from a bench row without a `tracing-subscriber`
/// dependency, and it cannot be satisfied by a message that merely looks right.
/// Both are emitted — the count so it can be asserted, the line so an operator
/// learns *which* file.
pub fn drop_keep(path: &Path) {
match std::fs::remove_file(path) {
Ok(()) => {}
// Already gone is the outcome this function wants, not a failure.
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => {
KEEP_REMOVAL_FAILURES.fetch_add(1, Ordering::Relaxed);
tracing::error!(
event = "store.keep.not_removed",
keep = %path.display(),
%err,
"the .keep marker could not be removed; the pack it names is complete, but \
this marker is now stale and `gc` will never reclaim that pack"
);
}
}
}
/// Read `extensions.objectFormat` out of the repository config.
///
/// A hand-rolled read of one key, rather than a `gix-config` dependency. The
/// grammar accepted is the one git itself writes on `git init`; anything more
/// exotic falls back to sha1, which is git's default and therefore the right
/// thing to be wrong towards.
fn stored_hash_kind(git_dir: &Path) -> Result<gix_hash::Kind> {
let path = git_dir.join("config");
let Ok(text) = std::fs::read_to_string(&path) else {
return Ok(gix_hash::Kind::Sha1);
};
for line in text.lines() {
let line = line.trim();
let Some((k, v)) = line.split_once('=') else {
continue;
};
if k.trim().eq_ignore_ascii_case("objectformat") {
return match v.trim() {
"sha256" => Ok(gix_hash::Kind::Sha256),
"sha1" => Ok(gix_hash::Kind::Sha1),
other => Err(anyhow!("unknown extensions.objectFormat {other:?}")),
};
}
}
Ok(gix_hash::Kind::Sha1)
}
/// Create a bare repository stock `git` will accept.
///
/// Written by hand rather than by shelling out, so the crate has no runtime
/// dependency on a `git` binary. `tests/` then runs real `git` against the
/// result, which is what makes this honest: the layout is asserted by the
/// implementation that has to read it, not by the one that wrote it.
fn init_bare(git_dir: &Path, hash_kind: gix_hash::Kind) -> Result<()> {
std::fs::create_dir_all(git_dir.join("objects").join("pack"))?;
std::fs::create_dir_all(git_dir.join("objects").join("info"))?;
std::fs::create_dir_all(git_dir.join("refs").join("heads"))?;
std::fs::create_dir_all(git_dir.join("refs").join("tags"))?;
std::fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")?;
// `repositoryformatversion` MUST be 1 for sha256: version 0 has no
// `extensions` section, and a git that reads a sha256 repository as
// version 0 misreads every object id in it.
let config = match hash_kind {
gix_hash::Kind::Sha256 => {
"[core]\n\trepositoryformatversion = 1\n\tfilemode = true\n\tbare = true\n\
\tlogallrefupdates = false\n[extensions]\n\tobjectFormat = sha256\n"
}
// `gix_hash::Kind` is `#[non_exhaustive]`. A hash kind this crate has
// never seen must be a hard error and not a silent fallback to sha1:
// writing a sha1 config over a repository whose objects are some third
// width would misread every object id in it.
gix_hash::Kind::Sha1 => {
"[core]\n\trepositoryformatversion = 0\n\tfilemode = true\n\tbare = true\n\
\tlogallrefupdates = false\n"
}
other => {
return Err(anyhow!(
"hash kind {other:?} has no known repository config layout"
))
}
};
std::fs::write(git_dir.join("config"), config)?;
Ok(())
}
impl GitOps for GixStore {
// ── STORE ───────────────────────────────────────────────────────────────
/// One push: pack bytes durable **first**, then the refs that point into
/// them.
///
/// The order is the contract's and the reason is stated there: a crash
/// between the two leaves objects nobody points at, which a gc reclaims,
/// whereas the reverse leaves a ref pointing at objects that are not there,
/// which no later pass repairs.
///
/// The `.keep` file `put_pack` leaves behind is removed **after** the ref
/// transaction commits, which is precisely what it is for: between the two
/// steps the pack's objects are unreferenced, and a concurrent `gc` without
/// the `.keep` would be correct to delete the push.
fn put(&self, pack: &[u8], refs: &[RefUpdate]) -> Result<TxId> {
let (mut tx, keep) = self.write_pack(pack)?;
let ref_tx = self.put_refs(refs)?;
if let Some(keep) = keep {
drop_keep(&keep);
}
tx.push_seq = ref_tx.push_seq;
Ok(tx)
}
/// The pack half, on its own. The `.keep` is dropped immediately, because
/// no ref transaction is coming to justify holding it.
fn put_pack(&self, bytes: &[u8]) -> Result<TxId> {
let (tx, keep) = self.write_pack(bytes)?;
if let Some(keep) = keep {
drop_keep(&keep);
}
Ok(tx)
}
/// The ref half, on its own — one all-or-nothing gix transaction.
///
/// `push_seq` is an **in-process** counter. This backend keeps no ref log,
/// so there is no durable ordering authority to report and inventing one
/// that resets to 0 on restart while looking persistent would be worse than
/// saying so. See the note on [`GixStore::seq`].
fn put_refs(&self, updates: &[RefUpdate]) -> Result<TxId> {
if updates.is_empty() {
return Ok(TxId::default());
}
let edits = updates
.iter()
.map(|u| refs::to_edit(u, self.hash_kind))
.collect::<Result<Vec<_>>>()?;
let existing: Vec<String> = self.refs()?.into_iter().map(|r| r.name).collect();
refs::precheck_name_conflict(&edits, &existing)?;
// `S-023`: evaluate the expectations before gix can short-circuit them.
refs::precheck_cas(&edits, |name| {
Ok(self
.refs
.try_find(name.as_ref())
.with_context(|| "read ref")?
.map(|r| r.target))
})?;
// gix prepares every edit — taking a lock and checking the expectation
// for each — before committing any, and a preparation failure rolls the
// whole batch back. That is the all-or-nothing this method promises,
// implemented by gix rather than re-implemented here.
//
// Both locks fail immediately rather than waiting: a server that blocks
// on a contended reference turns a lost race into a stalled connection.
self.refs
.transaction()
.prepare(
edits,
gix_lock::acquire::Fail::Immediately,
gix_lock::acquire::Fail::Immediately,
)
.map_err(|e| anyhow!("ref transaction refused: {e}"))?
.commit(None)
.map_err(|e| anyhow!("ref transaction failed to commit: {e}"))?;
Ok(TxId {
pack_id: None,
extent: None,
push_seq: Some(self.seq.fetch_add(1, Ordering::SeqCst)),
})
}
// ── READ ────────────────────────────────────────────────────────────────
/// The stored bytes of one object: the pack entry, header and all.
///
/// Sliced out of the mmap by offset arithmetic — nothing is inflated to
/// find where the entry ends (`P-025`). The single copy is the `to_vec`
/// the contract forces by making `Stored::bytes` an owned `Vec<u8>`.
///
/// Returns `None` for a **loose** object. That is a real limitation and it
/// is honest rather than papered over: a loose object has no pack entry, so
/// there are no verbatim pack bytes to return and no `ObjType` in
/// `{OfsDelta, RefDelta}` that could describe it. Nothing this crate writes
/// is ever loose — `put_pack` writes packs — so this is reachable only for
/// a repository that was populated by something else.
fn get(&self, oid: Oid<'_>) -> Result<Option<Stored>> {
let id = self.oid(oid)?;
let packs = self
.packs
.read()
.map_err(|_| anyhow!("pack set lock poisoned"))?;
let Some(pack) = packs.find(&id) else {
return Ok(None);
};
let Some(loc) = pack.locate(&id) else {
return Ok(None);
};
let Some((bytes, extent)) = pack.entry_bytes(&id) else {
return Ok(None);
};
// For a base type the entry header's size **is** the object size. For a
// delta it is the size of the instructions, so the chain has to be
// walked — and `try_header` walks headers only, never inflating a
// payload it does not need.
let uncompressed_size = if loc.is_delta {
self.header(&id)?
.map(|h| h.size())
.unwrap_or(loc.header_size)
} else {
loc.header_size
};
Ok(Some(Stored {
obj_type: loc.obj_type,
uncompressed_size,
extent,
bytes: bytes.to_vec(),
}))
}
/// Is this object here? The negotiation call, made a thousand at a time.
///
/// Packs first, and only then the loose probe. That ordering is `P-028`
/// respected as far as this trait allows: *"a `have` that misses costs a
/// `statx`"*, because `gix-odb`'s `contains` probes `objects/xx/yyy…` on
/// every miss and a negotiation is mostly misses. Answering out of the
/// mmapped `.idx` fanout tables costs no syscall at all, so the `statx` is
/// paid only by a genuine miss — which is still the common case, and is the
/// finding, not the fix. The fix needs a "packed only" seam the frozen trait
/// does not have; that is written up in the report rather than bolted on
/// here.
///
/// # And one syscall per miss that is not this crate's to remove
///
/// MEASURED 2026-08-10 on t14s, `strace -f -c -e trace=sched_yield` over
/// 4 000 misses against a 64-pack, 100 600-object store: **4 001
/// `sched_yield`, i.e. exactly one per miss** — the same count with and
/// without a multi-pack index, so [`midx`] does not touch it. That is
/// GitoxideLabs/gitoxide#2878, filed 2026-08-04: `Handle::contains` yields
/// on every miss even under `RefreshMode::Never`. ~260 ns on a path a
/// negotiation takes thousands of times. Filed upstream, not patched here,
/// and this crate is not blocked on it.
fn has(&self, oid: Oid<'_>) -> Result<bool> {
let id = self.oid(oid)?;
{
let packs = self
.packs
.read()
.map_err(|_| anyhow!("pack set lock poisoned"))?;
if packs.find(&id).is_some() {
return Ok(true);
}
}
// `lookup_without_rescan`, not `lookup`. A `have` line naming an object
// this server does not hold is the client telling you about its own
// commits, not evidence of a stale snapshot — so a miss is the answer
// and must not buy a `statx`. The generation check stays, because it is
// an atomic load and it catches a handle leased from before another
// request's scan, which the mtime watch structurally cannot.
use gix_object::Exists as _;
Ok(self
.odb
.lookup_without_rescan(|odb| odb.exists(&id), |found: &bool| *found))
}
/// Post-resolution size — what the object inflates to once its chain is
/// applied.
fn size(&self, oid: Oid<'_>) -> Result<Option<u64>> {
let id = self.oid(oid)?;
Ok(self.header(&id)?.map(|h| h.size()))
}
/// Byte extents, in bulk — the wire path.
///
/// The extent is **pack-local**: `(offset_into_its_packfile, entry_len)`.
/// That is what the contract asks for in as many words — *"an offset into a
/// packfile for a gix backend"* — but it carries a consequence a caller must
/// know: with more than one pack in the store, an extent does not identify a
/// byte range on its own, because it does not say which pack. The contract's
/// `Extent` is a bare `(u64, u64)` with nowhere to put a pack id. Flagged in
/// the report as the one place where this backend's honest answer is
/// narrower than the type suggests; not worked around by inventing a
/// synthetic global offset, which would be a number that indexes nothing.
///
/// Serial, on one thread. `P-004`: this is the serving tier.
fn extents(&self, oids: &[Oid<'_>]) -> Result<Vec<Option<Extent>>> {
let packs = self
.packs
.read()
.map_err(|_| anyhow!("pack set lock poisoned"))?;
let mut out = Vec::with_capacity(oids.len());
for raw in oids {
let Ok(id) = ObjectId::try_from(*raw) else {
out.push(None);
continue;
};
out.push(
packs
.find(&id)
.and_then(|p| p.locate(&id))
.map(|l| l.extent),
);
}
Ok(out)
}
// ── REFS ────────────────────────────────────────────────────────────────
/// The current ref namespace, sorted by name.
///
/// `all()` walks `refs/` loose and packed and excludes pseudo-refs such as
/// `HEAD`, which is the same set every other backend advertises.
fn refs(&self) -> Result<Vec<RefRow>> {
let platform = self.refs.iter().context("open ref iterator")?;
let mut out = Vec::new();
for r in platform.all().context("iterate refs")? {
let r = r.context("read ref")?;
let name = r.name.as_bstr().to_string();
out.push(refs::row(name, &r.target, |id| self.peel(id)));
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
/// Compare-and-swap one ref.
///
/// `old: None` means "must not exist" (a create); `new: None` means delete.
/// The expectation is checked by [`refs::precheck_cas`] before gix sees it —
/// `S-023`, without which a create that must fail silently succeeds.
fn update_ref(&self, name: &str, old: Option<Oid<'_>>, new: Option<Oid<'_>>) -> Result<TxId> {
use gix_ref::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
use gix_ref::{FullName, Target};
let full = FullName::try_from(name).with_context(|| format!("invalid ref name {name:?}"))?;
let expected = match old {
Some(raw) => PreviousValue::MustExistAndMatch(Target::Object(self.oid(raw)?)),
None => PreviousValue::MustNotExist,
};
let change = match new {
Some(raw) => Change::Update {
log: LogChange {
mode: RefLog::AndReference,
force_create_reflog: false,
message: Default::default(),
},
expected,
new: Target::Object(self.oid(raw)?),
},
None => Change::Delete {
expected,
log: RefLog::AndReference,
},
};
let edit = RefEdit {
change,
name: full,
deref: false,
};
refs::precheck_cas(std::slice::from_ref(&edit), |n| {
Ok(self
.refs
.try_find(n.as_ref())
.context("read ref")?
.map(|r| r.target))
})?;
self.refs
.transaction()
.prepare(
std::iter::once(edit),
gix_lock::acquire::Fail::Immediately,
gix_lock::acquire::Fail::Immediately,
)
.map_err(|e| anyhow!("ref transaction refused: {e}"))?
.commit(None)
.map_err(|e| anyhow!("ref transaction failed to commit: {e}"))?;
Ok(TxId {
pack_id: None,
extent: None,
push_seq: Some(self.seq.fetch_add(1, Ordering::SeqCst)),
})
}
/// Apply every edit or none — the `--atomic` push primitive.
///
/// The third shape: [`GixStore::put_refs`] is a batch without
/// compare-and-swap and [`GixStore::update_ref`] is compare-and-swap without
/// a batch, and `--atomic` is defined as both at once. Neither composes into
/// the other, which is why this exists rather than being spelled as a loop.
///
/// # `S-023` — the expectation is checked before gix sees the batch
///
/// gix's file ref store short-circuits an edit whose new value equals the
/// value the reference already holds: it rewrites `expected` to
/// `MustExistAndMatch(current)` and never evaluates the one the caller wrote.
/// For `old: None` — *must not exist* — that turns a create that must fail
/// into a **silent success**, so "exactly one creator wins", the property
/// receive-pack arbitrates two racing pushes with, was not true. The
/// **whole** batch is therefore pre-checked against one snapshot of the
/// namespace before any of it reaches gix's writer; see [`refs::precheck_cas`].
///
/// # The rejection is typed, and never a message
///
/// A lost race raises [`git_storage_trait::RefRejection::Cas`] carrying which
/// ref, what was expected and what was actually there; contention raises
/// [`git_storage_trait::RefRejection::Locked`]. Recover either with
/// `RefRejection::of(&err)`. **This arm is the one that can raise `Locked`
/// at all**, and the reason is two lines below: both lock modes are
/// `Fail::Immediately`, so a contended reference surfaces as a retryable
/// rejection rather than as a stalled connection.
///
/// # Empty is a no-op, not an error
///
/// A deletions-free push with nothing to apply calls this, and refusing it
/// would make every call site special-case the empty batch.
fn put_refs_cas(&self, edits: &[RefCas<'_>]) -> Result<TxId> {
use gix_ref::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
use gix_ref::{FullName, Target};
if edits.is_empty() {
return Ok(TxId::default());
}
let mut batch = Vec::with_capacity(edits.len());
for e in edits {
let full = FullName::try_from(e.name.as_str())
.with_context(|| format!("invalid ref name {:?}", e.name))?;
// `None` is *must not exist*, never *don't care* — the contract is
// explicit that there is no third state here, and `put_refs` is the
// batch that makes no claim.
let expected = match e.old {
Some(raw) => PreviousValue::MustExistAndMatch(Target::Object(self.oid(raw)?)),
None => PreviousValue::MustNotExist,
};
let change = match e.new {
Some(raw) => Change::Update {
log: LogChange {
mode: RefLog::AndReference,
force_create_reflog: false,
message: Default::default(),
},
expected,
new: Target::Object(self.oid(raw)?),
},
None => Change::Delete {
expected,
log: RefLog::AndReference,
},
};
batch.push(RefEdit {
change,
name: full,
// A push that names `refs/heads/main` means that reference, not
// whatever it might symbolically point at.
deref: false,
});
}
let existing: Vec<String> = self.refs()?.into_iter().map(|r| r.name).collect();
refs::precheck_name_conflict(&batch, &existing)?;
// `S-023`: the WHOLE batch, against one snapshot, before gix sees any of
// it. This is also where the typed `Cas` rejection for a
// create-that-must-fail comes from — gix's own `prepare` never sees that
// case, which is the whole point of the finding.
refs::precheck_cas(&batch, |n| {
Ok(self
.refs
.try_find(n.as_ref())
.context("read ref")?
.map(|r| r.target))
})?;
self.refs
.transaction()
.prepare(
batch.clone(),
gix_lock::acquire::Fail::Immediately,
gix_lock::acquire::Fail::Immediately,
)
.map_err(|e| refs::map_prepare_error(e, &batch))?
.commit(None)
.map_err(|e| anyhow!("ref transaction failed to commit: {e}"))?;
Ok(TxId {
pack_id: None,
extent: None,
push_seq: Some(self.seq.fetch_add(1, Ordering::SeqCst)),
})
}
// ── GRAPH ───────────────────────────────────────────────────────────────
/// `want` minus `have`, the object closure a fetch must send.
///
/// **Through [`GixStore::selection`], the same seam
/// [`git_storage_trait::GitServe::select`] uses**, so this answers from the
/// `.bitmap` when there is one and walks when there is not. It used to call
/// [`graph::reachable`] directly, on the reasoning that gitoxide has no
/// bitmap support — which is true of gitoxide and no longer true of this
/// crate, and the direct call was then a second reachability path that would
/// quietly never take the bitmap.
///
/// That mattered concretely rather than tidily: the head-to-head server
/// binary this crate ships, `examples/git-store-serve.rs`, serves every
/// clone through *this* method and not through `select`. Leaving it here
/// would have meant a benchmark arm measuring the walk and a test suite
/// measuring the bitmap — `P-027` exactly.
fn reachable(&self, want: &[Oid<'_>], have: &[Oid<'_>]) -> Result<Vec<Vec<u8>>> {
let to_ids = |s: &[Oid<'_>]| -> Result<Vec<ObjectId>> {
s.iter().map(|raw| self.oid(raw)).collect()
};
let want = to_ids(want)?;
let have = to_ids(have)?;
let sel = self.selection(&want, &have)?;
Ok(sel
.objects
.into_iter()
.map(|id| id.as_slice().to_vec())
.collect())
}
// ── MAINT ───────────────────────────────────────────────────────────────
/// Mark from every ref tip, then drop packs with no live object in them.
///
/// Reports itself as `"gix-mark-sweep-packs"`, not `"gc"`, because that is
/// what it is: no partially-dead pack is rewritten and no packs are
/// consolidated. See [`gc`]'s module doc for why (`P-009`: gitoxide has no
/// delta encoder, so a naive repack makes the repository larger).
fn gc(&self) -> Result<GcReport> {
// One writer over `objects/pack`; see [`GixStore::maint_lock`]. The
// multi-pack index writer takes the same lock, so it cannot be naming
// packs this is unlinking.
let _one_writer = self
.maint_lock
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tips = self.tip_ids()?;
let live = self
.odb
.with_handle(|odb| graph::live_set(odb, self.hash_kind, &tips))?;
let (sweep, retired, index_removed) = {
let packs = self
.packs
.read()
.map_err(|_| anyhow!("pack set lock poisoned"))?;
let sweep = gc::plan(&packs, &live);
// **Before** the unlink, not after. A multi-pack index names its
// `.idx` files; one deleted while the index still lists it turns
// every lookup that routes through the index into a read of a file
// that is not there. Removing it first degrades to `O(packs)` for
// the length of the sweep, which is slow and correct — the other
// order is fast and wrong.
let index_removed = if sweep.dead_packs.is_empty() {
false
} else {
midx::remove_multi_pack_index(&self.pack_dir())?
};
let (retired, _) = gc::apply(&packs, &sweep)?;
(sweep, retired, index_removed)
};
// Re-read the pack set off the filesystem, so `bytes_after` is measured
// rather than computed from what we intended to delete. That is what
// lets `verified` be `true`.
self.reload_packs()?;
// …and put the index back over what survived, but only if the store is
// still above the threshold that justified having one — otherwise this
// would write, from the cleanup path, an index the open path would have
// declined to write. `_locked`, because this function holds
// `maint_lock` and `std::sync::Mutex` is not reentrant.
if index_removed && odb::count_packs(&self.pack_dir()) >= midx::MULTI_PACK_INDEX_THRESHOLD {
if let Err(err) = self.write_multi_pack_index_locked() {
// A gc that reclaimed bytes has succeeded. A missing index costs
// speed and nothing else — the same posture
// `write_multi_pack_index_if_worthwhile` takes, for the same
// reason.
tracing::warn!(error = %err, "gc could not rewrite the multi-pack index");
}
}
// The reachability bitmap, which is where git writes one: `repack -b`
// emits `.bitmap` beside `.idx`, and this is the only pass over the
// whole object graph this backend makes. It runs **after** the sweep and
// the reload, so it bitmaps what survived rather than what was there.
//
// A failure is logged rather than propagated, the same posture the
// multi-pack index takes two blocks up and for the same reason: a gc
// that reclaimed bytes has succeeded, and a missing bitmap costs speed
// and nothing else. `graph`'s walk is still the whole answer without it.
match self.write_reachability_bitmap_locked() {
Ok(Some(path)) => tracing::info!(
event = "gc.bitmap.written",
bitmap = %path.display(),
"wrote a reachability bitmap"
),
Ok(None) => tracing::info!(
event = "gc.bitmap.declined",
"no pack here can carry an exact reachability bitmap; serving by walking"
),
Err(err) => tracing::warn!(
event = "gc.bitmap.failed",
error = %err,
"gc could not write a reachability bitmap"
),
}
let after = self
.packs
.read()
.map_err(|_| anyhow!("pack set lock poisoned"))?
.disk_bytes();
Ok(gc::report(
self.git_dir.clone(),
sweep.bytes_before,
after,
&sweep,
retired,
))
}
}
impl GixStore {
/// Index an untrusted pack into `objects/pack`, returning its receipt and
/// the `.keep` guarding it.
///
/// # What this reuses, and what it costs
///
/// `gunnar_pack::index_pack` is the whole body: it validates the stream,
/// resolves the deltas and writes `.pack`/`.idx`/`.keep`, with the
/// allocation ceiling and the CRC-verifying iteration mode a server
/// receiving a push needs. Reimplementing it here is the duplication the
/// brief forbids.
///
/// It also re-acquires a cost the Arrow backend shed. `index_pack` reaches
/// `gix-features`' parallel machinery, which is where `P-030` found *"every
/// push paid 50 ms of sleeping"* — a watcher thread in
/// `in_parallel_with_slice`, reached unconditionally from
/// `write_data_iter_to_stream`. The gunnar-side triage files `P-030` as
/// `DEAD — gix` **because the Arrow receive path stopped calling it**. This
/// path calls it. So `P-030` is live again in this crate and it is inherited
/// rather than introduced.
///
/// ## MEASURED 2026-08-10 on t14s. It is no longer a hypothesis.
///
/// It is a **count**, so the loaded box does not weaken it. Over
/// `tests/packs_beyond_the_slot_table.rs`'s 33 sequential `put_pack` calls,
/// `strace -f -T -e trace=clock_nanosleep`:
///
/// ```text
/// 33 x clock_nanosleep(CLOCK_MONOTONIC, {tv_nsec=50000000}) = 0 <0.050093>
/// ```
///
/// **Exactly one 50 ms sleep per `put_pack`, and it is real elapsed time**
/// (`<0.0501>` is strace's own wall measurement of the call). Every push,
/// every `prepare`, and every one of those test files' runtimes is mostly
/// this. `gix-pack`'s delta traversal asks for a 50 ms `periodic()` and
/// `in_parallel_with_slice`'s watcher sleeps it out **after** every worker
/// has been joined, inside the same `std::thread::scope` — so the interval
/// is appended to the call rather than overlapped with it.
///
/// **The fix exists and this workspace does not have it.**
/// GitoxideLabs/gitoxide#2877 is an open PR (park-with-deadline instead of
/// sleep, 32 lines), and gunnar carries it vendored —
/// `gunnar/vendor/gix-features` + `[patch.crates-io]` at
/// `gunnar/Cargo.toml:358`. edda's root `Cargo.toml` has **no `[patch]`
/// table at all** and `edda/vendor/` holds only `cozo-rayonfree`, so this
/// crate pays the 50 ms and the gunnar-built arm does not.
///
/// That is a **confound for any head-to-head push or `prepare` clock**, and
/// it is worth more than everything else measured in this crate today: on
/// the quarantine's own 8-object pack the fix moved the p50 from 50.32 ms to
/// 10.98 ms with process CPU unchanged, because nothing was made faster — a
/// wait was removed.
fn write_pack(&self, bytes: &[u8]) -> Result<(TxId, Option<PathBuf>)> {
let dir = self.pack_dir();
std::fs::create_dir_all(&dir)?;
let mut cursor = bytes;
let opts = gunnar_pack::IndexOptions {
hash_kind: self.hash_kind,
index_version: gix_pack::index::Version::V2,
// `Verify`, not `AsIs`: this is an untrusted client's pack and the
// CRC is the cheapest place to catch a corrupt one.
iteration_mode: gix_pack::data::input::Mode::Verify,
thread_limit: None,
alloc_limit_bytes: Some(gunnar_pack::DEFAULT_ALLOC_LIMIT_BYTES),
};
// `index_thin_pack`, never `index_pack`, and this is not a detail.
//
// MEASURED, not assumed: `index_pack` fails on a pack straight out of
// `git pack-objects --revs --stdout` with *"Ref delta objects are not
// supported as there is no way to look them up"*. `git pack-objects`
// emits **`OBJ_REF_DELTA`** unless `--delta-base-offset` is passed, and
// a pushing client sends a **thin** pack by default — a delta whose base
// the receiver already has and the sender therefore omitted. Both shapes
// need a base lookup, and `index_pack` has none.
//
// The bases are this store's own odb, so a delta may point at anything
// already here. Bases pulled in this way are written *into* the new
// pack, so what lands on disk is self-contained and every later read
// path — including the verbatim offset arithmetic in `packs` — is
// reading a complete pack.
let indexed = self
.odb
.with_handle(|odb| {
gunnar_pack::index_thin_pack(&mut cursor, Some(&dir), odb, &opts, None)
})
// `anyhow::Error::new` rather than `anyhow!("{e}")`: gunnar-pack's
// top-level Display is "failed to index the pack" and every useful
// word is in its `source()` chain. Flattening it to the Display
// string threw away the only part worth reading.
.map_err(anyhow::Error::new)
.context("index pack")?;
self.reload_packs()?;
// The pack id is the dense id the reloaded set assigned this pack, found
// by the path `index_pack` reported. Reading it back rather than
// predicting it keeps `TxId` "applied output", which is what the
// contract says every field of it must be.
let pack_id = indexed.data_path.as_ref().and_then(|p| {
self.packs
.read()
.ok()?
.packs
.iter()
.find(|pk| pk.data_path == *p)
.map(|pk| pk.id)
});
let len = indexed
.data_path
.as_ref()
.and_then(|p| std::fs::metadata(p).ok())
.map(|m| m.len())
.unwrap_or(bytes.len() as u64);
Ok((
TxId {
pack_id,
// The whole pack file, which is the extent this transaction
// claimed. Per-object extents come from `extents()`.
extent: Some((0, len)),
push_seq: None,
},
indexed.keep_path,
))
}
}
/// Re-exported so a caller can name the contract's types without a second
/// dependency edge on `git-storage-trait`.
pub use git_storage_trait::{self as contract, GitOps as _GitOpsIsImplemented};
/// The six pack entry codes, re-exported for tests and generators.
pub use git_storage_trait::ObjType as PackObjType;
const _: () = {
// A compile-time assertion that the eleven are actually implemented, so a
// signature drifting from the frozen contract is a build failure here rather
// than a surprise at a call site.
fn assert_impl<T: GitOps>() {}
fn check() {
assert_impl::<GixStore>();
}
let _ = check;
};
#[allow(dead_code)]
fn _obj_type_is_used(t: ObjType) -> u8 {
t.code()
}
#[cfg(test)]
mod tests {
use super::*;
/// `S-030`, as a count. One test rather than three, because the counter is
/// process-wide and three parallel tests reading it would race each other.
#[test]
fn a_keep_that_cannot_be_removed_is_counted_and_one_that_can_is_not() {
let tmp = tempfile::tempdir().unwrap();
// 1. A marker that is there goes, and is not a failure.
let real = tmp.path().join("pack-x.keep");
std::fs::write(&real, b"gunnar").unwrap();
let before = GixStore::keep_removal_failures();
drop_keep(&real);
assert!(!real.exists(), "an existing .keep must actually be removed");
assert_eq!(
GixStore::keep_removal_failures(),
before,
"removing a marker that was there is not a failure"
);
// 2. A marker that is already gone is the outcome we wanted, not a
// failure. Getting this wrong would make the counter fire on every
// ordinary `put_pack` and render it useless.
drop_keep(&real);
assert_eq!(GixStore::keep_removal_failures(), before);
// 3. A marker that will not go IS counted. A directory stands in for
// the real fault (a read-only parent, a `.keep` held open on another
// platform): `remove_file` on one is `EISDIR`, which is neither
// success nor `NotFound`.
let stuck = tmp.path().join("pack-y.keep");
std::fs::create_dir(&stuck).unwrap();
drop_keep(&stuck);
assert_eq!(
GixStore::keep_removal_failures(),
before + 1,
"a .keep that could not be removed must be counted — it is a pack `gc` will \
never reclaim, and before this it was a bare `let _ = remove_file(..)`"
);
}
}