stim 1.0.0 → 1.2.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 (66) hide show
  1. package/README.md +29 -10
  2. package/dist/android-BW8i_YxU.mjs +204 -0
  3. package/dist/android-Bcmev2s2.mjs +2334 -0
  4. package/dist/android-K63_0UfF.mjs +727 -0
  5. package/dist/android-cas-D6HPZ6UQ.mjs +1078 -0
  6. package/dist/android-cas-compiler.mjs +2 -2
  7. package/dist/app-install-NFxUahnH.mjs +1149 -0
  8. package/dist/build-lock-BsVoLDaq.mjs +645 -0
  9. package/dist/build-slots-D0b6sJyo.mjs +152 -0
  10. package/dist/{cache-manifest-4tH64LQ9.mjs → cache-manifest-Cu4_Znw9.mjs} +2 -2
  11. package/dist/cache-manifest.mjs +1 -1
  12. package/dist/cli.mjs +24 -27166
  13. package/dist/collector-run.d.mts +1 -0
  14. package/dist/collector-run.mjs +7 -5
  15. package/dist/command-output-8wHMEde0.mjs +289 -0
  16. package/dist/{config-7kmtuhO2.mjs → config-CMtIaTk9.mjs} +44 -13
  17. package/dist/deps-BLBsTNDS.mjs +435 -0
  18. package/dist/dev-client-RpvXxCXV.mjs +620 -0
  19. package/dist/device-CoCPmazS.mjs +250 -0
  20. package/dist/device-lease-Dpik41RY.mjs +335 -0
  21. package/dist/device-pool-B5pEVa3t.mjs +408 -0
  22. package/dist/device-remote-E81KAdXf.mjs +1209 -0
  23. package/dist/doctor-CqPeMsSm.mjs +1130 -0
  24. package/dist/doctor-D_CX7ib5.mjs +530 -0
  25. package/dist/error-diagnostics-Dw8Gn3yw.mjs +636 -0
  26. package/dist/{exec-bsN9MJXb.mjs → exec-CyylIdq9.mjs} +4 -2
  27. package/dist/gc-B7KkDinF.mjs +1665 -0
  28. package/dist/guide-BS2Xc24o.mjs +4503 -0
  29. package/dist/ios-B7KLgIp5.mjs +2733 -0
  30. package/dist/ios-D1h1Di39.mjs +451 -0
  31. package/dist/ios-device-D3pzTKQE.mjs +81 -0
  32. package/dist/ios-device-xNt0Lg6v.mjs +413 -0
  33. package/dist/logs-DcX9AvRF.mjs +176 -0
  34. package/dist/logs-query-oqn47Ffx.mjs +273 -0
  35. package/dist/metro-COslRK_F.mjs +184 -0
  36. package/dist/metro-store-CDDZHtPl.mjs +92 -0
  37. package/dist/ownership-CGPart6T.mjs +60 -0
  38. package/dist/ownership-CYOyqxUo.mjs +475 -0
  39. package/dist/ownership-claim-7zRxxTfZ.mjs +569 -0
  40. package/dist/{project-Dit1CckW.mjs → project-BBTVABUz.mjs} +12 -3
  41. package/dist/reclaim-C5Jmuahx.mjs +348 -0
  42. package/dist/reload-Z8nKifAk.mjs +332 -0
  43. package/dist/remote-cache-DDmJ1Roq.mjs +994 -0
  44. package/dist/{server-bare-BmTVVmBB.mjs → server-bare-DEk37Mz2.mjs} +8 -2
  45. package/dist/{server-expo-CHJe1r6j.mjs → server-expo-C3ktAwUG.mjs} +6 -5
  46. package/dist/server-expo-Drd7ksHu.mjs +2 -0
  47. package/dist/settings-DwU1_U_q.mjs +708 -0
  48. package/dist/spawn-entry-nCE6m770.mjs +14 -0
  49. package/dist/start-BrY4EuIg.mjs +822 -0
  50. package/dist/{state-2nush3MK.mjs → state-CZ-ER-8d.mjs} +21 -22
  51. package/dist/stats-BCWEVLrM.mjs +307 -0
  52. package/dist/stats-BsF15Bti.mjs +62 -0
  53. package/dist/status-DFI1Hfpg.mjs +393 -0
  54. package/dist/stop-B7DR4awK.mjs +3 -0
  55. package/dist/stop-Btg770vV.mjs +591 -0
  56. package/dist/supervisor-run.d.mts +0 -1
  57. package/dist/supervisor-run.mjs +4 -4
  58. package/dist/workspace-process-lock-BKfdys1q.mjs +47 -0
  59. package/dist/worktree-BPTbI-LN.mjs +1322 -0
  60. package/dist/worktree-C79xx_kM.mjs +543 -0
  61. package/dist/xcode-DeKCribl.mjs +1832 -0
  62. package/package.json +5 -5
  63. package/shim/bundle-response.cjs +5 -0
  64. package/dist/android-jc8MnUyL.mjs +0 -514
  65. package/dist/metro-store-COLl1pOk.mjs +0 -1249
  66. package/dist/server-expo-BZJm--wA.mjs +0 -2
@@ -0,0 +1,1322 @@
1
+ import { t as getExecutor } from "./exec-CyylIdq9.mjs";
2
+ import { S as upsertProject, f as getProject, g as loadConfig, h as isPathPrefix, u as getConfigDir, v as removeProject, w as captureProcessIdentity } from "./config-CMtIaTk9.mjs";
3
+ import { c as readMetroTunnel, u as readRemoteSession } from "./state-CZ-ER-8d.mjs";
4
+ import { _ as resolveFullRef, b as warmWorktreePaths, c as hasRemote, d as isPodInstallChurn, f as listWorktrees, 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 isMainWorkingTree, v as restoreFile, y as unpushedCommits } from "./worktree-C79xx_kM.mjs";
5
+ import { a as findProjectRoot } from "./project-BBTVABUz.mjs";
6
+ import { v as POOL_SETTING_REMEDY, x as parkedMaxSetting } from "./remote-cache-DDmJ1Roq.mjs";
7
+ import { A as resolveSettings, I as unknownSettingKeys, N as settingShapeErrors, a as SETTING_SHAPE_REMEDY } from "./settings-DwU1_U_q.mjs";
8
+ import { a as formatElapsed, f as releasedLeaseFact, i as formatDuration, l as phaseLine, m as shortUdid, u as plural } from "./command-output-8wHMEde0.mjs";
9
+ import { a as clearClaimChild, c as isClaimUnavailable, d as readClaimSet, f as releaseClaim, h as tryAcquireClaim, i as claimRemoveCommand, l as markClaimChildPending, m as settleClaim, p as setClaimChild, r as claimFailure, s as isClaimRefusal, u as processGroupAlive } from "./ownership-claim-7zRxxTfZ.mjs";
10
+ import { g as podInstallCommand, r as HEARTBEAT_INTERVAL_MS } from "./xcode-DeKCribl.mjs";
11
+ import { a as installedNpmTreeIsValid, i as hasInstalledDependencies, o as locallyKnownUpstream, t as dependencyState } from "./doctor-CqPeMsSm.mjs";
12
+ import { c as withManagedTunnelRemovalLock, o as withManagedRemoteWorktreeRemovalLock } from "./ownership-CYOyqxUo.mjs";
13
+ import { n as reclaimProject } from "./reclaim-C5Jmuahx.mjs";
14
+ import { a as runPodInstall, i as runCaptured, n as podsAreStale, r as readPodState, t as DEPS_ERROR } from "./deps-BLBsTNDS.mjs";
15
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
16
+ import { createHash, randomUUID } from "node:crypto";
17
+ import { join, relative } from "node:path";
18
+ import { existsSync as existsSync$1, realpathSync as realpathSync$1 } from "fs";
19
+ 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 { workspaceName } from "@stim-cli/core";
21
+ import chalk from "chalk";
22
+ //#region src/engine/warm-claim.ts
23
+ const WARM_CLAIM_LABEL = "worktree warm";
24
+ const WARM_CLAIM_POLL_MS = 250;
25
+ const WARM_CLAIM_PROGRESS_MS = 3e4;
26
+ const WARM_CLAIM_CEILING_MS = 54e5;
27
+ const INSTALLER_POLL_MS = 50;
28
+ const INSTALLER_CEILING_MS = 3e5;
29
+ const NO_INSTALLER_CLAIM = {
30
+ declare: () => {},
31
+ record: () => {},
32
+ settle: async () => ({
33
+ settled: true,
34
+ pid: null,
35
+ waitedMs: 0
36
+ })
37
+ };
38
+ function defaultSleep(ms) {
39
+ return new Promise((resolve) => setTimeout(resolve, ms));
40
+ }
41
+ function warmClaimsDir() {
42
+ return join(getConfigDir(), "warm-locks");
43
+ }
44
+ function warmClaimPath(repositoryRoot) {
45
+ return join(warmClaimsDir(), `${workspaceName(repositoryRoot)}.lock`);
46
+ }
47
+ function warmCommand(phase) {
48
+ return phase === "refresh" ? "stim worktree warm --refresh" : "stim worktree warm";
49
+ }
50
+ function asHolder(holder) {
51
+ return {
52
+ pid: holder.owner.pid,
53
+ phase: holder.mode === "exclusive" ? "refresh" : "copy"
54
+ };
55
+ }
56
+ function waitingLine(holder, elapsedMs) {
57
+ return phaseLine("lock", `waiting ${formatElapsed(elapsedMs)} for ${warmCommand(holder.phase)} (pid ${holder.pid}) -- stim guide lifecycle options`);
58
+ }
59
+ function warmClaimAcquiredLine(wait) {
60
+ if (!wait.holder || wait.waitedMs <= 0) return phaseLine("lock", "acquired");
61
+ return phaseLine("lock", `acquired (waited ${formatElapsed(wait.waitedMs)} for ${warmCommand(wait.holder.phase)} pid ${wait.holder.pid}) -- stim guide lifecycle options`);
62
+ }
63
+ function warmClaimUnavailableLine(reason) {
64
+ return phaseLine("lock", `unavailable (${reason}); copying without it`);
65
+ }
66
+ /**
67
+ * The reason a copy may proceed without the claim, or null when it must refuse. Plain `warm` worked on an
68
+ * unwritable STIM_HOME before any claim existed and only reads, so the states in which no claim can be
69
+ * recorded at all degrade to an unsynchronised copy: no process identity, a claim store the filesystem
70
+ * rejects, and a claim store whose own path holds a file. A claim record Stim cannot resolve and a wait
71
+ * that ran out are refusals: neither state can exist unless the claim does, so refusing them takes
72
+ * nothing away.
73
+ */
74
+ function warmClaimDegradation(error) {
75
+ if (isClaimUnavailable(error)) return error.message;
76
+ if (isClaimRefusal(error)) return error.reason === "the claim path is a file, not a claim directory" ? `${error.claimPath}: ${error.reason}` : null;
77
+ const code = error?.code;
78
+ if (typeof code === "string" && code.startsWith("STIM_")) return null;
79
+ return error?.message ?? String(error);
80
+ }
81
+ /**
82
+ * What a copy that could not record a claim of its own would overlap if it went ahead anyway. Reading the
83
+ * claim set classifies without writing anything and without refusing, so it works in exactly the states
84
+ * that made the claim unrecordable -- a read-only STIM_HOME included. It is a read before a copy rather
85
+ * than a held claim, so a refresh that starts after it is not covered; it closes the window in which one
86
+ * is already installing, which is the window a refresh actually occupies.
87
+ */
88
+ function warmClaimBlocker(repositoryRoot) {
89
+ const survey = readClaimSet(warmClaimPath(repositoryRoot));
90
+ const refresh = survey.live.find((holder) => holder.mode === "exclusive");
91
+ if (refresh) return {
92
+ kind: "refresh",
93
+ holder: asHolder(refresh)
94
+ };
95
+ const unresolved = survey.unresolved.find((problem) => problem.path.endsWith(".claim"));
96
+ if (unresolved) return {
97
+ kind: "unresolved",
98
+ path: unresolved.path,
99
+ reason: unresolved.reason
100
+ };
101
+ return null;
102
+ }
103
+ function warmClaimBlockedLine(reason, blocker) {
104
+ const what = blocker.kind === "refresh" ? `${warmCommand(blocker.holder.phase)} (pid ${blocker.holder.pid}) holds this repository` : `a claim at ${blocker.path} cannot be resolved: ${blocker.reason}`;
105
+ return phaseLine("lock", `unavailable (${reason}); ${what}`);
106
+ }
107
+ function warmClaimBlockedRefusal(blocker) {
108
+ if (blocker.kind === "refresh") return "Refusing to copy from a source checkout a refresh is rewriting, even without a claim of its own. Wait for that refresh to finish, then run warm again.";
109
+ return `Refusing to copy past a claim Stim cannot resolve. If nothing is warming, remove it and run warm again:\n ${claimRemoveCommand(blocker.path)}`;
110
+ }
111
+ function installerWaitingLine(pid, elapsedMs) {
112
+ return phaseLine("lock", `waiting on the install this refresh spawned (pid ${pid}, ${formatElapsed(elapsedMs)} elapsed) -- stim guide lifecycle options`);
113
+ }
114
+ function installerUnrecordedLine(reason) {
115
+ return phaseLine("lock", `could not record the install this refresh spawned (${reason}); holding the claim here until it exits`);
116
+ }
117
+ function installerClaim(claim, { now, progressMs, out }) {
118
+ let groups = [];
119
+ const forget = () => {
120
+ groups = [];
121
+ try {
122
+ clearClaimChild(claim);
123
+ } catch {}
124
+ };
125
+ const alive = () => groups.find((pid) => processGroupAlive(pid)) ?? null;
126
+ return {
127
+ declare: () => markClaimChildPending(claim),
128
+ record: (pid) => {
129
+ if (pid === null) {
130
+ forget();
131
+ return;
132
+ }
133
+ groups.push(pid);
134
+ const captured = captureProcessIdentity(pid);
135
+ let failure = captured.ok ? null : captured.reason;
136
+ if (captured.ok) try {
137
+ setClaimChild(claim, {
138
+ pid,
139
+ processToken: captured.token
140
+ });
141
+ return;
142
+ } catch (error) {
143
+ failure = error?.message ?? String(error);
144
+ }
145
+ if (!processGroupAlive(pid)) {
146
+ forget();
147
+ return;
148
+ }
149
+ out(installerUnrecordedLine(String(failure)));
150
+ },
151
+ settle: async () => {
152
+ const started = now();
153
+ let lastProgress = started;
154
+ for (;;) {
155
+ const pid = alive();
156
+ if (pid === null) {
157
+ forget();
158
+ return {
159
+ settled: true,
160
+ pid: null,
161
+ waitedMs: now() - started
162
+ };
163
+ }
164
+ const elapsed = now() - started;
165
+ if (elapsed >= INSTALLER_CEILING_MS) return {
166
+ settled: false,
167
+ pid,
168
+ waitedMs: elapsed
169
+ };
170
+ if (now() - lastProgress >= progressMs) {
171
+ lastProgress = now();
172
+ out(installerWaitingLine(pid, elapsed));
173
+ }
174
+ await defaultSleep(INSTALLER_POLL_MS);
175
+ }
176
+ },
177
+ holding: () => alive() !== null
178
+ };
179
+ }
180
+ function timeoutError(holder, elapsedMs, root) {
181
+ const who = holder ? `${warmCommand(holder.phase)} (pid ${holder.pid})` : "another stim worktree warm";
182
+ const error = /* @__PURE__ */ new Error(`Waited ${formatElapsed(elapsedMs)} for ${who} to release the warm lock on this repository. The lock is ${root}; remove it if nothing is really warming:\n ${claimRemoveCommand(root)}`);
183
+ error.code = "STIM_LOCK_TIMEOUT";
184
+ return error;
185
+ }
186
+ async function acquireWarmClaim({ repositoryRoot, phase, now = Date.now, sleep = defaultSleep, pollMs = WARM_CLAIM_POLL_MS, progressMs = WARM_CLAIM_PROGRESS_MS, ceilingMs = WARM_CLAIM_CEILING_MS, out = () => {} }) {
187
+ const root = warmClaimPath(repositoryRoot);
188
+ const started = now();
189
+ let lastProgress = started;
190
+ let waitedOn = null;
191
+ let pending = null;
192
+ try {
193
+ for (;;) {
194
+ const attempt = pending ? settleClaim(pending) : tryAcquireClaim({
195
+ root,
196
+ mode: phase === "refresh" ? "exclusive" : "shared",
197
+ label: WARM_CLAIM_LABEL
198
+ });
199
+ const acquired = attempt.acquired;
200
+ if (acquired) {
201
+ const installer = installerClaim(acquired, {
202
+ now,
203
+ progressMs,
204
+ out
205
+ });
206
+ return {
207
+ wait: {
208
+ waitedMs: now() - started,
209
+ holder: waitedOn
210
+ },
211
+ installer,
212
+ release: () => {
213
+ if (installer.holding()) return;
214
+ releaseClaim(acquired);
215
+ }
216
+ };
217
+ }
218
+ pending = attempt.pending ?? null;
219
+ const holder = attempt.held ?? attempt.waitingFor?.[0];
220
+ if (holder) waitedOn = asHolder(holder);
221
+ const elapsed = now() - started;
222
+ if (elapsed >= ceilingMs) throw timeoutError(waitedOn, elapsed, root);
223
+ if (waitedOn && now() - lastProgress >= progressMs) {
224
+ lastProgress = now();
225
+ out(waitingLine(waitedOn, elapsed));
226
+ }
227
+ await sleep(pollMs);
228
+ }
229
+ } catch (error) {
230
+ releaseClaim(pending);
231
+ throw error;
232
+ }
233
+ }
234
+ async function withWarmClaim(options, fn) {
235
+ const { release, ...hold } = await acquireWarmClaim(options);
236
+ try {
237
+ return await fn(hold);
238
+ } finally {
239
+ release();
240
+ }
241
+ }
242
+ //#endregion
243
+ //#region src/worktree-refresh.ts
244
+ const DIRTY_PATHS_SHOWN = 5;
245
+ const FETCH_TIMEOUT_MS = 12e4;
246
+ function mainCheckoutRefusal(root, state) {
247
+ if (state.operation) return {
248
+ code: "STIM_MAIN_DIRTY",
249
+ message: `Refusing to refresh ${root}: a ${state.operation === "am" ? "git am" : state.operation} is in progress there.`,
250
+ lines: [],
251
+ remedy: `Finish it, or run \`git -C ${root} ${state.operation} --abort\`, then run warm again.`
252
+ };
253
+ if (!state.dirtyKnown) return {
254
+ code: "STIM_MAIN_DIRTY",
255
+ message: `Refusing to refresh ${root}: git could not report whether its tracked files are modified.`,
256
+ lines: [],
257
+ remedy: `Run \`git -C ${root} status\` to see what it reports, clear it, then run warm again.`
258
+ };
259
+ if (state.dirtyTracked.length) return {
260
+ code: "STIM_MAIN_DIRTY",
261
+ message: `Refusing to refresh ${root}: it has uncommitted changes to tracked files.`,
262
+ lines: state.dirtyTracked.slice(0, DIRTY_PATHS_SHOWN),
263
+ remedy: `Commit them, or run \`git -C ${root} stash push -u -m warm-refresh\`, then run warm again.`
264
+ };
265
+ if (state.branch === null) return {
266
+ code: "STIM_MAIN_DETACHED",
267
+ message: `Refusing to refresh ${root}: its HEAD is detached, so there is no branch to fast-forward.`,
268
+ lines: [],
269
+ remedy: `Run \`git -C ${root} checkout <branch>\`, then run warm again.`
270
+ };
271
+ return null;
272
+ }
273
+ function checkoutPlan(upstream) {
274
+ if (!upstream) return { kind: "no-upstream" };
275
+ const { name, ahead, behind } = upstream;
276
+ if (ahead > 0 && behind > 0) return {
277
+ kind: "diverged",
278
+ ahead,
279
+ behind,
280
+ upstream: name
281
+ };
282
+ if (behind > 0) return {
283
+ kind: "fast-forward",
284
+ behind,
285
+ upstream: name
286
+ };
287
+ return {
288
+ kind: "current",
289
+ ahead,
290
+ upstream: name
291
+ };
292
+ }
293
+ function divergedRefusal(root, branch, plan) {
294
+ return {
295
+ code: "STIM_MAIN_DIVERGED",
296
+ message: `Refusing to refresh ${root}: ${branch} is ${plan.ahead} ahead of and ${plan.behind} behind ${plan.upstream}, so it cannot fast-forward.`,
297
+ lines: [],
298
+ remedy: `Rebase or merge ${branch} yourself, then run warm again. Warm never merges, resets, or switches a branch.`
299
+ };
300
+ }
301
+ function checkoutFactLine(branch, plan, head) {
302
+ const shortHead = head.slice(0, 7);
303
+ if (plan.kind === "no-upstream") return phaseLine("checkout", `${branch} has no upstream -> left${shortHead ? ` at ${shortHead}` : " alone"}`);
304
+ if (plan.kind === "fast-forward") return phaseLine("checkout", `${branch} ${plan.behind} commit${plan.behind === 1 ? "" : "s"} behind ${plan.upstream} -> fast-forwarded to ${shortHead}`);
305
+ const ahead = plan.ahead > 0 ? `, ${plan.ahead} ahead` : "";
306
+ return phaseLine("checkout", `${branch} up to date with ${plan.upstream}${ahead}`);
307
+ }
308
+ /**
309
+ * Whether to install, decided against the lockfile as it stands now rather than against what HEAD did.
310
+ * A lockfile that did not move is not evidence that its dependencies are installed: an install that was
311
+ * interrupted leaves a present node_modules behind and moves HEAD no further.
312
+ */
313
+ function depsPlan({ lockfile, lockfileChanged, installed, treeValid, lastInstall, lockHash }) {
314
+ if (!lockfile) return {
315
+ run: false,
316
+ reason: "no lockfile in this repository"
317
+ };
318
+ if (!installed) return {
319
+ run: true,
320
+ reason: "no installed dependencies"
321
+ };
322
+ if (treeValid === false) return {
323
+ run: true,
324
+ reason: `the installed tree does not match ${lockfile}`
325
+ };
326
+ if (lastInstall) {
327
+ if (!lastInstall.completed) return {
328
+ run: true,
329
+ reason: `the last install of ${lastInstall.lock} did not finish`
330
+ };
331
+ if (lastInstall.lock !== lockfile) return {
332
+ run: true,
333
+ reason: `the last completed install was of ${lastInstall.lock}`
334
+ };
335
+ if (lockHash === null || lastInstall.hash !== lockHash) return {
336
+ run: true,
337
+ reason: `${lockfile} does not match the last completed install`
338
+ };
339
+ return {
340
+ run: false,
341
+ reason: `${lockfile} matches the last completed install`
342
+ };
343
+ }
344
+ if (lockfileChanged) return {
345
+ run: true,
346
+ reason: `${lockfile} changed`
347
+ };
348
+ return {
349
+ run: false,
350
+ reason: `${lockfile} unchanged`
351
+ };
352
+ }
353
+ /**
354
+ * Why a copy must not read the source checkout's dependencies: the install recorded for the lockfile as it
355
+ * stands now did not finish, so what is installed there is partial. A record of any other lockfile says
356
+ * nothing about this one, and no record at all is the state of every repository before its first refresh.
357
+ */
358
+ function incompleteInstallRefusal(root, appDir) {
359
+ const dependencies = dependencyState(existsSync(appDir) ? appDir : root);
360
+ if (!dependencies) return null;
361
+ const last = readInstallEvidence(dependencies.root);
362
+ if (!last || last.completed || last.lock !== dependencies.lock) return null;
363
+ const hash = lockfileHash(dependencies.root, dependencies.lock);
364
+ if (hash === null || last.hash !== hash) return null;
365
+ return {
366
+ code: "STIM_DEPS_INCOMPLETE",
367
+ message: `Refusing to copy from ${dependencies.root}: the last install of ${dependencies.lock} there did not finish, so its dependencies are partial.`,
368
+ lines: [],
369
+ remedy: `Run \`stim worktree warm --refresh\` to install them in the source checkout -- it reinstalls rather than skipping, because no completed install of this ${dependencies.lock} is recorded -- then warm again.`
370
+ };
371
+ }
372
+ function podsPlan({ hasIos, hasPodfile, podfileLockChanged, stale }) {
373
+ if (!hasIos) return {
374
+ run: false,
375
+ reason: "no ios/ directory"
376
+ };
377
+ if (!hasPodfile) return {
378
+ run: false,
379
+ reason: "no ios/Podfile"
380
+ };
381
+ if (stale.noPods) return {
382
+ run: false,
383
+ reason: "no ios/Pods and no ios/Podfile.lock"
384
+ };
385
+ if (podfileLockChanged) return {
386
+ run: true,
387
+ reason: "ios/Podfile.lock changed"
388
+ };
389
+ if (stale.stale) return {
390
+ run: true,
391
+ reason: stale.reason ?? "ios/Pods does not match ios/Podfile.lock"
392
+ };
393
+ return {
394
+ run: false,
395
+ reason: "ios/Podfile.lock unchanged"
396
+ };
397
+ }
398
+ function defaultBranchNote(branch, defaultBranch) {
399
+ if (defaultBranch === null) return {
400
+ kind: "unknown",
401
+ lines: ["could not tell the default branch: no worktree.defaultBranch setting and no", "origin/HEAD. Run `git remote set-head origin -a` to record one."]
402
+ };
403
+ if (defaultBranch === branch) return { kind: "match" };
404
+ return {
405
+ kind: "warn",
406
+ lines: [`not the default branch (${defaultBranch}); worktrees seeded from this copy`, `carry ${branch}'s dependencies`]
407
+ };
408
+ }
409
+ function stepLine(label, reason, action) {
410
+ return phaseLine(label, `${reason} -> ${action}`);
411
+ }
412
+ function installRecordFile(depsRoot) {
413
+ return join(getConfigDir(), "warm-installs", `${workspaceName(depsRoot)}.json`);
414
+ }
415
+ function lockfileHash(depsRoot, lockfile) {
416
+ try {
417
+ return createHash("sha256").update(readFileSync(join(depsRoot, lockfile))).digest("hex");
418
+ } catch {
419
+ return null;
420
+ }
421
+ }
422
+ function readInstallEvidence(depsRoot) {
423
+ try {
424
+ const parsed = JSON.parse(readFileSync(installRecordFile(depsRoot), "utf-8"));
425
+ if (!parsed || typeof parsed !== "object") return null;
426
+ if (typeof parsed.lock !== "string" || typeof parsed.hash !== "string") return null;
427
+ return {
428
+ lock: parsed.lock,
429
+ hash: parsed.hash,
430
+ completed: parsed.completed === true
431
+ };
432
+ } catch {
433
+ return null;
434
+ }
435
+ }
436
+ function writeInstallEvidence(depsRoot, lockfile, hash, completed) {
437
+ const file = installRecordFile(depsRoot);
438
+ const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`;
439
+ mkdirSync(join(getConfigDir(), "warm-installs"), { recursive: true });
440
+ writeFileSync(tmp, JSON.stringify({
441
+ lock: lockfile,
442
+ hash: hash ?? "",
443
+ completed
444
+ }));
445
+ try {
446
+ renameSync(tmp, file);
447
+ } catch (error) {
448
+ rmSync(tmp, { force: true });
449
+ throw error;
450
+ }
451
+ }
452
+ function restoreInstallEvidence(depsRoot, prior) {
453
+ if (prior) writeInstallEvidence(depsRoot, prior.lock, prior.hash, prior.completed);
454
+ else rmSync(installRecordFile(depsRoot), { force: true });
455
+ }
456
+ function inProgressOperation(gitDir) {
457
+ if (existsSync(join(gitDir, "rebase-merge"))) return "rebase";
458
+ if (existsSync(join(gitDir, "rebase-apply"))) return existsSync(join(gitDir, "rebase-apply", "applying")) ? "am" : "rebase";
459
+ return existsSync(join(gitDir, "MERGE_HEAD")) ? "merge" : null;
460
+ }
461
+ function readMainCheckoutState(root) {
462
+ const exec = getExecutor();
463
+ const gitDir = exec.runFileQuiet("git", [
464
+ "-C",
465
+ root,
466
+ "rev-parse",
467
+ "--path-format=absolute",
468
+ "--git-dir"
469
+ ])?.trim();
470
+ const worktreeChanged = exec.runFileQuiet("git", [
471
+ "-C",
472
+ root,
473
+ "diff",
474
+ "--name-only",
475
+ "HEAD"
476
+ ]);
477
+ const indexChanged = exec.runFileQuiet("git", [
478
+ "-C",
479
+ root,
480
+ "diff",
481
+ "--name-only",
482
+ "--cached",
483
+ "HEAD"
484
+ ]);
485
+ const branch = exec.runFileQuiet("git", [
486
+ "-C",
487
+ root,
488
+ "symbolic-ref",
489
+ "--quiet",
490
+ "--short",
491
+ "HEAD"
492
+ ])?.trim() || null;
493
+ const paths = [worktreeChanged, indexChanged].flatMap((out) => (out ?? "").split("\n").map((line) => line.trim()).filter(Boolean));
494
+ return {
495
+ branch,
496
+ operation: gitDir ? inProgressOperation(gitDir) : null,
497
+ dirtyTracked: [...new Set(paths)],
498
+ dirtyKnown: worktreeChanged !== null && indexChanged !== null
499
+ };
500
+ }
501
+ function resolveDefaultBranch(root, settings) {
502
+ const worktree = settings.worktree;
503
+ const configured = worktree && typeof worktree === "object" && !Array.isArray(worktree) ? worktree.defaultBranch : void 0;
504
+ if (typeof configured === "string" && configured.trim()) return configured.trim();
505
+ const head = getExecutor().runFileQuiet("git", [
506
+ "-C",
507
+ root,
508
+ "symbolic-ref",
509
+ "--short",
510
+ "refs/remotes/origin/HEAD"
511
+ ])?.trim();
512
+ if (!head) return null;
513
+ const cut = head.indexOf("/");
514
+ return cut > 0 ? head.slice(cut + 1) : head;
515
+ }
516
+ function branchRemote(root, branch) {
517
+ return getExecutor().runFileQuiet("git", [
518
+ "-C",
519
+ root,
520
+ "config",
521
+ `branch.${branch}.remote`
522
+ ])?.trim() || "origin";
523
+ }
524
+ function gitPath(...parts) {
525
+ return parts.filter((part) => part && part !== ".").join("/");
526
+ }
527
+ function pathChangedBetween(root, from, to, path) {
528
+ if (from === to || !from || !to) return false;
529
+ const out = getExecutor().runFileQuiet("git", [
530
+ "-C",
531
+ root,
532
+ "diff",
533
+ "--name-only",
534
+ from,
535
+ to,
536
+ "--",
537
+ path
538
+ ]);
539
+ return Boolean(out && out.trim());
540
+ }
541
+ async function runInstallCommand({ command, cwd, spawnFn, now, heartbeatMs, onHeartbeat }) {
542
+ const [bin, ...args] = command.split(" ");
543
+ const run = await runCaptured({
544
+ logWriter: null,
545
+ spawn: spawnFn,
546
+ now,
547
+ heartbeatMs,
548
+ onHeartbeat,
549
+ cmd: String(bin),
550
+ args,
551
+ cwd,
552
+ env: {
553
+ ...process.env,
554
+ FORCE_COLOR: "0"
555
+ },
556
+ event: "warm_deps_install",
557
+ label: "deps"
558
+ });
559
+ const lastLines = run.transcript.slice(-20);
560
+ if (run.error) {
561
+ const reason = `Could not run \`${command}\`: ${run.error?.message || run.error}`;
562
+ return {
563
+ ok: false,
564
+ durationMs: run.durationMs,
565
+ reason,
566
+ lastLines
567
+ };
568
+ }
569
+ if (run.code !== 0) {
570
+ const how = run.signal ? `signal ${run.signal}` : `exit code ${run.code}`;
571
+ return {
572
+ ok: false,
573
+ durationMs: run.durationMs,
574
+ reason: `\`${command}\` failed (${how}).`,
575
+ lastLines
576
+ };
577
+ }
578
+ return {
579
+ ok: true,
580
+ durationMs: run.durationMs,
581
+ reason: null,
582
+ lastLines
583
+ };
584
+ }
585
+ function fastForward(root, upstream) {
586
+ try {
587
+ getExecutor().runFile("git", [
588
+ "-C",
589
+ root,
590
+ "merge",
591
+ "--ff-only",
592
+ "--end-of-options",
593
+ upstream
594
+ ]);
595
+ return {
596
+ ok: true,
597
+ lines: []
598
+ };
599
+ } catch (error) {
600
+ return {
601
+ ok: false,
602
+ lines: String(error?.stderr || error?.message || error).split("\n").map((line) => line.trim()).filter(Boolean).slice(0, 5)
603
+ };
604
+ }
605
+ }
606
+ async function refreshMainCheckout({ root, appDir, settings, emit, spawnFn = null, now = Date.now, heartbeatMs = HEARTBEAT_INTERVAL_MS, installer = NO_INSTALLER_CLAIM }) {
607
+ const state = readMainCheckoutState(root);
608
+ const refusal = mainCheckoutRefusal(root, state);
609
+ if (refusal) return refusal;
610
+ const branch = String(state.branch);
611
+ const spawnChild = spawnFn || ((cmd, args, opts) => getExecutor().spawn(cmd, args, opts));
612
+ const spawn = (cmd, args, opts) => {
613
+ installer.declare();
614
+ let child;
615
+ try {
616
+ child = spawnChild(cmd, args, {
617
+ ...opts,
618
+ detached: true
619
+ });
620
+ } catch (error) {
621
+ installer.record(null);
622
+ throw error;
623
+ }
624
+ installer.record(child.pid ?? null);
625
+ return child;
626
+ };
627
+ /**
628
+ * A package manager's own exit does not mean its install is over: `npm ci` spawns postinstall writers
629
+ * that outlive it, and one of those rewriting node_modules is exactly what the claim exists to keep a
630
+ * copy away from. So the group, not the direct child, decides when the install is done.
631
+ */
632
+ const awaitSpawnedWork = async (what) => {
633
+ const settled = await installer.settle();
634
+ if (settled.settled) return null;
635
+ return {
636
+ code: DEPS_ERROR,
637
+ message: `${what} exited but left a process group running (pid ${settled.pid}) in ${root} after ${formatElapsed(settled.waitedMs)}, so Stim cannot tell that it finished writing.`,
638
+ lines: [],
639
+ remedy: `Stop what pid ${settled.pid} leads (\`ps -g ${settled.pid}\`, then \`kill -TERM -${settled.pid}\`) and run warm again. The warm claim stays held until that group exits, so no copy reads a half-written tree in the meantime.`
640
+ };
641
+ };
642
+ const before = resolveFullRef(root, "HEAD") ?? "";
643
+ if (getExecutor().runFileQuiet("git", [
644
+ "-C",
645
+ root,
646
+ "fetch",
647
+ "--prune",
648
+ branchRemote(root, branch)
649
+ ], {
650
+ timeoutMs: FETCH_TIMEOUT_MS,
651
+ env: { GIT_TERMINAL_PROMPT: "0" }
652
+ }) === null) emit(phaseLine("checkout", "could not fetch; continuing with the local state"));
653
+ const plan = checkoutPlan(locallyKnownUpstream(root));
654
+ if (plan.kind === "diverged") return divergedRefusal(root, branch, plan);
655
+ const depsBefore = dependencyState(existsSync(appDir) ? appDir : root);
656
+ const priorInstall = depsBefore ? readInstallEvidence(depsBefore.root) : null;
657
+ const marked = plan.kind === "fast-forward" ? depsBefore : null;
658
+ if (marked) writeInstallEvidence(marked.root, marked.lock, lockfileHash(marked.root, marked.lock), false);
659
+ let head = before;
660
+ if (plan.kind === "fast-forward") {
661
+ const merged = fastForward(root, plan.upstream);
662
+ if (!merged.ok) return {
663
+ code: "STIM_MAIN_DIRTY",
664
+ message: `Refusing to refresh ${root}: git could not fast-forward ${branch} to ${plan.upstream}.`,
665
+ lines: merged.lines,
666
+ remedy: "Clear what git reports in the source checkout, then run warm again."
667
+ };
668
+ head = resolveFullRef(root, "HEAD") ?? before;
669
+ }
670
+ emit(checkoutFactLine(branch, plan, head));
671
+ const note = defaultBranchNote(branch, resolveDefaultBranch(root, settings));
672
+ if (note.kind !== "match") for (const line of note.lines) emit(phaseLine("", line));
673
+ const dependencies = dependencyState(existsSync(appDir) ? appDir : root);
674
+ const installed = dependencies ? hasInstalledDependencies(dependencies.root, dependencies.installed) : false;
675
+ const consumed = dependencies ? lockfileHash(dependencies.root, dependencies.lock) : null;
676
+ const deps = depsPlan({
677
+ lockfile: dependencies?.lock ?? null,
678
+ lockfileChanged: dependencies ? pathChangedBetween(root, before, head, gitPath(relative(root, dependencies.root), dependencies.lock)) : false,
679
+ installed,
680
+ treeValid: dependencies?.lock === "package-lock.json" && installed ? installedNpmTreeIsValid(dependencies.root) : null,
681
+ lastInstall: priorInstall,
682
+ lockHash: consumed
683
+ });
684
+ const depsSubject = `source ${dependencies?.root ?? root}: ${deps.reason}`;
685
+ if (deps.run && dependencies) {
686
+ writeInstallEvidence(dependencies.root, dependencies.lock, consumed, false);
687
+ const install = await runInstallCommand({
688
+ command: dependencies.command,
689
+ cwd: dependencies.root,
690
+ spawnFn: spawn,
691
+ now,
692
+ heartbeatMs,
693
+ onHeartbeat: emit
694
+ });
695
+ emit(stepLine("deps", depsSubject, `${dependencies.command} (${formatDuration(install.durationMs)})`));
696
+ const running = await awaitSpawnedWork(`\`${dependencies.command}\``);
697
+ if (!install.ok) return {
698
+ code: DEPS_ERROR,
699
+ message: String(install.reason),
700
+ lines: install.lastLines,
701
+ remedy: `Run \`cd ${dependencies.root} && ${dependencies.command}\` and fix what it reports, then run warm again.`
702
+ };
703
+ if (running) return running;
704
+ writeInstallEvidence(dependencies.root, dependencies.lock, consumed, true);
705
+ } else {
706
+ if (marked) restoreInstallEvidence(marked.root, priorInstall);
707
+ emit(stepLine("deps", depsSubject, "skipped"));
708
+ }
709
+ const app = relative(root, appDir) || ".";
710
+ const where = `source ${appDir}: `;
711
+ const podState = readPodState(appDir);
712
+ const pods = podsPlan({
713
+ hasIos: existsSync(join(appDir, "ios")),
714
+ hasPodfile: podState.hasPodfile,
715
+ podfileLockChanged: pathChangedBetween(root, before, head, gitPath(app, "ios", "Podfile.lock")),
716
+ stale: podsAreStale(podState.lockText, podState.manifestText)
717
+ });
718
+ if (!pods.run) {
719
+ emit(stepLine("pods", `${where}${pods.reason}`, "skipped"));
720
+ return null;
721
+ }
722
+ const result = await runPodInstall(appDir, null, {
723
+ spawnFn: spawn,
724
+ now,
725
+ heartbeatMs,
726
+ onHeartbeat: emit
727
+ });
728
+ const podCommand = result.command ?? "pod install";
729
+ emit(stepLine("pods", `${where}${pods.reason}`, result.durationMs === void 0 ? podCommand : `${podCommand} (${formatDuration(result.durationMs)})`));
730
+ for (const podNote of result.notes ?? []) emit(phaseLine("pods", podNote));
731
+ const podsRunning = await awaitSpawnedWork(`\`${podCommand}\``);
732
+ if (!result.ok) return {
733
+ code: result.code ?? "STIM_DEPS_FAILED",
734
+ message: result.reason ?? "`pod install` failed.",
735
+ lines: result.lastLines ?? [],
736
+ remedy: result.remedy ?? null
737
+ };
738
+ return podsRunning;
739
+ }
740
+ //#endregion
741
+ //#region src/commands/worktree.ts
742
+ function dependencyInstallCommand(target, dir = ".") {
743
+ const installRoot = resolve$1(target, dir);
744
+ let command = "npm install";
745
+ if (existsSync$1(resolve$1(installRoot, "pnpm-lock.yaml"))) command = "pnpm install";
746
+ else if (existsSync$1(resolve$1(installRoot, "yarn.lock"))) command = "yarn install";
747
+ else if (existsSync$1(resolve$1(installRoot, "bun.lock")) || existsSync$1(resolve$1(installRoot, "bun.lockb"))) command = "bun install";
748
+ else if (existsSync$1(resolve$1(installRoot, "package-lock.json"))) command = "npm ci";
749
+ return `cd '${installRoot.replaceAll("'", "'\\''")}' && ${command}`;
750
+ }
751
+ function reportCarriedStateHealth(root, target, copied) {
752
+ const staleDeps = depsOutOfSync(root, target, copied);
753
+ if (staleDeps.length) {
754
+ const manifests = staleDeps.map((d) => d.dir === "." ? d.lockfile : `${d.dir}/${d.lockfile}`).join(", ");
755
+ const remedies = [...new Set(staleDeps.map((dependency) => dependencyInstallCommand(target, dependency.dir)))];
756
+ console.error(chalk.yellow(phaseLine("carry", `carried dependencies may be stale: they do not match ${manifests}. Run ${remedies.map((command) => `\`${command}\``).join(" and ")} before building.`)));
757
+ }
758
+ for (const p of podsOutOfSync(target, copied)) {
759
+ const where = p.dir === "." ? "Podfile.lock" : `${p.dir}/Podfile.lock`;
760
+ const pod = podInstallCommand(p.dir === "." ? target : resolve$1(target, p.dir, ".."));
761
+ 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.`)));
762
+ }
763
+ }
764
+ function reportRefreshFailure(failure) {
765
+ console.error(chalk.red(failure.message));
766
+ for (const line of failure.lines) console.error(chalk.dim(` ${line}`));
767
+ if (failure.remedy) console.error(chalk.dim(failure.remedy));
768
+ console.error(chalk.red(`failed: ${failure.code}`));
769
+ process.exitCode = 1;
770
+ }
771
+ function mainCheckoutAppDir(root, target) {
772
+ const app = findProjectRoot(process.cwd());
773
+ if (!app) return root;
774
+ const rel = relative$1(target, app);
775
+ if (!rel || rel.startsWith("..") || isAbsolute$1(rel)) return root;
776
+ return resolve$1(root, rel);
777
+ }
778
+ function registerWarm(worktree) {
779
+ 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) => {
780
+ try {
781
+ const { root, target, common } = warmWorktreePaths(process.cwd());
782
+ const readSettings = () => {
783
+ const settings = resolveSettings({
784
+ gitCommonDir: common,
785
+ repoRoot: root
786
+ });
787
+ const shapeErrors = settingShapeErrors(settings);
788
+ if (shapeErrors.length) {
789
+ for (const message of shapeErrors) console.error(chalk.red(message));
790
+ console.error(chalk.dim(SETTING_SHAPE_REMEDY));
791
+ return null;
792
+ }
793
+ for (const key of unknownSettingKeys(settings)) console.error(chalk.yellow(`Warning: setting "${key}" is not read by Stim and will be ignored.`));
794
+ return settings;
795
+ };
796
+ if (opts.refresh) {
797
+ const settings = readSettings();
798
+ if (!settings) {
799
+ process.exitCode = 1;
800
+ return;
801
+ }
802
+ const failure = await withWarmClaim({
803
+ repositoryRoot: root,
804
+ phase: "refresh",
805
+ out: console.error
806
+ }, (hold) => {
807
+ console.error(warmClaimAcquiredLine(hold.wait));
808
+ return refreshMainCheckout({
809
+ root,
810
+ appDir: mainCheckoutAppDir(root, target),
811
+ settings,
812
+ emit: (line) => console.error(line),
813
+ installer: hold.installer
814
+ });
815
+ });
816
+ if (failure) {
817
+ reportRefreshFailure(failure);
818
+ return;
819
+ }
820
+ }
821
+ const copy = (wait) => {
822
+ if (wait?.holder) console.error(warmClaimAcquiredLine(wait));
823
+ const incomplete = incompleteInstallRefusal(root, mainCheckoutAppDir(root, target));
824
+ if (incomplete) {
825
+ reportRefreshFailure(incomplete);
826
+ return;
827
+ }
828
+ const settings = readSettings();
829
+ if (!settings) {
830
+ process.exitCode = 1;
831
+ return;
832
+ }
833
+ const excluded = readWorktreeExclude(root);
834
+ const patterns = excluded?.length ? excluded : settings.worktree?.exclude || [];
835
+ const result = cloneIgnoredEntries({
836
+ root,
837
+ target,
838
+ patterns
839
+ });
840
+ for (const entry of result.skipped) console.error(chalk.dim(phaseLine("carry", `kept ${entry.file} (${entry.reason})`)));
841
+ for (const entry of result.failed) console.error(chalk.yellow(phaseLine("carry", `could not copy ${entry.file}: ${entry.error}`)));
842
+ if (result.copied.length) {
843
+ console.error(chalk.dim(phaseLine("carry", `copied ${carriedFileList(result.copied)} from ${root}`)));
844
+ reportCarriedStateHealth(root, target, result.copied);
845
+ }
846
+ console.error(phaseLine("carry", `${result.failed.length ? "incomplete" : "complete"}: ${result.copied.length} ignored entries copied, ${result.skipped.length} kept, ${result.failed.length} failed`));
847
+ if (result.failed.length) process.exitCode = 1;
848
+ };
849
+ let hold = null;
850
+ try {
851
+ hold = await acquireWarmClaim({
852
+ repositoryRoot: root,
853
+ phase: "copy",
854
+ out: console.error
855
+ });
856
+ } catch (error) {
857
+ const degraded = opts.refresh ? null : warmClaimDegradation(error);
858
+ if (degraded === null) throw error;
859
+ const blocker = warmClaimBlocker(root);
860
+ if (blocker) {
861
+ console.error(chalk.dim(warmClaimBlockedLine(degraded, blocker)));
862
+ console.error(chalk.red(warmClaimBlockedRefusal(blocker)));
863
+ throw error;
864
+ }
865
+ console.error(chalk.dim(warmClaimUnavailableLine(degraded)));
866
+ }
867
+ if (!hold) copy(null);
868
+ else try {
869
+ copy(hold.wait);
870
+ } finally {
871
+ hold.release();
872
+ }
873
+ } catch (error) {
874
+ const code = error?.code;
875
+ console.error(chalk.red(`Could not warm this worktree: ${error.message}`));
876
+ const claim = claimFailure(error, `stim worktree warm${opts.refresh ? " --refresh" : ""}`);
877
+ if (claim) console.error(chalk.dim(claim.remedy));
878
+ if (typeof code === "string" && code.startsWith("STIM_")) console.error(chalk.red(`failed: ${code}`));
879
+ process.exitCode = 1;
880
+ }
881
+ });
882
+ }
883
+ function carriedFileList(files) {
884
+ const shown = files.slice(0, 3).join(", ");
885
+ return files.length > 3 ? `${shown}, +${files.length - 3}` : shown;
886
+ }
887
+ function porcelainPath(line) {
888
+ const raw = String(line).slice(3).trim();
889
+ if (raw === "") return null;
890
+ return (raw.includes(" -> ") ? raw.slice(raw.lastIndexOf(" -> ") + 4) : raw).replace(/^"(.*)"$/, "$1");
891
+ }
892
+ const SAFE_DIFF_PATH = /^[A-Za-z0-9._/-]+$/;
893
+ const POD_CHURN_PATH = /(?:^|\/)ios\/(?:Podfile\.lock|[^/]+\.xcodeproj\/project\.pbxproj)$/;
894
+ function excludePodChurn(lines) {
895
+ const kept = [];
896
+ const restore = [];
897
+ for (const line of lines || []) {
898
+ const path = porcelainPath(line);
899
+ if (path && String(line).startsWith(" M ") && SAFE_DIFF_PATH.test(path) && POD_CHURN_PATH.test(path)) {
900
+ restore.push(path);
901
+ continue;
902
+ }
903
+ kept.push(line);
904
+ }
905
+ if (kept.length) return {
906
+ lines: lines ? [...lines] : [],
907
+ restore: []
908
+ };
909
+ return {
910
+ lines: kept,
911
+ restore
912
+ };
913
+ }
914
+ function matchWorktreeEntry(entries, path) {
915
+ let best = null;
916
+ (entries || []).forEach((entry, index) => {
917
+ if (!entry?.path || !isPathPrefix(entry.path, path)) return;
918
+ if (!best || entry.path.length > best.path.length) best = {
919
+ ...entry,
920
+ index
921
+ };
922
+ });
923
+ return best;
924
+ }
925
+ function removalRemedy(dirtyLines, { worktree = "<worktree>" } = {}) {
926
+ const tracked = [];
927
+ const untracked = [];
928
+ for (const line of dirtyLines || []) {
929
+ if (porcelainPath(line) === null) continue;
930
+ (String(line).startsWith("??") ? untracked : tracked).push(line);
931
+ }
932
+ const lines = [];
933
+ if (tracked.length) {
934
+ if (isPodInstallChurn(tracked)) {
935
+ lines.push("That is only the files `pod install` rewrites. Restore them and retry:");
936
+ lines.push(` git -C ${worktree} checkout -- ${pathArgs(tracked)}`);
937
+ } else {
938
+ lines.push("Tracked files were modified -- if a build or a setup script did it, restore them and retry:");
939
+ lines.push(` git -C ${worktree} checkout -- ${pathArgs(tracked)}`);
940
+ }
941
+ }
942
+ if (untracked.length) {
943
+ lines.push("Untracked files are also present -- `git checkout` cannot clear those. Delete them and retry:");
944
+ lines.push(` git -C ${worktree} clean -fd ${pathArgs(untracked)} # or rm them yourself`);
945
+ }
946
+ return lines;
947
+ }
948
+ function pathArgs(lines, limit = 5) {
949
+ const paths = (lines || []).map(porcelainPath).filter((p) => Boolean(p));
950
+ const shown = paths.slice(0, limit).map((p) => /[\s"'\\$`]/.test(p) ? JSON.stringify(p) : p);
951
+ return `${shown.join(" ")}${paths.length > shown.length ? " ..." : ""}`;
952
+ }
953
+ function removalBlockers({ dirty, unpushed }) {
954
+ const blockers = [];
955
+ if (dirty === null || unpushed === null) blockers.push("could not determine git status; re-run with --force to override");
956
+ if (dirty) blockers.push("uncommitted changes or untracked files");
957
+ if (unpushed && unpushed.length) blockers.push(`${plural(unpushed.length, "commit")} not on any remote or any other local branch`);
958
+ return blockers;
959
+ }
960
+ function describeKeptDevice(s) {
961
+ return s.udid && s.udid !== s.name ? `${s.name} (${s.udid})` : s.name;
962
+ }
963
+ function reclaimKeys(rootPath) {
964
+ const cfg = loadConfig();
965
+ const keys = /* @__PURE__ */ new Set([rootPath]);
966
+ if (cfg?.projects) {
967
+ for (const key of Object.keys(cfg.projects)) if (isPathPrefix(rootPath, key)) keys.add(key);
968
+ }
969
+ return [...keys].toSorted();
970
+ }
971
+ async function withReclaimLocks(rootPath, fn) {
972
+ const keys = reclaimKeys(rootPath);
973
+ const acquire = (index) => index === keys.length ? fn(keys) : withManagedTunnelRemovalLock(keys[index], () => acquire(index + 1));
974
+ return acquire(0);
975
+ }
976
+ async function reclaimAll(rootPath, keys = reclaimKeys(rootPath), { preserveRootProject = false } = {}) {
977
+ const dereferenced = [];
978
+ const killedPids = [];
979
+ const deletedDevices = [];
980
+ const parkedDevices = [];
981
+ const evictedDevices = [];
982
+ const poolNotes = [];
983
+ const skippedDevices = [];
984
+ const keptEntries = [];
985
+ const retainedResources = [];
986
+ const stoppedSessions = [];
987
+ const stoppedTunnels = [];
988
+ const releasedLeases = [];
989
+ const removedWorkspaceDirs = [];
990
+ const failedWorkspaceDirs = [];
991
+ for (const key of keys) {
992
+ const r = await reclaimProject(key, {
993
+ deleteOwnedDevices: true,
994
+ parkOwnedDevices: true,
995
+ preserveProjectRecord: preserveRootProject && key === rootPath
996
+ });
997
+ dereferenced.push(...r.dereferenced);
998
+ if (r.killedPid) killedPids.push(r.killedPid);
999
+ deletedDevices.push(...r.deletedDevices);
1000
+ parkedDevices.push(...r.parkedDevices);
1001
+ evictedDevices.push(...r.evictedDevices);
1002
+ poolNotes.push(...r.poolNotes);
1003
+ skippedDevices.push(...r.skippedDevices);
1004
+ if (r.stoppedSession) stoppedSessions.push(r.stoppedSession);
1005
+ if (r.stoppedTunnel) stoppedTunnels.push(r.stoppedTunnel);
1006
+ releasedLeases.push(...r.releasedLeases);
1007
+ removedWorkspaceDirs.push(...r.removedWorkspaceDirs);
1008
+ failedWorkspaceDirs.push(...r.failedWorkspaceDirs);
1009
+ if (r.keptEntry) {
1010
+ keptEntries.push(key);
1011
+ for (const resource of r.failedDevices) retainedResources.push({
1012
+ ...resource,
1013
+ project: key
1014
+ });
1015
+ }
1016
+ }
1017
+ for (const key of keys) {
1018
+ if (keptEntries.includes(key)) continue;
1019
+ const tunnel = readMetroTunnel(key);
1020
+ const managedTunnel = tunnel?.kind === "managed" ? tunnel : null;
1021
+ const remote = readRemoteSession(key);
1022
+ if (!managedTunnel && !remote) continue;
1023
+ keptEntries.push(key);
1024
+ retainedResources.push({
1025
+ platform: remote?.platform ?? "ios",
1026
+ name: remote ? `remote session ${remote.sessionId}` : `${managedTunnel?.provider ?? "managed"} tunnel`,
1027
+ reason: "A remote ownership record appeared during reclaim and is retained for a later cleanup.",
1028
+ project: key
1029
+ });
1030
+ }
1031
+ for (const key of reclaimKeys(rootPath)) {
1032
+ if (keys.includes(key) || keptEntries.includes(key)) continue;
1033
+ keptEntries.push(key);
1034
+ retainedResources.push({
1035
+ name: "new project ownership state",
1036
+ reason: "The project was registered after removal acquired its locks. Retry removal after the other command finishes.",
1037
+ project: key
1038
+ });
1039
+ }
1040
+ return {
1041
+ dereferenced,
1042
+ killedPids,
1043
+ deletedDevices,
1044
+ parkedDevices,
1045
+ evictedDevices,
1046
+ poolNotes,
1047
+ parkedMax: {
1048
+ ios: parkedMaxSetting("ios").max,
1049
+ android: parkedMaxSetting("android").max
1050
+ },
1051
+ skippedDevices,
1052
+ keptEntries,
1053
+ retainedResources,
1054
+ reclaimedKeys: [...keys],
1055
+ stoppedSessions,
1056
+ stoppedTunnels,
1057
+ releasedLeases,
1058
+ removedWorkspaceDirs,
1059
+ failedWorkspaceDirs
1060
+ };
1061
+ }
1062
+ function poolLines(result) {
1063
+ const lines = [];
1064
+ for (const device of result.parkedDevices) lines.push(chalk.dim(phaseLine("device", `parked ${device.name}${device.platform === "android" ? "" : ` (${shortUdid(device.udid)})`}`)));
1065
+ for (const device of result.evictedDevices) lines.push(chalk.dim(phaseLine("device", `deleted ${device.name} (pool over ${result.parkedMax[device.platform ?? "ios"]})`)));
1066
+ for (const note of result.poolNotes) lines.push(chalk.yellow(phaseLine("device", note)));
1067
+ return lines;
1068
+ }
1069
+ function reportRetainedResources(root, result) {
1070
+ console.error(chalk.red(`Refusing to remove ${root}: Stim could not release owned resources.`));
1071
+ for (const resource of result.retainedResources) {
1072
+ console.error(chalk.yellow(` - Stim still tracks ${describeKeptDevice(resource)} for ${resource.project}`));
1073
+ console.error(chalk.dim(` ${resource.reason}`));
1074
+ }
1075
+ for (const kept of result.keptEntries) {
1076
+ if (result.retainedResources.some((resource) => resource.project === kept)) continue;
1077
+ console.error(chalk.yellow(` - retained Stim ownership state for ${kept}`));
1078
+ }
1079
+ console.error(chalk.dim("Fix the reported cause, then run `stim worktree remove` again."));
1080
+ process.exitCode = 1;
1081
+ }
1082
+ function hasRegisteredProjectUnder(rootPath) {
1083
+ const cfg = loadConfig();
1084
+ return Object.keys(cfg?.projects ?? {}).some((key) => isPathPrefix(rootPath, key));
1085
+ }
1086
+ async function reclaimEnvironment(root, why) {
1087
+ await withManagedRemoteWorktreeRemovalLock(root, () => withReclaimLocks(root, async (lockedKeys) => {
1088
+ const result = await reclaimAll(root, lockedKeys);
1089
+ for (const line of poolLines(result)) console.error(line);
1090
+ for (const device of result.deletedDevices) console.error(chalk.dim(phaseLine("device", `deleted ${device}`)));
1091
+ for (const session of result.stoppedSessions) console.error(chalk.dim(phaseLine("device", `stopped remote session ${session}`)));
1092
+ for (const tunnel of result.stoppedTunnels) console.error(chalk.dim(phaseLine("lan", `stopped the ${tunnel} tunnel`)));
1093
+ for (const lease of result.releasedLeases) console.error(chalk.dim(phaseLine("lease", releasedLeaseFact(lease))));
1094
+ for (const pid of result.killedPids) console.error(chalk.dim(phaseLine("metro", `killed pid ${pid}`)));
1095
+ for (const dir of result.removedWorkspaceDirs) console.error(chalk.dim(phaseLine("workspace", `removed ${dir}`)));
1096
+ for (const dir of result.failedWorkspaceDirs) console.error(chalk.yellow(phaseLine("workspace", `could not remove ${dir}`)));
1097
+ if (result.dereferenced.length) console.error(chalk.dim(phaseLine("workspace", `no longer referenced: ${result.dereferenced.join(", ")}`)));
1098
+ if (result.keptEntries.length) {
1099
+ reportRetainedResources(root, result);
1100
+ return;
1101
+ }
1102
+ for (const s of result.skippedDevices) console.error(chalk.yellow(phaseLine("device", `kept ${describeKeptDevice(s)} (${s.reason})`)));
1103
+ console.error(chalk.green(`Reclaimed the environment; the working tree stays (${why}).`));
1104
+ }));
1105
+ }
1106
+ function removalPath(target) {
1107
+ return canonicalExistingPath(target ?? process.cwd());
1108
+ }
1109
+ function canonicalExistingPath(target) {
1110
+ const resolved = resolve$1(target);
1111
+ let existing = resolved;
1112
+ const missing = [];
1113
+ while (!existsSync$1(existing)) {
1114
+ const parent = dirname$1(existing);
1115
+ if (parent === existing) return resolved;
1116
+ missing.unshift(basename$1(existing));
1117
+ existing = parent;
1118
+ }
1119
+ return resolve$1(realpathSync$1(existing), ...missing);
1120
+ }
1121
+ function inspectRemoval(path) {
1122
+ const gitAnswered = hasUncommittedWork(path);
1123
+ const { lines: dirtyLines, restore: podChurn } = excludePodChurn(gitAnswered ? dirtyPaths(path, { limit: Infinity }) : []);
1124
+ const dirty = gitAnswered === null ? null : dirtyLines.length > 0;
1125
+ const unpushed = unpushedCommits(path);
1126
+ return {
1127
+ dirtyLines,
1128
+ podChurn,
1129
+ unpushed,
1130
+ blockers: removalBlockers({
1131
+ dirty,
1132
+ unpushed
1133
+ })
1134
+ };
1135
+ }
1136
+ function printRemovalRefusal(path, inspection) {
1137
+ const { blockers, dirtyLines, unpushed } = inspection;
1138
+ console.error(chalk.red(`Refusing to remove ${path}:`));
1139
+ for (const blocker of blockers) console.error(chalk.red(` - ${blocker}`));
1140
+ if (unpushed && unpushed.length && !hasRemote(path)) console.error(chalk.dim(" (no remote is configured for this worktree, so every commit no other local branch reaches counts as unpushed)"));
1141
+ for (const line of dirtyLines.slice(0, 10)) console.error(chalk.dim(` ${line}`));
1142
+ if (dirtyLines.length) console.error(chalk.dim(` (git -C ${path} status -s for the full list)`));
1143
+ for (const line of removalRemedy(dirtyLines, { worktree: path })) console.error(chalk.dim(line));
1144
+ console.error(chalk.dim("Otherwise: commit or push the branch (only commits found nowhere else are counted,"));
1145
+ console.error(chalk.dim("so pushing publishes nothing but this worktree's own work). --force is a last"));
1146
+ console.error(chalk.dim("resort -- it discards uncommitted changes and untracked files permanently; committed"));
1147
+ console.error(chalk.dim("work stays on the branch."));
1148
+ process.exitCode = 1;
1149
+ }
1150
+ function restorePodChurn(path, files) {
1151
+ for (const file of files) if (restoreFile(path, file)) console.error(chalk.dim(` restored ${file} (pod install churn; the worktree is being removed)`));
1152
+ else console.error(chalk.yellow(` could not restore ${file}; git may refuse to remove the worktree`));
1153
+ }
1154
+ function printRemovalCleanup(result, failed) {
1155
+ for (const line of poolLines(result)) console.error(line);
1156
+ for (const device of result.deletedDevices) console.error(chalk.dim(phaseLine("device", `deleted ${device}`)));
1157
+ for (const session of result.stoppedSessions) console.error(chalk.dim(phaseLine("device", `stopped remote session ${session}`)));
1158
+ for (const tunnel of result.stoppedTunnels) console.error(chalk.dim(phaseLine("lan", `stopped the ${tunnel} tunnel`)));
1159
+ for (const lease of result.releasedLeases) console.error(chalk.dim(phaseLine("lease", releasedLeaseFact(lease))));
1160
+ for (const pid of result.killedPids) console.error(chalk.dim(phaseLine("metro", `killed pid ${pid}`)));
1161
+ if (!failed) for (const dir of result.removedWorkspaceDirs) console.error(chalk.dim(phaseLine("workspace", `removed ${dir}`)));
1162
+ if (result.dereferenced.length) console.error(chalk.dim(phaseLine("workspace", `no longer referenced: ${result.dereferenced.join(", ")}`)));
1163
+ for (const skipped of result.skippedDevices) console.error((failed ? chalk.dim : chalk.yellow)(phaseLine("device", `kept ${describeKeptDevice(skipped)} (${skipped.reason})`)));
1164
+ }
1165
+ async function runRemove(target, opts = {}) {
1166
+ const poolError = parkedMaxSetting("ios").error || parkedMaxSetting("android").error;
1167
+ if (poolError) {
1168
+ console.error(chalk.red(poolError));
1169
+ console.error(chalk.dim(POOL_SETTING_REMEDY));
1170
+ process.exitCode = 1;
1171
+ return;
1172
+ }
1173
+ let path = removalPath(target);
1174
+ if (!existsSync$1(path)) {
1175
+ const pending = getProject(path);
1176
+ if (pending?.worktreeRemovalComplete === true && pending.worktreeBranchOwned === true && pending.worktreeBranch && pending.worktreeMainRoot && pending.worktreePendingBranchSha) {
1177
+ const branch = pending.worktreeBranch;
1178
+ const mainRoot = pending.worktreeMainRoot;
1179
+ if (!existsSync$1(mainRoot)) {
1180
+ console.error(chalk.red(`Cannot finish branch cleanup for ${path}: source checkout ${mainRoot} is missing.`));
1181
+ console.error(chalk.dim(`Delete ${branch} from the repository, then run \`stim gc --delete\`.`));
1182
+ process.exitCode = 1;
1183
+ return;
1184
+ }
1185
+ if (!branchExists(mainRoot, branch)) {
1186
+ removeProject(path);
1187
+ console.error(chalk.dim(phaseLine("branch", `${branch} is already absent; cleared its pending cleanup record`)));
1188
+ return;
1189
+ }
1190
+ const checkedOutAt = listWorktrees(mainRoot).find((candidate) => candidate.branch === branch && resolve$1(candidate.path) !== resolve$1(path))?.path;
1191
+ if (checkedOutAt) {
1192
+ console.error(chalk.red(`Refusing pending cleanup for ${branch}: it is checked out at ${checkedOutAt}.`));
1193
+ console.error(chalk.dim("Switch that worktree to another branch, then retry this command."));
1194
+ process.exitCode = 1;
1195
+ return;
1196
+ }
1197
+ const currentSha = resolveFullRef(mainRoot, branch);
1198
+ if (currentSha !== pending.worktreePendingBranchSha) {
1199
+ console.error(chalk.red(`Refusing pending cleanup for ${branch}: its tip changed from ${pending.worktreePendingBranchSha} to ${currentSha || "an unknown commit"}.`));
1200
+ console.error(chalk.dim("The branch can contain new work. Inspect it and delete it manually if appropriate."));
1201
+ process.exitCode = 1;
1202
+ return;
1203
+ }
1204
+ try {
1205
+ deleteBranch(mainRoot, branch, pending.worktreePendingBranchSha);
1206
+ removeProject(path);
1207
+ console.error(chalk.dim(phaseLine("branch", `deleted ${branch}; cleared its pending cleanup record`)));
1208
+ } catch (error) {
1209
+ console.error(chalk.red(`Could not delete branch ${branch}: ${String(error?.message || error)}`));
1210
+ console.error(chalk.dim(`Retry with: stim worktree remove ${path}`));
1211
+ process.exitCode = 1;
1212
+ }
1213
+ return;
1214
+ }
1215
+ console.error(chalk.red(`No such worktree: ${path}`));
1216
+ process.exitCode = 1;
1217
+ return;
1218
+ }
1219
+ const worktrees = listWorktrees(path);
1220
+ const entry = matchWorktreeEntry(worktrees, path);
1221
+ if (!entry) {
1222
+ if (gitCommonDir(path) === null && hasRegisteredProjectUnder(path)) {
1223
+ await reclaimEnvironment(path, "it is not a git repository");
1224
+ return;
1225
+ }
1226
+ console.error(chalk.red(`Refusing to remove ${path}: it is not inside any worktree known to git.`));
1227
+ console.error(chalk.dim(" Run it from inside the worktree, or pass the worktree root path, e.g. as printed by `git worktree list`."));
1228
+ process.exitCode = 1;
1229
+ return;
1230
+ }
1231
+ if (isMainWorkingTree(entry.path)) {
1232
+ if (entry.path !== path) console.error(chalk.dim(`${path} is inside the source checkout ${entry.path}; reclaiming its environment.`));
1233
+ await reclaimEnvironment(entry.path, "it is the source checkout");
1234
+ return;
1235
+ }
1236
+ if (entry.path !== path) {
1237
+ console.error(chalk.dim(`${path} is inside the worktree ${entry.path}; removing that.`));
1238
+ path = entry.path;
1239
+ }
1240
+ const project = getProject(path);
1241
+ const branch = entry.branch;
1242
+ const ownsBranch = Boolean(branch && project?.worktreeBranchOwned === true && project.worktreeBranch === branch);
1243
+ const approvedBranchSha = ownsBranch ? resolveFullRef(path, "HEAD") : null;
1244
+ const inspection = inspectRemoval(path);
1245
+ if (inspection.blockers.length && !opts.force) {
1246
+ printRemovalRefusal(path, inspection);
1247
+ return;
1248
+ }
1249
+ const deleteOwnedBranch = Boolean(ownsBranch && approvedBranchSha && inspection.unpushed?.length === 0);
1250
+ 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;
1251
+ const branchDeleteCwd = worktrees.find((candidate) => isMainWorkingTree(candidate.path))?.path;
1252
+ await withManagedRemoteWorktreeRemovalLock(path, () => withReclaimLocks(path, async (lockedKeys) => {
1253
+ const result = await reclaimAll(path, lockedKeys, { preserveRootProject: true });
1254
+ if (result.keptEntries.length) {
1255
+ reportRetainedResources(path, result);
1256
+ return;
1257
+ }
1258
+ restorePodChurn(path, inspection.podChurn);
1259
+ try {
1260
+ removeWorktree(path, { force: opts.force });
1261
+ } catch (error) {
1262
+ console.error(chalk.red(`git worktree remove failed: ${String(error?.message || error)}`));
1263
+ console.error(chalk.dim(`The directory and Stim ownership record for ${path} were kept.`));
1264
+ printRemovalCleanup(result, true);
1265
+ process.exitCode = 1;
1266
+ return;
1267
+ }
1268
+ const finish = () => {
1269
+ printRemovalCleanup(result, false);
1270
+ console.error(chalk.dim(phaseLine("removed", path)));
1271
+ };
1272
+ if (deleteOwnedBranch && branch) {
1273
+ if (!approvedBranchSha) {
1274
+ console.error(chalk.yellow(phaseLine("branch", `kept ${branch} (Stim could not record its commit before removal)`)));
1275
+ process.exitCode = 1;
1276
+ finish();
1277
+ return;
1278
+ }
1279
+ upsertProject(path, {
1280
+ worktreeRemovalComplete: true,
1281
+ worktreePendingBranchSha: approvedBranchSha
1282
+ });
1283
+ if (!branchDeleteCwd) {
1284
+ console.error(chalk.yellow(phaseLine("branch", `kept ${branch} (Stim could not find the source checkout)`)));
1285
+ console.error(chalk.dim(` Retry with: stim worktree remove ${path}`));
1286
+ process.exitCode = 1;
1287
+ finish();
1288
+ return;
1289
+ }
1290
+ const checkedOutAt = listWorktrees(branchDeleteCwd).find((candidate) => candidate.branch === branch && resolve$1(candidate.path) !== resolve$1(path))?.path;
1291
+ if (checkedOutAt) {
1292
+ console.error(chalk.yellow(phaseLine("branch", `kept ${branch} (it is checked out at ${checkedOutAt})`)));
1293
+ console.error(chalk.dim(` Switch that worktree to another branch, then retry: stim worktree remove ${path}`));
1294
+ process.exitCode = 1;
1295
+ finish();
1296
+ return;
1297
+ }
1298
+ try {
1299
+ deleteBranch(branchDeleteCwd, branch, approvedBranchSha);
1300
+ console.error(chalk.dim(phaseLine("branch", `deleted ${branch}`)));
1301
+ } catch (error) {
1302
+ console.error(chalk.yellow(phaseLine("branch", `kept ${branch} (${String(error?.message || error)})`)));
1303
+ console.error(chalk.dim(` Retry with: git -C ${branchDeleteCwd} branch -D -- ${branch}`));
1304
+ process.exitCode = 1;
1305
+ finish();
1306
+ return;
1307
+ }
1308
+ } else if (branch && retainedBranchReason) console.error(chalk.dim(phaseLine("branch", `kept ${branch} (${retainedBranchReason})`)));
1309
+ removeProject(path);
1310
+ finish();
1311
+ }));
1312
+ }
1313
+ function registerRemove(worktree) {
1314
+ 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);
1315
+ }
1316
+ function worktreeCommand(program) {
1317
+ const worktree = program.command("worktree").description("Warm and remove Git worktrees");
1318
+ registerWarm(worktree);
1319
+ registerRemove(worktree);
1320
+ }
1321
+ //#endregion
1322
+ export { worktreeCommand as default, dependencyInstallCommand, excludePodChurn, matchWorktreeEntry, porcelainPath, registerRemove, registerWarm, removalBlockers, removalPath, removalRemedy };