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