use anyhow::{Context, Result, ensure};
use nostr_sdk::prelude::{Filter, Keys, ToBech32};
use test_harness::{Harness, KIND_REPO_STATE, tag_value};
#[tokio::test]
async fn git_clone_and_push_nostr_url_through_external_remote_helper() -> Result<()> {
let harness = Harness::builder(
env!("CARGO_BIN_EXE_ngit"),
env!("CARGO_BIN_EXE_git-remote-nostr"),
)
.with_relay("default")
.build()
.await?;
let publisher = harness.fresh_repo()?;
let filename = "REMOTE_HELPER_E2E.md";
let contents = "cloned through git-remote-nostr and git-remote-ext\n";
std::fs::write(publisher.dir().join(filename), contents).context("write seed file")?;
require_success(
"git add",
&publisher
.git(["add", filename])
.output()
.await
.context("spawn git add")?,
)?;
require_success(
"git commit",
&publisher
.git(["commit", "-m", "seed remote-helper e2e", "--no-gpg-sign"])
.output()
.await
.context("spawn git commit")?,
)?;
let source = publisher.snapshot()?;
let main_oid = source
.refs
.get("refs/heads/main")
.context("publisher main ref missing")?
.clone();
let keys = Keys::generate();
let npub = keys
.public_key()
.to_bech32()
.context("encode publisher npub")?;
let nsec = keys.secret_key().to_bech32()?;
let identifier = "remote-helper-cli-e2e";
let relay_url = harness.relay("default").url().to_string();
let backing_dir = tempfile::tempdir().context("allocate backing repo directory")?;
let backing_repo = backing_dir.path().join("backing.git");
let backing_repo_str = backing_repo
.to_str()
.context("backing repository path is not UTF-8")?;
require_success(
"git init --bare backing repository",
&publisher
.git(["init", "--bare", backing_repo_str])
.output()
.await
.context("spawn git init --bare")?,
)?;
require_success(
"seed backing repository",
&publisher
.git(["push", backing_repo_str, "main:refs/heads/main"])
.output()
.await
.context("spawn seed push")?,
)?;
require_success(
"set backing repository HEAD",
&publisher
.git([
"--git-dir",
backing_repo_str,
"symbolic-ref",
"HEAD",
"refs/heads/main",
])
.output()
.await
.context("spawn git symbolic-ref")?,
)?;
let helper_url = format!("ext::%S {backing_repo_str}");
publisher
.git_ok(
["config", "--local", "protocol.ext.allow", "always"],
"allow ext helper for init",
)
.await?;
publisher
.git_ok(
["config", "--local", "nostr.nostate", "true"],
"disable init state generation",
)
.await?;
let init = publisher
.ngit([
"init",
"--name",
identifier,
"--description",
"exercises nested Git remote helpers",
"--additional-clone",
helper_url.as_str(),
"--additional-relay",
relay_url.as_str(),
"--nsec",
nsec.as_str(),
"--defaults",
])
.output()
.await
.context("spawn ngit init with external helper URL")?;
require_success("ngit init with external helper URL", &init)?;
let relay_hint = urlencoding::encode(&relay_url);
let nostr_url = format!("nostr://{npub}/{relay_hint}/{identifier}");
let cloned = harness
.clone_url_with_git_config(&nostr_url, &[("protocol.ext.allow", "always")])
.await?;
let cloned_snapshot = cloned.snapshot()?;
assert_eq!(cloned_snapshot.head.as_deref(), Some("refs/heads/main"));
assert_eq!(
cloned_snapshot
.refs
.get("refs/heads/main")
.map(String::as_str),
Some(main_oid.as_str())
);
assert_eq!(
std::fs::read_to_string(cloned.dir().join(filename))?,
contents
);
cloned
.git_ok(
["config", "--local", "nostr.nsec", &nsec],
"git config nostr.nsec",
)
.await?;
cloned
.git_ok(
["config", "--local", "protocol.ext.allow", "always"],
"git config protocol.ext.allow",
)
.await?;
let pushed_filename = "PUSHED_THROUGH_HELPER.md";
std::fs::write(
cloned.dir().join(pushed_filename),
"pushed through both remote helpers\n",
)?;
cloned
.git_ok(["add", pushed_filename], "git add pushed file")
.await?;
cloned
.git_ok(
["commit", "-m", "push through helper", "--no-gpg-sign"],
"git commit pushed file",
)
.await?;
let pushed_oid = cloned.rev_parse("HEAD").await?;
cloned.nostr_push(["origin", "main"]).await?;
let backing_oid = publisher
.git([
"--git-dir",
backing_repo_str,
"rev-parse",
"refs/heads/main",
])
.output()
.await
.context("read backing main after nostr push")?;
require_success("read backing main after nostr push", &backing_oid)?;
assert_eq!(
String::from_utf8(backing_oid.stdout)?.trim(),
pushed_oid,
"the actual CLI push must advance the external-helper backing ref"
);
require_success(
"reject deletes on backing repository",
&publisher
.git([
"--git-dir",
backing_repo_str,
"config",
"receive.denyDeletes",
"true",
])
.output()
.await
.context("configure backing repository to reject deletes")?,
)?;
let rejected_delete = cloned.nostr_push(["origin", "--delete", "main"]).await;
assert!(
rejected_delete.is_err(),
"nostr push must fail when the external helper rejects its ref update"
);
let backing_oid_after_rejection = publisher
.git([
"--git-dir",
backing_repo_str,
"rev-parse",
"refs/heads/main",
])
.output()
.await
.context("read backing main after rejected nostr push")?;
require_success(
"read backing main after rejected nostr push",
&backing_oid_after_rejection,
)?;
assert_eq!(
String::from_utf8(backing_oid_after_rejection.stdout)?.trim(),
pushed_oid,
"the rejected helper push must not change the backing ref"
);
let state_events = harness
.relay("default")
.events(
Filter::new()
.author(keys.public_key())
.kind(KIND_REPO_STATE),
)
.await?;
let repo_state_events = state_events
.iter()
.filter(|event| tag_value(event, "d").as_deref() == Some(identifier))
.collect::<Vec<_>>();
ensure!(
!repo_state_events.is_empty(),
"the successful helper push must publish repository state"
);
ensure!(
repo_state_events.iter().all(|event| {
tag_value(event, "refs/heads/main").as_deref() == Some(pushed_oid.as_str())
}),
"the rejected helper push must not fan out state without main"
);
Ok(())
}
fn require_success(label: &str, output: &std::process::Output) -> Result<()> {
ensure!(
output.status.success(),
"{label} exited {:?}\nstdout: {}\nstderr: {}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
Ok(())
}