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