diff --git a/scripts/init-repo-github-defaults.ps1 b/scripts/init-repo-github-defaults.ps1 new file mode 100644 index 0000000..8c5f73d --- /dev/null +++ b/scripts/init-repo-github-defaults.ps1 @@ -0,0 +1,93 @@ +<# +.SYNOPSIS + Apply opinionated GitHub repo-level defaults to a target repo via gh CLI. + +.DESCRIPTION + Idempotent: detect current state, only PATCH when divergent. + + Settings applied: + delete_branch_on_merge = true GitHub auto-deletes head branches + on PR merge. Eliminates the slow + drift of stale merged-PR branches + that this script was written to + prevent (captured 2026-05-18 after + a marathon session left 8 orphans). + + Documented as a cross-project pattern in the vault at + 00_meta/patterns/github-branch-hygiene.md. + +.PARAMETER Repo + Target repo as owner/name. Defaults to the current repo's origin remote. + +.PARAMETER DryRun + Show what would change without applying. + +.NOTES + Requires the gh CLI authenticated (gh auth status). + ASCII-only per project policy (PSScriptAnalyzer PSUseBOMForUnicodeEncodedFile). +#> +param( + [string]$Repo, + [switch]$DryRun +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$ghCmd = Get-Command gh -ErrorAction SilentlyContinue +if (-not $ghCmd) { + Write-Error 'gh CLI not found. Install: https://cli.github.com/' + exit 3 +} + +# --- Resolve repo --- +if (-not $Repo) { + try { + $originUrl = (& git remote get-url origin 2>$null) -replace "`r`n|`r|`n", '' + } catch { + $originUrl = '' + } + if (-not $originUrl) { + Write-Error 'Not in a git repo or no origin remote. Use -Repo .' + exit 4 + } + # Strip git@github.com: or https://github.com/ and trailing .git + $Repo = $originUrl -replace '^git@github\.com:', '' ` + -replace '^https?://github\.com/', '' ` + -replace '\.git$', '' + if ($Repo -notmatch '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$') { + Write-Error "Could not derive owner/name from origin ($originUrl). Use -Repo." + exit 5 + } +} + +Write-Host "[INFO] Target repo: $Repo" + +# --- Current state --- +try { + $currentState = & gh api "/repos/$Repo" --jq '.delete_branch_on_merge' 2>$null + $currentState = "$currentState".Trim() +} catch { + $currentState = 'unknown' +} + +Write-Host "[INFO] Current delete_branch_on_merge: $currentState" + +if ($currentState -eq 'true') { + Write-Host '[OK] Already enabled, nothing to do.' + exit 0 +} + +# --- Apply --- +if ($DryRun) { + Write-Host "[DRY-RUN] Would PATCH /repos/$Repo with delete_branch_on_merge=true" + exit 0 +} + +try { + & gh api -X PATCH "/repos/$Repo" -f 'delete_branch_on_merge=true' --jq '.delete_branch_on_merge' 2>&1 | Out-Null + Write-Host "[OK] delete_branch_on_merge enabled on $Repo" +} catch { + Write-Error "PATCH failed: $_" + exit 6 +} diff --git a/scripts/init-repo-github-defaults.sh b/scripts/init-repo-github-defaults.sh new file mode 100755 index 0000000..ef6ac9c --- /dev/null +++ b/scripts/init-repo-github-defaults.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# init-repo-github-defaults.sh +# Purpose: Apply opinionated GitHub repo-level defaults to a target repo +# via `gh api`. Idempotent (detect-current-state, patch only when +# a setting diverges). +# +# Why: avoids the slow drift of stale merged-PR head branches on origin. +# Empirically captured 2026-05-18 after a marathon session left 8 +# orphan branches because `gh pr merge` was used without +# `--delete-branch` and the repo did not have +# `delete_branch_on_merge` enabled. +# +# Documented as a cross-project pattern in vault: +# 00_meta/patterns/github-branch-hygiene.md +# +# Usage: +# ./init-repo-github-defaults.sh [--repo ] [--dry-run] +# +# If --repo is not given, derives owner/name from the current repo's +# `origin` remote URL. + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: init-repo-github-defaults.sh [--repo ] [--dry-run] + + --repo target repo (default: derived from `origin` remote) + --dry-run show diffs without applying + -h, --help show this help + +Settings applied: + delete_branch_on_merge = true GitHub auto-deletes the head branch when + a PR is merged (squash, rebase, or merge + commit). Eliminates the orphan branch + drift this script was written to prevent. + +Requires: gh CLI authenticated (`gh auth status`). +EOF +} + +TARGET_REPO="" +DRY_RUN=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --repo) TARGET_REPO="$2"; shift 2 ;; + --dry-run) DRY_RUN=1; shift ;; + -h|--help) usage; exit 0 ;; + *) printf '[ERROR] Unknown argument: %s\n' "$1" >&2; usage >&2; exit 2 ;; + esac +done + +if ! command -v gh >/dev/null 2>&1; then + printf '[ERROR] gh CLI not found. Install: https://cli.github.com/\n' >&2 + exit 3 +fi + +# --- Resolve repo --- +if [[ -z "$TARGET_REPO" ]]; then + if ! ORIGIN_URL="$(git remote get-url origin 2>/dev/null)"; then + printf '[ERROR] Not in a git repo or no `origin` remote. Use --repo .\n' >&2 + exit 4 + fi + # Handle both SSH (git@github.com:owner/name.git) and HTTPS forms + TARGET_REPO=$(printf '%s\n' "$ORIGIN_URL" \ + | sed -E 's#^git@github\.com:##; s#^https?://github\.com/##; s#\.git$##') + if ! [[ "$TARGET_REPO" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + printf '[ERROR] Could not derive owner/name from origin (%s). Use --repo.\n' "$ORIGIN_URL" >&2 + exit 5 + fi +fi + +printf '[INFO] Target repo: %s\n' "$TARGET_REPO" + +# --- Current state --- +CURRENT_STATE=$(gh api "/repos/$TARGET_REPO" --jq '{delete_branch_on_merge}' 2>/dev/null || echo '{}') +CURRENT_DELETE_ON_MERGE=$(printf '%s\n' "$CURRENT_STATE" | grep -oE 'true|false' | head -1 || echo 'unknown') + +printf '[INFO] Current delete_branch_on_merge: %s\n' "$CURRENT_DELETE_ON_MERGE" + +if [[ "$CURRENT_DELETE_ON_MERGE" = "true" ]]; then + printf '[OK] Already enabled, nothing to do.\n' + exit 0 +fi + +# --- Apply --- +if [[ "$DRY_RUN" -eq 1 ]]; then + printf '[DRY-RUN] Would PATCH /repos/%s with delete_branch_on_merge=true\n' "$TARGET_REPO" + exit 0 +fi + +if gh api -X PATCH "/repos/$TARGET_REPO" -f delete_branch_on_merge=true --jq '{delete_branch_on_merge}' 2>&1; then + printf '[OK] delete_branch_on_merge enabled on %s\n' "$TARGET_REPO" +else + printf '[ERROR] PATCH failed (insufficient permissions, or repo archived?)\n' >&2 + exit 6 +fi diff --git a/tests/init-repo-github-defaults.bats b/tests/init-repo-github-defaults.bats new file mode 100644 index 0000000..a5335f6 --- /dev/null +++ b/tests/init-repo-github-defaults.bats @@ -0,0 +1,85 @@ +#!/usr/bin/env bats +# Tests for scripts/init-repo-github-defaults.sh + +setup() { + export REPO_DIR="$BATS_TEST_DIRNAME/.." + export SCRIPT="$REPO_DIR/scripts/init-repo-github-defaults.sh" + export PS_SCRIPT="$REPO_DIR/scripts/init-repo-github-defaults.ps1" +} + +@test "init-repo-github-defaults.sh exists and is executable" { + [ -x "$SCRIPT" ] +} + +@test "init-repo-github-defaults.sh passes bash syntax" { + bash -n "$SCRIPT" +} + +@test "init-repo-github-defaults.sh passes zsh syntax" { + zsh -n "$SCRIPT" +} + +@test "init-repo-github-defaults.sh uses set -euo pipefail" { + grep -q 'set -euo pipefail' "$SCRIPT" +} + +@test "init-repo-github-defaults.sh PATCHes delete_branch_on_merge=true" { + grep -qE 'delete_branch_on_merge=true' "$SCRIPT" +} + +@test "init-repo-github-defaults.sh is idempotent (early-exit on already-enabled)" { + grep -q 'Already enabled, nothing to do' "$SCRIPT" +} + +@test "init-repo-github-defaults.sh supports --dry-run" { + grep -qE '\-\-dry-run' "$SCRIPT" +} + +@test "init-repo-github-defaults.sh supports --repo override" { + grep -qE '\-\-repo' "$SCRIPT" +} + +@test "init-repo-github-defaults.sh --help works" { + run "$SCRIPT" --help + [ "$status" -eq 0 ] + [[ "$output" == *"Usage:"* ]] +} + +@test "init-repo-github-defaults.sh rejects unknown args" { + run "$SCRIPT" --bogus + [ "$status" -ne 0 ] +} + +@test "init-repo-github-defaults.sh derives owner/name from SSH origin URL" { + # Sanity-check the sed pipeline that strips git@github.com: / .git + grep -qF 'git@github' "$SCRIPT" + grep -qF 'https?://github' "$SCRIPT" + grep -qF '.git$' "$SCRIPT" +} + +# --- PowerShell parity --- + +@test "init-repo-github-defaults.ps1 exists" { + [ -f "$PS_SCRIPT" ] +} + +@test "init-repo-github-defaults.ps1 is ASCII-only (PSScriptAnalyzer rule)" { + # No em dashes, arrows, smart quotes, ellipsis -- the recurring CI failure. + ! grep -nP '[^\x00-\x7F]' "$PS_SCRIPT" +} + +@test "init-repo-github-defaults.ps1 PATCHes delete_branch_on_merge=true" { + grep -qE "delete_branch_on_merge=true" "$PS_SCRIPT" +} + +@test "init-repo-github-defaults.ps1 is idempotent (early-exit on already-enabled)" { + grep -q "Already enabled" "$PS_SCRIPT" +} + +@test "init-repo-github-defaults.ps1 supports -DryRun switch" { + grep -qE '\[switch\]\$DryRun' "$PS_SCRIPT" +} + +@test "init-repo-github-defaults.ps1 derives owner/name from origin" { + grep -qE 'remote get-url origin' "$PS_SCRIPT" +}