diff --git a/.gitignore b/.gitignore
index e660fd9..b5109ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
bin/
+data/
diff --git a/Makefile b/Makefile
index cc4f49d..f3aaea5 100644
--- a/Makefile
+++ b/Makefile
@@ -6,7 +6,7 @@ GOIMPORTS := go run golang.org/x/tools/cmd/goimports@v0.47.0
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
-.PHONY: all fmt lint test build e2e-local e2e-restore
+.PHONY: all fmt lint test build dev dev-reset e2e-local e2e-restore
all: lint test build
@@ -22,6 +22,19 @@ test:
build:
go build -ldflags "-X main.version=$(VERSION)" -o bin/gitmote ./cmd/gitmote
+ go build -o bin/gitmote-hook ./cmd/gitmote-hook
+
+# Local dev: MinIO in a container (S3 :9100) + gitmote run natively on :8080,
+# with a bootstrapped admin/token/repo persisted under data/ across restarts.
+# First run prints the token and clone URL; Ctrl-C stops the server. See
+# scripts/dev.sh. Requires docker + docker compose.
+dev: build
+ ./scripts/dev.sh
+
+# Wipe all local dev state: the MinIO volume and the persisted DB/cache/token.
+dev-reset:
+ docker compose -p gitmote-dev -f docker-compose.dev.yml down -v 2>/dev/null || true
+ rm -rf data
# End-to-end: gitmote hosts itself against MinIO in Docker (CONTRIBUTING.md —
# integration tests drive real git). Requires docker + docker compose.
diff --git a/README.md b/README.md
index 55f8c7c..5b044b3 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,22 @@ production on Scaleway Object Storage at
[gitmote.atmin.net](https://gitmote.atmin.net). The design lives in
[docs/architecture/](docs/architecture/).
+## Develop locally
+
+`make dev` gives you a running instance in one command: it builds the binaries,
+starts MinIO in a container (S3 on :9100), runs gitmote **natively** on :8080,
+and bootstraps an admin/token/repo on the first run. State (the metadata DB,
+object cache, and the minted token) persists under `data/`, so the printed token
+keeps working across restarts:
+
+```sh
+make dev # first run prints the token, clone URL, and UI URL
+make dev-reset # wipe MinIO + data/ for a clean slate
+```
+
+Sign in to the UI at <http://localhost:8080/ui> by pasting the token, or
+clone/push straight away with the printed URL. Requires Docker + Docker Compose.
+
## Run it locally
`make e2e-local` brings up a gitmote container plus MinIO with
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
new file mode 100644
index 0000000..4a6a458
--- /dev/null
+++ b/docker-compose.dev.yml
@@ -0,0 +1,35 @@
+# Local dev stack: MinIO only, reachable from a natively-run `bin/gitmote`.
+# Driven by `make dev` (scripts/dev.sh). Unlike docker-compose.yml (the e2e
+# topology, where gitmote also runs in a container), here gitmote runs on the
+# host, so MinIO must publish its ports. They're 9100/9101 (not the usual
+# 9000/9001) to avoid colliding with any other local MinIO.
+name: gitmote-dev
+
+services:
+ minio:
+ image: minio/minio
+ command: server /data --console-address :9101
+ environment:
+ MINIO_ROOT_USER: minioadmin
+ MINIO_ROOT_PASSWORD: minioadmin
+ ports:
+ - "9100:9000" # S3 API → GITMOTE_S3_ENDPOINT=http://localhost:9100
+ - "9101:9101" # console → http://localhost:9101 (minioadmin/minioadmin)
+ volumes:
+ - minio-data:/data
+
+ # One-shot bucket creation; not started by `up` (setup profile), invoked with
+ # `docker compose run --rm mc`. MC_HOST_local configures the alias via env, so
+ # no interactive `mc alias set` is needed; it retries until MinIO is reachable.
+ mc:
+ image: minio/mc
+ profiles: ["setup"]
+ depends_on: [minio]
+ environment:
+ MC_HOST_local: http://minioadmin:minioadmin@minio:9000
+ entrypoint: ["/bin/sh", "-c"]
+ command:
+ - "until mc ls local >/dev/null 2>&1; do echo 'waiting for minio'; sleep 1; done; mc mb --ignore-existing local/gitmote"
+
+volumes:
+ minio-data:
diff --git a/scripts/dev.sh b/scripts/dev.sh
new file mode 100755
index 0000000..e6aee44
--- /dev/null
+++ b/scripts/dev.sh
@@ -0,0 +1,75 @@
+#!/usr/bin/env bash
+# Local dev loop: MinIO in a container + a natively-run gitmote, with a
+# bootstrapped admin/token/repo persisted under data/ across restarts.
+# Invoked by `make dev`; `make dev-reset` wipes the state it creates.
+set -euo pipefail
+
+ROOT="$(git rev-parse --show-toplevel)"
+cd "$ROOT"
+
+COMPOSE=(docker compose -p gitmote-dev -f docker-compose.dev.yml)
+TOKEN_FILE="$ROOT/data/dev-token"
+
+# All-local configuration — none of these are secret. The DB, cache, and socket
+# live under data/ (gitignored) so refs survive restarts; the token below is
+# minted against that same DB, so the two stay in sync.
+export GITMOTE_ADDR=":8080"
+export GITMOTE_S3_BUCKET="gitmote"
+export GITMOTE_S3_ENDPOINT="http://localhost:9100"
+export AWS_REGION="us-east-1"
+export AWS_ACCESS_KEY_ID="minioadmin"
+export AWS_SECRET_ACCESS_KEY="minioadmin"
+export GITMOTE_DB="$ROOT/data/meta.sqlite3"
+export GITMOTE_CACHE="$ROOT/data/cache"
+export GITMOTE_SOCK="$ROOT/data/gitmote.sock"
+export GITMOTE_HOOK="$ROOT/bin/gitmote-hook"
+export GITMOTE_COOKIE_KEY="dev-cookie-key-not-for-production"
+
+mkdir -p "$ROOT/data"
+
+# Free :8080 from a stale dev server left running from a previous session.
+if pid="$(lsof -nP -tiTCP:8080 -sTCP:LISTEN 2>/dev/null)"; then
+ echo "--- stopping process on :8080 (pid $pid) ---"
+ kill $pid 2>/dev/null || true
+ for _ in $(seq 1 20); do
+ lsof -nP -tiTCP:8080 -sTCP:LISTEN >/dev/null 2>&1 || break
+ sleep 0.1
+ done
+fi
+
+echo "--- starting MinIO (S3 :9100, console :9101) ---"
+"${COMPOSE[@]}" up -d minio
+"${COMPOSE[@]}" run --rm mc
+
+# Bootstrap exactly once and persist the one-time token. The token file is the
+# marker for "dev is set up"; it and the DB are wiped together by dev-reset, so
+# they never drift.
+if [ ! -f "$TOKEN_FILE" ]; then
+ echo "--- bootstrapping admin/token/repo (first run) ---"
+ out="$(bin/gitmote bootstrap -handle atmin -repo atmin/gitmote -default-branch master)"
+ if printf '%s' "$out" | grep -q "already bootstrapped"; then
+ echo "ERROR: the metadata DB already has an admin but data/dev-token is missing." >&2
+ echo "The one-time token can't be recovered — run 'make dev-reset' then 'make dev'." >&2
+ exit 1
+ fi
+ token="$(printf '%s\n' "$out" | grep -oE 'gmt_[0-9a-f]+\.[0-9a-f]+' | head -1)"
+ [ -n "$token" ] || { printf '%s\n' "$out"; echo "ERROR: no token in bootstrap output" >&2; exit 1; }
+ printf '%s\n' "$token" >"$TOKEN_FILE"
+fi
+TOKEN="$(cat "$TOKEN_FILE")"
+
+cat <<EOF
+
+ gitmote dev is ready.
+
+ UI: http://localhost:8080/ui (sign in at /login by pasting the token)
+ token: $TOKEN
+ clone: git clone http://atmin:$TOKEN@localhost:8080/atmin/gitmote
+ push: git push http://atmin:$TOKEN@localhost:8080/atmin/gitmote HEAD:refs/heads/master
+
+ MinIO console: http://localhost:9101 (minioadmin / minioadmin)
+ Ctrl-C stops the server; MinIO keeps running. 'make dev-reset' wipes all state.
+
+EOF
+
+exec bin/gitmote