Files
orca/src/main/git/wsl-direct-git-read-commands.ts
T
6efd4061dc fix(git): run WSL git reads without a shell (#15257)
* fix(git): run WSL git reads without a shell

WSL-routed git ran through the distro user's interactive login shell for
one reason: to inherit their PATH. That shell also runs the distro's
rc/motd and writes it to the stdout callers parse, which is why #10917
reports a shell banner breaking GitHub source detection.

A shell-free route already existed (`--exec /usr/bin/env PATH=... git`,
added for status reads in #13207) but it was opt-in, and only
gitStatusReadOptionsForWorktree opted in. Every other read -- remote
get-url, config --get, log, show, rev-parse -- took the login shell on
every call, so the reported parse never benefited.

Classify reads at the resolver instead of at each caller. A read needs
nothing the login shell provides, so it takes the direct route without
the caller asking. Writes and network operations stay on the login
shell: they can depend on credential helpers and ssh-agent that the
user's profile sets up.

Subcommands that both read and write (config, remote, branch,
submodule) require an explicit read flag before they qualify, so
`config --get` goes direct while `config user.email x` does not.

`git show` blob reads stop forcing the login shell and go direct too.
The fence stays on that path: the login shell is still the fallback when
the environment probe is cold or rejected, and a banner there would
become file content.

Behavior note: the first WSL read per distro now also warms the
environment probe in the background, so a cold read spawns one extra
wsl.exe. Subsequent reads start no shell at all.

* fix(git): keep queried WSL remote reads on login shell

* style: format WSL runner test

* fix(git): match WSL read markers positionally

The conditional read markers were matched anywhere after the subcommand,
so a positional argument that happened to share a marker's name routed a
write shell-free: `worktree remove list` read as a listing, and
`submodule foreach status` as a status query -- the latter can run
arbitrary commands, including network ones.

Split the two kinds of marker apart. `config`/`branch` are flag-marked
and still match anywhere; `remote`/`worktree`/`submodule` are
action-marked and must match the first non-flag argument. The queried
`remote show` rule and the `symbolic-ref` arity rule are unchanged.

Neither case is reachable today -- Orca issues no `submodule foreach`,
and the worktree paths are its own CLI, not git argv -- but the loose
match was the shape of the defect, not those two instances.

* test(git): pin read routing behind global options

Writes hidden behind -c/-C/--git-dir must not reach the shell-free
route, and an unparsed global form must fall back to the login shell
rather than guess at the subcommand. Both directions fail safe.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-08-18 02:54:19 -07:00

106 lines
3.4 KiB
TypeScript

/**
* Decide whether a git invocation is a plain read that can run without a shell.
*
* Why: WSL-routed git otherwise goes through the distro user's interactive login
* shell, purely to inherit their PATH. That shell also runs the distro's rc/motd
* and writes it to the stdout callers parse. Reads need none of it -- the direct
* route supplies PATH and HOME explicitly and starts no shell at all.
*
* Writes and network operations stay on the login shell: they can depend on
* credential helpers, ssh-agent and other environment only the user's profile
* sets up.
*/
// Subcommands that only ever read. `status` is here for completeness; its
// callers already opted in explicitly.
const ALWAYS_READ_SUBCOMMANDS = new Set([
'blame',
'cat-file',
'check-ignore',
'describe',
'diff',
'for-each-ref',
'log',
'ls-files',
'ls-tree',
'merge-base',
'name-rev',
'rev-list',
'rev-parse',
'show',
'show-ref',
'status',
'var'
])
// Read markers that appear as a flag anywhere after the subcommand.
const READ_FLAG_SUBCOMMANDS: Record<string, ReadonlySet<string>> = {
branch: new Set(['--list', '-l', '--show-current', '--contains', '--points-at']),
config: new Set(['--get', '--get-all', '--get-regexp', '--get-urlmatch', '--list', '-l'])
}
// Read markers that must be the *first non-flag* argument, i.e. the action.
// Position matters here: matching them anywhere would read `worktree remove list`
// as a listing, because a worktree may legitimately be named "list".
const READ_ACTION_SUBCOMMANDS: Record<string, ReadonlySet<string>> = {
remote: new Set(['get-url']),
submodule: new Set(['status']),
worktree: new Set(['list'])
}
// Subcommands whose action-less form only lists (`git remote`, `git submodule`).
const BARE_FORM_IS_READ = new Set(['remote', 'submodule'])
/** Leading `-c key=value` / `--git-dir=...` style options precede the subcommand. */
function findSubcommandIndex(args: readonly string[]): number {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '-c' || arg === '-C') {
index += 1
continue
}
if (arg.startsWith('-')) {
continue
}
return index
}
return -1
}
export function isWslDirectGitReadCommand(args: readonly string[]): boolean {
const subcommandIndex = findSubcommandIndex(args)
if (subcommandIndex === -1) {
return false
}
const subcommand = args[subcommandIndex]
if (ALWAYS_READ_SUBCOMMANDS.has(subcommand)) {
return true
}
const rest = args.slice(subcommandIndex + 1)
if (subcommand === 'symbolic-ref') {
if (rest.some((arg) => arg === '-d' || arg === '--delete' || arg === '-m')) {
return false
}
// Reading takes one ref; a second positional is the value being written.
return rest.filter((arg) => arg !== '--' && !arg.startsWith('-')).length <= 1
}
const readActions = READ_ACTION_SUBCOMMANDS[subcommand]
if (readActions) {
const action = rest.find((arg) => !arg.startsWith('-'))
if (!action) {
return BARE_FORM_IS_READ.has(subcommand)
}
// `remote show` queries the transport unless -n is given, so the queried
// form has to keep the profile's SSH and credential setup.
if (subcommand === 'remote' && action === 'show') {
return rest.includes('-n')
}
return readActions.has(action)
}
const readFlags = READ_FLAG_SUBCOMMANDS[subcommand]
return Boolean(readFlags && rest.some((arg) => readFlags.has(arg.split('=')[0])))
}