New Sign in

fierj

Public

Tiny personal git forge

4da217258f8da7ea43fcfb2d97074edb3ef6e5b8
diff --git a/git.go b/git.go
index 7ca37c4..cc0a8f5 100644
--- a/git.go
+++ b/git.go
@@ -11,6 +11,49 @@ 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
+`
+
+// writePreReceiveHook installs the pre-receive hook into a bare repository.
+func writePreReceiveHook(repoPath string) error {
+	hookPath := filepath.Join(repoPath, "hooks", "pre-receive")
+	if err := os.WriteFile(hookPath, []byte(preReceiveHook), 0755); err != nil {
+		return fmt.Errorf("write pre-receive hook: %w", err)
+	}
+	return nil
+}
+
 type TreeEntry struct {
 	Mode string
 	Type string // "blob" or "tree"
@@ -132,6 +175,9 @@ func InitRepo(cfg Config, name, description string) error {
 	if err := cmd.Run(); err != nil {
 		return fmt.Errorf("git init failed: %v, stderr: %s", err, stderr.String())
 	}
+	if err := writePreReceiveHook(repoPath); err != nil {
+		return err
+	}
 	if description != "" {
 		g := &Git{Dir: absRoot, Name: name}
 		g.SaveMeta(RepoMeta{Description: description})
@@ -158,6 +204,9 @@ func ImportRepo(cfg Config, cloneURL, description string) (string, error) {
 	if err := cmd.Run(); err != nil {
 		return "", fmt.Errorf("clone failed: %v, stderr: %s", err, stderr.String())
 	}
+	if err := writePreReceiveHook(repoPath); err != nil {
+		return "", err
+	}
 	if description != "" {
 		g := &Git{Dir: absRoot, Name: name}
 		g.SaveMeta(RepoMeta{Description: description})
diff --git a/repo_handlers.go b/repo_handlers.go
index 563c572..bcd16e6 100644
--- a/repo_handlers.go
+++ b/repo_handlers.go
@@ -6,6 +6,7 @@ import (
 	"log/slog"
 	"net"
 	"net/http"
+	"path/filepath"
 	"sort"
 	"strings"
 	"time"
@@ -442,6 +443,11 @@ func SettingsPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
 			renderError(w, tmpl, http.StatusInternalServerError, "failed to save settings: "+err.Error())
 			return
 		}
+		// Re-install hook in case protected branches changed.
+		rp := filepath.Join(cfg.Dir, git.Name+".git")
+		if err := writePreReceiveHook(rp); err != nil {
+			slog.Error("write pre-receive hook", "repo", git.Name, "error", err)
+		}
 		http.Redirect(w, r, "/"+git.Name+"/settings", http.StatusSeeOther)
 	}
 }