1//! `git clone https://lmjtfy.fun/lmjtfy.git`: the code, for 2//! anyone, without a GitHub account and without publishing it on GitHub. 3//! 4//! Git's smart HTTP is two requests: `GET <repo>/info/refs?service= 5//! git-upload-pack` lists the refs, and `POST <repo>/git-upload-pack` sends 6//! the pack. Both are forwarded to the private repository on GitHub with a 7//! fine-grained token that can read these two repositories and do nothing 8//! else, so a push through here is refused by GitHub, not only by the routes. 9//! Nothing is kept and nothing is spent: a clone reaches neither Jev nor the 10//! LLM, and the budgets do not count it. 11//! 12//! jevcrates is served beside lmjtfy because lmjtfy's `.gitmodules` names it 13//! by a relative URL (`../jevcrates.git`), which git resolves against 14//! wherever the clone came from: here, the Worker; from GitHub, GitHub. 15 16use axum::body::{Body, to_bytes}; 17use axum::extract::{Path, Query, State}; 18use axum::http::{HeaderMap, Method, Response, StatusCode, header}; 19use base64::Engine; 20use base64::engine::general_purpose::STANDARD; 21use serde::Deserialize; 22 23use std::cell::RefCell; 24 25use archive::Fetch; 26use card::Commit; 27use std::io::Read; 28 29use futures_util::StreamExt; 30 31use super::App; 32 33/// The secret's name: a GitHub fine-grained token with Contents: read on 34/// `deizel/lmjtfy` and `deizel/jevcrates`, and no other permission. 35const TOKEN: &str = "LMJTFY_GITHUB_TOKEN"; 36 37/// The one git service served. `git-receive-pack` is a push. 38const UPLOAD_PACK: &str = "git-upload-pack"; 39 40/// The largest request body forwarded. An upload-pack request is the refs a 41/// client wants and has, a few kilobytes even for a long history. 42const MAX_REQUEST_BYTES: usize = 1 << 20; 43 44/// The headers a git client sends that change GitHub's answer, forwarded as 45/// they came. `git-protocol` selects protocol v2, which modern git asks for; 46/// GitHub answers smart HTTP only to a `git/` user agent. 47const FORWARDED: [&str; 5] = ["accept", "content-type", "content-encoding", "git-protocol", "user-agent"]; 48 49/// The latest commit on lmjtfy's main, from GitHub's API. The token's 50/// Contents: read covers it. `per_page=1` makes the last page's number the 51/// count of commits. 52 53/// How long an isolate keeps the latest commit before asking again. Chats 54/// fetch a pasted link's page and picture together; this makes that one 55/// call, not two. 56const LATEST_MS: f64 = 60_000.0; 57 58thread_local! { 59 /// Each repository's latest commit and when it was read, in this isolate. 60 static LATEST: RefCell<Vec<(Repo, f64, Commit)>> = const { RefCell::new(Vec::new()) }; 61} 62 63/// The repositories that can be cloned from here. Anything else is not found. 64/// lmjtfy and the three other projects that ask Jev, jevcrates, the client 65/// those four share, and jevstrudel, which asks Jev from the browser. 66#[derive(Clone, Copy, Debug, PartialEq, Eq)] 67pub enum Repo { 68 Lmjtfy, 69 Jevcrates, 70 Postjevsql, 71 Jevsnes, 72 Jevhooks, 73 Jevstrudel, 74} 75 76impl Repo { 77 /// In the order the code pages list them: this site, the client, then 78 /// the other projects that use it. 79 pub const ALL: [Repo; 6] = [Repo::Lmjtfy, Repo::Jevcrates, Repo::Postjevsql, Repo::Jevsnes, Repo::Jevhooks, Repo::Jevstrudel]; 80 81 /// The path segment it is cloned by: `lmjtfy.git`. 82 pub fn served(self) -> &'static str { 83 match self { 84 Repo::Lmjtfy => "lmjtfy.git", 85 Repo::Jevcrates => "jevcrates.git", 86 Repo::Postjevsql => "postjevsql.git", 87 Repo::Jevsnes => "jevsnes.git", 88 Repo::Jevhooks => "jevhooks.git", 89 Repo::Jevstrudel => "jevstrudel.git", 90 } 91 } 92 93 pub fn named(segment: &str) -> Option<Repo> { 94 Repo::ALL.into_iter().find(|repo| repo.served() == segment) 95 } 96 97 /// `owner/name` on GitHub. 98 pub fn github(self) -> &'static str { 99 match self { 100 Repo::Lmjtfy => "deizel/lmjtfy", 101 Repo::Jevcrates => "deizel/jevcrates", 102 Repo::Postjevsql => "deizel/postjevsql", 103 Repo::Jevsnes => "deizel/jevsnes", 104 Repo::Jevhooks => "deizel/jevhooks", 105 Repo::Jevstrudel => "deizel/jevstrudel", 106 } 107 } 108 109 /// What it is, in a few words, for the list of repositories. 110 pub fn blurb(self) -> &'static str { 111 match self { 112 Repo::Lmjtfy => "This site: Rust on Cloudflare Workers, a rules engine, and Jev.", 113 Repo::Jevcrates => "The Rust client for Jev that all four projects share.", 114 Repo::Postjevsql => "Jev in Postgres: a pgrx extension, in SQL.", 115 Repo::Jevsnes => "Jev plays A Link to the Past on a SNES core.", 116 Repo::Jevhooks => "Jev judges Claude Code's hooks.", 117 Repo::Jevstrudel => "Jev arranges songs live, in a fork of Strudel.", 118 } 119 } 120 121 /// Its submodules: where each is mounted, and which repository it is. 122 /// The test below holds this to `.gitmodules`, where the checkout has it. 123 pub fn submodules(self) -> &'static [(&'static str, Repo)] { 124 match self { 125 Repo::Lmjtfy | Repo::Postjevsql | Repo::Jevhooks => &[("third-party/jevcrates", Repo::Jevcrates)], 126 Repo::Jevsnes => &[("third-party/rust/jevcrates", Repo::Jevcrates)], 127 Repo::Jevcrates | Repo::Jevstrudel => &[], 128 } 129 } 130 131 /// The branch the pages read: GitHub's default for it. 132 pub fn branch(self) -> &'static str { 133 match self { 134 Repo::Jevstrudel => "jevstrudel", 135 _ => "main", 136 } 137 } 138 139 fn upstream(self) -> String { 140 format!("https://github.com/{}.git", self.github()) 141 } 142 143 /// The command the page offers, for this site's origin. 144 pub fn command(self, origin: &str) -> String { 145 let recurse = if self.submodules().is_empty() { "" } else { "--recurse-submodules " }; 146 format!("git clone {recurse}{origin}/{}", self.served()) 147 } 148 149 /// What the code pages need to know about it. 150 pub fn project(self) -> crate::view::code::Project { 151 crate::view::code::Project { served: self.served(), blurb: self.blurb(), branch: self.branch() } 152 } 153} 154 155/// The command the home page offers: lmjtfy's. 156pub fn command(origin: &str) -> String { 157 Repo::Lmjtfy.command(origin) 158} 159 160/// `?view=agents` on the front page shows the root CLAUDE.md. 161#[derive(Deserialize)] 162pub struct Viewed { 163 view: Option<String>, 164} 165 166#[derive(Deserialize)] 167pub struct Service { 168 #[serde(default)] 169 service: String, 170} 171 172/// `GET /lmjtfy.git` (or any served repository) from a browser or a chat's 173/// link preview: its front page. Git never asks for this path, so the clone 174/// address is also the page. 175#[worker::send] 176pub async fn landing( 177 State(app): State<App>, 178 Path(segment): Path<String>, 179 Query(asked): Query<Viewed>, 180 headers: HeaderMap, 181) -> Response<Body> { 182 let Some(repo) = Repo::named(&segment) else { return refused(StatusCode::NOT_FOUND, "No such page.") }; 183 let latest = latest(&app.env, repo).await; 184 let view = crate::view::code::View::asked(asked.view.as_deref()); 185 let docs = crate::browse::root(&app.env, repo, view).await; 186 let whole = crate::browse::explorer(&app.env, repo).await; 187 let origin = super::origin(&headers); 188 let frame = crate::browse::frame(&origin, repo, "", &whole, latest.as_ref()); 189 let page = crate::view::code::page(&frame, crate::view::code::Shown::Dir { docs }); 190 Response::builder() 191 .header(header::CONTENT_TYPE, "text/html; charset=utf-8") 192 .header(header::CACHE_CONTROL, "no-cache") 193 .body(Body::from(page.into_string())) 194 .expect("static headers are valid") 195} 196 197/// The latest commit on main, read at most once a minute per isolate. If 198/// GitHub cannot be read, the last one read, however old; `None` only if 199/// there has never been one. 200pub async fn latest(env: &worker::Env, repo: Repo) -> Option<Commit> { 201 let now = js_sys::Date::now(); 202 let kept = LATEST.with(|latest| latest.borrow().iter().find(|(seen, _, _)| *seen == repo).map(|(_, at, commit)| (*at, commit.clone()))); 203 if let Some((at, commit)) = &kept 204 && now - at < LATEST_MS 205 { 206 return Some(commit.clone()); 207 } 208 match read_latest(env, repo).await { 209 Some(commit) => { 210 LATEST.with(|latest| { 211 let mut latest = latest.borrow_mut(); 212 latest.retain(|(seen, _, _)| *seen != repo); 213 latest.push((repo, now, commit.clone())); 214 }); 215 Some(commit) 216 } 217 None => kept.map(|(_, commit)| commit), 218 } 219} 220 221async fn read_latest(env: &worker::Env, repo: Repo) -> Option<Commit> { 222 let url = format!("https://api.github.com/repos/{}/commits?sha={}&per_page=1", repo.github(), repo.branch()); 223 match github(env, &url).await? { 224 (200, body, link) => listed(&body, link.as_deref()), 225 _ => None, 226 } 227} 228 229/// A GET of GitHub's API with the read-only token: its status, body and 230/// `link` header. `None` if there is no token or GitHub did not answer. 231pub async fn github(env: &worker::Env, url: &str) -> Option<(u16, String, Option<String>)> { 232 let token = env.secret(TOKEN).ok()?.to_string(); 233 let headers = worker::Headers::new(); 234 headers.set("authorization", &format!("Bearer {token}")).ok()?; 235 headers.set("accept", "application/vnd.github+json").ok()?; 236 // GitHub refuses API requests without one. 237 headers.set("user-agent", "lmjtfy").ok()?; 238 let mut init = worker::RequestInit::new(); 239 init.with_headers(headers); 240 let request = worker::Request::new_with_init(url, &init).ok()?; 241 let mut response = worker::Fetch::Request(request).send().await.ok()?; 242 let link = response.headers().get("link").ok().flatten(); 243 Some((response.status_code(), response.text().await.ok()?, link)) 244} 245 246#[derive(Deserialize)] 247struct Listed { 248 sha: String, 249 commit: Inner, 250} 251 252#[derive(Deserialize)] 253struct Inner { 254 message: String, 255 committer: Option<Signed>, 256} 257 258#[derive(Deserialize)] 259struct Signed { 260 date: String, 261} 262 263/// The commit in GitHub's one-item list, and the count from its `link` 264/// header. 265fn listed(body: &str, link: Option<&str>) -> Option<Commit> { 266 let mut listed: Vec<Listed> = serde_json::from_str(body).ok()?; 267 let first = (!listed.is_empty()).then(|| listed.swap_remove(0))?; 268 Some(Commit { 269 subject: first.commit.message.lines().next().unwrap_or_default().to_owned(), 270 date: first.commit.committer.map(|signed| signed.date.chars().take(10).collect()).unwrap_or_default(), 271 count: link.and_then(last_page).or(Some(1)), 272 sha: first.sha, 273 }) 274} 275 276/// The page number of `rel="last"` in a `link` header. With no `link` there 277/// is one page, which the caller counts. 278fn last_page(link: &str) -> Option<u64> { 279 let last = link.split(',').find(|part| part.contains("rel=\"last\""))?; 280 let page = last.split(['?', '&', '>']).find_map(|part| part.strip_prefix("page="))?; 281 page.parse().ok() 282} 283 284/// `GET /<repo>/info/refs?service=git-upload-pack`. Without the service 285/// parameter this is git's old dumb protocol, which is not served. 286#[worker::send] 287pub async fn refs(State(app): State<App>, Path(segment): Path<String>, Query(asked): Query<Service>, headers: HeaderMap) -> Response<Body> { 288 let Some(repo) = Repo::named(&segment) else { return refused(StatusCode::NOT_FOUND, "No such repository.") }; 289 if asked.service != UPLOAD_PACK { 290 return refused(StatusCode::FORBIDDEN, "This copy can be cloned and fetched, and nothing else."); 291 } 292 let url = format!("{}/info/refs?service={UPLOAD_PACK}", repo.upstream()); 293 forward(&app, Method::GET, url, &headers, None).await 294} 295 296/// `POST /<repo>/git-upload-pack`: the pack. 297#[worker::send] 298pub async fn upload_pack( 299 State(app): State<App>, 300 Path(segment): Path<String>, 301 axum::extract::Extension(event): axum::extract::Extension<crate::events::Event>, 302 headers: HeaderMap, 303 body: Body, 304) -> Response<Body> { 305 let Some(repo) = Repo::named(&segment) else { return refused(StatusCode::NOT_FOUND, "No such repository.") }; 306 let Ok(body) = to_bytes(body, MAX_REQUEST_BYTES).await else { 307 return refused(StatusCode::PAYLOAD_TOO_LARGE, "That request is too large."); 308 }; 309 let fetch = fetched(&body, headers.get(header::CONTENT_ENCODING).and_then(|value| value.to_str().ok())); 310 let url = format!("{}/{UPLOAD_PACK}", repo.upstream()); 311 let response = forward(&app, Method::POST, url, &headers, Some(body.to_vec())).await; 312 let Some(fetch) = fetch.filter(|_| response.status() == StatusCode::OK) else { return response }; 313 // A round of negotiation that ends a fetch is the one GitHub answers 314 // with the pack. The start of the reply is read to see, and sent on 315 // with the rest. 316 let (parts, body) = response.into_parts(); 317 let mut rest = body.into_data_stream(); 318 let mut start = Vec::new(); 319 while start.len() < PEEK_BYTES && !packs(&start) { 320 match rest.next().await { 321 Some(Ok(chunk)) => start.extend_from_slice(&chunk), 322 _ => break, 323 } 324 } 325 if packs(&start) { 326 let which = match fetch { 327 Fetch::Clone => "clone", 328 Fetch::Pull => "pull", 329 }; 330 let mut event = event.named("fetch").with(which); 331 event.status = 200.0; 332 crate::events::record(&app.env, event).await; 333 crate::archive::fetched(&app.env, repo.served(), fetch).await; 334 } 335 let first = futures_util::stream::once(async move { Ok::<_, axum::Error>(bytes::Bytes::from(start)) }); 336 Response::from_parts(parts, Body::from_stream(first.chain(rest))) 337} 338 339/// How much of a reply is read before giving up on finding a pack: past the 340/// acknowledgments a long negotiation can send. 341const PEEK_BYTES: usize = 64 * 1024; 342 343/// Whether a reply has begun sending a pack: protocol v2's `packfile` 344/// section, or a v0 pack's `PACK` signature. 345fn packs(start: &[u8]) -> bool { 346 start.windows(9).any(|window| window == b"packfile\n") || start.windows(4).any(|window| window == b"PACK") 347} 348 349/// What a fetch is, from its upload-pack request: a clone if it names no 350/// commit the client has, a pull if it does. `None` for anything that is 351/// not a fetch, such as protocol v2's `ls-refs`, which every clone and pull 352/// (and a pull with nothing new) starts with. Every round of a negotiation 353/// looks like this; the reply says which round sent the pack (`packs`). 354pub fn fetched(body: &[u8], encoding: Option<&str>) -> Option<Fetch> { 355 let body = match encoding { 356 Some(encoding) if encoding.eq_ignore_ascii_case("gzip") => { 357 let mut unzipped = Vec::new(); 358 flate2::read::GzDecoder::new(body).take(MAX_REQUEST_BYTES as u64 * 8).read_to_end(&mut unzipped).ok()?; 359 unzipped 360 } 361 Some(_) => return None, 362 None => body.to_vec(), 363 }; 364 let lines = pkt_lines(&body)?; 365 let command = lines.iter().find_map(|line| line.strip_prefix(b"command=".as_slice())); 366 if command.is_some_and(|command| command != b"fetch") { 367 return None; 368 } 369 let wants = lines.iter().any(|line| line.starts_with(b"want ")); 370 let haves = lines.iter().any(|line| line.starts_with(b"have ")); 371 match (wants, haves) { 372 (true, false) => Some(Fetch::Clone), 373 (true, true) => Some(Fetch::Pull), 374 (false, _) => None, 375 } 376} 377 378/// Git's pkt-lines: four hex digits of length, then the payload, without its 379/// newline. Flush, delimiter and response-end packets (`0000`, `0001`, 380/// `0002`) carry nothing. `None` if the body is not pkt-lines. 381fn pkt_lines(body: &[u8]) -> Option<Vec<&[u8]>> { 382 let mut lines = Vec::new(); 383 let mut at = 0; 384 while at < body.len() { 385 let length = usize::from_str_radix(std::str::from_utf8(body.get(at..at + 4)?).ok()?, 16).ok()?; 386 if length < 4 { 387 at += 4; 388 continue; 389 } 390 let line = body.get(at + 4..at + length)?; 391 lines.push(line.strip_suffix(b"\n").unwrap_or(line)); 392 at += length; 393 } 394 Some(lines) 395} 396 397async fn forward(app: &App, method: Method, url: String, headers: &HeaderMap, body: Option<Vec<u8>>) -> Response<Body> { 398 let Ok(token) = app.env.secret(TOKEN).map(|secret| secret.to_string()) else { 399 return refused(StatusCode::SERVICE_UNAVAILABLE, "Cloning is not set up here yet."); 400 }; 401 let mut request = http::Request::builder().method(method).uri(url); 402 for (name, value) in forwarded(headers) { 403 request = request.header(name, value); 404 } 405 request = request.header(header::AUTHORIZATION, authorization(&token)); 406 let request = match request.body(Body::from(body.unwrap_or_default())) { 407 Ok(request) => request, 408 Err(_) => return refused(StatusCode::BAD_REQUEST, "That request could not be read."), 409 }; 410 let sent = match worker::Request::try_from(request) { 411 Ok(request) => worker::Fetch::Request(request).send().await, 412 Err(error) => Err(error), 413 }; 414 match sent { 415 // Streamed back as GitHub sends it: a pack can be megabytes. 416 Ok(response) => response.into(), 417 Err(_) => refused(StatusCode::BAD_GATEWAY, "GitHub did not answer."), 418 } 419} 420 421/// The client's headers that are passed on. Its own `authorization`, if it 422/// sent one, is not: the token is the only credential GitHub sees. 423fn forwarded(headers: &HeaderMap) -> impl Iterator<Item = (&'static str, &header::HeaderValue)> { 424 FORWARDED.into_iter().filter_map(|name| headers.get(name).map(|value| (name, value))) 425} 426 427/// GitHub takes a token over git's HTTP as the password of Basic auth. 428fn authorization(token: &str) -> String { 429 format!("Basic {}", STANDARD.encode(format!("x-access-token:{token}"))) 430} 431 432pub fn refused(status: StatusCode, why: &str) -> Response<Body> { 433 Response::builder() 434 .status(status) 435 .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") 436 .body(Body::from(format!("{why}\n"))) 437 .expect("a static response") 438} 439 440#[cfg(test)] 441mod tests { 442 use super::*; 443 444 #[test] 445 fn only_the_listed_repositories_are_served() { 446 assert_eq!(Repo::named("lmjtfy.git"), Some(Repo::Lmjtfy)); 447 assert_eq!(Repo::named("jevcrates.git"), Some(Repo::Jevcrates)); 448 assert_eq!(Repo::named("postjevsql.git"), Some(Repo::Postjevsql)); 449 assert_eq!(Repo::named("jevhooks.git"), Some(Repo::Jevhooks)); 450 assert_eq!(Repo::named("jevsnes.git"), Some(Repo::Jevsnes)); 451 assert_eq!(Repo::named("lmjtfy"), None); 452 assert_eq!(Repo::named("nixos-config.git"), None); 453 assert_eq!(Repo::named("..%2Fnixos-config.git"), None); 454 } 455 456 #[test] 457 fn the_submodule_resolves_to_a_served_repository() { 458 // `.gitmodules` names jevcrates relative to lmjtfy, so the path it 459 // resolves to here must be one that is served. 460 let gitmodules = include_str!("../../../.gitmodules"); 461 assert!(gitmodules.contains("url = ../jevcrates.git"), "{gitmodules}"); 462 assert_eq!(Repo::named("jevcrates.git"), Some(Repo::Jevcrates)); 463 for (mount, repo) in Repo::Lmjtfy.submodules() { 464 assert!(gitmodules.contains(&format!("path = {mount}")), "{mount}"); 465 assert!(gitmodules.contains(&format!("url = ../{}", repo.served())), "{mount}"); 466 } 467 } 468 469 #[test] 470 fn a_repository_with_submodules_is_cloned_with_them() { 471 assert_eq!(Repo::Lmjtfy.command("https://x"), "git clone --recurse-submodules https://x/lmjtfy.git"); 472 assert_eq!(Repo::Jevcrates.command("https://x"), "git clone https://x/jevcrates.git"); 473 // Every submodule is a served repository, so a clone from here can 474 // fetch it from here. 475 for repo in Repo::ALL { 476 for (_, inner) in repo.submodules() { 477 assert!(Repo::ALL.contains(inner), "{repo:?}"); 478 } 479 } 480 } 481 482 #[test] 483 fn the_clients_own_credentials_are_not_forwarded() { 484 let mut headers = HeaderMap::new(); 485 headers.insert("authorization", "Basic c29tZW9uZQ==".parse().unwrap()); 486 headers.insert("cookie", "a=b".parse().unwrap()); 487 headers.insert("git-protocol", "version=2".parse().unwrap()); 488 headers.insert("user-agent", "git/2.51.0".parse().unwrap()); 489 let names: Vec<_> = forwarded(&headers).map(|(name, _)| name).collect(); 490 assert_eq!(names, ["git-protocol", "user-agent"]); 491 } 492 493 #[test] 494 fn the_token_is_the_basic_password() { 495 let decoded = STANDARD.decode(authorization("t0k").trim_start_matches("Basic ")).unwrap(); 496 assert_eq!(decoded, b"x-access-token:t0k"); 497 } 498 499 #[test] 500 fn the_latest_commit_is_read_from_githubs_list() { 501 let body = r#"[{"sha":"4d93353abc","commit":{"message":"clone: the token\n\nmore","committer":{"date":"2026-10-02T23:59:31Z"}}}]"#; 502 let link = r#"<https://api.github.com/repositories/1/commits?sha=main&per_page=1&page=2>; rel="next", <https://api.github.com/repositories/1/commits?sha=main&per_page=1&page=140>; rel="last""#; 503 let commit = listed(body, Some(link)).unwrap(); 504 assert_eq!(commit.short(), "4d93353"); 505 assert_eq!(commit.subject, "clone: the token"); 506 assert_eq!(commit.date, "2026-10-02"); 507 assert_eq!(commit.count, Some(140)); 508 assert_eq!(listed(body, None).unwrap().count, Some(1)); 509 assert_eq!(listed("[]", None), None); 510 } 511 512 fn pkt(lines: &[&str]) -> Vec<u8> { 513 let mut out = Vec::new(); 514 for line in lines { 515 match *line { 516 "0000" | "0001" => out.extend_from_slice(line.as_bytes()), 517 line => out.extend_from_slice(format!("{:04x}{line}\n", line.len() + 5).as_bytes()), 518 } 519 } 520 out 521 } 522 523 #[test] 524 fn a_clone_and_a_pull_are_told_apart() { 525 let want = "want 4d93353131e2d6b5a1b1a3a3c5f2f6a7b8c9d0e1"; 526 let have = "have 886ece71e2d6b5a1b1a3a3c5f2f6a7b8c9d0e1f2"; 527 // Protocol v2, as git 2.x sends it. 528 let clone = pkt(&["command=fetch", "agent=git/2.51.0", "0001", "thin-pack", want, "done", "0000"]); 529 assert_eq!(fetched(&clone, None), Some(Fetch::Clone)); 530 let pull = pkt(&["command=fetch", "0001", want, have, "done", "0000"]); 531 assert_eq!(fetched(&pull, None), Some(Fetch::Pull)); 532 // Without `done`: the server may still send the pack, and the reply 533 // says whether it did. 534 let round = pkt(&["command=fetch", "0001", want, have, "0000"]); 535 assert_eq!(fetched(&round, None), Some(Fetch::Pull)); 536 let refs = pkt(&["command=ls-refs", "0001", "peel", "symrefs", "0000"]); 537 assert_eq!(fetched(&refs, None), None); 538 // Protocol v0, with capabilities on the first want. 539 let old = pkt(&[&format!("{want} multi_ack side-band-64k"), "0000", "done"]); 540 assert_eq!(fetched(&old, None), Some(Fetch::Clone)); 541 assert_eq!(fetched(b"not pkt lines", None), None); 542 } 543 544 #[test] 545 fn only_a_reply_that_sends_the_pack_counts() { 546 assert!(packs(&pkt(&["acknowledgments", "ACK 886ece7", "ready", "0001", "packfile", "0000"]))); 547 assert!(packs(b"0008NAK\n0031\x01PACK\x00\x00\x00\x02")); 548 assert!(!packs(&pkt(&["acknowledgments", "NAK", "0000"]))); 549 assert!(!packs(&pkt(&["886ece7 HEAD symref-target:refs/heads/main", "0000"]))); 550 } 551 552 #[test] 553 fn a_gzipped_request_is_read() { 554 use std::io::Write; 555 let pull = pkt(&["command=fetch", "0001", "want a", "have b", "done", "0000"]); 556 let mut gzip = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); 557 gzip.write_all(&pull).unwrap(); 558 assert_eq!(fetched(&gzip.finish().unwrap(), Some("gzip")), Some(Fetch::Pull)); 559 assert_eq!(fetched(&pull, Some("br")), None); 560 } 561 562 #[test] 563 fn the_command_clones_the_submodule_too() { 564 assert_eq!(command("https://lmjtfy.fun"), "git clone --recurse-submodules https://lmjtfy.fun/lmjtfy.git"); 565 } 566}