git 1.19.1 → 2.0.0.pre2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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