kintsugi 0.6.3 → 0.7.1

Sign up to get free protection for your applications and to get access to all the features.
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}