Skip to content

Instantly share code, notes, and snippets.

@kkharji
Created December 31, 2025 03:13
Show Gist options
  • Select an option

  • Save kkharji/e89e6dac908d9584dd2216ca2123c4aa to your computer and use it in GitHub Desktop.

Select an option

Save kkharji/e89e6dac908d9584dd2216ca2123c4aa to your computer and use it in GitHub Desktop.
#!/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