From 35e4bd04fb4ed034fee82ddaa34dab7664b7987f Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Fri, 4 Sep 2026 03:30:31 +0900 Subject: [PATCH] gitmirror: `org.tangled.temp.git.deleteBranch` Signed-off-by: Seongmin Lee --- gitmirror/crates/gitmirror-git/src/layout.rs | 4 + gitmirror/crates/gitmirror-xrpc/src/lib.rs | 1 + .../crates/gitmirror-xrpc/src/routes/git.rs | 185 +++++++++++++----- 3 files changed, 146 insertions(+), 44 deletions(-) diff --git a/gitmirror/crates/gitmirror-git/src/layout.rs b/gitmirror/crates/gitmirror-git/src/layout.rs index 7c5245a9c..33109b875 100644 --- a/gitmirror/crates/gitmirror-git/src/layout.rs +++ b/gitmirror/crates/gitmirror-git/src/layout.rs @@ -34,6 +34,10 @@ impl Layout { self.scan_path.join(repo.as_str()) } + pub fn open(&self, repo: &Did) -> Result { + gix::open(self.repo_path(repo)) + } + pub fn open_scratch(&self, dids: &[&Did]) -> Result { if dids.is_empty() { return Err(Error::NotEnoughRepos); diff --git a/gitmirror/crates/gitmirror-xrpc/src/lib.rs b/gitmirror/crates/gitmirror-xrpc/src/lib.rs index 40c80c5b3..24fadc22a 100644 --- a/gitmirror/crates/gitmirror-xrpc/src/lib.rs +++ b/gitmirror/crates/gitmirror-xrpc/src/lib.rs @@ -77,6 +77,7 @@ pub fn router(state: AppState) -> Router { .route("/xrpc/sh.tangled.git.temp2.listCommits", get(git::list_commits)) .route("/xrpc/sh.tangled.git.temp2.mergeCheck", get(git::merge_check)) .route("/xrpc/sh.tangled.git.mergeCommit", post(git::merge_commit)) + .route("/xrpc/org.tangled.temp.git.deleteBranch", post(git::delete_branch)) .with_state(state) } diff --git a/gitmirror/crates/gitmirror-xrpc/src/routes/git.rs b/gitmirror/crates/gitmirror-xrpc/src/routes/git.rs index c59153716..6d2651acc 100644 --- a/gitmirror/crates/gitmirror-xrpc/src/routes/git.rs +++ b/gitmirror/crates/gitmirror-xrpc/src/routes/git.rs @@ -12,7 +12,10 @@ use gix::revision::walk::Sorting; use jacquard_axum::{ExtractXrpc, XrpcResponse}; use jacquard_common::ToSmolStr; use jacquard_common::types::string::Datetime; +use jacquard_common::service_auth::ServiceAuthClaims; +use jacquard_common::types::did::Did; use jacquard_identity::resolver::IdentityResolver as _; +use lexicons::org_tangled::temp::git::delete_branch; use lexicons::sh_tangled; use lexicons::sh_tangled::git::{ merge_commit, @@ -679,29 +682,13 @@ pub(crate) async fn merge_commit( ExtractAnyServiceAuth(auth, token): ExtractAnyServiceAuth, ExtractXrpc(input): ExtractXrpc, ) -> Result, XrpcError> { - if !auth - .lxm - .as_ref() - .is_some_and(|lxm| lxm.as_str() == REPO_PUSH_NSID) - { - return Err(XrpcError::Unauthorized(format!( - "the token is bound to {:?}, which is not the push method", - auth.lxm.as_ref().map(|lxm| lxm.as_str()) - ))); - } + require_push_token(&auth)?; // 1. validate inputs let merge_style = MergeStyle::parse(input.style.as_str()).ok_or_else(|| { XrpcError::InvalidRequest(format!("unknown merge style: {:?}", input.style.as_str())) })?; - let target_branch = - gix::refs::FullName::try_from(format!("refs/heads/{}", input.target.branch.as_str())) - .map_err(|e| { - XrpcError::InvalidRequest(format!( - "bad branch name {:?}: {e}", - input.target.branch.as_str() - )) - })?; + let target_branch = head_ref(input.target.branch.as_str())?; // 2. load target & source repos as scratch repo. let scratch = state @@ -717,32 +704,7 @@ pub(crate) async fn merge_commit( hidden.push(source_commit_id); } - let remote = { - let repo = input.target.repo.as_str(); - let document = state - .resolver() - .resolve_did_doc_owned(&input.target.repo) - .await - .map_err(|e| XrpcError::Internal(format!("could not resolve {repo}: {e}")))?; - let endpoint = document - .knot_endpoint() - // TODO(post-1.0): remove legacy `#atproto_pds` fallback - .or_else(|| document.pds_endpoint()) - .ok_or_else(|| XrpcError::InvalidRequest(format!("{repo} declares no knot service")))?; - let knot = safe_knot_host(endpoint.as_str(), state.knot_policy).map_err(|_reason| { - XrpcError::InvalidRequest(format!("{repo} declares invalid knot service endpoint")) - })?; - - let audience = knot_service_did(&knot); - if auth.aud.audience().as_str() != audience { - return Err(XrpcError::Unauthorized(format!( - "the token is audienced to {}, but {repo} is hosted on {audience}", - auth.aud.as_str() - ))); - } - - format!("{}{}", knot.url(), repo) - }; + let remote = push_remote(&state, &input.target.repo, &auth).await?; let http = state.http.clone(); let committer = gix::actor::Signature { @@ -815,6 +777,141 @@ pub(crate) async fn merge_commit( Ok(XrpcResponse(())) } +#[axum::debug_handler] +pub(crate) async fn delete_branch( + State(state): State, + ExtractAnyServiceAuth(auth, token): ExtractAnyServiceAuth, + ExtractXrpc(input): ExtractXrpc, +) -> Result, XrpcError> { + require_push_token(&auth)?; + let refname = head_ref(input.branch.as_str())?; + + let expected_old = { + let repo = state.layout.open(&input.repo).map_err(|e| { + XrpcError::RepoNotFound { + detail: e.to_string(), + } + })?; + let reference = repo + .try_find_reference(refname.as_ref().as_partial_name()) + .map_err(|e| XrpcError::Internal(e.to_string()))? + .ok_or_else(|| XrpcError::RefNotFound { + rev: input.branch.to_string(), + detail: "the mirror holds no such branch".to_owned(), + })?; + // Not peeled: `expected_old` is the value the knot holds for the ref itself, and peeling an + // annotated tag would name something the knot never wrote there. + reference + .target() + .try_id() + .ok_or_else(|| { + XrpcError::InvalidRequest(format!( + "{:?} is a symbolic ref, not a branch", + input.branch.as_str() + )) + })? + .to_owned() + }; + + let remote = push_remote(&state, &input.repo, &auth).await?; + let http = state.http.clone(); + // NOTE: see note from `merge_commit()`. + // TODO(boltless): implement `gix_transport::async_io_send` or something like that. + let runtime = tokio::runtime::Handle::current(); + let statuses = tokio::task::spawn_blocking(move || { + let mut transport = crate::git_transport::HttpTransport::new(http, remote.clone(), token); + let mut session = runtime + .block_on(gix_receive_pack::Session::handshake( + &mut transport, + Some(AGENT), + )) + .map_err(|e| XrpcError::Internal(format!("{remote}: {e}")))?; + + runtime + .block_on(session.push( + false, + &[gix_receive_pack::Update { + name: refname, + expected_old, + new: gix::ObjectId::null(expected_old.kind()), + }], + None, + gix_receive_pack::Options { + atomic: true, + push_options: &[], + quiet: false, + }, + &mut gix::progress::Discard, + &gix::interrupt::IS_INTERRUPTED, + )) + .map_err(|e| XrpcError::Internal(format!("{remote}: {e}"))) + }) + .await + .map_err(|e| XrpcError::Internal(e.to_string()))??; + + if let Some(reason) = statuses.iter().find_map(|s| s.status.as_ref().err()) { + return Err(XrpcError::PushRejected(reason.to_string())); + } + + info!( + pusher = %auth.iss.as_str(), + repo = %input.repo.as_str(), + branch = %input.branch.as_str(), + "deleted branch" + ); + Ok(XrpcResponse(())) +} + +fn require_push_token(auth: &ServiceAuthClaims) -> Result<(), XrpcError> { + if auth + .lxm + .as_ref() + .is_none_or(|lxm| lxm.as_str() != REPO_PUSH_NSID) + { + return Err(XrpcError::Unauthorized(format!( + "the token is bound to {:?}, which is not the push method", + auth.lxm.as_ref().map(|lxm| lxm.as_str()) + ))); + } + Ok(()) +} + +fn head_ref(branch: &str) -> Result { + gix::refs::FullName::try_from(format!("refs/heads/{branch}")) + .map_err(|e| XrpcError::InvalidRequest(format!("bad branch name {branch:?}: {e}"))) +} + +async fn push_remote( + state: &AppState, + repo: &Did, + auth: &ServiceAuthClaims, +) -> Result { + let did = repo.as_str(); + let document = state + .resolver() + .resolve_did_doc_owned(repo) + .await + .map_err(|e| XrpcError::Internal(format!("could not resolve {did}: {e}")))?; + let endpoint = document + .knot_endpoint() + // TODO(post-1.0): remove legacy `#atproto_pds` fallback + .or_else(|| document.pds_endpoint()) + .ok_or_else(|| XrpcError::InvalidRequest(format!("{did} declares no knot service")))?; + let knot = safe_knot_host(endpoint.as_str(), state.knot_policy).map_err(|_reason| { + XrpcError::InvalidRequest(format!("{did} declares invalid knot service endpoint")) + })?; + + let audience = knot_service_did(&knot); + if auth.aud.audience().as_str() != audience { + return Err(XrpcError::Unauthorized(format!( + "the token is audienced to {}, but {did} is hosted on {audience}", + auth.aud.as_str() + ))); + } + + Ok(format!("{}{}", knot.url(), did)) +} + fn safe_knot_host(endpoint: &str, policy: KnotPolicy) -> Result { let host = KnotHost::parse(endpoint).map_err(|_| "is not a usable base URL")?; if policy.require_https && host.url().scheme() != "https" { -- 2.51.2