alkgit 0.1.0

Git smart protocol for the alk family: pkt-line substrates, protocol V2 serving (upload-pack/receive-pack), producer/consumer halves on alkcall channels, and the backend seam traits — the shipped alkstore+alkblobs composition is the alkgit-substrate crate (ADR-010/025)
Documentation
//! The shared object-storage seam types (ADR-018 §5).
//!
//! One-way doors: these shapes enter OQ-03's freeze inventory at the
//! core's first publish. Ref-name validation is *not* part of these
//! types — the deny-list and the `funny refname` mapping live in the
//! transport layer (ADR-013 §9), which consumes `PreparedPush.updates`
//! and builds the `apply_updates` call.

/// One listed ref (ADR-018 §5).
///
/// `oid: None` is the unborn symref line (the N-5 rider's
/// "symref-target with no oid"; the wire layer maps it to the unborn
/// advertisement/ls-refs line — the literal `unborn` token in the oid
/// field, the upstream emission). Advertisement projects oid+name only
/// — peeled tags and symref targets are ls-refs-arg data.
use gix_hash::ObjectId;

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RefLine {
    pub name: String,
    pub oid: Option<ObjectId>,
    pub peeled: Option<ObjectId>,
    pub symref_target: Option<String>,
}

/// One prepared/applied ref update (ADR-018 §5).
///
/// `expected: None` = must-not-exist (create); `new: None` = delete.
/// Identical in the prepare handoff and the CAS call — one type across
/// the seam (review 002 R-1's fork point).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RefUpdate {
    pub name: String,
    pub expected: Option<ObjectId>,
    pub new: Option<ObjectId>,
}

/// Per-ref CAS outcome (ADR-018 §5) — an outcome, not an error: a stale
/// ref is a business result of receive-pack (ADR-013 §7–8). The reason
/// string is the client-displayed `ng <ref> <reason>` text
/// (server-chosen, verbatim).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RefOutcome {
    pub name: String,
    pub result: Result<(), String>,
}

/// The prepare output of [`crate::GitPackIngest::prepare`] (ADR-018 §5).
///
/// `unpack` is the report's unpack leg (ok, or the `unpack ng <reason>`
/// text); pack-level validation failures (parse, index, fsck
/// connectivity) land here as the ng leg, not as `Err` — the session
/// continues to the report per ADR-013 §8. `Err` is for
/// substrate/infrastructure failure only (ADR-018 §7).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PreparedPush {
    pub unpack: Result<(), String>,
    pub updates: Vec<RefUpdate>,
}

/// Parsed push-options (ADR-018 §5) — `(key, value)` pairs verbatim,
/// un-interpreted (ADR-013 §11; repeated keys preserved). `None` on
/// `prepare` until the config gate opens; opening the gate later is
/// value-additive.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct PushOptions {
    pub pairs: Vec<(String, String)>,
}

#[cfg(test)]
mod tests {
    use super::*;

    fn oid(b: u8) -> ObjectId {
        ObjectId::from([b; 20])
    }

    #[test]
    fn unborn_symref_line_has_no_oid() {
        let line = RefLine {
            name: "HEAD".to_string(),
            oid: None,
            peeled: None,
            symref_target: Some("refs/heads/main".to_string()),
        };
        assert!(line.oid.is_none());
        assert_eq!(line.symref_target.as_deref(), Some("refs/heads/main"));
    }

    #[test]
    fn ref_line_carries_peeled_tag() {
        let line = RefLine {
            name: "refs/tags/v1".to_string(),
            oid: Some(oid(1)),
            peeled: Some(oid(2)),
            symref_target: None,
        };
        assert_eq!(line.oid, Some(oid(1)));
        assert_eq!(line.peeled, Some(oid(2)));
    }

    #[test]
    fn create_update_has_no_expected() {
        let u = RefUpdate {
            name: "refs/heads/main".to_string(),
            expected: None,
            new: Some(oid(3)),
        };
        assert!(u.expected.is_none());
        assert_eq!(u.new, Some(oid(3)));
    }

    #[test]
    fn delete_update_has_no_new() {
        let u = RefUpdate {
            name: "refs/heads/main".to_string(),
            expected: Some(oid(3)),
            new: None,
        };
        assert!(u.new.is_none());
    }

    #[test]
    fn ref_outcome_is_a_value_not_an_error() {
        let ok = RefOutcome {
            name: "refs/heads/main".to_string(),
            result: Ok(()),
        };
        let stale = RefOutcome {
            name: "refs/heads/other".to_string(),
            result: Err("stale info".to_string()),
        };
        assert!(ok.result.is_ok());
        assert_eq!(
            stale.result.as_ref().err().map(String::as_str),
            Some("stale info")
        );
    }

    #[test]
    fn pack_validation_failure_is_the_unpack_ng_leg() {
        let prepared = PreparedPush {
            unpack: Err("index-pack failed".to_string()),
            updates: vec![RefUpdate {
                name: "refs/heads/main".to_string(),
                expected: None,
                new: Some(oid(4)),
            }],
        };
        assert!(prepared.unpack.is_err());
        assert_eq!(prepared.updates.len(), 1);
    }

    #[test]
    fn infrastructure_failure_is_err_on_prepare_result_only() {
        let prepared = PreparedPush {
            unpack: Ok(()),
            updates: Vec::new(),
        };
        assert!(prepared.unpack.is_ok());
        assert!(prepared.updates.is_empty());
    }

    #[test]
    fn push_options_preserve_repeated_keys_verbatim() {
        let opts = PushOptions {
            pairs: vec![
                ("private".to_string(), "yes".to_string()),
                ("private".to_string(), "no".to_string()),
            ],
        };
        assert_eq!(opts.pairs.len(), 2);
        assert_eq!(opts.pairs[0], ("private".to_string(), "yes".to_string()));
        assert_eq!(opts.pairs[1], ("private".to_string(), "no".to_string()));
    }
}