kintsugi 0.4.3 → 0.5.3

Sign up to get free protection for your applications and to get access to all the features.
data/lib/kintsugi/cli.rb CHANGED
@@ -50,6 +50,7 @@ module Kintsugi
50
50
  exit(1)
51
51
  end
52
52
  Kintsugi.three_way_merge(arguments[0], arguments[1], arguments[2], arguments[3])
53
+ warn "\e[32mKintsugi auto-merged #{arguments[3]}\e[0m"
53
54
  }
54
55
 
55
56
  Command.new(
@@ -109,7 +110,7 @@ module Kintsugi
109
110
  def global_attributes_file_path
110
111
  # The logic to decide the path to the global attributes file is described at:
111
112
  # https://git-scm.com/docs/gitattributes.
112
- config_attributes_file_path = `git config --global core.attributesfile`
113
+ config_attributes_file_path = `git config --global core.attributesfile`.chomp
113
114
  return config_attributes_file_path unless config_attributes_file_path.empty?
114
115
 
115
116
  if ENV["XDG_CONFIG_HOME"].nil? || ENV["XDG_CONFIG_HOME"].empty?
@@ -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
@@ -3,6 +3,6 @@
3
3
  module Kintsugi
4
4
  # This module holds the Kintsugi version information.
5
5
  module Version
6
- STRING = "0.4.3"
6
+ STRING = "0.5.3"
7
7
  end
8
8
  end
@@ -27,5 +27,127 @@ module Xcodeproj
27
27
  self[:project_ref]
28
28
  end
29
29
  end
30
+
31
+ module Object
32
+ # Extends `XCBuildConfiguration` to convert array settings (which might be either array or
33
+ # string) to actual arrays in `to_tree_hash` so diffs are always between arrays. This means
34
+ # that if the values in both `ours` and `theirs` are different strings, we will know to solve
35
+ # the conflict into an array containing both strings.
36
+ # Code was mostly copied from https://github.com/CocoaPods/Xcodeproj/blob/master/lib/xcodeproj/project/object/build_configuration.rb#L211
37
+ class XCBuildConfiguration
38
+ @@old_to_tree_hash = instance_method(:to_tree_hash)
39
+
40
+ def to_tree_hash
41
+ @@old_to_tree_hash.bind(self).call.tap do |hash|
42
+ convert_array_settings_to_arrays(hash['buildSettings'])
43
+ end
44
+ end
45
+
46
+ def convert_array_settings_to_arrays(settings)
47
+ return unless settings
48
+
49
+ array_settings = BuildSettingsArraySettingsByObjectVersion[project.object_version]
50
+
51
+ settings.each_key do |key|
52
+ value = settings[key]
53
+ next unless value.is_a?(String)
54
+
55
+ stripped_key = key.sub(/\[[^\]]+\]$/, '')
56
+ next unless array_settings.include?(stripped_key)
57
+
58
+ array_value = split_string_setting_into_array(value)
59
+ settings[key] = array_value
60
+ end
61
+ end
62
+
63
+ def split_string_setting_into_array(string)
64
+ string.scan(/ *((['"]?).*?[^\\]\2)(?=( |\z))/).map(&:first)
65
+ end
66
+ end
67
+ end
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
30
152
  end
31
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