7513901b

Archive
Tree [7513901b] [>]
commit
7513901bcd62657b2703e715047f69632a29de08
parent
author
Christopher K. Schmitt <me@shmish.dev>
date
2026-07-29
committer
Christopher K. Schmitt <me@shmish.dev>
date
2026-07-29
changes
3
insertions
157
deletions
2
Add smart HTTP protocol handlers
Msrc/routes.rs
~mod blame;~mod branch;~mod commit;+mod git_upload;~mod raw;~mod repo;~mod tag;
~use axum::http::header;~use axum::http::request::Parts;~use axum::response::IntoResponse;-use axum::routing::get;+use axum::routing::{get, post};~use serde::Serialize;~~/// A breadcrumb segment for the navigation bar.
~        .route("/{repo}/commits/{sha}/blame/{*path}", get(blame::handler))~        .route("/{repo}/commits/{sha}/raw/{*path}", get(raw::handler))~        .route("/{repo}/archive/{format}/{*ref}", get(archive::handler))+        .route("/{repo}/info/refs", get(git_upload::info_refs))+        .route("/{repo}/git-upload-pack", post(git_upload::upload_pack))~        .with_state(state)~}
Msrc/routes/archive.rs
~/// Background task that reads stderr and waits for the child process to exit.~/// Errors are printed to stderr — by this point we have already started~/// streaming the response and cannot change the HTTP status.-async fn reaper(mut child: Child, mut stderr: impl AsyncRead + Unpin, label: &'static str) {+pub(crate) async fn reaper(+    mut child: Child,+    mut stderr: impl AsyncRead + Unpin,+    label: &'static str,+) {~    use tokio::io::AsyncReadExt;~    let mut buf = String::new();~    let _ = stderr.read_to_string(&mut buf).await;
Asrc/routes/git_upload.rs
+//! Smart HTTP git protocol handlers.+//!+//! Provides handlers for `info/refs` (ref advertisement) and `git-upload-pack`+//! (pack negotiation and transport), enabling `git clone` and `git fetch` over+//! HTTP without CGI.+//!+//! Both handlers shell out to `git upload-pack --stateless-rpc`, following the+//! same streaming pattern as the archive handler.++use std::process::Stdio;++use axum::body::Body;+use axum::extract::{Query, State};+use axum::http::HeaderMap;+use axum::http::header;+use axum::response::IntoResponse;+use serde::Deserialize;+use tokio::process::Command;+use tokio_util::io::ReaderStream;++use crate::error::{Error, Result};+use crate::git;+use crate::routes::{AppState, RepoName};++use super::archive::reaper;++/// Query parameters for `info/refs`.+#[derive(Deserialize)]+pub(super) struct InfoRefsQuery {+    service: Option<String>,+}++/// Encodes a string as a pkt-line (4-digit hex length + payload).+fn pkt_line(s: &str) -> Vec<u8> {+    let len = s.len() + 4;+    format!("{len:04x}{s}").into_bytes()+}++/// Handles `GET /{repo}/info/refs?service=git-upload-pack`.+///+/// Returns a ref advertisement so clients can discover branches and tags before+/// fetching.  The response is a pkt-line stream with a `# service=git-upload-pack`+/// header, followed by the output of `git upload-pack --stateless-rpc --advertise-refs`.+pub async fn info_refs(+    State(state): State<AppState>,+    RepoName(repo): RepoName,+    Query(query): Query<InfoRefsQuery>,+) -> Result<impl IntoResponse> {+    let _service = query+        .service+        .filter(|s| s == "git-upload-pack")+        .ok_or_else(|| Error::BadRequest("unknown or missing service".into()))?;++    let repo_path = state.root.join(&repo);+    if !repo_path.exists() {+        return Err(Error::RepoNotFound(repo));+    }++    let _git_repo = git::open_repo(&state.root, &repo)?;++    let output = Command::new("git")+        .arg("upload-pack")+        .arg("--stateless-rpc")+        .arg("--advertise-refs")+        .arg(repo_path.as_os_str())+        .stdout(Stdio::piped())+        .stderr(Stdio::piped())+        .output()+        .await+        .map_err(Error::Io)?;++    if !output.status.success() {+        let stderr = String::from_utf8_lossy(&output.stderr);+        return Err(Error::GitCorrupt(format!(+            "git upload-pack --advertise-refs failed: {stderr}"+        )));+    }++    let mut buf = Vec::new();+    buf.extend_from_slice(&pkt_line("# service=git-upload-pack\n"));+    buf.extend_from_slice(b"0000");+    buf.extend_from_slice(&output.stdout);++    Ok((+        [(+            header::CONTENT_TYPE,+            "application/x-git-upload-pack-advertisement",+        )],+        buf,+    ))+}++/// Handles `POST /{repo}/git-upload-pack`.+///+/// Processes a pack-request body (wants / haves) and streams back the generated+/// pack file.  The response is a pkt-line stream with status/progress messages+/// in band 2 and pack data in band 1.+pub async fn upload_pack(+    State(state): State<AppState>,+    RepoName(repo): RepoName,+    headers: HeaderMap,+    body: String,+) -> Result<impl IntoResponse> {+    let content_type = headers+        .get(header::CONTENT_TYPE)+        .and_then(|v| v.to_str().ok())+        .unwrap_or("");+    if content_type != "application/x-git-upload-pack-request" {+        return Err(Error::BadRequest(+            "expected application/x-git-upload-pack-request".into(),+        ));+    }++    let repo_path = state.root.join(&repo);+    if !repo_path.exists() {+        return Err(Error::RepoNotFound(repo));+    }++    let _git_repo = git::open_repo(&state.root, &repo)?;++    let mut child = Command::new("git")+        .arg("upload-pack")+        .arg("--stateless-rpc")+        .arg(repo_path.as_os_str())+        .stdin(Stdio::piped())+        .stdout(Stdio::piped())+        .stderr(Stdio::piped())+        .spawn()+        .map_err(Error::Io)?;++    let mut stdin = child.stdin.take().expect("stdin configured");+    let stdout = child.stdout.take().expect("stdout configured");+    let stderr = child.stderr.take().expect("stderr configured");++    tokio::spawn(async move {+        use tokio::io::AsyncWriteExt;+        let _ = stdin.write_all(body.as_bytes()).await;+        let _ = stdin.shutdown().await;+    });++    tokio::spawn(reaper(child, stderr, "git upload-pack"));++    let stream = ReaderStream::new(stdout);+    Ok((+        [(header::CONTENT_TYPE, "application/x-git-upload-pack-result")],+        Body::from_stream(stream),+    ))+}