use std::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-ff-update";
const BRANCH: &str = "feature";
struct Snapshot {
pr_update_event: Event,
pr_count: usize,
pr_update_count: usize,
original_pr_event_id: String,
original_merge_base: String,
update_tip_oid: String,
contributor_remote_tracking_oid: String,
grasp_update_ref_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("git_push_pr::ff_update 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("ff-update maintainer".into()),
identifier: Some(IDENTIFIER.into()),
..Default::default()
})
.await?;
let contributor = harness
.clone_published_repo(
&published,
CloneLogin::AsContributor {
display_name: "ff-update 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", "add t1.md", "--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?;
std::fs::write(publisher.dir().join("t-on-main.md"), "content\n")
.context("failed to write t-on-main.md on publisher side")?;
publisher
.git_ok(["add", "t-on-main.md"], "git add t-on-main.md")
.await?;
publisher
.git_ok(
["commit", "-m", "advance main", "--no-gpg-sign"],
"git commit advance main",
)
.await?;
publisher
.nostr_push(["-u", "origin", "main"])
.await
.context("maintainer nostr_push to advance main failed")?;
contributor
.nostr_push(["-u", "origin", &format!("pr/{BRANCH}")])
.await
.context("first nostr_push -u origin pr/feature failed")?;
let pr_events_after_first_push = harness
.grasp("repo")
.events(
Filter::new()
.author(contributor_pubkey)
.kind(KIND_PULL_REQUEST),
)
.await?;
let original_pr_event = pr_events_after_first_push
.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 first `git push -u origin pr/feature`",
)?;
let original_pr_event_id = original_pr_event.id.to_hex();
let original_merge_base = tag_value(&original_pr_event, "merge-base").with_context(|| {
format!(
"original PR event (id={original_pr_event_id}) has no `merge-base` tag; \
cannot verify 840c581 behaviour"
)
})?;
std::fs::write(contributor.dir().join("t3.md"), "more content\n")
.context("failed to write t3.md")?;
contributor
.git_ok(["add", "t3.md"], "git add t3.md")
.await?;
contributor
.git_ok(
["commit", "-m", "add t3.md", "--no-gpg-sign"],
"git commit t3.md",
)
.await?;
let update_tip_oid = contributor
.rev_parse("HEAD")
.await
.context("rev-parse HEAD after t3.md commit")?;
contributor
.nostr_push(["origin", &format!("pr/{BRANCH}")])
.await
.context("second nostr_push origin pr/feature failed")?;
let contributor_snap = contributor
.snapshot()
.context("capturing contributor snapshot after second 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 second push — \
git did not record the tracking ref after the helper's `ok`"
)
})?
.clone();
let pr_events_final = harness
.grasp("repo")
.events(
Filter::new()
.author(contributor_pubkey)
.kind(KIND_PULL_REQUEST),
)
.await?;
let pr_count = pr_events_final.len();
let pr_update_events = harness
.grasp("repo")
.events(
Filter::new()
.author(contributor_pubkey)
.kind(KIND_PULL_REQUEST_UPDATE),
)
.await?;
let pr_update_count = pr_update_events.len();
let pr_update_event = pr_update_events.into_iter().next().context(
"no KIND_PULL_REQUEST_UPDATE authored by contributor found on GRASP \
after second `git push origin pr/feature`",
)?;
let update_event_id_hex = pr_update_event.id.to_hex();
let grasp_update_ref_oid = harness
.grasp("repo")
.read_nostr_ref(&published.maintainer_npub, IDENTIFIER, &update_event_id_hex)
.await?;
Ok(Snapshot {
pr_update_event,
pr_count,
pr_update_count,
original_pr_event_id,
original_merge_base,
update_tip_oid,
contributor_remote_tracking_oid,
grasp_update_ref_oid,
})
}
#[rstest]
#[tokio::test]
async fn one_pr_one_update(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
s.pr_count, 1,
"expected exactly one KIND_PULL_REQUEST on GRASP after both pushes; \
got {} — did the second push produce a new PR instead of an update?",
s.pr_count,
);
assert_eq!(
s.pr_update_count, 1,
"expected exactly one KIND_PULL_REQUEST_UPDATE on GRASP after both pushes; \
got {}",
s.pr_update_count,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn update_e_tag_points_at_original_pr(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
tag_value(&s.pr_update_event, "E").as_deref(),
Some(s.original_pr_event_id.as_str()),
"update event uppercase `E` tag should equal original PR event ID; \
got {:?}, want {:?}",
tag_value(&s.pr_update_event, "E"),
s.original_pr_event_id,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn update_merge_base_equals_original_merge_base(
#[future] snapshot: Arc<Snapshot>,
) -> Result<()> {
let s = snapshot.await;
assert_eq!(
tag_value(&s.pr_update_event, "merge-base").as_deref(),
Some(s.original_merge_base.as_str()),
"update event `merge-base` tag should equal original PR's merge-base \
(840c581 regression catcher); got {:?}, want {:?}",
tag_value(&s.pr_update_event, "merge-base"),
s.original_merge_base,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn update_c_tag_is_new_tip(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
tag_value(&s.pr_update_event, "c").as_deref(),
Some(s.update_tip_oid.as_str()),
"update event `c` tag should equal the new tip OID (t3.md commit); \
got {:?}, want {:?}",
tag_value(&s.pr_update_event, "c"),
s.update_tip_oid,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn publisher_pr_remote_tracking_advanced_to_new_tip(
#[future] snapshot: Arc<Snapshot>,
) -> Result<()> {
let s = snapshot.await;
assert_eq!(
s.contributor_remote_tracking_oid, s.update_tip_oid,
"contributor refs/remotes/origin/pr/{BRANCH} ({}) should point at new tip ({}); \
was the remote-tracking ref advanced by the second push?",
s.contributor_remote_tracking_oid, s.update_tip_oid,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn grasp_has_refs_nostr_for_update(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
s.grasp_update_ref_oid, s.update_tip_oid,
"GRASP refs/nostr/<update_event_id> resolves to {} but expected update tip {}; \
git data may not have been pushed for the FF update",
s.grasp_update_ref_oid, s.update_tip_oid,
);
Ok(())
}