use std::{collections::BTreeMap, sync::Arc};
use anyhow::{Context, Result};
use nostr_sdk::prelude::*;
use rstest::*;
use test_harness::{
CloneLogin, Harness, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, PublishRepoOpts,
event_branch_name_tag, tag_value,
};
use tokio::sync::OnceCell;
const IDENTIFIER: &str = "git-push-pr-new-pr";
const BRANCH: &str = "feature";
const COMMIT_SUBJECT: &str = "add t1.md";
const COMMIT_DESCRIPTION: &str = "this adds the t1.md file";
struct Snapshot {
pr_event: Event,
pr_count: usize,
patch_count: usize,
pr_update_count: usize,
contributor_tip_oid: String,
contributor_remote_tracking_oid: String,
upstream_merge_cfg: String,
grasp_pr_ref_oid: String,
maintainer_pubkey: PublicKey,
identifier: String,
pr_event_id_hex: String,
nostr_clone_ls_refs: BTreeMap<String, 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("git_push_pr::new_pr 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 (_publisher, published) = harness
.publish_repo(PublishRepoOpts {
display_name: Some("git push pr maintainer".into()),
identifier: Some(IDENTIFIER.into()),
..Default::default()
})
.await?;
let maintainer_pubkey = published.maintainer_keys.public_key();
let contributor = harness
.clone_published_repo(
&published,
CloneLogin::AsContributor {
display_name: "git push pr contributor".into(),
},
)
.await?;
let contributor_nsec = contributor
.config("nostr.nsec")
.await?
.context("nostr.nsec missing after AsContributor login")?;
let contributor_keys =
Keys::parse(&contributor_nsec).context("contributor nostr.nsec is not a valid key")?;
let contributor_pubkey = contributor_keys.public_key();
contributor
.git_ok(
["checkout", "-b", &format!("pr/{BRANCH}")],
&format!("git checkout -b pr/{BRANCH}"),
)
.await?;
std::fs::write(contributor.dir().join("t1.md"), "some content\n")
.context("failed to write t1.md")?;
contributor
.git_ok(["add", "t1.md"], "git add t1.md")
.await?;
contributor
.git_ok(
[
"commit",
"-m",
&format!("{COMMIT_SUBJECT}\n\n{COMMIT_DESCRIPTION}"),
"--no-gpg-sign",
],
"git commit t1.md",
)
.await?;
std::fs::write(contributor.dir().join("t2.md"), "some content\n")
.context("failed to write t2.md")?;
contributor
.git_ok(["add", "t2.md"], "git add t2.md")
.await?;
contributor
.git_ok(
["commit", "-m", "add t2.md", "--no-gpg-sign"],
"git commit t2.md",
)
.await?;
let contributor_tip_oid = contributor
.rev_parse("HEAD")
.await
.context("rev-parse HEAD after second commit")?;
contributor
.nostr_push(["-u", "origin", &format!("pr/{BRANCH}")])
.await
.context("nostr_push -u origin pr/feature failed")?;
let contributor_snap = contributor
.snapshot()
.context("capturing contributor snapshot after push")?;
let remote_tracking_ref = format!("refs/remotes/origin/pr/{BRANCH}");
let contributor_remote_tracking_oid = contributor_snap
.refs
.get(&remote_tracking_ref)
.with_context(|| {
format!(
"{remote_tracking_ref} missing from contributor refs after push — \
git did not record the tracking ref after the helper's `ok`"
)
})?
.clone();
let upstream_merge_cfg = contributor
.config(&format!("branch.pr/{BRANCH}.merge"))
.await?
.with_context(|| {
format!(
"branch.pr/{BRANCH}.merge not set after `git push -u` — \
the -u flag did not write upstream tracking config"
)
})?;
let pr_events = harness
.grasp("repo")
.events(
Filter::new()
.author(contributor_pubkey)
.kind(KIND_PULL_REQUEST),
)
.await?;
let pr_count = pr_events.len();
let pr_event = pr_events
.into_iter()
.find(|e| event_branch_name_tag(e).as_deref() == Some(BRANCH))
.context(
"no KIND_PULL_REQUEST with branch-name=\"feature\" authored by contributor \
found on GRASP after `git push pr/feature`",
)?;
let pr_event_id_hex = pr_event.id.to_hex();
let patch_count = harness
.grasp("repo")
.events(
Filter::new()
.author(contributor_pubkey)
.kind(Kind::GitPatch),
)
.await?
.len()
+ harness
.relay("default")
.events(
Filter::new()
.author(contributor_pubkey)
.kind(Kind::GitPatch),
)
.await?
.len();
let pr_update_count = harness
.grasp("repo")
.events(
Filter::new()
.author(contributor_pubkey)
.kind(KIND_PULL_REQUEST_UPDATE),
)
.await?
.len()
+ harness
.relay("default")
.events(
Filter::new()
.author(contributor_pubkey)
.kind(KIND_PULL_REQUEST_UPDATE),
)
.await?
.len();
let grasp_pr_ref_oid = harness
.grasp("repo")
.read_nostr_ref(&published.maintainer_npub, IDENTIFIER, &pr_event_id_hex)
.await?;
let new_clone = harness
.clone_published_repo(&published, CloneLogin::None)
.await?;
new_clone
.git_ok(
["config", "--local", "nostr.auto-pr-branches", "true"],
"enable automatic PR branches for ls-remote compatibility check",
)
.await?;
let ls_out = new_clone
.git(["ls-remote", "origin"])
.output()
.await
.context("failed to spawn git ls-remote origin")?;
anyhow::ensure!(
ls_out.status.success(),
"git ls-remote origin exited {:?}\nstdout: {}\nstderr: {}",
ls_out.status,
String::from_utf8_lossy(&ls_out.stdout),
String::from_utf8_lossy(&ls_out.stderr),
);
let ls_stdout =
String::from_utf8(ls_out.stdout).context("git ls-remote origin stdout is not UTF-8")?;
let nostr_clone_ls_refs: BTreeMap<String, String> = ls_stdout
.lines()
.filter(|l| !l.is_empty() && !l.starts_with("ref: "))
.filter_map(|l| l.split_once('\t'))
.map(|(oid, name)| (name.to_string(), oid.to_string()))
.collect();
Ok(Snapshot {
pr_event,
pr_count,
patch_count,
pr_update_count,
contributor_tip_oid,
contributor_remote_tracking_oid,
upstream_merge_cfg,
grasp_pr_ref_oid,
maintainer_pubkey,
identifier: IDENTIFIER.to_string(),
pr_event_id_hex,
nostr_clone_ls_refs,
})
}
#[rstest]
#[tokio::test]
async fn pr_event_exactly_one(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
s.pr_count, 1,
"expected exactly one KIND_PULL_REQUEST on the GRASP authored by contributor; \
got {} — did the 9e06e7b GRASP-default path fire?",
s.pr_count,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn zero_patch_events(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
s.patch_count, 0,
"expected zero Kind::GitPatch events across GRASP and default relay; \
got {} — was a patch event accidentally emitted alongside the PR?",
s.patch_count,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn zero_pr_update_events(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
s.pr_update_count, 0,
"expected zero KIND_PULL_REQUEST_UPDATE events across GRASP and default relay; \
got {} — was the push incorrectly routed through the update path?",
s.pr_update_count,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn contributor_pr_remote_tracking_matches_local(
#[future] snapshot: Arc<Snapshot>,
) -> Result<()> {
let s = snapshot.await;
assert_eq!(
s.contributor_remote_tracking_oid, s.contributor_tip_oid,
"contributor refs/remotes/origin/pr/{BRANCH} ({}) does not match local tip ({}); \
git may not have recorded the tracking ref after the helper's `ok`",
s.contributor_remote_tracking_oid, s.contributor_tip_oid,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn upstream_tracking_config_set(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let expected = format!("refs/heads/pr/{BRANCH}");
assert_eq!(
s.upstream_merge_cfg, expected,
"branch.pr/{BRANCH}.merge = {:?}, expected {:?}; \
the -u flag did not set upstream tracking correctly",
s.upstream_merge_cfg, expected,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn grasp_has_refs_nostr_for_pr(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
s.grasp_pr_ref_oid,
s.contributor_tip_oid,
"GRASP refs/nostr/{} resolves to {} but expected tip {}; \
git data may not have been pushed to the GRASP",
&s.pr_event_id_hex[..16],
s.grasp_pr_ref_oid,
s.contributor_tip_oid,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn pr_event_branch_name_tag_is_feature(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
tag_value(&s.pr_event, "branch-name").as_deref(),
Some(BRANCH),
"PR event branch-name tag should be {:?} (pr/ prefix stripped); got {:?}",
BRANCH,
tag_value(&s.pr_event, "branch-name"),
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn pr_event_c_tag_is_tip(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
tag_value(&s.pr_event, "c").as_deref(),
Some(s.contributor_tip_oid.as_str()),
"PR event c tag should equal contributor's tip OID; got {:?}, want {:?}",
tag_value(&s.pr_event, "c"),
s.contributor_tip_oid,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn pr_event_a_tag_is_repo_coordinate(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let expected = format!("30617:{}:{}", s.maintainer_pubkey, s.identifier);
let a_tags: Vec<&Tag> = s
.pr_event
.tags
.iter()
.filter(|t| t.as_slice().first().map(String::as_str) == Some("a"))
.collect();
assert!(
a_tags
.iter()
.any(|t| t.as_slice().get(1).map(String::as_str) == Some(expected.as_str())),
"expected an `a` tag with value {expected:?}; found a tags: {:?}",
a_tags
.iter()
.filter_map(|t| t.as_slice().get(1).cloned())
.collect::<Vec<_>>(),
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn new_clone_lists_pr_feature_branch(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let shorthand = &s.pr_event_id_hex[..8];
let expected_ref = format!("refs/heads/pr/{BRANCH}({shorthand})");
let got_oid = s.nostr_clone_ls_refs.get(&expected_ref).cloned();
assert_eq!(
got_oid.as_deref(),
Some(s.contributor_tip_oid.as_str()),
"expected fresh clone ls-remote to contain {expected_ref} → {}; \
got {:?}\nfull ls-remote map: {:#?}",
s.contributor_tip_oid,
got_oid,
s.nostr_clone_ls_refs,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn pr_event_subject_is_first_commit_subject(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
tag_value(&s.pr_event, "subject").as_deref(),
Some(COMMIT_SUBJECT),
"PR event subject tag should be {:?} (first commit subject line); got {:?}",
COMMIT_SUBJECT,
tag_value(&s.pr_event, "subject"),
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn pr_event_content_is_first_commit_description(
#[future] snapshot: Arc<Snapshot>,
) -> Result<()> {
let s = snapshot.await;
assert_eq!(
s.pr_event.content, COMMIT_DESCRIPTION,
"PR event content should equal first commit description {:?}; got {:?}",
COMMIT_DESCRIPTION, s.pr_event.content,
);
Ok(())
}