use crate::db::MirrorDb; use crate::discovery::DiscoveredRepo; use crate::health::GraspServer; use crate::nip46::Nip46Client; use anyhow::{Context, Result}; use git2::RemoteCallbacks; use nostr_sdk::prelude::*; use std::path::Path; use std::sync::Arc; pub struct GitMirror { mirror_dir: std::path::PathBuf, } impl GitMirror { pub fn new(mirror_dir: &Path) -> Self { Self { mirror_dir: mirror_dir.to_path_buf(), } } fn repo_path(&self, pubkey: &str, identifier: &str) -> std::path::PathBuf { self.mirror_dir .join(pubkey) .join(format!("{}.git", identifier)) } pub async fn mirror_repo_to_servers( &self, db: &MirrorDb, repo: &DiscoveredRepo, target_servers: &[GraspServer], nip46_client: Option<&Arc>, ) -> Result<()> { if target_servers.is_empty() { tracing::debug!( identifier = %repo.identifier, "no missing servers to mirror to" ); return Ok(()); } let pk_hex = repo.pubkey.to_hex(); let repo_path = self.repo_path(&pk_hex, &repo.identifier); if !repo_path.exists() { self.clone_bare(&repo_path, &repo.clone_urls)?; } let state_event = match self.build_state_event(&repo_path, repo, nip46_client).await { Ok(Some(event)) => Some(event), Ok(None) => { tracing::warn!( identifier = %repo.identifier, "could not build state event — push may be rejected by GRASP servers" ); None } Err(e) => { tracing::error!( identifier = %repo.identifier, error = %e, "failed to build state event" ); None } }; for server in target_servers { let target_url = server.clone_url(&pk_hex, &repo.identifier); tracing::info!( identifier = %repo.identifier, server = %server.domain, target = %target_url, "mirroring git data" ); if let Some(ref event) = state_event { let relay_url = server.relay_url(); if let Ok(url) = RelayUrl::parse(&relay_url) { let urls = vec![url]; if let Err(e) = nip46_client .map_or(Ok(()), |_| { Err(anyhow::anyhow!("need nostr client to send state event")) }) { let _ = e; } let nostr_client = nostr_sdk::Client::default(); let _ = nostr_client.add_relay(&relay_url).await; nostr_client.connect().await; if let Err(e) = nostr_client.send_event_to(urls, event.clone()).await { tracing::warn!( server = %server.domain, error = %e, "failed to publish state event to server relay" ); } } } let repo_id = db.get_all_repos().await.ok().and_then(|repos| { repos .iter() .find(|r| r.pubkey == pk_hex && r.identifier == repo.identifier) .map(|r| r.id) }); match self.push_mirror(&repo_path, &target_url) { Ok(()) => { tracing::info!( identifier = %repo.identifier, server = %server.domain, "git mirror succeeded" ); if let Some(id) = repo_id { let _ = db.mark_git_synced(id, &server.domain).await; } } Err(e) => { tracing::error!( identifier = %repo.identifier, server = %server.domain, error = %e, "git mirror failed" ); if let Some(id) = repo_id { let _ = db.mark_sync_error(id, &server.domain, &e.to_string()).await; } } } } Ok(()) } async fn build_state_event( &self, repo_path: &std::path::PathBuf, repo: &DiscoveredRepo, nip46_client: Option<&Arc>, ) -> Result> { let nip46 = match nip46_client { Some(c) => c, None => return Ok(None), }; let git_repo = git2::Repository::open(repo_path) .with_context(|| format!("failed to open bare repo at {:?}", repo_path))?; let mut tags: Vec = vec![ Tag::custom(TagKind::Custom("d".into()), [&repo.identifier]), ]; let refs = git_repo.references()?; for reference in refs { let reference = reference?; let name = reference.name().unwrap_or(""); if name.is_empty() { continue; } if let Some(oid) = reference.target() { tags.push(Tag::custom( TagKind::Custom("ref".into()), [name, &oid.to_string()], )); } } let builder = EventBuilder::new(Kind::Custom(30618), "").tags(tags); let unsigned = builder.build(repo.pubkey); match nip46.sign_event(&repo.pubkey, &unsigned).await { Ok(signed) => { tracing::info!( identifier = %repo.identifier, event_id = %signed.id.to_hex(), "signed kind:30618 state event via NIP-46" ); Ok(Some(signed)) } Err(e) => { tracing::error!( identifier = %repo.identifier, error = %e, "NIP-46 signing failed for state event" ); Err(e) } } } fn clone_bare(&self, repo_path: &Path, clone_urls: &[String]) -> Result<()> { if let Some(parent) = repo_path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("failed to create {:?}", parent))?; } let mut last_error = None; for url in clone_urls { if url.is_empty() { continue; } tracing::info!(url = %url, path = ?repo_path, "cloning bare repo"); let mut callbacks = RemoteCallbacks::new(); callbacks.credentials(|_url, _username, _allowed| git2::Cred::default()); let mut fetch_opts = git2::FetchOptions::new(); fetch_opts.remote_callbacks(callbacks); let mut builder = git2::build::RepoBuilder::new(); builder.bare(true).fetch_options(fetch_opts); match builder.clone(url, repo_path) { Ok(_) => { tracing::info!(url = %url, "bare clone succeeded"); return Ok(()); } Err(e) => { tracing::warn!(url = %url, error = %e, "clone failed, trying next URL"); last_error = Some(e); if repo_path.exists() { let _ = std::fs::remove_dir_all(repo_path); } } } } let err = last_error.unwrap_or_else(|| git2::Error::from_str("no clone URLs available")); Err(err).with_context(|| format!("all clone attempts failed for {:?}", repo_path)) } fn push_mirror(&self, repo_path: &Path, target_url: &str) -> Result<()> { let repo = git2::Repository::open(repo_path) .with_context(|| format!("failed to open bare repo at {:?}", repo_path))?; let remote_name = "push_target"; match repo.find_remote(remote_name) { Ok(_) => { repo.remote_set_url(remote_name, target_url)?; } Err(_) => { repo.remote(remote_name, target_url)?; } } let mut remote = repo.find_remote(remote_name)?; let mut callbacks = RemoteCallbacks::new(); callbacks.credentials(|_url, _username, _allowed| git2::Cred::default()); callbacks.push_update_reference(|_refname, status| { if let Some(s) = status { tracing::warn!(status = %s, "push rejected"); } Ok(()) }); let mut push_opts = git2::PushOptions::new(); push_opts.remote_callbacks(callbacks); let refspecs = ["+refs/*:refs/*"]; remote .push(&refspecs, Some(&mut push_opts)) .with_context(|| format!("failed to push mirror to {}", target_url))?; Ok(()) } }