quiet_quality 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/workflows/dogfood.yml +28 -0
- data/.github/workflows/linters.yml +31 -0
- data/.github/workflows/rspec.yml +33 -0
- data/.gitignore +6 -0
- data/.rspec +1 -0
- data/.rubocop.yml +21 -0
- data/Gemfile +3 -0
- data/LICENSE +20 -0
- data/README.md +34 -0
- data/bin/qq +35 -0
- data/lib/quiet_quality/annotation_locator.rb +33 -0
- data/lib/quiet_quality/annotators/github_stdout.rb +36 -0
- data/lib/quiet_quality/annotators.rb +19 -0
- data/lib/quiet_quality/changed_file.rb +40 -0
- data/lib/quiet_quality/changed_files.rb +35 -0
- data/lib/quiet_quality/cli/option_parser.rb +103 -0
- data/lib/quiet_quality/cli/options.rb +27 -0
- data/lib/quiet_quality/cli/options_builder.rb +49 -0
- data/lib/quiet_quality/cli.rb +12 -0
- data/lib/quiet_quality/executors/base_executor.rb +46 -0
- data/lib/quiet_quality/executors/concurrent_executor.rb +20 -0
- data/lib/quiet_quality/executors/pipeline.rb +59 -0
- data/lib/quiet_quality/executors/serial_executor.rb +15 -0
- data/lib/quiet_quality/executors.rb +17 -0
- data/lib/quiet_quality/message.rb +25 -0
- data/lib/quiet_quality/message_filter.rb +33 -0
- data/lib/quiet_quality/messages.rb +54 -0
- data/lib/quiet_quality/tool_options.rb +32 -0
- data/lib/quiet_quality/tools/outcome.rb +26 -0
- data/lib/quiet_quality/tools/rspec/parser.rb +45 -0
- data/lib/quiet_quality/tools/rspec/runner.rb +57 -0
- data/lib/quiet_quality/tools/rspec.rb +11 -0
- data/lib/quiet_quality/tools/rubocop/parser.rb +46 -0
- data/lib/quiet_quality/tools/rubocop/runner.rb +67 -0
- data/lib/quiet_quality/tools/rubocop.rb +11 -0
- data/lib/quiet_quality/tools/standardrb/parser.rb +8 -0
- data/lib/quiet_quality/tools/standardrb/runner.rb +11 -0
- data/lib/quiet_quality/tools/standardrb.rb +13 -0
- data/lib/quiet_quality/tools.rb +21 -0
- data/lib/quiet_quality/version.rb +3 -0
- data/lib/quiet_quality/version_control_systems/git.rb +117 -0
- data/lib/quiet_quality/version_control_systems.rb +8 -0
- data/lib/quiet_quality.rb +15 -0
- data/quiet_quality.gemspec +44 -0
- data/tmp/.gitkeep +0 -0
- metadata +219 -0
@@ -0,0 +1,59 @@
|
|
1
|
+
module QuietQuality
|
2
|
+
module Executors
|
3
|
+
class Pipeline
|
4
|
+
def initialize(tool_options:, changed_files: nil)
|
5
|
+
@tool_options = tool_options
|
6
|
+
@changed_files = changed_files
|
7
|
+
end
|
8
|
+
|
9
|
+
def tool_name
|
10
|
+
tool_options.tool_name
|
11
|
+
end
|
12
|
+
|
13
|
+
def outcome
|
14
|
+
@_outcome ||= runner.invoke!
|
15
|
+
end
|
16
|
+
|
17
|
+
def failure?
|
18
|
+
outcome.failure?
|
19
|
+
end
|
20
|
+
|
21
|
+
def messages
|
22
|
+
return @_messages if defined?(@_messages)
|
23
|
+
@_messages = parser.messages
|
24
|
+
@_messages = relevance_filter.filter(@_messages) if filter_messages? && changed_files
|
25
|
+
@_messages.each { |m| locator.update!(m) } if changed_files
|
26
|
+
@_messages
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
attr_reader :changed_files, :tool_options
|
32
|
+
|
33
|
+
def limit_targets?
|
34
|
+
tool_options.limit_targets?
|
35
|
+
end
|
36
|
+
|
37
|
+
def filter_messages?
|
38
|
+
tool_options.filter_messages?
|
39
|
+
end
|
40
|
+
|
41
|
+
def runner
|
42
|
+
@_runner ||= tool_options.runner_class
|
43
|
+
.new(changed_files: limit_targets? ? changed_files : nil)
|
44
|
+
end
|
45
|
+
|
46
|
+
def parser
|
47
|
+
@_parser ||= tool_options.parser_class.new(outcome.output)
|
48
|
+
end
|
49
|
+
|
50
|
+
def relevance_filter
|
51
|
+
@_relevance_filter ||= MessageFilter.new(changed_files: changed_files)
|
52
|
+
end
|
53
|
+
|
54
|
+
def locator
|
55
|
+
@_locator ||= AnnotationLocator.new(changed_files: changed_files)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require_relative "./base_executor"
|
2
|
+
|
3
|
+
module QuietQuality
|
4
|
+
module Executors
|
5
|
+
class SerialExecutor < BaseExecutor
|
6
|
+
def execute!
|
7
|
+
pipelines.each do |pipeline|
|
8
|
+
pipeline.outcome
|
9
|
+
pipeline.messages
|
10
|
+
end
|
11
|
+
pipelines.none?(&:failure?)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module QuietQuality
|
2
|
+
module Executors
|
3
|
+
Error = Class.new(::QuietQuality::Error)
|
4
|
+
end
|
5
|
+
end
|
6
|
+
|
7
|
+
glob = File.expand_path("../executors/*.rb", __FILE__)
|
8
|
+
Dir.glob(glob).sort.each { |f| require f }
|
9
|
+
|
10
|
+
module QuietQuality
|
11
|
+
module Executors
|
12
|
+
AVAILABLE = {
|
13
|
+
serial: SerialExecutor,
|
14
|
+
concurrent: ConcurrentExecutor
|
15
|
+
}.freeze
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module QuietQuality
|
2
|
+
class Message
|
3
|
+
attr_accessor :annotated_line
|
4
|
+
attr_reader :path, :body, :start_line, :stop_line, :level, :rule
|
5
|
+
|
6
|
+
def self.load(hash)
|
7
|
+
new(**hash)
|
8
|
+
end
|
9
|
+
|
10
|
+
def initialize(**attrs)
|
11
|
+
@attrs = attrs.map { |k, v| [k.to_s, v] }.to_h
|
12
|
+
@path = @attrs.fetch("path")
|
13
|
+
@body = @attrs.fetch("body")
|
14
|
+
@start_line = @attrs.fetch("start_line")
|
15
|
+
@stop_line = @attrs.fetch("stop_line", @start_line)
|
16
|
+
@annotated_line = @attrs.fetch("annotated_line", nil)
|
17
|
+
@level = @attrs.fetch("level", nil)
|
18
|
+
@rule = @attrs.fetch("rule", nil)
|
19
|
+
end
|
20
|
+
|
21
|
+
def to_h
|
22
|
+
@attrs.map { |k, v| [k.to_s, v] }.to_h
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module QuietQuality
|
2
|
+
class MessageFilter
|
3
|
+
def initialize(changed_files:)
|
4
|
+
@changed_files = changed_files
|
5
|
+
end
|
6
|
+
|
7
|
+
def relevant?(message)
|
8
|
+
return false unless changed_files.include?(message.path)
|
9
|
+
|
10
|
+
file = changed_files.file(message.path)
|
11
|
+
return true if file.entire?
|
12
|
+
|
13
|
+
relevant_lines?(message, file)
|
14
|
+
end
|
15
|
+
|
16
|
+
def filter(messages)
|
17
|
+
Messages.new(messages.select { |m| relevant?(m) })
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
attr_reader :changed_files
|
23
|
+
|
24
|
+
def relevant_lines?(message, file)
|
25
|
+
if message.stop_line == message.start_line
|
26
|
+
file.lines.include?(message.start_line)
|
27
|
+
else
|
28
|
+
message_range = (message.start_line..message.stop_line)
|
29
|
+
file.line_numbers.any? { |n| message_range.cover?(n) }
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
module QuietQuality
|
2
|
+
class Messages
|
3
|
+
include Enumerable
|
4
|
+
|
5
|
+
def self.load_data(data)
|
6
|
+
messages = data.map { |message_data| Message.new(**message_data) }
|
7
|
+
new(messages)
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.load_json(text)
|
11
|
+
load_data(JSON.parse(text))
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.load_yaml(text)
|
15
|
+
load_data(YAML.safe_load(text))
|
16
|
+
end
|
17
|
+
|
18
|
+
def initialize(messages)
|
19
|
+
@messages = messages
|
20
|
+
end
|
21
|
+
|
22
|
+
def to_hashes
|
23
|
+
messages.map(&:to_h)
|
24
|
+
end
|
25
|
+
|
26
|
+
def to_json(pretty: false)
|
27
|
+
pretty ? JSON.pretty_generate(to_hashes) : JSON.generate(to_hashes)
|
28
|
+
end
|
29
|
+
|
30
|
+
def to_yaml
|
31
|
+
to_hashes.to_yaml
|
32
|
+
end
|
33
|
+
|
34
|
+
def all
|
35
|
+
messages
|
36
|
+
end
|
37
|
+
|
38
|
+
def empty?
|
39
|
+
messages.length == 0
|
40
|
+
end
|
41
|
+
|
42
|
+
def each(&block)
|
43
|
+
if block
|
44
|
+
messages.each(&block)
|
45
|
+
else
|
46
|
+
to_enum(:each)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
attr_reader :messages
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module QuietQuality
|
2
|
+
class ToolOptions
|
3
|
+
def initialize(tool, limit_targets: true, filter_messages: true)
|
4
|
+
@tool_name = tool.to_sym
|
5
|
+
@limit_targets = limit_targets
|
6
|
+
@filter_messages = filter_messages
|
7
|
+
end
|
8
|
+
|
9
|
+
attr_reader :tool_name
|
10
|
+
attr_writer :limit_targets, :filter_messages
|
11
|
+
|
12
|
+
def limit_targets?
|
13
|
+
@limit_targets
|
14
|
+
end
|
15
|
+
|
16
|
+
def filter_messages?
|
17
|
+
@filter_messages
|
18
|
+
end
|
19
|
+
|
20
|
+
def tool_namespace
|
21
|
+
Tools::AVAILABLE.fetch(tool_name)
|
22
|
+
end
|
23
|
+
|
24
|
+
def runner_class
|
25
|
+
tool_namespace::Runner
|
26
|
+
end
|
27
|
+
|
28
|
+
def parser_class
|
29
|
+
tool_namespace::Parser
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module QuietQuality
|
2
|
+
module Tools
|
3
|
+
class Outcome
|
4
|
+
attr_reader :output, :logging, :tool
|
5
|
+
|
6
|
+
def initialize(tool:, output:, logging: nil, failure: false)
|
7
|
+
@tool = tool
|
8
|
+
@output = output
|
9
|
+
@logging = logging
|
10
|
+
@failure = failure
|
11
|
+
end
|
12
|
+
|
13
|
+
def failure?
|
14
|
+
@failure
|
15
|
+
end
|
16
|
+
|
17
|
+
def success?
|
18
|
+
!failure?
|
19
|
+
end
|
20
|
+
|
21
|
+
def ==(other)
|
22
|
+
tool == other.tool && output == other.output && logging == other.logging && failure? == other.failure?
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module QuietQuality
|
2
|
+
module Tools
|
3
|
+
module Rspec
|
4
|
+
class Parser
|
5
|
+
def initialize(text)
|
6
|
+
@text = text
|
7
|
+
end
|
8
|
+
|
9
|
+
def messages
|
10
|
+
return @_messages if defined?(@_messages)
|
11
|
+
messages = failed_examples.map { |ex| message_for(ex) }
|
12
|
+
@_messages = Messages.new(messages)
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
attr_reader :text
|
18
|
+
|
19
|
+
def content
|
20
|
+
@_content ||= JSON.parse(text, symbolize_names: true)
|
21
|
+
end
|
22
|
+
|
23
|
+
def examples
|
24
|
+
@_examples ||= content.fetch(:examples)
|
25
|
+
end
|
26
|
+
|
27
|
+
def failed_examples
|
28
|
+
@_failed_examples ||= examples.select { |ex| ex[:status] == "failed" }
|
29
|
+
end
|
30
|
+
|
31
|
+
def reduced_path(path)
|
32
|
+
path.gsub(%r{^\./}, "")
|
33
|
+
end
|
34
|
+
|
35
|
+
def message_for(example)
|
36
|
+
path = reduced_path(example.fetch(:file_path))
|
37
|
+
body = example.dig(:exception, :message) || example.fetch(:description)
|
38
|
+
line = example.fetch(:line_number)
|
39
|
+
rule = example.dig(:exception, :class) || "Failed Example"
|
40
|
+
Message.new(path: path, body: body, start_line: line, rule: rule)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
module QuietQuality
|
2
|
+
module Tools
|
3
|
+
module Rspec
|
4
|
+
class Runner
|
5
|
+
MAX_FILES = 100
|
6
|
+
NO_FILES_OUTPUT = '{"examples": [], "summary": {"failure_count": 0}}'
|
7
|
+
|
8
|
+
def initialize(changed_files: nil)
|
9
|
+
@changed_files = changed_files
|
10
|
+
end
|
11
|
+
|
12
|
+
def invoke!
|
13
|
+
@_outcome ||= skip_execution? ? skipped_outcome : performed_outcome
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
attr_reader :changed_files, :error_stream
|
19
|
+
|
20
|
+
def skip_execution?
|
21
|
+
changed_files && relevant_files.empty?
|
22
|
+
end
|
23
|
+
|
24
|
+
def relevant_files
|
25
|
+
return nil if changed_files.nil?
|
26
|
+
changed_files.paths.select { |path| path.end_with?("_spec.rb") }
|
27
|
+
end
|
28
|
+
|
29
|
+
def target_files
|
30
|
+
return [] if changed_files.nil?
|
31
|
+
return [] if relevant_files.length > MAX_FILES
|
32
|
+
relevant_files
|
33
|
+
end
|
34
|
+
|
35
|
+
def command
|
36
|
+
return nil if skip_execution?
|
37
|
+
["rspec", "-f", "json"] + target_files.sort
|
38
|
+
end
|
39
|
+
|
40
|
+
def skipped_outcome
|
41
|
+
Outcome.new(tool: :rspec, output: NO_FILES_OUTPUT)
|
42
|
+
end
|
43
|
+
|
44
|
+
def performed_outcome
|
45
|
+
out, err, stat = Open3.capture3(*command)
|
46
|
+
if stat.success?
|
47
|
+
Outcome.new(tool: :rspec, output: out, logging: err)
|
48
|
+
elsif stat.exitstatus == 1
|
49
|
+
Outcome.new(tool: :rspec, output: out, logging: err, failure: true)
|
50
|
+
else
|
51
|
+
fail(ExecutionError, "Execution of rspec failed with #{stat.exitstatus}")
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module QuietQuality
|
2
|
+
module Tools
|
3
|
+
module Rubocop
|
4
|
+
class Parser
|
5
|
+
def initialize(text)
|
6
|
+
@text = text
|
7
|
+
end
|
8
|
+
|
9
|
+
def messages
|
10
|
+
return @_messages if defined?(@_messages)
|
11
|
+
messages = content
|
12
|
+
.fetch(:files)
|
13
|
+
.map { |f| messages_for_file(f) }
|
14
|
+
.flatten
|
15
|
+
@_messages = Messages.new(messages)
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
attr_reader :text
|
21
|
+
|
22
|
+
def content
|
23
|
+
@_content ||= JSON.parse(text, symbolize_names: true)
|
24
|
+
end
|
25
|
+
|
26
|
+
def messages_for_file(file_details)
|
27
|
+
path = file_details.fetch(:path)
|
28
|
+
file_details.fetch(:offenses).map do |offense|
|
29
|
+
message_for_offense(path, offense)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def message_for_offense(path, offense)
|
34
|
+
Message.new(
|
35
|
+
path: path,
|
36
|
+
body: offense.fetch(:message),
|
37
|
+
start_line: offense.dig(:location, :start_line),
|
38
|
+
stop_line: offense.dig(:location, :last_line),
|
39
|
+
level: offense.fetch(:severity, nil),
|
40
|
+
rule: offense.fetch(:cop_name, nil)
|
41
|
+
)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
module QuietQuality
|
2
|
+
module Tools
|
3
|
+
module Rubocop
|
4
|
+
class Runner
|
5
|
+
MAX_FILES = 100
|
6
|
+
NO_FILES_OUTPUT = '{"files": [], "summary": {"offense_count": 0}}'
|
7
|
+
|
8
|
+
def command_name
|
9
|
+
"rubocop"
|
10
|
+
end
|
11
|
+
|
12
|
+
# Supplying changed_files: nil means "run against all files".
|
13
|
+
def initialize(changed_files: nil)
|
14
|
+
@changed_files = changed_files
|
15
|
+
end
|
16
|
+
|
17
|
+
def invoke!
|
18
|
+
@_outcome ||= skip_execution? ? skipped_outcome : performed_outcome
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
attr_reader :changed_files
|
24
|
+
|
25
|
+
# If we were told that _no files changed_ (which is distinct from not being told that
|
26
|
+
# any files changed - a [] instead of a nil), then we shouldn't run rubocop at all.
|
27
|
+
def skip_execution?
|
28
|
+
changed_files && relevant_files.empty?
|
29
|
+
end
|
30
|
+
|
31
|
+
# Note: if target_files goes over MAX_FILES, it's _empty_ instead - that means that
|
32
|
+
# we run against the full repository instead of the specific files (rubocop's behavior
|
33
|
+
# when no target files are specified)
|
34
|
+
def command
|
35
|
+
return nil if skip_execution?
|
36
|
+
[command_name, "-f", "json"] + target_files.sort
|
37
|
+
end
|
38
|
+
|
39
|
+
def relevant_files
|
40
|
+
return nil if changed_files.nil?
|
41
|
+
changed_files.paths.select { |path| path.end_with?(".rb") }
|
42
|
+
end
|
43
|
+
|
44
|
+
def target_files
|
45
|
+
return [] if changed_files.nil?
|
46
|
+
return [] if relevant_files.length > MAX_FILES
|
47
|
+
relevant_files
|
48
|
+
end
|
49
|
+
|
50
|
+
def skipped_outcome
|
51
|
+
Outcome.new(tool: command_name.to_sym, output: NO_FILES_OUTPUT)
|
52
|
+
end
|
53
|
+
|
54
|
+
def performed_outcome
|
55
|
+
out, err, stat = Open3.capture3(*command)
|
56
|
+
if stat.success?
|
57
|
+
Outcome.new(tool: command_name.to_sym, output: out, logging: err)
|
58
|
+
elsif stat.exitstatus == 1
|
59
|
+
Outcome.new(tool: command_name.to_sym, output: out, logging: err, failure: true)
|
60
|
+
else
|
61
|
+
fail(ExecutionError, "Execution of #{command_name} failed with #{stat.exitstatus}")
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
require_relative "./rubocop"
|
2
|
+
|
3
|
+
module QuietQuality
|
4
|
+
module Tools
|
5
|
+
module Standardrb
|
6
|
+
ExecutionError = Class.new(Tools::Error)
|
7
|
+
ParsingError = Class.new(Tools::Error)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
glob = File.expand_path("../standardrb/*.rb", __FILE__)
|
13
|
+
Dir.glob(glob).sort.each { |f| require f }
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require "open3"
|
2
|
+
|
3
|
+
module QuietQuality
|
4
|
+
module Tools
|
5
|
+
Error = Class.new(::QuietQuality::Error)
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
glob = File.expand_path("../tools/*.rb", __FILE__)
|
10
|
+
Dir.glob(glob).sort.each { |f| require f }
|
11
|
+
|
12
|
+
# reopen the class after the tools have been loaded, so we can list them for reference elsewhere.
|
13
|
+
module QuietQuality
|
14
|
+
module Tools
|
15
|
+
AVAILABLE = {
|
16
|
+
rspec: Rspec,
|
17
|
+
rubocop: Rubocop,
|
18
|
+
standardrb: Standardrb
|
19
|
+
}
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,117 @@
|
|
1
|
+
module QuietQuality
|
2
|
+
module VersionControlSystems
|
3
|
+
class Git
|
4
|
+
Error = Class.new(VersionControlSystems::Error)
|
5
|
+
|
6
|
+
attr_reader :git, :path
|
7
|
+
|
8
|
+
#
|
9
|
+
# Initializer
|
10
|
+
#
|
11
|
+
# @param [String] path Path to git repository
|
12
|
+
#
|
13
|
+
def initialize(path = ".")
|
14
|
+
@path = path
|
15
|
+
@git = ::Git.open(path)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Retrieves the files changed in the given commit compared to the base. When no base is given,
|
19
|
+
# the default branch is used as the base. When no sha is given, the HEAD commit is used.
|
20
|
+
# Optionally, uncommitted changes can be included in the result, as well as untracked files.
|
21
|
+
#
|
22
|
+
# @param [String] base The base commit to compare against
|
23
|
+
# @param [String] sha The commit to compare
|
24
|
+
# @param [Boolean] include_uncommitted Whether to include uncommitted changes
|
25
|
+
# @param [Boolean] include_untracked Whether to include untracked files
|
26
|
+
#
|
27
|
+
# @return [Hash] A hash of file paths and the files changed in those files as a Set
|
28
|
+
def changed_files(base: nil, sha: "HEAD", include_uncommitted: true, include_untracked: false)
|
29
|
+
base_commit = comparison_base(sha: sha, comparison_branch: base || default_branch)
|
30
|
+
[
|
31
|
+
committed_changed_files(base_commit, sha),
|
32
|
+
include_uncommitted ? uncommitted_changed_files : nil,
|
33
|
+
include_untracked ? untracked_changed_files : nil
|
34
|
+
].compact.reduce(&:merge)
|
35
|
+
end
|
36
|
+
|
37
|
+
#
|
38
|
+
# The default branch for the default remote for the local git repository
|
39
|
+
#
|
40
|
+
# @return [String] Branch name
|
41
|
+
def default_branch
|
42
|
+
self.class.default_branch(remote: git.remote.url)
|
43
|
+
end
|
44
|
+
|
45
|
+
#
|
46
|
+
# The default branch for the given remote
|
47
|
+
#
|
48
|
+
# @param [String] remote The remote repository url
|
49
|
+
# @return [String] Branch name
|
50
|
+
#
|
51
|
+
def self.default_branch(remote:)
|
52
|
+
::Git.default_branch(remote)
|
53
|
+
end
|
54
|
+
|
55
|
+
#
|
56
|
+
# Determines the nearest common ancestor for the given `sha` compared to the `branch`.
|
57
|
+
#
|
58
|
+
# @param [String] sha The git SHA of the commit
|
59
|
+
# @param [String] comparison_branch The comparison branch
|
60
|
+
#
|
61
|
+
# @return [String] The nearest common ancestor (SHA)
|
62
|
+
#
|
63
|
+
def comparison_base(sha:, comparison_branch:)
|
64
|
+
git.merge_base(comparison_branch, sha).first.sha
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
def changed_lines_for(diff)
|
70
|
+
GitDiffParser.parse(diff).flat_map do |parsed_diff|
|
71
|
+
parsed_diff.changed_line_numbers.to_set
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def committed_changed_files(base, sha)
|
76
|
+
ChangedFiles.new(committed_changes(base, sha))
|
77
|
+
end
|
78
|
+
|
79
|
+
def committed_changes(base, sha)
|
80
|
+
patch = git.diff(base, sha).patch
|
81
|
+
GitDiffParser.parse(patch).map { to_changed_file(_1) }
|
82
|
+
end
|
83
|
+
|
84
|
+
def to_changed_file(patch_file)
|
85
|
+
ChangedFile.new(path: patch_file.file, lines: patch_file.changed_line_numbers.to_set)
|
86
|
+
end
|
87
|
+
|
88
|
+
def uncommitted_changed_files
|
89
|
+
ChangedFiles.new(uncommitted_changes)
|
90
|
+
end
|
91
|
+
|
92
|
+
def uncommitted_changes
|
93
|
+
patch = git.diff.patch
|
94
|
+
GitDiffParser.parse(patch).map { to_changed_file(_1) }
|
95
|
+
end
|
96
|
+
|
97
|
+
def untracked_changed_files
|
98
|
+
ChangedFiles.new(untracked_changes)
|
99
|
+
end
|
100
|
+
|
101
|
+
def untracked_changes
|
102
|
+
untracked_paths.map do |file_path|
|
103
|
+
ChangedFile.new(path: file_path, lines: :all)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def untracked_paths
|
108
|
+
out, err, stat = Open3.capture3("git", "-C", path, "ls-files", "--others", "--exclude-standard")
|
109
|
+
unless stat.success?
|
110
|
+
warn err
|
111
|
+
fail(Error, "git ls-files failed")
|
112
|
+
end
|
113
|
+
out.split
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|