//! Patch proposals over SSH, driven by real git over real SSH.
//!
//! Tests the proc-receive hook that intercepts pushes to `refs/patches` and
//! creates patch proposals automatically.

mod support;

use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};

use sea_orm::{ColumnTrait, Database, EntityTrait, QueryFilter};
use support::{Client, Server, free_port};

/// The password the tests register and log in with.
const PASSWORD: &str = "correct horse battery staple";

/// Names the scratch directories, so several tests can run at once.
static COUNTER: AtomicU64 = AtomicU64::new(0);

fn spawn() -> (Server, u16) {
    let port = free_port();
    let mut server = Server::spawn(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);
    let ssh_port = server.ssh_port();
    server.wait_for_connection(ssh_port);
    (server, port)
}

/// Whether the external tools these tests drive are on the machine.
fn tooling_present() -> bool {
    fn have(binary: &str, arg: &str) -> bool {
        Command::new(binary).arg(arg).output().is_ok()
    }

    have("git", "--version") && have("ssh", "-V") && have("ssh-keygen", "--version")
}

/// Leaves the test quietly if `git`, `ssh` or `ssh-keygen` is missing.
macro_rules! skip_without_tooling {
    () => {
        if !tooling_present() {
            eprintln!("skipping: this test needs `git`, `ssh` and `ssh-keygen` on PATH");
            return Ok(());
        }
    };
}

/// A directory of the test's own, removed when it goes out of scope.
struct Scratch {
    dir: PathBuf,
}

impl Scratch {
    fn new(label: &str) -> Self {
        let count = COUNTER.fetch_add(1, Ordering::Relaxed);
        let dir =
            std::env::temp_dir().join(format!("frogue-{label}-{}-{count}", std::process::id()));
        fs::create_dir_all(&dir).expect("the scratch directory should be creatable");
        Scratch { dir }
    }

    fn path(&self, name: &str) -> PathBuf {
        self.dir.join(name)
    }
}

impl Drop for Scratch {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.dir);
    }
}

/// Writes a fresh ed25519 keypair into `scratch`, and returns the private
/// half's path.
fn keygen(scratch: &Scratch, name: &str) -> PathBuf {
    let key = scratch.path(name);

    let output = Command::new("ssh-keygen")
        .arg("-t")
        .arg("ed25519")
        .arg("-f")
        .arg(&key)
        .arg("-N")
        .arg("")
        .arg("-C")
        .arg(name)
        .output()
        .expect("ssh-keygen should run");

    assert!(
        output.status.success(),
        "ssh-keygen failed:\n{}",
        String::from_utf8_lossy(&output.stderr)
    );

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;
        fs::set_permissions(&key, fs::Permissions::from_mode(0o600))
            .expect("the private key should be readable by its owner alone");
    }

    key
}

/// Registers `name` through the form, which logs them in.
fn register_and_login(client: &mut Client, name: &str, email: &str) {
    let response = client.post_form(
        "/register",
        &[("name", name), ("email", email), ("password", PASSWORD)],
    );

    assert_eq!(
        response.status, 303,
        "registering should log in\n{}",
        response.body
    );
}

/// Adds the public key at `public_key` to `owner`'s account.
fn add_key(client: &mut Client, owner: &str, public_key: &Path) {
    let key = fs::read_to_string(public_key).expect("the public key should be readable");

    let response = client.post_form(&format!("/~{owner}/ssh-keys"), &[("key", key.trim())]);

    assert_eq!(
        response.status, 303,
        "adding a key should redirect\n{}",
        response.body
    );
}

/// Creates `owner`'s repository `slug` with the given visibility.
fn create_repository(client: &mut Client, owner: &str, slug: &str, visibility: &str) {
    let response = client.post_form(
        &format!("/~{owner}/repositories"),
        &[
            ("slug", slug),
            ("description", "a repository from the patch tests"),
            ("visibility", visibility),
        ],
    );

    assert_eq!(
        response.status, 303,
        "creating a repository should redirect\n{}",
        response.body
    );
}

/// The `GIT_SSH_COMMAND` that points `git` at our server using `key`.
fn ssh_command(key: &Path, port: u16) -> String {
    format!(
        "ssh -i {} -o IdentitiesOnly=yes -o BatchMode=yes \
         -o PreferredAuthentications=publickey \
         -o StrictHostKeyChecking=accept-new \
         -o UserKnownHostsFile=/dev/null -p {port}",
        key.display()
    )
}

/// Runs `git` in `dir`, pointed at the server, and returns what it said.
fn git(dir: &Path, key: &Path, port: u16, args: &[&str]) -> Output {
    Command::new("git")
        .current_dir(dir)
        .arg("-c")
        .arg("user.name=Test")
        .arg("-c")
        .arg("user.email=test@example.org")
        .args(args)
        .env("GIT_SSH_COMMAND", ssh_command(key, port))
        .env("GIT_TERMINAL_PROMPT", "0")
        .env("GIT_CONFIG_GLOBAL", "/dev/null")
        .env("GIT_CONFIG_SYSTEM", "/dev/null")
        .output()
        .expect("git should run")
}

/// Fails with `git`'s own words when it did not succeed.
fn assert_ok(output: &Output, what: &str) {
    assert!(
        output.status.success(),
        "{what} should succeed, but git exited with {:?}\nstdout:\n{}\nstderr:\n{}",
        output.status.code(),
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr),
    );
}

/// The `ssh://` URL for a repository on the test server.
fn url(port: u16, owner: &str, slug: &str) -> String {
    format!("ssh://git@127.0.0.1:{port}/{owner}/{slug}.git")
}

/// Test 1: Stranger pushes to `refs/patches` creates a draft PP.
#[tokio::test]
async fn stranger_push_to_refs_patches_creates_draft() -> miette::Result<()> {
    skip_without_tooling!();

    let (server, port) = spawn();
    let ssh_port = server.ssh_port();
    let scratch = Scratch::new("pp-stranger-push");

    let alice_key = keygen(&scratch, "alice_key");
    let bob_key = keygen(&scratch, "bob_key");

    // Alice creates a public repository.
    let mut alice = Client::new(port);
    register_and_login(&mut alice, "alice", "alice@example.org");
    add_key(&mut alice, "alice", &scratch.path("alice_key.pub"));
    create_repository(&mut alice, "alice", "test-repo", "public");

    // Alice pushes to main to have a base.
    let alice_clone = scratch.path("alice-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &alice_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                alice_clone.to_str().unwrap(),
            ],
        ),
        "alice cloning",
    );
    fs::write(alice_clone.join("README.md"), "# Notes\n").expect("writing README");
    assert_ok(
        &git(&alice_clone, &alice_key, ssh_port, &["add", "README.md"]),
        "staging",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["commit", "-m", "Initial commit"],
        ),
        "committing",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["push", "origin", "main"],
        ),
        "alice pushing main",
    );

    // Bob clones and pushes to refs/patches.
    let mut bob = Client::new(port);
    register_and_login(&mut bob, "bob", "bob@example.org");
    add_key(&mut bob, "bob", &scratch.path("bob_key.pub"));

    let bob_clone = scratch.path("bob-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &bob_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                bob_clone.to_str().unwrap(),
            ],
        ),
        "bob cloning",
    );
    fs::write(bob_clone.join("feature.txt"), "new feature\n").expect("writing feature");
    assert_ok(
        &git(&bob_clone, &bob_key, ssh_port, &["add", "feature.txt"]),
        "bob staging",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["commit", "-m", "Add a feature"],
        ),
        "bob committing",
    );

    // Bob pushes to refs/patches (the magic ref).
    let output = git(
        &bob_clone,
        &bob_key,
        ssh_port,
        &["push", "origin", "HEAD:refs/patches"],
    );
    assert_ok(&output, "bob pushing to refs/patches");

    // The client output should carry the patch proposal link.
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("/patches/1"),
        "stderr should mention the patch proposal URL, got:\n{stderr}"
    );

    // Check the database: a draft PP row should exist.
    let db = Database::connect(server.database_url())
        .await
        .expect("connecting to test database");

    let repository = frogue::entities::repositories::Entity::find()
        .filter(frogue::entities::repositories::Column::Slug.eq("test-repo"))
        .one(&db)
        .await
        .expect("querying repository")
        .expect("repository should exist");

    let patch = frogue::entities::patch_proposals::Entity::find()
        .filter(frogue::entities::patch_proposals::Column::RepositoryId.eq(repository.id))
        .filter(frogue::entities::patch_proposals::Column::Number.eq(1))
        .one(&db)
        .await
        .expect("querying patch proposal")
        .expect("patch proposal should exist");

    assert_eq!(
        patch.status,
        frogue::entities::patch_proposals::Status::Draft
    );
    assert_eq!(patch.summary, "Add a feature");
    assert_eq!(patch.source_ref, "patches/1");
    assert_eq!(patch.target_branch, "main");
    assert_eq!(patch.creator_id, Some(2)); // Bob's user ID

    Ok(())
}

/// Test 2: Stranger pushing to `main` is refused (security tripwire).
#[tokio::test]
async fn stranger_push_to_main_is_refused() -> miette::Result<()> {
    skip_without_tooling!();

    let (server, port) = spawn();
    let ssh_port = server.ssh_port();
    let scratch = Scratch::new("pp-stranger-main");

    let alice_key = keygen(&scratch, "alice_key");
    let bob_key = keygen(&scratch, "bob_key");

    let mut alice = Client::new(port);
    register_and_login(&mut alice, "alice", "alice@example.org");
    add_key(&mut alice, "alice", &scratch.path("alice_key.pub"));
    create_repository(&mut alice, "alice", "test-repo", "public");

    // Alice initializes the repo.
    let alice_clone = scratch.path("alice-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &alice_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                alice_clone.to_str().unwrap(),
            ],
        ),
        "alice cloning",
    );
    fs::write(alice_clone.join("README.md"), "# Notes\n").expect("writing README");
    assert_ok(
        &git(&alice_clone, &alice_key, ssh_port, &["add", "README.md"]),
        "staging",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["commit", "-m", "Initial commit"],
        ),
        "committing",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["push", "origin", "main"],
        ),
        "alice pushing main",
    );

    // Bob tries to push to main (should be refused).
    let mut bob = Client::new(port);
    register_and_login(&mut bob, "bob", "bob@example.org");
    add_key(&mut bob, "bob", &scratch.path("bob_key.pub"));

    let bob_clone = scratch.path("bob-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &bob_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                bob_clone.to_str().unwrap(),
            ],
        ),
        "bob cloning",
    );
    fs::write(bob_clone.join("intrusion.txt"), "hello\n").expect("writing file");
    assert_ok(
        &git(&bob_clone, &bob_key, ssh_port, &["add", "intrusion.txt"]),
        "bob staging",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["commit", "-m", "Unsolicited"],
        ),
        "bob committing",
    );

    // Bob pushes to main (should fail).
    let output = git(&bob_clone, &bob_key, ssh_port, &["push", "origin", "main"]);
    assert!(
        !output.status.success(),
        "bob should not be able to push to main\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr),
    );

    Ok(())
}

/// Test 3: Second stranger push gets !2, independent of !1.
#[tokio::test]
async fn second_stranger_push_gets_independent_number() -> miette::Result<()> {
    skip_without_tooling!();

    let (server, port) = spawn();
    let ssh_port = server.ssh_port();
    let scratch = Scratch::new("pp-second-push");

    let alice_key = keygen(&scratch, "alice_key");
    let bob_key = keygen(&scratch, "bob_key");
    let charlie_key = keygen(&scratch, "charlie_key");

    let mut alice = Client::new(port);
    register_and_login(&mut alice, "alice", "alice@example.org");
    add_key(&mut alice, "alice", &scratch.path("alice_key.pub"));
    create_repository(&mut alice, "alice", "test-repo", "public");

    // Alice initializes.
    let alice_clone = scratch.path("alice-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &alice_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                alice_clone.to_str().unwrap(),
            ],
        ),
        "alice cloning",
    );
    fs::write(alice_clone.join("README.md"), "# Notes\n").expect("writing README");
    assert_ok(
        &git(&alice_clone, &alice_key, ssh_port, &["add", "README.md"]),
        "staging",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["commit", "-m", "Initial commit"],
        ),
        "committing",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["push", "origin", "main"],
        ),
        "alice pushing main",
    );

    // Bob pushes !1.
    let mut bob = Client::new(port);
    register_and_login(&mut bob, "bob", "bob@example.org");
    add_key(&mut bob, "bob", &scratch.path("bob_key.pub"));

    let bob_clone = scratch.path("bob-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &bob_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                bob_clone.to_str().unwrap(),
            ],
        ),
        "bob cloning",
    );
    fs::write(bob_clone.join("feature1.txt"), "feature 1\n").expect("writing feature1");
    assert_ok(
        &git(&bob_clone, &bob_key, ssh_port, &["add", "feature1.txt"]),
        "bob staging",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["commit", "-m", "Feature 1"],
        ),
        "bob committing",
    );
    let output = git(
        &bob_clone,
        &bob_key,
        ssh_port,
        &["push", "origin", "HEAD:refs/patches"],
    );
    assert_ok(&output, "bob pushing !1");
    assert!(
        String::from_utf8_lossy(&output.stderr).contains("/patches/1"),
        "bob should get !1"
    );

    // Charlie pushes !2.
    let mut charlie = Client::new(port);
    register_and_login(&mut charlie, "charlie", "charlie@example.org");
    add_key(&mut charlie, "charlie", &scratch.path("charlie_key.pub"));

    let charlie_clone = scratch.path("charlie-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &charlie_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                charlie_clone.to_str().unwrap(),
            ],
        ),
        "charlie cloning",
    );
    fs::write(charlie_clone.join("feature2.txt"), "feature 2\n").expect("writing feature2");
    assert_ok(
        &git(
            &charlie_clone,
            &charlie_key,
            ssh_port,
            &["add", "feature2.txt"],
        ),
        "charlie staging",
    );
    assert_ok(
        &git(
            &charlie_clone,
            &charlie_key,
            ssh_port,
            &["commit", "-m", "Feature 2"],
        ),
        "charlie committing",
    );
    let output = git(
        &charlie_clone,
        &charlie_key,
        ssh_port,
        &["push", "origin", "HEAD:refs/patches"],
    );
    assert_ok(&output, "charlie pushing !2");
    assert!(
        String::from_utf8_lossy(&output.stderr).contains("/patches/2"),
        "charlie should get !2"
    );

    // Check database: two patches, numbers 1 and 2.
    let db = Database::connect(server.database_url())
        .await
        .expect("connecting to test database");

    let repository = frogue::entities::repositories::Entity::find()
        .filter(frogue::entities::repositories::Column::Slug.eq("test-repo"))
        .one(&db)
        .await
        .expect("querying repository")
        .expect("repository should exist");

    let patches = frogue::entities::patch_proposals::Entity::find()
        .filter(frogue::entities::patch_proposals::Column::RepositoryId.eq(repository.id))
        .all(&db)
        .await
        .expect("querying patches");

    assert_eq!(patches.len(), 2, "there should be two patches");
    assert!(
        patches
            .iter()
            .any(|p| p.number == 1 && p.creator_id == Some(2))
    ); // Bob
    assert!(
        patches
            .iter()
            .any(|p| p.number == 2 && p.creator_id == Some(3))
    ); // Charlie

    Ok(())
}

/// Test 4a: Creator can fast-forward update their patch, stranger cannot
#[tokio::test]
async fn creator_can_fastforward_update_patch_stranger_cannot() -> miette::Result<()> {
    skip_without_tooling!();

    let (server, port) = spawn();
    let ssh_port = server.ssh_port();
    let scratch = Scratch::new("pp-update-ff");

    let alice_key = keygen(&scratch, "alice_key");
    let bob_key = keygen(&scratch, "bob_key");
    let charlie_key = keygen(&scratch, "charlie_key");

    let mut alice = Client::new(port);
    register_and_login(&mut alice, "alice", "alice@example.org");
    add_key(&mut alice, "alice", &scratch.path("alice_key.pub"));
    create_repository(&mut alice, "alice", "test-repo", "public");

    // Alice initializes
    let alice_clone = scratch.path("alice-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &alice_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                alice_clone.to_str().unwrap(),
            ],
        ),
        "alice cloning",
    );
    fs::write(alice_clone.join("README.md"), "# Notes\n").expect("writing README");
    assert_ok(
        &git(&alice_clone, &alice_key, ssh_port, &["add", "README.md"]),
        "staging",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["commit", "-m", "Initial commit"],
        ),
        "committing",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["push", "origin", "main"],
        ),
        "alice pushing main",
    );

    // Bob creates !1
    let mut bob = Client::new(port);
    register_and_login(&mut bob, "bob", "bob@example.org");
    add_key(&mut bob, "bob", &scratch.path("bob_key.pub"));

    let bob_clone = scratch.path("bob-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &bob_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                bob_clone.to_str().unwrap(),
            ],
        ),
        "bob cloning",
    );
    fs::write(bob_clone.join("feature.txt"), "v1\n").expect("writing feature");
    assert_ok(
        &git(&bob_clone, &bob_key, ssh_port, &["add", "feature.txt"]),
        "bob staging",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["commit", "-m", "Feature v1"],
        ),
        "bob committing",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["push", "origin", "HEAD:refs/patches"],
        ),
        "bob pushing !1",
    );

    // Bob fast-forwards !1 with a new commit
    fs::write(bob_clone.join("feature.txt"), "v2\n").expect("writing feature v2");
    assert_ok(
        &git(&bob_clone, &bob_key, ssh_port, &["add", "feature.txt"]),
        "bob staging v2",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["commit", "-m", "Feature v2"],
        ),
        "bob committing v2",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["push", "origin", "HEAD:refs/patches/1"],
        ),
        "bob fast-forward updating !1",
    );

    // Charlie tries to update !1 (should fail)
    let mut charlie = Client::new(port);
    register_and_login(&mut charlie, "charlie", "charlie@example.org");
    add_key(&mut charlie, "charlie", &scratch.path("charlie_key.pub"));

    let charlie_clone = scratch.path("charlie-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &charlie_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                charlie_clone.to_str().unwrap(),
            ],
        ),
        "charlie cloning",
    );
    fs::write(charlie_clone.join("intrusion.txt"), "hacker\n").expect("writing intrusion");
    assert_ok(
        &git(
            &charlie_clone,
            &charlie_key,
            ssh_port,
            &["add", "intrusion.txt"],
        ),
        "charlie staging",
    );
    assert_ok(
        &git(
            &charlie_clone,
            &charlie_key,
            ssh_port,
            &["commit", "-m", "Charlie's version"],
        ),
        "charlie committing",
    );
    let output = git(
        &charlie_clone,
        &charlie_key,
        ssh_port,
        &["push", "origin", "HEAD:refs/patches/1"],
    );
    assert!(
        !output.status.success(),
        "charlie should not be able to update bob's patch\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr),
    );

    // Check database: still only 1 patch, number 1, creator is bob
    let db = Database::connect(server.database_url())
        .await
        .expect("connecting to test database");

    let repository = frogue::entities::repositories::Entity::find()
        .filter(frogue::entities::repositories::Column::Slug.eq("test-repo"))
        .one(&db)
        .await
        .expect("querying repository")
        .expect("repository should exist");

    let patches = frogue::entities::patch_proposals::Entity::find()
        .filter(frogue::entities::patch_proposals::Column::RepositoryId.eq(repository.id))
        .all(&db)
        .await
        .expect("querying patches");

    assert_eq!(patches.len(), 1, "there should still be only one patch");
    assert_eq!(patches[0].number, 1);
    assert_eq!(patches[0].creator_id, Some(2)); // Bob

    Ok(())
}

/// Test 4b: Creator can force-push to update their patch, owner can force-push
#[tokio::test]
async fn creator_and_owner_can_force_push_patch() -> miette::Result<()> {
    skip_without_tooling!();

    let (server, port) = spawn();
    let ssh_port = server.ssh_port();
    let scratch = Scratch::new("pp-update-force");

    let alice_key = keygen(&scratch, "alice_key");
    let bob_key = keygen(&scratch, "bob_key");

    let mut alice = Client::new(port);
    register_and_login(&mut alice, "alice", "alice@example.org");
    add_key(&mut alice, "alice", &scratch.path("alice_key.pub"));
    create_repository(&mut alice, "alice", "test-repo", "public");

    // Alice initializes
    let alice_clone = scratch.path("alice-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &alice_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                alice_clone.to_str().unwrap(),
            ],
        ),
        "alice cloning",
    );
    fs::write(alice_clone.join("README.md"), "# Notes\n").expect("writing README");
    assert_ok(
        &git(&alice_clone, &alice_key, ssh_port, &["add", "README.md"]),
        "staging",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["commit", "-m", "Initial commit"],
        ),
        "committing",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["push", "origin", "main"],
        ),
        "alice pushing main",
    );

    // Bob creates !1
    let mut bob = Client::new(port);
    register_and_login(&mut bob, "bob", "bob@example.org");
    add_key(&mut bob, "bob", &scratch.path("bob_key.pub"));

    let bob_clone = scratch.path("bob-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &bob_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                bob_clone.to_str().unwrap(),
            ],
        ),
        "bob cloning",
    );
    fs::write(bob_clone.join("feature.txt"), "v1\n").expect("writing feature");
    assert_ok(
        &git(&bob_clone, &bob_key, ssh_port, &["add", "feature.txt"]),
        "bob staging",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["commit", "-m", "Feature v1"],
        ),
        "bob committing",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["push", "origin", "HEAD:refs/patches"],
        ),
        "bob pushing !1",
    );

    // Bob amends his commit and force-pushes (non-fast-forward)
    fs::write(bob_clone.join("feature.txt"), "v1 amended\n").expect("amending feature");
    assert_ok(
        &git(&bob_clone, &bob_key, ssh_port, &["add", "feature.txt"]),
        "bob staging amend",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["commit", "--amend", "-m", "Feature v1 amended"],
        ),
        "bob amending commit",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["push", "--force", "origin", "HEAD:refs/patches/1"],
        ),
        "bob force-updating !1",
    );

    // Alice (owner) also makes independent changes and force-pushes
    fs::write(alice_clone.join("owner-update.txt"), "owner was here\n")
        .expect("writing owner update");
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["add", "owner-update.txt"],
        ),
        "alice staging",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["commit", "-m", "Owner update"],
        ),
        "alice committing",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["push", "--force", "origin", "HEAD:refs/patches/1"],
        ),
        "alice force-updating !1",
    );

    // Check database: still only 1 patch, number 1, creator is bob
    let db = Database::connect(server.database_url())
        .await
        .expect("connecting to test database");

    let repository = frogue::entities::repositories::Entity::find()
        .filter(frogue::entities::repositories::Column::Slug.eq("test-repo"))
        .one(&db)
        .await
        .expect("querying repository")
        .expect("repository should exist");

    let patches = frogue::entities::patch_proposals::Entity::find()
        .filter(frogue::entities::patch_proposals::Column::RepositoryId.eq(repository.id))
        .all(&db)
        .await
        .expect("querying patches");

    assert_eq!(patches.len(), 1, "there should still be only one patch");
    assert_eq!(patches[0].number, 1);
    assert_eq!(patches[0].creator_id, Some(2)); // Bob

    Ok(())
}

/// Test 5: Creator deletes refs/patches/1 → PP closed. Stranger deletes → refused.
#[tokio::test]
async fn creator_can_delete_patch_stranger_cannot() -> miette::Result<()> {
    skip_without_tooling!();

    let (server, port) = spawn();
    let ssh_port = server.ssh_port();
    let scratch = Scratch::new("pp-delete");

    let alice_key = keygen(&scratch, "alice_key");
    let bob_key = keygen(&scratch, "bob_key");
    let charlie_key = keygen(&scratch, "charlie_key");

    let mut alice = Client::new(port);
    register_and_login(&mut alice, "alice", "alice@example.org");
    add_key(&mut alice, "alice", &scratch.path("alice_key.pub"));
    create_repository(&mut alice, "alice", "test-repo", "public");

    // Alice initializes
    let alice_clone = scratch.path("alice-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &alice_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                alice_clone.to_str().unwrap(),
            ],
        ),
        "alice cloning",
    );
    fs::write(alice_clone.join("README.md"), "# Notes\n").expect("writing README");
    assert_ok(
        &git(&alice_clone, &alice_key, ssh_port, &["add", "README.md"]),
        "staging",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["commit", "-m", "Initial commit"],
        ),
        "committing",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["push", "origin", "main"],
        ),
        "alice pushing main",
    );

    // Bob creates !1
    let mut bob = Client::new(port);
    register_and_login(&mut bob, "bob", "bob@example.org");
    add_key(&mut bob, "bob", &scratch.path("bob_key.pub"));

    let bob_clone = scratch.path("bob-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &bob_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                bob_clone.to_str().unwrap(),
            ],
        ),
        "bob cloning",
    );
    fs::write(bob_clone.join("feature.txt"), "feature\n").expect("writing feature");
    assert_ok(
        &git(&bob_clone, &bob_key, ssh_port, &["add", "feature.txt"]),
        "bob staging",
    );
    assert_ok(
        &git(&bob_clone, &bob_key, ssh_port, &["commit", "-m", "Feature"]),
        "bob committing",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["push", "origin", "HEAD:refs/patches"],
        ),
        "bob pushing !1",
    );

    // Charlie tries to delete !1 (should fail)
    let mut charlie = Client::new(port);
    register_and_login(&mut charlie, "charlie", "charlie@example.org");
    add_key(&mut charlie, "charlie", &scratch.path("charlie_key.pub"));

    let charlie_clone = scratch.path("charlie-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &charlie_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                charlie_clone.to_str().unwrap(),
            ],
        ),
        "charlie cloning",
    );
    let output = git(
        &charlie_clone,
        &charlie_key,
        ssh_port,
        &["push", "origin", ":refs/patches/1"],
    );
    assert!(
        !output.status.success(),
        "charlie should not be able to delete bob's patch\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr),
    );

    // Bob deletes !1 (should succeed)
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["push", "origin", ":refs/patches/1"],
        ),
        "bob deleting !1",
    );

    // Check database: patch 1 should be closed
    let db = Database::connect(server.database_url())
        .await
        .expect("connecting to test database");

    let repository = frogue::entities::repositories::Entity::find()
        .filter(frogue::entities::repositories::Column::Slug.eq("test-repo"))
        .one(&db)
        .await
        .expect("querying repository")
        .expect("repository should exist");

    let patch = frogue::entities::patch_proposals::Entity::find()
        .filter(frogue::entities::patch_proposals::Column::RepositoryId.eq(repository.id))
        .filter(frogue::entities::patch_proposals::Column::Number.eq(1))
        .one(&db)
        .await
        .expect("querying patch")
        .expect("patch should exist");

    assert_eq!(
        patch.status,
        frogue::entities::patch_proposals::Status::Closed
    );

    Ok(())
}

/// Test 6: Owner pushes branches/tags normally → unchanged behaviour, no PP.
#[tokio::test]
async fn owner_can_push_branches_normally() -> miette::Result<()> {
    skip_without_tooling!();

    let (server, port) = spawn();
    let ssh_port = server.ssh_port();
    let scratch = Scratch::new("pp-owner-branch");

    let alice_key = keygen(&scratch, "alice_key");

    let mut alice = Client::new(port);
    register_and_login(&mut alice, "alice", "alice@example.org");
    add_key(&mut alice, "alice", &scratch.path("alice_key.pub"));
    create_repository(&mut alice, "alice", "test-repo", "public");

    // Alice initializes and pushes to main
    let alice_clone = scratch.path("alice-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &alice_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                alice_clone.to_str().unwrap(),
            ],
        ),
        "alice cloning",
    );
    fs::write(alice_clone.join("README.md"), "# Notes\n").expect("writing README");
    assert_ok(
        &git(&alice_clone, &alice_key, ssh_port, &["add", "README.md"]),
        "staging",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["commit", "-m", "Initial commit"],
        ),
        "committing",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["push", "origin", "main"],
        ),
        "alice pushing main",
    );

    // Alice pushes a new branch
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["checkout", "-b", "feature"],
        ),
        "alice creating branch",
    );
    fs::write(alice_clone.join("feature.txt"), "feature\n").expect("writing feature");
    assert_ok(
        &git(&alice_clone, &alice_key, ssh_port, &["add", "feature.txt"]),
        "staging",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["commit", "-m", "Add feature"],
        ),
        "committing",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["push", "origin", "feature"],
        ),
        "alice pushing feature branch",
    );

    // Alice pushes a tag
    assert_ok(
        &git(&alice_clone, &alice_key, ssh_port, &["tag", "v1.0"]),
        "alice creating tag",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["push", "origin", "v1.0"],
        ),
        "alice pushing tag",
    );

    // Check database: no patches should exist
    let db = Database::connect(server.database_url())
        .await
        .expect("connecting to test database");

    let repository = frogue::entities::repositories::Entity::find()
        .filter(frogue::entities::repositories::Column::Slug.eq("test-repo"))
        .one(&db)
        .await
        .expect("querying repository")
        .expect("repository should exist");

    let patches = frogue::entities::patch_proposals::Entity::find()
        .filter(frogue::entities::patch_proposals::Column::RepositoryId.eq(repository.id))
        .all(&db)
        .await
        .expect("querying patches");

    assert_eq!(patches.len(), 0, "there should be no patches");

    Ok(())
}

/// Test 7: Private repo, stranger → refused at connection level (existing gate).
#[tokio::test]
async fn stranger_cannot_push_to_private_repo() -> miette::Result<()> {
    skip_without_tooling!();

    let (_server, port) = spawn();
    let ssh_port = port + 1000; // Different port for SSH
    let scratch = Scratch::new("pp-private");

    let _alice_key = keygen(&scratch, "alice_key");
    let bob_key = keygen(&scratch, "bob_key");

    let mut alice = Client::new(port);
    register_and_login(&mut alice, "alice", "alice@example.org");
    add_key(&mut alice, "alice", &scratch.path("alice_key.pub"));
    create_repository(&mut alice, "alice", "secret", "private");

    // Bob tries to clone (should fail)
    let mut bob = Client::new(port);
    register_and_login(&mut bob, "bob", "bob@example.org");
    add_key(&mut bob, "bob", &scratch.path("bob_key.pub"));

    let bob_clone = scratch.path("bob-clone");
    let output = git(
        &scratch.dir,
        &bob_key,
        ssh_port,
        &[
            "clone",
            &url(ssh_port, "alice", "secret"),
            bob_clone.to_str().unwrap(),
        ],
    );
    assert!(
        !output.status.success(),
        "bob should not be able to clone alice's private repo\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr),
    );

    Ok(())
}

/// Test 8: Mixed push (branch + patch ref) by a stranger → whole push refused by pre-receive.
#[tokio::test]
async fn stranger_cannot_mix_branch_and_patch_push() -> miette::Result<()> {
    skip_without_tooling!();

    let (server, port) = spawn();
    let ssh_port = server.ssh_port();
    let scratch = Scratch::new("pp-mixed");

    let alice_key = keygen(&scratch, "alice_key");
    let bob_key = keygen(&scratch, "bob_key");

    let mut alice = Client::new(port);
    register_and_login(&mut alice, "alice", "alice@example.org");
    add_key(&mut alice, "alice", &scratch.path("alice_key.pub"));
    create_repository(&mut alice, "alice", "test-repo", "public");

    // Alice initializes
    let alice_clone = scratch.path("alice-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &alice_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                alice_clone.to_str().unwrap(),
            ],
        ),
        "alice cloning",
    );
    fs::write(alice_clone.join("README.md"), "# Notes\n").expect("writing README");
    assert_ok(
        &git(&alice_clone, &alice_key, ssh_port, &["add", "README.md"]),
        "staging",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["commit", "-m", "Initial commit"],
        ),
        "committing",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["push", "origin", "main"],
        ),
        "alice pushing main",
    );

    // Bob tries to push both a branch and a patch (should fail)
    let mut bob = Client::new(port);
    register_and_login(&mut bob, "bob", "bob@example.org");
    add_key(&mut bob, "bob", &scratch.path("bob_key.pub"));

    let bob_clone = scratch.path("bob-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &bob_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                bob_clone.to_str().unwrap(),
            ],
        ),
        "bob cloning",
    );
    fs::write(bob_clone.join("feature.txt"), "feature\n").expect("writing feature");
    assert_ok(
        &git(&bob_clone, &bob_key, ssh_port, &["add", "feature.txt"]),
        "bob staging",
    );
    assert_ok(
        &git(&bob_clone, &bob_key, ssh_port, &["commit", "-m", "Feature"]),
        "bob committing",
    );

    let output = git(
        &bob_clone,
        &bob_key,
        ssh_port,
        &[
            "push",
            "origin",
            "HEAD:refs/heads/feature",
            "HEAD:refs/patches",
        ],
    );
    assert!(
        !output.status.success(),
        "bob should not be able to push both branch and patch\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr),
    );

    Ok(())
}

/// Test that the owner can merge a patch proposal via the web UI.
/// This tests the complete merge flow including git operations.
#[tokio::test]
async fn owner_can_merge_patch_proposal() -> miette::Result<()> {
    skip_without_tooling!();

    let (server, port) = spawn();
    let ssh_port = server.ssh_port();
    let scratch = Scratch::new("pp-merge");

    let alice_key = keygen(&scratch, "alice_key");
    let bob_key = keygen(&scratch, "bob_key");

    // Alice creates a public repository and pushes initial commit
    let mut alice = Client::new(port);
    register_and_login(&mut alice, "alice", "alice@example.org");
    add_key(&mut alice, "alice", &scratch.path("alice_key.pub"));
    create_repository(&mut alice, "alice", "test-repo", "public");

    let alice_clone = scratch.path("alice-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &alice_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                alice_clone.to_str().unwrap(),
            ],
        ),
        "alice cloning",
    );
    fs::write(alice_clone.join("README.md"), "# Notes\n").expect("writing README");
    assert_ok(
        &git(&alice_clone, &alice_key, ssh_port, &["add", "README.md"]),
        "staging",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["commit", "-m", "Initial commit"],
        ),
        "committing",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["push", "origin", "main"],
        ),
        "alice pushing main",
    );

    // Bob creates a patch proposal
    let mut bob = Client::new(port);
    register_and_login(&mut bob, "bob", "bob@example.org");
    add_key(&mut bob, "bob", &scratch.path("bob_key.pub"));

    let bob_clone = scratch.path("bob-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &bob_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                bob_clone.to_str().unwrap(),
            ],
        ),
        "bob cloning",
    );
    fs::write(bob_clone.join("feature.txt"), "new feature\n").expect("writing feature");
    assert_ok(
        &git(&bob_clone, &bob_key, ssh_port, &["add", "feature.txt"]),
        "bob staging",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["commit", "-m", "Add a feature"],
        ),
        "bob committing",
    );

    let output = git(
        &bob_clone,
        &bob_key,
        ssh_port,
        &["push", "origin", "HEAD:refs/patches"],
    );
    assert_ok(&output, "bob pushing to refs/patches");

    // Check that the patch proposal was created
    let db = Database::connect(server.database_url())
        .await
        .expect("connecting to test database");

    let repository = frogue::entities::repositories::Entity::find()
        .filter(frogue::entities::repositories::Column::Slug.eq("test-repo"))
        .one(&db)
        .await
        .expect("querying repository")
        .expect("repository should exist");

    let patch = frogue::entities::patch_proposals::Entity::find()
        .filter(frogue::entities::patch_proposals::Column::RepositoryId.eq(repository.id))
        .one(&db)
        .await
        .expect("querying patch")
        .expect("patch should exist");

    assert_eq!(patch.number, 1);
    assert_eq!(
        patch.status,
        frogue::entities::patch_proposals::Status::Draft
    );

    // Alice (owner) merges the patch via the web UI
    let response = alice.post_form(&format!("/~alice/test-repo/patches/1/merge"), &[]);
    assert_eq!(response.status, 303, "merge should redirect");

    // Verify the patch status changed to merged
    let patch_after = frogue::entities::patch_proposals::Entity::find()
        .filter(frogue::entities::patch_proposals::Column::Id.eq(patch.id))
        .one(&db)
        .await
        .expect("querying patch after merge")
        .expect("patch should still exist");

    assert_eq!(
        patch_after.status,
        frogue::entities::patch_proposals::Status::Merged
    );

    // Verify the main branch was updated
    let bob_clone_after = scratch.path("bob-clone-after");
    assert_ok(
        &git(
            &scratch.dir,
            &bob_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                bob_clone_after.to_str().unwrap(),
            ],
        ),
        "bob cloning after merge",
    );

    assert!(
        bob_clone_after.join("feature.txt").exists(),
        "feature.txt should exist after merge"
    );

    let content =
        fs::read_to_string(bob_clone_after.join("feature.txt")).expect("reading feature.txt");
    assert_eq!(
        content, "new feature\n",
        "feature.txt should have the new content"
    );

    Ok(())
}

/// Test 9: Pushing a patch with no differences from target branch is refused.
#[tokio::test]
async fn patch_with_no_differences_from_target_is_refused() -> miette::Result<()> {
    skip_without_tooling!();

    let (server, port) = spawn();
    let ssh_port = server.ssh_port();
    let scratch = Scratch::new("pp-no-diff");

    let alice_key = keygen(&scratch, "alice_key");
    let bob_key = keygen(&scratch, "bob_key");

    // Alice creates a repository and pushes initial commit to main
    let mut alice = Client::new(port);
    register_and_login(&mut alice, "alice", "alice@example.org");
    add_key(&mut alice, "alice", &scratch.path("alice_key.pub"));
    create_repository(&mut alice, "alice", "test-repo", "public");

    let alice_clone = scratch.path("alice-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &alice_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                alice_clone.to_str().unwrap(),
            ],
        ),
        "alice cloning",
    );
    fs::write(alice_clone.join("README.md"), "# Notes\n").expect("writing README");
    assert_ok(
        &git(&alice_clone, &alice_key, ssh_port, &["add", "README.md"]),
        "staging",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["commit", "-m", "Initial commit"],
        ),
        "committing",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["push", "origin", "main"],
        ),
        "alice pushing main",
    );

    // Bob clones and tries to push the same commit as a patch (no differences)
    let mut bob = Client::new(port);
    register_and_login(&mut bob, "bob", "bob@example.org");
    add_key(&mut bob, "bob", &scratch.path("bob_key.pub"));

    let bob_clone = scratch.path("bob-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &bob_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                bob_clone.to_str().unwrap(),
            ],
        ),
        "bob cloning",
    );

    // Bob tries to push HEAD (which is the same as main) as a patch
    let output = git(
        &bob_clone,
        &bob_key,
        ssh_port,
        &["push", "origin", "HEAD:refs/patches"],
    );

    // The push should fail
    assert!(
        !output.status.success(),
        "pushing a patch with no differences should be refused\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr),
    );

    // The error message should mention "no differences"
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("no differences") || stderr.contains("no differences from target branch"),
        "error message should mention no differences, got:\n{stderr}"
    );

    // Verify no patch was created in the database
    let db = Database::connect(server.database_url())
        .await
        .expect("connecting to test database");

    let repository = frogue::entities::repositories::Entity::find()
        .filter(frogue::entities::repositories::Column::Slug.eq("test-repo"))
        .one(&db)
        .await
        .expect("querying repository")
        .expect("repository should exist");

    let patches = frogue::entities::patch_proposals::Entity::find()
        .filter(frogue::entities::patch_proposals::Column::RepositoryId.eq(repository.id))
        .all(&db)
        .await
        .expect("querying patches");

    assert_eq!(
        patches.len(),
        0,
        "no patch should be created when there are no differences"
    );

    Ok(())
}

/// Creates a commit with a jj change-id header using git plumbing.
///
/// This helper builds a commit object by hand so we can embed the `change-id`
/// header the way jj does, then moves the branch ref to it. The commit's tree
/// is taken from the current working directory.
fn commit_with_change_id(
    dir: &Path,
    key: &Path,
    port: u16,
    change_id: &str,
    message: &str,
) -> String {
    // Get the current tree from HEAD
    let output = git(dir, key, port, &["rev-parse", "HEAD^{tree}"]);
    assert_ok(&output, "getting tree");
    let tree = String::from_utf8_lossy(&output.stdout).trim().to_string();

    // Get the parent commit
    let output = git(dir, key, port, &["rev-parse", "HEAD"]);
    assert_ok(&output, "getting parent");
    let parent = String::from_utf8_lossy(&output.stdout).trim().to_string();

    // Build the commit object with the change-id header
    let commit_text = format!(
        "tree {}\nparent {}\nauthor Test <test@example.com> 1234567890 +0000\ncommitter Test <test@example.com> 1234567890 +0000\nchange-id {}\n\n{}\n",
        tree, parent, change_id, message
    );

    // Write the commit object using git hash-object
    let mut child = Command::new("git")
        .current_dir(dir)
        .args(&["hash-object", "-t", "commit", "-w", "--stdin"])
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .spawn()
        .expect("git hash-object should run");

    use std::io::Write;
    child
        .stdin
        .as_mut()
        .expect("stdin should be available")
        .write_all(commit_text.as_bytes())
        .expect("writing to stdin should work");

    let output = child.wait_with_output().expect("waiting should work");
    assert!(
        output.status.success(),
        "git hash-object should succeed\nstderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let commit_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();

    // Update the branch ref to point to the new commit
    let output = git(dir, key, port, &["update-ref", "HEAD", &commit_hash]);
    assert_ok(&output, "updating branch ref");

    commit_hash
}

/// Test 1: Pushing a commit with a change-id to refs/patches creates a PP with that change-id.
#[tokio::test]
async fn push_with_change_id_creates_pp_with_change_id() -> miette::Result<()> {
    skip_without_tooling!();

    let (server, port) = spawn();
    let ssh_port = server.ssh_port();
    let scratch = Scratch::new("pp-change-id-create");

    let alice_key = keygen(&scratch, "alice_key");
    let bob_key = keygen(&scratch, "bob_key");

    let mut alice = Client::new(port);
    register_and_login(&mut alice, "alice", "alice@example.org");
    add_key(&mut alice, "alice", &scratch.path("alice_key.pub"));
    create_repository(&mut alice, "alice", "test-repo", "public");

    let alice_clone = scratch.path("alice-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &alice_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                alice_clone.to_str().unwrap(),
            ],
        ),
        "alice cloning",
    );
    fs::write(alice_clone.join("README.md"), "# Notes\n").expect("writing README");
    assert_ok(
        &git(&alice_clone, &alice_key, ssh_port, &["add", "README.md"]),
        "staging",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["commit", "-m", "Initial commit"],
        ),
        "committing",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["push", "origin", "main"],
        ),
        "alice pushing main",
    );

    let mut bob = Client::new(port);
    register_and_login(&mut bob, "bob", "bob@example.org");
    add_key(&mut bob, "bob", &scratch.path("bob_key.pub"));

    let bob_clone = scratch.path("bob-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &bob_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                bob_clone.to_str().unwrap(),
            ],
        ),
        "bob cloning",
    );
    fs::write(bob_clone.join("feature.txt"), "feature\n").expect("writing feature");
    assert_ok(
        &git(&bob_clone, &bob_key, ssh_port, &["add", "feature.txt"]),
        "bob staging",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["commit", "-m", "Add feature"],
        ),
        "bob committing",
    );

    // Create a commit with a jj change-id
    let change_id = "mwyosuvoswmkponquzmrkrtkyqqwsorp";
    commit_with_change_id(
        &bob_clone,
        &bob_key,
        ssh_port,
        change_id,
        "Add feature with change-id",
    );

    // Push to refs/patches
    let output = git(
        &bob_clone,
        &bob_key,
        ssh_port,
        &["push", "origin", "HEAD:refs/patches"],
    );
    assert_ok(&output, "bob pushing to refs/patches");

    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("/patches/1"),
        "stderr should mention patch proposal, got:\n{stderr}"
    );

    // Check the database
    let db = Database::connect(server.database_url())
        .await
        .expect("connecting to test database");

    let repository = frogue::entities::repositories::Entity::find()
        .filter(frogue::entities::repositories::Column::Slug.eq("test-repo"))
        .one(&db)
        .await
        .expect("querying repository")
        .expect("repository should exist");

    let patch = frogue::entities::patch_proposals::Entity::find()
        .filter(frogue::entities::patch_proposals::Column::RepositoryId.eq(repository.id))
        .one(&db)
        .await
        .expect("querying patch")
        .expect("patch should exist");

    assert_eq!(patch.number, 1);
    assert_eq!(patch.change_id, Some(change_id.to_string()));

    Ok(())
}

/// Test 2: Amending with the same change-id updates the existing PP instead of creating a new one.
#[tokio::test]
async fn amend_with_same_change_id_updates_existing_pp() -> miette::Result<()> {
    skip_without_tooling!();

    let (server, port) = spawn();
    let ssh_port = server.ssh_port();
    let scratch = Scratch::new("pp-change-id-amend");

    let alice_key = keygen(&scratch, "alice_key");
    let bob_key = keygen(&scratch, "bob_key");

    let mut alice = Client::new(port);
    register_and_login(&mut alice, "alice", "alice@example.org");
    add_key(&mut alice, "alice", &scratch.path("alice_key.pub"));
    create_repository(&mut alice, "alice", "test-repo", "public");

    let alice_clone = scratch.path("alice-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &alice_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                alice_clone.to_str().unwrap(),
            ],
        ),
        "alice cloning",
    );
    fs::write(alice_clone.join("README.md"), "# Notes\n").expect("writing README");
    assert_ok(
        &git(&alice_clone, &alice_key, ssh_port, &["add", "README.md"]),
        "staging",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["commit", "-m", "Initial commit"],
        ),
        "committing",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["push", "origin", "main"],
        ),
        "alice pushing main",
    );

    let mut bob = Client::new(port);
    register_and_login(&mut bob, "bob", "bob@example.org");
    add_key(&mut bob, "bob", &scratch.path("bob_key.pub"));

    let bob_clone = scratch.path("bob-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &bob_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                bob_clone.to_str().unwrap(),
            ],
        ),
        "bob cloning",
    );
    fs::write(bob_clone.join("feature.txt"), "v1\n").expect("writing feature v1");
    assert_ok(
        &git(&bob_clone, &bob_key, ssh_port, &["add", "feature.txt"]),
        "bob staging",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["commit", "-m", "Add feature v1"],
        ),
        "bob committing v1",
    );

    // Create first commit with change-id X
    let change_id = "mwyosuvoswmkponquzmrkrtkyqqwsorp";
    commit_with_change_id(&bob_clone, &bob_key, ssh_port, change_id, "Add feature v1");

    let output = git(
        &bob_clone,
        &bob_key,
        ssh_port,
        &["push", "origin", "HEAD:refs/patches"],
    );
    assert_ok(&output, "bob pushing !1");
    assert!(
        String::from_utf8_lossy(&output.stderr).contains("/patches/1"),
        "should create !1"
    );

    // Amend: change the file but keep the same change-id
    fs::write(bob_clone.join("feature.txt"), "v2 amended\n").expect("amending feature");
    assert_ok(
        &git(&bob_clone, &bob_key, ssh_port, &["add", "feature.txt"]),
        "bob staging amend",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["commit", "-m", "Add feature v2"],
        ),
        "bob committing amend",
    );

    // Create amended commit with the SAME change-id
    commit_with_change_id(&bob_clone, &bob_key, ssh_port, change_id, "Add feature v2");

    let output = git(
        &bob_clone,
        &bob_key,
        ssh_port,
        &["push", "origin", "HEAD:refs/patches"],
    );
    assert_ok(&output, "bob pushing amended version");

    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("updated") && stderr.contains("/patches/1"),
        "should update !1, not create !2, got:\n{stderr}"
    );

    // Check database: still only one patch
    let db = Database::connect(server.database_url())
        .await
        .expect("connecting to test database");

    let repository = frogue::entities::repositories::Entity::find()
        .filter(frogue::entities::repositories::Column::Slug.eq("test-repo"))
        .one(&db)
        .await
        .expect("querying repository")
        .expect("repository should exist");

    let patches = frogue::entities::patch_proposals::Entity::find()
        .filter(frogue::entities::patch_proposals::Column::RepositoryId.eq(repository.id))
        .all(&db)
        .await
        .expect("querying patches");

    assert_eq!(patches.len(), 1, "should still have only one patch");
    assert_eq!(patches[0].change_id, Some(change_id.to_string()));

    Ok(())
}

/// Test 3: Pushing with a different change-id creates an independent PP.
#[tokio::test]
async fn different_change_id_creates_independent_pp() -> miette::Result<()> {
    skip_without_tooling!();

    let (server, port) = spawn();
    let ssh_port = server.ssh_port();
    let scratch = Scratch::new("pp-change-id-different");

    let alice_key = keygen(&scratch, "alice_key");
    let bob_key = keygen(&scratch, "bob_key");

    let mut alice = Client::new(port);
    register_and_login(&mut alice, "alice", "alice@example.org");
    add_key(&mut alice, "alice", &scratch.path("alice_key.pub"));
    create_repository(&mut alice, "alice", "test-repo", "public");

    let alice_clone = scratch.path("alice-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &alice_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                alice_clone.to_str().unwrap(),
            ],
        ),
        "alice cloning",
    );
    fs::write(alice_clone.join("README.md"), "# Notes\n").expect("writing README");
    assert_ok(
        &git(&alice_clone, &alice_key, ssh_port, &["add", "README.md"]),
        "staging",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["commit", "-m", "Initial commit"],
        ),
        "committing",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["push", "origin", "main"],
        ),
        "alice pushing main",
    );

    let mut bob = Client::new(port);
    register_and_login(&mut bob, "bob", "bob@example.org");
    add_key(&mut bob, "bob", &scratch.path("bob_key.pub"));

    let bob_clone = scratch.path("bob-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &bob_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                bob_clone.to_str().unwrap(),
            ],
        ),
        "bob cloning",
    );

    // First commit with change-id X
    fs::write(bob_clone.join("feature1.txt"), "feature1\n").expect("writing feature1");
    assert_ok(
        &git(&bob_clone, &bob_key, ssh_port, &["add", "feature1.txt"]),
        "bob staging feature1",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["commit", "-m", "Add feature1"],
        ),
        "bob committing feature1",
    );

    let change_id_x = "mwyosuvoswmkponquzmrkrtkyqqwsorp";
    commit_with_change_id(&bob_clone, &bob_key, ssh_port, change_id_x, "Add feature1");

    let output = git(
        &bob_clone,
        &bob_key,
        ssh_port,
        &["push", "origin", "HEAD:refs/patches"],
    );
    assert_ok(&output, "bob pushing !1");

    // Second commit with different change-id Y
    fs::write(bob_clone.join("feature2.txt"), "feature2\n").expect("writing feature2");
    assert_ok(
        &git(&bob_clone, &bob_key, ssh_port, &["add", "feature2.txt"]),
        "bob staging feature2",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["commit", "-m", "Add feature2"],
        ),
        "bob committing feature2",
    );

    let change_id_y = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz";
    commit_with_change_id(&bob_clone, &bob_key, ssh_port, change_id_y, "Add feature2");

    let output = git(
        &bob_clone,
        &bob_key,
        ssh_port,
        &["push", "origin", "HEAD:refs/patches"],
    );
    assert_ok(&output, "bob pushing !2");

    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("/patches/2"),
        "should create !2, got:\n{stderr}"
    );

    // Check database: two patches with different change-ids
    let db = Database::connect(server.database_url())
        .await
        .expect("connecting to test database");

    let repository = frogue::entities::repositories::Entity::find()
        .filter(frogue::entities::repositories::Column::Slug.eq("test-repo"))
        .one(&db)
        .await
        .expect("querying repository")
        .expect("repository should exist");

    let patches = frogue::entities::patch_proposals::Entity::find()
        .filter(frogue::entities::patch_proposals::Column::RepositoryId.eq(repository.id))
        .all(&db)
        .await
        .expect("querying patches");

    assert_eq!(patches.len(), 2, "should have two patches");
    assert_eq!(patches[0].number, 1);
    assert_eq!(patches[0].change_id, Some(change_id_x.to_string()));
    assert_eq!(patches[1].number, 2);
    assert_eq!(patches[1].change_id, Some(change_id_y.to_string()));

    Ok(())
}

/// Test 4: Pushing a commit with a mismatched change-id to refs/patches/{n} is refused.
#[tokio::test]
async fn push_mismatched_change_id_to_numbered_ref_is_refused() -> miette::Result<()> {
    skip_without_tooling!();

    let (server, port) = spawn();
    let ssh_port = server.ssh_port();
    let scratch = Scratch::new("pp-change-id-mismatch");

    let alice_key = keygen(&scratch, "alice_key");
    let bob_key = keygen(&scratch, "bob_key");

    let mut alice = Client::new(port);
    register_and_login(&mut alice, "alice", "alice@example.org");
    add_key(&mut alice, "alice", &scratch.path("alice_key.pub"));
    create_repository(&mut alice, "alice", "test-repo", "public");

    let alice_clone = scratch.path("alice-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &alice_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                alice_clone.to_str().unwrap(),
            ],
        ),
        "alice cloning",
    );
    fs::write(alice_clone.join("README.md"), "# Notes\n").expect("writing README");
    assert_ok(
        &git(&alice_clone, &alice_key, ssh_port, &["add", "README.md"]),
        "staging",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["commit", "-m", "Initial commit"],
        ),
        "committing",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["push", "origin", "main"],
        ),
        "alice pushing main",
    );

    let mut bob = Client::new(port);
    register_and_login(&mut bob, "bob", "bob@example.org");
    add_key(&mut bob, "bob", &scratch.path("bob_key.pub"));

    let bob_clone = scratch.path("bob-clone");
    assert_ok(
        &git(
            &scratch.dir,
            &bob_key,
            ssh_port,
            &[
                "clone",
                &url(ssh_port, "alice", "test-repo"),
                bob_clone.to_str().unwrap(),
            ],
        ),
        "bob cloning",
    );
    fs::write(bob_clone.join("feature.txt"), "v1\n").expect("writing feature");
    assert_ok(
        &git(&bob_clone, &bob_key, ssh_port, &["add", "feature.txt"]),
        "bob staging",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["commit", "-m", "Add feature"],
        ),
        "bob committing",
    );

    // Create !1 with change-id X
    let change_id_x = "mwyosuvoswmkponquzmrkrtkyqqwsorp";
    commit_with_change_id(&bob_clone, &bob_key, ssh_port, change_id_x, "Add feature");

    let output = git(
        &bob_clone,
        &bob_key,
        ssh_port,
        &["push", "origin", "HEAD:refs/patches"],
    );
    assert_ok(&output, "bob pushing !1");

    // Try to push a commit with a DIFFERENT change-id Y to refs/patches/1
    fs::write(bob_clone.join("feature.txt"), "v2\n").expect("writing feature v2");
    assert_ok(
        &git(&bob_clone, &bob_key, ssh_port, &["add", "feature.txt"]),
        "bob staging v2",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["commit", "-m", "Add feature v2"],
        ),
        "bob committing v2",
    );

    let change_id_y = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz";
    commit_with_change_id(
        &bob_clone,
        &bob_key,
        ssh_port,
        change_id_y,
        "Add feature v2",
    );

    let output = git(
        &bob_clone,
        &bob_key,
        ssh_port,
        &["push", "--force", "origin", "HEAD:refs/patches/1"],
    );

    // Should be refused
    assert!(
        !output.status.success(),
        "push with mismatched change-id should be refused\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("change-id mismatch") || stderr.contains("mismatch"),
        "error should mention change-id mismatch, got:\n{stderr}"
    );

    Ok(())
}
