use anyhow::{Context, Result, bail};
use nostr_sdk::prelude::*;
use test_harness::{ArrangedInitStateA, Harness, KIND_REPO_STATE, Repo, tag_value};
const BRANCH: &str = "main";
const PUSH_REFLOG_MESSAGE: &str = "update by push";
async fn build_harness() -> Result<Harness> {
Harness::builder(
env!("CARGO_BIN_EXE_ngit"),
env!("CARGO_BIN_EXE_git-remote-nostr"),
)
.with_relay("default")
.with_grasp_server("repo")
.build()
.await
}
async fn arrange_and_init(
harness: &Harness,
name: &str,
identifier: &str,
) -> Result<(Repo, ArrangedInitStateA)> {
let (repo, state) = harness.arrange_init_state_a_fresh().await?;
let grasp_url = harness.grasp("repo").url().to_string();
let out = repo
.ngit([
"init",
"--name",
name,
"--identifier",
identifier,
"--grasp-server",
&grasp_url,
])
.output()
.await
.context("failed to spawn ngit init")?;
if !out.status.success() {
bail!(
"ngit init exited non-zero ({:?})\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
}
Ok((repo, state))
}
async fn last_reflog_message(repo: &Repo, refname: &str) -> Result<String> {
let out = repo
.git(["reflog", "--format=%gs", "-n", "1", refname])
.output()
.await
.with_context(|| format!("failed to spawn git reflog for {refname}"))?;
if !out.status.success() {
bail!(
"git reflog {refname} exited non-zero ({:?}) — does the ref have a reflog?\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr),
);
}
Ok(String::from_utf8(out.stdout)
.context("git reflog returned non-utf8")?
.trim()
.to_string())
}
#[tokio::test]
async fn fresh_init_replicates_git_push_bookkeeping() -> Result<()> {
let harness = build_harness().await?;
let (repo, _state) = arrange_and_init(&harness, "init bookkeeping", "init-bookkeeping").await?;
let tracking_ref = format!("refs/remotes/origin/{BRANCH}");
let snap = repo.snapshot()?;
let local = snap
.refs
.get(&format!("refs/heads/{BRANCH}"))
.with_context(|| format!("refs/heads/{BRANCH} missing after ngit init"))?;
let tracking = snap.refs.get(&tracking_ref).with_context(|| {
format!(
"{tracking_ref} missing after ngit init — the in-process push \
skipped git's tracking-ref bookkeeping"
)
})?;
assert_eq!(
tracking, local,
"{tracking_ref} ({tracking}) does not match the pushed local tip ({local})",
);
let reflog_message = last_reflog_message(&repo, &tracking_ref).await?;
assert_eq!(
reflog_message, PUSH_REFLOG_MESSAGE,
"last reflog entry on {tracking_ref} is {reflog_message:?}; git \
writes {PUSH_REFLOG_MESSAGE:?} after an accepted push and \
push_bookkeeping must match it",
);
assert_eq!(
repo.config(&format!("branch.{BRANCH}.remote"))
.await?
.as_deref(),
Some("origin"),
"branch.{BRANCH}.remote not set to origin — init skipped `git push -u`'s upstream setup",
);
assert_eq!(
repo.config(&format!("branch.{BRANCH}.merge"))
.await?
.as_deref(),
Some(format!("refs/heads/{BRANCH}").as_str()),
"branch.{BRANCH}.merge not set — init skipped `git push -u`'s upstream setup",
);
Ok(())
}
#[tokio::test]
async fn helper_push_after_init_advances_and_prunes_tracking_ref() -> Result<()> {
let harness = build_harness().await?;
let (repo, _state) = arrange_and_init(&harness, "init seam", "init-seam").await?;
std::fs::write(repo.dir().join("feature.txt"), "advance main\n")
.context("failed to write feature.txt")?;
repo.git_ok(["add", "feature.txt"], "git add feature.txt")
.await?;
repo.git_ok(
["commit", "-m", "advance main", "--no-gpg-sign"],
"git commit on main",
)
.await?;
let new_tip = repo.rev_parse(&format!("refs/heads/{BRANCH}")).await?;
repo.nostr_push(["origin", BRANCH])
.await
.context("git push origin main after init")?;
let main_tracking_ref = format!("refs/remotes/origin/{BRANCH}");
let snap = repo.snapshot()?;
assert_eq!(
snap.refs.get(&main_tracking_ref),
Some(&new_tip),
"{main_tracking_ref} did not advance to the new tip after a push \
through the remote helper",
);
let reflog_message = last_reflog_message(&repo, &main_tracking_ref).await?;
assert_eq!(
reflog_message, PUSH_REFLOG_MESSAGE,
"git's own post-push tracking update should log {PUSH_REFLOG_MESSAGE:?} \
on {main_tracking_ref}; got {reflog_message:?}",
);
let topic = "topic";
let topic_tracking_ref = format!("refs/remotes/origin/{topic}");
repo.git_ok(["checkout", "-b", topic], "git checkout -b topic")
.await?;
std::fs::write(repo.dir().join("topic.txt"), "topic work\n")
.context("failed to write topic.txt")?;
repo.git_ok(["add", "topic.txt"], "git add topic.txt")
.await?;
repo.git_ok(
["commit", "-m", "topic work", "--no-gpg-sign"],
"git commit on topic",
)
.await?;
let topic_tip = repo.rev_parse(&format!("refs/heads/{topic}")).await?;
repo.git_ok(["checkout", BRANCH], "git checkout main")
.await?;
repo.nostr_push(["origin", topic])
.await
.context("git push origin topic")?;
assert_eq!(
repo.snapshot()?.refs.get(&topic_tracking_ref),
Some(&topic_tip),
"{topic_tracking_ref} missing or stale after pushing the topic branch",
);
repo.nostr_push(["origin", "--delete", topic])
.await
.context("git push origin --delete topic")?;
let snap = repo.snapshot()?;
assert!(
!snap.refs.contains_key(&topic_tracking_ref),
"{topic_tracking_ref} survived an accepted `git push origin --delete \
{topic}` — git should have pruned it after the helper's `ok`",
);
assert_eq!(
snap.refs.get(&main_tracking_ref),
Some(&new_tip),
"deleting {topic} on the remote disturbed {main_tracking_ref}",
);
Ok(())
}
#[tokio::test]
async fn narrowed_fetch_refspec_writes_no_out_of_refspec_tracking_ref() -> Result<()> {
let identifier = "init-narrow-refspec";
let harness = build_harness().await?;
let (repo, state) = arrange_and_init(&harness, "init narrow refspec", identifier).await?;
let main_tracking_ref = format!("refs/remotes/origin/{BRANCH}");
let init_main_tracking = repo
.snapshot()?
.refs
.get(&main_tracking_ref)
.with_context(|| format!("{main_tracking_ref} missing after ngit init"))?
.clone();
repo.git_ok(
[
"config",
"--local",
"remote.origin.fetch",
&format!("+refs/heads/{BRANCH}:refs/remotes/origin/{BRANCH}"),
],
"git config remote.origin.fetch (narrowed)",
)
.await?;
let outside = "outside";
repo.git_ok(["checkout", "-b", outside], "git checkout -b outside")
.await?;
std::fs::write(repo.dir().join("outside.txt"), "out of refspec\n")
.context("failed to write outside.txt")?;
repo.git_ok(["add", "outside.txt"], "git add outside.txt")
.await?;
repo.git_ok(
["commit", "-m", "outside work", "--no-gpg-sign"],
"git commit on outside",
)
.await?;
let outside_tip = repo.rev_parse(&format!("refs/heads/{outside}")).await?;
repo.git_ok(["checkout", BRANCH], "git checkout main")
.await?;
repo.nostr_push(["origin", outside])
.await
.context("git push origin outside")?;
let snap = repo.snapshot()?;
let outside_tracking_ref = format!("refs/remotes/origin/{outside}");
assert!(
!snap.refs.contains_key(&outside_tracking_ref),
"{outside_tracking_ref} exists after pushing a branch outside the \
narrowed remote.origin.fetch — someone reintroduced helper-side \
tracking-ref writes that diverge from git (see commit 73d0e57)",
);
assert_eq!(
snap.refs.get(&main_tracking_ref),
Some(&init_main_tracking),
"pushing {outside} disturbed {main_tracking_ref}",
);
let state_events = harness
.grasp("repo")
.events(
Filter::new()
.author(state.keys.public_key())
.kind(KIND_REPO_STATE),
)
.await?;
let state_event = state_events
.iter()
.find(|e| tag_value(e, "d").as_deref() == Some(identifier))
.with_context(|| {
format!("no kind-30618 state event with `d` = {identifier:?} on the grasp")
})?;
assert_eq!(
tag_value(state_event, &format!("refs/heads/{outside}")).as_deref(),
Some(outside_tip.as_str()),
"state event does not list refs/heads/{outside} at the pushed tip — \
the out-of-refspec push did not reach the server",
);
Ok(())
}