Asks before a gh or git push call runs on a GitHub account other than the one the repo's owner needs, and offers to switch.

A Claude Code mod that stops a gh or git push call from running on the wrong GitHub account by accident. gh auth switch changes the account for every repo at once, and on the wrong account gh doesn't say "wrong account": it says the repo doesn't exist, and a push fails or lands under the wrong name. This mod catches that before the call runs.
When Claude runs a shell command (the Bash or Monitor tool) that contains gh or git push, the mod reads every gh and git push in it. For each one it works out which repo owner it targets and which account it will use, and checks them against rules you set.
<account> and run runs gh auth switch --user <account>, then runs the call unchanged.gh auth switch --user … to run, and is asked to run it only if you agree.gh account is active;claude -p).When the mod can't tell, it asks. A false question costs one click; a silent pass is what the mod exists to prevent. It asks when:
GH_HOST, GH_CONFIG_DIR or an enterprise token;gh command writes without naming an owner (gh repo create name, gh gist create, gh ssh-key add), or is one it doesn't know;gh api uses graphql, writes without naming an owner, or sends its own Authorization header;gh or git push runs inside bash -c, eval, xargs, find -exec, ssh, $(...) or backticks.Shell commands without gh or git in them aren't looked at. Commands that never touch an account's data pass: gh auth status, gh --version, gh help, gh config, gh search, gh status, gh repo list, gh api user, a plain gh api GET with no owner, and anything with --help. A mention in echo, grep, a comment or a quoted PR body passes too.
Which repo owner. For each gh or git push in the command:
gh:-R owner/repo or --repo owner/repo;https://github.com/owner/repo/pull/1);GH_REPO=owner/repo set on the command or in Claude Code's environment;gh repo create owner/name, --org on gh secret, gh variable and gh repo fork;repos/OWNER, orgs/OWNER and users/OWNER endpoint of gh api.A URL that ends up as a flag's value is compared with the repo the command runs in, and if they differ the mod asks.
gh, git remote get-url origin;git push <remote>, every push URL of that remote (remote get-url --push --all);git push, the remote git itself would push to (@{push}: the branch's pushRemote, then remote.pushDefault, then the upstream).A cd dir && earlier in the command, git -C dir, --git-dir and GIT_DIR= are followed.
An SSH host whose name starts with github (git@github-work:owner/repo, the usual alias for a second key) counts as GitHub.
Which account the call will use. In this order:
GH_TOKEN or GITHUB_TOKEN, from Claude Code's environment, set before the command word, or exported earlier in the command. An empty value, env -u and unset take it away. The call runs if the owner is in your tokenOwners list.git push only: a clone that resets the credential helpers and brings its own. The repo's local config (or a -c on the command) must have an empty credential.helper entry followed by a helper; then that helper picks the account, not gh, and the mod stands down. A helper added without the reset doesn't count, because git tries the global gh auth git-credential first. Helper settings it can't read make it ask.gh auth switch --user X in the same command: later parts are judged as X, but only when they surely run after it (joined by ; or &&). After ||, | or &, after a switch that may itself be skipped (false && gh auth switch …; git push), or after gh auth login/logout, the account is unknown and the mod asks.gh keyring, read with gh config get -h github.com user each time a command is checked. That reads the local gh config, with no network call. It's the value gh auth switch rewrites, so a switch made in another terminal is seen at once.A git push to an SSH remote isn't checked: it uses your SSH key, not the gh account.
The patterns it demonstrates:
tool.call hook with $.ui.ask, the engine's own question dialog. Time spent waiting there doesn't count against the hook's budget, and the call rejects when there is nobody to ask..catch, so an error inside it refuses the call instead of letting it through.userConfig.A seat belt against accidents, not a security boundary. The risk it covers is Claude running an ordinary gh or git push command while a stale account is active. It does not try to stop a command written to get past it. A text reader can't follow every shell trick: quoting a command word apart (g'h' pr merge), shell functions and aliases, scripts and sourced files, variables used as commands, and heredoc bodies. For a hard block, use permission rules.
No screenshot yet. The question reads like this (made-up names):
The repo owner some-org needs the GitHub account work-user, but this call would run as my-user. Run
git push origin main?
- Switch to work-user and run
- Run anyway
- Cancel
gh … or git push … call, work out which account the target repo needs, compare it with the account the call will use, and on a mismatch offer Switch, Run anyway and Cancel; never slow down ordinary shell calls.gh auth status and cached the answer, because that command checks each token over the network (about half a second). A cache goes stale when another session switches the global account, which is exactly the case this mod is for. gh config get -h github.com user reads the same answer from the local config in about a tenth of a second, so it's read on every checked command and there's no cache.$.ui.ask) does all of that, so the pane is gone.gh auth word anywhere in the text;gh api flags before the path;Each of these now asks.
gh repo create;git push assumed to go to origin;-c remote.* overrides;GH_REPO and GH_HOST;bash -c beside a readable target;gh auth switch read as a real one;The command is now read in one pass that respects quotes, and each finding has a test. The review also argued that no text reader can be safe against a command built to evade it. That is true, and out of scope (see What this is not).
claude plugin test against stand-ins for git, gh and the question dialog (50 tests). Not yet tried in a live session.Requirements:
gh and git on your PATH.Steps:
/plugin install gh-account-guard --marketplace jsnkle/ai-native-sdlc
Answer y to add the marketplace, then pick a scope (user scope first). The install screen asks for the options below.
/config, or by hand in ~/.claude/settings.json (plugin options aren't read from a project's .claude/settings.json): {
"pluginConfigs": {
"gh-account-guard@jsnkle": {
"options": {
"rules": "my-user=my-user,my-side-org; work-user=Work-Org",
"tokenOwners": "my-side-org"
}
}
}
}
For a copy loaded with claude --plugin-dir, the key is gh-account-guard instead.
gh or git push in a repo whose owner needs a different account from the active one.Options:
| Option | What it holds | Default |
|---|---|---|
rules | Which account each repo owner needs: account=owner,owner; account=owner. Rules are separated by ; or a new line, owners by commas. Owners are GitHub users or orgs and are matched without regard to case. Your own user name is an owner too: list it if its repos should run as that account. If an owner is listed twice, the first rule wins. | empty |
tokenOwners | Owners that a GH_TOKEN or GITHUB_TOKEN may reach, comma-separated. A token call to any other owner is held. | empty |
gh flags are told apart from their values by a short list of flags that take none. An unlisted one takes the next word as its value. A URL taken that way is still compared with the repo the command runs in.github.com (and SSH hosts named github…) is checked. Other hosts pass through, and --hostname or GH_HOST for another host makes it ask.git up to four times and gh config get once, which adds a fraction of a second. Shell commands without gh or git cost nothing.gh account for every session and every repo, as gh auth switch always does.| Name | Version | License (SPDX) | Source |
|---|---|---|---|
| None |
The mod has no packages to install. It calls gh and git, which are already on the machine.
GitHub is a trademark of GitHub, Inc. Git is a trademark of Software Freedom Conservancy. OpenAI and GPT are trademarks of OpenAI. Use of these names here is descriptive and implies no endorsement.
Shared as-is. No support or maintenance is implied. See the repository's LICENSE.
hooks/register.tsx 649 lines1// gh-account-guard: asks before a `gh` or `git push` shell call runs on a
2// GitHub account other than the one the repo's owner needs.
3//
4// tool.call (Bash, Monitor): read every `gh` and `git push` in the command,
5// find the repo owner each one targets and the account it will use (a token,
6// the clone's own credential helper, an earlier `gh auth switch` in the same
7// command, or the active gh keyring account), and compare with the rules from
8// userConfig. On a mismatch, ask in the engine's own question dialog
9// (`$.ui.ask`). The rule throughout: when the mod cannot tell, it asks.
10// It guards against accidents, not against a command written to evade it.
11
12import type { EngineInterface, Register } from 'claude-code'
13
14const RUN = 'Run anyway'
15const CANCEL = 'Cancel'
16const FAILED = 'gh-account-guard: its check failed, so the call was refused. Ask the user how to go on.'
17const HELPER_KEY = '^credential\\.(https://github\\.com/?\\.)?helper$'
18const ENDPOINT = /^\/?(?:repos|orgs|users)\/([^/\s?]+)/
19const OTHER_URL = /^([a-z][a-z0-9+.-]*:\/\/|[^\s/:]+:|\.{0,2}\/|~)/i
20const TOKEN_NAMES = ['GH_TOKEN', 'GITHUB_TOKEN']
21const GH_UNSAFE_ENV = /^(GH_HOST|GH_CONFIG_DIR|GH_ENTERPRISE_TOKEN|GITHUB_ENTERPRISE_TOKEN|GIT_CONFIG.*)$/
22const set = (words: string) => new Set(words.split(' '))
23// gh commands that act on the working folder's repo when no -R is given; ones that never touch an account's data.
24const REPO_SCOPED = set('pr issue repo run workflow release secret variable label cache browse ruleset attestation')
25const GH_FREE = set('auth config completion help version search status')
26// gh flags that take no value, so the next word is not read as theirs; gh api flags that do take one.
27const GH_BOOL = set(
28 '--web -w --fill --draft -d --squash -s --merge -m --rebase -r --admin --auto --delete-branch --yes -y --force ' +
29 '--private --public --internal --clone --push --watch --exit-status --user -u --delete-last --remote',
30)
31const API_VALUE = set('-X --method -H --header -f -F --field --raw-field --input -q --jq -t --template --cache -p --preview --hostname')
32// Words that can come before the real command, options of theirs that take a value, and commands that run another.
33const PREFIXES = set('command exec env nohup time if then elif else while until do ! timeout sudo nice caffeinate')
34const PREFIX_VALUE = set('-u -g -n -s -k -C -D -p -r -t -T -U -S')
35const WRAPPERS = set('bash sh zsh dash ksh fish eval xargs find parallel watch su ssh script')
36const GIT_VALUE = set('-C -c --git-dir --work-tree --namespace --super-prefix --config-env')
37const PUSH_VALUE = set('-o --push-option --receive-pack --exec')
38const XARGS_VALUE = set('-I -n -P -L -d -E -s -a')
39
40/** One gh or git push in the command. `as`: set by an earlier `gh auth switch` (null: to an unknown account). */
41type Target = {
42 kind: 'gh' | 'push'; owner?: string; mention?: string; url?: string; remote?: string; unclear?: string
43 dir: string | null; gitDir?: string; opts: string[]; token: boolean; as?: string | null; remotesChanged: boolean
44}
45type GhTarget = Pick<Target, 'owner' | 'mention' | 'remote' | 'unclear'>
46type Dest = { owner: string; ssh: boolean }
47type Problem =
48 | { kind: 'unclear'; why: string }
49 | { kind: 'token'; owner: string }
50 | { kind: 'account'; owner: string; needs: string | null; uses: string | null; canSwitch: boolean }
51type Config = { rules: Map<string, string>; tokenOwners: Set<string> }
52
53export const register: Register = (on, options) => {
54 const config: Config = {
55 rules: parseRules(String(options.rules ?? '')),
56 tokenOwners: new Set(splitList(String(options.tokenOwners ?? ''))),
57 }
58
59 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
60 const deny = await judge($, e.command, config)
61 return deny === null ? next(e) : { deny }
62 }).catch(($, e, next) => (next.called ? next(e) : { deny: FAILED }))
63
64 on('tool.call', { tool: 'Monitor' }, async ($, e, next) => {
65 const deny = await judge($, e.command ?? '', config)
66 return deny === null ? next(e) : { deny }
67 }).catch(($, e, next) => (next.called ? next(e) : { deny: FAILED }))
68}
69
70// ---- Deciding --------------------------------------------------------------
71
72/** null to run the call, or the reason it was refused. */
73async function judge($: EngineInterface, command: string, config: Config): Promise<string | null> {
74 if ((!command.includes('gh') && !command.includes('git')) || parse(command, new Map()).length === 0) return null
75 const session = new Map<string, string>([
76 ['GH_TOKEN', (await $.env.get('GH_TOKEN')) ?? ''],
77 ['GITHUB_TOKEN', (await $.env.get('GITHUB_TOKEN')) ?? ''],
78 ['GH_REPO', (await $.env.get('GH_REPO')) ?? ''],
79 ])
80 const targets = parse(command, session)
81 const cwd = await $.session.cwd()
82 const home = (await $.env.get('HOME')) ?? '~'
83 const expand = (path: string) => path.replace(/^~(?=\/|$)/, home)
84 let active: string | null | undefined
85 const problems: Problem[] = []
86 const accounts = new Set<string>()
87
88 for (const t of targets) {
89 if (t.unclear !== undefined) {
90 problems.push({ kind: 'unclear', why: t.unclear })
91 continue
92 }
93 const where = ['-C', cwd, ...(t.dir === null ? [] : ['-C', expand(t.dir)]), ...(t.gitDir === undefined ? [] : ['--git-dir', expand(t.gitDir)])]
94 const dests =
95 t.owner !== undefined ? [{ owner: t.owner, ssh: false }]
96 : t.url !== undefined ? destsOf([t.url], t.url)
97 : t.remotesChanged ? 'an earlier part of the command changes the git remotes or their config'
98 : await lookup($, where, t)
99 if (typeof dests === 'string') {
100 problems.push({ kind: 'unclear', why: dests })
101 continue
102 }
103 const other = dests.find(d => t.mention !== undefined && d.owner !== t.mention)
104 if (other !== undefined) {
105 problems.push({ kind: 'unclear', why: `it names the owner ${t.mention} but runs in a repo of ${other.owner}` })
106 continue
107 }
108 let helper: 'own' | 'none' | 'bad' | undefined
109 for (const d of dests) {
110 if (t.kind === 'push' && d.ssh) continue // an SSH push uses an SSH key, not the gh account
111 if (t.token) {
112 if (!config.tokenOwners.has(d.owner)) problems.push({ kind: 'token', owner: d.owner })
113 continue
114 }
115 if (t.kind === 'push') {
116 helper ??= await ownHelper($, where, t.opts)
117 if (helper === 'own') continue // the clone resets the helpers and brings its own
118 if (helper === 'bad') {
119 problems.push({ kind: 'unclear', why: 'could not read the credential helpers git would use' })
120 break
121 }
122 }
123 if (t.as === null) {
124 problems.push({ kind: 'unclear', why: 'an earlier part of the command may change the gh account, to one it cannot name' })
125 continue
126 }
127 if (t.as === undefined && active === undefined) active = await activeAccount($)
128 const uses = t.as ?? active ?? null
129 const needs = config.rules.get(d.owner) ?? null
130 if (needs !== null) accounts.add(needs)
131 if (uses === null || needs === null || needs.toLowerCase() !== uses.toLowerCase()) {
132 problems.push({ kind: 'account', owner: d.owner, needs, uses, canSwitch: needs !== null && uses !== null && t.as === undefined })
133 }
134 }
135 }
136 return problems.length === 0 ? null : decide($, command, problems, accounts)
137}
138
139/** Where a push or a repo-scoped gh call goes, read from the repo it runs in; a string says why it could not tell. */
140async function lookup($: EngineInterface, where: string[], t: Target): Promise<Dest[] | string> {
141 let remote = t.remote
142 if (remote === undefined) {
143 // A bare `git push`: where git itself sends the branch (pushRemote, pushDefault, upstream).
144 const run = await $.process.run(['git', ...where, 'rev-parse', '--abbrev-ref', '@{push}'], { timeoutMs: 10000 })
145 remote = run.exitCode === 0 && !run.isStdoutTruncated ? /^([^/\s]+)\/\S+$/.exec(run.stdout.trim())?.[1] : undefined
146 if (remote === undefined) return 'could not tell where git push sends the current branch'
147 }
148 const argv = ['git', ...where, 'remote', 'get-url', ...(t.kind === 'push' ? ['--push', '--all'] : []), remote]
149 const run = await $.process.run(argv, { timeoutMs: 10000 })
150 if (run.exitCode !== 0 || run.isStdoutTruncated) return `could not read the remote ${remote} of the repo it runs in`
151 return destsOf(run.stdout.split('\n'), remote)
152}
153
154/** The GitHub owners among URLs; a string when one cannot be read or none is given. */
155function destsOf(urls: string[], name: string): Dest[] | string {
156 const lines = urls.map(u => u.trim()).filter(u => u !== '')
157 if (lines.length === 0) return `the remote ${name} gave no URL`
158 const dests: Dest[] = []
159 for (const url of lines) {
160 const owner = urlOwner(url)
161 if (owner !== undefined) {
162 dests.push({ owner, ssh: /^(ssh:|git\+ssh:|[^/:]+:(?!\/\/))/.test(url) })
163 } else if (/github/i.test(url) || !OTHER_URL.test(url)) {
164 return `could not read the remote URL ${url.slice(0, 200)}`
165 }
166 }
167 return dests
168}
169
170/** Asks the user about the first problem; null to run the call, or the reason it was refused. */
171async function decide($: EngineInterface, command: string, problems: Problem[], accounts: Set<string>): Promise<string | null> {
172 const [first] = problems
173 if (first === undefined) return null
174 const others = problems.slice(1)
175 const target = first.kind === 'account' && first.canSwitch ? first.needs : null
176 const same = target !== null && others.every(p => p.kind === 'account' && p.canSwitch && p.needs === target)
177 const account = same && accounts.size <= 1 ? target : null
178 const switchLabel = `Switch to ${account} and run`
179 const labels = account === null ? [RUN, CANCEL] : [switchLabel, RUN, CANCEL]
180 const { what, todo } = explain(first)
181 const more =
182 (others.length === 0 ? '' : ` It also has ${others.length} more ${others.length === 1 ? 'problem' : 'problems'} like this.`) +
183 (accounts.size > 1 ? ` Its parts need different accounts (${[...accounts].join(', ')}), so no one switch makes it all right.` : '')
184 const shown = command.length > 120 ? `${command.slice(0, 117)}...` : command
185
186 let answer = ''
187 try {
188 answer = await $.ui.ask(`${what}${more} Run \`${shown}\`?`, { options: labels, header: 'GitHub' })
189 } catch {
190 answer = '' // dismissed, or nobody to ask (a -p run)
191 }
192 if (answer === RUN) return null
193 if (account !== null && answer === switchLabel) {
194 const run = await $.process.run(['gh', 'auth', 'switch', '--hostname', 'github.com', '--user', account], { timeoutMs: 15000 })
195 if (run.exitCode === 0) {
196 $.ui.toast(`gh-account-guard: switched to ${account}`)
197 return null
198 }
199 return `gh-account-guard did not run this call: switching to ${account} failed (${run.stderr.trim().slice(0, 300)}). ${what} ${todo}`
200 }
201 const outcome =
202 answer === CANCEL ? 'the user chose Cancel' : answer === '' ? 'the question was dismissed or could not be asked' : `the user answered "${answer.slice(0, 200)}"`
203 return `gh-account-guard did not run this call: ${outcome}. ${what}${more} ${todo}`
204}
205
206/** What is wrong, and what the model should do about it. */
207function explain(p: Problem): { what: string; todo: string } {
208 if (p.kind === 'unclear') {
209 return { what: `gh-account-guard could not tell which repo or account this call uses: ${p.why}.`, todo: 'Ask the user how to go on.' }
210 }
211 if (p.kind === 'token') {
212 return {
213 what: `This call runs with a GH_TOKEN/GITHUB_TOKEN token, and the repo owner ${p.owner} is not in the tokenOwners setting.`,
214 todo: 'Run it without the token, or ask the user.',
215 }
216 }
217 if (p.uses === null) {
218 return { what: `No gh account is active, and this call targets the repo owner ${p.owner}.`, todo: 'Ask the user to log in with `gh auth login`.' }
219 }
220 if (p.needs === null) {
221 return { what: `No rule names an account for the repo owner ${p.owner}; this call would run as ${p.uses}.`, todo: 'Ask the user which account to use.' }
222 }
223 return {
224 what: `The repo owner ${p.owner} needs the GitHub account ${p.needs}, but this call would run as ${p.uses}.`,
225 todo: `If the user agrees, run \`gh auth switch --user ${p.needs}\` first, then retry.`,
226 }
227}
228
229/** 'own' when the clone's config (or the command's -c) resets the helpers and then adds its own; 'bad' when unreadable. */
230async function ownHelper($: EngineInterface, where: string[], opts: string[]): Promise<'own' | 'none' | 'bad'> {
231 const extra = opts.flatMap(o => ['-c', o])
232 const run = await $.process.run(['git', ...where, ...extra, 'config', '--show-scope', '--get-regexp', HELPER_KEY], { timeoutMs: 10000 })
233 if (run.exitCode === 1 && run.stdout.trim() === '') return 'none' // no helper configured at all
234 if (run.exitCode !== 0 || run.isStdoutTruncated) return 'bad'
235 let resetBy = ''
236 let after = 0
237 for (const line of run.stdout.split('\n').filter(l => l !== '')) {
238 const m = /^(\w+)\t\S+ ?(.*)$/.exec(line)
239 if (m === null) return 'bad'
240 if ((m[2] ?? '').trim() === '') {
241 resetBy = m[1] ?? ''
242 after = 0
243 } else {
244 after += 1
245 }
246 }
247 return ['local', 'worktree', 'command'].includes(resetBy) && after > 0 ? 'own' : 'none'
248}
249
250/** The active keyring account for github.com: the `user` key `gh auth switch` rewrites; no network. */
251async function activeAccount($: EngineInterface): Promise<string | null> {
252 const run = await $.process.run(['gh', 'config', 'get', '-h', 'github.com', 'user'], { timeoutMs: 10000 })
253 const account = run.exitCode === 0 ? run.stdout.trim() : ''
254 return /^[A-Za-z0-9-]+$/.test(account) ? account : null
255}
256
257// ---- Reading the command ---------------------------------------------------
258
259/** Every gh and git push in the command, in order. `session`: GH_TOKEN, GITHUB_TOKEN and GH_REPO as Claude Code has them. */
260function parse(command: string, session: Map<string, string>): Target[] {
261 const { segments, spans } = lex(command)
262 const targets: Target[] = []
263 const exported = new Map(session)
264 const base = { dir: null, opts: [], token: false, remotesChanged: false }
265 if (spans.some(runsGhIn)) {
266 targets.push({ kind: 'gh', ...base, unclear: 'it runs gh or git push inside $(...) or backticks' })
267 }
268 let dir: string | null = null
269 let as: string | null | undefined
270 let asIsConditional = false
271 let remotesChanged = false
272 for (const { sep, words } of segments) {
273 // A switch holds for later parts only when they surely run after it: joined by ; or &&, never || | or &.
274 if (as !== undefined && (['||', '|', '&'].includes(sep) || (asIsConditional && (sep === ';' || sep === '\n')))) as = null
275 const env = new Map(exported)
276 const i = skipPrefixes(words, env)
277 const isCall = i >= 0 // `command -v gh` only describes it
278 const cmd = baseName(words[i] ?? '')
279 const args = words.slice(i + 1)
280 const unsafe = [...env.keys()].find(name => GH_UNSAFE_ENV.test(name) && env.get(name) !== '')
281 const scope = {
282 dir, as, remotesChanged, opts: [] as string[],
283 token: TOKEN_NAMES.some(name => (env.get(name) ?? '') !== ''),
284 ...(unsafe === undefined ? {} : { unclear: `it sets ${unsafe}` }),
285 }
286 if (!isCall) continue
287 if (cmd === 'cd') {
288 dir = joinDir(dir, args[0])
289 } else if (cmd === 'export' || cmd === 'unset') {
290 for (const a of args) {
291 const m = /^([A-Za-z_]\w*)(?:=([^]*))?$/.exec(a)
292 if (m !== null && (cmd === 'unset' || m[2] !== undefined)) exported.set(m[1] ?? '', cmd === 'unset' ? '' : m[2] ?? '')
293 }
294 } else if (WRAPPERS.has(cmd)) {
295 if (wraps(cmd, args)) targets.push({ kind: 'gh', ...scope, unclear: `it runs gh or git push through ${cmd}` })
296 } else if (cmd === 'gh' && args[0] === 'auth' && ['switch', 'login', 'logout'].includes(args[1] ?? '')) {
297 const user = args[1] === 'switch' ? flagValue(args, '--user', '-u') ?? null : null
298 as = sep === '' || sep === ';' || sep === '\n' || sep === '&&' ? user : null
299 asIsConditional = sep === '&&'
300 } else if (cmd === 'gh') {
301 const target = ghTarget(args, env.get('GH_REPO') ?? '')
302 if (target !== null) targets.push({ kind: 'gh', ...scope, ...target })
303 } else if (cmd === 'git') {
304 const git = gitCall(args, dir, env.get('GIT_DIR'))
305 const sub = git.args[0]
306 if ((sub === 'remote' && ['add', 'set-url', 'rename', 'remove', 'rm'].includes(git.args[1] ?? '')) ||
307 (sub === 'config' && git.args.some(a => /^(remote|url|branch)\./i.test(a)))) {
308 remotesChanged = true
309 } else if (sub === 'push') {
310 const target = pushTarget(git.args.slice(1))
311 if (target !== null) targets.push({ ...scope, unclear: git.unclear ?? scope.unclear, ...target, dir: git.dir, gitDir: git.gitDir, opts: git.opts })
312 }
313 }
314 }
315 return targets
316}
317
318/** What a gh call targets, or null when it touches no account's data. */
319function ghTarget(args: string[], ghRepo: string): GhTarget | null {
320 const sub = args[0]
321 if (sub === undefined || sub.startsWith('-') || GH_FREE.has(sub) || args.includes('--help') || args.includes('-h')) return null
322 const decisive = new Set<string>()
323 const mentioned = new Set<string>()
324 const positional: string[] = []
325 let method = ''
326 let fields = false
327 let org: string | undefined
328 let unclear: string | undefined
329 const addRepo = (value: string, what: string) => {
330 const owner = repoOwner(value)
331 if (owner !== undefined) decisive.add(owner)
332 else unclear ??= `${what} ${value} is not a github.com repo it can read`
333 }
334 for (let i = 1; i < args.length; i += 1) {
335 const a = args[i] ?? ''
336 if (!a.startsWith('-')) {
337 positional.push(a)
338 const owner = urlOwner(a)
339 if (owner !== undefined) decisive.add(owner)
340 continue
341 }
342 const long = a.startsWith('--')
343 const eq = a.indexOf('=')
344 const flag = long ? (eq < 0 ? a : a.slice(0, eq)) : a.slice(0, 2)
345 let value = long ? (eq < 0 ? undefined : a.slice(eq + 1)) : a.length > 2 ? a.slice(2) : undefined
346 if (value === undefined && (sub === 'api' ? API_VALUE.has(flag) : !GH_BOOL.has(flag))) value = args[(i += 1)]
347 if (value === undefined) continue
348 if (flag === '-R' || flag === '--repo') addRepo(value, '-R')
349 else if (flag === '--hostname' && value.toLowerCase() !== 'github.com') unclear ??= `it targets the host ${value}`
350 else if (flag === '-X' || flag === '--method') method = value.toUpperCase()
351 else if (['-f', '-F', '--field', '--raw-field'].includes(flag)) fields = true
352 else if ((flag === '-H' || flag === '--header') && /^\s*authorization\s*:/i.test(value)) unclear ??= 'it sends its own Authorization header'
353 else if (flag === '-o' || flag === '--org') org = value
354 const owner = flag === '--input' ? undefined : urlOwner(value)
355 if (owner !== undefined) mentioned.add(owner)
356 }
357 const [action, first] = positional
358 if (sub === 'repo' && first?.includes('/') === true) addRepo(first, 'the repo')
359 if ((sub === 'secret' || sub === 'variable' || (sub === 'repo' && action === 'fork')) && org !== undefined) decisive.add(org.toLowerCase())
360 let fromRemote = false
361 if (sub === 'api') {
362 for (const word of positional) {
363 const owner = ENDPOINT.exec(word)?.[1]
364 if (owner === '{owner}') fromRemote = true
365 else if (owner !== undefined) decisive.add(owner.toLowerCase())
366 }
367 }
368 if (unclear !== undefined) return { unclear }
369 const all = new Set([...decisive, ...mentioned])
370 if (decisive.size > 1 || all.size > 1) return { unclear: `it names more than one owner (${[...all].join(', ')})` }
371 const [owner] = decisive
372 if (owner !== undefined) return { owner }
373 const mention = [...mentioned][0]
374 const fromEnv = repoOwner(ghRepo)
375 const remote: GhTarget =
376 ghRepo === '' ? { remote: 'origin', mention }
377 : fromEnv !== undefined && (mention === undefined || mention === fromEnv) ? { owner: fromEnv }
378 : { unclear: `GH_REPO ${ghRepo} is not a github.com repo it can read, or names another owner than the command` }
379 if (sub === 'api') {
380 if (fromRemote) return remote
381 if (action === 'graphql') return { unclear: 'a gh api graphql call names no repo' }
382 if (method === 'GET') return null // an explicit GET: fields are query parameters
383 return method !== '' || fields ? { unclear: 'a gh api call that writes names no repo' } : null
384 }
385 if (sub === 'repo' && (action === 'list' || action === 'clone')) return null
386 if (sub === 'repo' && (action === 'create' || action === 'fork')) return { unclear: `gh repo ${action} names no owner, so it would use whichever account is active` }
387 if (sub === 'gist') return action === 'list' || action === 'view' ? null : { unclear: 'gists belong to whichever account is active' }
388 if ((sub === 'secret' || sub === 'variable') && (args.includes('-u') || args.includes('--user'))) {
389 return { unclear: `a user-level ${sub} belongs to whichever account is active` }
390 }
391 return REPO_SCOPED.has(sub) ? remote : { unclear: `gh ${sub} acts for whichever account is active` }
392}
393
394/** git's own options before the subcommand: where it runs, and config that could change the push. */
395function gitCall(args: string[], start: string | null, gitDirEnv: string | undefined) {
396 let dir = start
397 let gitDir = gitDirEnv
398 let unclear: string | undefined
399 const opts: string[] = []
400 let i = 0
401 for (; i < args.length && (args[i] ?? '').startsWith('-'); i += 1) {
402 const a = args[i] ?? ''
403 const eq = a.startsWith('--') ? a.indexOf('=') : -1
404 const name = eq < 0 ? a : a.slice(0, eq)
405 const value = eq >= 0 ? a.slice(eq + 1) : GIT_VALUE.has(name) ? args[(i += 1)] ?? '' : ''
406 if (name === '-C') dir = joinDir(dir, value)
407 if (name === '--git-dir') gitDir = value
408 const key = (value.split('=')[0] ?? '').toLowerCase()
409 if (name === '-c' && key.startsWith('credential.')) opts.push(value) // modelled by ownHelper
410 else if ((name === '-c' || name === '--config-env') && /^(remote|url|branch|credential)\.|pushurl/.test(key)) {
411 unclear = `it overrides the git config ${key}`
412 }
413 }
414 return { args: args.slice(i), dir, gitDir, opts, unclear }
415}
416
417function pushTarget(args: string[]): Pick<Target, 'kind' | 'url' | 'remote' | 'unclear'> | null {
418 let positional: string | undefined
419 let repoFlag: string | undefined
420 for (let j = 0; j < args.length; j += 1) {
421 const a = args[j] ?? ''
422 if (a === '--repo') repoFlag = args[(j += 1)]
423 else if (a.startsWith('--repo=')) repoFlag = a.slice(7)
424 else if (PUSH_VALUE.has(a)) j += 1
425 else if (!a.startsWith('-') && positional === undefined) positional = a
426 }
427 const remote = positional ?? repoFlag
428 if (remote === undefined || /^[\w.-]+$/.test(remote)) return { kind: 'push', remote } // a remote name, or git's own choice
429 if (urlOwner(remote) !== undefined) return { kind: 'push', url: remote }
430 return /github/i.test(remote) || !OTHER_URL.test(remote) ? { kind: 'push', unclear: `could not read the push destination ${remote}` } : null
431}
432
433function flagValue(args: string[], long: string, short: string): string | undefined {
434 for (let i = 0; i < args.length; i += 1) {
435 const a = args[i] ?? ''
436 if (a === long || a === short) return args[i + 1]
437 if (a.startsWith(`${long}=`)) return a.slice(long.length + 1)
438 }
439 return undefined
440}
441
442/** True when a command that runs another (a shell, eval, xargs, find -exec, ssh) would run gh or git push. */
443function wraps(cmd: string, args: string[]): boolean {
444 if (cmd === 'xargs') {
445 let i = 0
446 while ((args[i] ?? '').startsWith('-')) i += XARGS_VALUE.has(args[i] ?? '') ? 2 : 1
447 return runsGh(args.slice(i))
448 }
449 if (cmd === 'find') return args.some((a, i) => /^-(exec|execdir|ok|okdir)$/.test(a) && runsGh(args.slice(i + 1)))
450 // A shell string: read it as a command line of its own.
451 const script = args.findIndex(a => /^-[a-z]*c[a-z]*$/i.test(a))
452 if (['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'su'].includes(cmd) && script >= 0) return runsGhIn(args[script + 1] ?? '')
453 let i = 0
454 while ((args[i] ?? '').startsWith('-')) i += PREFIX_VALUE.has(args[i] ?? '') ? 2 : 1
455 return runsGhIn(args.slice(cmd === 'ssh' ? i + 1 : i).join(' '))
456}
457
458/**
459 * The index of a segment's command word, past VAR=value words and prefixes like sudo, env or timeout
460 * and their options; -1 for `command -v`. Assignments and `env -u`/`-i` are written to `env`.
461 */
462function skipPrefixes(words: string[], env = new Map<string, string>()): number {
463 let i = 0
464 while (i < words.length) {
465 const word = words[i] ?? ''
466 const assign = /^([A-Za-z_]\w*)=([^]*)$/.exec(word)
467 if (assign !== null) {
468 env.set(assign[1] ?? '', assign[2] ?? '')
469 i += 1
470 continue
471 }
472 const prefix = baseName(word)
473 if (!PREFIXES.has(prefix)) break
474 i += 1
475 if (prefix === 'command' && /^-[vV]$/.test(words[i] ?? '')) return -1
476 while ((words[i] ?? '').startsWith('-')) {
477 const flag = words[i] ?? ''
478 if (prefix === 'env' && flag === '-i') TOKEN_NAMES.forEach(name => env.set(name, ''))
479 if (prefix === 'env' && flag === '-u') env.set(words[i + 1] ?? '', '')
480 i += prefix !== 'command' && PREFIX_VALUE.has(flag) ? 2 : 1 // command's own flags take no value
481 }
482 if (prefix === 'timeout') i += 1 // the duration
483 }
484 return i
485}
486
487/** True when a command line runs gh or git push anywhere it can be read, $(...) included. */
488function runsGhIn(text: string): boolean {
489 const { segments, spans } = lex(text)
490 return segments.some(s => runsGh(s.words.slice(Math.max(0, skipPrefixes(s.words))))) || spans.some(runsGhIn)
491}
492
493function runsGh(words: string[]): boolean {
494 const cmd = baseName(words[0] ?? '')
495 return cmd === 'gh' || (cmd === 'git' && words.includes('push')) || (WRAPPERS.has(cmd) && wraps(cmd, words.slice(1)))
496}
497
498function baseName(word: string): string {
499 return word.replace(/^\\/, '').split('/').pop() ?? ''
500}
501
502/** `OWNER/REPO`, `github.com/OWNER/REPO` or a URL to the owner, lower case. */
503function repoOwner(value: string): string | undefined {
504 const parts = value.split('/')
505 if (parts.length === 2 && /^[A-Za-z0-9-]+$/.test(parts[0] ?? '')) return parts[0]?.toLowerCase()
506 if (parts.length === 3 && parts[0]?.toLowerCase() === 'github.com') return parts[1]?.toLowerCase()
507 return urlOwner(value)
508}
509
510/** The owner in a github.com https URL, or an ssh/scp URL whose host starts with `github` (alias hosts too). */
511function urlOwner(value: string): string | undefined {
512 const m =
513 /^https?:\/\/(?:[^@/]+@)?github\.com\/([A-Za-z0-9-]+)\/[\w.-]+/i.exec(value) ??
514 /^(?:git\+)?ssh:\/\/(?:[^@/]+@)?github[\w.-]*(?::\d+)?\/([A-Za-z0-9-]+)\/[\w.-]+/i.exec(value) ??
515 /^(?:[^@/:]+@)?github[\w.-]*:([A-Za-z0-9-]+)\/[\w.-]+/i.exec(value)
516 return m?.[1]?.toLowerCase()
517}
518
519function joinDir(dir: string | null, arg: string | undefined): string | null {
520 if (arg === undefined || arg === '~' || arg.startsWith('/') || arg.startsWith('~/')) return arg ?? '~'
521 return dir === null ? arg : `${dir}/${arg}`
522}
523
524/**
525 * Splits a command into segments of words in one pass: quotes and escapes join a
526 * word, `;` `&&` `||` `|` `&` and new lines end a segment (`sep` is the one before
527 * it), `#` starts a comment, redirections are dropped, and the text of every
528 * `$(...)` and backtick span is kept apart in `spans`.
529 */
530function lex(text: string): { segments: { sep: string; words: string[] }[]; spans: string[] } {
531 const segments: { sep: string; words: string[] }[] = []
532 const spans: string[] = []
533 let words: string[] = []
534 let word = ''
535 let inWord = false
536 let skipWord = false
537 let sep = ''
538 const endWord = () => {
539 if (inWord && !skipWord) words.push(word)
540 if (inWord) skipWord = false
541 word = ''
542 inWord = false
543 }
544 const endSegment = (next: string) => {
545 endWord()
546 const kept = words.filter(w => w !== '{' && w !== '}')
547 if (kept.length > 0) segments.push({ sep, words: kept })
548 if (kept.length > 0 || next !== '\n') sep = next
549 words = []
550 }
551 /** The index just past the span that opens at `start` (`$(` or a backtick). */
552 const span = (start: number): number => {
553 const tick = text[start] === '`'
554 let depth = 0
555 let i = tick ? start + 1 : start + 2
556 for (; i < text.length; i += 1) {
557 const c = text[i]
558 if (c === '\\') i += 1
559 else if (tick && c === '`') break
560 else if (!tick && c === '(') depth += 1
561 else if (!tick && c === ')' && depth-- === 0) break
562 }
563 spans.push(text.slice(tick ? start + 1 : start + 2, i))
564 return i + 1
565 }
566 for (let i = 0; i < text.length; ) {
567 const c = text[i] ?? ''
568 const next = text[i + 1] ?? ''
569 if (c === '\\') {
570 if (next !== '\n') {
571 word += next
572 inWord = true
573 }
574 i += 2
575 } else if (c === "'") {
576 const end = text.indexOf("'", i + 1)
577 word += text.slice(i + 1, end < 0 ? text.length : end)
578 inWord = true
579 i = end < 0 ? text.length : end + 1
580 } else if (c === '"') {
581 inWord = true
582 for (i += 1; i < text.length && text[i] !== '"'; ) {
583 if (text[i] === '\\' && /["\\$`]/.test(text[i + 1] ?? '')) {
584 word += text[i + 1]
585 i += 2
586 } else if ((text[i] === '$' && text[i + 1] === '(') || text[i] === '`') {
587 const end = span(i)
588 word += text.slice(i, end)
589 i = end
590 } else {
591 word += text[i]
592 i += 1
593 }
594 }
595 i += 1
596 } else if ((c === '$' && next === '(') || c === '`') {
597 const end = span(i)
598 word += text.slice(i, end)
599 inWord = true
600 i = end
601 } else if (c === '#' && !inWord) {
602 const end = text.indexOf('\n', i)
603 i = end < 0 ? text.length : end
604 } else if (c === '>' || c === '<' || (c === '&' && next === '>')) {
605 if (/^\d+$/.test(word)) inWord = false // a file descriptor: 2>
606 endWord()
607 while (/[<>&|]/.test(text[i] ?? '')) i += 1
608 skipWord = true // the file or descriptor it redirects to
609 } else if (c === ';' || c === '\n' || c === '&' || c === '|') {
610 const two = c + next
611 const op = two === '&&' || two === '||' ? two : two === '|&' ? '|' : c
612 endSegment(op)
613 i += two === '&&' || two === '||' || two === '|&' ? 2 : 1
614 } else if (c === ' ' || c === '\t' || c === '(' || c === ')') {
615 endWord()
616 i += 1
617 } else {
618 word += c
619 inWord = true
620 i += 1
621 }
622 }
623 endSegment('')
624 return { segments, spans }
625}
626
627// ---- Options ---------------------------------------------------------------
628
629/** `account=owner,owner; account=owner` to a map of owner (lower case) to account. */
630function parseRules(text: string): Map<string, string> {
631 const rules = new Map<string, string>()
632 for (const part of text.split(/[;\n]/)) {
633 const [account, owners] = part.split('=')
634 const name = (account ?? '').trim()
635 if (name === '' || owners === undefined) continue
636 for (const owner of splitList(owners)) {
637 if (!rules.has(owner)) rules.set(owner, name)
638 }
639 }
640 return rules
641}
642
643function splitList(text: string): string[] {
644 return text
645 .split(/[,\s]+/)
646 .map(s => s.trim().toLowerCase())
647 .filter(s => s !== '')
648}
649