# githttp-fs
Git-based Content Management System served over HTTP.
## What it is
githttp-fs is a single Rust binary that wraps git repositories and exposes them as a file-system-over-HTTP API. Each tenant gets its own git repository on disk. Clients can create, read, update, delete, and move `.md`/`.mdx` files via REST. Every write produces a git commit. A configurable webhook fires after each commit so downstream systems (e.g. a read-only SQL database) can stay in sync.
Git is never exposed in the API surface — no git terminology appears in requests or responses.
## Project layout
```
src/
main.rs — server startup, router wiring, config loading
config.rs — TOML config types (ServerConfig, HooksConfig, HookEvent)
state.rs — AppState: Arc<Config>, reqwest::Client, per-tenant DashMap<Mutex>
error.rs — AppError enum with axum IntoResponse (JSON error bodies)
git.rs — all git2 operations (write, delete, move, list, commits, revert)
hooks.rs — async hook delivery with exponential backoff retry
middleware.rs — Bearer API key guard (axum middleware)
routes/
mod.rs — shared request types (AuthorRequest)
files.rs — GET/PUT/DELETE/POST on /:tenant_id/files and /:tenant_id/files/*path
commits.rs — commit list, commit detail, revert
tenant.rs — DELETE /:tenant_id
```
## HTTP API
All routes are prefixed `/v1` and require `Authorization: Bearer <api_key>`.
| `DELETE` | `/v1/:tenant_id` | Delete entire tenant repository |
| `GET` | `/v1/:tenant_id/files` | List all tracked files (path + size) |
| `GET` | `/v1/:tenant_id/files/*path` | Read file content |
| `PUT` | `/v1/:tenant_id/files/*path` | Create or update a file |
| `DELETE` | `/v1/:tenant_id/files/*path` | Delete a file |
| `POST` | `/v1/:tenant_id/files/*path/move` | Move / rename a file |
| `GET` | `/v1/:tenant_id/commits?page=&per_page=` | List commits, paginated (default 100, max 500) |
| `GET` | `/v1/:tenant_id/commits/:sha` | Commit detail with per-file diffs and snapshots |
| `POST` | `/v1/:tenant_id/commits/:sha/revert` | Revert a commit |
### Request bodies
All write requests share a required `author` object. `message` is optional everywhere — auto-generated from the operation if omitted (e.g. `"update: docs/intro.md"`).
**PUT** — create or update a file
```json
{
"author": { "name": "Valerian Saliou", "email": "valerian@example.com" },
"content": "# Hello",
"message": "optional commit message"
}
```
**DELETE** `/files/*path` — delete a file
```json
{
"author": { "name": "Valerian Saliou", "email": "valerian@example.com" },
"message": "optional commit message"
}
```
**POST** `/files/*path/move` — move / rename a file
```json
{
"author": { "name": "Valerian Saliou", "email": "valerian@example.com" },
"destination": "new/path/to/file.md",
"message": "optional commit message"
}
```
**POST** `/commits/:sha/revert` — revert a commit
```json
{
"author": { "name": "Valerian Saliou", "email": "valerian@example.com" },
"message": "optional commit message"
}
```
### Response shapes
**GET** `/files` — file listing
```json
{
"files": [
{ "path": "docs/intro.md", "size": 1234 }
]
}
```
**GET** `/files/*path` — read file
```json
{
"path": "docs/intro.md",
"content": "# Hello world\n..."
}
```
**PUT / DELETE / POST move** — write result
```json
{ "commit_sha": "a3f9c1d" }
```
**GET** `/commits` — commit list
```json
{
"page": 1,
"per_page": 100,
"commits": [
{
"sha": "a3f9c1d",
"message": "update: docs/intro.md",
"author": { "name": "Valerian Saliou", "email": "valerian@example.com" },
"committed_at": "2026-06-16T10:00:00Z"
}
]
}
```
**GET** `/commits/:sha` — commit detail
```json
{
"sha": "a3f9c1d",
"message": "update: docs/intro.md",
"author": { "name": "Valerian Saliou", "email": "valerian@example.com" },
"committed_at": "2026-06-16T10:00:00Z",
"files": [
{
"path": "docs/intro.md",
"change": "updated",
"content": "# Hello world\n...",
"diff": "@@ -1,3 +1,4 @@\n ..."
}
]
}
```
`change` is one of `"created"`, `"updated"`, `"deleted"`, `"moved"`. Moved files include an additional `"from_path"` field. `content` is empty string for deleted files.
**POST** `/commits/:sha/revert`
```json
{
"reverted_sha": "a3f9c1d",
"commit_sha": "b8d2e4a"
}
```
## Configuration (`config.toml`)
```toml
[server]
host = "0.0.0.0"
port = 5355
api_key = "your-secret-key"
repos_path = "./dev/repositories"
# Tracing log level: "trace" | "debug" | "info" | "warn" | "error"
# Defaults to "info" if unset. Overridden by the RUST_LOG env var.
log_level = "debug"
[hooks]
url = "https://your-receiver.example.com/hook"
events = ["file.created", "file.updated", "file.deleted", "file.moved"]
retry_attempts = 5
retry_backoff_ms = 2000
[hooks.auth] # optional
header = "Authorization"
value = "Bearer hook-secret"
```
Config file path defaults to `config.toml` in the working directory. Override with `CONFIG_PATH=/path/to/config.toml`.
Log verbosity priority: `RUST_LOG` env var → `log_level` in config → `"info"` default.
## Key design decisions
- **One git working tree per tenant** at `repos_path/<tenant_id>/`. Repos are auto-initialised on the first write with a `"chore: initialize"` root commit — no explicit provisioning step needed.
- **Per-tenant in-memory mutex** (`DashMap<String, Arc<Mutex<()>>>`) serialises all write operations on the same repo. Reads never acquire the lock.
- **All git operations run in `spawn_blocking`** so they never stall the tokio executor.
- **Hook delivery is fire-and-forget** — spawned as a background task after the write lock is released, so writes are never delayed by a slow hook receiver.
- **Hook events are sequential per commit** — files within a single commit are delivered one hook at a time in order, so the receiver can process them synchronously.
- **Rename = single hook** — a `POST .../move` produces one `file.moved` event with both `from` and `to` paths, preserving entity identity in downstream systems.
- **Revert = new commit** — reverts never rewrite history; they produce a new inverse commit and fire the appropriate hooks for each changed file.
- **Author identity is caller-supplied** — every write request requires an `author` object with `name` and `email`. Both are stored in the git commit and validated as non-empty.
- **Commit identifier is named `sha`** (not `sha1`) — future-proof against git's SHA-256 migration; matches the convention used by GitHub, GitLab, and Gitea.
- **Timestamps are named `committed_at`** — follows the `*_at` suffix convention (Stripe, GitHub API, Rails); unambiguous about what the value represents.
- **`/move` URL suffix on POST** — axum's wildcard router cannot match a fixed suffix after `*path`, so the handler is registered on `POST /*path` and enforces the `/move` suffix internally, returning 400 otherwise.
- **Stale `.git/index.lock` cleanup** — removed at startup across all repos, and checked before each write (removed if older than 30 s), to recover from crashed processes.
- **`git2` compiled with `vendored-libgit2`** — libgit2 is bundled in the binary; no system dependency needed.
## Webhook payloads
All payloads include `tenant_id`, `commit_sha`, and `committed_at`.
**file.created / file.updated**
```json
{
"event": "file.created",
"tenant_id": "acme",
"commit_sha": "a3f9c1d",
"committed_at": "2026-06-16T10:00:00Z",
"file": { "path": "docs/intro.md", "content": "# Hello" }
}
```
**file.deleted**
```json
{
"event": "file.deleted",
"tenant_id": "acme",
"commit_sha": "a3f9c1d",
"committed_at": "2026-06-16T10:00:00Z",
"file": { "path": "docs/intro.md" }
}
```
**file.moved**
```json
{
"event": "file.moved",
"tenant_id": "acme",
"commit_sha": "b8d2e4a",
"committed_at": "2026-06-16T10:01:00Z",
"from": { "path": "docs/old.md" },
"to": { "path": "docs/new.md", "content": "# Hello" }
}
```
## Running
```sh
cargo run # uses config.toml in cwd
cargo run -- -c /etc/githttp-fs.toml
RUST_LOG=debug cargo run # overrides log_level in config
```
## Docker
Two-stage build: compiles in `rust:alpine` (static musl binary), runs in `alpine:3.22`.
```sh
docker build -t githttp-fs .
docker run -p 5355:5355 \
-v ./config.toml:/app/config.toml \
-v ./data:/app/data \
githttp-fs
```
## License
Mozilla Public License v2.0 (MPL v2.0) — Copyright 2026, Valerian Saliou.