kintsugi 0.6.3 → 0.7.0

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