use std::{path::Path, sync::Arc, time::Duration};
use anyhow::{Context, Result};
use nostr::event::FinalizeEvent;
use nostr_sdk::prelude::*;
use rstest::*;
use test_harness::{Harness, RepoSnapshot};
use tokio::sync::OnceCell;
const STATE_KIND: u16 = 30618;
const DEFAULT_BRANCH: &str = "main";
const SECOND_BRANCH: &str = "vnext";
struct Snapshot {
publisher: RepoSnapshot,
upstream_merge_cfg_main: String,
upstream_merge_cfg_vnext: String,
nostr_clone: RepoSnapshot,
grasp1_clone: RepoSnapshot,
grasp2_clone: RepoSnapshot,
state_event_grasp1: Event,
state_event_grasp2: Event,
state_event_vanilla: Event,
main_branch_ref: String,
vnext_branch_ref: 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("add_branch 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("repo1")
.with_grasp_server("repo2")
.build()
.await?;
let mut publisher = harness.fresh_repo()?;
let display_name = "git push add-branch test";
let identifier = "git-push-add-branch-test";
let create_out = publisher
.ngit(["account", "create", "--local", "--name", display_name])
.output()
.await
.context("failed to spawn ngit account create")?;
require_success("ngit account create", &create_out)?;
let nsec = publisher
.config("nostr.nsec")
.await?
.context("nostr.nsec missing from local git config after account create")?;
let keys = Keys::parse(&nsec).context("invalid nsec in local config")?;
let pubkey = keys.public_key();
let npub = pubkey
.to_bech32()
.context("failed to bech32-encode publisher pubkey")?;
let keyring_file = tempfile::NamedTempFile::new()?;
publisher.set_env("NGIT_SECRET_STORAGE", "auto");
publisher.set_env(
"NGIT_KEYRING_FILE",
keyring_file.path().to_string_lossy().into_owned(),
);
let login = publisher
.ngit(["account", "login", "--local", "--offline", "--nsec", &nsec])
.output()
.await?;
require_success("re-login into credential store", &login)?;
let pointer = publisher
.config("nostr.nsec")
.await?
.context("nostr.nsec missing after credential-store login")?;
assert_eq!(pointer, npub, "entry name is the account npub");
let main_branch_ref = format!("refs/heads/{DEFAULT_BRANCH}");
let vnext_branch_ref = format!("refs/heads/{SECOND_BRANCH}");
let seed_filename = "README.md";
let seed_content = "hello, add-branch scenario!\n";
std::fs::write(publisher.dir().join(seed_filename), seed_content)
.context("failed to write seed file in publisher repo")?;
require_success(
"git add README.md",
&publisher
.git(["add", seed_filename])
.output()
.await
.context("failed to spawn git add")?,
)?;
require_success(
"git commit initial",
&publisher
.git(["commit", "-m", "initial", "--no-gpg-sign"])
.output()
.await
.context("failed to spawn git commit")?,
)?;
let main_oid = publisher
.snapshot()?
.refs
.get(&main_branch_ref)
.with_context(|| format!("{main_branch_ref} missing after initial commit"))?
.clone();
let grasp1 = harness.grasp("repo1");
let grasp2 = harness.grasp("repo2");
let standard_relay = harness.relay("default");
let grasp1_clone_url = format!("{}/{}/{}.git", grasp1.url(), npub, identifier);
let grasp2_clone_url = format!("{}/{}/{}.git", grasp2.url(), npub, identifier);
let standard_relay_url = standard_relay.url().to_string();
let grasp1_relay_url = grasp1.relay_url();
let grasp2_relay_url = grasp2.relay_url();
let announcement_tags: Vec<Tag> = vec![
Tag::identifier(identifier.to_string()),
Tag::parse(["r".to_string(), main_oid.clone(), "euc".to_string()]).unwrap(),
Tag::parse(["name".to_string(), display_name.to_string()]).unwrap(),
Tag::parse([
"description".to_string(),
"test repo for git push add-branch assertions".to_string(),
])
.unwrap(),
Tag::parse([
"clone".to_string(),
grasp1_clone_url.clone(),
grasp2_clone_url.clone(),
])
.unwrap(),
Tag::parse(["web".to_string()]).unwrap(),
Tag::parse([
"relays".to_string(),
standard_relay_url.clone(),
grasp1_relay_url.clone(),
grasp2_relay_url.clone(),
])
.unwrap(),
Tag::parse(["maintainers".to_string(), pubkey.to_string()]).unwrap(),
Tag::parse(["alt".to_string(), format!("git repository: {display_name}")]).unwrap(),
];
let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "")
.tags(announcement_tags)
.finalize(&keys)
.context("failed to sign repo announcement")?;
publish_event_to_all(
&announcement,
&[
grasp1_relay_url.as_str(),
grasp2_relay_url.as_str(),
standard_relay_url.as_str(),
],
)
.await?;
let bare1 = grasp1
.git_data_path()
.join(&npub)
.join(format!("{identifier}.git"));
let bare2 = grasp2
.git_data_path()
.join(&npub)
.join(format!("{identifier}.git"));
wait_for_path(&bare1, Duration::from_secs(5)).await?;
wait_for_path(&bare2, Duration::from_secs(5)).await?;
let relay_hint = urlencoding::encode(standard_relay.url()).into_owned();
let nostr_url = format!("nostr://{npub}/{relay_hint}/{identifier}");
require_success(
"git remote add origin <nostr-url>",
&publisher
.git(["remote", "add", "origin", &nostr_url])
.output()
.await
.context("failed to spawn git remote add origin")?,
)?;
publisher
.nostr_push(["-u", "origin", DEFAULT_BRANCH])
.await
.context("git push -u origin main")?;
require_success(
"git checkout -b vnext",
&publisher
.git(["checkout", "-b", SECOND_BRANCH])
.output()
.await
.context("failed to spawn git checkout -b vnext")?,
)?;
let feature_filename = "FEATURE.md";
let feature_content = "vnext branch feature work\n";
std::fs::write(publisher.dir().join(feature_filename), feature_content)
.context("failed to write feature file on vnext")?;
require_success(
"git add FEATURE.md",
&publisher
.git(["add", feature_filename])
.output()
.await
.context("failed to spawn git add FEATURE.md")?,
)?;
require_success(
"git commit vnext",
&publisher
.git(["commit", "-m", "vnext: add feature", "--no-gpg-sign"])
.output()
.await
.context("failed to spawn git commit on vnext")?,
)?;
let vnext_oid = publisher
.snapshot()?
.refs
.get(&vnext_branch_ref)
.with_context(|| format!("{vnext_branch_ref} missing after commit on vnext"))?
.clone();
require_success(
"git checkout main",
&publisher
.git(["checkout", DEFAULT_BRANCH])
.output()
.await
.context("failed to spawn git checkout main")?,
)?;
publisher
.nostr_push(["-u", "origin", SECOND_BRANCH])
.await
.context("git push -u origin vnext")?;
let publisher_snap = publisher
.snapshot()
.context("capturing publisher snapshot after vnext push")?;
let local_main = publisher_snap
.refs
.get(&main_branch_ref)
.with_context(|| format!("{main_branch_ref} missing from publisher post-push snapshot"))?;
anyhow::ensure!(
*local_main == main_oid,
"publisher's {main_branch_ref} drifted from {main_oid} to {local_main} \
between the initial commit and the vnext push — captured snapshot is \
no longer a clean fixture for the rest of the cases",
);
let local_vnext = publisher_snap
.refs
.get(&vnext_branch_ref)
.with_context(|| format!("{vnext_branch_ref} missing from publisher post-push snapshot"))?;
anyhow::ensure!(
*local_vnext == vnext_oid,
"publisher's {vnext_branch_ref} drifted from {vnext_oid} to {local_vnext} \
between commit and snapshot",
);
let upstream_merge_cfg_main = publisher
.config(&format!("branch.{DEFAULT_BRANCH}.merge"))
.await?
.with_context(|| {
format!(
"branch.{DEFAULT_BRANCH}.merge missing — first push's `-u` \
did not set upstream tracking, or the second push wiped it"
)
})?;
let upstream_merge_cfg_vnext = publisher
.config(&format!("branch.{SECOND_BRANCH}.merge"))
.await?
.with_context(|| {
format!(
"branch.{SECOND_BRANCH}.merge missing — second push's `-u` did \
not set upstream tracking"
)
})?;
let cloner = harness.fresh_repo()?;
let nostr_clone_subdir = "cloned-via-nostr";
let nostr_clone_out = cloner
.git(["clone", &nostr_url, nostr_clone_subdir])
.output()
.await
.context("failed to spawn git clone <nostr-url>")?;
require_success("git clone <nostr-url>", &nostr_clone_out)?;
let nostr_clone = RepoSnapshot::capture(&cloner.dir().join(nostr_clone_subdir))
.context("capturing nostr clone snapshot")?;
let host1 = harness
.fresh_repo()
.context("fresh_repo for direct grasp1 clone")?;
let host2 = harness
.fresh_repo()
.context("fresh_repo for direct grasp2 clone")?;
let grasp1_clone = clone_via_http_and_snapshot(host1.dir(), &grasp1_clone_url)
.await
.context("direct grasp1 clone")?;
let grasp2_clone = clone_via_http_and_snapshot(host2.dir(), &grasp2_clone_url)
.await
.context("direct grasp2 clone")?;
let filter = || Filter::new().author(pubkey).kind(Kind::Custom(STATE_KIND));
let grasp1_state = grasp1.events(filter()).await?;
let grasp2_state = grasp2.events(filter()).await?;
let relay_state = standard_relay.events(filter()).await?;
let state_event_grasp1 = pick_state_event(&grasp1_state, identifier)
.context("no state event with the expected `d` tag on grasp1")?
.clone();
let state_event_grasp2 = pick_state_event(&grasp2_state, identifier)
.context("no state event with the expected `d` tag on grasp2")?
.clone();
let state_event_vanilla = pick_state_event(&relay_state, identifier)
.context("no state event with the expected `d` tag on the vanilla relay")?
.clone();
Ok(Snapshot {
publisher: publisher_snap,
upstream_merge_cfg_main,
upstream_merge_cfg_vnext,
nostr_clone,
grasp1_clone,
grasp2_clone,
state_event_grasp1,
state_event_grasp2,
state_event_vanilla,
main_branch_ref,
vnext_branch_ref,
})
}
#[rstest]
#[tokio::test]
async fn publisher_head_still_points_at_main(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let head = s.publisher.head.as_deref().context(
"publisher snapshot has no HEAD — repo state was somehow unborn after \
two successful pushes",
)?;
assert_eq!(
head, s.main_branch_ref,
"publisher HEAD is {head:?}, expected {:?} — vnext push moved the \
working-tree HEAD away from main",
s.main_branch_ref,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn publisher_main_remote_tracking_matches_local(
#[future] snapshot: Arc<Snapshot>,
) -> Result<()> {
let s = snapshot.await;
let local = oid_at(&s.publisher, &s.main_branch_ref, "publisher local main")?;
let remote_tracking_ref = format!("refs/remotes/origin/{DEFAULT_BRANCH}");
let remote_tracking = oid_at(
&s.publisher,
&remote_tracking_ref,
"publisher remote-tracking main",
)?;
assert_eq!(
remote_tracking, local,
"publisher's {remote_tracking_ref} ({remote_tracking}) does not match \
local {} ({local}) — vnext push disturbed main's remote tracking",
s.main_branch_ref,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn publisher_vnext_remote_tracking_matches_local(
#[future] snapshot: Arc<Snapshot>,
) -> Result<()> {
let s = snapshot.await;
let local = oid_at(&s.publisher, &s.vnext_branch_ref, "publisher local vnext")?;
let remote_tracking_ref = format!("refs/remotes/origin/{SECOND_BRANCH}");
let remote_tracking = oid_at(
&s.publisher,
&remote_tracking_ref,
"publisher remote-tracking vnext",
)?;
assert_eq!(
remote_tracking, local,
"publisher's {remote_tracking_ref} ({remote_tracking}) does not match \
local {} ({local})",
s.vnext_branch_ref,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn publisher_main_upstream_tracking_config_preserved(
#[future] snapshot: Arc<Snapshot>,
) -> Result<()> {
let s = snapshot.await;
assert_eq!(
s.upstream_merge_cfg_main, s.main_branch_ref,
"branch.{DEFAULT_BRANCH}.merge = {:?}, expected {:?}",
s.upstream_merge_cfg_main, s.main_branch_ref,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn publisher_vnext_upstream_tracking_config_set(
#[future] snapshot: Arc<Snapshot>,
) -> Result<()> {
let s = snapshot.await;
assert_eq!(
s.upstream_merge_cfg_vnext, s.vnext_branch_ref,
"branch.{SECOND_BRANCH}.merge = {:?}, expected {:?}",
s.upstream_merge_cfg_vnext, s.vnext_branch_ref,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn nostr_clone_reproduces_main(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let local = oid_at(&s.publisher, &s.main_branch_ref, "publisher local main")?;
let cloned = oid_at(&s.nostr_clone, &s.main_branch_ref, "nostr clone main")?;
assert_eq!(
cloned, local,
"nostr clone's {} ({cloned}) does not match publisher's local ({local})",
s.main_branch_ref,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn nostr_clone_reproduces_vnext(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let local = oid_at(&s.publisher, &s.vnext_branch_ref, "publisher local vnext")?;
let remote_tracking_ref = format!("refs/remotes/origin/{SECOND_BRANCH}");
let cloned = oid_at(&s.nostr_clone, &remote_tracking_ref, "nostr clone vnext")?;
assert_eq!(
cloned, local,
"nostr clone's {remote_tracking_ref} ({cloned}) does not match \
publisher's local {} ({local})",
s.vnext_branch_ref,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn nostr_clone_head_points_at_main(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let head = s.nostr_clone.head.as_deref().context(
"nostr clone snapshot has no HEAD — clone left the working tree in \
an unborn state",
)?;
assert_eq!(
head, s.main_branch_ref,
"nostr clone HEAD is {head:?}, expected {:?}",
s.main_branch_ref,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn grasp1_direct_clone_reproduces_main(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let local = oid_at(&s.publisher, &s.main_branch_ref, "publisher local main")?;
let cloned = oid_at(
&s.grasp1_clone,
&s.main_branch_ref,
"grasp1 direct clone main",
)?;
assert_eq!(
cloned, local,
"direct grasp1 clone's {} ({cloned}) does not match publisher's local ({local})",
s.main_branch_ref,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn grasp1_direct_clone_reproduces_vnext(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let local = oid_at(&s.publisher, &s.vnext_branch_ref, "publisher local vnext")?;
let remote_tracking_ref = format!("refs/remotes/origin/{SECOND_BRANCH}");
let cloned = oid_at(
&s.grasp1_clone,
&remote_tracking_ref,
"grasp1 direct clone vnext",
)?;
assert_eq!(
cloned, local,
"direct grasp1 clone's {remote_tracking_ref} ({cloned}) does not match \
publisher's local {} ({local})",
s.vnext_branch_ref,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn grasp2_direct_clone_reproduces_main(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let local = oid_at(&s.publisher, &s.main_branch_ref, "publisher local main")?;
let cloned = oid_at(
&s.grasp2_clone,
&s.main_branch_ref,
"grasp2 direct clone main",
)?;
assert_eq!(
cloned, local,
"direct grasp2 clone's {} ({cloned}) does not match publisher's local ({local})",
s.main_branch_ref,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn grasp2_direct_clone_reproduces_vnext(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let local = oid_at(&s.publisher, &s.vnext_branch_ref, "publisher local vnext")?;
let remote_tracking_ref = format!("refs/remotes/origin/{SECOND_BRANCH}");
let cloned = oid_at(
&s.grasp2_clone,
&remote_tracking_ref,
"grasp2 direct clone vnext",
)?;
assert_eq!(
cloned, local,
"direct grasp2 clone's {remote_tracking_ref} ({cloned}) does not match \
publisher's local {} ({local})",
s.vnext_branch_ref,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn grasps_state_events_agree_on_id(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
s.state_event_grasp1.id, s.state_event_grasp2.id,
"state events on grasp1 ({}) and grasp2 ({}) differ after the vnext \
push — replacement did not converge across grasps",
s.state_event_grasp1.id, s.state_event_grasp2.id,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn grasp_and_vanilla_state_events_agree_on_id(
#[future] snapshot: Arc<Snapshot>,
) -> Result<()> {
let s = snapshot.await;
assert_eq!(
s.state_event_grasp1.id, s.state_event_vanilla.id,
"state events on grasp1 ({}) and the vanilla relay ({}) differ after \
the vnext push — replacement did not reach the non-grasp relay",
s.state_event_grasp1.id, s.state_event_vanilla.id,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn state_event_head_still_points_at_main(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let head_value = tag_value(&s.state_event_grasp1, "HEAD")
.context("state event missing a HEAD tag — required by add_head()")?;
assert_eq!(
head_value,
format!("ref: {}", s.main_branch_ref),
"state event HEAD tag {head_value:?} does not point at {} — the \
vnext push flipped the published default branch",
s.main_branch_ref,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn state_event_main_ref_matches_local_oid(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let local = oid_at(&s.publisher, &s.main_branch_ref, "publisher local main")?;
let branch_value = tag_value(&s.state_event_grasp1, &s.main_branch_ref).with_context(|| {
format!(
"state event missing a {} tag — main dropped out of the state \
event after the vnext push",
s.main_branch_ref,
)
})?;
assert_eq!(
branch_value, *local,
"state event {} tag {branch_value} does not match local oid {local}",
s.main_branch_ref,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn state_event_vnext_ref_matches_local_oid(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let local = oid_at(&s.publisher, &s.vnext_branch_ref, "publisher local vnext")?;
let branch_value =
tag_value(&s.state_event_grasp1, &s.vnext_branch_ref).with_context(|| {
format!(
"state event missing a {} tag — vnext did not make it into the \
state event despite a successful push",
s.vnext_branch_ref,
)
})?;
assert_eq!(
branch_value, *local,
"state event {} tag {branch_value} does not match local oid {local}",
s.vnext_branch_ref,
);
Ok(())
}
fn oid_at<'a>(snap: &'a RepoSnapshot, refname: &str, label: &str) -> Result<&'a String> {
snap.refs
.get(refname)
.with_context(|| format!("{label} snapshot has no {refname}"))
}
async fn wait_for_path(path: &Path, timeout: Duration) -> Result<()> {
let deadline = std::time::Instant::now() + timeout;
while !path.is_dir() {
if std::time::Instant::now() >= deadline {
anyhow::bail!(
"timed out after {:?} waiting for {} to be created — \
did the grasp accept the announcement?",
timeout,
path.display(),
);
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
Ok(())
}
async fn publish_event_to_all(event: &Event, urls: &[&str]) -> Result<()> {
let client = Client::default();
for url in urls {
client
.add_relay(*url)
.await
.with_context(|| format!("add_relay {url}"))?;
}
client.connect().await;
let output = client
.send_event(event)
.to(urls.iter().copied())
.await
.context("send_event fan-out")?;
client.disconnect().await;
if !output.failed.is_empty() {
anyhow::bail!(
"one or more relays rejected announcement event id={}: {:?}",
event.id,
output.failed,
);
}
Ok(())
}
async fn clone_via_http_and_snapshot(host_dir: &Path, http_url: &str) -> Result<RepoSnapshot> {
let subdir = "cloned-via-http";
let mut cmd = tokio::process::Command::new("git");
cmd.current_dir(host_dir);
cmd.env("GIT_CONFIG_GLOBAL", "/dev/null");
cmd.env("GIT_CONFIG_SYSTEM", "/dev/null");
cmd.args(["clone", http_url, subdir]);
let out = cmd.output().await.context("failed to spawn direct clone")?;
require_success("direct http clone", &out)?;
RepoSnapshot::capture(&host_dir.join(subdir)).context("capturing direct http clone snapshot")
}
fn pick_state_event<'a>(events: &'a [Event], identifier: &str) -> Option<&'a Event> {
events
.iter()
.find(|e| tag_value(e, "d").as_deref() == Some(identifier))
}
fn tag_value(event: &Event, name: &str) -> Option<String> {
event.tags.iter().find_map(|t| {
let s = t.as_slice();
if s.first().map(String::as_str) == Some(name) {
s.get(1).cloned()
} else {
None
}
})
}
fn require_success(label: &str, out: &std::process::Output) -> Result<()> {
if out.status.success() {
Ok(())
} else {
anyhow::bail!(
"{label} exited non-zero ({:?})\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
)
}
}