use anyhow::{Context, Result};
use test_harness::{CloneLogin, Harness, PublishRepoOpts, PublishedPr, PublishedRepo, Repo};
struct Setup {
harness: Harness,
_published: PublishedRepo,
prs: [PublishedPr; 3],
test_repo: Repo,
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("pr-checkout maintainer".into()),
identifier: Some("pr-checkout-repo".into()),
..Default::default()
})
.await?;
let prs = harness.publish_three_open_proposals(&published).await?;
let test_repo = harness
.clone_published_repo(&published, CloneLogin::None)
.await?;
Ok(Setup {
harness,
_published: published,
prs,
test_repo,
publisher,
})
}
fn expected_branch_name(pr: &PublishedPr) -> String {
let hex = pr.event_id.to_hex();
format!("pr/{}({})", pr.branch_name, &hex[..8])
}
async fn run_pr_checkout(repo: &Repo, pr: &PublishedPr) -> Result<()> {
let event_id_hex = pr.event_id.to_hex();
let out = repo
.ngit(["pr", "checkout", &event_id_hex])
.output()
.await
.context("failed to spawn ngit pr checkout")?;
anyhow::ensure!(
out.status.success(),
"ngit pr checkout {event_id_hex} exited {:?}\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
Ok(())
}
async fn run_pr_checkout_force(repo: &Repo, pr: &PublishedPr) -> Result<()> {
let event_id_hex = pr.event_id.to_hex();
let out = repo
.ngit(["pr", "checkout", "--force", &event_id_hex])
.output()
.await
.context("failed to spawn ngit pr checkout --force")?;
anyhow::ensure!(
out.status.success(),
"ngit pr checkout --force {event_id_hex} exited {:?}\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
Ok(())
}
async fn git_ok<I, S>(repo: &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}"))?;
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 rev_parse(repo: &Repo, rev: &str) -> Result<String> {
let out = repo
.git(["rev-parse", rev])
.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> {
let out = repo
.git(["symbolic-ref", "--short", "HEAD"])
.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())
}
async fn is_ancestor(repo: &Repo, maybe_ancestor: &str, descendant: &str) -> Result<bool> {
let out = repo
.git(["merge-base", "--is-ancestor", maybe_ancestor, descendant])
.output()
.await
.context("failed to spawn git merge-base --is-ancestor")?;
Ok(out.status.success())
}
#[tokio::test]
async fn fresh_branch_is_created_checked_out_and_at_published_tip() -> Result<()> {
let Setup {
harness: _h,
_published: _,
prs,
test_repo,
publisher: _,
} = setup().await?;
let pr = &prs[0];
run_pr_checkout(&test_repo, pr).await?;
let branch = expected_branch_name(pr);
assert_eq!(
current_branch(&test_repo).await?,
branch,
"expected the proposal branch to be the currently checked-out one",
);
let tip = rev_parse(&test_repo, &branch).await?;
assert_eq!(
tip, pr.tip,
"proposal branch tip should match the published PR's tip",
);
Ok(())
}
#[tokio::test]
async fn up_to_date_branch_stays_at_published_tip_after_second_checkout() -> Result<()> {
let Setup {
harness: _h,
_published: _,
prs,
test_repo,
publisher: _,
} = setup().await?;
let pr = &prs[0];
run_pr_checkout(&test_repo, pr).await?;
let branch = expected_branch_name(pr);
let first_tip = rev_parse(&test_repo, &branch).await?;
assert_eq!(first_tip, pr.tip);
git_ok(&test_repo, ["checkout", "main"], "git checkout main").await?;
run_pr_checkout(&test_repo, pr).await?;
assert_eq!(
current_branch(&test_repo).await?,
branch,
"second checkout should leave us on the proposal branch",
);
let second_tip = rev_parse(&test_repo, &branch).await?;
assert_eq!(
second_tip, pr.tip,
"tip should still equal the published tip after a no-op checkout",
);
Ok(())
}
#[tokio::test]
async fn behind_branch_fast_forwards_to_published_tip() -> Result<()> {
let Setup {
harness: _h,
_published: _,
prs,
test_repo,
publisher: _,
} = setup().await?;
let pr = &prs[0];
run_pr_checkout(&test_repo, pr).await?;
let branch = expected_branch_name(pr);
git_ok(
&test_repo,
["checkout", "main"],
"git checkout main (pre-rewind)",
)
.await?;
let parent_oid = rev_parse(&test_repo, &format!("{branch}~1")).await?;
git_ok(
&test_repo,
["branch", "-f", &branch, &parent_oid],
"git branch -f <branch> <branch>~1",
)
.await?;
let rewound_tip = rev_parse(&test_repo, &branch).await?;
assert_eq!(
rewound_tip, parent_oid,
"branch should now point at its previous parent commit",
);
assert_ne!(
rewound_tip, pr.tip,
"branch should differ from published tip after the rewind",
);
run_pr_checkout(&test_repo, pr).await?;
assert_eq!(current_branch(&test_repo).await?, branch);
let tip_after = rev_parse(&test_repo, &branch).await?;
assert_eq!(
tip_after, pr.tip,
"branch should have been fast-forwarded back to the published tip",
);
Ok(())
}
#[tokio::test]
async fn local_amendments_are_preserved_when_checkout_without_force_fails() -> Result<()> {
let Setup {
harness: _h,
_published: _,
prs,
test_repo,
publisher: _,
} = setup().await?;
let pr = &prs[0];
run_pr_checkout(&test_repo, pr).await?;
let branch = expected_branch_name(pr);
git_ok(
&test_repo,
["checkout", "main"],
"git checkout main (pre-amend)",
)
.await?;
let parent_oid = rev_parse(&test_repo, &format!("{branch}~1")).await?;
git_ok(
&test_repo,
["branch", "-f", &branch, &parent_oid],
"git branch -f rewind (amend setup)",
)
.await?;
git_ok(&test_repo, ["checkout", &branch], "git checkout <branch>").await?;
std::fs::write(
test_repo.dir().join("ammended-commit.md"),
"add ammended-commit.md",
)
.context("write ammended-commit.md")?;
git_ok(&test_repo, ["add", "ammended-commit.md"], "git add amend").await?;
git_ok(
&test_repo,
["commit", "-m", "add ammended-commit.md", "--no-gpg-sign"],
"git commit amend",
)
.await?;
let amended_tip = rev_parse(&test_repo, &branch).await?;
assert_ne!(amended_tip, pr.tip);
assert_ne!(amended_tip, parent_oid);
git_ok(
&test_repo,
["checkout", "main"],
"git checkout main (post-amend)",
)
.await?;
let res = run_pr_checkout(&test_repo, pr).await;
assert!(
res.is_err(),
"expected `ngit pr checkout` to fail on a diverged branch without --force",
);
let tip_after = rev_parse(&test_repo, &branch).await?;
assert_eq!(
tip_after, amended_tip,
"amended local commit should still be the branch tip",
);
assert_ne!(
tip_after, pr.tip,
"local tip must NOT have been overwritten with the published tip",
);
Ok(())
}
#[tokio::test]
async fn local_commits_on_top_are_not_discarded() -> Result<()> {
let Setup {
harness: _h,
_published: _,
prs,
test_repo,
publisher: _,
} = setup().await?;
let pr = &prs[0];
run_pr_checkout(&test_repo, pr).await?;
let branch = expected_branch_name(pr);
git_ok(&test_repo, ["checkout", &branch], "git checkout <branch>").await?;
std::fs::write(test_repo.dir().join("local-extra.md"), "local work\n")
.context("write local-extra.md")?;
git_ok(&test_repo, ["add", "local-extra.md"], "git add local-extra").await?;
git_ok(
&test_repo,
["commit", "-m", "add local-extra.md", "--no-gpg-sign"],
"git commit local-extra",
)
.await?;
let local_tip = rev_parse(&test_repo, &branch).await?;
assert_ne!(local_tip, pr.tip);
git_ok(
&test_repo,
["checkout", "main"],
"git checkout main (between checkouts)",
)
.await?;
run_pr_checkout(&test_repo, pr).await?;
assert_eq!(
current_branch(&test_repo).await?,
branch,
"branch should be checked out after the second `ngit pr checkout`",
);
let tip_after = rev_parse(&test_repo, &branch).await?;
assert_eq!(
tip_after, local_tip,
"local commits should not have been discarded",
);
assert_ne!(
tip_after, pr.tip,
"local tip should still be ahead of the published tip",
);
assert!(
is_ancestor(&test_repo, &pr.tip, &tip_after).await?,
"published tip must be an ancestor of the local tip",
);
Ok(())
}
#[tokio::test]
async fn newer_revision_force_updates_to_revised_tip() -> Result<()> {
let Setup {
harness: _h,
_published: _,
prs,
test_repo,
publisher,
} = setup().await?;
let pr = &prs[0];
run_pr_checkout(&test_repo, pr).await?;
let branch = expected_branch_name(pr);
let tip_before_revision = rev_parse(&test_repo, &branch).await?;
assert_eq!(tip_before_revision, pr.tip);
run_pr_checkout(&publisher, pr).await?;
git_ok(
&publisher,
["checkout", "main"],
"git checkout main after fetching proposal tip",
)
.await?;
std::fs::write(publisher.dir().join("amazing.md"), "rebase base content\n")
.context("write amazing.md")?;
git_ok(&publisher, ["add", "amazing.md"], "git add amazing.md").await?;
git_ok(
&publisher,
[
"commit",
"-m",
"commit for rebasing on top of",
"--no-gpg-sign",
],
"git commit rebase base",
)
.await?;
publisher.nostr_push(["origin", "main"]).await?;
git_ok(
&publisher,
["checkout", "-b", &pr.branch_name],
"git checkout -b feature (revision)",
)
.await?;
std::fs::write(publisher.dir().join("revised-a3.md"), "revised a3\n")
.context("write revised-a3.md")?;
git_ok(&publisher, ["add", "revised-a3.md"], "git add revised-a3").await?;
git_ok(
&publisher,
["commit", "-m", "add revised-a3.md", "--no-gpg-sign"],
"git commit revised-a3",
)
.await?;
std::fs::write(publisher.dir().join("revised-a4.md"), "revised a4\n")
.context("write revised-a4.md")?;
git_ok(&publisher, ["add", "revised-a4.md"], "git add revised-a4").await?;
git_ok(
&publisher,
["commit", "-m", "add revised-a4.md", "--no-gpg-sign"],
"git commit revised-a4",
)
.await?;
let revised_tip = rev_parse(&publisher, "HEAD").await?;
assert_ne!(revised_tip, pr.tip);
let in_reply_to_hex = pr.event_id.to_hex();
let send_out = publisher
.ngit([
"send",
"HEAD~2",
"--force-pr",
"--force",
"--defaults",
"--in-reply-to",
&in_reply_to_hex,
])
.output()
.await
.context("failed to spawn ngit send --force-pr --in-reply-to")?;
anyhow::ensure!(
send_out.status.success(),
"ngit send revision exited {:?}\nstdout: {}\nstderr: {}",
send_out.status,
String::from_utf8_lossy(&send_out.stdout),
String::from_utf8_lossy(&send_out.stderr),
);
let res = run_pr_checkout(&test_repo, pr).await;
assert!(
res.is_err(),
"expected `ngit pr checkout` to fail on a diverged branch without --force after revision",
);
run_pr_checkout_force(&test_repo, pr).await?;
assert_eq!(
current_branch(&test_repo).await?,
branch,
"branch should be checked out after the force-checkout",
);
let tip_after = rev_parse(&test_repo, &branch).await?;
assert_eq!(
tip_after, revised_tip,
"branch should now point at the revision's tip",
);
Ok(())
}