use std::{collections::BTreeMap, sync::Arc};
use anyhow::{Context, Result, anyhow};
use nostr_sdk::prelude::*;
use rstest::*;
use test_harness::{
CloneLogin, Harness, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, PublishPatchSeriesOpts,
PublishRepoOpts, event_branch_name_tag, tag_value, tag_values_multiple,
};
use tokio::sync::OnceCell;
const IDENTIFIER: &str = "git-push-pr-patch-update-force";
async fn find_patch_events(
harness: &Harness,
commit_oid: &str,
minimum_event_count: usize,
) -> Result<Vec<Event>> {
let events = harness
.grasp("repo")
.events(Filter::new().kind(Kind::GitPatch))
.await?;
anyhow::ensure!(
events.len() >= minimum_event_count
&& events
.iter()
.any(|event| tag_value(event, "commit").as_deref() == Some(commit_oid)),
"successful push returned before at least {minimum_event_count} GitPatch events, including \
commit={commit_oid}, were queryable; observed {} event(s): {:?}",
events.len(),
events
.iter()
.map(|event| {
format!(
"id={} commit={:?} root={:?}",
event.id,
tag_value(event, "commit"),
event.tags.iter().find_map(|tag| {
let values = tag.as_slice();
(values.first().map(String::as_str) == Some("e"))
.then(|| values.get(1).cloned())
.flatten()
}),
)
})
.collect::<Vec<_>>(),
);
Ok(events)
}
struct Snapshot {
all_patch_events: Vec<Event>,
revision_root: Event,
revision_patch_2: Event,
revision_tip: Event,
revision_2_root: Event,
revision_2_tip: Event,
original_root_patch_id: EventId,
pr_count: usize,
pr_update_count: usize,
original_branch_name: 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::patch_update_force 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("patch-update-force maintainer".into()),
identifier: Some(IDENTIFIER.into()),
..Default::default()
})
.await?;
let maintainer_keys = Keys::parse(&published.maintainer_nsec)
.context("published.maintainer_nsec is not a valid key")?;
let maintainer_pubkey = maintainer_keys.public_key();
let series = harness
.publish_patch_series(&published, PublishPatchSeriesOpts::default())
.await?;
let original_root_patch = series
.patch_events
.iter()
.find(|e| event_branch_name_tag(e).as_deref() == Some(series.branch_name.as_str()))
.with_context(|| {
format!(
"no patch event with branch-name={:?} in publish_patch_series output; \
has the harness changed how it tags the series root?",
series.branch_name,
)
})?;
let original_root_patch_id = original_root_patch.id;
let shorthand = &original_root_patch.id.to_hex()[..8];
let remote_branch = format!("pr/{}({})", series.branch_name, shorthand);
let maintainer_clone = harness
.clone_published_repo(&published, CloneLogin::AsMaintainer)
.await?;
maintainer_clone
.git_ok(
["config", "--local", "nostr.auto-pr-branches", "true"],
"enable automatic PR branches for raw remote-ref checkout",
)
.await?;
maintainer_clone
.git_ok(["fetch", "origin"], "fetch automatic PR branches")
.await?;
let snap = maintainer_clone
.snapshot()
.context("snapshotting maintainer clone before checkout")?;
let remote_ref = format!("refs/remotes/origin/{remote_branch}");
snap.refs.get(&remote_ref).with_context(|| {
format!(
"{remote_ref} missing from maintainer clone after `git clone` — \
list.rs no longer advertises patch proposals as pr/<branch>(<shorthand>) \
for non-author viewers? Available refs: {:?}",
snap.refs.keys().collect::<Vec<_>>(),
)
})?;
maintainer_clone
.git_ok(
["checkout", &remote_branch],
&format!("git checkout {remote_branch}"),
)
.await?;
std::fs::write(
maintainer_clone.dir().join("maintainer-update.md"),
"maintainer follow-up\n",
)
.context("failed to write maintainer-update.md")?;
maintainer_clone
.git_ok(
["add", "maintainer-update.md"],
"git add maintainer-update.md",
)
.await?;
maintainer_clone
.git_ok(
["commit", "-m", "follow-up on patch series", "--no-gpg-sign"],
"git commit maintainer-update.md",
)
.await?;
let first_push_tip_oid = maintainer_clone
.rev_parse("HEAD")
.await
.context("rev-parse HEAD before first push")?;
maintainer_clone
.nostr_push(["origin", &remote_branch])
.await
.context("nostr_push of maintainer follow-up (first push) failed")?;
find_patch_events(&harness, &first_push_tip_oid, 3).await?;
std::fs::write(
maintainer_clone.dir().join("maintainer-update.md"),
"maintainer follow-up (amended)\n",
)
.context("failed to write amended maintainer-update.md")?;
maintainer_clone
.git_ok(
["add", "maintainer-update.md"],
"git add maintainer-update.md (amend step)",
)
.await?;
maintainer_clone
.git_ok(
[
"commit",
"--amend",
"-m",
"amended follow-up on patch series",
"--no-gpg-sign",
],
"git commit --amend",
)
.await?;
let amended_tip_oid = maintainer_clone
.rev_parse("HEAD")
.await
.context("rev-parse HEAD after amend")?;
maintainer_clone
.nostr_push(["-f", "origin", &remote_branch])
.await
.context("nostr_push -f (force push after amend) failed")?;
find_patch_events(&harness, &amended_tip_oid, 6).await?;
std::fs::write(
maintainer_clone.dir().join("maintainer-update.md"),
"maintainer follow-up (amended twice)\n",
)
.context("failed to write twice-amended maintainer-update.md")?;
maintainer_clone
.git_ok(
["add", "maintainer-update.md"],
"git add maintainer-update.md (second amend step)",
)
.await?;
maintainer_clone
.git_ok(
[
"commit",
"--amend",
"-m",
"twice-amended follow-up on patch series",
"--no-gpg-sign",
],
"git commit --amend (second)",
)
.await?;
let amended_tip_2_oid = maintainer_clone
.rev_parse("HEAD")
.await
.context("rev-parse HEAD after second amend")?;
if amended_tip_2_oid == amended_tip_oid {
return Err(anyhow!(
"second amend produced the same commit OID as the first amend \
({amended_tip_2_oid}); the second force push will be a no-op and \
the test will not exercise the intended path",
));
}
maintainer_clone
.nostr_push(["-f", "origin", &remote_branch])
.await
.context("nostr_push -f (second force push after second amend) failed")?;
let all_patch_events = find_patch_events(&harness, &amended_tip_2_oid, 9).await?;
let pr_count = harness
.grasp("repo")
.events(Filter::new().kind(KIND_PULL_REQUEST))
.await?
.len();
let pr_update_count = harness
.grasp("repo")
.events(Filter::new().kind(KIND_PULL_REQUEST_UPDATE))
.await?
.len();
let mut revision_roots: Vec<Event> = all_patch_events
.iter()
.filter(|e| {
tag_values_multiple(e, "t")
.iter()
.any(|v| v == "root-revision" || v == "revision-root")
})
.cloned()
.collect();
revision_roots.sort_by_key(|e| e.created_at);
if revision_roots.len() != 2 {
return Err(anyhow!(
"expected exactly 2 Kind::GitPatch events on GRASP carrying \
t={{root-revision,revision-root}} (one per force push); got {}. \
all {} patch event ids: {:?}",
revision_roots.len(),
all_patch_events.len(),
all_patch_events
.iter()
.map(|e| e.id.to_hex())
.collect::<Vec<_>>(),
));
}
let revision_2_root = revision_roots.pop().expect("len == 2 checked above");
let revision_root = revision_roots.pop().expect("len == 2 checked above");
if revision_root.id == revision_2_root.id {
return Err(anyhow!(
"the two revision roots have identical event ids ({}); \
the second force push appears to have re-published the first \
revision root verbatim, defeating this test's purpose",
revision_root.id.to_hex(),
));
}
let second_commit_oid = series
.commits
.get(1)
.cloned()
.context("series.commits has fewer than 2 entries; expected at least 2")?;
let mut maintainer_patches_for_c2: Vec<Event> = all_patch_events
.iter()
.filter(|e| {
e.pubkey == maintainer_pubkey
&& tag_value(e, "commit").as_deref() == Some(second_commit_oid.as_str())
})
.cloned()
.collect();
maintainer_patches_for_c2.sort_by_key(|e| e.created_at);
let revision_patch_2 = maintainer_patches_for_c2.first().cloned().ok_or_else(|| {
anyhow!(
"no Kind::GitPatch event authored by maintainer ({}) with \
commit tag = {second_commit_oid} found on GRASP after force push",
maintainer_pubkey.to_hex(),
)
})?;
let revision_tip = all_patch_events
.iter()
.find(|e| tag_value(e, "commit").as_deref() == Some(amended_tip_oid.as_str()))
.cloned()
.ok_or_else(|| {
anyhow!(
"no Kind::GitPatch event with commit tag = {amended_tip_oid} \
found on GRASP after force push",
)
})?;
let revision_2_tip = all_patch_events
.iter()
.find(|e| tag_value(e, "commit").as_deref() == Some(amended_tip_2_oid.as_str()))
.cloned()
.ok_or_else(|| {
anyhow!(
"no Kind::GitPatch event with commit tag = {amended_tip_2_oid} \
found on GRASP after second force push",
)
})?;
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 on fresh clone")?;
anyhow::ensure!(
ls_out.status.success(),
"fresh-clone `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("fresh-clone `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 {
all_patch_events,
revision_root,
revision_patch_2,
revision_tip,
revision_2_root,
revision_2_tip,
original_root_patch_id,
pr_count,
pr_update_count,
original_branch_name: series.branch_name.clone(),
nostr_clone_ls_refs,
})
}
#[rstest]
#[tokio::test]
async fn nine_patch_events_total(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
s.all_patch_events.len(),
9,
"expected exactly 9 Kind::GitPatch events on GRASP \
(2 original + 1 first push + 3 revision #1 + 3 revision #2); \
got {} (event ids: {:?})",
s.all_patch_events.len(),
s.all_patch_events
.iter()
.map(|e| e.id.to_hex())
.collect::<Vec<_>>(),
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn zero_pr_events(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
s.pr_count, 0,
"expected zero KIND_PULL_REQUEST events on GRASP after force pushing on top \
of a patch-kind proposal; got {}",
s.pr_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 on GRASP; got {}",
s.pr_update_count,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn revision_root_has_t_root(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert!(
tag_values_multiple(&s.revision_root, "t")
.iter()
.any(|v| v == "root"),
"revision root should carry `t root`; t tags: {:?}",
tag_values_multiple(&s.revision_root, "t"),
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn revision_root_has_t_revision_root(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let t_values = tag_values_multiple(&s.revision_root, "t");
assert!(
t_values
.iter()
.any(|v| v == "root-revision" || v == "revision-root"),
"revision root should carry `t root-revision` (or alias `revision-root`); \
t tags: {t_values:?}",
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn revision_root_replies_to_original_root(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let reply_e = s
.revision_root
.tags
.iter()
.find(|t| {
let v = t.as_slice();
v.first().map(String::as_str) == Some("e")
&& v.len() == 4
&& v.get(3).map(String::as_str) == Some("reply")
})
.ok_or_else(|| {
anyhow!(
"revision root missing 4-slot `e ... reply` tag; \
all tags: {:?}",
s.revision_root.tags,
)
})?;
assert_eq!(
reply_e.as_slice().get(1).map(String::as_str),
Some(s.original_root_patch_id.to_hex().as_str()),
"revision root's `e reply` should point at the original series root; \
got {:?}, want {:?}",
reply_e.as_slice().get(1),
s.original_root_patch_id.to_hex(),
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn tip_patch_root_is_revision_root(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let root_e = s
.revision_tip
.tags
.iter()
.find(|t| {
let v = t.as_slice();
v.first().map(String::as_str) == Some("e")
&& v.len() == 4
&& v.get(3).map(String::as_str) == Some("root")
})
.ok_or_else(|| {
anyhow!(
"revision tip patch missing 4-slot `e ... root` tag; \
all tags: {:?}",
s.revision_tip.tags,
)
})?;
assert_eq!(
root_e.as_slice().get(1).map(String::as_str),
Some(s.revision_root.id.to_hex().as_str()),
"revision tip patch's `e root` should point at the revision root; \
got {:?}, want {:?}",
root_e.as_slice().get(1),
s.revision_root.id.to_hex(),
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn tip_patch_replies_to_second_patch(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let reply_e = s
.revision_tip
.tags
.iter()
.find(|t| {
let v = t.as_slice();
v.first().map(String::as_str) == Some("e")
&& v.len() == 4
&& v.get(3).map(String::as_str) == Some("reply")
})
.ok_or_else(|| {
anyhow!(
"revision tip patch missing 4-slot `e ... reply` tag; \
all tags: {:?}",
s.revision_tip.tags,
)
})?;
assert_eq!(
reply_e.as_slice().get(1).map(String::as_str),
Some(s.revision_patch_2.id.to_hex().as_str()),
"revision tip patch's `e reply` should point at the second patch in the \
revision series (not the revision root); got {:?}, want {:?}",
reply_e.as_slice().get(1),
s.revision_patch_2.id.to_hex(),
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn revision_2_root_replies_to_original_root(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let reply_e = s
.revision_2_root
.tags
.iter()
.find(|t| {
let v = t.as_slice();
v.first().map(String::as_str) == Some("e")
&& v.len() == 4
&& v.get(3).map(String::as_str) == Some("reply")
})
.ok_or_else(|| {
anyhow!(
"second revision root missing 4-slot `e ... reply` tag; \
all tags: {:?}",
s.revision_2_root.tags,
)
})?;
let got = reply_e.as_slice().get(1).map(String::as_str);
let want_original = s.original_root_patch_id.to_hex();
let other_candidate = s.revision_root.id.to_hex();
assert_eq!(
got,
Some(want_original.as_str()),
"second revision root's `e reply` should point at the ORIGINAL root \
patch ({want_original}), not the first revision root \
({other_candidate}); got {got:?}.\n\n\
If got == first-revision-root id, the second force push is anchoring \
on the latest revision instead of the original proposal — re-check \
the filter in `get_all_proposals` (lib/utils.rs:208) and the lookup \
in `find_proposal_and_patches_by_branch_name` (push.rs:472).",
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn revision_2_tip_root_is_revision_2_root(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let root_e = s
.revision_2_tip
.tags
.iter()
.find(|t| {
let v = t.as_slice();
v.first().map(String::as_str) == Some("e")
&& v.len() == 4
&& v.get(3).map(String::as_str) == Some("root")
})
.ok_or_else(|| {
anyhow!(
"second revision tip patch missing 4-slot `e ... root` tag; \
all tags: {:?}",
s.revision_2_tip.tags,
)
})?;
assert_eq!(
root_e.as_slice().get(1).map(String::as_str),
Some(s.revision_2_root.id.to_hex().as_str()),
"second revision tip patch's `e root` should point at the second \
revision root ({}); got {:?}. If it points at the first revision \
root ({}) or the original root ({}), the per-revision thread \
boundary has been broken.",
s.revision_2_root.id.to_hex(),
root_e.as_slice().get(1),
s.revision_root.id.to_hex(),
s.original_root_patch_id.to_hex(),
);
Ok(())
}
fn branch_shaped_pr_refs<'a>(
map: &'a std::collections::BTreeMap<String, String>,
branch: &str,
) -> Vec<(&'a String, &'a String)> {
let needle_prefix = format!("pr/{branch}(");
map.iter()
.filter(|(name, _)| {
let suffix = name
.strip_prefix("refs/heads/")
.or_else(|| name.strip_prefix("refs/"))
.unwrap_or(name.as_str());
suffix.starts_with(&needle_prefix) && suffix.ends_with(')')
})
.collect()
}
#[rstest]
#[tokio::test]
async fn fresh_clone_exactly_one_pr_branch(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let pr_branch_refs = branch_shaped_pr_refs(&s.nostr_clone_ls_refs, &s.original_branch_name);
assert_eq!(
pr_branch_refs.len(),
2,
"expected exactly two branch-shaped proposal advertisements \
(one each under `refs/pr/` and `refs/heads/pr/` for `{}`) in the \
fresh clone after two force-push revisions; got {} \
(full ls-remote map: {:#?})",
s.original_branch_name,
pr_branch_refs.len(),
s.nostr_clone_ls_refs,
);
Ok(())
}