stim 1.9.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 (83) hide show
  1. package/dist/{android-_y4Ohi5z.mjs → android-787HhP5w.mjs} +119 -29
  2. package/dist/{android-BPGizezh.mjs → android-DBkeHwpm.mjs} +168 -95
  3. package/dist/{android-DEgo4oCo.mjs → android-DPov-fv2.mjs} +2 -2
  4. package/dist/{android-cas-eiExNLf7.mjs → android-cas-CITmIeWY.mjs} +9 -8
  5. package/dist/android-cas-compiler.mjs +1 -1
  6. package/dist/{app-install-Uf3a83Tv.mjs → app-install-CrJ6t_bv.mjs} +2 -2
  7. package/dist/budget-BP4jZZL4.mjs +620 -0
  8. package/dist/build-progress-BXx-ZBe4.mjs +512 -0
  9. package/dist/{cache-manifest-Dg7OnMrp.mjs → cache-manifest-Pubfp8wV.mjs} +1 -1
  10. package/dist/cache-manifest.mjs +1 -1
  11. package/dist/cli.mjs +16 -16
  12. package/dist/collector-run.d.mts +5 -1
  13. package/dist/collector-run.mjs +7 -7
  14. package/dist/{command-output-gByVmIjy.mjs → command-output-D5z2_bcF.mjs} +1 -1
  15. package/dist/{config-Cp9lM3wX.mjs → config-BEq0Qw_a.mjs} +14 -131
  16. package/dist/{dependency-state-Bq5DcLGn.mjs → dependency-state-r9UesA9a.mjs} +2 -2
  17. package/dist/{deps-Cip5vUm1.mjs → deps-DP4eWqea.mjs} +3 -3
  18. package/dist/{detached-entry-KEu0_AXL.mjs → detached-entry-DJHoca49.mjs} +1 -1
  19. package/dist/{dev-client-HyqISM0h.mjs → dev-client-92U593KT.mjs} +137 -250
  20. package/dist/{device-D4U-xLty.mjs → device-DhTZe7L4.mjs} +7 -7
  21. package/dist/{build-lock-DPXPLgTc.mjs → device-lease-CbEaIk7b.mjs} +370 -12
  22. package/dist/{device-pool-CnTAz8X1.mjs → device-pool-Di8Q-9Ro.mjs} +4 -4
  23. package/dist/{device-remote-AQ5rQPqr.mjs → device-remote-Dy1jGj3U.mjs} +547 -468
  24. package/dist/{doctor-B_Ei3bZu.mjs → doctor-B-inAJmt.mjs} +58 -22
  25. package/dist/{error-diagnostics-Vfz83GCF.mjs → error-diagnostics-xxWKNO3-.mjs} +11 -12
  26. package/dist/{exec-jIAlgilk.mjs → exec-DK8KaVHK.mjs} +3 -2
  27. package/dist/{gc-Ckw8RQHi.mjs → gc-ChPFZMRC.mjs} +500 -1114
  28. package/dist/{guide-BiAbhs1J.mjs → guide-DwmaD_Wa.mjs} +882 -528
  29. package/dist/guide-status-CWvti2Lg.mjs +125 -0
  30. package/dist/idle-DpRLp5lj.mjs +157 -0
  31. package/dist/in-use-CjUluqIX.mjs +245 -0
  32. package/dist/{ios-BL2NFckr.mjs → ios-B2Fpqvlb.mjs} +25 -59
  33. package/dist/{ios-BzUJWk1K.mjs → ios-Dd-hYJBh.mjs} +130 -89
  34. package/dist/{ios-device-DPsSm0v_.mjs → ios-device-BiOsSVzd.mjs} +1 -1
  35. package/dist/{ios-device-D972i9wf.mjs → ios-device-CtRdEVjx.mjs} +3 -3
  36. package/dist/{launch-verify-Q_OtW1Rf.mjs → launch-verify-CQS_Kg-C.mjs} +5 -5
  37. package/dist/{logs-CI9NQSK7.mjs → logs-MARIy6m9.mjs} +9 -9
  38. package/dist/{metro-DrLHaMn2.mjs → metro-BWLOJ_Wr.mjs} +116 -10
  39. package/dist/{named-ports-CmLQSIRj.mjs → named-ports-DiTfSz6p.mjs} +4 -4
  40. package/dist/{ndjson-9uFNRwfe.mjs → ndjson-CDY9IkVC.mjs} +3 -56
  41. package/dist/{ndjson-DvHSsNU9.d.mts → ndjson-Du3j0AQ9.d.mts} +1 -0
  42. package/dist/{ownership-D15Op8vN.mjs → ownership-D2xbLVoz.mjs} +2 -3
  43. package/dist/ownership-DZjdx8Gq.mjs +1180 -0
  44. package/dist/{ports-BNKoWVWZ.mjs → ports-CLSSFOuz.mjs} +3 -3
  45. package/dist/{project-DS112DYB.mjs → project-BXZzOABt.mjs} +2 -2
  46. package/dist/{reload-ZNJUo1pl.mjs → reload-DoETJ3rl.mjs} +9 -10
  47. package/dist/{server-bare-DWkkcIxV.mjs → server-bare-DVbb7kXH.mjs} +1 -1
  48. package/dist/{server-expo-B_lctBR3.mjs → server-expo-BvOCeakp.mjs} +4 -4
  49. package/dist/server-expo-DBVTdult.mjs +2 -0
  50. package/dist/{settings-D030k0t4.mjs → settings-DOugPlPz.mjs} +16 -428
  51. package/dist/{settings-BKNh5rl4.mjs → settings-EWYNwe72.mjs} +16 -14
  52. package/dist/settings.schema.json +75 -0
  53. package/dist/{simslim-B_LsGLuJ.mjs → simslim-Ca3487H6.mjs} +182 -5
  54. package/dist/{slot-launch-DU9A-gYw.mjs → slot-launch-C5cg0fmk.mjs} +4 -4
  55. package/dist/{spawn-claims-Dn3C_N6w.mjs → spawn-claims-CUleC2cM.mjs} +1 -1
  56. package/dist/{start-DYkkKF0s.mjs → start-F9gv5dcC.mjs} +147 -98
  57. package/dist/start-VoFsPSes.mjs +2 -0
  58. package/dist/{state-NcJzMEBF.mjs → state-WGdIbi_d.mjs} +2 -2
  59. package/dist/{stats-DG9e66Rz.mjs → stats-BAgJaLA4.mjs} +1 -1
  60. package/dist/{stats-B63-JVAY.mjs → stats-DBYJ5bxL.mjs} +4 -4
  61. package/dist/{status-S6ehL4Ui.mjs → status-DLCD-o3s.mjs} +63 -231
  62. package/dist/status-Dr4fRkym.mjs +216 -0
  63. package/dist/{guide-status-BAMVU0ZA.mjs → stim-installations-CINXy_su.mjs} +3 -124
  64. package/dist/{stop-BO28HGqF.mjs → stop-Dj4pfmyv.mjs} +22 -18
  65. package/dist/{stop-T1R-cZRz.mjs → stop-ZvOeTmKb.mjs} +2 -2
  66. package/dist/supervisor-run.d.mts +11 -2
  67. package/dist/supervisor-run.mjs +185 -17
  68. package/dist/{workspace-process-lock-DWaiRD0e.mjs → workspace-process-lock-BXA96myD.mjs} +2 -2
  69. package/dist/workspaces-BTUyFfkv.mjs +1003 -0
  70. package/dist/{worktree-DKhSF0UX.mjs → worktree-4aZg2NnI.mjs} +1 -1
  71. package/dist/{worktree-LGB-UwgG.mjs → worktree-BMS7GeqB.mjs} +40 -122
  72. package/dist/{worktree-R_cUlE7s.mjs → worktree-BlizKDpZ.mjs} +143 -18
  73. package/dist/{xcode-BZqyxh3e.mjs → xcode-B2tBkf0m.mjs} +9 -158
  74. package/package.json +6 -5
  75. package/dist/build-progress-CVqkfPC6.mjs +0 -188
  76. package/dist/device-lease-Crs-HkJY.mjs +0 -366
  77. package/dist/logs-query-Cqma3WIu.mjs +0 -298
  78. package/dist/ownership-DCQPuWEc.mjs +0 -489
  79. package/dist/public-http-probe-CyMDrQ3c.mjs +0 -61
  80. package/dist/server-expo-CeILEu86.mjs +0 -2
  81. package/dist/sim-pool-KaIsp2Ft.mjs +0 -723
  82. package/dist/spawn-entry-nCE6m770.mjs +0 -14
  83. package/dist/workspace-state-DF2gCFhx.mjs +0 -169
@@ -1,727 +1,23 @@
1
- import { F as formatBytes, I as isOnMountedVolume, K as projectDeviceSlots, L as listMountedVolumes, M as readJsonFile, P as directorySize, R as measuredDirectorySize, T as withConfigLock, U as deviceSlotPlatforms, b as removeProject, f as getProject, g as loadConfig, h as isPathPrefix, i as clearDevice, j as isJsonObject, k as ownership_claim_exports, u as getConfigDir, z as volumeRootFor } from "./config-Cp9lM3wX.mjs";
2
- import { t as getExecutor } from "./exec-jIAlgilk.mjs";
3
- import { i as registeredCaches } from "./cache-manifest-Dg7OnMrp.mjs";
4
- import { S as workspaceStateFile, _ as workspaceDir, s as workspaceLastUsed, x as workspaceName$1 } from "./workspace-state-DF2gCFhx.mjs";
5
- import { S as unpushedCommits, c as hasPopulatedSubmodules, f as listWorktrees, o as dirtyPaths, u as hasUncommittedWork, x as sourceCheckoutOf } from "./worktree-R_cUlE7s.mjs";
6
- import { a as detectIsExpo, o as findProjectRoot } from "./project-DS112DYB.mjs";
7
- import { E as listOrphanedAvdDirectories, k as ownedAvdDirectory, w as listAvds } from "./android-_y4Ohi5z.mjs";
8
- import { h as shortUdid, o as formatLongDuration, u as plural } from "./command-output-gByVmIjy.mjs";
9
- import { n as listBuildLocks } from "./build-lock-DPXPLgTc.mjs";
10
- import { b as resolveEasCliBin, o as parkedMaxSetting, r as dropParked, s as readParked, t as POOL_SETTING_REMEDY } from "./sim-pool-KaIsp2Ft.mjs";
11
- import { _ as parseRuntimeVersion, f as listIosDeviceTypes, u as listAllIosSims } from "./ios-BL2NFckr.mjs";
12
- import { i as listLeaseFiles, r as leaseIsExpired, u as removeExpiredLease } from "./device-lease-Crs-HkJY.mjs";
13
- import { _ as listBuildSlots } from "./xcode-BZqyxh3e.mjs";
14
- import { C as verifyStoppedSession, D as teardownParkedAvd, E as teardownOwnedIosSim, O as teardownParkedIosSim, S as stopSessionArgs, T as teardownOwnedAvd, _ as inspectSessionForTeardown, d as withEasProjectLock, f as easMachineStateRoot, g as getSessionArgs, h as findOrphanedOwnedSessions, m as removeEasSessionClaim, p as readEasSessionLedger, u as withRemoteSessionLock, v as isDefinitiveMissingSessionError, x as parseSessionListPage, y as listOwnedSessionsArgs } from "./device-remote-AQ5rQPqr.mjs";
15
- import { _ as workspaceInUse, a as reclaimKeys, d as removeWorktreeTarget, g as withIdleWorkspace, h as emptyWorkspaceDir, m as reclaimProject, n as excludePodChurn, p as describeDereferenced, r as matchWorktreeEntry, v as canonicalPath } from "./worktree-LGB-UwgG.mjs";
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";
16
17
  import { InvalidArgumentError } from "commander";
17
- import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, rmSync, statSync } from "fs";
18
- import { basename, dirname, isAbsolute, join, relative, resolve } from "path";
19
- import { METRO_NAMED_CACHE_LAYOUT } from "@stim-cli/core";
20
- import { homedir, tmpdir } from "os";
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";
21
20
  import chalk from "chalk";
22
- //#region src/cache/caches.ts
23
- function xcodeDerivedDataRoot() {
24
- return join(homedir(), "Library", "Developer", "Xcode", "DerivedData");
25
- }
26
- function compilationCache() {
27
- const dir = join(xcodeDerivedDataRoot(), "CompilationCache.noindex");
28
- if (!existsSync(dir)) return null;
29
- return {
30
- name: "Xcode compilation cache",
31
- dir,
32
- prune: "atomic",
33
- note: "index-backed, so it can only be emptied whole"
34
- };
35
- }
36
- function gradleBuildCache() {
37
- const gradleHome = process.env.GRADLE_USER_HOME || join(homedir(), ".gradle");
38
- const dir = resolve(gradleHome, "caches", "build-cache-1");
39
- if (!existsSync(dir)) return null;
40
- return {
41
- name: "Gradle build cache",
42
- dir,
43
- prune: "report-only",
44
- note: "shared with every Gradle build; report only, never pruned or emptied by Stim"
45
- };
46
- }
47
- function metroFileMaps() {
48
- const root = tmpdir();
49
- if (!existsSync(root)) return null;
50
- let names;
51
- try {
52
- names = readdirSync(root).filter((n) => n.startsWith("metro-file-map-"));
53
- } catch {
54
- return null;
55
- }
56
- const uid = process.getuid?.();
57
- const files = [];
58
- let bytes = 0;
59
- for (const n of names) try {
60
- const st = statSync(join(root, n));
61
- if (uid !== void 0 && st.uid !== uid) continue;
62
- files.push(join(root, n));
63
- bytes += st.size;
64
- } catch {}
65
- if (!files.length) return null;
66
- return {
67
- name: "Metro file maps",
68
- dir: root,
69
- files,
70
- bytes,
71
- prune: "entries",
72
- note: `${files.length} file(s), one per project root Metro has served`
73
- };
74
- }
75
- function discoverCaches() {
76
- const gradle = gradleBuildCache();
77
- const gradleDir = gradle ? canonicalCacheDir(gradle.dir) : null;
78
- const registered = protectNestedMetroAncestors(suppressLegacyMetroAncestors(registeredCaches().map((c) => Object.assign({}, c, {
79
- name: c.name ?? c.dir,
80
- note: c.note ?? "registered",
81
- prune: c.prune,
82
- source: "registered"
83
- }))));
84
- const detected = [
85
- compilationCache(),
86
- gradle,
87
- metroFileMaps()
88
- ].filter((c) => Boolean(c)).map((c) => Object.assign({}, c, { source: "detected" }));
89
- return mergeCacheDescriptors([...registered, ...detected], gradleDir);
90
- }
91
- function suppressLegacyMetroAncestors(caches) {
92
- const current = caches.filter(isCurrentMetroStore);
93
- return caches.filter((cache) => {
94
- if (!isLegacyMetroStore(cache)) return true;
95
- return !current.some((candidate) => isNamedMetroChild(cache.dir, candidate.dir));
96
- });
97
- }
98
- function protectNestedMetroAncestors(caches) {
99
- const current = caches.filter(isCurrentMetroStore);
100
- return caches.map((cache) => {
101
- if (!isCurrentMetroStore(cache)) return cache;
102
- if (!current.some((candidate) => isNamedMetroChild(cache.dir, candidate.dir))) return cache;
103
- return Object.assign({}, cache, {
104
- prune: "report-only",
105
- note: "named Metro store is also an override parent; report only while its child is registered"
106
- });
107
- });
108
- }
109
- function isLegacyMetroStore(cache) {
110
- return isMetroStore(cache) && cache.layout === void 0;
111
- }
112
- function isCurrentMetroStore(cache) {
113
- return isMetroStore(cache) && cache.layout === METRO_NAMED_CACHE_LAYOUT;
114
- }
115
- function isMetroStore(cache) {
116
- return cache.source === "registered" && cache.name === "Metro transform cache" && cache.prune === "entries" && cache.entriesDepth === 2;
117
- }
118
- function isNamedMetroChild(parent, candidate) {
119
- const dir = canonicalCacheDir(parent);
120
- const child = canonicalCacheDir(candidate);
121
- return dirname(child) === dir || canonicalCacheDir(dirname(candidate)) === dir;
122
- }
123
- function mergeCacheDescriptors(caches, gradleDir) {
124
- const merged = [];
125
- for (const cache of caches) {
126
- const descriptor = protectGradleCache(cache, gradleDir);
127
- const dir = canonicalCacheDir(descriptor.dir);
128
- const exact = merged.findIndex((candidate) => canonicalCacheDir(candidate.dir) === dir);
129
- if (exact !== -1) {
130
- const current = merged[exact];
131
- if (pruneStrength(descriptor.prune) > pruneStrength(current.prune)) merged[exact] = Object.assign({}, current, {
132
- prune: descriptor.prune,
133
- note: descriptor.note
134
- });
135
- continue;
136
- }
137
- if (gradleDir && isGradleProtectedDescriptor(descriptor, gradleDir)) {
138
- if (merged.some((candidate) => {
139
- const candidateDir = canonicalCacheDir(candidate.dir);
140
- return isGradleProtectedDescriptor(candidate, gradleDir) && cachePathContains(candidateDir, dir);
141
- })) continue;
142
- for (let i = merged.length - 1; i >= 0; i--) {
143
- const candidate = merged[i];
144
- const candidateDir = canonicalCacheDir(candidate.dir);
145
- if (isGradleProtectedDescriptor(candidate, gradleDir) && cachePathContains(dir, candidateDir)) merged.splice(i, 1);
146
- }
147
- }
148
- merged.push(descriptor);
149
- }
150
- return merged;
151
- }
152
- function protectGradleCache(cache, gradleDir) {
153
- if (cache.files) return cache;
154
- if (!gradleDir || !cachePathsOverlap(canonicalCacheDir(cache.dir), gradleDir)) return cache;
155
- return Object.assign({}, cache, {
156
- prune: "report-only",
157
- note: "shared with every Gradle build; report only, never pruned or emptied by Stim"
158
- });
159
- }
160
- function isGradleProtectedDescriptor(cache, gradleDir) {
161
- return cache.prune === "report-only" && cachePathsOverlap(canonicalCacheDir(cache.dir), gradleDir);
162
- }
163
- function pruneStrength(prune) {
164
- if (prune === "report-only") return 3;
165
- if (prune === "atomic") return 2;
166
- return 1;
167
- }
168
- function cachePathsOverlap(left, right) {
169
- return cachePathContains(left, right) || cachePathContains(right, left);
170
- }
171
- function cachePathContains(parent, child) {
172
- const rel = relative(parent, child);
173
- return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
174
- }
175
- function canonicalCacheDir(dir) {
176
- try {
177
- return realpathSync(dir);
178
- } catch {
179
- return resolve(dir);
180
- }
181
- }
182
- function sizeCaches(caches) {
183
- return caches.map((c) => Object.assign({}, c, { bytes: c.bytes ?? directorySize(c.dir) }));
184
- }
185
- function pruneCache(cache, { olderThanDays, now = Date.now() } = {}) {
186
- const cutoff = now - olderThanDays * 24 * 60 * 60 * 1e3;
187
- if (cache.prune === "report-only") return {
188
- removed: 0,
189
- bytes: 0,
190
- skipped: "report-only shared cache; Stim never deletes it"
191
- };
192
- if (cache.prune === "atomic") return {
193
- removed: 0,
194
- bytes: 0,
195
- skipped: "index-backed; empty it whole or not at all"
196
- };
197
- const entries = cache.files ?? entriesAtDepth(cache.dir, cache.entriesDepth ?? 1);
198
- let removed = 0;
199
- let bytes = 0;
200
- let failed = 0;
201
- for (const entry of entries) {
202
- let used;
203
- let size;
204
- try {
205
- const st = statSync(entry);
206
- used = Math.max(st.atimeMs, st.mtimeMs);
207
- size = st.isDirectory() ? directorySize(entry) : st.size;
208
- } catch {
209
- continue;
210
- }
211
- if (used >= cutoff) continue;
212
- try {
213
- rmSync(entry, {
214
- recursive: true,
215
- force: true
216
- });
217
- removed++;
218
- bytes += size;
219
- } catch {
220
- failed++;
221
- }
222
- }
223
- return {
224
- removed,
225
- bytes,
226
- failed,
227
- skipped: null
228
- };
229
- }
230
- function entriesAtDepth(dir, depth) {
231
- let level = [dir];
232
- for (let i = 0; i < depth; i++) {
233
- const next = [];
234
- for (const parent of level) {
235
- let names;
236
- try {
237
- names = readdirSync(parent);
238
- } catch {
239
- continue;
240
- }
241
- for (const name of names) next.push(join(parent, name));
242
- }
243
- level = next;
244
- }
245
- return level;
246
- }
247
- //#endregion
248
- //#region src/commands/gc/caches.ts
249
- const DAY_MS$3 = 864e5;
250
- function cacheSweepIsScoped() {
251
- return Boolean(process.env.STIM_HOME);
252
- }
253
- function isInsideConfigDir(dir) {
254
- const root = canonicalPath(getConfigDir());
255
- const target = canonicalPath(dir);
256
- const rel = relative(root, target);
257
- return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
258
- }
259
- function machineGlobalReason(cache) {
260
- if (!cacheSweepIsScoped()) return null;
261
- if (cache.source === "registered") return null;
262
- if (isInsideConfigDir(cache.dir)) return null;
263
- return `STIM_HOME scopes this config, but ${cache.dir} is outside it and therefore machine-global`;
264
- }
265
- const EVERY_CACHE = "all";
266
- const WORKSPACE_OUTPUTS = "workspaces";
267
- function includesWorkspaceOutputs(name) {
268
- const wanted = name?.trim().toLowerCase();
269
- return !wanted || wanted === EVERY_CACHE || wanted === WORKSPACE_OUTPUTS;
270
- }
271
- function selectCaches(caches, name) {
272
- if (!name) return caches;
273
- const wanted = name.trim().toLowerCase();
274
- if (wanted === EVERY_CACHE) return caches;
275
- if (wanted === WORKSPACE_OUTPUTS) return [];
276
- return caches.filter((c) => c.name.toLowerCase().includes(wanted) || c.dir.toLowerCase().includes(wanted));
277
- }
278
- function planCacheEmptying(caches, all) {
279
- const annotated = caches.map((c) => Object.assign({}, c, { machineGlobal: machineGlobalReason(c) }));
280
- if (!all) return annotated;
281
- return annotated.map((c) => {
282
- if (c.prune === "report-only") return Object.assign({}, c, {
283
- willEmpty: false,
284
- emptySkipped: "report-only shared cache; Stim never deletes it"
285
- });
286
- if (c.machineGlobal) return Object.assign({}, c, {
287
- willEmpty: false,
288
- emptySkipped: c.machineGlobal
289
- });
290
- if (!ownsItsDirectory(c)) return Object.assign({}, c, {
291
- willEmpty: false,
292
- emptySkipped: `${c.dir} is not a directory this cache owns`
293
- });
294
- return Object.assign({}, c, {
295
- willEmpty: true,
296
- emptySkipped: null
297
- });
298
- });
299
- }
300
- function ownsItsDirectory(cache) {
301
- if (Array.isArray(cache.files)) return false;
302
- const dir = canonicalPath(cache.dir);
303
- if (dirname(dir) === dir) return false;
304
- return ![
305
- homedir(),
306
- tmpdir(),
307
- getConfigDir()
308
- ].map(canonicalPath).includes(dir);
309
- }
310
- function emptyCache(cache) {
311
- if (cache.prune === "report-only") return {
312
- removed: 0,
313
- bytes: 0,
314
- skipped: "report-only shared cache; Stim never deletes it"
315
- };
316
- if (cache.prune !== "atomic") return pruneCache(cache, {
317
- olderThanDays: 0,
318
- now: Date.now() + DAY_MS$3
319
- });
320
- let names;
321
- try {
322
- names = readdirSync(cache.dir);
323
- } catch (e) {
324
- return {
325
- removed: 0,
326
- bytes: 0,
327
- skipped: `could not read ${cache.dir}: ${e.message}`
328
- };
329
- }
330
- let removed = 0;
331
- let failed = 0;
332
- for (const name of names) try {
333
- rmSync(join(cache.dir, name), {
334
- recursive: true,
335
- force: true
336
- });
337
- removed++;
338
- } catch {
339
- failed++;
340
- }
341
- return {
342
- removed,
343
- bytes: failed ? 0 : cache.bytes ?? 0,
344
- failed,
345
- skipped: null
346
- };
347
- }
348
- function reportFailures(cache, failed) {
349
- if (!failed) return;
350
- console.log(chalk.red(`${cache.name}: ${failed} entr${failed === 1 ? "y" : "ies"} in ${cache.dir} could not be removed`));
351
- process.exitCode = 1;
352
- }
353
- function trimCaches(caches, olderThan) {
354
- let cacheBytes = 0;
355
- for (const c of caches) {
356
- if (c.machineGlobal) {
357
- console.log(chalk.yellow(`Left ${c.name} alone: ${c.machineGlobal}`));
358
- continue;
359
- }
360
- const r = pruneCache(c, { olderThanDays: olderThan });
361
- if (r.skipped) console.log(chalk.yellow(`Left ${c.name} alone: ${r.skipped}`));
362
- else if (r.removed) {
363
- cacheBytes += r.bytes;
364
- console.log(chalk.green(`Trimmed ${c.name}: ${r.removed} entr${r.removed === 1 ? "y" : "ies"} (${formatBytes(r.bytes)})`));
365
- } else if (!r.failed) console.log(chalk.dim(`${c.name}: nothing older than ${olderThan}d`));
366
- reportFailures(c, r.failed);
367
- }
368
- if (cacheBytes) console.log(chalk.dim(`Trimmed ${formatBytes(cacheBytes)} of shared cache. The next build that wanted those entries pays to rebuild them.`));
369
- }
370
- function emptyCaches(caches) {
371
- let cacheBytes = 0;
372
- for (const c of caches) {
373
- if (!c.willEmpty) {
374
- console.log(chalk.yellow(`Left ${c.name} alone: ${c.emptySkipped}`));
375
- continue;
376
- }
377
- const r = emptyCache(c);
378
- if (r.skipped) console.log(chalk.yellow(`Left ${c.name} alone: ${r.skipped}`));
379
- else if (r.removed) {
380
- cacheBytes += r.bytes;
381
- console.log(chalk.green(`Emptied ${c.name}: ${r.removed} entr${r.removed === 1 ? "y" : "ies"} (${formatBytes(r.bytes)})`));
382
- } else if (!r.failed) console.log(chalk.dim(`${c.name}: already empty`));
383
- reportFailures(c, r.failed);
384
- }
385
- if (cacheBytes) console.log(chalk.dim(`Emptied ${formatBytes(cacheBytes)} of shared cache. Every build that wanted any of it now pays to rebuild it.`));
386
- }
387
- //#endregion
388
- //#region src/commands/gc/devices.ts
389
- const DEVICE_LIST_TIMEOUT_MS = 3e4;
390
- const AVD_SIZE_TIMEOUT_MS = 5e3;
391
- const DAY_MS$2 = 864e5;
392
- function describeKept(ref) {
393
- return ref.mounted ? `referenced by ${ref.path}` : `referenced by ${ref.path} (volume not mounted; kept just in case)`;
394
- }
395
- function findOrphanedDevices({ sims = [], avds = [], config, isMounted, deadProjects = [] }) {
396
- const dead = new Set(deadProjects);
397
- const referenced = /* @__PURE__ */ new Map();
398
- for (const [path, proj] of Object.entries(config?.projects || {})) {
399
- if (dead.has(path)) continue;
400
- const mounted = isMounted ? isMounted(path) : true;
401
- for (const { platforms } of projectDeviceSlots(proj)) {
402
- const ios = platforms.ios;
403
- if (ios?.deviceUdid) referenced.set(ios.deviceUdid, {
404
- path,
405
- mounted
406
- });
407
- const android = platforms.android;
408
- if (android?.avdName) referenced.set(android.avdName, {
409
- path,
410
- mounted
411
- });
412
- }
413
- }
414
- for (const sim of readParked("ios", { config })) referenced.set(sim.udid, {
415
- path: "the simulator pool",
416
- mounted: true
417
- });
418
- for (const avd of readParked("android", { config })) referenced.set(avd.name, {
419
- path: "the emulator pool",
420
- mounted: true
421
- });
422
- const orphaned = [];
423
- const kept = [];
424
- for (const sim of sims) {
425
- if (!sim?.name?.startsWith("stim-")) continue;
426
- const ref = referenced.get(sim.udid);
427
- if (!ref) orphaned.push({
428
- kind: "ios",
429
- id: sim.udid,
430
- name: sim.name
431
- });
432
- else kept.push({
433
- kind: "ios",
434
- id: sim.udid,
435
- name: sim.name,
436
- reason: describeKept(ref)
437
- });
438
- }
439
- for (const avdName of avds) {
440
- if (!avdName?.startsWith("stim-")) continue;
441
- const ref = referenced.get(avdName);
442
- if (!ref) orphaned.push({
443
- kind: "android",
444
- id: avdName,
445
- name: avdName
446
- });
447
- else kept.push({
448
- kind: "android",
449
- id: avdName,
450
- name: avdName,
451
- reason: describeKept(ref)
452
- });
453
- }
454
- return {
455
- orphaned,
456
- kept
457
- };
458
- }
459
- function findStaleProjectDevices({ config, sims = [], avds = [], olderThanDays, now = Date.now(), lastTouched, deadProjects = [] }) {
460
- if (!Number.isFinite(olderThanDays) || typeof lastTouched !== "function") return [];
461
- const cutoff = now - olderThanDays * DAY_MS$2;
462
- const dead = new Set(deadProjects);
463
- const liveSims = new Map(sims.filter((s) => s?.name?.startsWith("stim-")).map((s) => [s.udid, s.name]));
464
- const liveAvds = new Set(avds.filter((a) => typeof a === "string" && a.startsWith("stim-")));
465
- const stale = [];
466
- for (const [path, proj] of Object.entries(config?.projects || {})) {
467
- if (dead.has(path) || !isAbsolute(path)) continue;
468
- const touched = lastTouched(path);
469
- if (!Number.isFinite(touched) || touched >= cutoff) continue;
470
- const idleDays = Math.floor((now - touched) / DAY_MS$2);
471
- for (const { slot, platforms } of projectDeviceSlots(proj)) {
472
- const ios = platforms.ios;
473
- if (ios?.owned && ios.deviceUdid && liveSims.has(ios.deviceUdid)) stale.push({
474
- kind: "ios",
475
- id: ios.deviceUdid,
476
- name: liveSims.get(ios.deviceUdid),
477
- project: path,
478
- ...slot === "default" ? {} : { slot },
479
- idleDays
480
- });
481
- const android = platforms.android;
482
- if (android?.owned && android.avdName && liveAvds.has(android.avdName)) stale.push({
483
- kind: "android",
484
- id: android.avdName,
485
- name: android.avdName,
486
- project: path,
487
- idleDays,
488
- ...slot === "default" ? {} : { slot }
489
- });
490
- }
491
- }
492
- return stale;
493
- }
494
- function findStaleDeviceRecords({ config, sims = [], avds = [], deadProjects = [], simsChecked = true, avdsChecked = true }) {
495
- const dead = new Set(deadProjects);
496
- const liveSims = new Set(sims.map((s) => s?.udid).filter(Boolean));
497
- const liveAvds = new Set(avds.filter((a) => typeof a === "string"));
498
- const stale = [];
499
- for (const [path, proj] of Object.entries(config?.projects || {})) {
500
- if (dead.has(path)) continue;
501
- for (const { slot, platforms } of projectDeviceSlots(proj)) {
502
- const ios = platforms.ios;
503
- if (simsChecked && ios?.deviceUdid && !liveSims.has(ios.deviceUdid)) stale.push({
504
- kind: "ios",
505
- id: ios.deviceUdid,
506
- project: path,
507
- ...slot === "default" ? {} : { slot },
508
- owned: Boolean(ios.owned)
509
- });
510
- const android = platforms.android;
511
- if (avdsChecked && android?.avdName && !liveAvds.has(android.avdName)) stale.push({
512
- kind: "android",
513
- id: android.avdName,
514
- project: path,
515
- ...slot === "default" ? {} : { slot },
516
- owned: Boolean(android.owned)
517
- });
518
- }
519
- }
520
- return stale;
521
- }
522
- function describeUnverifiableDevices(simNames = [], avdNames = [], { reason = "no Stim config found" } = {}) {
523
- const ours = [...simNames, ...avdNames].filter((n) => typeof n === "string" && n.startsWith("stim-"));
524
- if (ours.length === 0) return [`${reason}; device sweep skipped`];
525
- 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>`"];
526
- }
527
- function withAndroidAvdSizes(devices, { avdDirectory = ownedAvdDirectory, size = directorySize } = {}) {
528
- return devices.map((device) => {
529
- if (device.kind !== "android") return device;
530
- const dir = device.orphanedDirectory?.directory ?? avdDirectory(device.id);
531
- if (!dir) return device;
532
- try {
533
- const bytes = size(dir, { timeoutMs: AVD_SIZE_TIMEOUT_MS });
534
- return bytes > 0 ? {
535
- ...device,
536
- bytes
537
- } : device;
538
- } catch {
539
- return device;
540
- }
541
- });
542
- }
543
- function describeParkedSims(records, sims, deviceTypes, { simsChecked = true } = {}) {
544
- const listed = new Map(sims.map((sim) => [sim.udid, sim]));
545
- return records.map((record) => {
546
- const sim = listed.get(record.udid);
547
- return {
548
- udid: record.udid,
549
- name: sim?.name ?? record.name,
550
- model: deviceTypes.find((d) => d.identifier === record.deviceTypeIdentifier)?.name ?? null,
551
- runtime: sim ? parseRuntimeVersion(sim.runtime) : parseRuntimeVersion(record.runtimeIdentifier),
552
- parkedAt: record.parkedAt,
553
- bytes: typeof sim?.dataPathSize === "number" ? sim.dataPathSize : null,
554
- listed: simsChecked ? Boolean(sim) : null
555
- };
556
- });
557
- }
558
- function collectParkedSims(deps) {
559
- const records = readParked("ios");
560
- if (records.length === 0) return [];
561
- let sims;
562
- let deviceTypes = [];
563
- try {
564
- sims = (deps.listAllIosSims ?? listAllIosSims)({
565
- timeoutMs: DEVICE_LIST_TIMEOUT_MS,
566
- includeUnavailable: true
567
- });
568
- } catch {
569
- return describeParkedSims(records, [], [], { simsChecked: false });
570
- }
571
- try {
572
- deviceTypes = (deps.listIosDeviceTypes ?? listIosDeviceTypes)();
573
- } catch {}
574
- return describeParkedSims(records, sims, deviceTypes);
575
- }
576
- function collectParkedAvds(deps) {
577
- const records = readParked("android");
578
- if (!records.length) return [];
579
- let avds = null;
580
- try {
581
- avds = (deps.listAvds ?? listAvds)();
582
- } catch {}
583
- return records.map((record) => {
584
- let bytes = null;
585
- try {
586
- const directory = (deps.avdDirectory ?? ownedAvdDirectory)(record.name);
587
- if (directory) bytes = (deps.directorySize ?? directorySize)(directory, { timeoutMs: AVD_SIZE_TIMEOUT_MS });
588
- } catch {}
589
- return {
590
- name: record.name,
591
- systemImage: record.systemImage,
592
- parkedAt: record.parkedAt,
593
- bytes,
594
- listed: avds === null ? null : avds.includes(record.name)
595
- };
596
- });
597
- }
598
- function deleteParkedAvds(avds) {
599
- let failures = 0;
600
- for (const avd of avds) {
601
- if (avd.listed === null) {
602
- failures++;
603
- console.log(chalk.red(`Could not verify parked android emulator ${avd.name}; its pool record was kept.`));
604
- continue;
605
- }
606
- const result = teardownParkedAvd(avd.name);
607
- if (result.status === "failed") {
608
- failures++;
609
- console.log(chalk.red(`Failed to delete parked android emulator ${avd.name}: ${result.reason}`));
610
- } else if (result.status === "torn-down") console.log(chalk.green(`Deleted parked android emulator ${avd.name}`));
611
- else console.log(chalk.dim(`Skipped ${avd.name}; it is no longer parked.`));
612
- }
613
- return failures;
614
- }
615
- function deleteParkedSims(parkedSims, deps = {}) {
616
- let failures = 0;
617
- let emptied = 0;
618
- for (const sim of parkedSims) {
619
- if (sim.listed === null) {
620
- failures++;
621
- console.log(chalk.red(`Could not verify parked ios sim ${sim.name} (${sim.udid}); its pool record was kept.`));
622
- continue;
623
- }
624
- try {
625
- const teardown = sim.listed ? teardownParkedIosSim(sim.udid, {
626
- label: sim.name,
627
- deleteSim: deps.deleteParkedIosSim
628
- }) : null;
629
- if (!(sim.listed ? teardown?.status === "torn-down" : dropParked("ios", sim.udid))) {
630
- if (teardown?.status === "failed") {
631
- failures++;
632
- console.log(chalk.red(`Failed to delete parked ios sim ${sim.name}: ${teardown.reason}`));
633
- } else console.log(chalk.dim(`Skipped ${sim.name} (${sim.udid}); it is no longer parked.`));
634
- continue;
635
- }
636
- emptied++;
637
- 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.`));
638
- } catch (err) {
639
- failures++;
640
- console.log(chalk.red(`Failed to delete parked ios sim ${sim.name}: ${err?.message || err}`));
641
- }
642
- }
643
- if (emptied) console.log(chalk.dim(` emptied ${plural(emptied, "parked simulator")} from the pool`));
644
- return failures;
645
- }
646
- function deviceSweepIsScoped() {
647
- return Boolean(process.env.STIM_HOME);
648
- }
649
- function collectDeviceLeases(now) {
650
- const expired = [];
651
- const kept = [];
652
- for (const entry of listLeaseFiles()) {
653
- const lease = entry.lease;
654
- if (!lease) {
655
- kept.push({
656
- name: entry.name,
657
- path: entry.path,
658
- reason: "it does not parse as a lease, so no run may take that device"
659
- });
660
- continue;
661
- }
662
- if (leaseIsExpired(lease, now)) {
663
- expired.push(entry);
664
- continue;
665
- }
666
- if (!existsSync(lease.holder)) kept.push({
667
- name: entry.name,
668
- path: entry.path,
669
- reason: `${lease.holder} is not on this machine, but the lease runs until ${lease.expiresAt}`
670
- });
671
- }
672
- return {
673
- expired,
674
- kept
675
- };
676
- }
677
- function deleteProjectDevices(orphanedDevices, staleDevices, staleDeviceRecords) {
678
- let deleteFailures = 0;
679
- function reap(d, onlyIfMissing = false) {
680
- const r = d.kind === "ios" ? teardownOwnedIosSim(d.id, {
681
- del: true,
682
- label: d.name
683
- }) : teardownOwnedAvd(d.name, {
684
- del: true,
685
- onlyIfMissing,
686
- ..."project" in d ? { owner: {
687
- projectPath: d.project,
688
- slot: d.slot
689
- } } : {},
690
- ..."project" in d ? { onRemoved: () => {
691
- if (clearDevice(d.project, d.kind, d.slot, d.id)) console.log(chalk.dim(` cleared the ${d.kind} record for ${d.project}`));
692
- } } : {},
693
- ..."orphanedDirectory" in d ? { orphanedDirectory: d.orphanedDirectory } : {}
694
- });
695
- const what = d.kind === "ios" ? `ios sim ${d.name} (${d.id})` : `android avd ${d.name}`;
696
- if (r.status === "torn-down") console.log(chalk.green(`Deleted ${what}`));
697
- else if (r.status === "missing") console.log(chalk.dim(`${what} is already gone; nothing to delete.`));
698
- else if (r.status === "skipped") console.log(chalk.yellow(`Skipped ${what}: ${r.reason} -- left for a later gc`));
699
- else {
700
- deleteFailures++;
701
- console.log(chalk.red(`Failed to delete ${d.kind} device ${d.name}: ${r.reason}`));
702
- }
703
- return r.status;
704
- }
705
- for (const d of orphanedDevices) reap(d);
706
- for (const d of staleDevices) {
707
- const status = reap(d);
708
- if (d.kind === "ios" && (status === "torn-down" || status === "missing")) {
709
- clearDevice(d.project, d.kind, d.slot, d.id);
710
- console.log(chalk.dim(` cleared the ${d.kind} record for ${d.project}`));
711
- }
712
- }
713
- for (const r of staleDeviceRecords) if (r.kind === "android" && r.owned) reap({
714
- ...r,
715
- name: r.id,
716
- idleDays: 0
717
- }, true);
718
- else if (withConfigLock(() => {
719
- if (r.kind === "android" && deviceSlotPlatforms(getProject(r.project), r.slot)?.android?.owned) return false;
720
- return clearDevice(r.project, r.kind, r.slot, r.id);
721
- })) console.log(chalk.green(`Cleared the ${r.kind} record for ${r.project} (${r.id} is not on this machine)`));
722
- return deleteFailures;
723
- }
724
- //#endregion
725
21
  //#region src/commands/gc/eas-sessions.ts
726
22
  const EAS_OPERATION_TIMEOUT_MS = 3e4;
727
23
  const EAS_COLLECTION_TIMEOUT_MS = 6e4;
@@ -741,18 +37,18 @@ function readRecordedRemoteSessionIds(roots) {
741
37
  let safe = true;
742
38
  for (const root of new Set(roots)) {
743
39
  try {
744
- if (!statSync(root).isDirectory()) throw new Error("path is not a directory");
745
- readdirSync(root);
40
+ if (!statSync$1(root).isDirectory()) throw new Error("path is not a directory");
41
+ readdirSync$1(root);
746
42
  } catch (error) {
747
43
  safe = false;
748
44
  notices.push(`${root} is not available as a readable workspace directory: ${describeError(error)}`);
749
45
  continue;
750
46
  }
751
47
  const path = workspaceStateFile(root);
752
- if (!existsSync(path)) continue;
48
+ if (!existsSync$1(path)) continue;
753
49
  let state;
754
50
  try {
755
- state = JSON.parse(readFileSync(path, "utf-8"));
51
+ state = JSON.parse(readFileSync$1(path, "utf-8"));
756
52
  } catch (error) {
757
53
  safe = false;
758
54
  notices.push(`${path} could not be read as valid JSON: ${describeError(error)}`);
@@ -788,8 +84,8 @@ function claimStateAbsence(claim) {
788
84
  const stateDir = dirname(claim.stateFile);
789
85
  let entries;
790
86
  try {
791
- if (!statSync(stateDir).isDirectory()) throw new Error("path is not a directory");
792
- entries = readdirSync(stateDir);
87
+ if (!statSync$1(stateDir).isDirectory()) throw new Error("path is not a directory");
88
+ entries = readdirSync$1(stateDir);
793
89
  } catch (error) {
794
90
  return {
795
91
  absent: false,
@@ -802,7 +98,7 @@ function claimStateAbsence(claim) {
802
98
  };
803
99
  let state;
804
100
  try {
805
- state = JSON.parse(readFileSync(claim.stateFile, "utf-8"));
101
+ state = JSON.parse(readFileSync$1(claim.stateFile, "utf-8"));
806
102
  } catch (error) {
807
103
  return {
808
104
  absent: false,
@@ -919,8 +215,8 @@ function easLockRoots(projectRoot, config) {
919
215
  }
920
216
  function unavailableLockRootNotice(roots) {
921
217
  for (const root of roots) try {
922
- if (!statSync(root).isDirectory()) throw new Error("path is not a directory");
923
- readdirSync(root);
218
+ if (!statSync$1(root).isDirectory()) throw new Error("path is not a directory");
219
+ readdirSync$1(root);
924
220
  } catch (error) {
925
221
  return `EAS session sweep skipped: ${root} is not available for remote-session lock inspection: ${describeError(error)}`;
926
222
  }
@@ -929,7 +225,7 @@ function unavailableLockRootNotice(roots) {
929
225
  function malformedRemoteSessionLockNotice(roots) {
930
226
  for (const root of roots) {
931
227
  const lockPath = join(workspaceDir(root), "remote-session.lock");
932
- if (!existsSync(lockPath)) continue;
228
+ if (!existsSync$1(lockPath)) continue;
933
229
  if ((0, ownership_claim_exports.readClaimSet)(lockPath).unresolved.length === 0) continue;
934
230
  return `EAS session sweep skipped: the remote-session lock at ${lockPath} has a malformed owner record.`;
935
231
  }
@@ -1103,261 +399,425 @@ async function deleteEasSessions(easSessionSweep, deps) {
1103
399
  return deleteFailures;
1104
400
  }
1105
401
  //#endregion
1106
- //#region src/commands/gc/workspaces.ts
1107
- function workspacesRoot() {
1108
- return join(getConfigDir(), "workspaces");
1109
- }
1110
- function isInsideWorkspaces(dir) {
1111
- const rel = relative(canonicalPath(workspacesRoot()), canonicalPath(dir));
1112
- return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
1113
- }
1114
- function readRecordedRoot(dir, name) {
1115
- let parsed;
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();
1116
421
  try {
1117
- parsed = readJsonFile(join(dir, "workspace.json"));
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
+ });
1118
460
  } catch (error) {
1119
- return {
1120
- projectRoot: null,
1121
- problem: error?.code === "ENOENT" ? "it has no workspace.json" : "its workspace.json does not parse"
1122
- };
461
+ return { error: `git fetch origin ${branch} failed: ${failure(error)}` };
1123
462
  }
1124
- const projectRoot = isJsonObject(parsed) ? parsed.projectRoot : void 0;
1125
- if (typeof projectRoot !== "string" || !isAbsolute(projectRoot)) return {
1126
- projectRoot: null,
1127
- problem: "its workspace.json records no absolute project root"
1128
- };
1129
- if (workspaceName$1(projectRoot) !== name) return {
1130
- projectRoot: null,
1131
- problem: `its workspace.json records ${projectRoot}, whose workspace directory has another name`
463
+ return {
464
+ ref,
465
+ name: `origin/${branch}`
1132
466
  };
467
+ }
468
+ function notMerged(detail) {
1133
469
  return {
1134
- projectRoot,
1135
- problem: null
470
+ merged: false,
471
+ unknown: false,
472
+ detail
1136
473
  };
1137
474
  }
1138
- function listWorkspaceDirs() {
1139
- const root = workspacesRoot();
1140
- let names;
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}`);
1141
552
  try {
1142
- names = readdirSync(root).toSorted();
1143
- } catch {
1144
- return [];
1145
- }
1146
- const entries = [];
1147
- for (const name of names) {
1148
- const dir = join(root, name);
1149
- try {
1150
- if (!statSync(dir).isDirectory()) continue;
1151
- } catch {
1152
- continue;
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
+ };
1153
577
  }
1154
- entries.push({
1155
- dir,
1156
- ...readRecordedRoot(dir, name)
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)
1157
603
  });
1158
- }
1159
- return entries;
1160
- }
1161
- function classifyWorkspaceDirs(entries, { registryKeys, exists, isMounted, inUse }) {
1162
- const orphaned = [];
1163
- const skipped = [];
1164
- for (const { dir, projectRoot, problem } of entries) {
1165
- if (projectRoot === null) {
1166
- skipped.push({
1167
- dir,
1168
- reason: `workspace directory not resolved: ${problem ?? "unknown project root"}`
1169
- });
1170
- continue;
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();
1171
618
  }
1172
- const present = exists(projectRoot);
1173
- if (present === null) {
1174
- skipped.push({
1175
- dir,
1176
- reason: `cannot tell whether its project root ${projectRoot} still exists`
1177
- });
1178
- continue;
1179
- }
1180
- if (present) continue;
1181
- if (registryKeys.some((key) => isPathPrefix(projectRoot, key))) continue;
1182
- if (!isMounted(projectRoot)) {
1183
- skipped.push({
1184
- dir,
1185
- reason: `the volume of its project root ${projectRoot} is not mounted`
1186
- });
1187
- continue;
1188
- }
1189
- const reasons = inUse(projectRoot);
1190
- if (reasons.length) {
1191
- skipped.push({
1192
- dir,
1193
- reason: `its project root ${projectRoot} is gone, but ${reasons.join("; ")}`
1194
- });
1195
- continue;
1196
- }
1197
- orphaned.push({
1198
- dir,
1199
- projectRoot
1200
- });
1201
- }
1202
- return {
1203
- orphaned,
1204
- skipped
1205
- };
1206
- }
1207
- function rootPresence(path) {
1208
- try {
1209
- lstatSync(path);
1210
- return true;
619
+ return notMerged(`not merged into ${name}`);
1211
620
  } catch (error) {
1212
- const code = error?.code;
1213
- return code === "ENOENT" || code === "ENOTDIR" ? false : null;
621
+ return {
622
+ merged: false,
623
+ unknown: true,
624
+ detail: `merge state unknown: ${failure(error)}`
625
+ };
1214
626
  }
1215
627
  }
1216
- function collectOrphanedWorkspaces(registryKeys, mountedVolumes) {
1217
- const classified = classifyWorkspaceDirs(listWorkspaceDirs(), {
1218
- registryKeys,
1219
- exists: rootPresence,
1220
- isMounted: (path) => isOnMountedVolume(path, mountedVolumes),
1221
- inUse: (root) => workspaceInUse(root)
1222
- });
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) {
1223
639
  return {
1224
- orphaned: classified.orphaned.map((entry) => Object.assign({}, entry, { bytes: sizeOf(entry.dir) })),
1225
- skipped: classified.skipped
640
+ code,
641
+ text
1226
642
  };
1227
643
  }
1228
- const REBUILD_COST = "The next build of an unchanged app installs from the shared build cache; after a native change the compilation cache speeds the rebuild, but on React Native 0.86 Swift does not use it (explicit modules are off), so that build recompiles Swift.";
1229
- const WORKSPACE_OUTPUT_DIRS = [
1230
- "derived-data",
1231
- "gradle-build",
1232
- "android-cas",
1233
- "cache-provider"
1234
- ];
1235
- const DAY_MS$1 = 864e5;
1236
- function planWorkspaceOutputs(entries, { olderThan, now }) {
1237
- return entries.map(({ dir, projectRoot, problem, bytes, lastUsed, inUse }) => {
1238
- const idleDays = Number.isFinite(lastUsed) ? Math.max(0, Math.floor((now - lastUsed) / DAY_MS$1)) : null;
1239
- const [keptCode, keptReason] = projectRoot === null ? ["unresolved", `workspace directory not resolved: ${problem ?? "unknown project root"}`] : inUse.length ? ["in-use", `in use: ${inUse.join("; ")}`] : olderThan === null ? [null, null] : idleDays === null ? ["last-use-unknown", "its last use is unknown"] : idleDays < olderThan ? ["recently-used", `used ${idleDays}d ago, within --older-than ${olderThan}`] : [null, null];
1240
- return {
1241
- dir,
1242
- projectRoot,
1243
- bytes,
1244
- idleDays,
1245
- willClear: keptCode === null,
1246
- keptCode,
1247
- keptReason
1248
- };
1249
- });
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;
1250
663
  }
1251
- const SIZE_TIMEOUT_MS = 6e4;
1252
- function sizeOf(dir) {
1253
- return measuredDirectorySize(dir, { timeoutMs: SIZE_TIMEOUT_MS });
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`;
1254
667
  }
1255
- function outputPaths(dir) {
1256
- return WORKSPACE_OUTPUT_DIRS.map((name) => join(dir, name)).filter((path) => existsSync(path));
668
+ function lastUsedOf(keys) {
669
+ const times = keys.map(workspaceLastUsed).filter(Number.isFinite);
670
+ return times.length ? Math.max(...times) : NaN;
1257
671
  }
1258
- function outputBytes(paths) {
1259
- let total = 0;
1260
- for (const path of paths) {
1261
- const size = sizeOf(path);
1262
- if (size === null) return null;
1263
- total += size;
1264
- }
1265
- return total;
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;
1266
675
  }
1267
- function collectWorkspaceOutputs({ olderThan, now, exclude = [] }) {
1268
- const mountedVolumes = listMountedVolumes();
1269
- const entries = listWorkspaceDirs().filter((entry) => !exclude.includes(entry.dir)).filter((entry) => entry.projectRoot === null || isOnMountedVolume(entry.projectRoot, mountedVolumes)).map((entry) => Object.assign({}, entry, { paths: outputPaths(entry.dir) })).filter((entry) => entry.paths.length > 0).map(({ paths, ...entry }) => Object.assign({}, entry, { bytes: outputBytes(paths) })).map((entry) => Object.assign({}, entry, {
1270
- lastUsed: entry.projectRoot === null ? NaN : workspaceLastUsed(entry.projectRoot),
1271
- inUse: entry.projectRoot === null ? [] : workspaceInUse(entry.projectRoot)
1272
- }));
1273
- return {
1274
- root: workspacesRoot(),
1275
- workspaces: planWorkspaceOutputs(entries, {
1276
- olderThan,
1277
- now
1278
- })
1279
- };
676
+ function inUseOf(keys, checks) {
677
+ return [...new Set(keys.flatMap((key) => workspaceInUse(key, checks)))];
1280
678
  }
1281
- async function clearWorkspaceOutputs(report, { olderThan, now = Date.now() }) {
1282
- let failures = 0;
1283
- let cleared = 0;
1284
- for (const entry of report.workspaces) {
1285
- const root = entry.projectRoot;
1286
- if (!entry.willClear || root === null) {
1287
- if (entry.keptReason) console.log(chalk.dim(`Kept the build outputs of ${root ?? entry.dir}: ${entry.keptReason}`));
1288
- continue;
1289
- }
1290
- let run;
1291
- try {
1292
- run = await withIdleWorkspace(root, () => {
1293
- const [again] = planWorkspaceOutputs([{
1294
- ...entry,
1295
- problem: null,
1296
- lastUsed: workspaceLastUsed(root),
1297
- inUse: []
1298
- }], {
1299
- olderThan,
1300
- now
1301
- });
1302
- if (again?.keptReason) return again.keptReason;
1303
- for (const name of WORKSPACE_OUTPUT_DIRS) rmSync(join(entry.dir, name), {
1304
- recursive: true,
1305
- force: true
1306
- });
1307
- return null;
1308
- }, { purpose: "gc" });
1309
- } catch (error) {
1310
- failures++;
1311
- console.log(chalk.red(`Could not clear the build outputs of ${root}: ${error?.message || String(error)}`));
1312
- continue;
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
+ });
1313
710
  }
1314
- const kept = run.ran ? run.value : `in use: ${run.reasons.join("; ")}`;
1315
- if (kept) {
1316
- console.log(chalk.yellow(`Kept the build outputs of ${root}: ${kept}`));
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
+ });
1317
732
  continue;
1318
733
  }
1319
- cleared += entry.bytes ?? 0;
1320
- console.log(chalk.green(`Cleared the build outputs of ${root} (${sizeText(entry.bytes)})`));
734
+ groups.set(entry.path, [...groups.get(entry.path) ?? [], root]);
1321
735
  }
1322
- if (cleared) console.log(chalk.dim(`Cleared ${formatBytes(cleared)} of workspace build outputs. ${REBUILD_COST}`));
1323
- return failures;
1324
- }
1325
- function stillOrphaned({ dir, projectRoot }) {
1326
- if (readRecordedRoot(dir, basename(dir)).projectRoot !== projectRoot || rootPresence(projectRoot) !== false || !isOnMountedVolume(projectRoot)) return false;
1327
- return !Object.keys(loadConfig()?.projects ?? {}).some((key) => isPathPrefix(projectRoot, key));
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
+ };
1328
782
  }
1329
- async function deleteOrphanedWorkspaces(orphaned) {
783
+ async function removeWorktrees(sweep, { now = Date.now() } = {}) {
1330
784
  let failures = 0;
1331
- for (const entry of orphaned) {
1332
- if (!existsSync(entry.dir)) continue;
1333
- let run;
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;
1334
802
  try {
1335
- run = await withIdleWorkspace(entry.projectRoot, () => {
1336
- if (!stillOrphaned(entry)) return false;
1337
- emptyWorkspaceDir(entry.dir);
1338
- return true;
1339
- }, { purpose: "gc" });
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
+ });
1340
809
  } catch (error) {
1341
- failures++;
1342
- console.log(chalk.red(`Could not remove ${entry.dir}: ${error?.message || error}`));
1343
- continue;
1344
- }
1345
- if (!run.ran) {
1346
- console.log(chalk.yellow(`Kept ${entry.dir}: ${run.reasons.join("; ")}`));
1347
- continue;
810
+ console.error(chalk.red(`Could not remove ${candidate.path}: ${error?.message || String(error)}`));
1348
811
  }
1349
- if (!run.value) {
1350
- console.log(chalk.yellow(`Kept ${entry.dir}: it can no longer be confirmed orphaned.`));
1351
- continue;
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.`));
1352
817
  }
1353
- const size = entry.bytes === void 0 ? "" : ` (${sizeText(entry.bytes)})`;
1354
- console.log(chalk.green(`Removed the orphaned workspace directory ${entry.dir}${size}`));
1355
818
  }
1356
819
  return failures;
1357
820
  }
1358
- function sizeText(bytes) {
1359
- return bytes === null ? "size unknown" : formatBytes(bytes);
1360
- }
1361
821
  //#endregion
1362
822
  //#region src/commands/gc/report.ts
1363
823
  function shortKey(key) {
@@ -1408,7 +868,7 @@ function unresolvedLockLines(locks, slots) {
1408
868
  " run that yourself once you know nothing is building with it"
1409
869
  ];
1410
870
  }
1411
- function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invalidProjects = [], orphanedWorkspaces = [], parkedSims = [], parkedAvds = [], orphanedDevices = [], staleDevices = [], staleDeviceRecords = [], buildLocks = {
871
+ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invalidProjects = [], orphanedWorkspaces = [], parkedSims = [], parkedAvds = [], orphanedDevices = [], unverifiedDevices = [], staleDevices = [], staleDeviceRecords = [], buildLocks = {
1412
872
  stale: [],
1413
873
  live: [],
1414
874
  unresolved: []
@@ -1419,7 +879,7 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
1419
879
  }, deviceLeases = {
1420
880
  expired: [],
1421
881
  kept: []
1422
- }, deviceSweepNotices = [], easSessionSweep = {
882
+ }, idleDevices = [], deviceSweepNotices = [], easSessionSweep = {
1423
883
  projectScope: null,
1424
884
  orphaned: [],
1425
885
  notices: [],
@@ -1465,6 +925,14 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
1465
925
  lines.push(`Orphaned devices (${orphanedDevices.length}):`);
1466
926
  for (const d of orphanedDevices) lines.push(` ${d.kind} ${d.name} (${d.id})${deviceSizeSuffix(d)}`);
1467
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
+ }
1468
936
  if (staleDevices.length) {
1469
937
  lines.push(`Stale owned devices (${staleDevices.length}) - workspace unused for ${olderThan ?? "?"}d or more:`);
1470
938
  for (const d of staleDevices) {
@@ -1472,6 +940,7 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
1472
940
  lines.push(` ${d.project} (idle ${d.idleDays}d)`);
1473
941
  }
1474
942
  }
943
+ lines.push(...idleDeviceLines(idleDevices));
1475
944
  if (staleDeviceRecords.length) {
1476
945
  lines.push(`Stale device records (${staleDeviceRecords.length}) - the device is gone, the project is not:`);
1477
946
  for (const r of staleDeviceRecords) {
@@ -1610,16 +1079,16 @@ function cacheLines(caches, workspaceOutputs) {
1610
1079
  return lines;
1611
1080
  }
1612
1081
  function worktreeSweepLines(sweep) {
1613
- if (!sweep) return [];
1082
+ if (!sweep || !sweep.idle && !sweep.worktrees.length) return [];
1614
1083
  const removable = sweep.worktrees.filter((w) => !w.skipped);
1615
- const idle = sweep.defaulted ? `idle ${sweep.olderThan}d or more (the default without --older-than)` : `idle ${sweep.olderThan}d or more`;
1616
- const lines = [`Linked worktrees (${removable.length} removable, ${sweep.worktrees.length - removable.length} kept) - clean, pushed, ${idle}:`];
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}:`];
1617
1086
  for (const w of sweep.worktrees) {
1618
1087
  const age = w.idleDays === null ? "" : ` (idle ${w.idleDays}d)`;
1619
1088
  lines.push(` ${w.path}${age}`);
1620
- lines.push(w.skipped ? ` kept: ${w.skipped}` : " would be REMOVED by `stim worktree remove`");
1089
+ lines.push(w.skipped ? ` kept: ${w.skipped}` : ` would be REMOVED by \`stim worktree remove\`: ${worktreeRemovalReason(w)}`);
1621
1090
  }
1622
- if (removable.length) lines.push(" --delete runs it without --force; use and idleness are re-checked under its removal locks.");
1091
+ if (removable.length) lines.push(" --delete runs it without --force; use, idleness and HEAD are re-checked under its removal locks.");
1623
1092
  return lines;
1624
1093
  }
1625
1094
  function jsonLock({ path, platform, key, pid, projectRoot }) {
@@ -1631,7 +1100,7 @@ function jsonLock({ path, platform, key, pid, projectRoot }) {
1631
1100
  projectRoot
1632
1101
  };
1633
1102
  }
1634
- function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [], invalidProjects = [], orphanedWorkspaces = [], worktreeSweep = null, parkedSims = [], parkedAvds = [], orphanedDevices = [], staleDevices = [], staleDeviceRecords = [], buildLocks = {
1103
+ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [], invalidProjects = [], orphanedWorkspaces = [], worktreeSweep = null, parkedSims = [], parkedAvds = [], orphanedDevices = [], unverifiedDevices = [], staleDevices = [], staleDeviceRecords = [], buildLocks = {
1635
1104
  stale: [],
1636
1105
  live: [],
1637
1106
  unresolved: []
@@ -1642,7 +1111,7 @@ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [],
1642
1111
  }, deviceLeases = {
1643
1112
  expired: [],
1644
1113
  kept: []
1645
- }, deviceSweepNotices = [], easSessionSweep = {
1114
+ }, idleDevices = [], deviceSweepNotices = [], easSessionSweep = {
1646
1115
  projectScope: null,
1647
1116
  orphaned: [],
1648
1117
  notices: [],
@@ -1664,9 +1133,10 @@ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [],
1664
1133
  linkedWorktrees: (worktreeSweep?.worktrees ?? []).map((w) => ({
1665
1134
  path: w.path,
1666
1135
  idleDays: w.idleDays,
1136
+ mergedInto: w.merge?.merged ? w.merge.into : null,
1667
1137
  willRemove: w.skipCode === null,
1668
1138
  reason: w.skipCode,
1669
- detail: w.skipped
1139
+ detail: w.skipped ?? worktreeRemovalReason(w)
1670
1140
  })),
1671
1141
  parkedSimulators: parkedSims.map(({ udid, name, model, runtime, parkedAt, bytes, listed }) => ({
1672
1142
  udid,
@@ -1691,6 +1161,12 @@ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [],
1691
1161
  bytes: d.bytes ?? null,
1692
1162
  directory: d.orphanedDirectory?.directory ?? null
1693
1163
  })),
1164
+ unverifiedDevices: unverifiedDevices.map((d) => ({
1165
+ kind: d.kind,
1166
+ id: d.id,
1167
+ name: d.name,
1168
+ command: unverifiedDeviceCommand(d)
1169
+ })),
1694
1170
  staleDevices: staleDevices.map((d) => ({
1695
1171
  kind: d.kind,
1696
1172
  id: d.id,
@@ -1706,6 +1182,16 @@ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [],
1706
1182
  project: r.project,
1707
1183
  slot: r.slot ?? null
1708
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
+ })),
1709
1195
  orphanedEasSessions: easSessionSweep.orphaned.map((s) => ({
1710
1196
  id: s.id,
1711
1197
  name: s.name,
@@ -1768,136 +1254,6 @@ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [],
1768
1254
  };
1769
1255
  }
1770
1256
  //#endregion
1771
- //#region src/commands/gc/worktrees.ts
1772
- const DEFAULT_WORKTREE_IDLE_DAYS = 7;
1773
- const DAY_MS = 864e5;
1774
- function skip(code, text) {
1775
- return {
1776
- code,
1777
- text
1778
- };
1779
- }
1780
- function worktreeSkipReason(facts, olderThan) {
1781
- if (facts.bare) return skip("bare-repository", "bare repository");
1782
- if (typeof facts.source === "object") return skip("source-checkout-unknown", `source checkout unknown: ${facts.source.refusal}`);
1783
- if (facts.source === "source") return skip("source-checkout", "source checkout");
1784
- if (facts.locked) return skip("locked", "locked with git worktree lock");
1785
- if (facts.inUse.length) return skip("in-use", `in use: ${facts.inUse.join("; ")}`);
1786
- if (facts.porcelain === null) return skip("status-unreadable", "git status could not be read");
1787
- if (excludePodChurn(facts.porcelain).lines.length) return skip("dirty", "dirty: uncommitted changes or untracked files");
1788
- if (facts.unpushed === null) return skip("unpushed-unchecked", "unpushed commits could not be checked");
1789
- if (facts.unpushed.length) return skip("unpushed", `unpushed: ${plural(facts.unpushed.length, "commit")} on no remote or other branch`);
1790
- if (facts.submodules) return skip("submodules", "initialized submodules");
1791
- if (facts.idleDays === null) return skip("last-use-unknown", "recently used: its last use is unknown");
1792
- if (facts.idleDays < olderThan) return skip("recently-used", `recently used ${facts.idleDays}d ago`);
1793
- return null;
1794
- }
1795
- function lastUsedOf(keys) {
1796
- const times = keys.map(workspaceLastUsed).filter(Number.isFinite);
1797
- return times.length ? Math.max(...times) : NaN;
1798
- }
1799
- function idleDaysOf(keys, now) {
1800
- const last = lastUsedOf(keys);
1801
- return Number.isFinite(last) ? Math.max(0, Math.floor((now - last) / DAY_MS)) : null;
1802
- }
1803
- function inUseOf(keys, checks) {
1804
- return [...new Set(keys.flatMap((key) => workspaceInUse(key, checks)))];
1805
- }
1806
- function porcelainOf(path, gitAnswered) {
1807
- if (gitAnswered === null) return null;
1808
- if (!gitAnswered) return [];
1809
- const lines = dirtyPaths(path, { limit: Infinity });
1810
- return lines.length ? lines : null;
1811
- }
1812
- function candidateRoots() {
1813
- const registered = Object.keys(loadConfig()?.projects ?? {}).filter(isAbsolute);
1814
- const recorded = listWorkspaceDirs().flatMap((entry) => entry.projectRoot ? [entry.projectRoot] : []);
1815
- return [.../* @__PURE__ */ new Set([...registered, ...recorded])].filter((root) => existsSync(root)).toSorted();
1816
- }
1817
- function collectWorktreeSweep({ olderThan, now }) {
1818
- const days = olderThan ?? DEFAULT_WORKTREE_IDLE_DAYS;
1819
- const groups = /* @__PURE__ */ new Map();
1820
- const outside = [];
1821
- for (const root of candidateRoots()) {
1822
- const entry = matchWorktreeEntry(listWorktrees(root), root);
1823
- if (!entry) {
1824
- outside.push({
1825
- path: root,
1826
- keys: [root],
1827
- idleDays: null,
1828
- skipCode: "not-a-worktree",
1829
- skipped: "not inside a git worktree"
1830
- });
1831
- continue;
1832
- }
1833
- groups.set(entry.path, [...groups.get(entry.path) ?? [], root]);
1834
- }
1835
- const worktrees = [];
1836
- for (const [path, roots] of groups) {
1837
- const entries = listWorktrees(path);
1838
- const entry = matchWorktreeEntry(entries, path);
1839
- const source = sourceCheckoutOf(entries);
1840
- const keys = [.../* @__PURE__ */ new Set([...roots, ...reclaimKeys(path)])];
1841
- const idleDays = idleDaysOf(keys, now);
1842
- const linked = !("refusal" in source) && entry !== null && source.path !== entry.path;
1843
- const gitAnswered = linked ? hasUncommittedWork(path) : null;
1844
- const verdict = worktreeSkipReason({
1845
- source: "refusal" in source ? source : linked ? "linked" : "source",
1846
- bare: Boolean(entry?.bare),
1847
- locked: Boolean(entry?.locked),
1848
- porcelain: porcelainOf(path, gitAnswered),
1849
- unpushed: linked ? unpushedCommits(path) : null,
1850
- submodules: linked && hasPopulatedSubmodules(path),
1851
- inUse: linked ? inUseOf(keys, { managedLocks: true }) : [],
1852
- idleDays
1853
- }, days);
1854
- worktrees.push({
1855
- path,
1856
- keys,
1857
- idleDays,
1858
- skipCode: verdict?.code ?? null,
1859
- skipped: verdict?.text ?? null
1860
- });
1861
- }
1862
- return {
1863
- olderThan: days,
1864
- defaulted: olderThan === null,
1865
- worktrees: [...worktrees, ...outside]
1866
- };
1867
- }
1868
- async function removeWorktrees(sweep, { now = Date.now() } = {}) {
1869
- let failures = 0;
1870
- for (const candidate of sweep.worktrees) {
1871
- if (candidate.skipped) continue;
1872
- let kept = [];
1873
- const guard = (lockedKeys) => {
1874
- const keys = [.../* @__PURE__ */ new Set([...candidate.keys, ...lockedKeys])];
1875
- const unlocked = keys.filter((key) => !lockedKeys.includes(key));
1876
- const reasons = [...inUseOf(lockedKeys, { managedLocks: false }), ...inUseOf(unlocked, { managedLocks: true })].map((r) => `in use: ${r}`);
1877
- const idleDays = idleDaysOf(keys, now);
1878
- if (idleDays === null || idleDays < sweep.olderThan) reasons.push(`used ${idleDays === null ? "at an unknown time" : `${idleDays}d ago`} since gc checked it`);
1879
- kept = reasons;
1880
- return reasons;
1881
- };
1882
- let removed = false;
1883
- try {
1884
- removed = await removeWorktreeTarget(candidate.path, {
1885
- linkedOnly: true,
1886
- guard
1887
- });
1888
- } catch (error) {
1889
- console.error(chalk.red(`Could not remove ${candidate.path}: ${error?.message || String(error)}`));
1890
- }
1891
- if (removed) console.log(chalk.green(`Removed the worktree ${candidate.path}`));
1892
- else if (kept.length) console.log(chalk.yellow(`Kept the worktree ${candidate.path}: ${kept.join("; ")}`));
1893
- else {
1894
- failures++;
1895
- console.error(chalk.yellow(`Kept the worktree ${candidate.path}; see the lines above for why.`));
1896
- }
1897
- }
1898
- return failures;
1899
- }
1900
- //#endregion
1901
1257
  //#region src/commands/gc.ts
1902
1258
  function removeInvalidProjectEntries(invalidProjects) {
1903
1259
  for (const path of invalidProjects) {
@@ -1909,13 +1265,15 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
1909
1265
  const scope = typeof cache === "string" && cache.trim() ? cache : null;
1910
1266
  const all = scope !== null && olderThan === null;
1911
1267
  const withWorkspaces = includesWorkspaceOutputs(scope);
1912
- const caches = planCacheEmptying(sizeCaches(selectCaches(discoverCaches(), scope).filter((c) => !withWorkspaces || !isInsideWorkspaces(c.dir))), all);
1268
+ const selected = selectCaches(discoverCaches(), scope).filter((c) => !withWorkspaces || !isInsideWorkspaces(c.dir));
1269
+ const caches = planCacheEmptying(sizeCaches(selected), all);
1913
1270
  if (scope) return {
1914
1271
  skipped: [],
1915
1272
  deadProjects: [],
1916
1273
  invalidProjects: [],
1917
1274
  orphanedWorkspaces: [],
1918
1275
  orphanedDevices: [],
1276
+ unverifiedDevices: [],
1919
1277
  staleDevices: [],
1920
1278
  staleDeviceRecords: [],
1921
1279
  buildLocks: {
@@ -1932,6 +1290,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
1932
1290
  expired: [],
1933
1291
  kept: []
1934
1292
  },
1293
+ idleDevices: [],
1935
1294
  deviceSweepNotices: [],
1936
1295
  easSessionSweep: {
1937
1296
  projectScope: null,
@@ -1964,8 +1323,14 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
1964
1323
  deletionSafe: false
1965
1324
  };
1966
1325
  }
1967
- const parkedSims = collectParkedSims(deps);
1968
- 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
+ });
1969
1334
  const deadProjects = [];
1970
1335
  const invalidProjects = [];
1971
1336
  const skipped = [];
@@ -1979,7 +1344,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
1979
1344
  else invalidProjects.push(path);
1980
1345
  continue;
1981
1346
  }
1982
- if (existsSync(path)) continue;
1347
+ if (existsSync$1(path)) continue;
1983
1348
  if (!isOnMountedVolume(path, mountedVolumes)) {
1984
1349
  const volume = volumeRootFor(path);
1985
1350
  skipped.push({
@@ -1992,8 +1357,10 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
1992
1357
  skipped.push(...workspaceDirs.skipped);
1993
1358
  const deviceSweepNotices = [];
1994
1359
  let orphanedDevices = [];
1360
+ let unverifiedDevices = [];
1995
1361
  let staleDevices = [];
1996
1362
  let staleDeviceRecords = [];
1363
+ let idleDevices = [];
1997
1364
  const unsweepableReason = cfg === null ? "no Stim config found" : deviceSweepIsScoped() ? "STIM_HOME scopes this config, but simulators and AVDs are machine-global" : null;
1998
1365
  if (unsweepableReason) {
1999
1366
  let simNames = [];
@@ -2031,19 +1398,26 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
2031
1398
  }
2032
1399
  avds = [.../* @__PURE__ */ new Set([...avds, ...orphanedAvdDirectories.map((entry) => entry.name)])];
2033
1400
  const isMounted = (path) => isOnMountedVolume(path, mountedVolumes);
2034
- orphanedDevices = withAndroidAvdSizes(findOrphanedDevices({
1401
+ const found = findOrphanedDevices({
2035
1402
  sims,
2036
1403
  avds,
2037
1404
  config: cfg,
2038
1405
  isMounted,
2039
- deadProjects
2040
- }).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) => {
2041
1414
  const directories = device.kind === "android" ? orphanedAvdDirectories.filter((entry) => entry.name === device.name) : [];
2042
1415
  return directories.length ? directories.map((orphanedDirectory) => Object.assign({}, device, { orphanedDirectory })) : [device];
2043
1416
  }), {
2044
1417
  avdDirectory: deps.avdDirectory ?? ownedAvdDirectory,
2045
1418
  size: deps.directorySize ?? directorySize
2046
1419
  });
1420
+ idleDevices = collectIdleDevices(cfg, sims, deadProjects, now);
2047
1421
  staleDeviceRecords = findStaleDeviceRecords({
2048
1422
  config: cfg,
2049
1423
  sims,
@@ -2081,6 +1455,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
2081
1455
  parkedSims,
2082
1456
  parkedAvds,
2083
1457
  orphanedDevices,
1458
+ unverifiedDevices,
2084
1459
  staleDevices,
2085
1460
  staleDeviceRecords,
2086
1461
  buildLocks: {
@@ -2094,6 +1469,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
2094
1469
  unresolved: slots.filter((s) => s.unresolved)
2095
1470
  },
2096
1471
  deviceLeases,
1472
+ idleDevices,
2097
1473
  deviceSweepNotices,
2098
1474
  easSessionSweep,
2099
1475
  caches,
@@ -2102,10 +1478,11 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
2102
1478
  now,
2103
1479
  exclude: [...workspaceDirs.orphaned.map((entry) => entry.dir), ...deadProjects.map(workspaceDir)]
2104
1480
  }),
2105
- worktreeSweep: worktrees ? collectWorktreeSweep({
1481
+ worktreeSweep: collectWorktreeSweep({
1482
+ idle: worktrees,
2106
1483
  olderThan,
2107
1484
  now
2108
- }) : null,
1485
+ }),
2109
1486
  cacheScope: null,
2110
1487
  olderThan,
2111
1488
  all
@@ -2127,15 +1504,18 @@ async function runGc(opts = {}, deps = {}) {
2127
1504
  console.log(JSON.stringify(payload));
2128
1505
  }
2129
1506
  async function sweep(opts, deps) {
2130
- if (opts.cache && opts.worktrees) {
2131
- console.error(chalk.red("--cache acts only on the named caches, and --worktrees sweeps linked worktrees."));
2132
- console.error(chalk.dim("Run `stim gc --worktrees` and `stim gc --cache <name>` separately."));
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));
2133
1513
  console.error(chalk.red("failed: STIM_BAD_ARG"));
2134
1514
  process.exitCode = 1;
2135
1515
  return {
2136
1516
  code: "STIM_BAD_ARG",
2137
- message: "--cache acts only on the named caches, and --worktrees sweeps linked worktrees.",
2138
- remedy: "Run `stim gc --worktrees` and `stim gc --cache <name>` separately."
1517
+ message,
1518
+ remedy
2139
1519
  };
2140
1520
  }
2141
1521
  const poolError = parkedMaxSetting("ios").error || parkedMaxSetting("android").error;
@@ -2230,14 +1610,15 @@ async function runGcCore(opts, deps) {
2230
1610
  for (const line of formatGcReport(report)) console.log(line);
2231
1611
  const { deadProjects, invalidProjects, orphanedDevices, staleDevices, staleDeviceRecords, buildLocks, buildSlots, deviceLeases, easSessionSweep, caches } = report;
2232
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;
2233
1616
  const payload = (failures) => ({
2234
1617
  mode: opts.delete ? "delete" : "dry-run",
1618
+ idle,
2235
1619
  cacheScope: report.cacheScope,
2236
1620
  olderThan,
2237
- worktreeSweep: report.worktreeSweep ? {
2238
- olderThan: report.worktreeSweep.olderThan,
2239
- defaulted: report.worktreeSweep.defaulted
2240
- } : null,
1621
+ worktreeSweep: report.worktreeSweep?.idle ?? null,
2241
1622
  actionable,
2242
1623
  failures,
2243
1624
  sections: gcReportSections(report)
@@ -2246,13 +1627,14 @@ async function runGcCore(opts, deps) {
2246
1627
  if (all) console.log(chalk.dim("\nDry run. Re-run with --delete to empty the caches above."));
2247
1628
  else if (actionable) console.log(chalk.dim("\nDry run. Re-run with --delete to reclaim."));
2248
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."));
2249
- return payload(null);
1630
+ return payload(idle === null ? null : idleFailures);
2250
1631
  }
2251
- let deleteFailures = report.workspaceOutputs ? await clearWorkspaceOutputs(report.workspaceOutputs, { olderThan }) : 0;
1632
+ let deleteFailures = idleFailures;
1633
+ deleteFailures += report.workspaceOutputs ? (await clearWorkspaceOutputs(report.workspaceOutputs, { olderThan })).failures : 0;
2252
1634
  deleteFailures += deleteParkedSims(report.parkedSims, deps) + deleteParkedAvds(report.parkedAvds);
2253
1635
  removeInvalidProjectEntries(invalidProjects);
2254
1636
  for (const path of deadProjects) {
2255
- if (existsSync(path) || !isOnMountedVolume(path)) {
1637
+ if (existsSync$1(path) || !isOnMountedVolume(path)) {
2256
1638
  console.log(chalk.yellow(`Kept ${path}: its absence can no longer be confirmed.`));
2257
1639
  continue;
2258
1640
  }
@@ -2331,13 +1713,17 @@ async function runGcCore(opts, deps) {
2331
1713
  return payload(deleteFailures);
2332
1714
  }
2333
1715
  function gcCommand(program) {
2334
- program.command("gc").description("Report what Stim has left behind: dead project entries, orphaned workspace directories, orphaned owned devices and EAS sessions, records of devices that no longer exist, build locks whose builder is gone, expired physical-device leases, the shared build caches, and the build outputs of each workspace. Reports by default; pass --delete to act.").option("--worktrees", "also report every clean, idle, Stim-managed linked worktree, and with --delete run `stim worktree remove` (never --force) on each; idle means unused for --older-than days, 7 without it").option("--delete", "actually prune the reported entries, reap the reported devices, and clear the build outputs of every workspace not in use").option("--older-than <days>", "also reap owned devices whose workspace has not been used this long, trim shared cache entries nothing has used in that time, and clear workspace build outputs only for workspaces idle this long", (v) => {
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) => {
2335
1717
  const n = parseInt(v, 10);
2336
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");
2337
1719
  return n;
2338
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) => {
2339
1721
  if (!v.trim()) throw new InvalidArgumentError("must name a cache, e.g. --cache \"compilation cache\"");
2340
1722
  return v;
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;
2341
1727
  }).option("--json", "print the report as JSON on stdout; every other line goes to stderr").action(async (opts) => {
2342
1728
  await runGc(opts);
2343
1729
  });