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 }