refinement 0.0.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eccee9a8f8fbbb164e975298ee97e5072b62b0404030ffec5f42438228c9a397
4
- data.tar.gz: 174eb0ae0854f951902364a437e2ba2c1681c3607d09398156911cc53880d31c
3
+ metadata.gz: 695104530dba26775514c23cdf0e7802eb5cc2d83c421855c1a063b064a86a2e
4
+ data.tar.gz: 46d8e6cdb5774196354a4e13ca91f2bc5db969f572116856db0bf8498102a245
5
5
  SHA512:
6
- metadata.gz: ec616c4a30c44be7e927dd5fe289ee1ec8ebb412e26ca3af37e168f0f2c0a48d3d042a672c897b377c97ef0932885210173f66ffc52005c7f6f30f408e8868ba
7
- data.tar.gz: 20a704ef85107e0417bbaa0e163d6a65061ff520ca9ba7872112553807705a84aadd5d3fd83374ed939ae5bd6c5d533bfd3287a66e76b74637db5a8d3e95fd41
6
+ metadata.gz: 8e5c9694afb88668f2224a50863f37a2624dbcc0a7cdc5e32e4ad4e91f718a66658475a5b4fa3bbb33f7601dc63f5950982794a51d94e2654d90f0655b474b97
7
+ data.tar.gz: c2a655bf067bedd777d7dc2a5b2a0a4beede3250fb8708219884e63bfff4e66f7dd260ba7e4b6a47ba1a9538f2d819e4b4e763bef7084a12386b77e4a5a1fbc0
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Refinement Changes
2
+
3
+ ## 0.1.0 (2019-02-19)
4
+
5
+ ##### Enhancements
6
+
7
+ * Initial implementation.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.0.1
1
+ 0.1.0
data/exe/refine ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'refinement'
4
+ require 'refinement/cli'
5
+
6
+ Refinement::CLI.run(ARGV)
@@ -0,0 +1,19 @@
1
+ require 'cocoapods'
2
+
3
+ Pod::Installer
4
+ .prepend(Module.new do
5
+ def perform_post_install_actions
6
+ super
7
+
8
+ return unless plugins.key?('refinement')
9
+
10
+ unless Gem::Version.create(Pod::VERSION) >= Gem::Version.create('1.6.0')
11
+ raise Pod::Informative, 'Refinement requires a CocoaPods version >= 1.6.0'
12
+ end
13
+
14
+ require 'refinement/cocoapods_post_install_writer'
15
+ Pod::UI.message 'Writing refinement file' do
16
+ Refinement::CocoaPodsPostInstallWriter.new(aggregate_targets, config, plugins['refinement']).write!
17
+ end
18
+ end
19
+ end)
@@ -0,0 +1,348 @@
1
+ module Refinement
2
+ # Analyzes changes in a repository
3
+ # and determines how those changes impact the targets in Xcode projects in the workspace.
4
+ class Analyzer
5
+ attr_reader :changeset, :workspace_path, :augmenting_paths_yaml_files
6
+ private :changeset, :workspace_path, :augmenting_paths_yaml_files
7
+
8
+ # Initializes an analyzer with a changeset, projects, and augmenting paths.
9
+ # @param changeset [Changeset]
10
+ # @param workspace_path [Pathname] path to a root workspace or project,
11
+ # must be `nil` if `projects` are specified explicitly
12
+ # @param projects [Array<Xcodeproj::Project>] projects to find targets in,
13
+ # must not be specified if `workspace_path` is not `nil`
14
+ # @param augmenting_paths_yaml_files [Array<Pathname>] paths to YAML files that provide augmenting paths by target,
15
+ # must be `nil` if `augmenting_paths_by_target` are specified explicitly
16
+ # @param augmenting_paths_by_target [Hash<String, Array>] arrays of hashes keyed by target name
17
+ # (or '*' for all targets)
18
+ # describing paths or globs that each target should be considered to be using,
19
+ # must not be specified if `augmenting_paths_yaml_files` is not `nil`
20
+ #
21
+ # @raise [ArgumentError] when conflicting arguments are given
22
+ #
23
+ def initialize(changeset:, workspace_path:, projects: nil,
24
+ augmenting_paths_yaml_files:, augmenting_paths_by_target: nil)
25
+
26
+ @changeset = changeset
27
+
28
+ raise ArgumentError, 'Can only specify one of workspace_path and projects' if workspace_path && projects
29
+ @workspace_path = workspace_path
30
+ @projects = projects
31
+
32
+ if augmenting_paths_yaml_files && augmenting_paths_by_target
33
+ raise ArgumentError, 'Can only specify one of augmenting_paths_yaml_files and augmenting_paths_by_target'
34
+ end
35
+ @augmenting_paths_yaml_files = augmenting_paths_yaml_files
36
+ @augmenting_paths_by_target = augmenting_paths_by_target
37
+ end
38
+
39
+ # @return [Array<AnnotatedTarget>] targets from the projects annotated with their changes, based upon
40
+ # the changeset
41
+ def annotate_targets!
42
+ @annotate_targets ||= annotated_targets
43
+ end
44
+
45
+ # @param scheme_path [Pathname] the absolute path to the scheme to be filtered
46
+ # @param change_level [Symbol] the change level at which a target must have changed in order
47
+ # to remain in the scheme. defaults to `:full_transitive`
48
+ # @param filter_when_scheme_has_changed [Boolean] whether the scheme should be filtered
49
+ # even when the changeset includes the scheme's path as changed.
50
+ # Defaults to `false`
51
+ # @param log_changes [Boolean] whether modifications to the scheme are logged.
52
+ # Defaults to `false`
53
+ # @return [Xcodeproj::XCScheme] a scheme whose unchanged targets have been removed
54
+ def filtered_scheme(scheme_path:, change_level: :full_transitive, filter_when_scheme_has_changed: false, log_changes: false)
55
+ scheme = Xcodeproj::XCScheme.new(scheme_path)
56
+
57
+ if filter_when_scheme_has_changed ||
58
+ !UsedPath.new(path: Pathname(scheme_path), inclusion_reason: 'scheme').find_in_changeset(changeset)
59
+
60
+ changes_by_suite_name = Hash[annotate_targets!
61
+ .map { |at| [at.xcode_target.name, at.change_reason(level: change_level)] }]
62
+
63
+ doc = scheme.doc
64
+
65
+ xpaths = %w[
66
+ //*/TestableReference/BuildableReference
67
+ //*/BuildActionEntry/BuildableReference
68
+ ]
69
+ xpaths.each do |xpath|
70
+ doc.get_elements(xpath).to_a.each do |buildable_reference|
71
+ suite_name = buildable_reference.attributes['BlueprintName']
72
+ if (change_reason = changes_by_suite_name[suite_name])
73
+ puts "#{suite_name} changed because #{change_reason}" if log_changes
74
+ next
75
+ end
76
+ puts "#{suite_name} did not change, removing from scheme" if log_changes
77
+ buildable_reference.parent.remove
78
+ end
79
+ end
80
+ end
81
+
82
+ scheme
83
+ end
84
+
85
+ # @return [String] a string suitable for user display that explains target changes
86
+ # @param include_unchanged_targets [Boolean] whether targets that have not changed should also be displayed
87
+ # @param change_level [Symbol] the change level used for computing whether a target has changed
88
+ def format_changes(include_unchanged_targets: false, change_level: :full_transitive)
89
+ annotate_targets!.group_by { |target| target.xcode_target.project.path.to_s }.sort_by(&:first)
90
+ .map do |project, annotated_targets|
91
+ changes = annotated_targets.sort_by { |annotated_target| annotated_target.xcode_target.name }
92
+ .map do |annotated_target|
93
+ change_reason = annotated_target.change_reason(level: change_level)
94
+ next if !include_unchanged_targets && !change_reason
95
+ change_reason ||= 'did not change'
96
+ "\t#{annotated_target.xcode_target}: #{change_reason}"
97
+ end.compact
98
+ "#{project}:\n#{changes.join("\n")}" unless changes.empty?
99
+ end.compact.join("\n")
100
+ end
101
+
102
+ private
103
+
104
+ # @return [Array<Xcodeproj::Project>]
105
+ def projects
106
+ @projects ||= find_projects(workspace_path)
107
+ end
108
+
109
+ # @return [Hash<String,Array<Hash>>]
110
+ def augmenting_paths_by_target
111
+ @augmenting_paths_by_target ||= begin
112
+ require 'yaml'
113
+ augmenting_paths_yaml_files.reduce({}) do |augmenting_paths_by_target, yaml_file|
114
+ yaml_file = Pathname(yaml_file).expand_path(changeset.repository)
115
+ yaml = YAML.safe_load(yaml_file.read)
116
+ augmenting_paths_by_target.merge(yaml) do |_target_name, prior_paths, new_paths|
117
+ prior_paths + new_paths
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ # @return [Array<AnnotatedTarget>] targets in the given list of Xcode projects,
124
+ # annotated according to the given changeset
125
+ def annotated_targets
126
+ workspace_modification = find_workspace_modification_in_changeset
127
+ project_changes = Hash[projects.map do |project|
128
+ [project, find_project_modification_in_changeset(project: project) || workspace_modification]
129
+ end]
130
+
131
+ require 'tsort'
132
+ targets = projects.flat_map(&:targets)
133
+ targets_by_uuid = Hash[targets.map { |t| [t.uuid, t] }]
134
+ targets_by_name = Hash[targets.map { |t| [t.name, t] }]
135
+ targets_by_product_name = Hash[targets.map do |t|
136
+ next unless t.respond_to?(:product_reference)
137
+ [File.basename(t.product_reference.path), t]
138
+ end.compact]
139
+
140
+ find_dep = ->(td) { targets_by_uuid[td.native_target_uuid] || targets_by_name[td.name] }
141
+ target_deps = lambda do |target|
142
+ target_dependencies = []
143
+ target.dependencies.each do |td|
144
+ target_dependencies << find_dep[td]
145
+ end
146
+
147
+ # TODO: also resolve OTHER_LDFLAGS?
148
+ # yay auto-linking
149
+ if (phase = target.frameworks_build_phases)
150
+ phase.files_references.each do |fr|
151
+ if (dt = fr && fr.path && targets_by_product_name[File.basename(fr.path)])
152
+ target_dependencies << dt
153
+ end
154
+ end
155
+ end
156
+
157
+ target_dependencies
158
+ end
159
+
160
+ targets = TSort.tsort(
161
+ ->(&b) { targets.each(&b) },
162
+ ->(target, &b) { target_deps[target].each(&b) }
163
+ )
164
+
165
+ targets.each_with_object({}) do |target, h|
166
+ change_reason = project_changes[target.project] || find_target_modification_in_changeset(target: target)
167
+
168
+ h[target] = AnnotatedTarget.new(
169
+ target: target,
170
+ dependencies: target_deps[target].map { |td| h.fetch(td) },
171
+ change_reason: change_reason
172
+ )
173
+ end.values
174
+ end
175
+
176
+ # @return [Array<Xcodeproj::Project>] the projects found by walking the
177
+ # project/workspace at the given path
178
+ # @param path [Pathname] path to a `.xcodeproj` or `.xcworkspace` on disk
179
+ def find_projects(path)
180
+ seen = {}
181
+ find_projects_cached = lambda do |project_path|
182
+ return if seen.key?(project_path)
183
+
184
+ case File.extname(project_path)
185
+ when '.xcodeproj'
186
+ project = Xcodeproj::Project.open(project_path)
187
+ seen[project_path] = project
188
+ project.files.each do |file_reference|
189
+ next unless File.extname(file_reference.path) == '.xcodeproj'
190
+
191
+ find_projects_cached[file_reference.real_path]
192
+ end
193
+ when '.xcworkspace'
194
+ workspace = Xcodeproj::Workspace.new_from_xcworkspace(project_path)
195
+ workspace.file_references.each do |file_reference|
196
+ next unless File.extname(file_reference.path) == '.xcodeproj'
197
+
198
+ find_projects_cached[file_reference.absolute_path(File.dirname(project_path))]
199
+ end
200
+ else
201
+ raise ArgumentError, "Unknown path #{project_path.inspect}"
202
+ end
203
+ end
204
+ find_projects_cached[path]
205
+
206
+ seen.values
207
+ end
208
+
209
+ # @yieldparam used_path [UsedPath] an absolute path that belongs to the given target
210
+ # @return [Void]
211
+ # @param target [Xcodeproj::Project::AbstractTarget]
212
+ def target_each_file_path(target:)
213
+ return enum_for(__method__, target: target) unless block_given?
214
+
215
+ expand_build_settings = lambda do |s|
216
+ return [s] unless s =~ /\$(?:\{([_a-zA-Z0-0]+?)\}|\(([_a-zA-Z0-0]+?)\))/
217
+ match, key = Regexp.last_match.values_at(0, 1, 2).compact
218
+ substitutions = target.resolved_build_setting(key, true).values.compact.uniq
219
+ substitutions.flat_map do |sub|
220
+ expand_build_settings[s.gsub(match, sub)]
221
+ end
222
+ end
223
+
224
+ target.build_configuration_list.build_configurations.each do |build_configuration|
225
+ ref = build_configuration.base_configuration_reference
226
+ next unless ref
227
+ yield UsedPath.new(path: ref.real_path,
228
+ inclusion_reason: "base configuration reference for #{build_configuration}")
229
+ end
230
+
231
+ target.build_phases.each do |build_phase|
232
+ build_phase.files_references.each do |fr|
233
+ next unless fr
234
+ yield UsedPath.new(path: fr.real_path,
235
+ inclusion_reason: "#{build_phase.display_name.downcase.chomp('s')} file")
236
+ end
237
+ end
238
+
239
+ target.shell_script_build_phases.each do |shell_script_build_phase|
240
+ %w[input_file_list_paths output_file_list_paths input_paths output_paths].each do |method|
241
+ next unless (paths = shell_script_build_phase.public_send(method))
242
+ file_type = method.tr('_', ' ').chomp('s')
243
+ paths.each do |config_path|
244
+ next unless config_path
245
+ expand_build_settings[config_path].each do |path|
246
+ path = Pathname(path).expand_path(target.project.project_dir)
247
+ yield UsedPath.new(path: path,
248
+ inclusion_reason: "#{shell_script_build_phase.name} build phase #{file_type}")
249
+ end
250
+ end
251
+ end
252
+ end
253
+
254
+ %w[INFOPLIST_FILE HEADER_SEARCH_PATHS FRAMEWORK_SEARCH_PATHS USER_HEADER_SEARCH_PATHS].each do |build_setting|
255
+ target.resolved_build_setting(build_setting, true).each_value do |paths|
256
+ Array(paths).each do |path|
257
+ next unless path
258
+ path = Pathname(path).expand_path(target.project.project_dir)
259
+ yield UsedPath.new(path: path, inclusion_reason: "#{build_setting} value")
260
+ end
261
+ end
262
+ end
263
+ end
264
+
265
+ # @return [FileModification,Nil] a modification to a file that is used by the given target, or `nil`
266
+ # if none if found
267
+ # @param target [Xcodeproj::Project::AbstractTarget]
268
+ def find_target_modification_in_changeset(target:)
269
+ augmenting_paths = used_paths_from_augmenting_paths_by_target[target.name]
270
+ find_in_changeset = ->(path) { path.find_in_changeset(changeset) }
271
+ Refinement.map_find(augmenting_paths, &find_in_changeset) ||
272
+ Refinement.map_find(target_each_file_path(target: target), &find_in_changeset)
273
+ end
274
+
275
+ # @yieldparam used_path [UsedPath] an absolute path that belongs to the given project
276
+ # @return [Void]
277
+ # @param project [Xcodeproj::Project]
278
+ def project_each_file_path(project:)
279
+ return enum_for(__method__, project: project) unless block_given?
280
+
281
+ yield UsedPath.new(path: project.path, inclusion_reason: 'project directory')
282
+
283
+ project.root_object.build_configuration_list.build_configurations.each do |build_configuration|
284
+ ref = build_configuration.base_configuration_reference
285
+ next unless ref
286
+ yield UsedPath.new(path: ref.real_path,
287
+ inclusion_reason: "base configuration reference for #{build_configuration}")
288
+ end
289
+ end
290
+
291
+ # # @return [FileModification,Nil] a modification to a file that is directly used by the given project, or `nil`
292
+ # if none if found
293
+ # @note This method does not take into account whatever file paths targets in the project may reference
294
+ # @param project [Xcodeproj::Project]
295
+ def find_project_modification_in_changeset(project:)
296
+ Refinement.map_find(project_each_file_path(project: project)) do |path|
297
+ path.find_in_changeset(changeset)
298
+ end
299
+ end
300
+
301
+ # @return [FileModification,Nil] a modification to the workspace itself, or `nil`
302
+ # if none if found
303
+ # @note This method does not take into account whatever file paths projects or
304
+ # targets in the workspace path may reference
305
+ def find_workspace_modification_in_changeset
306
+ return unless workspace_path
307
+
308
+ UsedPath.new(path: workspace_path, inclusion_reason: 'workspace directory')
309
+ .find_in_changeset(changeset)
310
+ end
311
+
312
+ # @return [Hash<String,UsedPath>]
313
+ def used_paths_from_augmenting_paths_by_target
314
+ @used_paths_from_augmenting_paths_by_target ||= begin
315
+ repo = changeset.repository
316
+ used_paths_from_augmenting_paths_by_target =
317
+ augmenting_paths_by_target.each_with_object({}) do |(name, augmenting_paths), h|
318
+ h[name] = augmenting_paths.map do |augmenting_path|
319
+ case augmenting_path.keys.sort
320
+ when %w[inclusion_reason path], %w[inclusion_reason path yaml_keypath]
321
+ kwargs = {
322
+ path: Pathname(augmenting_path['path']).expand_path(repo),
323
+ inclusion_reason: augmenting_path['inclusion_reason']
324
+ }
325
+ if augmenting_path.key?('yaml_keypath')
326
+ kwargs[:yaml_keypath] = augmenting_path['yaml_keypath']
327
+ UsedPath::YAML.new(**kwargs)
328
+ else
329
+ UsedPath.new(**kwargs)
330
+ end
331
+ when %w[glob inclusion_reason]
332
+ UsedGlob.new(glob: File.expand_path(augmenting_path['glob'], repo),
333
+ inclusion_reason: augmenting_path['inclusion_reason'])
334
+ else
335
+ raise ArgumentError,
336
+ "unhandled set of keys in augmenting paths dictionary entry: #{augmenting_path.keys.inspect}"
337
+ end
338
+ end
339
+ end
340
+ wildcard_paths = used_paths_from_augmenting_paths_by_target.fetch('*', [])
341
+
342
+ Hash.new do |h, k|
343
+ h[k] = wildcard_paths + used_paths_from_augmenting_paths_by_target.fetch(k, [])
344
+ end
345
+ end
346
+ end
347
+ end
348
+ end
@@ -0,0 +1,71 @@
1
+ module Refinement
2
+ # A target, annotated with any changes
3
+ class AnnotatedTarget
4
+ # @return [Xcodeproj::Project::AbstactTarget] the target in an Xcode project
5
+ attr_reader :xcode_target
6
+
7
+ # @return [String,Nil] the reason why the target has changed, or `nil` if it has not changed
8
+ attr_reader :direct_change_reason
9
+ private :direct_change_reason
10
+
11
+ def initialize(target:, change_reason:, dependencies: [])
12
+ @xcode_target = target
13
+ @direct_change_reason = change_reason
14
+ @dependencies = dependencies
15
+ dependencies.each do |dependency|
16
+ dependency.depended_upon_by << self
17
+ end
18
+ @depended_upon_by = []
19
+ end
20
+
21
+ # @visibility private
22
+ def to_s
23
+ xcode_target.to_s
24
+ end
25
+
26
+ CHANGE_LEVELS = %i[
27
+ itself
28
+ at_most_n_away
29
+ full_transitive
30
+ ].freeze
31
+ private_constant :CHANGE_LEVELS
32
+
33
+ # @return [Boolean] whether the target has changed, at the given change level
34
+ # @param level [Symbol,(:at_most_n_away,Integer)] change level, e.g. :itself, :at_most_n_away, :full_transitive
35
+ def change_reason(level:)
36
+ @change_reason ||= {}
37
+ # need to use this form for memoization, as opposed to ||=,
38
+ # since this will (often) be nil and it makes a significant performance difference
39
+ return @change_reason[level] if @change_reason.key?(level)
40
+
41
+ @change_reason[level] =
42
+ case level
43
+ when :itself
44
+ direct_change_reason
45
+ when :full_transitive
46
+ direct_change_reason || Refinement.map_find(dependencies) do |dependency|
47
+ next unless (dependency_change_reason = dependency.change_reason(level: level))
48
+ "dependency #{dependency} changed because #{dependency_change_reason}"
49
+ end
50
+ when proc { |symbol, int| (symbol == :at_most_n_away) && int.is_a?(Integer) }
51
+ distance_from_target = level.last
52
+ raise ArgumentError, "level must be positive, not #{distance_from_target}" if distance_from_target < 0
53
+ change_reason = direct_change_reason
54
+ if distance_from_target > 0
55
+ change_reason ||= Refinement.map_find(dependencies) do |dependency|
56
+ next unless (dependency_change_reason = dependency.change_reason(level: [:at_most_n_away, level.last.pred]))
57
+ "dependency #{dependency} changed because #{dependency_change_reason}"
58
+ end
59
+ end
60
+ change_reason
61
+ else
62
+ raise Error, "No known change level #{level.inspect}, only #{CHANGE_LEVELS.inspect} are known"
63
+ end
64
+ end
65
+
66
+ # @return [Array<AnnotatedTarget>] the list of annotated targets this target depends upon
67
+ attr_reader :dependencies
68
+ # @return [Array<AnnotatedTarget>] the list of annotated targets that depend upon this target
69
+ attr_reader :depended_upon_by
70
+ end
71
+ end
@@ -0,0 +1,131 @@
1
+ module Refinement
2
+ class Changeset
3
+ # Represents a modification to a single file or directory on disk
4
+ class FileModification
5
+ # @return [Symbol] the change type for directories
6
+ DIRECTORY_CHANGE_TYPE = :'had contents change'
7
+
8
+ # @return [Pathname] the path to the modified file
9
+ attr_reader :path
10
+
11
+ # @return [Pathname, Nil] the prior path to the modified file, or `nil` if it was not renamed or copied
12
+ attr_reader :prior_path
13
+
14
+ # @return [#to_s] the type of change that happened to this file
15
+ attr_reader :type
16
+
17
+ def initialize(path:, type:,
18
+ prior_path: nil,
19
+ contents_reader: -> { nil },
20
+ prior_contents_reader: -> { nil })
21
+ @path = path
22
+ @type = type
23
+ @prior_path = prior_path
24
+ @contents_reader = contents_reader
25
+ @prior_contents_reader = prior_contents_reader
26
+ end
27
+
28
+ # @visibility private
29
+ def to_s
30
+ case type
31
+ when DIRECTORY_CHANGE_TYPE
32
+ "contents of dir `#{path}` changed"
33
+ else
34
+ message = "file `#{path}` #{type}"
35
+ message += " (from #{prior_path})" if prior_path
36
+ message
37
+ end
38
+ end
39
+
40
+ # @visibility private
41
+ def inspect
42
+ "#<#{self.class} path=#{path.inspect} type=#{type.inspect} prior_path=#{prior_path.inspect}" \
43
+ " contents=#{contents.inspect} prior_contents=#{prior_contents.inspect}>"
44
+ end
45
+
46
+ # @visibility private
47
+ def hash
48
+ path.hash ^ type.hash
49
+ end
50
+
51
+ # @visibility private
52
+ def ==(other)
53
+ return unless other.is_a?(FileModification)
54
+ (path == other.path) && (type == other.type) && prior_path == other.prior_path
55
+ end
56
+
57
+ # @visibility private
58
+ def eql?(other)
59
+ return unless other.is_a?(FileModification)
60
+ path.eql?(other.path) && type.eql?(other.type) && prior_path.eql?(other.prior_path)
61
+ end
62
+
63
+ # @return [String,Nil] a YAML string representing the diff of the file
64
+ # from the prior revision to the current revision at the given keypath
65
+ # in the YAML, or `nil` if there is no diff
66
+ # @param keypath [Array] a list of indices passed to `dig`.
67
+ # An empty array is equivalent to the entire YAML document
68
+ def yaml_diff(keypath)
69
+ require 'yaml'
70
+
71
+ dig_yaml = lambda do |yaml|
72
+ return yaml if DOES_NOT_EXIST == yaml
73
+ object = YAML.safe_load(yaml, [Symbol])
74
+ if keypath.empty?
75
+ object
76
+ elsif object.respond_to?(:dig)
77
+ object.dig(*keypath)
78
+ else # backwards compatibility
79
+ keypath.reduce(object) do |acc, elem|
80
+ acc[elem]
81
+ end
82
+ end
83
+ end
84
+
85
+ prior = dig_yaml[prior_contents]
86
+ current = dig_yaml[contents]
87
+
88
+ require 'xcodeproj/differ'
89
+
90
+ return unless (diff = Xcodeproj::Differ.diff(
91
+ prior,
92
+ current,
93
+ key_1: 'prior_revision',
94
+ key_2: 'current_revision'
95
+ ))
96
+
97
+ diff.to_yaml.prepend("#{path} changed at keypath #{keypath.inspect}\n")
98
+ end
99
+
100
+ DOES_NOT_EXIST = Object.new.tap do |o|
101
+ class << o
102
+ def to_s
103
+ 'DOES NOT EXISTS'
104
+ end
105
+ alias_method :inspect, :to_s
106
+ end
107
+ end.freeze
108
+ private_constant :DOES_NOT_EXIST
109
+
110
+ # @return [String] the current contents of the file
111
+ def contents
112
+ @contents ||=
113
+ begin
114
+ @contents_reader[].tap { @contents_reader = nil } || DOES_NOT_EXIST
115
+ rescue StandardError
116
+ DOES_NOT_EXIST
117
+ end
118
+ end
119
+
120
+ # @return [String] the prior contents of the file
121
+ def prior_contents
122
+ @prior_contents ||=
123
+ begin
124
+ @prior_contents_reader[].tap { @prior_contents_reader = nil } || DOES_NOT_EXIST
125
+ rescue StandardError
126
+ DOES_NOT_EXIST
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,213 @@
1
+ require 'cocoapods/executable'
2
+ require 'set'
3
+
4
+ module Refinement
5
+ # Represents a set of changes in a repository between a prior revision and the current state
6
+ class Changeset
7
+ # An error that happens when computing a git diff
8
+ class GitError < Error; end
9
+ require 'refinement/changeset/file_modification'
10
+
11
+ # @return [Pathname] the path to the repository
12
+ attr_reader :repository
13
+ # @return [Array<FileModification>] the modifications in the changeset
14
+ attr_reader :modifications
15
+ # @return [Hash<Pathname,FileModification>] modifications keyed by relative path
16
+ attr_reader :modified_paths
17
+ # @return [Hash<Pathname,FileModification>] modifications keyed by relative path
18
+ attr_reader :modified_absolute_paths
19
+
20
+ private :modifications, :modified_paths, :modified_absolute_paths
21
+
22
+ def initialize(repository:, modifications:)
23
+ @repository = repository
24
+ @modifications = self.class.add_directories(modifications).uniq.freeze
25
+
26
+ @modified_paths = {}
27
+ @modifications
28
+ .each { |mod| @modified_paths[mod.path] = mod }
29
+ .each { |mod| @modified_paths[mod.prior_path] ||= mod if mod.prior_path }
30
+ @modified_paths.freeze
31
+
32
+ @modified_absolute_paths = {}
33
+ @modified_paths
34
+ .each { |path, mod| @modified_absolute_paths[path.expand_path(repository).freeze] = mod }
35
+ @modified_absolute_paths.freeze
36
+ end
37
+
38
+ # @visibility private
39
+ # @return [Array<FileModification>] file modifications that include modifications for each
40
+ # directory that has had a child modified
41
+ # @param modifications [Array<FileModification>] The modifications to add directory modifications to
42
+ def self.add_directories(modifications)
43
+ dirs = Set.new
44
+ add = lambda { |path|
45
+ break unless dirs.add?(path)
46
+ add[path.dirname]
47
+ }
48
+ modifications.each do |mod|
49
+ add[mod.path.dirname]
50
+ add[mod.prior_path.dirname] if mod.prior_path
51
+ end
52
+ modifications +
53
+ dirs.map { |d| FileModification.new(path: Pathname("#{d}/").freeze, type: FileModification::DIRECTORY_CHANGE_TYPE) }
54
+ end
55
+
56
+ # @return [FileModification,Nil] the changeset for the given absolute path,
57
+ # or `nil` if the given path is un-modified
58
+ # @param absolute_path [Pathname]
59
+ def find_modification_for_path(absolute_path:)
60
+ modified_absolute_paths[absolute_path]
61
+ end
62
+
63
+ # @return [Array<String>] An array of patterns converted from a
64
+ # {Dir.glob} pattern to patterns that {File.fnmatch} can handle.
65
+ # This is used by the {#relative_glob} method to emulate
66
+ # {Dir.glob}.
67
+ #
68
+ # The expansion provides support for:
69
+ #
70
+ # - Literals
71
+ #
72
+ # dir_glob_equivalent_patterns('{file1,file2}.{h,m}')
73
+ # => ["file1.h", "file1.m", "file2.h", "file2.m"]
74
+ #
75
+ # - Matching the direct children of a directory with `**`
76
+ #
77
+ # dir_glob_equivalent_patterns('Classes/**/file.m')
78
+ # => ["Classes/**/file.m", "Classes/file.m"]
79
+ #
80
+ # @param [String] pattern A {Dir#glob} like pattern.
81
+ #
82
+ def dir_glob_equivalent_patterns(pattern)
83
+ pattern = pattern.gsub('/**/', '{/**/,/}')
84
+ values_by_set = {}
85
+ pattern.scan(/\{[^}]*\}/) do |set|
86
+ values = set.gsub(/[{}]/, '').split(',')
87
+ values_by_set[set] = values
88
+ end
89
+
90
+ if values_by_set.empty?
91
+ [pattern]
92
+ else
93
+ patterns = [pattern]
94
+ values_by_set.each do |set, values|
95
+ patterns = patterns.flat_map do |old_pattern|
96
+ values.map do |value|
97
+ old_pattern.gsub(set, value)
98
+ end
99
+ end
100
+ end
101
+ patterns
102
+ end
103
+ end
104
+ private :dir_glob_equivalent_patterns
105
+
106
+ # @return [FileModification,Nil] the modification for the given absolute glob,
107
+ # or `nil` if no files matching the glob were modified
108
+ # @note Will only return a single (arbitrary) matching modification, even if there are
109
+ # multiple modifications that match the glob
110
+ # @param absolute_glob [String] a glob pattern for absolute paths, suitable for an invocation of `Dir.glob`
111
+ def find_modification_for_glob(absolute_glob:)
112
+ absolute_globs = dir_glob_equivalent_patterns(absolute_glob)
113
+ _path, modification = modified_absolute_paths.find do |absolute_path, _modification|
114
+ absolute_globs.any? do |glob|
115
+ File.fnmatch?(glob, absolute_path, File::FNM_CASEFOLD | File::FNM_PATHNAME)
116
+ end
117
+ end
118
+ modification
119
+ end
120
+
121
+ # @return [FileModification,Nil] a modification and yaml diff for the keypath at the given absolute path,
122
+ # or `nil` if the value at the given keypath is un-modified
123
+ # @param absolute_path [Pathname]
124
+ # @param keypath [Array]
125
+ def find_modification_for_yaml_keypath(absolute_path:, keypath:)
126
+ return unless (file_modification = find_modification_for_path(absolute_path: absolute_path))
127
+ diff = file_modification.yaml_diff(keypath)
128
+ return unless diff
129
+ [file_modification, diff]
130
+ end
131
+
132
+ # @return [Changeset] the changes in the given git repository between the given revision and HEAD
133
+ # @param repository [Pathname,String]
134
+ # @param base_revision [String]
135
+ def self.from_git(repository:, base_revision:)
136
+ merge_base = git!('merge-base', base_revision, 'HEAD', chdir: repository).strip
137
+ diff = git!('diff', '--raw', '-z', merge_base, chdir: repository)
138
+ modifications = parse_raw_diff(diff, repository: repository, base_revision: base_revision).freeze
139
+
140
+ new(repository: repository, modifications: modifications)
141
+ end
142
+
143
+ CHANGE_TYPES = {
144
+ :'was added' => 'A',
145
+ :'was copied' => 'C',
146
+ :'was deleted' => 'D',
147
+ :'was modified' => 'M',
148
+ :'was renamed' => 'R',
149
+ :'changed type' => 'T',
150
+ :'is unmerged' => 'U',
151
+ :'changed in an unknown way' => 'X'
152
+ }.freeze
153
+ private_constant :CHANGE_TYPES
154
+
155
+ CHANGE_CHARACTERS = CHANGE_TYPES.invert.freeze
156
+ private_constant :CHANGE_CHARACTERS
157
+
158
+ # Parses the raw diff into FileModification objects
159
+ # @return [Array<FileModification>]
160
+ # @param diff [String] a diff generated by `git diff --raw -z`
161
+ # @param repository [Pathname] the path to the repository
162
+ # @param base_revision [String] the base revision the diff was constructed agains
163
+ def self.parse_raw_diff(diff, repository:, base_revision:)
164
+ # since we're null separating the chunks (to avoid dealing with path escaping) we have to reconstruct
165
+ # the chunks into individual diff entries. entries always start with a colon so we can use that to signal if
166
+ # we're on a new entry
167
+ parsed_lines = diff.split("\0").each_with_object([]) do |chunk, lines|
168
+ lines << [] if chunk.start_with?(':')
169
+ lines.last << chunk
170
+ end
171
+
172
+ parsed_lines.map do |split_line|
173
+ # change chunk (letter + optional similarity percentage) will always be the last part of first line chunk
174
+ change_chunk = split_line[0].split(/\s/).last
175
+
176
+ new_path = Pathname(split_line[2]).freeze if split_line[2]
177
+ old_path = Pathname(split_line[1]).freeze
178
+ prior_path = old_path if new_path
179
+ # new path if one exists, else existing path. new path only exists for rename and copy
180
+ changed_path = new_path || old_path
181
+
182
+ change_character = change_chunk[0]
183
+ # returns 0 when a similarity percentage isn't specified by git.
184
+ _similarity = change_chunk[1..3].to_i
185
+
186
+ FileModification.new(
187
+ path: changed_path,
188
+ type: CHANGE_CHARACTERS[change_character],
189
+ prior_path: prior_path,
190
+ contents_reader: -> { repo.join(changed_path).read },
191
+ prior_contents_reader: lambda {
192
+ git!('show', "#{base_revision}:#{prior_path || changed_path}", chdir: repository)
193
+ }
194
+ )
195
+ end
196
+ end
197
+
198
+ # @return [String] the STDOUT of the git command
199
+ # @raise [GitError] when running the git command fails
200
+ # @param command [String] the base git command to run
201
+ # @param args [String...] arguments to the git command
202
+ # @param chdir [String,Pathname] the directory to run the git command in
203
+ def self.git!(command, *args, chdir:)
204
+ require 'open3'
205
+ out, err, status = Open3.capture3('git', command, *args, chdir: chdir.to_s)
206
+ unless status.success?
207
+ raise GitError, "Running git #{command} failed (#{status.to_s.gsub(/pid \d+\s*/, '')}):\n\n#{err}"
208
+ end
209
+ out
210
+ end
211
+ private_class_method :git!
212
+ end
213
+ end
@@ -0,0 +1,113 @@
1
+ require 'claide'
2
+
3
+ module Refinement
4
+ # @visibility private
5
+ class CLI < CLAide::Command
6
+ self.abstract_command = true
7
+ self.command = 'refine'
8
+ self.version = VERSION
9
+
10
+ self.summary = 'Generates a list of Xcode targets to build & test as a result of a diff'
11
+
12
+ def self.options
13
+ super + [
14
+ ['--repository=REPOSITORY', 'Path to repository'],
15
+ ['--workspace=WORKSPACE_PATH', 'Path to project or workspace'],
16
+ ['--scheme=SCHEME_PATH', 'Path to scheme to be filtered'],
17
+ ['--augmenting-paths-yaml-files=PATH1,PATH2...', 'Paths to augmenting yaml files, relative to the repository path'],
18
+ ['--[no-]print-changes', 'Print the change reason for changed targets'],
19
+ ['--[no-]print-scheme-changes', 'Print the change reason for targets in the given scheme'],
20
+ ['--change-level=LEVEL', 'Change level at which a target must have changed in order to be considered changed. ' \
21
+ 'One of `full-transitive`, `itself`, or an integer']
22
+ ]
23
+ end
24
+
25
+ def initialize(argv)
26
+ @repository = argv.option('repository', '.')
27
+ @workspace = argv.option('workspace')
28
+ @scheme = argv.option('scheme')
29
+ @augmenting_paths_yaml_files = argv.option('augmenting-paths-yaml-files', '')
30
+ @print_changes = argv.flag?('print-changes', false)
31
+ @print_scheme_changes = argv.flag?('print-scheme-changes', false)
32
+ @change_level = argv.option('change-level', 'full-transitive')
33
+
34
+ super
35
+ end
36
+
37
+ def run
38
+ changeset = compute_changeset
39
+
40
+ analyzer = Refinement::Analyzer.new(changeset: changeset,
41
+ workspace_path: @workspace,
42
+ augmenting_paths_yaml_files: @augmenting_paths_yaml_files)
43
+ analyzer.annotate_targets!
44
+
45
+ puts analyzer.format_changes if @print_changes
46
+
47
+ return unless @scheme
48
+ analyzer.filtered_scheme(scheme_path: @scheme, log_changes: @print_scheme_changes)
49
+ .save_as(@scheme.gsub(%r{\.(xcodeproj|xcworkspace)/.+}, '.\1'), File.basename(@scheme, '.xcscheme'), true)
50
+ end
51
+
52
+ def validate!
53
+ super
54
+
55
+ File.directory?(@repository) || help!("Unable to find a repository at #{@repository.inspect}")
56
+
57
+ @workspace || help!('Must specify a project or workspace path')
58
+ File.directory?(@workspace) || help!("Unable to find a project or workspace at #{@workspace.inspect}")
59
+
60
+ @augmenting_paths_yaml_files = @augmenting_paths_yaml_files.split(',')
61
+ @augmenting_paths_yaml_files.each do |yaml_path|
62
+ yaml_path = File.join(@repository, yaml_path)
63
+ File.file?(yaml_path) || help!("Unable to find a YAML file at #{yaml_path.inspect}")
64
+
65
+ require 'yaml'
66
+ begin
67
+ YAML.safe_load(File.read(yaml_path))
68
+ rescue StandardError => e
69
+ help! "Failed to load YAML file at #{yaml_path.inspect} (#{e})"
70
+ end
71
+ end
72
+
73
+ File.file?(@scheme) || help!("Unabled to find a scheme at #{@scheme.inspect}") if @scheme
74
+
75
+ @change_level =
76
+ case @change_level
77
+ when 'full-transitive' then :full_transitive
78
+ when 'itself' then :itself
79
+ when /\A\d+\z/ then [:at_most_n_away, @change_level.to_i]
80
+ else help! "Unknown change level #{@change_level.inspect}"
81
+ end
82
+ end
83
+
84
+ # @visibility private
85
+ class Git < CLI
86
+ self.summary = 'Generates a list of Xcode targets to build & test as a result of a git diff'
87
+
88
+ def self.options
89
+ super + [
90
+ ['--base-revision=SHA', 'Base revision to compute the git diff against']
91
+ ]
92
+ end
93
+
94
+ def initialize(argv)
95
+ @base_revision = argv.option('base-revision')
96
+
97
+ super
98
+ end
99
+
100
+ def validate!
101
+ super
102
+
103
+ @base_revision || help!('Must specify a base revision')
104
+ end
105
+
106
+ private
107
+
108
+ def compute_changeset
109
+ Refinement::Changeset.from_git(repository: @repository, base_revision: @base_revision)
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,118 @@
1
+ module Refinement
2
+ # Called after CocoaPods installation to write an augmenting file that
3
+ # takes into account changes to Pod configuration,
4
+ # as well as the globs used by podspecs to search for files
5
+ class CocoaPodsPostInstallWriter
6
+ attr_reader :aggregate_targets, :config, :repo, :options
7
+ private :aggregate_targets, :config, :repo, :options
8
+
9
+ # Initializes a post-install writer with CocoaPods target objects.
10
+ # @return [CocoaPodsPostInstallWriter] a new instance of CocoaPodsPostInstallWriter
11
+ # @param aggregate_targets [Array<Pod::AggregateTarget>]
12
+ # @param config [Pod::Config]
13
+ # @param options [Hash]
14
+ def initialize(aggregate_targets, config, options)
15
+ @aggregate_targets = aggregate_targets
16
+ @config = config
17
+ @repo = config.installation_root
18
+ @options = options || {}
19
+ end
20
+
21
+ # Writes the refinement augmenting file to the configured path
22
+ # @return [Void]
23
+ def write!
24
+ write_file options.fetch('output_path', config.sandbox.root.join('pods_refinement.json')), paths_by_target_name
25
+ end
26
+
27
+ private
28
+
29
+ def write_file(path, hash)
30
+ require 'json'
31
+ File.open(path, 'w') do |f|
32
+ f << JSON.generate(hash) << "\n"
33
+ end
34
+ end
35
+
36
+ def paths_by_target_name
37
+ targets = {}
38
+ aggregate_targets.each do |aggregate_target|
39
+ targets[aggregate_target.label] = paths_for_aggregate_target(aggregate_target)
40
+ end
41
+ aggregate_targets.flat_map(&:pod_targets).uniq.each do |pod_target|
42
+ targets.merge! paths_for_pod_targets(pod_target)
43
+ end
44
+ targets
45
+ end
46
+
47
+ def paths_for_aggregate_target(aggregate_target)
48
+ paths = []
49
+ if (podfile_path = aggregate_target.podfile.defined_in_file)
50
+ paths << { path: podfile_path.relative_path_from(repo), inclusion_reason: 'Podfile' }
51
+ end
52
+ if (user_project_path = aggregate_target.user_project_path)
53
+ paths << { path: user_project_path.relative_path_from(repo), inclusion_reason: 'user project' }
54
+ end
55
+ paths
56
+ end
57
+
58
+ def library_specification?(spec)
59
+ # Backwards compatibility
60
+ if spec.respond_to?(:library_specification?, false)
61
+ spec.library_specification?
62
+ else
63
+ !spec.test_specification?
64
+ end
65
+ end
66
+
67
+ def specification_paths_from_pod_target(pod_target)
68
+ pod_target
69
+ .target_definitions
70
+ .map(&:podfile)
71
+ .uniq
72
+ .flat_map do |podfile|
73
+ podfile
74
+ .dependencies
75
+ .select { |d| d.root_name == pod_target.pod_name }
76
+ .map { |d| (d.external_source || {})[:path] }
77
+ .compact
78
+ end.uniq
79
+ end
80
+
81
+ def paths_for_pod_targets(pod_target)
82
+ file_accessors_by_target_name = pod_target.file_accessors.group_by do |fa|
83
+ if library_specification?(fa.spec)
84
+ pod_target.label
85
+ elsif pod_target.respond_to?(:non_library_spec_label)
86
+ pod_target.non_library_spec_label(fa.spec)
87
+ else
88
+ pod_target.test_target_label(fa.spec)
89
+ end
90
+ end
91
+
92
+ pod_dir = pod_target.sandbox.pod_dir(pod_target.pod_name).relative_path_from(repo)
93
+
94
+ spec_paths = specification_paths_from_pod_target(pod_target)
95
+
96
+ file_accessors_by_target_name.each_with_object({}) do |(label, file_accessors), h|
97
+ paths = [
98
+ { path: 'Podfile.lock',
99
+ inclusion_reason: 'CocoaPods lockfile',
100
+ yaml_keypath: ['SPEC CHECKSUMS', pod_target.pod_name] }
101
+ ]
102
+ spec_paths.each { |path| paths << { path: path, inclusion_reason: 'podspec' } }
103
+
104
+ Pod::Validator::FILE_PATTERNS.each do |pattern|
105
+ file_accessors.each do |fa|
106
+ globs = fa.spec_consumer.send(pattern)
107
+ globs = globs.values.flatten if globs.is_a?(Hash)
108
+ globs.each do |glob|
109
+ paths << { glob: pod_dir.join(glob), inclusion_reason: pattern.to_s.tr('_', ' ').chomp('s') }
110
+ end
111
+ end
112
+ end
113
+
114
+ h[label] = paths.uniq
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,114 @@
1
+ module Refinement
2
+ # Represents a path that some target depends upon.
3
+ class UsedPath
4
+ # @return [Pathname] the absolute path to the file
5
+ attr_reader :path
6
+ private :path
7
+
8
+ # @return [String] the reason why this path is being used by a target
9
+ attr_reader :inclusion_reason
10
+ private :inclusion_reason
11
+
12
+ def initialize(path:, inclusion_reason:)
13
+ @path = path
14
+ @inclusion_reason = inclusion_reason
15
+ end
16
+
17
+ # @return [Nil, String] If the path has been modified, a string explaining the modification
18
+ # @param changeset [Changeset] the changeset to search for a modification to this path
19
+ def find_in_changeset(changeset)
20
+ add_reason changeset.find_modification_for_path(absolute_path: path)
21
+ end
22
+
23
+ # @return [String]
24
+ # @visibility private
25
+ def to_s
26
+ "#{path.to_s.inspect} (#{inclusion_reason})"
27
+ end
28
+
29
+ private
30
+
31
+ # @return [Nil, String] A string suitable for user display that explains
32
+ # why the given modification means a target is modified
33
+ # @param modification [Nil, FileModification]
34
+ def add_reason(modification)
35
+ return unless modification
36
+
37
+ "#{modification.path} (#{inclusion_reason}) #{modification.type}"
38
+ end
39
+
40
+ # Represents a path to a YAML file that some target depends upon,
41
+ # but where only a subset of the YAML is needed to determine a change.
42
+ class YAML < UsedPath
43
+ # @return [Array] the keypath to search for modifications in a YAML document
44
+ attr_reader :yaml_keypath
45
+ private :yaml_keypath
46
+
47
+ def initialize(yaml_keypath:, **kwargs)
48
+ super(**kwargs)
49
+ @yaml_keypath = yaml_keypath
50
+ end
51
+
52
+ # (see UsedPath#find_in_changeset)
53
+ def find_in_changeset(changeset)
54
+ modification, _yaml_diff = changeset.find_modification_for_yaml_keypath(absolute_path: path, keypath: yaml_keypath)
55
+ add_reason modification
56
+ end
57
+
58
+ # (see UsedPath#to_s)
59
+ def to_s
60
+ "#{path.to_s.inspect} @ #{yaml_keypath.join('.')} (#{inclusion_reason})"
61
+ end
62
+
63
+ private
64
+
65
+ # (see UsedPath#add_reason)
66
+ def add_reason(modification)
67
+ return unless modification
68
+
69
+ keypath_string =
70
+ if yaml_keypath.empty?
71
+ ''
72
+ else
73
+ ' @ ' + yaml_keypath.map { |path| path.to_s =~ /\A[a-zA-Z0-9_]+\z/ ? path : path.inspect }.join('.')
74
+ end
75
+ "#{modification.path}#{keypath_string} (#{inclusion_reason}) #{modification.type}"
76
+ end
77
+ end
78
+ end
79
+
80
+ # Represents a glob that some target depends upon.
81
+ class UsedGlob
82
+ # @return [String] a relative path glob
83
+ attr_reader :glob
84
+ private :glob
85
+
86
+ # (see UsedPath#inclusion_reason)
87
+ attr_reader :inclusion_reason
88
+ private :inclusion_reason
89
+
90
+ def initialize(glob:, inclusion_reason:)
91
+ @glob = glob
92
+ @inclusion_reason = inclusion_reason
93
+ end
94
+
95
+ # (see UsedPath#find_in_changeset)
96
+ def find_in_changeset(changeset)
97
+ add_reason changeset.find_modification_for_glob(absolute_glob: glob)
98
+ end
99
+
100
+ # (see UsedPath#to_s)
101
+ def to_s
102
+ "#{glob.to_s.inspect} (#{inclusion_reason})"
103
+ end
104
+
105
+ private
106
+
107
+ # (see UsedPath#add_reason)
108
+ def add_reason(modification)
109
+ return unless modification
110
+
111
+ "#{modification.path} (#{inclusion_reason}) #{modification.type}"
112
+ end
113
+ end
114
+ end
@@ -1,3 +1,4 @@
1
1
  module Refinement
2
+ # @visibility private
2
3
  VERSION = File.read(File.expand_path('../../VERSION', __dir__)).strip.freeze
3
4
  end
data/lib/refinement.rb CHANGED
@@ -1,6 +1,27 @@
1
- require 'refinement/version'
1
+ require 'xcodeproj'
2
2
 
3
+ # Generates a list of Xcode targets to build & test as a result of a git diff.
3
4
  module Refinement
4
5
  class Error < StandardError; end
5
- # Your code goes here...
6
+
7
+ # @visibility private
8
+ # @param enum [Enumerable]
9
+ # Enumerates through `enum`, and applied the given block to each element.
10
+ # If the result of calling the block is truthy, the first such result is returned.
11
+ # If no such result is found, `nil` is returned.
12
+ def self.map_find(enum)
13
+ enum.each do |elem|
14
+ transformed = yield elem
15
+ return transformed if transformed
16
+ end
17
+
18
+ nil
19
+ end
20
+
21
+ require 'refinement/version'
22
+
23
+ require 'refinement/analyzer'
24
+ require 'refinement/annotated_target'
25
+ require 'refinement/changeset'
26
+ require 'refinement/used_path'
6
27
  end
metadata CHANGED
@@ -1,49 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: refinement
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Giddins
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2019-01-18 00:00:00.000000000 Z
11
+ date: 2019-02-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: cocoapods
14
+ name: xcodeproj
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: 1.6.a
19
+ version: 1.8.0
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: 1.6.a
27
- - !ruby/object:Gem::Dependency
28
- name: bundler
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - ">="
32
- - !ruby/object:Gem::Version
33
- version: '1.17'
34
- - - "<"
35
- - !ruby/object:Gem::Version
36
- version: '3'
37
- type: :development
38
- prerelease: false
39
- version_requirements: !ruby/object:Gem::Requirement
40
- requirements:
41
- - - ">="
42
- - !ruby/object:Gem::Version
43
- version: '1.17'
44
- - - "<"
45
- - !ruby/object:Gem::Version
46
- version: '3'
26
+ version: 1.8.0
47
27
  - !ruby/object:Gem::Dependency
48
28
  name: rake
49
29
  requirement: !ruby/object:Gem::Requirement
@@ -61,14 +41,25 @@ dependencies:
61
41
  description:
62
42
  email:
63
43
  - segiddins@squareup.com
64
- executables: []
44
+ executables:
45
+ - refine
65
46
  extensions: []
66
47
  extra_rdoc_files: []
67
48
  files:
49
+ - CHANGELOG.md
68
50
  - CODE_OF_CONDUCT.md
69
51
  - README.md
70
52
  - VERSION
53
+ - exe/refine
54
+ - lib/cocoapods_plugin.rb
71
55
  - lib/refinement.rb
56
+ - lib/refinement/analyzer.rb
57
+ - lib/refinement/annotated_target.rb
58
+ - lib/refinement/changeset.rb
59
+ - lib/refinement/changeset/file_modification.rb
60
+ - lib/refinement/cli.rb
61
+ - lib/refinement/cocoapods_post_install_writer.rb
62
+ - lib/refinement/used_path.rb
72
63
  - lib/refinement/version.rb
73
64
  homepage: https://github.com/square/refinement
74
65
  licenses: []