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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +320 -0
- data/Rakefile +36 -0
- data/exe/toggle-local-spm +11 -0
- data/lib/toggle_local_spm/cli.rb +477 -0
- data/lib/toggle_local_spm/version.rb +5 -0
- data/lib/toggle_local_spm.rb +8 -0
- data/sig/toggle_local_spm.rbs +4 -0
- metadata +79 -0
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
|
+
[](https://github.com/squeaky-nose/toggle-local-spm/actions/workflows/tests.yml)
|
|
4
|
+
[](https://codecov.io/gh/squeaky-nose/toggle-local-spm)
|
|
5
|
+
[](https://rubygems.org/gems/toggle-local-spm)
|
|
6
|
+
[](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,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
|
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: []
|