use std::path::Path;
use anyhow::{Context, Result};
use nostr_sdk::prelude::*;
use test_harness::Harness;
#[tokio::test]
async fn fetch_advances_remote_tracking_refs_after_publisher_pushes() -> Result<()> {
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 = harness.fresh_repo()?;
let display_name = "lighthouse fetch grasp";
let identifier = "lighthouse-fetch-grasp";
let create_output = publisher
.ngit(["account", "create", "--local", "--name", display_name])
.output()
.await
.context("failed to spawn ngit account create")?;
assert!(
create_output.status.success(),
"ngit account create exited non-zero ({:?})\nstdout: {}\nstderr: {}",
create_output.status,
String::from_utf8_lossy(&create_output.stdout),
String::from_utf8_lossy(&create_output.stderr),
);
let nsec = publisher
.config("nostr.nsec")
.await?
.context("nostr.nsec missing from local git config after account create")?;
let keys = Keys::parse(&nsec).context("nostr.nsec from local config is not a valid key")?;
let pubkey = keys.public_key();
let npub = pubkey
.to_bech32()
.context("failed to bech32-encode the new account's public key")?;
std::fs::write(publisher.dir().join("README.md"), "main v1\n")
.context("failed to write README.md")?;
run_git_ok(&publisher, ["add", "README.md"], "git add README").await?;
run_git_ok(
&publisher,
["commit", "-m", "initial main", "--no-gpg-sign"],
"git commit initial main",
)
.await?;
run_git_ok(
&publisher,
["checkout", "-b", "vnext"],
"git checkout -b vnext",
)
.await?;
std::fs::write(publisher.dir().join("vnext.md"), "vnext content\n")
.context("failed to write vnext.md")?;
run_git_ok(&publisher, ["add", "vnext.md"], "git add vnext.md").await?;
run_git_ok(
&publisher,
["commit", "-m", "vnext commit", "--no-gpg-sign"],
"git commit vnext",
)
.await?;
run_git_ok(&publisher, ["checkout", "main"], "git checkout main").await?;
let snapshot_before_push = publisher.snapshot()?;
let main_oid_v1 = snapshot_before_push
.refs
.get("refs/heads/main")
.context("refs/heads/main missing after initial commit")?
.clone();
let vnext_oid = snapshot_before_push
.refs
.get("refs/heads/vnext")
.context("refs/heads/vnext missing after vnext commit")?
.clone();
let grasp_url = harness.grasp("repo").url().to_string();
let init_output = publisher
.ngit([
"init",
"--name",
display_name,
"--identifier",
identifier,
"--grasp-server",
&grasp_url,
"-d",
])
.output()
.await
.context("failed to spawn ngit init")?;
assert!(
init_output.status.success(),
"ngit init exited non-zero ({:?})\nstdout: {}\nstderr: {}",
init_output.status,
String::from_utf8_lossy(&init_output.stdout),
String::from_utf8_lossy(&init_output.stderr),
);
let init_stdout = String::from_utf8_lossy(&init_output.stdout);
let clone_url = extract_clone_url(&init_stdout).with_context(|| {
format!("no `clone url:` line in ngit init stdout. full stdout was:\n{init_stdout}")
})?;
assert!(
clone_url.starts_with("nostr://"),
"expected nostr:// clone URL; got {clone_url}",
);
assert!(
clone_url.contains(&npub),
"clone URL {clone_url} does not contain publisher's npub {npub}",
);
let push_out = publisher
.nostr_push(["-u", "origin", "main", "vnext"])
.await
.context("nostr_push main+vnext failed")?;
assert!(
push_out.status.success(),
"nostr_push main+vnext exited non-zero ({:?})\nstdout: {}\nstderr: {}",
push_out.status,
String::from_utf8_lossy(&push_out.stdout),
String::from_utf8_lossy(&push_out.stderr),
);
assert_state_event_ref(
harness.grasp("repo"),
pubkey,
"refs/heads/vnext",
&vnext_oid,
)
.await?;
let cloner = harness.fresh_repo()?;
let clone_dir_name = "cloned";
let clone_target = cloner.dir().join(clone_dir_name);
run_git_ok(
&cloner,
["clone", &clone_url, clone_dir_name],
"git clone over nostr://",
)
.await?;
assert!(
clone_target.join(".git").is_dir(),
"git clone succeeded but .git missing at {}",
clone_target.display(),
);
let cloned_main_v1 = read_local_ref_oid(&clone_target, "refs/remotes/origin/main")
.with_context(|| {
format!(
"reading refs/remotes/origin/main from clone at {}",
clone_target.display()
)
})?;
assert_eq!(
cloned_main_v1, main_oid_v1,
"clone's origin/main ({cloned_main_v1}) does not match publisher's ({main_oid_v1})",
);
let cloned_vnext = read_local_ref_oid(&clone_target, "refs/remotes/origin/vnext")
.with_context(|| {
format!(
"reading refs/remotes/origin/vnext from clone at {}",
clone_target.display()
)
})?;
assert_eq!(
cloned_vnext, vnext_oid,
"clone's origin/vnext ({cloned_vnext}) does not match publisher's ({vnext_oid})",
);
std::fs::write(publisher.dir().join("README.md"), "main v2\n")
.context("failed to overwrite README.md for second commit")?;
run_git_ok(&publisher, ["add", "README.md"], "git add v2").await?;
run_git_ok(
&publisher,
["commit", "-m", "second main", "--no-gpg-sign"],
"git commit second main",
)
.await?;
let push_out = publisher
.nostr_push(["origin", "main"])
.await
.context("nostr_push second main failed")?;
assert!(
push_out.status.success(),
"nostr_push second main exited non-zero ({:?})\nstdout: {}\nstderr: {}",
push_out.status,
String::from_utf8_lossy(&push_out.stdout),
String::from_utf8_lossy(&push_out.stderr),
);
let main_oid_v2 = publisher
.snapshot()?
.refs
.get("refs/heads/main")
.context("refs/heads/main missing after second commit")?
.clone();
assert_ne!(
main_oid_v2, main_oid_v1,
"second commit did not advance refs/heads/main",
);
assert_state_event_ref(
harness.grasp("repo"),
pubkey,
"refs/heads/main",
&main_oid_v2,
)
.await?;
let clone_target_str = clone_target
.to_str()
.context("clone target path is not utf-8")?;
let fetch_output = cloner
.git(["-C", clone_target_str, "fetch", "origin"])
.output()
.await
.context("failed to spawn git fetch")?;
assert!(
fetch_output.status.success(),
"git fetch exited non-zero ({:?})\nstdout: {}\nstderr: {}",
fetch_output.status,
String::from_utf8_lossy(&fetch_output.stdout),
String::from_utf8_lossy(&fetch_output.stderr),
);
let cloned_main_v2 = read_local_ref_oid(&clone_target, "refs/remotes/origin/main")
.with_context(|| {
format!(
"reading refs/remotes/origin/main from clone at {} after fetch",
clone_target.display()
)
})?;
assert_eq!(
cloned_main_v2, main_oid_v2,
"clone's origin/main did not advance after fetch: \
got {cloned_main_v2}, publisher's is {main_oid_v2}",
);
let cloned_vnext_after = read_local_ref_oid(&clone_target, "refs/remotes/origin/vnext")
.with_context(|| {
format!(
"reading refs/remotes/origin/vnext from clone at {} after fetch",
clone_target.display()
)
})?;
assert_eq!(
cloned_vnext_after, vnext_oid,
"vnext should not have moved; got {cloned_vnext_after}, expected {vnext_oid}",
);
Ok(())
}
async fn run_git_ok<I, S>(repo: &test_harness::Repo, args: I, label: &str) -> Result<()>
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
let out = repo
.git(args)
.output()
.await
.with_context(|| format!("failed to spawn {label}"))?;
if !out.status.success() {
anyhow::bail!(
"{label} exited non-zero ({:?})\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
}
Ok(())
}
async fn assert_state_event_ref(
grasp: &test_harness::GraspServer,
pubkey: PublicKey,
ref_name: &str,
expected_oid: &str,
) -> Result<()> {
let events = grasp
.events(Filter::new().author(pubkey).kind(Kind::Custom(30618)))
.await?;
let latest = events.iter().max_by_key(|event| event.created_at);
let oid_in_event = latest.and_then(|event| {
event.tags.iter().find_map(|tag| {
let values = tag.as_slice();
if values.first().map(String::as_str) == Some(ref_name) {
values.get(1).cloned()
} else {
None
}
})
});
anyhow::ensure!(
oid_in_event.as_deref() == Some(expected_oid),
"successful push returned before kind-30618 listed {ref_name}={expected_oid}; \
latest event: {latest:?}",
);
Ok(())
}
fn extract_clone_url(stdout: &str) -> Option<String> {
for line in stdout.lines() {
let lower = line.to_ascii_lowercase();
if let Some(idx) = lower.find("clone url:") {
let rest = line[idx + "clone url:".len()..].trim();
if rest.starts_with("nostr://") {
return Some(rest.to_string());
}
}
}
None
}
fn read_local_ref_oid(working_path: &Path, refname: &str) -> Result<String> {
let repo = git2::Repository::open(working_path)
.with_context(|| format!("open {}", working_path.display()))?;
let reference = repo
.find_reference(refname)
.with_context(|| format!("find_reference {refname}"))?;
let oid = reference
.target()
.with_context(|| format!("reference {refname} has no direct target"))?;
Ok(oid.to_string())
}