Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
Rust
at icy/tmrrpn
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579use 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<PathBuf>,}
#[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<GixSignature<'_>> for sh_tangled::git::Signature { type Error = anyhow::Error;
fn try_from(GixSignature(sig): GixSignature) -> Result<Self, Self::Error> { 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<GixCommit<'_>> for list_commits::Commit { type Error = anyhow::Error;
fn try_from(GixCommit(commit): GixCommit<'_>) -> Result<Self, Self::Error> { 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<crate::diff::FileContent> 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<crate::diff::Hunk> 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<line_numbers::LineNumber>| -> Vec<i64> { let mut v: Vec<i64> = 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<crate::diff::Diff> 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<gix::ObjectId, XrpcError> { 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<Vec<sh_tangled::git::FileDiff>, XrpcError> { let compare = || -> anyhow::Result<Vec<sh_tangled::git::FileDiff>> { 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<XrpcState>, ExtractXrpc(args): ExtractXrpc<get_diff::GetDiffRequest>,) -> Result<XrpcResponse<get_diff::GetDiffRequest>, 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<XrpcState>, ExtractXrpc(params): ExtractXrpc<merge_check::MergeCheckRequest>,) -> Result<XrpcResponse<merge_check::MergeCheckRequest>, 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<Vec<sh_tangled::git::FileDiff>, 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<Vec<sh_tangled::git::FileDiff>> { 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<XrpcState>, ExtractXrpc(args): ExtractXrpc<get_interdiff::GetInterdiffRequest>,) -> Result<XrpcResponse<get_interdiff::GetInterdiffRequest>, 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<Vec<list_commits::Commit>, XrpcError> { let ranges: Vec<Vec<u8>> = 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<list_commits::Commit> = 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<XrpcState>, ExtractXrpc(args): ExtractXrpc<list_commits::ListCommitsRequest>,) -> Result<XrpcResponse<list_commits::ListCommitsRequest>, 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<Vec<sh_tangled::git::FileDiff>, 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<String> = 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(_)) )); }}