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.
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 { |_, arguments|
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 { |_, arguments|
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
- `git config --global merge.kintsugi.driver "kintsugi driver %O %A %B %P"`
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
@@ -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.2"
6
+ STRING = "0.7.0"
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}