git-shark 🦈
Self-hosted Git platform (GitHub-like) as a single, natively-compiled Quarkus service.
Bare Git repositories on disk, served over smart HTTP (JGit GitServlet) and SSH
(embedded Apache MINA SSHD), with a server-rendered Qute web UI, OIDC login,
PostgreSQL metadata (Flyway-managed schema), and per-repository access control.
Features
- Create, browse, and delete personal repositories (public or private); the owner can switch a repository between public and private later on its Settings page
- Organisations — shared repository namespaces with guest/member/owner roles. An org owns
repositories exactly like a user (
/repos/<org>/<repo>, same clone URL forms); org and user names share one collision-checked handle namespace. Guests read private org repos, members also push, owners manage members and org repositories. Guides: for users, for admins - Clone/fetch/push over
https://<host>/git/<owner>/<repo>.git- anonymous read on public repositories
- push and private read authenticate with personal access tokens (HTTP Basic password)
- Clone/fetch/push over
ssh://git@<host>:2222/<owner>/<repo>.git- public-key authentication only; keys managed per user in the UI
- Web UI: an auth-aware header nav (a "Log in" button for visitors; for signed-in users a top-level Following link plus an Account dropdown holding Profile, SSH keys, Access tokens, and Logout — a JS-free
<details>menu), landing page with login CTA for visitors (/), repository list for authenticated users (/), public repository browse at/explore, file/tree browser with self-hosted syntax highlighting (extension-based language detection, falls back to plain text for unknown extensions and binary files; Markdown files render to HTML by default with a Rendered/Code toggle), a rendered README (commonmark-java with GFM tables, XSS-safe) shown below the file list on the repository overview page, commit log (paginated), branches, tags (own dedicated page, separate from branches), one-time handle selection (/onboarding), profile settings (/settings/profile). Every repository sub-page shows a persistent left sidebar with repo identity, a Clone button opening the clone dialog, a pin toggle, and section navigation (Code, Commits, Branches, Tags, Issues, Merge requests, plus Settings for the owner) with per-section counts and active-section highlighting, and the clone panel has copy-to-clipboard buttons for the HTTP and SSHgit clonecommands. Keyboard shortcuts are an optional, progressive enhancement (?opens a help overlay,Escapecloses it,g hgoes home) — every page works fully without JavaScript - Per-repository issues: title, optional description (rendered as Markdown, XSS-safe), per-repo sequential number (
#1,#2, …), an author, and an optional assignee (any local user, set by username; blank clears it); created and managed by the repo owner and collaborators, readable by anyone who can read the repo, via a dedicated "New issue" page; title and description can be edited afterwards via an "Edit issue" page. Issue pages are addressed by number (…/issues/1); old UUID URLs redirect permanently to the number form - Issues move through a fixed lifecycle (Planned → In development → Done); the repo navigation shows the open (Planned + In development) issue count, and Done issues collapse into an "Archive" section on the issues page
- Issues auto-close from pushed commit messages, GitHub-style (
close(s|d)/fix(es|ed)/resolve(s|d)+#<number>, e.g.fixes #12), over both HTTP and SSH pushes - Per-repository merge requests: source → target branch within one repo, with a title, optional description, a per-repo sequential number displayed bang-prefixed (
!1,!2, …, distinct from issues'#), an author, and an optional assignee and reviewer (any local user, set by username from a GitHub-style picker; blank clears); created and managed by the repo owner and collaborators, readable by anyone who can read the repo, via a dedicated "New merge request" page where the author picks source and target from the repo's branches. Merge-request pages are addressed by number (…/merge-requests/1, matching the API and the issue URL scheme); old UUID URLs redirect permanently to the number form - Merge requests move through the lifecycle Open → Merged / Closed; the repo navigation and left sidebar show the open merge-request count, and merged/closed ones collapse into an "Archive" section on the list page (same pattern as issues)
- Dashboard notifications: the signed-in home page (
/) surfaces the open issues and merge requests you are involved in — ones you authored, are assigned to, or (for merge requests) are asked to review — each linking straight to the item and labelled with its repository; items in a repository you can no longer read never appear. Built on a pluggableNotificationSourceaggregation, so future item types can contribute without touching the dashboard - The merge request detail page renders the live diff of the source branch relative to the merge base with the target (three-dot diff), file by file with per-line add/delete coloring and a changed-files / +additions / −deletions summary — always computed live from git, never duplicated into the database
- The owner or a collaborator can Merge or Close an open merge request from the detail page; merging runs entirely in-core against the bare repository (no working tree), fast-forwarding when possible or else recording a two-parent merge commit authored by the acting user and advancing the target branch ref. An automatic merge that would conflict is rejected; a source branch already contained in the target is treated as already merged
- Line-level review comments on a merge request's diff: any authenticated user who can read the repository can comment on a specific diff line (added, deleted, or context) from the merge-request detail page; comments render inline beneath the line they anchor to. A comment can be deleted by its author, the repository owner, or a collaborator. Comments are anchored to a file plus the diff line's old/new line numbers and must land on a line that's part of the current diff. Hovering a commentable line reveals a comment icon on the right; clicking it opens the form inline — a progressive-enhancement disclosure that works without JavaScript
- OIDC login (authorization code flow) via
GET /login; on first login the user account is created without a username and the browser is redirected to/onboarding, where the user picks a URL-safe handle (^[a-z0-9][a-z0-9-]{0,38}$, unique across users and organisations). The chosen handle — not the OIDCpreferred_usernameclaim (which is an SPN form in kanidm and not URL-safe) — is used in all repo, SSH, ActivityPub, and webfinger URLs. Thenameclaim becomes an editable display name; both can be changed later at/settings/profile. A request filter blocks all app pages until a handle is chosen. The code-flow callback is pinned to/login(redirect-path+restore-path-after-redirect, so strict-redirect_uriIdPs like kanidm register one URI) and after login the user lands back on the page they came from — the header's Log in button carries?redirect=<current page>(local paths only, open-redirect guarded). Expired ID tokens are refreshed silently with the refresh token (refresh-expired=true, 60 s proactive skew, session cookie usable 12 h past expiry) instead of logging the user out. Logout is local-session only viaPOST /logout(the kanidm provider advertises noend_session_endpoint, so RP-Initiated Logout is disabled) - Profile pictures: users can upload a PNG/JPEG/GIF/WebP avatar (≤ 2 MB, content-type and magic bytes both validated) at
/settings/profile, stored on the filesystem keyed by user UUID and served publicly atGET /users/{username}/avatar; shown wherever a local user is rendered (header nav, repo lists, repo sidebar, issue/MR/comment authors) via a reusable Qute avatar tag, removable, and falling back to an initials badge when absent. Git commit authors and remote federation actors are not local users and keep their existing pseudo-avatars - Per-user content width setting (Appearance section on
/settings/profile): Full (default — repository pages span the entire screen, other pages cap at 1400px), Comfortable (fixed 1400px), or Compact (fixed 1120px) on every page; the width also aligns the header nav content (the bar background stays edge-to-edge), and windows narrower than the cap just fill the screen. Guide: profile settings - Collaborators — the repository owner can grant other local users read+write access
(one flat role) on a per-repository settings page (
…/settings/collaborators, reached from a "Manage collaborators" link on the owner-only repository Settings page). Collaborators can read and push — UI, HTTP, and SSH alike — even on private repositories, and manage issues and merge requests; deleting the repository, managing mirrors, and managing collaborators stay owner-only. Guides: for users, for admins - Per-repository images: the repo owner can upload a custom image (same PNG/JPEG/GIF/WebP, ≤ 2 MB, validated rules as avatars) on a dedicated owner-only repository Settings page (
/repos/{owner}/{name}/settings), stored on the filesystem keyed by repo UUID. It replaces the owner's avatar wherever the repository is shown (repo lists, repo sidebar); a repository with no custom image falls back to its owner's avatar. Served atGET /repos/{owner}/{name}/image, visibility-guarded so a private repo's image never leaks (404for non-viewers), and removable back to the fallback - Forks — any logged-in user can fork a repository they can read into their own namespace with
one click (or
POST /api/v1/repos/{owner}/{name}/fork). The fork carries the source's name, visibility, and description, copies every branch and tag via a local bare clone, and records aforked from {owner}/{name}link back to its parent. A private source can only be forked by someone who can already read it, so a fork never exposes it. Guides: for users, for admins, architecture - Search — a simple case-insensitive substring search over repositories (owner, name,
description) and people (username, display name), from the header search box or
GET /search, and as JSON atGET /api/v1/search?q=<term>. Repository hits obey the same visibility rule as everywhere else, so a private repo never surfaces to someone who cannot already read it. Person hits link to that user's public profile page atGET /users/{username}(avatar, display name, and their visibility-filtered repositories — the person equivalent of the/orgs/{name}organisation profile). Guides: for users, for admins - Single access policy on all paths: owner read/write, collaborators read/write, org roles (guest read / member write / owner admin) on org repositories, public world-readable, private repositories visible only to whoever holds a read grant
- JSON REST API under
/api/v1, authenticated with the same personal access tokens as git-over-HTTP (Authorization: Bearer <token>), auto-documented via OpenAPI/Swagger UI (see below) - MCP server at
/mcp(Streamable HTTP), exposing the same feature set as the REST API as MCP tools so an AI client can manage repositories, issues, merge requests, and MR line-comments (see below) - Push mirrors — the repository owner can replicate a repository to external remotes on
every push (
git push --mirrorsemantics, all refs including deletions), over HTTPS with stored credentials or over SSH with a server-generated Ed25519 deploy key. Syncs run asynchronously (the incoming push never waits or fails because of a mirror), coalesce under rapid pushes, retry with exponential backoff, and dead-letter with the error visible on the owner-only repository Settings page where mirrors are managed; secrets are encrypted at rest (GITSHARK_SECRET_KEY). Guides: for users, for admins, architecture - Federation (ForgeFed / ActivityPub) — opt-in, off by default. Public repositories are
exposed as ForgeFed
Repositoryactors that remote instances can follow and receivePushactivities from (see below) - CI/CD runners — phase 1: registration only. git-shark implements the server side of the
Forgejo/Gitea
runner.v1Connect protocol under/api/actions, so a stockforgejo-runner/act_runnerregisters and reports in unchanged. Instance admins (handles inGITSHARK_ADMIN_HANDLES) generate reusable registration tokens and manage runners at/admin/runners; secrets are stored hashed. Workflow execution (fetching and running jobs) is a follow-up phase. Guides: for users, for admins, architecture activities from; local users can in turn follow a remote repository — or a whole remote user, whose public repositories are then followed and shown grouped — and read their pushes (see below)
Federation (ForgeFed)
git-shark speaks ForgeFed over ActivityPub server-to-server so instances
can interoperate. The first goal is git-shark ↔ git-shark: a remote instance follows a public
repository and receives a signed Push activity whenever commits land. Standard ActivityStreams +
ForgeFed vocabulary and HTTP Signatures (RSA) are used throughout, so other ForgeFed software could
interoperate later (untested). Detailed guides: for users,
for deployment, and
architecture notes for maintainers.
When enabled, each public repository (and each user) is an actor under /ap:
GET /ap/repos/{owner}/{name}—Repositoryactor (also via content negotiation on the repo page withAccept: application/activity+json);…/inbox,…/outbox,…/followersGET /ap/users/{username},GET /ap/instance—Person/ instanceApplicationactorsGET /.well-known/webfinger?resource=acct:{owner}/{name}@{host}— actor discovery- A remote actor
POSTs a signedFollowto a repo inbox → recorded +Accept'd;Undounfollows - On push, a
Pushactivity is published to the outbox and delivered to followers via a persisted, retrying, HTTP-Signature-signed delivery queue - Outbound: logged-in users can follow a remote repository/actor from the Following page by
handle (
owner/name@host) or actor URL; the handle is resolved via WebFinger (lenient about bare-host vs. host:portacct:forms) and a signedFollowis enqueued for delivery, tracked as PENDING until the remoteAccepts (unfollow sendsUndo)- Inbound
Pushactivities from followed repositories are stored and surfaced as a "Recent pushes" feed on the Following page; pushes from actors nobody follows are dropped
- Inbound
Inbound activities must carry a valid HTTP Signature from an allowlisted peer; outbound fetches are HTTPS-only, allowlist-bound, and SSRF-guarded (no private/loopback/link-local targets). Issues and merge requests exist locally but are not federated yet.
Enabling federation publishes permanent actor IDs derived from
base-url. Set a real, stable, non-loopback HTTPS origin before turning it on; git-shark refuses to emit actor documents otherwise.
REST API
A JSON REST API is served under /api/v1, auto-documented via the existing
quarkus-smallrye-openapi extension (GET /q/openapi, GET /q/swagger-ui).
- Authenticated with the same personal access tokens used for git-over-HTTP, but sent as
Authorization: Bearer <token>(not HTTP Basic) - Anonymous requests are allowed for public reads only; mutations require a token and write access (owner or collaborator; posting a comment only requires read access, deleting a repository stays owner-only)
- Same visibility rules as the UI: private repositories are hidden as
404, not403
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/user |
The token owner (401 without a valid token) |
| GET | /api/v1/repos |
Repositories visible to the caller |
| POST | /api/v1/repos |
Create a repository (400 invalid name, 409 duplicate) |
| GET | /api/v1/repos/{owner}/{name} |
Repository detail |
| DELETE | /api/v1/repos/{owner}/{name} |
Delete a repository (owner only) |
| GET, POST | /api/v1/repos/{owner}/{name}/issues |
List / create issues |
| GET, PATCH, DELETE | /api/v1/repos/{owner}/{name}/issues/{number} |
Get / update status / delete an issue |
| GET, POST | /api/v1/repos/{owner}/{name}/merge-requests |
List / create merge requests |
| GET | /api/v1/repos/{owner}/{name}/merge-requests/{number} |
Merge request detail |
| POST | /api/v1/repos/{owner}/{name}/merge-requests/{number}/merge |
Merge |
| POST | /api/v1/repos/{owner}/{name}/merge-requests/{number}/close |
Close |
| GET, POST | /api/v1/repos/{owner}/{name}/merge-requests/{number}/comments |
List / add line-level review comments (any reader may comment) |
| DELETE | /api/v1/repos/{owner}/{name}/merge-requests/{number}/comments/{commentId} |
Delete a comment (author, repo owner, or collaborator) |
MCP server
An MCP (Model Context Protocol) server is exposed via the Quarkiverse extension
io.quarkiverse.mcp:quarkus-mcp-server-http (1.13.1), mirroring the REST API's feature set as
MCP tools. Client setup (Claude Code, Claude Desktop, others) is described in
docs/users/mcp.md.
- Transport: Streamable HTTP at
/mcp(the extension also serves the legacy HTTP/SSE variant, but Streamable HTTP is the one git-shark uses) - Auth: same personal access tokens as the REST API, sent as
Authorization: Bearer <token>on the MCP request; read tools work anonymously for public repos, write tools require a token and owner or collaborator rights (commenting only requires read access)
| Area | Tools |
|---|---|
| Repositories | listRepositories, getRepository, createRepository, forkRepository, deleteRepository |
| Issues | listIssues, getIssue, createIssue, updateIssueStatus, deleteIssue |
| Merge requests | listMergeRequests, getMergeRequest, createMergeRequest, mergeMergeRequest, closeMergeRequest, listMergeRequestComments, addMergeRequestComment |
| User | currentUser |
Architecture notes
- Repository names resolve to UUID-based storage paths through the database only
(
<storage-root>/<owner-uuid>/<repo-uuid>.git); HTTP, SSH, and UI share the same resolution and authorization services. - Tokens are stored as SHA-256 hashes; the plaintext is shown exactly once at creation, with a copy-to-clipboard button next to it.
- The SSH host key is generated on first start and persisted, so the host identity is stable across restarts.
- SSH serves only
git-upload-pack/git-receive-pack; shells and other commands are rejected. - UI fonts (Space Grotesk, JetBrains Mono) are self-hosted from the application origin — no CDN or other external requests, so the UI works fully offline/air-gapped.
Configuration
| Property / Env var | Default | Purpose |
|---|---|---|
GITSHARK_STORAGE_ROOT |
data/repositories |
Root directory for bare repositories (persistent volume) |
GITSHARK_AVATAR_ROOT |
data/avatars |
Root directory for uploaded profile pictures (persistent volume) |
GITSHARK_REPO_IMAGE_ROOT |
data/repo-images |
Root directory for uploaded per-repository images (persistent volume) |
GITSHARK_SSH_PORT |
2222 |
Embedded SSH server port |
GITSHARK_SSH_HOST_KEY |
data/ssh/host-key |
Persisted SSH host key file |
GITSHARK_ADMIN_HANDLES |
— (empty = no admins) | Comma-separated handles allowed into /admin/* (CI runner management) |
QUARKUS_DATASOURCE_JDBC_URL / _USERNAME / _PASSWORD |
— (Dev Services in dev/test) | PostgreSQL connection |
QUARKUS_OIDC_AUTH_SERVER_URL / _CLIENT_ID / _CREDENTIALS_SECRET |
— (Keycloak Dev Services in dev/test) | OIDC provider |
QUARKUS_OIDC_AUTHENTICATION_STATE_SECRET |
— (dev/test use a fixed dev secret) | Encrypts the OIDC state cookie carrying the PKCE code_verifier; ≥ 32 chars, stable across pods |
QUARKUS_OIDC_TOKEN_STATE_ENCRYPTION_SECRET |
— (falls back to credentials secret) | Encrypts the post-login session cookie holding the tokens; ≥ 32 chars, set explicitly for multi-pod |
GITSHARK_SECRET_KEY |
— | Symmetric key encrypting push-mirror secrets at rest (AES-256-GCM); required to create mirrors, keep it stable |
GITSHARK_MIRROR_MAX_ATTEMPTS |
8 |
Max attempts per mirror sync before it is dead-lettered |
GITSHARK_MIRROR_ALLOW_INSECURE |
false |
Dev/local only. Allow http:// and loopback/private mirror targets. Never enable in production. |
GITSHARK_FEDERATION_ENABLED |
false |
Master switch for ForgeFed federation |
GITSHARK_FEDERATION_BASE_URL |
— | Public HTTPS origin (e.g. https://shark.example); actor IDs derive from it and are permanent |
GITSHARK_FEDERATION_PEER_ALLOWLIST |
— (empty = deny all) | Comma-separated peer hosts allowed to send/receive federation traffic |
GITSHARK_FEDERATION_MAX_ATTEMPTS |
8 |
Max delivery attempts before a queued activity is dead-lettered |
GITSHARK_FEDERATION_USER_RESYNC_INTERVAL |
5m |
How often followed remote users are re-scanned for new public repositories |
GITSHARK_FEDERATION_DEV_ALLOW_INSECURE |
false |
Dev/local only. Lets the SSRF guard accept http + loopback/private targets so two instances can federate on one machine (peer allowlist still enforced). Never enable in production. |
TLS required in production: personal access tokens travel as HTTP Basic credentials (git-over-HTTP) or a
Bearertoken (REST API). Terminate TLS in front of the service; never expose plain HTTP publicly.
Development
./mvnw quarkus:dev # dev mode; PostgreSQL + Keycloak via Dev Services (Docker/Podman required)
./mvnw test # JVM tests
./mvnw verify -Dnative # native build + integration tests (HTTP, Git protocol, SSH)
Native build uses a container build automatically when no local GraalVM is present
(-Dquarkus.native.container-build=true to force). Binary: target/git-shark-1.0-SNAPSHOT-runner.
Native-specific fixes: JGit's NLS classes (JGitText, HttpServerText) and its config enums are
registered in META-INF/native-image/reflect-config.json, and BouncyCastle is registered at build
time via quarkus.security.security-providers=BC (Apache SSHD's runtime provider registration
doesn't work in native images).
Integration tests (Failsafe, *IT) run against the packaged app — JVM jar or native binary alike:
SmokeIT— health endpoint, landing page, explore page, OpenAPI document, SSH bannerGitHttpIT— anonymous JGit clone of the seededalice/demorepo over smart HTTP, anonymous push rejection (refs unchanged), repository overview page, raw file servingSshHandshakeIT— full SSH handshake (KEX/host key/cipher via Apache SSHD + BouncyCastle), asserting cleanpublickey-auth rejection for an unregistered key
ITs seed demo data (GITSHARK_DEV_SEED_DATA=true) and isolate runtime data under
target/it-data (see Failsafe systemPropertyVariables in pom.xml).
Dev mode seed data
Two %dev-only flags are set in application.properties (both default false in all other profiles):
gitshark.dev.seed-data=true— on startup,DevDataSeederidempotently creates useraliceowning a public repositorydemowith a Markdown README onmain, an open merge request (feature→main, with a sample review comment) and four issues in different states with short and long Markdown descriptions. A fresh dev instance is never empty.gitshark.dev.adopt-username=true— Keycloak Dev Services mint a fresh OIDC subject on every run, so the seededalicerow would otherwise collide on login. With this flag, an unknown subject whose username matches an existing account re-keys that account to the new subject instead of rejecting the login. Never enabled in production (re-keying from a username claim is an account-takeover vector).
Dev/test Keycloak imports src/main/resources/quarkus-realm.json (users alice/alice and
bob/bob, client quarkus-app with the profile and email client scopes). The app requests
quarkus.oidc.authentication.scopes=profile,email, so the ID token carries preferred_username,
name and email — logging in as alice adopts the seeded account directly and skips the
onboarding page.
Persisted data
| Store | What |
|---|---|
| PostgreSQL | users, repositories (metadata), repository_pins (per-user pinned repositories), ssh_keys (public keys + fingerprints), access_tokens (SHA-256 hashes, labels, last-used), push-mirror tables (push_mirror with AES-GCM-encrypted secrets, mirror_sync queue), federation tables (federation_keys, remote_actors, repository_followers, remote_follows, remote_user_follows, received_pushes, federation_outbox, federation_inbox, federation_delivery) |
Filesystem (GITSHARK_STORAGE_ROOT) |
Bare Git repositories |
Filesystem (GITSHARK_AVATAR_ROOT) |
Uploaded profile pictures, one file per user (UUID-named) |
Filesystem (GITSHARK_SSH_HOST_KEY) |
SSH host key |
CI
.github/workflows/jvm.yml— JVM tests on every push/PR; on main, builds and pushes the JVM container image to GHCR as a multi-arch manifest (linux/amd64 + linux/arm64, via Buildx/QEMU)..github/workflows/native.yml— native build + integration tests on every push/PR; on main, builds and pushes the native container image to GHCR with a-nativetag suffix (linux/amd64 only).