Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ REPO_ROOT=data/repos
# where the host key file is kept (for ssh)
SSH_HOST_KEY_PATH=data/ssh/host_ed25519

# background maintenance intervals (go duration syntax, e.g. "30m", "12h")
# a zero duration ("0") disables the loop
TOKEN_GC_INTERVAL=1h
REPO_GC_INTERVAL=5h
# goroutines delivering webhook events
WEBHOOK_WORKERS=3


# enable the LFS endpoints (batch + verify + object transfer)
LFS_ENABLED=false
Expand Down
26 changes: 20 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ It provides:
- Git over SSH and HTTP (clone / fetch / push).
- Git LFS (object transfer over HTTP; disk or S3 storage).
- A control API to manage repositories, users, SSH keys, tokens, permissions, and webhooks.
- Repo APIs on the control plane: contents listing, blob read/upload, streamed
zip/tar.gz archives (optional LFS smudging), and commit creation on bare repos
(blobs + commits, CAS ref updates, `.gitattributes`-driven LFS cleaning) — so
products never need local clones.
- A `read` / `write` / `admin` permission model enforced before every Git operation.
- Push webhooks, signed and delivered off the push path.

Expand Down Expand Up @@ -52,7 +56,8 @@ Package map:
| `internal/domain` | Core types (repository, account, role, token, ssh key, lfs). |
| `internal/services/*` | Business logic per area (repositories, users, auth, permissions, lfs); each has a service + a registry over `db`. |
| `internal/storage` | LFS object storage behind an interface (`disk`, `s3`). |
| `internal/gitbackend` | Git pack protocol behind a small interface (`Local` execs system `git`; future `RPC` to storage nodes). |
| `internal/archive` | Pure mechanism: streaming tar re-encode to zip/tar.gz with an injected LFS smudge callback. |
| `internal/gitbackend` | Git subprocesses behind a small interface: pack protocol, read ops (ls-tree, blobs, archive), commit creation. |
| `internal/server` | Composition root: wires control + git servers, runs and shuts down listeners. |
| `internal/server/control` | Control API (REST); sub-handlers in `repositories/`, `users/`, `permissions/`. |
| `internal/server/git/gitssh` | Git-over-SSH transport (custom in-process SSH server). |
Expand Down Expand Up @@ -113,6 +118,12 @@ Prefer simple Go: small interfaces at module boundaries, context-aware I/O, expl
errors with context, table-driven tests, standard library first. Avoid large global
state, framework-heavy abstractions, and dependencies added for small tasks.

**File layout.** Do not multiply files or packages. A service is `service.go` +
`errors.go` + `registry.go` — new service methods go into `service.go`, not new
files. One test file per package where practical (`service_test.go`,
`handlers_test.go`). Small parsing/vocabulary helpers (formats, pointers, modes)
belong in `domain` next to the types they produce, not in new packages.

**Dependencies & interfaces.** The package that _invokes_ a dependency defines a
minimal consumer interface for it (e.g. `gitssh`'s `Authenticator`/`TokenMinter`,
the handler packages' own interfaces). Composition/router layers (`server`, `git`,
Expand All @@ -125,7 +136,9 @@ concretes once.
**HTTP.** One `net/http` + `chi` stack (no `fasthttp`/Fiber: the smart-HTTP path
streams bodies to/from the `git-upload-pack`/`git-receive-pack` subprocesses). The
Git/LFS routes must stay streaming-safe — no body-size limits or body-buffering
middleware. Health (`/healthz`) is unauthenticated and sits outside the audit chain.
middleware; the same applies to the control API's archive route, which streams
`git archive` output. Health (`/healthz`) is unauthenticated and sits outside the
audit chain.

**Logging.** `zap` structured logging. Never log secrets (tokens, webhook secrets,
SSH key material, full auth headers). Each transport request emits one audit line:
Expand All @@ -143,10 +156,11 @@ storage clients. Do not roll your own SSH or Git protocol implementation.

## Webhooks

- Emitted only **after** a successful push, off the push path: receive-pack runs,
`gitbackend` diffs the repo's refs before/after, and the transport hands the
changes to the webhooks service. Delivery is an in-process bounded queue with
worker goroutines and retries — no external job system.
- Emitted only **after** a ref actually moved, off the push path: receive-pack
runs and `gitbackend` diffs the repo's refs before/after (transports dispatch),
or an api commit lands its CAS ref update (repositories service dispatches).
Both produce identical push events. Delivery is an in-process bounded queue
with worker goroutines and retries — no external job system.
- One delivery per changed ref. Payload is self-describing: `event`, `ref`,
`before`/`after`, `created`/`deleted`, a `repository` object (`id`, `name`,
`full_name`), a `pusher` object (`id`, `username`), and `timestamp`. Creates/
Expand Down
103 changes: 101 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ Basically, this is a Git layer of infrastructure you'd put _underneath_ a projec
- 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.
- **Push webhooks** — signed deliveries on every successful push.
- **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
Expand Down Expand Up @@ -78,6 +79,9 @@ All configuration is via environment variables.
| `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`](.env.example).

Expand Down Expand Up @@ -138,13 +142,108 @@ Every request requires `Authorization: Bearer <ADMIN_TOKEN>`. Responses are enve
| `POST` | `/repositories/{id}/webhooks` | `{url}` | Register a push webhook; the signing secret is returned **once**. |
| `DELETE` | `/repositories/{id}/webhooks/{hookId}` | — | Delete a webhook. |

**Repository contents & commits**

| Method | Path | Body | Description |
| ------ | ---------------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| `GET` | `/repositories/{id}/contents?ref=&path=` | — | List one directory level of the tree at a ref. |
| `GET` | `/repositories/{id}/blob?ref=&path=&lfs=` | — | Stream one file's raw content. |
| `GET` | `/repositories/{id}/archive?ref=&format=&lfs=` | — | 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 on a branch from uploaded blobs. |

### 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:

```json
{
"data": {
"ref": "main",
"sha": "9fb037999f264ba9a7fc6274d15fa3ae2ab98312",
"path": "src",
"entries": [
{
"name": "main.go",
"path": "src/main.go",
"type": "file",
"mode": "100644",
"size": 1234,
"sha": "..."
},
{
"name": "vendor",
"path": "src/vendor",
"type": "dir",
"mode": "040000",
"sha": "..."
}
]
}
}
```

`type` is `file` | `dir` | `symlink` | `submodule`. Listings over 10k entries set `"truncated": true`.

`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` with a matching top-level folder. 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](images/archive.png)

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](images/commit.png)

```sh
# 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..."}}
```

`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`).

**LFS is automatic**, the same way it is for a git client: if the repo's `.gitattributes` marks a path as `filter=lfs` (including attributes added in the very same commit), the server stores the content as an LFS object and commits a pointer instead. Content that already _is_ a valid pointer passes through untouched, so pre-uploading big files via the LFS API (presigned, straight to the bucket) and committing the pointer yourself remains the efficient path for large objects.

API commits dispatch the same signed [webhooks](#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`.

## Webhooks

Register a webhook on a repository and `headlessgit` will `POST` to it after every successful push.
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](#writing-without-a-clone) produce identical events.

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

Expand Down
48 changes: 28 additions & 20 deletions cmd/app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,26 +55,6 @@ func main() {
log.Fatal(err)
}

repoService := repositories.NewService(
root.With(zap.String("service", "repositories")),
repositories.NewRegistry(db),
gitBackend,
)

authService := auth.NewService(
root.With(zap.String("service", "auth")),
auth.NewRegistry(db),
)

if config.AdminToken != "" {
if err := authService.SeedAdmin(context.Background(), config.AdminToken); err != nil {
log.Fatal(err)
}
}

permsService := permissions.NewService(permissions.NewRegistry(db))
usersService := users.NewService(users.NewRegistry(db))

// nil when LFS is disabled
var lfsService *lfs.Service
if config.LFS.Enabled {
Expand All @@ -93,11 +73,39 @@ func main() {
)
}

// assign through a typed variable so a disabled LFS stays a real nil interface
var repoLFS repositories.LFSObjects
if lfsService != nil {
repoLFS = lfsService
}

webhooksService := webhooks.NewService(
root.With(zap.String("service", "webhooks")),
webhooks.NewRegistry(db),
)

repoService := repositories.NewService(
root.With(zap.String("service", "repositories")),
repositories.NewRegistry(db),
gitBackend,
repoLFS,
webhooksService,
)

authService := auth.NewService(
root.With(zap.String("service", "auth")),
auth.NewRegistry(db),
)

if config.AdminToken != "" {
if err := authService.SeedAdmin(context.Background(), config.AdminToken); err != nil {
log.Fatal(err)
}
}

permsService := permissions.NewService(permissions.NewRegistry(db))
usersService := users.NewService(users.NewRegistry(db))

ctx, stop := signal.NotifyContext(
context.Background(),
syscall.SIGINT,
Expand Down
Binary file added images/archive.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/commit.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
83 changes: 83 additions & 0 deletions internal/archive/encoder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package archive

import (
"archive/tar"
"archive/zip"
"compress/gzip"
"io"
"strings"
)

type Encoder interface {
Write(hdr *tar.Header, body io.Reader) error
Close() error
}

type zipEncoder struct {
zw *zip.Writer
}

func NewZipEncoder(out io.Writer) Encoder {
return &zipEncoder{zw: zip.NewWriter(out)}
}

func (e *zipEncoder) Write(hdr *tar.Header, body io.Reader) error {
fi := hdr.FileInfo()
fh, err := zip.FileInfoHeader(fi)
if err != nil {
return err
}

fh.Name = hdr.Name
if fi.IsDir() {
fh.Name = strings.TrimSuffix(fh.Name, "/") + "/"
fh.Method = zip.Store
} else {
fh.Method = zip.Deflate
}

w, err := e.zw.CreateHeader(fh)
if err != nil {
return err
}

switch hdr.Typeflag {
case tar.TypeReg:
_, err = io.Copy(w, body)
case tar.TypeSymlink:
// zip stores the link target as the entry body
_, err = io.Copy(w, strings.NewReader(hdr.Linkname))
}
return err
}

func (e *zipEncoder) Close() error { return e.zw.Close() }

type tarGzEncoder struct {
gz *gzip.Writer
tw *tar.Writer
}

func NewTarGzEncoder(out io.Writer) Encoder {
gz := gzip.NewWriter(out)
return &tarGzEncoder{gz: gz, tw: tar.NewWriter(gz)}
}

func (e *tarGzEncoder) Write(hdr *tar.Header, body io.Reader) error {
if err := e.tw.WriteHeader(hdr); err != nil {
return err
}
if hdr.Typeflag == tar.TypeReg {
if _, err := io.Copy(e.tw, body); err != nil {
return err
}
}
return nil
}

func (e *tarGzEncoder) Close() error {
if err := e.tw.Close(); err != nil {
return err
}
return e.gz.Close()
}
Loading
Loading