use std::{sync::Arc, time::Duration};
use anyhow::{Context, Result, bail};
use nostr_sdk::prelude::*;
use rstest::*;
use test_harness::Harness;
use tokio::sync::OnceCell;
const DISPLAY_NAME: &str = "My Project";
const EXPECTED_IDENTIFIER: &str = "My-Project";
#[tokio::test]
async fn no_signer_fails_without_retrying_interactive_login() -> Result<()> {
let harness = Harness::builder(
env!("CARGO_BIN_EXE_ngit"),
env!("CARGO_BIN_EXE_git-remote-nostr"),
)
.build()
.await?;
let repo = harness.fresh_repo()?;
let commit = repo
.git(["commit", "--allow-empty", "-m", "initial commit"])
.output()
.await
.context("failed to create initial commit")?;
assert!(commit.status.success(), "failed to create initial commit");
let mut init = repo.ngit(["init"]);
init.kill_on_drop(true);
let out = tokio::time::timeout(Duration::from_secs(5), init.output())
.await
.context("`ngit init` did not fail promptly without a configured signer")?
.context("failed to spawn ngit init")?;
assert!(
!out.status.success(),
"expected `ngit init` to fail without a configured signer"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("nostr account required"),
"expected account-required error, got: {stderr}"
);
assert!(
stderr.contains("ngit account login") && stderr.contains("ngit account create"),
"expected login and account creation guidance, got: {stderr}"
);
assert!(
!stderr.contains("error getting fresh signer from nsec"),
"non-interactive init unexpectedly attempted fresh login: {stderr}"
);
Ok(())
}
#[tokio::test]
async fn bare_no_flags_errors_missing_required_fields() -> Result<()> {
let harness = Harness::builder(
env!("CARGO_BIN_EXE_ngit"),
env!("CARGO_BIN_EXE_git-remote-nostr"),
)
.with_relay("default")
.build()
.await?;
let (repo, _state) = harness.arrange_init_state_a_fresh().await?;
let out = repo
.ngit(["init"])
.output()
.await
.context("failed to spawn ngit init")?;
assert!(
!out.status.success(),
"expected `ngit init` to fail with no flags in State A; \
exited successfully\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("missing required fields"),
"expected 'missing required fields' error, got: {combined}",
);
Ok(())
}
#[tokio::test]
async fn name_only_errors_missing_grasp_server() -> Result<()> {
let harness = Harness::builder(
env!("CARGO_BIN_EXE_ngit"),
env!("CARGO_BIN_EXE_git-remote-nostr"),
)
.with_relay("default")
.build()
.await?;
let (repo, _state) = harness.arrange_init_state_a_fresh().await?;
let out = repo
.ngit(["init", "--name", DISPLAY_NAME])
.output()
.await
.context("failed to spawn ngit init --name")?;
assert!(
!out.status.success(),
"expected `ngit init --name` to fail in State A; exited successfully",
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("missing --grasp-server"),
"expected 'missing --grasp-server' error, got: {combined}",
);
Ok(())
}
#[tokio::test]
async fn relays_only_errors_missing_required_fields() -> Result<()> {
let harness = Harness::builder(
env!("CARGO_BIN_EXE_ngit"),
env!("CARGO_BIN_EXE_git-remote-nostr"),
)
.with_relay("default")
.build()
.await?;
let (repo, _state) = harness.arrange_init_state_a_fresh().await?;
let relay_url = harness.relay("default").url().to_string();
let out = repo
.ngit(["init", "--additional-relay", &relay_url])
.output()
.await
.context("failed to spawn ngit init --additional-relay")?;
assert!(
!out.status.success(),
"expected `ngit init --additional-relay <url>` to fail in State A; exited successfully",
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("missing required fields"),
"expected 'missing required fields' error, got: {combined}",
);
Ok(())
}
struct Snapshot {
announcement: Event,
grasp_http_url: String,
grasp_relay_url: String,
maintainer_npub: String,
root_oid: String,
}
static SNAPSHOT: OnceCell<Arc<Snapshot>> = OnceCell::const_new();
#[fixture]
async fn snapshot() -> Arc<Snapshot> {
SNAPSHOT
.get_or_init(|| async {
Arc::new(
capture_snapshot()
.await
.expect("init_state_fresh fixture: capture_snapshot failed"),
)
})
.await
.clone()
}
async fn capture_snapshot() -> Result<Snapshot> {
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 (repo, state) = harness.arrange_init_state_a_fresh().await?;
let grasp = harness.grasp("repo");
let grasp_http_url = grasp.url().to_string();
let grasp_relay_url = grasp.relay_url();
let init_out = repo
.ngit([
"init",
"--name",
DISPLAY_NAME,
"--grasp-server",
&grasp_http_url,
])
.output()
.await
.context("failed to spawn ngit init --name --grasp-server")?;
if !init_out.status.success() {
bail!(
"ngit init exited non-zero ({:?})\nstdout: {}\nstderr: {}",
init_out.status,
String::from_utf8_lossy(&init_out.stdout),
String::from_utf8_lossy(&init_out.stderr),
);
}
let announcements = harness
.relay("default")
.events(
Filter::new()
.author(state.keys.public_key())
.kind(Kind::GitRepoAnnouncement),
)
.await?;
let announcement = announcements
.into_iter()
.find(|e| tag_value(e, "d").as_deref() == Some(EXPECTED_IDENTIFIER))
.with_context(|| {
format!(
"no kind-30617 with `d` = {EXPECTED_IDENTIFIER:?} on the default \
relay after `ngit init --name`"
)
})?;
Ok(Snapshot {
announcement,
grasp_http_url,
grasp_relay_url,
maintainer_npub: state.npub,
root_oid: state.root_oid,
})
}
#[rstest]
#[tokio::test]
async fn identifier_derived_from_name(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
tag_value(&s.announcement, "d").as_deref(),
Some(EXPECTED_IDENTIFIER),
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn name_tag_matches(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
tag_value(&s.announcement, "name").as_deref(),
Some(DISPLAY_NAME)
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn description_empty(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
tag_value(&s.announcement, "description").as_deref(),
Some("")
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn clone_url_derived_from_grasp_server(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let clone_urls = tag_values(&s.announcement, "clone");
assert_eq!(
clone_urls.len(),
1,
"expected exactly one clone url; got {clone_urls:?}",
);
let url = &clone_urls[0];
assert!(
url.starts_with(&format!("{}/", s.grasp_http_url)),
"clone url should start with grasp HTTP base ({}/); got: {url}",
s.grasp_http_url,
);
assert!(
url.ends_with(&format!("/{EXPECTED_IDENTIFIER}.git")),
"clone url should end with /{EXPECTED_IDENTIFIER}.git; got: {url}",
);
assert!(
url.contains(&s.maintainer_npub),
"clone url should contain maintainer npub ({}); got: {url}",
s.maintainer_npub,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn relays_include_grasp_derived(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let relays = tag_values(&s.announcement, "relays");
assert_eq!(
relays,
vec![s.grasp_relay_url.clone()],
"a grasp-backed repository should not gain default additional relays",
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn sole_maintainer_is_implicit(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let membership_tags: Vec<Vec<String>> = s
.announcement
.tags
.iter()
.map(|t| t.as_slice().to_vec())
.filter(|tag| {
matches!(
tag.first().map(String::as_str),
Some("M" | "m" | "o" | "maintainers")
)
})
.collect();
assert_eq!(
membership_tags,
Vec::<Vec<String>>::new(),
"the announcement author should remain the implicit sole maintainer",
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn earliest_unique_commit_is_root(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let euc = s
.announcement
.tags
.iter()
.find_map(|t| {
let parts = t.as_slice();
if parts.first().map(String::as_str) == Some("r")
&& parts.len() > 2
&& parts.get(2).map(String::as_str) == Some("euc")
{
parts.get(1).cloned()
} else {
None
}
})
.context("announcement missing the `r <oid> euc` tag")?;
assert_eq!(euc, s.root_oid);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn vanilla_clone_url_passes_through_to_announcement() -> Result<()> {
let harness = Harness::builder(
env!("CARGO_BIN_EXE_ngit"),
env!("CARGO_BIN_EXE_git-remote-nostr"),
)
.with_relay("default")
.with_vanilla_git_server("host")
.build()
.await?;
let (repo, state) = harness.arrange_init_state_a_fresh().await?;
let vanilla_url = harness.vanilla_git_server("host").url().to_string();
let default_relay_url = harness.relay("default").url().to_string();
let ls = tokio::process::Command::new("git")
.args(["ls-remote", &vanilla_url])
.output()
.await
.context("failed to spawn git ls-remote against vanilla server")?;
assert!(
ls.status.success(),
"ls-remote against harness-managed vanilla git server failed: stdout={} stderr={}",
String::from_utf8_lossy(&ls.stdout),
String::from_utf8_lossy(&ls.stderr),
);
assert!(
String::from_utf8_lossy(&ls.stdout).trim().is_empty(),
"empty bare repo should advertise zero refs; got: {}",
String::from_utf8_lossy(&ls.stdout),
);
let init_out = repo
.ngit([
"init",
"--name",
DISPLAY_NAME,
"--additional-clone",
&vanilla_url,
"--additional-relay",
&default_relay_url,
])
.output()
.await
.context("failed to spawn ngit init with additional clone and relay")?;
if !init_out.status.success() {
bail!(
"ngit init exited non-zero ({:?})\nstdout: {}\nstderr: {}",
init_out.status,
String::from_utf8_lossy(&init_out.stdout),
String::from_utf8_lossy(&init_out.stderr),
);
}
let announcements = harness
.relay("default")
.events(
Filter::new()
.author(state.keys.public_key())
.kind(Kind::GitRepoAnnouncement),
)
.await?;
let announcement = announcements
.into_iter()
.find(|e| tag_value(e, "d").as_deref() == Some(EXPECTED_IDENTIFIER))
.with_context(|| {
format!(
"no kind-30617 with `d` = {EXPECTED_IDENTIFIER:?} on the default \
relay after `ngit init` with additional clone and relay"
)
})?;
let clone_urls = tag_values(&announcement, "clone");
assert!(
clone_urls.iter().any(|u| u == &vanilla_url),
"expected vanilla URL {vanilla_url:?} verbatim in announcement's \
clone tag (no <npub>/<id>.git synthesis on the non-grasp path); \
got {clone_urls:?}",
);
assert_eq!(
harness
.vanilla_git_server("host")
.nostr_authorization_requests(),
0,
"a public mirror must not receive private-repository HTTP authorization",
);
let mirror = git2::Repository::open_bare(harness.vanilla_git_server("host").repo_path())?;
assert_eq!(
mirror.refname_to_id("refs/heads/main")?.to_string(),
state.head_oid,
"the initial branch must be pushed after an empty advertisement",
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pre_existing_origin_with_tag_promotes_to_nostr_and_state_event_covers_tag() -> Result<()> {
const TAG_NAME: &str = "v0.1.0";
let harness = Harness::builder(
env!("CARGO_BIN_EXE_ngit"),
env!("CARGO_BIN_EXE_git-remote-nostr"),
)
.with_relay("default")
.with_vanilla_git_server("host")
.build()
.await?;
let (repo, state) = harness.arrange_init_state_a_fresh().await?;
let vanilla_url = harness.vanilla_git_server("host").url().to_string();
let default_relay_url = harness.relay("default").url().to_string();
let out = repo
.git(["remote", "add", "origin", &vanilla_url])
.output()
.await
.context("failed to spawn git remote add origin")?;
if !out.status.success() {
bail!(
"git remote add origin exited non-zero ({:?})\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
}
let out = repo
.git(["push", "origin", "main"])
.output()
.await
.context("failed to spawn git push origin main")?;
if !out.status.success() {
bail!(
"git push origin main exited non-zero ({:?})\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
}
let out = repo
.git(["tag", "-a", TAG_NAME, "-m", "release v0.1.0"])
.output()
.await
.context("failed to spawn git tag -a")?;
if !out.status.success() {
bail!(
"git tag -a exited non-zero ({:?})\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
}
let out = repo
.git(["push", "origin", "tag", TAG_NAME])
.output()
.await
.context("failed to spawn git push origin tag")?;
if !out.status.success() {
bail!(
"git push origin tag {TAG_NAME} exited non-zero ({:?})\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
}
let ls = tokio::process::Command::new("git")
.args(["ls-remote", "--tags", &vanilla_url])
.output()
.await
.context("failed to spawn git ls-remote --tags against vanilla server")?;
assert!(
ls.status.success(),
"ls-remote against vanilla server failed: stdout={} stderr={}",
String::from_utf8_lossy(&ls.stdout),
String::from_utf8_lossy(&ls.stderr),
);
let tag_listing = String::from_utf8_lossy(&ls.stdout);
assert!(
tag_listing.contains(&format!("refs/tags/{TAG_NAME}")),
"expected refs/tags/{TAG_NAME} on vanilla server before ngit init; \
ls-remote --tags reported: {tag_listing}",
);
let init_out = repo
.ngit([
"init",
"--name",
DISPLAY_NAME,
"--additional-clone",
&vanilla_url,
"--additional-relay",
&default_relay_url,
])
.output()
.await
.context("failed to spawn ngit init")?;
if !init_out.status.success() {
bail!(
"ngit init exited non-zero ({:?})\nstdout: {}\nstderr: {}",
init_out.status,
String::from_utf8_lossy(&init_out.stdout),
String::from_utf8_lossy(&init_out.stderr),
);
}
let origin_url = repo
.config("remote.origin.url")
.await
.context("failed to read remote.origin.url after ngit init")?
.context("remote.origin.url was unset after ngit init")?;
assert!(
origin_url.starts_with("nostr://"),
"expected `remote.origin.url` rewritten from {vanilla_url:?} to a \
nostr:// URL after ngit init Step 7; got {origin_url:?}",
);
let state_events = harness
.relay("default")
.events(
Filter::new()
.author(state.keys.public_key())
.kind(Kind::Custom(30618)),
)
.await?;
let state_event = state_events
.into_iter()
.find(|e| tag_value(e, "d").as_deref() == Some(EXPECTED_IDENTIFIER))
.with_context(|| {
format!(
"no kind-30618 state event with `d` = {EXPECTED_IDENTIFIER:?} on \
the default relay after `ngit init` against a repo with a \
pre-existing reachable `origin` remote — the origin-state \
branch in init.rs:1213-1257 should have fired"
)
})?;
let ref_tag_names: Vec<String> = state_event
.tags
.iter()
.filter_map(|t| {
let s = t.as_slice();
s.first().and_then(|name| {
if name.starts_with("refs/heads/") || name.starts_with("refs/tags/") {
Some(name.clone())
} else {
None
}
})
})
.collect();
assert!(
ref_tag_names
.iter()
.any(|n| n == &format!("refs/tags/{TAG_NAME}")),
"expected first kind-30618 to cover refs/tags/{TAG_NAME} taken from \
the existing origin's state (the user never passed the tag on the \
`ngit init` command line); got ref-name tags: {ref_tag_names:?}",
);
let snapshot = repo.snapshot()?;
let local_main = snapshot
.refs
.get("refs/heads/main")
.context("refs/heads/main missing after ngit init")?;
let tracking_main = snapshot.refs.get("refs/remotes/origin/main").context(
"refs/remotes/origin/main missing after ngit init — the \
origin-derived state push should record tracking refs for the \
branches it published",
)?;
assert_eq!(
tracking_main, local_main,
"expected refs/remotes/origin/main to match the pushed main tip",
);
Ok(())
}
#[tokio::test]
async fn lead_maintainer_self_emits_uppercase_m_role_tag() -> Result<()> {
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 (repo, state) = harness.arrange_init_state_a_fresh().await?;
let grasp_http_url = harness.grasp("repo").url().to_string();
let init_out = repo
.ngit([
"init",
"--name",
DISPLAY_NAME,
"--grasp-server",
&grasp_http_url,
])
.output()
.await
.context("failed to spawn ngit init")?;
if !init_out.status.success() {
bail!(
"ngit init exited non-zero ({:?})\nstdout: {}\nstderr: {}",
init_out.status,
String::from_utf8_lossy(&init_out.stdout),
String::from_utf8_lossy(&init_out.stderr),
);
}
repo.nostr_push(["-u", "origin", "main"])
.await
.context("graduate sole-maintainer announcement")?;
let edit_out = repo
.ngit(["repo", "edit", "--lead-maintainer", &state.npub])
.output()
.await
.context("failed to spawn ngit repo edit --lead-maintainer")?;
if !edit_out.status.success() {
bail!(
"ngit repo edit --lead-maintainer exited non-zero ({:?})\nstdout: {}\nstderr: {}",
edit_out.status,
String::from_utf8_lossy(&edit_out.stdout),
String::from_utf8_lossy(&edit_out.stderr),
);
}
let announcements = harness
.relay("default")
.events(
Filter::new()
.author(state.keys.public_key())
.kind(Kind::GitRepoAnnouncement),
)
.await?;
let announcement = announcements
.into_iter()
.find(|e| tag_value(e, "d").as_deref() == Some(EXPECTED_IDENTIFIER))
.with_context(|| {
format!(
"no kind-30617 with `d` = {EXPECTED_IDENTIFIER:?} on the default \
relay after `ngit repo edit --lead-maintainer`"
)
})?;
let role_tags: Vec<Vec<String>> = announcement
.tags
.iter()
.map(|t| t.as_slice().to_vec())
.filter(|t| t.first().is_some_and(|name| name == "M" || name == "m"))
.collect();
let author = state.keys.public_key().to_string();
assert_eq!(
role_tags,
vec![vec!["M".to_string(), author]],
"the implicit sole maintainer should be lead from the beginning without m history",
);
assert_eq!(
tag_values(&announcement, "maintainers"),
vec![state.keys.public_key().to_string()],
"deprecated `maintainers` tag should carry the same sole member",
);
Ok(())
}
fn tag_value(event: &Event, key: &str) -> Option<String> {
event.tags.iter().find_map(|t| {
let s = t.as_slice();
if s.first().map(String::as_str) == Some(key) {
s.get(1).cloned()
} else {
None
}
})
}
fn tag_values(event: &Event, key: &str) -> Vec<String> {
event
.tags
.iter()
.find(|t| t.as_slice().first().map(String::as_str) == Some(key))
.map(|t| t.as_slice()[1..].to_vec())
.unwrap_or_default()
}