stim 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +23 -155
  2. package/dist/{android-BRgBYzU0.mjs → android-BPGizezh.mjs} +112 -135
  3. package/dist/{android-DeYdjjBP.mjs → android-DEgo4oCo.mjs} +2 -2
  4. package/dist/{android-DWssqHQ8.mjs → android-_y4Ohi5z.mjs} +54 -15
  5. package/dist/android-cas-compiler.mjs +2 -2
  6. package/dist/{android-cas-SWIjzeuL.mjs → android-cas-eiExNLf7.mjs} +21 -14
  7. package/dist/app-install-Uf3a83Tv.mjs +819 -0
  8. package/dist/{build-lock-CgtsB6TV.mjs → build-lock-DPXPLgTc.mjs} +120 -24
  9. package/dist/build-progress-CVqkfPC6.mjs +188 -0
  10. package/dist/{cache-manifest-CYJi8-Hx.mjs → cache-manifest-Dg7OnMrp.mjs} +4 -4
  11. package/dist/cache-manifest.mjs +1 -1
  12. package/dist/cli.mjs +22 -19
  13. package/dist/collector-run.d.mts +2 -0
  14. package/dist/collector-run.mjs +10 -9
  15. package/dist/{command-output-B3ycpsYv.mjs → command-output-gByVmIjy.mjs} +11 -2
  16. package/dist/{config-B7iyKWkL.mjs → config-Cp9lM3wX.mjs} +187 -57
  17. package/dist/{dependency-state-D0M7oKA7.mjs → dependency-state-Bq5DcLGn.mjs} +2 -2
  18. package/dist/{deps-oCXVNEqQ.mjs → deps-Cip5vUm1.mjs} +8 -5
  19. package/dist/{detached-entry-Bpo6ZY_I.mjs → detached-entry-KEu0_AXL.mjs} +1 -1
  20. package/dist/{device-BUoZyMoR.mjs → dev-client-HyqISM0h.mjs} +1178 -635
  21. package/dist/{device-RdujjHLB.mjs → device-D4U-xLty.mjs} +13 -20
  22. package/dist/{device-lease-Ffz_M5t0.mjs → device-lease-Crs-HkJY.mjs} +16 -9
  23. package/dist/{device-pool-BxRwdufO.mjs → device-pool-CnTAz8X1.mjs} +7 -7
  24. package/dist/{device-remote-ByN8P6CB.mjs → device-remote-AQ5rQPqr.mjs} +482 -35
  25. package/dist/{doctor-BMt156qe.mjs → doctor-B_Ei3bZu.mjs} +95 -107
  26. package/dist/{error-diagnostics-D1Kaf-eT.mjs → error-diagnostics-Vfz83GCF.mjs} +22 -16
  27. package/dist/exec-jIAlgilk.mjs +132 -0
  28. package/dist/{gc-v2YcoJvm.mjs → gc-Ckw8RQHi.mjs} +763 -126
  29. package/dist/{guide-BTAlD1_y.mjs → guide-BiAbhs1J.mjs} +891 -501
  30. package/dist/{guide-status-Craq9z0t.mjs → guide-status-BAMVU0ZA.mjs} +5 -3
  31. package/dist/{ios-BcCiI4TS.mjs → ios-BL2NFckr.mjs} +43 -20
  32. package/dist/{ios-BP04pY5f.mjs → ios-BzUJWk1K.mjs} +141 -175
  33. package/dist/{ios-device-CauFJiYx.mjs → ios-device-D972i9wf.mjs} +149 -52
  34. package/dist/{ios-device-CFXA46ez.mjs → ios-device-DPsSm0v_.mjs} +3 -2
  35. package/dist/launch-verify-Q_OtW1Rf.mjs +365 -0
  36. package/dist/{logs-CakdBTtm.mjs → logs-CI9NQSK7.mjs} +18 -13
  37. package/dist/{logs-query-BGL-Pl12.mjs → logs-query-Cqma3WIu.mjs} +36 -28
  38. package/dist/{metro-C7bcJ0uo.mjs → metro-DrLHaMn2.mjs} +78 -8
  39. package/dist/{named-ports-DzMOlmaX.mjs → named-ports-CmLQSIRj.mjs} +4 -4
  40. package/dist/{ndjson-BZwI1NBv.mjs → ndjson-9uFNRwfe.mjs} +36 -3
  41. package/dist/{ownership-Cp52QAK1.mjs → ownership-D15Op8vN.mjs} +3 -3
  42. package/dist/{ownership-wolE-0Do.mjs → ownership-DCQPuWEc.mjs} +26 -7
  43. package/dist/{ports-BIbJaKNW.mjs → ports-BNKoWVWZ.mjs} +3 -3
  44. package/dist/{process-output-Dl5uKE03.mjs → process-output-Fpl8PYi8.mjs} +1 -1
  45. package/dist/{project-DDZWf_16.mjs → project-DS112DYB.mjs} +8 -3
  46. package/dist/{reload-BdypPbjM.mjs → reload-ZNJUo1pl.mjs} +12 -13
  47. package/dist/{server-bare-BQkyxa_b.mjs → server-bare-DWkkcIxV.mjs} +2 -2
  48. package/dist/{server-expo-BVAA1OqJ.mjs → server-expo-B_lctBR3.mjs} +8 -7
  49. package/dist/server-expo-CeILEu86.mjs +2 -0
  50. package/dist/settings-BKNh5rl4.mjs +376 -0
  51. package/dist/{settings-BXXorVM2.mjs → settings-D030k0t4.mjs} +464 -152
  52. package/dist/settings.schema.json +989 -0
  53. package/dist/{teardown-BJqEzPo_.mjs → sim-pool-KaIsp2Ft.mjs} +14 -419
  54. package/dist/simslim-B_LsGLuJ.mjs +259 -0
  55. package/dist/{slot-launch-ZUUe0TQP.mjs → slot-launch-DU9A-gYw.mjs} +4 -4
  56. package/dist/spawn-claims-Dn3C_N6w.mjs +49 -0
  57. package/dist/{start-CmxtOKjU.mjs → start-DYkkKF0s.mjs} +27 -31
  58. package/dist/{state-DZobzxLW.mjs → state-NcJzMEBF.mjs} +5 -3
  59. package/dist/{stats-CLg5RuDS.mjs → stats-B63-JVAY.mjs} +4 -4
  60. package/dist/{stats-BYcZ6n1h.mjs → stats-DG9e66Rz.mjs} +66 -5
  61. package/dist/status-S6ehL4Ui.mjs +932 -0
  62. package/dist/{stop-C8JW6kKp.mjs → stop-BO28HGqF.mjs} +101 -25
  63. package/dist/{stop-B1kOpYei.mjs → stop-T1R-cZRz.mjs} +2 -2
  64. package/dist/supervisor-run.mjs +52 -34
  65. package/dist/{workspace-process-lock-BYfZz_S-.mjs → workspace-process-lock-DWaiRD0e.mjs} +14 -9
  66. package/dist/{workspace-state-BT5E3SA2.mjs → workspace-state-DF2gCFhx.mjs} +52 -29
  67. package/dist/worktree-DKhSF0UX.mjs +2 -0
  68. package/dist/{worktree-CFLA-6MX.mjs → worktree-LGB-UwgG.mjs} +603 -59
  69. package/dist/{worktree-FG_ZKKdb.mjs → worktree-R_cUlE7s.mjs} +62 -41
  70. package/dist/{xcode-B6YaWeg0.mjs → xcode-BZqyxh3e.mjs} +216 -58
  71. package/package.json +5 -6
  72. package/skill/SKILL.md +2 -0
  73. package/dist/app-install-BDbsSgCl.mjs +0 -1175
  74. package/dist/build-slots-C-oAHRT1.mjs +0 -151
  75. package/dist/dev-client-BmT-ioFI.mjs +0 -797
  76. package/dist/exec-B29XNuhP.mjs +0 -77
  77. package/dist/reclaim-KJ-0uWEF.mjs +0 -361
  78. package/dist/server-expo-YDDG3eP7.mjs +0 -2
  79. package/dist/status-DdkLki47.mjs +0 -426
  80. package/dist/{metro-store-CDDZHtPl.mjs → metro-store-DzeWkfqW.mjs} +1 -1
@@ -1,24 +1,23 @@
1
- import { t as getExecutor } from "./exec-B29XNuhP.mjs";
2
- import { A as isOnMountedVolume, C as withConfigLock, E as ownership_claim_exports, I as deviceSlotPlatforms, M as volumeRootFor, O as directorySize, R as projectDeviceSlots, f as getProject, g as loadConfig, i as clearDevice, j as listMountedVolumes, k as formatBytes, u as getConfigDir, v as removeProject } from "./config-B7iyKWkL.mjs";
3
- import { i as registeredCaches } from "./cache-manifest-CYJi8-Hx.mjs";
4
- import { h as workspaceDir, y as workspaceStateFile } from "./workspace-state-BT5E3SA2.mjs";
5
- import { g as repoRoot, s as gitCommonDir } from "./worktree-FG_ZKKdb.mjs";
6
- import { a as findProjectRoot, i as detectIsExpo } from "./project-DDZWf_16.mjs";
7
- import { A as resolveSettings, N as settingShapeErrors, a as SETTING_SHAPE_REMEDY } from "./settings-BXXorVM2.mjs";
8
- import { C as listAvds, O as ownedAvdDirectory, T as listOrphanedAvdDirectories } from "./android-DWssqHQ8.mjs";
9
- import { m as shortUdid, o as formatLongDuration, u as plural } from "./command-output-B3ycpsYv.mjs";
10
- import { r as listBuildLocks } from "./build-lock-CgtsB6TV.mjs";
11
- import { S as resolveEasCliBin, a as POOL_SETTING_REMEDY, i as teardownParkedIosSim, l as parkedMaxSetting, n as teardownOwnedIosSim, r as teardownParkedAvd, s as dropParked, t as teardownOwnedAvd, u as readParked } from "./teardown-BJqEzPo_.mjs";
12
- import { d as listIosDeviceTypes, g as parseRuntimeVersion, u as listAllIosSims } from "./ios-BcCiI4TS.mjs";
13
- import { i as listLeaseFiles, r as leaseIsExpired, u as removeExpiredLease } from "./device-lease-Ffz_M5t0.mjs";
14
- import { n as listBuildSlots } from "./build-slots-C-oAHRT1.mjs";
15
- import { C as verifyStoppedSession, S as stopSessionArgs, _ as inspectSessionForTeardown, d as withEasProjectLock, f as easMachineStateRoot, g as getSessionArgs, h as findOrphanedOwnedSessions, m as removeEasSessionClaim, p as readEasSessionLedger, u as withRemoteSessionLock, v as isDefinitiveMissingSessionError, x as parseSessionListPage, y as listOwnedSessionsArgs } from "./device-remote-ByN8P6CB.mjs";
16
- import { n as reclaimProject, t as describeDereferenced } from "./reclaim-KJ-0uWEF.mjs";
1
+ import { F as formatBytes, I as isOnMountedVolume, K as projectDeviceSlots, L as listMountedVolumes, M as readJsonFile, P as directorySize, R as measuredDirectorySize, T as withConfigLock, U as deviceSlotPlatforms, b as removeProject, f as getProject, g as loadConfig, h as isPathPrefix, i as clearDevice, j as isJsonObject, k as ownership_claim_exports, u as getConfigDir, z as volumeRootFor } from "./config-Cp9lM3wX.mjs";
2
+ import { t as getExecutor } from "./exec-jIAlgilk.mjs";
3
+ import { i as registeredCaches } from "./cache-manifest-Dg7OnMrp.mjs";
4
+ import { S as workspaceStateFile, _ as workspaceDir, s as workspaceLastUsed, x as workspaceName$1 } from "./workspace-state-DF2gCFhx.mjs";
5
+ import { S as unpushedCommits, c as hasPopulatedSubmodules, f as listWorktrees, o as dirtyPaths, u as hasUncommittedWork, x as sourceCheckoutOf } from "./worktree-R_cUlE7s.mjs";
6
+ import { a as detectIsExpo, o as findProjectRoot } from "./project-DS112DYB.mjs";
7
+ import { E as listOrphanedAvdDirectories, k as ownedAvdDirectory, w as listAvds } from "./android-_y4Ohi5z.mjs";
8
+ import { h as shortUdid, o as formatLongDuration, u as plural } from "./command-output-gByVmIjy.mjs";
9
+ import { n as listBuildLocks } from "./build-lock-DPXPLgTc.mjs";
10
+ import { b as resolveEasCliBin, o as parkedMaxSetting, r as dropParked, s as readParked, t as POOL_SETTING_REMEDY } from "./sim-pool-KaIsp2Ft.mjs";
11
+ import { _ as parseRuntimeVersion, f as listIosDeviceTypes, u as listAllIosSims } from "./ios-BL2NFckr.mjs";
12
+ import { i as listLeaseFiles, r as leaseIsExpired, u as removeExpiredLease } from "./device-lease-Crs-HkJY.mjs";
13
+ import { _ as listBuildSlots } from "./xcode-BZqyxh3e.mjs";
14
+ import { C as verifyStoppedSession, D as teardownParkedAvd, E as teardownOwnedIosSim, O as teardownParkedIosSim, S as stopSessionArgs, T as teardownOwnedAvd, _ as inspectSessionForTeardown, d as withEasProjectLock, f as easMachineStateRoot, g as getSessionArgs, h as findOrphanedOwnedSessions, m as removeEasSessionClaim, p as readEasSessionLedger, u as withRemoteSessionLock, v as isDefinitiveMissingSessionError, x as parseSessionListPage, y as listOwnedSessionsArgs } from "./device-remote-AQ5rQPqr.mjs";
15
+ import { _ as workspaceInUse, a as reclaimKeys, d as removeWorktreeTarget, g as withIdleWorkspace, h as emptyWorkspaceDir, m as reclaimProject, n as excludePodChurn, p as describeDereferenced, r as matchWorktreeEntry, v as canonicalPath } from "./worktree-LGB-UwgG.mjs";
17
16
  import { InvalidArgumentError } from "commander";
18
- import { existsSync, readFileSync, readdirSync, realpathSync, rmSync, statSync } from "fs";
19
- import { homedir, tmpdir } from "os";
17
+ import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, rmSync, statSync } from "fs";
20
18
  import { basename, dirname, isAbsolute, join, relative, resolve } from "path";
21
19
  import { METRO_NAMED_CACHE_LAYOUT } from "@stim-cli/core";
20
+ import { homedir, tmpdir } from "os";
22
21
  import chalk from "chalk";
23
22
  //#region src/cache/caches.ts
24
23
  function xcodeDerivedDataRoot() {
@@ -54,45 +53,26 @@ function metroFileMaps() {
54
53
  } catch {
55
54
  return null;
56
55
  }
57
- if (!names.length) return null;
56
+ const uid = process.getuid?.();
57
+ const files = [];
58
58
  let bytes = 0;
59
59
  for (const n of names) try {
60
- bytes += statSync(join(root, n)).size;
60
+ const st = statSync(join(root, n));
61
+ if (uid !== void 0 && st.uid !== uid) continue;
62
+ files.push(join(root, n));
63
+ bytes += st.size;
61
64
  } catch {}
65
+ if (!files.length) return null;
62
66
  return {
63
67
  name: "Metro file maps",
64
68
  dir: root,
65
- files: names.map((n) => join(root, n)),
69
+ files,
66
70
  bytes,
67
71
  prune: "entries",
68
- note: `${names.length} file(s), one per project root Metro has served`
72
+ note: `${files.length} file(s), one per project root Metro has served`
69
73
  };
70
74
  }
71
- function declaredCaches(paths) {
72
- return (paths || []).map((p) => resolve(p.startsWith("~") ? join(homedir(), p.slice(1)) : p)).filter((p) => existsSync(p)).map((dir) => ({
73
- name: "declared",
74
- dir,
75
- prune: "entries",
76
- note: "from the `caches` setting"
77
- }));
78
- }
79
- function projectSettings(cwd) {
80
- const root = findProjectRoot(cwd);
81
- if (!root) return {};
82
- return resolveSettings({
83
- projectPath: root,
84
- gitCommonDir: gitCommonDir(root),
85
- repoRoot: repoRoot(root)
86
- });
87
- }
88
- function declaredCachePaths(cwd = process.cwd()) {
89
- const declared = projectSettings(cwd).caches;
90
- return Array.isArray(declared) ? declared : [];
91
- }
92
- function projectSettingShapeErrors(cwd = process.cwd()) {
93
- return settingShapeErrors(projectSettings(cwd));
94
- }
95
- function discoverCaches({ declared = [] } = {}) {
75
+ function discoverCaches() {
96
76
  const gradle = gradleBuildCache();
97
77
  const gradleDir = gradle ? canonicalCacheDir(gradle.dir) : null;
98
78
  const registered = protectNestedMetroAncestors(suppressLegacyMetroAncestors(registeredCaches().map((c) => Object.assign({}, c, {
@@ -104,8 +84,7 @@ function discoverCaches({ declared = [] } = {}) {
104
84
  const detected = [
105
85
  compilationCache(),
106
86
  gradle,
107
- metroFileMaps(),
108
- ...declaredCaches(declared)
87
+ metroFileMaps()
109
88
  ].filter((c) => Boolean(c)).map((c) => Object.assign({}, c, { source: "detected" }));
110
89
  return mergeCacheDescriptors([...registered, ...detected], gradleDir);
111
90
  }
@@ -218,6 +197,7 @@ function pruneCache(cache, { olderThanDays, now = Date.now() } = {}) {
218
197
  const entries = cache.files ?? entriesAtDepth(cache.dir, cache.entriesDepth ?? 1);
219
198
  let removed = 0;
220
199
  let bytes = 0;
200
+ let failed = 0;
221
201
  for (const entry of entries) {
222
202
  let used;
223
203
  let size;
@@ -236,11 +216,14 @@ function pruneCache(cache, { olderThanDays, now = Date.now() } = {}) {
236
216
  });
237
217
  removed++;
238
218
  bytes += size;
239
- } catch {}
219
+ } catch {
220
+ failed++;
221
+ }
240
222
  }
241
223
  return {
242
224
  removed,
243
225
  bytes,
226
+ failed,
244
227
  skipped: null
245
228
  };
246
229
  }
@@ -262,17 +245,8 @@ function entriesAtDepth(dir, depth) {
262
245
  return level;
263
246
  }
264
247
  //#endregion
265
- //#region src/commands/gc/paths.ts
266
- function canonicalPath(p) {
267
- try {
268
- return realpathSync(p);
269
- } catch {
270
- return resolve(p);
271
- }
272
- }
273
- //#endregion
274
248
  //#region src/commands/gc/caches.ts
275
- const DAY_MS$1 = 864e5;
249
+ const DAY_MS$3 = 864e5;
276
250
  function cacheSweepIsScoped() {
277
251
  return Boolean(process.env.STIM_HOME);
278
252
  }
@@ -289,10 +263,16 @@ function machineGlobalReason(cache) {
289
263
  return `STIM_HOME scopes this config, but ${cache.dir} is outside it and therefore machine-global`;
290
264
  }
291
265
  const EVERY_CACHE = "all";
266
+ const WORKSPACE_OUTPUTS = "workspaces";
267
+ function includesWorkspaceOutputs(name) {
268
+ const wanted = name?.trim().toLowerCase();
269
+ return !wanted || wanted === EVERY_CACHE || wanted === WORKSPACE_OUTPUTS;
270
+ }
292
271
  function selectCaches(caches, name) {
293
272
  if (!name) return caches;
294
273
  const wanted = name.trim().toLowerCase();
295
274
  if (wanted === EVERY_CACHE) return caches;
275
+ if (wanted === WORKSPACE_OUTPUTS) return [];
296
276
  return caches.filter((c) => c.name.toLowerCase().includes(wanted) || c.dir.toLowerCase().includes(wanted));
297
277
  }
298
278
  function planCacheEmptying(caches, all) {
@@ -335,7 +315,7 @@ function emptyCache(cache) {
335
315
  };
336
316
  if (cache.prune !== "atomic") return pruneCache(cache, {
337
317
  olderThanDays: 0,
338
- now: Date.now() + DAY_MS$1
318
+ now: Date.now() + DAY_MS$3
339
319
  });
340
320
  let names;
341
321
  try {
@@ -365,6 +345,11 @@ function emptyCache(cache) {
365
345
  skipped: null
366
346
  };
367
347
  }
348
+ function reportFailures(cache, failed) {
349
+ if (!failed) return;
350
+ console.log(chalk.red(`${cache.name}: ${failed} entr${failed === 1 ? "y" : "ies"} in ${cache.dir} could not be removed`));
351
+ process.exitCode = 1;
352
+ }
368
353
  function trimCaches(caches, olderThan) {
369
354
  let cacheBytes = 0;
370
355
  for (const c of caches) {
@@ -377,7 +362,8 @@ function trimCaches(caches, olderThan) {
377
362
  else if (r.removed) {
378
363
  cacheBytes += r.bytes;
379
364
  console.log(chalk.green(`Trimmed ${c.name}: ${r.removed} entr${r.removed === 1 ? "y" : "ies"} (${formatBytes(r.bytes)})`));
380
- } else console.log(chalk.dim(`${c.name}: nothing older than ${olderThan}d`));
365
+ } else if (!r.failed) console.log(chalk.dim(`${c.name}: nothing older than ${olderThan}d`));
366
+ reportFailures(c, r.failed);
381
367
  }
382
368
  if (cacheBytes) console.log(chalk.dim(`Trimmed ${formatBytes(cacheBytes)} of shared cache. The next build that wanted those entries pays to rebuild them.`));
383
369
  }
@@ -393,8 +379,8 @@ function emptyCaches(caches) {
393
379
  else if (r.removed) {
394
380
  cacheBytes += r.bytes;
395
381
  console.log(chalk.green(`Emptied ${c.name}: ${r.removed} entr${r.removed === 1 ? "y" : "ies"} (${formatBytes(r.bytes)})`));
396
- } else console.log(chalk.dim(`${c.name}: already empty`));
397
- if (r.failed) console.log(chalk.red(` ${r.failed} entr${r.failed === 1 ? "y" : "ies"} in ${c.dir} could not be removed`));
382
+ } else if (!r.failed) console.log(chalk.dim(`${c.name}: already empty`));
383
+ reportFailures(c, r.failed);
398
384
  }
399
385
  if (cacheBytes) console.log(chalk.dim(`Emptied ${formatBytes(cacheBytes)} of shared cache. Every build that wanted any of it now pays to rebuild it.`));
400
386
  }
@@ -402,7 +388,7 @@ function emptyCaches(caches) {
402
388
  //#region src/commands/gc/devices.ts
403
389
  const DEVICE_LIST_TIMEOUT_MS = 3e4;
404
390
  const AVD_SIZE_TIMEOUT_MS = 5e3;
405
- const DAY_MS = 864e5;
391
+ const DAY_MS$2 = 864e5;
406
392
  function describeKept(ref) {
407
393
  return ref.mounted ? `referenced by ${ref.path}` : `referenced by ${ref.path} (volume not mounted; kept just in case)`;
408
394
  }
@@ -472,7 +458,7 @@ function findOrphanedDevices({ sims = [], avds = [], config, isMounted, deadProj
472
458
  }
473
459
  function findStaleProjectDevices({ config, sims = [], avds = [], olderThanDays, now = Date.now(), lastTouched, deadProjects = [] }) {
474
460
  if (!Number.isFinite(olderThanDays) || typeof lastTouched !== "function") return [];
475
- const cutoff = now - olderThanDays * DAY_MS;
461
+ const cutoff = now - olderThanDays * DAY_MS$2;
476
462
  const dead = new Set(deadProjects);
477
463
  const liveSims = new Map(sims.filter((s) => s?.name?.startsWith("stim-")).map((s) => [s.udid, s.name]));
478
464
  const liveAvds = new Set(avds.filter((a) => typeof a === "string" && a.startsWith("stim-")));
@@ -481,7 +467,7 @@ function findStaleProjectDevices({ config, sims = [], avds = [], olderThanDays,
481
467
  if (dead.has(path) || !isAbsolute(path)) continue;
482
468
  const touched = lastTouched(path);
483
469
  if (!Number.isFinite(touched) || touched >= cutoff) continue;
484
- const idleDays = Math.floor((now - touched) / DAY_MS);
470
+ const idleDays = Math.floor((now - touched) / DAY_MS$2);
485
471
  for (const { slot, platforms } of projectDeviceSlots(proj)) {
486
472
  const ios = platforms.ios;
487
473
  if (ios?.owned && ios.deviceUdid && liveSims.has(ios.deviceUdid)) stale.push({
@@ -1117,6 +1103,262 @@ async function deleteEasSessions(easSessionSweep, deps) {
1117
1103
  return deleteFailures;
1118
1104
  }
1119
1105
  //#endregion
1106
+ //#region src/commands/gc/workspaces.ts
1107
+ function workspacesRoot() {
1108
+ return join(getConfigDir(), "workspaces");
1109
+ }
1110
+ function isInsideWorkspaces(dir) {
1111
+ const rel = relative(canonicalPath(workspacesRoot()), canonicalPath(dir));
1112
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
1113
+ }
1114
+ function readRecordedRoot(dir, name) {
1115
+ let parsed;
1116
+ try {
1117
+ parsed = readJsonFile(join(dir, "workspace.json"));
1118
+ } catch (error) {
1119
+ return {
1120
+ projectRoot: null,
1121
+ problem: error?.code === "ENOENT" ? "it has no workspace.json" : "its workspace.json does not parse"
1122
+ };
1123
+ }
1124
+ const projectRoot = isJsonObject(parsed) ? parsed.projectRoot : void 0;
1125
+ if (typeof projectRoot !== "string" || !isAbsolute(projectRoot)) return {
1126
+ projectRoot: null,
1127
+ problem: "its workspace.json records no absolute project root"
1128
+ };
1129
+ if (workspaceName$1(projectRoot) !== name) return {
1130
+ projectRoot: null,
1131
+ problem: `its workspace.json records ${projectRoot}, whose workspace directory has another name`
1132
+ };
1133
+ return {
1134
+ projectRoot,
1135
+ problem: null
1136
+ };
1137
+ }
1138
+ function listWorkspaceDirs() {
1139
+ const root = workspacesRoot();
1140
+ let names;
1141
+ try {
1142
+ names = readdirSync(root).toSorted();
1143
+ } catch {
1144
+ return [];
1145
+ }
1146
+ const entries = [];
1147
+ for (const name of names) {
1148
+ const dir = join(root, name);
1149
+ try {
1150
+ if (!statSync(dir).isDirectory()) continue;
1151
+ } catch {
1152
+ continue;
1153
+ }
1154
+ entries.push({
1155
+ dir,
1156
+ ...readRecordedRoot(dir, name)
1157
+ });
1158
+ }
1159
+ return entries;
1160
+ }
1161
+ function classifyWorkspaceDirs(entries, { registryKeys, exists, isMounted, inUse }) {
1162
+ const orphaned = [];
1163
+ const skipped = [];
1164
+ for (const { dir, projectRoot, problem } of entries) {
1165
+ if (projectRoot === null) {
1166
+ skipped.push({
1167
+ dir,
1168
+ reason: `workspace directory not resolved: ${problem ?? "unknown project root"}`
1169
+ });
1170
+ continue;
1171
+ }
1172
+ const present = exists(projectRoot);
1173
+ if (present === null) {
1174
+ skipped.push({
1175
+ dir,
1176
+ reason: `cannot tell whether its project root ${projectRoot} still exists`
1177
+ });
1178
+ continue;
1179
+ }
1180
+ if (present) continue;
1181
+ if (registryKeys.some((key) => isPathPrefix(projectRoot, key))) continue;
1182
+ if (!isMounted(projectRoot)) {
1183
+ skipped.push({
1184
+ dir,
1185
+ reason: `the volume of its project root ${projectRoot} is not mounted`
1186
+ });
1187
+ continue;
1188
+ }
1189
+ const reasons = inUse(projectRoot);
1190
+ if (reasons.length) {
1191
+ skipped.push({
1192
+ dir,
1193
+ reason: `its project root ${projectRoot} is gone, but ${reasons.join("; ")}`
1194
+ });
1195
+ continue;
1196
+ }
1197
+ orphaned.push({
1198
+ dir,
1199
+ projectRoot
1200
+ });
1201
+ }
1202
+ return {
1203
+ orphaned,
1204
+ skipped
1205
+ };
1206
+ }
1207
+ function rootPresence(path) {
1208
+ try {
1209
+ lstatSync(path);
1210
+ return true;
1211
+ } catch (error) {
1212
+ const code = error?.code;
1213
+ return code === "ENOENT" || code === "ENOTDIR" ? false : null;
1214
+ }
1215
+ }
1216
+ function collectOrphanedWorkspaces(registryKeys, mountedVolumes) {
1217
+ const classified = classifyWorkspaceDirs(listWorkspaceDirs(), {
1218
+ registryKeys,
1219
+ exists: rootPresence,
1220
+ isMounted: (path) => isOnMountedVolume(path, mountedVolumes),
1221
+ inUse: (root) => workspaceInUse(root)
1222
+ });
1223
+ return {
1224
+ orphaned: classified.orphaned.map((entry) => Object.assign({}, entry, { bytes: sizeOf(entry.dir) })),
1225
+ skipped: classified.skipped
1226
+ };
1227
+ }
1228
+ const REBUILD_COST = "The next build of an unchanged app installs from the shared build cache; after a native change the compilation cache speeds the rebuild, but on React Native 0.86 Swift does not use it (explicit modules are off), so that build recompiles Swift.";
1229
+ const WORKSPACE_OUTPUT_DIRS = [
1230
+ "derived-data",
1231
+ "gradle-build",
1232
+ "android-cas",
1233
+ "cache-provider"
1234
+ ];
1235
+ const DAY_MS$1 = 864e5;
1236
+ function planWorkspaceOutputs(entries, { olderThan, now }) {
1237
+ return entries.map(({ dir, projectRoot, problem, bytes, lastUsed, inUse }) => {
1238
+ const idleDays = Number.isFinite(lastUsed) ? Math.max(0, Math.floor((now - lastUsed) / DAY_MS$1)) : null;
1239
+ const [keptCode, keptReason] = projectRoot === null ? ["unresolved", `workspace directory not resolved: ${problem ?? "unknown project root"}`] : inUse.length ? ["in-use", `in use: ${inUse.join("; ")}`] : olderThan === null ? [null, null] : idleDays === null ? ["last-use-unknown", "its last use is unknown"] : idleDays < olderThan ? ["recently-used", `used ${idleDays}d ago, within --older-than ${olderThan}`] : [null, null];
1240
+ return {
1241
+ dir,
1242
+ projectRoot,
1243
+ bytes,
1244
+ idleDays,
1245
+ willClear: keptCode === null,
1246
+ keptCode,
1247
+ keptReason
1248
+ };
1249
+ });
1250
+ }
1251
+ const SIZE_TIMEOUT_MS = 6e4;
1252
+ function sizeOf(dir) {
1253
+ return measuredDirectorySize(dir, { timeoutMs: SIZE_TIMEOUT_MS });
1254
+ }
1255
+ function outputPaths(dir) {
1256
+ return WORKSPACE_OUTPUT_DIRS.map((name) => join(dir, name)).filter((path) => existsSync(path));
1257
+ }
1258
+ function outputBytes(paths) {
1259
+ let total = 0;
1260
+ for (const path of paths) {
1261
+ const size = sizeOf(path);
1262
+ if (size === null) return null;
1263
+ total += size;
1264
+ }
1265
+ return total;
1266
+ }
1267
+ function collectWorkspaceOutputs({ olderThan, now, exclude = [] }) {
1268
+ const mountedVolumes = listMountedVolumes();
1269
+ const entries = listWorkspaceDirs().filter((entry) => !exclude.includes(entry.dir)).filter((entry) => entry.projectRoot === null || isOnMountedVolume(entry.projectRoot, mountedVolumes)).map((entry) => Object.assign({}, entry, { paths: outputPaths(entry.dir) })).filter((entry) => entry.paths.length > 0).map(({ paths, ...entry }) => Object.assign({}, entry, { bytes: outputBytes(paths) })).map((entry) => Object.assign({}, entry, {
1270
+ lastUsed: entry.projectRoot === null ? NaN : workspaceLastUsed(entry.projectRoot),
1271
+ inUse: entry.projectRoot === null ? [] : workspaceInUse(entry.projectRoot)
1272
+ }));
1273
+ return {
1274
+ root: workspacesRoot(),
1275
+ workspaces: planWorkspaceOutputs(entries, {
1276
+ olderThan,
1277
+ now
1278
+ })
1279
+ };
1280
+ }
1281
+ async function clearWorkspaceOutputs(report, { olderThan, now = Date.now() }) {
1282
+ let failures = 0;
1283
+ let cleared = 0;
1284
+ for (const entry of report.workspaces) {
1285
+ const root = entry.projectRoot;
1286
+ if (!entry.willClear || root === null) {
1287
+ if (entry.keptReason) console.log(chalk.dim(`Kept the build outputs of ${root ?? entry.dir}: ${entry.keptReason}`));
1288
+ continue;
1289
+ }
1290
+ let run;
1291
+ try {
1292
+ run = await withIdleWorkspace(root, () => {
1293
+ const [again] = planWorkspaceOutputs([{
1294
+ ...entry,
1295
+ problem: null,
1296
+ lastUsed: workspaceLastUsed(root),
1297
+ inUse: []
1298
+ }], {
1299
+ olderThan,
1300
+ now
1301
+ });
1302
+ if (again?.keptReason) return again.keptReason;
1303
+ for (const name of WORKSPACE_OUTPUT_DIRS) rmSync(join(entry.dir, name), {
1304
+ recursive: true,
1305
+ force: true
1306
+ });
1307
+ return null;
1308
+ }, { purpose: "gc" });
1309
+ } catch (error) {
1310
+ failures++;
1311
+ console.log(chalk.red(`Could not clear the build outputs of ${root}: ${error?.message || String(error)}`));
1312
+ continue;
1313
+ }
1314
+ const kept = run.ran ? run.value : `in use: ${run.reasons.join("; ")}`;
1315
+ if (kept) {
1316
+ console.log(chalk.yellow(`Kept the build outputs of ${root}: ${kept}`));
1317
+ continue;
1318
+ }
1319
+ cleared += entry.bytes ?? 0;
1320
+ console.log(chalk.green(`Cleared the build outputs of ${root} (${sizeText(entry.bytes)})`));
1321
+ }
1322
+ if (cleared) console.log(chalk.dim(`Cleared ${formatBytes(cleared)} of workspace build outputs. ${REBUILD_COST}`));
1323
+ return failures;
1324
+ }
1325
+ function stillOrphaned({ dir, projectRoot }) {
1326
+ if (readRecordedRoot(dir, basename(dir)).projectRoot !== projectRoot || rootPresence(projectRoot) !== false || !isOnMountedVolume(projectRoot)) return false;
1327
+ return !Object.keys(loadConfig()?.projects ?? {}).some((key) => isPathPrefix(projectRoot, key));
1328
+ }
1329
+ async function deleteOrphanedWorkspaces(orphaned) {
1330
+ let failures = 0;
1331
+ for (const entry of orphaned) {
1332
+ if (!existsSync(entry.dir)) continue;
1333
+ let run;
1334
+ try {
1335
+ run = await withIdleWorkspace(entry.projectRoot, () => {
1336
+ if (!stillOrphaned(entry)) return false;
1337
+ emptyWorkspaceDir(entry.dir);
1338
+ return true;
1339
+ }, { purpose: "gc" });
1340
+ } catch (error) {
1341
+ failures++;
1342
+ console.log(chalk.red(`Could not remove ${entry.dir}: ${error?.message || error}`));
1343
+ continue;
1344
+ }
1345
+ if (!run.ran) {
1346
+ console.log(chalk.yellow(`Kept ${entry.dir}: ${run.reasons.join("; ")}`));
1347
+ continue;
1348
+ }
1349
+ if (!run.value) {
1350
+ console.log(chalk.yellow(`Kept ${entry.dir}: it can no longer be confirmed orphaned.`));
1351
+ continue;
1352
+ }
1353
+ const size = entry.bytes === void 0 ? "" : ` (${sizeText(entry.bytes)})`;
1354
+ console.log(chalk.green(`Removed the orphaned workspace directory ${entry.dir}${size}`));
1355
+ }
1356
+ return failures;
1357
+ }
1358
+ function sizeText(bytes) {
1359
+ return bytes === null ? "size unknown" : formatBytes(bytes);
1360
+ }
1361
+ //#endregion
1120
1362
  //#region src/commands/gc/report.ts
1121
1363
  function shortKey(key) {
1122
1364
  const text = String(key ?? "");
@@ -1162,11 +1404,11 @@ function unresolvedLockLines(locks, slots) {
1162
1404
  if (entries.length === 0) return [];
1163
1405
  return [
1164
1406
  `Build locks and slots Stim cannot resolve (${entries.length}) - NOT touched, because a dead holder and a live one cannot be told apart:`,
1165
- ...entries.map((entry) => ` ${entry.path}`),
1166
- " remove one yourself once you know nothing is building with it"
1407
+ ...entries.flatMap((entry) => [` ${entry.path}`, ` ${(0, ownership_claim_exports.claimRemoveCommand)(entry.path)}`]),
1408
+ " run that yourself once you know nothing is building with it"
1167
1409
  ];
1168
1410
  }
1169
- function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invalidProjects = [], parkedSims = [], parkedAvds = [], orphanedDevices = [], staleDevices = [], staleDeviceRecords = [], buildLocks = {
1411
+ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invalidProjects = [], orphanedWorkspaces = [], parkedSims = [], parkedAvds = [], orphanedDevices = [], staleDevices = [], staleDeviceRecords = [], buildLocks = {
1170
1412
  stale: [],
1171
1413
  live: [],
1172
1414
  unresolved: []
@@ -1182,14 +1424,29 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
1182
1424
  orphaned: [],
1183
1425
  notices: [],
1184
1426
  deletionSafe: true
1185
- }, caches = [], cacheScope = null, olderThan = null }, { now = Date.now() } = {}) {
1427
+ }, caches = [], workspaceOutputs = null, worktreeSweep = null, cacheScope = null, olderThan = null }, { now = Date.now() } = {}) {
1186
1428
  const lines = [];
1187
1429
  const staleLocks = buildLocks?.stale ?? [];
1188
1430
  const liveLocks = buildLocks?.live ?? [];
1189
1431
  const staleSlots = buildSlots?.stale ?? [];
1190
1432
  const expiredLeases = deviceLeases?.expired ?? [];
1191
1433
  if (cacheScope) lines.push(`Cache scope: "${cacheScope}". Devices, project entries and locks were not inspected.`);
1192
- else if (deadProjects.length === 0 && invalidProjects.length === 0 && parkedSims.length === 0 && parkedAvds.length === 0 && orphanedDevices.length === 0 && staleDevices.length === 0 && staleDeviceRecords.length === 0 && staleLocks.length === 0 && staleSlots.length === 0 && expiredLeases.length === 0 && easSessionSweep.orphaned.length === 0) {
1434
+ else if ([
1435
+ deadProjects,
1436
+ invalidProjects,
1437
+ orphanedWorkspaces,
1438
+ parkedSims,
1439
+ parkedAvds,
1440
+ orphanedDevices,
1441
+ staleDevices,
1442
+ staleDeviceRecords,
1443
+ staleLocks,
1444
+ staleSlots,
1445
+ expiredLeases,
1446
+ easSessionSweep.orphaned,
1447
+ workspaceOutputs?.workspaces.filter((entry) => entry.willClear) ?? [],
1448
+ worktreeSweep?.worktrees.filter((entry) => !entry.skipped) ?? []
1449
+ ].every((found) => found.length === 0)) {
1193
1450
  const reasons = [];
1194
1451
  if (skipped.length > 0) reasons.push(`${skipped.length} entr${skipped.length === 1 ? "y" : "ies"} could not be checked`);
1195
1452
  if (deviceSweepNotices.length > 0) reasons.push("device sweep incomplete");
@@ -1200,6 +1457,8 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
1200
1457
  lines.push(...projectEntryLines(`Dead project entries (${deadProjects.length}):`, deadProjects));
1201
1458
  lines.push(...projectEntryLines(`Invalid project entries (${invalidProjects.length}) - the key is not an absolute path:`, invalidProjects));
1202
1459
  lines.push(...namedPortLines(orphanedPorts));
1460
+ lines.push(...orphanedWorkspaceLines(orphanedWorkspaces));
1461
+ lines.push(...worktreeSweepLines(worktreeSweep));
1203
1462
  lines.push(...formatParkedSimReport(parkedSims, now));
1204
1463
  lines.push(...formatParkedAvdReport(parkedAvds, now));
1205
1464
  if (orphanedDevices.length) {
@@ -1207,7 +1466,7 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
1207
1466
  for (const d of orphanedDevices) lines.push(` ${d.kind} ${d.name} (${d.id})${deviceSizeSuffix(d)}`);
1208
1467
  }
1209
1468
  if (staleDevices.length) {
1210
- lines.push(`Stale owned devices (${staleDevices.length}) - project untouched for ${olderThan ?? "?"}d or more:`);
1469
+ lines.push(`Stale owned devices (${staleDevices.length}) - workspace unused for ${olderThan ?? "?"}d or more:`);
1211
1470
  for (const d of staleDevices) {
1212
1471
  lines.push(` ${d.kind} ${d.name} (${d.id})${deviceSizeSuffix(d)}`);
1213
1472
  lines.push(` ${d.project} (idle ${d.idleDays}d)`);
@@ -1265,24 +1524,7 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
1265
1524
  lines.push(`Skipped (${skipped.length}) - not classified as dead:`);
1266
1525
  for (const entry of skipped) lines.push(` ${entry.dir}: ${entry.reason}`);
1267
1526
  }
1268
- if (caches.length) {
1269
- const total = caches.reduce((n, c) => n + (c.bytes ?? 0), 0);
1270
- lines.push(`Shared build caches (${caches.length}) - alive, not garbage:`);
1271
- for (const c of caches) {
1272
- const tag = c.source ? ` (${c.source})` : "";
1273
- lines.push(` ${formatBytes(c.bytes ?? 0).padStart(10)} ${c.name}${tag}`);
1274
- lines.push(` ${c.dir}`);
1275
- if (c.note) lines.push(` ${c.note}`);
1276
- if (c.willEmpty) lines.push(" would be EMPTIED");
1277
- else if (c.emptySkipped) lines.push(` would be left alone: ${c.emptySkipped}`);
1278
- }
1279
- lines.push(` total: ${formatBytes(total)}`);
1280
- const doomed = caches.filter((c) => c.willEmpty);
1281
- if (doomed.length) {
1282
- const doomedBytes = doomed.reduce((n, c) => n + (c.bytes ?? 0), 0);
1283
- lines.push(` would empty ${doomed.length} of these (${formatBytes(doomedBytes)})`);
1284
- }
1285
- }
1527
+ lines.push(...cacheLines(caches, workspaceOutputs));
1286
1528
  return lines;
1287
1529
  }
1288
1530
  function leaseGarbageLines({ expired, kept }) {
@@ -1313,6 +1555,348 @@ function namedPortLines(ports = []) {
1313
1555
  " --delete stops TCP listeners and releases these allocations."
1314
1556
  ];
1315
1557
  }
1558
+ function orphanedWorkspaceLines(orphaned) {
1559
+ if (!orphaned.length) return [];
1560
+ const lines = [`Orphaned workspace directories (${orphaned.length}):`];
1561
+ for (const entry of orphaned) {
1562
+ lines.push(` ${entry.dir}${entry.bytes === void 0 ? "" : ` - ${sizeText(entry.bytes)}`}`);
1563
+ lines.push(` recorded project root ${entry.projectRoot} is gone and no registry entry names it`);
1564
+ }
1565
+ lines.push(" --delete re-checks each directory, then removes it whole.");
1566
+ return lines;
1567
+ }
1568
+ function workspaceOutputLines(outputs, bytes) {
1569
+ const lines = [
1570
+ ` ${formatBytes(bytes).padStart(10)} Workspace build outputs (detected)`,
1571
+ ` ${outputs.root}`,
1572
+ ` ${WORKSPACE_OUTPUT_DIRS.join(", ")} of each workspace; workspace.json, state.json, logs and device records stay`,
1573
+ ` ${REBUILD_COST}`
1574
+ ];
1575
+ for (const w of outputs.workspaces) {
1576
+ const idle = w.idleDays === null ? "last use unknown" : `idle ${w.idleDays}d`;
1577
+ lines.push(` ${sizeText(w.bytes).padStart(8)} ${w.projectRoot ?? w.dir} (${idle})`);
1578
+ lines.push(w.willClear ? " would be CLEARED" : ` kept: ${w.keptReason}`);
1579
+ }
1580
+ return lines;
1581
+ }
1582
+ function cacheLines(caches, workspaceOutputs) {
1583
+ const lines = [];
1584
+ const outputs = workspaceOutputs?.workspaces.length ? workspaceOutputs : null;
1585
+ if (caches.length || outputs) {
1586
+ const outputBytes = outputs ? outputs.workspaces.reduce((n, w) => n + (w.bytes ?? 0), 0) : 0;
1587
+ const total = caches.reduce((n, c) => n + (c.bytes ?? 0), 0) + outputBytes;
1588
+ lines.push(`Shared build caches (${caches.length + (outputs ? 1 : 0)}) - alive, not garbage:`);
1589
+ if (outputs) lines.push(...workspaceOutputLines(outputs, outputBytes));
1590
+ for (const c of caches) {
1591
+ const tag = c.source ? ` (${c.source})` : "";
1592
+ lines.push(` ${formatBytes(c.bytes ?? 0).padStart(10)} ${c.name}${tag}`);
1593
+ lines.push(` ${c.dir}`);
1594
+ if (c.note) lines.push(` ${c.note}`);
1595
+ if (c.willEmpty) lines.push(" would be EMPTIED");
1596
+ else if (c.emptySkipped) lines.push(` would be left alone: ${c.emptySkipped}`);
1597
+ }
1598
+ lines.push(` total: ${formatBytes(total)}`);
1599
+ const doomed = caches.filter((c) => c.willEmpty);
1600
+ if (doomed.length) {
1601
+ const doomedBytes = doomed.reduce((n, c) => n + (c.bytes ?? 0), 0);
1602
+ lines.push(` would empty ${doomed.length} of these (${formatBytes(doomedBytes)})`);
1603
+ }
1604
+ const clearing = outputs?.workspaces.filter((w) => w.willClear) ?? [];
1605
+ if (clearing.length) {
1606
+ const clearBytes = clearing.reduce((n, w) => n + (w.bytes ?? 0), 0);
1607
+ lines.push(` would clear the build outputs of ${clearing.length} workspace${clearing.length === 1 ? "" : "s"} (${formatBytes(clearBytes)})`);
1608
+ }
1609
+ }
1610
+ return lines;
1611
+ }
1612
+ function worktreeSweepLines(sweep) {
1613
+ if (!sweep) return [];
1614
+ const removable = sweep.worktrees.filter((w) => !w.skipped);
1615
+ const idle = sweep.defaulted ? `idle ${sweep.olderThan}d or more (the default without --older-than)` : `idle ${sweep.olderThan}d or more`;
1616
+ const lines = [`Linked worktrees (${removable.length} removable, ${sweep.worktrees.length - removable.length} kept) - clean, pushed, ${idle}:`];
1617
+ for (const w of sweep.worktrees) {
1618
+ const age = w.idleDays === null ? "" : ` (idle ${w.idleDays}d)`;
1619
+ lines.push(` ${w.path}${age}`);
1620
+ lines.push(w.skipped ? ` kept: ${w.skipped}` : " would be REMOVED by `stim worktree remove`");
1621
+ }
1622
+ if (removable.length) lines.push(" --delete runs it without --force; use and idleness are re-checked under its removal locks.");
1623
+ return lines;
1624
+ }
1625
+ function jsonLock({ path, platform, key, pid, projectRoot }) {
1626
+ return {
1627
+ path,
1628
+ platform,
1629
+ key,
1630
+ pid,
1631
+ projectRoot
1632
+ };
1633
+ }
1634
+ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [], invalidProjects = [], orphanedWorkspaces = [], worktreeSweep = null, parkedSims = [], parkedAvds = [], orphanedDevices = [], staleDevices = [], staleDeviceRecords = [], buildLocks = {
1635
+ stale: [],
1636
+ live: [],
1637
+ unresolved: []
1638
+ }, buildSlots = {
1639
+ stale: [],
1640
+ live: [],
1641
+ unresolved: []
1642
+ }, deviceLeases = {
1643
+ expired: [],
1644
+ kept: []
1645
+ }, deviceSweepNotices = [], easSessionSweep = {
1646
+ projectScope: null,
1647
+ orphaned: [],
1648
+ notices: [],
1649
+ deletionSafe: true
1650
+ }, workspaceOutputs = null, caches = [] }) {
1651
+ return {
1652
+ deadProjects: deadProjects.map((path) => ({ path })),
1653
+ invalidProjects: invalidProjects.map((path) => ({ path })),
1654
+ orphanedPorts: orphanedPorts.map(({ project, label, port }) => ({
1655
+ project,
1656
+ label,
1657
+ port
1658
+ })),
1659
+ orphanedWorkspaces: orphanedWorkspaces.map(({ dir, projectRoot, bytes }) => ({
1660
+ dir,
1661
+ projectRoot,
1662
+ bytes: bytes ?? null
1663
+ })),
1664
+ linkedWorktrees: (worktreeSweep?.worktrees ?? []).map((w) => ({
1665
+ path: w.path,
1666
+ idleDays: w.idleDays,
1667
+ willRemove: w.skipCode === null,
1668
+ reason: w.skipCode,
1669
+ detail: w.skipped
1670
+ })),
1671
+ parkedSimulators: parkedSims.map(({ udid, name, model, runtime, parkedAt, bytes, listed }) => ({
1672
+ udid,
1673
+ name,
1674
+ model,
1675
+ runtime,
1676
+ parkedAt,
1677
+ bytes,
1678
+ listed
1679
+ })),
1680
+ parkedEmulators: parkedAvds.map(({ name, systemImage, parkedAt, bytes, listed }) => ({
1681
+ name,
1682
+ systemImage,
1683
+ parkedAt,
1684
+ bytes,
1685
+ listed
1686
+ })),
1687
+ orphanedDevices: orphanedDevices.map((d) => ({
1688
+ kind: d.kind,
1689
+ id: d.id,
1690
+ name: d.name,
1691
+ bytes: d.bytes ?? null,
1692
+ directory: d.orphanedDirectory?.directory ?? null
1693
+ })),
1694
+ staleDevices: staleDevices.map((d) => ({
1695
+ kind: d.kind,
1696
+ id: d.id,
1697
+ name: d.name,
1698
+ project: d.project,
1699
+ slot: d.slot ?? null,
1700
+ idleDays: d.idleDays,
1701
+ bytes: d.bytes ?? null
1702
+ })),
1703
+ staleDeviceRecords: staleDeviceRecords.map((r) => ({
1704
+ kind: r.kind,
1705
+ id: r.id,
1706
+ project: r.project,
1707
+ slot: r.slot ?? null
1708
+ })),
1709
+ orphanedEasSessions: easSessionSweep.orphaned.map((s) => ({
1710
+ id: s.id,
1711
+ name: s.name,
1712
+ platform: s.platform,
1713
+ status: s.status,
1714
+ projectScope: s.projectScope
1715
+ })),
1716
+ staleBuildLocks: buildLocks.stale.map(jsonLock),
1717
+ staleBuildSlots: buildSlots.stale.map((s) => ({
1718
+ path: s.path,
1719
+ index: s.index,
1720
+ pid: s.pid,
1721
+ projectRoot: s.projectRoot
1722
+ })),
1723
+ unresolvedBuildClaims: [...(buildLocks.unresolved ?? []).map((l) => ({
1724
+ kind: "lock",
1725
+ path: l.path
1726
+ })), ...(buildSlots.unresolved ?? []).map((s) => ({
1727
+ kind: "slot",
1728
+ path: s.path
1729
+ }))],
1730
+ buildsInProgress: buildLocks.live.map(jsonLock),
1731
+ expiredDeviceLeases: deviceLeases.expired.map((entry) => ({
1732
+ path: entry.path,
1733
+ platform: entry.platform,
1734
+ id: entry.id ?? entry.name,
1735
+ deviceName: entry.lease?.deviceName ?? null,
1736
+ holder: entry.lease?.holder ?? null,
1737
+ expiresAt: entry.lease?.expiresAt ?? null
1738
+ })),
1739
+ keptDeviceLeases: deviceLeases.kept.map(({ name, path, reason }) => ({
1740
+ name,
1741
+ path,
1742
+ detail: reason
1743
+ })),
1744
+ deviceSweepNotices: deviceSweepNotices.map((message) => ({ message })),
1745
+ easSessionSweepNotices: easSessionSweep.notices.map((message) => ({ message })),
1746
+ skipped: skipped.map(({ dir, reason }) => ({
1747
+ path: dir,
1748
+ detail: reason
1749
+ })),
1750
+ workspaceBuildOutputs: (workspaceOutputs?.workspaces ?? []).map((w) => ({
1751
+ dir: w.dir,
1752
+ projectRoot: w.projectRoot,
1753
+ bytes: w.bytes,
1754
+ idleDays: w.idleDays,
1755
+ willClear: w.willClear,
1756
+ reason: w.keptCode,
1757
+ detail: w.keptReason
1758
+ })),
1759
+ caches: caches.map((c) => ({
1760
+ name: c.name,
1761
+ dir: c.dir,
1762
+ source: c.source ?? null,
1763
+ bytes: c.bytes ?? null,
1764
+ note: c.note || null,
1765
+ willEmpty: Boolean(c.willEmpty),
1766
+ emptySkipped: c.emptySkipped ?? null
1767
+ }))
1768
+ };
1769
+ }
1770
+ //#endregion
1771
+ //#region src/commands/gc/worktrees.ts
1772
+ const DEFAULT_WORKTREE_IDLE_DAYS = 7;
1773
+ const DAY_MS = 864e5;
1774
+ function skip(code, text) {
1775
+ return {
1776
+ code,
1777
+ text
1778
+ };
1779
+ }
1780
+ function worktreeSkipReason(facts, olderThan) {
1781
+ if (facts.bare) return skip("bare-repository", "bare repository");
1782
+ if (typeof facts.source === "object") return skip("source-checkout-unknown", `source checkout unknown: ${facts.source.refusal}`);
1783
+ if (facts.source === "source") return skip("source-checkout", "source checkout");
1784
+ if (facts.locked) return skip("locked", "locked with git worktree lock");
1785
+ if (facts.inUse.length) return skip("in-use", `in use: ${facts.inUse.join("; ")}`);
1786
+ if (facts.porcelain === null) return skip("status-unreadable", "git status could not be read");
1787
+ if (excludePodChurn(facts.porcelain).lines.length) return skip("dirty", "dirty: uncommitted changes or untracked files");
1788
+ if (facts.unpushed === null) return skip("unpushed-unchecked", "unpushed commits could not be checked");
1789
+ if (facts.unpushed.length) return skip("unpushed", `unpushed: ${plural(facts.unpushed.length, "commit")} on no remote or other branch`);
1790
+ if (facts.submodules) return skip("submodules", "initialized submodules");
1791
+ if (facts.idleDays === null) return skip("last-use-unknown", "recently used: its last use is unknown");
1792
+ if (facts.idleDays < olderThan) return skip("recently-used", `recently used ${facts.idleDays}d ago`);
1793
+ return null;
1794
+ }
1795
+ function lastUsedOf(keys) {
1796
+ const times = keys.map(workspaceLastUsed).filter(Number.isFinite);
1797
+ return times.length ? Math.max(...times) : NaN;
1798
+ }
1799
+ function idleDaysOf(keys, now) {
1800
+ const last = lastUsedOf(keys);
1801
+ return Number.isFinite(last) ? Math.max(0, Math.floor((now - last) / DAY_MS)) : null;
1802
+ }
1803
+ function inUseOf(keys, checks) {
1804
+ return [...new Set(keys.flatMap((key) => workspaceInUse(key, checks)))];
1805
+ }
1806
+ function porcelainOf(path, gitAnswered) {
1807
+ if (gitAnswered === null) return null;
1808
+ if (!gitAnswered) return [];
1809
+ const lines = dirtyPaths(path, { limit: Infinity });
1810
+ return lines.length ? lines : null;
1811
+ }
1812
+ function candidateRoots() {
1813
+ const registered = Object.keys(loadConfig()?.projects ?? {}).filter(isAbsolute);
1814
+ const recorded = listWorkspaceDirs().flatMap((entry) => entry.projectRoot ? [entry.projectRoot] : []);
1815
+ return [.../* @__PURE__ */ new Set([...registered, ...recorded])].filter((root) => existsSync(root)).toSorted();
1816
+ }
1817
+ function collectWorktreeSweep({ olderThan, now }) {
1818
+ const days = olderThan ?? DEFAULT_WORKTREE_IDLE_DAYS;
1819
+ const groups = /* @__PURE__ */ new Map();
1820
+ const outside = [];
1821
+ for (const root of candidateRoots()) {
1822
+ const entry = matchWorktreeEntry(listWorktrees(root), root);
1823
+ if (!entry) {
1824
+ outside.push({
1825
+ path: root,
1826
+ keys: [root],
1827
+ idleDays: null,
1828
+ skipCode: "not-a-worktree",
1829
+ skipped: "not inside a git worktree"
1830
+ });
1831
+ continue;
1832
+ }
1833
+ groups.set(entry.path, [...groups.get(entry.path) ?? [], root]);
1834
+ }
1835
+ const worktrees = [];
1836
+ for (const [path, roots] of groups) {
1837
+ const entries = listWorktrees(path);
1838
+ const entry = matchWorktreeEntry(entries, path);
1839
+ const source = sourceCheckoutOf(entries);
1840
+ const keys = [.../* @__PURE__ */ new Set([...roots, ...reclaimKeys(path)])];
1841
+ const idleDays = idleDaysOf(keys, now);
1842
+ const linked = !("refusal" in source) && entry !== null && source.path !== entry.path;
1843
+ const gitAnswered = linked ? hasUncommittedWork(path) : null;
1844
+ const verdict = worktreeSkipReason({
1845
+ source: "refusal" in source ? source : linked ? "linked" : "source",
1846
+ bare: Boolean(entry?.bare),
1847
+ locked: Boolean(entry?.locked),
1848
+ porcelain: porcelainOf(path, gitAnswered),
1849
+ unpushed: linked ? unpushedCommits(path) : null,
1850
+ submodules: linked && hasPopulatedSubmodules(path),
1851
+ inUse: linked ? inUseOf(keys, { managedLocks: true }) : [],
1852
+ idleDays
1853
+ }, days);
1854
+ worktrees.push({
1855
+ path,
1856
+ keys,
1857
+ idleDays,
1858
+ skipCode: verdict?.code ?? null,
1859
+ skipped: verdict?.text ?? null
1860
+ });
1861
+ }
1862
+ return {
1863
+ olderThan: days,
1864
+ defaulted: olderThan === null,
1865
+ worktrees: [...worktrees, ...outside]
1866
+ };
1867
+ }
1868
+ async function removeWorktrees(sweep, { now = Date.now() } = {}) {
1869
+ let failures = 0;
1870
+ for (const candidate of sweep.worktrees) {
1871
+ if (candidate.skipped) continue;
1872
+ let kept = [];
1873
+ const guard = (lockedKeys) => {
1874
+ const keys = [.../* @__PURE__ */ new Set([...candidate.keys, ...lockedKeys])];
1875
+ const unlocked = keys.filter((key) => !lockedKeys.includes(key));
1876
+ const reasons = [...inUseOf(lockedKeys, { managedLocks: false }), ...inUseOf(unlocked, { managedLocks: true })].map((r) => `in use: ${r}`);
1877
+ const idleDays = idleDaysOf(keys, now);
1878
+ if (idleDays === null || idleDays < sweep.olderThan) reasons.push(`used ${idleDays === null ? "at an unknown time" : `${idleDays}d ago`} since gc checked it`);
1879
+ kept = reasons;
1880
+ return reasons;
1881
+ };
1882
+ let removed = false;
1883
+ try {
1884
+ removed = await removeWorktreeTarget(candidate.path, {
1885
+ linkedOnly: true,
1886
+ guard
1887
+ });
1888
+ } catch (error) {
1889
+ console.error(chalk.red(`Could not remove ${candidate.path}: ${error?.message || String(error)}`));
1890
+ }
1891
+ if (removed) console.log(chalk.green(`Removed the worktree ${candidate.path}`));
1892
+ else if (kept.length) console.log(chalk.yellow(`Kept the worktree ${candidate.path}: ${kept.join("; ")}`));
1893
+ else {
1894
+ failures++;
1895
+ console.error(chalk.yellow(`Kept the worktree ${candidate.path}; see the lines above for why.`));
1896
+ }
1897
+ }
1898
+ return failures;
1899
+ }
1316
1900
  //#endregion
1317
1901
  //#region src/commands/gc.ts
1318
1902
  function removeInvalidProjectEntries(invalidProjects) {
@@ -1321,21 +1905,16 @@ function removeInvalidProjectEntries(invalidProjects) {
1321
1905
  console.log(chalk.green(`Removed the invalid registry entry ${path}`));
1322
1906
  }
1323
1907
  }
1324
- function projectLastTouched(path) {
1325
- try {
1326
- return statSync(path).mtimeMs;
1327
- } catch {
1328
- return NaN;
1329
- }
1330
- }
1331
- async function collectGcReport({ olderThan = null, cache = null, now = Date.now(), lastTouched = projectLastTouched } = {}, deps = {}) {
1908
+ async function collectGcReport({ olderThan = null, cache = null, worktrees = false, now = Date.now(), lastTouched = workspaceLastUsed } = {}, deps = {}) {
1332
1909
  const scope = typeof cache === "string" && cache.trim() ? cache : null;
1333
1910
  const all = scope !== null && olderThan === null;
1334
- const caches = planCacheEmptying(sizeCaches(selectCaches(discoverCaches({ declared: declaredCachePaths() }), scope)), all);
1911
+ const withWorkspaces = includesWorkspaceOutputs(scope);
1912
+ const caches = planCacheEmptying(sizeCaches(selectCaches(discoverCaches(), scope).filter((c) => !withWorkspaces || !isInsideWorkspaces(c.dir))), all);
1335
1913
  if (scope) return {
1336
1914
  skipped: [],
1337
1915
  deadProjects: [],
1338
1916
  invalidProjects: [],
1917
+ orphanedWorkspaces: [],
1339
1918
  orphanedDevices: [],
1340
1919
  staleDevices: [],
1341
1920
  staleDeviceRecords: [],
@@ -1363,6 +1942,11 @@ async function collectGcReport({ olderThan = null, cache = null, now = Date.now(
1363
1942
  parkedSims: [],
1364
1943
  parkedAvds: [],
1365
1944
  caches,
1945
+ workspaceOutputs: withWorkspaces ? collectWorkspaceOutputs({
1946
+ olderThan,
1947
+ now
1948
+ }) : null,
1949
+ worktreeSweep: null,
1366
1950
  cacheScope: scope,
1367
1951
  olderThan,
1368
1952
  all
@@ -1404,6 +1988,8 @@ async function collectGcReport({ olderThan = null, cache = null, now = Date.now(
1404
1988
  });
1405
1989
  } else deadProjects.push(path);
1406
1990
  }
1991
+ const workspaceDirs = collectOrphanedWorkspaces(Object.keys(cfg?.projects ?? {}), mountedVolumes);
1992
+ skipped.push(...workspaceDirs.skipped);
1407
1993
  const deviceSweepNotices = [];
1408
1994
  let orphanedDevices = [];
1409
1995
  let staleDevices = [];
@@ -1491,6 +2077,7 @@ async function collectGcReport({ olderThan = null, cache = null, now = Date.now(
1491
2077
  port
1492
2078
  }))),
1493
2079
  invalidProjects,
2080
+ orphanedWorkspaces: workspaceDirs.orphaned,
1494
2081
  parkedSims,
1495
2082
  parkedAvds,
1496
2083
  orphanedDevices,
@@ -1510,21 +2097,49 @@ async function collectGcReport({ olderThan = null, cache = null, now = Date.now(
1510
2097
  deviceSweepNotices,
1511
2098
  easSessionSweep,
1512
2099
  caches,
2100
+ workspaceOutputs: collectWorkspaceOutputs({
2101
+ olderThan,
2102
+ now,
2103
+ exclude: [...workspaceDirs.orphaned.map((entry) => entry.dir), ...deadProjects.map(workspaceDir)]
2104
+ }),
2105
+ worktreeSweep: worktrees ? collectWorktreeSweep({
2106
+ olderThan,
2107
+ now
2108
+ }) : null,
1513
2109
  cacheScope: null,
1514
2110
  olderThan,
1515
2111
  all
1516
2112
  };
1517
2113
  }
1518
2114
  async function runGc(opts = {}, deps = {}) {
1519
- const poolError = parkedMaxSetting("ios").error || parkedMaxSetting("android").error;
1520
- if (poolError) console.error(chalk.yellow(`${poolError} ${POOL_SETTING_REMEDY}`));
1521
- const shapeErrors = (deps.settingShapeErrors ?? projectSettingShapeErrors)();
1522
- if (shapeErrors.length) {
1523
- for (const message of shapeErrors) console.error(chalk.red(message));
1524
- console.error(chalk.dim(SETTING_SHAPE_REMEDY));
1525
- process.exitCode = 1;
2115
+ if (!opts.json) {
2116
+ await sweep(opts, deps);
1526
2117
  return;
1527
2118
  }
2119
+ const log = console.log;
2120
+ console.log = console.error;
2121
+ let payload;
2122
+ try {
2123
+ payload = await sweep(opts, deps);
2124
+ } finally {
2125
+ console.log = log;
2126
+ }
2127
+ console.log(JSON.stringify(payload));
2128
+ }
2129
+ async function sweep(opts, deps) {
2130
+ if (opts.cache && opts.worktrees) {
2131
+ console.error(chalk.red("--cache acts only on the named caches, and --worktrees sweeps linked worktrees."));
2132
+ console.error(chalk.dim("Run `stim gc --worktrees` and `stim gc --cache <name>` separately."));
2133
+ console.error(chalk.red("failed: STIM_BAD_ARG"));
2134
+ process.exitCode = 1;
2135
+ return {
2136
+ code: "STIM_BAD_ARG",
2137
+ message: "--cache acts only on the named caches, and --worktrees sweeps linked worktrees.",
2138
+ remedy: "Run `stim gc --worktrees` and `stim gc --cache <name>` separately."
2139
+ };
2140
+ }
2141
+ const poolError = parkedMaxSetting("ios").error || parkedMaxSetting("android").error;
2142
+ if (poolError) console.error(chalk.yellow(`${poolError} ${POOL_SETTING_REMEDY}`));
1528
2143
  if (opts.cache) return runGcCore(opts, {
1529
2144
  ...deps,
1530
2145
  precollectedEasSessionSweep: {
@@ -1596,25 +2211,45 @@ async function runGcCore(opts, deps) {
1596
2211
  const cache = typeof opts.cache === "string" && opts.cache.trim() ? opts.cache : null;
1597
2212
  const report = await collectGcReport({
1598
2213
  olderThan,
1599
- cache
2214
+ cache,
2215
+ worktrees: Boolean(opts.worktrees)
1600
2216
  }, deps);
1601
- if (cache && report.caches.length === 0) {
1602
- const names = [...new Set(discoverCaches({ declared: declaredCachePaths() }).map((c) => c.name))];
1603
- console.log(chalk.yellow(`No shared cache carries "${cache}" in its name or directory.`));
2217
+ if (cache && report.caches.length === 0 && report.workspaceOutputs === null) {
2218
+ const names = [...new Set(discoverCaches().map((c) => c.name))];
2219
+ const message = `No shared cache carries "${cache}" in its name or directory.`;
2220
+ console.log(chalk.yellow(message));
1604
2221
  if (names.length) console.log(chalk.dim(`Caches on this machine: ${names.join(", ")}`));
1605
- return;
2222
+ if (opts.json) process.exitCode = 1;
2223
+ return {
2224
+ code: "STIM_BAD_ARG",
2225
+ message,
2226
+ remedy: names.length ? `Pass --cache all or a name from: ${names.join(", ")}.` : "No shared cache was found on this machine."
2227
+ };
1606
2228
  }
1607
2229
  const all = report.all;
1608
2230
  for (const line of formatGcReport(report)) console.log(line);
1609
2231
  const { deadProjects, invalidProjects, orphanedDevices, staleDevices, staleDeviceRecords, buildLocks, buildSlots, deviceLeases, easSessionSweep, caches } = report;
1610
- const actionable = deadProjects.length + invalidProjects.length > 0 || report.parkedSims.length > 0 || report.parkedAvds.length > 0 || orphanedDevices.length > 0 || staleDevices.length > 0 || staleDeviceRecords.length > 0 || buildLocks.stale.length > 0 || buildSlots.stale.length > 0 || deviceLeases.expired.length > 0 || easSessionSweep.orphaned.length > 0 || (olderThan !== null || all) && caches.length > 0;
2232
+ const actionable = deadProjects.length + invalidProjects.length > 0 || report.orphanedWorkspaces.length > 0 || Boolean(report.workspaceOutputs?.workspaces.some((entry) => entry.willClear)) || Boolean(report.worktreeSweep?.worktrees.some((entry) => !entry.skipped)) || report.parkedSims.length > 0 || report.parkedAvds.length > 0 || orphanedDevices.length > 0 || staleDevices.length > 0 || staleDeviceRecords.length > 0 || buildLocks.stale.length > 0 || buildSlots.stale.length > 0 || deviceLeases.expired.length > 0 || easSessionSweep.orphaned.length > 0 || (olderThan !== null || all) && caches.length > 0;
2233
+ const payload = (failures) => ({
2234
+ mode: opts.delete ? "delete" : "dry-run",
2235
+ cacheScope: report.cacheScope,
2236
+ olderThan,
2237
+ worktreeSweep: report.worktreeSweep ? {
2238
+ olderThan: report.worktreeSweep.olderThan,
2239
+ defaulted: report.worktreeSweep.defaulted
2240
+ } : null,
2241
+ actionable,
2242
+ failures,
2243
+ sections: gcReportSections(report)
2244
+ });
1611
2245
  if (!opts.delete) {
1612
2246
  if (all) console.log(chalk.dim("\nDry run. Re-run with --delete to empty the caches above."));
1613
2247
  else if (actionable) console.log(chalk.dim("\nDry run. Re-run with --delete to reclaim."));
1614
2248
  else if (caches.length) console.log(chalk.dim("\nPass --delete --cache all to empty the caches above, or --delete --older-than <days> to trim them."));
1615
- return;
2249
+ return payload(null);
1616
2250
  }
1617
- let deleteFailures = deleteParkedSims(report.parkedSims, deps) + deleteParkedAvds(report.parkedAvds);
2251
+ let deleteFailures = report.workspaceOutputs ? await clearWorkspaceOutputs(report.workspaceOutputs, { olderThan }) : 0;
2252
+ deleteFailures += deleteParkedSims(report.parkedSims, deps) + deleteParkedAvds(report.parkedAvds);
1618
2253
  removeInvalidProjectEntries(invalidProjects);
1619
2254
  for (const path of deadProjects) {
1620
2255
  if (existsSync(path) || !isOnMountedVolume(path)) {
@@ -1638,7 +2273,9 @@ async function runGcCore(opts, deps) {
1638
2273
  if (result.stoppedSession) console.log(chalk.dim(` stopped remote session ${result.stoppedSession}`));
1639
2274
  if (result.stoppedTunnel) console.log(chalk.dim(` stopped ${result.stoppedTunnel} tunnel`));
1640
2275
  for (const s of result.skippedDevices) console.log(chalk.yellow(` ${s.name}: ${s.reason}`));
2276
+ deleteFailures += result.failedDevices.length;
1641
2277
  }
2278
+ deleteFailures += await deleteOrphanedWorkspaces(report.orphanedWorkspaces);
1642
2279
  deleteFailures += deleteProjectDevices(orphanedDevices, staleDevices, staleDeviceRecords);
1643
2280
  for (const lock of buildLocks.stale) {
1644
2281
  const cleared = (0, ownership_claim_exports.clearFreeClaimSet)({
@@ -1682,26 +2319,26 @@ async function runGcCore(opts, deps) {
1682
2319
  console.log(chalk.red(`Failed to clear the device lease at ${entry.path}: ${err?.message || err}`));
1683
2320
  }
1684
2321
  deleteFailures += await deleteEasSessions(easSessionSweep, deps);
1685
- if (deleteFailures) console.log(chalk.red(`\n${deleteFailures} entr${deleteFailures === 1 ? "y" : "ies"} could not be deleted; see above.`));
1686
- if (all) {
1687
- emptyCaches(caches);
1688
- return;
2322
+ if (report.worktreeSweep) deleteFailures += await removeWorktrees(report.worktreeSweep);
2323
+ if (deleteFailures) {
2324
+ console.log(chalk.red(`\n${deleteFailures} entr${deleteFailures === 1 ? "y" : "ies"} could not be deleted; see above.`));
2325
+ process.exitCode = 1;
1689
2326
  }
1690
- if (olderThan === null) {
2327
+ if (all) emptyCaches(caches);
2328
+ else if (olderThan === null) {
1691
2329
  if (caches.length) console.log(chalk.dim("Shared caches left alone: pass --cache all to empty them, or --older-than <days> to trim them."));
1692
- return;
1693
- }
1694
- trimCaches(caches, olderThan);
2330
+ } else trimCaches(caches, olderThan);
2331
+ return payload(deleteFailures);
1695
2332
  }
1696
2333
  function gcCommand(program) {
1697
- program.command("gc").description("Report what Stim has left behind: dead project entries, orphaned owned devices and EAS sessions, records of devices that no longer exist, build locks whose builder is gone, expired physical-device leases, and the shared build caches. Reports by default; pass --delete to act.").option("--delete", "actually prune the reported entries and reap the reported devices").option("--older-than <days>", "also reap owned devices whose project has been untouched this long, and trim shared cache entries nothing has used in that time", (v) => {
2334
+ program.command("gc").description("Report what Stim has left behind: dead project entries, orphaned workspace directories, orphaned owned devices and EAS sessions, records of devices that no longer exist, build locks whose builder is gone, expired physical-device leases, the shared build caches, and the build outputs of each workspace. Reports by default; pass --delete to act.").option("--worktrees", "also report every clean, idle, Stim-managed linked worktree, and with --delete run `stim worktree remove` (never --force) on each; idle means unused for --older-than days, 7 without it").option("--delete", "actually prune the reported entries, reap the reported devices, and clear the build outputs of every workspace not in use").option("--older-than <days>", "also reap owned devices whose workspace has not been used this long, trim shared cache entries nothing has used in that time, and clear workspace build outputs only for workspaces idle this long", (v) => {
1698
2335
  const n = parseInt(v, 10);
1699
2336
  if (!Number.isFinite(n) || String(n) !== String(v).trim()) throw new InvalidArgumentError("must be a whole number of days, e.g. --older-than 30");
1700
2337
  return n;
1701
- }).option("--cache <name>", "act on the shared caches whose name or directory contains <name>, or every cache with --cache all, a reserved name that never selects a single cache. With --delete they are emptied whole, which is the only way to clear an index-backed cache; add --older-than <days> to trim them by age instead. Only those caches are reported; devices and project entries are not inspected. Caches outside the config dir are refused while STIM_HOME is set.", (v) => {
2338
+ }).option("--cache <name>", "act on the shared caches whose name or directory contains <name>, every cache and the workspace build outputs with --cache all, or only the workspace build outputs with --cache workspaces; all and workspaces are reserved names that never select a single cache. With --delete they are emptied whole, which is the only way to clear an index-backed cache; add --older-than <days> to trim them by age instead. Only those caches are reported; devices and project entries are not inspected. Caches outside the config dir are refused while STIM_HOME is set.", (v) => {
1702
2339
  if (!v.trim()) throw new InvalidArgumentError("must name a cache, e.g. --cache \"compilation cache\"");
1703
2340
  return v;
1704
- }).action(async (opts) => {
2341
+ }).option("--json", "print the report as JSON on stdout; every other line goes to stderr").action(async (opts) => {
1705
2342
  await runGc(opts);
1706
2343
  });
1707
2344
  }