Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:
fi

- name: shellcheck
run: shellcheck -s bash install.sh gh/gh claude/build-settings
run: shellcheck -s bash install.sh gh/gh git/credential-gh-account claude/build-settings

- name: The shells parse it
run: |
Expand Down
64 changes: 64 additions & 0 deletions git/credential-gh-account
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# git credential helper that resolves a GitHub account per repository, the way the gh wrapper
# next door already does: the repo's `gh.account`, or a username git carries in the remote URL.
#
# It exists because `gh auth git-credential` -- the helper `gh auth setup-git` installs -- answers
# only for whichever account is currently active. With one account that is invisible; with two it
# is wrong half the time, and wrong in the worst way: a read as the other account returns 404, not
# 403, so the failure looks like the repository not existing rather than the wrong identity.
#
# Resolution is read-only, via `gh auth token --user`, and never `gh auth switch`. Switching
# mutates the shared active account in hosts.yml, so two sessions working in repos belonging to
# different accounts flip it under each other mid-request. Same reasoning as gh/gh.

set -uo pipefail

die() {
printf 'credential-gh-account: %s\n' "$@" >&2
exit 1
}

# store and erase have nothing to do: the tokens live in gh's hosts.yml and gh owns their
# lifecycle. Anything other than `get` succeeds silently rather than erroring, because git
# calls all three and a helper that fails on store turns every push into a warning.
[[ "${1:-}" == get ]] || exit 0

declare -A req=()
while IFS='=' read -r key value; do
[[ -z "$key" ]] && break
req["$key"]="$value"
done

# Scoped deliberately. A helper that answered for every host would shadow whatever the box uses
# for GitLab or a private forge, and it has no tokens for those to offer in exchange.
[[ "${req[protocol]:-}" == https && "${req[host]:-}" == github.com ]] || exit 0

# gh may not be on PATH: git runs credential helpers with the environment of whatever invoked
# git, which for an editor or a scheduled job is not a login shell that sourced zshrc.
GH_BINARY="$(command -v gh 2>/dev/null)"
if [[ -z "$GH_BINARY" ]]; then
for candidate in /opt/homebrew/bin/gh /usr/local/bin/gh /home/linuxbrew/.linuxbrew/bin/gh; do
[[ -x "$candidate" ]] && GH_BINARY="$candidate" && break
done
fi
[[ -n "$GH_BINARY" ]] || die "gh was not found, so no token can be resolved for github.com." \
" Install it: brew install gh"

# A username in the request wins: it came from the remote URL or an explicit credential.username,
# both of which are more specific than a repo-wide default. This is also what makes the first
# clone work, before there is a repo in which to set gh.account -- clone
# https://ldayton@github.com/... and the account comes along in the URL.
account="${req[username]:-}"
[[ -n "$account" ]] || account="$(git config --get gh.account 2>/dev/null)"
[[ -n "$account" ]] || die "no gh.account set for this repository, and no username in the URL." \
" Set one: git config gh.account <github-login>" \
" Or clone: git clone https://<github-login>@github.com/owner/repo.git"

# GH_WRAPPER_DISABLE because gh on PATH is this repo's wrapper, and its policy hook has no
# business adjudicating a credential lookup that git is blocking on.
token="$(GH_WRAPPER_DISABLE=1 "$GH_BINARY" auth token --user "$account" 2>/dev/null)"
[[ -n "$token" ]] || die "gh.account=$account, but gh has no token stored for that account." \
" Log in: gh auth login (choose $account)" \
" Check: gh auth status"

printf 'username=%s\npassword=%s\n' "$account" "$token"
12 changes: 12 additions & 0 deletions git/gitconfig
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,15 @@
smudge = git-lfs smudge -- %f
process = git-lfs filter-process
required = true
# GitHub authentication goes through gh's stored tokens rather than an ssh key. A key can be
# registered on exactly one GitHub account, so two accounts on one machine cannot both use one;
# tokens have no such limit, and `gh auth login` on a fresh box replaces copying key material
# onto it. See git/credential-gh-account for how the account is chosen.
#
# The empty helper first is a reset, not an accident: helpers accumulate across system, global
# and local config and are tried in order, so without it a system helper (osxkeychain, or a
# leftover `gh auth setup-git` line) answers first and wins with the wrong account. Scoped to
# github.com, so helpers for every other host survive it.
[credential "https://github.com"]
helper =
helper = "!\"$HOME/source/dotfiles/git/credential-gh-account\""
Loading