#!/bin/bash # CI pre-push hook: enforce versioning and branching rules # Install: git config core.hooksPath .githooks zero="0000000000000000000000000000000000000000" while read local_ref local_oid remote_ref remote_oid; do if [ "$local_oid" = "$zero" ]; then continue fi # Check pushed tags if echo "$local_ref" | grep -qE "^refs/tags/"; then tag_name=$(echo "$local_ref" | sed 's|^refs/tags/||') # Validate semver format if echo "$tag_name" | grep -qE "^v[0-9]+\.[0-9]+\.[0-9]+$"; then tag_major=$(echo "$tag_name" | sed 's/v\([0-9]*\)\.[0-9]*\.[0-9]*/\1/') tag_minor=$(echo "$tag_name" | sed 's/v[0-9]*\.\([0-9]*\)\.[0-9]*/\1/') tag_patch=$(echo "$tag_name" | sed 's/v[0-9]*\.[0-9]*\.\([0-9]*\)/\1/') # Check for semver ordering violations for existing_tag in $(git tag -l "v${tag_major}.${tag_minor}.*" 2>/dev/null); do if [ "$existing_tag" = "$tag_name" ]; then continue fi existing_patch=$(echo "$existing_tag" | sed 's/v[0-9]*\.[0-9]*\.\([0-9]*\)/\1/') if [ "$existing_patch" -ge "$tag_patch" ] && [ "$tag_patch" -le "$existing_patch" ]; then echo "ERROR: Tag $tag_name is not greater than existing tag $existing_tag" echo " Milestone tags must be the NEXT version (e.g., v0.6.0 after v0.5.1-5, NOT v0.5.0)" exit 1 fi done # Check for milestone-tags-below-phase-tags # If this is a .0 tag (milestone), verify no .N tags exist with higher patch if [ "$tag_patch" = "0" ]; then for existing_tag in $(git tag -l "v${tag_major}.${tag_minor}.*" 2>/dev/null); do existing_patch=$(echo "$existing_tag" | sed 's/v[0-9]*\.[0-9]*\.\([0-9]*\)/\1/') if [ "$existing_patch" -gt 0 ] && [ "$existing_patch" -gt "$tag_patch" ]; then echo "ERROR: Milestone tag $tag_name is below existing phase tags (e.g., $existing_tag)" echo " Feature milestone completion must be tagged as v${tag_major}.$(($tag_minor + 1)).0, not v${tag_major}.${tag_minor}.0" exit 1 fi done fi fi fi # Check branch merges: reject direct-to-main pushes if milestone branch exists if echo "$local_ref" | grep -qE "^refs/heads/main$"; then milestone_branches=$(git branch -r 2>/dev/null | grep 'milestone/v' | grep -v ':$' || true) if [ -n "$milestone_branches" ]; then # Allow if this is a merge commit from a milestone branch merge_parents=$(git cat-file -p "$local_oid" 2>/dev/null | grep "^parent" | wc -l) if [ "$merge_parents" -lt 2 ]; then # Not a merge commit — check if there are active milestone branches active_milestones="" for mb in $milestone_branches; do clean_name=$(echo "$mb" | sed 's|^[^/]*/||' | tr -d ' ') merged=$(git branch -r --merged origin/main 2>/dev/null | grep "$clean_name" || true) if [ -z "$merged" ]; then active_milestones="$active_milestones $clean_name" fi done if [ -n "$active_milestones" ]; then echo "WARNING: Pushing directly to main while active milestone branches exist:" for ms in $active_milestones; do echo " - $ms" done echo " Phase branches should merge into the milestone branch first." # Warning only — not blocking. The code-level enforcement in git-branch.ts # is the hard gate; this hook is a safety net. fi fi fi fi done exit 0