stim 1.20.1 → 1.21.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 +94 -11
- package/dist/acquire-artifact-CD4lbiWE.mjs +153 -0
- package/dist/act-DHAw2A2S.mjs +2667 -0
- package/dist/activity-BzVfHSrY.mjs +1376 -0
- package/dist/{agent-device-usage-output-VPaOgGaF.mjs → agent-device-usage-output-zh1vhbLp.mjs} +21 -66
- package/dist/{android-CZh6ElG5.mjs → android-BEJ8Jpl7.mjs} +3 -3
- package/dist/{android-CLbBJ2ve.mjs → android-BLgbNqox.mjs} +128 -79
- package/dist/{android-eXfIjKJr.mjs → android-CV_GCELZ.mjs} +5 -5
- package/dist/{android-DE8-JfGW.mjs → android-DUYkNF4u.mjs} +188 -106
- package/dist/{android-cas-BFpS84v2.mjs → android-cas-Ct5b1xCK.mjs} +7 -6
- package/dist/android-cas-compiler.mjs +2 -2
- package/dist/android-project-BCAAmMwz.mjs +12 -0
- package/dist/api.d.mts +740 -8
- package/dist/api.mjs +585 -1
- package/dist/{artifact-Ctb2jpw6.mjs → artifact-B8T3S5gi.mjs} +223 -201
- package/dist/{artifact-DXk_Fle_.mjs → artifact-DAuZ-FNU.mjs} +158 -148
- package/dist/{browser-web-xQWC2RWl.mjs → browser-web-QQzVjz3O.mjs} +28 -26
- package/dist/budget-Bs6SxxzO.mjs +9154 -0
- package/dist/{build-COBA1tBc.mjs → build-CoPzQRNw.mjs} +144 -101
- package/dist/build-cache-DmyR9Zzc.mjs +602 -0
- package/dist/build-plan-CaFskB7_.mjs +188 -0
- package/dist/{cache-manifest-qA3dT2x2.mjs → cache-manifest-DkkqguoK.mjs} +10 -5
- package/dist/cache-manifest.mjs +1 -1
- package/dist/{ccache-CtbqWxIC.mjs → ccache-BJRZ9pG3.mjs} +10 -9
- package/dist/{cdp-Dq_ypAlJ.mjs → cdp-CWmIUMbw.mjs} +18 -5
- package/dist/{chrome-Dp2lMLBe.mjs → chrome-B6tfCta1.mjs} +7 -4
- package/dist/{cli-sGqlPu1n.mjs → cli-0DYf8QhQ.mjs} +20 -20
- package/dist/cli.mjs +1 -1
- package/dist/client-B7QVOlCr.d.mts +253 -0
- package/dist/client-UXD70ZbM.mjs +1406 -0
- package/dist/collector-run.d.mts +4 -39
- package/dist/collector-run.mjs +9 -9
- package/dist/{command-output-CPDq_90S.mjs → command-output-B1UrjZHL.mjs} +1 -1
- package/dist/config-CYcBF478.mjs +949 -0
- package/dist/{created-devices-CXBMtoj5.mjs → created-devices-CqeBlrEn.mjs} +3 -3
- package/dist/{dependency-state-B2m8eqsS.mjs → dependency-state-BbKQW83h.mjs} +20 -5
- package/dist/{detached-entry-BXq4fIl-.mjs → detached-entry-DFRyG1_o.mjs} +1 -1
- package/dist/{dev-client-BqYjMIaC.mjs → dev-client-UOeESKm6.mjs} +2 -2
- package/dist/device-DCxH7_By.mjs +1068 -0
- package/dist/{device-D4DqjSZZ.mjs → device-ExJq8Ei7.mjs} +35 -36
- package/dist/{device-capacity-DrwHwR97.mjs → device-capacity-CMlqEcvw.mjs} +43 -14
- package/dist/device-host-worker.mjs +65 -62
- package/dist/{device-ios-DE8KJd3b.mjs → device-ios-DRUeKaFV.mjs} +49 -49
- package/dist/{device-lease-run-Baelt3nc.mjs → device-lease-run-C8raGFoP.mjs} +14 -26
- package/dist/{device-pool-DtK9S6AD.mjs → device-pool-DMRpVX9a.mjs} +5 -5
- package/dist/doctor-BLHCYxzi.mjs +2 -0
- package/dist/{doctor-Dunu3Nv1.mjs → doctor-DTvaDOda.mjs} +317 -344
- package/dist/{doctor-DiP9HsIP.mjs → doctor-Dhqebm0q.mjs} +183 -91
- package/dist/doctor-fingerprint.mjs +19 -0
- package/dist/{doctor-watchman-XJzT8tK-.mjs → doctor-watchman-Cv1F10Kj.mjs} +40 -23
- package/dist/{eas-build-0gadvrca.mjs → eas-build-BomfRnFT.mjs} +53 -20
- package/dist/{build-plan-CRjAA6Qi.mjs → eas-fallback-DJwZpy0c.mjs} +26 -208
- package/dist/{watchman-CqmaGcIb.mjs → eas-session-ledger-op6A54yi.mjs} +229 -11
- package/dist/{error-diagnostics-BhQtoLrZ.mjs → error-diagnostics-Bf0rZvG9.mjs} +172 -82
- package/dist/exec-H20xSg2T.d.mts +65 -0
- package/dist/exec-_UbtCQcG.mjs +750 -0
- package/dist/gc-C-WwL0yi.mjs +3 -0
- package/dist/{gc-D5cz5G0k.mjs → gc-_-dCXdyL.mjs} +625 -786
- package/dist/git-C3iTsJaZ.mjs +73 -0
- package/dist/{guide-DakjcoUt.mjs → guide-Yqwt2bd6.mjs} +990 -280
- package/dist/{hosted-android-C-D8Dj0_.mjs → hosted-android-Bw-honsd.mjs} +8 -6
- package/dist/{hosted-client--hNFgixu.mjs → hosted-client-DB_gBPfY.mjs} +47 -24
- package/dist/{hosted-ios-Chx9XQyI.mjs → hosted-ios-CUvOf4Vr.mjs} +1 -1
- package/dist/{hosted-logs-Bkz0Hxzh.mjs → hosted-logs-DL_P5AdN.mjs} +77 -43
- package/dist/{hosted-macos-ckTjN-0V.mjs → hosted-macos-QI141yKk.mjs} +21 -15
- package/dist/{hosted-native-3KXZ5-qn.mjs → hosted-native-Ksyjgzq-.mjs} +31 -20
- package/dist/idle-BVeRGroT.d.mts +24 -0
- package/dist/{idle-shutdown-DhAUyf5s.mjs → idle-shutdown-DaKmZi0z.mjs} +11 -10
- package/dist/{ios-BDBN-xpf.mjs → ios-CQ68hG9s.mjs} +8 -8
- package/dist/{ios-o896T9PE.mjs → ios-CpByZy12.mjs} +141 -81
- package/dist/{ios-device-B1B14xND.mjs → ios-device-CXGP1bsD.mjs} +1 -1
- package/dist/{ios-device-BWHKGaJq.mjs → ios-device-CwFMehUS.mjs} +60 -35
- package/dist/{ios-project-BeOXKPt2.mjs → ios-project-WuwwBdvW.mjs} +13 -13
- package/dist/{ios-VGvNdsv2.mjs → ios-qNJt4dFm.mjs} +1 -1
- package/dist/ios-signing-DTTIChGs.mjs +489 -0
- package/dist/{ios-state-BsXMwo5y.mjs → ios-state-DvFX-K_l.mjs} +1 -1
- package/dist/{ios-CQ8bHQXB.mjs → ios-ubFsBzwq.mjs} +129 -86
- package/dist/{js-swap-Z_mSuRrx.mjs → js-swap-Cc1dttps.mjs} +60 -33
- package/dist/{launch-V1zqiOYn.mjs → launch-CD1yVc7U.mjs} +92 -91
- package/dist/{launch-BootbMk4.mjs → launch-Zq2mOSDy.mjs} +87 -545
- package/dist/logs-BS00Anxf.mjs +352 -0
- package/dist/logs-CMm05HxN.mjs +255 -0
- package/dist/macos-B65--pPd.mjs +2 -0
- package/dist/{macos-D7irBW3A.mjs → macos-C-0qJ6zq.mjs} +244 -215
- package/dist/macos-run.mjs +1 -1
- package/dist/maintenance-run.mjs +122 -247
- package/dist/{metro-_ZIE3Axg.mjs → metro-DEXpIhHC.mjs} +71 -17
- package/dist/{metro-gateway-BzlQZQmU.mjs → metro-gateway-BMJNlj3E.mjs} +20 -12
- package/dist/{named-ports-O1TIl7tx.mjs → named-ports-BWTzt6mu.mjs} +8 -64
- package/dist/native-android-JACyjTZs.mjs +844 -0
- package/dist/{native-gradle-inputs-BQnIdAlL.mjs → native-gradle-inputs-Bep9JcSF.mjs} +128 -147
- package/dist/native-inputs-TRTTTRxt.mjs +91 -0
- package/dist/native-run-DY_RLx3C.mjs +277 -0
- package/dist/{native-runtime-D024BAvm.mjs → native-runtime-Dg1Q7tfF.mjs} +10 -10
- package/dist/{native-xcode-inputs-UElfGs59.mjs → native-xcode-inputs-CB2iQeWq.mjs} +28 -20
- package/dist/{native-xcode-ios-CjS-nDVY.mjs → native-xcode-ios-CXX39F7o.mjs} +96 -47
- package/dist/{ndjson-GOnbyvso.mjs → ndjson-CgE_o3Dl.mjs} +3 -3
- package/dist/offload-worker.d.mts +1 -46
- package/dist/offload-worker.mjs +213 -86
- package/dist/{ownership-BrjepDiv.mjs → ownership-DEmM5vhh.mjs} +3 -3
- package/dist/{page-Bv9s2KwY.mjs → page-CPaHeYqj.mjs} +7 -4
- package/dist/paths-BsJMoPP2.mjs +12 -0
- package/dist/paths-DQtTgsBf.mjs +42 -0
- package/dist/{placement-log-BItXET1x.mjs → placement-log-BqGbTa61.mjs} +5 -5
- package/dist/{plan-placement-B466UYMU.mjs → plan-placement-Bwj-IjY1.mjs} +6 -6
- package/dist/{ports-BFRL6JTj.mjs → ports-D-MmCTdv.mjs} +3 -3
- package/dist/prebuild-uD_ipCxF.mjs +449 -0
- package/dist/{project-DX8fXyw_.mjs → project-BZNjmW_8.mjs} +65 -38
- package/dist/{pull-request-vOhLNmIb.mjs → pull-request-Lfetn8Kr.mjs} +5 -18
- package/dist/pull-request-zjCSAryH.d.mts +120 -0
- package/dist/pull-requests.d.mts +1 -119
- package/dist/pull-requests.mjs +1 -1
- package/dist/{react-native-android-CuXM3QS8.mjs → react-native-android-DuzH0cn3.mjs} +83 -53
- package/dist/{gradle-CZZo1TSA.mjs → react-native-build-rhk4smwQ.mjs} +292 -15
- package/dist/react-native-doctor-CQpuIMso.mjs +234 -0
- package/dist/{react-native-ios-DtVO4qf2.mjs → react-native-ios-Bhq1rJeV.mjs} +46 -28
- package/dist/{recordings-Bk-VivUV.mjs → recordings-DYteQSu4.mjs} +12 -9
- package/dist/{reload-D9V7D8gP.mjs → reload-CmTRFJaX.mjs} +95 -49
- package/dist/reload-DzjSl5P_.mjs +2 -0
- package/dist/{remote-CZiTijxG.mjs → remote-ByYkXdGD.mjs} +15 -10
- package/dist/result-DNiS-tTP.mjs +434 -0
- package/dist/{run-ClC8UVB5.mjs → run-D23HmJMs.mjs} +4 -5
- package/dist/selection-CSl9i5qU.mjs +619 -0
- package/dist/{server-bare-dWjAarki.mjs → server-bare-CxTrvDg8.mjs} +3 -4
- package/dist/{server-command-DnJYLnr4.mjs → server-command-CtzloH3v.mjs} +14 -7
- package/dist/server-expo-C-90EhcG.mjs +2 -0
- package/dist/{server-expo-CUuzP9Mp.mjs → server-expo-DT_F_uVI.mjs} +51 -15
- package/dist/{settings-Bbztm4oV.mjs → settings-CsJDEXgK.mjs} +301 -132
- package/dist/{settings-vk3mJGSm.d.mts → settings-DkdmlelE.d.mts} +6 -4
- package/dist/settings-T1gtc3bf.mjs +114 -0
- package/dist/settings-write-HyVZ8eQ8.mjs +359 -0
- package/dist/settings.schema.json +163 -16
- package/dist/{simslim-ChMfe8rg.mjs → simslim-Cgk17SXV.mjs} +14 -13
- package/dist/{slot-launch-CeCRr6MK.mjs → slot-launch-DF8VfT64.mjs} +15 -20
- package/dist/{spawn-entry-Dcb7sDUz.mjs → spawn-entry-jw19m8Nl.mjs} +0 -1
- package/dist/stage-BIS0ydsV.mjs +183 -0
- package/dist/{start-CRkG-mZ7.mjs → start-7lpH7hmN.mjs} +66 -48
- package/dist/{start-mh6bS27z.mjs → start-RChynRDp.mjs} +1 -1
- package/dist/{state-lqxdOH_g.mjs → state-C4ZtUisW.mjs} +41 -14
- package/dist/{state-BUpAmt2r.mjs → state-XMziexv2.mjs} +3 -2
- package/dist/{stats-CaB1MxMi.mjs → stats-53T7OTb9.mjs} +7 -13
- package/dist/stats-report-BFfMpWWF.mjs +16 -0
- package/dist/{status-C7vzkInu.mjs → status-BL7jVyAB.mjs} +538 -467
- package/dist/status-CSi6tlRq.mjs +2 -0
- package/dist/{status-BDGHwkCs.mjs → status-DqEUSSxs.mjs} +6 -4
- package/dist/{status-watch-ZaVqRdtW.mjs → status-watch-GDCfqpTE.mjs} +187 -22
- package/dist/status-worker.d.mts +1 -0
- package/dist/status-worker.mjs +19 -0
- package/dist/stim-desktop-C12we7lz.mjs +112 -0
- package/dist/{stop-BU7JaFCg.mjs → stop-Bk_1qiV6.mjs} +2 -2
- package/dist/{stop-BO80KJoT.mjs → stop-CPzlM1L-.mjs} +138 -99
- package/dist/{stop-Cftw_sXJ.mjs → stop-D7h6jAXQ.mjs} +11 -10
- package/dist/supervisor-run.d.mts +8 -17
- package/dist/supervisor-run.mjs +63 -28
- package/dist/{support-Ca6PZbpr.mjs → support-67vraa1Q.mjs} +18 -11
- package/dist/{support-Co0wk8Cv.mjs → support-Dlu37g6H.mjs} +12 -12
- package/dist/{swiftpm-macos-siFSkmCO.mjs → swiftpm-macos-p8WZbxKU.mjs} +35 -24
- package/dist/toolchain-DhKwp9iI.mjs +2 -0
- package/dist/toolchain-Hp9sCpK4.mjs +873 -0
- package/dist/{trigger-C0MWBi8O.mjs → trigger-BAahxUPq.mjs} +20 -5
- package/dist/trigger-Bf3uDcpv.mjs +2 -0
- package/dist/{warm-progress-5FAXkXIg.mjs → warm-progress-BHX5r0Ei.mjs} +252 -176
- package/dist/{web-BTjzE8mc.mjs → web-CJmIj6AI.mjs} +18 -13
- package/dist/web-DknQETPs.mjs +2 -0
- package/dist/web-run.mjs +16 -17
- package/dist/windows-report.d.mts +1 -0
- package/dist/windows-report.mjs +18 -0
- package/dist/windows-report.ps1 +43 -0
- package/dist/workspace-process-lock-1_1OoVW5.mjs +57 -0
- package/dist/{workspace-state-CvgTFe-h.mjs → workspace-state-Cjuigeeu.mjs} +70 -56
- package/dist/{workspaces-C4kqIOlx.mjs → workspaces-BccMFmGQ.mjs} +1188 -1080
- package/dist/worktree-CK2MpJW5.mjs +2 -0
- package/dist/{worktree-DtRsh6go.mjs → worktree-DQ6Zi2Mv.mjs} +368 -110
- package/dist/xcode-Zlx3qjEs.mjs +896 -0
- package/package.json +7 -5
- package/shim/android-optimizations.gradle +7 -5
- package/dist/activity-BaMpUQ4-.mjs +0 -629
- package/dist/api-run.mjs +0 -296
- package/dist/api-zuX3ynYN.mjs +0 -154
- package/dist/app-install-Cg9YJy3x.mjs +0 -873
- package/dist/artifact-lifecycle-DzPpBYvK.mjs +0 -30
- package/dist/budget-RFbwFpG5.mjs +0 -4109
- package/dist/build-facts-DcLv2HSd.d.mts +0 -111
- package/dist/client-zqjFd79D.mjs +0 -1779
- package/dist/config-D0LhTY3S.mjs +0 -524
- package/dist/device-BgNj-qVj.mjs +0 -290
- package/dist/device-lease-C309csaB.mjs +0 -1228
- package/dist/device-remote-DjsNw8DD.mjs +0 -1272
- package/dist/eas-session-ledger-DJDeL4Uq.mjs +0 -205
- package/dist/exec-B81Qodjn.mjs +0 -254
- package/dist/launch-verify-Dl78EVgR.mjs +0 -428
- package/dist/logs-Bh4eqCCc.mjs +0 -384
- package/dist/macos-kivF8qKz.mjs +0 -2
- package/dist/miss-reason-SuTptY-I.mjs +0 -238
- package/dist/native-android-D94RlJ-X.mjs +0 -226
- package/dist/ndjson-Du3j0AQ9.d.mts +0 -17
- package/dist/plan-Jwu_vr-F.mjs +0 -260
- package/dist/prebuild-DB_zLOLc.mjs +0 -213
- package/dist/preview-BRLFRhTz.mjs +0 -1004
- package/dist/process-identity-Biv3arCO.mjs +0 -40
- package/dist/react-native-build-JUF31S8U.mjs +0 -239
- package/dist/react-native-doctor-BQHqiXaQ.mjs +0 -78
- package/dist/remote-cache-fxcl5C-L.mjs +0 -526
- package/dist/result-BrfgXtdO.mjs +0 -186
- package/dist/server-expo-Dvkd7JcF.mjs +0 -2
- package/dist/settings-BuKMOANA.mjs +0 -402
- package/dist/spawn-claims-Kqiifsk2.mjs +0 -72
- package/dist/stage-BNKKdDNc.mjs +0 -157
- package/dist/stim-desktop-8GQX2OV8.mjs +0 -63
- package/dist/tailnet-D7-tE_ug.mjs +0 -265
- package/dist/toolchain-B12psPA2.mjs +0 -2
- package/dist/toolchain-SqehyPLw.mjs +0 -1657
- package/dist/trigger-SHLOWJ4O.mjs +0 -2
- package/dist/web-CHN5gh34.mjs +0 -2
- package/dist/worktree-Cgnm6vZt.mjs +0 -2
- package/dist/{api-run.d.mts → doctor-fingerprint.d.mts} +0 -0
- package/dist/{errors-DzkMYh8Q.mjs → errors-ccaPc4ox.mjs} +2 -2
|
@@ -0,0 +1,2667 @@
|
|
|
1
|
+
import { n as isTimeoutError, t as getExecutor } from "./exec-_UbtCQcG.mjs";
|
|
2
|
+
import { g as workspaceStateFile$1, m as workspaceLogsDir$1 } from "./paths-DQtTgsBf.mjs";
|
|
3
|
+
import { F as isOnMountedVolume, I as listMountedVolumes, K as projectDeviceSlots, N as directorySizeAsync, P as formatBytes, R as measuredDirectorySizeAsync, _ as loadConfig$1, p as getProject } from "./config-CYcBF478.mjs";
|
|
4
|
+
import { o as formatLongDuration, u as plural } from "./command-output-B1UrjZHL.mjs";
|
|
5
|
+
import { r as readWorkspaceState$1, u as workspaceLastUsed } from "./workspace-state-Cjuigeeu.mjs";
|
|
6
|
+
import { E as maintenanceKeepSetting, J as resolveProjectSettingsAsync, Lt as resolveFullRef, Ot as listWorktrees, Rt as resolveFullRefAsync, St as hasPopulatedSubmodulesAsync, Tt as hasUncommittedWorkAsync, Ut as unpushedCommitsAsync, Vt as sourceCheckoutOf, Xt as removeTemporaryEntry, _t as dirtyPathsAsync, kt as listWorktreesAsync, q as resolveProjectSettings, zt as resolveSourceCheckoutAsync } from "./settings-CsJDEXgK.mjs";
|
|
7
|
+
import { i as gitQuietAsync, r as gitQuiet } from "./git-C3iTsJaZ.mjs";
|
|
8
|
+
import { i as findProjectRoot$1 } from "./project-BZNjmW_8.mjs";
|
|
9
|
+
import { a as unregister, n as readManifest } from "./cache-manifest-DkkqguoK.mjs";
|
|
10
|
+
import { A as workspaceBuildInProgress, E as collectOwnedDeviceActivityAsync, St as deleteProjectDevices, T as collectOwnedDeviceActivity, Tt as deviceSweepIsScoped, X as emptyCaches, _r as phase, br as discoverCaches, bt as deleteParkedAvds, ct as deleteGradleVersion, d as trimmableCaches, f as stopOwnedMetro, fr as memorySweepIsScoped, gr as stopIdleGradleDaemons, gt as DEVICE_LIST_TIMEOUT_MS, i as enforceBudget, kt as findStaleProjectDevices, nt as planCacheEmptying, o as machineNumber, pt as waitForGradleExit, r as cacheLabel, sr as collectMemoryReport, st as collectGradleVersions, u as resolveBudget, vt as collectParkedAvds, xr as pruneCache, xt as deleteParkedSims, yt as collectParkedSims } from "./budget-Bs6SxxzO.mjs";
|
|
11
|
+
import { t as canonicalPath } from "./paths-BsJMoPP2.mjs";
|
|
12
|
+
import { B as takeGcResults, a as collectWorkspaceOutputs, c as listWorkspaceDirs, f as withIdleWorkspace, i as collectOrphanedWorkspaces, o as deleteOrphanedWorkspaces, p as workspaceInUse, r as clearWorkspaceOutputs, s as isInsideWorkspaces, z as recordGcResult } from "./workspaces-BccMFmGQ.mjs";
|
|
13
|
+
import { N as listBuildLocks, b as leaseIsExpired, g as workspaceActivity, x as listLeaseFiles } from "./activity-BzVfHSrY.mjs";
|
|
14
|
+
import { H as ownedAvdSerialResolver, M as listAvds, U as ownedAvdSerialResolverAsync, gt as readParked, z as ownedAvdDirectory } from "./android-DUYkNF4u.mjs";
|
|
15
|
+
import { d as listAllIosSims, f as listAllIosSimsAsync } from "./ios-CpByZy12.mjs";
|
|
16
|
+
import { s as metroStoreRoot } from "./errors-ccaPc4ox.mjs";
|
|
17
|
+
import { t as resolveMaintenanceSettings } from "./settings-Djl-mpjX.mjs";
|
|
18
|
+
import { A as lowestFree, C as measurePressure, D as diskPressureLevel, O as levelFloors, S as measureDisk, a as matchWorktreeEntry, c as reclaimKeys, i as excludeWatchmanCookies, j as protectWindowMs, m as removeWorktreeTarget, o as nestedWorktrees, r as excludePodChurn, s as porcelainPath, w as measurePressureAsync, x as cacheBlocked } from "./worktree-DQ6Zi2Mv.mjs";
|
|
19
|
+
import { c as FETCH_FRESH_MS, d as localDefaultBranchAsync, l as fetchDefaultBranchAsync, o as recordPullRequestLookup, p as mergeStateAsync, u as lastFetchedAtAsync } from "./warm-progress-BHX5r0Ei.mjs";
|
|
20
|
+
import { n as describePullRequest, o as pullRequestLookups, r as endedPullRequest } from "./pull-request-Lfetn8Kr.mjs";
|
|
21
|
+
import { existsSync, lstatSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
24
|
+
import { buildCacheRoot, configDir, metroCacheRoot } from "@stim-cli/core";
|
|
25
|
+
import { LAST_BUILD_KEYS, MAINTENANCE_LOG_NAME, loadConfig, readBuildHistory, readMaintenanceState, readPullRequestCache, settingDefinition, sharedCompilationCache } from "@stim-cli/core/state";
|
|
26
|
+
import { isAbsolute as isAbsolute$1, join as join$1 } from "path";
|
|
27
|
+
import { readClaimSet } from "@stim-cli/core/ownership-claim";
|
|
28
|
+
import chalk from "chalk";
|
|
29
|
+
import { existsSync as existsSync$1, readdirSync as readdirSync$1, statSync as statSync$1 } from "fs";
|
|
30
|
+
import { lstat, readdir, realpath, rename, stat } from "node:fs/promises";
|
|
31
|
+
//#region src/commands/gc/worktrees.ts
|
|
32
|
+
const DEFAULT_WORKTREE_IDLE_DAYS = 7;
|
|
33
|
+
const DIRTY_PATHS_SHOWN = 3;
|
|
34
|
+
const DAY_MS = 864e5;
|
|
35
|
+
const MINUTE_MS = 6e4;
|
|
36
|
+
const GRACE_SETTING = "gc.worktreeGraceMinutes";
|
|
37
|
+
const NO_OWN_COMMITS_PRESSURE_FLOOR_MS = 5 * MINUTE_MS;
|
|
38
|
+
const PULL_REQUEST_NOT_CHECKED = "not checked since the last maintenance worktree check";
|
|
39
|
+
const WORKTREE_CHECK_SETTING = "maintenance.worktreeCheckMinutes";
|
|
40
|
+
const MERGE_DECIDES = /* @__PURE__ */ new Set([
|
|
41
|
+
"unpushed",
|
|
42
|
+
"not-merged",
|
|
43
|
+
"last-use-unknown",
|
|
44
|
+
"recently-used"
|
|
45
|
+
]);
|
|
46
|
+
function skip(code, text) {
|
|
47
|
+
return {
|
|
48
|
+
code,
|
|
49
|
+
text
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function pullRequestNote(lookup) {
|
|
53
|
+
const pr = lookup && "pullRequest" in lookup ? lookup.pullRequest : null;
|
|
54
|
+
if (!pr) return null;
|
|
55
|
+
return pr.containsHead ? describePullRequest(pr) : `${describePullRequest(pr)}, and HEAD has commits it does not`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Why gc keeps a worktree, or null when it removes it. A worktree that would be removed is still kept while its last
|
|
59
|
+
* activity, or its merge into the default branch, is less than `grace.ms` old, and kept when that activity is unknown.
|
|
60
|
+
* A worktree with no commits of its own beyond the default branch is removable when idle for `olderThan` days, by its
|
|
61
|
+
* last Stim use or, when no Stim use is recorded, its last activity; with `noOwnCommitsIdleMs`, as under disk
|
|
62
|
+
* pressure, once its last activity is that old.
|
|
63
|
+
*/
|
|
64
|
+
function worktreeSkipReason(facts, olderThan, grace = {
|
|
65
|
+
ms: 0,
|
|
66
|
+
now: 0
|
|
67
|
+
}, noOwnCommitsIdleMs = null) {
|
|
68
|
+
return removalBlocker(facts, olderThan, grace.now, noOwnCommitsIdleMs) ?? graceBlocker(facts, grace);
|
|
69
|
+
}
|
|
70
|
+
function noOwnCommitsActivityBlocker(activity, idleMs, now) {
|
|
71
|
+
if (!activity) return skip("activity-unknown", "its last activity could not be read");
|
|
72
|
+
const eligibleAt = activity.at + idleMs;
|
|
73
|
+
if (now >= eligibleAt) return null;
|
|
74
|
+
const ago = formatLongDuration(Math.max(0, now - activity.at));
|
|
75
|
+
return {
|
|
76
|
+
code: "recent-activity",
|
|
77
|
+
text: `recent activity: ${activity.basis} ${ago} ago; removable after ${new Date(eligibleAt).toISOString()}`,
|
|
78
|
+
eligibleAt
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function hasNoOwnCommits(merge) {
|
|
82
|
+
return merge?.merged === false && merge.noOwnCommits === true;
|
|
83
|
+
}
|
|
84
|
+
function activityIdleDays(activity, now) {
|
|
85
|
+
return Math.max(0, Math.floor((now - activity.at) / DAY_MS));
|
|
86
|
+
}
|
|
87
|
+
function graceBlocker(facts, grace) {
|
|
88
|
+
if (grace.ms <= 0) return null;
|
|
89
|
+
if (!facts.activity) return skip("activity-unknown", "its last activity could not be read");
|
|
90
|
+
const merged = facts.merge?.merged ? facts.merge : null;
|
|
91
|
+
const ended = endedPullRequest(facts.pullRequest);
|
|
92
|
+
const events = [facts.activity];
|
|
93
|
+
if (merged) events.push({
|
|
94
|
+
at: merged.mergedAt,
|
|
95
|
+
basis: `merged into ${merged.into}`
|
|
96
|
+
});
|
|
97
|
+
if (ended?.endedAt) events.push({
|
|
98
|
+
at: ended.endedAt,
|
|
99
|
+
basis: describePullRequest(ended)
|
|
100
|
+
});
|
|
101
|
+
const latest = events.reduce((a, b) => b.at > a.at ? b : a);
|
|
102
|
+
const eligibleAt = latest.at + grace.ms;
|
|
103
|
+
if (grace.now >= eligibleAt) return null;
|
|
104
|
+
const ago = formatLongDuration(Math.max(0, grace.now - latest.at));
|
|
105
|
+
return {
|
|
106
|
+
code: "recent-activity",
|
|
107
|
+
text: `recent activity: ${latest.basis} ${ago} ago; removable after ${new Date(eligibleAt).toISOString()}`,
|
|
108
|
+
eligibleAt
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function removalBlocker(facts, olderThan, now, noOwnCommitsIdleMs) {
|
|
112
|
+
if (facts.bare) return skip("bare-repository", "bare repository");
|
|
113
|
+
if (typeof facts.source === "object") return skip("source-checkout-unknown", `source checkout unknown: ${facts.source.refusal}`);
|
|
114
|
+
if (facts.source === "source") return skip("source-checkout", "source checkout");
|
|
115
|
+
if (facts.locked) return skip("locked", "locked with git worktree lock");
|
|
116
|
+
if (facts.nested?.length) return skip("nested-worktree", `contains the worktree ${facts.nested.join(", ")}`);
|
|
117
|
+
if (facts.inUse.length) return skip("in-use", `in use: ${facts.inUse.join("; ")}`);
|
|
118
|
+
if (facts.porcelain === null) return skip("status-unreadable", "git status could not be read");
|
|
119
|
+
const dirty = excludePodChurn(excludeWatchmanCookies(facts.porcelain).lines).lines;
|
|
120
|
+
if (dirty.length) {
|
|
121
|
+
const paths = dirty.slice(0, DIRTY_PATHS_SHOWN).flatMap((line) => porcelainPath(line) ?? []);
|
|
122
|
+
const more = dirty.length > DIRTY_PATHS_SHOWN ? ", ..." : "";
|
|
123
|
+
return skip("dirty", `dirty: ${plural(dirty.length, "uncommitted or untracked file")} (${paths.join(", ")}${more})`);
|
|
124
|
+
}
|
|
125
|
+
if (facts.unpushed === null) return skip("unpushed-unchecked", "unpushed commits could not be checked");
|
|
126
|
+
const merged = facts.merge?.merged ? facts.merge : null;
|
|
127
|
+
const ended = endedPullRequest(facts.pullRequest);
|
|
128
|
+
if (facts.unpushed.length && !merged?.coversUnpushed && ended?.state !== "merged") return skip("unpushed", `unpushed: ${plural(facts.unpushed.length, "commit")} on no remote or other branch`);
|
|
129
|
+
if (facts.submodules === null) return skip("submodules", "initialized submodules could not be checked");
|
|
130
|
+
if (facts.submodules) return skip("submodules", "initialized submodules");
|
|
131
|
+
if (merged || ended) return null;
|
|
132
|
+
const notMerged = facts.merge && !facts.merge.merged ? facts.merge : null;
|
|
133
|
+
const details = [notMerged?.detail, pullRequestNote(facts.pullRequest)].filter(Boolean);
|
|
134
|
+
const noOwnCommits = hasNoOwnCommits(notMerged);
|
|
135
|
+
if (olderThan === null) {
|
|
136
|
+
if (noOwnCommits && noOwnCommitsIdleMs !== null) return noOwnCommitsActivityBlocker(facts.activity, noOwnCommitsIdleMs, now);
|
|
137
|
+
return skip(notMerged?.unknown ? "merge-unknown" : "not-merged", details.join("; ") || "not merged");
|
|
138
|
+
}
|
|
139
|
+
const also = details.map((detail) => `; ${detail}`).join("");
|
|
140
|
+
if (facts.idleDays === null && noOwnCommits && facts.activity) {
|
|
141
|
+
const days = activityIdleDays(facts.activity, now);
|
|
142
|
+
if (days < olderThan) return skip("recently-used", `recently active: ${facts.activity.basis} ${days}d ago${also}`);
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
if (facts.idleDays === null) return skip("last-use-unknown", `recently used: its last use is unknown${also}`);
|
|
146
|
+
if (facts.idleDays < olderThan) return skip("recently-used", `recently used ${facts.idleDays}d ago${also}`);
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Why gc removes a worktree it did not skip: `merged into origin/main`, `PR #12 closed`, `idle 9d`,
|
|
151
|
+
* `no commits of its own beyond origin/main, idle 9d`, or, under disk pressure,
|
|
152
|
+
* `no commits of its own beyond origin/main, inactive for 12m under disk pressure`.
|
|
153
|
+
*/
|
|
154
|
+
function worktreeRemovalReason(candidate, sweep) {
|
|
155
|
+
const { merge, activity } = candidate;
|
|
156
|
+
if (merge?.merged) return `merged into ${merge.into}`;
|
|
157
|
+
const ended = endedPullRequest(candidate.pullRequest);
|
|
158
|
+
if (ended) return describePullRequest(ended);
|
|
159
|
+
if (merge && hasNoOwnCommits(merge)) {
|
|
160
|
+
if (sweep.noOwnCommitsIdleMs !== null && activity) return `${merge.detail}, inactive for ${formatLongDuration(Math.max(0, sweep.now - activity.at))} under disk pressure`;
|
|
161
|
+
const days = candidate.idleDays ?? (activity ? activityIdleDays(activity, sweep.now) : null);
|
|
162
|
+
return days === null ? merge.detail : `${merge.detail}, idle ${days}d`;
|
|
163
|
+
}
|
|
164
|
+
return `idle ${candidate.idleDays ?? 0}d`;
|
|
165
|
+
}
|
|
166
|
+
/** The pull request note a kept worktree's report line carries, such as `PR #12 merged`, or null. */
|
|
167
|
+
function worktreePullRequestNote(candidate) {
|
|
168
|
+
return pullRequestNote(candidate.pullRequest);
|
|
169
|
+
}
|
|
170
|
+
function lastUsedOf(keys) {
|
|
171
|
+
const times = keys.map(workspaceLastUsed).filter(Number.isFinite);
|
|
172
|
+
return times.length ? Math.max(...times) : NaN;
|
|
173
|
+
}
|
|
174
|
+
function idleDaysOf(keys, now) {
|
|
175
|
+
const last = lastUsedOf(keys);
|
|
176
|
+
return Number.isFinite(last) ? Math.max(0, Math.floor((now - last) / DAY_MS)) : null;
|
|
177
|
+
}
|
|
178
|
+
function deviceReads() {
|
|
179
|
+
let sims;
|
|
180
|
+
let resolver;
|
|
181
|
+
return {
|
|
182
|
+
sims: () => {
|
|
183
|
+
if (sims === void 0) try {
|
|
184
|
+
sims = listAllIosSims({ timeoutMs: DEVICE_LIST_TIMEOUT_MS });
|
|
185
|
+
} catch {
|
|
186
|
+
sims = null;
|
|
187
|
+
}
|
|
188
|
+
return sims;
|
|
189
|
+
},
|
|
190
|
+
avdSerial: (avdName) => (resolver ??= ownedAvdSerialResolver({ timeoutMs: DEVICE_LIST_TIMEOUT_MS }))(avdName)
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
async function reportDeviceReads(keys) {
|
|
194
|
+
const slots = keys.flatMap((key) => {
|
|
195
|
+
try {
|
|
196
|
+
return projectDeviceSlots(getProject(key));
|
|
197
|
+
} catch {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
const requestedSims = new Set(slots.flatMap(({ platforms }) => {
|
|
202
|
+
const ios = platforms.ios;
|
|
203
|
+
return ios?.owned && ios.deviceUdid ? [ios.deviceUdid] : [];
|
|
204
|
+
}));
|
|
205
|
+
let sims = null;
|
|
206
|
+
if (requestedSims.size) try {
|
|
207
|
+
sims = await listAllIosSimsAsync({ timeoutMs: DEVICE_LIST_TIMEOUT_MS });
|
|
208
|
+
} catch {}
|
|
209
|
+
const names = new Set(slots.flatMap(({ platforms }) => {
|
|
210
|
+
const android = platforms.android;
|
|
211
|
+
return android?.owned && android.avdName ? [android.avdName] : [];
|
|
212
|
+
}));
|
|
213
|
+
const avds = /* @__PURE__ */ new Map();
|
|
214
|
+
const resolver = ownedAvdSerialResolverAsync({ timeoutMs: DEVICE_LIST_TIMEOUT_MS });
|
|
215
|
+
for (const name of names) try {
|
|
216
|
+
avds.set(name, { value: await resolver(name) });
|
|
217
|
+
} catch (error) {
|
|
218
|
+
avds.set(name, { error });
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
sims: (udid) => requestedSims.has(udid) || sims?.some((sim) => sim.udid === udid) ? sims : null,
|
|
222
|
+
avdSerial: (name) => {
|
|
223
|
+
const result = avds.get(name);
|
|
224
|
+
if (!result) throw new Error("The workspace device record changed during inspection.");
|
|
225
|
+
if ("error" in result) throw result.error;
|
|
226
|
+
return result.value;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
function inUseOf(keys, checks, devices = deviceReads()) {
|
|
231
|
+
return [.../* @__PURE__ */ new Set([...keys.flatMap((key) => workspaceInUse(key, checks)), ...deviceUseOf(keys, devices)])];
|
|
232
|
+
}
|
|
233
|
+
function deviceUseOf(keys, devices) {
|
|
234
|
+
const reasons = [];
|
|
235
|
+
for (const key of keys) {
|
|
236
|
+
let slots;
|
|
237
|
+
try {
|
|
238
|
+
slots = projectDeviceSlots(getProject(key));
|
|
239
|
+
} catch (error) {
|
|
240
|
+
reasons.push(`its device records cannot be read: ${error.message}`);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
for (const { platforms } of slots) {
|
|
244
|
+
const ios = platforms.ios;
|
|
245
|
+
if (ios?.owned && ios.deviceUdid) {
|
|
246
|
+
const sims = devices.sims(ios.deviceUdid);
|
|
247
|
+
const sim = sims?.find((entry) => entry.udid === ios.deviceUdid);
|
|
248
|
+
if (sims === null) reasons.push(`the state of its owned simulator ${ios.deviceUdid} cannot be read`);
|
|
249
|
+
else if (sim && sim.state !== "Shutdown") reasons.push(`its owned simulator ${sim.name} is ${sim.state}; \`stim stop\` in ${key} shuts it down`);
|
|
250
|
+
}
|
|
251
|
+
const android = platforms.android;
|
|
252
|
+
if (android?.owned && android.avdName) try {
|
|
253
|
+
if (devices.avdSerial(android.avdName).serial) reasons.push(`its owned emulator ${android.avdName} is running; \`stim stop\` in ${key} shuts it down`);
|
|
254
|
+
} catch {
|
|
255
|
+
reasons.push(`the state of its owned emulator ${android.avdName} cannot be read`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const holders = new Set(keys.map(canonicalPath));
|
|
260
|
+
const now = Date.now();
|
|
261
|
+
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}`);
|
|
262
|
+
return reasons;
|
|
263
|
+
}
|
|
264
|
+
function newestMtime(paths) {
|
|
265
|
+
let newest = null;
|
|
266
|
+
for (const { path, basis } of paths) {
|
|
267
|
+
let mtime;
|
|
268
|
+
try {
|
|
269
|
+
mtime = statSync$1(path).mtimeMs;
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if (error.code === "ENOENT") continue;
|
|
272
|
+
return "unreadable";
|
|
273
|
+
}
|
|
274
|
+
if (!newest || mtime > newest.at) newest = {
|
|
275
|
+
at: mtime,
|
|
276
|
+
basis
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
return newest;
|
|
280
|
+
}
|
|
281
|
+
function logFiles$1(key) {
|
|
282
|
+
const dir = workspaceLogsDir$1(key);
|
|
283
|
+
try {
|
|
284
|
+
return readdirSync$1(dir).filter((name) => !name.startsWith(MAINTENANCE_LOG_NAME)).map((name) => ({
|
|
285
|
+
path: join$1(dir, name),
|
|
286
|
+
basis: "a Stim log write"
|
|
287
|
+
}));
|
|
288
|
+
} catch (error) {
|
|
289
|
+
return error.code === "ENOENT" ? [] : null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function worktreeActivityOf(path, keys) {
|
|
293
|
+
const gitDir = gitQuiet(path, [
|
|
294
|
+
"rev-parse",
|
|
295
|
+
"--path-format=absolute",
|
|
296
|
+
"--git-dir"
|
|
297
|
+
])?.trim();
|
|
298
|
+
return activityFromGitDir(gitDir, keys);
|
|
299
|
+
}
|
|
300
|
+
async function worktreeActivityOfAsync(path, keys) {
|
|
301
|
+
const gitDir = (await gitQuietAsync(path, [
|
|
302
|
+
"rev-parse",
|
|
303
|
+
"--path-format=absolute",
|
|
304
|
+
"--git-dir"
|
|
305
|
+
], { killSignal: "SIGKILL" }))?.trim();
|
|
306
|
+
return activityFromGitDir(gitDir, keys);
|
|
307
|
+
}
|
|
308
|
+
function activityFromGitDir(gitDir, keys) {
|
|
309
|
+
if (!gitDir) return null;
|
|
310
|
+
const paths = [
|
|
311
|
+
{
|
|
312
|
+
path: join$1(gitDir, "index"),
|
|
313
|
+
basis: "a git index write"
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
path: join$1(gitDir, "HEAD"),
|
|
317
|
+
basis: "a git HEAD change"
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
path: join$1(gitDir, "logs", "HEAD"),
|
|
321
|
+
basis: "a git reflog entry"
|
|
322
|
+
}
|
|
323
|
+
];
|
|
324
|
+
for (const key of keys) {
|
|
325
|
+
const logs = logFiles$1(key);
|
|
326
|
+
if (logs === null) return null;
|
|
327
|
+
paths.push({
|
|
328
|
+
path: workspaceStateFile$1(key),
|
|
329
|
+
basis: "a Stim workspace state write"
|
|
330
|
+
}, ...logs);
|
|
331
|
+
}
|
|
332
|
+
const newest = newestMtime(paths);
|
|
333
|
+
return newest === "unreadable" ? null : newest;
|
|
334
|
+
}
|
|
335
|
+
function graceMsSetting(operation) {
|
|
336
|
+
const { value, error } = machineNumber(GRACE_SETTING, loadConfig$1(), operation?.env ?? process.env);
|
|
337
|
+
if (error) {
|
|
338
|
+
const fallback = Number(settingDefinition(GRACE_SETTING)?.default);
|
|
339
|
+
const line = chalk.yellow(`${error} gc uses the default of ${fallback} minutes.`);
|
|
340
|
+
const context = operation;
|
|
341
|
+
if (context) context.output("stderr", `${line}\n`);
|
|
342
|
+
else console.error(line);
|
|
343
|
+
return fallback * MINUTE_MS;
|
|
344
|
+
}
|
|
345
|
+
return (value ?? 0) * MINUTE_MS;
|
|
346
|
+
}
|
|
347
|
+
async function unlessTimedOut(read, unknown) {
|
|
348
|
+
try {
|
|
349
|
+
return await read();
|
|
350
|
+
} catch (error) {
|
|
351
|
+
if (isTimeoutError(error)) return unknown;
|
|
352
|
+
throw error;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
async function porcelainOf(path, gitAnswered) {
|
|
356
|
+
if (gitAnswered === null) return null;
|
|
357
|
+
if (!gitAnswered) return [];
|
|
358
|
+
const lines = await unlessTimedOut(() => dirtyPathsAsync(path, { limit: Infinity }), null);
|
|
359
|
+
return lines?.length ? lines : null;
|
|
360
|
+
}
|
|
361
|
+
function candidateRoots() {
|
|
362
|
+
const registered = Object.keys(loadConfig$1()?.projects ?? {}).filter(isAbsolute$1);
|
|
363
|
+
const recorded = listWorkspaceDirs().flatMap((entry) => entry.projectRoot ? [entry.projectRoot] : []);
|
|
364
|
+
return [.../* @__PURE__ */ new Set([
|
|
365
|
+
...registered,
|
|
366
|
+
...recorded,
|
|
367
|
+
...linkedWorktreesOf(registered)
|
|
368
|
+
])].filter((root) => existsSync$1(root)).toSorted();
|
|
369
|
+
}
|
|
370
|
+
async function candidateRootsAsync() {
|
|
371
|
+
const registered = Object.keys(loadConfig$1()?.projects ?? {}).filter(isAbsolute$1);
|
|
372
|
+
const recorded = listWorkspaceDirs().flatMap((entry) => entry.projectRoot ? [entry.projectRoot] : []);
|
|
373
|
+
const linked = /* @__PURE__ */ new Set();
|
|
374
|
+
for (const root of registered) {
|
|
375
|
+
if (!existsSync$1(root)) continue;
|
|
376
|
+
const { entries, ...source } = await resolveSourceCheckoutAsync(root);
|
|
377
|
+
if ("refusal" in source) continue;
|
|
378
|
+
for (const entry of entries) if (!entry.bare && !entry.prunable && entry.path !== source.path) linked.add(entry.path);
|
|
379
|
+
}
|
|
380
|
+
return [.../* @__PURE__ */ new Set([
|
|
381
|
+
...registered,
|
|
382
|
+
...recorded,
|
|
383
|
+
...linked
|
|
384
|
+
])].filter((root) => existsSync$1(root)).toSorted();
|
|
385
|
+
}
|
|
386
|
+
function linkedWorktreesOf(roots) {
|
|
387
|
+
const linked = /* @__PURE__ */ new Set();
|
|
388
|
+
for (const root of roots) {
|
|
389
|
+
if (!existsSync$1(root)) continue;
|
|
390
|
+
const entries = listWorktrees(root);
|
|
391
|
+
const source = sourceCheckoutOf(entries);
|
|
392
|
+
if ("refusal" in source) continue;
|
|
393
|
+
for (const entry of entries) if (!entry.bare && !entry.prunable && entry.path !== source.path) linked.add(entry.path);
|
|
394
|
+
}
|
|
395
|
+
return [...linked];
|
|
396
|
+
}
|
|
397
|
+
async function staleFetch(repo, now, freshMs) {
|
|
398
|
+
const at = await lastFetchedAtAsync(repo);
|
|
399
|
+
if (at !== null && now - at < freshMs) return null;
|
|
400
|
+
return at === null ? "the repository has no recorded fetch" : `the repository was last fetched ${formatLongDuration(Math.max(0, now - at))} ago`;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* How old a fetch or a cached pull request lookup can be and still count as fresh in a report: twice the maintenance
|
|
404
|
+
* worktree check interval, which refreshes both, and never less than 10 minutes.
|
|
405
|
+
*/
|
|
406
|
+
function reportFreshMs(operation) {
|
|
407
|
+
const minutes = machineNumber(WORKTREE_CHECK_SETTING, loadConfig$1(), operation?.env ?? process.env).value ?? Number(settingDefinition(WORKTREE_CHECK_SETTING)?.default);
|
|
408
|
+
return Math.max(FETCH_FRESH_MS, 2 * minutes * MINUTE_MS);
|
|
409
|
+
}
|
|
410
|
+
function notRefreshedNote(fetch, facts) {
|
|
411
|
+
const lookup = facts.pullRequest;
|
|
412
|
+
const stale = [fetch, lookup && "unavailable" in lookup && lookup.unavailable === PULL_REQUEST_NOT_CHECKED ? "its pull request was not checked" : null].filter(Boolean);
|
|
413
|
+
return stale.length ? `merge state not refreshed: ${stale.join(", ")}; the next maintenance worktree check, which a stim command starts when it is due, or \`stim gc --delete\` checks again` : null;
|
|
414
|
+
}
|
|
415
|
+
async function checkMergeStates(pending, idle, grace, noOwnCommitsIdleMs, refresh, freshMs) {
|
|
416
|
+
const repos = /* @__PURE__ */ new Map();
|
|
417
|
+
for (const entry of pending) repos.set(entry.repo, [...repos.get(entry.repo) ?? [], entry]);
|
|
418
|
+
for (const [repo, entries] of repos) {
|
|
419
|
+
const target = refresh ? await fetchDefaultBranchAsync(repo, grace.now) : await localDefaultBranchAsync(repo);
|
|
420
|
+
const fetch = refresh ? null : await staleFetch(repo, grace.now, freshMs);
|
|
421
|
+
for (const { candidate, facts } of entries) {
|
|
422
|
+
const merge = "error" in target ? {
|
|
423
|
+
merged: false,
|
|
424
|
+
unknown: true,
|
|
425
|
+
detail: `merge state unknown: ${target.error}`
|
|
426
|
+
} : await mergeStateAsync(candidate.path, target);
|
|
427
|
+
const verdict = worktreeSkipReason({
|
|
428
|
+
...facts,
|
|
429
|
+
merge
|
|
430
|
+
}, idle, grace, noOwnCommitsIdleMs);
|
|
431
|
+
const note = !refresh && verdict && MERGE_DECIDES.has(verdict.code) ? notRefreshedNote(hasNoOwnCommits(merge) ? null : fetch, facts) : null;
|
|
432
|
+
Object.assign(candidate, {
|
|
433
|
+
merge,
|
|
434
|
+
skipCode: note && verdict?.code === "not-merged" ? "merge-unknown" : verdict?.code ?? null,
|
|
435
|
+
skipped: verdict ? note ? `${verdict.text}; ${note}` : verdict.text : null,
|
|
436
|
+
eligibleAt: verdict?.eligibleAt ?? null
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Classifies each linked worktree of a registered project or its repository. A merged one is removable; with `idle`, so is one unused for
|
|
443
|
+
* `olderThan` days; with `removesNoOwnCommits`, so is one with no commits of its own beyond the default branch whose
|
|
444
|
+
* last activity is older than the grace period, and never younger than 5 minutes. Device use is read last, in one pass against one simulator list and one AVD resolver, so
|
|
445
|
+
* the resolver's time budget covers only device reads. Pull requests are asked once per repository, all
|
|
446
|
+
* repositories at once. Merge state is checked only where it decides the verdict, after one fetch per repository.
|
|
447
|
+
* Without `refresh`, it runs no fetch and no `gh`: merge state comes from the existing remote-tracking ref and pull
|
|
448
|
+
* requests from the cache `status --watch` writes, and a kept verdict that fresher state could change says so.
|
|
449
|
+
*/
|
|
450
|
+
async function collectWorktreeSweep({ idle, olderThan, now, graceMs, removesNoOwnCommits = false, refresh = true, operation }) {
|
|
451
|
+
const days = idle ? olderThan ?? DEFAULT_WORKTREE_IDLE_DAYS : null;
|
|
452
|
+
const grace = {
|
|
453
|
+
ms: graceMs ?? graceMsSetting(operation),
|
|
454
|
+
now
|
|
455
|
+
};
|
|
456
|
+
const freshMs = refresh ? 0 : reportFreshMs(operation);
|
|
457
|
+
const noOwnCommitsIdleMs = removesNoOwnCommits ? Math.max(grace.ms, NO_OWN_COMMITS_PRESSURE_FLOOR_MS) : null;
|
|
458
|
+
const groups = /* @__PURE__ */ new Map();
|
|
459
|
+
const outside = [];
|
|
460
|
+
const candidates = await candidateRootsAsync();
|
|
461
|
+
if (candidates.length) phase("worktrees", `checking ${plural(candidates.length, "project")} for merged or idle worktrees`, operation);
|
|
462
|
+
for (const root of candidates) {
|
|
463
|
+
const entry = matchWorktreeEntry(await listWorktreesAsync(root), root);
|
|
464
|
+
if (!entry) {
|
|
465
|
+
outside.push({
|
|
466
|
+
path: root,
|
|
467
|
+
keys: [root],
|
|
468
|
+
idleDays: null,
|
|
469
|
+
merge: null,
|
|
470
|
+
head: null,
|
|
471
|
+
pullRequest: null,
|
|
472
|
+
skipCode: "not-a-worktree",
|
|
473
|
+
skipped: "not inside a git worktree",
|
|
474
|
+
eligibleAt: null,
|
|
475
|
+
activity: null
|
|
476
|
+
});
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
groups.set(entry.path, [...groups.get(entry.path) ?? [], root]);
|
|
480
|
+
}
|
|
481
|
+
const inspected = [];
|
|
482
|
+
for (const [path, roots] of groups) {
|
|
483
|
+
const { entries, ...source } = await resolveSourceCheckoutAsync(path);
|
|
484
|
+
const entry = matchWorktreeEntry(entries, path);
|
|
485
|
+
const keys = [.../* @__PURE__ */ new Set([...roots, ...reclaimKeys(path)])];
|
|
486
|
+
const idleDays = idleDaysOf(keys, now);
|
|
487
|
+
const linked = !("refusal" in source) && entry !== null && source.path !== entry.path;
|
|
488
|
+
const activity = linked && (grace.ms > 0 || days !== null || removesNoOwnCommits) ? await worktreeActivityOfAsync(path, keys) : null;
|
|
489
|
+
const gitAnswered = linked ? await unlessTimedOut(() => hasUncommittedWorkAsync(path), null) : null;
|
|
490
|
+
const head = linked ? await resolveFullRefAsync(path, "HEAD") : null;
|
|
491
|
+
const facts = {
|
|
492
|
+
source: "refusal" in source ? source : linked ? "linked" : "source",
|
|
493
|
+
bare: Boolean(entry?.bare),
|
|
494
|
+
locked: Boolean(entry?.locked),
|
|
495
|
+
nested: entry ? nestedWorktrees(entries, entry.path) : [],
|
|
496
|
+
porcelain: await porcelainOf(path, gitAnswered),
|
|
497
|
+
unpushed: linked ? await unlessTimedOut(() => unpushedCommitsAsync(path), null) : null,
|
|
498
|
+
submodules: linked ? await unlessTimedOut(() => hasPopulatedSubmodulesAsync(path), null) : false,
|
|
499
|
+
inUse: [],
|
|
500
|
+
idleDays,
|
|
501
|
+
merge: null,
|
|
502
|
+
pullRequest: null,
|
|
503
|
+
activity
|
|
504
|
+
};
|
|
505
|
+
inspected.push({
|
|
506
|
+
candidate: {
|
|
507
|
+
path,
|
|
508
|
+
keys,
|
|
509
|
+
idleDays,
|
|
510
|
+
merge: null,
|
|
511
|
+
head,
|
|
512
|
+
pullRequest: null,
|
|
513
|
+
skipCode: null,
|
|
514
|
+
skipped: null,
|
|
515
|
+
eligibleAt: null,
|
|
516
|
+
activity
|
|
517
|
+
},
|
|
518
|
+
facts,
|
|
519
|
+
repo: "refusal" in source ? null : source.path,
|
|
520
|
+
branch: head && entry?.branch ? entry.branch : null
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
const linked = inspected.filter(({ facts }) => facts.source === "linked");
|
|
524
|
+
for (const { candidate, facts } of linked) facts.inUse = candidate.keys.flatMap((key) => workspaceInUse(key, { managedLocks: true }));
|
|
525
|
+
const devices = await reportDeviceReads(linked.flatMap(({ candidate }) => candidate.keys));
|
|
526
|
+
for (const { candidate, facts } of linked) facts.inUse = [.../* @__PURE__ */ new Set([...facts.inUse, ...deviceUseOf(candidate.keys, devices)])];
|
|
527
|
+
if (refresh) await lookUpPullRequests(inspected);
|
|
528
|
+
else readCachedPullRequests(inspected, now, freshMs);
|
|
529
|
+
const worktrees = [];
|
|
530
|
+
const pending = [];
|
|
531
|
+
for (const { candidate, facts, repo } of inspected) {
|
|
532
|
+
const verdict = worktreeSkipReason(facts, days, grace, noOwnCommitsIdleMs);
|
|
533
|
+
Object.assign(candidate, {
|
|
534
|
+
pullRequest: facts.pullRequest,
|
|
535
|
+
skipCode: verdict?.code ?? null,
|
|
536
|
+
skipped: verdict?.text ?? null,
|
|
537
|
+
eligibleAt: verdict?.eligibleAt ?? null
|
|
538
|
+
});
|
|
539
|
+
if (verdict && MERGE_DECIDES.has(verdict.code) && repo !== null) pending.push({
|
|
540
|
+
candidate,
|
|
541
|
+
facts,
|
|
542
|
+
repo
|
|
543
|
+
});
|
|
544
|
+
worktrees.push(candidate);
|
|
545
|
+
}
|
|
546
|
+
await checkMergeStates(pending, days, grace, noOwnCommitsIdleMs, refresh, freshMs);
|
|
547
|
+
const listed = [...worktrees, ...outside].filter((w) => idle || w.skipCode !== "not-a-worktree" && w.skipCode !== "source-checkout");
|
|
548
|
+
return {
|
|
549
|
+
idle: days === null ? null : {
|
|
550
|
+
olderThan: days,
|
|
551
|
+
defaulted: olderThan === null
|
|
552
|
+
},
|
|
553
|
+
graceMs: grace.ms,
|
|
554
|
+
now,
|
|
555
|
+
noOwnCommitsIdleMs,
|
|
556
|
+
worktrees: listed
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
async function lookUpPullRequests(inspected) {
|
|
560
|
+
const repos = /* @__PURE__ */ new Map();
|
|
561
|
+
for (const { candidate, facts, repo, branch } of inspected) {
|
|
562
|
+
if (repo === null || branch === null || candidate.head === null) continue;
|
|
563
|
+
const query = {
|
|
564
|
+
cwd: candidate.path,
|
|
565
|
+
branch,
|
|
566
|
+
head: candidate.head
|
|
567
|
+
};
|
|
568
|
+
repos.set(repo, [...repos.get(repo) ?? [], {
|
|
569
|
+
facts,
|
|
570
|
+
query
|
|
571
|
+
}]);
|
|
572
|
+
}
|
|
573
|
+
const lookup = pullRequestLookups();
|
|
574
|
+
await Promise.all([...repos].map(async ([repo, entries]) => {
|
|
575
|
+
const answers = await lookup(repo, entries.map(({ query }) => query));
|
|
576
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
577
|
+
entries.forEach(({ facts, query }, i) => {
|
|
578
|
+
const answer = answers[i] ?? null;
|
|
579
|
+
facts.pullRequest = answer;
|
|
580
|
+
if (answer && "pullRequest" in answer) recordPullRequestLookup({
|
|
581
|
+
path: query.cwd,
|
|
582
|
+
branch: query.branch,
|
|
583
|
+
head: query.head
|
|
584
|
+
}, answer.pullRequest, at);
|
|
585
|
+
});
|
|
586
|
+
}));
|
|
587
|
+
}
|
|
588
|
+
function readCachedPullRequests(inspected, now, freshMs) {
|
|
589
|
+
let gh;
|
|
590
|
+
for (const { candidate, facts, repo, branch } of inspected) {
|
|
591
|
+
if (repo === null || branch === null || candidate.head === null) continue;
|
|
592
|
+
const cached = cachedPullRequest(readPullRequestCache(candidate.path), branch, candidate.head, now, freshMs);
|
|
593
|
+
if (!("pullRequest" in cached)) gh ??= getExecutor().findExecutable("gh") !== null;
|
|
594
|
+
facts.pullRequest = "pullRequest" in cached || gh ? cached : { unavailable: "gh is not installed" };
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
function cachedPullRequest(cached, branch, head, now, freshMs) {
|
|
598
|
+
const notChecked = { unavailable: PULL_REQUEST_NOT_CHECKED };
|
|
599
|
+
if (!cached || cached.branch !== branch || cached.head !== head) return notChecked;
|
|
600
|
+
if (!(now - Date.parse(cached.checkedAt) < freshMs)) return notChecked;
|
|
601
|
+
const { pullRequest: pr, containsHead, endedAt } = cached;
|
|
602
|
+
if (pr === null) return { pullRequest: null };
|
|
603
|
+
if (containsHead === void 0 || pr.state !== "open" && pr.state !== "draft" && typeof endedAt !== "number") return notChecked;
|
|
604
|
+
const state = pr.state === "draft" ? "open" : pr.state;
|
|
605
|
+
const open = state === "open";
|
|
606
|
+
return { pullRequest: {
|
|
607
|
+
number: pr.number,
|
|
608
|
+
url: pr.url,
|
|
609
|
+
head,
|
|
610
|
+
containsHead,
|
|
611
|
+
state,
|
|
612
|
+
draft: pr.state === "draft",
|
|
613
|
+
endedAt: open ? null : endedAt ?? null
|
|
614
|
+
} };
|
|
615
|
+
}
|
|
616
|
+
function noOwnCommitsRecheck(sweep, path, keys, now) {
|
|
617
|
+
if (!sweep.idle) {
|
|
618
|
+
if (sweep.noOwnCommitsIdleMs === null) return "it has no commits of its own and is not idle";
|
|
619
|
+
return noOwnCommitsActivityBlocker(worktreeActivityOf(path, keys), sweep.noOwnCommitsIdleMs, now)?.text ?? null;
|
|
620
|
+
}
|
|
621
|
+
const idleDays = idleDaysOf(keys, now);
|
|
622
|
+
if (idleDays !== null) return idleDays < sweep.idle.olderThan ? `used ${idleDays}d ago since gc checked it` : null;
|
|
623
|
+
const activity = worktreeActivityOf(path, keys);
|
|
624
|
+
if (!activity) return "its last activity could not be read";
|
|
625
|
+
const days = activityIdleDays(activity, now);
|
|
626
|
+
return days < sweep.idle.olderThan ? `active ${days}d ago since gc checked it: ${activity.basis}` : null;
|
|
627
|
+
}
|
|
628
|
+
async function removeWorktrees(sweep, { now = Date.now() } = {}) {
|
|
629
|
+
let failures = 0;
|
|
630
|
+
for (const candidate of sweep.worktrees) {
|
|
631
|
+
if (candidate.skipped) continue;
|
|
632
|
+
let kept = [];
|
|
633
|
+
const guard = (lockedKeys) => {
|
|
634
|
+
const keys = [.../* @__PURE__ */ new Set([...candidate.keys, ...lockedKeys])];
|
|
635
|
+
const unlocked = keys.filter((key) => !lockedKeys.includes(key));
|
|
636
|
+
const reasons = [...inUseOf(lockedKeys, { managedLocks: false }), ...inUseOf(unlocked, { managedLocks: true })].map((r) => `in use: ${r}`);
|
|
637
|
+
const mergedHead = candidate.merge?.merged ? candidate.merge.head : endedPullRequest(candidate.pullRequest) ? candidate.head : null;
|
|
638
|
+
if (mergedHead) {
|
|
639
|
+
if (resolveFullRef(candidate.path, "HEAD") !== mergedHead) reasons.push("its HEAD moved since gc checked it");
|
|
640
|
+
} else if (hasNoOwnCommits(candidate.merge)) {
|
|
641
|
+
if (resolveFullRef(candidate.path, "HEAD") !== candidate.head) reasons.push("its HEAD moved since gc checked it");
|
|
642
|
+
const recent = noOwnCommitsRecheck(sweep, candidate.path, keys, Date.now());
|
|
643
|
+
if (recent) reasons.push(recent);
|
|
644
|
+
} else {
|
|
645
|
+
const idleDays = idleDaysOf(keys, now);
|
|
646
|
+
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`);
|
|
647
|
+
}
|
|
648
|
+
if (sweep.graceMs > 0) {
|
|
649
|
+
const recent = graceBlocker({
|
|
650
|
+
merge: candidate.merge,
|
|
651
|
+
pullRequest: candidate.pullRequest,
|
|
652
|
+
activity: worktreeActivityOf(candidate.path, keys)
|
|
653
|
+
}, {
|
|
654
|
+
ms: sweep.graceMs,
|
|
655
|
+
now: Date.now()
|
|
656
|
+
});
|
|
657
|
+
if (recent) reasons.push(recent.text);
|
|
658
|
+
}
|
|
659
|
+
kept = reasons;
|
|
660
|
+
return reasons;
|
|
661
|
+
};
|
|
662
|
+
let removed = false;
|
|
663
|
+
let error = null;
|
|
664
|
+
try {
|
|
665
|
+
const ended = endedPullRequest(candidate.pullRequest);
|
|
666
|
+
const mergedHead = candidate.merge?.merged && candidate.merge.coversUnpushed ? candidate.merge.head : ended?.state === "merged" ? candidate.head ?? void 0 : void 0;
|
|
667
|
+
const expectedHead = candidate.merge?.merged || ended || hasNoOwnCommits(candidate.merge) ? candidate.head ?? void 0 : void 0;
|
|
668
|
+
removed = await removeWorktreeTarget(candidate.path, {
|
|
669
|
+
linkedOnly: true,
|
|
670
|
+
guard,
|
|
671
|
+
mergedHead,
|
|
672
|
+
expectedHead
|
|
673
|
+
});
|
|
674
|
+
} catch (thrown) {
|
|
675
|
+
error = thrown?.message || String(thrown);
|
|
676
|
+
console.error(chalk.red(`Could not remove ${candidate.path}: ${error}`));
|
|
677
|
+
}
|
|
678
|
+
if (removed) {
|
|
679
|
+
console.log(chalk.green(`Removed the worktree ${candidate.path} (${worktreeRemovalReason(candidate, sweep)})`));
|
|
680
|
+
recordGcResult("worktree", "done", candidate.path, { detail: worktreeRemovalReason(candidate, sweep) });
|
|
681
|
+
} else if (kept.length) {
|
|
682
|
+
console.log(chalk.yellow(`Kept the worktree ${candidate.path}: ${kept.join("; ")}`));
|
|
683
|
+
recordGcResult("worktree", "kept", candidate.path, { detail: kept.join("; ") });
|
|
684
|
+
} else {
|
|
685
|
+
failures++;
|
|
686
|
+
console.error(chalk.yellow(`Kept the worktree ${candidate.path}; see the lines above for why.`));
|
|
687
|
+
recordGcResult("worktree", "failed", candidate.path, { detail: error ?? "stim worktree remove did not remove it; see the details" });
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
return failures;
|
|
691
|
+
}
|
|
692
|
+
//#endregion
|
|
693
|
+
//#region src/maintenance/plan.ts
|
|
694
|
+
function plan({ pressure, sizes, settings, budget, diskSteps = [], protectedRoot, scoped = false, projects = [], pinned = () => false, recentlyUsed = () => null }) {
|
|
695
|
+
const actions = [];
|
|
696
|
+
const blocked = [];
|
|
697
|
+
const skips = [];
|
|
698
|
+
const add = (action) => {
|
|
699
|
+
if (action.kind === "would-clear-outputs" && action.workspace && pinned(action.workspace)) {
|
|
700
|
+
skips.push({
|
|
701
|
+
target: action.workspace,
|
|
702
|
+
reason: "pinned by maintenance.keep",
|
|
703
|
+
workspace: action.workspace
|
|
704
|
+
});
|
|
705
|
+
return false;
|
|
706
|
+
}
|
|
707
|
+
const recent = action.kind === "would-clear-outputs" && action.workspace ? recentlyUsed(action.workspace) : null;
|
|
708
|
+
if (recent) {
|
|
709
|
+
skips.push({
|
|
710
|
+
target: action.workspace,
|
|
711
|
+
reason: recent,
|
|
712
|
+
workspace: action.workspace
|
|
713
|
+
});
|
|
714
|
+
return false;
|
|
715
|
+
}
|
|
716
|
+
if (actions.some((entry) => entry.kind === action.kind && entry.target === action.target)) return false;
|
|
717
|
+
actions.push(action);
|
|
718
|
+
return true;
|
|
719
|
+
};
|
|
720
|
+
const floor = Math.max(budget.minFreeDiskMb, budget.hardFloorDiskMb);
|
|
721
|
+
const emergency = pressure?.disk.some((disk) => disk.freeMb < budget.hardFloorDiskMb) ?? false;
|
|
722
|
+
const low = pressure?.disk.filter((disk) => disk.freeMb < floor) ?? [];
|
|
723
|
+
const reason = low.map((disk) => `free disk ${(disk.freeMb / 1024).toFixed(1)}G on ${disk.volume} under the ${(floor / 1024).toFixed(1)}G floor`).join("; ");
|
|
724
|
+
const kinds = {
|
|
725
|
+
"idle-devices": "would-shutdown-device",
|
|
726
|
+
"idle-dev-servers": "would-stop-workspace",
|
|
727
|
+
"workspace-outputs": "would-clear-outputs",
|
|
728
|
+
"stale-cache-entries": "would-trim-cache"
|
|
729
|
+
};
|
|
730
|
+
if (low.length) {
|
|
731
|
+
for (const step of diskSteps) {
|
|
732
|
+
if (scoped && step.step === "idle-devices") continue;
|
|
733
|
+
for (const target of step.targets) {
|
|
734
|
+
const workspace = projects.find((root) => root === target || target.endsWith(` in ${root}`)) ?? (step.step === "idle-dev-servers" || step.step === "workspace-outputs" ? target : void 0);
|
|
735
|
+
if (workspace === protectedRoot || target === protectedRoot) continue;
|
|
736
|
+
const size = workspace && step.step !== "idle-devices" ? sizes.find((entry) => entry.workspace === workspace && entry.category === "workspace-outputs") : void 0;
|
|
737
|
+
add({
|
|
738
|
+
kind: kinds[step.step],
|
|
739
|
+
target,
|
|
740
|
+
bytes: step.step === "workspace-outputs" ? size?.bytes ?? 0 : 0,
|
|
741
|
+
reason: `${size?.idleDays == null ? "" : `idle ${size.idleDays} days, `}${reason}`,
|
|
742
|
+
...workspace ? { workspace } : {},
|
|
743
|
+
check: "disk",
|
|
744
|
+
...step.step === "stale-cache-entries" ? { olderThanDays: 14 } : {}
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
if (step.failures) blocked.push(`Could not plan ${step.step}: ${step.failures} failures`);
|
|
748
|
+
}
|
|
749
|
+
if (actions.length === 0) blocked.push(`${reason}; no reclaimable disk targets`);
|
|
750
|
+
}
|
|
751
|
+
const caps = [
|
|
752
|
+
[
|
|
753
|
+
"workspace-outputs",
|
|
754
|
+
settings.workspaceOutputsMaxGb,
|
|
755
|
+
"would-clear-outputs"
|
|
756
|
+
],
|
|
757
|
+
[
|
|
758
|
+
"build-cache",
|
|
759
|
+
settings.buildCacheMaxGb,
|
|
760
|
+
"would-trim-cache"
|
|
761
|
+
],
|
|
762
|
+
[
|
|
763
|
+
"metro-cache",
|
|
764
|
+
settings.metroCacheMaxGb,
|
|
765
|
+
"would-trim-cache"
|
|
766
|
+
],
|
|
767
|
+
[
|
|
768
|
+
"compilation-cache",
|
|
769
|
+
settings.swiftCompilationCacheMaxGb,
|
|
770
|
+
"would-empty-cache"
|
|
771
|
+
]
|
|
772
|
+
];
|
|
773
|
+
for (const [category, capGb, kind] of caps) {
|
|
774
|
+
const entries = sizes.filter((size) => size.category === category);
|
|
775
|
+
const total = entries.reduce((sum, size) => sum + size.bytes, 0);
|
|
776
|
+
const cap = capGb * 1024 ** 3;
|
|
777
|
+
if (cap === 0 || total <= cap) continue;
|
|
778
|
+
let remaining = total;
|
|
779
|
+
const targetBytes = cap * settings.capTargetPercent / 100;
|
|
780
|
+
const why = `${category} ${(total / 1024 ** 3).toFixed(1)}G over the ${capGb}G cap; target ${(targetBytes / 1024 ** 3).toFixed(1)}G`;
|
|
781
|
+
if (category === "compilation-cache" && !emergency) {
|
|
782
|
+
skips.push({
|
|
783
|
+
target: entries[0]?.dir ?? category,
|
|
784
|
+
reason: `${why}; it can only be emptied whole, which runs only below the ${(budget.hardFloorDiskMb / 1024).toFixed(1)}G hard floor`
|
|
785
|
+
});
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
for (const size of entries.toSorted((a, b) => (b.idleDays ?? 0) - (a.idleDays ?? 0))) {
|
|
789
|
+
if (remaining <= targetBytes) break;
|
|
790
|
+
const kept = size.workspace === protectedRoot ? "the triggering workspace is protected" : size.blocked;
|
|
791
|
+
if (kept) {
|
|
792
|
+
skips.push({
|
|
793
|
+
target: size.dir,
|
|
794
|
+
reason: kept,
|
|
795
|
+
...size.workspace ? { workspace: size.workspace } : {}
|
|
796
|
+
});
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
const bytes = kind === "would-trim-cache" ? Math.round(Math.min(size.bytes, remaining - targetBytes)) : size.bytes;
|
|
800
|
+
if (!add({
|
|
801
|
+
kind,
|
|
802
|
+
target: size.workspace ?? size.dir,
|
|
803
|
+
bytes,
|
|
804
|
+
reason: `${!size.workspace || size.idleDays == null ? "" : `idle ${size.idleDays} days, `}${why}`,
|
|
805
|
+
...size.workspace ? { workspace: size.workspace } : {},
|
|
806
|
+
...kind === "would-clear-outputs" ? {} : { dir: size.dir }
|
|
807
|
+
})) continue;
|
|
808
|
+
remaining -= bytes;
|
|
809
|
+
}
|
|
810
|
+
if (remaining > targetBytes) blocked.push(`${why}; eligible targets cannot reach the target`);
|
|
811
|
+
}
|
|
812
|
+
if (pressure?.memory.pressured) skips.push({
|
|
813
|
+
target: "memory",
|
|
814
|
+
reason: "memory pressure observed; automatic maintenance does not stop helpers or idle workspaces yet"
|
|
815
|
+
});
|
|
816
|
+
return {
|
|
817
|
+
actions,
|
|
818
|
+
blocked,
|
|
819
|
+
skips
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
//#endregion
|
|
823
|
+
//#region src/maintenance/protect.ts
|
|
824
|
+
const BUILD_RECORDS = ["lastBuild", ...Object.values(LAST_BUILD_KEYS)];
|
|
825
|
+
function recordKeys(record) {
|
|
826
|
+
if (record === null || typeof record !== "object") return [];
|
|
827
|
+
const value = record;
|
|
828
|
+
return [value.cacheKey, value.missReason?.baseline?.cacheKey].filter((key) => typeof key === "string");
|
|
829
|
+
}
|
|
830
|
+
function protectedCacheKeys(config = loadConfig$1()) {
|
|
831
|
+
const keys = /* @__PURE__ */ new Set();
|
|
832
|
+
for (const root of Object.keys(config?.projects ?? {})) {
|
|
833
|
+
const state = readWorkspaceState$1(root);
|
|
834
|
+
for (const name of BUILD_RECORDS) for (const key of recordKeys(state?.[name])) keys.add(key);
|
|
835
|
+
for (const entries of Object.values(readBuildHistory(state))) {
|
|
836
|
+
const key = entries.find((entry) => entry.result === "succeeded")?.cacheKey;
|
|
837
|
+
if (key) keys.add(key);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
for (const platform of ["ios", "android"]) for (const record of readParked(platform, { config })) if (record.cacheKey) keys.add(record.cacheKey);
|
|
841
|
+
for (const lock of listBuildLocks()) if ((lock.alive || lock.unresolved) && lock.key) keys.add(lock.key);
|
|
842
|
+
return keys;
|
|
843
|
+
}
|
|
844
|
+
function liveMetroStores(config = loadConfig$1()) {
|
|
845
|
+
return Object.keys(config?.projects ?? {}).filter((root) => workspaceInUse(root, {
|
|
846
|
+
managedLocks: false,
|
|
847
|
+
hosted: false,
|
|
848
|
+
nativeRun: false
|
|
849
|
+
}).length > 0).map((root) => canonicalPath(metroStoreRoot(root)));
|
|
850
|
+
}
|
|
851
|
+
function cacheEntryProtection(config = loadConfig$1()) {
|
|
852
|
+
try {
|
|
853
|
+
const keys = protectedCacheKeys(config);
|
|
854
|
+
const stores = liveMetroStores(config);
|
|
855
|
+
return (entry) => {
|
|
856
|
+
if (keys.has(basename(entry))) return "a project, parked device or running build still uses this build";
|
|
857
|
+
const path = canonicalPath(entry);
|
|
858
|
+
if (stores.some((store) => path === store || path.startsWith(`${store}${sep}`))) return "its project has a running or unverifiable dev server";
|
|
859
|
+
return null;
|
|
860
|
+
};
|
|
861
|
+
} catch (error) {
|
|
862
|
+
const reason = `cache protection could not be read: ${error instanceof Error ? error.message : String(error)}`;
|
|
863
|
+
return () => reason;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
function maintenancePinned(root) {
|
|
867
|
+
try {
|
|
868
|
+
return maintenanceKeepSetting(resolveProjectSettings(root).settings);
|
|
869
|
+
} catch {
|
|
870
|
+
return true;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
async function maintenancePinnedAsync(root, operation) {
|
|
874
|
+
try {
|
|
875
|
+
return maintenanceKeepSetting((await resolveProjectSettingsAsync(root, {
|
|
876
|
+
env: operation.env,
|
|
877
|
+
signal: operation.signal
|
|
878
|
+
})).settings);
|
|
879
|
+
} catch {
|
|
880
|
+
operation.signal.throwIfAborted();
|
|
881
|
+
return true;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
function recentUse(workspace, settings, windowMs = settings.protectRecentHours * 36e5) {
|
|
885
|
+
const since = Date.now() - workspaceLastUsed(workspace);
|
|
886
|
+
if (!Number.isFinite(since) || since >= windowMs) return null;
|
|
887
|
+
return windowMs < 36e5 ? `used within the last ${Math.round(windowMs / 6e4)} minutes` : `used within the last ${windowMs / 36e5} hours`;
|
|
888
|
+
}
|
|
889
|
+
//#endregion
|
|
890
|
+
//#region src/maintenance/activity.ts
|
|
891
|
+
function readDeviceActivity(withSims, now) {
|
|
892
|
+
const sims = withSims ? listAllIosSims({ timeoutMs: DEVICE_LIST_TIMEOUT_MS }) : [];
|
|
893
|
+
return collectOwnedDeviceActivity(loadConfig$1(), sims, now);
|
|
894
|
+
}
|
|
895
|
+
/** A reader that reads device activity at most once per kind and reuses the answer, including a failure. */
|
|
896
|
+
function cachedDeviceActivity(now) {
|
|
897
|
+
const answers = /* @__PURE__ */ new Map();
|
|
898
|
+
return (withSims) => {
|
|
899
|
+
let answer = answers.get(withSims);
|
|
900
|
+
if (!answer) {
|
|
901
|
+
try {
|
|
902
|
+
answer = { devices: readDeviceActivity(withSims, now) };
|
|
903
|
+
} catch (error) {
|
|
904
|
+
answer = { error };
|
|
905
|
+
}
|
|
906
|
+
answers.set(withSims, answer);
|
|
907
|
+
}
|
|
908
|
+
if ("error" in answer) throw answer.error;
|
|
909
|
+
return answer.devices;
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
function cachedDeviceActivityAsync(now, env) {
|
|
913
|
+
const answers = /* @__PURE__ */ new Map();
|
|
914
|
+
return (withSims) => {
|
|
915
|
+
let answer = answers.get(withSims);
|
|
916
|
+
if (!answer) {
|
|
917
|
+
answer = (async () => {
|
|
918
|
+
const sims = withSims ? await listAllIosSimsAsync({ timeoutMs: DEVICE_LIST_TIMEOUT_MS }) : [];
|
|
919
|
+
return collectOwnedDeviceActivityAsync(loadConfig$1(), sims, now, env);
|
|
920
|
+
})();
|
|
921
|
+
answers.set(withSims, answer);
|
|
922
|
+
}
|
|
923
|
+
return answer;
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
function* workspaceActiveChecks(root, now, windowMs) {
|
|
927
|
+
if (workspaceBuildInProgress(root)) return "a build is in progress";
|
|
928
|
+
const holder = canonicalPath(root);
|
|
929
|
+
for (const { lease } of listLeaseFiles()) if (lease && !leaseIsExpired(lease, now) && canonicalPath(lease.holder) === holder) return `it holds the ${lease.platform} device lease on ${lease.deviceName ?? lease.id}`;
|
|
930
|
+
const owned = ownedPlatforms(root);
|
|
931
|
+
if (owned.ios || owned.android) {
|
|
932
|
+
let devices;
|
|
933
|
+
try {
|
|
934
|
+
devices = yield owned.ios;
|
|
935
|
+
} catch {
|
|
936
|
+
return "its owned devices cannot be listed to prove them idle";
|
|
937
|
+
}
|
|
938
|
+
const busy = devices.find((device) => device.project === root && device.activity.state !== "idle");
|
|
939
|
+
if (busy) return `its owned ${busy.kind} device ${busy.name} is ${busy.activity.state}`;
|
|
940
|
+
}
|
|
941
|
+
const activity = workspaceActivity(root, now);
|
|
942
|
+
if (activity.state !== "idle") return `its activity is ${activity.state}`;
|
|
943
|
+
const known = [Date.parse(activity.lastActivityAt ?? ""), workspaceLastUsed(root)].filter(Number.isFinite);
|
|
944
|
+
if (!known.length) return "its last activity is unknown";
|
|
945
|
+
return now - Math.max(...known) < windowMs ? usedWithin(windowMs) : null;
|
|
946
|
+
}
|
|
947
|
+
/** Why a workspace is not inactive for the pressure window, or null when maintenance may stop it. */
|
|
948
|
+
function workspaceActiveReason(root, now, windowMs, devices = (withSims) => readDeviceActivity(withSims, now)) {
|
|
949
|
+
const checks = workspaceActiveChecks(root, now, windowMs);
|
|
950
|
+
let step = checks.next();
|
|
951
|
+
while (!step.done) try {
|
|
952
|
+
step = checks.next(devices(step.value));
|
|
953
|
+
} catch (error) {
|
|
954
|
+
step = checks.throw(error);
|
|
955
|
+
}
|
|
956
|
+
return step.value;
|
|
957
|
+
}
|
|
958
|
+
async function workspaceActiveReasonAsync(root, now, windowMs, devices) {
|
|
959
|
+
const checks = workspaceActiveChecks(root, now, windowMs);
|
|
960
|
+
let step = checks.next();
|
|
961
|
+
while (!step.done) try {
|
|
962
|
+
step = checks.next(await devices(step.value));
|
|
963
|
+
} catch (error) {
|
|
964
|
+
step = checks.throw(error);
|
|
965
|
+
}
|
|
966
|
+
return step.value;
|
|
967
|
+
}
|
|
968
|
+
function ownedPlatforms(root) {
|
|
969
|
+
try {
|
|
970
|
+
const slots = projectDeviceSlots(loadConfig$1()?.projects?.[root]);
|
|
971
|
+
return {
|
|
972
|
+
ios: slots.some(({ platforms }) => platforms.ios?.owned === true),
|
|
973
|
+
android: slots.some(({ platforms }) => platforms.android?.owned === true)
|
|
974
|
+
};
|
|
975
|
+
} catch {
|
|
976
|
+
return {
|
|
977
|
+
ios: true,
|
|
978
|
+
android: true
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
function hasOwnedDevices(root) {
|
|
983
|
+
try {
|
|
984
|
+
return projectDeviceSlots(loadConfig$1()?.projects?.[root]).some(({ platforms }) => platforms.ios?.owned === true || platforms.android?.owned === true);
|
|
985
|
+
} catch {
|
|
986
|
+
return false;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
function usedWithin(windowMs) {
|
|
990
|
+
return `used within the last ${Math.round(windowMs / 6e4)} minutes`;
|
|
991
|
+
}
|
|
992
|
+
//#endregion
|
|
993
|
+
//#region src/maintenance/devices.ts
|
|
994
|
+
const AVD_SIZE_TIMEOUT_MS = 3e4;
|
|
995
|
+
/** The disk of each owned simulator or emulator of a registered workspace and of each parked device. */
|
|
996
|
+
async function ownedDeviceUsage(failure, { sims = () => listAllIosSims({ timeoutMs: DEVICE_LIST_TIMEOUT_MS }), avds = () => listAvds({ timeoutMs: DEVICE_LIST_TIMEOUT_MS }), avdDirectory = ownedAvdDirectory, size = (dir) => measuredDirectorySizeAsync(dir, { timeoutMs: AVD_SIZE_TIMEOUT_MS }) } = {}) {
|
|
997
|
+
const config = loadConfig$1();
|
|
998
|
+
const owned = [];
|
|
999
|
+
for (const [root, project] of Object.entries(config?.projects ?? {})) for (const { platforms } of projectDeviceSlots(project)) {
|
|
1000
|
+
if (platforms.ios?.owned && platforms.ios.deviceUdid) owned.push({
|
|
1001
|
+
kind: "ios",
|
|
1002
|
+
id: platforms.ios.deviceUdid,
|
|
1003
|
+
workspace: canonicalPath(root)
|
|
1004
|
+
});
|
|
1005
|
+
if (platforms.android?.owned && platforms.android.avdName) owned.push({
|
|
1006
|
+
kind: "android",
|
|
1007
|
+
id: platforms.android.avdName,
|
|
1008
|
+
workspace: canonicalPath(root)
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
for (const record of readParked("ios", { config })) owned.push({
|
|
1012
|
+
kind: "ios",
|
|
1013
|
+
id: record.udid
|
|
1014
|
+
});
|
|
1015
|
+
for (const record of readParked("android", { config })) owned.push({
|
|
1016
|
+
kind: "android",
|
|
1017
|
+
id: record.name
|
|
1018
|
+
});
|
|
1019
|
+
const listed = (read, label) => {
|
|
1020
|
+
if (!owned.length) return [];
|
|
1021
|
+
try {
|
|
1022
|
+
return read();
|
|
1023
|
+
} catch {
|
|
1024
|
+
failure(label);
|
|
1025
|
+
return null;
|
|
1026
|
+
}
|
|
1027
|
+
};
|
|
1028
|
+
const iosRecords = owned.some((device) => device.kind === "ios") ? listed(sims, "the simulator list") : [];
|
|
1029
|
+
const avdNames = owned.some((device) => device.kind === "android") ? listed(avds, "the emulator list") : [];
|
|
1030
|
+
const usage = [];
|
|
1031
|
+
for (const device of owned) {
|
|
1032
|
+
const workspace = device.workspace ? { workspace: device.workspace } : {};
|
|
1033
|
+
if (device.kind === "ios") {
|
|
1034
|
+
const sim = iosRecords?.find((record) => record.udid === device.id);
|
|
1035
|
+
if (sim && typeof sim.dataPathSize === "number") usage.push({
|
|
1036
|
+
kind: "ios",
|
|
1037
|
+
name: sim.name,
|
|
1038
|
+
dir: sim.dataPath ?? device.id,
|
|
1039
|
+
bytes: sim.dataPathSize,
|
|
1040
|
+
...workspace
|
|
1041
|
+
});
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
const dir = avdNames?.includes(device.id) ? avdDirectory(device.id) : null;
|
|
1045
|
+
if (!dir) continue;
|
|
1046
|
+
const bytes = await size(dir);
|
|
1047
|
+
if (bytes === null) failure(dir, device.workspace);
|
|
1048
|
+
else usage.push({
|
|
1049
|
+
kind: "android",
|
|
1050
|
+
name: device.id,
|
|
1051
|
+
dir,
|
|
1052
|
+
bytes,
|
|
1053
|
+
...workspace
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
return usage;
|
|
1057
|
+
}
|
|
1058
|
+
async function measureOwnedDevices(now, failure) {
|
|
1059
|
+
return (await ownedDeviceUsage(failure)).map(({ kind, name, dir, bytes, workspace }) => {
|
|
1060
|
+
const size = {
|
|
1061
|
+
name: `${workspace ? "owned" : "parked"} ${kind === "ios" ? "simulator" : "emulator"} ${name}`,
|
|
1062
|
+
category: "owned-devices",
|
|
1063
|
+
dir,
|
|
1064
|
+
bytes,
|
|
1065
|
+
measuredAt: now
|
|
1066
|
+
};
|
|
1067
|
+
if (workspace) size.workspace = workspace;
|
|
1068
|
+
return size;
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
function* deviceDeletionChecks({ roots, windowMs, reason, protectedRoot, pinned, sizes, now, extra, env = process.env }) {
|
|
1072
|
+
const plan = {
|
|
1073
|
+
actions: [],
|
|
1074
|
+
blocked: [],
|
|
1075
|
+
skips: []
|
|
1076
|
+
};
|
|
1077
|
+
if (deviceSweepIsScoped(env)) return plan;
|
|
1078
|
+
for (const entry of roots) {
|
|
1079
|
+
const root = canonicalPath(entry.root);
|
|
1080
|
+
if (root === protectedRoot || !hasOwnedDevices(root)) continue;
|
|
1081
|
+
const kept = pinned(root) ? "pinned by maintenance.keep" : now - workspaceLastUsed(root) < windowMs ? usedWithin(windowMs) : workspaceInUse(root).map((why) => `in use: ${why}`)[0] ?? (yield root);
|
|
1082
|
+
if (kept) {
|
|
1083
|
+
plan.skips.push({
|
|
1084
|
+
target: root,
|
|
1085
|
+
reason: kept,
|
|
1086
|
+
workspace: root
|
|
1087
|
+
});
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
plan.actions.push({
|
|
1091
|
+
kind: "would-delete-devices",
|
|
1092
|
+
target: root,
|
|
1093
|
+
workspace: root,
|
|
1094
|
+
bytes: sizes.filter((size) => size.category === "owned-devices" && size.workspace === root).reduce((sum, size) => sum + size.bytes, 0),
|
|
1095
|
+
reason: entry.reason ? `${entry.reason}, ${reason}` : reason,
|
|
1096
|
+
...extra
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
return plan;
|
|
1100
|
+
}
|
|
1101
|
+
function planDeviceDeletion(options) {
|
|
1102
|
+
const activity = cachedDeviceActivity(options.now);
|
|
1103
|
+
const checks = deviceDeletionChecks(options);
|
|
1104
|
+
let step = checks.next();
|
|
1105
|
+
while (!step.done) step = checks.next(workspaceActiveReason(step.value, options.now, options.windowMs, activity));
|
|
1106
|
+
return step.value;
|
|
1107
|
+
}
|
|
1108
|
+
async function planDeviceDeletionAsync(options, signal) {
|
|
1109
|
+
const activity = cachedDeviceActivityAsync(options.now, options.env ?? process.env);
|
|
1110
|
+
const checks = deviceDeletionChecks(options);
|
|
1111
|
+
let step = checks.next();
|
|
1112
|
+
while (!step.done) {
|
|
1113
|
+
signal.throwIfAborted();
|
|
1114
|
+
step = checks.next(await workspaceActiveReasonAsync(step.value, options.now, options.windowMs, activity));
|
|
1115
|
+
}
|
|
1116
|
+
return step.value;
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* The workspaces of linked worktrees that gc keeps although their branch is merged or their pull request ended,
|
|
1120
|
+
* such as a dirty one. A worktree gc removes takes its devices with it.
|
|
1121
|
+
*/
|
|
1122
|
+
function finishedWorkspaces(sweep, now) {
|
|
1123
|
+
const kept = /* @__PURE__ */ new Set([
|
|
1124
|
+
"not-a-worktree",
|
|
1125
|
+
"bare-repository",
|
|
1126
|
+
"source-checkout",
|
|
1127
|
+
"source-checkout-unknown",
|
|
1128
|
+
"locked",
|
|
1129
|
+
"nested-worktree",
|
|
1130
|
+
"recent-activity",
|
|
1131
|
+
"activity-unknown"
|
|
1132
|
+
]);
|
|
1133
|
+
if (!sweep) return [];
|
|
1134
|
+
return sweep.worktrees.flatMap((worktree) => {
|
|
1135
|
+
if (worktree.skipped === null || kept.has(worktree.skipCode ?? "")) return [];
|
|
1136
|
+
const merged = worktree.merge?.merged ? worktree.merge.mergedAt : null;
|
|
1137
|
+
const ended = endedPullRequest(worktree.pullRequest);
|
|
1138
|
+
if (merged === null && !ended) return [];
|
|
1139
|
+
if (now - Math.max(merged ?? -Infinity, ended ? ended.endedAt ?? now : -Infinity) < sweep.graceMs) return [];
|
|
1140
|
+
const reason = `its worktree is finished (${worktreeRemovalReason(worktree, sweep)}) but kept`;
|
|
1141
|
+
return worktree.keys.map((root) => ({
|
|
1142
|
+
root: canonicalPath(root),
|
|
1143
|
+
reason
|
|
1144
|
+
}));
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
//#endregion
|
|
1148
|
+
//#region src/maintenance/sweep.ts
|
|
1149
|
+
async function planSweep({ settings, sizes, protectedRoot, pinned, now, devices = true, operation }) {
|
|
1150
|
+
const actions = [];
|
|
1151
|
+
const skips = [];
|
|
1152
|
+
const blocked = [];
|
|
1153
|
+
const days = settings.olderThanDays;
|
|
1154
|
+
for (const entry of collectWorkspaceOutputs({
|
|
1155
|
+
olderThan: days,
|
|
1156
|
+
now,
|
|
1157
|
+
measure: false
|
|
1158
|
+
}).workspaces) {
|
|
1159
|
+
const root = entry.projectRoot === null ? null : canonicalPath(entry.projectRoot);
|
|
1160
|
+
if (root === null || !entry.willClear || root === protectedRoot) continue;
|
|
1161
|
+
if (pinned(root)) {
|
|
1162
|
+
skips.push({
|
|
1163
|
+
target: root,
|
|
1164
|
+
reason: "pinned by maintenance.keep",
|
|
1165
|
+
workspace: root
|
|
1166
|
+
});
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
const size = sizes.find((candidate) => candidate.workspace === root && candidate.category === "workspace-outputs");
|
|
1170
|
+
actions.push({
|
|
1171
|
+
kind: "would-clear-outputs",
|
|
1172
|
+
target: root,
|
|
1173
|
+
workspace: root,
|
|
1174
|
+
bytes: size?.bytes ?? 0,
|
|
1175
|
+
olderThanDays: days,
|
|
1176
|
+
reason: `idle ${entry.idleDays} days, older than ${days} days (age sweep)`,
|
|
1177
|
+
check: "sweep"
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
for (const cache of trimmableCaches(operation?.env, operation?.cwd).filter((candidate) => !isInsideWorkspaces(candidate.dir))) actions.push({
|
|
1181
|
+
kind: "would-trim-cache",
|
|
1182
|
+
target: cacheLabel(cache),
|
|
1183
|
+
dir: cache.dir,
|
|
1184
|
+
bytes: 0,
|
|
1185
|
+
olderThanDays: days,
|
|
1186
|
+
reason: `entries unused for ${days} days (age sweep)`,
|
|
1187
|
+
check: "sweep"
|
|
1188
|
+
});
|
|
1189
|
+
try {
|
|
1190
|
+
const orphans = collectOrphanedWorkspaces(Object.keys(loadConfig$1()?.projects ?? {}), listMountedVolumes(), { measure: false });
|
|
1191
|
+
for (const orphan of orphans.orphaned) actions.push({
|
|
1192
|
+
kind: "would-remove-orphan",
|
|
1193
|
+
target: orphan.dir,
|
|
1194
|
+
bytes: orphan.bytes ?? 0,
|
|
1195
|
+
reason: `its project ${orphan.projectRoot} no longer exists`,
|
|
1196
|
+
check: "sweep"
|
|
1197
|
+
});
|
|
1198
|
+
for (const kept of orphans.skipped) skips.push({
|
|
1199
|
+
target: kept.dir,
|
|
1200
|
+
reason: kept.reason
|
|
1201
|
+
});
|
|
1202
|
+
} catch (error) {
|
|
1203
|
+
blocked.push(`Could not look for orphaned workspace directories: ${error instanceof Error ? error.message : error}`);
|
|
1204
|
+
}
|
|
1205
|
+
if (devices && settings.deviceIdleDays > 0) {
|
|
1206
|
+
const options = {
|
|
1207
|
+
roots: Object.keys(loadConfig$1()?.projects ?? {}).map((root) => ({ root })),
|
|
1208
|
+
windowMs: settings.deviceIdleDays * 864e5,
|
|
1209
|
+
reason: `idle for ${settings.deviceIdleDays} days (age sweep)`,
|
|
1210
|
+
protectedRoot,
|
|
1211
|
+
pinned,
|
|
1212
|
+
sizes,
|
|
1213
|
+
now,
|
|
1214
|
+
extra: {
|
|
1215
|
+
check: "sweep",
|
|
1216
|
+
olderThanDays: settings.deviceIdleDays
|
|
1217
|
+
},
|
|
1218
|
+
env: operation?.env
|
|
1219
|
+
};
|
|
1220
|
+
const reaped = operation ? await planDeviceDeletionAsync(options, operation.signal) : planDeviceDeletion(options);
|
|
1221
|
+
actions.push(...reaped.actions);
|
|
1222
|
+
skips.push(...reaped.skips);
|
|
1223
|
+
}
|
|
1224
|
+
const mounted = listMountedVolumes();
|
|
1225
|
+
for (const cache of readManifest().caches) {
|
|
1226
|
+
if (!isAbsolute(cache.dir) || existsSync(cache.dir) || !isOnMountedVolume(cache.dir, mounted)) continue;
|
|
1227
|
+
actions.push({
|
|
1228
|
+
kind: "would-unregister-cache",
|
|
1229
|
+
target: cache.dir,
|
|
1230
|
+
dir: cache.dir,
|
|
1231
|
+
bytes: 0,
|
|
1232
|
+
reason: "its directory no longer exists",
|
|
1233
|
+
check: "sweep"
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
return {
|
|
1237
|
+
actions,
|
|
1238
|
+
blocked,
|
|
1239
|
+
skips
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
async function planWorktrees({ protectedRoot, pinned, now, level, graceMs, sizes, protectRecentMs = 0, refresh, operation }) {
|
|
1243
|
+
const sweep = await collectWorktreeSweep({
|
|
1244
|
+
idle: false,
|
|
1245
|
+
olderThan: null,
|
|
1246
|
+
now,
|
|
1247
|
+
removesNoOwnCommits: level === 3,
|
|
1248
|
+
operation,
|
|
1249
|
+
...graceMs === void 0 ? {} : { graceMs },
|
|
1250
|
+
...refresh === void 0 ? {} : { refresh }
|
|
1251
|
+
});
|
|
1252
|
+
const actions = [];
|
|
1253
|
+
const skips = [];
|
|
1254
|
+
const keep = [];
|
|
1255
|
+
for (const worktree of sweep.worktrees) {
|
|
1256
|
+
if (worktree.skipped) continue;
|
|
1257
|
+
const path = canonicalPath(worktree.path);
|
|
1258
|
+
const reason = protectedRoot === path || protectedRoot.startsWith(`${path}${sep}`) ? "the command that started this pass runs inside it" : worktree.keys.some(pinned) ? "pinned by maintenance.keep" : null;
|
|
1259
|
+
if (reason) {
|
|
1260
|
+
skips.push({
|
|
1261
|
+
target: worktree.path,
|
|
1262
|
+
reason
|
|
1263
|
+
});
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
keep.push(worktree);
|
|
1267
|
+
actions.push({
|
|
1268
|
+
kind: "would-remove-worktree",
|
|
1269
|
+
target: worktree.path,
|
|
1270
|
+
bytes: 0,
|
|
1271
|
+
reason: worktreeRemovalReason(worktree, sweep),
|
|
1272
|
+
check: "worktree",
|
|
1273
|
+
...level ? { level } : {}
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
if (sizes) {
|
|
1277
|
+
const options = {
|
|
1278
|
+
roots: finishedWorkspaces(sweep, now),
|
|
1279
|
+
windowMs: Math.max(sweep.graceMs, protectRecentMs),
|
|
1280
|
+
reason: "finished-worktree check",
|
|
1281
|
+
protectedRoot,
|
|
1282
|
+
pinned,
|
|
1283
|
+
sizes,
|
|
1284
|
+
now,
|
|
1285
|
+
extra: { check: "worktree" },
|
|
1286
|
+
env: operation?.env
|
|
1287
|
+
};
|
|
1288
|
+
const devices = operation ? await planDeviceDeletionAsync(options, operation.signal) : planDeviceDeletion(options);
|
|
1289
|
+
actions.push(...devices.actions);
|
|
1290
|
+
skips.push(...devices.skips);
|
|
1291
|
+
}
|
|
1292
|
+
return {
|
|
1293
|
+
actions,
|
|
1294
|
+
blocked: [],
|
|
1295
|
+
skips,
|
|
1296
|
+
sweep: {
|
|
1297
|
+
...sweep,
|
|
1298
|
+
worktrees: keep
|
|
1299
|
+
}
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
//#endregion
|
|
1303
|
+
//#region src/maintenance/artifacts.ts
|
|
1304
|
+
/** Directories that hold only what a build or an install regenerates, relative to an app or package root. */
|
|
1305
|
+
const ARTIFACT_DIRS = {
|
|
1306
|
+
build: [
|
|
1307
|
+
"ios/build",
|
|
1308
|
+
"android/app/build",
|
|
1309
|
+
"android/build",
|
|
1310
|
+
"android/.gradle",
|
|
1311
|
+
".build"
|
|
1312
|
+
],
|
|
1313
|
+
dependencies: ["node_modules", "ios/Pods"]
|
|
1314
|
+
};
|
|
1315
|
+
const PACKAGE_PARENTS = ["apps", "packages"];
|
|
1316
|
+
const SCAN_DEPTH = {
|
|
1317
|
+
build: 3,
|
|
1318
|
+
dependencies: 1
|
|
1319
|
+
};
|
|
1320
|
+
const SCAN_LIMIT = 2e4;
|
|
1321
|
+
function isDirectory(path) {
|
|
1322
|
+
try {
|
|
1323
|
+
return lstatSync(path).isDirectory();
|
|
1324
|
+
} catch {
|
|
1325
|
+
return false;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
function artifactWorktrees() {
|
|
1329
|
+
const found = /* @__PURE__ */ new Map();
|
|
1330
|
+
for (const root of candidateRoots()) {
|
|
1331
|
+
const entry = artifactWorktree(root, listWorktrees(root));
|
|
1332
|
+
if (entry) found.set(entry.path, entry);
|
|
1333
|
+
}
|
|
1334
|
+
return [...found.values()];
|
|
1335
|
+
}
|
|
1336
|
+
function artifactWorktree(root, entries, source = sourceCheckoutOf(entries)) {
|
|
1337
|
+
const entry = matchWorktreeEntry(entries, root);
|
|
1338
|
+
if (!entry || entry.bare || "refusal" in source) return null;
|
|
1339
|
+
const keys = reclaimKeys(entry.path).filter((key) => matchWorktreeEntry(entries, key)?.path === entry.path);
|
|
1340
|
+
return {
|
|
1341
|
+
path: entry.path,
|
|
1342
|
+
linked: entry.path !== source.path,
|
|
1343
|
+
keys
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
async function artifactWorktreesAsync(signal) {
|
|
1347
|
+
const found = /* @__PURE__ */ new Map();
|
|
1348
|
+
for (const root of await candidateRootsAsync()) {
|
|
1349
|
+
signal.throwIfAborted();
|
|
1350
|
+
const { entries, ...source } = await resolveSourceCheckoutAsync(root);
|
|
1351
|
+
const entry = artifactWorktree(root, entries, source);
|
|
1352
|
+
if (entry) found.set(entry.path, entry);
|
|
1353
|
+
}
|
|
1354
|
+
return [...found.values()];
|
|
1355
|
+
}
|
|
1356
|
+
function appRoots(worktree, kind) {
|
|
1357
|
+
const roots = /* @__PURE__ */ new Set([worktree.path, ...worktree.keys]);
|
|
1358
|
+
if (kind === "build") for (const parent of PACKAGE_PARENTS) {
|
|
1359
|
+
const dir = join(worktree.path, parent);
|
|
1360
|
+
try {
|
|
1361
|
+
for (const name of readdirSync(dir)) if (isDirectory(join(dir, name))) roots.add(join(dir, name));
|
|
1362
|
+
} catch {}
|
|
1363
|
+
}
|
|
1364
|
+
return [...roots];
|
|
1365
|
+
}
|
|
1366
|
+
/** The newest mtime under `dir` down to `maxDepth`, or null when the tree is too large or unreadable to tell. */
|
|
1367
|
+
function newestWrite(dir, maxDepth) {
|
|
1368
|
+
let newest = 0;
|
|
1369
|
+
let seen = 0;
|
|
1370
|
+
const walk = (path, depth) => {
|
|
1371
|
+
let stat;
|
|
1372
|
+
try {
|
|
1373
|
+
stat = lstatSync(path);
|
|
1374
|
+
} catch {
|
|
1375
|
+
return false;
|
|
1376
|
+
}
|
|
1377
|
+
if (++seen > SCAN_LIMIT) return false;
|
|
1378
|
+
newest = Math.max(newest, stat.mtimeMs);
|
|
1379
|
+
if (depth === maxDepth || !stat.isDirectory()) return true;
|
|
1380
|
+
let names;
|
|
1381
|
+
try {
|
|
1382
|
+
names = readdirSync(path);
|
|
1383
|
+
} catch {
|
|
1384
|
+
return false;
|
|
1385
|
+
}
|
|
1386
|
+
return names.every((name) => walk(join(path, name), depth + 1));
|
|
1387
|
+
};
|
|
1388
|
+
return walk(dir, 0) ? newest : null;
|
|
1389
|
+
}
|
|
1390
|
+
async function newestWriteAsync(dir, maxDepth, signal) {
|
|
1391
|
+
let newest = 0;
|
|
1392
|
+
let seen = 0;
|
|
1393
|
+
const walk = async (path, depth) => {
|
|
1394
|
+
signal.throwIfAborted();
|
|
1395
|
+
try {
|
|
1396
|
+
const stat = await lstat(path);
|
|
1397
|
+
if (++seen > SCAN_LIMIT) return false;
|
|
1398
|
+
newest = Math.max(newest, stat.mtimeMs);
|
|
1399
|
+
if (depth === maxDepth || !stat.isDirectory()) return true;
|
|
1400
|
+
for (const name of await readdir(path)) if (!await walk(join(path, name), depth + 1)) return false;
|
|
1401
|
+
return true;
|
|
1402
|
+
} catch {
|
|
1403
|
+
signal.throwIfAborted();
|
|
1404
|
+
return false;
|
|
1405
|
+
}
|
|
1406
|
+
};
|
|
1407
|
+
return await walk(dir, 0) ? newest : null;
|
|
1408
|
+
}
|
|
1409
|
+
function keptReason(candidate, kind, { now, windowMs, pinned, locked }, reads) {
|
|
1410
|
+
const guarded = artifactGuard(candidate, kind, pinned, locked);
|
|
1411
|
+
if (guarded) return guarded;
|
|
1412
|
+
const { worktree, path, keys } = candidate;
|
|
1413
|
+
const tracked = reads ? reads.tracked : gitQuiet(worktree, [
|
|
1414
|
+
"ls-files",
|
|
1415
|
+
"--",
|
|
1416
|
+
relative(worktree, path)
|
|
1417
|
+
]);
|
|
1418
|
+
if (tracked === null) return "git cannot tell whether it holds tracked files";
|
|
1419
|
+
if (tracked.trim()) return "it holds files git tracks";
|
|
1420
|
+
const activity = reads ? reads.activity : worktreeActivityOf(worktree, keys);
|
|
1421
|
+
const written = reads ? reads.written : newestWrite(path, SCAN_DEPTH[kind]);
|
|
1422
|
+
if (!activity || written === null) return "its last activity cannot be read";
|
|
1423
|
+
if (now - Math.max(activity.at, written) < windowMs) return `used within the last ${Math.round(windowMs / 6e4)} minutes`;
|
|
1424
|
+
return null;
|
|
1425
|
+
}
|
|
1426
|
+
function artifactGuard(candidate, kind, pinned, locked) {
|
|
1427
|
+
const { worktree, path, keys } = candidate;
|
|
1428
|
+
if (keys.some(pinned)) return "pinned by maintenance.keep";
|
|
1429
|
+
let real;
|
|
1430
|
+
try {
|
|
1431
|
+
real = canonicalPath(path);
|
|
1432
|
+
} catch {
|
|
1433
|
+
return "its path cannot be resolved";
|
|
1434
|
+
}
|
|
1435
|
+
if (!isDirectory(path) || real !== join(canonicalPath(worktree), relative(worktree, path))) return "it or a directory above it is a link, or it lies outside its worktree";
|
|
1436
|
+
const inUse = keys.flatMap((key) => workspaceInUse(key, {
|
|
1437
|
+
supervisor: kind === "dependencies",
|
|
1438
|
+
nativeRun: key !== locked
|
|
1439
|
+
}));
|
|
1440
|
+
if (inUse.length) return `in use: ${inUse.join("; ")}`;
|
|
1441
|
+
return null;
|
|
1442
|
+
}
|
|
1443
|
+
async function collectArtifactsAsync(kind, options, signal) {
|
|
1444
|
+
const candidates = [];
|
|
1445
|
+
const skips = [];
|
|
1446
|
+
for (const worktree of await artifactWorktreesAsync(signal)) {
|
|
1447
|
+
if (kind === "dependencies" && !worktree.linked) continue;
|
|
1448
|
+
const root = canonicalPath(worktree.path);
|
|
1449
|
+
const protectedHere = options.protectedRoot === root || options.protectedRoot.startsWith(`${root}${sep}`);
|
|
1450
|
+
const bases = /* @__PURE__ */ new Set([worktree.path, ...worktree.keys]);
|
|
1451
|
+
if (kind === "build") for (const parent of PACKAGE_PARENTS) {
|
|
1452
|
+
const dir = join(worktree.path, parent);
|
|
1453
|
+
try {
|
|
1454
|
+
for (const name of await readdir(dir)) {
|
|
1455
|
+
signal.throwIfAborted();
|
|
1456
|
+
if ((await lstat(join(dir, name))).isDirectory()) bases.add(join(dir, name));
|
|
1457
|
+
}
|
|
1458
|
+
} catch {
|
|
1459
|
+
signal.throwIfAborted();
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
for (const base of bases) for (const dir of ARTIFACT_DIRS[kind]) {
|
|
1463
|
+
signal.throwIfAborted();
|
|
1464
|
+
const path = join(base, dir);
|
|
1465
|
+
try {
|
|
1466
|
+
await stat(path);
|
|
1467
|
+
} catch {
|
|
1468
|
+
signal.throwIfAborted();
|
|
1469
|
+
continue;
|
|
1470
|
+
}
|
|
1471
|
+
const candidate = {
|
|
1472
|
+
worktree: worktree.path,
|
|
1473
|
+
path,
|
|
1474
|
+
keys: worktree.keys
|
|
1475
|
+
};
|
|
1476
|
+
let reason = protectedHere ? "the command that started this pass runs inside its worktree" : artifactGuard(candidate, kind, options.pinned);
|
|
1477
|
+
if (!reason) {
|
|
1478
|
+
const tracked = await gitQuietAsync(worktree.path, [
|
|
1479
|
+
"ls-files",
|
|
1480
|
+
"--",
|
|
1481
|
+
relative(worktree.path, path)
|
|
1482
|
+
]);
|
|
1483
|
+
const untracked = tracked !== null && !tracked.trim();
|
|
1484
|
+
reason = keptReason(candidate, kind, options, {
|
|
1485
|
+
tracked,
|
|
1486
|
+
activity: untracked ? await worktreeActivityOfAsync(worktree.path, worktree.keys) : null,
|
|
1487
|
+
written: untracked ? await newestWriteAsync(path, SCAN_DEPTH[kind], signal) : null
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
if (reason) skips.push({
|
|
1491
|
+
target: path,
|
|
1492
|
+
reason
|
|
1493
|
+
});
|
|
1494
|
+
else candidates.push(candidate);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
return {
|
|
1498
|
+
candidates,
|
|
1499
|
+
skips
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
/** Regenerable directories of idle worktrees, oldest activity first; dependencies only in linked worktrees. */
|
|
1503
|
+
function collectArtifacts(kind, options) {
|
|
1504
|
+
const candidates = [];
|
|
1505
|
+
const skips = [];
|
|
1506
|
+
for (const worktree of artifactWorktrees()) {
|
|
1507
|
+
if (kind === "dependencies" && !worktree.linked) continue;
|
|
1508
|
+
const root = canonicalPath(worktree.path);
|
|
1509
|
+
const protectedHere = options.protectedRoot === root || options.protectedRoot.startsWith(`${root}${sep}`);
|
|
1510
|
+
for (const base of appRoots(worktree, kind)) for (const dir of ARTIFACT_DIRS[kind]) {
|
|
1511
|
+
const path = join(base, dir);
|
|
1512
|
+
try {
|
|
1513
|
+
statSync(path);
|
|
1514
|
+
} catch {
|
|
1515
|
+
continue;
|
|
1516
|
+
}
|
|
1517
|
+
const candidate = {
|
|
1518
|
+
worktree: worktree.path,
|
|
1519
|
+
path,
|
|
1520
|
+
keys: worktree.keys
|
|
1521
|
+
};
|
|
1522
|
+
const reason = protectedHere ? "the command that started this pass runs inside its worktree" : keptReason(candidate, kind, options);
|
|
1523
|
+
if (reason) skips.push({
|
|
1524
|
+
target: path,
|
|
1525
|
+
reason
|
|
1526
|
+
});
|
|
1527
|
+
else candidates.push(candidate);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
return {
|
|
1531
|
+
candidates,
|
|
1532
|
+
skips
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
async function clearArtifact(action, kind, options) {
|
|
1536
|
+
const worktree = options.worktrees.filter((entry) => action.target.startsWith(`${entry.path}${sep}`)).reduce((best, entry) => best && best.path.length >= entry.path.length ? best : entry, null);
|
|
1537
|
+
if (kind === "dependencies" && worktree && !worktree.linked) return {
|
|
1538
|
+
status: "kept",
|
|
1539
|
+
bytes: 0,
|
|
1540
|
+
detail: "it is in the source checkout"
|
|
1541
|
+
};
|
|
1542
|
+
if (!worktree || !ARTIFACT_DIRS[kind].some((dir) => action.target.endsWith(`${sep}${dir.split("/").join(sep)}`))) return {
|
|
1543
|
+
status: "kept",
|
|
1544
|
+
bytes: 0,
|
|
1545
|
+
detail: "it is no longer a regenerable directory of a known worktree"
|
|
1546
|
+
};
|
|
1547
|
+
const root = canonicalPath(worktree.path);
|
|
1548
|
+
if (options.protectedRoot === root || options.protectedRoot.startsWith(`${root}${sep}`)) return {
|
|
1549
|
+
status: "kept",
|
|
1550
|
+
bytes: 0,
|
|
1551
|
+
detail: "the command that started this pass runs inside its worktree"
|
|
1552
|
+
};
|
|
1553
|
+
const candidate = {
|
|
1554
|
+
worktree: worktree.path,
|
|
1555
|
+
path: action.target,
|
|
1556
|
+
keys: worktree.keys
|
|
1557
|
+
};
|
|
1558
|
+
const early = keptReason(candidate, kind, {
|
|
1559
|
+
...options,
|
|
1560
|
+
now: Date.now()
|
|
1561
|
+
});
|
|
1562
|
+
if (early) return {
|
|
1563
|
+
status: "kept",
|
|
1564
|
+
bytes: 0,
|
|
1565
|
+
detail: early
|
|
1566
|
+
};
|
|
1567
|
+
const before = lowestFree(measureDisk());
|
|
1568
|
+
const [key] = candidate.keys;
|
|
1569
|
+
const remove = () => {
|
|
1570
|
+
const reason = keptReason(candidate, kind, {
|
|
1571
|
+
...options,
|
|
1572
|
+
now: Date.now(),
|
|
1573
|
+
...key ? { locked: key } : {}
|
|
1574
|
+
});
|
|
1575
|
+
if (reason) return reason;
|
|
1576
|
+
rmSync(candidate.path, {
|
|
1577
|
+
recursive: true,
|
|
1578
|
+
force: true
|
|
1579
|
+
});
|
|
1580
|
+
return null;
|
|
1581
|
+
};
|
|
1582
|
+
const kept = key ? await withIdleWorkspace(key, remove, {
|
|
1583
|
+
purpose: "maintenance",
|
|
1584
|
+
supervisor: kind === "dependencies"
|
|
1585
|
+
}).then((run) => run.ran ? run.value : `in use: ${run.reasons.join("; ")}`) : remove();
|
|
1586
|
+
if (kept) return {
|
|
1587
|
+
status: "kept",
|
|
1588
|
+
bytes: 0,
|
|
1589
|
+
detail: kept
|
|
1590
|
+
};
|
|
1591
|
+
const freed = Math.max(0, lowestFree(measureDisk()) - before);
|
|
1592
|
+
return {
|
|
1593
|
+
status: "done",
|
|
1594
|
+
bytes: Number.isFinite(freed) ? Math.round(freed * 1024 * 1024) : 0,
|
|
1595
|
+
detail: candidate.path
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
//#endregion
|
|
1599
|
+
//#region src/commands/gc/temp-leftovers-guard-env.ts
|
|
1600
|
+
const TEMP_LEFTOVERS_TEST_GUARD_ENV = "STIM_TEST_GUARD_REAL_TMPDIR";
|
|
1601
|
+
//#endregion
|
|
1602
|
+
//#region src/commands/gc/temp-leftovers.ts
|
|
1603
|
+
const TEMP_LEFTOVER_MIN_AGE_HOURS = 24;
|
|
1604
|
+
const MIN_AGE_MS = 864e5;
|
|
1605
|
+
const STIM_PREFIXES = [
|
|
1606
|
+
"stim-claim-",
|
|
1607
|
+
"stim-host-",
|
|
1608
|
+
"stim-fakehome-",
|
|
1609
|
+
"stim-hosted-macos-",
|
|
1610
|
+
"stim-server-",
|
|
1611
|
+
"stim-ws-"
|
|
1612
|
+
];
|
|
1613
|
+
const STAGING_PREFIX = ".stim-delete-";
|
|
1614
|
+
const TRACE_PATTERN = /^instruments.*\.ktrace$/;
|
|
1615
|
+
const TRACE_PROCESSES = /* @__PURE__ */ new Set(["xctrace", "Instruments"]);
|
|
1616
|
+
const REMEDIES = {
|
|
1617
|
+
stim: "stim gc --delete removes them once they are idle for 24 hours",
|
|
1618
|
+
xctrace: "stim gc --delete removes them once they are idle for 24 hours and no xctrace or Instruments process runs; export a recording you need first",
|
|
1619
|
+
jest: "Jest owns this cache; clear it with `npx jest --clearCache`"
|
|
1620
|
+
};
|
|
1621
|
+
const LABELS = {
|
|
1622
|
+
stim: "Stim temp directories",
|
|
1623
|
+
xctrace: "xctrace recordings (instruments*.ktrace)",
|
|
1624
|
+
jest: "Jest cache"
|
|
1625
|
+
};
|
|
1626
|
+
function kindOf(name, stat, jestName) {
|
|
1627
|
+
const unstaged = name.startsWith(STAGING_PREFIX) ? name.slice(13) : name;
|
|
1628
|
+
if (stat.isDirectory() && STIM_PREFIXES.some((prefix) => unstaged.startsWith(prefix))) return "stim";
|
|
1629
|
+
if ((stat.isFile() || stat.isDirectory()) && TRACE_PATTERN.test(name)) return "xctrace";
|
|
1630
|
+
if (stat.isDirectory() && name === jestName) return "jest";
|
|
1631
|
+
return null;
|
|
1632
|
+
}
|
|
1633
|
+
function errorCode(error) {
|
|
1634
|
+
return error instanceof Error && "code" in error ? String(error.code) : String(error);
|
|
1635
|
+
}
|
|
1636
|
+
async function lstatOrError(path) {
|
|
1637
|
+
try {
|
|
1638
|
+
return await lstat(path);
|
|
1639
|
+
} catch (error) {
|
|
1640
|
+
const code = errorCode(error);
|
|
1641
|
+
return code === "ENOENT" ? void 0 : { error: code };
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
async function walk(path, stat, signal, state = {
|
|
1645
|
+
bytes: 0,
|
|
1646
|
+
newest: 0,
|
|
1647
|
+
claimRoots: [],
|
|
1648
|
+
locks: [],
|
|
1649
|
+
unreadable: null
|
|
1650
|
+
}) {
|
|
1651
|
+
signal?.throwIfAborted();
|
|
1652
|
+
state.bytes += stat.size;
|
|
1653
|
+
state.newest = Math.max(state.newest, stat.mtimeMs);
|
|
1654
|
+
if (!stat.isDirectory()) return state;
|
|
1655
|
+
let names;
|
|
1656
|
+
try {
|
|
1657
|
+
names = await readdir(path);
|
|
1658
|
+
} catch (error) {
|
|
1659
|
+
state.unreadable ??= `${path} cannot be listed: ${errorCode(error)}`;
|
|
1660
|
+
return state;
|
|
1661
|
+
}
|
|
1662
|
+
for (const name of names) {
|
|
1663
|
+
const child = join(path, name);
|
|
1664
|
+
const childStat = await lstatOrError(child);
|
|
1665
|
+
if (!childStat) continue;
|
|
1666
|
+
if ("error" in childStat) {
|
|
1667
|
+
state.unreadable ??= `${child} cannot be read: ${childStat.error}`;
|
|
1668
|
+
continue;
|
|
1669
|
+
}
|
|
1670
|
+
if ((name === "exclusive" || name === "shared") && !state.claimRoots.includes(path)) state.claimRoots.push(path);
|
|
1671
|
+
await walk(child, childStat, signal, state);
|
|
1672
|
+
}
|
|
1673
|
+
if (path.endsWith(".lock") && !state.claimRoots.includes(path)) state.locks.push(path);
|
|
1674
|
+
return state;
|
|
1675
|
+
}
|
|
1676
|
+
function canonicalKey(path) {
|
|
1677
|
+
try {
|
|
1678
|
+
const rest = [];
|
|
1679
|
+
let current = resolve(path);
|
|
1680
|
+
while (!lstatSync(current, { throwIfNoEntry: false }) && dirname(current) !== current) {
|
|
1681
|
+
rest.unshift(basename(current));
|
|
1682
|
+
current = dirname(current);
|
|
1683
|
+
}
|
|
1684
|
+
return join(realpathSync(current), ...rest);
|
|
1685
|
+
} catch {
|
|
1686
|
+
return resolve(path);
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
function inside(parent, path) {
|
|
1690
|
+
const rel = relative(parent, path);
|
|
1691
|
+
return rel === "" || rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
1692
|
+
}
|
|
1693
|
+
/** Basenames of running executables, or null when the process list cannot be read. */
|
|
1694
|
+
async function runningExecutables() {
|
|
1695
|
+
try {
|
|
1696
|
+
const out = await getExecutor().runFileAsync("ps", [
|
|
1697
|
+
"-axww",
|
|
1698
|
+
"-o",
|
|
1699
|
+
"comm="
|
|
1700
|
+
], { timeoutMs: 1e4 });
|
|
1701
|
+
return new Set(out.split("\n").map((line) => line.trim()).filter(Boolean).map((line) => basename(line)));
|
|
1702
|
+
} catch {
|
|
1703
|
+
return null;
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
function stimKept(identity, scan, projects) {
|
|
1707
|
+
const registered = projects.find((root) => inside(identity, root) || inside(root, identity));
|
|
1708
|
+
if (registered) return `the registered project ${registered} is in it; gc handles it as a project entry`;
|
|
1709
|
+
if (scan.unreadable) return scan.unreadable;
|
|
1710
|
+
if (scan.locks.length) return `the lock directory ${scan.locks[0]} has no verifiable owner`;
|
|
1711
|
+
for (const root of scan.claimRoots) {
|
|
1712
|
+
const survey = readClaimSet(root);
|
|
1713
|
+
if (survey.live.length) return `it holds a live ownership claim at ${root}`;
|
|
1714
|
+
if (survey.unresolved.length) return `the ownership claim at ${root} cannot be verified`;
|
|
1715
|
+
}
|
|
1716
|
+
return null;
|
|
1717
|
+
}
|
|
1718
|
+
/** The temporary root `env` selects, with Node's `os.tmpdir()` precedence. */
|
|
1719
|
+
function temporaryRoot(env) {
|
|
1720
|
+
if (env === process.env) return tmpdir();
|
|
1721
|
+
const chosen = process.platform === "win32" ? env.TEMP || env.TMP || join(env.SystemRoot || env.windir || "C:\\Windows", "temp") : env.TMPDIR || env.TMP || env.TEMP || "/tmp";
|
|
1722
|
+
const driveRoot = process.platform === "win32" && chosen.endsWith(":\\");
|
|
1723
|
+
return chosen.length > 1 && chosen.endsWith(sep) && !driveRoot ? chosen.slice(0, -1) : chosen;
|
|
1724
|
+
}
|
|
1725
|
+
/**
|
|
1726
|
+
* Recognized leftovers directly inside the user's TMPDIR, never following a symlink: Stim's own temp directories,
|
|
1727
|
+
* xctrace recordings and Jest's cache. Only the first two are ever deleted, and only once nothing in them changed for
|
|
1728
|
+
* 24 hours. Null with STIM_HOME set, because TMPDIR is shared by every Stim home, and for the real TMPDIR a test
|
|
1729
|
+
* process recorded in STIM_TEST_GUARD_REAL_TMPDIR.
|
|
1730
|
+
*/
|
|
1731
|
+
async function collectTempLeftovers({ now = Date.now(), env = process.env, signal } = {}) {
|
|
1732
|
+
if (deviceSweepIsScoped(env)) return null;
|
|
1733
|
+
let root;
|
|
1734
|
+
let names;
|
|
1735
|
+
try {
|
|
1736
|
+
root = await realpath(temporaryRoot(env));
|
|
1737
|
+
const guarded = process.env[TEMP_LEFTOVERS_TEST_GUARD_ENV];
|
|
1738
|
+
if (guarded && root === guarded) return null;
|
|
1739
|
+
names = await readdir(root);
|
|
1740
|
+
} catch {
|
|
1741
|
+
return null;
|
|
1742
|
+
}
|
|
1743
|
+
const uid = process.getuid?.();
|
|
1744
|
+
const jestName = uid === void 0 ? null : `jest_${uid.toString(36)}`;
|
|
1745
|
+
const registeredProjects = () => Object.keys(loadConfig$1()?.projects ?? {}).flatMap((key) => [key, canonicalKey(key)]);
|
|
1746
|
+
let projects = registeredProjects();
|
|
1747
|
+
let executables;
|
|
1748
|
+
const traceBusy = async () => {
|
|
1749
|
+
if (executables === void 0) executables = await runningExecutables();
|
|
1750
|
+
const listed = executables;
|
|
1751
|
+
if (listed === null) return "the running processes could not be listed";
|
|
1752
|
+
const running = [...TRACE_PROCESSES].filter((name) => listed.has(name));
|
|
1753
|
+
return running.length ? `${running.join(" and ")} is running` : null;
|
|
1754
|
+
};
|
|
1755
|
+
const judge = async (path, stat, kind, identity = path) => {
|
|
1756
|
+
const scan = await walk(path, stat, signal);
|
|
1757
|
+
const kept = now - scan.newest < MIN_AGE_MS ? `modified within the last ${TEMP_LEFTOVER_MIN_AGE_HOURS}h` : kind === "stim" ? stimKept(identity, scan, projects) : scan.unreadable ?? await traceBusy();
|
|
1758
|
+
return {
|
|
1759
|
+
bytes: scan.bytes,
|
|
1760
|
+
kept
|
|
1761
|
+
};
|
|
1762
|
+
};
|
|
1763
|
+
const recheck = async (staged, original, kind) => {
|
|
1764
|
+
const stat = await lstatOrError(staged);
|
|
1765
|
+
if (!stat || "error" in stat || stat.isSymbolicLink()) return "it changed while it was being deleted";
|
|
1766
|
+
if (kind === "xctrace") executables = void 0;
|
|
1767
|
+
else projects = registeredProjects();
|
|
1768
|
+
return (await judge(staged, stat, kind, original)).kept;
|
|
1769
|
+
};
|
|
1770
|
+
const entries = [];
|
|
1771
|
+
for (const name of names.toSorted()) {
|
|
1772
|
+
const path = join(root, name);
|
|
1773
|
+
const stat = await lstatOrError(path);
|
|
1774
|
+
if (!stat || "error" in stat || stat.isSymbolicLink() || uid !== void 0 && stat.uid !== uid) continue;
|
|
1775
|
+
const kind = kindOf(name, stat, jestName);
|
|
1776
|
+
if (!kind) continue;
|
|
1777
|
+
if (kind === "jest") {
|
|
1778
|
+
const bytes = await directorySizeAsync(path, { timeoutMs: 1e4 });
|
|
1779
|
+
entries.push({
|
|
1780
|
+
kind,
|
|
1781
|
+
path,
|
|
1782
|
+
bytes,
|
|
1783
|
+
willDelete: false,
|
|
1784
|
+
kept: "report only: Jest owns it"
|
|
1785
|
+
});
|
|
1786
|
+
continue;
|
|
1787
|
+
}
|
|
1788
|
+
const { bytes, kept } = await judge(path, stat, kind, kind === "stim" && name.startsWith(STAGING_PREFIX) ? join(root, name.slice(13)) : path);
|
|
1789
|
+
entries.push({
|
|
1790
|
+
kind,
|
|
1791
|
+
path,
|
|
1792
|
+
bytes,
|
|
1793
|
+
willDelete: kept === null,
|
|
1794
|
+
kept
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
return {
|
|
1798
|
+
root,
|
|
1799
|
+
entries,
|
|
1800
|
+
recheck
|
|
1801
|
+
};
|
|
1802
|
+
}
|
|
1803
|
+
function summarize(report) {
|
|
1804
|
+
return [
|
|
1805
|
+
"stim",
|
|
1806
|
+
"xctrace",
|
|
1807
|
+
"jest"
|
|
1808
|
+
].flatMap((kind) => {
|
|
1809
|
+
const entries = report.entries.filter((entry) => entry.kind === kind);
|
|
1810
|
+
if (!entries.length) return [];
|
|
1811
|
+
const deleting = entries.filter((entry) => entry.willDelete);
|
|
1812
|
+
const kept = /* @__PURE__ */ new Map();
|
|
1813
|
+
for (const entry of entries) if (entry.kept) kept.set(entry.kept, (kept.get(entry.kept) ?? 0) + 1);
|
|
1814
|
+
return [{
|
|
1815
|
+
kind,
|
|
1816
|
+
count: entries.length,
|
|
1817
|
+
bytes: entries.reduce((sum, entry) => sum + entry.bytes, 0),
|
|
1818
|
+
deleteCount: deleting.length,
|
|
1819
|
+
deleteBytes: deleting.reduce((sum, entry) => sum + entry.bytes, 0),
|
|
1820
|
+
kept: [...kept].map(([reason, count]) => ({
|
|
1821
|
+
reason,
|
|
1822
|
+
count
|
|
1823
|
+
})),
|
|
1824
|
+
remedy: REMEDIES[kind]
|
|
1825
|
+
}];
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
function tempLeftoverLines(report) {
|
|
1829
|
+
if (!report?.entries.length) return [];
|
|
1830
|
+
const groups = summarize(report);
|
|
1831
|
+
const total = groups.reduce((sum, group) => sum + group.bytes, 0);
|
|
1832
|
+
const lines = [`TMPDIR leftovers (${formatBytes(total)} in ${report.entries.length}) - ${report.root}:`];
|
|
1833
|
+
for (const group of groups) {
|
|
1834
|
+
lines.push(` ${formatBytes(group.bytes).padStart(10)} ${LABELS[group.kind]}: ${group.count}`);
|
|
1835
|
+
if (group.deleteCount) lines.push(` --delete would DELETE ${group.deleteCount} (${formatBytes(group.deleteBytes)})`);
|
|
1836
|
+
for (const { reason, count } of group.kept) lines.push(` kept ${count}: ${reason}`);
|
|
1837
|
+
lines.push(` remedy: ${group.remedy}`);
|
|
1838
|
+
}
|
|
1839
|
+
return lines;
|
|
1840
|
+
}
|
|
1841
|
+
function tempLeftoverSection(report) {
|
|
1842
|
+
return report ? {
|
|
1843
|
+
root: report.root,
|
|
1844
|
+
groups: summarize(report)
|
|
1845
|
+
} : null;
|
|
1846
|
+
}
|
|
1847
|
+
/** Deletes what a fresh `collectTempLeftovers` marks for deletion. Returns the bytes freed and the failures. */
|
|
1848
|
+
async function deleteTempLeftovers(options = {}) {
|
|
1849
|
+
const report = await collectTempLeftovers(options);
|
|
1850
|
+
const done = {
|
|
1851
|
+
count: 0,
|
|
1852
|
+
bytes: 0,
|
|
1853
|
+
failures: 0
|
|
1854
|
+
};
|
|
1855
|
+
if (!report) return done;
|
|
1856
|
+
for (const entry of report.entries) {
|
|
1857
|
+
if (!entry.willDelete) continue;
|
|
1858
|
+
try {
|
|
1859
|
+
if (entry.kind === "jest") continue;
|
|
1860
|
+
const name = basename(entry.path);
|
|
1861
|
+
const staged = entry.kind === "xctrace" || name.startsWith(STAGING_PREFIX) ? entry.path : join(report.root, `${STAGING_PREFIX}${name}`);
|
|
1862
|
+
const before = staged === entry.path ? null : await report.recheck?.(entry.path, entry.path, entry.kind) ?? null;
|
|
1863
|
+
if (before) {
|
|
1864
|
+
recordGcResult("tempLeftover", "kept", entry.path, {
|
|
1865
|
+
bytes: entry.bytes,
|
|
1866
|
+
detail: before
|
|
1867
|
+
});
|
|
1868
|
+
continue;
|
|
1869
|
+
}
|
|
1870
|
+
if (staged !== entry.path) await rename(entry.path, staged);
|
|
1871
|
+
let kept;
|
|
1872
|
+
try {
|
|
1873
|
+
const original = name.startsWith(STAGING_PREFIX) ? join(report.root, name.slice(13)) : entry.path;
|
|
1874
|
+
kept = await report.recheck?.(staged, original, entry.kind) ?? null;
|
|
1875
|
+
} catch (error) {
|
|
1876
|
+
kept = `it could not be checked again: ${errorCode(error)}`;
|
|
1877
|
+
if (options.signal?.aborted) {
|
|
1878
|
+
if (staged !== entry.path) await rename(staged, entry.path).catch(() => {});
|
|
1879
|
+
throw error;
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
if (kept) {
|
|
1883
|
+
let detail = kept;
|
|
1884
|
+
try {
|
|
1885
|
+
if (staged !== entry.path) await rename(staged, entry.path);
|
|
1886
|
+
} catch (error) {
|
|
1887
|
+
detail = `${kept}; it stays at ${staged} because it could not be renamed back: ${errorCode(error)}`;
|
|
1888
|
+
console.log(chalk.yellow(`Kept ${entry.path} at ${staged}: ${detail}`));
|
|
1889
|
+
}
|
|
1890
|
+
recordGcResult("tempLeftover", "kept", entry.path, {
|
|
1891
|
+
bytes: entry.bytes,
|
|
1892
|
+
detail
|
|
1893
|
+
});
|
|
1894
|
+
continue;
|
|
1895
|
+
}
|
|
1896
|
+
removeTemporaryEntry(staged);
|
|
1897
|
+
done.count++;
|
|
1898
|
+
done.bytes += entry.bytes;
|
|
1899
|
+
} catch (error) {
|
|
1900
|
+
if (options.signal?.aborted) throw error;
|
|
1901
|
+
if (errorCode(error) === "ENOENT") continue;
|
|
1902
|
+
done.failures++;
|
|
1903
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1904
|
+
console.log(chalk.red(`Could not delete ${entry.path}: ${detail}`));
|
|
1905
|
+
recordGcResult("tempLeftover", "failed", entry.path, {
|
|
1906
|
+
bytes: entry.bytes,
|
|
1907
|
+
detail
|
|
1908
|
+
});
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
if (done.count) {
|
|
1912
|
+
console.log(chalk.green(`Deleted ${plural(done.count, "TMPDIR leftover")} (${formatBytes(done.bytes)}) in ${report.root}`));
|
|
1913
|
+
recordGcResult("tempLeftover", "done", report.root, {
|
|
1914
|
+
bytes: done.bytes,
|
|
1915
|
+
detail: `${plural(done.count, "entry", "entries")} deleted`
|
|
1916
|
+
});
|
|
1917
|
+
}
|
|
1918
|
+
return done;
|
|
1919
|
+
}
|
|
1920
|
+
//#endregion
|
|
1921
|
+
//#region src/maintenance/levels.ts
|
|
1922
|
+
async function planLevel2({ settings, sizes, protectedRoot, pinned, now, operation }) {
|
|
1923
|
+
const windowMs = settings.pressureProtectMinutes * 6e4;
|
|
1924
|
+
const actions = [];
|
|
1925
|
+
const skips = [];
|
|
1926
|
+
const reason = `disk pressure level 2, idle for ${settings.pressureProtectMinutes} minutes`;
|
|
1927
|
+
const outputs = collectWorkspaceOutputs({
|
|
1928
|
+
olderThan: null,
|
|
1929
|
+
now,
|
|
1930
|
+
measure: false
|
|
1931
|
+
}).workspaces.flatMap((entry) => {
|
|
1932
|
+
const root = entry.projectRoot === null ? null : canonicalPath(entry.projectRoot);
|
|
1933
|
+
if (root === null || !entry.willClear || root === protectedRoot || pinned(root)) return [];
|
|
1934
|
+
const used = workspaceLastUsed(root);
|
|
1935
|
+
return Number.isFinite(used) && now - used >= windowMs ? [{
|
|
1936
|
+
root,
|
|
1937
|
+
used
|
|
1938
|
+
}] : [];
|
|
1939
|
+
}).toSorted((a, b) => a.used - b.used);
|
|
1940
|
+
for (const { root } of outputs) {
|
|
1941
|
+
const size = sizes.find((entry) => entry.workspace === root && entry.category === "workspace-outputs");
|
|
1942
|
+
actions.push({
|
|
1943
|
+
kind: "would-clear-outputs",
|
|
1944
|
+
target: root,
|
|
1945
|
+
workspace: root,
|
|
1946
|
+
bytes: size?.bytes ?? 0,
|
|
1947
|
+
reason,
|
|
1948
|
+
level: 2
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
if (!deviceSweepIsScoped(operation?.env ?? process.env)) for (const platform of ["ios", "android"]) for (const record of readParked(platform)) actions.push({
|
|
1952
|
+
kind: "would-delete-parked-device",
|
|
1953
|
+
target: platform === "ios" ? record.udid : record.name,
|
|
1954
|
+
bytes: 0,
|
|
1955
|
+
reason: `parked ${platform} device, disk pressure level 2`,
|
|
1956
|
+
level: 2
|
|
1957
|
+
});
|
|
1958
|
+
const leftovers = await collectTempLeftovers({
|
|
1959
|
+
now,
|
|
1960
|
+
env: operation?.env,
|
|
1961
|
+
signal: operation?.signal
|
|
1962
|
+
});
|
|
1963
|
+
const deletable = leftovers?.entries.filter((entry) => entry.willDelete) ?? [];
|
|
1964
|
+
if (leftovers && deletable.length) actions.push({
|
|
1965
|
+
kind: "would-delete-temp-leftovers",
|
|
1966
|
+
target: leftovers.root,
|
|
1967
|
+
bytes: deletable.reduce((sum, entry) => sum + entry.bytes, 0),
|
|
1968
|
+
reason: "disk pressure level 2, Stim temp directories and xctrace recordings unmodified for 24 hours",
|
|
1969
|
+
level: 2
|
|
1970
|
+
});
|
|
1971
|
+
const options = {
|
|
1972
|
+
now,
|
|
1973
|
+
windowMs,
|
|
1974
|
+
protectedRoot,
|
|
1975
|
+
pinned
|
|
1976
|
+
};
|
|
1977
|
+
const build = operation ? await collectArtifactsAsync("build", options, operation.signal) : collectArtifacts("build", options);
|
|
1978
|
+
for (const candidate of build.candidates) actions.push({
|
|
1979
|
+
kind: "would-clear-build-dirs",
|
|
1980
|
+
target: candidate.path,
|
|
1981
|
+
bytes: 0,
|
|
1982
|
+
reason,
|
|
1983
|
+
level: 2
|
|
1984
|
+
});
|
|
1985
|
+
skips.push(...build.skips);
|
|
1986
|
+
const deviceOptions = {
|
|
1987
|
+
roots: Object.keys(loadConfig$1()?.projects ?? {}).map((root) => ({ root })),
|
|
1988
|
+
windowMs,
|
|
1989
|
+
reason,
|
|
1990
|
+
protectedRoot,
|
|
1991
|
+
pinned,
|
|
1992
|
+
sizes,
|
|
1993
|
+
now,
|
|
1994
|
+
extra: { level: 2 },
|
|
1995
|
+
env: operation?.env
|
|
1996
|
+
};
|
|
1997
|
+
const devices = operation ? await planDeviceDeletionAsync(deviceOptions, operation.signal) : planDeviceDeletion(deviceOptions);
|
|
1998
|
+
actions.push(...devices.actions);
|
|
1999
|
+
skips.push(...devices.skips);
|
|
2000
|
+
return {
|
|
2001
|
+
actions,
|
|
2002
|
+
blocked: [],
|
|
2003
|
+
skips
|
|
2004
|
+
};
|
|
2005
|
+
}
|
|
2006
|
+
async function planGradleDaemonStop(blockers, operation) {
|
|
2007
|
+
if (memorySweepIsScoped(operation?.env ?? process.env)) return { kept: "STIM_HOME scopes this config, so the machine-global Gradle daemons that keep it are not stopped" };
|
|
2008
|
+
const report = await collectMemoryReport({
|
|
2009
|
+
watchman: false,
|
|
2010
|
+
gradle: true
|
|
2011
|
+
}, false, operation);
|
|
2012
|
+
const skipped = report.notices.find((notice) => notice.startsWith("memory sweep skipped"));
|
|
2013
|
+
if (skipped) return { kept: `the running Gradle processes could not be checked: ${skipped}` };
|
|
2014
|
+
const plans = new Map(report.processes.map((entry) => [entry.pid, entry]));
|
|
2015
|
+
for (const pid of blockers) {
|
|
2016
|
+
const plan = plans.get(pid);
|
|
2017
|
+
if (!plan || !plan.reclaimable) return { kept: `Gradle pid ${pid} keeps it: ${plan?.detail ?? "it is not a daemon gc --delete --cache gradle-daemons stops"}` };
|
|
2018
|
+
}
|
|
2019
|
+
return { pids: [...blockers] };
|
|
2020
|
+
}
|
|
2021
|
+
async function planLevel3({ settings, sizes, protectedRoot, pinned, now, refresh, operation }) {
|
|
2022
|
+
const windowMs = settings.pressureProtectMinutes * 6e4;
|
|
2023
|
+
const actions = [];
|
|
2024
|
+
const blocked = [];
|
|
2025
|
+
const skips = [];
|
|
2026
|
+
const reason = `disk pressure level 3, idle for ${settings.pressureProtectMinutes} minutes`;
|
|
2027
|
+
for (const size of sizes.filter((entry) => entry.category === "compilation-cache")) if (size.blocked) {
|
|
2028
|
+
skips.push({
|
|
2029
|
+
target: size.dir,
|
|
2030
|
+
reason: size.blocked
|
|
2031
|
+
});
|
|
2032
|
+
blocked.push(`the Swift compilation cache (${formatBytes(size.bytes)}) can only be emptied whole and is kept: ${size.blocked}`);
|
|
2033
|
+
} else actions.push({
|
|
2034
|
+
kind: "would-empty-cache",
|
|
2035
|
+
target: size.dir,
|
|
2036
|
+
dir: size.dir,
|
|
2037
|
+
bytes: size.bytes,
|
|
2038
|
+
reason: "disk pressure level 3",
|
|
2039
|
+
level: 3
|
|
2040
|
+
});
|
|
2041
|
+
const worktrees = settings.removeFinishedWorktrees ? await planWorktrees({
|
|
2042
|
+
protectedRoot,
|
|
2043
|
+
pinned,
|
|
2044
|
+
now,
|
|
2045
|
+
level: 3,
|
|
2046
|
+
graceMs: windowMs,
|
|
2047
|
+
refresh,
|
|
2048
|
+
operation
|
|
2049
|
+
}) : null;
|
|
2050
|
+
actions.push(...worktrees?.actions ?? []);
|
|
2051
|
+
skips.push(...worktrees?.skips ?? []);
|
|
2052
|
+
const activity = operation ? cachedDeviceActivityAsync(now, operation.env) : null;
|
|
2053
|
+
for (const root of Object.keys(loadConfig$1()?.projects ?? {}).map(canonicalPath)) {
|
|
2054
|
+
if (root === protectedRoot || pinned(root)) continue;
|
|
2055
|
+
if (workspaceInUse(root, {
|
|
2056
|
+
managedLocks: false,
|
|
2057
|
+
hosted: false,
|
|
2058
|
+
nativeRun: false
|
|
2059
|
+
}).length === 0) continue;
|
|
2060
|
+
const active = activity ? await workspaceActiveReasonAsync(root, now, windowMs, activity) : workspaceActiveReason(root, now, windowMs);
|
|
2061
|
+
if (active) skips.push({
|
|
2062
|
+
target: root,
|
|
2063
|
+
reason: active,
|
|
2064
|
+
workspace: root
|
|
2065
|
+
});
|
|
2066
|
+
else actions.push({
|
|
2067
|
+
kind: "would-stop-workspace",
|
|
2068
|
+
target: root,
|
|
2069
|
+
workspace: root,
|
|
2070
|
+
bytes: 0,
|
|
2071
|
+
reason,
|
|
2072
|
+
level: 3
|
|
2073
|
+
});
|
|
2074
|
+
}
|
|
2075
|
+
const artifactOptions = {
|
|
2076
|
+
now,
|
|
2077
|
+
windowMs,
|
|
2078
|
+
protectedRoot,
|
|
2079
|
+
pinned
|
|
2080
|
+
};
|
|
2081
|
+
const dependencies = operation ? await collectArtifactsAsync("dependencies", artifactOptions, operation.signal) : collectArtifacts("dependencies", artifactOptions);
|
|
2082
|
+
for (const candidate of dependencies.candidates) actions.push({
|
|
2083
|
+
kind: "would-delete-dependencies",
|
|
2084
|
+
target: candidate.path,
|
|
2085
|
+
bytes: 0,
|
|
2086
|
+
reason,
|
|
2087
|
+
level: 3
|
|
2088
|
+
});
|
|
2089
|
+
skips.push(...dependencies.skips);
|
|
2090
|
+
if (settings.gradleVersionIdleDays > 0) {
|
|
2091
|
+
const gradle = await collectGradleVersions({
|
|
2092
|
+
operation,
|
|
2093
|
+
olderThanDays: settings.gradleVersionIdleDays,
|
|
2094
|
+
now,
|
|
2095
|
+
measure: false,
|
|
2096
|
+
pastRunningGradle: true
|
|
2097
|
+
});
|
|
2098
|
+
const stop = gradle.gradleRunning && gradle.versions.some((entry) => entry.willDelete) ? await planGradleDaemonStop(gradle.gradleRunningPids, operation) : null;
|
|
2099
|
+
if (stop?.pids) actions.push({
|
|
2100
|
+
kind: "would-stop-gradle-daemons",
|
|
2101
|
+
target: `pid ${stop.pids.join(", ")}`,
|
|
2102
|
+
bytes: 0,
|
|
2103
|
+
reason: "disk pressure level 3, these idle Gradle and Kotlin daemons keep unpinned Gradle versions",
|
|
2104
|
+
level: 3
|
|
2105
|
+
});
|
|
2106
|
+
for (const entry of gradle.versions) if (entry.willDelete && !stop?.kept) actions.push({
|
|
2107
|
+
kind: "would-delete-gradle-version",
|
|
2108
|
+
target: entry.version,
|
|
2109
|
+
dir: gradle.home,
|
|
2110
|
+
bytes: 0,
|
|
2111
|
+
reason: `disk pressure level 3, no registered project pins it and it is unmodified for ${settings.gradleVersionIdleDays} days`,
|
|
2112
|
+
level: 3
|
|
2113
|
+
});
|
|
2114
|
+
else skips.push({
|
|
2115
|
+
target: `Gradle ${entry.version}`,
|
|
2116
|
+
reason: entry.willDelete && stop?.kept || entry.detail || "kept"
|
|
2117
|
+
});
|
|
2118
|
+
}
|
|
2119
|
+
return {
|
|
2120
|
+
actions,
|
|
2121
|
+
blocked,
|
|
2122
|
+
skips,
|
|
2123
|
+
...worktrees ? { sweep: worktrees.sweep } : {}
|
|
2124
|
+
};
|
|
2125
|
+
}
|
|
2126
|
+
/** Level 2 and 3 actions for a pass that measured `level`; level 1 is the regular plan. */
|
|
2127
|
+
async function planPressureLevels(level, context) {
|
|
2128
|
+
if (level < 1) return {
|
|
2129
|
+
actions: [],
|
|
2130
|
+
blocked: [],
|
|
2131
|
+
skips: []
|
|
2132
|
+
};
|
|
2133
|
+
const second = await planLevel2(context);
|
|
2134
|
+
if (level < 3) return second;
|
|
2135
|
+
const third = await planLevel3(context);
|
|
2136
|
+
return {
|
|
2137
|
+
actions: [...second.actions, ...third.actions],
|
|
2138
|
+
blocked: third.blocked,
|
|
2139
|
+
skips: [...second.skips, ...third.skips],
|
|
2140
|
+
...third.sweep ? { pressureSweep: third.sweep } : {}
|
|
2141
|
+
};
|
|
2142
|
+
}
|
|
2143
|
+
//#endregion
|
|
2144
|
+
//#region src/maintenance/preview.ts
|
|
2145
|
+
function triggeringRoot(operation) {
|
|
2146
|
+
return canonicalPath(findProjectRoot$1(operation?.cwd ?? process.cwd()) ?? join(configDir(), "maintenance", "no-project"));
|
|
2147
|
+
}
|
|
2148
|
+
async function plannedMaintenance(pressure, sizes, settings, { devices = true, sweep = false, worktrees = false, refresh = true, operation } = {}) {
|
|
2149
|
+
const env = operation?.env ?? process.env;
|
|
2150
|
+
const resolved = resolveBudget({ env });
|
|
2151
|
+
if (resolved.error) throw new Error(resolved.error);
|
|
2152
|
+
const budget = {
|
|
2153
|
+
...resolved.budget,
|
|
2154
|
+
maxCommittedMemoryMb: 0,
|
|
2155
|
+
maxLiveWorkspaces: 0
|
|
2156
|
+
};
|
|
2157
|
+
const root = triggeringRoot(operation);
|
|
2158
|
+
const floor = Math.max(budget.minFreeDiskMb, budget.hardFloorDiskMb);
|
|
2159
|
+
const low = pressure?.disk.some((disk) => disk.freeMb < floor);
|
|
2160
|
+
const outcome = low ? await enforceBudget({
|
|
2161
|
+
root,
|
|
2162
|
+
note: () => {},
|
|
2163
|
+
dryRun: true,
|
|
2164
|
+
budget,
|
|
2165
|
+
env,
|
|
2166
|
+
cwd: operation?.cwd
|
|
2167
|
+
}, { volumes: () => pressure.disk }) : null;
|
|
2168
|
+
if (outcome && outcome.status !== "ok") throw new Error(outcome.refusal.message);
|
|
2169
|
+
const outputs = collectWorkspaceOutputs({
|
|
2170
|
+
olderThan: null,
|
|
2171
|
+
now: Date.now(),
|
|
2172
|
+
measure: false
|
|
2173
|
+
});
|
|
2174
|
+
const currentSizes = sizes.map((size) => {
|
|
2175
|
+
if (size.category === "compilation-cache") return {
|
|
2176
|
+
...size,
|
|
2177
|
+
blocked: cacheBlocked(size, discoverCaches(env, operation?.cwd), env)
|
|
2178
|
+
};
|
|
2179
|
+
if (!size.workspace) return size;
|
|
2180
|
+
const current = outputs.workspaces.find((entry) => entry.projectRoot && canonicalPath(entry.projectRoot) === size.workspace);
|
|
2181
|
+
return Object.assign({}, size, { blocked: current?.willClear ? void 0 : current?.keptReason ?? "workspace outputs cannot be resolved" });
|
|
2182
|
+
});
|
|
2183
|
+
const pins = /* @__PURE__ */ new Map();
|
|
2184
|
+
if (operation) {
|
|
2185
|
+
const candidates = await candidateRootsAsync();
|
|
2186
|
+
const roots = /* @__PURE__ */ new Set([
|
|
2187
|
+
...Object.keys(loadConfig()?.projects ?? {}),
|
|
2188
|
+
...candidates,
|
|
2189
|
+
...sizes.flatMap((size) => size.workspace ? [size.workspace] : []),
|
|
2190
|
+
...outputs.workspaces.flatMap((entry) => entry.projectRoot ? [entry.projectRoot] : [])
|
|
2191
|
+
]);
|
|
2192
|
+
for (const path of candidates) for (const entry of await listWorktreesAsync(path)) roots.add(entry.path);
|
|
2193
|
+
for (const path of Array.from(roots, canonicalPath)) roots.add(path);
|
|
2194
|
+
for (const path of roots) pins.set(path, await maintenancePinnedAsync(path, operation));
|
|
2195
|
+
}
|
|
2196
|
+
const pinned = (workspace) => {
|
|
2197
|
+
let value = pins.get(workspace);
|
|
2198
|
+
if (operation) return value ?? true;
|
|
2199
|
+
if (value === void 0) pins.set(workspace, value = maintenancePinned(workspace));
|
|
2200
|
+
return value;
|
|
2201
|
+
};
|
|
2202
|
+
const result = plan({
|
|
2203
|
+
pressure,
|
|
2204
|
+
sizes: currentSizes,
|
|
2205
|
+
settings,
|
|
2206
|
+
budget,
|
|
2207
|
+
diskSteps: outcome?.status === "ok" ? outcome.reclaimed.filter((step) => devices || step.step !== "idle-devices") : [],
|
|
2208
|
+
protectedRoot: root,
|
|
2209
|
+
scoped: Boolean(env.STIM_HOME),
|
|
2210
|
+
projects: Object.keys(loadConfig()?.projects ?? {}).map(canonicalPath),
|
|
2211
|
+
pinned,
|
|
2212
|
+
recentlyUsed: (workspace) => recentUse(workspace, settings)
|
|
2213
|
+
});
|
|
2214
|
+
const merge = (extra) => {
|
|
2215
|
+
for (const action of extra.actions) if (!result.actions.some((entry) => entry.kind === action.kind && entry.target === action.target && (entry.level === action.level || action.kind === "would-delete-devices"))) result.actions.push(action);
|
|
2216
|
+
result.blocked.push(...extra.blocked);
|
|
2217
|
+
result.skips.push(...extra.skips);
|
|
2218
|
+
};
|
|
2219
|
+
if (sweep) merge(await planSweep({
|
|
2220
|
+
settings,
|
|
2221
|
+
sizes,
|
|
2222
|
+
protectedRoot: root,
|
|
2223
|
+
pinned,
|
|
2224
|
+
now: Date.now(),
|
|
2225
|
+
devices,
|
|
2226
|
+
operation
|
|
2227
|
+
}));
|
|
2228
|
+
if (worktrees) {
|
|
2229
|
+
const planned = await planWorktrees({
|
|
2230
|
+
protectedRoot: root,
|
|
2231
|
+
pinned,
|
|
2232
|
+
now: Date.now(),
|
|
2233
|
+
sizes: currentSizes,
|
|
2234
|
+
protectRecentMs: settings.protectRecentHours * 36e5,
|
|
2235
|
+
refresh,
|
|
2236
|
+
operation
|
|
2237
|
+
});
|
|
2238
|
+
merge(planned);
|
|
2239
|
+
result.worktreeSweep = planned.sweep;
|
|
2240
|
+
}
|
|
2241
|
+
if (pressure) {
|
|
2242
|
+
const pressured = await planPressureLevels(diskPressureLevel(lowestFree(pressure.disk), budget, settings), {
|
|
2243
|
+
settings,
|
|
2244
|
+
sizes: currentSizes,
|
|
2245
|
+
protectedRoot: root,
|
|
2246
|
+
pinned,
|
|
2247
|
+
now: Date.now(),
|
|
2248
|
+
refresh,
|
|
2249
|
+
operation
|
|
2250
|
+
});
|
|
2251
|
+
merge(pressured);
|
|
2252
|
+
if (pressured.pressureSweep) result.pressureSweep = pressured.pressureSweep;
|
|
2253
|
+
}
|
|
2254
|
+
if (low) result.skips.push(...outputs.workspaces.filter((entry) => !entry.willClear && entry.keptReason).map((entry) => ({
|
|
2255
|
+
target: entry.dir,
|
|
2256
|
+
reason: entry.keptReason,
|
|
2257
|
+
workspace: entry.projectRoot ?? void 0
|
|
2258
|
+
})));
|
|
2259
|
+
return result;
|
|
2260
|
+
}
|
|
2261
|
+
async function previewMaintenance({ devices = true, refresh = true, operation } = {}) {
|
|
2262
|
+
let settings;
|
|
2263
|
+
try {
|
|
2264
|
+
settings = resolveMaintenanceSettings(void 0, operation?.env ?? process.env);
|
|
2265
|
+
const state = readMaintenanceState();
|
|
2266
|
+
if (settings.invalid && settings.mode === "off") return {
|
|
2267
|
+
mode: "off",
|
|
2268
|
+
pressure: null,
|
|
2269
|
+
actions: [],
|
|
2270
|
+
blocked: [],
|
|
2271
|
+
skips: [],
|
|
2272
|
+
invalid: settings.invalid,
|
|
2273
|
+
note: settings.invalid
|
|
2274
|
+
};
|
|
2275
|
+
if (settings.mode === "off") return {
|
|
2276
|
+
mode: "off",
|
|
2277
|
+
pressure: null,
|
|
2278
|
+
actions: [],
|
|
2279
|
+
blocked: [],
|
|
2280
|
+
skips: [],
|
|
2281
|
+
note: "maintenance.mode is off"
|
|
2282
|
+
};
|
|
2283
|
+
const pressure = operation ? await measurePressureAsync(settings, state?.pressure ?? null, Date.now(), operation) : measurePressure(settings, state?.pressure ?? null, Date.now());
|
|
2284
|
+
return {
|
|
2285
|
+
...await plannedMaintenance(pressure, state?.sizes ?? [], settings, {
|
|
2286
|
+
devices,
|
|
2287
|
+
sweep: true,
|
|
2288
|
+
refresh,
|
|
2289
|
+
operation
|
|
2290
|
+
}),
|
|
2291
|
+
mode: settings.mode,
|
|
2292
|
+
...settings.invalid ? { invalid: settings.invalid } : {},
|
|
2293
|
+
pressure,
|
|
2294
|
+
note: settings.invalid ?? (state === null ? "no pass has run yet; sizes have not been measured" : null)
|
|
2295
|
+
};
|
|
2296
|
+
} catch (error) {
|
|
2297
|
+
return {
|
|
2298
|
+
mode: settings?.mode ?? "off",
|
|
2299
|
+
...settings?.invalid ? { invalid: settings.invalid } : {},
|
|
2300
|
+
pressure: null,
|
|
2301
|
+
actions: [],
|
|
2302
|
+
blocked: [String(error)],
|
|
2303
|
+
skips: [],
|
|
2304
|
+
note: "maintenance preview failed"
|
|
2305
|
+
};
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
//#endregion
|
|
2309
|
+
//#region src/maintenance/act.ts
|
|
2310
|
+
function freedSince(beforeMb) {
|
|
2311
|
+
const freed = lowestFree(measureDisk()) - beforeMb;
|
|
2312
|
+
return Number.isFinite(freed) && freed > 0 ? Math.round(freed * 1024 * 1024) : 0;
|
|
2313
|
+
}
|
|
2314
|
+
const kept = (detail) => ({
|
|
2315
|
+
status: "kept",
|
|
2316
|
+
bytes: 0,
|
|
2317
|
+
detail
|
|
2318
|
+
});
|
|
2319
|
+
async function quietly(run) {
|
|
2320
|
+
const { log, error } = {
|
|
2321
|
+
log: console.log,
|
|
2322
|
+
error: console.error
|
|
2323
|
+
};
|
|
2324
|
+
const exitCode = process.exitCode;
|
|
2325
|
+
takeGcResults();
|
|
2326
|
+
console.log = () => {};
|
|
2327
|
+
console.error = () => {};
|
|
2328
|
+
try {
|
|
2329
|
+
return {
|
|
2330
|
+
value: await run(),
|
|
2331
|
+
results: takeGcResults()
|
|
2332
|
+
};
|
|
2333
|
+
} finally {
|
|
2334
|
+
console.log = log;
|
|
2335
|
+
console.error = error;
|
|
2336
|
+
process.exitCode = exitCode;
|
|
2337
|
+
takeGcResults();
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
function outcomeOf(results, fallbackBytes) {
|
|
2341
|
+
const failed = results.find((result) => result.status === "failed");
|
|
2342
|
+
if (failed) return {
|
|
2343
|
+
status: "failed",
|
|
2344
|
+
bytes: 0,
|
|
2345
|
+
detail: failed.detail ?? `could not process ${failed.label}`
|
|
2346
|
+
};
|
|
2347
|
+
const done = results.filter((result) => result.status === "done");
|
|
2348
|
+
if (done.length) return {
|
|
2349
|
+
status: "done",
|
|
2350
|
+
bytes: done.reduce((sum, result) => sum + (result.bytes ?? 0), 0) || fallbackBytes,
|
|
2351
|
+
detail: done.map((result) => result.label).join(", ")
|
|
2352
|
+
};
|
|
2353
|
+
return kept(results.find((result) => result.detail)?.detail ?? "nothing was eligible when the action ran");
|
|
2354
|
+
}
|
|
2355
|
+
function rootCache(dir) {
|
|
2356
|
+
const name = canonicalPath(dir) === canonicalPath(buildCacheRoot()) ? "Build cache" : canonicalPath(dir) === canonicalPath(metroCacheRoot()) ? "Metro transform caches" : null;
|
|
2357
|
+
return name ? {
|
|
2358
|
+
name,
|
|
2359
|
+
dir,
|
|
2360
|
+
prune: "entries",
|
|
2361
|
+
entriesDepth: name === "Build cache" ? 2 : 3,
|
|
2362
|
+
note: ""
|
|
2363
|
+
} : null;
|
|
2364
|
+
}
|
|
2365
|
+
function cacheOf(action) {
|
|
2366
|
+
const discovered = discoverCaches();
|
|
2367
|
+
if (action.dir) {
|
|
2368
|
+
const dir = canonicalPath(action.dir);
|
|
2369
|
+
return discovered.find((cache) => canonicalPath(cache.dir) === dir) ?? rootCache(action.dir);
|
|
2370
|
+
}
|
|
2371
|
+
return discovered.find((cache) => cacheLabel(cache) === action.target) ?? null;
|
|
2372
|
+
}
|
|
2373
|
+
function trimCache(action, context) {
|
|
2374
|
+
const cache = cacheOf(action);
|
|
2375
|
+
if (!cache) return kept("it is no longer a cache Stim may trim");
|
|
2376
|
+
const [planned] = planCacheEmptying([cache], false);
|
|
2377
|
+
if (!planned || planned.machineGlobal) return kept(planned?.machineGlobal ?? "it is no longer a cache Stim may trim");
|
|
2378
|
+
const aged = action.olderThanDays !== void 0;
|
|
2379
|
+
const result = pruneCache(cache, {
|
|
2380
|
+
olderThanDays: Math.max(action.olderThanDays ?? 0, context.settings.protectRecentHours / 24),
|
|
2381
|
+
byMtime: true,
|
|
2382
|
+
protect: context.protect ??= cacheEntryProtection(),
|
|
2383
|
+
...aged ? {} : { evictBytes: action.bytes }
|
|
2384
|
+
});
|
|
2385
|
+
if (result.skipped) return kept(result.skipped);
|
|
2386
|
+
if (result.failed) return {
|
|
2387
|
+
status: "failed",
|
|
2388
|
+
bytes: result.bytes,
|
|
2389
|
+
detail: `${result.failed} entries could not be removed`
|
|
2390
|
+
};
|
|
2391
|
+
const protectedNote = result.protectedEntries ? `; ${result.protectedEntries} protected entries kept` : "";
|
|
2392
|
+
if (result.removed === 0) return kept(`no unprotected entry was old enough${protectedNote}`);
|
|
2393
|
+
return {
|
|
2394
|
+
status: "done",
|
|
2395
|
+
bytes: result.bytes,
|
|
2396
|
+
detail: `${result.removed} entries${protectedNote}`,
|
|
2397
|
+
...!aged && result.bytes < action.bytes ? { shortfall: `${action.target} reached ${result.bytes} of ${action.bytes} bytes${protectedNote}` } : {}
|
|
2398
|
+
};
|
|
2399
|
+
}
|
|
2400
|
+
async function emptyCompilationCache(action, context) {
|
|
2401
|
+
const floor = action.level === 3 ? levelFloors(context.budget, context.settings)[3] : context.budget.hardFloorDiskMb;
|
|
2402
|
+
if (!measureDisk().some((disk) => disk.freeMb < floor)) return kept(action.level === 3 ? "free disk is back above the level 3 floor, and this cache can only be emptied whole" : "free disk is above budget.hardFloorDiskGb, and this cache can only be emptied whole");
|
|
2403
|
+
const dir = sharedCompilationCache();
|
|
2404
|
+
const blocked = cacheBlocked({
|
|
2405
|
+
name: "Swift compilation cache",
|
|
2406
|
+
dir,
|
|
2407
|
+
category: "compilation-cache"
|
|
2408
|
+
});
|
|
2409
|
+
if (blocked) return kept(blocked);
|
|
2410
|
+
const cache = discoverCaches().find((candidate) => canonicalPath(candidate.dir) === canonicalPath(dir));
|
|
2411
|
+
if (cache?.prune !== "atomic") return kept("it is not a registered cache Stim empties whole");
|
|
2412
|
+
const [planned] = planCacheEmptying([{
|
|
2413
|
+
...cache,
|
|
2414
|
+
bytes: action.bytes
|
|
2415
|
+
}], true);
|
|
2416
|
+
const { results } = await quietly(() => emptyCaches(planned ? [planned] : []));
|
|
2417
|
+
return outcomeOf(results, action.bytes);
|
|
2418
|
+
}
|
|
2419
|
+
async function clearOutputs(action, context) {
|
|
2420
|
+
const root = action.workspace;
|
|
2421
|
+
if (!root) return kept("the action names no workspace");
|
|
2422
|
+
if (root === context.protectedRoot) return kept("the command that started this pass runs in it");
|
|
2423
|
+
if (maintenancePinned(root)) return kept("pinned by maintenance.keep");
|
|
2424
|
+
const recent = recentUse(root, context.settings, protectWindowMs(action, context.settings));
|
|
2425
|
+
if (recent) return kept(recent);
|
|
2426
|
+
const olderThan = action.olderThanDays ?? null;
|
|
2427
|
+
const now = Date.now();
|
|
2428
|
+
const report = collectWorkspaceOutputs({
|
|
2429
|
+
olderThan,
|
|
2430
|
+
now,
|
|
2431
|
+
measure: false
|
|
2432
|
+
});
|
|
2433
|
+
const entry = report.workspaces.find((candidate) => candidate.projectRoot && canonicalPath(candidate.projectRoot) === root);
|
|
2434
|
+
if (!entry) return kept("it has no build outputs any more");
|
|
2435
|
+
const { results } = await quietly(() => clearWorkspaceOutputs({
|
|
2436
|
+
...report,
|
|
2437
|
+
workspaces: [entry]
|
|
2438
|
+
}, {
|
|
2439
|
+
olderThan,
|
|
2440
|
+
now
|
|
2441
|
+
}));
|
|
2442
|
+
return outcomeOf(results, action.bytes);
|
|
2443
|
+
}
|
|
2444
|
+
async function removeWorktree(action, context) {
|
|
2445
|
+
const sweep = action.level === 3 ? context.pressureSweep : context.worktreeSweep;
|
|
2446
|
+
const worktree = sweep?.worktrees.find((candidate) => candidate.path === action.target);
|
|
2447
|
+
if (!sweep || !worktree) return kept("it is not in this pass's worktree check");
|
|
2448
|
+
const path = canonicalPath(worktree.path);
|
|
2449
|
+
if (context.protectedRoot === path || context.protectedRoot.startsWith(`${path}${sep}`)) return kept("the command that started this pass runs inside it");
|
|
2450
|
+
if (worktree.keys.some(maintenancePinned)) return kept("pinned by maintenance.keep");
|
|
2451
|
+
const before = lowestFree(measureDisk());
|
|
2452
|
+
const { results } = await quietly(() => removeWorktrees({
|
|
2453
|
+
...sweep,
|
|
2454
|
+
worktrees: [worktree]
|
|
2455
|
+
}));
|
|
2456
|
+
return outcomeOf(results, freedSince(before));
|
|
2457
|
+
}
|
|
2458
|
+
async function removeOrphan(action) {
|
|
2459
|
+
const orphan = (await collectOrphanedWorkspaces(Object.keys(loadConfig$1()?.projects ?? {}), listMountedVolumes())).orphaned.find((candidate) => candidate.dir === action.target);
|
|
2460
|
+
if (!orphan) return kept("it is no longer an orphaned workspace directory");
|
|
2461
|
+
const { results } = await quietly(() => deleteOrphanedWorkspaces([orphan]));
|
|
2462
|
+
return outcomeOf(results, orphan.bytes ?? 0);
|
|
2463
|
+
}
|
|
2464
|
+
async function deleteParkedDevice(action) {
|
|
2465
|
+
if (deviceSweepIsScoped()) return kept("STIM_HOME scopes this config, and devices are machine-wide");
|
|
2466
|
+
const sims = (await collectParkedSims({})).filter((sim) => sim.udid === action.target);
|
|
2467
|
+
const avds = sims.length ? [] : (await collectParkedAvds({})).filter((avd) => avd.name === action.target);
|
|
2468
|
+
if (!sims.length && !avds.length) return kept("it is no longer parked");
|
|
2469
|
+
const before = lowestFree(measureDisk());
|
|
2470
|
+
const { results } = await quietly(async () => await deleteParkedSims(sims) + await deleteParkedAvds(avds));
|
|
2471
|
+
return outcomeOf(results, freedSince(before));
|
|
2472
|
+
}
|
|
2473
|
+
async function deleteOwnedDevicesOf(root, { noneIsKept = false } = {}) {
|
|
2474
|
+
if (deviceSweepIsScoped()) return null;
|
|
2475
|
+
const config = loadConfig$1();
|
|
2476
|
+
const project = config?.projects?.[root];
|
|
2477
|
+
if (!config || !project) return null;
|
|
2478
|
+
const owns = (kind) => projectDeviceSlots(project).some(({ platforms }) => platforms[kind]?.owned === true);
|
|
2479
|
+
let sims = [];
|
|
2480
|
+
let avds = [];
|
|
2481
|
+
try {
|
|
2482
|
+
if (owns("ios")) sims = listAllIosSims({ timeoutMs: DEVICE_LIST_TIMEOUT_MS });
|
|
2483
|
+
if (owns("android")) avds = listAvds();
|
|
2484
|
+
} catch (error) {
|
|
2485
|
+
return {
|
|
2486
|
+
status: "failed",
|
|
2487
|
+
bytes: 0,
|
|
2488
|
+
detail: `its owned devices could not be listed: ${error instanceof Error ? error.message : String(error)}`
|
|
2489
|
+
};
|
|
2490
|
+
}
|
|
2491
|
+
const devices = findStaleProjectDevices({
|
|
2492
|
+
config: {
|
|
2493
|
+
...config,
|
|
2494
|
+
projects: { [root]: project }
|
|
2495
|
+
},
|
|
2496
|
+
sims,
|
|
2497
|
+
avds,
|
|
2498
|
+
olderThanDays: 0,
|
|
2499
|
+
lastTouched: () => 0
|
|
2500
|
+
});
|
|
2501
|
+
if (noneIsKept && devices.length === 0) return kept("it owns no listed devices any more");
|
|
2502
|
+
const failures = await deleteProjectDevices([], devices, []);
|
|
2503
|
+
return failures ? {
|
|
2504
|
+
status: "failed",
|
|
2505
|
+
bytes: 0,
|
|
2506
|
+
detail: `${failures} owned devices could not be deleted`
|
|
2507
|
+
} : null;
|
|
2508
|
+
}
|
|
2509
|
+
async function stopWorkspace(action, context) {
|
|
2510
|
+
const root = action.workspace ?? action.target;
|
|
2511
|
+
if (root === context.protectedRoot) return kept("the command that started this pass runs in it");
|
|
2512
|
+
if (maintenancePinned(root)) return kept("pinned by maintenance.keep");
|
|
2513
|
+
const windowMs = protectWindowMs(action, context.settings);
|
|
2514
|
+
const active = workspaceActiveReason(root, Date.now(), windowMs);
|
|
2515
|
+
if (active) return kept(active);
|
|
2516
|
+
const before = lowestFree(measureDisk());
|
|
2517
|
+
const run = await withIdleWorkspace(root, async () => {
|
|
2518
|
+
if (workspaceActiveReason(root, Date.now(), windowMs)) return kept("it became active");
|
|
2519
|
+
const metro = await stopOwnedMetro(root, {
|
|
2520
|
+
by: "automatic maintenance",
|
|
2521
|
+
workspace: context.protectedRoot
|
|
2522
|
+
});
|
|
2523
|
+
if ("reason" in metro) return kept(`its dev server: ${metro.reason}`);
|
|
2524
|
+
return (await quietly(() => deleteOwnedDevicesOf(root))).value;
|
|
2525
|
+
}, {
|
|
2526
|
+
purpose: "automatic maintenance",
|
|
2527
|
+
supervisor: false
|
|
2528
|
+
});
|
|
2529
|
+
if (!run.ran) return kept(`in use: ${run.reasons.join("; ")}`);
|
|
2530
|
+
const bytes = freedSince(before);
|
|
2531
|
+
return run.value ? {
|
|
2532
|
+
...run.value,
|
|
2533
|
+
bytes
|
|
2534
|
+
} : {
|
|
2535
|
+
status: "done",
|
|
2536
|
+
bytes,
|
|
2537
|
+
detail: root
|
|
2538
|
+
};
|
|
2539
|
+
}
|
|
2540
|
+
/**
|
|
2541
|
+
* Deletes the owned simulators and emulators of a workspace that stays idle for `windowMs`, under its native-run
|
|
2542
|
+
* claim, without stopping anything: a running dev server, lease, lock or driver keeps every device.
|
|
2543
|
+
*/
|
|
2544
|
+
async function deleteIdleWorkspaceDevices(root, { windowMs, protectedRoot, purpose }) {
|
|
2545
|
+
if (root === protectedRoot) return kept("the command that started this pass runs in it");
|
|
2546
|
+
if (maintenancePinned(root)) return kept("pinned by maintenance.keep");
|
|
2547
|
+
if (deviceSweepIsScoped()) return kept("STIM_HOME scopes this config, and devices are machine-wide");
|
|
2548
|
+
const active = workspaceActiveReason(root, Date.now(), windowMs);
|
|
2549
|
+
if (active) return kept(active);
|
|
2550
|
+
const before = lowestFree(measureDisk());
|
|
2551
|
+
const run = await withIdleWorkspace(root, async () => workspaceActiveReason(root, Date.now(), windowMs) ? kept("it became active") : deleteOwnedDevicesOf(root, { noneIsKept: true }), { purpose });
|
|
2552
|
+
if (!run.ran) return kept(`in use: ${run.reasons.join("; ")}`);
|
|
2553
|
+
const bytes = freedSince(before);
|
|
2554
|
+
return run.value ? {
|
|
2555
|
+
...run.value,
|
|
2556
|
+
bytes
|
|
2557
|
+
} : {
|
|
2558
|
+
status: "done",
|
|
2559
|
+
bytes,
|
|
2560
|
+
detail: root
|
|
2561
|
+
};
|
|
2562
|
+
}
|
|
2563
|
+
function deviceWindowMs(action, context) {
|
|
2564
|
+
if (action.level !== void 0) return protectWindowMs(action, context.settings);
|
|
2565
|
+
if (action.olderThanDays !== void 0) return action.olderThanDays * 864e5;
|
|
2566
|
+
return Math.max(context.worktreeSweep?.graceMs ?? 0, context.settings.protectRecentHours * 36e5);
|
|
2567
|
+
}
|
|
2568
|
+
async function deleteDevices(action, context) {
|
|
2569
|
+
const { value } = await quietly(() => deleteIdleWorkspaceDevices(action.workspace ?? action.target, {
|
|
2570
|
+
windowMs: deviceWindowMs(action, context),
|
|
2571
|
+
protectedRoot: context.protectedRoot,
|
|
2572
|
+
purpose: "automatic maintenance"
|
|
2573
|
+
}));
|
|
2574
|
+
return value;
|
|
2575
|
+
}
|
|
2576
|
+
async function clearArtifacts(action, context) {
|
|
2577
|
+
return clearArtifact(action, action.kind === "would-delete-dependencies" ? "dependencies" : "build", {
|
|
2578
|
+
windowMs: protectWindowMs(action, context.settings),
|
|
2579
|
+
protectedRoot: context.protectedRoot,
|
|
2580
|
+
pinned: maintenancePinned,
|
|
2581
|
+
worktrees: context.worktrees ??= artifactWorktrees()
|
|
2582
|
+
});
|
|
2583
|
+
}
|
|
2584
|
+
const GRADLE_EXIT_WAIT_MS = 3e3;
|
|
2585
|
+
async function stopGradleDaemonsAction(action) {
|
|
2586
|
+
const targets = [...action.target.matchAll(/\d+/g)].map((match) => Number(match[0]));
|
|
2587
|
+
const { value: failures, results } = await quietly(() => stopIdleGradleDaemons(targets));
|
|
2588
|
+
const daemons = results.filter((result) => result.kind === "gradleDaemon" || result.kind === "kotlinDaemon");
|
|
2589
|
+
const stopped = daemons.filter((result) => result.status === "done");
|
|
2590
|
+
if (failures) return {
|
|
2591
|
+
status: "failed",
|
|
2592
|
+
bytes: 0,
|
|
2593
|
+
detail: `${plural(failures, "daemon")} could not be stopped`
|
|
2594
|
+
};
|
|
2595
|
+
if (stopped.length === 0) return kept(daemons.find((result) => result.detail)?.detail ?? "no daemon was stopped");
|
|
2596
|
+
const left = await waitForGradleExit(stopped.flatMap((result) => result.id && /^\d+$/.test(result.id) ? [Number(result.id)] : []), GRADLE_EXIT_WAIT_MS);
|
|
2597
|
+
const labels = stopped.map((result) => result.label).join(", ");
|
|
2598
|
+
return {
|
|
2599
|
+
status: "done",
|
|
2600
|
+
bytes: 0,
|
|
2601
|
+
detail: left.length ? `${labels}; pid ${left.join(", ")} had not exited yet` : labels
|
|
2602
|
+
};
|
|
2603
|
+
}
|
|
2604
|
+
async function deleteGradleVersionAction(action, context) {
|
|
2605
|
+
const before = lowestFree(measureDisk());
|
|
2606
|
+
try {
|
|
2607
|
+
const result = await deleteGradleVersion(action.target, { olderThanDays: context.settings.gradleVersionIdleDays });
|
|
2608
|
+
return result.deleted ? {
|
|
2609
|
+
status: "done",
|
|
2610
|
+
bytes: freedSince(before),
|
|
2611
|
+
detail: result.detail
|
|
2612
|
+
} : kept(result.detail);
|
|
2613
|
+
} catch (error) {
|
|
2614
|
+
return {
|
|
2615
|
+
status: "failed",
|
|
2616
|
+
bytes: freedSince(before),
|
|
2617
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
2618
|
+
};
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
async function deleteTempLeftoversAction() {
|
|
2622
|
+
const { value: done } = await quietly(() => deleteTempLeftovers());
|
|
2623
|
+
if (done.failures) return {
|
|
2624
|
+
status: "failed",
|
|
2625
|
+
bytes: done.bytes,
|
|
2626
|
+
detail: `${done.failures} could not be deleted`
|
|
2627
|
+
};
|
|
2628
|
+
return done.count ? {
|
|
2629
|
+
status: "done",
|
|
2630
|
+
bytes: done.bytes,
|
|
2631
|
+
detail: `${done.count} deleted`
|
|
2632
|
+
} : kept("none is idle now");
|
|
2633
|
+
}
|
|
2634
|
+
function unregisterCache(action) {
|
|
2635
|
+
const dir = action.dir ?? action.target;
|
|
2636
|
+
if (existsSync(dir) || !isOnMountedVolume(dir, listMountedVolumes()) || !readManifest().caches.some((c) => c.dir === dir)) return kept("it can no longer be shown to be a stale registration");
|
|
2637
|
+
return unregister(dir) ? {
|
|
2638
|
+
status: "done",
|
|
2639
|
+
bytes: 0,
|
|
2640
|
+
detail: dir
|
|
2641
|
+
} : kept("it was already unregistered");
|
|
2642
|
+
}
|
|
2643
|
+
async function executeAction(action, context) {
|
|
2644
|
+
switch (action.kind) {
|
|
2645
|
+
case "would-clear-outputs": return clearOutputs(action, context);
|
|
2646
|
+
case "would-trim-cache": return trimCache(action, context);
|
|
2647
|
+
case "would-empty-cache": return emptyCompilationCache(action, context);
|
|
2648
|
+
case "would-remove-worktree": return removeWorktree(action, context);
|
|
2649
|
+
case "would-remove-orphan": return removeOrphan(action);
|
|
2650
|
+
case "would-unregister-cache": return unregisterCache(action);
|
|
2651
|
+
case "would-delete-parked-device": return deleteParkedDevice(action);
|
|
2652
|
+
case "would-delete-devices": return deleteDevices(action, context);
|
|
2653
|
+
case "would-stop-workspace": return action.level === 3 ? stopWorkspace(action, context) : kept("automatic maintenance stops dev servers only at disk pressure level 3");
|
|
2654
|
+
case "would-clear-build-dirs":
|
|
2655
|
+
case "would-delete-dependencies": return clearArtifacts(action, context);
|
|
2656
|
+
case "would-stop-gradle-daemons": return stopGradleDaemonsAction(action);
|
|
2657
|
+
case "would-delete-gradle-version": return deleteGradleVersionAction(action, context);
|
|
2658
|
+
case "would-delete-temp-leftovers": return deleteTempLeftoversAction();
|
|
2659
|
+
default: return kept("automatic maintenance does not stop devices or dev servers yet");
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
function diskRecovered(budget) {
|
|
2663
|
+
const floor = Math.max(budget.minFreeDiskMb, budget.hardFloorDiskMb);
|
|
2664
|
+
return measureDisk().every((disk) => disk.freeMb >= floor);
|
|
2665
|
+
}
|
|
2666
|
+
//#endregion
|
|
2667
|
+
export { collectWorktreeSweep as _, previewMaintenance as a, worktreeRemovalReason as b, deleteTempLeftovers as c, ARTIFACT_DIRS as d, appRoots as f, candidateRootsAsync as g, ownedDeviceUsage as h, plannedMaintenance as i, tempLeftoverLines as l, measureOwnedDevices as m, diskRecovered as n, triggeringRoot as o, finishedWorkspaces as p, executeAction as r, collectTempLeftovers as s, deleteIdleWorkspaceDevices as t, tempLeftoverSection as u, removeWorktrees as v, worktreePullRequestNote as y };
|