kintsugi 0.5.2 → 0.6.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
@@ -5,6 +5,7 @@
5
5
  require "fileutils"
6
6
  require "optparse"
7
7
 
8
+ require_relative "settings"
8
9
  require_relative "version"
9
10
 
10
11
  module Kintsugi
@@ -50,6 +51,7 @@ module Kintsugi
50
51
  exit(1)
51
52
  end
52
53
  Kintsugi.three_way_merge(arguments[0], arguments[1], arguments[2], arguments[3])
54
+ warn "\e[32mKintsugi auto-merged #{arguments[3]}\e[0m"
53
55
  }
54
56
 
55
57
  Command.new(
@@ -181,6 +183,8 @@ module Kintsugi
181
183
  exit
182
184
  end
183
185
 
186
+ opts.on("--allow-duplicates", "Allow to add duplicates of the same entity")
187
+
184
188
  opts.on_tail("\nSUBCOMMANDS\n#{subcommands_descriptions(subcommands)}")
185
189
  end
186
190
 
@@ -191,6 +195,10 @@ module Kintsugi
191
195
  exit(1)
192
196
  end
193
197
 
198
+ if options[:"allow-duplicates"]
199
+ Settings.allow_duplicates = true
200
+ end
201
+
194
202
  project_file_path = File.expand_path(arguments[0])
195
203
  Kintsugi.resolve_conflicts(project_file_path, options[:"changes-output-path"])
196
204
  puts "Resolved conflicts successfully"
@@ -0,0 +1,146 @@
1
+ # Copyright (c) 2021 Lightricks. All rights reserved.
2
+ # Created by Ben Yohay.
3
+ # frozen_string_literal: true
4
+
5
+ require "json"
6
+ require "tmpdir"
7
+ require "tempfile"
8
+ require "xcodeproj"
9
+
10
+ require_relative "xcodeproj_extensions"
11
+ require_relative "apply_change_to_project"
12
+ require_relative "error"
13
+
14
+ module Kintsugi
15
+ class << self
16
+ # Resolves git conflicts of a pbxproj file specified by `project_file_path`.
17
+ #
18
+ # @param [String] project_file_path
19
+ # Project to which to apply the changes.
20
+ #
21
+ # @param [String] changes_output_path
22
+ # Path to where the changes to apply to the project are written in JSON format.
23
+ #
24
+ # @raise [ArgumentError]
25
+ # If the file extension is not `pbxproj`, or the file doesn't exist, or if no rebase,
26
+ # cherry-pick, or merge is in progress
27
+ #
28
+ # @raise [MergeError]
29
+ # If there was an error applying the change to the project.
30
+ #
31
+ # @return [void]
32
+ def resolve_conflicts(project_file_path, changes_output_path)
33
+ validate_project(project_file_path)
34
+
35
+ base_project = copy_project_from_stage_number_to_temporary_directory(project_file_path, 1)
36
+ ours_project = copy_project_from_stage_number_to_temporary_directory(project_file_path, 2)
37
+ theirs_project = copy_project_from_stage_number_to_temporary_directory(project_file_path, 3)
38
+
39
+ change = Xcodeproj::Differ.project_diff(theirs_project, base_project, :added, :removed)
40
+
41
+ if changes_output_path
42
+ File.write(changes_output_path, JSON.pretty_generate(change))
43
+ end
44
+
45
+ apply_change_and_copy_to_original_path(ours_project, change, project_file_path)
46
+ end
47
+
48
+ # Merges the changes done between `theirs_project_path` and `base_project_path` to the file at
49
+ # `ours_project_path`. The files may not be at the original path, and therefore the
50
+ # `original_project_path` is required in order for the project metadata to be written properly.
51
+ #
52
+ # @param [String] base_project_path
53
+ # Path to the base version of the project.
54
+ #
55
+ # @param [String] ours_project_path
56
+ # Path to ours version of the project.
57
+ #
58
+ # @param [String] theirs_project_path
59
+ # Path to theirs version of the project.
60
+ #
61
+ # @param [String] original_project_path
62
+ # Path to the original path of the file.
63
+ #
64
+ # @raise [MergeError]
65
+ # If there was an error applying the change to the project.
66
+ #
67
+ # @return [void]
68
+ def three_way_merge(base_project_path, ours_project_path, theirs_project_path,
69
+ original_project_path)
70
+ original_directory_name = File.basename(File.dirname(original_project_path))
71
+ base_temporary_project =
72
+ copy_project_to_temporary_path_in_directory_with_name(base_project_path,
73
+ original_directory_name)
74
+ ours_temporary_project =
75
+ copy_project_to_temporary_path_in_directory_with_name(ours_project_path,
76
+ original_directory_name)
77
+ theirs_temporary_project =
78
+ copy_project_to_temporary_path_in_directory_with_name(theirs_project_path,
79
+ original_directory_name)
80
+
81
+ change =
82
+ Xcodeproj::Differ.project_diff(theirs_temporary_project, base_temporary_project,
83
+ :added, :removed)
84
+
85
+ apply_change_and_copy_to_original_path(ours_temporary_project, change, ours_project_path)
86
+ end
87
+
88
+ private
89
+
90
+ PROJECT_FILE_NAME = "project.pbxproj"
91
+
92
+ def apply_change_and_copy_to_original_path(project, change, original_project_file_path)
93
+ apply_change_to_project(project, change)
94
+ project.save
95
+ FileUtils.cp(File.join(project.path, PROJECT_FILE_NAME), original_project_file_path)
96
+ end
97
+
98
+ def validate_project(project_file_path)
99
+ unless File.exist?(project_file_path)
100
+ raise ArgumentError, "File '#{project_file_path}' doesn't exist"
101
+ end
102
+
103
+ if File.extname(project_file_path) != ".pbxproj"
104
+ raise ArgumentError, "Wrong file extension, please provide file with extension .pbxproj\""
105
+ end
106
+
107
+ unless file_has_base_ours_and_theirs_versions?(project_file_path)
108
+ raise ArgumentError, "File '#{project_file_path}' doesn't have conflicts, " \
109
+ "or a 3-way merge is not possible."
110
+ end
111
+ end
112
+
113
+ def copy_project_from_stage_number_to_temporary_directory(project_file_path, stage_number)
114
+ project_directory_name = File.basename(File.dirname(project_file_path))
115
+ temp_project_file_path = File.join(Dir.mktmpdir, project_directory_name, PROJECT_FILE_NAME)
116
+ Dir.mkdir(File.dirname(temp_project_file_path))
117
+ Dir.chdir(File.dirname(project_file_path)) do
118
+ `git show :#{stage_number}:./#{PROJECT_FILE_NAME} > "#{temp_project_file_path}"`
119
+ end
120
+ Xcodeproj::Project.open(File.dirname(temp_project_file_path))
121
+ end
122
+
123
+ def copy_project_to_temporary_path_in_directory_with_name(project_file_path, directory_name)
124
+ temp_directory_name = File.join(Dir.mktmpdir, directory_name)
125
+ Dir.mkdir(temp_directory_name)
126
+ temp_project_file_path = File.join(temp_directory_name, PROJECT_FILE_NAME)
127
+ FileUtils.cp(project_file_path, temp_project_file_path)
128
+ Xcodeproj::Project.open(File.dirname(temp_project_file_path))
129
+ end
130
+
131
+ def file_has_base_ours_and_theirs_versions?(file_path)
132
+ Dir.chdir(`git -C "#{File.dirname(file_path)}" rev-parse --show-toplevel`.strip) do
133
+ file_has_version_in_stage_numbers?(file_path, [1, 2, 3])
134
+ end
135
+ end
136
+
137
+ def file_has_version_in_stage_numbers?(file_path, stage_numbers)
138
+ file_absolute_path = File.absolute_path(file_path)
139
+ actual_stage_numbers =
140
+ `git ls-files -u -- "#{file_absolute_path}"`.split("\n").map do |git_file_status|
141
+ git_file_status.split[2]
142
+ end
143
+ (stage_numbers - actual_stage_numbers.map(&:to_i)).empty?
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,18 @@
1
+ # Copyright (c) 2022 Lightricks. All rights reserved.
2
+ # Created by Ben Yohay.
3
+ # frozen_string_literal: true
4
+
5
+ module Kintsugi
6
+ # Kintsugi global settings.
7
+ class Settings
8
+ class << self
9
+ # `true` if Kintsugi can create entities that are identical to existing ones, `false`
10
+ # otherwise.
11
+ attr_writer :allow_duplicates
12
+
13
+ def allow_duplicates
14
+ @allow_duplicates || false
15
+ end
16
+ end
17
+ end
18
+ 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.5.2"
6
+ STRING = "0.6.0"
7
7
  end
8
8
  end
@@ -66,4 +66,88 @@ module Xcodeproj
66
66
  end
67
67
  end
68
68
  end
69
+
70
+ module Differ
71
+ # Replaces the implementation of `array_diff` with an implementation that takes into account
72
+ # the number of occurrences an element is found in the array.
73
+ # Code was mostly copied from https://github.com/CocoaPods/Xcodeproj/blob/51fb78a03f31614103815ce21c56dc25c044a10d/lib/xcodeproj/differ.rb#L111
74
+ def self.array_diff(value_1, value_2, options)
75
+ ensure_class(value_1, Array)
76
+ ensure_class(value_2, Array)
77
+ return nil if value_1 == value_2
78
+
79
+ new_objects_value_1 = array_non_unique_diff(value_1, value_2)
80
+ new_objects_value_2 = array_non_unique_diff(value_2, value_1)
81
+ return nil if value_1.empty? && value_2.empty?
82
+
83
+ matched_diff = {}
84
+ if id_key = options[:id_key]
85
+ matched_value_1 = []
86
+ matched_value_2 = []
87
+ new_objects_value_1.each do |entry_value_1|
88
+ if entry_value_1.is_a?(Hash)
89
+ id_value = entry_value_1[id_key]
90
+ entry_value_2 = new_objects_value_2.find do |entry|
91
+ entry[id_key] == id_value
92
+ end
93
+ if entry_value_2
94
+ matched_value_1 << entry_value_1
95
+ matched_value_2 << entry_value_2
96
+ diff = diff(entry_value_1, entry_value_2, options)
97
+ matched_diff[id_value] = diff if diff
98
+ end
99
+ end
100
+ end
101
+
102
+ new_objects_value_1 -= matched_value_1
103
+ new_objects_value_2 -= matched_value_2
104
+ end
105
+
106
+ if new_objects_value_1.empty? && new_objects_value_2.empty?
107
+ if matched_diff.empty?
108
+ nil
109
+ else
110
+ matched_diff
111
+ end
112
+ else
113
+ result = {}
114
+ result[options[:key_1]] = new_objects_value_1 unless new_objects_value_1.empty?
115
+ result[options[:key_2]] = new_objects_value_2 unless new_objects_value_2.empty?
116
+ result[:diff] = matched_diff unless matched_diff.empty?
117
+ result
118
+ end
119
+ end
120
+
121
+ # Returns the difference between two arrays, taking into account the number of occurrences an
122
+ # element is found in both arrays.
123
+ #
124
+ # @param [Array] value_1
125
+ # First array to the difference operation.
126
+ #
127
+ # @param [Array] value_2
128
+ # Second array to the difference operation.
129
+ #
130
+ # @return [Array]
131
+ #
132
+ def self.array_non_unique_diff(value_1, value_2)
133
+ value_2_elements_by_count = value_2.reduce({}) do |hash, element|
134
+ updated_element_hash = hash.key?(element) ? {element => hash[element] + 1} : {element => 1}
135
+ hash.merge(updated_element_hash)
136
+ end
137
+
138
+ value_1_elements_by_deletions =
139
+ value_1.to_set.map do |element|
140
+ times_to_delete_element = value_2_elements_by_count[element] || 0
141
+ [element, times_to_delete_element]
142
+ end.to_h
143
+
144
+ value_1.select do |element|
145
+ if value_1_elements_by_deletions[element].positive?
146
+ value_1_elements_by_deletions[element] -= 1
147
+ next false
148
+ end
149
+ next true
150
+ end
151
+ end
152
+ end
69
153
  end
data/lib/kintsugi.rb CHANGED
@@ -1,167 +1,48 @@
1
- # Copyright (c) 2021 Lightricks. All rights reserved.
2
- # Created by Ben Yohay.
3
1
  # frozen_string_literal: true
4
2
 
5
- require "json"
6
- require "tmpdir"
7
- require "tempfile"
8
- require "xcodeproj"
3
+ # Copyright (c) 2022 Lightricks. All rights reserved.
4
+ # Created by Ben Yohay.
9
5
 
10
- require_relative "kintsugi/xcodeproj_extensions"
11
- require_relative "kintsugi/apply_change_to_project"
6
+ require_relative "kintsugi/cli"
12
7
  require_relative "kintsugi/error"
8
+ require_relative "kintsugi/merge"
13
9
 
14
10
  module Kintsugi
15
11
  class << self
16
- # Resolves git conflicts of a pbxproj file specified by `project_file_path`.
17
- #
18
- # @param [String] project_file_path
19
- # Project to which to apply the changes.
20
- #
21
- # @param [String] changes_output_path
22
- # Path to where the changes to apply to the project are written in JSON format.
23
- #
24
- # @raise [ArgumentError]
25
- # If the file extension is not `pbxproj`, or the file doesn't exist, or if no rebase,
26
- # cherry-pick, or merge is in progress
27
- #
28
- # @raise [MergeError]
29
- # If there was an error applying the change to the project.
30
- #
31
- # @return [void]
32
- def resolve_conflicts(project_file_path, changes_output_path)
33
- validate_project(project_file_path)
34
-
35
- project_in_temp_directory =
36
- open_project_of_current_commit_in_temporary_directory(project_file_path)
37
-
38
- change = change_of_conflicting_commit_with_parent(project_file_path)
39
-
40
- if changes_output_path
41
- File.write(changes_output_path, JSON.pretty_generate(change))
42
- end
43
-
44
- apply_change_and_copy_to_original_path(project_in_temp_directory, change, project_file_path)
45
- end
46
-
47
- # Merges the changes done between `theirs_project_path` and `base_project_path` to the file at
48
- # `ours_project_path`. The files may not be at the original path, and therefore the
49
- # `original_project_path` is required in order for the project metadata to be written properly.
50
- #
51
- # @param [String] base_project_path
52
- # Path to the base version of the project.
53
- #
54
- # @param [String] ours_project_path
55
- # Path to ours version of the project.
56
- #
57
- # @param [String] theirs_project_path
58
- # Path to theirs version of the project.
59
- #
60
- # @param [String] original_project_path
61
- # Path to the original path of the file.
62
- #
63
- # @raise [MergeError]
64
- # If there was an error applying the change to the project.
65
- #
66
- # @return [void]
67
- def three_way_merge(base_project_path, ours_project_path, theirs_project_path,
68
- original_project_path)
69
- original_directory_name = File.basename(File.dirname(original_project_path))
70
- base_temporary_project =
71
- copy_project_to_temporary_path_in_directory_with_name(base_project_path,
72
- original_directory_name)
73
- ours_temporary_project =
74
- copy_project_to_temporary_path_in_directory_with_name(ours_project_path,
75
- original_directory_name)
76
- theirs_temporary_project =
77
- copy_project_to_temporary_path_in_directory_with_name(theirs_project_path,
78
- original_directory_name)
79
-
80
- change =
81
- Xcodeproj::Differ.project_diff(theirs_temporary_project, base_temporary_project,
82
- :added, :removed)
83
-
84
- apply_change_and_copy_to_original_path(ours_temporary_project, change, ours_project_path)
85
- end
86
-
87
- private
88
-
89
- PROJECT_FILE_NAME = "project.pbxproj"
90
-
91
- def apply_change_and_copy_to_original_path(project, change, original_project_file_path)
92
- apply_change_to_project(project, change)
93
- project.save
94
- FileUtils.cp(File.join(project.path, PROJECT_FILE_NAME), original_project_file_path)
95
- end
96
-
97
- def validate_project(project_file_path)
98
- unless File.exist?(project_file_path)
99
- raise ArgumentError, "File '#{project_file_path}' doesn't exist"
100
- end
101
-
102
- if File.extname(project_file_path) != ".pbxproj"
103
- raise ArgumentError, "Wrong file extension, please provide file with extension .pbxproj\""
104
- end
105
-
106
- Dir.chdir(File.dirname(project_file_path)) do
107
- unless file_has_base_ours_and_theirs_versions?(project_file_path)
108
- raise ArgumentError, "File '#{project_file_path}' doesn't have conflicts, " \
109
- "or a 3-way merge is not possible."
12
+ def run(arguments)
13
+ first_argument = arguments[0]
14
+ cli = CLI.new
15
+ command =
16
+ if name_of_subcommand?(cli.subcommands, first_argument)
17
+ arguments.shift
18
+ cli.subcommands[first_argument]
19
+ else
20
+ cli.root_command
110
21
  end
111
- end
112
- end
113
22
 
114
- def copy_project_to_temporary_path_in_directory_with_name(project_file_path, directory_name)
115
- temp_directory_name = File.join(Dir.mktmpdir, directory_name)
116
- Dir.mkdir(temp_directory_name)
117
- temp_project_file_path = File.join(temp_directory_name, PROJECT_FILE_NAME)
118
- FileUtils.cp(project_file_path, temp_project_file_path)
119
- Xcodeproj::Project.open(File.dirname(temp_project_file_path))
120
- end
23
+ options = parse_options!(command, arguments)
121
24
 
122
- def open_project_of_current_commit_in_temporary_directory(project_file_path)
123
- project_directory_name = File.basename(File.dirname(project_file_path))
124
- temp_directory_name = File.join(Dir.mktmpdir, project_directory_name)
125
- Dir.mkdir(temp_directory_name)
126
- temp_project_file_path = File.join(temp_directory_name, PROJECT_FILE_NAME)
127
- Dir.chdir(File.dirname(project_file_path)) do
128
- `git show HEAD:./project.pbxproj > "#{temp_project_file_path}"`
25
+ begin
26
+ command.action.call(options, arguments)
27
+ rescue ArgumentError => e
28
+ puts "#{e.class}: #{e}"
29
+ raise
30
+ rescue Kintsugi::MergeError => e
31
+ puts e
32
+ raise
129
33
  end
130
- Xcodeproj::Project.open(File.dirname(temp_project_file_path))
131
34
  end
132
35
 
133
- def file_has_base_ours_and_theirs_versions?(file_path)
134
- Dir.chdir(`git rev-parse --show-toplevel`.strip) do
135
- file_has_version_in_stage_numbers?(file_path, [1, 2, 3])
136
- end
137
- end
36
+ private
138
37
 
139
- def file_has_version_in_stage_numbers?(file_path, stage_numbers)
140
- file_absolute_path = File.absolute_path(file_path)
141
- actual_stage_numbers =
142
- `git ls-files -u -- "#{file_absolute_path}"`.split("\n").map do |git_file_status|
143
- git_file_status.split[2]
144
- end
145
- (stage_numbers - actual_stage_numbers.map(&:to_i)).empty?
38
+ def parse_options!(command, arguments)
39
+ options = {}
40
+ command.option_parser.parse!(arguments, into: options)
41
+ options
146
42
  end
147
43
 
148
- def change_of_conflicting_commit_with_parent(project_file_path)
149
- Dir.chdir(File.dirname(project_file_path)) do
150
- conflicting_commit_project_file_path = File.join(Dir.mktmpdir, PROJECT_FILE_NAME)
151
- `git show :3:./#{PROJECT_FILE_NAME} > #{conflicting_commit_project_file_path}`
152
-
153
- conflicting_commit_parent_project_file_path = File.join(Dir.mktmpdir, PROJECT_FILE_NAME)
154
- `git show :1:./#{PROJECT_FILE_NAME} > #{conflicting_commit_parent_project_file_path}`
155
-
156
- conflicting_commit_project = Xcodeproj::Project.open(
157
- File.dirname(conflicting_commit_project_file_path)
158
- )
159
- conflicting_commit_parent_project =
160
- Xcodeproj::Project.open(File.dirname(conflicting_commit_parent_project_file_path))
161
-
162
- Xcodeproj::Differ.project_diff(conflicting_commit_project,
163
- conflicting_commit_parent_project, :added, :removed)
164
- end
44
+ def name_of_subcommand?(subcommands, argument)
45
+ subcommands.include?(argument)
165
46
  end
166
47
  end
167
48
  end