use std::sync::Arc;
use anyhow::{Context, Result, bail};
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 = "pr-update-rebase-test-repo";
const BRANCH: &str = "feature";
struct Snapshot {
pr_update_event: Event,
pr_update_count_primary: usize,
pr_count_primary: usize,
original_pr_event_id: String,
contributor_pubkey: PublicKey,
update_tip_oid: String,
rebased_merge_base_oid: String,
original_fork_point_oid: String,
maintainer_pubkey: PublicKey,
identifier: String,
grasp_primary_update_ref_oid: String,
grasp_secondary_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("send_pr_update_rebase 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")
.with_grasp_server("repo_secondary")
.build()
.await?;
let (publisher, published) = harness
.publish_repo(PublishRepoOpts {
display_name: Some("pr-update-rebase maintainer".into()),
identifier: Some(IDENTIFIER.into()),
additional_grasp_roles: vec!["repo_secondary".into()],
..Default::default()
})
.await?;
let maintainer_pubkey = published.maintainer_keys.public_key();
let contributor = harness
.clone_published_repo(
&published,
CloneLogin::AsContributor {
display_name: "pr-update-rebase 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", BRANCH],
&format!("git checkout -b {BRANCH}"),
)
.await?;
std::fs::write(contributor.dir().join("t3.md"), "some content\n")
.context("failed to write t3.md in contributor clone")?;
contributor
.git_ok(["add", "t3.md"], "git add t3.md")
.await?;
contributor
.git_ok(
["commit", "-m", "add t3.md", "--no-gpg-sign"],
"git commit -m add t3.md --no-gpg-sign",
)
.await?;
let original_fork_point_oid = published.initial_oid.clone();
std::fs::write(publisher.dir().join("main-v1.md"), "content\n")
.context("failed to write main-v1.md on publisher side")?;
publisher
.git_ok(["add", "main-v1.md"], "git add main-v1.md")
.await?;
publisher
.git_ok(
["commit", "-m", "advance main (v1)", "--no-gpg-sign"],
"git commit -m advance main (v1) --no-gpg-sign",
)
.await?;
publisher
.nostr_push(["-u", "origin", "main"])
.await
.context("maintainer nostr_push (first main advance) failed")?;
std::fs::write(contributor.dir().join("t4.md"), "some content\n")
.context("failed to write t4.md in contributor clone")?;
contributor
.git_ok(["add", "t4.md"], "git add t4.md")
.await?;
contributor
.git_ok(
["commit", "-m", "add t4.md", "--no-gpg-sign"],
"git commit -m add t4.md --no-gpg-sign",
)
.await?;
let send_pr_out = contributor
.ngit([
"send",
"HEAD~2",
"--force-pr",
"--title",
"add feature",
"--description",
"this adds the feature",
])
.output()
.await
.context("failed to spawn ngit send --force-pr (original PR)")?;
if !send_pr_out.status.success() {
bail!(
"original ngit send --force-pr exited non-zero ({:?})\nstdout: {}\nstderr: {}",
send_pr_out.status,
String::from_utf8_lossy(&send_pr_out.stdout),
String::from_utf8_lossy(&send_pr_out.stderr),
);
}
let pr_events_primary = harness
.grasp("repo")
.events(
Filter::new()
.author(contributor_pubkey)
.kind(KIND_PULL_REQUEST),
)
.await?;
let original_pr_event = pr_events_primary
.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 primary grasp after original `ngit send --force-pr`",
)?;
let original_pr_event_id = original_pr_event.id.to_hex();
std::fs::write(publisher.dir().join("main-v2.md"), "content\n")
.context("failed to write main-v2.md on publisher side")?;
publisher
.git_ok(["add", "main-v2.md"], "git add main-v2.md")
.await?;
publisher
.git_ok(
["commit", "-m", "advance main (v2)", "--no-gpg-sign"],
"git commit -m advance main (v2) --no-gpg-sign",
)
.await?;
publisher
.nostr_push(["origin", "main"])
.await
.context("maintainer nostr_push (second main advance) failed")?;
contributor
.git_ok(["fetch", "origin"], "git fetch origin")
.await?;
contributor
.git_ok(["rebase", "origin/main"], "git rebase origin/main")
.await?;
let rebased_merge_base_oid = contributor.rev_parse("HEAD~2").await?;
if rebased_merge_base_oid == original_fork_point_oid {
bail!(
"setup invariant violated: rebased_merge_base_oid equals original_fork_point_oid \
({rebased_merge_base_oid}) — rebase did not change the fork point as expected"
);
}
std::fs::write(contributor.dir().join("t5.md"), "more content\n")
.context("failed to write t5.md in contributor clone")?;
contributor
.git_ok(["add", "t5.md"], "git add t5.md")
.await?;
contributor
.git_ok(
["commit", "-m", "add t5.md", "--no-gpg-sign"],
"git commit -m add t5.md --no-gpg-sign",
)
.await?;
let update_tip_oid = contributor.rev_parse("HEAD").await?;
let send_update_out = contributor
.ngit([
"send",
"HEAD~3",
"--in-reply-to",
&original_pr_event_id,
"--title",
"update: add t5 (after rebase)",
"--description",
"rebased feature branch and added t5.md",
])
.output()
.await
.context("failed to spawn ngit send --in-reply-to (PR update after rebase)")?;
if !send_update_out.status.success() {
bail!(
"ngit send --in-reply-to (rebase) exited non-zero ({:?})\nstdout: {}\nstderr: {}",
send_update_out.status,
String::from_utf8_lossy(&send_update_out.stdout),
String::from_utf8_lossy(&send_update_out.stderr),
);
}
let pr_update_events_primary = harness
.grasp("repo")
.events(
Filter::new()
.author(contributor_pubkey)
.kind(KIND_PULL_REQUEST_UPDATE),
)
.await?;
let pr_update_count_primary = pr_update_events_primary.len();
let pr_update_event = pr_update_events_primary.into_iter().next().context(
"no KIND_PULL_REQUEST_UPDATE authored by contributor found on primary grasp \
after `ngit send --in-reply-to` (rebase variant)",
)?;
let pr_count_primary = harness
.grasp("repo")
.events(
Filter::new()
.author(contributor_pubkey)
.kind(KIND_PULL_REQUEST),
)
.await?
.len();
let update_event_id_hex = pr_update_event.id.to_hex();
let grasp_primary_update_ref_oid = harness
.grasp("repo")
.read_nostr_ref(&published.maintainer_npub, IDENTIFIER, &update_event_id_hex)
.await?;
let grasp_secondary_update_ref_oid = harness
.grasp("repo_secondary")
.read_nostr_ref(&published.maintainer_npub, IDENTIFIER, &update_event_id_hex)
.await?;
Ok(Snapshot {
pr_update_event,
pr_update_count_primary,
pr_count_primary,
original_pr_event_id,
contributor_pubkey,
update_tip_oid,
rebased_merge_base_oid,
original_fork_point_oid,
maintainer_pubkey,
identifier: IDENTIFIER.to_string(),
grasp_primary_update_ref_oid,
grasp_secondary_update_ref_oid,
})
}
#[rstest]
#[tokio::test]
async fn pr_update_event_exactly_one(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
s.pr_update_count_primary, 1,
"expected exactly one KIND_PULL_REQUEST_UPDATE on primary grasp authored by \
contributor; got {}",
s.pr_update_count_primary,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn 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_update_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:?}; got a tags: {:?}",
a_tags
.iter()
.filter_map(|t| t.as_slice().get(1).cloned())
.collect::<Vec<_>>(),
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn c_tag_is_update_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()),
"PR update event `c` tag should equal contributor's updated tip OID \
(after rebase + t5.md); got {:?}, want {:?}",
tag_value(&s.pr_update_event, "c"),
s.update_tip_oid,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn e_tag_is_original_pr_id(#[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()),
"PR update event uppercase `E` tag should equal the original PR event ID; \
got {:?}, want {:?}",
tag_value(&s.pr_update_event, "E"),
s.original_pr_event_id,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn p_tag_is_original_pr_author(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let expected = s.contributor_pubkey.to_string();
assert_eq!(
tag_value(&s.pr_update_event, "P").as_deref(),
Some(expected.as_str()),
"PR update event uppercase `P` tag should equal contributor pubkey \
(original PR author); got {:?}, want {:?}",
tag_value(&s.pr_update_event, "P"),
expected,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn merge_base_tag_is_new_fork_point(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_ne!(
s.rebased_merge_base_oid, s.original_fork_point_oid,
"setup invariant: rebased_merge_base_oid should differ from original_fork_point_oid \
(both are {:?}) — the rebase did not change the fork point",
s.rebased_merge_base_oid,
);
assert_eq!(
tag_value(&s.pr_update_event, "merge-base").as_deref(),
Some(s.rebased_merge_base_oid.as_str()),
"PR update event `merge-base` tag should equal the new fork-point OID \
(main-v2, the tip rebased onto); got {:?}, want {:?}",
tag_value(&s.pr_update_event, "merge-base"),
s.rebased_merge_base_oid,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn both_grasps_have_update_ref(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
s.grasp_primary_update_ref_oid, s.update_tip_oid,
"primary grasp: refs/nostr/<update_event_id> should resolve to update_tip_oid; \
got {:?}, want {:?}",
s.grasp_primary_update_ref_oid, s.update_tip_oid,
);
assert_eq!(
s.grasp_secondary_update_ref_oid, s.update_tip_oid,
"secondary grasp: refs/nostr/<update_event_id> should resolve to update_tip_oid; \
got {:?}, want {:?}",
s.grasp_secondary_update_ref_oid, s.update_tip_oid,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn no_new_pr_event(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
s.pr_count_primary, 1,
"expected exactly one KIND_PULL_REQUEST on primary grasp after both sends; \
got {} — did the rebase update accidentally publish a new PR?",
s.pr_count_primary,
);
Ok(())
}