use std::path::Path;
use anyhow::{Context, Result};
use nostr::prelude::{Filter, FromBech32, Kind, Nip19Event, ToBech32};
use serde_json::Value;
use test_harness::{CloneLogin, Harness, PublishRepoOpts, PublishedPr, PublishedRepo, Repo};
struct Setup {
harness: Harness,
_published: PublishedRepo,
prs: [PublishedPr; 3],
publisher: Repo,
}
async fn setup() -> Result<Setup> {
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 {
display_name: Some("merge maintainer".into()),
identifier: Some("merge-repo".into()),
..Default::default()
})
.await?;
let prs = harness.publish_three_open_proposals(&published).await?;
Ok(Setup {
harness,
_published: published,
prs,
publisher,
})
}
fn expected_branch_name(pr: &PublishedPr) -> String {
let hex = pr.event_id.to_hex();
format!("pr/{}({})", pr.branch_name, &hex[..8])
}
async fn git_ok<I, S>(repo: &Repo, args: I, label: &str) -> Result<()>
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
git_ok_at(repo, repo.dir(), args, label).await
}
async fn git_ok_at<I, S>(repo: &Repo, dir: &Path, args: I, label: &str) -> Result<()>
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
let mut command = repo.git(args);
command.current_dir(dir);
let out = command
.output()
.await
.with_context(|| format!("failed to spawn {label}"))?;
anyhow::ensure!(
out.status.success(),
"{label} exited {:?}\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
Ok(())
}
async fn git_stdout_at(repo: &Repo, dir: &Path, args: &[&str], label: &str) -> Result<Vec<u8>> {
let mut command = repo.git(args.iter().copied());
command.current_dir(dir);
let out = command
.output()
.await
.with_context(|| format!("failed to spawn {label}"))?;
anyhow::ensure!(
out.status.success(),
"{label} exited {:?}\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
Ok(out.stdout)
}
#[derive(Debug, PartialEq)]
struct DirtyWorktreeState {
status: Vec<u8>,
staged_diff: Vec<u8>,
unstaged_diff: Vec<u8>,
readme: Vec<u8>,
untracked: Vec<u8>,
stashes: Vec<u8>,
}
async fn dirty_worktree_state(repo: &Repo) -> Result<DirtyWorktreeState> {
dirty_worktree_state_at(repo, repo.dir()).await
}
async fn dirty_worktree_state_at(repo: &Repo, dir: &Path) -> Result<DirtyWorktreeState> {
Ok(DirtyWorktreeState {
status: git_stdout_at(repo, dir, &["status", "--porcelain=v1"], "git status").await?,
staged_diff: git_stdout_at(
repo,
dir,
&["diff", "--cached", "--binary", "--no-ext-diff"],
"git diff --cached",
)
.await?,
unstaged_diff: git_stdout_at(
repo,
dir,
&["diff", "--binary", "--no-ext-diff"],
"git diff",
)
.await?,
readme: std::fs::read(dir.join("README.md")).context("read README.md")?,
untracked: std::fs::read(dir.join("dirty.md")).context("read dirty.md")?,
stashes: git_stdout_at(
repo,
dir,
&["stash", "list", "--format=%H%x09%gs"],
"git stash list",
)
.await?,
})
}
async fn make_worktree_staged_unstaged_and_untracked(repo: &Repo) -> Result<()> {
make_worktree_staged_unstaged_and_untracked_at(repo, repo.dir()).await
}
async fn make_worktree_staged_unstaged_and_untracked_at(repo: &Repo, dir: &Path) -> Result<()> {
std::fs::write(dir.join("README.md"), "staged change\n").context("write staged README.md")?;
git_ok_at(repo, dir, ["add", "README.md"], "git add README.md").await?;
std::fs::write(dir.join("README.md"), "staged change\nunstaged change\n")
.context("write unstaged README.md")?;
std::fs::write(dir.join("dirty.md"), "untracked change\n").context("write dirty.md")?;
Ok(())
}
async fn rev_parse(repo: &Repo, rev: &str) -> Result<String> {
rev_parse_at(repo, repo.dir(), rev).await
}
async fn rev_parse_at(repo: &Repo, dir: &Path, rev: &str) -> Result<String> {
let mut command = repo.git(["rev-parse", rev]);
command.current_dir(dir);
let out = command
.output()
.await
.with_context(|| format!("failed to spawn git rev-parse {rev}"))?;
anyhow::ensure!(
out.status.success(),
"git rev-parse {rev} exited {:?}: {}",
out.status,
String::from_utf8_lossy(&out.stderr),
);
Ok(String::from_utf8(out.stdout)
.context("git rev-parse stdout not utf-8")?
.trim()
.to_string())
}
async fn current_branch(repo: &Repo) -> Result<String> {
current_branch_at(repo, repo.dir()).await
}
async fn current_branch_at(repo: &Repo, dir: &Path) -> Result<String> {
let mut command = repo.git(["symbolic-ref", "--short", "HEAD"]);
command.current_dir(dir);
let out = command
.output()
.await
.context("failed to spawn git symbolic-ref HEAD")?;
anyhow::ensure!(
out.status.success(),
"git symbolic-ref --short HEAD exited {:?}: {}",
out.status,
String::from_utf8_lossy(&out.stderr),
);
Ok(String::from_utf8(out.stdout)
.context("git symbolic-ref stdout not utf-8")?
.trim()
.to_string())
}
#[derive(Debug, PartialEq)]
struct LocalGitState {
current_branch: String,
head: String,
branch_refs: Vec<u8>,
}
async fn local_git_state(repo: &Repo) -> Result<LocalGitState> {
Ok(LocalGitState {
current_branch: current_branch(repo).await?,
head: rev_parse(repo, "HEAD").await?,
branch_refs: git_stdout_at(
repo,
repo.dir(),
&[
"for-each-ref",
"--format=%(refname) %(objectname)",
"refs/heads",
],
"git for-each-ref refs/heads",
)
.await?,
})
}
async fn assert_refusal_preserves_local_state(
repo: &Repo,
out: &std::process::Output,
expected: &LocalGitState,
label: &str,
) -> Result<()> {
assert!(
!out.status.success(),
"{label} must refuse the proposal\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
assert_eq!(
&local_git_state(repo).await?,
expected,
"{label} must not change HEAD or any local branch ref",
);
Ok(())
}
async fn commit_message(repo: &Repo, rev: &str) -> Result<String> {
let out = repo
.git(["log", "-1", "--format=%B", rev])
.output()
.await
.with_context(|| format!("failed to spawn git log {rev}"))?;
anyhow::ensure!(
out.status.success(),
"git log {rev} exited {:?}: {}",
out.status,
String::from_utf8_lossy(&out.stderr),
);
Ok(String::from_utf8(out.stdout)
.context("git log stdout not utf-8")?
.to_string())
}
async fn parent_count(repo: &Repo, rev: &str) -> Result<usize> {
let out = repo
.git(["rev-list", "--parents", "-n", "1", rev])
.output()
.await
.with_context(|| format!("failed to spawn git rev-list {rev}"))?;
anyhow::ensure!(out.status.success(), "git rev-list {rev} failed");
let line = String::from_utf8(out.stdout).context("git rev-list stdout not utf-8")?;
Ok(line.split_whitespace().count().saturating_sub(1))
}
async fn run_merge(repo: &Repo, args: &[&str]) -> Result<std::process::Output> {
run_merge_at(repo, repo.dir(), args).await
}
async fn run_merge_at(repo: &Repo, dir: &Path, args: &[&str]) -> Result<std::process::Output> {
let mut argv = vec!["merge"];
argv.extend_from_slice(args);
let mut command = repo.ngit(argv);
command.current_dir(dir);
command.output().await.context("failed to spawn ngit merge")
}
async fn run_pr_merge(repo: &Repo, args: &[&str]) -> Result<std::process::Output> {
let mut argv = vec!["pr", "merge"];
argv.extend_from_slice(args);
repo.ngit(argv)
.output()
.await
.context("failed to spawn ngit pr merge")
}
async fn add_linked_worktree(repo: &Repo, dir: &Path, branch: &str) -> Result<()> {
let mut command = repo.git(["worktree", "add"]);
command.arg(dir).arg(branch);
let out = command
.output()
.await
.context("failed to spawn git worktree add")?;
anyhow::ensure!(
out.status.success(),
"git worktree add exited {:?}\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
Ok(())
}
#[tokio::test]
async fn merge_by_id_creates_no_ff_merge_on_default_branch() -> Result<()> {
let Setup {
harness: _h,
_published: _,
prs,
publisher,
} = setup().await?;
let pr = &prs[0];
let main_before = rev_parse(&publisher, "main").await?;
let out = run_merge(&publisher, &[&pr.event_id.to_hex()]).await?;
anyhow::ensure!(
out.status.success(),
"ngit merge exited {:?}\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
assert_eq!(
current_branch(&publisher).await?,
"main",
"should be left on the default branch after merge",
);
let main_after = rev_parse(&publisher, "main").await?;
assert_ne!(
main_after, main_before,
"main should have a new merge commit"
);
assert_eq!(
parent_count(&publisher, "main").await?,
2,
"no-ff merge should produce a 2-parent merge commit",
);
let branch = expected_branch_name(pr);
let pr_tip = rev_parse(&publisher, &branch).await?;
assert_eq!(
pr_tip, pr.tip,
"local pr branch should sit at the published tip"
);
let msg = commit_message(&publisher, "main").await?;
let shorthand = &pr.event_id.to_hex()[..8];
let expected_subject = format!("Merge #{shorthand}: proposal");
assert!(
msg.lines()
.next()
.unwrap_or_default()
.starts_with(&expected_subject),
"merge commit subject should start with '{expected_subject}', got:\n{msg}",
);
assert!(
msg.contains("nostr:nevent1"),
"merge commit body should contain the PR nevent as a nostr: URI, got:\n{msg}",
);
assert!(
msg.contains("PR description:"),
"merge commit body should contain the PR description header, got:\n{msg}",
);
let author_npub = pr
.author_pubkey
.to_bech32()
.context("failed to bech32-encode PR author pubkey")?;
assert!(
msg.contains("PR-Author:"),
"merge commit body should contain a PR-Author trailer, got:\n{msg}",
);
assert!(
msg.contains(&format!("nostr:{author_npub}")),
"merge commit body should attribute the author by npub, got:\n{msg}",
);
Ok(())
}
#[tokio::test]
async fn merge_without_id_infers_pr_from_current_branch() -> Result<()> {
let Setup {
harness: _h,
_published: _,
prs,
publisher,
} = setup().await?;
let pr = &prs[0];
let out = publisher
.ngit(["pr", "checkout", &pr.event_id.to_hex()])
.output()
.await
.context("failed to spawn ngit pr checkout")?;
anyhow::ensure!(
out.status.success(),
"ngit pr checkout exited {:?}\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
let branch = expected_branch_name(pr);
assert_eq!(current_branch(&publisher).await?, branch);
std::fs::write(publisher.dir().join("prior-stash.md"), "keep me\n")
.context("write prior-stash.md")?;
git_ok(
&publisher,
[
"stash",
"push",
"--include-untracked",
"--message",
"existing user stash",
],
"git stash push",
)
.await?;
make_worktree_staged_unstaged_and_untracked(&publisher).await?;
let dirty_before = dirty_worktree_state(&publisher).await?;
let out = run_merge(&publisher, &[]).await?;
anyhow::ensure!(
out.status.success(),
"ngit merge (no id) exited {:?}\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
assert_eq!(
current_branch(&publisher).await?,
"main",
"should be left on the default branch after merge",
);
assert_eq!(
parent_count(&publisher, "main").await?,
2,
"no-ff merge should produce a 2-parent merge commit",
);
assert_eq!(
dirty_worktree_state(&publisher).await?,
dirty_before,
"the branch switch and merge must preserve the exact staged, unstaged, untracked, and stash state",
);
Ok(())
}
#[tokio::test]
async fn dirty_target_worktree_is_preserved_after_merge() -> Result<()> {
let Setup {
harness: _h,
_published: _,
prs,
publisher,
} = setup().await?;
let pr = &prs[0];
let main_before = rev_parse(&publisher, "main").await?;
make_worktree_staged_unstaged_and_untracked(&publisher).await?;
let dirty_before = dirty_worktree_state(&publisher).await?;
let out = run_merge(&publisher, &[&pr.event_id.to_hex()]).await?;
anyhow::ensure!(
out.status.success(),
"ngit merge exited {:?}\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
assert_ne!(
rev_parse(&publisher, "main").await?,
main_before,
"main should advance to the merge commit",
);
assert_eq!(
dirty_worktree_state(&publisher).await?,
dirty_before,
"the merge must preserve the exact staged, unstaged, untracked, and stash state",
);
Ok(())
}
#[tokio::test]
async fn linked_target_worktree_preserves_dirty_state_after_merge() -> Result<()> {
let Setup {
harness: _h,
_published: _,
prs,
publisher,
} = setup().await?;
let pr = &prs[0];
let out = publisher
.ngit(["pr", "checkout", &pr.event_id.to_hex()])
.output()
.await
.context("failed to spawn ngit pr checkout")?;
anyhow::ensure!(
out.status.success(),
"ngit pr checkout exited {:?}\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
git_ok(&publisher, ["checkout", "main"], "git checkout main").await?;
git_ok(
&publisher,
["checkout", "-b", "primary-parking"],
"git checkout -b primary-parking",
)
.await?;
let linked_parent = tempfile::tempdir().context("create linked-worktree parent")?;
let linked = linked_parent.path().join("target-worktree");
add_linked_worktree(&publisher, &linked, "main").await?;
let main_before = rev_parse_at(&publisher, &linked, "main").await?;
make_worktree_staged_unstaged_and_untracked_at(&publisher, &linked).await?;
let dirty_before = dirty_worktree_state_at(&publisher, &linked).await?;
let out = run_merge_at(&publisher, &linked, &[&pr.event_id.to_hex()]).await?;
anyhow::ensure!(
out.status.success(),
"ngit merge from linked target worktree exited {:?}\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
assert_eq!(current_branch_at(&publisher, &linked).await?, "main");
assert_eq!(
current_branch(&publisher).await?,
"primary-parking",
"the primary worktree branch must not change",
);
assert_ne!(
rev_parse_at(&publisher, &linked, "main").await?,
main_before,
"main should advance to the merge commit",
);
assert_eq!(
dirty_worktree_state_at(&publisher, &linked).await?,
dirty_before,
"the linked worktree must retain its exact dirty state",
);
Ok(())
}
#[tokio::test]
async fn linked_source_worktree_refuses_when_target_is_checked_out_elsewhere() -> Result<()> {
let Setup {
harness: _h,
_published: _,
prs,
publisher,
} = setup().await?;
let pr = &prs[0];
let out = publisher
.ngit(["pr", "checkout", &pr.event_id.to_hex()])
.output()
.await
.context("failed to spawn ngit pr checkout")?;
anyhow::ensure!(
out.status.success(),
"ngit pr checkout exited {:?}\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
let pr_branch = expected_branch_name(pr);
git_ok(&publisher, ["checkout", "main"], "git checkout main").await?;
let linked_parent = tempfile::tempdir().context("create linked-worktree parent")?;
let linked = linked_parent.path().join("source-worktree");
add_linked_worktree(&publisher, &linked, &pr_branch).await?;
let main_before = rev_parse(&publisher, "main").await?;
make_worktree_staged_unstaged_and_untracked_at(&publisher, &linked).await?;
let dirty_before = dirty_worktree_state_at(&publisher, &linked).await?;
let out = run_merge_at(&publisher, &linked, &[]).await?;
assert!(
!out.status.success(),
"ngit merge must refuse when the target branch is checked out in another worktree",
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("main") && stderr.contains("another worktree"),
"the error should identify the target and other worktree, got:\n{stderr}",
);
assert_eq!(
rev_parse(&publisher, "main").await?,
main_before,
"main must not advance",
);
assert_eq!(current_branch(&publisher).await?, "main");
assert_eq!(current_branch_at(&publisher, &linked).await?, pr_branch);
assert_eq!(
dirty_worktree_state_at(&publisher, &linked).await?,
dirty_before,
"the refused merge must leave the linked worktree untouched",
);
Ok(())
}
#[tokio::test]
async fn conflicting_saved_worktree_rolls_back_merge_and_restores_source() -> Result<()> {
let Setup {
harness: _h,
_published: _,
prs,
publisher,
} = setup().await?;
let pr = &prs[0];
let main_before = rev_parse(&publisher, "main").await?;
git_ok(
&publisher,
["checkout", "-b", "local-work"],
"git checkout -b local-work",
)
.await?;
std::fs::write(publisher.dir().join("README.md"), "local branch base\n")
.context("write local branch README.md")?;
git_ok(&publisher, ["add", "README.md"], "git add README.md").await?;
git_ok(
&publisher,
["commit", "-m", "local branch base", "--no-gpg-sign"],
"git commit local branch base",
)
.await?;
make_worktree_staged_unstaged_and_untracked(&publisher).await?;
let dirty_before = dirty_worktree_state(&publisher).await?;
let out = run_merge(&publisher, &[&pr.event_id.to_hex()]).await?;
assert!(
!out.status.success(),
"ngit merge must fail when the saved work cannot apply cleanly to the merged target",
);
assert_eq!(
current_branch(&publisher).await?,
"local-work",
"rollback should restore the source branch",
);
assert_eq!(
rev_parse(&publisher, "main").await?,
main_before,
"rollback must remove the merge commit from main",
);
assert_eq!(
dirty_worktree_state(&publisher).await?,
dirty_before,
"rollback must restore the exact staged, unstaged, untracked, and stash state",
);
Ok(())
}
#[tokio::test]
async fn exclude_description_omits_body_footer() -> Result<()> {
let Setup {
harness: _h,
_published: _,
prs,
publisher,
} = setup().await?;
let pr = &prs[0];
let out = run_merge(
&publisher,
&[&pr.event_id.to_hex(), "--exclude-description"],
)
.await?;
anyhow::ensure!(
out.status.success(),
"ngit merge --exclude-description exited {:?}\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
let msg = commit_message(&publisher, "main").await?;
let shorthand = &pr.event_id.to_hex()[..8];
assert!(
msg.starts_with(&format!("Merge #{shorthand}: ")),
"subject should still be present, got:\n{msg}",
);
assert!(
msg.contains("nostr:nevent1"),
"nevent reference should still be present, got:\n{msg}",
);
assert!(
!msg.contains("PR description:") && !msg.contains("CoverNote:"),
"description footer should be omitted with --exclude-description, got:\n{msg}",
);
Ok(())
}
#[tokio::test]
async fn author_trailer_carries_author_npub() -> Result<()> {
let Setup {
harness: _h,
_published: _,
prs,
publisher,
} = setup().await?;
let pr = &prs[0];
let out = run_merge(
&publisher,
&[&pr.event_id.to_hex(), "--exclude-description"],
)
.await?;
anyhow::ensure!(
out.status.success(),
"ngit merge --exclude-description exited {:?}\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
let msg = commit_message(&publisher, "main").await?;
let author_npub = pr
.author_pubkey
.to_bech32()
.context("failed to bech32-encode PR author pubkey")?;
assert!(
msg.contains("PR-Author:"),
"PR-Author trailer should survive --exclude-description, got:\n{msg}",
);
assert!(
msg.contains(&format!("\nnostr:{author_npub}")),
"author npub should be on its own bare nostr: URI line, got:\n{msg}",
);
Ok(())
}
#[tokio::test]
async fn merge_without_id_off_pr_branch_fails() -> Result<()> {
let Setup {
harness: _h,
_published: _,
prs: _,
publisher,
} = setup().await?;
git_ok(&publisher, ["checkout", "main"], "git checkout main").await?;
let out = run_merge(&publisher, &[]).await?;
assert!(
!out.status.success(),
"ngit merge with no id off a pr/ branch should fail",
);
Ok(())
}
#[tokio::test]
async fn canonical_and_alias_refuse_closed_and_applied_prs() -> Result<()> {
let Setup {
harness: _h,
_published: _,
prs,
publisher,
} = setup().await?;
let closed = &prs[0];
let applied = &prs[1];
let closed_id = closed.event_id.to_hex();
let close = publisher
.ngit(["pr", "close", &closed_id])
.output()
.await
.context("failed to spawn ngit pr close")?;
anyhow::ensure!(
close.status.success(),
"ngit pr close exited {:?}\nstdout: {}\nstderr: {}",
close.status,
String::from_utf8_lossy(&close.stdout),
String::from_utf8_lossy(&close.stderr),
);
let applied_id = applied.event_id.to_hex();
let first_merge = run_pr_merge(&publisher, &[&applied_id]).await?;
anyhow::ensure!(
first_merge.status.success(),
"initial ngit pr merge exited {:?}\nstdout: {}\nstderr: {}",
first_merge.status,
String::from_utf8_lossy(&first_merge.stdout),
String::from_utf8_lossy(&first_merge.stderr),
);
publisher.nostr_push(["origin", "main"]).await?;
let state_before = local_git_state(&publisher).await?;
let closed_merge = run_pr_merge(&publisher, &[&closed_id]).await?;
assert_refusal_preserves_local_state(
&publisher,
&closed_merge,
&state_before,
"canonical ngit pr merge of a closed PR",
)
.await?;
let closed_alias = run_merge(&publisher, &[&closed_id]).await?;
assert_refusal_preserves_local_state(
&publisher,
&closed_alias,
&state_before,
"top-level ngit merge of a closed PR",
)
.await?;
let applied_canonical = run_pr_merge(&publisher, &[&applied_id]).await?;
assert_refusal_preserves_local_state(
&publisher,
&applied_canonical,
&state_before,
"canonical ngit pr merge of an applied PR",
)
.await?;
let applied_merge = run_merge(&publisher, &[&applied_id]).await?;
assert_refusal_preserves_local_state(
&publisher,
&applied_merge,
&state_before,
"top-level ngit merge of an applied PR",
)
.await?;
Ok(())
}
#[tokio::test]
async fn pr_merge_without_id_infers_self_submitted_bare_pr_branch() -> 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 {
display_name: Some("merge maintainer".into()),
identifier: Some("merge-bare-branch-repo".into()),
..Default::default()
})
.await?;
let author = harness
.clone_published_repo(
&published,
CloneLogin::AsContributor {
display_name: "self merging contributor".into(),
},
)
.await?;
let branch = "pr/my-feature";
author
.git_ok(["checkout", "-b", branch], "git checkout -b pr/my-feature")
.await?;
std::fs::write(author.dir().join("feat.md"), "some content\n").context("write feat.md")?;
author.git_ok(["add", "feat.md"], "git add feat.md").await?;
author
.git_ok(
["commit", "-m", "add feat.md", "--no-gpg-sign"],
"git commit feat.md",
)
.await?;
let pr_tip = rev_parse(&author, "HEAD").await?;
author
.nostr_push(["-u", "origin", branch])
.await
.context("git push -u origin pr/my-feature (PR creation) failed")?;
assert_eq!(current_branch(&author).await?, branch);
let out = run_pr_merge(&author, &[]).await?;
anyhow::ensure!(
out.status.success(),
"ngit pr merge (no id) on a self-submitted bare pr/ branch exited {:?}\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
assert_eq!(
current_branch(&author).await?,
"main",
"should be left on the default branch after merge",
);
assert_eq!(
parent_count(&author, "main").await?,
2,
"no-ff merge should produce a 2-parent merge commit",
);
let merged_in = rev_parse(&author, "main^2").await?;
assert_eq!(
merged_in, pr_tip,
"the merge commit's second parent should be the pushed PR tip",
);
let applied = harness
.grasp("repo")
.events(Filter::new().kind(Kind::GitStatusApplied))
.await?;
assert!(
applied.is_empty(),
"a local PR merge must not publish an applied status before Git push",
);
Ok(())
}
async fn read_git_path(repo: &Repo, name: &str) -> Result<Option<String>> {
let out = repo
.git(["rev-parse", "--git-path", name])
.output()
.await
.with_context(|| format!("failed to spawn git rev-parse --git-path {name}"))?;
anyhow::ensure!(
out.status.success(),
"git rev-parse --git-path {name} exited {:?}: {}",
out.status,
String::from_utf8_lossy(&out.stderr),
);
let rel = String::from_utf8(out.stdout)
.context("git rev-parse stdout not utf-8")?
.trim()
.to_string();
let path = repo.dir().join(rel);
match std::fs::read_to_string(&path) {
Ok(s) => Ok(Some(s)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("failed to read {path:?}")),
}
}
#[tokio::test]
async fn conflicting_pr_merge_is_left_in_progress_for_manual_resolution() -> Result<()> {
let Setup {
harness: _h,
_published: _,
prs,
publisher,
} = setup().await?;
let pr = &prs[0];
std::fs::write(publisher.dir().join("a3.md"), "main side content\n")
.context("write conflicting a3.md on main")?;
publisher.git_ok(["add", "a3.md"], "git add a3.md").await?;
publisher
.git_ok(
["commit", "-m", "add a3.md (main side)", "--no-gpg-sign"],
"git commit a3.md on main",
)
.await?;
let main_before = rev_parse(&publisher, "main").await?;
let out = run_pr_merge(&publisher, &[&pr.event_id.to_hex(), "--json"]).await?;
anyhow::ensure!(
out.status.success(),
"ngit pr merge should exit 0 and leave the conflicted merge in progress, got {:?}\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
let json: Value = serde_json::from_slice(&out.stdout)
.with_context(|| format!("conflicted merge stdout is not JSON: {:?}", out.stdout))?;
assert_eq!(json["command_status"], "ok", "{json}");
assert_eq!(json["action"], "conflicted", "{json}");
assert_eq!(json["entity"], "pr", "{json}");
let id = json["id"]
.as_str()
.with_context(|| format!("conflicted merge id is not a string: {json}"))?;
assert_eq!(
Nip19Event::from_bech32(id)
.context("conflicted merge id is not a valid nevent")?
.event_id,
pr.event_id,
"conflicted merge must identify the proposal: {json}",
);
assert!(json.get("ci").is_none(), "{json}");
assert!(json.get("ci_warning").is_none(), "{json}");
assert!(json.get("event").is_none(), "{json}");
assert_eq!(
current_branch(&publisher).await?,
"main",
"should be left on the default branch with the merge in progress",
);
assert_eq!(
rev_parse(&publisher, "main").await?,
main_before,
"main must not advance until the user resolves and commits",
);
assert!(
read_git_path(&publisher, "MERGE_HEAD").await?.is_some(),
"MERGE_HEAD should be present (merge in progress)",
);
let merge_msg = read_git_path(&publisher, "MERGE_MSG")
.await?
.context("MERGE_MSG should be present (prepared by ngit)")?;
let shorthand = &pr.event_id.to_hex()[..8];
assert!(
merge_msg.contains(&format!("Merge #{shorthand}: ")),
"prepared MERGE_MSG should carry ngit's subject, got:\n{merge_msg}",
);
assert!(
merge_msg.contains("nostr:nevent1"),
"prepared MERGE_MSG should carry the PR nevent, got:\n{merge_msg}",
);
assert!(
merge_msg.contains("PR-Author:"),
"prepared MERGE_MSG should carry the PR-Author trailer, got:\n{merge_msg}",
);
std::fs::write(publisher.dir().join("a3.md"), "resolved content\n")
.context("write resolved a3.md")?;
publisher
.git_ok(["add", "a3.md"], "git add resolved a3.md")
.await?;
publisher
.git_ok(
["commit", "--no-edit", "--no-gpg-sign"],
"git commit to finish the merge",
)
.await?;
assert_eq!(
parent_count(&publisher, "main").await?,
2,
"completing the resolved merge should produce a 2-parent merge commit",
);
let msg = commit_message(&publisher, "main").await?;
assert!(
msg.contains(&format!("Merge #{shorthand}: ")) && msg.contains("nostr:nevent1"),
"the completed merge commit should carry ngit's prepared message, got:\n{msg}",
);
Ok(())
}
#[tokio::test]
async fn local_branch_ahead_of_published_tip_aborts_merge() -> Result<()> {
let Setup {
harness: _h,
_published: _,
prs,
publisher,
} = setup().await?;
let pr = &prs[0];
let out = publisher
.ngit(["pr", "checkout", &pr.event_id.to_hex()])
.output()
.await
.context("failed to spawn ngit pr checkout")?;
anyhow::ensure!(
out.status.success(),
"ngit pr checkout exited {:?}\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
let branch = expected_branch_name(pr);
assert_eq!(current_branch(&publisher).await?, branch);
std::fs::write(publisher.dir().join("more.md"), "unpushed\n").context("write more.md")?;
publisher
.git_ok(["add", "more.md"], "git add more.md")
.await?;
publisher
.git_ok(
["commit", "-m", "add more.md (unpushed)", "--no-gpg-sign"],
"git commit more.md",
)
.await?;
let main_before = rev_parse(&publisher, "main").await?;
let out = run_merge(&publisher, &[]).await?;
assert!(
!out.status.success(),
"ngit merge must abort when the local pr/ branch is ahead of the published tip\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
assert_eq!(
rev_parse(&publisher, "main").await?,
main_before,
"main must not advance when the merge is aborted",
);
Ok(())
}
#[tokio::test]
async fn self_submitted_bare_branch_ahead_of_published_tip_aborts_merge() -> 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 {
display_name: Some("merge maintainer".into()),
identifier: Some("merge-bare-drift-repo".into()),
..Default::default()
})
.await?;
let author = harness
.clone_published_repo(
&published,
CloneLogin::AsContributor {
display_name: "self merging drifting contributor".into(),
},
)
.await?;
let branch = "pr/bare-drift-feature";
author
.git_ok(
["checkout", "-b", branch],
"git checkout -b pr/bare-drift-feature",
)
.await?;
std::fs::write(author.dir().join("feat.md"), "some content\n").context("write feat.md")?;
author.git_ok(["add", "feat.md"], "git add feat.md").await?;
author
.git_ok(
["commit", "-m", "add feat.md", "--no-gpg-sign"],
"git commit feat.md",
)
.await?;
author
.nostr_push(["-u", "origin", branch])
.await
.context("git push -u origin pr/bare-drift-feature (PR creation) failed")?;
assert_eq!(current_branch(&author).await?, branch);
std::fs::write(author.dir().join("more.md"), "unpushed\n").context("write more.md")?;
author.git_ok(["add", "more.md"], "git add more.md").await?;
author
.git_ok(
["commit", "-m", "add more.md (unpushed)", "--no-gpg-sign"],
"git commit more.md",
)
.await?;
let main_before = rev_parse(&author, "main").await?;
let out = run_merge(&author, &[]).await?;
assert!(
!out.status.success(),
"ngit merge must abort when the bare self-submitted pr/ branch is ahead of the published tip\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
assert_eq!(
rev_parse(&author, "main").await?,
main_before,
"main must not advance when the merge is aborted",
);
Ok(())
}
#[tokio::test]
async fn merge_without_id_resolves_unauthored_bare_branch_by_tip() -> Result<()> {
let Setup {
harness: _h,
_published: _,
prs,
publisher,
} = setup().await?;
let pr = &prs[0];
let checkout = publisher
.ngit(["pr", "checkout", &pr.event_id.to_hex()])
.output()
.await
.context("failed to spawn ngit pr checkout")?;
anyhow::ensure!(
checkout.status.success(),
"ngit pr checkout exited {:?}\nstdout: {}\nstderr: {}",
checkout.status,
String::from_utf8_lossy(&checkout.stdout),
String::from_utf8_lossy(&checkout.stderr),
);
publisher
.git_ok(["checkout", "main"], "git checkout main")
.await?;
let bare_branch = format!("pr/{}", pr.branch_name);
publisher
.git_ok(
["checkout", "-b", &bare_branch, &pr.tip],
"git checkout -b bare pr branch at published tip",
)
.await?;
let out = run_merge(&publisher, &[]).await?;
anyhow::ensure!(
out.status.success(),
"ngit merge (no id) on an unauthored bare pr/ branch exited {:?}\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
assert_eq!(
current_branch(&publisher).await?,
"main",
"should be left on the default branch after merge",
);
assert_eq!(
parent_count(&publisher, "main").await?,
2,
"no-ff merge should produce a 2-parent merge commit",
);
let merged_in = rev_parse(&publisher, "main^2").await?;
assert_eq!(
merged_in, pr.tip,
"the merge commit's second parent should be the published PR tip",
);
let msg = commit_message(&publisher, "main").await?;
let shorthand = &pr.event_id.to_hex()[..8];
assert!(
msg.starts_with(&format!("Merge #{shorthand}: ")),
"merge commit subject should carry the tip-matched PR's shorthand, got:\n{msg}",
);
Ok(())
}
#[tokio::test]
async fn merge_without_id_resolves_bare_branch_by_tip_when_logged_out() -> 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 {
display_name: Some("merge maintainer".into()),
identifier: Some("merge-tip-logged-out-repo".into()),
..Default::default()
})
.await?;
let author = harness
.clone_published_repo(
&published,
CloneLogin::AsContributor {
display_name: "soon logged out contributor".into(),
},
)
.await?;
let branch = "pr/tip-match-feature";
author
.git_ok(
["checkout", "-b", branch],
"git checkout -b pr/tip-match-feature",
)
.await?;
std::fs::write(author.dir().join("feat.md"), "some content\n").context("write feat.md")?;
author.git_ok(["add", "feat.md"], "git add feat.md").await?;
author
.git_ok(
["commit", "-m", "add feat.md", "--no-gpg-sign"],
"git commit feat.md",
)
.await?;
let pr_tip = rev_parse(&author, "HEAD").await?;
author
.nostr_push(["-u", "origin", branch])
.await
.context("git push -u origin pr/tip-match-feature (PR creation) failed")?;
assert_eq!(current_branch(&author).await?, branch);
let out = author
.ngit(["account", "logout"])
.output()
.await
.context("failed to spawn ngit account logout")?;
anyhow::ensure!(
out.status.success(),
"ngit account logout exited {:?}\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
let out = author
.git(["config", "--get", "nostr.npub"])
.output()
.await
.context("failed to spawn git config --get nostr.npub")?;
anyhow::ensure!(
!out.status.success(),
"nostr.npub should be unset after logout",
);
let out = run_merge(&author, &[]).await?;
anyhow::ensure!(
out.status.success(),
"ngit merge (no id) logged out on own bare pr/ branch exited {:?}\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
assert_eq!(
current_branch(&author).await?,
"main",
"should be left on the default branch after merge",
);
assert_eq!(
parent_count(&author, "main").await?,
2,
"no-ff merge should produce a 2-parent merge commit",
);
let merged_in = rev_parse(&author, "main^2").await?;
assert_eq!(
merged_in, pr_tip,
"the merge commit's second parent should be the pushed PR tip",
);
Ok(())
}
#[tokio::test]
async fn merge_without_id_ambiguous_name_and_tip_still_errors() -> 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 {
display_name: Some("merge maintainer".into()),
identifier: Some("merge-ambiguous-tip-repo".into()),
..Default::default()
})
.await?;
let contributor_a = harness
.clone_published_repo(
&published,
CloneLogin::AsContributor {
display_name: "contributor a".into(),
},
)
.await?;
contributor_a
.git_ok(["checkout", "-b", "pr/dup"], "git checkout -b pr/dup (a)")
.await?;
std::fs::write(contributor_a.dir().join("dup.md"), "duplicated work\n")
.context("write dup.md")?;
contributor_a
.git_ok(["add", "dup.md"], "git add dup.md")
.await?;
contributor_a
.git_ok(
["commit", "-m", "add dup.md", "--no-gpg-sign"],
"git commit dup.md",
)
.await?;
let shared_tip = rev_parse(&contributor_a, "HEAD").await?;
contributor_a
.nostr_push(["-u", "origin", "pr/dup"])
.await
.context("git push -u origin pr/dup (contributor a) failed")?;
let contributor_b = harness
.clone_published_repo(
&published,
CloneLogin::AsContributor {
display_name: "contributor b".into(),
},
)
.await?;
contributor_b
.git_ok(
["config", "--local", "nostr.auto-pr-branches", "true"],
"enable automatic PR branches for duplicate-tip setup",
)
.await?;
contributor_b
.git_ok(["fetch", "origin"], "fetch contributor A's PR tip")
.await?;
contributor_b
.git_ok(
["checkout", "-b", "pr/dup", &shared_tip],
"git checkout -b pr/dup at shared tip (b)",
)
.await?;
contributor_b
.nostr_push(["-u", "origin", "pr/dup"])
.await
.context("git push -u origin pr/dup (contributor b) failed")?;
let pr_roots = harness
.grasp("repo")
.events(nostr::prelude::Filter::new().kind(ngit::git_events::KIND_PULL_REQUEST))
.await?;
anyhow::ensure!(
pr_roots.len() == 2,
"expected two same-named same-tip PR roots on the relay, got {}",
pr_roots.len(),
);
publisher
.git_ok(
["config", "--local", "nostr.auto-pr-branches", "true"],
"enable automatic PR branches for ambiguous-tip setup",
)
.await?;
publisher
.git_ok(["fetch", "origin"], "git fetch origin")
.await?;
publisher
.git_ok(
["checkout", "-b", "pr/dup", &shared_tip],
"git checkout -b pr/dup at shared tip (maintainer)",
)
.await?;
let main_before = rev_parse(&publisher, "main").await?;
let out = run_merge(&publisher, &[]).await?;
assert!(
!out.status.success(),
"ngit merge must bail when two open PRs share the branch name and tip\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
let stderr = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
stderr.contains("pr/dup") && stderr.contains("event-id"),
"error should name the branch and the event-id remedy, got:\n{stderr}",
);
assert_eq!(
rev_parse(&publisher, "main").await?,
main_before,
"main must not advance when the merge is refused",
);
Ok(())
}