diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 3a2cd0df..bc207a9e 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -18,7 +18,7 @@ jobs: fail-fast: false matrix: # Only the latest versions of JRuby and TruffleRuby are tested - ruby: ["3.0", "3.1", "3.2", "3.3", "truffleruby-23.1.1", "jruby-9.4.5.0"] + ruby: ["3.0", "3.1", "3.2", "3.3", "truffleruby-24.0.0", "jruby-9.4.5.0"] operating-system: [ubuntu-latest] experimental: [No] include: @@ -38,7 +38,7 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Ruby uses: ruby/setup-ruby@v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index eb37889d..073223fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ # Change Log +## v2.0.0.pre2 (2024-02-24) + +[Full Changelog](https://github.com/ruby-git/ruby-git/compare/v2.0.0.pre1..v2.0.0.pre2) + +Changes since v2.0.0.pre1: + +* 023017b Add a timeout for git commands (#692) +* 8286ceb Refactor the Error heriarchy (#693) + ## v2.0.0.pre1 (2024-01-15) [Full Changelog](https://github.com/ruby-git/ruby-git/compare/v1.19.1..v2.0.0.pre1) diff --git a/README.md b/README.md index f0c42db7..64f05cac 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,18 @@ [![Build Status](https://github.com/ruby-git/ruby-git/workflows/CI/badge.svg?branch=master)](https://github.com/ruby-git/ruby-git/actions?query=workflow%3ACI) [![Code Climate](https://codeclimate.com/github/ruby-git/ruby-git.png)](https://codeclimate.com/github/ruby-git/ruby-git) +* [Summary](#summary) +* [v2.0.0 pre-release](#v200-pre-release) +* [Install](#install) +* [Major Objects](#major-objects) +* [Errors Raised By This Gem](#errors-raised-by-this-gem) +* [Specifying And Handling Timeouts](#specifying-and-handling-timeouts) +* [Examples](#examples) +* [Ruby version support policy](#ruby-version-support-policy) +* [License](#license) + +## Summary + The [git gem](https://rubygems.org/gems/git) provides an API that can be used to create, read, and manipulate Git repositories by wrapping system calls to the `git` command line. The API can be used for working with Git in complex interactions @@ -90,11 +102,119 @@ Pass the `--all` option to `git log` as follows: **Git::Worktrees** - Enumerable object that holds `Git::Worktree objects`. +## Errors Raised By This Gem + +This gem raises custom errors that derive from `Git::Error`. These errors are +arranged in the following class heirarchy: + +Error heirarchy: + +```text +Error +└── CommandLineError + ├── FailedError + └── SignaledError + └── TimeoutError +``` + +Other standard errors may also be raised like `ArgumentError`. Each method should +document the errors it may raise. + +Description of each Error class: + +* `Error`: This catch-all error serves as the base class for other custom errors in this + gem. Errors of this class are raised when no more approriate specific error to + raise. +* `CommandLineError`: This error is raised when there's a problem executing the git + command line. This gem will raise a more specific error depending on how the + command line failed. +* `FailedError`: This error is raised when the git command line exits with a non-zero + status code that is not expected by the git gem. +* `SignaledError`: This error is raised when the git command line is terminated as a + result of receiving a signal. This could happen if the process is forcibly + terminated or if there is a serious system error. +* `TimeoutError`: This is a specific type of `SignaledError` that is raised when the + git command line operation times out and is killed via the SIGKILL signal. This + happens if the operation takes longer than the timeout duration configured in + `Git.config.timeout` or via the `:timeout` parameter given in git methods that + support this parameter. + +`Git::GitExecuteError` remains as an alias for `Git::Error`. It is considered +deprecated as of git-2.0.0. + +Here is an example of catching errors when using the git gem: + +```ruby +begin + timeout_duration = 0.001 # seconds + repo = Git.clone('https://github.com/ruby-git/ruby-git', 'ruby-git-temp', timeout: timeout_duration) +rescue Git::TimeoutError => e # Catch the more specific error first! + puts "Git clone took too long and timed out #{e}" +rescue Git::Error => e + puts "Received the following error: #{e}" +``` + +## Specifying And Handling Timeouts + +The timeout feature was added in git gem version `2.0.0`. + +A timeout for git operations can be set either globally or for specific method calls +that accept a `:timeout` parameter. + +The timeout value must be a real, non-negative `Numeric` value that specifies a +number of seconds a `git` command will be given to complete before being sent a KILL +signal. This library may hang if the `git` command does not terminate after receiving +the KILL signal. + +When a command times out, a `Git::TimeoutError` is raised. + +If the timeout value is `0` or `nil`, no timeout will be enforced. + +If a method accepts a `:timeout` parameter and a receives a non-nil value, it will +override the global timeout value. In this context, a value of `nil` (which is +usually the default) will use the global timeout value and a value of `0` will turn +off timeout enforcement for that method call no matter what the global value is. + +To set a global timeout, use the `Git.config` object: + +```ruby +Git.config.timeout = nil # a value of nil or 0 means no timeout is enforced +Git.config.timeout = 1.5 # can be any real, non-negative Numeric interpreted as number of seconds +``` + +The global timeout can be overridden for a specific method if the method accepts a +`:timeout` parameter: + +```ruby +repo_url = 'https://github.com/ruby-git/ruby-git.git' +Git.clone(repo_url) # Use the global timeout value +Git.clone(repo_url, timeout: nil) # Also uses the global timeout value +Git.clone(repo_url, timeout: 0) # Do not enforce a timeout +Git.clone(repo_url, timeout: 10.5) # Timeout after 10.5 seconds raising Git::SignaledError +``` + +If the command takes too long, a `Git::SignaledError` will be raised: + +```ruby +begin + Git.clone(repo_url, timeout: 10) +rescue Git::TimeoutError => e + result = e.result + result.class #=> Git::CommandLineResult + result.status #=> # + result.status.timeout? #=> true + result.git_cmd # The git command ran as an array of strings + result.stdout # The command's output to stdout until it was terminated + result.stderr # The command's output to stderr until it was terminated +end +``` + ## Examples Here are a bunch of examples of how to use the Ruby/Git package. Require the 'git' gem. + ```ruby require 'git' ``` @@ -261,11 +381,11 @@ g.add(:all=>true) # git add --all -- "." g.add('file_path') # git add -- "file_path" g.add(['file_path_1', 'file_path_2']) # git add -- "file_path_1" "file_path_2" -g.remove() # git rm -f -- "." -g.remove('file.txt') # git rm -f -- "file.txt" -g.remove(['file.txt', 'file2.txt']) # git rm -f -- "file.txt" "file2.txt" -g.remove('file.txt', :recursive => true) # git rm -f -r -- "file.txt" -g.remove('file.txt', :cached => true) # git rm -f --cached -- "file.txt" +g.remove() # git rm -f -- "." +g.remove('file.txt') # git rm -f -- "file.txt" +g.remove(['file.txt', 'file2.txt']) # git rm -f -- "file.txt" "file2.txt" +g.remove('file.txt', :recursive => true) # git rm -f -r -- "file.txt" +g.remove('file.txt', :cached => true) # git rm -f --cached -- "file.txt" g.commit('message') g.commit_all('message') diff --git a/bin/command_line_test b/bin/command_line_test index a88893a2..1827da2b 100755 --- a/bin/command_line_test +++ b/bin/command_line_test @@ -35,10 +35,11 @@ require 'optparse' class CommandLineParser def initialize @option_parser = OptionParser.new + @duration = 0 define_options end - attr_reader :stdout, :stderr, :exitstatus, :signal + attr_reader :duration, :stdout, :stderr, :exitstatus, :signal # Parse the command line arguements returning the options # @@ -84,7 +85,7 @@ class CommandLineParser option_parser.separator 'Options:' %i[ define_help_option define_stdout_option define_stderr_option - define_exitstatus_option define_signal_option + define_exitstatus_option define_signal_option define_duration_option ].each { |m| send(m) } end @@ -135,6 +136,15 @@ class CommandLineParser end end + # Define the duration option + # @return [void] + # @api private + def define_duration_option + option_parser.on('--duration=0', 'The number of seconds the command should take') do |duration| + @duration = Integer(duration) + end + end + # Define the help option # @return [void] # @api private @@ -176,5 +186,6 @@ options = CommandLineParser.new.parse(*ARGV) STDOUT.puts options.stdout if options.stdout STDERR.puts options.stderr if options.stderr +sleep options.duration unless options.duration.zero? Process.kill(options.signal, Process.pid) if options.signal exit(options.exitstatus) if options.exitstatus diff --git a/git.gemspec b/git.gemspec index 5ba540c0..8a2af4e4 100644 --- a/git.gemspec +++ b/git.gemspec @@ -28,7 +28,7 @@ Gem::Specification.new do |s| s.requirements = ['git 2.28.0 or greater'] s.add_runtime_dependency 'addressable', '~> 2.8' - s.add_runtime_dependency 'process_executer', '~> 0.7' + s.add_runtime_dependency 'process_executer', '~> 1.1' s.add_runtime_dependency 'rchardet', '~> 1.8' s.add_development_dependency 'minitar', '~> 0.9' diff --git a/lib/git.rb b/lib/git.rb index f4825206..4b41a393 100644 --- a/lib/git.rb +++ b/lib/git.rb @@ -7,11 +7,13 @@ require 'git/base' require 'git/branch' require 'git/branches' +require 'git/command_line_error' require 'git/command_line_result' require 'git/command_line' require 'git/config' require 'git/diff' require 'git/encoding_utils' +require 'git/error' require 'git/escaped_path' require 'git/failed_error' require 'git/git_execute_error' @@ -24,9 +26,9 @@ require 'git/repository' require 'git/signaled_error' require 'git/status' -require 'git/signaled_error' require 'git/stash' require 'git/stashes' +require 'git/timeout_error' require 'git/url' require 'git/version' require 'git/working_directory' diff --git a/lib/git/command_line.rb b/lib/git/command_line.rb index 3001c55d..ed81cba6 100644 --- a/lib/git/command_line.rb +++ b/lib/git/command_line.rb @@ -166,6 +166,13 @@ def initialize(env, binary_path, global_opts, logger) # @param merge [Boolean] whether to merge stdout and stderr in the string returned # @param chdir [String] the directory to run the command in # + # @param timeout [Numeric, nil] the maximum seconds to wait for the command to complete + # + # If timeout is zero or nil, the command will not time out. If the command + # times out, it is killed via a SIGKILL signal and `Git::TimeoutError` is raised. + # + # If the command does not respond to SIGKILL, it will hang this method. + # # @return [Git::CommandLineResult] the output of the command # # This result of running the command. @@ -173,14 +180,16 @@ def initialize(env, binary_path, global_opts, logger) # @raise [ArgumentError] if `args` is not an array of strings # @raise [Git::SignaledError] if the command was terminated because of an uncaught signal # @raise [Git::FailedError] if the command returned a non-zero exitstatus + # @raise [Git::GitExecuteError] if an exception was raised while collecting subprocess output + # @raise [Git::TimeoutError] if the command times out # - def run(*args, out:, err:, normalize:, chomp:, merge:, chdir: nil) + def run(*args, out:, err:, normalize:, chomp:, merge:, chdir: nil, timeout: nil) git_cmd = build_git_cmd(args) out ||= StringIO.new err ||= (merge ? out : StringIO.new) - status = execute(git_cmd, out, err, chdir: (chdir || :not_set)) + status = execute(git_cmd, out, err, chdir: (chdir || :not_set), timeout: timeout) - process_result(git_cmd, status, out, err, normalize, chomp) + process_result(git_cmd, status, out, err, normalize, chomp, timeout) end private @@ -258,17 +267,24 @@ def raise_pipe_error(git_cmd, pipe_name, pipe) # # @param cmd [Array] the git command to execute # @param chdir [String] the directory to run the command in + # @param timeout [Float, Integer, nil] the maximum seconds to wait for the command to complete + # + # If timeout is zero of nil, the command will not time out. If the command + # times out, it is killed via a SIGKILL signal and `Git::TimeoutError` is raised. + # + # If the command does not respond to SIGKILL, it will hang this method. # # @raise [Git::GitExecuteError] if an exception was raised while collecting subprocess output + # @raise [Git::TimeoutError] if the command times out # - # @return [Process::Status] the status of the completed subprocess + # @return [ProcessExecuter::Status] the status of the completed subprocess # # @api private # - def spawn(cmd, out_writers, err_writers, chdir:) + def spawn(cmd, out_writers, err_writers, chdir:, timeout:) out_pipe = ProcessExecuter::MonitoredPipe.new(*out_writers, chunk_size: 10_000) err_pipe = ProcessExecuter::MonitoredPipe.new(*err_writers, chunk_size: 10_000) - ProcessExecuter.spawn(env, *cmd, out: out_pipe, err: err_pipe, chdir: chdir) + ProcessExecuter.spawn(env, *cmd, out: out_pipe, err: err_pipe, chdir: chdir, timeout: timeout) ensure out_pipe.close err_pipe.close @@ -313,11 +329,12 @@ def writers(out, err) # # @api private # - def process_result(git_cmd, status, out, err, normalize, chomp) + def process_result(git_cmd, status, out, err, normalize, chomp, timeout) out_str, err_str = post_process_all([out, err], normalize, chomp) logger.info { "#{git_cmd} exited with status #{status}" } logger.debug { "stdout:\n#{out_str.inspect}\nstderr:\n#{err_str.inspect}" } Git::CommandLineResult.new(git_cmd, status, out_str, err_str).tap do |result| + raise Git::TimeoutError.new(result, timeout) if status.timeout? raise Git::SignaledError.new(result) if status.signaled? raise Git::FailedError.new(result) unless status.success? end @@ -329,14 +346,23 @@ def process_result(git_cmd, status, out, err, normalize, chomp) # @param out [#write] the object to write stdout to # @param err [#write] the object to write stderr to # @param chdir [String] the directory to run the command in + # @param timeout [Float, Integer, nil] the maximum seconds to wait for the command to complete + # + # If timeout is zero of nil, the command will not time out. If the command + # times out, it is killed via a SIGKILL signal and `Git::TimeoutError` is raised. + # + # If the command does not respond to SIGKILL, it will hang this method. + # + # @raise [Git::GitExecuteError] if an exception was raised while collecting subprocess output + # @raise [Git::TimeoutError] if the command times out # # @return [Git::CommandLineResult] the result of the command to return to the caller # # @api private # - def execute(git_cmd, out, err, chdir:) + def execute(git_cmd, out, err, chdir:, timeout:) out_writers, err_writers = writers(out, err) - spawn(git_cmd, out_writers, err_writers, chdir: chdir) + spawn(git_cmd, out_writers, err_writers, chdir: chdir, timeout: timeout) end end end diff --git a/lib/git/command_line_error.rb b/lib/git/command_line_error.rb new file mode 100644 index 00000000..269ef3cd --- /dev/null +++ b/lib/git/command_line_error.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require_relative 'error' + +module Git + # Raised when a git command fails or exits because of an uncaught signal + # + # The git command executed, status, stdout, and stderr are available from this + # object. + # + # Rather than creating a CommandLineError object directly, it is recommended to use + # one of the derived classes for the appropriate type of error: + # + # * {Git::FailedError}: when the git command exits with a non-zero status + # * {Git::SignaledError}: when the git command exits because of an uncaught signal + # * {Git::TimeoutError}: when the git command times out + # + # @api public + # + class CommandLineError < Git::Error + # Create a CommandLineError object + # + # @example + # `exit 1` # set $? appropriately for this example + # result = Git::CommandLineResult.new(%w[git status], $?, 'stdout', 'stderr') + # error = Git::CommandLineError.new(result) + # error.to_s #=> '["git", "status"], status: pid 89784 exit 1, stderr: "stderr"' + # + # @param result [Git::CommandLineResult] the result of the git command including + # the git command, status, stdout, and stderr + # + def initialize(result) + @result = result + super() + end + + # The human readable representation of this error + # + # @example + # error.to_s #=> '["git", "status"], status: pid 89784 exit 1, stderr: "stderr"' + # + # @return [String] + # + def to_s = <<~MESSAGE.chomp + #{result.git_cmd}, status: #{result.status}, stderr: #{result.stderr.inspect} + MESSAGE + + # @attribute [r] result + # + # The result of the git command including the git command and its status and output + # + # @example + # error.result #=> # + # + # @return [Git::CommandLineResult] + # + attr_reader :result + end +end diff --git a/lib/git/config.rb b/lib/git/config.rb index 4fefe454..0a3fd71e 100644 --- a/lib/git/config.rb +++ b/lib/git/config.rb @@ -2,11 +2,12 @@ module Git class Config - attr_writer :binary_path, :git_ssh + attr_writer :binary_path, :git_ssh, :timeout def initialize @binary_path = nil @git_ssh = nil + @timeout = nil end def binary_path @@ -17,6 +18,9 @@ def git_ssh @git_ssh || ENV['GIT_SSH'] end + def timeout + @timeout || (ENV['GIT_TIMEOUT'] && ENV['GIT_TIMEOUT'].to_i) + end end end diff --git a/lib/git/error.rb b/lib/git/error.rb new file mode 100644 index 00000000..1b2e44be --- /dev/null +++ b/lib/git/error.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Git + # Base class for all custom git module errors + # + class Error < StandardError; end +end \ No newline at end of file diff --git a/lib/git/failed_error.rb b/lib/git/failed_error.rb index 75973f6f..5c6e1f62 100644 --- a/lib/git/failed_error.rb +++ b/lib/git/failed_error.rb @@ -1,51 +1,14 @@ # frozen_string_literal: true -require 'git/git_execute_error' +require_relative 'command_line_error' module Git - # This error is raised when a git command fails + # This error is raised when a git command returns a non-zero exitstatus # # The git command executed, status, stdout, and stderr are available from this - # object. The #message includes the git command, the status of the process, and - # the stderr of the process. + # object. # # @api public # - class FailedError < Git::GitExecuteError - # Create a FailedError object - # - # @example - # `exit 1` # set $? appropriately for this example - # result = Git::CommandLineResult.new(%w[git status], $?, 'stdout', 'stderr') - # error = Git::FailedError.new(result) - # error.message #=> - # "[\"git\", \"status\"]\nstatus: pid 89784 exit 1\nstderr: \"stderr\"" - # - # @param result [Git::CommandLineResult] the result of the git command including - # the git command, status, stdout, and stderr - # - def initialize(result) - super("#{result.git_cmd}\nstatus: #{result.status}\nstderr: #{result.stderr.inspect}") - @result = result - end - - # @attribute [r] result - # - # The result of the git command including the git command and its status and output - # - # @example - # `exit 1` # set $? appropriately for this example - # result = Git::CommandLineResult.new(%w[git status], $?, 'stdout', 'stderr') - # error = Git::FailedError.new(result) - # error.result #=> - # #, - # @stderr="stderr", - # @stdout="stdout"> - # - # @return [Git::CommandLineResult] - # - attr_reader :result - end + class FailedError < Git::CommandLineError; end end diff --git a/lib/git/git_execute_error.rb b/lib/git/git_execute_error.rb index 52d2c80f..654dfc5b 100644 --- a/lib/git/git_execute_error.rb +++ b/lib/git/git_execute_error.rb @@ -1,7 +1,14 @@ # frozen_string_literal: true +require_relative 'error' + module Git # This error is raised when a git command fails # - class GitExecuteError < StandardError; end + # This error class is used as an alias for Git::Error for backwards compatibility. + # It is recommended to use Git::Error directly. + # + # @deprecated Use Git::Error instead + # + GitExecuteError = Git::Error end \ No newline at end of file diff --git a/lib/git/lib.rb b/lib/git/lib.rb index 9a6be282..da68d83f 100644 --- a/lib/git/lib.rb +++ b/lib/git/lib.rb @@ -115,7 +115,7 @@ def clone(repository_url, directory, opts = {}) arr_opts << repository_url arr_opts << clone_dir - command('clone', *arr_opts) + command('clone', *arr_opts, timeout: opts[:timeout]) return_base_opts_from_clone(clone_dir, opts) end @@ -1191,8 +1191,48 @@ def command_line Git::CommandLine.new(env_overrides, Git::Base.config.binary_path, global_opts, @logger) end - def command(*args, out: nil, err: nil, normalize: true, chomp: true, merge: false, chdir: nil) - result = command_line.run(*args, out: out, err: err, normalize: normalize, chomp: chomp, merge: merge, chdir: chdir) + # Runs a git command and returns the output + # + # @param args [Array] the git command to run and its arguments + # + # This should exclude the 'git' command itself and global options. + # + # For example, to run `git log --pretty=oneline`, you would pass `['log', + # '--pretty=oneline']` + # + # @param out [String, nil] the path to a file or an IO to write the command's + # stdout to + # + # @param err [String, nil] the path to a file or an IO to write the command's + # stdout to + # + # @param normalize [Boolean] true to normalize the output encoding + # + # @param chomp [Boolean] true to remove trailing newlines from the output + # + # @param merge [Boolean] true to merge stdout and stderr + # + # @param chdir [String, nil] the directory to run the command in + # + # @param timeout [Numeric, nil] the maximum time to wait for the command to + # complete + # + # @see Git::CommandLine#run + # + # @return [String] the command's stdout (or merged stdout and stderr if `merge` + # is true) + # + # @raise [Git::GitExecuteError] if the command fails + # + # The exception's `result` attribute is a {Git::CommandLineResult} which will + # contain the result of the command including the exit status, stdout, and + # stderr. + # + # @api private + # + def command(*args, out: nil, err: nil, normalize: true, chomp: true, merge: false, chdir: nil, timeout: nil) + timeout = timeout || Git.config.timeout + result = command_line.run(*args, out: out, err: err, normalize: normalize, chomp: chomp, merge: merge, chdir: chdir, timeout: timeout) result.stdout end diff --git a/lib/git/signaled_error.rb b/lib/git/signaled_error.rb index 279f0fb0..cb24ea30 100644 --- a/lib/git/signaled_error.rb +++ b/lib/git/signaled_error.rb @@ -1,50 +1,14 @@ # frozen_string_literal: true -require 'git/git_execute_error' +require_relative 'command_line_error' module Git # This error is raised when a git command exits because of an uncaught signal # # The git command executed, status, stdout, and stderr are available from this - # object. The #message includes the git command, the status of the process, and - # the stderr of the process. + # object. # # @api public # - class SignaledError < Git::GitExecuteError - # Create a SignaledError object - # - # @example - # `kill -9 $$` # set $? appropriately for this example - # result = Git::CommandLineResult.new(%w[git status], $?, '', "killed") - # error = Git::SignaledError.new(result) - # error.message #=> - # "[\"git\", \"status\"]\nstatus: pid 88811 SIGKILL (signal 9)\nstderr: \"killed\"" - # - # @param result [Git::CommandLineResult] the result of the git command including the git command, status, stdout, and stderr - # - def initialize(result) - super("#{result.git_cmd}\nstatus: #{result.status}\nstderr: #{result.stderr.inspect}") - @result = result - end - - # @attribute [r] result - # - # The result of the git command including the git command, status, and output - # - # @example - # `kill -9 $$` # set $? appropriately for this example - # result = Git::CommandLineResult.new(%w[git status], $?, '', "killed") - # error = Git::SignaledError.new(result) - # error.result #=> - # #, - # @stderr="killed", - # @stdout=""> - # - # @return [Git::CommandLineResult] - # - attr_reader :result - end + class SignaledError < Git::CommandLineError; end end diff --git a/lib/git/timeout_error.rb b/lib/git/timeout_error.rb new file mode 100644 index 00000000..ed482e73 --- /dev/null +++ b/lib/git/timeout_error.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require_relative 'signaled_error' + +module Git + # This error is raised when a git command takes longer than the configured timeout + # + # The git command executed, status, stdout, and stderr, and the timeout duration + # are available from this object. + # + # result.status.timeout? will be `true` + # + # @api public + # + class TimeoutError < Git::SignaledError + # Create a TimeoutError object + # + # @example + # command = %w[sleep 10] + # timeout_duration = 1 + # status = ProcessExecuter.spawn(*command, timeout: timeout_duration) + # result = Git::CommandLineResult.new(command, status, 'stdout', 'err output') + # error = Git::TimeoutError.new(result, timeout_duration) + # error.to_s #=> '["sleep", "10"], status: pid 70144 SIGKILL (signal 9), stderr: "err output", timed out after 1s' + # + # @param result [Git::CommandLineResult] the result of the git command including + # the git command, status, stdout, and stderr + # + # @param timeout_duration [Numeric] the amount of time the subprocess was allowed + # to run before being killed + # + def initialize(result, timeout_duration) + @timeout_duration = timeout_duration + super(result) + end + + # The human readable representation of this error + # + # @example + # error.to_s #=> '["sleep", "10"], status: pid 88811 SIGKILL (signal 9), stderr: "err output", timed out after 1s' + # + # @return [String] + # + def to_s = <<~MESSAGE.chomp + #{super}, timed out after #{timeout_duration}s + MESSAGE + + # The amount of time the subprocess was allowed to run before being killed + # + # @example + # `kill -9 $$` # set $? appropriately for this example + # result = Git::CommandLineResult.new(%w[git status], $?, '', "killed") + # error = Git::TimeoutError.new(result, 10) + # error.timeout_duration #=> 10 + # + # @return [Numeric] + # + attr_reader :timeout_duration + end +end diff --git a/lib/git/version.rb b/lib/git/version.rb index 120657f0..d50f3c40 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.0.0.pre1' + VERSION='2.0.0.pre2' end diff --git a/tests/units/test_command_line.rb b/tests/units/test_command_line.rb index 81f48bb9..c03df542 100644 --- a/tests/units/test_command_line.rb +++ b/tests/units/test_command_line.rb @@ -54,6 +54,39 @@ def merge # END DEFAULT VALUES + sub_test_case "when a timeout is given" do + test 'it should raise an ArgumentError if the timeout is not an Integer, Float, or nil' do + command_line = Git::CommandLine.new(env, binary_path, global_opts, logger) + args = [] + error = assert_raise ArgumentError do + command_line.run(*args, out: out_writer, err: err_writer, normalize: normalize, chomp: chomp, merge: merge, timeout: 'not a number') + end + end + + test 'it should raise a Git::TimeoutError if the command takes too long' do + command_line = Git::CommandLine.new(env, binary_path, global_opts, logger) + args = ['--duration=5'] + + error = assert_raise Git::TimeoutError do + command_line.run(*args, out: out_writer, err: err_writer, normalize: normalize, chomp: chomp, merge: merge, timeout: 0.01) + end + end + + test 'the error raised should indicate the command timed out' do + command_line = Git::CommandLine.new(env, binary_path, global_opts, logger) + args = ['--duration=5'] + + # Git::TimeoutError (alone with Git::FailedError and Git::SignaledError) is a + # subclass of Git::GitExecuteError + + begin + command_line.run(*args, out: out_writer, err: err_writer, normalize: normalize, chomp: chomp, merge: merge, timeout: 0.01) + rescue Git::GitExecuteError => e + assert_equal(true, e.result.status.timeout?) + end + end + end + test "run should return a result that includes the command ran, its output, and resulting status" do command_line = Git::CommandLine.new(env, binary_path, global_opts, logger) args = ['--stdout=stdout output', '--stderr=stderr output'] @@ -62,7 +95,7 @@ def merge assert_equal(['ruby', 'bin/command_line_test', '--stdout=stdout output', '--stderr=stderr output'], result.git_cmd) assert_equal('stdout output', result.stdout.chomp) assert_equal('stderr output', result.stderr.chomp) - assert(result.status.is_a? Process::Status) + assert(result.status.is_a? ProcessExecuter::Status) assert_equal(0, result.status.exitstatus) end @@ -116,10 +149,10 @@ def merge command_line = Git::CommandLine.new(env, binary_path, global_opts, logger) args = ['--stdout=stdout output'] - def command_line.spawn(cmd, out_writers, err_writers, chdir: nil) + def command_line.spawn(cmd, out_writers, err_writers, chdir: nil, timeout: nil) out_writers.each { |w| w.write(File.read('tests/files/encoding/test1.txt')) } `true` - $? # return status + ProcessExecuter::Status.new($?, false) # return status end normalize = true @@ -139,10 +172,10 @@ def command_line.spawn(cmd, out_writers, err_writers, chdir: nil) command_line = Git::CommandLine.new(env, binary_path, global_opts, logger) args = ['--stdout=stdout output'] - def command_line.spawn(cmd, out_writers, err_writers, chdir: nil) + def command_line.spawn(cmd, out_writers, err_writers, chdir: nil, timeout: nil) out_writers.each { |w| w.write(File.read('tests/files/encoding/test1.txt')) } `true` - $? # return status + ProcessExecuter::Status.new($?, false) # return status end normalize = false diff --git a/tests/units/test_command_line_error.rb b/tests/units/test_command_line_error.rb new file mode 100644 index 00000000..30b859ab --- /dev/null +++ b/tests/units/test_command_line_error.rb @@ -0,0 +1,23 @@ +require 'test_helper' + +class TestCommandLineError < Test::Unit::TestCase + def test_initializer + status = Struct.new(:to_s).new('pid 89784 exit 1') + result = Git::CommandLineResult.new(%w[git status], status, 'stdout', 'stderr') + + error = Git::CommandLineError.new(result) + + assert(error.is_a?(Git::Error)) + assert_equal(result, error.result) + end + + def test_to_s + status = Struct.new(:to_s).new('pid 89784 exit 1') + result = Git::CommandLineResult.new(%w[git status], status, 'stdout', 'stderr') + + error = Git::CommandLineError.new(result) + + expected_message = '["git", "status"], status: pid 89784 exit 1, stderr: "stderr"' + assert_equal(expected_message, error.to_s) + end +end diff --git a/tests/units/test_failed_error.rb b/tests/units/test_failed_error.rb index ea4ad4b2..63b894f7 100644 --- a/tests/units/test_failed_error.rb +++ b/tests/units/test_failed_error.rb @@ -7,17 +7,16 @@ def test_initializer error = Git::FailedError.new(result) - assert(error.is_a?(Git::GitExecuteError)) - assert_equal(result, error.result) + assert(error.is_a?(Git::CommandLineError)) end - def test_message + def test_to_s status = Struct.new(:to_s).new('pid 89784 exit 1') result = Git::CommandLineResult.new(%w[git status], status, 'stdout', 'stderr') error = Git::FailedError.new(result) - expected_message = "[\"git\", \"status\"]\nstatus: pid 89784 exit 1\nstderr: \"stderr\"" - assert_equal(expected_message, error.message) + expected_message = '["git", "status"], status: pid 89784 exit 1, stderr: "stderr"' + assert_equal(expected_message, error.to_s) end end diff --git a/tests/units/test_git_clone.rb b/tests/units/test_git_clone.rb index 9f208b61..24221e38 100644 --- a/tests/units/test_git_clone.rb +++ b/tests/units/test_git_clone.rb @@ -5,6 +5,57 @@ # Tests for Git.clone class TestGitClone < Test::Unit::TestCase + sub_test_case 'Git.clone with timeouts' do + test 'global timmeout' do + begin + saved_timeout = Git.config.timeout + + in_temp_dir do |path| + setup_repo + Git.config.timeout = 0.00001 + + error = assert_raise Git::TimeoutError do + Git.clone('repository.git', 'temp2', timeout: nil) + end + + assert_equal(true, error.result.status.timeout?) + end + ensure + Git.config.timeout = saved_timeout + end + end + + test 'override global timeout' do + in_temp_dir do |path| + saved_timeout = Git.config.timeout + + in_temp_dir do |path| + setup_repo + Git.config.timeout = 0.00001 + + assert_nothing_raised do + Git.clone('repository.git', 'temp2', timeout: 10) + end + end + ensure + Git.config.timeout = saved_timeout + end + end + + test 'per command timeout' do + in_temp_dir do |path| + setup_repo + + error = assert_raise Git::TimeoutError do + Git.clone('repository.git', 'temp2', timeout: 0.00001) + end + + assert_equal(true, error.result.status.timeout?) + end + end + + end + def setup_repo Git.init('repository.git', bare: true) git = Git.clone('repository.git', 'temp') @@ -51,7 +102,7 @@ def test_git_clone_with_no_name git.lib.clone(repository_url, destination, { config: 'user.name=John Doe' }) end - expected_command_line = ['clone', '--config', 'user.name=John Doe', '--', repository_url, destination] + expected_command_line = ['clone', '--config', 'user.name=John Doe', '--', repository_url, destination, {timeout: nil}] assert_equal(expected_command_line, actual_command_line) end @@ -77,7 +128,7 @@ def test_git_clone_with_no_name 'clone', '--config', 'user.name=John Doe', '--config', 'user.email=john@doe.com', - '--', repository_url, destination + '--', repository_url, destination, {timeout: nil} ] assert_equal(expected_command_line, actual_command_line) @@ -103,7 +154,7 @@ def test_git_clone_with_no_name expected_command_line = [ 'clone', '--filter', 'tree:0', - '--', repository_url, destination + '--', repository_url, destination, {timeout: nil} ] assert_equal(expected_command_line, actual_command_line) diff --git a/tests/units/test_signaled_error.rb b/tests/units/test_signaled_error.rb index 25922aa9..6bf46c2b 100644 --- a/tests/units/test_signaled_error.rb +++ b/tests/units/test_signaled_error.rb @@ -7,17 +7,16 @@ def test_initializer error = Git::SignaledError.new(result) - assert(error.is_a?(Git::GitExecuteError)) - assert_equal(result, error.result) + assert(error.is_a?(Git::Error)) end - def test_message + def test_to_s status = Struct.new(:to_s).new('pid 65628 SIGKILL (signal 9)') # `kill -9 $$` result = Git::CommandLineResult.new(%w[git status], status, '', "uncaught signal") error = Git::SignaledError.new(result) - expected_message = "[\"git\", \"status\"]\nstatus: pid 65628 SIGKILL (signal 9)\nstderr: \"uncaught signal\"" - assert_equal(expected_message, error.message) + expected_message = '["git", "status"], status: pid 65628 SIGKILL (signal 9), stderr: "uncaught signal"' + assert_equal(expected_message, error.to_s) end end diff --git a/tests/units/test_timeout_error.rb b/tests/units/test_timeout_error.rb new file mode 100644 index 00000000..3bfc90b6 --- /dev/null +++ b/tests/units/test_timeout_error.rb @@ -0,0 +1,24 @@ +require 'test_helper' + +class TestTimeoutError < Test::Unit::TestCase + def test_initializer + status = Struct.new(:to_s).new('pid 65628 SIGKILL (signal 9)') # `kill -9 $$` + result = Git::CommandLineResult.new(%w[git status], status, 'stdout', 'stderr') + timeout_diration = 10 + + error = Git::TimeoutError.new(result, timeout_diration) + + assert(error.is_a?(Git::SignaledError)) + end + + def test_to_s + status = Struct.new(:to_s).new('pid 65628 SIGKILL (signal 9)') # `kill -9 $$` + result = Git::CommandLineResult.new(%w[git status], status, 'stdout', 'Waiting...') + timeout_duration = 10 + + error = Git::TimeoutError.new(result, timeout_duration) + + expected_message = '["git", "status"], status: pid 65628 SIGKILL (signal 9), stderr: "Waiting...", timed out after 10s' + assert_equal(expected_message, error.to_s) + end +end