#!/usr/bin/env bash # git-credential-found: a Git credential helper that finds which of your # GitHub CLI accounts can actually access the repo you're pushing to or # pulling from, so you stop seeing "remote: Repository not found." # # https://github.com/advaith334/git-found # MIT License set -u VERSION="0.2.1" CACHE_TTL="${GIT_FOUND_CACHE_TTL:-86400}" CACHE_FILE="${XDG_CACHE_HOME:-$HOME/.cache}/git-found/accounts" GH_CONFIG="${GH_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/gh}" # GUI Git clients often run helpers with a minimal PATH that misses Homebrew. command -v gh >/dev/null 2>&1 || PATH="$PATH:/opt/homebrew/bin:/usr/local/bin:/home/linuxbrew/.linuxbrew/bin" log() { [ -n "${GIT_FOUND_DEBUG:-}" ] && echo "git-found: $*" >&2; return 0; } warn() { echo "git-found: $*" >&2; } die() { warn "$*"; exit 1; } # Run gh without any token from the environment, so it reads its own keyring. gh_clean() { env -u GH_TOKEN -u GITHUB_TOKEN -u GH_ENTERPRISE_TOKEN -u GITHUB_ENTERPRISE_TOKEN gh "$@"; } # Every host gh is signed in to, one per line. gh_hosts() { [ -f "$GH_CONFIG/hosts.yml" ] || return 1 awk '/^[^ #][^:]*:/ { h = $0; sub(/:.*/, "", h); print h }' "$GH_CONFIG/hosts.yml" } # Print "login active" for every gh account on HOST. Reads gh's hosts.yml # directly because `gh auth status` validates every token over the network. accounts() { local host="$1" out="" if [ -f "$GH_CONFIG/hosts.yml" ]; then out=$(awk -v h="$host" ' { match($0, /^ */); ind = RLENGTH; line = substr($0, ind + 1) } line == "" || line ~ /^#/ { next } ind == 0 { cur = line; sub(/:.*/, "", cur); uind = -1; next } cur != h { next } uind >= 0 && ind > uind { if (eind < 0) eind = ind if (ind == eind) { u = line; sub(/:.*/, "", u); users[++n] = u } next } { uind = -1 } line ~ /^users:/ { uind = ind; eind = -1; next } line ~ /^user:/ { active = line; sub(/^user:[ ]*/, "", active) } END { for (i = 1; i <= n; i++) print users[i], (users[i] == active ? "true" : "false") } ' "$GH_CONFIG/hosts.yml") fi if [ -z "$out" ]; then out=$(gh_clean auth status --hostname "$host" --json hosts \ --jq '.hosts[][] | select(.state == "success") | "\(.login) \(.active)"' 2>/dev/null) fi [ -n "$out" ] && echo "$out" } token_for() { gh_clean auth token --hostname "$1" --user "$2" 2>/dev/null; } # probe HOST REPO TOKEN prints what the token can do on the repo: # push | pull | none | badtoken | sso probe() { local out sso out=$(GH_TOKEN="$3" GH_ENTERPRISE_TOKEN="$3" gh api -i --hostname "$1" "repos/$2" \ --jq '.permissions.push' 2>/dev/null) case "$(echo "$out" | awk 'NR == 1 { print $2 }')" in 200) [ "$(echo "$out" | tail -n 1)" = true ] && echo push || echo pull ;; 401) echo badtoken ;; 403) # Orgs with SAML single sign-on refuse tokens that weren't approved for them. sso=$(echo "$out" | tr -d '\r' | awk 'tolower($1) == "x-github-sso:" && $2 == "required;" { sub(/^url=/, "", $3); print $3; exit }') if [ -n "$sso" ]; then echo "sso $sso"; else echo none; fi ;; *) echo none ;; esac } # "owner/repo.git/..." -> "owner/repo" repo_from_path() { local p="${1#/}" owner rest name owner="${p%%/*}" rest="${p#*/}" [ "$owner" = "$p" ] && return 1 name="${rest%%/*}" name="${name%.git}" [ -n "$owner" ] && [ -n "$name" ] || return 1 echo "$owner/$name" } # "https://github.com/owner/repo(.git)", an SSH URL or "owner/repo" -> "host repo" parse_url() { local u="${1%/}" host="github.com" u="${u#https://}" u="${u#http://}" u="${u#ssh://}" # Drop "git@" or "user:token@" before the host. case "${u%%/*}" in *@*) u="${u#*@}" ;; esac case "$u" in *:*/*) host="${u%%:*}"; u="${u#*:}" ;; */*/*) host="${u%%/*}"; u="${u#*/}" ;; esac echo "$host $(repo_from_path "$u")" } lower() { echo "$1" | tr '[:upper:]' '[:lower:]'; } cache_get() { [ -f "$CACHE_FILE" ] || return 1 awk -v k="$1" -v now="$(date +%s)" -v ttl="$CACHE_TTL" \ '$1 == k && now - $3 < ttl { print $2; found = 1 } END { exit !found }' "$CACHE_FILE" } cache_del() { [ -f "$CACHE_FILE" ] || return 0 awk -v k="$1" '$1 != k' "$CACHE_FILE" > "$CACHE_FILE.$$" && mv "$CACHE_FILE.$$" "$CACHE_FILE" } cache_set() { mkdir -p "$(dirname "$CACHE_FILE")" || return 0 { [ -f "$CACHE_FILE" ] && awk -v k="$1" '$1 != k' "$CACHE_FILE"; echo "$1 $2 $(date +%s)"; } \ > "$CACHE_FILE.$$" && mv "$CACHE_FILE.$$" "$CACHE_FILE" } # Map a name to the exact login of a gh account on this host, or fail. match_login() { [ -n "$2" ] || return 1 echo "$1" | awk -v c="$(lower "$2")" 'tolower($1) == c { print $1; found = 1; exit } END { exit !found }' } # pinned_account HOST REPO prints the current repo's `found.account`, but only # when one of its remotes points at HOST/REPO (so clones from inside it don't use it). pinned_account() { local pin want url h r pin=$(git config --get found.account 2>/dev/null) || return 0 want="$(lower "$1/$2")" git config --get-regexp '^remote\..*\.url$' 2>/dev/null | while read -r _ url; do read -r h r < $login ($source)" FOUND_LOGIN="$login" FOUND_TOKEN="$tok" FOUND_PERM="$source" return 0 fi if [ "$source" = pinned ]; then warn "found.account is $pref, but gh has no account named $pref on $host; ignoring it." else warn "the URL asks for $pref, but gh has no account named $pref on $host; ignoring it." fi fi if [ -z "$repo" ]; then log "no repo path from git (set credential.useHttpPath=true); using active account" else key="$(lower "$host/$repo")" if cached=$(cache_get "$key") && login=$(match_login "$all" "$cached") && tok=$(token_for "$host" "$login"); then log "$repo -> $login (cached)" FOUND_LOGIN="$login" FOUND_TOKEN="$tok" FOUND_PERM="cached" return 0 fi # Try the repo owner's account first, then the active one, then the rest. for c in "${repo%%/*}" "$active" $(echo "$all" | awk '{ print $1 }'); do login=$(match_login "$all" "$c") || continue case " $tried " in *" $login "*) continue ;; esac tried="$tried $login" tok=$(token_for "$host" "$login") || continue res=$(probe "$host" "$repo" "$tok") perm="${res%% *}" log "$repo as $login: $perm" case "$perm" in push) FOUND_LOGIN="$login" FOUND_TOKEN="$tok" FOUND_PERM="push" cache_set "$key" "$login" return 0 ;; pull) [ -z "$pull_login" ] && pull_login="$login" pull_tok="$tok" ;; sso) [ -z "$sso_login" ] && sso_login="$login" sso_tok="$tok" sso_url="${res#* }" ;; badtoken) expired="$expired $login" ;; esac done if [ -n "$pull_login" ]; then FOUND_LOGIN="$pull_login" FOUND_TOKEN="$pull_tok" FOUND_PERM="pull" cache_set "$key" "$pull_login" return 0 fi # Hand Git the right account anyway, so its own error names the SSO problem too. if [ -n "$sso_login" ]; then warn "$sso_login can reach $repo, but its token isn't approved for the org's single sign-on." warn "approve it here, then try again: $sso_url" FOUND_LOGIN="$sso_login" FOUND_TOKEN="$sso_tok" FOUND_PERM="sso" return 0 fi warn "none of your gh accounts on $host ($(echo $tried | sed 's/ /, /g')) can see $repo." for login in $expired; do warn "the saved login for $login has expired; sign in again with: gh auth login --hostname $host" done warn "check the URL, or add an account that has access with: gh auth login --hostname $host" fi # Fall back to the active account, same as `gh auth git-credential`. [ -n "$active" ] && tok=$(token_for "$host" "$active") || return 1 FOUND_LOGIN="$active" FOUND_TOKEN="$tok" FOUND_PERM="fallback" } cmd_get() { local line protocol="" host="" path="" username="" repo="" pin="" while IFS= read -r line || [ -n "$line" ]; do [ -z "$line" ] && break case "$line" in protocol=*) protocol="${line#protocol=}" ;; host=*) host="${line#host=}" ;; path=*) path="${line#path=}" ;; username=*) username="${line#username=}" ;; esac done [ "$protocol" = https ] && [ -n "$host" ] || exit 0 [ -n "$path" ] && repo=$(repo_from_path "$path") if [ -n "$username" ]; then resolve "$host" "$repo" "$username" url || exit 0 else [ -n "$repo" ] && pin=$(pinned_account "$host" "$repo") resolve "$host" "$repo" "$pin" pinned || exit 0 fi printf 'username=%s\npassword=%s\n' "$FOUND_LOGIN" "$FOUND_TOKEN" } # Git calls erase when a credential is rejected; forget that repo's account. cmd_erase() { local line host="" path="" repo while IFS= read -r line || [ -n "$line" ]; do [ -z "$line" ] && break case "$line" in host=*) host="${line#host=}" ;; path=*) path="${line#path=}" ;; esac done [ -n "$host" ] && [ -n "$path" ] && repo=$(repo_from_path "$path") && cache_del "$(lower "$host/$repo")" return 0 } # The URL to check: the argument, or this repo's origin. target_url() { local url if [ -n "${1:-}" ]; then echo "$1"; return 0; fi url=$(git ls-remote --get-url origin 2>/dev/null) [ -n "$url" ] && [ "$url" != origin ] && echo "$url" } cmd_which() { local url host repo url=$(target_url "${1:-}") || die "usage: git-credential-found which (or run it inside a repo)" read -r host repo < $FOUND_LOGIN ($FOUND_PERM)" } cmd_forget() { local host repo if [ $# -eq 0 ]; then rm -f "$CACHE_FILE" echo "git-found: cleared all remembered accounts" else read -r host repo < (or run it inside a repo)" read -r host repo </dev/null || { bad "the GitHub CLI (gh) isn't installed"; note "fix: https://cli.github.com"; } if all=$(accounts "$host"); then for login in $(echo "$all" | awk '{ print $1 }'); do if ! tok=$(token_for "$host" "$login"); then bad "$login: gh has no saved token" note "fix: gh auth login --hostname $host" continue fi res=$(probe "$host" "$repo" "$tok") case "${res%% *}" in push) ok "$login can push"; reachable=1 ;; pull) ok "$login can read, but not push"; reachable=1 ;; none) note "· $login can't see this repo" ;; sso) bad "$login has access, but its token isn't approved for the org's single sign-on" note "fix: approve it at ${res#* }"; reachable=1 ;; badtoken) bad "$login's saved login has expired or was revoked" note "fix: gh auth login --hostname $host" ;; esac done [ -n "$reachable" ] || { bad "none of your accounts can see $repo" note "check the URL, or sign in with an account that has access: gh auth login --hostname $host" } else bad "gh isn't signed in to any account on $host" note "fix: gh auth login --hostname $host" fi pin=$(pinned_account "$host" "$repo") if [ -n "$pin" ]; then if match_login "${all:-}" "$pin" >/dev/null; then ok "this repo is pinned to $pin (found.account)" else bad "found.account is $pin, but gh has no account named $pin on $host" note "fix: git config found.account , or git config --unset found.account" fi fi cached=$(cache_get "$(lower "$host/$repo")") && note "remembered account: $cached" echo echo "Git config" if [ "$(git config --get-urlmatch credential.useHttpPath "$fixurl" 2>/dev/null)" = true ]; then ok "useHttpPath is on, so git-found can see which repo Git wants" else bad "useHttpPath is off, so git-found can't tell repos apart" note "fix: git-credential-found setup $host" fi # Ask Git for a login exactly as a push would, and compare with git-found's pick. resolve "$host" "$repo" "$pin" pinned 2>/dev/null filled=$(printf 'protocol=https\nhost=%s\npath=%s.git\n\n' "$host" "$repo" \ | env -u GIT_ASKPASS -u SSH_ASKPASS GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=never \ git -c core.askPass= credential fill 2>/dev/null | sed -n 's/^username=//p') if [ -n "$FOUND_LOGIN" ] && [ "$filled" = "$FOUND_LOGIN" ]; then ok "Git asks git-found, which picks $FOUND_LOGIN ($FOUND_PERM)" else if [ -z "$filled" ]; then bad "Git got no login for $host from any credential helper" else bad "another credential helper answers first: Git got $filled, git-found would pick ${FOUND_LOGIN:-nobody}" fi helpers=$(git config --show-origin --get-regexp '^credential\..*helper$' 2>/dev/null) [ -n "$helpers" ] && note "your credential helpers:" && echo "$helpers" | sed 's/^/ /' note "fix: git-credential-found setup $host" fi echo if [ "$PROBLEMS" -eq 0 ]; then echo "No problems found." else echo "$PROBLEMS problem(s) found." return 1 fi } # Hosts whose global Git config points at git-found. found_hosts() { git config --global --get-regexp '^credential\..*\.helper$' 2>/dev/null \ | awk '$2 ~ /(^|\/)git-credential-found$/ || $2 == "found" { k = $1; sub(/^credential\.https?:\/\//, "", k); sub(/\.helper$/, "", k); print k }' \ | sort -u } cmd_setup() { local hosts="$*" host self helper=found command -v gh >/dev/null || die "the GitHub CLI (gh) is required: https://cli.github.com" [ -n "$hosts" ] || hosts=$(gh_hosts) [ -n "$hosts" ] || hosts=github.com # Point Git at this file directly so GUI clients with a short PATH find it too. # Paths with spaces can't be used as a helper, so fall back to "found" on PATH. self="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" case "$self" in *" "*) command -v git-credential-found >/dev/null \ || die "move git-credential-found to a folder on your PATH without spaces, then rerun setup" ;; *) helper="$self" ;; esac for host in $hosts; do accounts "$host" >/dev/null || warn "no gh accounts on $host yet; add them with: gh auth login --hostname $host" git config --global --unset-all "credential.https://$host.helper" 2>/dev/null git config --global --add "credential.https://$host.helper" "" git config --global --add "credential.https://$host.helper" "$helper" git config --global "credential.https://$host.useHttpPath" true echo "git-found: Git now picks the right gh account for every repo on $host." done echo "git-found: try it with: git-credential-found which " } cmd_uninstall() { local hosts="$*" host [ -n "$hosts" ] || hosts=$(found_hosts) [ -n "$hosts" ] || { echo "git-found: not set up for any host"; return 0; } for host in $hosts; do git config --global --unset-all "credential.https://$host.helper" 2>/dev/null git config --global --unset "credential.https://$host.useHttpPath" 2>/dev/null echo "git-found: removed from $host. To go back to plain gh, run: gh auth setup-git --hostname $host" done rm -f "$CACHE_FILE" } usage() { cat < Git calls this as a credential helper with get, store and erase. Set GIT_FOUND_DEBUG=1 to see each decision. EOF } case "${1:-}" in get) cmd_get ;; store) exit 0 ;; erase) cmd_erase ;; setup|install) shift; cmd_setup "$@" ;; which) shift; cmd_which "$@" ;; doctor) shift; cmd_doctor "$@" ;; forget) shift; cmd_forget "$@" ;; uninstall) shift; cmd_uninstall "$@" ;; version|--version|-v) echo "git-found $VERSION" ;; help|--help|-h|"") usage ;; *) usage >&2; exit 1 ;; esac