@recombine-ai/bosun 0.16.2 → 0.17.1

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 (524) hide show
  1. package/README.md +24 -0
  2. package/bin/bosun.js +153 -20
  3. package/bin/git-mode.js +1139 -0
  4. package/build/client/_app/immutable/assets/0.D9HpHezk.css +1 -0
  5. package/build/client/_app/immutable/assets/0.D9HpHezk.css.br +0 -0
  6. package/build/client/_app/immutable/assets/0.D9HpHezk.css.gz +0 -0
  7. package/build/client/_app/immutable/assets/{2.DtxQDOXx.css → 2.Bc0Sf3YD.css} +1 -1
  8. package/build/client/_app/immutable/assets/2.Bc0Sf3YD.css.br +0 -0
  9. package/build/client/_app/immutable/assets/2.Bc0Sf3YD.css.gz +0 -0
  10. package/build/client/_app/immutable/chunks/{DneQQ7D5.js → -3ZOIHDA.js} +1 -1
  11. package/build/client/_app/immutable/chunks/-3ZOIHDA.js.br +0 -0
  12. package/build/client/_app/immutable/chunks/-3ZOIHDA.js.gz +0 -0
  13. package/build/client/_app/immutable/chunks/7hV1SZxK.js +1 -0
  14. package/build/client/_app/immutable/chunks/7hV1SZxK.js.br +2 -0
  15. package/build/client/_app/immutable/chunks/7hV1SZxK.js.gz +0 -0
  16. package/build/client/_app/immutable/chunks/B-dTD_wm.js +1 -0
  17. package/build/client/_app/immutable/chunks/B-dTD_wm.js.br +0 -0
  18. package/build/client/_app/immutable/chunks/B-dTD_wm.js.gz +0 -0
  19. package/build/client/_app/immutable/chunks/{UqjCSZmA.js → B-iasAJv.js} +1 -1
  20. package/build/client/_app/immutable/chunks/B-iasAJv.js.br +0 -0
  21. package/build/client/_app/immutable/chunks/B-iasAJv.js.gz +0 -0
  22. package/build/client/_app/immutable/chunks/BDijIo6D.js +33 -0
  23. package/build/client/_app/immutable/chunks/BDijIo6D.js.br +0 -0
  24. package/build/client/_app/immutable/chunks/BDijIo6D.js.gz +0 -0
  25. package/build/client/_app/immutable/chunks/BHB0Pj1h.js +2 -0
  26. package/build/client/_app/immutable/chunks/BHB0Pj1h.js.br +0 -0
  27. package/build/client/_app/immutable/chunks/BHB0Pj1h.js.gz +0 -0
  28. package/build/client/_app/immutable/chunks/{Bog028VR.js → BKfmN1Rj.js} +1 -1
  29. package/build/client/_app/immutable/chunks/BKfmN1Rj.js.br +0 -0
  30. package/build/client/_app/immutable/chunks/BKfmN1Rj.js.gz +0 -0
  31. package/build/client/_app/immutable/chunks/{pPpIn6tB.js → BlFOg-Sd.js} +1 -1
  32. package/build/client/_app/immutable/chunks/BlFOg-Sd.js.br +0 -0
  33. package/build/client/_app/immutable/chunks/BlFOg-Sd.js.gz +0 -0
  34. package/build/client/_app/immutable/chunks/{CJcZ2GVC.js → Byg3jr1u.js} +1 -1
  35. package/build/client/_app/immutable/chunks/Byg3jr1u.js.br +0 -0
  36. package/build/client/_app/immutable/chunks/Byg3jr1u.js.gz +0 -0
  37. package/build/client/_app/immutable/chunks/{Bmd5zhWC.js → BzxxLgHi.js} +1 -1
  38. package/build/client/_app/immutable/chunks/BzxxLgHi.js.br +0 -0
  39. package/build/client/_app/immutable/chunks/BzxxLgHi.js.gz +0 -0
  40. package/build/client/_app/immutable/chunks/{CEHsomMo.js → C4UC6AgE.js} +1 -1
  41. package/build/client/_app/immutable/chunks/C4UC6AgE.js.br +0 -0
  42. package/build/client/_app/immutable/chunks/{CEHsomMo.js.gz → C4UC6AgE.js.gz} +0 -0
  43. package/build/client/_app/immutable/chunks/C60AG6QQ.js +1 -0
  44. package/build/client/_app/immutable/chunks/C60AG6QQ.js.br +0 -0
  45. package/build/client/_app/immutable/chunks/C60AG6QQ.js.gz +0 -0
  46. package/build/client/_app/immutable/chunks/CAIfVN23.js +1 -0
  47. package/build/client/_app/immutable/chunks/CAIfVN23.js.br +0 -0
  48. package/build/client/_app/immutable/chunks/CAIfVN23.js.gz +0 -0
  49. package/build/client/_app/immutable/chunks/{JcG4w2gF.js → CB9iDxwI.js} +1 -1
  50. package/build/client/_app/immutable/chunks/CB9iDxwI.js.br +0 -0
  51. package/build/client/_app/immutable/chunks/CB9iDxwI.js.gz +0 -0
  52. package/build/client/_app/immutable/chunks/{DoD3q5w5.js → CD_KdWs9.js} +1 -1
  53. package/build/client/_app/immutable/chunks/CD_KdWs9.js.br +0 -0
  54. package/build/client/_app/immutable/chunks/CD_KdWs9.js.gz +0 -0
  55. package/build/client/_app/immutable/chunks/CDy1LBR5.js +1 -0
  56. package/build/client/_app/immutable/chunks/CDy1LBR5.js.br +0 -0
  57. package/build/client/_app/immutable/chunks/CDy1LBR5.js.gz +0 -0
  58. package/build/client/_app/immutable/chunks/{DCXjBDmY.js → CMFNmSvo.js} +1 -1
  59. package/build/client/_app/immutable/chunks/CMFNmSvo.js.br +0 -0
  60. package/build/client/_app/immutable/chunks/CMFNmSvo.js.gz +0 -0
  61. package/build/client/_app/immutable/chunks/{Bv1mNXs0.js → CNIn4L55.js} +1 -1
  62. package/build/client/_app/immutable/chunks/CNIn4L55.js.br +0 -0
  63. package/build/client/_app/immutable/chunks/CNIn4L55.js.gz +0 -0
  64. package/build/client/_app/immutable/chunks/CUKoVL36.js +1 -0
  65. package/build/client/_app/immutable/chunks/CUKoVL36.js.br +0 -0
  66. package/build/client/_app/immutable/chunks/CUKoVL36.js.gz +0 -0
  67. package/build/client/_app/immutable/chunks/CYGbCr1i.js +3 -0
  68. package/build/client/_app/immutable/chunks/CYGbCr1i.js.br +0 -0
  69. package/build/client/_app/immutable/chunks/CYGbCr1i.js.gz +0 -0
  70. package/build/client/_app/immutable/chunks/CYr0FGgn.js +1 -0
  71. package/build/client/_app/immutable/chunks/CYr0FGgn.js.br +0 -0
  72. package/build/client/_app/immutable/chunks/CYr0FGgn.js.gz +0 -0
  73. package/build/client/_app/immutable/chunks/{DtXXy94i.js → Ce6a22qU.js} +1 -1
  74. package/build/client/_app/immutable/chunks/Ce6a22qU.js.br +0 -0
  75. package/build/client/_app/immutable/chunks/Ce6a22qU.js.gz +0 -0
  76. package/build/client/_app/immutable/chunks/{DfcDjAv-.js → CeoQ-OhK.js} +1 -1
  77. package/build/client/_app/immutable/chunks/CeoQ-OhK.js.br +0 -0
  78. package/build/client/_app/immutable/chunks/CeoQ-OhK.js.gz +0 -0
  79. package/build/client/_app/immutable/chunks/{D8mA-uZ4.js → CupXtwVS.js} +1 -1
  80. package/build/client/_app/immutable/chunks/CupXtwVS.js.br +0 -0
  81. package/build/client/_app/immutable/chunks/CupXtwVS.js.gz +0 -0
  82. package/build/client/_app/immutable/chunks/{B5zf3x62.js → Cy2Uze-c.js} +1 -1
  83. package/build/client/_app/immutable/chunks/Cy2Uze-c.js.br +0 -0
  84. package/build/client/_app/immutable/chunks/Cy2Uze-c.js.gz +0 -0
  85. package/build/client/_app/immutable/chunks/{Dg0A8wPA.js → D8Lmn3S5.js} +1 -1
  86. package/build/client/_app/immutable/chunks/D8Lmn3S5.js.br +0 -0
  87. package/build/client/_app/immutable/chunks/D8Lmn3S5.js.gz +0 -0
  88. package/build/client/_app/immutable/chunks/D8doM1Y6.js +1 -0
  89. package/build/client/_app/immutable/chunks/D8doM1Y6.js.br +2 -0
  90. package/build/client/_app/immutable/chunks/D8doM1Y6.js.gz +0 -0
  91. package/build/client/_app/immutable/chunks/{BHHfrjbV.js → D9BWOD7V.js} +1 -1
  92. package/build/client/_app/immutable/chunks/D9BWOD7V.js.br +3 -0
  93. package/build/client/_app/immutable/chunks/D9BWOD7V.js.gz +0 -0
  94. package/build/client/_app/immutable/chunks/{Cp_st-64.js → DBB88ZB7.js} +1 -1
  95. package/build/client/_app/immutable/chunks/DBB88ZB7.js.br +2 -0
  96. package/build/client/_app/immutable/chunks/DBB88ZB7.js.gz +0 -0
  97. package/build/client/_app/immutable/chunks/{Dm49pcMm.js → DC7lPrn4.js} +1 -1
  98. package/build/client/_app/immutable/chunks/DC7lPrn4.js.br +0 -0
  99. package/build/client/_app/immutable/chunks/DC7lPrn4.js.gz +0 -0
  100. package/build/client/_app/immutable/chunks/DCwWNTpf.js +9 -0
  101. package/build/client/_app/immutable/chunks/DCwWNTpf.js.br +0 -0
  102. package/build/client/_app/immutable/chunks/DCwWNTpf.js.gz +0 -0
  103. package/build/client/_app/immutable/chunks/{wcTQUnZO.js → DEnUeQNt.js} +1 -1
  104. package/build/client/_app/immutable/chunks/DEnUeQNt.js.br +0 -0
  105. package/build/client/_app/immutable/chunks/DEnUeQNt.js.gz +0 -0
  106. package/build/client/_app/immutable/chunks/D_laxq3h.js +1 -0
  107. package/build/client/_app/immutable/chunks/D_laxq3h.js.br +0 -0
  108. package/build/client/_app/immutable/chunks/D_laxq3h.js.gz +0 -0
  109. package/build/client/_app/immutable/chunks/{Ddv5LgEc.js → DfkYlKBk.js} +1 -1
  110. package/build/client/_app/immutable/chunks/DfkYlKBk.js.br +0 -0
  111. package/build/client/_app/immutable/chunks/DfkYlKBk.js.gz +0 -0
  112. package/build/client/_app/immutable/chunks/DnMS2X6M.js +2 -0
  113. package/build/client/_app/immutable/chunks/DnMS2X6M.js.br +0 -0
  114. package/build/client/_app/immutable/chunks/DnMS2X6M.js.gz +0 -0
  115. package/build/client/_app/immutable/chunks/{B1HdnQRQ.js → DrE1ro9S.js} +1 -1
  116. package/build/client/_app/immutable/chunks/DrE1ro9S.js.br +0 -0
  117. package/build/client/_app/immutable/chunks/DrE1ro9S.js.gz +0 -0
  118. package/build/client/_app/immutable/chunks/DzY_RPb9.js +1 -0
  119. package/build/client/_app/immutable/chunks/DzY_RPb9.js.br +0 -0
  120. package/build/client/_app/immutable/chunks/DzY_RPb9.js.gz +0 -0
  121. package/build/client/_app/immutable/chunks/PrCSpCPg.js +1 -0
  122. package/build/client/_app/immutable/chunks/PrCSpCPg.js.br +0 -0
  123. package/build/client/_app/immutable/chunks/PrCSpCPg.js.gz +0 -0
  124. package/build/client/_app/immutable/chunks/{CN555stc.js → eMofpOrR.js} +1 -1
  125. package/build/client/_app/immutable/chunks/eMofpOrR.js.br +0 -0
  126. package/build/client/_app/immutable/chunks/eMofpOrR.js.gz +0 -0
  127. package/build/client/_app/immutable/entry/app.BQUuHkSR.js +2 -0
  128. package/build/client/_app/immutable/entry/app.BQUuHkSR.js.br +0 -0
  129. package/build/client/_app/immutable/entry/app.BQUuHkSR.js.gz +0 -0
  130. package/build/client/_app/immutable/entry/start.DoeTycdu.js +1 -0
  131. package/build/client/_app/immutable/entry/start.DoeTycdu.js.br +2 -0
  132. package/build/client/_app/immutable/entry/start.DoeTycdu.js.gz +0 -0
  133. package/build/client/_app/immutable/nodes/0.DBnFpwRM.js +4 -0
  134. package/build/client/_app/immutable/nodes/0.DBnFpwRM.js.br +0 -0
  135. package/build/client/_app/immutable/nodes/0.DBnFpwRM.js.gz +0 -0
  136. package/build/client/_app/immutable/nodes/1.D-kI-fLd.js +1 -0
  137. package/build/client/_app/immutable/nodes/1.D-kI-fLd.js.br +0 -0
  138. package/build/client/_app/immutable/nodes/1.D-kI-fLd.js.gz +0 -0
  139. package/build/client/_app/immutable/nodes/10.DWb5H7vX.js +28 -0
  140. package/build/client/_app/immutable/nodes/10.DWb5H7vX.js.br +0 -0
  141. package/build/client/_app/immutable/nodes/10.DWb5H7vX.js.gz +0 -0
  142. package/build/client/_app/immutable/nodes/{15.D2I0y3ys.js → 15.CGc3_07c.js} +1 -1
  143. package/build/client/_app/immutable/nodes/15.CGc3_07c.js.br +3 -0
  144. package/build/client/_app/immutable/nodes/15.CGc3_07c.js.gz +0 -0
  145. package/build/client/_app/immutable/nodes/17.BIba62hW.js +3 -0
  146. package/build/client/_app/immutable/nodes/17.BIba62hW.js.br +0 -0
  147. package/build/client/_app/immutable/nodes/17.BIba62hW.js.gz +0 -0
  148. package/build/client/_app/immutable/nodes/18.Clxr6FwI.js +11 -0
  149. package/build/client/_app/immutable/nodes/18.Clxr6FwI.js.br +0 -0
  150. package/build/client/_app/immutable/nodes/18.Clxr6FwI.js.gz +0 -0
  151. package/build/client/_app/immutable/nodes/2.cbpnR8be.js +35 -0
  152. package/build/client/_app/immutable/nodes/2.cbpnR8be.js.br +0 -0
  153. package/build/client/_app/immutable/nodes/2.cbpnR8be.js.gz +0 -0
  154. package/build/client/_app/immutable/nodes/3.DsP759xk.js +1 -0
  155. package/build/client/_app/immutable/nodes/3.DsP759xk.js.br +0 -0
  156. package/build/client/_app/immutable/nodes/3.DsP759xk.js.gz +0 -0
  157. package/build/client/_app/immutable/nodes/{4.C8VxVbVn.js → 4.BIGLAIDe.js} +1 -1
  158. package/build/client/_app/immutable/nodes/4.BIGLAIDe.js.br +0 -0
  159. package/build/client/_app/immutable/nodes/4.BIGLAIDe.js.gz +0 -0
  160. package/build/client/_app/immutable/nodes/{5.BjrnKYaA.js → 5.zMHOyhDv.js} +1 -1
  161. package/build/client/_app/immutable/nodes/5.zMHOyhDv.js.br +0 -0
  162. package/build/client/_app/immutable/nodes/5.zMHOyhDv.js.gz +0 -0
  163. package/build/client/_app/immutable/nodes/6.DFQ1IZsQ.js +1 -0
  164. package/build/client/_app/immutable/nodes/6.DFQ1IZsQ.js.br +0 -0
  165. package/build/client/_app/immutable/nodes/6.DFQ1IZsQ.js.gz +0 -0
  166. package/build/client/_app/immutable/nodes/8.CTgEdr7L.js +2 -0
  167. package/build/client/_app/immutable/nodes/8.CTgEdr7L.js.br +0 -0
  168. package/build/client/_app/immutable/nodes/8.CTgEdr7L.js.gz +0 -0
  169. package/build/client/_app/immutable/nodes/9.BidF5Uvq.js +2 -0
  170. package/build/client/_app/immutable/nodes/9.BidF5Uvq.js.br +0 -0
  171. package/build/client/_app/immutable/nodes/9.BidF5Uvq.js.gz +0 -0
  172. package/build/client/_app/version.json +1 -1
  173. package/build/client/_app/version.json.br +0 -0
  174. package/build/client/_app/version.json.gz +0 -0
  175. package/build/server/chunks/0-BZOFUIfF.js +9 -0
  176. package/build/server/chunks/0-BZOFUIfF.js.map +1 -0
  177. package/build/server/chunks/1-XwGU5VVN.js +9 -0
  178. package/build/server/chunks/{1-C0HdTvXW.js.map → 1-XwGU5VVN.js.map} +1 -1
  179. package/build/server/chunks/10-BgFSx4ib.js +9 -0
  180. package/build/server/chunks/10-BgFSx4ib.js.map +1 -0
  181. package/build/server/chunks/15-BjTTC1jf.js +9 -0
  182. package/build/server/chunks/{15-BQcb9C_y.js.map → 15-BjTTC1jf.js.map} +1 -1
  183. package/build/server/chunks/17-BEkWCfzc.js +9 -0
  184. package/build/server/chunks/17-BEkWCfzc.js.map +1 -0
  185. package/build/server/chunks/18-C-S0wKdp.js +9 -0
  186. package/build/server/chunks/18-C-S0wKdp.js.map +1 -0
  187. package/build/server/chunks/2-o-cIkIjs.js +9 -0
  188. package/build/server/chunks/2-o-cIkIjs.js.map +1 -0
  189. package/build/server/chunks/3-B-ABlyFO.js +9 -0
  190. package/build/server/chunks/3-B-ABlyFO.js.map +1 -0
  191. package/build/server/chunks/4-joAFA_Y8.js +9 -0
  192. package/build/server/chunks/4-joAFA_Y8.js.map +1 -0
  193. package/build/server/chunks/5-DIhC9k4l.js +9 -0
  194. package/build/server/chunks/5-DIhC9k4l.js.map +1 -0
  195. package/build/server/chunks/6-CMuAQIcW.js +9 -0
  196. package/build/server/chunks/6-CMuAQIcW.js.map +1 -0
  197. package/build/server/chunks/8-CbETTVRG.js +9 -0
  198. package/build/server/chunks/8-CbETTVRG.js.map +1 -0
  199. package/build/server/chunks/9-nMV90uVU.js +9 -0
  200. package/build/server/chunks/9-nMV90uVU.js.map +1 -0
  201. package/build/server/chunks/{AgentSidebar-BWBnqHnV.js → AgentSidebar-BEbSdZdN.js} +4 -4
  202. package/build/server/chunks/{AgentSidebar-BWBnqHnV.js.map → AgentSidebar-BEbSdZdN.js.map} +1 -1
  203. package/build/server/chunks/{Button-DH7gdU0s.js → Button-Dc9CR4Wh.js} +2 -2
  204. package/build/server/chunks/{Button-DH7gdU0s.js.map → Button-Dc9CR4Wh.js.map} +1 -1
  205. package/build/server/chunks/{ChatStepTrace-BivoNYOY.js → ChatStepTrace-OM96X4g8.js} +18 -7
  206. package/build/server/chunks/ChatStepTrace-OM96X4g8.js.map +1 -0
  207. package/build/server/chunks/{Dropdown-DnARl86Q.js → Dropdown-Cgd7pG-k.js} +2 -2
  208. package/build/server/chunks/{Dropdown-DnARl86Q.js.map → Dropdown-Cgd7pG-k.js.map} +1 -1
  209. package/build/server/chunks/GitControlsButton-BLcNzrDt.js +81 -0
  210. package/build/server/chunks/GitControlsButton-BLcNzrDt.js.map +1 -0
  211. package/build/server/chunks/{Icon-DP1YKCRV.js → Icon-Bogvl2n9.js} +2 -2
  212. package/build/server/chunks/{Icon-DP1YKCRV.js.map → Icon-Bogvl2n9.js.map} +1 -1
  213. package/build/server/chunks/{Input-CUJ9Cg7b.js → Input-7hpw1WLW.js} +2 -2
  214. package/build/server/chunks/{Input-CUJ9Cg7b.js.map → Input-7hpw1WLW.js.map} +1 -1
  215. package/build/server/chunks/{Modal-CGJuNpZ0.js → Modal-7IIIfFEG.js} +5 -5
  216. package/build/server/chunks/{Modal-CGJuNpZ0.js.map → Modal-7IIIfFEG.js.map} +1 -1
  217. package/build/server/chunks/{Panel-zvjxe036.js → Panel-Cj1mXx9-.js} +2 -2
  218. package/build/server/chunks/{Panel-zvjxe036.js.map → Panel-Cj1mXx9-.js.map} +1 -1
  219. package/build/server/chunks/{Tab-CUkyLqbN.js → Tab-bf4ZDs5y.js} +4 -4
  220. package/build/server/chunks/{Tab-CUkyLqbN.js.map → Tab-bf4ZDs5y.js.map} +1 -1
  221. package/build/server/chunks/{_layout.svelte-BnrWYuLv.js → _layout.svelte-C71luIwv.js} +10 -10
  222. package/build/server/chunks/{_layout.svelte-BnrWYuLv.js.map → _layout.svelte-C71luIwv.js.map} +1 -1
  223. package/build/server/chunks/{_layout.svelte-wi6o5Ecu.js → _layout.svelte-CFhHgXI_.js} +24 -18
  224. package/build/server/chunks/_layout.svelte-CFhHgXI_.js.map +1 -0
  225. package/build/server/chunks/_layout.svelte-CJd_pmkx.js +431 -0
  226. package/build/server/chunks/_layout.svelte-CJd_pmkx.js.map +1 -0
  227. package/build/server/chunks/{_layout.svelte-B88WMyk0.js → _layout.svelte-D6rhwm98.js} +445 -70
  228. package/build/server/chunks/_layout.svelte-D6rhwm98.js.map +1 -0
  229. package/build/server/chunks/{_page.svelte-CeS_21Ho.js → _page.svelte-BBvDMiTV.js} +25 -21
  230. package/build/server/chunks/_page.svelte-BBvDMiTV.js.map +1 -0
  231. package/build/server/chunks/{_page.svelte-h_J4lNT9.js → _page.svelte-BQs9dEuq.js} +8 -7
  232. package/build/server/chunks/{_page.svelte-h_J4lNT9.js.map → _page.svelte-BQs9dEuq.js.map} +1 -1
  233. package/build/server/chunks/{_page.svelte-DhFNFMnl.js → _page.svelte-BoO2LWVX.js} +34 -14
  234. package/build/server/chunks/_page.svelte-BoO2LWVX.js.map +1 -0
  235. package/build/server/chunks/{_page.svelte-CHgQjBQj.js → _page.svelte-DUCTSdv-.js} +2 -2
  236. package/build/server/chunks/{_page.svelte-CHgQjBQj.js.map → _page.svelte-DUCTSdv-.js.map} +1 -1
  237. package/build/server/chunks/_page.svelte-FXYLKyMw.js +110 -0
  238. package/build/server/chunks/_page.svelte-FXYLKyMw.js.map +1 -0
  239. package/build/server/chunks/{_page.svelte-57pyhszV.js → _page.svelte-KA1hqKj2.js} +20 -107
  240. package/build/server/chunks/_page.svelte-KA1hqKj2.js.map +1 -0
  241. package/build/server/chunks/{_page.svelte-BSVJ2d2G.js → _page.svelte-y8CZFL8a.js} +210 -106
  242. package/build/server/chunks/_page.svelte-y8CZFL8a.js.map +1 -0
  243. package/build/server/chunks/{_page.svelte-DHSYVLoj.js → _page.svelte-yIHSIEW6.js} +21 -15
  244. package/build/server/chunks/_page.svelte-yIHSIEW6.js.map +1 -0
  245. package/build/server/chunks/_server.ts-Cp9DgLcE.js +336 -0
  246. package/build/server/chunks/_server.ts-Cp9DgLcE.js.map +1 -0
  247. package/build/server/chunks/{agents-context-CbFsZmi5.js → agents-context-owgusUPR.js} +2 -2
  248. package/build/server/chunks/{agents-context-CbFsZmi5.js.map → agents-context-owgusUPR.js.map} +1 -1
  249. package/build/server/chunks/{client-DO-hqMbT.js → client-P-7bDULt.js} +4 -4
  250. package/build/server/chunks/{client-DO-hqMbT.js.map → client-P-7bDULt.js.map} +1 -1
  251. package/build/server/chunks/{conversation-view-memory-CtbUZd_L.js → conversation-view-memory-SdpdqTR7.js} +2 -2
  252. package/build/server/chunks/{conversation-view-memory-CtbUZd_L.js.map → conversation-view-memory-SdpdqTR7.js.map} +1 -1
  253. package/build/server/chunks/{edit-workspace-context-tyHBPujC.js → edit-workspace-context-hanFnXR3.js} +2 -2
  254. package/build/server/chunks/{edit-workspace-context-tyHBPujC.js.map → edit-workspace-context-hanFnXR3.js.map} +1 -1
  255. package/build/server/chunks/error.svelte-yI9RS6H4.js +15 -0
  256. package/build/server/chunks/{error.svelte-DmKKN1RU.js.map → error.svelte-yI9RS6H4.js.map} +1 -1
  257. package/build/server/chunks/git-mode-context-BpNsAUGF.js +18 -0
  258. package/build/server/chunks/git-mode-context-BpNsAUGF.js.map +1 -0
  259. package/build/server/chunks/{index-LMsd1ih2.js → index-DGtbUu8Y.js} +2 -2
  260. package/build/server/chunks/{index-LMsd1ih2.js.map → index-DGtbUu8Y.js.map} +1 -1
  261. package/build/server/chunks/{index-server-BRr5mZEq.js → index-server-l8-WsVn4.js} +2 -2
  262. package/build/server/chunks/{index-server-BRr5mZEq.js.map → index-server-l8-WsVn4.js.map} +1 -1
  263. package/build/server/chunks/{index2-ClgfzZ89.js → index2-CtgLiJVO.js} +3 -3
  264. package/build/server/chunks/{index2-ClgfzZ89.js.map → index2-CtgLiJVO.js.map} +1 -1
  265. package/build/server/chunks/{index3-BdRUAffj.js → index3-4hbbCqW0.js} +2 -2
  266. package/build/server/chunks/{index3-BdRUAffj.js.map → index3-4hbbCqW0.js.map} +1 -1
  267. package/build/server/chunks/{root-4q2Pr7ck.js → root-sWPnlpi3.js} +2 -2
  268. package/build/server/chunks/{root-4q2Pr7ck.js.map → root-sWPnlpi3.js.map} +1 -1
  269. package/build/server/chunks/{source-selection-CcN1cgXc.js → source-selection-DSkaPGfZ.js} +7 -2
  270. package/build/server/chunks/source-selection-DSkaPGfZ.js.map +1 -0
  271. package/build/server/chunks/{state.svelte-Bd1i-u49.js → state.svelte-BkvaElc6.js} +3 -3
  272. package/build/server/chunks/{state.svelte-Bd1i-u49.js.map → state.svelte-BkvaElc6.js.map} +1 -1
  273. package/build/server/chunks/trpc-client-Cr8RJ4UO.js +260 -0
  274. package/build/server/chunks/trpc-client-Cr8RJ4UO.js.map +1 -0
  275. package/build/server/index.js +4 -4
  276. package/build/server/index.js.map +1 -1
  277. package/build/server/manifest.js +15 -15
  278. package/build/server/manifest.js.map +1 -1
  279. package/package.json +1 -1
  280. package/src/backend/git-mode/command.spec.ts +172 -0
  281. package/src/backend/git-mode/command.ts +405 -0
  282. package/src/backend/git-mode/config.spec.ts +45 -0
  283. package/src/backend/git-mode/config.ts +90 -0
  284. package/src/backend/git-mode/index.ts +20 -0
  285. package/src/backend/git-mode/repository-write-gate.spec.ts +116 -0
  286. package/src/backend/git-mode/repository-write-gate.ts +260 -0
  287. package/src/backend/git-mode/router.ts +59 -0
  288. package/src/backend/git-mode/service.spec.ts +1653 -0
  289. package/src/backend/git-mode/service.ts +2183 -0
  290. package/src/backend/git-mode/types.ts +82 -0
  291. package/src/backend/git-mode/write-tracker.spec.ts +46 -0
  292. package/src/backend/git-mode/write-tracker.ts +39 -0
  293. package/src/backend/lib/backend-restart.spec.ts +27 -0
  294. package/src/backend/lib/backend-restart.ts +16 -2
  295. package/src/backend/lib/middleware/cors.spec.ts +67 -0
  296. package/src/backend/lib/middleware/cors.ts +40 -12
  297. package/src/backend/roleplay/agent-loader.ts +49 -0
  298. package/src/backend/roleplay/old-server.ts +106 -56
  299. package/src/backend/roleplay/prompt-edit-proposals.ts +38 -4
  300. package/src/backend/roleplay/prompt-file.ts +59 -0
  301. package/src/backend/roleplay/router.spec.ts +75 -0
  302. package/src/backend/roleplay/router.ts +123 -69
  303. package/src/backend/roleplay/suggest-file-edit.ts +160 -0
  304. package/src/backend/roleplay/suggest-prompt-edit.ts +9 -138
  305. package/src/backend/roleplay/suggest-test-fix.spec.ts +50 -0
  306. package/src/backend/roleplay/suggest-test-fix.ts +65 -0
  307. package/src/backend/roleplay/test-fix-proposal-acceptance.spec.ts +253 -0
  308. package/src/backend/server.ts +116 -13
  309. package/src/backend/tests/academy.ts +17 -3
  310. package/src/backend/tests/data.spec.ts +81 -2
  311. package/src/backend/tests/data.ts +74 -4
  312. package/src/backend/tests/router.ts +101 -36
  313. package/src/backend/tests/suggest-test-history-edit.spec.ts +20 -0
  314. package/src/backend/tests/suggest-test-history-edit.ts +52 -0
  315. package/src/backend/tests/test-edit-source.spec.ts +61 -0
  316. package/src/backend/tests/test-edit-source.ts +34 -0
  317. package/src/backend/tests/test-history-edit-proposals.ts +69 -0
  318. package/src/backend/tests/test-history-overrides.spec.ts +24 -0
  319. package/src/backend/tests/test-selection.ts +27 -0
  320. package/src/lib/api-telescope/source-selection.spec.ts +44 -0
  321. package/src/lib/api-telescope/source-selection.ts +28 -0
  322. package/src/lib/components/Modal.svelte +1 -1
  323. package/src/lib/edit/edit-workspace-context.ts +33 -3
  324. package/src/lib/edit/test-ai-edit.spec.ts +31 -0
  325. package/src/lib/edit/test-ai-edit.ts +52 -0
  326. package/src/lib/git-mode/BlockingGitDialog.svelte +118 -0
  327. package/src/lib/git-mode/GitControlsButton.svelte +96 -0
  328. package/src/lib/git-mode/client-admission.spec.ts +304 -0
  329. package/src/lib/git-mode/client-admission.ts +343 -0
  330. package/src/lib/git-mode/errors.ts +2 -0
  331. package/src/lib/git-mode/git-mode-context.ts +70 -0
  332. package/src/lib/git-mode/refresh-policy.spec.ts +95 -0
  333. package/src/lib/git-mode/refresh-policy.ts +39 -0
  334. package/src/lib/git-mode/status-recovery.spec.ts +86 -0
  335. package/src/lib/git-mode/status-recovery.ts +33 -0
  336. package/src/lib/monaco/bosun-theme.ts +6 -83
  337. package/src/lib/monaco/editor-appearance.spec.ts +84 -0
  338. package/src/lib/monaco/editor-appearance.ts +154 -0
  339. package/src/lib/monaco/editor-decorations.spec.ts +91 -0
  340. package/src/lib/monaco/editor-decorations.ts +133 -0
  341. package/src/lib/tests/assertion-types.ts +74 -0
  342. package/src/lib/trpc-client.ts +15 -1
  343. package/src/lib/ws-client.spec.ts +208 -0
  344. package/src/lib/ws-client.ts +196 -14
  345. package/src/lib/ws-message.ts +24 -0
  346. package/build/client/_app/immutable/assets/0.Rrc0O--C.css +0 -1
  347. package/build/client/_app/immutable/assets/0.Rrc0O--C.css.br +0 -0
  348. package/build/client/_app/immutable/assets/0.Rrc0O--C.css.gz +0 -0
  349. package/build/client/_app/immutable/assets/2.DtxQDOXx.css.br +0 -0
  350. package/build/client/_app/immutable/assets/2.DtxQDOXx.css.gz +0 -0
  351. package/build/client/_app/immutable/chunks/B1HdnQRQ.js.br +0 -0
  352. package/build/client/_app/immutable/chunks/B1HdnQRQ.js.gz +0 -0
  353. package/build/client/_app/immutable/chunks/B5zf3x62.js.br +0 -0
  354. package/build/client/_app/immutable/chunks/B5zf3x62.js.gz +0 -0
  355. package/build/client/_app/immutable/chunks/BHHfrjbV.js.br +0 -2
  356. package/build/client/_app/immutable/chunks/BHHfrjbV.js.gz +0 -0
  357. package/build/client/_app/immutable/chunks/BU7aeYn4.js +0 -1
  358. package/build/client/_app/immutable/chunks/BU7aeYn4.js.br +0 -0
  359. package/build/client/_app/immutable/chunks/BU7aeYn4.js.gz +0 -0
  360. package/build/client/_app/immutable/chunks/BbGYcfPY.js +0 -3
  361. package/build/client/_app/immutable/chunks/BbGYcfPY.js.br +0 -0
  362. package/build/client/_app/immutable/chunks/BbGYcfPY.js.gz +0 -0
  363. package/build/client/_app/immutable/chunks/Bmd5zhWC.js.br +0 -0
  364. package/build/client/_app/immutable/chunks/Bmd5zhWC.js.gz +0 -0
  365. package/build/client/_app/immutable/chunks/BnqH7p3U.js +0 -33
  366. package/build/client/_app/immutable/chunks/BnqH7p3U.js.br +0 -0
  367. package/build/client/_app/immutable/chunks/BnqH7p3U.js.gz +0 -0
  368. package/build/client/_app/immutable/chunks/Bog028VR.js.br +0 -0
  369. package/build/client/_app/immutable/chunks/Bog028VR.js.gz +0 -0
  370. package/build/client/_app/immutable/chunks/Bv1mNXs0.js.br +0 -0
  371. package/build/client/_app/immutable/chunks/Bv1mNXs0.js.gz +0 -0
  372. package/build/client/_app/immutable/chunks/BvjXTrOz.js +0 -1
  373. package/build/client/_app/immutable/chunks/BvjXTrOz.js.br +0 -0
  374. package/build/client/_app/immutable/chunks/BvjXTrOz.js.gz +0 -0
  375. package/build/client/_app/immutable/chunks/C1R-s4aD.js +0 -1
  376. package/build/client/_app/immutable/chunks/C1R-s4aD.js.br +0 -0
  377. package/build/client/_app/immutable/chunks/C1R-s4aD.js.gz +0 -0
  378. package/build/client/_app/immutable/chunks/CC1OcqhR.js +0 -1
  379. package/build/client/_app/immutable/chunks/CC1OcqhR.js.br +0 -0
  380. package/build/client/_app/immutable/chunks/CC1OcqhR.js.gz +0 -0
  381. package/build/client/_app/immutable/chunks/CEHsomMo.js.br +0 -0
  382. package/build/client/_app/immutable/chunks/CJcZ2GVC.js.br +0 -0
  383. package/build/client/_app/immutable/chunks/CJcZ2GVC.js.gz +0 -0
  384. package/build/client/_app/immutable/chunks/CN555stc.js.br +0 -0
  385. package/build/client/_app/immutable/chunks/CN555stc.js.gz +0 -0
  386. package/build/client/_app/immutable/chunks/CQg2Mme0.js +0 -9
  387. package/build/client/_app/immutable/chunks/CQg2Mme0.js.br +0 -0
  388. package/build/client/_app/immutable/chunks/CQg2Mme0.js.gz +0 -0
  389. package/build/client/_app/immutable/chunks/Cp_st-64.js.br +0 -0
  390. package/build/client/_app/immutable/chunks/Cp_st-64.js.gz +0 -0
  391. package/build/client/_app/immutable/chunks/D8mA-uZ4.js.br +0 -0
  392. package/build/client/_app/immutable/chunks/D8mA-uZ4.js.gz +0 -0
  393. package/build/client/_app/immutable/chunks/DCXjBDmY.js.br +0 -0
  394. package/build/client/_app/immutable/chunks/DCXjBDmY.js.gz +0 -0
  395. package/build/client/_app/immutable/chunks/DSLyVE1P.js +0 -1
  396. package/build/client/_app/immutable/chunks/DSLyVE1P.js.br +0 -0
  397. package/build/client/_app/immutable/chunks/DSLyVE1P.js.gz +0 -0
  398. package/build/client/_app/immutable/chunks/Ddv5LgEc.js.br +0 -0
  399. package/build/client/_app/immutable/chunks/Ddv5LgEc.js.gz +0 -0
  400. package/build/client/_app/immutable/chunks/DfcDjAv-.js.br +0 -0
  401. package/build/client/_app/immutable/chunks/DfcDjAv-.js.gz +0 -0
  402. package/build/client/_app/immutable/chunks/Dg0A8wPA.js.br +0 -0
  403. package/build/client/_app/immutable/chunks/Dg0A8wPA.js.gz +0 -0
  404. package/build/client/_app/immutable/chunks/Dm49pcMm.js.br +0 -0
  405. package/build/client/_app/immutable/chunks/Dm49pcMm.js.gz +0 -0
  406. package/build/client/_app/immutable/chunks/DneQQ7D5.js.br +0 -0
  407. package/build/client/_app/immutable/chunks/DneQQ7D5.js.gz +0 -0
  408. package/build/client/_app/immutable/chunks/DoD3q5w5.js.br +0 -0
  409. package/build/client/_app/immutable/chunks/DoD3q5w5.js.gz +0 -0
  410. package/build/client/_app/immutable/chunks/DtXXy94i.js.br +0 -0
  411. package/build/client/_app/immutable/chunks/DtXXy94i.js.gz +0 -0
  412. package/build/client/_app/immutable/chunks/Dy2CFyD6.js +0 -2
  413. package/build/client/_app/immutable/chunks/Dy2CFyD6.js.br +0 -0
  414. package/build/client/_app/immutable/chunks/Dy2CFyD6.js.gz +0 -0
  415. package/build/client/_app/immutable/chunks/DyBwTDJp.js +0 -2
  416. package/build/client/_app/immutable/chunks/DyBwTDJp.js.br +0 -0
  417. package/build/client/_app/immutable/chunks/DyBwTDJp.js.gz +0 -0
  418. package/build/client/_app/immutable/chunks/ILTCveYH.js +0 -1
  419. package/build/client/_app/immutable/chunks/ILTCveYH.js.br +0 -0
  420. package/build/client/_app/immutable/chunks/ILTCveYH.js.gz +0 -0
  421. package/build/client/_app/immutable/chunks/JcG4w2gF.js.br +0 -0
  422. package/build/client/_app/immutable/chunks/JcG4w2gF.js.gz +0 -0
  423. package/build/client/_app/immutable/chunks/UqjCSZmA.js.br +0 -0
  424. package/build/client/_app/immutable/chunks/UqjCSZmA.js.gz +0 -0
  425. package/build/client/_app/immutable/chunks/_YcrHZ6P.js +0 -1
  426. package/build/client/_app/immutable/chunks/_YcrHZ6P.js.br +0 -2
  427. package/build/client/_app/immutable/chunks/_YcrHZ6P.js.gz +0 -0
  428. package/build/client/_app/immutable/chunks/i6ygh6h8.js +0 -1
  429. package/build/client/_app/immutable/chunks/i6ygh6h8.js.br +0 -2
  430. package/build/client/_app/immutable/chunks/i6ygh6h8.js.gz +0 -0
  431. package/build/client/_app/immutable/chunks/oRi68ch3.js +0 -1
  432. package/build/client/_app/immutable/chunks/oRi68ch3.js.br +0 -0
  433. package/build/client/_app/immutable/chunks/oRi68ch3.js.gz +0 -0
  434. package/build/client/_app/immutable/chunks/pPpIn6tB.js.br +0 -0
  435. package/build/client/_app/immutable/chunks/pPpIn6tB.js.gz +0 -0
  436. package/build/client/_app/immutable/chunks/r0sHOWrM.js +0 -1
  437. package/build/client/_app/immutable/chunks/r0sHOWrM.js.br +0 -0
  438. package/build/client/_app/immutable/chunks/r0sHOWrM.js.gz +0 -0
  439. package/build/client/_app/immutable/chunks/wcTQUnZO.js.br +0 -0
  440. package/build/client/_app/immutable/chunks/wcTQUnZO.js.gz +0 -0
  441. package/build/client/_app/immutable/entry/app.DdO0gkPg.js +0 -2
  442. package/build/client/_app/immutable/entry/app.DdO0gkPg.js.br +0 -0
  443. package/build/client/_app/immutable/entry/app.DdO0gkPg.js.gz +0 -0
  444. package/build/client/_app/immutable/entry/start.eAjDq0c9.js +0 -1
  445. package/build/client/_app/immutable/entry/start.eAjDq0c9.js.br +0 -2
  446. package/build/client/_app/immutable/entry/start.eAjDq0c9.js.gz +0 -0
  447. package/build/client/_app/immutable/nodes/0.nG3DdvIX.js +0 -3
  448. package/build/client/_app/immutable/nodes/0.nG3DdvIX.js.br +0 -0
  449. package/build/client/_app/immutable/nodes/0.nG3DdvIX.js.gz +0 -0
  450. package/build/client/_app/immutable/nodes/1.Ceu0XS6v.js +0 -1
  451. package/build/client/_app/immutable/nodes/1.Ceu0XS6v.js.br +0 -0
  452. package/build/client/_app/immutable/nodes/1.Ceu0XS6v.js.gz +0 -0
  453. package/build/client/_app/immutable/nodes/10.dm2dgy31.js +0 -13
  454. package/build/client/_app/immutable/nodes/10.dm2dgy31.js.br +0 -0
  455. package/build/client/_app/immutable/nodes/10.dm2dgy31.js.gz +0 -0
  456. package/build/client/_app/immutable/nodes/15.D2I0y3ys.js.br +0 -0
  457. package/build/client/_app/immutable/nodes/15.D2I0y3ys.js.gz +0 -0
  458. package/build/client/_app/immutable/nodes/17.CKxGp0E7.js +0 -3
  459. package/build/client/_app/immutable/nodes/17.CKxGp0E7.js.br +0 -0
  460. package/build/client/_app/immutable/nodes/17.CKxGp0E7.js.gz +0 -0
  461. package/build/client/_app/immutable/nodes/18.DHJxyQpw.js +0 -11
  462. package/build/client/_app/immutable/nodes/18.DHJxyQpw.js.br +0 -0
  463. package/build/client/_app/immutable/nodes/18.DHJxyQpw.js.gz +0 -0
  464. package/build/client/_app/immutable/nodes/2.DesR2keg.js +0 -35
  465. package/build/client/_app/immutable/nodes/2.DesR2keg.js.br +0 -0
  466. package/build/client/_app/immutable/nodes/2.DesR2keg.js.gz +0 -0
  467. package/build/client/_app/immutable/nodes/3.BUKrXGR_.js +0 -1
  468. package/build/client/_app/immutable/nodes/3.BUKrXGR_.js.br +0 -0
  469. package/build/client/_app/immutable/nodes/3.BUKrXGR_.js.gz +0 -0
  470. package/build/client/_app/immutable/nodes/4.C8VxVbVn.js.br +0 -0
  471. package/build/client/_app/immutable/nodes/4.C8VxVbVn.js.gz +0 -0
  472. package/build/client/_app/immutable/nodes/5.BjrnKYaA.js.br +0 -0
  473. package/build/client/_app/immutable/nodes/5.BjrnKYaA.js.gz +0 -0
  474. package/build/client/_app/immutable/nodes/6.C_hEzPrU.js +0 -1
  475. package/build/client/_app/immutable/nodes/6.C_hEzPrU.js.br +0 -0
  476. package/build/client/_app/immutable/nodes/6.C_hEzPrU.js.gz +0 -0
  477. package/build/client/_app/immutable/nodes/8.BTYaOiDC.js +0 -2
  478. package/build/client/_app/immutable/nodes/8.BTYaOiDC.js.br +0 -0
  479. package/build/client/_app/immutable/nodes/8.BTYaOiDC.js.gz +0 -0
  480. package/build/client/_app/immutable/nodes/9.DVspuDYY.js +0 -2
  481. package/build/client/_app/immutable/nodes/9.DVspuDYY.js.br +0 -0
  482. package/build/client/_app/immutable/nodes/9.DVspuDYY.js.gz +0 -0
  483. package/build/server/chunks/0-D_duQDWy.js +0 -9
  484. package/build/server/chunks/0-D_duQDWy.js.map +0 -1
  485. package/build/server/chunks/1-C0HdTvXW.js +0 -9
  486. package/build/server/chunks/10-DcfhCR4R.js +0 -9
  487. package/build/server/chunks/10-DcfhCR4R.js.map +0 -1
  488. package/build/server/chunks/15-BQcb9C_y.js +0 -9
  489. package/build/server/chunks/17-DuVCTioj.js +0 -9
  490. package/build/server/chunks/17-DuVCTioj.js.map +0 -1
  491. package/build/server/chunks/18-CT0VFW_p.js +0 -9
  492. package/build/server/chunks/18-CT0VFW_p.js.map +0 -1
  493. package/build/server/chunks/2-DgTzKQAP.js +0 -9
  494. package/build/server/chunks/2-DgTzKQAP.js.map +0 -1
  495. package/build/server/chunks/3-eQMSMzXP.js +0 -9
  496. package/build/server/chunks/3-eQMSMzXP.js.map +0 -1
  497. package/build/server/chunks/4-OaAJtDqP.js +0 -9
  498. package/build/server/chunks/4-OaAJtDqP.js.map +0 -1
  499. package/build/server/chunks/5-CBdMutbv.js +0 -9
  500. package/build/server/chunks/5-CBdMutbv.js.map +0 -1
  501. package/build/server/chunks/6-FoEi7ggm.js +0 -9
  502. package/build/server/chunks/6-FoEi7ggm.js.map +0 -1
  503. package/build/server/chunks/8-B44yRkfX.js +0 -9
  504. package/build/server/chunks/8-B44yRkfX.js.map +0 -1
  505. package/build/server/chunks/9-eUjFpHzI.js +0 -9
  506. package/build/server/chunks/9-eUjFpHzI.js.map +0 -1
  507. package/build/server/chunks/ChatStepTrace-BivoNYOY.js.map +0 -1
  508. package/build/server/chunks/_layout.svelte-B-0DHjLB.js +0 -45
  509. package/build/server/chunks/_layout.svelte-B-0DHjLB.js.map +0 -1
  510. package/build/server/chunks/_layout.svelte-B88WMyk0.js.map +0 -1
  511. package/build/server/chunks/_layout.svelte-wi6o5Ecu.js.map +0 -1
  512. package/build/server/chunks/_page.svelte-57pyhszV.js.map +0 -1
  513. package/build/server/chunks/_page.svelte-BSVJ2d2G.js.map +0 -1
  514. package/build/server/chunks/_page.svelte-BoHt2cpr.js +0 -61
  515. package/build/server/chunks/_page.svelte-BoHt2cpr.js.map +0 -1
  516. package/build/server/chunks/_page.svelte-CeS_21Ho.js.map +0 -1
  517. package/build/server/chunks/_page.svelte-DHSYVLoj.js.map +0 -1
  518. package/build/server/chunks/_page.svelte-DhFNFMnl.js.map +0 -1
  519. package/build/server/chunks/_server.ts-D_eo_OAK.js +0 -139
  520. package/build/server/chunks/_server.ts-D_eo_OAK.js.map +0 -1
  521. package/build/server/chunks/error.svelte-DmKKN1RU.js +0 -15
  522. package/build/server/chunks/source-selection-CcN1cgXc.js.map +0 -1
  523. package/build/server/chunks/trpc-client-D8L6vWf4.js +0 -14
  524. package/build/server/chunks/trpc-client-D8L6vWf4.js.map +0 -1
@@ -0,0 +1,2183 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto'
2
+ import { watch, type FSWatcher } from 'node:fs'
3
+ import {
4
+ access,
5
+ lstat,
6
+ mkdir,
7
+ open,
8
+ readFile,
9
+ readlink,
10
+ realpath,
11
+ rename,
12
+ rm,
13
+ unlink,
14
+ writeFile
15
+ } from 'node:fs/promises'
16
+ import path from 'node:path'
17
+
18
+ import { CommandError, runCommand, type CommandResult, type CommandRunner } from './command'
19
+ import type {
20
+ ChangeChoice,
21
+ GitModeConfig,
22
+ GitModeFatal,
23
+ GitModeFatalKind,
24
+ GitModePreparationStage,
25
+ GitModeService,
26
+ GitModeStatus
27
+ } from './types'
28
+
29
+ const RUNTIME_STATE_VERSION = 1
30
+ // Browser teardown delivery is best-effort (a closed/crashed tab may never unregister). Keep the
31
+ // heartbeat lease shorter than the flush deadline so such a tab expires instead of turning an
32
+ // otherwise safe refresh into a permanent fatal state. Active tabs poll much more frequently.
33
+ const DEFAULT_LIVE_TAB_MS = 15_000
34
+ const DEFAULT_FLUSH_TIMEOUT_MS = 45_000
35
+ const GIT_TIMEOUT_MS = 2 * 60 * 1000
36
+ const INSTALL_TIMEOUT_MS = 10 * 60 * 1000
37
+ const CHANGE_CACHE_MS = 500
38
+ const PREPARE_ELIGIBILITY_CACHE_MS = 500
39
+ const CONTENT_REFRESH_JOURNAL_VERSION = 1
40
+ const PREPARE_JOURNAL_VERSION = 1
41
+ const PREPARE_RESULT_REF = 'refs/bosun/prepare-release-result'
42
+
43
+ const LOCAL_CHANGES_REFUSAL = [
44
+ /Your local changes to the following files would be overwritten by (?:merge|checkout)/i,
45
+ /The following untracked working tree files would be overwritten by merge/i,
46
+ /Please commit your changes or stash them before you merge/i,
47
+ /Entry .* not uptodate\. Cannot merge\./i,
48
+ /Cannot pull with rebase: You have unstaged changes/i
49
+ ]
50
+
51
+ const BLOCKING_PHASES = new Set([
52
+ 'flushing',
53
+ 'refreshing',
54
+ 'awaiting-changes',
55
+ 'processing',
56
+ 'restarting',
57
+ 'fatal'
58
+ ])
59
+
60
+ interface PersistedRuntimeState {
61
+ version: typeof RUNTIME_STATE_VERSION
62
+ launchId: string
63
+ firstActivationCompleted: boolean
64
+ lastCompletedActivationAt: number | null
65
+ lastRelevantChangeAt: number
66
+ reloadToken: string | null
67
+ fatal: GitModeFatal | null
68
+ }
69
+
70
+ interface ActiveOperation {
71
+ id: string
72
+ kind: 'activation' | 'manual' | 'prepare-release'
73
+ contentBaseline: string | null
74
+ pendingTabs: Set<string>
75
+ acknowledgedTabs: Set<string>
76
+ blockedReasons: Map<string, string>
77
+ choice: ChangeChoice | null
78
+ deadline: number
79
+ flushStarted: boolean
80
+ stage: GitModePreparationStage
81
+ }
82
+
83
+ interface ContentRefreshJournal {
84
+ version: typeof CONTENT_REFRESH_JOURNAL_VERSION
85
+ baseline: string
86
+ installedContent: string | null
87
+ reloadToken: string | null
88
+ }
89
+
90
+ interface PrepareReleaseJournal {
91
+ version: typeof PREPARE_JOURNAL_VERSION
92
+ currentBranch: string
93
+ personalBranch: string
94
+ remote: string
95
+ currentRemoteBranch: string
96
+ personalRemoteBranch: string
97
+ remoteUrlDigest: string
98
+ oldCurrentRemoteHead: string
99
+ oldPersonalRemoteHead: string
100
+ oldCurrentLocalHead: string
101
+ resultHead: string | null
102
+ }
103
+
104
+ interface GitFeatures {
105
+ mergeAutostashOption: boolean
106
+ ortStrategy: boolean
107
+ }
108
+
109
+ export interface GitModeServiceDependencies {
110
+ runCommand?: CommandRunner
111
+ settleWrites?: () => Promise<void>
112
+ resumeWrites?: () => Promise<void>
113
+ installProject?: () => Promise<void>
114
+ scheduleRestart?: (exitCode: number) => boolean | Promise<boolean>
115
+ now?: () => number
116
+ randomId?: () => string
117
+ liveTabMs?: number
118
+ flushTimeoutMs?: number
119
+ watchFileChanges?: boolean
120
+ logError?: (message: string, error?: unknown) => void
121
+ }
122
+
123
+ class RepositoryValidationError extends Error {
124
+ constructor(
125
+ message: string,
126
+ readonly kind: GitModeFatalKind = 'general',
127
+ options?: ErrorOptions
128
+ ) {
129
+ super(message, options)
130
+ this.name = 'RepositoryValidationError'
131
+ }
132
+ }
133
+
134
+ class PrepareMergeConflictError extends Error {
135
+ constructor() {
136
+ super('The personal branch conflicts with the release branch.')
137
+ this.name = 'PrepareMergeConflictError'
138
+ }
139
+ }
140
+
141
+ export function createGitModeService(
142
+ config: GitModeConfig,
143
+ dependencies: GitModeServiceDependencies = {}
144
+ ): GitModeService {
145
+ return new DefaultGitModeService(config, dependencies)
146
+ }
147
+
148
+ class DefaultGitModeService implements GitModeService {
149
+ private readonly command: CommandRunner
150
+ private readonly settleWrites: () => Promise<void>
151
+ private readonly resumeWrites: () => Promise<void>
152
+ private readonly installProject: () => Promise<void>
153
+ private readonly scheduleRestart: (exitCode: number) => boolean | Promise<boolean>
154
+ private readonly now: () => number
155
+ private readonly randomId: () => string
156
+ private readonly liveTabMs: number
157
+ private readonly flushTimeoutMs: number
158
+ private readonly shouldWatchFiles: boolean
159
+ private readonly logError: (message: string, error?: unknown) => void
160
+ private readonly hooksDir: string
161
+ private readonly ready: Promise<void>
162
+
163
+ private runtime: PersistedRuntimeState
164
+ private phase: GitModeStatus['phase']
165
+ private operationId: string | null = null
166
+ private operation: ActiveOperation | null = null
167
+ private deferredReason: string | null = null
168
+ private hasChanges = false
169
+ private canPrepareRelease = false
170
+ private tabs = new Map<string, number>()
171
+ private watcher: FSWatcher | null = null
172
+ private watchedPaths = new Set<string>()
173
+ private watchDebounce: ReturnType<typeof setTimeout> | null = null
174
+ private flushTimer: ReturnType<typeof setInterval> | null = null
175
+ private changeRefreshAt = 0
176
+ private changeRefresh: Promise<void> | null = null
177
+ private prepareEligibilityRefreshAt = 0
178
+ private prepareEligibilityRefresh: Promise<void> | null = null
179
+ private persistQueue: Promise<void> = Promise.resolve()
180
+ private gitQueue: Promise<void> = Promise.resolve()
181
+ private gitFeaturesPromise: Promise<GitFeatures> | null = null
182
+ private operationTask: Promise<void> | null = null
183
+ private disposed = false
184
+ private readonly contentRefreshJournalPath: string
185
+ private readonly prepareJournalPath: string
186
+ private readonly prepareWorkspacePath: string
187
+
188
+ constructor(
189
+ private readonly config: GitModeConfig,
190
+ dependencies: GitModeServiceDependencies
191
+ ) {
192
+ this.command = dependencies.runCommand ?? runCommand
193
+ this.settleWrites = dependencies.settleWrites ?? (async () => {})
194
+ this.resumeWrites =
195
+ dependencies.resumeWrites ??
196
+ (async () => {
197
+ if (this.config.enabled) {
198
+ throw new Error('The repository write gate cannot be released without restarting Bosun.')
199
+ }
200
+ })
201
+ this.installProject =
202
+ dependencies.installProject ??
203
+ (async () => {
204
+ await this.command(
205
+ 'pnpm',
206
+ ['install', '--frozen-lockfile', '--ignore-scripts', '--ignore-pnpmfile'],
207
+ {
208
+ cwd: this.config.projectDir,
209
+ env: this.nonInteractiveEnvironment(),
210
+ timeoutMs: INSTALL_TIMEOUT_MS
211
+ }
212
+ )
213
+ })
214
+ this.scheduleRestart =
215
+ dependencies.scheduleRestart ??
216
+ ((exitCode) => {
217
+ const timer = setTimeout(() => process.exit(exitCode), 500)
218
+ timer.unref()
219
+ return true
220
+ })
221
+ this.now = dependencies.now ?? Date.now
222
+ this.randomId = dependencies.randomId ?? randomUUID
223
+ this.liveTabMs = dependencies.liveTabMs ?? DEFAULT_LIVE_TAB_MS
224
+ this.flushTimeoutMs = dependencies.flushTimeoutMs ?? DEFAULT_FLUSH_TIMEOUT_MS
225
+ this.shouldWatchFiles = dependencies.watchFileChanges ?? true
226
+ this.logError = dependencies.logError ?? console.error
227
+ this.hooksDir = path.join(
228
+ path.dirname(config.runtimeStatePath || config.projectDir),
229
+ 'empty-hooks'
230
+ )
231
+ this.contentRefreshJournalPath = path.join(
232
+ config.projectDir,
233
+ '.git',
234
+ 'bosun-content-refresh.json'
235
+ )
236
+ this.prepareJournalPath = path.join(config.projectDir, '.git', 'bosun-prepare-release.json')
237
+ this.prepareWorkspacePath = path.join(
238
+ config.projectDir,
239
+ '.git',
240
+ 'bosun-prepare-release-workspace'
241
+ )
242
+ this.phase = config.enabled ? 'refreshing' : 'idle'
243
+ this.runtime = this.newRuntimeState()
244
+ this.ready = this.initialize()
245
+ }
246
+
247
+ isUsageBlocked(): boolean {
248
+ if (!this.config.enabled) return false
249
+ return BLOCKING_PHASES.has(this.phase)
250
+ }
251
+
252
+ async status(input: { tabId: string }): Promise<GitModeStatus> {
253
+ await this.ready
254
+ this.registerTab(input.tabId)
255
+ // Status polling stays filesystem-only while the repository lock is active; even read-only
256
+ // Git commands add needless lock/error races with an in-progress pull, commit, or stash.
257
+ if (this.phase === 'idle') await this.refreshRepositoryStatus()
258
+ return this.createStatus(input.tabId)
259
+ }
260
+
261
+ async unregister(input: { tabId: string }): Promise<GitModeStatus> {
262
+ await this.ready
263
+ this.tabs.delete(input.tabId)
264
+ const operation = this.operation
265
+ if (operation && (this.phase === 'preparing' || this.phase === 'flushing')) {
266
+ operation.pendingTabs.delete(input.tabId)
267
+ operation.acknowledgedTabs.delete(input.tabId)
268
+ operation.blockedReasons.delete(input.tabId)
269
+ this.evaluateFlush(operation)
270
+ }
271
+ return this.createStatus(input.tabId)
272
+ }
273
+
274
+ async activate(input: { tabId: string; reason: 'page-open' | 'focus' }): Promise<GitModeStatus> {
275
+ await this.ready
276
+ this.registerTab(input.tabId)
277
+ if (!this.config.enabled || this.phase === 'fatal') return this.createStatus(input.tabId)
278
+ if (this.operation || this.phase !== 'idle') return this.createStatus(input.tabId)
279
+ if (!this.isActivationEligible()) return this.createStatus(input.tabId)
280
+ if (!(await this.prevalidateStart(input.tabId))) return this.createStatus(input.tabId)
281
+ if (this.operation || this.phase !== 'idle') return this.createStatus(input.tabId)
282
+
283
+ this.startFlush('activation', input.tabId, null)
284
+ return this.createStatus(input.tabId)
285
+ }
286
+
287
+ async flushAck(input: {
288
+ tabId: string
289
+ operationId: string
290
+ stage: GitModePreparationStage
291
+ blockedReason?: string
292
+ }): Promise<GitModeStatus> {
293
+ await this.ready
294
+ this.registerTab(input.tabId)
295
+ const operation = this.operation
296
+ if (
297
+ !operation ||
298
+ operation.id !== input.operationId ||
299
+ this.phase !== 'preparing' ||
300
+ operation.stage !== input.stage
301
+ ) {
302
+ return this.createStatus(input.tabId)
303
+ }
304
+
305
+ if (operation.pendingTabs.delete(input.tabId)) {
306
+ operation.acknowledgedTabs.add(input.tabId)
307
+ const reason = input.blockedReason?.trim()
308
+ if (reason) operation.blockedReasons.set(input.tabId, reason)
309
+ }
310
+ this.evaluateFlush(operation)
311
+ return this.createStatus(input.tabId)
312
+ }
313
+
314
+ async startManual(input: { tabId: string } & ChangeChoice): Promise<GitModeStatus> {
315
+ await this.ready
316
+ this.registerTab(input.tabId)
317
+ if (!this.config.enabled || this.phase === 'fatal') return this.createStatus(input.tabId)
318
+ this.assertManualOperationAvailable()
319
+ this.validateChoice(input)
320
+
321
+ await this.refreshHasChanges(true)
322
+ this.assertManualOperationAvailable()
323
+ if (!this.hasChanges) return this.createStatus(input.tabId)
324
+ if (!(await this.prevalidateStart(input.tabId, true))) return this.createStatus(input.tabId)
325
+ this.assertManualOperationAvailable()
326
+
327
+ this.startFlush('manual', input.tabId, this.normalizeChoice(input))
328
+ return this.createStatus(input.tabId)
329
+ }
330
+
331
+ async prepareRelease(input: { tabId: string }): Promise<GitModeStatus> {
332
+ await this.ready
333
+ this.registerTab(input.tabId)
334
+ if (!this.config.enabled || this.phase === 'fatal') return this.createStatus(input.tabId)
335
+ this.assertManualOperationAvailable()
336
+
337
+ await this.refreshRepositoryStatus(true)
338
+ this.assertManualOperationAvailable()
339
+ if (!this.canPrepareRelease) return this.createStatus(input.tabId)
340
+ if (!(await this.prevalidateStart(input.tabId, true))) return this.createStatus(input.tabId)
341
+ this.assertManualOperationAvailable()
342
+
343
+ this.startFlush('prepare-release', input.tabId, null)
344
+ return this.createStatus(input.tabId)
345
+ }
346
+
347
+ async resolveChanges(
348
+ input: {
349
+ tabId: string
350
+ operationId: string
351
+ } & ChangeChoice
352
+ ): Promise<GitModeStatus> {
353
+ await this.ready
354
+ this.registerTab(input.tabId)
355
+ const operation = this.operation
356
+ if (
357
+ !operation ||
358
+ operation.id !== input.operationId ||
359
+ operation.kind !== 'activation' ||
360
+ this.phase !== 'awaiting-changes'
361
+ ) {
362
+ throw new Error('This refresh choice was already handled in another Bosun tab.')
363
+ }
364
+
365
+ this.validateChoice(input)
366
+ operation.choice = this.normalizeChoice(input)
367
+ this.phase = 'processing'
368
+ this.startOperation(() => this.continueAfterChanges(operation))
369
+ return this.createStatus(input.tabId)
370
+ }
371
+
372
+ async dispose(): Promise<void> {
373
+ this.disposed = true
374
+ if (this.flushTimer) clearInterval(this.flushTimer)
375
+ if (this.watchDebounce) clearTimeout(this.watchDebounce)
376
+ this.watcher?.close()
377
+ await this.operationTask?.catch(() => {})
378
+ await this.gitQueue.catch(() => {})
379
+ await this.persistQueue.catch(() => {})
380
+ }
381
+
382
+ private async initialize(): Promise<void> {
383
+ if (!this.config.enabled) return
384
+
385
+ try {
386
+ await mkdir(this.hooksDir, { recursive: true })
387
+ this.runtime = await this.loadRuntimeState()
388
+ if (await this.prepareJournalExists()) {
389
+ await this.settleWrites()
390
+ await this.recoverPrepareRelease()
391
+ } else if (await this.contentRefreshJournalExists()) {
392
+ await this.settleWrites()
393
+ await this.recoverContentRefresh()
394
+ }
395
+ if (this.phase !== 'restarting') this.phase = this.runtime.fatal ? 'fatal' : 'idle'
396
+ await this.queueRuntimePersist()
397
+ if (this.shouldWatchFiles && this.phase !== 'restarting') this.startFileWatcher()
398
+ } catch (error) {
399
+ const validation = error instanceof RepositoryValidationError ? error : null
400
+ await this.setFatal(
401
+ validation?.kind ?? 'general',
402
+ validation?.message ??
403
+ 'Bosun could not initialise Git mode. Resolve the problem and restart Bosun.',
404
+ null,
405
+ error
406
+ )
407
+ }
408
+ }
409
+
410
+ private newRuntimeState(): PersistedRuntimeState {
411
+ return {
412
+ version: RUNTIME_STATE_VERSION,
413
+ launchId: this.config.launchId,
414
+ firstActivationCompleted: false,
415
+ lastCompletedActivationAt: null,
416
+ lastRelevantChangeAt: this.now(),
417
+ reloadToken: null,
418
+ fatal: null
419
+ }
420
+ }
421
+
422
+ private async loadRuntimeState(): Promise<PersistedRuntimeState> {
423
+ let raw: string
424
+ try {
425
+ raw = await readFile(this.config.runtimeStatePath, 'utf8')
426
+ } catch (error) {
427
+ if (isNodeError(error, 'ENOENT')) return this.newRuntimeState()
428
+ throw error
429
+ }
430
+
431
+ const parsed: unknown = JSON.parse(raw)
432
+ if (!isRuntimeState(parsed) || parsed.launchId !== this.config.launchId) {
433
+ return this.newRuntimeState()
434
+ }
435
+ return parsed
436
+ }
437
+
438
+ private queueRuntimePersist(): Promise<void> {
439
+ if (!this.config.enabled) return Promise.resolve()
440
+ const snapshot = JSON.stringify(this.runtime)
441
+ this.persistQueue = this.persistQueue
442
+ .catch(() => {})
443
+ .then(async () => {
444
+ const target = this.config.runtimeStatePath
445
+ await mkdir(path.dirname(target), { recursive: true })
446
+ const temporary = `${target}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
447
+ try {
448
+ await writeFile(temporary, `${snapshot}\n`, { encoding: 'utf8', mode: 0o600 })
449
+ await rename(temporary, target)
450
+ } finally {
451
+ await unlink(temporary).catch(() => {})
452
+ }
453
+ })
454
+ return this.persistQueue
455
+ }
456
+
457
+ private startFileWatcher(): void {
458
+ this.watcher = watch(this.config.projectDir, { recursive: true }, (_eventType, filename) =>
459
+ this.noteWatchEvent(filename?.toString() ?? null)
460
+ )
461
+ this.watcher.on('error', (error) => {
462
+ if (this.phase === 'fatal') return
463
+ void this.setFatal(
464
+ 'general',
465
+ 'Bosun can no longer track repository activity safely. Restart Bosun before continuing.',
466
+ null,
467
+ error
468
+ )
469
+ })
470
+ }
471
+
472
+ private noteWatchEvent(filename: string | null): void {
473
+ if (this.disposed) return
474
+ if (filename === null) {
475
+ this.recordRelevantChange()
476
+ return
477
+ }
478
+
479
+ const relative = filename.split(path.sep).join('/')
480
+ if (
481
+ relative === '.git' ||
482
+ relative.startsWith('.git/') ||
483
+ relative === 'node_modules' ||
484
+ relative.startsWith('node_modules/') ||
485
+ relative === '.svelte-kit' ||
486
+ relative.startsWith('.svelte-kit/') ||
487
+ relative === 'build' ||
488
+ relative.startsWith('build/') ||
489
+ path.resolve(this.config.projectDir, filename) === path.resolve(this.config.runtimeStatePath)
490
+ ) {
491
+ return
492
+ }
493
+
494
+ this.watchedPaths.add(relative)
495
+ if (this.watchDebounce) clearTimeout(this.watchDebounce)
496
+ this.watchDebounce = setTimeout(() => {
497
+ this.watchDebounce = null
498
+ void this.filterWatchedPaths().catch(() => this.recordRelevantChange())
499
+ }, 150)
500
+ this.watchDebounce.unref()
501
+ }
502
+
503
+ private async filterWatchedPaths(): Promise<void> {
504
+ const candidates = [...this.watchedPaths]
505
+ this.watchedPaths.clear()
506
+ if (!candidates.length) return
507
+ if (this.phase !== 'idle') {
508
+ // Pull/install changes are relevant for the next idle window, but the watcher must not
509
+ // launch an ancillary Git read between commands in the refresh transaction.
510
+ this.recordRelevantChange()
511
+ return
512
+ }
513
+ if (candidates.length > 1_000) {
514
+ this.recordRelevantChange()
515
+ return
516
+ }
517
+
518
+ const result = await this.git(['check-ignore', '--stdin', '-z'], {
519
+ input: `${candidates.join('\0')}\0`,
520
+ acceptedExitCodes: [0, 1]
521
+ })
522
+ const ignored = new Set(result.stdout.split('\0').filter(Boolean))
523
+ if (candidates.some((candidate) => !ignored.has(candidate))) this.recordRelevantChange()
524
+ }
525
+
526
+ private recordRelevantChange(): void {
527
+ const changedAt = this.now()
528
+ if (changedAt <= this.runtime.lastRelevantChangeAt) return
529
+ this.runtime.lastRelevantChangeAt = changedAt
530
+ void this.queueRuntimePersist().catch((error) => {
531
+ this.logError('Bosun could not persist the latest working-tree change time.', error)
532
+ })
533
+ }
534
+
535
+ private registerTab(tabId: string): void {
536
+ this.tabs.set(tabId, this.now())
537
+ const operation = this.operation
538
+ if (
539
+ operation &&
540
+ !operation.flushStarted &&
541
+ this.phase === 'preparing' &&
542
+ !operation.acknowledgedTabs.has(tabId)
543
+ ) {
544
+ operation.pendingTabs.add(tabId)
545
+ }
546
+ }
547
+
548
+ private createStatus(tabId: string): GitModeStatus {
549
+ return {
550
+ enabled: this.config.enabled,
551
+ phase: this.config.enabled ? this.phase : 'idle',
552
+ operationId: this.operationId,
553
+ hasChanges: this.config.enabled ? this.hasChanges : false,
554
+ canPrepareRelease:
555
+ this.config.enabled && this.phase === 'idle' ? this.canPrepareRelease : false,
556
+ fatal: this.runtime.fatal,
557
+ prUrl: this.runtime.fatal?.prUrl ?? null,
558
+ reloadToken: this.runtime.reloadToken,
559
+ deferredReason: this.deferredReason,
560
+ tabNeedsFlush: this.phase === 'preparing' && Boolean(this.operation?.pendingTabs.has(tabId)),
561
+ preparationStage: this.phase === 'preparing' ? (this.operation?.stage ?? null) : null
562
+ }
563
+ }
564
+
565
+ private isActivationEligible(): boolean {
566
+ if (!this.runtime.firstActivationCompleted) return true
567
+ const completedAt = this.runtime.lastCompletedActivationAt
568
+ if (completedAt === null) return true
569
+ const now = this.now()
570
+ return (
571
+ now - completedAt >= this.config.idleMs &&
572
+ now - this.runtime.lastRelevantChangeAt >= this.config.idleMs
573
+ )
574
+ }
575
+
576
+ private async refreshHasChanges(force = false): Promise<void> {
577
+ if (!this.config.enabled || this.phase === 'fatal') return
578
+ const now = this.now()
579
+ if (!force && now - this.changeRefreshAt < CHANGE_CACHE_MS) return
580
+ if (this.changeRefresh) return this.changeRefresh
581
+
582
+ this.changeRefresh = (async () => {
583
+ try {
584
+ const result = await this.git([
585
+ 'status',
586
+ '--porcelain=v1',
587
+ '-z',
588
+ '--untracked-files=normal',
589
+ '--ignore-submodules=none'
590
+ ])
591
+ this.hasChanges = result.stdout.length > 0
592
+ this.changeRefreshAt = this.now()
593
+ } catch {
594
+ // Activation revalidates the repository and turns this into a useful fatal error.
595
+ this.changeRefreshAt = this.now()
596
+ }
597
+ })().finally(() => {
598
+ this.changeRefresh = null
599
+ })
600
+ return this.changeRefresh
601
+ }
602
+
603
+ private async refreshRepositoryStatus(force = false): Promise<void> {
604
+ await this.refreshHasChanges(force)
605
+ if (this.hasChanges) {
606
+ this.canPrepareRelease = false
607
+ return
608
+ }
609
+ await this.refreshPrepareEligibility(force)
610
+ }
611
+
612
+ private async refreshPrepareEligibility(force = false): Promise<void> {
613
+ if (!this.config.enabled || this.phase !== 'idle') return
614
+ const now = this.now()
615
+ if (
616
+ !force &&
617
+ this.prepareEligibilityRefreshAt !== 0 &&
618
+ now - this.prepareEligibilityRefreshAt < PREPARE_ELIGIBILITY_CACHE_MS
619
+ ) {
620
+ return
621
+ }
622
+ if (this.prepareEligibilityRefresh) return this.prepareEligibilityRefresh
623
+
624
+ this.prepareEligibilityRefresh = (async () => {
625
+ try {
626
+ await this.updatePrepareEligibilityFromFetchedRefs()
627
+ } catch {
628
+ // The locked Prepare transaction refreshes and repeats this check. Idle status polling uses
629
+ // only the last safely fetched tracking ref and keeps the action unavailable if it is invalid.
630
+ this.canPrepareRelease = false
631
+ } finally {
632
+ this.prepareEligibilityRefreshAt = this.now()
633
+ }
634
+ })().finally(() => {
635
+ this.prepareEligibilityRefresh = null
636
+ })
637
+ return this.prepareEligibilityRefresh
638
+ }
639
+
640
+ private async updatePrepareEligibilityFromFetchedRefs(
641
+ currentHead = this.config.currentUpstream
642
+ ): Promise<void> {
643
+ this.canPrepareRelease =
644
+ !this.hasChanges && (await this.commitCount(`${currentHead}..HEAD`)) > 0
645
+ this.prepareEligibilityRefreshAt = this.now()
646
+ }
647
+
648
+ private startFlush(
649
+ kind: ActiveOperation['kind'],
650
+ initiatingTabId: string,
651
+ choice: ChangeChoice | null
652
+ ): void {
653
+ const now = this.now()
654
+ const liveTabs = this.snapshotLiveTabs()
655
+ liveTabs.add(initiatingTabId)
656
+
657
+ const operation: ActiveOperation = {
658
+ id: this.randomId(),
659
+ kind,
660
+ contentBaseline: null,
661
+ pendingTabs: liveTabs,
662
+ acknowledgedTabs: new Set(),
663
+ blockedReasons: new Map(),
664
+ choice,
665
+ deadline: now + this.flushTimeoutMs,
666
+ flushStarted: false,
667
+ stage: 'preflight'
668
+ }
669
+ this.operation = operation
670
+ this.operationId = operation.id
671
+ this.phase = 'preparing'
672
+ this.deferredReason = null
673
+ this.startFlushTimer(operation)
674
+ }
675
+
676
+ private snapshotLiveTabs(): Set<string> {
677
+ const now = this.now()
678
+ for (const [tabId, lastSeen] of this.tabs) {
679
+ if (now - lastSeen > this.liveTabMs) this.tabs.delete(tabId)
680
+ }
681
+ return new Set(
682
+ [...this.tabs.entries()]
683
+ .filter(([, lastSeen]) => now - lastSeen <= this.liveTabMs)
684
+ .map(([tabId]) => tabId)
685
+ )
686
+ }
687
+
688
+ private startFlushTimer(operation: ActiveOperation): void {
689
+ if (this.flushTimer) clearInterval(this.flushTimer)
690
+ this.flushTimer = setInterval(() => this.evaluateFlush(operation), 500)
691
+ this.flushTimer.unref()
692
+ }
693
+
694
+ private evaluateFlush(operation: ActiveOperation): void {
695
+ if (this.operation !== operation || operation.flushStarted || this.phase !== 'preparing') return
696
+ const now = this.now()
697
+ for (const tabId of operation.pendingTabs) {
698
+ const lastSeen = this.tabs.get(tabId)
699
+ if (lastSeen === undefined || now - lastSeen > this.liveTabMs)
700
+ operation.pendingTabs.delete(tabId)
701
+ }
702
+
703
+ if (operation.pendingTabs.size > 0 && now >= operation.deadline) {
704
+ operation.flushStarted = true
705
+ this.clearFlushTimer()
706
+ void this.setFatal(
707
+ 'general',
708
+ 'Bosun could not confirm that every open tab saved its pending edits. Restart Bosun before continuing.'
709
+ )
710
+ return
711
+ }
712
+ if (operation.pendingTabs.size > 0) return
713
+
714
+ this.clearFlushTimer()
715
+ if (operation.blockedReasons.size > 0) {
716
+ this.deferredReason = [...new Set(operation.blockedReasons.values())].join('\n')
717
+ this.phase = 'idle'
718
+ this.operation = null
719
+ this.operationId = null
720
+ return
721
+ }
722
+ if (operation.stage === 'preflight') {
723
+ operation.pendingTabs = this.snapshotLiveTabs()
724
+ operation.acknowledgedTabs.clear()
725
+ operation.blockedReasons.clear()
726
+ operation.deadline = this.now() + this.flushTimeoutMs
727
+ operation.stage = 'browser-writes'
728
+ this.startFlushTimer(operation)
729
+ return
730
+ }
731
+
732
+ operation.flushStarted = true
733
+ // From this synchronous transition onward, the HTTP and WebSocket admission guards reject
734
+ // ordinary repository operations. Work admitted before the lock is drained below.
735
+ this.phase = 'flushing'
736
+ this.startOperation(() => this.runAfterFlush(operation))
737
+ }
738
+
739
+ private startOperation(operation: () => Promise<void>): void {
740
+ const task = operation()
741
+ .catch((error: unknown) => this.failFromError(error))
742
+ .finally(() => {
743
+ if (this.operationTask === task) this.operationTask = null
744
+ })
745
+ this.operationTask = task
746
+ }
747
+
748
+ private clearFlushTimer(): void {
749
+ if (!this.flushTimer) return
750
+ clearInterval(this.flushTimer)
751
+ this.flushTimer = null
752
+ }
753
+
754
+ private async runAfterFlush(operation: ActiveOperation): Promise<void> {
755
+ if (this.operation !== operation) return
756
+ await this.settleWrites()
757
+ if (this.operation !== operation) return
758
+ await this.validateRepository()
759
+ if (this.operation !== operation) return
760
+ operation.contentBaseline = await this.projectContentDigest()
761
+ if (this.operation !== operation) return
762
+ await this.writeContentRefreshJournal({
763
+ version: CONTENT_REFRESH_JOURNAL_VERSION,
764
+ baseline: operation.contentBaseline,
765
+ installedContent: null,
766
+ reloadToken: null
767
+ })
768
+ if (this.operation !== operation) return
769
+ this.phase = operation.kind === 'activation' ? 'refreshing' : 'processing'
770
+
771
+ if (operation.kind === 'prepare-release') {
772
+ await this.prepareReleaseThenFinish(operation)
773
+ return
774
+ }
775
+
776
+ if (operation.kind === 'manual') {
777
+ await this.refreshHasChanges(true)
778
+ if (this.hasChanges) {
779
+ if (!operation.choice) throw new Error('The manual Git operation has no selected action.')
780
+ await this.validateRepository()
781
+ await this.applyChoice(operation.choice)
782
+ }
783
+ }
784
+
785
+ await this.pullThenFinish(operation, operation.kind === 'activation')
786
+ }
787
+
788
+ private async continueAfterChanges(operation: ActiveOperation): Promise<void> {
789
+ if (this.operation !== operation || !operation.choice) return
790
+ await this.validateRepository()
791
+ if (this.operation !== operation) return
792
+ await this.applyChoice(operation.choice)
793
+ if (this.operation !== operation) return
794
+ await this.pullThenFinish(operation, false)
795
+ }
796
+
797
+ private async prepareReleaseThenFinish(operation: ActiveOperation): Promise<void> {
798
+ if (this.operation !== operation) return
799
+ await this.refreshHasChanges(true)
800
+ if (this.hasChanges) {
801
+ throw new Error('Save or discard local changes before preparing a release.')
802
+ }
803
+
804
+ const prePullHead = await this.revParse('HEAD')
805
+ try {
806
+ await this.pullOnce()
807
+ } catch (error) {
808
+ if (error instanceof CommandError && (await this.hasMergeConflict())) {
809
+ await this.handlePullMergeConflict(prePullHead)
810
+ return
811
+ }
812
+ throw error
813
+ }
814
+ if (this.operation !== operation || this.runtime.fatal) return
815
+
816
+ await this.pushAheadBranch()
817
+ if (this.operation !== operation) return
818
+ await this.fetchCurrentUpstream()
819
+ await this.validateRepository()
820
+
821
+ const personalHead = await this.revParse('HEAD')
822
+ const personalRemoteHead = await this.revParse(this.config.personalUpstream)
823
+ if (personalHead !== personalRemoteHead) {
824
+ throw new Error('The personal branch was not fully published before release preparation.')
825
+ }
826
+
827
+ const unreleasedCount = await this.commitCount(
828
+ `${this.config.currentUpstream}..${personalHead}`
829
+ )
830
+ if (unreleasedCount === 0) {
831
+ this.canPrepareRelease = false
832
+ this.prepareEligibilityRefreshAt = this.now()
833
+ await this.finishForContentChange(operation)
834
+ return
835
+ }
836
+
837
+ const currentRemoteHead = await this.revParse(this.config.currentUpstream)
838
+ const currentLocalRef = `refs/heads/${this.config.currentBranch}`
839
+ const currentLocalHead = await this.revParse(currentLocalRef)
840
+ if ((await this.commitCount(`${this.config.currentUpstream}..${currentLocalRef}`)) > 0) {
841
+ throw new Error(
842
+ `The local release branch ${this.config.currentBranch} has unpublished commits. Publish or remove them before preparing a release.`
843
+ )
844
+ }
845
+
846
+ const remoteUrlDigest = await this.validatedRemoteUrlDigest()
847
+ const remoteHeads = await this.readReleaseRemoteHeads()
848
+ if (remoteHeads.current !== currentRemoteHead || remoteHeads.personal !== personalRemoteHead) {
849
+ throw new Error('The release branches changed remotely while Bosun was preparing them.')
850
+ }
851
+
852
+ const journal: PrepareReleaseJournal = {
853
+ version: PREPARE_JOURNAL_VERSION,
854
+ currentBranch: this.config.currentBranch,
855
+ personalBranch: this.config.personalBranch,
856
+ remote: this.config.currentUpstreamRemote,
857
+ currentRemoteBranch: this.config.currentUpstreamBranch,
858
+ personalRemoteBranch: this.config.personalUpstreamBranch,
859
+ remoteUrlDigest,
860
+ oldCurrentRemoteHead: currentRemoteHead,
861
+ oldPersonalRemoteHead: personalRemoteHead,
862
+ oldCurrentLocalHead: currentLocalHead,
863
+ resultHead: null
864
+ }
865
+ await this.writePrepareJournal(journal)
866
+
867
+ let resultHead: string
868
+ try {
869
+ resultHead = await this.buildPrepareResult(currentRemoteHead, personalHead)
870
+ } catch (error) {
871
+ if (error instanceof PrepareMergeConflictError) {
872
+ await this.cleanupPrepareArtifacts()
873
+ await this.publishMergeConflict(personalHead, this.config.currentUpstreamBranch)
874
+ return
875
+ }
876
+ throw error
877
+ }
878
+
879
+ journal.resultHead = resultHead
880
+ await this.writePrepareJournal(journal)
881
+ await this.publishPreparedRelease(journal)
882
+ await this.alignPublishedRelease(journal)
883
+
884
+ this.canPrepareRelease = false
885
+ this.prepareEligibilityRefreshAt = this.now()
886
+ await this.finishForContentChange(operation, () => this.cleanupPrepareArtifacts())
887
+ }
888
+
889
+ private async finishForContentChange(
890
+ operation: ActiveOperation,
891
+ finalize: () => Promise<void> = async () => {}
892
+ ): Promise<void> {
893
+ if (!operation.contentBaseline) {
894
+ throw new Error('Bosun did not capture the project content before the Git operation.')
895
+ }
896
+ const finalContent = await this.projectContentDigest()
897
+ if (this.operation !== operation || this.runtime.fatal || this.disposed) return
898
+ if (finalContent === operation.contentBaseline) {
899
+ await this.completeWithoutRestart(operation, async () => {
900
+ await finalize()
901
+ await this.cleanupContentRefreshJournal()
902
+ })
903
+ return
904
+ }
905
+ await this.installProject()
906
+ if (this.operation !== operation) return
907
+ const reloadToken = this.randomId()
908
+ await this.writeContentRefreshJournal({
909
+ version: CONTENT_REFRESH_JOURNAL_VERSION,
910
+ baseline: operation.contentBaseline,
911
+ installedContent: await this.projectContentDigest(),
912
+ reloadToken
913
+ })
914
+ if (this.operation !== operation) return
915
+ await this.completeAndRestart(operation, finalize, reloadToken)
916
+ }
917
+
918
+ private async fetchCurrentUpstream(): Promise<void> {
919
+ await this.git(['fetch', '--no-tags', this.config.currentUpstreamRemote])
920
+ await this.revParse('--verify', this.config.currentUpstream)
921
+ }
922
+
923
+ private async buildPrepareResult(currentHead: string, personalHead: string): Promise<string> {
924
+ const isFastForward =
925
+ (
926
+ await this.git(['merge-base', '--is-ancestor', currentHead, personalHead], {
927
+ acceptedExitCodes: [0, 1]
928
+ })
929
+ ).exitCode === 0
930
+ if (isFastForward) {
931
+ await this.git(['update-ref', PREPARE_RESULT_REF, personalHead])
932
+ return personalHead
933
+ }
934
+
935
+ try {
936
+ await access(this.prepareWorkspacePath)
937
+ throw new Error(
938
+ `The prepare workspace already exists at ${this.prepareWorkspacePath}. Restart Bosun so it can recover it.`
939
+ )
940
+ } catch (error) {
941
+ if (!isNodeError(error, 'ENOENT')) throw error
942
+ }
943
+
944
+ await this.git([
945
+ 'clone',
946
+ '--shared',
947
+ '--no-checkout',
948
+ '--no-hardlinks',
949
+ this.config.projectDir,
950
+ this.prepareWorkspacePath
951
+ ])
952
+ await this.gitInPrepareWorkspace(['checkout', '--detach', '--quiet', currentHead])
953
+
954
+ const features = await this.gitFeatures()
955
+ try {
956
+ await this.gitInPrepareWorkspace([
957
+ 'merge',
958
+ '--no-ff',
959
+ '--no-commit',
960
+ '--no-edit',
961
+ '--no-gpg-sign',
962
+ '--no-verify-signatures',
963
+ `--strategy=${features.ortStrategy ? 'ort' : 'recursive'}`,
964
+ personalHead
965
+ ])
966
+ } catch (error) {
967
+ if (error instanceof CommandError) {
968
+ const unmerged = await this.gitInPrepareWorkspace(['ls-files', '-u'])
969
+ if (unmerged.stdout.trim()) throw new PrepareMergeConflictError()
970
+ }
971
+ throw error
972
+ }
973
+
974
+ const userName = (await this.git(['config', '--get', 'user.name'])).stdout.trim()
975
+ const userEmail = (await this.git(['config', '--get', 'user.email'])).stdout.trim()
976
+ if (!userName || !userEmail) {
977
+ throw new Error('Git user.name and user.email are required to prepare a release.')
978
+ }
979
+ await this.gitInPrepareWorkspace([
980
+ '-c',
981
+ `user.name=${userName}`,
982
+ '-c',
983
+ `user.email=${userEmail}`,
984
+ 'commit',
985
+ '--no-verify',
986
+ '--no-gpg-sign',
987
+ '-m',
988
+ `[BOSUN] [Merge] Merge ${this.config.personalBranch} into ${this.config.currentBranch}`
989
+ ])
990
+ const resultHead = (
991
+ await this.gitInPrepareWorkspace(['rev-parse', '--verify', 'HEAD'])
992
+ ).stdout.trim()
993
+ await this.git([
994
+ 'fetch',
995
+ '--no-tags',
996
+ this.prepareWorkspacePath,
997
+ `+${resultHead}:${PREPARE_RESULT_REF}`
998
+ ])
999
+ await rm(this.prepareWorkspacePath, { recursive: true, force: true })
1000
+ return resultHead
1001
+ }
1002
+
1003
+ private gitInPrepareWorkspace(args: readonly string[]): Promise<CommandResult> {
1004
+ return this.git(['-C', this.prepareWorkspacePath, ...args])
1005
+ }
1006
+
1007
+ private async publishPreparedRelease(journal: PrepareReleaseJournal): Promise<void> {
1008
+ if (!journal.resultHead) throw new Error('The prepared release does not have a result commit.')
1009
+ await this.assertReleaseBranchNotCheckedOut()
1010
+ if ((await this.validatedRemoteUrlDigest()) !== journal.remoteUrlDigest) {
1011
+ throw new Error('The shared upstream remote URL changed during release preparation.')
1012
+ }
1013
+ await this.git([
1014
+ 'push',
1015
+ '--atomic',
1016
+ '--no-verify',
1017
+ `--force-with-lease=refs/heads/${journal.currentRemoteBranch}:${journal.oldCurrentRemoteHead}`,
1018
+ `--force-with-lease=refs/heads/${journal.personalRemoteBranch}:${journal.oldPersonalRemoteHead}`,
1019
+ journal.remote,
1020
+ `${PREPARE_RESULT_REF}:refs/heads/${journal.currentRemoteBranch}`,
1021
+ `${PREPARE_RESULT_REF}:refs/heads/${journal.personalRemoteBranch}`
1022
+ ])
1023
+
1024
+ const remoteHeads = await this.readReleaseRemoteHeads()
1025
+ if (remoteHeads.current !== journal.resultHead || remoteHeads.personal !== journal.resultHead) {
1026
+ throw new Error('The remote did not retain the complete atomic release publication.')
1027
+ }
1028
+ }
1029
+
1030
+ private async alignPublishedRelease(journal: PrepareReleaseJournal): Promise<void> {
1031
+ if (!journal.resultHead) throw new Error('The published release has no result commit.')
1032
+ await this.assertReleaseBranchNotCheckedOut()
1033
+ await this.git(['fetch', '--no-tags', journal.remote])
1034
+ const localPersonalHead = await this.revParse('HEAD')
1035
+ if (
1036
+ localPersonalHead !== journal.oldPersonalRemoteHead &&
1037
+ localPersonalHead !== journal.resultHead
1038
+ ) {
1039
+ throw new Error('The local personal branch changed during release publication.')
1040
+ }
1041
+ const localCurrentHead = await this.revParse(`refs/heads/${journal.currentBranch}`)
1042
+ if (
1043
+ localCurrentHead !== journal.oldCurrentLocalHead &&
1044
+ localCurrentHead !== journal.resultHead
1045
+ ) {
1046
+ throw new Error('The local release branch changed during release publication.')
1047
+ }
1048
+
1049
+ if (localPersonalHead !== journal.resultHead) {
1050
+ await this.git(['merge', '--ff-only', PREPARE_RESULT_REF])
1051
+ }
1052
+ if (localCurrentHead !== journal.resultHead) {
1053
+ await this.git([
1054
+ 'update-ref',
1055
+ `refs/heads/${journal.currentBranch}`,
1056
+ journal.resultHead,
1057
+ localCurrentHead
1058
+ ])
1059
+ }
1060
+ }
1061
+
1062
+ private async commitCount(revisionRange: string): Promise<number> {
1063
+ const count = Number((await this.git(['rev-list', '--count', revisionRange])).stdout.trim())
1064
+ if (!Number.isSafeInteger(count) || count < 0) {
1065
+ throw new Error(`Git returned an invalid commit count for ${revisionRange}.`)
1066
+ }
1067
+ return count
1068
+ }
1069
+
1070
+ private async pullThenFinish(
1071
+ operation: ActiveOperation,
1072
+ allowChangesDialog: boolean
1073
+ ): Promise<void> {
1074
+ if (this.operation !== operation) return
1075
+ await this.validateRepository()
1076
+ if (this.operation !== operation) return
1077
+ const prePullHead = await this.revParse('HEAD')
1078
+
1079
+ try {
1080
+ await this.pullOnce()
1081
+ } catch (error) {
1082
+ if (error instanceof CommandError) {
1083
+ const output = `${error.result.stdout}\n${error.result.stderr}`
1084
+ if (allowChangesDialog && LOCAL_CHANGES_REFUSAL.some((pattern) => pattern.test(output))) {
1085
+ this.phase = 'awaiting-changes'
1086
+ this.hasChanges = true
1087
+ return
1088
+ }
1089
+ if (await this.hasMergeConflict()) {
1090
+ await this.handlePullMergeConflict(prePullHead)
1091
+ return
1092
+ }
1093
+ }
1094
+ throw error
1095
+ }
1096
+
1097
+ if (this.operation !== operation) return
1098
+ await this.fetchCurrentUpstream()
1099
+ if (this.operation !== operation) return
1100
+ const preCurrentMergeHead = await this.revParse('HEAD')
1101
+ const currentHead = await this.revParse(this.config.currentUpstream)
1102
+ try {
1103
+ await this.mergeCurrentUpstreamOnce(currentHead)
1104
+ } catch (error) {
1105
+ if (error instanceof CommandError) {
1106
+ const output = `${error.result.stdout}\n${error.result.stderr}`
1107
+ if (allowChangesDialog && LOCAL_CHANGES_REFUSAL.some((pattern) => pattern.test(output))) {
1108
+ this.phase = 'awaiting-changes'
1109
+ this.hasChanges = true
1110
+ return
1111
+ }
1112
+ if (await this.hasMergeConflict()) {
1113
+ await this.handleCurrentMergeConflict(preCurrentMergeHead, currentHead)
1114
+ return
1115
+ }
1116
+ }
1117
+ throw error
1118
+ }
1119
+
1120
+ if (this.operation !== operation || this.runtime.fatal) return
1121
+ await this.pushAheadBranch()
1122
+ if (this.operation !== operation) return
1123
+ await this.updatePrepareEligibilityFromFetchedRefs(currentHead)
1124
+ if (this.operation !== operation) return
1125
+ await this.finishForContentChange(operation)
1126
+ }
1127
+
1128
+ private async pullOnce(): Promise<void> {
1129
+ const features = await this.gitFeatures()
1130
+ const pullArguments = [
1131
+ 'pull',
1132
+ '--no-rebase',
1133
+ '--ff',
1134
+ '--no-commit',
1135
+ '--no-squash',
1136
+ '--no-edit',
1137
+ '--no-gpg-sign',
1138
+ '--no-verify-signatures',
1139
+ `--strategy=${features.ortStrategy ? 'ort' : 'recursive'}`,
1140
+ this.config.personalUpstreamRemote,
1141
+ this.config.personalUpstreamBranch
1142
+ ]
1143
+ if (features.mergeAutostashOption) pullArguments.splice(4, 0, '--no-autostash')
1144
+ await this.git(pullArguments)
1145
+
1146
+ // `--no-commit` makes a divergent pull stop with MERGE_HEAD before creating its commit.
1147
+ // Create that commit once with its final Bosun subject, eliminating an unlabeled-then-amend
1148
+ // crash window. Fast-forwards still complete normally and have no MERGE_HEAD.
1149
+ await this.commitPendingMerge(
1150
+ `[BOSUN] [Merge] Merge ${this.config.personalUpstream} into ${this.config.personalBranch}`
1151
+ )
1152
+ }
1153
+
1154
+ private async mergeCurrentUpstreamOnce(currentHead: string): Promise<void> {
1155
+ const features = await this.gitFeatures()
1156
+ const mergeArguments = [
1157
+ 'merge',
1158
+ '--ff',
1159
+ '--no-commit',
1160
+ '--no-squash',
1161
+ '--no-edit',
1162
+ '--no-gpg-sign',
1163
+ '--no-verify-signatures',
1164
+ `--strategy=${features.ortStrategy ? 'ort' : 'recursive'}`,
1165
+ currentHead
1166
+ ]
1167
+ if (features.mergeAutostashOption) mergeArguments.splice(4, 0, '--no-autostash')
1168
+ await this.git(mergeArguments)
1169
+ await this.commitPendingMerge(
1170
+ `[BOSUN] [Merge] Merge ${this.config.currentUpstream} into ${this.config.personalBranch}`
1171
+ )
1172
+ }
1173
+
1174
+ private async commitPendingMerge(subject: string): Promise<void> {
1175
+ if (!(await this.gitPathExists('MERGE_HEAD'))) return
1176
+ await this.git(['commit', '--no-verify', '--no-gpg-sign', '-m', subject])
1177
+ }
1178
+
1179
+ private async pushAheadBranch(): Promise<void> {
1180
+ const counts = (
1181
+ await this.git([
1182
+ 'rev-list',
1183
+ '--left-right',
1184
+ '--count',
1185
+ `HEAD...${this.config.personalUpstream}`
1186
+ ])
1187
+ ).stdout
1188
+ .trim()
1189
+ .split(/\s+/)
1190
+ .map(Number)
1191
+ if (counts.length !== 2 || counts.some((count) => !Number.isSafeInteger(count))) {
1192
+ throw new Error('Git returned invalid ahead/behind counts for the configured upstream.')
1193
+ }
1194
+ const [ahead, behind] = counts
1195
+ if (behind > 0) {
1196
+ throw new Error('The configured branch is still behind its upstream after pulling.')
1197
+ }
1198
+ if (ahead === 0) return
1199
+
1200
+ await this.git([
1201
+ 'push',
1202
+ '--no-verify',
1203
+ this.config.personalUpstreamRemote,
1204
+ `HEAD:refs/heads/${this.config.personalUpstreamBranch}`
1205
+ ])
1206
+ }
1207
+
1208
+ private async applyChoice(choice: ChangeChoice): Promise<void> {
1209
+ if (choice.action === 'discard') {
1210
+ await this.git([
1211
+ 'stash',
1212
+ 'push',
1213
+ '--include-untracked',
1214
+ '-m',
1215
+ `[BOSUN] Discard before refresh ${new Date(this.now()).toISOString()}`
1216
+ ])
1217
+ this.hasChanges = false
1218
+ this.changeRefreshAt = this.now()
1219
+ return
1220
+ }
1221
+
1222
+ const normalized = this.normalizeChoice(choice)
1223
+ if (!normalized.description) throw new Error('A description is required when saving changes.')
1224
+ await this.git(['add', '-A'])
1225
+ const staged = await this.git(['diff', '--cached', '--quiet'], {
1226
+ acceptedExitCodes: [0, 1]
1227
+ })
1228
+ if (staged.exitCode === 0) {
1229
+ throw new Error('There are no changes to save or discard.')
1230
+ }
1231
+
1232
+ await this.git([
1233
+ 'commit',
1234
+ '--no-verify',
1235
+ '--no-gpg-sign',
1236
+ '-m',
1237
+ `[BOSUN] ${normalized.description}`
1238
+ ])
1239
+ this.hasChanges = false
1240
+ this.changeRefreshAt = this.now()
1241
+ }
1242
+
1243
+ private validateChoice(choice: ChangeChoice): void {
1244
+ if (choice.action !== 'save') return
1245
+ const normalized = this.normalizeChoice(choice)
1246
+ if (!normalized.description) throw new Error('Describe your changes before saving.')
1247
+ }
1248
+
1249
+ private normalizeChoice(choice: ChangeChoice): ChangeChoice {
1250
+ return {
1251
+ action: choice.action,
1252
+ description: choice.description?.trim().replace(/\s+/g, ' ')
1253
+ }
1254
+ }
1255
+
1256
+ private assertManualOperationAvailable(): void {
1257
+ if (!this.operation && this.phase === 'idle') return
1258
+ throw new Error('Another Bosun Git operation is already in progress.')
1259
+ }
1260
+
1261
+ private async hasMergeConflict(): Promise<boolean> {
1262
+ const unmerged = await this.git(['ls-files', '-u'])
1263
+ // A clean `pull --no-commit` intentionally has MERGE_HEAD until Bosun creates its labelled
1264
+ // merge commit. Only unmerged index entries prove a conflict; treating MERGE_HEAD itself as
1265
+ // one would misclassify an unrelated commit failure and publish a bogus conflict branch.
1266
+ return Boolean(unmerged.stdout.trim())
1267
+ }
1268
+
1269
+ private async handlePullMergeConflict(prePullHead: string): Promise<void> {
1270
+ try {
1271
+ await this.git(['merge', '--abort'])
1272
+ const currentHead = await this.revParse('HEAD')
1273
+ if (currentHead !== prePullHead) {
1274
+ throw new Error('Aborting the failed merge did not restore the pre-merge commit.')
1275
+ }
1276
+
1277
+ await this.publishMergeConflict(prePullHead, this.config.personalUpstreamBranch)
1278
+ } catch (error) {
1279
+ if (this.runtime.fatal?.kind === 'merge-conflict') return
1280
+ throw new Error('Bosun could not safely publish the merge-conflict branch.', { cause: error })
1281
+ }
1282
+ }
1283
+
1284
+ private async handleCurrentMergeConflict(
1285
+ preMergeHead: string,
1286
+ currentHead: string
1287
+ ): Promise<void> {
1288
+ try {
1289
+ await this.git(['merge', '--abort'])
1290
+ const restoredHead = await this.revParse('HEAD')
1291
+ if (restoredHead !== preMergeHead) {
1292
+ throw new Error('Aborting the failed merge did not restore the pre-merge commit.')
1293
+ }
1294
+
1295
+ await this.pushAheadBranch()
1296
+ await this.publishMergeConflict(currentHead, this.config.personalUpstreamBranch)
1297
+ } catch (error) {
1298
+ if (this.runtime.fatal?.kind === 'merge-conflict') return
1299
+ throw new Error('Bosun could not safely publish the merge-conflict branch.', { cause: error })
1300
+ }
1301
+ }
1302
+
1303
+ private async publishMergeConflict(sourceHead: string, baseBranch: string): Promise<void> {
1304
+ const remoteUrl = (
1305
+ await this.git(['config', '--get', `remote.${this.config.personalUpstreamRemote}.url`])
1306
+ ).stdout.trim()
1307
+ const repository = parseGitHubRepository(remoteUrl)
1308
+ if (!repository) {
1309
+ throw new Error(`The upstream remote is not a supported GitHub URL: ${remoteUrl}`)
1310
+ }
1311
+
1312
+ const conflictBranch = this.createConflictBranchName(baseBranch)
1313
+ const prUrl = createPullRequestUrl(repository, baseBranch, conflictBranch)
1314
+ await this.git(['branch', conflictBranch, sourceHead])
1315
+ await this.git([
1316
+ 'push',
1317
+ '--no-verify',
1318
+ this.config.personalUpstreamRemote,
1319
+ `${sourceHead}:refs/heads/${conflictBranch}`
1320
+ ])
1321
+
1322
+ await this.setFatal(
1323
+ 'merge-conflict',
1324
+ 'There was a merge conflict during refresh. Please let the dev team know that it needs resolving with the provided pull-request link. Unfortunately, you will not be able to proceed until this is resolved by them. Restart Bosun after the team has confirmed that the conflict has been resolved.',
1325
+ prUrl
1326
+ )
1327
+ }
1328
+
1329
+ private createConflictBranchName(baseBranch: string): string {
1330
+ const timestamp = new Date(this.now()).toISOString().replace(/[-:.]/g, '')
1331
+ const suffix = randomBytes(4).toString('hex')
1332
+ return `bosun-conflict/${baseBranch}/${timestamp}-${suffix}`
1333
+ }
1334
+
1335
+ private async contentRefreshJournalExists(): Promise<boolean> {
1336
+ try {
1337
+ await access(this.contentRefreshJournalPath)
1338
+ return true
1339
+ } catch (error) {
1340
+ if (isNodeError(error, 'ENOENT')) return false
1341
+ throw error
1342
+ }
1343
+ }
1344
+
1345
+ private async recoverContentRefresh(): Promise<void> {
1346
+ await this.validateRepository()
1347
+ const journal = await this.readContentRefreshJournal()
1348
+ const currentContent = await this.projectContentDigest()
1349
+ if (currentContent === journal.baseline) {
1350
+ await this.validateRepository()
1351
+ await this.cleanupContentRefreshJournal()
1352
+ await this.resumeWrites()
1353
+ return
1354
+ }
1355
+
1356
+ if (journal.installedContent === currentContent && journal.reloadToken) {
1357
+ if (this.runtime.reloadToken !== journal.reloadToken) {
1358
+ this.runtime.reloadToken = journal.reloadToken
1359
+ await this.queueRuntimePersist()
1360
+ }
1361
+ await this.validateRepository()
1362
+ await this.cleanupContentRefreshJournal()
1363
+ await this.resumeWrites()
1364
+ return
1365
+ }
1366
+
1367
+ await this.installProject()
1368
+ const installedContent = await this.projectContentDigest()
1369
+ const reloadToken = this.randomId()
1370
+ await this.writeContentRefreshJournal({
1371
+ version: CONTENT_REFRESH_JOURNAL_VERSION,
1372
+ baseline: journal.baseline,
1373
+ installedContent,
1374
+ reloadToken
1375
+ })
1376
+ await this.validateRepository()
1377
+ this.runtime.reloadToken = reloadToken
1378
+ await this.queueRuntimePersist()
1379
+ const scheduled = await this.scheduleRestart(this.config.restartExitCode)
1380
+ if (!scheduled) throw new Error('The managed Bosun backend restart is not available.')
1381
+ this.phase = 'restarting'
1382
+ }
1383
+
1384
+ private async readContentRefreshJournal(): Promise<ContentRefreshJournal> {
1385
+ let value: unknown
1386
+ try {
1387
+ value = JSON.parse(await readFile(this.contentRefreshJournalPath, 'utf8'))
1388
+ } catch (error) {
1389
+ throw new Error('Bosun could not read the interrupted project refresh safely.', {
1390
+ cause: error
1391
+ })
1392
+ }
1393
+ if (!isContentRefreshJournal(value)) {
1394
+ throw new Error('The interrupted project refresh record is invalid.')
1395
+ }
1396
+ return value
1397
+ }
1398
+
1399
+ private writeContentRefreshJournal(journal: ContentRefreshJournal): Promise<void> {
1400
+ return writeDurableJson(this.contentRefreshJournalPath, journal)
1401
+ }
1402
+
1403
+ private async cleanupContentRefreshJournal(): Promise<void> {
1404
+ await unlink(this.contentRefreshJournalPath).catch((error: unknown) => {
1405
+ if (!isNodeError(error, 'ENOENT')) throw error
1406
+ })
1407
+ await syncDirectory(path.dirname(this.contentRefreshJournalPath))
1408
+ }
1409
+
1410
+ private async prepareJournalExists(): Promise<boolean> {
1411
+ try {
1412
+ await access(this.prepareJournalPath)
1413
+ return true
1414
+ } catch (error) {
1415
+ if (isNodeError(error, 'ENOENT')) return false
1416
+ throw error
1417
+ }
1418
+ }
1419
+
1420
+ private async recoverPrepareRelease(): Promise<void> {
1421
+ await this.validateRepository()
1422
+ if (!(await this.contentRefreshJournalExists())) {
1423
+ throw new Error('The interrupted release is missing its project-content baseline.')
1424
+ }
1425
+ const journal = await this.readPrepareJournal()
1426
+ this.assertJournalMatchesConfig(journal, await this.validatedRemoteUrlDigest())
1427
+ const remoteHeads = await this.readReleaseRemoteHeads()
1428
+ const remoteIsOld =
1429
+ remoteHeads.current === journal.oldCurrentRemoteHead &&
1430
+ remoteHeads.personal === journal.oldPersonalRemoteHead
1431
+ const remoteIsResult =
1432
+ journal.resultHead !== null &&
1433
+ remoteHeads.current === journal.resultHead &&
1434
+ remoteHeads.personal === journal.resultHead
1435
+
1436
+ if (remoteIsOld) {
1437
+ this.runtime.fatal = null
1438
+ await this.queueRuntimePersist()
1439
+ await this.cleanupPrepareArtifacts()
1440
+ await this.recoverContentRefresh()
1441
+ return
1442
+ }
1443
+ if (!remoteIsResult || !journal.resultHead) {
1444
+ throw new Error(
1445
+ 'Bosun found an interrupted release publication, but the two remote branches are not both at their recorded old commits or both at the prepared result. Bosun will not guess which remote state is safe.'
1446
+ )
1447
+ }
1448
+
1449
+ await this.refreshHasChanges(true)
1450
+ if (this.hasChanges) {
1451
+ throw new Error(
1452
+ 'Bosun published a release before shutdown, but local changes now prevent safe recovery.'
1453
+ )
1454
+ }
1455
+ this.runtime.fatal = null
1456
+ await this.git(['fetch', '--no-tags', journal.remote])
1457
+ await this.revParse('--verify', journal.resultHead)
1458
+ await this.git(['update-ref', PREPARE_RESULT_REF, journal.resultHead])
1459
+ await this.alignPublishedRelease(journal)
1460
+
1461
+ this.canPrepareRelease = false
1462
+ const completedAt = this.now()
1463
+ this.runtime.firstActivationCompleted = true
1464
+ this.runtime.lastCompletedActivationAt = completedAt
1465
+ this.hasChanges = false
1466
+ this.changeRefreshAt = completedAt
1467
+ await this.queueRuntimePersist()
1468
+ await this.cleanupPrepareArtifacts()
1469
+ await this.recoverContentRefresh()
1470
+ }
1471
+
1472
+ private async readPrepareJournal(): Promise<PrepareReleaseJournal> {
1473
+ let value: unknown
1474
+ try {
1475
+ value = JSON.parse(await readFile(this.prepareJournalPath, 'utf8'))
1476
+ } catch (error) {
1477
+ throw new Error('Bosun could not read the interrupted release journal safely.', {
1478
+ cause: error
1479
+ })
1480
+ }
1481
+ if (!isPrepareReleaseJournal(value)) {
1482
+ throw new Error('The interrupted release journal is invalid.')
1483
+ }
1484
+ return value
1485
+ }
1486
+
1487
+ private assertJournalMatchesConfig(
1488
+ journal: PrepareReleaseJournal,
1489
+ remoteUrlDigest: string
1490
+ ): void {
1491
+ if (
1492
+ journal.currentBranch !== this.config.currentBranch ||
1493
+ journal.personalBranch !== this.config.personalBranch ||
1494
+ journal.remote !== this.config.currentUpstreamRemote ||
1495
+ journal.currentRemoteBranch !== this.config.currentUpstreamBranch ||
1496
+ journal.personalRemoteBranch !== this.config.personalUpstreamBranch ||
1497
+ journal.remoteUrlDigest !== remoteUrlDigest
1498
+ ) {
1499
+ throw new Error(
1500
+ 'The interrupted release journal does not match the configured release and personal branches.'
1501
+ )
1502
+ }
1503
+ }
1504
+
1505
+ private async writePrepareJournal(journal: PrepareReleaseJournal): Promise<void> {
1506
+ await writeDurableJson(this.prepareJournalPath, journal)
1507
+ }
1508
+
1509
+ private async cleanupPrepareArtifacts(): Promise<void> {
1510
+ await rm(this.prepareWorkspacePath, { recursive: true, force: true })
1511
+ await this.git(['update-ref', '-d', PREPARE_RESULT_REF])
1512
+ await unlink(this.prepareJournalPath).catch((error: unknown) => {
1513
+ if (!isNodeError(error, 'ENOENT')) throw error
1514
+ })
1515
+ await syncDirectory(path.dirname(this.prepareJournalPath))
1516
+ }
1517
+
1518
+ private async readReleaseRemoteHeads(): Promise<{
1519
+ current: string
1520
+ personal: string
1521
+ }> {
1522
+ const currentRef = `refs/heads/${this.config.currentUpstreamBranch}`
1523
+ const personalRef = `refs/heads/${this.config.personalUpstreamBranch}`
1524
+ const output = (
1525
+ await this.git([
1526
+ 'ls-remote',
1527
+ '--heads',
1528
+ this.config.currentUpstreamRemote,
1529
+ currentRef,
1530
+ personalRef
1531
+ ])
1532
+ ).stdout
1533
+ const heads = new Map<string, string>()
1534
+ for (const line of output.trim().split('\n')) {
1535
+ if (!line) continue
1536
+ const [objectId, ref, extra] = line.trim().split(/\s+/)
1537
+ if (!objectId || !ref || extra || heads.has(ref)) {
1538
+ throw new Error('Git returned invalid remote branch information.')
1539
+ }
1540
+ heads.set(ref, objectId)
1541
+ }
1542
+ const current = heads.get(currentRef)
1543
+ const personal = heads.get(personalRef)
1544
+ if (!current || !personal) {
1545
+ throw new Error('The configured release or personal branch does not exist remotely.')
1546
+ }
1547
+ return { current, personal }
1548
+ }
1549
+
1550
+ private async completeAndRestart(
1551
+ operation: ActiveOperation,
1552
+ finalize: () => Promise<void> = async () => {},
1553
+ reloadToken = this.randomId()
1554
+ ): Promise<void> {
1555
+ if (this.operation !== operation || this.runtime.fatal) return
1556
+ const completedAt = this.now()
1557
+ this.runtime.firstActivationCompleted = true
1558
+ this.runtime.lastCompletedActivationAt = completedAt
1559
+ this.runtime.reloadToken = reloadToken
1560
+ this.hasChanges = false
1561
+ this.canPrepareRelease = false
1562
+ this.changeRefreshAt = completedAt
1563
+ await this.queueRuntimePersist()
1564
+ if (this.operation !== operation || this.runtime.fatal) return
1565
+ await finalize()
1566
+ if (this.operation !== operation || this.runtime.fatal) return
1567
+
1568
+ const scheduled = await this.scheduleRestart(this.config.restartExitCode)
1569
+ if (!scheduled) throw new Error('The managed Bosun backend restart is not available.')
1570
+ if (this.operation !== operation || this.runtime.fatal) return
1571
+ this.phase = 'restarting'
1572
+ }
1573
+
1574
+ private async completeWithoutRestart(
1575
+ operation: ActiveOperation,
1576
+ finalize: () => Promise<void> = async () => {}
1577
+ ): Promise<void> {
1578
+ if (this.operation !== operation || this.runtime.fatal || this.disposed) return
1579
+ await this.refreshHasChanges(true)
1580
+ if (this.operation !== operation || this.runtime.fatal || this.disposed) return
1581
+
1582
+ const completedAt = this.now()
1583
+ this.runtime.firstActivationCompleted = true
1584
+ this.runtime.lastCompletedActivationAt = completedAt
1585
+ this.changeRefreshAt = completedAt
1586
+ await this.queueRuntimePersist()
1587
+ if (this.operation !== operation || this.runtime.fatal || this.disposed) return
1588
+ await finalize()
1589
+ if (this.operation !== operation || this.runtime.fatal || this.disposed) return
1590
+
1591
+ // Keep backend admission blocked until the cross-process gate is gone. Once unlock resolves,
1592
+ // finish synchronously so no ordinary request can enter between the two states.
1593
+ await this.resumeWrites()
1594
+ if (this.operation !== operation || this.runtime.fatal || this.disposed) {
1595
+ // Shutdown or a concurrent fatal transition may have won while the filesystem operation was
1596
+ // in flight. Restore the gate before yielding instead of reopening repository writes.
1597
+ await this.settleWrites()
1598
+ return
1599
+ }
1600
+ this.operation = null
1601
+ this.operationId = null
1602
+ this.deferredReason = null
1603
+ this.phase = 'idle'
1604
+ }
1605
+
1606
+ private async validateRepository(): Promise<void> {
1607
+ if (this.config.currentBranch === this.config.personalBranch) {
1608
+ throw new RepositoryValidationError(
1609
+ 'BOSUN_CURRENT_BRANCH and BOSUN_PERSONAL_BRANCH must name different branches.'
1610
+ )
1611
+ }
1612
+ if (this.config.currentUpstreamRemote !== this.config.personalUpstreamRemote) {
1613
+ throw new RepositoryValidationError(
1614
+ 'The release and personal branches must use the same upstream remote.'
1615
+ )
1616
+ }
1617
+ if (this.config.currentUpstreamBranch === this.config.personalUpstreamBranch) {
1618
+ throw new RepositoryValidationError(
1619
+ 'The release and personal branches must track different remote branches.'
1620
+ )
1621
+ }
1622
+ if (
1623
+ this.config.currentUpstream !==
1624
+ `${this.config.currentUpstreamRemote}/${this.config.currentUpstreamBranch}` ||
1625
+ this.config.personalUpstream !==
1626
+ `${this.config.personalUpstreamRemote}/${this.config.personalUpstreamBranch}`
1627
+ ) {
1628
+ throw new RepositoryValidationError(
1629
+ 'The configured upstream names do not match their remote and branch fields.'
1630
+ )
1631
+ }
1632
+
1633
+ let configuredRoot: string
1634
+ let actualRoot: string
1635
+ try {
1636
+ configuredRoot = await realpath(this.config.projectDir)
1637
+ actualRoot = await realpath((await this.git(['rev-parse', '--show-toplevel'])).stdout.trim())
1638
+ } catch (error) {
1639
+ throw new RepositoryValidationError(
1640
+ 'Bosun is not running in a valid Git repository.',
1641
+ 'general',
1642
+ {
1643
+ cause: error
1644
+ }
1645
+ )
1646
+ }
1647
+ if (configuredRoot !== actualRoot) {
1648
+ throw new RepositoryValidationError(
1649
+ 'Git resolved a different repository than the Bosun project.'
1650
+ )
1651
+ }
1652
+
1653
+ const gitMarker = path.join(configuredRoot, '.git')
1654
+ try {
1655
+ const marker = await lstat(gitMarker)
1656
+ if (!marker.isDirectory() || marker.isSymbolicLink())
1657
+ throw new Error('.git is not a directory')
1658
+ const markerRealPath = await realpath(gitMarker)
1659
+ if (
1660
+ path.dirname(markerRealPath) !== configuredRoot ||
1661
+ path.basename(markerRealPath) !== '.git'
1662
+ ) {
1663
+ throw new Error('The .git marker resolves outside the project.')
1664
+ }
1665
+ } catch (error) {
1666
+ throw new RepositoryValidationError(
1667
+ 'Linked worktrees and submodule checkouts are not supported by Bosun Git mode.',
1668
+ 'general'
1669
+ )
1670
+ }
1671
+
1672
+ const superproject = (
1673
+ await this.git(['rev-parse', '--show-superproject-working-tree'])
1674
+ ).stdout.trim()
1675
+ if (superproject) {
1676
+ throw new RepositoryValidationError(
1677
+ 'Submodule checkouts are not supported by Bosun Git mode.'
1678
+ )
1679
+ }
1680
+ const trackedSubmodules = await this.git(['ls-files', '--stage'])
1681
+ if (trackedSubmodules.stdout.split('\n').some((line) => line.startsWith('160000 '))) {
1682
+ throw new RepositoryValidationError(
1683
+ 'Repositories containing tracked submodules are not supported by Bosun Git mode.'
1684
+ )
1685
+ }
1686
+ await this.revParse('--verify', 'HEAD').catch((error) => {
1687
+ throw new RepositoryValidationError(
1688
+ 'The repository does not have a committed HEAD.',
1689
+ 'general',
1690
+ {
1691
+ cause: error
1692
+ }
1693
+ )
1694
+ })
1695
+
1696
+ const branchResult = await this.git(['symbolic-ref', '--quiet', '--short', 'HEAD'], {
1697
+ acceptedExitCodes: [0, 1]
1698
+ })
1699
+ const branch = branchResult.exitCode === 0 ? branchResult.stdout.trim() : ''
1700
+ if (!branch) throw new RepositoryValidationError('Bosun cannot run from a detached HEAD.')
1701
+ if (branch !== this.config.personalBranch) {
1702
+ throw new RepositoryValidationError(
1703
+ `Bosun is on branch "${branch}", but Git mode requires the personal branch "${this.config.personalBranch}". Restart Bosun after running: git switch ${this.config.personalBranch}`,
1704
+ 'branch-mismatch'
1705
+ )
1706
+ }
1707
+
1708
+ await this.assertReleaseBranchNotCheckedOut()
1709
+ await this.validateRemotePublicationTarget()
1710
+
1711
+ const upstreamResult = await this.git([
1712
+ 'rev-parse',
1713
+ '--abbrev-ref',
1714
+ '--symbolic-full-name',
1715
+ '@{upstream}'
1716
+ ])
1717
+ if (upstreamResult.stdout.trim() !== this.config.personalUpstream) {
1718
+ throw new RepositoryValidationError(
1719
+ `The personal branch must track ${this.config.personalUpstream}, but it tracks ${upstreamResult.stdout.trim() || 'nothing'}.`
1720
+ )
1721
+ }
1722
+ await this.revParse('--verify', this.config.personalUpstream)
1723
+ await this.revParse('--verify', `refs/heads/${this.config.currentBranch}`)
1724
+ await this.revParse('--verify', this.config.currentUpstream)
1725
+
1726
+ const currentUpstream = (
1727
+ await this.git([
1728
+ 'for-each-ref',
1729
+ '--format=%(upstream:short)',
1730
+ `refs/heads/${this.config.currentBranch}`
1731
+ ])
1732
+ ).stdout.trim()
1733
+ if (currentUpstream !== this.config.currentUpstream) {
1734
+ throw new RepositoryValidationError(
1735
+ `The release branch must track ${this.config.currentUpstream}, but it tracks ${currentUpstream || 'nothing'}.`
1736
+ )
1737
+ }
1738
+
1739
+ for (const gitPath of [
1740
+ 'MERGE_HEAD',
1741
+ 'rebase-merge',
1742
+ 'rebase-apply',
1743
+ 'CHERRY_PICK_HEAD',
1744
+ 'REVERT_HEAD',
1745
+ 'BISECT_START',
1746
+ 'BISECT_LOG',
1747
+ 'sequencer'
1748
+ ]) {
1749
+ if (await this.gitPathExists(gitPath)) {
1750
+ throw new RepositoryValidationError(
1751
+ 'A Git merge, rebase, cherry-pick, revert, bisect, or sequenced operation is already in progress. Resolve it and restart Bosun.'
1752
+ )
1753
+ }
1754
+ }
1755
+ }
1756
+
1757
+ private async assertReleaseBranchNotCheckedOut(): Promise<void> {
1758
+ const worktrees = (await this.git(['worktree', 'list', '--porcelain'])).stdout
1759
+ if (
1760
+ !worktrees
1761
+ .split('\n')
1762
+ .some((line) => line.trim() === `branch refs/heads/${this.config.currentBranch}`)
1763
+ ) {
1764
+ return
1765
+ }
1766
+ throw new RepositoryValidationError(
1767
+ `The release branch ${this.config.currentBranch} is checked out in another Git worktree. Remove that worktree and restart Bosun.`
1768
+ )
1769
+ }
1770
+
1771
+ private async validateRemotePublicationTarget(): Promise<string> {
1772
+ const fetchUrls = (
1773
+ await this.git(['remote', 'get-url', '--all', this.config.currentUpstreamRemote])
1774
+ ).stdout
1775
+ .trim()
1776
+ .split('\n')
1777
+ .filter(Boolean)
1778
+ const pushUrls = (
1779
+ await this.git(['remote', 'get-url', '--push', '--all', this.config.currentUpstreamRemote])
1780
+ ).stdout
1781
+ .trim()
1782
+ .split('\n')
1783
+ .filter(Boolean)
1784
+ if (fetchUrls.length !== 1 || pushUrls.length !== 1 || fetchUrls[0] !== pushUrls[0]) {
1785
+ throw new RepositoryValidationError(
1786
+ 'The shared upstream remote must have exactly one fetch URL and one matching push URL.'
1787
+ )
1788
+ }
1789
+ return fetchUrls[0]
1790
+ }
1791
+
1792
+ private async validatedRemoteUrlDigest(): Promise<string> {
1793
+ return createHash('sha256')
1794
+ .update(await this.validateRemotePublicationTarget())
1795
+ .digest('hex')
1796
+ }
1797
+
1798
+ private async gitPathExists(name: string): Promise<boolean> {
1799
+ const rawPath = (await this.git(['rev-parse', '--git-path', name])).stdout.trim()
1800
+ if (!rawPath) return false
1801
+ const resolved = path.isAbsolute(rawPath)
1802
+ ? rawPath
1803
+ : path.resolve(this.config.projectDir, rawPath)
1804
+ try {
1805
+ await access(resolved)
1806
+ return true
1807
+ } catch (error) {
1808
+ if (isNodeError(error, 'ENOENT')) return false
1809
+ throw error
1810
+ }
1811
+ }
1812
+
1813
+ private async projectContentDigest(): Promise<string> {
1814
+ // Git refs and the index can change when Save only records content that is already on disk.
1815
+ // Hash the checkout itself so only a user-visible project-file change requires a restart.
1816
+ const listed = await this.git(['ls-files', '--cached', '--others', '--exclude-standard', '-z'])
1817
+ const listedBytes = listed.stdoutBuffer ?? Buffer.from(listed.stdout)
1818
+ const relativePaths = splitNullDelimitedBuffers(listedBytes)
1819
+ const digest = createHash('sha256')
1820
+ const update = (value: string | Buffer) => {
1821
+ const bytes = typeof value === 'string' ? Buffer.from(value) : value
1822
+ const length = Buffer.allocUnsafe(8)
1823
+ length.writeBigUInt64BE(BigInt(bytes.length))
1824
+ digest.update(length)
1825
+ digest.update(bytes)
1826
+ }
1827
+
1828
+ for (const relativePath of relativePaths) {
1829
+ if (!isRepositoryRelativePath(relativePath)) {
1830
+ throw new Error('Git returned a project path outside the repository.')
1831
+ }
1832
+ const absolutePath = Buffer.concat([
1833
+ Buffer.from(`${this.config.projectDir}${path.sep}`),
1834
+ relativePath
1835
+ ])
1836
+
1837
+ update(relativePath)
1838
+ let file: Awaited<ReturnType<typeof lstat>>
1839
+ try {
1840
+ file = await lstat(absolutePath)
1841
+ } catch (error) {
1842
+ if (!isNodeError(error, 'ENOENT')) throw error
1843
+ update('missing')
1844
+ continue
1845
+ }
1846
+
1847
+ if (file.isSymbolicLink()) {
1848
+ update('symlink')
1849
+ update(await readlink(absolutePath, { encoding: 'buffer' }))
1850
+ continue
1851
+ }
1852
+ if (file.isFile()) {
1853
+ update(file.mode & 0o111 ? 'file-executable' : 'file')
1854
+ update(await readFile(absolutePath))
1855
+ continue
1856
+ }
1857
+ update(`other-${file.mode}`)
1858
+ }
1859
+
1860
+ return digest.digest('hex')
1861
+ }
1862
+
1863
+ private async revParse(...revisions: string[]): Promise<string> {
1864
+ return (await this.git(['rev-parse', ...revisions])).stdout.trim()
1865
+ }
1866
+
1867
+ private async git(
1868
+ args: readonly string[],
1869
+ options: { input?: string | Buffer; acceptedExitCodes?: readonly number[] } = {}
1870
+ ): Promise<CommandResult> {
1871
+ const operation = this.gitQueue.then(() =>
1872
+ this.command(
1873
+ 'git',
1874
+ [
1875
+ '-c',
1876
+ `core.hooksPath=${this.hooksDir}`,
1877
+ '-c',
1878
+ 'commit.gpgSign=false',
1879
+ '-c',
1880
+ 'tag.gpgSign=false',
1881
+ '-c',
1882
+ 'push.gpgSign=false',
1883
+ '-c',
1884
+ 'merge.autoEdit=no',
1885
+ '-c',
1886
+ 'merge.gpgSign=false',
1887
+ '-c',
1888
+ 'merge.autostash=false',
1889
+ '-c',
1890
+ 'rebase.autostash=false',
1891
+ ...args
1892
+ ],
1893
+ {
1894
+ cwd: this.config.projectDir,
1895
+ env: this.nonInteractiveEnvironment(),
1896
+ input: options.input,
1897
+ timeoutMs: GIT_TIMEOUT_MS,
1898
+ acceptedExitCodes: options.acceptedExitCodes
1899
+ }
1900
+ )
1901
+ )
1902
+ this.gitQueue = operation.then(
1903
+ () => undefined,
1904
+ () => undefined
1905
+ )
1906
+ return operation
1907
+ }
1908
+
1909
+ private async prevalidateStart(tabId: string, rejectBusy = false): Promise<boolean> {
1910
+ try {
1911
+ await this.validateRepository()
1912
+ return true
1913
+ } catch (error) {
1914
+ // Another tab can win the operation while this read-only validation is running. Its
1915
+ // operation owns the outcome; otherwise make validation failures visible before the
1916
+ // browser is blurred or asked to persist anything.
1917
+ if (!this.operation && this.phase === 'idle') await this.failFromError(error)
1918
+ else if (rejectBusy) throw new Error('Another Bosun Git operation is already in progress.')
1919
+ this.registerTab(tabId)
1920
+ return false
1921
+ }
1922
+ }
1923
+
1924
+ private gitFeatures(): Promise<GitFeatures> {
1925
+ if (this.gitFeaturesPromise) return this.gitFeaturesPromise
1926
+ this.gitFeaturesPromise = this.git(['version']).then((result) => {
1927
+ const match = /git version (\d+)\.(\d+)(?:\.(\d+))?/i.exec(result.stdout)
1928
+ if (!match)
1929
+ throw new Error(`Could not parse the installed Git version: ${result.stdout.trim()}`)
1930
+ const version = [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)] as const
1931
+ return {
1932
+ mergeAutostashOption: versionAtLeast(version, [2, 27, 0]),
1933
+ ortStrategy: versionAtLeast(version, [2, 34, 0])
1934
+ }
1935
+ })
1936
+ return this.gitFeaturesPromise
1937
+ }
1938
+
1939
+ private nonInteractiveEnvironment(): NodeJS.ProcessEnv {
1940
+ const env: NodeJS.ProcessEnv = {
1941
+ ...process.env,
1942
+ CI: '1',
1943
+ LC_ALL: 'C',
1944
+ LANG: 'C',
1945
+ GIT_TERMINAL_PROMPT: '0',
1946
+ GIT_ASKPASS: 'true',
1947
+ SSH_ASKPASS: 'true',
1948
+ GCM_INTERACTIVE: 'Never',
1949
+ GIT_EDITOR: 'true',
1950
+ GIT_SEQUENCE_EDITOR: 'true',
1951
+ GIT_MERGE_AUTOEDIT: 'no',
1952
+ EDITOR: 'true',
1953
+ VISUAL: 'true'
1954
+ }
1955
+ for (const name of [
1956
+ 'GIT_DIR',
1957
+ 'GIT_WORK_TREE',
1958
+ 'GIT_COMMON_DIR',
1959
+ 'GIT_INDEX_FILE',
1960
+ 'GIT_OBJECT_DIRECTORY',
1961
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
1962
+ 'GIT_NAMESPACE',
1963
+ 'GIT_CEILING_DIRECTORIES',
1964
+ 'GIT_DISCOVERY_ACROSS_FILESYSTEM',
1965
+ 'GIT_PREFIX'
1966
+ ]) {
1967
+ delete env[name]
1968
+ }
1969
+ return env
1970
+ }
1971
+
1972
+ private async failFromError(error: unknown): Promise<void> {
1973
+ if (this.phase === 'fatal') return
1974
+ const validation = error instanceof RepositoryValidationError ? error : null
1975
+ const kind = validation?.kind ?? 'general'
1976
+ const message = validation
1977
+ ? validation.message
1978
+ : 'Bosun could not refresh the repository. Resolve the Git, network, authentication, package-install, push, or restart problem shown in the console, then restart Bosun.'
1979
+ await this.setFatal(kind, message, null, error)
1980
+ }
1981
+
1982
+ private async setFatal(
1983
+ kind: GitModeFatalKind,
1984
+ message: string,
1985
+ prUrl: string | null = null,
1986
+ error?: unknown
1987
+ ): Promise<void> {
1988
+ const title =
1989
+ kind === 'merge-conflict'
1990
+ ? 'Merge conflict during refresh'
1991
+ : kind === 'branch-mismatch'
1992
+ ? 'Bosun is on the wrong branch'
1993
+ : 'Bosun could not refresh'
1994
+ this.runtime.fatal = { kind, title, message, prUrl }
1995
+ this.phase = 'fatal'
1996
+ this.clearFlushTimer()
1997
+ this.operation = null
1998
+ this.operationId = null
1999
+ this.deferredReason = null
2000
+ if (error === undefined) this.logError(`[Bosun Git mode] ${message}`)
2001
+ else this.logError(`[Bosun Git mode] ${message}`, error)
2002
+ try {
2003
+ await this.queueRuntimePersist()
2004
+ } catch (persistError) {
2005
+ this.logError('[Bosun Git mode] Could not persist the fatal state.', persistError)
2006
+ }
2007
+ }
2008
+ }
2009
+
2010
+ interface GitHubRepository {
2011
+ host: string
2012
+ owner: string
2013
+ repository: string
2014
+ }
2015
+
2016
+ export function parseGitHubRepository(remoteUrl: string): GitHubRepository | null {
2017
+ let host: string
2018
+ let pathname: string
2019
+ try {
2020
+ if (/^[^/@:]+@[^:]+:.+/.test(remoteUrl)) {
2021
+ const match = /^[^/@:]+@([^:]+):(.+)$/.exec(remoteUrl)
2022
+ if (!match) return null
2023
+ ;[, host, pathname] = match
2024
+ } else {
2025
+ const url = new URL(remoteUrl)
2026
+ if (!['https:', 'http:', 'ssh:', 'git:'].includes(url.protocol)) return null
2027
+ host = url.hostname
2028
+ pathname = url.pathname.replace(/^\//, '')
2029
+ }
2030
+ } catch {
2031
+ return null
2032
+ }
2033
+
2034
+ if (host.toLowerCase() !== 'github.com') return null
2035
+ const parts = pathname
2036
+ .replace(/\.git$/, '')
2037
+ .split('/')
2038
+ .filter(Boolean)
2039
+ if (parts.length !== 2) return null
2040
+ return { host: 'github.com', owner: parts[0], repository: parts[1] }
2041
+ }
2042
+
2043
+ export function createPullRequestUrl(
2044
+ repository: GitHubRepository,
2045
+ baseBranch: string,
2046
+ headBranch: string
2047
+ ): string {
2048
+ return `https://${repository.host}/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repository)}/compare/${encodeURIComponent(baseBranch)}...${encodeURIComponent(headBranch)}?expand=1`
2049
+ }
2050
+
2051
+ function isRuntimeState(value: unknown): value is PersistedRuntimeState {
2052
+ if (!value || typeof value !== 'object') return false
2053
+ const state = value as Partial<PersistedRuntimeState>
2054
+ return (
2055
+ state.version === RUNTIME_STATE_VERSION &&
2056
+ typeof state.launchId === 'string' &&
2057
+ typeof state.firstActivationCompleted === 'boolean' &&
2058
+ (state.lastCompletedActivationAt === null ||
2059
+ typeof state.lastCompletedActivationAt === 'number') &&
2060
+ typeof state.lastRelevantChangeAt === 'number' &&
2061
+ (state.reloadToken === null || typeof state.reloadToken === 'string') &&
2062
+ (state.fatal === null || isFatal(state.fatal))
2063
+ )
2064
+ }
2065
+
2066
+ function isFatal(value: unknown): value is GitModeFatal {
2067
+ if (!value || typeof value !== 'object') return false
2068
+ const fatal = value as Partial<GitModeFatal>
2069
+ return (
2070
+ (fatal.kind === 'general' ||
2071
+ fatal.kind === 'branch-mismatch' ||
2072
+ fatal.kind === 'merge-conflict') &&
2073
+ typeof fatal.title === 'string' &&
2074
+ typeof fatal.message === 'string' &&
2075
+ (fatal.prUrl === null || typeof fatal.prUrl === 'string')
2076
+ )
2077
+ }
2078
+
2079
+ function isContentRefreshJournal(value: unknown): value is ContentRefreshJournal {
2080
+ if (!value || typeof value !== 'object') return false
2081
+ const journal = value as Partial<ContentRefreshJournal>
2082
+ const isDigest = (digest: unknown): digest is string =>
2083
+ typeof digest === 'string' && /^[0-9a-f]{64}$/.test(digest)
2084
+ const hasInstalledContent = isDigest(journal.installedContent)
2085
+ const hasReloadToken = typeof journal.reloadToken === 'string' && journal.reloadToken.length > 0
2086
+ return (
2087
+ journal.version === CONTENT_REFRESH_JOURNAL_VERSION &&
2088
+ isDigest(journal.baseline) &&
2089
+ ((journal.installedContent === null && journal.reloadToken === null) ||
2090
+ (hasInstalledContent && hasReloadToken))
2091
+ )
2092
+ }
2093
+
2094
+ function splitNullDelimitedBuffers(value: Buffer): Buffer[] {
2095
+ const unique = new Map<string, Buffer>()
2096
+ let start = 0
2097
+ for (let index = 0; index <= value.length; index += 1) {
2098
+ if (index < value.length && value[index] !== 0) continue
2099
+ if (index > start) {
2100
+ const entry = value.subarray(start, index)
2101
+ unique.set(entry.toString('hex'), entry)
2102
+ }
2103
+ start = index + 1
2104
+ }
2105
+ return [...unique.values()].sort((left, right) => Buffer.compare(left, right))
2106
+ }
2107
+
2108
+ function isRepositoryRelativePath(value: Buffer): boolean {
2109
+ if (value.length === 0 || value[0] === 0x2f) return false
2110
+ return !value
2111
+ .toString('binary')
2112
+ .split('/')
2113
+ .some((component) => component === '' || component === '..')
2114
+ }
2115
+
2116
+ function isPrepareReleaseJournal(value: unknown): value is PrepareReleaseJournal {
2117
+ if (!value || typeof value !== 'object') return false
2118
+ const journal = value as Partial<PrepareReleaseJournal>
2119
+ const isObjectId = (objectId: unknown): objectId is string =>
2120
+ typeof objectId === 'string' && /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(objectId)
2121
+ return (
2122
+ journal.version === PREPARE_JOURNAL_VERSION &&
2123
+ typeof journal.currentBranch === 'string' &&
2124
+ Boolean(journal.currentBranch) &&
2125
+ typeof journal.personalBranch === 'string' &&
2126
+ Boolean(journal.personalBranch) &&
2127
+ typeof journal.remote === 'string' &&
2128
+ Boolean(journal.remote) &&
2129
+ typeof journal.currentRemoteBranch === 'string' &&
2130
+ Boolean(journal.currentRemoteBranch) &&
2131
+ typeof journal.personalRemoteBranch === 'string' &&
2132
+ Boolean(journal.personalRemoteBranch) &&
2133
+ typeof journal.remoteUrlDigest === 'string' &&
2134
+ /^[0-9a-f]{64}$/.test(journal.remoteUrlDigest) &&
2135
+ isObjectId(journal.oldCurrentRemoteHead) &&
2136
+ isObjectId(journal.oldPersonalRemoteHead) &&
2137
+ isObjectId(journal.oldCurrentLocalHead) &&
2138
+ (journal.resultHead === null || isObjectId(journal.resultHead))
2139
+ )
2140
+ }
2141
+
2142
+ async function writeDurableJson(filePath: string, value: unknown): Promise<void> {
2143
+ const directoryPath = path.dirname(filePath)
2144
+ await mkdir(directoryPath, { recursive: true })
2145
+ const temporaryPath = `${filePath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
2146
+ let file: Awaited<ReturnType<typeof open>> | null = null
2147
+ try {
2148
+ file = await open(temporaryPath, 'wx', 0o600)
2149
+ await file.writeFile(`${JSON.stringify(value)}\n`, 'utf8')
2150
+ await file.sync()
2151
+ await file.close()
2152
+ file = null
2153
+ await rename(temporaryPath, filePath)
2154
+ await syncDirectory(directoryPath)
2155
+ } finally {
2156
+ await file?.close().catch(() => {})
2157
+ await unlink(temporaryPath).catch(() => {})
2158
+ }
2159
+ }
2160
+
2161
+ async function syncDirectory(directoryPath: string): Promise<void> {
2162
+ const directory = await open(directoryPath, 'r')
2163
+ try {
2164
+ await directory.sync()
2165
+ } finally {
2166
+ await directory.close()
2167
+ }
2168
+ }
2169
+
2170
+ function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException {
2171
+ return error instanceof Error && 'code' in error && error.code === code
2172
+ }
2173
+
2174
+ function versionAtLeast(
2175
+ actual: readonly [number, number, number],
2176
+ required: readonly [number, number, number]
2177
+ ): boolean {
2178
+ for (let index = 0; index < actual.length; index += 1) {
2179
+ if (actual[index] > required[index]) return true
2180
+ if (actual[index] < required[index]) return false
2181
+ }
2182
+ return true
2183
+ }