stim 1.8.0 → 1.10.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 (91) hide show
  1. package/README.md +23 -155
  2. package/dist/{android-DWssqHQ8.mjs → android-787HhP5w.mjs} +168 -39
  3. package/dist/{android-BRgBYzU0.mjs → android-DBkeHwpm.mjs} +250 -200
  4. package/dist/{android-DeYdjjBP.mjs → android-DPov-fv2.mjs} +2 -2
  5. package/dist/{android-cas-SWIjzeuL.mjs → android-cas-CITmIeWY.mjs} +22 -14
  6. package/dist/android-cas-compiler.mjs +2 -2
  7. package/dist/app-install-CrJ6t_bv.mjs +819 -0
  8. package/dist/budget-BP4jZZL4.mjs +620 -0
  9. package/dist/build-progress-BXx-ZBe4.mjs +512 -0
  10. package/dist/{cache-manifest-CYJi8-Hx.mjs → cache-manifest-Pubfp8wV.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 +7 -1
  14. package/dist/collector-run.mjs +10 -9
  15. package/dist/{command-output-B3ycpsYv.mjs → command-output-D5z2_bcF.mjs} +11 -2
  16. package/dist/{config-B7iyKWkL.mjs → config-BEq0Qw_a.mjs} +134 -121
  17. package/dist/{dependency-state-D0M7oKA7.mjs → dependency-state-r9UesA9a.mjs} +2 -2
  18. package/dist/{deps-oCXVNEqQ.mjs → deps-DP4eWqea.mjs} +8 -5
  19. package/dist/{detached-entry-Bpo6ZY_I.mjs → detached-entry-DJHoca49.mjs} +1 -1
  20. package/dist/{device-BUoZyMoR.mjs → dev-client-92U593KT.mjs} +1090 -660
  21. package/dist/{device-RdujjHLB.mjs → device-DhTZe7L4.mjs} +13 -20
  22. package/dist/{build-lock-CgtsB6TV.mjs → device-lease-CbEaIk7b.mjs} +481 -27
  23. package/dist/{device-pool-BxRwdufO.mjs → device-pool-Di8Q-9Ro.mjs} +7 -7
  24. package/dist/{device-remote-ByN8P6CB.mjs → device-remote-Dy1jGj3U.mjs} +645 -119
  25. package/dist/{doctor-BMt156qe.mjs → doctor-B-inAJmt.mjs} +137 -113
  26. package/dist/{error-diagnostics-D1Kaf-eT.mjs → error-diagnostics-xxWKNO3-.mjs} +22 -17
  27. package/dist/exec-DK8KaVHK.mjs +133 -0
  28. package/dist/{gc-v2YcoJvm.mjs → gc-ChPFZMRC.mjs} +836 -813
  29. package/dist/{guide-BTAlD1_y.mjs → guide-DwmaD_Wa.mjs} +953 -209
  30. package/dist/guide-status-CWvti2Lg.mjs +125 -0
  31. package/dist/idle-DpRLp5lj.mjs +157 -0
  32. package/dist/{build-slots-C-oAHRT1.mjs → in-use-CjUluqIX.mjs} +102 -8
  33. package/dist/{ios-BcCiI4TS.mjs → ios-B2Fpqvlb.mjs} +60 -71
  34. package/dist/{ios-BP04pY5f.mjs → ios-Dd-hYJBh.mjs} +241 -234
  35. package/dist/{ios-device-CFXA46ez.mjs → ios-device-BiOsSVzd.mjs} +3 -2
  36. package/dist/{ios-device-CauFJiYx.mjs → ios-device-CtRdEVjx.mjs} +149 -52
  37. package/dist/launch-verify-CQS_Kg-C.mjs +365 -0
  38. package/dist/{logs-CakdBTtm.mjs → logs-MARIy6m9.mjs} +20 -15
  39. package/dist/metro-BWLOJ_Wr.mjs +409 -0
  40. package/dist/{named-ports-DzMOlmaX.mjs → named-ports-DiTfSz6p.mjs} +4 -4
  41. package/dist/{ndjson-BZwI1NBv.mjs → ndjson-CDY9IkVC.mjs} +23 -43
  42. package/dist/{ndjson-DvHSsNU9.d.mts → ndjson-Du3j0AQ9.d.mts} +1 -0
  43. package/dist/{ownership-Cp52QAK1.mjs → ownership-D2xbLVoz.mjs} +2 -3
  44. package/dist/{teardown-BJqEzPo_.mjs → ownership-DZjdx8Gq.mjs} +664 -612
  45. package/dist/{ports-BIbJaKNW.mjs → ports-CLSSFOuz.mjs} +3 -3
  46. package/dist/{process-output-Dl5uKE03.mjs → process-output-Fpl8PYi8.mjs} +1 -1
  47. package/dist/{project-DDZWf_16.mjs → project-BXZzOABt.mjs} +8 -3
  48. package/dist/{reload-BdypPbjM.mjs → reload-DoETJ3rl.mjs} +11 -13
  49. package/dist/{server-bare-BQkyxa_b.mjs → server-bare-DVbb7kXH.mjs} +2 -2
  50. package/dist/{server-expo-BVAA1OqJ.mjs → server-expo-BvOCeakp.mjs} +8 -7
  51. package/dist/server-expo-DBVTdult.mjs +2 -0
  52. package/dist/{settings-BXXorVM2.mjs → settings-DOugPlPz.mjs} +59 -159
  53. package/dist/settings-EWYNwe72.mjs +378 -0
  54. package/dist/settings.schema.json +1064 -0
  55. package/dist/simslim-Ca3487H6.mjs +436 -0
  56. package/dist/{slot-launch-ZUUe0TQP.mjs → slot-launch-C5cg0fmk.mjs} +4 -4
  57. package/dist/spawn-claims-CUleC2cM.mjs +49 -0
  58. package/dist/{start-CmxtOKjU.mjs → start-F9gv5dcC.mjs} +159 -114
  59. package/dist/start-VoFsPSes.mjs +2 -0
  60. package/dist/{state-DZobzxLW.mjs → state-WGdIbi_d.mjs} +5 -3
  61. package/dist/{stats-BYcZ6n1h.mjs → stats-BAgJaLA4.mjs} +66 -5
  62. package/dist/{stats-CLg5RuDS.mjs → stats-DBYJ5bxL.mjs} +4 -4
  63. package/dist/status-DLCD-o3s.mjs +764 -0
  64. package/dist/status-Dr4fRkym.mjs +216 -0
  65. package/dist/{guide-status-Craq9z0t.mjs → stim-installations-CINXy_su.mjs} +3 -122
  66. package/dist/{stop-C8JW6kKp.mjs → stop-Dj4pfmyv.mjs} +111 -31
  67. package/dist/{stop-B1kOpYei.mjs → stop-ZvOeTmKb.mjs} +2 -2
  68. package/dist/supervisor-run.d.mts +11 -2
  69. package/dist/supervisor-run.mjs +228 -42
  70. package/dist/{workspace-process-lock-BYfZz_S-.mjs → workspace-process-lock-BXA96myD.mjs} +14 -9
  71. package/dist/workspaces-BTUyFfkv.mjs +1003 -0
  72. package/dist/worktree-4aZg2NnI.mjs +2 -0
  73. package/dist/{worktree-CFLA-6MX.mjs → worktree-BMS7GeqB.mjs} +523 -61
  74. package/dist/{worktree-FG_ZKKdb.mjs → worktree-BlizKDpZ.mjs} +200 -54
  75. package/dist/{xcode-B6YaWeg0.mjs → xcode-B2tBkf0m.mjs} +66 -57
  76. package/package.json +6 -6
  77. package/skill/SKILL.md +2 -0
  78. package/dist/app-install-BDbsSgCl.mjs +0 -1175
  79. package/dist/dev-client-BmT-ioFI.mjs +0 -797
  80. package/dist/device-lease-Ffz_M5t0.mjs +0 -359
  81. package/dist/exec-B29XNuhP.mjs +0 -77
  82. package/dist/logs-query-BGL-Pl12.mjs +0 -290
  83. package/dist/metro-C7bcJ0uo.mjs +0 -233
  84. package/dist/ownership-wolE-0Do.mjs +0 -470
  85. package/dist/public-http-probe-CyMDrQ3c.mjs +0 -61
  86. package/dist/reclaim-KJ-0uWEF.mjs +0 -361
  87. package/dist/server-expo-YDDG3eP7.mjs +0 -2
  88. package/dist/spawn-entry-nCE6m770.mjs +0 -14
  89. package/dist/status-DdkLki47.mjs +0 -426
  90. package/dist/workspace-state-BT5E3SA2.mjs +0 -146
  91. package/dist/{metro-store-CDDZHtPl.mjs → metro-store-DzeWkfqW.mjs} +1 -1
@@ -1,23 +1,418 @@
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";
1
+ import { D as process_identity_exports, H 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-BEq0Qw_a.mjs";
2
+ import { t as getExecutor } from "./exec-DK8KaVHK.mjs";
3
+ import { O as workspaceDir, c as signalProcessTree, h as recordWorkspaceUse, i as pidExists, m as readWorkspaceState, s as resolveProjectMetro, t as killMetroTree } from "./metro-BWLOJ_Wr.mjs";
4
+ import { C as sourceCheckoutOf, S as restoreFile, T as warmWorktreePaths, _ as readWorktreeExclude, b as resolveFullRef, d as hasUncommittedWork, f as isPodInstallChurn, g as podsOutOfSync, h as locallyKnownUpstream, i as depsOutOfSync, l as hasPopulatedSubmodules, m as listWorktrees, n as cloneIgnoredEntries, o as dirtyPaths, r as deleteBranch, s as gitCommonDir, t as branchExists, u as hasRemote, v as removeWorktree, w as unpushedCommits } from "./worktree-BlizKDpZ.mjs";
5
+ import { o as findProjectRoot } from "./project-BXZzOABt.mjs";
6
+ import { a as clearRemoteSession, i as clearManagedMetroTunnel, l as readRemoteSession, s as readMetroTunnel, u as readRemoteSessionId } from "./state-WGdIbi_d.mjs";
7
+ import { a as formatElapsed, h as shortUdid, i as formatDuration, l as phaseLine, p as releasedLeaseFact, u as plural } from "./command-output-D5z2_bcF.mjs";
8
+ import { u as releaseWorkspaceLeases } from "./device-lease-CbEaIk7b.mjs";
9
+ import { L as unknownSettingKeys, P as settingShapeErrors, a as SETTING_SHAPE_REMEDY, j as resolveSettings } from "./settings-DOugPlPz.mjs";
10
+ import { d as teardownOwnedAvd, f as teardownOwnedIosSim, h as POOL_SETTING_REMEDY, i as stopTunnel, l as withManagedTunnelRemovalLock, s as withManagedRemoteWorktreeRemovalLock, t as resolveSupervisorTarget, y as parkedMaxSetting } from "./ownership-DZjdx8Gq.mjs";
11
+ import { n as withIdleWorkspace, t as emptyWorkspaceDir } from "./in-use-CjUluqIX.mjs";
12
+ import { n as verifyCollectorOwnership } from "./ownership-D2xbLVoz.mjs";
13
+ import { i as endRecordedSession, z as resolveEasCliBin } from "./device-remote-Dy1jGj3U.mjs";
14
+ import { h as podInstallCommand, n as HEARTBEAT_INTERVAL_MS } from "./xcode-B2tBkf0m.mjs";
15
+ import { n as hasInstalledDependencies, r as installedNpmTreeIsValid, t as dependencyState } from "./dependency-state-r9UesA9a.mjs";
16
+ import { t as clearNamedPorts } from "./named-ports-DiTfSz6p.mjs";
17
+ import { a as runPodInstall, i as runCaptured, n as podsAreStale, r as readPodState, t as DEPS_ERROR } from "./deps-DP4eWqea.mjs";
14
18
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
15
- import { createHash, randomUUID } from "node:crypto";
16
- import { join, relative } from "node:path";
17
19
  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";
20
+ import { basename, dirname, isAbsolute, relative, resolve } from "path";
19
21
  import { workspaceName } from "@stim-cli/core";
22
+ import { join as join$1, relative as relative$1 } from "node:path";
23
+ import { createHash, randomUUID } from "node:crypto";
20
24
  import chalk from "chalk";
25
+ //#region src/devices/reclaim.ts
26
+ async function reapCollectors(root, { verify = verifyCollectorOwnership, collectors }) {
27
+ const skippedDevices = [];
28
+ const failedDevices = [];
29
+ for (const [platform, record] of Object.entries(collectors)) {
30
+ const rec = record;
31
+ const pid = rec?.pid;
32
+ if (typeof pid !== "number" || pid <= 0 || pid === process.pid || !pidExists(pid)) continue;
33
+ const ownership = verify({
34
+ pid,
35
+ platform,
36
+ root,
37
+ expected: rec
38
+ });
39
+ if (ownership.status === "gone") continue;
40
+ if (ownership.status === "unverified") {
41
+ const name = `${platform} log collector (pid ${pid})`;
42
+ const entry = {
43
+ platform: platform.split(":")[0] === "android" ? "android" : "ios",
44
+ name,
45
+ reason: `${ownership.reason}; keeping the record without signalling the process`
46
+ };
47
+ skippedDevices.push(entry);
48
+ failedDevices.push(entry);
49
+ continue;
50
+ }
51
+ try {
52
+ process.kill(pid, "SIGTERM");
53
+ if (await (0, process_identity_exports.waitForProcessExit)(rec, 5e3)) continue;
54
+ } catch (error) {
55
+ if (error.code === "ESRCH") continue;
56
+ }
57
+ const entry = {
58
+ platform: platform.split(":")[0] === "android" ? "android" : "ios",
59
+ name: `${platform} log collector (pid ${pid})`,
60
+ reason: "Collector exit could not be confirmed; keeping its ownership record."
61
+ };
62
+ skippedDevices.push(entry);
63
+ failedDevices.push(entry);
64
+ }
65
+ return {
66
+ skippedDevices,
67
+ failedDevices
68
+ };
69
+ }
70
+ function reclaimRemoteSession(root, { stopSession = defaultStopSession } = {}) {
71
+ const sessionId = readRemoteSessionId(root);
72
+ if (!sessionId) return {
73
+ stopped: null,
74
+ failed: null
75
+ };
76
+ let result;
77
+ try {
78
+ result = stopSession(root, sessionId);
79
+ } catch (err) {
80
+ result = {
81
+ status: "failed",
82
+ reason: String(err?.message ?? err)
83
+ };
84
+ }
85
+ if (result.status === "torn-down") {
86
+ if (result.reason) return {
87
+ stopped: sessionId,
88
+ failed: {
89
+ platform: "ios",
90
+ name: `remote session ${sessionId}`,
91
+ reason: `${result.reason} The session is stopped. Re-run cleanup to reconcile its retained ownership claim.`
92
+ }
93
+ };
94
+ clearRemoteSession(root, sessionId);
95
+ return {
96
+ stopped: sessionId,
97
+ failed: null
98
+ };
99
+ }
100
+ return {
101
+ stopped: null,
102
+ failed: {
103
+ platform: "ios",
104
+ name: `remote session ${sessionId}`,
105
+ 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}`
106
+ }
107
+ };
108
+ }
109
+ function defaultStopSession(root, sessionId) {
110
+ return endRecordedSession({
111
+ root,
112
+ sessionId,
113
+ easBin: resolveEasCliBin(root)?.file ?? null
114
+ });
115
+ }
116
+ function defaultStopMetroTunnel(record) {
117
+ return stopTunnel(record);
118
+ }
119
+ async function reclaimMetroTunnel(root, { stopMetroTunnel = defaultStopMetroTunnel } = {}) {
120
+ const record = readMetroTunnel(root);
121
+ if (record?.kind !== "managed") return {
122
+ stopped: null,
123
+ failed: null
124
+ };
125
+ let result;
126
+ try {
127
+ result = await stopMetroTunnel(record);
128
+ } catch (err) {
129
+ result = {
130
+ status: "failed",
131
+ reason: String(err?.message ?? err)
132
+ };
133
+ }
134
+ if (result.status === "failed") return {
135
+ stopped: null,
136
+ failed: {
137
+ platform: "ios",
138
+ name: `${record.provider} tunnel (pid ${record.pid})`,
139
+ 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\`.`
140
+ }
141
+ };
142
+ if (!clearManagedMetroTunnel(root, record)) return {
143
+ stopped: null,
144
+ failed: {
145
+ platform: "ios",
146
+ name: "replacement managed tunnel",
147
+ reason: "A replacement managed tunnel record appeared during cleanup and is retained for a later stop."
148
+ }
149
+ };
150
+ return {
151
+ stopped: record.provider,
152
+ failed: null
153
+ };
154
+ }
155
+ function describeDereferenced(project) {
156
+ const devices = Object.entries(project?.ports ?? {}).map(([label, port]) => `port ${label} (${port})`);
157
+ for (const { platforms } of projectDeviceSlots(project)) {
158
+ const ios = platforms.ios;
159
+ if (ios?.deviceUdid) devices.push(`ios sim ${ios.deviceUdid}`);
160
+ const android = platforms.android;
161
+ if (android?.avdName) devices.push(`android avd ${android.avdName}`);
162
+ else if (android?.serial) devices.push(`android device ${android.serial}`);
163
+ }
164
+ return devices;
165
+ }
166
+ function parkRequest(projectPath, slot, simslimManaged) {
167
+ const { max, error } = parkedMaxSetting("ios");
168
+ if (error || max <= 0) return void 0;
169
+ return {
170
+ projectPath,
171
+ max,
172
+ simslimManaged,
173
+ slot
174
+ };
175
+ }
176
+ function reclaimOwnedDevices(project, projectPath, { park = false } = {}) {
177
+ const deletedDevices = [];
178
+ const parkedDevices = [];
179
+ const evictedDevices = [];
180
+ const poolNotes = [];
181
+ const skippedDevices = [];
182
+ const failedDevices = [];
183
+ for (const { slot, platforms } of projectDeviceSlots(project)) {
184
+ const ios = platforms.ios;
185
+ if (ios?.owned && ios.deviceUdid) {
186
+ const udid = ios.deviceUdid;
187
+ const label = ios.deviceName || udid;
188
+ const r = teardownOwnedIosSim(udid, {
189
+ del: true,
190
+ label,
191
+ ...park ? { park: parkRequest(projectPath, slot, Boolean(ios.simslimManaged)) } : {}
192
+ });
193
+ if (r.parkFallback) poolNotes.push(`could not park ${label}: ${r.parkFallback} -- deleted it instead`);
194
+ for (const failure of r.evictionFailures ?? []) poolNotes.push(failure);
195
+ if (r.parked) {
196
+ parkedDevices.push(r.parked);
197
+ evictedDevices.push(...r.evicted ?? []);
198
+ } else if (r.status === "torn-down") deletedDevices.push(r.label);
199
+ if (!r.parked && (r.status === "torn-down" || r.status === "missing")) clearDevice(projectPath, "ios", slot, udid);
200
+ if (r.status === "skipped") skippedDevices.push({
201
+ platform: "ios",
202
+ name: label,
203
+ udid,
204
+ reason: `${r.reason} -- not touched`
205
+ });
206
+ else if (r.status === "failed") {
207
+ const entry = {
208
+ platform: "ios",
209
+ name: label,
210
+ udid,
211
+ reason: `teardown failed: ${r.reason}`
212
+ };
213
+ skippedDevices.push(entry);
214
+ failedDevices.push(entry);
215
+ }
216
+ }
217
+ const android = platforms.android;
218
+ if (android?.owned && android.avdName) {
219
+ const bound = parkedMaxSetting("android");
220
+ const r = teardownOwnedAvd(android.avdName, {
221
+ del: true,
222
+ owner: {
223
+ projectPath,
224
+ slot
225
+ },
226
+ ...park && !bound.error && bound.max > 0 ? { park: {
227
+ projectPath,
228
+ slot,
229
+ max: bound.max,
230
+ configuration: typeof android.poolConfiguration === "string" ? android.poolConfiguration : void 0
231
+ } } : {}
232
+ });
233
+ if (r.parkFallback) poolNotes.push(`could not park ${android.avdName}: ${r.parkFallback} -- deleted it instead`);
234
+ for (const failure of r.evictionFailures ?? []) poolNotes.push(failure);
235
+ if (!r.parked && (r.status === "torn-down" || r.status === "missing")) clearDevice(projectPath, "android", slot, android.avdName);
236
+ if (r.parked) {
237
+ parkedDevices.push(r.parked);
238
+ evictedDevices.push(...r.evicted ?? []);
239
+ } else if (r.status === "torn-down") deletedDevices.push(android.avdName);
240
+ else if (r.status === "skipped") skippedDevices.push({
241
+ platform: "android",
242
+ name: android.avdName,
243
+ reason: `${r.reason} -- not touched`
244
+ });
245
+ else if (r.status === "failed") {
246
+ const entry = {
247
+ platform: "android",
248
+ name: android.avdName,
249
+ reason: `teardown failed: ${r.reason}`
250
+ };
251
+ skippedDevices.push(entry);
252
+ failedDevices.push(entry);
253
+ }
254
+ }
255
+ }
256
+ return {
257
+ deletedDevices,
258
+ parkedDevices,
259
+ evictedDevices,
260
+ poolNotes,
261
+ skippedDevices,
262
+ failedDevices
263
+ };
264
+ }
265
+ async function reclaimProject(path, options = {}) {
266
+ const dir = workspaceDir(path);
267
+ const existed = existsSync(dir);
268
+ const run = await withIdleWorkspace(path, () => reclaimIdleProject(path, existed, options), {
269
+ purpose: "workspace removal",
270
+ supervisor: false,
271
+ managedLocks: false
272
+ });
273
+ if (run.ran) return run.value;
274
+ const kept = {
275
+ name: `workspace ${dir}`,
276
+ reason: `kept because ${run.reasons.join("; ")}`
277
+ };
278
+ return {
279
+ path,
280
+ dereferenced: [],
281
+ killedPid: null,
282
+ skippedMetro: null,
283
+ metroPort: getProject(path)?.metroPort ?? null,
284
+ deletedDevices: [],
285
+ parkedDevices: [],
286
+ evictedDevices: [],
287
+ poolNotes: [],
288
+ skippedDevices: [kept],
289
+ failedDevices: [kept],
290
+ keptEntry: true,
291
+ stoppedSession: null,
292
+ stoppedTunnel: null,
293
+ releasedLeases: [],
294
+ removedWorkspaceDirs: [],
295
+ failedWorkspaceDirs: []
296
+ };
297
+ }
298
+ async function reclaimIdleProject(path, workspaceExisted, { deleteOwnedDevices = false, parkOwnedDevices = false, preserveProjectRecord = false, stopSession = defaultStopSession, stopMetroTunnel = defaultStopMetroTunnel, releaseLeases = releaseWorkspaceLeases, verifyCollector = verifyCollectorOwnership }) {
299
+ await clearNamedPorts(path, { stop: true });
300
+ const project = getProject(path);
301
+ for (const { platforms } of projectDeviceSlots(project)) if (platforms.android?.owned && platforms.android.avdName) (0, ownership_claim_exports.releaseClaim)(acquireAvdClaim(platforms.android.avdName));
302
+ const dereferenced = describeDereferenced(project);
303
+ const initialState = readWorkspaceState(path);
304
+ let killedPid = null;
305
+ let skippedMetro = null;
306
+ let supervisorHeld = false;
307
+ const supervisor = resolveSupervisorTarget({
308
+ state: initialState?.supervisor,
309
+ record: project?.supervisor,
310
+ reservedPort: project?.metroPort
311
+ });
312
+ const orphanServer = {
313
+ pid: Number(initialState?.supervisor?.serverPid),
314
+ processToken: initialState?.supervisor?.serverProcessToken
315
+ };
316
+ const orphanIdentity = supervisor.status === "stale" && typeof orphanServer.processToken === "string" ? (0, process_identity_exports.inspectProcessIdentity)(orphanServer) : null;
317
+ if (supervisor.status === "ours") {
318
+ if (killMetroTree(supervisor.pid, supervisor.processToken) && await (0, process_identity_exports.waitForProcessExit)(supervisor, 1e4)) killedPid = supervisor.pid;
319
+ else {
320
+ skippedMetro = `could not confirm supervisor pid ${supervisor.pid} exited`;
321
+ supervisorHeld = true;
322
+ }
323
+ } else if (supervisor.status === "unverified") {
324
+ skippedMetro = supervisor.reason ?? "supervisor identity could not be verified";
325
+ supervisorHeld = true;
326
+ } else if (orphanIdentity === "same") {
327
+ let signalled = false;
328
+ try {
329
+ signalled = signalProcessTree(orphanServer.pid, "SIGTERM");
330
+ } catch {}
331
+ if (signalled ? await (0, process_identity_exports.waitForProcessExit)(orphanServer, 1e4) : ["gone", "different"].includes((0, process_identity_exports.inspectProcessIdentity)(orphanServer))) killedPid = orphanServer.pid;
332
+ else {
333
+ skippedMetro = `could not confirm dev server pid ${orphanServer.pid} left by the supervisor exited`;
334
+ supervisorHeld = true;
335
+ }
336
+ } else if (orphanIdentity === "unknown") {
337
+ skippedMetro = `the identity of dev server pid ${orphanServer.pid} left by the supervisor could not be verified`;
338
+ supervisorHeld = true;
339
+ } else if (typeof project?.metroPort === "number") {
340
+ if (!(await resolveProjectMetro(project.metroPort, path)).missing) skippedMetro = "Externally started server left alone; no verified Stim supervisor owns it";
341
+ }
342
+ const { skippedDevices: skippedCollectors, failedDevices: failedCollectors } = await reapCollectors(path, {
343
+ verify: verifyCollector,
344
+ collectors: initialState?.collectors ?? {}
345
+ });
346
+ function retainReplacementProcesses() {
347
+ const currentState = readWorkspaceState(path);
348
+ const currentProject = getProject(path);
349
+ 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])))) {
350
+ supervisorHeld = true;
351
+ skippedMetro = "A replacement process appeared during cleanup; its ownership records are retained";
352
+ }
353
+ }
354
+ retainReplacementProcesses();
355
+ const { deletedDevices, parkedDevices, evictedDevices, poolNotes, skippedDevices, failedDevices } = deleteOwnedDevices && !supervisorHeld && failedCollectors.length === 0 ? reclaimOwnedDevices(project, path, { park: parkOwnedDevices }) : {
356
+ deletedDevices: [],
357
+ parkedDevices: [],
358
+ evictedDevices: [],
359
+ poolNotes: [],
360
+ skippedDevices: [],
361
+ failedDevices: []
362
+ };
363
+ skippedDevices.push(...skippedCollectors);
364
+ failedDevices.push(...failedCollectors);
365
+ const remote = reclaimRemoteSession(path, { stopSession });
366
+ if (remote.failed) {
367
+ skippedDevices.push(remote.failed);
368
+ failedDevices.push(remote.failed);
369
+ }
370
+ const tunnel = await reclaimMetroTunnel(path, { stopMetroTunnel });
371
+ if (tunnel.failed) {
372
+ skippedDevices.push(tunnel.failed);
373
+ failedDevices.push(tunnel.failed);
374
+ }
375
+ let releasedLeases = [];
376
+ try {
377
+ releasedLeases = releaseLeases(path);
378
+ } catch {
379
+ releasedLeases = [];
380
+ }
381
+ retainReplacementProcesses();
382
+ const removedWorkspaceDirs = [];
383
+ const failedWorkspaceDirs = [];
384
+ if (failedDevices.length === 0 && !supervisorHeld) {
385
+ const dir = workspaceDir(path);
386
+ if (existsSync(dir)) try {
387
+ emptyWorkspaceDir(dir);
388
+ if (workspaceExisted) removedWorkspaceDirs.push(dir);
389
+ } catch {
390
+ failedWorkspaceDirs.push(dir);
391
+ }
392
+ }
393
+ const keptEntry = supervisorHeld || failedDevices.length > 0 || failedWorkspaceDirs.length > 0;
394
+ if (project && !keptEntry && !preserveProjectRecord) removeProject(path);
395
+ return {
396
+ path,
397
+ dereferenced,
398
+ killedPid,
399
+ skippedMetro,
400
+ metroPort: project?.metroPort ?? null,
401
+ deletedDevices,
402
+ parkedDevices,
403
+ evictedDevices,
404
+ poolNotes,
405
+ skippedDevices,
406
+ failedDevices,
407
+ keptEntry,
408
+ stoppedSession: remote.stopped,
409
+ stoppedTunnel: tunnel.stopped,
410
+ releasedLeases,
411
+ removedWorkspaceDirs,
412
+ failedWorkspaceDirs
413
+ };
414
+ }
415
+ //#endregion
21
416
  //#region src/engine/warm-claim.ts
22
417
  const WARM_CLAIM_LABEL = "worktree warm";
23
418
  const WARM_CLAIM_POLL_MS = 250;
@@ -38,10 +433,10 @@ function defaultSleep(ms) {
38
433
  return new Promise((resolve) => setTimeout(resolve, ms));
39
434
  }
40
435
  function warmClaimsDir() {
41
- return join(getConfigDir(), "warm-locks");
436
+ return join$1(getConfigDir(), "warm-locks");
42
437
  }
43
438
  function warmClaimPath(repositoryRoot) {
44
- return join(warmClaimsDir(), `${workspaceName(repositoryRoot)}.lock`);
439
+ return join$1(warmClaimsDir(), `${workspaceName(repositoryRoot)}.lock`);
45
440
  }
46
441
  function warmCommand(phase) {
47
442
  return phase === "refresh" ? "stim worktree warm --refresh" : "stim worktree warm";
@@ -368,11 +763,11 @@ function stepLine(label, reason, action) {
368
763
  return phaseLine(label, `${reason} -> ${action}`);
369
764
  }
370
765
  function installRecordFile(depsRoot) {
371
- return join(getConfigDir(), "warm-installs", `${workspaceName(depsRoot)}.json`);
766
+ return join$1(getConfigDir(), "warm-installs", `${workspaceName(depsRoot)}.json`);
372
767
  }
373
768
  function lockfileHash(depsRoot, lockfile) {
374
769
  try {
375
- return createHash("sha256").update(readFileSync(join(depsRoot, lockfile))).digest("hex");
770
+ return createHash("sha256").update(readFileSync(join$1(depsRoot, lockfile))).digest("hex");
376
771
  } catch {
377
772
  return null;
378
773
  }
@@ -394,7 +789,7 @@ function readInstallEvidence(depsRoot) {
394
789
  function writeInstallEvidence(depsRoot, lockfile, hash, completed) {
395
790
  const file = installRecordFile(depsRoot);
396
791
  const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`;
397
- mkdirSync(join(getConfigDir(), "warm-installs"), { recursive: true });
792
+ mkdirSync(join$1(getConfigDir(), "warm-installs"), { recursive: true });
398
793
  writeFileSync(tmp, JSON.stringify({
399
794
  lock: lockfile,
400
795
  hash: hash ?? "",
@@ -412,9 +807,9 @@ function restoreInstallEvidence(depsRoot, prior) {
412
807
  else rmSync(installRecordFile(depsRoot), { force: true });
413
808
  }
414
809
  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;
810
+ if (existsSync(join$1(gitDir, "rebase-merge"))) return "rebase";
811
+ if (existsSync(join$1(gitDir, "rebase-apply"))) return existsSync(join$1(gitDir, "rebase-apply", "applying")) ? "am" : "rebase";
812
+ return existsSync(join$1(gitDir, "MERGE_HEAD")) ? "merge" : null;
418
813
  }
419
814
  function readMainCheckoutState(root) {
420
815
  const exec = getExecutor();
@@ -668,7 +1063,7 @@ async function refreshMainCheckout({ root, appDir, settings, emit, spawnFn = nul
668
1063
  const consumed = dependencies ? lockfileHash(dependencies.root, dependencies.lock) : null;
669
1064
  const deps = depsPlan({
670
1065
  lockfile: dependencies?.lock ?? null,
671
- lockfileChanged: dependencies ? pathChangedBetween(root, before, head, gitPath(relative(root, dependencies.root), dependencies.lock)) : false,
1066
+ lockfileChanged: dependencies ? pathChangedBetween(root, before, head, gitPath(relative$1(root, dependencies.root), dependencies.lock)) : false,
672
1067
  installed,
673
1068
  treeValid: dependencies?.lock === "package-lock.json" && installed ? installedNpmTreeIsValid(dependencies.root) : null,
674
1069
  lastInstall: priorInstall,
@@ -700,11 +1095,11 @@ async function refreshMainCheckout({ root, appDir, settings, emit, spawnFn = nul
700
1095
  if (marked) restoreInstallEvidence(marked.root, priorInstall);
701
1096
  emit(stepLine("deps", depsSubject, "skipped"));
702
1097
  }
703
- const app = relative(root, appDir) || ".";
1098
+ const app = relative$1(root, appDir) || ".";
704
1099
  const where = `source ${appDir}: `;
705
1100
  const podState = readPodState(appDir);
706
1101
  const pods = podsPlan({
707
- hasIos: existsSync(join(appDir, "ios")),
1102
+ hasIos: existsSync(join$1(appDir, "ios")),
708
1103
  hasPodfile: podState.hasPodfile,
709
1104
  podfileLockChanged: pathChangedBetween(root, before, head, gitPath(app, "ios", "Podfile.lock")),
710
1105
  stale: podsAreStale(podState.lockText, podState.manifestText)
@@ -735,16 +1130,22 @@ async function refreshMainCheckout({ root, appDir, settings, emit, spawnFn = nul
735
1130
  //#endregion
736
1131
  //#region src/commands/worktree.ts
737
1132
  function dependencyInstallCommand(target, dir = ".") {
738
- const installRoot = resolve$1(target, dir);
1133
+ const installRoot = resolve(target, dir);
739
1134
  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";
1135
+ if (existsSync$1(resolve(installRoot, "pnpm-lock.yaml"))) command = "pnpm install";
1136
+ else if (existsSync$1(resolve(installRoot, "yarn.lock"))) command = "yarn install";
1137
+ else if (existsSync$1(resolve(installRoot, "bun.lock")) || existsSync$1(resolve(installRoot, "bun.lockb"))) command = "bun install";
1138
+ else if (existsSync$1(resolve(installRoot, "package-lock.json"))) command = "npm ci";
744
1139
  return `cd '${installRoot.replaceAll("'", "'\\''")}' && ${command}`;
745
1140
  }
746
1141
  function reportCarriedStateHealth(root, target, copied) {
747
- const staleDeps = depsOutOfSync(root, target, copied);
1142
+ const outOfSync = depsOutOfSync(root, target, copied);
1143
+ for (const d of outOfSync.filter((dependency) => dependency.reason === "installed")) {
1144
+ const where = d.dir === "." ? d.lockfile : `${d.dir}/${d.lockfile}`;
1145
+ const modules = d.dir === "." ? "node_modules" : `${d.dir}/node_modules`;
1146
+ console.error(chalk.yellow(phaseLine("carry", `carried ${modules} was installed from a different ${d.lockfile} than the ${where} on disk here. node_modules is gitignored and cloned from the source checkout; ${d.lockfile} is tracked, so the two can disagree. Run \`${dependencyInstallCommand(target, d.dir)}\` before building, or pod install and native builds see the wrong package versions.`)));
1147
+ }
1148
+ const staleDeps = outOfSync.filter((dependency) => dependency.reason === "lockfile");
748
1149
  if (staleDeps.length) {
749
1150
  const manifests = staleDeps.map((d) => d.dir === "." ? d.lockfile : `${d.dir}/${d.lockfile}`).join(", ");
750
1151
  const remedies = [...new Set(staleDeps.map((dependency) => dependencyInstallCommand(target, dependency.dir)))];
@@ -752,7 +1153,7 @@ function reportCarriedStateHealth(root, target, copied) {
752
1153
  }
753
1154
  for (const p of podsOutOfSync(target, copied)) {
754
1155
  const where = p.dir === "." ? "Podfile.lock" : `${p.dir}/Podfile.lock`;
755
- const pod = podInstallCommand(p.dir === "." ? target : resolve$1(target, p.dir, ".."));
1156
+ const pod = podInstallCommand(p.dir === "." ? target : resolve(target, p.dir, ".."));
756
1157
  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
1158
  }
758
1159
  }
@@ -766,14 +1167,16 @@ function reportRefreshFailure(failure) {
766
1167
  function mainCheckoutAppDir(root, target) {
767
1168
  const app = findProjectRoot(process.cwd());
768
1169
  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);
1170
+ const rel = relative(target, app);
1171
+ if (!rel || rel.startsWith("..") || isAbsolute(rel)) return root;
1172
+ return resolve(root, rel);
772
1173
  }
773
1174
  function registerWarm(worktree) {
774
1175
  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
1176
  try {
776
1177
  const { root, target, common } = warmWorktreePaths(process.cwd());
1178
+ const app = findProjectRoot(process.cwd());
1179
+ if (app) recordWorkspaceUse(app);
777
1180
  const readSettings = () => {
778
1181
  const settings = resolveSettings({
779
1182
  gitCommonDir: common,
@@ -920,10 +1323,20 @@ function excludePodChurn(lines) {
920
1323
  restore
921
1324
  };
922
1325
  }
1326
+ function nativeCanonicalPath(path) {
1327
+ const missing = [];
1328
+ for (let existing = resolve(path);; existing = dirname(existing)) try {
1329
+ return resolve(realpathSync$1.native(existing), ...missing);
1330
+ } catch {
1331
+ if (dirname(existing) === existing) return resolve(path);
1332
+ missing.unshift(basename(existing));
1333
+ }
1334
+ }
923
1335
  function matchWorktreeEntry(entries, path) {
924
1336
  let best = null;
1337
+ const target = nativeCanonicalPath(path);
925
1338
  (entries || []).forEach((entry, index) => {
926
- if (!entry?.path || !isPathPrefix(entry.path, path)) return;
1339
+ if (!entry?.path || !isPathPrefix(nativeCanonicalPath(entry.path), target)) return;
927
1340
  if (!best || entry.path.length > best.path.length) best = {
928
1341
  ...entry,
929
1342
  index
@@ -972,8 +1385,9 @@ function describeKeptDevice(s) {
972
1385
  function reclaimKeys(rootPath) {
973
1386
  const cfg = loadConfig();
974
1387
  const keys = /* @__PURE__ */ new Set([rootPath]);
1388
+ const root = nativeCanonicalPath(rootPath);
975
1389
  if (cfg?.projects) {
976
- for (const key of Object.keys(cfg.projects)) if (isPathPrefix(rootPath, key)) keys.add(key);
1390
+ for (const key of Object.keys(cfg.projects)) if (isPathPrefix(rootPath, key) || isPathPrefix(root, nativeCanonicalPath(key))) keys.add(key);
977
1391
  }
978
1392
  return [...keys].toSorted();
979
1393
  }
@@ -998,11 +1412,22 @@ async function reclaimAll(rootPath, keys = reclaimKeys(rootPath), { preserveRoot
998
1412
  const removedWorkspaceDirs = [];
999
1413
  const failedWorkspaceDirs = [];
1000
1414
  for (const key of keys) {
1001
- const r = await reclaimProject(key, {
1002
- deleteOwnedDevices: true,
1003
- parkOwnedDevices: true,
1004
- preserveProjectRecord: preserveRootProject && key === rootPath
1005
- });
1415
+ let r;
1416
+ try {
1417
+ r = await reclaimProject(key, {
1418
+ deleteOwnedDevices: true,
1419
+ parkOwnedDevices: true,
1420
+ preserveProjectRecord: preserveRootProject && key === rootPath
1421
+ });
1422
+ } catch (error) {
1423
+ keptEntries.push(key);
1424
+ retainedResources.push({
1425
+ name: "ownership state",
1426
+ reason: String(error?.message || error),
1427
+ project: key
1428
+ });
1429
+ continue;
1430
+ }
1006
1431
  dereferenced.push(...r.dereferenced);
1007
1432
  if (r.killedPid) killedPids.push(r.killedPid);
1008
1433
  deletedDevices.push(...r.deletedDevices);
@@ -1116,30 +1541,32 @@ function removalPath(target) {
1116
1541
  return canonicalExistingPath(target ?? process.cwd());
1117
1542
  }
1118
1543
  function canonicalExistingPath(target) {
1119
- const resolved = resolve$1(target);
1544
+ const resolved = resolve(target);
1120
1545
  let existing = resolved;
1121
1546
  const missing = [];
1122
1547
  while (!existsSync$1(existing)) {
1123
- const parent = dirname$1(existing);
1548
+ const parent = dirname(existing);
1124
1549
  if (parent === existing) return resolved;
1125
- missing.unshift(basename$1(existing));
1550
+ missing.unshift(basename(existing));
1126
1551
  existing = parent;
1127
1552
  }
1128
- return resolve$1(realpathSync$1(existing), ...missing);
1553
+ return resolve(realpathSync$1(existing), ...missing);
1129
1554
  }
1130
- function inspectRemoval(path) {
1555
+ function inspectRemoval(path, mergedHead) {
1131
1556
  const gitAnswered = hasUncommittedWork(path);
1132
1557
  const { lines: dirtyLines, restore: podChurn } = excludePodChurn(gitAnswered ? dirtyPaths(path, { limit: Infinity }) : []);
1133
1558
  const dirty = gitAnswered === null ? null : dirtyLines.length > 0;
1134
1559
  const unpushed = unpushedCommits(path);
1560
+ const blockers = removalBlockers({
1561
+ dirty,
1562
+ unpushed: Boolean(mergedHead) && resolveFullRef(path, "HEAD") === mergedHead && unpushed ? [] : unpushed
1563
+ });
1564
+ if (hasPopulatedSubmodules(path)) blockers.push("initialized submodules, which git removes only with --force");
1135
1565
  return {
1136
1566
  dirtyLines,
1137
1567
  podChurn,
1138
1568
  unpushed,
1139
- blockers: removalBlockers({
1140
- dirty,
1141
- unpushed
1142
- })
1569
+ blockers
1143
1570
  };
1144
1571
  }
1145
1572
  function printRemovalRefusal(path, inspection) {
@@ -1171,7 +1598,14 @@ function printRemovalCleanup(result, failed) {
1171
1598
  if (result.dereferenced.length) console.error(chalk.dim(phaseLine("workspace", `no longer referenced: ${result.dereferenced.join(", ")}`)));
1172
1599
  for (const skipped of result.skippedDevices) console.error((failed ? chalk.dim : chalk.yellow)(phaseLine("device", `kept ${describeKeptDevice(skipped)} (${skipped.reason})`)));
1173
1600
  }
1174
- async function runRemove(target, opts = {}) {
1601
+ async function removeWorktreeTarget(target, opts = {}) {
1602
+ let removed = false;
1603
+ await runRemove(target, opts, () => {
1604
+ removed = true;
1605
+ });
1606
+ return removed;
1607
+ }
1608
+ async function runRemove(target, opts, onRemoved) {
1175
1609
  const poolError = parkedMaxSetting("ios").error || parkedMaxSetting("android").error;
1176
1610
  if (poolError) {
1177
1611
  console.error(chalk.red(poolError));
@@ -1196,7 +1630,7 @@ async function runRemove(target, opts = {}) {
1196
1630
  console.error(chalk.dim(phaseLine("branch", `${branch} is already absent; cleared its pending cleanup record`)));
1197
1631
  return;
1198
1632
  }
1199
- const checkedOutAt = listWorktrees(mainRoot).find((candidate) => candidate.branch === branch && resolve$1(candidate.path) !== resolve$1(path))?.path;
1633
+ const checkedOutAt = listWorktrees(mainRoot).find((candidate) => candidate.branch === branch && resolve(candidate.path) !== resolve(path))?.path;
1200
1634
  if (checkedOutAt) {
1201
1635
  console.error(chalk.red(`Refusing pending cleanup for ${branch}: it is checked out at ${checkedOutAt}.`));
1202
1636
  console.error(chalk.dim("Switch that worktree to another branch, then retry this command."));
@@ -1228,7 +1662,7 @@ async function runRemove(target, opts = {}) {
1228
1662
  const worktrees = listWorktrees(path);
1229
1663
  const entry = matchWorktreeEntry(worktrees, path);
1230
1664
  if (!entry) {
1231
- if (gitCommonDir(path) === null && hasRegisteredProjectUnder(path)) {
1665
+ if (!opts.linkedOnly && gitCommonDir(path) === null && hasRegisteredProjectUnder(path)) {
1232
1666
  await reclaimEnvironment(path, "it is not a git repository");
1233
1667
  return;
1234
1668
  }
@@ -1249,6 +1683,11 @@ async function runRemove(target, opts = {}) {
1249
1683
  process.exitCode = 1;
1250
1684
  return;
1251
1685
  }
1686
+ if (entry.path === source.path && opts.linkedOnly) {
1687
+ console.error(chalk.red(`Refusing to remove ${path}: ${entry.path} is the source checkout.`));
1688
+ process.exitCode = 1;
1689
+ return;
1690
+ }
1252
1691
  if (entry.path === source.path) {
1253
1692
  if (entry.path !== path) console.error(chalk.dim(`${path} is inside the source checkout ${entry.path}; reclaiming its environment.`));
1254
1693
  await reclaimEnvironment(entry.path, "it is the source checkout");
@@ -1258,11 +1697,17 @@ async function runRemove(target, opts = {}) {
1258
1697
  console.error(chalk.dim(`${path} is inside the worktree ${entry.path}; removing that.`));
1259
1698
  path = entry.path;
1260
1699
  }
1700
+ if (entry.locked) {
1701
+ console.error(chalk.red(`Refusing to remove ${path}: git has it locked.`));
1702
+ console.error(chalk.dim(`Unlock it, then retry: git -C ${source.path} worktree unlock ${path}`));
1703
+ process.exitCode = 1;
1704
+ return;
1705
+ }
1261
1706
  const project = getProject(path);
1262
1707
  const branch = entry.branch;
1263
1708
  const ownsBranch = Boolean(branch && project?.worktreeBranchOwned === true && project.worktreeBranch === branch);
1264
1709
  const approvedBranchSha = ownsBranch ? resolveFullRef(path, "HEAD") : null;
1265
- const inspection = inspectRemoval(path);
1710
+ const inspection = inspectRemoval(path, opts.mergedHead);
1266
1711
  if (inspection.blockers.length && !opts.force) {
1267
1712
  printRemovalRefusal(path, inspection);
1268
1713
  return;
@@ -1271,12 +1716,26 @@ async function runRemove(target, opts = {}) {
1271
1716
  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
1717
  const branchDeleteCwd = source.path;
1273
1718
  await withManagedRemoteWorktreeRemovalLock(path, () => withReclaimLocks(path, async (lockedKeys) => {
1719
+ if (opts.guard?.(lockedKeys).length) return;
1720
+ const inspectedHead = resolveFullRef(path, "HEAD");
1721
+ const current = inspectRemoval(path, opts.mergedHead);
1722
+ if (current.blockers.length && !opts.force) {
1723
+ printRemovalRefusal(path, current);
1724
+ return;
1725
+ }
1274
1726
  const result = await reclaimAll(path, lockedKeys, { preserveRootProject: true });
1275
1727
  if (result.keptEntries.length) {
1276
1728
  reportRetainedResources(path, result);
1277
1729
  return;
1278
1730
  }
1279
- restorePodChurn(path, inspection.podChurn);
1731
+ if (!opts.force && resolveFullRef(path, "HEAD") !== inspectedHead) {
1732
+ console.error(chalk.red(`Refusing to remove ${path}: its HEAD moved while its environment was reclaimed.`));
1733
+ console.error(chalk.dim(`The directory and Stim ownership record for ${path} were kept.`));
1734
+ printRemovalCleanup(result, true);
1735
+ process.exitCode = 1;
1736
+ return;
1737
+ }
1738
+ restorePodChurn(path, current.podChurn);
1280
1739
  try {
1281
1740
  removeWorktree(path, {
1282
1741
  from: source.path,
@@ -1291,6 +1750,7 @@ async function runRemove(target, opts = {}) {
1291
1750
  process.exitCode = 1;
1292
1751
  return;
1293
1752
  }
1753
+ onRemoved();
1294
1754
  const finish = () => {
1295
1755
  printRemovalCleanup(result, false);
1296
1756
  console.error(chalk.dim(phaseLine("removed", path)));
@@ -1306,7 +1766,7 @@ async function runRemove(target, opts = {}) {
1306
1766
  worktreeRemovalComplete: true,
1307
1767
  worktreePendingBranchSha: approvedBranchSha
1308
1768
  });
1309
- const checkedOutAt = listWorktrees(branchDeleteCwd).find((candidate) => candidate.branch === branch && resolve$1(candidate.path) !== resolve$1(path))?.path;
1769
+ const checkedOutAt = listWorktrees(branchDeleteCwd).find((candidate) => candidate.branch === branch && resolve(candidate.path) !== resolve(path))?.path;
1310
1770
  if (checkedOutAt) {
1311
1771
  console.error(chalk.yellow(phaseLine("branch", `kept ${branch} (it is checked out at ${checkedOutAt})`)));
1312
1772
  console.error(chalk.dim(` Switch that worktree to another branch, then retry: stim worktree remove ${path}`));
@@ -1330,7 +1790,9 @@ async function runRemove(target, opts = {}) {
1330
1790
  }));
1331
1791
  }
1332
1792
  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);
1793
+ 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) => {
1794
+ await removeWorktreeTarget(target, { force: opts.force });
1795
+ });
1334
1796
  }
1335
1797
  function worktreeCommand(program) {
1336
1798
  const worktree = program.command("worktree").description("Warm and remove Git worktrees");
@@ -1338,4 +1800,4 @@ function worktreeCommand(program) {
1338
1800
  registerRemove(worktree);
1339
1801
  }
1340
1802
  //#endregion
1341
- export { worktreeCommand as default, dependencyInstallCommand, excludePodChurn, matchWorktreeEntry, porcelainPath, registerRemove, registerWarm, removalBlockers, removalPath, removalRemedy };
1803
+ export { reclaimKeys as a, removalBlockers as c, removeWorktreeTarget as d, worktreeCommand as f, 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 };