Files
simplegit/hooks/hooks_test.go
T
2026-07-17 05:32:14 -04:00

165 lines
5.0 KiB
Go

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 <root>/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)
}
}
}