@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,1653 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import path from 'node:path'
5
+ import { pathToFileURL } from 'node:url'
6
+ import { promisify } from 'node:util'
7
+
8
+ import { afterEach, describe, expect, it, vi } from 'vitest'
9
+
10
+ import { CommandError, runCommand, type CommandRunner } from './command'
11
+ import { RepositoryWriteGate } from './repository-write-gate'
12
+ import { createGitModeService } from './service'
13
+ import type { GitModeConfig, GitModeService, GitModeStatus } from './types'
14
+
15
+ const execFileAsync = promisify(execFile)
16
+ const temporaryDirectories: string[] = []
17
+
18
+ afterEach(async () => {
19
+ await Promise.all(
20
+ temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
21
+ )
22
+ })
23
+
24
+ describe.sequential('Git mode service', () => {
25
+ it('validates the repository before asking browsers to flush', async () => {
26
+ const fixture = await createFixture()
27
+ await git(fixture.project, ['switch', '-c', 'unexpected-branch'])
28
+ const settleWrites = vi.fn(async () => {})
29
+ const service = createTestService(fixture, { settleWrites })
30
+
31
+ const status = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
32
+
33
+ expect(status.phase).toBe('fatal')
34
+ expect(status.operationId).toBeNull()
35
+ expect(status.tabNeedsFlush).toBe(false)
36
+ expect(status.fatal?.kind).toBe('branch-mismatch')
37
+ expect(settleWrites).not.toHaveBeenCalled()
38
+ await service.dispose()
39
+ })
40
+
41
+ it('serializes ancillary Git reads ahead of activation validation', async () => {
42
+ const fixture = await createFixture()
43
+ let releaseStatus!: () => void
44
+ let markStatusStarted!: () => void
45
+ const statusStarted = new Promise<void>((resolve) => (markStatusStarted = resolve))
46
+ const statusRelease = new Promise<void>((resolve) => (releaseStatus = resolve))
47
+ let activeCommands = 0
48
+ let maximumActiveCommands = 0
49
+ const observedRunner: CommandRunner = async (executable, args, options) => {
50
+ activeCommands += 1
51
+ maximumActiveCommands = Math.max(maximumActiveCommands, activeCommands)
52
+ try {
53
+ if (executable === 'git' && args.includes('status')) {
54
+ markStatusStarted()
55
+ await statusRelease
56
+ }
57
+ return await runCommand(executable, args, options)
58
+ } finally {
59
+ activeCommands -= 1
60
+ }
61
+ }
62
+ const service = createTestService(fixture, { runCommand: observedRunner })
63
+
64
+ const status = service.status({ tabId: 'tab-a' })
65
+ await statusStarted
66
+ const activation = service.activate({ tabId: 'tab-b', reason: 'page-open' })
67
+ await new Promise((resolve) => setTimeout(resolve, 50))
68
+ expect(maximumActiveCommands).toBe(1)
69
+ releaseStatus()
70
+ await status
71
+ expect((await activation).phase).toBe('preparing')
72
+ expect(maximumActiveCommands).toBe(1)
73
+ await service.dispose()
74
+ })
75
+
76
+ it('finishes shutdown cleanly once an in-flight repository operation has settled', async () => {
77
+ const fixture = await createFixture()
78
+ await writeFile(path.join(fixture.collaborator, 'remote.txt'), 'remote\n')
79
+ await git(fixture.collaborator, ['add', '-A'])
80
+ await git(fixture.collaborator, ['commit', '-m', 'remote work'])
81
+ await git(fixture.collaborator, ['push'])
82
+ let markInstallStarted!: () => void
83
+ let releaseInstall!: () => void
84
+ const installStarted = new Promise<void>((resolve) => (markInstallStarted = resolve))
85
+ const installRelease = new Promise<void>((resolve) => (releaseInstall = resolve))
86
+ const service = createTestService(fixture, {
87
+ installProject: async () => {
88
+ markInstallStarted()
89
+ await installRelease
90
+ }
91
+ })
92
+ const started = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
93
+ await acknowledgePreparationAndFlush(service, 'tab-a', started.operationId!)
94
+ await installStarted
95
+
96
+ const disposal = service.dispose()
97
+ releaseInstall()
98
+ await expect(disposal).resolves.toBeUndefined()
99
+ })
100
+
101
+ it('locks writers, drains admitted backend work, and revalidates before Git mutation', async () => {
102
+ const fixture = await createFixture()
103
+ let releaseWrites!: () => void
104
+ const settleWrites = vi.fn(() => new Promise<void>((resolve) => (releaseWrites = resolve)))
105
+ const installProject = vi.fn(async () => {})
106
+ const service = createTestService(fixture, { settleWrites, installProject })
107
+ const started = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
108
+ await service.flushAck({
109
+ tabId: 'tab-a',
110
+ operationId: started.operationId!,
111
+ stage: 'preflight'
112
+ })
113
+ await git(fixture.project, ['switch', '-c', 'changed-during-preparation'])
114
+ const locked = await service.flushAck({
115
+ tabId: 'tab-a',
116
+ operationId: started.operationId!,
117
+ stage: 'browser-writes'
118
+ })
119
+
120
+ expect(locked.phase).toBe('flushing')
121
+ expect(service.isUsageBlocked()).toBe(true)
122
+ expect(settleWrites).toHaveBeenCalledOnce()
123
+ expect(installProject).not.toHaveBeenCalled()
124
+
125
+ releaseWrites()
126
+ const failed = await waitForPhase(service, 'tab-a', ['fatal'])
127
+ expect(failed.fatal?.kind).toBe('branch-mismatch')
128
+ expect(installProject).not.toHaveBeenCalled()
129
+ await service.dispose()
130
+ })
131
+
132
+ it('completes an unchanged activation without install, restart, reload, or a closed write gate', async () => {
133
+ const fixture = await createFixture()
134
+ const gateConfig = {
135
+ enabled: true,
136
+ projectDir: fixture.project,
137
+ leaseId: 'test-lease',
138
+ launchId: fixture.config.launchId
139
+ }
140
+ const backendGate = new RepositoryWriteGate(gateConfig)
141
+ const frontendGate = new RepositoryWriteGate(gateConfig)
142
+ await backendGate.initializeBackend()
143
+
144
+ const installProject = vi.fn(async () => {})
145
+ const scheduleRestart = vi.fn(() => true)
146
+ const resumeWrites = vi.fn(() => backendGate.unlock())
147
+ const service = createTestService(fixture, {
148
+ installProject,
149
+ scheduleRestart,
150
+ settleWrites: () => backendGate.lockAndDrain(),
151
+ resumeWrites,
152
+ now: () => 1_000
153
+ })
154
+
155
+ const started = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
156
+ await acknowledgePreparationAndFlush(service, 'tab-a', started.operationId!)
157
+ const finished = await waitForOperationEnd(service, 'tab-a')
158
+
159
+ expect(finished.phase).toBe('idle')
160
+ expect(finished.operationId).toBeNull()
161
+ expect(finished.fatal).toBeNull()
162
+ expect(finished.reloadToken).toBeNull()
163
+ expect(finished.hasChanges).toBe(false)
164
+ expect(service.isUsageBlocked()).toBe(false)
165
+ expect(resumeWrites).toHaveBeenCalledOnce()
166
+ expect(installProject).not.toHaveBeenCalled()
167
+ expect(scheduleRestart).not.toHaveBeenCalled()
168
+ await expect(frontendGate.withWriteAdmission(async () => 'written')).resolves.toBe('written')
169
+
170
+ const persisted = JSON.parse(await readFile(fixture.config.runtimeStatePath, 'utf8'))
171
+ expect(persisted.firstActivationCompleted).toBe(true)
172
+ expect(persisted.lastCompletedActivationAt).toBe(1_000)
173
+ expect((await service.activate({ tabId: 'tab-a', reason: 'focus' })).operationId).toBeNull()
174
+ await service.dispose()
175
+ })
176
+
177
+ it('preserves dirty status when an unchanged activation can pull without saving', async () => {
178
+ const fixture = await createFixture()
179
+ await writeFile(path.join(fixture.project, 'local-draft.txt'), 'draft\n')
180
+ const installProject = vi.fn(async () => {})
181
+ const scheduleRestart = vi.fn(() => true)
182
+ const resumeWrites = vi.fn(async () => {})
183
+ const service = createTestService(fixture, {
184
+ installProject,
185
+ scheduleRestart,
186
+ resumeWrites
187
+ })
188
+
189
+ expect((await service.status({ tabId: 'tab-a' })).hasChanges).toBe(true)
190
+ const started = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
191
+ await acknowledgePreparationAndFlush(service, 'tab-a', started.operationId!)
192
+ const finished = await waitForOperationEnd(service, 'tab-a')
193
+
194
+ expect(finished.phase).toBe('idle')
195
+ expect(finished.hasChanges).toBe(true)
196
+ expect(finished.reloadToken).toBeNull()
197
+ expect(resumeWrites).toHaveBeenCalledOnce()
198
+ expect(installProject).not.toHaveBeenCalled()
199
+ expect(scheduleRestart).not.toHaveBeenCalled()
200
+ await service.dispose()
201
+ })
202
+
203
+ it('keeps writes blocked if an unchanged activation cannot release its gate', async () => {
204
+ const fixture = await createFixture()
205
+ const installProject = vi.fn(async () => {})
206
+ const scheduleRestart = vi.fn(() => true)
207
+ const service = createTestService(fixture, {
208
+ installProject,
209
+ scheduleRestart,
210
+ resumeWrites: async () => {
211
+ throw new Error('unlock failed')
212
+ }
213
+ })
214
+
215
+ const started = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
216
+ await acknowledgePreparationAndFlush(service, 'tab-a', started.operationId!)
217
+ const finished = await waitForPhase(service, 'tab-a', ['fatal'])
218
+
219
+ expect(finished.fatal?.kind).toBe('general')
220
+ expect(service.isUsageBlocked()).toBe(true)
221
+ expect(installProject).not.toHaveBeenCalled()
222
+ expect(scheduleRestart).not.toHaveBeenCalled()
223
+ await service.dispose()
224
+ })
225
+
226
+ it('relocks writes when shutdown begins during no-op gate release', async () => {
227
+ const fixture = await createFixture()
228
+ let markResumeStarted!: () => void
229
+ let releaseResume!: () => void
230
+ const resumeStarted = new Promise<void>((resolve) => (markResumeStarted = resolve))
231
+ const resumeRelease = new Promise<void>((resolve) => (releaseResume = resolve))
232
+ const settleWrites = vi.fn(async () => {})
233
+ const installProject = vi.fn(async () => {})
234
+ const scheduleRestart = vi.fn(() => true)
235
+ const service = createTestService(fixture, {
236
+ installProject,
237
+ scheduleRestart,
238
+ settleWrites,
239
+ resumeWrites: async () => {
240
+ markResumeStarted()
241
+ await resumeRelease
242
+ }
243
+ })
244
+
245
+ const started = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
246
+ await acknowledgePreparationAndFlush(service, 'tab-a', started.operationId!)
247
+ await resumeStarted
248
+ const disposal = service.dispose()
249
+ releaseResume()
250
+ await disposal
251
+
252
+ expect(settleWrites).toHaveBeenCalledTimes(2)
253
+ expect(service.isUsageBlocked()).toBe(true)
254
+ expect(installProject).not.toHaveBeenCalled()
255
+ expect(scheduleRestart).not.toHaveBeenCalled()
256
+ })
257
+
258
+ it('installs, restarts, and reloads after a fast-forward pull changes project files', async () => {
259
+ const fixture = await createFixture()
260
+ await writeFile(path.join(fixture.collaborator, 'remote.txt'), 'remote\n')
261
+ await git(fixture.collaborator, ['add', '-A'])
262
+ await git(fixture.collaborator, ['commit', '-m', 'remote work'])
263
+ await git(fixture.collaborator, ['push'])
264
+ const installProject = vi.fn(async () => {})
265
+ const scheduleRestart = vi.fn(() => true)
266
+ const resumeWrites = vi.fn(async () => {})
267
+ const service = createTestService(fixture, {
268
+ installProject,
269
+ scheduleRestart,
270
+ resumeWrites
271
+ })
272
+
273
+ const started = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
274
+ await acknowledgePreparationAndFlush(service, 'tab-a', started.operationId!)
275
+ const finished = await waitForPhase(service, 'tab-a', ['restarting', 'fatal'])
276
+
277
+ expect(finished.phase).toBe('restarting')
278
+ expect(finished.reloadToken).not.toBeNull()
279
+ expect(installProject).toHaveBeenCalledOnce()
280
+ expect(scheduleRestart).toHaveBeenCalledWith(76)
281
+ expect(resumeWrites).not.toHaveBeenCalled()
282
+ expect(await gitText(fixture.project, ['rev-parse', 'HEAD'])).toBe(
283
+ await gitText(fixture.remote, ['rev-parse', 'sb/bosun'])
284
+ )
285
+ await service.dispose()
286
+ })
287
+
288
+ it('merges a release update into personal and pushes it without moving the local release ref', async () => {
289
+ const fixture = await createFixture()
290
+ const localCurrentHead = await gitText(fixture.project, ['rev-parse', 'next-release'])
291
+ await switchCollaboratorToCurrent(fixture)
292
+ await writeFile(path.join(fixture.collaborator, 'release.txt'), 'release\n')
293
+ await git(fixture.collaborator, ['add', '-A'])
294
+ await git(fixture.collaborator, ['commit', '-m', 'release work'])
295
+ await git(fixture.collaborator, ['push'])
296
+ const currentHead = await gitText(fixture.remote, ['rev-parse', 'next-release'])
297
+ const installProject = vi.fn(async () => {})
298
+ const scheduleRestart = vi.fn(() => true)
299
+ const service = createTestService(fixture, { installProject, scheduleRestart })
300
+
301
+ const started = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
302
+ await acknowledgePreparationAndFlush(service, 'tab-a', started.operationId!)
303
+ const finished = await waitForPhase(service, 'tab-a', ['restarting', 'fatal'])
304
+
305
+ expect(finished.fatal).toBeNull()
306
+ expect(finished.phase).toBe('restarting')
307
+ expect(await readFile(path.join(fixture.project, 'release.txt'), 'utf8')).toBe('release\n')
308
+ expect(await gitText(fixture.project, ['branch', '--show-current'])).toBe('sb/bosun')
309
+ expect(await gitText(fixture.project, ['rev-parse', 'HEAD'])).toBe(currentHead)
310
+ expect(await gitText(fixture.project, ['rev-parse', 'origin/next-release'])).toBe(currentHead)
311
+ expect(await gitText(fixture.project, ['rev-parse', 'next-release'])).toBe(localCurrentHead)
312
+ expect(await gitText(fixture.remote, ['rev-parse', 'sb/bosun'])).toBe(currentHead)
313
+ expect(await gitText(fixture.remote, ['rev-parse', 'next-release'])).toBe(currentHead)
314
+ expect(installProject).toHaveBeenCalledOnce()
315
+ expect(scheduleRestart).toHaveBeenCalledWith(76)
316
+ await service.dispose()
317
+ })
318
+
319
+ it('does not restart when a release update changes history but not project files', async () => {
320
+ const fixture = await createFixture()
321
+ await switchCollaboratorToCurrent(fixture)
322
+ await git(fixture.collaborator, ['commit', '--allow-empty', '-m', 'metadata only'])
323
+ await git(fixture.collaborator, ['push'])
324
+ const currentHead = await gitText(fixture.remote, ['rev-parse', 'next-release'])
325
+ const installProject = vi.fn(async () => {})
326
+ const scheduleRestart = vi.fn(() => true)
327
+ const resumeWrites = vi.fn(async () => {})
328
+ const service = createTestService(fixture, {
329
+ installProject,
330
+ scheduleRestart,
331
+ resumeWrites
332
+ })
333
+
334
+ const started = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
335
+ await acknowledgePreparationAndFlush(service, 'tab-a', started.operationId!)
336
+ const finished = await waitForOperationEnd(service, 'tab-a')
337
+
338
+ expect(finished.phase).toBe('idle')
339
+ expect(finished.reloadToken).toBeNull()
340
+ expect(finished.canPrepareRelease).toBe(false)
341
+ expect(await gitText(fixture.project, ['rev-parse', 'HEAD'])).toBe(currentHead)
342
+ expect(await gitText(fixture.remote, ['rev-parse', 'sb/bosun'])).toBe(currentHead)
343
+ expect(resumeWrites).toHaveBeenCalledOnce()
344
+ expect(installProject).not.toHaveBeenCalled()
345
+ expect(scheduleRestart).not.toHaveBeenCalled()
346
+ await service.dispose()
347
+ })
348
+
349
+ it('recovers an interrupted content-changing pull before reopening Bosun', async () => {
350
+ const fixture = await createFixture()
351
+ await writeFile(path.join(fixture.collaborator, 'remote.txt'), 'remote\n')
352
+ await git(fixture.collaborator, ['add', '-A'])
353
+ await git(fixture.collaborator, ['commit', '-m', 'remote work'])
354
+ await git(fixture.collaborator, ['push'])
355
+ let interruptPull = true
356
+ const runner: CommandRunner = async (executable, args, options) => {
357
+ const result = await runCommand(executable, args, options)
358
+ if (interruptPull && executable === 'git' && args.includes('pull')) {
359
+ interruptPull = false
360
+ throw new Error('simulated shutdown after pull')
361
+ }
362
+ return result
363
+ }
364
+ const interrupted = createTestService(fixture, { runCommand: runner })
365
+ const started = await interrupted.activate({ tabId: 'tab-a', reason: 'page-open' })
366
+ await acknowledgePreparationAndFlush(interrupted, 'tab-a', started.operationId!)
367
+ expect((await waitForPhase(interrupted, 'tab-a', ['fatal'])).fatal?.kind).toBe('general')
368
+ expect(await readFile(path.join(fixture.project, 'remote.txt'), 'utf8')).toBe('remote\n')
369
+ await interrupted.dispose()
370
+
371
+ fixture.config.launchId = 'recovery-launch'
372
+ const installProject = vi.fn(async () => {})
373
+ const scheduleRestart = vi.fn(() => true)
374
+ const resumeWrites = vi.fn(async () => {})
375
+ const recovered = createTestService(fixture, {
376
+ installProject,
377
+ scheduleRestart,
378
+ resumeWrites
379
+ })
380
+ const recovering = await recovered.status({ tabId: 'tab-b' })
381
+
382
+ expect(recovering.phase).toBe('restarting')
383
+ expect(recovering.reloadToken).not.toBeNull()
384
+ expect(installProject).toHaveBeenCalledOnce()
385
+ expect(scheduleRestart).toHaveBeenCalledWith(76)
386
+ expect(resumeWrites).not.toHaveBeenCalled()
387
+ await recovered.dispose()
388
+
389
+ const managedRestartInstall = vi.fn(async () => {})
390
+ const managedRestartResume = vi.fn(async () => {})
391
+ const managedRestart = createTestService(fixture, {
392
+ installProject: managedRestartInstall,
393
+ resumeWrites: managedRestartResume
394
+ })
395
+ const ready = await managedRestart.status({ tabId: 'tab-c' })
396
+
397
+ expect(ready.phase).toBe('idle')
398
+ expect(ready.reloadToken).toBe(recovering.reloadToken)
399
+ expect(managedRestartInstall).not.toHaveBeenCalled()
400
+ expect(managedRestartResume).toHaveBeenCalledOnce()
401
+ await expect(
402
+ readFile(path.join(fixture.project, '.git', 'bosun-content-refresh.json'))
403
+ ).rejects.toMatchObject({ code: 'ENOENT' })
404
+ await managedRestart.dispose()
405
+ }, 15_000)
406
+
407
+ it('clears an interrupted pull record when project files stayed identical', async () => {
408
+ const fixture = await createFixture()
409
+ await git(fixture.collaborator, ['commit', '--allow-empty', '-m', 'metadata only'])
410
+ await git(fixture.collaborator, ['push'])
411
+ let interruptPull = true
412
+ const runner: CommandRunner = async (executable, args, options) => {
413
+ const result = await runCommand(executable, args, options)
414
+ if (interruptPull && executable === 'git' && args.includes('pull')) {
415
+ interruptPull = false
416
+ throw new Error('simulated shutdown after pull')
417
+ }
418
+ return result
419
+ }
420
+ const interrupted = createTestService(fixture, { runCommand: runner })
421
+ const started = await interrupted.activate({ tabId: 'tab-a', reason: 'page-open' })
422
+ await acknowledgePreparationAndFlush(interrupted, 'tab-a', started.operationId!)
423
+ expect((await waitForPhase(interrupted, 'tab-a', ['fatal'])).fatal?.kind).toBe('general')
424
+ await interrupted.dispose()
425
+
426
+ fixture.config.launchId = 'unchanged-recovery-launch'
427
+ const installProject = vi.fn(async () => {})
428
+ const scheduleRestart = vi.fn(() => true)
429
+ const resumeWrites = vi.fn(async () => {})
430
+ const recovered = createTestService(fixture, {
431
+ installProject,
432
+ scheduleRestart,
433
+ resumeWrites
434
+ })
435
+ const ready = await recovered.status({ tabId: 'tab-b' })
436
+
437
+ expect(ready.phase).toBe('idle')
438
+ expect(ready.reloadToken).toBeNull()
439
+ expect(installProject).not.toHaveBeenCalled()
440
+ expect(scheduleRestart).not.toHaveBeenCalled()
441
+ expect(resumeWrites).toHaveBeenCalledOnce()
442
+ await expect(
443
+ readFile(path.join(fixture.project, '.git', 'bosun-content-refresh.json'))
444
+ ).rejects.toMatchObject({ code: 'ENOENT' })
445
+ await recovered.dispose()
446
+ })
447
+
448
+ it('installs dependencies without running package scripts', async () => {
449
+ const fixture = await createFixture()
450
+ await writeFile(path.join(fixture.collaborator, 'remote.txt'), 'remote\n')
451
+ await git(fixture.collaborator, ['add', '-A'])
452
+ await git(fixture.collaborator, ['commit', '-m', 'remote work'])
453
+ await git(fixture.collaborator, ['push'])
454
+ const installCommands: string[][] = []
455
+ const observedRunner: CommandRunner = async (executable, args, options) => {
456
+ if (executable === 'pnpm') {
457
+ installCommands.push([...args])
458
+ return { stdout: '', stderr: '', exitCode: 0 }
459
+ }
460
+ return runCommand(executable, args, options)
461
+ }
462
+ const service = createGitModeService(fixture.config, {
463
+ runCommand: observedRunner,
464
+ scheduleRestart: () => true,
465
+ watchFileChanges: false,
466
+ logError: () => {}
467
+ })
468
+
469
+ const started = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
470
+ await acknowledgePreparationAndFlush(service, 'tab-a', started.operationId!)
471
+ const finished = await waitForPhase(service, 'tab-a', ['restarting', 'fatal'])
472
+
473
+ expect(finished.phase).toBe('restarting')
474
+ expect(installCommands).toEqual([
475
+ ['install', '--frozen-lockfile', '--ignore-scripts', '--ignore-pnpmfile']
476
+ ])
477
+ await service.dispose()
478
+ })
479
+
480
+ it('rejects repositories containing tracked submodules before browser preparation', async () => {
481
+ const fixture = await createFixture()
482
+ const head = await gitText(fixture.project, ['rev-parse', 'HEAD'])
483
+ await git(fixture.project, [
484
+ 'update-index',
485
+ '--add',
486
+ '--cacheinfo',
487
+ `160000,${head},vendor/example`
488
+ ])
489
+ const service = createTestService(fixture)
490
+
491
+ const status = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
492
+
493
+ expect(status.phase).toBe('fatal')
494
+ expect(status.fatal?.message).toContain('tracked submodules')
495
+ expect(status.tabNeedsFlush).toBe(false)
496
+ await service.dispose()
497
+ })
498
+
499
+ it('uses explicit merge semantics, labels the merge, bypasses hooks, and pushes once', async () => {
500
+ const fixture = await createFixture()
501
+ await writeFile(path.join(fixture.project, 'local.txt'), 'local\n')
502
+ await git(fixture.project, ['add', '-A'])
503
+ await git(fixture.project, ['commit', '-m', 'local work'])
504
+ await writeFile(path.join(fixture.collaborator, 'remote.txt'), 'remote\n')
505
+ await git(fixture.collaborator, ['add', '-A'])
506
+ await git(fixture.collaborator, ['commit', '-m', 'remote work'])
507
+ await git(fixture.collaborator, ['push'])
508
+ await setHostilePullConfiguration(fixture.project)
509
+ await installFailingHooks(fixture.project)
510
+
511
+ const installProject = vi.fn(async () => {})
512
+ const scheduleRestart = vi.fn(() => true)
513
+ const service = createTestService(fixture, { installProject, scheduleRestart })
514
+
515
+ const started = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
516
+ expect(started.phase).toBe('preparing')
517
+ expect(started.tabNeedsFlush).toBe(true)
518
+ expect(service.isUsageBlocked()).toBe(false)
519
+ const flushing = await service.flushAck({
520
+ tabId: 'tab-a',
521
+ operationId: started.operationId!,
522
+ stage: 'preflight'
523
+ })
524
+ expect(flushing.phase).toBe('preparing')
525
+ expect(flushing.preparationStage).toBe('browser-writes')
526
+ expect(flushing.tabNeedsFlush).toBe(true)
527
+ expect(service.isUsageBlocked()).toBe(false)
528
+ await service.flushAck({
529
+ tabId: 'tab-a',
530
+ operationId: started.operationId!,
531
+ stage: 'browser-writes'
532
+ })
533
+ const finished = await waitForPhase(service, 'tab-a', ['restarting', 'fatal'])
534
+
535
+ expect(finished.fatal).toBeNull()
536
+ expect(finished.phase).toBe('restarting')
537
+ expect(service.isUsageBlocked()).toBe(true)
538
+ expect(installProject).toHaveBeenCalledOnce()
539
+ expect(scheduleRestart).toHaveBeenCalledWith(76)
540
+ expect(await gitText(fixture.project, ['log', '-1', '--pretty=%s'])).toBe(
541
+ '[BOSUN] [Merge] Merge origin/sb/bosun into sb/bosun'
542
+ )
543
+ expect(await gitText(fixture.project, ['rev-parse', 'HEAD'])).toBe(
544
+ await gitText(fixture.remote, ['rev-parse', 'sb/bosun'])
545
+ )
546
+ await service.dispose()
547
+ })
548
+
549
+ it('merges divergent release work into personal with one labelled push', async () => {
550
+ const fixture = await createFixture()
551
+ const previousCurrentHead = await gitText(fixture.project, ['rev-parse', 'origin/next-release'])
552
+ await writeFile(path.join(fixture.project, 'personal.txt'), 'personal\n')
553
+ await git(fixture.project, ['add', '-A'])
554
+ await git(fixture.project, ['commit', '-m', 'personal work'])
555
+ const personalHead = await gitText(fixture.project, ['rev-parse', 'HEAD'])
556
+
557
+ await switchCollaboratorToCurrent(fixture)
558
+ await writeFile(path.join(fixture.collaborator, 'release.txt'), 'release\n')
559
+ await git(fixture.collaborator, ['add', '-A'])
560
+ await git(fixture.collaborator, ['commit', '-m', 'release work'])
561
+ await git(fixture.collaborator, ['push'])
562
+ const currentHead = await gitText(fixture.remote, ['rev-parse', 'next-release'])
563
+ await setHostilePullConfiguration(fixture.project)
564
+ await installFailingHooks(fixture.project)
565
+
566
+ const pushes: string[][] = []
567
+ let movedCurrentTrackingRef = false
568
+ const observedRunner: CommandRunner = async (executable, args, options) => {
569
+ if (executable === 'git' && args.includes('push')) pushes.push([...args])
570
+ if (executable === 'git' && args.includes('merge') && !movedCurrentTrackingRef) {
571
+ movedCurrentTrackingRef = true
572
+ await git(fixture.project, [
573
+ 'update-ref',
574
+ 'refs/remotes/origin/next-release',
575
+ previousCurrentHead
576
+ ])
577
+ }
578
+ return runCommand(executable, args, options)
579
+ }
580
+ const service = createTestService(fixture, { runCommand: observedRunner })
581
+ const started = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
582
+ await acknowledgePreparationAndFlush(service, 'tab-a', started.operationId!)
583
+ const finished = await waitForPhase(service, 'tab-a', ['restarting', 'fatal'])
584
+
585
+ expect(finished.fatal).toBeNull()
586
+ expect(finished.phase).toBe('restarting')
587
+ expect(await gitText(fixture.project, ['log', '-1', '--pretty=%s'])).toBe(
588
+ '[BOSUN] [Merge] Merge origin/next-release into sb/bosun'
589
+ )
590
+ expect((await gitText(fixture.project, ['log', '-1', '--pretty=%P'])).split(' ')).toEqual([
591
+ personalHead,
592
+ currentHead
593
+ ])
594
+ expect(movedCurrentTrackingRef).toBe(true)
595
+ expect(pushes).toHaveLength(1)
596
+ expect(await gitText(fixture.project, ['branch', '--show-current'])).toBe('sb/bosun')
597
+ expect(await gitText(fixture.remote, ['rev-parse', 'sb/bosun'])).toBe(
598
+ await gitText(fixture.project, ['rev-parse', 'HEAD'])
599
+ )
600
+ expect(await gitText(fixture.remote, ['rev-parse', 'next-release'])).toBe(currentHead)
601
+ await service.dispose()
602
+ }, 15_000)
603
+
604
+ it('does not mistake a clean pending merge for a conflict when its labelled commit fails', async () => {
605
+ const fixture = await createFixture()
606
+ await writeFile(path.join(fixture.project, 'local.txt'), 'local\n')
607
+ await git(fixture.project, ['add', '-A'])
608
+ await git(fixture.project, ['commit', '-m', 'local work'])
609
+ await writeFile(path.join(fixture.collaborator, 'remote.txt'), 'remote\n')
610
+ await git(fixture.collaborator, ['add', '-A'])
611
+ await git(fixture.collaborator, ['commit', '-m', 'remote work'])
612
+ await git(fixture.collaborator, ['push'])
613
+
614
+ const rejectMergeCommit: CommandRunner = (executable, args, options) => {
615
+ if (executable === 'git' && args.includes('commit')) {
616
+ return Promise.reject(
617
+ new CommandError(executable, args, {
618
+ stdout: '',
619
+ stderr: 'simulated commit failure',
620
+ exitCode: 1
621
+ })
622
+ )
623
+ }
624
+ return runCommand(executable, args, options)
625
+ }
626
+ const service = createTestService(fixture, { runCommand: rejectMergeCommit })
627
+ const started = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
628
+ await acknowledgePreparationAndFlush(service, 'tab-a', started.operationId!)
629
+ const failed = await waitForPhase(service, 'tab-a', ['fatal'])
630
+
631
+ expect(failed.fatal?.kind).toBe('general')
632
+ expect(await gitText(fixture.project, ['ls-files', '-u'])).toBe('')
633
+ expect(await gitText(fixture.project, ['rev-parse', '--verify', 'MERGE_HEAD'])).not.toBe('')
634
+ await service.dispose()
635
+ })
636
+
637
+ it('waits for every live tab and defers without blocking while a browser draft exists', async () => {
638
+ const fixture = await createFixture()
639
+ const installProject = vi.fn(async () => {})
640
+ const service = createTestService(fixture, { installProject })
641
+ await service.status({ tabId: 'tab-b' })
642
+
643
+ const started = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
644
+ await service.flushAck({
645
+ tabId: 'tab-a',
646
+ operationId: started.operationId!,
647
+ stage: 'preflight'
648
+ })
649
+ expect((await service.status({ tabId: 'tab-a' })).phase).toBe('preparing')
650
+ await service.flushAck({
651
+ tabId: 'tab-b',
652
+ operationId: started.operationId!,
653
+ stage: 'preflight',
654
+ blockedReason: 'Save or close the open schema draft.'
655
+ })
656
+
657
+ const deferred = await service.status({ tabId: 'tab-a' })
658
+ expect(deferred.phase).toBe('idle')
659
+ expect(deferred.deferredReason).toBe('Save or close the open schema draft.')
660
+ expect(service.isUsageBlocked()).toBe(false)
661
+ expect(installProject).not.toHaveBeenCalled()
662
+ await service.dispose()
663
+ })
664
+
665
+ it('takes a fresh all-tab snapshot for the locked flush barrier', async () => {
666
+ const fixture = await createFixture()
667
+ const service = createTestService(fixture)
668
+ await service.status({ tabId: 'tab-b' })
669
+
670
+ const started = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
671
+ await service.flushAck({
672
+ tabId: 'tab-a',
673
+ operationId: started.operationId!,
674
+ stage: 'preflight'
675
+ })
676
+ const flushing = await service.flushAck({
677
+ tabId: 'tab-b',
678
+ operationId: started.operationId!,
679
+ stage: 'preflight'
680
+ })
681
+ expect(flushing.phase).toBe('preparing')
682
+ expect(flushing.preparationStage).toBe('browser-writes')
683
+ expect((await service.status({ tabId: 'tab-a' })).tabNeedsFlush).toBe(true)
684
+ expect((await service.status({ tabId: 'tab-b' })).tabNeedsFlush).toBe(true)
685
+
686
+ await service.flushAck({
687
+ tabId: 'tab-a',
688
+ operationId: started.operationId!,
689
+ stage: 'browser-writes'
690
+ })
691
+ expect((await service.status({ tabId: 'tab-a' })).phase).toBe('preparing')
692
+ await service.flushAck({
693
+ tabId: 'tab-b',
694
+ operationId: started.operationId!,
695
+ stage: 'browser-writes',
696
+ blockedReason: 'A pending edit could not be flushed.'
697
+ })
698
+ const deferred = await service.status({ tabId: 'tab-a' })
699
+ expect(deferred.phase).toBe('idle')
700
+ expect(deferred.deferredReason).toBe('A pending edit could not be flushed.')
701
+ await service.dispose()
702
+ })
703
+
704
+ it('adds a tab that appears during browser-write preparation and rejects a stale-stage ACK', async () => {
705
+ const fixture = await createFixture()
706
+ const service = createTestService(fixture)
707
+ const started = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
708
+ await service.flushAck({
709
+ tabId: 'tab-a',
710
+ operationId: started.operationId!,
711
+ stage: 'preflight'
712
+ })
713
+
714
+ const joined = await service.status({ tabId: 'tab-b' })
715
+ expect(joined.phase).toBe('preparing')
716
+ expect(joined.preparationStage).toBe('browser-writes')
717
+ expect(joined.tabNeedsFlush).toBe(true)
718
+ const staleAck = await service.flushAck({
719
+ tabId: 'tab-b',
720
+ operationId: started.operationId!,
721
+ stage: 'preflight'
722
+ })
723
+ expect(staleAck.tabNeedsFlush).toBe(true)
724
+
725
+ await service.flushAck({
726
+ tabId: 'tab-a',
727
+ operationId: started.operationId!,
728
+ stage: 'browser-writes'
729
+ })
730
+ expect((await service.status({ tabId: 'tab-a' })).phase).toBe('preparing')
731
+ await service.flushAck({
732
+ tabId: 'tab-b',
733
+ operationId: started.operationId!,
734
+ stage: 'browser-writes'
735
+ })
736
+ expect((await waitForOperationEnd(service, 'tab-a')).phase).toBe('idle')
737
+ await service.dispose()
738
+ })
739
+
740
+ it('expires a disappeared tab before the browser barrier deadline', async () => {
741
+ const fixture = await createFixture()
742
+ let now = 1_000
743
+ const service = createTestService(fixture, {
744
+ now: () => now,
745
+ liveTabMs: 5,
746
+ flushTimeoutMs: 2_000
747
+ })
748
+ await service.status({ tabId: 'closed-tab' })
749
+
750
+ const started = await service.activate({ tabId: 'active-tab', reason: 'page-open' })
751
+ await service.flushAck({
752
+ tabId: 'active-tab',
753
+ operationId: started.operationId!,
754
+ stage: 'preflight'
755
+ })
756
+ now += 6
757
+ await service.status({ tabId: 'active-tab' })
758
+ await new Promise((resolve) => setTimeout(resolve, 550))
759
+
760
+ const flushing = await service.status({ tabId: 'active-tab' })
761
+ expect(flushing.phase).toBe('preparing')
762
+ expect(flushing.preparationStage).toBe('browser-writes')
763
+ expect(flushing.tabNeedsFlush).toBe(true)
764
+ await service.flushAck({
765
+ tabId: 'active-tab',
766
+ operationId: started.operationId!,
767
+ stage: 'browser-writes'
768
+ })
769
+ expect((await waitForOperationEnd(service, 'active-tab')).phase).toBe('idle')
770
+ await service.dispose()
771
+ })
772
+
773
+ it('saves and publishes files without restarting when their content stays the same', async () => {
774
+ const fixture = await createFixture()
775
+ await writeFile(path.join(fixture.project, 'manual.txt'), 'ready\n')
776
+ const installProject = vi.fn(async () => {})
777
+ const scheduleRestart = vi.fn(() => true)
778
+ const resumeWrites = vi.fn(async () => {})
779
+ const service = createTestService(fixture, {
780
+ installProject,
781
+ scheduleRestart,
782
+ resumeWrites
783
+ })
784
+
785
+ expect((await service.status({ tabId: 'tab-a' })).hasChanges).toBe(true)
786
+ const started = await service.startManual({
787
+ tabId: 'tab-a',
788
+ action: 'save',
789
+ description: 'manual release'
790
+ })
791
+ expect(started.phase).toBe('preparing')
792
+ await acknowledgePreparationAndFlush(service, 'tab-a', started.operationId!)
793
+ const finished = await waitForOperationEnd(service, 'tab-a')
794
+
795
+ expect(finished.fatal).toBeNull()
796
+ expect(finished.phase).toBe('idle')
797
+ expect(finished.reloadToken).toBeNull()
798
+ expect(resumeWrites).toHaveBeenCalledOnce()
799
+ expect(installProject).not.toHaveBeenCalled()
800
+ expect(scheduleRestart).not.toHaveBeenCalled()
801
+ expect(await gitText(fixture.project, ['log', '-1', '--pretty=%s'])).toBe(
802
+ '[BOSUN] manual release'
803
+ )
804
+ expect(await gitText(fixture.project, ['rev-parse', 'HEAD'])).toBe(
805
+ await gitText(fixture.remote, ['rev-parse', 'sb/bosun'])
806
+ )
807
+ await service.dispose()
808
+ }, 15_000)
809
+
810
+ it('restarts after manual Discard changes the checked-out project files', async () => {
811
+ const fixture = await createFixture()
812
+ await writeFile(path.join(fixture.project, 'app.txt'), 'discard me\n')
813
+ await writeFile(path.join(fixture.project, 'untracked.txt'), 'discard me too\n')
814
+ const installProject = vi.fn(async () => {})
815
+ const scheduleRestart = vi.fn(() => true)
816
+ const service = createTestService(fixture, { installProject, scheduleRestart })
817
+
818
+ const started = await service.startManual({ tabId: 'tab-a', action: 'discard' })
819
+ await acknowledgePreparationAndFlush(service, 'tab-a', started.operationId!)
820
+ const finished = await waitForPhase(service, 'tab-a', ['restarting', 'fatal'])
821
+
822
+ expect(finished.fatal).toBeNull()
823
+ expect(finished.phase).toBe('restarting')
824
+ expect(finished.reloadToken).not.toBeNull()
825
+ expect(installProject).toHaveBeenCalledOnce()
826
+ expect(scheduleRestart).toHaveBeenCalledWith(76)
827
+ expect(await readFile(path.join(fixture.project, 'app.txt'), 'utf8')).toBe('base\n')
828
+ await expect(
829
+ readFile(path.join(fixture.project, 'untracked.txt'), 'utf8')
830
+ ).rejects.toMatchObject({
831
+ code: 'ENOENT'
832
+ })
833
+ await service.dispose()
834
+ })
835
+
836
+ it('saves refused local changes with a Bosun prefix, merges, and publishes both commits', async () => {
837
+ const fixture = await createFixture('one\ntwo\nthree\nfour\n')
838
+ await writeFile(path.join(fixture.project, 'app.txt'), 'one\ntwo\nthree\nlocal four\n')
839
+ await writeFile(path.join(fixture.collaborator, 'app.txt'), 'remote one\ntwo\nthree\nfour\n')
840
+ await git(fixture.collaborator, ['add', '-A'])
841
+ await git(fixture.collaborator, ['commit', '-m', 'remote line'])
842
+ await git(fixture.collaborator, ['push'])
843
+ await installFailingHooks(fixture.project)
844
+
845
+ const service = createTestService(fixture)
846
+ const started = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
847
+ await acknowledgePreparationAndFlush(service, 'tab-a', started.operationId!)
848
+ const awaiting = await waitForPhase(service, 'tab-a', ['awaiting-changes', 'fatal'])
849
+ expect(awaiting.phase).toBe('awaiting-changes')
850
+
851
+ await service.resolveChanges({
852
+ tabId: 'tab-a',
853
+ operationId: awaiting.operationId!,
854
+ action: 'save',
855
+ description: ' keep both lines '
856
+ })
857
+ await expect(
858
+ service.resolveChanges({
859
+ tabId: 'tab-b',
860
+ operationId: awaiting.operationId!,
861
+ action: 'discard'
862
+ })
863
+ ).rejects.toThrow('This refresh choice was already handled in another Bosun tab.')
864
+ const finished = await waitForPhase(service, 'tab-a', ['restarting', 'fatal'])
865
+ expect(finished.fatal).toBeNull()
866
+ const subjects = (await gitText(fixture.project, ['log', '-2', '--pretty=%s'])).split('\n')
867
+ expect(subjects).toEqual([
868
+ '[BOSUN] [Merge] Merge origin/sb/bosun into sb/bosun',
869
+ '[BOSUN] keep both lines'
870
+ ])
871
+ expect(await gitText(fixture.project, ['rev-parse', 'HEAD'])).toBe(
872
+ await gitText(fixture.remote, ['rev-parse', 'sb/bosun'])
873
+ )
874
+ await service.dispose()
875
+ }, 15_000)
876
+
877
+ it('stashes refused tracked and untracked changes before pulling', async () => {
878
+ const fixture = await createFixture('base\n')
879
+ await writeFile(path.join(fixture.project, 'app.txt'), 'local dirty\n')
880
+ await writeFile(path.join(fixture.project, 'untracked.txt'), 'recover me\n')
881
+ await switchCollaboratorToCurrent(fixture)
882
+ await writeFile(path.join(fixture.collaborator, 'app.txt'), 'remote update\n')
883
+ await git(fixture.collaborator, ['add', '-A'])
884
+ await git(fixture.collaborator, ['commit', '-m', 'remote update'])
885
+ await git(fixture.collaborator, ['push'])
886
+
887
+ const service = createTestService(fixture)
888
+ const started = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
889
+ await acknowledgePreparationAndFlush(service, 'tab-a', started.operationId!)
890
+ const awaiting = await waitForPhase(service, 'tab-a', ['awaiting-changes', 'fatal'])
891
+ expect(awaiting.phase).toBe('awaiting-changes')
892
+ await service.resolveChanges({
893
+ tabId: 'tab-a',
894
+ operationId: awaiting.operationId!,
895
+ action: 'discard'
896
+ })
897
+ const finished = await waitForPhase(service, 'tab-a', ['restarting', 'fatal'])
898
+
899
+ expect(finished.fatal).toBeNull()
900
+ expect(await readFile(path.join(fixture.project, 'app.txt'), 'utf8')).toBe('remote update\n')
901
+ expect(await gitText(fixture.project, ['stash', 'list'])).toContain(
902
+ '[BOSUN] Discard before refresh'
903
+ )
904
+ expect(
905
+ await gitText(fixture.project, ['ls-tree', '-r', '--name-only', 'stash@{0}^3'])
906
+ ).toContain('untracked.txt')
907
+ expect(await gitText(fixture.remote, ['rev-parse', 'sb/bosun'])).toBe(
908
+ await gitText(fixture.remote, ['rev-parse', 'next-release'])
909
+ )
910
+ await service.dispose()
911
+ }, 15_000)
912
+
913
+ it('aborts a conflict, publishes a unique conflict branch, and keeps the current branch intact', async () => {
914
+ const fixture = await createFixture('base\n')
915
+ await writeFile(path.join(fixture.project, 'app.txt'), 'local commit\n')
916
+ await git(fixture.project, ['add', '-A'])
917
+ await git(fixture.project, ['commit', '-m', 'local conflicting work'])
918
+ const localHead = await gitText(fixture.project, ['rev-parse', 'HEAD'])
919
+ await writeFile(path.join(fixture.collaborator, 'app.txt'), 'remote commit\n')
920
+ await git(fixture.collaborator, ['add', '-A'])
921
+ await git(fixture.collaborator, ['commit', '-m', 'remote conflicting work'])
922
+ await git(fixture.collaborator, ['push'])
923
+
924
+ const githubUrl = 'https://github.com/recombine-ai/example.git'
925
+ await git(fixture.project, ['remote', 'set-url', 'origin', githubUrl])
926
+ await git(fixture.project, [
927
+ 'config',
928
+ `url.${pathToFileURL(fixture.remote).href}.insteadOf`,
929
+ githubUrl
930
+ ])
931
+
932
+ const installProject = vi.fn(async () => {})
933
+ const service = createTestService(fixture, { installProject })
934
+ const started = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
935
+ await acknowledgePreparationAndFlush(service, 'tab-a', started.operationId!)
936
+ const failed = await waitForPhase(service, 'tab-a', ['fatal'])
937
+
938
+ expect(failed.fatal?.kind).toBe('merge-conflict')
939
+ expect(failed.prUrl).toMatch(
940
+ /^https:\/\/github\.com\/recombine-ai\/example\/compare\/sb%2Fbosun\.\.\.bosun-conflict%2Fsb%2Fbosun%2F/
941
+ )
942
+ expect(installProject).not.toHaveBeenCalled()
943
+ expect(await gitText(fixture.project, ['branch', '--show-current'])).toBe('sb/bosun')
944
+ expect(await gitText(fixture.project, ['rev-parse', 'HEAD'])).toBe(localHead)
945
+ expect(await gitText(fixture.project, ['ls-files', '-u'])).toBe('')
946
+ const conflictRefs = (
947
+ await gitText(fixture.remote, [
948
+ 'for-each-ref',
949
+ '--format=%(refname:short) %(objectname)',
950
+ 'refs/heads/bosun-conflict/'
951
+ ])
952
+ )
953
+ .split('\n')
954
+ .filter(Boolean)
955
+ expect(conflictRefs).toHaveLength(1)
956
+ expect(conflictRefs[0]).toContain(localHead)
957
+ expect(service.isUsageBlocked()).toBe(true)
958
+ await service.dispose()
959
+ })
960
+
961
+ it('publishes a release snapshot into personal when the release merge conflicts', async () => {
962
+ const fixture = await createFixture('base\n')
963
+ await writeFile(path.join(fixture.project, 'app.txt'), 'personal commit\n')
964
+ await git(fixture.project, ['add', '-A'])
965
+ await git(fixture.project, ['commit', '-m', 'personal conflicting work'])
966
+ const personalHead = await gitText(fixture.project, ['rev-parse', 'HEAD'])
967
+
968
+ await switchCollaboratorToCurrent(fixture)
969
+ await writeFile(path.join(fixture.collaborator, 'app.txt'), 'release commit\n')
970
+ await git(fixture.collaborator, ['add', '-A'])
971
+ await git(fixture.collaborator, ['commit', '-m', 'release conflicting work'])
972
+ await git(fixture.collaborator, ['push'])
973
+ const currentHead = await gitText(fixture.remote, ['rev-parse', 'next-release'])
974
+
975
+ const githubUrl = 'https://github.com/recombine-ai/example.git'
976
+ await git(fixture.project, ['remote', 'set-url', 'origin', githubUrl])
977
+ await git(fixture.project, [
978
+ 'config',
979
+ `url.${pathToFileURL(fixture.remote).href}.insteadOf`,
980
+ githubUrl
981
+ ])
982
+
983
+ const installProject = vi.fn(async () => {})
984
+ const service = createTestService(fixture, { installProject })
985
+ const started = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
986
+ await acknowledgePreparationAndFlush(service, 'tab-a', started.operationId!)
987
+ const failed = await waitForPhase(service, 'tab-a', ['fatal'])
988
+
989
+ expect(failed.fatal?.kind).toBe('merge-conflict')
990
+ expect(failed.prUrl).toMatch(
991
+ /^https:\/\/github\.com\/recombine-ai\/example\/compare\/sb%2Fbosun\.\.\.bosun-conflict%2Fsb%2Fbosun%2F/
992
+ )
993
+ expect(installProject).not.toHaveBeenCalled()
994
+ expect(await gitText(fixture.project, ['branch', '--show-current'])).toBe('sb/bosun')
995
+ expect(await gitText(fixture.project, ['rev-parse', 'HEAD'])).toBe(personalHead)
996
+ expect(await gitText(fixture.project, ['ls-files', '-u'])).toBe('')
997
+ expect(await gitText(fixture.remote, ['rev-parse', 'sb/bosun'])).toBe(personalHead)
998
+ expect(await gitText(fixture.remote, ['rev-parse', 'next-release'])).toBe(currentHead)
999
+ const conflictRefs = (
1000
+ await gitText(fixture.remote, [
1001
+ 'for-each-ref',
1002
+ '--format=%(refname:short) %(objectname)',
1003
+ 'refs/heads/bosun-conflict/'
1004
+ ])
1005
+ )
1006
+ .split('\n')
1007
+ .filter(Boolean)
1008
+ expect(conflictRefs).toHaveLength(1)
1009
+ expect(conflictRefs[0]?.split(' ')[1]).toBe(currentHead)
1010
+ expect(service.isUsageBlocked()).toBe(true)
1011
+ await service.dispose()
1012
+ }, 15_000)
1013
+
1014
+ it('publishes a clean personal branch to the current branch without leaving personal', async () => {
1015
+ const fixture = await createFixture()
1016
+ await writeFile(path.join(fixture.project, 'release.txt'), 'ready\n')
1017
+ await git(fixture.project, ['add', '-A'])
1018
+ await git(fixture.project, ['commit', '-m', 'personal release'])
1019
+ await git(fixture.project, ['push'])
1020
+ const installProject = vi.fn(async () => {})
1021
+ const scheduleRestart = vi.fn(() => true)
1022
+ const service = createTestService(fixture, { installProject, scheduleRestart })
1023
+
1024
+ const available = await service.status({ tabId: 'tab-a' })
1025
+ expect(available.hasChanges).toBe(false)
1026
+ expect(available.canPrepareRelease).toBe(true)
1027
+ const started = await service.prepareRelease({ tabId: 'tab-a' })
1028
+ expect(started.phase).toBe('preparing')
1029
+ await acknowledgePreparationAndFlush(service, 'tab-a', started.operationId!)
1030
+ const finished = await waitForOperationEnd(service, 'tab-a')
1031
+
1032
+ expect(finished.phase).toBe('idle')
1033
+ expect(finished.canPrepareRelease).toBe(false)
1034
+ expect(await gitText(fixture.project, ['branch', '--show-current'])).toBe('sb/bosun')
1035
+ const personalRemoteHead = await gitText(fixture.remote, ['rev-parse', 'sb/bosun'])
1036
+ expect(await gitText(fixture.remote, ['rev-parse', 'next-release'])).toBe(personalRemoteHead)
1037
+ expect(await gitText(fixture.project, ['rev-parse', 'next-release'])).toBe(personalRemoteHead)
1038
+ expect(installProject).not.toHaveBeenCalled()
1039
+ expect(scheduleRestart).not.toHaveBeenCalled()
1040
+ await service.dispose()
1041
+ })
1042
+
1043
+ it('builds a divergent release outside the primary checkout and atomically publishes both branches', async () => {
1044
+ const fixture = await createFixture()
1045
+ await git(fixture.collaborator, ['branch', '--track', 'next-release', 'origin/next-release'])
1046
+ await git(fixture.collaborator, ['switch', 'next-release'])
1047
+ await writeFile(path.join(fixture.collaborator, 'current.txt'), 'current work\n')
1048
+ await git(fixture.collaborator, ['add', '-A'])
1049
+ await git(fixture.collaborator, ['commit', '-m', 'current work'])
1050
+ await git(fixture.collaborator, ['push'])
1051
+
1052
+ await writeFile(path.join(fixture.project, 'personal.txt'), 'personal work\n')
1053
+ await git(fixture.project, ['add', '-A'])
1054
+ await git(fixture.project, ['commit', '-m', 'personal work'])
1055
+ await git(fixture.project, ['push'])
1056
+ await installFailingHooks(fixture.project)
1057
+ const installProject = vi.fn(async () => {})
1058
+ const scheduleRestart = vi.fn(() => true)
1059
+ const service = createTestService(fixture, { installProject, scheduleRestart })
1060
+
1061
+ expect((await service.status({ tabId: 'tab-a' })).canPrepareRelease).toBe(true)
1062
+ const started = await service.prepareRelease({ tabId: 'tab-a' })
1063
+ await acknowledgePreparationAndFlush(service, 'tab-a', started.operationId!)
1064
+ const finished = await waitForPhase(service, 'tab-a', ['restarting', 'fatal'])
1065
+
1066
+ expect(finished.fatal).toBeNull()
1067
+ expect(finished.phase).toBe('restarting')
1068
+ expect(await gitText(fixture.project, ['branch', '--show-current'])).toBe('sb/bosun')
1069
+ const resultHead = await gitText(fixture.remote, ['rev-parse', 'next-release'])
1070
+ expect(await gitText(fixture.remote, ['rev-parse', 'sb/bosun'])).toBe(resultHead)
1071
+ expect(await gitText(fixture.project, ['rev-parse', 'HEAD'])).toBe(resultHead)
1072
+ expect(await gitText(fixture.project, ['rev-parse', 'next-release'])).toBe(resultHead)
1073
+ expect(await gitText(fixture.project, ['log', '-1', '--pretty=%s'])).toBe(
1074
+ '[BOSUN] [Merge] Merge sb/bosun into next-release'
1075
+ )
1076
+ expect(await readFile(path.join(fixture.project, 'current.txt'), 'utf8')).toBe('current work\n')
1077
+ expect(installProject).toHaveBeenCalledOnce()
1078
+ expect(scheduleRestart).toHaveBeenCalledOnce()
1079
+ await expect(
1080
+ readFile(path.join(fixture.project, '.git', 'bosun-prepare-release.json'))
1081
+ ).rejects.toMatchObject({ code: 'ENOENT' })
1082
+ await service.dispose()
1083
+ }, 15_000)
1084
+
1085
+ it('publishes a conflict branch for a Prepare conflict without changing either release branch', async () => {
1086
+ const fixture = await createFixture('base\n')
1087
+ await git(fixture.collaborator, ['branch', '--track', 'next-release', 'origin/next-release'])
1088
+ await git(fixture.collaborator, ['switch', 'next-release'])
1089
+ await writeFile(path.join(fixture.collaborator, 'app.txt'), 'current change\n')
1090
+ await git(fixture.collaborator, ['add', '-A'])
1091
+ await git(fixture.collaborator, ['commit', '-m', 'current conflict'])
1092
+ await git(fixture.collaborator, ['push'])
1093
+ const currentHead = await gitText(fixture.remote, ['rev-parse', 'next-release'])
1094
+
1095
+ await writeFile(path.join(fixture.project, 'app.txt'), 'personal change\n')
1096
+ await git(fixture.project, ['add', '-A'])
1097
+ await git(fixture.project, ['commit', '-m', 'personal conflict'])
1098
+ await git(fixture.project, ['push'])
1099
+ const personalHead = await gitText(fixture.project, ['rev-parse', 'HEAD'])
1100
+ const githubUrl = 'https://github.com/recombine-ai/example.git'
1101
+ await git(fixture.project, ['remote', 'set-url', 'origin', githubUrl])
1102
+ await git(fixture.project, [
1103
+ 'config',
1104
+ `url.${pathToFileURL(fixture.remote).href}.insteadOf`,
1105
+ githubUrl
1106
+ ])
1107
+ const installProject = vi.fn(async () => {})
1108
+ const service = createTestService(fixture, { installProject })
1109
+
1110
+ const started = await service.prepareRelease({ tabId: 'tab-a' })
1111
+ await acknowledgePreparationAndFlush(service, 'tab-a', started.operationId!)
1112
+ const failed = await waitForPhase(service, 'tab-a', ['fatal'])
1113
+
1114
+ expect(failed.fatal?.kind).toBe('merge-conflict')
1115
+ expect(failed.prUrl).toMatch(
1116
+ /^https:\/\/github\.com\/recombine-ai\/example\/compare\/next-release\.\.\.bosun-conflict%2Fnext-release%2F/
1117
+ )
1118
+ expect(await gitText(fixture.remote, ['rev-parse', 'next-release'])).toBe(currentHead)
1119
+ expect(await gitText(fixture.remote, ['rev-parse', 'sb/bosun'])).toBe(personalHead)
1120
+ expect(await gitText(fixture.project, ['branch', '--show-current'])).toBe('sb/bosun')
1121
+ expect(await gitText(fixture.project, ['rev-parse', 'HEAD'])).toBe(personalHead)
1122
+ expect(installProject).not.toHaveBeenCalled()
1123
+ await expect(
1124
+ readFile(path.join(fixture.project, '.git', 'bosun-prepare-release.json'))
1125
+ ).rejects.toMatchObject({ code: 'ENOENT' })
1126
+ await expect(
1127
+ readFile(path.join(fixture.project, '.git', 'bosun-prepare-release-workspace', 'HEAD'))
1128
+ ).rejects.toMatchObject({ code: 'ENOENT' })
1129
+ await service.dispose()
1130
+ }, 15_000)
1131
+
1132
+ it('refuses to overwrite unpublished commits on the local current branch', async () => {
1133
+ const fixture = await createFixture()
1134
+ await git(fixture.project, ['switch', 'next-release'])
1135
+ await writeFile(path.join(fixture.project, 'local-current.txt'), 'local only\n')
1136
+ await git(fixture.project, ['add', '-A'])
1137
+ await git(fixture.project, ['commit', '-m', 'unpublished current work'])
1138
+ const localCurrentHead = await gitText(fixture.project, ['rev-parse', 'HEAD'])
1139
+ await git(fixture.project, ['switch', 'sb/bosun'])
1140
+ await writeFile(path.join(fixture.project, 'personal.txt'), 'release me\n')
1141
+ await git(fixture.project, ['add', '-A'])
1142
+ await git(fixture.project, ['commit', '-m', 'personal release'])
1143
+ await git(fixture.project, ['push'])
1144
+ const oldRemoteCurrent = await gitText(fixture.remote, ['rev-parse', 'next-release'])
1145
+ const service = createTestService(fixture)
1146
+
1147
+ const started = await service.prepareRelease({ tabId: 'tab-a' })
1148
+ await acknowledgePreparationAndFlush(service, 'tab-a', started.operationId!)
1149
+ const failed = await waitForPhase(service, 'tab-a', ['fatal'])
1150
+
1151
+ expect(failed.fatal?.kind).toBe('general')
1152
+ expect(await gitText(fixture.remote, ['rev-parse', 'next-release'])).toBe(oldRemoteCurrent)
1153
+ expect(await gitText(fixture.project, ['rev-parse', 'next-release'])).toBe(localCurrentHead)
1154
+ expect(await gitText(fixture.project, ['branch', '--show-current'])).toBe('sb/bosun')
1155
+ await service.dispose()
1156
+ })
1157
+
1158
+ it('blocks Prepare if the release branch is checked out in a worktree added after launch', async () => {
1159
+ const fixture = await createFixture()
1160
+ await writeFile(path.join(fixture.project, 'personal.txt'), 'release me\n')
1161
+ await git(fixture.project, ['add', '-A'])
1162
+ await git(fixture.project, ['commit', '-m', 'personal release'])
1163
+ await git(fixture.project, ['push'])
1164
+ const service = createTestService(fixture)
1165
+ expect((await service.status({ tabId: 'tab-a' })).canPrepareRelease).toBe(true)
1166
+
1167
+ const releaseWorktree = path.join(fixture.root, 'release-worktree')
1168
+ await git(fixture.project, ['worktree', 'add', releaseWorktree, 'next-release'])
1169
+ const status = await service.prepareRelease({ tabId: 'tab-a' })
1170
+
1171
+ expect(status.phase).toBe('fatal')
1172
+ expect(status.operationId).toBeNull()
1173
+ expect(await gitText(fixture.project, ['branch', '--show-current'])).toBe('sb/bosun')
1174
+ expect(await gitText(releaseWorktree, ['branch', '--show-current'])).toBe('next-release')
1175
+ await service.dispose()
1176
+ })
1177
+
1178
+ it('blocks activation if the shared remote gains a different push URL after launch', async () => {
1179
+ const fixture = await createFixture()
1180
+ const service = createTestService(fixture)
1181
+ await service.status({ tabId: 'tab-a' })
1182
+ await git(fixture.project, ['remote', 'set-url', '--push', 'origin', `${fixture.remote}-other`])
1183
+
1184
+ const status = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
1185
+
1186
+ expect(status.phase).toBe('fatal')
1187
+ expect(status.operationId).toBeNull()
1188
+ expect(status.fatal?.message).toContain('matching push URL')
1189
+ await service.dispose()
1190
+ })
1191
+
1192
+ it('rejects explicit Save and Prepare starts while another operation owns Git mode', async () => {
1193
+ const fixture = await createFixture()
1194
+ await writeFile(path.join(fixture.project, 'local.txt'), 'local draft\n')
1195
+ const service = createTestService(fixture)
1196
+ const activation = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
1197
+ expect(activation.phase).toBe('preparing')
1198
+
1199
+ await expect(
1200
+ service.startManual({
1201
+ tabId: 'tab-b',
1202
+ action: 'save',
1203
+ description: 'must not attach to activation'
1204
+ })
1205
+ ).rejects.toThrow('already in progress')
1206
+ await expect(service.prepareRelease({ tabId: 'tab-b' })).rejects.toThrow('already in progress')
1207
+ await service.dispose()
1208
+ })
1209
+
1210
+ it('recovers a fully published release after shutdown before local alignment', async () => {
1211
+ const fixture = await createFixture()
1212
+ await writeFile(path.join(fixture.project, 'release.txt'), 'published\n')
1213
+ await git(fixture.project, ['add', '-A'])
1214
+ await git(fixture.project, ['commit', '-m', 'personal release'])
1215
+ await git(fixture.project, ['push'])
1216
+ let lostAtomicPushResponse = false
1217
+ const runner: CommandRunner = async (executable, args, options) => {
1218
+ const result = await runCommand(executable, args, options)
1219
+ if (!lostAtomicPushResponse && executable === 'git' && args.includes('--atomic')) {
1220
+ lostAtomicPushResponse = true
1221
+ throw new Error('simulated shutdown after atomic publication')
1222
+ }
1223
+ return result
1224
+ }
1225
+ const interrupted = createTestService(fixture, { runCommand: runner })
1226
+ const started = await interrupted.prepareRelease({ tabId: 'tab-a' })
1227
+ await acknowledgePreparationAndFlush(interrupted, 'tab-a', started.operationId!)
1228
+ expect((await waitForPhase(interrupted, 'tab-a', ['fatal'])).fatal?.kind).toBe('general')
1229
+ await interrupted.dispose()
1230
+
1231
+ const publishedHead = await gitText(fixture.remote, ['rev-parse', 'next-release'])
1232
+ expect(await gitText(fixture.remote, ['rev-parse', 'sb/bosun'])).toBe(publishedHead)
1233
+ expect(await gitText(fixture.project, ['rev-parse', 'next-release'])).not.toBe(publishedHead)
1234
+ fixture.config.launchId = 'recovery-launch'
1235
+ const resumeWrites = vi.fn(async () => {})
1236
+ const recovered = createTestService(fixture, { resumeWrites })
1237
+ const status = await recovered.status({ tabId: 'tab-b' })
1238
+
1239
+ expect(status.phase).toBe('idle')
1240
+ expect(status.fatal).toBeNull()
1241
+ expect(status.canPrepareRelease).toBe(false)
1242
+ expect(await gitText(fixture.project, ['branch', '--show-current'])).toBe('sb/bosun')
1243
+ expect(await gitText(fixture.project, ['rev-parse', 'next-release'])).toBe(publishedHead)
1244
+ expect(resumeWrites).toHaveBeenCalledOnce()
1245
+ await expect(
1246
+ readFile(path.join(fixture.project, '.git', 'bosun-prepare-release.json'))
1247
+ ).rejects.toMatchObject({ code: 'ENOENT' })
1248
+ await recovered.dispose()
1249
+ }, 15_000)
1250
+
1251
+ it('installs and restarts when divergent release recovery changes the personal tree', async () => {
1252
+ const fixture = await createFixture()
1253
+ await git(fixture.collaborator, ['branch', '--track', 'next-release', 'origin/next-release'])
1254
+ await git(fixture.collaborator, ['switch', 'next-release'])
1255
+ await writeFile(path.join(fixture.collaborator, 'current.txt'), 'current work\n')
1256
+ await git(fixture.collaborator, ['add', '-A'])
1257
+ await git(fixture.collaborator, ['commit', '-m', 'current work'])
1258
+ await git(fixture.collaborator, ['push'])
1259
+ await writeFile(path.join(fixture.project, 'personal.txt'), 'personal work\n')
1260
+ await git(fixture.project, ['add', '-A'])
1261
+ await git(fixture.project, ['commit', '-m', 'personal work'])
1262
+ await git(fixture.project, ['push'])
1263
+
1264
+ let lostAtomicPushResponse = false
1265
+ const runner: CommandRunner = async (executable, args, options) => {
1266
+ const result = await runCommand(executable, args, options)
1267
+ if (!lostAtomicPushResponse && executable === 'git' && args.includes('--atomic')) {
1268
+ lostAtomicPushResponse = true
1269
+ throw new Error('simulated shutdown after divergent atomic publication')
1270
+ }
1271
+ return result
1272
+ }
1273
+ const interrupted = createTestService(fixture, { runCommand: runner })
1274
+ const started = await interrupted.prepareRelease({ tabId: 'tab-a' })
1275
+ await acknowledgePreparationAndFlush(interrupted, 'tab-a', started.operationId!)
1276
+ await waitForPhase(interrupted, 'tab-a', ['fatal'])
1277
+ await interrupted.dispose()
1278
+
1279
+ const resultHead = await gitText(fixture.remote, ['rev-parse', 'next-release'])
1280
+ expect(await gitText(fixture.remote, ['rev-parse', 'sb/bosun'])).toBe(resultHead)
1281
+ expect(await gitText(fixture.project, ['rev-parse', 'HEAD'])).not.toBe(resultHead)
1282
+ fixture.config.launchId = 'divergent-recovery-launch'
1283
+ const installProject = vi.fn(async () => {})
1284
+ const scheduleRestart = vi.fn(() => true)
1285
+ const resumeWrites = vi.fn(async () => {})
1286
+ const recovered = createTestService(fixture, {
1287
+ installProject,
1288
+ scheduleRestart,
1289
+ resumeWrites
1290
+ })
1291
+ const status = await recovered.status({ tabId: 'tab-b' })
1292
+
1293
+ expect(status.phase).toBe('restarting')
1294
+ expect(status.reloadToken).not.toBeNull()
1295
+ expect(await gitText(fixture.project, ['branch', '--show-current'])).toBe('sb/bosun')
1296
+ expect(await gitText(fixture.project, ['rev-parse', 'HEAD'])).toBe(resultHead)
1297
+ expect(await gitText(fixture.project, ['rev-parse', 'next-release'])).toBe(resultHead)
1298
+ expect(await readFile(path.join(fixture.project, 'current.txt'), 'utf8')).toBe('current work\n')
1299
+ expect(installProject).toHaveBeenCalledOnce()
1300
+ expect(scheduleRestart).toHaveBeenCalledWith(76)
1301
+ expect(resumeWrites).not.toHaveBeenCalled()
1302
+ await expect(
1303
+ readFile(path.join(fixture.project, '.git', 'bosun-prepare-release.json'))
1304
+ ).rejects.toMatchObject({ code: 'ENOENT' })
1305
+ await recovered.dispose()
1306
+ }, 20_000)
1307
+
1308
+ it('cleans an interrupted Prepare attempt when neither remote branch was published', async () => {
1309
+ const fixture = await createFixture()
1310
+ await writeFile(path.join(fixture.project, 'release.txt'), 'not published\n')
1311
+ await git(fixture.project, ['add', '-A'])
1312
+ await git(fixture.project, ['commit', '-m', 'personal release'])
1313
+ await git(fixture.project, ['push'])
1314
+ const rejectAtomicPush: CommandRunner = (executable, args, options) => {
1315
+ if (executable === 'git' && args.includes('--atomic')) {
1316
+ return Promise.reject(new Error('simulated shutdown before atomic publication'))
1317
+ }
1318
+ return runCommand(executable, args, options)
1319
+ }
1320
+ const interrupted = createTestService(fixture, { runCommand: rejectAtomicPush })
1321
+ const started = await interrupted.prepareRelease({ tabId: 'tab-a' })
1322
+ await acknowledgePreparationAndFlush(interrupted, 'tab-a', started.operationId!)
1323
+ await waitForPhase(interrupted, 'tab-a', ['fatal'])
1324
+ await interrupted.dispose()
1325
+
1326
+ const oldCurrentHead = await gitText(fixture.remote, ['rev-parse', 'next-release'])
1327
+ fixture.config.launchId = 'unpublished-recovery-launch'
1328
+ const resumeWrites = vi.fn(async () => {})
1329
+ const recovered = createTestService(fixture, { resumeWrites })
1330
+ const status = await recovered.status({ tabId: 'tab-b' })
1331
+
1332
+ expect(status.phase).toBe('idle')
1333
+ expect(status.canPrepareRelease).toBe(true)
1334
+ expect(await gitText(fixture.remote, ['rev-parse', 'next-release'])).toBe(oldCurrentHead)
1335
+ expect(resumeWrites).toHaveBeenCalledOnce()
1336
+ await expect(
1337
+ readFile(path.join(fixture.project, '.git', 'bosun-prepare-release.json'))
1338
+ ).rejects.toMatchObject({ code: 'ENOENT' })
1339
+ await recovered.dispose()
1340
+ }, 15_000)
1341
+
1342
+ it('blocks recovery when the interrupted release refs have a foreign split state', async () => {
1343
+ const fixture = await createFixture()
1344
+ await writeFile(path.join(fixture.project, 'release.txt'), 'not published\n')
1345
+ await git(fixture.project, ['add', '-A'])
1346
+ await git(fixture.project, ['commit', '-m', 'personal release'])
1347
+ await git(fixture.project, ['push'])
1348
+ const rejectAtomicPush: CommandRunner = (executable, args, options) => {
1349
+ if (executable === 'git' && args.includes('--atomic')) {
1350
+ return Promise.reject(new Error('simulated shutdown before atomic publication'))
1351
+ }
1352
+ return runCommand(executable, args, options)
1353
+ }
1354
+ const interrupted = createTestService(fixture, { runCommand: rejectAtomicPush })
1355
+ const started = await interrupted.prepareRelease({ tabId: 'tab-a' })
1356
+ await acknowledgePreparationAndFlush(interrupted, 'tab-a', started.operationId!)
1357
+ await waitForPhase(interrupted, 'tab-a', ['fatal'])
1358
+ await interrupted.dispose()
1359
+
1360
+ await git(fixture.collaborator, ['branch', '--track', 'next-release', 'origin/next-release'])
1361
+ await git(fixture.collaborator, ['switch', 'next-release'])
1362
+ await writeFile(path.join(fixture.collaborator, 'foreign.txt'), 'foreign update\n')
1363
+ await git(fixture.collaborator, ['add', '-A'])
1364
+ await git(fixture.collaborator, ['commit', '-m', 'foreign current update'])
1365
+ await git(fixture.collaborator, ['push'])
1366
+
1367
+ fixture.config.launchId = 'foreign-recovery-launch'
1368
+ const resumeWrites = vi.fn(async () => {})
1369
+ const recovered = createTestService(fixture, { resumeWrites })
1370
+ const status = await recovered.status({ tabId: 'tab-b' })
1371
+
1372
+ expect(status.phase).toBe('fatal')
1373
+ expect(status.fatal?.kind).toBe('general')
1374
+ expect(resumeWrites).not.toHaveBeenCalled()
1375
+ await expect(
1376
+ readFile(path.join(fixture.project, '.git', 'bosun-prepare-release.json'), 'utf8')
1377
+ ).resolves.toContain('oldCurrentRemoteHead')
1378
+ await recovered.dispose()
1379
+ }, 15_000)
1380
+
1381
+ it('blocks interrupted Prepare recovery if the effective remote URL changed', async () => {
1382
+ const fixture = await createFixture()
1383
+ await writeFile(path.join(fixture.project, 'release.txt'), 'not published\n')
1384
+ await git(fixture.project, ['add', '-A'])
1385
+ await git(fixture.project, ['commit', '-m', 'personal release'])
1386
+ await git(fixture.project, ['push'])
1387
+ const rejectAtomicPush: CommandRunner = (executable, args, options) => {
1388
+ if (executable === 'git' && args.includes('--atomic')) {
1389
+ return Promise.reject(new Error('simulated shutdown before atomic publication'))
1390
+ }
1391
+ return runCommand(executable, args, options)
1392
+ }
1393
+ const interrupted = createTestService(fixture, { runCommand: rejectAtomicPush })
1394
+ const started = await interrupted.prepareRelease({ tabId: 'tab-a' })
1395
+ await acknowledgePreparationAndFlush(interrupted, 'tab-a', started.operationId!)
1396
+ await waitForPhase(interrupted, 'tab-a', ['fatal'])
1397
+ await interrupted.dispose()
1398
+
1399
+ await git(fixture.project, [
1400
+ 'remote',
1401
+ 'set-url',
1402
+ 'origin',
1403
+ path.join(fixture.root, 'different-remote.git')
1404
+ ])
1405
+ fixture.config.launchId = 'changed-url-recovery-launch'
1406
+ const resumeWrites = vi.fn(async () => {})
1407
+ const recovered = createTestService(fixture, { resumeWrites })
1408
+ const status = await recovered.status({ tabId: 'tab-b' })
1409
+
1410
+ expect(status.phase).toBe('fatal')
1411
+ expect(resumeWrites).not.toHaveBeenCalled()
1412
+ await expect(
1413
+ readFile(path.join(fixture.project, '.git', 'bosun-prepare-release.json'), 'utf8')
1414
+ ).resolves.toContain('remoteUrlDigest')
1415
+ await recovered.dispose()
1416
+ }, 15_000)
1417
+
1418
+ it('persists completion so a managed backend restart cannot reactivate immediately', async () => {
1419
+ const fixture = await createFixture()
1420
+ await writeFile(path.join(fixture.collaborator, 'remote.txt'), 'remote\n')
1421
+ await git(fixture.collaborator, ['add', '-A'])
1422
+ await git(fixture.collaborator, ['commit', '-m', 'remote work'])
1423
+ await git(fixture.collaborator, ['push'])
1424
+ let now = 1_000
1425
+ const service = createTestService(fixture, { now: () => now })
1426
+ const started = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
1427
+ await acknowledgePreparationAndFlush(service, 'tab-a', started.operationId!)
1428
+ const firstFinished = await waitForPhase(service, 'tab-a', ['restarting', 'fatal'])
1429
+ expect(firstFinished.phase).toBe('restarting')
1430
+ const reloadToken = firstFinished.reloadToken
1431
+ await service.dispose()
1432
+ const persisted = JSON.parse(await readFile(fixture.config.runtimeStatePath, 'utf8'))
1433
+ persisted.lastRelevantChangeAt = 1_500
1434
+ await writeFile(fixture.config.runtimeStatePath, `${JSON.stringify(persisted)}\n`)
1435
+
1436
+ const restarted = createTestService(fixture, { now: () => now })
1437
+ const initial = await restarted.status({ tabId: 'tab-b' })
1438
+ expect(initial.phase).toBe('idle')
1439
+ expect(initial.reloadToken).toBe(reloadToken)
1440
+ await expect(
1441
+ readFile(path.join(fixture.project, '.git', 'bosun-content-refresh.json'))
1442
+ ).rejects.toMatchObject({ code: 'ENOENT' })
1443
+ expect(
1444
+ (await restarted.activate({ tabId: 'tab-b', reason: 'page-open' })).operationId
1445
+ ).toBeNull()
1446
+
1447
+ now += fixture.config.idleMs
1448
+ expect((await restarted.activate({ tabId: 'tab-b', reason: 'focus' })).operationId).toBeNull()
1449
+ now = 1_500 + fixture.config.idleMs
1450
+ const eligible = await restarted.activate({ tabId: 'tab-b', reason: 'focus' })
1451
+ expect(eligible.phase).toBe('preparing')
1452
+ await restarted.dispose()
1453
+ }, 15_000)
1454
+
1455
+ it('keeps recovery blocked if the checkout leaves the personal branch before restart', async () => {
1456
+ const fixture = await createFixture()
1457
+ await writeFile(path.join(fixture.collaborator, 'remote.txt'), 'remote\n')
1458
+ await git(fixture.collaborator, ['add', '-A'])
1459
+ await git(fixture.collaborator, ['commit', '-m', 'remote work'])
1460
+ await git(fixture.collaborator, ['push'])
1461
+ const service = createTestService(fixture)
1462
+ const started = await service.activate({ tabId: 'tab-a', reason: 'page-open' })
1463
+ await acknowledgePreparationAndFlush(service, 'tab-a', started.operationId!)
1464
+ expect((await waitForPhase(service, 'tab-a', ['restarting', 'fatal'])).phase).toBe('restarting')
1465
+ await service.dispose()
1466
+
1467
+ await git(fixture.project, ['switch', 'next-release'])
1468
+ const installProject = vi.fn(async () => {})
1469
+ const resumeWrites = vi.fn(async () => {})
1470
+ const restarted = createTestService(fixture, { installProject, resumeWrites })
1471
+ const blocked = await restarted.status({ tabId: 'tab-b' })
1472
+
1473
+ expect(blocked.phase).toBe('fatal')
1474
+ expect(blocked.fatal?.kind).toBe('branch-mismatch')
1475
+ expect(installProject).not.toHaveBeenCalled()
1476
+ expect(resumeWrites).not.toHaveBeenCalled()
1477
+ await expect(
1478
+ readFile(path.join(fixture.project, '.git', 'bosun-content-refresh.json'), 'utf8')
1479
+ ).resolves.toContain('installedContent')
1480
+ await restarted.dispose()
1481
+ })
1482
+ })
1483
+
1484
+ interface Fixture {
1485
+ root: string
1486
+ remote: string
1487
+ project: string
1488
+ collaborator: string
1489
+ config: GitModeConfig
1490
+ }
1491
+
1492
+ async function createFixture(appContents = 'base\n'): Promise<Fixture> {
1493
+ const root = await mkdtemp(path.join(tmpdir(), 'bosun-git-mode-'))
1494
+ temporaryDirectories.push(root)
1495
+ const remote = path.join(root, 'remote.git')
1496
+ const seed = path.join(root, 'seed')
1497
+ const project = path.join(root, 'project')
1498
+ const collaborator = path.join(root, 'collaborator')
1499
+ await git(root, ['init', '--bare', remote])
1500
+ await mkdir(seed)
1501
+ await git(seed, ['init'])
1502
+ await configureUser(seed)
1503
+ await git(seed, ['checkout', '-b', 'next-release'])
1504
+ await writeFile(path.join(seed, 'app.txt'), appContents)
1505
+ await writeFile(path.join(seed, 'package.json'), '{"name":"fixture","private":true}\n')
1506
+ await git(seed, ['add', '-A'])
1507
+ await git(seed, ['commit', '-m', 'initial'])
1508
+ await git(seed, ['remote', 'add', 'origin', remote])
1509
+ await git(seed, ['push', '-u', 'origin', 'next-release'])
1510
+ await git(seed, ['switch', '-c', 'sb/bosun'])
1511
+ await git(seed, ['push', '-u', 'origin', 'sb/bosun'])
1512
+ await git(root, ['clone', '--branch', 'sb/bosun', remote, project])
1513
+ await git(root, ['clone', '--branch', 'sb/bosun', remote, collaborator])
1514
+ await git(project, ['branch', '--track', 'next-release', 'origin/next-release'])
1515
+ await configureUser(project)
1516
+ await configureUser(collaborator)
1517
+
1518
+ return {
1519
+ root,
1520
+ remote,
1521
+ project,
1522
+ collaborator,
1523
+ config: {
1524
+ enabled: true,
1525
+ projectDir: project,
1526
+ currentBranch: 'next-release',
1527
+ personalBranch: 'sb/bosun',
1528
+ idleMs: 60_000,
1529
+ currentUpstream: 'origin/next-release',
1530
+ currentUpstreamRemote: 'origin',
1531
+ currentUpstreamBranch: 'next-release',
1532
+ personalUpstream: 'origin/sb/bosun',
1533
+ personalUpstreamRemote: 'origin',
1534
+ personalUpstreamBranch: 'sb/bosun',
1535
+ launchId: 'test-launch',
1536
+ runtimeStatePath: path.join(root, 'runtime', 'git-mode.json'),
1537
+ restartExitCode: 76
1538
+ }
1539
+ }
1540
+ }
1541
+
1542
+ function createTestService(
1543
+ fixture: Fixture,
1544
+ overrides: {
1545
+ installProject?: () => Promise<void>
1546
+ settleWrites?: () => Promise<void>
1547
+ resumeWrites?: () => Promise<void>
1548
+ scheduleRestart?: (exitCode: number) => boolean
1549
+ runCommand?: CommandRunner
1550
+ now?: () => number
1551
+ liveTabMs?: number
1552
+ flushTimeoutMs?: number
1553
+ } = {}
1554
+ ): GitModeService {
1555
+ return createGitModeService(fixture.config, {
1556
+ runCommand: overrides.runCommand,
1557
+ installProject: overrides.installProject ?? (async () => {}),
1558
+ settleWrites: overrides.settleWrites,
1559
+ resumeWrites: overrides.resumeWrites ?? (async () => {}),
1560
+ scheduleRestart: overrides.scheduleRestart ?? (() => true),
1561
+ now: overrides.now,
1562
+ liveTabMs: overrides.liveTabMs,
1563
+ flushTimeoutMs: overrides.flushTimeoutMs,
1564
+ watchFileChanges: false,
1565
+ logError: () => {}
1566
+ })
1567
+ }
1568
+
1569
+ async function acknowledgePreparationAndFlush(
1570
+ service: GitModeService,
1571
+ tabId: string,
1572
+ operationId: string
1573
+ ): Promise<void> {
1574
+ const flushing = await service.flushAck({ tabId, operationId, stage: 'preflight' })
1575
+ expect(flushing.phase).toBe('preparing')
1576
+ expect(flushing.preparationStage).toBe('browser-writes')
1577
+ expect(flushing.tabNeedsFlush).toBe(true)
1578
+ await service.flushAck({ tabId, operationId, stage: 'browser-writes' })
1579
+ }
1580
+
1581
+ async function waitForPhase(
1582
+ service: GitModeService,
1583
+ tabId: string,
1584
+ phases: GitModeStatus['phase'][]
1585
+ ): Promise<GitModeStatus> {
1586
+ const deadline = Date.now() + 10_000
1587
+ while (Date.now() < deadline) {
1588
+ const status = await service.status({ tabId })
1589
+ if (phases.includes(status.phase)) return status
1590
+ await new Promise((resolve) => setTimeout(resolve, 10))
1591
+ }
1592
+ throw new Error(`Timed out waiting for Git mode phase: ${phases.join(', ')}`)
1593
+ }
1594
+
1595
+ async function waitForOperationEnd(service: GitModeService, tabId: string): Promise<GitModeStatus> {
1596
+ const deadline = Date.now() + 10_000
1597
+ while (Date.now() < deadline) {
1598
+ const status = await service.status({ tabId })
1599
+ if (
1600
+ status.phase === 'fatal' ||
1601
+ status.phase === 'restarting' ||
1602
+ (status.phase === 'idle' && status.operationId === null)
1603
+ ) {
1604
+ return status
1605
+ }
1606
+ await new Promise((resolve) => setTimeout(resolve, 10))
1607
+ }
1608
+ throw new Error('Timed out waiting for the Git mode operation to finish')
1609
+ }
1610
+
1611
+ async function configureUser(repository: string): Promise<void> {
1612
+ await git(repository, ['config', 'user.name', 'Bosun Test'])
1613
+ await git(repository, ['config', 'user.email', 'bosun@example.test'])
1614
+ }
1615
+
1616
+ async function switchCollaboratorToCurrent(fixture: Fixture): Promise<void> {
1617
+ await git(fixture.collaborator, ['branch', '--track', 'next-release', 'origin/next-release'])
1618
+ await git(fixture.collaborator, ['switch', 'next-release'])
1619
+ }
1620
+
1621
+ async function setHostilePullConfiguration(repository: string): Promise<void> {
1622
+ await git(repository, ['config', 'pull.rebase', 'true'])
1623
+ await git(repository, ['config', 'pull.ff', 'only'])
1624
+ await git(repository, ['config', 'pull.squash', 'true'])
1625
+ await git(repository, ['config', 'merge.autostash', 'true'])
1626
+ await git(repository, ['config', 'commit.gpgSign', 'true'])
1627
+ await git(repository, ['config', 'merge.gpgSign', 'true'])
1628
+ await git(repository, ['config', 'push.gpgSign', 'true'])
1629
+ await git(repository, ['config', 'merge.verifySignatures', 'true'])
1630
+ await git(repository, ['config', 'branch.sb/bosun.mergeOptions', '--no-commit'])
1631
+ }
1632
+
1633
+ async function installFailingHooks(repository: string): Promise<void> {
1634
+ const hooks = path.join(repository, '.git', 'hooks')
1635
+ for (const name of ['pre-commit', 'pre-push']) {
1636
+ const hook = path.join(hooks, name)
1637
+ await writeFile(hook, '#!/bin/sh\nexit 91\n')
1638
+ await chmod(hook, 0o755)
1639
+ }
1640
+ }
1641
+
1642
+ async function git(cwd: string, args: string[]): Promise<void> {
1643
+ await execFileAsync('git', args, { cwd, env: { ...process.env, LC_ALL: 'C' } })
1644
+ }
1645
+
1646
+ async function gitText(cwd: string, args: string[]): Promise<string> {
1647
+ const result = await execFileAsync('git', args, {
1648
+ cwd,
1649
+ env: { ...process.env, LC_ALL: 'C' },
1650
+ encoding: 'utf8'
1651
+ })
1652
+ return result.stdout.trim()
1653
+ }