kintsugi 0.6.3 → 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/.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 +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 +605 -113
- 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}
|