stim 1.11.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/{activity-BtG4Va9l.mjs → activity-DfVKZTEB.mjs} +44 -26
- package/dist/{android-BA2i_KAV.mjs → android-BHfAu2mf.mjs} +1 -1
- package/dist/{android-C1q4l523.mjs → android-CD8pr8Q2.mjs} +112 -54
- package/dist/{android-cas-DfJkwCXW.mjs → android-cas-B9Kv9aIy.mjs} +12 -10
- package/dist/{android-C4zG2Tuc.mjs → android-qx_31wJo.mjs} +414 -118
- package/dist/{app-install-DHut6JnV.mjs → app-install-CDFY2nUL.mjs} +1 -1
- package/dist/budget-E5o91NR1.mjs +1739 -0
- package/dist/{build-plan-CeAYgKAx.mjs → build-plan-BTa93o_r.mjs} +125 -107
- package/dist/{build-progress-CDwogz6R.mjs → build-progress-l7d-X-ZP.mjs} +76 -8
- package/dist/{cache-manifest-Pubfp8wV.mjs → cache-manifest-DWPF4RKJ.mjs} +1 -1
- package/dist/cache-manifest.mjs +1 -1
- package/dist/cdp-Dq_ypAlJ.mjs +115 -0
- package/dist/chrome-B5hWgQJS.mjs +61 -0
- package/dist/cli.mjs +17 -16
- package/dist/collector-run.mjs +15 -9
- package/dist/{command-output-D5z2_bcF.mjs → command-output-BIL7rlX7.mjs} +1 -1
- package/dist/{config-BEq0Qw_a.mjs → config-0jCcMM0E.mjs} +10 -1
- package/dist/created-devices-Cd558h31.mjs +37 -0
- package/dist/{dependency-state-r9UesA9a.mjs → dependency-state-DNN0yfx6.mjs} +1 -1
- package/dist/{deps-OF7CD5Dx.mjs → deps-2Wo81np9.mjs} +2 -2
- package/dist/{device-BwH2iI3O.mjs → device-Cf7vl-so.mjs} +7 -7
- package/dist/{device-lease-D83nqeMX.mjs → device-lease-CL9dZf73.mjs} +55 -6
- package/dist/{device-pool-DHj7cdPy.mjs → device-pool-MuoiyRcu.mjs} +4 -4
- package/dist/{device-remote-BQZSnnmE.mjs → device-remote-DT9ngZiD.mjs} +254 -579
- package/dist/{doctor-D8QdMRMf.mjs → doctor-D2iysNNH.mjs} +50 -20
- package/dist/{error-diagnostics-UYFSQwRy.mjs → error-diagnostics-BoiOhck8.mjs} +7 -7
- package/dist/{gc-COZteyiq.mjs → gc-eA-PRBX8.mjs} +1022 -131
- package/dist/{guide-NDvTmMi-.mjs → guide-BfZ0OmiP.mjs} +909 -132
- package/dist/{guide-status-CWvti2Lg.mjs → guide-status-BaMYu55M.mjs} +2 -2
- package/dist/{idle-NETbeNiQ.mjs → idle-DxLi5OkN.mjs} +43 -10
- package/dist/{in-use-DVhlSoyn.mjs → in-use-wYEaKQM9.mjs} +11 -7
- package/dist/{ios-CQLKpNfr.mjs → ios-CcfCeZ19.mjs} +103 -58
- package/dist/{ios-DArbmwer.mjs → ios-CmEzZiOj.mjs} +31 -22
- package/dist/{ios-device-CwDZo0X4.mjs → ios-device-BW-t_Lkv.mjs} +1 -1
- package/dist/{launch-verify-CuPDGXdr.mjs → launch-verify-CuJaBpEa.mjs} +33 -20
- package/dist/{logs-B44a1UeU.mjs → logs-DcSRbC2t.mjs} +12 -11
- package/dist/{merge-state-DDxS-XIZ.mjs → merge-state-ytFQCeh4.mjs} +38 -8
- package/dist/{metro-BWLOJ_Wr.mjs → metro-BPeY1YMh.mjs} +5 -5
- package/dist/{named-ports-Dc7neAlc.mjs → named-ports-n7tnJsI8.mjs} +78 -6
- package/dist/native-runtime-CiiFHt0e.mjs +74 -0
- package/dist/{ownership-D2xbLVoz.mjs → ownership-Bz2kETAS.mjs} +2 -2
- package/dist/{ownership-BiojdtAp.mjs → ownership-PUP4Dwai.mjs} +175 -217
- package/dist/page-BTu7I60K.mjs +29 -0
- package/dist/{ports-DwBaRFJs.mjs → ports-C5UutqpJ.mjs} +5 -5
- package/dist/{project-BXZzOABt.mjs → project-DGeclhvO.mjs} +55 -87
- package/dist/{reload-By9Dwsrt.mjs → reload-C-cV5yy_.mjs} +80 -21
- package/dist/remote-cache--aehr4Pr.mjs +526 -0
- package/dist/{server-bare-D_RK1RCj.mjs → server-bare-Bq8BpSIo.mjs} +1 -1
- package/dist/server-expo-BeYwP_Xy.mjs +2 -0
- package/dist/{server-expo-o6H5gcBc.mjs → server-expo-DUCg52-K.mjs} +2 -2
- package/dist/{settings-C0ugALSC.mjs → settings-ssVBToDR.mjs} +29 -17
- package/dist/{settings-B5oFZEri.mjs → settings-xB0Frkg6.mjs} +13 -4
- package/dist/settings.schema.json +74 -2
- package/dist/{simslim-CIR3IYJO.mjs → simslim-Bbg9UWrt.mjs} +13 -6
- package/dist/{slot-launch-rggO1Esw.mjs → slot-launch-BIWEW-wI.mjs} +4 -4
- package/dist/{spawn-claims-BJwXTMPt.mjs → spawn-claims-PT8aQpCa.mjs} +1 -1
- package/dist/{start-BSsf2tUs.mjs → start-BZhItkiL.mjs} +110 -18
- package/dist/{start-i8zc9VmS.mjs → start-Rw2GD3nV.mjs} +1 -1
- package/dist/{state-WGdIbi_d.mjs → state-DwAU2_-D.mjs} +15 -3
- package/dist/state-l1pYwOre.mjs +148 -0
- package/dist/{stats-DBYJ5bxL.mjs → stats-CaSwmsSZ.mjs} +4 -4
- package/dist/{stats-BAgJaLA4.mjs → stats-D8rUTPHd.mjs} +1 -1
- package/dist/{status-CitcBRsC.mjs → status-TJlL0HWo.mjs} +649 -116
- package/dist/{status-Q-WDEGCB.mjs → status-sszP4XKU.mjs} +130 -28
- package/dist/stim-desktop-CXt8-Z3J.mjs +40 -0
- package/dist/{stop-DIhrvu0B.mjs → stop-BZ2qMiBP.mjs} +50 -18
- package/dist/{stop-rst5mL14.mjs → stop-DskDKNTU.mjs} +2 -2
- package/dist/supervisor-run.mjs +11 -11
- package/dist/web-run.d.mts +22 -0
- package/dist/web-run.mjs +481 -0
- package/dist/web-sWXd5iek.mjs +394 -0
- package/dist/{worktree-DhxDTK44.mjs → worktree-3cBa-bQt.mjs} +1 -1
- package/dist/{worktree-KOjMX3zA.mjs → worktree-D4Do_XiQ.mjs} +229 -21
- package/dist/{worktree-BlizKDpZ.mjs → worktree-DeBQSVtR.mjs} +3 -1
- package/dist/{xcode-B5SPD--o.mjs → xcode-LM6A8cNZ.mjs} +7 -7
- package/package.json +5 -5
- package/dist/budget-De3P2rq_.mjs +0 -620
- package/dist/native-run-D7yB_AsB.mjs +0 -143
- package/dist/server-expo-B-CCUJZj.mjs +0 -2
- package/dist/workspace-process-lock-Cx74RSDZ.mjs +0 -57
- package/dist/workspaces-COD2xCz-.mjs +0 -1013
|
@@ -1,22 +1,28 @@
|
|
|
1
|
-
import { A as
|
|
1
|
+
import { A as ownership_claim_exports, E as withConfigLock$1, I as volumeRootFor, M as formatBytes, N as isOnMountedVolume, P as listMountedVolumes, U as projectDeviceSlots, _ as loadConfig$1, j as directorySize, k as acquireAvdClaim, p as getProject, x as removeProject } from "./config-0jCcMM0E.mjs";
|
|
2
2
|
import { t as getExecutor } from "./exec-DK8KaVHK.mjs";
|
|
3
|
-
import {
|
|
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-
|
|
5
|
-
import { a as
|
|
6
|
-
import { h as shortUdid, o as formatLongDuration, u as plural } from "./command-output-
|
|
7
|
-
import { d as removeExpiredLease, h as listBuildLocks } from "./device-lease-
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { l as listAllIosSims } from "./ios-
|
|
11
|
-
import { a as parseIdleDuration, o as shutDownIdleDevices, r as idleDeviceLines, t as collectIdleDevices } from "./idle-
|
|
12
|
-
import { i as canonicalPath, o as listBuildSlots, r as workspaceInUse } from "./in-use-
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
3
|
+
import { F as withDirLock, M as workspaceLogsDir, P as workspaceStateFile, k as workspaceDir, y as workspaceLastUsed } from "./metro-BPeY1YMh.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-DeBQSVtR.mjs";
|
|
5
|
+
import { a as findProjectRoot, r as detectIsExpo } from "./project-DGeclhvO.mjs";
|
|
6
|
+
import { h as shortUdid, o as formatLongDuration, u as plural } from "./command-output-BIL7rlX7.mjs";
|
|
7
|
+
import { d as removeExpiredLease, h as listBuildLocks, i as listLeaseFiles, r as leaseIsExpired } from "./device-lease-CL9dZf73.mjs";
|
|
8
|
+
import { A as listInstalledSystemImages, B as parseAvdDeviceProfile, F as ownedAvdDirectory, R as ownedAvdSerialResolver, V as parseAvdSystemImage, a as androidHome, ct as parkedMaxSetting, f as avdIniPaths, g as avdStorageRoots, j as listOrphanedAvdDirectories, k as listAvds, lt as readParked, nt as isStimOwnedSim, p as avdNameAbsent, rt as POOL_SETTING_REMEDY, tt as isStimOwnedAvd, u as androidToolPath } from "./android-qx_31wJo.mjs";
|
|
9
|
+
import { n as readCreatedDevices$1, t as forgetCreatedDevice } from "./created-devices-Cd558h31.mjs";
|
|
10
|
+
import { l as listAllIosSims } from "./ios-CmEzZiOj.mjs";
|
|
11
|
+
import { a as parseIdleDuration, c as recordGcResult, l as takeGcResults, o as shutDownIdleDevices, r as idleDeviceLines, t as collectIdleDevices } from "./idle-DxLi5OkN.mjs";
|
|
12
|
+
import { i as canonicalPath, n as withIdleWorkspace, o as listBuildSlots, r as workspaceInUse } from "./in-use-wYEaKQM9.mjs";
|
|
13
|
+
import { r as readCollectors } from "./ownership-Bz2kETAS.mjs";
|
|
14
|
+
import { f as resolveEasCliBin } from "./remote-cache--aehr4Pr.mjs";
|
|
15
|
+
import { A as findOrphanedOwnedSessions, C as withEasProjectLock, E as removeEasSessionClaim, I as parseSessionListPage, L as stopSessionArgs, M as inspectSessionForTeardown, N as isDefinitiveMissingSessionError, P as listOwnedSessionsArgs, R as verifyStoppedSession, T as readEasSessionLedger$1, d as withRemoteSessionLock, j as getSessionArgs, w as easMachineStateRoot$1 } from "./device-remote-DT9ngZiD.mjs";
|
|
16
|
+
import { A as includesWorkspaceOutputs, C as deviceSweepIsScoped, D as unverifiedDeviceCommand, E as findStaleProjectDevices, F as sizeCaches, M as selectCaches, N as trimCaches, O as withAndroidAvdSizes, P as discoverCaches, S as describeUnverifiableDevices, T as findStaleDeviceRecords, _ as collectParkedSims, a as REBUILD_COST, b as deleteProjectDevices, c as collectOrphanedWorkspaces, d as isInsideWorkspaces, f as listWorkspaceDirs, g as collectParkedAvds, h as collectDeviceLeases, i as machineNumber, j as planCacheEmptying, k as emptyCaches, l as collectWorkspaceOutputs, m as DEVICE_LIST_TIMEOUT_MS, o as WORKSPACE_OUTPUT_DIRS, p as sizeText, s as clearWorkspaceOutputs, u as deleteOrphanedWorkspaces, v as deleteParkedAvds, w as findOrphanedDevices, x as describeParkedSims, y as deleteParkedSims } from "./budget-E5o91NR1.mjs";
|
|
17
|
+
import { _ as reclaimProject, a as reclaimKeys, d as removeWorktreeTarget, g as describeDereferenced, h as pullRequestLookups, m as endedPullRequest, n as excludePodChurn, p as describePullRequest, r as matchWorktreeEntry } from "./worktree-D4Do_XiQ.mjs";
|
|
18
|
+
import { n as mergeState, t as fetchDefaultBranch } from "./merge-state-ytFQCeh4.mjs";
|
|
19
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
17
20
|
import { InvalidArgumentError } from "commander";
|
|
18
|
-
import { existsSync, readFileSync, readdirSync, statSync } from "fs";
|
|
21
|
+
import { closeSync as closeSync$1, existsSync as existsSync$1, openSync as openSync$1, readFileSync as readFileSync$1, readSync as readSync$1, readdirSync as readdirSync$1, renameSync as renameSync$1, rmSync as rmSync$1, statSync as statSync$1, utimesSync, writeFileSync as writeFileSync$1 } from "fs";
|
|
19
22
|
import { basename, dirname, isAbsolute, join } from "path";
|
|
23
|
+
import { LOG_ROTATE_BYTES, rotatedLogPath } from "@stim-cli/core";
|
|
24
|
+
import { settingDefinition } from "@stim-cli/core/state";
|
|
25
|
+
import { dirname as dirname$1, join as join$1 } from "node:path";
|
|
20
26
|
import chalk from "chalk";
|
|
21
27
|
//#region src/commands/gc/eas-sessions.ts
|
|
22
28
|
const EAS_OPERATION_TIMEOUT_MS = 3e4;
|
|
@@ -37,18 +43,18 @@ function readRecordedRemoteSessionIds(roots) {
|
|
|
37
43
|
let safe = true;
|
|
38
44
|
for (const root of new Set(roots)) {
|
|
39
45
|
try {
|
|
40
|
-
if (!statSync(root).isDirectory()) throw new Error("path is not a directory");
|
|
41
|
-
readdirSync(root);
|
|
46
|
+
if (!statSync$1(root).isDirectory()) throw new Error("path is not a directory");
|
|
47
|
+
readdirSync$1(root);
|
|
42
48
|
} catch (error) {
|
|
43
49
|
safe = false;
|
|
44
50
|
notices.push(`${root} is not available as a readable workspace directory: ${describeError(error)}`);
|
|
45
51
|
continue;
|
|
46
52
|
}
|
|
47
53
|
const path = workspaceStateFile(root);
|
|
48
|
-
if (!existsSync(path)) continue;
|
|
54
|
+
if (!existsSync$1(path)) continue;
|
|
49
55
|
let state;
|
|
50
56
|
try {
|
|
51
|
-
state = JSON.parse(readFileSync(path, "utf-8"));
|
|
57
|
+
state = JSON.parse(readFileSync$1(path, "utf-8"));
|
|
52
58
|
} catch (error) {
|
|
53
59
|
safe = false;
|
|
54
60
|
notices.push(`${path} could not be read as valid JSON: ${describeError(error)}`);
|
|
@@ -84,8 +90,8 @@ function claimStateAbsence(claim) {
|
|
|
84
90
|
const stateDir = dirname(claim.stateFile);
|
|
85
91
|
let entries;
|
|
86
92
|
try {
|
|
87
|
-
if (!statSync(stateDir).isDirectory()) throw new Error("path is not a directory");
|
|
88
|
-
entries = readdirSync(stateDir);
|
|
93
|
+
if (!statSync$1(stateDir).isDirectory()) throw new Error("path is not a directory");
|
|
94
|
+
entries = readdirSync$1(stateDir);
|
|
89
95
|
} catch (error) {
|
|
90
96
|
return {
|
|
91
97
|
absent: false,
|
|
@@ -98,7 +104,7 @@ function claimStateAbsence(claim) {
|
|
|
98
104
|
};
|
|
99
105
|
let state;
|
|
100
106
|
try {
|
|
101
|
-
state = JSON.parse(readFileSync(claim.stateFile, "utf-8"));
|
|
107
|
+
state = JSON.parse(readFileSync$1(claim.stateFile, "utf-8"));
|
|
102
108
|
} catch (error) {
|
|
103
109
|
return {
|
|
104
110
|
absent: false,
|
|
@@ -141,8 +147,8 @@ function collectEasSessionSweep(config, deps) {
|
|
|
141
147
|
const eas = (deps.resolveEasCliBin ?? resolveEasCliBin)(projectRoot);
|
|
142
148
|
if (!eas) return empty(["EAS session sweep skipped: eas-cli is not available for the current Expo project."]);
|
|
143
149
|
const recorded = readRecordedRemoteSessionIds([...Object.keys(config?.projects ?? {}), projectRoot]);
|
|
144
|
-
const ledgerRoot = deps.easLedgerRoot ?? easMachineStateRoot();
|
|
145
|
-
const ledger = readEasSessionLedger(ledgerRoot);
|
|
150
|
+
const ledgerRoot = deps.easLedgerRoot ?? easMachineStateRoot$1();
|
|
151
|
+
const ledger = readEasSessionLedger$1(ledgerRoot);
|
|
146
152
|
const run = deps.runEasFile ?? ((file, args, options) => getExecutor().runFile(file, args, options));
|
|
147
153
|
const sessions = [];
|
|
148
154
|
const seenCursors = /* @__PURE__ */ new Set();
|
|
@@ -180,7 +186,7 @@ function collectEasSessionSweep(config, deps) {
|
|
|
180
186
|
}
|
|
181
187
|
const lockedRoots = deps.easLockedRoots;
|
|
182
188
|
if (lockedRoots) {
|
|
183
|
-
if (easLockRoots(projectRoot, loadConfig()).some((root) => !lockedRoots.includes(root))) return empty([...recorded.notices, "EAS session sweep skipped: registered workspace roots changed after remote-session locks were acquired."]);
|
|
189
|
+
if (easLockRoots(projectRoot, loadConfig$1()).some((root) => !lockedRoots.includes(root))) return empty([...recorded.notices, "EAS session sweep skipped: registered workspace roots changed after remote-session locks were acquired."]);
|
|
184
190
|
}
|
|
185
191
|
const compared = findOrphanedOwnedSessions({
|
|
186
192
|
sessions,
|
|
@@ -215,8 +221,8 @@ function easLockRoots(projectRoot, config) {
|
|
|
215
221
|
}
|
|
216
222
|
function unavailableLockRootNotice(roots) {
|
|
217
223
|
for (const root of roots) try {
|
|
218
|
-
if (!statSync(root).isDirectory()) throw new Error("path is not a directory");
|
|
219
|
-
readdirSync(root);
|
|
224
|
+
if (!statSync$1(root).isDirectory()) throw new Error("path is not a directory");
|
|
225
|
+
readdirSync$1(root);
|
|
220
226
|
} catch (error) {
|
|
221
227
|
return `EAS session sweep skipped: ${root} is not available for remote-session lock inspection: ${describeError(error)}`;
|
|
222
228
|
}
|
|
@@ -225,7 +231,7 @@ function unavailableLockRootNotice(roots) {
|
|
|
225
231
|
function malformedRemoteSessionLockNotice(roots) {
|
|
226
232
|
for (const root of roots) {
|
|
227
233
|
const lockPath = join(workspaceDir(root), "remote-session.lock");
|
|
228
|
-
if (!existsSync(lockPath)) continue;
|
|
234
|
+
if (!existsSync$1(lockPath)) continue;
|
|
229
235
|
if ((0, ownership_claim_exports.readClaimSet)(lockPath).unresolved.length === 0) continue;
|
|
230
236
|
return `EAS session sweep skipped: the remote-session lock at ${lockPath} has a malformed owner record.`;
|
|
231
237
|
}
|
|
@@ -236,7 +242,7 @@ async function withRemoteSessionGcLocks(projectRoot, deps, fn) {
|
|
|
236
242
|
const waitMs = deps.easLockWaitMs ?? EAS_LOCK_WAIT_MS;
|
|
237
243
|
const maxSnapshots = deps.easLockSnapshotRetries ?? EAS_LOCK_SNAPSHOT_RETRIES;
|
|
238
244
|
for (let attempt = 0; attempt < maxSnapshots; attempt++) {
|
|
239
|
-
const roots = easLockRoots(projectRoot, loadConfig());
|
|
245
|
+
const roots = easLockRoots(projectRoot, loadConfig$1());
|
|
240
246
|
const unavailable = unavailableLockRootNotice(roots);
|
|
241
247
|
if (unavailable) return fn({
|
|
242
248
|
...deps,
|
|
@@ -250,7 +256,7 @@ async function withRemoteSessionGcLocks(projectRoot, deps, fn) {
|
|
|
250
256
|
let expanded = false;
|
|
251
257
|
let coreStarted = false;
|
|
252
258
|
const acquire = (index) => index === roots.length ? (async () => {
|
|
253
|
-
if (easLockRoots(projectRoot, loadConfig()).some((root) => !roots.includes(root))) {
|
|
259
|
+
if (easLockRoots(projectRoot, loadConfig$1()).some((root) => !roots.includes(root))) {
|
|
254
260
|
expanded = true;
|
|
255
261
|
return null;
|
|
256
262
|
}
|
|
@@ -279,6 +285,13 @@ async function withRemoteSessionGcLocks(projectRoot, deps, fn) {
|
|
|
279
285
|
}
|
|
280
286
|
async function deleteEasSessions(easSessionSweep, deps) {
|
|
281
287
|
let deleteFailures = 0;
|
|
288
|
+
const refuseAll = (detail) => {
|
|
289
|
+
deleteFailures += easSessionSweep.orphaned.length;
|
|
290
|
+
for (const session of easSessionSweep.orphaned) recordGcResult("easSession", "failed", session.name, {
|
|
291
|
+
id: session.id,
|
|
292
|
+
detail
|
|
293
|
+
});
|
|
294
|
+
};
|
|
282
295
|
if (easSessionSweep.orphaned.length && easSessionSweep.deletionSafe && easSessionSweep.projectScope) {
|
|
283
296
|
const projectScope = easSessionSweep.projectScope;
|
|
284
297
|
const withProjectLock = deps.withEasProjectLock ?? withEasProjectLock;
|
|
@@ -288,17 +301,17 @@ async function deleteEasSessions(easSessionSweep, deps) {
|
|
|
288
301
|
finalSweepStarted = true;
|
|
289
302
|
return withRemoteSessionGcLocks(projectScope, deps, async (lockedDeps) => {
|
|
290
303
|
if (lockedDeps.easSweepBlockedNotice) {
|
|
291
|
-
|
|
304
|
+
refuseAll(`EAS session deletion refused: ${lockedDeps.easSweepBlockedNotice}`);
|
|
292
305
|
console.log(chalk.red(`EAS session deletion refused: ${lockedDeps.easSweepBlockedNotice}`));
|
|
293
306
|
return;
|
|
294
307
|
}
|
|
295
308
|
const coordinatedDeps = lockedDeps;
|
|
296
|
-
const currentConfig = loadConfig();
|
|
309
|
+
const currentConfig = loadConfig$1();
|
|
297
310
|
const currentRoots = easLockRoots(projectScope, currentConfig);
|
|
298
311
|
const registryExpanded = coordinatedDeps.easLockedRoots && currentRoots.some((root) => !coordinatedDeps.easLockedRoots.includes(root));
|
|
299
312
|
const currentRecords = registryExpanded ? null : readRecordedRemoteSessionIds([...Object.keys(currentConfig?.projects ?? {}), projectScope]);
|
|
300
|
-
const ledgerRoot = coordinatedDeps.easLedgerRoot ?? easMachineStateRoot();
|
|
301
|
-
const ledger = readEasSessionLedger(ledgerRoot);
|
|
313
|
+
const ledgerRoot = coordinatedDeps.easLedgerRoot ?? easMachineStateRoot$1();
|
|
314
|
+
const ledger = readEasSessionLedger$1(ledgerRoot);
|
|
302
315
|
const removeClaim = coordinatedDeps.removeEasSessionClaim ?? removeEasSessionClaim;
|
|
303
316
|
const reconcileClaim = (sessionId) => {
|
|
304
317
|
let detail = "the claim store did not remove it";
|
|
@@ -311,10 +324,10 @@ async function deleteEasSessions(easSessionSweep, deps) {
|
|
|
311
324
|
console.log(chalk.red(`EAS session ${sessionId} is resolved, but its ownership claim could not be removed: ${detail}. The claim was retained for reconciliation.`));
|
|
312
325
|
};
|
|
313
326
|
if (registryExpanded) {
|
|
314
|
-
|
|
327
|
+
refuseAll("EAS session deletion refused: registered workspace roots changed after classification.");
|
|
315
328
|
console.log(chalk.red("EAS session deletion refused: registered workspace roots changed after classification."));
|
|
316
329
|
} else if (!currentRecords?.safe || !ledger.safe) {
|
|
317
|
-
|
|
330
|
+
refuseAll("EAS session deletion refused: the session records could not be read safely.");
|
|
318
331
|
for (const notice of [...currentRecords?.notices ?? [], ...ledger.notice ? [ledger.notice] : []]) console.log(chalk.red(`EAS session deletion refused: ${notice}`));
|
|
319
332
|
} else {
|
|
320
333
|
const recorded = new Set(currentRecords.ids);
|
|
@@ -323,6 +336,10 @@ async function deleteEasSessions(easSessionSweep, deps) {
|
|
|
323
336
|
for (const session of easSessionSweep.orphaned) {
|
|
324
337
|
if (recorded.has(session.id)) {
|
|
325
338
|
console.log(chalk.dim(`EAS session ${session.id} has a workspace record and was left running.`));
|
|
339
|
+
recordGcResult("easSession", "kept", session.name, {
|
|
340
|
+
id: session.id,
|
|
341
|
+
detail: "it has a workspace record and was left running"
|
|
342
|
+
});
|
|
326
343
|
continue;
|
|
327
344
|
}
|
|
328
345
|
const claim = ledger.claims.get(session.id);
|
|
@@ -330,11 +347,19 @@ async function deleteEasSessions(easSessionSweep, deps) {
|
|
|
330
347
|
if (!claim || claim.name !== session.name || claim.platform !== session.platform || !claimState?.absent || claimState.notice) {
|
|
331
348
|
deleteFailures++;
|
|
332
349
|
console.log(chalk.red(`Could not verify fixed ownership for EAS session ${session.id}: ${claimState?.notice ?? "the claim is missing, mismatched, or still recorded in workspace state"}.`));
|
|
350
|
+
recordGcResult("easSession", "failed", session.name, {
|
|
351
|
+
id: session.id,
|
|
352
|
+
detail: `Could not verify fixed ownership for EAS session ${session.id}: ${claimState?.notice ?? "the claim is missing, mismatched, or still recorded in workspace state"}.`
|
|
353
|
+
});
|
|
333
354
|
continue;
|
|
334
355
|
}
|
|
335
356
|
if (!eas) {
|
|
336
357
|
deleteFailures++;
|
|
337
358
|
console.log(chalk.red(`Could not verify EAS session ${session.id}: eas-cli is not available.`));
|
|
359
|
+
recordGcResult("easSession", "failed", session.name, {
|
|
360
|
+
id: session.id,
|
|
361
|
+
detail: `Could not verify EAS session ${session.id}: eas-cli is not available.`
|
|
362
|
+
});
|
|
338
363
|
continue;
|
|
339
364
|
}
|
|
340
365
|
const options = {
|
|
@@ -352,6 +377,10 @@ async function deleteEasSessions(easSessionSweep, deps) {
|
|
|
352
377
|
} else {
|
|
353
378
|
deleteFailures++;
|
|
354
379
|
console.log(chalk.red(`Could not verify EAS session ${session.id}: ${describeError(error)}`));
|
|
380
|
+
recordGcResult("easSession", "failed", session.name, {
|
|
381
|
+
id: session.id,
|
|
382
|
+
detail: `Could not verify EAS session ${session.id}: ${describeError(error)}`
|
|
383
|
+
});
|
|
355
384
|
}
|
|
356
385
|
continue;
|
|
357
386
|
}
|
|
@@ -364,6 +393,10 @@ async function deleteEasSessions(easSessionSweep, deps) {
|
|
|
364
393
|
if (inspection.action === "refused") {
|
|
365
394
|
deleteFailures++;
|
|
366
395
|
console.log(chalk.red(`Could not stop EAS session ${session.id}: ${inspection.reason}`));
|
|
396
|
+
recordGcResult("easSession", "failed", session.name, {
|
|
397
|
+
id: session.id,
|
|
398
|
+
detail: `Could not stop EAS session ${session.id}: ${inspection.reason}`
|
|
399
|
+
});
|
|
367
400
|
continue;
|
|
368
401
|
}
|
|
369
402
|
let stopped;
|
|
@@ -372,15 +405,24 @@ async function deleteEasSessions(easSessionSweep, deps) {
|
|
|
372
405
|
} catch (error) {
|
|
373
406
|
deleteFailures++;
|
|
374
407
|
console.log(chalk.red(`Could not stop EAS session ${session.id}: ${describeError(error)}`));
|
|
408
|
+
recordGcResult("easSession", "failed", session.name, {
|
|
409
|
+
id: session.id,
|
|
410
|
+
detail: `Could not stop EAS session ${session.id}: ${describeError(error)}`
|
|
411
|
+
});
|
|
375
412
|
continue;
|
|
376
413
|
}
|
|
377
414
|
const verified = verifyStoppedSession(stopped, session.id);
|
|
378
415
|
if (!verified.ok) {
|
|
379
416
|
deleteFailures++;
|
|
380
417
|
console.log(chalk.red(`Could not verify EAS session ${session.id} stopped: ${verified.reason}`));
|
|
418
|
+
recordGcResult("easSession", "failed", session.name, {
|
|
419
|
+
id: session.id,
|
|
420
|
+
detail: `Could not verify EAS session ${session.id} stopped: ${verified.reason}`
|
|
421
|
+
});
|
|
381
422
|
continue;
|
|
382
423
|
}
|
|
383
424
|
console.log(chalk.green(`Stopped EAS session ${session.id} (${session.name})`));
|
|
425
|
+
recordGcResult("easSession", "done", session.name, { id: session.id });
|
|
384
426
|
reconcileClaim(session.id);
|
|
385
427
|
}
|
|
386
428
|
}
|
|
@@ -391,8 +433,8 @@ async function deleteEasSessions(easSessionSweep, deps) {
|
|
|
391
433
|
machineRoot: deps.easLedgerRoot
|
|
392
434
|
});
|
|
393
435
|
} catch (error) {
|
|
394
|
-
deleteFailures += easSessionSweep.orphaned.length;
|
|
395
436
|
const phase = finalSweepStarted ? "failed" : "lock acquisition failed";
|
|
437
|
+
refuseAll(`EAS session deletion ${phase}: ${describeError(error)}`);
|
|
396
438
|
console.log(chalk.red(`EAS session deletion ${phase}: ${describeError(error)}`));
|
|
397
439
|
}
|
|
398
440
|
}
|
|
@@ -402,6 +444,8 @@ async function deleteEasSessions(easSessionSweep, deps) {
|
|
|
402
444
|
//#region src/commands/gc/worktrees.ts
|
|
403
445
|
const DEFAULT_WORKTREE_IDLE_DAYS = 7;
|
|
404
446
|
const DAY_MS = 864e5;
|
|
447
|
+
const MINUTE_MS = 6e4;
|
|
448
|
+
const GRACE_SETTING = "gc.worktreeGraceMinutes";
|
|
405
449
|
const MERGE_DECIDES = /* @__PURE__ */ new Set([
|
|
406
450
|
"unpushed",
|
|
407
451
|
"not-merged",
|
|
@@ -414,29 +458,77 @@ function skip(code, text) {
|
|
|
414
458
|
text
|
|
415
459
|
};
|
|
416
460
|
}
|
|
417
|
-
function
|
|
461
|
+
function pullRequestNote(lookup) {
|
|
462
|
+
const pr = lookup && "pullRequest" in lookup ? lookup.pullRequest : null;
|
|
463
|
+
if (!pr) return null;
|
|
464
|
+
return pr.containsHead ? describePullRequest(pr) : `${describePullRequest(pr)}, and HEAD has commits it does not`;
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Why gc keeps a worktree, or null when it removes it. A worktree that would be removed is still kept while its last
|
|
468
|
+
* activity, or its merge into the default branch, is less than `grace.ms` old, and kept when that activity is unknown.
|
|
469
|
+
*/
|
|
470
|
+
function worktreeSkipReason(facts, olderThan, grace = {
|
|
471
|
+
ms: 0,
|
|
472
|
+
now: 0
|
|
473
|
+
}) {
|
|
474
|
+
return removalBlocker(facts, olderThan) ?? graceBlocker(facts, grace);
|
|
475
|
+
}
|
|
476
|
+
function graceBlocker(facts, grace) {
|
|
477
|
+
if (grace.ms <= 0) return null;
|
|
478
|
+
if (!facts.activity) return skip("activity-unknown", "its last activity could not be read");
|
|
479
|
+
const merged = facts.merge?.merged ? facts.merge : null;
|
|
480
|
+
const ended = endedPullRequest(facts.pullRequest);
|
|
481
|
+
const events = [facts.activity];
|
|
482
|
+
if (merged) events.push({
|
|
483
|
+
at: merged.mergedAt,
|
|
484
|
+
basis: `merged into ${merged.into}`
|
|
485
|
+
});
|
|
486
|
+
if (ended?.endedAt) events.push({
|
|
487
|
+
at: ended.endedAt,
|
|
488
|
+
basis: describePullRequest(ended)
|
|
489
|
+
});
|
|
490
|
+
const latest = events.reduce((a, b) => b.at > a.at ? b : a);
|
|
491
|
+
const eligibleAt = latest.at + grace.ms;
|
|
492
|
+
if (grace.now >= eligibleAt) return null;
|
|
493
|
+
const ago = formatLongDuration(Math.max(0, grace.now - latest.at));
|
|
494
|
+
return {
|
|
495
|
+
code: "recent-activity",
|
|
496
|
+
text: `recent activity: ${latest.basis} ${ago} ago; removable after ${new Date(eligibleAt).toISOString()}`,
|
|
497
|
+
eligibleAt
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
function removalBlocker(facts, olderThan) {
|
|
418
501
|
if (facts.bare) return skip("bare-repository", "bare repository");
|
|
419
502
|
if (typeof facts.source === "object") return skip("source-checkout-unknown", `source checkout unknown: ${facts.source.refusal}`);
|
|
420
503
|
if (facts.source === "source") return skip("source-checkout", "source checkout");
|
|
421
504
|
if (facts.locked) return skip("locked", "locked with git worktree lock");
|
|
422
505
|
if (facts.inUse.length) return skip("in-use", `in use: ${facts.inUse.join("; ")}`);
|
|
423
506
|
if (facts.porcelain === null) return skip("status-unreadable", "git status could not be read");
|
|
424
|
-
|
|
507
|
+
const dirty = excludePodChurn(facts.porcelain).lines.length;
|
|
508
|
+
if (dirty) return skip("dirty", `dirty: ${plural(dirty, "uncommitted or untracked file")}`);
|
|
425
509
|
if (facts.unpushed === null) return skip("unpushed-unchecked", "unpushed commits could not be checked");
|
|
426
510
|
const merged = facts.merge?.merged ? facts.merge : null;
|
|
427
|
-
|
|
511
|
+
const ended = endedPullRequest(facts.pullRequest);
|
|
512
|
+
if (facts.unpushed.length && !merged?.coversUnpushed && ended?.state !== "merged") return skip("unpushed", `unpushed: ${plural(facts.unpushed.length, "commit")} on no remote or other branch`);
|
|
428
513
|
if (facts.submodules) return skip("submodules", "initialized submodules");
|
|
429
|
-
if (merged) return null;
|
|
514
|
+
if (merged || ended) return null;
|
|
430
515
|
const notMerged = facts.merge && !facts.merge.merged ? facts.merge : null;
|
|
431
|
-
|
|
432
|
-
|
|
516
|
+
const details = [notMerged?.detail, pullRequestNote(facts.pullRequest)].filter(Boolean);
|
|
517
|
+
if (olderThan === null) return skip(notMerged?.unknown ? "merge-unknown" : "not-merged", details.join("; ") || "not merged");
|
|
518
|
+
const also = details.map((detail) => `; ${detail}`).join("");
|
|
433
519
|
if (facts.idleDays === null) return skip("last-use-unknown", `recently used: its last use is unknown${also}`);
|
|
434
520
|
if (facts.idleDays < olderThan) return skip("recently-used", `recently used ${facts.idleDays}d ago${also}`);
|
|
435
521
|
return null;
|
|
436
522
|
}
|
|
437
|
-
/** Why gc removes a worktree it did not skip: `merged into origin/main` or `idle 9d`. */
|
|
523
|
+
/** Why gc removes a worktree it did not skip: `merged into origin/main`, `PR #12 closed` or `idle 9d`. */
|
|
438
524
|
function worktreeRemovalReason(candidate) {
|
|
439
|
-
|
|
525
|
+
if (candidate.merge?.merged) return `merged into ${candidate.merge.into}`;
|
|
526
|
+
const ended = endedPullRequest(candidate.pullRequest);
|
|
527
|
+
return ended ? describePullRequest(ended) : `idle ${candidate.idleDays ?? 0}d`;
|
|
528
|
+
}
|
|
529
|
+
/** The pull request note a kept worktree's report line carries, such as `PR #12 merged`, or null. */
|
|
530
|
+
function worktreePullRequestNote(candidate) {
|
|
531
|
+
return pullRequestNote(candidate.pullRequest);
|
|
440
532
|
}
|
|
441
533
|
function lastUsedOf(keys) {
|
|
442
534
|
const times = keys.map(workspaceLastUsed).filter(Number.isFinite);
|
|
@@ -446,8 +538,126 @@ function idleDaysOf(keys, now) {
|
|
|
446
538
|
const last = lastUsedOf(keys);
|
|
447
539
|
return Number.isFinite(last) ? Math.max(0, Math.floor((now - last) / DAY_MS)) : null;
|
|
448
540
|
}
|
|
449
|
-
function
|
|
450
|
-
|
|
541
|
+
function deviceReads() {
|
|
542
|
+
let sims;
|
|
543
|
+
let resolver;
|
|
544
|
+
return {
|
|
545
|
+
sims: () => {
|
|
546
|
+
if (sims === void 0) try {
|
|
547
|
+
sims = listAllIosSims({ timeoutMs: DEVICE_LIST_TIMEOUT_MS });
|
|
548
|
+
} catch {
|
|
549
|
+
sims = null;
|
|
550
|
+
}
|
|
551
|
+
return sims;
|
|
552
|
+
},
|
|
553
|
+
avdSerial: (avdName) => (resolver ??= ownedAvdSerialResolver({ timeoutMs: DEVICE_LIST_TIMEOUT_MS }))(avdName)
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
function inUseOf(keys, checks, devices = deviceReads()) {
|
|
557
|
+
return [.../* @__PURE__ */ new Set([...keys.flatMap((key) => workspaceInUse(key, checks)), ...deviceUseOf(keys, devices)])];
|
|
558
|
+
}
|
|
559
|
+
function deviceUseOf(keys, devices) {
|
|
560
|
+
const reasons = [];
|
|
561
|
+
for (const key of keys) {
|
|
562
|
+
let slots;
|
|
563
|
+
try {
|
|
564
|
+
slots = projectDeviceSlots(getProject(key));
|
|
565
|
+
} catch (error) {
|
|
566
|
+
reasons.push(`its device records cannot be read: ${error.message}`);
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
for (const { platforms } of slots) {
|
|
570
|
+
const ios = platforms.ios;
|
|
571
|
+
if (ios?.owned && ios.deviceUdid) {
|
|
572
|
+
const sims = devices.sims();
|
|
573
|
+
const sim = sims?.find((entry) => entry.udid === ios.deviceUdid);
|
|
574
|
+
if (sims === null) reasons.push(`the state of its owned simulator ${ios.deviceUdid} cannot be read`);
|
|
575
|
+
else if (sim && sim.state !== "Shutdown") reasons.push(`its owned simulator ${sim.name} is ${sim.state}; \`stim stop\` in ${key} shuts it down`);
|
|
576
|
+
}
|
|
577
|
+
const android = platforms.android;
|
|
578
|
+
if (android?.owned && android.avdName) try {
|
|
579
|
+
if (devices.avdSerial(android.avdName).serial) reasons.push(`its owned emulator ${android.avdName} is running; \`stim stop\` in ${key} shuts it down`);
|
|
580
|
+
} catch {
|
|
581
|
+
reasons.push(`the state of its owned emulator ${android.avdName} cannot be read`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
const holders = new Set(keys.map(canonicalPath));
|
|
586
|
+
const now = Date.now();
|
|
587
|
+
for (const { lease } of listLeaseFiles()) if (lease && !leaseIsExpired(lease, now) && holders.has(canonicalPath(lease.holder))) reasons.push(`it holds the ${lease.platform} device lease on ${lease.deviceName ?? lease.id}`);
|
|
588
|
+
return reasons;
|
|
589
|
+
}
|
|
590
|
+
function newestMtime(paths) {
|
|
591
|
+
let newest = null;
|
|
592
|
+
for (const { path, basis } of paths) {
|
|
593
|
+
let mtime;
|
|
594
|
+
try {
|
|
595
|
+
mtime = statSync$1(path).mtimeMs;
|
|
596
|
+
} catch (error) {
|
|
597
|
+
if (error.code === "ENOENT") continue;
|
|
598
|
+
return "unreadable";
|
|
599
|
+
}
|
|
600
|
+
if (!newest || mtime > newest.at) newest = {
|
|
601
|
+
at: mtime,
|
|
602
|
+
basis
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
return newest;
|
|
606
|
+
}
|
|
607
|
+
function logFiles$1(key) {
|
|
608
|
+
const dir = workspaceLogsDir(key);
|
|
609
|
+
try {
|
|
610
|
+
return readdirSync$1(dir).map((name) => ({
|
|
611
|
+
path: join(dir, name),
|
|
612
|
+
basis: "a Stim log write"
|
|
613
|
+
}));
|
|
614
|
+
} catch (error) {
|
|
615
|
+
return error.code === "ENOENT" ? [] : null;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
function worktreeActivityOf(path, keys) {
|
|
619
|
+
const gitDir = getExecutor().runFileQuiet("git", [
|
|
620
|
+
"--no-optional-locks",
|
|
621
|
+
"-C",
|
|
622
|
+
path,
|
|
623
|
+
"rev-parse",
|
|
624
|
+
"--path-format=absolute",
|
|
625
|
+
"--git-dir"
|
|
626
|
+
])?.trim();
|
|
627
|
+
if (!gitDir) return null;
|
|
628
|
+
const paths = [
|
|
629
|
+
{
|
|
630
|
+
path: join(gitDir, "index"),
|
|
631
|
+
basis: "a git index write"
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
path: join(gitDir, "HEAD"),
|
|
635
|
+
basis: "a git HEAD change"
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
path: join(gitDir, "logs", "HEAD"),
|
|
639
|
+
basis: "a git reflog entry"
|
|
640
|
+
}
|
|
641
|
+
];
|
|
642
|
+
for (const key of keys) {
|
|
643
|
+
const logs = logFiles$1(key);
|
|
644
|
+
if (logs === null) return null;
|
|
645
|
+
paths.push({
|
|
646
|
+
path: workspaceStateFile(key),
|
|
647
|
+
basis: "a Stim workspace state write"
|
|
648
|
+
}, ...logs);
|
|
649
|
+
}
|
|
650
|
+
const newest = newestMtime(paths);
|
|
651
|
+
return newest === "unreadable" ? null : newest;
|
|
652
|
+
}
|
|
653
|
+
function graceMsSetting() {
|
|
654
|
+
const { value, error } = machineNumber(GRACE_SETTING, loadConfig$1(), process.env);
|
|
655
|
+
if (error) {
|
|
656
|
+
const fallback = Number(settingDefinition(GRACE_SETTING)?.default);
|
|
657
|
+
console.error(chalk.yellow(`${error} gc uses the default of ${fallback} minutes.`));
|
|
658
|
+
return fallback * MINUTE_MS;
|
|
659
|
+
}
|
|
660
|
+
return (value ?? 0) * MINUTE_MS;
|
|
451
661
|
}
|
|
452
662
|
function porcelainOf(path, gitAnswered) {
|
|
453
663
|
if (gitAnswered === null) return null;
|
|
@@ -456,15 +666,15 @@ function porcelainOf(path, gitAnswered) {
|
|
|
456
666
|
return lines.length ? lines : null;
|
|
457
667
|
}
|
|
458
668
|
function candidateRoots() {
|
|
459
|
-
const registered = Object.keys(loadConfig()?.projects ?? {}).filter(isAbsolute);
|
|
669
|
+
const registered = Object.keys(loadConfig$1()?.projects ?? {}).filter(isAbsolute);
|
|
460
670
|
const recorded = listWorkspaceDirs().flatMap((entry) => entry.projectRoot ? [entry.projectRoot] : []);
|
|
461
|
-
return [.../* @__PURE__ */ new Set([...registered, ...recorded])].filter((root) => existsSync(root)).toSorted();
|
|
671
|
+
return [.../* @__PURE__ */ new Set([...registered, ...recorded])].filter((root) => existsSync$1(root)).toSorted();
|
|
462
672
|
}
|
|
463
|
-
function checkMergeStates(pending, idle,
|
|
673
|
+
function checkMergeStates(pending, idle, grace) {
|
|
464
674
|
const repos = /* @__PURE__ */ new Map();
|
|
465
675
|
for (const entry of pending) repos.set(entry.repo, [...repos.get(entry.repo) ?? [], entry]);
|
|
466
676
|
for (const [repo, entries] of repos) {
|
|
467
|
-
const target = fetchDefaultBranch(repo, now);
|
|
677
|
+
const target = fetchDefaultBranch(repo, grace.now);
|
|
468
678
|
for (const { candidate, facts } of entries) {
|
|
469
679
|
const merge = "error" in target ? {
|
|
470
680
|
merged: false,
|
|
@@ -474,21 +684,28 @@ function checkMergeStates(pending, idle, now) {
|
|
|
474
684
|
const verdict = worktreeSkipReason({
|
|
475
685
|
...facts,
|
|
476
686
|
merge
|
|
477
|
-
}, idle);
|
|
687
|
+
}, idle, grace);
|
|
478
688
|
Object.assign(candidate, {
|
|
479
689
|
merge,
|
|
480
690
|
skipCode: verdict?.code ?? null,
|
|
481
|
-
skipped: verdict?.text ?? null
|
|
691
|
+
skipped: verdict?.text ?? null,
|
|
692
|
+
eligibleAt: verdict?.eligibleAt ?? null
|
|
482
693
|
});
|
|
483
694
|
}
|
|
484
695
|
}
|
|
485
696
|
}
|
|
486
697
|
/**
|
|
487
698
|
* Classifies each Stim-managed linked worktree. A merged one is removable; with `idle`, so is one unused for
|
|
488
|
-
* `olderThan` days.
|
|
699
|
+
* `olderThan` days. Device use is read last, in one pass against one simulator list and one AVD resolver, so
|
|
700
|
+
* the resolver's time budget covers only device reads. Pull requests are asked once per repository, all
|
|
701
|
+
* repositories at once. Merge state is checked only where it decides the verdict, after one fetch per repository.
|
|
489
702
|
*/
|
|
490
|
-
function collectWorktreeSweep({ idle, olderThan, now }) {
|
|
703
|
+
async function collectWorktreeSweep({ idle, olderThan, now }) {
|
|
491
704
|
const days = idle ? olderThan ?? DEFAULT_WORKTREE_IDLE_DAYS : null;
|
|
705
|
+
const grace = {
|
|
706
|
+
ms: graceMsSetting(),
|
|
707
|
+
now
|
|
708
|
+
};
|
|
492
709
|
const groups = /* @__PURE__ */ new Map();
|
|
493
710
|
const outside = [];
|
|
494
711
|
for (const root of candidateRoots()) {
|
|
@@ -499,15 +716,17 @@ function collectWorktreeSweep({ idle, olderThan, now }) {
|
|
|
499
716
|
keys: [root],
|
|
500
717
|
idleDays: null,
|
|
501
718
|
merge: null,
|
|
719
|
+
head: null,
|
|
720
|
+
pullRequest: null,
|
|
502
721
|
skipCode: "not-a-worktree",
|
|
503
|
-
skipped: "not inside a git worktree"
|
|
722
|
+
skipped: "not inside a git worktree",
|
|
723
|
+
eligibleAt: null
|
|
504
724
|
});
|
|
505
725
|
continue;
|
|
506
726
|
}
|
|
507
727
|
groups.set(entry.path, [...groups.get(entry.path) ?? [], root]);
|
|
508
728
|
}
|
|
509
|
-
const
|
|
510
|
-
const pending = [];
|
|
729
|
+
const inspected = [];
|
|
511
730
|
for (const [path, roots] of groups) {
|
|
512
731
|
const entries = listWorktrees(path);
|
|
513
732
|
const entry = matchWorktreeEntry(entries, path);
|
|
@@ -515,7 +734,9 @@ function collectWorktreeSweep({ idle, olderThan, now }) {
|
|
|
515
734
|
const keys = [.../* @__PURE__ */ new Set([...roots, ...reclaimKeys(path)])];
|
|
516
735
|
const idleDays = idleDaysOf(keys, now);
|
|
517
736
|
const linked = !("refusal" in source) && entry !== null && source.path !== entry.path;
|
|
737
|
+
const activity = linked && grace.ms > 0 ? worktreeActivityOf(path, keys) : null;
|
|
518
738
|
const gitAnswered = linked ? hasUncommittedWork(path) : null;
|
|
739
|
+
const head = linked ? resolveFullRef(path, "HEAD") : null;
|
|
519
740
|
const facts = {
|
|
520
741
|
source: "refusal" in source ? source : linked ? "linked" : "source",
|
|
521
742
|
bare: Boolean(entry?.bare),
|
|
@@ -523,36 +744,82 @@ function collectWorktreeSweep({ idle, olderThan, now }) {
|
|
|
523
744
|
porcelain: porcelainOf(path, gitAnswered),
|
|
524
745
|
unpushed: linked ? unpushedCommits(path) : null,
|
|
525
746
|
submodules: linked && hasPopulatedSubmodules(path),
|
|
526
|
-
inUse:
|
|
527
|
-
idleDays,
|
|
528
|
-
merge: null
|
|
529
|
-
};
|
|
530
|
-
const verdict = worktreeSkipReason(facts, days);
|
|
531
|
-
const candidate = {
|
|
532
|
-
path,
|
|
533
|
-
keys,
|
|
747
|
+
inUse: [],
|
|
534
748
|
idleDays,
|
|
535
749
|
merge: null,
|
|
536
|
-
|
|
537
|
-
|
|
750
|
+
pullRequest: null,
|
|
751
|
+
activity
|
|
538
752
|
};
|
|
539
|
-
|
|
753
|
+
inspected.push({
|
|
754
|
+
candidate: {
|
|
755
|
+
path,
|
|
756
|
+
keys,
|
|
757
|
+
idleDays,
|
|
758
|
+
merge: null,
|
|
759
|
+
head,
|
|
760
|
+
pullRequest: null,
|
|
761
|
+
skipCode: null,
|
|
762
|
+
skipped: null,
|
|
763
|
+
eligibleAt: null
|
|
764
|
+
},
|
|
765
|
+
facts,
|
|
766
|
+
repo: "refusal" in source ? null : source.path,
|
|
767
|
+
branch: head && entry?.branch ? entry.branch : null
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
const linked = inspected.filter(({ facts }) => facts.source === "linked");
|
|
771
|
+
for (const { candidate, facts } of linked) facts.inUse = candidate.keys.flatMap((key) => workspaceInUse(key, { managedLocks: true }));
|
|
772
|
+
const devices = deviceReads();
|
|
773
|
+
for (const { candidate, facts } of linked) facts.inUse = [.../* @__PURE__ */ new Set([...facts.inUse, ...deviceUseOf(candidate.keys, devices)])];
|
|
774
|
+
await lookUpPullRequests(inspected);
|
|
775
|
+
const worktrees = [];
|
|
776
|
+
const pending = [];
|
|
777
|
+
for (const { candidate, facts, repo } of inspected) {
|
|
778
|
+
const verdict = worktreeSkipReason(facts, days, grace);
|
|
779
|
+
Object.assign(candidate, {
|
|
780
|
+
pullRequest: facts.pullRequest,
|
|
781
|
+
skipCode: verdict?.code ?? null,
|
|
782
|
+
skipped: verdict?.text ?? null,
|
|
783
|
+
eligibleAt: verdict?.eligibleAt ?? null
|
|
784
|
+
});
|
|
785
|
+
if (verdict && MERGE_DECIDES.has(verdict.code) && repo !== null) pending.push({
|
|
540
786
|
candidate,
|
|
541
787
|
facts,
|
|
542
|
-
repo
|
|
788
|
+
repo
|
|
543
789
|
});
|
|
544
790
|
worktrees.push(candidate);
|
|
545
791
|
}
|
|
546
|
-
checkMergeStates(pending, days,
|
|
792
|
+
checkMergeStates(pending, days, grace);
|
|
547
793
|
const listed = [...worktrees, ...outside].filter((w) => idle || w.skipCode !== "not-a-worktree" && w.skipCode !== "source-checkout");
|
|
548
794
|
return {
|
|
549
795
|
idle: days === null ? null : {
|
|
550
796
|
olderThan: days,
|
|
551
797
|
defaulted: olderThan === null
|
|
552
798
|
},
|
|
799
|
+
graceMs: grace.ms,
|
|
553
800
|
worktrees: listed
|
|
554
801
|
};
|
|
555
802
|
}
|
|
803
|
+
async function lookUpPullRequests(inspected) {
|
|
804
|
+
const repos = /* @__PURE__ */ new Map();
|
|
805
|
+
for (const { candidate, facts, repo, branch } of inspected) {
|
|
806
|
+
if (repo === null || branch === null || candidate.head === null) continue;
|
|
807
|
+
const query = {
|
|
808
|
+
cwd: candidate.path,
|
|
809
|
+
branch,
|
|
810
|
+
head: candidate.head
|
|
811
|
+
};
|
|
812
|
+
repos.set(repo, [...repos.get(repo) ?? [], {
|
|
813
|
+
facts,
|
|
814
|
+
query
|
|
815
|
+
}]);
|
|
816
|
+
}
|
|
817
|
+
const lookup = pullRequestLookups();
|
|
818
|
+
await Promise.all([...repos].map(async ([repo, entries]) => {
|
|
819
|
+
const answers = await lookup(repo, entries.map(({ query }) => query));
|
|
820
|
+
entries.forEach(({ facts }, i) => facts.pullRequest = answers[i] ?? null);
|
|
821
|
+
}));
|
|
822
|
+
}
|
|
556
823
|
async function removeWorktrees(sweep, { now = Date.now() } = {}) {
|
|
557
824
|
let failures = 0;
|
|
558
825
|
for (const candidate of sweep.worktrees) {
|
|
@@ -562,31 +829,51 @@ async function removeWorktrees(sweep, { now = Date.now() } = {}) {
|
|
|
562
829
|
const keys = [.../* @__PURE__ */ new Set([...candidate.keys, ...lockedKeys])];
|
|
563
830
|
const unlocked = keys.filter((key) => !lockedKeys.includes(key));
|
|
564
831
|
const reasons = [...inUseOf(lockedKeys, { managedLocks: false }), ...inUseOf(unlocked, { managedLocks: true })].map((r) => `in use: ${r}`);
|
|
565
|
-
|
|
566
|
-
|
|
832
|
+
const mergedHead = candidate.merge?.merged ? candidate.merge.head : endedPullRequest(candidate.pullRequest) ? candidate.head : null;
|
|
833
|
+
if (mergedHead) {
|
|
834
|
+
if (resolveFullRef(candidate.path, "HEAD") !== mergedHead) reasons.push("its HEAD moved since gc checked it");
|
|
567
835
|
} else {
|
|
568
836
|
const idleDays = idleDaysOf(keys, now);
|
|
569
837
|
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`);
|
|
570
838
|
}
|
|
839
|
+
if (sweep.graceMs > 0) {
|
|
840
|
+
const recent = graceBlocker({
|
|
841
|
+
merge: candidate.merge,
|
|
842
|
+
pullRequest: candidate.pullRequest,
|
|
843
|
+
activity: worktreeActivityOf(candidate.path, keys)
|
|
844
|
+
}, {
|
|
845
|
+
ms: sweep.graceMs,
|
|
846
|
+
now: Date.now()
|
|
847
|
+
});
|
|
848
|
+
if (recent) reasons.push(recent.text);
|
|
849
|
+
}
|
|
571
850
|
kept = reasons;
|
|
572
851
|
return reasons;
|
|
573
852
|
};
|
|
574
853
|
let removed = false;
|
|
854
|
+
let error = null;
|
|
575
855
|
try {
|
|
576
|
-
const
|
|
856
|
+
const ended = endedPullRequest(candidate.pullRequest);
|
|
857
|
+
const mergedHead = candidate.merge?.merged && candidate.merge.coversUnpushed ? candidate.merge.head : ended?.state === "merged" ? candidate.head ?? void 0 : void 0;
|
|
577
858
|
removed = await removeWorktreeTarget(candidate.path, {
|
|
578
859
|
linkedOnly: true,
|
|
579
860
|
guard,
|
|
580
861
|
mergedHead
|
|
581
862
|
});
|
|
582
|
-
} catch (
|
|
583
|
-
|
|
863
|
+
} catch (thrown) {
|
|
864
|
+
error = thrown?.message || String(thrown);
|
|
865
|
+
console.error(chalk.red(`Could not remove ${candidate.path}: ${error}`));
|
|
584
866
|
}
|
|
585
|
-
if (removed)
|
|
586
|
-
|
|
587
|
-
|
|
867
|
+
if (removed) {
|
|
868
|
+
console.log(chalk.green(`Removed the worktree ${candidate.path} (${worktreeRemovalReason(candidate)})`));
|
|
869
|
+
recordGcResult("worktree", "done", candidate.path, { detail: worktreeRemovalReason(candidate) });
|
|
870
|
+
} else if (kept.length) {
|
|
871
|
+
console.log(chalk.yellow(`Kept the worktree ${candidate.path}: ${kept.join("; ")}`));
|
|
872
|
+
recordGcResult("worktree", "kept", candidate.path, { detail: kept.join("; ") });
|
|
873
|
+
} else {
|
|
588
874
|
failures++;
|
|
589
875
|
console.error(chalk.yellow(`Kept the worktree ${candidate.path}; see the lines above for why.`));
|
|
876
|
+
recordGcResult("worktree", "failed", candidate.path, { detail: error ?? "stim worktree remove did not remove it; see the details" });
|
|
590
877
|
}
|
|
591
878
|
}
|
|
592
879
|
return failures;
|
|
@@ -641,7 +928,7 @@ function unresolvedLockLines(locks, slots) {
|
|
|
641
928
|
" run that yourself once you know nothing is building with it"
|
|
642
929
|
];
|
|
643
930
|
}
|
|
644
|
-
function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invalidProjects = [], orphanedWorkspaces = [], parkedSims = [], parkedAvds = [], orphanedDevices = [], unverifiedDevices = [], staleDevices = [], staleDeviceRecords = [], buildLocks = {
|
|
931
|
+
function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invalidProjects = [], orphanedWorkspaces = [], parkedSims = [], parkedAvds = [], orphanedDevices = [], unverifiedDevices = [], staleDevices = [], staleDeviceRecords = [], staleLedgerEntries = [], buildLocks = {
|
|
645
932
|
stale: [],
|
|
646
933
|
live: [],
|
|
647
934
|
unresolved: []
|
|
@@ -657,7 +944,7 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
|
|
|
657
944
|
orphaned: [],
|
|
658
945
|
notices: [],
|
|
659
946
|
deletionSafe: true
|
|
660
|
-
}, caches = [], workspaceOutputs = null, worktreeSweep = null, cacheScope = null, olderThan = null }, { now = Date.now() } = {}) {
|
|
947
|
+
}, caches = [], workspaceOutputs = null, workspaceLogs = [], worktreeSweep = null, cacheScope = null, olderThan = null }, { now = Date.now() } = {}) {
|
|
661
948
|
const lines = [];
|
|
662
949
|
const staleLocks = buildLocks?.stale ?? [];
|
|
663
950
|
const liveLocks = buildLocks?.live ?? [];
|
|
@@ -673,11 +960,13 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
|
|
|
673
960
|
orphanedDevices,
|
|
674
961
|
staleDevices,
|
|
675
962
|
staleDeviceRecords,
|
|
963
|
+
staleLedgerEntries,
|
|
676
964
|
staleLocks,
|
|
677
965
|
staleSlots,
|
|
678
966
|
expiredLeases,
|
|
679
967
|
easSessionSweep.orphaned,
|
|
680
968
|
workspaceOutputs?.workspaces.filter((entry) => entry.willClear) ?? [],
|
|
969
|
+
workspaceLogs.filter((entry) => entry.willTrim),
|
|
681
970
|
worktreeSweep?.worktrees.filter((entry) => !entry.skipped) ?? []
|
|
682
971
|
].every((found) => found.length === 0)) {
|
|
683
972
|
const reasons = [];
|
|
@@ -699,7 +988,7 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
|
|
|
699
988
|
for (const d of orphanedDevices) lines.push(` ${d.kind} ${d.name} (${d.id})${deviceSizeSuffix(d)}`);
|
|
700
989
|
}
|
|
701
990
|
if (unverifiedDevices.length) {
|
|
702
|
-
lines.push(`Unrecognized stim-* devices (${unverifiedDevices.length}) - NOT deleted, because Stim has no record of creating them:`);
|
|
991
|
+
lines.push(`Unrecognized stim-* devices (${unverifiedDevices.length}) - NOT deleted, because this Stim home has no record of creating them:`);
|
|
703
992
|
for (const d of unverifiedDevices) {
|
|
704
993
|
lines.push(` ${d.kind} ${d.name} (${d.id})`);
|
|
705
994
|
lines.push(` ${unverifiedDeviceCommand(d)}`);
|
|
@@ -722,6 +1011,11 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
|
|
|
722
1011
|
}
|
|
723
1012
|
lines.push(" --delete clears the RECORD only; there is no device left to touch.");
|
|
724
1013
|
}
|
|
1014
|
+
if (staleLedgerEntries.length) {
|
|
1015
|
+
lines.push(`Stale device ledger entries (${staleLedgerEntries.length}) - Stim created these devices or browser profiles; they are gone:`);
|
|
1016
|
+
for (const entry of staleLedgerEntries) lines.push(` ${entry.kind} ${entry.id} is not on this machine`);
|
|
1017
|
+
lines.push(" --delete forgets the LEDGER ENTRY only; there is no device left to touch.");
|
|
1018
|
+
}
|
|
725
1019
|
if (easSessionSweep.orphaned.length) {
|
|
726
1020
|
lines.push(`Orphaned EAS sessions (${easSessionSweep.orphaned.length}) - current EAS project only:`);
|
|
727
1021
|
for (const session of easSessionSweep.orphaned) {
|
|
@@ -766,9 +1060,21 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
|
|
|
766
1060
|
lines.push(`Skipped (${skipped.length}) - not classified as dead:`);
|
|
767
1061
|
for (const entry of skipped) lines.push(` ${entry.dir}: ${entry.reason}`);
|
|
768
1062
|
}
|
|
1063
|
+
lines.push(...workspaceLogLines(workspaceLogs));
|
|
769
1064
|
lines.push(...cacheLines(caches, workspaceOutputs));
|
|
770
1065
|
return lines;
|
|
771
1066
|
}
|
|
1067
|
+
function workspaceLogLines(workspaceLogs) {
|
|
1068
|
+
const over = workspaceLogs.filter((w) => w.trimBytes > 0);
|
|
1069
|
+
if (!over.length) return [];
|
|
1070
|
+
const total = workspaceLogs.reduce((n, w) => n + w.bytes, 0);
|
|
1071
|
+
const lines = [`Workspace logs (${formatBytes(total)} in ${workspaceLogs.length} workspace${workspaceLogs.length === 1 ? "" : "s"}) - ${over.length} with a log over twice the 8 MiB cap:`];
|
|
1072
|
+
for (const w of over) {
|
|
1073
|
+
lines.push(` ${formatBytes(w.bytes).padStart(10)} ${w.projectRoot ?? w.dir}`);
|
|
1074
|
+
lines.push(w.willTrim ? ` would TRIM ${formatBytes(w.trimBytes)}: Metro, client and device logs keep their newest 8 MiB` : ` kept: ${w.keptReason}`);
|
|
1075
|
+
}
|
|
1076
|
+
return lines;
|
|
1077
|
+
}
|
|
772
1078
|
function leaseGarbageLines({ expired, kept }) {
|
|
773
1079
|
const lines = [];
|
|
774
1080
|
if (expired.length) {
|
|
@@ -787,7 +1093,7 @@ function leaseGarbageLines({ expired, kept }) {
|
|
|
787
1093
|
return lines;
|
|
788
1094
|
}
|
|
789
1095
|
function deviceSizeSuffix(device) {
|
|
790
|
-
return device.
|
|
1096
|
+
return device.bytes !== void 0 ? ` - ${formatBytes(device.bytes)} on disk` : "";
|
|
791
1097
|
}
|
|
792
1098
|
function namedPortLines(ports = []) {
|
|
793
1099
|
if (!ports.length) return [];
|
|
@@ -855,11 +1161,14 @@ function worktreeSweepLines(sweep) {
|
|
|
855
1161
|
if (!sweep || !sweep.idle && !sweep.worktrees.length) return [];
|
|
856
1162
|
const removable = sweep.worktrees.filter((w) => !w.skipped);
|
|
857
1163
|
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`;
|
|
858
|
-
const lines = [`Linked worktrees (${removable.length} removable, ${sweep.worktrees.length - removable.length} kept) - clean and merged into the default branch${idle}:`];
|
|
1164
|
+
const lines = [`Linked worktrees (${removable.length} removable, ${sweep.worktrees.length - removable.length} kept) - clean and merged into the default branch, or with a merged or closed pull request${idle}:`];
|
|
1165
|
+
const unknown = new Set(sweep.worktrees.flatMap((w) => w.pullRequest && "unavailable" in w.pullRequest ? [w.pullRequest.unavailable] : []));
|
|
1166
|
+
for (const reason of unknown) lines.push(` Pull request state unknown (${reason}); merges are judged from git alone.`);
|
|
859
1167
|
for (const w of sweep.worktrees) {
|
|
860
1168
|
const age = w.idleDays === null ? "" : ` (idle ${w.idleDays}d)`;
|
|
861
1169
|
lines.push(` ${w.path}${age}`);
|
|
862
|
-
|
|
1170
|
+
const note = worktreePullRequestNote(w);
|
|
1171
|
+
lines.push(w.skipped ? ` kept: ${w.skipped}${note && !w.skipped.includes(note) ? ` (${note})` : ""}` : ` would be REMOVED by \`stim worktree remove\`: ${worktreeRemovalReason(w)}`);
|
|
863
1172
|
}
|
|
864
1173
|
if (removable.length) lines.push(" --delete runs it without --force; use, idleness and HEAD are re-checked under its removal locks.");
|
|
865
1174
|
return lines;
|
|
@@ -873,7 +1182,16 @@ function jsonLock({ path, platform, key, pid, projectRoot }) {
|
|
|
873
1182
|
projectRoot
|
|
874
1183
|
};
|
|
875
1184
|
}
|
|
876
|
-
function
|
|
1185
|
+
function jsonPullRequest(lookup) {
|
|
1186
|
+
const pr = lookup && "pullRequest" in lookup ? lookup.pullRequest : null;
|
|
1187
|
+
return pr ? {
|
|
1188
|
+
number: pr.number,
|
|
1189
|
+
state: pr.state,
|
|
1190
|
+
url: pr.url,
|
|
1191
|
+
containsHead: pr.containsHead
|
|
1192
|
+
} : null;
|
|
1193
|
+
}
|
|
1194
|
+
function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [], invalidProjects = [], orphanedWorkspaces = [], worktreeSweep = null, parkedSims = [], parkedAvds = [], orphanedDevices = [], unverifiedDevices = [], staleDevices = [], staleDeviceRecords = [], staleLedgerEntries = [], buildLocks = {
|
|
877
1195
|
stale: [],
|
|
878
1196
|
live: [],
|
|
879
1197
|
unresolved: []
|
|
@@ -889,7 +1207,7 @@ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [],
|
|
|
889
1207
|
orphaned: [],
|
|
890
1208
|
notices: [],
|
|
891
1209
|
deletionSafe: true
|
|
892
|
-
}, workspaceOutputs = null, caches = [] }) {
|
|
1210
|
+
}, workspaceOutputs = null, workspaceLogs = [], caches = [] }) {
|
|
893
1211
|
return {
|
|
894
1212
|
deadProjects: deadProjects.map((path) => ({ path })),
|
|
895
1213
|
invalidProjects: invalidProjects.map((path) => ({ path })),
|
|
@@ -907,9 +1225,12 @@ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [],
|
|
|
907
1225
|
path: w.path,
|
|
908
1226
|
idleDays: w.idleDays,
|
|
909
1227
|
mergedInto: w.merge?.merged ? w.merge.into : null,
|
|
1228
|
+
pullRequest: jsonPullRequest(w.pullRequest),
|
|
1229
|
+
pullRequestUnknown: w.pullRequest && "unavailable" in w.pullRequest ? w.pullRequest.unavailable : null,
|
|
910
1230
|
willRemove: w.skipCode === null,
|
|
911
1231
|
reason: w.skipCode,
|
|
912
|
-
detail: w.skipped ?? worktreeRemovalReason(w)
|
|
1232
|
+
detail: w.skipped ?? worktreeRemovalReason(w),
|
|
1233
|
+
eligibleAt: w.eligibleAt === null ? null : new Date(w.eligibleAt).toISOString()
|
|
913
1234
|
})),
|
|
914
1235
|
parkedSimulators: parkedSims.map(({ udid, name, model, runtime, parkedAt, bytes, listed }) => ({
|
|
915
1236
|
udid,
|
|
@@ -956,6 +1277,10 @@ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [],
|
|
|
956
1277
|
project: r.project,
|
|
957
1278
|
slot: r.slot ?? null
|
|
958
1279
|
})),
|
|
1280
|
+
staleLedgerEntries: staleLedgerEntries.map(({ kind, id }) => ({
|
|
1281
|
+
kind,
|
|
1282
|
+
id
|
|
1283
|
+
})),
|
|
959
1284
|
idleDevices: idleDevices.map(({ kind, id, name, project, slot, lastActivityAt, idleForMs, buildInProgress }) => ({
|
|
960
1285
|
kind,
|
|
961
1286
|
id,
|
|
@@ -1007,6 +1332,15 @@ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [],
|
|
|
1007
1332
|
path: dir,
|
|
1008
1333
|
detail: reason
|
|
1009
1334
|
})),
|
|
1335
|
+
workspaceLogs: workspaceLogs.map((w) => ({
|
|
1336
|
+
dir: w.dir,
|
|
1337
|
+
projectRoot: w.projectRoot,
|
|
1338
|
+
bytes: w.bytes,
|
|
1339
|
+
trimBytes: w.trimBytes,
|
|
1340
|
+
willTrim: w.willTrim,
|
|
1341
|
+
reason: w.keptCode,
|
|
1342
|
+
detail: w.keptReason
|
|
1343
|
+
})),
|
|
1010
1344
|
workspaceBuildOutputs: (workspaceOutputs?.workspaces ?? []).map((w) => ({
|
|
1011
1345
|
dir: w.dir,
|
|
1012
1346
|
projectRoot: w.projectRoot,
|
|
@@ -1028,11 +1362,505 @@ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [],
|
|
|
1028
1362
|
};
|
|
1029
1363
|
}
|
|
1030
1364
|
//#endregion
|
|
1365
|
+
//#region src/commands/gc/ledger.ts
|
|
1366
|
+
/** `sims` must be a complete listing, unavailable simulators included, taken after `ledger` was read. */
|
|
1367
|
+
function findStaleLedgerEntries(ledger, sims) {
|
|
1368
|
+
const listed = new Set(sims.map((sim) => sim.udid));
|
|
1369
|
+
return [...ledger.ios].filter((udid) => !listed.has(udid)).map((id) => ({
|
|
1370
|
+
kind: "ios",
|
|
1371
|
+
id
|
|
1372
|
+
}));
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* `registered` must come from an emulator listing that succeeded after `ledger` was read. A name stays while an
|
|
1376
|
+
* unfinished setup reserves it, or while any AVD root could still hold it.
|
|
1377
|
+
*/
|
|
1378
|
+
function findStaleAndroidLedgerEntries(ledger, registered, config) {
|
|
1379
|
+
const listed = new Set(registered);
|
|
1380
|
+
return [...ledger.android].filter((name) => !listed.has(name) && isStaleAvdName(name, config)).map((id) => ({
|
|
1381
|
+
kind: "android",
|
|
1382
|
+
id
|
|
1383
|
+
}));
|
|
1384
|
+
}
|
|
1385
|
+
function isStaleAvdName(name, config) {
|
|
1386
|
+
try {
|
|
1387
|
+
return !reservedForSetup(name, config) && avdNameAbsent(name);
|
|
1388
|
+
} catch {
|
|
1389
|
+
return false;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
function reservedForSetup(name, config) {
|
|
1393
|
+
return Object.values(config?.projects ?? {}).some((project) => projectDeviceSlots(project).some(({ platforms }) => platforms.android?.setupIncomplete && platforms.android.avdName === name));
|
|
1394
|
+
}
|
|
1395
|
+
function forgetStaleAvdName(name) {
|
|
1396
|
+
let claim = null;
|
|
1397
|
+
try {
|
|
1398
|
+
claim = acquireAvdClaim(name);
|
|
1399
|
+
return withConfigLock$1(() => {
|
|
1400
|
+
if (!isStaleAvdName(name, loadConfig$1())) return false;
|
|
1401
|
+
forgetCreatedDevice("android", name);
|
|
1402
|
+
return true;
|
|
1403
|
+
});
|
|
1404
|
+
} finally {
|
|
1405
|
+
(0, ownership_claim_exports.releaseClaim)(claim);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Browser profiles the ledger lists whose directory is gone while the workspaces directory that held it is
|
|
1410
|
+
* present and the workspace is not a link to a missing volume, so an unmounted volume never reads as a deleted
|
|
1411
|
+
* profile.
|
|
1412
|
+
*/
|
|
1413
|
+
function staleBrowserProfiles(ledger, exists = existsSync) {
|
|
1414
|
+
return [...ledger.web].filter((path) => isStaleProfile(path, exists)).map((id) => ({
|
|
1415
|
+
kind: "web",
|
|
1416
|
+
id
|
|
1417
|
+
}));
|
|
1418
|
+
}
|
|
1419
|
+
function isStaleProfile(path, exists) {
|
|
1420
|
+
const workspace = dirname$1(dirname$1(path));
|
|
1421
|
+
return !exists(path) && exists(dirname$1(workspace)) && !isDanglingLink(workspace);
|
|
1422
|
+
}
|
|
1423
|
+
function isDanglingLink(path) {
|
|
1424
|
+
try {
|
|
1425
|
+
return lstatSync(path).isSymbolicLink() && !existsSync(path);
|
|
1426
|
+
} catch {
|
|
1427
|
+
return false;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
function forgetStaleLedgerEntries(entries) {
|
|
1431
|
+
let failures = 0;
|
|
1432
|
+
for (const entry of entries) {
|
|
1433
|
+
if (entry.kind === "web" && !isStaleProfile(entry.id, existsSync)) continue;
|
|
1434
|
+
try {
|
|
1435
|
+
if (entry.kind === "android") {
|
|
1436
|
+
if (!forgetStaleAvdName(entry.id)) {
|
|
1437
|
+
console.log(chalk.dim(`Kept the ledger entry for android ${entry.id}: the AVD or its setup reappeared`));
|
|
1438
|
+
continue;
|
|
1439
|
+
}
|
|
1440
|
+
} else forgetCreatedDevice(entry.kind, entry.id);
|
|
1441
|
+
console.log(chalk.green(`Forgot the ledger entry for ${entry.kind} ${entry.id} (not on this machine)`));
|
|
1442
|
+
} catch (error) {
|
|
1443
|
+
failures++;
|
|
1444
|
+
console.log(chalk.red(`Could not forget the ledger entry for ${entry.kind} ${entry.id}: ${error?.message || error}`));
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
return failures;
|
|
1448
|
+
}
|
|
1449
|
+
//#endregion
|
|
1450
|
+
//#region src/commands/gc/logs.ts
|
|
1451
|
+
const CAPPED_LOGS = [
|
|
1452
|
+
"metro.ndjson",
|
|
1453
|
+
"client.ndjson",
|
|
1454
|
+
"device.ndjson"
|
|
1455
|
+
];
|
|
1456
|
+
const TRIM_ABOVE_BYTES = 2 * LOG_ROTATE_BYTES;
|
|
1457
|
+
function fileSize(path) {
|
|
1458
|
+
try {
|
|
1459
|
+
const stat = statSync$1(path);
|
|
1460
|
+
return stat.isFile() ? stat.size : 0;
|
|
1461
|
+
} catch {
|
|
1462
|
+
return 0;
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
function cappedFiles(logsDir) {
|
|
1466
|
+
return CAPPED_LOGS.flatMap((name) => {
|
|
1467
|
+
const log = join(logsDir, name);
|
|
1468
|
+
return [{
|
|
1469
|
+
file: log,
|
|
1470
|
+
log
|
|
1471
|
+
}, {
|
|
1472
|
+
file: rotatedLogPath(log),
|
|
1473
|
+
log
|
|
1474
|
+
}];
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
function trimmable(size) {
|
|
1478
|
+
return size > TRIM_ABOVE_BYTES ? size - LOG_ROTATE_BYTES : 0;
|
|
1479
|
+
}
|
|
1480
|
+
function excess(logsDir) {
|
|
1481
|
+
return cappedFiles(logsDir).reduce((sum, { file }) => sum + trimmable(fileSize(file)), 0);
|
|
1482
|
+
}
|
|
1483
|
+
function collectorReason(root) {
|
|
1484
|
+
const platforms = Object.keys(readCollectors(root));
|
|
1485
|
+
return platforms.length ? `a device log collector is recorded for ${platforms.join(", ")}` : null;
|
|
1486
|
+
}
|
|
1487
|
+
function collectWorkspaceLogs({ exclude = [] } = {}) {
|
|
1488
|
+
const mountedVolumes = listMountedVolumes();
|
|
1489
|
+
const entries = [];
|
|
1490
|
+
for (const { dir, projectRoot, problem } of listWorkspaceDirs()) {
|
|
1491
|
+
if (exclude.includes(dir)) continue;
|
|
1492
|
+
if (projectRoot !== null && !isOnMountedVolume(projectRoot, mountedVolumes)) continue;
|
|
1493
|
+
const logsDir = join(dir, "logs");
|
|
1494
|
+
let names;
|
|
1495
|
+
try {
|
|
1496
|
+
names = readdirSync$1(logsDir);
|
|
1497
|
+
} catch {
|
|
1498
|
+
continue;
|
|
1499
|
+
}
|
|
1500
|
+
const bytes = names.reduce((sum, name) => sum + fileSize(join(logsDir, name)), 0);
|
|
1501
|
+
if (bytes === 0) continue;
|
|
1502
|
+
const trimBytes = excess(logsDir);
|
|
1503
|
+
let keptCode = null;
|
|
1504
|
+
let keptReason = null;
|
|
1505
|
+
if (trimBytes > 0) {
|
|
1506
|
+
if (projectRoot === null) {
|
|
1507
|
+
keptCode = "unresolved";
|
|
1508
|
+
keptReason = `workspace directory not resolved: ${problem ?? "unknown project root"}`;
|
|
1509
|
+
} else {
|
|
1510
|
+
const inUse = workspaceInUse(projectRoot);
|
|
1511
|
+
const collector = collectorReason(projectRoot);
|
|
1512
|
+
if (inUse.length) {
|
|
1513
|
+
keptCode = "in-use";
|
|
1514
|
+
keptReason = `in use: ${inUse.join("; ")}`;
|
|
1515
|
+
} else if (collector) {
|
|
1516
|
+
keptCode = "collector";
|
|
1517
|
+
keptReason = collector;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
entries.push({
|
|
1522
|
+
dir,
|
|
1523
|
+
projectRoot,
|
|
1524
|
+
bytes,
|
|
1525
|
+
trimBytes,
|
|
1526
|
+
willTrim: trimBytes > 0 && keptCode === null,
|
|
1527
|
+
keptCode,
|
|
1528
|
+
keptReason
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
return entries;
|
|
1532
|
+
}
|
|
1533
|
+
function trimToNewest({ file, log }, maxBytes) {
|
|
1534
|
+
if (!trimmable(fileSize(file))) return 0;
|
|
1535
|
+
return withDirLock(`${log}.lock`, () => {
|
|
1536
|
+
const { size, atime, mtime } = statSync$1(file);
|
|
1537
|
+
if (!trimmable(size)) return 0;
|
|
1538
|
+
const tail = Buffer.alloc(maxBytes);
|
|
1539
|
+
const fd = openSync$1(file, "r");
|
|
1540
|
+
try {
|
|
1541
|
+
readSync$1(fd, tail, 0, maxBytes, size - maxBytes);
|
|
1542
|
+
} finally {
|
|
1543
|
+
closeSync$1(fd);
|
|
1544
|
+
}
|
|
1545
|
+
const newline = tail.indexOf(10);
|
|
1546
|
+
const kept = newline === -1 ? Buffer.alloc(0) : tail.subarray(newline + 1);
|
|
1547
|
+
const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
1548
|
+
try {
|
|
1549
|
+
writeFileSync$1(tmp, kept);
|
|
1550
|
+
utimesSync(tmp, atime, mtime);
|
|
1551
|
+
renameSync$1(tmp, file);
|
|
1552
|
+
} catch (error) {
|
|
1553
|
+
rmSync$1(tmp, { force: true });
|
|
1554
|
+
throw error;
|
|
1555
|
+
}
|
|
1556
|
+
return size - kept.length;
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
async function trimWorkspaceLogs(entries) {
|
|
1560
|
+
let failures = 0;
|
|
1561
|
+
let trimmed = 0;
|
|
1562
|
+
for (const entry of entries) {
|
|
1563
|
+
const root = entry.projectRoot;
|
|
1564
|
+
if (!entry.willTrim || root === null) {
|
|
1565
|
+
if (entry.keptReason) console.log(chalk.dim(`Kept the logs of ${root ?? entry.dir}: ${entry.keptReason}`));
|
|
1566
|
+
continue;
|
|
1567
|
+
}
|
|
1568
|
+
let run;
|
|
1569
|
+
try {
|
|
1570
|
+
run = await withIdleWorkspace(root, () => {
|
|
1571
|
+
const collector = collectorReason(root);
|
|
1572
|
+
if (collector) return {
|
|
1573
|
+
kept: collector,
|
|
1574
|
+
bytes: 0
|
|
1575
|
+
};
|
|
1576
|
+
return {
|
|
1577
|
+
kept: null,
|
|
1578
|
+
bytes: cappedFiles(join(entry.dir, "logs")).reduce((sum, capped) => sum + trimToNewest(capped, LOG_ROTATE_BYTES), 0)
|
|
1579
|
+
};
|
|
1580
|
+
}, { purpose: "gc" });
|
|
1581
|
+
} catch (error) {
|
|
1582
|
+
failures++;
|
|
1583
|
+
console.log(chalk.red(`Could not trim the logs of ${root}: ${error?.message || String(error)}`));
|
|
1584
|
+
continue;
|
|
1585
|
+
}
|
|
1586
|
+
if (!run.ran || run.value.kept) {
|
|
1587
|
+
const kept = run.ran ? run.value.kept : `in use: ${run.reasons.join("; ")}`;
|
|
1588
|
+
console.log(chalk.yellow(`Kept the logs of ${root}: ${kept}`));
|
|
1589
|
+
continue;
|
|
1590
|
+
}
|
|
1591
|
+
trimmed += run.value.bytes;
|
|
1592
|
+
console.log(chalk.green(`Trimmed the logs of ${root} (${formatBytes(run.value.bytes)})`));
|
|
1593
|
+
}
|
|
1594
|
+
if (trimmed) console.log(chalk.dim(`Trimmed ${formatBytes(trimmed)} of workspace logs.`));
|
|
1595
|
+
return failures;
|
|
1596
|
+
}
|
|
1597
|
+
//#endregion
|
|
1598
|
+
//#region src/commands/gc/inventory.ts
|
|
1599
|
+
const IOS_SIMULATOR_PLATFORM = "com.apple.platform.iphonesimulator";
|
|
1600
|
+
function text(value) {
|
|
1601
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
1602
|
+
}
|
|
1603
|
+
function parseSimRuntimeList(jsonOutput) {
|
|
1604
|
+
const parsed = JSON.parse(jsonOutput);
|
|
1605
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Expected simctl runtime list to return a JSON object.");
|
|
1606
|
+
const runtimes = [];
|
|
1607
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
1608
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) continue;
|
|
1609
|
+
const entry = value;
|
|
1610
|
+
if (entry.platformIdentifier !== IOS_SIMULATOR_PLATFORM) continue;
|
|
1611
|
+
runtimes.push({
|
|
1612
|
+
identifier: text(entry.identifier) ?? key,
|
|
1613
|
+
runtimeIdentifier: text(entry.runtimeIdentifier),
|
|
1614
|
+
version: text(entry.version),
|
|
1615
|
+
build: text(entry.build),
|
|
1616
|
+
sizeBytes: typeof entry.sizeBytes === "number" && Number.isFinite(entry.sizeBytes) ? entry.sizeBytes : null,
|
|
1617
|
+
deletable: entry.deletable === true,
|
|
1618
|
+
lastUsedAt: text(entry.lastUsedAt)
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
return runtimes;
|
|
1622
|
+
}
|
|
1623
|
+
function unlistedRuntimes(jsonOutput, images) {
|
|
1624
|
+
const known = new Set(images.map((image) => image.runtimeIdentifier));
|
|
1625
|
+
const parsed = JSON.parse(jsonOutput);
|
|
1626
|
+
if (!Array.isArray(parsed.runtimes)) throw new Error("Expected simctl list runtimes to return a runtimes array.");
|
|
1627
|
+
return parsed.runtimes.flatMap((value) => {
|
|
1628
|
+
if (value === null || typeof value !== "object") return [];
|
|
1629
|
+
const entry = value;
|
|
1630
|
+
const identifier = text(entry.identifier);
|
|
1631
|
+
if (!identifier || !/\.iOS-/.test(identifier) || known.has(identifier)) return [];
|
|
1632
|
+
return [{
|
|
1633
|
+
identifier,
|
|
1634
|
+
runtimeIdentifier: identifier,
|
|
1635
|
+
version: text(entry.version),
|
|
1636
|
+
build: text(entry.buildversion),
|
|
1637
|
+
sizeBytes: null,
|
|
1638
|
+
deletable: false,
|
|
1639
|
+
lastUsedAt: null
|
|
1640
|
+
}];
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
function modelName(deviceTypeIdentifier) {
|
|
1644
|
+
return deviceTypeIdentifier.replace(/^com\.apple\.CoreSimulator\.SimDeviceType\./, "").replaceAll("-", " ");
|
|
1645
|
+
}
|
|
1646
|
+
function sdkmanagerCommand(pkg) {
|
|
1647
|
+
const tool = androidToolPath("sdkmanager");
|
|
1648
|
+
return `${tool === "sdkmanager" ? tool : JSON.stringify(tool)} --uninstall ${JSON.stringify(pkg)}`;
|
|
1649
|
+
}
|
|
1650
|
+
function buildInventory({ sims, avds, runtimes, systemImages, config, deadProjects = [] }) {
|
|
1651
|
+
const dead = new Set(deadProjects);
|
|
1652
|
+
const assigned = /* @__PURE__ */ new Map();
|
|
1653
|
+
for (const [project, record] of Object.entries(config?.projects ?? {})) {
|
|
1654
|
+
if (dead.has(project)) continue;
|
|
1655
|
+
let slots;
|
|
1656
|
+
try {
|
|
1657
|
+
slots = projectDeviceSlots(record);
|
|
1658
|
+
} catch {
|
|
1659
|
+
continue;
|
|
1660
|
+
}
|
|
1661
|
+
for (const { slot, platforms } of slots) {
|
|
1662
|
+
if (platforms.ios?.deviceUdid) assigned.set(`ios:${platforms.ios.deviceUdid}`, {
|
|
1663
|
+
project,
|
|
1664
|
+
slot
|
|
1665
|
+
});
|
|
1666
|
+
if (platforms.android?.avdName) assigned.set(`android:${platforms.android.avdName}`, {
|
|
1667
|
+
project,
|
|
1668
|
+
slot
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
const parked = /* @__PURE__ */ new Set([...readParked("ios", { config }).map((sim) => `ios:${sim.udid}`), ...readParked("android", { config }).map((avd) => `android:${avd.name}`)]);
|
|
1673
|
+
const owner = (key, name, created) => {
|
|
1674
|
+
const workspace = assigned.get(key);
|
|
1675
|
+
if (workspace && created()) return {
|
|
1676
|
+
owner: "workspace",
|
|
1677
|
+
project: workspace.project,
|
|
1678
|
+
slot: workspace.slot
|
|
1679
|
+
};
|
|
1680
|
+
if (parked.has(key)) return {
|
|
1681
|
+
owner: "parked",
|
|
1682
|
+
project: null,
|
|
1683
|
+
slot: null
|
|
1684
|
+
};
|
|
1685
|
+
if (!name.startsWith("stim-")) return {
|
|
1686
|
+
owner: "user",
|
|
1687
|
+
project: null,
|
|
1688
|
+
slot: null
|
|
1689
|
+
};
|
|
1690
|
+
return {
|
|
1691
|
+
owner: created() ? "orphaned" : "otherStimHome",
|
|
1692
|
+
project: null,
|
|
1693
|
+
slot: null
|
|
1694
|
+
};
|
|
1695
|
+
};
|
|
1696
|
+
const devices = [...sims.map((sim) => ({
|
|
1697
|
+
kind: "ios",
|
|
1698
|
+
id: sim.udid,
|
|
1699
|
+
name: sim.name,
|
|
1700
|
+
model: modelName(sim.deviceTypeIdentifier),
|
|
1701
|
+
runtime: sim.runtime,
|
|
1702
|
+
state: sim.state,
|
|
1703
|
+
lastUsedAt: sim.lastUsedAt ?? null,
|
|
1704
|
+
bytes: sim.dataPathSize ?? null,
|
|
1705
|
+
directory: sim.dataPath ? sim.dataPath.replace(/\/data\/?$/, "") : null,
|
|
1706
|
+
...owner(`ios:${sim.udid}`, sim.name, () => isStimOwnedSim({
|
|
1707
|
+
udid: sim.udid,
|
|
1708
|
+
name: sim.name
|
|
1709
|
+
}))
|
|
1710
|
+
})), ...avds.map((avd) => ({
|
|
1711
|
+
kind: "android",
|
|
1712
|
+
id: avd.name,
|
|
1713
|
+
name: avd.name,
|
|
1714
|
+
model: avd.deviceProfile,
|
|
1715
|
+
runtime: avd.systemImage,
|
|
1716
|
+
state: null,
|
|
1717
|
+
lastUsedAt: avd.lastUsedAt,
|
|
1718
|
+
bytes: null,
|
|
1719
|
+
directory: avd.directory,
|
|
1720
|
+
...owner(`android:${avd.name}`, avd.name, () => isStimOwnedAvd(avd.name))
|
|
1721
|
+
}))];
|
|
1722
|
+
const simsByRuntime = /* @__PURE__ */ new Map();
|
|
1723
|
+
for (const sim of sims) simsByRuntime.set(sim.runtime, (simsByRuntime.get(sim.runtime) ?? 0) + 1);
|
|
1724
|
+
const avdsByImage = /* @__PURE__ */ new Map();
|
|
1725
|
+
for (const avd of avds) if (avd.systemImage) avdsByImage.set(avd.systemImage, (avdsByImage.get(avd.systemImage) ?? 0) + 1);
|
|
1726
|
+
return {
|
|
1727
|
+
devices,
|
|
1728
|
+
runtimes: runtimes.map((runtime) => ({
|
|
1729
|
+
identifier: runtime.identifier,
|
|
1730
|
+
runtimeIdentifier: runtime.runtimeIdentifier,
|
|
1731
|
+
version: runtime.version,
|
|
1732
|
+
build: runtime.build,
|
|
1733
|
+
bytes: runtime.sizeBytes,
|
|
1734
|
+
lastUsedAt: runtime.lastUsedAt,
|
|
1735
|
+
deviceCount: runtime.runtimeIdentifier ? simsByRuntime.get(runtime.runtimeIdentifier) ?? 0 : 0,
|
|
1736
|
+
command: runtime.deletable ? `xcrun simctl runtime delete ${runtime.identifier}` : null
|
|
1737
|
+
})),
|
|
1738
|
+
systemImages: systemImages.map((image) => ({
|
|
1739
|
+
package: image.pkg,
|
|
1740
|
+
directory: image.directory,
|
|
1741
|
+
avdCount: avdsByImage.get(image.pkg) ?? 0,
|
|
1742
|
+
command: sdkmanagerCommand(image.pkg)
|
|
1743
|
+
}))
|
|
1744
|
+
};
|
|
1745
|
+
}
|
|
1746
|
+
function modifiedAt(path) {
|
|
1747
|
+
try {
|
|
1748
|
+
return statSync(path).mtime.toISOString();
|
|
1749
|
+
} catch {
|
|
1750
|
+
return null;
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
function isMissing(error) {
|
|
1754
|
+
const code = error.code;
|
|
1755
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
1756
|
+
}
|
|
1757
|
+
function unreadableNotice(what, error) {
|
|
1758
|
+
const notice = `${what} not listed: ${describeError(error)}`;
|
|
1759
|
+
if (process.platform !== "darwin" || error.code !== "EPERM") return notice;
|
|
1760
|
+
return `${notice}. macOS privacy protection blocked the read; allow ${process.env.STIM_DESKTOP_APP ? "Stim Desktop" : "the app that runs stim"} under System Settings > Privacy & Security > Files and Folders (Removable Volumes for an external disk) or Full Disk Access`;
|
|
1761
|
+
}
|
|
1762
|
+
function listAvdRecords(notices, roots = avdStorageRoots()) {
|
|
1763
|
+
const records = /* @__PURE__ */ new Map();
|
|
1764
|
+
for (const root of roots) {
|
|
1765
|
+
let names;
|
|
1766
|
+
try {
|
|
1767
|
+
names = readdirSync(root);
|
|
1768
|
+
} catch (error) {
|
|
1769
|
+
if (!isMissing(error)) notices.push(unreadableNotice(`AVDs in ${root}`, error));
|
|
1770
|
+
continue;
|
|
1771
|
+
}
|
|
1772
|
+
for (const entry of names) {
|
|
1773
|
+
if (!entry.endsWith(".ini")) continue;
|
|
1774
|
+
const name = entry.slice(0, -4);
|
|
1775
|
+
if (records.has(name)) continue;
|
|
1776
|
+
let ini;
|
|
1777
|
+
try {
|
|
1778
|
+
ini = readFileSync(join$1(root, entry), "utf8");
|
|
1779
|
+
} catch (error) {
|
|
1780
|
+
if (!isMissing(error)) notices.push(unreadableNotice(`AVD ${name}`, error));
|
|
1781
|
+
continue;
|
|
1782
|
+
}
|
|
1783
|
+
let directory = null;
|
|
1784
|
+
let config = "";
|
|
1785
|
+
let unreadable = null;
|
|
1786
|
+
for (const candidate of avdIniPaths(root, ini)) try {
|
|
1787
|
+
config = readFileSync(join$1(candidate, "config.ini"), "utf8");
|
|
1788
|
+
directory = candidate;
|
|
1789
|
+
break;
|
|
1790
|
+
} catch (error) {
|
|
1791
|
+
if (!isMissing(error)) unreadable ??= error;
|
|
1792
|
+
}
|
|
1793
|
+
if (!directory && unreadable) notices.push(unreadableNotice(`details of AVD ${name}`, unreadable));
|
|
1794
|
+
records.set(name, {
|
|
1795
|
+
name,
|
|
1796
|
+
directory,
|
|
1797
|
+
systemImage: parseAvdSystemImage(config),
|
|
1798
|
+
deviceProfile: parseAvdDeviceProfile(config),
|
|
1799
|
+
lastUsedAt: directory ? modifiedAt(join$1(directory, "hardware-qemu.ini")) : null
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
return [...records.values()];
|
|
1804
|
+
}
|
|
1805
|
+
function simctl(args) {
|
|
1806
|
+
return getExecutor().runFile("xcrun", ["simctl", ...args], {
|
|
1807
|
+
timeoutMs: DEVICE_LIST_TIMEOUT_MS,
|
|
1808
|
+
killSignal: "SIGKILL"
|
|
1809
|
+
});
|
|
1810
|
+
}
|
|
1811
|
+
function collectInventory(config, deadProjects) {
|
|
1812
|
+
const notices = [];
|
|
1813
|
+
let sims = [];
|
|
1814
|
+
let runtimes = [];
|
|
1815
|
+
if (process.platform === "darwin") {
|
|
1816
|
+
try {
|
|
1817
|
+
sims = listAllIosSims({ timeoutMs: DEVICE_LIST_TIMEOUT_MS });
|
|
1818
|
+
} catch (error) {
|
|
1819
|
+
notices.push(`simulators not listed: ${describeError(error)}`);
|
|
1820
|
+
}
|
|
1821
|
+
try {
|
|
1822
|
+
runtimes = parseSimRuntimeList(simctl([
|
|
1823
|
+
"runtime",
|
|
1824
|
+
"list",
|
|
1825
|
+
"-j"
|
|
1826
|
+
]));
|
|
1827
|
+
} catch (error) {
|
|
1828
|
+
notices.push(`simulator runtime images not listed: ${describeError(error)}`);
|
|
1829
|
+
}
|
|
1830
|
+
try {
|
|
1831
|
+
runtimes.push(...unlistedRuntimes(simctl([
|
|
1832
|
+
"list",
|
|
1833
|
+
"runtimes",
|
|
1834
|
+
"-j"
|
|
1835
|
+
]), runtimes));
|
|
1836
|
+
} catch (error) {
|
|
1837
|
+
notices.push(`simulator runtimes not listed: ${describeError(error)}`);
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
const imagesRoot = join$1(androidHome(), "system-images");
|
|
1841
|
+
const images = listInstalledSystemImages((error) => notices.push(unreadableNotice("Android system images", error))).map((image) => ({
|
|
1842
|
+
pkg: image.pkg,
|
|
1843
|
+
directory: join$1(imagesRoot, `android-${image.api}`, image.tag, image.arch)
|
|
1844
|
+
}));
|
|
1845
|
+
return {
|
|
1846
|
+
...buildInventory({
|
|
1847
|
+
sims,
|
|
1848
|
+
avds: listAvdRecords(notices),
|
|
1849
|
+
runtimes,
|
|
1850
|
+
systemImages: images,
|
|
1851
|
+
config,
|
|
1852
|
+
deadProjects
|
|
1853
|
+
}),
|
|
1854
|
+
notices
|
|
1855
|
+
};
|
|
1856
|
+
}
|
|
1857
|
+
//#endregion
|
|
1031
1858
|
//#region src/commands/gc.ts
|
|
1032
1859
|
function removeInvalidProjectEntries(invalidProjects) {
|
|
1033
1860
|
for (const path of invalidProjects) {
|
|
1034
1861
|
removeProject(path);
|
|
1035
1862
|
console.log(chalk.green(`Removed the invalid registry entry ${path}`));
|
|
1863
|
+
recordGcResult("project", "done", path);
|
|
1036
1864
|
}
|
|
1037
1865
|
}
|
|
1038
1866
|
async function collectGcReport({ olderThan = null, cache = null, worktrees = false, now = Date.now(), lastTouched = workspaceLastUsed } = {}, deps = {}) {
|
|
@@ -1050,6 +1878,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
|
|
|
1050
1878
|
unverifiedDevices: [],
|
|
1051
1879
|
staleDevices: [],
|
|
1052
1880
|
staleDeviceRecords: [],
|
|
1881
|
+
staleLedgerEntries: [],
|
|
1053
1882
|
buildLocks: {
|
|
1054
1883
|
stale: [],
|
|
1055
1884
|
live: [],
|
|
@@ -1079,13 +1908,14 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
|
|
|
1079
1908
|
olderThan,
|
|
1080
1909
|
now
|
|
1081
1910
|
}) : null,
|
|
1911
|
+
workspaceLogs: [],
|
|
1082
1912
|
worktreeSweep: null,
|
|
1083
1913
|
cacheScope: scope,
|
|
1084
1914
|
olderThan,
|
|
1085
1915
|
all
|
|
1086
1916
|
};
|
|
1087
1917
|
const mountedVolumes = listMountedVolumes();
|
|
1088
|
-
const cfg = loadConfig();
|
|
1918
|
+
const cfg = loadConfig$1();
|
|
1089
1919
|
let easSessionSweep = deps.precollectedEasSessionSweep;
|
|
1090
1920
|
if (!easSessionSweep) try {
|
|
1091
1921
|
easSessionSweep = collectEasSessionSweep(cfg, deps);
|
|
@@ -1118,7 +1948,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
|
|
|
1118
1948
|
else invalidProjects.push(path);
|
|
1119
1949
|
continue;
|
|
1120
1950
|
}
|
|
1121
|
-
if (existsSync(path)) continue;
|
|
1951
|
+
if (existsSync$1(path)) continue;
|
|
1122
1952
|
if (!isOnMountedVolume(path, mountedVolumes)) {
|
|
1123
1953
|
const volume = volumeRootFor(path);
|
|
1124
1954
|
skipped.push({
|
|
@@ -1134,6 +1964,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
|
|
|
1134
1964
|
let unverifiedDevices = [];
|
|
1135
1965
|
let staleDevices = [];
|
|
1136
1966
|
let staleDeviceRecords = [];
|
|
1967
|
+
let staleLedgerEntries = staleBrowserProfiles(readCreatedDevices$1());
|
|
1137
1968
|
let idleDevices = [];
|
|
1138
1969
|
const unsweepableReason = cfg === null ? "no Stim config found" : deviceSweepIsScoped() ? "STIM_HOME scopes this config, but simulators and AVDs are machine-global" : null;
|
|
1139
1970
|
if (unsweepableReason) {
|
|
@@ -1149,8 +1980,14 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
|
|
|
1149
1980
|
} else {
|
|
1150
1981
|
let sims = [];
|
|
1151
1982
|
let simsChecked = true;
|
|
1983
|
+
const ledger = readCreatedDevices$1();
|
|
1152
1984
|
try {
|
|
1153
|
-
|
|
1985
|
+
const allSims = listAllIosSims({
|
|
1986
|
+
timeoutMs: DEVICE_LIST_TIMEOUT_MS,
|
|
1987
|
+
includeUnavailable: true
|
|
1988
|
+
});
|
|
1989
|
+
staleLedgerEntries = [...staleLedgerEntries, ...findStaleLedgerEntries(ledger, allSims)];
|
|
1990
|
+
sims = allSims.filter((sim) => sim.available);
|
|
1154
1991
|
} catch {
|
|
1155
1992
|
simsChecked = false;
|
|
1156
1993
|
deviceSweepNotices.push(`ios device sweep skipped: simulator tooling did not answer within ${DEVICE_LIST_TIMEOUT_MS / 1e3}s`);
|
|
@@ -1170,6 +2007,8 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
|
|
|
1170
2007
|
avdsChecked = false;
|
|
1171
2008
|
deviceSweepNotices.push(`android data sweep skipped: ${error.message}`);
|
|
1172
2009
|
}
|
|
2010
|
+
if (avdsChecked) staleLedgerEntries = [...staleLedgerEntries, ...findStaleAndroidLedgerEntries(ledger, avds, loadConfig$1())];
|
|
2011
|
+
const registeredAvds = new Set(avds);
|
|
1173
2012
|
avds = [.../* @__PURE__ */ new Set([...avds, ...orphanedAvdDirectories.map((entry) => entry.name)])];
|
|
1174
2013
|
const isMounted = (path) => isOnMountedVolume(path, mountedVolumes);
|
|
1175
2014
|
const found = findOrphanedDevices({
|
|
@@ -1177,13 +2016,12 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
|
|
|
1177
2016
|
avds,
|
|
1178
2017
|
config: cfg,
|
|
1179
2018
|
isMounted,
|
|
1180
|
-
deadProjects
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
2019
|
+
deadProjects
|
|
2020
|
+
});
|
|
2021
|
+
unverifiedDevices = found.unverified.flatMap((device) => {
|
|
2022
|
+
const directories = device.kind === "android" && !registeredAvds.has(device.id) ? orphanedAvdDirectories.filter((entry) => entry.name === device.id) : [];
|
|
2023
|
+
return directories.length ? directories.map(({ directory }) => Object.assign({}, device, { directory })) : [device];
|
|
1185
2024
|
});
|
|
1186
|
-
unverifiedDevices = found.unverified;
|
|
1187
2025
|
orphanedDevices = withAndroidAvdSizes(found.orphaned.flatMap((device) => {
|
|
1188
2026
|
const directories = device.kind === "android" ? orphanedAvdDirectories.filter((entry) => entry.name === device.name) : [];
|
|
1189
2027
|
return directories.length ? directories.map((orphanedDirectory) => Object.assign({}, device, { orphanedDirectory })) : [device];
|
|
@@ -1215,6 +2053,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
|
|
|
1215
2053
|
}
|
|
1216
2054
|
const locks = listBuildLocks();
|
|
1217
2055
|
const slots = listBuildSlots();
|
|
2056
|
+
const goneWorkspaceDirs = [...workspaceDirs.orphaned.map((entry) => entry.dir), ...deadProjects.map(workspaceDir)];
|
|
1218
2057
|
const deviceLeases = collectDeviceLeases(now);
|
|
1219
2058
|
return {
|
|
1220
2059
|
skipped,
|
|
@@ -1232,6 +2071,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
|
|
|
1232
2071
|
unverifiedDevices,
|
|
1233
2072
|
staleDevices,
|
|
1234
2073
|
staleDeviceRecords,
|
|
2074
|
+
staleLedgerEntries,
|
|
1235
2075
|
buildLocks: {
|
|
1236
2076
|
stale: locks.filter((l) => !l.alive && !l.unresolved),
|
|
1237
2077
|
live: locks.filter((l) => l.alive),
|
|
@@ -1250,9 +2090,10 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
|
|
|
1250
2090
|
workspaceOutputs: collectWorkspaceOutputs({
|
|
1251
2091
|
olderThan,
|
|
1252
2092
|
now,
|
|
1253
|
-
exclude:
|
|
2093
|
+
exclude: goneWorkspaceDirs
|
|
1254
2094
|
}),
|
|
1255
|
-
|
|
2095
|
+
workspaceLogs: collectWorkspaceLogs({ exclude: goneWorkspaceDirs }),
|
|
2096
|
+
worktreeSweep: await collectWorktreeSweep({
|
|
1256
2097
|
idle: worktrees,
|
|
1257
2098
|
olderThan,
|
|
1258
2099
|
now
|
|
@@ -1341,7 +2182,7 @@ async function sweep(opts, deps) {
|
|
|
1341
2182
|
try {
|
|
1342
2183
|
easSessionSweep = await withProjectLock(projectRoot, () => {
|
|
1343
2184
|
sweepStarted = true;
|
|
1344
|
-
return withRemoteSessionGcLocks(projectRoot, deps, (coordinatedDeps) => Promise.resolve(collectEasSessionSweep(loadConfig(), coordinatedDeps)));
|
|
2185
|
+
return withRemoteSessionGcLocks(projectRoot, deps, (coordinatedDeps) => Promise.resolve(collectEasSessionSweep(loadConfig$1(), coordinatedDeps)));
|
|
1345
2186
|
}, {
|
|
1346
2187
|
waitMs: 0,
|
|
1347
2188
|
ownerPurpose: "EAS orphan sweep",
|
|
@@ -1360,7 +2201,51 @@ async function sweep(opts, deps) {
|
|
|
1360
2201
|
precollectedEasSessionSweep: easSessionSweep
|
|
1361
2202
|
});
|
|
1362
2203
|
}
|
|
2204
|
+
async function pruneDeadProjects(deadProjects) {
|
|
2205
|
+
let deleteFailures = 0;
|
|
2206
|
+
for (const path of deadProjects) {
|
|
2207
|
+
if (existsSync$1(path) || !isOnMountedVolume(path)) {
|
|
2208
|
+
console.log(chalk.yellow(`Kept ${path}: its absence can no longer be confirmed.`));
|
|
2209
|
+
recordGcResult("project", "kept", path, { detail: "its absence can no longer be confirmed" });
|
|
2210
|
+
continue;
|
|
2211
|
+
}
|
|
2212
|
+
const result = await reclaimProject(path).catch((error) => {
|
|
2213
|
+
deleteFailures++;
|
|
2214
|
+
console.log(chalk.red(`Could not prune ${path}; its registry entry was kept: ${error.message}`));
|
|
2215
|
+
recordGcResult("project", "failed", path, { detail: `its registry entry was kept: ${error.message}` });
|
|
2216
|
+
return null;
|
|
2217
|
+
});
|
|
2218
|
+
if (!result) continue;
|
|
2219
|
+
if (result.keptEntry) {
|
|
2220
|
+
console.log(chalk.yellow(`Could not fully prune ${path}; its registry entry was kept.`));
|
|
2221
|
+
recordGcResult("project", "kept", path, { detail: "it could not be fully pruned; its registry entry was kept" });
|
|
2222
|
+
} else {
|
|
2223
|
+
console.log(chalk.green(`Pruned ${path}`));
|
|
2224
|
+
recordGcResult("project", "done", path);
|
|
2225
|
+
}
|
|
2226
|
+
for (const dir of result.removedWorkspaceDirs) console.log(chalk.dim(` removed workspace output ${dir}`));
|
|
2227
|
+
for (const dir of result.failedWorkspaceDirs) {
|
|
2228
|
+
console.log(chalk.red(` could not remove workspace output ${dir}`));
|
|
2229
|
+
recordGcResult("workspaceDirectory", "failed", dir, { detail: `could not remove it while pruning ${path}` });
|
|
2230
|
+
deleteFailures += 1;
|
|
2231
|
+
}
|
|
2232
|
+
if (result.killedPid) console.log(chalk.dim(` killed orphaned Metro pid ${result.killedPid}`));
|
|
2233
|
+
if (result.stoppedSession) console.log(chalk.dim(` stopped remote session ${result.stoppedSession}`));
|
|
2234
|
+
if (result.stoppedTunnel) console.log(chalk.dim(` stopped ${result.stoppedTunnel} tunnel`));
|
|
2235
|
+
for (const name of result.deletedDevices) recordGcResult("device", "done", name);
|
|
2236
|
+
for (const s of result.skippedDevices) {
|
|
2237
|
+
console.log(chalk.yellow(` ${s.name}: ${s.reason}`));
|
|
2238
|
+
recordGcResult("device", result.failedDevices.includes(s) ? "failed" : "kept", s.name, {
|
|
2239
|
+
id: s.udid ?? null,
|
|
2240
|
+
detail: s.reason
|
|
2241
|
+
});
|
|
2242
|
+
}
|
|
2243
|
+
deleteFailures += result.failedDevices.length;
|
|
2244
|
+
}
|
|
2245
|
+
return deleteFailures;
|
|
2246
|
+
}
|
|
1363
2247
|
async function runGcCore(opts, deps) {
|
|
2248
|
+
takeGcResults();
|
|
1364
2249
|
const olderThan = typeof opts.olderThan === "number" ? opts.olderThan : null;
|
|
1365
2250
|
const cache = typeof opts.cache === "string" && opts.cache.trim() ? opts.cache : null;
|
|
1366
2251
|
const report = await collectGcReport({
|
|
@@ -1383,10 +2268,11 @@ async function runGcCore(opts, deps) {
|
|
|
1383
2268
|
const all = report.all;
|
|
1384
2269
|
for (const line of formatGcReport(report)) console.log(line);
|
|
1385
2270
|
const { deadProjects, invalidProjects, orphanedDevices, staleDevices, staleDeviceRecords, buildLocks, buildSlots, deviceLeases, easSessionSweep, caches } = report;
|
|
1386
|
-
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;
|
|
2271
|
+
const actionable = deadProjects.length + invalidProjects.length > 0 || report.orphanedWorkspaces.length > 0 || Boolean(report.workspaceOutputs?.workspaces.some((entry) => entry.willClear)) || report.workspaceLogs.some((entry) => entry.willTrim) || 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 || report.staleLedgerEntries.length > 0 || buildLocks.stale.length > 0 || buildSlots.stale.length > 0 || deviceLeases.expired.length > 0 || easSessionSweep.orphaned.length > 0 || (olderThan !== null || all) && caches.length > 0;
|
|
1387
2272
|
const idle = opts.idle ?? null;
|
|
1388
|
-
const idleFailures = idle === null ? 0 : shutDownIdleDevices(report.idleDevices, idle, () => collectIdleDevices(loadConfig(), listAllIosSims({ timeoutMs: DEVICE_LIST_TIMEOUT_MS }), report.deadProjects)).failures;
|
|
2273
|
+
const idleFailures = idle === null ? 0 : shutDownIdleDevices(report.idleDevices, idle, () => collectIdleDevices(loadConfig$1(), listAllIosSims({ timeoutMs: DEVICE_LIST_TIMEOUT_MS }), report.deadProjects)).failures;
|
|
1389
2274
|
if (idleFailures) process.exitCode = 1;
|
|
2275
|
+
const inventory = opts.json && !opts.delete && idle === null && report.cacheScope === null ? collectInventory(loadConfig$1(), deadProjects) : null;
|
|
1390
2276
|
const payload = (failures) => ({
|
|
1391
2277
|
mode: opts.delete ? "delete" : "dry-run",
|
|
1392
2278
|
idle,
|
|
@@ -1395,7 +2281,9 @@ async function runGcCore(opts, deps) {
|
|
|
1395
2281
|
worktreeSweep: report.worktreeSweep?.idle ?? null,
|
|
1396
2282
|
actionable,
|
|
1397
2283
|
failures,
|
|
1398
|
-
sections: gcReportSections(report)
|
|
2284
|
+
sections: gcReportSections(report),
|
|
2285
|
+
results: takeGcResults(),
|
|
2286
|
+
inventory
|
|
1399
2287
|
});
|
|
1400
2288
|
if (!opts.delete) {
|
|
1401
2289
|
if (all) console.log(chalk.dim("\nDry run. Re-run with --delete to empty the caches above."));
|
|
@@ -1405,34 +2293,13 @@ async function runGcCore(opts, deps) {
|
|
|
1405
2293
|
}
|
|
1406
2294
|
let deleteFailures = idleFailures;
|
|
1407
2295
|
deleteFailures += report.workspaceOutputs ? (await clearWorkspaceOutputs(report.workspaceOutputs, { olderThan })).failures : 0;
|
|
2296
|
+
deleteFailures += await trimWorkspaceLogs(report.workspaceLogs);
|
|
1408
2297
|
deleteFailures += deleteParkedSims(report.parkedSims, deps) + deleteParkedAvds(report.parkedAvds);
|
|
1409
2298
|
removeInvalidProjectEntries(invalidProjects);
|
|
1410
|
-
|
|
1411
|
-
if (existsSync(path) || !isOnMountedVolume(path)) {
|
|
1412
|
-
console.log(chalk.yellow(`Kept ${path}: its absence can no longer be confirmed.`));
|
|
1413
|
-
continue;
|
|
1414
|
-
}
|
|
1415
|
-
const result = await reclaimProject(path).catch((error) => {
|
|
1416
|
-
deleteFailures++;
|
|
1417
|
-
console.log(chalk.red(`Could not prune ${path}; its registry entry was kept: ${error.message}`));
|
|
1418
|
-
return null;
|
|
1419
|
-
});
|
|
1420
|
-
if (!result) continue;
|
|
1421
|
-
if (result.keptEntry) console.log(chalk.yellow(`Could not fully prune ${path}; its registry entry was kept.`));
|
|
1422
|
-
else console.log(chalk.green(`Pruned ${path}`));
|
|
1423
|
-
for (const dir of result.removedWorkspaceDirs) console.log(chalk.dim(` removed workspace output ${dir}`));
|
|
1424
|
-
for (const dir of result.failedWorkspaceDirs) {
|
|
1425
|
-
console.log(chalk.red(` could not remove workspace output ${dir}`));
|
|
1426
|
-
deleteFailures += 1;
|
|
1427
|
-
}
|
|
1428
|
-
if (result.killedPid) console.log(chalk.dim(` killed orphaned Metro pid ${result.killedPid}`));
|
|
1429
|
-
if (result.stoppedSession) console.log(chalk.dim(` stopped remote session ${result.stoppedSession}`));
|
|
1430
|
-
if (result.stoppedTunnel) console.log(chalk.dim(` stopped ${result.stoppedTunnel} tunnel`));
|
|
1431
|
-
for (const s of result.skippedDevices) console.log(chalk.yellow(` ${s.name}: ${s.reason}`));
|
|
1432
|
-
deleteFailures += result.failedDevices.length;
|
|
1433
|
-
}
|
|
2299
|
+
deleteFailures += await pruneDeadProjects(deadProjects);
|
|
1434
2300
|
deleteFailures += await deleteOrphanedWorkspaces(report.orphanedWorkspaces);
|
|
1435
2301
|
deleteFailures += deleteProjectDevices(orphanedDevices, staleDevices, staleDeviceRecords);
|
|
2302
|
+
deleteFailures += forgetStaleLedgerEntries(report.staleLedgerEntries);
|
|
1436
2303
|
for (const lock of buildLocks.stale) {
|
|
1437
2304
|
const cleared = (0, ownership_claim_exports.clearFreeClaimSet)({
|
|
1438
2305
|
root: lock.path,
|
|
@@ -1441,13 +2308,22 @@ async function runGcCore(opts, deps) {
|
|
|
1441
2308
|
if (cleared.status === "held") continue;
|
|
1442
2309
|
if (cleared.status === "refused") {
|
|
1443
2310
|
console.log(chalk.yellow(`Left the build lock at ${lock.path} alone: ${cleared.reason}`));
|
|
2311
|
+
recordGcResult("buildLock", "kept", `${lock.platform} build lock`, {
|
|
2312
|
+
id: lock.path,
|
|
2313
|
+
detail: cleared.reason
|
|
2314
|
+
});
|
|
1444
2315
|
continue;
|
|
1445
2316
|
}
|
|
1446
2317
|
if (cleared.status === "failed") {
|
|
1447
2318
|
deleteFailures++;
|
|
1448
2319
|
console.log(chalk.red(`Failed to clear the build lock at ${lock.path}: ${cleared.reason}`));
|
|
2320
|
+
recordGcResult("buildLock", "failed", `${lock.platform} build lock`, {
|
|
2321
|
+
id: lock.path,
|
|
2322
|
+
detail: cleared.reason
|
|
2323
|
+
});
|
|
1449
2324
|
continue;
|
|
1450
2325
|
}
|
|
2326
|
+
recordGcResult("buildLock", "done", `${lock.platform} build lock`, { id: lock.path });
|
|
1451
2327
|
console.log(chalk.green(`Cleared the ${lock.platform} build lock left by pid ${lock.pid ?? "?"} (${lock.projectRoot || "unrecorded workspace"})`));
|
|
1452
2328
|
}
|
|
1453
2329
|
for (const slot of buildSlots.stale) {
|
|
@@ -1458,21 +2334,36 @@ async function runGcCore(opts, deps) {
|
|
|
1458
2334
|
if (cleared.status === "held") continue;
|
|
1459
2335
|
if (cleared.status === "refused") {
|
|
1460
2336
|
console.log(chalk.yellow(`Left the build slot at ${slot.path} alone: ${cleared.reason}`));
|
|
2337
|
+
recordGcResult("buildSlot", "kept", `build slot ${slot.index ?? "?"}`, {
|
|
2338
|
+
id: slot.path,
|
|
2339
|
+
detail: cleared.reason
|
|
2340
|
+
});
|
|
1461
2341
|
continue;
|
|
1462
2342
|
}
|
|
1463
2343
|
if (cleared.status === "failed") {
|
|
1464
2344
|
deleteFailures++;
|
|
1465
2345
|
console.log(chalk.red(`Failed to clear the build slot at ${slot.path}: ${cleared.reason}`));
|
|
2346
|
+
recordGcResult("buildSlot", "failed", `build slot ${slot.index ?? "?"}`, {
|
|
2347
|
+
id: slot.path,
|
|
2348
|
+
detail: cleared.reason
|
|
2349
|
+
});
|
|
1466
2350
|
continue;
|
|
1467
2351
|
}
|
|
2352
|
+
recordGcResult("buildSlot", "done", `build slot ${slot.index ?? "?"}`, { id: slot.path });
|
|
1468
2353
|
console.log(chalk.green(`Cleared build slot ${slot.index ?? "?"} left by pid ${slot.pid ?? "?"} (${slot.projectRoot || "unrecorded workspace"})`));
|
|
1469
2354
|
}
|
|
1470
2355
|
for (const entry of deviceLeases.expired) try {
|
|
1471
|
-
if (removeExpiredLease(entry))
|
|
1472
|
-
|
|
2356
|
+
if (removeExpiredLease(entry)) {
|
|
2357
|
+
recordGcResult("deviceLease", "done", `${entry.platform} lease on ${entry.id ?? entry.name}`, { id: entry.path });
|
|
2358
|
+
console.log(chalk.green(`Cleared the expired ${entry.platform} device lease on ${entry.id ?? entry.name} (held by ${entry.lease?.holder ?? "an unrecorded workspace"})`));
|
|
2359
|
+
} else console.log(chalk.dim(`${entry.name} is no longer an expired lease; left alone.`));
|
|
1473
2360
|
} catch (err) {
|
|
1474
2361
|
deleteFailures++;
|
|
1475
2362
|
console.log(chalk.red(`Failed to clear the device lease at ${entry.path}: ${err?.message || err}`));
|
|
2363
|
+
recordGcResult("deviceLease", "failed", `${entry.platform} lease on ${entry.id ?? entry.name}`, {
|
|
2364
|
+
id: entry.path,
|
|
2365
|
+
detail: err?.message || String(err)
|
|
2366
|
+
});
|
|
1476
2367
|
}
|
|
1477
2368
|
deleteFailures += await deleteEasSessions(easSessionSweep, deps);
|
|
1478
2369
|
if (report.worktreeSweep) deleteFailures += await removeWorktrees(report.worktreeSweep);
|
|
@@ -1487,7 +2378,7 @@ async function runGcCore(opts, deps) {
|
|
|
1487
2378
|
return payload(deleteFailures);
|
|
1488
2379
|
}
|
|
1489
2380
|
function gcCommand(program) {
|
|
1490
|
-
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,
|
|
2381
|
+
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 or whose pull request was merged or closed, 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 and logs 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, clear the build outputs of every workspace not in use, and trim its Metro, client and device logs to their newest 8 MiB").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) => {
|
|
1491
2382
|
const n = parseInt(v, 10);
|
|
1492
2383
|
if (!Number.isFinite(n) || String(n) !== String(v).trim()) throw new InvalidArgumentError("must be a whole number of days, e.g. --older-than 30");
|
|
1493
2384
|
return n;
|