#!/usr/bin/env sh set -eu ROOT_PATH="${GO_GIT_ROOT:-$HOME/git}" STANDARDS_REPO="" STANDARDS_REPO_SET=0 EXCLUDE_NAME="project-standards" CHECK_ONLY=0 WATCH=0 INTERVAL=60 usage() { cat <<'EOF' Usage: reconcile-project-standards.sh [options] Options: --root Root folder containing repositories --standards-repo Path to project-standards repository --exclude First-level directory name to skip (default: project-standards) --check-only Check drift only, do not update files --watch Continuously scan and reconcile --interval Watch interval in seconds (default: 60) -h, --help Show this help Environment: GO_GIT_ROOT Overrides default root path (default: ~/git) GO_PROJECT_STANDARDS Overrides standards repository path Default resolution: ROOT_PATH defaults to GO_GIT_ROOT or ~/git. STANDARDS_REPO defaults to GO_PROJECT_STANDARDS or $ROOT_PATH/project-standards. EOF } while [ "$#" -gt 0 ]; do case "$1" in --root) ROOT_PATH=$2 shift 2 ;; --standards-repo) STANDARDS_REPO=$2 STANDARDS_REPO_SET=1 shift 2 ;; --exclude) EXCLUDE_NAME=$2 shift 2 ;; --check-only) CHECK_ONLY=1 shift ;; --watch) WATCH=1 shift ;; --interval) INTERVAL=$2 shift 2 ;; -h|--help) usage exit 0 ;; *) echo "Unknown option: $1" >&2 usage >&2 exit 1 ;; esac done if [ "$STANDARDS_REPO_SET" -ne 1 ]; then if [ -n "${GO_PROJECT_STANDARDS:-}" ]; then STANDARDS_REPO="$GO_PROJECT_STANDARDS" else STANDARDS_REPO="$ROOT_PATH/project-standards" fi fi if [ "$INTERVAL" -lt 5 ]; then echo "interval must be >= 5" >&2 exit 1 fi AGENTS_TEMPLATE="$STANDARDS_REPO/templates/AGENTS.base.md" DOD_TEMPLATE="$STANDARDS_REPO/templates/DEFINITION_OF_DONE.base.md" GITIGNORE_TEMPLATE="$STANDARDS_REPO/templates/.gitignore.base" GITATTRIBUTES_TEMPLATE="$STANDARDS_REPO/templates/.gitattributes.base" EDITORCONFIG_TEMPLATE="$STANDARDS_REPO/templates/.editorconfig.base" PRECOMMIT_TEMPLATE="$STANDARDS_REPO/templates/pre-commit.base.sh" HOOKS_README_TEMPLATE="$STANDARDS_REPO/templates/.githooks.README.base.md" if [ ! -f "$AGENTS_TEMPLATE" ]; then echo "AGENTS template not found: $AGENTS_TEMPLATE" >&2 exit 1 fi if [ ! -f "$DOD_TEMPLATE" ]; then echo "DoD template not found: $DOD_TEMPLATE" >&2 exit 1 fi if [ ! -f "$GITIGNORE_TEMPLATE" ]; then echo "gitignore template not found: $GITIGNORE_TEMPLATE" >&2 exit 1 fi if [ ! -f "$GITATTRIBUTES_TEMPLATE" ]; then echo "gitattributes template not found: $GITATTRIBUTES_TEMPLATE" >&2 exit 1 fi if [ ! -f "$EDITORCONFIG_TEMPLATE" ]; then echo "editorconfig template not found: $EDITORCONFIG_TEMPLATE" >&2 exit 1 fi if [ ! -f "$PRECOMMIT_TEMPLATE" ]; then echo "pre-commit hook template not found: $PRECOMMIT_TEMPLATE" >&2 exit 1 fi if [ ! -f "$HOOKS_README_TEMPLATE" ]; then echo "hooks readme template not found: $HOOKS_README_TEMPLATE" >&2 exit 1 fi hash_or_missing() { path=$1 if [ ! -f "$path" ]; then printf "%s" "__MISSING__" return 0 fi if command -v sha256sum >/dev/null 2>&1; then sha256sum "$path" | awk '{print $1}' else shasum -a 256 "$path" | awk '{print $1}' fi } ensure_file() { template=$1 target=$2 template_hash=$(hash_or_missing "$template") target_hash=$(hash_or_missing "$target") if [ "$template_hash" = "$target_hash" ]; then printf "%s" "ok" return 0 fi if [ "$CHECK_ONLY" -eq 1 ]; then printf "%s" "drift" return 0 fi mkdir -p "$(dirname "$target")" cp "$template" "$target" printf "%s" "updated" } ensure_gitignore_entries_from_template() { template_path=$1 gitignore_path=$2 missing=0 if [ ! -f "$gitignore_path" ]; then if [ "$CHECK_ONLY" -eq 1 ]; then printf "%s" "drift" return 0 fi : > "$gitignore_path" fi while IFS= read -r entry; do entry=$(printf '%s' "$entry" | tr -d '\r') [ -n "$entry" ] || continue case "$entry" in \#*) continue ;; esac if tr -d '\r' < "$gitignore_path" | grep -Fqx "$entry"; then continue fi missing=1 if [ "$CHECK_ONLY" -ne 1 ]; then printf '%s\n' "$entry" >> "$gitignore_path" fi done < "$template_path" if [ "$missing" -eq 0 ]; then printf "%s" "ok" return 0 fi if [ "$CHECK_ONLY" -eq 1 ]; then printf "%s" "drift" else printf "%s" "updated" fi } run_once() { scanned=0 updated=0 drift=0 for repo in "$ROOT_PATH"/*; do [ -d "$repo" ] || continue name=$(basename "$repo") [ "$name" = "$EXCLUDE_NAME" ] && continue scanned=$((scanned + 1)) agents_target="$repo/AGENTS.md" dod_target="$repo/docs/DEFINITION_OF_DONE.md" gitattributes_target="$repo/.gitattributes" editorconfig_target="$repo/.editorconfig" precommit_target="$repo/.githooks/pre-commit" hooks_readme_target="$repo/.githooks/README.md" gitignore_target="$repo/.gitignore" agents_state=$(ensure_file "$AGENTS_TEMPLATE" "$agents_target") dod_state=$(ensure_file "$DOD_TEMPLATE" "$dod_target") gitattributes_state=$(ensure_file "$GITATTRIBUTES_TEMPLATE" "$gitattributes_target") editorconfig_state=$(ensure_file "$EDITORCONFIG_TEMPLATE" "$editorconfig_target") precommit_state=$(ensure_file "$PRECOMMIT_TEMPLATE" "$precommit_target") hooks_readme_state=$(ensure_file "$HOOKS_README_TEMPLATE" "$hooks_readme_target") gitignore_state=$(ensure_gitignore_entries_from_template "$GITIGNORE_TEMPLATE" "$gitignore_target") if [ "$agents_state" = "updated" ] || [ "$dod_state" = "updated" ] || [ "$gitattributes_state" = "updated" ] || [ "$editorconfig_state" = "updated" ] || [ "$precommit_state" = "updated" ] || [ "$hooks_readme_state" = "updated" ] || [ "$gitignore_state" = "updated" ]; then updated=$((updated + 1)) echo "UPDATED: $repo" continue fi if [ "$agents_state" = "drift" ] || [ "$dod_state" = "drift" ] || [ "$gitattributes_state" = "drift" ] || [ "$editorconfig_state" = "drift" ] || [ "$precommit_state" = "drift" ] || [ "$hooks_readme_state" = "drift" ] || [ "$gitignore_state" = "drift" ]; then drift=$((drift + 1)) echo "DRIFT: $repo" continue fi echo "OK: $repo" done echo "Summary -> scanned=$scanned, updated=$updated, drift=$drift" if [ "$CHECK_ONLY" -eq 1 ] && [ "$drift" -gt 0 ]; then return 1 fi return 0 } if [ "$WATCH" -eq 1 ]; then while :; do echo "[$(date '+%Y-%m-%d %H:%M:%S')] Reconciling project standards..." run_once || true sleep "$INTERVAL" done else run_once fi