Skip to content

Repository files navigation

HeadlessGit

A headless Git server for platforms and internal tools.

Provides Git hosting primitives (git over SSH/HTTP, authentication, permissions, storage) and pluggable storage.

Basically, this is a Git layer of infrastructure you'd put underneath a project. This service is not responsible for billing and the UI, etc. It just handles the actual git transport, enforces access, and stores the bare repositories.

What it is

  • Basic Git over SSH and HTTP for clone / fetch / push.
  • Git LFS for large files, with object storage on local disk or any S3-compatible bucket (AWS S3, Cloudflare R2, MinIO).
  • A small control API, RESTful api to manage repositories, users, SSH keys, tokens, and permissions.
  • A repo content API — list trees, read files, download zip/tar.gz archives, and create commits over REST, so your backend never needs a local clone.
  • Simple permission model (read / write / admin) enforced before every Git operation.
  • Path policies — block chosen paths from ever being committed, enforced identically on API commits and git push (via a pre-receive hook).
  • Push webhooks — signed deliveries on every ref change, pushed or committed via the API.
  • Bare-repository storage on a filesystem, with SQLite for metadata.

Example

Start the server using headlessgit image (it already bundles git):

docker run --rm \
  -p 4000:4000 -p 4001:4001 -p 2222:2222 \
  -v "$PWD/data:/data" \
  -e DATABASE_URL=/data/headlessgit.db \
  -e ADMIN_TOKEN="$(openssl rand -hex 32)" \
  ghcr.io/axenos-dev/headlessgit:latest

That brings up three listeners - Git over HTTP (4000) and SSH (2222) for clients, and the control API (4001) for your backend. ADMIN_TOKEN seeds an admin service account on boot — your backend uses it as a bearer token to provision accounts, repositories, and permissions through the control API.

Once a user has a repository and credentials, they can use it like any other Git remote — the path is always <owner-username>/<repo-name>.git:

# over SSH, authenticated by a registered public key
git clone ssh://localhost:2222/username/api.git

# over HTTP, authenticated by a token
git clone http://x:<token>@localhost:4000/username/api.git

For local development, see ./dev.sh up.

Integration model

diagram

  • Backend using the admin token calls the control API to create accounts, register credentials, create repositories, and grant permissions - translating its own users into explicit repo grants here.
  • Users use the data plane directly with their own credentials (SSH key or token). Service authenticates them and authorizes each operation against the permissions they have.

Identities

An account is either a user (a human with a Git client) or a service (a machine — backend). They authenticate identically and are authorized by the same per-repo permissions. The seeded ADMIN_TOKEN account is an admin service account — typically your application's backend, which uses it to provision accounts, repositories, and permissions.

Recommended deployment

  • Keep the control API on an internal interface — it's the privileged plane. The data-plane ports are the ones you expose to clients.
  • Treat ADMIN_TOKEN as a secret. Rotate it by changing the env value and restarting the service (to reseed the admin account).
  • Persist /data (bare repos, the SQLite file, and the SSH host key all live there).

Repository storage

Today, bare repositories are stored on the local filesystem under REPO_ROOT. Support for keeping repositories on dedicated storage nodes over RPC is planned.

Configuration

All configuration is via environment variables.

Variable Default Description
DATABASE_URL (required) SQLite file path, e.g. data/headlessgit.db.
AUTO_MIGRATE true Run migrations on startup.
ENVIRONMENT DEVELOPMENT DEVELOPMENT or PRODUCTION.
CONTROL_PORT 4001 Control API listener.
GIT_HTTP_PORT 4000 Git-over-HTTP listener.
GIT_SSH_PORT 2222 Git-over-SSH listener.
REPO_ROOT data/repos Where bare repositories are stored.
SSH_HOST_KEY_PATH data/ssh/host_ed25519 SSH host key file (generated on first boot if absent).
ADMIN_TOKEN (empty) Raw token for the seeded admin account. Only its hash is stored. Empty = no admin seeded.
TOKEN_GC_INTERVAL 1h How often expired tokens are deleted. 0 disables the loop.
REPO_GC_INTERVAL 5h How often git gc sweeps the repositories (repack + prune). 0 disables the loop.
WEBHOOK_WORKERS 3 Goroutines delivering webhook events.

See .env.example.

Git LFS

Git LFS is enabled if LFS_ENABLED=true set in environment. Clients then use it transparently over both HTTP and SSH, and nothing beyond the usual git lfs track.

Storage sits behind an interface, separate from the bare repos. It can be one of those:

  • disk (default) — objects stored locally on disk under LFS_ROOT.
  • s3 — any S3-compatible bucket (AWS S3, Cloudflare R2, MinIO). Transfers use presigned URLs, so object bytes flow directly between the client and the bucket instead of streaming through the server.
Variable Default Description
LFS_ENABLED false Enable Git LFS.
LFS_STORAGE_TYPE disk disk or s3.
LFS_PUBLIC_URL (required if enabled) Externally-reachable base URL of the Git HTTP server, e.g. https://git.example.com.
LFS_ROOT data/lfs Object directory when LFS_STORAGE_TYPE=disk.
LFS_S3_BUCKET (required for s3) Bucket name.
LFS_S3_ENDPOINT (required for s3) Host without scheme, e.g. <account>.r2.cloudflarestorage.com.
LFS_S3_ACCESS_KEY_ID (required for s3) Access key ID.
LFS_S3_SECRET_ACCESS_KEY (required for s3) Secret access key.
LFS_S3_REGION (empty) Region; use auto for Cloudflare R2.
LFS_S3_USE_SSL true Reach the endpoint over HTTPS.
LFS_S3_USE_PATH_STYLE false Force path-style addressing (needed by some S3-compatible providers).
LFS_S3_KEY_PREFIX (empty) Optional prefix prepended to every object key.

Control API

Every request requires Authorization: Bearer <ADMIN_TOKEN>. Responses are enveloped: {"data": ...} on success, {"error": {"code", "message"}} on failure.

Accounts & credentials

Method Path Body Description
POST /users {username, kind} Create a user/service account (kind: user | service); 409 user_exists if the username is taken.
GET /users/{id} Get an account.
GET /users/by-username/{username} Look up an account by username (name -> id resolution).
GET /users/{id}/repositories List repositories owned by the account.
POST /users/{id}/ssh-keys {title, publicKey} Register an SSH public key.
GET /users/{id}/ssh-keys List the account's SSH keys.
DELETE /users/{id}/ssh-keys/{keyId} Revoke an SSH key.
POST /users/{id}/tokens {title} Mint a token; the raw value is returned once.
GET /users/{id}/tokens List the account's tokens (never the secret).
DELETE /users/{id}/tokens/{tokenId} Revoke a single token.
DELETE /users/{id}/tokens Revoke all of the account's tokens.

Repositories & permissions

Method Path Body Description
POST /repositories {ownerId, name, visibility} Create a repository (visibility: public | private); 409 repository_exists if the owner already has one with that name.
GET /repositories/{id} Get repository metadata.
GET /repositories/by-path/{namespace}/{name} Look up a repository by owner username + name (name -> id resolution).
PUT /repositories/{id}/visibility {visibility} Change visibility (public | private).
DELETE /repositories/{id} Delete a repository (row + bare repo).
GET /repositories/{id}/permissions List collaborators.
PUT /repositories/{id}/permissions {userId, role} Grant/update a collaborator role (read | write | admin).
DELETE /repositories/{id}/permissions/{userId} Revoke a collaborator.
POST /repositories/{id}/webhooks {url} Register a push webhook; the signing secret is returned once. 409 webhook_exists if the URL is already registered on the repo.
GET /repositories/{id}/webhooks List the repository's webhooks (never the secret).
DELETE /repositories/{id}/webhooks/{hookId} Delete a webhook.
GET /repositories/{id}/path-policies List the repository's path policies.
POST /repositories/{id}/path-policies {pattern, reason?} Block a path; see Path policies.
DELETE /repositories/{id}/path-policies/{policyId} Remove a policy.

Repository contents & commits

Method Path Body Description
GET /repositories/{id}/contents?ref=&path=&include=lastCommit List one directory level, optionally with each entry's last commit.
GET /repositories/{id}/commits/{sha} Get metadata for one commit by its full SHA.
GET /repositories/{id}/diff?base=&head= Compare two refs with per-file metadata and unified patches.
GET /repositories/{id}/blob?ref=&path=&lfs= Stream one file's raw content.
GET /repositories/{id}/archive?ref=&format=&lfs=&prefix= Stream a zip (default) or tar.gz archive of the tree.
POST /repositories/{id}/blobs raw bytes Upload content into the repo's object database; returns {sha, size}.
POST /repositories/{id}/commits JSON Create a commit from Git blobs or verified LFS objects.

Reading a repository

ref accepts anything git can resolve to a commit — a branch, tag, sha, or expression like main~2 — and defaults to HEAD. Every response is pinned to the exact commit it was answered from, so consumers can page through a repository without seeing a torn view mid-push.

GET /contents returns the entries of one directory level:

{
  "data": {
    "ref": "main",
    "sha": "9fb037999f264ba9a7fc6274d15fa3ae2ab98312",
    "path": "src",
    "entries": [
      {
        "name": "main.go",
        "path": "src/main.go",
        "type": "file",
        "mode": "100644",
        "size": 1234,
        "sha": "...",
        "lastCommit": {
          "sha": "7786adb...",
          "message": "Change server difficulty",
          "committedAt": "2026-07-30T18:42:00Z"
        }
      },
      {
        "name": "vendor",
        "path": "src/vendor",
        "type": "dir",
        "mode": "040000",
        "sha": "..."
      }
    ]
  }
}

type is file | dir | symlink | submodule. Add include=lastCommit to populate the optional lastCommit object for every entry.

GET /commits/{sha} requires commit SHA and returns the complete commit message and metadata:

{
  "data": {
    "sha": "9fb037999f264ba9a7fc6274d15fa3ae2ab98312",
    "parents": ["7786adb11411791e94b04f5f672e50df2a472a65"],
    "message": "Update server configuration",
    "author": {
      "name": "Alex Developer",
      "email": "alex@example.com"
    },
    "authoredAt": "2026-07-30T18:40:00Z",
    "committedAt": "2026-07-30T18:42:00Z"
  }
}

parents is an empty array for a root commit and contains multiple SHAs for a merge commit. A missing commit returns 404 commit_not_found.

GET /diff requires base and head, each accepting the same ref syntax as the other read endpoints. The all-zero SHA is also accepted on either side as the empty tree, so a ref creation can be diffed as base=0000...&head=<commit> and a ref deletion as base=<commit>&head=0000....

{
  "data": {
    "baseSha": "...",
    "headSha": "...",
    "files": [
      {
        "status": "renamed",
        "oldPath": "config/default.properties",
        "newPath": "config/server.properties",
        "oldBlobSha": "...",
        "newBlobSha": "...",
        "oldMode": "100644",
        "newMode": "100644",
        "additions": 2,
        "deletions": 1,
        "binary": false,
        "patch": "diff --git a/config/default.properties b/config/server.properties\n...\n@@ -1,3 +1,4 @@\n ...\n"
      }
    ],
    "truncated": false
  }
}

patch and binary are always present. Binary files return null for patch, additions, and deletions, with "patchOmittedReason": "binary". A patch larger than 1 MiB, or one that would take the response over its 10 MiB patch budget, is omitted completely with "patchOmittedReason": "too_large"—the API never returns a partially cut patch. Non-UTF-8 patches use "unsupported_encoding". Diffs over 10k files set "truncated": true and omit patches as "too_large".

The patch is intended for normal unified-diff renderers. Consumers that need complete old and new file bodies can fetch them through /blob using base + oldPath and head + newPath.

GET /blob streams the file bytes with Content-Length, a strong ETag (the blob sha — content-addressed, so If-None-Match caching works perfectly), and X-HeadlessGit-Commit carrying the resolved commit. With lfs=true, an LFS pointer file is replaced by the real object; a missing object is a 404 rather than silently serving the pointer.

GET /archive streams the whole tree as an artifact, named <repo>-<shortsha>.zip. By default its entries are under the matching <repo>-<shortsha>/ directory. Set prefix=release/source to choose another directory, or explicitly set prefix= to place entries at the archive root. Prefixes are relative directory paths and a trailing / is optional.

With lfs=true, pointer files are swapped for the real objects in-flight — the archive is re-encoded entry by entry, nothing is buffered or written to disk:

archive

A pointer whose object is missing stays a pointer.

Writing without a clone

Commits follow two-step model: upload content first, then commit metadata referencing it.

commit

# 1. upload each new/changed file's bytes (raw body, streamed)
curl -H "Authorization: Bearer $TOKEN" \
  --data-binary @config.yaml \
  http://localhost:4001/repositories/7/blobs
# -> {"data": {"sha": "44b4fc6d...", "size": 812}}

# 2. create the commit (atomic, any number of operations)
curl -H "Authorization: Bearer $TOKEN" -X POST \
  http://localhost:4001/repositories/7/commits -d '{
    "branch": "main",
    "message": "update config",
    "author": { "name": "deploy-bot", "email": "bot@example.com" },
    "expectedHeadSha": "9fb03799...",
    "operations": [
      { "op": "put", "path": "config.yaml", "blobSha": "44b4fc6d..." },
      { "op": "delete", "path": "config.old.yaml" }
    ]
  }'
# -> 201 {"data": {"branch": "main", "commitSha": "...", "before": "9fb03799..."}}

A put operation takes exactly one content source:

{ "op": "put", "path": "README.md", "blobSha": "<sha from POST /blobs>" }

or a repository-scoped LFS object already uploaded and verified through the LFS Batch API:

{
  "op": "put",
  "path": "models/model.bin",
  "lfs": {
    "oid": "...",
    "size": 734003200
  }
}

blobSha and lfs are mutually exclusive. executable is optional for puts. A delete operation takes only op and path.

A move relocates a file or a whole directory tree:

{
  "op": "move",
  "fromPath": "plugins",
  "path": "server/plugins"
}

Operations are applied in array order. After the move, later operations in the same request use the destination path:

{
  "operations": [
    { "op": "move", "fromPath": "plugins", "path": "server/plugins" },
    { "op": "put", "path": "server/plugins/config.yml", "blobSha": "<sha>" },
    { "op": "delete", "path": "server/plugins/something.yaml" }
  ]
}

expectedHeadSha controls concurrency:

Value Meaning
(omitted) Last write wins.
a commit sha Compare-and-swap: 409 head_mismatch if the branch moved.
the all-zero sha The branch must not exist yet — creates it (or the first commit on an empty repository).

Content is deduplicated by sha, so retrying an upload is free and a lost 409 race can be retried without re-uploading anything. Blobs that never get committed are garbage-collected after a grace period (see REPO_GC_INTERVAL).

API commits dispatch the same signed webhooks as a git push — consumers can't tell them apart.

Health

The control port also serves an unauthenticated GET /healthz readiness probe. It returns 200 {"status":"ok"} when the database is reachable and 503 {"status":"unavailable"} otherwise, and backs the container HEALTHCHECK.

Path policies

A path policy blocks a path, and everything under it from being added or modified in a repository.

curl -H "Authorization: Bearer $TOKEN" -X POST \
  http://localhost:4001/repositories/7/path-policies \
  -d '{"pattern": "runtime/", "reason": "....."}'

Semantics:

  • A pattern matches the exact path and its whole subtree: runtime blocks runtime and runtime/state.json, but not runtime.md or src/runtime/x — patterns anchor at the repo root and match whole path segments.
  • Deletes are always allowed, so blocked content already in history can be cleaned up.
  • Policies apply to new commits only.
  • Enforcement is identical on both write paths: POST /commits returns 422 path_blocked, and a git push is rejected by a pre-receive hook before any ref moves — every commit in the push is checked, so a blocked path added and removed within the same push is still refused. The client sees the reason verbatim:
remote: push rejected: "runtime/state.json" is blocked by policy (.....)
 ! [remote rejected] main -> main (pre-receive hook declined)

Webhooks

Register a webhook on a repository and headlessgit will POST to it after every ref change — a git push or a commit created through the content API produce identical events. A repository can have multiple webhooks, but each URL only once (409 webhook_exists on duplicates); to rotate a secret, delete the webhook and re-register it.

One delivery is sent per changed ref (a branch/tag create, update, or delete — not per file or commit). The JSON body:

{
  "event": "push",
  "ref": "refs/heads/main",
  "before": "0000000000000000000000000000000000000000",
  "after": "344018f5c8bce597cfb1b13058edc688f3a13230",
  "created": true,
  "deleted": false,
  "repository": {
    "id": 6,
    "name": "HeadlessGit",
    "full_name": "Axenos-dev/HeadlessGit"
  },
  "pusher": { "id": 7, "username": "Axenos-dev" },
  "timestamp": "2026-06-29T19:06:48Z"
}

before/after are the ref's SHAs around the push; a create has before all-zero (created: true), a delete has after all-zero (deleted: true). repository.full_name is namespace/name.

Each request carries these headers:

Header Value
X-HeadlessGit-Event push
X-HeadlessGit-Delivery Unique id for this delivery attempt.
X-HeadlessGit-Signature sha256=<hex> — HMAC-SHA256 of the raw body keyed by the webhook secret.

Verify a delivery by recomputing the HMAC over the exact request body with the secret returned at registration, e.g.:

mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
ok := hmac.Equal([]byte(expected), []byte(r.Header.Get("X-HeadlessGit-Signature")))

The secret is generated server-side and shown once in the registration response.

Development

Git 2.52 or newer is required for include=lastCommit. The container image bundles a compatible Git version.

./dev.sh up     # build and run the stack (docker compose)
./dev.sh gen    # regenerate sqlc code
./dev.sh test   # build + vet + test (what CI runs)

License

MIT

About

Headless Git server for platforms - git over SSH and HTTP, auth, permissions, Git LFS (disk or S3). No forge UI

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages