gitmote

gitmote  ·  history  ·  branches

feat: project scaffold — health server, make gates, CI

c7da38371a3ca81bf169b4cc6ced5ce54da350b1
Atanas Minev <atmin@protonmail.com> · 2026-07-02 15:53

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..752bbe1
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,16 @@
+name: ci
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+
+jobs:
+ ci:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ - run: make all
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e660fd9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+bin/
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 0000000..2126bf7
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,17 @@
+version: "2"
+
+linters:
+ # The standard set: errcheck, govet, ineffassign, staticcheck, unused.
+ default: standard
+ settings:
+ errcheck:
+ exclude-functions:
+ # Writes to an http.ResponseWriter have nowhere to report errors.
+ - fmt.Fprintln(net/http.ResponseWriter)
+ # Deferred response-body closes in tests.
+ - (io.ReadCloser).Close
+
+formatters:
+ enable:
+ - gofmt
+ - goimports
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..1a13dc4
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,24 @@
+# Single source of truth for the gates: format → lint → test (CONTRIBUTING.md).
+# CI runs the same targets, so green locally = green in CI.
+
+GOLANGCI_LINT := go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
+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
+
+all: lint test build
+
+fmt:
+ gofmt -w .
+ $(GOIMPORTS) -w .
+
+lint:
+ $(GOLANGCI_LINT) run
+
+test:
+ go test ./...
+
+build:
+ go build -ldflags "-X main.version=$(VERSION)" -o bin/gitmote ./cmd/gitmote
diff --git a/cmd/gitmote/main.go b/cmd/gitmote/main.go
new file mode 100644
index 0000000..cd6fccc
--- /dev/null
+++ b/cmd/gitmote/main.go
@@ -0,0 +1,88 @@
+// Command gitmote is the gitmote server binary.
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+)
+
+// version is stamped at build time via -ldflags "-X main.version=...".
+var version = "dev"
+
+const shutdownTimeout = 10 * time.Second
+
+func main() {
+ addr := flag.String("addr", envOr("GITMOTE_ADDR", ":8080"), "listen address")
+ flag.Parse()
+
+ logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
+
+ if err := run(context.Background(), logger, *addr); err != nil {
+ logger.Error("server failed", "error", err)
+ os.Exit(1)
+ }
+}
+
+// run starts the HTTP server and blocks until ctx is cancelled or a
+// SIGINT/SIGTERM arrives, then shuts the server down gracefully.
+func run(ctx context.Context, logger *slog.Logger, addr string) error {
+ ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
+ defer stop()
+
+ srv := &http.Server{
+ Addr: addr,
+ Handler: newHandler(),
+ }
+
+ errCh := make(chan error, 1)
+ go func() {
+ logger.Info("listening", "addr", addr, "version", version)
+ errCh <- srv.ListenAndServe()
+ }()
+
+ select {
+ case err := <-errCh:
+ return err
+ case <-ctx.Done():
+ }
+
+ logger.Info("shutting down")
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
+ defer cancel()
+ if err := srv.Shutdown(shutdownCtx); err != nil {
+ return fmt.Errorf("shutdown: %w", err)
+ }
+ if err := <-errCh; !errors.Is(err, http.ErrServerClosed) {
+ return err
+ }
+ return nil
+}
+
+// newHandler returns the server's HTTP routes.
+func newHandler() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
+ fmt.Fprintln(w, "ok")
+ })
+ mux.HandleFunc("GET /version", func(w http.ResponseWriter, _ *http.Request) {
+ fmt.Fprintln(w, version)
+ })
+ return mux
+}
+
+// envOr returns the value of the environment variable key, or fallback if
+// it is unset or empty.
+func envOr(key, fallback string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return fallback
+}
diff --git a/cmd/gitmote/main_test.go b/cmd/gitmote/main_test.go
new file mode 100644
index 0000000..2a32306
--- /dev/null
+++ b/cmd/gitmote/main_test.go
@@ -0,0 +1,91 @@
+package main
+
+import (
+ "context"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestHealthz(t *testing.T) {
+ srv := httptest.NewServer(newHandler())
+ defer srv.Close()
+
+ resp, err := http.Get(srv.URL + "/healthz")
+ if err != nil {
+ t.Fatalf("GET /healthz: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("GET /healthz status = %d, want %d", resp.StatusCode, http.StatusOK)
+ }
+}
+
+func TestVersion(t *testing.T) {
+ srv := httptest.NewServer(newHandler())
+ defer srv.Close()
+
+ resp, err := http.Get(srv.URL + "/version")
+ if err != nil {
+ t.Fatalf("GET /version: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("GET /version status = %d, want %d", resp.StatusCode, http.StatusOK)
+ }
+}
+
+func TestUnknownRouteNotFound(t *testing.T) {
+ srv := httptest.NewServer(newHandler())
+ defer srv.Close()
+
+ resp, err := http.Get(srv.URL + "/nope")
+ if err != nil {
+ t.Fatalf("GET /nope: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("GET /nope status = %d, want %d", resp.StatusCode, http.StatusNotFound)
+ }
+}
+
+func TestRunShutsDownOnCancel(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ logger := slog.New(slog.DiscardHandler)
+
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- run(ctx, logger, "127.0.0.1:0")
+ }()
+
+ // Give the server a moment to start, then trigger shutdown.
+ time.Sleep(100 * time.Millisecond)
+ cancel()
+
+ select {
+ case err := <-errCh:
+ if err != nil {
+ t.Errorf("run returned %v, want nil", err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("run did not shut down after cancel")
+ }
+}
+
+func TestRunFailsOnBadAddr(t *testing.T) {
+ logger := slog.New(slog.DiscardHandler)
+
+ err := run(context.Background(), logger, "not-an-addr")
+ if err == nil {
+ t.Fatal("run returned nil, want listen error")
+ }
+ if !strings.Contains(err.Error(), "listen") {
+ t.Errorf("run error = %q, want a listen error", err)
+ }
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..a1f5e1e
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module github.com/atmin/gitmote
+
+go 1.26
diff --git a/scripts/pre-commit b/scripts/pre-commit
new file mode 100755
index 0000000..0895890
--- /dev/null
+++ b/scripts/pre-commit
@@ -0,0 +1,7 @@
+#!/bin/sh
+# Pre-commit hook: run the same gates as CI, in CONTRIBUTING.md order.
+# Install with: ln -sf ../../scripts/pre-commit .git/hooks/pre-commit
+set -e
+
+cd "$(git rev-parse --show-toplevel)"
+make fmt lint test
diff --git a/tasks/01-project-scaffold.md b/tasks/01-project-scaffold.md
deleted file mode 100644
index 26c1a95..0000000
--- a/tasks/01-project-scaffold.md
+++ /dev/null
@@ -1,32 +0,0 @@
-# 01 — Project scaffold & gates
-
-Depends on: nothing.
-
-## Spec
-
-Establish the build/test/lint gates promised in
-[CONTRIBUTING.md](../CONTRIBUTING.md) so every later task can land green, plus a
-minimal server binary that starts, reports version/health, and shuts down
-cleanly.
-
-## Current
-
-Docs only — no `go.mod`, no code, no Makefile.
-
-## Change
-
-- `go.mod` (module `github.com/atmin/gitmote`, Go pinned to a recent stable).
-- `cmd/gitmote/main.go` — env/flag config, structured logging, an HTTP server
- with `GET /healthz` and `GET /version`, graceful shutdown on SIGINT/SIGTERM.
-- `Makefile` — the single source of truth: `fmt` (gofmt/goimports), `lint`
- (golangci-lint, includes vet), `test` (`go test ./...`), `build`, and
- `all = lint test build`.
-- `.golangci.yml`; `.github/workflows/ci.yml` calling the same Make targets;
- `scripts/pre-commit` running `make fmt lint test`.
-
-## Verify
-
-- `make all` is green from a clean checkout; CI runs the Make targets and passes.
-- `go run ./cmd/gitmote` serves `200` on `/healthz` — asserted with an
- `httptest` unit test.
-- Non-breaking: this is the baseline; nothing to regress.
diff --git a/tasks/README.md b/tasks/README.md
index f9dd090..f718266 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -6,7 +6,6 @@ lands** (commits are the record — this list never becomes a changelog).
Ordered by dependency; each task lands in a non-breaking, tested state.
-- [01-project-scaffold.md](01-project-scaffold.md) — Go module, Makefile gates, CI, health server.
- [02-s3-object-store.md](02-s3-object-store.md) — `Store` interface + S3 and in-memory impls for objects/packs.
- [03-metadata-s3lite.md](03-metadata-s3lite.md) — Embed s3lite; schema, migrations, query layer, ref CAS.
- [04-repo-materialization.md](04-repo-materialization.md) — Build ephemeral bare repos from refs + hydrated objects.