@dylanpiercey/work 0.0.119 → 0.0.121

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 (262) hide show
  1. package/README.md +35 -8
  2. package/bin/work.mjs +380 -41
  3. package/dist/_B1lVb01g.js +2 -0
  4. package/dist/_Bjh5JEm7.js +2 -0
  5. package/dist/_CfwTkTbL.js +352 -0
  6. package/dist/{_BOIUqihs.js → _CivNK9fo.js} +20 -18
  7. package/dist/{_DE2RUdbF.js → _Cr71Xd0d.js} +8 -3
  8. package/dist/_DJyHc1w-.js +1739 -0
  9. package/dist/{_BIhoUpsW.js → _DnaZVHJ7.js} +95 -94
  10. package/dist/_Dpa8WFuv.js +2 -0
  11. package/dist/index.mjs +4301 -4236
  12. package/dist/public/assets/{_BSsp7Z4i2.js → _-zdUyUYM2.js} +1 -1
  13. package/dist/public/assets/_-zdUyUYM2.js.br +0 -0
  14. package/dist/public/assets/{_LUNsNRnJ2.js → _0L2K8sFq2.js} +1 -1
  15. package/dist/public/assets/_0L2K8sFq2.js.br +0 -0
  16. package/dist/public/assets/{_BE3wNPJo.js → _42jdam49.js} +1 -1
  17. package/dist/public/assets/_42jdam49.js.br +0 -0
  18. package/dist/public/assets/{_CIFN7c1t2.js → _9dJ8BnyZ2.js} +1 -1
  19. package/dist/public/assets/_9dJ8BnyZ2.js.br +0 -0
  20. package/dist/public/assets/{_CqDb111T.js → _B03G1Nvs.js} +1 -1
  21. package/dist/public/assets/_B03G1Nvs.js.br +0 -0
  22. package/dist/public/assets/{_Dzy9r1lm.js → _B0dZ7_qK.js} +1 -1
  23. package/dist/public/assets/_B0dZ7_qK.js.br +0 -0
  24. package/dist/public/assets/{_c1YZLlfE.js → _B1VuWiKK.js} +1 -1
  25. package/dist/public/assets/_B1VuWiKK.js.br +0 -0
  26. package/dist/public/assets/{_Bp1b0XWB2.js → _B5nrQZIq2.js} +1 -1
  27. package/dist/public/assets/_B5nrQZIq2.js.br +0 -0
  28. package/dist/public/assets/{_Bq2fn_8D.js → _B8ZQ8inF.js} +1 -1
  29. package/dist/public/assets/_B8ZQ8inF.js.br +0 -0
  30. package/dist/public/assets/{_CvnxxlPb.js → _B9e51G1K.js} +1 -1
  31. package/dist/public/assets/_B9e51G1K.js.br +0 -0
  32. package/dist/public/assets/{_CUjnrp_u2.js → _BH015W2i2.js} +1 -1
  33. package/dist/public/assets/_BH015W2i2.js.br +0 -0
  34. package/dist/public/assets/{_B62GzYdT.js → _BHjcfVXB.js} +6 -6
  35. package/dist/public/assets/_BHjcfVXB.js.br +0 -0
  36. package/dist/public/assets/{_DMHXo8uX.js → _BNIilCsC.js} +1 -1
  37. package/dist/public/assets/_BNIilCsC.js.br +0 -0
  38. package/dist/public/assets/{_szXVEFuj2.js → _BPgL37mi2.js} +1 -1
  39. package/dist/public/assets/_BPgL37mi2.js.br +0 -0
  40. package/dist/public/assets/{_Ctuh4IDL2.js → _BR3D4nzj2.js} +1 -1
  41. package/dist/public/assets/_BR3D4nzj2.js.br +0 -0
  42. package/dist/public/assets/{_BkTJUq-u.js → _BRSJwfZ3.js} +1 -1
  43. package/dist/public/assets/{_DS628Dnv.js → _BSFENb4l.js} +1 -1
  44. package/dist/public/assets/_BSFENb4l.js.br +0 -0
  45. package/dist/public/assets/{_Btt4k5w3.js → _BSWpz0dK.js} +1 -1
  46. package/dist/public/assets/_BSWpz0dK.js.br +0 -0
  47. package/dist/public/assets/{_N0vzdKCD.js → _BUOWar32.js} +1 -1
  48. package/dist/public/assets/_BUOWar32.js.br +0 -0
  49. package/dist/public/assets/{_1bmjwuWH.js → _BZWlvMSJ.js} +1 -1
  50. package/dist/public/assets/_BZWlvMSJ.js.br +0 -0
  51. package/dist/public/assets/{_BlVrxgss2.js → _B_dMglA92.js} +1 -1
  52. package/dist/public/assets/_B_dMglA92.js.br +0 -0
  53. package/dist/public/assets/{_XgOGaUKD.js → _Beb6Lb1w.js} +1 -1
  54. package/dist/public/assets/_Beb6Lb1w.js.br +0 -0
  55. package/dist/public/assets/{_CXQ_K2Vh2.js → _BjIMoOV-2.js} +1 -1
  56. package/dist/public/assets/_BjIMoOV-2.js.br +0 -0
  57. package/dist/public/assets/{_Dhu480me.js → _BlLuOKRT.js} +1 -1
  58. package/dist/public/assets/_BlLuOKRT.js.br +0 -0
  59. package/dist/public/assets/{_BijwdXvp.js → _BlXwius8.js} +1 -1
  60. package/dist/public/assets/_BlXwius8.js.br +0 -0
  61. package/dist/public/assets/{_DBJ43cPF.js → _Bmt76eWT.js} +1 -1
  62. package/dist/public/assets/_Bmt76eWT.js.br +0 -0
  63. package/dist/public/assets/{_CkuVE2zK.js → _BoS5LU5l.js} +1 -1
  64. package/dist/public/assets/_BoS5LU5l.js.br +0 -0
  65. package/dist/public/assets/{_DtLQVzKB2.js → _BsNKabYu2.js} +1 -1
  66. package/dist/public/assets/_BsNKabYu2.js.br +0 -0
  67. package/dist/public/assets/{_CEWjw4lq.js → _C0OXw3Vy.js} +1 -1
  68. package/dist/public/assets/_C0OXw3Vy.js.br +0 -0
  69. package/dist/public/assets/{_CpRKjPVO2.js → _CBUJNdeq2.js} +1 -1
  70. package/dist/public/assets/_CBUJNdeq2.js.br +0 -0
  71. package/dist/public/assets/{_CZzZOY4P2.js → _CCNhM32c2.js} +1 -1
  72. package/dist/public/assets/_CCNhM32c2.js.br +0 -0
  73. package/dist/public/assets/{_BgnvPND_.js → _CFBaZNUT.js} +1 -1
  74. package/dist/public/assets/_CFBaZNUT.js.br +0 -0
  75. package/dist/public/assets/{_CJT7ujcv2.js → _CJHqzl--2.js} +1 -1
  76. package/dist/public/assets/_CJHqzl--2.js.br +0 -0
  77. package/dist/public/assets/{_duTaTgEk2.js → _CMGgQ-4H2.js} +1 -1
  78. package/dist/public/assets/_CMGgQ-4H2.js.br +0 -0
  79. package/dist/public/assets/{_5YlmpA6X.js → _CPX0D-my.js} +1 -1
  80. package/dist/public/assets/_CPX0D-my.js.br +0 -0
  81. package/dist/public/assets/{_BYYDEuFR2.js → _CRuHDn_O2.js} +1 -1
  82. package/dist/public/assets/_CRuHDn_O2.js.br +0 -0
  83. package/dist/public/assets/{_CnKbN1QV.js → _CUOao_Hj.js} +1 -1
  84. package/dist/public/assets/_CUOao_Hj.js.br +0 -0
  85. package/dist/public/assets/{_CZejW_fq.js → _CUVw5V4z.js} +1 -1
  86. package/dist/public/assets/_CUVw5V4z.js.br +0 -0
  87. package/dist/public/assets/{_DO4n-NwM.js → _CYMGOhCb.js} +1 -1
  88. package/dist/public/assets/_CYMGOhCb.js.br +0 -0
  89. package/dist/public/assets/{_ER-_pvil.js → _CZfap_Dj.js} +1 -1
  90. package/dist/public/assets/_CZfap_Dj.js.br +0 -0
  91. package/dist/public/assets/{_DYngS949.js → _CbNmSe5k.js} +1 -1
  92. package/dist/public/assets/_CbNmSe5k.js.br +0 -0
  93. package/dist/public/assets/{_DKciAPQl.js → _CdtuhZSc.js} +1 -1
  94. package/dist/public/assets/_CdtuhZSc.js.br +0 -0
  95. package/dist/public/assets/{_B7w9Jp5h.js → _ClhApln3.js} +1 -1
  96. package/dist/public/assets/_ClhApln3.js.br +0 -0
  97. package/dist/public/assets/{_BD9TBsnM2.js → _CvA5IOAW2.js} +1 -1
  98. package/dist/public/assets/_CvA5IOAW2.js.br +0 -0
  99. package/dist/public/assets/{_Cp-yobQE2.js → _CwZYmUdt2.js} +1 -1
  100. package/dist/public/assets/{_MofKWwY9.js → _CxrcWZPE.js} +1 -1
  101. package/dist/public/assets/_CxrcWZPE.js.br +0 -0
  102. package/dist/public/assets/{_RhYe7Uqa.js → _D-KjGTSW.js} +24 -21
  103. package/dist/public/assets/_D-KjGTSW.js.br +0 -0
  104. package/dist/public/assets/{_BRiOyxeF.js → _D4u4SpiK.js} +1 -1
  105. package/dist/public/assets/_D4u4SpiK.js.br +0 -0
  106. package/dist/public/assets/{_D8raMSvy.js → _D6CjH3JN.js} +1 -1
  107. package/dist/public/assets/_D6CjH3JN.js.br +0 -0
  108. package/dist/public/assets/{_Cym2lUeD2.js → _D7cQLLGK2.js} +1 -1
  109. package/dist/public/assets/_D7cQLLGK2.js.br +0 -0
  110. package/dist/public/assets/{_C8DWM9rr.js → _DBoABI85.js} +1 -1
  111. package/dist/public/assets/_DBoABI85.js.br +0 -0
  112. package/dist/public/assets/{_Rf_7ZEPL.js → _DNcd1xtp.js} +1 -1
  113. package/dist/public/assets/_DNcd1xtp.js.br +0 -0
  114. package/dist/public/assets/{_Bhwci4Zy.js → _DQGTProt.js} +1 -1
  115. package/dist/public/assets/_DQGTProt.js.br +0 -0
  116. package/dist/public/assets/{_B2g0gJmZ.js → _DSIH6_Li.js} +1 -1
  117. package/dist/public/assets/_DSIH6_Li.js.br +0 -0
  118. package/dist/public/assets/{_T2999saD2.js → _DXHf4WHs2.js} +1 -1
  119. package/dist/public/assets/_DXHf4WHs2.js.br +0 -0
  120. package/dist/public/assets/{_CoStmjbL.js → _D_qDhyn2.js} +1 -1
  121. package/dist/public/assets/_D_qDhyn2.js.br +0 -0
  122. package/dist/public/assets/{_CGOYaUuV.js → _DcMfCkmd.js} +1 -1
  123. package/dist/public/assets/_DcMfCkmd.js.br +0 -0
  124. package/dist/public/assets/{_Cv9Hgy8P.js → _DgDEdxEu.js} +1 -1
  125. package/dist/public/assets/_DgDEdxEu.js.br +0 -0
  126. package/dist/public/assets/{_Bsk9ewWF.js → _DlhDim1D.js} +1 -1
  127. package/dist/public/assets/_DlhDim1D.js.br +0 -0
  128. package/dist/public/assets/{_BNQgBkwF.js → _DqLib98x.js} +1 -1
  129. package/dist/public/assets/_DqLib98x.js.br +0 -0
  130. package/dist/public/assets/{_l9vJoVeP.js → _Ds0Qa0YX.js} +1 -1
  131. package/dist/public/assets/_Ds0Qa0YX.js.br +0 -0
  132. package/dist/public/assets/{_iDn41XPe2.js → _DyUvL09k2.js} +1 -1
  133. package/dist/public/assets/_DyUvL09k2.js.br +0 -0
  134. package/dist/public/assets/{_AqmX0pBH2.js → _J6xlcydH2.js} +1 -1
  135. package/dist/public/assets/_J6xlcydH2.js.br +0 -0
  136. package/dist/public/assets/{_Dni1s_3Q2.js → _MInulccf2.js} +1 -1
  137. package/dist/public/assets/{_BsOtzbIC.js → _QMVFINxi.js} +1 -1
  138. package/dist/public/assets/_QMVFINxi.js.br +0 -0
  139. package/dist/public/assets/{_CWNv7bdi.js → _S9QQtt68.js} +1 -1
  140. package/dist/public/assets/_S9QQtt68.js.br +0 -0
  141. package/dist/public/assets/{_DlZyjzE_.js → _XCJLLGxV.js} +1 -1
  142. package/dist/public/assets/_XCJLLGxV.js.br +0 -0
  143. package/dist/public/assets/{_B2LXCPdE.js → _Y-bTh0dW.js} +1 -1
  144. package/dist/public/assets/_Y-bTh0dW.js.br +0 -0
  145. package/dist/public/assets/{_zPAwS_-H.js → _Y8TOk1pR.js} +1 -1
  146. package/dist/public/assets/_Y8TOk1pR.js.br +0 -0
  147. package/dist/public/assets/{_CzI2QhH4.js → _YetFhXkM.js} +1 -1
  148. package/dist/public/assets/_YetFhXkM.js.br +0 -0
  149. package/dist/public/assets/{_O8R4l7yw2.js → _YmJopjX92.js} +1 -1
  150. package/dist/public/assets/_YmJopjX92.js.br +0 -0
  151. package/dist/public/assets/{_Du5pjG7G.js → _mbzaOqik.js} +1 -1
  152. package/dist/public/assets/_mbzaOqik.js.br +0 -0
  153. package/dist/public/assets/{_qj_i_cqe.js → _rMcfapaa.js} +1 -1
  154. package/dist/public/assets/_rMcfapaa.js.br +0 -0
  155. package/dist/public/assets/{_yTOoKAfM2.js → _rRtbbfKJ2.js} +1 -1
  156. package/dist/public/assets/_rRtbbfKJ2.js.br +0 -0
  157. package/dist/public/assets/{_BD68UZIf.js → _sSWgnHeU.js} +1 -1
  158. package/dist/public/assets/_sSWgnHeU.js.br +0 -0
  159. package/dist/public/assets/{_CegJB2vl.js → _xOeYy_3v.js} +1 -1
  160. package/dist/public/assets/_xOeYy_3v.js.br +0 -0
  161. package/dist/public/assets/app-page-BWf0w6W9.css +1 -0
  162. package/dist/public/assets/app-page-BWf0w6W9.css.br +0 -0
  163. package/dist/public/assets/diff-view-BZaYYEDK.css +1 -0
  164. package/dist/public/assets/diff-view-BZaYYEDK.css.br +0 -0
  165. package/dist/public/assets/new-K-7z0qE8.js +1 -0
  166. package/dist/public/assets/settings-K-7z0qE8.js +1 -0
  167. package/dist/public/assets/w-K-7z0qE8.js +1 -0
  168. package/dist/public/assets/w._-D5d4klUv.js +1 -0
  169. package/dist/public/assets/w._-K-7z0qE8.js +1 -0
  170. package/lib/review/markdown.mjs +6 -2
  171. package/lib/setup/catalog.d.mts +3 -1
  172. package/lib/setup/catalog.mjs +11 -2
  173. package/lib/setup/doctor.mjs +96 -20
  174. package/lib/setup/install.d.mts +9 -3
  175. package/lib/setup/install.mjs +68 -39
  176. package/lib/share/manifest.d.mts +27 -0
  177. package/lib/share/manifest.mjs +144 -0
  178. package/package.json +6 -2
  179. package/scripts/migrate.mjs +101 -30
  180. package/dist/_BIDn_RW1.js +0 -2
  181. package/dist/public/assets/_1bmjwuWH.js.br +0 -0
  182. package/dist/public/assets/_5YlmpA6X.js.br +0 -0
  183. package/dist/public/assets/_AqmX0pBH2.js.br +0 -0
  184. package/dist/public/assets/_B2LXCPdE.js.br +0 -0
  185. package/dist/public/assets/_B2g0gJmZ.js.br +0 -0
  186. package/dist/public/assets/_B62GzYdT.js.br +0 -0
  187. package/dist/public/assets/_B7w9Jp5h.js.br +0 -0
  188. package/dist/public/assets/_BD68UZIf.js.br +0 -0
  189. package/dist/public/assets/_BD9TBsnM2.js.br +0 -0
  190. package/dist/public/assets/_BE3wNPJo.js.br +0 -0
  191. package/dist/public/assets/_BNQgBkwF.js.br +0 -0
  192. package/dist/public/assets/_BRiOyxeF.js.br +0 -0
  193. package/dist/public/assets/_BSsp7Z4i2.js.br +0 -0
  194. package/dist/public/assets/_BYYDEuFR2.js.br +0 -0
  195. package/dist/public/assets/_BgnvPND_.js.br +0 -0
  196. package/dist/public/assets/_Bhwci4Zy.js.br +0 -0
  197. package/dist/public/assets/_BijwdXvp.js.br +0 -0
  198. package/dist/public/assets/_BlVrxgss2.js.br +0 -0
  199. package/dist/public/assets/_Bp1b0XWB2.js.br +0 -0
  200. package/dist/public/assets/_Bq2fn_8D.js.br +0 -0
  201. package/dist/public/assets/_BsOtzbIC.js.br +0 -0
  202. package/dist/public/assets/_Bsk9ewWF.js.br +0 -0
  203. package/dist/public/assets/_Btt4k5w3.js.br +0 -0
  204. package/dist/public/assets/_C8DWM9rr.js.br +0 -0
  205. package/dist/public/assets/_CEWjw4lq.js.br +0 -0
  206. package/dist/public/assets/_CGOYaUuV.js.br +0 -0
  207. package/dist/public/assets/_CIFN7c1t2.js.br +0 -0
  208. package/dist/public/assets/_CJT7ujcv2.js.br +0 -0
  209. package/dist/public/assets/_CUjnrp_u2.js.br +0 -0
  210. package/dist/public/assets/_CWNv7bdi.js.br +0 -0
  211. package/dist/public/assets/_CXQ_K2Vh2.js.br +0 -0
  212. package/dist/public/assets/_CZejW_fq.js.br +0 -0
  213. package/dist/public/assets/_CZzZOY4P2.js.br +0 -0
  214. package/dist/public/assets/_CegJB2vl.js.br +0 -0
  215. package/dist/public/assets/_CkuVE2zK.js.br +0 -0
  216. package/dist/public/assets/_CnKbN1QV.js.br +0 -0
  217. package/dist/public/assets/_CoStmjbL.js.br +0 -0
  218. package/dist/public/assets/_CpRKjPVO2.js.br +0 -0
  219. package/dist/public/assets/_CqDb111T.js.br +0 -0
  220. package/dist/public/assets/_Ctuh4IDL2.js.br +0 -0
  221. package/dist/public/assets/_Cv9Hgy8P.js.br +0 -0
  222. package/dist/public/assets/_CvnxxlPb.js.br +0 -0
  223. package/dist/public/assets/_Cym2lUeD2.js.br +0 -0
  224. package/dist/public/assets/_CzI2QhH4.js.br +0 -0
  225. package/dist/public/assets/_D8raMSvy.js.br +0 -0
  226. package/dist/public/assets/_DBJ43cPF.js.br +0 -0
  227. package/dist/public/assets/_DKciAPQl.js.br +0 -0
  228. package/dist/public/assets/_DMHXo8uX.js.br +0 -0
  229. package/dist/public/assets/_DO4n-NwM.js.br +0 -0
  230. package/dist/public/assets/_DS628Dnv.js.br +0 -0
  231. package/dist/public/assets/_DYngS949.js.br +0 -0
  232. package/dist/public/assets/_Dhu480me.js.br +0 -0
  233. package/dist/public/assets/_DlZyjzE_.js.br +0 -0
  234. package/dist/public/assets/_DtLQVzKB2.js.br +0 -0
  235. package/dist/public/assets/_Du5pjG7G.js.br +0 -0
  236. package/dist/public/assets/_Dzy9r1lm.js.br +0 -0
  237. package/dist/public/assets/_ER-_pvil.js.br +0 -0
  238. package/dist/public/assets/_LUNsNRnJ2.js.br +0 -0
  239. package/dist/public/assets/_MofKWwY9.js.br +0 -0
  240. package/dist/public/assets/_N0vzdKCD.js.br +0 -0
  241. package/dist/public/assets/_O8R4l7yw2.js.br +0 -0
  242. package/dist/public/assets/_Rf_7ZEPL.js.br +0 -0
  243. package/dist/public/assets/_RhYe7Uqa.js.br +0 -0
  244. package/dist/public/assets/_T2999saD2.js.br +0 -0
  245. package/dist/public/assets/_XgOGaUKD.js.br +0 -0
  246. package/dist/public/assets/_c1YZLlfE.js.br +0 -0
  247. package/dist/public/assets/_duTaTgEk2.js.br +0 -0
  248. package/dist/public/assets/_iDn41XPe2.js.br +0 -0
  249. package/dist/public/assets/_l9vJoVeP.js.br +0 -0
  250. package/dist/public/assets/_qj_i_cqe.js.br +0 -0
  251. package/dist/public/assets/_szXVEFuj2.js.br +0 -0
  252. package/dist/public/assets/_yTOoKAfM2.js.br +0 -0
  253. package/dist/public/assets/_zPAwS_-H.js.br +0 -0
  254. package/dist/public/assets/app-page-BXbewHHv.css +0 -1
  255. package/dist/public/assets/app-page-BXbewHHv.css.br +0 -0
  256. package/dist/public/assets/diff-view-BJ1RAVYB.css +0 -1
  257. package/dist/public/assets/diff-view-BJ1RAVYB.css.br +0 -0
  258. package/dist/public/assets/new-DUXTU7jB.js +0 -1
  259. package/dist/public/assets/settings-DUXTU7jB.js +0 -1
  260. package/dist/public/assets/w-DUXTU7jB.js +0 -1
  261. package/dist/public/assets/w._-DUXTU7jB.js +0 -1
  262. package/dist/public/assets/w._-bwrmfhu0.js +0 -1
@@ -0,0 +1,1739 @@
1
+ import { $ as setSetting, V as publish, Y as getSetting, _t as git, bt as config, c as killWorkspaceSessions, dt as all, ft as db, gt as gh, i as disposeSessions, l as listSessions, nt as ghKnownHosts, ot as DEFAULT_GITHUB_HOST, p as panePids, pt as get, vt as run } from "./_Cr71Xd0d.js";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { AsyncLocalStorage } from "node:async_hooks";
5
+ import os from "node:os";
6
+ import fs$1 from "node:fs/promises";
7
+ import crypto from "node:crypto";
8
+ //#region src/server/branches.ts
9
+ /** The one branch-prefix rule: `ref` is this workspace's branch or one of
10
+ * its `-topic` lanes. Another workspace's branch can extend this prefix
11
+ * (slugs are user-chosen), so prefix matching must also subtract
12
+ * `otherWorkspaceBranches` — every caller of this predicate does. */
13
+ /** Is `ref` the workspace branch named by `prefix`, or a `-topic` lane of
14
+ * it? Lives alone in its own module: both `workspaces.ts` and `prs.ts` need
15
+ * it, and either importing the other would drag a heavy import graph along. */
16
+ function ownsBranch(ref, prefix) {
17
+ return ref === prefix || ref.startsWith(`${prefix}-`);
18
+ }
19
+ //#endregion
20
+ //#region src/server/github-hosts.ts
21
+ /** GitHub hosts: github.com plus any Enterprise servers the user added or
22
+ * signed `gh` in to. A repo's full name carries its host as a leading
23
+ * segment (`ghe.corp.example/owner/name`) and omits it for github.com, so
24
+ * every existing `owner/name` row, cache dir, and picker entry keeps its
25
+ * meaning — the same shape `gh repo clone HOST/OWNER/REPO` accepts. */
26
+ var DEFAULT_HOST = DEFAULT_GITHUB_HOST;
27
+ /** `[host/]owner/name` → parts. Anything without a dot in its first
28
+ * segment is an owner, not a host. */
29
+ function splitRepo(fullName) {
30
+ const parts = fullName.split("/");
31
+ if (parts.length >= 3 && parts[0].includes(".")) return {
32
+ host: parts[0],
33
+ name: parts.slice(1).join("/")
34
+ };
35
+ return {
36
+ host: DEFAULT_HOST,
37
+ name: fullName
38
+ };
39
+ }
40
+ function joinRepo(host, name) {
41
+ return host === DEFAULT_HOST ? name : `${host}/${name}`;
42
+ }
43
+ function repoWebUrl(fullName) {
44
+ const { host, name } = splitRepo(fullName);
45
+ return `https://${host}/${name}`;
46
+ }
47
+ function cloneUrl(fullName) {
48
+ return `${repoWebUrl(fullName)}.git`;
49
+ }
50
+ /** Enterprise hosts the user added in Settings (persisted). */
51
+ function addedHosts() {
52
+ try {
53
+ return JSON.parse(getSetting("github.hosts") || "[]");
54
+ } catch {
55
+ return [];
56
+ }
57
+ }
58
+ /** Every host in play: github.com, added ones, and any gh already knows. */
59
+ function configuredHosts() {
60
+ return [.../* @__PURE__ */ new Set([
61
+ DEFAULT_HOST,
62
+ ...addedHosts(),
63
+ ...ghKnownHosts()
64
+ ])];
65
+ }
66
+ function isValidHost(host) {
67
+ return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(host);
68
+ }
69
+ /** `https://ghe.corp/anything/` → `ghe.corp`: people paste URLs. */
70
+ function normalizeHost(input) {
71
+ return input.trim().toLowerCase().replace(/^[a-z]+:\/\//, "").replace(/[/?#].*$/, "").replace(/:\d+$/, "");
72
+ }
73
+ function addHost(host) {
74
+ const h = normalizeHost(host);
75
+ if (!isValidHost(h)) throw new Error(`invalid host: ${host}`);
76
+ if (h !== DEFAULT_HOST) setSetting("github.hosts", JSON.stringify([.../* @__PURE__ */ new Set([...addedHosts(), h])]));
77
+ return h;
78
+ }
79
+ function removeHost(host) {
80
+ const h = normalizeHost(host);
81
+ setSetting("github.hosts", JSON.stringify(addedHosts().filter((x) => x !== h)));
82
+ }
83
+ /** Point git's credential helper for `host` at gh (`gh auth setup-git`),
84
+ * so clones/pushes over https use the token gh holds. Idempotent and
85
+ * cheap; without it git prompts "Username for https://<host>" on a pipe
86
+ * and the clone hangs. */
87
+ async function ensureGitCredentials(host) {
88
+ await run("gh", [
89
+ "auth",
90
+ "setup-git",
91
+ "--hostname",
92
+ host
93
+ ]).catch(() => {});
94
+ }
95
+ function graphqlUrl(host) {
96
+ return host === DEFAULT_HOST ? "https://api.github.com/graphql" : `https://${host}/api/graphql`;
97
+ }
98
+ var tokens = /* @__PURE__ */ new Map();
99
+ /** The token `gh` holds for a host, read once per process per host.
100
+ * Environment first (`GH_TOKEN` for github.com, `GH_ENTERPRISE_TOKEN` for
101
+ * anything else — gh's own convention) so a CI-style token works without
102
+ * `gh` being logged in at all. */
103
+ function hostToken(host) {
104
+ let p = tokens.get(host);
105
+ if (!p) {
106
+ p = (async () => {
107
+ const fromEnv = host === DEFAULT_HOST ? process.env.GH_TOKEN || process.env.GITHUB_TOKEN : process.env.GH_ENTERPRISE_TOKEN || process.env.GITHUB_ENTERPRISE_TOKEN;
108
+ if (fromEnv) return fromEnv;
109
+ return run("gh", [
110
+ "auth",
111
+ "token",
112
+ "--hostname",
113
+ host
114
+ ]);
115
+ })().catch((err) => {
116
+ tokens.delete(host);
117
+ throw err;
118
+ });
119
+ tokens.set(host, p);
120
+ }
121
+ return p;
122
+ }
123
+ /** Forget a cached token (401/403 — rotated under us, or just signed in). */
124
+ function forgetToken(host) {
125
+ tokens.delete(host);
126
+ }
127
+ //#endregion
128
+ //#region src/server/cache.ts
129
+ /** Keyed stale-while-revalidate cache: a cached value is served immediately
130
+ * (revalidating in the background when older than `ttlMs`); loads are deduped
131
+ * per key, and `refresh` bypasses both the cache and any in-flight load. */
132
+ function swr(ttlMs, load, seed) {
133
+ const cache = /* @__PURE__ */ new Map();
134
+ const inflight = /* @__PURE__ */ new Map();
135
+ function start(key, refresh) {
136
+ const startedAt = Date.now();
137
+ const promise = load(key, refresh).then((value) => {
138
+ const existing = cache.get(key);
139
+ if (!existing || existing.at <= startedAt) cache.set(key, {
140
+ at: Date.now(),
141
+ value
142
+ });
143
+ return value;
144
+ }).finally(() => {
145
+ if (inflight.get(key)?.promise === promise) inflight.delete(key);
146
+ });
147
+ inflight.set(key, {
148
+ promise,
149
+ refresh
150
+ });
151
+ return promise;
152
+ }
153
+ const read = (key, refresh = false) => {
154
+ if (refresh) {
155
+ const current = inflight.get(key);
156
+ return current?.refresh ? current.promise : start(key, true);
157
+ }
158
+ let cached = cache.get(key);
159
+ if (!cached && seed) {
160
+ cached = seed(key);
161
+ if (cached) cache.set(key, cached);
162
+ }
163
+ if (cached) {
164
+ if (Date.now() - cached.at >= ttlMs && !inflight.has(key)) start(key, false).catch(() => {});
165
+ return Promise.resolve(cached.value);
166
+ }
167
+ return inflight.get(key)?.promise ?? start(key, false);
168
+ };
169
+ /** Inject an externally fetched value (e.g. from a batched load). */
170
+ read.set = (key, value) => {
171
+ cache.set(key, {
172
+ at: Date.now(),
173
+ value
174
+ });
175
+ };
176
+ /** Current cached value, if any — used when a batch update must merge
177
+ * with a richer per-key load rather than replace it. */
178
+ read.get = (key) => cache.get(key)?.value;
179
+ return read;
180
+ }
181
+ /** Serialize async work per key: calls for one key run in submission order,
182
+ * each starting only after the previous settled. The stored tail is the
183
+ * all-settled form, so a rejection neither blocks the next caller nor leaves
184
+ * the entry behind — the map holds only keys with work still in flight. */
185
+ var locks = /* @__PURE__ */ new Map();
186
+ function withLock(key, fn) {
187
+ const run = (locks.get(key) ?? Promise.resolve()).then(fn);
188
+ const tail = run.then(() => {}, () => {});
189
+ locks.set(key, tail);
190
+ tail.then(() => {
191
+ if (locks.get(key) === tail) locks.delete(key);
192
+ });
193
+ return run;
194
+ }
195
+ //#endregion
196
+ //#region src/server/github.ts
197
+ var TTL_MS = 9e5;
198
+ /** github.com keeps the historical file name; Enterprise hosts get their own. */
199
+ var cachePath = (host) => path.join(config.dataDir, host === DEFAULT_HOST ? "repos.json" : `repos.${host}.json`);
200
+ /**
201
+ * User's repos plus every org's on every configured host, merged and cached
202
+ * (in memory, persisted to disk across restarts). Cached results are served
203
+ * immediately no matter their age, with a background revalidate when stale
204
+ * — only an explicit refresh or an empty cache blocks. Hosts are
205
+ * independent: a signed-out Enterprise server costs its own repos, never
206
+ * github.com's.
207
+ */
208
+ async function repoCatalog(refresh = false) {
209
+ const hosts = configuredHosts();
210
+ const [parts, ssoParts] = await Promise.all([Promise.all(hosts.map((host) => catalog(host, refresh).catch(() => {
211
+ if (host === DEFAULT_HOST && refresh) throw new Error(`gh repo listing failed`);
212
+ return catalog.get(host) ?? null;
213
+ }))), Promise.all(hosts.map((host) => pendingSsoOrgs(host, refresh).catch(() => [])))]);
214
+ const repos = parts.flatMap((c) => c?.repos ?? []);
215
+ const sso = dedupeSso(ssoParts.flat());
216
+ return {
217
+ fetchedAt: Math.min(...parts.map((c) => c?.fetchedAt ?? Date.now())),
218
+ repos: repos.sort((a, b) => a.nameWithOwner.localeCompare(b.nameWithOwner)),
219
+ ...sso.length ? { sso } : {}
220
+ };
221
+ }
222
+ var catalog = swr(TTL_MS, fetchCatalog, readDiskCache);
223
+ function readDiskCache(host) {
224
+ try {
225
+ const cached = JSON.parse(fs.readFileSync(cachePath(host), "utf8"));
226
+ if (!Array.isArray(cached?.repos)) return void 0;
227
+ for (const r of cached.repos) r.host ||= host;
228
+ return {
229
+ at: cached.fetchedAt,
230
+ value: cached
231
+ };
232
+ } catch {
233
+ return;
234
+ }
235
+ }
236
+ var LIST_FIELDS = "nameWithOwner,defaultBranchRef,isArchived,parent";
237
+ var LIST_FIELDS_NO_PARENT = "nameWithOwner,defaultBranchRef,isArchived";
238
+ /** Orgs that still need SAML SSO for this host's gh token. Memberships
239
+ * list them even when unauthorized; probing `orgs/<login>` yields the
240
+ * authorize URL via `X-GitHub-SSO`. Authorize URLs live ~1 h; both
241
+ * authorize flows pass `refresh=true`, so a 15 min TTL is enough and
242
+ * keeps the status poll from re-probing every minute. */
243
+ var ssoCache = swr(9e5, (host) => probePendingSso(host));
244
+ function pendingSsoOrgs(host, refresh = false) {
245
+ return ssoCache(host, refresh);
246
+ }
247
+ async function probePendingSso(host) {
248
+ const logins = (await gh([
249
+ "api",
250
+ "user/memberships/orgs",
251
+ "--jq",
252
+ ".[].organization.login"
253
+ ], host).catch(() => "")).split("\n").filter(Boolean);
254
+ return (await Promise.all(logins.map(async (org) => {
255
+ const { out } = await ghCapture([
256
+ "api",
257
+ `orgs/${org}`,
258
+ "--include"
259
+ ], host);
260
+ return parseSsoRequired(out, org, host);
261
+ }))).filter((x) => !!x);
262
+ }
263
+ /** Run gh and keep stdout/stderr even when it exits non-zero — needed for
264
+ * `gh api --include` SAML 403s that put `X-GitHub-SSO` on stdout. */
265
+ async function ghCapture(args, host) {
266
+ try {
267
+ return {
268
+ ok: true,
269
+ out: await gh(args, host)
270
+ };
271
+ } catch (err) {
272
+ const e = err;
273
+ return {
274
+ ok: false,
275
+ out: [
276
+ e.stdout,
277
+ e.stderr,
278
+ e.message
279
+ ].filter(Boolean).join("\n")
280
+ };
281
+ }
282
+ }
283
+ /** Pull org + authorize URL from an `X-GitHub-SSO` header or error body. */
284
+ function parseSsoRequired(text, fallbackOrg, host) {
285
+ const header = /^x-github-sso:\s*required;\s*url=(\S+)/im.exec(text);
286
+ const fromBody = /https:\/\/[^\s"'<>]+\/orgs\/[^/\s"'<>]+\/sso\?authorization_request=[^\s"'<>]+/i.exec(text)?.[0];
287
+ const raw = header?.[1] || fromBody;
288
+ if (!raw) return null;
289
+ const authorizeUrl = raw.replace(/[.,);\]}>]+$/g, "");
290
+ const org = /\/orgs\/([^/]+)\/sso/i.exec(authorizeUrl)?.[1] || fallbackOrg;
291
+ if (!org) return null;
292
+ return {
293
+ org,
294
+ host,
295
+ authorizeUrl
296
+ };
297
+ }
298
+ function dedupeSso(items) {
299
+ const byOrg = /* @__PURE__ */ new Map();
300
+ for (const item of items) byOrg.set(item.org, item);
301
+ return [...byOrg.values()].sort((a, b) => a.org.localeCompare(b.org));
302
+ }
303
+ /** One owner's repos. Prefer including `parent`; when a fork's upstream is
304
+ * behind org SAML that the OAuth token has not authorized, GraphQL fails
305
+ * the *whole* list — retry without `parent` rather than treating the
306
+ * owner as unlistable (which would fail an explicit catalog refresh). */
307
+ async function listOwnerRepos(host, owner) {
308
+ const args = (fields) => [
309
+ "repo",
310
+ "list",
311
+ ...owner ? [owner] : [],
312
+ "--limit",
313
+ "500",
314
+ "--json",
315
+ fields
316
+ ];
317
+ try {
318
+ const out = await gh(args(LIST_FIELDS), host);
319
+ return {
320
+ repos: JSON.parse(out),
321
+ parents: true,
322
+ partial: false
323
+ };
324
+ } catch (err) {
325
+ const msg = err instanceof Error ? err.message : String(err);
326
+ if (/SAML enforcement|protected by organization/i.test(msg)) try {
327
+ const out = await gh(args(LIST_FIELDS_NO_PARENT), host);
328
+ return {
329
+ repos: JSON.parse(out),
330
+ parents: false,
331
+ partial: false
332
+ };
333
+ } catch {}
334
+ return {
335
+ repos: [],
336
+ parents: true,
337
+ partial: true
338
+ };
339
+ }
340
+ }
341
+ async function fetchCatalog(host) {
342
+ let partial = false;
343
+ const orgs = (await gh([
344
+ "api",
345
+ "user/orgs",
346
+ "--jq",
347
+ ".[].login"
348
+ ], host).catch(() => {
349
+ partial = true;
350
+ return "";
351
+ })).split("\n").filter(Boolean);
352
+ const lists = await Promise.all([void 0, ...orgs].map(async (owner) => {
353
+ const listed = await listOwnerRepos(host, owner);
354
+ if (listed.partial) partial = true;
355
+ return listed;
356
+ }));
357
+ const byName = /* @__PURE__ */ new Map();
358
+ for (const { repos: rows, parents } of lists) for (const r of rows) byName.set(r.nameWithOwner, {
359
+ nameWithOwner: joinRepo(host, r.nameWithOwner),
360
+ host,
361
+ defaultBranch: r.defaultBranchRef?.name || "main",
362
+ isArchived: !!r.isArchived,
363
+ parent: !parents ? void 0 : r.parent ? joinRepo(host, `${r.parent.owner.login}/${r.parent.name}`) : null
364
+ });
365
+ const repos = [...byName.values()].sort((a, b) => a.nameWithOwner.localeCompare(b.nameWithOwner));
366
+ const catalog = {
367
+ fetchedAt: Date.now(),
368
+ repos
369
+ };
370
+ if (partial) {
371
+ if (readDiskCache(host)) throw new Error("gh repo listing failed");
372
+ return catalog;
373
+ }
374
+ const tmp = cachePath(host) + ".tmp";
375
+ fs.writeFileSync(tmp, JSON.stringify(catalog));
376
+ fs.renameSync(tmp, cachePath(host));
377
+ return catalog;
378
+ }
379
+ /** Fork `fullName` under the signed-in user and return the fork's
380
+ * `[host/]owner/name`. Idempotent: GitHub answers the POST with the
381
+ * existing fork if there is one. Fork creation is async on GitHub's side,
382
+ * so wait until the fork actually has branches — cloning too early gets a
383
+ * repo with no refs and the worktree add fails on a missing base. An
384
+ * empty *upstream* also has no branches; the timeout lets that case fall
385
+ * through to the clone rather than erroring here. */
386
+ async function ensureFork(fullName) {
387
+ const { host, name } = splitRepo(fullName);
388
+ const out = await gh([
389
+ "api",
390
+ "-X",
391
+ "POST",
392
+ `repos/${name}/forks`,
393
+ "--jq",
394
+ ".full_name"
395
+ ], host);
396
+ const fork = out.trim();
397
+ if (!/^[\w.-]+\/[\w.-]+$/.test(fork)) throw new Error(`forking ${fullName} failed: ${out || "no response"}`);
398
+ const forkFull = joinRepo(host, fork);
399
+ for (let tries = 0; tries < 15; tries++) {
400
+ if (await forkHasRefs(forkFull, host, fork)) break;
401
+ if (tries < 14) await new Promise((r) => setTimeout(r, 2e3));
402
+ }
403
+ return forkFull;
404
+ }
405
+ async function forkHasRefs(forkFull, host, fork) {
406
+ try {
407
+ await ensureGitCredentials(host);
408
+ if ((await git([
409
+ "ls-remote",
410
+ "--heads",
411
+ cloneUrl(forkFull)
412
+ ])).split("\n").some((l) => l.includes(" refs/heads/"))) return true;
413
+ } catch {}
414
+ const count = await gh([
415
+ "api",
416
+ `repos/${fork}/branches?per_page=1`,
417
+ "--jq",
418
+ "length"
419
+ ], host).catch(() => "");
420
+ return !!count && count !== "0";
421
+ }
422
+ /** Branch names for a repo (for base-branch selection). `refresh` bypasses
423
+ * the cache, for the picker's refresh button — a branch pushed a moment ago
424
+ * must not wait out the TTL. */
425
+ var repoBranches = (fullName, refresh = false) => branches(fullName, refresh);
426
+ /** Upstream `[host/]owner/name` when the repo is a fork, else null. A PR
427
+ * opened from a fork lives in the upstream repo, so PR search needs this to
428
+ * know where else to look. The catalog answers from disk for listed repos
429
+ * (the status batch asks for every active repo at once, and must not fan
430
+ * out into per-repo REST calls on a cold start); the per-repo lookup covers
431
+ * repos outside it. Failures resolve to null (and age out with the TTL) —
432
+ * a blip here must cost fork PRs, not the whole status tick. */
433
+ async function repoParent(fullName) {
434
+ const hit = (await repoCatalog().catch(() => null))?.repos.find((r) => r.nameWithOwner === fullName);
435
+ if (hit && hit.parent !== void 0) return hit.parent;
436
+ return (await repoMeta(fullName)).parent;
437
+ }
438
+ /** The repo's default branch, or null when the lookup failed. */
439
+ var repoDefaultBranch = async (fullName, refresh = false) => (await repoMeta(fullName, refresh)).defaultBranch;
440
+ var repoMeta = swr(36e5, async (fullName) => {
441
+ const { host, name } = splitRepo(fullName);
442
+ const out = await gh([
443
+ "api",
444
+ `repos/${name}`,
445
+ "--jq",
446
+ "{parent: (.parent.full_name // null), default: .default_branch}"
447
+ ], host).catch(() => "");
448
+ let parsed = {};
449
+ try {
450
+ parsed = JSON.parse(out);
451
+ } catch {}
452
+ if (!parsed.default) try {
453
+ await ensureGitCredentials(host);
454
+ const symref = await git([
455
+ "ls-remote",
456
+ "--symref",
457
+ cloneUrl(fullName),
458
+ "HEAD"
459
+ ]);
460
+ parsed.default = /^ref: refs\/heads\/(\S+)\tHEAD/m.exec(symref)?.[1] ?? null;
461
+ } catch {}
462
+ return {
463
+ parent: parsed.parent ? joinRepo(host, parsed.parent) : null,
464
+ defaultBranch: parsed.default || null
465
+ };
466
+ });
467
+ /** REST pages branches at 100 and orders them alphabetically, so a repo
468
+ * with more than one page hid everything past it — on a repo with a couple
469
+ * hundred workspace branches that silently cut `main` itself out of the
470
+ * picker. Paginate, but bounded: a repo past this many pages costs more
471
+ * round trips than a picker should, and the filter is how you find a branch
472
+ * there anyway. */
473
+ var BRANCH_PAGES = 10;
474
+ var branches = swr(3e5, async (fullName) => {
475
+ const { host, name } = splitRepo(fullName);
476
+ try {
477
+ const all = [];
478
+ for (let page = 1; page <= BRANCH_PAGES; page++) {
479
+ const names = (await gh([
480
+ "api",
481
+ `repos/${name}/branches?per_page=100&page=${page}`,
482
+ "--jq",
483
+ ".[].name"
484
+ ], host)).split("\n").filter(Boolean);
485
+ all.push(...names);
486
+ if (names.length < 100) break;
487
+ }
488
+ return all;
489
+ } catch {
490
+ await ensureGitCredentials(host);
491
+ return (await git([
492
+ "ls-remote",
493
+ "--heads",
494
+ cloneUrl(fullName)
495
+ ])).split("\n").map((l) => l.split(" refs/heads/")[1]).filter(Boolean);
496
+ }
497
+ });
498
+ //#endregion
499
+ //#region src/server/git-refs.ts
500
+ /** The checked-out commit (and branch name) of a worktree, read off disk
501
+ * instead of spawned.
502
+ *
503
+ * Every status tick needs the oid for every repo of every active workspace —
504
+ * it is what tells an open review that an agent committed, since a local
505
+ * commit reaches GitHub through nothing the app can be told about. Diff
506
+ * loads also need the branch; both answers come from the same HEAD read.
507
+ * As a process per repo per tick that was the bulk of the git the server
508
+ * ran; the same answer is three small file reads.
509
+ *
510
+ * `git` stays the authority for anything this cannot read confidently: a
511
+ * ref backend it does not understand, a chain that does not end in an oid,
512
+ * a layout it has never seen. Being slower is better than being wrong,
513
+ * because the drift banner reads a stale oid as "your checkout differs
514
+ * from the review". */
515
+ async function headOid(dir) {
516
+ const read = await readHead(dir).catch(() => null);
517
+ if (read) return read;
518
+ const [oid, branch] = await Promise.all([spawnHead(dir), git([
519
+ "rev-parse",
520
+ "--abbrev-ref",
521
+ "HEAD"
522
+ ], dir).catch(() => "HEAD")]);
523
+ return {
524
+ oid,
525
+ branch
526
+ };
527
+ }
528
+ var spawnHead = (dir) => git(["rev-parse", "HEAD"], dir).catch(() => "");
529
+ /** Does the remote-tracking store hold any real branch head? Decides
530
+ * "empty upstream" (diff every file as an add, create an orphan branch)
531
+ * vs a misconfigured base that should fail loudly.
532
+ *
533
+ * `origin/HEAD` does not count: it can exist as a symref even when the
534
+ * branch it points at has no commits yet (a plain clone of an empty repo),
535
+ * and counting it would make an empty repo look configured. */
536
+ async function remoteHasHeads(repo) {
537
+ return (await git([
538
+ "for-each-ref",
539
+ "--format=%(refname)",
540
+ "refs/remotes/origin/"
541
+ ], repo).catch(() => "")).split("\n").some((ref) => ref.startsWith("refs/remotes/origin/") && ref !== "refs/remotes/origin/HEAD");
542
+ }
543
+ /** Both hash algorithms, since a repo may be SHA-256. */
544
+ var OID = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
545
+ /** A symref chain that runs this long is a loop, not a layout. */
546
+ var MAX_HOPS = 5;
547
+ async function readHead(dir) {
548
+ const { gitDir, common } = await gitDirs(dir);
549
+ if (await exists(path.join(common, "reftable"))) return null;
550
+ let ref = (await fs$1.readFile(path.join(gitDir, "HEAD"), "utf8")).trim();
551
+ let branch = "HEAD";
552
+ if (ref.startsWith("ref: ")) {
553
+ const name = ref.slice(5).trim();
554
+ if (name.startsWith("refs/heads/")) branch = name.slice(11);
555
+ } else if (OID.test(ref)) return {
556
+ oid: ref,
557
+ branch: "HEAD"
558
+ };
559
+ else return null;
560
+ for (let hop = 0; ref.startsWith("ref: ") && hop < MAX_HOPS; hop++) {
561
+ const name = ref.slice(5).trim();
562
+ const loose = await read(path.join(gitDir, name)) ?? await read(path.join(common, name));
563
+ if (loose !== void 0) {
564
+ ref = loose.trim();
565
+ continue;
566
+ }
567
+ const packed = await read(path.join(common, "packed-refs"));
568
+ if (packed === void 0) return {
569
+ oid: "",
570
+ branch
571
+ };
572
+ const line = packed.split("\n").find((l) => !l.startsWith("^") && l.endsWith(` ${name}`));
573
+ if (!line) return {
574
+ oid: "",
575
+ branch
576
+ };
577
+ ref = line.slice(0, line.indexOf(" "));
578
+ }
579
+ return OID.test(ref) ? {
580
+ oid: ref,
581
+ branch
582
+ } : null;
583
+ }
584
+ /** A worktree's `.git` is a file pointing at its admin directory inside the
585
+ * shared bare cache, which in turn points back at the common ref store. */
586
+ async function gitDirs(dir) {
587
+ const dotGit = path.join(dir, ".git");
588
+ const gitDir = await isDir(dotGit) ? dotGit : path.resolve(dir, (await fs$1.readFile(dotGit, "utf8")).replace(/^gitdir:/, "").trim());
589
+ const commonRef = await read(path.join(gitDir, "commondir"));
590
+ return {
591
+ gitDir,
592
+ common: commonRef ? path.resolve(gitDir, commonRef.trim()) : gitDir
593
+ };
594
+ }
595
+ var read = (file) => fs$1.readFile(file, "utf8").catch(() => void 0);
596
+ var exists = (file) => fs$1.stat(file).then(() => true, () => false);
597
+ var isDir = (file) => fs$1.stat(file).then((s) => s.isDirectory(), () => false);
598
+ //#endregion
599
+ //#region src/server/procs.ts
600
+ var darwin = process.platform === "darwin";
601
+ /** Is `cwd` the workspace root or somewhere under it? The trailing slash is
602
+ * the whole point: a sibling `…/work-2` must not read as inside `…/work`. */
603
+ function inside(cwd, root) {
604
+ return cwd === root || cwd.startsWith(root + "/");
605
+ }
606
+ /** Listening TCP ports attributed to workspaces by the owning process's cwd. */
607
+ async function portsByWorkspace(paths) {
608
+ const result = /* @__PURE__ */ new Map();
609
+ const canonical = new Map([...paths].map(([wsId, p]) => [wsId, realPath(p)]));
610
+ const [ports, cwds] = darwin ? await Promise.all([listeningPorts(), processCwds()]) : await (async () => {
611
+ const ports = await listeningPorts();
612
+ const cwds = /* @__PURE__ */ new Map();
613
+ await Promise.all([...new Set(ports.map((p) => p.pid))].map(async (pid) => {
614
+ const cwd = await readCwd(pid);
615
+ if (cwd) cwds.set(pid, cwd);
616
+ }));
617
+ return [ports, cwds];
618
+ })();
619
+ for (const info of ports) {
620
+ const cwd = cwds.get(info.pid);
621
+ if (!cwd) continue;
622
+ for (const [wsId, wsPath] of canonical) if (inside(cwd, wsPath)) {
623
+ const list = result.get(wsId) || [];
624
+ list.push(info);
625
+ result.set(wsId, list);
626
+ }
627
+ }
628
+ return result;
629
+ }
630
+ /** Processes whose cwd is inside the workspace, with their listening ports. */
631
+ async function workspaceProcs(wsPath) {
632
+ wsPath = realPath(wsPath);
633
+ const portsByPid = /* @__PURE__ */ new Map();
634
+ const [ports, panes, cwds] = await Promise.all([
635
+ listeningPorts(),
636
+ panePids(),
637
+ processCwds()
638
+ ]);
639
+ for (const { port, pid } of ports) portsByPid.set(pid, [...portsByPid.get(pid) || [], port]);
640
+ const procs = [];
641
+ for (const [pid, cwd] of cwds) {
642
+ if (!inside(cwd, wsPath)) continue;
643
+ const parts = await procArgs(pid);
644
+ if (!parts.length) continue;
645
+ const cmd = parts[0].split("/").pop() || parts[0];
646
+ if (cmd === "tmux" || cmd.startsWith("tmux:") || cmd === "git" || cmd.startsWith("git-") || panes.has(pid)) continue;
647
+ procs.push({
648
+ pid,
649
+ cmd,
650
+ args: parts.slice(1).join(" ").slice(0, 120),
651
+ ports: [...new Set(portsByPid.get(pid) || [])]
652
+ });
653
+ }
654
+ return procs.sort((a, b) => b.ports.length - a.ports.length || a.pid - b.pid);
655
+ }
656
+ /** SIGTERM every process running inside the workspace (dev servers etc.). */
657
+ async function killWorkspaceProcs(wsPath) {
658
+ for (const proc of await workspaceProcs(wsPath)) try {
659
+ process.kill(proc.pid, "SIGTERM");
660
+ } catch {}
661
+ }
662
+ /** SIGTERM a single pid, but only when its cwd sits inside one of the
663
+ * given workspace paths — the kill endpoint must never be able to signal
664
+ * arbitrary host processes. Returns whether a signal was sent. */
665
+ async function killWorkspaceProc(pid, wsPaths) {
666
+ const cwd = await readCwd(pid);
667
+ if (!cwd) return false;
668
+ if (!wsPaths.some((p) => inside(cwd, realPath(p)))) return false;
669
+ try {
670
+ process.kill(pid, "SIGTERM");
671
+ } catch {
672
+ return false;
673
+ }
674
+ return true;
675
+ }
676
+ /** Every listening TCP port with its owning pid/command: `ss` on Linux,
677
+ * `lsof` on macOS (no /proc, no ss there). */
678
+ async function listeningPorts() {
679
+ const seen = /* @__PURE__ */ new Set();
680
+ const ports = [];
681
+ if (darwin) {
682
+ const out = await run("/usr/sbin/lsof", [
683
+ "-nP",
684
+ "-iTCP",
685
+ "-sTCP:LISTEN",
686
+ "-Fpcn"
687
+ ]).catch(() => "");
688
+ let pid = 0;
689
+ let cmd = "?";
690
+ for (const line of out.split("\n")) if (line.startsWith("p")) pid = Number(line.slice(1));
691
+ else if (line.startsWith("c")) cmd = line.slice(1) || "?";
692
+ else if (line.startsWith("n")) {
693
+ const port = Number(line.match(/:(\d+)$/)?.[1]);
694
+ if (!port || !pid || seen.has(`${pid}:${port}`)) continue;
695
+ seen.add(`${pid}:${port}`);
696
+ ports.push({
697
+ port,
698
+ pid,
699
+ cmd
700
+ });
701
+ }
702
+ return ports;
703
+ }
704
+ const out = await run("ss", ["-tlnpH"]).catch(() => "");
705
+ for (const line of out.split("\n")) {
706
+ const port = Number(line.match(/:(\d+)\s/)?.[1]);
707
+ const pid = Number(line.match(/pid=(\d+)/)?.[1]);
708
+ const cmd = line.match(/users:\(\("([^"]+)"/)?.[1] || "?";
709
+ if (!port || !pid || seen.has(`${pid}:${port}`)) continue;
710
+ seen.add(`${pid}:${port}`);
711
+ ports.push({
712
+ port,
713
+ pid,
714
+ cmd
715
+ });
716
+ }
717
+ return ports;
718
+ }
719
+ /** Every process's cwd: /proc scan on Linux, one `lsof` sweep on macOS. */
720
+ async function processCwds() {
721
+ const cwds = /* @__PURE__ */ new Map();
722
+ if (darwin) {
723
+ const out = await run("/usr/sbin/lsof", [
724
+ "-a",
725
+ "-d",
726
+ "cwd",
727
+ "-Fpn"
728
+ ]).catch(() => "");
729
+ let pid = 0;
730
+ for (const line of out.split("\n")) if (line.startsWith("p")) pid = Number(line.slice(1));
731
+ else if (line.startsWith("n") && pid) cwds.set(pid, line.slice(1));
732
+ return cwds;
733
+ }
734
+ for (const entry of await fs$1.readdir("/proc")) {
735
+ const pid = Number(entry);
736
+ if (!pid) continue;
737
+ const cwd = await readCwd(pid);
738
+ if (cwd) cwds.set(pid, cwd);
739
+ }
740
+ return cwds;
741
+ }
742
+ /** A process's argv (best-effort; empty when it died or is unreadable). */
743
+ async function procArgs(pid) {
744
+ if (darwin) return (await run("ps", [
745
+ "-p",
746
+ String(pid),
747
+ "-o",
748
+ "args="
749
+ ]).catch(() => "")).trim().split(/\s+/).filter(Boolean);
750
+ try {
751
+ return (await fs$1.readFile(`/proc/${pid}/cmdline`, "utf8")).split("\0").filter(Boolean);
752
+ } catch {
753
+ return [];
754
+ }
755
+ }
756
+ async function readCwd(pid) {
757
+ if (darwin) {
758
+ const line = (await run("/usr/sbin/lsof", [
759
+ "-a",
760
+ "-d",
761
+ "cwd",
762
+ "-p",
763
+ String(pid),
764
+ "-Fn"
765
+ ]).catch(() => "")).split("\n").find((l) => l.startsWith("n"));
766
+ return line ? line.slice(1) : void 0;
767
+ }
768
+ try {
769
+ return await fs$1.readlink(`/proc/${pid}/cwd`);
770
+ } catch {
771
+ return;
772
+ }
773
+ }
774
+ /** Resolve symlinks (e.g. /home → /var/home on ostree) so paths compare
775
+ * equal to the fully-resolved targets the cwd lookups return. */
776
+ function realPath(p) {
777
+ try {
778
+ return fs.realpathSync(p);
779
+ } catch {
780
+ return p;
781
+ }
782
+ }
783
+ //#endregion
784
+ //#region src/server/hook-token.ts
785
+ /** Shared secret baked into git push hooks. /api/repo-pushed rides the
786
+ * public port now (no internal-port split), so the token keeps strangers
787
+ * on the LAN/tunnel from spending our GitHub API budget with spoofed
788
+ * refresh pings. Persisted so hooks survive server restarts; migrate.mjs
789
+ * reads the same file. */
790
+ var file = path.join(config.dataDir, "hook-token");
791
+ var cached;
792
+ function hookToken() {
793
+ if (cached) return cached;
794
+ try {
795
+ cached = fs.readFileSync(file, "utf8").trim();
796
+ if (cached) return cached;
797
+ } catch {}
798
+ cached = crypto.randomBytes(16).toString("hex");
799
+ fs.writeFileSync(file, `${cached}\n`, { mode: 384 });
800
+ return cached;
801
+ }
802
+ //#endregion
803
+ //#region src/server/repo-events.ts
804
+ /** Ref-update detection: a reference-transaction hook in each bare-cache
805
+ * repo (work-owned — never a user worktree) posts the transaction's ref
806
+ * lines to the app the moment any workspace pushes, fetches or commits.
807
+ * Remote-tracking updates refresh PR badges in seconds instead of waiting
808
+ * out the poll; local branch updates are what auto-push acts on (see
809
+ * auto-push.ts). scripts/migrate.mjs backfills existing caches — keep the
810
+ * script text there in sync with this one. */
811
+ function hookScript(fullName) {
812
+ return `#!/bin/sh
813
+ # installed by work: signals ref updates to the app server.
814
+ [ "\$1" = "committed" ] || exit 0
815
+ refs=\$(cat)
816
+ case "\$refs" in *"refs/remotes/origin/"*|*" refs/heads/"*) ;; *) exit 0 ;; esac
817
+ printf '%s\\n' "$refs" | curl -sf -m 2 -X POST --data-binary @- "http://127.0.0.1:${config.appPort}/api/repo-pushed?repo=${encodeURIComponent(fullName)}&token=${hookToken()}" >/dev/null 2>&1 &\nexit 0\n`;
818
+ }
819
+ function ensurePushHook(bare, fullName) {
820
+ const script = hookScript(fullName);
821
+ try {
822
+ const file = path.join(bare, "hooks", "reference-transaction");
823
+ try {
824
+ if (fs.readFileSync(file, "utf8") === script) return;
825
+ } catch {}
826
+ fs.mkdirSync(path.dirname(file), { recursive: true });
827
+ fs.writeFileSync(file, script, { mode: 493 });
828
+ } catch (err) {
829
+ console.warn(`[work] failed to install push hook in ${bare}`, err);
830
+ }
831
+ }
832
+ /** Every bare cache on disk gets the current hook at boot: the script
833
+ * changes between versions, and a repo that no workspace touches after an
834
+ * update would otherwise keep signalling the old way. */
835
+ function ensureAllPushHooks() {
836
+ let hosts;
837
+ try {
838
+ hosts = fs.readdirSync(config.cacheDir);
839
+ } catch {
840
+ return;
841
+ }
842
+ for (const host of hosts) {
843
+ const hostDir = path.join(config.cacheDir, host);
844
+ let owners = [];
845
+ try {
846
+ owners = fs.readdirSync(hostDir);
847
+ } catch {}
848
+ for (const owner of owners) {
849
+ const ownerDir = path.join(hostDir, owner);
850
+ let repos = [];
851
+ try {
852
+ repos = fs.readdirSync(ownerDir);
853
+ } catch {}
854
+ for (const repo of repos) {
855
+ if (!repo.endsWith(".git")) continue;
856
+ const name = `${owner}/${repo.slice(0, -4)}`;
857
+ ensurePushHook(path.join(ownerDir, repo), host === "github.com" ? name : `${host}/${name}`);
858
+ }
859
+ }
860
+ }
861
+ }
862
+ //#endregion
863
+ //#region src/server/workspaces.ts
864
+ function listWorkspaces() {
865
+ return all("SELECT * FROM workspaces ORDER BY created_at DESC").map(hydrate);
866
+ }
867
+ /** Single-row lookup — this runs on every /api/workspaces/:id/* request,
868
+ * so it mustn't load every workspace. */
869
+ function getWorkspace(id) {
870
+ const ws = get("SELECT * FROM workspaces WHERE id = ?", id);
871
+ return ws && hydrate(ws);
872
+ }
873
+ var home = os.homedir();
874
+ /** Materialize failures, by workspace id — the row stays pending and the
875
+ * nav shows why until a retry (un-archive, restart) gets through. */
876
+ var materializeErrors = /* @__PURE__ */ new Map();
877
+ function hydrate(ws) {
878
+ const raw = ws;
879
+ ws.pending = raw.materialized === 0;
880
+ delete raw.materialized;
881
+ if (ws.pending && materializeErrors.has(ws.id)) ws.pending_error = materializeErrors.get(ws.id);
882
+ ws.repos = all("SELECT full_name, worktree, base_branch, default_branch FROM repos WHERE workspace_id = ? ORDER BY worktree", ws.id);
883
+ ws.display_path = ws.path.startsWith(home) ? `~${ws.path.slice(home.length)}` : ws.path;
884
+ return ws;
885
+ }
886
+ async function createWorkspace(name, repos, opts = {}) {
887
+ if (!name.trim()) throw new Error("workspace name required");
888
+ for (const repo of repos) assertKnownHost(repo.fullName);
889
+ const join = opts.join?.trim() || null;
890
+ if (join) {
891
+ if (!/^[\w./+@-]+$/.test(join)) throw new Error(`not a branch name: ${join}`);
892
+ await git([
893
+ "check-ref-format",
894
+ "--branch",
895
+ join
896
+ ], config.workRoot).catch(() => {
897
+ throw new Error(`not a branch name: ${join}`);
898
+ });
899
+ const own = listWorkspaces().find((w) => w.branch === join || ownsBranch(join, w.branch));
900
+ if (own) throw new Error(`${join} is ${own.name}, a workspace on this machine — open it instead`);
901
+ }
902
+ const slug = slugify(name);
903
+ const id = randomId();
904
+ const dir = path.join(config.workRoot, `${slug}-${id}`);
905
+ const branch = join ? `${join}-${slugify(config.branchPrefix)}` : `${config.branchPrefix}-${slug}-${id}`;
906
+ const siblings = assignSiblingNames(repos.map((r) => r.fullName));
907
+ const added = [];
908
+ fs.mkdirSync(dir, { recursive: true });
909
+ try {
910
+ const results = await Promise.allSettled(repos.map(async (repo) => {
911
+ const worktree = siblings.get(repo.fullName);
912
+ const fullName = await resolveFork(repo);
913
+ return withRepoLock(fullName, async () => {
914
+ const bare = await ensureBare(fullName);
915
+ const defaultBranch = await bareDefaultBranch(bare);
916
+ const base = join || repo.baseBranch || defaultBranch;
917
+ if (join && !await revParse(bare, `refs/remotes/origin/${join}`)) throw new Error(`${fullName} has no branch ${join} on origin — its owner has not pushed it yet`);
918
+ await addWorkspaceWorktree(bare, path.join(dir, worktree), branch, base);
919
+ return {
920
+ full_name: fullName,
921
+ worktree,
922
+ base_branch: base,
923
+ default_branch: defaultBranch
924
+ };
925
+ });
926
+ }));
927
+ for (const r of results) if (r.status === "fulfilled") added.push(r.value);
928
+ const failed = results.find((r) => r.status === "rejected");
929
+ if (failed && failed.status === "rejected") throw failed.reason;
930
+ } catch (err) {
931
+ for (const r of added) await withRepoLock(r.full_name, () => git([
932
+ "worktree",
933
+ "remove",
934
+ "--force",
935
+ path.join(dir, r.worktree)
936
+ ], barePath(r.full_name))).catch(() => {});
937
+ fs.rmSync(dir, {
938
+ recursive: true,
939
+ force: true
940
+ });
941
+ throw err;
942
+ }
943
+ db.prepare("INSERT INTO workspaces (id, name, path, branch, origin_branch, imported_at) VALUES (?, ?, ?, ?, ?, ?)").run(id, name, dir, branch, join, join ? (/* @__PURE__ */ new Date()).toISOString() : null);
944
+ for (const r of added) db.prepare("INSERT INTO repos (workspace_id, full_name, worktree, base_branch, default_branch) VALUES (?, ?, ?, ?, ?)").run(id, r.full_name, r.worktree, r.base_branch, r.default_branch);
945
+ writeAgentsMd(dir, branch, added, join);
946
+ notifyWorkspacesChanged();
947
+ return getWorkspace(id);
948
+ }
949
+ /** Insert a workspace from a backup as a pending row: the directory and
950
+ * worktrees come later (see materializeWorkspace), so a restore of many
951
+ * workspaces is instant and clones happen one at a time behind it. The
952
+ * id is kept — it is what a manifest and this machine agree on — unless
953
+ * this database already has it. Returns the new path. */
954
+ /** One path segment, no traversal — a workspace id or a worktree dir. */
955
+ var SEGMENT_RE = /^(?!\.{1,2}$)[\w.-]+$/;
956
+ var BRANCH_RE = /^[\w./+@-]+$/;
957
+ function insertPendingWorkspace(row) {
958
+ if (!SEGMENT_RE.test(row.id)) throw new Error(`not a workspace id: ${row.id}`);
959
+ if (!BRANCH_RE.test(row.branch)) throw new Error(`not a branch name: ${row.branch}`);
960
+ if (row.origin_branch && !BRANCH_RE.test(row.origin_branch)) throw new Error(`not a branch name: ${row.origin_branch}`);
961
+ for (const r of row.repos) {
962
+ if (!SEGMENT_RE.test(r.worktree)) throw new Error(`not a directory name: ${r.worktree}`);
963
+ for (const b of [r.base_branch, r.default_branch]) if (b && !BRANCH_RE.test(b)) throw new Error(`not a branch name: ${b}`);
964
+ }
965
+ if (getWorkspace(row.id)) throw new Error(`${row.name} is already here`);
966
+ for (const repo of row.repos) assertKnownHost(repo.full_name);
967
+ const slug = slugify(row.name);
968
+ let dir = path.join(config.workRoot, `${slug}-${row.id}`);
969
+ for (let n = 2; fs.existsSync(dir); n++) dir = path.join(config.workRoot, `${slug}-${row.id}-${n}`);
970
+ const repos = new Map(row.repos.map((r) => [r.full_name, r]));
971
+ const dirs = /* @__PURE__ */ new Set();
972
+ for (const r of repos.values()) {
973
+ if (dirs.has(r.worktree)) throw new Error(`two repos in ${row.name} share the directory ${r.worktree}`);
974
+ dirs.add(r.worktree);
975
+ }
976
+ db.exec("BEGIN");
977
+ try {
978
+ db.prepare("INSERT INTO workspaces (id, name, path, branch, status, created_at, origin_branch, imported_at, materialized) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)").run(row.id, row.name, dir, row.branch, row.status, row.created_at || (/* @__PURE__ */ new Date()).toISOString(), row.origin_branch ?? null, (/* @__PURE__ */ new Date()).toISOString());
979
+ for (const r of repos.values()) db.prepare("INSERT INTO repos (workspace_id, full_name, worktree, base_branch, default_branch) VALUES (?, ?, ?, ?, ?)").run(row.id, r.full_name, r.worktree, r.base_branch, r.default_branch);
980
+ db.exec("COMMIT");
981
+ } catch (err) {
982
+ db.exec("ROLLBACK");
983
+ throw err;
984
+ }
985
+ materializeErrors.delete(row.id);
986
+ notifyWorkspacesChanged();
987
+ return dir;
988
+ }
989
+ /** Put a pending workspace on disk: clone/fetch every repo and check its
990
+ * branch out from origin (the branch a backup names, or a fresh one from
991
+ * the base when it was never pushed). A failure rolls the directory back
992
+ * and leaves the row pending with the reason. */
993
+ async function materializeWorkspace(id) {
994
+ const ws = getWorkspace(id);
995
+ if (!ws || !ws.pending) return;
996
+ materializeErrors.delete(id);
997
+ const added = [];
998
+ fs.mkdirSync(ws.path, { recursive: true });
999
+ try {
1000
+ for (const repo of ws.repos) await withRepoLock(repo.full_name, async () => {
1001
+ const bare = await ensureBare(repo.full_name);
1002
+ const defaultBranch = await bareDefaultBranch(bare);
1003
+ if (!repo.base_branch) repo.base_branch = defaultBranch;
1004
+ if (repo.default_branch !== defaultBranch || repo.base_branch !== defaultBranch) {
1005
+ repo.default_branch = defaultBranch;
1006
+ db.prepare("UPDATE repos SET base_branch = ?, default_branch = ? WHERE workspace_id = ? AND full_name = ?").run(repo.base_branch, defaultBranch, id, repo.full_name);
1007
+ }
1008
+ (await import("./_Dpa8WFuv.js")).muteAutoPush(repo.full_name, ws.branch);
1009
+ await addWorkspaceWorktree(bare, path.join(ws.path, repo.worktree), ws.branch, repo.base_branch);
1010
+ added.push(repo);
1011
+ });
1012
+ } catch (err) {
1013
+ for (const r of added) await withRepoLock(r.full_name, () => git([
1014
+ "worktree",
1015
+ "remove",
1016
+ "--force",
1017
+ path.join(ws.path, r.worktree)
1018
+ ], barePath(r.full_name))).catch(() => {});
1019
+ fs.rmSync(ws.path, {
1020
+ recursive: true,
1021
+ force: true
1022
+ });
1023
+ materializeErrors.set(id, err instanceof Error ? err.message : String(err));
1024
+ notifyWorkspacesChanged();
1025
+ throw err;
1026
+ }
1027
+ writeAgentsMd(ws.path, ws.branch, ws.repos, ws.origin_branch);
1028
+ db.prepare("UPDATE workspaces SET materialized = 1 WHERE id = ?").run(id);
1029
+ notifyWorkspacesChanged();
1030
+ }
1031
+ /** One materialize at a time: they share the bare caches and the network,
1032
+ * and a restore of ten workspaces should not fan out ten clones. Each runs
1033
+ * under its own progress op so a nav row can follow its own steps. */
1034
+ var materializeChain = Promise.resolve();
1035
+ function queueMaterialize(id) {
1036
+ materializeChain = materializeChain.then(() => withProgressOp(`restore:${id}`, () => materializeWorkspace(id)).catch((err) => {
1037
+ console.warn(`[work] restore of workspace ${id} failed`, err);
1038
+ }));
1039
+ return materializeChain;
1040
+ }
1041
+ /** Pending active workspaces left over from a restore that did not finish
1042
+ * (a restart mid-way, a clone that failed) — retried on boot. */
1043
+ function resumePendingWorkspaces() {
1044
+ for (const ws of listWorkspaces()) if (ws.pending && ws.status === "active") queueMaterialize(ws.id);
1045
+ }
1046
+ async function addRepo(workspaceId, fullName, baseBranch, fork) {
1047
+ const ws = getWorkspace(workspaceId);
1048
+ if (!ws) throw new Error(`unknown workspace: ${workspaceId}`);
1049
+ assertKnownHost(fullName);
1050
+ const requested = fullName;
1051
+ const worktree = assignSiblingNames([...ws.repos.map((r) => r.full_name), requested]).get(requested);
1052
+ fullName = await resolveFork({
1053
+ fullName: requested,
1054
+ fork
1055
+ });
1056
+ await assertRepoNotInWorkspace(ws, requested, fullName);
1057
+ const { base, defaultBranch } = await withRepoLock(fullName, async () => {
1058
+ const bare = await ensureBare(fullName);
1059
+ const defaultBranch = await bareDefaultBranch(bare);
1060
+ const base = baseBranch || defaultBranch;
1061
+ try {
1062
+ await addWorkspaceWorktree(bare, path.join(ws.path, worktree), ws.branch, base);
1063
+ } catch (err) {
1064
+ const dest = path.join(ws.path, worktree);
1065
+ await git([
1066
+ "worktree",
1067
+ "remove",
1068
+ "--force",
1069
+ dest
1070
+ ], bare).catch(() => {
1071
+ fs.rmSync(dest, {
1072
+ recursive: true,
1073
+ force: true
1074
+ });
1075
+ });
1076
+ await git(["worktree", "prune"], bare).catch(() => {});
1077
+ throw err;
1078
+ }
1079
+ return {
1080
+ base,
1081
+ defaultBranch
1082
+ };
1083
+ });
1084
+ db.prepare("INSERT INTO repos (workspace_id, full_name, worktree, base_branch, default_branch) VALUES (?, ?, ?, ?, ?)").run(workspaceId, fullName, worktree, base, defaultBranch);
1085
+ const updated = getWorkspace(workspaceId);
1086
+ writeAgentsMd(ws.path, ws.branch, updated.repos, ws.origin_branch);
1087
+ notifyWorkspacesChanged();
1088
+ return updated;
1089
+ }
1090
+ async function removeRepo(workspaceId, worktree) {
1091
+ const ws = getWorkspace(workspaceId);
1092
+ if (!ws) throw new Error(`unknown workspace: ${workspaceId}`);
1093
+ const repo = ws.repos.find((r) => r.worktree === worktree);
1094
+ if (!repo) throw new Error(`unknown repo: ${worktree}`);
1095
+ const bare = barePath(repo.full_name);
1096
+ const dir = path.join(ws.path, worktree);
1097
+ progress(`Removing worktree ${worktree}/…`);
1098
+ const foreign = otherWorkspaceBranches(ws.id);
1099
+ await withRepoLock(repo.full_name, async () => {
1100
+ await git([
1101
+ "worktree",
1102
+ "remove",
1103
+ "--force",
1104
+ dir
1105
+ ], bare).catch(() => {
1106
+ fs.rmSync(dir, {
1107
+ recursive: true,
1108
+ force: true
1109
+ });
1110
+ });
1111
+ await git(["worktree", "prune"], bare).catch(() => {});
1112
+ for (const branch of await workspaceBranches(bare, ws.branch, foreign)) await git([
1113
+ "branch",
1114
+ "-D",
1115
+ branch
1116
+ ], bare).catch(() => {});
1117
+ });
1118
+ db.prepare("DELETE FROM repos WHERE workspace_id = ? AND worktree = ?").run(workspaceId, worktree);
1119
+ db.prepare("DELETE FROM review_comments WHERE workspace_id = ? AND worktree = ?").run(workspaceId, worktree);
1120
+ db.prepare("DELETE FROM review_viewed WHERE workspace_id = ? AND key GLOB ? || '/*'").run(workspaceId, worktree);
1121
+ const updated = getWorkspace(workspaceId);
1122
+ try {
1123
+ writeAgentsMd(ws.path, ws.branch, updated.repos, ws.origin_branch);
1124
+ } catch {}
1125
+ notifyWorkspacesChanged();
1126
+ publish("review.changed", { workspaceId });
1127
+ return updated;
1128
+ }
1129
+ async function setWorkspaceStatus(id, status) {
1130
+ const ws = getWorkspace(id);
1131
+ db.prepare("UPDATE workspaces SET status = ? WHERE id = ?").run(status, id);
1132
+ notifyWorkspacesChanged();
1133
+ if (status === "active" && ws?.pending) queueMaterialize(id);
1134
+ if (status === "archived") {
1135
+ progress(`Stopping sessions…`);
1136
+ await killWorkspaceSessions(id);
1137
+ if (ws) {
1138
+ progress(`Stopping processes…`);
1139
+ await killWorkspaceProcs(ws.path);
1140
+ }
1141
+ }
1142
+ }
1143
+ async function deleteWorkspace(id) {
1144
+ const ws = getWorkspace(id);
1145
+ if (!ws) throw new Error(`unknown workspace: ${id}`);
1146
+ const sessions = listSessions(id);
1147
+ db.prepare("DELETE FROM workspaces WHERE id = ?").run(id);
1148
+ db.prepare("DELETE FROM settings WHERE key = ?").run(`review.seen:${id}`);
1149
+ db.prepare("DELETE FROM settings WHERE key IN (?, ?, ?)").run(`ui.tab:${id}`, `ui.diff-filter:${id}`, `ui.diff-pos:${id}`);
1150
+ notifyWorkspacesChanged();
1151
+ progress(`Stopping sessions and processes…`);
1152
+ await disposeSessions(sessions);
1153
+ await killWorkspaceProcs(ws.path);
1154
+ const foreign = otherWorkspaceBranches(ws.id);
1155
+ for (const repo of ws.repos) {
1156
+ const bare = barePath(repo.full_name);
1157
+ const worktree = path.join(ws.path, repo.worktree);
1158
+ progress(`Removing worktree ${repo.worktree}/…`);
1159
+ await withRepoLock(repo.full_name, async () => {
1160
+ await git([
1161
+ "worktree",
1162
+ "remove",
1163
+ "--force",
1164
+ worktree
1165
+ ], bare).catch(() => {});
1166
+ await git(["worktree", "prune"], bare).catch(() => {});
1167
+ for (const branch of await workspaceBranches(bare, ws.branch, foreign)) await git([
1168
+ "branch",
1169
+ "-D",
1170
+ branch
1171
+ ], bare).catch(() => {});
1172
+ });
1173
+ }
1174
+ progress(`Deleting workspace files…`);
1175
+ fs.rmSync(ws.path, {
1176
+ recursive: true,
1177
+ force: true
1178
+ });
1179
+ materializeErrors.delete(id);
1180
+ }
1181
+ /** SSE payload: full workspace list so clients don't refetch. */
1182
+ function notifyWorkspacesChanged() {
1183
+ publish("workspaces.changed", listWorkspaces());
1184
+ }
1185
+ /** Creation/add-repo progress, streamed to clients over SSE — and kept in
1186
+ * a small ring so clients whose SSE a proxy/VPN buffers can poll it
1187
+ * (see /api/progress). Seqs let the two paths dedupe. Each entry carries
1188
+ * the id of the operation that produced it (see withProgressOp) so a
1189
+ * client's progress view never shows another operation's steps. */
1190
+ var progressSeq = 0;
1191
+ var progressLog = [];
1192
+ var progressOp = new AsyncLocalStorage();
1193
+ /** Tag every progress() emitted inside fn with an operation id — route
1194
+ * handlers wrap the workspace mutation they run so concurrent operations
1195
+ * don't cross-contaminate each other's step lists. */
1196
+ function withProgressOp(op, fn) {
1197
+ return progressOp.run(op, fn);
1198
+ }
1199
+ function progress(message) {
1200
+ const entry = {
1201
+ seq: ++progressSeq,
1202
+ op: progressOp.getStore(),
1203
+ message
1204
+ };
1205
+ progressLog.push(entry);
1206
+ if (progressLog.length > 50) progressLog.shift();
1207
+ publish("workspace.progress", entry);
1208
+ }
1209
+ function progressSince(seq, op) {
1210
+ return {
1211
+ seq: progressSeq,
1212
+ messages: seq === void 0 ? [] : progressLog.filter((e) => e.seq > seq && (!op || e.op === op))
1213
+ };
1214
+ }
1215
+ /** The name to clone: the user's fork of `fullName` when asked, made (or
1216
+ * found — GitHub's fork POST is idempotent) on the spot. */
1217
+ async function resolveFork(repo) {
1218
+ if (!repo.fork) return repo.fullName;
1219
+ progress(`Forking ${repo.fullName}…`);
1220
+ return ensureFork(repo.fullName);
1221
+ }
1222
+ /** Only github.com and hosts the user added (or gh already knows). Blocks
1223
+ * pasted `evil.com/o/r` from reaching ls-remote / clone. */
1224
+ function assertKnownHost(fullName) {
1225
+ const { host } = splitRepo(fullName);
1226
+ if (!configuredHosts().includes(host)) throw new Error(`unknown host ${host}: add it in Settings before cloning`);
1227
+ }
1228
+ /** Reject when the workspace already holds the resolved name, the
1229
+ * requested upstream, or a fork/parent pair of either — so forking
1230
+ * `torvalds/linux` then pasting that URL as-is (or the reverse) fails
1231
+ * cleanly instead of adding a second worktree. */
1232
+ async function assertRepoNotInWorkspace(ws, requested, resolved) {
1233
+ const held = new Set(ws.repos.map((r) => r.full_name));
1234
+ if (held.has(resolved) || held.has(requested)) throw new Error(`${held.has(resolved) ? resolved : requested} is already in this workspace`);
1235
+ for (const r of ws.repos) {
1236
+ const parent = await repoParent(r.full_name).catch(() => null);
1237
+ if (parent === requested || parent === resolved) throw new Error(`${r.full_name} (fork of ${parent}) is already in this workspace`);
1238
+ }
1239
+ const parentOfRequested = await repoParent(requested).catch(() => null);
1240
+ if (parentOfRequested && held.has(parentOfRequested)) throw new Error(`${parentOfRequested} (upstream of ${requested}) is already in this workspace`);
1241
+ }
1242
+ /** One operation at a time per shared bare cache: concurrent create/delete
1243
+ * on the same repo would race `worktree add` against `worktree prune` and
1244
+ * concurrent fetches. Chained promises, released even on failure. */
1245
+ var repoLocks = /* @__PURE__ */ new Map();
1246
+ function withRepoLock(fullName, fn) {
1247
+ const run = (repoLocks.get(fullName) ?? Promise.resolve()).then(fn);
1248
+ const tail = run.then(() => {}, () => {});
1249
+ repoLocks.set(fullName, tail);
1250
+ tail.then(() => {
1251
+ if (repoLocks.get(fullName) === tail) repoLocks.delete(fullName);
1252
+ });
1253
+ return run;
1254
+ }
1255
+ /** `<cache>/<host>/<owner>/<name>.git` — the host segment a full name
1256
+ * carries (or github.com) becomes the directory, so the pre-hosts layout
1257
+ * is unchanged for github.com repos. */
1258
+ function barePath(fullName) {
1259
+ const { host, name } = splitRepo(fullName);
1260
+ return path.join(config.cacheDir, host, `${name}.git`);
1261
+ }
1262
+ async function ensureBare(fullName) {
1263
+ const bare = barePath(fullName);
1264
+ if (fs.existsSync(bare)) {
1265
+ ensurePushHook(bare, fullName);
1266
+ progress(`Fetching ${fullName}…`);
1267
+ await git([
1268
+ "fetch",
1269
+ "--prune",
1270
+ "--quiet",
1271
+ "origin"
1272
+ ], bare);
1273
+ } else {
1274
+ progress(`Cloning ${fullName} (first time, may take a while)…`);
1275
+ fs.mkdirSync(path.dirname(bare), { recursive: true });
1276
+ await ensureGitCredentials(splitRepo(fullName).host);
1277
+ await git([
1278
+ "clone",
1279
+ "--bare",
1280
+ cloneUrl(fullName),
1281
+ bare
1282
+ ]);
1283
+ await git([
1284
+ "config",
1285
+ "remote.origin.fetch",
1286
+ "+refs/heads/*:refs/remotes/origin/*"
1287
+ ], bare);
1288
+ ensurePushHook(bare, fullName);
1289
+ await git([
1290
+ "fetch",
1291
+ "--prune",
1292
+ "--quiet",
1293
+ "origin"
1294
+ ], bare);
1295
+ }
1296
+ return bare;
1297
+ }
1298
+ async function bareDefaultBranch(bare) {
1299
+ try {
1300
+ const out = await git([
1301
+ "ls-remote",
1302
+ "--symref",
1303
+ "origin",
1304
+ "HEAD"
1305
+ ], bare);
1306
+ const remote = /^ref: refs\/heads\/(\S+)\tHEAD/m.exec(out)?.[1];
1307
+ if (remote) {
1308
+ await git([
1309
+ "symbolic-ref",
1310
+ "HEAD",
1311
+ `refs/heads/${remote}`
1312
+ ], bare);
1313
+ return remote;
1314
+ }
1315
+ } catch {}
1316
+ return (await git(["symbolic-ref", "HEAD"], bare)).replace("refs/heads/", "");
1317
+ }
1318
+ /** `git rev-parse --verify` → oid, or null if the ref is missing. */
1319
+ async function revParse(cwd, ref) {
1320
+ try {
1321
+ return await git([
1322
+ "rev-parse",
1323
+ "--verify",
1324
+ ref
1325
+ ], cwd);
1326
+ } catch {
1327
+ return null;
1328
+ }
1329
+ }
1330
+ /**
1331
+ * Create a worktree on the workspace branch tip that matches latest
1332
+ * `origin/<base>`.
1333
+ *
1334
+ * Fresh branch → cut from `origin/<base>` after fetch (ensureBare).
1335
+ * Existing `origin/<ws-branch>` (re-add, open PR, prior push) → restore
1336
+ * that branch then rebase onto `origin/<base>` so the checkout is not
1337
+ * stuck on an old main tip. Empty upstream (GitHub "no commits yet") →
1338
+ * `worktree add --orphan` so the first real commit can be the root.
1339
+ * Leftover local branches with no worktree are deleted so
1340
+ * `worktree add -b` can recreate them.
1341
+ */
1342
+ async function addWorkspaceWorktree(bare, dest, branch, base) {
1343
+ const baseRef = `origin/${base}`;
1344
+ const baseOid = await revParse(bare, baseRef);
1345
+ await git(["worktree", "prune"], bare).catch(() => {});
1346
+ if (await revParse(bare, `refs/heads/${branch}`)) {
1347
+ if ((await git([
1348
+ "worktree",
1349
+ "list",
1350
+ "--porcelain"
1351
+ ], bare)).includes(`branch refs/heads/${branch}`)) throw new Error(`${branch} is already checked out in another worktree`);
1352
+ await git([
1353
+ "branch",
1354
+ "-D",
1355
+ branch
1356
+ ], bare);
1357
+ }
1358
+ const label = path.basename(dest);
1359
+ if (!baseOid) {
1360
+ if (await remoteHasHeads(bare)) throw new Error(`missing ${baseRef} after fetch`);
1361
+ progress(`Adding empty worktree ${label}/…`);
1362
+ await git([
1363
+ "worktree",
1364
+ "add",
1365
+ "--orphan",
1366
+ "-b",
1367
+ branch,
1368
+ dest
1369
+ ], bare);
1370
+ return;
1371
+ }
1372
+ if (await revParse(bare, `refs/remotes/origin/${branch}`)) {
1373
+ progress(`Restoring ${label}/ from origin/${branch}…`);
1374
+ await git([
1375
+ "worktree",
1376
+ "add",
1377
+ "-b",
1378
+ branch,
1379
+ dest,
1380
+ `origin/${branch}`
1381
+ ], bare);
1382
+ if (await git([
1383
+ "merge-base",
1384
+ branch,
1385
+ baseRef
1386
+ ], dest) !== baseOid) {
1387
+ progress(`Rebasing ${label}/ onto ${base}…`);
1388
+ try {
1389
+ await git(["rebase", baseRef], dest);
1390
+ } catch (err) {
1391
+ await git(["rebase", "--abort"], dest).catch(() => {});
1392
+ const why = err instanceof Error ? err.message : String(err);
1393
+ throw new Error(`Could not rebase ${branch} onto ${baseRef} for ${label}/. ${why}`);
1394
+ }
1395
+ }
1396
+ } else {
1397
+ progress(`Adding worktree ${label}/ (${base})…`);
1398
+ await git([
1399
+ "worktree",
1400
+ "add",
1401
+ "-b",
1402
+ branch,
1403
+ dest,
1404
+ baseRef
1405
+ ], bare);
1406
+ }
1407
+ }
1408
+ /** Branches of every other workspace — slugs are user-chosen, so one
1409
+ * workspace's branch can be a strict prefix of another's; prefix matching
1410
+ * (cleanup, PR attribution) must exclude these. */
1411
+ function otherWorkspaceBranches(id) {
1412
+ return all("SELECT branch FROM workspaces WHERE id != ?", id).map((r) => r.branch);
1413
+ }
1414
+ /** The workspace branch plus its `-topic` lanes, if they exist locally —
1415
+ * minus any ref that actually belongs to another workspace whose branch
1416
+ * extends this one's (see otherWorkspaceBranches). */
1417
+ async function workspaceBranches(bare, branch, exclude) {
1418
+ return (await git([
1419
+ "for-each-ref",
1420
+ "--format=%(refname:short)",
1421
+ "refs/heads/"
1422
+ ], bare).catch(() => "")).split("\n").filter((b) => ownsBranch(b, branch) && !exclude.some((x) => ownsBranch(b, x)));
1423
+ }
1424
+ /** Delete a finished lane's local branch from the shared bare cache.
1425
+ *
1426
+ * Only a `-topic` lane, never the workspace branch itself: that one is
1427
+ * reused for the next change, so it outlives every PR opened from it.
1428
+ *
1429
+ * `headOid` is the finished PR's own head commit, and the local tip has to
1430
+ * still be sitting on it. That is what separates "this branch is the PR you
1431
+ * just finished with" from "this branch has commits you never pushed" — the
1432
+ * former stays reachable through the PR on GitHub (`refs/pull/N/head`
1433
+ * outlives both the merge and the branch, so a closed-unmerged lane is as
1434
+ * recoverable as a merged one), the latter would be gone for good. The
1435
+ * remote ref is not the test: GitHub prunes it on merge exactly when
1436
+ * pruning is safest.
1437
+ *
1438
+ * `-D` rather than `-d` because a squash-merged lane is not an ancestor of
1439
+ * its base, so git cannot see the merge that GitHub reports. It is also
1440
+ * what refuses when the lane is still checked out in a worktree — that
1441
+ * returns false rather than raising, a lane you are standing on being a
1442
+ * reason to keep it, not an error. */
1443
+ async function pruneLaneBranch(workspaceId, fullName, lane, headOid) {
1444
+ const ws = getWorkspace(workspaceId);
1445
+ if (!ws || !headOid || lane === ws.branch) return false;
1446
+ if (!ownsBranch(lane, ws.branch)) return false;
1447
+ if (otherWorkspaceBranches(workspaceId).some((b) => ownsBranch(lane, b))) return false;
1448
+ if (!ws.repos.some((r) => r.full_name === fullName)) return false;
1449
+ return withRepoLock(fullName, async () => {
1450
+ const bare = barePath(fullName);
1451
+ if (await revParse(bare, `refs/heads/${lane}`) !== headOid) return false;
1452
+ return git([
1453
+ "branch",
1454
+ "-D",
1455
+ lane
1456
+ ], bare).then(() => true, () => false);
1457
+ });
1458
+ }
1459
+ /** Directory/branch-safe prefix for a workspace name. Decomposing first keeps
1460
+ * accented Latin readable (é → e); a name with nothing ASCII left (another
1461
+ * script, emoji) falls back to a generic prefix — the random id is what makes
1462
+ * the name unique, so the slug never has to be rejected. */
1463
+ function slugify(name) {
1464
+ return name.normalize("NFKD").replace(/\p{M}+/gu, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "ws";
1465
+ }
1466
+ function randomId() {
1467
+ return Math.random().toString(36).slice(2, 8);
1468
+ }
1469
+ /** Sibling dir per repo; on basename collision fall back to owner-repo. */
1470
+ function assignSiblingNames(fullNames) {
1471
+ const byBase = /* @__PURE__ */ new Map();
1472
+ for (const fullName of fullNames) {
1473
+ const base = splitRepo(fullName).name.split("/")[1];
1474
+ byBase.set(base, [...byBase.get(base) || [], fullName]);
1475
+ }
1476
+ const result = /* @__PURE__ */ new Map();
1477
+ for (const [base, names] of byBase) for (const fullName of names) result.set(fullName, names.length > 1 ? splitRepo(fullName).name.replace("/", "-") : base);
1478
+ return result;
1479
+ }
1480
+ function writeAgentsMd(dir, branch, repos, origin = null) {
1481
+ const repoLines = repos.map((r) => `- \`${r.worktree}/\`: ${r.full_name} (base: \`${r.base_branch}\`)`).join("\n");
1482
+ const content = `# Workspace
1483
+
1484
+ Workspace branch: **\`${branch}\`**. Every repo here is checked out on it, and
1485
+ it is the required **prefix** for every other branch you create.
1486
+
1487
+ ${origin ? `- **Shared workspace:** \`${branch}\` is a lane on \`${origin}\`, another
1488
+ person's workspace branch. Every PR from here targets it
1489
+ (\`gh pr create --base ${origin}\`), and when \`origin/${origin}\` moves,
1490
+ rebase onto it. Never push to \`${origin}\` itself. Once \`${origin}\` has
1491
+ landed and is gone from origin: \`git fetch --prune\`, rebase onto
1492
+ \`origin/<base>\`, and retarget the PR (\`gh pr edit --base <base>\`).
1493
+ ` : ""}- **Branches:** stay on \`${branch}\` and keep pushing it: one open PR, more
1494
+ commits. After it lands, \`git fetch\` and reset \`${branch}\` to
1495
+ \`origin/<base>\` so the next change reuses this branch. Do not cut a
1496
+ \`${branch}-<topic>\` just to start the next piece of work.
1497
+ A topic lane is only for when \`${branch}\` must stay unmerged (a larger
1498
+ change) and you need a second PR on top of it. Then push every repo's
1499
+ workspace branch first, cut from the parent
1500
+ (\`git switch -c ${branch}-<topic> origin/${branch}\`), and keep the prefix.
1501
+ Only two levels: never \`${branch}-a-b\`. Tooling finds PRs by that prefix,
1502
+ so a branch named anything else is invisible: never invent one.
1503
+ - **Repos:**
1504
+ ${repoLines}
1505
+ - **Repo agents files:** you **MUST** read a repo's \`AGENTS.md\` (or
1506
+ \`CLAUDE.md\`) before doing any work in it. It is not loaded for you.
1507
+ - **No \`git stash\`:** every workspace using a repo shares one stash stack, so
1508
+ a pop can take another workspace's work. Commit as you go; uncommitted
1509
+ work isn't part of the review.
1510
+ - **Push:** \`git push -u origin HEAD\` in each repo, for review on GitHub.
1511
+ Never commit on a base branch.
1512
+ - **Rebase after merge:** when \`${branch}\` is still open and \`origin/<base>\`
1513
+ moves, rebase onto it. A squash-merge does not move the fork point, so the
1514
+ review keeps showing landed work until you do. On a topic lane, rebase onto
1515
+ \`${branch}\` when the parent moves; after the parent merges: \`git fetch
1516
+ --prune\`, rebase onto \`origin/<base>\`, and retarget (\`gh pr edit --base
1517
+ <base>\`).
1518
+ - **PRs:** the description covers only what is in the diff, a few sentences on
1519
+ what changed and why. No verification logs or file-by-file detail. A topic
1520
+ lane's PR is \`gh pr create --base ${branch}\`. GitHub will otherwise target
1521
+ the repo default, and the local review will disagree with the files tab.
1522
+ - **Ask \`work\` for facts:** the \`work\` CLI answers from the same data as
1523
+ the app: \`work workspace\` (this workspace: repos, base branches, open PRs,
1524
+ sessions, running procs/ports), \`work repos\`, \`work procs\`,
1525
+ \`work review\` (review comments the user left on the diff and has not
1526
+ sent yet, as markdown), \`work workspaces\`, \`work doctor\` (installed
1527
+ tools / agents / GitHub sign-in), \`work usage\` (agent plan quotas). Add
1528
+ \`--json\` for machine output. Prefer these over guessing from the
1529
+ filesystem or calling GitHub.
1530
+ `;
1531
+ fs.writeFileSync(path.join(dir, "AGENTS.md"), content);
1532
+ const claudeMd = path.join(dir, "CLAUDE.md");
1533
+ if (!fs.existsSync(claudeMd)) fs.symlinkSync("AGENTS.md", claudeMd);
1534
+ }
1535
+ //#endregion
1536
+ //#region src/server/auto-push.ts
1537
+ /** Auto-push: every commit on a workspace branch (or one of its lanes)
1538
+ * goes to origin on its own, a few seconds after it lands. What it buys is
1539
+ * that a workspace is always recoverable from a backup manifest — the
1540
+ * branch is the only copy of the work, and an export that finds unpushed
1541
+ * commits can only warn.
1542
+ *
1543
+ * Signal comes from the bare cache's reference-transaction hook (see
1544
+ * repo-events.ts): worktrees share the bare's ref store, so a commit in
1545
+ * any of them updates `refs/heads/<branch>` there. Pushes run from the
1546
+ * bare. A fast-forward always goes. A rewrite (rebase, amend, the reset
1547
+ * after a squash-merge) goes only when origin's tip is one this machine
1548
+ * put there — `--force-with-lease` against the remote-tracking ref is not
1549
+ * enough on its own, since any fetch refreshes that ref to whatever
1550
+ * someone else pushed, and a lease that matches it would overwrite them. */
1551
+ function autoPushEnabled() {
1552
+ return getSetting("autoPush") !== "0";
1553
+ }
1554
+ function setAutoPush(on) {
1555
+ setSetting("autoPush", on ? "1" : "0");
1556
+ }
1557
+ /** A commit is usually the first of several (an agent commits, amends,
1558
+ * rebases); wait for the burst to settle before pushing once. */
1559
+ var SETTLE_MS = 2500;
1560
+ var timers = /* @__PURE__ */ new Map();
1561
+ /** Failed pushes, `fullName#branch` → why. Cleared by the next success. */
1562
+ var failures = /* @__PURE__ */ new Map();
1563
+ /** One push per branch at a time; a hook that lands mid-push queues one
1564
+ * more run rather than racing it (two pushes with the same lease → the
1565
+ * second is rejected as stale, and the branch shows as unpushed). */
1566
+ var inflight = /* @__PURE__ */ new Map();
1567
+ var rerun = /* @__PURE__ */ new Set();
1568
+ /** Keys the hook must ignore until the time given: a restore rebases the
1569
+ * branch it checks out, and that rewrite is not something to publish. */
1570
+ var muted = /* @__PURE__ */ new Map();
1571
+ var key = (fullName, branch) => `${fullName}#${branch}`;
1572
+ /** Origin tips this machine put there, `fullName#branch` → oid. Kept in
1573
+ * the settings table so a restart still knows which rewrites are ours.
1574
+ * Bounded by the number of branches ever pushed; entries go when the
1575
+ * branch does. */
1576
+ var PUSHED_KEY = "autoPush.pushed";
1577
+ function pushedTips() {
1578
+ try {
1579
+ return JSON.parse(getSetting(PUSHED_KEY) || "{}");
1580
+ } catch {
1581
+ return {};
1582
+ }
1583
+ }
1584
+ function rememberPushed(k, oid) {
1585
+ const tips = pushedTips();
1586
+ if (oid) tips[k] = oid;
1587
+ else delete tips[k];
1588
+ setSetting(PUSHED_KEY, JSON.stringify(tips));
1589
+ }
1590
+ /** The workspace whose branch (or lane) `branch` is, in `fullName` — the
1591
+ * most specific owner, since one workspace's branch can prefix another's. */
1592
+ function ownerOf(fullName, branch) {
1593
+ let best;
1594
+ for (const ws of listWorkspaces()) {
1595
+ if (!ws.repos.some((r) => r.full_name === fullName)) continue;
1596
+ if (!ownsBranch(branch, ws.branch)) continue;
1597
+ if (!best || ws.branch.length > best.branch.length) best = ws;
1598
+ }
1599
+ return best;
1600
+ }
1601
+ /** Refs a branch with no commits of its own would equal: its repo base,
1602
+ * and the workspace branch a lane was cut from. */
1603
+ function basesFor(ws, fullName) {
1604
+ return [ws.repos.find((r) => r.full_name === fullName)?.base_branch, ws.branch].filter((b) => !!b);
1605
+ }
1606
+ /** Ignore the hook for this branch for a moment — around a checkout that
1607
+ * rewrites it (restore's rebase onto the base). */
1608
+ function muteAutoPush(fullName, branch, ms = 1e4) {
1609
+ const k = key(fullName, branch);
1610
+ muted.set(k, Date.now() + ms);
1611
+ clearTimeout(timers.get(k));
1612
+ timers.delete(k);
1613
+ }
1614
+ /** Local branch heads the hook reported for `fullName`. */
1615
+ function noteLocalRefs(fullName, refs) {
1616
+ if (!autoPushEnabled()) return;
1617
+ for (const ref of refs) {
1618
+ if (!ref.startsWith("refs/heads/")) continue;
1619
+ const branch = ref.slice(11);
1620
+ const k = key(fullName, branch);
1621
+ const until = muted.get(k);
1622
+ if (until && until > Date.now()) continue;
1623
+ muted.delete(k);
1624
+ const ws = ownerOf(fullName, branch);
1625
+ if (!ws) continue;
1626
+ const bases = basesFor(ws, fullName);
1627
+ clearTimeout(timers.get(k));
1628
+ timers.set(k, setTimeout(() => {
1629
+ timers.delete(k);
1630
+ if (autoPushEnabled()) pushBranch(fullName, branch, bases);
1631
+ }, SETTLE_MS));
1632
+ }
1633
+ }
1634
+ /** The hook saw `refs/remotes/origin/<branch>` move to `oid` — a push from
1635
+ * a worktree, or a fetch. A hand push that put the local tip up clears a
1636
+ * recorded failure (and counts as ours for the next rewrite). */
1637
+ async function noteRemoteRef(fullName, branch, oid) {
1638
+ const k = key(fullName, branch);
1639
+ if (await revParse(barePath(fullName), `refs/heads/${branch}`) !== oid) return;
1640
+ rememberPushed(k, oid);
1641
+ if (failures.delete(k)) {
1642
+ const { pokeStatusWatch } = await import("./_Bjh5JEm7.js");
1643
+ pokeStatusWatch();
1644
+ }
1645
+ }
1646
+ /** Push one branch from the bare cache. Returns the failure text, or null
1647
+ * when the branch is up (or has nothing of its own yet). */
1648
+ function pushBranch(fullName, branch, bases = []) {
1649
+ const k = key(fullName, branch);
1650
+ const running = inflight.get(k);
1651
+ if (running) {
1652
+ rerun.add(k);
1653
+ return running;
1654
+ }
1655
+ const run = withRepoLock(fullName, () => pushOnce(fullName, branch, bases)).finally(() => {
1656
+ inflight.delete(k);
1657
+ }).then((result) => {
1658
+ if (!rerun.delete(k)) return result;
1659
+ return pushBranch(fullName, branch, bases);
1660
+ });
1661
+ inflight.set(k, run);
1662
+ return run;
1663
+ }
1664
+ async function pushOnce(fullName, branch, bases) {
1665
+ const bare = barePath(fullName);
1666
+ const k = key(fullName, branch);
1667
+ const local = await revParse(bare, `refs/heads/${branch}`);
1668
+ if (!local) {
1669
+ failures.delete(k);
1670
+ rememberPushed(k, null);
1671
+ return null;
1672
+ }
1673
+ const remote = await revParse(bare, `refs/remotes/origin/${branch}`);
1674
+ if (remote === local) {
1675
+ failures.delete(k);
1676
+ return null;
1677
+ }
1678
+ if (!remote) {
1679
+ for (const base of bases) if (await revParse(bare, `refs/remotes/origin/${base}`) === local) return null;
1680
+ }
1681
+ if (remote && remote !== pushedTips()[k]) {
1682
+ if (!await git([
1683
+ "merge-base",
1684
+ "--is-ancestor",
1685
+ remote,
1686
+ local
1687
+ ], bare).then(() => true, () => false)) return fail(k, `origin/${branch} has commits this machine did not push — pull them in, then push by hand`);
1688
+ }
1689
+ try {
1690
+ await git([
1691
+ "push",
1692
+ "--quiet",
1693
+ `--force-with-lease=refs/heads/${branch}:${remote ?? ""}`,
1694
+ "origin",
1695
+ `refs/heads/${branch}:refs/heads/${branch}`
1696
+ ], bare);
1697
+ failures.delete(k);
1698
+ rememberPushed(k, local);
1699
+ return null;
1700
+ } catch (err) {
1701
+ const lines = (err instanceof Error ? err.message : String(err)).split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("Command failed"));
1702
+ return fail(k, lines.find((l) => /^!\s*\[rejected\]|\(stale info\)/.test(l)) || lines.pop() || "push failed");
1703
+ }
1704
+ }
1705
+ async function fail(k, why) {
1706
+ failures.set(k, why);
1707
+ const { pokeStatusWatch } = await import("./_Bjh5JEm7.js");
1708
+ pokeStatusWatch();
1709
+ return why;
1710
+ }
1711
+ /** Push every branch a workspace owns in one repo. Used by the backup
1712
+ * export, which needs everything on origin before a manifest means
1713
+ * anything. Returns the branches that still failed. */
1714
+ async function pushWorkspaceRepo(ws, fullName, foreign, log) {
1715
+ const bare = barePath(fullName);
1716
+ const bases = basesFor(ws, fullName);
1717
+ const failed = [];
1718
+ for (const branch of await workspaceBranches(bare, ws.branch, foreign)) {
1719
+ log?.(`Pushing ${branch}…`);
1720
+ const error = await pushBranch(fullName, branch, bases);
1721
+ if (error) failed.push({
1722
+ branch,
1723
+ error
1724
+ });
1725
+ }
1726
+ return failed;
1727
+ }
1728
+ /** The failure to show on a workspace's repo row, if any of its branches
1729
+ * could not be pushed. */
1730
+ function pushFailureFor(fullName, branch, foreign) {
1731
+ for (const [k, why] of failures) {
1732
+ const sep = k.indexOf("#");
1733
+ if (k.slice(0, sep) !== fullName) continue;
1734
+ const b = k.slice(sep + 1);
1735
+ if (ownsBranch(b, branch) && !foreign.some((x) => ownsBranch(b, x))) return why;
1736
+ }
1737
+ }
1738
+ //#endregion
1739
+ export { portsByWorkspace as A, withLock as B, revParse as C, ensureAllPushHooks as D, workspaceBranches as E, repoBranches as F, ensureGitCredentials as G, addHost as H, repoCatalog as I, hostToken as J, forgetToken as K, repoDefaultBranch as L, headOid as M, remoteHasHeads as N, hookToken as O, pendingSsoOrgs as P, ownsBranch as Q, repoParent as R, resumePendingWorkspaces as S, withProgressOp as T, cloneUrl as U, DEFAULT_HOST as V, configuredHosts as W, removeHost as X, joinRepo as Y, splitRepo as Z, progress as _, pushBranch as a, queueMaterialize as b, setAutoPush as c, createWorkspace as d, deleteWorkspace as f, otherWorkspaceBranches as g, listWorkspaces as h, noteRemoteRef as i, workspaceProcs as j, killWorkspaceProc as k, addRepo as l, insertPendingWorkspace as m, muteAutoPush as n, pushFailureFor as o, getWorkspace as p, graphqlUrl as q, noteLocalRefs as r, pushWorkspaceRepo as s, autoPushEnabled as t, barePath as u, progressSince as v, setWorkspaceStatus as w, removeRepo as x, pruneLaneBranch as y, swr as z };