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