don't
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(knot): re-organise public xrpc handlers

Signed-off-by: tjh <x@tjh.dev>

tjh 8b0c3335 3eca18a2

+1428 -1199
+1
.gitignore
··· 4 4 did:*/ 5 5 deleted/ 6 6 node_modules/ 7 + test-repositories/ 7 8 jetstream.json 8 9 git_config 9 10 .env
+1
crates/gordian-knot/Cargo.toml
··· 69 69 default = ["jemalloc"] 70 70 jemalloc = ["dep:tikv-jemallocator"] 71 71 frontend = ["dep:maud"] 72 +
+1
crates/gordian-knot/src/extract.rs
··· 1 1 pub mod git_protocol; 2 2 pub mod if_none_match; 3 + pub mod repository; 3 4 pub mod request_id;
+41
crates/gordian-knot/src/extract/repository.rs
··· 1 + use axum::extract::FromRequestParts; 2 + use axum::http::request::Parts; 3 + use gix::ThreadSafeRepository; 4 + use serde::Deserialize; 5 + 6 + use crate::model::knot_state::RepositoryProvider; 7 + use crate::public::xrpc::XrpcError; 8 + use crate::public::xrpc::XrpcQuery; 9 + use crate::types::repository_spec::RepositoryKey; 10 + 11 + /// Extract a repository from the `repo` parameter of a `sh.tangled.repo.*` XRPC 12 + /// request. 13 + pub struct XrpcRepository(pub ThreadSafeRepository); 14 + 15 + #[derive(Deserialize)] 16 + struct Param { 17 + repo: String, 18 + } 19 + 20 + impl<S: Send + Sync> FromRequestParts<S> for XrpcRepository 21 + where 22 + S: RepositoryProvider, 23 + { 24 + type Rejection = XrpcError; 25 + 26 + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { 27 + let XrpcQuery(Param { repo }) = XrpcQuery::from_request_parts(parts, state).await?; 28 + 29 + // Appview should always give use "did/name", let's just pretend its a 30 + // "did/rkey". 31 + let key: RepositoryKey = repo.parse()?; 32 + 33 + // @review cardinality may be an issue. 34 + let labels = [("did", key.owner.to_string()), ("name", key.rkey.clone())]; 35 + metrics::counter!("knot_repo_requests_total", &labels).increment(1); 36 + 37 + let repo = state.open_repository(key).await?; 38 + 39 + Ok(Self(repo)) 40 + } 41 + }
+2
crates/gordian-knot/src/lib.rs
··· 14 14 #[cfg(test)] 15 15 pub(crate) mod mock; 16 16 #[cfg(test)] 17 + mod test_helpers; 18 + #[cfg(test)] 17 19 mod tests; 18 20 19 21 pub use gordian_lexicon as lexicon;
+6 -5
crates/gordian-knot/src/model/convert.rs
··· 10 10 use time::error::ComponentRange; 11 11 12 12 use crate::public::xrpc::XrpcError; 13 - use crate::types::sh_tangled::repo::tags; 13 + use crate::public::xrpc::sh_tangled::repo::tags::Tag; 14 + use crate::public::xrpc::sh_tangled::repo::tags::TagAnnotation; 14 15 15 16 #[derive(Debug, thiserror::Error)] 16 17 pub enum ConversionError { ··· 113 112 }) 114 113 } 115 114 116 - impl TryFrom<gix::Tag<'_>> for tags::TagAnnotation { 115 + impl TryFrom<gix::Tag<'_>> for TagAnnotation { 117 116 type Error = gix::objs::decode::Error; 118 117 119 118 fn try_from(value: gix::Tag<'_>) -> Result<Self, Self::Error> { ··· 130 129 Kind::Tag => 4, 131 130 }; 132 131 133 - Ok(tags::TagAnnotation { 132 + Ok(TagAnnotation { 134 133 hash, 135 134 name: decoded.name.to_string(), 136 135 tagger: decoded ··· 144 143 } 145 144 } 146 145 147 - impl TryFrom<gix::Reference<'_>> for tags::Tag { 146 + impl TryFrom<gix::Reference<'_>> for Tag { 148 147 type Error = gix::objs::decode::Error; 149 148 150 149 fn try_from(mut value: gix::Reference<'_>) -> Result<Self, Self::Error> { ··· 155 154 .map(TryFrom::try_from) 156 155 .transpose()?; 157 156 158 - Ok(tags::Tag { r#ref, annotation }) 157 + Ok(Tag { r#ref, annotation }) 159 158 } 160 159 }
+77 -16
crates/gordian-knot/src/model/knot_state.rs
··· 1 + use core::fmt; 1 2 use std::collections::HashMap; 3 + use std::convert::Infallible; 2 4 use std::io::ErrorKind; 3 5 use std::io::{self}; 4 6 use std::net::SocketAddr; ··· 13 11 14 12 use futures_util::FutureExt; 15 13 use futures_util::future::BoxFuture; 14 + use gix::ThreadSafeRepository; 16 15 use gordian_auth::service_auth; 17 16 use gordian_identity::HttpClient; 18 17 use gordian_identity::Resolver; ··· 24 21 use gordian_types::aturi::AtUri; 25 22 use moka::future::Cache; 26 23 use moka::future::CacheBuilder; 27 - use rayon::ThreadPool; 28 - use rayon::ThreadPoolBuilder; 29 24 use serde::Serialize; 30 25 use time::OffsetDateTime; 31 26 use tokio::process::Command; ··· 71 70 72 71 database: DataStore, 73 72 74 - /// Thread pool for running synchronous tasks. 75 - pool: ThreadPool, 76 - 77 73 events: tokio::sync::broadcast::Sender<(i64, OffsetDateTime, Event)>, 78 74 79 75 repo_key_cache: Cache<RepositoryPath, RepositoryKey>, ··· 92 94 database: DataStore, 93 95 private_binds: impl IntoIterator<Item = &'a SocketAddr>, 94 96 ) -> io::Result<Arc<Self>> { 95 - let pool = ThreadPoolBuilder::new() 96 - .build() 97 - .expect("Failed to build thread pool"); 98 - 99 97 let (events, _) = tokio::sync::broadcast::channel(16); 100 98 101 99 let private_addrs = private_binds ··· 112 118 http, 113 119 resolver, 114 120 database, 115 - pool, 116 121 events, 117 122 repo_key_cache: CacheBuilder::new(REPO_KEY_CACHE_SIZE) 118 123 .name("repository_key_cache") ··· 134 141 135 142 pub fn http(&self) -> &HttpClient { 136 143 &self.http 137 - } 138 - 139 - /// Return a reference the sync thread pool. 140 - #[inline] 141 - pub(crate) fn pool(&self) -> &ThreadPool { 142 - &self.pool 143 144 } 144 145 145 146 pub(crate) fn subscribe_events( ··· 519 532 #[inline] 520 533 fn deref(&self) -> &Self::Target { 521 534 &self.config 535 + } 536 + } 537 + 538 + pub trait ResolveRepository<Context: ?Sized = ()> { 539 + type Error: fmt::Display + fmt::Debug; 540 + 541 + fn resolve_to_key(self, ctx: &Context) -> Result<RepositoryKey, Self::Error>; 542 + } 543 + 544 + impl<T> ResolveRepository<T> for RepositoryKey { 545 + type Error = Infallible; 546 + 547 + fn resolve_to_key(self, _: &T) -> Result<RepositoryKey, Self::Error> { 548 + Ok(self) 549 + } 550 + } 551 + 552 + pub trait RepositoryProvider { 553 + fn path_for_repository(&self, key: &RepositoryKey) -> PathBuf; 554 + 555 + fn open_repository<R>( 556 + &self, 557 + repo: R, 558 + ) -> impl Future<Output = Result<ThreadSafeRepository, RepositoryOpenError>> + Send 559 + where 560 + R: ResolveRepository<Self> + Send, 561 + { 562 + use gix::open::Options; 563 + 564 + let key = repo.resolve_to_key(&self).unwrap(); 565 + let path = self.path_for_repository(&key); 566 + 567 + async move { 568 + let repository = Options::default() 569 + .strict_config(true) 570 + .open_path_as_is(true) 571 + .open(path) 572 + .map_err(Arc::new)?; 573 + 574 + Ok(repository) 575 + } 576 + } 577 + } 578 + 579 + impl<T> RepositoryProvider for T 580 + where 581 + T: ops::Deref<Target = KnotState> + Send + Sync, 582 + { 583 + fn path_for_repository(&self, key: &RepositoryKey) -> PathBuf { 584 + self.repository_base() 585 + .join(key.owner_str()) 586 + .join(key.rkey()) 587 + } 588 + 589 + async fn open_repository<R>(&self, repo: R) -> Result<ThreadSafeRepository, RepositoryOpenError> 590 + where 591 + R: ResolveRepository<Self> + Send, 592 + { 593 + use gix::open::Options; 594 + 595 + let key = repo.resolve_to_key(&self).unwrap(); 596 + let repository = self 597 + .repo_cache 598 + .try_get_with_by_ref(&key, async { 599 + let path = self.path_for_repository(&key); 600 + tracing::debug!(?path, "opening repository"); 601 + Options::default() 602 + .strict_config(true) 603 + .open_path_as_is(true) 604 + .open(path) 605 + }) 606 + .await?; 607 + 608 + Ok(repository) 522 609 } 523 610 }
+6 -474
crates/gordian-knot/src/model/repository.rs
··· 1 - mod merge_check; 2 - 3 1 use core::error; 4 2 use core::fmt; 5 3 use std::collections::BTreeMap; 6 - use std::collections::HashSet; 7 4 use std::collections::VecDeque; 8 5 use std::io; 9 6 use std::ops; ··· 9 12 use std::process::Command; 10 13 use std::process::Stdio; 11 14 12 - use axum::Json; 13 15 use axum::extract::FromRef; 14 16 use axum::extract::FromRequestParts; 15 17 use gix::ObjectId; 16 - use gix::bstr::BString; 17 - use gix::bstr::ByteSlice; 18 - use gix::submodule::config::Branch; 19 - use gordian_lexicon::sh_tangled::repo::blob::Submodule; 20 - use gordian_lexicon::sh_tangled::repo::branch; 21 - use gordian_lexicon::sh_tangled::repo::get_default_branch; 22 - use gordian_lexicon::sh_tangled::repo::languages; 23 - use gordian_lexicon::sh_tangled::repo::tree; 24 - use rustc_hash::FxHashSet; 25 - use serde::Deserialize; 18 + use gix::Repository; 26 19 27 20 use super::Knot; 28 21 use crate::command::CommandExt as _; 29 - use crate::model::convert; 30 - use crate::model::errors; 31 - use crate::model::nicediff; 32 - use crate::public::xrpc::XrpcError; 33 - use crate::public::xrpc::XrpcQuery; 34 - use crate::public::xrpc::XrpcResponse; 35 - use crate::public::xrpc::XrpcResult; 36 22 use crate::types::repository_spec::RepositoryKey; 37 23 use crate::types::repository_spec::RepositoryPath; 38 - use crate::types::sh_tangled::repo::compare; 39 - use crate::types::sh_tangled::repo::diff; 40 - use crate::types::sh_tangled::repo::log; 41 - use crate::types::sh_tangled::repo::tags; 42 24 43 25 #[derive(Debug)] 44 26 pub struct TangledRepository { 45 27 knot: Knot, 46 28 repo_key: RepositoryKey, 47 - repository: gix::Repository, 29 + repository: Repository, 48 30 } 49 31 50 32 impl From<(Knot, RepositoryKey, gix::Repository)> for TangledRepository { ··· 36 60 } 37 61 } 38 62 39 - #[derive(Debug, thiserror::Error)] 40 - #[error(transparent)] 41 - pub struct TreeError(#[from] gix::object::commit::Error); 42 - 43 - #[derive(Debug, thiserror::Error)] 44 - pub enum BlobError { 45 - #[error(transparent)] 46 - PathLookupError(#[from] gix::object::find::existing::Error), 47 - #[error("File not found at the specified path")] 48 - FileNotFound, 49 - #[error("Requested path is not a blob")] 50 - NotABlob, 51 - } 52 - 53 63 impl TangledRepository { 54 64 pub fn path(&self) -> &Path { 55 65 self.repository.path() ··· 43 81 44 82 pub fn repository_key(&self) -> &RepositoryKey { 45 83 &self.repo_key 46 - } 47 - 48 - /// Resolve a revspec into a [`(gix::Commit, bool)`] tuple. 49 - /// 50 - /// The boolean value indicates whether the revspec is immutable (ie. if 51 - /// it is an object ID). 52 - fn resolve_revspec(&self, revspec: Option<&str>) -> Result<(gix::Commit<'_>, bool), XrpcError> { 53 - use std::str::FromStr as _; 54 - 55 - Ok(if let Some(refspec) = revspec { 56 - match gix::ObjectId::from_str(refspec) { 57 - Ok(id) => ( 58 - self.repository 59 - .find_commit(id) 60 - .map_err(errors::RefNotFound)?, 61 - true, 62 - ), 63 - Err(_) => { 64 - // Assume the revspec is a branch or tag. 65 - let mut reference = self 66 - .repository 67 - .find_reference(refspec) 68 - .map_err(errors::RefNotFound)?; 69 - ( 70 - reference.peel_to_commit().map_err(errors::RefNotFound)?, 71 - false, 72 - ) 73 - } 74 - } 75 - } else { 76 - ( 77 - self.repository.head_commit().map_err(errors::RefNotFound)?, 78 - false, 79 - ) 80 - }) 81 - } 82 - 83 - pub fn get_tree<'repo>( 84 - &self, 85 - commit: &gix::Commit<'repo>, 86 - ) -> Result<gix::Tree<'repo>, TreeError> { 87 - Ok(commit.tree()?) 88 - } 89 - 90 - pub fn get_blob(&self, tree: &gix::Tree<'_>, path: &Path) -> Result<Vec<u8>, BlobError> { 91 - let entry = tree 92 - .lookup_entry_by_path(path)? 93 - .ok_or(BlobError::FileNotFound)?; 94 - 95 - if !(entry.mode().is_blob() || entry.mode().is_link()) { 96 - return Err(BlobError::NotABlob); 97 - } 98 - 99 - Ok(entry.object()?.into_blob().take_data()) 100 - } 101 - 102 - pub fn branch(&self, params: branch::Input) -> XrpcResult<Json<branch::Output>> { 103 - let mut reference = self 104 - .repository 105 - .find_reference(&params.name) 106 - .map_err(errors::RefNotFound)?; 107 - 108 - let commit = reference.peel_to_commit().map_err(errors::RefNotFound)?; 109 - let name = reference.name().shorten().to_string(); 110 - let hash = commit.id.into(); 111 - let time = commit 112 - .committer() 113 - .map_err(errors::RepoError)? 114 - .time() 115 - .map_err(errors::RepoError)?; 116 - 117 - let when = convert::time_to_offsetdatetime(&time).map_err(errors::RepoError)?; 118 - let author = convert::try_convert_signature(commit.author().map_err(errors::RepoError)?)?; 119 - let message = commit 120 - .message() 121 - .map_err(errors::RepoError)? 122 - .summary() 123 - .to_string(); 124 - 125 - // Assume HEAD points to the intended default branch. This *should* be 126 - // true for a bare repository. 127 - let head = self.repository.head()?; 128 - let default_name = head 129 - .referent_name() 130 - .ok_or(errors::HeadDetached)? 131 - .shorten() 132 - .to_string(); 133 - 134 - let is_default = default_name == name; 135 - 136 - Ok(Json(branch::Output { 137 - name, 138 - hash, 139 - when, 140 - author, 141 - message, 142 - is_default, 143 - }) 144 - .into()) 145 - } 146 - 147 - pub fn compare(&self, params: compare::Input) -> XrpcResult<Json<compare::Output>> { 148 - let (rev1, rev1_immutable) = self.resolve_revspec(Some(&params.rev1))?; 149 - let (rev2, rev2_immutable) = self.resolve_revspec(Some(&params.rev2))?; 150 - 151 - let mut seen = HashSet::new(); 152 - let mut commits = self 153 - .repository 154 - .rev_walk([rev2.id]) 155 - .with_hidden([rev1.id]) 156 - .selected(|oid| seen.insert(oid.to_owned())) 157 - .map_err(errors::RepoError)? 158 - .take_while(|val| { 159 - val.as_ref() 160 - .is_ok_and(|commit| commit.parent_ids.len() == 1) 161 - }) 162 - .collect::<Result<Vec<_>, _>>() 163 - .map_err(errors::RepoError)?; 164 - 165 - commits.reverse(); 166 - 167 - let mut format_patch_raw = String::new(); 168 - for commit in commits { 169 - let output = self 170 - .git() 171 - .arg("format-patch") 172 - .arg("-1") 173 - .arg(commit.id.to_hex().to_string()) 174 - .arg("--stdout") 175 - .output() 176 - .map_err(errors::Internal)?; 177 - 178 - format_patch_raw.push_str(&output.stdout.to_str_lossy()); 179 - format_patch_raw.push('\n'); 180 - } 181 - 182 - Ok(XrpcResponse { 183 - response: Json(compare::Output { 184 - rev1: rev1.id.into(), 185 - rev2: rev2.id.into(), 186 - format_patch_raw, 187 - }), 188 - immutable: rev1_immutable && rev2_immutable, 189 - }) 190 - } 191 - 192 - pub fn diff(&self, params: diff::Input) -> XrpcResult<Json<diff::Output>> { 193 - let (this_commit, immutable) = self.resolve_revspec(Some(&params.rev))?; 194 - let diff = nicediff::unified_diff_from_parent(this_commit).unwrap(); 195 - let response = diff::Output { 196 - rev: params.rev.into(), 197 - diff, 198 - }; 199 - 200 - Ok(XrpcResponse { 201 - response: Json(response), 202 - immutable, 203 - }) 204 - } 205 - 206 - pub fn get_default_branch( 207 - &self, 208 - _: get_default_branch::Input, 209 - ) -> XrpcResult<Json<get_default_branch::Output>> { 210 - // Assume HEAD points the intended default branch. This *should* be true 211 - // for a bare repository. 212 - let mut head = self.repository.head()?; 213 - let name = head 214 - .referent_name() 215 - .ok_or(errors::HeadDetached)? 216 - .shorten() 217 - .to_string(); 218 - 219 - let hash = head.id().map(|id| id.detach().into()); 220 - let when = head 221 - .peel_to_commit() 222 - .ok() 223 - .and_then(|commit| { 224 - commit 225 - .committer() 226 - .ok() 227 - .and_then(|committer| committer.time().ok()) 228 - }) 229 - .and_then(|time| convert::time_to_offsetdatetime(&time).ok()); 230 - 231 - Ok(Json(get_default_branch::Output { name, hash, when }).into()) 232 - } 233 - 234 - pub fn languages(&self, _: languages::Input) -> XrpcResult<Json<languages::Output>> { 235 - Ok(Json(languages::Output::default()).into()) 236 - } 237 - 238 - pub fn log(&self, params: log::Input) -> XrpcResult<Json<log::Output>> { 239 - let commit_graph = self.repository.commit_graph_if_enabled()?; 240 - let total = match &commit_graph { 241 - Some(cg) => cg 242 - .num_commits() 243 - .try_into() 244 - .expect("You must be at least 32 bits tall to enjoy this ride"), 245 - None => { 246 - tracing::warn!(repository = ?self.repository, "no commit-graph, counting commits manually"); 247 - self.repository 248 - .rev_walk([self.repository.head_id().map_err(errors::RepoEmpty)?]) 249 - .all() 250 - .map_err(errors::RepoError)? 251 - .count() 252 - } 253 - }; 254 - 255 - let (tip, _) = self.resolve_revspec(params.rev.as_deref())?; 256 - 257 - let mut commits = Vec::new(); 258 - for commit in self 259 - .repository 260 - .rev_walk([tip.id()]) 261 - .with_commit_graph(commit_graph) 262 - .all() 263 - .map_err(errors::RepoError)? 264 - .skip(params.cursor) 265 - .take(params.limit.into()) 266 - { 267 - match commit { 268 - Ok(commit) => { 269 - let commit = self 270 - .repository 271 - .find_commit(commit.id()) 272 - .map_err(errors::RepoError)?; 273 - commits.push(convert::try_convert_commit(commit).map_err(errors::RepoError)?); 274 - } 275 - Err(error) => { 276 - tracing::error!(?error); 277 - break; 278 - } 279 - } 280 - } 281 - 282 - Ok(Json(log::Output { 283 - commits, 284 - log: true, 285 - total, 286 - page: 1 + params.cursor / usize::from(params.limit), 287 - per_page: params.limit, 288 - }) 289 - .into()) 290 - } 291 - 292 - pub fn tags(&self, _: tags::Input) -> XrpcResult<Json<tags::Output>> { 293 - use std::cmp::Reverse; 294 - 295 - let mut tags: Vec<_> = self 296 - .repository 297 - .references()? 298 - .tags()? 299 - .filter_map(|tag| { 300 - tag.inspect_err(|error| tracing::error!(?error)) 301 - .ok()? 302 - .try_into() 303 - .inspect_err(|error| tracing::error!(?error)) 304 - .ok() 305 - }) 306 - .collect(); 307 - 308 - tags.sort_by_key(|tag: &tags::Tag| { 309 - Reverse( 310 - tag.annotation 311 - .as_ref() 312 - .map(|an| an.tagger.as_ref().map(|tagger| tagger.when)), 313 - ) 314 - }); 315 - 316 - Ok(Json(tags::Output { tags }).into()) 317 - } 318 - 319 - pub fn tree( 320 - &self, 321 - params: tree::Input, 322 - readmes: &FxHashSet<BString>, 323 - ) -> XrpcResult<Json<tree::Output>> { 324 - let (tip, immutable) = self.resolve_revspec(params.rev.as_deref())?; 325 - let dotdot = params.path.clone().and_then(|mut path| { 326 - path.pop(); 327 - match path.as_os_str().is_empty() { 328 - true => None, 329 - false => Some(path), 330 - } 331 - }); 332 - 333 - let mut parent = None; 334 - let mut tree = tip.tree()?; 335 - if let Some(subpath) = &params.path { 336 - let entry = tree 337 - .lookup_entry_by_path(subpath)? 338 - .ok_or(errors::PathNotFound(subpath.to_string_lossy()))?; 339 - 340 - if !entry.mode().is_tree() { 341 - return Ok(XrpcResponse { 342 - response: Json(tree::Output { 343 - files: vec![], 344 - dotdot: dotdot.map(|path| path.into()), 345 - parent: params.path.map(|path| path.into()), 346 - rev: params.rev.as_deref().unwrap_or_default().to_string(), 347 - readme: None, 348 - }), 349 - immutable, 350 - }); 351 - } 352 - 353 - let subtree = self.repository.find_tree(entry.id()).unwrap(); 354 - tree = subtree; 355 - parent = Some(subpath.to_path_buf()); 356 - } 357 - 358 - let mut files: Vec<tree::TreeEntry> = vec![]; 359 - let mut readme = None; 360 - for entry in tree.iter() { 361 - let Ok(entry) = entry else { 362 - continue; 363 - }; 364 - 365 - if readmes.contains(entry.filename()) && entry.mode().is_blob() && readme.is_none() { 366 - let mut file = self.repository.find_blob(entry.id())?; 367 - if let Ok(contents) = String::from_utf8(file.take_data()) { 368 - readme.replace(tree::Readme { 369 - contents, 370 - filename: entry.filename().to_string(), 371 - }); 372 - } 373 - } 374 - 375 - files.push(convert::convert_entry(entry)); 376 - } 377 - 378 - let files: Vec<_> = tree 379 - .iter() 380 - .filter_map(|entry| { 381 - let entry = entry.ok()?; 382 - let file = convert::convert_entry(entry); 383 - Some(file) 384 - }) 385 - .collect(); 386 - 387 - Ok(XrpcResponse { 388 - response: Json(tree::Output { 389 - files, 390 - dotdot: dotdot.map(|path| path.into()), 391 - parent, 392 - rev: params.rev.as_deref().unwrap_or_default().to_string(), 393 - readme, 394 - }), 395 - immutable, 396 - }) 397 - } 398 - 399 - pub fn submodule(&self, path: &Path) -> Option<Submodule> { 400 - if let Ok(Some(submodules)) = self.repository.modules() 401 - && let Some(name) = submodules.name_by_path(path.as_os_str().as_encoded_bytes().into()) 402 - { 403 - let url = submodules.url(name).ok()?.to_string(); 404 - let branch = submodules 405 - .branch(name) 406 - .ok()? 407 - .and_then(|branch| match branch { 408 - Branch::CurrentInSuperproject => None, 409 - Branch::Name(name) => Some(name.to_string()), 410 - }); 411 - 412 - let name = name.to_string(); 413 - return Some(Submodule { name, url, branch }); 414 - } 415 - 416 - None 417 - } 418 - } 419 - 420 - impl<S: Send + Sync> FromRequestParts<S> for TangledRepository 421 - where 422 - Knot: axum::extract::FromRef<S>, 423 - { 424 - type Rejection = XrpcError; 425 - 426 - async fn from_request_parts( 427 - parts: &mut axum::http::request::Parts, 428 - state: &S, 429 - ) -> Result<Self, Self::Rejection> { 430 - #[derive(Deserialize)] 431 - struct Param { 432 - repo: String, 433 - } 434 - 435 - let XrpcQuery(Param { repo }) = XrpcQuery::from_request_parts(parts, state).await?; 436 - let repo_path: RepositoryPath = repo.parse().map_err(errors::InvalidRequest)?; 437 - 438 - let knot = Knot::from_ref(state); 439 - let repo_key = knot 440 - .resolve_repo_key(&repo_path) 441 - .await 442 - .map_err(errors::RepoNotFound)?; 443 - 444 - // @review cardinality may be an issue. 445 - metrics::counter!( 446 - "knot_repo_requests_total", 447 - "did" => repo_key.owner_str().to_string(), 448 - "rkey" => repo_key.rkey().to_string(), 449 - "name" => repo_path.name().to_string() 450 - ) 451 - .increment(1); 452 - 453 - let repository = knot 454 - .open_repository(&repo_key) 455 - .await 456 - .map_err(errors::RepoNotFound)? 457 - .to_thread_local(); 458 - 459 - Ok(Self { 460 - knot, 461 - repo_key, 462 - repository, 463 - }) 464 84 } 465 85 } 466 86 ··· 101 557 /// when the object is dropped. 102 558 /// 103 559 #[derive(Debug)] 104 - struct TempWorktree<'repo> { 560 + pub struct TempWorktree<'repo> { 105 561 repo: &'repo gix::Repository, 106 562 107 563 /// Worktree name. ··· 196 652 /// # Panics 197 653 /// 198 654 /// Panics if `repo` is not a bare repository. 199 - fn build<'repo>(&self, repo: &'repo gix::Repository) -> io::Result<TempWorktree<'repo>> { 655 + pub fn build<'repo>(&self, repo: &'repo gix::Repository) -> io::Result<TempWorktree<'repo>> { 200 656 assert!(repo.is_bare(), "repository should be bare"); 201 657 202 658 let mut name = ··· 298 754 R: ops::Deref<Target = str>; 299 755 } 300 756 301 - impl ResolveRevspec for gix::Repository { 757 + impl ResolveRevspec for Repository { 302 758 fn resolve_revspec<'repo, R>( 303 759 &'repo self, 304 760 revspec: &Option<R>, ··· 332 788 } 333 789 } 334 790 335 - impl ResolveRevspec for TangledRepository { 336 - fn resolve_revspec<'repo, R>( 337 - &'repo self, 338 - revspec: &Option<R>, 339 - ) -> Result<ResolvedRevspec<'repo>, RevspecError> 340 - where 341 - R: ops::Deref<Target = str>, 342 - { 343 - self.repository.resolve_revspec(revspec) 344 - } 345 - } 346 - 347 791 pub trait RepositoryStatsExt { 348 792 fn count_commits(&self, start: &ObjectId, end: &ObjectId) -> BTreeMap<String, u64>; 349 793 350 794 fn language_breakdown(&self, at: &ObjectId) -> BTreeMap<String, u64>; 351 795 } 352 796 353 - impl RepositoryStatsExt for gix::Repository { 797 + impl RepositoryStatsExt for Repository { 354 798 fn count_commits(&self, start: &ObjectId, end: &ObjectId) -> BTreeMap<String, u64> { 355 799 let mut counts = BTreeMap::new(); 356 800
-104
crates/gordian-knot/src/model/repository/merge_check.rs
··· 1 - use std::borrow::Cow; 2 - use std::io::Write as _; 3 - use std::process::Stdio; 4 - 5 - use axum::Json; 6 - use gordian_lexicon::sh_tangled::repo::merge_check::ConflictInfo; 7 - use gordian_lexicon::sh_tangled::repo::merge_check::Output; 8 - 9 - use crate::model::errors; 10 - use crate::model::repository::ResolveRevspec as _; 11 - use crate::model::repository::ResolvedRevspec; 12 - use crate::model::repository::TempWorktree; 13 - use crate::public::xrpc::XrpcError; 14 - use crate::public::xrpc::XrpcResponse; 15 - 16 - impl super::TangledRepository { 17 - pub fn merge_check( 18 - &self, 19 - patch: String, 20 - branch: &str, 21 - ) -> Result<XrpcResponse<Json<Output>>, XrpcError> { 22 - let ResolvedRevspec { commit, immutable } = 23 - self.repository.resolve_revspec(&Some(branch.as_ref()))?; 24 - 25 - let worktree = TempWorktree::builder() 26 - .prefix("merge-check") 27 - .config(&self.knot.git_config_path()) 28 - .commit(&commit.id) 29 - .build(&self.repository) 30 - .map_err(errors::Internal)?; 31 - 32 - let mut child = self 33 - .git() 34 - .arg("-C") 35 - .arg(worktree.path()) 36 - .arg("apply") 37 - .arg("--check") 38 - .arg("--verbose") 39 - .arg("-") 40 - .stdin(Stdio::piped()) 41 - .stderr(Stdio::piped()) 42 - .spawn() 43 - .map_err(errors::Internal)?; 44 - 45 - let mut stdin = child.stdin.take().expect("handle present"); 46 - let writer = std::thread::spawn(move || stdin.write_all(patch.as_bytes())); 47 - let output = child.wait_with_output().map_err(errors::Internal)?; 48 - 49 - writer 50 - .join() 51 - .expect("thread should not panic") 52 - .map_err(errors::Internal)?; 53 - 54 - let errors = std::str::from_utf8(&output.stderr).map_err(errors::Internal)?; 55 - let conflicts = parse_git_apply_check_errors(errors); 56 - let is_conflicted = !output.status.success() && !conflicts.is_empty(); 57 - let message = is_conflicted.then_some(Cow::Borrowed("patch cannot be applied cleanly")); 58 - 59 - Ok(XrpcResponse { 60 - response: Json(Output { 61 - is_conflicted, 62 - conflicts, 63 - message, 64 - error: None, 65 - }), 66 - immutable, 67 - }) 68 - } 69 - } 70 - 71 - fn parse_git_apply_check_errors(stderr: &str) -> Vec<ConflictInfo> { 72 - let mut hunk_name = None; 73 - stderr 74 - .lines() 75 - .filter_map(|line| { 76 - let mut parts = line.splitn(3, ':').map(|s| s.trim()); 77 - match (parts.next(), parts.next(), parts.next()) { 78 - (Some("error"), Some("patch failed"), Some(hunk)) => { 79 - hunk_name = Some(hunk); 80 - None 81 - } 82 - (Some("error"), Some(filename), Some("already exists in working directory")) => { 83 - Some(ConflictInfo { 84 - filename: hunk_name.unwrap_or(filename).to_owned(), 85 - reason: Cow::Borrowed("file already exists"), 86 - }) 87 - } 88 - (Some("error"), Some(filename), Some("does not exist in working tree")) => { 89 - Some(ConflictInfo { 90 - filename: hunk_name.unwrap_or(filename).to_owned(), 91 - reason: Cow::Borrowed("file does not exist"), 92 - }) 93 - } 94 - (Some("error"), Some(filename), Some("patch does not apply")) => { 95 - Some(ConflictInfo { 96 - filename: hunk_name.unwrap_or(filename).to_owned(), 97 - reason: Cow::Borrowed("patch does not apply"), 98 - }) 99 - } 100 - _ => None, 101 - } 102 - }) 103 - .collect() 104 - }
+6 -8
crates/gordian-knot/src/private.rs
··· 21 21 use serde::Deserialize; 22 22 use serde::Serialize; 23 23 use time::OffsetDateTime; 24 - use tokio_rayon::AsyncThreadPool as _; 25 24 26 25 use crate::model::Knot; 27 26 use crate::model::errors; ··· 237 238 238 239 let commits = { 239 240 let repo = repo.to_thread_local(); 240 - knot.pool() 241 - .spawn_fifo_async(move || repo.count_commits(&new_sha, &old_sha)) 241 + tokio_rayon::spawn_fifo(move || repo.count_commits(&new_sha, &old_sha)) 242 242 }; 243 243 244 244 let languages = { 245 245 let repo = repo.to_thread_local(); 246 - knot.pool() 247 - .spawn_fifo_async(move || repo.language_breakdown(&new_sha)) 246 + tokio_rayon::spawn_fifo(move || repo.language_breakdown(&new_sha)) 248 247 }; 249 248 250 249 let (commits, languages) = tokio::join!(commits, languages); ··· 310 313 let repository: TangledRepository = (knot.clone(), repo_key.clone(), repo).into(); 311 314 312 315 // Schedule a maintenance run. 313 - knot.pool().spawn(move || { 314 - // We can do anything if this fails. 315 - let _ = run_maintenance(repo_key, repository).inspect_err(|error| tracing::error!(?error)); 316 + // 317 + // We cannot do anything if this fails. 318 + let _ = tokio_rayon::spawn(move || { 319 + run_maintenance(repo_key, repository).inspect_err(|error| tracing::error!(?error)) 316 320 }); 317 321 318 322 Ok(StatusCode::NO_CONTENT)
+9 -30
crates/gordian-knot/src/public/xrpc.rs
··· 7 7 use axum::http::StatusCode; 8 8 use axum::response::IntoResponse; 9 9 use gordian_identity::Resolver; 10 + use serde::Deserialize; 10 11 use serde::de::DeserializeOwned; 11 12 12 13 use crate::model::Knot; 13 14 use crate::model::errors; 14 - use crate::model::repository::BlobError; 15 + use crate::model::knot_state::RepositoryProvider; 15 16 use crate::model::repository::RevspecError; 16 - use crate::model::repository::TreeError; 17 17 18 18 pub mod sh_tangled; 19 19 20 - pub fn router<S: Clone + Send + Sync + 'static>() -> Router<S> 20 + pub fn router<S: RepositoryProvider + Clone + Send + Sync + 'static>() -> Router<S> 21 21 where 22 22 Knot: FromRef<S>, 23 23 Resolver: FromRef<S>, ··· 97 97 } 98 98 } 99 99 100 - #[derive(Debug, Default)] 100 + #[derive(Debug, Default, Deserialize)] 101 101 pub struct XrpcError { 102 + #[serde(skip)] 102 103 pub status: StatusCode, 103 104 pub error: Cow<'static, str>, 104 105 pub message: Cow<'static, str>, ··· 234 233 } 235 234 } 236 235 237 - impl From<BlobError> for XrpcError { 238 - fn from(value: BlobError) -> Self { 239 - let (status, error) = match value { 240 - BlobError::PathLookupError(_) => (StatusCode::NOT_FOUND, "FileNotFound"), 241 - BlobError::FileNotFound => (StatusCode::NOT_FOUND, "FileNotFound"), 242 - BlobError::NotABlob => (StatusCode::BAD_REQUEST, "InvalidRequest"), 243 - }; 244 - Self { 245 - status, 246 - error: error.into(), 247 - message: format!("{value}").into(), 248 - } 249 - } 250 - } 251 - 252 - impl From<TreeError> for XrpcError { 253 - fn from(value: TreeError) -> Self { 254 - Self { 255 - status: StatusCode::NOT_FOUND, 256 - error: "FileNotFound".into(), 257 - message: format!("{value}").into(), 258 - } 259 - } 260 - } 261 - 262 236 impl From<crate::types::repository_spec::Error> for XrpcError { 263 237 fn from(value: crate::types::repository_spec::Error) -> Self { 264 238 Self::new(StatusCode::BAD_REQUEST, "InvalidRequest", value.to_string()) ··· 267 291 gix::open::Error::Io(error) if error.kind() == std::io::ErrorKind::NotFound => { 268 292 Self::new(StatusCode::NOT_FOUND, "RepoNotFound", error.to_string()) 269 293 } 270 - _ => Self::new( 294 + error @ gix::open::Error::NotARepository { .. } => { 295 + Self::new(StatusCode::NOT_FOUND, "RepoNotFound", error.to_string()) 296 + } 297 + error => Self::new( 271 298 StatusCode::INTERNAL_SERVER_ERROR, 272 299 "RepoNotFound", 273 300 error.to_string(),
+33 -32
crates/gordian-knot/src/public/xrpc/sh_tangled/repo.rs
··· 1 1 use gordian_identity::Resolver; 2 2 3 3 use crate::model::Knot; 4 + use crate::model::knot_state::RepositoryProvider; 4 5 5 - mod impl_archive; 6 - mod impl_blob; 7 - mod impl_branch; 8 - mod impl_branches; 9 - mod impl_compare; 10 - mod impl_create; 11 - mod impl_delete; 12 - mod impl_diff; 13 - mod impl_get_default_branch; 14 - mod impl_languages; 15 - mod impl_log; 16 - mod impl_merge_check; 17 - mod impl_set_default_branch; 18 - mod impl_tags; 19 - mod impl_tree; 6 + pub mod archive; 7 + pub mod blob; 8 + pub mod branch; 9 + pub mod branches; 10 + pub mod compare; 11 + pub mod create; 12 + pub mod delete; 13 + pub mod diff; 14 + pub mod get_default_branch; 15 + pub mod languages; 16 + pub mod log; 17 + pub mod merge_check; 18 + pub mod set_default_branch; 19 + pub mod tags; 20 + pub mod tree; 20 21 21 22 macro_rules! impl_xrpc { 22 23 (QUERY, $name:ident, $module:ident) => { 23 24 pub fn $name<S>() -> axum::Router<S> 24 25 where 25 - S: Clone + Send + Sync + 'static, 26 + S: RepositoryProvider + Clone + Send + Sync + 'static, 26 27 Knot: axum::extract::FromRef<S>, 27 28 { 28 29 use $module::LXM; ··· 34 33 (PROCEDURE, $name:ident, $module:ident) => { 35 34 pub fn $name<S>() -> axum::Router<S> 36 35 where 37 - S: Clone + Send + Sync + 'static, 36 + S: RepositoryProvider + Clone + Send + Sync + 'static, 38 37 Knot: axum::extract::FromRef<S>, 39 38 Resolver: axum::extract::FromRef<S>, 40 39 { ··· 45 44 }; 46 45 } 47 46 48 - impl_xrpc!(QUERY, archive, impl_archive); 49 - impl_xrpc!(QUERY, blob, impl_blob); 50 - impl_xrpc!(QUERY, branch, impl_branch); 51 - impl_xrpc!(QUERY, branches, impl_branches); 52 - impl_xrpc!(QUERY, compare, impl_compare); 53 - impl_xrpc!(QUERY, diff, impl_diff); 54 - impl_xrpc!(QUERY, get_default_branch, impl_get_default_branch); 55 - impl_xrpc!(QUERY, languages, impl_languages); 56 - impl_xrpc!(QUERY, log, impl_log); 57 - impl_xrpc!(QUERY, tags, impl_tags); 58 - impl_xrpc!(QUERY, tree, impl_tree); 47 + impl_xrpc!(QUERY, archive, archive); 48 + impl_xrpc!(QUERY, blob, blob); 49 + impl_xrpc!(QUERY, branch, branch); 50 + impl_xrpc!(QUERY, branches, branches); 51 + impl_xrpc!(QUERY, compare, compare); 52 + impl_xrpc!(QUERY, diff, diff); 53 + impl_xrpc!(QUERY, get_default_branch, get_default_branch); 54 + impl_xrpc!(QUERY, languages, languages); 55 + impl_xrpc!(QUERY, log, log); 56 + impl_xrpc!(QUERY, tags, tags); 57 + impl_xrpc!(QUERY, tree, tree); 59 58 60 - impl_xrpc!(PROCEDURE, create, impl_create); 61 - impl_xrpc!(PROCEDURE, delete, impl_delete); 62 - impl_xrpc!(PROCEDURE, merge_check, impl_merge_check); 63 - impl_xrpc!(PROCEDURE, set_default_branch, impl_set_default_branch); 59 + impl_xrpc!(PROCEDURE, create, create); 60 + impl_xrpc!(PROCEDURE, delete, delete); 61 + impl_xrpc!(PROCEDURE, merge_check, merge_check); 62 + impl_xrpc!(PROCEDURE, set_default_branch, set_default_branch);
+210
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/blob.rs
··· 1 + use std::path::Path; 2 + use std::path::PathBuf; 3 + 4 + use axum::Json; 5 + use axum::http::HeaderMap; 6 + use axum::http::HeaderValue; 7 + use axum::http::StatusCode; 8 + use axum::response::IntoResponse; 9 + use axum::response::Response; 10 + use data_encoding::BASE64; 11 + use gix::Repository; 12 + use gix::submodule::config::Branch; 13 + use gordian_lexicon::sh_tangled::repo::blob::Encoding; 14 + use gordian_lexicon::sh_tangled::repo::blob::Input; 15 + use gordian_lexicon::sh_tangled::repo::blob::Output; 16 + use gordian_lexicon::sh_tangled::repo::blob::Submodule; 17 + use mimetype_detector::MimeType; 18 + use reqwest::header::CACHE_CONTROL; 19 + use reqwest::header::CONTENT_TYPE; 20 + use reqwest::header::ETAG; 21 + 22 + use crate::extract::if_none_match::EntityTag; 23 + use crate::extract::if_none_match::IfNoneMatch; 24 + use crate::extract::repository::XrpcRepository; 25 + use crate::model::repository::ResolveRevspec as _; 26 + use crate::model::repository::ResolvedRevspec; 27 + use crate::public::xrpc::XrpcError; 28 + use crate::public::xrpc::XrpcQuery; 29 + 30 + pub const LXM: &str = "/sh.tangled.repo.blob"; 31 + 32 + #[tracing::instrument(target = "sh_tangled::repo::blob", skip(if_none_match), err)] 33 + pub async fn handle( 34 + XrpcRepository(repository): XrpcRepository, 35 + XrpcQuery(Input { 36 + repo, 37 + rev, 38 + path, 39 + raw, 40 + }): XrpcQuery<Input>, 41 + if_none_match: IfNoneMatch, 42 + ) -> Result<Response, XrpcError> { 43 + tokio_rayon::spawn(move || { 44 + let repository = repository.to_thread_local(); 45 + 46 + let ResolvedRevspec { commit, immutable } = 47 + repository.resolve_revspec(&Some(rev.as_str()))?; 48 + 49 + // Use the tree object ID as an entity tag. 50 + // 51 + // 1. If the blob content has changed, the blob object ID will be different, and 52 + // therefore the tree object ID will also be different. 53 + // 54 + // 2. Using the tree object ID avoids searching the tree for the blob path. 55 + 56 + let tree = get_tree(&commit)?; 57 + let etag = EntityTag::strong(tree.id.to_hex().to_string()); 58 + if if_none_match.match_weak(&etag) { 59 + return Ok(StatusCode::NOT_MODIFIED.into_response()); 60 + } 61 + 62 + let mut response = match submodule(&repository, &path) { 63 + Some(submodule) => Json(blob_submodule(rev, path, submodule)).into_response(), 64 + None => { 65 + let buffer = get_blob(&tree, &path)?; 66 + let mime_type = mimetype_detector::detect(&buffer); 67 + match raw { 68 + true => blob_raw(mime_type, buffer), 69 + false => Json(blob_json(rev, path, mime_type, buffer)).into_response(), 70 + } 71 + } 72 + }; 73 + 74 + let headers = response.headers_mut(); 75 + if immutable { 76 + headers.insert( 77 + CACHE_CONTROL, 78 + HeaderValue::from_static("public, immutable, s-maxage=604800"), 79 + ); 80 + } 81 + 82 + headers.insert( 83 + ETAG, 84 + etag.to_header_value() 85 + .expect("Hex-string should be a valid header value"), 86 + ); 87 + 88 + Ok(response) 89 + }) 90 + .await 91 + } 92 + 93 + fn blob_submodule(rev: String, path: PathBuf, submodule: Submodule) -> Output { 94 + Output { 95 + rev, 96 + path, 97 + content: None, 98 + encoding: None, 99 + size: None, 100 + is_binary: None, 101 + mime_type: None, 102 + submodule: Some(submodule), 103 + last_commit: None, 104 + } 105 + } 106 + 107 + fn blob_json(rev: String, path: PathBuf, mime_type: &MimeType, buffer: Vec<u8>) -> Output { 108 + let size = buffer.len(); 109 + let (content, encoding, is_binary) = match String::from_utf8(buffer) { 110 + Ok(content) => (content, Encoding::Utf8, false), 111 + Err(error) => (BASE64.encode(error.as_bytes()), Encoding::Base64, true), 112 + }; 113 + 114 + Output { 115 + rev, 116 + path, 117 + encoding: Some(encoding), 118 + size: Some(size), 119 + content: Some(content), 120 + mime_type: Some(mime_type.mime().into()), 121 + is_binary: Some(is_binary), 122 + submodule: None, 123 + last_commit: None, 124 + } 125 + } 126 + 127 + fn blob_raw(mime_type: &MimeType, buffer: Vec<u8>) -> Response { 128 + let mut headers = HeaderMap::new(); 129 + headers.insert( 130 + CONTENT_TYPE, 131 + HeaderValue::from_str(mime_type.mime()).expect("MIME type should be a valid header value"), 132 + ); 133 + 134 + (headers, buffer).into_response() 135 + } 136 + 137 + fn submodule(repository: &Repository, path: &Path) -> Option<Submodule> { 138 + if let Ok(Some(submodules)) = repository.modules() 139 + && let Some(name) = submodules.name_by_path(path.as_os_str().as_encoded_bytes().into()) 140 + { 141 + let url = submodules.url(name).ok()?.to_string(); 142 + let branch = submodules 143 + .branch(name) 144 + .ok()? 145 + .and_then(|branch| match branch { 146 + Branch::CurrentInSuperproject => None, 147 + Branch::Name(name) => Some(name.to_string()), 148 + }); 149 + 150 + let name = name.to_string(); 151 + return Some(Submodule { name, url, branch }); 152 + } 153 + 154 + None 155 + } 156 + 157 + fn get_blob(tree: &gix::Tree<'_>, path: &Path) -> Result<Vec<u8>, BlobError> { 158 + let entry = tree 159 + .lookup_entry_by_path(path)? 160 + .ok_or(BlobError::FileNotFound)?; 161 + 162 + if !(entry.mode().is_blob() || entry.mode().is_link()) { 163 + return Err(BlobError::NotABlob); 164 + } 165 + 166 + Ok(entry.object()?.into_blob().take_data()) 167 + } 168 + 169 + fn get_tree<'repo>(commit: &gix::Commit<'repo>) -> Result<gix::Tree<'repo>, TreeError> { 170 + Ok(commit.tree()?) 171 + } 172 + 173 + #[derive(Debug, thiserror::Error)] 174 + #[error(transparent)] 175 + pub struct TreeError(#[from] gix::object::commit::Error); 176 + 177 + #[derive(Debug, thiserror::Error)] 178 + pub enum BlobError { 179 + #[error(transparent)] 180 + PathLookupError(#[from] gix::object::find::existing::Error), 181 + #[error("File not found at the specified path")] 182 + FileNotFound, 183 + #[error("Requested path is not a blob")] 184 + NotABlob, 185 + } 186 + 187 + impl From<BlobError> for XrpcError { 188 + fn from(value: BlobError) -> Self { 189 + let (status, error) = match value { 190 + BlobError::PathLookupError(_) => (StatusCode::NOT_FOUND, "FileNotFound"), 191 + BlobError::FileNotFound => (StatusCode::NOT_FOUND, "FileNotFound"), 192 + BlobError::NotABlob => (StatusCode::BAD_REQUEST, "InvalidRequest"), 193 + }; 194 + Self { 195 + status, 196 + error: error.into(), 197 + message: format!("{value}").into(), 198 + } 199 + } 200 + } 201 + 202 + impl From<TreeError> for XrpcError { 203 + fn from(value: TreeError) -> Self { 204 + Self { 205 + status: StatusCode::NOT_FOUND, 206 + error: "FileNotFound".into(), 207 + message: format!("{value}").into(), 208 + } 209 + } 210 + }
+62
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/branch.rs
··· 1 + use axum::Json; 2 + 3 + use crate::extract::repository::XrpcRepository; 4 + use crate::lexicon::sh_tangled::repo::branch::Input; 5 + use crate::lexicon::sh_tangled::repo::branch::Output; 6 + use crate::model::convert; 7 + use crate::model::errors::HeadDetached; 8 + use crate::model::errors::RefNotFound; 9 + use crate::model::errors::RepoError; 10 + use crate::public::xrpc::XrpcQuery; 11 + use crate::public::xrpc::XrpcResult; 12 + 13 + pub const LXM: &str = "/sh.tangled.repo.branch"; 14 + 15 + #[tracing::instrument(target = "sh_tangled::repo::branch", err)] 16 + pub async fn handle( 17 + XrpcRepository(repository): XrpcRepository, 18 + XrpcQuery(params): XrpcQuery<Input>, 19 + ) -> XrpcResult<Json<Output>> { 20 + tokio_rayon::spawn(move || { 21 + let repository = repository.to_thread_local(); 22 + 23 + let mut reference = repository 24 + .find_reference(&params.name) 25 + .map_err(RefNotFound)?; 26 + 27 + let commit = reference.peel_to_commit().map_err(RefNotFound)?; 28 + let name = reference.name().shorten().to_string(); 29 + let hash = commit.id.into(); 30 + let time = commit 31 + .committer() 32 + .map_err(RepoError)? 33 + .time() 34 + .map_err(RepoError)?; 35 + 36 + let when = convert::time_to_offsetdatetime(&time).map_err(RepoError)?; 37 + let author = convert::try_convert_signature(commit.author().map_err(RepoError)?)?; 38 + let message = commit.message().map_err(RepoError)?.summary().to_string(); 39 + 40 + // Assume HEAD points to the intended default branch. This *should* be 41 + // true for a bare repository. 42 + let head = repository.head()?; 43 + let default_name = head 44 + .referent_name() 45 + .ok_or(HeadDetached)? 46 + .shorten() 47 + .to_string(); 48 + 49 + let is_default = default_name == name; 50 + 51 + Ok(Json(Output { 52 + name, 53 + hash, 54 + when, 55 + author, 56 + message, 57 + is_default, 58 + }) 59 + .into()) 60 + }) 61 + .await 62 + }
+205
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/branches.rs
··· 1 + use axum::Json; 2 + use gordian_lexicon::sh_tangled::repo::refs::Commit; 3 + use gordian_lexicon::sh_tangled::repo::refs::Reference; 4 + use serde::Deserialize; 5 + use serde::Serialize; 6 + 7 + use crate::extract::repository::XrpcRepository; 8 + pub use crate::lexicon::sh_tangled::repo::branches::Input; 9 + use crate::model::convert; 10 + use crate::model::errors; 11 + use crate::public::xrpc::XrpcQuery; 12 + use crate::public::xrpc::XrpcResult; 13 + 14 + pub const LXM: &str = "/sh.tangled.repo.branches"; 15 + 16 + /// Output of `sh.tangled.repo.branches` query. 17 + #[derive(Debug, Default, Deserialize, Serialize)] 18 + #[serde(deny_unknown_fields, rename_all = "camelCase")] 19 + pub struct Output { 20 + #[serde(skip_serializing_if = "Vec::is_empty")] 21 + pub branches: Vec<Branch>, 22 + } 23 + 24 + #[derive(Debug, PartialEq, Eq, Deserialize, Serialize)] 25 + #[serde(deny_unknown_fields, rename_all = "snake_case")] 26 + pub struct Branch { 27 + pub reference: Reference, 28 + pub commit: Commit, 29 + pub is_default: bool, 30 + } 31 + 32 + pub type Response = XrpcResult<Json<Output>>; 33 + 34 + #[tracing::instrument(target = "sh_tangled::repo::branches", err)] 35 + pub async fn handle( 36 + XrpcRepository(repository): XrpcRepository, 37 + XrpcQuery(Input { 38 + repo, 39 + limit, 40 + cursor, 41 + }): XrpcQuery<Input>, 42 + ) -> Response { 43 + tokio_rayon::spawn(move || { 44 + let repository = repository.to_thread_local(); 45 + 46 + // Assume HEAD points to the intended default branch. This *should* be 47 + // true for a bare repository. 48 + let head = repository.head()?; 49 + let default_name = head 50 + .referent_name() 51 + .ok_or(errors::HeadDetached)? 52 + .shorten() 53 + .to_string(); 54 + 55 + let mut branches = Vec::new(); 56 + for branch in repository 57 + .references()? 58 + .local_branches()? 59 + .skip(cursor.into()) 60 + .take(limit.into()) 61 + { 62 + let Ok(branch) = branch.inspect_err(|error| tracing::error!(?error)) else { 63 + continue; 64 + }; 65 + 66 + let name = branch.name().shorten().to_string(); 67 + let Some(id) = branch.try_id() else { 68 + tracing::warn!(?name, "branch unborn, skipping"); 69 + continue; 70 + }; 71 + 72 + let Ok(commit) = repository.find_commit(id) else { 73 + tracing::error!(?name, ?id, "failed to find commit for branch"); 74 + continue; 75 + }; 76 + 77 + let is_default = name == default_name; 78 + branches.push(Branch { 79 + reference: Reference { 80 + name, 81 + hash: commit.id.into(), 82 + }, 83 + commit: convert::try_convert_commit(commit)?, 84 + is_default, 85 + }); 86 + } 87 + 88 + Ok(Json(Output { branches }).into()) 89 + }) 90 + .await 91 + } 92 + 93 + #[cfg(test)] 94 + mod tests { 95 + use std::fs; 96 + use std::path::Path; 97 + use std::process::Command; 98 + 99 + use gordian_lexicon::extra::objectid::ObjectId; 100 + use gordian_lexicon::sh_tangled::repo::refs::Signature; 101 + 102 + use super::Branch; 103 + use super::Output; 104 + use crate::model::knot_state::RepositoryProvider; 105 + use crate::test_helpers::TestClient; 106 + use crate::types::repository_spec::RepositoryKey; 107 + 108 + fn setup() { 109 + let test_repos = Path::new(env!("CARGO_MANIFEST_DIR")).join("test-repositories"); 110 + if !fs::exists(&test_repos).unwrap() { 111 + fs::create_dir_all(&test_repos).unwrap(); 112 + } 113 + 114 + let core = test_repos.join("did:plc:wshs7t2adsemcrrd4snkeqli/core"); 115 + if !fs::exists(&core).unwrap() { 116 + assert!( 117 + Command::new("git") 118 + .args(["clone", "--bare", "https://tangled.org/tangled.org/core"]) 119 + .arg(&core) 120 + .status() 121 + .unwrap() 122 + .success(), 123 + "failed to setup test repository" 124 + ); 125 + } 126 + 127 + eprintln!("resetting {core:?}"); 128 + assert!( 129 + Command::new("git") 130 + .args(["-C"]) 131 + .arg(core) 132 + .args(["reset", "--soft", "v1.12.0-alpha"]) 133 + .status() 134 + .unwrap() 135 + .success() 136 + ); 137 + } 138 + 139 + fn app() -> axum::Router { 140 + #[derive(Clone)] 141 + struct TestState; 142 + 143 + impl RepositoryProvider for TestState { 144 + fn path_for_repository(&self, key: &RepositoryKey) -> std::path::PathBuf { 145 + Path::new(env!("CARGO_MANIFEST_DIR")) 146 + .join("test-repositories") 147 + .join(key.owner_str()) 148 + .join(key.rkey()) 149 + } 150 + } 151 + 152 + setup(); 153 + axum::Router::new() 154 + .route(super::LXM, axum::routing::get(super::handle)) 155 + .with_state(TestState) 156 + } 157 + 158 + #[tokio::test] 159 + async fn can_list_branches() { 160 + let client = TestClient::new(app()); 161 + 162 + let response = client 163 + .get("/sh.tangled.repo.branches?repo=did:plc:wshs7t2adsemcrrd4snkeqli/core&limit=500") 164 + .await 165 + .ok(); 166 + 167 + let Output { branches } = response.json().await; 168 + let Branch { 169 + reference, 170 + commit, 171 + is_default, 172 + } = branches 173 + .iter() 174 + .find(|branch| branch.reference.name == "push-mozrrovxmlou") 175 + .expect("branch 'push-mozrrovxmlou' should exist in test repo"); 176 + 177 + let commit_oid: ObjectId = "214dc688ec8cccc0b58c4418c629ce16954d8442" 178 + .parse::<gix::ObjectId>() 179 + .unwrap() 180 + .into(); 181 + 182 + assert_eq!(reference.hash, commit_oid); 183 + assert_eq!(commit.hash, commit_oid); 184 + 185 + assert_eq!( 186 + commit.author, 187 + Signature { 188 + name: "Anirudh Oppiliappan".to_string(), 189 + email: "anirudh@tangled.org".to_string(), 190 + when: time::macros::datetime!(2025-09-29 14:01:13 UTC) 191 + } 192 + ); 193 + 194 + assert_eq!( 195 + commit.committer, 196 + Signature { 197 + name: "Anirudh Oppiliappan".to_string(), 198 + email: "anirudh@tangled.org".to_string(), 199 + when: time::macros::datetime!(2025-09-29 17:28:35 +03) 200 + } 201 + ); 202 + 203 + assert!(!is_default); 204 + } 205 + }
+81
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/compare.rs
··· 1 + use std::collections::HashSet; 2 + use std::process::Command; 3 + 4 + use axum::Json; 5 + 6 + use crate::extract::repository::XrpcRepository; 7 + use crate::model::errors::Internal; 8 + use crate::model::errors::RepoError; 9 + use crate::model::repository::ResolveRevspec as _; 10 + use crate::model::repository::ResolvedRevspec; 11 + use crate::public::xrpc::XrpcQuery; 12 + use crate::public::xrpc::XrpcResponse; 13 + use crate::public::xrpc::XrpcResult; 14 + use crate::types::sh_tangled::repo::compare::Input; 15 + use crate::types::sh_tangled::repo::compare::Output; 16 + 17 + pub const LXM: &str = "/sh.tangled.repo.compare"; 18 + 19 + #[tracing::instrument(target = "sh_tangled::repo::compare", err)] 20 + pub async fn handle( 21 + XrpcRepository(repository): XrpcRepository, 22 + XrpcQuery(params): XrpcQuery<Input>, 23 + ) -> XrpcResult<Json<Output>> { 24 + tokio_rayon::spawn(move || { 25 + let repository = repository.to_thread_local(); 26 + 27 + let ResolvedRevspec { 28 + commit: rev1, 29 + immutable: rev1_immutable, 30 + } = repository.resolve_revspec(&Some(params.rev1.as_ref()))?; 31 + 32 + let ResolvedRevspec { 33 + commit: rev2, 34 + immutable: rev2_immutable, 35 + } = repository.resolve_revspec(&Some(params.rev2.as_ref()))?; 36 + 37 + let mut seen = HashSet::new(); 38 + let mut commits = repository 39 + .rev_walk([rev2.id]) 40 + .with_hidden([rev1.id]) 41 + .selected(|oid| seen.insert(oid.to_owned())) 42 + .map_err(RepoError)? 43 + .take_while(|val| { 44 + val.as_ref() 45 + .is_ok_and(|commit| commit.parent_ids.len() == 1) 46 + }) 47 + .collect::<Result<Vec<_>, _>>() 48 + .map_err(RepoError)?; 49 + 50 + commits.reverse(); 51 + 52 + let mut format_patch_raw = String::new(); 53 + for commit in commits { 54 + let output = Command::new("git") 55 + .current_dir(repository.path()) 56 + .arg("-C") 57 + .arg(repository.path()) 58 + .args([ 59 + "format-patch", 60 + "-1", 61 + &commit.id.to_hex().to_string(), 62 + "--stdout", 63 + ]) 64 + .output() 65 + .map_err(Internal)?; 66 + 67 + format_patch_raw.push_str(&String::from_utf8_lossy(&output.stdout)); 68 + format_patch_raw.push('\n'); 69 + } 70 + 71 + Ok(XrpcResponse { 72 + response: Json(Output { 73 + rev1: rev1.id.into(), 74 + rev2: rev2.id.into(), 75 + format_patch_raw, 76 + }), 77 + immutable: rev1_immutable && rev2_immutable, 78 + }) 79 + }) 80 + .await 81 + }
+34
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/diff.rs
··· 1 + use axum::Json; 2 + 3 + use crate::extract::repository::XrpcRepository; 4 + use crate::model::nicediff; 5 + use crate::model::repository::ResolveRevspec as _; 6 + use crate::model::repository::ResolvedRevspec; 7 + use crate::public::xrpc::XrpcQuery; 8 + use crate::public::xrpc::XrpcResponse; 9 + use crate::public::xrpc::XrpcResult; 10 + use crate::types::sh_tangled::repo::diff::Input; 11 + use crate::types::sh_tangled::repo::diff::Output; 12 + 13 + pub const LXM: &str = "/sh.tangled.repo.diff"; 14 + 15 + #[tracing::instrument(target = "sh_tangled::repo::diff", err)] 16 + pub async fn handle( 17 + XrpcRepository(repository): XrpcRepository, 18 + XrpcQuery(Input { repo, rev }): XrpcQuery<Input>, 19 + ) -> XrpcResult<Json<Output>> { 20 + tokio_rayon::spawn(move || { 21 + let repository = repository.to_thread_local(); 22 + 23 + let ResolvedRevspec { commit, immutable } = 24 + repository.resolve_revspec(&Some(rev.as_ref()))?; 25 + 26 + let diff = nicediff::unified_diff_from_parent(commit).unwrap(); 27 + 28 + Ok(XrpcResponse { 29 + response: Json(Output { rev, diff }), 30 + immutable, 31 + }) 32 + }) 33 + .await 34 + }
+45
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/get_default_branch.rs
··· 1 + use axum::Json; 2 + 3 + use crate::extract::repository::XrpcRepository; 4 + use crate::lexicon::sh_tangled::repo::get_default_branch::Input; 5 + use crate::lexicon::sh_tangled::repo::get_default_branch::Output; 6 + use crate::model::convert; 7 + use crate::model::errors::HeadDetached; 8 + use crate::public::xrpc::XrpcQuery; 9 + use crate::public::xrpc::XrpcResult; 10 + 11 + pub const LXM: &str = "/sh.tangled.repo.getDefaultBranch"; 12 + 13 + #[tracing::instrument(target = "sh_tangled::repo::getDefaultBranch", err)] 14 + pub async fn handle( 15 + XrpcRepository(repository): XrpcRepository, 16 + XrpcQuery(Input { repo }): XrpcQuery<Input>, 17 + ) -> XrpcResult<Json<Output>> { 18 + tokio_rayon::spawn(move || { 19 + let repository = repository.to_thread_local(); 20 + 21 + // Assume HEAD points the intended default branch. This *should* be true 22 + // for a bare repository. 23 + let mut head = repository.head()?; 24 + let name = head 25 + .referent_name() 26 + .ok_or(HeadDetached)? 27 + .shorten() 28 + .to_string(); 29 + 30 + let hash = head.id().map(|id| id.detach().into()); 31 + let when = head 32 + .peel_to_commit() 33 + .ok() 34 + .and_then(|commit| { 35 + commit 36 + .committer() 37 + .ok() 38 + .and_then(|committer| committer.time().ok()) 39 + }) 40 + .and_then(|time| convert::time_to_offsetdatetime(&time).ok()); 41 + 42 + Ok(Json(Output { name, hash, when }).into()) 43 + }) 44 + .await 45 + }
+9 -5
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_archive.rs crates/gordian-knot/src/public/xrpc/sh_tangled/repo/archive.rs
··· 9 9 use axum_extra::body::AsyncReadBody; 10 10 use gordian_lexicon::sh_tangled::repo::archive::Format; 11 11 use gordian_lexicon::sh_tangled::repo::archive::Input; 12 + use tokio::process::Command; 12 13 13 14 use crate::command::CommandExt as _; 15 + use crate::extract::repository::XrpcRepository; 14 16 use crate::model::Knot; 15 17 use crate::model::errors; 16 18 use crate::model::repository::ResolvedRevspec; 17 - use crate::model::repository::TangledRepository; 18 19 use crate::public::xrpc::XrpcError; 19 20 use crate::public::xrpc::XrpcQuery; 20 21 use crate::types::repository_spec::RepositoryPath; ··· 24 23 25 24 const SPAWN_WAIT: Duration = Duration::from_millis(100); 26 25 27 - #[tracing::instrument(target = "sh_tangled::repo::archive", skip(knot, repository), err)] 26 + #[tracing::instrument(target = "sh_tangled::repo::archive", err)] 28 27 pub async fn handle( 29 28 State(knot): State<Knot>, 29 + XrpcRepository(repository): XrpcRepository, 30 30 XrpcQuery(Input { 31 31 repo, 32 32 rev, 33 33 format, 34 34 prefix, 35 35 }): XrpcQuery<Input>, 36 - repository: TangledRepository, 37 36 ) -> Result<impl IntoResponse, XrpcError> { 38 37 use axum::http::header; 39 38 40 39 use crate::model::repository::ResolveRevspec as _; 40 + 41 + let repository = repository.to_thread_local(); 41 42 42 43 let repo_path: RepositoryPath = repo 43 44 .parse() ··· 58 55 format, 59 56 ); 60 57 61 - let mut command: tokio::process::Command = repository.git().into(); 62 - let child = command 58 + let child = Command::new("git") 59 + .arg("-C") 60 + .arg(repository.path()) 63 61 .arg("archive") 64 62 .arg(format!("--format={format}")) 65 63 .option_arg(prefix.map(|prefix| format!("--prefix={prefix}/")))
-139
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_blob.rs
··· 1 - use std::path::PathBuf; 2 - 3 - use axum::Json; 4 - use axum::extract::State; 5 - use axum::http::HeaderMap; 6 - use axum::http::HeaderValue; 7 - use axum::http::StatusCode; 8 - use axum::response::IntoResponse; 9 - use axum::response::Response; 10 - use data_encoding::BASE64; 11 - use gordian_lexicon::sh_tangled::repo::blob::Encoding; 12 - use gordian_lexicon::sh_tangled::repo::blob::Input; 13 - use gordian_lexicon::sh_tangled::repo::blob::Output; 14 - use gordian_lexicon::sh_tangled::repo::blob::Submodule; 15 - use mimetype_detector::MimeType; 16 - use reqwest::header::CACHE_CONTROL; 17 - use reqwest::header::CONTENT_TYPE; 18 - use reqwest::header::ETAG; 19 - use tokio_rayon::AsyncThreadPool as _; 20 - 21 - use crate::extract::if_none_match::EntityTag; 22 - use crate::extract::if_none_match::IfNoneMatch; 23 - use crate::model::Knot; 24 - use crate::model::repository::ResolveRevspec as _; 25 - use crate::model::repository::ResolvedRevspec; 26 - use crate::model::repository::TangledRepository; 27 - use crate::public::xrpc::XrpcError; 28 - use crate::public::xrpc::XrpcQuery; 29 - 30 - pub const LXM: &str = "/sh.tangled.repo.blob"; 31 - 32 - #[tracing::instrument( 33 - target = "sh_tangled::repo::blob", 34 - skip(knot, if_none_match, repository), 35 - err 36 - )] 37 - pub async fn handle( 38 - State(knot): State<Knot>, 39 - if_none_match: IfNoneMatch, 40 - XrpcQuery(Input { 41 - repo, 42 - rev, 43 - path, 44 - raw, 45 - }): XrpcQuery<Input>, 46 - repository: TangledRepository, 47 - ) -> Result<Response, XrpcError> { 48 - knot.pool() 49 - .spawn_async(move || { 50 - let ResolvedRevspec { commit, immutable } = 51 - repository.resolve_revspec(&Some(rev.as_str()))?; 52 - 53 - // Use the tree object ID as an entity tag. 54 - // 55 - // 1. If the blob content has changed, the blob object ID will be different, and 56 - // therefore the tree object ID will also be different. 57 - // 58 - // 2. Using the tree object ID avoids searching the tree for the blob path. 59 - 60 - let tree = repository.get_tree(&commit)?; 61 - let etag = EntityTag::strong(tree.id.to_hex().to_string()); 62 - if if_none_match.match_weak(&etag) { 63 - return Ok(StatusCode::NOT_MODIFIED.into_response()); 64 - } 65 - 66 - let mut response = match repository.submodule(&path) { 67 - Some(submodule) => Json(blob_submodule(rev, path, submodule)).into_response(), 68 - None => { 69 - let buffer = repository.get_blob(&tree, &path)?; 70 - let mime_type = mimetype_detector::detect(&buffer); 71 - match raw { 72 - true => blob_raw(mime_type, buffer), 73 - false => Json(blob_json(rev, path, mime_type, buffer)).into_response(), 74 - } 75 - } 76 - }; 77 - 78 - let headers = response.headers_mut(); 79 - if immutable { 80 - headers.insert( 81 - CACHE_CONTROL, 82 - HeaderValue::from_static("public, immutable, s-maxage=604800"), 83 - ); 84 - } 85 - 86 - headers.insert( 87 - ETAG, 88 - etag.to_header_value() 89 - .expect("Hex-string should be a valid header value"), 90 - ); 91 - 92 - Ok(response) 93 - }) 94 - .await 95 - } 96 - 97 - fn blob_submodule(rev: String, path: PathBuf, submodule: Submodule) -> Output { 98 - Output { 99 - rev, 100 - path, 101 - content: None, 102 - encoding: None, 103 - size: None, 104 - is_binary: None, 105 - mime_type: None, 106 - submodule: Some(submodule), 107 - last_commit: None, 108 - } 109 - } 110 - 111 - fn blob_json(rev: String, path: PathBuf, mime_type: &MimeType, buffer: Vec<u8>) -> Output { 112 - let size = buffer.len(); 113 - let (content, encoding, is_binary) = match String::from_utf8(buffer) { 114 - Ok(content) => (content, Encoding::Utf8, false), 115 - Err(error) => (BASE64.encode(error.as_bytes()), Encoding::Base64, true), 116 - }; 117 - 118 - Output { 119 - rev, 120 - path, 121 - encoding: Some(encoding), 122 - size: Some(size), 123 - content: Some(content), 124 - mime_type: Some(mime_type.mime().into()), 125 - is_binary: Some(is_binary), 126 - submodule: None, 127 - last_commit: None, 128 - } 129 - } 130 - 131 - fn blob_raw(mime_type: &MimeType, buffer: Vec<u8>) -> Response { 132 - let mut headers = HeaderMap::new(); 133 - headers.insert( 134 - CONTENT_TYPE, 135 - HeaderValue::from_str(mime_type.mime()).expect("MIME type should be a valid header value"), 136 - ); 137 - 138 - (headers, buffer).into_response() 139 - }
-23
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_branch.rs
··· 1 - use axum::Json; 2 - use axum::extract::State; 3 - use gordian_lexicon::sh_tangled::repo::branch::Input; 4 - use gordian_lexicon::sh_tangled::repo::branch::Output; 5 - use tokio_rayon::AsyncThreadPool as _; 6 - 7 - use crate::model::Knot; 8 - use crate::model::repository::TangledRepository; 9 - use crate::public::xrpc::XrpcQuery; 10 - use crate::public::xrpc::XrpcResult; 11 - 12 - pub const LXM: &str = "/sh.tangled.repo.branch"; 13 - 14 - #[tracing::instrument(target = "sh_tangled::repo::branch", skip(knot, repository), err)] 15 - pub async fn handle( 16 - State(knot): State<Knot>, 17 - XrpcQuery(params): XrpcQuery<Input>, 18 - repository: TangledRepository, 19 - ) -> XrpcResult<Json<Output>> { 20 - knot.pool() 21 - .spawn_async(move || repository.branch(params)) 22 - .await 23 - }
-98
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_branches.rs
··· 1 - use axum::Json; 2 - use axum::extract::State; 3 - use gordian_lexicon::sh_tangled::repo::refs::Commit; 4 - use gordian_lexicon::sh_tangled::repo::refs::Reference; 5 - use serde::Serialize; 6 - use tokio_rayon::AsyncThreadPool as _; 7 - 8 - use crate::model::Knot; 9 - use crate::model::convert; 10 - use crate::model::errors; 11 - use crate::public::xrpc::XrpcQuery; 12 - use crate::public::xrpc::XrpcResult; 13 - 14 - pub use crate::lexicon::sh_tangled::repo::branches::Input; 15 - 16 - pub const LXM: &str = "/sh.tangled.repo.branches"; 17 - 18 - /// Output of `sh.tangled.repo.branches` query. 19 - #[derive(Debug, Default, Serialize)] 20 - #[serde(rename_all = "camelCase")] 21 - pub struct Output { 22 - #[serde(skip_serializing_if = "Vec::is_empty")] 23 - pub branches: Vec<Branch>, 24 - } 25 - 26 - #[derive(Debug, Serialize)] 27 - #[serde(rename_all = "camelCase")] 28 - pub struct Branch { 29 - pub reference: Reference, 30 - pub commit: Commit, 31 - pub is_default: bool, 32 - } 33 - 34 - pub type Response = XrpcResult<Json<Output>>; 35 - 36 - #[tracing::instrument(target = "sh_tangled::repo::branches", skip(knot), err)] 37 - pub async fn handle( 38 - State(knot): State<Knot>, 39 - XrpcQuery(Input { 40 - repo, 41 - limit, 42 - cursor, 43 - }): XrpcQuery<Input>, 44 - ) -> Response { 45 - let repo_path = repo.parse()?; 46 - let repo_key = knot.resolve_repo_key(&repo_path).await?; 47 - let repository = knot.open_repository(&repo_key).await?; 48 - 49 - knot.pool() 50 - .spawn_async(move || { 51 - let repository = repository.to_thread_local(); 52 - 53 - // Assume HEAD points to the intended default branch. This *should* be 54 - // true for a bare repository. 55 - let head = repository.head()?; 56 - let default_name = head 57 - .referent_name() 58 - .ok_or(errors::HeadDetached)? 59 - .shorten() 60 - .to_string(); 61 - 62 - let mut branches = Vec::new(); 63 - for branch in repository 64 - .references()? 65 - .local_branches()? 66 - .skip(cursor.into()) 67 - .take(limit.into()) 68 - { 69 - let Ok(branch) = branch.inspect_err(|error| tracing::error!(?error)) else { 70 - continue; 71 - }; 72 - 73 - let name = branch.name().shorten().to_string(); 74 - let Some(id) = branch.try_id() else { 75 - tracing::warn!(?name, "branch unborn, skipping"); 76 - continue; 77 - }; 78 - 79 - let Ok(commit) = repository.find_commit(id) else { 80 - tracing::error!(?name, ?id, "failed to find commit for branch"); 81 - continue; 82 - }; 83 - 84 - let is_default = name == default_name; 85 - branches.push(Branch { 86 - reference: Reference { 87 - name, 88 - hash: commit.id.into(), 89 - }, 90 - commit: convert::try_convert_commit(commit)?, 91 - is_default, 92 - }); 93 - } 94 - 95 - Ok(Json(Output { branches }).into()) 96 - }) 97 - .await 98 - }
-23
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_compare.rs
··· 1 - use axum::Json; 2 - use axum::extract::State; 3 - use tokio_rayon::AsyncThreadPool as _; 4 - 5 - use crate::model::Knot; 6 - use crate::model::repository::TangledRepository; 7 - use crate::public::xrpc::XrpcQuery; 8 - use crate::public::xrpc::XrpcResult; 9 - use crate::types::sh_tangled::repo::compare::Input; 10 - use crate::types::sh_tangled::repo::compare::Output; 11 - 12 - pub const LXM: &str = "/sh.tangled.repo.compare"; 13 - 14 - #[tracing::instrument(target = "sh_tangled::repo::compare", skip(knot, repository), err)] 15 - pub async fn handle( 16 - State(knot): State<Knot>, 17 - XrpcQuery(params): XrpcQuery<Input>, 18 - repository: TangledRepository, 19 - ) -> XrpcResult<Json<Output>> { 20 - knot.pool() 21 - .spawn_async(move || repository.compare(params)) 22 - .await 23 - }
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_create.rs crates/gordian-knot/src/public/xrpc/sh_tangled/repo/create.rs
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_delete.rs crates/gordian-knot/src/public/xrpc/sh_tangled/repo/delete.rs
-23
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_diff.rs
··· 1 - use axum::Json; 2 - use axum::extract::State; 3 - use tokio_rayon::AsyncThreadPool as _; 4 - 5 - use crate::model::Knot; 6 - use crate::model::repository::TangledRepository; 7 - use crate::public::xrpc::XrpcQuery; 8 - use crate::public::xrpc::XrpcResult; 9 - use crate::types::sh_tangled::repo::diff::Input; 10 - use crate::types::sh_tangled::repo::diff::Output; 11 - 12 - pub const LXM: &str = "/sh.tangled.repo.diff"; 13 - 14 - #[tracing::instrument(target = "sh_tangled::repo::diff", skip(knot, repository), err)] 15 - pub async fn handle( 16 - State(knot): State<Knot>, 17 - XrpcQuery(params): XrpcQuery<Input>, 18 - repository: TangledRepository, 19 - ) -> XrpcResult<Json<Output>> { 20 - knot.pool() 21 - .spawn_async(move || repository.diff(params)) 22 - .await 23 - }
-27
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_get_default_branch.rs
··· 1 - use axum::Json; 2 - use axum::extract::State; 3 - use gordian_lexicon::sh_tangled::repo::get_default_branch::Input; 4 - use gordian_lexicon::sh_tangled::repo::get_default_branch::Output; 5 - use tokio_rayon::AsyncThreadPool as _; 6 - 7 - use crate::model::Knot; 8 - use crate::model::repository::TangledRepository; 9 - use crate::public::xrpc::XrpcQuery; 10 - use crate::public::xrpc::XrpcResult; 11 - 12 - pub const LXM: &str = "/sh.tangled.repo.getDefaultBranch"; 13 - 14 - #[tracing::instrument( 15 - target = "sh_tangled::repo::getDefaultBranch", 16 - skip(knot, repository), 17 - err 18 - )] 19 - pub async fn handle( 20 - State(knot): State<Knot>, 21 - XrpcQuery(params): XrpcQuery<Input>, 22 - repository: TangledRepository, 23 - ) -> XrpcResult<Json<Output>> { 24 - knot.pool() 25 - .spawn_async(move || repository.get_default_branch(params)) 26 - .await 27 - }
-23
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_languages.rs
··· 1 - use axum::Json; 2 - use axum::extract::State; 3 - use tokio_rayon::AsyncThreadPool as _; 4 - 5 - use crate::lexicon::sh_tangled::repo::languages::Input; 6 - use crate::lexicon::sh_tangled::repo::languages::Output; 7 - use crate::model::Knot; 8 - use crate::model::repository::TangledRepository; 9 - use crate::public::xrpc::XrpcQuery; 10 - use crate::public::xrpc::XrpcResult; 11 - 12 - pub const LXM: &str = "/sh.tangled.repo.languages"; 13 - 14 - #[tracing::instrument(target = "sh_tangled::repo::languages", skip(knot, repository), err)] 15 - pub async fn handle( 16 - State(knot): State<Knot>, 17 - XrpcQuery(params): XrpcQuery<Input>, 18 - repository: TangledRepository, 19 - ) -> XrpcResult<Json<Output>> { 20 - knot.pool() 21 - .spawn_async(move || repository.languages(params)) 22 - .await 23 - }
-23
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_log.rs
··· 1 - use axum::Json; 2 - use axum::extract::State; 3 - use tokio_rayon::AsyncThreadPool as _; 4 - 5 - use crate::model::Knot; 6 - use crate::model::repository::TangledRepository; 7 - use crate::public::xrpc::XrpcQuery; 8 - use crate::public::xrpc::XrpcResult; 9 - use crate::types::sh_tangled::repo::log::Input; 10 - use crate::types::sh_tangled::repo::log::Output; 11 - 12 - pub const LXM: &str = "/sh.tangled.repo.log"; 13 - 14 - #[tracing::instrument(target = "sh_tangled::repo::log", skip(knot, repository), err)] 15 - pub async fn handle( 16 - State(knot): State<Knot>, 17 - XrpcQuery(params): XrpcQuery<Input>, 18 - repository: TangledRepository, 19 - ) -> XrpcResult<Json<Output>> { 20 - knot.pool() 21 - .spawn_async(move || repository.log(params)) 22 - .await 23 - }
-44
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_merge_check.rs
··· 1 - use axum::Json; 2 - use axum::extract::State; 3 - use tokio_rayon::AsyncThreadPool as _; 4 - 5 - use crate::lexicon::sh_tangled::repo::merge_check::Input; 6 - use crate::lexicon::sh_tangled::repo::merge_check::Output; 7 - use crate::model::Knot; 8 - use crate::model::errors; 9 - use crate::model::repository::TangledRepository; 10 - use crate::public::xrpc::XrpcResult; 11 - use crate::types::repository_spec::RepositoryPath; 12 - 13 - pub const LXM: &str = "/sh.tangled.repo.mergeCheck"; 14 - 15 - #[tracing::instrument(target = "sh_tangled::repo::merge_check", skip(knot, patch), err)] 16 - pub async fn handle( 17 - State(knot): State<Knot>, 18 - Json(Input { 19 - did, 20 - name, 21 - patch, 22 - branch, 23 - }): Json<Input>, 24 - ) -> XrpcResult<Json<Output>> { 25 - let repo_path = 26 - RepositoryPath::from_parts(did.as_str(), name).map_err(errors::InvalidRequest)?; 27 - 28 - let repo_key = knot 29 - .resolve_repo_key(&repo_path) 30 - .await 31 - .map_err(errors::RepoNotFound)?; 32 - 33 - let repo = knot 34 - .open_repository(&repo_key) 35 - .await 36 - .map_err(errors::RepoNotFound)? 37 - .to_thread_local(); 38 - 39 - let repository: TangledRepository = (knot.clone(), repo_key, repo).into(); 40 - 41 - knot.pool() 42 - .spawn_async(move || repository.merge_check(patch, &branch)) 43 - .await 44 - }
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_set_default_branch.rs crates/gordian-knot/src/public/xrpc/sh_tangled/repo/set_default_branch.rs
-23
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_tags.rs
··· 1 - use axum::Json; 2 - use axum::extract::State; 3 - use tokio_rayon::AsyncThreadPool as _; 4 - 5 - use crate::model::Knot; 6 - use crate::model::repository::TangledRepository; 7 - use crate::public::xrpc::XrpcQuery; 8 - use crate::public::xrpc::XrpcResult; 9 - use crate::types::sh_tangled::repo::tags::Input; 10 - use crate::types::sh_tangled::repo::tags::Output; 11 - 12 - pub const LXM: &str = "/sh.tangled.repo.tags"; 13 - 14 - #[tracing::instrument(target = "sh_tangled::repo::tags", skip(knot, repository), err)] 15 - pub async fn handle( 16 - State(knot): State<Knot>, 17 - XrpcQuery(params): XrpcQuery<Input>, 18 - repository: TangledRepository, 19 - ) -> XrpcResult<Json<Output>> { 20 - knot.pool() 21 - .spawn_async(move || repository.tags(params)) 22 - .await 23 - }
-24
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_tree.rs
··· 1 - use axum::Json; 2 - use axum::extract::State; 3 - use tokio_rayon::AsyncThreadPool as _; 4 - 5 - use crate::lexicon::sh_tangled::repo::tree::Input; 6 - use crate::lexicon::sh_tangled::repo::tree::Output; 7 - use crate::model::Knot; 8 - use crate::model::repository::TangledRepository; 9 - use crate::public::xrpc::XrpcQuery; 10 - use crate::public::xrpc::XrpcResult; 11 - 12 - pub const LXM: &str = "/sh.tangled.repo.tree"; 13 - 14 - #[tracing::instrument(target = "sh_tangled::repo::tree", skip(knot, repository), err)] 15 - pub async fn handle( 16 - State(knot): State<Knot>, 17 - XrpcQuery(params): XrpcQuery<Input>, 18 - repository: TangledRepository, 19 - ) -> XrpcResult<Json<Output>> { 20 - let cloned_knot = knot.clone(); 21 - knot.pool() 22 - .spawn_async(move || repository.tree(params, cloned_knot.readmes())) 23 - .await 24 - }
+17
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/languages.rs
··· 1 + use axum::Json; 2 + 3 + use crate::extract::repository::XrpcRepository; 4 + use crate::lexicon::sh_tangled::repo::languages::Input; 5 + use crate::lexicon::sh_tangled::repo::languages::Output; 6 + use crate::public::xrpc::XrpcQuery; 7 + use crate::public::xrpc::XrpcResult; 8 + 9 + pub const LXM: &str = "/sh.tangled.repo.languages"; 10 + 11 + #[tracing::instrument(target = "sh_tangled::repo::languages", err)] 12 + pub async fn handle( 13 + XrpcRepository(repository): XrpcRepository, 14 + XrpcQuery(params): XrpcQuery<Input>, 15 + ) -> XrpcResult<Json<Output>> { 16 + tokio_rayon::spawn(move || Ok(Json(Output::default()).into())).await 17 + }
+88
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/log.rs
··· 1 + use axum::Json; 2 + use serde::Serialize; 3 + 4 + use crate::extract::repository::XrpcRepository; 5 + pub use crate::lexicon::sh_tangled::repo::log::Input; 6 + use crate::lexicon::sh_tangled::repo::refs::Commit; 7 + use crate::model::convert; 8 + use crate::model::errors; 9 + use crate::model::repository::ResolveRevspec as _; 10 + use crate::model::repository::ResolvedRevspec; 11 + use crate::public::xrpc::XrpcQuery; 12 + use crate::public::xrpc::XrpcResult; 13 + 14 + pub const LXM: &str = "/sh.tangled.repo.log"; 15 + 16 + #[derive(Debug, Serialize)] 17 + pub struct Output { 18 + pub commits: Vec<Commit>, 19 + pub log: bool, 20 + pub total: usize, 21 + pub page: usize, 22 + pub per_page: u16, 23 + } 24 + 25 + #[tracing::instrument(target = "sh_tangled::repo::log", err)] 26 + pub async fn handle( 27 + XrpcRepository(repository): XrpcRepository, 28 + XrpcQuery(Input { 29 + repo, 30 + rev, 31 + path, 32 + limit, 33 + cursor, 34 + }): XrpcQuery<Input>, 35 + ) -> XrpcResult<Json<Output>> { 36 + tokio_rayon::spawn(move || { 37 + let repository = repository.to_thread_local(); 38 + let commit_graph = repository.commit_graph_if_enabled()?; 39 + let total = match &commit_graph { 40 + Some(cg) => cg 41 + .num_commits() 42 + .try_into() 43 + .expect("You must be at least 32 bits tall to enjoy this ride"), 44 + None => { 45 + tracing::warn!(repository = ?repository, "no commit-graph, counting commits manually"); 46 + repository 47 + .rev_walk([repository.head_id().map_err(errors::RepoEmpty)?]) 48 + .all() 49 + .map_err(errors::RepoError)? 50 + .count() 51 + } 52 + }; 53 + 54 + let ResolvedRevspec { commit, immutable: _ }= repository.resolve_revspec(&rev.as_deref())?; 55 + 56 + let mut commits = Vec::new(); 57 + let walk = repository.rev_walk([commit.id()]) 58 + .with_commit_graph(commit_graph) 59 + .all() 60 + .map_err(errors::RepoError)?; 61 + 62 + for commit in walk.skip(cursor).take(limit.into()) { 63 + match commit { 64 + Ok(commit) => { 65 + let commit = 66 + repository 67 + .find_commit(commit.id()) 68 + .map_err(errors::RepoError)?; 69 + commits.push(convert::try_convert_commit(commit).map_err(errors::RepoError)?); 70 + } 71 + Err(error) => { 72 + tracing::error!(?error); 73 + break; 74 + } 75 + } 76 + } 77 + 78 + Ok(Json(Output { 79 + commits, 80 + log: true, 81 + total, 82 + page: 1 + cursor / usize::from(limit), 83 + per_page: limit, 84 + }) 85 + .into()) 86 + }) 87 + .await 88 + }
+136
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/merge_check.rs
··· 1 + use std::borrow::Cow; 2 + use std::io::Write as _; 3 + use std::process::Command; 4 + use std::process::Stdio; 5 + 6 + use axum::Json; 7 + use axum::extract::State; 8 + use gix::Repository; 9 + 10 + use crate::lexicon::sh_tangled::repo::merge_check::ConflictInfo; 11 + use crate::lexicon::sh_tangled::repo::merge_check::Input; 12 + use crate::lexicon::sh_tangled::repo::merge_check::Output; 13 + use crate::model::Knot; 14 + use crate::model::errors; 15 + use crate::model::repository::ResolveRevspec as _; 16 + use crate::model::repository::ResolvedRevspec; 17 + use crate::model::repository::TempWorktree; 18 + use crate::public::xrpc::XrpcResponse; 19 + use crate::public::xrpc::XrpcResult; 20 + use crate::types::repository_spec::RepositoryPath; 21 + 22 + pub const LXM: &str = "/sh.tangled.repo.mergeCheck"; 23 + 24 + #[tracing::instrument(target = "sh_tangled::repo::merge_check", skip(knot, patch), err)] 25 + pub async fn handle( 26 + State(knot): State<Knot>, 27 + Json(Input { 28 + did, 29 + name, 30 + patch, 31 + branch, 32 + }): Json<Input>, 33 + ) -> XrpcResult<Json<Output>> { 34 + let repo_path = 35 + RepositoryPath::from_parts(did.as_str(), name).map_err(errors::InvalidRequest)?; 36 + 37 + let repo_key = knot 38 + .resolve_repo_key(&repo_path) 39 + .await 40 + .map_err(errors::RepoNotFound)?; 41 + 42 + let repository = knot 43 + .open_repository(&repo_key) 44 + .await 45 + .map_err(errors::RepoNotFound)? 46 + .to_thread_local(); 47 + 48 + tokio_rayon::spawn(move || merge_check(repository, patch, &branch)).await 49 + } 50 + 51 + pub fn merge_check( 52 + repository: Repository, 53 + patch: String, 54 + branch: &str, 55 + ) -> XrpcResult<Json<Output>> { 56 + let ResolvedRevspec { commit, immutable } = 57 + repository.resolve_revspec(&Some(branch.as_ref()))?; 58 + 59 + let worktree = TempWorktree::builder() 60 + .prefix("merge-check") 61 + // .config(&self.knot.git_config_path()) 62 + .commit(&commit.id) 63 + .build(&repository) 64 + .map_err(errors::Internal)?; 65 + 66 + let mut child = Command::new("git") 67 + .arg("-C") 68 + .arg(worktree.path()) 69 + .arg("apply") 70 + .arg("--check") 71 + .arg("--verbose") 72 + .arg("-") 73 + .stdin(Stdio::piped()) 74 + .stderr(Stdio::piped()) 75 + .spawn() 76 + .map_err(errors::Internal)?; 77 + 78 + let mut stdin = child.stdin.take().expect("handle present"); 79 + let writer = std::thread::spawn(move || stdin.write_all(patch.as_bytes())); 80 + let output = child.wait_with_output().map_err(errors::Internal)?; 81 + 82 + writer 83 + .join() 84 + .expect("thread should not panic") 85 + .map_err(errors::Internal)?; 86 + 87 + let errors = std::str::from_utf8(&output.stderr).map_err(errors::Internal)?; 88 + let conflicts = parse_git_apply_check_errors(errors); 89 + let is_conflicted = !output.status.success() && !conflicts.is_empty(); 90 + let message = is_conflicted.then_some(Cow::Borrowed("patch cannot be applied cleanly")); 91 + 92 + Ok(XrpcResponse { 93 + response: Json(Output { 94 + is_conflicted, 95 + conflicts, 96 + message, 97 + error: None, 98 + }), 99 + immutable, 100 + }) 101 + } 102 + 103 + fn parse_git_apply_check_errors(stderr: &str) -> Vec<ConflictInfo> { 104 + let mut hunk_name = None; 105 + stderr 106 + .lines() 107 + .filter_map(|line| { 108 + let mut parts = line.splitn(3, ':').map(|s| s.trim()); 109 + match (parts.next(), parts.next(), parts.next()) { 110 + (Some("error"), Some("patch failed"), Some(hunk)) => { 111 + hunk_name = Some(hunk); 112 + None 113 + } 114 + (Some("error"), Some(filename), Some("already exists in working directory")) => { 115 + Some(ConflictInfo { 116 + filename: hunk_name.unwrap_or(filename).to_owned(), 117 + reason: Cow::Borrowed("file already exists"), 118 + }) 119 + } 120 + (Some("error"), Some(filename), Some("does not exist in working tree")) => { 121 + Some(ConflictInfo { 122 + filename: hunk_name.unwrap_or(filename).to_owned(), 123 + reason: Cow::Borrowed("file does not exist"), 124 + }) 125 + } 126 + (Some("error"), Some(filename), Some("patch does not apply")) => { 127 + Some(ConflictInfo { 128 + filename: hunk_name.unwrap_or(filename).to_owned(), 129 + reason: Cow::Borrowed("patch does not apply"), 130 + }) 131 + } 132 + _ => None, 133 + } 134 + }) 135 + .collect() 136 + }
+82
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/tags.rs
··· 1 + use axum::Json; 2 + use serde::Serialize; 3 + 4 + use crate::extract::repository::XrpcRepository; 5 + use crate::lexicon::extra::objectid::Array; 6 + use crate::lexicon::extra::objectid::ObjectId; 7 + use crate::lexicon::sh_tangled::repo::refs::Reference; 8 + use crate::lexicon::sh_tangled::repo::refs::Signature; 9 + use crate::lexicon::sh_tangled::repo::tags::Input; 10 + use crate::public::xrpc::XrpcQuery; 11 + use crate::public::xrpc::XrpcResult; 12 + 13 + pub const LXM: &str = "/sh.tangled.repo.tags"; 14 + 15 + /// Output of `sh.tangled.repo.tags` query. 16 + /// 17 + /// This is not defined in the lexicon, but models what knotserver 18 + /// currently produces. 19 + #[derive(Debug, Serialize)] 20 + pub struct Output { 21 + pub tags: Vec<Tag>, 22 + } 23 + 24 + #[derive(Debug, Serialize)] 25 + #[serde(rename_all = "PascalCase")] 26 + pub struct TagAnnotation { 27 + pub hash: ObjectId<Array>, 28 + pub name: String, 29 + pub tagger: Option<Signature>, 30 + pub message: String, 31 + #[serde(rename = "PGPSignature")] 32 + pub pgp_signature: Option<String>, 33 + pub target_type: i32, 34 + pub target: ObjectId<Array>, 35 + } 36 + 37 + #[derive(Debug, Serialize)] 38 + pub struct Tag { 39 + #[serde(flatten)] 40 + pub r#ref: Reference, 41 + #[serde(rename = "tag", skip_serializing_if = "Option::is_none")] 42 + pub annotation: Option<TagAnnotation>, 43 + } 44 + 45 + #[tracing::instrument(target = "sh_tangled::repo::tags", err)] 46 + pub async fn handle( 47 + XrpcRepository(repository): XrpcRepository, 48 + XrpcQuery(Input { 49 + repo, 50 + limit, 51 + cursor, 52 + }): XrpcQuery<Input>, 53 + ) -> XrpcResult<Json<Output>> { 54 + use std::cmp::Reverse; 55 + 56 + tokio_rayon::spawn(move || { 57 + let repository = repository.to_thread_local(); 58 + 59 + let mut tags: Vec<_> = repository 60 + .references()? 61 + .tags()? 62 + .filter_map(|tag| { 63 + tag.inspect_err(|error| tracing::error!(?error)) 64 + .ok()? 65 + .try_into() 66 + .inspect_err(|error| tracing::error!(?error)) 67 + .ok() 68 + }) 69 + .collect(); 70 + 71 + tags.sort_by_key(|tag: &Tag| { 72 + Reverse( 73 + tag.annotation 74 + .as_ref() 75 + .map(|an| an.tagger.as_ref().map(|tagger| tagger.when)), 76 + ) 77 + }); 78 + 79 + Ok(Json(Output { tags }).into()) 80 + }) 81 + .await 82 + }
+105
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/tree.rs
··· 1 + use axum::Json; 2 + use axum::extract::State; 3 + 4 + use crate::Knot; 5 + use crate::extract::repository::XrpcRepository; 6 + use crate::lexicon::sh_tangled::repo::tree::Input; 7 + use crate::lexicon::sh_tangled::repo::tree::Output; 8 + use crate::lexicon::sh_tangled::repo::tree::Readme; 9 + use crate::lexicon::sh_tangled::repo::tree::TreeEntry; 10 + use crate::model::convert; 11 + use crate::model::errors::PathNotFound; 12 + use crate::model::repository::ResolveRevspec as _; 13 + use crate::model::repository::ResolvedRevspec; 14 + use crate::public::xrpc::XrpcQuery; 15 + use crate::public::xrpc::XrpcResponse; 16 + use crate::public::xrpc::XrpcResult; 17 + 18 + pub const LXM: &str = "/sh.tangled.repo.tree"; 19 + 20 + #[tracing::instrument(target = "sh_tangled::repo::tree", skip(knot), err)] 21 + pub async fn handle( 22 + State(knot): State<Knot>, 23 + XrpcRepository(repository): XrpcRepository, 24 + XrpcQuery(Input { repo, rev, path }): XrpcQuery<Input>, 25 + ) -> XrpcResult<Json<Output>> { 26 + tokio_rayon::spawn(move || { 27 + let repository = repository.to_thread_local(); 28 + 29 + let ResolvedRevspec { commit, immutable } = repository.resolve_revspec(&rev.as_deref())?; 30 + let dotdot = path.clone().and_then(|mut path| { 31 + path.pop(); 32 + match path.as_os_str().is_empty() { 33 + true => None, 34 + false => Some(path), 35 + } 36 + }); 37 + 38 + let mut parent = None; 39 + let mut tree = commit.tree()?; 40 + if let Some(subpath) = &path { 41 + let entry = tree 42 + .lookup_entry_by_path(subpath)? 43 + .ok_or(PathNotFound(subpath.to_string_lossy()))?; 44 + 45 + if !entry.mode().is_tree() { 46 + return Ok(XrpcResponse { 47 + response: Json(Output { 48 + files: vec![], 49 + dotdot: dotdot.map(|path| path.into()), 50 + parent: path.map(|path| path.into()), 51 + rev: rev.as_deref().unwrap_or_default().to_string(), 52 + readme: None, 53 + }), 54 + immutable, 55 + }); 56 + } 57 + 58 + let subtree = repository.find_tree(entry.id()).unwrap(); 59 + tree = subtree; 60 + parent = Some(subpath.to_path_buf()); 61 + } 62 + 63 + let mut files: Vec<TreeEntry> = vec![]; 64 + let mut readme = None; 65 + for entry in tree.iter() { 66 + let Ok(entry) = entry else { 67 + continue; 68 + }; 69 + 70 + if knot.readmes.contains(entry.filename()) && entry.mode().is_blob() && readme.is_none() 71 + { 72 + let mut file = repository.find_blob(entry.id())?; 73 + if let Ok(contents) = String::from_utf8(file.take_data()) { 74 + readme.replace(Readme { 75 + contents, 76 + filename: entry.filename().to_string(), 77 + }); 78 + } 79 + } 80 + 81 + files.push(convert::convert_entry(entry)); 82 + } 83 + 84 + let files: Vec<_> = tree 85 + .iter() 86 + .filter_map(|entry| { 87 + let entry = entry.ok()?; 88 + let file = convert::convert_entry(entry); 89 + Some(file) 90 + }) 91 + .collect(); 92 + 93 + Ok(XrpcResponse { 94 + response: Json(Output { 95 + files, 96 + dotdot: dotdot.map(|path| path.into()), 97 + parent, 98 + rev: rev.as_deref().unwrap_or_default().to_string(), 99 + readme, 100 + }), 101 + immutable, 102 + }) 103 + }) 104 + .await 105 + }
+111
crates/gordian-knot/src/test_helpers.rs
··· 1 + //! Helpers for testing routes. 2 + //! 3 + //! This is mostly just stolen from: <https://github.com/tokio-rs/axum/blob/main/axum/src/test_helpers/test_client.rs> 4 + use std::convert::Infallible; 5 + use std::net::SocketAddr; 6 + use std::ops; 7 + 8 + use axum::extract::Request; 9 + use axum::response::Response; 10 + use futures_util::future::BoxFuture; 11 + use reqwest::StatusCode; 12 + use serde::de::DeserializeOwned; 13 + use tokio::net::TcpListener; 14 + use tower::Service; 15 + 16 + pub fn spawn_service<S>(service: S) -> SocketAddr 17 + where 18 + S: Service<Request, Response = Response, Error = Infallible> + Clone + Send + 'static, 19 + S::Future: Send, 20 + { 21 + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); 22 + listener.set_nonblocking(true).unwrap(); 23 + 24 + let addr = listener.local_addr().unwrap(); 25 + let listener = TcpListener::from_std(listener).unwrap(); 26 + 27 + tokio::spawn(async move { 28 + axum::serve(listener, tower::make::Shared::new(service)) 29 + .await 30 + .unwrap(); 31 + }); 32 + 33 + addr 34 + } 35 + 36 + pub struct TestClient { 37 + client: reqwest::Client, 38 + addr: SocketAddr, 39 + } 40 + 41 + impl TestClient { 42 + pub fn new<S>(service: S) -> Self 43 + where 44 + S: Service<Request, Response = Response, Error = Infallible> + Clone + Send + 'static, 45 + S::Future: Send, 46 + { 47 + let addr = spawn_service(service); 48 + let client = reqwest::Client::builder() 49 + .redirect(reqwest::redirect::Policy::none()) 50 + .build() 51 + .unwrap(); 52 + 53 + Self { client, addr } 54 + } 55 + 56 + pub fn get(&self, url: &str) -> RequestBuilder { 57 + self.client.get(format!("http://{}{url}", self.addr)).into() 58 + } 59 + } 60 + 61 + #[must_use] 62 + pub struct RequestBuilder(reqwest::RequestBuilder); 63 + 64 + impl RequestBuilder { 65 + async fn send(self) -> TestResponse { 66 + self.0.send().await.unwrap().into() 67 + } 68 + } 69 + 70 + impl From<reqwest::RequestBuilder> for RequestBuilder { 71 + fn from(value: reqwest::RequestBuilder) -> Self { 72 + Self(value) 73 + } 74 + } 75 + 76 + impl IntoFuture for RequestBuilder { 77 + type Output = TestResponse; 78 + 79 + type IntoFuture = BoxFuture<'static, Self::Output>; 80 + 81 + fn into_future(self) -> Self::IntoFuture { 82 + Box::pin(async { self.send().await }) 83 + } 84 + } 85 + 86 + pub struct TestResponse(reqwest::Response); 87 + 88 + impl TestResponse { 89 + pub fn ok(self) -> Self { 90 + assert_eq!(self.status(), StatusCode::OK); 91 + self 92 + } 93 + 94 + pub async fn json<T: DeserializeOwned>(self) -> T { 95 + self.0.json().await.unwrap() 96 + } 97 + } 98 + 99 + impl From<reqwest::Response> for TestResponse { 100 + fn from(value: reqwest::Response) -> Self { 101 + Self(value) 102 + } 103 + } 104 + 105 + impl ops::Deref for TestResponse { 106 + type Target = reqwest::Response; 107 + 108 + fn deref(&self) -> &Self::Target { 109 + &self.0 110 + } 111 + }
+1 -40
crates/gordian-knot/src/types/sh_tangled.rs
··· 30 30 #[derive(Debug, Serialize)] 31 31 pub struct Output { 32 32 #[serde(rename = "ref")] 33 - pub rev: Box<str>, 33 + pub rev: String, 34 34 pub diff: NiceDiff, 35 35 } 36 36 ··· 144 144 pub total: usize, 145 145 pub page: usize, 146 146 pub per_page: u16, 147 - } 148 - } 149 - 150 - pub mod tags { 151 - use serde::Serialize; 152 - 153 - use crate::lexicon::extra::objectid::Array; 154 - use crate::lexicon::extra::objectid::ObjectId; 155 - use crate::lexicon::sh_tangled::repo::refs; 156 - pub use crate::lexicon::sh_tangled::repo::tags::Input; 157 - 158 - /// Output of `sh.tangled.repo.tags` query. 159 - /// 160 - /// This is not defined in the lexicon, but models what knotserver 161 - /// currently produces. 162 - #[derive(Debug, Serialize)] 163 - pub struct Output { 164 - pub tags: Vec<Tag>, 165 - } 166 - 167 - #[derive(Debug, Serialize)] 168 - #[serde(rename_all = "PascalCase")] 169 - pub struct TagAnnotation { 170 - pub hash: ObjectId<Array>, 171 - pub name: String, 172 - pub tagger: Option<refs::Signature>, 173 - pub message: String, 174 - #[serde(rename = "PGPSignature")] 175 - pub pgp_signature: Option<String>, 176 - pub target_type: i32, 177 - pub target: ObjectId<Array>, 178 - } 179 - 180 - #[derive(Debug, Serialize)] 181 - pub struct Tag { 182 - #[serde(flatten)] 183 - pub r#ref: refs::Reference, 184 - #[serde(rename = "tag", skip_serializing_if = "Option::is_none")] 185 - pub annotation: Option<TagAnnotation>, 186 147 } 187 148 } 188 149 }
+51 -7
crates/gordian-lexicon/src/extra/objectid.rs
··· 1 + use core::fmt; 1 2 use std::marker::PhantomData; 2 3 use std::str::FromStr; 3 4 ··· 10 9 #[doc(hidden)] 11 10 #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] 12 11 pub struct Hex; 12 + #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] 13 13 #[doc(hidden)] 14 14 pub struct Array; 15 15 16 16 /// An object ID that can serialized as a hex-string or an array of integers. 17 17 /// 18 18 /// This only exists because knotserver uses both representations. 19 - #[derive(Clone, Hash, PartialEq, Eq)] 19 + #[derive(Clone, Hash, Eq)] 20 20 pub struct ObjectId<E = Hex> { 21 21 inner: gix_hash::ObjectId, 22 22 enc: PhantomData<E>, ··· 40 38 } 41 39 } 42 40 43 - impl<E> std::fmt::Debug for ObjectId<E> { 44 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 41 + impl<E, F> PartialEq<ObjectId<E>> for ObjectId<F> { 42 + fn eq(&self, other: &ObjectId<E>) -> bool { 43 + self.inner.eq(&other.inner) 44 + } 45 + } 46 + 47 + impl<E> fmt::Display for ObjectId<E> { 48 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 49 + write!(f, "{}", self.inner.to_hex()) 50 + } 51 + } 52 + 53 + impl<E> fmt::Debug for ObjectId<E> { 54 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 45 55 write!(f, "{}", self.inner) 46 56 } 47 57 } ··· 113 99 } 114 100 } 115 101 116 - struct Hash; 117 - 118 - impl<'de> Visitor<'de> for Hash { 102 + impl<'de> Visitor<'de> for Hex { 119 103 type Value = ObjectId<Hex>; 120 104 121 105 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { ··· 134 122 where 135 123 D: serde::Deserializer<'de>, 136 124 { 137 - deserializer.deserialize_any(Hash) 125 + deserializer.deserialize_any(Hex) 126 + } 127 + } 128 + 129 + impl<'de> Visitor<'de> for Array { 130 + type Value = ObjectId<Array>; 131 + 132 + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 133 + formatter.write_str("commit hash") 134 + } 135 + 136 + fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error> 137 + where 138 + A: serde::de::SeqAccess<'de>, 139 + { 140 + let mut bytes: Vec<u8> = Vec::new(); 141 + while let Some(element) = seq.next_element()? { 142 + bytes.push(element); 143 + } 144 + 145 + let object_id = 146 + gix_hash::ObjectId::try_from(bytes.as_slice()).map_err(serde::de::Error::custom)?; 147 + 148 + Ok(ObjectId::from(object_id)) 149 + } 150 + } 151 + 152 + impl<'de> Deserialize<'de> for ObjectId<Array> { 153 + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 154 + where 155 + D: serde::Deserializer<'de>, 156 + { 157 + deserializer.deserialize_any(Array) 138 158 } 139 159 }
+7 -5
crates/gordian-lexicon/src/sh_tangled/repo.rs
··· 158 158 use std::borrow::Cow; 159 159 use std::collections::HashMap; 160 160 161 + use serde::Deserialize; 161 162 use serde::Serialize; 162 163 use time::OffsetDateTime; 163 164 164 165 use crate::extra::objectid::Array; 165 166 use crate::extra::objectid::ObjectId; 166 167 167 - #[derive(Debug, Default, Serialize)] 168 - #[serde(rename_all = "camelCase")] 168 + #[derive(Debug, Default, PartialEq, Eq, Deserialize, Serialize)] 169 + #[serde(deny_unknown_fields, rename_all = "camelCase")] 169 170 pub struct Reference { 170 171 /// Short-name of the reference. 171 172 pub name: String, ··· 174 173 } 175 174 176 175 /// Git commit signature (ie. the author or committer). 177 - #[derive(Debug, Serialize)] 176 + #[derive(Debug, PartialEq, Eq, Deserialize, Serialize)] 177 + #[serde(deny_unknown_fields)] 178 178 pub struct Signature { 179 179 /// Author or committer name. 180 180 pub name: String, ··· 188 186 pub when: OffsetDateTime, 189 187 } 190 188 191 - #[derive(Debug, Serialize)] 192 - #[serde(rename_all = "PascalCase")] 189 + #[derive(Debug, PartialEq, Eq, Deserialize, Serialize)] 190 + #[serde(deny_unknown_fields, rename_all = "PascalCase")] 193 191 pub struct Commit { 194 192 pub hash: ObjectId<Array>, 195 193 pub author: Signature,
-2
crates/gordian-lexicon/src/sh_tangled/repo/branch.rs
··· 29 29 /// Latest commit hash on this branch. 30 30 pub hash: ObjectId, 31 31 32 - // @NOTE Refusing to send redundant data 33 - // pub short_hash: String, 34 32 /// Timestamp of the latest commit. 35 33 #[serde(with = "time::serde::rfc3339")] 36 34 pub when: OffsetDateTime,
+1 -1
crates/gordian-lexicon/src/sh_tangled/repo/branches.rs
··· 2 2 //! <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/branches.json> 3 3 4 4 const LIMIT_MIN: u16 = 1; 5 - const LIMIT_MAX: u16 = 100; 5 + const LIMIT_MAX: u16 = 500; 6 6 const LIMIT_DEFAULT: u16 = 50; 7 7 8 8 /// Parameters for the `sh.tangled.repo.branches` query.