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/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;
}