johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! A repository and what it holds, as XRPC queries: `dev.knotless.getRepo`,
//! `getTree`, `listPaths`, `getLastCommits`, `getFile`, `getBlame`, `listCommits`, `getCommit`,
//! `listBranches` and `listTags`.
use super::*;
/// The parameters of the queries of a repository: each takes what it
/// declares.
#[derive(Deserialize)]
pub struct RepoParams {
repo: String,
#[serde(rename = "ref")]
refname: Option<String>,
path: Option<String>,
limit: Option<usize>,
cursor: Option<String>,
#[serde(rename = "subjectOnly", default)]
subject_only: bool,
#[serde(default)]
commits: bool,
}
/// The most commits a page of `listCommits` holds, and how many if not
/// asked.
const COMMITS_MAX: usize = 100;
const COMMITS_PER_PAGE: usize = 50;
/// The same of branches and tags, for `listBranches` and `listTags`.
const BRANCHES_MAX: usize = 1000;
const BRANCHES_PER_PAGE: usize = 100;
pub(super) fn invalid(message: String) -> Shown {
Shown(StatusCode::BAD_REQUEST, message)
}
/// The repository whose record `uri` names, its authority a DID or a
/// handle, or its record in its space, under the name it has now.
pub(super) async fn found(app: &App, uri: &str) -> Result<Repo> {
named(app, uri, false).await
}
/// [`found`] for a view of its code, which may be of a mirror a change has
/// made stale while it syncs.
async fn found_for_reader(app: &App, uri: &str) -> Result<Repo> {
named(app, uri, true).await
}
async fn named(app: &App, uri: &str, reader: bool) -> Result<Repo> {
match knotless::aturi::AtUri::parse(uri) {
Ok(at) if at.collection == nsid::REPO && reader => {
Repo::find_for_reader(app, &at.author, &at.rkey).await
}
Ok(at) if at.collection == nsid::REPO => Repo::find(app, &at.author, &at.rkey).await,
_ => Err(invalid(format!(
"{uri} is not at://<authority>/{}/<name>",
nsid::REPO
))),
}
}
/// How many of a list the pages before held, as a cursor says.
pub(super) fn skipped(cursor: Option<&str>) -> Result<usize> {
match cursor.map(str::parse::<usize>) {
None => Ok(0),
Some(Ok(skip)) => Ok(skip),
Some(Err(_)) => Err(invalid("cursor is no count".into())),
}
}
/// The revision and the path `p` names: the default branch's root, or
/// `ref` and `path` together, split where a branch or a tag ends.
fn located(repo: &Repo, p: &RepoParams) -> Result<(String, String)> {
let path = p.path.as_deref().unwrap_or_default();
let Some(r) = &p.refname else {
return Ok((repo.head.clone(), path.to_string()));
};
let rest = match path {
"" => r.clone(),
path => format!("{r}/{path}"),
};
let (rev, path) = repo.locate(&rest)?;
Ok((rev, path.to_string()))
}
/// `dev.knotless.getRepo`.
pub async fn get_repo(
State(app): State<Arc<App>>,
axum::Extension(viewer): axum::Extension<crate::viewer::Viewer>,
Query(p): Query<RepoParams>,
) -> Result<Response> {
let repo = found_for_reader(&app, &p.repo).await?;
Ok(answer(repo.head(&app, viewer.0.as_deref()).await))
}
/// One entry of a directory.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Entry {
name: String,
/// `tree` or `blob`.
kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
size: Option<u64>,
}
/// A README, as Markdown made into HTML or as text.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Readme {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
html: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
text: Option<String>,
}
/// `dev.knotless.getTree`'s answer: a directory at a revision, its latest
/// commit, its entries, folders first, and its README. Nothing at all, for
/// a repository never pushed.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Tree {
#[serde(rename = "ref")]
refname: String,
path: String,
#[serde(skip_serializing_if = "std::ops::Not::not")]
empty: bool,
#[serde(skip_serializing_if = "Option::is_none")]
last_commit: Option<CommitView>,
entries: Vec<Entry>,
#[serde(skip_serializing_if = "Option::is_none")]
readme: Option<Readme>,
/// What the repository's runners report of the commit shown.
checks: Vec<CheckView>,
/// What the repository is written in, most first: shown at the top
/// of its default branch, and nowhere else.
languages: Vec<LanguageView>,
}
/// `dev.knotless.getTree`.
pub async fn get_tree(
State(app): State<Arc<App>>,
Query(p): Query<RepoParams>,
) -> Result<Response> {
let repo = found_for_reader(&app, &p.repo).await?;
if unpushed(&repo, &p)? {
return Ok(answer(Tree {
refname: repo.head.clone(),
path: String::new(),
empty: true,
last_commit: None,
entries: Vec::new(),
readme: None,
checks: Vec::new(),
languages: Vec::new(),
}));
}
let (rev, path) = located(&repo, &p)?;
Ok(answer(tree(&app, &repo, rev, path).await?))
}
/// Whether `p` asks for the root of the default branch of a repository
/// never pushed, which is nothing rather than not found: the pages link
/// there before the branch exists.
fn unpushed(repo: &Repo, p: &RepoParams) -> Result<bool> {
let root = p.path.as_deref().unwrap_or_default().is_empty();
let head = p.refname.as_deref().is_none_or(|r| r == repo.head);
Ok(root && head && repo.objects()?.tree("HEAD", "").is_err())
}
async fn tree(app: &App, repo: &Repo, rev: String, path: String) -> Result<Tree> {
let o = repo.objects()?;
let mut found = o.tree(&rev, &path).map_err(not_found)?;
found.sort_by_key(|e| (e.kind != "tree", e.name.to_lowercase()));
let last = o.log(&rev, &path, 0, 1).map_err(not_found)?;
let tip = o.resolve(&rev).map_err(not_found)?;
let prefix = if path.is_empty() {
String::new()
} else {
format!("{path}/")
};
let readme = match found
.iter()
.find(|e| e.kind == "blob" && e.name.to_lowercase().starts_with("readme"))
{
Some(readme) => {
let bytes = o
.blob(&rev, &format!("{prefix}{}", readme.name))
.map_err(not_found)?;
let text = String::from_utf8_lossy(&bytes).into_owned();
let html = is_markdown(&readme.name).then(|| {
markdown(
&text,
&repo.files("blob", &rev),
&repo.files("raw", &rev),
&path,
Some((&repo.files("tree", &rev), &|dir| o.is_dir(&rev, dir))),
)
});
Some(Readme {
name: readme.name.clone(),
text: html.is_none().then_some(text),
html,
})
}
None => None,
};
let entries = found
.into_iter()
.map(|e| Entry {
name: e.name,
kind: e.kind,
size: e.size,
})
.collect();
// The checks of the commit the directory shows as its last, which is
// the tip at the root but may be older below it.
let shown_at = last.first().map_or(tip, |c| c.sha.clone());
let at = CheckFilter {
commit: Some(&shown_at),
..CheckFilter::default()
};
let basic = repo.basic(app).await;
let languages = if path.is_empty() && rev == repo.head {
repo.languages()
.into_iter()
.map(LanguageView::from)
.collect()
} else {
Vec::new()
};
Ok(Tree {
last_commit: shown(app, repo, last).await.pop(),
checks: newest(checks(app, &repo.reference(), &basic, at, 100)),
refname: rev,
path,
empty: false,
entries,
readme,
languages,
})
}
/// The most paths `listPaths` gives: what a page can filter as it is
/// typed into, and more than nearly every repository holds.
const PATHS_MAX: usize = 50_000;
/// One path of a tree, as `listPaths` gives it.
#[derive(Serialize)]
pub struct PathView {
path: String,
kind: &'static str,
}
/// `dev.knotless.listPaths`'s answer.
#[derive(Serialize)]
pub struct Paths {
#[serde(rename = "ref")]
refname: String,
paths: Vec<PathView>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
truncated: bool,
}
/// `dev.knotless.listPaths`: every path at a revision, for a page to
/// find a file by as it is typed.
pub async fn list_paths(
State(app): State<Arc<App>>,
Query(p): Query<RepoParams>,
) -> Result<Response> {
let repo = found(&app, &p.repo).await?;
let (rev, _) = located(&repo, &p)?;
let limit = p.limit.unwrap_or(PATHS_MAX).clamp(1, PATHS_MAX);
let (paths, truncated) = repo.objects()?.paths(&rev, limit).map_err(not_found)?;
Ok(answer(Paths {
refname: rev,
paths: paths
.into_iter()
.map(|(path, kind)| PathView { path, kind })
.collect(),
truncated,
}))
}
/// One entry of a directory, and the commit that last changed it.
#[derive(Serialize)]
pub struct LastCommit {
name: String,
sha: String,
}
/// `dev.knotless.getLastCommits`'s answer: the newest commit to change
/// each entry of a directory, and each of those commits once.
#[derive(Serialize)]
pub struct LastCommits {
#[serde(rename = "ref")]
refname: String,
path: String,
entries: Vec<LastCommit>,
commits: Vec<CommitView>,
/// How many commits the ref's history holds: at the root only.
#[serde(rename = "commitCount", skip_serializing_if = "Option::is_none")]
commit_count: Option<usize>,
}
/// `dev.knotless.getLastCommits`: found off the server's async threads, as
/// a long history takes a while to walk the first time.
pub async fn get_last_commits(
State(app): State<Arc<App>>,
Query(p): Query<RepoParams>,
) -> Result<Response> {
let repo = found_for_reader(&app, &p.repo).await?;
let (rev, path) = located(&repo, &p)?;
let (dir, at, from) = (repo.git.dir.clone(), rev.clone(), path.clone());
let walked = tokio::task::spawn_blocking(move || walk(&dir, &at, &from));
let walked = walked.await.expect("a walk does not panic");
let ((entries, commits), commit_count) = walked.map_err(not_found)?;
Ok(answer(LastCommits {
entries: entries
.into_iter()
.map(|(name, sha)| LastCommit { name, sha })
.collect(),
commits: shown(&app, &repo, commits).await,
commit_count,
refname: rev,
path,
}))
}
/// The last changes of the directory `path` at `rev`, in the mirror at
/// `dir`, and at the root, how many commits its history holds.
fn walk(
dir: &std::path::Path,
rev: &str,
path: &str,
) -> anyhow::Result<(crate::objects::LastChanges, Option<usize>)> {
let objects = crate::objects::open(dir)?;
let changes = objects.last_changes(rev, path)?;
let count = match path {
"" => Some(objects.count(rev)?),
_ => None,
};
Ok((changes, count))
}
/// `dev.knotless.getFile`'s answer: one file at a revision, an image, a
/// binary, too large to show, or text with the ranges its highlighting
/// paints, and for Markdown, made into HTML too.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Blob {
#[serde(rename = "ref")]
refname: String,
path: String,
/// `image`, `binary`, `large` or `text`.
kind: String,
size: u64,
lines: usize,
#[serde(skip_serializing_if = "Option::is_none")]
text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
ranges: Option<String>,
/// A Markdown file made into HTML, shown in place of its text unless the
/// page is asked for it plain.
#[serde(skip_serializing_if = "Option::is_none")]
html: Option<String>,
}
/// `dev.knotless.getFile`.
pub async fn get_file(
State(app): State<Arc<App>>,
Query(p): Query<RepoParams>,
) -> Result<Response> {
let repo = found(&app, &p.repo).await?;
let (rev, path) = located(&repo, &p)?;
let o = repo.objects()?;
let bytes = o.blob(&rev, &path).map_err(not_found)?;
let text = String::from_utf8_lossy(&bytes);
let kind = if is_image(&path).is_some() {
"image"
} else if is_binary(&bytes) {
"binary"
} else if bytes.len() > INLINE {
"large"
} else {
"text"
};
let ranges = (kind == "text").then(|| {
highlight::code(&path, &text)
.map(|r| r.encode())
.unwrap_or_default()
});
let html = (kind == "text" && is_markdown(&path)).then(|| {
let dir = path.rsplit_once('/').map_or("", |(dir, _)| dir);
markdown(
&text,
&repo.files("blob", &rev),
&repo.files("raw", &rev),
dir,
Some((&repo.files("tree", &rev), &|dir| o.is_dir(&rev, dir))),
)
});
Ok(answer(Blob {
kind: kind.to_string(),
size: bytes.len() as u64,
lines: text.lines().count().max(1),
text: ranges.is_some().then(|| text.into_owned()),
ranges,
html,
refname: rev,
path,
}))
}
/// The largest file blamed. Blame reads every version of a file back to
/// where each of its lines began, so its cost grows with the file as well
/// as its history.
pub const BLAME_MAX: usize = 256 * 1024;
/// How many blames run at once: the rest wait their turn.
static BLAMING: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);
/// The blames kept, in each of the cache's two generations.
const BLAMES_KEPT: usize = 256;
/// Each file's blame, by the commit and the path: a blame at a commit
/// never changes, so it is kept, for any repository that asks.
type Blames = objects::Kept<(String, String), Arc<Vec<read::Hunk>>>;
static BLAMES: LazyLock<Mutex<Blames>> = LazyLock::new(|| Mutex::new(Blames::new(BLAMES_KEPT)));
/// A run of a file's lines that one commit last changed.
#[derive(Serialize)]
pub struct BlameHunk {
sha: String,
start: usize,
count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
previous: Option<read::Previous>,
}
/// `dev.knotless.getBlame`'s answer: a text file at a revision, the ranges
/// its highlighting paints, who last changed each run of its lines, and
/// each of those commits once.
#[derive(Serialize)]
pub struct Blame {
#[serde(rename = "ref")]
refname: String,
path: String,
lines: usize,
text: String,
ranges: String,
hunks: Vec<BlameHunk>,
commits: Vec<CommitView>,
}
/// `dev.knotless.getBlame`: refused for a binary or a file over
/// [`BLAME_MAX`].
pub async fn get_blame(
State(app): State<Arc<App>>,
Query(p): Query<RepoParams>,
) -> Result<Response> {
let repo = found(&app, &p.repo).await?;
let (rev, path) = located(&repo, &p)?;
let (bytes, sha) = {
let o = repo.objects()?;
let bytes = o.blob(&rev, &path).map_err(not_found)?;
(bytes, o.resolve(&rev).map_err(not_found)?)
};
if is_binary(&bytes) {
return Err(invalid(format!("{path} is binary: only text is blamed")));
}
if bytes.len() > BLAME_MAX {
let most = BLAME_MAX / 1024;
return Err(invalid(format!(
"{path} is over {most} KiB, too large to blame"
)));
}
let hunks = blamed(&repo.git, &sha, &path).await?;
let mut seen = HashSet::new();
let shas = hunks
.iter()
.map(|h| h.sha.as_str())
.filter(|s| seen.insert(*s));
let commits = {
let o = repo.objects()?;
let read: anyhow::Result<Vec<_>> = shas.map(|sha| o.tip(sha)).collect();
read.map_err(AppError::internal)?
};
let text = String::from_utf8_lossy(&bytes).into_owned();
let ranges = highlight::code(&path, &text).map(|r| r.encode());
let hunks = hunks.iter().map(|h| BlameHunk {
sha: h.sha.clone(),
start: h.start,
count: h.count,
previous: h.previous.clone(),
});
Ok(answer(Blame {
hunks: hunks.collect(),
commits: shown(&app, &repo, commits).await,
lines: text.lines().count().max(1),
ranges: ranges.unwrap_or_default(),
text,
refname: rev,
path,
}))
}
/// Who last changed each run of `path`'s lines at the commit `sha`, as
/// kept, or else as git finds it, among as many others as may run at once.
async fn blamed(git: &Git, sha: &str, path: &str) -> Result<Arc<Vec<read::Hunk>>> {
let key = (sha.to_string(), path.to_string());
let kept = BLAMES.lock().unwrap().get(&key);
if let Some(kept) = kept {
return Ok(kept);
}
let _turn = BLAMING.acquire().await.expect("never closed");
let found = read::blame(git, sha, path, None).await;
let found = Arc::new(found.map_err(AppError::internal)?);
BLAMES.lock().unwrap().put(key, found.clone());
Ok(found)
}
/// `dev.knotless.listCommits`'s answer: history, a page at a time, newest
/// first.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct History {
#[serde(rename = "ref")]
refname: String,
path: String,
commits: Vec<CommitView>,
/// The newest run of each workflow at each of `commits`.
checks: Vec<CheckView>,
/// Where the next page starts; none on the last.
#[serde(skip_serializing_if = "Option::is_none")]
cursor: Option<String>,
}
/// The most runs read for a page of `listCommits`' checks: the newest of
/// each workflow at each commit are among them unless a page's commits
/// were run that many times.
const COMMIT_CHECKS_MAX: usize = 1000;
/// The newest run of each workflow at each of `commits`, of `repo`.
async fn commit_checks(app: &App, repo: &Repo, commits: &[CommitView]) -> Vec<CheckView> {
let shas: Vec<String> = commits.iter().map(|c| c.sha.clone()).collect();
let of = CheckFilter {
commits: Some(&shas),
..CheckFilter::default()
};
let basic = repo.basic(app).await;
let all = checks(app, &repo.reference(), &basic, of, COMMIT_CHECKS_MAX);
// The runs come newest first, so the first of each is the one kept.
let mut newest = BTreeMap::new();
for c in all {
newest
.entry((c.workflow.clone(), c.commit.clone()))
.or_insert(c);
}
newest.into_values().collect()
}
/// `dev.knotless.listCommits`.
pub async fn list_commits(
State(app): State<Arc<App>>,
Query(p): Query<RepoParams>,
) -> Result<Response> {
let limit = p.limit.unwrap_or(COMMITS_PER_PAGE).clamp(1, COMMITS_MAX);
let skip = skipped(p.cursor.as_deref())?;
let repo = found(&app, &p.repo).await?;
if unpushed(&repo, &p)? {
return Ok(answer(History {
commits: Vec::new(),
checks: Vec::new(),
refname: repo.head.clone(),
path: String::new(),
cursor: None,
}));
}
let (rev, path) = located(&repo, &p)?;
let log = repo.objects()?.log(&rev, &path, skip, limit);
let log = log.map_err(not_found)?;
let cursor = (log.len() == limit).then(|| (skip + limit).to_string());
let mut commits = shown(&app, &repo, log).await;
if p.subject_only {
for c in &mut commits {
let end = c.message.find('\n').unwrap_or(c.message.len());
c.message.truncate(end);
}
}
let checks = commit_checks(&app, &repo, &commits).await;
Ok(answer(History {
commits,
checks,
refname: rev,
path,
cursor,
}))
}
/// One branch or tag of a repository, and, when asked, the commit it
/// names and that commit's newest run of each workflow.
#[derive(Serialize)]
pub struct RefView {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
commit: Option<CommitView>,
#[serde(skip_serializing_if = "Option::is_none")]
checks: Option<Vec<CheckView>>,
}
/// Each of `names`, branches or tags, as a view: with the commit it
/// names, among `tips` in the same order, and its checks when given.
async fn ref_views(
app: &App,
repo: &Repo,
names: Vec<String>,
tips: Option<Vec<read::Commit>>,
) -> Vec<RefView> {
let Some(tips) = tips else {
let bare = |name| RefView {
name,
commit: None,
checks: None,
};
return names.into_iter().map(bare).collect();
};
let commits = shown(app, repo, tips).await;
let mut at: HashMap<String, Vec<CheckView>> = HashMap::new();
for c in commit_checks(app, repo, &commits).await {
at.entry(c.commit.clone()).or_default().push(c);
}
let view = |(name, commit): (String, CommitView)| RefView {
name,
checks: Some(at.get(&commit.sha).cloned().unwrap_or_default()),
commit: Some(commit),
};
names.into_iter().zip(commits).map(view).collect()
}
/// `dev.knotless.listBranches`'s answer: a page of a repository's
/// branches, the default first.
#[derive(Serialize)]
pub struct Branches {
branches: Vec<RefView>,
/// Where the next page starts; none on the last.
#[serde(skip_serializing_if = "Option::is_none")]
cursor: Option<String>,
}
/// `dev.knotless.listBranches`.
pub async fn list_branches(
State(app): State<Arc<App>>,
Query(p): Query<RepoParams>,
) -> Result<Response> {
let limit = p.limit.unwrap_or(BRANCHES_PER_PAGE).clamp(1, BRANCHES_MAX);
let skip = skipped(p.cursor.as_deref())?;
let repo = found_for_reader(&app, &p.repo).await?;
let all = repo.branches()?;
let cursor = (all.len() > skip + limit).then(|| (skip + limit).to_string());
let page: Vec<String> = all.into_iter().skip(skip).take(limit).collect();
// Each tip read only when asked: the branch menu asks for a thousand.
let tips = if p.commits {
let o = repo.objects()?;
let read: anyhow::Result<Vec<_>> = page.iter().map(|name| o.tip(name)).collect();
Some(read.map_err(AppError::internal)?)
} else {
None
};
Ok(answer(Branches {
branches: ref_views(&app, &repo, page, tips).await,
cursor,
}))
}
/// `dev.knotless.listTags`'s answer: a page of a repository's tags, the
/// newest commit first.
#[derive(Serialize)]
pub struct Tags {
tags: Vec<RefView>,
/// Where the next page starts; none on the last.
#[serde(skip_serializing_if = "Option::is_none")]
cursor: Option<String>,
}
/// `dev.knotless.listTags`.
pub async fn list_tags(
State(app): State<Arc<App>>,
Query(p): Query<RepoParams>,
) -> Result<Response> {
let limit = p.limit.unwrap_or(BRANCHES_PER_PAGE).clamp(1, BRANCHES_MAX);
let skip = skipped(p.cursor.as_deref())?;
let repo = found_for_reader(&app, &p.repo).await?;
let all = repo.objects()?.tags().map_err(AppError::internal)?;
let cursor = (all.len() > skip + limit).then(|| (skip + limit).to_string());
let (names, tips) = all.into_iter().skip(skip).take(limit).unzip();
Ok(answer(Tags {
tags: ref_views(&app, &repo, names, p.commits.then_some(tips)).await,
cursor,
}))
}
/// `dev.knotless.getCommit`'s answer: a commit and what it changed.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Change {
commit: CommitView,
files: Vec<FileDiffView>,
}
/// `dev.knotless.getCommit`. A merge's diff is against its first parent:
/// what it brought into the branch it was made on, where git's own would
/// show only what differs from both, nothing at all for a clean merge.
/// With `firstRows`, only the first files come with their rows, as the
/// commit page draws them at once; with `paths`, only those files, rows
/// and all, for the rest as a reader comes to them: as a pull's files
/// come from `getThread` and `getPullDiff`. Its parameters are pairs, as
/// `paths` repeats.
pub async fn get_commit(
State(app): State<Arc<App>>,
Query(pairs): Query<Vec<(String, String)>>,
) -> Result<Response> {
let one = |name| super::items::named(&pairs, name).first().copied();
let (Some(repo), Some(sha)) = (one("repo"), one("sha")) else {
return Err(invalid("repo and sha are required".into()));
};
let paths = super::items::named(&pairs, "paths");
let max = super::items::DIFF_PATHS_MAX;
if paths.len() > max {
return Err(invalid(format!("paths names at most {max} files")));
}
let rows = match (paths.is_empty(), one("firstRows")) {
(true, Some("true")) => Rows::First,
_ => Rows::All,
};
let repo = found(&app, repo).await?;
let rev = read::rev(Some(sha)).map_err(not_found)?;
let log = repo.objects()?.log(&rev, "", 0, 1).map_err(not_found)?;
let c = shown(&app, &repo, log).await.remove(0);
let files = files(
&repo.git,
&["show", "--format=", "--patch", "--diff-merges=first-parent"],
&c.sha,
&paths,
rows,
)
.await?;
Ok(answer(Change { commit: c, files }))
}