chadtmiller.com/knotless

Git hosted in your atproto account, on any stock PDS, fast enough for agents.

git clone knotless://chadtmiller.com/knotless

needs the knotless git helper. set it up

232 lines · 7.1 KB Raw History
//! Files, trees, history and diffs without a clone.

use crate::{App, AppError, repo_name};
use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response};
use knotless::git::Git;
use serde::Deserialize;
use serde_json::{Value, json};
use std::sync::Arc;

#[derive(Deserialize, Default)]
pub struct Params {
    rev: Option<String>,
    path: Option<String>,
    limit: Option<usize>,
    base: Option<String>,
    head: Option<String>,
}

type Repo = Path<(String, String)>;

async fn open(app: &App, (authority, repo): (String, String)) -> Result<Git, AppError> {
    app.mirror(&authority, repo_name(&repo)).await
}

/// A revision from the query, refusing anything git could read as an option.
fn rev(value: Option<&str>) -> Result<String, AppError> {
    let rev = value.unwrap_or("HEAD");
    if rev.is_empty() || rev.starts_with('-') || rev.contains(['\0', '\n', ' ']) {
        return Err(AppError(
            StatusCode::BAD_REQUEST,
            format!("bad revision {rev:?}"),
        ));
    }
    Ok(rev.to_string())
}

fn path(value: Option<&str>) -> String {
    value.unwrap_or_default().trim_matches('/').to_string()
}

async fn git(git: &Git, args: &[&str]) -> Result<Vec<u8>, AppError> {
    git.run(args, None)
        .await
        .map_err(|e| AppError(StatusCode::NOT_FOUND, format!("{e:#}")))
}

/// Caching for a response about `rev`: forever when it names a commit by
/// its hash, briefly when it names a ref that can move.
fn cache_for(rev: &str) -> &'static str {
    if rev.len() == 40 && rev.bytes().all(|b| b.is_ascii_hexdigit()) {
        "public, max-age=31536000, immutable"
    } else {
        "no-cache"
    }
}

pub async fn refs(State(app): State<Arc<App>>, Path(repo): Repo) -> Result<Json<Value>, AppError> {
    let g = open(&app, repo).await?;
    let refs: Vec<Value> = g
        .refs()
        .await
        .map_err(AppError::internal)?
        .into_iter()
        .map(|(name, target)| json!({"name": name, "target": target}))
        .collect();
    let head = g.text(&["symbolic-ref", "-q", "HEAD"], None).await.ok();
    Ok(Json(json!({"refs": refs, "head": head})))
}

pub async fn tree(
    State(app): State<Arc<App>>,
    Path(repo): Repo,
    Query(q): Query<Params>,
) -> Result<Response, AppError> {
    let g = open(&app, repo).await?;
    let rev = rev(q.rev.as_deref())?;
    let path = path(q.path.as_deref());
    let out = git(&g, &["ls-tree", "-l", "-z", &format!("{rev}:{path}")]).await?;
    let entries: Vec<Value> = String::from_utf8_lossy(&out)
        .split('\0')
        .filter_map(|line| {
            let (meta, name) = line.split_once('\t')?;
            let mut f = meta.split_whitespace();
            let (mode, kind, sha, size) = (f.next()?, f.next()?, f.next()?, f.next()?);
            Some(json!({"name": name, "mode": mode, "type": kind, "sha": sha, "size": size.parse::<u64>().ok()}))
        })
        .collect();
    Ok((
        [(header::CACHE_CONTROL, cache_for(&rev))],
        Json(json!({"rev": rev, "path": path, "entries": entries})),
    )
        .into_response())
}

pub async fn blob(
    State(app): State<Arc<App>>,
    Path(repo): Repo,
    Query(q): Query<Params>,
) -> Result<Response, AppError> {
    let g = open(&app, repo).await?;
    let rev = rev(q.rev.as_deref())?;
    let spec = format!("{rev}:{}", path(q.path.as_deref()));
    let sha = String::from_utf8_lossy(&git(&g, &["rev-parse", "--verify", &spec]).await?)
        .trim()
        .to_string();
    let bytes = git(&g, &["cat-file", "blob", &sha]).await?;
    Ok((
        [
            (header::CONTENT_TYPE, "application/octet-stream".to_string()),
            (header::ETAG, format!("\"{sha}\"")),
            (header::CACHE_CONTROL, cache_for(&rev).to_string()),
        ],
        bytes,
    )
        .into_response())
}

pub async fn log(
    State(app): State<Arc<App>>,
    Path(repo): Repo,
    Query(q): Query<Params>,
) -> Result<Json<Value>, AppError> {
    let g = open(&app, repo).await?;
    let rev = rev(q.rev.as_deref())?;
    let limit = q.limit.unwrap_or(50).min(1000).to_string();
    let mut args = vec![
        "log",
        "-z",
        "--format=%H%x1f%P%x1f%an%x1f%ae%x1f%aI%x1f%B",
        "-n",
        &limit,
        &rev,
        "--",
    ];
    let path = path(q.path.as_deref());
    if !path.is_empty() {
        args.push(&path);
    }
    let out = git(&g, &args).await?;
    let commits: Vec<Value> = String::from_utf8_lossy(&out)
        .split('\0')
        .filter(|s| !s.is_empty())
        .map(|record| {
            let mut f = record.splitn(6, '\x1f');
            let mut field = || f.next().unwrap_or_default();
            let (sha, parents, name, email, date, message) =
                (field(), field(), field(), field(), field(), field());
            json!({
                "sha": sha,
                "parents": parents.split_whitespace().collect::<Vec<_>>(),
                "author": {"name": name, "email": email, "date": date},
                "message": message.trim_end(),
            })
        })
        .collect();
    Ok(Json(json!({"rev": rev, "commits": commits})))
}

pub async fn diff(
    State(app): State<Arc<App>>,
    Path(repo): Repo,
    Query(q): Query<Params>,
) -> Result<Response, AppError> {
    let g = open(&app, repo).await?;
    let base = rev(q.base.as_deref())?;
    let head = rev(q.head.as_deref())?;
    let out = git(
        &g,
        &[
            "diff",
            "--no-color",
            "--no-ext-diff",
            &format!("{base}...{head}"),
            "--",
        ],
    )
    .await?;
    Ok(([(header::CONTENT_TYPE, "text/x-diff; charset=utf-8")], out).into_response())
}

pub async fn object(
    State(app): State<Arc<App>>,
    Path((authority, repo, sha)): Path<(String, String, String)>,
) -> Result<Response, AppError> {
    if !(sha.len() == 40 || sha.len() == 64) || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
        return Err(AppError(
            StatusCode::BAD_REQUEST,
            format!("not an object id: {sha}"),
        ));
    }
    let g = open(&app, (authority, repo)).await?;
    let kind = String::from_utf8_lossy(&git(&g, &["cat-file", "-t", &sha]).await?)
        .trim()
        .to_string();
    let bytes = git(&g, &["cat-file", &kind, &sha]).await?;
    Ok((
        [
            (header::CONTENT_TYPE, "application/octet-stream".to_string()),
            (header::HeaderName::from_static("x-git-type"), kind),
            (header::ETAG, format!("\"{sha}\"")),
            (
                header::CACHE_CONTROL,
                "public, max-age=31536000, immutable".to_string(),
            ),
        ],
        bytes,
    )
        .into_response())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn revisions_and_caching() {
        assert_eq!(rev(None).unwrap(), "HEAD");
        assert!(rev(Some("--output=/x")).is_err());
        assert!(rev(Some("")).is_err());
        assert!(rev(Some("a b")).is_err());
        assert_eq!(path(Some("/src/")), "src");
        assert_eq!(
            cache_for(&"a".repeat(40)),
            "public, max-age=31536000, immutable"
        );
        assert_eq!(cache_for("main"), "no-cache");
        let _ = Params::default();
    }
}