refinement 0.0.1 → 0.1.0

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