crates/storage-git-gix/parked-gix-2/gunnar-store-gix/tests/fs_reflog.rs
//! The `fs` backend keeps a reference history, and `gunnar_store::history` can
//! read it.
//!
//! Until the journal was wired this was false in the strongest possible way:
//! `FsStore` opens `gix-ref`'s file store with `WriteReflog::Disable`, and a
//! bare repository is created with `core.logallrefupdates = false`, so the
//! backend the server actually runs kept **no reference history at all**.
//! `refs.arrow` existed in every repository and held nothing but its schema.
//!
//! §4.5 of the specification promises "the log of `(ref, old, new, actor, at)`
//! **is** the reflog" and "who moved `main` last week is SQL". That promise was
//! attached to `ArrowLogRefStore`, which is the *znippy* backend's ref store and
//! is instantiated by no server crate. These tests are what make it true on
//! `fs`.
//!
//! ## Seen red
//!
//! * `a_reference_move_through_the_fs_backend_is_in_the_history` — red by
//! removing the `self.journal.journal(&applied)` call from
//! `FsRefStore::transaction`, i.e. the exact state of the tree before this
//! change. It failed on 0 transactions where 3 were expected, and the value
//! assertions below failed with it.
//! * `the_history_records_what_a_reference_moved_FROM` — red by journalling the
//! edits the caller **sent** rather than the ones gix handed back. A force
//! push sends `PreviousValue::Any`, so every `old_oid` came out null and the
//! history could not be replayed. This is why `journal` documents that it
//! takes `applied`.
//!
//! `a_deleted_reference_leaves_its_deletion_in_the_history` stayed **green**
//! under that probe, and the reason is worth stating rather than glossing:
//! `RefEdit::delete` takes the previous value as an argument, so the sent edit
//! already carries `MustExistAndMatch`. A deletion therefore cannot
//! distinguish sent from applied, and it is the update path that decides this
//! — which is why the update tests are the ones that assert it.
use gunnar_store::fs::FsStore;
use gunnar_store::fs::STORE_DIR;
use gunnar_store::history::History;
use gunnar_store::refs::{FullName, RefEdit, RefStore, Target};
use gunnar_store::PreviousValue;
const KIND: gix_hash::Kind = gix_hash::Kind::Sha256;
fn name(s: &str) -> FullName {
FullName::new(s).expect("a valid ref name")
}
fn oid(step: u8) -> gix_hash::ObjectId {
let mut bytes = [0u8; 32];
bytes[0] = step;
bytes[31] = step.wrapping_mul(11);
gix_hash::ObjectId::from_bytes_or_panic(&bytes)
}
/// Three moves through the real backend, read back out of the real files.
#[test]
fn a_reference_move_through_the_fs_backend_is_in_the_history() {
let scratch = tempfile::tempdir().expect("a scratch directory");
let git_dir = scratch.path().join("r.git");
{
let store = FsStore::open_or_init(&git_dir, KIND).expect("open");
let refs = store.refs();
refs.transaction(vec![RefEdit::create(
name("refs/heads/main"),
Target::Object(oid(1)),
)])
.expect("create");
refs.transaction(vec![RefEdit::force(
name("refs/heads/main"),
Target::Object(oid(2)),
)])
.expect("move");
refs.transaction(vec![RefEdit::create(
name("refs/heads/topic"),
Target::Object(oid(3)),
)])
.expect("branch");
// The store handle holds the O_EXCL lock; drop it so this test is about
// the history and not about the lock, which its own test covers.
}
let history = History::read(git_dir.join(STORE_DIR), KIND).expect("read the history");
let transactions = history.transactions().expect("decode");
assert_eq!(
transactions.len(),
3,
"the fs backend recorded {} of 3 reference moves; \
`refs.arrow` holding only its schema is exactly what it did before the journal",
transactions.len()
);
// Applied output: the real oids, in order, from bytes on disk.
let moves: Vec<(&str, Option<gix_hash::ObjectId>, Option<gix_hash::ObjectId>)> = transactions
.iter()
.map(|t| (t.refname.as_str(), t.old_oid, t.new_oid))
.collect();
assert_eq!(
moves,
vec![
("refs/heads/main", None, Some(oid(1))),
("refs/heads/main", Some(oid(1)), Some(oid(2))),
("refs/heads/topic", None, Some(oid(3))),
],
"the history is not the sequence of moves that was performed"
);
// And it agrees with the references gix is actually serving, which is the
// property a journal has to have: it is a record of the authority, not a
// second authority.
let store = FsStore::open_or_init(&git_dir, KIND).expect("reopen");
assert_eq!(
store.refs().read(&name("refs/heads/main")).expect("read"),
Some(Target::Object(oid(2))),
"gix and the journal disagree about where main points"
);
}
/// The previous value is the half a reflog is *for*, and it is the half that a
/// naive journal loses.
#[test]
fn the_history_records_what_a_reference_moved_from() {
let scratch = tempfile::tempdir().expect("a scratch directory");
let git_dir = scratch.path().join("r.git");
{
let store = FsStore::open_or_init(&git_dir, KIND).expect("open");
let refs = store.refs();
refs.transaction(vec![RefEdit::create(
name("refs/heads/main"),
Target::Object(oid(7)),
)])
.expect("create");
// A FORCE push: the caller's expectation is `Any`, so the previous
// value exists nowhere in the edit that was sent. Only the edit gix
// hands back carries it.
let sent = RefEdit::force(name("refs/heads/main"), Target::Object(oid(8)));
assert!(
matches!(sent.change.expected(), PreviousValue::Any),
"this test only means something if the edit it sends really does carry no \
previous value"
);
refs.transaction(vec![sent]).expect("force");
}
let transactions = History::read(git_dir.join(STORE_DIR), KIND)
.expect("read")
.transactions()
.expect("decode");
let forced = transactions.last().expect("the force push");
assert_eq!(
forced.new_oid,
Some(oid(8)),
"the force push is not the last row of the history"
);
assert_eq!(
forced.old_oid,
Some(oid(7)),
"the history says main moved from {:?}; a force push sends PreviousValue::Any, so \
journalling the SENT edits rather than the APPLIED ones loses this and the history \
cannot be replayed",
forced.old_oid
);
}
/// A deletion is history too, and it is the row a state snapshot can never
/// carry: after it, nothing on disk says the reference ever existed.
#[test]
fn a_deleted_reference_leaves_its_deletion_in_the_history() {
let scratch = tempfile::tempdir().expect("a scratch directory");
let git_dir = scratch.path().join("r.git");
{
let store = FsStore::open_or_init(&git_dir, KIND).expect("open");
let refs = store.refs();
refs.transaction(vec![RefEdit::create(
name("refs/heads/gone"),
Target::Object(oid(5)),
)])
.expect("create");
refs.transaction(vec![RefEdit::delete(
name("refs/heads/gone"),
Target::Object(oid(5)),
)])
.expect("delete");
assert_eq!(
refs.read(&name("refs/heads/gone")).expect("read"),
None,
"the reference is still there, so this test is not about a deletion"
);
}
let transactions = History::read(git_dir.join(STORE_DIR), KIND)
.expect("read")
.transactions()
.expect("decode");
let deletion = transactions.last().expect("the deletion row");
assert!(
deletion.is_deletion(),
"the last row of the history is not a deletion: {deletion:?}"
);
assert_eq!(
deletion.old_oid,
Some(oid(5)),
"the deletion did not record what was deleted, which is the only thing anyone \
asks a reflog after a branch disappears"
);
assert_eq!(deletion.refname, "refs/heads/gone");
}