Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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");
    }
}