Skip to content

Commit d0c532f

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

20 files changed

+580
-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: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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 spawn(cmd, out_writers, err_writers)
159+
out_pipe = ProcessExecuter::MonitoredPipe.new(*out_writers, chunk_size: 10_000)
160+
err_pipe = ProcessExecuter::MonitoredPipe.new(*err_writers, chunk_size: 10_000)
161+
ProcessExecuter.spawn(env, *cmd, out: out_pipe, err: err_pipe).tap do
162+
raise_pipe_error(git_cmd, :stdout, out_pipe) if out_pipe.exception
163+
raise_pipe_error(git_cmd, :stderr, err_pipe) if err_pipe.exception
164+
end
165+
ensure
166+
out_pipe.close
167+
err_pipe.close
168+
end
169+
170+
def writers(out, err)
171+
out_writers = [out]
172+
err_writers = [err]
173+
[out_writers, err_writers]
174+
end
175+
176+
def log_result(git_cmd, status, stdout, stderr)
177+
logger.info { "#{git_cmd} exited with status #{status.inspect}" }
178+
logger.debug { "stdout:\n#{stdout.inspect}\nstderr:\n#{stderr.inspect}" }
179+
end
180+
181+
def process_result(git_cmd, status, out, err, normalize, chomp)
182+
out_str, err_str = post_process_all([out, err], normalize, chomp)
183+
logger.info { "#{git_cmd} exited with status #{status}" }
184+
logger.debug { "stdout:\n#{out_str.inspect}\nstderr:\n#{err_str.inspect}" }
185+
result = Git::CommandLineResult.new(git_cmd, status, out_str, err_str).tap do |result|
186+
raise Git::SignaledError.new(result) if status.signaled?
187+
raise Git::FailedError.new(result) unless status.success?
188+
end
189+
end
190+
191+
def execute(git_cmd, out, err)
192+
out_writers, err_writers = writers(out, err)
193+
spawn(git_cmd, out_writers, err_writers)
194+
end
195+
end
196+
end

lib/git/command_line_result.rb

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

lib/git/failed_error.rb

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# frozen_string_literal: true
2+
3+
require 'git/git_execute_error'
4+
5+
module Git
6+
# This error is raised when a git command fails
7+
#
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.
11+
#
12+
# @api public
13+
#
14+
class FailedError < Git::GitExecuteError
15+
# Create a FailedError object
16+
#
17+
# @example
18+
# `exit 1` # set $? appropriately for this example
19+
# result = Git::CommandLineResult.new(%w[git status], $?, '', "failed")
20+
# error = Git::FailedError.new(result)
21+
# error.message #=>
22+
# "[\"git\", \"status\"]\nstatus: pid 89784 exit 1\nstderr: \"failed\""
23+
#
24+
# @param result [Git::CommandLineResult] the result of the git command including
25+
# the git command, status, stdout, and stderr
26+
#
27+
def initialize(result)
28+
super("#{result.git_cmd}\nstatus: #{result.status}\nstderr: #{result.stderr.inspect}")
29+
@result = result
30+
end
31+
32+
# @attribute [r] result
33+
#
34+
# The result of the git command including the git command and its status and output
35+
#
36+
# @example
37+
# `exit 1` # set $? appropriately for this example
38+
# result = Git::CommandLineResult.new(%w[git status], $?, '', "failed")
39+
# error = Git::FailedError.new(result)
40+
# error.result #=>
41+
# #<Git::CommandLineResult:0x00000001046bd488
42+
# @git_cmd=["git", "status"],
43+
# @status=#<Process::Status: pid 89784 exit 1>,
44+
# @stderr="failed",
45+
# @stdout="">
46+
#
47+
# @return [Git::CommandLineResult]
48+
#
49+
attr_reader :result
50+
end
51+
end

0 commit comments

Comments
 (0)