kintsugi 0.7.8 → 0.7.10

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.
@@ -2148,6 +2148,593 @@ describe Kintsugi, :apply_change_to_project do
2148
2148
  end
2149
2149
  end
2150
2150
 
2151
+ describe "file system synchronized groups" do
2152
+ before do
2153
+ base_project.new_target("com.apple.product-type.library.static", "foo", :ios)
2154
+ end
2155
+
2156
+ def add_synchronized_root_group(project, path, source_tree: "<group>")
2157
+ group = project.new(Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup)
2158
+ group.source_tree = source_tree
2159
+ group.path = path
2160
+ project.main_group.children << group
2161
+ group
2162
+ end
2163
+
2164
+ it "adds a file system synchronized root group to the main group" do
2165
+ theirs_project = create_copy_of_project(base_project, "theirs")
2166
+ add_synchronized_root_group(theirs_project, "SyncedSources")
2167
+
2168
+ changes_to_apply = get_diff(theirs_project, base_project)
2169
+ described_class.apply_change_to_project(base_project, changes_to_apply, theirs_project)
2170
+
2171
+ expect(base_project).to be_equivalent_to_project(theirs_project)
2172
+ end
2173
+
2174
+ it "adds a file system synchronized root group referenced by a target" do
2175
+ theirs_project = create_copy_of_project(base_project, "theirs")
2176
+ group = add_synchronized_root_group(theirs_project, "SyncedSources")
2177
+ theirs_project.targets[0].file_system_synchronized_groups << group
2178
+
2179
+ changes_to_apply = get_diff(theirs_project, base_project)
2180
+ described_class.apply_change_to_project(base_project, changes_to_apply, theirs_project)
2181
+
2182
+ expect(base_project).to be_equivalent_to_project(theirs_project)
2183
+
2184
+ # `be_equivalent_to_project` compares tree hashes (attributes only), so it can't detect a
2185
+ # duplicated object. Assert that the target reuses the exact same object that was created in
2186
+ # the group tree, rather than a second root group with the same attributes.
2187
+ synchronized_groups = base_project.objects.select do |object|
2188
+ object.isa == "PBXFileSystemSynchronizedRootGroup"
2189
+ end
2190
+ expect(synchronized_groups.count).to eq(1)
2191
+ expect(base_project.targets[0].file_system_synchronized_groups.first)
2192
+ .to equal(synchronized_groups.first)
2193
+ end
2194
+
2195
+ it "adds a synchronized root group with explicit file types and folders" do
2196
+ theirs_project = create_copy_of_project(base_project, "theirs")
2197
+ group = add_synchronized_root_group(theirs_project, "SyncedSources")
2198
+ group.explicit_file_types = {"*.md" => "text"}
2199
+ group.explicit_folders = ["Fixtures"]
2200
+ theirs_project.targets[0].file_system_synchronized_groups << group
2201
+
2202
+ changes_to_apply = get_diff(theirs_project, base_project)
2203
+ described_class.apply_change_to_project(base_project, changes_to_apply, theirs_project)
2204
+
2205
+ expect(base_project).to be_equivalent_to_project(theirs_project)
2206
+ end
2207
+
2208
+ it "adds a synchronized root group with build file exceptions" do
2209
+ theirs_project = create_copy_of_project(base_project, "theirs")
2210
+ group = add_synchronized_root_group(theirs_project, "SyncedSources")
2211
+ theirs_project.targets[0].file_system_synchronized_groups << group
2212
+ exception_set =
2213
+ theirs_project.new(Xcodeproj::Project::PBXFileSystemSynchronizedBuildFileExceptionSet)
2214
+ exception_set.target = theirs_project.targets[0]
2215
+ exception_set.membership_exceptions = ["Excluded.swift"]
2216
+ group.exceptions << exception_set
2217
+
2218
+ changes_to_apply = get_diff(theirs_project, base_project)
2219
+ described_class.apply_change_to_project(base_project, changes_to_apply, theirs_project)
2220
+
2221
+ expect(base_project).to be_equivalent_to_project(theirs_project)
2222
+ resolved = base_project.objects.find do |object|
2223
+ object.isa == "PBXFileSystemSynchronizedBuildFileExceptionSet"
2224
+ end
2225
+ expect(resolved.target).to equal(base_project.targets[0])
2226
+ end
2227
+
2228
+ it "adds a synchronized root group with build phase membership exceptions" do
2229
+ theirs_project = create_copy_of_project(base_project, "theirs")
2230
+ group = add_synchronized_root_group(theirs_project, "SyncedSources")
2231
+ theirs_project.targets[0].file_system_synchronized_groups << group
2232
+ klass = Xcodeproj::Project::PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet
2233
+ exception_set = theirs_project.new(klass)
2234
+ exception_set.build_phase = theirs_project.targets[0].source_build_phase
2235
+ exception_set.membership_exceptions = ["OnlyInSources.swift"]
2236
+ group.exceptions << exception_set
2237
+
2238
+ changes_to_apply = get_diff(theirs_project, base_project)
2239
+ described_class.apply_change_to_project(base_project, changes_to_apply, theirs_project)
2240
+
2241
+ expect(base_project).to be_equivalent_to_project(theirs_project)
2242
+ end
2243
+
2244
+ it "removes a file system synchronized root group" do
2245
+ group = add_synchronized_root_group(base_project, "SyncedSources")
2246
+ base_project.targets[0].file_system_synchronized_groups << group
2247
+
2248
+ theirs_project = create_copy_of_project(base_project, "theirs")
2249
+ theirs_project.main_group.children
2250
+ .find { |child| child.display_name == "SyncedSources" }
2251
+ .remove_from_project
2252
+
2253
+ changes_to_apply = get_diff(theirs_project, base_project)
2254
+ described_class.apply_change_to_project(base_project, changes_to_apply, theirs_project)
2255
+
2256
+ expect(base_project).to be_equivalent_to_project(theirs_project)
2257
+ expect(base_project.objects.count { |o| o.isa == "PBXFileSystemSynchronizedRootGroup" })
2258
+ .to eq(0)
2259
+ end
2260
+
2261
+ it "unlinks a folder from one target without deleting it for the others" do
2262
+ base_project.new_target("com.apple.product-type.library.static", "bar", :ios)
2263
+ group = add_synchronized_root_group(base_project, "Shared")
2264
+ base_project.targets.each { |target| target.file_system_synchronized_groups << group }
2265
+
2266
+ theirs_project = create_copy_of_project(base_project, "theirs")
2267
+ foo = theirs_project.targets.find { |target| target.display_name == "foo" }
2268
+ foo.file_system_synchronized_groups.delete(foo.file_system_synchronized_groups.first)
2269
+
2270
+ changes = get_diff(theirs_project, base_project)
2271
+ described_class.apply_change_to_project(base_project, changes, theirs_project)
2272
+
2273
+ expect(base_project).to be_equivalent_to_project(theirs_project)
2274
+ # The shared folder must survive: only foo's link is removed; bar keeps it and it stays in
2275
+ # the main group (removing a target reference must not delete the shared object).
2276
+ expect(base_project.objects.count { |o| o.isa == "PBXFileSystemSynchronizedRootGroup" })
2277
+ .to eq(1)
2278
+ expect(base_project.targets.find { |t| t.display_name == "foo" }
2279
+ .file_system_synchronized_groups).to be_empty
2280
+ expect(base_project.targets.find { |t| t.display_name == "bar" }
2281
+ .file_system_synchronized_groups.count).to eq(1)
2282
+ end
2283
+
2284
+ it "keeps a folder shared by three targets when one unlinks it" do
2285
+ base_project.new_target("com.apple.product-type.library.static", "bar", :ios)
2286
+ base_project.new_target("com.apple.product-type.library.static", "baz", :ios)
2287
+ group = add_synchronized_root_group(base_project, "Shared")
2288
+ base_project.targets.each { |target| target.file_system_synchronized_groups << group }
2289
+
2290
+ theirs_project = create_copy_of_project(base_project, "theirs")
2291
+ foo = theirs_project.targets.find { |target| target.display_name == "foo" }
2292
+ foo.file_system_synchronized_groups.delete(foo.file_system_synchronized_groups.first)
2293
+
2294
+ changes = get_diff(theirs_project, base_project)
2295
+ described_class.apply_change_to_project(base_project, changes, theirs_project)
2296
+
2297
+ expect(base_project).to be_equivalent_to_project(theirs_project)
2298
+ expect(base_project.objects.count { |o| o.isa == "PBXFileSystemSynchronizedRootGroup" })
2299
+ .to eq(1)
2300
+ %w[bar baz].each do |name|
2301
+ expect(base_project.targets.find { |t| t.display_name == name }
2302
+ .file_system_synchronized_groups.count).to eq(1)
2303
+ end
2304
+ end
2305
+
2306
+ it "removes a folder shared by two targets and unlinks both in one change" do
2307
+ base_project.new_target("com.apple.product-type.library.static", "bar", :ios)
2308
+ group = add_synchronized_root_group(base_project, "Shared")
2309
+ base_project.targets.each { |target| target.file_system_synchronized_groups << group }
2310
+
2311
+ theirs_project = create_copy_of_project(base_project, "theirs")
2312
+ theirs_project.main_group.children
2313
+ .find { |child| child.display_name == "Shared" }
2314
+ .remove_from_project
2315
+
2316
+ changes = get_diff(theirs_project, base_project)
2317
+ described_class.apply_change_to_project(base_project, changes, theirs_project)
2318
+
2319
+ expect(base_project).to be_equivalent_to_project(theirs_project)
2320
+ expect(base_project.objects.count { |o| o.isa == "PBXFileSystemSynchronizedRootGroup" })
2321
+ .to eq(0)
2322
+ base_project.targets.each do |target|
2323
+ expect(target.file_system_synchronized_groups).to be_empty
2324
+ end
2325
+ end
2326
+
2327
+ it "resolves a same-named folder when another candidate is not in the group tree" do
2328
+ # A buildable folder linked by two targets but absent from the main group has no parent, so
2329
+ # its hierarchy_path raises; resolution must tolerate it rather than aborting the merge.
2330
+ base_project.new_target("com.apple.product-type.library.static", "bar", :ios)
2331
+ groupless = base_project.new(Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup)
2332
+ groupless.source_tree = "<group>"
2333
+ groupless.path = "Shared"
2334
+ base_project.targets.each { |target| target.file_system_synchronized_groups << groupless }
2335
+
2336
+ theirs_project = create_copy_of_project(base_project, "theirs")
2337
+ feature = theirs_project.main_group.new_group("FeatureA")
2338
+ real_group = theirs_project.new(Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup)
2339
+ real_group.source_tree = "<group>"
2340
+ real_group.path = "Shared"
2341
+ feature << real_group
2342
+ theirs_project.targets.find { |t| t.display_name == "foo" }
2343
+ .file_system_synchronized_groups << real_group
2344
+
2345
+ changes = get_diff(theirs_project, base_project)
2346
+ expect {
2347
+ described_class.apply_change_to_project(base_project, changes, theirs_project)
2348
+ }.not_to raise_error
2349
+
2350
+ # The new FeatureA/Shared is linked in addition to the pre-existing group-tree-less folder.
2351
+ expect(base_project.targets.find { |t| t.display_name == "foo" }
2352
+ .file_system_synchronized_groups.count).to eq(2)
2353
+ end
2354
+
2355
+ it "avoids adding a synchronized root group that already exists" do
2356
+ existing_group = add_synchronized_root_group(base_project, "SyncedSources")
2357
+ base_project.targets[0].file_system_synchronized_groups << existing_group
2358
+
2359
+ theirs_project = create_copy_of_project(base_project, "theirs")
2360
+ theirs_group = add_synchronized_root_group(theirs_project, "SyncedSources")
2361
+ theirs_project.targets[0].file_system_synchronized_groups << theirs_group
2362
+
2363
+ changes_to_apply = get_diff(theirs_project, base_project)
2364
+ other_project = create_copy_of_project(base_project, "other")
2365
+ described_class.apply_change_to_project(other_project, changes_to_apply, theirs_project)
2366
+
2367
+ expect(other_project).to be_equivalent_to_project(base_project)
2368
+ end
2369
+
2370
+ it "resolves an exception target added in the same change" do
2371
+ theirs_project = create_copy_of_project(base_project, "theirs")
2372
+ target = theirs_project.new_target("com.apple.product-type.library.static", "bar", :ios)
2373
+ group = add_synchronized_root_group(theirs_project, "SyncedSources")
2374
+ target.file_system_synchronized_groups << group
2375
+
2376
+ klass = Xcodeproj::Project::PBXFileSystemSynchronizedBuildFileExceptionSet
2377
+ exception = theirs_project.new(klass)
2378
+ exception.target = target
2379
+ exception.membership_exceptions = ["Excluded.swift"]
2380
+ group.exceptions << exception
2381
+
2382
+ changes = get_diff(theirs_project, base_project)
2383
+ described_class.apply_change_to_project(base_project, changes, theirs_project)
2384
+
2385
+ resolved = base_project.objects.find { |object| object.is_a?(klass) }
2386
+ expected_target = base_project.targets.find { |item| item.name == "bar" }
2387
+ expect(resolved.target&.uuid).to eq(expected_target.uuid)
2388
+ end
2389
+
2390
+ it "resolves a build-phase exception against the correct target" do
2391
+ base_project.new_target("com.apple.product-type.library.static", "bar", :ios)
2392
+ theirs_project = create_copy_of_project(base_project, "theirs")
2393
+ target = theirs_project.targets.find { |item| item.name == "bar" }
2394
+ group = add_synchronized_root_group(theirs_project, "SyncedSources")
2395
+ target.file_system_synchronized_groups << group
2396
+
2397
+ klass = Xcodeproj::Project::PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet
2398
+ exception = theirs_project.new(klass)
2399
+ exception.build_phase = target.source_build_phase
2400
+ exception.membership_exceptions = ["OnlyInSources.swift"]
2401
+ group.exceptions << exception
2402
+
2403
+ changes = get_diff(theirs_project, base_project)
2404
+ described_class.apply_change_to_project(base_project, changes, theirs_project)
2405
+
2406
+ resolved = base_project.objects.find { |object| object.is_a?(klass) }
2407
+ expected_phase = base_project.targets.find { |item| item.name == "bar" }.source_build_phase
2408
+ expect(resolved.build_phase.uuid).to eq(expected_phase.uuid)
2409
+ end
2410
+
2411
+ it "resolves a build-phase exception when the folder is shared by multiple targets" do
2412
+ base_project.new_target("com.apple.product-type.library.static", "bar", :ios)
2413
+ theirs_project = create_copy_of_project(base_project, "theirs")
2414
+ group = add_synchronized_root_group(theirs_project, "Shared")
2415
+ theirs_project.targets.find { |item| item.display_name == "foo" }
2416
+ .file_system_synchronized_groups << group
2417
+ bar = theirs_project.targets.find { |item| item.display_name == "bar" }
2418
+ bar.file_system_synchronized_groups << group
2419
+
2420
+ klass = Xcodeproj::Project::PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet
2421
+ exception = theirs_project.new(klass)
2422
+ # Reference the SECOND target's Sources phase. "foo" (added first) also references the group
2423
+ # and has its own "Sources" phase, so a resolver scoped only to the referencing targets would
2424
+ # pick foo's phase instead of bar's.
2425
+ exception.build_phase = bar.source_build_phase
2426
+ exception.membership_exceptions = ["OnlyInBarSources.swift"]
2427
+ group.exceptions << exception
2428
+
2429
+ changes = get_diff(theirs_project, base_project)
2430
+ described_class.apply_change_to_project(base_project, changes, theirs_project)
2431
+
2432
+ expect(base_project).to be_equivalent_to_project(theirs_project)
2433
+ resolved = base_project.objects.find { |object| object.is_a?(klass) }
2434
+ expected_phase = base_project.targets.find { |item| item.name == "bar" }.source_build_phase
2435
+ expect(resolved.build_phase.uuid).to eq(expected_phase.uuid)
2436
+ end
2437
+
2438
+ it "attaches the correct folder when two buildable folders share a name" do
2439
+ base_project.new_target("com.apple.product-type.library.static", "bar", :ios)
2440
+ theirs_project = create_copy_of_project(base_project, "theirs")
2441
+
2442
+ feature_a = theirs_project.main_group.new_group("FeatureA")
2443
+ feature_b = theirs_project.main_group.new_group("FeatureB")
2444
+ group_a = theirs_project.new(Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup)
2445
+ group_a.source_tree = "<group>"
2446
+ group_a.path = "Shared"
2447
+ feature_a << group_a
2448
+ group_b = theirs_project.new(Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup)
2449
+ group_b.source_tree = "<group>"
2450
+ group_b.path = "Shared"
2451
+ feature_b << group_b
2452
+ theirs_project.targets.find { |item| item.display_name == "foo" }
2453
+ .file_system_synchronized_groups << group_a
2454
+ theirs_project.targets.find { |item| item.display_name == "bar" }
2455
+ .file_system_synchronized_groups << group_b
2456
+
2457
+ changes = get_diff(theirs_project, base_project)
2458
+ described_class.apply_change_to_project(base_project, changes, theirs_project)
2459
+
2460
+ expect(base_project).to be_equivalent_to_project(theirs_project)
2461
+
2462
+ helper = Xcodeproj::Project::Object::GroupableHelper
2463
+ foo_group = base_project.targets.find { |item| item.display_name == "foo" }
2464
+ .file_system_synchronized_groups.first
2465
+ bar_group = base_project.targets.find { |item| item.display_name == "bar" }
2466
+ .file_system_synchronized_groups.first
2467
+ expect(helper.hierarchy_path(foo_group)).to eq("/FeatureA/Shared")
2468
+ expect(helper.hierarchy_path(bar_group)).to eq("/FeatureB/Shared")
2469
+ end
2470
+
2471
+ it "does not duplicate an exception set added on both sides" do
2472
+ group = add_synchronized_root_group(base_project, "Models")
2473
+ base_project.targets[0].file_system_synchronized_groups << group
2474
+ common_base = create_copy_of_project(base_project, "base")
2475
+
2476
+ add_build_file_exception = lambda do |project|
2477
+ synchronized_group = project.objects.find do |object|
2478
+ object.isa == "PBXFileSystemSynchronizedRootGroup"
2479
+ end
2480
+ exception = project.new(Xcodeproj::Project::PBXFileSystemSynchronizedBuildFileExceptionSet)
2481
+ exception.target = project.targets.find { |target| target.display_name == "foo" }
2482
+ exception.membership_exceptions = ["Excluded.swift"]
2483
+ synchronized_group.exceptions << exception
2484
+ end
2485
+
2486
+ theirs_project = create_copy_of_project(base_project, "theirs")
2487
+ add_build_file_exception.call(theirs_project)
2488
+ add_build_file_exception.call(base_project)
2489
+
2490
+ changes = get_diff(theirs_project, common_base)
2491
+ described_class.apply_change_to_project(base_project, changes, theirs_project)
2492
+
2493
+ exception_count = base_project.objects.count do |object|
2494
+ object.isa == "PBXFileSystemSynchronizedBuildFileExceptionSet"
2495
+ end
2496
+ expect(exception_count).to eq(1)
2497
+ end
2498
+
2499
+ it "does not duplicate a deferred exception added to an existing buildable folder" do
2500
+ group = add_synchronized_root_group(base_project, "Models")
2501
+ base_project.targets[0].file_system_synchronized_groups << group
2502
+ common_base = create_copy_of_project(base_project, "base")
2503
+
2504
+ klass = Xcodeproj::Project::PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet
2505
+ theirs_project = create_copy_of_project(base_project, "theirs")
2506
+ synchronized_group = theirs_project.objects.find do |object|
2507
+ object.isa == "PBXFileSystemSynchronizedRootGroup"
2508
+ end
2509
+ exception = theirs_project.new(klass)
2510
+ exception.build_phase = theirs_project.targets[0].source_build_phase
2511
+ exception.membership_exceptions = ["Excluded.swift"]
2512
+ synchronized_group.exceptions << exception
2513
+
2514
+ changes = get_diff(theirs_project, common_base)
2515
+ described_class.apply_change_to_project(base_project, changes, theirs_project)
2516
+
2517
+ # The build-phase reference is resolved lazily, so the exception travels both the main-group
2518
+ # and the target graph paths with a still-unresolved reference; dedup must recognize it.
2519
+ expect(base_project.objects.count { |object| object.is_a?(klass) }).to eq(1)
2520
+ expect(base_project).to be_equivalent_to_project(theirs_project)
2521
+ end
2522
+
2523
+ it "attaches the correct slashed-path folder when two share a name" do
2524
+ base_project.new_target("com.apple.product-type.library.static", "bar", :ios)
2525
+ theirs_project = create_copy_of_project(base_project, "theirs")
2526
+
2527
+ klass = Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup
2528
+ feature_a = theirs_project.main_group.new_group("FeatureA")
2529
+ feature_b = theirs_project.main_group.new_group("FeatureB")
2530
+ group_a = theirs_project.new(klass)
2531
+ group_a.source_tree = "<group>"
2532
+ group_a.path = "Sub/Shared"
2533
+ feature_a << group_a
2534
+ group_b = theirs_project.new(klass)
2535
+ group_b.source_tree = "<group>"
2536
+ group_b.path = "Sub/Shared"
2537
+ feature_b << group_b
2538
+ theirs_project.targets.find { |item| item.display_name == "foo" }
2539
+ .file_system_synchronized_groups << group_a
2540
+ theirs_project.targets.find { |item| item.display_name == "bar" }
2541
+ .file_system_synchronized_groups << group_b
2542
+
2543
+ changes = get_diff(theirs_project, base_project)
2544
+ described_class.apply_change_to_project(base_project, changes, theirs_project)
2545
+
2546
+ helper = Xcodeproj::Project::Object::GroupableHelper
2547
+ foo_group = base_project.targets.find { |item| item.display_name == "foo" }
2548
+ .file_system_synchronized_groups.first
2549
+ bar_group = base_project.targets.find { |item| item.display_name == "bar" }
2550
+ .file_system_synchronized_groups.first
2551
+ expect(helper.hierarchy_path(foo_group)).to eq("/FeatureA/Sub/Shared")
2552
+ expect(helper.hierarchy_path(bar_group)).to eq("/FeatureB/Sub/Shared")
2553
+ end
2554
+
2555
+ it "serializes a build file exception set with an unset target without crashing" do
2556
+ group = add_synchronized_root_group(base_project, "Models")
2557
+ exception =
2558
+ base_project.new(Xcodeproj::Project::PBXFileSystemSynchronizedBuildFileExceptionSet)
2559
+ group.exceptions << exception
2560
+
2561
+ expect { exception.to_tree_hash }.not_to raise_error
2562
+ expect(exception.to_tree_hash["displayName"])
2563
+ .to eq("Exceptions for \"Models\" folder in \"\" target")
2564
+ end
2565
+
2566
+ it "serializes an exception set with no parent group without infinite recursion" do
2567
+ exception =
2568
+ base_project.new(Xcodeproj::Project::PBXFileSystemSynchronizedBuildFileExceptionSet)
2569
+
2570
+ expect { exception.to_tree_hash }.not_to raise_error
2571
+ end
2572
+
2573
+ it "serializes a parent-less build-phase exception set without infinite recursion" do
2574
+ klass = Xcodeproj::Project::PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet
2575
+ exception = base_project.new(klass)
2576
+
2577
+ expect { exception.to_tree_hash }.not_to raise_error
2578
+ expect(exception.to_tree_hash["displayName"])
2579
+ .to eq("Exceptions for \"\" folder in \"\" build phase")
2580
+ end
2581
+
2582
+ it "links both folders when one target references two folders sharing a name" do
2583
+ theirs_project = create_copy_of_project(base_project, "theirs")
2584
+ target = theirs_project.targets.find { |item| item.display_name == "foo" }
2585
+
2586
+ klass = Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup
2587
+ %w[FeatureA FeatureB].each do |feature|
2588
+ parent = theirs_project.main_group.new_group(feature)
2589
+ group = theirs_project.new(klass)
2590
+ group.source_tree = "<group>"
2591
+ group.path = "Shared"
2592
+ parent << group
2593
+ target.file_system_synchronized_groups << group
2594
+ end
2595
+
2596
+ changes = get_diff(theirs_project, base_project)
2597
+ described_class.apply_change_to_project(base_project, changes, theirs_project)
2598
+
2599
+ helper = Xcodeproj::Project::Object::GroupableHelper
2600
+ linked = base_project.targets.find { |item| item.display_name == "foo" }
2601
+ .file_system_synchronized_groups
2602
+ expect(linked.map { |group| helper.hierarchy_path(group) })
2603
+ .to contain_exactly("/FeatureA/Shared", "/FeatureB/Shared")
2604
+ end
2605
+
2606
+ it "keeps exception sets that differ only by target" do
2607
+ base_project.new_target("com.apple.product-type.library.static", "bar", :ios)
2608
+ theirs_project = create_copy_of_project(base_project, "theirs")
2609
+ group = add_synchronized_root_group(theirs_project, "Models")
2610
+ theirs_project.targets.each { |target| target.file_system_synchronized_groups << group }
2611
+
2612
+ klass = Xcodeproj::Project::PBXFileSystemSynchronizedBuildFileExceptionSet
2613
+ %w[foo bar].each do |target_name|
2614
+ exception = theirs_project.new(klass)
2615
+ exception.target = theirs_project.targets.find { |t| t.display_name == target_name }
2616
+ exception.membership_exceptions = ["Excluded.swift"]
2617
+ group.exceptions << exception
2618
+ end
2619
+
2620
+ changes = get_diff(theirs_project, base_project)
2621
+ described_class.apply_change_to_project(base_project, changes, theirs_project)
2622
+
2623
+ expect(base_project.objects.count { |object| object.is_a?(klass) }).to eq(2)
2624
+ expect(base_project).to be_equivalent_to_project(theirs_project)
2625
+ end
2626
+
2627
+ it "keeps exception sets that differ only by membership" do
2628
+ theirs_project = create_copy_of_project(base_project, "theirs")
2629
+ group = add_synchronized_root_group(theirs_project, "Models")
2630
+ theirs_project.targets[0].file_system_synchronized_groups << group
2631
+
2632
+ klass = Xcodeproj::Project::PBXFileSystemSynchronizedBuildFileExceptionSet
2633
+ %w[A B].each do |suffix|
2634
+ exception = theirs_project.new(klass)
2635
+ exception.target = theirs_project.targets[0]
2636
+ exception.membership_exceptions = ["Excluded#{suffix}.swift"]
2637
+ group.exceptions << exception
2638
+ end
2639
+
2640
+ changes = get_diff(theirs_project, base_project)
2641
+ described_class.apply_change_to_project(base_project, changes, theirs_project)
2642
+
2643
+ expect(base_project.objects.count { |object| object.is_a?(klass) }).to eq(2)
2644
+ expect(base_project).to be_equivalent_to_project(theirs_project)
2645
+ end
2646
+
2647
+ it "recreates a synchronized root group modified on theirs and deleted on ours" do
2648
+ allow(Kintsugi::ConflictResolver).to receive(:create_nonexistent_component_when_changing_it?)
2649
+ .and_return(true)
2650
+
2651
+ group = add_synchronized_root_group(base_project, "Models")
2652
+ base_project.targets[0].file_system_synchronized_groups << group
2653
+ common_base = create_copy_of_project(base_project, "base")
2654
+
2655
+ theirs_project = create_copy_of_project(base_project, "theirs")
2656
+ theirs_project.objects.find { |object| object.isa == "PBXFileSystemSynchronizedRootGroup" }
2657
+ .explicit_folders = ["Generated"]
2658
+
2659
+ base_project.objects.find { |object| object.isa == "PBXFileSystemSynchronizedRootGroup" }
2660
+ .remove_from_project
2661
+
2662
+ changes = get_diff(theirs_project, common_base)
2663
+ expect {
2664
+ described_class.apply_change_to_project(base_project, changes, theirs_project)
2665
+ }.not_to raise_error
2666
+
2667
+ recreated =
2668
+ base_project.objects.find { |object| object.isa == "PBXFileSystemSynchronizedRootGroup" }
2669
+ expect(recreated&.explicit_folders).to eq(["Generated"])
2670
+ end
2671
+
2672
+ context "when duplicates are allowed" do
2673
+ before { Kintsugi::Settings.allow_duplicates = true }
2674
+
2675
+ after { Kintsugi::Settings.allow_duplicates = false }
2676
+
2677
+ it "adds a synchronized root group that already exists" do
2678
+ existing_group = add_synchronized_root_group(base_project, "SyncedSources")
2679
+ base_project.targets[0].file_system_synchronized_groups << existing_group
2680
+
2681
+ theirs_project = create_copy_of_project(base_project, "theirs")
2682
+ theirs_group = add_synchronized_root_group(theirs_project, "SyncedSources")
2683
+ theirs_project.targets[0].file_system_synchronized_groups << theirs_group
2684
+
2685
+ changes_to_apply = get_diff(theirs_project, base_project)
2686
+ other_project = create_copy_of_project(base_project, "other")
2687
+ described_class.apply_change_to_project(other_project, changes_to_apply, theirs_project)
2688
+
2689
+ expect(other_project.targets[0].file_system_synchronized_groups.count).to eq(2)
2690
+ end
2691
+
2692
+ it "still does not duplicate an exception on an existing folder" do
2693
+ group = add_synchronized_root_group(base_project, "Models")
2694
+ base_project.targets[0].file_system_synchronized_groups << group
2695
+ common_base = create_copy_of_project(base_project, "base")
2696
+
2697
+ klass = Xcodeproj::Project::PBXFileSystemSynchronizedBuildFileExceptionSet
2698
+ theirs_project = create_copy_of_project(base_project, "theirs")
2699
+ synchronized_group = theirs_project.objects.find do |object|
2700
+ object.isa == "PBXFileSystemSynchronizedRootGroup"
2701
+ end
2702
+ exception = theirs_project.new(klass)
2703
+ exception.target = theirs_project.targets[0]
2704
+ exception.membership_exceptions = ["Excluded.swift"]
2705
+ synchronized_group.exceptions << exception
2706
+
2707
+ changes = get_diff(theirs_project, common_base)
2708
+ described_class.apply_change_to_project(base_project, changes, theirs_project)
2709
+
2710
+ # The two-pass traversal visits the same source exception twice; that artifact must be
2711
+ # suppressed even when duplicates are allowed (an identical exception set is meaningless).
2712
+ expect(base_project.objects.count { |object| object.is_a?(klass) }).to eq(1)
2713
+ end
2714
+
2715
+ it "still does not duplicate a deferred build-phase exception on an existing folder" do
2716
+ group = add_synchronized_root_group(base_project, "Models")
2717
+ base_project.targets[0].file_system_synchronized_groups << group
2718
+ common_base = create_copy_of_project(base_project, "base")
2719
+
2720
+ klass = Xcodeproj::Project::PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet
2721
+ theirs_project = create_copy_of_project(base_project, "theirs")
2722
+ synchronized_group = theirs_project.objects.find do |object|
2723
+ object.isa == "PBXFileSystemSynchronizedRootGroup"
2724
+ end
2725
+ exception = theirs_project.new(klass)
2726
+ exception.build_phase = theirs_project.targets[0].source_build_phase
2727
+ exception.membership_exceptions = ["Excluded.swift"]
2728
+ synchronized_group.exceptions << exception
2729
+
2730
+ changes = get_diff(theirs_project, common_base)
2731
+ described_class.apply_change_to_project(base_project, changes, theirs_project)
2732
+
2733
+ expect(base_project.objects.count { |object| object.is_a?(klass) }).to eq(1)
2734
+ end
2735
+ end
2736
+ end
2737
+
2151
2738
  def create_copy_of_project(project, new_project_prefix)
2152
2739
  copied_project_path = make_temp_directory(new_project_prefix, ".xcodeproj")
2153
2740
  project.save(copied_project_path)
@@ -125,6 +125,51 @@ shared_examples "tests" do |git_command, project_name|
125
125
  expect(`git -C #{git_directory_path} diff --name-only --diff-filter=U`.chomp)
126
126
  .to eq("#{project_name}/project.pbxproj")
127
127
  end
128
+
129
+ it "resolves conflicts when adding a file system synchronized root group" do
130
+ File.write(File.join(git_directory_path, ".gitattributes"), "*.pbxproj merge=Unset")
131
+
132
+ project = create_new_project_at_path(File.join(git_directory_path, project_name))
133
+
134
+ git.add(File.join(git_directory_path, ".gitattributes"))
135
+ git.add(project.path)
136
+ git.commit("Initial project")
137
+
138
+ # A buildable folder, as created by Xcode 16: a `PBXFileSystemSynchronizedRootGroup` that
139
+ # lives in the main group and is referenced by the target. Before this feature, merging any
140
+ # conflict on such a project failed with "Trying to add unsupported component type
141
+ # PBXFileSystemSynchronizedRootGroup".
142
+ target = project.new_target("com.apple.product-type.library.static", "foo", :ios)
143
+ group = project.new(Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup)
144
+ group.source_tree = "<group>"
145
+ group.path = "SyncedSources"
146
+ project.main_group.children << group
147
+ target.file_system_synchronized_groups << group
148
+ project.save
149
+
150
+ git.add(all: true)
151
+ git.commit("Add target foo with a buildable folder")
152
+ first_commit_hash = git.revparse("HEAD")
153
+
154
+ git.checkout("HEAD^")
155
+ project = Xcodeproj::Project.open(project.path)
156
+ project.new_target("com.apple.product-type.library.static", "bar", :ios)
157
+ project.save
158
+ git.add(all: true)
159
+ git.commit("Add target bar")
160
+
161
+ `git -C #{git_directory_path} #{git_command} #{first_commit_hash} &> /dev/null`
162
+ Kintsugi.run([File.join(project.path, "project.pbxproj")])
163
+
164
+ project = Xcodeproj::Project.open(project.path)
165
+ synchronized_groups = project.objects.select do |object|
166
+ object.isa == "PBXFileSystemSynchronizedRootGroup"
167
+ end
168
+ expect(project.targets.map(&:display_name)).to contain_exactly("foo", "bar")
169
+ expect(synchronized_groups.count).to eq(1)
170
+ expect(project.targets.find { |native_target| native_target.display_name == "foo" }
171
+ .file_system_synchronized_groups.first).to equal(synchronized_groups.first)
172
+ end
128
173
  end
129
174
 
130
175
  def make_temp_directory