Created
December 31, 2025 03:13
-
-
Save kkharji/e89e6dac908d9584dd2216ca2123c4aa to your computer and use it in GitHub Desktop.
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 | |
| # gh-download: Download a specific file or directory from a GitHub repository | |
| # Usage: gh-download <github-url> [output-path] | |
| readonly SCRIPT_NAME="${0##*/}" | |
| die() { | |
| printf '%s: error: %s\n' "$SCRIPT_NAME" "$1" >&2 | |
| exit 1 | |
| } | |
| usage() { | |
| cat <<EOF | |
| Usage: $SCRIPT_NAME <github-url> [output-path] | |
| Download a specific file or directory from a GitHub repository. | |
| Arguments: | |
| github-url The full URL to the file or directory on GitHub | |
| output-path Optional. The local path to save the download. | |
| If directory (ends in / or exists), downloads into it. | |
| Otherwise, downloads as this path. | |
| Examples: | |
| $SCRIPT_NAME https://github.com/owner/repo/blob/main/path/to/file.txt | |
| $SCRIPT_NAME https://github.com/owner/repo/tree/main/path/to/directory/ | |
| $SCRIPT_NAME https://github.com/owner/repo/tree/main/dir/ local-dir/ | |
| The file or directory will be downloaded to the current working directory if no output path is specified. | |
| EOF | |
| exit "${1:-0}" | |
| } | |
| check_deps() { | |
| local missing=() | |
| for cmd in curl jq; do | |
| command -v "$cmd" &>/dev/null || missing+=("$cmd") | |
| done | |
| [[ ${#missing[@]} -eq 0 ]] || die "missing required commands: ${missing[*]}" | |
| } | |
| parse_github_url() { | |
| local url="$1" | |
| url="${url%/}" | |
| [[ "$url" =~ ^https?://github\.com/ ]] || die "not a valid GitHub URL: $url" | |
| local path="${url#*github.com/}" | |
| if [[ "$path" =~ ^([^/]+)/([^/]+)/(blob|tree)/([^/]+)/(.+)$ ]]; then | |
| OWNER="${BASH_REMATCH[1]}" | |
| REPO="${BASH_REMATCH[2]}" | |
| REF="${BASH_REMATCH[4]}" | |
| TARGET_PATH="${BASH_REMATCH[5]}" | |
| elif [[ "$path" =~ ^([^/]+)/([^/]+)/(blob|tree)/([^/]+)$ ]]; then | |
| OWNER="${BASH_REMATCH[1]}" | |
| REPO="${BASH_REMATCH[2]}" | |
| REF="${BASH_REMATCH[4]}" | |
| TARGET_PATH="" | |
| else | |
| die "unable to parse GitHub URL: $url" | |
| fi | |
| } | |
| download_file() { | |
| local file_path="$1" | |
| local output_path="$2" | |
| local raw_url="https://raw.githubusercontent.com/${OWNER}/${REPO}/${REF}/${file_path}" | |
| local dir="${output_path%/*}" | |
| [[ "$dir" != "$output_path" ]] && mkdir -p "$dir" | |
| printf ' %s\n' "$file_path" | |
| curl -fsSL --retry 3 -o "$output_path" "$raw_url" || die "failed to download: $file_path" | |
| } | |
| # Fetch all files recursively using GitHub Tree API (single API call) | |
| fetch_tree() { | |
| local tree_url="https://api.github.com/repos/${OWNER}/${REPO}/git/trees/${REF}?recursive=1" | |
| curl -fsSL --retry 3 -H "Accept: application/vnd.github.v3+json" "$tree_url" \ | |
| || die "failed to fetch repository tree" | |
| } | |
| check_path_type() { | |
| local api_url="https://api.github.com/repos/${OWNER}/${REPO}/contents/${TARGET_PATH}?ref=${REF}" | |
| local response | |
| response=$(curl -fsSL --retry 3 -H "Accept: application/vnd.github.v3+json" "$api_url" 2>/dev/null) \ | |
| || die "path not found: $TARGET_PATH" | |
| if echo "$response" | jq -e 'type == "array"' &>/dev/null; then | |
| echo "dir" | |
| elif echo "$response" | jq -e '.type == "file"' &>/dev/null; then | |
| echo "file" | |
| else | |
| die "unable to determine type of: $TARGET_PATH" | |
| fi | |
| } | |
| main() { | |
| [[ $# -lt 1 || $# -gt 2 ]] && usage 1 | |
| [[ "$1" == "-h" || "$1" == "--help" ]] && usage 0 | |
| check_deps | |
| parse_github_url "$1" | |
| printf 'Repository: %s/%s @ %s\n' "$OWNER" "$REPO" "$REF" | |
| printf 'Path: %s\n' "${TARGET_PATH:-/}" | |
| local default_name | |
| if [[ -n "$TARGET_PATH" ]]; then | |
| default_name="${TARGET_PATH##*/}" | |
| else | |
| default_name="$REPO" | |
| fi | |
| local output_name | |
| if [[ -n "${2:-}" ]]; then | |
| output_name="$2" | |
| # If output_name is a directory or ends with /, append default_name | |
| if [[ -d "$output_name" || "$output_name" == */ ]]; then | |
| # Create directory if it ends with / and doesn't exist | |
| [[ ! -d "$output_name" && "$output_name" == */ ]] && mkdir -p "$output_name" | |
| output_name="${output_name%/}/$default_name" | |
| fi | |
| else | |
| output_name="$default_name" | |
| fi | |
| local path_type | |
| path_type=$(check_path_type) | |
| case "$path_type" in | |
| file) | |
| local dir="${output_name%/*}" | |
| [[ "$dir" != "$output_name" ]] && mkdir -p "$dir" | |
| [[ -f "$output_name" ]] && rm -f "$output_name" | |
| printf 'Downloading file to %s:\n' "$output_name" | |
| download_file "$TARGET_PATH" "$output_name" | |
| ;; | |
| dir) | |
| if [[ -d "$output_name" ]]; then | |
| printf 'Removing existing directory: %s\n' "$output_name" | |
| rm -rf "$output_name" | |
| fi | |
| mkdir -p "$output_name" | |
| printf 'Fetching tree...\n' | |
| local tree | |
| tree=$(fetch_tree) | |
| # Filter files under TARGET_PATH and download them | |
| local prefix="${TARGET_PATH}/" | |
| local files | |
| files=$(echo "$tree" | jq -r --arg prefix "$prefix" \ | |
| '.tree[] | select(.type == "blob" and (.path | startswith($prefix))) | .path') | |
| if [[ -z "$files" ]]; then | |
| die "no files found under: $TARGET_PATH" | |
| fi | |
| printf 'Downloading files to %s:\n' "$output_name" | |
| while IFS= read -r file_path; do | |
| local rel_path="${file_path#"$TARGET_PATH/"}" | |
| local output_path="${output_name}/${rel_path}" | |
| download_file "$file_path" "$output_path" | |
| done <<< "$files" | |
| ;; | |
| esac | |
| printf 'Done: %s\n' "$output_name" | |
| } | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment