Skip to content

Use correct shell#74

Open
graeme wants to merge 4 commits into
mainfrom
use-correct-shell
Open

Use correct shell#74
graeme wants to merge 4 commits into
mainfrom
use-correct-shell

Conversation

@graeme

@graeme graeme commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

PR: Run brew through the user's login + interactive shell

Summary

brew config and brew doctor reported through BrewUI didn't match what users see in Terminal, because a GUI process launched from Finder/Dock/launchd inherits a stripped environment — missing the PATH entries and HOMEBREW_* vars that only exist after a login shell sources the user's profile files. This PR routes every brew invocation through <login-shell> -l -i -c "<brew> <quoted args>", fixing the diagnostics outright and giving every other brew call (install, upgrade, info) the same environment for free.

Changes

New types in BrewCLI:

  • LoginShellResolver — resolves the current user's login shell from Directory Services via getpwuid(getuid())->pw_shell, with /bin/zsh as fallback. $SHELL is intentionally not consulted because it can be stale or absent in a GUI launch context.
  • LoginShellBrewCommandRunnerBrewCommandRunning decorator that rewrites run(executableURL: brew, arguments: [...]) into <login-shell> -l -i -c "<brew> <quoted args>". Quoting uses POSIX single-quote ('…' with '\'' for embedded apostrophes) so package names and flags with spaces, $, backticks, etc. survive the shell parse intact.

Live wiring (single spawning route):

  • BrewCommandExecutionContext.live(), BrewConfigRepository.live(), and BrewInstalledPackagesRepository.live() now construct LoginShellBrewCommandRunner() instead of BrewCommandService() directly. These are the only three production sites that construct a brew runner.
  • Tests still use BrewCommandService() directly for raw-process plumbing — that's intentional; the shell wrap is a live-wiring concern.

Decision documented:

  • .ai/memory.md records why we ship both -l and -i (Terminal-exact parity, including users who put brew shellenv in .zshrc), and the tradeoff we accept (rc files may print banners or emit TTY warnings on stderr).

Why this split (optional)

Stacked as four small commits, one per slice of the requirements plan, so each is reviewable on its own:

  1. LoginShellResolver (pure, independently testable, no UI surface)
  2. LoginShellBrewCommandRunner (decorator + quoting + argv construction)
  3. Wiring the wrapper into the three live() factories (the demoable diagnostics fix)
  4. Documenting the -l -i decision in .ai/memory.md

Testing

  • scripts/test — full BrewKit + BrewUILint test suite, 617 + 19 tests pass
  • mint run swiftformat --lint . clean
  • mint run swiftlint lint --strict — 0 violations
  • BrewUILint over Brew + Sources — 0 violations
  • New unit tests cover resolver (lookup / fallback / live Directory Services probe) and runner (quoting edge cases, -l -i -c argv, fallback path, output passthrough)
  • Manual: launch the app from Finder, open Configuration tab, confirm brew config output matches brew config in Terminal (acceptance criterion 1)
  • Manual: open Doctor tab, confirm warnings match brew doctor in Terminal — no phantom warnings from a stripped environment (acceptance criterion 2)

PR checklist

  • Have you followed this repository's contribution and workflow guidance?
  • Have you explained what changed and why this should land now?
  • Have you run relevant local checks for the changed scope?
  • Are changes scoped and free of unrelated modifications?

  • AI was used to generate or assist with generating this PR.
  • If yes, describe exactly how AI was used and what manual verification was performed.

Claude Code (Opus 4.7) implemented the four-slice plan end-to-end: read the existing BrewCommandService / BrewExecutableLocator / live() wiring, wrote LoginShellResolver + LoginShellBrewCommandRunner + their unit tests, swept the three production live() factories, and drafted the .ai/memory.md entry. Manual verification performed: ran scripts/test, mint run swiftformat --lint ., mint run swiftlint lint --strict, and BrewUILint over the production tree — all green. The -l -i (vs -l only) decision was an explicit human call before implementation. The Finder-launched diagnostics checks above are still to be done manually before merge.


Follow-ups (optional)

  • If the -i flag turns out to surface noisy banners or TTY warnings from real users' rc files, the dial is to drop back to -l only (one-line change in LoginShellBrewCommandRunner). (cc @MikeMcQuaid : Maybe I should just do this off the bat?)

graeme and others added 4 commits June 25, 2026 21:37
Resolves the current user's login shell from Directory Services via
getpwuid(getuid())->pw_shell, with /bin/zsh as fallback. $SHELL is
intentionally not consulted because it can be stale or absent in a
GUI launch context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Decorates a BrewCommandRunning so every brew invocation is executed
inside the user's login + interactive shell:
<login-shell> -l -i -c "<brew> <quoted args>".

The -l flag sources profile files (.zprofile / .bash_profile) where
Homebrew installs brew shellenv; -i additionally sources interactive
rc files (.zshrc / .bashrc) so users who configure brew there also
get parity with Terminal. Quoting is POSIX single-quote with the
'\'' escape so package names and flags survive intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The three production live() factories that construct a brew runner
(BrewCommandExecutionContext, BrewConfigRepository,
BrewInstalledPackagesRepository) now use LoginShellBrewCommandRunner,
giving the app a single login-shell spawning path. Tests still use
BrewCommandService directly for raw-process plumbing.

This is the visible diagnostics fix: brew config and brew doctor
reported through BrewUI now match what the user sees in Terminal,
and every other brew invocation inherits the same environment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Records why brew is now spawned through the user's login + interactive
shell, why -l AND -i (the .zshrc tradeoff), why $SHELL is not consulted,
and the single-spawning-route invariant. Per acceptance criterion 5 of
the shell-parity plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates BrewUI’s production Homebrew command execution to run brew through the user’s login + interactive shell so GUI-launched sessions see the same environment as Terminal (improving parity for brew config / brew doctor and other brew calls).

Changes:

  • Add LoginShellResolver (pw_shell via Directory Services) and LoginShellBrewCommandRunner (wraps brew invocations with <shell> -l -i -c ... and token quoting).
  • Wire the new runner into the three production live() factories so all brew subprocesses use the shell-wrapped path.
  • Add unit tests for resolver behavior and runner command construction/quoting; document the -l -i decision in .ai/memory.md.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
Tests/BrewCLITests/LoginShellResolverTests.swift Adds resolver unit tests (including a live probe test).
Tests/BrewCLITests/LoginShellBrewCommandRunnerTests.swift Adds tests for quoting and shell-wrapped argv construction.
Sources/BrewRepositories/BrewInstalledPackagesRepository.swift Switches production wiring to use LoginShellBrewCommandRunner.
Sources/BrewRepositories/BrewConfigRepository.swift Switches production wiring to use LoginShellBrewCommandRunner.
Sources/BrewCLI/LoginShellResolver.swift Introduces resolver for the current user’s login shell.
Sources/BrewCLI/LoginShellBrewCommandRunner.swift Introduces the command-runner decorator that executes brew via the login shell.
Sources/BrewCLI/BrewCommandExecutionContext+Live.swift Updates live execution context wiring to use the new runner.
.ai/memory.md Documents rationale/tradeoffs for -l -i and the new spawning route.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +39 to +51
public static let directoryServicesLookup: @Sendable () -> URL? = {
guard let entry = getpwuid(getuid()) else {
return nil
}
guard let cString = entry.pointee.pw_shell else {
return nil
}
let path = String(cString: cString)
guard !path.isEmpty else {
return nil
}
return URL(fileURLWithPath: path)
}
Comment on lines +41 to +52
/// Joins the brew executable path and its arguments into a single shell-safe command string,
/// suitable for passing as the argument to `sh -c` / `zsh -c`. Each token is wrapped in single
/// quotes; any embedded single-quote is escaped as `'\''` (POSIX-portable). This keeps package
/// names and flags that contain spaces, `$`, backticks, or other metacharacters intact.
static func shellCommand(executableURL: URL, arguments: [String]) -> String {
let tokens = [executableURL.path] + arguments
return tokens.map(singleQuoted).joined(separator: " ")
}

static func singleQuoted(_ string: String) -> String {
"'" + string.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
Comment on lines +33 to +39
@Test func `directoryServicesLookup returns the running user's login shell when present`() {
// Live probe — on macOS CI and dev machines the running user always has a pw_shell entry.
// The value itself varies (zsh, bash, fish, …), so we only assert non-nil and absolute path.
let resolved = LoginShellResolver.directoryServicesLookup()
try? #require(resolved != nil)
#expect(resolved?.path.hasPrefix("/") == true)
}
Comment on lines +47 to 53
/// Production wiring: brew is spawned through the user's login + interactive shell
/// (``LoginShellBrewCommandRunner``) so `brew config` reflects the same environment as Terminal.
public static func live() -> BrewConfigRepository {
BrewConfigRepository(
commandRunner: BrewCommandService(),
commandRunner: LoginShellBrewCommandRunner(),
locator: BrewExecutableLocator(),
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants