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