use std::collections::{BTreeMap, HashMap};
use anyhow::{Context, Result};
use nostr::{
event::FinalizeEvent,
prelude::{Event, EventBuilder, EventId, Keys, Kind, Tag},
};
use nostr_sdk::prelude::Client;
use test_harness::{
CloneLogin, Harness, PublishRepoOpts, PublishedPatchSeries, PublishedRepo, Repo,
};
async fn setup() -> Result<(Harness, PublishedRepo, [PublishedPatchSeries; 3])> {
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-patch maintainer".into()),
identifier: Some("list-patch-repo".into()),
..Default::default()
})
.await?;
let series = harness
.publish_three_open_patch_proposals(&published)
.await?;
Ok((harness, published, series))
}
fn expected_long_branch(series: &PublishedPatchSeries) -> Result<String> {
let root_id = root_event_id(series)?;
let hex = root_id.to_hex();
Ok(format!("pr/{}({})", series.branch_name, &hex[..8]))
}
fn root_event_id(series: &PublishedPatchSeries) -> Result<EventId> {
if let Some(cl) = &series.cover_letter_event {
return Ok(cl.id);
}
series
.patch_events
.first()
.map(|e| e.id)
.context("patch series has no events — programmer error")
}
#[derive(Debug)]
struct LsRemoteOutput {
refs: BTreeMap<String, String>,
}
async fn ls_remote(repo: &Repo, remote: &str) -> Result<LsRemoteOutput> {
let out = repo
.git(["ls-remote", remote])
.output()
.await
.with_context(|| format!("spawn git ls-remote {remote}"))?;
anyhow::ensure!(
out.status.success(),
"git ls-remote {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 refs = BTreeMap::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with("ref: ") {
continue;
}
let (oid, name) = line
.split_once('\t')
.with_context(|| format!("malformed ls-remote line: {line:?}"))?;
refs.insert(name.to_string(), oid.to_string());
}
Ok(LsRemoteOutput { refs })
}
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!("failed to 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 publish_to_repo_grasp(harness: &Harness, event: &Event) -> Result<()> {
let relay_url = harness.grasp("repo").relay_url();
let client = Client::default();
client
.add_relay(&relay_url)
.await
.with_context(|| format!("failed to add repository relay {relay_url}"))?;
client.connect().await;
let output = client
.send_event(event)
.to([relay_url.as_str()])
.await
.with_context(|| format!("failed to publish malformed patch to {relay_url}"))?;
client.disconnect().await;
anyhow::ensure!(
output.failed.is_empty(),
"repository relay rejected malformed patch {}: {:?}",
event.id,
output.failed,
);
Ok(())
}
#[tokio::test]
async fn enabling_auto_pr_branches_lists_open_patch_proposals() -> Result<()> {
let (harness, published, series) = setup().await?;
let test_repo = harness
.clone_published_repo(&published, CloneLogin::None)
.await?;
git_ok(
&test_repo,
["config", "--local", "nostr.auto-pr-branches", "true"],
"enable automatic PR branches",
)
.await?;
let ls = ls_remote(&test_repo, "origin").await?;
for (idx, s) in series.iter().enumerate() {
let long = expected_long_branch(s)?;
let root_hex = root_event_id(s)?.to_hex();
let expected: HashMap<String, &str> = [
(format!("refs/heads/{long}"), s.tip.as_str()),
(format!("refs/{long}"), s.tip.as_str()),
(format!("refs/pr/{root_hex}/head"), s.tip.as_str()),
]
.into_iter()
.collect();
for (ref_name, want_oid) in expected {
let got = ls.refs.get(&ref_name).cloned();
assert_eq!(
got.as_deref(),
Some(want_oid),
"patch-series #{idx} ({:?}): expected {ref_name} → {want_oid}, got {got:?}\n\
full ls-remote refs: {:#?}",
s.branch_name,
ls.refs,
);
}
}
assert_eq!(
ls.refs.get("refs/heads/main").map(String::as_str),
Some(published.initial_oid.as_str()),
"main should still be listed alongside the patch-series PR namespaces",
);
Ok(())
}
#[tokio::test]
async fn malformed_patch_events_do_not_break_passive_ref_listing() -> Result<()> {
let (harness, published, series) = setup().await?;
let test_repo = harness
.clone_published_repo(&published, CloneLogin::None)
.await?;
let template = series[0]
.cover_letter_event
.as_ref()
.context("patch-series fixture should include a cover letter")?;
let mut malformed_description_tags = template.tags.clone().to_vec();
malformed_description_tags
.retain(|tag| tag.as_slice().first().map(String::as_str) != Some("branch-name"));
malformed_description_tags.push(Tag::custom(
"branch-name",
vec!["malformed-description".to_string()],
));
malformed_description_tags.push(Tag::parse(["description"])?);
let malformed_description = EventBuilder::new(Kind::GitPatch, template.content.clone())
.tags(malformed_description_tags)
.finalize(&Keys::generate())?;
publish_to_repo_grasp(&harness, &malformed_description).await?;
let mut malformed_envelope_tags: Vec<Tag> = template
.tags
.iter()
.filter(|tag| {
let values = tag.as_slice();
values.first().map(String::as_str) != Some("branch-name")
&& !(values.first().map(String::as_str) == Some("t")
&& values.get(1).map(String::as_str) == Some("cover-letter"))
})
.cloned()
.collect();
malformed_envelope_tags.extend([
Tag::custom("branch-name", vec!["malformed-envelope".to_string()]),
Tag::custom("description", vec!["malformed envelope".to_string()]),
Tag::custom("parent-commit", vec![published.initial_oid.clone()]),
]);
let malformed_envelope = EventBuilder::new(
Kind::GitPatch,
format!(
"From {}💣 Mon Sep 17 00:00:00 2001\nSubject: [PATCH] malformed envelope\n",
"a".repeat(39),
),
)
.tags(malformed_envelope_tags)
.finalize(&Keys::generate())?;
publish_to_repo_grasp(&harness, &malformed_envelope).await?;
git_ok(
&test_repo,
["config", "--local", "nostr.auto-pr-branches", "true"],
"enable automatic PR branches",
)
.await?;
let ls = ls_remote(&test_repo, "origin").await?;
assert_eq!(
ls.refs.get("refs/heads/main").map(String::as_str),
Some(published.initial_oid.as_str()),
"malformed proposals must not suppress ordinary repository refs",
);
for valid in &series {
assert_eq!(
ls.refs
.get(&format!("refs/heads/{}", expected_long_branch(valid)?))
.map(String::as_str),
Some(valid.tip.as_str()),
"malformed proposals must not suppress valid proposal refs",
);
}
assert!(
ls.refs.keys().all(|name| {
!name.contains("malformed-description") && !name.contains("malformed-envelope")
}),
"malformed proposals must not be advertised: {:#?}",
ls.refs,
);
Ok(())
}
#[tokio::test]
async fn default_auto_pr_branches_reconstructs_only_checked_out_patch_series() -> Result<()> {
let (harness, published, series) = setup().await?;
let test_repo = harness
.clone_published_repo(&published, CloneLogin::None)
.await?;
let before_checkout = ls_remote(&test_repo, "origin").await?;
assert!(
before_checkout
.refs
.keys()
.all(|name| !name.starts_with("refs/heads/pr/") && !name.starts_with("refs/pr/")),
"no patch-series refs should be advertised before checkout: {:#?}",
before_checkout.refs,
);
let selected = &series[0];
let checkout = test_repo
.ngit(["pr", "checkout", &root_event_id(selected)?.to_hex()])
.output()
.await
.context("failed to spawn ngit pr checkout")?;
anyhow::ensure!(
checkout.status.success(),
"ngit pr checkout exited {:?}\nstdout: {}\nstderr: {}",
checkout.status,
String::from_utf8_lossy(&checkout.stdout),
String::from_utf8_lossy(&checkout.stderr),
);
let branch = expected_long_branch(selected)?;
let after_checkout = ls_remote(&test_repo, "origin").await?;
assert_eq!(
after_checkout
.refs
.get(&format!("refs/heads/{branch}"))
.map(String::as_str),
Some(selected.tip.as_str()),
"the checked-out patch series should be reconstructed and advertised",
);
assert_eq!(
after_checkout
.refs
.keys()
.filter(|name| name.starts_with("refs/heads/pr/"))
.count(),
1,
"only the checked-out patch series should appear as a PR branch",
);
Ok(())
}