- Must run in posix terminal
- Install
gh-cli - Install
jq
Run gh auth login first, if needed. This implicitly uses your cached token; private repos require a token with repo.
Copy the script to a file and then change permissions of the file to executable.
chmod +x ghunsub.sh# Preview what it will do (no changes), verbose log written alongside:
./ghunsub.sh --dry-run
# Do the thing, sleeping 0.2s between each DELETE to be gentle on rate limits:
./ghunsub.sh --sleep 0.2
# Only unsubscribe threads (keep repo watches):
./ghunsub.sh --only-threads
# Only unwatch repos:
./ghunsub.sh --only-repos
# Tweak retries and log file path:
./ghunsub.sh --retries 6 --log my-run.logNote
- If you hit abuse/secondary rate limits, the script will automatically retry with exponential backoff (2s → 4s → 8s … capped at 32s).
- The log includes every action and any stderr from gh so you can see why something failed.
- --sleep is optional; it can help on very large accounts.
#!/usr/bin/env bash
set -euo pipefail
# --- Config defaults (override via flags) ---
DRY_RUN=0
SLEEP_SECS=0
ONLY_THREADS=0
ONLY_REPOS=0
MAX_RETRIES=5
LOG_FILE="gh-unsub-$(date +%Y%m%d-%H%M%S).log"
# --- Logging helpers ---
log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE" >&2; }
log_ok() { log "✅ $*"; }
log_err() { log "❌ $*"; }
log_info() { log "ℹ️ $*"; }
usage() {
cat <<'USAGE'
Unsubscribe from ALL GitHub notification threads and unwatch ALL repos.
Usage:
gh-unsub-all.sh [options]
Options:
-n, --dry-run Show what would happen; do not make changes
-s, --sleep SECS Sleep between DELETE calls (default: 0)
-t, --only-threads Only unsubscribe threads (skip repos)
-r, --only-repos Only unwatch repos (skip threads)
-R, --retries N Max retries for failed calls (default: 5)
-l, --log FILE Log file path (default: auto-named)
-h, --help Show this help
Requires:
- GitHub CLI: gh
- jq
Scopes:
- Threads: notifications
- Repos (if any private watched): repo
USAGE
}
# --- Parse args ---
while [[ $# -gt 0 ]]; do
case "$1" in
-n|--dry-run) DRY_RUN=1; shift ;;
-s|--sleep) SLEEP_SECS="${2:-}"; shift 2 ;;
-t|--only-threads) ONLY_THREADS=1; shift ;;
-r|--only-repos) ONLY_REPOS=1; shift ;;
-R|--retries) MAX_RETRIES="${2:-}"; shift 2 ;;
-l|--log) LOG_FILE="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) log_err "Unknown arg: $1"; usage; exit 1 ;;
esac
done
# --- Pre-flight checks ---
command -v gh >/dev/null 2>&1 || { log_err "gh not found. Install GitHub CLI."; exit 1; }
command -v jq >/dev/null 2>&1 || { log_err "jq not found. Install jq."; exit 1; }
gh auth status >/dev/null 2>&1 || { log_err "gh is not authenticated. Run: gh auth login"; exit 1; }
log_info "Logging to: $LOG_FILE"
log_info "Dry run: $DRY_RUN, Sleep: ${SLEEP_SECS}s, Retries: $MAX_RETRIES"
if [[ "$ONLY_THREADS" -eq 1 && "$ONLY_REPOS" -eq 1 ]]; then
log_err "Cannot use --only-threads and --only-repos together."; exit 1
fi
# --- Retry wrapper for gh api ---
gh_api_delete() {
local endpoint="$1"
local attempt=1
local backoff=2
while :; do
if [[ "$DRY_RUN" -eq 1 ]]; then
log_info "[DRY-RUN] DELETE $endpoint"
return 0
fi
# gh returns non-zero on HTTP error; capture stderr for logs
if gh api -X DELETE -H "Accept: application/vnd.github+json" "$endpoint" >/dev/null 2>>"$LOG_FILE"; then
return 0
fi
if (( attempt >= MAX_RETRIES )); then
log_err "Failed DELETE $endpoint after $MAX_RETRIES attempts."
return 1
fi
log_info "Retry $attempt/$MAX_RETRIES for $endpoint … sleeping ${backoff}s"
sleep "$backoff"
attempt=$((attempt+1))
backoff=$((backoff*2))
# Cap the backoff to something sane (e.g., 32s)
if (( backoff > 32 )); then backoff=32; fi
done
}
# --- Unsubscribe from all notification threads (POSIX) ---
unsubscribe_threads() {
local processed=0 ok=0 fail=0
log_info "Fetching notification threads (including read)…"
local ids_tmp
ids_tmp="$(mktemp)" || { log_err "mktemp failed"; return 1; }
# shellcheck disable=SC2069 # we want stderr into the log file only
if ! gh api -H "Accept: application/vnd.github+json" "/notifications?all=true" --paginate \
| jq -r '.[]? | .id' >"$ids_tmp" 2>>"$LOG_FILE"; then
log_err "Failed to list notifications."
rm -f "$ids_tmp"
return 1
fi
local total
total=$(wc -l <"$ids_tmp" | tr -d ' ') || total=0
if [ "$total" -eq 0 ]; then
log_ok "No notification threads found. Nothing to unsubscribe."
rm -f "$ids_tmp"
return 0
fi
while IFS= read -r tid; do
[ -z "$tid" ] && continue
processed=$((processed+1))
if gh_api_delete "/notifications/threads/${tid}/subscription"; then
ok=$((ok+1))
log_ok "Unsubscribed thread ${tid} (${processed}/${total})"
else
fail=$((fail+1))
log_err "Failed to unsubscribe thread ${tid} (${processed}/${total}; failures: ${fail})"
fi
[ "$SLEEP_SECS" -gt 0 ] && sleep "$SLEEP_SECS"
done <"$ids_tmp"
rm -f "$ids_tmp"
log_info "Threads processed: total=${total}, ok=${ok}, failed=${fail}"
[ "$fail" -eq 0 ]
}
# --- Unwatch all repos (POSIX) ---
unwatch_repos() {
local processed=0 ok=0 fail=0
log_info "Fetching watched repositories…"
local repos_tmp
repos_tmp="$(mktemp)" || { log_err "mktemp failed"; return 1; }
if ! gh api -H "Accept: application/vnd.github+json" "/user/subscriptions" --paginate \
| jq -r '.[]? | .full_name' >"$repos_tmp" 2>>"$LOG_FILE"; then
log_err "Failed to list watched repositories."
rm -f "$repos_tmp"
return 1
fi
local total
total=$(wc -l <"$repos_tmp" | tr -d ' ') || total=0
if [ "$total" -eq 0 ]; then
log_ok "No watched repositories found. Nothing to unwatch."
rm -f "$repos_tmp"
return 0
fi
while IFS= read -r full; do
[ -z "$full" ] && continue
processed=$((processed+1))
if gh_api_delete "/repos/${full}/subscription"; then
ok=$((ok+1))
log_ok "Unwatched ${full} (${processed}/${total})"
else
fail=$((fail+1))
log_err "Failed to unwatch ${full} (${processed}/${total}; failures: ${fail})"
fi
[ "$SLEEP_SECS" -gt 0 ] && sleep "$SLEEP_SECS"
done <"$repos_tmp"
rm -f "$repos_tmp"
log_info "Repos processed: total=${total}, ok=${ok}, failed=${fail}"
[ "$fail" -eq 0 ]
}
# --- Orchestration ---
overall_rc=0
if [[ "$ONLY_REPOS" -ne 1 ]]; then
if ! unsubscribe_threads; then overall_rc=1; fi
fi
if [[ "$ONLY_THREADS" -ne 1 ]]; then
if ! unwatch_repos; then overall_rc=1; fi
fi
if [[ "$DRY_RUN" -eq 1 ]]; then
log_ok "DRY-RUN complete. Review $LOG_FILE for the planned actions."
else
if [[ "$overall_rc" -eq 0 ]]; then
log_ok "All done without errors. See log: $LOG_FILE"
else
log_err "Completed with some errors. See log: $LOG_FILE"
fi
fi
exit "$overall_rc"