git 1.19.1 → 2.0.0.pre2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,368 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'git/base'
4
+ require 'git/command_line_result'
5
+ require 'git/failed_error'
6
+ require 'git/signaled_error'
7
+ require 'stringio'
8
+
9
+ module Git
10
+ # Runs a git command and returns the result
11
+ #
12
+ # @api public
13
+ #
14
+ class CommandLine
15
+ # Create a Git::CommandLine object
16
+ #
17
+ # @example
18
+ # env = { 'GIT_DIR' => '/path/to/git/dir' }
19
+ # binary_path = '/usr/bin/git'
20
+ # global_opts = %w[--git-dir /path/to/git/dir]
21
+ # logger = Logger.new(STDOUT)
22
+ # cli = CommandLine.new(env, binary_path, global_opts, logger)
23
+ # cli.run('version') #=> #<Git::CommandLineResult:0x00007f9b0c0b0e00
24
+ #
25
+ # @param env [Hash<String, String>] environment variables to set
26
+ # @param global_opts [Array<String>] global options to pass to git
27
+ # @param logger [Logger] the logger to use
28
+ #
29
+ def initialize(env, binary_path, global_opts, logger)
30
+ @env = env
31
+ @binary_path = binary_path
32
+ @global_opts = global_opts
33
+ @logger = logger
34
+ end
35
+
36
+ # @attribute [r] env
37
+ #
38
+ # Variables to set (or unset) in the git command's environment
39
+ #
40
+ # @example
41
+ # env = { 'GIT_DIR' => '/path/to/git/dir' }
42
+ # command_line = Git::CommandLine.new(env, '/usr/bin/git', [], Logger.new(STDOUT))
43
+ # command_line.env #=> { 'GIT_DIR' => '/path/to/git/dir' }
44
+ #
45
+ # @return [Hash<String, String>]
46
+ #
47
+ # @see https://ruby-doc.org/3.2.1/Process.html#method-c-spawn Process.spawn
48
+ # for details on how to set environment variables using the `env` parameter
49
+ #
50
+ attr_reader :env
51
+
52
+ # @attribute [r] binary_path
53
+ #
54
+ # The path to the command line binary to run
55
+ #
56
+ # @example
57
+ # binary_path = '/usr/bin/git'
58
+ # command_line = Git::CommandLine.new({}, binary_path, ['version'], Logger.new(STDOUT))
59
+ # command_line.binary_path #=> '/usr/bin/git'
60
+ #
61
+ # @return [String]
62
+ #
63
+ attr_reader :binary_path
64
+
65
+ # @attribute [r] global_opts
66
+ #
67
+ # The global options to pass to git
68
+ #
69
+ # These are options that are passed to git before the command name and
70
+ # arguments. For example, in `git --git-dir /path/to/git/dir version`, the
71
+ # global options are %w[--git-dir /path/to/git/dir].
72
+ #
73
+ # @example
74
+ # env = {}
75
+ # global_opts = %w[--git-dir /path/to/git/dir]
76
+ # logger = Logger.new(nil)
77
+ # cli = CommandLine.new(env, '/usr/bin/git', global_opts, logger)
78
+ # cli.global_opts #=> %w[--git-dir /path/to/git/dir]
79
+ #
80
+ # @return [Array<String>]
81
+ #
82
+ attr_reader :global_opts
83
+
84
+ # @attribute [r] logger
85
+ #
86
+ # The logger to use for logging git commands and results
87
+ #
88
+ # @example
89
+ # env = {}
90
+ # global_opts = %w[]
91
+ # logger = Logger.new(STDOUT)
92
+ # cli = CommandLine.new(env, '/usr/bin/git', global_opts, logger)
93
+ # cli.logger == logger #=> true
94
+ #
95
+ # @return [Logger]
96
+ #
97
+ attr_reader :logger
98
+
99
+ # Execute a git command, wait for it to finish, and return the result
100
+ #
101
+ # NORMALIZATION
102
+ #
103
+ # The command output is returned as a Unicde string containing the binary output
104
+ # from the command. If the binary output is not valid UTF-8, the output will
105
+ # cause problems because the encoding will be invalid.
106
+ #
107
+ # Normalization is a process that trys to convert the binary output to a valid
108
+ # UTF-8 string. It uses the `rchardet` gem to detect the encoding of the binary
109
+ # output and then converts it to UTF-8.
110
+ #
111
+ # Normalization is not enabled by default. Pass `normalize: true` to Git::CommandLine#run
112
+ # to enable it. Normalization will only be performed on stdout and only if the `out:`` option
113
+ # is nil or is a StringIO object. If the out: option is set to a file or other IO object,
114
+ # the normalize option will be ignored.
115
+ #
116
+ # @example Run a command and return the output
117
+ #
118
+ # cli.run('version') #=> "git version 2.39.1\n"
119
+ #
120
+ # @example The args array should be splatted into the parameter list
121
+ # args = %w[log -n 1 --oneline]
122
+ # cli.run(*args) #=> "f5baa11 beginning of Ruby/Git project\n"
123
+ #
124
+ # @example Run a command and return the chomped output
125
+ # cli.run('version', chomp: true) #=> "git version 2.39.1"
126
+ #
127
+ # @example Run a command and without normalizing the output
128
+ # cli.run('version', normalize: false) #=> "git version 2.39.1\n"
129
+ #
130
+ # @example Capture stdout in a temporary file
131
+ # require 'tempfile'
132
+ # tempfile = Tempfile.create('git') do |file|
133
+ # cli.run('version', out: file)
134
+ # file.rewind
135
+ # file.read #=> "git version 2.39.1\n"
136
+ # end
137
+ #
138
+ # @example Capture stderr in a StringIO object
139
+ # require 'stringio'
140
+ # stderr = StringIO.new
141
+ # begin
142
+ # cli.run('log', 'nonexistent-branch', err: stderr)
143
+ # rescue Git::FailedError => e
144
+ # stderr.string #=> "unknown revision or path not in the working tree.\n"
145
+ # end
146
+ #
147
+ # @param args [Array<String>] the command line arguements to pass to git
148
+ #
149
+ # This array should be splatted into the parameter list.
150
+ #
151
+ # @param out [#write, nil] the object to write stdout to or nil to ignore stdout
152
+ #
153
+ # If this is a 'StringIO' object, then `stdout_writer.string` will be returned.
154
+ #
155
+ # In general, only specify a `stdout_writer` object when you want to redirect
156
+ # stdout to a file or some other object that responds to `#write`. The default
157
+ # behavior will return the output of the command.
158
+ #
159
+ # @param err [#write] the object to write stderr to or nil to ignore stderr
160
+ #
161
+ # If this is a 'StringIO' object and `merged_output` is `true`, then
162
+ # `stderr_writer.string` will be merged into the output returned by this method.
163
+ #
164
+ # @param normalize [Boolean] whether to normalize the output to a valid encoding
165
+ # @param chomp [Boolean] whether to chomp the output
166
+ # @param merge [Boolean] whether to merge stdout and stderr in the string returned
167
+ # @param chdir [String] the directory to run the command in
168
+ #
169
+ # @param timeout [Numeric, nil] the maximum seconds to wait for the command to complete
170
+ #
171
+ # If timeout is zero or nil, the command will not time out. If the command
172
+ # times out, it is killed via a SIGKILL signal and `Git::TimeoutError` is raised.
173
+ #
174
+ # If the command does not respond to SIGKILL, it will hang this method.
175
+ #
176
+ # @return [Git::CommandLineResult] the output of the command
177
+ #
178
+ # This result of running the command.
179
+ #
180
+ # @raise [ArgumentError] if `args` is not an array of strings
181
+ # @raise [Git::SignaledError] if the command was terminated because of an uncaught signal
182
+ # @raise [Git::FailedError] if the command returned a non-zero exitstatus
183
+ # @raise [Git::GitExecuteError] if an exception was raised while collecting subprocess output
184
+ # @raise [Git::TimeoutError] if the command times out
185
+ #
186
+ def run(*args, out:, err:, normalize:, chomp:, merge:, chdir: nil, timeout: nil)
187
+ git_cmd = build_git_cmd(args)
188
+ out ||= StringIO.new
189
+ err ||= (merge ? out : StringIO.new)
190
+ status = execute(git_cmd, out, err, chdir: (chdir || :not_set), timeout: timeout)
191
+
192
+ process_result(git_cmd, status, out, err, normalize, chomp, timeout)
193
+ end
194
+
195
+ private
196
+
197
+ # Build the git command line from the available sources to send to `Process.spawn`
198
+ # @return [Array<String>]
199
+ # @api private
200
+ #
201
+ def build_git_cmd(args)
202
+ raise ArgumentError.new('The args array can not contain an array') if args.any? { |a| a.is_a?(Array) }
203
+
204
+ [binary_path, *global_opts, *args].map { |e| e.to_s }
205
+ end
206
+
207
+ # Determine the output to return in the `CommandLineResult`
208
+ #
209
+ # If the writer can return the output by calling `#string` (such as a StringIO),
210
+ # then return the result of normalizing the encoding and chomping the output
211
+ # as requested.
212
+ #
213
+ # If the writer does not support `#string`, then return nil. The output is
214
+ # assumed to be collected by the writer itself such as when the writer
215
+ # is a file instead of a StringIO.
216
+ #
217
+ # @param writer [#string] the writer to post-process
218
+ #
219
+ # @return [String, nil]
220
+ #
221
+ # @api private
222
+ #
223
+ def post_process(writer, normalize, chomp)
224
+ if writer.respond_to?(:string)
225
+ output = writer.string.dup
226
+ output = output.lines.map { |l| Git::EncodingUtils.normalize_encoding(l) }.join if normalize
227
+ output.chomp! if chomp
228
+ output
229
+ else
230
+ nil
231
+ end
232
+ end
233
+
234
+ # Post-process all writers and return an array of the results
235
+ #
236
+ # @param writers [Array<#write>] the writers to post-process
237
+ # @param normalize [Boolean] whether to normalize the output of each writer
238
+ # @param chomp [Boolean] whether to chomp the output of each writer
239
+ #
240
+ # @return [Array<String, nil>] the output of each writer that supports `#string`
241
+ #
242
+ # @api private
243
+ #
244
+ def post_process_all(writers, normalize, chomp)
245
+ Array.new.tap do |result|
246
+ writers.each { |writer| result << post_process(writer, normalize, chomp) }
247
+ end
248
+ end
249
+
250
+ # Raise an error when there was exception while collecting the subprocess output
251
+ #
252
+ # @param git_cmd [Array<String>] the git command that was executed
253
+ # @param pipe_name [Symbol] the name of the pipe that raised the exception
254
+ # @param pipe [ProcessExecuter::MonitoredPipe] the pipe that raised the exception
255
+ #
256
+ # @raise [Git::GitExecuteError]
257
+ #
258
+ # @return [void] this method always raises an error
259
+ #
260
+ # @api private
261
+ #
262
+ def raise_pipe_error(git_cmd, pipe_name, pipe)
263
+ raise Git::GitExecuteError.new("Pipe Exception for #{git_cmd}: #{pipe_name}"), cause: pipe.exception
264
+ end
265
+
266
+ # Execute the git command and collect the output
267
+ #
268
+ # @param cmd [Array<String>] the git command to execute
269
+ # @param chdir [String] the directory to run the command in
270
+ # @param timeout [Float, Integer, nil] the maximum seconds to wait for the command to complete
271
+ #
272
+ # If timeout is zero of nil, the command will not time out. If the command
273
+ # times out, it is killed via a SIGKILL signal and `Git::TimeoutError` is raised.
274
+ #
275
+ # If the command does not respond to SIGKILL, it will hang this method.
276
+ #
277
+ # @raise [Git::GitExecuteError] if an exception was raised while collecting subprocess output
278
+ # @raise [Git::TimeoutError] if the command times out
279
+ #
280
+ # @return [ProcessExecuter::Status] the status of the completed subprocess
281
+ #
282
+ # @api private
283
+ #
284
+ def spawn(cmd, out_writers, err_writers, chdir:, timeout:)
285
+ out_pipe = ProcessExecuter::MonitoredPipe.new(*out_writers, chunk_size: 10_000)
286
+ err_pipe = ProcessExecuter::MonitoredPipe.new(*err_writers, chunk_size: 10_000)
287
+ ProcessExecuter.spawn(env, *cmd, out: out_pipe, err: err_pipe, chdir: chdir, timeout: timeout)
288
+ ensure
289
+ out_pipe.close
290
+ err_pipe.close
291
+ raise_pipe_error(cmd, :stdout, out_pipe) if out_pipe.exception
292
+ raise_pipe_error(cmd, :stderr, err_pipe) if err_pipe.exception
293
+ end
294
+
295
+ # The writers that will be used to collect stdout and stderr
296
+ #
297
+ # Additional writers could be added here if you wanted to tee output
298
+ # or send output to the terminal.
299
+ #
300
+ # @param out [#write] the object to write stdout to
301
+ # @param err [#write] the object to write stderr to
302
+ #
303
+ # @return [Array<Array<#write>, Array<#write>>] the writers for stdout and stderr
304
+ #
305
+ # @api private
306
+ #
307
+ def writers(out, err)
308
+ out_writers = [out]
309
+ err_writers = [err]
310
+ [out_writers, err_writers]
311
+ end
312
+
313
+ # Process the result of the command and return a Git::CommandLineResult
314
+ #
315
+ # Post process output, log the command and result, and raise an error if the
316
+ # command failed.
317
+ #
318
+ # @param git_cmd [Array<String>] the git command that was executed
319
+ # @param status [Process::Status] the status of the completed subprocess
320
+ # @param out [#write] the object that stdout was written to
321
+ # @param err [#write] the object that stderr was written to
322
+ # @param normalize [Boolean] whether to normalize the output of each writer
323
+ # @param chomp [Boolean] whether to chomp the output of each writer
324
+ #
325
+ # @return [Git::CommandLineResult] the result of the command to return to the caller
326
+ #
327
+ # @raise [Git::FailedError] if the command failed
328
+ # @raise [Git::SignaledError] if the command was signaled
329
+ #
330
+ # @api private
331
+ #
332
+ def process_result(git_cmd, status, out, err, normalize, chomp, timeout)
333
+ out_str, err_str = post_process_all([out, err], normalize, chomp)
334
+ logger.info { "#{git_cmd} exited with status #{status}" }
335
+ logger.debug { "stdout:\n#{out_str.inspect}\nstderr:\n#{err_str.inspect}" }
336
+ Git::CommandLineResult.new(git_cmd, status, out_str, err_str).tap do |result|
337
+ raise Git::TimeoutError.new(result, timeout) if status.timeout?
338
+ raise Git::SignaledError.new(result) if status.signaled?
339
+ raise Git::FailedError.new(result) unless status.success?
340
+ end
341
+ end
342
+
343
+ # Execute the git command and write the command output to out and err
344
+ #
345
+ # @param git_cmd [Array<String>] the git command to execute
346
+ # @param out [#write] the object to write stdout to
347
+ # @param err [#write] the object to write stderr to
348
+ # @param chdir [String] the directory to run the command in
349
+ # @param timeout [Float, Integer, nil] the maximum seconds to wait for the command to complete
350
+ #
351
+ # If timeout is zero of nil, the command will not time out. If the command
352
+ # times out, it is killed via a SIGKILL signal and `Git::TimeoutError` is raised.
353
+ #
354
+ # If the command does not respond to SIGKILL, it will hang this method.
355
+ #
356
+ # @raise [Git::GitExecuteError] if an exception was raised while collecting subprocess output
357
+ # @raise [Git::TimeoutError] if the command times out
358
+ #
359
+ # @return [Git::CommandLineResult] the result of the command to return to the caller
360
+ #
361
+ # @api private
362
+ #
363
+ def execute(git_cmd, out, err, chdir:, timeout:)
364
+ out_writers, err_writers = writers(out, err)
365
+ spawn(git_cmd, out_writers, err_writers, chdir: chdir, timeout: timeout)
366
+ end
367
+ end
368
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'error'
4
+
5
+ module Git
6
+ # Raised when a git command fails or exits because of an uncaught signal
7
+ #
8
+ # The git command executed, status, stdout, and stderr are available from this
9
+ # object.
10
+ #
11
+ # Rather than creating a CommandLineError object directly, it is recommended to use
12
+ # one of the derived classes for the appropriate type of error:
13
+ #
14
+ # * {Git::FailedError}: when the git command exits with a non-zero status
15
+ # * {Git::SignaledError}: when the git command exits because of an uncaught signal
16
+ # * {Git::TimeoutError}: when the git command times out
17
+ #
18
+ # @api public
19
+ #
20
+ class CommandLineError < Git::Error
21
+ # Create a CommandLineError object
22
+ #
23
+ # @example
24
+ # `exit 1` # set $? appropriately for this example
25
+ # result = Git::CommandLineResult.new(%w[git status], $?, 'stdout', 'stderr')
26
+ # error = Git::CommandLineError.new(result)
27
+ # error.to_s #=> '["git", "status"], status: pid 89784 exit 1, stderr: "stderr"'
28
+ #
29
+ # @param result [Git::CommandLineResult] the result of the git command including
30
+ # the git command, status, stdout, and stderr
31
+ #
32
+ def initialize(result)
33
+ @result = result
34
+ super()
35
+ end
36
+
37
+ # The human readable representation of this error
38
+ #
39
+ # @example
40
+ # error.to_s #=> '["git", "status"], status: pid 89784 exit 1, stderr: "stderr"'
41
+ #
42
+ # @return [String]
43
+ #
44
+ def to_s = <<~MESSAGE.chomp
45
+ #{result.git_cmd}, status: #{result.status}, stderr: #{result.stderr.inspect}
46
+ MESSAGE
47
+
48
+ # @attribute [r] result
49
+ #
50
+ # The result of the git command including the git command and its status and output
51
+ #
52
+ # @example
53
+ # error.result #=> #<Git::CommandLineResult:0x00000001046bd488 ...>
54
+ #
55
+ # @return [Git::CommandLineResult]
56
+ #
57
+ attr_reader :result
58
+ end
59
+ end
data/lib/git/config.rb CHANGED
@@ -2,11 +2,12 @@ module Git
2
2
 
3
3
  class Config
4
4
 
5
- attr_writer :binary_path, :git_ssh
5
+ attr_writer :binary_path, :git_ssh, :timeout
6
6
 
7
7
  def initialize
8
8
  @binary_path = nil
9
9
  @git_ssh = nil
10
+ @timeout = nil
10
11
  end
11
12
 
12
13
  def binary_path
@@ -17,6 +18,9 @@ module Git
17
18
  @git_ssh || ENV['GIT_SSH']
18
19
  end
19
20
 
21
+ def timeout
22
+ @timeout || (ENV['GIT_TIMEOUT'] && ENV['GIT_TIMEOUT'].to_i)
23
+ end
20
24
  end
21
25
 
22
26
  end
data/lib/git/error.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ # Base class for all custom git module errors
5
+ #
6
+ class Error < StandardError; end
7
+ end
@@ -1,53 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'git/git_execute_error'
3
+ require_relative 'command_line_error'
4
4
 
5
5
  module Git
6
- # This error is raised when a git command fails
6
+ # This error is raised when a git command returns a non-zero exitstatus
7
7
  #
8
8
  # The git command executed, status, stdout, and stderr are available from this
9
- # object. The #message includes the git command, the status of the process, and
10
- # the stderr of the process.
9
+ # object.
11
10
  #
12
11
  # @api public
13
12
  #
14
- class FailedError < Git::GitExecuteError
15
- # Create a FailedError object
16
- #
17
- # Since this gem redirects stderr to stdout, the stdout of the process is used.
18
- #
19
- # @example
20
- # `exit 1` # set $? appropriately for this example
21
- # result = Git::CommandLineResult.new(%w[git status], $?, 'stdout', 'stderr')
22
- # error = Git::FailedError.new(result)
23
- # error.message #=>
24
- # "[\"git\", \"status\"]\nstatus: pid 89784 exit 1\noutput: \"stdout\""
25
- #
26
- # @param result [Git::CommandLineResult] the result of the git command including
27
- # the git command, status, stdout, and stderr
28
- #
29
- def initialize(result)
30
- super("#{result.git_cmd}\nstatus: #{result.status}\noutput: #{result.stdout.inspect}")
31
- @result = result
32
- end
33
-
34
- # @attribute [r] result
35
- #
36
- # The result of the git command including the git command and its status and output
37
- #
38
- # @example
39
- # `exit 1` # set $? appropriately for this example
40
- # result = Git::CommandLineResult.new(%w[git status], $?, 'stdout', 'stderr')
41
- # error = Git::FailedError.new(result)
42
- # error.result #=>
43
- # #<Git::CommandLineResult:0x00000001046bd488
44
- # @git_cmd=["git", "status"],
45
- # @status=#<Process::Status: pid 89784 exit 1>,
46
- # @stderr="stderr",
47
- # @stdout="stdout">
48
- #
49
- # @return [Git::CommandLineResult]
50
- #
51
- attr_reader :result
52
- end
13
+ class FailedError < Git::CommandLineError; end
53
14
  end
@@ -1,7 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'error'
4
+
3
5
  module Git
4
6
  # This error is raised when a git command fails
5
7
  #
6
- class GitExecuteError < StandardError; end
8
+ # This error class is used as an alias for Git::Error for backwards compatibility.
9
+ # It is recommended to use Git::Error directly.
10
+ #
11
+ # @deprecated Use Git::Error instead
12
+ #
13
+ GitExecuteError = Git::Error
7
14
  end