From 6b232818fda10177e6558f8a0e4290c09a1e8907 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Thu, 22 Oct 2026 18:26:33 +0200 Subject: [PATCH] Let `spm deinit` take an app back to CocoaPods Converting an app to SwiftPM with `--deintegrate` now moves the Podfile to Podfile.bak instead of deleting React Native lines from it (that edit left invalid Ruby for multi-line calls). `spm deinit` restores the Podfile, undoes the .gitignore lines the conversion wrote, removes empty ReactNativeSPM folders, and says to run `pod install`. The conversion refuses, before changing anything, when the Podfile has pods that are not React Native or when Podfile.bak already exists. --- .../react-native/scripts/setup-apple-spm.js | 298 +++++-- .../scripts/spm/__docs__/README.md | 10 + .../scripts/spm/__docs__/spm-scripts.md | 84 +- .../__tests__/remove-spm-injection-test.js | 122 +++ .../spm/__tests__/setup-apple-spm-test.js | 736 +++++++++++++++++- .../scripts/spm/generate-spm-xcodeproj.js | 32 +- .../react-native/scripts/spm/spm-types.js | 11 +- 7 files changed, 1177 insertions(+), 116 deletions(-) diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js index b445bb2969b1..a94492b36f8b 100644 --- a/packages/react-native/scripts/setup-apple-spm.js +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -10,7 +10,11 @@ 'use strict'; -/*:: import type {CliConfigJson, SetupArgs} from './spm/spm-types'; */ +/*:: import type { + CliConfigJson, + GitignoreRecord, + SetupArgs, +} from './spm/spm-types'; */ /** * setup-apple-spm.js – Entry point for setting up Swift Package Manager support @@ -25,13 +29,14 @@ * settings, the Sync build phase) into your * existing .xcodeproj, in place. Idempotent. * Default on first run. `--deintegrate` first - * runs `pod deintegrate` + strips React Native - * from the Podfile (CocoaPods → SPM migration). + * runs `pod deintegrate` + moves the Podfile to + * Podfile.bak (CocoaPods → SPM migration). * update Re-run the pipeline and refresh the existing * injection. Default once a project is injected. * deinit The exact inverse of `add`: surgically remove * only what `add` injected (recorded in * .spm-injected.json) and drop the marker. + * Restores Podfile.bak to Podfile. * scaffold Generate Package.swift for community deps that * lack SPM support. * sync Re-run the build-time autolinking sync and its @@ -52,8 +57,8 @@ * --yes Skip the dirty-pbxproj confirmation prompt. * [add] --xcodeproj Which .xcodeproj to inject into (when several). * [add] --product-name Which app target to inject into (when several). - * [add] --deintegrate Run `pod deintegrate` + strip RN from the - * Podfile before injecting. + * [add] --deintegrate Run `pod deintegrate` + move the Podfile to + * Podfile.bak before injecting. * [advanced] --artifacts Local two-flavor artifact root; * must contain debug/ and release/ cache slots. * [advanced] --download Artifact policy (default: auto). @@ -197,7 +202,7 @@ function parseArgs(argv /*: Array */) /*: SetupArgs */ { type: 'boolean', default: false, describe: - '[add] Run `pod deintegrate` + strip React Native from the Podfile before injecting (CocoaPods → SPM migration).', + '[add] Run `pod deintegrate` + move the Podfile to Podfile.bak before injecting (CocoaPods → SPM migration).', }) .option('artifacts', { type: 'string', @@ -266,36 +271,100 @@ const SPM_GITIGNORE_ENTRIES = [ 'build/xcframeworks/', '.build/', ]; +const SPM_GITIGNORE_HEADER = + '# SPM – auto-generated at build time (do not commit)'; /** * Ensure the project's .gitignore contains entries for SPM-generated - * directories. Called during init so that generated artifacts are not - * accidentally committed. + * directories, so generated artifacts are not accidentally committed. Returns + * `previous` (the record in the marker, if any) extended with what this run + * wrote, for `spm deinit` to undo. */ -function ensureGitignoreSpmEntries(appRoot /*: string */) { +function ensureGitignoreSpmEntries( + appRoot /*: string */, + previous /*: ?GitignoreRecord */, +) /*: GitignoreRecord */ { const gitignorePath = path.join(appRoot, '.gitignore'); - let content = ''; - if (fs.existsSync(gitignorePath)) { - content = fs.readFileSync(gitignorePath, 'utf8'); - } + const exists = fs.existsSync(gitignorePath); + const content = exists ? fs.readFileSync(gitignorePath, 'utf8') : ''; + const created = previous?.created === true; + const lines = previous?.lines ?? []; const existingEntries = new Set(content.split('\n').map(l => l.trim())); const missing = SPM_GITIGNORE_ENTRIES.filter(e => !existingEntries.has(e)); if (missing.length === 0) { - return; + return {created, lines}; } - const block = [ - '', - '# SPM – auto-generated at build time (do not commit)', - ...missing, - ].join('\n'); + const block = ['', SPM_GITIGNORE_HEADER, ...missing].join('\n'); // Append, ensuring we start on a fresh line const separator = content.length > 0 && !content.endsWith('\n') ? '\n' : ''; fs.writeFileSync(gitignorePath, content + separator + block + '\n', 'utf8'); log(`Updated .gitignore with SPM entries: ${missing.join(', ')}`); + return { + created: created || !exists, + lines: [...lines, SPM_GITIGNORE_HEADER, ...missing], + }; +} + +/** + * The inverse of ensureGitignoreSpmEntries: removes the lines `record` says + * `add` appended (a header with the blank line written before it), and the + * file when `add` created it and nothing else is left. Without a record, as + * in a marker from before it existed, .gitignore is left untouched. + */ +function removeGitignoreSpmEntries( + appRoot /*: string */, + record /*: ?GitignoreRecord */, +) { + const gitignorePath = path.join(appRoot, '.gitignore'); + if ( + record == null || + record.lines.length === 0 || + !fs.existsSync(gitignorePath) + ) { + return; + } + const lines = fs.readFileSync(gitignorePath, 'utf8').split('\n'); + let changedByUser = 0; + // Last occurrence first: `add` only ever appends. + for (const recorded of [...record.lines].reverse()) { + const index = lines.lastIndexOf(recorded); + if (index === -1) { + changedByUser++; + } else if ( + recorded === SPM_GITIGNORE_HEADER && + index > 0 && + lines[index - 1] === '' + ) { + lines.splice(index - 1, 2); + } else { + lines.splice(index, 1); + } + } + const remaining = lines.join('\n'); + if (record.created && remaining.trim() === '') { + fs.rmSync(gitignorePath); + log('Removed .gitignore, which `spm add` created.'); + return; + } + fs.writeFileSync(gitignorePath, remaining, 'utf8'); + if (changedByUser > 0) { + log( + `.gitignore: ${changedByUser} SPM line(s) changed or removed after ` + + '`spm add` were left as they are. Remove any SPM entries you no ' + + 'longer need.', + ); + } else if (record.created) { + log( + 'Removed the SPM entries from .gitignore. Kept the file, which ' + + '`spm add` created, because it now has lines of your own.', + ); + } else { + log('Removed the SPM entries from .gitignore.'); + } } /** @@ -681,6 +750,21 @@ function pbxprojUsesCocoaPods(xcodeprojPath /*: string */) /*: boolean */ { } } +// A web link, because npm does not ship the __docs__ directories. +const KEEPING_NON_RN_PODS_DOCS_URL = + 'https://github.com/facebook/react-native/blob/main/packages/react-native/' + + 'scripts/spm/__docs__/spm-scripts.md#keeping-non-rn-pods'; + +function cocoaPodsIntegratedMessage(xcodeprojPath /*: string */) /*: string */ { + return ( + `${path.basename(xcodeprojPath)} is CocoaPods-integrated. If your ` + + 'Podfile declares only React Native, re-run `spm add --deintegrate` ' + + '(runs `pod deintegrate` and moves the Podfile to Podfile.bak). If it ' + + 'declares other pods, convert by hand as described in "Keeping non-RN ' + + `pods": ${KEEPING_NON_RN_PODS_DOCS_URL}` + ); +} + // True when the Podfile still declares React Native integration — a latent // landmine even after `pod deintegrate` (a future `pod install` re-breaks the // SPM graph). Warned about (not refused) once the pbxproj itself is clean. @@ -694,13 +778,16 @@ function podfileHasRnIntegration(appRoot /*: string */) /*: boolean */ { ); } -// True when the Podfile declares any explicit `pod '...'` (third-party pods). +// True when the Podfile declares any explicit `pod '...'` or `pod('...')` +// (third-party pods), at the start of a line or after a `;`. function podfileHasThirdPartyPods(appRoot /*: string */) /*: boolean */ { const podfilePath = path.join(appRoot, 'Podfile'); if (!fs.existsSync(podfilePath)) { return false; } - return /^\s*pod\s+['"]/m.test(fs.readFileSync(podfilePath, 'utf8')); + return /^(?:[^#\n]*;)?\s*pod\s*\(?\s*['"]/m.test( + fs.readFileSync(podfilePath, 'utf8'), + ); } // The zero-arg "fresh project" safe-gate: auto-`add --deintegrate` ONLY when it @@ -718,7 +805,7 @@ function shouldAutoDeintegrate( if (xcodeprojPath == null || !pbxprojUsesCocoaPods(xcodeprojPath)) { return false; } - if (podfileHasThirdPartyPods(appRoot)) { + if (podfileHasThirdPartyPods(appRoot) || hasPodfileBackup(appRoot)) { return false; } const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); @@ -735,10 +822,17 @@ function shouldAutoDeintegrate( return true; } -// Run `pod deintegrate` then strip React Native from the Podfile (leaving any -// non-RN pods). Requires CocoaPods on PATH (fail-loud otherwise). Flag-gated ⇒ -// no prompt ⇒ CI-safe. Does NOT touch the .xcworkspace. +// Run `pod deintegrate` then move the Podfile aside as Podfile.bak, unedited, +// so `spm deinit` can put CocoaPods back exactly as it was. Requires CocoaPods +// on PATH (fail-loud otherwise). Flag-gated ⇒ no prompt ⇒ CI-safe. Does NOT +// touch the .xcworkspace. function runDeintegrate(appRoot /*: string */) /*: void */ { + const podfilePath = path.join(appRoot, 'Podfile'); + const backupPath = path.join(appRoot, 'Podfile.bak'); + if (refuseToOverwritePodfileBackup(appRoot)) { + process.exitCode = 1; + throw new Error('Podfile.bak already exists'); + } try { execFileSync('pod', ['--version'], {stdio: 'ignore'}); } catch { @@ -752,23 +846,62 @@ function runDeintegrate(appRoot /*: string */) /*: void */ { log('Running `pod deintegrate`...'); execFileSync('pod', ['deintegrate'], {cwd: appRoot, stdio: 'inherit'}); - const podfilePath = path.join(appRoot, 'Podfile'); - if (fs.existsSync(podfilePath)) { - const orig = fs.readFileSync(podfilePath, 'utf8'); - const stripped = orig - .split('\n') - .filter( - l => - !/use_react_native!|use_native_modules!|prepare_react_native_project!/.test( - l, - ), - ) - .join('\n'); - if (stripped !== orig) { - fs.writeFileSync(podfilePath, stripped, 'utf8'); - log('Stripped React Native integration from Podfile.'); - } + if (entryExists(podfilePath)) { + fs.renameSync(podfilePath, backupPath); + log('Moved Podfile to Podfile.bak. `spm deinit` restores it.'); + } +} + +// Unlike existsSync, true for a dangling symlink too, which a rename onto +// that path would silently replace. +function entryExists(filePath /*: string */) /*: boolean */ { + try { + fs.lstatSync(filePath); + return true; + } catch { + return false; + } +} + +function hasPodfileBackup(appRoot /*: string */) /*: boolean */ { + return entryExists(path.join(appRoot, 'Podfile.bak')); +} + +// True (after logging) when runDeintegrate would have to overwrite +// Podfile.bak, which may hold the only copy of an earlier Podfile. +function refuseToOverwritePodfileBackup(appRoot /*: string */) /*: boolean */ { + if (!hasPodfileBackup(appRoot)) { + return false; + } + logError( + 'Podfile.bak already exists, and `--deintegrate` will not overwrite it. ' + + 'Move or delete Podfile.bak, then run the command again. ' + + 'Nothing was changed.', + ); + return true; +} + +// The inverse of runDeintegrate's Podfile move. Returns false (after logging) +// when both files exist, so the caller stops before changing anything. +function checkPodfileRestorable(appRoot /*: string */) /*: boolean */ { + if (hasPodfileBackup(appRoot) && entryExists(path.join(appRoot, 'Podfile'))) { + logError( + 'Both Podfile and Podfile.bak exist, so `spm deinit` cannot restore ' + + 'Podfile.bak without overwriting Podfile. Keep the one you want, ' + + 'delete the other, then run `spm deinit` again.', + ); + return false; } + return true; +} + +function restorePodfile(appRoot /*: string */) /*: void */ { + const backupPath = path.join(appRoot, 'Podfile.bak'); + if (!entryExists(backupPath)) { + return; + } + fs.renameSync(backupPath, path.join(appRoot, 'Podfile')); + log('Restored Podfile from Podfile.bak. Next step: run `pod install`.'); } // Pick the .xcodeproj to inject into: --xcodeproj override > a prior in-place @@ -852,6 +985,7 @@ async function setupXcodeproj( reactNativeRoot /*: string */, action /*: string */, cxxApiDefines /*: ReadonlyArray */, + gitignore /*: ?GitignoreRecord */, ) /*: Promise */ { const target = resolveInjectionTarget(args, appRoot); if (target.error != null) { @@ -879,12 +1013,7 @@ async function setupXcodeproj( // Preflight: a still-CocoaPods-integrated pbxproj is the real build-breaker. if (pbxprojUsesCocoaPods(xcodeprojPath)) { - logError( - `${path.basename(xcodeprojPath)} is CocoaPods-integrated. Re-run ` + - '`spm add --deintegrate` to convert it (runs `pod deintegrate` + ' + - 'strips React Native from the Podfile), or run `pod deintegrate` ' + - 'yourself first. Side-by-side non-RN pods are fine.', - ); + logError(cocoaPodsIntegratedMessage(xcodeprojPath)); process.exitCode = 1; throw new Error('CocoaPods-integrated project'); } @@ -930,6 +1059,7 @@ async function setupXcodeproj( artifactsVersionOverride: args.version ?? null, configCommand: resolveConfigCommandToPin(args), cxxApiDefines, + gitignore, }); if (result.status !== 'injected') { logError(`SPM injection failed: ${result.reason}`); @@ -1122,7 +1252,7 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { ) { log( 'Detected a freshly-scaffolded CocoaPods project — converting to SwiftPM ' + - '(running `pod deintegrate`). Revert with `git` or `spm deinit`.', + '(running `pod deintegrate`). `spm deinit` restores CocoaPods.', ); args.deintegrate = true; } @@ -1137,16 +1267,46 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { args.xcodeprojPath != null ? path.resolve(appRoot, args.xcodeprojPath) : findInjectedXcodeproj(appRoot); - if (xcodeprojPath == null) { - log('No SPM injection found — nothing to remove.'); + if (!checkPodfileRestorable(appRoot)) { + process.exitCode = 1; return; } - const result = removeSpmInjection({appRoot, xcodeprojPath}); - log( - result.status === 'removed' - ? `Removed SPM packages from ${path.basename(xcodeprojPath)}.` - : 'No SPM injection found — nothing to remove.', + // removeSpmInjection drops the marker, and the record with it. + const gitignoreRecord = + xcodeprojPath != null ? readMarker(xcodeprojPath)?.gitignore : null; + if ( + xcodeprojPath != null && + removeSpmInjection({appRoot, xcodeprojPath}).status === 'removed' + ) { + log(`Removed SPM packages from ${path.basename(xcodeprojPath)}.`); + } else if (hasPodfileBackup(appRoot)) { + log('No SwiftPM injection found.'); + } else { + log('No SPM injection found — nothing to remove.'); + } + try { + removeGitignoreSpmEntries(appRoot, gitignoreRecord); + } catch (e) { + logError( + `Could not remove the SPM entries from .gitignore: ${e.message}. ` + + 'Remove them by hand.', + ); + } + restorePodfile(appRoot); + return; + } + + if (args.deintegrate && podfileHasThirdPartyPods(appRoot)) { + logError( + 'Your Podfile declares pods that are not part of React Native. They ' + + 'would stop working under SwiftPM, so the conversion is refused. ' + + 'Nothing was changed.', ); + process.exitCode = 1; + return; + } + if (args.deintegrate && refuseToOverwritePodfileBackup(appRoot)) { + process.exitCode = 1; return; } @@ -1157,12 +1317,7 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { if ((action === 'add' || action === 'update') && !args.deintegrate) { const target = resolveInjectionTarget(args, appRoot); if (target.path != null && pbxprojUsesCocoaPods(target.path)) { - logError( - `${path.basename(target.path)} is CocoaPods-integrated. Re-run ` + - '`spm add --deintegrate` to convert it (runs `pod deintegrate` + ' + - 'strips React Native from the Podfile), or run `pod deintegrate` ' + - 'yourself first. Side-by-side non-RN pods are fine.', - ); + logError(cocoaPodsIntegratedMessage(target.path)); process.exitCode = 1; return; } @@ -1379,15 +1534,28 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { // so no path-locator JSON is written. buildPerAppHeaderTree(appRoot, {log}); - // First-time setup only adds the gitignore entries on `add`. + // First-time setup only adds the gitignore entries on `add`. The record + // reaches the marker through setupXcodeproj, which writes it. + let gitignoreRecord = null; if (action === 'add') { - ensureGitignoreSpmEntries(appRoot); + const injected = resolveInjectionTarget(args, appRoot).path; + gitignoreRecord = ensureGitignoreSpmEntries( + appRoot, + injected != null ? readMarker(injected)?.gitignore : null, + ); } // Xcodeproj setup: in-place injection into the existing project (the only // strategy — no rename, no from-scratch; git is the safety net). try { - await setupXcodeproj(args, appRoot, reactNativeRoot, action, cxxApiDefines); + await setupXcodeproj( + args, + appRoot, + reactNativeRoot, + action, + cxxApiDefines, + gitignoreRecord, + ); } catch (e) { logError(`xcodeproj setup failed: ${e.message}`); if (process.exitCode == null) { @@ -1409,6 +1577,7 @@ module.exports = { main, detectStandardRnLayoutRedirect, determineVersion, + ensureGitignoreSpmEntries, findInjectedXcodeproj, generateAutolinkingConfigOrFailClosed, parseArgs, @@ -1417,6 +1586,7 @@ module.exports = { resolveConfigCommandToPin, resolveExplicitConfigCommand, resolveCxxApiDefines, + runDeintegrate, shouldAutoDeintegrate, ensureBothArtifactFlavors, }; diff --git a/packages/react-native/scripts/spm/__docs__/README.md b/packages/react-native/scripts/spm/__docs__/README.md index b04da44fc81f..d9053787019c 100644 --- a/packages/react-native/scripts/spm/__docs__/README.md +++ b/packages/react-native/scripts/spm/__docs__/README.md @@ -23,6 +23,16 @@ cd ios npx react-native spm # add on first run, update thereafter ``` +On a fresh CocoaPods app, this converts the app to SwiftPM. It runs +`pod deintegrate` and moves the `Podfile` to `Podfile.bak`. A Podfile with pods +that are not part of React Native is refused. Without a Podfile, the CLI needs +`@react-native-community/cli` 21 or newer to find the iOS project. + +To go back to CocoaPods, run `npx react-native spm deinit`, then `pod install` +in `ios/`. If your Podfile does not call `react_native_post_install`, check +`REACT_NATIVE_PATH`; see +[Removing / resetting](./spm-scripts.md#removing--resetting). + **If an autolinked dependency ships no `Package.swift`, setup stops with `error: Package.swift is missing for library ""` and exit code 2.** Run `npx react-native spm scaffold`, persist the manifest with `patch-package`, and diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index 4a8460d1a3a7..0a268cb64201 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -42,28 +42,35 @@ dependency changes. **On a fresh clone or CI checkout, run CocoaPods app it fails loud and points you at `--deintegrate`, which: 1. runs `pod deintegrate`. This removes CocoaPods integration from the - `.xcodeproj` (Pods references, `[CP]` build phases, xcconfig links). Your - `Podfile` is left on disk. -2. strips **only** the React Native directives (`use_react_native!`, - `use_native_modules!`, `prepare_react_native_project!`) from the Podfile. - Every other line, **including your own `pod '…'` entries, is preserved**. The - strip is line-based: it deletes each line that contains one of those names, - and nothing else. The argument lines of a multi-line `use_react_native!(` - call and any `react_native_post_install(...)` call remain, so remove them by - hand before you run `pod install`. + `.xcodeproj` (Pods references, `[CP]` build phases, xcconfig links). +2. moves the `Podfile` to `Podfile.bak`, unchanged. `spm deinit` moves it back. + If `Podfile.bak` already exists, the command stops before it changes + anything. 3. injects SwiftPM into the `.xcodeproj`. -React Native now comes from SwiftPM. No pods are linked yet, because deintegrate -removed the integration. +The zero-arg conversion of a fresh app does the same steps. It does not start +when `Podfile.bak` exists; the command then fails as a plain `add` does. + +If the Podfile declares pods that are not part of React Native, `--deintegrate` +refuses and changes nothing. Those pods would stop working under SwiftPM. See +[Keeping non-RN pods](#keeping-non-rn-pods). + +After the conversion, the app has no Podfile. The CLI then finds the iOS project +through the `.spm-injected.json` marker. This needs +`@react-native-community/cli` 21 or newer. An older CLI needs a Podfile to find +the iOS project. ### Keeping non-RN pods -Non-RN pods can stay side-by-side. Your Podfile still lists them. Remove the -leftover React Native lines (see step 2 above), then re-integrate the pods with -a normal install: +`--deintegrate` does not convert an app whose Podfile has pods that are not part +of React Native. To keep those pods side-by-side, convert by hand: ```bash -pod install # re-integrates the remaining (non-RN) pods; (re)creates the .xcworkspace +pod deintegrate # removes the CocoaPods integration from the .xcodeproj +# remove use_react_native!, use_native_modules!, prepare_react_native_project! +# and react_native_post_install from the Podfile; keep your own pods +npx react-native spm add +pod install # re-integrates the remaining (non-RN) pods; (re)creates the .xcworkspace ``` Then **open the `.xcworkspace`** (not the `.xcodeproj`). The workspace includes @@ -115,9 +122,9 @@ redirects into `ios/` and prints a banner. | Action | Description | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `add` | Inject SwiftPM packages (package refs, the base reference to `ReactNativeSPM/ReactNativeSPM.xcconfig`, the Sync build phase) into the existing `.xcodeproj`, in place. Idempotent. Default on first run. `--deintegrate` first runs `pod deintegrate` + strips React Native from the Podfile. | +| `add` | Inject SwiftPM packages (package refs, the base reference to `ReactNativeSPM/ReactNativeSPM.xcconfig`, the Sync build phase) into the existing `.xcodeproj`, in place. Idempotent. Default on first run. `--deintegrate` first runs `pod deintegrate` and moves the Podfile to `Podfile.bak`. | | `update` | Re-run the pipeline and refresh the existing injection. Default once a project is injected. | -| `deinit` | The inverse of `add`: surgically remove only what `add` injected (recorded in `.spm-injected.json`) and drop the marker. Git-recoverable; no prompt. Some edits it does not undo — see [Files the tool touches](#files-the-tool-touches). | +| `deinit` | The inverse of `add`: surgically remove only what `add` injected (recorded in `.spm-injected.json`) and drop the marker. Moves `Podfile.bak` back to `Podfile`. Git-recoverable; no prompt. Some edits it does not undo — see [Files the tool touches](#files-the-tool-touches). | | `scaffold` | Generate `Package.swift` into `node_modules//` for community RN libraries that ship only a podspec. | | `sync` (advanced) | Lightweight resync invoked by the Xcode auto-sync hooks. Regenerates invariant codegen and autolinking output only. Not for humans. | | `codegen` (advanced) | Run codegen and install the SwiftPM codegen template only. | @@ -133,7 +140,7 @@ Flags below use the `react-native spm` (camelCase) form. | `--yes` | Skip the dirty-pbxproj confirmation prompt | | `--xcodeproj ` | [add, update, scaffold, deinit] Which `.xcodeproj` to inject into or remove from (when several exist) | | `--productName ` | [add, update, scaffold] Which app target to inject into (when several exist) | -| `--deintegrate` | [add] Run `pod deintegrate` + strip React Native from the Podfile before injecting | +| `--deintegrate` | [add] Run `pod deintegrate` and move the Podfile to `Podfile.bak` before injecting. Refused if the Podfile has pods that are not part of React Native | | `--artifacts ` | [advanced] Local artifact root containing complete `debug/` and `release/` cache slots | | `--download ` | [advanced] Artifact download policy (default: auto) | | `--skipCodegen` | [advanced] Skip the codegen step | @@ -287,16 +294,16 @@ Paths are relative to the Xcode project directory (`ios/`) unless noted. ### In your repo — committed -| Path | Written by | What happens | Undone by `deinit`? | -| --------------------------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MyApp.xcodeproj/project.pbxproj` | `add`, `update`, `scaffold` | SwiftPM package refs, a reference to `ReactNativeSPM/ReactNativeSPM.xcconfig` set as the base configuration of each app configuration, the Sync build phase, and the flavored-framework embed phase are added. The two phases run their scripts from `ReactNativeSPM/Scripts/`. A re-run is a no-op. A `${PODS_ROOT}`-anchored `REACT_NATIVE_PATH` is removed, so the xcconfig's SwiftPM path applies. `add` also removes the template's unused `JavaScriptCore.framework` reference, and `add --deintegrate` removes an empty `Pods` group. | Yes — exactly what was injected, per the marker. The removed `REACT_NATIVE_PATH`, `JavaScriptCore.framework` reference, and `Pods` group are **not** restored. | -| `MyApp.xcodeproj/.spm-injected.json` | `add`, `update`, `scaffold` | Created. Three roles: it records every edit made — including which configurations it based on the generated xcconfig — so removal is surgical and re-runs stay idempotent; it records the frameworks the project links (`linkedFrameworks`), which the build-time sync compares with the frameworks plugins provide; and it **pins configuration** later runs and Xcode builds must reuse (see the two pins below). | Yes — deleted, and the pins go with it | -| `ReactNativeSPM/ReactNativeSPM.xcconfig` | `add`, `update`, `scaffold` | Regenerated on each run and written when its content changes. Holds the React build settings: search paths, linker flags, `REACT_NATIVE_PATH`, the C++ standard, each configuration's flavor (`RN_SPM_FLAVOR`), and `DEBUG` for Swift on debug configurations (see [Build settings](#build-settings)). **Commit it.** Xcode reads it before any build phase runs, so a build cannot regenerate it. If it is missing, run `npx react-native spm update` and commit it. | Yes — deleted, unless a configuration is based on an xcconfig of your own, which may `#include` it. Then the file is kept, and `deinit` names those configurations. | -| `ReactNativeSPM/Scripts/` | `add`, `update`, `scaffold` | Regenerated on each run and written when their content changes: `sync-autolinking.sh` and `embed-flavored-frameworks.sh`, the scripts the Sync and embed phases run. Plugin script phases keep their scripts in `project.pbxproj`. **Commit it.** When a script is missing, its phase fails the build with `error: is missing. Run 'npx react-native spm update' to generate it.` | Yes — both scripts are deleted | -| `MyApp.xcodeproj/xcshareddata/xcschemes/*.xcscheme` | `add`, `update`, `scaffold` | The sync pre-action is added to the scheme that builds your target; a shared scheme is created if there is none. Commit this or teammates lose the pre-action. | Yes — a scheme `add` created is deleted only if it is still byte-identical to the generated one; otherwise only the pre-action is stripped | -| `.gitignore` | `add` only | Created if absent, else appended: a `# SPM – auto-generated at build time` block adding `Package.resolved`, `build/generated/`, `build/xcframeworks/`, `.build/`. | **No** — the block is left behind | -| `Podfile` | `add --deintegrate` | Only the lines containing the React Native directives (`use_react_native!`, `use_native_modules!`, `prepare_react_native_project!`) are stripped. Your own `pod '…'` lines are preserved. The argument lines of a multi-line `use_react_native!(` call and `react_native_post_install(...)` remain — remove them by hand before `pod install`. | **No** — re-add the directives yourself to go back to CocoaPods | -| `Pods/`, `Pods-*.xcconfig`, `[CP]` phases | `add --deintegrate` | Removed by `pod deintegrate`. The `.xcworkspace` referencing them is left on disk. | **No** — run `pod install` to restore | +| Path | Written by | What happens | Undone by `deinit`? | +| --------------------------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `MyApp.xcodeproj/project.pbxproj` | `add`, `update`, `scaffold` | SwiftPM package refs, a reference to `ReactNativeSPM/ReactNativeSPM.xcconfig` set as the base configuration of each app configuration, the Sync build phase, and the flavored-framework embed phase are added. The two phases run their scripts from `ReactNativeSPM/Scripts/`. A re-run is a no-op. A `${PODS_ROOT}`-anchored `REACT_NATIVE_PATH` is removed, so the xcconfig's SwiftPM path applies. `add` also removes the template's unused `JavaScriptCore.framework` reference, and `add --deintegrate` removes an empty `Pods` group. | Yes — exactly what was injected, per the marker. The removed `REACT_NATIVE_PATH`, `JavaScriptCore.framework` reference, and `Pods` group are **not** restored. | +| `MyApp.xcodeproj/.spm-injected.json` | `add`, `update`, `scaffold` | Created. Three roles: it records every edit made — including which configurations it based on the generated xcconfig — so removal is surgical and re-runs stay idempotent; it records the frameworks the project links (`linkedFrameworks`), which the build-time sync compares with the frameworks plugins provide; and it **pins configuration** later runs and Xcode builds must reuse (see the two pins below). | Yes — deleted, and the pins go with it | +| `ReactNativeSPM/ReactNativeSPM.xcconfig` | `add`, `update`, `scaffold` | Regenerated on each run and written when its content changes. Holds the React build settings: search paths, linker flags, `REACT_NATIVE_PATH`, the C++ standard, each configuration's flavor (`RN_SPM_FLAVOR`), and `DEBUG` for Swift on debug configurations (see [Build settings](#build-settings)). **Commit it.** Xcode reads it before any build phase runs, so a build cannot regenerate it. If it is missing, run `npx react-native spm update` and commit it. | Yes — deleted, unless a configuration is based on an xcconfig of your own, which may `#include` it. Then the file is kept, and `deinit` names those configurations. | +| `ReactNativeSPM/Scripts/` | `add`, `update`, `scaffold` | Regenerated on each run and written when their content changes: `sync-autolinking.sh` and `embed-flavored-frameworks.sh`, the scripts the Sync and embed phases run. Plugin script phases keep their scripts in `project.pbxproj`. **Commit it.** When a script is missing, its phase fails the build with `error: is missing. Run 'npx react-native spm update' to generate it.` | Yes — both scripts are deleted, then each `ReactNativeSPM/` directory left empty | +| `MyApp.xcodeproj/xcshareddata/xcschemes/*.xcscheme` | `add`, `update`, `scaffold` | The sync pre-action is added to the scheme that builds your target; a shared scheme is created if there is none. Commit this or teammates lose the pre-action. | Yes — a scheme `add` created is deleted only if it is still byte-identical to the generated one; otherwise only the pre-action is stripped | +| `.gitignore` | `add` only | Created if absent, else appended: a `# SPM – auto-generated at build time` block adding `Package.resolved`, `build/generated/`, `build/xcframeworks/`, `.build/`. | Yes — only the lines `add` wrote, as recorded in the marker, and the file too if `add` created it and nothing else is left. A line you changed is kept, and `deinit` says so. A marker from an older version has no record, so `.gitignore` is left as it is | +| `Podfile` | `add --deintegrate` | Moved to `Podfile.bak`, unchanged. Refused, before any change, if the Podfile has pods that are not part of React Native or `Podfile.bak` already exists. | Yes — `Podfile.bak` is moved back to `Podfile`. If both files exist, `deinit` stops before it changes anything | +| `Pods/`, `Pods-*.xcconfig`, `[CP]` phases | `add --deintegrate` | Removed by `pod deintegrate`. The `.xcworkspace` referencing them is left on disk. | **No** — run `pod install` to restore | The two pins are the `--version` pin (`artifactsVersionOverride`, see [Pinning the React Native version](#pinning-the-react-native-version)) and the @@ -684,10 +691,21 @@ the context/return contract, lifecycle, and failure behavior. To remove SwiftPM entirely, use `deinit` (the inverse of `add`): ```bash -react-native spm deinit # surgically removes everything `add` injected -pod install # then, to restore CocoaPods +npx react-native spm deinit # surgically removes everything `add` injected +pod install # then, to restore CocoaPods ``` +`deinit` also moves `Podfile.bak` back to `Podfile` and removes the `.gitignore` +lines that `add` recorded in the marker. + +The conversion (`spm add`) removed the app target's `${PODS_ROOT}`-anchored +`REACT_NATIVE_PATH` and the unused `JavaScriptCore.framework` reference, and +`deinit` does not put them back. `pod install` sets `REACT_NATIVE_PATH` again at +project level, through `react_native_post_install`, and the app target inherits +it. Check it by hand only if your Podfile does not call +`react_native_post_install`, or if the target's own value differed from the +project's. + `deinit` removes each configuration's base reference to `ReactNativeSPM/ReactNativeSPM.xcconfig` and deletes the generated scripts. It deletes the xcconfig too, unless a configuration is based on an xcconfig of your @@ -712,7 +730,7 @@ across apps; refresh it with `react-native spm update --download force`. | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `xcodebuild` fails: "Could not resolve package dependencies … `build/generated/autolinking` doesn't exist" | Fresh clone — run `npx react-native spm` once before building (see [Fresh clones & CI](#fresh-clones--ci)) | | Build fails: `error: …/ReactNativeSPM/Scripts/.sh is missing` | `ReactNativeSPM/` was not committed. Run `npx react-native spm update`, then commit `ReactNativeSPM/` | -| `spm add` fails: "CocoaPods-integrated project" | Re-run `spm add --deintegrate` (runs `pod deintegrate` + strips RN from the Podfile), or `pod deintegrate` yourself first. | +| `spm add` fails: "CocoaPods-integrated project" | Re-run `spm add --deintegrate` (runs `pod deintegrate` and moves the Podfile to `Podfile.bak`). If the Podfile has pods that are not part of React Native, convert by hand (see [Keeping non-RN pods](#keeping-non-rn-pods)). | | `spm add` fails: "no .xcodeproj found" | Create an app first (`npx @react-native-community/cli init`) or make a project in Xcode, then `spm add`. | | `spm add` fails: "multiple .xcodeproj found" | Pass `--xcodeproj ` (and `--productName ` if multiple app targets). | | `Package.swift is missing for library ""` (exit 2) | The dep ships no SwiftPM support. `npx react-native spm scaffold`, then re-run setup; persist with `patch-package`. See [Community packages without a Package.swift](#community-packages-without-a-packageswift) | @@ -726,7 +744,7 @@ across apps; refresh it with `react-native spm update --download force`. | "Sync SPM Autolinking" build phase fails: `'npx --no-install @react-native-community/cli config' exited with status 1` | This app replaces `@react-native-community/cli` autolinking (e.g. an Expo app). Re-run `spm add`/`update` with `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` exported (or the raw script with `--config-command`) so the working command is pinned for the build phase to reuse — see [The autolinking config command is remembered](#the-autolinking-config-command-is-remembered). | | Autolinking not updating on build | Touch `package.json` to force a sync, or delete `build/generated/autolinking/.spm-sync-stamp` | | Stale SwiftPM state or corrupted build | `rm -rf build/ .build/`, then `react-native spm update`, then reopen Xcode | -| Want to revert to CocoaPods | `react-native spm deinit`, then `pod install` | +| Want to revert to CocoaPods | `npx react-native spm deinit`, then `pod install` in `ios/`. Without `react_native_post_install` in the Podfile, check `REACT_NATIVE_PATH` (see [Removing / resetting](#removing--resetting)) | --- @@ -754,7 +772,7 @@ my-app/ios/ ReactNativeSPM/ <-- committed, rewritten by add/update ReactNativeSPM.xcconfig <-- React build settings; base configuration of the app configurations Scripts/ <-- scripts the Sync and embed phases run - Podfile <-- kept; `--deintegrate` only strips the React Native lines (CocoaPods coexistence is best-effort) + Podfile.bak <-- the Podfile, moved aside by `--deintegrate`; `deinit` moves it back build/ generated/ autolinking/ <-- gitignored (regenerated at build time) diff --git a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js index 0297e5e2eb36..3afa46d0938c 100644 --- a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js +++ b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js @@ -567,6 +567,83 @@ describe('removeSpmInjection — the surgical inverse of add', () => { expect(pbxprojOf(xcodeprojPath)).toBe(before); }); + it('removes the ReactNativeSPM directories its file removal leaves empty', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + removeSpmInjection({appRoot, xcodeprojPath}); + + expect(fs.existsSync(path.join(appRoot, 'ReactNativeSPM'))).toBe(false); + }); + + it('keeps a ReactNativeSPM directory that still holds a file of the user', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const userScript = path.join(appRoot, 'ReactNativeSPM', 'Scripts', 'my.sh'); + fs.writeFileSync(userScript, 'echo mine\n', 'utf8'); + + removeSpmInjection({appRoot, xcodeprojPath}); + + expect(fs.readFileSync(userScript, 'utf8')).toBe('echo mine\n'); + expect( + generatedFileContents(appRoot).map(content => content != null), + ).toEqual(GENERATED_FILES.map(() => false)); + }); + + it('prunes no directory outside the app through a symlinked ReactNativeSPM', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-outside-')); + scaffoldedAppRoots.push(outside); + fs.mkdirSync(path.join(outside, 'Scripts')); + fs.symlinkSync(outside, path.join(appRoot, 'ReactNativeSPM'), 'dir'); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + removeSpmInjection({appRoot, xcodeprojPath}); + + expect(fs.statSync(path.join(outside, 'Scripts')).isDirectory()).toBe(true); + expect( + fs.lstatSync(path.join(appRoot, 'ReactNativeSPM')).isSymbolicLink(), + ).toBe(true); + }); + + it('warns and carries on when an empty directory cannot be removed', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const rmdirSpy = jest.spyOn(fs, 'rmdirSync').mockImplementation(() => { + throw new Error('EACCES: permission denied'); + }); + + try { + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe( + 'removed', + ); + expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe( + false, + ); + expect(warnSpy.mock.calls.join('\n')).toContain('EACCES'); + } finally { + rmdirSpy.mockRestore(); + warnSpy.mockRestore(); + } + }); + it('round-trips WITH a generated-sources manifest (add then deinit is byte-identical)', () => { const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); writeManifest(appRoot, [PROVIDER_REL]); @@ -1855,3 +1932,48 @@ describe("a list setting of the project's own that keeps $(inherited)", () => { expect(after).not.toContain(SCALAR); }); }); + +// --------------------------------------------------------------------------- +// gitignore — the marker field recording exactly what `spm add` wrote to the +// app's .gitignore, so `deinit` (setup-apple-spm.js) undoes only that. +// --------------------------------------------------------------------------- +describe('gitignore marker field', () => { + const RECORD = {created: true, lines: ['# SPM', 'Package.resolved']}; + + it('records the .gitignore record add passes in', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + gitignore: RECORD, + }); + expect(readMarker(xcodeprojPath).gitignore).toEqual(RECORD); + }); + + it('preserves the record on a later run that passes none (update)', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + gitignore: RECORD, + }); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).gitignore).toEqual(RECORD); + }); + + it('writes no record when add never passed one', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath)).not.toHaveProperty('gitignore'); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js index 02ece867a323..73ae9634f09d 100644 --- a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js +++ b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js @@ -10,18 +10,79 @@ 'use strict'; +// `pod` is faked so the CocoaPods conversion tests need no CocoaPods install, +// and the autolinking config command (spawnSync → npx) fails fast instead of +// reaching the network. A test that sets mockAutolinkingSourceDir lets the +// config command succeed instead, so `main` gets far enough to write +// build/generated/ — what "refuses before any change" has to rule out. git and +// everything else run for real. +const mockPodCalls = []; +let mockAutolinkingSourceDir = null; +jest.mock('node:child_process', () => { + const actual = jest.requireActual('node:child_process'); + return { + ...actual, + execFileSync: (command, args, options) => { + if (command === 'pod') { + mockPodCalls.push(args); + return ''; + } + return actual.execFileSync(command, args, options); + }, + spawnSync: () => + mockAutolinkingSourceDir == null + ? {status: 1, stdout: '', stderr: 'stubbed in test'} + : { + status: 0, + stdout: JSON.stringify({ + project: {ios: {sourceDir: mockAutolinkingSourceDir}}, + dependencies: {}, + }), + stderr: '', + }, + }; +}); + +// A test that sets mockInjectorCalls to an array runs `add` to the end: the +// xcframeworks package (which needs real XCFramework slices) is skipped, and +// the injector (which needs that package's output) only records its options. +let mockInjectorCalls = null; +jest.mock('../generate-spm-package', () => { + const actual = jest.requireActual('../generate-spm-package'); + return { + ...actual, + main: argv => (mockInjectorCalls == null ? actual.main(argv) : undefined), + }; +}); +jest.mock('../generate-spm-xcodeproj', () => { + const actual = jest.requireActual('../generate-spm-xcodeproj'); + return { + ...actual, + injectSpmIntoExistingXcodeproj: opts => { + if (mockInjectorCalls == null) { + return actual.injectSpmIntoExistingXcodeproj(opts); + } + mockInjectorCalls.push(opts); + return {status: 'injected', target: 'MyApp'}; + }, + }; +}); + const { detectStandardRnLayoutRedirect, determineVersion, ensureBothArtifactFlavors, + ensureGitignoreSpmEntries, findInjectedXcodeproj, generateAutolinkingConfigOrFailClosed, + main, parseArgs, resolveAction, resolveAppIosDeploymentTarget, resolveConfigCommandToPin, resolveCxxApiDefines, resolveExplicitConfigCommand, + runDeintegrate, shouldAutoDeintegrate, } = require('../../setup-apple-spm'); const {REQUIRED_ARTIFACTS} = require('../download-spm-artifacts'); @@ -63,6 +124,26 @@ function mkXcodeproj(appRoot, name, {cocoapods = false} = {}) { return dir; } +// A Debug or Release artifact slot that passes validation: every required +// XCFramework directory, listed in artifacts.json. +function writeCompleteArtifactSlot(root, flavor) { + const slot = path.join(root, flavor); + fs.mkdirSync(path.join(slot, 'hermes-headers', 'hermes'), { + recursive: true, + }); + const artifacts = {}; + for (const name of REQUIRED_ARTIFACTS) { + const xcframeworkPath = path.join(slot, `${name}.xcframework`); + fs.mkdirSync(xcframeworkPath, {recursive: true}); + artifacts[name] = {xcframeworkPath}; + } + fs.writeFileSync( + path.join(slot, 'artifacts.json'), + JSON.stringify(artifacts), + ); + return slot; +} + function gitInitAndCommit(dir) { const opts = {cwd: dir, stdio: 'ignore'}; execFileSync('git', ['init'], opts); @@ -461,21 +542,7 @@ describe('dual-flavor artifact input', () => { afterEach(() => fs.rmSync(root, {recursive: true, force: true})); function writeCompleteSlot(flavor) { - const slot = path.join(root, flavor); - fs.mkdirSync(path.join(slot, 'hermes-headers', 'hermes'), { - recursive: true, - }); - const artifacts = {}; - for (const name of REQUIRED_ARTIFACTS) { - const xcframeworkPath = path.join(slot, `${name}.xcframework`); - fs.mkdirSync(xcframeworkPath, {recursive: true}); - artifacts[name] = {xcframeworkPath}; - } - fs.writeFileSync( - path.join(slot, 'artifacts.json'), - JSON.stringify(artifacts), - ); - return slot; + return writeCompleteArtifactSlot(root, flavor); } function args() { @@ -618,6 +685,645 @@ describe('shouldAutoDeintegrate', () => { fs.writeFileSync(path.join(tempDir, 'package-lock.json'), '{}'); expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(true); }); + + it('false when Podfile.bak exists, which the conversion would overwrite', () => { + const xcodeproj = mkXcodeproj(tempDir, 'MyApp.xcodeproj', { + cocoapods: true, + }); + fs.writeFileSync( + path.join(tempDir, 'Podfile'), + "target 'MyApp' do\n use_react_native!\nend\n", + ); + fs.writeFileSync( + path.join(tempDir, 'Podfile.bak'), + "target 'Old' do\nend\n", + ); + gitInitAndCommit(tempDir); + expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(false); + }); + + describe('third-party pod detection', () => { + function podfileWith(line) { + return `target 'MyApp' do\n use_react_native!\n${line}\nend\n`; + } + + function autoDeintegrates(podfile) { + const xcodeproj = mkXcodeproj(tempDir, 'MyApp.xcodeproj', { + cocoapods: true, + }); + fs.writeFileSync(path.join(tempDir, 'Podfile'), podfile); + gitInitAndCommit(tempDir); + return shouldAutoDeintegrate(tempDir, xcodeproj); + } + + it.each([ + [" pod 'MBProgressHUD'"], + [' pod "MBProgressHUD"'], + [" pod('Firebase/Analytics')"], + [' pod ("Firebase/Analytics")'], + [" pod( 'Firebase/Analytics', '~> 10.0')"], + ["target 'App' do; pod('Foo'); end"], + [" use_frameworks!; pod 'Foo'"], + ])('detects %s', line => { + expect(autoDeintegrates(podfileWith(line))).toBe(false); + }); + + it.each([ + [" # pod 'MBProgressHUD'"], + [" podspec :path => '../MyLib.podspec'"], + [" pods_root = 'Pods'"], + [" pod_targets = ['MyApp']"], + [" # use_frameworks!; pod 'MBProgressHUD'"], + [" use_frameworks!; podspec :path => '../MyLib.podspec'"], + ])('ignores %s', line => { + expect(autoDeintegrates(podfileWith(line))).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// CocoaPods conversion is reversible: `--deintegrate` moves the Podfile aside +// as Podfile.bak instead of editing it, and `deinit` moves it back. +// --------------------------------------------------------------------------- + +describe('Podfile backup and restore', () => { + // Same shape as private/helloworld/ios/Podfile: a multi-line + // use_react_native! call and a post_install hook, which line-based + // stripping would leave as invalid Ruby. + const STOCK_PODFILE = [ + 'require_relative "../node_modules/react-native/scripts/react_native_pods"', + '', + 'platform :ios, min_ios_version_supported', + 'prepare_react_native_project!', + '', + "target 'MyApp' do", + ' config = use_native_modules!', + '', + ' use_react_native!(', + ' :path => config[:reactNativePath],', + ' :app_path => "#{Pod::Config.instance.installation_root}/.."', + ' )', + '', + ' post_install do |installer|', + ' react_native_post_install(', + ' installer,', + ' config[:reactNativePath],', + ' :mac_catalyst_enabled => false', + ' )', + ' end', + 'end', + '', + ].join('\n'); + const PODFILE_WITH_THIRD_PARTY_POD = STOCK_PODFILE.replace( + ' config = use_native_modules!\n', + " config = use_native_modules!\n pod 'Foo'\n", + ); + + let appRoot; + let podfilePath; + let backupPath; + let logSpy; + let warnSpy; + let errorSpy; + + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-podfile-backup-')); + podfilePath = path.join(appRoot, 'Podfile'); + backupPath = path.join(appRoot, 'Podfile.bak'); + mockPodCalls.length = 0; + jest.spyOn(process, 'cwd').mockReturnValue(appRoot); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + process.exitCode = undefined; + mockAutolinkingSourceDir = null; + mockInjectorCalls = null; + fs.rmSync(appRoot, {recursive: true, force: true}); + }); + + // `spm add` that runs to the end offline: a successful config command, + // complete local artifact slots, and a recording injector. + function runAdd(...flags) { + const artifacts = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-artifacts-')); + writeCompleteArtifactSlot(artifacts, 'debug'); + writeCompleteArtifactSlot(artifacts, 'release'); + mockAutolinkingSourceDir = appRoot; + mockInjectorCalls = []; + return main([ + 'add', + ...flags, + '--skip-codegen', + '--version', + '0.85.0', + '--download', + 'skip', + '--artifacts', + artifacts, + ]).finally(() => fs.rmSync(artifacts, {recursive: true, force: true})); + } + + function expectCocoaPodsAdvice() { + expect(errors()).toContain('CocoaPods-integrated'); + expect(errors()).toMatch(/only React Native[^\n]*`spm add --deintegrate`/); + expect(errors()).toContain('Keeping non-RN pods'); + expect(errors()).not.toContain('non-RN pods are fine'); + } + + function expectNoPipelineOutput(xcodeproj) { + expect(mockPodCalls).not.toContainEqual(['deintegrate']); + expect(fs.existsSync(path.join(appRoot, '.gitignore'))).toBe(false); + expect(fs.existsSync(path.join(appRoot, 'build'))).toBe(false); + expect(fs.existsSync(path.join(xcodeproj, SPM_INJECTED_MARKER))).toBe( + false, + ); + } + + function output(spy) { + return spy.mock.calls.map(call => call.join(' ')).join('\n'); + } + + function errors() { + return output(warnSpy) + '\n' + output(errorSpy); + } + + function mkInjectedProject(markerFields = {}) { + const xcodeproj = mkXcodeproj(appRoot, 'MyApp.xcodeproj'); + mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj', markerFields); + return xcodeproj; + } + + describe('add --deintegrate', () => { + it('moves a stock Podfile aside as Podfile.bak, byte-identical and unedited', () => { + fs.writeFileSync(podfilePath, STOCK_PODFILE); + + runDeintegrate(appRoot); + + expect(mockPodCalls).toContainEqual(['deintegrate']); + expect(fs.existsSync(podfilePath)).toBe(false); + expect(fs.readFileSync(backupPath, 'utf8')).toBe(STOCK_PODFILE); + }); + + it('fails loud and changes nothing when Podfile.bak already exists', () => { + const olderBackup = "target 'Old' do\nend\n"; + fs.writeFileSync(podfilePath, STOCK_PODFILE); + fs.writeFileSync(backupPath, olderBackup); + + expect(() => runDeintegrate(appRoot)).toThrow(); + + expect(process.exitCode).toBe(1); + expect(errors()).toContain('Podfile.bak already exists'); + expect(mockPodCalls).not.toContainEqual(['deintegrate']); + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + expect(fs.readFileSync(backupPath, 'utf8')).toBe(olderBackup); + }); + + it('refuses before any change when the Podfile has non-React-Native pods', async () => { + const xcodeproj = mkXcodeproj(appRoot, 'MyApp.xcodeproj', { + cocoapods: true, + }); + const pbxprojPath = path.join(xcodeproj, 'project.pbxproj'); + const pbxprojBefore = fs.readFileSync(pbxprojPath, 'utf8'); + fs.writeFileSync(podfilePath, PODFILE_WITH_THIRD_PARTY_POD); + mockAutolinkingSourceDir = appRoot; + + await main(['add', '--deintegrate']); + + expect(process.exitCode).toBe(1); + expect(errors()).toMatch(/pods?\b[^]*SwiftPM/i); + expect(errors()).toMatch(/stop working/i); + expectNoPipelineOutput(xcodeproj); + expect(fs.readFileSync(podfilePath, 'utf8')).toBe( + PODFILE_WITH_THIRD_PARTY_POD, + ); + expect(fs.existsSync(backupPath)).toBe(false); + expect(fs.readFileSync(pbxprojPath, 'utf8')).toBe(pbxprojBefore); + }); + + it('refuses before any change when Podfile.bak already exists', async () => { + const xcodeproj = mkXcodeproj(appRoot, 'MyApp.xcodeproj', { + cocoapods: true, + }); + const olderBackup = "target 'Old' do\nend\n"; + fs.writeFileSync(podfilePath, STOCK_PODFILE); + fs.writeFileSync(backupPath, olderBackup); + mockAutolinkingSourceDir = appRoot; + + await main(['add', '--deintegrate']); + + expect(process.exitCode).toBe(1); + expect(errors()).toContain('Podfile.bak already exists'); + expectNoPipelineOutput(xcodeproj); + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + expect(fs.readFileSync(backupPath, 'utf8')).toBe(olderBackup); + }); + + it('does not auto-convert on the zero-arg path when Podfile.bak already exists', async () => { + const xcodeproj = mkXcodeproj(appRoot, 'MyApp.xcodeproj', { + cocoapods: true, + }); + const olderBackup = "target 'Old' do\nend\n"; + fs.writeFileSync(podfilePath, STOCK_PODFILE); + fs.writeFileSync(backupPath, olderBackup); + gitInitAndCommit(appRoot); + mockAutolinkingSourceDir = appRoot; + + await main([]); + + expect(process.exitCode).toBe(1); + expect(errors()).toContain('CocoaPods-integrated'); + expectNoPipelineOutput(xcodeproj); + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + expect(fs.readFileSync(backupPath, 'utf8')).toBe(olderBackup); + }); + + it('refuses before any change when Podfile.bak is a dangling symlink', async () => { + const xcodeproj = mkXcodeproj(appRoot, 'MyApp.xcodeproj', { + cocoapods: true, + }); + fs.writeFileSync(podfilePath, STOCK_PODFILE); + fs.symlinkSync('missing-podfile', backupPath); + + await runAdd('--deintegrate'); + + expect(process.exitCode).toBe(1); + expect(errors()).toContain('Podfile.bak already exists'); + expectNoPipelineOutput(xcodeproj); + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + expect(fs.readlinkSync(backupPath)).toBe('missing-podfile'); + }); + + // Shows that expectNoPipelineOutput can fail: this run gets past every + // refusal and writes what the refusal tests rule out. + it('runs the pipeline when nothing refuses the conversion', async () => { + mkXcodeproj(appRoot, 'MyApp.xcodeproj', {cocoapods: true}); + fs.writeFileSync(podfilePath, STOCK_PODFILE); + + await runAdd('--deintegrate'); + + expect(mockPodCalls).toContainEqual(['deintegrate']); + expect(fs.existsSync(path.join(appRoot, 'build'))).toBe(true); + expect(fs.existsSync(path.join(appRoot, '.gitignore'))).toBe(true); + }); + + it('says when --deintegrate applies if the project is still CocoaPods-integrated after it', async () => { + // The faked `pod deintegrate` leaves the pbxproj integrated. + mkXcodeproj(appRoot, 'MyApp.xcodeproj', {cocoapods: true}); + fs.writeFileSync(podfilePath, STOCK_PODFILE); + + await runAdd('--deintegrate'); + + expect(process.exitCode).toBe(1); + expectCocoaPodsAdvice(); + }); + }); + + it('says when --deintegrate applies on an explicit `spm add` of a CocoaPods project', async () => { + const xcodeproj = mkXcodeproj(appRoot, 'MyApp.xcodeproj', { + cocoapods: true, + }); + fs.writeFileSync(podfilePath, PODFILE_WITH_THIRD_PARTY_POD); + + await runAdd(); + + expect(process.exitCode).toBe(1); + expectCocoaPodsAdvice(); + expectNoPipelineOutput(xcodeproj); + }); + + describe('deinit', () => { + it('restores Podfile.bak to Podfile, byte-identical, and says to run `pod install`', async () => { + mkInjectedProject(); + fs.writeFileSync(backupPath, STOCK_PODFILE); + + await main(['deinit']); + + expect(process.exitCode).toBeUndefined(); + expect(fs.existsSync(backupPath)).toBe(false); + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + expect(output(logSpy)).toContain('pod install'); + }); + + it('leaves the Podfile untouched when there is no Podfile.bak', async () => { + mkInjectedProject(); + fs.writeFileSync(podfilePath, STOCK_PODFILE); + + await main(['deinit']); + + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + expect(fs.existsSync(backupPath)).toBe(false); + }); + + it('fails loud and keeps both files when Podfile and Podfile.bak both exist', async () => { + mkInjectedProject(); + const current = "target 'MyApp' do\nend\n"; + fs.writeFileSync(podfilePath, current); + fs.writeFileSync(backupPath, STOCK_PODFILE); + + await main(['deinit']); + + expect(process.exitCode).toBe(1); + expect(errors()).toContain('Podfile.bak'); + expect(errors()).toMatch(/\bPodfile\b(?!\.bak)/); + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(current); + expect(fs.readFileSync(backupPath, 'utf8')).toBe(STOCK_PODFILE); + }); + + it('fails loud and changes nothing when Podfile is a dangling symlink next to Podfile.bak', async () => { + const xcodeproj = mkInjectedProject(); + const markerPath = path.join(xcodeproj, SPM_INJECTED_MARKER); + const markerBefore = fs.readFileSync(markerPath, 'utf8'); + fs.symlinkSync('missing-podfile', podfilePath); + fs.writeFileSync(backupPath, STOCK_PODFILE); + + await main(['deinit']); + + expect(process.exitCode).toBe(1); + expect(errors()).toContain('Both Podfile and Podfile.bak exist'); + expect(fs.readlinkSync(podfilePath)).toBe('missing-podfile'); + expect(fs.readFileSync(backupPath, 'utf8')).toBe(STOCK_PODFILE); + expect(fs.readFileSync(markerPath, 'utf8')).toBe(markerBefore); + }); + + it('restores Podfile.bak even when no SPM injection is found', async () => { + fs.writeFileSync(backupPath, STOCK_PODFILE); + + await main(['deinit']); + + expect(process.exitCode).toBeUndefined(); + expect(fs.existsSync(backupPath)).toBe(false); + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + expect(output(logSpy)).toContain('pod install'); + }); + + it('does not claim there is nothing to do when it restores Podfile.bak', async () => { + fs.writeFileSync(backupPath, STOCK_PODFILE); + + await main(['deinit']); + + expect(output(logSpy)).toContain('No SwiftPM injection found.'); + expect(output(logSpy)).not.toContain('nothing to remove'); + }); + + it('says there is nothing to remove when there is nothing to do', async () => { + await main(['deinit']); + + expect(output(logSpy)).toContain( + 'No SPM injection found — nothing to remove.', + ); + }); + + it('restores Podfile.bak even when removing an empty directory fails', async () => { + mkInjectedProject(); + fs.mkdirSync(path.join(appRoot, 'ReactNativeSPM', 'Scripts'), { + recursive: true, + }); + fs.writeFileSync(backupPath, STOCK_PODFILE); + jest.spyOn(fs, 'rmdirSync').mockImplementation(() => { + throw new Error('EACCES: permission denied'); + }); + + await main(['deinit']); + + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + expect(fs.existsSync(backupPath)).toBe(false); + expect(errors()).toContain('EACCES'); + }); + }); + + describe('deinit undoes exactly the .gitignore lines add recorded', () => { + const HEADER = '# SPM – auto-generated at build time (do not commit)'; + const ENTRIES = [ + 'Package.resolved', + 'build/generated/', + 'build/xcframeworks/', + '.build/', + ]; + let gitignorePath; + + beforeEach(() => { + gitignorePath = path.join(appRoot, '.gitignore'); + }); + + // What `add` does to .gitignore, with its record stored in the marker. + function add(previous = null) { + const record = ensureGitignoreSpmEntries(appRoot, previous); + mkInjectedProject({gitignore: record}); + return record; + } + + // Only deinit's output counts as a note; add's own log line does not. + async function deinit() { + jest.clearAllMocks(); + await main(['deinit']); + } + + function readGitignore() { + return fs.readFileSync(gitignorePath, 'utf8'); + } + + function gitignoreNotes() { + return [output(logSpy), errors()] + .join('\n') + .split('\n') + .filter(line => line.includes('.gitignore')); + } + + it('records the lines it appended, and none the file already had', () => { + fs.writeFileSync(gitignorePath, 'node_modules/\nPackage.resolved\n'); + + expect(add()).toEqual({ + created: false, + lines: [HEADER, 'build/generated/', 'build/xcframeworks/', '.build/'], + }); + }); + + it('records that it created the file', () => { + expect(add()).toEqual({created: true, lines: [HEADER, ...ENTRIES]}); + }); + + it('keeps the record unchanged on a later run that appends nothing', () => { + const first = add(); + + expect(ensureGitignoreSpmEntries(appRoot, first)).toEqual(first); + }); + + it('deletes a .gitignore that add created and nobody changed', async () => { + add(); + + await deinit(); + + expect(fs.existsSync(gitignorePath)).toBe(false); + }); + + it('removes only the lines add appended to an existing .gitignore', async () => { + const userContent = 'node_modules/\n*.log\n'; + fs.writeFileSync(gitignorePath, userContent); + add(); + + await deinit(); + + expect(readGitignore()).toBe(userContent); + }); + + it('keeps the entries the user already had', async () => { + const userContent = 'node_modules/\nPackage.resolved\n'; + fs.writeFileSync(gitignorePath, userContent); + add(); + expect(readGitignore()).not.toBe(userContent); + + await deinit(); + + expect(readGitignore()).toBe(userContent); + }); + + it('leaves a .gitignore that already had every entry untouched', async () => { + const userContent = ['node_modules/', HEADER, ...ENTRIES, ''].join('\n'); + fs.writeFileSync(gitignorePath, userContent); + add(); + + await deinit(); + + expect(readGitignore()).toBe(userContent); + expect(gitignoreNotes()).toEqual([]); + }); + + it('keeps an empty .gitignore the user had', async () => { + fs.writeFileSync(gitignorePath, ''); + add(); + + await deinit(); + + expect(readGitignore()).toBe(''); + }); + + it('keeps lines the user added to a .gitignore add created, and says so in one line', async () => { + add(); + fs.appendFileSync(gitignorePath, '\n# mine\nsecrets/\n'); + + await deinit(); + + expect(readGitignore()).toBe('\n# mine\nsecrets/\n'); + expect(gitignoreNotes()).toHaveLength(1); + }); + + it('keeps a recorded line the user edited, and says so in one line', async () => { + add(); + fs.writeFileSync( + gitignorePath, + readGitignore().replace('build/generated/\n', 'build/generated/**\n'), + ); + + await deinit(); + + expect(readGitignore()).toBe('build/generated/**\n'); + expect(gitignoreNotes()).toHaveLength(1); + expect(process.exitCode).toBeUndefined(); + }); + + it('leaves .gitignore alone when the marker has no record (an older add)', async () => { + ensureGitignoreSpmEntries(appRoot); + mkInjectedProject(); + const before = readGitignore(); + + await deinit(); + + expect(readGitignore()).toBe(before); + expect(gitignoreNotes()).toEqual([]); + }); + + it('leaves .gitignore alone when there is no marker', async () => { + const userContent = ['node_modules/', '', HEADER, ...ENTRIES, ''].join( + '\n', + ); + fs.writeFileSync(gitignorePath, userContent); + + await deinit(); + + expect(readGitignore()).toBe(userContent); + }); + + it('also removes an entry that a later add appended', async () => { + const userContent = 'node_modules/\n'; + const olderLines = [HEADER, ...ENTRIES.slice(0, 3)]; + fs.writeFileSync( + gitignorePath, + userContent + ['', ...olderLines, ''].join('\n'), + ); + const record = add({created: false, lines: olderLines}); + expect(record.lines).toEqual([...olderLines, HEADER, '.build/']); + + await deinit(); + + expect(readGitignore()).toBe(userContent); + }); + + it('restores Podfile.bak even when the .gitignore cleanup fails', async () => { + add(); + fs.writeFileSync(backupPath, STOCK_PODFILE); + const readFileSync = fs.readFileSync; + jest.spyOn(fs, 'readFileSync').mockImplementation((file, ...rest) => { + if (file === gitignorePath) { + throw new Error('EIO: i/o error'); + } + return readFileSync(file, ...rest); + }); + + await deinit(); + + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + expect(fs.existsSync(backupPath)).toBe(false); + expect(gitignoreNotes().join('\n')).toContain('EIO'); + }); + + describe('add hands the injector the record it wrote', () => { + it('records a .gitignore that add created', async () => { + mkXcodeproj(appRoot, 'MyApp.xcodeproj'); + + await runAdd(); + + expect(process.exitCode).toBeUndefined(); + expect(mockInjectorCalls).toHaveLength(1); + expect(mockInjectorCalls[0].gitignore).toEqual({ + created: true, + lines: [HEADER, ...ENTRIES], + }); + }); + + it('extends the record of an earlier add from the marker', async () => { + const olderLines = [HEADER, ...ENTRIES.slice(0, 3)]; + fs.writeFileSync(gitignorePath, ['', ...olderLines, ''].join('\n')); + mkInjectedProject({gitignore: {created: true, lines: olderLines}}); + + await runAdd(); + + expect(mockInjectorCalls[0].gitignore).toEqual({ + created: true, + lines: [...olderLines, HEADER, '.build/'], + }); + }); + }); + }); + + it('tells the user on the zero-arg conversion that `spm deinit` restores CocoaPods', async () => { + mkXcodeproj(appRoot, 'MyApp.xcodeproj', {cocoapods: true}); + fs.writeFileSync(podfilePath, STOCK_PODFILE); + gitInitAndCommit(appRoot); + + await main([]); + + const conversionMessage = output(logSpy) + .split('\n') + .find(line => line.includes('freshly-scaffolded CocoaPods project')); + expect(conversionMessage).toContain('spm deinit'); + expect(conversionMessage).toMatch(/restor/i); + expect(conversionMessage).not.toContain( + 'Revert with `git` or `spm deinit`', + ); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index aa37bb05cec4..3775e8a59cb7 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -73,11 +73,12 @@ const path = require('node:path'); /*:: import type { FlavoredFrameworkManifestEntry, + GitignoreRecord, PluginScriptPhase, XcframeworkSlice, } from './spm-types'; */ -const {log} = makeLogger('generate-spm-xcodeproj'); +const {log, warn} = makeLogger('generate-spm-xcodeproj'); // Sidecar inside a USER-OWNED xcodeproj that SPM packages were injected into in // place. Records the host project's root UUID + every edit so `spm deinit` @@ -2467,7 +2468,7 @@ function readScriptPhasesManifest( */ function readMarker( xcodeprojPath /*: string */, -) /*: ?{targetUuid?: ?string, generatedSources?: {[string]: Array}, scriptPhases?: {[string]: string}, linkedFrameworks?: Array<{id: string, frameworkName: string}>, artifactsVersionOverride?: ?string, configCommand?: ?Array, cxxApiDefines?: unknown, buildSettingChanges?: Array, xcconfig?: XcconfigRecord, createdArrayFields?: Array, scheme?: {file?: ?string, created?: ?boolean}, ...} */ { +) /*: ?{targetUuid?: ?string, generatedSources?: {[string]: Array}, scriptPhases?: {[string]: string}, linkedFrameworks?: Array<{id: string, frameworkName: string}>, artifactsVersionOverride?: ?string, configCommand?: ?Array, cxxApiDefines?: unknown, buildSettingChanges?: Array, xcconfig?: XcconfigRecord, createdArrayFields?: Array, scheme?: {file?: ?string, created?: ?boolean}, gitignore?: GitignoreRecord, ...} */ { const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); try { // $FlowFixMe[incompatible-return] JSON.parse returns any @@ -2803,7 +2804,7 @@ function linkedReactNativeRoot( * when the project can't be safely edited (caller surfaces it; fail-loud). */ function injectSpmIntoExistingXcodeproj( - opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string, artifactsVersionOverride?: ?string, configCommand?: ?Array, cxxApiDefines?: ReadonlyArray} */, + opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string, artifactsVersionOverride?: ?string, configCommand?: ?Array, cxxApiDefines?: ReadonlyArray, gitignore?: ?GitignoreRecord} */, ) /*: {status: 'injected', target: string} | {status: 'refused', reason: string} */ { const {appRoot, reactNativeRoot, xcodeprojPath} = opts; const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); @@ -3004,6 +3005,10 @@ function injectSpmIntoExistingXcodeproj( schemeResult.status === 'created' || prevMarker?.scheme?.created === true, }, + // Passed by `add` with the previous record already folded in; + // `update` passes none and keeps it. Without one (an older marker), + // deinit leaves .gitignore untouched. + gitignore: opts.gitignore ?? prevMarker?.gitignore, }, null, 2, @@ -3281,6 +3286,7 @@ function removeSpmInjection( EMBED_FRAMEWORKS_SCRIPT_PATH, ]) { fs.rmSync(path.join(appRoot, relPath), {force: true}); + removeEmptyDirsUpTo(path.dirname(path.join(appRoot, relPath)), appRoot); } // 3. Scheme: delete it if injection created it AND still owns its contents, @@ -3316,6 +3322,26 @@ function removeSpmInjection( return {status: 'removed', target: marker.target}; } +// Never follows a symlink, which could lead outside `stopAt`. A failure only +// warns: deinit has more to undo after this. +function removeEmptyDirsUpTo(dir /*: string */, stopAt /*: string */) { + let current = dir; + try { + const root = fs.realpathSync(stopAt); + while ( + fs.existsSync(current) && + fs.lstatSync(current).isDirectory() && + fs.realpathSync(current).startsWith(root + path.sep) && + fs.readdirSync(current).length === 0 + ) { + fs.rmdirSync(current); + current = path.dirname(current); + } + } catch (e) { + warn(`Could not remove the empty directory ${current}: ${e.message}`); + } +} + module.exports = { generateXcscheme, buildSyncAutolinkingScript, diff --git a/packages/react-native/scripts/spm/spm-types.js b/packages/react-native/scripts/spm/spm-types.js index 2fc514907eb4..4fe80907df7d 100644 --- a/packages/react-native/scripts/spm/spm-types.js +++ b/packages/react-native/scripts/spm/spm-types.js @@ -26,7 +26,8 @@ export type SetupArgs = { // `add` target selection: which app target (when several) and which project. productName: string | null, xcodeprojPath: string | null, - // `add`: run `pod deintegrate` + strip RN from the Podfile before injecting. + // `add`: run `pod deintegrate` + move the Podfile to Podfile.bak before + // injecting. // Also implied on the zero-arg path when resolveAction's safe-gate detects a // freshly-scaffolded CocoaPods project (see setup-apple-spm.js). deintegrate: boolean, @@ -35,6 +36,14 @@ export type SetupArgs = { yes: boolean, }; +// What `spm add` wrote to the app's .gitignore, kept in the injection marker +// so `spm deinit` removes exactly that: whether it created the file, and every +// line it appended (header included), in order, across all `add` runs. +export type GitignoreRecord = { + created: boolean, + lines: Array, +}; + export type DownloadArgs = { version: string | null, flavor: string,