gitmote

gitmote  ·  history  ·  branches

feat: write path — receive-pack + pre-receive CAS; git push works

27b587f713b7c202175d67c3b5a30d7ba5d281af
Atanas Minev <atmin@protonmail.com> · 2026-07-02 18:50

diff --git a/cmd/gitmote-hook/main.go b/cmd/gitmote-hook/main.go
new file mode 100644
index 0000000..851e945
--- /dev/null
+++ b/cmd/gitmote-hook/main.go
@@ -0,0 +1,78 @@
+// Command gitmote-hook is git's pre-receive hook for gitmote. receive-pack runs
+// it (via core.hooksPath) as a child process; it forwards the ref commands and
+// the quarantine path to the parent over a unix socket and turns the parent's
+// verdict into its exit code. It fails closed: any error rejects the push.
+package main
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/atmin/gitmote/internal/hookrpc"
+)
+
+func main() {
+ if err := run(os.Stdin); err != nil {
+ fmt.Fprintln(os.Stderr, "gitmote:", err)
+ os.Exit(1)
+ }
+}
+
+func run(stdin io.Reader) error {
+ sock := os.Getenv(hookrpc.EnvSock)
+ nonce := os.Getenv(hookrpc.EnvNonce)
+ if sock == "" || nonce == "" {
+ return fmt.Errorf("hook invoked outside a gitmote push (%s/%s unset)", hookrpc.EnvSock, hookrpc.EnvNonce)
+ }
+
+ // receive-pack points GIT_OBJECT_DIRECTORY at the quarantine while the hook
+ // runs, so it names exactly the newly pushed objects. Resolve it against the
+ // hook's cwd (the bare repo) to an absolute path for the parent.
+ quarantine := os.Getenv("GIT_OBJECT_DIRECTORY")
+ if quarantine == "" {
+ return fmt.Errorf("GIT_OBJECT_DIRECTORY unset")
+ }
+ quarantine, err := filepath.Abs(quarantine)
+ if err != nil {
+ return err
+ }
+
+ commands, err := parseCommands(stdin)
+ if err != nil {
+ return err
+ }
+
+ resp, err := hookrpc.Call(sock, hookrpc.Request{
+ Nonce: nonce,
+ Commands: commands,
+ QuarantinePath: quarantine,
+ })
+ if err != nil {
+ return err
+ }
+ if !resp.OK {
+ return fmt.Errorf("push rejected: %s", resp.Reason)
+ }
+ return nil
+}
+
+// parseCommands reads the `<old> <new> <ref>` lines git feeds a pre-receive hook.
+func parseCommands(r io.Reader) ([]hookrpc.RefCommand, error) {
+ var commands []hookrpc.RefCommand
+ sc := bufio.NewScanner(r)
+ for sc.Scan() {
+ fields := strings.Fields(sc.Text())
+ if len(fields) != 3 {
+ return nil, fmt.Errorf("malformed ref command %q", sc.Text())
+ }
+ commands = append(commands, hookrpc.RefCommand{Old: fields[0], New: fields[1], Ref: fields[2]})
+ }
+ if err := sc.Err(); err != nil {
+ return nil, err
+ }
+ return commands, nil
+}
diff --git a/cmd/gitmote/main.go b/cmd/gitmote/main.go
index d66e9c5..22bc9b5 100644
--- a/cmd/gitmote/main.go
+++ b/cmd/gitmote/main.go
@@ -100,13 +100,16 @@ func newHandler(gitHandler http.Handler) http.Handler {
return mux
}
-// buildGitHandler wires the git read path (task 05) when an object store is
+// buildGitHandler wires the git read + write paths when an object store is
// configured (GITMOTE_S3_BUCKET). Without it, the server runs health/version
// only — a dev convenience — and the returned close func is a no-op.
//
// Metadata lives at GITMOTE_DB (default ./gitmote.sqlite3); materialized repos
-// cache under GITMOTE_CACHE (default a temp dir). Litestream replication of the
-// metadata DB is wired in the deploy task (11); it is local-only here.
+// cache under GITMOTE_CACHE (default a temp dir). The push path listens on
+// GITMOTE_SOCK (default a temp path) and installs the pre-receive hook binary
+// at GITMOTE_HOOK (default gitmote-hook alongside this binary). Litestream
+// replication of the metadata DB is wired in the deploy task (11); it is
+// local-only here.
func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, func() error, error) {
noop := func() error { return nil }
@@ -126,13 +129,44 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, fu
return nil, noop, fmt.Errorf("metadata: %w", err)
}
- cacheRoot := envOr("GITMOTE_CACHE", filepath.Join(os.TempDir(), "gitmote-repos"))
- handler, err := githttp.New(repo.New(md, objs, cacheRoot), auth.NewGuard(md), logger)
+ sockPath := envOr("GITMOTE_SOCK", filepath.Join(os.TempDir(), "gitmote.sock"))
+ writer, err := githttp.NewWriter(md, objs, hookBinaryPath(), sockPath, logger)
if err != nil {
_ = md.Close()
+ return nil, noop, fmt.Errorf("write path: %w", err)
+ }
+ cleanup := func() error {
+ err := writer.Close()
+ if cerr := md.Close(); err == nil {
+ err = cerr
+ }
+ return err
+ }
+
+ cacheRoot := envOr("GITMOTE_CACHE", filepath.Join(os.TempDir(), "gitmote-repos"))
+ handler, err := githttp.New(githttp.Config{
+ Materializer: repo.New(md, objs, cacheRoot),
+ Authorizer: auth.NewGuard(md),
+ Writer: writer,
+ Logger: logger,
+ })
+ if err != nil {
+ _ = cleanup()
return nil, noop, err
}
- return handler, md.Close, nil
+ return handler, cleanup, nil
+}
+
+// hookBinaryPath resolves the pre-receive hook executable: GITMOTE_HOOK if set,
+// otherwise gitmote-hook next to this binary.
+func hookBinaryPath() string {
+ if p := os.Getenv("GITMOTE_HOOK"); p != "" {
+ return p
+ }
+ if exe, err := os.Executable(); err == nil {
+ return filepath.Join(filepath.Dir(exe), "gitmote-hook")
+ }
+ return "gitmote-hook"
}
// envOr returns the value of the environment variable key, or fallback if
diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md
index 2b41f0c..a346102 100644
--- a/docs/architecture/open-questions.md
+++ b/docs/architecture/open-questions.md
@@ -5,9 +5,6 @@
Items with worked-out context live as one-concern notes under
[`../notes/`](../notes/); the rest stay one-liners until they earn a note.
-- **The push hook channel.** How the `pre-receive` hook discovers and
- authenticates to the parent's socket, and how it is installed into ephemeral
- repos — [../notes/push-hook-channel.md](../notes/push-hook-channel.md).
- **Object hydration for reads.** `upload-pack` walks a _local_ object store, so
the closure must be present before it runs ("on demand" doesn't apply at the
git layer); full-hydrate vs partial-clone/promisor —
diff --git a/docs/architecture/request-flows.md b/docs/architecture/request-flows.md
index ac9fe1e..3881888 100644
--- a/docs/architecture/request-flows.md
+++ b/docs/architecture/request-flows.md
@@ -69,7 +69,16 @@ sequenceDiagram
embedded in the parent. Two processes writing it is the one thing s3lite
forbids. So the hook RPCs back to the parent over a unix socket; the parent,
the sole writer, performs the PUT + CAS and returns a verdict. (This is exactly
- how GitLab/Gitaly wires its git hooks back to the app.)
+ how GitLab/Gitaly wires its git hooks back to the app.) The channel:
+ - **Installation** — one hook binary (`gitmote-hook`), referenced by
+ `core.hooksPath` (set per spawned `receive-pack` via `GIT_CONFIG_*`), so
+ every materialized repo inherits it with nothing to install per repo.
+ - **Discovery + auth** — the parent exports the socket path (`GITMOTE_SOCK`)
+ and a **single-use nonce** (`GITMOTE_NONCE`) into `receive-pack`'s
+ environment. The nonce is minted per push, bound to the locked operation,
+ and burned on use, so a stray local process can't forge a ref update.
+ - **Fail closed** — any hook-side error (missing env, dial failure, bad
+ verdict) rejects the push.
- **One SQL transaction = atomic multi-ref push.** All per-ref CAS run inside a
single s3lite transaction; all-or-nothing matches `git push --atomic`, and is
_stronger_ than loose-file refs.
diff --git a/docs/notes/README.md b/docs/notes/README.md
index d0907bc..c1fd8a1 100644
--- a/docs/notes/README.md
+++ b/docs/notes/README.md
@@ -6,8 +6,6 @@ future task or decision. The architecture's
[open questions](../architecture/open-questions.md) are the superset and link
here.
-- [push-hook-channel.md](push-hook-channel.md) — how the `pre-receive` hook
- finds, authenticates to, and is installed for the parent's unix socket.
- [object-hydration.md](object-hydration.md) — getting the object closure onto
local disk before git runs; full-hydrate vs partial-clone.
- [bootstrap.md](bootstrap.md) — first admin, token, repo, and schema migration
diff --git a/docs/notes/push-hook-channel.md b/docs/notes/push-hook-channel.md
deleted file mode 100644
index 8dcdb53..0000000
--- a/docs/notes/push-hook-channel.md
+++ /dev/null
@@ -1,52 +0,0 @@
-# The push hook channel
-
-The control channel between git's `pre-receive` hook and the gitmote parent
-process — plus how the hook gets wired into materialized repos.
-
-**Context.** Push durability gates _inside_ `receive-pack`, at the `pre-receive`
-hook (see [architecture → push write path](../architecture/request-flows.md)). The
-hook runs as a separate process and cannot touch the embedded single-writer
-s3lite, so it RPCs back to the parent — the sole writer — which performs the S3
-PUT + ref CAS and returns a verdict the hook turns into its exit code.
-
-Three things that channel needs, none yet pinned down.
-
-## 1. Socket discovery
-
-How the hook finds the parent's unix socket.
-
-- **Leaning:** the parent exports the socket path into `receive-pack`'s
- environment; the hook reads `$GITMOTE_SOCK`. (A fixed well-known path works
- too, but env is explicit and testable.)
-
-## 2. Authentication
-
-A random local process must not be able to forge a ref update.
-
-- **Leaning:** the parent mints a **single-use nonce** per push, bound to the
- in-flight operation (repo, user, lock holder), and passes it into
- `receive-pack`'s environment. The hook presents the nonce on the socket; the
- parent validates it against the live push and burns it. It expires with the
- push.
-
-## 3. Hook installation
-
-Ephemeral / materialized repos must have the hook present without reinstalling
-per repo.
-
-- **Leaning:** set git's `core.hooksPath` (container-global config) to a fixed
- path holding the gitmote hook binary; every materialized repo inherits it. The
- binary is tiny — a socket client that forwards stdin + `$GIT_QUARANTINE_PATH` +
- the nonce and relays the verdict.
-
-## Payload sketch
-
-- Hook → parent: `{ nonce, repo, ref_commands (from stdin), quarantine_path }`.
-- Parent → hook: `ok` (hook exits 0) or `reject: <reason>` (hook exits ≠ 0,
- reason to stderr → surfaced to the client).
-
-## Open sub-points
-
-- Socket lifecycle — one per container, created at boot.
-- Exactly what binds the nonce to a push (the per-repo lock token? a request id?).
-- Timeout / parent-crash handling on the hook side — fail closed (reject).
diff --git a/internal/githttp/backend.go b/internal/githttp/backend.go
index 205232a..edef194 100644
--- a/internal/githttp/backend.go
+++ b/internal/githttp/backend.go
@@ -1,9 +1,14 @@
-// Package githttp serves the git smart-HTTP read path (clone / fetch). It
-// materializes the target repo (refs from s3lite, objects hydrated from the
-// object store) and then delegates all protocol work — ref advertisement and
-// pack negotiation — to stock `git http-backend` over CGI, per the read path
-// in docs/architecture/request-flows.md. Only the upload-pack (read) service is
-// routed here; the write path (receive-pack) lands in task 07.
+// Package githttp serves git smart-HTTP. It materializes the target repo (refs
+// from s3lite, objects hydrated from the object store) and delegates protocol
+// work — ref advertisement, pack negotiation, receive-pack — to stock
+// `git http-backend` over CGI, per the request flows in
+// docs/architecture/request-flows.md.
+//
+// The read path (clone / fetch, upload-pack) needs only a materialized repo.
+// The write path (push, receive-pack) additionally serializes per repo, mints a
+// per-push nonce, and installs the pre-receive hook that RPCs the parent to
+// enforce the content-before-pointer CAS; that machinery lives in the Writer
+// (receive.go) and is engaged only when the handler is built with one.
package githttp
import (
@@ -27,79 +32,151 @@ type Authorizer interface {
Authorize(r *http.Request, repoName string, perm meta.Perm) (*meta.User, error)
}
-// Handler serves the read-path smart-HTTP endpoints for any repo, dispatching
-// on the git URL suffix. Mount it at "/"; more specific routes (e.g. /healthz)
-// still win under http.ServeMux.
+// Config assembles a Handler.
+type Config struct {
+ // Materializer builds the on-disk repo git-http-backend serves. Required.
+ Materializer *repo.Materializer
+ // Authorizer guards every request. Required.
+ Authorizer Authorizer
+ // Writer enables the push path. Nil serves the read path only; write
+ // endpoints then 404.
+ Writer *Writer
+ // Logger is optional; nil discards.
+ Logger *slog.Logger
+}
+
+// Handler serves the git smart-HTTP endpoints for any repo, dispatching on the
+// git URL suffix. Mount it at "/"; more specific routes (e.g. /healthz) still
+// win under http.ServeMux.
type Handler struct {
mz *repo.Materializer
authz Authorizer
+ writer *Writer
gitPath string
logger *slog.Logger
}
-// New returns a read-path handler backed by mz and guarded by authz. It fails
-// if the `git` executable is not on PATH, since the whole design delegates to
-// it.
-func New(mz *repo.Materializer, authz Authorizer, logger *slog.Logger) (*Handler, error) {
+// New returns a handler. It fails if the `git` executable is not on PATH, since
+// the whole design delegates to it.
+func New(cfg Config) (*Handler, error) {
gitPath, err := exec.LookPath("git")
if err != nil {
return nil, fmt.Errorf("githttp: git not found on PATH: %w", err)
}
+ logger := cfg.Logger
if logger == nil {
logger = slog.New(slog.DiscardHandler)
}
- return &Handler{mz: mz, authz: authz, gitPath: gitPath, logger: logger}, nil
+ return &Handler{
+ mz: cfg.Materializer,
+ authz: cfg.Authorizer,
+ writer: cfg.Writer,
+ gitPath: gitPath,
+ logger: logger,
+ }, nil
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- repoName, endpoint, ok := parseReadPath(r.URL.Path)
+ repoName, endpoint, ok := parseGitPath(r.URL.Path)
if !ok {
http.NotFound(w, r)
return
}
- // Serve only the upload-pack (read) service. receive-pack — and a
- // receive-pack advertisement — are not part of the read path.
- switch endpoint {
- case "info/refs":
- if r.Method != http.MethodGet || r.URL.Query().Get("service") != "git-upload-pack" {
- http.NotFound(w, r)
- return
- }
- case "git-upload-pack":
- if r.Method != http.MethodPost {
+ perm, isReceive, isPush, ok := classify(endpoint, r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ // Writes are served only when a Writer is configured.
+ if isReceive && h.writer == nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ if _, err := h.authz.Authorize(r, repoName, perm); err != nil {
+ h.writeAuthError(w, r, repoName, err)
+ return
+ }
+
+ if isPush {
+ h.serveReceivePack(w, r, repoName)
+ return
+ }
+
+ // Read, or a receive-pack advertisement: materialize then hand off to
+ // http-backend. The receive advertisement needs http.receivepack enabled.
+ if !h.materialize(w, r, repoName) {
+ return
+ }
+ var extra []string
+ if isReceive {
+ extra = gitConfigEnv([2]string{"http.receivepack", "true"})
+ }
+ h.serveCGI(w, r, extra)
+}
+
+// serveReceivePack runs a push under the per-repo write lock: refresh the repo
+// from the sources of truth, mint a nonce, and hand off to receive-pack with the
+// pre-receive hook wired to call back on the socket. The hook's callback does
+// the object PUT + ref CAS (see Writer.handle).
+func (h *Handler) serveReceivePack(w http.ResponseWriter, r *http.Request, repoName string) {
+ push, err := h.writer.Begin(r.Context(), repoName)
+ if err != nil {
+ if errors.Is(err, meta.ErrNotFound) {
http.NotFound(w, r)
return
}
+ h.logger.Error("begin push failed", "repo", repoName, "error", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
}
+ defer push.Release()
- // Authorize the read before doing any work. receive-pack is not served
- // here, so read is the only permission this handler ever requires.
- if _, err := h.authz.Authorize(r, repoName, meta.PermRead); err != nil {
- h.writeAuthError(w, r, repoName, err)
+ // Materialize under the lock so receive-pack's fast-forward check runs
+ // against the authoritative tip.
+ if !h.materialize(w, r, repoName) {
return
}
+ extra := append(
+ gitConfigEnv(
+ [2]string{"http.receivepack", "true"},
+ [2]string{"core.hooksPath", h.writer.HooksPath()},
+ ),
+ "GITMOTE_SOCK="+h.writer.SockPath(),
+ "GITMOTE_NONCE="+push.Nonce,
+ )
+ h.serveCGI(w, r, extra)
+}
+
+// materialize builds the on-disk repo, writing the HTTP error itself on failure
+// and returning false so the caller stops.
+func (h *Handler) materialize(w http.ResponseWriter, r *http.Request, repoName string) bool {
if _, err := h.mz.Materialize(r.Context(), repoName); err != nil {
if errors.Is(err, meta.ErrNotFound) {
http.NotFound(w, r)
- return
+ return false
}
h.logger.Error("materialize failed", "repo", repoName, "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
- return
+ return false
}
+ return true
+}
- // Delegate to git-http-backend. PATH_INFO carries the full request path
- // (empty CGI Root) and GIT_PROJECT_ROOT is the cache root, so http-backend
- // resolves the repo dir the materializer just wrote.
+// serveCGI delegates to git-http-backend. PATH_INFO carries the full request
+// path (empty CGI Root) and GIT_PROJECT_ROOT is the cache root, so http-backend
+// resolves the repo dir the materializer just wrote.
+func (h *Handler) serveCGI(w http.ResponseWriter, r *http.Request, extraEnv []string) {
+ env := append([]string{
+ "GIT_PROJECT_ROOT=" + h.mz.Root(),
+ "GIT_HTTP_EXPORT_ALL=1",
+ }, extraEnv...)
backend := &cgi.Handler{
Path: h.gitPath,
Args: []string{"http-backend"},
Dir: h.mz.Root(),
- Env: []string{
- "GIT_PROJECT_ROOT=" + h.mz.Root(),
- "GIT_HTTP_EXPORT_ALL=1",
- },
+ Env: env,
}
backend.ServeHTTP(w, r)
}
@@ -122,12 +199,44 @@ func (h *Handler) writeAuthError(w http.ResponseWriter, r *http.Request, repoNam
}
}
-// parseReadPath splits a git smart-HTTP read URL into the repo name and the
-// endpoint suffix ("info/refs" or "git-upload-pack"). The repo name — which may
-// contain slashes ("atmin/dotfiles") — must be non-empty.
-func parseReadPath(urlPath string) (repoName, endpoint string, ok bool) {
+// classify determines, for a parsed endpoint and request, the required
+// permission and whether it targets receive-pack (isReceive) and mutates
+// (isPush). ok=false rejects a wrong method or unknown service.
+func classify(endpoint string, r *http.Request) (perm meta.Perm, isReceive, isPush, ok bool) {
+ switch endpoint {
+ case "info/refs":
+ if r.Method != http.MethodGet {
+ return "", false, false, false
+ }
+ switch r.URL.Query().Get("service") {
+ case "git-upload-pack":
+ return meta.PermRead, false, false, true
+ case "git-receive-pack":
+ return meta.PermWrite, true, false, true
+ default:
+ return "", false, false, false
+ }
+ case "git-upload-pack":
+ if r.Method != http.MethodPost {
+ return "", false, false, false
+ }
+ return meta.PermRead, false, false, true
+ case "git-receive-pack":
+ if r.Method != http.MethodPost {
+ return "", false, false, false
+ }
+ return meta.PermWrite, true, true, true
+ default:
+ return "", false, false, false
+ }
+}
+
+// parseGitPath splits a git smart-HTTP URL into the repo name and the endpoint
+// suffix ("info/refs", "git-upload-pack", or "git-receive-pack"). The repo name
+// — which may contain slashes ("atmin/dotfiles") — must be non-empty.
+func parseGitPath(urlPath string) (repoName, endpoint string, ok bool) {
p := strings.TrimPrefix(urlPath, "/")
- for _, ep := range []string{"info/refs", "git-upload-pack"} {
+ for _, ep := range []string{"info/refs", "git-upload-pack", "git-receive-pack"} {
if suffix := "/" + ep; strings.HasSuffix(p, suffix) {
name := strings.TrimSuffix(p, suffix)
if name == "" {
@@ -138,3 +247,17 @@ func parseReadPath(urlPath string) (repoName, endpoint string, ok bool) {
}
return "", "", false
}
+
+// gitConfigEnv renders config overrides as the GIT_CONFIG_COUNT/KEY/VALUE
+// environment git honors in every process, so they reach the spawned
+// receive-pack (and its hooks).
+func gitConfigEnv(pairs ...[2]string) []string {
+ env := []string{fmt.Sprintf("GIT_CONFIG_COUNT=%d", len(pairs))}
+ for i, p := range pairs {
+ env = append(env,
+ fmt.Sprintf("GIT_CONFIG_KEY_%d=%s", i, p[0]),
+ fmt.Sprintf("GIT_CONFIG_VALUE_%d=%s", i, p[1]),
+ )
+ }
+ return env
+}
diff --git a/internal/githttp/backend_test.go b/internal/githttp/backend_test.go
index 0e0f9ce..3e6900b 100644
--- a/internal/githttp/backend_test.go
+++ b/internal/githttp/backend_test.go
@@ -76,7 +76,11 @@ func newServer(t *testing.T) (*httptest.Server, *meta.Metadata, store.Store) {
t.Cleanup(func() { _ = m.Close() })
s := store.NewMem()
- h, err := New(repo.New(m, s, t.TempDir()), auth.NewGuard(m), slog.New(slog.DiscardHandler))
+ h, err := New(Config{
+ Materializer: repo.New(m, s, t.TempDir()),
+ Authorizer: auth.NewGuard(m),
+ Logger: slog.New(slog.DiscardHandler),
+ })
if err != nil {
t.Fatalf("githttp.New: %v", err)
}
@@ -258,7 +262,7 @@ func TestReceivePackNotServed(t *testing.T) {
}
}
-func TestParseReadPath(t *testing.T) {
+func TestParseGitPath(t *testing.T) {
cases := []struct {
path string
wantRepo string
@@ -267,6 +271,7 @@ func TestParseReadPath(t *testing.T) {
}{
{"/atmin/dotfiles/info/refs", "atmin/dotfiles", "info/refs", true},
{"/atmin/dotfiles/git-upload-pack", "atmin/dotfiles", "git-upload-pack", true},
+ {"/atmin/dotfiles/git-receive-pack", "atmin/dotfiles", "git-receive-pack", true},
{"/one/info/refs", "one", "info/refs", true},
{"/info/refs", "", "", false}, // no repo
{"/git-upload-pack", "", "", false}, // no repo
@@ -274,9 +279,9 @@ func TestParseReadPath(t *testing.T) {
{"/", "", "", false},
}
for _, c := range cases {
- gotRepo, gotEndpoint, ok := parseReadPath(c.path)
+ gotRepo, gotEndpoint, ok := parseGitPath(c.path)
if gotRepo != c.wantRepo || gotEndpoint != c.wantEndpoint || ok != c.wantOK {
- t.Errorf("parseReadPath(%q) = (%q, %q, %v), want (%q, %q, %v)",
+ t.Errorf("parseGitPath(%q) = (%q, %q, %v), want (%q, %q, %v)",
c.path, gotRepo, gotEndpoint, ok, c.wantRepo, c.wantEndpoint, c.wantOK)
}
}
diff --git a/internal/githttp/receive.go b/internal/githttp/receive.go
new file mode 100644
index 0000000..b71439e
--- /dev/null
+++ b/internal/githttp/receive.go
@@ -0,0 +1,292 @@
+package githttp
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/atmin/gitmote/internal/hookrpc"
+ "github.com/atmin/gitmote/internal/meta"
+ "github.com/atmin/gitmote/internal/store"
+)
+
+// casTimeout bounds the parent's object PUT + ref CAS for one push.
+const casTimeout = 2 * time.Minute
+
+// Writer is the write-path coordinator, shared by all repos. It owns the unix
+// socket the pre-receive hook calls back on, the per-repo write locks that
+// serialize pushes, and the single-use nonces that bind each hook call to its
+// in-flight push. On a hook call it enforces the safety-critical ordering —
+// objects durable in S3 first, ref CAS in s3lite second (safety.md §3).
+type Writer struct {
+ meta *meta.Metadata
+ store store.Store
+ logger *slog.Logger
+
+ hookDir string // core.hooksPath: holds the pre-receive executable
+ sockPath string
+ listener net.Listener
+
+ locksMu sync.Mutex
+ locks map[string]*sync.Mutex
+
+ pushMu sync.Mutex
+ pushes map[string]pushOp // nonce → in-flight push
+
+ // beforeCAS, when set, runs after the object PUT and before the ref CAS. It
+ // is a test seam for the ordering invariant; a non-nil error rejects the
+ // push after the objects are already durable.
+ beforeCAS func() error
+}
+
+// pushOp is the parent-side context a nonce resolves to.
+type pushOp struct {
+ repoID int64
+ repoName string
+}
+
+// NewWriter starts the write coordinator: it installs the pre-receive hook
+// (a symlink to hookBinary under a private hooks dir) and begins listening on
+// sockPath. Call Close to stop the listener and remove the socket.
+func NewWriter(md *meta.Metadata, s store.Store, hookBinary, sockPath string, logger *slog.Logger) (*Writer, error) {
+ if logger == nil {
+ logger = slog.New(slog.DiscardHandler)
+ }
+
+ hookDir, err := installHook(hookBinary)
+ if err != nil {
+ return nil, err
+ }
+
+ // A stale socket from a crashed predecessor would block Listen.
+ _ = os.Remove(sockPath)
+ if err := os.MkdirAll(filepath.Dir(sockPath), 0o755); err != nil {
+ return nil, err
+ }
+ l, err := net.Listen("unix", sockPath)
+ if err != nil {
+ return nil, fmt.Errorf("githttp: listen %s: %w", sockPath, err)
+ }
+
+ w := &Writer{
+ meta: md,
+ store: s,
+ logger: logger,
+ hookDir: hookDir,
+ sockPath: sockPath,
+ listener: l,
+ locks: make(map[string]*sync.Mutex),
+ pushes: make(map[string]pushOp),
+ }
+ go hookrpc.Serve(l, w.handle)
+ return w, nil
+}
+
+// Close stops the socket listener and removes the socket file.
+func (w *Writer) Close() error {
+ err := w.listener.Close()
+ _ = os.Remove(w.sockPath)
+ return err
+}
+
+// HooksPath is the core.hooksPath value for spawned receive-pack processes.
+func (w *Writer) HooksPath() string { return w.hookDir }
+
+// SockPath is the unix socket the hook dials.
+func (w *Writer) SockPath() string { return w.sockPath }
+
+// Push is an in-flight push: its single-use Nonce goes to receive-pack's hook,
+// and Release drops the per-repo lock and burns the nonce.
+type Push struct {
+ Nonce string
+ release func()
+}
+
+// Release ends the push, unlocking the repo and discarding the nonce.
+func (p *Push) Release() { p.release() }
+
+// Begin starts a push: it resolves the repo, takes the per-repo write lock (so
+// pushes to one repo serialize), and mints a nonce bound to this operation. The
+// caller must Release the returned Push. Begin returns meta.ErrNotFound for an
+// unknown repo.
+func (w *Writer) Begin(ctx context.Context, repoName string) (*Push, error) {
+ repo, err := w.meta.GetRepo(ctx, repoName)
+ if err != nil {
+ return nil, err
+ }
+ lk := w.lockRepo(repoName)
+ lk.Lock()
+ nonce, err := w.register(pushOp{repoID: repo.ID, repoName: repoName})
+ if err != nil {
+ lk.Unlock()
+ return nil, err
+ }
+ return &Push{
+ Nonce: nonce,
+ release: func() {
+ w.unregister(nonce)
+ lk.Unlock()
+ },
+ }, nil
+}
+
+// lockRepo returns the per-repo write mutex, creating it on first use.
+func (w *Writer) lockRepo(repoName string) *sync.Mutex {
+ w.locksMu.Lock()
+ defer w.locksMu.Unlock()
+ m := w.locks[repoName]
+ if m == nil {
+ m = &sync.Mutex{}
+ w.locks[repoName] = m
+ }
+ return m
+}
+
+// register mints a single-use nonce for an in-flight push and records it.
+func (w *Writer) register(op pushOp) (string, error) {
+ var b [16]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ return "", err
+ }
+ nonce := hex.EncodeToString(b[:])
+ w.pushMu.Lock()
+ w.pushes[nonce] = op
+ w.pushMu.Unlock()
+ return nonce, nil
+}
+
+// unregister removes a nonce (deferred cleanup after the push completes).
+func (w *Writer) unregister(nonce string) {
+ w.pushMu.Lock()
+ delete(w.pushes, nonce)
+ w.pushMu.Unlock()
+}
+
+// take looks up and burns a nonce, so a nonce is valid for exactly one call.
+func (w *Writer) take(nonce string) (pushOp, bool) {
+ w.pushMu.Lock()
+ defer w.pushMu.Unlock()
+ op, ok := w.pushes[nonce]
+ if ok {
+ delete(w.pushes, nonce)
+ }
+ return op, ok
+}
+
+// handle is the socket handler: it authenticates the nonce, PUTs the quarantined
+// objects to the store (content before pointer), then runs the per-ref CAS in a
+// single transaction. Any failure rejects the push, leaving at worst orphan
+// objects in the store — never a ref pointing at a missing object.
+func (w *Writer) handle(req hookrpc.Request) hookrpc.Response {
+ op, ok := w.take(req.Nonce)
+ if !ok {
+ return hookrpc.Response{Reason: "invalid or expired nonce"}
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), casTimeout)
+ defer cancel()
+
+ // 1. Content before pointer: make the pushed objects durable first.
+ if err := w.putObjects(ctx, op.repoName, req.QuarantinePath); err != nil {
+ w.logger.Error("object put failed", "repo", op.repoName, "error", err)
+ return hookrpc.Response{Reason: "object upload failed"}
+ }
+
+ if w.beforeCAS != nil {
+ if err := w.beforeCAS(); err != nil {
+ return hookrpc.Response{Reason: err.Error()}
+ }
+ }
+
+ // 2. The ref CAS — the linearization point. One transaction for all refs,
+ // so a multi-ref push is all-or-nothing.
+ updates := make([]meta.RefUpdate, len(req.Commands))
+ for i, c := range req.Commands {
+ updates[i] = meta.RefUpdate{Name: c.Ref, Old: c.Old, New: c.New}
+ }
+ if err := w.meta.CASRefs(ctx, op.repoID, updates); err != nil {
+ var mm *meta.CASMismatchError
+ if errors.As(err, &mm) {
+ return hookrpc.Response{Reason: "non-fast-forward (ref moved concurrently): " + mm.Error()}
+ }
+ w.logger.Error("ref cas failed", "repo", op.repoName, "error", err)
+ return hookrpc.Response{Reason: "ref update failed"}
+ }
+ return hookrpc.Response{OK: true}
+}
+
+// putObjects mirrors the quarantine's git objects into the store under the
+// repo's prefix. Only true object entries (fan-out dirs and pack/) are copied,
+// never bookkeeping like objects/info/alternates.
+func (w *Writer) putObjects(ctx context.Context, repoName, quarantine string) error {
+ return filepath.WalkDir(quarantine, func(p string, d os.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return err
+ }
+ rel, err := filepath.Rel(quarantine, p)
+ if err != nil {
+ return err
+ }
+ rel = filepath.ToSlash(rel)
+ if !isObjectPath(rel) {
+ return nil
+ }
+ f, err := os.Open(p)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = f.Close() }()
+ return w.store.Put(ctx, repoName+"/objects/"+rel, f)
+ })
+}
+
+// isObjectPath reports whether a quarantine-relative path names a git object: a
+// packfile/index under pack/, or a loose object in a two-hex fan-out dir.
+func isObjectPath(rel string) bool {
+ head, _, ok := strings.Cut(rel, "/")
+ if !ok {
+ return false
+ }
+ if head == "pack" {
+ return true
+ }
+ if len(head) != 2 {
+ return false
+ }
+ for _, r := range head {
+ if !strings.ContainsRune("0123456789abcdef", r) {
+ return false
+ }
+ }
+ return true
+}
+
+// installHook creates a private hooks directory containing a `pre-receive`
+// symlink to hookBinary, suitable for core.hooksPath.
+func installHook(hookBinary string) (string, error) {
+ abs, err := filepath.Abs(hookBinary)
+ if err != nil {
+ return "", err
+ }
+ if _, err := os.Stat(abs); err != nil {
+ return "", fmt.Errorf("githttp: hook binary %s: %w", abs, err)
+ }
+ dir, err := os.MkdirTemp("", "gitmote-hooks-")
+ if err != nil {
+ return "", err
+ }
+ link := filepath.Join(dir, "pre-receive")
+ if err := os.Symlink(abs, link); err != nil {
+ return "", err
+ }
+ return dir, nil
+}
diff --git a/internal/githttp/receive_test.go b/internal/githttp/receive_test.go
new file mode 100644
index 0000000..16dcdd5
--- /dev/null
+++ b/internal/githttp/receive_test.go
@@ -0,0 +1,348 @@
+package githttp
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sync"
+ "testing"
+
+ "github.com/atmin/gitmote/internal/auth"
+ "github.com/atmin/gitmote/internal/meta"
+ "github.com/atmin/gitmote/internal/repo"
+ "github.com/atmin/gitmote/internal/store"
+)
+
+var (
+ hookOnce sync.Once
+ hookPath string
+ hookErr error
+)
+
+// buildHook compiles the pre-receive hook binary once per test run and returns
+// its path.
+func buildHook(t *testing.T) string {
+ t.Helper()
+ hookOnce.Do(func() {
+ dir, err := os.MkdirTemp("", "gitmote-hookbin-")
+ if err != nil {
+ hookErr = err
+ return
+ }
+ out := filepath.Join(dir, "gitmote-hook")
+ cmd := exec.Command("go", "build", "-o", out, "github.com/atmin/gitmote/cmd/gitmote-hook")
+ if b, err := cmd.CombinedOutput(); err != nil {
+ hookErr = fmt.Errorf("build hook: %v\n%s", err, b)
+ return
+ }
+ hookPath = out
+ })
+ if hookErr != nil {
+ t.Fatal(hookErr)
+ }
+ return hookPath
+}
+
+func openMeta(t *testing.T) *meta.Metadata {
+ t.Helper()
+ m, err := meta.Open(context.Background(), meta.Config{
+ LocalPath: filepath.Join(t.TempDir(), "meta.sqlite3"),
+ })
+ if err != nil {
+ t.Fatalf("meta.Open: %v", err)
+ }
+ t.Cleanup(func() { _ = m.Close() })
+ return m
+}
+
+// newWriteServer wires meta + mem store + materializer + write coordinator
+// behind a guarded handler on an httptest server.
+func newWriteServer(t *testing.T) (*httptest.Server, *meta.Metadata, store.Store, *Writer) {
+ t.Helper()
+ m := openMeta(t)
+ s := store.NewMem()
+ // A short socket dir: unix socket paths are limited to ~104 bytes, and
+ // t.TempDir() embeds the (long) test name.
+ sockDir, err := os.MkdirTemp("", "gm")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.RemoveAll(sockDir) })
+ sock := filepath.Join(sockDir, "s.sock")
+ writer, err := NewWriter(m, s, buildHook(t), sock, slog.New(slog.DiscardHandler))
+ if err != nil {
+ t.Fatalf("NewWriter: %v", err)
+ }
+ t.Cleanup(func() { _ = writer.Close() })
+
+ h, err := New(Config{
+ Materializer: repo.New(m, s, t.TempDir()),
+ Authorizer: auth.NewGuard(m),
+ Writer: writer,
+ Logger: slog.New(slog.DiscardHandler),
+ })
+ if err != nil {
+ t.Fatalf("githttp.New: %v", err)
+ }
+ srv := httptest.NewServer(h)
+ t.Cleanup(srv.Close)
+ return srv, m, s, writer
+}
+
+// tryGit runs a git command hermetically and returns its combined output and
+// error without failing the test — for operations expected to be rejected.
+func tryGit(dir string, args ...string) (string, error) {
+ cmd := exec.CommandContext(context.Background(), "git", args...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com",
+ "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com",
+ "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
+ "GIT_TERMINAL_PROMPT=0",
+ )
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+}
+
+// initCommit makes a fresh source repo with one commit on main and returns its
+// dir and head sha.
+func initCommit(t *testing.T, content string) string {
+ t.Helper()
+ src := t.TempDir()
+ git(t, src, "init", "-b", "main", ".")
+ if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git(t, src, "add", ".")
+ git(t, src, "commit", "-m", "commit")
+ return src
+}
+
+// serverRefSHA returns the sha meta holds for a ref, or "" if absent.
+func serverRefSHA(t *testing.T, m *meta.Metadata, repoID int64, name string) string {
+ t.Helper()
+ refs, err := m.ListRefs(context.Background(), repoID)
+ if err != nil {
+ t.Fatalf("ListRefs: %v", err)
+ }
+ for _, r := range refs {
+ if r.Name == name {
+ return r.SHA
+ }
+ }
+ return ""
+}
+
+func TestPushThenClone(t *testing.T) {
+ ctx := context.Background()
+ srv, m, s, _ := newWriteServer(t)
+
+ r, err := m.CreateRepo(ctx, repoName, "main")
+ if err != nil {
+ t.Fatalf("CreateRepo: %v", err)
+ }
+ raw := mintUser(t, m, r.ID, "atmin", meta.PermWrite)
+ remote := authedURL(srv.URL, raw) + "/" + repoName
+
+ // Golden path: push a new repo's first commits.
+ src := initCommit(t, "hello\n")
+ head := git(t, src, "rev-parse", "HEAD")
+ git(t, src, "push", remote, "main")
+
+ // The ref advanced in s3lite and the objects are durable in the store.
+ if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != head {
+ t.Fatalf("server main = %q, want %q", got, head)
+ }
+ objs, _ := s.List(ctx, repoName+"/objects/")
+ if len(objs) == 0 {
+ t.Fatal("no objects uploaded to the store after push")
+ }
+
+ // A clone returns exactly what was pushed, and fsck is clean.
+ dst := filepath.Join(t.TempDir(), "clone")
+ git(t, "", "clone", remote, dst)
+ if got := git(t, dst, "rev-parse", "HEAD"); got != head {
+ t.Errorf("cloned HEAD = %q, want %q", got, head)
+ }
+ if data, _ := os.ReadFile(filepath.Join(dst, "file.txt")); string(data) != "hello\n" {
+ t.Errorf("cloned file = %q, want \"hello\\n\"", data)
+ }
+ git(t, dst, "fsck", "--strict")
+
+ // A follow-up fast-forward push is accepted.
+ if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("hello\nagain\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git(t, src, "commit", "-am", "second")
+ head2 := git(t, src, "rev-parse", "HEAD")
+ git(t, src, "push", remote, "main")
+ if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != head2 {
+ t.Errorf("server main after ff = %q, want %q", got, head2)
+ }
+}
+
+func TestNonFastForwardRejected(t *testing.T) {
+ ctx := context.Background()
+ srv, m, s, _ := newWriteServer(t)
+ r, _ := m.CreateRepo(ctx, repoName, "main")
+ raw := mintUser(t, m, r.ID, "atmin", meta.PermWrite)
+ remote := authedURL(srv.URL, raw) + "/" + repoName
+
+ // Establish main.
+ src := initCommit(t, "one\n")
+ git(t, src, "push", remote, "main")
+ base := git(t, src, "rev-parse", "HEAD")
+
+ // A stale clone that still sees main at base.
+ stale := filepath.Join(t.TempDir(), "stale")
+ git(t, "", "clone", remote, stale)
+
+ // Meanwhile main advances on the server.
+ if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("one\ntwo\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git(t, src, "commit", "-am", "advance")
+ git(t, src, "push", remote, "main")
+ advanced := git(t, src, "rev-parse", "HEAD")
+
+ // The stale client commits on top of base and pushes — a non-fast-forward
+ // against the advanced tip. It must be rejected, main unchanged.
+ if err := os.WriteFile(filepath.Join(stale, "file.txt"), []byte("one\ndivergent\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git(t, stale, "commit", "-am", "divergent")
+ if out, err := tryGit(stale, "push", remote, "main"); err == nil {
+ t.Fatalf("stale push succeeded, want rejection\n%s", out)
+ }
+ if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != advanced {
+ t.Errorf("main after rejected push = %q, want %q (unchanged)", got, advanced)
+ }
+ _ = base
+ _ = s
+}
+
+func TestConcurrentPushOneWins(t *testing.T) {
+ ctx := context.Background()
+ srv, m, _, _ := newWriteServer(t)
+ r, _ := m.CreateRepo(ctx, repoName, "main")
+ raw := mintUser(t, m, r.ID, "atmin", meta.PermWrite)
+ remote := authedURL(srv.URL, raw) + "/" + repoName
+
+ // A base commit both racers build on.
+ src := initCommit(t, "base\n")
+ git(t, src, "push", remote, "main")
+
+ // Two independent clones, each with its own new commit on top of base.
+ dirs := [2]string{filepath.Join(t.TempDir(), "a"), filepath.Join(t.TempDir(), "b")}
+ heads := [2]string{}
+ for i, d := range dirs {
+ git(t, "", "clone", remote, d)
+ if err := os.WriteFile(filepath.Join(d, "file.txt"), []byte(fmt.Sprintf("base\nracer-%d\n", i)), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git(t, d, "commit", "-am", "racer")
+ heads[i] = git(t, d, "rev-parse", "HEAD")
+ }
+
+ // Push both concurrently; exactly one must win.
+ var wg sync.WaitGroup
+ errs := [2]error{}
+ for i, d := range dirs {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ _, errs[i] = tryGit(d, "push", remote, "main")
+ }()
+ }
+ wg.Wait()
+
+ wins := 0
+ winner := -1
+ for i := range errs {
+ if errs[i] == nil {
+ wins++
+ winner = i
+ }
+ }
+ if wins != 1 {
+ t.Fatalf("concurrent pushes: %d winners, want exactly 1 (errs: %v)", wins, errs)
+ }
+ if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != heads[winner] {
+ t.Errorf("main = %q, want winner %q — no lost update", got, heads[winner])
+ }
+}
+
+func TestAtomicMultiRefRollback(t *testing.T) {
+ ctx := context.Background()
+ srv, m, _, _ := newWriteServer(t)
+ r, _ := m.CreateRepo(ctx, repoName, "main")
+ raw := mintUser(t, m, r.ID, "atmin", meta.PermWrite)
+ remote := authedURL(srv.URL, raw) + "/" + repoName
+
+ src := initCommit(t, "one\n")
+ git(t, src, "push", remote, "main")
+
+ // Stale clone still at the original tip.
+ stale := filepath.Join(t.TempDir(), "stale")
+ git(t, "", "clone", remote, stale)
+
+ // Advance main on the server.
+ if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("one\ntwo\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git(t, src, "commit", "-am", "advance")
+ git(t, src, "push", remote, "main")
+ advanced := git(t, src, "rev-parse", "HEAD")
+
+ // The stale client builds a new commit and a brand-new branch, then pushes
+ // both atomically. The main update is a non-fast-forward (fails); with
+ // --atomic the feature create must roll back too.
+ if err := os.WriteFile(filepath.Join(stale, "file.txt"), []byte("one\ndivergent\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git(t, stale, "commit", "-am", "divergent")
+ git(t, stale, "branch", "feature")
+ if out, err := tryGit(stale, "push", "--atomic", remote, "main", "feature"); err == nil {
+ t.Fatalf("atomic push succeeded, want rejection\n%s", out)
+ }
+
+ if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != advanced {
+ t.Errorf("main = %q, want %q (unchanged)", got, advanced)
+ }
+ if got := serverRefSHA(t, m, r.ID, "refs/heads/feature"); got != "" {
+ t.Errorf("feature = %q, want absent — atomic rollback", got)
+ }
+}
+
+func TestOrderingInvariantObjectsBeforeRef(t *testing.T) {
+ ctx := context.Background()
+ srv, m, s, writer := newWriteServer(t)
+ r, _ := m.CreateRepo(ctx, repoName, "main")
+ raw := mintUser(t, m, r.ID, "atmin", meta.PermWrite)
+ remote := authedURL(srv.URL, raw) + "/" + repoName
+
+ // Inject a CAS failure that fires *after* the object PUT. This models a ref
+ // CAS that loses the race (or any post-PUT failure): objects are already
+ // durable, and the ref must never advance.
+ writer.beforeCAS = func() error { return errors.New("injected cas failure") }
+
+ src := initCommit(t, "hello\n")
+ if out, err := tryGit(src, "push", remote, "main"); err == nil {
+ t.Fatalf("push succeeded despite injected CAS failure\n%s", out)
+ }
+
+ // Ordering invariant: the objects reached the store (harmless orphans that
+ // gc reclaims), but the ref never advanced (no corruption).
+ objs, _ := s.List(ctx, repoName+"/objects/")
+ if len(objs) == 0 {
+ t.Error("expected orphan objects in the store (content-before-pointer)")
+ }
+ if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != "" {
+ t.Errorf("main = %q, want absent — ref must not advance when CAS fails", got)
+ }
+}
diff --git a/internal/hookrpc/hookrpc.go b/internal/hookrpc/hookrpc.go
new file mode 100644
index 0000000..b26c1ca
--- /dev/null
+++ b/internal/hookrpc/hookrpc.go
@@ -0,0 +1,90 @@
+// Package hookrpc is the control channel between git's pre-receive hook and the
+// gitmote parent process, per docs/notes/push-hook-channel.md. The hook runs as
+// a child of receive-pack — a separate process that cannot touch the embedded
+// single-writer s3lite — so it RPCs the parent (the sole writer) over a unix
+// socket. The parent performs the S3 PUT + ref CAS and returns a verdict the
+// hook turns into its exit code.
+package hookrpc
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net"
+)
+
+// Environment variables the parent exports into receive-pack (and thus the
+// hook): the socket to dial and the single-use nonce that binds the call to the
+// in-flight push.
+const (
+ EnvSock = "GITMOTE_SOCK"
+ EnvNonce = "GITMOTE_NONCE"
+)
+
+// RefCommand is one `<old> <new> <ref>` line from the hook's stdin. old/new are
+// object ids; the all-zero id means "absent" (create when old, delete when new).
+type RefCommand struct {
+ Old string `json:"old"`
+ New string `json:"new"`
+ Ref string `json:"ref"`
+}
+
+// Request is the hook → parent message: the nonce, the ref commands, and the
+// absolute path of the quarantine object directory holding the pushed objects.
+type Request struct {
+ Nonce string `json:"nonce"`
+ Commands []RefCommand `json:"commands"`
+ QuarantinePath string `json:"quarantine_path"`
+}
+
+// Response is the parent → hook verdict. OK=false rejects the push; Reason is
+// surfaced to the client via the hook's stderr.
+type Response struct {
+ OK bool `json:"ok"`
+ Reason string `json:"reason,omitempty"`
+}
+
+// Call dials the parent's unix socket, sends req, and returns its verdict. It
+// is used by the hook binary.
+func Call(sockPath string, req Request) (Response, error) {
+ conn, err := net.Dial("unix", sockPath)
+ if err != nil {
+ return Response{}, fmt.Errorf("hookrpc: dial %s: %w", sockPath, err)
+ }
+ defer func() { _ = conn.Close() }()
+
+ if err := json.NewEncoder(conn).Encode(req); err != nil {
+ return Response{}, fmt.Errorf("hookrpc: send: %w", err)
+ }
+ var resp Response
+ if err := json.NewDecoder(conn).Decode(&resp); err != nil {
+ return Response{}, fmt.Errorf("hookrpc: receive: %w", err)
+ }
+ return resp, nil
+}
+
+// Serve accepts connections on l until l is closed, handling each with h. Each
+// connection carries exactly one Request/Response exchange.
+func Serve(l net.Listener, h func(Request) Response) {
+ for {
+ conn, err := l.Accept()
+ if err != nil {
+ // A closed listener ends the loop; nothing else is recoverable.
+ if errors.Is(err, net.ErrClosed) {
+ return
+ }
+ continue
+ }
+ go serveConn(conn, h)
+ }
+}
+
+func serveConn(conn net.Conn, h func(Request) Response) {
+ defer func() { _ = conn.Close() }()
+ var req Request
+ if err := json.NewDecoder(conn).Decode(&req); err != nil {
+ _ = json.NewEncoder(conn).Encode(Response{OK: false, Reason: "malformed request"})
+ return
+ }
+ _ = json.NewEncoder(conn).Encode(h(req))
+}
diff --git a/internal/hookrpc/hookrpc_test.go b/internal/hookrpc/hookrpc_test.go
new file mode 100644
index 0000000..840f648
--- /dev/null
+++ b/internal/hookrpc/hookrpc_test.go
@@ -0,0 +1,69 @@
+package hookrpc
+
+import (
+ "net"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// listen starts a Serve loop on a short-path unix socket and returns its path.
+func listen(t *testing.T, h func(Request) Response) string {
+ t.Helper()
+ dir, err := os.MkdirTemp("", "hr")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.RemoveAll(dir) })
+ sock := filepath.Join(dir, "s.sock")
+ l, err := net.Listen("unix", sock)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = l.Close() })
+ go Serve(l, h)
+ return sock
+}
+
+func TestCallRoundTrip(t *testing.T) {
+ var got Request
+ sock := listen(t, func(req Request) Response {
+ got = req
+ return Response{OK: true}
+ })
+
+ req := Request{
+ Nonce: "n1",
+ QuarantinePath: "/q",
+ Commands: []RefCommand{{Old: "0", New: "abc", Ref: "refs/heads/main"}},
+ }
+ resp, err := Call(sock, req)
+ if err != nil {
+ t.Fatalf("Call: %v", err)
+ }
+ if !resp.OK {
+ t.Errorf("resp.OK = false, want true")
+ }
+ if got.Nonce != "n1" || got.QuarantinePath != "/q" || len(got.Commands) != 1 || got.Commands[0].New != "abc" {
+ t.Errorf("server received %+v, want the sent request", got)
+ }
+}
+
+func TestCallRejectVerdict(t *testing.T) {
+ sock := listen(t, func(Request) Response {
+ return Response{OK: false, Reason: "nope"}
+ })
+ resp, err := Call(sock, Request{Nonce: "x"})
+ if err != nil {
+ t.Fatalf("Call: %v", err)
+ }
+ if resp.OK || resp.Reason != "nope" {
+ t.Errorf("resp = %+v, want {false nope}", resp)
+ }
+}
+
+func TestCallNoServer(t *testing.T) {
+ if _, err := Call(filepath.Join(t.TempDir(), "absent.sock"), Request{}); err == nil {
+ t.Error("Call to a nonexistent socket succeeded, want error")
+ }
+}
diff --git a/tasks/07-write-path-cas.md b/tasks/07-write-path-cas.md
deleted file mode 100644
index ea36b5e..0000000
--- a/tasks/07-write-path-cas.md
+++ /dev/null
@@ -1,43 +0,0 @@
-# 07 — Write path: push (the CAS)
-
-Depends on: 04, 03, 06.
-
-## Spec
-
-Serve `git-receive-pack` so `git push` works, with the safety-critical ordering
-**objects durable in S3 first, ref CAS in s3lite second** — the write path and
-all four points in [request-flows.md](../docs/architecture/request-flows.md) and
-[safety.md](../docs/architecture/safety.md). Resolves the push-hook channel
-([notes/push-hook-channel.md](../docs/notes/push-hook-channel.md)).
-
-## Current
-
-Read path only. No way to write.
-
-## Change
-
-- Routes: `GET /{repo}/info/refs?service=git-receive-pack` and
- `POST /{repo}/git-receive-pack`, gated by `write` ACL (task 06).
-- A per-repo in-process mutex serializes writes; materialize target history
- (full-hydrate) before spawning `receive-pack` and piping the request body.
-- Install a `pre-receive` hook into the materialized repo that RPCs the parent
- over a unix socket — **decide:** socket path passed via the hook's env, hook
- authenticated by a per-request nonce. The parent (the sole writer) PUTs
- quarantined objects to S3 (`$GIT_QUARANTINE_PATH`) — content before pointer —
- then runs the per-ref CAS in one s3lite transaction and returns a verdict the
- hook turns into its exit code.
-- On `ok`, receive-pack migrates the quarantine; report-status flows back.
-
-## Verify
-
-- Golden: a real `git push` of new commits succeeds; a subsequent clone
- (task 05) returns them.
-- Safety tests — the point of this task:
- - **non-fast-forward is rejected** and leaves refs unchanged.
- - **concurrent-push CAS**: two pushes racing the same ref → exactly one wins,
- the other is rejected; no lost update.
- - **atomic multi-ref**: a push updating two refs where one fails rolls back
- both.
- - **ordering invariant**: inject a ref-CAS failure after the object PUT → S3
- holds orphan objects (harmless), the ref never advanced (no corruption).
-- Non-breaking: adds write endpoints; the read path is unaffected.
diff --git a/tasks/README.md b/tasks/README.md
index 2ffac7b..b15df42 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.
-- [07-write-path-cas.md](07-write-path-cas.md) — `receive-pack` + pre-receive RPC + object PUT + ref CAS; `git push` works.
- [08-bootstrap.md](08-bootstrap.md) — First admin, token, repo from an empty bucket.
- [09-management-web-ui.md](09-management-web-ui.md) — Authenticated UI: repos, tokens, ACLs.
- [10-self-host-local.md](10-self-host-local.md) — MinIO + Docker; e2e clone+push of the gitmote repo.