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