rn-iso
v1.5.0
Published
Isolated React Native dev environments per project/worktree
Downloads
239
Maintainers
Readme
rn-iso
The React Native / Expo CLI for AI agents. One isolated dev environment per project or worktree: rn-iso start runs the dev server on a reserved, collision-free Metro port under a detached supervisor; rn-iso ios / rn-iso android boot a dedicated, owned simulator/emulator, install a build from a shared fingerprint cache when nothing native changed, and launch the app wired to that port; rn-iso logs --errors answers "did that work" from a captured timeline instead of a scraped terminal. Multiple worktrees or coding agents can each get their own environment and build the same app in parallel without port or device collisions.
It never prompts, prints on the order of ten lines, takes --json everywhere, and reports a failing build as the extracted compiler diagnostic plus a log path rather than four thousand lines of transcript.
Experimental. APIs, flags, and on-disk state may change. File issues if anything breaks.
Why this exists
Coding agents are moving to the cloud, and React Native is one of the places that goes badly. A cloud agent needs macOS, a matching Xcode, a booted simulator, a signing identity, and every MCP server re-authenticated -- on runners that cost several times a Linux box and lag Xcode releases by months. Physical devices are simply out of reach.
Locally, none of that is a problem. The environment is already set up, the Mac is already paid for, simulators work, you are already logged into everything, and the agent harness already provides the isolation that a cloud sandbox is there to provide.
What breaks locally is that agents share one machine. Two of them reach for
port 8081, or the same booted simulator, and both end up talking to the wrong
bundler -- silently, because nothing tells you a build attached to somebody
else's Metro. When an agent is killed mid-run it leaves a simulator booted, a
Metro squatting on a port, and an xcodebuild test runner pinning a device
nothing can now delete.
That is the first job of this tool: arbitrate the contended resources, and
reclaim them when the agent that owned them dies badly. The second is the dev
server, which every agent otherwise backgrounds by hand and then scrapes a log
file for: start runs it on the reserved port and captures its output as
structured records, so logs --errors replaces the scraping. What stays out is
the build -- which command, which flags, when to install -- because that is
judgment a coding agent already has from reading the repo, and rn-iso
deliberately does not take it back.
Where local honestly loses
- CPU and memory are finite. Two or three live environments on a 16 GB machine, not ten. Cloud wins this outright.
- Paths are not stable. CI checks out to the same path every run, so path-keyed caches (ccache, Xcode's compilation cache, a CocoaPods sandbox) just work. Locally every worktree sits somewhere different, and those caches quietly miss everything -- measured on one project as 0 ccache hits out of 1094 across two workspaces. It is fixable, but it is a tax cloud does not pay.
- Disk grows without bound. Simulators and the shared caches that make any
of this fast all accumulate.
gcexists for that reason.
State lives in ~/.rn-iso/config.json, keyed by absolute project path. Worktrees count as separate projects. There is no shared mutex -- each project gets its own port and its own device.
Quick start
Run via npx from any RN/Expo project directory -- no install needed:
npx rn-iso start # dev server on a reserved port, under a supervisor
npx rn-iso ios # owned sim booted, app installed and launched on it
npx rn-iso logs --errors # no output + exit 0 = nothing is broken
npx rn-iso stop # supervisor down, sim shut down, port freed$ npx rn-iso start
OK: dev server on port 8082, supervisor pid 41233 (expo-child) (6s)
$ npx rn-iso ios
device rn-iso-myproject (BF2A..) booted (9s)
fingerprint a3f9b1.. hit (2s)
install from cache (3s)
launch com.example.app (1s)
OK: com.example.app launched on BF2A..The order is not optional: ios / android never start the bundler, so with nothing holding the reserved port they refuse in about a second with RN_ISO_NO_METRO instead of spending four minutes building an app that cannot load a bundle.
Each command takes --json and then prints exactly one line of JSON on stdout, with every other line on stderr:
{
"platform": "ios",
"udid": "BF2A-...",
"deviceName": "rn-iso-myproject",
"fingerprint": "a3f9b1...",
"cacheKey": "...",
"cacheHit": "local",
"cacheSkipped": false,
"appPath": "/...",
"bundleId": "com.example.app",
"launched": true,
"metroPort": 8082,
"logs": { "dir": "/path/.rn-iso/logs" },
"durationMs": 9412
}cacheHit is a LEVEL, not a boolean: "local" (this machine's shared cache), "remote" (the project's own Expo buildCacheProvider, whose artifact is copied into the local cache on the way past) or false (it was compiled). cacheSkipped is true only when --no-build-cache was passed, which is "nothing was looked up" rather than "nothing was found".
In a different worktree of the same app, the same two commands get a different owned sim and Metro port, so both run side by side.
To set a repo up for that in the first place, run rn-iso doctor and work
through what it reports:
npx rn-iso doctorThere is no rn-iso init. Every edit that setup needs lands in a file the
project already owns -- a metro.config.js with its own transformer, a
Podfile with existing post_install logic, an app config that may be
TypeScript -- and a generator that rewrites those eventually corrupts one. So
doctor reports, read-only and always exit 0, and the bundled rn-iso-init
skill is the playbook for applying each finding by hand. The one edit that
needed no judgement, .rn-iso/ in .gitignore, is self-ensured: start,
ios and android each add it if it is missing and say so once on stderr --
commit that line with the change you were already making, and it stops being
rewritten in every fresh worktree.
For AI coding agents, install the bundled skills so the agent knows how to drive the CLI (the lifecycle, the facts contract, and the destructive-command rules). They install with the skills CLI, straight from GitHub:
npx skills add appandflow/rn-isoGetting started is the whole human-side setup: install the skill, have the agent run /rn-iso-init, then describe what you want built.
Owned devices
Every simulator or emulator rn-iso uses is one rn-iso created, named rn-iso-<label>, and recorded with owned: true. rn-iso never boots, allocates, or destroys a device it did not create -- it cannot stomp a foreign tool's simulator, because it never touches devices it didn't make. Teardown of the owning project (worktree remove, or gc on an orphan) destroys the device, not just a claim on it.
That rule has no exception. rn-iso has no physical-device support: there is no code path that boots, installs onto, or even probes hardware. A legacy record naming a serial is reported once and replaced by an owned emulator; the serial itself is never touched.
This is a change from earlier versions, where rn-iso picked an existing, unclaimed simulator from the pool instead of creating one. That model existed to avoid accumulating junk simulators -- but the accumulation was really a symptom of creation without a reaper. The reaper now exists, so creating a device and guaranteeing its eventual destruction is no longer the same hazard.
A pre-pivot assignment without owned: true ("legacy") is reused only while it is actually running -- rn-iso will not boot, shut down, or delete it. It converges to an owned device naturally, once it is shut down and re-created.
Destruction lives in exactly two commands
worktree remove destroys the workspace you name; gc --delete sweeps the machine. Nothing else deletes anything.
The one workspace worktree remove never destroys is the main checkout: git cannot remove the main working tree, and deleting the source tree is not what anyone meant. There -- and only there -- it reclaims the environment instead: the owned devices are deleted, the Metro port freed, the registry entries (including nested monorepo app dirs) dropped and .rn-iso/ removed, while the tree itself stays exactly as it was. Because nothing in the tree is deleted, the dirty-tree and unpushed-work guards do not apply on that path. A registered project directory that is not a git repo at all gets the same environment reclaim.
In particular stop does not, by design: it shuts the owned device down and leaves it assigned, so returning to a branch costs a boot rather than a create, a provision and a reinstall. There is no --delete on it, because an agent reaching for stop to reclaim memory must not have one within reach of a typo. Destruction lives in worktree remove and gc, never here.
A delete is not occupancy-guarded. An owned sim goes away even if another tool is still attached to it. It is a device rn-iso created, for a project that is going away, and the process holding it is almost always the caller's own UI-test runner, which has nothing to return to. Skipping occupied sims there leaked booted sims and live xcodebuild test-without-building runners out of worktree remove, and "left for a later gc" only asked the same question again forever.
stop is occupancy-guarded, because the device it spares survives the call and is still there to come back to: an iOS sim actively driven by a foreign UI-test runner is left running and reported instead of shut down. (Android has no occupancy probe, so an owned, identity-verified AVD is always eligible.)
If a delete fails, the failure is reported, the config record is kept so the device stays tracked, and the command exits 1. Dropping the record on a failed teardown is exactly what turns it into a simulator nothing references.
Commands
All commands below take the same npx rn-iso prefix.
| Command | Purpose |
| -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| start [--json] [--wait <seconds>] | Start this workspace's dev server on the reserved port under a detached supervisor, and block until it answers and verifies as this project's (default 60s). Idempotent: a healthy dev server on the port is a no-op. Bare RN is hosted in-process with rn-iso's NDJSON reporter; Expo runs the project's own expo start --port <n> as a child. Structured logs land in <root>/.rn-iso/logs, and .rn-iso/ is added to the project's .gitignore if it is not already there. A failure under --json still puts one line on stdout: the {code, message, remedy} contract (RN_ISO_METRO_TIMEOUT, RN_ISO_SUPERVISOR_EXITED, ...). |
| logs [--source <s...>] [--level <l>] [--since <d>] [--grep <re>] [--tail <n>] [--errors] [--follow] [--json] | Query the merged NDJSON timeline in <root>/.rn-iso/logs. Prints and exits; nothing matching is a successful, empty result (exit 0). --errors is the agent-loop query: errors and fatals since the last marker. --follow streams. |
| ios [--json] [--no-metro-check] [--no-build-cache] | Boot this workspace's owned simulator, verify the reserved port holds this project's dev server, fingerprint the native inputs, install the cached .app if that fingerprint has one (otherwise prebuild / pod install / xcodebuild and store the result), install, launch wired to the reserved port, and attach a device-log collector. Refuses with RN_ISO_NO_METRO in about a second when nothing holds the port; --no-metro-check overrides. On an Expo project a local miss also asks the provider the project already configured (expo.buildCacheProvider), time-bounded, and a hit is stored locally on the way past. --no-build-cache looks nothing up and builds fresh -- it still stores (replacing the entry) and still uploads. Debug / simulator only. |
| android [--json] [--no-metro-check] [--no-build-cache] [--variant <name>] | The same over gradlew assembleDebug and adb, on this workspace's owned emulator, with adb reverse tcp:8081 tcp:<port> doing the port wiring. On a project with product flavors, --variant productionDebug (or the android.variant setting, which the flag overrides) runs assembleProductionDebug, finds the APK in apk/production/debug/ and keys the build cache on the variant; the launched applicationId is always read from the built APK's manifest. Debug variants only. |
| stop [--force] [--json] | The inverse of start: halt this workspace's supervisor, reap its device-log collectors, shut the owned device down (never deleted, so it stays assigned), and free the reserved port. Non-destructive and takes no target -- it acts on the current workspace. With no supervisor recorded it falls back to killing an identity-verified Metro on the reserved port; --force is only for an unproven listener there. Already-stopped is a success at every step. |
| status [--json] | Show every registered project (machine-wide by default; there is no --all): device assignments (owned/legacy), Metro state, supervisor pid / mode / health, last build (fingerprint, cache hit, duration), log directory and error count since the last marker, plus machine capacity and free disk -- on the boot volume, and on the current project's volume too when that is a different one. |
| gc [--delete] [--older-than <days>] [--all] | Report what rn-iso has left behind: entries for projects whose directory no longer exists, orphaned rn-iso-* devices, records naming a device that is no longer on the machine, and every shared build cache with its size. Reports and writes nothing by default; --delete reclaims the dead entries (freeing their Metro ports), reaps the orphaned devices, and clears the stale device records (the record only -- there is no device left to touch, so it issues no simctl/avdmanager command). --older-than <days> additionally reaps owned devices whose project has gone untouched that long, and trims cache entries nothing has used in that time. --all (with --delete) empties the caches whole -- see below. |
| doctor [--json] | Report the configuration that makes a second workspace slower than it needs to be: a missing dev client, a per-project Metro cache, a compilation cache left at its default path, Gradle's build cache off, a ccache conflict, a configured build-cache provider on the key this SDK ignores, and -- last, because it computes a real fingerprint twice via a temporary worktree of HEAD (removed again) -- a checkout that does not fingerprint like a fresh worktree. Read-only, and always exits 0. |
| worktree create <name> [--base <ref>] [--label <name>] [--carry-ignored] | Create an isolated git worktree: carries over gitignored files, prints the worktree path (and, on stderr, what it branched from -- ref and short sha). --base takes fresh (origin/HEAD, the default), head, or any ref git rev-parse resolves; an unresolvable one is refused before anything is created. Does not install dependencies unless --carry-ignored clones the source's working state: its gitignored paths (node_modules, Pods, build output) plus its uncommitted tracked changes, applied when they fit the base and reported when they do not. |
| worktree remove [<path>] [--force] | Remove a worktree, reclaiming its build artifacts, Metro port, and owned devices (deleted, not just freed). Defaults to the current workspace. Refuses if it has uncommitted or unpushed work unless --force, naming the right restore command per class (git checkout -- for modified tracked files, git clean -fd for untracked ones). The workspace's own .rn-iso/ never counts as dirt -- it dies with the worktree by design. On the main checkout it reclaims the environment only (devices deleted, port freed, .rn-iso/ removed) and never touches the tree. |
| guide [topic] | Print reference docs for the installed version (topics: facts, metro, logs, errors, lifecycle, cleanup, settings). Generated by the binary, so it cannot drift. |
How it works
- Config at
~/.rn-iso/config.json, keyed by absolute project path. Symlinked worktrees collapse viarealpath. Every write goes through a lockfile and lands by atomic rename, so several agents provisioning at once cannot lose each other's device records. A config that will not parse is reported by name and never reset automatically -- it holds the records of every device rn-iso owns, and resetting it would orphan all of them. - Port allocation:
startscans upward from 8082 for a port that is both unclaimed in the registry and actually free on the machine, reclaiming ports from dead projects on the way. Claiming is race-safe: the write only lands if the config still shows the port unclaimed, so two parallel runs that probe the same free port cannot both take it. A project whose directory only looks gone because its volume is unmounted keeps its port. - Owned device creation: on iOS,
ioscreates the newest iPhone device type -- highest generation number, base model rather than Pro/Pro Max -- on the newest installed runtime by default (or reuses the project's already-recorded owned sim, booting it if shut down). On Android, it creates an AVD viaavdmanager create avdagainst the newest installed arm64 system image (rn-iso never installs system images itself -- it errors with install instructions if none is found). Override the defaults withios.deviceType/ios.runtime/android.systemImagein a settings file -- see "Settings" below. - Build output is workspace-local.
-derivedDataPathpoints at<worktree>/.rn-iso/derived-dataand gradle builds under<worktree>/.rn-iso/gradle-build, soworktree removereclaims them definitionally and there is no global DerivedData directory to reverse-map to a workspace. - The port is never baked into a build. The fingerprint cache shares binaries across workspaces, so a port compiled in would let a binary built for 8082 be served to a workspace holding 8083. iOS gets
RCT_jsLocationwritten into the app's simulator defaults (or anexpo-development-clientdeep link); Android getsadb reverse tcp:8081 tcp:<port>.RCT_METRO_PORTis deliberately not passed to builds. - Starting the bundler yourself still works. Both Expo and the RN CLI probe the port and skip spawning a second bundler when one already answers
/status, andios's Metro gate accepts a server you started as long as it runs from inside the project -- but nothing is captured that way, sorn-iso logsstays empty. Teardown (stop,worktree remove,gc) finds Metro by port vialsofand only kills it after confirming it answers/statusand runs from inside the project: a port is not identity, so an unidentified listener is reported instead of killed.
If you need a single shared sim with a mutex instead of one owned device per project, see react-native-worktree.
The dev server and logs
rn-iso start runs the dev server for you, on the port it reserves for this workspace, under a detached per-workspace supervisor. It blocks until the server both answers and verifies as this project's (the same identity check teardown uses, never a bare port probe), then exits leaving it running:
npx rn-iso start --json
# {"port":8082,"supervisorPid":41233,"mode":"bare-inproc","logsDir":"/path/.rn-iso/logs","alreadyRunning":false}It has two flags and will not grow more: --json and --wait <seconds> (default 60). Anything a project needs beyond that belongs in its own bundler command. Running start twice leaves one supervisor -- a healthy dev server on the port is a no-op, including one you started yourself, which is reported with supervisorPid: null and left alone rather than fought over.
There is no machine-wide daemon: one supervisor process per workspace, recorded in <root>/.rn-iso/state.json and in the registry before it starts serving, and gone (with its records) on any exit path. Two modes:
bare-inproc-- bare React Native: Metro is hosted inside the supervisor, loaded from the project's ownnode_modules, with@rn-iso/metro's NDJSON reporter attached. Bundler events, in-appconsole.logand redboxes all arrive structured. Hosting is the only way to get them: both CLIs overwriteconfig.reporterafter loadingmetro.config.js, so a reporter wired in there is discarded.expo-child-- Expo: the project's ownexpo start --port <n>runs as a child and its stdout is parsed into the same records. Expo's dev server is protocol-bearing (manifest, dev-client and expo-router middleware), so reimplementing it would be forking Expo; the cost is that levels are inferred from each line, which those records mark withraw: true.
Everything lands as one JSON object per line under <root>/.rn-iso/logs, and rn-iso logs queries the files merged into one timeline:
npx rn-iso logs --errors # errors since the last marker; empty + exit 0 = healthy
npx rn-iso logs --source client --since 5m --grep 'Profile'
npx rn-iso logs --follow --level warn
npx rn-iso logs --errors --json # raw records, so stdout is valid NDJSONNothing matching is exit 0. logs --errors returning nothing is the pass condition of a build loop, so an empty result must never read as a failure; the only exit-1 paths are a malformed query and no project. --errors means level error or fatal strictly after the most recent record carrying marker: true, and the marker is searched across every source, so a marker in one file closes the window for all of them. Markers are written when a bundle build finishes -- which is what stops an error you already fixed from being reported forever. rn-iso status reports the same count per workspace.
The record is { ts, src, level, msg } plus optional event, stack, marker and raw. src is one of metro, client, device, build: the supervisor writes metro (both modes) and client (bare only -- in expo-child mode Expo's client output arrives on the bundler stream), and ios / android write build (the transcript at level debug, the extracted diagnostics at level error) and device (via the simctl log stream / logcat collector they attach after launch). .rn-iso/logs/supervisor.log is deliberately not part of the timeline: it is the supervisor's raw stdio, and it is what start quotes when a supervisor dies before it can write a structured record.
rn-iso stop is the inverse: it halts the supervisor (identity-verified: a pid is only signalled when it is alive, recorded for this workspace, and holding the port this project reserved), SIGTERMs the device-log collectors recorded in the same state.json, shuts the owned device down without deleting it, and frees the port. It never escalates to SIGKILL -- a supervisor mid-write on the log files is exactly what SIGTERM handling exists to finish -- so a supervisor that will not exit is reported with its pid instead.
Device settings
The device model, runtime and system image can be pinned per project so rn-iso's defaults are not what you get. There is no rn-iso config command -- rn-iso's commands take no device flags, so settings are files. See "Settings" below for the layers; the one that travels with the repo is .rn-iso.json at its root:
{
"ios": { "deviceType": "iPhone 17 Pro", "runtime": "26.2" },
"android": { "systemImage": "system-images;android-36;google_apis;arm64-v8a" }
}Resolution order: the project layer, then the repo layer, then that committed file, then rn-iso's own default (newest iPhone, base model, on the newest installed runtime; newest installed arm64 system image). A pinned model is honoured on reuse as well as on creation: an existing owned sim of a different model is refused rather than silently booted.
Shared build caches
Everything gc reclaims is dead: a project entry whose directory no longer
exists belongs to nobody, and a rn-iso-* simulator nothing references is
never coming back. Shared build caches are the opposite -- alive by design,
shared by every project on the machine, and pruned by nothing:
- Metro's
FileStorehas no eviction logic whatsoever. - Xcode's compilation cache has no size cap.
- Metro file maps accumulate one file per project root ever served.
So every gc run reports them -- in their own bucket, tagged registered or
detected, and never counted in the reclaim total -- and a plain gc --delete
never touches them:
npx rn-iso gc # report everything, caches included
npx rn-iso gc --delete --older-than 30 # trim entries unused for 30 days
npx rn-iso gc --delete --all # empty them completelyPrefer trimming. Most of these caches are a flat collection of independent
entries -- one file per key for Metro's FileStore, one directory per
fingerprint for a build cache -- so the ones nothing has touched in weeks
can go while the rest keep working. "Unused" means neither read nor written: a
cache hit reads an entry without rewriting it, so pruning on modification time
alone would evict exactly the entries that are earning their keep.
Xcode's compilation cache is the exception. It is an LLVM CAS whose v4.actions
index references its v9.*.leaf data files, so removing leaves individually
would leave the index pointing at data that is gone. --older-than reports it
as left alone; it can only be emptied whole, which is what --all does.
Emptying is a performance decision, not cleanup: the next build in every project pays to refill what you removed. The summary says so.
Registering a cache rn-iso cannot detect
A Metro FileStore root, a build-cache provider's artifact directory, a
relocated COMPILATION_CACHE_CAS_PATH -- all come from a project's own config,
so rn-iso cannot guess them. The cache names itself instead, once, from code:
// A setup script, a build-cache provider -- anywhere that creates the cache.
// `rn-iso/cache-manifest` is ESM, so a CJS caller needs `await import(...)`.
import { register } from 'rn-iso/cache-manifest';
register({
dir: '~/.myapp-metro-cache',
name: 'Metro transforms',
entriesDepth: 2,
});
register({ dir: '~/.myapp-cas', prune: 'atomic' }); // index-backed: emptied whole or not at allentriesDepth says how far below the directory one entry sits, and it is
what keeps trimming safe. The default, 1, is a flat store: every child of the
root is an entry. A root with a layer of grouping above the entries registers
2 -- Metro's FileStore shards its keys across 256 directories, and a build
cache is keyed <platform>/<key> -- so gc --delete --older-than 30
trims one transform or one build instead of a 256th of every transform on the
machine, or an entire platform's builds.
Registration is idempotent and keyed on the directory, so a cache can call it on
every build; @rn-iso/metro and @rn-iso/expo-build-cache both do (by writing
the manifest directly, so they need no rn-iso installed at all).
The caches setting is the no-code alternative and is still read: a list of
paths under caches in a committed .rn-iso.json is reported alongside the
registered ones. Every path in it is treated as a flat store, so register from
code for anything that needs a depth or atomic.
{ "caches": ["~/.myapp-metro-cache", "~/.myapp-build-cache"] }The cache packages
Two optional packages ship alongside the CLI. Both register themselves with
rn-iso the first time they run, so gc reports and trims them, and
both work fine without rn-iso installed -- it is an optional peer.
@rn-iso/metro-- one Metro transform cache shared by every worktree, instead of Metro's per-project default that makes each new workspace re-transform the whole module graph. It also carries the NDJSON reporter rn-iso uses to capture a dev server's logs, which is not a cache and is not wired up byinit.@rn-iso/expo-build-cache-- a local Expo build cache provider. When no native input changed, the Expo CLI installs a cached.app/.apkinstead of compiling. Wire it toexpo.buildCacheProvideron SDK 54+, orexpo.experiments.buildCacheProvideron SDK 53, which reads only that key and ignores the top-level one in silence.
Each package's README has the wiring. Neither is needed for rn-iso ios /
rn-iso android, which address the build cache directly: the Expo provider is
for builds run outside rn-iso (expo run:ios by hand, or EAS), so that the two
share artifacts instead of filling two caches with the same builds. Bare React
Native has no provider hook at all and needs none.
What every entry point does need is @expo/fingerprint, resolved from the
project, to compute the key. It works on a project with no Expo in it at all.
Without it rn-iso ios refuses with RN_ISO_NO_FINGERPRINT rather than
compiling from scratch forever.
Entries are keyed <fingerprintHash>-<variant>-<target>, identically by every
entry point. The fingerprint covers what the project is, never how it was
built, so the variant (the Xcode configuration on iOS, the gradle variant on
Android; debug when unset) and the target class (sim unless the device
selector says otherwise) are part of the key. Without them a Release build would
answer a Debug lookup and a device build would answer a simulator one -- both
silently, both producing a binary that cannot run. rn-iso builds Debug for a
simulator and nothing else, so those fields are constant here; they exist
because the Expo provider and any future release path share the same keyspace.
Project labels (--label)
Every project has a "shortcut": its label if one was set (e.g. via worktree create --label), else inherited from the enclosing worktree's label, else the directory basename. It is what names the owned device -- rn-iso-<label> -- and what status reports a workspace as.
npx rn-iso worktree create feature-x --label agent-1 # its sim will be rn-iso-agent-1Two projects sharing the same basename with no distinguishing label collide, which is why worktree create registers a label for the worktree root: every worktree of a monorepo otherwise shares the same app-dir basename.
Worktrees
npx rn-iso worktree create feature-x # creates ../<repo>-worktrees/feature-x
npx rn-iso worktree remove # removes it, deleting its owned device(s) and freeing its Metro portworktree create <name> does three things in one step: creates the git worktree itself (branched worktree-<name> off origin/HEAD by default -- pass --base head to branch off the current HEAD instead), carries over gitignored files (see "Carry-over" below), and registers a label for the worktree root so rn-iso shortcuts don't collide across a monorepo's worktrees (every worktree of a monorepo shares the same app-dir basename). Prefer it over a raw git worktree add for that reason. It prints only the resulting worktree path to stdout; everything else goes to stderr (see "Wiring into Claude Code" below).
It deliberately does not install dependencies. Which commands a repo actually needs -- a plain install, a workspace filter, a codegen step after it -- is project-specific judgment. Install them yourself (or from your agent) before building, or use --carry-ignored to clone the source worktree's node_modules.
worktree remove [<path>] defaults to the current workspace. It reclaims the worktree's build artifacts, Metro port, and every owned device registered under it (deleting them, not just clearing the claim -- the environment dies whole) before removing the git worktree itself. It refuses if the worktree has uncommitted changes, untracked files, or commits that exist on no remote -- pass --force to override, but note --force only discards uncommitted/untracked state; committed work stays safe on the branch either way. On the main checkout it reclaims the environment only -- devices deleted, port freed, registry entries dropped, .rn-iso/ removed -- and leaves the tree itself untouched; the dirty-tree guard does not apply there, since nothing in the tree is deleted.
There is no worktree list: rn-iso status shows the same worktrees with their devices, ports and supervisors, including ones that have no environment yet.
Carry-over
Gitignored files (like .env, local certs, or IDE state) don't exist in a fresh worktree by default. worktree create copies any gitignored file matching a pattern from either:
.worktreeincludeat the repo root -- one gitignore-style pattern per line (#comments allowed), e.g.:.env .env.* **/*.local.json- or the
worktree.includesetting (see "Settings" below), if no.worktreeincludefile exists.
Only files that are both gitignored and pattern-matched are copied -- tracked files are never duplicated into the worktree.
--carry-ignored
That carry-over is file-by-file, which suits a handful of small config files but not the multi-gigabyte trees a worktree needs in order to build without reinstalling. worktree create --carry-ignored instead clones every gitignored path -- node_modules, ios/Pods, ios/build (React Native codegen output, without which xcodebuild fails on a missing States.cpp until pod install regenerates it) -- minus:
.rn-iso/, at any depth, always. It holds the workspace's own derived data, logs and supervisor pidfile: build output keyed to a path the new worktree does not have, and a pidfile for a process that is not running. That exclusion is in code, and no pattern file can turn it off.- anything matching
.worktreeexcludeat the repo root, same gitignore-style syntax as.worktreeinclude, e.g.:bench/results/logs - or the
worktree.excludesetting, if no.worktreeexcludefile exists.
It is a skip list rather than a copy list on purpose: forgetting to name something you needed shows up months later as a confusing build error, while forgetting to skip something only costs a needless copy.
Each path is cloned with cp -Rc, so on APFS the copy is copy-on-write -- a 3.6 GB tree costs roughly 12s and tens of MB of real disk. Off by default because that only holds on APFS, within one volume: elsewhere the clone is refused and the fallback is a real copy of every byte, which worktree create warns about.
Cloned dependencies match the source worktree, not necessarily the new branch's manifests -- the same contract as restoring a CI cache. Reinstall if the branch changes them.
--carry-ignored carries the source's working state, not just its gitignored trees: after the clone it also carries the source tree's uncommitted tracked changes (git diff HEAD --binary, checked with git apply --check and then applied), because the cloned artifacts were installed and fingerprinted against that working tree, not against a clean HEAD. When the patch applies, the worktree says so on stderr and leaves the changes uncommitted:
Carried 2 uncommitted change(s) from the source (app.json, ios/Podfile.lock) -- uncommitted here too; commit deliberately.When the worktree's --base diverges from the source HEAD and the patch does not apply, nothing is changed and a warning names the files instead: the carried artifacts were installed for the source's uncommitted state, so fingerprints and cache keys in the worktree will differ from the source's until the two are reconciled. Untracked (non-ignored) files are not carried, same as always -- and a plain worktree create without the flag stays pure HEAD: no clone, no diff carry, no warning.
Why worktrees live next to the repo, not inside it
worktree create places new worktrees in a sibling directory (../<repo>-worktrees/<name>), never under the repo root. A worktree nested inside the repo puts a second copy of every package.json inside Metro's watch root, which causes jest-haste-map naming collisions (two files claiming the same module name). Its multi-gigabyte node_modules also gets walked by Metro, TypeScript, and ESLint on every run. Gitignoring the nested worktree directory does not fix either problem: those tools walk the filesystem directly, not git, so a .gitignore entry is invisible to them.
Wiring into Claude Code (WorktreeCreate hook)
Claude Code's WorktreeCreate hook fires when a session for a new worktree starts, and uses the hook command's stdout as the directory for that session. rn-iso worktree create is built for exactly this contract -- it prints only the resulting path to stdout, and everything else goes to stderr. Wire it in .claude/settings.json:
{
"hooks": {
"WorktreeCreate": [{ "hooks": [{ "type": "command", "command": "rn-iso worktree create \"$(jq -r .name)\"" }] }]
}
}Keeping the agent skills in sync
The skills other AI agents read ship inside the npm package. Because they are installed by copy, upgrading rn-iso does not refresh them -- a 0.14.0 CLI happily runs against a skill from 0.6.x, and the only symptom is an agent following instructions that no longer match the binary.
After upgrading, refresh them the same way they were installed:
npx skills add appandflow/rn-isoThat installs both bundled skills: rn-iso (how to drive the CLI) and
rn-iso-init (how to make a repo fast for parallel agents).
The rn-iso skill is deliberately thin: it carries the rules that don't change
(the ownership model, the destructive-command rules, the parallel-agent rules)
and defers everything version-specific to npx rn-iso guide <topic>, which is
generated by the installed binary and so cannot drift.
Settings
worktree create, start, ios and android resolve settings from three layers, merged with the first match winning (nested objects merge key by key; arrays -- like worktree.include -- are replaced wholesale, never concatenated):
- Project settings -- per absolute project path, stored in
~/.rn-iso/config.json. Highest precedence. - Repo settings -- shared by every worktree of the same repository (keyed by the repo's git common dir), also stored in
~/.rn-iso/config.json. Local to this machine. - Committed settings --
.rn-iso.jsonat the repo root, checked into git and shared with everyone who clones the repo. Lowest precedence, but the only layer that travels with the repo -- and, with theconfigcommand gone, normally the one you want.
The keys rn-iso reads are ios.deviceType, ios.runtime, android.systemImage, worktreeDir, caches, and, under worktree: baseRef ("fresh" or "head"), include (carry-over patterns, same role as .worktreeinclude) and exclude (the --carry-ignored skip list, same role as .worktreeexclude). Anything else is ignored, and rn-iso warns about it by name on every run that resolves settings -- a worktree.install pipeline, for instance, is not a key rn-iso reads. Example .rn-iso.json:
{
"ios": { "deviceType": "iPhone 17 Pro" },
"worktree": {
"baseRef": "fresh",
"include": [".env", ".env.*"]
}
}Never put secrets in .rn-iso.json. It's committed to git and readable by anyone with repo access. Secrets belong in gitignored files (.env and friends) that worktree create's carry-over feature copies into each new worktree -- that mechanism exists specifically so gitignored, secret-bearing files reach a fresh worktree without ever being committed to .rn-iso.json or anywhere else in git history.
Requirements
- macOS (iOS); macOS or Linux (Android)
- Node 20+
- Xcode (iOS), Android SDK + at least one installed arm64 system image (Android)
expoorreact-nativein the project'spackage.json
License
MIT
