git-meta-cli 0.1.0

Command-line tool for structured Git metadata (get/set, serialize, materialize, push/pull). Installs the `git-meta` binary.
use gix::prelude::ObjectIdExt;
use predicates::prelude::*;

use crate::harness::{
    self, commit_target, open_repo, ref_to_commit_oid, setup_bare_with_meta, setup_repo,
};

#[test]
fn push_simple() {
    let (dir, sha) = setup_repo();
    let bare_dir = setup_bare_with_meta("meta");
    let bare_path = bare_dir.path().to_str().unwrap();

    harness::git_meta(dir.path())
        .args(["remote", "add", bare_path])
        .assert()
        .success();
    harness::git_meta(dir.path())
        .args(["pull"])
        .assert()
        .success();

    let target = commit_target(&sha);
    harness::git_meta(dir.path())
        .args(["set", &target, "agent:model", "claude-4.6"])
        .assert()
        .success();

    harness::git_meta(dir.path())
        .args(["push"])
        .assert()
        .success()
        .stdout(predicate::str::contains("Pushed metadata to meta"));

    let bare = open_repo(bare_dir.path());
    let tip_oid = ref_to_commit_oid(&bare, "refs/meta/main");
    let commit = tip_oid.attach(&bare).object().unwrap().into_commit();
    assert_eq!(
        commit.parent_ids().count(),
        1,
        "pushed commit should have exactly 1 parent (no merge commits)"
    );
}

#[test]
fn push_up_to_date() {
    let (dir, sha) = setup_repo();
    let bare_dir = setup_bare_with_meta("meta");
    let bare_path = bare_dir.path().to_str().unwrap();

    harness::git_meta(dir.path())
        .args(["remote", "add", bare_path])
        .assert()
        .success();
    harness::git_meta(dir.path())
        .args(["pull"])
        .assert()
        .success();

    let target = commit_target(&sha);
    harness::git_meta(dir.path())
        .args(["set", &target, "agent:model", "claude-4.6"])
        .assert()
        .success();

    harness::git_meta(dir.path())
        .args(["push"])
        .assert()
        .success();

    harness::git_meta(dir.path())
        .args(["push"])
        .assert()
        .success()
        .stdout(predicate::str::contains("Everything up-to-date"));
}

#[test]
fn push_commit_message_format() {
    let (dir, sha) = setup_repo();
    let bare_dir = setup_bare_with_meta("meta");
    let bare_path = bare_dir.path().to_str().unwrap();

    harness::git_meta(dir.path())
        .args(["remote", "add", bare_path])
        .assert()
        .success();
    harness::git_meta(dir.path())
        .args(["pull"])
        .assert()
        .success();

    let target = commit_target(&sha);
    harness::git_meta(dir.path())
        .args(["set", &target, "agent:model", "claude-4.6"])
        .assert()
        .success();
    harness::git_meta(dir.path())
        .args(["set", &target, "agent:cost", "0.05"])
        .assert()
        .success();
    harness::git_meta(dir.path())
        .args(["push"])
        .assert()
        .success();

    let bare = open_repo(bare_dir.path());
    let tip_oid = ref_to_commit_oid(&bare, "refs/meta/main");
    let commit = tip_oid.attach(&bare).object().unwrap().into_commit();
    let msg = commit.message_raw_sloppy().to_string();
    assert!(
        msg.contains("git-meta: serialize"),
        "commit message should contain 'git-meta: serialize', got: {msg}"
    );
    assert!(
        msg.contains("agent:model"),
        "commit message should contain changed key, got: {msg}"
    );
}

#[test]
fn push_conflict_produces_no_merge_commits() {
    let bare_dir = setup_bare_with_meta("meta");
    let bare_path = bare_dir.path().to_str().unwrap();

    let (dir_a, sha_a) = setup_repo();
    let (dir_b, sha_b) = setup_repo();

    harness::git_meta(dir_a.path())
        .args(["remote", "add", bare_path])
        .assert()
        .success();
    harness::git_meta(dir_b.path())
        .args(["remote", "add", bare_path])
        .assert()
        .success();

    harness::git_meta(dir_a.path())
        .args(["pull"])
        .assert()
        .success();
    harness::git_meta(dir_b.path())
        .args(["pull"])
        .assert()
        .success();

    let target_a = commit_target(&sha_a);
    harness::git_meta(dir_a.path())
        .args(["set", &target_a, "from:a", "value-a"])
        .assert()
        .success();
    harness::git_meta(dir_a.path())
        .args(["push"])
        .assert()
        .success();

    let target_b = commit_target(&sha_b);
    harness::git_meta(dir_b.path())
        .args(["set", &target_b, "from:b", "value-b"])
        .assert()
        .success();
    harness::git_meta(dir_b.path())
        .args(["push"])
        .assert()
        .success();

    let bare = open_repo(bare_dir.path());
    let tip_oid = ref_to_commit_oid(&bare, "refs/meta/main");

    let walk = bare.rev_walk(Some(tip_oid));
    let iter = walk.all().unwrap();
    for info_result in iter {
        let info = info_result.unwrap();
        let oid = info.id;
        let commit = oid.attach(&bare).object().unwrap().into_commit();
        let parent_count = commit.parent_ids().count();
        assert!(
            parent_count <= 1,
            "commit {} has {} parents — merge commits are not allowed in pushed history",
            &oid.to_string()[..8],
            parent_count
        );
    }
}

#[test]
fn pull_simple() {
    let (dir, _sha) = setup_repo();
    let bare_dir = setup_bare_with_meta("meta");
    let bare_path = bare_dir.path().to_str().unwrap();

    harness::git_meta(dir.path())
        .args(["remote", "add", bare_path])
        .assert()
        .success();

    harness::git_meta(dir.path())
        .args(["pull"])
        .assert()
        .success();

    harness::git_meta(dir.path())
        .args(["get", "project", "testing"])
        .assert()
        .success()
        .stdout(predicate::str::contains("hello"));
}

#[test]
fn pull_up_to_date() {
    let (dir, _sha) = setup_repo();
    let bare_dir = setup_bare_with_meta("meta");
    let bare_path = bare_dir.path().to_str().unwrap();

    harness::git_meta(dir.path())
        .args(["remote", "add", bare_path])
        .assert()
        .success();

    harness::git_meta(dir.path())
        .args(["pull"])
        .assert()
        .success();

    harness::git_meta(dir.path())
        .args(["pull"])
        .assert()
        .success()
        .stdout(predicate::str::contains("Already up-to-date"));
}

#[test]
fn pull_merges_with_local_data() {
    let (dir, sha) = setup_repo();
    let bare_dir = setup_bare_with_meta("meta");
    let bare_path = bare_dir.path().to_str().unwrap();

    let target = commit_target(&sha);
    harness::git_meta(dir.path())
        .args(["set", &target, "local:key", "local-value"])
        .assert()
        .success();

    harness::git_meta(dir.path())
        .args(["remote", "add", bare_path])
        .assert()
        .success();
    harness::git_meta(dir.path())
        .args(["pull"])
        .assert()
        .success();

    harness::git_meta(dir.path())
        .args(["get", &target, "local:key"])
        .assert()
        .success()
        .stdout(predicate::str::contains("local-value"));

    harness::git_meta(dir.path())
        .args(["get", "project", "testing"])
        .assert()
        .success()
        .stdout(predicate::str::contains("hello"));
}