quiet_quality 0.1.0
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.
- 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
|