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/repos.rs

//! Namespaces and repositories.

use axum::extract::{Path, Query, State};
use axum::Json;
use serde::{Deserialize, Serialize};

use crate::api::{
    hash_kind_name, repo_path, visibility_from_name, visibility_name, ApiError, ApiResult, Limit,
    Page, Reader,
};
use crate::app::AppState;
use crate::pb;

/// A repository as the console shows it.
#[derive(Debug, Clone, Serialize)]
pub struct Repo {
    /// `<namespace>/<repo>`, which is also the SSH path.
    pub path: String,
    pub namespace: String,
    pub name: String,
    pub hash_kind: &'static str,
    /// A full reference name such as `refs/heads/main`, not a short branch.
    pub default_branch: String,
    pub visibility: &'static str,
    /// `None` when gunnar did not record one.
    pub created_at_unix_ms: Option<i64>,
    pub description: String,
    /// **What it stores**: the archive minus the re-derivable read cache, plus
    /// the cold tier — the repository list's one size column (owner,
    /// 2026-10-02). `None` when the sizes could not be read.
    pub stored_bytes: Option<u64>,
    /// **An encrypted repository** (gunnar 2026-10-05): the server takes
    /// ciphertext only. The account admin's flag, read back from gunnar.
    pub encrypted: bool,
}

/// Each repository's stored bytes, keyed `<namespace>/<repo>`, out of the
/// console's `Admin.StorageInfo`. Only the rows of `repos` — the list the
/// CALLER may read — are ever given one, so this tells nobody the size of a
/// repository they cannot see.
pub fn with_sizes(mut repos: Vec<Repo>, storage: Option<&crate::api::admin::Storage>) -> Vec<Repo> {
    let Some(storage) = storage else {
        return repos;
    };
    let sizes: std::collections::BTreeMap<&str, u64> = storage
        .repos
        .iter()
        .map(|r| (r.repo.as_str(), r.hot_bytes.saturating_sub(r.explode_bytes).saturating_add(r.cold_bytes)))
        .collect();
    for repo in &mut repos {
        repo.stored_bytes = sizes.get(repo.path.as_str()).copied();
    }
    repos
}

/// A namespace as the console shows it.
#[derive(Debug, Clone, Serialize)]
pub struct Namespace {
    pub name: String,
    pub description: String,
    /// `None` when gunnar did not record one.
    pub created_at_unix_ms: Option<i64>,
    /// The namespace is public (its account admin said so). Private — the
    /// default for every account — is shown to an outsider only through its
    /// public repositories.
    pub public: bool,
    /// The account's emitted events are public. Private by default.
    pub events_public: bool,
    /// Employees its admin enrolled; an account with any is an enterprise
    /// account (derived, never set).
    pub employees: u32,
    /// **gunnar's word for whose account this is** (thin layer, owner
    /// 2026-10-04): the kind of the principal that owns it — `user` for a
    /// person, `machine` / `group` / `deploy` for the estate's own parts,
    /// `unspecified` for a namespace no principal owns. The browser tells the
    /// box's own namespaces from people's by this, instead of joining against
    /// the identity table (which a person may not read).
    pub owner_kind: &'static str,
}

pub(crate) fn namespace_out(n: pb::Namespace) -> Namespace {
    Namespace {
        name: n.name,
        description: n.description,
        created_at_unix_ms: crate::api::recorded(n.created_at_unix_ms),
        public: n.public,
        events_public: n.events_public,
        employees: n.employees,
        owner_kind: crate::api::access::principal_kind_name(n.owner_kind),
    }
}

fn render(repo: pb::Repo) -> Repo {
    let path = repo.path.unwrap_or_default();
    Repo {
        path: format!("{}/{}", path.namespace, path.repo),
        namespace: path.namespace,
        name: path.repo,
        hash_kind: hash_kind_name(repo.hash_kind),
        default_branch: repo.default_branch,
        visibility: visibility_name(repo.visibility),
        created_at_unix_ms: crate::api::recorded(repo.created_at_unix_ms),
        description: repo.description,
        stored_bytes: None,
        encrypted: repo.encrypted,
    }
}

/// Ask gunnar for the catalogue, as whoever this request is.
///
/// Pulled out of [`list`] so the anonymous path and the session path send the
/// *same* request and shape the answer the *same* way, differing only in which
/// credential signs it. Two copies would be two chances for the anonymous one
/// to drift into asking for something wider.
async fn catalogue(reader: &Reader, search: &Search) -> Result<Vec<Repo>, ApiError> {
    let response = reader
        .gunnar
        .repos()
        // page_size is sent for the day gunnar honours it. Today it is
        // ignored and the whole catalogue comes back regardless.
        //
        // **The search is gunnar's** (thin layer, owner 2026-10-04): the name
        // query and "mirrors only" are applied by gunnar before anything is
        // paged (gunnar 3f391ba), so a search finds the 251st repository too.
        // Until then the browser filtered the first page it was handed.
        .list(pb::ListReposRequest {
            query: search.q.clone().unwrap_or_default().trim().to_owned(),
            mirrored_only: search.imported.unwrap_or(false),
            ..Default::default()
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    let listed = response.repos.into_iter().map(render);
    if !reader.guest {
        return Ok(listed.collect());
    }
    // **The second wall, and the one place in this module where a check beside
    // gunnar's is right.**
    //
    // Everywhere else, re-deciding what gunnar decided would be a second ACL
    // that can disagree with the first, and the permissive one would win
    // silently. This is not that: it can only ever remove rows, and what it
    // defends against is not a bug in gunnar but a mistake in DEPLOYMENT. Point
    // GUNNAR_UI_GUEST_IDENTITY at a key that was enrolled with a grant — the
    // console's own, copied because it was to hand — and gunnar answers
    // correctly for the principal it was asked about, which is every private
    // repository on the box. `auth::routes::public_repos` has made exactly this
    // argument since the guest identity existed, and the login page has been
    // the only route that carried it; `/api/repos` is the same list, answered
    // on the same credential, to the same visitor.
    Ok(listed
        .filter(|repo| repo.visibility == visibility_name(pb::Visibility::Public as i32))
        .collect())
}

/// `GET /api/repos`
///
/// # The cache is skipped for a visitor with no session, and that is the point
///
/// [`crate::api::Caches::repos`] holds ONE value for the whole server with no
/// key for who asked, which was correct while there was one asker: the
/// console's own admin principal. It is now the sharpest edge in this file. A
/// guest reading it would be handed the admin's catalogue — every private
/// repository on the box — without a gRPC call being made at all, so gunnar
/// would never get the chance to refuse. The guest key would be perfectly
/// configured and perfectly bypassed.
///
/// So an anonymous request neither reads nor fills it, and pays one gRPC call
/// instead. gunnar narrows a grantless caller's `Repos.List` to the public
/// subset, so what comes back is already the answer a stranger is allowed to
/// have; the console does not filter it again here, because a second filter
/// beside gunnar's is a second ACL and the permissive one wins silently.
pub async fn list(
    reader: Reader,
    State(state): State<AppState>,
    Query(limit): Query<Limit>,
    Query(search): Query<Search>,
) -> ApiResult<Page<Repo>> {
    // The shared cache holds the whole, unsearched catalogue only.
    let all = if reader.may_cache() && search.is_empty() {
        state.caches.repos.get(catalogue(&reader, &search)).await?
    } else {
        catalogue(&reader, &search).await?
    };
    let mut page = Page::of(all, limit.get());
    // The size column. A failure to read the sizes is not a failure to list.
    //
    // **Asked as the reader** (thin layer, owner 2026-10-04): a session that
    // signs with the console's own key reads the whole box once; a person is
    // answered per repository on THEIR key (`StorageInfo{repo}` is a Read on
    // that repository), so the console never reads the box to size a
    // customer's list. A visitor with no session gets no size column: one
    // anonymous GET must not cost gunnar a call per public repository.
    if reader.may_cache() {
        let sizes = crate::api::admin::storage_as(&reader, &state).await.ok();
        page.items = with_sizes(page.items, sizes.as_ref());
    } else if !reader.anonymous {
        page.items = sized_as(&reader, page.items).await;
    }
    Ok(Json(page))
}

/// `?q=&imported=` on `GET /api/repos`: gunnar's name query and "mirrors only".
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Search {
    #[serde(default)]
    pub q: Option<String>,
    #[serde(default)]
    pub imported: Option<bool>,
}

impl Search {
    fn is_empty(&self) -> bool {
        self.q.as_deref().is_none_or(|q| q.trim().is_empty()) && !self.imported.unwrap_or(false)
    }
}

/// How many rows of a person's list are sized, one `StorageInfo{repo}` each.
const SIZED_PER_LIST: usize = 64;

/// Each row's stored bytes, asked of gunnar AS the reader, one repository at a
/// time — the first [`SIZED_PER_LIST`] rows; the rest stay unsized.
async fn sized_as(reader: &Reader, mut repos: Vec<Repo>) -> Vec<Repo> {
    let asks = repos.iter().take(SIZED_PER_LIST).map(|repo| {
        crate::api::admin::repo_storage_as(reader, repo_path(&repo.namespace, &repo.name))
    });
    let rows = futures_util::future::join_all(asks).await;
    for (repo, row) in repos.iter_mut().zip(rows) {
        repo.stored_bytes = row.map(|r| {
            r.hot_bytes
                .saturating_sub(r.explode_bytes)
                .saturating_add(r.cold_bytes)
        });
    }
    repos
}

/// Every namespace, as the console's key reads it, out of the short-lived
/// shared cache.
pub async fn namespaces(state: &AppState) -> Result<Vec<Namespace>, ApiError> {
    state
        .caches
        .namespaces
        .get(async {
            let response = state
                .gunnar
                .repos()
                .list_namespaces(pb::ListNamespacesRequest::default())
                .await
                .map_err(ApiError::from)?
                .into_inner();
            Ok::<_, ApiError>(
                response
                    .namespaces
                    .into_iter()
                    .map(namespace_out)
                    .collect::<Vec<_>>(),
            )
        })
        .await
}

/// The namespaces as THIS request's reader sees them — never out of the shared
/// cache for a narrowed reader, which holds the console key's answer (every
/// namespace on the box).
async fn namespaces_as(reader: &Reader, state: &AppState) -> Result<Vec<Namespace>, ApiError> {
    if reader.may_cache() {
        return namespaces(state).await;
    }
    let listed = reader
        .gunnar
        .repos()
        .list_namespaces(pb::ListNamespacesRequest::default())
        .await
        .map_err(ApiError::from)?
        .into_inner()
        .namespaces
        .into_iter()
        .map(namespace_out);
    // The guest's second wall, as `catalogue` keeps one: a guest key enrolled
    // with a grant by mistake must still show a visitor public namespaces only.
    Ok(if reader.guest {
        listed.filter(|ns| ns.public).collect()
    } else {
        listed.collect()
    })
}

/// `GET /api/namespaces`
///
/// **Asked as the reader** (owner, 2026-10-03: namespaces are not public by
/// default). The console's own sessions (operator, admin) see the box; a
/// viewer is answered by gunnar as itself — the namespaces it is inside and
/// the public ones — and never out of the cache that holds the console's view.
pub async fn list_namespaces(
    reader: Reader,
    State(state): State<AppState>,
    Query(limit): Query<Limit>,
) -> ApiResult<Page<Namespace>> {
    let all = namespaces_as(&reader, &state).await?;
    Ok(Json(Page::of(all, limit.get())))
}

/// What `/<ns>` shows: the namespace and the repositories this reader may see
/// in it.
#[derive(Debug, Clone, Serialize)]
pub struct NamespacePage {
    pub name: String,
    /// The namespace itself is public.
    pub public: bool,
    pub repos: Vec<Repo>,
}

/// `GET /api/namespaces/{namespace}/page` — **`/<ns>`** (owner, 2026-10-03).
///
/// Asked as the reader, so gunnar decides: a visitor (the guest) or an
/// outsider is shown a private namespace's PUBLIC repositories and nothing else
/// — not the private ones, not a count of them. A namespace the reader is not
/// inside, that is not public and holds no public repository, answers exactly
/// as a namespace that does not exist ([`crate::api::absent`]). A public
/// repository is reachable by its direct path either way.
pub async fn namespace_page(
    reader: Reader,
    State(state): State<AppState>,
    Path(namespace): Path<String>,
) -> Result<axum::response::Response, ApiError> {
    use axum::response::IntoResponse as _;
    let listed = reader
        .gunnar
        .repos()
        .list(pb::ListReposRequest {
            namespace: namespace.clone(),
            ..Default::default()
        })
        .await;
    let repos = match listed {
        Ok(response) => response.into_inner().repos,
        // A refusal is an answer about the name; it reads as "not here".
        Err(status)
            if matches!(status.code(), tonic::Code::NotFound | tonic::Code::PermissionDenied) =>
        {
            Vec::new()
        }
        Err(status) => return Err(ApiError::from(status)),
    };
    let mut repos: Vec<Repo> = repos
        .into_iter()
        .map(render)
        .filter(|repo| repo.namespace == namespace)
        .collect();
    if reader.guest {
        repos.retain(|repo| repo.visibility == visibility_name(pb::Visibility::Public as i32));
    }
    let shown = namespaces_as(&reader, &state)
        .await?
        .into_iter()
        .find(|ns| ns.name == namespace);
    if repos.is_empty() && shown.is_none() {
        return Ok(crate::api::absent::not_here());
    }
    Ok(Json(NamespacePage {
        public: shown.is_some_and(|ns| ns.public),
        name: namespace,
        repos,
    })
    .into_response())
}

/// `GET /api/repos/{namespace}/{name}`
///
/// Reachable with no session, through the guest identity. Nothing about the
/// handler changes for that case: gunnar's `Repos.Get` is scoped to the
/// repository and has answered it against `AuthTable::may` all along, so a
/// public repository is served and a private one is refused, by the same rule
/// that decides an SSH clone.
pub async fn get(
    reader: Reader,
    Path((namespace, name)): Path<(String, String)>,
) -> ApiResult<Repo> {
    let response = reader
        .gunnar
        .repos()
        .get(pb::GetRepoRequest {
            path: Some(repo_path(&namespace, &name)),
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    Ok(Json(render(response.repo.unwrap_or_default())))
}

/// What the console sends to create a repository.
#[derive(Debug, Clone, Deserialize)]
pub struct NewRepo {
    pub namespace: String,
    pub name: String,
    /// `"sha256"` or `"sha1"`. Absent means gunnar's own default, which is
    /// sha256 for a new repository.
    pub hash_kind: Option<String>,
    pub default_branch: Option<String>,
    pub visibility: Option<String>,
    #[serde(default)]
    pub description: String,
    /// **Encrypted from the start** — the existing `Repos.SetEncrypted`,
    /// signed as the same person right after the create (the account admin's
    /// decision, as gunnar keeps it).
    #[serde(default)]
    pub encrypted: bool,
}

/// `POST /api/repos`
///
/// ★ **gunnar decides who may** (owner 2026-10-06): `Repos.Create` is signed
/// AS THE PERSON (`Actor`), so an account admin creates in their own account
/// and in a clan they administer, a writer or reader does not, and the name
/// filter, the plan's room and "only the account admin publishes" are gunnar's
/// — the console's role floor lets a viewer's session reach this handler and
/// nothing else of `/api/repos`. With `encrypted`, `Repos.SetEncrypted` is asked
/// next, as the same person; refused, the empty repository just made is purged
/// again, so a repository asked for encrypted never stands unencrypted.
pub async fn create(
    actor: crate::api::Actor,State(state): State<AppState>, Json(new): Json<NewRepo>) -> ApiResult<Repo> {
    let hash_kind = match new.hash_kind.as_deref() {
        Some("sha1") => pb::HashKind::Sha1,
        Some("sha256") => pb::HashKind::Sha256,
        _ => pb::HashKind::Unspecified,
    };
    let visibility = new
        .visibility
        .as_deref()
        .and_then(visibility_from_name)
        .unwrap_or(pb::Visibility::Unspecified);
    let response = actor
        .gunnar
        .repos()
        .create(pb::CreateRepoRequest {
            path: Some(repo_path(&new.namespace, &new.name)),
            hash_kind: hash_kind as i32,
            default_branch: new.default_branch.unwrap_or_default(),
            visibility: visibility as i32,
            description: new.description,
            // A create from the console never adopts a directory it did not
            // make (gunnar's own default, said out loud here): adopting takes
            // responsibility for objects somebody else wrote, which is an
            // operator's decision at the control plane, not a side effect of
            // pressing New repository.
            adopt: false,
            // The console's New repository makes an empty one; a server-written first
            // commit is the sale's welcome repository's (gunnar `Repos.Create{readme}`).
            readme: String::new(),
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    let mut repo = response.repo.unwrap_or_default();
    if new.encrypted {
        let path = repo_path(&new.namespace, &new.name);
        match actor
            .gunnar
            .repos()
            .set_encrypted(pb::SetEncryptedRequest { path: Some(path.clone()), encrypted: true })
            .await
        {
            Ok(answer) => {
                if let Some(marked) = answer.into_inner().repo {
                    repo = marked;
                }
            }
            Err(status) => {
                let undone = actor
                    .gunnar
                    .repos()
                    .delete(pb::DeleteRepoRequest {
                        path: Some(path),
                        purge: true,
                        retention_days: 0,
                        zero_retention: true,
                    })
                    .await;
                state.caches.invalidate_repos().await;
                let mut fail = ApiError::from(status);
                fail.0.message = match undone {
                    Ok(_) => format!("{} — the repository was not kept: it is not encrypted", fail.0.message),
                    Err(e) => format!(
                        "{} — and the empty repository could not be removed again ({}): it exists, NOT encrypted",
                        fail.0.message,
                        e.message()
                    ),
                };
                return Err(fail);
            }
        }
    }
    state.caches.invalidate_repos().await;
    Ok(Json(render(repo)))
}

/// Where this session's New repository may create, in gunnar's words.
#[derive(Debug, Clone, Serialize)]
pub struct Creatable {
    /// An operator's (or an open console's) session: the namespace is typed,
    /// as before, and gunnar decides at the create.
    pub any_namespace: bool,
    /// The accounts gunnar says this session's person administers — their own,
    /// one they hold Admin over, a clan of theirs — each one publishable (only
    /// the account admin publishes, and these are theirs).
    pub namespaces: Vec<CreatableNamespace>,
}

#[derive(Debug, Clone, Serialize)]
pub struct CreatableNamespace {
    pub name: String,
    pub public: bool,
}

/// At most this many namespaces are asked about: a person's own account, the
/// accounts their grants name and their clans — a handful, never the estate.
const CREATABLE_ASKED: usize = 32;

/// `GET /api/repos/creatable` — where may this session's New repository create?
///
/// gunnar answers it, about THIS session's person (`Access.Check`, Admin over
/// the namespace — the account-admin rule `Repos.Create` keeps), for the
/// namespaces that could be theirs: their own account, the namespaces their own
/// grants name, and their clans. Nothing is decided here; a namespace gunnar
/// does not confirm is not offered. A GET, so a viewer's.
pub async fn creatable(
    State(state): State<AppState>,
    caller: Option<axum::Extension<crate::auth::middleware::Caller>>,
) -> ApiResult<Creatable> {
    use crate::role::Role;
    let caller = caller.map(|c| c.0);
    let any_namespace = match &caller {
        Some(caller) => caller.role().allows(Role::Operator),
        None => !state.passkey_enabled() && state.admin_token.is_none(),
    };
    let Ok(actor) = crate::api::Actor::of(&state, caller.as_ref()).await else {
        return Ok(Json(Creatable { any_namespace, namespaces: Vec::new() }));
    };
    let mut candidates = std::collections::BTreeSet::new();
    if let Some(me) = actor.principal.clone() {
        if let Ok(answer) = actor.gunnar.access().list_grants(pb::ListGrantsRequest { principal_id: me.clone(), ..Default::default() }).await {
            candidates.extend(
                answer
                    .into_inner()
                    .grants
                    .into_iter()
                    .filter(|g| g.principal_id == me && !g.namespace.is_empty() && g.repo.is_empty())
                    .map(|g| g.namespace),
            );
        }
        if let Ok(answer) = actor.gunnar.clans().list(pb::ListClansRequest {}).await {
            candidates.extend(answer.into_inner().clans.into_iter().map(|c| c.name));
        }
        candidates.insert(me);
    }
    let mut namespaces = Vec::new();
    for ns in candidates.into_iter().take(CREATABLE_ASKED) {
        if let Ok((true, _)) = crate::api::may(&actor.gunnar, &ns, "", pb::GrantLevel::Admin).await {
            namespaces.push(CreatableNamespace { name: ns, public: true });
        }
    }
    Ok(Json(Creatable { any_namespace, namespaces }))
}

/// `DELETE /api/repos/{namespace}/{name}`
pub async fn delete(
    actor: crate::api::Actor,
    State(state): State<AppState>,
    Path((namespace, name)): Path<(String, String)>,
) -> ApiResult<Done> {
    actor
        .gunnar
        .repos()
        .delete(pb::DeleteRepoRequest {
            path: Some(repo_path(&namespace, &name)),
            // A SOFT delete, on purpose. The console's delete button is one
            // click, and gunnar's tombstone is what makes that click
            // undoable: `Repos.Undelete` puts the repository back until the
            // retention window closes. Purging is deliberate and belongs at
            // the control plane.
            purge: false,
            // Unset together with `zero_retention: false` means "the server's
            // own retention window" — the proto spells this out because a
            // plain 0 is what a client sends when it means nothing at all, and
            // reading that as "reclaim immediately" would turn every default
            // delete into a purge.
            retention_days: 0,
            zero_retention: false,
        })
        .await
        .map_err(ApiError::from)?;
    state.caches.invalidate_repos().await;
    Ok(Json(Done { ok: true }))
}

/// ★ **What the Danger zone draws** (owner spec 2026-10-28): may THIS person
/// delete the repository, and for how many days can it be restored.
///
/// Nothing is decided here. `may_delete` is gunnar's `Access.Check` at Admin on
/// the repository, asked as the person (`Repos.Delete`'s own rule: Admin over
/// the repository or its namespace); `retention_days` is gunnar's default window
/// (`ListDeletedResponse.default_retention_days`, the namespace's listing asked
/// as the same person), 0 where gunnar does not say. The DELETE re-asks.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Deletable {
    pub may_delete: bool,
    /// Why not, with gunnar's reason in it, when `may_delete` is false.
    pub why: Option<String>,
    pub retention_days: u32,
}

/// The sentence for a person gunnar does not let delete, before gunnar's reason.
pub fn not_deletable_words(path: &str) -> String {
    format!("Only an admin of {path} or of its account may delete it.")
}

/// `GET /api/repos/{namespace}/{name}/deletable`. A GET, so a viewer's to ask
/// (`role_floor`); not on the anonymous allow-list, so a visitor never reaches it.
pub async fn deletable(
    State(state): State<AppState>,
    caller: Option<axum::Extension<crate::auth::middleware::Caller>>,
    Path((namespace, name)): Path<(String, String)>,
) -> ApiResult<Deletable> {
    let path = format!("{namespace}/{name}");
    let refused = |reason: &str| Deletable {
        may_delete: false,
        why: Some(format!("{} {reason}", not_deletable_words(&path)).trim_end().to_owned()),
        retention_days: 0,
    };
    let actor = match crate::api::Actor::of(&state, caller.as_ref().map(|c| &c.0)).await {
        Ok(actor) => actor,
        // No person to ask as: the refusal says which.
        Err(why) => return Ok(Json(refused(&why.0.message))),
    };
    let (allowed, reason) = crate::api::may(&actor.gunnar, &namespace, &name, pb::GrantLevel::Admin).await?;
    if !allowed {
        return Ok(Json(refused(&reason)));
    }
    let retention_days = actor
        .gunnar
        .repos()
        .list_deleted(pb::ListDeletedRequest { namespace: namespace.clone() })
        .await
        .map(|answer| answer.into_inner().default_retention_days)
        .unwrap_or(0);
    Ok(Json(Deletable { may_delete: true, why: None, retention_days }))
}

/// ★ **One soft-deleted repository, and the undo that is still open on it.**
///
/// The console's delete is a soft delete (`purge: false`), so every one of
/// those clicks is undoable until the retention window closes — and until now
/// the only way to undo one was `gunnar control repo undelete` on a box whose
/// PID 1 has no shell. A delete with an undo nobody can reach is a delete.
#[derive(Debug, Clone, Serialize)]
pub struct DeletedRepo {
    /// `<namespace>/<repo>`, the name the undo puts back.
    pub path: String,
    pub namespace: String,
    pub name: String,
    pub deleted_at_unix_ms: i64,
    /// When a sweep may reclaim the bytes. `None` = no deadline, which is what
    /// a reclaim that FAILED leaves behind: bytes owed to an operator rather
    /// than bytes on a schedule.
    pub purge_after_unix_ms: Option<i64>,
    /// Who asked for the delete. Empty when gunnar did not record one.
    pub principal_id: String,
    /// Bytes still on disk, and still on the bill.
    pub bytes: u64,
}

/// `GET /api/repos/deleted`
///
/// `Admin.StorageInfo` already carries the tombstoned total, so Storage could
/// say "N GB held by deleted repositories" without this. It could not say WHO
/// deleted them, and — the half that matters — a row here is what the undo
/// button hangs on.
///
/// **Asked as the reader** (thin layer, owner 2026-10-04): gunnar answers an
/// every-namespace `ListDeleted` to a server-wide Read grant only, so who
/// deleted what, and how many bytes it still holds, is not handed to a
/// customer's session on the console's key.
pub async fn list_deleted(
    reader: Reader,
    Query(limit): Query<Limit>,
) -> ApiResult<Page<DeletedRepo>> {
    let response = reader
        .gunnar
        .repos()
        // Empty namespace: every namespace. The console is the whole server's
        // window, and a per-namespace filter with nothing to filter by would
        // only hide rows.
        .list_deleted(pb::ListDeletedRequest::default())
        .await
        .map_err(ApiError::from)?
        .into_inner();
    let all: Vec<DeletedRepo> = response
        .repos
        .into_iter()
        .map(|row| {
            let path = row.repo.unwrap_or_default();
            DeletedRepo {
                path: format!("{}/{}", path.namespace, path.repo),
                namespace: path.namespace,
                name: path.repo,
                deleted_at_unix_ms: row.deleted_at_unix_ms,
                purge_after_unix_ms: crate::api::recorded(row.purge_after_unix_ms),
                principal_id: row.principal_id,
                bytes: row.bytes,
            }
        })
        .collect();
    Ok(Json(Page::of(all, limit.get())))
}

/// `POST /api/repos/{namespace}/{name}/undelete`
///
/// The undo. It answers with the repository as gunnar restored it — the
/// recorded hash kind above all, which is why the row is put back rather than
/// re-created: a sha256 row over a sha1 store serves refs no client can read.
pub async fn undelete(
    actor: crate::api::Actor,
    State(state): State<AppState>,
    Path((namespace, name)): Path<(String, String)>,
) -> ApiResult<Repo> {
    let response = actor
        .gunnar
        .repos()
        .undelete(pb::UndeleteRepoRequest {
            path: Some(repo_path(&namespace, &name)),
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    state.caches.invalidate_repos().await;
    Ok(Json(render(response.repo.unwrap_or_default())))
}

/// ★ **A store directory with no catalog row and no tombstone**: bytes nothing
/// else on the box can see, under a name nothing else can free.
///
/// The console SHOWS them and offers no button, deliberately. Both exits —
/// `ReclaimOrphan` and `Create --adopt` — take an irreversible decision about
/// objects somebody wrote, and they belong to an operator at the control plane.
/// Invisible bytes are a billing surprise; a one-click reclaim of them is a
/// worse one.
#[derive(Debug, Clone, Serialize)]
pub struct Orphan {
    pub path: String,
    pub namespace: String,
    pub name: String,
    /// Where it is on the appliance's disk.
    pub dir: String,
    pub bytes: u64,
    /// `"sha1"`, `"sha256"`, or empty for a store written before gunnar wrote
    /// the marker. Empty is what `Create --adopt` has to be told.
    pub hash_marker: String,
    /// Newest mtime anywhere under it: Friday's wreckage against something a
    /// push is still writing to.
    pub modified_unix_ms: Option<i64>,
    /// Non-empty when a tombstone says a RECLAIM FAILED here — bytes owed to an
    /// operator rather than bytes nobody asked about.
    pub failed_reclaim: String,
}

/// `GET /api/orphans`
///
/// The third listing. `List` reads the catalog and an orphan is not in it;
/// `ListDeleted` keys off a tombstone and an orphan has none. Together the
/// three account for every `<name>.git` directory under the repository root,
/// which is the only way an operator can reconcile what a disk holds against
/// what a bill says.
///
/// Asked as the reader, as [`list_deleted`] is: the box's orphan stores are a
/// server-wide Read in gunnar.
pub async fn list_orphans(
    reader: Reader,
    Query(limit): Query<Limit>,
) -> ApiResult<Page<Orphan>> {
    let response = reader
        .gunnar
        .repos()
        .list_orphans(pb::ListOrphansRequest::default())
        .await
        .map_err(ApiError::from)?
        .into_inner();
    let all: Vec<Orphan> = response
        .orphans
        .into_iter()
        .map(|row| {
            let path = row.repo.unwrap_or_default();
            Orphan {
                path: format!("{}/{}", path.namespace, path.repo),
                namespace: path.namespace,
                name: path.repo,
                dir: row.dir,
                bytes: row.bytes,
                hash_marker: row.hash_marker,
                modified_unix_ms: crate::api::recorded(row.modified_unix_ms),
                failed_reclaim: row.failed_reclaim,
            }
        })
        .collect();
    Ok(Json(Page::of(all, limit.get())))
}

/// What the console sends to change a repository's settings.
#[derive(Debug, Clone, Deserialize)]
pub struct RepoSettings {
    pub default_branch: Option<String>,
    pub visibility: Option<String>,
    /// Turn the repository's "encrypted" flag on or off (the account admin's).
    #[serde(default)]
    pub encrypted: Option<bool>,
}

/// `PATCH /api/repos/{namespace}/{name}`
///
/// gunnar has one RPC per field, so this makes up to two calls and returns the
/// repository as it stands afterwards.
///
/// **A visibility change is the ACCOUNT admin's** (gunnar's default for every
/// account), and since 2026-10-04 (thin layer) gunnar is the one that says so:
/// `Repos.SetVisibility` is signed AS THE PERSON ([`crate::api::Actor`]) and
/// gunnar's account-admin rule (Admin over the namespace, the account gate
/// honoured) decides. It is asked FIRST, so a refused request changes nothing
/// at all, the default branch included.
pub async fn update(
    State(state): State<AppState>,
    caller: Option<axum::Extension<crate::auth::middleware::Caller>>,
    Path((namespace, name)): Path<(String, String)>,
    Json(settings): Json<RepoSettings>,
) -> ApiResult<Repo> {
    let path = repo_path(&namespace, &name);
    let mut repo = None;
    // Every call here is the person's (thin layer, owner 2026-10-04): their
    // own key, and gunnar's grants decide.
    let actor = crate::api::Actor::of(&state, caller.as_ref().map(|c| &c.0)).await?;
    if let Some(visibility) = settings
        .visibility
        .as_deref()
        .and_then(visibility_from_name)
    {
        repo = actor
            .gunnar
            .repos()
            .set_visibility(pb::SetVisibilityRequest {
                path: Some(path.clone()),
                visibility: visibility as i32,
            })
            .await
            .map_err(|status| {
                if status.code() == tonic::Code::PermissionDenied {
                    tracing::info!(
                        target: crate::auth::AUDIT_TARGET,
                        event = "visibility.refused",
                        user = %actor.who,
                        account = %namespace,
                        reason = %status.message(),
                        "gunnar refused a visibility change asked as the person"
                    );
                }
                ApiError::from(status)
            })?
            .into_inner()
            .repo;
    }

    // **The "encrypted" flag is the account admin's too** (gunnar 2026-10-05,
    // the same rule as visibility): `Repos.SetEncrypted`, signed as the
    // person, asked before the default branch so a refusal changes nothing
    // that came after it. The console's one door to it on an installed
    // estate, whose control plane is bound to its WireGuard overlay.
    if let Some(encrypted) = settings.encrypted {
        repo = actor
            .gunnar
            .repos()
            .set_encrypted(pb::SetEncryptedRequest {
                path: Some(path.clone()),
                encrypted,
            })
            .await
            .map_err(|status| {
                if status.code() == tonic::Code::PermissionDenied {
                    tracing::info!(
                        target: crate::auth::AUDIT_TARGET,
                        event = "encrypted.refused",
                        user = %actor.who,
                        account = %namespace,
                        reason = %status.message(),
                        "gunnar refused an encrypted-flag change asked as the person"
                    );
                }
                ApiError::from(status)
            })?
            .into_inner()
            .repo;
    }

    if let Some(branch) = settings.default_branch.filter(|b| !b.is_empty()) {
        repo = actor
            .gunnar
            .repos()
            .set_default_branch(pb::SetDefaultBranchRequest {
                path: Some(path.clone()),
                default_branch: branch,
            })
            .await
            .map_err(ApiError::from)?
            .into_inner()
            .repo;
    }

    let repo = match repo {
        Some(repo) => repo,
        // Nothing was asked for. Read it back rather than answer with a shape
        // the console would have to special-case.
        None => actor
            .gunnar
            .repos()
            .get(pb::GetRepoRequest { path: Some(path) })
            .await
            .map_err(ApiError::from)?
            .into_inner()
            .repo
            .unwrap_or_default(),
    };
    state.caches.invalidate_repos().await;
    Ok(Json(render(repo)))
}

/// What the console sends to create a namespace.
#[derive(Debug, Clone, Deserialize)]
pub struct NewNamespace {
    pub name: String,
    #[serde(default)]
    pub description: String,
}

/// `POST /api/namespaces`
pub async fn create_namespace(
    actor: crate::api::Actor,
    State(state): State<AppState>,
    Json(new): Json<NewNamespace>,
) -> ApiResult<Namespace> {
    let response = actor
        .gunnar
        .repos()
        .create_namespace(pb::CreateNamespaceRequest {
            name: new.name,
            description: new.description,
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    let namespace = response.namespace.unwrap_or_default();
    state.caches.invalidate_repos().await;
    Ok(Json(namespace_out(namespace)))
}

/// `?cascade=true` on a namespace delete.
#[derive(Debug, Clone, Deserialize)]
pub struct Cascade {
    #[serde(default)]
    pub cascade: bool,
}

/// `DELETE /api/namespaces/{name}`
///
/// Without `?cascade=true` gunnar refuses a namespace that still holds
/// repositories, which is the refusal the console wants: deleting repositories
/// as a side effect of tidying a name is not something to do quietly.
pub async fn delete_namespace(
    actor: crate::api::Actor,
    State(state): State<AppState>,
    Path(name): Path<String>,
    Query(cascade): Query<Cascade>,
) -> ApiResult<Deleted> {
    let response = actor
        .gunnar
        .repos()
        .delete_namespace(pb::DeleteNamespaceRequest {
            name,
            delete_repos: cascade.cascade,
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    state.caches.invalidate_repos().await;
    Ok(Json(Deleted {
        repos_deleted: response.repos_deleted,
    }))
}

/// A mutation that carries nothing back.
#[derive(Debug, Clone, Serialize)]
pub struct Done {
    pub ok: bool,
}

/// How much a cascading delete removed.
#[derive(Debug, Clone, Serialize)]
pub struct Deleted {
    pub repos_deleted: u32,
}

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

    /// ★ **The repository list carries one size column, and only for the
    /// repositories the caller was listed** (owner, 2026-10-02).
    #[test]
    fn the_list_gets_a_size_for_each_listed_repository_and_no_other() {
        let mine = |path: &str| Repo {
            path: path.into(),
            namespace: path.split('/').next().unwrap().into(),
            name: path.split('/').nth(1).unwrap().into(),
            hash_kind: "sha256",
            default_branch: "refs/heads/main".into(),
            visibility: "private",
            created_at_unix_ms: None,
            description: String::new(),
            stored_bytes: None,
            encrypted: false,
        };
        let row = |repo: &str, hot: u64, explode: u64, cold: u64| crate::api::admin::RepoStorage {
            repo: repo.into(),
            hash_kind: "sha256",
            loose_object_count: 0,
            hot_bytes: hot,
            cold_bytes: cold,
            cold_object_count: 0,
            cold_measured: false,
            last_repack_at_unix_ms: None,
            ref_count: 0,
            explode_bytes: explode,
            explode_budget_bytes: 0,
            explode_evicted_bytes: 0,
        };
        let storage = crate::api::admin::Storage {
            object_backend: String::new(),
            ref_backend: String::new(),
            engine: None,
            total_hot_bytes: 0,
            total_cold_bytes: 0,
            cold_measured: false,
            repos: vec![row("anna/a", 100, 40, 0), row("olof/secret", 9_000, 0, 0)],
            total_explode_bytes: 0,
            total_explode_budget_bytes: 0,
            total_tombstoned_bytes: 0,
            tombstoned: Vec::new(),
        };
        let listed = with_sizes(vec![mine("anna/a"), mine("anna/new")], Some(&storage));
        assert_eq!(listed.iter().map(|r| (r.path.as_str(), r.stored_bytes)).collect::<Vec<_>>(), [("anna/a", Some(60)), ("anna/new", None)]);
        assert_eq!(listed.len(), 2, "a size never adds a repository to the list");
        assert!(with_sizes(vec![mine("anna/a")], None)[0].stored_bytes.is_none(), "no sizes, no column values");
    }

    #[test]
    fn a_repository_renders_with_its_ssh_path() {
        let repo = render(pb::Repo {
            path: Some(repo_path("vetra", "gunnar")),
            hash_kind: pb::HashKind::Sha256 as i32,
            default_branch: "refs/heads/main".into(),
            visibility: pb::Visibility::Private as i32,
            created_at_unix_ms: 1,
            description: "the forge".into(),
            encrypted: false,
        });

        assert_eq!(repo.path, "vetra/gunnar");
        assert_eq!(repo.namespace, "vetra");
        assert_eq!(repo.name, "gunnar");
        assert_eq!(repo.hash_kind, "sha256");
        assert_eq!(repo.visibility, "private");
    }

    #[test]
    fn a_repository_without_a_path_does_not_panic() {
        // gunnar always sends one, but a proto3 message field is optional on
        // the wire and a missing one must not take the console down.
        let repo = render(pb::Repo::default());

        assert_eq!(repo.path, "/");
    }
}

/// ★ **A maintenance job, as the console shows it.**
///
/// The console renders `state` and nothing else decides what it draws: a
/// `queued` job shows its place in the line, a `done` job shows what it freed,
/// a `failed` job shows why. `bytes_reclaimed` is deliberately `None` unless
/// the job is `done` — a zero on a running job reads as "it freed nothing",
/// which is a different and wrong claim.
#[derive(Debug, Clone, Serialize)]
pub struct GcJob {
    /// Stable for the life of the job, so a poller can tell its own run from
    /// somebody else's that started after it.
    pub job_id: String,
    pub path: String,
    /// `queued` | `running` | `done` | `failed` | `unknown`.
    pub state: &'static str,
    /// How many jobs are ahead of this one. Only meaningful while `queued`.
    pub queue_position: u32,
    /// Bytes that left the filesystem. `None` unless `done` — see above.
    pub bytes_reclaimed: Option<u64>,
    /// Only set when `failed`.
    pub error: Option<String>,
    pub enqueued_at_unix_ms: Option<i64>,
    pub finished_at_unix_ms: Option<i64>,
}

fn gc_state_name(state: i32) -> &'static str {
    match pb::GcState::try_from(state) {
        Ok(pb::GcState::Queued) => "queued",
        Ok(pb::GcState::Running) => "running",
        Ok(pb::GcState::Done) => "done",
        Ok(pb::GcState::Failed) => "failed",
        // An enum value this build does not know is not the same as "no job",
        // and rendering it as `queued` would make a console poll forever.
        _ => "unknown",
    }
}

fn render_gc(job: pb::GcJob) -> GcJob {
    let path = job.path.unwrap_or_default();
    let state = gc_state_name(job.state);
    GcJob {
        job_id: job.job_id,
        path: format!("{}/{}", path.namespace, path.repo),
        state,
        queue_position: job.queue_position,
        // Only DONE carries a meaningful figure; every other state would be
        // reporting the zero-initialised field as a measurement.
        bytes_reclaimed: (state == "done").then_some(job.bytes_reclaimed),
        error: (!job.error.is_empty()).then(|| job.error.clone()),
        enqueued_at_unix_ms: crate::api::recorded(job.enqueued_at_unix_ms),
        finished_at_unix_ms: crate::api::recorded(job.finished_at_unix_ms),
    }
}

/// ★ **`POST /api/repos/{namespace}/{name}/gc` — the console's gc button.**
///
/// **This ENQUEUES.** It returns as soon as the server has taken the job, not
/// when the gc has run: a gc unlinks the whole exploded read cache, which on a
/// large repository is hundreds of thousands of files and pure I/O. The server
/// runs one gc at a time globally and queues the rest, so this answers with the
/// job's place in the line and the page polls [`gc_status`] for the rest.
///
/// Pressing the button twice does not enqueue twice — the server returns the
/// job that is already there. That is the server's guarantee and not this
/// handler's: a console can only serialise itself, and the CLI holds the same
/// credential.
///
/// Authorised at **Write**, not Admin. The argument is NOT "it destroys
/// nothing" — that was corrected in gunnar-ui `bd0b244`: `admin::gc` runs
/// `Engine::maintain` = `seal` -> `store.gc()` -> `drop_explode_cache`, and the
/// compaction leg really does reduce `pack_bytes`. The argument that survives
/// is narrower and still holds: nothing on that path destroys data that is not
/// re-derivable, and `--prune` is inert (`let _ = &args.prune;`). So an owner
/// may reclaim their own repository.
pub async fn gc(
    actor: crate::api::Actor,
    Path((namespace, name)): Path<(String, String)>,
) -> ApiResult<GcJob> {
    let job = actor
        .gunnar
        .repos()
        .gc(pb::GcRepoRequest {
            path: Some(repo_path(&namespace, &name)),
        })
        .await
        .map_err(ApiError::from)?
        .into_inner()
        .job
        // A success with no job is a contract violation, not an empty result:
        // `Gc` promises the job it enqueued or the one already there.
        .ok_or_else(|| {
            ApiError(crate::grpc::Fail {
                code: "internal".to_owned(),
                message: "the server enqueued a gc but reported no job".to_owned(),
            })
        })?;
    Ok(Json(render_gc(job)))
}

/// ★ **`GET /api/repos/{namespace}/{name}/gc` — where that gc got to.**
///
/// `null` when the server holds no job for this repository: none was ever asked
/// for, or the last one finished long enough ago to have been dropped. Absence
/// and "it did nothing" are different answers and this is which — so the page
/// can show an idle button rather than a permanent zero.
///
/// Authorised at **Read**: seeing that maintenance ran is not asking for it.
///
/// Asked as the reader: gunnar's Read on THIS repository decides, so another
/// account's maintenance is not a customer's to watch.
pub async fn gc_status(
    reader: Reader,
    Path((namespace, name)): Path<(String, String)>,
) -> ApiResult<Option<GcJob>> {
    let job = reader
        .gunnar
        .repos()
        .gc_status(pb::GcStatusRequest {
            path: Some(repo_path(&namespace, &name)),
        })
        .await
        .map_err(ApiError::from)?
        .into_inner()
        .job;
    Ok(Json(job.map(render_gc)))
}