refinement 0.6.0 → 0.7.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 +4 -4
- data/CHANGELOG.md +16 -0
- data/VERSION +1 -1
- data/exe/refine +1 -1
- data/lib/cocoapods_plugin.rb +2 -2
- data/lib/refinement.rb +6 -27
- data/lib/sq/refinement/analyzer.rb +396 -0
- data/lib/sq/refinement/annotated_target.rb +82 -0
- data/lib/sq/refinement/changeset/file_modification.rb +144 -0
- data/lib/sq/refinement/changeset.rb +225 -0
- data/lib/sq/refinement/cli.rb +121 -0
- data/lib/sq/refinement/cocoapods_post_install_writer.rb +129 -0
- data/lib/sq/refinement/setup.rb +22 -0
- data/lib/sq/refinement/used_path.rb +156 -0
- data/lib/sq/refinement/version.rb +8 -0
- metadata +15 -27
- data/lib/refinement/analyzer.rb +0 -395
- data/lib/refinement/annotated_target.rb +0 -78
- data/lib/refinement/changeset/file_modification.rb +0 -138
- data/lib/refinement/changeset.rb +0 -223
- data/lib/refinement/cli.rb +0 -119
- data/lib/refinement/cocoapods_post_install_writer.rb +0 -126
- data/lib/refinement/used_path.rb +0 -154
- data/lib/refinement/version.rb +0 -6
@@ -0,0 +1,144 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'xcodeproj'
|
4
|
+
|
5
|
+
module Sq
|
6
|
+
module Refinement
|
7
|
+
class Changeset
|
8
|
+
# Represents a modification to a single file or directory on disk
|
9
|
+
class FileModification
|
10
|
+
# @return [Symbol] the change type for directories
|
11
|
+
DIRECTORY_CHANGE_TYPE = :'had contents change'
|
12
|
+
|
13
|
+
# @return [Pathname] the path to the modified file
|
14
|
+
attr_reader :path
|
15
|
+
|
16
|
+
# @return [Pathname, Nil] the prior path to the modified file, or `nil` if it was not renamed or copied
|
17
|
+
attr_reader :prior_path
|
18
|
+
|
19
|
+
# @return [#to_s] the type of change that happened to this file
|
20
|
+
attr_reader :type
|
21
|
+
|
22
|
+
def initialize(path:, type:,
|
23
|
+
prior_path: nil,
|
24
|
+
contents_reader: -> {},
|
25
|
+
prior_contents_reader: -> {})
|
26
|
+
@path = path
|
27
|
+
@type = type
|
28
|
+
@prior_path = prior_path
|
29
|
+
@contents_reader = contents_reader
|
30
|
+
@prior_contents_reader = prior_contents_reader
|
31
|
+
end
|
32
|
+
|
33
|
+
# @visibility private
|
34
|
+
def to_s
|
35
|
+
case type
|
36
|
+
when DIRECTORY_CHANGE_TYPE
|
37
|
+
"contents of dir `#{path}` changed"
|
38
|
+
else
|
39
|
+
message = "file `#{path}` #{type}"
|
40
|
+
message += " (from #{prior_path})" if prior_path
|
41
|
+
message
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# @visibility private
|
46
|
+
def inspect
|
47
|
+
"#<#{self.class} path=#{path.inspect} type=#{type.inspect} prior_path=#{prior_path.inspect} " \
|
48
|
+
"contents=#{contents.inspect} prior_contents=#{prior_contents.inspect}>"
|
49
|
+
end
|
50
|
+
|
51
|
+
# @visibility private
|
52
|
+
def hash
|
53
|
+
[path, type].hash
|
54
|
+
end
|
55
|
+
|
56
|
+
# @visibility private
|
57
|
+
def ==(other)
|
58
|
+
return unless other.is_a?(FileModification)
|
59
|
+
|
60
|
+
(path == other.path) && (type == other.type) && prior_path == other.prior_path
|
61
|
+
end
|
62
|
+
|
63
|
+
# @visibility private
|
64
|
+
def eql?(other)
|
65
|
+
return false unless other.is_a?(FileModification)
|
66
|
+
|
67
|
+
path.eql?(other.path) && type.eql?(other.type) && prior_path.eql?(other.prior_path)
|
68
|
+
end
|
69
|
+
|
70
|
+
# @return [String,Nil] a YAML string representing the diff of the file
|
71
|
+
# from the prior revision to the current revision at the given keypath
|
72
|
+
# in the YAML, or `nil` if there is no diff
|
73
|
+
# @param keypath [Array] a list of indices passed to `dig`.
|
74
|
+
# An empty array is equivalent to the entire YAML document
|
75
|
+
def yaml_diff(keypath)
|
76
|
+
require 'yaml'
|
77
|
+
|
78
|
+
@cached_yaml ||= {}
|
79
|
+
|
80
|
+
dig_yaml = lambda do |yaml, path|
|
81
|
+
return yaml if yaml == DOES_NOT_EXIST
|
82
|
+
|
83
|
+
object = @cached_yaml[path] ||= YAML.safe_load(yaml, permitted_classes: [Symbol], aliases: true)
|
84
|
+
if keypath.empty?
|
85
|
+
object
|
86
|
+
elsif object.respond_to?(:dig)
|
87
|
+
object.dig(*keypath)
|
88
|
+
else # backwards compatibility
|
89
|
+
keypath.reduce(object) do |acc, elem|
|
90
|
+
acc[elem]
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
prior = dig_yaml[prior_contents, :prior]
|
96
|
+
current = dig_yaml[contents, :current]
|
97
|
+
|
98
|
+
require 'xcodeproj/differ'
|
99
|
+
|
100
|
+
# rubocop:disable Naming/VariableNumber
|
101
|
+
return unless (diff = Xcodeproj::Differ.diff(
|
102
|
+
prior,
|
103
|
+
current,
|
104
|
+
key_1: 'prior_revision',
|
105
|
+
key_2: 'current_revision'
|
106
|
+
))
|
107
|
+
# rubocop:enable Naming/VariableNumber
|
108
|
+
|
109
|
+
diff.to_yaml.prepend("#{path} changed at keypath #{keypath.inspect}\n")
|
110
|
+
end
|
111
|
+
|
112
|
+
DOES_NOT_EXIST = Object.new.tap do |o|
|
113
|
+
class << o
|
114
|
+
def to_s
|
115
|
+
'DOES NOT EXISTS'
|
116
|
+
end
|
117
|
+
alias_method :inspect, :to_s
|
118
|
+
end
|
119
|
+
end.freeze
|
120
|
+
private_constant :DOES_NOT_EXIST
|
121
|
+
|
122
|
+
# @return [String] the current contents of the file
|
123
|
+
def contents
|
124
|
+
@contents ||=
|
125
|
+
begin
|
126
|
+
@contents_reader[].tap { @contents_reader = nil } || DOES_NOT_EXIST
|
127
|
+
rescue StandardError
|
128
|
+
DOES_NOT_EXIST
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
# @return [String] the prior contents of the file
|
133
|
+
def prior_contents
|
134
|
+
@prior_contents ||=
|
135
|
+
begin
|
136
|
+
@prior_contents_reader[].tap { @prior_contents_reader = nil } || DOES_NOT_EXIST
|
137
|
+
rescue StandardError
|
138
|
+
DOES_NOT_EXIST
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
@@ -0,0 +1,225 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'cocoapods/executable'
|
4
|
+
require 'set'
|
5
|
+
|
6
|
+
module Sq
|
7
|
+
module Refinement
|
8
|
+
# Represents a set of changes in a repository between a prior revision and the current state
|
9
|
+
class Changeset
|
10
|
+
# An error that happens when computing a git diff
|
11
|
+
class GitError < Error; end
|
12
|
+
require 'sq/refinement/changeset/file_modification'
|
13
|
+
|
14
|
+
# @return [Pathname] the path to the repository
|
15
|
+
attr_reader :repository
|
16
|
+
# @return [Array<FileModification>] the modifications in the changeset
|
17
|
+
attr_reader :modifications
|
18
|
+
# @return [Hash<Pathname,FileModification>] modifications keyed by relative path
|
19
|
+
attr_reader :modified_paths
|
20
|
+
# @return [Hash<Pathname,FileModification>] modifications keyed by relative path
|
21
|
+
attr_reader :modified_absolute_paths
|
22
|
+
# @return [String] a desciption of the changeset
|
23
|
+
attr_reader :description
|
24
|
+
|
25
|
+
private :modifications, :modified_paths, :modified_absolute_paths
|
26
|
+
|
27
|
+
def initialize(repository:, modifications:, description: nil)
|
28
|
+
@repository = repository
|
29
|
+
@modifications = self.class.add_directories(modifications).uniq.freeze
|
30
|
+
@description = description
|
31
|
+
|
32
|
+
@modified_paths = {}
|
33
|
+
@modifications
|
34
|
+
.each { |mod| @modified_paths[mod.path] = mod }
|
35
|
+
.each { |mod| @modified_paths[mod.prior_path] ||= mod if mod.prior_path }
|
36
|
+
@modified_paths.freeze
|
37
|
+
|
38
|
+
@modified_absolute_paths = {}
|
39
|
+
@modified_paths
|
40
|
+
.each { |path, mod| @modified_absolute_paths[path.expand_path(repository).freeze] = mod }
|
41
|
+
@modified_absolute_paths.freeze
|
42
|
+
end
|
43
|
+
|
44
|
+
# @visibility private
|
45
|
+
# @return [Array<FileModification>] file modifications that include modifications for each
|
46
|
+
# directory that has had a child modified
|
47
|
+
# @param modifications [Array<FileModification>] The modifications to add directory modifications to
|
48
|
+
def self.add_directories(modifications)
|
49
|
+
dirs = Set.new
|
50
|
+
add = lambda { |path|
|
51
|
+
break unless dirs.add?(path)
|
52
|
+
|
53
|
+
add[path.dirname]
|
54
|
+
}
|
55
|
+
modifications.each do |mod|
|
56
|
+
add[mod.path.dirname]
|
57
|
+
add[mod.prior_path.dirname] if mod.prior_path
|
58
|
+
end
|
59
|
+
modifications +
|
60
|
+
dirs.map { |d| FileModification.new(path: Pathname("#{d}/").freeze, type: FileModification::DIRECTORY_CHANGE_TYPE) }
|
61
|
+
end
|
62
|
+
|
63
|
+
# @return [FileModification,Nil] the changeset for the given absolute path,
|
64
|
+
# or `nil` if the given path is un-modified
|
65
|
+
# @param absolute_path [Pathname]
|
66
|
+
def find_modification_for_path(absolute_path:)
|
67
|
+
modified_absolute_paths[absolute_path]
|
68
|
+
end
|
69
|
+
|
70
|
+
# @return [Array<String>] An array of patterns converted from a
|
71
|
+
# {Dir.glob} pattern to patterns that {File.fnmatch} can handle.
|
72
|
+
# This is used by the {#relative_glob} method to emulate
|
73
|
+
# {Dir.glob}.
|
74
|
+
#
|
75
|
+
# The expansion provides support for:
|
76
|
+
#
|
77
|
+
# - Literals
|
78
|
+
#
|
79
|
+
# dir_glob_equivalent_patterns('{file1,file2}.{h,m}')
|
80
|
+
# => ["file1.h", "file1.m", "file2.h", "file2.m"]
|
81
|
+
#
|
82
|
+
# - Matching the direct children of a directory with `**`
|
83
|
+
#
|
84
|
+
# dir_glob_equivalent_patterns('Classes/**/file.m')
|
85
|
+
# => ["Classes/**/file.m", "Classes/file.m"]
|
86
|
+
#
|
87
|
+
# @param [String] pattern A {Dir#glob} like pattern.
|
88
|
+
#
|
89
|
+
def dir_glob_equivalent_patterns(pattern)
|
90
|
+
pattern = pattern.gsub('/**/', '{/**/,/}')
|
91
|
+
values_by_set = {}
|
92
|
+
pattern.scan(/\{[^}]*\}/) do |set|
|
93
|
+
values = set.gsub(/[{}]/, '').split(',', -1)
|
94
|
+
values_by_set[set] = values
|
95
|
+
end
|
96
|
+
|
97
|
+
if values_by_set.empty?
|
98
|
+
[pattern]
|
99
|
+
else
|
100
|
+
patterns = [pattern]
|
101
|
+
values_by_set.each do |set, values|
|
102
|
+
patterns = patterns.flat_map do |old_pattern|
|
103
|
+
values.map do |value|
|
104
|
+
old_pattern.gsub(set, value)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
patterns
|
109
|
+
end
|
110
|
+
end
|
111
|
+
private :dir_glob_equivalent_patterns
|
112
|
+
|
113
|
+
# @return [FileModification,Nil] the modification for the given absolute glob,
|
114
|
+
# or `nil` if no files matching the glob were modified
|
115
|
+
# @note Will only return a single (arbitrary) matching modification, even if there are
|
116
|
+
# multiple modifications that match the glob
|
117
|
+
# @param absolute_glob [String] a glob pattern for absolute paths, suitable for an invocation of `Dir.glob`
|
118
|
+
def find_modification_for_glob(absolute_glob:)
|
119
|
+
absolute_globs = dir_glob_equivalent_patterns(absolute_glob)
|
120
|
+
_path, modification = modified_absolute_paths.find do |absolute_path, _modification|
|
121
|
+
absolute_globs.any? do |glob|
|
122
|
+
File.fnmatch?(glob, absolute_path, File::FNM_CASEFOLD | File::FNM_PATHNAME)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
modification
|
126
|
+
end
|
127
|
+
|
128
|
+
# @return [FileModification,Nil] a modification and yaml diff for the keypath at the given absolute path,
|
129
|
+
# or `nil` if the value at the given keypath is un-modified
|
130
|
+
# @param absolute_path [Pathname]
|
131
|
+
# @param keypath [Array]
|
132
|
+
def find_modification_for_yaml_keypath(absolute_path:, keypath:)
|
133
|
+
return unless (file_modification = find_modification_for_path(absolute_path:))
|
134
|
+
|
135
|
+
diff = file_modification.yaml_diff(keypath)
|
136
|
+
return unless diff
|
137
|
+
|
138
|
+
[file_modification, diff]
|
139
|
+
end
|
140
|
+
|
141
|
+
# @return [Changeset] the changes in the given git repository between the given revision and HEAD
|
142
|
+
# @param repository [Pathname]
|
143
|
+
# @param base_revision [String]
|
144
|
+
def self.from_git(repository:, base_revision:)
|
145
|
+
raise ArgumentError, "must be given a Pathname for repository, got #{repository.inspect}" unless repository.is_a?(Pathname)
|
146
|
+
raise ArgumentError, "must be given a String for base_revision, got #{base_revision.inspect}" unless base_revision.is_a?(String)
|
147
|
+
|
148
|
+
merge_base = git!('merge-base', base_revision, 'HEAD', chdir: repository).strip
|
149
|
+
diff = git!('diff', '--raw', '-z', merge_base, chdir: repository)
|
150
|
+
modifications = parse_raw_diff(diff, repository:, base_revision: merge_base).freeze
|
151
|
+
|
152
|
+
new(repository:, modifications:, description: "since #{base_revision}")
|
153
|
+
end
|
154
|
+
|
155
|
+
CHANGE_TYPES = {
|
156
|
+
'was added': 'A',
|
157
|
+
'was copied': 'C',
|
158
|
+
'was deleted': 'D',
|
159
|
+
'was modified': 'M',
|
160
|
+
'was renamed': 'R',
|
161
|
+
'changed type': 'T',
|
162
|
+
'is unmerged': 'U',
|
163
|
+
'changed in an unknown way': 'X'
|
164
|
+
}.freeze
|
165
|
+
private_constant :CHANGE_TYPES
|
166
|
+
|
167
|
+
CHANGE_CHARACTERS = CHANGE_TYPES.invert.freeze
|
168
|
+
private_constant :CHANGE_CHARACTERS
|
169
|
+
|
170
|
+
# Parses the raw diff into FileModification objects
|
171
|
+
# @return [Array<FileModification>]
|
172
|
+
# @param diff [String] a diff generated by `git diff --raw -z`
|
173
|
+
# @param repository [Pathname] the path to the repository
|
174
|
+
# @param base_revision [String] the base revision the diff was constructed agains
|
175
|
+
def self.parse_raw_diff(diff, repository:, base_revision:)
|
176
|
+
# since we're null separating the chunks (to avoid dealing with path escaping) we have to reconstruct
|
177
|
+
# the chunks into individual diff entries. entries always start with a colon so we can use that to signal if
|
178
|
+
# we're on a new entry
|
179
|
+
parsed_lines = diff.split("\0").each_with_object([]) do |chunk, lines|
|
180
|
+
lines << [] if chunk.start_with?(':')
|
181
|
+
lines.last << chunk
|
182
|
+
end
|
183
|
+
|
184
|
+
parsed_lines.map do |split_line|
|
185
|
+
# change chunk (letter + optional similarity percentage) will always be the last part of first line chunk
|
186
|
+
change_chunk = split_line[0].split(/\s/).last
|
187
|
+
|
188
|
+
new_path = Pathname(split_line[2]).freeze if split_line[2]
|
189
|
+
old_path = Pathname(split_line[1]).freeze
|
190
|
+
prior_path = old_path if new_path
|
191
|
+
# new path if one exists, else existing path. new path only exists for rename and copy
|
192
|
+
changed_path = new_path || old_path
|
193
|
+
|
194
|
+
change_character = change_chunk[0]
|
195
|
+
# returns 0 when a similarity percentage isn't specified by git.
|
196
|
+
_similarity = change_chunk[1..3].to_i
|
197
|
+
|
198
|
+
FileModification.new(
|
199
|
+
path: changed_path,
|
200
|
+
type: CHANGE_CHARACTERS[change_character],
|
201
|
+
prior_path:,
|
202
|
+
contents_reader: -> { repository.join(changed_path).read },
|
203
|
+
prior_contents_reader: lambda {
|
204
|
+
git!('show', "#{base_revision}:#{prior_path || changed_path}", chdir: repository)
|
205
|
+
}
|
206
|
+
)
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
# @return [String] the STDOUT of the git command
|
211
|
+
# @raise [GitError] when running the git command fails
|
212
|
+
# @param command [String] the base git command to run
|
213
|
+
# @param args [String...] arguments to the git command
|
214
|
+
# @param chdir [String,Pathname] the directory to run the git command in
|
215
|
+
def self.git!(command, *args, chdir:)
|
216
|
+
require 'open3'
|
217
|
+
out, err, status = Open3.capture3('git', command, *args, chdir: chdir.to_s)
|
218
|
+
raise GitError, "Running git #{command} failed (#{status.to_s.gsub(/pid \d+\s*/, '')}):\n\n#{err}" unless status.success?
|
219
|
+
|
220
|
+
out
|
221
|
+
end
|
222
|
+
private_class_method :git!
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'claide'
|
4
|
+
|
5
|
+
module Sq
|
6
|
+
module Refinement
|
7
|
+
# @visibility private
|
8
|
+
class CLI < CLAide::Command
|
9
|
+
self.abstract_command = true
|
10
|
+
self.command = 'refine'
|
11
|
+
self.version = VERSION
|
12
|
+
|
13
|
+
self.summary = 'Generates a list of Xcode targets to build & test as a result of a diff'
|
14
|
+
|
15
|
+
def self.options
|
16
|
+
super + [
|
17
|
+
['--repository=REPOSITORY', 'Path to repository'],
|
18
|
+
['--workspace=WORKSPACE_PATH', 'Path to project or workspace'],
|
19
|
+
['--scheme=SCHEME_PATH', 'Path to scheme to be filtered'],
|
20
|
+
['--augmenting-paths-yaml-files=PATH1,PATH2...', 'Paths to augmenting yaml files, relative to the repository path'],
|
21
|
+
['--[no-]print-changes', 'Print the change reason for changed targets'],
|
22
|
+
['--[no-]print-scheme-changes', 'Print the change reason for targets in the given scheme'],
|
23
|
+
['--change-level=LEVEL', 'Change level at which a target must have changed in order to be considered changed. ' \
|
24
|
+
'One of `full-transitive`, `itself`, or an integer'],
|
25
|
+
['--filter-scheme-for-build-action=BUILD_ACTION', 'The xcodebuild action the scheme (if given) is filtered for. ' \
|
26
|
+
'One of `building` or `testing`.']
|
27
|
+
]
|
28
|
+
end
|
29
|
+
|
30
|
+
def initialize(argv)
|
31
|
+
@repository = argv.option('repository', '.')
|
32
|
+
@workspace = argv.option('workspace')
|
33
|
+
@scheme = argv.option('scheme')
|
34
|
+
@augmenting_paths_yaml_files = argv.option('augmenting-paths-yaml-files', '')
|
35
|
+
@print_changes = argv.flag?('print-changes', false)
|
36
|
+
@print_scheme_changes = argv.flag?('print-scheme-changes', false)
|
37
|
+
@change_level = argv.option('change-level', 'full-transitive')
|
38
|
+
@filter_scheme_for_build_action = argv.option('filter-scheme-for-build-action', 'testing').to_sym
|
39
|
+
|
40
|
+
super
|
41
|
+
end
|
42
|
+
|
43
|
+
def run
|
44
|
+
changeset = compute_changeset
|
45
|
+
|
46
|
+
analyzer = Sq::Refinement::Analyzer.new(changeset:,
|
47
|
+
workspace_path: @workspace,
|
48
|
+
augmenting_paths_yaml_files: @augmenting_paths_yaml_files)
|
49
|
+
analyzer.annotate_targets!
|
50
|
+
|
51
|
+
puts analyzer.format_changes if @print_changes
|
52
|
+
|
53
|
+
return unless @scheme
|
54
|
+
|
55
|
+
analyzer.filtered_scheme(scheme_path: @scheme, log_changes: @print_scheme_changes, filter_scheme_for_build_action: @filter_scheme_for_build_action)
|
56
|
+
.save_as(@scheme.gsub(%r{\.(xcodeproj|xcworkspace)/.+}, '.\1'), File.basename(@scheme, '.xcscheme'), true)
|
57
|
+
end
|
58
|
+
|
59
|
+
def validate!
|
60
|
+
super
|
61
|
+
|
62
|
+
File.directory?(@repository) || help!("Unable to find a repository at #{@repository.inspect}")
|
63
|
+
|
64
|
+
@workspace || help!('Must specify a project or workspace path')
|
65
|
+
File.directory?(@workspace) || help!("Unable to find a project or workspace at #{@workspace.inspect}")
|
66
|
+
|
67
|
+
@augmenting_paths_yaml_files = @augmenting_paths_yaml_files.split(',')
|
68
|
+
@augmenting_paths_yaml_files.each do |yaml_path|
|
69
|
+
yaml_path = File.join(@repository, yaml_path)
|
70
|
+
File.file?(yaml_path) || help!("Unable to find a YAML file at #{yaml_path.inspect}")
|
71
|
+
|
72
|
+
require 'yaml'
|
73
|
+
begin
|
74
|
+
YAML.safe_load_file(yaml_path)
|
75
|
+
rescue StandardError => e
|
76
|
+
help! "Failed to load YAML file at #{yaml_path.inspect} (#{e})"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
File.file?(@scheme) || help!("Unabled to find a scheme at #{@scheme.inspect}") if @scheme
|
81
|
+
|
82
|
+
@change_level =
|
83
|
+
case @change_level
|
84
|
+
when 'full-transitive' then :full_transitive
|
85
|
+
when 'itself' then :itself
|
86
|
+
when /\A\d+\z/ then [:at_most_n_away, @change_level.to_i]
|
87
|
+
else help! "Unknown change level #{@change_level.inspect}"
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# @visibility private
|
92
|
+
class Git < CLI
|
93
|
+
self.summary = 'Generates a list of Xcode targets to build & test as a result of a git diff'
|
94
|
+
|
95
|
+
def self.options
|
96
|
+
super + [
|
97
|
+
['--base-revision=SHA', 'Base revision to compute the git diff against']
|
98
|
+
]
|
99
|
+
end
|
100
|
+
|
101
|
+
def initialize(argv)
|
102
|
+
@base_revision = argv.option('base-revision')
|
103
|
+
|
104
|
+
super
|
105
|
+
end
|
106
|
+
|
107
|
+
def validate!
|
108
|
+
super
|
109
|
+
|
110
|
+
@base_revision || help!('Must specify a base revision')
|
111
|
+
end
|
112
|
+
|
113
|
+
private
|
114
|
+
|
115
|
+
def compute_changeset
|
116
|
+
Sq::Refinement::Changeset.from_git(repository: Pathname(@repository), base_revision: @base_revision)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
@@ -0,0 +1,129 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module Sq
|
6
|
+
module Refinement
|
7
|
+
# Called after CocoaPods installation to write an augmenting file that
|
8
|
+
# takes into account changes to Pod configuration,
|
9
|
+
# as well as the globs used by podspecs to search for files
|
10
|
+
class CocoaPodsPostInstallWriter
|
11
|
+
attr_reader :aggregate_targets, :pod_targets, :config, :repo, :options
|
12
|
+
private :aggregate_targets, :pod_targets, :config, :repo, :options
|
13
|
+
|
14
|
+
# Initializes a post-install writer with CocoaPods target objects.
|
15
|
+
# @return [CocoaPodsPostInstallWriter] a new instance of CocoaPodsPostInstallWriter
|
16
|
+
# @param aggregate_targets [Array<Pod::AggregateTarget>]
|
17
|
+
# @param pod_targets [Array<Pod::PodTarget>]
|
18
|
+
# @param config [Pod::Config]
|
19
|
+
# @param options [Hash]
|
20
|
+
def initialize(aggregate_targets, pod_targets, config, options)
|
21
|
+
@aggregate_targets = aggregate_targets
|
22
|
+
@pod_targets = pod_targets
|
23
|
+
@config = config
|
24
|
+
@repo = config.installation_root
|
25
|
+
@options = options || {}
|
26
|
+
end
|
27
|
+
|
28
|
+
# Writes the refinement augmenting file to the configured path
|
29
|
+
# @return [Void]
|
30
|
+
def write!
|
31
|
+
write_file options.fetch('output_path', config.sandbox.root.join('pods_refinement.json')), paths_by_target_name
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def write_file(path, hash)
|
37
|
+
File.open(path, 'w') do |f|
|
38
|
+
f << JSON.generate(hash) << "\n"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def paths_by_target_name
|
43
|
+
targets = {}
|
44
|
+
aggregate_targets.each do |aggregate_target|
|
45
|
+
targets[aggregate_target.label] = paths_for_aggregate_target(aggregate_target)
|
46
|
+
end
|
47
|
+
pod_targets.each do |pod_target|
|
48
|
+
targets.merge! paths_for_pod_targets(pod_target)
|
49
|
+
end
|
50
|
+
targets
|
51
|
+
end
|
52
|
+
|
53
|
+
def paths_for_aggregate_target(aggregate_target)
|
54
|
+
paths = []
|
55
|
+
if (podfile_path = aggregate_target.podfile.defined_in_file)
|
56
|
+
paths << { path: podfile_path.relative_path_from(repo), inclusion_reason: 'Podfile' }
|
57
|
+
end
|
58
|
+
if (user_project_path = aggregate_target.user_project_path)
|
59
|
+
paths << { path: user_project_path.relative_path_from(repo), inclusion_reason: 'user project' }
|
60
|
+
end
|
61
|
+
paths
|
62
|
+
end
|
63
|
+
|
64
|
+
def library_specification?(spec)
|
65
|
+
# Backwards compatibility
|
66
|
+
if spec.respond_to?(:library_specification?, false)
|
67
|
+
spec.library_specification?
|
68
|
+
else
|
69
|
+
!spec.test_specification?
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def specification_paths_from_pod_target(pod_target)
|
74
|
+
pod_target
|
75
|
+
.target_definitions
|
76
|
+
.map(&:podfile)
|
77
|
+
.uniq
|
78
|
+
.flat_map do |podfile|
|
79
|
+
podfile
|
80
|
+
.dependencies
|
81
|
+
.select { |d| d.root_name == pod_target.pod_name }
|
82
|
+
.map { |d| (d.external_source || {})[:path] }
|
83
|
+
.compact
|
84
|
+
end.uniq
|
85
|
+
end
|
86
|
+
|
87
|
+
def paths_for_pod_targets(pod_target)
|
88
|
+
file_accessors_by_target_name = pod_target.file_accessors.group_by do |fa|
|
89
|
+
if library_specification?(fa.spec)
|
90
|
+
pod_target.label
|
91
|
+
elsif pod_target.respond_to?(:non_library_spec_label)
|
92
|
+
pod_target.non_library_spec_label(fa.spec)
|
93
|
+
else
|
94
|
+
pod_target.test_target_label(fa.spec)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
pod_dir = pod_target.sandbox.pod_dir(pod_target.pod_name).relative_path_from(repo)
|
99
|
+
|
100
|
+
spec_paths = specification_paths_from_pod_target(pod_target)
|
101
|
+
|
102
|
+
file_accessors_by_target_name.each_with_object({}) do |(label, file_accessors), h|
|
103
|
+
paths = [
|
104
|
+
{ path: 'Podfile.lock',
|
105
|
+
inclusion_reason: 'CocoaPods lockfile',
|
106
|
+
yaml_keypath: ['SPEC CHECKSUMS', pod_target.pod_name] }
|
107
|
+
]
|
108
|
+
if pod_target.sandbox.predownloaded?(pod_target.pod_name)
|
109
|
+
paths << { path: 'Podfile.lock', inclusion_reason: 'Dependency external source', yaml_keypath: ['EXTERNAL SOURCES', pod_target.pod_name] }
|
110
|
+
paths << { path: 'Podfile.lock', inclusion_reason: 'Pod checkout options', yaml_keypath: ['CHECKOUT OPTIONS', pod_target.pod_name] }
|
111
|
+
end
|
112
|
+
spec_paths.each { |path| paths << { path:, inclusion_reason: 'podspec' } }
|
113
|
+
|
114
|
+
Pod::Validator::FILE_PATTERNS.each do |pattern|
|
115
|
+
file_accessors.each do |fa|
|
116
|
+
globs = fa.spec_consumer.send(pattern)
|
117
|
+
globs = globs.values.flatten if globs.is_a?(Hash)
|
118
|
+
globs.each do |glob|
|
119
|
+
paths << { glob: pod_dir.join(glob), inclusion_reason: pattern.to_s.tr('_', ' ').chomp('s') }
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
h[label] = paths.uniq
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Sq
|
4
|
+
# Generates a list of Xcode targets to build & test as a result of a git diff.
|
5
|
+
module Refinement
|
6
|
+
class Error < StandardError; end
|
7
|
+
|
8
|
+
# @visibility private
|
9
|
+
# @param enum [Enumerable]
|
10
|
+
# Enumerates through `enum`, and applied the given block to each element.
|
11
|
+
# If the result of calling the block is truthy, the first such result is returned.
|
12
|
+
# If no such result is found, `nil` is returned.
|
13
|
+
def self.map_find(enum)
|
14
|
+
enum.each do |elem|
|
15
|
+
transformed = yield elem
|
16
|
+
return transformed if transformed
|
17
|
+
end
|
18
|
+
|
19
|
+
nil
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|