use std::collections::{BTreeMap, HashMap};
use anyhow::{Context, Result};
use nostr_sdk::prelude::*;
use test_harness::{
CloneLogin, Harness, PublishRepoOpts, PublishStateEventOpts, PublishStateEventTarget,
PublishedRepo, Repo,
};
async fn setup() -> Result<(Harness, Repo, PublishedRepo)> {
let harness = Harness::builder(
env!("CARGO_BIN_EXE_ngit"),
env!("CARGO_BIN_EXE_git-remote-nostr"),
)
.with_relay("default")
.with_grasp_server("repo")
.build()
.await?;
let (publisher, published) = harness
.publish_repo(PublishRepoOpts {
display_name: Some("list-state maintainer".into()),
identifier: Some("list-state-repo".into()),
..Default::default()
})
.await?;
Ok((harness, publisher, published))
}
async fn commit_on_branch(repo: &Repo, branch: &str, file: &str, content: &str) -> Result<String> {
git_ok(repo, ["checkout", "-b", branch], "git checkout -b").await?;
std::fs::write(repo.dir().join(file), content).with_context(|| format!("write {file}"))?;
git_ok(repo, ["add", file], "git add").await?;
git_ok(
repo,
["commit", "-m", &format!("add {file}"), "--no-gpg-sign"],
"git commit",
)
.await?;
rev_parse(repo, "HEAD").await
}
async fn push_branch(repo: &Repo, branch: &str) -> Result<()> {
repo.nostr_push(["-u", "origin", branch])
.await
.with_context(|| format!("git push origin {branch}"))?;
Ok(())
}
async fn rev_parse(repo: &Repo, rev: &str) -> Result<String> {
let out = repo
.git(["rev-parse", rev])
.output()
.await
.with_context(|| format!("git rev-parse {rev}"))?;
anyhow::ensure!(
out.status.success(),
"git rev-parse {rev} exited {:?}: {}",
out.status,
String::from_utf8_lossy(&out.stderr),
);
Ok(String::from_utf8(out.stdout)
.context("git rev-parse stdout not utf-8")?
.trim()
.to_string())
}
async fn git_ok<I, S>(repo: &Repo, args: I, label: &str) -> Result<()>
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
let out = repo
.git(args)
.output()
.await
.with_context(|| format!("spawn {label}"))?;
anyhow::ensure!(
out.status.success(),
"{label} exited {:?}\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
Ok(())
}
async fn ls_remote_via_clone(
harness: &Harness,
published: &PublishedRepo,
) -> Result<LsRemoteOutput> {
let clone = harness
.clone_published_repo(published, CloneLogin::None)
.await?;
ls_remote(&clone, "origin").await
}
struct LsRemoteOutput {
symrefs: BTreeMap<String, String>,
refs: BTreeMap<String, String>,
}
impl LsRemoteOutput {
fn heads(&self) -> BTreeMap<&str, &str> {
self.refs
.iter()
.filter_map(|(k, v)| k.strip_prefix("refs/heads/").map(|name| (name, v.as_str())))
.collect()
}
}
async fn ls_remote(repo: &Repo, remote: &str) -> Result<LsRemoteOutput> {
let out = repo
.git(["ls-remote", "--symref", remote])
.output()
.await
.with_context(|| format!("spawn git ls-remote --symref {remote}"))?;
anyhow::ensure!(
out.status.success(),
"git ls-remote --symref {remote} exited {:?}\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
let stdout = String::from_utf8(out.stdout).context("ls-remote stdout not utf-8")?;
let mut symrefs = BTreeMap::new();
let mut refs = BTreeMap::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some(rest) = line.strip_prefix("ref: ") {
let (target, name) = rest
.split_once('\t')
.with_context(|| format!("malformed symref line: {line:?}"))?;
symrefs.insert(name.to_string(), target.to_string());
continue;
}
let (oid, name) = line
.split_once('\t')
.with_context(|| format!("malformed ref line: {line:?}"))?;
refs.insert(name.to_string(), oid.to_string());
}
Ok(LsRemoteOutput { symrefs, refs })
}
async fn find_state_event_covering(
harness: &Harness,
repo: &PublishedRepo,
ref_name: &str,
expected_oid: &str,
) -> Result<Event> {
let kind = nostr::prelude::Kind::Custom(30618);
let events = harness
.grasp("repo")
.events(
nostr::prelude::Filter::new()
.kind(kind)
.author(repo.maintainer_keys.public_key()),
)
.await?;
let mut sorted = events;
sorted.sort_by_key(|event| std::cmp::Reverse(event.created_at));
sorted
.iter()
.find(|event| {
event.tags.iter().any(|tag| {
let values = tag.as_slice();
values.first().map(String::as_str) == Some(ref_name)
&& values.get(1).map(String::as_str) == Some(expected_oid)
})
})
.cloned()
.with_context(|| {
format!(
"successful push returned before kind-30618 advertised \
{ref_name}={expected_oid}; observed {} event(s)",
sorted.len()
)
})
}
#[tokio::test]
async fn lists_head_and_branches_from_git_server_when_state_event_matches() -> Result<()> {
let (harness, publisher, published) = setup().await?;
let vnext_oid = commit_on_branch(&publisher, "vnext", "vnext.md", "vnext\n").await?;
push_branch(&publisher, "vnext").await?;
let main_oid = published.initial_oid.clone();
find_state_event_covering(&harness, &published, "refs/heads/vnext", &vnext_oid).await?;
let ls = ls_remote_via_clone(&harness, &published).await?;
assert_eq!(
ls.symrefs.get("HEAD").map(String::as_str),
Some("refs/heads/main"),
"HEAD should be a symref to refs/heads/main",
);
let heads: HashMap<&str, &str> = ls.heads().into_iter().collect();
assert_eq!(
heads.get("main"),
Some(&main_oid.as_str()),
"refs/heads/main should resolve to the publisher's main tip",
);
assert_eq!(
heads.get("vnext"),
Some(&vnext_oid.as_str()),
"refs/heads/vnext should resolve to the publisher's vnext tip",
);
Ok(())
}
#[tokio::test]
async fn immediate_second_push_orders_state_from_cached_first_push() -> Result<()> {
let (harness, publisher, published) = setup().await?;
let first = find_state_event_covering(
&harness,
&published,
"refs/heads/main",
&published.initial_oid,
)
.await?;
let vnext_oid = commit_on_branch(&publisher, "vnext", "vnext.md", "vnext\n").await?;
let push = publisher
.nostr_push(["-u", "origin", "vnext"])
.await
.context("immediate nostr push -u origin vnext")?;
anyhow::ensure!(
push.status.success(),
"immediate nostr push failed: {}",
String::from_utf8_lossy(&push.stderr)
);
let second =
find_state_event_covering(&harness, &published, "refs/heads/vnext", &vnext_oid).await?;
assert!(
second.created_at > first.created_at
|| (second.created_at == first.created_at && second.id < first.id),
"second state must win under NIP-01 ordering: first=({}, {}), second=({}, {})",
first.created_at,
first.id,
second.created_at,
second.id,
);
Ok(())
}
#[tokio::test]
async fn grasp_exposes_same_second_lower_id_state_replacement() -> Result<()> {
let (harness, _publisher, published) = setup().await?;
let current = find_state_event_covering(
&harness,
&published,
"refs/heads/main",
&published.initial_oid,
)
.await?;
let created_at = Timestamp::from_secs(current.created_at.as_secs() + 1);
let state_tags: Vec<Tag> = current
.tags
.iter()
.filter(|tag| {
!matches!(
tag.as_slice(),
[name, _, difficulty, marker]
if name == "nonce" && difficulty == "0" && marker == "ngit-created-at-tiebreak"
)
})
.cloned()
.collect();
let build = |nonce: u64| {
EventBuilder::new(Kind::Custom(30618), "")
.tags(state_tags.clone())
.tag(Tag::custom(
"nonce",
vec![
nonce.to_string(),
"0".to_string(),
"ngit-created-at-tiebreak".to_string(),
],
))
.custom_created_at(created_at)
.finalize(&published.maintainer_keys)
.expect("sign state fixture")
};
let mut candidates = [build(0), build(1)];
candidates.sort_by_key(|event| event.id);
let [lower_id, higher_id] = candidates;
let relay_url = harness.grasp("repo").relay_url();
let client = Client::default();
client.add_relay(&relay_url).await?;
client.connect().await;
for event in [&higher_id, &lower_id] {
let output = client.send_event(event).to([relay_url.as_str()]).await?;
anyhow::ensure!(
output.failed.is_empty(),
"GRASP rejected state {}: {:?}",
event.id,
output.failed
);
}
client.disconnect().await;
let exposed = harness
.grasp("repo")
.events(
Filter::new()
.kind(Kind::Custom(30618))
.author(published.maintainer_keys.public_key()),
)
.await?
.into_iter()
.find(|event| event.tags.identifier().as_deref() == Some(published.identifier.as_str()))
.context("GRASP did not expose repository state")?;
assert_eq!(exposed.created_at, higher_id.created_at);
assert_eq!(exposed.id, lower_id.id);
assert!(lower_id.id < higher_id.id);
Ok(())
}
#[tokio::test]
async fn falls_back_to_git_server_when_state_event_references_missing_oids() -> Result<()> {
let (harness, _publisher, published) = setup().await?;
let fake_oid = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string();
let real_main_oid = published.initial_oid.clone();
let mut state = BTreeMap::new();
state.insert("HEAD".to_string(), "ref: refs/heads/main".to_string());
state.insert("refs/heads/main".to_string(), fake_oid.clone());
harness
.publish_state_event(
&published,
PublishStateEventOpts {
state,
..Default::default()
},
)
.await?;
let ls = ls_remote_via_clone(&harness, &published).await?;
let heads = ls.heads();
assert!(
!heads.values().any(|v| *v == fake_oid),
"fake OID from the fabricated state event must NOT be advertised; got {heads:?}",
);
assert_eq!(
heads.get("main").copied(),
Some(real_main_oid.as_str()),
"main should fall back to the bare repo's actual oid; got {heads:?}",
);
Ok(())
}
#[tokio::test]
async fn lists_branches_from_state_event_when_matches_git_server() -> Result<()> {
let (harness, publisher, published) = setup().await?;
let vnext_oid =
commit_on_branch(&publisher, "example-branch", "example.md", "example\n").await?;
push_branch(&publisher, "example-branch").await?;
let main_oid = published.initial_oid.clone();
let mut state = BTreeMap::new();
state.insert("HEAD".to_string(), "ref: refs/heads/main".to_string());
state.insert("refs/heads/main".to_string(), main_oid.clone());
state.insert("refs/heads/example-branch".to_string(), vnext_oid.clone());
harness
.publish_state_event(
&published,
PublishStateEventOpts {
state,
..Default::default()
},
)
.await?;
let ls = ls_remote_via_clone(&harness, &published).await?;
let heads = ls.heads();
assert_eq!(heads.get("main").copied(), Some(main_oid.as_str()));
assert_eq!(
heads.get("example-branch").copied(),
Some(vnext_oid.as_str()),
);
assert_eq!(
ls.symrefs.get("HEAD").map(String::as_str),
Some("refs/heads/main"),
);
Ok(())
}
#[tokio::test]
async fn state_event_takes_precedence_over_advanced_git_server_state() -> Result<()> {
let (harness, publisher, published) = setup().await?;
let original_main_oid = published.initial_oid.clone();
let example_oid =
commit_on_branch(&publisher, "example-branch", "example.md", "example\n").await?;
push_branch(&publisher, "example-branch").await?;
let mut state = BTreeMap::new();
state.insert("HEAD".to_string(), "ref: refs/heads/main".to_string());
state.insert("refs/heads/main".to_string(), original_main_oid.clone());
state.insert("refs/heads/example-branch".to_string(), example_oid.clone());
harness
.publish_state_event(
&published,
PublishStateEventOpts {
state,
..Default::default()
},
)
.await?;
git_ok(&publisher, ["checkout", "main"], "git checkout main").await?;
std::fs::write(publisher.dir().join("commitx.md"), "some content\n")
.context("write commitx.md")?;
git_ok(&publisher, ["add", "commitx.md"], "git add commitx").await?;
git_ok(
&publisher,
["commit", "-m", "add commitx.md", "--no-gpg-sign"],
"git commit commitx",
)
.await?;
let advanced_main_oid = rev_parse(&publisher, "HEAD").await?;
assert_ne!(advanced_main_oid, original_main_oid);
push_branch(&publisher, "main").await?;
let mut state = BTreeMap::new();
state.insert("HEAD".to_string(), "ref: refs/heads/main".to_string());
state.insert("refs/heads/main".to_string(), original_main_oid.clone());
state.insert("refs/heads/example-branch".to_string(), example_oid.clone());
harness
.publish_state_event(
&published,
PublishStateEventOpts {
state,
..Default::default()
},
)
.await?;
let ls = ls_remote_via_clone(&harness, &published).await?;
let heads = ls.heads();
assert_eq!(
heads.get("main").copied(),
Some(original_main_oid.as_str()),
"main should reflect the state event's view (original oid), not the \
advanced bare-repo tip; got {heads:?}",
);
assert_eq!(
heads.get("example-branch").copied(),
Some(example_oid.as_str()),
);
Ok(())
}
#[tokio::test]
async fn uses_older_resolvable_state_event_from_different_relay() -> Result<()> {
let harness = Harness::builder(
env!("CARGO_BIN_EXE_ngit"),
env!("CARGO_BIN_EXE_git-remote-nostr"),
)
.with_relay("default")
.with_grasp_server("repo")
.with_relay("repo-extra")
.build()
.await?;
let extra_relay_url = harness.relay("repo-extra").url().to_string();
let (_publisher, published) = harness
.publish_repo(PublishRepoOpts {
display_name: Some("list-state maintainer".into()),
identifier: Some("list-state-fallback-repo".into()),
extra_repo_relays: vec![extra_relay_url.clone()],
..Default::default()
})
.await?;
let real_main_oid = published.initial_oid.clone();
let fake_oid = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string();
assert_ne!(fake_oid, real_main_oid);
let mut state = BTreeMap::new();
state.insert("HEAD".to_string(), "ref: refs/heads/main".to_string());
state.insert("refs/heads/main".to_string(), fake_oid.clone());
harness
.publish_state_event(
&published,
PublishStateEventOpts {
state,
target: PublishStateEventTarget::RelayUrl(extra_relay_url.clone()),
..Default::default()
},
)
.await?;
let ls = ls_remote_via_clone(&harness, &published).await?;
let heads = ls.heads();
assert!(
!ls.refs.values().any(|v| *v == fake_oid),
"fake OID from the newer-unresolvable state event on {extra_relay_url} \
must NOT be advertised for any ref; got {refs:?}",
refs = ls.refs,
);
assert_eq!(
heads.get("main").copied(),
Some(real_main_oid.as_str()),
"main should fall back to the older-but-resolvable state event on \
the grasp (real OID), not the newer-unresolvable one on the vanilla \
relay (fake OID); got {heads:?}",
);
Ok(())
}