kintsugi 0.5.2 → 0.6.0

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