diff --git a/cmd/main.go b/cmd/main.go index 7e6d9fe..3bd00be 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -16,6 +16,7 @@ import ( "simplegit/cmd/daemon" "simplegit/cmd/gitctl" + "simplegit/hooks" ) func main() { @@ -29,6 +30,8 @@ func main() { daemon.Run(args[1:]) case "ctl": gitctl.Run(args[1:]) + case "hook": + hooks.RunClient(args[1:]) case "help", "-h", "--help": printHelp(os.Stdout) default: @@ -44,6 +47,7 @@ func printHelp(w *os.File) { Usage: simplegit daemon [flags] run the git hosting server (HTTP + SSH + manage) simplegit ctl [flags] run the gitctl TUI against the manage port + simplegit hook [flags] forward a git hook invocation (run by hook scripts) simplegit help show this help Run "simplegit daemon -h" or "simplegit ctl -h" for subcommand flags. diff --git a/common/config.go b/common/config.go index 25541a1..90389af 100644 --- a/common/config.go +++ b/common/config.go @@ -31,6 +31,19 @@ func (c *Config) RepoRoot() string { return filepath.Join(c.Root, "repos") } +// TemplateDir is the `git init --template` source: /template. The daemon +// materializes it at startup (hooks.EnsureTemplate) so every CreateRepo gets +// forwarding hooks. CreateRepo falls back to plain `git init --bare` if absent. +func (c *Config) TemplateDir() string { + return filepath.Join(c.Root, "template") +} + +// HookSock is the unix socket the hook bus listens on: /hook.sock. Hook +// scripts dial it (via `simplegit hook --root=...`) to forward invocations. +func (c *Config) HookSock() string { + return filepath.Join(c.Root, "hook.sock") +} + // Prepare checks that the config is valid and creates the repo root if needed. func (c *Config) Prepare() error { if c.prepared.Load() { diff --git a/hooks/event.go b/hooks/event.go new file mode 100644 index 0000000..32dda2f --- /dev/null +++ b/hooks/event.go @@ -0,0 +1,65 @@ +// Package hooks wires git hook invocations back into the simplegit daemon. +// +// Git hooks are scripts git runs at fixed points in an operation (push, commit, +// ...). For a bare hosting repo only the server-side hooks fire during a push, +// but the same forwarding script is installed for every git-defined hook name so +// whichever one git runs is captured -- the bus listens to every hook type. +// +// Pipeline: +// +// git (during a push) -> hooks/ (rendered shell script) -> +// `simplegit hook --root=... [args]` (this package's client) -> +// unix socket /hook.sock -> daemon Listener -> buffered bus chan -> +// Updater (logs now; future: forwards to a subscribed URL or MQ). +// +// Hooks fail open: if the socket is absent or the daemon is unreachable the +// client exits 0, so a git operation (push) is never blocked by the event bus. +// Enforcement (pre-receive reject) is a future layer on the same response +// protocol; today the listener observes only. +package hooks + +// HookEvent is one hook invocation forwarded by a hook script. +type HookEvent struct { + Type string `json:"type"` // hook name, e.g. "pre-receive" + Repo string `json:"repo"` // "ns/name" resolved from the repo path, "" if unknown + RepoPath string `json:"repo_path"` // absolute bare repo path (the hook's cwd) + Args []string `json:"args"` // hook argv after the hook name + Stdin string `json:"stdin"` // raw stdin (ref-update lines for receive hooks) + Env map[string]string `json:"env"` // captured GIT_* env vars +} + +// hookNames is every hook git looks for in $GIT_DIR/hooks. Only the server-side +// ones fire on a bare hosting repo during a push, but installing all of them +// means whichever git runs is forwarded -- and a hook name git does not use is +// simply ignored, so the full set is harmless. Keep this list in sync with +// githooks(5). +var hookNames = []string{ + // server-side (fire during a push to a bare repo) + "pre-receive", + "update", + "post-receive", + "post-update", + "proc-receive", + "push-to-checkout", + "reference-transaction", + // client-side (fire in a working repo; dormant on a bare host) + "pre-applypatch", + "post-applypatch", + "pre-commit", + "pre-merge-commit", + "prepare-commit-msg", + "commit-msg", + "post-commit", + "pre-rebase", + "post-checkout", + "post-merge", + "pre-push", + "pre-auto-gc", + "post-rewrite", + "sendemail-validate", + "fsmonitor-watchman", + "p4-pre-submit", + "p4-post-changelist", + "p4-prepare-changelist", + "p4-changelist", +} diff --git a/hooks/hooks_test.go b/hooks/hooks_test.go new file mode 100644 index 0000000..188fa79 --- /dev/null +++ b/hooks/hooks_test.go @@ -0,0 +1,164 @@ +package hooks + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "testing" + "time" +) + +// expectedHookBody is the exact forwarding body every rendered hook carries, +// with %s = the hook name. Kept here so drift in the generator output fails the +// test loudly. +const expectedHookBody = `#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via ` + "`simplegit hook`" + `. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" %s "$@" +` + +// TestTemplateInSync asserts the checked-in template/hooks/ files exactly match +// hooks.hookNames: one real script per hook, no more, no less. If hookNames +// changes, rerun the template generator and commit. +func TestTemplateInSync(t *testing.T) { + entries, err := fs.ReadDir(templateFS, "template/hooks") + if err != nil { + t.Fatalf("read embedded template/hooks: %v", err) + } + got := map[string]bool{} + for _, e := range entries { + got[e.Name()] = true + } + for _, name := range hookNames { + if !got[name] { + t.Errorf("hook %q: no checked-in template file", name) + } + } + for name := range got { + found := false + for _, n := range hookNames { + if n == name { + found = true + break + } + } + if !found { + t.Errorf("template/hooks/%s: not in hookNames (stale file)", name) + } + } +} + +func TestEnsureTemplate(t *testing.T) { + root := t.TempDir() + dir, err := EnsureTemplate(root) + if err != nil { + t.Fatalf("EnsureTemplate: %v", err) + } + if dir != TemplateDir(root) { + t.Fatalf("template dir = %s, want %s", dir, TemplateDir(root)) + } + for _, name := range hookNames { + p := filepath.Join(dir, "hooks", name) + fi, err := os.Stat(p) + if err != nil { + t.Errorf("hook %s: not installed: %v", name, err) + continue + } + if fi.Mode()&0o111 == 0 { + t.Errorf("hook %s: not executable", name) + } + body, err := os.ReadFile(p) + if err != nil { + t.Errorf("hook %s: read: %v", name, err) + continue + } + if want := fmt.Sprintf(expectedHookBody, name); string(body) != want { + t.Errorf("hook %s: body mismatch\n got: %q\nwant: %q", name, string(body), want) + } + } + // Idempotent: a second run must not error. + if _, err := EnsureTemplate(root); err != nil { + t.Fatalf("EnsureTemplate second run: %v", err) + } +} + +func TestStartRoundTrip(t *testing.T) { + root := t.TempDir() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + resolver, err := Start(ctx, root) + if err != nil { + t.Fatalf("Start: %v", err) + } + + // Give the listener a moment to bind (Bind is synchronous, but the accept + // loop runs in a goroutine; the socket file existing is the ready signal). + deadline := time.Now().Add(2 * time.Second) + for { + if _, err := os.Stat(SockPath(root)); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatalf("socket never appeared") + } + time.Sleep(5 * time.Millisecond) + } + + want := HookEvent{ + Type: "post-receive", + Repo: "acme/widget", + RepoPath: filepath.Join(root, "repos", "acme", "widget.git"), + Args: []string{}, + Stdin: "0000000000000000000000000000000000000000 1234567890abcdef refs/heads/main\n", + Env: map[string]string{"GIT_DIR": "."}, + } + if exit := forward(want, SockPath(root)); exit != 0 { + t.Fatalf("forward exit = %d, want 0", exit) + } + + select { + case got := <-resolver: + if got.Type != want.Type || got.Repo != want.Repo || got.Stdin != want.Stdin { + t.Fatalf("events got %+v, want %+v", got, want) + } + case <-time.After(2 * time.Second): + t.Fatalf("events channel did not receive the event") + } +} + +func TestForwardFailOpen(t *testing.T) { + // No listener on this root -> dial fails -> forward must return 0 (fail + // open) rather than blocking or rejecting the push. + root := t.TempDir() + exit := forward(HookEvent{Type: "pre-receive"}, SockPath(root)) + if exit != 0 { + t.Fatalf("forward exit = %d, want 0 (fail open)", exit) + } +} + +func TestResolveRepo(t *testing.T) { + root := t.TempDir() + // GIT_DIR is the primary signal git sets for the hook (relative "." under + // git http-backend / SSH receive-pack, which chdir into the repo); cwd is + // the fallback when GIT_DIR is unset. Both sides are symlink-resolved. + cases := []struct{ gitDir, cwd, want string }{ + {filepath.Join(root, "repos", "acme", "widget.git"), "/somewhere", "acme/widget"}, + {filepath.Join(root, "repos", "org", "team", "svc.git"), "/somewhere", "org/team/svc"}, + // GIT_DIR unset -> fall back to cwd. + {"", filepath.Join(root, "repos", "acme", "widget.git"), "acme/widget"}, + // neither under /repos. + {"", "/tmp/elsewhere.git", ""}, + {filepath.Join(root, "repos"), "/somewhere", ""}, + } + for _, c := range cases { + if got := resolveRepo(root, c.gitDir, c.cwd); got != c.want { + t.Errorf("resolveRepo(gitDir=%q, cwd=%q) = %q, want %q", c.gitDir, c.cwd, got, c.want) + } + } +} diff --git a/hooks/listener.go b/hooks/listener.go new file mode 100644 index 0000000..c9413fd --- /dev/null +++ b/hooks/listener.go @@ -0,0 +1,126 @@ +package hooks + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net" + "os" + "path/filepath" +) + +// SockPath returns the unix socket path for the hook bus: /hook.sock. +func SockPath(root string) string { + return filepath.Join(root, "hook.sock") +} + +// Listener accepts hook events on a unix socket and fans them out to a buffered +// channel. It responds to each hook with exit 0 immediately -- observe-only, +// never block a push. If the channel is full an event is dropped (with a log) +// rather than blocking the hook. +type Listener struct { + sock string + bus chan<- HookEvent + ln net.Listener +} + +// NewListener creates a Listener that will serve on /hook.sock and publish +// received events to bus. +func NewListener(root string, bus chan<- HookEvent) *Listener { + return &Listener{sock: SockPath(root), bus: bus} +} + +// Start binds the hook listener on /hook.sock and returns a read-only +// channel of forwarded hook events. The accept loop runs in a goroutine until +// ctx is canceled. A bind error is returned synchronously so the caller can +// decide to proceed (hooks then fail open) or abort. +// +// The returned channel is buffered (256); when full, new events are dropped +// with a log rather than blocking a push. Nothing consumes it yet -- a full +// buffer is the expected steady state and pushes keep working (hooks always +// fail open). How the channel is consumed is left for later. +func Start(ctx context.Context, root string) (<-chan HookEvent, error) { + bus := make(chan HookEvent, 256) + l := NewListener(root, bus) + if err := l.Bind(); err != nil { + return nil, err + } + go func() { + if err := l.Serve(ctx); err != nil { + log.Printf("hooks: accept loop: %v", err) + } + }() + return bus, nil +} + +// Bind creates the unix socket, removing any stale socket first. Call once +// before Serve. Split from Serve so callers can fail fast on a bind error +// instead of learning about it from a background goroutine. +func (l *Listener) Bind() error { + if err := os.RemoveAll(l.sock); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("hooks: remove stale socket: %w", err) + } + ln, err := net.Listen("unix", l.sock) + if err != nil { + return fmt.Errorf("hooks: listen %s: %w", l.sock, err) + } + l.ln = ln + log.Printf("hooks: listener on %s", l.sock) + return nil +} + +// Serve accepts connections until ctx is done or a fatal accept error occurs. +// Bind must have been called. Returns net.ErrClosed (wrapped) when shut down via +// ctx cancellation. +func (l *Listener) Serve(ctx context.Context) error { + go func() { + <-ctx.Done() + _ = l.ln.Close() + }() + for { + conn, err := l.ln.Accept() + if err != nil { + if errors.Is(err, net.ErrClosed) { + return nil + } + return fmt.Errorf("hooks: accept: %w", err) + } + go l.handle(conn) + } +} + +// handle reads one newline-delimited JSON event, responds exit 0, and dispatches +// the event to the bus asynchronously. Responding before dispatch keeps the hook +// (and thus the push) from waiting on the consumer. +func (l *Listener) handle(conn net.Conn) { + defer conn.Close() + reader := bufio.NewReader(conn) + line, err := reader.ReadString('\n') + if err != nil && line == "" { + log.Printf("hooks: read event: %v", err) + writeExit0(conn) + return + } + var ev HookEvent + if err := json.Unmarshal([]byte(line), &ev); err != nil { + log.Printf("hooks: parse event: %v", err) + writeExit0(conn) + return + } + // Observe-only: always allow. Enforcement is a future layer. + writeExit0(conn) + + select { + case l.bus <- ev: + default: + log.Printf("hooks: bus full, dropping %s event on %s", ev.Type, ev.Repo) + } +} + +// writeExit0 sends the fail-open response so the hook client exits 0. +func writeExit0(conn net.Conn) { + _, _ = conn.Write([]byte(`{"exit":0}` + "\n")) +} diff --git a/hooks/template.go b/hooks/template.go new file mode 100644 index 0000000..d2d8843 --- /dev/null +++ b/hooks/template.go @@ -0,0 +1,63 @@ +package hooks + +import ( + "embed" + "fmt" + "io/fs" + "os" + "path/filepath" +) + +// templateFS is the checked-in git template directory. It ships real, copy-able +// hook scripts (one per git hook name) under template/hooks/. The daemon +// materializes it to /template at startup and CreateRepo passes +// --template=/template to `git init --bare`, so every new repo gets these +// hooks verbatim. This is the "template repo": a versioned, inspectable artifact +// rather than something generated from a Go string. +// +//go:embed template +var templateFS embed.FS + +// TemplateDir returns the on-disk git template directory for root: /template. +// CreateRepo passes --template= to `git init --bare`. +func TemplateDir(root string) string { + return filepath.Join(root, "template") +} + +// EnsureTemplate materializes the embedded template directory to /template +// and returns the template dir. Files under hooks/ are made executable (go:embed +// does not preserve the executable bit). Idempotent: rewrites every file each +// call, so an updated template is picked up on the next daemon start. +func EnsureTemplate(root string) (string, error) { + dst := TemplateDir(root) + if err := os.MkdirAll(dst, 0o755); err != nil { + return "", fmt.Errorf("hooks: mkdir template: %w", err) + } + err := fs.WalkDir(templateFS, "template", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return os.MkdirAll(filepath.Join(root, p), 0o755) + } + data, err := templateFS.ReadFile(p) + if err != nil { + return fmt.Errorf("hooks: read embedded %s: %w", p, err) + } + out := filepath.Join(root, p) + if err := os.WriteFile(out, data, 0o644); err != nil { + return fmt.Errorf("hooks: write %s: %w", p, err) + } + // Hook scripts must be executable; go:embed does not preserve mode bits. + if filepath.Dir(p) == "template/hooks" { + if err := os.Chmod(out, 0o755); err != nil { + return fmt.Errorf("hooks: chmod %s: %w", p, err) + } + } + return nil + }) + if err != nil { + return "", err + } + return dst, nil +} diff --git a/hooks/template/hooks/commit-msg b/hooks/template/hooks/commit-msg new file mode 100755 index 0000000..8a830cb --- /dev/null +++ b/hooks/template/hooks/commit-msg @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" commit-msg "$@" diff --git a/hooks/template/hooks/fsmonitor-watchman b/hooks/template/hooks/fsmonitor-watchman new file mode 100755 index 0000000..095b76f --- /dev/null +++ b/hooks/template/hooks/fsmonitor-watchman @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" fsmonitor-watchman "$@" diff --git a/hooks/template/hooks/p4-changelist b/hooks/template/hooks/p4-changelist new file mode 100755 index 0000000..8ea2464 --- /dev/null +++ b/hooks/template/hooks/p4-changelist @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" p4-changelist "$@" diff --git a/hooks/template/hooks/p4-post-changelist b/hooks/template/hooks/p4-post-changelist new file mode 100755 index 0000000..386e1d2 --- /dev/null +++ b/hooks/template/hooks/p4-post-changelist @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" p4-post-changelist "$@" diff --git a/hooks/template/hooks/p4-pre-submit b/hooks/template/hooks/p4-pre-submit new file mode 100755 index 0000000..ace7f54 --- /dev/null +++ b/hooks/template/hooks/p4-pre-submit @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" p4-pre-submit "$@" diff --git a/hooks/template/hooks/p4-prepare-changelist b/hooks/template/hooks/p4-prepare-changelist new file mode 100755 index 0000000..b381b28 --- /dev/null +++ b/hooks/template/hooks/p4-prepare-changelist @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" p4-prepare-changelist "$@" diff --git a/hooks/template/hooks/post-applypatch b/hooks/template/hooks/post-applypatch new file mode 100755 index 0000000..e01ddec --- /dev/null +++ b/hooks/template/hooks/post-applypatch @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" post-applypatch "$@" diff --git a/hooks/template/hooks/post-checkout b/hooks/template/hooks/post-checkout new file mode 100755 index 0000000..e7d3f67 --- /dev/null +++ b/hooks/template/hooks/post-checkout @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" post-checkout "$@" diff --git a/hooks/template/hooks/post-commit b/hooks/template/hooks/post-commit new file mode 100755 index 0000000..0488802 --- /dev/null +++ b/hooks/template/hooks/post-commit @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" post-commit "$@" diff --git a/hooks/template/hooks/post-merge b/hooks/template/hooks/post-merge new file mode 100755 index 0000000..608fc89 --- /dev/null +++ b/hooks/template/hooks/post-merge @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" post-merge "$@" diff --git a/hooks/template/hooks/post-receive b/hooks/template/hooks/post-receive new file mode 100755 index 0000000..dfb131f --- /dev/null +++ b/hooks/template/hooks/post-receive @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" post-receive "$@" diff --git a/hooks/template/hooks/post-rewrite b/hooks/template/hooks/post-rewrite new file mode 100755 index 0000000..1629f1e --- /dev/null +++ b/hooks/template/hooks/post-rewrite @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" post-rewrite "$@" diff --git a/hooks/template/hooks/post-update b/hooks/template/hooks/post-update new file mode 100755 index 0000000..5de45f3 --- /dev/null +++ b/hooks/template/hooks/post-update @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" post-update "$@" diff --git a/hooks/template/hooks/pre-applypatch b/hooks/template/hooks/pre-applypatch new file mode 100755 index 0000000..bf02d9d --- /dev/null +++ b/hooks/template/hooks/pre-applypatch @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" pre-applypatch "$@" diff --git a/hooks/template/hooks/pre-auto-gc b/hooks/template/hooks/pre-auto-gc new file mode 100755 index 0000000..72185b0 --- /dev/null +++ b/hooks/template/hooks/pre-auto-gc @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" pre-auto-gc "$@" diff --git a/hooks/template/hooks/pre-commit b/hooks/template/hooks/pre-commit new file mode 100755 index 0000000..2c67b9f --- /dev/null +++ b/hooks/template/hooks/pre-commit @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" pre-commit "$@" diff --git a/hooks/template/hooks/pre-merge-commit b/hooks/template/hooks/pre-merge-commit new file mode 100755 index 0000000..b02a1fc --- /dev/null +++ b/hooks/template/hooks/pre-merge-commit @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" pre-merge-commit "$@" diff --git a/hooks/template/hooks/pre-push b/hooks/template/hooks/pre-push new file mode 100755 index 0000000..b4fd883 --- /dev/null +++ b/hooks/template/hooks/pre-push @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" pre-push "$@" diff --git a/hooks/template/hooks/pre-rebase b/hooks/template/hooks/pre-rebase new file mode 100755 index 0000000..964b626 --- /dev/null +++ b/hooks/template/hooks/pre-rebase @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" pre-rebase "$@" diff --git a/hooks/template/hooks/pre-receive b/hooks/template/hooks/pre-receive new file mode 100755 index 0000000..744fcf1 --- /dev/null +++ b/hooks/template/hooks/pre-receive @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" pre-receive "$@" diff --git a/hooks/template/hooks/prepare-commit-msg b/hooks/template/hooks/prepare-commit-msg new file mode 100755 index 0000000..c42e5ff --- /dev/null +++ b/hooks/template/hooks/prepare-commit-msg @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" prepare-commit-msg "$@" diff --git a/hooks/template/hooks/proc-receive b/hooks/template/hooks/proc-receive new file mode 100755 index 0000000..ff388b3 --- /dev/null +++ b/hooks/template/hooks/proc-receive @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" proc-receive "$@" diff --git a/hooks/template/hooks/push-to-checkout b/hooks/template/hooks/push-to-checkout new file mode 100755 index 0000000..c52ca46 --- /dev/null +++ b/hooks/template/hooks/push-to-checkout @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" push-to-checkout "$@" diff --git a/hooks/template/hooks/reference-transaction b/hooks/template/hooks/reference-transaction new file mode 100755 index 0000000..4a88b0a --- /dev/null +++ b/hooks/template/hooks/reference-transaction @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" reference-transaction "$@" diff --git a/hooks/template/hooks/sendemail-validate b/hooks/template/hooks/sendemail-validate new file mode 100755 index 0000000..856a8c1 --- /dev/null +++ b/hooks/template/hooks/sendemail-validate @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" sendemail-validate "$@" diff --git a/hooks/template/hooks/update b/hooks/template/hooks/update new file mode 100755 index 0000000..680592a --- /dev/null +++ b/hooks/template/hooks/update @@ -0,0 +1,8 @@ +#!/bin/sh +# simplegit-managed git hook. Forwards this invocation to the simplegit daemon +# hook bus via `simplegit hook`. The daemon exports SIMPLEGIT_BIN (absolute +# path to the simplegit binary) and SIMPLEGIT_ROOT (data root, where hook.sock +# lives). If SIMPLEGIT_BIN is unset -- the hook running outside the daemon -- +# fail open (exit 0) so the git operation is never blocked by the event bus. +test -n "$SIMPLEGIT_BIN" || exit 0 +exec "$SIMPLEGIT_BIN" hook --root="${SIMPLEGIT_ROOT:-}" update "$@" diff --git a/hooks/trigger.go b/hooks/trigger.go new file mode 100644 index 0000000..62011ff --- /dev/null +++ b/hooks/trigger.go @@ -0,0 +1,138 @@ +package hooks + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "log" + "net" + "os" + "path/filepath" + "strings" + "time" +) + +// RunClient implements the `simplegit hook` subcommand invoked by rendered hook +// scripts. It forwards one hook invocation to the daemon's hook bus over +// /hook.sock and exits with the daemon's response code. It fails open: +// any IPC error (no socket, refused, timeout) exits 0 so the git operation is +// never blocked by the event bus. +// +// Usage: simplegit hook --root=PATH [hook-args...] +// +// --root is parsed manually (not via flag) so hook args that begin with '-' are +// passed through verbatim instead of being mistaken for flags. +func RunClient(args []string) { + root, rest := parseRoot(args) + if root == "" || len(rest) < 1 { + // Malformed invocation (e.g. manual call): fail open, do not break git. + fmt.Fprintln(os.Stderr, "simplegit hook: usage: simplegit hook --root=PATH [args...]") + os.Exit(0) + } + hookType := rest[0] + hookArgs := rest[1:] + + stdin, _ := io.ReadAll(os.Stdin) + cwd, _ := os.Getwd() + + ev := HookEvent{ + Type: hookType, + Repo: resolveRepo(root, os.Getenv("GIT_DIR"), cwd), + RepoPath: cwd, + Args: hookArgs, + Stdin: string(stdin), + Env: captureGitEnv(), + } + os.Exit(forward(ev, SockPath(root))) +} + +// parseRoot extracts a --root=PATH (or --root PATH) flag from args and returns +// it plus the remaining positional args. +func parseRoot(args []string) (root string, rest []string) { + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case strings.HasPrefix(a, "--root="): + root = strings.TrimPrefix(a, "--root=") + case a == "--root" && i+1 < len(args): + root = args[i+1] + i++ + default: + rest = append(rest, a) + } + } + return root, rest +} + +// forward sends ev to the hook socket and returns the daemon's requested exit +// code. Returns 0 on any error (fail open). +func forward(ev HookEvent, sock string) int { + body, err := json.Marshal(ev) + if err != nil { + log.Printf("hook %s: marshal: %v", ev.Type, err) + return 0 + } + conn, err := net.DialTimeout("unix", sock, 5*time.Second) + if err != nil { + // Daemon not running / socket absent: fail open and stay quiet (common in + // isolated mode or during a daemon restart). + return 0 + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(10 * time.Second)) + if _, err := conn.Write(append(body, '\n')); err != nil { + log.Printf("hook %s: write: %v", ev.Type, err) + return 0 + } + reader := bufio.NewReader(conn) + line, err := reader.ReadString('\n') + if err != nil { + log.Printf("hook %s: read response: %v", ev.Type, err) + return 0 + } + var resp struct { + Exit int `json:"exit"` + } + if err := json.Unmarshal([]byte(line), &resp); err != nil { + log.Printf("hook %s: parse response: %v", ev.Type, err) + return 0 + } + return resp.Exit +} + +// resolveRepo turns the hook's repo directory into "ns/name". git sets GIT_DIR +// for the hook (relative "." under git http-backend / SSH receive-pack, which +// chdir into the repo); cwd is the fallback when GIT_DIR is unset. Both sides +// are symlink-resolved so a root passed as /tmp and a cwd that resolves to +// /private/tmp (macOS) still match. Returns "" if not under /repos. +func resolveRepo(root, gitDir, cwd string) string { + dir := gitDir + if dir == "" || !filepath.IsAbs(dir) { + dir = filepath.Join(cwd, dir) + } + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolved + } + reposRoot := filepath.Join(root, "repos") + if resolved, err := filepath.EvalSymlinks(reposRoot); err == nil { + reposRoot = resolved + } + rel, err := filepath.Rel(reposRoot, dir) + if err != nil || strings.HasPrefix(rel, "..") || rel == "." { + return "" + } + return strings.TrimSuffix(rel, ".git") +} + +// captureGitEnv collects GIT_* env vars (GIT_DIR, GIT_PUSH_CERT_*, etc.) so the +// event consumer sees the context git handed the hook. +func captureGitEnv() map[string]string { + env := map[string]string{} + for _, kv := range os.Environ() { + if k, v, ok := strings.Cut(kv, "="); ok && strings.HasPrefix(k, "GIT_") { + env[k] = v + } + } + return env +} diff --git a/state/mut.go b/state/mut.go index 5707ef9..2f84521 100644 --- a/state/mut.go +++ b/state/mut.go @@ -132,7 +132,16 @@ func (s *LocalState) CreateRepo(ns, name string) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return fmt.Errorf("create repo: mkdir ns: %w", err) } - if out, err := exec.Command("git", "init", "--bare", path).CombinedOutput(); err != nil { + // Use the hook template (materialized by the daemon at startup) so every new + // repo gets forwarding hooks for all git hook types. Fall back to a plain + // init if the template is absent (e.g. a state test without the daemon); + // such a repo simply has no hooks and pushes still work. + initArgs := []string{"init", "--bare"} + if fi, err := os.Stat(s.cfg.TemplateDir()); err == nil && fi.IsDir() { + initArgs = append(initArgs, "--template="+s.cfg.TemplateDir()) + } + initArgs = append(initArgs, path) + if out, err := exec.Command("git", initArgs...).CombinedOutput(); err != nil { return fmt.Errorf("create repo: git init: %w: %s", err, strings.TrimSpace(string(out))) }