v0.3.0 9fdf386

README.md RawHistoryFolder

ermite

A single-user Git forge in one Go binary. ermite hosts bare repositories, serves clone and push over HTTPS, reviews branches as merge requests, and shows everything in a web interface with live updates.

Status: early development. ermite serve serves Git over HTTP (clone, fetch, push), the API, GET /-/health, and the web: repositories, tree, files, commits, refs and merge requests. Visitors read public repositories; the owner signs in with a one-time link from the CLI to see private ones and to review and merge.

Run

export ERMITE_DATA_DIR=/var/lib/ermite
export ERMITE_PUBLIC_URL=https://git.example.com
export ERMITE_ADMIN_TOKEN=$(head -c 32 /dev/urandom | base64)
ermite serve
Variable Default Notes
ERMITE_DATA_DIR required absolute path of the data root
ERMITE_PUBLIC_URL required http(s) base URL, no path
ERMITE_ADMIN_TOKEN required at least 32 characters
ERMITE_ADDR :7341 listen address
ERMITE_TRUSTED_PROXIES empty comma-separated CIDRs
ERMITE_GIT git git 2.45 or newer
ERMITE_GIT_READER ermite-git ermite-git reads refs, trees, blobs and history with the ermite-git library where it is faster than the git binary and falls back to git elsewhere and for repositories it does not read; cli uses the git binary for every read (the escape hatch if a read looks wrong: set it and restart)
ERMITE_SOURCE_URL $ERMITE_PUBLIC_URL/ermite link to the source (AGPL)
ERMITE_LOG_LEVEL info trace, debug, info, warn, error
ERMITE_LOG_FORMAT json json or console
ERMITE_COMMITTER_NAME ermite committer of squash merges
ERMITE_COMMITTER_EMAIL ermite@<public host> committer email of squash merges

ermite version prints the version (and the server's when ERMITE_URL is set).

Remote CLI

The same binary drives a running server over its API. It reads ERMITE_URL (the server address, http:// or https://) and ERMITE_TOKEN (a bearer token; optional for public reads). A token sent over plain http:// to a host that is not loopback triggers a warning on every call.

export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_...
ermite repo list | show <name> | create <name> [--description …] [--visibility public] | edit <name> [--description …] [--visibility …] [--default-branch …] | delete <name> [--yes]
ermite token create <label> [--preset cli|agent|ci] [--scope repo:read …] [--repo name …] [--expires 30d]   # prints the secret once
ermite token list | revoke <id>
ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff]
ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment>
ermite mr merge <repo> <n> --head <id> [--squash] | close <repo> <n>
ermite web login                # prints a one-time sign-in link (60 s)
ermite web logout --all         # signs every browser out
ermite signing-key show         # public key and allowed_signers line of squash commits
ermite version                  # client and server, warns about missing server features
ermite watch [repo] [--json]    # follows events, reconnects and resumes

--json on repo list|show|create|edit, token create|list and version prints stable field names, listed in each command's --help; watch --json prints one object per line. Errors are one line on standard error (ermite: …) and exit status 1. ermite completion bash|zsh|fish prints a shell completion script that completes repository names from the server.

Git over HTTP

git clone http://git.example.com/<repo>.git          # public repositories need no credentials
git push http://u:<token>@git.example.com/<repo>.git main

Use the admin token (ERMITE_ADMIN_TOKEN) or a stored token as the password; the username is ignored. Store it in a Git credential helper rather than in the URL. Pushing to a new name creates a private repository. Repository names are lowercase, [a-z0-9._-]. TLS is terminated by your proxy; list its network in ERMITE_TRUSTED_PROXIES so that failed-login limits see the real client address (X-Forwarded-For).

Merge requests

A merge request proposes a branch for its target (the default branch unless set). The merge is refused when the branch moved since the reviewed head (--head) or when the target is not an ancestor of it. Fast-forward moves the target; squash writes one commit signed by the server's SSH key (ermite signing-key show prints the allowed_signers line for git verify-commit). ermite repo edit --merge-modes limits the modes per repository. Tokens need mr:write to open and comment, and mr:merge to merge.

Web sign-in

ermite web login, run with the admin token or a token with every scope, prints a link that works once within 60 seconds. Opening it and confirming sets a session cookie for 12 hours (never longer than the token that asked for the link). It needs https in ERMITE_PUBLIC_URL (except on localhost). Browser writes require the session and an Origin equal to ERMITE_PUBLIC_URL. ermite web logout --all, or revoking a token with every scope, ends every session.

The server keeps its runtime files in $ERMITE_DATA_DIR/run/ (the hook socket and scripts that git calls on each push); the hook socket path must stay under about 100 bytes.

Run with Docker

docker build --build-arg VERSION="$(git describe --tags --always --dirty)" -t ermite .
token=$(head -c 32 /dev/urandom | base64)   # keep it: it is the root credential
docker run -d -p 127.0.0.1:7341:7341 -v ermite-data:/data \
  -e ERMITE_PUBLIC_URL=http://localhost:7341 -e ERMITE_ADMIN_TOKEN="$token" ermite

The image is alpine with git, runs as uid 10001 and keeps its data in the /data volume. Logs are JSON by default. The build fetches bnema.dev/ermite-* from git.bnema.dev, so the host must reach it (NetBird); the build container uses the host's DNS and routes. Behind another route, pass --build-arg GIT_REWRITE_BASE=http://git.bnema.dev:8088/ with --network host, as the CI job does.

Develop with compose

docker compose up

Serves on http://localhost:7341 with the development admin token in compose.yaml. The source is mounted and air (go tool air, .air.toml) rebuilds and restarts the server on each change to a .go or .templ file; the module and build caches and the data live in named volumes. docker compose down -v removes them. The dev container runs as root, which maps to your user under rootless Docker.

Build

go build ./cmd/ermite
go test ./...

Develop

Tools are pinned in go.mod:

go tool buf lint
go tool buf generate        # proto/ -> gen/
go generate ./...           # mockery mocks of the ports
go tool hexcheck -fail-on warn ./...

License

AGPL-3.0-or-later. See LICENSE.

source
1# ermite
2
3A single-user Git forge in one Go binary. ermite hosts bare repositories, serves clone and push over HTTPS, reviews branches as merge requests, and shows everything in a web interface with live updates.
4
5Status: early development. `ermite serve` serves Git over HTTP (clone, fetch, push), the API, `GET /-/health`, and the web: repositories, tree, files, commits, refs and merge requests. Visitors read public repositories; the owner signs in with a one-time link from the CLI to see private ones and to review and merge.
6
7## Run
8
9```sh
10export ERMITE_DATA_DIR=/var/lib/ermite
11export ERMITE_PUBLIC_URL=https://git.example.com
12export ERMITE_ADMIN_TOKEN=$(head -c 32 /dev/urandom | base64)
13ermite serve
14```
15
16| Variable | Default | Notes |
17|---|---|---|
18| `ERMITE_DATA_DIR` | required | absolute path of the data root |
19| `ERMITE_PUBLIC_URL` | required | `http(s)` base URL, no path |
20| `ERMITE_ADMIN_TOKEN` | required | at least 32 characters |
21| `ERMITE_ADDR` | `:7341` | listen address |
22| `ERMITE_TRUSTED_PROXIES` | empty | comma-separated CIDRs |
23| `ERMITE_GIT` | `git` | git 2.45 or newer |
24| `ERMITE_GIT_READER` | `ermite-git` | `ermite-git` reads refs, trees, blobs and history with the [ermite-git](https://git.bnema.dev/bnema/ermite-git) library where it is faster than the `git` binary and falls back to `git` elsewhere and for repositories it does not read; `cli` uses the `git` binary for every read (the escape hatch if a read looks wrong: set it and restart) |
25| `ERMITE_SOURCE_URL` | `$ERMITE_PUBLIC_URL/ermite` | link to the source (AGPL) |
26| `ERMITE_LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error` |
27| `ERMITE_LOG_FORMAT` | `json` | `json` or `console` |
28| `ERMITE_COMMITTER_NAME` | `ermite` | committer of squash merges |
29| `ERMITE_COMMITTER_EMAIL` | `ermite@<public host>` | committer email of squash merges |
30
31`ermite version` prints the version (and the server's when `ERMITE_URL` is set).
32
33## Remote CLI
34
35The same binary drives a running server over its API. It reads `ERMITE_URL` (the server address, `http://` or `https://`) and `ERMITE_TOKEN` (a bearer token; optional for public reads). A token sent over plain `http://` to a host that is not loopback triggers a warning on every call.
36
37```sh
38export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_...
39ermite repo list | show <name> | create <name> [--description …] [--visibility public] | edit <name> [--description …] [--visibility …] [--default-branch …] | delete <name> [--yes]
40ermite token create <label> [--preset cli|agent|ci] [--scope repo:read …] [--repo name …] [--expires 30d]   # prints the secret once
41ermite token list | revoke <id>
42ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff]
43ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment>
44ermite mr merge <repo> <n> --head <id> [--squash] | close <repo> <n>
45ermite web login                # prints a one-time sign-in link (60 s)
46ermite web logout --all         # signs every browser out
47ermite signing-key show         # public key and allowed_signers line of squash commits
48ermite version                  # client and server, warns about missing server features
49ermite watch [repo] [--json]    # follows events, reconnects and resumes
50```
51
52`--json` on `repo list|show|create|edit`, `token create|list` and `version` prints stable field names, listed in each command's `--help`; `watch --json` prints one object per line. Errors are one line on standard error (`ermite: …`) and exit status 1. `ermite completion bash|zsh|fish` prints a shell completion script that completes repository names from the server.
53
54## Git over HTTP
55
56```sh
57git clone http://git.example.com/<repo>.git          # public repositories need no credentials
58git push http://u:<token>@git.example.com/<repo>.git main
59```
60
61Use the admin token (`ERMITE_ADMIN_TOKEN`) or a stored token as the password; the username is ignored. Store it in a Git credential helper rather than in the URL. Pushing to a new name creates a private repository. Repository names are lowercase, `[a-z0-9._-]`. TLS is terminated by your proxy; list its network in `ERMITE_TRUSTED_PROXIES` so that failed-login limits see the real client address (`X-Forwarded-For`).
62
63## Merge requests
64
65A merge request proposes a branch for its target (the default branch unless set). The merge is refused when the branch moved since the reviewed head (`--head`) or when the target is not an ancestor of it. Fast-forward moves the target; squash writes one commit signed by the server's SSH key (`ermite signing-key show` prints the `allowed_signers` line for `git verify-commit`). `ermite repo edit --merge-modes` limits the modes per repository. Tokens need `mr:write` to open and comment, and `mr:merge` to merge.
66
67## Web sign-in
68
69`ermite web login`, run with the admin token or a token with every scope, prints a link that works once within 60 seconds. Opening it and confirming sets a session cookie for 12 hours (never longer than the token that asked for the link). It needs `https` in `ERMITE_PUBLIC_URL` (except on localhost). Browser writes require the session and an `Origin` equal to `ERMITE_PUBLIC_URL`. `ermite web logout --all`, or revoking a token with every scope, ends every session.
70
71The server keeps its runtime files in `$ERMITE_DATA_DIR/run/` (the hook socket and scripts that git calls on each push); the hook socket path must stay under about 100 bytes.
72
73## Run with Docker
74
75```sh
76docker build --build-arg VERSION="$(git describe --tags --always --dirty)" -t ermite .
77token=$(head -c 32 /dev/urandom | base64)   # keep it: it is the root credential
78docker run -d -p 127.0.0.1:7341:7341 -v ermite-data:/data \
79  -e ERMITE_PUBLIC_URL=http://localhost:7341 -e ERMITE_ADMIN_TOKEN="$token" ermite
80```
81
82The image is alpine with git, runs as uid 10001 and keeps its data in the `/data` volume. Logs are JSON by default. The build fetches `bnema.dev/ermite-*` from `git.bnema.dev`, so the host must reach it (NetBird); the build container uses the host's DNS and routes. Behind another route, pass `--build-arg GIT_REWRITE_BASE=http://git.bnema.dev:8088/` with `--network host`, as the CI job does.
83
84## Develop with compose
85
86```sh
87docker compose up
88```
89
90Serves on `http://localhost:7341` with the development admin token in `compose.yaml`. The source is mounted and [air](https://github.com/air-verse/air) (`go tool air`, `.air.toml`) rebuilds and restarts the server on each change to a `.go` or `.templ` file; the module and build caches and the data live in named volumes. `docker compose down -v` removes them. The dev container runs as root, which maps to your user under rootless Docker.
91
92## Build
93
94```sh
95go build ./cmd/ermite
96go test ./...
97```
98
99## Develop
100
101Tools are pinned in `go.mod`:
102
103```sh
104go tool buf lint
105go tool buf generate        # proto/ -> gen/
106go generate ./...           # mockery mocks of the ports
107go tool hexcheck -fail-on warn ./...
108```
109
110## License
111
112AGPL-3.0-or-later. See [LICENSE](LICENSE).

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 41

↑↓ moveenter openesc close