crates/gunnar-ui-server/src/api/refs.rs
//! References, history, and raw objects.
use axum::extract::{Path, Query};
use axum::Json;
use serde::{Deserialize, Serialize};
use crate::api::{repo_path, ApiError, ApiResult, Limit, Page, Reader};
use crate::pb;
/// gunnar's own ceiling on a single `ReadObject` response.
///
/// Asking for more is refused, so the console never does. Anything larger comes
/// back truncated, and anything over 64 MiB is refused outright whatever
/// `max_bytes` says.
const MAX_OBJECT_BYTES: u64 = 1024 * 1024;
/// A resolved reference.
#[derive(Debug, Clone, Serialize)]
pub struct RefTarget {
pub ref_name: String,
pub oid: String,
/// Set for a symbolic reference such as `HEAD`.
pub symbolic_target: Option<String>,
/// Set only for an annotated tag: the commit it ultimately names.
pub peeled_oid: Option<String>,
}
/// One commit.
#[derive(Debug, Clone, Serialize)]
pub struct Commit {
pub oid: String,
pub parent_oids: Vec<String>,
pub tree_oid: String,
pub author: Option<Signature>,
pub committer: Option<Signature>,
pub message: String,
/// The first line, which is what a list view shows.
pub subject: String,
}
/// Who wrote something, and when.
#[derive(Debug, Clone, Serialize)]
pub struct Signature {
pub name: String,
pub email: String,
pub time_unix_seconds: i64,
pub tz_offset_minutes: i32,
}
/// `?prefix=`, `?after=` and `?limit=` on a reference listing.
#[derive(Debug, Clone, Deserialize)]
pub struct RefQuery {
#[serde(default)]
pub prefix: Option<String>,
/// The cursor: only the references whose name sorts after this one (the
/// last name of the page before), so a listing longer than one page can be
/// read to its end. Absent or empty is the first page.
#[serde(default)]
pub after: Option<String>,
#[serde(default)]
pub limit: Option<usize>,
}
/// `GET /api/repos/{namespace}/{name}/refs`
///
/// `prefix` is gunnar's only real filter here: `page_size` is ignored, so the
/// whole set comes back either way. `after` pages through it (the console's
/// branch pickers read every page): a picker that kept only the first page lost
/// every branch past it, `main` among them once a repository had enough
/// `fix/...` branches.
pub async fn list(
reader: Reader,
Path((namespace, name)): Path<(String, String)>,
Query(query): Query<RefQuery>,
) -> ApiResult<Page<RefTarget>> {
let response = reader
.gunnar
.refs()
.list(pb::ListRefsRequest {
repo: Some(repo_path(&namespace, &name)),
prefix: query.prefix.unwrap_or_default(),
peel: true,
..Default::default()
})
.await
.map_err(ApiError::from)?
.into_inner();
let all: Vec<RefTarget> = response.refs.into_iter().map(render_ref).collect();
Ok(Json(page_after(
all,
query.after.as_deref(),
Limit { limit: query.limit }.get(),
)))
}
/// Pure: **one page of a reference listing**: by name, the ones after the
/// cursor `after` (none or empty: from the start), the first `limit`. `total`
/// is the whole listing; `truncated` says more come after this page, and the
/// last name on it is the next page's cursor.
pub fn page_after(all: Vec<RefTarget>, after: Option<&str>, limit: usize) -> Page<RefTarget> {
let total = all.len();
let mut rest = all;
rest.sort_by(|a, b| a.ref_name.cmp(&b.ref_name));
if let Some(after) = after.filter(|a| !a.is_empty()) {
rest.retain(|t| t.ref_name.as_str() > after);
}
let left = rest.len();
let page = Page::of(rest, limit);
Page {
truncated: page.items.len() < left,
items: page.items,
total,
}
}
/// `GET /api/repos/{namespace}/{name}/head`
pub async fn head(
reader: Reader,
Path((namespace, name)): Path<(String, String)>,
) -> ApiResult<RefTarget> {
let response = reader
.gunnar
.refs()
.get(pb::GetRefRequest {
repo: Some(repo_path(&namespace, &name)),
ref_name: "HEAD".to_owned(),
peel: true,
})
.await
.map_err(ApiError::from)?
.into_inner();
Ok(Json(render_ref(response.ref_target.unwrap_or_default())))
}
/// `?start=` and `?limit=` on a history request.
#[derive(Debug, Clone, Deserialize)]
pub struct LogQuery {
/// A reference name or object id. Empty means the default branch.
#[serde(default)]
pub start: Option<String>,
#[serde(default)]
pub limit: Option<usize>,
/// Only commits that changed this path. Filtered BY GUNNAR, where the
/// objects are local — this used to be silently ignored, which made
/// `?path=` a claim with no implementation.
#[serde(default)]
pub path: Option<String>,
/// The History page's branch picker: a full ref name to walk from. The
/// same thing `start` carries, under the name the page uses.
#[serde(default, rename = "ref")]
pub reference: Option<String>,
/// `all=1`: walk every branch and tag tip at once (gunnar's
/// `LogRequest.all_refs`), the History page's "all branches" view.
#[serde(default)]
pub all: Option<String>,
}
/// The `Refs.Log` request one history query asks for. The ref walk is
/// gunnar's; this only carries the page's words across.
fn log_request(namespace: &str, name: &str, query: LogQuery, limit: usize) -> pb::LogRequest {
// A `start` (the frontier oid "Load more" continues from) wins over both
// the picker's `ref` and `all`: a continued page walks from where the
// wall ends, whichever view drew it. With neither, gunnar walks the
// default branch.
let frontier = query.start.filter(|s| !s.is_empty());
let all_refs =
frontier.is_none() && matches!(query.all.as_deref(), Some("1" | "true" | "yes"));
let start = match (frontier, all_refs) {
(Some(oid), _) => oid,
(None, true) => String::new(),
(None, false) => query.reference.unwrap_or_default(),
};
pb::LogRequest {
repo: Some(repo_path(namespace, name)),
start,
// gunnar caps this at 1000 itself.
page_size: limit.min(1000) as u32,
page_token: String::new(),
path: query.path.unwrap_or_default(),
all_refs,
}
}
/// `GET /api/repos/{namespace}/{name}/log`
///
/// The one list where gunnar honours `page_size`, so the limit is passed
/// through rather than applied here. There is still no cursor: history past the
/// first page is not reachable.
pub async fn log(
reader: Reader,
Path((namespace, name)): Path<(String, String)>,
Query(query): Query<LogQuery>,
) -> ApiResult<Page<Commit>> {
let limit = Limit { limit: query.limit }.get();
let response = reader
.gunnar
.refs()
.log(log_request(&namespace, &name, query, limit))
.await
.map_err(ApiError::from)?
.into_inner();
let all: Vec<Commit> = response.commits.into_iter().map(render_commit).collect();
Ok(Json(Page::of(all, limit)))
}
/// `?oid=` and `?max_bytes=` on an object read.
#[derive(Debug, Clone, Deserialize)]
pub struct ObjectQuery {
pub oid: String,
#[serde(default)]
pub max_bytes: Option<u64>,
}
/// A git object, as far as the console will carry it.
#[derive(Debug, Clone, Serialize)]
pub struct Object {
pub oid: String,
pub kind: &'static str,
/// The object's full size, whatever was actually sent.
pub size: u64,
/// True when only the first `max_bytes` came back.
pub truncated: bool,
/// The payload as text, when it is text.
pub text: Option<String>,
/// True when the bytes are not valid UTF-8, so there is nothing to show.
pub binary: bool,
}
/// `GET /api/repos/{namespace}/{name}/object?oid=…`
///
/// Three outcomes rather than two. gunnar refuses any object over 64 MiB
/// outright, caps a single response at 4 MiB, and truncates to `max_bytes`
/// below that — so "too large to read at all" is a distinct answer from
/// "here is the beginning of it".
pub async fn object(
reader: Reader,
Path((namespace, name)): Path<(String, String)>,
Query(query): Query<ObjectQuery>,
) -> ApiResult<Object> {
let max_bytes = query
.max_bytes
.unwrap_or(MAX_OBJECT_BYTES)
.min(MAX_OBJECT_BYTES);
let response = reader
.gunnar
.refs()
.read_object(pb::ReadObjectRequest {
repo: Some(repo_path(&namespace, &name)),
oid: query.oid,
max_bytes,
})
.await
.map_err(ApiError::from)?
.into_inner();
let text = String::from_utf8(response.data.clone()).ok();
Ok(Json(Object {
oid: response.oid,
kind: object_kind_name(response.kind),
size: response.size,
truncated: response.truncated,
binary: text.is_none(),
text,
}))
}
fn render_ref(target: pb::RefTarget) -> RefTarget {
RefTarget {
ref_name: target.ref_name,
oid: target.oid,
symbolic_target: Some(target.symbolic_target).filter(|s| !s.is_empty()),
peeled_oid: Some(target.peeled_oid).filter(|s| !s.is_empty()),
}
}
fn render_commit(commit: pb::Commit) -> Commit {
Commit {
subject: commit.message.lines().next().unwrap_or_default().to_owned(),
oid: commit.oid,
parent_oids: commit.parent_oids,
tree_oid: commit.tree_oid,
author: commit.author.map(render_signature),
committer: commit.committer.map(render_signature),
message: commit.message,
}
}
fn render_signature(signature: pb::Signature) -> Signature {
Signature {
name: signature.name,
email: signature.email,
time_unix_seconds: signature.time_unix_seconds,
tz_offset_minutes: signature.tz_offset_minutes,
}
}
pub fn object_kind_name(value: i32) -> &'static str {
match pb::ObjectKind::try_from(value) {
Ok(pb::ObjectKind::Blob) => "blob",
Ok(pb::ObjectKind::Tree) => "tree",
Ok(pb::ObjectKind::Commit) => "commit",
Ok(pb::ObjectKind::Tag) => "tag",
_ => "unspecified",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn named(names: &[&str]) -> Vec<RefTarget> {
names
.iter()
.map(|n| RefTarget {
ref_name: (*n).to_owned(),
oid: "abc".into(),
symbolic_target: None,
peeled_oid: None,
})
.collect()
}
/// ★ **A long reference listing is read to its end** (Henrik, 2026-10-31):
/// 300 `fix/...` branches and then `main` — the first page stops in the
/// fixes and says more come; the page after its last name holds `main`.
#[test]
fn a_listing_longer_than_a_page_is_read_page_after_page() {
let mut names: Vec<String> = (0..300).map(|i| format!("refs/heads/fix/{i:03}")).collect();
names.push("refs/heads/main".into());
names.push("refs/heads/release/1".into());
// gunnar's order is not the cursor's: shuffle it.
names.reverse();
let all = named(&names.iter().map(String::as_str).collect::<Vec<_>>());
let first = page_after(all.clone(), None, 250);
assert_eq!(first.items.len(), 250);
assert_eq!((first.total, first.truncated), (302, true));
assert_eq!(first.items[0].ref_name, "refs/heads/fix/000", "by name");
assert!(first.items.iter().all(|t| t.ref_name.starts_with("refs/heads/fix/")));
let cursor = first.items.last().unwrap().ref_name.clone();
let second = page_after(all.clone(), Some(&cursor), 250);
let names: Vec<&str> = second.items.iter().map(|t| t.ref_name.as_str()).collect();
assert_eq!(names.len(), 52);
assert!(names.contains(&"refs/heads/main"), "main is on the second page: {names:?}");
assert_eq!(names.last(), Some(&"refs/heads/release/1"));
assert!(!second.truncated, "nothing after the second page");
assert_eq!(second.total, 302);
assert_eq!(page_after(all, Some(""), 250).items[0].ref_name, "refs/heads/fix/000", "an empty cursor is the start");
}
#[test]
fn an_empty_symbolic_target_becomes_absent() {
// Proto3 sends "" rather than nothing, and "" would render as a
// symbolic reference pointing nowhere.
let target = render_ref(pb::RefTarget {
ref_name: "refs/heads/main".into(),
oid: "abc".into(),
symbolic_target: String::new(),
peeled_oid: String::new(),
});
assert_eq!(target.symbolic_target, None);
assert_eq!(target.peeled_oid, None);
}
#[test]
fn head_keeps_the_reference_it_points_at() {
let target = render_ref(pb::RefTarget {
ref_name: "HEAD".into(),
oid: "abc".into(),
symbolic_target: "refs/heads/main".into(),
peeled_oid: String::new(),
});
assert_eq!(target.symbolic_target.as_deref(), Some("refs/heads/main"));
}
#[test]
fn a_commit_subject_is_the_first_line() {
let commit = render_commit(pb::Commit {
message: "fix the thing\n\nA longer explanation follows.\n".into(),
..Default::default()
});
assert_eq!(commit.subject, "fix the thing");
assert!(commit.message.contains("longer explanation"));
}
#[test]
fn an_empty_message_has_an_empty_subject_rather_than_panicking() {
assert_eq!(render_commit(pb::Commit::default()).subject, "");
}
fn query(raw: &str) -> LogQuery {
axum::extract::Query::<LogQuery>::try_from_uri(
&format!("/log?{raw}").parse().expect("a uri"),
)
.expect("a history query")
.0
}
/// **History's picker and "all branches" reach gunnar** (owner order
/// 2026-10-18): `?ref=` starts the walk at that ref, `?all=1` asks gunnar
/// for every ref tip, and a plain query is today's default-branch walk.
#[test]
fn the_history_query_carries_the_ref_and_all_branches_to_gunnar() {
let plain = log_request("ns", "r", query("limit=500"), 500);
assert_eq!((plain.start.as_str(), plain.all_refs), ("", false));
let side = log_request("ns", "r", query("limit=500&ref=refs/heads/side"), 500);
assert_eq!((side.start.as_str(), side.all_refs), ("refs/heads/side", false));
let all = log_request("ns", "r", query("limit=500&all=1"), 500);
assert_eq!((all.start.as_str(), all.all_refs), ("", true));
// "Load more" continues from a frontier oid with a plain `start`.
let more = log_request("ns", "r", query("limit=500&start=abc"), 500);
assert_eq!((more.start.as_str(), more.all_refs), ("abc", false));
// A continued page of the all-branches view is a plain walk from the
// frontier, never a restart from every tip.
let cont = log_request("ns", "r", query("limit=500&all=1&start=abc"), 500);
assert_eq!((cont.start.as_str(), cont.all_refs), ("abc", false));
// Anything but an explicit yes is the default walk.
let no = log_request("ns", "r", query("all=0"), 50);
assert!(!no.all_refs);
}
#[test]
fn an_unknown_object_kind_is_named() {
assert_eq!(object_kind_name(77), "unspecified");
assert_eq!(object_kind_name(pb::ObjectKind::Blob as i32), "blob");
}
}