crates/gunnar-ui-server/proto/gunnar/v1/repos.proto
syntax = "proto3";
package gunnar.v1;
import "gunnar/v1/common.proto";
// The repository and namespace catalogue.
//
// Namespace CRUD lives here rather than on `Admin` because a namespace is the
// container a repo is created in. Multi-tenancy makes it part of every path, so the
// service that creates paths owns it.
service Repos {
rpc CreateNamespace(CreateNamespaceRequest) returns (CreateNamespaceResponse);
rpc ListNamespaces(ListNamespacesRequest) returns (ListNamespacesResponse);
rpc DeleteNamespace(DeleteNamespaceRequest) returns (DeleteNamespaceResponse);
rpc Create(CreateRepoRequest) returns (CreateRepoResponse);
// **A dated tombstone by default; a reclaim only when asked.** See
// `DeleteRepoRequest.purge`.
rpc Delete(DeleteRepoRequest) returns (DeleteRepoResponse);
// Put a soft-deleted repository back, inside its retention window.
rpc Undelete(UndeleteRepoRequest) returns (UndeleteRepoResponse);
// Reclaim a soft-deleted repository's bytes NOW, before the window closes.
rpc Purge(PurgeRepoRequest) returns (PurgeRepoResponse);
// **Maintain a LIVE repository: seal, compact, and drop the read cache.**
//
// `gunnar-server`'s `storage.rs` states what the reclaim is:
//
// "`repo gc` reclaims the read cache: every lazily-exploded object
// unlinked, the commit-and-tree pool kept because that is the graph.
// Returns the bytes that left the filesystem."
//
// **It destroys nothing.** Everything unlinked is re-derivable from the
// packs the same run just compacted; the commit-and-tree pool stays because
// it IS the graph. The worst outcome is a slower next read while the cache
// re-explodes. That is why this is the only reclaim verb on this service
// that is not Admin — see `Repos.Gc`'s handler for the argument.
//
// **This ENQUEUES. It does not run the gc, and it returns before one has.**
//
// A gc unlinks the whole exploded read cache — hundreds of thousands of
// files on a large repository, and pure I/O. Ten owners pressing a console
// button at once is a self-inflicted outage, so the server runs **one gc at
// a time, globally**, and queues the rest FIFO. The serialisation lives here
// and not in a client because a console can only serialise itself: the CLI,
// a second console and anything else holding a credential would still
// stampede a box that trusted them to take turns.
//
// A second request for a repository already queued or running does **not**
// enqueue twice — it returns the job that is already there. The console
// button will be pressed twice by anyone impatient, and twice is one gc.
//
// The response carries the job: its id, its state, and where it sits in the
// queue while it waits. Poll `GcStatus` for the rest.
rpc Gc(GcRepoRequest) returns (GcRepoResponse);
// **Where a repository's gc got to.** The read half of `Gc`, for a console
// button that lights up when the job is queued and goes down when it is
// done, showing what it freed.
//
// A finished job stays readable for a while after the worker moves on, so a
// poller that was between requests when it completed still sees the result
// rather than an empty answer indistinguishable from "never asked".
//
// A dedicated RPC rather than a field on `Repos.Get`: `Get` answers with the
// catalog row, which is what a repository IS and is carried in events and
// caches, and hanging a job that changes every second off it would make
// every reader of a `Repo` a poller. AUTHORISATION is `Read` — seeing that
// maintenance ran is not asking for it.
rpc GcStatus(GcStatusRequest) returns (GcStatusResponse);
// What is deleted and not yet purged, with the bytes each is still holding.
rpc ListDeleted(ListDeletedRequest) returns (ListDeletedResponse);
// **Store directories with no catalog row and no soft-delete tombstone.**
//
// The class of bytes nothing on the box could see before 2026-08-30.
// `List` reads the catalog and an orphan is not in it; `ListDeleted`,
// `Undelete`, `Purge` and the expiry sweep all key off a tombstone and an
// orphan has none. On the appliance that was ~25-39 GB of a failed kernel
// mirror standing at `alice/linux` with no row, no tombstone, and no verb
// that could reach it — while `Create` refused the name because the
// directory was there.
//
// Together with `List` and `ListDeleted` this closes the set: every
// `<name>.git` directory under the repository root is in exactly one of the
// three listings.
//
// AUTHORISATION: exactly `ListDeleted`'s — a namespace grant for one
// namespace, a server grant for all of them, because the answer spans
// tenants.
rpc ListOrphans(ListOrphansRequest) returns (ListOrphansResponse);
// **Reclaim an orphan's bytes.** Admin, and there is no undo.
//
// Refuses anything that is not an orphan by name: a catalogued repository is
// `Delete`'s and a soft-deleted one is `Undelete`'s or `Purge`'s. To keep an
// orphan's objects instead of reclaiming them, `Create` with `adopt` and the
// hash the listing reports.
//
// AUTHORISATION: Admin, at the narrowest scope that can hold a grant — which
// for a repository with no catalog row is the namespace. A repo-scoped check
// is exactly what made this state inescapable.
rpc ReclaimOrphan(ReclaimOrphanRequest) returns (ReclaimOrphanResponse);
rpc List(ListReposRequest) returns (ListReposResponse);
rpc Get(GetRepoRequest) returns (GetRepoResponse);
rpc SetDefaultBranch(SetDefaultBranchRequest) returns (SetDefaultBranchResponse);
rpc SetVisibility(SetVisibilityRequest) returns (SetVisibilityResponse);
// **Encrypted repository on or off** (2026-10-05). On, every push must carry
// ciphertext only: each new blob a gunnar-crypt envelope, except
// `.gitattributes`, `.gunnar-crypt/members` and the age-wrapped
// `.gunnar-crypt/keys`; symbolic links refused. The server never holds a key
// either way. AUTHORISATION: the ACCOUNT admin — Admin over the namespace —
// as for `SetVisibility`. Audited.
rpc SetEncrypted(SetEncryptedRequest) returns (SetEncryptedResponse);
// **A member's SSH PUBLIC keys, to wrap an encrypted repository's key ring
// to them** (2026-10-05). gunnar already holds every user's SSH keys, so
// adding a member needs nothing from them: `gunnar-cli crypt add-member
// <name> --from-server`. Public keys only, as the stored OpenSSH lines.
//
// AUTHORISATION: Read on the repository, and the NAMED principal must hold
// Read on it too — keys are handed out only for someone who could read the
// repository anyway, never as a directory of everyone's keys.
rpc RecipientKeys(RecipientKeysRequest) returns (RecipientKeysResponse);
// **The account's own settings: is the namespace public, are its events
// public.** Both are private by default for every account, and only the
// ACCOUNT admin — Admin over the namespace — may change either, as only the
// account admin may change a repository's visibility. An unset field is
// left as it is.
rpc SetNamespaceSettings(SetNamespaceSettingsRequest) returns (SetNamespaceSettingsResponse);
// **One repository's stored bytes, off the box.**
//
// `gunnar repo archive-out --to FILE` writes the same bytes to a path ON the
// appliance, which is no use for an off-box backup of a machine whose PID 1
// is gunnar and which has no shell to copy the file out with. This is the
// same cut, streamed: concatenate every chunk's `data` and the result is
// byte-for-byte the file, restorable with `gunnar repo archive-in`.
//
// `from_generation` is a WATERMARK, not a diff. The pack is append-only and
// already fsynced below the cut, so there is nothing to quiesce and no
// freeze verb: the archive is taken while the server is serving.
//
// AUTHORISATION: this exports a repository's whole contents, so it takes
// exactly what cloning a private repository over SSH takes — Read on the
// repository. Anything less would make a Read grant on the control plane a
// wider thing than a Read grant on the git door.
rpc ArchiveOut(ArchiveOutRequest) returns (stream ArchiveOutChunk);
}
message CreateNamespaceRequest {
string name = 1;
string description = 2;
}
message CreateNamespaceResponse {
Namespace namespace = 1;
}
message ListNamespacesRequest {
uint32 page_size = 1;
string page_token = 2;
}
message ListNamespacesResponse {
repeated Namespace namespaces = 1;
string next_page_token = 2;
}
message DeleteNamespaceRequest {
string name = 1;
// A namespace holding repos is only removed when this is set; otherwise the
// call is FAILED_PRECONDITION. Deleting repos is never implicit.
bool delete_repos = 2;
}
message DeleteNamespaceResponse {
uint32 repos_deleted = 1;
}
message CreateRepoRequest {
RepoPath path = 1;
// Unspecified means the server default, which is sha256.
HashKind hash_kind = 2;
// Full ref name. Empty means the server default ("refs/heads/main").
string default_branch = 3;
Visibility visibility = 4;
string description = 5;
// Take over a store already on disk at this name (matching hash only).
// Default false: a create never adopts a directory it did not make.
bool adopt = 6;
// **The repository's first commit, written by the server** (2026-10-08): a
// README.md holding this text on the default branch, one commit with no
// parent, as the server (`Gunnar`). Empty: the repository is created empty,
// as before. A sold private Gunnar's welcome repository is made so — no git
// client anywhere in the sale.
string readme = 7;
}
message CreateRepoResponse {
Repo repo = 1;
}
message DeleteRepoRequest {
RepoPath path = 1;
// **Reclaim the bytes NOW, with no window.** The pre-2026-08-30 behaviour,
// and it is measured: 181 GiB back in ~2 seconds, gone.
//
// Without it the delete is a *dated tombstone*: the catalog row goes, so the
// name stops working and nothing can be pushed to it, and the bytes stay
// until `retention_days` have passed. `Repos.Undelete` puts it back until
// then. A mirror does not protect against a delete — the delete replicates —
// so this window is the only thing between a fat finger and a lost
// repository.
bool purge = 2;
// How long the bytes are kept. 0 means *the server's default*, which is 30
// days; a deliberate "purge at the next sweep" is `retention_days` unset
// together with `zero_retention` below, because a plain 0 is what a client
// that never heard of this field sends.
uint32 retention_days = 3;
// Say the zero. Reclaim at the next sweep rather than in 30 days — an
// operator freeing space meant it, and a 0 that silently became 30 days
// would be the server overruling them.
bool zero_retention = 4;
}
message DeleteRepoResponse {
// True when `purge` was set and the bytes went now.
bool purged = 1;
// Bytes the reclaim returned. Only meaningful when `purged`.
uint64 bytes_reclaimed = 2;
// Bytes the tombstone is HOLDING. **They still count against the account**:
// a customer who deletes a repository and sees no change in their usage is
// looking at a promise, not a bug.
uint64 bytes_kept = 3;
// When a sweep may reclaim them. Only meaningful when not `purged`.
int64 purge_after_unix_ms = 4;
}
message UndeleteRepoRequest {
RepoPath path = 1;
}
message UndeleteRepoResponse {
// The catalog row as it was — the recorded hash kind above all, never a
// default: a sha256 row over a sha1 store serves refs no client can read.
Repo repo = 1;
}
message PurgeRepoRequest {
RepoPath path = 1;
}
message PurgeRepoResponse {
uint64 bytes_reclaimed = 1;
}
message GcRepoRequest {
RepoPath path = 1;
}
message GcRepoResponse {
// The job now responsible for this repository — freshly enqueued, or the one
// that was already queued or running when the request arrived. There is no
// second `bytes_reclaimed` beside it: one place says how a gc went.
GcJob job = 1;
}
message GcStatusRequest {
RepoPath path = 1;
}
message GcStatusResponse {
// Unset when the server is holding 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.
GcJob job = 1;
}
enum GcState {
GC_STATE_UNSPECIFIED = 0;
// Waiting its turn. `queue_position` says how many are ahead of it.
GC_STATE_QUEUED = 1;
// The one gc the server is running.
GC_STATE_RUNNING = 2;
// Finished. `bytes_reclaimed` is the answer.
GC_STATE_DONE = 3;
// Finished badly. `error` says how, and the repository was not maintained.
GC_STATE_FAILED = 4;
}
// One queued or completed maintenance run.
message GcJob {
// Stable for the life of the job, so a console can tell "still the run I
// asked for" from "somebody else's run that started after mine".
string job_id = 1;
RepoPath path = 2;
GcState state = 3;
// How many jobs are ahead of this one. 0 while running, done or failed —
// a queue position is a fact only about waiting.
uint32 queue_position = 4;
// Bytes that left the filesystem: the compaction's saving plus the exploded
// read cache it unlinked. Only meaningful when `state` is DONE.
uint64 bytes_reclaimed = 5;
// Only set when `state` is FAILED.
string error = 6;
int64 enqueued_at_unix_ms = 7;
// 0 until the job leaves RUNNING.
int64 finished_at_unix_ms = 8;
}
message ListDeletedRequest {
// Empty lists across every namespace.
string namespace = 1;
}
message ListDeletedResponse {
repeated DeletedRepo repos = 1;
// The sum of `bytes` below — what the tombstones are holding in total.
uint64 total_bytes = 2;
// **How many days a delete keeps the bytes when it names no window** (additive,
// 2026-10-28): the server's default retention, so a client can say "it can be
// restored for N days" before it deletes, without a number of its own. 0 = this
// server does not say.
uint32 default_retention_days = 3;
}
// One soft-deleted repository.
message DeletedRepo {
RepoPath repo = 1;
int64 deleted_at_unix_ms = 2;
// When the sweep may reclaim it. 0 = no deadline (a reclaim that FAILED is
// owed to an operator, not scheduled).
int64 purge_after_unix_ms = 3;
// Who asked for the delete.
string principal_id = 4;
// Bytes still on disk, and still on the bill.
uint64 bytes = 5;
}
message ListOrphansRequest {
// Empty lists across every namespace.
string namespace = 1;
}
message ListOrphansResponse {
repeated OrphanStore orphans = 1;
// The sum of `bytes` below — what is stranded in total.
uint64 total_bytes = 2;
}
// One store directory with no catalog row and no soft-delete tombstone.
message OrphanStore {
RepoPath repo = 1;
// Where it is on the appliance's disk.
string dir = 2;
// What it is holding, right now.
uint64 bytes = 3;
// The store's `OBJECT_FORMAT` marker: "sha1", "sha256", or EMPTY for a store
// written before the marker existed. Empty is what `Create` with `adopt`
// takes the caller's `hash_kind` at their word about — and then writes.
string hash_marker = 4;
// Newest mtime anywhere under the directory. Tells Friday's wreckage from
// something a push is still writing to.
int64 modified_unix_ms = 5;
// Non-empty when a tombstone says a RECLAIM FAILED here — bytes owed to an
// operator. Those are orphans too: `ListDeleted` filters that reason out on
// purpose, which left them in no listing at all.
string failed_reclaim = 6;
}
message ReclaimOrphanRequest {
RepoPath path = 1;
}
message ReclaimOrphanResponse {
uint64 bytes_reclaimed = 1;
}
message ListReposRequest {
// Empty lists across every namespace.
string namespace = 1;
uint32 page_size = 2;
string page_token = 3;
// **Only repositories whose `<namespace>/<name>` contains this**,
// case-insensitively (2026-10-04). Applied by gunnar to the caller's own,
// already narrowed listing — so a search sees every repository the caller
// may, not the first page a console happened to fetch. Empty: no filter.
string query = 4;
// Only repositories that are mirrors of an upstream (`Mirror.Add`).
bool mirrored_only = 5;
}
message ListReposResponse {
repeated Repo repos = 1;
string next_page_token = 2;
}
message GetRepoRequest {
RepoPath path = 1;
}
message GetRepoResponse {
Repo repo = 1;
}
message SetDefaultBranchRequest {
RepoPath path = 1;
string default_branch = 2;
}
message SetDefaultBranchResponse {
Repo repo = 1;
}
message SetVisibilityRequest {
RepoPath path = 1;
Visibility visibility = 2;
}
message SetVisibilityResponse {
Repo repo = 1;
}
message SetEncryptedRequest {
RepoPath path = 1;
bool encrypted = 2;
}
message SetEncryptedResponse {
Repo repo = 1;
}
message RecipientKeysRequest {
RepoPath path = 1;
string principal_id = 2;
}
message RecipientKey {
// "SHA256:...".
string fingerprint = 1;
// "ssh-ed25519", "sk-ssh-ed25519@openssh.com", ...
string algo = 2;
// The whole authorized_keys line.
string openssh = 3;
}
message RecipientKeysResponse {
repeated RecipientKey keys = 1;
}
message SetNamespaceSettingsRequest {
string name = 1;
optional bool public = 2;
optional bool events_public = 3;
}
message SetNamespaceSettingsResponse {
Namespace namespace = 1;
}
message ArchiveOutRequest {
RepoPath path = 1;
// Start above this `objects.pack` byte offset: a previous run's
// `to_generation`. 0 is a full archive. A watermark above a pack that a GC
// has rewritten is refused BY NAME rather than restored into a mixture.
uint64 from_generation = 2;
}
message ArchiveOutChunk {
// Archive bytes, in order. Every message but the last carries these.
bytes data = 1;
// **The receipt, on the LAST message, which carries no data.** A stream that
// ends without one ended early: the bytes so far are a truncated archive and
// must not be kept. Written this way round because the totals are not known
// until the cut is finished, and a header that guessed them would be a
// header that can lie.
bool last = 2;
// Pass this as `from_generation` next time.
uint64 to_generation = 3;
// Payload bytes sent — what moved, not the stream's framed size.
uint64 bytes_out = 4;
// SHA-256 of every byte streamed, as the server computed it while sending.
// A client that hashes what it received and compares has proved the transfer
// end to end without trusting the transport.
bytes file_sha256 = 5;
}