Skip to content

Commit e99bdab

Browse files
committed
Integrate process_executor gem for controlling the git subprocess
Signed-off-by: James Couball <jcouball@yahoo.com>
1 parent cf74b91 commit e99bdab

20 files changed

+637
-324
lines changed

.github/workflows/continuous_integration.yml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,17 @@ jobs:
1313
strategy:
1414
fail-fast: false
1515
matrix:
16-
ruby: [2.7, 3.0, 3.1, 3.2]
16+
ruby: [2.7, 3.0, 3.1, 3.2, head]
1717
operating-system: [ubuntu-latest]
1818
include:
19-
- ruby: head
20-
operating-system: ubuntu-latest
21-
- ruby: truffleruby-head
22-
operating-system: ubuntu-latest
23-
- ruby: 2.7
24-
operating-system: windows-latest
2519
- ruby: jruby-head
20+
operating-system: ubuntu-latest
21+
- ruby: 3.0
2622
operating-system: windows-latest
23+
# - ruby: jruby-head
24+
# operating-system: windows-latest
25+
# - ruby: truffleruby-head
26+
# operating-system: ubuntu-latest
2727

2828
name: Ruby ${{ matrix.ruby }} on ${{ matrix.operating-system }}
2929

git.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Gem::Specification.new do |s|
2727
s.requirements = ['git 1.6.0.0, or greater']
2828

2929
s.add_runtime_dependency 'addressable', '~> 2.8'
30+
s.add_runtime_dependency 'process_executer', '~> 0.7'
3031
s.add_runtime_dependency 'rchardet', '~> 1.8'
3132

3233
s.add_development_dependency 'bump', '~> 0.10'

lib/git.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@
77
require 'git/base'
88
require 'git/branch'
99
require 'git/branches'
10+
require 'git/command_line_result'
11+
require 'git/command_line'
1012
require 'git/config'
1113
require 'git/diff'
1214
require 'git/encoding_utils'
1315
require 'git/escaped_path'
16+
require 'git/failed_error'
17+
require 'git/git_execute_error'
1418
require 'git/index'
1519
require 'git/lib'
1620
require 'git/log'
@@ -19,6 +23,7 @@
1923
require 'git/remote'
2024
require 'git/repository'
2125
require 'git/status'
26+
require 'git/signaled_error'
2227
require 'git/stash'
2328
require 'git/stashes'
2429
require 'git/url'

lib/git/command_line.rb

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
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

lib/git/command_line_result.rb

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# frozen_string_literal: true
2+
3+
module Git
4+
# The result of running a git command
5+
#
6+
# This object stores the status, stdout, and stderr of a git command.
7+
#
8+
# @example
9+
# `true`
10+
# status = $?
11+
# stdout = "git version 2.39.1\n"
12+
# stderr = ""
13+
# result = Git::CommandLineResult.new(status, stdout, stderr)
14+
# result.status #=> #<Process::Status: pid 87859 exit 0>
15+
# result.stdout #=> "git version 2.39.1\n"
16+
# result.stderr #=> ""
17+
#
18+
# @api public
19+
#
20+
class CommandLineResult
21+
# Create a CommandLineResult object
22+
#
23+
# @example
24+
# `true`
25+
# status = $?
26+
# stdout = "git version 2.39.1\n"
27+
# stderr = ""
28+
# result = Git::CommandLineResult.new(status, stdout, stderr)
29+
#
30+
# @param status [Process::Status] the status of the process
31+
# @param stdout [String] the output of the process
32+
# @param stderr [String] the error output of the process
33+
#
34+
def initialize(status, stdout, stderr)
35+
@status = status
36+
@stdout = stdout
37+
@stderr = stderr
38+
end
39+
40+
# @attribute [r] status
41+
#
42+
# The status of the process
43+
#
44+
# @example
45+
# `true`
46+
# status = $?
47+
# result = Git::CommandLineResult.new(status, "", "")
48+
# result.status #=> #<Process::Status: pid 87859 exit 0>
49+
#
50+
# @return [Process::Status]
51+
#
52+
attr_reader :status
53+
54+
# @attribute [r] stdout
55+
#
56+
# The output of the process
57+
#
58+
# @example
59+
# stdout = "git version 2.39.1\n"
60+
# result = Git::CommandLineResult.new($?, stdout, "")
61+
# result.stdout #=> "git version 2.39.1\n"
62+
#
63+
# @return [String]
64+
#
65+
attr_reader :stdout
66+
67+
# @attribute [r] stderr
68+
#
69+
# The error output of the process
70+
#
71+
# @example
72+
# stderr = "Tag not found\n"
73+
# result = Git::CommandLineResult.new($?, "", stderr)
74+
# result.stderr #=> "Tag not found\n"
75+
#
76+
# @return [String]
77+
#
78+
attr_reader :stderr
79+
end
80+
end

0 commit comments

Comments
 (0)