Skip to content

Commit 97a20fb

Browse files
alexzhangsclaude
andcommitted
BREAKING: replace XSH_GIT_HUB_ACCOUNT_MAP with XSH_GIT_HUB_ACCOUNTS; add hub/ssh
The env var schema changes from a flat email=account map to a list of account profile records, each describing one gh account: <account> : <email> : <org>[,<org>...] This unifies what were two conceptually-coupled mappings (email->account for identity, org->account for SSH routing) into one record-per-account table that's plug-and-play per org file. There is no compatibility shim: 0.3.0 reads XSH_GIT_HUB_ACCOUNTS, not the previous variable. Update the shell rc files seeding the var when bumping to this release. New utilities: hub/account-for-org New sibling of account-for-email; returns the account that defaults for a given GitHub org. First-match-wins for orgs claimed by more than one record. hub/ssh New script (under scripts/, symlinked into /usr/local/bin/git-hub-ssh on xsh imports). Designed for core.sshCommand: parses the org from git's git-{upload,receive}-pack arg, looks it up in XSH_GIT_HUB_ACCOUNTS, exec's ssh with -i ~/.ssh/github-<account> and IdentitiesOnly=yes. Falls through to plain ssh for non-github hosts and unmapped orgs. The router lets bare git@github.com:<org>/<repo>.git URLs work directly without needing url.<base>.insteadOf rewrites in gitconfig — cloned remote stays as the natural bare URL. Tests cover the new record format, the new utilities, and the fake-ssh-on-PATH harness for git/hub/ssh. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 4020b6f commit 97a20fb

6 files changed

Lines changed: 365 additions & 55 deletions

File tree

README.md

Lines changed: 63 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ This project is still at version `0.x` and should be considered immature.
3535
xsh load xsh-lib/core
3636
```
3737

38-
2. Some utilities have additional dependencies (e.g. `gh`, `w3m`,
38+
2. Some utilities have additional dependencies (e.g. `gh`, `ssh`, `w3m`,
3939
`collaborator`). See the per-utility help for details.
4040

4141

@@ -75,43 +75,87 @@ xsh help git/<package>/<util>
7575

7676
| Utility | Kind | Purpose |
7777
|-------------------------------|----------|------------------------------------------------------------------------------------------------------------------|
78-
| `git/hub/account-for-email` | function | Look up a `gh` account name for an email via `XSH_GIT_HUB_ACCOUNT_MAP`. |
79-
| `git/hub/account-for-repo` | function | Derive the `gh` account for the current repo from `git config user.email`, using the same mapping. |
78+
| `git/hub/account-for-email` | function | Walk `XSH_GIT_HUB_ACCOUNTS`, return the account whose email matches. |
79+
| `git/hub/account-for-org` | function | Walk `XSH_GIT_HUB_ACCOUNTS`, return the account that defaults for the given GitHub org. First-match-wins. |
80+
| `git/hub/account-for-repo` | function | Derive the account from `git config user.email` in the current repo (delegates to `account-for-email`). |
8081
| `git/hub/run` | function | Run a command with a chosen `gh` account active, isolated from concurrent shell sessions via per-call `GH_CONFIG_DIR`. |
82+
| `git/hub/ssh` | script | SSH wrapper for `core.sshCommand`: picks the right per-account key from the repo owner in git's command line. |
8183
| `git/hub/collaborator` | function | Add, remove, or list collaborators of the current GitHub repo. Requires `collaborator` and `w3m`. |
8284
| `git/rebase-i-in-dumb-term` | script | Helper for running `git rebase -i` in dumb terminals. |
8385

8486

87+
### The account profile env var: `XSH_GIT_HUB_ACCOUNTS`
88+
89+
The `git/hub/*` utilities read a single env var that describes every gh
90+
account you operate. Whitespace-separated records, each with three
91+
colon-separated fields:
92+
93+
```
94+
<account> : <email> : <org>[,<org>...]
95+
```
96+
97+
| Field | Cardinality | Meaning |
98+
|---|---|---|
99+
| `account` | required | gh account name; also the suffix in `~/.ssh/github-<account>`. |
100+
| `email` | 0..1 | Email tied to this account by `~/.gitconfig` `includeIf` rules. Empty for bot-style accounts with no per-directory binding. |
101+
| `orgs` | 0..N | Comma-separated GitHub orgs this account defaults for. Read by `account-for-org` and `git/hub/ssh`. |
102+
103+
Example:
104+
105+
```bash
106+
export XSH_GIT_HUB_ACCOUNTS="alice:alice@example.com:alice,xsh-alice bob:bob@corp.io:bob-corp"
107+
```
108+
109+
If two records list the same org, **first match wins** — the earlier
110+
record's account becomes the default for that org. The other account is
111+
still reachable via the explicit SSH alias URL
112+
`git@github-<account>:<org>/<repo>.git`.
113+
114+
85115
### Multi-`gh`-account workflow
86116

87117
If you operate multiple GitHub accounts simultaneously (e.g. personal +
88118
work) and rely on `~/.gitconfig`'s `includeIf "gitdir:..."` rules to switch
89-
identities per directory tree, the `git/hub/*` utilities make it transparent
90-
to push/PR/etc. against the right account without ever mutating the global
119+
identities per directory tree, this library makes it transparent to push,
120+
PR, and clone against the right account without ever mutating the global
91121
active account in `~/.config/gh`.
92122

93-
1. Map your emails to `gh` account names via an env var:
94-
95-
```bash
96-
export XSH_GIT_HUB_ACCOUNT_MAP="alice@personal.com=alice alice@corp.io=alice-corp"
97-
```
123+
1. Map your accounts via the env var described above.
98124

99-
2. Use `git/hub/run` as a transparent wrapper around any `git` or `gh`
100-
command. The account is auto-derived from the current repo's
101-
`user.email`:
125+
2. Use `git/hub/run` as a transparent wrapper around any `gh` command
126+
(account auto-derived from the current repo's `user.email`):
102127

103128
```bash
104-
xsh git/hub/run -- git push origin main
105129
xsh git/hub/run -- gh pr create --fill
106130
xsh git/hub/run -u alice-corp -- gh pr list # explicit override
107131
```
108132

109133
Each call snapshots `~/.config/gh` to a private mode-700 tempdir,
110-
`gh auth switch -u <account>` runs against the **copy**, and
111-
`GH_CONFIG_DIR` is exported only for the wrapped command. The real
112-
config is never mutated, so other shell sessions and credential-helper
113-
invocations are unaffected — even when several `git/hub/run` calls are
114-
in flight at the same time.
134+
runs `gh auth switch -u <account>` against the **copy**, and exports
135+
`GH_CONFIG_DIR` only for the wrapped command. The real config is never
136+
mutated, so other shell sessions and credential-helper invocations are
137+
unaffected — even when several `git/hub/run` calls are in flight at the
138+
same time.
139+
140+
3. Wire `git/hub/ssh` into git for transparent SSH routing on bare
141+
`git@github.com:<org>/<repo>.git` URLs:
142+
143+
```bash
144+
xsh imports git/hub/ssh
145+
git config --global core.sshCommand git-hub-ssh
146+
```
147+
148+
Now `git clone git@github.com:<org>/<repo>.git` (or `gh repo clone`,
149+
or the web "Clone with SSH" copy-paste) auto-resolves to the right
150+
`~/.ssh/github-<account>` key — without rewriting the URL stored in
151+
the cloned `origin`. SSH key selection happens in the wrapper based on
152+
the org parsed from git's `git-{upload,receive}-pack` command line.
153+
154+
The wrapper falls through to plain `ssh` for any host that isn't
155+
`git@github.com` (CodeCommit, EC2, the per-account `github-<name>`
156+
aliases, etc.) and for any org that isn't in
157+
`XSH_GIT_HUB_ACCOUNTS` — letting your existing SSH config handle
158+
those cases unchanged.
115159

116160

117161
## Development

functions/hub/account-for-email.sh

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
11
#? Description:
2-
#? Print the gh account name mapped to the given email.
2+
#? Print the gh account name associated with the given email.
33
#?
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:
4+
#? The data is read from the environment variable `XSH_GIT_HUB_ACCOUNTS`,
5+
#? a whitespace-separated list of records, each in the format:
76
#?
8-
#? export XSH_GIT_HUB_ACCOUNT_MAP="alice@example.com=alice bob@corp.io=bob-corp"
7+
#? <account>:<email>:<org>[,<org>...]
98
#?
10-
#? This util is the lookup primitive; it does not touch the repo or gh.
9+
#? - <account> gh account name; required.
10+
#? - <email> email tied to this account via ~/.gitconfig includeIf
11+
#? rules. May be empty for bot-style accounts with no
12+
#? per-directory email binding.
13+
#? - <org> GitHub org for which this account is the default. Zero
14+
#? or more, comma-separated. Used by `account-for-org` and
15+
#? `git/hub/ssh`; ignored here.
16+
#?
17+
#? Example:
18+
#?
19+
#? export XSH_GIT_HUB_ACCOUNTS="alice:alice@example.com:alice,xsh-alice bob:bob@corp.io:bob-corp"
20+
#?
21+
#? This util is the email-lookup primitive; it does not touch the repo
22+
#? or gh. Records whose email field is empty are skipped.
1123
#?
1224
#? Usage:
1325
#? @account-for-email <EMAIL>
@@ -25,12 +37,11 @@
2537
#?
2638
function account-for-email () {
2739
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"
40+
declare record r_account r_email
41+
for record in $XSH_GIT_HUB_ACCOUNTS; do
42+
IFS=':' read -r r_account r_email _ <<< "$record"
43+
if [[ -n $r_email && $r_email == "$email" ]]; then
44+
printf '%s\n' "$r_account"
3445
return 0
3546
fi
3647
done

functions/hub/account-for-org.sh

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#? Description:
2+
#? Print the gh account name that defaults for the given GitHub org.
3+
#?
4+
#? The data is read from the environment variable `XSH_GIT_HUB_ACCOUNTS`,
5+
#? a whitespace-separated list of records, each in the format:
6+
#?
7+
#? <account>:<email>:<org>[,<org>...]
8+
#?
9+
#? For details on the record format, see `xsh help git/hub/account-for-email`.
10+
#?
11+
#? First match wins: if two records both list <ORG> in their orgs field,
12+
#? the one earlier in XSH_GIT_HUB_ACCOUNTS is returned. The other account
13+
#? stays reachable via the explicit SSH alias URL
14+
#? `git@github-<account>:<ORG>/<repo>.git`.
15+
#?
16+
#? This util is the org-lookup primitive; it does not touch the repo or
17+
#? gh. Records whose orgs field is empty are skipped.
18+
#?
19+
#? Usage:
20+
#? @account-for-org <ORG>
21+
#?
22+
#? Options:
23+
#? <ORG> The GitHub org name (e.g. `HyperSolidAPP`).
24+
#?
25+
#? Return:
26+
#? 0 on hit, with the account name printed to stdout.
27+
#? 1 on miss, with no output.
28+
#?
29+
#? Example:
30+
#? @account-for-org HyperSolidAPP
31+
#?
32+
function account-for-org () {
33+
declare org=${1:?missing ORG}
34+
declare record r_account r_orgs_csv o
35+
for record in $XSH_GIT_HUB_ACCOUNTS; do
36+
IFS=':' read -r r_account _ r_orgs_csv <<< "$record"
37+
[[ -z $r_orgs_csv ]] && continue
38+
IFS=',' read -ra r_orgs <<< "$r_orgs_csv"
39+
for o in "${r_orgs[@]}"; do
40+
if [[ $o == "$org" ]]; then
41+
printf '%s\n' "$r_account"
42+
return 0
43+
fi
44+
done
45+
done
46+
return 1
47+
}

functions/hub/account-for-repo.sh

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
#?
99
#? Dependency:
1010
#? 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`.
11+
#? 2. The env var XSH_GIT_HUB_ACCOUNTS must contain a record for the
12+
#? repo's email. See `xsh help git/hub/account-for-email`.
1313
#?
1414
#? Usage:
1515
#? @account-for-repo
@@ -31,7 +31,7 @@ function account-for-repo () {
3131
fi
3232
if ! account=$(xsh git/hub/account-for-email "$email"); then
3333
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
34+
printf ' add a record with this email to XSH_GIT_HUB_ACCOUNTS\n' >&2
3535
return 1
3636
fi
3737
printf '%s\n' "$account"

scripts/hub/ssh.sh

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
#!/bin/bash
2+
#? Description:
3+
#? SSH wrapper that picks the right per-account key from the repo owner
4+
#? in the git command line. Invoked by git via core.sshCommand.
5+
#?
6+
#? For each invocation, the script inspects argv to determine:
7+
#? 1. Is this an SSH to git@github.com? If not, fall through to plain
8+
#? ssh unchanged. This preserves connections to other hosts
9+
#? (CodeCommit, EC2, the per-account github-<name> aliases, etc.)
10+
#? 2. Does the git-{upload,receive}-pack command name an org we have
11+
#? a default account for in $XSH_GIT_HUB_ACCOUNTS? If not, fall
12+
#? through unchanged — the bare github.com URL will then hit the
13+
#? loud-fail Host github.com block in ~/.ssh/config, surfacing the
14+
#? missing routing entry as Permission denied instead of as a
15+
#? silent wrong-account auth.
16+
#? 3. If yes, exec ssh with `-i ~/.ssh/github-<account>` and
17+
#? IdentitiesOnly=yes prepended to the original args.
18+
#?
19+
#? First-match-wins for the org lookup — see
20+
#? `xsh help git/hub/account-for-org`.
21+
#?
22+
#? This script is dependency-free at runtime: it does not require xsh to
23+
#? be loaded in the shell that invokes git. Configure it once via
24+
#? gitconfig and it works in every git context (terminal, IDE, cron):
25+
#?
26+
#? git config --global core.sshCommand git-hub-ssh
27+
#?
28+
#? (After `xsh imports git/hub/ssh`, the script is symlinked into
29+
#? /usr/local/bin/git-hub-ssh, so the bare name works on PATH.)
30+
#?
31+
#? Dependency:
32+
#? 1. ssh
33+
#? 2. Env var XSH_GIT_HUB_ACCOUNTS — see `xsh help git/hub/account-for-email`.
34+
#?
35+
#? Usage:
36+
#? git-hub-ssh [SSH_ARGS...]
37+
#?
38+
#? Not normally invoked by hand. Set as `core.sshCommand` in gitconfig.
39+
#?
40+
#? Example:
41+
#? git config --global core.sshCommand git-hub-ssh
42+
#? git clone git@github.com:HyperSolidAPP/foo.git
43+
#? # → router resolves HyperSolidAPP -> alex-hypersolid -> uses
44+
#? # ~/.ssh/github-alex-hypersolid. Cloned origin stays bare.
45+
#?
46+
47+
set -e
48+
49+
# Pass-through for everything not aimed at git@github.com.
50+
is_github=false
51+
for a in "$@"; do
52+
if [[ $a == git@github.com ]]; then
53+
is_github=true
54+
break
55+
fi
56+
done
57+
$is_github || exec ssh "$@"
58+
59+
# git invokes us with e.g. git-upload-pack 'HyperSolidAPP/foo.git'
60+
# Extract the org from the first single-quoted path.
61+
org=""
62+
for a in "$@"; do
63+
case "$a" in
64+
"git-upload-pack '"*"/"*"'"|"git-receive-pack '"*"/"*"'")
65+
# strip the wrapping git-*-pack '<path>'
66+
tmp=${a#*\'}
67+
tmp=${tmp%\'}
68+
org=${tmp%%/*}
69+
break
70+
;;
71+
esac
72+
done
73+
[[ -z $org ]] && exec ssh "$@"
74+
75+
# Look up org -> account in XSH_GIT_HUB_ACCOUNTS (account:email:orgs).
76+
# First match wins.
77+
account=""
78+
for record in $XSH_GIT_HUB_ACCOUNTS; do
79+
IFS=':' read -r r_account _ r_orgs_csv <<< "$record"
80+
[[ -z $r_orgs_csv ]] && continue
81+
IFS=',' read -ra r_orgs <<< "$r_orgs_csv"
82+
for o in "${r_orgs[@]}"; do
83+
if [[ $o == "$org" ]]; then
84+
account=$r_account
85+
break 2
86+
fi
87+
done
88+
done
89+
[[ -z $account ]] && exec ssh "$@"
90+
91+
exec ssh -i "$HOME/.ssh/github-$account" -o IdentitiesOnly=yes "$@"

0 commit comments

Comments
 (0)