New Sign in

fierj

Public

Tiny personal git forge

a86159dee9300f1f5b3ab7b1aeae32410526651f
diff --git a/git.go b/git.go
index cc0a8f5..0ff67d7 100644
--- a/git.go
+++ b/git.go
@@ -1,6 +1,7 @@
 package main
 
 import (
+	"bufio"
 	"bytes"
 	"encoding/json"
 	"fmt"
@@ -11,49 +12,63 @@ import (
 	"strings"
 )
 
-// preReceiveHook is a git pre-receive hook that rejects force-pushes to protected
-// branches listed in .fierj.json. It uses only standard git plumbing and POSIX sh.
-const preReceiveHook = `#!/bin/sh
-# pre-receive hook — managed by fierj, do not edit.
-# Rejects force-pushes and deletions of protected branches.
-set -e
-
-CONF="$(dirname "$0")/../../.fierj.json"
-[ -f "$CONF" ] || exit 0
-
-PROTECTED=$(grep -o '"protected_branches"[[:space:]]*:[[:space:]]*\[[^]]*\]' "$CONF" | \
-    sed 's/.*\[//;s/\]//;s/"//g;s/, */ /g')
-[ -n "$PROTECTED" ] || exit 0
-
-zero=0000000000000000000000000000000000000000
-while read old new ref; do
-	branch=$(echo "$ref" | sed 's|refs/heads/||')
-	for p in $PROTECTED; do
-		[ "$branch" = "$p" ] || continue
-		if [ "$new" = "$zero" ]; then
-			echo "ERROR: Cannot delete protected branch '$branch'."
-			exit 1
-		fi
-		[ "$old" = "$zero" ] && continue
-		if ! git merge-base --is-ancestor "$old" "$new" 2>/dev/null; then
-			echo "ERROR: Force push to protected branch '$branch' rejected."
-			echo "       Use a feature branch and submit a patch instead."
-			exit 1
-		fi
-	done
-done
-exit 0
-`
+const zeroSHA = "0000000000000000000000000000000000000000"
+
+// runPreReceive is invoked as a git pre-receive hook. It reads protected
+// branches from .fierj.json in the current directory (the repo root) and
+// rejects direct pushes to them.
+func runPreReceive() int {
+	data, err := os.ReadFile(".fierj.json")
+	if err != nil {
+		return 0
+	}
+	var meta RepoMeta
+	if err := json.Unmarshal(data, &meta); err != nil || len(meta.ProtectedBranches) == 0 {
+		return 0
+	}
+	protected := make(map[string]bool, len(meta.ProtectedBranches))
+	for _, b := range meta.ProtectedBranches {
+		protected[b] = true
+	}
+
+	sc := bufio.NewScanner(os.Stdin)
+	for sc.Scan() {
+		parts := strings.Fields(sc.Text())
+		if len(parts) != 3 {
+			continue
+		}
+		old, new_, ref := parts[0], parts[1], parts[2]
+		branch := strings.TrimPrefix(ref, "refs/heads/")
+		if !protected[branch] {
+			continue
+		}
+		if new_ == zeroSHA {
+			fmt.Fprintf(os.Stderr, "ERROR: Cannot delete protected branch %q.\n", branch)
+			return 1
+		}
+		if old == zeroSHA {
+			continue // new branch, allow
+		}
+		fmt.Fprintf(os.Stderr, "ERROR: Direct push to protected branch %q rejected.\n", branch)
+		fmt.Fprintf(os.Stderr, "       Use a feature branch and submit a patch instead.\n")
+		return 1
+	}
+	return 0
+}
 
 // writePreReceiveHook installs the pre-receive hook into a bare repository.
 func writePreReceiveHook(repoPath string) error {
+	exe, err := os.Executable()
+	if err != nil {
+		return fmt.Errorf("find executable: %w", err)
+	}
+	script := fmt.Sprintf("#!/bin/sh\nexec %s hook pre-receive\n", exe)
 	hookPath := filepath.Join(repoPath, "hooks", "pre-receive")
-	if err := os.WriteFile(hookPath, []byte(preReceiveHook), 0755); err != nil {
+	if err := os.WriteFile(hookPath, []byte(script), 0755); err != nil {
 		return fmt.Errorf("write pre-receive hook: %w", err)
 	}
 	return nil
 }
-
 type TreeEntry struct {
 	Mode string
 	Type string // "blob" or "tree"
diff --git a/main.go b/main.go
index 92d0671..f297d2b 100644
--- a/main.go
+++ b/main.go
@@ -18,6 +18,10 @@ var templateFS embed.FS
 type contextKey string
 
 func main() {
+	if len(os.Args) >= 2 && os.Args[1] == "hook" {
+		os.Exit(hookCmd(os.Args[2:]))
+	}
+
 	cfgPath := "config.json"
 	if len(os.Args) > 1 {
 		cfgPath = os.Args[1]
@@ -75,7 +79,6 @@ func main() {
 	mux.HandleFunc("POST /{repo}/threads/{threadId}/close", ThreadClosePost(cfg, tmpl))
 	mux.HandleFunc("POST /{repo}/threads/{threadId}/reopen", ThreadReopenPost(cfg, tmpl))
 
-	// Patches
 	mux.HandleFunc("GET /{repo}/patches", PatchesList(cfg, tmpl))
 	mux.HandleFunc("GET /{repo}/patches/new", PatchNewGet(cfg, tmpl))
 	mux.HandleFunc("POST /{repo}/patches/new", PatchNewPost(cfg, tmpl))
@@ -126,6 +129,18 @@ func main() {
 	slog.Info("stopped")
 }
 
+// hookCmd dispatches git hook invocations.
+func hookCmd(args []string) int {
+	if len(args) < 1 {
+		return 0
+	}
+	switch args[0] {
+	case "pre-receive":
+		return runPreReceive()
+	}
+	return 0
+}
+
 // User returns the logged-in username from the request context, or "" if anonymous.
 func User(r *http.Request) string {
 	u, _ := r.Context().Value(contextKey("user")).(string)