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 +4 -4
- data/CHANGELOG.md +7 -0
- data/VERSION +1 -1
- data/exe/refine +6 -0
- data/lib/cocoapods_plugin.rb +19 -0
- data/lib/refinement/analyzer.rb +348 -0
- data/lib/refinement/annotated_target.rb +71 -0
- data/lib/refinement/changeset/file_modification.rb +131 -0
- data/lib/refinement/changeset.rb +213 -0
- data/lib/refinement/cli.rb +113 -0
- data/lib/refinement/cocoapods_post_install_writer.rb +118 -0
- data/lib/refinement/used_path.rb +114 -0
- data/lib/refinement/version.rb +1 -0
- data/lib/refinement.rb +23 -2
- metadata +17 -26
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 695104530dba26775514c23cdf0e7802eb5cc2d83c421855c1a063b064a86a2e
|
4
|
+
data.tar.gz: 46d8e6cdb5774196354a4e13ca91f2bc5db969f572116856db0bf8498102a245
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8e5c9694afb88668f2224a50863f37a2624dbcc0a7cdc5e32e4ad4e91f718a66658475a5b4fa3bbb33f7601dc63f5950982794a51d94e2654d90f0655b474b97
|
7
|
+
data.tar.gz: c2a655bf067bedd777d7dc2a5b2a0a4beede3250fb8708219884e63bfff4e66f7dd260ba7e4b6a47ba1a9538f2d819e4b4e763bef7084a12386b77e4a5a1fbc0
|
data/CHANGELOG.md
ADDED
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.0
|
1
|
+
0.1.0
|
data/exe/refine
ADDED
@@ -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
|
data/lib/refinement/version.rb
CHANGED
data/lib/refinement.rb
CHANGED
@@ -1,6 +1,27 @@
|
|
1
|
-
require '
|
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
|
-
|
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
|
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-
|
11
|
+
date: 2019-02-19 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
|
-
name:
|
14
|
+
name: xcodeproj
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
16
16
|
requirements:
|
17
17
|
- - "~>"
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version: 1.
|
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.
|
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: []
|