agentstategraph 1.4.1

AI-native versioned state store for intent-based systems
Documentation
//! `--from`/merge RefSpec resolution: branch names, full commit hashes, and
//! unique `sg_`/hex commit-id prefixes, with distinct error variants.

use agentstategraph::{CommitOptions, RepoError, Repository};
use agentstategraph_core::{IntentCategory, Object};
use agentstategraph_storage::SqliteStorage;

fn repo() -> Repository {
    let r = Repository::new(Box::new(
        SqliteStorage::in_memory().expect("in-memory sqlite"),
    ));
    r.init().unwrap();
    r
}

fn opts(desc: &str) -> CommitOptions {
    CommitOptions::new("agent/test", IntentCategory::Checkpoint, desc)
}

/// Advance main and return its head commit id.
fn commit(r: &Repository, path: &str, v: i64, desc: &str) -> agentstategraph_core::ObjectId {
    r.set("main", path, &Object::int(v), opts(desc)).unwrap()
}

#[test]
fn branch_from_full_commit_hash() {
    let r = repo();
    let c = commit(&r, "/x", 1, "c1");
    commit(&r, "/y", 2, "c2"); // main moves on; c is now historical

    // sg_-prefixed full hash
    r.branch("recovery", &format!("{c}")).unwrap();
    assert_eq!(r.get("recovery", "/x").unwrap(), Object::int(1));
    // recovery is pinned at c, so /y must NOT be present
    assert!(r.get("recovery", "/y").is_err());
}

#[test]
fn branch_from_unique_prefix() {
    let r = repo();
    let c = commit(&r, "/x", 1, "c1");
    commit(&r, "/y", 2, "c2");

    let short = c.short(); // "sg_<12 hex>"
    r.branch("recovery", &short).unwrap();
    assert_eq!(r.get("recovery", "/x").unwrap(), Object::int(1));

    // Raw hex prefix (no sg_) must also resolve.
    let raw = &c.to_hex()[..10];
    r.branch("recovery2", raw).unwrap();
    assert_eq!(r.get("recovery2", "/x").unwrap(), Object::int(1));
}

#[test]
fn branch_from_orphaned_commit() {
    // A commit whose branch was deleted must still be resolvable by id.
    let r = repo();
    r.branch("temp", "main").unwrap();
    let c = r
        .set("temp", "/orphan", &Object::int(9), opts("on temp"))
        .unwrap();
    assert!(r.delete_branch("temp").unwrap());

    r.branch("recovery", &format!("{c}")).unwrap();
    assert_eq!(r.get("recovery", "/orphan").unwrap(), Object::int(9));
}

#[test]
fn unknown_branch_name_is_branch_not_found() {
    let r = repo();
    let err = r.branch("feat", "definitely-not-a-ref").unwrap_err();
    assert!(matches!(err, RepoError::BranchNotFound(_)), "got {err:?}");
}

#[test]
fn hex_shaped_miss_is_commit_not_found() {
    let r = repo();
    // Well-formed but nonexistent prefix.
    let err = r.branch("feat", "sg_deadbeef").unwrap_err();
    assert!(matches!(err, RepoError::CommitNotFound(_)), "got {err:?}");
}

#[test]
fn ambiguous_prefix_errors() {
    let r = repo();
    // With more than 16 commits, pigeonhole guarantees at least two commit ids
    // share the same first hex digit, so some 1-hex-digit prefix is ambiguous.
    for i in 0..24 {
        commit(&r, &format!("/k{i}"), i, "c");
    }

    let mut saw_ambiguous = false;
    for digit in "0123456789abcdef".chars() {
        if let Err(RepoError::AmbiguousCommitPrefix { count, .. }) =
            r.branch(&format!("b_{digit}"), &digit.to_string())
        {
            assert!(count >= 2);
            saw_ambiguous = true;
            break;
        }
    }
    assert!(
        saw_ambiguous,
        "expected some 1-hex-digit prefix to be ambiguous"
    );
}

/// A repository limited to branch names refuses commit ids and prefixes,
/// which resolve over every namespace's commits, and its forks keep the
/// limit. Branch names still resolve, including one that looks like hex.
#[test]
fn branch_refs_only_refuses_commit_ids() {
    let r = repo().with_branch_refs_only();
    let c = commit(&r, "/x", 1, "c1");
    let hex = c.to_hex();
    for spec in [hex.clone(), format!("sg_{hex}"), hex[..10].to_string()] {
        match r.get_json(&spec, "/x") {
            Err(RepoError::CommitRefNotAllowed(s)) => assert_eq!(s, spec),
            other => panic!("{spec}: {other:?}"),
        }
    }
    assert!(matches!(
        r.get_json("no-such-branch", "/x"),
        Err(RepoError::BranchNotFound(_))
    ));
    assert_eq!(r.get_json("main", "/x").unwrap(), serde_json::json!(1));
    r.branch("abc123", "main").unwrap();
    assert_eq!(r.get_json("abc123", "/x").unwrap(), serde_json::json!(1));

    let fork = r.fork_namespace(agentstategraph_core::Namespace::default_ns());
    assert!(matches!(
        fork.get_json(&hex, "/x"),
        Err(RepoError::CommitRefNotAllowed(_))
    ));
    // Without the limit, the same id resolves.
    let open = repo();
    let c = commit(&open, "/x", 1, "c1");
    assert_eq!(
        open.get_json(&c.to_hex(), "/x").unwrap(),
        serde_json::json!(1)
    );
}