use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; use axum::extract::State; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::routing::get; use axum::{Json, Router}; use bobbin_types::sh_tangled; use bobbin_types::sh_tangled::git::temp2::{get_diff, get_interdiff, list_commits, merge_check}; use gix::ObjectId; use jacquard_axum::{ExtractXrpc, XrpcResponse}; use jacquard_common::ToSmolStr; use jacquard_common::types::string::Datetime; use serde_json::json; use tracing::{error, info}; use crate::diff; use crate::git::{commit_log_walk, open_scratch}; const DEFAULT_LIMIT: u32 = 50; const MAX_LIMIT: u32 = 100; #[derive(Clone)] struct XrpcState { repo_base: Arc, } #[derive(Debug)] pub(crate) enum XrpcError { InvalidRequest(String), RepoNotFound { detail: String }, RefNotFound { rev: String, detail: String }, RevisionNotFound { rev: String }, CompareError(String), Internal(String), } impl IntoResponse for XrpcError { fn into_response(self) -> Response { let (status, error, message) = match self { Self::InvalidRequest(m) => (StatusCode::BAD_REQUEST, "InvalidRequest", m), Self::RepoNotFound { detail } => { error!(error = %detail, "repo not found"); ( StatusCode::NOT_FOUND, "RepoNotFound", "repository not found".to_owned(), ) } Self::RefNotFound { rev, detail } => { error!(error = %detail, rev = %rev, "revision not found"); ( StatusCode::NOT_FOUND, "RefNotFound", format!("revision not found: {rev}"), ) } Self::RevisionNotFound { rev } => ( StatusCode::NOT_FOUND, "RevisionNotFound", format!("commit not found: {rev}"), ), Self::CompareError(m) => { error!(error = %m, "compare failed"); ( StatusCode::INTERNAL_SERVER_ERROR, "CompareError", "failed to compare revisions".to_owned(), ) } Self::Internal(m) => { error!(error = %m, "xrpc request failed"); ( StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError", "internal error".to_owned(), ) } }; (status, Json(json!({ "error": error, "message": message }))).into_response() } } struct GixSignature<'a>(gix::actor::SignatureRef<'a>); impl TryFrom> for sh_tangled::git::Signature { type Error = anyhow::Error; fn try_from(GixSignature(sig): GixSignature) -> Result { let time = sig.time()?; let offset = chrono::FixedOffset::east_opt(time.offset).ok_or_else(|| { anyhow::anyhow!("commit timezone offset out of range: {}", time.offset) })?; let when = chrono::DateTime::from_timestamp(time.seconds, 0) .ok_or_else(|| anyhow::anyhow!("commit timestamp out of range: {}", time.seconds))? .with_timezone(&offset); Ok(Self { name: sig.name.to_smolstr(), email: sig.email.to_smolstr(), when: Datetime::new(when), extra_data: Default::default(), }) } } struct GixCommit<'a>(gix::Commit<'a>); impl TryFrom> for list_commits::Commit { type Error = anyhow::Error; fn try_from(GixCommit(commit): GixCommit<'_>) -> Result { let decoded = commit.decode()?; Ok(Self { oid: commit.id.to_smolstr(), parents: decoded .parents .iter() .map(|parent| parent.to_smolstr()) .collect(), tree: decoded.tree.to_smolstr(), author: GixSignature(decoded.author()?).try_into()?, committer: GixSignature(decoded.committer()?).try_into()?, extra_headers: decoded .extra_headers .iter() .map(|(key, value)| list_commits::Header { key: key.to_smolstr(), value: value.to_smolstr(), extra_data: Default::default(), }) .collect(), message: decoded.message.to_smolstr(), extra_data: Default::default(), }) } } impl From for sh_tangled::git::DiffSrc { fn from(f: crate::diff::FileContent) -> Self { Self { path: f.path.into(), oid: f.oid.into(), size: f.size as i64, is_binary: f.is_binary, is_submodule: f.is_submodule, content: f .content .map(|b| String::from_utf8_lossy(&b).into_owned().into()), extra_data: Default::default(), } } } impl From for sh_tangled::git::DiffHunk { fn from(h: crate::diff::Hunk) -> Self { // The novel sets are hash sets; sort them so the output is stable across runs. let sorted = |set: rustc_hash::FxHashSet| -> Vec { let mut v: Vec = set.into_iter().map(|n| i64::from(n.0)).collect(); v.sort_unstable(); v }; Self { novel_lhs: sorted(h.novel_lhs), novel_rhs: sorted(h.novel_rhs), lines: h .lines .into_iter() .map(|(lhs, rhs)| sh_tangled::git::LinePair { lhs: lhs.map(|n| i64::from(n.0)), rhs: rhs.map(|n| i64::from(n.0)), extra_data: Default::default(), }) .collect(), extra_data: Default::default(), } } } impl From for sh_tangled::git::FileDiff { fn from(d: crate::diff::Diff) -> Self { Self { lhs_src: d.lhs_src.into(), rhs_src: d.rhs_src.into(), hunks: d.hunks.into_iter().map(Into::into).collect(), has_byte_changes: d .has_byte_changes .map(|(lhs, rhs)| sh_tangled::git::ByteChanges { lhs: lhs as i64, rhs: rhs as i64, extra_data: Default::default(), }), has_syntactic_changes: d.has_syntactic_changes, extra_data: Default::default(), } } } /// Resolve a full hex oid to a commit that actually exists in `repo`. fn find_commit(repo: &gix::Repository, sha: &str) -> Result { let oid = gix::ObjectId::from_hex(sha.as_bytes()) .map_err(|e| XrpcError::InvalidRequest(format!("bad commit sha {sha:?}: {e}")))?; repo.find_commit(oid) .map_err(|_| XrpcError::RevisionNotFound { rev: sha.to_owned(), })?; Ok(oid) } fn get_diff_inner( repo: &gix::Repository, base: gix::ObjectId, head: gix::ObjectId, ) -> Result, XrpcError> { let compare = || -> anyhow::Result> { let merge_base = repo.merge_base(base, head)?.detach(); let old = repo.find_tree(repo.find_commit(merge_base)?.tree_id()?)?; let new = repo.find_tree(repo.find_commit(head)?.tree_id()?)?; diff::diff(repo, &old, &new, false)? .map(|d| d.map(Into::into)) .collect() }; compare().map_err(|e| XrpcError::CompareError(e.to_string())) } async fn get_diff( State(state): State, ExtractXrpc(args): ExtractXrpc, ) -> Result, XrpcError> { let scratch = open_scratch( &state.repo_base, &[args.head_repo.as_str(), args.base_repo.as_str()], )?; let base = find_commit(&scratch, args.base_commit.as_ref())?; let head = find_commit(&scratch, args.head_commit.as_ref())?; tokio::task::spawn_blocking(move || get_diff_inner(&scratch, base, head)) .await .map_err(|e| XrpcError::Internal(e.to_string()))? .map(|diffs| { XrpcResponse(get_diff::GetDiffOutput { diffs, extra_data: Default::default(), }) }) } async fn merge_check( State(state): State, ExtractXrpc(params): ExtractXrpc, ) -> Result, XrpcError> { let scratch = open_scratch( &state.repo_base, &[params.target_repo.as_str(), params.source_repo.as_str()], )?; let target = find_commit(&scratch, params.target_commit.as_ref())?; let source = find_commit(&scratch, params.source_commit.as_ref())?; tokio::task::spawn_blocking(move || crate::merge::merge_check(&scratch, target, source)) .await .map_err(|e| XrpcError::Internal(e.to_string()))? .map(XrpcResponse) .map_err(|e| XrpcError::Internal(e.to_string())) } fn get_interdiff_inner( repo: &gix::Repository, (from_base_id, from_head_id): (ObjectId, ObjectId), (to_base_id, to_head_id): (ObjectId, ObjectId), ) -> Result, XrpcError> { let to_head = repo .find_commit(to_head_id) .map_err(|e| XrpcError::Internal(e.to_string()))?; let to_head_tree = to_head .tree() .map_err(|e| XrpcError::Internal(e.to_string()))?; let rebased_tree = diff::prepare_interdiff(&repo, (from_base_id, from_head_id), to_base_id) .map_err(|e| XrpcError::Internal(e.to_string()))?; let compare = || -> anyhow::Result> { diff::diff(&repo, &rebased_tree, &to_head_tree, true)? .map(|d| d.map(Into::into)) .collect() }; compare().map_err(|e| XrpcError::CompareError(e.to_string())) } async fn get_interdiff( State(state): State, ExtractXrpc(args): ExtractXrpc, ) -> Result, XrpcError> { let from_base_id = ObjectId::from_hex(args.base_commit1.as_bytes()) .map_err(|e| XrpcError::InvalidRequest(e.to_string()))?; let from_head_id = ObjectId::from_hex(args.base_commit2.as_bytes()) .map_err(|e| XrpcError::InvalidRequest(e.to_string()))?; let to_base_id = ObjectId::from_hex(args.head_commit1.as_bytes()) .map_err(|e| XrpcError::InvalidRequest(e.to_string()))?; let to_head_id = ObjectId::from_hex(args.head_commit2.as_bytes()) .map_err(|e| XrpcError::InvalidRequest(e.to_string()))?; let scratch = open_scratch( &state.repo_base, &[args.head_repo.as_str(), args.base_repo.as_str()], )?; tokio::task::spawn_blocking(move || { get_interdiff_inner( &scratch, (from_base_id, from_head_id), (to_base_id, to_head_id), ) }) .await .map_err(|e| XrpcError::Internal(e.to_string()))? .map(|diffs| { XrpcResponse(get_interdiff::GetInterdiffOutput { diffs, extra_data: Default::default(), }) }) } fn list_commits_inner( repo: &gix::Repository, args: list_commits::ListCommits, ) -> Result, XrpcError> { let ranges: Vec> = args .ranges .clone() .unwrap_or_default() .iter() .map(|revspec| revspec.as_bytes().to_vec()) .collect(); let walk = commit_log_walk(repo, &ranges, args.all_refs.unwrap_or(false)).map_err(|e| { XrpcError::RefNotFound { rev: args.ranges.unwrap_or_default().join(", "), detail: e.to_string(), } })?; let limit = args .limit .map(|limit| limit as u32) .unwrap_or(DEFAULT_LIMIT); if limit == 0 || limit > MAX_LIMIT { return Err(XrpcError::InvalidRequest(format!( "limit must be between 1 and {MAX_LIMIT}" ))); } let mut commits: Vec = Vec::with_capacity(limit as usize); let mut skipped = 0usize; for info in walk { let info = info.map_err(|e| XrpcError::Internal(e.to_string()))?; if (skipped as i64) < args.skip.unwrap_or(0) { skipped += 1; continue; } if (commits.len() as i64) == args.limit.unwrap_or(50) { break; } let commit = info .object() .map_err(|e| XrpcError::Internal(e.to_string()))?; commits.push( list_commits::Commit::try_from(GixCommit(commit)) .map_err(|e| XrpcError::Internal(e.to_string()))?, ); } Ok(commits) } async fn list_commits( State(state): State, ExtractXrpc(args): ExtractXrpc, ) -> Result, XrpcError> { let path = state.repo_base.join(args.repo.as_str()); let repo = gix::open(path) .map_err(|e| XrpcError::RepoNotFound { detail: e.to_string(), })? .into_sync(); tokio::task::spawn_blocking(move || list_commits_inner(&repo.to_thread_local(), args)) .await .map_err(|e| XrpcError::Internal(e.to_string()))? .map(|commits| { XrpcResponse(list_commits::ListCommitsOutput { commits, extra_data: Default::default(), }) }) } pub async fn serve(addr: SocketAddr, repo_base: PathBuf) -> anyhow::Result<()> { let app = Router::new() .route("/xrpc/sh.tangled.git.temp2.getDiff", get(get_diff)) .route( "/xrpc/sh.tangled.git.temp2.getInterdiff", get(get_interdiff), ) .route("/xrpc/sh.tangled.git.temp2.listCommits", get(list_commits)) .route("/xrpc/sh.tangled.git.temp2.mergeCheck", get(merge_check)) .with_state(XrpcState { repo_base: Arc::new(repo_base), }); let listener = tokio::net::TcpListener::bind(addr).await?; info!(addr = %addr, "gitmirror XRPC server listening"); axum::serve(listener, app).await?; Ok(()) } #[cfg(test)] mod tests { use super::*; use std::path::Path; use std::process::Command; const BASE_DID: &str = "did:plc:upstream"; const HEAD_DID: &str = "did:plc:fork"; fn git(dir: &Path, args: &[&str]) { let status = Command::new("git") .args(args) .current_dir(dir) .env("GIT_AUTHOR_NAME", "t") .env("GIT_AUTHOR_EMAIL", "t@t") .env("GIT_COMMITTER_NAME", "t") .env("GIT_COMMITTER_EMAIL", "t@t") .status() .expect("run git"); assert!(status.success(), "git {args:?} failed"); } fn rev_parse(dir: &Path, rev: &str) -> String { let out = Command::new("git") .args(["rev-parse", rev]) .current_dir(dir) .output() .expect("rev-parse"); String::from_utf8(out.stdout).unwrap().trim().to_owned() } /// The real fork shape: the base tip lives only in the upstream mirror and the head tip only /// in the fork's, with a shared ancestor. Returns `(repo_base, base_commit, head_commit)`. fn fork_fixture() -> (tempfile::TempDir, String, String) { let root = tempfile::tempdir().unwrap(); let repo_base = root.path().join("repos"); std::fs::create_dir_all(&repo_base).unwrap(); let upstream = root.path().join("upstream"); std::fs::create_dir_all(&upstream).unwrap(); git(&upstream, &["init", "-q", "-b", "main"]); std::fs::write(upstream.join("a.txt"), "line1\nline2\nline3\n").unwrap(); std::fs::write(upstream.join("b.txt"), "keep\n").unwrap(); git(&upstream, &["add", "."]); git(&upstream, &["commit", "-q", "-m", "shared ancestor"]); // Fork before upstream moves on, so neither tip is reachable from the other. let fork = root.path().join("fork"); git( root.path(), &[ "clone", "-q", upstream.to_str().unwrap(), fork.to_str().unwrap(), ], ); std::fs::write(upstream.join("upstream.txt"), "theirs\n").unwrap(); git(&upstream, &["add", "."]); git(&upstream, &["commit", "-q", "-m", "upstream only"]); let base_commit = rev_parse(&upstream, "HEAD"); std::fs::write(fork.join("a.txt"), "line1\nCHANGED\nline3\n").unwrap(); git(&fork, &["mv", "b.txt", "c.txt"]); git(&fork, &["add", "-A"]); git(&fork, &["commit", "-q", "-m", "fork only"]); let head_commit = rev_parse(&fork, "HEAD"); for (did, src) in [(BASE_DID, &upstream), (HEAD_DID, &fork)] { git( root.path(), &[ "clone", "-q", "--bare", src.to_str().unwrap(), repo_base.join(did).to_str().unwrap(), ], ); } (root, base_commit, head_commit) } fn diff_fixture( root: &Path, base_commit: &str, head_commit: &str, ) -> Result, XrpcError> { let repo_base = root.join("repos"); let scratch = open_scratch(&repo_base, &[HEAD_DID, BASE_DID])?; let base = find_commit(&scratch, base_commit)?; let head = find_commit(&scratch, head_commit)?; get_diff_inner(&scratch, base, head) } #[test] fn a_cross_repo_diff_reports_only_what_the_head_side_changed() { let (root, base, head) = fork_fixture(); let diffs = diff_fixture(root.path(), &base, &head).unwrap(); let mut paths: Vec = diffs .iter() .flat_map(|d| [d.lhs_src.path.to_string(), d.rhs_src.path.to_string()]) .collect(); paths.sort(); paths.dedup(); // `upstream.txt` is on the base side only: three-dot semantics exclude it. assert_eq!(paths, ["a.txt", "b.txt", "c.txt"]); let a = diffs .iter() .find(|d| d.rhs_src.path == "a.txt") .expect("a.txt is in the diff"); // Only line 2 (0-based: 1) changed. assert_eq!(a.hunks.len(), 1); assert_eq!(a.hunks[0].novel_lhs, [1]); assert_eq!(a.hunks[0].novel_rhs, [1]); assert_eq!(a.hunks[0].lines, { vec![sh_tangled::git::LinePair { lhs: Some(1), rhs: Some(1), extra_data: Default::default(), }] }); // "line2\n" -> "CHANGED\n" is two bytes longer. assert_eq!( a.has_byte_changes, Some(sh_tangled::git::ByteChanges { lhs: 18, rhs: 20, extra_data: Default::default(), }) ); } #[test] fn a_malformed_sha_is_a_client_error_and_an_absent_one_is_a_miss() { let (root, base, head) = fork_fixture(); assert!(matches!( diff_fixture(root.path(), &base, "not-a-sha"), Err(XrpcError::InvalidRequest(_)) )); assert!(matches!( diff_fixture(root.path(), &base, &"0".repeat(head.len())), Err(XrpcError::RevisionNotFound { .. }) )); } #[test] fn a_repo_that_is_not_a_did_never_reaches_the_filesystem() { let (root, _, _) = fork_fixture(); let repo_base = root.path().join("repos"); assert!(matches!( open_scratch(&repo_base, &["../../etc"]).map(|_| ()), Err(XrpcError::InvalidRequest(_)) )); } }