use anyhow::{Context, Result, bail};
use nostr_sdk::prelude::*;
use rstest::*;
use test_harness::{CloneLogin, Harness, PublishRepoOpts, Repo};
#[derive(Debug, Clone)]
struct ValidationCase {
args: &'static [&'static str],
expected: &'static [&'static str],
}
#[rstest]
#[case::bare_send(ValidationCase {
args: &[],
expected: &[
"ngit send requires additional arguments",
"<since_or_range>",
"--subject",
"--description",
"--defaults",
"--interactive",
],
})]
#[case::range_only(ValidationCase {
args: &["HEAD~2"],
expected: &[
"ngit send requires additional arguments",
"--subject",
"--description",
"--defaults",
],
})]
#[case::force_pr_without_title(ValidationCase {
args: &["--force-pr", "HEAD~2"],
expected: &[
"ngit send requires additional arguments",
"--subject",
"--description",
"--defaults",
],
})]
#[case::description_without_subject(ValidationCase {
args: &["--description", "Y", "HEAD~2"],
expected: &[
"ngit send requires --subject when --description is provided",
"--subject",
],
})]
#[case::subject_without_description(ValidationCase {
args: &["--subject", "X", "HEAD~2"],
expected: &[
"ngit send requires --description when --subject is provided",
"--description",
],
})]
#[tokio::test]
async fn non_interactive_arg_validation_errors(#[case] case: ValidationCase) -> Result<()> {
let harness = Harness::builder(
env!("CARGO_BIN_EXE_ngit"),
env!("CARGO_BIN_EXE_git-remote-nostr"),
)
.build()
.await?;
let repo = harness.fresh_repo()?;
seed_main_commit(&repo).await?;
let mut argv: Vec<&str> = vec!["send"];
argv.extend(case.args.iter().copied());
let out = repo
.ngit(&argv)
.output()
.await
.context("failed to spawn ngit send")?;
if out.status.success() {
bail!(
"ngit send {:?} unexpectedly succeeded\nstdout: {}\nstderr: {}",
case.args,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
}
let combined = format!(
"{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
)
.to_ascii_lowercase();
for expected in case.expected {
let needle = expected.to_ascii_lowercase();
assert!(
combined.contains(&needle),
"ngit send {:?} output missing expected substring {expected:?}\ncombined output:\n{combined}",
case.args,
);
}
Ok(())
}
#[tokio::test]
async fn send_errors_when_no_main_or_master_branch_exists() -> Result<()> {
let harness = Harness::builder(
env!("CARGO_BIN_EXE_ngit"),
env!("CARGO_BIN_EXE_git-remote-nostr"),
)
.build()
.await?;
let repo = harness.fresh_repo()?;
check_ok(
"git checkout -b notmain",
repo.git(["checkout", "-b", "notmain"])
.output()
.await
.context("failed to spawn git checkout -b notmain")?,
)?;
seed_commit(&repo, "README.md", "hello").await?;
let out = repo
.ngit(["send"])
.output()
.await
.context("failed to spawn ngit send")?;
if out.status.success() {
bail!(
"ngit send unexpectedly succeeded without a main/master branch\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
}
let combined = format!(
"{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
)
.to_ascii_lowercase();
assert!(
combined.contains("default branches")
&& combined.contains("main")
&& combined.contains("master"),
"expected stderr to mention the default-branch error; got:\n{combined}",
);
Ok(())
}
#[tokio::test]
async fn send_with_defaults_publishes_patches_without_cover_letter() -> 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, published) = harness.publish_repo(PublishRepoOpts::default()).await?;
let clone = harness
.clone_published_repo(
&published,
CloneLogin::AsContributor {
display_name: "ngit test contributor".into(),
},
)
.await?;
check_ok(
"git checkout -b feature",
clone
.git(["checkout", "-b", "feature"])
.output()
.await
.context("failed to spawn git checkout -b feature")?,
)?;
seed_commit(&clone, "t3.md", "some content\n").await?;
seed_commit(&clone, "t4.md", "some content\n").await?;
let send = clone
.ngit(["--defaults", "send", "--force-patch"])
.output()
.await
.context("failed to spawn ngit --defaults send --force-patch")?;
check_ok("ngit --defaults send --force-patch", send)?;
let author = read_clone_pubkey(&clone).await?;
let events = harness
.grasp("repo")
.events(Filter::new().author(author).kind(Kind::GitPatch))
.await?;
assert_eq!(
events.len(),
2,
"expected exactly 2 patch events authored by contributor on grasp; got {}",
events.len(),
);
for ev in &events {
assert!(
!is_cover_letter(ev),
"no patch event should carry `t cover-letter` under --defaults; saw event {} \
with tags {:?}",
ev.id,
ev.tags,
);
}
Ok(())
}
async fn setup_feature_behind_main(harness: &Harness) -> Result<Repo> {
let (publisher, _published) = harness.publish_repo(PublishRepoOpts::default()).await?;
check_ok(
"git checkout -b feature",
publisher
.git(["checkout", "-b", "feature"])
.output()
.await
.context("failed to spawn git checkout -b feature")?,
)?;
seed_commit(&publisher, "t3.md", "some content\n").await?;
seed_commit(&publisher, "t4.md", "some content\n").await?;
check_ok(
"git checkout main",
publisher
.git(["checkout", "main"])
.output()
.await
.context("failed to spawn git checkout main")?,
)?;
seed_commit(&publisher, "t5.md", "some content\n").await?;
check_ok(
"git push origin main (advance origin)",
publisher
.git(["push", "origin", "main"])
.output()
.await
.context("failed to spawn git push origin main")?,
)?;
check_ok(
"git checkout feature",
publisher
.git(["checkout", "feature"])
.output()
.await
.context("failed to spawn git checkout feature (after advancing origin/main)")?,
)?;
Ok(publisher)
}
#[tokio::test]
async fn send_when_behind_main_errors_without_force() -> 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 clone = setup_feature_behind_main(&harness).await?;
let out = clone
.ngit(["send", "HEAD~2", "--force-patch", "--no-cover-letter"])
.output()
.await
.context("failed to spawn ngit send")?;
if out.status.success() {
bail!(
"ngit send unexpectedly succeeded with feature behind main\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
}
let combined = format!(
"{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
)
.to_ascii_lowercase();
assert!(
combined.contains("behind"),
"expected behind-main error message; got:\n{combined}",
);
assert!(
combined.contains("--force"),
"expected error to recommend --force; got:\n{combined}",
);
Ok(())
}
#[tokio::test]
async fn send_when_behind_main_succeeds_with_force() -> 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 clone = setup_feature_behind_main(&harness).await?;
let send = clone
.ngit([
"send",
"HEAD~2",
"--force-patch",
"--no-cover-letter",
"--force",
])
.output()
.await
.context("failed to spawn ngit send --force")?;
check_ok("ngit send --force-patch --no-cover-letter --force", send)?;
let author = read_clone_pubkey(&clone).await?;
let events = harness
.grasp("repo")
.events(Filter::new().author(author).kind(Kind::GitPatch))
.await?;
assert_eq!(
events.len(),
2,
"expected 2 patch events on grasp after --force send; got {}",
events.len(),
);
Ok(())
}
async fn seed_commit(repo: &Repo, file_name: &str, content: &str) -> Result<()> {
std::fs::write(repo.dir().join(file_name), content)
.with_context(|| format!("failed to write {file_name} into {}", repo.dir().display()))?;
check_ok(
"git add",
repo.git(["add", file_name])
.output()
.await
.with_context(|| format!("failed to spawn git add {file_name}"))?,
)?;
check_ok(
"git commit",
repo.git(["commit", "-m", &format!("add {file_name}"), "--no-gpg-sign"])
.output()
.await
.with_context(|| format!("failed to spawn git commit for {file_name}"))?,
)?;
Ok(())
}
async fn seed_main_commit(repo: &Repo) -> Result<()> {
seed_commit(repo, "README.md", "hello\n").await
}
async fn read_clone_pubkey(clone: &Repo) -> Result<PublicKey> {
let nsec = clone
.config("nostr.nsec")
.await?
.context("nostr.nsec missing from clone — was clone_published_repo called with a login?")?;
let keys = Keys::parse(&nsec)
.context("nostr.nsec in clone's local config is not a valid bech32 nsec")?;
Ok(keys.public_key())
}
fn is_cover_letter(event: &Event) -> bool {
event.tags.iter().any(|t| {
let s = t.as_slice();
s.first().map(String::as_str) == Some("t")
&& s.get(1).map(String::as_str) == Some("cover-letter")
})
}
fn check_ok(label: &str, out: std::process::Output) -> Result<()> {
if out.status.success() {
Ok(())
} else {
bail!(
"{label} exited non-zero ({:?})\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
)
}
}