kintsugi 0.6.2 → 0.7.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/.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 +210 -116
- data/lib/kintsugi/cli.rb +25 -8
- 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 +686 -108
- data/spec/kintsugi_integration_spec.rb +2 -1
- metadata +19 -3
data/lib/kintsugi/cli.rb
CHANGED
@@ -33,23 +33,31 @@ module Kintsugi
|
|
33
33
|
def create_driver_subcommand
|
34
34
|
option_parser =
|
35
35
|
OptionParser.new do |opts|
|
36
|
-
opts.banner = "Usage: kintsugi driver BASE OURS THEIRS ORIGINAL_FILE_PATH\n" \
|
36
|
+
opts.banner = "Usage: kintsugi driver BASE OURS THEIRS ORIGINAL_FILE_PATH [options]\n" \
|
37
37
|
"Uses Kintsugi as a Git merge driver. Parameters " \
|
38
38
|
"should be the path to base version of the file, path to ours version, path to " \
|
39
39
|
"theirs version, and the original file path."
|
40
40
|
|
41
|
+
opts.on("--interactive-resolution=FLAG", TrueClass, "In case a conflict that requires " \
|
42
|
+
"human decision to resolve, show an interactive prompt with choices to resolve it")
|
43
|
+
|
41
44
|
opts.on("-h", "--help", "Prints this help") do
|
42
45
|
puts opts
|
43
46
|
exit
|
44
47
|
end
|
45
48
|
end
|
46
49
|
|
47
|
-
driver_action = lambda { |
|
50
|
+
driver_action = lambda { |options, arguments|
|
48
51
|
if arguments.count != 4
|
49
52
|
puts "Incorrect number of arguments to 'driver' subcommand\n\n"
|
50
53
|
puts option_parser
|
51
54
|
exit(1)
|
52
55
|
end
|
56
|
+
|
57
|
+
unless options[:"interactive-resolution"].nil?
|
58
|
+
Settings.interactive_resolution = options[:"interactive-resolution"]
|
59
|
+
end
|
60
|
+
|
53
61
|
Kintsugi.three_way_merge(arguments[0], arguments[1], arguments[2], arguments[3])
|
54
62
|
warn "\e[32mKintsugi auto-merged #{arguments[3]}\e[0m"
|
55
63
|
}
|
@@ -64,8 +72,9 @@ module Kintsugi
|
|
64
72
|
def create_install_driver_subcommand
|
65
73
|
option_parser =
|
66
74
|
OptionParser.new do |opts|
|
67
|
-
opts.banner = "Usage: kintsugi install-driver\n" \
|
68
|
-
"Installs Kintsugi as a Git merge driver globally. "
|
75
|
+
opts.banner = "Usage: kintsugi install-driver [driver-options]\n" \
|
76
|
+
"Installs Kintsugi as a Git merge driver globally. `driver-options` will be passed " \
|
77
|
+
"to `kintsugi driver`."
|
69
78
|
|
70
79
|
opts.on("-h", "--help", "Prints this help") do
|
71
80
|
puts opts
|
@@ -73,7 +82,7 @@ module Kintsugi
|
|
73
82
|
end
|
74
83
|
end
|
75
84
|
|
76
|
-
action = lambda { |
|
85
|
+
action = lambda { |options, arguments|
|
77
86
|
if arguments.count != 0
|
78
87
|
puts "Incorrect number of arguments to 'install-driver' subcommand\n\n"
|
79
88
|
puts option_parser
|
@@ -85,7 +94,7 @@ module Kintsugi
|
|
85
94
|
exit(1)
|
86
95
|
end
|
87
96
|
|
88
|
-
install_kintsugi_driver_globally
|
97
|
+
install_kintsugi_driver_globally(options)
|
89
98
|
puts "Done! 🪄"
|
90
99
|
}
|
91
100
|
|
@@ -96,9 +105,10 @@ module Kintsugi
|
|
96
105
|
)
|
97
106
|
end
|
98
107
|
|
99
|
-
def install_kintsugi_driver_globally
|
108
|
+
def install_kintsugi_driver_globally(options)
|
100
109
|
`git config --global merge.kintsugi.name "Kintsugi driver"`
|
101
|
-
|
110
|
+
kintsugi_command = "kintsugi driver %O %A %B %P #{options}"
|
111
|
+
`git config --global merge.kintsugi.driver "#{kintsugi_command}"`
|
102
112
|
|
103
113
|
attributes_file_path = global_attributes_file_path
|
104
114
|
FileUtils.mkdir_p(File.dirname(attributes_file_path))
|
@@ -185,6 +195,9 @@ module Kintsugi
|
|
185
195
|
|
186
196
|
opts.on("--allow-duplicates", "Allow to add duplicates of the same entity")
|
187
197
|
|
198
|
+
opts.on("--interactive-resolution=FLAG", TrueClass, "In case a conflict that requires " \
|
199
|
+
"human decision to resolve, show an interactive prompt with choices to resolve it")
|
200
|
+
|
188
201
|
opts.on_tail("\nSUBCOMMANDS\n#{subcommands_descriptions(subcommands)}")
|
189
202
|
end
|
190
203
|
|
@@ -199,6 +212,10 @@ module Kintsugi
|
|
199
212
|
Settings.allow_duplicates = true
|
200
213
|
end
|
201
214
|
|
215
|
+
unless options[:"interactive-resolution"].nil?
|
216
|
+
Settings.interactive_resolution = options[:"interactive-resolution"]
|
217
|
+
end
|
218
|
+
|
202
219
|
project_file_path = File.expand_path(arguments[0])
|
203
220
|
Kintsugi.resolve_conflicts(project_file_path, options[:"changes-output-path"])
|
204
221
|
puts "Resolved conflicts successfully"
|
@@ -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}
|