Skip to content

Commit dbe8dc7

Browse files
alexzhangsclaude
andcommitted
Add hub multi-account utilities, smoke tests, and CI workflow
Three new utilities under functions/hub for transparently running gh / git commands under the right GitHub account when multiple accounts are in use simultaneously: account-for-email Look up a gh account by email via the mapping in env var XSH_GIT_HUB_ACCOUNT_MAP ("<email>=<account> ..." pairs). account-for-repo Derive the account from `git config user.email` in the current repo, delegating to account-for-email. run Run a command with a chosen account active, isolated from every other shell session via per-call GH_CONFIG_DIR copy — the real ~/.config/gh is never mutated, so concurrent sessions are unaffected. Uses @subshell so the trap and exports are scoped to the call. test.sh mirrors xsh-lib/core's smoke-test style (plain bash assertions, no external framework). 13 offline tests + 1 opt-in network test. A small local wrapper re-applies XSH_DEV per call to survive xsh's __xsh_clean RETURN trap. .github/workflows/ci-unittest.yml replaces the dead .travis.yml: matrix on ubuntu-latest + macos-latest, installs xsh, loads xsh-lib/core, then swaps in this checkout so the workflow exercises the commit under test rather than whatever was last pushed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent d643171 commit dbe8dc7

5 files changed

Lines changed: 340 additions & 0 deletions

File tree

.github/workflows/ci-unittest.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: ci-unittest
2+
3+
on: [push, pull_request]
4+
5+
jobs:
6+
test:
7+
runs-on: ${{ matrix.os }}
8+
strategy:
9+
fail-fast: false
10+
matrix:
11+
os: [ubuntu-latest, macos-latest]
12+
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- name: Install xsh
17+
run: |
18+
git clone --depth=50 https://github.com/alexzhangs/xsh.git xsh-source
19+
bash xsh-source/install.sh
20+
21+
- name: Load deps
22+
run: |
23+
# shellcheck disable=SC1090
24+
source ~/.xshrc
25+
xsh load xsh-lib/core
26+
# Load this lib at the same branch we're testing. Falls back to the
27+
# default branch if the branch hasn't been pushed yet (e.g. PR from a
28+
# fork). The local checkout is what test.sh exercises via xsh, so this
29+
# ensures the version under test matches the workflow run.
30+
BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}"
31+
xsh load -b "$BRANCH" xsh-lib/git || xsh load xsh-lib/git
32+
# Swap the loaded copy for this checkout so the workflow tests the
33+
# exact files in this commit, not whatever was last pushed.
34+
REPO_HOME="$HOME/.xsh/repo/xsh-lib/git"
35+
rm -rf "$REPO_HOME"
36+
cp -R "$GITHUB_WORKSPACE" "$REPO_HOME"
37+
38+
- name: Run tests
39+
run: |
40+
# shellcheck disable=SC1090
41+
source ~/.xshrc
42+
xsh version
43+
xsh list
44+
bash test.sh

functions/hub/account-for-email.sh

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#? Description:
2+
#? Print the gh account name mapped to the given email.
3+
#?
4+
#? The mapping is read from the environment variable
5+
#? `XSH_GIT_HUB_ACCOUNT_MAP`, a whitespace-separated list of
6+
#? "<email>=<account>" pairs. Example:
7+
#?
8+
#? export XSH_GIT_HUB_ACCOUNT_MAP="alice@example.com=alice bob@corp.io=bob-corp"
9+
#?
10+
#? This util is the lookup primitive; it does not touch the repo or gh.
11+
#?
12+
#? Usage:
13+
#? @account-for-email <EMAIL>
14+
#?
15+
#? Options:
16+
#? <EMAIL> The email address to look up (typically the value of
17+
#? `git config user.email` inside a repo).
18+
#?
19+
#? Return:
20+
#? 0 on hit, with the account name printed to stdout.
21+
#? 1 on miss, with no output.
22+
#?
23+
#? Example:
24+
#? @account-for-email alice@example.com
25+
#?
26+
function account-for-email () {
27+
declare email=${1:?missing EMAIL}
28+
declare pair k v
29+
for pair in $XSH_GIT_HUB_ACCOUNT_MAP; do
30+
k=${pair%%=*}
31+
v=${pair#*=}
32+
if [[ $k == "$email" ]]; then
33+
printf '%s\n' "$v"
34+
return 0
35+
fi
36+
done
37+
return 1
38+
}

functions/hub/account-for-repo.sh

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#? Description:
2+
#? Print the gh account name that should be used for the current repo.
3+
#?
4+
#? The account is derived from `git config user.email` and looked up via
5+
#? `@account-for-email`. `~/.gitconfig` typically has `includeIf "gitdir:..."`
6+
#? rules that set user.email per directory tree, so this is the
7+
#? authoritative signal — not the globally-active gh account.
8+
#?
9+
#? Dependency:
10+
#? 1. xsh git/hub/account-for-email
11+
#? 2. The env var XSH_GIT_HUB_ACCOUNT_MAP must contain a mapping for the
12+
#? repo's email. See `xsh help /git/hub/account-for-email`.
13+
#?
14+
#? Usage:
15+
#? @account-for-repo
16+
#?
17+
#? Return:
18+
#? 0 on hit, with the account name printed to stdout.
19+
#? 1 if not in a repo, user.email unset, or email not mapped.
20+
#?
21+
#? Example:
22+
#? (inside ~/Workspace/GitHub/your-org/some-repo)
23+
#? @account-for-repo
24+
#?
25+
function account-for-repo () {
26+
declare email account
27+
email=$(git config user.email 2>/dev/null)
28+
if [[ -z $email ]]; then
29+
printf 'account-for-repo: not in a git repo, or user.email unset\n' >&2
30+
return 1
31+
fi
32+
if ! account=$(xsh git/hub/account-for-email "$email"); then
33+
printf 'account-for-repo: no gh account mapped for %s\n' "$email" >&2
34+
printf ' add "<email>=<account>" to XSH_GIT_HUB_ACCOUNT_MAP\n' >&2
35+
return 1
36+
fi
37+
printf '%s\n' "$account"
38+
}

functions/hub/run.sh

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
#? Description:
2+
#? Run a command with a specific gh account active, isolated from every
3+
#? other shell session.
4+
#?
5+
#? The active-account state lives in ~/.config/gh/hosts.yml, which is
6+
#? shared by every terminal and every credential-helper invocation
7+
#? system-wide. Calling `gh auth switch` directly therefore impacts every
8+
#? concurrent session, even momentarily.
9+
#?
10+
#? This util avoids that entirely. For each invocation it:
11+
#? 1. Creates a private mode-700 tempdir.
12+
#? 2. Copies ~/.config/gh into it.
13+
#? 3. Points GH_CONFIG_DIR at the copy.
14+
#? 4. Runs `gh auth switch -u <account>` against the copy.
15+
#? 5. Runs the wrapped command with GH_CONFIG_DIR still pointed at the copy.
16+
#? 6. Deletes the copy on exit.
17+
#?
18+
#? The real ~/.config/gh is never mutated. Concurrent sessions are
19+
#? unaffected. No lockfile is needed because there is no shared resource
20+
#? being contended.
21+
#?
22+
#? Works for `git push/pull/fetch` because gh registers itself as a
23+
#? credential helper that reads GH_CONFIG_DIR from the environment, and
24+
#? child git processes inherit it.
25+
#?
26+
#? Dependency:
27+
#? 1. gh (GitHub CLI) — https://cli.github.com
28+
#? 2. xsh git/hub/account-for-repo (only when -u is omitted)
29+
#?
30+
#? Usage:
31+
#? @run [-u ACCOUNT] -- COMMAND [ARGS...]
32+
#?
33+
#? Options:
34+
#? [-u ACCOUNT] The gh account to activate. If omitted, derived from
35+
#? the current repo via @account-for-repo.
36+
#?
37+
#? -- Mandatory separator before the command to run. Allows
38+
#? the wrapped command to use its own flags without
39+
#? confusing this util's option parser.
40+
#?
41+
#? COMMAND ... The command to run with the chosen account active.
42+
#?
43+
#? Example:
44+
#? @run -- git push origin main
45+
#? @run -- gh pr create --fill
46+
#? @run -u alice-corp -- gh pr list
47+
#?
48+
#? @subshell
49+
#?
50+
function run () {
51+
declare account=""
52+
while [[ $# -gt 0 ]]; do
53+
case "$1" in
54+
-u) account=$2; shift 2 ;;
55+
--) shift; break ;;
56+
-*) printf 'hub-run: unknown option %s\n' "$1" >&2; return 2 ;;
57+
*) break ;;
58+
esac
59+
done
60+
61+
if [[ $# -eq 0 ]]; then
62+
printf 'hub-run: no command given (usage: @run [-u ACCOUNT] -- CMD [ARGS...])\n' >&2
63+
return 2
64+
fi
65+
66+
if [[ -z $account ]]; then
67+
account=$(xsh git/hub/account-for-repo) || return 1
68+
fi
69+
70+
declare src_dir tmpdir
71+
src_dir=${GH_CONFIG_DIR:-$HOME/.config/gh}
72+
if [[ ! -d $src_dir ]]; then
73+
printf 'hub-run: gh config dir not found at %s\n' "$src_dir" >&2
74+
return 1
75+
fi
76+
77+
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/xsh-git-hub-run.XXXXXXXX") || return 1
78+
chmod 700 "$tmpdir"
79+
trap 'rm -rf "$tmpdir"' EXIT
80+
81+
cp -R "$src_dir/." "$tmpdir/" || return 1
82+
export GH_CONFIG_DIR=$tmpdir
83+
84+
if ! gh auth switch -u "$account" >/dev/null 2>&1; then
85+
printf "hub-run: 'gh auth switch -u %s' failed — is the account logged in? (try 'gh auth status')\n" "$account" >&2
86+
return 1
87+
fi
88+
89+
"$@"
90+
}

test.sh

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
#!/bin/bash
2+
#
3+
# Smoke tests for xsh-lib/git utilities. Mirrors xsh-lib/core/test.sh: plain
4+
# bash assertions, no external test framework.
5+
#
6+
# Usage:
7+
# xsh load xsh-lib/git # one-time
8+
# bash test.sh # tests the loaded copy
9+
#
10+
# Local dev iteration against an unpushed working copy:
11+
# xsh lib-dev-manager link xsh-lib/git /path/to/workspace
12+
# XSH_DEV=1 bash test.sh
13+
#
14+
# Tests that touch the network or require an authenticated `gh` are skipped
15+
# unless XSH_GIT_TEST_NETWORK=1 is set explicitly.
16+
#
17+
18+
set -e -o pipefail
19+
20+
# xsh's __xsh_clean unsets XSH_DEV on every RETURN trap, so a script that makes
21+
# multiple xsh calls only gets dev-mode for the first one. Capture the initial
22+
# value once and re-apply it via a wrapper.
23+
_TEST_XSH_DEV="${XSH_DEV-}"
24+
_xsh () { XSH_DEV="$_TEST_XSH_DEV" xsh "$@"; }
25+
26+
27+
echo "==> xsh list /"
28+
_xsh list /
29+
30+
31+
# -----------------------------------------------------------------------------
32+
# git/hub/account-for-email
33+
# -----------------------------------------------------------------------------
34+
35+
echo "==> git/hub/account-for-email (hit on first pair)"
36+
[[ $(XSH_GIT_HUB_ACCOUNT_MAP="a@b=alice c@d=bob" \
37+
_xsh git/hub/account-for-email a@b) == alice ]]
38+
39+
echo "==> git/hub/account-for-email (hit on later pair)"
40+
[[ $(XSH_GIT_HUB_ACCOUNT_MAP="a@b=alice c@d=bob" \
41+
_xsh git/hub/account-for-email c@d) == bob ]]
42+
43+
echo "==> git/hub/account-for-email (miss returns 1)"
44+
rc=0
45+
XSH_GIT_HUB_ACCOUNT_MAP="a@b=alice" \
46+
_xsh git/hub/account-for-email nope@x >/dev/null 2>&1 || rc=$?
47+
[[ $rc -eq 1 ]]
48+
49+
echo "==> git/hub/account-for-email (empty map returns 1)"
50+
rc=0
51+
XSH_GIT_HUB_ACCOUNT_MAP="" \
52+
_xsh git/hub/account-for-email anything@x >/dev/null 2>&1 || rc=$?
53+
[[ $rc -eq 1 ]]
54+
55+
echo "==> git/hub/account-for-email (account value containing '=')"
56+
[[ $(XSH_GIT_HUB_ACCOUNT_MAP="weird@e=acct=with=eq" \
57+
_xsh git/hub/account-for-email weird@e) == "acct=with=eq" ]]
58+
59+
60+
# -----------------------------------------------------------------------------
61+
# git/hub/account-for-repo
62+
# -----------------------------------------------------------------------------
63+
64+
echo "==> git/hub/account-for-repo (derives from local repo's user.email)"
65+
tmprepo=$(mktemp -d "${TMPDIR:-/tmp}/xsh-git-test.XXXXXXXX")
66+
trap 'rm -rf "$tmprepo"' EXIT
67+
(
68+
cd "$tmprepo"
69+
git init -q
70+
git config user.email "a@b"
71+
export XSH_GIT_HUB_ACCOUNT_MAP="a@b=alice"
72+
[[ $(_xsh git/hub/account-for-repo) == alice ]]
73+
)
74+
75+
echo "==> git/hub/account-for-repo (outside repo / no user.email returns 1)"
76+
rc=0
77+
(
78+
cd "${TMPDIR:-/tmp}"
79+
GIT_CONFIG_NOSYSTEM=1 HOME="$tmprepo" \
80+
_xsh git/hub/account-for-repo >/dev/null 2>&1
81+
) || rc=$?
82+
[[ $rc -eq 1 ]]
83+
84+
echo "==> git/hub/account-for-repo (unmapped email returns 1)"
85+
rc=0
86+
(
87+
cd "$tmprepo"
88+
git config user.email "unknown@x"
89+
XSH_GIT_HUB_ACCOUNT_MAP="a@b=alice" \
90+
_xsh git/hub/account-for-repo >/dev/null 2>&1
91+
) || rc=$?
92+
[[ $rc -eq 1 ]]
93+
94+
95+
# -----------------------------------------------------------------------------
96+
# git/hub/run
97+
# -----------------------------------------------------------------------------
98+
99+
echo "==> git/hub/run (no command after -- returns 2)"
100+
rc=0
101+
_xsh git/hub/run -u dummy -- >/dev/null 2>&1 || rc=$?
102+
[[ $rc -eq 2 ]]
103+
104+
echo "==> git/hub/run (unknown option returns 2)"
105+
rc=0
106+
_xsh git/hub/run --bogus -- echo x >/dev/null 2>&1 || rc=$?
107+
[[ $rc -eq 2 ]]
108+
109+
echo "==> git/hub/run (missing gh config dir returns 1)"
110+
rc=0
111+
GH_CONFIG_DIR="${TMPDIR:-/tmp}/xsh-git-no-such-dir-$$" \
112+
_xsh git/hub/run -u dummy -- echo x >/dev/null 2>&1 || rc=$?
113+
[[ $rc -eq 1 ]]
114+
115+
# Happy path needs a logged-in gh account; opt-in only.
116+
if [[ "${XSH_GIT_TEST_NETWORK:-}" == "1" ]] && command -v gh >/dev/null 2>&1; then
117+
echo "==> git/hub/run (happy path: round-trip gh api user)"
118+
acct=$(gh auth status 2>&1 \
119+
| awk '/Logged in to github.com account/{print $7; exit}')
120+
if [[ -n $acct ]]; then
121+
[[ $(_xsh git/hub/run -u "$acct" -- gh api user --jq .login) == "$acct" ]]
122+
else
123+
echo " (skipped: no gh account logged in)"
124+
fi
125+
else
126+
echo "==> git/hub/run (happy path skipped — set XSH_GIT_TEST_NETWORK=1 to enable)"
127+
fi
128+
129+
echo
130+
echo "All tests passed."

0 commit comments

Comments
 (0)