lmjtfy.git / apps / lmjtfy / src / clone.rs
clone.rsannotatedclone.rssource566 lines · 23.4 KB · raw
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}