refinement 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +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: []
|