stim 1.26.0 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/dist/{acquire-artifact-DVsvSwf7.mjs → acquire-artifact-CKByBnTD.mjs} +2 -2
  2. package/dist/{act-C-BqJEpu.mjs → act-8iswyfRa.mjs} +319 -126
  3. package/dist/{activity-Lnb_EoRY.mjs → activity-DRMg4_mD.mjs} +6 -6
  4. package/dist/{agent-device-usage-output-DbXy5tg8.mjs → agent-device-usage-output-tE90fDdo.mjs} +5 -5
  5. package/dist/{android-CiNQw3kq.mjs → android-CHmSU4B4.mjs} +101 -45
  6. package/dist/{android-DTAcRwBC.mjs → android-CsPs-7Mg.mjs} +23 -18
  7. package/dist/{android-B5P81yI-.mjs → android-D577ApKG.mjs} +37 -15
  8. package/dist/{android-CSbLRD53.mjs → android-YWCbmkVK.mjs} +5 -5
  9. package/dist/{android-cas-BTVhZ0Qs.mjs → android-cas-_aDifWW0.mjs} +4 -4
  10. package/dist/android-cas-compiler.mjs +1 -1
  11. package/dist/api.d.mts +14 -4
  12. package/dist/api.mjs +39 -29
  13. package/dist/{apk-signed-tail-B46U9hXc.mjs → apk-signed-tail-CQbwM0qO.mjs} +2 -2
  14. package/dist/{artifact-CsAP6AGK.mjs → artifact-DYtHjxsn.mjs} +19 -24
  15. package/dist/{artifact-BxREhAaV.mjs → artifact-bFkfD3vf.mjs} +18 -23
  16. package/dist/{artifact-platform-CMRc0M_r.mjs → artifact-platform-BO2Uvk_A.mjs} +1 -1
  17. package/dist/auto-placement-BVqs8AJi.mjs +350 -0
  18. package/dist/{browser-web-rYPyqhTf.mjs → browser-web-BUaLcafY.mjs} +16 -16
  19. package/dist/{build-DPhApsjV.mjs → build-cQURy_KW.mjs} +35 -31
  20. package/dist/{build-cache-DrXNCy3T.mjs → build-cache-CZULpXVl.mjs} +43 -21
  21. package/dist/{build-plan-DrMMdayj.mjs → build-plan-CxoYfxsc.mjs} +7 -6
  22. package/dist/{cache-manifest-BEZ9Bpl1.mjs → cache-manifest-DEjqGGat.mjs} +1 -1
  23. package/dist/cache-manifest.mjs +1 -1
  24. package/dist/{budget-k2mZGvvk.mjs → ccache-Bm_A1xX9.mjs} +562 -1604
  25. package/dist/{cdp-BG3FJCCx.mjs → cdp-C3svvXiE.mjs} +1 -1
  26. package/dist/{chrome-BvV8ssI3.mjs → chrome-DyDWurWG.mjs} +1 -1
  27. package/dist/{cli-BBKFGOFl.mjs → cli-CpbaLDa7.mjs} +24 -23
  28. package/dist/cli.mjs +1 -1
  29. package/dist/{client-D9CaRks-.mjs → client-YxCer6ab.mjs} +84 -65
  30. package/dist/collector-run.d.mts +10 -9
  31. package/dist/collector-run.mjs +42 -27
  32. package/dist/{command-output-BF5AR2dL.mjs → command-output-C8R1KIvi.mjs} +1 -1
  33. package/dist/{config-BktkY_zC.mjs → config-Cn94Jc0g.mjs} +23 -4
  34. package/dist/{dependencies-Dw6wfQdv.mjs → dependencies-DKeMzwXY.mjs} +108 -60
  35. package/dist/{dependency-state-DFB41vfr.mjs → dependency-state-CojA2o0t.mjs} +14 -7
  36. package/dist/{detached-entry-KmieOKgN.mjs → detached-entry-BPlSaCwC.mjs} +1 -1
  37. package/dist/{dev-client-DXR_tPVY.mjs → dev-client-D-BW5uiq.mjs} +2 -2
  38. package/dist/{device-BK-zwyd_.mjs → device-CgXfbcz5.mjs} +25 -24
  39. package/dist/{device-capacity-DCSvUF0l.mjs → device-capacity-Cd8lMSn3.mjs} +175 -22
  40. package/dist/{device-BcYeHSFu.mjs → device-eeK95Lir.mjs} +16 -16
  41. package/dist/device-host-worker.mjs +22 -20
  42. package/dist/{device-ios-BLGfXQeK.mjs → device-ios-Cc9ulihb.mjs} +251 -23
  43. package/dist/{device-lease-run-BmvIl7bI.mjs → device-lease-run-Mtzp4THX.mjs} +5 -5
  44. package/dist/{device-pool-Dluix4H3.mjs → device-pool-eRz6DtCg.mjs} +3 -3
  45. package/dist/{doctor-Bhg1ii70.mjs → doctor-Bz_ouYAW.mjs} +28 -16
  46. package/dist/{doctor-Cp-BhzTS.mjs → doctor-C2QyhejJ.mjs} +1 -1
  47. package/dist/{doctor-fingerprint-D3a04Mhu.mjs → doctor-fingerprint-o7ZI9YLn.mjs} +1 -1
  48. package/dist/doctor-fingerprint.mjs +1 -1
  49. package/dist/{doctor-watchman-BStK05nt.mjs → doctor-watchman-BTW82PDC.mjs} +5 -5
  50. package/dist/{doctor-OnwP94ne.mjs → doctor-xf7ht-5n.mjs} +76 -32
  51. package/dist/{eas-build-Dsj93iM9.mjs → eas-build-Dm6q68QY.mjs} +4 -4
  52. package/dist/eas-fallback-DajNpxf-.mjs +87 -0
  53. package/dist/{eas-session-ledger-B5vygTiD.mjs → eas-session-ledger-CBFo_Uo1.mjs} +37 -8
  54. package/dist/{error-diagnostics-BOOm9Db7.mjs → error-diagnostics-D1QfyJzQ.mjs} +51 -25
  55. package/dist/{exec-VFRpu1iA.mjs → exec-aULwtNly.mjs} +57 -6
  56. package/dist/{gc-ldgr-hXU.mjs → gc-DM5oMm-J.mjs} +161 -45
  57. package/dist/{gc-C_79zvz4.mjs → gc-PLFwb0k4.mjs} +2 -2
  58. package/dist/{guide-D7K0X6KG.mjs → guide-C4I46XAk.mjs} +674 -213
  59. package/dist/{hosted-android-Bk54ej6n.mjs → hosted-android-DlaKLbW0.mjs} +6 -7
  60. package/dist/{hosted-client-fHO8yqzc.mjs → hosted-client-DDRm9hPJ.mjs} +51 -20
  61. package/dist/{hosted-logs-BfbmDM4M.mjs → hosted-logs-bSebOKbz.mjs} +8 -8
  62. package/dist/{hosted-logs-sync-B0pvmOk-.mjs → hosted-logs-sync-B0c53W_c.mjs} +5 -5
  63. package/dist/{hosted-macos-CPl0OZ9h.mjs → hosted-macos-B7-8Pv4H.mjs} +6 -6
  64. package/dist/{hosted-native-CpDB3-PO.mjs → hosted-native-CDe33CSr.mjs} +22 -18
  65. package/dist/{idle-shutdown-FfxL1KdG.mjs → idle-shutdown-CQ0QwU9J.mjs} +7 -8
  66. package/dist/{ios-WxpDJ5T1.mjs → ios-B1DYuOjh.mjs} +27 -11
  67. package/dist/{ios-B7-lToDu.mjs → ios-B1HvafEG.mjs} +8 -8
  68. package/dist/{ios-ENC4sRPA.mjs → ios-CsRVik64.mjs} +7 -1
  69. package/dist/{ios-device-CsyryvpT.mjs → ios-device-B4elVFKi.mjs} +3 -3
  70. package/dist/{ios-device-B6HK951C.mjs → ios-device-CiBA-er9.mjs} +1 -1
  71. package/dist/{ios-project-Ty5pHl9O.mjs → ios-project-CR4mZ7CH.mjs} +22 -9
  72. package/dist/{ios-signing-4c_qXvQm.mjs → ios-signing-BJzvDPfU.mjs} +2 -2
  73. package/dist/{ios-state-Cw7-WQf2.mjs → ios-state-BPHqMDdZ.mjs} +1 -1
  74. package/dist/{ios-ftxdQbJS.mjs → ios-tg4tp3q1.mjs} +94 -37
  75. package/dist/{js-swap-BMhXYVDS.mjs → js-swap-CVpSW1fn.mjs} +8 -7
  76. package/dist/{launch-DOp8RjDz.mjs → launch-DoySmhjz.mjs} +42 -38
  77. package/dist/lightened-DFAJgfIk.mjs +171 -0
  78. package/dist/{logs-s1dRIG1E.mjs → logs-BGsW7iPe.mjs} +46 -21
  79. package/dist/{logs-dQDki6Tt.mjs → logs-DI5ILkYB.mjs} +7 -7
  80. package/dist/{macos-DBBPNnIA.mjs → macos-Bo9st9Di.mjs} +73 -38
  81. package/dist/{macos-B0VEV3Gf.mjs → macos-C12RMJQF.mjs} +1 -1
  82. package/dist/macos-run.mjs +1 -1
  83. package/dist/maintenance-run.mjs +7 -7
  84. package/dist/{metro-CjC10t6E.mjs → metro-BnhXV2Mj.mjs} +4 -4
  85. package/dist/{metro-gateway-B6_PgD0L.mjs → metro-request-DXyKjVDF.mjs} +26 -47
  86. package/dist/{named-ports-BG0j1X4g.mjs → named-ports-Phoi2POD.mjs} +3 -3
  87. package/dist/{native-android-CL2NGiik.mjs → native-android-vxgtLD7w.mjs} +17 -18
  88. package/dist/{native-gradle-artifact-inputs-btSUulme.mjs → native-gradle-artifact-inputs-BN96IVw8.mjs} +5 -5
  89. package/dist/{native-runtime-Dq3RBcON.mjs → native-runtime-DG4Rodzj.mjs} +9 -8
  90. package/dist/{native-source-D-4Dh4JU.mjs → native-source-D1AzWiuP.mjs} +1 -1
  91. package/dist/{native-xcode-inputs-BW7H8JGu.mjs → native-xcode-inputs-BKarCoKg.mjs} +3 -3
  92. package/dist/{native-xcode-ios-CBXJAxfC.mjs → native-xcode-ios-8A-efGa5.mjs} +17 -17
  93. package/dist/{ndjson-CgE_o3Dl.mjs → ndjson-Dy2W4vRa.mjs} +2 -2
  94. package/dist/offload-worker.mjs +23 -24
  95. package/dist/{operation-exec-CUZifp6Z.mjs → operation-exec-Dp4FuIGR.mjs} +1 -1
  96. package/dist/{ownership-BjYvMNX_.mjs → ownership-DJCq48IJ.mjs} +3 -3
  97. package/dist/{page-CzEA_-5_.mjs → page-CQJlcj3x.mjs} +2 -2
  98. package/dist/{phone-reverses-lmj6kcPm.mjs → phone-reverses-BzTa8g1F.mjs} +18 -4
  99. package/dist/{placement-log-DIFPQAXp.mjs → placement-log-Cvhta5ib.mjs} +3 -3
  100. package/dist/{plan-placement-IiiSQhtz.mjs → plan-placement-WkZdccuN.mjs} +36 -10
  101. package/dist/{ports-C2_w1Z0Z.mjs → ports-UV4mJgyC.mjs} +3 -3
  102. package/dist/{prebuild-gmouvmJZ.mjs → prebuild-k8d_tqOq.mjs} +16 -17
  103. package/dist/{project-BUu6Is4k.mjs → project-Sui52ywT.mjs} +17 -17
  104. package/dist/{pull-request-CbNlkFok.mjs → pull-request-BzLmzAIQ.mjs} +2 -2
  105. package/dist/pull-requests.mjs +1 -1
  106. package/dist/{react-native-android-Cgg99REN.mjs → react-native-android-DANlGGQf.mjs} +32 -26
  107. package/dist/{react-native-build-oNwqF5Wb.mjs → react-native-build-jUHnIfZ9.mjs} +9 -9
  108. package/dist/{react-native-doctor-BhA3KUte.mjs → react-native-doctor-DCWRjS7K.mjs} +6 -6
  109. package/dist/{react-native-ios-B-nf4rjN.mjs → react-native-ios-BwMsFMXi.mjs} +23 -17
  110. package/dist/{recordings-BldzVKq0.mjs → recordings-iy0mkm7b.mjs} +1 -1
  111. package/dist/{registration-CN6nlhcD.mjs → registration-BwCe64PH.mjs} +1 -1
  112. package/dist/{reload-C9OduiNc.mjs → reload-Bj7QyuDy.mjs} +1 -1
  113. package/dist/{reload-CCR7KC_l.mjs → reload-c_uEyc_X.mjs} +16 -16
  114. package/dist/{remote-C8S7FlPF.mjs → remote-DFlpAqdV.mjs} +23 -10
  115. package/dist/{result-7F5hEwQJ.mjs → result-BIcXREyC.mjs} +32 -21
  116. package/dist/{run-BqB0v3pt.mjs → run-y2HwyNvg.mjs} +3 -3
  117. package/dist/{selection-BNPBZ6T5.mjs → selection-CXgM_VK_.mjs} +37 -29
  118. package/dist/{server-bare-A75rDIdD.mjs → server-bare-2N4vSRZG.mjs} +2 -2
  119. package/dist/{server-command-CssKFOD7.mjs → server-command-qneHPcRI.mjs} +6 -6
  120. package/dist/{server-expo-eQfOdydb.mjs → server-expo-Ccm9zB0o.mjs} +4 -4
  121. package/dist/server-expo-WulkyAwT.mjs +2 -0
  122. package/dist/{settings-CosW5X-S.mjs → settings-CY5JZlNN.mjs} +375 -62
  123. package/dist/{settings-OaGQF-ri.mjs → settings-Cfd_Q6ys.mjs} +24 -3
  124. package/dist/settings-payload-DwQiEMCn.mjs +257 -0
  125. package/dist/settings-write-CJpjkeAd.mjs +185 -0
  126. package/dist/settings.schema.json +51 -33
  127. package/dist/{simslim-Bax1qcxu.mjs → simslim-DCQwzoPy.mjs} +2 -2
  128. package/dist/{slot-launch-CYCEhfBY.mjs → slot-launch-BpmanOnk.mjs} +6 -6
  129. package/dist/{stage-jKQ7CP_B.mjs → stage-CmugrSG5.mjs} +2 -2
  130. package/dist/{start-C_wefNKB.mjs → start-CHH3zgIU.mjs} +1 -1
  131. package/dist/{start-_BdJ32cZ.mjs → start-DYLk6GXn.mjs} +27 -22
  132. package/dist/{state-C1i2nMOP.mjs → state-B2QJbDwz.mjs} +4 -4
  133. package/dist/{state-C4OdpOTF.mjs → state-BXC1Lfxw.mjs} +2 -2
  134. package/dist/{stats-tEpEEH-u.mjs → stats-DxKqhcGp.mjs} +5 -5
  135. package/dist/{stats-report-n2U5plhk.mjs → stats-report-B86Cv7Uc.mjs} +1 -1
  136. package/dist/{status-CRXz-Gtj.mjs → status-7ULDEJ1e.mjs} +1 -1
  137. package/dist/{status-DQrqodLj.mjs → status-BHCkOZga.mjs} +51 -28
  138. package/dist/{status-B9M7x4CZ.mjs → status-D_Ljc89p.mjs} +1 -1
  139. package/dist/{status-watch-B5wDIH_o.mjs → status-watch-BmsPqYSU.mjs} +18 -7
  140. package/dist/status-worker.mjs +1 -1
  141. package/dist/{stim-desktop-6Dqp-gjX.mjs → stim-desktop-CbWo21kb.mjs} +2 -2
  142. package/dist/{stop-DRqQVqDJ.mjs → stop-D-dGE9gh.mjs} +21 -20
  143. package/dist/{stop-ySNQHXMG.mjs → stop-DQvE-P76.mjs} +10 -6
  144. package/dist/{stop-CmdNhGlC.mjs → stop-b2pouxAO.mjs} +2 -2
  145. package/dist/supervisor-run.mjs +237 -63
  146. package/dist/{support-BGSCBoib.mjs → support-04IhBUTT.mjs} +6 -6
  147. package/dist/{support-CVOCp0Ft.mjs → support-BtlJKjyQ.mjs} +12 -11
  148. package/dist/{swiftpm-artifact-inputs-CfUzt_0Z.mjs → swiftpm-artifact-inputs-Cj2UpmPN.mjs} +4 -4
  149. package/dist/{swiftpm-macos-x6HPpl_2.mjs → swiftpm-macos-BhF2stxY.mjs} +11 -12
  150. package/dist/{toolchain-J4J8MrAr.mjs → toolchain-mLA_km-b.mjs} +321 -15
  151. package/dist/{toolchain-DoavrvIA.mjs → toolchain-qktiV8xc.mjs} +1 -1
  152. package/dist/{trigger-KVaWWNyn.mjs → trigger-DMou-X7j.mjs} +2 -2
  153. package/dist/trigger-qp2ji5wE.mjs +2 -0
  154. package/dist/{warm-progress-DvhTX_eu.mjs → warm-progress-B0Vpuvpl.mjs} +731 -727
  155. package/dist/{web-Bihbf7-u.mjs → web-BYmK0TjL.mjs} +9 -9
  156. package/dist/web-CrVLVQZY.mjs +2 -0
  157. package/dist/web-run.mjs +9 -9
  158. package/dist/windows-report.mjs +1 -1
  159. package/dist/{workspace-state-pUg3_RRA.mjs → workspace-state-kIZrdlSg.mjs} +3 -3
  160. package/dist/{workspaces-g2lhdBXF.mjs → workspaces-CmOcceDN.mjs} +1879 -74
  161. package/dist/{worktree-BKuklFh_.mjs → worktree-2dk5edza.mjs} +933 -534
  162. package/dist/worktree-nliAoHxB.mjs +2 -0
  163. package/package.json +7 -7
  164. package/dist/ccache-Bx5TbxwG.mjs +0 -143
  165. package/dist/eas-fallback-FwkeqqGi.mjs +0 -381
  166. package/dist/hosted-ios-D55QhYaD.mjs +0 -15
  167. package/dist/native-run-BfIDFKLH.mjs +0 -275
  168. package/dist/server-expo-wvcj5tiZ.mjs +0 -2
  169. package/dist/settings-write-QjYfSrjF.mjs +0 -360
  170. package/dist/trigger-DzJu208Q.mjs +0 -2
  171. package/dist/web-BFLueSh-.mjs +0 -2
  172. package/dist/worktree-Bm4cb-nU.mjs +0 -2
@@ -1,808 +1,808 @@
1
- import { D as ownership_claim_exports, E as debugLog, l as WorkspaceProcessCleanupError, n as isTimeoutError, t as getExecutor } from "./exec-VFRpu1iA.mjs";
2
- import { E as workspaceDir, M as withDirLock, _ as ensureWorkspaceStorage, h as discoverAgentSessions, m as attributeAgentSessions, o as updateWorkspaceState, p as agentKey } from "./workspace-state-pUg3_RRA.mjs";
3
- import { l as gitQuietAsync, o as git$1, s as gitAsync } from "./operation-exec-CUZifp6Z.mjs";
4
- import { Tt as linkedWorktreesOnDisk, bt as gitCommonDirOnDisk } from "./settings-CosW5X-S.mjs";
5
- import { V as ownedAvdDirectory } from "./android-DTAcRwBC.mjs";
6
- import { s as pullRequestLookups } from "./pull-request-CbNlkFok.mjs";
1
+ import { D as ownership_claim_exports, E as debugLog, l as WorkspaceProcessCleanupError, n as isTimeoutError, t as getExecutor } from "./exec-aULwtNly.mjs";
2
+ import { E as workspaceDir, M as withDirLock, _ as ensureWorkspaceStorage, h as discoverAgentSessions, m as attributeAgentSessions, o as updateWorkspaceState, p as agentKey } from "./workspace-state-kIZrdlSg.mjs";
3
+ import { l as gitQuietAsync, o as git$1, s as gitAsync } from "./operation-exec-Dp4FuIGR.mjs";
4
+ import { Ct as gitCommonDirOnDisk, Ot as linkedWorktreesOnDisk } from "./settings-CY5JZlNN.mjs";
5
+ import { V as ownedAvdDirectory } from "./android-CsPs-7Mg.mjs";
6
+ import { s as pullRequestLookups } from "./pull-request-BzLmzAIQ.mjs";
7
7
  import { clearClaimChild, markClaimChildPending, releaseClaim, setClaimChild, tryAcquireClaim } from "@stim-cli/core/ownership-claim";
8
8
  import { randomUUID } from "node:crypto";
9
9
  import { existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
11
  import { basename, dirname, join, sep } from "node:path";
12
- import { ENDED_AGENT_RETENTION_MS, READY_PHASE_MS, WARM_KEY, agentSessionsCacheFile, diskUsageCacheFile, diskUsageFailureFile, diskUsageWalkClaims, pullRequestCacheFile, readAgentSessionsCache, readDiskUsage, readDiskUsageFailure, readEndedAgentSessions, readPullRequestCache, readWarmRecord, readWorkspaceAgent, readWorkspaceState, workspaceEndedAgentsFile, workspaceEndedAgentsLock } from "@stim-cli/core/state";
12
+ import { ENDED_AGENT_RETENTION_MS, READY_PHASE_MS, WARM_KEY, agentSessionsCacheFile, diskUsageCacheFile, diskUsageFailureFile, diskUsageWalkClaims, pullRequestCacheFile, readAgentSessionsCache, readDiskUsage, readDiskUsageFailure, readEndedAgentSessions, readPullRequestCache, readWarmRecord, readWorkspaceAgent, readWorkspaceState, trace, workspaceEndedAgentsFile, workspaceEndedAgentsLock } from "@stim-cli/core/state";
13
13
  import { captureProcessIdentity } from "@stim-cli/core/process-identity";
14
- //#region src/workspace/merge-state.ts
15
- const FETCH_TIMEOUT_MS = 3e4;
16
- /** How old a fetch or a cached lookup can be and still count as fresh. */
17
- const FETCH_FRESH_MS = 6e5;
18
- const GIT_TIMEOUT_MS$1 = 6e4;
19
- const REMOTE_PREFIX = "refs/remotes/origin/";
20
- const MAX_MERGE_STATE_COMMITS = 2e3;
21
- function failure(error) {
22
- const { code, stderr, message } = error;
23
- const text = (code === "ENOBUFS" ? "" : String(stderr ?? "").trim()) || String(message ?? error);
24
- return text.split("\n")[0] ?? text;
25
- }
26
- function fetchTime(path) {
27
- path = path?.trim() ?? null;
14
+ //#region src/status-measures.ts
15
+ const LIVE_DISK_MAX_AGE_MS = 3e5;
16
+ const IDLE_DISK_MAX_AGE_MS = 36e5;
17
+ const DU_TIMEOUT_MS = 12e4;
18
+ const DU_NICENESS = "19";
19
+ const PULL_REQUEST_MAX_AGE_MS = 3e5;
20
+ const PULL_REQUEST_CHECK_MS = 6e4;
21
+ const GIT_TIMEOUT_MS$1 = 5e3;
22
+ const AGENT_DISCOVERY_MS = 15e3;
23
+ const AGENT_CACHE_MAX_AGE_MS = 12e4;
24
+ function writeCacheFile(file, entry) {
25
+ const temporary = `${file}.${randomUUID()}.tmp`;
28
26
  try {
29
- return path ? statSync(path).mtimeMs : null;
27
+ mkdirSync(dirname(file), { recursive: true });
28
+ writeFileSync(temporary, JSON.stringify(entry));
29
+ renameSync(temporary, file);
30
30
  } catch {
31
- return null;
31
+ rmSync(temporary, { force: true });
32
32
  }
33
33
  }
34
- function defaultBranch(repo, ref) {
35
- ref = ref?.trim() ?? null;
36
- if (!ref?.startsWith(REMOTE_PREFIX)) return { error: `origin/HEAD is not set; run \`git -C ${repo} remote set-head origin --auto\`` };
34
+ function forgetStatusMeasures(worktree, workspaces) {
35
+ const roots = [.../* @__PURE__ */ new Set([worktree, ...workspaces])];
36
+ const files = [pullRequestCacheFile(worktree), ...roots.flatMap((root) => [
37
+ diskUsageCacheFile(root),
38
+ diskUsageFailureFile(root),
39
+ diskUsageCacheFile(join(root, "node_modules")),
40
+ diskUsageFailureFile(join(root, "node_modules")),
41
+ diskUsageCacheFile(workspaceDir(root)),
42
+ diskUsageFailureFile(workspaceDir(root))
43
+ ])];
44
+ for (const file of files) try {
45
+ rmSync(file, { force: true });
46
+ } catch {}
47
+ }
48
+ function simulatorDataDir(udid) {
49
+ return join(homedir(), "Library", "Developer", "CoreSimulator", "Devices", udid);
50
+ }
51
+ function checkoutDir(state) {
52
+ if (state.worktree?.path) return state.worktree.path;
53
+ const common = gitCommonDirOnDisk(state.path);
54
+ return common && basename(common) === ".git" ? dirname(common) : state.path;
55
+ }
56
+ function deviceFolders(state) {
57
+ const folders = [];
58
+ for (const { ios, android } of [state, ...state.slots ?? []]) {
59
+ if (ios?.owned) folders.push({
60
+ device: ios,
61
+ path: simulatorDataDir(ios.udid)
62
+ });
63
+ if (android?.owned && !android.physical && android.name) {
64
+ const path = ownedAvdDirectory(android.name);
65
+ if (path) folders.push({
66
+ device: android,
67
+ path
68
+ });
69
+ }
70
+ }
71
+ return folders;
72
+ }
73
+ /** The folders an environment's disk use sums: its checkout, its node_modules folders and Stim's workspace folder. */
74
+ function environmentFolders(state) {
75
+ const worktree = checkoutDir(state);
37
76
  return {
38
- ref,
39
- name: `origin/${ref.slice(20)}`
77
+ worktree,
78
+ nodeModules: [.../* @__PURE__ */ new Set([join(worktree, "node_modules"), join(state.path, "node_modules")])],
79
+ build: workspaceDir(state.path)
40
80
  };
41
81
  }
42
- function notMerged(detail) {
82
+ /** The environment's disk use from the cached measurements; null before any folder was measured. */
83
+ function environmentDisk(folders) {
84
+ const worktree = readDiskUsage(folders.worktree);
85
+ const modules = folders.nodeModules.map((path) => readDiskUsage(path));
86
+ const build = readDiskUsage(folders.build);
87
+ const measured = [
88
+ worktree,
89
+ ...modules,
90
+ build
91
+ ].filter((entry) => entry !== null);
92
+ if (!measured.length) return null;
43
93
  return {
44
- merged: false,
45
- unknown: false,
46
- detail
94
+ worktreeBytes: worktree?.bytes ?? null,
95
+ nodeModulesBytes: modules.every((entry) => entry !== null) ? modules.reduce((sum, entry) => sum + entry.bytes, 0) : null,
96
+ buildBytes: build?.bytes ?? null,
97
+ measuredAt: measured.map((entry) => entry.measuredAt).toSorted()[0]
47
98
  };
48
99
  }
49
- function* git(args, input) {
50
- return yield {
51
- args: ["--literal-pathspecs", ...args],
52
- options: { input }
100
+ function statusPullRequest(fact, checkedAt) {
101
+ return {
102
+ number: fact.number,
103
+ url: fact.url,
104
+ title: fact.title ?? "",
105
+ state: fact.state === "open" && fact.draft ? "draft" : fact.state,
106
+ checks: fact.checks ?? null,
107
+ reviewDecision: fact.reviewDecision ?? null,
108
+ checkedAt
53
109
  };
54
110
  }
55
- function* patch(args, input) {
56
- return yield {
57
- args: ["--literal-pathspecs", ...args],
58
- options: {
59
- input,
60
- untrimmed: true
61
- }
111
+ /**
112
+ * Writes a pull request lookup into the cache `status` and report-only `gc` read. A lookup made without detail is
113
+ * marked `partial`, so `status` asks again for the title, checks and review decision; until then it keeps the title
114
+ * cached for the same pull request, and the checks and review decision cached for the same HEAD.
115
+ */
116
+ function recordPullRequestLookup({ path, branch, head }, fact, checkedAt) {
117
+ let pullRequest = fact ? statusPullRequest(fact, checkedAt) : null;
118
+ const partial = pullRequest !== null && fact?.title === void 0;
119
+ const previous = partial ? readPullRequestCache(path) : null;
120
+ if (pullRequest && previous?.pullRequest?.number === pullRequest.number) {
121
+ const same = previous.head === head;
122
+ pullRequest = {
123
+ ...pullRequest,
124
+ title: previous.pullRequest.title,
125
+ checks: same ? previous.pullRequest.checks : null,
126
+ reviewDecision: same ? previous.pullRequest.reviewDecision : null
127
+ };
128
+ }
129
+ const entry = {
130
+ path,
131
+ branch,
132
+ head,
133
+ checkedAt,
134
+ pullRequest,
135
+ ...fact ? {
136
+ containsHead: fact.containsHead,
137
+ endedAt: fact.endedAt
138
+ } : {},
139
+ ...partial ? { partial: true } : {}
62
140
  };
141
+ writeCacheFile(pullRequestCacheFile(path), entry);
63
142
  }
64
- function* probe(args) {
143
+ function canonicalPath(path) {
65
144
  try {
66
- return yield { args };
67
- } catch (error) {
68
- if (isTimeoutError(error) || typeof error === "object" && error !== null && "code" in error && error.code === "ENOBUFS") throw error;
69
- return null;
145
+ return realpathSync(path);
146
+ } catch {
147
+ return path;
70
148
  }
71
149
  }
72
- function* patchIdCommits(text) {
73
- const output = text ? yield* git(["patch-id", "--verbatim"], text) : "";
74
- return new Map(output.split("\n").flatMap((line) => {
75
- const [id, commit] = line.split(" ");
76
- return id && commit ? [[id, commit]] : [];
150
+ function agentWorkspaces(states) {
151
+ return states.map((state) => ({
152
+ path: canonicalPath(state.path),
153
+ root: canonicalPath(checkoutDir(state)),
154
+ recorded: readWorkspaceAgent(readWorkspaceState(state.path))
77
155
  }));
78
156
  }
79
- function* patchIds(text) {
80
- return [...(yield* patchIdCommits(text)).keys()];
81
- }
82
- function* committedAt(commits) {
83
- const times = commits.length ? (yield* git([
84
- "log",
85
- "--no-walk",
86
- "--stdin",
87
- "--format=%ct"
88
- ], commits.join("\n"))).split("\n").map(Number) : [];
89
- if (!times.length || !times.every(Number.isFinite)) throw new Error(`no committer date for ${commits.join(" ")}`);
90
- return Math.max(...times) * 1e3;
91
- }
92
- function* currentBranch() {
93
- const ref = (yield* probe([
94
- "symbolic-ref",
95
- "--quiet",
96
- "HEAD"
97
- ]))?.trim();
98
- return ref?.startsWith("refs/heads/") ? ref : null;
99
- }
100
- function* upstreamGone(branch) {
101
- if (!branch) return false;
102
- const [upstream, state] = ((yield* probe([
103
- "for-each-ref",
104
- "--format=%(upstream)%00%(upstream:track)",
105
- branch
106
- ])) ?? "").trim().split("\0");
107
- return Boolean(upstream) && state === "[gone]";
157
+ function applyAgentSessions(states, now) {
158
+ const cache = readAgentSessionsCache();
159
+ const sessions = cache && now - Date.parse(cache.discoveredAt) <= AGENT_CACHE_MAX_AGE_MS ? cache.sessions : [];
160
+ const discovered = new Set(sessions.map(agentKey));
161
+ const ended = states.map((state) => readEndedAgentSessions(state.path, now));
162
+ const workspaces = agentWorkspaces(states);
163
+ workspaces.forEach((workspace, i) => {
164
+ const { recorded } = workspace;
165
+ if (!recorded || discovered.has(agentKey(recorded))) return;
166
+ if (ended[i].some((session) => agentKey(session) === agentKey(recorded))) workspace.recorded = null;
167
+ });
168
+ attributeAgentSessions(workspaces, sessions, now).forEach((agents, i) => {
169
+ if (agents.length) states[i].agents = agents;
170
+ const running = new Set(agents.map(agentKey));
171
+ const endedAgents = ended[i].filter((session) => !running.has(agentKey(session)));
172
+ if (endedAgents.length) states[i].endedAgents = endedAgents;
173
+ });
108
174
  }
109
- function* committedOn(branch, head) {
110
- if (!branch) return false;
111
- const entries = (yield* probe([
112
- "reflog",
113
- "show",
114
- "--format=%H %gs",
115
- branch
116
- ])) ?? "";
117
- for (const entry of entries.split("\n")) {
118
- const [sha = "", ...subject] = entry.split(" ");
119
- if (/^(commit|cherry-pick|rebase|revert)\b/.test(subject.join(" ")) && (yield* probe([
120
- "merge-base",
121
- "--is-ancestor",
122
- sha,
123
- head
124
- ])) !== null) return true;
175
+ function writeFileAtomic(file, entry) {
176
+ const temporary = `${file}.${randomUUID()}.tmp`;
177
+ try {
178
+ writeFileSync(temporary, JSON.stringify(entry));
179
+ renameSync(temporary, file);
180
+ } catch (error) {
181
+ rmSync(temporary, { force: true });
182
+ throw error;
125
183
  }
126
- return false;
127
184
  }
128
- function* mergeStateReads({ ref, name }, maxCommits) {
129
- const diffOptions = ["--no-color", "--no-ext-diff"];
130
- const noOwnCommits = {
131
- merged: false,
132
- unknown: false,
133
- detail: `no commits of its own beyond ${name}`,
134
- noOwnCommits: true
185
+ function recordEndedAgents(states, gone, endedAt, now) {
186
+ const goneKeys = new Set(gone.map(agentKey));
187
+ const result = {
188
+ changed: false,
189
+ failed: false
135
190
  };
136
- try {
137
- const head = yield* git([
138
- "rev-parse",
139
- "--verify",
140
- "HEAD^{commit}"
141
- ]);
142
- const base = yield* git([
143
- "merge-base",
144
- head,
145
- ref
146
- ]);
147
- const branch = yield* currentBranch();
148
- if (base === head) {
149
- if ((yield* git(["rev-parse", ref])) === head || (yield* git([
150
- "rev-list",
151
- "--first-parent",
152
- "--parents",
153
- `${head}..${ref}`
154
- ])).split("\n").some((line) => line.split(" ")[1] === head) || !(yield* committedOn(branch, head))) return noOwnCommits;
155
- const descendants = new Set((yield* git([
156
- "rev-list",
157
- "--ancestry-path",
158
- `${head}..${ref}`
159
- ])).split("\n"));
160
- const landed = (yield* git([
161
- "rev-list",
162
- "--first-parent",
163
- "--reverse",
164
- `${head}..${ref}`
165
- ])).split("\n").find((commit) => descendants.has(commit));
166
- if (!landed) throw new Error(`no commit on ${name} merges ${head}`);
167
- return {
168
- merged: true,
169
- into: name,
170
- head,
171
- coversUnpushed: false,
172
- mergedAt: yield* committedAt([landed])
173
- };
174
- }
175
- const aheadCount = Number(yield* git([
176
- "rev-list",
177
- "--count",
178
- `${base}..${head}`
179
- ]));
180
- if (aheadCount > maxCommits) return {
181
- merged: false,
182
- unknown: true,
183
- detail: `merge state unknown: ${aheadCount} commits of its own ahead of the merge base with ${name} exceeds the ${maxCommits}-commit limit`
184
- };
185
- const files = (yield* git([
186
- "diff",
187
- "--name-only",
188
- "--no-renames",
189
- "-z",
190
- base,
191
- head
192
- ])).split("\0").filter(Boolean);
193
- if (!files.length) return notMerged(`no net change beyond ${name}`);
194
- const log = function* (range, pathspec = []) {
195
- return yield* patch([
196
- "log",
197
- "-p",
198
- "--no-merges",
199
- ...diffOptions,
200
- "--format=commit %H",
201
- "--stdin"
202
- ], pathspec.length ? `${range}\n--\n${pathspec.join("\n")}\n` : `${range}\n`);
203
- };
204
- const upstream = yield* patchIdCommits(yield* log(`${base}..${ref}`, files));
205
- const patchEquivalent = function* (ids) {
206
- return {
207
- merged: true,
208
- into: name,
209
- head,
210
- coversUnpushed: yield* upstreamGone(branch),
211
- mergedAt: yield* committedAt(ids.map((id) => upstream.get(id)))
212
- };
213
- };
214
- const squash = yield* patchIds(yield* patch([
215
- "diff",
216
- ...diffOptions,
217
- base,
218
- head
219
- ]));
220
- if (squash.length === 1 && upstream.has(squash[0])) return yield* patchEquivalent(squash);
221
- if (!(yield* git([
222
- "rev-list",
223
- "--merges",
224
- `${base}..${head}`
225
- ]))) {
226
- const own = yield* patchIds(yield* log(`${base}..${head}`));
227
- if (own.length && own.every((id) => upstream.has(id))) return yield* patchEquivalent(own);
191
+ attributeAgentSessions(agentWorkspaces(states), gone, now).forEach((sessions, i) => {
192
+ const root = states[i].path;
193
+ const ended = sessions.filter((session) => goneKeys.has(agentKey(session)));
194
+ if (!ended.length || !existsSync(workspaceDir(root))) return;
195
+ try {
196
+ withDirLock(workspaceEndedAgentsLock(root), () => {
197
+ const kept = new Map(readEndedAgentSessions(root, now).map((session) => [agentKey(session), session]));
198
+ for (const { pid: _pid, ...session } of ended) kept.set(agentKey(session), {
199
+ ...session,
200
+ endedAt
201
+ });
202
+ writeFileAtomic(workspaceEndedAgentsFile(root), { sessions: [...kept.values()] });
203
+ });
204
+ result.changed = true;
205
+ } catch {
206
+ result.failed = true;
228
207
  }
229
- return notMerged(`not merged into ${name}`);
230
- } catch (error) {
231
- const timedOut = isTimeoutError(error) ? { timedOut: true } : {};
232
- return {
233
- merged: false,
234
- unknown: true,
235
- detail: `merge state unknown: ${failure(error)}`,
236
- ...timedOut
237
- };
238
- }
208
+ });
209
+ return result;
239
210
  }
240
211
  /**
241
- * Whether the worktree's HEAD is merged into `target`, and when: `mergedAt` is the committer date, in epoch
242
- * milliseconds, of the commit on `target` that brought the work in, the latest one for a rebase merge.
243
- * The signals, all local git:
244
- * - HEAD is an ancestor of the default branch, off its first-parent line, and the branch's reflog shows a commit made
245
- * on it that HEAD contains, so a merge commit brought the branch's own work in. A branch with no commit of its own
246
- * is not merged.
247
- * - The branch changes the tree, has no merge commits, and every commit since the merge base has the same
248
- * `git patch-id --verbatim` as a commit on the default branch (a rebase merge).
249
- * - The branch changes the tree and its whole diff since the merge base has the same verbatim patch id as a commit on
250
- * the default branch, limited to the files the branch changes (a squash merge).
251
- * A HEAD the default branch contains with no commit of its own on the branch is not merged and carries
252
- * `noOwnCommits`. Anything git cannot answer is unknown, never merged. `coversUnpushed` is true for a patch-equivalent HEAD whose
253
- * upstream branch was deleted: its commits exist only locally, but their change is on the default branch.
212
+ * Adds the cached measurements to a status read: each environment's `disk`, `agents` and `endedAgents`, each owned
213
+ * device's `disk`, and each linked worktree's `pullRequest` when the cache holds a lookup for its current branch.
214
+ * Reads files only.
254
215
  */
255
- function mergeState(path, target, { timeoutMs = GIT_TIMEOUT_MS$1, maxCommits = MAX_MERGE_STATE_COMMITS } = {}) {
256
- const reads = mergeStateReads(target, maxCommits);
257
- let step = reads.next();
258
- while (!step.done) {
259
- const { args, options } = step.value;
260
- try {
261
- step = reads.next(git$1(path, args, {
262
- timeoutMs,
263
- ...options
264
- }));
265
- } catch (error) {
266
- step = reads.throw(error);
216
+ function applyStatusMeasures(states, worktrees) {
217
+ applyAgentSessions(states, Date.now());
218
+ for (const state of states) {
219
+ const disk = environmentDisk(environmentFolders(state));
220
+ if (disk) state.disk = disk;
221
+ for (const { device, path } of deviceFolders(state)) {
222
+ const measure = readDiskUsage(path);
223
+ if (measure) device.disk = measure;
267
224
  }
268
225
  }
269
- return step.value;
226
+ for (const worktree of worktrees) {
227
+ const cached = worktree.branch ? readPullRequestCache(worktree.path) : null;
228
+ if (cached && cached.branch === worktree.branch) worktree.pullRequest = cached.pullRequest;
229
+ }
270
230
  }
271
- /** `mergeState` without blocking the event loop during Git reads. */
272
- async function mergeStateAsync(path, target, { timeoutMs = GIT_TIMEOUT_MS$1, maxCommits = MAX_MERGE_STATE_COMMITS, ...execution } = {}) {
273
- const reads = mergeStateReads(target, maxCommits);
274
- let step = reads.next();
275
- while (!step.done) {
276
- execution.signal?.throwIfAborted();
277
- const { args, options } = step.value;
231
+ /** Kilobytes from `du -sk` output, summed over its lines, or null when it printed none. */
232
+ function parseDuKilobytes(output) {
233
+ const sizes = [...output.matchAll(/^(\d+)\s/gm)].map((match) => Number(match[1]));
234
+ return sizes.length ? sizes.reduce((sum, kb) => sum + kb, 0) : null;
235
+ }
236
+ /** The folders inside a checkout that another measurement owns: its linked worktrees and Stim's workspace folder. */
237
+ function nestedFolders(state) {
238
+ const common = gitCommonDirOnDisk(state.path);
239
+ return [workspaceDir(state.path), ...common ? linkedWorktreesOnDisk(common).map((entry) => entry.path) : []];
240
+ }
241
+ /** The entries to hand to `du` so it sums `root` without descending into `nested`; `[root]` when none is inside it. */
242
+ function walkTargets(root, nested) {
243
+ const top = canonicalPath(root);
244
+ const skip = new Set(nested.map(canonicalPath));
245
+ const inside = [...skip].filter((path) => path.startsWith(top + sep));
246
+ if (!inside.length) return [root];
247
+ const targets = [];
248
+ const visit = (dir) => {
249
+ let names;
278
250
  try {
279
- step = reads.next(await gitAsync(path, args, {
280
- ...execution,
281
- timeoutMs,
282
- ...options
283
- }));
284
- } catch (error) {
285
- if (error instanceof WorkspaceProcessCleanupError || error instanceof Error && "reportCleanup" in error) throw error;
286
- execution.signal?.throwIfAborted();
287
- step = reads.throw(error);
251
+ names = readdirSync(dir);
252
+ } catch {
253
+ targets.push(dir);
254
+ return;
288
255
  }
289
- }
290
- execution.signal?.throwIfAborted();
291
- return step.value;
292
- }
293
- /** The last fetch timestamp, or null, read without blocking on Git. */
294
- async function lastFetchedAtAsync(repo, execution = {}) {
295
- return fetchTime(await gitQuietAsync(repo, [
296
- "rev-parse",
297
- "--path-format=absolute",
298
- "--git-path",
299
- "FETCH_HEAD"
300
- ], execution));
256
+ for (const name of names) {
257
+ const path = join(dir, name);
258
+ if (skip.has(path)) continue;
259
+ if (inside.some((nestedPath) => nestedPath.startsWith(path + sep))) visit(path);
260
+ else targets.push(path);
261
+ }
262
+ };
263
+ visit(top);
264
+ return targets;
301
265
  }
302
- /** The existing origin/HEAD target, or a refusal, without fetching or blocking on Git. */
303
- async function localDefaultBranchAsync(repo, execution = {}) {
304
- return defaultBranch(repo, await gitQuietAsync(repo, [
305
- "symbolic-ref",
306
- "--quiet",
307
- "refs/remotes/origin/HEAD"
308
- ], execution));
266
+ function isStale(measure, maxAgeMs, now) {
267
+ return !measure || now - Date.parse(measure.measuredAt) >= maxAgeMs;
309
268
  }
310
- /** Refreshes origin/HEAD without credential prompts when the last fetch is at least ten minutes old. */
311
- async function fetchDefaultBranchAsync(repo, now = Date.now(), execution = {}) {
312
- execution.signal?.throwIfAborted();
313
- const target = await localDefaultBranchAsync(repo, execution);
314
- if ("error" in target) return target;
315
- const at = await lastFetchedAtAsync(repo, execution);
316
- if (at !== null && now - at < 6e5) return target;
317
- const branch = target.ref.slice(20);
269
+ function takeWalkClaim() {
318
270
  try {
319
- await gitAsync(repo, [
320
- "fetch",
321
- "--quiet",
322
- "--no-tags",
323
- "--no-recurse-submodules",
324
- "origin",
325
- `+refs/heads/${branch}:${target.ref}`
326
- ], {
327
- ...execution,
328
- write: true,
329
- timeoutMs: FETCH_TIMEOUT_MS,
330
- env: {
331
- ...execution.env,
332
- GIT_TERMINAL_PROMPT: "0"
333
- },
334
- omitEnv: execution.omitEnv?.filter((name) => name !== "GIT_TERMINAL_PROMPT")
271
+ const attempt = tryAcquireClaim({
272
+ root: diskUsageWalkClaims(),
273
+ mode: "exclusive",
274
+ label: "disk usage walk"
335
275
  });
276
+ if (attempt.pending) releaseClaim(attempt.pending);
277
+ return attempt.acquired ?? null;
336
278
  } catch (error) {
337
- if (error instanceof WorkspaceProcessCleanupError || error instanceof Error && "reportCleanup" in error) throw error;
338
- execution.signal?.throwIfAborted();
339
- return { error: `git fetch origin ${branch} failed: ${failure(error)}` };
340
- }
341
- return target;
342
- }
343
- //#endregion
344
- //#region src/status-measures.ts
345
- const LIVE_DISK_MAX_AGE_MS = 3e5;
346
- const IDLE_DISK_MAX_AGE_MS = 36e5;
347
- const DU_TIMEOUT_MS = 12e4;
348
- const DU_NICENESS = "19";
349
- const PULL_REQUEST_MAX_AGE_MS = 3e5;
350
- const PULL_REQUEST_CHECK_MS = 6e4;
351
- const GIT_TIMEOUT_MS = 5e3;
352
- const AGENT_DISCOVERY_MS = 15e3;
353
- const AGENT_CACHE_MAX_AGE_MS = 12e4;
354
- function writeCacheFile(file, entry) {
355
- const temporary = `${file}.${randomUUID()}.tmp`;
356
- try {
357
- mkdirSync(dirname(file), { recursive: true });
358
- writeFileSync(temporary, JSON.stringify(entry));
359
- renameSync(temporary, file);
360
- } catch {
361
- rmSync(temporary, { force: true });
362
- }
363
- }
364
- function forgetStatusMeasures(worktree, workspaces) {
365
- const roots = [.../* @__PURE__ */ new Set([worktree, ...workspaces])];
366
- const files = [pullRequestCacheFile(worktree), ...roots.flatMap((root) => [
367
- diskUsageCacheFile(root),
368
- diskUsageFailureFile(root),
369
- diskUsageCacheFile(join(root, "node_modules")),
370
- diskUsageFailureFile(join(root, "node_modules")),
371
- diskUsageCacheFile(workspaceDir(root)),
372
- diskUsageFailureFile(workspaceDir(root))
373
- ])];
374
- for (const file of files) try {
375
- rmSync(file, { force: true });
376
- } catch {}
377
- }
378
- function simulatorDataDir(udid) {
379
- return join(homedir(), "Library", "Developer", "CoreSimulator", "Devices", udid);
380
- }
381
- function checkoutDir(state) {
382
- if (state.worktree?.path) return state.worktree.path;
383
- const common = gitCommonDirOnDisk(state.path);
384
- return common && basename(common) === ".git" ? dirname(common) : state.path;
385
- }
386
- function deviceFolders(state) {
387
- const folders = [];
388
- for (const { ios, android } of [state, ...state.slots ?? []]) {
389
- if (ios?.owned) folders.push({
390
- device: ios,
391
- path: simulatorDataDir(ios.udid)
392
- });
393
- if (android?.owned && !android.physical && android.name) {
394
- const path = ownedAvdDirectory(android.name);
395
- if (path) folders.push({
396
- device: android,
397
- path
398
- });
399
- }
279
+ debugLog.log("disk-walk-claim", { error: String(error?.message ?? error) });
280
+ return null;
400
281
  }
401
- return folders;
402
- }
403
- /** The folders an environment's disk use sums: its checkout, its node_modules folders and Stim's workspace folder. */
404
- function environmentFolders(state) {
405
- const worktree = checkoutDir(state);
406
- return {
407
- worktree,
408
- nodeModules: [.../* @__PURE__ */ new Set([join(worktree, "node_modules"), join(state.path, "node_modules")])],
409
- build: workspaceDir(state.path)
410
- };
411
- }
412
- /** The environment's disk use from the cached measurements; null before any folder was measured. */
413
- function environmentDisk(folders) {
414
- const worktree = readDiskUsage(folders.worktree);
415
- const modules = folders.nodeModules.map((path) => readDiskUsage(path));
416
- const build = readDiskUsage(folders.build);
417
- const measured = [
418
- worktree,
419
- ...modules,
420
- build
421
- ].filter((entry) => entry !== null);
422
- if (!measured.length) return null;
423
- return {
424
- worktreeBytes: worktree?.bytes ?? null,
425
- nodeModulesBytes: modules.every((entry) => entry !== null) ? modules.reduce((sum, entry) => sum + entry.bytes, 0) : null,
426
- buildBytes: build?.bytes ?? null,
427
- measuredAt: measured.map((entry) => entry.measuredAt).toSorted()[0]
428
- };
429
- }
430
- function statusPullRequest(fact, checkedAt) {
431
- return {
432
- number: fact.number,
433
- url: fact.url,
434
- title: fact.title ?? "",
435
- state: fact.state === "open" && fact.draft ? "draft" : fact.state,
436
- checks: fact.checks ?? null,
437
- reviewDecision: fact.reviewDecision ?? null,
438
- checkedAt
439
- };
440
282
  }
441
283
  /**
442
- * Writes a pull request lookup into the cache `status` and report-only `gc` read. A lookup made without detail is
443
- * marked `partial`, so `status` asks again for the title, checks and review decision; until then it keeps the title
444
- * cached for the same pull request, and the checks and review decision cached for the same HEAD.
284
+ * Keeps the disk use, pull request and agent session caches fresh for `status --watch`, off its refresh path: one
285
+ * agent session discovery at most every 15 seconds, which status ignores once it is over 2 minutes old, one `du -sk` at
286
+ * a time on the whole machine (an ownership claim; a failed walk is recorded so other watchers do not repeat it), at low
287
+ * priority, live environments first, a checkout without the linked worktrees nested in it, each folder at most every 5 minutes while its environment is live and every hour otherwise, and one `gh api
288
+ * graphql` per repository for worktrees whose lookup is over 5 minutes old or whose branch or HEAD moved. It
289
+ * runs git only in the repository, never in a worktree, and only for worktrees whose git state status read. It rechecks
290
+ * a folder's cache right before measuring, so two watchers rarely measure the same folder. A session the previous
291
+ * discovery found and this one does not is recorded as ended in the workspaces it worked in. `updated` runs after each
292
+ * write.
445
293
  */
446
- function recordPullRequestLookup({ path, branch, head }, fact, checkedAt) {
447
- let pullRequest = fact ? statusPullRequest(fact, checkedAt) : null;
448
- const partial = pullRequest !== null && fact?.title === void 0;
449
- const previous = partial ? readPullRequestCache(path) : null;
450
- if (pullRequest && previous?.pullRequest?.number === pullRequest.number) {
451
- const same = previous.head === head;
452
- pullRequest = {
453
- ...pullRequest,
454
- title: previous.pullRequest.title,
455
- checks: same ? previous.pullRequest.checks : null,
456
- reviewDecision: same ? previous.pullRequest.reviewDecision : null
294
+ function createStatusMeasurer({ updated, now = Date.now }) {
295
+ const exec = getExecutor();
296
+ let measuring = false;
297
+ let duMissing = false;
298
+ let checking = false;
299
+ let checkedAt = -Infinity;
300
+ let discovering = false;
301
+ let discoveredAt = -Infinity;
302
+ async function du(targets, claim) {
303
+ const hold = (child) => {
304
+ if (child.pid === void 0) return;
305
+ const captured = captureProcessIdentity(child.pid);
306
+ if (!captured.ok) throw new Error(`Cannot record the du process: ${captured.reason}`);
307
+ setClaimChild(claim, {
308
+ pid: child.pid,
309
+ processToken: captured.token
310
+ });
457
311
  };
312
+ try {
313
+ markClaimChildPending(claim);
314
+ return parseDuKilobytes(await exec.runFileAsync("nice", [
315
+ "-n",
316
+ DU_NICENESS,
317
+ "du",
318
+ "-sk",
319
+ ...targets
320
+ ], {
321
+ timeoutMs: DU_TIMEOUT_MS,
322
+ onSpawn: hold
323
+ }));
324
+ } catch (error) {
325
+ const { code, status, stdout } = error;
326
+ if (code === "ENOENT") duMissing = true;
327
+ return status === 1 && typeof stdout === "string" ? parseDuKilobytes(stdout) : null;
328
+ } finally {
329
+ clearClaimChild(claim);
330
+ }
458
331
  }
459
- const entry = {
460
- path,
461
- branch,
462
- head,
463
- checkedAt,
464
- pullRequest,
465
- ...fact ? {
466
- containsHead: fact.containsHead,
467
- endedAt: fact.endedAt
468
- } : {},
469
- ...partial ? { partial: true } : {}
470
- };
471
- writeCacheFile(pullRequestCacheFile(path), entry);
472
- }
473
- function canonicalPath(path) {
474
- try {
475
- return realpathSync(path);
476
- } catch {
477
- return path;
332
+ function due(path, maxAgeMs) {
333
+ const failedAt = readDiskUsageFailure(path);
334
+ return isStale(readDiskUsage(path), maxAgeMs, now()) && (!failedAt || now() - Date.parse(failedAt) >= maxAgeMs);
478
335
  }
479
- }
480
- function agentWorkspaces(states) {
481
- return states.map((state) => ({
482
- path: canonicalPath(state.path),
483
- root: canonicalPath(checkoutDir(state)),
484
- recorded: readWorkspaceAgent(readWorkspaceState(state.path))
485
- }));
486
- }
487
- function applyAgentSessions(states, now) {
488
- const cache = readAgentSessionsCache();
489
- const sessions = cache && now - Date.parse(cache.discoveredAt) <= AGENT_CACHE_MAX_AGE_MS ? cache.sessions : [];
490
- const discovered = new Set(sessions.map(agentKey));
491
- const ended = states.map((state) => readEndedAgentSessions(state.path, now));
492
- const workspaces = agentWorkspaces(states);
493
- workspaces.forEach((workspace, i) => {
494
- const { recorded } = workspace;
495
- if (!recorded || discovered.has(agentKey(recorded))) return;
496
- if (ended[i].some((session) => agentKey(session) === agentKey(recorded))) workspace.recorded = null;
497
- });
498
- attributeAgentSessions(workspaces, sessions, now).forEach((agents, i) => {
499
- if (agents.length) states[i].agents = agents;
500
- const running = new Set(agents.map(agentKey));
501
- const endedAgents = ended[i].filter((session) => !running.has(agentKey(session)));
502
- if (endedAgents.length) states[i].endedAgents = endedAgents;
503
- });
504
- }
505
- function writeFileAtomic(file, entry) {
506
- const temporary = `${file}.${randomUUID()}.tmp`;
507
- try {
508
- writeFileSync(temporary, JSON.stringify(entry));
509
- renameSync(temporary, file);
510
- } catch (error) {
511
- rmSync(temporary, { force: true });
512
- throw error;
336
+ async function measure(folders) {
337
+ for (const { path, maxAgeMs, nested } of folders) {
338
+ if (duMissing || !due(path, maxAgeMs)) continue;
339
+ const stamp = () => ({
340
+ path,
341
+ measuredAt: new Date(now()).toISOString()
342
+ });
343
+ if (!existsSync(path)) {
344
+ if (!basename(path).startsWith("node_modules")) continue;
345
+ writeCacheFile(diskUsageCacheFile(path), {
346
+ ...stamp(),
347
+ bytes: 0
348
+ });
349
+ updated();
350
+ continue;
351
+ }
352
+ const claim = takeWalkClaim();
353
+ if (!claim) return;
354
+ try {
355
+ if (!due(path, maxAgeMs)) continue;
356
+ const targets = walkTargets(path, nested?.() ?? []);
357
+ const kb = targets.length ? await du(targets, claim) : 0;
358
+ if (kb === null) {
359
+ writeCacheFile(diskUsageFailureFile(path), {
360
+ path,
361
+ failedAt: new Date(now()).toISOString()
362
+ });
363
+ continue;
364
+ }
365
+ writeCacheFile(diskUsageCacheFile(path), {
366
+ ...stamp(),
367
+ bytes: kb * 1024
368
+ });
369
+ updated();
370
+ } finally {
371
+ releaseClaim(claim);
372
+ }
373
+ }
513
374
  }
514
- }
515
- function recordEndedAgents(states, gone, endedAt, now) {
516
- const goneKeys = new Set(gone.map(agentKey));
517
- const result = {
518
- changed: false,
519
- failed: false
520
- };
521
- attributeAgentSessions(agentWorkspaces(states), gone, now).forEach((sessions, i) => {
522
- const root = states[i].path;
523
- const ended = sessions.filter((session) => goneKeys.has(agentKey(session)));
524
- if (!ended.length || !existsSync(workspaceDir(root))) return;
375
+ async function branchHead(repository, branch) {
525
376
  try {
526
- withDirLock(workspaceEndedAgentsLock(root), () => {
527
- const kept = new Map(readEndedAgentSessions(root, now).map((session) => [agentKey(session), session]));
528
- for (const { pid: _pid, ...session } of ended) kept.set(agentKey(session), {
529
- ...session,
530
- endedAt
531
- });
532
- writeFileAtomic(workspaceEndedAgentsFile(root), { sessions: [...kept.values()] });
533
- });
534
- result.changed = true;
377
+ return (await gitAsync(repository, [
378
+ "rev-parse",
379
+ "--verify",
380
+ `refs/heads/${branch}`
381
+ ], { timeoutMs: GIT_TIMEOUT_MS$1 })).trim() || null;
535
382
  } catch {
536
- result.failed = true;
383
+ return null;
537
384
  }
538
- });
539
- return result;
540
- }
541
- /**
542
- * Adds the cached measurements to a status read: each environment's `disk`, `agents` and `endedAgents`, each owned
543
- * device's `disk`, and each linked worktree's `pullRequest` when the cache holds a lookup for its current branch.
544
- * Reads files only.
545
- */
546
- function applyStatusMeasures(states, worktrees) {
547
- applyAgentSessions(states, Date.now());
548
- for (const state of states) {
549
- const disk = environmentDisk(environmentFolders(state));
550
- if (disk) state.disk = disk;
551
- for (const { device, path } of deviceFolders(state)) {
552
- const measure = readDiskUsage(path);
553
- if (measure) device.disk = measure;
385
+ }
386
+ async function checkPullRequests(worktrees) {
387
+ const lookup = pullRequestLookups({ detail: true });
388
+ const byRepository = /* @__PURE__ */ new Map();
389
+ for (const { path, branch, repository, git } of worktrees) {
390
+ if (!branch || !repository || !git) continue;
391
+ const head = await branchHead(repository, branch);
392
+ if (!head) continue;
393
+ const cached = readPullRequestCache(path);
394
+ if (!(cached?.branch === branch && cached.head === head && !cached.partial && now() - Date.parse(cached.checkedAt) < PULL_REQUEST_MAX_AGE_MS)) byRepository.set(repository, [...byRepository.get(repository) ?? [], {
395
+ path,
396
+ cwd: repository,
397
+ branch,
398
+ head
399
+ }]);
400
+ }
401
+ for (const [repository, queries] of byRepository) {
402
+ const results = await lookup(repository, queries);
403
+ const at = new Date(now()).toISOString();
404
+ results.forEach((result, i) => {
405
+ if (!("pullRequest" in result)) return;
406
+ const { path, branch, head } = queries[i];
407
+ recordPullRequestLookup({
408
+ path,
409
+ branch,
410
+ head
411
+ }, result.pullRequest, at);
412
+ });
413
+ if (results.some((result) => "pullRequest" in result)) updated();
554
414
  }
555
415
  }
556
- for (const worktree of worktrees) {
557
- const cached = worktree.branch ? readPullRequestCache(worktree.path) : null;
558
- if (cached && cached.branch === worktree.branch) worktree.pullRequest = cached.pullRequest;
416
+ async function discoverAgents(states) {
417
+ const at = now();
418
+ const sessions = await discoverAgentSessions({ now: at });
419
+ const previous = readAgentSessionsCache();
420
+ const running = new Set(sessions.map(agentKey));
421
+ const lastFound = previous ? Date.parse(previous.discoveredAt) : -Infinity;
422
+ const gone = at - lastFound <= ENDED_AGENT_RETENTION_MS ? previous.sessions.filter((session) => !running.has(agentKey(session))) : [];
423
+ const ended = gone.length ? recordEndedAgents(states, gone, previous.discoveredAt, at) : {
424
+ changed: false,
425
+ failed: false
426
+ };
427
+ if (ended.failed && at - lastFound <= AGENT_CACHE_MAX_AGE_MS) return;
428
+ writeCacheFile(agentSessionsCacheFile(), {
429
+ discoveredAt: new Date(at).toISOString(),
430
+ sessions
431
+ });
432
+ const shown = previous && at - Date.parse(previous.discoveredAt) <= AGENT_CACHE_MAX_AGE_MS;
433
+ if (ended.changed || !shown || JSON.stringify(previous.sessions) !== JSON.stringify(sessions)) updated();
434
+ }
435
+ return { schedule(states, worktrees) {
436
+ if (!discovering && now() - discoveredAt >= AGENT_DISCOVERY_MS) {
437
+ discovering = true;
438
+ discoveredAt = now();
439
+ discoverAgents(states).catch(() => {}).finally(() => {
440
+ discovering = false;
441
+ });
442
+ }
443
+ if (!measuring) {
444
+ const folders = /* @__PURE__ */ new Map();
445
+ for (const state of states.toSorted((a, b) => Number(b.live) - Number(a.live))) {
446
+ if (!existsSync(state.path)) continue;
447
+ const maxAgeMs = state.live ? LIVE_DISK_MAX_AGE_MS : IDLE_DISK_MAX_AGE_MS;
448
+ const { worktree, nodeModules, build } = environmentFolders(state);
449
+ for (const path of [
450
+ worktree,
451
+ ...nodeModules,
452
+ build,
453
+ ...deviceFolders(state).map((folder) => folder.path)
454
+ ]) {
455
+ const known = folders.get(path);
456
+ folders.set(path, {
457
+ path,
458
+ maxAgeMs: Math.min(known?.maxAgeMs ?? Infinity, maxAgeMs),
459
+ nested: known?.nested ?? (path === worktree ? () => nestedFolders(state) : void 0)
460
+ });
461
+ }
462
+ }
463
+ measuring = true;
464
+ measure([...folders.values()]).catch(() => {}).finally(() => {
465
+ measuring = false;
466
+ });
467
+ }
468
+ if (!checking && now() - checkedAt >= PULL_REQUEST_CHECK_MS) {
469
+ checking = true;
470
+ checkedAt = now();
471
+ checkPullRequests(worktrees).catch(() => {}).finally(() => {
472
+ checking = false;
473
+ });
474
+ }
475
+ } };
476
+ }
477
+ //#endregion
478
+ //#region src/workspace/merge-state.ts
479
+ const FETCH_TIMEOUT_MS = 3e4;
480
+ /** How old a fetch or a cached lookup can be and still count as fresh. */
481
+ const FETCH_FRESH_MS = 6e5;
482
+ const GIT_TIMEOUT_MS = 6e4;
483
+ const REMOTE_PREFIX = "refs/remotes/origin/";
484
+ const MAX_MERGE_STATE_COMMITS = 2e3;
485
+ function failure(error) {
486
+ const { code, stderr, message } = error;
487
+ const text = (code === "ENOBUFS" ? "" : String(stderr ?? "").trim()) || String(message ?? error);
488
+ return text.split("\n")[0] ?? text;
489
+ }
490
+ function fetchTime(path) {
491
+ path = path?.trim() ?? null;
492
+ try {
493
+ return path ? statSync(path).mtimeMs : null;
494
+ } catch {
495
+ return null;
559
496
  }
560
497
  }
561
- /** Kilobytes from `du -sk` output, summed over its lines, or null when it printed none. */
562
- function parseDuKilobytes(output) {
563
- const sizes = [...output.matchAll(/^(\d+)\s/gm)].map((match) => Number(match[1]));
564
- return sizes.length ? sizes.reduce((sum, kb) => sum + kb, 0) : null;
498
+ function defaultBranch(repo, ref) {
499
+ ref = ref?.trim() ?? null;
500
+ if (!ref?.startsWith(REMOTE_PREFIX)) return { error: `origin/HEAD is not set; run \`git -C ${repo} remote set-head origin --auto\`` };
501
+ return {
502
+ ref,
503
+ name: `origin/${ref.slice(20)}`
504
+ };
565
505
  }
566
- /** The folders inside a checkout that another measurement owns: its linked worktrees and Stim's workspace folder. */
567
- function nestedFolders(state) {
568
- const common = gitCommonDirOnDisk(state.path);
569
- return [workspaceDir(state.path), ...common ? linkedWorktreesOnDisk(common).map((entry) => entry.path) : []];
506
+ function notMerged(detail) {
507
+ return {
508
+ merged: false,
509
+ unknown: false,
510
+ detail
511
+ };
570
512
  }
571
- /** The entries to hand to `du` so it sums `root` without descending into `nested`; `[root]` when none is inside it. */
572
- function walkTargets(root, nested) {
573
- const top = canonicalPath(root);
574
- const skip = new Set(nested.map(canonicalPath));
575
- const inside = [...skip].filter((path) => path.startsWith(top + sep));
576
- if (!inside.length) return [root];
577
- const targets = [];
578
- const visit = (dir) => {
579
- let names;
580
- try {
581
- names = readdirSync(dir);
582
- } catch {
583
- targets.push(dir);
584
- return;
585
- }
586
- for (const name of names) {
587
- const path = join(dir, name);
588
- if (skip.has(path)) continue;
589
- if (inside.some((nestedPath) => nestedPath.startsWith(path + sep))) visit(path);
590
- else targets.push(path);
591
- }
513
+ function* git(args, input) {
514
+ return yield {
515
+ args: ["--literal-pathspecs", ...args],
516
+ options: { input }
592
517
  };
593
- visit(top);
594
- return targets;
595
518
  }
596
- function isStale(measure, maxAgeMs, now) {
597
- return !measure || now - Date.parse(measure.measuredAt) >= maxAgeMs;
519
+ function* patch(args, input) {
520
+ return yield {
521
+ args: ["--literal-pathspecs", ...args],
522
+ options: {
523
+ input,
524
+ untrimmed: true
525
+ }
526
+ };
598
527
  }
599
- function takeWalkClaim() {
528
+ function* probe(args) {
600
529
  try {
601
- const attempt = tryAcquireClaim({
602
- root: diskUsageWalkClaims(),
603
- mode: "exclusive",
604
- label: "disk usage walk"
605
- });
606
- if (attempt.pending) releaseClaim(attempt.pending);
607
- return attempt.acquired ?? null;
530
+ return yield { args };
608
531
  } catch (error) {
609
- debugLog.log("disk-walk-claim", { error: String(error?.message ?? error) });
532
+ if (isTimeoutError(error) || typeof error === "object" && error !== null && "code" in error && error.code === "ENOBUFS") throw error;
610
533
  return null;
611
534
  }
612
535
  }
613
- /**
614
- * Keeps the disk use, pull request and agent session caches fresh for `status --watch`, off its refresh path: one
615
- * agent session discovery at most every 15 seconds, which status ignores once it is over 2 minutes old, one `du -sk` at
616
- * a time on the whole machine (an ownership claim; a failed walk is recorded so other watchers do not repeat it), at low
617
- * priority, live environments first, a checkout without the linked worktrees nested in it, each folder at most every 5 minutes while its environment is live and every hour otherwise, and one `gh api
618
- * graphql` per repository for worktrees whose lookup is over 5 minutes old or whose branch or HEAD moved. It
619
- * runs git only in the repository, never in a worktree, and only for worktrees whose git state status read. It rechecks
620
- * a folder's cache right before measuring, so two watchers rarely measure the same folder. A session the previous
621
- * discovery found and this one does not is recorded as ended in the workspaces it worked in. `updated` runs after each
622
- * write.
623
- */
624
- function createStatusMeasurer({ updated, now = Date.now }) {
625
- const exec = getExecutor();
626
- let measuring = false;
627
- let duMissing = false;
628
- let checking = false;
629
- let checkedAt = -Infinity;
630
- let discovering = false;
631
- let discoveredAt = -Infinity;
632
- async function du(targets, claim) {
633
- const hold = (child) => {
634
- if (child.pid === void 0) return;
635
- const captured = captureProcessIdentity(child.pid);
636
- if (!captured.ok) throw new Error(`Cannot record the du process: ${captured.reason}`);
637
- setClaimChild(claim, {
638
- pid: child.pid,
639
- processToken: captured.token
640
- });
536
+ function* patchIdCommits(text) {
537
+ const output = text ? yield* git(["patch-id", "--verbatim"], text) : "";
538
+ return new Map(output.split("\n").flatMap((line) => {
539
+ const [id, commit] = line.split(" ");
540
+ return id && commit ? [[id, commit]] : [];
541
+ }));
542
+ }
543
+ function* patchIds(text) {
544
+ return [...(yield* patchIdCommits(text)).keys()];
545
+ }
546
+ function* committedAt(commits) {
547
+ const times = commits.length ? (yield* git([
548
+ "log",
549
+ "--no-walk",
550
+ "--stdin",
551
+ "--format=%ct"
552
+ ], commits.join("\n"))).split("\n").map(Number) : [];
553
+ if (!times.length || !times.every(Number.isFinite)) throw new Error(`no committer date for ${commits.join(" ")}`);
554
+ return Math.max(...times) * 1e3;
555
+ }
556
+ function* currentBranch() {
557
+ const ref = (yield* probe([
558
+ "symbolic-ref",
559
+ "--quiet",
560
+ "HEAD"
561
+ ]))?.trim();
562
+ return ref?.startsWith("refs/heads/") ? ref : null;
563
+ }
564
+ function* upstreamGone(branch) {
565
+ if (!branch) return false;
566
+ const [upstream, state] = ((yield* probe([
567
+ "for-each-ref",
568
+ "--format=%(upstream)%00%(upstream:track)",
569
+ branch
570
+ ])) ?? "").trim().split("\0");
571
+ return Boolean(upstream) && state === "[gone]";
572
+ }
573
+ function* committedOn(branch, head) {
574
+ if (!branch) return false;
575
+ const entries = (yield* probe([
576
+ "reflog",
577
+ "show",
578
+ "--format=%H %gs",
579
+ branch
580
+ ])) ?? "";
581
+ for (const entry of entries.split("\n")) {
582
+ const [sha = "", ...subject] = entry.split(" ");
583
+ if (/^(commit|cherry-pick|rebase|revert)\b/.test(subject.join(" ")) && (yield* probe([
584
+ "merge-base",
585
+ "--is-ancestor",
586
+ sha,
587
+ head
588
+ ])) !== null) return true;
589
+ }
590
+ return false;
591
+ }
592
+ function* mergeStateReads({ ref, name }, maxCommits) {
593
+ const diffOptions = ["--no-color", "--no-ext-diff"];
594
+ const noOwnCommits = {
595
+ merged: false,
596
+ unknown: false,
597
+ detail: `no commits of its own beyond ${name}`,
598
+ noOwnCommits: true
599
+ };
600
+ try {
601
+ const head = yield* git([
602
+ "rev-parse",
603
+ "--verify",
604
+ "HEAD^{commit}"
605
+ ]);
606
+ const base = yield* git([
607
+ "merge-base",
608
+ head,
609
+ ref
610
+ ]);
611
+ const branch = yield* currentBranch();
612
+ if (base === head) {
613
+ if ((yield* git(["rev-parse", ref])) === head || (yield* git([
614
+ "rev-list",
615
+ "--first-parent",
616
+ "--parents",
617
+ `${head}..${ref}`
618
+ ])).split("\n").some((line) => line.split(" ")[1] === head) || !(yield* committedOn(branch, head))) return noOwnCommits;
619
+ const descendants = new Set((yield* git([
620
+ "rev-list",
621
+ "--ancestry-path",
622
+ `${head}..${ref}`
623
+ ])).split("\n"));
624
+ const landed = (yield* git([
625
+ "rev-list",
626
+ "--first-parent",
627
+ "--reverse",
628
+ `${head}..${ref}`
629
+ ])).split("\n").find((commit) => descendants.has(commit));
630
+ if (!landed) throw new Error(`no commit on ${name} merges ${head}`);
631
+ return {
632
+ merged: true,
633
+ into: name,
634
+ head,
635
+ coversUnpushed: false,
636
+ mergedAt: yield* committedAt([landed])
637
+ };
638
+ }
639
+ const aheadCount = Number(yield* git([
640
+ "rev-list",
641
+ "--count",
642
+ `${base}..${head}`
643
+ ]));
644
+ if (aheadCount > maxCommits) return {
645
+ merged: false,
646
+ unknown: true,
647
+ detail: `merge state unknown: ${aheadCount} commits of its own ahead of the merge base with ${name} exceeds the ${maxCommits}-commit limit`
648
+ };
649
+ const files = (yield* git([
650
+ "diff",
651
+ "--name-only",
652
+ "--no-renames",
653
+ "-z",
654
+ base,
655
+ head
656
+ ])).split("\0").filter(Boolean);
657
+ if (!files.length) return notMerged(`no net change beyond ${name}`);
658
+ const log = function* (range, pathspec = []) {
659
+ return yield* patch([
660
+ "log",
661
+ "-p",
662
+ "--no-merges",
663
+ ...diffOptions,
664
+ "--format=commit %H",
665
+ "--stdin"
666
+ ], pathspec.length ? `${range}\n--\n${pathspec.join("\n")}\n` : `${range}\n`);
667
+ };
668
+ const upstream = yield* patchIdCommits(yield* log(`${base}..${ref}`, files));
669
+ const patchEquivalent = function* (ids) {
670
+ return {
671
+ merged: true,
672
+ into: name,
673
+ head,
674
+ coversUnpushed: yield* upstreamGone(branch),
675
+ mergedAt: yield* committedAt(ids.map((id) => upstream.get(id)))
676
+ };
677
+ };
678
+ const squash = yield* patchIds(yield* patch([
679
+ "diff",
680
+ ...diffOptions,
681
+ base,
682
+ head
683
+ ]));
684
+ if (squash.length === 1 && upstream.has(squash[0])) return yield* patchEquivalent(squash);
685
+ if (!(yield* git([
686
+ "rev-list",
687
+ "--merges",
688
+ `${base}..${head}`
689
+ ]))) {
690
+ const own = yield* patchIds(yield* log(`${base}..${head}`));
691
+ if (own.length && own.every((id) => upstream.has(id))) return yield* patchEquivalent(own);
692
+ }
693
+ return notMerged(`not merged into ${name}`);
694
+ } catch (error) {
695
+ const timedOut = isTimeoutError(error) ? { timedOut: true } : {};
696
+ return {
697
+ merged: false,
698
+ unknown: true,
699
+ detail: `merge state unknown: ${failure(error)}`,
700
+ ...timedOut
641
701
  };
702
+ }
703
+ }
704
+ /**
705
+ * Whether the worktree's HEAD is merged into `target`, and when: `mergedAt` is the committer date, in epoch
706
+ * milliseconds, of the commit on `target` that brought the work in, the latest one for a rebase merge.
707
+ * The signals, all local git:
708
+ * - HEAD is an ancestor of the default branch, off its first-parent line, and the branch's reflog shows a commit made
709
+ * on it that HEAD contains, so a merge commit brought the branch's own work in. A branch with no commit of its own
710
+ * is not merged.
711
+ * - The branch changes the tree, has no merge commits, and every commit since the merge base has the same
712
+ * `git patch-id --verbatim` as a commit on the default branch (a rebase merge).
713
+ * - The branch changes the tree and its whole diff since the merge base has the same verbatim patch id as a commit on
714
+ * the default branch, limited to the files the branch changes (a squash merge).
715
+ * A HEAD the default branch contains with no commit of its own on the branch is not merged and carries
716
+ * `noOwnCommits`. Anything git cannot answer is unknown, never merged. `coversUnpushed` is true for a patch-equivalent HEAD whose
717
+ * upstream branch was deleted: its commits exist only locally, but their change is on the default branch.
718
+ */
719
+ function mergeState(path, target, { timeoutMs = GIT_TIMEOUT_MS, maxCommits = MAX_MERGE_STATE_COMMITS } = {}) {
720
+ const reads = mergeStateReads(target, maxCommits);
721
+ let step = reads.next();
722
+ while (!step.done) {
723
+ const { args, options } = step.value;
642
724
  try {
643
- markClaimChildPending(claim);
644
- return parseDuKilobytes(await exec.runFileAsync("nice", [
645
- "-n",
646
- DU_NICENESS,
647
- "du",
648
- "-sk",
649
- ...targets
650
- ], {
651
- timeoutMs: DU_TIMEOUT_MS,
652
- onSpawn: hold
725
+ step = reads.next(git$1(path, args, {
726
+ timeoutMs,
727
+ ...options
653
728
  }));
654
729
  } catch (error) {
655
- const { code, status, stdout } = error;
656
- if (code === "ENOENT") duMissing = true;
657
- return status === 1 && typeof stdout === "string" ? parseDuKilobytes(stdout) : null;
658
- } finally {
659
- clearClaimChild(claim);
660
- }
661
- }
662
- function due(path, maxAgeMs) {
663
- const failedAt = readDiskUsageFailure(path);
664
- return isStale(readDiskUsage(path), maxAgeMs, now()) && (!failedAt || now() - Date.parse(failedAt) >= maxAgeMs);
665
- }
666
- async function measure(folders) {
667
- for (const { path, maxAgeMs, nested } of folders) {
668
- if (duMissing || !due(path, maxAgeMs)) continue;
669
- const stamp = () => ({
670
- path,
671
- measuredAt: new Date(now()).toISOString()
672
- });
673
- if (!existsSync(path)) {
674
- if (!basename(path).startsWith("node_modules")) continue;
675
- writeCacheFile(diskUsageCacheFile(path), {
676
- ...stamp(),
677
- bytes: 0
678
- });
679
- updated();
680
- continue;
681
- }
682
- const claim = takeWalkClaim();
683
- if (!claim) return;
684
- try {
685
- if (!due(path, maxAgeMs)) continue;
686
- const targets = walkTargets(path, nested?.() ?? []);
687
- const kb = targets.length ? await du(targets, claim) : 0;
688
- if (kb === null) {
689
- writeCacheFile(diskUsageFailureFile(path), {
690
- path,
691
- failedAt: new Date(now()).toISOString()
692
- });
693
- continue;
694
- }
695
- writeCacheFile(diskUsageCacheFile(path), {
696
- ...stamp(),
697
- bytes: kb * 1024
698
- });
699
- updated();
700
- } finally {
701
- releaseClaim(claim);
702
- }
730
+ step = reads.throw(error);
703
731
  }
704
732
  }
705
- async function branchHead(repository, branch) {
733
+ return step.value;
734
+ }
735
+ /** `mergeState` without blocking the event loop during Git reads. */
736
+ async function mergeStateAsync(path, target, { timeoutMs = GIT_TIMEOUT_MS, maxCommits = MAX_MERGE_STATE_COMMITS, ...execution } = {}) {
737
+ const reads = mergeStateReads(target, maxCommits);
738
+ let step = reads.next();
739
+ while (!step.done) {
740
+ execution.signal?.throwIfAborted();
741
+ const { args, options } = step.value;
706
742
  try {
707
- return (await gitAsync(repository, [
708
- "rev-parse",
709
- "--verify",
710
- `refs/heads/${branch}`
711
- ], { timeoutMs: GIT_TIMEOUT_MS })).trim() || null;
712
- } catch {
713
- return null;
714
- }
715
- }
716
- async function checkPullRequests(worktrees) {
717
- const lookup = pullRequestLookups({ detail: true });
718
- const byRepository = /* @__PURE__ */ new Map();
719
- for (const { path, branch, repository, git } of worktrees) {
720
- if (!branch || !repository || !git) continue;
721
- const head = await branchHead(repository, branch);
722
- if (!head) continue;
723
- const cached = readPullRequestCache(path);
724
- if (!(cached?.branch === branch && cached.head === head && !cached.partial && now() - Date.parse(cached.checkedAt) < PULL_REQUEST_MAX_AGE_MS)) byRepository.set(repository, [...byRepository.get(repository) ?? [], {
725
- path,
726
- cwd: repository,
727
- branch,
728
- head
729
- }]);
730
- }
731
- for (const [repository, queries] of byRepository) {
732
- const results = await lookup(repository, queries);
733
- const at = new Date(now()).toISOString();
734
- results.forEach((result, i) => {
735
- if (!("pullRequest" in result)) return;
736
- const { path, branch, head } = queries[i];
737
- recordPullRequestLookup({
738
- path,
739
- branch,
740
- head
741
- }, result.pullRequest, at);
742
- });
743
- if (results.some((result) => "pullRequest" in result)) updated();
743
+ step = reads.next(await gitAsync(path, args, {
744
+ ...execution,
745
+ timeoutMs,
746
+ ...options
747
+ }));
748
+ } catch (error) {
749
+ if (error instanceof WorkspaceProcessCleanupError || error instanceof Error && "reportCleanup" in error) throw error;
750
+ execution.signal?.throwIfAborted();
751
+ step = reads.throw(error);
744
752
  }
745
753
  }
746
- async function discoverAgents(states) {
747
- const at = now();
748
- const sessions = await discoverAgentSessions({ now: at });
749
- const previous = readAgentSessionsCache();
750
- const running = new Set(sessions.map(agentKey));
751
- const lastFound = previous ? Date.parse(previous.discoveredAt) : -Infinity;
752
- const gone = at - lastFound <= ENDED_AGENT_RETENTION_MS ? previous.sessions.filter((session) => !running.has(agentKey(session))) : [];
753
- const ended = gone.length ? recordEndedAgents(states, gone, previous.discoveredAt, at) : {
754
- changed: false,
755
- failed: false
756
- };
757
- if (ended.failed && at - lastFound <= AGENT_CACHE_MAX_AGE_MS) return;
758
- writeCacheFile(agentSessionsCacheFile(), {
759
- discoveredAt: new Date(at).toISOString(),
760
- sessions
754
+ execution.signal?.throwIfAborted();
755
+ return step.value;
756
+ }
757
+ /** The last fetch timestamp, or null, read without blocking on Git. */
758
+ async function lastFetchedAtAsync(repo, execution = {}) {
759
+ return fetchTime(await gitQuietAsync(repo, [
760
+ "rev-parse",
761
+ "--path-format=absolute",
762
+ "--git-path",
763
+ "FETCH_HEAD"
764
+ ], execution));
765
+ }
766
+ /** The existing origin/HEAD target, or a refusal, without fetching or blocking on Git. */
767
+ async function localDefaultBranchAsync(repo, execution = {}) {
768
+ return defaultBranch(repo, await gitQuietAsync(repo, [
769
+ "symbolic-ref",
770
+ "--quiet",
771
+ "refs/remotes/origin/HEAD"
772
+ ], execution));
773
+ }
774
+ /** Refreshes origin/HEAD without credential prompts when the last fetch is at least ten minutes old. */
775
+ async function fetchDefaultBranchAsync(repo, now = Date.now(), execution = {}) {
776
+ execution.signal?.throwIfAborted();
777
+ const target = await localDefaultBranchAsync(repo, execution);
778
+ if ("error" in target) return target;
779
+ const at = await lastFetchedAtAsync(repo, execution);
780
+ if (at !== null && now - at < 6e5) return target;
781
+ const branch = target.ref.slice(20);
782
+ try {
783
+ await gitAsync(repo, [
784
+ "fetch",
785
+ "--quiet",
786
+ "--no-tags",
787
+ "--no-recurse-submodules",
788
+ "origin",
789
+ `+refs/heads/${branch}:${target.ref}`
790
+ ], {
791
+ ...execution,
792
+ write: true,
793
+ timeoutMs: FETCH_TIMEOUT_MS,
794
+ env: {
795
+ ...execution.env,
796
+ GIT_TERMINAL_PROMPT: "0"
797
+ },
798
+ omitEnv: execution.omitEnv?.filter((name) => name !== "GIT_TERMINAL_PROMPT")
761
799
  });
762
- const shown = previous && at - Date.parse(previous.discoveredAt) <= AGENT_CACHE_MAX_AGE_MS;
763
- if (ended.changed || !shown || JSON.stringify(previous.sessions) !== JSON.stringify(sessions)) updated();
800
+ } catch (error) {
801
+ if (error instanceof WorkspaceProcessCleanupError || error instanceof Error && "reportCleanup" in error) throw error;
802
+ execution.signal?.throwIfAborted();
803
+ return { error: `git fetch origin ${branch} failed: ${failure(error)}` };
764
804
  }
765
- return { schedule(states, worktrees) {
766
- if (!discovering && now() - discoveredAt >= AGENT_DISCOVERY_MS) {
767
- discovering = true;
768
- discoveredAt = now();
769
- discoverAgents(states).catch(() => {}).finally(() => {
770
- discovering = false;
771
- });
772
- }
773
- if (!measuring) {
774
- const folders = /* @__PURE__ */ new Map();
775
- for (const state of states.toSorted((a, b) => Number(b.live) - Number(a.live))) {
776
- if (!existsSync(state.path)) continue;
777
- const maxAgeMs = state.live ? LIVE_DISK_MAX_AGE_MS : IDLE_DISK_MAX_AGE_MS;
778
- const { worktree, nodeModules, build } = environmentFolders(state);
779
- for (const path of [
780
- worktree,
781
- ...nodeModules,
782
- build,
783
- ...deviceFolders(state).map((folder) => folder.path)
784
- ]) {
785
- const known = folders.get(path);
786
- folders.set(path, {
787
- path,
788
- maxAgeMs: Math.min(known?.maxAgeMs ?? Infinity, maxAgeMs),
789
- nested: known?.nested ?? (path === worktree ? () => nestedFolders(state) : void 0)
790
- });
791
- }
792
- }
793
- measuring = true;
794
- measure([...folders.values()]).catch(() => {}).finally(() => {
795
- measuring = false;
796
- });
797
- }
798
- if (!checking && now() - checkedAt >= PULL_REQUEST_CHECK_MS) {
799
- checking = true;
800
- checkedAt = now();
801
- checkPullRequests(worktrees).catch(() => {}).finally(() => {
802
- checking = false;
803
- });
804
- }
805
- } };
805
+ return target;
806
806
  }
807
807
  //#endregion
808
808
  //#region src/engine/warm-progress.ts
@@ -860,14 +860,18 @@ function startWarmProgress(root, step, { note, now = Date.now }) {
860
860
  ...state,
861
861
  [WARM_KEY]: warming(step)
862
862
  }));
863
+ const phases = trace.phases("worktree warm");
864
+ phases.enter(step);
863
865
  return {
864
866
  step(next) {
867
+ phases.enter(next);
865
868
  write((state) => ours(state) ? {
866
869
  ...state,
867
870
  [WARM_KEY]: warming(next)
868
871
  } : state);
869
872
  },
870
873
  finish(succeeded) {
874
+ phases.end({ ok: succeeded });
871
875
  write((state) => {
872
876
  if (!ours(state)) return state;
873
877
  const { [WARM_KEY]: _, ...rest } = state;
@@ -914,4 +918,4 @@ function workspacePhase(live, state, { now = Date.now(), survey = ownership_clai
914
918
  };
915
919
  }
916
920
  //#endregion
917
- export { forgetStatusMeasures as a, FETCH_FRESH_MS as c, localDefaultBranchAsync as d, mergeState as f, createStatusMeasurer as i, fetchDefaultBranchAsync as l, workspacePhase as n, recordPullRequestLookup as o, mergeStateAsync as p, applyStatusMeasures as r, writeCacheFile as s, startWarmProgress as t, lastFetchedAtAsync as u };
921
+ export { lastFetchedAtAsync as a, mergeStateAsync as c, forgetStatusMeasures as d, recordPullRequestLookup as f, fetchDefaultBranchAsync as i, applyStatusMeasures as l, workspacePhase as n, localDefaultBranchAsync as o, writeCacheFile as p, FETCH_FRESH_MS as r, mergeState as s, startWarmProgress as t, createStatusMeasurer as u };