|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +require 'git/command_line_result' |
| 4 | +require 'git/failed_error' |
| 5 | +require 'git/signaled_error' |
| 6 | + |
| 7 | +module Git |
| 8 | + # Runs a git command and returns the result |
| 9 | + # |
| 10 | + # @api public |
| 11 | + # |
| 12 | + class CommandLine |
| 13 | + # Create a Git::CommandLine object |
| 14 | + # |
| 15 | + # @example |
| 16 | + # env = { 'GIT_DIR' => '/path/to/git/dir' } |
| 17 | + # global_opts = %w[--git-dir /path/to/git/dir] |
| 18 | + # logger = Logger.new(STDOUT) |
| 19 | + # cli = CommandLine.new(env, global_opts, logger) |
| 20 | + # cli.run('version') #=> #<Git::CommandLineResult:0x00007f9b0c0b0e00 |
| 21 | + # |
| 22 | + # @param env [Hash<String, String>] environment variables to set |
| 23 | + # @param global_opts [Array<String>] global options to pass to git |
| 24 | + # @param logger [Logger] the logger to use |
| 25 | + # |
| 26 | + def initialize(env, global_opts, logger) |
| 27 | + @env = env |
| 28 | + @global_opts = global_opts |
| 29 | + @logger = logger |
| 30 | + end |
| 31 | + |
| 32 | + # @attribute [r] env |
| 33 | + # |
| 34 | + # Variables to set (or unset) in the git command's environment |
| 35 | + # |
| 36 | + # @example |
| 37 | + # env = { 'GIT_DIR' => '/path/to/git/dir' } |
| 38 | + # command_line = Git::CommandLine.new(env, [], Logger.new(STDOUT)) |
| 39 | + # command_line.env #=> { 'GIT_DIR' => '/path/to/git/dir' } |
| 40 | + # |
| 41 | + # @return [Hash<String, String>] |
| 42 | + # |
| 43 | + # @see https://ruby-doc.org/3.2.1/Process.html#method-c-spawn Process.spawn |
| 44 | + # for details on how env determines the environment of the subprocess |
| 45 | + # |
| 46 | + attr_reader :env |
| 47 | + |
| 48 | + # @attribute [r] global_opts |
| 49 | + # @return [Array<String>] |
| 50 | + attr_reader :global_opts |
| 51 | + |
| 52 | + # @attribute [r] logger |
| 53 | + # @return [Logger] |
| 54 | + attr_reader :logger |
| 55 | + |
| 56 | + # Execute a git command, wait for it to finish, and return the result |
| 57 | + # |
| 58 | + # @example Run a command and return the output |
| 59 | + # |
| 60 | + # cli.run('version') #=> "git version 2.39.1\n" |
| 61 | + # |
| 62 | + # @example The args array should be splatted into the parameter list |
| 63 | + # args = %w[log -n 1 --oneline] |
| 64 | + # cli.run(*args) #=> "f5baa11 beginning of Ruby/Git project\n" |
| 65 | + # |
| 66 | + # @example Run a command and return the chomped output |
| 67 | + # cli.run('version', chomp: true) #=> "git version 2.39.1" |
| 68 | + # |
| 69 | + # @example Run a command and without normalizing the output |
| 70 | + # cli.run('version', normalize: false) #=> "git version 2.39.1\n" |
| 71 | + # |
| 72 | + # @example Capture stdout in a temporary file |
| 73 | + # require 'tempfile' |
| 74 | + # tempfile = Tempfile.create('git') do |file| |
| 75 | + # cli.run('version', out: file) |
| 76 | + # file.rewind |
| 77 | + # file.read #=> "git version 2.39.1\n" |
| 78 | + # end |
| 79 | + # |
| 80 | + # @example Capture stderr in a StringIO object |
| 81 | + # require 'stringio' |
| 82 | + # stderr = StringIO.new |
| 83 | + # begin |
| 84 | + # cli.run('log', 'nonexistent-branch', err: stderr) |
| 85 | + # rescue Git::FailedError => e |
| 86 | + # stderr.string #=> "unknown revision or path not in the working tree.\n" |
| 87 | + # end |
| 88 | + # |
| 89 | + # @param args [Array<String>] the command line arguements to pass to git |
| 90 | + # |
| 91 | + # This array should be splatted into the parameter list. |
| 92 | + # |
| 93 | + # @param out [#write, nil] the object to write stdout to or nil to ignore stdout |
| 94 | + # |
| 95 | + # If this is a 'StringIO' object, then `stdout_writer.string` will be returned. |
| 96 | + # |
| 97 | + # In general, only specify a `stdout_writer` object when you want to redirect |
| 98 | + # stdout to a file or some other object that responds to `#write`. The default |
| 99 | + # behavior will return the output of the command. |
| 100 | + # |
| 101 | + # @param err [#write] the object to write stderr to or nil to ignore stderr |
| 102 | + # |
| 103 | + # If this is a 'StringIO' object and `merged_output` is `true`, then |
| 104 | + # `stderr_writer.string` will be merged into the output returned by this method. |
| 105 | + # |
| 106 | + # @param normalize [Boolean] whether to normalize the output to a valid encoding |
| 107 | + # @param chomp [Boolean] whether to chomp the output |
| 108 | + # @param merge [Boolean] whether to merge stdout and stderr in the string returned |
| 109 | + # |
| 110 | + # @return [Git::CommandLineResult] the output of the command |
| 111 | + # |
| 112 | + # This result of running the command. |
| 113 | + # |
| 114 | + # @raise [ArgumentError] if args is not an array of strings |
| 115 | + # @raise [Git::SignaledError] if the command was terminated because of an uncaught signal |
| 116 | + # @raise [Git::FailedError] if the command returned a non-zero exitstatus |
| 117 | + # |
| 118 | + # @api private |
| 119 | + # |
| 120 | + def run(*args, out: , err: , normalize: , chomp: , merge: ) |
| 121 | + git_cmd = build_git_cmd(args) |
| 122 | + out ||= StringIO.new |
| 123 | + err ||= (merge ? out : StringIO.new) |
| 124 | + status = execute(git_cmd, out, err) |
| 125 | + |
| 126 | + process_result(git_cmd, status, out, err, normalize, chomp) |
| 127 | + end |
| 128 | + |
| 129 | + private |
| 130 | + |
| 131 | + def build_git_cmd(args) |
| 132 | + raise ArgumentError.new('The args array can not contain an array') if args.any? { |a| a.is_a?(Array) } |
| 133 | + |
| 134 | + [Git::Base.config.binary_path, *global_opts, *args].map { |e| e.to_s } |
| 135 | + end |
| 136 | + |
| 137 | + def post_process(writer, normalize, chomp) |
| 138 | + if writer.respond_to?(:string) |
| 139 | + output = writer.string.dup |
| 140 | + output = output.lines.map { |l| Git::EncodingUtils.normalize_encoding(l) }.join if normalize |
| 141 | + output.chomp! if chomp |
| 142 | + output |
| 143 | + else |
| 144 | + nil |
| 145 | + end |
| 146 | + end |
| 147 | + |
| 148 | + def post_process_all(writers, normalize, chomp) |
| 149 | + Array.new.tap do |result| |
| 150 | + writers.each { |writer| result << post_process(writer, normalize, chomp) } |
| 151 | + end |
| 152 | + end |
| 153 | + |
| 154 | + def raise_pipe_error(git_cmd, pipe_name, pipe) |
| 155 | + raise Git::GitExecuteError, "Pipe Exception for #{git_cmd}: #{pipe_name}.exception=#{pipe.exception.inspect}" |
| 156 | + end |
| 157 | + |
| 158 | + def raise_signaled_error(git_cmd, status) |
| 159 | + raise Git::SignaledError.new(git_cmd, status) |
| 160 | + end |
| 161 | + |
| 162 | + def raise_failed_error(git_cmd, status, stdout, stderr) |
| 163 | + raise Git::FailedError.new(git_cmd, status, stdout, stderr) |
| 164 | + end |
| 165 | + |
| 166 | + def spawn(cmd, out_writers, err_writers) |
| 167 | + out_pipe = ProcessExecuter::MonitoredPipe.new(*out_writers, chunk_size: 10_000) |
| 168 | + err_pipe = ProcessExecuter::MonitoredPipe.new(*err_writers, chunk_size: 10_000) |
| 169 | + ProcessExecuter.spawn(env, *cmd, out: out_pipe, err: err_pipe).tap do |
| 170 | + raise_pipe_error(git_cmd, :stdout, out_pipe) if out_pipe.exception |
| 171 | + raise_pipe_error(git_cmd, :stderr, err_pipe) if err_pipe.exception |
| 172 | + end |
| 173 | + ensure |
| 174 | + out_pipe.close |
| 175 | + err_pipe.close |
| 176 | + end |
| 177 | + |
| 178 | + def writers(out, err) |
| 179 | + out_writers = [out] |
| 180 | + err_writers = [err] |
| 181 | + [out_writers, err_writers] |
| 182 | + end |
| 183 | + |
| 184 | + def log_result(git_cmd, status, stdout, stderr) |
| 185 | + logger.info { "#{git_cmd} exited with status #{status.inspect}" } |
| 186 | + logger.debug { "stdout:\n#{stdout.inspect}\nstderr:\n#{stderr.inspect}" } |
| 187 | + end |
| 188 | + |
| 189 | + def process_result(git_cmd, status, out, err, normalize, chomp) |
| 190 | + raise_signaled_error(git_cmd, status) if status.signaled? |
| 191 | + out_str, err_str = post_process_all([out, err], normalize, chomp) |
| 192 | + log_result(git_cmd, status, out_str, err_str) |
| 193 | + raise_failed_error(git_cmd, status, out_str, out_str) unless status.success? |
| 194 | + Git::CommandLineResult.new(status, out_str, err_str) |
| 195 | + end |
| 196 | + |
| 197 | + def execute(git_cmd, out, err) |
| 198 | + out_writers, err_writers = writers(out, err) |
| 199 | + spawn(git_cmd, out_writers, err_writers) |
| 200 | + end |
| 201 | + end |
| 202 | +end |
0 commit comments