class Git::Commands::Base
@api private
Base class for git command implementations.
Provides default {#initialize} and {#call} methods so that simple commands only need to declare their arguments:
class Add < Git::Commands::Base arguments do literal 'add' flag_option :all flag_option :force end_of_options operand :paths, repeatable: true end # Execute the git add command # ...YARD docs... def call(...) = super end
Commands whose git process may exit with a non-zero status that is not an error can declare the acceptable range of exit codes:
class Delete < Git::Commands::Base arguments do literal 'branch' literal '--delete' operand :branch_names, repeatable: true, required: true end allow_exit_status 0..1 # Execute the git branch --delete command # ...YARD docs... def call(...) = super end
Commands with execution options (e.g., timeout) work with the default ‘call` — execution options are extracted and forwarded automatically.
Attributes
@return [Range, nil] range of exit status values accepted by this command
@return [Git::Commands::Arguments, nil] the frozen argument definition for this command
@return [Git::VersionConstraint, nil] version constraint for this command
Returns +nil+ if the command is available in all supported git versions.
Public Class Methods
Source
# File lib/git/commands/base.rb, line 86 def allow_exit_status(range) raise ArgumentError, 'allow_exit_status expects a Range' unless range.is_a?(Range) unless range.begin.is_a?(Integer) && range.end.is_a?(Integer) raise ArgumentError, 'allow_exit_status bounds must be Integers' end raise ArgumentError, 'allow_exit_status range must not be empty' if range.begin > range.end @allowed_exit_status_range = range end
Declare the acceptable range of exit status values for this command.
@example git-diff exits 1 when a diff is found (not an error)
allow_exit_status 0..1
@example git-fsck uses exit codes 0-7 as bit flags
allow_exit_status 0..7
@param range [Range] range of accepted exit status values
@return [void]
@raise [ArgumentError] if range is invalid
Source
# File lib/git/commands/base.rb, line 63 def arguments(&) raise ArgumentError, "arguments already defined for #{name}" if @args_definition @args_definition = Arguments.define(&).freeze end
Define the command’s arguments using the {Arguments} DSL.
@return [void]
@raise [ArgumentError] if called more than once on the same class
@yield the block passed to {Arguments.define}
Source
# File lib/git/commands/base.rb, line 242 def call(*, **) bound = args_definition.bind(*, **) validate_version!(bound.execution_options) result = execute_command(bound) validate_exit_status!(result) result end
Execute the git command.
@overload call(*args, **kwargs)
Bind arguments and execute the command.
Execution options (declared via `execution_option` in the Arguments
DSL) are extracted from the bound arguments via
{Git::Commands::Arguments::Bound#execution_options} and forwarded as
keyword arguments to the execution context via {#execute_command}.
When the `:out` execution option is present, stdout is streamed using
`@execution_context.command_streaming`. Otherwise, stdout is captured
using `@execution_context.command_capturing`.
@example
# In a command subclass:
# result = command.call('HEAD', timeout: 10)
@param args [Array] positional arguments forwarded to {Arguments#bind}
@param kwargs [Hash] keyword arguments forwarded to {Arguments#bind}
@return [Git::CommandLine::Result] the result of calling ‘git`
@raise [ArgumentError] if no arguments definition is declared on the command class
@raise [Git::FailedError] if git returns an exit code outside the allowed range
@raise [Git::VersionError] if the installed git version doesn’t meet requirements
Source
# File lib/git/commands/base.rb, line 209 def initialize(execution_context) @execution_context = execution_context end
@param execution_context [Git::ExecutionContext] context that provides
{Git::ExecutionContext#command_capturing} and {Git::ExecutionContext#command_streaming}
Source
# File lib/git/commands/base.rb, line 163 def normalize_version_constraint(min, before_version) raise ArgumentError, 'requires_git_version requires min or before:' unless min || before_version min_version = min ? parse_version(min, 'min') : nil before_parsed = before_version ? parse_version(before_version, 'before:') : nil Git::VersionConstraint.new(min: min_version, before: before_parsed) end
Normalize a version constraint to a VersionConstraint
@param min [String, nil] minimum version
@param before_version [String, nil] upper bound version
@return [Git::VersionConstraint] the normalized constraint
@raise [ArgumentError] if the constraint is invalid
Source
# File lib/git/commands/base.rb, line 182 def parse_version(version, key_name) validate_version_format!(version, key_name) Git::Version.parse(version) end
Parse a version string into a Git version object
@param version [String] version string to parse
@param key_name [String] argument name used in validation errors
@return [Git::Version] the parsed version
@raise [ArgumentError] if the version format is invalid
Source
# File lib/git/commands/base.rb, line 145 def requires_git_version(min = nil, before: nil) raise ArgumentError, 'requires_git_version already declared for this class' if @git_version_constraint @git_version_constraint = normalize_version_constraint(min, before) end
Declare the git version requirements for this command.
Use this when the command (or the specific sub-action this class wraps) was introduced in a git version later than Git::MINIMUM_GIT_VERSION, or when the command was removed in a later version. When not declared, the command is assumed to be available in all supported git versions.
@example git-am –retry requires git 2.46.0 or later
requires_git_version '2.46.0'
@example Feature with version range
requires_git_version '2.29.0', before: '2.50.0'
@example Feature available before git 2.50.0
requires_git_version before: '2.50.0'
@param min [String, nil] minimum version
@param before [String, nil] upper bound version (exclusive)
@return [void]
@raise [ArgumentError] if version format is invalid or called twice
Source
# File lib/git/commands/base.rb, line 117 def skip_version_validation @skip_version_validation = true end
Declare that this command should skip version validation.
This is intended for internal use only — specifically for the ‘git version` command, which cannot validate versions without causing infinite recursion.
@return [void]
@api private
Source
# File lib/git/commands/base.rb, line 105 def skip_version_validation? = !!@skip_version_validation # Declare that this command should skip version validation. # # This is intended for internal use only — specifically for the # `git version` command, which cannot validate versions without # causing infinite recursion. # # @return [void] # # @api private # def skip_version_validation @skip_version_validation = true end # Declare the git version requirements for this command. # # Use this when the command (or the specific sub-action this class wraps) was # introduced in a git version later than +Git::MINIMUM_GIT_VERSION+, or when # the command was removed in a later version. When not declared, the command # is assumed to be available in all supported git versions. # # @example git-am --retry requires git 2.46.0 or later # requires_git_version '2.46.0' # # @example Feature with version range # requires_git_version '2.29.0', before: '2.50.0' # # @example Feature available before git 2.50.0 # requires_git_version before: '2.50.0' # # @param min [String, nil] minimum version # # @param before [String, nil] upper bound version (exclusive) # # @return [void] # # @raise [ArgumentError] if version format is invalid or called twice # def requires_git_version(min = nil, before: nil) raise ArgumentError, 'requires_git_version already declared for this class' if @git_version_constraint @git_version_constraint = normalize_version_constraint(min, before) end private # Normalize a version constraint to a VersionConstraint # # @param min [String, nil] minimum version # # @param before_version [String, nil] upper bound version # # @return [Git::VersionConstraint] the normalized constraint # # @raise [ArgumentError] if the constraint is invalid # def normalize_version_constraint(min, before_version) raise ArgumentError, 'requires_git_version requires min or before:' unless min || before_version min_version = min ? parse_version(min, 'min') : nil before_parsed = before_version ? parse_version(before_version, 'before:') : nil Git::VersionConstraint.new(min: min_version, before: before_parsed) end # Parse a version string into a Git version object # # @param version [String] version string to parse # # @param key_name [String] argument name used in validation errors # # @return [Git::Version] the parsed version # # @raise [ArgumentError] if the version format is invalid # def parse_version(version, key_name) validate_version_format!(version, key_name) Git::Version.parse(version) end # Validate a version string uses the required format # # @param version [Object] value to validate # # @param context [String, nil] argument name included in validation errors # # @return [void] # # @raise [ArgumentError] if the value is not a `major.minor.patch` version string # def validate_version_format!(version, context = nil) return if version.is_a?(String) && version.match?(/\A\d+\.\d+\.\d+\z/) subject = context || 'a version' raise ArgumentError, "requires_git_version expects #{subject} to be a 'major.minor.patch' version string, " \ "got: #{version.inspect}" end end
@!attribute [r] skip_version_validation?
@return [Boolean] whether this command skips version validation
Source
# File lib/git/commands/base.rb, line 197 def validate_version_format!(version, context = nil) return if version.is_a?(String) && version.match?(/\A\d+\.\d+\.\d+\z/) subject = context || 'a version' raise ArgumentError, "requires_git_version expects #{subject} to be a 'major.minor.patch' version string, " \ "got: #{version.inspect}" end
Validate a version string uses the required format
@param version [Object] value to validate
@param context [String, nil] argument name included in validation errors
@return [void]
@raise [ArgumentError] if the value is not a ‘major.minor.patch` version string
Private Class Methods
Source
# File lib/git/commands/base.rb, line 297 def capturing_opts(exec_opts) opts = exec_opts opts = opts.merge(normalize: false) unless normalize_captured_stdout? opts = opts.merge(chomp: false) unless chomp_captured_stdout? opts end
Build options for captured command output
@param exec_opts [Hash] execution options for the command line
@return [Hash] options for captured command output
Source
# File lib/git/commands/base.rb, line 334 def chomp_captured_stdout? true end
Whether {#execute_command} should chomp trailing newlines from ‘result.stdout` on the capturing path.
When ‘true` (the default), `command_capturing` strips the trailing newline from captured stdout. Override to return `false` in subclasses whose output must preserve trailing whitespace (e.g. `git show`, which can return blob content with significant trailing newlines).
This hook only affects the capturing path — when an ‘out:` execution option is present, stdout is streamed directly to the caller-supplied IO object and is never chomped regardless of this setting.
@return [Boolean]
Source
# File lib/git/commands/base.rb, line 350 def env {} end
Environment variable overrides to pass to the subprocess.
Returns an empty hash by default. Subclasses may override this method to inject process-level environment changes required for correctness (e.g. unsetting ‘GIT_INDEX_FILE` for worktree management commands).
The returned hash is merged with any environment overrides the caller supplies via the ‘env:` execution option — this hook’s values win on conflict so that safety invariants cannot be accidentally overridden.
@return [Hash] environment variable overrides (key: variable name, value: new value or nil to unset)
Source
# File lib/git/commands/base.rb, line 268 def execute_command(bound) exec_opts = execution_opts(bound) if exec_opts.key?(:out) @execution_context.command_streaming(*bound, **exec_opts, raise_on_failure: false) else @execution_context.command_capturing(*bound, **capturing_opts(exec_opts), raise_on_failure: false) end end
Execute a bound git command through the execution context
@param bound [Git::Commands::Arguments::Bound] bound command arguments
@return [Git::CommandLine::Result] the command result
Source
# File lib/git/commands/base.rb, line 284 def execution_opts(bound) caller_env = bound.execution_options.fetch(:env, {}) || {} merged_env = caller_env.merge(env) opts = bound.execution_options.except(:env) merged_env.empty? ? opts : opts.merge(env: merged_env) end
Build execution options for a bound command
@param bound [Git::Commands::Arguments::Bound] bound command arguments
@return [Hash] execution options for the command line
Source
# File lib/git/commands/base.rb, line 317 def normalize_captured_stdout? true end
Whether {#execute_command} should apply Ruby string normalization to ‘result.stdout` on the capturing path.
When ‘true` (the default), `command_capturing` normalizes the encoding of captured stdout. Override to return `false` in subclasses whose output is intrinsically binary (e.g. `git archive`), so that stdout bytes in `result.stdout` are returned unchanged.
This hook only affects the capturing path — when an ‘out:` execution option is present, stdout is streamed directly to the caller-supplied IO object and is never normalized or chomped regardless of this setting.
@return [Boolean]
Source
# File lib/git/commands/base.rb, line 509 def start_stdin_writer(content, writer) Thread.new do writer.write(content) unless content.empty? rescue Errno::EPIPE, IOError nil # subprocess closed stdin early ensure writer.close unless writer.closed? end rescue ThreadError => e raise Git::Error, "Failed to start the stdin writer thread: #{e.message}" end
Spawns a thread that writes content to writer then closes it. Rescues EPIPE/IOError so the thread exits cleanly when the subprocess closes its stdin early (e.g. on error exit before reading all input).
@param content [String] text to write to the pipe
@param writer [IO] write end of the pipe
@return [Thread] thread writing content to the pipe
@raise [Git::Error] if the thread cannot be started; the ‘ThreadError` is
the `cause`
Source
# File lib/git/commands/base.rb, line 426 def validate_class_version_constraint!(actual_version) constraint = self.class.git_version_constraint return unless constraint return if constraint.satisfied_by?(actual_version) raise Git::VersionError.new( subject: self.class, constraint: constraint, actual_version: actual_version ) end
Validate that git satisfies this command class’s version constraint
@param actual_version [Git::Version] installed git version
@return [void]
@raise [Git::VersionError] if the installed git version is unsupported
Source
# File lib/git/commands/base.rb, line 370 def validate_exit_status!(result) raise Git::FailedError, result unless allowed_exit_status_range.include?(result.status.exitstatus) end
Validate that a command result has an accepted exit status
@param result [Git::CommandLine::Result] command result to validate
@return [void]
@raise [Git::FailedError] if the result exit status is not accepted
Source
# File lib/git/commands/base.rb, line 408 def validate_floor_version!(actual_version) return if actual_version >= Git::MINIMUM_GIT_VERSION raise Git::VersionError.new( subject: 'The git gem', constraint: Git::VersionConstraint.new(min: Git::MINIMUM_GIT_VERSION, before: nil), actual_version: actual_version ) end
Validate that git satisfies the gem-wide minimum version
@param actual_version [Git::Version] installed git version
@return [void]
@raise [Git::VersionError] if the installed git version is too old
Source
# File lib/git/commands/base.rb, line 388 def validate_version!(exec_opts = {}) return if self.class.skip_version_validation? actual_version = @execution_context.git_version(timeout: exec_opts[:timeout]) # Floor check: fail-fast if git is too old for the gem itself validate_floor_version!(actual_version) # Class-level constraint check validate_class_version_constraint!(actual_version) end
Validate that the installed git version meets requirements
Raises Git::VersionError if:
-
The installed version is below Git::MINIMUM_GIT_VERSION (floor check)
-
The command has a class-level constraint that isn’t satisfied
Floor check always runs first and fails fast.
@param exec_opts [Hash] execution options used to query git version
@return [void]
@raise [Git::VersionError] if the installed git version is unsupported
Source
# File lib/git/commands/base.rb, line 484 def with_stdin(content) reader, writer = Git::SystemCallGuard.call('Failed to create a pipe for stdin') { IO.pipe } writer_thread = start_stdin_writer(content, writer) yield reader ensure reader.close unless reader.nil? || reader.closed? # The writer thread owns writer and closes it when it finishes. Close it # here only when the thread was never started (Thread.new raised). writer.close if writer_thread.nil? && !(writer.nil? || writer.closed?) writer_thread&.join end
Opens an in-memory IO pipe, spawns a background thread to write ‘content` to the write end (then close it), and immediately yields the read end. The write and close happen concurrently with the block.
The read end can be passed as the ‘in:` keyword to {Git::ExecutionContext#command_capturing} / {Git::CommandLine#run_with_capture}, connecting it directly to the spawned git process’s stdin without an intermediate file or shell heredoc. This is required because ‘Process.spawn` only accepts real IO objects with a file descriptor — `StringIO` does not work.
The threaded write prevents deadlocks when ‘content` exceeds the OS pipe buffer: the subprocess can drain the pipe concurrently while the writer thread continues writing.
Pass an empty string when the process should receive no input (e.g. when ‘–batch-all-objects` is used and git enumerates objects itself).
Both ends of the pipe are closed by the time this method returns or raises, whether the failure happens in the block, in the writer thread, or before the writer thread can be started.
@example Feed bound object names to a git batch command
bound = args_definition.bind(*args, **kwargs) stdin_content = Array(bound.object).map { |object| "#{object}\n" }.join with_stdin(stdin_content) do |reader| @execution_context.command_capturing('cat-file', '--batch-check', in: reader, raise_on_failure: false) end
@param content [String] text to write to the process’s stdin
@return [Object] the value returned by the block
@raise [Git::Error] if the pipe cannot be created (for example, when the
process is out of file descriptors); the `SystemCallError` is the `cause`
@raise [Git::Error] if the writer thread cannot be started (for example,
when the process cannot allocate another thread); the `ThreadError` is the `cause`
@yield [reader] the read end of the pipe
@yieldparam reader [IO] the read end of the pipe; valid only for the
duration of the block
@yieldreturn [Object] the block’s return value, which becomes the method’s return value