stim 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +23 -155
  2. package/dist/{android-DWssqHQ8.mjs → android-787HhP5w.mjs} +168 -39
  3. package/dist/{android-BRgBYzU0.mjs → android-DBkeHwpm.mjs} +250 -200
  4. package/dist/{android-DeYdjjBP.mjs → android-DPov-fv2.mjs} +2 -2
  5. package/dist/{android-cas-SWIjzeuL.mjs → android-cas-CITmIeWY.mjs} +22 -14
  6. package/dist/android-cas-compiler.mjs +2 -2
  7. package/dist/app-install-CrJ6t_bv.mjs +819 -0
  8. package/dist/budget-BP4jZZL4.mjs +620 -0
  9. package/dist/build-progress-BXx-ZBe4.mjs +512 -0
  10. package/dist/{cache-manifest-CYJi8-Hx.mjs → cache-manifest-Pubfp8wV.mjs} +4 -4
  11. package/dist/cache-manifest.mjs +1 -1
  12. package/dist/cli.mjs +22 -19
  13. package/dist/collector-run.d.mts +7 -1
  14. package/dist/collector-run.mjs +10 -9
  15. package/dist/{command-output-B3ycpsYv.mjs → command-output-D5z2_bcF.mjs} +11 -2
  16. package/dist/{config-B7iyKWkL.mjs → config-BEq0Qw_a.mjs} +134 -121
  17. package/dist/{dependency-state-D0M7oKA7.mjs → dependency-state-r9UesA9a.mjs} +2 -2
  18. package/dist/{deps-oCXVNEqQ.mjs → deps-DP4eWqea.mjs} +8 -5
  19. package/dist/{detached-entry-Bpo6ZY_I.mjs → detached-entry-DJHoca49.mjs} +1 -1
  20. package/dist/{device-BUoZyMoR.mjs → dev-client-92U593KT.mjs} +1090 -660
  21. package/dist/{device-RdujjHLB.mjs → device-DhTZe7L4.mjs} +13 -20
  22. package/dist/{build-lock-CgtsB6TV.mjs → device-lease-CbEaIk7b.mjs} +481 -27
  23. package/dist/{device-pool-BxRwdufO.mjs → device-pool-Di8Q-9Ro.mjs} +7 -7
  24. package/dist/{device-remote-ByN8P6CB.mjs → device-remote-Dy1jGj3U.mjs} +645 -119
  25. package/dist/{doctor-BMt156qe.mjs → doctor-B-inAJmt.mjs} +137 -113
  26. package/dist/{error-diagnostics-D1Kaf-eT.mjs → error-diagnostics-xxWKNO3-.mjs} +22 -17
  27. package/dist/exec-DK8KaVHK.mjs +133 -0
  28. package/dist/{gc-v2YcoJvm.mjs → gc-ChPFZMRC.mjs} +836 -813
  29. package/dist/{guide-BTAlD1_y.mjs → guide-DwmaD_Wa.mjs} +953 -209
  30. package/dist/guide-status-CWvti2Lg.mjs +125 -0
  31. package/dist/idle-DpRLp5lj.mjs +157 -0
  32. package/dist/{build-slots-C-oAHRT1.mjs → in-use-CjUluqIX.mjs} +102 -8
  33. package/dist/{ios-BcCiI4TS.mjs → ios-B2Fpqvlb.mjs} +60 -71
  34. package/dist/{ios-BP04pY5f.mjs → ios-Dd-hYJBh.mjs} +241 -234
  35. package/dist/{ios-device-CFXA46ez.mjs → ios-device-BiOsSVzd.mjs} +3 -2
  36. package/dist/{ios-device-CauFJiYx.mjs → ios-device-CtRdEVjx.mjs} +149 -52
  37. package/dist/launch-verify-CQS_Kg-C.mjs +365 -0
  38. package/dist/{logs-CakdBTtm.mjs → logs-MARIy6m9.mjs} +20 -15
  39. package/dist/metro-BWLOJ_Wr.mjs +409 -0
  40. package/dist/{named-ports-DzMOlmaX.mjs → named-ports-DiTfSz6p.mjs} +4 -4
  41. package/dist/{ndjson-BZwI1NBv.mjs → ndjson-CDY9IkVC.mjs} +23 -43
  42. package/dist/{ndjson-DvHSsNU9.d.mts → ndjson-Du3j0AQ9.d.mts} +1 -0
  43. package/dist/{ownership-Cp52QAK1.mjs → ownership-D2xbLVoz.mjs} +2 -3
  44. package/dist/{teardown-BJqEzPo_.mjs → ownership-DZjdx8Gq.mjs} +664 -612
  45. package/dist/{ports-BIbJaKNW.mjs → ports-CLSSFOuz.mjs} +3 -3
  46. package/dist/{process-output-Dl5uKE03.mjs → process-output-Fpl8PYi8.mjs} +1 -1
  47. package/dist/{project-DDZWf_16.mjs → project-BXZzOABt.mjs} +8 -3
  48. package/dist/{reload-BdypPbjM.mjs → reload-DoETJ3rl.mjs} +11 -13
  49. package/dist/{server-bare-BQkyxa_b.mjs → server-bare-DVbb7kXH.mjs} +2 -2
  50. package/dist/{server-expo-BVAA1OqJ.mjs → server-expo-BvOCeakp.mjs} +8 -7
  51. package/dist/server-expo-DBVTdult.mjs +2 -0
  52. package/dist/{settings-BXXorVM2.mjs → settings-DOugPlPz.mjs} +59 -159
  53. package/dist/settings-EWYNwe72.mjs +378 -0
  54. package/dist/settings.schema.json +1064 -0
  55. package/dist/simslim-Ca3487H6.mjs +436 -0
  56. package/dist/{slot-launch-ZUUe0TQP.mjs → slot-launch-C5cg0fmk.mjs} +4 -4
  57. package/dist/spawn-claims-CUleC2cM.mjs +49 -0
  58. package/dist/{start-CmxtOKjU.mjs → start-F9gv5dcC.mjs} +159 -114
  59. package/dist/start-VoFsPSes.mjs +2 -0
  60. package/dist/{state-DZobzxLW.mjs → state-WGdIbi_d.mjs} +5 -3
  61. package/dist/{stats-BYcZ6n1h.mjs → stats-BAgJaLA4.mjs} +66 -5
  62. package/dist/{stats-CLg5RuDS.mjs → stats-DBYJ5bxL.mjs} +4 -4
  63. package/dist/status-DLCD-o3s.mjs +764 -0
  64. package/dist/status-Dr4fRkym.mjs +216 -0
  65. package/dist/{guide-status-Craq9z0t.mjs → stim-installations-CINXy_su.mjs} +3 -122
  66. package/dist/{stop-C8JW6kKp.mjs → stop-Dj4pfmyv.mjs} +111 -31
  67. package/dist/{stop-B1kOpYei.mjs → stop-ZvOeTmKb.mjs} +2 -2
  68. package/dist/supervisor-run.d.mts +11 -2
  69. package/dist/supervisor-run.mjs +228 -42
  70. package/dist/{workspace-process-lock-BYfZz_S-.mjs → workspace-process-lock-BXA96myD.mjs} +14 -9
  71. package/dist/workspaces-BTUyFfkv.mjs +1003 -0
  72. package/dist/worktree-4aZg2NnI.mjs +2 -0
  73. package/dist/{worktree-CFLA-6MX.mjs → worktree-BMS7GeqB.mjs} +523 -61
  74. package/dist/{worktree-FG_ZKKdb.mjs → worktree-BlizKDpZ.mjs} +200 -54
  75. package/dist/{xcode-B6YaWeg0.mjs → xcode-B2tBkf0m.mjs} +66 -57
  76. package/package.json +6 -6
  77. package/skill/SKILL.md +2 -0
  78. package/dist/app-install-BDbsSgCl.mjs +0 -1175
  79. package/dist/dev-client-BmT-ioFI.mjs +0 -797
  80. package/dist/device-lease-Ffz_M5t0.mjs +0 -359
  81. package/dist/exec-B29XNuhP.mjs +0 -77
  82. package/dist/logs-query-BGL-Pl12.mjs +0 -290
  83. package/dist/metro-C7bcJ0uo.mjs +0 -233
  84. package/dist/ownership-wolE-0Do.mjs +0 -470
  85. package/dist/public-http-probe-CyMDrQ3c.mjs +0 -61
  86. package/dist/reclaim-KJ-0uWEF.mjs +0 -361
  87. package/dist/server-expo-YDDG3eP7.mjs +0 -2
  88. package/dist/spawn-entry-nCE6m770.mjs +0 -14
  89. package/dist/status-DdkLki47.mjs +0 -426
  90. package/dist/workspace-state-BT5E3SA2.mjs +0 -146
  91. package/dist/{metro-store-CDDZHtPl.mjs → metro-store-DzeWkfqW.mjs} +1 -1
@@ -1,741 +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 { A as directorySize, F as volumeRootFor, M as isOnMountedVolume, N as listMountedVolumes, b as removeProject, g as loadConfig, j as formatBytes, k as ownership_claim_exports } from "./config-BEq0Qw_a.mjs";
2
+ import { t as getExecutor } from "./exec-DK8KaVHK.mjs";
3
+ import { N as workspaceStateFile, O as workspaceDir, v as workspaceLastUsed } from "./metro-BWLOJ_Wr.mjs";
4
+ import { C as sourceCheckoutOf, b as resolveFullRef, d as hasUncommittedWork, l as hasPopulatedSubmodules, m as listWorktrees, o as dirtyPaths, w as unpushedCommits } from "./worktree-BlizKDpZ.mjs";
5
+ import { a as detectIsExpo, o as findProjectRoot } from "./project-BXZzOABt.mjs";
6
+ import { h as shortUdid, o as formatLongDuration, u as plural } from "./command-output-D5z2_bcF.mjs";
7
+ import { d as removeExpiredLease, h as listBuildLocks } from "./device-lease-CbEaIk7b.mjs";
8
+ import { A as ownedAvdDirectory, C as isStimOwnedAvdName, D as listOrphanedAvdDirectories, K as isStimOwnedSim, T as listAvds } from "./android-787HhP5w.mjs";
9
+ import { h as POOL_SETTING_REMEDY, y as parkedMaxSetting } from "./ownership-DZjdx8Gq.mjs";
10
+ import { l as listAllIosSims } from "./ios-B2Fpqvlb.mjs";
11
+ import { a as parseIdleDuration, o as shutDownIdleDevices, r as idleDeviceLines, t as collectIdleDevices } from "./idle-DpRLp5lj.mjs";
12
+ import { i as canonicalPath, o as listBuildSlots, r as workspaceInUse } from "./in-use-CjUluqIX.mjs";
13
+ import { C as listOwnedSessionsArgs, D as verifyStoppedSession, E as stopSessionArgs, S as isDefinitiveMissingSessionError, T as parseSessionListPage, b as getSessionArgs, d as withRemoteSessionLock, f as withEasProjectLock, h as removeEasSessionClaim, m as readEasSessionLedger, p as easMachineStateRoot, x as inspectSessionForTeardown, y as findOrphanedOwnedSessions, z as resolveEasCliBin } from "./device-remote-Dy1jGj3U.mjs";
14
+ import { A as discoverCaches, C as unverifiedDeviceCommand, D as planCacheEmptying, E as includesWorkspaceOutputs, O as selectCaches, S as findStaleProjectDevices, T as emptyCaches, _ as describeParkedSims, a as collectWorkspaceOutputs, b as findOrphanedDevices, c as listWorkspaceDirs, d as collectDeviceLeases, f as collectParkedAvds, g as deleteProjectDevices, h as deleteParkedSims, i as collectOrphanedWorkspaces, j as sizeCaches, k as trimCaches, l as sizeText, m as deleteParkedAvds, n as WORKSPACE_OUTPUT_DIRS, o as deleteOrphanedWorkspaces, p as collectParkedSims, r as clearWorkspaceOutputs, s as isInsideWorkspaces, t as REBUILD_COST, u as DEVICE_LIST_TIMEOUT_MS, v as describeUnverifiableDevices, w as withAndroidAvdSizes, x as findStaleDeviceRecords, y as deviceSweepIsScoped } from "./workspaces-BTUyFfkv.mjs";
15
+ import { a as reclaimKeys, d as removeWorktreeTarget, m as reclaimProject, n as excludePodChurn, p as describeDereferenced, r as matchWorktreeEntry } from "./worktree-BMS7GeqB.mjs";
16
+ import { statSync } from "node:fs";
17
17
  import { InvalidArgumentError } from "commander";
18
- import { existsSync, readFileSync, readdirSync, realpathSync, rmSync, statSync } from "fs";
19
- import { homedir, tmpdir } from "os";
20
- import { basename, dirname, isAbsolute, join, relative, resolve } from "path";
21
- import { METRO_NAMED_CACHE_LAYOUT } from "@stim-cli/core";
18
+ import { existsSync as existsSync$1, readFileSync as readFileSync$1, readdirSync as readdirSync$1, statSync as statSync$1 } from "fs";
19
+ import { basename, dirname, isAbsolute, join } from "path";
22
20
  import chalk from "chalk";
23
- //#region src/cache/caches.ts
24
- function xcodeDerivedDataRoot() {
25
- return join(homedir(), "Library", "Developer", "Xcode", "DerivedData");
26
- }
27
- function compilationCache() {
28
- const dir = join(xcodeDerivedDataRoot(), "CompilationCache.noindex");
29
- if (!existsSync(dir)) return null;
30
- return {
31
- name: "Xcode compilation cache",
32
- dir,
33
- prune: "atomic",
34
- note: "index-backed, so it can only be emptied whole"
35
- };
36
- }
37
- function gradleBuildCache() {
38
- const gradleHome = process.env.GRADLE_USER_HOME || join(homedir(), ".gradle");
39
- const dir = resolve(gradleHome, "caches", "build-cache-1");
40
- if (!existsSync(dir)) return null;
41
- return {
42
- name: "Gradle build cache",
43
- dir,
44
- prune: "report-only",
45
- note: "shared with every Gradle build; report only, never pruned or emptied by Stim"
46
- };
47
- }
48
- function metroFileMaps() {
49
- const root = tmpdir();
50
- if (!existsSync(root)) return null;
51
- let names;
52
- try {
53
- names = readdirSync(root).filter((n) => n.startsWith("metro-file-map-"));
54
- } catch {
55
- return null;
56
- }
57
- if (!names.length) return null;
58
- let bytes = 0;
59
- for (const n of names) try {
60
- bytes += statSync(join(root, n)).size;
61
- } catch {}
62
- return {
63
- name: "Metro file maps",
64
- dir: root,
65
- files: names.map((n) => join(root, n)),
66
- bytes,
67
- prune: "entries",
68
- note: `${names.length} file(s), one per project root Metro has served`
69
- };
70
- }
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 = [] } = {}) {
96
- const gradle = gradleBuildCache();
97
- const gradleDir = gradle ? canonicalCacheDir(gradle.dir) : null;
98
- const registered = protectNestedMetroAncestors(suppressLegacyMetroAncestors(registeredCaches().map((c) => Object.assign({}, c, {
99
- name: c.name ?? c.dir,
100
- note: c.note ?? "registered",
101
- prune: c.prune,
102
- source: "registered"
103
- }))));
104
- const detected = [
105
- compilationCache(),
106
- gradle,
107
- metroFileMaps(),
108
- ...declaredCaches(declared)
109
- ].filter((c) => Boolean(c)).map((c) => Object.assign({}, c, { source: "detected" }));
110
- return mergeCacheDescriptors([...registered, ...detected], gradleDir);
111
- }
112
- function suppressLegacyMetroAncestors(caches) {
113
- const current = caches.filter(isCurrentMetroStore);
114
- return caches.filter((cache) => {
115
- if (!isLegacyMetroStore(cache)) return true;
116
- return !current.some((candidate) => isNamedMetroChild(cache.dir, candidate.dir));
117
- });
118
- }
119
- function protectNestedMetroAncestors(caches) {
120
- const current = caches.filter(isCurrentMetroStore);
121
- return caches.map((cache) => {
122
- if (!isCurrentMetroStore(cache)) return cache;
123
- if (!current.some((candidate) => isNamedMetroChild(cache.dir, candidate.dir))) return cache;
124
- return Object.assign({}, cache, {
125
- prune: "report-only",
126
- note: "named Metro store is also an override parent; report only while its child is registered"
127
- });
128
- });
129
- }
130
- function isLegacyMetroStore(cache) {
131
- return isMetroStore(cache) && cache.layout === void 0;
132
- }
133
- function isCurrentMetroStore(cache) {
134
- return isMetroStore(cache) && cache.layout === METRO_NAMED_CACHE_LAYOUT;
135
- }
136
- function isMetroStore(cache) {
137
- return cache.source === "registered" && cache.name === "Metro transform cache" && cache.prune === "entries" && cache.entriesDepth === 2;
138
- }
139
- function isNamedMetroChild(parent, candidate) {
140
- const dir = canonicalCacheDir(parent);
141
- const child = canonicalCacheDir(candidate);
142
- return dirname(child) === dir || canonicalCacheDir(dirname(candidate)) === dir;
143
- }
144
- function mergeCacheDescriptors(caches, gradleDir) {
145
- const merged = [];
146
- for (const cache of caches) {
147
- const descriptor = protectGradleCache(cache, gradleDir);
148
- const dir = canonicalCacheDir(descriptor.dir);
149
- const exact = merged.findIndex((candidate) => canonicalCacheDir(candidate.dir) === dir);
150
- if (exact !== -1) {
151
- const current = merged[exact];
152
- if (pruneStrength(descriptor.prune) > pruneStrength(current.prune)) merged[exact] = Object.assign({}, current, {
153
- prune: descriptor.prune,
154
- note: descriptor.note
155
- });
156
- continue;
157
- }
158
- if (gradleDir && isGradleProtectedDescriptor(descriptor, gradleDir)) {
159
- if (merged.some((candidate) => {
160
- const candidateDir = canonicalCacheDir(candidate.dir);
161
- return isGradleProtectedDescriptor(candidate, gradleDir) && cachePathContains(candidateDir, dir);
162
- })) continue;
163
- for (let i = merged.length - 1; i >= 0; i--) {
164
- const candidate = merged[i];
165
- const candidateDir = canonicalCacheDir(candidate.dir);
166
- if (isGradleProtectedDescriptor(candidate, gradleDir) && cachePathContains(dir, candidateDir)) merged.splice(i, 1);
167
- }
168
- }
169
- merged.push(descriptor);
170
- }
171
- return merged;
172
- }
173
- function protectGradleCache(cache, gradleDir) {
174
- if (cache.files) return cache;
175
- if (!gradleDir || !cachePathsOverlap(canonicalCacheDir(cache.dir), gradleDir)) return cache;
176
- return Object.assign({}, cache, {
177
- prune: "report-only",
178
- note: "shared with every Gradle build; report only, never pruned or emptied by Stim"
179
- });
180
- }
181
- function isGradleProtectedDescriptor(cache, gradleDir) {
182
- return cache.prune === "report-only" && cachePathsOverlap(canonicalCacheDir(cache.dir), gradleDir);
183
- }
184
- function pruneStrength(prune) {
185
- if (prune === "report-only") return 3;
186
- if (prune === "atomic") return 2;
187
- return 1;
188
- }
189
- function cachePathsOverlap(left, right) {
190
- return cachePathContains(left, right) || cachePathContains(right, left);
191
- }
192
- function cachePathContains(parent, child) {
193
- const rel = relative(parent, child);
194
- return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
195
- }
196
- function canonicalCacheDir(dir) {
197
- try {
198
- return realpathSync(dir);
199
- } catch {
200
- return resolve(dir);
201
- }
202
- }
203
- function sizeCaches(caches) {
204
- return caches.map((c) => Object.assign({}, c, { bytes: c.bytes ?? directorySize(c.dir) }));
205
- }
206
- function pruneCache(cache, { olderThanDays, now = Date.now() } = {}) {
207
- const cutoff = now - olderThanDays * 24 * 60 * 60 * 1e3;
208
- if (cache.prune === "report-only") return {
209
- removed: 0,
210
- bytes: 0,
211
- skipped: "report-only shared cache; Stim never deletes it"
212
- };
213
- if (cache.prune === "atomic") return {
214
- removed: 0,
215
- bytes: 0,
216
- skipped: "index-backed; empty it whole or not at all"
217
- };
218
- const entries = cache.files ?? entriesAtDepth(cache.dir, cache.entriesDepth ?? 1);
219
- let removed = 0;
220
- let bytes = 0;
221
- for (const entry of entries) {
222
- let used;
223
- let size;
224
- try {
225
- const st = statSync(entry);
226
- used = Math.max(st.atimeMs, st.mtimeMs);
227
- size = st.isDirectory() ? directorySize(entry) : st.size;
228
- } catch {
229
- continue;
230
- }
231
- if (used >= cutoff) continue;
232
- try {
233
- rmSync(entry, {
234
- recursive: true,
235
- force: true
236
- });
237
- removed++;
238
- bytes += size;
239
- } catch {}
240
- }
241
- return {
242
- removed,
243
- bytes,
244
- skipped: null
245
- };
246
- }
247
- function entriesAtDepth(dir, depth) {
248
- let level = [dir];
249
- for (let i = 0; i < depth; i++) {
250
- const next = [];
251
- for (const parent of level) {
252
- let names;
253
- try {
254
- names = readdirSync(parent);
255
- } catch {
256
- continue;
257
- }
258
- for (const name of names) next.push(join(parent, name));
259
- }
260
- level = next;
261
- }
262
- return level;
263
- }
264
- //#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
- //#region src/commands/gc/caches.ts
275
- const DAY_MS$1 = 864e5;
276
- function cacheSweepIsScoped() {
277
- return Boolean(process.env.STIM_HOME);
278
- }
279
- function isInsideConfigDir(dir) {
280
- const root = canonicalPath(getConfigDir());
281
- const target = canonicalPath(dir);
282
- const rel = relative(root, target);
283
- return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
284
- }
285
- function machineGlobalReason(cache) {
286
- if (!cacheSweepIsScoped()) return null;
287
- if (cache.source === "registered") return null;
288
- if (isInsideConfigDir(cache.dir)) return null;
289
- return `STIM_HOME scopes this config, but ${cache.dir} is outside it and therefore machine-global`;
290
- }
291
- const EVERY_CACHE = "all";
292
- function selectCaches(caches, name) {
293
- if (!name) return caches;
294
- const wanted = name.trim().toLowerCase();
295
- if (wanted === EVERY_CACHE) return caches;
296
- return caches.filter((c) => c.name.toLowerCase().includes(wanted) || c.dir.toLowerCase().includes(wanted));
297
- }
298
- function planCacheEmptying(caches, all) {
299
- const annotated = caches.map((c) => Object.assign({}, c, { machineGlobal: machineGlobalReason(c) }));
300
- if (!all) return annotated;
301
- return annotated.map((c) => {
302
- if (c.prune === "report-only") return Object.assign({}, c, {
303
- willEmpty: false,
304
- emptySkipped: "report-only shared cache; Stim never deletes it"
305
- });
306
- if (c.machineGlobal) return Object.assign({}, c, {
307
- willEmpty: false,
308
- emptySkipped: c.machineGlobal
309
- });
310
- if (!ownsItsDirectory(c)) return Object.assign({}, c, {
311
- willEmpty: false,
312
- emptySkipped: `${c.dir} is not a directory this cache owns`
313
- });
314
- return Object.assign({}, c, {
315
- willEmpty: true,
316
- emptySkipped: null
317
- });
318
- });
319
- }
320
- function ownsItsDirectory(cache) {
321
- if (Array.isArray(cache.files)) return false;
322
- const dir = canonicalPath(cache.dir);
323
- if (dirname(dir) === dir) return false;
324
- return ![
325
- homedir(),
326
- tmpdir(),
327
- getConfigDir()
328
- ].map(canonicalPath).includes(dir);
329
- }
330
- function emptyCache(cache) {
331
- if (cache.prune === "report-only") return {
332
- removed: 0,
333
- bytes: 0,
334
- skipped: "report-only shared cache; Stim never deletes it"
335
- };
336
- if (cache.prune !== "atomic") return pruneCache(cache, {
337
- olderThanDays: 0,
338
- now: Date.now() + DAY_MS$1
339
- });
340
- let names;
341
- try {
342
- names = readdirSync(cache.dir);
343
- } catch (e) {
344
- return {
345
- removed: 0,
346
- bytes: 0,
347
- skipped: `could not read ${cache.dir}: ${e.message}`
348
- };
349
- }
350
- let removed = 0;
351
- let failed = 0;
352
- for (const name of names) try {
353
- rmSync(join(cache.dir, name), {
354
- recursive: true,
355
- force: true
356
- });
357
- removed++;
358
- } catch {
359
- failed++;
360
- }
361
- return {
362
- removed,
363
- bytes: failed ? 0 : cache.bytes ?? 0,
364
- failed,
365
- skipped: null
366
- };
367
- }
368
- function trimCaches(caches, olderThan) {
369
- let cacheBytes = 0;
370
- for (const c of caches) {
371
- if (c.machineGlobal) {
372
- console.log(chalk.yellow(`Left ${c.name} alone: ${c.machineGlobal}`));
373
- continue;
374
- }
375
- const r = pruneCache(c, { olderThanDays: olderThan });
376
- if (r.skipped) console.log(chalk.yellow(`Left ${c.name} alone: ${r.skipped}`));
377
- else if (r.removed) {
378
- cacheBytes += r.bytes;
379
- 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`));
381
- }
382
- if (cacheBytes) console.log(chalk.dim(`Trimmed ${formatBytes(cacheBytes)} of shared cache. The next build that wanted those entries pays to rebuild them.`));
383
- }
384
- function emptyCaches(caches) {
385
- let cacheBytes = 0;
386
- for (const c of caches) {
387
- if (!c.willEmpty) {
388
- console.log(chalk.yellow(`Left ${c.name} alone: ${c.emptySkipped}`));
389
- continue;
390
- }
391
- const r = emptyCache(c);
392
- if (r.skipped) console.log(chalk.yellow(`Left ${c.name} alone: ${r.skipped}`));
393
- else if (r.removed) {
394
- cacheBytes += r.bytes;
395
- 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`));
398
- }
399
- 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
- }
401
- //#endregion
402
- //#region src/commands/gc/devices.ts
403
- const DEVICE_LIST_TIMEOUT_MS = 3e4;
404
- const AVD_SIZE_TIMEOUT_MS = 5e3;
405
- const DAY_MS = 864e5;
406
- function describeKept(ref) {
407
- return ref.mounted ? `referenced by ${ref.path}` : `referenced by ${ref.path} (volume not mounted; kept just in case)`;
408
- }
409
- function findOrphanedDevices({ sims = [], avds = [], config, isMounted, deadProjects = [] }) {
410
- const dead = new Set(deadProjects);
411
- const referenced = /* @__PURE__ */ new Map();
412
- for (const [path, proj] of Object.entries(config?.projects || {})) {
413
- if (dead.has(path)) continue;
414
- const mounted = isMounted ? isMounted(path) : true;
415
- for (const { platforms } of projectDeviceSlots(proj)) {
416
- const ios = platforms.ios;
417
- if (ios?.deviceUdid) referenced.set(ios.deviceUdid, {
418
- path,
419
- mounted
420
- });
421
- const android = platforms.android;
422
- if (android?.avdName) referenced.set(android.avdName, {
423
- path,
424
- mounted
425
- });
426
- }
427
- }
428
- for (const sim of readParked("ios", { config })) referenced.set(sim.udid, {
429
- path: "the simulator pool",
430
- mounted: true
431
- });
432
- for (const avd of readParked("android", { config })) referenced.set(avd.name, {
433
- path: "the emulator pool",
434
- mounted: true
435
- });
436
- const orphaned = [];
437
- const kept = [];
438
- for (const sim of sims) {
439
- if (!sim?.name?.startsWith("stim-")) continue;
440
- const ref = referenced.get(sim.udid);
441
- if (!ref) orphaned.push({
442
- kind: "ios",
443
- id: sim.udid,
444
- name: sim.name
445
- });
446
- else kept.push({
447
- kind: "ios",
448
- id: sim.udid,
449
- name: sim.name,
450
- reason: describeKept(ref)
451
- });
452
- }
453
- for (const avdName of avds) {
454
- if (!avdName?.startsWith("stim-")) continue;
455
- const ref = referenced.get(avdName);
456
- if (!ref) orphaned.push({
457
- kind: "android",
458
- id: avdName,
459
- name: avdName
460
- });
461
- else kept.push({
462
- kind: "android",
463
- id: avdName,
464
- name: avdName,
465
- reason: describeKept(ref)
466
- });
467
- }
468
- return {
469
- orphaned,
470
- kept
471
- };
472
- }
473
- function findStaleProjectDevices({ config, sims = [], avds = [], olderThanDays, now = Date.now(), lastTouched, deadProjects = [] }) {
474
- if (!Number.isFinite(olderThanDays) || typeof lastTouched !== "function") return [];
475
- const cutoff = now - olderThanDays * DAY_MS;
476
- const dead = new Set(deadProjects);
477
- const liveSims = new Map(sims.filter((s) => s?.name?.startsWith("stim-")).map((s) => [s.udid, s.name]));
478
- const liveAvds = new Set(avds.filter((a) => typeof a === "string" && a.startsWith("stim-")));
479
- const stale = [];
480
- for (const [path, proj] of Object.entries(config?.projects || {})) {
481
- if (dead.has(path) || !isAbsolute(path)) continue;
482
- const touched = lastTouched(path);
483
- if (!Number.isFinite(touched) || touched >= cutoff) continue;
484
- const idleDays = Math.floor((now - touched) / DAY_MS);
485
- for (const { slot, platforms } of projectDeviceSlots(proj)) {
486
- const ios = platforms.ios;
487
- if (ios?.owned && ios.deviceUdid && liveSims.has(ios.deviceUdid)) stale.push({
488
- kind: "ios",
489
- id: ios.deviceUdid,
490
- name: liveSims.get(ios.deviceUdid),
491
- project: path,
492
- ...slot === "default" ? {} : { slot },
493
- idleDays
494
- });
495
- const android = platforms.android;
496
- if (android?.owned && android.avdName && liveAvds.has(android.avdName)) stale.push({
497
- kind: "android",
498
- id: android.avdName,
499
- name: android.avdName,
500
- project: path,
501
- idleDays,
502
- ...slot === "default" ? {} : { slot }
503
- });
504
- }
505
- }
506
- return stale;
507
- }
508
- function findStaleDeviceRecords({ config, sims = [], avds = [], deadProjects = [], simsChecked = true, avdsChecked = true }) {
509
- const dead = new Set(deadProjects);
510
- const liveSims = new Set(sims.map((s) => s?.udid).filter(Boolean));
511
- const liveAvds = new Set(avds.filter((a) => typeof a === "string"));
512
- const stale = [];
513
- for (const [path, proj] of Object.entries(config?.projects || {})) {
514
- if (dead.has(path)) continue;
515
- for (const { slot, platforms } of projectDeviceSlots(proj)) {
516
- const ios = platforms.ios;
517
- if (simsChecked && ios?.deviceUdid && !liveSims.has(ios.deviceUdid)) stale.push({
518
- kind: "ios",
519
- id: ios.deviceUdid,
520
- project: path,
521
- ...slot === "default" ? {} : { slot },
522
- owned: Boolean(ios.owned)
523
- });
524
- const android = platforms.android;
525
- if (avdsChecked && android?.avdName && !liveAvds.has(android.avdName)) stale.push({
526
- kind: "android",
527
- id: android.avdName,
528
- project: path,
529
- ...slot === "default" ? {} : { slot },
530
- owned: Boolean(android.owned)
531
- });
532
- }
533
- }
534
- return stale;
535
- }
536
- function describeUnverifiableDevices(simNames = [], avdNames = [], { reason = "no Stim config found" } = {}) {
537
- const ours = [...simNames, ...avdNames].filter((n) => typeof n === "string" && n.startsWith("stim-"));
538
- if (ours.length === 0) return [`${reason}; device sweep skipped`];
539
- return [`${reason}, so ${ours.length} stim-created device(s) cannot be verified as orphaned: ${ours.join(", ")}`, "they were NOT touched. If they are stale, delete them with `xcrun simctl delete <udid>` or `avdmanager delete avd -n <name>`"];
540
- }
541
- function withAndroidAvdSizes(devices, { avdDirectory = ownedAvdDirectory, size = directorySize } = {}) {
542
- return devices.map((device) => {
543
- if (device.kind !== "android") return device;
544
- const dir = device.orphanedDirectory?.directory ?? avdDirectory(device.id);
545
- if (!dir) return device;
546
- try {
547
- const bytes = size(dir, { timeoutMs: AVD_SIZE_TIMEOUT_MS });
548
- return bytes > 0 ? {
549
- ...device,
550
- bytes
551
- } : device;
552
- } catch {
553
- return device;
554
- }
555
- });
556
- }
557
- function describeParkedSims(records, sims, deviceTypes, { simsChecked = true } = {}) {
558
- const listed = new Map(sims.map((sim) => [sim.udid, sim]));
559
- return records.map((record) => {
560
- const sim = listed.get(record.udid);
561
- return {
562
- udid: record.udid,
563
- name: sim?.name ?? record.name,
564
- model: deviceTypes.find((d) => d.identifier === record.deviceTypeIdentifier)?.name ?? null,
565
- runtime: sim ? parseRuntimeVersion(sim.runtime) : parseRuntimeVersion(record.runtimeIdentifier),
566
- parkedAt: record.parkedAt,
567
- bytes: typeof sim?.dataPathSize === "number" ? sim.dataPathSize : null,
568
- listed: simsChecked ? Boolean(sim) : null
569
- };
570
- });
571
- }
572
- function collectParkedSims(deps) {
573
- const records = readParked("ios");
574
- if (records.length === 0) return [];
575
- let sims;
576
- let deviceTypes = [];
577
- try {
578
- sims = (deps.listAllIosSims ?? listAllIosSims)({
579
- timeoutMs: DEVICE_LIST_TIMEOUT_MS,
580
- includeUnavailable: true
581
- });
582
- } catch {
583
- return describeParkedSims(records, [], [], { simsChecked: false });
584
- }
585
- try {
586
- deviceTypes = (deps.listIosDeviceTypes ?? listIosDeviceTypes)();
587
- } catch {}
588
- return describeParkedSims(records, sims, deviceTypes);
589
- }
590
- function collectParkedAvds(deps) {
591
- const records = readParked("android");
592
- if (!records.length) return [];
593
- let avds = null;
594
- try {
595
- avds = (deps.listAvds ?? listAvds)();
596
- } catch {}
597
- return records.map((record) => {
598
- let bytes = null;
599
- try {
600
- const directory = (deps.avdDirectory ?? ownedAvdDirectory)(record.name);
601
- if (directory) bytes = (deps.directorySize ?? directorySize)(directory, { timeoutMs: AVD_SIZE_TIMEOUT_MS });
602
- } catch {}
603
- return {
604
- name: record.name,
605
- systemImage: record.systemImage,
606
- parkedAt: record.parkedAt,
607
- bytes,
608
- listed: avds === null ? null : avds.includes(record.name)
609
- };
610
- });
611
- }
612
- function deleteParkedAvds(avds) {
613
- let failures = 0;
614
- for (const avd of avds) {
615
- if (avd.listed === null) {
616
- failures++;
617
- console.log(chalk.red(`Could not verify parked android emulator ${avd.name}; its pool record was kept.`));
618
- continue;
619
- }
620
- const result = teardownParkedAvd(avd.name);
621
- if (result.status === "failed") {
622
- failures++;
623
- console.log(chalk.red(`Failed to delete parked android emulator ${avd.name}: ${result.reason}`));
624
- } else if (result.status === "torn-down") console.log(chalk.green(`Deleted parked android emulator ${avd.name}`));
625
- else console.log(chalk.dim(`Skipped ${avd.name}; it is no longer parked.`));
626
- }
627
- return failures;
628
- }
629
- function deleteParkedSims(parkedSims, deps = {}) {
630
- let failures = 0;
631
- let emptied = 0;
632
- for (const sim of parkedSims) {
633
- if (sim.listed === null) {
634
- failures++;
635
- console.log(chalk.red(`Could not verify parked ios sim ${sim.name} (${sim.udid}); its pool record was kept.`));
636
- continue;
637
- }
638
- try {
639
- const teardown = sim.listed ? teardownParkedIosSim(sim.udid, {
640
- label: sim.name,
641
- deleteSim: deps.deleteParkedIosSim
642
- }) : null;
643
- if (!(sim.listed ? teardown?.status === "torn-down" : dropParked("ios", sim.udid))) {
644
- if (teardown?.status === "failed") {
645
- failures++;
646
- console.log(chalk.red(`Failed to delete parked ios sim ${sim.name}: ${teardown.reason}`));
647
- } else console.log(chalk.dim(`Skipped ${sim.name} (${sim.udid}); it is no longer parked.`));
648
- continue;
649
- }
650
- emptied++;
651
- console.log(chalk.green(sim.listed ? `Deleted parked ios sim ${sim.name} (${sim.udid})` : `Dropped the parked record for ${sim.name} (${sim.udid}); it is not on this machine.`));
652
- } catch (err) {
653
- failures++;
654
- console.log(chalk.red(`Failed to delete parked ios sim ${sim.name}: ${err?.message || err}`));
655
- }
656
- }
657
- if (emptied) console.log(chalk.dim(` emptied ${plural(emptied, "parked simulator")} from the pool`));
658
- return failures;
659
- }
660
- function deviceSweepIsScoped() {
661
- return Boolean(process.env.STIM_HOME);
662
- }
663
- function collectDeviceLeases(now) {
664
- const expired = [];
665
- const kept = [];
666
- for (const entry of listLeaseFiles()) {
667
- const lease = entry.lease;
668
- if (!lease) {
669
- kept.push({
670
- name: entry.name,
671
- path: entry.path,
672
- reason: "it does not parse as a lease, so no run may take that device"
673
- });
674
- continue;
675
- }
676
- if (leaseIsExpired(lease, now)) {
677
- expired.push(entry);
678
- continue;
679
- }
680
- if (!existsSync(lease.holder)) kept.push({
681
- name: entry.name,
682
- path: entry.path,
683
- reason: `${lease.holder} is not on this machine, but the lease runs until ${lease.expiresAt}`
684
- });
685
- }
686
- return {
687
- expired,
688
- kept
689
- };
690
- }
691
- function deleteProjectDevices(orphanedDevices, staleDevices, staleDeviceRecords) {
692
- let deleteFailures = 0;
693
- function reap(d, onlyIfMissing = false) {
694
- const r = d.kind === "ios" ? teardownOwnedIosSim(d.id, {
695
- del: true,
696
- label: d.name
697
- }) : teardownOwnedAvd(d.name, {
698
- del: true,
699
- onlyIfMissing,
700
- ..."project" in d ? { owner: {
701
- projectPath: d.project,
702
- slot: d.slot
703
- } } : {},
704
- ..."project" in d ? { onRemoved: () => {
705
- if (clearDevice(d.project, d.kind, d.slot, d.id)) console.log(chalk.dim(` cleared the ${d.kind} record for ${d.project}`));
706
- } } : {},
707
- ..."orphanedDirectory" in d ? { orphanedDirectory: d.orphanedDirectory } : {}
708
- });
709
- const what = d.kind === "ios" ? `ios sim ${d.name} (${d.id})` : `android avd ${d.name}`;
710
- if (r.status === "torn-down") console.log(chalk.green(`Deleted ${what}`));
711
- else if (r.status === "missing") console.log(chalk.dim(`${what} is already gone; nothing to delete.`));
712
- else if (r.status === "skipped") console.log(chalk.yellow(`Skipped ${what}: ${r.reason} -- left for a later gc`));
713
- else {
714
- deleteFailures++;
715
- console.log(chalk.red(`Failed to delete ${d.kind} device ${d.name}: ${r.reason}`));
716
- }
717
- return r.status;
718
- }
719
- for (const d of orphanedDevices) reap(d);
720
- for (const d of staleDevices) {
721
- const status = reap(d);
722
- if (d.kind === "ios" && (status === "torn-down" || status === "missing")) {
723
- clearDevice(d.project, d.kind, d.slot, d.id);
724
- console.log(chalk.dim(` cleared the ${d.kind} record for ${d.project}`));
725
- }
726
- }
727
- for (const r of staleDeviceRecords) if (r.kind === "android" && r.owned) reap({
728
- ...r,
729
- name: r.id,
730
- idleDays: 0
731
- }, true);
732
- else if (withConfigLock(() => {
733
- if (r.kind === "android" && deviceSlotPlatforms(getProject(r.project), r.slot)?.android?.owned) return false;
734
- return clearDevice(r.project, r.kind, r.slot, r.id);
735
- })) console.log(chalk.green(`Cleared the ${r.kind} record for ${r.project} (${r.id} is not on this machine)`));
736
- return deleteFailures;
737
- }
738
- //#endregion
739
21
  //#region src/commands/gc/eas-sessions.ts
740
22
  const EAS_OPERATION_TIMEOUT_MS = 3e4;
741
23
  const EAS_COLLECTION_TIMEOUT_MS = 6e4;
@@ -755,18 +37,18 @@ function readRecordedRemoteSessionIds(roots) {
755
37
  let safe = true;
756
38
  for (const root of new Set(roots)) {
757
39
  try {
758
- if (!statSync(root).isDirectory()) throw new Error("path is not a directory");
759
- readdirSync(root);
40
+ if (!statSync$1(root).isDirectory()) throw new Error("path is not a directory");
41
+ readdirSync$1(root);
760
42
  } catch (error) {
761
43
  safe = false;
762
44
  notices.push(`${root} is not available as a readable workspace directory: ${describeError(error)}`);
763
45
  continue;
764
46
  }
765
47
  const path = workspaceStateFile(root);
766
- if (!existsSync(path)) continue;
48
+ if (!existsSync$1(path)) continue;
767
49
  let state;
768
50
  try {
769
- state = JSON.parse(readFileSync(path, "utf-8"));
51
+ state = JSON.parse(readFileSync$1(path, "utf-8"));
770
52
  } catch (error) {
771
53
  safe = false;
772
54
  notices.push(`${path} could not be read as valid JSON: ${describeError(error)}`);
@@ -802,8 +84,8 @@ function claimStateAbsence(claim) {
802
84
  const stateDir = dirname(claim.stateFile);
803
85
  let entries;
804
86
  try {
805
- if (!statSync(stateDir).isDirectory()) throw new Error("path is not a directory");
806
- entries = readdirSync(stateDir);
87
+ if (!statSync$1(stateDir).isDirectory()) throw new Error("path is not a directory");
88
+ entries = readdirSync$1(stateDir);
807
89
  } catch (error) {
808
90
  return {
809
91
  absent: false,
@@ -816,7 +98,7 @@ function claimStateAbsence(claim) {
816
98
  };
817
99
  let state;
818
100
  try {
819
- state = JSON.parse(readFileSync(claim.stateFile, "utf-8"));
101
+ state = JSON.parse(readFileSync$1(claim.stateFile, "utf-8"));
820
102
  } catch (error) {
821
103
  return {
822
104
  absent: false,
@@ -933,8 +215,8 @@ function easLockRoots(projectRoot, config) {
933
215
  }
934
216
  function unavailableLockRootNotice(roots) {
935
217
  for (const root of roots) try {
936
- if (!statSync(root).isDirectory()) throw new Error("path is not a directory");
937
- readdirSync(root);
218
+ if (!statSync$1(root).isDirectory()) throw new Error("path is not a directory");
219
+ readdirSync$1(root);
938
220
  } catch (error) {
939
221
  return `EAS session sweep skipped: ${root} is not available for remote-session lock inspection: ${describeError(error)}`;
940
222
  }
@@ -943,7 +225,7 @@ function unavailableLockRootNotice(roots) {
943
225
  function malformedRemoteSessionLockNotice(roots) {
944
226
  for (const root of roots) {
945
227
  const lockPath = join(workspaceDir(root), "remote-session.lock");
946
- if (!existsSync(lockPath)) continue;
228
+ if (!existsSync$1(lockPath)) continue;
947
229
  if ((0, ownership_claim_exports.readClaimSet)(lockPath).unresolved.length === 0) continue;
948
230
  return `EAS session sweep skipped: the remote-session lock at ${lockPath} has a malformed owner record.`;
949
231
  }
@@ -1117,6 +399,426 @@ async function deleteEasSessions(easSessionSweep, deps) {
1117
399
  return deleteFailures;
1118
400
  }
1119
401
  //#endregion
402
+ //#region src/workspace/merge-state.ts
403
+ const FETCH_TIMEOUT_MS = 3e4;
404
+ const FETCH_FRESH_MS = 6e5;
405
+ const GIT_TIMEOUT_MS = 6e4;
406
+ const REMOTE_PREFIX = "refs/remotes/origin/";
407
+ function failure(error) {
408
+ const { stderr, message } = error;
409
+ const text = String(stderr ?? "").trim() || String(message ?? error);
410
+ return text.split("\n")[0] ?? text;
411
+ }
412
+ function fetchedRecently(repo, now) {
413
+ const path = getExecutor().runFileQuiet("git", [
414
+ "-C",
415
+ repo,
416
+ "rev-parse",
417
+ "--path-format=absolute",
418
+ "--git-path",
419
+ "FETCH_HEAD"
420
+ ])?.trim();
421
+ try {
422
+ return Boolean(path) && now - statSync(path).mtimeMs < FETCH_FRESH_MS;
423
+ } catch {
424
+ return false;
425
+ }
426
+ }
427
+ /**
428
+ * Fetches the default branch that `origin/HEAD` names into its remote-tracking ref, bounded by a timeout and never
429
+ * prompting for credentials. It skips the fetch when the checkout fetched anything in the last 10 minutes.
430
+ */
431
+ function fetchDefaultBranch(repo, now = Date.now()) {
432
+ const exec = getExecutor();
433
+ const ref = exec.runFileQuiet("git", [
434
+ "-C",
435
+ repo,
436
+ "symbolic-ref",
437
+ "--quiet",
438
+ "refs/remotes/origin/HEAD"
439
+ ])?.trim();
440
+ if (!ref?.startsWith(REMOTE_PREFIX)) return { error: `origin/HEAD is not set; run \`git -C ${repo} remote set-head origin --auto\`` };
441
+ const branch = ref.slice(20);
442
+ if (fetchedRecently(repo, now)) return {
443
+ ref,
444
+ name: `origin/${branch}`
445
+ };
446
+ try {
447
+ exec.runFile("git", [
448
+ "-C",
449
+ repo,
450
+ "fetch",
451
+ "--quiet",
452
+ "--no-tags",
453
+ "--no-recurse-submodules",
454
+ "origin",
455
+ `+refs/heads/${branch}:${ref}`
456
+ ], {
457
+ timeoutMs: FETCH_TIMEOUT_MS,
458
+ env: { GIT_TERMINAL_PROMPT: "0" }
459
+ });
460
+ } catch (error) {
461
+ return { error: `git fetch origin ${branch} failed: ${failure(error)}` };
462
+ }
463
+ return {
464
+ ref,
465
+ name: `origin/${branch}`
466
+ };
467
+ }
468
+ function notMerged(detail) {
469
+ return {
470
+ merged: false,
471
+ unknown: false,
472
+ detail
473
+ };
474
+ }
475
+ function currentBranch(path) {
476
+ const ref = getExecutor().runFileQuiet("git", [
477
+ "-C",
478
+ path,
479
+ "symbolic-ref",
480
+ "--quiet",
481
+ "HEAD"
482
+ ])?.trim();
483
+ return ref?.startsWith("refs/heads/") ? ref : null;
484
+ }
485
+ function upstreamGone(path, branch) {
486
+ if (!branch) return false;
487
+ const [upstream, state] = (getExecutor().runFileQuiet("git", [
488
+ "-C",
489
+ path,
490
+ "for-each-ref",
491
+ "--format=%(upstream)%00%(upstream:track)",
492
+ branch
493
+ ]) ?? "").trim().split("\0");
494
+ return Boolean(upstream) && state === "[gone]";
495
+ }
496
+ function committedOn(path, branch, head) {
497
+ if (!branch) return false;
498
+ const exec = getExecutor();
499
+ return (exec.runFileQuiet("git", [
500
+ "-C",
501
+ path,
502
+ "reflog",
503
+ "show",
504
+ "--format=%H %gs",
505
+ branch
506
+ ]) ?? "").split("\n").some((entry) => {
507
+ const [sha = "", ...subject] = entry.split(" ");
508
+ return /^(commit|cherry-pick|rebase|revert)\b/.test(subject.join(" ")) && exec.runFileQuiet("git", [
509
+ "-C",
510
+ path,
511
+ "merge-base",
512
+ "--is-ancestor",
513
+ sha,
514
+ head
515
+ ]) !== null;
516
+ });
517
+ }
518
+ /**
519
+ * Whether the worktree's HEAD is merged into `target`. The signals, all local git:
520
+ * - HEAD is an ancestor of the default branch, off its first-parent line, and the branch's reflog shows a commit made
521
+ * on it that HEAD contains, so a merge commit brought the branch's own work in. A branch with no commit of its own
522
+ * is not merged.
523
+ * - The branch changes the tree, has no merge commits, and every commit since the merge base has the same
524
+ * `git patch-id --verbatim` as a commit on the default branch (a rebase merge).
525
+ * - The branch changes the tree and its whole diff since the merge base has the same verbatim patch id as a commit on
526
+ * the default branch, limited to the files the branch changes (a squash merge).
527
+ * Anything git cannot answer is unknown, never merged. `coversUnpushed` is true for a patch-equivalent HEAD whose
528
+ * upstream branch was deleted: its commits exist only locally, but their change is on the default branch.
529
+ */
530
+ function mergeState(path, { ref, name }) {
531
+ const git = (args, input) => getExecutor().runFile("git", [
532
+ "--literal-pathspecs",
533
+ "-C",
534
+ path,
535
+ ...args
536
+ ], {
537
+ timeoutMs: GIT_TIMEOUT_MS,
538
+ input
539
+ });
540
+ const patch = (args) => getExecutor().runFile("git", [
541
+ "--literal-pathspecs",
542
+ "-C",
543
+ path,
544
+ ...args
545
+ ], {
546
+ timeoutMs: GIT_TIMEOUT_MS,
547
+ untrimmed: true
548
+ });
549
+ const patchIds = (text) => text ? git(["patch-id", "--verbatim"], text).split("\n").flatMap((line) => line.split(" ")[0] || []) : [];
550
+ const diffOptions = ["--no-color", "--no-ext-diff"];
551
+ const noOwnCommits = notMerged(`no commits of its own beyond ${name}`);
552
+ try {
553
+ const head = git([
554
+ "rev-parse",
555
+ "--verify",
556
+ "HEAD^{commit}"
557
+ ]);
558
+ const base = git([
559
+ "merge-base",
560
+ head,
561
+ ref
562
+ ]);
563
+ const branch = currentBranch(path);
564
+ if (base === head) {
565
+ if (git(["rev-parse", ref]) === head || git([
566
+ "rev-list",
567
+ "--first-parent",
568
+ "--parents",
569
+ `${head}..${ref}`
570
+ ]).split("\n").some((line) => line.split(" ")[1] === head) || !committedOn(path, branch, head)) return noOwnCommits;
571
+ return {
572
+ merged: true,
573
+ into: name,
574
+ head,
575
+ coversUnpushed: false
576
+ };
577
+ }
578
+ const files = git([
579
+ "diff",
580
+ "--name-only",
581
+ "--no-renames",
582
+ "-z",
583
+ base,
584
+ head
585
+ ]).split("\0").filter(Boolean);
586
+ if (!files.length) return notMerged(`no net change beyond ${name}`);
587
+ const log = (range, pathspec = []) => patch([
588
+ "log",
589
+ "-p",
590
+ "--no-merges",
591
+ ...diffOptions,
592
+ "--format=commit %H",
593
+ range,
594
+ "--",
595
+ ...pathspec
596
+ ]);
597
+ const upstream = new Set(patchIds(log(`${base}..${ref}`, files)));
598
+ const patchEquivalent = () => ({
599
+ merged: true,
600
+ into: name,
601
+ head,
602
+ coversUnpushed: upstreamGone(path, branch)
603
+ });
604
+ const squash = patchIds(patch([
605
+ "diff",
606
+ ...diffOptions,
607
+ base,
608
+ head
609
+ ]));
610
+ if (squash.length === 1 && upstream.has(squash[0])) return patchEquivalent();
611
+ if (!git([
612
+ "rev-list",
613
+ "--merges",
614
+ `${base}..${head}`
615
+ ])) {
616
+ const own = patchIds(log(`${base}..${head}`));
617
+ if (own.length && own.every((id) => upstream.has(id))) return patchEquivalent();
618
+ }
619
+ return notMerged(`not merged into ${name}`);
620
+ } catch (error) {
621
+ return {
622
+ merged: false,
623
+ unknown: true,
624
+ detail: `merge state unknown: ${failure(error)}`
625
+ };
626
+ }
627
+ }
628
+ //#endregion
629
+ //#region src/commands/gc/worktrees.ts
630
+ const DEFAULT_WORKTREE_IDLE_DAYS = 7;
631
+ const DAY_MS = 864e5;
632
+ const MERGE_DECIDES = /* @__PURE__ */ new Set([
633
+ "unpushed",
634
+ "not-merged",
635
+ "last-use-unknown",
636
+ "recently-used"
637
+ ]);
638
+ function skip(code, text) {
639
+ return {
640
+ code,
641
+ text
642
+ };
643
+ }
644
+ function worktreeSkipReason(facts, olderThan) {
645
+ if (facts.bare) return skip("bare-repository", "bare repository");
646
+ if (typeof facts.source === "object") return skip("source-checkout-unknown", `source checkout unknown: ${facts.source.refusal}`);
647
+ if (facts.source === "source") return skip("source-checkout", "source checkout");
648
+ if (facts.locked) return skip("locked", "locked with git worktree lock");
649
+ if (facts.inUse.length) return skip("in-use", `in use: ${facts.inUse.join("; ")}`);
650
+ if (facts.porcelain === null) return skip("status-unreadable", "git status could not be read");
651
+ if (excludePodChurn(facts.porcelain).lines.length) return skip("dirty", "dirty: uncommitted changes or untracked files");
652
+ if (facts.unpushed === null) return skip("unpushed-unchecked", "unpushed commits could not be checked");
653
+ const merged = facts.merge?.merged ? facts.merge : null;
654
+ if (facts.unpushed.length && !merged?.coversUnpushed) return skip("unpushed", `unpushed: ${plural(facts.unpushed.length, "commit")} on no remote or other branch`);
655
+ if (facts.submodules) return skip("submodules", "initialized submodules");
656
+ if (merged) return null;
657
+ const notMerged = facts.merge && !facts.merge.merged ? facts.merge : null;
658
+ if (olderThan === null) return skip(notMerged?.unknown ? "merge-unknown" : "not-merged", notMerged?.detail ?? "not merged");
659
+ const also = notMerged ? `; ${notMerged.detail}` : "";
660
+ if (facts.idleDays === null) return skip("last-use-unknown", `recently used: its last use is unknown${also}`);
661
+ if (facts.idleDays < olderThan) return skip("recently-used", `recently used ${facts.idleDays}d ago${also}`);
662
+ return null;
663
+ }
664
+ /** Why gc removes a worktree it did not skip: `merged into origin/main` or `idle 9d`. */
665
+ function worktreeRemovalReason(candidate) {
666
+ return candidate.merge?.merged ? `merged into ${candidate.merge.into}` : `idle ${candidate.idleDays ?? 0}d`;
667
+ }
668
+ function lastUsedOf(keys) {
669
+ const times = keys.map(workspaceLastUsed).filter(Number.isFinite);
670
+ return times.length ? Math.max(...times) : NaN;
671
+ }
672
+ function idleDaysOf(keys, now) {
673
+ const last = lastUsedOf(keys);
674
+ return Number.isFinite(last) ? Math.max(0, Math.floor((now - last) / DAY_MS)) : null;
675
+ }
676
+ function inUseOf(keys, checks) {
677
+ return [...new Set(keys.flatMap((key) => workspaceInUse(key, checks)))];
678
+ }
679
+ function porcelainOf(path, gitAnswered) {
680
+ if (gitAnswered === null) return null;
681
+ if (!gitAnswered) return [];
682
+ const lines = dirtyPaths(path, { limit: Infinity });
683
+ return lines.length ? lines : null;
684
+ }
685
+ function candidateRoots() {
686
+ const registered = Object.keys(loadConfig()?.projects ?? {}).filter(isAbsolute);
687
+ const recorded = listWorkspaceDirs().flatMap((entry) => entry.projectRoot ? [entry.projectRoot] : []);
688
+ return [.../* @__PURE__ */ new Set([...registered, ...recorded])].filter((root) => existsSync$1(root)).toSorted();
689
+ }
690
+ function checkMergeStates(pending, idle, now) {
691
+ const repos = /* @__PURE__ */ new Map();
692
+ for (const entry of pending) repos.set(entry.repo, [...repos.get(entry.repo) ?? [], entry]);
693
+ for (const [repo, entries] of repos) {
694
+ const target = fetchDefaultBranch(repo, now);
695
+ for (const { candidate, facts } of entries) {
696
+ const merge = "error" in target ? {
697
+ merged: false,
698
+ unknown: true,
699
+ detail: `merge state unknown: ${target.error}`
700
+ } : mergeState(candidate.path, target);
701
+ const verdict = worktreeSkipReason({
702
+ ...facts,
703
+ merge
704
+ }, idle);
705
+ Object.assign(candidate, {
706
+ merge,
707
+ skipCode: verdict?.code ?? null,
708
+ skipped: verdict?.text ?? null
709
+ });
710
+ }
711
+ }
712
+ }
713
+ /**
714
+ * Classifies each Stim-managed linked worktree. A merged one is removable; with `idle`, so is one unused for
715
+ * `olderThan` days. Merge state is checked only where it decides the verdict, after one fetch per repository.
716
+ */
717
+ function collectWorktreeSweep({ idle, olderThan, now }) {
718
+ const days = idle ? olderThan ?? DEFAULT_WORKTREE_IDLE_DAYS : null;
719
+ const groups = /* @__PURE__ */ new Map();
720
+ const outside = [];
721
+ for (const root of candidateRoots()) {
722
+ const entry = matchWorktreeEntry(listWorktrees(root), root);
723
+ if (!entry) {
724
+ outside.push({
725
+ path: root,
726
+ keys: [root],
727
+ idleDays: null,
728
+ merge: null,
729
+ skipCode: "not-a-worktree",
730
+ skipped: "not inside a git worktree"
731
+ });
732
+ continue;
733
+ }
734
+ groups.set(entry.path, [...groups.get(entry.path) ?? [], root]);
735
+ }
736
+ const worktrees = [];
737
+ const pending = [];
738
+ for (const [path, roots] of groups) {
739
+ const entries = listWorktrees(path);
740
+ const entry = matchWorktreeEntry(entries, path);
741
+ const source = sourceCheckoutOf(entries);
742
+ const keys = [.../* @__PURE__ */ new Set([...roots, ...reclaimKeys(path)])];
743
+ const idleDays = idleDaysOf(keys, now);
744
+ const linked = !("refusal" in source) && entry !== null && source.path !== entry.path;
745
+ const gitAnswered = linked ? hasUncommittedWork(path) : null;
746
+ const facts = {
747
+ source: "refusal" in source ? source : linked ? "linked" : "source",
748
+ bare: Boolean(entry?.bare),
749
+ locked: Boolean(entry?.locked),
750
+ porcelain: porcelainOf(path, gitAnswered),
751
+ unpushed: linked ? unpushedCommits(path) : null,
752
+ submodules: linked && hasPopulatedSubmodules(path),
753
+ inUse: linked ? inUseOf(keys, { managedLocks: true }) : [],
754
+ idleDays,
755
+ merge: null
756
+ };
757
+ const verdict = worktreeSkipReason(facts, days);
758
+ const candidate = {
759
+ path,
760
+ keys,
761
+ idleDays,
762
+ merge: null,
763
+ skipCode: verdict?.code ?? null,
764
+ skipped: verdict?.text ?? null
765
+ };
766
+ if (verdict && MERGE_DECIDES.has(verdict.code) && !("refusal" in source)) pending.push({
767
+ candidate,
768
+ facts,
769
+ repo: source.path
770
+ });
771
+ worktrees.push(candidate);
772
+ }
773
+ checkMergeStates(pending, days, now);
774
+ const listed = [...worktrees, ...outside].filter((w) => idle || w.skipCode !== "not-a-worktree" && w.skipCode !== "source-checkout");
775
+ return {
776
+ idle: days === null ? null : {
777
+ olderThan: days,
778
+ defaulted: olderThan === null
779
+ },
780
+ worktrees: listed
781
+ };
782
+ }
783
+ async function removeWorktrees(sweep, { now = Date.now() } = {}) {
784
+ let failures = 0;
785
+ for (const candidate of sweep.worktrees) {
786
+ if (candidate.skipped) continue;
787
+ let kept = [];
788
+ const guard = (lockedKeys) => {
789
+ const keys = [.../* @__PURE__ */ new Set([...candidate.keys, ...lockedKeys])];
790
+ const unlocked = keys.filter((key) => !lockedKeys.includes(key));
791
+ const reasons = [...inUseOf(lockedKeys, { managedLocks: false }), ...inUseOf(unlocked, { managedLocks: true })].map((r) => `in use: ${r}`);
792
+ if (candidate.merge?.merged) {
793
+ if (resolveFullRef(candidate.path, "HEAD") !== candidate.merge.head) reasons.push("its HEAD moved since gc checked it");
794
+ } else {
795
+ const idleDays = idleDaysOf(keys, now);
796
+ if (idleDays === null || !sweep.idle || idleDays < sweep.idle.olderThan) reasons.push(`used ${idleDays === null ? "at an unknown time" : `${idleDays}d ago`} since gc checked it`);
797
+ }
798
+ kept = reasons;
799
+ return reasons;
800
+ };
801
+ let removed = false;
802
+ try {
803
+ const mergedHead = candidate.merge?.merged && candidate.merge.coversUnpushed ? candidate.merge.head : void 0;
804
+ removed = await removeWorktreeTarget(candidate.path, {
805
+ linkedOnly: true,
806
+ guard,
807
+ mergedHead
808
+ });
809
+ } catch (error) {
810
+ console.error(chalk.red(`Could not remove ${candidate.path}: ${error?.message || String(error)}`));
811
+ }
812
+ if (removed) console.log(chalk.green(`Removed the worktree ${candidate.path} (${worktreeRemovalReason(candidate)})`));
813
+ else if (kept.length) console.log(chalk.yellow(`Kept the worktree ${candidate.path}: ${kept.join("; ")}`));
814
+ else {
815
+ failures++;
816
+ console.error(chalk.yellow(`Kept the worktree ${candidate.path}; see the lines above for why.`));
817
+ }
818
+ }
819
+ return failures;
820
+ }
821
+ //#endregion
1120
822
  //#region src/commands/gc/report.ts
1121
823
  function shortKey(key) {
1122
824
  const text = String(key ?? "");
@@ -1162,11 +864,11 @@ function unresolvedLockLines(locks, slots) {
1162
864
  if (entries.length === 0) return [];
1163
865
  return [
1164
866
  `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"
867
+ ...entries.flatMap((entry) => [` ${entry.path}`, ` ${(0, ownership_claim_exports.claimRemoveCommand)(entry.path)}`]),
868
+ " run that yourself once you know nothing is building with it"
1167
869
  ];
1168
870
  }
1169
- function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invalidProjects = [], parkedSims = [], parkedAvds = [], orphanedDevices = [], staleDevices = [], staleDeviceRecords = [], buildLocks = {
871
+ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invalidProjects = [], orphanedWorkspaces = [], parkedSims = [], parkedAvds = [], orphanedDevices = [], unverifiedDevices = [], staleDevices = [], staleDeviceRecords = [], buildLocks = {
1170
872
  stale: [],
1171
873
  live: [],
1172
874
  unresolved: []
@@ -1177,19 +879,34 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
1177
879
  }, deviceLeases = {
1178
880
  expired: [],
1179
881
  kept: []
1180
- }, deviceSweepNotices = [], easSessionSweep = {
882
+ }, idleDevices = [], deviceSweepNotices = [], easSessionSweep = {
1181
883
  projectScope: null,
1182
884
  orphaned: [],
1183
885
  notices: [],
1184
886
  deletionSafe: true
1185
- }, caches = [], cacheScope = null, olderThan = null }, { now = Date.now() } = {}) {
887
+ }, caches = [], workspaceOutputs = null, worktreeSweep = null, cacheScope = null, olderThan = null }, { now = Date.now() } = {}) {
1186
888
  const lines = [];
1187
889
  const staleLocks = buildLocks?.stale ?? [];
1188
890
  const liveLocks = buildLocks?.live ?? [];
1189
891
  const staleSlots = buildSlots?.stale ?? [];
1190
892
  const expiredLeases = deviceLeases?.expired ?? [];
1191
893
  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) {
894
+ else if ([
895
+ deadProjects,
896
+ invalidProjects,
897
+ orphanedWorkspaces,
898
+ parkedSims,
899
+ parkedAvds,
900
+ orphanedDevices,
901
+ staleDevices,
902
+ staleDeviceRecords,
903
+ staleLocks,
904
+ staleSlots,
905
+ expiredLeases,
906
+ easSessionSweep.orphaned,
907
+ workspaceOutputs?.workspaces.filter((entry) => entry.willClear) ?? [],
908
+ worktreeSweep?.worktrees.filter((entry) => !entry.skipped) ?? []
909
+ ].every((found) => found.length === 0)) {
1193
910
  const reasons = [];
1194
911
  if (skipped.length > 0) reasons.push(`${skipped.length} entr${skipped.length === 1 ? "y" : "ies"} could not be checked`);
1195
912
  if (deviceSweepNotices.length > 0) reasons.push("device sweep incomplete");
@@ -1200,19 +917,30 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
1200
917
  lines.push(...projectEntryLines(`Dead project entries (${deadProjects.length}):`, deadProjects));
1201
918
  lines.push(...projectEntryLines(`Invalid project entries (${invalidProjects.length}) - the key is not an absolute path:`, invalidProjects));
1202
919
  lines.push(...namedPortLines(orphanedPorts));
920
+ lines.push(...orphanedWorkspaceLines(orphanedWorkspaces));
921
+ lines.push(...worktreeSweepLines(worktreeSweep));
1203
922
  lines.push(...formatParkedSimReport(parkedSims, now));
1204
923
  lines.push(...formatParkedAvdReport(parkedAvds, now));
1205
924
  if (orphanedDevices.length) {
1206
925
  lines.push(`Orphaned devices (${orphanedDevices.length}):`);
1207
926
  for (const d of orphanedDevices) lines.push(` ${d.kind} ${d.name} (${d.id})${deviceSizeSuffix(d)}`);
1208
927
  }
928
+ if (unverifiedDevices.length) {
929
+ lines.push(`Unrecognized stim-* devices (${unverifiedDevices.length}) - NOT deleted, because Stim has no record of creating them:`);
930
+ for (const d of unverifiedDevices) {
931
+ lines.push(` ${d.kind} ${d.name} (${d.id})`);
932
+ lines.push(` ${unverifiedDeviceCommand(d)}`);
933
+ }
934
+ lines.push(" run that yourself if you no longer need it");
935
+ }
1209
936
  if (staleDevices.length) {
1210
- lines.push(`Stale owned devices (${staleDevices.length}) - project untouched for ${olderThan ?? "?"}d or more:`);
937
+ lines.push(`Stale owned devices (${staleDevices.length}) - workspace unused for ${olderThan ?? "?"}d or more:`);
1211
938
  for (const d of staleDevices) {
1212
939
  lines.push(` ${d.kind} ${d.name} (${d.id})${deviceSizeSuffix(d)}`);
1213
940
  lines.push(` ${d.project} (idle ${d.idleDays}d)`);
1214
941
  }
1215
942
  }
943
+ lines.push(...idleDeviceLines(idleDevices));
1216
944
  if (staleDeviceRecords.length) {
1217
945
  lines.push(`Stale device records (${staleDeviceRecords.length}) - the device is gone, the project is not:`);
1218
946
  for (const r of staleDeviceRecords) {
@@ -1265,24 +993,7 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
1265
993
  lines.push(`Skipped (${skipped.length}) - not classified as dead:`);
1266
994
  for (const entry of skipped) lines.push(` ${entry.dir}: ${entry.reason}`);
1267
995
  }
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
- }
996
+ lines.push(...cacheLines(caches, workspaceOutputs));
1286
997
  return lines;
1287
998
  }
1288
999
  function leaseGarbageLines({ expired, kept }) {
@@ -1313,6 +1024,235 @@ function namedPortLines(ports = []) {
1313
1024
  " --delete stops TCP listeners and releases these allocations."
1314
1025
  ];
1315
1026
  }
1027
+ function orphanedWorkspaceLines(orphaned) {
1028
+ if (!orphaned.length) return [];
1029
+ const lines = [`Orphaned workspace directories (${orphaned.length}):`];
1030
+ for (const entry of orphaned) {
1031
+ lines.push(` ${entry.dir}${entry.bytes === void 0 ? "" : ` - ${sizeText(entry.bytes)}`}`);
1032
+ lines.push(` recorded project root ${entry.projectRoot} is gone and no registry entry names it`);
1033
+ }
1034
+ lines.push(" --delete re-checks each directory, then removes it whole.");
1035
+ return lines;
1036
+ }
1037
+ function workspaceOutputLines(outputs, bytes) {
1038
+ const lines = [
1039
+ ` ${formatBytes(bytes).padStart(10)} Workspace build outputs (detected)`,
1040
+ ` ${outputs.root}`,
1041
+ ` ${WORKSPACE_OUTPUT_DIRS.join(", ")} of each workspace; workspace.json, state.json, logs and device records stay`,
1042
+ ` ${REBUILD_COST}`
1043
+ ];
1044
+ for (const w of outputs.workspaces) {
1045
+ const idle = w.idleDays === null ? "last use unknown" : `idle ${w.idleDays}d`;
1046
+ lines.push(` ${sizeText(w.bytes).padStart(8)} ${w.projectRoot ?? w.dir} (${idle})`);
1047
+ lines.push(w.willClear ? " would be CLEARED" : ` kept: ${w.keptReason}`);
1048
+ }
1049
+ return lines;
1050
+ }
1051
+ function cacheLines(caches, workspaceOutputs) {
1052
+ const lines = [];
1053
+ const outputs = workspaceOutputs?.workspaces.length ? workspaceOutputs : null;
1054
+ if (caches.length || outputs) {
1055
+ const outputBytes = outputs ? outputs.workspaces.reduce((n, w) => n + (w.bytes ?? 0), 0) : 0;
1056
+ const total = caches.reduce((n, c) => n + (c.bytes ?? 0), 0) + outputBytes;
1057
+ lines.push(`Shared build caches (${caches.length + (outputs ? 1 : 0)}) - alive, not garbage:`);
1058
+ if (outputs) lines.push(...workspaceOutputLines(outputs, outputBytes));
1059
+ for (const c of caches) {
1060
+ const tag = c.source ? ` (${c.source})` : "";
1061
+ lines.push(` ${formatBytes(c.bytes ?? 0).padStart(10)} ${c.name}${tag}`);
1062
+ lines.push(` ${c.dir}`);
1063
+ if (c.note) lines.push(` ${c.note}`);
1064
+ if (c.willEmpty) lines.push(" would be EMPTIED");
1065
+ else if (c.emptySkipped) lines.push(` would be left alone: ${c.emptySkipped}`);
1066
+ }
1067
+ lines.push(` total: ${formatBytes(total)}`);
1068
+ const doomed = caches.filter((c) => c.willEmpty);
1069
+ if (doomed.length) {
1070
+ const doomedBytes = doomed.reduce((n, c) => n + (c.bytes ?? 0), 0);
1071
+ lines.push(` would empty ${doomed.length} of these (${formatBytes(doomedBytes)})`);
1072
+ }
1073
+ const clearing = outputs?.workspaces.filter((w) => w.willClear) ?? [];
1074
+ if (clearing.length) {
1075
+ const clearBytes = clearing.reduce((n, w) => n + (w.bytes ?? 0), 0);
1076
+ lines.push(` would clear the build outputs of ${clearing.length} workspace${clearing.length === 1 ? "" : "s"} (${formatBytes(clearBytes)})`);
1077
+ }
1078
+ }
1079
+ return lines;
1080
+ }
1081
+ function worktreeSweepLines(sweep) {
1082
+ if (!sweep || !sweep.idle && !sweep.worktrees.length) return [];
1083
+ const removable = sweep.worktrees.filter((w) => !w.skipped);
1084
+ const idle = !sweep.idle ? "" : sweep.idle.defaulted ? `, or clean, pushed and idle ${sweep.idle.olderThan}d or more (the default without --older-than)` : `, or clean, pushed and idle ${sweep.idle.olderThan}d or more`;
1085
+ const lines = [`Linked worktrees (${removable.length} removable, ${sweep.worktrees.length - removable.length} kept) - clean and merged into the default branch${idle}:`];
1086
+ for (const w of sweep.worktrees) {
1087
+ const age = w.idleDays === null ? "" : ` (idle ${w.idleDays}d)`;
1088
+ lines.push(` ${w.path}${age}`);
1089
+ lines.push(w.skipped ? ` kept: ${w.skipped}` : ` would be REMOVED by \`stim worktree remove\`: ${worktreeRemovalReason(w)}`);
1090
+ }
1091
+ if (removable.length) lines.push(" --delete runs it without --force; use, idleness and HEAD are re-checked under its removal locks.");
1092
+ return lines;
1093
+ }
1094
+ function jsonLock({ path, platform, key, pid, projectRoot }) {
1095
+ return {
1096
+ path,
1097
+ platform,
1098
+ key,
1099
+ pid,
1100
+ projectRoot
1101
+ };
1102
+ }
1103
+ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [], invalidProjects = [], orphanedWorkspaces = [], worktreeSweep = null, parkedSims = [], parkedAvds = [], orphanedDevices = [], unverifiedDevices = [], staleDevices = [], staleDeviceRecords = [], buildLocks = {
1104
+ stale: [],
1105
+ live: [],
1106
+ unresolved: []
1107
+ }, buildSlots = {
1108
+ stale: [],
1109
+ live: [],
1110
+ unresolved: []
1111
+ }, deviceLeases = {
1112
+ expired: [],
1113
+ kept: []
1114
+ }, idleDevices = [], deviceSweepNotices = [], easSessionSweep = {
1115
+ projectScope: null,
1116
+ orphaned: [],
1117
+ notices: [],
1118
+ deletionSafe: true
1119
+ }, workspaceOutputs = null, caches = [] }) {
1120
+ return {
1121
+ deadProjects: deadProjects.map((path) => ({ path })),
1122
+ invalidProjects: invalidProjects.map((path) => ({ path })),
1123
+ orphanedPorts: orphanedPorts.map(({ project, label, port }) => ({
1124
+ project,
1125
+ label,
1126
+ port
1127
+ })),
1128
+ orphanedWorkspaces: orphanedWorkspaces.map(({ dir, projectRoot, bytes }) => ({
1129
+ dir,
1130
+ projectRoot,
1131
+ bytes: bytes ?? null
1132
+ })),
1133
+ linkedWorktrees: (worktreeSweep?.worktrees ?? []).map((w) => ({
1134
+ path: w.path,
1135
+ idleDays: w.idleDays,
1136
+ mergedInto: w.merge?.merged ? w.merge.into : null,
1137
+ willRemove: w.skipCode === null,
1138
+ reason: w.skipCode,
1139
+ detail: w.skipped ?? worktreeRemovalReason(w)
1140
+ })),
1141
+ parkedSimulators: parkedSims.map(({ udid, name, model, runtime, parkedAt, bytes, listed }) => ({
1142
+ udid,
1143
+ name,
1144
+ model,
1145
+ runtime,
1146
+ parkedAt,
1147
+ bytes,
1148
+ listed
1149
+ })),
1150
+ parkedEmulators: parkedAvds.map(({ name, systemImage, parkedAt, bytes, listed }) => ({
1151
+ name,
1152
+ systemImage,
1153
+ parkedAt,
1154
+ bytes,
1155
+ listed
1156
+ })),
1157
+ orphanedDevices: orphanedDevices.map((d) => ({
1158
+ kind: d.kind,
1159
+ id: d.id,
1160
+ name: d.name,
1161
+ bytes: d.bytes ?? null,
1162
+ directory: d.orphanedDirectory?.directory ?? null
1163
+ })),
1164
+ unverifiedDevices: unverifiedDevices.map((d) => ({
1165
+ kind: d.kind,
1166
+ id: d.id,
1167
+ name: d.name,
1168
+ command: unverifiedDeviceCommand(d)
1169
+ })),
1170
+ staleDevices: staleDevices.map((d) => ({
1171
+ kind: d.kind,
1172
+ id: d.id,
1173
+ name: d.name,
1174
+ project: d.project,
1175
+ slot: d.slot ?? null,
1176
+ idleDays: d.idleDays,
1177
+ bytes: d.bytes ?? null
1178
+ })),
1179
+ staleDeviceRecords: staleDeviceRecords.map((r) => ({
1180
+ kind: r.kind,
1181
+ id: r.id,
1182
+ project: r.project,
1183
+ slot: r.slot ?? null
1184
+ })),
1185
+ idleDevices: idleDevices.map(({ kind, id, name, project, slot, lastActivityAt, idleForMs, buildInProgress }) => ({
1186
+ kind,
1187
+ id,
1188
+ name,
1189
+ project,
1190
+ slot,
1191
+ lastActivityAt,
1192
+ idleForMs,
1193
+ buildInProgress
1194
+ })),
1195
+ orphanedEasSessions: easSessionSweep.orphaned.map((s) => ({
1196
+ id: s.id,
1197
+ name: s.name,
1198
+ platform: s.platform,
1199
+ status: s.status,
1200
+ projectScope: s.projectScope
1201
+ })),
1202
+ staleBuildLocks: buildLocks.stale.map(jsonLock),
1203
+ staleBuildSlots: buildSlots.stale.map((s) => ({
1204
+ path: s.path,
1205
+ index: s.index,
1206
+ pid: s.pid,
1207
+ projectRoot: s.projectRoot
1208
+ })),
1209
+ unresolvedBuildClaims: [...(buildLocks.unresolved ?? []).map((l) => ({
1210
+ kind: "lock",
1211
+ path: l.path
1212
+ })), ...(buildSlots.unresolved ?? []).map((s) => ({
1213
+ kind: "slot",
1214
+ path: s.path
1215
+ }))],
1216
+ buildsInProgress: buildLocks.live.map(jsonLock),
1217
+ expiredDeviceLeases: deviceLeases.expired.map((entry) => ({
1218
+ path: entry.path,
1219
+ platform: entry.platform,
1220
+ id: entry.id ?? entry.name,
1221
+ deviceName: entry.lease?.deviceName ?? null,
1222
+ holder: entry.lease?.holder ?? null,
1223
+ expiresAt: entry.lease?.expiresAt ?? null
1224
+ })),
1225
+ keptDeviceLeases: deviceLeases.kept.map(({ name, path, reason }) => ({
1226
+ name,
1227
+ path,
1228
+ detail: reason
1229
+ })),
1230
+ deviceSweepNotices: deviceSweepNotices.map((message) => ({ message })),
1231
+ easSessionSweepNotices: easSessionSweep.notices.map((message) => ({ message })),
1232
+ skipped: skipped.map(({ dir, reason }) => ({
1233
+ path: dir,
1234
+ detail: reason
1235
+ })),
1236
+ workspaceBuildOutputs: (workspaceOutputs?.workspaces ?? []).map((w) => ({
1237
+ dir: w.dir,
1238
+ projectRoot: w.projectRoot,
1239
+ bytes: w.bytes,
1240
+ idleDays: w.idleDays,
1241
+ willClear: w.willClear,
1242
+ reason: w.keptCode,
1243
+ detail: w.keptReason
1244
+ })),
1245
+ caches: caches.map((c) => ({
1246
+ name: c.name,
1247
+ dir: c.dir,
1248
+ source: c.source ?? null,
1249
+ bytes: c.bytes ?? null,
1250
+ note: c.note || null,
1251
+ willEmpty: Boolean(c.willEmpty),
1252
+ emptySkipped: c.emptySkipped ?? null
1253
+ }))
1254
+ };
1255
+ }
1316
1256
  //#endregion
1317
1257
  //#region src/commands/gc.ts
1318
1258
  function removeInvalidProjectEntries(invalidProjects) {
@@ -1321,22 +1261,19 @@ function removeInvalidProjectEntries(invalidProjects) {
1321
1261
  console.log(chalk.green(`Removed the invalid registry entry ${path}`));
1322
1262
  }
1323
1263
  }
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 = {}) {
1264
+ async function collectGcReport({ olderThan = null, cache = null, worktrees = false, now = Date.now(), lastTouched = workspaceLastUsed } = {}, deps = {}) {
1332
1265
  const scope = typeof cache === "string" && cache.trim() ? cache : null;
1333
1266
  const all = scope !== null && olderThan === null;
1334
- const caches = planCacheEmptying(sizeCaches(selectCaches(discoverCaches({ declared: declaredCachePaths() }), scope)), all);
1267
+ const withWorkspaces = includesWorkspaceOutputs(scope);
1268
+ const selected = selectCaches(discoverCaches(), scope).filter((c) => !withWorkspaces || !isInsideWorkspaces(c.dir));
1269
+ const caches = planCacheEmptying(sizeCaches(selected), all);
1335
1270
  if (scope) return {
1336
1271
  skipped: [],
1337
1272
  deadProjects: [],
1338
1273
  invalidProjects: [],
1274
+ orphanedWorkspaces: [],
1339
1275
  orphanedDevices: [],
1276
+ unverifiedDevices: [],
1340
1277
  staleDevices: [],
1341
1278
  staleDeviceRecords: [],
1342
1279
  buildLocks: {
@@ -1353,6 +1290,7 @@ async function collectGcReport({ olderThan = null, cache = null, now = Date.now(
1353
1290
  expired: [],
1354
1291
  kept: []
1355
1292
  },
1293
+ idleDevices: [],
1356
1294
  deviceSweepNotices: [],
1357
1295
  easSessionSweep: {
1358
1296
  projectScope: null,
@@ -1363,6 +1301,11 @@ async function collectGcReport({ olderThan = null, cache = null, now = Date.now(
1363
1301
  parkedSims: [],
1364
1302
  parkedAvds: [],
1365
1303
  caches,
1304
+ workspaceOutputs: withWorkspaces ? collectWorkspaceOutputs({
1305
+ olderThan,
1306
+ now
1307
+ }) : null,
1308
+ worktreeSweep: null,
1366
1309
  cacheScope: scope,
1367
1310
  olderThan,
1368
1311
  all
@@ -1380,8 +1323,14 @@ async function collectGcReport({ olderThan = null, cache = null, now = Date.now(
1380
1323
  deletionSafe: false
1381
1324
  };
1382
1325
  }
1383
- const parkedSims = collectParkedSims(deps);
1384
- const parkedAvds = collectParkedAvds(deps);
1326
+ const parkedSims = collectParkedSims(deps, {
1327
+ olderThanDays: olderThan,
1328
+ now
1329
+ });
1330
+ const parkedAvds = collectParkedAvds(deps, {
1331
+ olderThanDays: olderThan,
1332
+ now
1333
+ });
1385
1334
  const deadProjects = [];
1386
1335
  const invalidProjects = [];
1387
1336
  const skipped = [];
@@ -1395,7 +1344,7 @@ async function collectGcReport({ olderThan = null, cache = null, now = Date.now(
1395
1344
  else invalidProjects.push(path);
1396
1345
  continue;
1397
1346
  }
1398
- if (existsSync(path)) continue;
1347
+ if (existsSync$1(path)) continue;
1399
1348
  if (!isOnMountedVolume(path, mountedVolumes)) {
1400
1349
  const volume = volumeRootFor(path);
1401
1350
  skipped.push({
@@ -1404,10 +1353,14 @@ async function collectGcReport({ olderThan = null, cache = null, now = Date.now(
1404
1353
  });
1405
1354
  } else deadProjects.push(path);
1406
1355
  }
1356
+ const workspaceDirs = collectOrphanedWorkspaces(Object.keys(cfg?.projects ?? {}), mountedVolumes);
1357
+ skipped.push(...workspaceDirs.skipped);
1407
1358
  const deviceSweepNotices = [];
1408
1359
  let orphanedDevices = [];
1360
+ let unverifiedDevices = [];
1409
1361
  let staleDevices = [];
1410
1362
  let staleDeviceRecords = [];
1363
+ let idleDevices = [];
1411
1364
  const unsweepableReason = cfg === null ? "no Stim config found" : deviceSweepIsScoped() ? "STIM_HOME scopes this config, but simulators and AVDs are machine-global" : null;
1412
1365
  if (unsweepableReason) {
1413
1366
  let simNames = [];
@@ -1445,19 +1398,26 @@ async function collectGcReport({ olderThan = null, cache = null, now = Date.now(
1445
1398
  }
1446
1399
  avds = [.../* @__PURE__ */ new Set([...avds, ...orphanedAvdDirectories.map((entry) => entry.name)])];
1447
1400
  const isMounted = (path) => isOnMountedVolume(path, mountedVolumes);
1448
- orphanedDevices = withAndroidAvdSizes(findOrphanedDevices({
1401
+ const found = findOrphanedDevices({
1449
1402
  sims,
1450
1403
  avds,
1451
1404
  config: cfg,
1452
1405
  isMounted,
1453
- deadProjects
1454
- }).orphaned.flatMap((device) => {
1406
+ deadProjects,
1407
+ isOwned: (device) => device.kind === "ios" ? isStimOwnedSim({
1408
+ udid: device.id,
1409
+ name: device.name
1410
+ }) : isStimOwnedAvdName(device.id, ownedAvdDirectory(device.id) ?? orphanedAvdDirectories.find((entry) => entry.name === device.id)?.directory ?? null)
1411
+ });
1412
+ unverifiedDevices = found.unverified;
1413
+ orphanedDevices = withAndroidAvdSizes(found.orphaned.flatMap((device) => {
1455
1414
  const directories = device.kind === "android" ? orphanedAvdDirectories.filter((entry) => entry.name === device.name) : [];
1456
1415
  return directories.length ? directories.map((orphanedDirectory) => Object.assign({}, device, { orphanedDirectory })) : [device];
1457
1416
  }), {
1458
1417
  avdDirectory: deps.avdDirectory ?? ownedAvdDirectory,
1459
1418
  size: deps.directorySize ?? directorySize
1460
1419
  });
1420
+ idleDevices = collectIdleDevices(cfg, sims, deadProjects, now);
1461
1421
  staleDeviceRecords = findStaleDeviceRecords({
1462
1422
  config: cfg,
1463
1423
  sims,
@@ -1491,9 +1451,11 @@ async function collectGcReport({ olderThan = null, cache = null, now = Date.now(
1491
1451
  port
1492
1452
  }))),
1493
1453
  invalidProjects,
1454
+ orphanedWorkspaces: workspaceDirs.orphaned,
1494
1455
  parkedSims,
1495
1456
  parkedAvds,
1496
1457
  orphanedDevices,
1458
+ unverifiedDevices,
1497
1459
  staleDevices,
1498
1460
  staleDeviceRecords,
1499
1461
  buildLocks: {
@@ -1507,24 +1469,57 @@ async function collectGcReport({ olderThan = null, cache = null, now = Date.now(
1507
1469
  unresolved: slots.filter((s) => s.unresolved)
1508
1470
  },
1509
1471
  deviceLeases,
1472
+ idleDevices,
1510
1473
  deviceSweepNotices,
1511
1474
  easSessionSweep,
1512
1475
  caches,
1476
+ workspaceOutputs: collectWorkspaceOutputs({
1477
+ olderThan,
1478
+ now,
1479
+ exclude: [...workspaceDirs.orphaned.map((entry) => entry.dir), ...deadProjects.map(workspaceDir)]
1480
+ }),
1481
+ worktreeSweep: collectWorktreeSweep({
1482
+ idle: worktrees,
1483
+ olderThan,
1484
+ now
1485
+ }),
1513
1486
  cacheScope: null,
1514
1487
  olderThan,
1515
1488
  all
1516
1489
  };
1517
1490
  }
1518
1491
  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;
1492
+ if (!opts.json) {
1493
+ await sweep(opts, deps);
1526
1494
  return;
1527
1495
  }
1496
+ const log = console.log;
1497
+ console.log = console.error;
1498
+ let payload;
1499
+ try {
1500
+ payload = await sweep(opts, deps);
1501
+ } finally {
1502
+ console.log = log;
1503
+ }
1504
+ console.log(JSON.stringify(payload));
1505
+ }
1506
+ async function sweep(opts, deps) {
1507
+ if (opts.cache && (opts.worktrees || opts.idle !== void 0)) {
1508
+ const flag = opts.worktrees ? "--worktrees" : "--idle";
1509
+ const message = opts.worktrees ? "--cache acts only on the named caches, and --worktrees sweeps linked worktrees." : "--cache acts only on the named caches, and --idle shuts down idle owned devices.";
1510
+ const remedy = `Run \`stim gc ${flag}${opts.worktrees ? "" : " <duration>"}\` and \`stim gc --cache <name>\` separately.`;
1511
+ console.error(chalk.red(message));
1512
+ console.error(chalk.dim(remedy));
1513
+ console.error(chalk.red("failed: STIM_BAD_ARG"));
1514
+ process.exitCode = 1;
1515
+ return {
1516
+ code: "STIM_BAD_ARG",
1517
+ message,
1518
+ remedy
1519
+ };
1520
+ }
1521
+ const poolError = parkedMaxSetting("ios").error || parkedMaxSetting("android").error;
1522
+ if (poolError) console.error(chalk.yellow(`${poolError} ${POOL_SETTING_REMEDY}`));
1528
1523
  if (opts.cache) return runGcCore(opts, {
1529
1524
  ...deps,
1530
1525
  precollectedEasSessionSweep: {
@@ -1596,28 +1591,50 @@ async function runGcCore(opts, deps) {
1596
1591
  const cache = typeof opts.cache === "string" && opts.cache.trim() ? opts.cache : null;
1597
1592
  const report = await collectGcReport({
1598
1593
  olderThan,
1599
- cache
1594
+ cache,
1595
+ worktrees: Boolean(opts.worktrees)
1600
1596
  }, 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.`));
1597
+ if (cache && report.caches.length === 0 && report.workspaceOutputs === null) {
1598
+ const names = [...new Set(discoverCaches().map((c) => c.name))];
1599
+ const message = `No shared cache carries "${cache}" in its name or directory.`;
1600
+ console.log(chalk.yellow(message));
1604
1601
  if (names.length) console.log(chalk.dim(`Caches on this machine: ${names.join(", ")}`));
1605
- return;
1602
+ if (opts.json) process.exitCode = 1;
1603
+ return {
1604
+ code: "STIM_BAD_ARG",
1605
+ message,
1606
+ remedy: names.length ? `Pass --cache all or a name from: ${names.join(", ")}.` : "No shared cache was found on this machine."
1607
+ };
1606
1608
  }
1607
1609
  const all = report.all;
1608
1610
  for (const line of formatGcReport(report)) console.log(line);
1609
1611
  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;
1612
+ 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;
1613
+ const idle = opts.idle ?? null;
1614
+ const idleFailures = idle === null ? 0 : shutDownIdleDevices(report.idleDevices, idle, () => collectIdleDevices(loadConfig(), listAllIosSims({ timeoutMs: DEVICE_LIST_TIMEOUT_MS }), report.deadProjects)).failures;
1615
+ if (idleFailures) process.exitCode = 1;
1616
+ const payload = (failures) => ({
1617
+ mode: opts.delete ? "delete" : "dry-run",
1618
+ idle,
1619
+ cacheScope: report.cacheScope,
1620
+ olderThan,
1621
+ worktreeSweep: report.worktreeSweep?.idle ?? null,
1622
+ actionable,
1623
+ failures,
1624
+ sections: gcReportSections(report)
1625
+ });
1611
1626
  if (!opts.delete) {
1612
1627
  if (all) console.log(chalk.dim("\nDry run. Re-run with --delete to empty the caches above."));
1613
1628
  else if (actionable) console.log(chalk.dim("\nDry run. Re-run with --delete to reclaim."));
1614
1629
  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;
1630
+ return payload(idle === null ? null : idleFailures);
1616
1631
  }
1617
- let deleteFailures = deleteParkedSims(report.parkedSims, deps) + deleteParkedAvds(report.parkedAvds);
1632
+ let deleteFailures = idleFailures;
1633
+ deleteFailures += report.workspaceOutputs ? (await clearWorkspaceOutputs(report.workspaceOutputs, { olderThan })).failures : 0;
1634
+ deleteFailures += deleteParkedSims(report.parkedSims, deps) + deleteParkedAvds(report.parkedAvds);
1618
1635
  removeInvalidProjectEntries(invalidProjects);
1619
1636
  for (const path of deadProjects) {
1620
- if (existsSync(path) || !isOnMountedVolume(path)) {
1637
+ if (existsSync$1(path) || !isOnMountedVolume(path)) {
1621
1638
  console.log(chalk.yellow(`Kept ${path}: its absence can no longer be confirmed.`));
1622
1639
  continue;
1623
1640
  }
@@ -1638,7 +1655,9 @@ async function runGcCore(opts, deps) {
1638
1655
  if (result.stoppedSession) console.log(chalk.dim(` stopped remote session ${result.stoppedSession}`));
1639
1656
  if (result.stoppedTunnel) console.log(chalk.dim(` stopped ${result.stoppedTunnel} tunnel`));
1640
1657
  for (const s of result.skippedDevices) console.log(chalk.yellow(` ${s.name}: ${s.reason}`));
1658
+ deleteFailures += result.failedDevices.length;
1641
1659
  }
1660
+ deleteFailures += await deleteOrphanedWorkspaces(report.orphanedWorkspaces);
1642
1661
  deleteFailures += deleteProjectDevices(orphanedDevices, staleDevices, staleDeviceRecords);
1643
1662
  for (const lock of buildLocks.stale) {
1644
1663
  const cleared = (0, ownership_claim_exports.clearFreeClaimSet)({
@@ -1682,26 +1701,30 @@ async function runGcCore(opts, deps) {
1682
1701
  console.log(chalk.red(`Failed to clear the device lease at ${entry.path}: ${err?.message || err}`));
1683
1702
  }
1684
1703
  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;
1704
+ if (report.worktreeSweep) deleteFailures += await removeWorktrees(report.worktreeSweep);
1705
+ if (deleteFailures) {
1706
+ console.log(chalk.red(`\n${deleteFailures} entr${deleteFailures === 1 ? "y" : "ies"} could not be deleted; see above.`));
1707
+ process.exitCode = 1;
1689
1708
  }
1690
- if (olderThan === null) {
1709
+ if (all) emptyCaches(caches);
1710
+ else if (olderThan === null) {
1691
1711
  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);
1712
+ } else trimCaches(caches, olderThan);
1713
+ return payload(deleteFailures);
1695
1714
  }
1696
1715
  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) => {
1716
+ program.command("gc").description("Report what Stim has left behind: dead project entries, orphaned workspace directories, clean Stim-managed linked worktrees whose branch is merged, 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 remove clean, idle Stim-managed linked worktrees, not only merged ones: 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 and parked devices only once parked this long", (v) => {
1698
1717
  const n = parseInt(v, 10);
1699
1718
  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
1719
  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) => {
1720
+ }).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
1721
  if (!v.trim()) throw new InvalidArgumentError("must name a cache, e.g. --cache \"compilation cache\"");
1703
1722
  return v;
1704
- }).action(async (opts) => {
1723
+ }).option("--idle <duration>", "shut down (never delete) owned simulators and emulators that have had no driver, claim, or activity for at least <duration>, such as 30m, 2h, or 1d; acts without --delete", (v) => {
1724
+ const ms = parseIdleDuration(v);
1725
+ if (ms === null) throw new InvalidArgumentError("must be a whole number of minutes, hours, or days, e.g. --idle 2h");
1726
+ return ms;
1727
+ }).option("--json", "print the report as JSON on stdout; every other line goes to stderr").action(async (opts) => {
1705
1728
  await runGc(opts);
1706
1729
  });
1707
1730
  }