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 = "git-push-pr-rebase-update";
const BRANCH: &str = "feature";
struct Snapshot {
pr_update_event: Event,
pr_count: usize,
pr_update_count: usize,
original_pr_event_id: String,
original_fork_point_oid: String,
rebased_merge_base_oid: String,
update_tip_oid: String,
contributor_remote_tracking_oid: String,
grasp_update_ref_oid: String,
non_force_push_was_rejected: bool,
}
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::rebase_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("rebase-update maintainer".into()),
identifier: Some(IDENTIFIER.into()),
..Default::default()
})
.await?;
let contributor = harness
.clone_published_repo(
&published,
CloneLogin::AsContributor {
display_name: "rebase-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?;
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 advance main (v1)",
)
.await?;
publisher
.nostr_push(["-u", "origin", "main"])
.await
.context("maintainer nostr_push to advance main (v1) failed")?;
contributor
.nostr_push(["-u", "origin", &format!("pr/{BRANCH}")])
.await
.context("first nostr_push -u origin pr/feature failed")?;
let original_pr_event = harness
.grasp("repo")
.events(
Filter::new()
.author(contributor_pubkey)
.kind(KIND_PULL_REQUEST),
)
.await?
.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();
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 advance main (v2)",
)
.await?;
publisher
.nostr_push(["origin", "main"])
.await
.context("maintainer nostr_push to advance main (v2) 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("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")?;
let plain_push_out = contributor
.git(["push", "origin", &format!("pr/{BRANCH}")])
.output()
.await
.context("failed to spawn git push origin pr/feature (no -f)")?;
let non_force_push_was_rejected = !plain_push_out.status.success();
if non_force_push_was_rejected {
contributor
.nostr_push(["-f", "origin", &format!("pr/{BRANCH}")])
.await
.context("nostr_push -f origin pr/feature (force push after rebase) failed")?;
}
let contributor_snap = contributor
.snapshot()
.context("capturing contributor snapshot after force 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 force 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 `git push -f origin pr/feature` (force push after rebase)",
)?;
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_fork_point_oid,
rebased_merge_base_oid,
update_tip_oid,
contributor_remote_tracking_oid,
grasp_update_ref_oid,
non_force_push_was_rejected,
})
}
#[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 force 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_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()),
"update event `merge-base` tag should equal the new fork-point OID after rebase \
(840c581 preserve-merge-base logic must not apply to force-push updates); \
got {:?}, want {:?}",
tag_value(&s.pr_update_event, "merge-base"),
s.rebased_merge_base_oid,
);
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 on rebased branch); \
got {:?}, want {:?}",
tag_value(&s.pr_update_event, "c"),
s.update_tip_oid,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn contributor_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 force 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 force-push rebase update",
s.grasp_update_ref_oid, s.update_tip_oid,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn non_force_push_rejected(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert!(
s.non_force_push_was_rejected,
"plain `git push origin pr/{BRANCH}` (no -f) after rebase was unexpectedly \
accepted by the remote; a non-fast-forward push should be rejected without -f",
);
Ok(())
}