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.
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
- OptionParser.new do |opts|
36
- opts.banner = "Usage: kintsugi driver BASE OURS THEIRS ORIGINAL_FILE_PATH\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
- opts.on("-h", "--help", "Prints this help") do
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
- OptionParser.new do |opts|
67
- opts.banner = "Usage: kintsugi install-driver\n" \
68
- "Installs Kintsugi as a Git merge driver globally. "
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
- opts.on("-h", "--help", "Prints this help") do
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
- install_kintsugi_driver_globally
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 install_kintsugi_driver_globally
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
- `git config --global merge.kintsugi.driver "kintsugi driver %O %A %B %P"`
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 = OptionParser.new do |opts|
167
- opts.banner = "Kintsugi, version #{Version::STRING}\n" \
168
- "Copyright (c) 2021 Lightricks\n\n" \
169
- "Usage: kintsugi <pbxproj_filepath> [options]\n" \
170
- " kintsugi <subcommand> [options]"
171
-
172
- opts.separator ""
173
- opts.on("--changes-output-path=PATH", "Path to which changes applied to the project are " \
174
- "written in JSON format. Used for debug purposes.")
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
- opts.on("--allow-duplicates", "Allow to add duplicates of the same entity")
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
- opts.on_tail("\nSUBCOMMANDS\n#{subcommands_descriptions(subcommands)}")
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
- if options[:"allow-duplicates"]
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
@@ -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
- apply_change_to_project(project, change)
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
@@ -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
@@ -3,6 +3,6 @@
3
3
  module Kintsugi
4
4
  # This module holds the Kintsugi version information.
5
5
  module Version
6
- STRING = "0.6.3"
6
+ STRING = "0.7.1"
7
7
  end
8
8
  end
@@ -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}