Skip to content

Add brew bundle exec --services #19552

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Mar 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions Library/Homebrew/bundle/brew_installer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,15 @@ def changed?

def service_change_state!(verbose:)
require "bundle/brew_services"

file = Bundle::BrewServices.versioned_service_file(@name)

if restart_service_needed?
puts "Restarting #{@name} service." if verbose
BrewServices.restart(@full_name, verbose:)
BrewServices.restart(@full_name, file:, verbose:)
elsif start_service_needed?
puts "Starting #{@name} service." if verbose
BrewServices.start(@full_name, verbose:)
BrewServices.start(@full_name, file:, verbose:)
else
true
end
Expand Down
54 changes: 43 additions & 11 deletions Library/Homebrew/bundle/brew_services.rb
Original file line number Diff line number Diff line change
@@ -1,43 +1,58 @@
# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true

require "services/system"

module Homebrew
module Bundle
module BrewServices
module_function

def reset!
def self.reset!
@started_services = nil
end

def stop(name, verbose: false)
def self.stop(name, keep: false, verbose: false)
return true unless started?(name)

return unless Bundle.brew("services", "stop", name, verbose:)
args = ["services", "stop", name]
args << "--keep" if keep
return unless Bundle.brew(*args, verbose:)

started_services.delete(name)
true
end

def start(name, verbose: false)
return unless Bundle.brew("services", "start", name, verbose:)
def self.start(name, file: nil, verbose: false)
args = ["services", "start", name]
args << "--file=#{file}" if file
return unless Bundle.brew(*args, verbose:)

started_services << name
true
end

def self.run(name, file: nil, verbose: false)
args = ["services", "run", name]
args << "--file=#{file}" if file
return unless Bundle.brew(*args, verbose:)

started_services << name
true
end

def restart(name, verbose: false)
return unless Bundle.brew("services", "restart", name, verbose:)
def self.restart(name, file: nil, verbose: false)
args = ["services", "restart", name]
args << "--file=#{file}" if file
return unless Bundle.brew(*args, verbose:)

started_services << name
true
end

def started?(name)
def self.started?(name)
started_services.include? name
end

def started_services
def self.started_services
@started_services ||= begin
states_to_skip = %w[stopped none]
Utils.safe_popen_read(HOMEBREW_BREW_FILE, "services", "list").lines.filter_map do |line|
Expand All @@ -48,6 +63,23 @@ def started_services
end
end
end

def self.versioned_service_file(name)
env_version = Bundle.formula_versions_from_env[name]
return if env_version.nil?

formula = Formula[name]
prefix = formula.rack/env_version
return unless prefix.directory?

service_file = if Homebrew::Services::System.launchctl?
prefix/"#{formula.plist_name}.plist"
else
prefix/"#{formula.service_name}.service"
end

service_file if service_file.file?
end
end
end
end
132 changes: 130 additions & 2 deletions Library/Homebrew/bundle/commands/exec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@

PATH_LIKE_ENV_REGEX = /.+#{File::PATH_SEPARATOR}/

def self.run(*args, global: false, file: nil, subcommand: "")
def self.run(*args, global: false, file: nil, subcommand: "", services: false)
# Cleanup Homebrew's global environment
HOMEBREW_ENV_CLEANUP.each { |key| ENV.delete(key) }

Expand Down Expand Up @@ -157,7 +157,135 @@
return
end

exec(*args)
if services
require "bundle/brew_services"

exit_code = 0
run_services(@dsl.entries) do
Kernel.system(*args)
exit_code = $CHILD_STATUS.exitstatus

Check warning on line 166 in Library/Homebrew/bundle/commands/exec.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/bundle/commands/exec.rb#L163-L166

Added lines #L163 - L166 were not covered by tests
Copy link
Member

Choose a reason for hiding this comment

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

Would love a follow-up PR with some test coverage here.

end
exit!(exit_code)

Check warning on line 168 in Library/Homebrew/bundle/commands/exec.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/bundle/commands/exec.rb#L168

Added line #L168 was not covered by tests
else
exec(*args)
end
end

sig {
params(

Check warning on line 175 in Library/Homebrew/bundle/commands/exec.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/bundle/commands/exec.rb#L175

Added line #L175 was not covered by tests
entries: T::Array[Homebrew::Bundle::Dsl::Entry],
_block: T.proc.params(
info: T::Hash[String, T.anything],
service_file: Pathname,
conflicting_services: T::Array[T::Hash[String, T.anything]],
).void,
).void
}
private_class_method def self.map_service_info(entries, &_block)
entries_formulae = entries.filter_map do |entry|

Check warning on line 185 in Library/Homebrew/bundle/commands/exec.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/bundle/commands/exec.rb#L185

Added line #L185 was not covered by tests
Copy link
Member

Choose a reason for hiding this comment

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

Would love a follow-up PR with some test coverage here.

next if entry.type != :brew

formula = Formula[entry.name]

Check warning on line 188 in Library/Homebrew/bundle/commands/exec.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/bundle/commands/exec.rb#L188

Added line #L188 was not covered by tests
next unless formula.any_version_installed?

[entry, formula]

Check warning on line 191 in Library/Homebrew/bundle/commands/exec.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/bundle/commands/exec.rb#L191

Added line #L191 was not covered by tests
end.to_h

# The formula + everything that could possible conflict with the service
names_to_query = entries_formulae.flat_map do |entry, formula|

Check warning on line 195 in Library/Homebrew/bundle/commands/exec.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/bundle/commands/exec.rb#L195

Added line #L195 was not covered by tests
[
formula.name,

Check warning on line 197 in Library/Homebrew/bundle/commands/exec.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/bundle/commands/exec.rb#L197

Added line #L197 was not covered by tests
*formula.versioned_formulae_names,
*formula.conflicts.map(&:name),
*entry.options[:conflicts_with],
]
end

# We parse from a command invocation so that brew wrappers can invoke special actions
# for the elevated nature of `brew services`
services_info = JSON.parse(

Check warning on line 206 in Library/Homebrew/bundle/commands/exec.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/bundle/commands/exec.rb#L206

Added line #L206 was not covered by tests
Utils.safe_popen_read(HOMEBREW_BREW_FILE, "services", "info", "--json", *names_to_query),
)

entries_formulae.filter_map do |entry, formula|
service_file = Bundle::BrewServices.versioned_service_file(entry.name)

Check warning on line 211 in Library/Homebrew/bundle/commands/exec.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/bundle/commands/exec.rb#L210-L211

Added lines #L210 - L211 were not covered by tests

unless service_file&.file?
prefix = formula.any_installed_prefix
next if prefix.nil?

service_file = if Homebrew::Services::System.launchctl?

Check warning on line 217 in Library/Homebrew/bundle/commands/exec.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/bundle/commands/exec.rb#L217

Added line #L217 was not covered by tests
prefix/"#{formula.plist_name}.plist"
else
prefix/"#{formula.service_name}.service"
end
end

next unless service_file.file?

info = services_info.find { |candidate| candidate["name"] == formula.name }
conflicting_services = services_info.select do |candidate|

Check warning on line 227 in Library/Homebrew/bundle/commands/exec.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/bundle/commands/exec.rb#L226-L227

Added lines #L226 - L227 were not covered by tests
next unless candidate["running"]

formula.versioned_formulae_names.include?(candidate["name"])

Check warning on line 230 in Library/Homebrew/bundle/commands/exec.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/bundle/commands/exec.rb#L230

Added line #L230 was not covered by tests
end

raise "Failed to get service info for #{entry.name}" if info.nil?

yield info, service_file, conflicting_services

Check warning on line 235 in Library/Homebrew/bundle/commands/exec.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/bundle/commands/exec.rb#L235

Added line #L235 was not covered by tests
end
end

sig { params(entries: T::Array[Homebrew::Bundle::Dsl::Entry], _block: T.nilable(T.proc.void)).void }
private_class_method def self.run_services(entries, &_block)
services_to_restart = []

Check warning on line 241 in Library/Homebrew/bundle/commands/exec.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/bundle/commands/exec.rb#L241

Added line #L241 was not covered by tests
Copy link
Member

Choose a reason for hiding this comment

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

Would love a follow-up PR with some test coverage here.


map_service_info(entries) do |info, service_file, conflicting_services|

Check warning on line 243 in Library/Homebrew/bundle/commands/exec.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/bundle/commands/exec.rb#L243

Added line #L243 was not covered by tests
if info["running"] && !Bundle::BrewServices.stop(info["name"], keep: true)
opoo "Failed to stop #{info["name"]} service"
end

conflicting_services.each do |conflict|
if Bundle::BrewServices.stop(conflict["name"], keep: true)

Check warning on line 249 in Library/Homebrew/bundle/commands/exec.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/bundle/commands/exec.rb#L248-L249

Added lines #L248 - L249 were not covered by tests
services_to_restart << conflict["name"] if conflict["registered"]
else
opoo "Failed to stop #{conflict["name"]} service"
end
end

unless Bundle::BrewServices.run(info["name"], file: service_file)
opoo "Failed to start #{info["name"]} service"
end
end

return unless block_given?

begin
yield

Check warning on line 264 in Library/Homebrew/bundle/commands/exec.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/bundle/commands/exec.rb#L264

Added line #L264 was not covered by tests
ensure
# Do a full re-evaluation of services instead state has changed
stop_services(entries)

Check warning on line 267 in Library/Homebrew/bundle/commands/exec.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/bundle/commands/exec.rb#L267

Added line #L267 was not covered by tests

services_to_restart.each do |service|

Check warning on line 269 in Library/Homebrew/bundle/commands/exec.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/bundle/commands/exec.rb#L269

Added line #L269 was not covered by tests
next if Bundle::BrewServices.run(service)

opoo "Failed to restart #{service} service"

Check warning on line 272 in Library/Homebrew/bundle/commands/exec.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/bundle/commands/exec.rb#L272

Added line #L272 was not covered by tests
end
end
end

sig { params(entries: T::Array[Homebrew::Bundle::Dsl::Entry]).void }
private_class_method def self.stop_services(entries)
map_service_info(entries) do |info, _, _|

Check warning on line 279 in Library/Homebrew/bundle/commands/exec.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/bundle/commands/exec.rb#L279

Added line #L279 was not covered by tests
Copy link
Member

Choose a reason for hiding this comment

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

Would love a follow-up PR with some test coverage here.

next unless info["loaded"]

# Try avoid services not started by `brew bundle services`
next if Homebrew::Services::System.launchctl? && info["registered"]

if info["running"] && !Bundle::BrewServices.stop(info["name"], keep: true)
opoo "Failed to stop #{info["name"]} service"
end
end
end
end
end
Expand Down
6 changes: 4 additions & 2 deletions Library/Homebrew/cmd/bundle.rb
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@
"even if `$HOMEBREW_BUNDLE_NO_UPGRADE` is set. "
switch "--install",
description: "Run `install` before continuing to other operations e.g. `exec`."
switch "--services",
description: "Temporarily start services while running the `exec` or `sh` command."
switch "-f", "--force",
description: "`install` runs with `--force`/`--overwrite`. " \
"`dump` overwrites an existing `Brewfile`. " \
Expand Down Expand Up @@ -133,7 +135,7 @@
require "bundle"

subcommand = args.named.first.presence
if ["exec", "add", "remove"].exclude?(subcommand) && args.named.size > 1
if %w[exec add remove].exclude?(subcommand) && args.named.size > 1
raise UsageError, "This command does not take more than 1 subcommand argument."
end

Expand Down Expand Up @@ -232,7 +234,7 @@
["env"]
end
require "bundle/commands/exec"
Homebrew::Bundle::Commands::Exec.run(*named_args, global:, file:, subcommand:)
Homebrew::Bundle::Commands::Exec.run(*named_args, global:, file:, subcommand:, services: args.services?)

Check warning on line 237 in Library/Homebrew/cmd/bundle.rb

View check run for this annotation

Codecov / codecov/patch

Library/Homebrew/cmd/bundle.rb#L237

Added line #L237 was not covered by tests
when "list"
require "bundle/commands/list"
Homebrew::Bundle::Commands::List.run(
Expand Down
28 changes: 22 additions & 6 deletions Library/Homebrew/cmd/services.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,14 @@ class Services < AbstractCommand
[`sudo`] `brew services start` (<formula>|`--all`|`--file=`):
Start the service <formula> immediately and register it to launch at login (or boot).

[`sudo`] `brew services stop` (<formula>|`--all`):
Stop the service <formula> immediately and unregister it from launching at login (or boot).
[`sudo`] `brew services stop` (`--keep`) (`--no-wait`|`--max-wait=`) (<formula>|`--all`):
Stop the service <formula> immediately and unregister it from launching at login (or boot),
unless `--keep` is specified.

[`sudo`] `brew services kill` (<formula>|`--all`):
Stop the service <formula> immediately but keep it registered to launch at login (or boot).

[`sudo`] `brew services restart` (<formula>|`--all`):
[`sudo`] `brew services restart` (<formula>|`--all`|`--file=`):
Stop (if necessary) and start the service <formula> immediately and register it to launch at login (or boot).

[`sudo`] `brew services cleanup`:
Expand All @@ -57,6 +58,7 @@ class Services < AbstractCommand
switch "--all", description: "Run <subcommand> on all services."
switch "--json", description: "Output as JSON."
switch "--no-wait", description: "Don't wait for `stop` to finish stopping the service."
switch "--keep", description: "When stopped, don't unregister the service from launching at login (or boot)."
conflicts "--max-wait=", "--no-wait"
named_args
end
Expand Down Expand Up @@ -108,6 +110,7 @@ def run
file_commands = [
*Homebrew::Services::Commands::Start::TRIGGERS,
*Homebrew::Services::Commands::Run::TRIGGERS,
*Homebrew::Services::Commands::Restart::TRIGGERS,
]
if file_commands.exclude?(subcommand)
raise UsageError, "The `#{subcommand}` subcommand does not accept the --file= argument!"
Expand All @@ -117,6 +120,14 @@ def run
end
end

unless Homebrew::Services::Commands::Stop::TRIGGERS.include?(subcommand)
raise UsageError, "The `#{subcommand}` subcommand does not accept the --keep argument!" if args.keep?
raise UsageError, "The `#{subcommand}` subcommand does not accept the --no-wait argument!" if args.no_wait?
if args.max_wait
raise UsageError, "The `#{subcommand}` subcommand does not accept the --max-wait= argument!"
end
end

opoo "The --all argument overrides provided formula argument!" if formulae.present? && args.all?

targets = if args.all?
Expand Down Expand Up @@ -156,14 +167,19 @@ def run
when *Homebrew::Services::Commands::Info::TRIGGERS
Homebrew::Services::Commands::Info.run(targets, verbose: args.verbose?, json: args.json?)
when *Homebrew::Services::Commands::Restart::TRIGGERS
Homebrew::Services::Commands::Restart.run(targets, verbose: args.verbose?)
Homebrew::Services::Commands::Restart.run(targets, args.file, verbose: args.verbose?)
when *Homebrew::Services::Commands::Run::TRIGGERS
Homebrew::Services::Commands::Run.run(targets, args.file, verbose: args.verbose?)
when *Homebrew::Services::Commands::Start::TRIGGERS
Homebrew::Services::Commands::Start.run(targets, args.file, verbose: args.verbose?)
when *Homebrew::Services::Commands::Stop::TRIGGERS
max_wait = args.max_wait.to_f
Homebrew::Services::Commands::Stop.run(targets, verbose: args.verbose?, no_wait: args.no_wait?, max_wait:)
Homebrew::Services::Commands::Stop.run(
targets,
verbose: args.verbose?,
no_wait: args.no_wait?,
max_wait: args.max_wait.to_f,
keep: args.keep?,
)
when *Homebrew::Services::Commands::Kill::TRIGGERS
Homebrew::Services::Commands::Kill.run(targets, verbose: args.verbose?)
else
Expand Down
Loading
Loading