stim 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +23 -155
  2. package/dist/{android-BRgBYzU0.mjs → android-BPGizezh.mjs} +112 -135
  3. package/dist/{android-DeYdjjBP.mjs → android-DEgo4oCo.mjs} +2 -2
  4. package/dist/{android-DWssqHQ8.mjs → android-_y4Ohi5z.mjs} +54 -15
  5. package/dist/android-cas-compiler.mjs +2 -2
  6. package/dist/{android-cas-SWIjzeuL.mjs → android-cas-eiExNLf7.mjs} +21 -14
  7. package/dist/app-install-Uf3a83Tv.mjs +819 -0
  8. package/dist/{build-lock-CgtsB6TV.mjs → build-lock-DPXPLgTc.mjs} +120 -24
  9. package/dist/build-progress-CVqkfPC6.mjs +188 -0
  10. package/dist/{cache-manifest-CYJi8-Hx.mjs → cache-manifest-Dg7OnMrp.mjs} +4 -4
  11. package/dist/cache-manifest.mjs +1 -1
  12. package/dist/cli.mjs +22 -19
  13. package/dist/collector-run.d.mts +2 -0
  14. package/dist/collector-run.mjs +10 -9
  15. package/dist/{command-output-B3ycpsYv.mjs → command-output-gByVmIjy.mjs} +11 -2
  16. package/dist/{config-B7iyKWkL.mjs → config-Cp9lM3wX.mjs} +187 -57
  17. package/dist/{dependency-state-D0M7oKA7.mjs → dependency-state-Bq5DcLGn.mjs} +2 -2
  18. package/dist/{deps-oCXVNEqQ.mjs → deps-Cip5vUm1.mjs} +8 -5
  19. package/dist/{detached-entry-Bpo6ZY_I.mjs → detached-entry-KEu0_AXL.mjs} +1 -1
  20. package/dist/{device-BUoZyMoR.mjs → dev-client-HyqISM0h.mjs} +1178 -635
  21. package/dist/{device-RdujjHLB.mjs → device-D4U-xLty.mjs} +13 -20
  22. package/dist/{device-lease-Ffz_M5t0.mjs → device-lease-Crs-HkJY.mjs} +16 -9
  23. package/dist/{device-pool-BxRwdufO.mjs → device-pool-CnTAz8X1.mjs} +7 -7
  24. package/dist/{device-remote-ByN8P6CB.mjs → device-remote-AQ5rQPqr.mjs} +482 -35
  25. package/dist/{doctor-BMt156qe.mjs → doctor-B_Ei3bZu.mjs} +95 -107
  26. package/dist/{error-diagnostics-D1Kaf-eT.mjs → error-diagnostics-Vfz83GCF.mjs} +22 -16
  27. package/dist/exec-jIAlgilk.mjs +132 -0
  28. package/dist/{gc-v2YcoJvm.mjs → gc-Ckw8RQHi.mjs} +763 -126
  29. package/dist/{guide-BTAlD1_y.mjs → guide-BiAbhs1J.mjs} +891 -501
  30. package/dist/{guide-status-Craq9z0t.mjs → guide-status-BAMVU0ZA.mjs} +5 -3
  31. package/dist/{ios-BcCiI4TS.mjs → ios-BL2NFckr.mjs} +43 -20
  32. package/dist/{ios-BP04pY5f.mjs → ios-BzUJWk1K.mjs} +141 -175
  33. package/dist/{ios-device-CauFJiYx.mjs → ios-device-D972i9wf.mjs} +149 -52
  34. package/dist/{ios-device-CFXA46ez.mjs → ios-device-DPsSm0v_.mjs} +3 -2
  35. package/dist/launch-verify-Q_OtW1Rf.mjs +365 -0
  36. package/dist/{logs-CakdBTtm.mjs → logs-CI9NQSK7.mjs} +18 -13
  37. package/dist/{logs-query-BGL-Pl12.mjs → logs-query-Cqma3WIu.mjs} +36 -28
  38. package/dist/{metro-C7bcJ0uo.mjs → metro-DrLHaMn2.mjs} +78 -8
  39. package/dist/{named-ports-DzMOlmaX.mjs → named-ports-CmLQSIRj.mjs} +4 -4
  40. package/dist/{ndjson-BZwI1NBv.mjs → ndjson-9uFNRwfe.mjs} +36 -3
  41. package/dist/{ownership-Cp52QAK1.mjs → ownership-D15Op8vN.mjs} +3 -3
  42. package/dist/{ownership-wolE-0Do.mjs → ownership-DCQPuWEc.mjs} +26 -7
  43. package/dist/{ports-BIbJaKNW.mjs → ports-BNKoWVWZ.mjs} +3 -3
  44. package/dist/{process-output-Dl5uKE03.mjs → process-output-Fpl8PYi8.mjs} +1 -1
  45. package/dist/{project-DDZWf_16.mjs → project-DS112DYB.mjs} +8 -3
  46. package/dist/{reload-BdypPbjM.mjs → reload-ZNJUo1pl.mjs} +12 -13
  47. package/dist/{server-bare-BQkyxa_b.mjs → server-bare-DWkkcIxV.mjs} +2 -2
  48. package/dist/{server-expo-BVAA1OqJ.mjs → server-expo-B_lctBR3.mjs} +8 -7
  49. package/dist/server-expo-CeILEu86.mjs +2 -0
  50. package/dist/settings-BKNh5rl4.mjs +376 -0
  51. package/dist/{settings-BXXorVM2.mjs → settings-D030k0t4.mjs} +464 -152
  52. package/dist/settings.schema.json +989 -0
  53. package/dist/{teardown-BJqEzPo_.mjs → sim-pool-KaIsp2Ft.mjs} +14 -419
  54. package/dist/simslim-B_LsGLuJ.mjs +259 -0
  55. package/dist/{slot-launch-ZUUe0TQP.mjs → slot-launch-DU9A-gYw.mjs} +4 -4
  56. package/dist/spawn-claims-Dn3C_N6w.mjs +49 -0
  57. package/dist/{start-CmxtOKjU.mjs → start-DYkkKF0s.mjs} +27 -31
  58. package/dist/{state-DZobzxLW.mjs → state-NcJzMEBF.mjs} +5 -3
  59. package/dist/{stats-CLg5RuDS.mjs → stats-B63-JVAY.mjs} +4 -4
  60. package/dist/{stats-BYcZ6n1h.mjs → stats-DG9e66Rz.mjs} +66 -5
  61. package/dist/status-S6ehL4Ui.mjs +932 -0
  62. package/dist/{stop-C8JW6kKp.mjs → stop-BO28HGqF.mjs} +101 -25
  63. package/dist/{stop-B1kOpYei.mjs → stop-T1R-cZRz.mjs} +2 -2
  64. package/dist/supervisor-run.mjs +52 -34
  65. package/dist/{workspace-process-lock-BYfZz_S-.mjs → workspace-process-lock-DWaiRD0e.mjs} +14 -9
  66. package/dist/{workspace-state-BT5E3SA2.mjs → workspace-state-DF2gCFhx.mjs} +52 -29
  67. package/dist/worktree-DKhSF0UX.mjs +2 -0
  68. package/dist/{worktree-CFLA-6MX.mjs → worktree-LGB-UwgG.mjs} +603 -59
  69. package/dist/{worktree-FG_ZKKdb.mjs → worktree-R_cUlE7s.mjs} +62 -41
  70. package/dist/{xcode-B6YaWeg0.mjs → xcode-BZqyxh3e.mjs} +216 -58
  71. package/package.json +5 -6
  72. package/skill/SKILL.md +2 -0
  73. package/dist/app-install-BDbsSgCl.mjs +0 -1175
  74. package/dist/build-slots-C-oAHRT1.mjs +0 -151
  75. package/dist/dev-client-BmT-ioFI.mjs +0 -797
  76. package/dist/exec-B29XNuhP.mjs +0 -77
  77. package/dist/reclaim-KJ-0uWEF.mjs +0 -361
  78. package/dist/server-expo-YDDG3eP7.mjs +0 -2
  79. package/dist/status-DdkLki47.mjs +0 -426
  80. package/dist/{metro-store-CDDZHtPl.mjs → metro-store-DzeWkfqW.mjs} +1 -1
@@ -1,23 +1,514 @@
1
- import { t as getExecutor } from "./exec-B29XNuhP.mjs";
2
- import { E as ownership_claim_exports, S as upsertProject, f as getProject, g as loadConfig, h as isPathPrefix, u as getConfigDir, v as removeProject, w as process_identity_exports } from "./config-B7iyKWkL.mjs";
3
- import { S as warmWorktreePaths, _ as resolveFullRef, b as sourceCheckoutOf, c as hasRemote, d as listWorktrees, f as locallyKnownUpstream, h as removeWorktree, i as depsOutOfSync, l as hasUncommittedWork, m as readWorktreeExclude, n as cloneIgnoredEntries, o as dirtyPaths, p as podsOutOfSync, r as deleteBranch, s as gitCommonDir, t as branchExists, u as isPodInstallChurn, x as unpushedCommits, y as restoreFile } from "./worktree-FG_ZKKdb.mjs";
4
- import { a as findProjectRoot } from "./project-DDZWf_16.mjs";
5
- import { l as readRemoteSession, s as readMetroTunnel } from "./state-DZobzxLW.mjs";
6
- import { A as resolveSettings, I as unknownSettingKeys, N as settingShapeErrors, a as SETTING_SHAPE_REMEDY } from "./settings-BXXorVM2.mjs";
7
- import { a as formatElapsed, f as releasedLeaseFact, i as formatDuration, l as phaseLine, m as shortUdid, u as plural } from "./command-output-B3ycpsYv.mjs";
8
- import { a as POOL_SETTING_REMEDY, l as parkedMaxSetting } from "./teardown-BJqEzPo_.mjs";
9
- import { h as podInstallCommand, n as HEARTBEAT_INTERVAL_MS } from "./xcode-B6YaWeg0.mjs";
10
- import { n as hasInstalledDependencies, r as installedNpmTreeIsValid, t as dependencyState } from "./dependency-state-D0M7oKA7.mjs";
11
- import { c as withManagedTunnelRemovalLock, o as withManagedRemoteWorktreeRemovalLock } from "./ownership-wolE-0Do.mjs";
12
- import { n as reclaimProject } from "./reclaim-KJ-0uWEF.mjs";
13
- import { a as runPodInstall, i as runCaptured, n as podsAreStale, r as readPodState, t as DEPS_ERROR } from "./deps-oCXVNEqQ.mjs";
14
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
15
- import { createHash, randomUUID } from "node:crypto";
16
- import { join, relative } from "node:path";
1
+ import { D as process_identity_exports, K as projectDeviceSlots, O as acquireAvdClaim, b as removeProject, f as getProject, g as loadConfig, h as isPathPrefix, i as clearDevice, k as ownership_claim_exports, u as getConfigDir, w as upsertProject } from "./config-Cp9lM3wX.mjs";
2
+ import { t as getExecutor } from "./exec-jIAlgilk.mjs";
3
+ import { _ as workspaceDir, i as recordWorkspaceUse, r as readWorkspaceState } from "./workspace-state-DF2gCFhx.mjs";
4
+ import { C as warmWorktreePaths, S as unpushedCommits, b as restoreFile, c as hasPopulatedSubmodules, d as isPodInstallChurn, f as listWorktrees, g as removeWorktree, h as readWorktreeExclude, i as depsOutOfSync, l as hasRemote, m as podsOutOfSync, n as cloneIgnoredEntries, o as dirtyPaths, p as locallyKnownUpstream, r as deleteBranch, s as gitCommonDir, t as branchExists, u as hasUncommittedWork, v as resolveFullRef, x as sourceCheckoutOf } from "./worktree-R_cUlE7s.mjs";
5
+ import { o as findProjectRoot } from "./project-DS112DYB.mjs";
6
+ import { a as clearRemoteSession, i as clearManagedMetroTunnel, l as readRemoteSession, s as readMetroTunnel, u as readRemoteSessionId } from "./state-NcJzMEBF.mjs";
7
+ import { a as pidExists, c as resolveProjectMetro, l as signalProcessTree, n as killMetroTree } from "./metro-DrLHaMn2.mjs";
8
+ import { n as verifyCollectorOwnership } from "./ownership-D15Op8vN.mjs";
9
+ import { F as unknownSettingKeys, M as settingShapeErrors, i as SETTING_SHAPE_REMEDY, k as resolveSettings } from "./settings-D030k0t4.mjs";
10
+ import { a as formatElapsed, h as shortUdid, i as formatDuration, l as phaseLine, p as releasedLeaseFact, u as plural } from "./command-output-gByVmIjy.mjs";
11
+ import { n as listBuildLocks } from "./build-lock-DPXPLgTc.mjs";
12
+ import { b as resolveEasCliBin, o as parkedMaxSetting, t as POOL_SETTING_REMEDY } from "./sim-pool-KaIsp2Ft.mjs";
13
+ import { n as workspaceProcessLockError, r as workspaceProcessLockPath, t as withWorkspaceProcessLock } from "./workspace-process-lock-DWaiRD0e.mjs";
14
+ import { l as releaseWorkspaceLeases } from "./device-lease-Crs-HkJY.mjs";
15
+ import { _ as listBuildSlots, h as podInstallCommand, n as HEARTBEAT_INTERVAL_MS } from "./xcode-BZqyxh3e.mjs";
16
+ import { E as teardownOwnedIosSim, T as teardownOwnedAvd, i as endRecordedSession } from "./device-remote-AQ5rQPqr.mjs";
17
+ import { n as hasInstalledDependencies, r as installedNpmTreeIsValid, t as dependencyState } from "./dependency-state-Bq5DcLGn.mjs";
18
+ import { t as clearNamedPorts } from "./named-ports-CmLQSIRj.mjs";
19
+ import { i as stopTunnel, l as withManagedTunnelRemovalLock, n as managedLockHolders, s as withManagedRemoteWorktreeRemovalLock, t as resolveSupervisorTarget } from "./ownership-DCQPuWEc.mjs";
20
+ import { a as runPodInstall, i as runCaptured, n as podsAreStale, r as readPodState, t as DEPS_ERROR } from "./deps-Cip5vUm1.mjs";
21
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
17
22
  import { existsSync as existsSync$1, realpathSync as realpathSync$1 } from "fs";
18
- import { basename as basename$1, dirname as dirname$1, isAbsolute as isAbsolute$1, relative as relative$1, resolve as resolve$1 } from "path";
23
+ import { basename, dirname, isAbsolute, relative, resolve } from "path";
19
24
  import { workspaceName } from "@stim-cli/core";
25
+ import { join as join$1, relative as relative$1 } from "node:path";
26
+ import { createHash, randomUUID } from "node:crypto";
20
27
  import chalk from "chalk";
28
+ //#region src/commands/gc/paths.ts
29
+ function canonicalPath(p) {
30
+ try {
31
+ return realpathSync$1(p);
32
+ } catch {
33
+ return resolve(p);
34
+ }
35
+ }
36
+ //#endregion
37
+ //#region src/workspace/in-use.ts
38
+ const NATIVE_RUN = "native-run";
39
+ const NATIVE_RUN_HELD = "a stim ios, android or stop run holds its native-run.lock";
40
+ function workspaceInUse(root, { supervisor = true, managedLocks = true, nativeRun = true } = {}) {
41
+ const reasons = [];
42
+ if (supervisor) {
43
+ const project = getProject(root);
44
+ const target = resolveSupervisorTarget({
45
+ state: readWorkspaceState(root)?.supervisor,
46
+ record: project?.supervisor,
47
+ reservedPort: project?.metroPort
48
+ });
49
+ if (target.status === "ours") reasons.push(`its dev server supervisor (pid ${target.pid}) is running`);
50
+ else if (target.status === "unverified") reasons.push(`its dev server supervisor cannot be verified: ${target.reason ?? "unknown identity"}`);
51
+ }
52
+ if (nativeRun) {
53
+ const claims = (0, ownership_claim_exports.readClaimSet)(workspaceProcessLockPath(workspaceDir(root), NATIVE_RUN, true));
54
+ if (claims.live.length) reasons.push(NATIVE_RUN_HELD);
55
+ else if (claims.unresolved[0]) reasons.push(`its native-run.lock cannot be resolved: ${claims.unresolved[0].reason}`);
56
+ }
57
+ const self = canonicalPath(root);
58
+ for (const [kind, entries] of [["build lock", listBuildLocks()], ["build slot", listBuildSlots()]]) for (const entry of entries) {
59
+ if (!entry.alive && !entry.unresolved) continue;
60
+ if (entry.projectRoot !== null && canonicalPath(entry.projectRoot) === self) reasons.push(`a ${entry.alive ? "live" : "unresolvable"} ${kind} at ${entry.path} names it`);
61
+ }
62
+ if (managedLocks) reasons.push(...managedLockHolders(root));
63
+ return reasons;
64
+ }
65
+ function emptyWorkspaceDir(dir) {
66
+ if (lstatSync(dir).isSymbolicLink()) {
67
+ unlinkSync(dir);
68
+ return;
69
+ }
70
+ for (const name of readdirSync(dir)) if (name !== `${NATIVE_RUN}.lock`) rmSync(join$1(dir, name), {
71
+ recursive: true,
72
+ force: true
73
+ });
74
+ }
75
+ async function withIdleWorkspace(root, fn, { purpose, ...checks }) {
76
+ try {
77
+ return await withWorkspaceProcessLock(workspaceDir(root), NATIVE_RUN, async () => {
78
+ const reasons = workspaceInUse(root, {
79
+ ...checks,
80
+ nativeRun: false
81
+ });
82
+ if (reasons.length) return {
83
+ ran: false,
84
+ reasons
85
+ };
86
+ return {
87
+ ran: true,
88
+ value: await fn()
89
+ };
90
+ }, {
91
+ external: true,
92
+ waitMs: 0,
93
+ ownerPurpose: purpose
94
+ });
95
+ } catch (error) {
96
+ if (workspaceProcessLockError(error)) return {
97
+ ran: false,
98
+ reasons: [NATIVE_RUN_HELD]
99
+ };
100
+ if ((0, ownership_claim_exports.isClaimRefusal)(error) || (0, ownership_claim_exports.isClaimUnavailable)(error)) return {
101
+ ran: false,
102
+ reasons: [`its native-run.lock cannot be taken: ${error.reason}`]
103
+ };
104
+ throw error;
105
+ } finally {
106
+ try {
107
+ const dir = workspaceDir(root);
108
+ if (!lstatSync(dir).isSymbolicLink()) rmdirSync(dir);
109
+ } catch {}
110
+ }
111
+ }
112
+ //#endregion
113
+ //#region src/devices/reclaim.ts
114
+ async function reapCollectors(root, { verify = verifyCollectorOwnership, collectors }) {
115
+ const skippedDevices = [];
116
+ const failedDevices = [];
117
+ for (const [platform, record] of Object.entries(collectors)) {
118
+ const rec = record;
119
+ const pid = rec?.pid;
120
+ if (typeof pid !== "number" || pid <= 0 || pid === process.pid || !pidExists(pid)) continue;
121
+ const ownership = verify({
122
+ pid,
123
+ platform,
124
+ root,
125
+ expected: rec
126
+ });
127
+ if (ownership.status === "gone") continue;
128
+ if (ownership.status === "unverified") {
129
+ const name = `${platform} log collector (pid ${pid})`;
130
+ const entry = {
131
+ platform: platform.split(":")[0] === "android" ? "android" : "ios",
132
+ name,
133
+ reason: `${ownership.reason}; keeping the record without signalling the process`
134
+ };
135
+ skippedDevices.push(entry);
136
+ failedDevices.push(entry);
137
+ continue;
138
+ }
139
+ try {
140
+ process.kill(pid, "SIGTERM");
141
+ if (await (0, process_identity_exports.waitForProcessExit)(rec, 5e3)) continue;
142
+ } catch (error) {
143
+ if (error.code === "ESRCH") continue;
144
+ }
145
+ const entry = {
146
+ platform: platform.split(":")[0] === "android" ? "android" : "ios",
147
+ name: `${platform} log collector (pid ${pid})`,
148
+ reason: "Collector exit could not be confirmed; keeping its ownership record."
149
+ };
150
+ skippedDevices.push(entry);
151
+ failedDevices.push(entry);
152
+ }
153
+ return {
154
+ skippedDevices,
155
+ failedDevices
156
+ };
157
+ }
158
+ function reclaimRemoteSession(root, { stopSession = defaultStopSession } = {}) {
159
+ const sessionId = readRemoteSessionId(root);
160
+ if (!sessionId) return {
161
+ stopped: null,
162
+ failed: null
163
+ };
164
+ let result;
165
+ try {
166
+ result = stopSession(root, sessionId);
167
+ } catch (err) {
168
+ result = {
169
+ status: "failed",
170
+ reason: String(err?.message ?? err)
171
+ };
172
+ }
173
+ if (result.status === "torn-down") {
174
+ if (result.reason) return {
175
+ stopped: sessionId,
176
+ failed: {
177
+ platform: "ios",
178
+ name: `remote session ${sessionId}`,
179
+ reason: `${result.reason} The session is stopped. Re-run cleanup to reconcile its retained ownership claim.`
180
+ }
181
+ };
182
+ clearRemoteSession(root, sessionId);
183
+ return {
184
+ stopped: sessionId,
185
+ failed: null
186
+ };
187
+ }
188
+ return {
189
+ stopped: null,
190
+ failed: {
191
+ platform: "ios",
192
+ name: `remote session ${sessionId}`,
193
+ reason: `${result.reason ?? "stop failed"} -- it keeps billing until its max duration. Stop it by hand from any directory of this project: eas simulator:stop --id ${sessionId}`
194
+ }
195
+ };
196
+ }
197
+ function defaultStopSession(root, sessionId) {
198
+ return endRecordedSession({
199
+ root,
200
+ sessionId,
201
+ easBin: resolveEasCliBin(root)?.file ?? null
202
+ });
203
+ }
204
+ function defaultStopMetroTunnel(record) {
205
+ return stopTunnel(record);
206
+ }
207
+ async function reclaimMetroTunnel(root, { stopMetroTunnel = defaultStopMetroTunnel } = {}) {
208
+ const record = readMetroTunnel(root);
209
+ if (record?.kind !== "managed") return {
210
+ stopped: null,
211
+ failed: null
212
+ };
213
+ let result;
214
+ try {
215
+ result = await stopMetroTunnel(record);
216
+ } catch (err) {
217
+ result = {
218
+ status: "failed",
219
+ reason: String(err?.message ?? err)
220
+ };
221
+ }
222
+ if (result.status === "failed") return {
223
+ stopped: null,
224
+ failed: {
225
+ platform: "ios",
226
+ name: `${record.provider} tunnel (pid ${record.pid})`,
227
+ reason: `${result.reason ?? "stop failed"} The process identity could not be verified or the stop could not be confirmed. The ownership record is kept. Inspect the process and retry \`stim worktree remove\`.`
228
+ }
229
+ };
230
+ if (!clearManagedMetroTunnel(root, record)) return {
231
+ stopped: null,
232
+ failed: {
233
+ platform: "ios",
234
+ name: "replacement managed tunnel",
235
+ reason: "A replacement managed tunnel record appeared during cleanup and is retained for a later stop."
236
+ }
237
+ };
238
+ return {
239
+ stopped: record.provider,
240
+ failed: null
241
+ };
242
+ }
243
+ function describeDereferenced(project) {
244
+ const devices = Object.entries(project?.ports ?? {}).map(([label, port]) => `port ${label} (${port})`);
245
+ for (const { platforms } of projectDeviceSlots(project)) {
246
+ const ios = platforms.ios;
247
+ if (ios?.deviceUdid) devices.push(`ios sim ${ios.deviceUdid}`);
248
+ const android = platforms.android;
249
+ if (android?.avdName) devices.push(`android avd ${android.avdName}`);
250
+ else if (android?.serial) devices.push(`android device ${android.serial}`);
251
+ }
252
+ return devices;
253
+ }
254
+ function parkedIosCacheKey(lastBuild) {
255
+ if (lastBuild === null || typeof lastBuild !== "object" || Array.isArray(lastBuild)) return null;
256
+ const build = lastBuild;
257
+ return build.platform === "ios" && typeof build.cacheKey === "string" ? build.cacheKey : null;
258
+ }
259
+ function parkRequest(project, projectPath, slot, simslimManaged) {
260
+ const { max, error } = parkedMaxSetting("ios");
261
+ if (error || max <= 0) return void 0;
262
+ const cacheKey = parkedIosCacheKey(readWorkspaceState(projectPath)?.lastBuild);
263
+ return {
264
+ projectPath,
265
+ max,
266
+ bundleId: typeof project?.bundleId === "string" ? project.bundleId : null,
267
+ cacheKey,
268
+ simslimManaged,
269
+ slot
270
+ };
271
+ }
272
+ function reclaimOwnedDevices(project, projectPath, { park = false } = {}) {
273
+ const deletedDevices = [];
274
+ const parkedDevices = [];
275
+ const evictedDevices = [];
276
+ const poolNotes = [];
277
+ const skippedDevices = [];
278
+ const failedDevices = [];
279
+ for (const { slot, platforms } of projectDeviceSlots(project)) {
280
+ const ios = platforms.ios;
281
+ if (ios?.owned && ios.deviceUdid) {
282
+ const udid = ios.deviceUdid;
283
+ const label = ios.deviceName || udid;
284
+ const r = teardownOwnedIosSim(udid, {
285
+ del: true,
286
+ label,
287
+ ...park ? { park: parkRequest(project, projectPath, slot, Boolean(ios.simslimManaged)) } : {}
288
+ });
289
+ if (r.parkFallback) poolNotes.push(`could not park ${label}: ${r.parkFallback} -- deleted it instead`);
290
+ for (const failure of r.evictionFailures ?? []) poolNotes.push(failure);
291
+ if (r.parked) {
292
+ parkedDevices.push(r.parked);
293
+ evictedDevices.push(...r.evicted ?? []);
294
+ } else if (r.status === "torn-down") deletedDevices.push(r.label);
295
+ if (!r.parked && (r.status === "torn-down" || r.status === "missing")) clearDevice(projectPath, "ios", slot, udid);
296
+ if (r.status === "skipped") skippedDevices.push({
297
+ platform: "ios",
298
+ name: label,
299
+ udid,
300
+ reason: `${r.reason} -- not touched`
301
+ });
302
+ else if (r.status === "failed") {
303
+ const entry = {
304
+ platform: "ios",
305
+ name: label,
306
+ udid,
307
+ reason: `teardown failed: ${r.reason}`
308
+ };
309
+ skippedDevices.push(entry);
310
+ failedDevices.push(entry);
311
+ }
312
+ }
313
+ const android = platforms.android;
314
+ if (android?.owned && android.avdName) {
315
+ const bound = parkedMaxSetting("android");
316
+ const r = teardownOwnedAvd(android.avdName, {
317
+ del: true,
318
+ owner: {
319
+ projectPath,
320
+ slot
321
+ },
322
+ ...park && !bound.error && bound.max > 0 ? { park: {
323
+ projectPath,
324
+ slot,
325
+ max: bound.max,
326
+ configuration: typeof android.poolConfiguration === "string" ? android.poolConfiguration : void 0
327
+ } } : {}
328
+ });
329
+ if (r.parkFallback) poolNotes.push(`could not park ${android.avdName}: ${r.parkFallback} -- deleted it instead`);
330
+ for (const failure of r.evictionFailures ?? []) poolNotes.push(failure);
331
+ if (!r.parked && (r.status === "torn-down" || r.status === "missing")) clearDevice(projectPath, "android", slot, android.avdName);
332
+ if (r.parked) {
333
+ parkedDevices.push(r.parked);
334
+ evictedDevices.push(...r.evicted ?? []);
335
+ } else if (r.status === "torn-down") deletedDevices.push(android.avdName);
336
+ else if (r.status === "skipped") skippedDevices.push({
337
+ platform: "android",
338
+ name: android.avdName,
339
+ reason: `${r.reason} -- not touched`
340
+ });
341
+ else if (r.status === "failed") {
342
+ const entry = {
343
+ platform: "android",
344
+ name: android.avdName,
345
+ reason: `teardown failed: ${r.reason}`
346
+ };
347
+ skippedDevices.push(entry);
348
+ failedDevices.push(entry);
349
+ }
350
+ }
351
+ }
352
+ return {
353
+ deletedDevices,
354
+ parkedDevices,
355
+ evictedDevices,
356
+ poolNotes,
357
+ skippedDevices,
358
+ failedDevices
359
+ };
360
+ }
361
+ async function reclaimProject(path, options = {}) {
362
+ const dir = workspaceDir(path);
363
+ const existed = existsSync(dir);
364
+ const run = await withIdleWorkspace(path, () => reclaimIdleProject(path, existed, options), {
365
+ purpose: "workspace removal",
366
+ supervisor: false,
367
+ managedLocks: false
368
+ });
369
+ if (run.ran) return run.value;
370
+ const kept = {
371
+ name: `workspace ${dir}`,
372
+ reason: `kept because ${run.reasons.join("; ")}`
373
+ };
374
+ return {
375
+ path,
376
+ dereferenced: [],
377
+ killedPid: null,
378
+ skippedMetro: null,
379
+ metroPort: getProject(path)?.metroPort ?? null,
380
+ deletedDevices: [],
381
+ parkedDevices: [],
382
+ evictedDevices: [],
383
+ poolNotes: [],
384
+ skippedDevices: [kept],
385
+ failedDevices: [kept],
386
+ keptEntry: true,
387
+ stoppedSession: null,
388
+ stoppedTunnel: null,
389
+ releasedLeases: [],
390
+ removedWorkspaceDirs: [],
391
+ failedWorkspaceDirs: []
392
+ };
393
+ }
394
+ async function reclaimIdleProject(path, workspaceExisted, { deleteOwnedDevices = false, parkOwnedDevices = false, preserveProjectRecord = false, stopSession = defaultStopSession, stopMetroTunnel = defaultStopMetroTunnel, releaseLeases = releaseWorkspaceLeases, verifyCollector = verifyCollectorOwnership }) {
395
+ await clearNamedPorts(path, { stop: true });
396
+ const project = getProject(path);
397
+ for (const { platforms } of projectDeviceSlots(project)) if (platforms.android?.owned && platforms.android.avdName) (0, ownership_claim_exports.releaseClaim)(acquireAvdClaim(platforms.android.avdName));
398
+ const dereferenced = describeDereferenced(project);
399
+ const initialState = readWorkspaceState(path);
400
+ let killedPid = null;
401
+ let skippedMetro = null;
402
+ let supervisorHeld = false;
403
+ const supervisor = resolveSupervisorTarget({
404
+ state: initialState?.supervisor,
405
+ record: project?.supervisor,
406
+ reservedPort: project?.metroPort
407
+ });
408
+ const orphanServer = {
409
+ pid: Number(initialState?.supervisor?.serverPid),
410
+ processToken: initialState?.supervisor?.serverProcessToken
411
+ };
412
+ const orphanIdentity = supervisor.status === "stale" && typeof orphanServer.processToken === "string" ? (0, process_identity_exports.inspectProcessIdentity)(orphanServer) : null;
413
+ if (supervisor.status === "ours") {
414
+ if (killMetroTree(supervisor.pid, supervisor.processToken) && await (0, process_identity_exports.waitForProcessExit)(supervisor, 1e4)) killedPid = supervisor.pid;
415
+ else {
416
+ skippedMetro = `could not confirm supervisor pid ${supervisor.pid} exited`;
417
+ supervisorHeld = true;
418
+ }
419
+ } else if (supervisor.status === "unverified") {
420
+ skippedMetro = supervisor.reason ?? "supervisor identity could not be verified";
421
+ supervisorHeld = true;
422
+ } else if (orphanIdentity === "same") {
423
+ let signalled = false;
424
+ try {
425
+ signalled = signalProcessTree(orphanServer.pid, "SIGTERM");
426
+ } catch {}
427
+ if (signalled ? await (0, process_identity_exports.waitForProcessExit)(orphanServer, 1e4) : ["gone", "different"].includes((0, process_identity_exports.inspectProcessIdentity)(orphanServer))) killedPid = orphanServer.pid;
428
+ else {
429
+ skippedMetro = `could not confirm dev server pid ${orphanServer.pid} left by the supervisor exited`;
430
+ supervisorHeld = true;
431
+ }
432
+ } else if (orphanIdentity === "unknown") {
433
+ skippedMetro = `the identity of dev server pid ${orphanServer.pid} left by the supervisor could not be verified`;
434
+ supervisorHeld = true;
435
+ } else if (typeof project?.metroPort === "number") {
436
+ if (!(await resolveProjectMetro(project.metroPort, path)).missing) skippedMetro = "Externally started server left alone; no verified Stim supervisor owns it";
437
+ }
438
+ const { skippedDevices: skippedCollectors, failedDevices: failedCollectors } = await reapCollectors(path, {
439
+ verify: verifyCollector,
440
+ collectors: initialState?.collectors ?? {}
441
+ });
442
+ function retainReplacementProcesses() {
443
+ const currentState = readWorkspaceState(path);
444
+ const currentProject = getProject(path);
445
+ if (Boolean(currentState?.supervisor && !(0, process_identity_exports.sameProcessRecord)(currentState.supervisor, initialState?.supervisor) || currentProject?.supervisor && !(0, process_identity_exports.sameProcessRecord)(currentProject.supervisor, project?.supervisor) || Object.entries(currentState?.collectors ?? {}).some(([platform, record]) => !(0, process_identity_exports.sameProcessRecord)(record, initialState?.collectors?.[platform])))) {
446
+ supervisorHeld = true;
447
+ skippedMetro = "A replacement process appeared during cleanup; its ownership records are retained";
448
+ }
449
+ }
450
+ retainReplacementProcesses();
451
+ const { deletedDevices, parkedDevices, evictedDevices, poolNotes, skippedDevices, failedDevices } = deleteOwnedDevices && !supervisorHeld && failedCollectors.length === 0 ? reclaimOwnedDevices(project, path, { park: parkOwnedDevices }) : {
452
+ deletedDevices: [],
453
+ parkedDevices: [],
454
+ evictedDevices: [],
455
+ poolNotes: [],
456
+ skippedDevices: [],
457
+ failedDevices: []
458
+ };
459
+ skippedDevices.push(...skippedCollectors);
460
+ failedDevices.push(...failedCollectors);
461
+ const remote = reclaimRemoteSession(path, { stopSession });
462
+ if (remote.failed) {
463
+ skippedDevices.push(remote.failed);
464
+ failedDevices.push(remote.failed);
465
+ }
466
+ const tunnel = await reclaimMetroTunnel(path, { stopMetroTunnel });
467
+ if (tunnel.failed) {
468
+ skippedDevices.push(tunnel.failed);
469
+ failedDevices.push(tunnel.failed);
470
+ }
471
+ let releasedLeases = [];
472
+ try {
473
+ releasedLeases = releaseLeases(path);
474
+ } catch {
475
+ releasedLeases = [];
476
+ }
477
+ retainReplacementProcesses();
478
+ const removedWorkspaceDirs = [];
479
+ const failedWorkspaceDirs = [];
480
+ if (failedDevices.length === 0 && !supervisorHeld) {
481
+ const dir = workspaceDir(path);
482
+ if (existsSync(dir)) try {
483
+ emptyWorkspaceDir(dir);
484
+ if (workspaceExisted) removedWorkspaceDirs.push(dir);
485
+ } catch {
486
+ failedWorkspaceDirs.push(dir);
487
+ }
488
+ }
489
+ const keptEntry = supervisorHeld || failedDevices.length > 0 || failedWorkspaceDirs.length > 0;
490
+ if (project && !keptEntry && !preserveProjectRecord) removeProject(path);
491
+ return {
492
+ path,
493
+ dereferenced,
494
+ killedPid,
495
+ skippedMetro,
496
+ metroPort: project?.metroPort ?? null,
497
+ deletedDevices,
498
+ parkedDevices,
499
+ evictedDevices,
500
+ poolNotes,
501
+ skippedDevices,
502
+ failedDevices,
503
+ keptEntry,
504
+ stoppedSession: remote.stopped,
505
+ stoppedTunnel: tunnel.stopped,
506
+ releasedLeases,
507
+ removedWorkspaceDirs,
508
+ failedWorkspaceDirs
509
+ };
510
+ }
511
+ //#endregion
21
512
  //#region src/engine/warm-claim.ts
22
513
  const WARM_CLAIM_LABEL = "worktree warm";
23
514
  const WARM_CLAIM_POLL_MS = 250;
@@ -38,10 +529,10 @@ function defaultSleep(ms) {
38
529
  return new Promise((resolve) => setTimeout(resolve, ms));
39
530
  }
40
531
  function warmClaimsDir() {
41
- return join(getConfigDir(), "warm-locks");
532
+ return join$1(getConfigDir(), "warm-locks");
42
533
  }
43
534
  function warmClaimPath(repositoryRoot) {
44
- return join(warmClaimsDir(), `${workspaceName(repositoryRoot)}.lock`);
535
+ return join$1(warmClaimsDir(), `${workspaceName(repositoryRoot)}.lock`);
45
536
  }
46
537
  function warmCommand(phase) {
47
538
  return phase === "refresh" ? "stim worktree warm --refresh" : "stim worktree warm";
@@ -368,11 +859,11 @@ function stepLine(label, reason, action) {
368
859
  return phaseLine(label, `${reason} -> ${action}`);
369
860
  }
370
861
  function installRecordFile(depsRoot) {
371
- return join(getConfigDir(), "warm-installs", `${workspaceName(depsRoot)}.json`);
862
+ return join$1(getConfigDir(), "warm-installs", `${workspaceName(depsRoot)}.json`);
372
863
  }
373
864
  function lockfileHash(depsRoot, lockfile) {
374
865
  try {
375
- return createHash("sha256").update(readFileSync(join(depsRoot, lockfile))).digest("hex");
866
+ return createHash("sha256").update(readFileSync(join$1(depsRoot, lockfile))).digest("hex");
376
867
  } catch {
377
868
  return null;
378
869
  }
@@ -394,7 +885,7 @@ function readInstallEvidence(depsRoot) {
394
885
  function writeInstallEvidence(depsRoot, lockfile, hash, completed) {
395
886
  const file = installRecordFile(depsRoot);
396
887
  const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`;
397
- mkdirSync(join(getConfigDir(), "warm-installs"), { recursive: true });
888
+ mkdirSync(join$1(getConfigDir(), "warm-installs"), { recursive: true });
398
889
  writeFileSync(tmp, JSON.stringify({
399
890
  lock: lockfile,
400
891
  hash: hash ?? "",
@@ -412,9 +903,9 @@ function restoreInstallEvidence(depsRoot, prior) {
412
903
  else rmSync(installRecordFile(depsRoot), { force: true });
413
904
  }
414
905
  function inProgressOperation(gitDir) {
415
- if (existsSync(join(gitDir, "rebase-merge"))) return "rebase";
416
- if (existsSync(join(gitDir, "rebase-apply"))) return existsSync(join(gitDir, "rebase-apply", "applying")) ? "am" : "rebase";
417
- return existsSync(join(gitDir, "MERGE_HEAD")) ? "merge" : null;
906
+ if (existsSync(join$1(gitDir, "rebase-merge"))) return "rebase";
907
+ if (existsSync(join$1(gitDir, "rebase-apply"))) return existsSync(join$1(gitDir, "rebase-apply", "applying")) ? "am" : "rebase";
908
+ return existsSync(join$1(gitDir, "MERGE_HEAD")) ? "merge" : null;
418
909
  }
419
910
  function readMainCheckoutState(root) {
420
911
  const exec = getExecutor();
@@ -668,7 +1159,7 @@ async function refreshMainCheckout({ root, appDir, settings, emit, spawnFn = nul
668
1159
  const consumed = dependencies ? lockfileHash(dependencies.root, dependencies.lock) : null;
669
1160
  const deps = depsPlan({
670
1161
  lockfile: dependencies?.lock ?? null,
671
- lockfileChanged: dependencies ? pathChangedBetween(root, before, head, gitPath(relative(root, dependencies.root), dependencies.lock)) : false,
1162
+ lockfileChanged: dependencies ? pathChangedBetween(root, before, head, gitPath(relative$1(root, dependencies.root), dependencies.lock)) : false,
672
1163
  installed,
673
1164
  treeValid: dependencies?.lock === "package-lock.json" && installed ? installedNpmTreeIsValid(dependencies.root) : null,
674
1165
  lastInstall: priorInstall,
@@ -700,11 +1191,11 @@ async function refreshMainCheckout({ root, appDir, settings, emit, spawnFn = nul
700
1191
  if (marked) restoreInstallEvidence(marked.root, priorInstall);
701
1192
  emit(stepLine("deps", depsSubject, "skipped"));
702
1193
  }
703
- const app = relative(root, appDir) || ".";
1194
+ const app = relative$1(root, appDir) || ".";
704
1195
  const where = `source ${appDir}: `;
705
1196
  const podState = readPodState(appDir);
706
1197
  const pods = podsPlan({
707
- hasIos: existsSync(join(appDir, "ios")),
1198
+ hasIos: existsSync(join$1(appDir, "ios")),
708
1199
  hasPodfile: podState.hasPodfile,
709
1200
  podfileLockChanged: pathChangedBetween(root, before, head, gitPath(app, "ios", "Podfile.lock")),
710
1201
  stale: podsAreStale(podState.lockText, podState.manifestText)
@@ -735,12 +1226,12 @@ async function refreshMainCheckout({ root, appDir, settings, emit, spawnFn = nul
735
1226
  //#endregion
736
1227
  //#region src/commands/worktree.ts
737
1228
  function dependencyInstallCommand(target, dir = ".") {
738
- const installRoot = resolve$1(target, dir);
1229
+ const installRoot = resolve(target, dir);
739
1230
  let command = "npm install";
740
- if (existsSync$1(resolve$1(installRoot, "pnpm-lock.yaml"))) command = "pnpm install";
741
- else if (existsSync$1(resolve$1(installRoot, "yarn.lock"))) command = "yarn install";
742
- else if (existsSync$1(resolve$1(installRoot, "bun.lock")) || existsSync$1(resolve$1(installRoot, "bun.lockb"))) command = "bun install";
743
- else if (existsSync$1(resolve$1(installRoot, "package-lock.json"))) command = "npm ci";
1231
+ if (existsSync$1(resolve(installRoot, "pnpm-lock.yaml"))) command = "pnpm install";
1232
+ else if (existsSync$1(resolve(installRoot, "yarn.lock"))) command = "yarn install";
1233
+ else if (existsSync$1(resolve(installRoot, "bun.lock")) || existsSync$1(resolve(installRoot, "bun.lockb"))) command = "bun install";
1234
+ else if (existsSync$1(resolve(installRoot, "package-lock.json"))) command = "npm ci";
744
1235
  return `cd '${installRoot.replaceAll("'", "'\\''")}' && ${command}`;
745
1236
  }
746
1237
  function reportCarriedStateHealth(root, target, copied) {
@@ -752,7 +1243,7 @@ function reportCarriedStateHealth(root, target, copied) {
752
1243
  }
753
1244
  for (const p of podsOutOfSync(target, copied)) {
754
1245
  const where = p.dir === "." ? "Podfile.lock" : `${p.dir}/Podfile.lock`;
755
- const pod = podInstallCommand(p.dir === "." ? target : resolve$1(target, p.dir, ".."));
1246
+ const pod = podInstallCommand(p.dir === "." ? target : resolve(target, p.dir, ".."));
756
1247
  console.error(chalk.yellow(phaseLine("carry", p.reason === "missing" ? `carried ${p.dir === "." ? "Pods" : `${p.dir}/Pods`} but there is no ${where}. Run \`${pod}\` before building.` : `carried ${p.dir === "." ? "Pods" : `${p.dir}/Pods`} does not match the ${where} on disk here. Pods are gitignored and cloned; Podfile.lock is tracked, so the two can disagree. Run \`${pod}\` before building, or xcodebuild fails with "sandbox is not in sync" only after every pod has compiled.`)));
757
1248
  }
758
1249
  }
@@ -766,14 +1257,16 @@ function reportRefreshFailure(failure) {
766
1257
  function mainCheckoutAppDir(root, target) {
767
1258
  const app = findProjectRoot(process.cwd());
768
1259
  if (!app) return root;
769
- const rel = relative$1(target, app);
770
- if (!rel || rel.startsWith("..") || isAbsolute$1(rel)) return root;
771
- return resolve$1(root, rel);
1260
+ const rel = relative(target, app);
1261
+ if (!rel || rel.startsWith("..") || isAbsolute(rel)) return root;
1262
+ return resolve(root, rel);
772
1263
  }
773
1264
  function registerWarm(worktree) {
774
1265
  worktree.command("warm").description("Copy missing ignored paths from the source checkout into the current linked worktree.").option("--refresh", "Before copying, fast-forward the source checkout to its upstream and install what the new commits moved.").action(async (opts) => {
775
1266
  try {
776
1267
  const { root, target, common } = warmWorktreePaths(process.cwd());
1268
+ const app = findProjectRoot(process.cwd());
1269
+ if (app) recordWorkspaceUse(app);
777
1270
  const readSettings = () => {
778
1271
  const settings = resolveSettings({
779
1272
  gitCommonDir: common,
@@ -920,10 +1413,20 @@ function excludePodChurn(lines) {
920
1413
  restore
921
1414
  };
922
1415
  }
1416
+ function nativeCanonicalPath(path) {
1417
+ const missing = [];
1418
+ for (let existing = resolve(path);; existing = dirname(existing)) try {
1419
+ return resolve(realpathSync$1.native(existing), ...missing);
1420
+ } catch {
1421
+ if (dirname(existing) === existing) return resolve(path);
1422
+ missing.unshift(basename(existing));
1423
+ }
1424
+ }
923
1425
  function matchWorktreeEntry(entries, path) {
924
1426
  let best = null;
1427
+ const target = nativeCanonicalPath(path);
925
1428
  (entries || []).forEach((entry, index) => {
926
- if (!entry?.path || !isPathPrefix(entry.path, path)) return;
1429
+ if (!entry?.path || !isPathPrefix(nativeCanonicalPath(entry.path), target)) return;
927
1430
  if (!best || entry.path.length > best.path.length) best = {
928
1431
  ...entry,
929
1432
  index
@@ -972,8 +1475,9 @@ function describeKeptDevice(s) {
972
1475
  function reclaimKeys(rootPath) {
973
1476
  const cfg = loadConfig();
974
1477
  const keys = /* @__PURE__ */ new Set([rootPath]);
1478
+ const root = nativeCanonicalPath(rootPath);
975
1479
  if (cfg?.projects) {
976
- for (const key of Object.keys(cfg.projects)) if (isPathPrefix(rootPath, key)) keys.add(key);
1480
+ for (const key of Object.keys(cfg.projects)) if (isPathPrefix(rootPath, key) || isPathPrefix(root, nativeCanonicalPath(key))) keys.add(key);
977
1481
  }
978
1482
  return [...keys].toSorted();
979
1483
  }
@@ -998,11 +1502,22 @@ async function reclaimAll(rootPath, keys = reclaimKeys(rootPath), { preserveRoot
998
1502
  const removedWorkspaceDirs = [];
999
1503
  const failedWorkspaceDirs = [];
1000
1504
  for (const key of keys) {
1001
- const r = await reclaimProject(key, {
1002
- deleteOwnedDevices: true,
1003
- parkOwnedDevices: true,
1004
- preserveProjectRecord: preserveRootProject && key === rootPath
1005
- });
1505
+ let r;
1506
+ try {
1507
+ r = await reclaimProject(key, {
1508
+ deleteOwnedDevices: true,
1509
+ parkOwnedDevices: true,
1510
+ preserveProjectRecord: preserveRootProject && key === rootPath
1511
+ });
1512
+ } catch (error) {
1513
+ keptEntries.push(key);
1514
+ retainedResources.push({
1515
+ name: "ownership state",
1516
+ reason: String(error?.message || error),
1517
+ project: key
1518
+ });
1519
+ continue;
1520
+ }
1006
1521
  dereferenced.push(...r.dereferenced);
1007
1522
  if (r.killedPid) killedPids.push(r.killedPid);
1008
1523
  deletedDevices.push(...r.deletedDevices);
@@ -1116,30 +1631,32 @@ function removalPath(target) {
1116
1631
  return canonicalExistingPath(target ?? process.cwd());
1117
1632
  }
1118
1633
  function canonicalExistingPath(target) {
1119
- const resolved = resolve$1(target);
1634
+ const resolved = resolve(target);
1120
1635
  let existing = resolved;
1121
1636
  const missing = [];
1122
1637
  while (!existsSync$1(existing)) {
1123
- const parent = dirname$1(existing);
1638
+ const parent = dirname(existing);
1124
1639
  if (parent === existing) return resolved;
1125
- missing.unshift(basename$1(existing));
1640
+ missing.unshift(basename(existing));
1126
1641
  existing = parent;
1127
1642
  }
1128
- return resolve$1(realpathSync$1(existing), ...missing);
1643
+ return resolve(realpathSync$1(existing), ...missing);
1129
1644
  }
1130
1645
  function inspectRemoval(path) {
1131
1646
  const gitAnswered = hasUncommittedWork(path);
1132
1647
  const { lines: dirtyLines, restore: podChurn } = excludePodChurn(gitAnswered ? dirtyPaths(path, { limit: Infinity }) : []);
1133
1648
  const dirty = gitAnswered === null ? null : dirtyLines.length > 0;
1134
1649
  const unpushed = unpushedCommits(path);
1650
+ const blockers = removalBlockers({
1651
+ dirty,
1652
+ unpushed
1653
+ });
1654
+ if (hasPopulatedSubmodules(path)) blockers.push("initialized submodules, which git removes only with --force");
1135
1655
  return {
1136
1656
  dirtyLines,
1137
1657
  podChurn,
1138
1658
  unpushed,
1139
- blockers: removalBlockers({
1140
- dirty,
1141
- unpushed
1142
- })
1659
+ blockers
1143
1660
  };
1144
1661
  }
1145
1662
  function printRemovalRefusal(path, inspection) {
@@ -1171,7 +1688,14 @@ function printRemovalCleanup(result, failed) {
1171
1688
  if (result.dereferenced.length) console.error(chalk.dim(phaseLine("workspace", `no longer referenced: ${result.dereferenced.join(", ")}`)));
1172
1689
  for (const skipped of result.skippedDevices) console.error((failed ? chalk.dim : chalk.yellow)(phaseLine("device", `kept ${describeKeptDevice(skipped)} (${skipped.reason})`)));
1173
1690
  }
1174
- async function runRemove(target, opts = {}) {
1691
+ async function removeWorktreeTarget(target, opts = {}) {
1692
+ let removed = false;
1693
+ await runRemove(target, opts, () => {
1694
+ removed = true;
1695
+ });
1696
+ return removed;
1697
+ }
1698
+ async function runRemove(target, opts, onRemoved) {
1175
1699
  const poolError = parkedMaxSetting("ios").error || parkedMaxSetting("android").error;
1176
1700
  if (poolError) {
1177
1701
  console.error(chalk.red(poolError));
@@ -1196,7 +1720,7 @@ async function runRemove(target, opts = {}) {
1196
1720
  console.error(chalk.dim(phaseLine("branch", `${branch} is already absent; cleared its pending cleanup record`)));
1197
1721
  return;
1198
1722
  }
1199
- const checkedOutAt = listWorktrees(mainRoot).find((candidate) => candidate.branch === branch && resolve$1(candidate.path) !== resolve$1(path))?.path;
1723
+ const checkedOutAt = listWorktrees(mainRoot).find((candidate) => candidate.branch === branch && resolve(candidate.path) !== resolve(path))?.path;
1200
1724
  if (checkedOutAt) {
1201
1725
  console.error(chalk.red(`Refusing pending cleanup for ${branch}: it is checked out at ${checkedOutAt}.`));
1202
1726
  console.error(chalk.dim("Switch that worktree to another branch, then retry this command."));
@@ -1228,7 +1752,7 @@ async function runRemove(target, opts = {}) {
1228
1752
  const worktrees = listWorktrees(path);
1229
1753
  const entry = matchWorktreeEntry(worktrees, path);
1230
1754
  if (!entry) {
1231
- if (gitCommonDir(path) === null && hasRegisteredProjectUnder(path)) {
1755
+ if (!opts.linkedOnly && gitCommonDir(path) === null && hasRegisteredProjectUnder(path)) {
1232
1756
  await reclaimEnvironment(path, "it is not a git repository");
1233
1757
  return;
1234
1758
  }
@@ -1249,6 +1773,11 @@ async function runRemove(target, opts = {}) {
1249
1773
  process.exitCode = 1;
1250
1774
  return;
1251
1775
  }
1776
+ if (entry.path === source.path && opts.linkedOnly) {
1777
+ console.error(chalk.red(`Refusing to remove ${path}: ${entry.path} is the source checkout.`));
1778
+ process.exitCode = 1;
1779
+ return;
1780
+ }
1252
1781
  if (entry.path === source.path) {
1253
1782
  if (entry.path !== path) console.error(chalk.dim(`${path} is inside the source checkout ${entry.path}; reclaiming its environment.`));
1254
1783
  await reclaimEnvironment(entry.path, "it is the source checkout");
@@ -1258,6 +1787,12 @@ async function runRemove(target, opts = {}) {
1258
1787
  console.error(chalk.dim(`${path} is inside the worktree ${entry.path}; removing that.`));
1259
1788
  path = entry.path;
1260
1789
  }
1790
+ if (entry.locked) {
1791
+ console.error(chalk.red(`Refusing to remove ${path}: git has it locked.`));
1792
+ console.error(chalk.dim(`Unlock it, then retry: git -C ${source.path} worktree unlock ${path}`));
1793
+ process.exitCode = 1;
1794
+ return;
1795
+ }
1261
1796
  const project = getProject(path);
1262
1797
  const branch = entry.branch;
1263
1798
  const ownsBranch = Boolean(branch && project?.worktreeBranchOwned === true && project.worktreeBranch === branch);
@@ -1271,12 +1806,18 @@ async function runRemove(target, opts = {}) {
1271
1806
  const retainedBranchReason = !branch ? null : !ownsBranch ? "Stim did not create it" : inspection.unpushed === null ? "Stim could not verify whether it has unique commits" : inspection.unpushed.length > 0 ? `it has ${plural(inspection.unpushed.length, "unique commit")}` : null;
1272
1807
  const branchDeleteCwd = source.path;
1273
1808
  await withManagedRemoteWorktreeRemovalLock(path, () => withReclaimLocks(path, async (lockedKeys) => {
1809
+ if (opts.guard?.(lockedKeys).length) return;
1810
+ const current = inspectRemoval(path);
1811
+ if (current.blockers.length && !opts.force) {
1812
+ printRemovalRefusal(path, current);
1813
+ return;
1814
+ }
1274
1815
  const result = await reclaimAll(path, lockedKeys, { preserveRootProject: true });
1275
1816
  if (result.keptEntries.length) {
1276
1817
  reportRetainedResources(path, result);
1277
1818
  return;
1278
1819
  }
1279
- restorePodChurn(path, inspection.podChurn);
1820
+ restorePodChurn(path, current.podChurn);
1280
1821
  try {
1281
1822
  removeWorktree(path, {
1282
1823
  from: source.path,
@@ -1291,6 +1832,7 @@ async function runRemove(target, opts = {}) {
1291
1832
  process.exitCode = 1;
1292
1833
  return;
1293
1834
  }
1835
+ onRemoved();
1294
1836
  const finish = () => {
1295
1837
  printRemovalCleanup(result, false);
1296
1838
  console.error(chalk.dim(phaseLine("removed", path)));
@@ -1306,7 +1848,7 @@ async function runRemove(target, opts = {}) {
1306
1848
  worktreeRemovalComplete: true,
1307
1849
  worktreePendingBranchSha: approvedBranchSha
1308
1850
  });
1309
- const checkedOutAt = listWorktrees(branchDeleteCwd).find((candidate) => candidate.branch === branch && resolve$1(candidate.path) !== resolve$1(path))?.path;
1851
+ const checkedOutAt = listWorktrees(branchDeleteCwd).find((candidate) => candidate.branch === branch && resolve(candidate.path) !== resolve(path))?.path;
1310
1852
  if (checkedOutAt) {
1311
1853
  console.error(chalk.yellow(phaseLine("branch", `kept ${branch} (it is checked out at ${checkedOutAt})`)));
1312
1854
  console.error(chalk.dim(` Switch that worktree to another branch, then retry: stim worktree remove ${path}`));
@@ -1330,7 +1872,9 @@ async function runRemove(target, opts = {}) {
1330
1872
  }));
1331
1873
  }
1332
1874
  function registerRemove(worktree) {
1333
- worktree.command("remove [target]").description("Remove a worktree, its unused Stim-created branch, build artifacts, owned devices, and Metro port. Defaults to the current workspace. On the source checkout it reclaims the environment only and leaves the tree in place.").option("--force", "remove even when the worktree holds uncommitted or unpushed work").action(runRemove);
1875
+ worktree.command("remove [target]").description("Remove a worktree, its unused Stim-created branch, build artifacts, owned devices, and Metro port. Defaults to the current workspace. On the source checkout it reclaims the environment only and leaves the tree in place.").option("--force", "remove even when the worktree holds uncommitted or unpushed work or initialized submodules").action(async (target, opts) => {
1876
+ await removeWorktreeTarget(target, { force: opts.force });
1877
+ });
1334
1878
  }
1335
1879
  function worktreeCommand(program) {
1336
1880
  const worktree = program.command("worktree").description("Warm and remove Git worktrees");
@@ -1338,4 +1882,4 @@ function worktreeCommand(program) {
1338
1882
  registerRemove(worktree);
1339
1883
  }
1340
1884
  //#endregion
1341
- export { worktreeCommand as default, dependencyInstallCommand, excludePodChurn, matchWorktreeEntry, porcelainPath, registerRemove, registerWarm, removalBlockers, removalPath, removalRemedy };
1885
+ export { workspaceInUse as _, reclaimKeys as a, removalBlockers as c, removeWorktreeTarget as d, worktreeCommand as f, withIdleWorkspace as g, emptyWorkspaceDir as h, porcelainPath as i, removalPath as l, reclaimProject as m, excludePodChurn as n, registerRemove as o, describeDereferenced as p, matchWorktreeEntry as r, registerWarm as s, dependencyInstallCommand as t, removalRemedy as u, canonicalPath as v };