Switch branches/tags
Find file Copy path
Fetching contributors…
Cannot retrieve contributors at this time
135 lines (120 sloc) 6 KB
require "open3"
module Fastlane
module Actions
# Execute a shell command
# This method will output the string and execute it
# Just an alias for sh_no_action
# When running this in tests, it will return the actual command instead of executing it
# @param log [Boolean] should fastlane print out the executed command
# @param error_callback [Block] a callback invoked with the command output if there is a non-zero exit status
def*command, log: true, error_callback: nil, &b)
sh_control_output(*command, print_command: log, print_command_output: log, error_callback: error_callback, &b)
def self.sh_no_action(*command, log: true, error_callback: nil, &b)
sh_control_output(*command, print_command: log, print_command_output: log, error_callback: error_callback, &b)
# @param command The command to be executed (variadic)
# @param print_command [Boolean] Should we print the command that's being executed
# @param print_command_output [Boolean] Should we print the command output during execution
# @param error_callback [Block] A block that's called if the command exits with a non-zero status
# @yield [status, result, cmd] The return status of the command, all output from the command and an equivalent shell command
# @yieldparam [Process::Status] status A Process::Status indicating the status of the completed command
# @yieldparam [String] result The complete output to stdout and stderr of the completed command
# @yieldparam [String] cmd A shell command equivalent to the arguments passed
# rubocop: disable Metrics/PerceivedComplexity
def self.sh_control_output(*command, print_command: true, print_command_output: true, error_callback: nil)
print_command = print_command_output = true if $troubleshoot
# Set the encoding first, the user might have set it wrong
previous_encoding = [Encoding.default_external, Encoding.default_internal]
Encoding.default_external = Encoding::UTF_8
Encoding.default_internal = Encoding::UTF_8
# Workaround to support previous Fastlane syntax.
# This has some limitations. For example, it requires the caller to shell escape
# everything because of usages like ["ls -la", "/tmp"] instead of ["ls", "-la", "/tmp"].
command = [command.first.join(" ")] if command.length == 1 && command.first.kind_of?(Array)
shell_command = shell_command_from_args(*command)
UI.command(shell_command) if print_command
result = ''
exit_status = nil
if Helper.sh_enabled?
# The argument list is passed directly to Open3.popen2e, which
# handles the variadic argument list in the same way as Kernel#spawn.
# ( or
# Process.spawn (
# sh "ls -la /Applications/Xcode\"
# sh "ls", "-la", "/Applications/Xcode"
# sh({ "FOO" => "Hello" }, "echo $FOO")
Open3.popen2e(*command) do |stdin, io, thread|
io.sync = true
io.each do |line|
UI.command_output(line.strip) if print_command_output
result << line
exit_status = thread.value
# Checking Process::Status#exitstatus instead of #success? makes for more
# testable code. (Tests mock exitstatus only.) This is also consistent
# with previous implementations of sh and... probably portable to all
# relevant platforms.
if exit_status.exitstatus != 0
message = if print_command
"Exit status of command '#{shell_command}' was #{exit_status.exitstatus} instead of 0."
"Shell command exited with exit status #{exit_status.exitstatus} instead of 0."
message += "\n#{result}" if print_command_output
if error_callback || block_given?
# block notified below, on success or failure
error_callback &&
result << shell_command # only for the tests
if block_given?
# Avoid yielding nil in tests. $? will be meaningless, but calls to
# it will not crash. There is no The alternative
# is to move this inside the sh_enabled? check and not yield in tests.
return yield(exit_status || $?, result, shell_command)
rescue => ex
raise ex
Encoding.default_external = previous_encoding.first
Encoding.default_internal = previous_encoding.last
# rubocop: enable Metrics/PerceivedComplexity
# Used to produce a shell command string from a list of arguments that may
# be passed to methods such as Kernel#system, Kernel#spawn and Open3.popen2e
# in order to print the command to the terminal. The same *args are passed
# directly to a system call (Open3.popen2e). This interpretation is not
# used when executing a command.
# @param args Any number of arguments used to construct a command
# @raise [ArgumentError] If no arguments passed
# @return [String] A shell command representing the arguments passed in
def self.shell_command_from_args(*args)
raise ArgumentError, "sh requires at least one argument" unless args.count > 0
command = ""
# Optional initial environment Hash
if args.first.kind_of?(Hash)
command = { |k, v| "#{k}=#{v.shellescape}" }.join(" ") + " "
# Support [ "/usr/local/bin/foo", "foo" ], "-x", ...
if args.first.kind_of?(Array)
command += args.shift.first.shellescape + " " + args.shelljoin
command.chomp!(" ")
elsif args.count == 1 && args.first.kind_of?(String)
command += args.first
command += args.shelljoin