Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions scripts/init-repo-github-defaults.ps1
Original file line number Diff line number Diff line change
@@ -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 <owner/name>.'
exit 4
}
# Strip [email protected]: 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
}
97 changes: 97 additions & 0 deletions scripts/init-repo-github-defaults.sh
Original file line number Diff line number Diff line change
@@ -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 <owner/name>] [--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 <owner/name>] [--dry-run]

--repo <owner/name> 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 <owner/name>.\n' >&2
exit 4
fi
# Handle both SSH ([email protected]: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
85 changes: 85 additions & 0 deletions tests/init-repo-github-defaults.bats
Original file line number Diff line number Diff line change
@@ -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 [email protected]: / .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"
}
Loading