refinement 0.6.1 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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