kintsugi 0.7.9 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e84a9556f00f729599cefe60241abf1e60e025dfd3445fe8878990214d12932b
4
- data.tar.gz: e719d02da96de57206c02c6846370cf0baf712e9ca79c58e1ce8e958aa6996cb
3
+ metadata.gz: feebfac564140617fc5d55b773eadb7bc12b90ce0a10c1498827fd445319c36c
4
+ data.tar.gz: 364efec483aa9be794146d57d22d3ce0093bdeadc29c5cc16bc837bf42147088
5
5
  SHA512:
6
- metadata.gz: a1629a6c641c5926489bf84ff8e840da1d90f3a4af17745aeed51fd70e9da054dbbdbcf36644b83017d262a327348a8e924dea42e02e6cffa7caa924d4e4f2e4
7
- data.tar.gz: 9dccb781f8b52926e7ba1a2fcfbfdeec54c8a2fe6f3581c6fea877f3a8e983ee13019441d73d4cb7aa2b42fd4b96aea727ab18dea352e7b3cf9580233159b5d8
6
+ metadata.gz: b12c34ac0ba743b080e81cc006cfbea332335493d66f5703bf40bc9ae84e7b2bec7d352d67fd2dcc8f1bd401c75d6d438516ba23e5d0bc6c68c42d1cad270d44
7
+ data.tar.gz: 012a869ace7be6b2869cb060181d7aebb2ee231c27a811f17b85e9a4b7904fd0046e44aa9cd647db05e77ea4d8775b8ccee7acdb99b9abda0d3ba54c308f8817
data/kintsugi.gemspec CHANGED
@@ -23,7 +23,7 @@ Gem::Specification.new do |spec|
23
23
  spec.require_paths = ["lib"]
24
24
 
25
25
  spec.add_dependency "tty-prompt", "~> 0"
26
- spec.add_dependency "xcodeproj", ">= 1.19.0", "<= 1.27.0"
26
+ spec.add_dependency "xcodeproj", ">= 1.26.0", "<= 1.27.0"
27
27
 
28
28
  spec.add_development_dependency "git", "~> 1.11"
29
29
  spec.add_development_dependency "rake", "~> 13.0"
@@ -24,6 +24,12 @@ end
24
24
 
25
25
  module Kintsugi
26
26
  class << self
27
+ # Isas of components whose additions and removals are handled by the main group pipeline
28
+ # (`apply_group_additions`/`apply_group_removals`). `PBXFileSystemSynchronizedRootGroup` is the
29
+ # Xcode 16 "buildable folder" object, which lives in the group tree like a group.
30
+ GROUP_PIPELINE_ISAS =
31
+ %w[PBXGroup PBXVariantGroup PBXFileSystemSynchronizedRootGroup].freeze
32
+
27
33
  # Applies the change specified by `change` to `project`.
28
34
  #
29
35
  # @param [Xcodeproj::Project] project
@@ -45,6 +51,7 @@ module Kintsugi
45
51
  @change_source_project = change_source_project
46
52
  @ignored_components_group_paths = []
47
53
  @created_components_group_paths = []
54
+ @pending_exception_references = []
48
55
 
49
56
  # We iterate over the main group and project references first because they might create file
50
57
  # or project references that are referenced in other parts.
@@ -65,6 +72,11 @@ module Kintsugi
65
72
  change["rootObject"].reject { |key|
66
73
  %w[mainGroup projectReferences].include?(key)
67
74
  }, "")
75
+
76
+ # Exception sets reference targets and build phases that may only be created later in the
77
+ # change (and groups are linked to their targets only in the rootObject pass above), so their
78
+ # references are resolved here, once everything exists.
79
+ resolve_pending_exception_references
68
80
  end
69
81
 
70
82
  private
@@ -107,7 +119,7 @@ module Kintsugi
107
119
 
108
120
  def apply_group_additions(project, additions, force_create_containing_group: false)
109
121
  additions.each do |change, path|
110
- next unless %w[PBXGroup PBXVariantGroup].include?(change["isa"])
122
+ next unless GROUP_PIPELINE_ISAS.include?(change["isa"])
111
123
 
112
124
  group_type = Module.const_get("Xcodeproj::Project::#{change["isa"]}")
113
125
  containing_group = project.group_or_file_at_path(path)
@@ -244,7 +256,8 @@ module Kintsugi
244
256
  when Xcodeproj::Project::PBXFileReference
245
257
  apply_file_changes(project, [[component_change, containing_group_path]], [],
246
258
  force_create_containing_group: true)
247
- when Xcodeproj::Project::PBXGroup
259
+ when Xcodeproj::Project::PBXGroup,
260
+ Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup
248
261
  apply_group_additions(project, [[component_change, containing_group_path]],
249
262
  force_create_containing_group: true)
250
263
  else
@@ -256,14 +269,16 @@ module Kintsugi
256
269
 
257
270
  def apply_group_removals(project, removals)
258
271
  removals.sort_by(&:last).reverse.each do |change, path|
259
- next unless %w[PBXGroup PBXVariantGroup].include?(change["isa"])
272
+ next unless GROUP_PIPELINE_ISAS.include?(change["isa"])
260
273
 
261
274
  group_path = join_path(path, change["displayName"])
262
275
 
263
276
  # by now we've deleted all of this group's children in the project, so we need to adapt the
264
277
  # change to the expected current state of the group, that is, without any children.
278
+ # `PBXFileSystemSynchronizedRootGroup` has no `children` attribute, so we must not inject an
279
+ # empty one, otherwise `remove_component`'s tree hash comparison would never match.
265
280
  change_without_children = change.dup
266
- change_without_children["children"] = []
281
+ change_without_children["children"] = [] if change.key?("children")
267
282
 
268
283
  remove_component(project[group_path], change_without_children)
269
284
  end
@@ -291,12 +306,7 @@ module Kintsugi
291
306
  if change[:removed].is_a?(Hash)
292
307
  remove_component(component, change[:removed])
293
308
  elsif change[:removed].is_a?(Array)
294
- unless component.nil?
295
- (change[:removed]).each do |removed_change|
296
- child = child_component_of_object_list(component, removed_change["displayName"])
297
- remove_component(child, removed_change)
298
- end
299
- end
309
+ remove_children_from_object_list(component, change[:removed]) unless component.nil?
300
310
  elsif !change[:removed].nil?
301
311
  raise MergeError, "Unsupported removed change type for #{change[:removed]}"
302
312
  end
@@ -323,6 +333,22 @@ module Kintsugi
323
333
  end
324
334
  end
325
335
 
336
+ def remove_children_from_object_list(object_list, removed_changes)
337
+ removed_changes.each do |removed_change|
338
+ child = child_component_of_object_list(object_list, removed_change["displayName"])
339
+ if removed_change["isa"] == "PBXFileSystemSynchronizedRootGroup"
340
+ # A target references the buildable folder object; unlinking it must detach the reference,
341
+ # not unconditionally delete the shared object. `ObjectList#delete` is reference-counted:
342
+ # it removes the object only when this was its last referrer, so a folder still referenced
343
+ # by the main group or another target survives. (Full deletion of the folder itself is
344
+ # driven separately by its main group entry, via `apply_group_removals`.)
345
+ object_list.delete(child) unless child.nil?
346
+ else
347
+ remove_component(child, removed_change)
348
+ end
349
+ end
350
+ end
351
+
326
352
  def resolve_nonexistent_component(parent_component, change_path)
327
353
  source_project_component = component_at_path(@change_source_project, change_path)
328
354
  group_path = group_path_of_group_based_component(source_project_component)
@@ -353,6 +379,10 @@ module Kintsugi
353
379
  elsif component.is_a?(Xcodeproj::Project::PBXFileReference) ||
354
380
  component.is_a?(Xcodeproj::Project::PBXGroup)
355
381
  component.hierarchy_path.delete_prefix("/")
382
+ elsif component.is_a?(Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup)
383
+ # A synchronized root group is not a `PBXGroup`, so it has no `hierarchy_path` instance
384
+ # method; the groupable helper computes the same path from its parent chain.
385
+ Xcodeproj::Project::Object::GroupableHelper.hierarchy_path(component).delete_prefix("/")
356
386
  end
357
387
  end
358
388
 
@@ -652,6 +682,13 @@ module Kintsugi
652
682
  add_file_reference(component, change, change_path)
653
683
  when "PBXGroup"
654
684
  add_group(component, change, change_path)
685
+ when "PBXFileSystemSynchronizedRootGroup"
686
+ add_file_system_synchronized_root_group(component, change, change_path)
687
+ when "PBXFileSystemSynchronizedBuildFileExceptionSet"
688
+ add_file_system_synchronized_build_file_exception_set(component, change, change_path)
689
+ when "PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet"
690
+ add_file_system_synchronized_group_build_phase_membership_exception_set(component, change,
691
+ change_path)
655
692
  when "PBXContainerItemProxy"
656
693
  add_container_item_proxy(component, change, change_path)
657
694
  when "PBXTargetDependency"
@@ -768,6 +805,239 @@ module Kintsugi
768
805
  end
769
806
  end
770
807
 
808
+ # Known limitation: buildable folders have no stable identity in a project diff (they're
809
+ # compared by value), so relocating a folder between parent groups while it stays linked to a
810
+ # target reads as remove-from-A + add-to-B. The old object (and its target link) is removed, and
811
+ # the new object is created but not re-linked to the target, since the target's own change is
812
+ # empty. Such a merge can drop the folder's target membership; re-verify after relocating.
813
+ def add_file_system_synchronized_root_group(containing_component, change, change_path)
814
+ case containing_component
815
+ when Xcodeproj::Project::PBXNativeTarget
816
+ # The group was already created in the group tree by the main group pass. Resolve it by its
817
+ # position in the tree so we reuse the exact same object, even when another buildable folder
818
+ # shares its name in a different parent group.
819
+ group = resolve_file_system_synchronized_root_group(containing_component, change)
820
+ if group.nil?
821
+ raise MergeError, "No file system synchronized root group matching #{change} was " \
822
+ "found in the group tree. Change path: #{change_path}"
823
+ end
824
+
825
+ # Dedup by object identity, not display name: one target can legitimately link several
826
+ # same-named folders from different parent groups, each a distinct object.
827
+ already_linked = containing_component.file_system_synchronized_groups.any? do |linked|
828
+ linked.uuid == group.uuid
829
+ end
830
+ return if !Settings.allow_duplicates && already_linked
831
+
832
+ containing_component.file_system_synchronized_groups << group
833
+ else
834
+ raise MergeError, "Trying to add file system synchronized root group to an unsupported " \
835
+ "component type #{containing_component.isa}. Change is: #{change}"
836
+ end
837
+ end
838
+
839
+ def resolve_file_system_synchronized_root_group(target, change)
840
+ candidates = target.project.objects.select do |object|
841
+ object.isa == "PBXFileSystemSynchronizedRootGroup" &&
842
+ object.display_name == change["displayName"]
843
+ end
844
+ return candidates.first if candidates.length <= 1
845
+
846
+ # More than one buildable folder shares this name (in different parent groups). Match against
847
+ # the folders the change's target links in the source project, comparing hierarchy paths as
848
+ # strings. A folder's own `path`/`display_name` may itself contain "/", so this must not be
849
+ # split into segments, so path-walking helpers like `group_or_file_at_path` can't be used.
850
+ source_hierarchies = source_synchronized_root_group_hierarchies(target, change)
851
+ source_matching = candidates.select do |candidate|
852
+ source_hierarchies.include?(synchronized_root_group_hierarchy_path(candidate))
853
+ end
854
+ return candidates.first if source_matching.empty?
855
+
856
+ # Prefer a folder not already linked to this target, so a target linking two same-named
857
+ # folders resolves each addition to a distinct object rather than dropping the second.
858
+ linked_uuids = target.file_system_synchronized_groups.map(&:uuid)
859
+ source_matching.find { |candidate| !linked_uuids.include?(candidate.uuid) } ||
860
+ source_matching.first
861
+ end
862
+
863
+ # Hierarchy paths (in source order) of the buildable folders the change's target links that
864
+ # match `change`. A target may link several same-named folders from different parents; their
865
+ # identity lets us map each addition to the right destination object.
866
+ def source_synchronized_root_group_hierarchies(target, change)
867
+ source_target = find_target(@change_source_project, target.display_name)
868
+ (source_target&.file_system_synchronized_groups || [])
869
+ .select { |group| group.to_tree_hash == change }
870
+ .map { |group| synchronized_root_group_hierarchy_path(group) }
871
+ .compact
872
+ end
873
+
874
+ # Groupable helper raises a `RuntimeError` consistency error (rather than returning nil) for an
875
+ # object with no parent group; treat that as "no hierarchy" so a stray unparented candidate
876
+ # can't abort the merge. Narrowly rescued so unrelated errors still surface.
877
+ def synchronized_root_group_hierarchy_path(group)
878
+ Xcodeproj::Project::Object::GroupableHelper.hierarchy_path(group)
879
+ rescue RuntimeError
880
+ nil
881
+ end
882
+
883
+ def add_file_system_synchronized_build_file_exception_set(containing_component, change,
884
+ change_path)
885
+ unless containing_component.is_a?(Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup)
886
+ raise MergeError, "Trying to add file system synchronized build file exception set to an " \
887
+ "unsupported component type #{containing_component.isa}. Change is: " \
888
+ "#{change}"
889
+ end
890
+ return if exception_set_already_exists?(containing_component, change)
891
+
892
+ exception_set = containing_component.project.new(
893
+ Xcodeproj::Project::PBXFileSystemSynchronizedBuildFileExceptionSet
894
+ )
895
+ containing_component.exceptions << exception_set
896
+ add_attributes_to_component(exception_set, change, change_path, ignore_keys: ["target"])
897
+ resolve_or_defer_exception_reference(exception_set, :target, change["target"])
898
+ end
899
+
900
+ def add_file_system_synchronized_group_build_phase_membership_exception_set(
901
+ containing_component, change, change_path
902
+ )
903
+ unless containing_component.is_a?(Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup)
904
+ raise MergeError, "Trying to add file system synchronized group build phase membership " \
905
+ "exception set to an unsupported component type " \
906
+ "#{containing_component.isa}. Change is: #{change}"
907
+ end
908
+ return if exception_set_already_exists?(containing_component, change)
909
+
910
+ exception_set = containing_component.project.new(
911
+ Xcodeproj::Project::PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet
912
+ )
913
+ containing_component.exceptions << exception_set
914
+ add_attributes_to_component(exception_set, change, change_path, ignore_keys: ["buildPhase"])
915
+ resolve_or_defer_exception_reference(exception_set, :build_phase, change["buildPhase"])
916
+ end
917
+
918
+ # `true` if an equivalent exception set already exists on `group`. This is intentionally NOT
919
+ # gated on `Settings.allow_duplicates`: a synchronized root group is referenced from both the
920
+ # main group and its target(s), so the same exception addition is visited through several graph
921
+ # paths, and two exception sets identical in target/build phase + membership are meaningless.
922
+ def exception_set_already_exists?(group, change)
923
+ group.exceptions.any? { |exception_set| exception_set_matches_change?(exception_set, change) }
924
+ end
925
+
926
+ def exception_set_matches_change?(exception_set, change)
927
+ signature = exception_set.to_tree_hash
928
+ pending = @pending_exception_references.find { |set, _, _| set.equal?(exception_set) }
929
+ unless pending.nil?
930
+ _, kind, reference = pending
931
+ signature = signature.merge(exception_reference_key(kind) => reference)
932
+ end
933
+ # `displayName` is derived from the (possibly still-unresolved) reference and the folder name,
934
+ # so it carries no information the reference key and simple attributes don't already carry;
935
+ # excluding it avoids a spurious mismatch when the reference is still pending (nil).
936
+ signature.reject { |key, _| key == "displayName" } ==
937
+ change.reject { |key, _| key == "displayName" }
938
+ end
939
+
940
+ def exception_reference_key(kind)
941
+ kind == :target ? "target" : "buildPhase"
942
+ end
943
+
944
+ # Resolves the target/build phase an exception set references. The target may be created, and
945
+ # the group linked to its targets, only later, so unresolvable references are recorded and
946
+ # retried by `resolve_pending_exception_references` after the whole change is applied. Build
947
+ # phase resolution always defers: it needs the group already linked to a target.
948
+ def resolve_or_defer_exception_reference(exception_set, kind, reference)
949
+ # Only the target reference is resolved eagerly (it just needs the target to exist). The build
950
+ # phase reference is always deferred, because the phase it points to might itself be created
951
+ # later in the same change (e.g. a target and its build phases added together).
952
+ if kind == :target
953
+ resolved = resolve_exception_reference(exception_set, kind, reference)
954
+ unless resolved.nil?
955
+ assign_exception_reference(exception_set, kind, resolved)
956
+ return
957
+ end
958
+ end
959
+
960
+ @pending_exception_references << [exception_set, kind, reference]
961
+ end
962
+
963
+ def resolve_pending_exception_references
964
+ @pending_exception_references.each do |exception_set, kind, reference|
965
+ resolved = resolve_exception_reference(exception_set, kind, reference)
966
+ if resolved.nil?
967
+ puts "Warning: Couldn't resolve #{kind} reference #{reference.inspect} for exception " \
968
+ "set '#{exception_set.display_name}'."
969
+ next
970
+ end
971
+
972
+ assign_exception_reference(exception_set, kind, resolved)
973
+ end
974
+ @pending_exception_references = []
975
+ end
976
+
977
+ def resolve_exception_reference(exception_set, kind, reference)
978
+ case kind
979
+ when :target
980
+ find_target(exception_set.project, reference)
981
+ when :build_phase
982
+ find_synchronized_group_build_phase(exception_set, reference)
983
+ end
984
+ end
985
+
986
+ def assign_exception_reference(exception_set, kind, resolved)
987
+ case kind
988
+ when :target then exception_set.target = resolved
989
+ when :build_phase then exception_set.build_phase = resolved
990
+ end
991
+ end
992
+
993
+ # Finds the build phase an exception set references. The reference carries its owning target,
994
+ # so we resolve that target by name (unique) then its build phase by name. This disambiguates
995
+ # same-named phases (e.g. "Sources") when the group is shared by several targets. Returns nil
996
+ # when the target isn't created yet, so the caller can defer.
997
+ def find_synchronized_group_build_phase(exception_set, reference)
998
+ project = exception_set.project
999
+ target_name = reference.is_a?(Hash) ? reference["target"] : nil
1000
+ phase_name = reference.is_a?(Hash) ? reference["name"] : reference
1001
+
1002
+ candidate_targets =
1003
+ if target_name.nil?
1004
+ synchronized_group_referencing_targets(exception_set)
1005
+ else
1006
+ target = find_target(project, target_name)
1007
+ return nil if target.nil?
1008
+
1009
+ [target]
1010
+ end
1011
+
1012
+ matching_build_phases = candidate_targets.flat_map(&:build_phases).select do |build_phase|
1013
+ build_phase.display_name == phase_name
1014
+ end
1015
+ if matching_build_phases.length > 1
1016
+ puts "Debug: Multiple build phases named '#{phase_name}'. Using the first one."
1017
+ end
1018
+ matching_build_phases.first
1019
+ end
1020
+
1021
+ # The targets that reference the exception set's owning synchronized root group. Used as a
1022
+ # fallback when a build phase reference doesn't carry its owning target. The groupable helper
1023
+ # raises `RuntimeError` for an unparented set (e.g. its group was removed earlier in the same
1024
+ # change); fall back to all native targets rather than aborting the merge.
1025
+ def synchronized_group_referencing_targets(exception_set)
1026
+ project = exception_set.project
1027
+ group =
1028
+ begin
1029
+ Xcodeproj::Project::Object::GroupableHelper.parent(exception_set)
1030
+ rescue RuntimeError
1031
+ nil
1032
+ end
1033
+ return project.native_targets if group.nil?
1034
+
1035
+ referencing_targets = project.native_targets.select do |target|
1036
+ target.file_system_synchronized_groups.any? { |linked| linked.uuid == group.uuid }
1037
+ end
1038
+ referencing_targets.empty? ? project.native_targets : referencing_targets
1039
+ end
1040
+
771
1041
  def add_build_rule(target, change, change_path)
772
1042
  build_rule = target.project.new(Xcodeproj::Project::PBXBuildRule)
773
1043
  target.build_rules << build_rule
@@ -3,6 +3,6 @@
3
3
  module Kintsugi
4
4
  # This module holds the Kintsugi version information.
5
5
  module Version
6
- STRING = "0.7.9"
6
+ STRING = "0.7.10"
7
7
  end
8
8
  end
@@ -126,6 +126,69 @@ module Xcodeproj
126
126
  end
127
127
  end
128
128
 
129
+ # Modifies `PBXFileSystemSynchronizedBuildFileExceptionSet`'s `to_tree_hash` to serialize its
130
+ # `target` as a reference (by display name) instead of recursing into it. Without this, the
131
+ # target expands into a hash that contains the synchronized root group owning this exception
132
+ # set, which owns this exception set, causing infinite recursion.
133
+ class PBXFileSystemSynchronizedBuildFileExceptionSet
134
+ # xcodeproj 1.27.0's `display_name` interpolates `#{target.name}`; guard against a nil target
135
+ # (which occurs transiently while the target reference is resolved) to avoid `NoMethodError`
136
+ # during serialization or tree hashing. `GroupableHelper.parent` raises (and re-interpolates
137
+ # `display_name`, recursing to a stack overflow) when the set has no referrers, so only call
138
+ # it when a parent actually exists.
139
+ def display_name
140
+ folder_name = referrers.empty? ? nil : GroupableHelper.parent(self)&.display_name
141
+ "Exceptions for \"#{folder_name}\" folder in \"#{target&.name}\" target"
142
+ end
143
+
144
+ def to_tree_hash
145
+ hash = { 'displayName' => display_name, 'isa' => isa }
146
+ self.class.simple_attributes.each do |attribute|
147
+ value = attribute.get_value(self)
148
+ hash[attribute.plist_name] = value unless value.nil?
149
+ end
150
+ hash['target'] = target.display_name if target
151
+ hash
152
+ end
153
+ end
154
+
155
+ # Same fix as `PBXFileSystemSynchronizedBuildFileExceptionSet`, for the build phase membership
156
+ # variant, whose recursing reference is `build_phase`.
157
+ class PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet
158
+ # xcodeproj 1.27.0's `display_name` calls `build_phase.name`, which build phases don't
159
+ # implement, raising `NoMethodError` on any serialization or tree hash of this object. Use
160
+ # the build phase's `display_name` (e.g. "Sources") instead. Also guard the parent lookup,
161
+ # which otherwise raises and recurses to a stack overflow when the set has no referrers.
162
+ def display_name
163
+ folder_name = referrers.empty? ? nil : GroupableHelper.parent(self)&.display_name
164
+ "Exceptions for \"#{folder_name}\" folder in \"#{build_phase&.display_name}\" build phase"
165
+ end
166
+
167
+ def to_tree_hash
168
+ hash = { 'displayName' => display_name, 'isa' => isa }
169
+ self.class.simple_attributes.each do |attribute|
170
+ value = attribute.get_value(self)
171
+ hash[attribute.plist_name] = value unless value.nil?
172
+ end
173
+ hash['buildPhase'] = build_phase_reference if build_phase
174
+ hash
175
+ end
176
+
177
+ # Serializes `build_phase` as a reference that also carries its owning target, so it can be
178
+ # resolved unambiguously even when the owning group is shared by multiple targets that each
179
+ # have a build phase with the same name (e.g. "Sources").
180
+ def build_phase_reference
181
+ reference = { 'name' => build_phase.display_name }
182
+ # Match by UUID: xcodeproj compares build phases by value, so `include?` would match a
183
+ # same-named empty phase on an unrelated target.
184
+ owning_target = build_phase.project.native_targets.find do |target|
185
+ target.build_phases.any? { |phase| phase.uuid == build_phase.uuid }
186
+ end
187
+ reference['target'] = owning_target.display_name unless owning_target.nil?
188
+ reference
189
+ end
190
+ end
191
+
129
192
  # By default, for this type, the `display_name` is used when calling `ascii_plist_annotation` (which is used
130
193
  # to serialize the project to disk). In the case where the `display_name` contains a "plugin:" prefix, which
131
194
  # means that the package is a plugin, the prefix is ommitted so just the package name is used.