//! 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 std::collections::HashSet;
use chrono::Utc;
use sea_orm::QueryFilter;
use uuid::Uuid;
use crate::activitypub::{ApUrls, types};
use crate::entities::prelude::{ApFollowers, ApRemoteActors};
use crate::entities::{ap_followers, ap_remote_actors, users};
// 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 uuid::Uuid;
use crate::activitypub::ApUrls;
use crate::entities::users;
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 uuid::Uuid;
use crate::activitypub::ApUrls;
use crate::entities::users;
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(())
}