🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! Git hook implementation for patch proposals
//!
//! This module implements the pre-receive and proc-receive hooks that intercept
//! pushes to refs/patches and create/update patch proposals.

use std::env;
use std::io::{self, BufRead, Write};
use std::process::Command;

use sea_orm::{ActiveModelTrait, ColumnTrait, Database, EntityTrait, Set};

use crate::entities::patch_proposals::MergePolicy;
use crate::entities::repositories::{self, MergePolicy as RepoMergePolicy};
use crate::protocol::pktline;
use crate::{
    DATABASE_URL_VAR_KEY, DEFAULT_DATABASE_URL, DEFAULT_REPO_ROOT, FROGUE_PUBLIC_URL_VAR_KEY,
    patches,
};

/// The git hook modes
pub enum HookMode {
    PreReceive,
    ProcReceive,
    PostReceive,
}

impl HookMode {
    pub fn from_arg(arg: &str) -> Option<Self> {
        match arg {
            "pre-receive" => Some(HookMode::PreReceive),
            "proc-receive" => Some(HookMode::ProcReceive),
            "post-receive" => Some(HookMode::PostReceive),
            _ => None,
        }
    }
}

/// A push command from git
struct PushCommand {
    old_oid: String,
    new_oid: String,
    ref_name: String,
}

/// Runs the git hook with the given mode
pub async fn run_hook(mode: HookMode) -> Result<(), Box<dyn std::error::Error>> {
    let mode_name = match mode {
        HookMode::PreReceive => "pre-receive",
        HookMode::ProcReceive => "proc-receive",
        HookMode::PostReceive => "post-receive",
    };

    if let Err(e) = run_hook_inner(mode).await {
        eprintln!("{} hook error: {}", mode_name, e);
        return Err(e);
    }
    Ok(())
}

async fn run_hook_inner(mode: HookMode) -> Result<(), Box<dyn std::error::Error>> {
    let user_id = env::var("FROGUE_PUSH_USER_ID")
        .map_err(|_| "FROGUE_PUSH_USER_ID not set")?
        .parse::<i64>()
        .map_err(|_| "FROGUE_PUSH_USER_ID not a valid integer")?;

    let repository_id = env::var("FROGUE_REPOSITORY_ID")
        .map_err(|_| "FROGUE_REPOSITORY_ID not set")?
        .parse::<i64>()
        .map_err(|_| "FROGUE_REPOSITORY_ID not a valid integer")?;

    let database_url = env::var(DATABASE_URL_VAR_KEY).unwrap_or(DEFAULT_DATABASE_URL.to_string());

    let db = Database::connect(&database_url).await?;

    match mode {
        HookMode::PreReceive => run_pre_receive(&db, repository_id, user_id).await,
        HookMode::ProcReceive => run_proc_receive(&db, repository_id, user_id).await,
        HookMode::PostReceive => run_post_receive(&db, repository_id, user_id).await,
    }
}

/// Runs the pre-receive hook
///
/// This hook checks authorization for each push command. The repository owner
/// can push anything. Other users can only push to refs/patches/*.
async fn run_pre_receive(
    db: &sea_orm::DatabaseConnection,
    repository_id: i64,
    user_id: i64,
) -> Result<(), Box<dyn std::error::Error>> {
    let repository = repositories::Entity::find_by_id(repository_id)
        .one(db)
        .await?
        .ok_or("repository not found")?;

    let is_owner = repository.owner_id == user_id;

    let stdin = io::stdin();
    let stdin_lock = stdin.lock();

    for line in stdin_lock.lines() {
        let line = line?;
        let parts: Vec<&str> = line.split_whitespace().collect();

        if parts.len() != 3 {
            return Err("invalid push command format".into());
        }

        let _old_oid = parts[0];
        let _new_oid = parts[1];
        let ref_name = parts[2];

        // Owner can push anything
        if is_owner {
            continue;
        }

        // Non-owners can only push to refs/patches*
        if !patches::is_patches_ref(ref_name) {
            eprintln!("error: only the repository owner may push to {}", ref_name);
            std::process::exit(1);
        }
    }

    Ok(())
}

/// Runs the proc-receive hook
///
/// This hook handles pushes to refs/patches* by creating or updating patch
/// proposals. It communicates with git using the pkt-line protocol.
async fn run_proc_receive(
    db: &sea_orm::DatabaseConnection,
    repository_id: i64,
    user_id: i64,
) -> Result<(), Box<dyn std::error::Error>> {
    let repository = repositories::Entity::find_by_id(repository_id)
        .one(db)
        .await?
        .ok_or("repository not found")?;

    let public_url = env::var(FROGUE_PUBLIC_URL_VAR_KEY).unwrap_or_default();

    let stdin = io::stdin();
    let stdout = io::stdout();
    let mut stdin_lock = stdin.lock();
    let mut stdout_lock = stdout.lock();

    // Read version packet from git
    let version_packet = pktline::decode(&mut stdin_lock)?.ok_or("expected version packet")?;

    let version_line = String::from_utf8(
        version_packet
            .data()
            .ok_or("expected data packet")?
            .to_vec(),
    )?;
    if !version_line.starts_with("version=1") {
        return Err(format!(
            "unsupported proc-receive protocol version: {}",
            version_line
        )
        .into());
    }

    // The client advertises its capabilities on the version line. We only read
    // a push-options section later if the client actually said it sent push
    // options; otherwise git skips the section entirely and would leave us
    // waiting forever for packets that aren't there.
    let client_sent_push_options = version_line.contains("push-options");

    // Read the flush packet that ends git's capability advertisement
    let flush_packet = pktline::decode(&mut stdin_lock)?.ok_or("expected flush packet from git")?;
    if !flush_packet.is_flush() {
        return Err(format!("expected flush packet, got: {:?}", flush_packet).into());
    }

    // Send our capabilities
    pktline::encode(b"version=1 push-options\n", &mut stdout_lock)?;
    pktline::encode_flush(&mut stdout_lock)?;
    stdout_lock.flush()?;

    // Read push commands until flush packet
    let mut commands = Vec::new();
    loop {
        let packet = pktline::decode(&mut stdin_lock)?.ok_or("unexpected end of input")?;

        if packet.is_flush() {
            break;
        }

        let line = String::from_utf8(packet.data().ok_or("expected data packet")?.to_vec())?;
        let line = line.trim();

        if line.is_empty() {
            continue;
        }

        let parts: Vec<&str> = line.split_whitespace().collect();
        if parts.len() != 3 {
            return Err(format!("invalid push command format: {}", line).into());
        }

        commands.push(PushCommand {
            old_oid: parts[0].to_string(),
            new_oid: parts[1].to_string(),
            ref_name: parts[2].to_string(),
        });
    }

    // Read push options if the client actually sent them. Git only includes
    // this section if the client used `-o` options; otherwise it expects
    // responses immediately after the commands flush.
    let mut push_options = Vec::new();
    if client_sent_push_options {
        loop {
            let packet = pktline::decode(&mut stdin_lock)?.ok_or("unexpected end of input")?;

            if packet.is_flush() {
                break;
            }

            let line = String::from_utf8(packet.data().ok_or("expected data packet")?.to_vec())?;
            let line = line.trim();

            if line.is_empty() {
                continue;
            }

            // Push options are sent as plain lines (no "push-option " prefix)
            push_options.push(line.to_string());
        }
    }

    // Process each command
    for cmd in &commands {
        let result = process_push_command(
            db,
            repository_id,
            user_id,
            &repository,
            &public_url,
            cmd,
            &push_options,
        )
        .await;

        match result {
            Ok(message) => {
                // Send ok response for the original ref from the command
                pktline::encode(
                    format!("ok {}\n", cmd.ref_name).as_bytes(),
                    &mut stdout_lock,
                )?;

                // If we created a new ref (refs/patches/N), tell git about it
                if cmd.ref_name == "refs/patches"
                    && let Some(actual_ref) = extract_ref_from_message(&message)
                {
                    pktline::encode(
                        format!("option refname {}\n", actual_ref).as_bytes(),
                        &mut stdout_lock,
                    )?;
                }

                // Send old and new OIDs
                pktline::encode(
                    format!("option old-oid {}\n", cmd.old_oid).as_bytes(),
                    &mut stdout_lock,
                )?;
                pktline::encode(
                    format!("option new-oid {}\n", cmd.new_oid).as_bytes(),
                    &mut stdout_lock,
                )?;

                stdout_lock.flush()?;

                // Print the message to stderr so the user sees it
                if !message.is_empty() {
                    eprintln!("{}", message);
                }
            }
            Err(e) => {
                pktline::encode(
                    format!("ng {} {}\n", cmd.ref_name, e).as_bytes(),
                    &mut stdout_lock,
                )?;
                stdout_lock.flush()?;
            }
        }
    }

    // Send final flush packet
    pktline::encode_flush(&mut stdout_lock)?;
    stdout_lock.flush()?;

    Ok(())
}

/// Processes a single push command
async fn process_push_command(
    db: &sea_orm::DatabaseConnection,
    repository_id: i64,
    user_id: i64,
    repository: &repositories::Model,
    public_url: &str,
    cmd: &PushCommand,
    push_options: &[String],
) -> Result<String, String> {
    const ZERO_OID: &str = "0000000000000000000000000000000000000000";

    // Parse push options
    let mut opt_summary: Option<String> = None;
    let mut opt_description: Option<String> = None;
    let mut opt_target: Option<String> = None;
    let mut opt_ready = false;

    for option in push_options {
        if let Some((key, value)) = option.split_once('=') {
            match key {
                "summary" => opt_summary = Some(value.to_string()),
                "description" => opt_description = Some(value.to_string()),
                "target" => opt_target = Some(value.to_string()),
                _ => {} // Ignore unknown options
            }
        } else if option == "ready" {
            opt_ready = true;
        }
    }

    if cmd.ref_name == "refs/patches" {
        // Creating a new patch proposal OR updating an existing one by change-id
        if cmd.new_oid == ZERO_OID {
            return Err("cannot delete refs/patches".to_string());
        }

        // Determine the target branch
        let target_branch = opt_target.as_deref().unwrap_or(&repository.default_branch);

        // Check if there are any differences between the patch and target branch
        if !has_differences(&cmd.new_oid, target_branch)
            .await
            .map_err(|e| format!("failed to check differences: {}", e))?
        {
            return Err(format!(
                "patch has no differences from target branch '{}'",
                target_branch
            ));
        }

        // Extract change-id from the head commit, if present
        let change_id =
            patches::change_id::parse_change_id_sync(std::path::Path::new("."), &cmd.new_oid);

        // Look for an existing open PP with this change-id
        let existing_pp = if let Some(ref cid) = change_id {
            patches::store::find_open_by_change_id(db, repository_id, cid)
                .await
                .map_err(|e| format!("failed to lookup patch by change-id: {}", e))?
        } else {
            None
        };

        let (pp, is_update) = if let Some(existing) = existing_pp {
            // Update the existing PP: move its ref
            tracing::info!(
                repository_id,
                pp_number = existing.number,
                ?change_id,
                "routing push to refs/patches as update via change-id"
            );
            update_ref(&format!("refs/patches/{}", existing.number), &cmd.new_oid)
                .await
                .map_err(|e| format!("failed to update ref: {}", e))?;
            (existing, true)
        } else {
            // Create a new PP
            let summary = if let Some(s) = opt_summary {
                s
            } else {
                get_commit_subject(&cmd.new_oid)
                    .await
                    .unwrap_or_else(|_| "New patch proposal".to_string())
            };

            let merge_policy = repo_merge_policy_to_pp(repository.default_merge_policy);
            let pp = patches::store::create_draft(
                db,
                repository_id,
                user_id,
                &summary,
                opt_description.as_deref(),
                opt_target.as_deref(),
                merge_policy,
                change_id.as_deref(),
            )
            .await
            .map_err(|e| format!("failed to create patch proposal: {}", e))?;

            update_ref(&format!("refs/patches/{}", pp.number), &cmd.new_oid)
                .await
                .map_err(|e| format!("failed to update ref: {}", e))?;

            (pp, false)
        };

        // If ready option was provided and we just created, open the patch proposal
        let pp = if opt_ready
            && !is_update
            && pp.status == crate::entities::patch_proposals::Status::Draft
        {
            let opened_pp = patches::store::update(
                db,
                pp.id,
                None,
                None,
                None,
                Some(crate::entities::patch_proposals::Status::Open),
                None,
            )
            .await
            .map_err(|e| format!("failed to open patch proposal: {}", e))?;

            // Emit Offer+Accept for federation if public_url is set
            if !public_url.is_empty()
                && let Err(e) = emit_patch_open_activity(
                    db,
                    repository_id,
                    user_id,
                    repository,
                    &opened_pp,
                    public_url,
                )
                .await
            {
                tracing::error!(%e, "failed to emit patch open activity");
            }

            opened_pp
        } else {
            pp
        };

        // Emit Add activity if this was an update by change-id
        if is_update
            && !public_url.is_empty()
            && let Err(e) =
                emit_patch_update_activity(db, repository_id, repository, &pp, public_url).await
        {
            tracing::error!(%e, "failed to emit patch update activity");
        }

        // Build the message to show the user
        let owner_name = get_user_name(db, repository.owner_id)
            .await
            .unwrap_or_else(|_| "unknown".to_string());
        let slug = &repository.slug;

        let message = if is_update {
            if public_url.is_empty() {
                format!(
                    "\nPatch proposal updated: /~{}/patches/{}",
                    owner_name, pp.number
                )
            } else {
                format!(
                    "\nPatch proposal updated: {}/~{}/{}/patches/{}",
                    public_url, owner_name, slug, pp.number
                )
            }
        } else if public_url.is_empty() {
            format!(
                "\nPatch proposal created: /~{}/patches/{}",
                owner_name, pp.number
            )
        } else {
            format!(
                "\nPatch proposal created: {}/~{}/{}/patches/{}",
                public_url, owner_name, slug, pp.number
            )
        };

        Ok(message)
    } else if patches::is_patches_ref(&cmd.ref_name) {
        // Updating or deleting an existing patch proposal
        let number =
            patches::patch_number_from_ref(&cmd.ref_name).ok_or("invalid patch proposal ref")?;

        let pp = patches::store::find_by_number(db, repository_id, number)
            .await
            .map_err(|e| format!("failed to find patch proposal: {}", e))?
            .ok_or("patch proposal not found")?;

        // Check authorization
        if !patches::may_revise(repository.owner_id, pp.creator_id, user_id) {
            return Err("you may not modify this patch proposal".to_string());
        }

        if cmd.new_oid == ZERO_OID {
            // Deleting the patch proposal
            patches::store::close(db, pp.id, None, None)
                .await
                .map_err(|e| format!("failed to close patch proposal: {}", e))?;

            // Delete the ref
            delete_ref(&cmd.ref_name)
                .await
                .map_err(|e| format!("failed to delete ref: {}", e))?;

            Ok(String::new())
        } else {
            // Updating the patch proposal: check change-id consistency
            let pushed_change_id =
                patches::change_id::parse_change_id_sync(std::path::Path::new("."), &cmd.new_oid);

            match (&pp.change_id, &pushed_change_id) {
                // PP has no recorded change-id — adopt the pushed commit's (if any)
                (None, Some(pushed)) => {
                    tracing::info!(
                        repository_id,
                        pp_number = pp.number,
                        change_id = %pushed,
                        "adopting change-id on existing patch proposal"
                    );
                    patches::store::set_change_id(db, pp.id, Some(pushed))
                        .await
                        .map_err(|e| format!("failed to adopt change-id: {}", e))?;
                }
                // Pushed commit has no change-id (plain git) — always allowed
                (_, None) => {}
                // Both present and equal — allowed
                (Some(existing), Some(pushed)) if existing == pushed => {}
                // Both present and different — refuse
                (Some(existing), Some(pushed)) => {
                    tracing::info!(
                        repository_id,
                        pp_number = pp.number,
                        existing_change_id = %existing,
                        pushed_change_id = %pushed,
                        "refusing push: change-id mismatch"
                    );
                    return Err(format!(
                        "change-id mismatch: patch !{} carries change-id `{}`, \
                         but the pushed commit has `{}`. A genuinely new change \
                         belongs in a new patch proposal.",
                        pp.number, existing, pushed
                    ));
                }
            }

            update_ref(&cmd.ref_name, &cmd.new_oid)
                .await
                .map_err(|e| format!("failed to update ref: {}", e))?;

            Ok(String::new())
        }
    } else {
        Err(format!("unsupported ref: {}", cmd.ref_name))
    }
}

/// Runs the post-receive hook
///
/// This hook emits ForgeFed Push activities for branch updates. It reads
/// ref updates from stdin (one per line: old_sha new_sha refname) and
/// creates Push activities for branch ref updates.
async fn run_post_receive(
    db: &sea_orm::DatabaseConnection,
    repository_id: i64,
    user_id: i64,
) -> Result<(), Box<dyn std::error::Error>> {
    use crate::activitypub::ApUrls;
    use crate::activitypub::types;
    use crate::entities::prelude::{ApFollowers, ApRemoteActors};
    use crate::entities::{ap_followers, ap_remote_actors, users};
    use chrono::Utc;
    use sea_orm::QueryFilter;
    use std::collections::HashSet;
    use uuid::Uuid;

    // Get the repository
    let repository = repositories::Entity::find_by_id(repository_id)
        .one(db)
        .await?
        .ok_or("repository not found")?;

    // Get the public URL for federation
    let public_url = env::var(FROGUE_PUBLIC_URL_VAR_KEY).unwrap_or_default();
    if public_url.is_empty() {
        // Federation disabled, nothing to do
        return Ok(());
    }

    // Get the pusher's information
    let pusher = users::Entity::find_by_id(user_id)
        .one(db)
        .await?
        .ok_or("user not found")?;

    // Get the repository owner (not the pusher - they might differ)
    let owner = users::Entity::find_by_id(repository.owner_id)
        .one(db)
        .await?
        .ok_or("repository owner not found")?;

    let urls = ApUrls::new(&public_url);
    let pusher_url = urls.actor(&pusher.name);

    // Get or create the repository actor
    let repo_actor = crate::activitypub::actor::get_or_create_for_repo(db, repository_id).await?;
    let repo_actor_url = urls.repo_actor(&owner.name, &repository.slug);

    // Read ref updates from stdin
    let stdin = io::stdin();
    let stdin_lock = stdin.lock();

    for line in stdin_lock.lines() {
        let line = line?;
        let parts: Vec<&str> = line.split_whitespace().collect();

        if parts.len() != 3 {
            continue; // Skip malformed lines
        }

        let old_sha = parts[0];
        let new_sha = parts[1];
        let ref_name = parts[2];

        // Only process branch updates
        if !ref_name.starts_with("refs/heads/") {
            continue;
        }

        // Skip branch deletions (new_sha is all zeros)
        const ZERO_SHA: &str = "0000000000000000000000000000000000000000";
        if new_sha == ZERO_SHA {
            continue;
        }

        // Get commits between old and new (newest first)
        let commits = if old_sha == ZERO_SHA {
            // New branch - get all commits up to new_sha
            get_commits_up_to(new_sha, 100).await?
        } else {
            // Existing branch - get commits between old and new
            get_commits_between(old_sha, new_sha, 100).await?
        };

        if commits.is_empty() {
            continue; // No commits to report
        }

        // Build Commit documents using the typed struct
        // Commits are already in newest-first order from git log
        let commit_docs: Vec<types::Commit> = commits
            .into_iter()
            .map(|commit| types::Commit {
                context: types::FORGEFED_CONTEXT.to_vec(),
                id: format!("{}/commits/{}", repo_actor_url, commit.full_hash),
                kind: "Commit",
                attributed_to: None, // No email matching implemented yet
                hash: commit.full_hash.clone(),
                created: commit.date.clone(),
                summary: commit.subject.clone(),
            })
            .collect();

        // Build the Push activity using the typed struct
        let activity_uuid = Uuid::new_v4();
        let activity_url = urls.repo_activity(&owner.name, &repository.slug, activity_uuid);

        let push_activity = types::Push {
            context: types::FORGEFED_CONTEXT.to_vec(),
            id: activity_url,
            kind: "Push",
            actor: repo_actor_url.clone(),
            attributed_to: pusher_url.clone(),
            target: repo_actor_url.clone(),
            hash_before: if old_sha == ZERO_SHA {
                None
            } else {
                Some(old_sha.to_string())
            },
            hash_after: new_sha.to_string(),
            object: types::PushCommitCollection {
                kind: "OrderedCollection",
                total_items: commit_docs.len() as u64,
                ordered_items: commit_docs,
            },
            published: Utc::now().to_rfc3339(),
            to: vec![
                urls.repo_followers(&owner.name, &repository.slug),
                "https://www.w3.org/ns/activitystreams#Public".to_string(),
            ],
        };

        let activity_json = serde_json::to_string(&push_activity)?;

        // Insert the activity
        let activity_model = crate::entities::ap_activities::ActiveModel {
            uuid: Set(activity_uuid),
            actor_id: Set(repo_actor.id),
            activity_json: Set(activity_json),
            object_url: Set(repo_actor_url.clone()),
            ..Default::default()
        };

        let activity = activity_model.insert(db).await?;

        // Get followers and fan out
        let followers: Vec<(ap_followers::Model, Option<ap_remote_actors::Model>)> =
            ApFollowers::find()
                .filter(ap_followers::Column::LocalActorId.eq(repo_actor.id))
                .find_also_related(ApRemoteActors)
                .all(db)
                .await?;

        // Deduplicate by inbox URL (prefer shared inbox)
        let mut seen_inboxes = HashSet::new();
        for (_, remote) in followers {
            if let Some(remote) = remote {
                let inbox_url = remote
                    .shared_inbox_url
                    .as_ref()
                    .unwrap_or(&remote.inbox_url)
                    .clone();

                if seen_inboxes.contains(&inbox_url) {
                    continue;
                }
                seen_inboxes.insert(inbox_url.clone());

                // Create delivery row
                let delivery_model = crate::entities::ap_deliveries::ActiveModel {
                    activity_id: Set(activity.id),
                    signer_actor_id: Set(repo_actor.id),
                    inbox_url: Set(inbox_url),
                    status: Set("pending".to_string()),
                    attempts: Set(0),
                    next_attempt_at: Set(Utc::now().into()),
                    last_error: Set(None),
                    ..Default::default()
                };

                delivery_model.insert(db).await?;
            }
        }
    }

    Ok(())
}

/// Get commits between two refs (newest first), limited to max_count
async fn get_commits_between(
    old_sha: &str,
    new_sha: &str,
    max_count: usize,
) -> Result<Vec<crate::repositories::git::Commit>, Box<dyn std::error::Error>> {
    use std::process::Command;

    let output = Command::new("git")
        .args([
            "log",
            "--format=%H%x00%an%x00%aI%x00%s",
            &format!("-n{}", max_count),
            &format!("{}..{}", old_sha, new_sha),
        ])
        .output()?;

    if !output.status.success() {
        return Ok(Vec::new());
    }

    let output_str = String::from_utf8_lossy(&output.stdout);
    let commits: Vec<crate::repositories::git::Commit> = output_str
        .lines()
        .filter(|line| !line.is_empty())
        .take(max_count)
        .filter_map(|line| {
            let parts: Vec<&str> = line.split('\0').collect();
            if parts.len() >= 4 {
                Some(crate::repositories::git::Commit {
                    hash: parts[0][..7].to_string(),
                    full_hash: parts[0].to_string(),
                    author: parts[1].to_string(),
                    date: parts[2].to_string(),
                    subject: parts[3].to_string(),
                    files_changed: 0,
                    insertions: 0,
                    deletions: 0,
                })
            } else {
                None
            }
        })
        .collect();

    Ok(commits)
}

/// Get all commits up to a ref (newest first), limited to max_count
async fn get_commits_up_to(
    ref_name: &str,
    max_count: usize,
) -> Result<Vec<crate::repositories::git::Commit>, Box<dyn std::error::Error>> {
    use std::process::Command;

    let output = Command::new("git")
        .args([
            "log",
            "--format=%H%x00%an%x00%aI%x00%s",
            "-n",
            &max_count.to_string(),
            ref_name,
        ])
        .output()?;

    if !output.status.success() {
        return Ok(Vec::new());
    }

    let output_str = String::from_utf8_lossy(&output.stdout);
    let commits: Vec<crate::repositories::git::Commit> = output_str
        .lines()
        .filter(|line| !line.is_empty())
        .filter_map(|line| {
            let parts: Vec<&str> = line.split('\0').collect();
            if parts.len() >= 4 {
                Some(crate::repositories::git::Commit {
                    hash: parts[0][..7].to_string(),
                    full_hash: parts[0].to_string(),
                    author: parts[1].to_string(),
                    date: parts[2].to_string(),
                    subject: parts[3].to_string(),
                    files_changed: 0,
                    insertions: 0,
                    deletions: 0,
                })
            } else {
                None
            }
        })
        .collect();

    Ok(commits)
}

/// Gets the commit subject for the given OID
async fn get_commit_subject(oid: &str) -> Result<String, Box<dyn std::error::Error>> {
    let output = Command::new("git")
        .args(["log", "-1", "--format=%s", oid])
        .output()?;

    if !output.status.success() {
        return Err("git log failed".into());
    }

    let subject = String::from_utf8_lossy(&output.stdout).trim().to_string();
    Ok(subject)
}

/// Updates a ref to point to the given OID
async fn update_ref(ref_name: &str, oid: &str) -> Result<(), Box<dyn std::error::Error>> {
    let output = Command::new("git")
        .args(["update-ref", ref_name, oid])
        .output()?;

    if !output.status.success() {
        return Err("git update-ref failed".into());
    }

    Ok(())
}

/// Deletes a ref
async fn delete_ref(ref_name: &str) -> Result<(), Box<dyn std::error::Error>> {
    let output = Command::new("git")
        .args(["update-ref", "-d", ref_name])
        .output()?;

    if !output.status.success() {
        return Err("git update-ref failed".into());
    }

    Ok(())
}

/// Gets a user's name by ID
async fn get_user_name(
    db: &sea_orm::DatabaseConnection,
    user_id: i64,
) -> Result<String, Box<dyn std::error::Error>> {
    use crate::entities::users;

    let user = users::Entity::find_by_id(user_id)
        .one(db)
        .await?
        .ok_or("user not found")?;

    Ok(user.name)
}

/// Extracts the ref name from a message
fn extract_ref_from_message(message: &str) -> Option<String> {
    // Look for "/patches/N" in the message
    for line in message.lines() {
        if let Some(start) = line.find("/patches/") {
            let rest = &line[start + 1..]; // skip the leading /
            let end = rest
                .find(|c: char| !c.is_alphanumeric() && c != '/')
                .unwrap_or(rest.len());
            let path = &rest[..end];
            return Some(format!("refs/{}", path));
        }
    }
    None
}

/// Converts a repository merge policy to a patch proposal merge policy
fn repo_merge_policy_to_pp(policy: RepoMergePolicy) -> MergePolicy {
    match policy {
        RepoMergePolicy::PreferFastForward => MergePolicy::PreferFastForward,
        RepoMergePolicy::FastForwardOnly => MergePolicy::FastForwardOnly,
        RepoMergePolicy::MergeCommitOnly => MergePolicy::MergeCommitOnly,
        RepoMergePolicy::RebaseThenFastForward => MergePolicy::RebaseThenFastForward,
    }
}

/// Checks if there are differences between two commits
async fn has_differences(commit1: &str, commit2: &str) -> Result<bool, Box<dyn std::error::Error>> {
    let output = Command::new("git")
        .args(["diff", "--quiet", commit1, commit2])
        .output()?;

    // git diff --quiet returns 0 if no differences, 1 if there are differences
    Ok(output.status.code() == Some(1))
}

/// Emits Offer+Accept activities when a patch is opened via push with ready option
async fn emit_patch_open_activity(
    db: &sea_orm::DatabaseConnection,
    repository_id: i64,
    _user_id: i64,
    repository: &repositories::Model,
    patch: &crate::entities::patch_proposals::Model,
    public_url: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    use crate::activitypub::ApUrls;
    use crate::entities::users;
    use uuid::Uuid;

    let urls = ApUrls::new(public_url);

    // Get the repository owner
    let owner = users::Entity::find_by_id(repository.owner_id)
        .one(db)
        .await?
        .ok_or("repository owner not found")?;

    // Get the repository actor
    let repo_actor = crate::activitypub::actor::get_or_create_for_repo(db, repository_id).await?;
    let repo_actor_url = urls.repo_actor(&owner.name, &repository.slug);

    // Get the patch creator
    let creator_id = patch.creator_id.ok_or("patch has no creator")?;
    let creator = users::Entity::find_by_id(creator_id)
        .one(db)
        .await?
        .ok_or("patch creator not found")?;

    let user_actor = crate::activitypub::actor::get_or_create(db, creator_id).await?;
    let user_actor_url = urls.actor(&creator.name);
    let offer_id = urls.activity(&creator.name, Uuid::new_v4());

    // Build the patch URL
    let patch_url = format!(
        "{}/~{}/{}/patches/{}",
        public_url, owner.name, repository.slug, patch.number
    );

    // Build the bare repo path
    let repo_path = crate::repositories::RepositoryPath {
        owner: owner.name.clone(),
        slug: repository.slug.clone(),
    };
    pub const FROGUE_REPO_ROOT_VAR_KEY: &str = "FROGUE_REPO_ROOT";
    let bare_path = repo_path.bare_path(std::path::Path::new(
        &std::env::var(FROGUE_REPO_ROOT_VAR_KEY).unwrap_or_else(|_| DEFAULT_REPO_ROOT.to_string()),
    ));

    // Build patch document
    let patch_doc = crate::activitypub::forge::builders::build_patch_document(
        patch,
        repository,
        &owner.name,
        &bare_path,
        &creator,
        &urls,
    )
    .await?;

    // Build Offer activity
    let offer = crate::activitypub::types::Offer {
        context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
        id: offer_id.clone(),
        kind: "Offer",
        actor: user_actor_url.clone(),
        object: serde_json::to_value(&patch_doc)?,
        target: repo_actor_url.clone(),
        to: vec![repo_actor_url.clone()],
        cc: Some(vec![urls.repo_followers(&owner.name, &repository.slug)]),
        published: chrono::Utc::now().to_rfc3339(),
    };

    let offer_json = serde_json::to_string(&offer)?;

    // Publish Offer activity (stores in user's activities)
    crate::activitypub::forge::activity::publish_activity(
        db,
        user_actor.id,
        &offer_json,
        &patch_url,
    )
    .await?;

    // Announce Accept from repo actor to followers
    crate::activitypub::forge::activity::announce_accept(
        db,
        repo_actor.id,
        &repo_actor_url,
        &offer_id,
        &patch_url,
        None,
    )
    .await?;

    Ok(())
}

/// Emits Add activity when a patch is updated by change-id
async fn emit_patch_update_activity(
    db: &sea_orm::DatabaseConnection,
    repository_id: i64,
    repository: &repositories::Model,
    patch: &crate::entities::patch_proposals::Model,
    public_url: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    use crate::activitypub::ApUrls;
    use crate::entities::users;
    use uuid::Uuid;

    let urls = ApUrls::new(public_url);

    // Get the repository owner
    let owner = users::Entity::find_by_id(repository.owner_id)
        .one(db)
        .await?
        .ok_or("repository owner not found")?;

    // Get the repository actor
    let repo_actor = crate::activitypub::actor::get_or_create_for_repo(db, repository_id).await?;
    let repo_actor_url = urls.repo_actor(&owner.name, &repository.slug);

    // Build the patch URL (target of the Add)
    let patch_url = format!(
        "{}/~{}/{}/patches/{}",
        public_url, owner.name, repository.slug, patch.number
    );

    // Build patch collection (the new patch set)
    // For now, we'll create a simple collection with the current patch state
    // In a full implementation, this would use git format-patch to generate the actual patches
    let activity_id = urls.repo_activity(&owner.name, &repository.slug, Uuid::new_v4());

    let add = crate::activitypub::types::Add {
        context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
        id: activity_id,
        kind: "Add",
        actor: repo_actor_url.clone(),
        object: serde_json::json!({
            "type": "Collection",
            "items": []
        }),
        target: patch_url.clone(),
        to: vec![urls.repo_followers(&owner.name, &repository.slug)],
        cc: Some(vec![
            "https://www.w3.org/ns/activitystreams#Public".to_string(),
        ]),
        published: chrono::Utc::now().to_rfc3339(),
    };

    let add_json = serde_json::to_string(&add)?;

    // Publish Add activity
    crate::activitypub::forge::activity::publish_activity(db, repo_actor.id, &add_json, &patch_url)
        .await?;

    Ok(())
}