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