diff --git a/CHANGELOG.md b/CHANGELOG.md index 829dfcd6..92821c76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ # Change Log +## v2.3.3 (2024-12-04) + +[Full Changelog](https://github.com/ruby-git/ruby-git/compare/v2.3.2..v2.3.3) + +Changes since v2.3.2: + +* c25e5e0 test: add tests for spaces in the git binary path or the working dir +* 5f43a1a fix: open3 errors on binary paths with spaces +* 60b58ba test: add #run_command for tests to use instead of backticks + ## v2.3.2 (2024-11-19) [Full Changelog](https://github.com/ruby-git/ruby-git/compare/v2.3.1..v2.3.2) diff --git a/lib/git/base.rb b/lib/git/base.rb index 8a987313..2e9f1951 100644 --- a/lib/git/base.rb +++ b/lib/git/base.rb @@ -37,9 +37,15 @@ def self.config end def self.binary_version(binary_path) - git_cmd = "#{binary_path} -c core.quotePath=true -c color.ui=false version 2>&1" - result, status = Open3.capture2(git_cmd) - result = result.chomp + result = nil + status = nil + + begin + result, status = Open3.capture2e(binary_path, "-c", "core.quotePath=true", "-c", "color.ui=false", "version") + result = result.chomp + rescue Errno::ENOENT + raise RuntimeError, "Failed to get git version: #{binary_path} not found" + end if status.success? version = result[/\d+(\.\d+)+/] @@ -81,9 +87,14 @@ def self.root_of_worktree(working_dir) result = working_dir status = nil - git_cmd = "#{Git::Base.config.binary_path} -c core.quotePath=true -c color.ui=false rev-parse --show-toplevel 2>&1" - result, status = Open3.capture2(git_cmd, chdir: File.expand_path(working_dir)) - result = result.chomp + raise ArgumentError, "'#{working_dir}' does not exist" unless Dir.exist?(working_dir) + + begin + result, status = Open3.capture2e(Git::Base.config.binary_path, "-c", "core.quotePath=true", "-c", "color.ui=false", "rev-parse", "--show-toplevel", chdir: File.expand_path(working_dir)) + result = result.chomp + rescue Errno::ENOENT + raise ArgumentError, "Failed to find the root of the worktree: git binary not found" + end raise ArgumentError, "'#{working_dir}' is not in a git working tree" unless status.success? result diff --git a/lib/git/version.rb b/lib/git/version.rb index c5710194..475f6e81 100644 --- a/lib/git/version.rb +++ b/lib/git/version.rb @@ -1,5 +1,5 @@ module Git # The current gem version # @return [String] the current gem version. - VERSION='2.3.2' + VERSION='2.3.3' end diff --git a/tests/test_helper.rb b/tests/test_helper.rb index 7be31378..0bb809ea 100644 --- a/tests/test_helper.rb +++ b/tests/test_helper.rb @@ -162,4 +162,112 @@ def windows_platform? win_platform_regex = /mingw|mswin/ RUBY_PLATFORM =~ win_platform_regex || RUBY_DESCRIPTION =~ win_platform_regex end + + require 'delegate' + + # A wrapper around a ProcessExecuter::Status that also includes command output + # @api public + class CommandResult < SimpleDelegator + # Create a new CommandResult + # @example + # status = ProcessExecuter.spawn(*command, timeout:, out:, err:) + # CommandResult.new(status, out_buffer.string, err_buffer.string) + # @param status [ProcessExecuter::Status] The status of the process + # @param out [String] The standard output of the process + # @param err [String] The standard error of the process + def initialize(status, out, err) + super(status) + @out = out + @err = err + end + + # @return [String] The stdout output of the process + attr_reader :out + + # @return [String] The stderr output of the process + attr_reader :err + end + + # Run a command and return the status including stdout and stderr output + # + # @example + # command = %w[git status] + # status = run(command) + # status.success? # => true + # status.exitstatus # => 0 + # status.out # => "On branch master\nnothing to commit, working tree clean\n" + # status.err # => "" + # + # @param command [Array] The command to run + # @param timeout [Numeric, nil] Seconds to allow command to run before killing it or nil for no timeout + # @param raise_errors [Boolean] Raise an exception if the command fails + # @param error_message [String] The message to use when raising an exception + # + # @return [CommandResult] The result of running + # + def run_command(*command, timeout: nil, raise_errors: true, error_message: "#{command[0]} failed") + out_buffer = StringIO.new + out = ProcessExecuter::MonitoredPipe.new(out_buffer) + err_buffer = StringIO.new + err = ProcessExecuter::MonitoredPipe.new(err_buffer) + + status = ProcessExecuter.spawn(*command, timeout: timeout, out: out, err: err) + + raise "#{error_message}: #{err_buffer.string}" if raise_errors && !status.success? + + CommandResult.new(status, out_buffer.string, err_buffer.string) + end +end + +# Replace the default git binary with the given script +# +# This method creates a temporary directory and writes the given script to a file +# named `git` in a subdirectory named `bin`. This subdirectory name can be changed by +# passing a different value for the `subdir` parameter. +# +# On non-windows platforms, make sure the script starts with a hash bang. On windows, +# make sure the script has a `.bat` extension. +# +# On non-windows platforms, the script is made executable. +# +# `Git::Base.config.binary_path` set to the path to the script. +# +# The block is called passing the path to the mocked git binary. +# +# `Git::Base.config.binary_path` is reset to its original value after the block +# returns. +# +# @example mocked_git_script = <<~GIT_SCRIPT #!/bin/sh puts 'git version 1.2.3' +# GIT_SCRIPT +# +# mock_git_binary(mocked_git_script) do +# # Run Git commands here -- they will call the mocked git script +# end +# +# @param script [String] The bash script to run instead of the real git binary +# +# @param subdir [String] The subdirectory to place the mocked git binary in +# +# @yield Call the block while the git binary is mocked +# +# @yieldparam git_binary_path [String] The path to the mocked git binary +# +# @yieldreturn [void] the return value of the block is ignored +# +# @return [void] +# +def mock_git_binary(script, subdir: 'bin') + Dir.mktmpdir do |binary_dir| + binary_name = windows_platform? ? 'git.bat' : 'git' + git_binary_path = File.join(binary_dir, subdir, binary_name) + FileUtils.mkdir_p(File.dirname(git_binary_path)) + File.write(git_binary_path, script) + File.chmod(0755, git_binary_path) unless windows_platform? + saved_binary_path = Git::Base.config.binary_path + Git::Base.config.binary_path = git_binary_path + + yield git_binary_path + + Git::Base.config.binary_path = saved_binary_path + end end diff --git a/tests/units/test_branch.rb b/tests/units/test_branch.rb index aaea661f..f150d878 100644 --- a/tests/units/test_branch.rb +++ b/tests/units/test_branch.rb @@ -50,6 +50,26 @@ def setup end end + test 'Git::Base#branches when checked out branch is a remote branch' do + in_temp_dir do + Dir.mkdir('remote_git') + Dir.chdir('remote_git') do + run_command 'git', 'init', '--initial-branch=main' + File.write('file1.txt', 'This is file1') + run_command 'git', 'add', 'file1.txt' + run_command 'git', 'commit', '-m', 'Add file1.txt' + end + + run_command 'git', 'clone', File.join('remote_git', '.git'), 'local_git' + + Dir.chdir('local_git') do + run_command 'git', 'checkout', 'origin/main' + git = Git.open('.') + assert_nothing_raised { git.branches } + end + end + end + # Git::Lib#current_branch_state test 'Git::Lib#current_branch_state -- empty repository' do diff --git a/tests/units/test_git_base_root_of_worktree.rb b/tests/units/test_git_base_root_of_worktree.rb new file mode 100644 index 00000000..3a13b59e --- /dev/null +++ b/tests/units/test_git_base_root_of_worktree.rb @@ -0,0 +1,90 @@ +require 'test_helper' + +class TestGitBaseRootOfWorktree < Test::Unit::TestCase + def mocked_git_script(toplevel) = <<~GIT_SCRIPT + #!/bin/sh + # Loop through the arguments and check for the "rev-parse --show-toplevel" args + for arg in "$@"; do + if [ "$arg" = "version" ]; then + echo "git version 1.2.3" + exit 0 + elif [ "$arg" = "rev-parse" ]; then + REV_PARSE_ARG=true + elif [ "$REV_PARSE_ARG" = "true" ] && [ $arg = "--show-toplevel" ]; then + echo #{toplevel} + exit 0 + fi + done + exit 1 + GIT_SCRIPT + + def test_root_of_worktree + omit('Only implemented for non-windows platforms') if windows_platform? + + in_temp_dir do |toplevel| + `git init` + + mock_git_binary(mocked_git_script(toplevel)) do + working_dir = File.join(toplevel, 'config') + Dir.mkdir(working_dir) + + assert_equal(toplevel, Git::Base.root_of_worktree(working_dir)) + end + end + end + + def test_working_dir_has_spaces + omit('Only implemented for non-windows platforms') if windows_platform? + + in_temp_dir do |toplevel| + `git init` + + mock_git_binary(mocked_git_script(toplevel)) do + working_dir = File.join(toplevel, 'app config') + Dir.mkdir(working_dir) + + assert_equal(toplevel, Git::Base.root_of_worktree(working_dir)) + end + end + end + + def test_working_dir_does_not_exist + assert_raise ArgumentError do + Git::Base.root_of_worktree('/path/to/nonexistent/work_dir') + end + end + + def mocked_git_script2 = <<~GIT_SCRIPT + #!/bin/sh + # Loop through the arguments and check for the "rev-parse --show-toplevel" args + for arg in "$@"; do + if [ "$arg" = "version" ]; then + echo "git version 1.2.3" + exit 0 + elif [ "$arg" = "rev-parse" ]; then + REV_PARSE_ARG=true + elif [ "$REV_PARSE_ARG" = "true" ] && [ $arg = "--show-toplevel" ]; then + echo fatal: not a git repository 1>&2 + exit 128 + fi + done + exit 1 + GIT_SCRIPT + + def test_working_dir_not_in_work_tree + omit('Only implemented for non-windows platforms') if windows_platform? + + in_temp_dir do |temp_dir| + toplevel = File.join(temp_dir, 'my_repo') + Dir.mkdir(toplevel) do + `git init` + end + + mock_git_binary(mocked_git_script2) do + assert_raise ArgumentError do + Git::Base.root_of_worktree(temp_dir) + end + end + end + end +end diff --git a/tests/units/test_git_binary_version.rb b/tests/units/test_git_binary_version.rb index 09afc1a1..c40b99a9 100644 --- a/tests/units/test_git_binary_version.rb +++ b/tests/units/test_git_binary_version.rb @@ -1,7 +1,7 @@ require 'test_helper' class TestGitBinaryVersion < Test::Unit::TestCase - def windows_mocked_git_binary = <<~GIT_SCRIPT + def mocked_git_script_windows = <<~GIT_SCRIPT @echo off # Loop through the arguments and check for the version command for %%a in (%*) do ( @@ -13,7 +13,7 @@ def windows_mocked_git_binary = <<~GIT_SCRIPT exit /b 1 GIT_SCRIPT - def linux_mocked_git_binary = <<~GIT_SCRIPT + def mocked_git_script_linux = <<~GIT_SCRIPT #!/bin/sh # Loop through the arguments and check for the version command for arg in "$@"; do @@ -25,24 +25,28 @@ def linux_mocked_git_binary = <<~GIT_SCRIPT exit 1 GIT_SCRIPT - def test_binary_version_windows - omit('Only implemented for Windows') unless windows_platform? + def mocked_git_script + if windows_platform? + mocked_git_script_windows + else + mocked_git_script_linux + end + end + def test_binary_version in_temp_dir do |path| - git_binary_path = File.join(path, 'my_git.bat') - File.write(git_binary_path, windows_mocked_git_binary) - assert_equal([1, 2, 3], Git.binary_version(git_binary_path)) + mock_git_binary(mocked_git_script) do |git_binary_path| + assert_equal([1, 2, 3], Git.binary_version(git_binary_path)) + end end end - def test_binary_version_linux - omit('Only implemented for Linux') if windows_platform? - + def test_binary_version_with_spaces in_temp_dir do |path| - git_binary_path = File.join(path, 'my_git.bat') - File.write(git_binary_path, linux_mocked_git_binary) - File.chmod(0755, git_binary_path) - assert_equal([1, 2, 3], Git.binary_version(git_binary_path)) + subdir = 'Git Bin Directory' + mock_git_binary(mocked_git_script, subdir: subdir) do |git_binary_path| + assert_equal([1, 2, 3], Git.binary_version(git_binary_path)) + end end end