refinement 0.6.1 → 0.7.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 +16 -0
- data/VERSION +1 -1
- data/exe/refine +1 -1
- data/lib/cocoapods_plugin.rb +2 -2
- data/lib/refinement.rb +6 -27
- data/lib/sq/refinement/analyzer.rb +396 -0
- data/lib/sq/refinement/annotated_target.rb +82 -0
- data/lib/sq/refinement/changeset/file_modification.rb +144 -0
- data/lib/sq/refinement/changeset.rb +225 -0
- data/lib/sq/refinement/cli.rb +121 -0
- data/lib/sq/refinement/cocoapods_post_install_writer.rb +129 -0
- data/lib/sq/refinement/setup.rb +22 -0
- data/lib/sq/refinement/used_path.rb +156 -0
- data/lib/sq/refinement/version.rb +8 -0
- metadata +15 -27
- data/lib/refinement/analyzer.rb +0 -395
- data/lib/refinement/annotated_target.rb +0 -78
- data/lib/refinement/changeset/file_modification.rb +0 -138
- data/lib/refinement/changeset.rb +0 -223
- data/lib/refinement/cli.rb +0 -119
- data/lib/refinement/cocoapods_post_install_writer.rb +0 -126
- data/lib/refinement/used_path.rb +0 -154
- data/lib/refinement/version.rb +0 -6
@@ -0,0 +1,144 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'xcodeproj'
|
4
|
+
|
5
|
+
module Sq
|
6
|
+
module Refinement
|
7
|
+
class Changeset
|
8
|
+
# Represents a modification to a single file or directory on disk
|
9
|
+
class FileModification
|
10
|
+
# @return [Symbol] the change type for directories
|
11
|
+
DIRECTORY_CHANGE_TYPE = :'had contents change'
|
12
|
+
|
13
|
+
# @return [Pathname] the path to the modified file
|
14
|
+
attr_reader :path
|
15
|
+
|
16
|
+
# @return [Pathname, Nil] the prior path to the modified file, or `nil` if it was not renamed or copied
|
17
|
+
attr_reader :prior_path
|
18
|
+
|
19
|
+
# @return [#to_s] the type of change that happened to this file
|
20
|
+
attr_reader :type
|
21
|
+
|
22
|
+
def initialize(path:, type:,
|
23
|
+
prior_path: nil,
|
24
|
+
contents_reader: -> {},
|
25
|
+
prior_contents_reader: -> {})
|
26
|
+
@path = path
|
27
|
+
@type = type
|
28
|
+
@prior_path = prior_path
|
29
|
+
@contents_reader = contents_reader
|
30
|
+
@prior_contents_reader = prior_contents_reader
|
31
|
+
end
|
32
|
+
|
33
|
+
# @visibility private
|
34
|
+
def to_s
|
35
|
+
case type
|
36
|
+
when DIRECTORY_CHANGE_TYPE
|
37
|
+
"contents of dir `#{path}` changed"
|
38
|
+
else
|
39
|
+
message = "file `#{path}` #{type}"
|
40
|
+
message += " (from #{prior_path})" if prior_path
|
41
|
+
message
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# @visibility private
|
46
|
+
def inspect
|
47
|
+
"#<#{self.class} path=#{path.inspect} type=#{type.inspect} prior_path=#{prior_path.inspect} " \
|
48
|
+
"contents=#{contents.inspect} prior_contents=#{prior_contents.inspect}>"
|
49
|
+
end
|
50
|
+
|
51
|
+
# @visibility private
|
52
|
+
def hash
|
53
|
+
[path, type].hash
|
54
|
+
end
|
55
|
+
|
56
|
+
# @visibility private
|
57
|
+
def ==(other)
|
58
|
+
return unless other.is_a?(FileModification)
|
59
|
+
|
60
|
+
(path == other.path) && (type == other.type) && prior_path == other.prior_path
|
61
|
+
end
|
62
|
+
|
63
|
+
# @visibility private
|
64
|
+
def eql?(other)
|
65
|
+
return false unless other.is_a?(FileModification)
|
66
|
+
|
67
|
+
path.eql?(other.path) && type.eql?(other.type) && prior_path.eql?(other.prior_path)
|
68
|
+
end
|
69
|
+
|
70
|
+
# @return [String,Nil] a YAML string representing the diff of the file
|
71
|
+
# from the prior revision to the current revision at the given keypath
|
72
|
+
# in the YAML, or `nil` if there is no diff
|
73
|
+
# @param keypath [Array] a list of indices passed to `dig`.
|
74
|
+
# An empty array is equivalent to the entire YAML document
|
75
|
+
def yaml_diff(keypath)
|
76
|
+
require 'yaml'
|
77
|
+
|
78
|
+
@cached_yaml ||= {}
|
79
|
+
|
80
|
+
dig_yaml = lambda do |yaml, path|
|
81
|
+
return yaml if yaml == DOES_NOT_EXIST
|
82
|
+
|
83
|
+
object = @cached_yaml[path] ||= YAML.safe_load(yaml, permitted_classes: [Symbol], aliases: true)
|
84
|
+
if keypath.empty?
|
85
|
+
object
|
86
|
+
elsif object.respond_to?(:dig)
|
87
|
+
object.dig(*keypath)
|
88
|
+
else # backwards compatibility
|
89
|
+
keypath.reduce(object) do |acc, elem|
|
90
|
+
acc[elem]
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
prior = dig_yaml[prior_contents, :prior]
|
96
|
+
current = dig_yaml[contents, :current]
|
97
|
+
|
98
|
+
require 'xcodeproj/differ'
|
99
|
+
|
100
|
+
# rubocop:disable Naming/VariableNumber
|
101
|
+
return unless (diff = Xcodeproj::Differ.diff(
|
102
|
+
prior,
|
103
|
+
current,
|
104
|
+
key_1: 'prior_revision',
|
105
|
+
key_2: 'current_revision'
|
106
|
+
))
|
107
|
+
# rubocop:enable Naming/VariableNumber
|
108
|
+
|
109
|
+
diff.to_yaml.prepend("#{path} changed at keypath #{keypath.inspect}\n")
|
110
|
+
end
|
111
|
+
|
112
|
+
DOES_NOT_EXIST = Object.new.tap do |o|
|
113
|
+
class << o
|
114
|
+
def to_s
|
115
|
+
'DOES NOT EXISTS'
|
116
|
+
end
|
117
|
+
alias_method :inspect, :to_s
|
118
|
+
end
|
119
|
+
end.freeze
|
120
|
+
private_constant :DOES_NOT_EXIST
|
121
|
+
|
122
|
+
# @return [String] the current contents of the file
|
123
|
+
def contents
|
124
|
+
@contents ||=
|
125
|
+
begin
|
126
|
+
@contents_reader[].tap { @contents_reader = nil } || DOES_NOT_EXIST
|
127
|
+
rescue StandardError
|
128
|
+
DOES_NOT_EXIST
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
# @return [String] the prior contents of the file
|
133
|
+
def prior_contents
|
134
|
+
@prior_contents ||=
|
135
|
+
begin
|
136
|
+
@prior_contents_reader[].tap { @prior_contents_reader = nil } || DOES_NOT_EXIST
|
137
|
+
rescue StandardError
|
138
|
+
DOES_NOT_EXIST
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
@@ -0,0 +1,225 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'cocoapods/executable'
|
4
|
+
require 'set'
|
5
|
+
|
6
|
+
module Sq
|
7
|
+
module Refinement
|
8
|
+
# Represents a set of changes in a repository between a prior revision and the current state
|
9
|
+
class Changeset
|
10
|
+
# An error that happens when computing a git diff
|
11
|
+
class GitError < Error; end
|
12
|
+
require 'sq/refinement/changeset/file_modification'
|
13
|
+
|
14
|
+
# @return [Pathname] the path to the repository
|
15
|
+
attr_reader :repository
|
16
|
+
# @return [Array<FileModification>] the modifications in the changeset
|
17
|
+
attr_reader :modifications
|
18
|
+
# @return [Hash<Pathname,FileModification>] modifications keyed by relative path
|
19
|
+
attr_reader :modified_paths
|
20
|
+
# @return [Hash<Pathname,FileModification>] modifications keyed by relative path
|
21
|
+
attr_reader :modified_absolute_paths
|
22
|
+
# @return [String] a desciption of the changeset
|
23
|
+
attr_reader :description
|
24
|
+
|
25
|
+
private :modifications, :modified_paths, :modified_absolute_paths
|
26
|
+
|
27
|
+
def initialize(repository:, modifications:, description: nil)
|
28
|
+
@repository = repository
|
29
|
+
@modifications = self.class.add_directories(modifications).uniq.freeze
|
30
|
+
@description = description
|
31
|
+
|
32
|
+
@modified_paths = {}
|
33
|
+
@modifications
|
34
|
+
.each { |mod| @modified_paths[mod.path] = mod }
|
35
|
+
.each { |mod| @modified_paths[mod.prior_path] ||= mod if mod.prior_path }
|
36
|
+
@modified_paths.freeze
|
37
|
+
|
38
|
+
@modified_absolute_paths = {}
|
39
|
+
@modified_paths
|
40
|
+
.each { |path, mod| @modified_absolute_paths[path.expand_path(repository).freeze] = mod }
|
41
|
+
@modified_absolute_paths.freeze
|
42
|
+
end
|
43
|
+
|
44
|
+
# @visibility private
|
45
|
+
# @return [Array<FileModification>] file modifications that include modifications for each
|
46
|
+
# directory that has had a child modified
|
47
|
+
# @param modifications [Array<FileModification>] The modifications to add directory modifications to
|
48
|
+
def self.add_directories(modifications)
|
49
|
+
dirs = Set.new
|
50
|
+
add = lambda { |path|
|
51
|
+
break unless dirs.add?(path)
|
52
|
+
|
53
|
+
add[path.dirname]
|
54
|
+
}
|
55
|
+
modifications.each do |mod|
|
56
|
+
add[mod.path.dirname]
|
57
|
+
add[mod.prior_path.dirname] if mod.prior_path
|
58
|
+
end
|
59
|
+
modifications +
|
60
|
+
dirs.map { |d| FileModification.new(path: Pathname("#{d}/").freeze, type: FileModification::DIRECTORY_CHANGE_TYPE) }
|
61
|
+
end
|
62
|
+
|
63
|
+
# @return [FileModification,Nil] the changeset for the given absolute path,
|
64
|
+
# or `nil` if the given path is un-modified
|
65
|
+
# @param absolute_path [Pathname]
|
66
|
+
def find_modification_for_path(absolute_path:)
|
67
|
+
modified_absolute_paths[absolute_path]
|
68
|
+
end
|
69
|
+
|
70
|
+
# @return [Array<String>] An array of patterns converted from a
|
71
|
+
# {Dir.glob} pattern to patterns that {File.fnmatch} can handle.
|
72
|
+
# This is used by the {#relative_glob} method to emulate
|
73
|
+
# {Dir.glob}.
|
74
|
+
#
|
75
|
+
# The expansion provides support for:
|
76
|
+
#
|
77
|
+
# - Literals
|
78
|
+
#
|
79
|
+
# dir_glob_equivalent_patterns('{file1,file2}.{h,m}')
|
80
|
+
# => ["file1.h", "file1.m", "file2.h", "file2.m"]
|
81
|
+
#
|
82
|
+
# - Matching the direct children of a directory with `**`
|
83
|
+
#
|
84
|
+
# dir_glob_equivalent_patterns('Classes/**/file.m')
|
85
|
+
# => ["Classes/**/file.m", "Classes/file.m"]
|
86
|
+
#
|
87
|
+
# @param [String] pattern A {Dir#glob} like pattern.
|
88
|
+
#
|
89
|
+
def dir_glob_equivalent_patterns(pattern)
|
90
|
+
pattern = pattern.gsub('/**/', '{/**/,/}')
|
91
|
+
values_by_set = {}
|
92
|
+
pattern.scan(/\{[^}]*\}/) do |set|
|
93
|
+
values = set.gsub(/[{}]/, '').split(',', -1)
|
94
|
+
values_by_set[set] = values
|
95
|
+
end
|
96
|
+
|
97
|
+
if values_by_set.empty?
|
98
|
+
[pattern]
|
99
|
+
else
|
100
|
+
patterns = [pattern]
|
101
|
+
values_by_set.each do |set, values|
|
102
|
+
patterns = patterns.flat_map do |old_pattern|
|
103
|
+
values.map do |value|
|
104
|
+
old_pattern.gsub(set, value)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
patterns
|
109
|
+
end
|
110
|
+
end
|
111
|
+
private :dir_glob_equivalent_patterns
|
112
|
+
|
113
|
+
# @return [FileModification,Nil] the modification for the given absolute glob,
|
114
|
+
# or `nil` if no files matching the glob were modified
|
115
|
+
# @note Will only return a single (arbitrary) matching modification, even if there are
|
116
|
+
# multiple modifications that match the glob
|
117
|
+
# @param absolute_glob [String] a glob pattern for absolute paths, suitable for an invocation of `Dir.glob`
|
118
|
+
def find_modification_for_glob(absolute_glob:)
|
119
|
+
absolute_globs = dir_glob_equivalent_patterns(absolute_glob)
|
120
|
+
_path, modification = modified_absolute_paths.find do |absolute_path, _modification|
|
121
|
+
absolute_globs.any? do |glob|
|
122
|
+
File.fnmatch?(glob, absolute_path, File::FNM_CASEFOLD | File::FNM_PATHNAME)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
modification
|
126
|
+
end
|
127
|
+
|
128
|
+
# @return [FileModification,Nil] a modification and yaml diff for the keypath at the given absolute path,
|
129
|
+
# or `nil` if the value at the given keypath is un-modified
|
130
|
+
# @param absolute_path [Pathname]
|
131
|
+
# @param keypath [Array]
|
132
|
+
def find_modification_for_yaml_keypath(absolute_path:, keypath:)
|
133
|
+
return unless (file_modification = find_modification_for_path(absolute_path:))
|
134
|
+
|
135
|
+
diff = file_modification.yaml_diff(keypath)
|
136
|
+
return unless diff
|
137
|
+
|
138
|
+
[file_modification, diff]
|
139
|
+
end
|
140
|
+
|
141
|
+
# @return [Changeset] the changes in the given git repository between the given revision and HEAD
|
142
|
+
# @param repository [Pathname]
|
143
|
+
# @param base_revision [String]
|
144
|
+
def self.from_git(repository:, base_revision:)
|
145
|
+
raise ArgumentError, "must be given a Pathname for repository, got #{repository.inspect}" unless repository.is_a?(Pathname)
|
146
|
+
raise ArgumentError, "must be given a String for base_revision, got #{base_revision.inspect}" unless base_revision.is_a?(String)
|
147
|
+
|
148
|
+
merge_base = git!('merge-base', base_revision, 'HEAD', chdir: repository).strip
|
149
|
+
diff = git!('diff', '--raw', '-z', merge_base, chdir: repository)
|
150
|
+
modifications = parse_raw_diff(diff, repository:, base_revision: merge_base).freeze
|
151
|
+
|
152
|
+
new(repository:, modifications:, description: "since #{base_revision}")
|
153
|
+
end
|
154
|
+
|
155
|
+
CHANGE_TYPES = {
|
156
|
+
'was added': 'A',
|
157
|
+
'was copied': 'C',
|
158
|
+
'was deleted': 'D',
|
159
|
+
'was modified': 'M',
|
160
|
+
'was renamed': 'R',
|
161
|
+
'changed type': 'T',
|
162
|
+
'is unmerged': 'U',
|
163
|
+
'changed in an unknown way': 'X'
|
164
|
+
}.freeze
|
165
|
+
private_constant :CHANGE_TYPES
|
166
|
+
|
167
|
+
CHANGE_CHARACTERS = CHANGE_TYPES.invert.freeze
|
168
|
+
private_constant :CHANGE_CHARACTERS
|
169
|
+
|
170
|
+
# Parses the raw diff into FileModification objects
|
171
|
+
# @return [Array<FileModification>]
|
172
|
+
# @param diff [String] a diff generated by `git diff --raw -z`
|
173
|
+
# @param repository [Pathname] the path to the repository
|
174
|
+
# @param base_revision [String] the base revision the diff was constructed agains
|
175
|
+
def self.parse_raw_diff(diff, repository:, base_revision:)
|
176
|
+
# since we're null separating the chunks (to avoid dealing with path escaping) we have to reconstruct
|
177
|
+
# the chunks into individual diff entries. entries always start with a colon so we can use that to signal if
|
178
|
+
# we're on a new entry
|
179
|
+
parsed_lines = diff.split("\0").each_with_object([]) do |chunk, lines|
|
180
|
+
lines << [] if chunk.start_with?(':')
|
181
|
+
lines.last << chunk
|
182
|
+
end
|
183
|
+
|
184
|
+
parsed_lines.map do |split_line|
|
185
|
+
# change chunk (letter + optional similarity percentage) will always be the last part of first line chunk
|
186
|
+
change_chunk = split_line[0].split(/\s/).last
|
187
|
+
|
188
|
+
new_path = Pathname(split_line[2]).freeze if split_line[2]
|
189
|
+
old_path = Pathname(split_line[1]).freeze
|
190
|
+
prior_path = old_path if new_path
|
191
|
+
# new path if one exists, else existing path. new path only exists for rename and copy
|
192
|
+
changed_path = new_path || old_path
|
193
|
+
|
194
|
+
change_character = change_chunk[0]
|
195
|
+
# returns 0 when a similarity percentage isn't specified by git.
|
196
|
+
_similarity = change_chunk[1..3].to_i
|
197
|
+
|
198
|
+
FileModification.new(
|
199
|
+
path: changed_path,
|
200
|
+
type: CHANGE_CHARACTERS[change_character],
|
201
|
+
prior_path:,
|
202
|
+
contents_reader: -> { repository.join(changed_path).read },
|
203
|
+
prior_contents_reader: lambda {
|
204
|
+
git!('show', "#{base_revision}:#{prior_path || changed_path}", chdir: repository)
|
205
|
+
}
|
206
|
+
)
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
# @return [String] the STDOUT of the git command
|
211
|
+
# @raise [GitError] when running the git command fails
|
212
|
+
# @param command [String] the base git command to run
|
213
|
+
# @param args [String...] arguments to the git command
|
214
|
+
# @param chdir [String,Pathname] the directory to run the git command in
|
215
|
+
def self.git!(command, *args, chdir:)
|
216
|
+
require 'open3'
|
217
|
+
out, err, status = Open3.capture3('git', command, *args, chdir: chdir.to_s)
|
218
|
+
raise GitError, "Running git #{command} failed (#{status.to_s.gsub(/pid \d+\s*/, '')}):\n\n#{err}" unless status.success?
|
219
|
+
|
220
|
+
out
|
221
|
+
end
|
222
|
+
private_class_method :git!
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'claide'
|
4
|
+
|
5
|
+
module Sq
|
6
|
+
module Refinement
|
7
|
+
# @visibility private
|
8
|
+
class CLI < CLAide::Command
|
9
|
+
self.abstract_command = true
|
10
|
+
self.command = 'refine'
|
11
|
+
self.version = VERSION
|
12
|
+
|
13
|
+
self.summary = 'Generates a list of Xcode targets to build & test as a result of a diff'
|
14
|
+
|
15
|
+
def self.options
|
16
|
+
super + [
|
17
|
+
['--repository=REPOSITORY', 'Path to repository'],
|
18
|
+
['--workspace=WORKSPACE_PATH', 'Path to project or workspace'],
|
19
|
+
['--scheme=SCHEME_PATH', 'Path to scheme to be filtered'],
|
20
|
+
['--augmenting-paths-yaml-files=PATH1,PATH2...', 'Paths to augmenting yaml files, relative to the repository path'],
|
21
|
+
['--[no-]print-changes', 'Print the change reason for changed targets'],
|
22
|
+
['--[no-]print-scheme-changes', 'Print the change reason for targets in the given scheme'],
|
23
|
+
['--change-level=LEVEL', 'Change level at which a target must have changed in order to be considered changed. ' \
|
24
|
+
'One of `full-transitive`, `itself`, or an integer'],
|
25
|
+
['--filter-scheme-for-build-action=BUILD_ACTION', 'The xcodebuild action the scheme (if given) is filtered for. ' \
|
26
|
+
'One of `building` or `testing`.']
|
27
|
+
]
|
28
|
+
end
|
29
|
+
|
30
|
+
def initialize(argv)
|
31
|
+
@repository = argv.option('repository', '.')
|
32
|
+
@workspace = argv.option('workspace')
|
33
|
+
@scheme = argv.option('scheme')
|
34
|
+
@augmenting_paths_yaml_files = argv.option('augmenting-paths-yaml-files', '')
|
35
|
+
@print_changes = argv.flag?('print-changes', false)
|
36
|
+
@print_scheme_changes = argv.flag?('print-scheme-changes', false)
|
37
|
+
@change_level = argv.option('change-level', 'full-transitive')
|
38
|
+
@filter_scheme_for_build_action = argv.option('filter-scheme-for-build-action', 'testing').to_sym
|
39
|
+
|
40
|
+
super
|
41
|
+
end
|
42
|
+
|
43
|
+
def run
|
44
|
+
changeset = compute_changeset
|
45
|
+
|
46
|
+
analyzer = Sq::Refinement::Analyzer.new(changeset:,
|
47
|
+
workspace_path: @workspace,
|
48
|
+
augmenting_paths_yaml_files: @augmenting_paths_yaml_files)
|
49
|
+
analyzer.annotate_targets!
|
50
|
+
|
51
|
+
puts analyzer.format_changes if @print_changes
|
52
|
+
|
53
|
+
return unless @scheme
|
54
|
+
|
55
|
+
analyzer.filtered_scheme(scheme_path: @scheme, log_changes: @print_scheme_changes, filter_scheme_for_build_action: @filter_scheme_for_build_action)
|
56
|
+
.save_as(@scheme.gsub(%r{\.(xcodeproj|xcworkspace)/.+}, '.\1'), File.basename(@scheme, '.xcscheme'), true)
|
57
|
+
end
|
58
|
+
|
59
|
+
def validate!
|
60
|
+
super
|
61
|
+
|
62
|
+
File.directory?(@repository) || help!("Unable to find a repository at #{@repository.inspect}")
|
63
|
+
|
64
|
+
@workspace || help!('Must specify a project or workspace path')
|
65
|
+
File.directory?(@workspace) || help!("Unable to find a project or workspace at #{@workspace.inspect}")
|
66
|
+
|
67
|
+
@augmenting_paths_yaml_files = @augmenting_paths_yaml_files.split(',')
|
68
|
+
@augmenting_paths_yaml_files.each do |yaml_path|
|
69
|
+
yaml_path = File.join(@repository, yaml_path)
|
70
|
+
File.file?(yaml_path) || help!("Unable to find a YAML file at #{yaml_path.inspect}")
|
71
|
+
|
72
|
+
require 'yaml'
|
73
|
+
begin
|
74
|
+
YAML.safe_load_file(yaml_path)
|
75
|
+
rescue StandardError => e
|
76
|
+
help! "Failed to load YAML file at #{yaml_path.inspect} (#{e})"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
File.file?(@scheme) || help!("Unabled to find a scheme at #{@scheme.inspect}") if @scheme
|
81
|
+
|
82
|
+
@change_level =
|
83
|
+
case @change_level
|
84
|
+
when 'full-transitive' then :full_transitive
|
85
|
+
when 'itself' then :itself
|
86
|
+
when /\A\d+\z/ then [:at_most_n_away, @change_level.to_i]
|
87
|
+
else help! "Unknown change level #{@change_level.inspect}"
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# @visibility private
|
92
|
+
class Git < CLI
|
93
|
+
self.summary = 'Generates a list of Xcode targets to build & test as a result of a git diff'
|
94
|
+
|
95
|
+
def self.options
|
96
|
+
super + [
|
97
|
+
['--base-revision=SHA', 'Base revision to compute the git diff against']
|
98
|
+
]
|
99
|
+
end
|
100
|
+
|
101
|
+
def initialize(argv)
|
102
|
+
@base_revision = argv.option('base-revision')
|
103
|
+
|
104
|
+
super
|
105
|
+
end
|
106
|
+
|
107
|
+
def validate!
|
108
|
+
super
|
109
|
+
|
110
|
+
@base_revision || help!('Must specify a base revision')
|
111
|
+
end
|
112
|
+
|
113
|
+
private
|
114
|
+
|
115
|
+
def compute_changeset
|
116
|
+
Sq::Refinement::Changeset.from_git(repository: Pathname(@repository), base_revision: @base_revision)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
@@ -0,0 +1,129 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module Sq
|
6
|
+
module Refinement
|
7
|
+
# Called after CocoaPods installation to write an augmenting file that
|
8
|
+
# takes into account changes to Pod configuration,
|
9
|
+
# as well as the globs used by podspecs to search for files
|
10
|
+
class CocoaPodsPostInstallWriter
|
11
|
+
attr_reader :aggregate_targets, :pod_targets, :config, :repo, :options
|
12
|
+
private :aggregate_targets, :pod_targets, :config, :repo, :options
|
13
|
+
|
14
|
+
# Initializes a post-install writer with CocoaPods target objects.
|
15
|
+
# @return [CocoaPodsPostInstallWriter] a new instance of CocoaPodsPostInstallWriter
|
16
|
+
# @param aggregate_targets [Array<Pod::AggregateTarget>]
|
17
|
+
# @param pod_targets [Array<Pod::PodTarget>]
|
18
|
+
# @param config [Pod::Config]
|
19
|
+
# @param options [Hash]
|
20
|
+
def initialize(aggregate_targets, pod_targets, config, options)
|
21
|
+
@aggregate_targets = aggregate_targets
|
22
|
+
@pod_targets = pod_targets
|
23
|
+
@config = config
|
24
|
+
@repo = config.installation_root
|
25
|
+
@options = options || {}
|
26
|
+
end
|
27
|
+
|
28
|
+
# Writes the refinement augmenting file to the configured path
|
29
|
+
# @return [Void]
|
30
|
+
def write!
|
31
|
+
write_file options.fetch('output_path', config.sandbox.root.join('pods_refinement.json')), paths_by_target_name
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def write_file(path, hash)
|
37
|
+
File.open(path, 'w') do |f|
|
38
|
+
f << JSON.generate(hash) << "\n"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def paths_by_target_name
|
43
|
+
targets = {}
|
44
|
+
aggregate_targets.each do |aggregate_target|
|
45
|
+
targets[aggregate_target.label] = paths_for_aggregate_target(aggregate_target)
|
46
|
+
end
|
47
|
+
pod_targets.each do |pod_target|
|
48
|
+
targets.merge! paths_for_pod_targets(pod_target)
|
49
|
+
end
|
50
|
+
targets
|
51
|
+
end
|
52
|
+
|
53
|
+
def paths_for_aggregate_target(aggregate_target)
|
54
|
+
paths = []
|
55
|
+
if (podfile_path = aggregate_target.podfile.defined_in_file)
|
56
|
+
paths << { path: podfile_path.relative_path_from(repo), inclusion_reason: 'Podfile' }
|
57
|
+
end
|
58
|
+
if (user_project_path = aggregate_target.user_project_path)
|
59
|
+
paths << { path: user_project_path.relative_path_from(repo), inclusion_reason: 'user project' }
|
60
|
+
end
|
61
|
+
paths
|
62
|
+
end
|
63
|
+
|
64
|
+
def library_specification?(spec)
|
65
|
+
# Backwards compatibility
|
66
|
+
if spec.respond_to?(:library_specification?, false)
|
67
|
+
spec.library_specification?
|
68
|
+
else
|
69
|
+
!spec.test_specification?
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def specification_paths_from_pod_target(pod_target)
|
74
|
+
pod_target
|
75
|
+
.target_definitions
|
76
|
+
.map(&:podfile)
|
77
|
+
.uniq
|
78
|
+
.flat_map do |podfile|
|
79
|
+
podfile
|
80
|
+
.dependencies
|
81
|
+
.select { |d| d.root_name == pod_target.pod_name }
|
82
|
+
.map { |d| (d.external_source || {})[:path] }
|
83
|
+
.compact
|
84
|
+
end.uniq
|
85
|
+
end
|
86
|
+
|
87
|
+
def paths_for_pod_targets(pod_target)
|
88
|
+
file_accessors_by_target_name = pod_target.file_accessors.group_by do |fa|
|
89
|
+
if library_specification?(fa.spec)
|
90
|
+
pod_target.label
|
91
|
+
elsif pod_target.respond_to?(:non_library_spec_label)
|
92
|
+
pod_target.non_library_spec_label(fa.spec)
|
93
|
+
else
|
94
|
+
pod_target.test_target_label(fa.spec)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
pod_dir = pod_target.sandbox.pod_dir(pod_target.pod_name).relative_path_from(repo)
|
99
|
+
|
100
|
+
spec_paths = specification_paths_from_pod_target(pod_target)
|
101
|
+
|
102
|
+
file_accessors_by_target_name.each_with_object({}) do |(label, file_accessors), h|
|
103
|
+
paths = [
|
104
|
+
{ path: 'Podfile.lock',
|
105
|
+
inclusion_reason: 'CocoaPods lockfile',
|
106
|
+
yaml_keypath: ['SPEC CHECKSUMS', pod_target.pod_name] }
|
107
|
+
]
|
108
|
+
if pod_target.sandbox.predownloaded?(pod_target.pod_name)
|
109
|
+
paths << { path: 'Podfile.lock', inclusion_reason: 'Dependency external source', yaml_keypath: ['EXTERNAL SOURCES', pod_target.pod_name] }
|
110
|
+
paths << { path: 'Podfile.lock', inclusion_reason: 'Pod checkout options', yaml_keypath: ['CHECKOUT OPTIONS', pod_target.pod_name] }
|
111
|
+
end
|
112
|
+
spec_paths.each { |path| paths << { path:, inclusion_reason: 'podspec' } }
|
113
|
+
|
114
|
+
Pod::Validator::FILE_PATTERNS.each do |pattern|
|
115
|
+
file_accessors.each do |fa|
|
116
|
+
globs = fa.spec_consumer.send(pattern)
|
117
|
+
globs = globs.values.flatten if globs.is_a?(Hash)
|
118
|
+
globs.each do |glob|
|
119
|
+
paths << { glob: pod_dir.join(glob), inclusion_reason: pattern.to_s.tr('_', ' ').chomp('s') }
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
h[label] = paths.uniq
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Sq
|
4
|
+
# Generates a list of Xcode targets to build & test as a result of a git diff.
|
5
|
+
module Refinement
|
6
|
+
class Error < StandardError; end
|
7
|
+
|
8
|
+
# @visibility private
|
9
|
+
# @param enum [Enumerable]
|
10
|
+
# Enumerates through `enum`, and applied the given block to each element.
|
11
|
+
# If the result of calling the block is truthy, the first such result is returned.
|
12
|
+
# If no such result is found, `nil` is returned.
|
13
|
+
def self.map_find(enum)
|
14
|
+
enum.each do |elem|
|
15
|
+
transformed = yield elem
|
16
|
+
return transformed if transformed
|
17
|
+
end
|
18
|
+
|
19
|
+
nil
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|