chadtmiller.com/knotless
Git hosted in your atproto account, on any stock PDS, fast enough for agents.
git clone knotless://chadtmiller.com/knotlessneeds the knotless git helper. set it up
//! 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();
}
}