kintsugi 0.6.3 → 0.7.1
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/.github/workflows/release.yml +1 -1
- data/.github/workflows/tests.yml +1 -1
- data/README.md +10 -0
- data/assets/interactive-conflict-resolution.png +0 -0
- data/kintsugi.gemspec +1 -0
- data/lib/kintsugi/apply_change_to_project.rb +204 -122
- data/lib/kintsugi/cli.rb +83 -53
- data/lib/kintsugi/conflict_resolver.rb +130 -0
- data/lib/kintsugi/merge.rb +7 -4
- data/lib/kintsugi/settings.rb +8 -0
- data/lib/kintsugi/version.rb +1 -1
- data/lib/kintsugi/xcodeproj_extensions.rb +49 -1
- data/spec/kintsugi_apply_change_to_project_spec.rb +605 -113
- data/spec/kintsugi_integration_spec.rb +2 -1
- metadata +19 -3
data/lib/kintsugi/cli.rb
CHANGED
@@ -31,25 +31,22 @@ module Kintsugi
|
|
31
31
|
Command = Struct.new(:option_parser, :action, :description, keyword_init: true)
|
32
32
|
|
33
33
|
def create_driver_subcommand
|
34
|
-
option_parser =
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
puts opts
|
43
|
-
exit
|
44
|
-
end
|
45
|
-
end
|
46
|
-
|
47
|
-
driver_action = lambda { |_, arguments|
|
34
|
+
option_parser = create_base_option_parser
|
35
|
+
option_parser.banner = "Usage: kintsugi driver BASE OURS THEIRS ORIGINAL_FILE_PATH " \
|
36
|
+
"[options]\n" \
|
37
|
+
"Uses Kintsugi as a Git merge driver. Parameters " \
|
38
|
+
"should be the path to base version of the file, path to ours version, path to " \
|
39
|
+
"theirs version, and the original file path."
|
40
|
+
|
41
|
+
driver_action = lambda { |options, arguments|
|
48
42
|
if arguments.count != 4
|
49
43
|
puts "Incorrect number of arguments to 'driver' subcommand\n\n"
|
50
44
|
puts option_parser
|
51
45
|
exit(1)
|
52
46
|
end
|
47
|
+
|
48
|
+
update_settings(options)
|
49
|
+
|
53
50
|
Kintsugi.three_way_merge(arguments[0], arguments[1], arguments[2], arguments[3])
|
54
51
|
warn "\e[32mKintsugi auto-merged #{arguments[3]}\e[0m"
|
55
52
|
}
|
@@ -62,18 +59,12 @@ module Kintsugi
|
|
62
59
|
end
|
63
60
|
|
64
61
|
def create_install_driver_subcommand
|
65
|
-
option_parser =
|
66
|
-
|
67
|
-
|
68
|
-
|
62
|
+
option_parser = create_base_option_parser
|
63
|
+
option_parser.banner = "Usage: kintsugi install-driver [driver-options]\n" \
|
64
|
+
"Installs Kintsugi as a Git merge driver globally. `driver-options` will be passed " \
|
65
|
+
"to `kintsugi driver`."
|
69
66
|
|
70
|
-
|
71
|
-
puts opts
|
72
|
-
exit
|
73
|
-
end
|
74
|
-
end
|
75
|
-
|
76
|
-
action = lambda { |_, arguments|
|
67
|
+
action = lambda { |options, arguments|
|
77
68
|
if arguments.count != 0
|
78
69
|
puts "Incorrect number of arguments to 'install-driver' subcommand\n\n"
|
79
70
|
puts option_parser
|
@@ -85,7 +76,9 @@ module Kintsugi
|
|
85
76
|
exit(1)
|
86
77
|
end
|
87
78
|
|
88
|
-
|
79
|
+
driver_options = extract_driver_options(options, option_parser)
|
80
|
+
|
81
|
+
install_kintsugi_driver_globally(driver_options)
|
89
82
|
puts "Done! 🪄"
|
90
83
|
}
|
91
84
|
|
@@ -96,9 +89,31 @@ module Kintsugi
|
|
96
89
|
)
|
97
90
|
end
|
98
91
|
|
99
|
-
def
|
92
|
+
def extract_driver_options(options, option_parser)
|
93
|
+
options.map do |option_name, option_value|
|
94
|
+
switch = option_parser.top.search(:long, option_name.to_s)
|
95
|
+
prefix = "--"
|
96
|
+
if switch.nil?
|
97
|
+
switch = option_parser.top.search(:short, option_name.to_s)
|
98
|
+
prefix = "-"
|
99
|
+
end
|
100
|
+
case switch
|
101
|
+
when OptionParser::Switch::NoArgument
|
102
|
+
[prefix + option_name.to_s]
|
103
|
+
when NilClass
|
104
|
+
puts "Invalid flag #{option_name} passed to 'install-driver' subcommand\n\n"
|
105
|
+
puts option_parser
|
106
|
+
exit(1)
|
107
|
+
else
|
108
|
+
[prefix + option_name.to_s, option_value]
|
109
|
+
end
|
110
|
+
end.flatten
|
111
|
+
end
|
112
|
+
|
113
|
+
def install_kintsugi_driver_globally(options)
|
100
114
|
`git config --global merge.kintsugi.name "Kintsugi driver"`
|
101
|
-
|
115
|
+
kintsugi_command = "kintsugi driver %O %A %B %P #{options.join(" ")}".rstrip
|
116
|
+
`git config --global merge.kintsugi.driver "#{kintsugi_command}"`
|
102
117
|
|
103
118
|
attributes_file_path = global_attributes_file_path
|
104
119
|
FileUtils.mkdir_p(File.dirname(attributes_file_path))
|
@@ -163,30 +178,21 @@ module Kintsugi
|
|
163
178
|
end
|
164
179
|
|
165
180
|
def create_root_command
|
166
|
-
option_parser =
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
opts.on("-h", "--help", "Prints this help") do
|
177
|
-
puts opts
|
178
|
-
exit
|
179
|
-
end
|
180
|
-
|
181
|
-
opts.on("-v", "--version", "Prints version") do
|
182
|
-
puts Version::STRING
|
183
|
-
exit
|
184
|
-
end
|
181
|
+
option_parser = create_base_option_parser
|
182
|
+
option_parser.banner = "Kintsugi, version #{Version::STRING}\n" \
|
183
|
+
"Copyright (c) 2021 Lightricks\n\n" \
|
184
|
+
"Usage: kintsugi <pbxproj_filepath> [options]\n" \
|
185
|
+
" kintsugi <subcommand> [options]"
|
186
|
+
|
187
|
+
option_parser.on("-v", "--version", "Prints version") do
|
188
|
+
puts Version::STRING
|
189
|
+
exit
|
190
|
+
end
|
185
191
|
|
186
|
-
|
192
|
+
option_parser.on("--changes-output-path=PATH", "Path to which changes applied to the " \
|
193
|
+
"project are written in JSON format. Used for debug purposes.")
|
187
194
|
|
188
|
-
|
189
|
-
end
|
195
|
+
option_parser.on_tail("\nSUBCOMMANDS\n#{subcommands_descriptions(subcommands)}")
|
190
196
|
|
191
197
|
root_action = lambda { |options, arguments|
|
192
198
|
if arguments.count != 1
|
@@ -195,9 +201,7 @@ module Kintsugi
|
|
195
201
|
exit(1)
|
196
202
|
end
|
197
203
|
|
198
|
-
|
199
|
-
Settings.allow_duplicates = true
|
200
|
-
end
|
204
|
+
update_settings(options)
|
201
205
|
|
202
206
|
project_file_path = File.expand_path(arguments[0])
|
203
207
|
Kintsugi.resolve_conflicts(project_file_path, options[:"changes-output-path"])
|
@@ -211,6 +215,32 @@ module Kintsugi
|
|
211
215
|
)
|
212
216
|
end
|
213
217
|
|
218
|
+
def create_base_option_parser
|
219
|
+
OptionParser.new do |opts|
|
220
|
+
opts.separator ""
|
221
|
+
|
222
|
+
opts.on("-h", "--help", "Prints this help") do
|
223
|
+
puts opts
|
224
|
+
exit
|
225
|
+
end
|
226
|
+
|
227
|
+
opts.on("--allow-duplicates", "Allow to add duplicates of the same entity")
|
228
|
+
|
229
|
+
opts.on("--interactive-resolution=FLAG", TrueClass, "In case a conflict that requires " \
|
230
|
+
"human decision to resolve, show an interactive prompt with choices to resolve it")
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
def update_settings(options)
|
235
|
+
if options[:"allow-duplicates"]
|
236
|
+
Settings.allow_duplicates = true
|
237
|
+
end
|
238
|
+
|
239
|
+
unless options[:"interactive-resolution"].nil?
|
240
|
+
Settings.interactive_resolution = options[:"interactive-resolution"]
|
241
|
+
end
|
242
|
+
end
|
243
|
+
|
214
244
|
def subcommands_descriptions(subcommands)
|
215
245
|
longest_subcommand_length = subcommands.keys.map(&:length).max + 4
|
216
246
|
format_string = " %-#{longest_subcommand_length}s%s"
|
@@ -0,0 +1,130 @@
|
|
1
|
+
# Copyright (c) 2023 Lightricks. All rights reserved.
|
2
|
+
# Created by Ben Yohay.
|
3
|
+
# frozen_string_literal: true
|
4
|
+
|
5
|
+
require "tty-prompt"
|
6
|
+
|
7
|
+
module Kintsugi
|
8
|
+
class ConflictResolver
|
9
|
+
class << self
|
10
|
+
# Should be called when trying to add a subgroup with name `subgroup_name` whose containing
|
11
|
+
# group with path `containing_group_path` doesn't exist. Returns `true` if the cotaining
|
12
|
+
# group should be created and `false` if adding the subgroup should be ignored.
|
13
|
+
def create_nonexistent_group_when_adding_subgroup?(containing_group_path, subgroup_name)
|
14
|
+
resolve_merge_error(
|
15
|
+
"Trying to create group '#{subgroup_name}' inside a group that doesn't exist. The " \
|
16
|
+
"group's path is '#{containing_group_path}'",
|
17
|
+
{
|
18
|
+
"Create containing group with path '#{containing_group_path}'": true,
|
19
|
+
"Ignore adding group '#{subgroup_name}'": false
|
20
|
+
}
|
21
|
+
)
|
22
|
+
end
|
23
|
+
|
24
|
+
# Should be called when trying to add a file with name `file_name` whose containing group
|
25
|
+
# with path `containing_group_path` doesn't exist. Returns `true` if the cotaining group
|
26
|
+
# should be created and `false` if adding the file should be ignored.
|
27
|
+
def create_nonexistent_group_when_adding_file?(containing_group_path, file_name)
|
28
|
+
resolve_merge_error(
|
29
|
+
"Trying to add or move a file with name '#{file_name}' to a group that doesn't exist. " \
|
30
|
+
"The group's path is '#{containing_group_path}'",
|
31
|
+
{
|
32
|
+
"Create group with path '#{containing_group_path}'": true,
|
33
|
+
"Ignore adding file '#{file_name}'": false
|
34
|
+
}
|
35
|
+
)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Should be called when trying to apply changes to a component with path `path` that doesn't
|
39
|
+
# exist. Returns `true` if the component should be created and `false` if applying the changes
|
40
|
+
# to it should be ignored.
|
41
|
+
def create_nonexistent_component_when_changing_it?(path)
|
42
|
+
resolve_merge_error(
|
43
|
+
"Trying to apply change to a component that doesn't exist at path '#{path}'",
|
44
|
+
{
|
45
|
+
'Create component and the components that contain it': true,
|
46
|
+
'Ignore change to component': false
|
47
|
+
}
|
48
|
+
)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Should be called when trying to merge `new_hash` into `new_hash` but `new_hash` contains
|
52
|
+
# keys that exist in `old_hash`. Returns `true` if the keys should be overriden from
|
53
|
+
# `new_hash`, `false` to keep the values from `old_hash`.
|
54
|
+
def override_values_when_keys_already_exist_in_hash?(hash_name, old_hash, new_hash)
|
55
|
+
resolve_merge_error(
|
56
|
+
"Trying to add values to hash of attribute named '#{hash_name}': Merging hash " \
|
57
|
+
"#{new_hash} into existing hash #{old_hash} but it contains values that already " \
|
58
|
+
"exist",
|
59
|
+
{
|
60
|
+
'Override values from new hash': true,
|
61
|
+
'Ignore values from new hash': false
|
62
|
+
}
|
63
|
+
)
|
64
|
+
end
|
65
|
+
|
66
|
+
# Should be called when trying to remove entries from a hash of an attribute named
|
67
|
+
# `hash_name`. The values of those entries were expected to be `expected_values` but instead
|
68
|
+
# they are `actual_values`. Returns `true` if the entries should be removed anyway, `false`
|
69
|
+
# to keep the entries.
|
70
|
+
def remove_entries_when_unexpected_values_in_hash?(hash_name, expected_values, actual_values)
|
71
|
+
resolve_merge_error(
|
72
|
+
"Trying to remove entries from hash of attribute named '#{hash_name}': Expected values " \
|
73
|
+
"for keys to be '#{expected_values}' but the existing values are '#{actual_values}'",
|
74
|
+
{
|
75
|
+
'Remove entries anyway': true,
|
76
|
+
'Keep entries': false
|
77
|
+
}
|
78
|
+
)
|
79
|
+
end
|
80
|
+
|
81
|
+
# Should be called when setting a string named `string_name` to value `new_value` and its
|
82
|
+
# expected value is `expected_value` but it has a value of `actual_value`. Returns `true` if
|
83
|
+
# the string should be set to `new_value`, `false` if the `actual_value` should remain.
|
84
|
+
def set_value_to_string_when_unxpected_value?(
|
85
|
+
string_name, new_value, expected_value, actual_value
|
86
|
+
)
|
87
|
+
resolve_merge_error(
|
88
|
+
"Trying to change value of attribute named '#{string_name} from '#{new_value}' to " \
|
89
|
+
"'#{expected_value || "nil"}', but the existing value is '#{actual_value}'",
|
90
|
+
{
|
91
|
+
"Set to new value '#{new_value}'": true,
|
92
|
+
"Keep existing value '#{actual_value}'": false
|
93
|
+
}
|
94
|
+
)
|
95
|
+
end
|
96
|
+
|
97
|
+
# Should be called when trying to remove a `component` who's expected to have a hash equal to
|
98
|
+
# `change` but they are not equal. Returns `true` if `component` should be removed anyway,
|
99
|
+
# `false` otherwise.
|
100
|
+
def remove_component_when_unexpected_hash?(component, change)
|
101
|
+
resolve_merge_error(
|
102
|
+
"Trying to remove a component named '#{component.display_name}': Expected hash of " \
|
103
|
+
"#{change} but its hash is #{component.to_tree_hash}",
|
104
|
+
{
|
105
|
+
'Remove object anyway': true,
|
106
|
+
'Keep object': false
|
107
|
+
}
|
108
|
+
)
|
109
|
+
end
|
110
|
+
|
111
|
+
private
|
112
|
+
|
113
|
+
def resolve_merge_error(message, options)
|
114
|
+
unless Settings.interactive_resolution
|
115
|
+
raise MergeError, "Merge error: #{message}"
|
116
|
+
end
|
117
|
+
|
118
|
+
prompt = TTY::Prompt.new
|
119
|
+
options = options.merge(
|
120
|
+
{Abort: -> { raise MergeError, "Merge error: #{message}" }}
|
121
|
+
)
|
122
|
+
|
123
|
+
prompt.select(
|
124
|
+
"A merge conflict that needs manual intervention occurred: #{message}. Choose one:",
|
125
|
+
options
|
126
|
+
)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
data/lib/kintsugi/merge.rb
CHANGED
@@ -42,7 +42,8 @@ module Kintsugi
|
|
42
42
|
File.write(changes_output_path, JSON.pretty_generate(change))
|
43
43
|
end
|
44
44
|
|
45
|
-
apply_change_and_copy_to_original_path(ours_project, change, project_file_path
|
45
|
+
apply_change_and_copy_to_original_path(ours_project, change, project_file_path,
|
46
|
+
theirs_project)
|
46
47
|
end
|
47
48
|
|
48
49
|
# Merges the changes done between `theirs_project_path` and `base_project_path` to the file at
|
@@ -82,15 +83,17 @@ module Kintsugi
|
|
82
83
|
Xcodeproj::Differ.project_diff(theirs_temporary_project, base_temporary_project,
|
83
84
|
:added, :removed)
|
84
85
|
|
85
|
-
apply_change_and_copy_to_original_path(ours_temporary_project, change, ours_project_path
|
86
|
+
apply_change_and_copy_to_original_path(ours_temporary_project, change, ours_project_path,
|
87
|
+
theirs_temporary_project)
|
86
88
|
end
|
87
89
|
|
88
90
|
private
|
89
91
|
|
90
92
|
PROJECT_FILE_NAME = "project.pbxproj"
|
91
93
|
|
92
|
-
def apply_change_and_copy_to_original_path(project, change, original_project_file_path
|
93
|
-
|
94
|
+
def apply_change_and_copy_to_original_path(project, change, original_project_file_path,
|
95
|
+
theirs_project)
|
96
|
+
apply_change_to_project(project, change, theirs_project)
|
94
97
|
project.save
|
95
98
|
FileUtils.cp(File.join(project.path, PROJECT_FILE_NAME), original_project_file_path)
|
96
99
|
end
|
data/lib/kintsugi/settings.rb
CHANGED
@@ -10,9 +10,17 @@ module Kintsugi
|
|
10
10
|
# otherwise.
|
11
11
|
attr_writer :allow_duplicates
|
12
12
|
|
13
|
+
# `true` if Kintsugi should ask the user for guide on how to resolve some conflicts
|
14
|
+
# interactively, `false` if an error should be thrown instead.
|
15
|
+
attr_writer :interactive_resolution
|
16
|
+
|
13
17
|
def allow_duplicates
|
14
18
|
@allow_duplicates || false
|
15
19
|
end
|
20
|
+
|
21
|
+
def interactive_resolution
|
22
|
+
@interactive_resolution.nil? ? $stdout.isatty : @interactive_resolution
|
23
|
+
end
|
16
24
|
end
|
17
25
|
end
|
18
26
|
end
|
data/lib/kintsugi/version.rb
CHANGED
@@ -6,6 +6,16 @@ require "xcodeproj"
|
|
6
6
|
|
7
7
|
module Xcodeproj
|
8
8
|
class Project
|
9
|
+
# Returns the group found at `path`. If `path` is empty returns the main group. Returns `nil` if
|
10
|
+
# the group at path was not found.
|
11
|
+
#
|
12
|
+
# @param [String] Path to the group.
|
13
|
+
#
|
14
|
+
# @return [PBXGroup/PBXVariantGroup/PBXFileReference]
|
15
|
+
def group_or_file_at_path(path)
|
16
|
+
path.empty? ? self.main_group : self[path]
|
17
|
+
end
|
18
|
+
|
9
19
|
# Extends `ObjectDictionary` to act like an `Object` if `self` repreresents a project reference.
|
10
20
|
class ObjectDictionary
|
11
21
|
@@old_to_tree_hash = instance_method(:to_tree_hash)
|
@@ -29,6 +39,45 @@ module Xcodeproj
|
|
29
39
|
end
|
30
40
|
|
31
41
|
module Object
|
42
|
+
# Modifies `PBXContainerItemProxy` to include relevant data in `displayName`.
|
43
|
+
# Currently, its `display_name` is just a constant for all `PBXContainerItemProxy` objects.
|
44
|
+
class PBXContainerItemProxy
|
45
|
+
def display_name
|
46
|
+
"#{self.remote_info} (#{self.remote_global_id_string})"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# Modifies `PBXReferenceProxy` to include more data in `displayName` to make it unique.
|
51
|
+
class PBXReferenceProxy
|
52
|
+
@@old_display_name = instance_method(:display_name)
|
53
|
+
|
54
|
+
def display_name
|
55
|
+
if self.remote_ref.nil?
|
56
|
+
@@old_display_name.bind(self).call
|
57
|
+
else
|
58
|
+
@@old_display_name.bind(self).call + " - " + self.remote_ref.display_name
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
# Modifies `PBXBuildFile` to calculate `ascii_plist_annotation` based on the underlying
|
64
|
+
# object's `ascii_plist_annotation` instead of relying on its `display_name`, as
|
65
|
+
# `display_name` might contain information that shouldn't be written to the project.
|
66
|
+
class PBXBuildFile
|
67
|
+
def ascii_plist_annotation
|
68
|
+
underlying_annotation =
|
69
|
+
if product_ref
|
70
|
+
product_ref.ascii_plist_annotation
|
71
|
+
elsif file_ref
|
72
|
+
file_ref.ascii_plist_annotation
|
73
|
+
else
|
74
|
+
super
|
75
|
+
end
|
76
|
+
|
77
|
+
" #{underlying_annotation.strip} in #{GroupableHelper.parent(self).display_name} "
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
32
81
|
# Extends `XCBuildConfiguration` to convert array settings (which might be either array or
|
33
82
|
# string) to actual arrays in `to_tree_hash` so diffs are always between arrays. This means
|
34
83
|
# that if the values in both `ours` and `theirs` are different strings, we will know to solve
|
@@ -128,7 +177,6 @@ module Xcodeproj
|
|
128
177
|
# Second array to the difference operation.
|
129
178
|
#
|
130
179
|
# @return [Array]
|
131
|
-
#
|
132
180
|
def self.array_non_unique_diff(value_1, value_2)
|
133
181
|
value_2_elements_by_count = value_2.reduce({}) do |hash, element|
|
134
182
|
updated_element_hash = hash.key?(element) ? {element => hash[element] + 1} : {element => 1}
|