toggle-local-spm 2.2.2

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cf5e1291d028c334f9fd40a089700d1bba713a637b6def0a7b46fe9b970decc4
4
+ data.tar.gz: 6c8ee86f80b2a062f4949d07027781a5ce35f42ff7ad6c51e33f0a4a1efb86df
5
+ SHA512:
6
+ metadata.gz: 2c5fd4f849cbdf6280e5bba06cd9839b64c6aa4b4ddb52eef40c43b7b1a41bc6328ce1f978152c814f3d35a9750a27ccc36b3ec118b48c53ac333e6832e90def
7
+ data.tar.gz: 14a34f2964b727573e5f45197d71cb62a94a2a6b00447f2e5eaf696fa9fabe25b03dc8e2bcf29a5575a26f30ab1838ee0d3c0abd5ae42f025d62426122f58a8e
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Sushant Verma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,320 @@
1
+ # toggle-local-spm
2
+
3
+ [![Tests](https://github.com/squeaky-nose/toggle-local-spm/actions/workflows/tests.yml/badge.svg)](https://github.com/squeaky-nose/toggle-local-spm/actions/workflows/tests.yml)
4
+ [![codecov](https://codecov.io/gh/squeaky-nose/toggle-local-spm/graph/badge.svg?token=0SUUH3UPIQ)](https://codecov.io/gh/squeaky-nose/toggle-local-spm)
5
+ [![Gem Version](https://badge.fury.io/rb/toggle-local-spm.svg)](https://rubygems.org/gems/toggle-local-spm)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ A CLI that toggles Swift Package Manager dependencies in an Xcode project
9
+ between their remote (git) reference and a local checkout in a sibling
10
+ folder — useful when developing a package alongside the app that consumes
11
+ it. This works both for packages the project depends on directly, and for
12
+ packages only pulled in transitively (a dependency of one of the project's
13
+ own dependencies) — see **Direct vs. indirect dependencies** below.
14
+
15
+ Run it on a package and it swaps the remote reference for a local one. Run
16
+ it again on the same package and it swaps back to the original remote
17
+ reference. It edits `project.pbxproj` directly (via the [xcodeproj][xcodeproj]
18
+ gem) and, for a direct dependency, reuses the existing package reference's
19
+ object ID when swapping, so the diff it produces is minimal and easy to
20
+ review — only the swapped reference's `isa`/attributes change, nothing else
21
+ in the file churns.
22
+
23
+ [xcodeproj]: https://github.com/CocoaPods/Xcodeproj
24
+
25
+ ## Requirements
26
+
27
+ - Run from the root of the Xcode project's repo — the directory containing
28
+ the `.xcodeproj` (there must be exactly one there).
29
+ - The first time you swap a package to local, its checkout is expected to be
30
+ an adjacent sibling directory, named to match the package's repo (e.g. if
31
+ the project depends on `git@github.com:org/some-package.git`, this tool
32
+ looks for `../some-package` containing a `Package.swift`). After that,
33
+ where it looks is whatever `spm-local-overrides.json` says — see below.
34
+ - macOS with Xcode installed (`xcodebuild` is used to re-resolve
35
+ `Package.resolved` after swapping).
36
+ - A `Package.resolved` that's already been generated at least once (needed to
37
+ discover indirect dependencies — see below). If there isn't one yet, only
38
+ direct dependencies are toggleable until one exists.
39
+
40
+ ## Installation
41
+
42
+ Add this to the consuming project's `Gemfile`:
43
+
44
+ ```ruby
45
+ gem "toggle-local-spm"
46
+ ```
47
+
48
+ Then:
49
+
50
+ ```bash
51
+ bundle install
52
+ ```
53
+
54
+ (Pin a version range, e.g. `gem "toggle-local-spm", "~> 2.0"`, if you want
55
+ more control over upgrades — see [Releasing a new version](#releasing-a-new-version)
56
+ for how versions are cut.)
57
+
58
+ ## Usage
59
+
60
+ From the root of the Xcode project's repo:
61
+
62
+ ```bash
63
+ # Toggle one package by name (matched against its repo/folder name)
64
+ bundle exec toggle-local-spm some-package
65
+
66
+ # Toggle several packages in one go
67
+ bundle exec toggle-local-spm some-package another-package
68
+
69
+ # Or with no arguments, pick from an interactive menu of every package
70
+ # dependency in the project (space/comma-separated numbers for more than one)
71
+ bundle exec toggle-local-spm
72
+ ```
73
+
74
+ The interactive menu lists every direct dependency (from the project itself)
75
+ and every indirect one (from `Package.resolved`) as a table, each tagged with
76
+ its type, current state, and whether it has a record in
77
+ `spm-local-overrides.json` (see below), e.g.:
78
+
79
+ ```
80
+ # Package Type State Managed
81
+ 1 mob-bifrost-ios 🎯 direct 🌏 remote ✅
82
+ 2 DeviceKit 🎯 direct 🌏 remote
83
+ 3 mob-redtail-widgets-generator 🧩 indirect 🌏 remote
84
+ ```
85
+
86
+ Each package toggles independently based on what's currently wired up in the
87
+ project: a package currently on its remote reference (or, for an indirect
88
+ dependency, not yet overridden at all) swaps to local; a package currently
89
+ on a local reference swaps back to remote.
90
+
91
+ Before touching `project.pbxproj`, if Xcode is currently running you'll be
92
+ asked how to handle it — Xcode holding the project open can silently
93
+ overwrite the file while this tool is editing it:
94
+
95
+ ```
96
+ Xcode is currently running, which can overwrite project.pbxproj while it's being edited.
97
+ 1) I'll close it myself
98
+ 2) Do nothing, proceed anyway
99
+ 3) Close Xcode for me
100
+ ```
101
+
102
+ Option 1 waits for you to close it and press Enter; option 3 quits Xcode for
103
+ you (a normal quit — Xcode will still prompt you to save anything unsaved).
104
+
105
+ After the swap(s), you'll be prompted:
106
+
107
+ ```
108
+ Resolve package dependencies now? [Y/n]
109
+ ```
110
+
111
+ Press enter (or `y`) to run `xcodebuild -resolvePackageDependencies` and
112
+ refresh `Package.resolved` immediately, or `n` to skip it and resolve later
113
+ yourself (e.g. via Xcode's **File > Packages > Resolve Package Versions**).
114
+ If resolution fails (no network, no SSH key for a private repo, etc.) it
115
+ prints a warning — the `project.pbxproj` swap itself has already succeeded
116
+ either way. Finally:
117
+
118
+ ```
119
+ Open in Xcode now? [Y/n]
120
+ ```
121
+
122
+ Press enter (or `y`) to open the project (the `.xcworkspace` if there is
123
+ one, otherwise the `.xcodeproj`) — handy for picking up right where the
124
+ "close Xcode for me" option above left off, with a freshly-resolved project.
125
+
126
+ ## Direct vs. indirect dependencies
127
+
128
+ A **direct** dependency already has (or had) its own top-level entry in the
129
+ project's "Package Dependencies" list (`project.pbxproj`'s
130
+ `XCRemoteSwiftPackageReference`/`XCLocalSwiftPackageReference` entries).
131
+ Toggling one off always leaves a reference behind — swapped back to remote —
132
+ since the project genuinely, permanently depends on it.
133
+
134
+ An **indirect** dependency has no such entry — it's only known because it
135
+ shows up in `Package.resolved`, meaning some *other* dependency's own
136
+ `Package.swift` depends on it (e.g. `mob-bifrost-ios` depending on
137
+ `mob-redtail-widgets-generator`). Toggling one of these on doesn't edit that
138
+ other package's `Package.swift` at all. Instead it adds a brand-new,
139
+ *unlinked* local package reference directly to the project — not attached to
140
+ any target — purely so Xcode/SwiftPM's identity-based dependency resolution
141
+ overrides the transitive reference with the local checkout, wherever else in
142
+ the graph it's declared. Toggling it back off removes that reference
143
+ entirely, since it was never a real dependency of the project — it was only
144
+ ever an override anchor.
145
+
146
+ > [!NOTE]
147
+ > This relies on SwiftPM allowing two different sources (a remote pin from a
148
+ > transitive `Package.swift`, and a local override elsewhere in the same
149
+ > graph) to share one package identity, silently preferring the local one.
150
+ > This works today, but SwiftPM's own resolver logs it as a *"Conflicting
151
+ > identity"* warning and states plainly that **this will be escalated to an
152
+ > error in future versions of SwiftPM**. If a future Xcode/SwiftPM version
153
+ > makes this a hard error, indirect-dependency overrides via this tool will
154
+ > stop working and need a different approach (e.g. editing the declaring
155
+ > package's `Package.swift` directly).
156
+
157
+ ## `spm-local-overrides.json`
158
+
159
+ The first time you swap any package to local, this tool creates
160
+ `spm-local-overrides.json` at the repo root. It's a permanent, per-developer
161
+ record of every package it has ever touched — created once and never
162
+ deleted, and entries are only ever added to or updated, never removed (even
163
+ after a package is swapped back to remote, or an indirect override is
164
+ removed). Whether a package is currently "on" (local) or "off" (remote) is
165
+ **not** read from this file — it's determined by inspecting the project
166
+ itself (is there a reference for it, and is it an
167
+ `XCRemoteSwiftPackageReference` or an `XCLocalSwiftPackageReference`?). This
168
+ file only ever supplies the *details* needed to perform a swap.
169
+
170
+ Each entry looks like:
171
+
172
+ ```json
173
+ {
174
+ "some-package": {
175
+ "repositoryURL": "git@github.com:org/some-package.git",
176
+ "requirement": { "kind": "exactVersion", "version": "1.2.3" },
177
+ "localPath": "/Users/you/code/some-package",
178
+ "type": "direct"
179
+ }
180
+ }
181
+ ```
182
+
183
+ - `repositoryURL` / `requirement` — the package's remote reference, captured
184
+ automatically the first time it's swapped to local (from the project
185
+ itself for a direct dependency, or from `Package.resolved` for an indirect
186
+ one). Used to restore the exact same remote reference when swapping a
187
+ direct dependency back; recorded for indirect dependencies too but not
188
+ strictly needed to turn one off (that just removes the reference).
189
+ - `type` — `"direct"` or `"indirect"`, set automatically the first time a
190
+ package is toggled (see above). Missing `type` on an older entry is
191
+ treated as `"direct"`.
192
+ - `localPath` — where the local checkout lives. Set automatically to the
193
+ default sibling folder the first time you swap a package to local. **Edit
194
+ this by hand** if your checkout lives somewhere else, or under a different
195
+ name — the next swap-to-local for that package will use whatever path is
196
+ here instead of guessing a sibling folder.
197
+
198
+ This file is specific to your machine (it records absolute local paths), so
199
+ it should stay out of version control — add `spm-local-overrides.json` to
200
+ the consuming project's `.gitignore`.
201
+
202
+ ## Development
203
+
204
+ After checking out the repo, run `bin/setup` to install dependencies. You can
205
+ also run `bin/console` for an interactive prompt that will allow you to
206
+ experiment.
207
+
208
+ To install this gem onto your local machine, run `bundle exec rake install`.
209
+
210
+ ### Tests and coverage
211
+
212
+ `bundle exec rake test` runs the suite (also the default Rake task) and
213
+ writes:
214
+
215
+ - A coverage report to `coverage/` via [SimpleCov][simplecov] —
216
+ `coverage/index.html` for a local human-readable view,
217
+ `coverage/coverage.json` for Codecov's coverage reporting to ingest.
218
+ - JUnit XML test results to `test/reports/` via [minitest-reporters][minitest-reporters].
219
+
220
+ CI ([.github/workflows/tests.yml](.github/workflows/tests.yml)) runs the
221
+ same suite on every push to `main` and every PR, then:
222
+
223
+ - Uploads coverage to Codecov via `codecov/codecov-action`.
224
+ - Publishes the JUnit XML as GitHub check annotations and a job summary
225
+ via [mikepenz/action-junit-report][action-junit-report] (with
226
+ `detailed_summary`/`include_passed` so every individual test case shows,
227
+ not just failures) — per-test pass/fail shows up directly on the
228
+ commit/PR's checks and in the workflow run's summary tab, no external
229
+ service needed. (We also tried reporting test results to Codecov's Test
230
+ Analytics via `report_type: test_results`; every upload consistently
231
+ logged success but never appeared anywhere in Codecov's UI even after
232
+ ruling out XML format, plan/visibility gating, and branch-config
233
+ mismatches — looks like a gap on their end, not something fixable from
234
+ this workflow. Filed with Codecov support; worth revisiting later.)
235
+ - Uploads both reports as a downloadable build artifact
236
+ (`test-reports`), for whenever a run needs deeper debugging.
237
+
238
+ Both the coverage upload and the check-annotations step run even if the
239
+ test step itself fails (`if: ${{ !cancelled() }}`), since a failing run is
240
+ exactly what you want visibility into.
241
+
242
+ Codecov needs one manual, one-time setup step that isn't in this diff: add
243
+ the repo at [codecov.io](https://codecov.io) (sign in with GitHub, enable
244
+ the org/repo), then copy its **upload token** and add it as a repo secret
245
+ named `CODECOV_TOKEN` (Settings → Secrets and variables → Actions).
246
+
247
+ [simplecov]: https://github.com/simplecov-ruby/simplecov
248
+ [minitest-reporters]: https://github.com/minitest-reporters/minitest-reporters
249
+ [action-junit-report]: https://github.com/mikepenz/action-junit-report
250
+
251
+ ## Releasing a new version
252
+
253
+ The [Release workflow](.github/workflows/release.yml) automates the whole
254
+ process, including publishing to RubyGems.org:
255
+
256
+ 1. Go to the repo's **Actions** tab → **Release** → **Run workflow**.
257
+ 2. Enter the new version as bare `x.y.z` (no `v` prefix — the workflow adds
258
+ it when constructing the tag, e.g. entering `2.3.0` produces `v2.3.0`).
259
+ 3. Run it.
260
+
261
+ On a fresh runner, the workflow then:
262
+
263
+ - Validates the input matches `x.y.z` and that the tag doesn't already exist
264
+ (refusing to overwrite an existing release).
265
+ - Bumps `lib/toggle_local_spm/version.rb`.
266
+ - Regenerates `Gemfile.lock` (`bundle install`) so its `PATH` section
267
+ matches the new version — CI's `bundler-cache: true` step in
268
+ [tests.yml](.github/workflows/tests.yml) installs in frozen mode and will
269
+ hard-fail if this drifts.
270
+ - Runs `bundle exec rake test` as a safety gate — nothing is committed,
271
+ tagged, published, or released if the suite fails.
272
+ - Commits as "Bump version to X.Y.Z", tags it `vX.Y.Z`, and pushes both to
273
+ `main`.
274
+ - Creates a GitHub Release for the tag via `gh release create --generate-notes`.
275
+ - Publishes the gem to [RubyGems.org](https://rubygems.org/gems/toggle-local-spm)
276
+ via [Trusted Publishing][trusted-publishing] (OIDC — no stored API key).
277
+
278
+ Consumers just run `bundle update toggle-local-spm` to pick up a new
279
+ version.
280
+
281
+ [trusted-publishing]: https://guides.rubygems.org/trusted-publishing/
282
+
283
+ ### One-time setup
284
+
285
+ RubyGems.org Trusted Publishing needs a "pending trusted publisher"
286
+ registered once, before the *first* release, at
287
+ [rubygems.org/profile/oidc/pending_trusted_publishers](https://rubygems.org/profile/oidc/pending_trusted_publishers):
288
+
289
+ - Gem name: `toggle-local-spm`
290
+ - Repository owner / name: `squeaky-nose` / `toggle-local-spm`
291
+ - Workflow filename: `release.yml`
292
+ - Environment: `release`
293
+
294
+ After the first successful publish, RubyGems.org converts this from
295
+ "pending" to a normal trusted publisher automatically — no further setup
296
+ needed for later releases.
297
+
298
+ **Manual fallback** (e.g. the workflow is unavailable, or `main`'s branch
299
+ protection blocks the workflow's token from pushing):
300
+
301
+ 1. Bump the version in `lib/toggle_local_spm/version.rb`.
302
+ 2. Run `bundle install` to regenerate `Gemfile.lock`.
303
+ 3. Run `bundle exec rake test`.
304
+ 4. Commit as "Bump version to X.Y.Z", then `git tag vX.Y.Z`.
305
+ 5. `git push origin main && git push origin vX.Y.Z`.
306
+ 6. Create a release from the pushed tag: `gh release create vX.Y.Z --generate-notes`
307
+ (or via the GitHub UI's Releases page).
308
+ 7. Publish the gem: `gem build toggle-local-spm.gemspec && gem push toggle-local-spm-X.Y.Z.gem`
309
+ (needs a RubyGems.org API key with push access on your machine, since
310
+ Trusted Publishing only works from the configured GitHub Actions workflow).
311
+
312
+ ## Contributing
313
+
314
+ Bug reports and pull requests are welcome on GitHub at
315
+ https://github.com/squeaky-nose/toggle-local-spm.
316
+
317
+ ## License
318
+
319
+ The gem is available as open source under the terms of the
320
+ [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ # minitest-reporters' JUnitReporter writes tests/failures/errors/time onto
13
+ # each <testsuite>, but never onto the enclosing <testsuites> root — Codecov
14
+ # Test Analytics requires those on the root element too, so copy them up.
15
+ desc "Add root-level summary attributes to JUnit XML reports for Codecov Test Analytics"
16
+ task :fix_junit_reports do
17
+ require "rexml/document"
18
+
19
+ Dir.glob("test/reports/TEST-*.xml").each do |path|
20
+ doc = REXML::Document.new(File.read(path))
21
+ suite = doc.root&.elements&.[]("testsuite")
22
+ next unless suite
23
+
24
+ %w[tests failures errors time].each do |attr|
25
+ doc.root.attributes[attr] = suite.attributes[attr]
26
+ end
27
+
28
+ File.write(path, doc.to_s)
29
+ end
30
+ end
31
+
32
+ Rake::Task["test"].enhance do
33
+ Rake::Task["fix_junit_reports"].invoke
34
+ end
35
+
36
+ task default: :test
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "toggle_local_spm"
5
+
6
+ begin
7
+ ToggleLocalSpm::CLI.run(ARGV)
8
+ rescue ToggleLocalSpm::Error => e
9
+ warn "error: #{e.message}"
10
+ exit 1
11
+ end
@@ -0,0 +1,477 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "xcodeproj"
4
+ require "json"
5
+ require "pathname"
6
+
7
+ module ToggleLocalSpm
8
+ # Toggles one or more Swift Package Manager dependencies between their
9
+ # remote (git) reference and a local checkout. Run again on the same
10
+ # package to swap back to the original remote reference. Afterwards,
11
+ # optionally runs `xcodebuild -resolvePackageDependencies` so
12
+ # Package.resolved is updated to match (requires network access and, for
13
+ # private repos, a working SSH key).
14
+ #
15
+ # Run from the root of the Xcode project's repo (the directory containing
16
+ # the .xcodeproj). A `spm-local-overrides.json` file is kept at the repo
17
+ # root as a permanent record of each package's remote reference and local
18
+ # checkout path (see README).
19
+ #
20
+ # Packages come in two flavors:
21
+ # - "direct": already has (or had) a top-level XCRemoteSwiftPackageReference/
22
+ # XCLocalSwiftPackageReference entry in project.pbxproj. Toggling off
23
+ # always leaves a reference behind (swapped back to remote) since the
24
+ # project genuinely depends on it.
25
+ # - "indirect": only known via Package.resolved — a transitive dependency
26
+ # of one of the project's own direct dependencies (e.g. a package
27
+ # declared in another package's own Package.swift), with no top-level
28
+ # reference of its own. Toggling one of these on adds a brand-new,
29
+ # unlinked local reference (no product/target linkage) purely so
30
+ # SwiftPM's identity-based graph resolution overrides the transitive
31
+ # pin with the local checkout; toggling off removes that reference
32
+ # entirely, since it was never a real project dependency.
33
+ #
34
+ # Which of these two states a package is in is recorded as "type" in
35
+ # spm-local-overrides.json. Whether it's currently local or remote is
36
+ # always read from the live project (an existing ref's isa, or its
37
+ # absence for a not-yet-toggled indirect package) — never from the state
38
+ # file or from Package.resolved, which can lag behind.
39
+ class CLI
40
+ RemoteRef = Xcodeproj::Project::Object::XCRemoteSwiftPackageReference
41
+ LocalRef = Xcodeproj::Project::Object::XCLocalSwiftPackageReference
42
+ ProductDependency = Xcodeproj::Project::Object::XCSwiftPackageProductDependency
43
+
44
+ Candidate = Struct.new(:name, :ref, :type, :resolved_entry, :managed) do
45
+ def local?
46
+ ref.is_a?(LocalRef)
47
+ end
48
+ end
49
+
50
+ TYPE_LABELS = { "direct" => "🎯 direct", "indirect" => "🧩 indirect" }.freeze
51
+ STATE_LABELS = { local: "📁 local", remote: "🌏 remote" }.freeze
52
+
53
+ def self.run(argv)
54
+ new(argv).run
55
+ end
56
+
57
+ def initialize(argv, root: Dir.pwd)
58
+ @package_names = argv
59
+ @root = Pathname.new(root).expand_path
60
+ @state_file = @root + "spm-local-overrides.json"
61
+ end
62
+
63
+ def run
64
+ xcodeproj_path = find_xcodeproj_path
65
+ project = Xcodeproj::Project.open(xcodeproj_path)
66
+ state = load_state
67
+
68
+ candidates = build_candidates(project, xcodeproj_path, state)
69
+ abort_with("no Swift package dependencies found") if candidates.empty?
70
+
71
+ selected = select_candidates(candidates)
72
+
73
+ check_xcode_running
74
+
75
+ selected.each { |candidate| toggle(project, xcodeproj_path, candidate, state) }
76
+
77
+ save_state(state)
78
+ project.save
79
+
80
+ resolve_package_dependencies(xcodeproj_path) if prompt_resolve?
81
+ open_in_xcode(xcodeproj_path) if prompt_open_in_xcode?
82
+
83
+ puts "Done."
84
+ end
85
+
86
+ private
87
+
88
+ def abort_with(message)
89
+ raise Error, message
90
+ end
91
+
92
+ def find_xcodeproj_path
93
+ candidates = Dir.glob(@root + "*.xcodeproj")
94
+ abort_with("no .xcodeproj found in #{@root}") if candidates.empty?
95
+ abort_with("multiple .xcodeproj found in #{@root}, expected one: #{candidates.join(", ")}") if candidates.size > 1
96
+ Pathname.new(candidates.first)
97
+ end
98
+
99
+ def find_workspace_path
100
+ candidates = Dir.glob(@root + "*.xcworkspace")
101
+ candidates.first && Pathname.new(candidates.first)
102
+ end
103
+
104
+ def load_state
105
+ return {} unless @state_file.exist?
106
+
107
+ JSON.parse(@state_file.read)
108
+ rescue JSON::ParserError => e
109
+ abort_with("could not parse #{@state_file}: #{e.message}")
110
+ end
111
+
112
+ # The state file is a permanent record: it is created on first use and
113
+ # never deleted, and entries are only ever added to or updated, never
114
+ # removed — even after a package is swapped back to remote (or an
115
+ # indirect override is removed). This lets a developer hand-edit a
116
+ # package's "localPath" without losing the recorded repositoryURL/
117
+ # requirement, and preserves "type" so a package's direct/indirect
118
+ # classification is stable across runs.
119
+ def save_state(state)
120
+ @state_file.write(JSON.pretty_generate(state) + "\n")
121
+ end
122
+
123
+ def ref_name(ref)
124
+ case ref
125
+ when RemoteRef
126
+ File.basename(ref.repositoryURL.to_s, ".git")
127
+ when LocalRef
128
+ File.basename(ref.relative_path.to_s)
129
+ end
130
+ end
131
+
132
+ def product_dependencies_for(project, ref)
133
+ project.objects.select { |o| o.is_a?(ProductDependency) && o.package == ref }
134
+ end
135
+
136
+ # Replaces `old_ref` with a new package reference of `new_ref_class`, reusing
137
+ # old_ref's UUID and its position in packageReferences. This keeps every
138
+ # `package = <uuid>` line in XCSwiftPackageProductDependency untouched (only
139
+ # the referenced object's isa/attributes change), so toggling produces a
140
+ # minimal, easy-to-review diff instead of churning UUIDs.
141
+ def replace_ref(project, old_ref, new_ref_class)
142
+ deps = product_dependencies_for(project, old_ref)
143
+ package_references = project.root_object.package_references
144
+ index = package_references.index(old_ref)
145
+ uuid = old_ref.uuid
146
+
147
+ # Detach old_ref from every referrer first so its UUID is freed from
148
+ # objects_by_uuid before the new object claims that same UUID below.
149
+ deps.each { |dep| dep.package = nil }
150
+ package_references.delete(old_ref)
151
+
152
+ new_ref = new_ref_class.new(project, uuid)
153
+ new_ref.initialize_defaults
154
+ yield new_ref
155
+
156
+ package_references.insert(index, new_ref)
157
+ deps.each { |dep| dep.package = new_ref }
158
+
159
+ new_ref
160
+ end
161
+
162
+ # --- Candidate discovery ---------------------------------------------
163
+
164
+ def build_candidates(project, xcodeproj_path, state)
165
+ refs = project.root_object.package_references.select { |r| r.is_a?(RemoteRef) || r.is_a?(LocalRef) }
166
+ direct = refs.map do |ref|
167
+ name = ref_name(ref)
168
+ type = state.dig(name, "type") || "direct"
169
+ Candidate.new(name, ref, type, nil, state.key?(name))
170
+ end
171
+
172
+ resolved = load_resolved_packages(xcodeproj_path)
173
+ indirect = resolved.reject { |name, _| direct.any? { |c| c.name.casecmp(name).zero? } }
174
+ .map { |name, info| Candidate.new(name, nil, "indirect", info, state.key?(name)) }
175
+
176
+ direct + indirect
177
+ end
178
+
179
+ def resolved_file_path(xcodeproj_path)
180
+ workspace = find_workspace_path
181
+ if workspace
182
+ workspace + "xcshareddata/swiftpm/Package.resolved"
183
+ else
184
+ xcodeproj_path + "project.xcworkspace/xcshareddata/swiftpm/Package.resolved"
185
+ end
186
+ end
187
+
188
+ def load_resolved_packages(xcodeproj_path)
189
+ path = resolved_file_path(xcodeproj_path)
190
+ return {} unless path.exist?
191
+
192
+ json = JSON.parse(path.read)
193
+ pins = json["pins"] || json.dig("object", "pins") || []
194
+ pins.each_with_object({}) do |pin, acc|
195
+ location = pin["location"] || pin["repositoryURL"]
196
+ next unless location
197
+
198
+ name = File.basename(location, ".git")
199
+ acc[name] = { "repositoryURL" => location, "requirement" => requirement_from_resolved_state(pin["state"] || {}) }
200
+ end
201
+ rescue JSON::ParserError
202
+ {}
203
+ end
204
+
205
+ def requirement_from_resolved_state(resolved_state)
206
+ if resolved_state["version"]
207
+ { "kind" => "exactVersion", "version" => resolved_state["version"] }
208
+ elsif resolved_state["branch"]
209
+ { "kind" => "branch", "branch" => resolved_state["branch"] }
210
+ elsif resolved_state["revision"]
211
+ { "kind" => "revision", "revision" => resolved_state["revision"] }
212
+ end
213
+ end
214
+
215
+ # --- Selection ----------------------------------------------------------
216
+
217
+ def find_candidate(candidates, wanted)
218
+ match = candidates.find { |c| c.name.casecmp(wanted).zero? }
219
+ return match if match
220
+
221
+ names = candidates.map(&:name).sort
222
+ abort_with("no package dependency named '#{wanted}'. Available: #{names.join(", ")}")
223
+ end
224
+
225
+ def select_candidates(candidates)
226
+ return @package_names.map { |wanted| find_candidate(candidates, wanted) }.uniq if @package_names.any?
227
+
228
+ select_candidates_interactively(candidates)
229
+ end
230
+
231
+ def select_candidates_interactively(candidates)
232
+ puts "Select package dependencies to toggle (e.g. 1, or 1 3):"
233
+ puts
234
+
235
+ index_width = candidates.size.to_s.length
236
+ name_width = candidates.map { |c| c.name.length }.max
237
+ type_width = TYPE_LABELS.values.map(&:length).max
238
+ state_width = STATE_LABELS.values.map(&:length).max
239
+
240
+ puts " #{"#".rjust(index_width)} #{"Package".ljust(name_width)} #{"Type".ljust(type_width)} " \
241
+ "#{"State".ljust(state_width)} Managed"
242
+ candidates.each_with_index do |c, i|
243
+ type_label = TYPE_LABELS.fetch(c.type, c.type).ljust(type_width)
244
+ state_label = STATE_LABELS[c.local? ? :local : :remote].ljust(state_width)
245
+ managed_label = c.managed ? "✅" : ""
246
+ puts " #{(i + 1).to_s.rjust(index_width)} #{c.name.ljust(name_width)} #{type_label} #{state_label} #{managed_label}"
247
+ end
248
+
249
+ print "\n> "
250
+ choice = $stdin.gets&.strip
251
+ abort_with("invalid selection") if choice.nil? || choice.empty?
252
+
253
+ tokens = choice.split(/[\s,]+/)
254
+ abort_with("invalid selection '#{choice}'") unless tokens.all? { |t| t.match?(/\A\d+\z/) }
255
+
256
+ tokens.map(&:to_i).map do |n|
257
+ index = n - 1
258
+ abort_with("invalid selection '#{n}'") unless index.between?(0, candidates.size - 1)
259
+ candidates[index]
260
+ end.uniq
261
+ end
262
+
263
+ # --- Toggling -------------------------------------------------------
264
+
265
+ def toggle(project, xcodeproj_path, candidate, state)
266
+ if candidate.local?
267
+ if candidate.type == "indirect"
268
+ remove_indirect_ref(project, candidate)
269
+ else
270
+ swap_local_to_remote(project, xcodeproj_path, candidate.ref, state)
271
+ end
272
+ elsif candidate.ref.nil?
273
+ add_indirect_local_ref(project, xcodeproj_path, candidate, state)
274
+ else
275
+ swap_remote_to_local(project, xcodeproj_path, candidate.ref, state)
276
+ end
277
+ end
278
+
279
+ def resolve_local_dir(entry, name)
280
+ local_dir = entry["localPath"] ? Pathname.new(entry["localPath"]).expand_path(@root) : (@root.parent + name)
281
+ unless local_dir.directory? && (local_dir + "Package.swift").file?
282
+ abort_with("expected a local checkout with a Package.swift at #{local_dir} " \
283
+ "(set or fix \"localPath\" for '#{name}' in #{@state_file} if it lives elsewhere)")
284
+ end
285
+ local_dir
286
+ end
287
+
288
+ def swap_remote_to_local(project, xcodeproj_path, ref, state)
289
+ name = ref_name(ref)
290
+ entry = state[name] || {}
291
+ local_dir = resolve_local_dir(entry, name)
292
+ relative_path = local_dir.relative_path_from(xcodeproj_path.dirname).to_s
293
+
294
+ state[name] = entry.merge(
295
+ "repositoryURL" => ref.repositoryURL,
296
+ "requirement" => ref.requirement,
297
+ "localPath" => local_dir.to_s,
298
+ "type" => "direct"
299
+ )
300
+
301
+ replace_ref(project, ref, LocalRef) { |local_ref| local_ref.relative_path = relative_path }
302
+
303
+ puts "Swapped '#{name}' to local checkout at #{relative_path} (kept resource id #{ref.uuid})"
304
+ end
305
+
306
+ def swap_local_to_remote(project, xcodeproj_path, ref, state)
307
+ name = ref_name(ref)
308
+ entry = state[name]
309
+ unless entry && entry["repositoryURL"] && entry["requirement"]
310
+ abort_with("no recorded remote reference for '#{name}' in #{@state_file}. " \
311
+ "Add \"repositoryURL\"/\"requirement\" for it manually, or restore project.pbxproj from git.")
312
+ end
313
+
314
+ # Backfill localPath in case this entry was created/edited by hand
315
+ # without one, so the record stays complete going forward.
316
+ entry["localPath"] ||= (xcodeproj_path.dirname + ref.relative_path).expand_path.to_s
317
+ entry["type"] = "direct"
318
+
319
+ replace_ref(project, ref, RemoteRef) do |remote_ref|
320
+ remote_ref.repositoryURL = entry["repositoryURL"]
321
+ remote_ref.requirement = entry["requirement"]
322
+ end
323
+
324
+ puts "Swapped '#{name}' back to remote reference #{entry["repositoryURL"]} (kept resource id #{ref.uuid})"
325
+ end
326
+
327
+ # Adds a brand-new, unlinked local reference for an indirect (transitive)
328
+ # dependency. New UUID, since there's no existing ref to reuse one from.
329
+ # Not wired into any target's product dependencies — its mere presence
330
+ # in packageReferences is enough for SwiftPM to unify the identity with
331
+ # the local checkout wherever else in the graph it's referenced
332
+ # (verified: it overrides the transitive pin even when the package that
333
+ # actually declares the dependency, e.g. mob-bifrost-ios, stays remote).
334
+ def add_indirect_local_ref(project, xcodeproj_path, candidate, state)
335
+ name = candidate.name
336
+ entry = state[name] || {}
337
+ local_dir = resolve_local_dir(entry, name)
338
+ relative_path = local_dir.relative_path_from(xcodeproj_path.dirname).to_s
339
+
340
+ repository_url = entry["repositoryURL"] || candidate.resolved_entry["repositoryURL"]
341
+ requirement = entry["requirement"] || candidate.resolved_entry["requirement"]
342
+
343
+ new_ref = LocalRef.new(project, project.generate_uuid)
344
+ new_ref.initialize_defaults
345
+ new_ref.relative_path = relative_path
346
+ project.root_object.package_references << new_ref
347
+
348
+ state[name] = entry.merge(
349
+ "repositoryURL" => repository_url,
350
+ "requirement" => requirement,
351
+ "localPath" => local_dir.to_s,
352
+ "type" => "indirect"
353
+ )
354
+
355
+ puts "Added local override for '#{name}' (indirect dependency) at #{relative_path} (new resource id #{new_ref.uuid})"
356
+ end
357
+
358
+ def remove_indirect_ref(project, candidate)
359
+ ref = candidate.ref
360
+ product_dependencies_for(project, ref).each { |dep| dep.package = nil }
361
+ project.root_object.package_references.delete(ref)
362
+
363
+ puts "Removed local override for '#{candidate.name}' (indirect dependency)"
364
+ end
365
+
366
+ # Xcode holding the project open can silently overwrite project.pbxproj
367
+ # out from under us (observed in practice: Xcode periodically re-saves
368
+ # its in-memory state to disk). Ask before we touch the file.
369
+ def check_xcode_running
370
+ return unless xcode_running?
371
+
372
+ puts "Xcode is currently running, which can overwrite project.pbxproj while it's being edited."
373
+ puts " 1) I'll close it myself"
374
+ puts " 2) Do nothing, proceed anyway"
375
+ puts " 3) Close Xcode for me"
376
+ print "> "
377
+ choice = $stdin.gets&.strip
378
+
379
+ case choice
380
+ when "1"
381
+ print "Close Xcode, then press Enter to continue... "
382
+ $stdin.gets
383
+ warn "warning: Xcode still appears to be running." if xcode_running?
384
+ when "3"
385
+ run_command("osascript", "-e", 'tell application "Xcode" to quit')
386
+ wait_for_xcode_to_quit
387
+ when "2"
388
+ nil
389
+ else
390
+ abort_with("invalid selection")
391
+ end
392
+ end
393
+
394
+ def xcode_running?
395
+ run_command("pgrep", "-x", "Xcode", out: File::NULL, err: File::NULL)
396
+ end
397
+
398
+ # Thin wrapper around Kernel#system so every shell-out point in this
399
+ # class can be stubbed from one place in tests, instead of each needing
400
+ # its own indirection.
401
+ def run_command(*args)
402
+ system(*args)
403
+ end
404
+
405
+ # Overridable so tests can shrink these without a real 10-second wait.
406
+ def xcode_quit_timeout
407
+ 10
408
+ end
409
+
410
+ def xcode_quit_poll_interval
411
+ 0.5
412
+ end
413
+
414
+ def wait_for_xcode_to_quit
415
+ deadline = Time.now + xcode_quit_timeout
416
+ sleep xcode_quit_poll_interval while xcode_running? && Time.now < deadline
417
+ warn "warning: Xcode still appears to be running (it may be waiting on an unsaved-changes prompt)." if xcode_running?
418
+ end
419
+
420
+ def prompt_resolve?
421
+ print "Resolve package dependencies now? [Y/n] "
422
+ answer = $stdin.gets&.strip
423
+ answer.nil? || answer.empty? || !answer.match?(/\An/i)
424
+ end
425
+
426
+ def prompt_open_in_xcode?
427
+ print "Open in Xcode now? [Y/n] "
428
+ answer = $stdin.gets&.strip
429
+ answer.nil? || answer.empty? || !answer.match?(/\An/i)
430
+ end
431
+
432
+ def open_in_xcode(xcodeproj_path)
433
+ target = find_workspace_path || xcodeproj_path
434
+ warn "warning: could not open #{target} in Xcode." unless run_command("open", target.to_s)
435
+ end
436
+
437
+ # Prefers `-workspace <workspace> -scheme <scheme>` over `-project` when a
438
+ # .xcworkspace exists, so resolution (and its Package.resolved output)
439
+ # targets the same file discovery reads from — some repos keep a
440
+ # separate top-level .xcworkspace whose Package.resolved is the one
441
+ # actually tracked in git, distinct from the .xcodeproj's own implicit
442
+ # workspace. Falls back to `-project` (no scheme needed) otherwise.
443
+ def resolve_target_args(xcodeproj_path)
444
+ workspace = find_workspace_path
445
+ return ["-project", xcodeproj_path.to_s] unless workspace
446
+
447
+ scheme = detect_scheme(xcodeproj_path, workspace)
448
+ return ["-project", xcodeproj_path.to_s] unless scheme
449
+
450
+ ["-workspace", workspace.to_s, "-scheme", scheme]
451
+ end
452
+
453
+ # Reads shared scheme files directly (no xcodebuild invocation, which
454
+ # would otherwise trigger its own package resolution just to answer
455
+ # "what schemes exist").
456
+ def detect_scheme(xcodeproj_path, workspace)
457
+ scheme_files = Dir.glob(xcodeproj_path + "xcshareddata/xcschemes/*.xcscheme").sort
458
+ return nil if scheme_files.empty?
459
+
460
+ preferred_name = workspace.basename(".xcworkspace").to_s
461
+ preferred = scheme_files.find { |f| File.basename(f, ".xcscheme").casecmp(preferred_name).zero? }
462
+ File.basename(preferred || scheme_files.first, ".xcscheme")
463
+ end
464
+
465
+ def resolve_package_dependencies(xcodeproj_path)
466
+ puts "Resolving package dependencies (xcodebuild -resolvePackageDependencies)..."
467
+ ok = run_command("xcodebuild", "-resolvePackageDependencies", *resolve_target_args(xcodeproj_path))
468
+ return if ok
469
+
470
+ warn "warning: automatic package resolution failed. Open the project in Xcode " \
471
+ "(File > Packages > Resolve Package Versions) to finish updating Package.resolved."
472
+ rescue Errno::ENOENT
473
+ warn "warning: xcodebuild not found on PATH; skipped automatic package resolution. " \
474
+ "Open the project in Xcode to resolve packages."
475
+ end
476
+ end
477
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToggleLocalSpm
4
+ VERSION = "2.2.2"
5
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "toggle_local_spm/version"
4
+ require_relative "toggle_local_spm/cli"
5
+
6
+ module ToggleLocalSpm
7
+ class Error < StandardError; end
8
+ end
@@ -0,0 +1,4 @@
1
+ module ToggleLocalSpm
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,79 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: toggle-local-spm
3
+ version: !ruby/object:Gem::Version
4
+ version: 2.2.2
5
+ platform: ruby
6
+ authors:
7
+ - Sushant Verma
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-08-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: xcodeproj
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '1.19'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '2.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '1.19'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ description: A CLI that swaps a Swift Package Manager dependency in an .xcodeproj
34
+ between its remote (git) reference and a local checkout in an adjacent sibling folder,
35
+ and back again, without churning the underlying pbxproj object IDs.
36
+ email:
37
+ - sushant.40@gmail.com
38
+ executables:
39
+ - toggle-local-spm
40
+ extensions: []
41
+ extra_rdoc_files: []
42
+ files:
43
+ - LICENSE.txt
44
+ - README.md
45
+ - Rakefile
46
+ - exe/toggle-local-spm
47
+ - lib/toggle_local_spm.rb
48
+ - lib/toggle_local_spm/cli.rb
49
+ - lib/toggle_local_spm/version.rb
50
+ - sig/toggle_local_spm.rbs
51
+ homepage: https://github.com/squeaky-nose/toggle-local-spm
52
+ licenses:
53
+ - MIT
54
+ metadata:
55
+ homepage_uri: https://github.com/squeaky-nose/toggle-local-spm
56
+ source_code_uri: https://github.com/squeaky-nose/toggle-local-spm
57
+ changelog_uri: https://github.com/squeaky-nose/toggle-local-spm/releases
58
+ rubygems_mfa_required: 'true'
59
+ post_install_message:
60
+ rdoc_options: []
61
+ require_paths:
62
+ - lib
63
+ required_ruby_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 3.1.0
68
+ required_rubygems_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '0'
73
+ requirements: []
74
+ rubygems_version: 3.5.22
75
+ signing_key:
76
+ specification_version: 4
77
+ summary: Toggle an Xcode project's Swift Package dependency between its remote reference
78
+ and a local sibling checkout.
79
+ test_files: []