kintsugi 0.1.1 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d48ba29d100ace39635610ad2876a7b6b78b2ff0b7eb56e34b158c962471b0e3
4
- data.tar.gz: 719750e48502f5a4bb3a9c924faf2a2b6b959235555a7545c40262ad80b8ee3a
3
+ metadata.gz: 0e2593fa95851c928b7ad943430c1db40d4c21831e24bd16f371f24298714c99
4
+ data.tar.gz: dca49c53301790803690ad5aa81dcf59b2f9036402a713b63b9093130994fe51
5
5
  SHA512:
6
- metadata.gz: 26e3adcbe40a0ee5d597cb3bae344f4b124db1de5fb662b5af3d4357d334edc3f46d4367879c33cb26e395b957ca62c2ac824e6d0478fbd3639fe0d71533da13
7
- data.tar.gz: 7a71972eb202112a3e83d6923f42b30e509af7b3a0ee4394d83822a89129fa984da8e6e1422046c5ee5c6e43152cbb876101f42dab04bad0c17516b6ba5d329f
6
+ metadata.gz: aa8c40252da2e56f9753fd143e1b100599c31737badcb5c16906e5883008b6344a5e44c09f2619f90045b0c987971f0248f2400d5f95acd20dac7c810b061a05
7
+ data.tar.gz: 15da61db3bea0d5d3c9528ba93b087ae67956eb58f4d846639936fcf682ac5f7cc5f6d2ec8eb87cc41673b960978e245b3eaf33d21b31c1e5a0ebadf46780d07
data/.rubocop.yml CHANGED
@@ -6,6 +6,9 @@ RSpec/ExampleLength:
6
6
  RSpec/DescribeClass:
7
7
  Enabled: false
8
8
 
9
+ RSpec/MultipleExpectations:
10
+ Enabled: false
11
+
9
12
  AllCops:
10
13
  DisplayCopNames: true
11
14
  TargetRubyVersion: 2.5
data/README.md CHANGED
@@ -38,13 +38,45 @@ When there's a `.pbxproj` file with Git conflicts, and a 3-way merge is possible
38
38
 
39
39
  And see the magic happen! :sparkles:
40
40
 
41
+ ### Git merge driver
42
+
43
+ You can setup Kintsugi to automatically resolve conflicts that occur in `pbxproj` files when such conflicts occur.
44
+
45
+ #### Automatic install
46
+
47
+ Run `kintsugi install-driver`. This will install Kintsugi as a merge driver globally. Note that Kintsugi needs to be in your `PATH`.
48
+
49
+ ❗ Do not install with bundler because the installation might succeed even if Kintsugi is not in `PATH`.
50
+
51
+ #### Manual install
52
+
53
+ - Add Kintsugi as driver to Git config file by running the following:
54
+ ```sh
55
+ git config merge.kintsugi.name "Kintsugi driver" # Or any other name you prefer
56
+ git config merge.kintsugi.driver "<path_to_kintsugi> driver %O %A %B %P"
57
+ ```
58
+
59
+ Run `git config` with `--global` to add this to the global config file.
60
+
61
+ - Add the following line to the `.gitattributes` file at the root of the repository:
62
+
63
+ `*.pbxproj merge=kintsugi`
64
+
65
+ This will instruct Git to use Kintsugi as a merge driver for `.pbxproj` files.
66
+
67
+ See the [official docs](https://git-scm.com/docs/gitattributes) if you want to set this globally.
68
+
41
69
  ## Contribution
42
70
 
43
71
  See our [Contribution guidelines](./CONTRIBUTING.md).
44
72
 
45
73
  ## Alternatives
46
74
 
47
- - [XcodeGen](https://github.com/yonaskolb/XcodeGen): You can commit this JSON file into Git instead of the `.pbxproj` file. Then resolving conflicts is much easier.
75
+ All of the alternatives below allow you to generate your Xcode projects based on a spec or manifest. You commit these files to git, and can even remove the `.xcodeproj` files from git.
76
+
77
+ - [XcodeGen](https://github.com/yonaskolb/XcodeGen)
78
+ - [Tuist](https://github.com/tuist)
79
+ - [Xcake](https://github.com/igor-makarov/xcake)
48
80
 
49
81
  ## Copyright
50
82
 
data/bin/kintsugi CHANGED
@@ -4,49 +4,36 @@
4
4
  # Copyright (c) 2020 Lightricks. All rights reserved.
5
5
  # Created by Ben Yohay.
6
6
 
7
- require "json"
8
- require "optparse"
9
-
10
7
  require "kintsugi"
11
- require_relative "../lib/kintsugi/version"
12
-
13
-
14
- def parse_options!(argv)
15
- options_parser = create_options_parser
8
+ require_relative "../lib/kintsugi/cli"
9
+ require_relative "../lib/kintsugi/error"
16
10
 
11
+ def parse_options!(command, argv)
17
12
  options = {}
18
- options_parser.parse!(argv, into: options)
19
-
20
- if argv.length != 1
21
- puts "Incorrect number of arguments\n\n"
22
- puts options_parser
23
- exit(1)
24
- end
25
-
13
+ command.option_parser.parse!(argv, into: options)
26
14
  options
27
15
  end
28
16
 
29
- def create_options_parser
30
- OptionParser.new do |opts|
31
- opts.banner = "Kintsugi, version #{Kintsugi::Version::STRING}\nCopyright (c) 2021 " \
32
- "Lightricks\n\nUsage: kintsugi [pbxproj_filepath] [options]"
33
- opts.on("--changes-output-path=PATH", "Path to which changes applied to the project are " \
34
- "written in JSON format. Used for debug purposes.")
35
-
36
- opts.on("-h", "--help", "Prints this help") do
37
- puts opts
38
- exit
39
- end
17
+ def name_of_subcommand?(subcommands, argument)
18
+ subcommands.include?(argument)
19
+ end
40
20
 
41
- opts.on_tail("-v", "--version", "Prints version") do
42
- puts Kintsugi::Version::STRING
43
- exit
44
- end
21
+ first_argument = ARGV[0]
22
+ cli = Kintsugi::CLI.new
23
+ command =
24
+ if name_of_subcommand?(cli.subcommands, first_argument)
25
+ ARGV.shift
26
+ cli.subcommands[first_argument]
27
+ else
28
+ cli.root_command
45
29
  end
46
- end
47
30
 
48
- options = parse_options!(ARGV)
49
- project_file_path = File.expand_path(ARGV[0])
50
- Kintsugi.resolve_conflicts(project_file_path, options[:"changes-output-path"])
31
+ options = parse_options!(command, ARGV)
51
32
 
52
- puts "Resolved conflicts successfully"
33
+ begin
34
+ command.action.call(options, ARGV)
35
+ rescue ArgumentError => e
36
+ puts "#{e.class}: #{e}"
37
+ rescue Kintsugi::MergeError => e
38
+ puts e
39
+ end
data/kintsugi.gemspec CHANGED
@@ -22,7 +22,7 @@ Gem::Specification.new do |spec|
22
22
  spec.test_files = spec.files.grep(%r{^(spec)/})
23
23
  spec.require_paths = ["lib"]
24
24
 
25
- spec.add_dependency "xcodeproj", "1.19.0"
25
+ spec.add_dependency "xcodeproj", ">= 1.19.0", "<= 1.21.0"
26
26
 
27
27
  spec.add_development_dependency "rake", "~> 13.0"
28
28
  spec.add_development_dependency "rspec", "~> 3.9"
@@ -167,7 +167,7 @@ module Kintsugi
167
167
  new_value = nil
168
168
 
169
169
  if change.key?(:removed)
170
- new_value = apply_removal_to_simple_attribute(old_value, change[:removed])
170
+ new_value = apply_removal_to_simple_attribute(old_value, change[:removed], change[:added])
171
171
  end
172
172
 
173
173
  if change.key?(:added)
@@ -183,29 +183,31 @@ module Kintsugi
183
183
  new_value
184
184
  end
185
185
 
186
- def apply_removal_to_simple_attribute(old_value, change)
187
- case change
186
+ def apply_removal_to_simple_attribute(old_value, removed_change, added_change)
187
+ case removed_change
188
188
  when Array
189
- (old_value || []) - change
189
+ (old_value || []) - removed_change
190
190
  when Hash
191
191
  (old_value || {}).reject do |key, value|
192
- if value != change[key]
193
- raise "Trying to remove value #{change[key]} of hash with key #{key} but it changed " \
194
- "to #{value}."
192
+ if value != removed_change[key] && added_change[key] != value
193
+ raise MergeError, "Trying to remove value '#{removed_change[key]}' of hash with key " \
194
+ "'#{key}' but it changed to #{value}. This is considered a conflict that should be " \
195
+ "resolved manually."
195
196
  end
196
197
 
197
- change.key?(key)
198
+ removed_change.key?(key)
198
199
  end
199
200
  when String
200
- if old_value != change
201
- raise "Value changed from #{old_value} to #{change}."
201
+ if old_value != removed_change && !old_value.nil? && added_change != old_value
202
+ raise MergeError, "Trying to remove value '#{removed_change}', but the existing value " \
203
+ "is '#{old_value}'. This is considered a conflict that should be resolved manually."
202
204
  end
203
205
 
204
206
  nil
205
207
  when nil
206
208
  nil
207
209
  else
208
- raise "Unsupported change #{change} of type #{change.class}"
210
+ raise MergeError, "Unsupported change #{removed_change} of type #{removed_change.class}"
209
211
  end
210
212
  end
211
213
 
@@ -218,7 +220,8 @@ module Kintsugi
218
220
  new_value = old_value.merge(change)
219
221
 
220
222
  unless (old_value.to_a - new_value.to_a).empty?
221
- raise "New hash #{change} contains values that conflict with old hash #{old_value}"
223
+ raise MergeError, "New hash #{change} contains values that conflict with old hash " \
224
+ "#{old_value}"
222
225
  end
223
226
 
224
227
  new_value
@@ -227,14 +230,15 @@ module Kintsugi
227
230
  when nil
228
231
  nil
229
232
  else
230
- raise "Unsupported change #{change} of type #{change.class}"
233
+ raise MergeError, "Unsupported change #{change} of type #{change.class}"
231
234
  end
232
235
  end
233
236
 
234
237
  def remove_component(component, change)
235
238
  if component.to_tree_hash != change
236
- raise "Trying to remove an object that changed since then. This is considered a conflict " \
237
- "that should be resolved manually. Name of the object is: '#{component.display_name}'"
239
+ raise MergeError, "Trying to remove an object that changed since then. This is " \
240
+ "considered a conflict that should be resolved manually. Name of the object is: " \
241
+ "'#{component.display_name}'"
238
242
  end
239
243
 
240
244
  if change["isa"] == "PBXFileReference"
@@ -265,6 +269,8 @@ module Kintsugi
265
269
  case change["isa"]
266
270
  when "PBXNativeTarget"
267
271
  add_target(component, change)
272
+ when "PBXAggregateTarget"
273
+ add_aggregate_target(component, change)
268
274
  when "PBXFileReference"
269
275
  add_file_reference(component, change)
270
276
  when "PBXGroup"
@@ -298,21 +304,36 @@ module Kintsugi
298
304
  when "PBXReferenceProxy"
299
305
  add_reference_proxy(component, change)
300
306
  else
301
- raise "Trying to add unsupported component type #{change["isa"]}. Full component change " \
302
- "is: #{change}"
307
+ raise MergeError, "Trying to add unsupported component type #{change["isa"]}. Full " \
308
+ "component change is: #{change}"
303
309
  end
304
310
  end
305
311
 
306
312
  def add_reference_proxy(containing_component, change)
307
313
  case containing_component
308
314
  when Xcodeproj::Project::PBXBuildFile
309
- containing_component.file_ref = find_file(containing_component.project, change)
315
+ # If there are two file references that refer to the same file, one with a build file and
316
+ # the other one without, this method will prefer to take the one without the build file.
317
+ # This assumes that it's preferred to have a file reference with build file than a file
318
+ # reference without/with two build files.
319
+ filter_references_without_build_files = lambda do |reference|
320
+ reference.referrers.find do |referrer|
321
+ referrer.is_a?(Xcodeproj::Project::PBXBuildFile)
322
+ end.nil?
323
+ end
324
+ file_reference =
325
+ find_reference_proxy(containing_component.project, change["remoteRef"],
326
+ reference_filter: filter_references_without_build_files)
327
+ if file_reference.nil?
328
+ file_reference = find_reference_proxy(containing_component.project, change["remoteRef"])
329
+ end
330
+ containing_component.file_ref = file_reference
310
331
  when Xcodeproj::Project::PBXGroup
311
332
  reference_proxy = containing_component.project.new(Xcodeproj::Project::PBXReferenceProxy)
312
333
  containing_component << reference_proxy
313
334
  add_attributes_to_component(reference_proxy, change)
314
335
  else
315
- raise "Trying to add reference proxy to an unsupported component type " \
336
+ raise MergeError, "Trying to add reference proxy to an unsupported component type " \
316
337
  "#{containing_component.isa}. Change is: #{change}"
317
338
  end
318
339
  end
@@ -327,7 +348,7 @@ module Kintsugi
327
348
  containing_component.children << variant_group
328
349
  add_attributes_to_component(variant_group, change)
329
350
  else
330
- raise "Trying to add variant group to an unsupported component type " \
351
+ raise MergeError, "Trying to add variant group to an unsupported component type " \
331
352
  "#{containing_component.isa}. Change is: #{change}"
332
353
  end
333
354
  end
@@ -423,7 +444,7 @@ module Kintsugi
423
444
  when "PBXReferenceProxy"
424
445
  component.remote_ref = container_proxy
425
446
  else
426
- raise "Trying to add container item proxy to an unsupported component type " \
447
+ raise MergeError, "Trying to add container item proxy to an unsupported component type " \
427
448
  "#{containing_component.isa}. Change is: #{change}"
428
449
  end
429
450
  add_attributes_to_component(container_proxy, change, ignore_keys: ["containerPortal"])
@@ -458,7 +479,14 @@ module Kintsugi
458
479
  end
459
480
 
460
481
  def add_subproject_reference(root_object, project_reference_change)
461
- subproject_reference = find_file(root_object.project, project_reference_change["ProjectRef"])
482
+ filter_subproject_without_project_references = lambda do |file_reference|
483
+ root_object.project_references.find do |project_reference|
484
+ project_reference.project_ref.uuid == file_reference.uuid
485
+ end.nil?
486
+ end
487
+ subproject_reference =
488
+ find_file(root_object.project, project_reference_change["ProjectRef"],
489
+ file_filter: filter_subproject_without_project_references)
462
490
 
463
491
  attribute =
464
492
  Xcodeproj::Project::PBXProject.references_by_keys_attributes
@@ -488,6 +516,12 @@ module Kintsugi
488
516
  add_attributes_to_component(target, change)
489
517
  end
490
518
 
519
+ def add_aggregate_target(root_object, change)
520
+ target = root_object.project.new(Xcodeproj::Project::PBXAggregateTarget)
521
+ root_object.project.targets << target
522
+ add_attributes_to_component(target, change)
523
+ end
524
+
491
525
  def add_file_reference(containing_component, change)
492
526
  # base configuration reference and product reference always reference a file that exists
493
527
  # inside a group, therefore in these cases the file is searched for.
@@ -509,7 +543,7 @@ module Kintsugi
509
543
  file_reference.include_in_index = nil
510
544
  add_attributes_to_component(file_reference, change)
511
545
  else
512
- raise "Trying to add file reference to an unsupported component type " \
546
+ raise MergeError, "Trying to add file reference to an unsupported component type " \
513
547
  "#{containing_component.isa}. Change is: #{change}"
514
548
  end
515
549
  end
@@ -524,8 +558,8 @@ module Kintsugi
524
558
  new_group = containing_component.project.new(Xcodeproj::Project::PBXGroup)
525
559
  containing_component.children << new_group
526
560
  else
527
- raise "Trying to add group to an unsupported component type #{containing_component.isa}. " \
528
- "Change is: #{change}"
561
+ raise MergeError, "Trying to add group to an unsupported component type " \
562
+ "#{containing_component.isa}. Change is: #{change}"
529
563
  end
530
564
 
531
565
  add_attributes_to_component(new_group, change)
@@ -549,31 +583,35 @@ module Kintsugi
549
583
  add_child_to_component(component, added_attribute_element)
550
584
  end
551
585
  else
552
- raise "Trying to add attribute of unsupported type '#{change_value.class}' to " \
553
- "object #{component}. Attribute name is '#{change_name}'"
586
+ raise MergeError, "Trying to add attribute of unsupported type '#{change_value.class}' " \
587
+ "to object #{component}. Attribute name is '#{change_name}'"
554
588
  end
555
589
  end
556
590
  end
557
591
 
558
- def find_file(project, file_reference_change)
559
- case file_reference_change["isa"]
560
- when "PBXFileReference"
561
- project.files.find do |file_reference|
562
- next file_reference.path == file_reference_change["path"]
563
- end
564
- when "PBXReferenceProxy"
565
- find_reference_proxy(project, file_reference_change["remoteRef"])
566
- else
567
- raise "Unsupported file reference change of type #{file_reference["isa"]}."
592
+ def find_file(project, file_reference_change, file_filter: ->(_) { true })
593
+ file_references = project.files.select do |file_reference|
594
+ file_reference.path == file_reference_change["path"] && file_filter.call(file_reference)
595
+ end
596
+ if file_references.length > 1
597
+ puts "Debug: Found more than one matching file with path " \
598
+ "'#{file_reference_change["path"]}'. Using the first one."
599
+ elsif file_references.empty?
600
+ puts "Debug: No file reference found for file with path " \
601
+ "'#{file_reference_change["path"]}'."
602
+ return
568
603
  end
604
+
605
+ file_references.first
569
606
  end
570
607
 
571
- def find_reference_proxy(project, container_item_proxy_change)
608
+ def find_reference_proxy(project, container_item_proxy_change, reference_filter: ->(_) { true })
572
609
  reference_proxies = project.root_object.project_references.map do |project_ref_and_products|
573
610
  project_ref_and_products[:product_group].children.find do |product|
574
611
  product.remote_ref.remote_global_id_string ==
575
612
  container_item_proxy_change["remoteGlobalIDString"] &&
576
- product.remote_ref.remote_info == container_item_proxy_change["remoteInfo"]
613
+ product.remote_ref.remote_info == container_item_proxy_change["remoteInfo"] &&
614
+ reference_filter.call(product)
577
615
  end
578
616
  end.compact
579
617
 
@@ -0,0 +1,203 @@
1
+ # Copyright (c) 2021 Lightricks. All rights reserved.
2
+ # Created by Ben Yohay.
3
+ # frozen_string_literal: true
4
+
5
+ require "optparse"
6
+
7
+ require_relative "version"
8
+
9
+ module Kintsugi
10
+ # Class resposible for creating the logic of various options for Kintsugi CLI.
11
+ class CLI
12
+ # Subcommands of Kintsugi CLI.
13
+ attr_reader :subcommands
14
+
15
+ # Root command of Kintsugi CLI.
16
+ attr_reader :root_command
17
+
18
+ def initialize
19
+ @subcommands = {
20
+ "driver" => create_driver_subcommand,
21
+ "install-driver" => create_install_driver_subcommand,
22
+ "uninstall-driver" => create_uninstall_driver_subcommand
23
+ }.freeze
24
+ @root_command = create_root_command
25
+ end
26
+
27
+ private
28
+
29
+ Command = Struct.new(:option_parser, :action, :description, keyword_init: true)
30
+
31
+ def create_driver_subcommand
32
+ option_parser =
33
+ OptionParser.new do |opts|
34
+ opts.banner = "Usage: kintsugi driver BASE OURS THEIRS ORIGINAL_FILE_PATH\n" \
35
+ "Uses Kintsugi as a Git merge driver. Parameters " \
36
+ "should be the path to base version of the file, path to ours version, path to " \
37
+ "theirs version, and the original file path."
38
+
39
+ opts.on("-h", "--help", "Prints this help") do
40
+ puts opts
41
+ exit
42
+ end
43
+ end
44
+
45
+ driver_action = lambda { |_, arguments|
46
+ if arguments.count != 4
47
+ puts "Incorrect number of arguments to 'driver' subcommand\n\n"
48
+ puts option_parser
49
+ exit(1)
50
+ end
51
+ Kintsugi.three_way_merge(arguments[0], arguments[1], arguments[2], arguments[3])
52
+ }
53
+
54
+ Command.new(
55
+ option_parser: option_parser,
56
+ action: driver_action,
57
+ description: "3-way merge compatible with Git merge driver"
58
+ )
59
+ end
60
+
61
+ def create_install_driver_subcommand
62
+ option_parser =
63
+ OptionParser.new do |opts|
64
+ opts.banner = "Usage: kintsugi install-driver\n" \
65
+ "Installs Kintsugi as a Git merge driver globally. "
66
+
67
+ opts.on("-h", "--help", "Prints this help") do
68
+ puts opts
69
+ exit
70
+ end
71
+ end
72
+
73
+ action = lambda { |_, arguments|
74
+ if arguments.count != 0
75
+ puts "Incorrect number of arguments to 'install-driver' subcommand\n\n"
76
+ puts option_parser
77
+ exit(1)
78
+ end
79
+
80
+ if `which kintsugi`.chomp.empty?
81
+ puts "Can only install Kintsugi globally if Kintsugi is in your PATH"
82
+ exit(1)
83
+ end
84
+
85
+ install_kintsugi_driver_globally
86
+ puts "Done! 🪄"
87
+ }
88
+
89
+ Command.new(
90
+ option_parser: option_parser,
91
+ action: action,
92
+ description: "Installs Kintsugi as a Git merge driver globally"
93
+ )
94
+ end
95
+
96
+ def install_kintsugi_driver_globally
97
+ `git config --global merge.kintsugi.name "Kintsugi driver"`
98
+ `git config --global merge.kintsugi.driver "kintsugi driver %O %A %B %P"`
99
+
100
+ attributes_file_path = global_attributes_file_path
101
+ merge_using_kintsugi_line = "'*.pbxproj merge=kintsugi'"
102
+ `grep -sqxF #{merge_using_kintsugi_line} "#{attributes_file_path}" \
103
+ || echo #{merge_using_kintsugi_line} >> "#{attributes_file_path}"`
104
+ end
105
+
106
+ def global_attributes_file_path
107
+ # The logic to decide the path to the global attributes file is described at:
108
+ # https://git-scm.com/docs/gitattributes.
109
+ config_attributes_file_path = `git config --global core.attributesfile`
110
+ return config_attributes_file_path unless config_attributes_file_path.empty?
111
+
112
+ if ENV["XDG_CONFIG_HOME"].nil? || ENV["XDG_CONFIG_HOME"].empty?
113
+ File.join(ENV["HOME"], ".config/git/attributes")
114
+ else
115
+ File.join(ENV["XDG_CONFIG_HOME"], "git/attributes")
116
+ end
117
+ end
118
+
119
+ def create_uninstall_driver_subcommand
120
+ option_parser =
121
+ OptionParser.new do |opts|
122
+ opts.banner = "Usage: kintsugi uninstall-driver\n" \
123
+ "Uninstalls Kintsugi as a Git merge driver that was previously installed globally."
124
+
125
+ opts.on("-h", "--help", "Prints this help") do
126
+ puts opts
127
+ exit
128
+ end
129
+ end
130
+
131
+ action = lambda { |_, arguments|
132
+ if arguments.count != 0
133
+ puts "Incorrect number of arguments to 'uninstall-driver' subcommand\n\n"
134
+ puts option_parser
135
+ exit(1)
136
+ end
137
+
138
+ uninstall_kintsugi_driver_globally
139
+ puts "Done!"
140
+ }
141
+
142
+ Command.new(
143
+ option_parser: option_parser,
144
+ action: action,
145
+ description: "Uninstalls Kintsugi as a Git merge driver that was previously installed " \
146
+ "globally."
147
+ )
148
+ end
149
+
150
+ def uninstall_kintsugi_driver_globally
151
+ `git config --global --unset merge.kintsugi.name`
152
+ `git config --global --unset merge.kintsugi.driver`
153
+
154
+ `sed -i '' '/\*.pbxproj\ merge=kintsugi/d' "#{global_attributes_file_path}"`
155
+ end
156
+
157
+ def create_root_command
158
+ option_parser = OptionParser.new do |opts|
159
+ opts.banner = "Kintsugi, version #{Version::STRING}\n" \
160
+ "Copyright (c) 2021 Lightricks\n\n" \
161
+ "Usage: kintsugi <pbxproj_filepath> [options]\n" \
162
+ " kintsugi <subcommand> [options]"
163
+
164
+ opts.separator ""
165
+ opts.on("--changes-output-path=PATH", "Path to which changes applied to the project are " \
166
+ "written in JSON format. Used for debug purposes.")
167
+
168
+ opts.on("-h", "--help", "Prints this help") do
169
+ puts opts
170
+ exit
171
+ end
172
+
173
+ opts.on("-v", "--version", "Prints version") do
174
+ puts Version::STRING
175
+ exit
176
+ end
177
+
178
+ subcommands_descriptions = @subcommands.map do |command_name, command|
179
+ " #{command_name}: #{command.description}"
180
+ end.join("\n")
181
+ opts.on_tail("\nSUBCOMMANDS\n#{subcommands_descriptions}")
182
+ end
183
+
184
+ root_action = lambda { |options, arguments|
185
+ if arguments.count != 1
186
+ puts "Incorrect number of arguments\n\n"
187
+ puts option_parser
188
+ exit(1)
189
+ end
190
+
191
+ project_file_path = File.expand_path(arguments[0])
192
+ Kintsugi.resolve_conflicts(project_file_path, options[:"changes-output-path"])
193
+ puts "Resolved conflicts successfully"
194
+ }
195
+
196
+ Command.new(
197
+ option_parser: option_parser,
198
+ action: root_action,
199
+ description: nil
200
+ )
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,9 @@
1
+ # Copyright (c) 2021 Lightricks. All rights reserved.
2
+ # Created by Ben Yohay.
3
+ # frozen_string_literal: true
4
+
5
+ module Kintsugi
6
+ # Raised when an error occurred while Kintsugi tried to resolve conflicts.
7
+ class MergeError < RuntimeError
8
+ end
9
+ 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.1.1"
6
+ STRING = "0.4.0"
7
7
  end
8
8
  end
data/lib/kintsugi.rb CHANGED
@@ -2,12 +2,14 @@
2
2
  # Created by Ben Yohay.
3
3
  # frozen_string_literal: true
4
4
 
5
+ require "json"
5
6
  require "tmpdir"
6
7
  require "tempfile"
7
8
  require "xcodeproj"
8
9
 
9
10
  require_relative "kintsugi/xcodeproj_extensions"
10
11
  require_relative "kintsugi/apply_change_to_project"
12
+ require_relative "kintsugi/error"
11
13
 
12
14
  module Kintsugi
13
15
  class << self
@@ -16,15 +18,15 @@ module Kintsugi
16
18
  # @param [String] project_file_path
17
19
  # Project to which to apply the changes.
18
20
  #
19
- # @param [String] output_changes_path
21
+ # @param [String] changes_output_path
20
22
  # Path to where the changes to apply to the project are written in JSON format.
21
23
  #
22
24
  # @raise [ArgumentError]
23
- # If the file extension is not `pbxproj` or the file doesn't exist
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
24
27
  #
25
- # @raise [RuntimeError]
26
- # If no rebase, cherry-pick, or merge is in progress, or the project file couldn't be
27
- # opened, or there was an error applying the change to the project.
28
+ # @raise [MergeError]
29
+ # If there was an error applying the change to the project.
28
30
  #
29
31
  # @return [void]
30
32
  def resolve_conflicts(project_file_path, changes_output_path)
@@ -39,43 +41,89 @@ module Kintsugi
39
41
  File.write(changes_output_path, JSON.pretty_generate(change))
40
42
  end
41
43
 
42
- apply_change_to_project(project_in_temp_directory, change)
43
-
44
- project_in_temp_directory.save
45
-
46
- Dir.chdir(File.dirname(project_file_path)) do
47
- `git reset #{project_file_path}`
48
- end
49
- FileUtils.cp(File.join(project_in_temp_directory.path, "project.pbxproj"), project_file_path)
44
+ apply_change_and_copy_to_original_path(project_in_temp_directory, change, project_file_path)
45
+ end
50
46
 
51
- # Some of the metadata in a `pbxproj` file include a part of the name of the directory it's
52
- # inside. The modified project is stored in a temporary directory and then copied to the
53
- # original path, therefore its metadata is incorrect. To fix this, the project at the original
54
- # path is opened and saved.
55
- Xcodeproj::Project.open(File.dirname(project_file_path)).save
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)
56
85
  end
57
86
 
58
87
  private
59
88
 
60
- def validate_project(project_file_path)
61
- if File.extname(project_file_path) != ".pbxproj"
62
- raise ArgumentError, "Wrong file extension, please provide file with extension .pbxproj\""
63
- end
89
+ PROJECT_FILE_NAME = "project.pbxproj"
64
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)
65
98
  unless File.exist?(project_file_path)
66
99
  raise ArgumentError, "File '#{project_file_path}' doesn't exist"
67
100
  end
68
101
 
102
+ if File.extname(project_file_path) != ".pbxproj"
103
+ raise ArgumentError, "Wrong file extension, please provide file with extension .pbxproj\""
104
+ end
105
+
69
106
  Dir.chdir(File.dirname(project_file_path)) do
70
107
  unless file_has_base_ours_and_theirs_versions?(project_file_path)
71
- raise ArgumentError, "File '#{project_file_path}' doesn't have conflicts, or a 3-way " \
72
- "merge is not possible."
108
+ raise ArgumentError, "File '#{project_file_path}' doesn't have conflicts, " \
109
+ "or a 3-way merge is not possible."
73
110
  end
74
111
  end
75
112
  end
76
113
 
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
121
+
77
122
  def open_project_of_current_commit_in_temporary_directory(project_file_path)
78
- temp_project_file_path = File.join(Dir.mktmpdir, "project.pbxproj")
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)
79
127
  Dir.chdir(File.dirname(project_file_path)) do
80
128
  `git show HEAD:./project.pbxproj > #{temp_project_file_path}`
81
129
  end
@@ -99,11 +147,11 @@ module Kintsugi
99
147
 
100
148
  def change_of_conflicting_commit_with_parent(project_file_path)
101
149
  Dir.chdir(File.dirname(project_file_path)) do
102
- conflicting_commit_project_file_path = File.join(Dir.mktmpdir, "project.pbxproj")
103
- `git show :3:./project.pbxproj > #{conflicting_commit_project_file_path}`
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}`
104
152
 
105
- conflicting_commit_parent_project_file_path = File.join(Dir.mktmpdir, "project.pbxproj")
106
- `git show :1:./project.pbxproj > #{conflicting_commit_parent_project_file_path}`
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}`
107
155
 
108
156
  conflicting_commit_project = Xcodeproj::Project.open(
109
157
  File.dirname(conflicting_commit_project_file_path)
@@ -38,6 +38,18 @@ describe Kintsugi, :apply_change_to_project do
38
38
  expect(base_project).to be_equivalent_to_project(theirs_project)
39
39
  end
40
40
 
41
+ it "adds new aggregate target" do
42
+ theirs_project = create_copy_of_project(base_project.path, "theirs")
43
+ theirs_project.new_aggregate_target("foo")
44
+
45
+ changes_to_apply = get_diff(theirs_project, base_project)
46
+
47
+ described_class.apply_change_to_project(base_project, changes_to_apply)
48
+ base_project.save
49
+
50
+ expect(base_project).to be_equivalent_to_project(theirs_project)
51
+ end
52
+
41
53
  it "adds new subproject" do
42
54
  theirs_project = create_copy_of_project(base_project.path, "theirs")
43
55
  add_new_subproject_to_project(theirs_project, "foo", "foo")
@@ -50,6 +62,26 @@ describe Kintsugi, :apply_change_to_project do
50
62
  expect(base_project).to be_equivalent_to_project(theirs_project, ignore_keys: ["containerPortal"])
51
63
  end
52
64
 
65
+ it "adds subproject that already exists" do
66
+ theirs_project = create_copy_of_project(base_project.path, "theirs")
67
+
68
+ subproject = add_new_subproject_to_project(theirs_project, "foo", "foo")
69
+ theirs_project.save
70
+
71
+ ours_project = create_copy_of_project(base_project.path, "ours")
72
+ add_existing_subproject_to_project(ours_project, subproject, "foo")
73
+
74
+ changes_to_apply = get_diff(theirs_project, base_project)
75
+
76
+ described_class.apply_change_to_project(ours_project, changes_to_apply)
77
+ ours_project.save
78
+
79
+ expect(ours_project.root_object.project_references[0][:project_ref].uuid)
80
+ .not_to equal(ours_project.root_object.project_references[1][:project_ref].uuid)
81
+ expect(ours_project.root_object.project_references[0][:project_ref].proxy_containers).not_to be_empty
82
+ expect(ours_project.root_object.project_references[1][:project_ref].proxy_containers).not_to be_empty
83
+ end
84
+
53
85
  # Checks that the order the changes are applied in is correct.
54
86
  it "adds new subproject and reference to its framework" do
55
87
  theirs_project = create_copy_of_project(base_project.path, "theirs")
@@ -144,6 +176,23 @@ describe Kintsugi, :apply_change_to_project do
144
176
  expect(base_project).to be_equivalent_to_project(theirs_project)
145
177
  end
146
178
 
179
+ it "changes simple attribute of a file that has a build file" do
180
+ target = base_project.new_target("com.apple.product-type.library.static", "bar", :ios)
181
+ file_reference = base_project.main_group.find_file_by_path(filepath)
182
+ target.frameworks_build_phase.add_file_reference(file_reference)
183
+ base_project.save
184
+
185
+ theirs_project = create_copy_of_project(base_project.path, "theirs")
186
+ file_reference = theirs_project.main_group.find_file_by_path(filepath)
187
+ file_reference.include_in_index = "4"
188
+
189
+ changes_to_apply = get_diff(theirs_project, base_project)
190
+
191
+ described_class.apply_change_to_project(base_project, changes_to_apply)
192
+
193
+ expect(base_project).to be_equivalent_to_project(theirs_project)
194
+ end
195
+
147
196
  it "removes build files of a removed file" do
148
197
  target = base_project.new_target("com.apple.product-type.library.static", "foo", :ios)
149
198
  target.source_build_phase.add_file_reference(
@@ -417,6 +466,30 @@ describe Kintsugi, :apply_change_to_project do
417
466
  expect(base_project).to be_equivalent_to_project(theirs_project, ignore_keys: ["containerPortal"])
418
467
  end
419
468
 
469
+ it "adds build file to a file reference that already exist" do
470
+ file_reference = base_project.main_group.new_reference("bar")
471
+ base_project.targets[0].frameworks_build_phase.add_file_reference(file_reference)
472
+
473
+ base_project.main_group.new_reference("bar")
474
+
475
+ base_project.save
476
+
477
+ theirs_project = create_copy_of_project(base_project.path, "theirs")
478
+
479
+ theirs_file_reference = theirs_project.main_group.files.find do |file|
480
+ !file.referrers.find { |referrer| referrer.is_a?(Xcodeproj::Project::PBXBuildFile) } &&
481
+ file.display_name == "bar"
482
+ end
483
+ theirs_project.targets[0].frameworks_build_phase.add_file_reference(theirs_file_reference)
484
+
485
+ changes_to_apply = get_diff(theirs_project, base_project)
486
+
487
+ described_class.apply_change_to_project(base_project, changes_to_apply)
488
+ base_project.save
489
+
490
+ expect(base_project).to be_equivalent_to_project(theirs_project)
491
+ end
492
+
420
493
  it "adds file reference to build file" do
421
494
  file_reference = base_project.main_group.new_reference("bar")
422
495
 
@@ -770,7 +843,44 @@ describe Kintsugi, :apply_change_to_project do
770
843
  expect(base_project).to be_equivalent_to_project(theirs_project)
771
844
  end
772
845
 
773
- it "identifies subproject added in separate times" do
846
+ it "removes attribute target changes from a project it was removed from already" do
847
+ base_project.root_object.attributes["TargetAttributes"] =
848
+ {"foo" => {"LastSwiftMigration" => "1140"}}
849
+ base_project.save
850
+
851
+ theirs_project = create_copy_of_project(base_project.path, "theirs")
852
+ theirs_project.root_object.attributes["TargetAttributes"]["foo"] = {}
853
+
854
+ ours_project = create_copy_of_project(base_project.path, "ours")
855
+ ours_project.root_object.attributes["TargetAttributes"]["foo"] = {}
856
+
857
+ changes_to_apply = get_diff(theirs_project, base_project)
858
+
859
+ described_class.apply_change_to_project(ours_project, changes_to_apply)
860
+ ours_project.save
861
+
862
+ expect(ours_project).to be_equivalent_to_project(theirs_project)
863
+ end
864
+
865
+ it "doesn't throw if existing attribute target change is same as added change" do
866
+ base_project.root_object.attributes["TargetAttributes"] = {"foo" => "1140"}
867
+ base_project.save
868
+
869
+ theirs_project = create_copy_of_project(base_project.path, "theirs")
870
+ theirs_project.root_object.attributes["TargetAttributes"]["foo"] = "1111"
871
+
872
+ ours_project = create_copy_of_project(base_project.path, "ours")
873
+ ours_project.root_object.attributes["TargetAttributes"]["foo"] = "1111"
874
+
875
+ changes_to_apply = get_diff(theirs_project, base_project)
876
+
877
+ described_class.apply_change_to_project(ours_project, changes_to_apply)
878
+ ours_project.save
879
+
880
+ expect(ours_project).to be_equivalent_to_project(theirs_project)
881
+ end
882
+
883
+ it "identifies subproject added at separate times when adding a product to the subproject" do
774
884
  framework_filename = "baz"
775
885
 
776
886
  subproject = new_subproject("subproj", framework_filename)
@@ -851,10 +961,10 @@ describe Kintsugi, :apply_change_to_project do
851
961
  file_reference.path == subproject_product_name
852
962
  end.remove_from_project
853
963
 
854
- project.root_object.project_references[0][:product_group] =
964
+ project.root_object.project_references[-1][:product_group] =
855
965
  project.new(Xcodeproj::Project::PBXGroup)
856
- project.root_object.project_references[0][:product_group].name = "Products"
857
- project.root_object.project_references[0][:product_group] <<
966
+ project.root_object.project_references[-1][:product_group].name = "Products"
967
+ project.root_object.project_references[-1][:product_group] <<
858
968
  create_reference_proxy_from_product_reference(project, subproject_reference,
859
969
  subproject.products_group.files[0])
860
970
  end
metadata CHANGED
@@ -1,29 +1,35 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kintsugi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben Yohay
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-06-29 00:00:00.000000000 Z
11
+ date: 2022-01-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: xcodeproj
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - '='
17
+ - - ">="
18
18
  - !ruby/object:Gem::Version
19
19
  version: 1.19.0
20
+ - - "<="
21
+ - !ruby/object:Gem::Version
22
+ version: 1.21.0
20
23
  type: :runtime
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
23
26
  requirements:
24
- - - '='
27
+ - - ">="
25
28
  - !ruby/object:Gem::Version
26
29
  version: 1.19.0
30
+ - - "<="
31
+ - !ruby/object:Gem::Version
32
+ version: 1.21.0
27
33
  - !ruby/object:Gem::Dependency
28
34
  name: rake
29
35
  requirement: !ruby/object:Gem::Requirement
@@ -116,6 +122,8 @@ files:
116
122
  - kintsugi.gemspec
117
123
  - lib/kintsugi.rb
118
124
  - lib/kintsugi/apply_change_to_project.rb
125
+ - lib/kintsugi/cli.rb
126
+ - lib/kintsugi/error.rb
119
127
  - lib/kintsugi/utils.rb
120
128
  - lib/kintsugi/version.rb
121
129
  - lib/kintsugi/xcodeproj_extensions.rb
@@ -142,7 +150,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
142
150
  - !ruby/object:Gem::Version
143
151
  version: '0'
144
152
  requirements: []
145
- rubygems_version: 3.1.2
153
+ rubyforge_project:
154
+ rubygems_version: 2.7.6.3
146
155
  signing_key:
147
156
  specification_version: 4
148
157
  summary: pbxproj files git conflicts solver