Last active
July 21, 2026 15:47
-
-
Save RandyMcMillan/7abd8016d5eaf3c493f68d70c9e743f4 to your computer and use it in GitHub Desktop.
gist-utility
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| INSTALL_DIR="/usr/local/bin" | |
| SCRIPT_NAME="git-clone-gists" | |
| CLONE_DIR="${CLONE_DIR:-gists}" | |
| show_usage() { | |
| cat <<EOF | |
| Usage: $0 [install|uninstall|--help] [--force] [--user USERNAME] [filters] [github-token] | |
| Defaults: | |
| github-username randymcmillan | |
| github-token optional token argument or GH_TOKEN env var | |
| --gh-token read the token from a file path | |
| --force overwrite existing gist directories instead of fetching | |
| --filename match filenames containing the value | |
| --language match gists with a file in the given language | |
| --extension match gists with a file ending in the extension | |
| --size match gists with a file size expression, e.g. >1000 | |
| --not exclude gists containing the value in metadata or filenames | |
| --fork-only clone only forked gists | |
| --anon recognized for parity, but unsupported by the REST API here | |
| --stars recognized for parity, but unsupported by the REST API here | |
| --query gist search syntax, e.g. 'cat stars:>100 user:octocat' | |
| The script clones matching gists for the chosen user into ./gists and refreshes | |
| ~/gists to point at that directory. | |
| EOF | |
| } | |
| install_script() { | |
| if [ ! -d "$INSTALL_DIR" ]; then | |
| echo "Error: installation directory '$INSTALL_DIR' does not exist." | |
| exit 1 | |
| fi | |
| cp "$0" "$INSTALL_DIR/$SCRIPT_NAME" | |
| chmod +x "$INSTALL_DIR/$SCRIPT_NAME" | |
| echo "Installed '$SCRIPT_NAME' to '$INSTALL_DIR/$SCRIPT_NAME'." | |
| } | |
| uninstall_script() { | |
| rm -f "$INSTALL_DIR/$SCRIPT_NAME" | |
| echo "Removed '$INSTALL_DIR/$SCRIPT_NAME'." | |
| } | |
| case "${1:-}" in | |
| install) | |
| install_script | |
| exit 0 | |
| ;; | |
| uninstall) | |
| uninstall_script | |
| exit 0 | |
| ;; | |
| -h|--help|help) | |
| show_usage | |
| exit 0 | |
| ;; | |
| esac | |
| FORCE_OVERWRITE=0 | |
| TARGET_USER="" | |
| FILTER_FILENAME="" | |
| FILTER_LANGUAGE="" | |
| FILTER_EXTENSION="" | |
| FILTER_SIZE="" | |
| FORK_ONLY=0 | |
| INCLUDE_ANON=0 | |
| STARS_EXPR="" | |
| SEARCH_QUERY="" | |
| GITHUB_TOKEN_PATH="" | |
| declare -a SEARCH_TERMS=() | |
| declare -a SEARCH_NOT_TERMS=() | |
| POSITIONAL=() | |
| while [ $# -gt 0 ]; do | |
| case "$1" in | |
| --force|-f) | |
| FORCE_OVERWRITE=1 | |
| shift | |
| ;; | |
| --user) | |
| if [ $# -lt 2 ]; then | |
| echo "Error: --user requires a value." >&2 | |
| exit 1 | |
| fi | |
| TARGET_USER="$2" | |
| shift 2 | |
| ;; | |
| --filename) | |
| if [ $# -lt 2 ]; then | |
| echo "Error: --filename requires a value." >&2 | |
| exit 1 | |
| fi | |
| FILTER_FILENAME="$2" | |
| shift 2 | |
| ;; | |
| --language) | |
| if [ $# -lt 2 ]; then | |
| echo "Error: --language requires a value." >&2 | |
| exit 1 | |
| fi | |
| FILTER_LANGUAGE="$2" | |
| shift 2 | |
| ;; | |
| --extension) | |
| if [ $# -lt 2 ]; then | |
| echo "Error: --extension requires a value." >&2 | |
| exit 1 | |
| fi | |
| FILTER_EXTENSION="$2" | |
| shift 2 | |
| ;; | |
| --size) | |
| if [ $# -lt 2 ]; then | |
| echo "Error: --size requires a value." >&2 | |
| exit 1 | |
| fi | |
| FILTER_SIZE="$2" | |
| shift 2 | |
| ;; | |
| --not) | |
| if [ $# -lt 2 ]; then | |
| echo "Error: --not requires a value." >&2 | |
| exit 1 | |
| fi | |
| SEARCH_NOT_TERMS+=("$2") | |
| shift 2 | |
| ;; | |
| --fork-only) | |
| FORK_ONLY=1 | |
| shift | |
| ;; | |
| --anon) | |
| INCLUDE_ANON=1 | |
| shift | |
| ;; | |
| --stars) | |
| if [ $# -lt 2 ]; then | |
| echo "Error: --stars requires a value." >&2 | |
| exit 1 | |
| fi | |
| STARS_EXPR="$2" | |
| shift 2 | |
| ;; | |
| --query) | |
| if [ $# -lt 2 ]; then | |
| echo "Error: --query requires a value." >&2 | |
| exit 1 | |
| fi | |
| SEARCH_QUERY="$2" | |
| shift 2 | |
| ;; | |
| --gh-token) | |
| if [ $# -lt 2 ]; then | |
| echo "Error: --gh-token requires a file path." >&2 | |
| exit 1 | |
| fi | |
| GITHUB_TOKEN_PATH="$2" | |
| shift 2 | |
| ;; | |
| --) | |
| shift | |
| while [ $# -gt 0 ]; do | |
| POSITIONAL+=("$1") | |
| shift | |
| done | |
| ;; | |
| -*) | |
| echo "Error: unknown flag '$1'." >&2 | |
| exit 1 | |
| ;; | |
| *) | |
| POSITIONAL+=("$1") | |
| shift | |
| ;; | |
| esac | |
| done | |
| if [ -z "$TARGET_USER" ]; then | |
| TARGET_USER="${POSITIONAL[0]:-randymcmillan}" | |
| fi | |
| if [ "${#POSITIONAL[@]}" -ge 2 ]; then | |
| GITHUB_PAT="${POSITIONAL[1]}" | |
| else | |
| GITHUB_PAT="${GITHUB_PAT:-}" | |
| fi | |
| if [ -n "$GITHUB_TOKEN_PATH" ]; then | |
| if [ ! -f "$GITHUB_TOKEN_PATH" ]; then | |
| echo "Error: token file '$GITHUB_TOKEN_PATH' does not exist." >&2 | |
| exit 1 | |
| fi | |
| GITHUB_PAT="$(<"$GITHUB_TOKEN_PATH")" | |
| fi | |
| if [ "$INCLUDE_ANON" -eq 1 ]; then | |
| echo "Error: --anon is recognized for parity, but GitHub's REST API does not expose anonymous gist discovery here." >&2 | |
| exit 1 | |
| fi | |
| if [ -n "$STARS_EXPR" ]; then | |
| echo "Error: --stars is recognized for parity, but GitHub's REST API does not expose gist stars here." >&2 | |
| exit 1 | |
| fi | |
| if [ -n "$FILTER_SIZE" ] && [[ ! "$FILTER_SIZE" =~ ^(>=|<=|>|<|=)?[0-9]+$ ]]; then | |
| echo "Error: invalid --size expression '$FILTER_SIZE'." >&2 | |
| exit 1 | |
| fi | |
| if [ -n "$FILTER_EXTENSION" ]; then | |
| FILTER_EXTENSION="${FILTER_EXTENSION#.}" | |
| fi | |
| parse_search_query() { | |
| local token negate_next=0 | |
| for token in $SEARCH_QUERY; do | |
| case "$token" in | |
| NOT) | |
| negate_next=1 | |
| ;; | |
| user:*) | |
| TARGET_USER="${token#user:}" | |
| ;; | |
| filename:*) | |
| FILTER_FILENAME="${token#filename:}" | |
| ;; | |
| language:*) | |
| FILTER_LANGUAGE="${token#language:}" | |
| ;; | |
| extension:*) | |
| FILTER_EXTENSION="${token#extension:}" | |
| FILTER_EXTENSION="${FILTER_EXTENSION#.}" | |
| ;; | |
| size:*) | |
| FILTER_SIZE="${token#size:}" | |
| ;; | |
| stars:*) | |
| STARS_EXPR="${token#stars:}" | |
| ;; | |
| anon:true) | |
| INCLUDE_ANON=1 | |
| ;; | |
| fork:only) | |
| FORK_ONLY=1 | |
| ;; | |
| *) | |
| if [ "$negate_next" -eq 1 ]; then | |
| SEARCH_NOT_TERMS+=("$token") | |
| negate_next=0 | |
| else | |
| SEARCH_TERMS+=("$token") | |
| fi | |
| ;; | |
| esac | |
| done | |
| } | |
| if [ -n "$SEARCH_QUERY" ]; then | |
| parse_search_query | |
| fi | |
| command -v gh >/dev/null | |
| command -v git >/dev/null | |
| command -v jq >/dev/null | |
| if [ -n "$GITHUB_PAT" ]; then | |
| export GH_TOKEN="$GITHUB_PAT" | |
| fi | |
| shopt -s nocasematch | |
| echo "Starting Gist cloning process for user: $TARGET_USER" | |
| mkdir -p "$CLONE_DIR" | |
| ln -sfn "$PWD/$CLONE_DIR" "$HOME/gists" | |
| cd "$CLONE_DIR" | |
| mkdir -p "$TARGET_USER" | |
| gist_matches_filters() { | |
| local gist_json="$1" | |
| local gist_description gist_url gist_fork_of gist_haystack | |
| local filename language size | |
| local filename_ok=0 language_ok=0 extension_ok=0 size_ok=0 | |
| local search_term search_not_term | |
| local content_match=0 | |
| gist_description="$(jq -r '.description // ""' <<<"$gist_json")" | |
| gist_url="$(jq -r '.html_url // ""' <<<"$gist_json")" | |
| gist_fork_of="$(jq -r '.fork_of != null' <<<"$gist_json")" | |
| gist_haystack="$( | |
| jq -r ' | |
| [ | |
| (.description // ""), | |
| (.html_url // ""), | |
| (.files[]?.filename // ""), | |
| (.files[]?.language // "") | |
| ] | map(select(. != null)) | join(" ") | |
| ' <<<"$gist_json" | |
| )" | |
| if [ "$FORK_ONLY" -eq 1 ] && [ "$gist_fork_of" != "true" ]; then | |
| return 1 | |
| fi | |
| if [ "${#SEARCH_NOT_TERMS[@]}" -gt 0 ]; then | |
| for search_not_term in "${SEARCH_NOT_TERMS[@]}"; do | |
| if [[ "$gist_haystack" == *"$search_not_term"* ]]; then | |
| return 1 | |
| fi | |
| done | |
| fi | |
| if [ "${#SEARCH_TERMS[@]}" -gt 0 ]; then | |
| for search_term in "${SEARCH_TERMS[@]}"; do | |
| if [[ "$gist_haystack" != *"$search_term"* ]]; then | |
| content_match=1 | |
| break | |
| fi | |
| done | |
| fi | |
| if [ "$content_match" -eq 1 ]; then | |
| return 1 | |
| fi | |
| while IFS=$'\t' read -r filename language size; do | |
| [ -z "${filename}${language}${size}" ] && continue | |
| if [ -n "$FILTER_FILENAME" ] && [[ "$filename" == *"$FILTER_FILENAME"* ]]; then | |
| filename_ok=1 | |
| fi | |
| if [ -n "$FILTER_LANGUAGE" ] && [[ "$language" == "$FILTER_LANGUAGE" ]]; then | |
| language_ok=1 | |
| fi | |
| if [ -n "$FILTER_EXTENSION" ] && [[ "$filename" == *."$FILTER_EXTENSION" ]]; then | |
| extension_ok=1 | |
| fi | |
| if [ -n "$FILTER_SIZE" ] && size_expr_matches "$FILTER_SIZE" "$size"; then | |
| size_ok=1 | |
| fi | |
| done < <(jq -r '.files[]? | [.filename, (.language // ""), (.size | tostring)] | @tsv' <<<"$gist_json") | |
| if [ -n "$FILTER_FILENAME" ] && [ "$filename_ok" -ne 1 ]; then | |
| return 1 | |
| fi | |
| if [ -n "$FILTER_LANGUAGE" ] && [ "$language_ok" -ne 1 ]; then | |
| return 1 | |
| fi | |
| if [ -n "$FILTER_EXTENSION" ] && [ "$extension_ok" -ne 1 ]; then | |
| return 1 | |
| fi | |
| if [ -n "$FILTER_SIZE" ] && [ "$size_ok" -ne 1 ]; then | |
| return 1 | |
| fi | |
| return 0 | |
| } | |
| size_expr_matches() { | |
| local expr="$1" | |
| local size="$2" | |
| local op limit | |
| if [[ "$expr" =~ ^(>=|<=|>|<|=)?([0-9]+)$ ]]; then | |
| op="${BASH_REMATCH[1]:-=}" | |
| limit="${BASH_REMATCH[2]}" | |
| else | |
| return 1 | |
| fi | |
| case "$op" in | |
| '>') | |
| [ "$size" -gt "$limit" ] | |
| ;; | |
| '>=') | |
| [ "$size" -ge "$limit" ] | |
| ;; | |
| '<') | |
| [ "$size" -lt "$limit" ] | |
| ;; | |
| '<=') | |
| [ "$size" -le "$limit" ] | |
| ;; | |
| '=') | |
| [ "$size" -eq "$limit" ] | |
| ;; | |
| *) | |
| [ "$size" -eq "$limit" ] | |
| ;; | |
| esac | |
| } | |
| if ! GIST_JSON="$( | |
| gh api --paginate --slurp "users/$TARGET_USER/gists?per_page=100" | |
| )"; then | |
| echo "Error: unable to list gists for $TARGET_USER. Authenticate with gh or pass a valid token." >&2 | |
| exit 1 | |
| fi | |
| FOUND_COUNT=0 | |
| while IFS= read -r gist_json; do | |
| [ -z "$gist_json" ] && continue | |
| if ! gist_matches_filters "$gist_json"; then | |
| continue | |
| fi | |
| GIST_ID="$(jq -r '.id' <<<"$gist_json")" | |
| GIST_URL="$(jq -r '.html_url' <<<"$gist_json")" | |
| GIST_DIR_NAME="$TARGET_USER/${GIST_ID:0:7}" | |
| FOUND_COUNT=$((FOUND_COUNT + 1)) | |
| if [ -e "$GIST_DIR_NAME" ] && [ "$FORCE_OVERWRITE" -eq 1 ]; then | |
| echo "Refreshing existing directory $GIST_URL" | |
| rm -rf "$GIST_DIR_NAME" | |
| elif [ -d "$GIST_DIR_NAME/.git" ]; then | |
| echo "Fetching updates for $GIST_URL" | |
| git -C "$GIST_DIR_NAME" fetch --all --prune >/dev/null | |
| continue | |
| elif [ -e "$GIST_DIR_NAME" ]; then | |
| echo "Error: '$GIST_DIR_NAME' exists but is not a git repository. Re-run with --force." >&2 | |
| exit 1 | |
| fi | |
| echo "Cloning $GIST_URL into gist/$GIST_DIR_NAME" | |
| gh gist clone "$GIST_ID" "$GIST_DIR_NAME" >/dev/null | |
| done < <(jq -c '.[][] | {id, html_url, description: (.description // ""), fork_of: (.fork_of != null), files: [ .files | to_entries[] | {filename: .value.filename, language: (.value.language // ""), size: .value.size} ] }' <<<"$GIST_JSON") | |
| if [ "$FOUND_COUNT" -eq 0 ]; then | |
| echo "No gists found for user $TARGET_USER." | |
| exit 0 | |
| fi | |
| echo "Found $FOUND_COUNT gists to clone." | |
| echo "Gist cloning process completed." |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment