@diffohq/diffo 0.17.1 → 0.18.0
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/dist/cli.mjs +598 -381
- package/dist/client/assets/{abnfDiagram-O67JEVCF-C4y4PNNf.js → abnfDiagram-WEYOIA3C-B6Y8XL3T.js} +1 -1
- package/dist/client/assets/{arc-CFoc9vPw.js → arc-BMKLX6de.js} +1 -1
- package/dist/client/assets/architecture-WOLXFQ4H-GCPjgG_6.js +1 -0
- package/dist/client/assets/architectureDiagram-ZHVYKT53-D0haekvL.js +36 -0
- package/dist/client/assets/{blockDiagram-BEXU5L5S-CpA03aJs.js → blockDiagram-RJAE7QA3-9ozRFq3h.js} +3 -3
- package/dist/client/assets/c-Cz4nF6n_.js +1 -0
- package/dist/client/assets/{c4Diagram-YGBWAQC7-CYMsQutS.js → c4Diagram-XDPPPE54-3atKbk2-.js} +2 -2
- package/dist/client/assets/channel-CY0_tHjG.js +1 -0
- package/dist/client/assets/{chunk-GTNCS2PH-B4TC-ScJ.js → chunk-2E7WHVMD-B-hVLdxW.js} +3 -3
- package/dist/client/assets/{chunk-NETBCI7D-B5tMFrlX.js → chunk-4EMNKR2T-9R_Ywqe-.js} +1 -1
- package/dist/client/assets/{chunk-GWA4HPMP-CQHzk-qZ.js → chunk-55XCU76K-CRfSz7WS.js} +1 -1
- package/dist/client/assets/chunk-7S6XSTFO-jZvVFXd4.js +2 -0
- package/dist/client/assets/{chunk-MBY4JIJT-CH5sGI-E.js → chunk-AQDTSKZB-DM1KPnfu.js} +8 -8
- package/dist/client/assets/chunk-DSNLSHJZ-D-0UEL_k.js +127 -0
- package/dist/client/assets/chunk-HAGFOTED-C_UwN40v.js +62 -0
- package/dist/client/assets/chunk-HNE4Q36P-Ng5JYsLi.js +1 -0
- package/dist/client/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
- package/dist/client/assets/chunk-NQOKRVIF-BGCe7Zmm.js +213 -0
- package/dist/client/assets/{chunk-7PRAP22T-jooxFnA3.js → chunk-UHW7PFBP-Hz5urMmR.js} +1 -1
- package/dist/client/assets/{chunk-WEXAMYUT-Dnrs1Ld4.js → chunk-VQ5W62QI-CvIK79s8.js} +1 -1
- package/dist/client/assets/chunk-WQQL4CDK-DP0cdd_-.js +10 -0
- package/dist/client/assets/{chunk-XXDRQBXY-BDPDpedV.js → chunk-XXDRQBXY-dz3bjW-Z.js} +1 -1
- package/dist/client/assets/{chunk-742MDFTN-BTP6JNdv.js → chunk-YZO67HFG-BGaQ4Np3.js} +1 -1
- package/dist/client/assets/{chunk-5DYCD2WN-BmQXtfyC.js → chunk-ZAUX2LTX-CaHpxHrZ.js} +1 -1
- package/dist/client/assets/chunk-ZCHMQSOR-BQAh24-J.js +2 -0
- package/dist/client/assets/{classDiagram-v2-NBCMYWYE-DS3TfzLh.js → classDiagram-v2-4MYWR3UA-C1uGZilp.js} +3 -3
- package/dist/client/assets/{core-C38j58LO.js → core-DmmPKspM.js} +1 -1
- package/dist/client/assets/cose-bilkent-JH36ORCC-CA0uoIGo.js +1 -0
- package/dist/client/assets/cpp-DzjeI0uH.js +1 -0
- package/dist/client/assets/css-DgaLhXQH.js +1 -0
- package/dist/client/assets/cynefin-EF2NZ3EQ-D7-HiRmU.js +1 -0
- package/dist/client/assets/cynefinDiagram-WF24UDRP-Ct_qs0cP.js +62 -0
- package/dist/client/assets/cytoscape.esm-BegBNzOV.js +321 -0
- package/dist/client/assets/dagre-NK4GIZVX-Cffqg6NJ.js +4 -0
- package/dist/client/assets/{diagram-3UASUU5V-DSX_Tpmv.js → diagram-CBK3MWTC-ByBNedZy.js} +11 -11
- package/dist/client/assets/{diagram-MLGK6HIB-BFgscT9R.js → diagram-I5XIIV2D-ByMRPZMW.js} +1 -1
- package/dist/client/assets/{diagram-CDSNMT55-BiLgPVAJ.js → diagram-ODIL2UCW-C0OayomU.js} +4 -4
- package/dist/client/assets/diagram-PFBEJUXE-BezcNJg0.js +3 -0
- package/dist/client/assets/diagram-RJA3I4VV-Bl3cl2pD.js +41 -0
- package/dist/client/assets/diagram-TYUNFSNR-CyGZb5mO.js +200 -0
- package/dist/client/assets/{dist-CMYkujym.js → dist-Uc27UV0T.js} +1 -1
- package/dist/client/assets/{dist-B77Lw20A.js → dist-YWSBp2hZ.js} +16 -16
- package/dist/client/assets/{ebnfDiagram-ZINNZB2B-x4kmvZzD.js → ebnfDiagram-V4P3TTPT-11k4sRCB.js} +1 -1
- package/dist/client/assets/elk-QONTBCTQ-DM4AV9FF.js +27 -0
- package/dist/client/assets/{engine-oniguruma-Ds-VbuSq.js → engine-oniguruma-MxRvz1no.js} +1 -1
- package/dist/client/assets/{erDiagram-OPXOYQCR-Bo1yx5ec.js → erDiagram-KYQIGQAA-D38TGssZ.js} +2 -2
- package/dist/client/assets/eventmodeling-K75KTNOO-DF0H3qdx.js +1 -0
- package/dist/client/assets/flowDiagram-IUGLLWBU-rSziK2Bd.js +1 -0
- package/dist/client/assets/ganttDiagram-R7GPNTJY-DXtKy5K6.js +292 -0
- package/dist/client/assets/gitGraph-VSP46ZUC-BJmPpwux.js +1 -0
- package/dist/client/assets/gitGraphDiagram-PDYL7XHY-FwrK2brd.js +106 -0
- package/dist/client/assets/gn-CAXYAgV-.js +1 -0
- package/dist/client/assets/html-BWDvXlHg.js +1 -0
- package/dist/client/assets/index-Caehfsn0.css +1 -0
- package/dist/client/assets/index-_VrQDc2L.js +91 -0
- package/dist/client/assets/info-OHQRW6UA-W1kRy4TV.js +1 -0
- package/dist/client/assets/{infoDiagram-VRGFBTTK-CautEhHX.js → infoDiagram-MVDRKAX5-giO2ZEe8.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-OU5B5YK6-C8BUCcyV.js → ishikawaDiagram-HPEHV2GE-DTOlB5LG.js} +3 -3
- package/dist/client/assets/java-_ATy9zCa.js +1 -0
- package/dist/client/assets/javascript-CpIGwZ_Y.js +1 -0
- package/dist/client/assets/{journeyDiagram-ZHPQQLJL-BA5WG8Xf.js → journeyDiagram-BOSHW7O7-DqENuBSc.js} +3 -3
- package/dist/client/assets/json-C3WlssX3.js +1 -0
- package/dist/client/assets/jsx-C4yDKi0A.js +1 -0
- package/dist/client/assets/{kanban-definition-PNTS6WVX-DRXzEJBa.js → kanban-definition-FNSA4HC5-CIYIECg_.js} +9 -9
- package/dist/client/assets/katex-B_JcIwqj.js +257 -0
- package/dist/client/assets/{line-DAcfMF01.js → line-D_x4AjTF.js} +1 -1
- package/dist/client/assets/{linear-uYi6Pah9.js → linear-CS4Fq7f5.js} +1 -1
- package/dist/client/assets/mermaid-parser.core-720dQRcD.js +7 -0
- package/dist/client/assets/mermaid.core-DNKhW2d1.js +44 -0
- package/dist/client/assets/{mindmap-definition-NLK3R4M7-Bn4VqIyk.js → mindmap-definition-6OY57UG3-Tyz7I7bd.js} +30 -28
- package/dist/client/assets/objective-c-D1A_Heim.js +1 -0
- package/dist/client/assets/objective-cpp-BsSzOQcm.js +1 -0
- package/dist/client/assets/packet-JDAUHWVQ-CIT4RofU.js +1 -0
- package/dist/client/assets/{pegDiagram-GJSIUBJH-BD1vaxu_.js → pegDiagram-ECIUJNBX-BDv64Oqu.js} +1 -1
- package/dist/client/assets/pie-XZMESJXO-B7TeMgE-.js +1 -0
- package/dist/client/assets/pieDiagram-GNMPY74T-DZ5ISynf.js +39 -0
- package/dist/client/assets/proto-DB4EqR-F.js +1 -0
- package/dist/client/assets/{quadrantDiagram-O4NWA36T-Co7yTeQ3.js → quadrantDiagram-D57K4MC3-q3ZFhuD4.js} +3 -3
- package/dist/client/assets/radar-ABXABTNO-DfOTCpQ-.js +1 -0
- package/dist/client/assets/railroad-I3PHUGI6-DtLPMTjt.js +1 -0
- package/dist/client/assets/railroad-abnf-LNEFI6M7-DPObEtfh.js +1 -0
- package/dist/client/assets/railroad-ebnf-DWYD2UWJ-g6Y97jri.js +1 -0
- package/dist/client/assets/railroad-peg-JR7BVNR7-CfUBWVcL.js +1 -0
- package/dist/client/assets/{railroadDiagram-XR7U4H2S-B2dydE7a.js → railroadDiagram-Y72NRBL7-CKGjfL5n.js} +1 -1
- package/dist/client/assets/{requirementDiagram-PLB6GJNP-orHF7QGe.js → requirementDiagram-EQYSW5RA-kfSYKuPQ.js} +4 -4
- package/dist/client/assets/sankeyDiagram-SY24JHO5-Dn5ou0D7.js +40 -0
- package/dist/client/assets/sequenceDiagram-BLEHNHQU-B6Qcw9U_.js +169 -0
- package/dist/client/assets/shellscript-CVTB3cr6.js +1 -0
- package/dist/client/assets/sql-CfM2IJm3.js +1 -0
- package/dist/client/assets/src-Bz4qgR7m.js +1 -0
- package/dist/client/assets/{stateDiagram-v2-GCMORJYK-CIs80eMe.js → stateDiagram-v2-A6IESKWZ-B56x9sAq.js} +2 -2
- package/dist/client/assets/swimlanes-B3ENRKFQ-BCQJqf0x.js +1 -0
- package/dist/client/assets/swimlanesDiagram-AYQRV4AK-C1YHgM4T.js +8 -0
- package/dist/client/assets/{timeline-definition-EJHVYXUP-Bbz9sh3O.js → timeline-definition-JXYKGLGB-lEItkXBD.js} +4 -4
- package/dist/client/assets/treeView-D4JQ5SDB-CYZkbtcm.js +1 -0
- package/dist/client/assets/treemap-SAJKECNS-B96tHxAb.js +1 -0
- package/dist/client/assets/tsx-CGosfAUg.js +1 -0
- package/dist/client/assets/typescript-Bb1J8qyy.js +1 -0
- package/dist/client/assets/usecaseDiagram-WPOLBNNX-CvTDEhna.js +328 -0
- package/dist/client/assets/{vennDiagram-UO4OBE2U-DvMMdO42.js → vennDiagram-Q3Y3IDCW-C2Ix7bQn.js} +3 -3
- package/dist/client/assets/wardley-7MLQ67FV-EAIVeoto.js +1 -0
- package/dist/client/assets/{wardleyDiagram-VNRHLVJA-CX0grRSr.js → wardleyDiagram-IVAIDKWA--Okbz3eY.js} +3 -3
- package/dist/client/assets/xml-DuKfbPI6.js +1 -0
- package/dist/client/assets/xychartDiagram-4URNHDU4-CpPxhDeV.js +7 -0
- package/dist/client/assets/yaml-DDA_XMNu.js +1 -0
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/diffo/SKILL.md +7 -4
- package/dist/client/assets/architecture-7GRP2DOG-D0AGQ72s.js +0 -1
- package/dist/client/assets/architectureDiagram-NJMV4G6O-Dr2fN1vr.js +0 -36
- package/dist/client/assets/c-DWGmR78Y.js +0 -1
- package/dist/client/assets/channel-BktdIn5n.js +0 -1
- package/dist/client/assets/chunk-3FUC2YCW-BjQ1itX5.js +0 -2
- package/dist/client/assets/chunk-7INBJB4K-BsVmPyov.js +0 -62
- package/dist/client/assets/chunk-7M6MHVWA-CsHA_fwz.js +0 -213
- package/dist/client/assets/chunk-FOHPRMQF-DzxwRta7.js +0 -161
- package/dist/client/assets/chunk-O7XYJQB3-LPw4VP6e.js +0 -126
- package/dist/client/assets/chunk-UA2S7LBM-DVU8-vtb.js +0 -1
- package/dist/client/assets/chunk-Z7XXMR3K-C5ewXtqS.js +0 -10
- package/dist/client/assets/chunk-ZIGJFQKS-C3kX-TWN.js +0 -2
- package/dist/client/assets/cose-bilkent-JH36ORCC-DbTnvWg3.js +0 -1
- package/dist/client/assets/cpp-CJIxL8VH.js +0 -1
- package/dist/client/assets/css-DUjzNfgv.js +0 -1
- package/dist/client/assets/cynefin-OW5HDTMX-BNjAi5zC.js +0 -1
- package/dist/client/assets/cynefinDiagram-VND7K2PF-DO7VFekp.js +0 -62
- package/dist/client/assets/cytoscape.esm-Yq6u8L66.js +0 -321
- package/dist/client/assets/dagre-6A5THRUB-_yf_g5Eu.js +0 -4
- package/dist/client/assets/diagram-22UHCM2B-C_0zfVyF.js +0 -200
- package/dist/client/assets/diagram-ATOU4E4O-BjdH8AkF.js +0 -3
- package/dist/client/assets/diagram-MPIPVDR6-BuoIMiDa.js +0 -41
- package/dist/client/assets/elk-276RUBZZ-YAyM0RBx.js +0 -27
- package/dist/client/assets/eventmodeling-NTZA5JFV-G8nAQ-r0.js +0 -1
- package/dist/client/assets/flowDiagram-KWPJA3E3-JYut8MuN.js +0 -1
- package/dist/client/assets/ganttDiagram-FUAMR5RP-CIi9ZaVr.js +0 -292
- package/dist/client/assets/gitGraph-4MIJSDKK-DPr2GO72.js +0 -1
- package/dist/client/assets/gitGraphDiagram-X574FWY7-8akAig1Y.js +0 -106
- package/dist/client/assets/html-BlpXnbzK.js +0 -1
- package/dist/client/assets/index-ClMx9yVg.css +0 -1
- package/dist/client/assets/index-IIP4HzAT.js +0 -93
- package/dist/client/assets/info-A6RAGUB7-Gtb-Ecti.js +0 -1
- package/dist/client/assets/java-DxlWrXwe.js +0 -1
- package/dist/client/assets/javascript-Yelw-5ZN.js +0 -1
- package/dist/client/assets/json-CaljABEy.js +0 -1
- package/dist/client/assets/jsx-hEbJ82HF.js +0 -1
- package/dist/client/assets/katex-ZlcWpGUi.js +0 -257
- package/dist/client/assets/mermaid-parser.core-CUupN43F.js +0 -7
- package/dist/client/assets/mermaid.core-P7Zn0OkI.js +0 -44
- package/dist/client/assets/packet-AYTQ26CC-CCcEthJT.js +0 -1
- package/dist/client/assets/pie-WAS4IAKB-DJrFAJNb.js +0 -1
- package/dist/client/assets/pieDiagram-5QR66LMP-BHamJTzC.js +0 -39
- package/dist/client/assets/radar-RG4KPBEZ-C2VAxB0O.js +0 -1
- package/dist/client/assets/railroad-74A4TZTK-dcSfLPuR.js +0 -1
- package/dist/client/assets/railroad-abnf-HS5TGJTU-CZqf5C4_.js +0 -1
- package/dist/client/assets/railroad-ebnf-LZEXJU2U-BkzQPC_g.js +0 -1
- package/dist/client/assets/railroad-peg-WCYAUIDC-CnjsL3Wl.js +0 -1
- package/dist/client/assets/sankeyDiagram-IPEJSGJF-C_OsMuGS.js +0 -40
- package/dist/client/assets/sequenceDiagram-PO4LG4MO-Dh6-7-gO.js +0 -169
- package/dist/client/assets/shellscript-BiGZg9Om.js +0 -1
- package/dist/client/assets/sql-BOsskAmu.js +0 -1
- package/dist/client/assets/src-CoUI9Pvh.js +0 -1
- package/dist/client/assets/swimlanes-2SLR337P-Dwo3Yt9h.js +0 -1
- package/dist/client/assets/swimlanesDiagram-TC7HE7FX-CINzeMsu.js +0 -8
- package/dist/client/assets/treeView-Q6P3EWNA-HHdzOu-n.js +0 -1
- package/dist/client/assets/treemap-WGGIJYW6-Bc3AMsxh.js +0 -1
- package/dist/client/assets/tsx-5f3ulCja.js +0 -1
- package/dist/client/assets/typescript-Utq2Cl8c.js +0 -1
- package/dist/client/assets/usecaseDiagram-POWQR4AR-Dx1EDbri.js +0 -328
- package/dist/client/assets/wardley-WFR3VGLG-BqMvXOfu.js +0 -1
- package/dist/client/assets/xml-CoDIzWF0.js +0 -1
- package/dist/client/assets/xychartDiagram-PMCCYNJV-BQQA3oHd.js +0 -7
- package/dist/client/assets/yaml-CxbubiML.js +0 -1
package/dist/cli.mjs
CHANGED
|
@@ -124,6 +124,59 @@ function detectHarness() {
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
//#endregion
|
|
127
|
+
//#region src/shared/anchors.ts
|
|
128
|
+
function findHunk$1(files, id) {
|
|
129
|
+
for (const file of files) {
|
|
130
|
+
const hunk = file.hunks.find((h) => h.id === id);
|
|
131
|
+
if (hunk) return hunk;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** Index of the line numbered `line` on `side`, or -1: a deleted line is not
|
|
135
|
+
* on the new side, an added one not on the old. */
|
|
136
|
+
function indexOfSideLine(lines, side, line) {
|
|
137
|
+
return lines.findIndex((l) => side === "old" ? l.kind !== "add" && l.oldNo === line : l.kind !== "del" && l.newNo === line);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The same hunk, numbered as another diff numbers it. A hunk id is
|
|
141
|
+
* content-addressed — the path and the changed lines, not where they sit — so
|
|
142
|
+
* the hunk a commit introduced and the one the full diff still shows are one
|
|
143
|
+
* id with two sets of line numbers whenever a later commit grew the file
|
|
144
|
+
* above it. A thread anchored in one numbering is renamed into the other by
|
|
145
|
+
* position in the hunk: line k of the hunk here is line k of the hunk there.
|
|
146
|
+
*
|
|
147
|
+
* Unchanged when either diff lacks the hunk, or when the two copies differ in
|
|
148
|
+
* shape (a context line more or less): then the numbers cannot be trusted and
|
|
149
|
+
* the anchor is left as it was, for placement to re-home by line as usual.
|
|
150
|
+
*/
|
|
151
|
+
function translateHunkAnchor(anchor, from, to) {
|
|
152
|
+
const src = findHunk$1(from, anchor.hunkId);
|
|
153
|
+
const dst = findHunk$1(to, anchor.hunkId);
|
|
154
|
+
if (!src || !dst || src === dst) return anchor;
|
|
155
|
+
if (src.lines.length !== dst.lines.length) return anchor;
|
|
156
|
+
if (src.lines.some((l, i) => l.kind !== dst.lines[i].kind)) return anchor;
|
|
157
|
+
const rename = (n) => {
|
|
158
|
+
const i = indexOfSideLine(src.lines, anchor.side, n);
|
|
159
|
+
if (i === -1) return null;
|
|
160
|
+
const l = dst.lines[i];
|
|
161
|
+
return anchor.side === "old" ? l.oldNo : l.newNo;
|
|
162
|
+
};
|
|
163
|
+
const line = rename(anchor.line);
|
|
164
|
+
if (line === null) return anchor;
|
|
165
|
+
if (anchor.endLine === void 0) return line === anchor.line ? anchor : {
|
|
166
|
+
...anchor,
|
|
167
|
+
line
|
|
168
|
+
};
|
|
169
|
+
const endLine = rename(anchor.endLine);
|
|
170
|
+
return endLine === null ? {
|
|
171
|
+
...anchor,
|
|
172
|
+
line
|
|
173
|
+
} : {
|
|
174
|
+
...anchor,
|
|
175
|
+
line,
|
|
176
|
+
endLine
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
//#endregion
|
|
127
180
|
//#region src/shared/review.ts
|
|
128
181
|
const THREAD_INTENTS = ["fix", "question"];
|
|
129
182
|
/** A review comment on GitHub tops out at 65 536 characters; a suggestion that
|
|
@@ -260,11 +313,7 @@ function describeAnchor(anchor) {
|
|
|
260
313
|
/** A comment can anchor to a long range; the frozen text keeps its head. */
|
|
261
314
|
const ANCHORED_TEXT_CAP = 10;
|
|
262
315
|
function findHunk(changeset, hunkId) {
|
|
263
|
-
|
|
264
|
-
const hunk = file.hunks.find((h) => h.id === hunkId);
|
|
265
|
-
if (hunk) return hunk;
|
|
266
|
-
}
|
|
267
|
-
return null;
|
|
316
|
+
return findHunk$1(changeset.files, hunkId) ?? null;
|
|
268
317
|
}
|
|
269
318
|
/**
|
|
270
319
|
* Freeze what a new thread anchors to: the whole hunk as a diff snapshot, plus
|
|
@@ -362,6 +411,17 @@ function buildConnectAsk(isDev, repoPath, home) {
|
|
|
362
411
|
*/
|
|
363
412
|
const POLL_STANCE = "a tracked background task when its completion wakes you on its own, so the reviewer can keep talking to you in chat meanwhile; if it would not, hold it in the foreground and do not end your turn while it runs — never a detached process";
|
|
364
413
|
/**
|
|
414
|
+
* When the first poll starts: in the same response as the open, before the
|
|
415
|
+
* guide and before any chat work. The poll is the one step with no later
|
|
416
|
+
* surface — every other command's ack says "then poll", but an agent that
|
|
417
|
+
* shares the URL and turns to chat work never runs another diffo command, and
|
|
418
|
+
* the review sits with no listener until the user notices. Stated once here
|
|
419
|
+
* and interpolated into the open's next step, the skill and `help agent`.
|
|
420
|
+
*/
|
|
421
|
+
const POLL_NOW = "now — in this same response, right after sharing the URL, before the guide and before anything the user asked for in chat: the review has no listener until it runs, and a turn that ends with it open and no attended poll leaves the reviewer talking to nobody";
|
|
422
|
+
/** The one exception to POLL_NOW, for harnesses whose poll holds the turn. */
|
|
423
|
+
const POLL_NOW_FOREGROUND = "only when that means a foreground poll does the guide go before it";
|
|
424
|
+
/**
|
|
365
425
|
* The tab-title doctrine — the few words an agent hands its first poll, which
|
|
366
426
|
* become the browser tab's name. Stated once and interpolated into every
|
|
367
427
|
* surface that teaches it (the skill, `help agent`, the open-time next step),
|
|
@@ -642,7 +702,7 @@ function prFrame(pr) {
|
|
|
642
702
|
*/
|
|
643
703
|
function guideNudge(hasGuide, D = AUTHOR_DOCTRINE) {
|
|
644
704
|
if (hasGuide) return null;
|
|
645
|
-
return `share the URL above with the user right now, as a message line, before anything else. Then, while they open it, orient them if this changeset needs it (${D.guide.when}): post a guide — one comment on the whole changeset: \`${CLI_COMMANDS.guide}\` (no file, so it anchors to the changeset; it appears live at the top of their review). It is ${D.guide.what}. Not in it: ${GUIDE.order}. How much: ${GUIDE.budget}. ${D.guide.stance}. \`diffo help guide\` has the diagram legend and one example.`;
|
|
705
|
+
return `share the URL above with the user right now, as a message line, before anything else. Then, with the poll running while they open it, orient them if this changeset needs it (${D.guide.when}): post a guide — one comment on the whole changeset: \`${CLI_COMMANDS.guide}\` (no file, so it anchors to the changeset; it appears live at the top of their review). It is ${D.guide.what}. Not in it: ${GUIDE.order}. How much: ${GUIDE.budget}. ${D.guide.stance}. \`diffo help guide\` has the diagram legend and one example.`;
|
|
646
706
|
}
|
|
647
707
|
/**
|
|
648
708
|
* Printed by `diffo` (open) next to the guide nudge while the review has neither
|
|
@@ -1366,74 +1426,6 @@ function parseLayersInput(raw) {
|
|
|
1366
1426
|
items
|
|
1367
1427
|
};
|
|
1368
1428
|
}
|
|
1369
|
-
//#endregion
|
|
1370
|
-
//#region src/cliArgs.ts
|
|
1371
|
-
const HELP_TEXT = `diffo: review a changeset the way you'd read a book
|
|
1372
|
-
|
|
1373
|
-
Usage: diffo [options] open (or resume) the review for this repo
|
|
1374
|
-
diffo <target> [options] review against a branch, or review a pull request
|
|
1375
|
-
diffo <command> [...] manage the server, or talk to the review
|
|
1376
|
-
diffo help [command] show help for a command
|
|
1377
|
-
|
|
1378
|
-
The target:
|
|
1379
|
-
<branch> everything since forking from that branch (same as --base)
|
|
1380
|
-
<PR URL> a GitHub pull request (also owner/repo#N, #N, or N)
|
|
1381
|
-
(the short forms resolve against this repo's origin)
|
|
1382
|
-
|
|
1383
|
-
For the reviewer:
|
|
1384
|
-
pr <target> Review a pull request (the same as \`diffo <target>\`,
|
|
1385
|
-
and the explicit answer for a pull request of your own;
|
|
1386
|
-
help pr explains the flow)
|
|
1387
|
-
status Show this repo's review server, if one is running
|
|
1388
|
-
(--json for a machine-readable answer)
|
|
1389
|
-
stop Stop this repo's review server
|
|
1390
|
-
clean Remove the worktrees diffo made for pull requests whose
|
|
1391
|
-
review is over (--force takes dirty ones, and ones a
|
|
1392
|
-
running server still serves; --all takes every one)
|
|
1393
|
-
setup Register diffo with the coding agents on this machine
|
|
1394
|
-
(Claude Code, Cursor, VS Code, Copilot, and the shared
|
|
1395
|
-
~/.agents skills dir read by Codex, Gemini, Amp, Goose, …)
|
|
1396
|
-
telemetry Show whether anonymous usage data is on, or turn it
|
|
1397
|
-
off / on (telemetry off · telemetry on)
|
|
1398
|
-
|
|
1399
|
-
For the agent (the AI that wrote the change):
|
|
1400
|
-
poll Wait for the reviewer's feedback (blocking long-poll;
|
|
1401
|
-
prints one JSON payload; safe to re-run any time)
|
|
1402
|
-
(--title "<what the change is>" on the first poll names
|
|
1403
|
-
the reviewer's browser tab)
|
|
1404
|
-
reply <threadId> Post a reply to a review thread
|
|
1405
|
-
(--message "<text>", or pipe the text on stdin)
|
|
1406
|
-
comment [<file>] Start a comment thread as the agent: on a line (--line),
|
|
1407
|
-
a file, or (with no file) the whole changeset; the
|
|
1408
|
-
reviewer replies to take it up, or resolves it
|
|
1409
|
-
layers Outline the changeset as steps to read in order
|
|
1410
|
-
(--suggest ["why"] flags it at open; --json '<Layer[]>'
|
|
1411
|
-
or --stdin posts the list, replacing the last one)
|
|
1412
|
-
end Detach from the review politely
|
|
1413
|
-
help agent The agent's whole protocol on one page
|
|
1414
|
-
help guide The guide comment — the map of the change — with an example
|
|
1415
|
-
|
|
1416
|
-
Options (for opening the review):
|
|
1417
|
-
--base <branch> Review everything since forking from <branch>
|
|
1418
|
-
(default: working tree vs HEAD)
|
|
1419
|
-
-p, --port <port> Port to serve on (default: the port this repo last used,
|
|
1420
|
-
else the first free one)
|
|
1421
|
-
--no-open Don't open the browser automatically
|
|
1422
|
-
--foreground Run the server in this terminal instead of leaving a
|
|
1423
|
-
background one behind (Ctrl-C stops it)
|
|
1424
|
-
-v, --version Show version
|
|
1425
|
-
-h, --help Show this help
|
|
1426
|
-
|
|
1427
|
-
Examples:
|
|
1428
|
-
diffo open the review for this repo in the browser
|
|
1429
|
-
diffo main review everything since forking from main
|
|
1430
|
-
diffo https://github.com/o/r/pull/482
|
|
1431
|
-
review pull request #482 with your agent as copilot
|
|
1432
|
-
diffo status is a server running here, and where?
|
|
1433
|
-
diffo reply t-3 -m "fixed" answer a review thread (agent side)
|
|
1434
|
-
|
|
1435
|
-
Feedback lives in the review, not in any process: diffo, poll, status, stop,
|
|
1436
|
-
and setup are all safe to re-run after an interruption.`;
|
|
1437
1429
|
const VERB_HELP = {
|
|
1438
1430
|
agent: `diffo help agent: the agent's whole protocol on one page
|
|
1439
1431
|
|
|
@@ -1452,7 +1444,15 @@ The loop:
|
|
|
1452
1444
|
ending every message with it while you stay attached.
|
|
1453
1445
|
Attached without ever seeing the URL (a takeover, a fresh session)?
|
|
1454
1446
|
\`diffo status\` prints it.
|
|
1455
|
-
2.
|
|
1447
|
+
2. Listen: run \`${CLI_COMMANDS.firstPoll}\` ${POLL_NOW}. Hold it as
|
|
1448
|
+
${POLL_STANCE}; ${POLL_NOW_FOREGROUND}. It blocks until the
|
|
1449
|
+
reviewer acts, then prints one JSON payload naming the threads to act on.
|
|
1450
|
+
The title is ${TAB_TITLE.what}: ${TAB_TITLE.why}. Write it the way it is
|
|
1451
|
+
read: ${TAB_TITLE.shape} (${TAB_TITLE.examples}). Send it ${TAB_TITLE.when};
|
|
1452
|
+
every other poll is a plain \`diffo poll\`.
|
|
1453
|
+
Killed or timed out? Re-run it; feedback is held in the review, not the
|
|
1454
|
+
poll.
|
|
1455
|
+
3. Guide: post one only when the changeset needs orientation:
|
|
1456
1456
|
${GUIDE.when}.
|
|
1457
1457
|
Right after sharing the URL, while the reviewer opens the page, post ONE
|
|
1458
1458
|
comment on the whole changeset (\`diffo comment -m "…"\`, no file). It is
|
|
@@ -1463,7 +1463,7 @@ The loop:
|
|
|
1463
1463
|
Legend: ${GUIDE.legend}; \`diffo help guide\` has them, and one example.
|
|
1464
1464
|
It lands live at the top of their review; never hold the URL back for it.
|
|
1465
1465
|
If the changeset later shifts under the guide, ${GUIDE.update}.
|
|
1466
|
-
|
|
1466
|
+
4. Layers: offer them when the changeset reads better in order:
|
|
1467
1467
|
${LAYERS.suggest}.
|
|
1468
1468
|
Flag it at open with \`diffo layers --suggest "<why, in one line>"\` and
|
|
1469
1469
|
say so in your handoff ("say layers and I'll outline it"). Post the
|
|
@@ -1473,14 +1473,6 @@ The loop:
|
|
|
1473
1473
|
Summary: ${LAYERS.summary}. Diagram: ${LAYERS.diagram}.
|
|
1474
1474
|
Order: ${LAYERS.order}. ${LAYERS.mechanical}. ${LAYERS.stance}.
|
|
1475
1475
|
${LAYERS.replace}. \`diffo help layers\` has the shape.
|
|
1476
|
-
4. Listen: run \`${CLI_COMMANDS.firstPoll}\`; it blocks until the reviewer
|
|
1477
|
-
acts, then prints one JSON payload naming the threads to act on. Run it
|
|
1478
|
-
attended: ${POLL_STANCE}.
|
|
1479
|
-
The title is ${TAB_TITLE.what}: ${TAB_TITLE.why}. Write it the way it is
|
|
1480
|
-
read: ${TAB_TITLE.shape} (${TAB_TITLE.examples}). Send it ${TAB_TITLE.when};
|
|
1481
|
-
every other poll is a plain \`diffo poll\`.
|
|
1482
|
-
Killed or timed out? Re-run it; feedback is held in the review, not the
|
|
1483
|
-
poll.
|
|
1484
1476
|
5. Act: \`[issue]\` threads want a code change; \`[question]\` threads want an
|
|
1485
1477
|
answer in the reply and no edit. Your edits reach the reviewer live.
|
|
1486
1478
|
6. Reply: \`diffo reply <threadId> --message "<text>"\` (pipe long replies on
|
|
@@ -1501,8 +1493,11 @@ The loop:
|
|
|
1501
1493
|
9. Detach: run \`diffo end\` when the review is over or the user moves on.
|
|
1502
1494
|
|
|
1503
1495
|
Rules:
|
|
1504
|
-
-
|
|
1505
|
-
|
|
1496
|
+
- An open review with no attended poll is a broken state: never end a turn
|
|
1497
|
+
in it. Ending every message with the URL does not make up for it.
|
|
1498
|
+
- Make no edits nobody asked for; the reviewer is mid-read, and an unrelated
|
|
1499
|
+
edit moves the diff under them. Edits the user asks for in chat are theirs
|
|
1500
|
+
to ask for: do them, attached — attaching never waits for chat work.
|
|
1506
1501
|
- One attached agent at a time: the newest poll carries the review; don't
|
|
1507
1502
|
re-poll to take it back from another session; tell the user instead. When
|
|
1508
1503
|
a poll says it took the review over, or returns status "superseded", say so
|
|
@@ -1782,7 +1777,7 @@ Example:
|
|
|
1782
1777
|
diffo telemetry off`
|
|
1783
1778
|
};
|
|
1784
1779
|
function helpFor(topic) {
|
|
1785
|
-
return topic && VERB_HELP[topic] ||
|
|
1780
|
+
return topic && VERB_HELP[topic] || "diffo: review a changeset the way you'd read a book\n\nUsage: diffo [options] open (or resume) the review for this repo\n diffo <target> [options] review against a branch, or review a pull request\n diffo <command> [...] manage the server, or talk to the review\n diffo help [command] show help for a command\n\nThe target:\n <branch> everything since forking from that branch (same as --base)\n <PR URL> a GitHub pull request (also owner/repo#N, #N, or N)\n (the short forms resolve against this repo's origin)\n\nFor the reviewer:\n pr <target> Review a pull request (the same as `diffo <target>`,\n and the explicit answer for a pull request of your own;\n help pr explains the flow)\n status Show this repo's review server, if one is running\n (--json for a machine-readable answer)\n stop Stop this repo's review server\n clean Remove the worktrees diffo made for pull requests whose\n review is over (--force takes dirty ones, and ones a\n running server still serves; --all takes every one)\n setup Register diffo with the coding agents on this machine\n (Claude Code, Cursor, VS Code, Copilot, and the shared\n ~/.agents skills dir read by Codex, Gemini, Amp, Goose, …)\n telemetry Show whether anonymous usage data is on, or turn it\n off / on (telemetry off · telemetry on)\n\nFor the agent (the AI that wrote the change):\n poll Wait for the reviewer's feedback (blocking long-poll;\n prints one JSON payload; safe to re-run any time)\n (--title \"<what the change is>\" on the first poll names\n the reviewer's browser tab)\n reply <threadId> Post a reply to a review thread\n (--message \"<text>\", or pipe the text on stdin)\n comment [<file>] Start a comment thread as the agent: on a line (--line),\n a file, or (with no file) the whole changeset; the\n reviewer replies to take it up, or resolves it\n layers Outline the changeset as steps to read in order\n (--suggest [\"why\"] flags it at open; --json '<Layer[]>'\n or --stdin posts the list, replacing the last one)\n end Detach from the review politely\n help agent The agent's whole protocol on one page\n help guide The guide comment — the map of the change — with an example\n\nOptions (for opening the review):\n --base <branch> Review everything since forking from <branch>\n (default: working tree vs HEAD)\n -p, --port <port> Port to serve on (default: the port this repo last used,\n else the first free one)\n --no-open Don't open the browser automatically\n --foreground Run the server in this terminal instead of leaving a\n background one behind (Ctrl-C stops it)\n -v, --version Show version\n -h, --help Show this help\n\nExamples:\n diffo open the review for this repo in the browser\n diffo main review everything since forking from main\n diffo https://github.com/o/r/pull/482\n review pull request #482 with your agent as copilot\n diffo status is a server running here, and where?\n diffo reply t-3 -m \"fixed\" answer a review thread (agent side)\n\nFeedback lives in the review, not in any process: diffo, poll, status, stop,\nand setup are all safe to re-run after an interruption.";
|
|
1786
1781
|
}
|
|
1787
1782
|
const VERBS = /* @__PURE__ */ new Set([
|
|
1788
1783
|
"poll",
|
|
@@ -3376,6 +3371,86 @@ async function getFullDiffAsync(root, spec) {
|
|
|
3376
3371
|
const [raw, untracked] = await Promise.all([getRawDiffAsync(root, spec), getUntrackedDiffAsync(root)]);
|
|
3377
3372
|
return [raw, untracked].filter(Boolean).join("\n");
|
|
3378
3373
|
}
|
|
3374
|
+
const COMMIT_FIELDS = [
|
|
3375
|
+
"%H",
|
|
3376
|
+
"%h",
|
|
3377
|
+
"%an",
|
|
3378
|
+
"%aI",
|
|
3379
|
+
"%s",
|
|
3380
|
+
"%b"
|
|
3381
|
+
].join("%x1f");
|
|
3382
|
+
/** `3 files changed, 10 insertions(+), 2 deletions(-)` — any part may be absent. */
|
|
3383
|
+
function parseShortstat(line) {
|
|
3384
|
+
const n = (re) => Number.parseInt(re.exec(line)?.[1] ?? "0", 10);
|
|
3385
|
+
return {
|
|
3386
|
+
files: n(/(\d+) files? changed/),
|
|
3387
|
+
additions: n(/(\d+) insertions?\(\+\)/),
|
|
3388
|
+
deletions: n(/(\d+) deletions?\(-\)/)
|
|
3389
|
+
};
|
|
3390
|
+
}
|
|
3391
|
+
/**
|
|
3392
|
+
* The commits a branch review spans, oldest first. A working-tree review has
|
|
3393
|
+
* none: its changeset is not made of commits. Merges are skipped — what they
|
|
3394
|
+
* bring in is the base branch, which this review is not about.
|
|
3395
|
+
*/
|
|
3396
|
+
async function branchStart(root, base) {
|
|
3397
|
+
try {
|
|
3398
|
+
return (await gitAsync(root, [
|
|
3399
|
+
"merge-base",
|
|
3400
|
+
base,
|
|
3401
|
+
"HEAD"
|
|
3402
|
+
])).trim();
|
|
3403
|
+
} catch {
|
|
3404
|
+
throw new MissingBaseError(base);
|
|
3405
|
+
}
|
|
3406
|
+
}
|
|
3407
|
+
/** Just the shas of `listCommitsAsync`, in the same order: a `rev-list`, no
|
|
3408
|
+
* per-commit stats, so a route that only needs to know whether a sha is in the
|
|
3409
|
+
* changeset does not pay for diffing every commit. */
|
|
3410
|
+
async function listCommitShasAsync(root, spec) {
|
|
3411
|
+
if (spec.kind !== "branch") return [];
|
|
3412
|
+
return (await gitAsync(root, [
|
|
3413
|
+
"rev-list",
|
|
3414
|
+
"--reverse",
|
|
3415
|
+
"--no-merges",
|
|
3416
|
+
`${await branchStart(root, spec.base)}..HEAD`
|
|
3417
|
+
])).split("\n").filter((sha) => sha !== "");
|
|
3418
|
+
}
|
|
3419
|
+
async function listCommitsAsync(root, spec) {
|
|
3420
|
+
if (spec.kind !== "branch") return [];
|
|
3421
|
+
const mergeBase = await branchStart(root, spec.base);
|
|
3422
|
+
return (await gitAsync(root, [
|
|
3423
|
+
"log",
|
|
3424
|
+
"--reverse",
|
|
3425
|
+
"--no-merges",
|
|
3426
|
+
"--no-color",
|
|
3427
|
+
`--format=%x1e${COMMIT_FIELDS}%x1f`,
|
|
3428
|
+
"--shortstat",
|
|
3429
|
+
`${mergeBase}..HEAD`
|
|
3430
|
+
])).split("").filter((chunk) => chunk.trim() !== "").map((chunk) => {
|
|
3431
|
+
const [sha = "", short = "", author = "", at = "", subject = "", body = "", stat = ""] = chunk.split("");
|
|
3432
|
+
return {
|
|
3433
|
+
sha,
|
|
3434
|
+
short,
|
|
3435
|
+
author,
|
|
3436
|
+
at,
|
|
3437
|
+
subject,
|
|
3438
|
+
body: body.trim(),
|
|
3439
|
+
stats: parseShortstat(stat)
|
|
3440
|
+
};
|
|
3441
|
+
});
|
|
3442
|
+
}
|
|
3443
|
+
/** One commit's own patch, against its first parent — or against nothing, for
|
|
3444
|
+
* a root commit, which `git show` handles where `sha^` would not resolve. */
|
|
3445
|
+
async function getCommitDiffAsync(root, sha) {
|
|
3446
|
+
return gitAsync(root, [
|
|
3447
|
+
"show",
|
|
3448
|
+
"--format=",
|
|
3449
|
+
"--patch",
|
|
3450
|
+
...DIFF_FLAGS,
|
|
3451
|
+
sha
|
|
3452
|
+
]);
|
|
3453
|
+
}
|
|
3379
3454
|
function isSafeRepoPath(root, relPath) {
|
|
3380
3455
|
return resolve(root, relPath).startsWith(resolve(root) + sep);
|
|
3381
3456
|
}
|
|
@@ -3966,88 +4041,398 @@ const VERSION = (() => {
|
|
|
3966
4041
|
return "0.0.0";
|
|
3967
4042
|
})();
|
|
3968
4043
|
//#endregion
|
|
3969
|
-
//#region src/
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
4044
|
+
//#region src/shared/types.ts
|
|
4045
|
+
/** The working-tree edits a branch review also carries, listed after the
|
|
4046
|
+
* commits as one more step to read. Not a sha, so it can never collide with one. */
|
|
4047
|
+
const UNCOMMITTED = "uncommitted";
|
|
4048
|
+
//#endregion
|
|
4049
|
+
//#region src/server/parse.ts
|
|
4050
|
+
const IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
4051
|
+
".png",
|
|
4052
|
+
".jpg",
|
|
4053
|
+
".jpeg",
|
|
4054
|
+
".gif",
|
|
4055
|
+
".webp",
|
|
4056
|
+
".bmp",
|
|
4057
|
+
".svg",
|
|
4058
|
+
".ico",
|
|
4059
|
+
".avif"
|
|
4060
|
+
]);
|
|
4061
|
+
function isImagePath(path) {
|
|
4062
|
+
const dot = path.lastIndexOf(".");
|
|
4063
|
+
return dot !== -1 && IMAGE_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
3982
4064
|
}
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
stallTimer = null;
|
|
3997
|
-
betweenPolls = false;
|
|
3998
|
-
/** Why the agent is parked between polls: a reply just landed, its poll
|
|
3999
|
-
* ended on its own and the re-poll is expected, or it opened the review and
|
|
4000
|
-
* is still orienting itself before its first poll. */
|
|
4001
|
-
parked = "replied";
|
|
4002
|
-
graceTimer = null;
|
|
4003
|
-
lastDetach = null;
|
|
4004
|
-
since = Date.now();
|
|
4005
|
-
lastDetail = {
|
|
4006
|
-
state: "waiting",
|
|
4007
|
-
reason: "no-agent"
|
|
4065
|
+
/** Content-addressed hunk identity: the hash covers the file path and the changed
|
|
4066
|
+
* lines only — not line numbers, not context. Editing the hunk itself mints a new
|
|
4067
|
+
* id; the occurrence index separates identical hunks within one file. */
|
|
4068
|
+
function hunkId(path, lines, occurrence) {
|
|
4069
|
+
const changed = lines.filter((l) => l.kind !== "context").map((l) => `${l.kind === "add" ? "+" : "-"}${l.text}`).join("\n");
|
|
4070
|
+
return createHash("sha256").update(`${path}\0${changed}\0${occurrence}`).digest("hex").slice(0, 16);
|
|
4071
|
+
}
|
|
4072
|
+
function parseSectionMeta(section) {
|
|
4073
|
+
return {
|
|
4074
|
+
renamedFrom: section.match(/^rename from (.+)$/m)?.[1] ?? null,
|
|
4075
|
+
renamedTo: section.match(/^rename to (.+)$/m)?.[1] ?? null,
|
|
4076
|
+
binary: /^Binary files .* differ$/m.test(section) || /^GIT binary patch$/m.test(section),
|
|
4077
|
+
symlink: /^(?:new file|deleted file|new|old) mode 120000$/m.test(section) || /^index [0-9a-f.]+ 120000$/m.test(section)
|
|
4008
4078
|
};
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4079
|
+
}
|
|
4080
|
+
function splitSections(patch) {
|
|
4081
|
+
if (!patch.trim()) return [];
|
|
4082
|
+
const sections = [];
|
|
4083
|
+
const lines = patch.split("\n");
|
|
4084
|
+
let current = null;
|
|
4085
|
+
for (const line of lines) if (line.startsWith("diff --git ")) {
|
|
4086
|
+
if (current) sections.push(current.join("\n"));
|
|
4087
|
+
current = [line];
|
|
4088
|
+
} else if (current) current.push(line);
|
|
4089
|
+
if (current) sections.push(current.join("\n"));
|
|
4090
|
+
return sections;
|
|
4091
|
+
}
|
|
4092
|
+
/**
|
|
4093
|
+
* `@@ -oldStart,oldCount +newStart,newCount @@ scope`. Both counts are optional in
|
|
4094
|
+
* the format and mean 1 when absent (`@@ -1 +1 @@`). The trailing scope is git's own
|
|
4095
|
+
* guess at what you are inside.
|
|
4096
|
+
*/
|
|
4097
|
+
const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/;
|
|
4098
|
+
/**
|
|
4099
|
+
* Read one hunk body, starting at the line after its header. The declared counts
|
|
4100
|
+
* are authoritative: consume exactly that many old and new lines and stop, rather
|
|
4101
|
+
* than reading up to the next `@@` — git's own rule, and what keeps the blank line
|
|
4102
|
+
* every patch ends with out of the last hunk.
|
|
4103
|
+
*/
|
|
4104
|
+
function parseHunkBody(lines, start, oldStart, newStart, oldCount, newCount) {
|
|
4105
|
+
const out = [];
|
|
4106
|
+
let oldNo = oldStart;
|
|
4107
|
+
let newNo = newStart;
|
|
4108
|
+
let remainingOld = oldCount;
|
|
4109
|
+
let remainingNew = newCount;
|
|
4110
|
+
let i = start;
|
|
4111
|
+
while (remainingOld > 0 || remainingNew > 0) {
|
|
4112
|
+
const line = lines[i];
|
|
4113
|
+
if (line === void 0) break;
|
|
4114
|
+
if (line.startsWith("@@") || line.startsWith("diff --git ")) break;
|
|
4115
|
+
i++;
|
|
4116
|
+
if (line.startsWith("\\")) continue;
|
|
4117
|
+
if (line.startsWith("+")) {
|
|
4118
|
+
out.push({
|
|
4119
|
+
kind: "add",
|
|
4120
|
+
oldNo: null,
|
|
4121
|
+
newNo: newNo++,
|
|
4122
|
+
text: line.slice(1)
|
|
4123
|
+
});
|
|
4124
|
+
remainingNew--;
|
|
4125
|
+
} else if (line.startsWith("-")) {
|
|
4126
|
+
out.push({
|
|
4127
|
+
kind: "del",
|
|
4128
|
+
oldNo: oldNo++,
|
|
4129
|
+
newNo: null,
|
|
4130
|
+
text: line.slice(1)
|
|
4131
|
+
});
|
|
4132
|
+
remainingOld--;
|
|
4133
|
+
} else {
|
|
4134
|
+
out.push({
|
|
4135
|
+
kind: "context",
|
|
4136
|
+
oldNo: oldNo++,
|
|
4137
|
+
newNo: newNo++,
|
|
4138
|
+
text: line.slice(1)
|
|
4139
|
+
});
|
|
4140
|
+
remainingOld--;
|
|
4141
|
+
remainingNew--;
|
|
4142
|
+
}
|
|
4028
4143
|
}
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4144
|
+
return {
|
|
4145
|
+
lines: out,
|
|
4146
|
+
next: i
|
|
4147
|
+
};
|
|
4148
|
+
}
|
|
4149
|
+
function parseHunks(lines) {
|
|
4150
|
+
const hunks = [];
|
|
4151
|
+
let i = 0;
|
|
4152
|
+
while (i < lines.length) {
|
|
4153
|
+
const header = lines[i]?.match(HUNK_HEADER);
|
|
4154
|
+
if (!header) {
|
|
4155
|
+
i++;
|
|
4156
|
+
continue;
|
|
4157
|
+
}
|
|
4158
|
+
const oldStart = Number(header[1]);
|
|
4159
|
+
const newStart = Number(header[3]);
|
|
4160
|
+
const body = parseHunkBody(lines, i + 1, oldStart, newStart, header[2] === void 0 ? 1 : Number(header[2]), header[4] === void 0 ? 1 : Number(header[4]));
|
|
4161
|
+
const context = (header[5] ?? "").trim();
|
|
4162
|
+
hunks.push({
|
|
4163
|
+
oldStart,
|
|
4164
|
+
newStart,
|
|
4165
|
+
context: context === "" ? null : context,
|
|
4166
|
+
lines: body.lines
|
|
4167
|
+
});
|
|
4168
|
+
i = body.next;
|
|
4046
4169
|
}
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4170
|
+
return hunks;
|
|
4171
|
+
}
|
|
4172
|
+
/**
|
|
4173
|
+
* `--- a/x` / `+++ b/x`, read only from the part of the section before the first
|
|
4174
|
+
* `@@`. That bound is load-bearing: inside a hunk body an added line starting with
|
|
4175
|
+
* `++` is spelled `+++ …`, so scanning the whole section would read file content as
|
|
4176
|
+
* a path header.
|
|
4177
|
+
*/
|
|
4178
|
+
function parseSectionPaths(section, lines) {
|
|
4179
|
+
let from = null;
|
|
4180
|
+
let to = null;
|
|
4181
|
+
for (const line of lines) {
|
|
4182
|
+
if (line.startsWith("@@")) break;
|
|
4183
|
+
if (from === null && line.startsWith("--- ")) from = diffPath(line.slice(4));
|
|
4184
|
+
else if (to === null && line.startsWith("+++ ")) to = diffPath(line.slice(4));
|
|
4185
|
+
}
|
|
4186
|
+
return {
|
|
4187
|
+
from,
|
|
4188
|
+
to,
|
|
4189
|
+
isNew: from === "/dev/null" || /^new file mode /m.test(section),
|
|
4190
|
+
isDeleted: to === "/dev/null" || /^deleted file mode /m.test(section)
|
|
4191
|
+
};
|
|
4192
|
+
}
|
|
4193
|
+
function diffPath(raw) {
|
|
4194
|
+
const path = raw.split(" ", 1)[0] ?? raw;
|
|
4195
|
+
return path === "/dev/null" ? path : stripAB(path);
|
|
4196
|
+
}
|
|
4197
|
+
function parsePatch(patch) {
|
|
4198
|
+
const files = [];
|
|
4199
|
+
for (const section of splitSections(patch)) {
|
|
4200
|
+
const sectionLines = section.split("\n");
|
|
4201
|
+
const meta = parseSectionMeta(section);
|
|
4202
|
+
const paths = parseSectionPaths(section, sectionLines);
|
|
4203
|
+
let path;
|
|
4204
|
+
let oldPath = null;
|
|
4205
|
+
let status;
|
|
4206
|
+
if (meta.renamedTo) {
|
|
4207
|
+
path = meta.renamedTo;
|
|
4208
|
+
oldPath = meta.renamedFrom;
|
|
4209
|
+
status = "renamed";
|
|
4210
|
+
} else if (paths.isNew) {
|
|
4211
|
+
path = paths.to ?? headerPath(section);
|
|
4212
|
+
status = "added";
|
|
4213
|
+
} else if (paths.isDeleted) {
|
|
4214
|
+
path = paths.from ?? headerPath(section);
|
|
4215
|
+
status = "deleted";
|
|
4216
|
+
} else if (paths.to !== null && paths.to !== "/dev/null") {
|
|
4217
|
+
path = paths.to;
|
|
4218
|
+
status = "modified";
|
|
4219
|
+
} else {
|
|
4220
|
+
path = headerPath(section);
|
|
4221
|
+
status = "modified";
|
|
4222
|
+
}
|
|
4223
|
+
const kind = meta.symlink ? "symlink" : meta.binary ? isImagePath(path) ? "image" : "binary" : isImagePath(path) ? "image" : "text";
|
|
4224
|
+
const seen = /* @__PURE__ */ new Map();
|
|
4225
|
+
const hunks = meta.binary ? [] : parseHunks(sectionLines).map((hunk) => {
|
|
4226
|
+
const lines = hunk.lines;
|
|
4227
|
+
const contentKey = lines.filter((l) => l.kind !== "context").map((l) => `${l.kind}:${l.text}`).join("\n");
|
|
4228
|
+
const occurrence = seen.get(contentKey) ?? 0;
|
|
4229
|
+
seen.set(contentKey, occurrence + 1);
|
|
4230
|
+
return {
|
|
4231
|
+
id: hunkId(path, lines, occurrence),
|
|
4232
|
+
path,
|
|
4233
|
+
oldStart: hunk.oldStart,
|
|
4234
|
+
newStart: hunk.newStart,
|
|
4235
|
+
lines,
|
|
4236
|
+
...hunk.context === null ? {} : { context: hunk.context }
|
|
4237
|
+
};
|
|
4238
|
+
});
|
|
4239
|
+
files.push({
|
|
4240
|
+
path,
|
|
4241
|
+
oldPath,
|
|
4242
|
+
status,
|
|
4243
|
+
kind,
|
|
4244
|
+
staged: false,
|
|
4245
|
+
hunks
|
|
4246
|
+
});
|
|
4247
|
+
}
|
|
4248
|
+
return files;
|
|
4249
|
+
}
|
|
4250
|
+
function stripAB(p) {
|
|
4251
|
+
return p.startsWith("b/") || p.startsWith("a/") ? p.slice(2) : p;
|
|
4252
|
+
}
|
|
4253
|
+
function headerPath(section) {
|
|
4254
|
+
const header = section.split("\n", 1)[0] ?? "";
|
|
4255
|
+
return header.match(/^diff --git a\/(.*) b\/(.*)$/)?.[2] ?? header.replace(/^diff --git /, "");
|
|
4256
|
+
}
|
|
4257
|
+
//#endregion
|
|
4258
|
+
//#region src/server/commits.ts
|
|
4259
|
+
function stats$2(files) {
|
|
4260
|
+
let additions = 0;
|
|
4261
|
+
let deletions = 0;
|
|
4262
|
+
for (const file of files) for (const hunk of file.hunks) for (const line of hunk.lines) if (line.kind === "add") additions++;
|
|
4263
|
+
else if (line.kind === "del") deletions++;
|
|
4264
|
+
return {
|
|
4265
|
+
files: files.length,
|
|
4266
|
+
additions,
|
|
4267
|
+
deletions
|
|
4268
|
+
};
|
|
4269
|
+
}
|
|
4270
|
+
/** A commit's diff has no working-tree file to size a trailing gap from, so
|
|
4271
|
+
* `newLineCount` stays unknown and that gap is simply not offered. The other
|
|
4272
|
+
* gaps read the file at `rev`, the commit's own version. */
|
|
4273
|
+
const commitFiles = (raw, rev) => parsePatch(raw).map((file) => ({
|
|
4274
|
+
...file,
|
|
4275
|
+
staged: false,
|
|
4276
|
+
newLineCount: null,
|
|
4277
|
+
...rev ? { rev } : {}
|
|
4278
|
+
}));
|
|
4279
|
+
/**
|
|
4280
|
+
* The tab fetches every commit's diff at once, and each is a git process: a
|
|
4281
|
+
* few at a time keeps a long branch from spawning one per commit in a burst.
|
|
4282
|
+
*/
|
|
4283
|
+
const GIT_SLOTS = 4;
|
|
4284
|
+
let gitBusy = 0;
|
|
4285
|
+
const gitWaiting = [];
|
|
4286
|
+
async function withGitSlot(work) {
|
|
4287
|
+
if (gitBusy >= GIT_SLOTS) await new Promise((resolve) => gitWaiting.push(resolve));
|
|
4288
|
+
gitBusy++;
|
|
4289
|
+
try {
|
|
4290
|
+
return await work();
|
|
4291
|
+
} finally {
|
|
4292
|
+
gitBusy--;
|
|
4293
|
+
gitWaiting.shift()?.();
|
|
4294
|
+
}
|
|
4295
|
+
}
|
|
4296
|
+
/** Whether `sha` is a full sha of one of the changeset's commits: the cheap
|
|
4297
|
+
* listing answers, so a reviewer-typed ref never reaches git as one. */
|
|
4298
|
+
async function isListedCommit(root, spec, sha) {
|
|
4299
|
+
if (!/^[0-9a-f]{40}$/.test(sha)) return false;
|
|
4300
|
+
return withGitSlot(async () => (await listCommitShasAsync(root, spec)).includes(sha));
|
|
4301
|
+
}
|
|
4302
|
+
const uncommittedFiles = async (root) => commitFiles(await getFullDiffAsync(root, { kind: "working-tree" }));
|
|
4303
|
+
/**
|
|
4304
|
+
* The changeset's commits, oldest first. A branch review's diff runs from the
|
|
4305
|
+
* merge-base to the *working tree*, so edits not yet committed are part of what
|
|
4306
|
+
* is under review: they follow the commits as one more entry, when there are any.
|
|
4307
|
+
*/
|
|
4308
|
+
async function listCommits(root, spec) {
|
|
4309
|
+
const raw = await listCommitsAsync(root, spec);
|
|
4310
|
+
if (raw.length === 0) return { commits: [] };
|
|
4311
|
+
const commits = raw.map((c) => ({
|
|
4312
|
+
sha: c.sha,
|
|
4313
|
+
short: c.short,
|
|
4314
|
+
subject: c.subject,
|
|
4315
|
+
body: c.body,
|
|
4316
|
+
author: c.author,
|
|
4317
|
+
at: c.at,
|
|
4318
|
+
stats: c.stats
|
|
4319
|
+
}));
|
|
4320
|
+
const pending = await uncommittedFiles(root);
|
|
4321
|
+
if (pending.length > 0) commits.push({
|
|
4322
|
+
sha: UNCOMMITTED,
|
|
4323
|
+
short: "",
|
|
4324
|
+
subject: "Uncommitted changes",
|
|
4325
|
+
body: "",
|
|
4326
|
+
author: null,
|
|
4327
|
+
at: null,
|
|
4328
|
+
stats: stats$2(pending)
|
|
4329
|
+
});
|
|
4330
|
+
return { commits };
|
|
4331
|
+
}
|
|
4332
|
+
/**
|
|
4333
|
+
* One listed commit's own diff. The sha must be one `listCommits` returns: the
|
|
4334
|
+
* route never hands git a ref the reviewer typed, only a full sha it already
|
|
4335
|
+
* listed — checked against the cheap sha listing, not the one that diffs every
|
|
4336
|
+
* commit for its stats. Null when it is not in the changeset.
|
|
4337
|
+
*/
|
|
4338
|
+
async function getCommitChangeset(root, spec, sha) {
|
|
4339
|
+
if (sha === "uncommitted") {
|
|
4340
|
+
if (spec.kind !== "branch") return null;
|
|
4341
|
+
return {
|
|
4342
|
+
sha,
|
|
4343
|
+
files: await uncommittedFiles(root)
|
|
4344
|
+
};
|
|
4345
|
+
}
|
|
4346
|
+
if (!await isListedCommit(root, spec, sha)) return null;
|
|
4347
|
+
const raw = await withGitSlot(() => getCommitDiffAsync(root, sha));
|
|
4348
|
+
return {
|
|
4349
|
+
sha,
|
|
4350
|
+
files: commitFiles(raw, sha)
|
|
4351
|
+
};
|
|
4352
|
+
}
|
|
4353
|
+
//#endregion
|
|
4354
|
+
//#region src/server/delivery.ts
|
|
4355
|
+
const STALL_AFTER_MS = 3e5;
|
|
4356
|
+
const REPLY_GRACE_MS = 9e4;
|
|
4357
|
+
/** A re-poll within this window of a delivery reads as "hasn't started yet". */
|
|
4358
|
+
const BATCH_SETTLE_MS = 2e3;
|
|
4359
|
+
/** Signal 0 probes existence without touching the process; EPERM still proves it exists. */
|
|
4360
|
+
function pidAlive(pid) {
|
|
4361
|
+
try {
|
|
4362
|
+
process.kill(pid, 0);
|
|
4363
|
+
return true;
|
|
4364
|
+
} catch (err) {
|
|
4365
|
+
return err.code === "EPERM";
|
|
4366
|
+
}
|
|
4367
|
+
}
|
|
4368
|
+
var DeliveryQueue = class DeliveryQueue {
|
|
4369
|
+
stallAfterMs;
|
|
4370
|
+
replyGraceMs;
|
|
4371
|
+
liveness;
|
|
4372
|
+
batchSettleMs;
|
|
4373
|
+
buckets = /* @__PURE__ */ new Map();
|
|
4374
|
+
scope = "";
|
|
4375
|
+
waiter = null;
|
|
4376
|
+
awaitingReply = false;
|
|
4377
|
+
/** Thread id -> epoch ms it was delivered. */
|
|
4378
|
+
deliveredAt = /* @__PURE__ */ new Map();
|
|
4379
|
+
listeners = /* @__PURE__ */ new Set();
|
|
4380
|
+
stalled = false;
|
|
4381
|
+
stallTimer = null;
|
|
4382
|
+
betweenPolls = false;
|
|
4383
|
+
/** Why the agent is parked between polls: a reply just landed, its poll
|
|
4384
|
+
* ended on its own and the re-poll is expected, or it opened the review and
|
|
4385
|
+
* is still orienting itself before its first poll. */
|
|
4386
|
+
parked = "replied";
|
|
4387
|
+
graceTimer = null;
|
|
4388
|
+
lastDetach = null;
|
|
4389
|
+
since = Date.now();
|
|
4390
|
+
lastDetail = {
|
|
4391
|
+
state: "waiting",
|
|
4392
|
+
reason: "no-agent"
|
|
4393
|
+
};
|
|
4394
|
+
owner = null;
|
|
4395
|
+
/** The session pid that last received the full reply protocol. Later
|
|
4396
|
+
* deliveries to the same session carry the compact form — the full text is
|
|
4397
|
+
* the loop's largest recurring token cost. A takeover (new pid) or an
|
|
4398
|
+
* anonymous session (null pid) always gets the full form again. */
|
|
4399
|
+
protocolPid = null;
|
|
4400
|
+
batch = null;
|
|
4401
|
+
batchWatch = null;
|
|
4402
|
+
waiterWatch = null;
|
|
4403
|
+
batchListeners = /* @__PURE__ */ new Set();
|
|
4404
|
+
constructor(stallAfterMs = STALL_AFTER_MS, replyGraceMs = REPLY_GRACE_MS, liveness = {}, batchSettleMs = BATCH_SETTLE_MS) {
|
|
4405
|
+
this.stallAfterMs = stallAfterMs;
|
|
4406
|
+
this.replyGraceMs = replyGraceMs;
|
|
4407
|
+
this.liveness = liveness;
|
|
4408
|
+
this.batchSettleMs = batchSettleMs;
|
|
4409
|
+
}
|
|
4410
|
+
onBatchClosed(listener) {
|
|
4411
|
+
this.batchListeners.add(listener);
|
|
4412
|
+
return () => this.batchListeners.delete(listener);
|
|
4413
|
+
}
|
|
4414
|
+
closeBatch(reason) {
|
|
4415
|
+
const batch = this.batch;
|
|
4416
|
+
if (!batch) return;
|
|
4417
|
+
const closed = {
|
|
4418
|
+
threadIds: batch.threadIds,
|
|
4419
|
+
unanswered: [...this.deliveredAt.keys()],
|
|
4420
|
+
deliveredAt: batch.deliveredAt,
|
|
4421
|
+
closedAt: Date.now(),
|
|
4422
|
+
reason
|
|
4423
|
+
};
|
|
4424
|
+
this.batch = null;
|
|
4425
|
+
this.deliveredAt.clear();
|
|
4426
|
+
this.awaitingReply = false;
|
|
4427
|
+
this.stalled = false;
|
|
4428
|
+
this.clearStall();
|
|
4429
|
+
this.clearBatchWatch();
|
|
4430
|
+
for (const listener of this.batchListeners) listener(closed);
|
|
4431
|
+
}
|
|
4432
|
+
armBatchWatch() {
|
|
4433
|
+
this.clearBatchWatch();
|
|
4434
|
+
const isAlive = this.liveness.isAlive ?? pidAlive;
|
|
4435
|
+
const timer = setInterval(() => {
|
|
4051
4436
|
if (!this.batch) return this.clearBatchWatch();
|
|
4052
4437
|
const pid = this.owner;
|
|
4053
4438
|
if (pid === null || isAlive(pid)) return;
|
|
@@ -4808,6 +5193,14 @@ function githubPosition(anchor, body, files) {
|
|
|
4808
5193
|
downgraded: false
|
|
4809
5194
|
};
|
|
4810
5195
|
const file = files.find((f) => f.path === anchor.path);
|
|
5196
|
+
if (anchor.commit !== void 0 && !file?.hunks.some((h) => h.id === anchor.hunkId)) return {
|
|
5197
|
+
draft: {
|
|
5198
|
+
path: anchor.path,
|
|
5199
|
+
body,
|
|
5200
|
+
subjectType: "FILE"
|
|
5201
|
+
},
|
|
5202
|
+
downgraded: true
|
|
5203
|
+
};
|
|
4811
5204
|
const has = (n) => file?.hunks.some((h) => h.lines.some((l) => anchor.side === "old" ? l.kind !== "add" && l.oldNo === n : l.kind !== "del" && l.newNo === n)) ?? false;
|
|
4812
5205
|
const end = anchor.endLine ?? anchor.line;
|
|
4813
5206
|
if (!has(end) || !has(anchor.line)) return {
|
|
@@ -6239,13 +6632,15 @@ function parseAnchor(value) {
|
|
|
6239
6632
|
if (anchor.side !== "old" && anchor.side !== "new") return null;
|
|
6240
6633
|
if (typeof anchor.line !== "number") return null;
|
|
6241
6634
|
const endLine = typeof anchor.endLine === "number" && Number.isInteger(anchor.endLine) && anchor.endLine > anchor.line ? anchor.endLine : void 0;
|
|
6635
|
+
const commit = typeof anchor.commit === "string" && /^[0-9a-f]{40}$/.test(anchor.commit) ? anchor.commit : void 0;
|
|
6242
6636
|
return {
|
|
6243
6637
|
kind: "hunk",
|
|
6244
6638
|
hunkId: anchor.hunkId,
|
|
6245
6639
|
path: anchor.path,
|
|
6246
6640
|
side: anchor.side,
|
|
6247
6641
|
line: anchor.line,
|
|
6248
|
-
...endLine !== void 0 ? { endLine } : {}
|
|
6642
|
+
...endLine !== void 0 ? { endLine } : {},
|
|
6643
|
+
...commit !== void 0 ? { commit } : {}
|
|
6249
6644
|
};
|
|
6250
6645
|
}
|
|
6251
6646
|
if (anchor.kind === "file") return typeof anchor.path === "string" ? {
|
|
@@ -6446,215 +6841,6 @@ function normalizePr(value) {
|
|
|
6446
6841
|
};
|
|
6447
6842
|
}
|
|
6448
6843
|
//#endregion
|
|
6449
|
-
//#region src/server/parse.ts
|
|
6450
|
-
const IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
6451
|
-
".png",
|
|
6452
|
-
".jpg",
|
|
6453
|
-
".jpeg",
|
|
6454
|
-
".gif",
|
|
6455
|
-
".webp",
|
|
6456
|
-
".bmp",
|
|
6457
|
-
".svg",
|
|
6458
|
-
".ico",
|
|
6459
|
-
".avif"
|
|
6460
|
-
]);
|
|
6461
|
-
function isImagePath(path) {
|
|
6462
|
-
const dot = path.lastIndexOf(".");
|
|
6463
|
-
return dot !== -1 && IMAGE_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
6464
|
-
}
|
|
6465
|
-
/** Content-addressed hunk identity: the hash covers the file path and the changed
|
|
6466
|
-
* lines only — not line numbers, not context. Editing the hunk itself mints a new
|
|
6467
|
-
* id; the occurrence index separates identical hunks within one file. */
|
|
6468
|
-
function hunkId(path, lines, occurrence) {
|
|
6469
|
-
const changed = lines.filter((l) => l.kind !== "context").map((l) => `${l.kind === "add" ? "+" : "-"}${l.text}`).join("\n");
|
|
6470
|
-
return createHash("sha256").update(`${path}\0${changed}\0${occurrence}`).digest("hex").slice(0, 16);
|
|
6471
|
-
}
|
|
6472
|
-
function parseSectionMeta(section) {
|
|
6473
|
-
return {
|
|
6474
|
-
renamedFrom: section.match(/^rename from (.+)$/m)?.[1] ?? null,
|
|
6475
|
-
renamedTo: section.match(/^rename to (.+)$/m)?.[1] ?? null,
|
|
6476
|
-
binary: /^Binary files .* differ$/m.test(section) || /^GIT binary patch$/m.test(section),
|
|
6477
|
-
symlink: /^(?:new file|deleted file|new|old) mode 120000$/m.test(section) || /^index [0-9a-f.]+ 120000$/m.test(section)
|
|
6478
|
-
};
|
|
6479
|
-
}
|
|
6480
|
-
function splitSections(patch) {
|
|
6481
|
-
if (!patch.trim()) return [];
|
|
6482
|
-
const sections = [];
|
|
6483
|
-
const lines = patch.split("\n");
|
|
6484
|
-
let current = null;
|
|
6485
|
-
for (const line of lines) if (line.startsWith("diff --git ")) {
|
|
6486
|
-
if (current) sections.push(current.join("\n"));
|
|
6487
|
-
current = [line];
|
|
6488
|
-
} else if (current) current.push(line);
|
|
6489
|
-
if (current) sections.push(current.join("\n"));
|
|
6490
|
-
return sections;
|
|
6491
|
-
}
|
|
6492
|
-
/**
|
|
6493
|
-
* `@@ -oldStart,oldCount +newStart,newCount @@ scope`. Both counts are optional in
|
|
6494
|
-
* the format and mean 1 when absent (`@@ -1 +1 @@`). The trailing scope is git's own
|
|
6495
|
-
* guess at what you are inside.
|
|
6496
|
-
*/
|
|
6497
|
-
const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/;
|
|
6498
|
-
/**
|
|
6499
|
-
* Read one hunk body, starting at the line after its header. The declared counts
|
|
6500
|
-
* are authoritative: consume exactly that many old and new lines and stop, rather
|
|
6501
|
-
* than reading up to the next `@@` — git's own rule, and what keeps the blank line
|
|
6502
|
-
* every patch ends with out of the last hunk.
|
|
6503
|
-
*/
|
|
6504
|
-
function parseHunkBody(lines, start, oldStart, newStart, oldCount, newCount) {
|
|
6505
|
-
const out = [];
|
|
6506
|
-
let oldNo = oldStart;
|
|
6507
|
-
let newNo = newStart;
|
|
6508
|
-
let remainingOld = oldCount;
|
|
6509
|
-
let remainingNew = newCount;
|
|
6510
|
-
let i = start;
|
|
6511
|
-
while (remainingOld > 0 || remainingNew > 0) {
|
|
6512
|
-
const line = lines[i];
|
|
6513
|
-
if (line === void 0) break;
|
|
6514
|
-
if (line.startsWith("@@") || line.startsWith("diff --git ")) break;
|
|
6515
|
-
i++;
|
|
6516
|
-
if (line.startsWith("\\")) continue;
|
|
6517
|
-
if (line.startsWith("+")) {
|
|
6518
|
-
out.push({
|
|
6519
|
-
kind: "add",
|
|
6520
|
-
oldNo: null,
|
|
6521
|
-
newNo: newNo++,
|
|
6522
|
-
text: line.slice(1)
|
|
6523
|
-
});
|
|
6524
|
-
remainingNew--;
|
|
6525
|
-
} else if (line.startsWith("-")) {
|
|
6526
|
-
out.push({
|
|
6527
|
-
kind: "del",
|
|
6528
|
-
oldNo: oldNo++,
|
|
6529
|
-
newNo: null,
|
|
6530
|
-
text: line.slice(1)
|
|
6531
|
-
});
|
|
6532
|
-
remainingOld--;
|
|
6533
|
-
} else {
|
|
6534
|
-
out.push({
|
|
6535
|
-
kind: "context",
|
|
6536
|
-
oldNo: oldNo++,
|
|
6537
|
-
newNo: newNo++,
|
|
6538
|
-
text: line.slice(1)
|
|
6539
|
-
});
|
|
6540
|
-
remainingOld--;
|
|
6541
|
-
remainingNew--;
|
|
6542
|
-
}
|
|
6543
|
-
}
|
|
6544
|
-
return {
|
|
6545
|
-
lines: out,
|
|
6546
|
-
next: i
|
|
6547
|
-
};
|
|
6548
|
-
}
|
|
6549
|
-
function parseHunks(lines) {
|
|
6550
|
-
const hunks = [];
|
|
6551
|
-
let i = 0;
|
|
6552
|
-
while (i < lines.length) {
|
|
6553
|
-
const header = lines[i]?.match(HUNK_HEADER);
|
|
6554
|
-
if (!header) {
|
|
6555
|
-
i++;
|
|
6556
|
-
continue;
|
|
6557
|
-
}
|
|
6558
|
-
const oldStart = Number(header[1]);
|
|
6559
|
-
const newStart = Number(header[3]);
|
|
6560
|
-
const body = parseHunkBody(lines, i + 1, oldStart, newStart, header[2] === void 0 ? 1 : Number(header[2]), header[4] === void 0 ? 1 : Number(header[4]));
|
|
6561
|
-
const context = (header[5] ?? "").trim();
|
|
6562
|
-
hunks.push({
|
|
6563
|
-
oldStart,
|
|
6564
|
-
newStart,
|
|
6565
|
-
context: context === "" ? null : context,
|
|
6566
|
-
lines: body.lines
|
|
6567
|
-
});
|
|
6568
|
-
i = body.next;
|
|
6569
|
-
}
|
|
6570
|
-
return hunks;
|
|
6571
|
-
}
|
|
6572
|
-
/**
|
|
6573
|
-
* `--- a/x` / `+++ b/x`, read only from the part of the section before the first
|
|
6574
|
-
* `@@`. That bound is load-bearing: inside a hunk body an added line starting with
|
|
6575
|
-
* `++` is spelled `+++ …`, so scanning the whole section would read file content as
|
|
6576
|
-
* a path header.
|
|
6577
|
-
*/
|
|
6578
|
-
function parseSectionPaths(section, lines) {
|
|
6579
|
-
let from = null;
|
|
6580
|
-
let to = null;
|
|
6581
|
-
for (const line of lines) {
|
|
6582
|
-
if (line.startsWith("@@")) break;
|
|
6583
|
-
if (from === null && line.startsWith("--- ")) from = diffPath(line.slice(4));
|
|
6584
|
-
else if (to === null && line.startsWith("+++ ")) to = diffPath(line.slice(4));
|
|
6585
|
-
}
|
|
6586
|
-
return {
|
|
6587
|
-
from,
|
|
6588
|
-
to,
|
|
6589
|
-
isNew: from === "/dev/null" || /^new file mode /m.test(section),
|
|
6590
|
-
isDeleted: to === "/dev/null" || /^deleted file mode /m.test(section)
|
|
6591
|
-
};
|
|
6592
|
-
}
|
|
6593
|
-
function diffPath(raw) {
|
|
6594
|
-
const path = raw.split(" ", 1)[0] ?? raw;
|
|
6595
|
-
return path === "/dev/null" ? path : stripAB(path);
|
|
6596
|
-
}
|
|
6597
|
-
function parsePatch(patch) {
|
|
6598
|
-
const files = [];
|
|
6599
|
-
for (const section of splitSections(patch)) {
|
|
6600
|
-
const sectionLines = section.split("\n");
|
|
6601
|
-
const meta = parseSectionMeta(section);
|
|
6602
|
-
const paths = parseSectionPaths(section, sectionLines);
|
|
6603
|
-
let path;
|
|
6604
|
-
let oldPath = null;
|
|
6605
|
-
let status;
|
|
6606
|
-
if (meta.renamedTo) {
|
|
6607
|
-
path = meta.renamedTo;
|
|
6608
|
-
oldPath = meta.renamedFrom;
|
|
6609
|
-
status = "renamed";
|
|
6610
|
-
} else if (paths.isNew) {
|
|
6611
|
-
path = paths.to ?? headerPath(section);
|
|
6612
|
-
status = "added";
|
|
6613
|
-
} else if (paths.isDeleted) {
|
|
6614
|
-
path = paths.from ?? headerPath(section);
|
|
6615
|
-
status = "deleted";
|
|
6616
|
-
} else if (paths.to !== null && paths.to !== "/dev/null") {
|
|
6617
|
-
path = paths.to;
|
|
6618
|
-
status = "modified";
|
|
6619
|
-
} else {
|
|
6620
|
-
path = headerPath(section);
|
|
6621
|
-
status = "modified";
|
|
6622
|
-
}
|
|
6623
|
-
const kind = meta.symlink ? "symlink" : meta.binary ? isImagePath(path) ? "image" : "binary" : isImagePath(path) ? "image" : "text";
|
|
6624
|
-
const seen = /* @__PURE__ */ new Map();
|
|
6625
|
-
const hunks = meta.binary ? [] : parseHunks(sectionLines).map((hunk) => {
|
|
6626
|
-
const lines = hunk.lines;
|
|
6627
|
-
const contentKey = lines.filter((l) => l.kind !== "context").map((l) => `${l.kind}:${l.text}`).join("\n");
|
|
6628
|
-
const occurrence = seen.get(contentKey) ?? 0;
|
|
6629
|
-
seen.set(contentKey, occurrence + 1);
|
|
6630
|
-
return {
|
|
6631
|
-
id: hunkId(path, lines, occurrence),
|
|
6632
|
-
path,
|
|
6633
|
-
oldStart: hunk.oldStart,
|
|
6634
|
-
newStart: hunk.newStart,
|
|
6635
|
-
lines,
|
|
6636
|
-
...hunk.context === null ? {} : { context: hunk.context }
|
|
6637
|
-
};
|
|
6638
|
-
});
|
|
6639
|
-
files.push({
|
|
6640
|
-
path,
|
|
6641
|
-
oldPath,
|
|
6642
|
-
status,
|
|
6643
|
-
kind,
|
|
6644
|
-
staged: false,
|
|
6645
|
-
hunks
|
|
6646
|
-
});
|
|
6647
|
-
}
|
|
6648
|
-
return files;
|
|
6649
|
-
}
|
|
6650
|
-
function stripAB(p) {
|
|
6651
|
-
return p.startsWith("b/") || p.startsWith("a/") ? p.slice(2) : p;
|
|
6652
|
-
}
|
|
6653
|
-
function headerPath(section) {
|
|
6654
|
-
const header = section.split("\n", 1)[0] ?? "";
|
|
6655
|
-
return header.match(/^diff --git a\/(.*) b\/(.*)$/)?.[2] ?? header.replace(/^diff --git /, "");
|
|
6656
|
-
}
|
|
6657
|
-
//#endregion
|
|
6658
6844
|
//#region src/server/changeset.ts
|
|
6659
6845
|
function stats$1(files) {
|
|
6660
6846
|
let additions = 0;
|
|
@@ -7365,8 +7551,17 @@ function createApp(ctx, store, review, queue) {
|
|
|
7365
7551
|
edited: message.prComment.text.trim() !== text
|
|
7366
7552
|
};
|
|
7367
7553
|
}
|
|
7368
|
-
const
|
|
7369
|
-
const
|
|
7554
|
+
const commitFiles = store && anchor.kind === "hunk" && typeof body?.commit === "string" ? (await getCommitChangeset(ctx.root, ctx.spec, body.commit).catch(() => null))?.files ?? null : null;
|
|
7555
|
+
const placed = commitFiles && anchor.kind === "hunk" ? findHunk$1(store.get().files, anchor.hunkId) ? translateHunkAnchor(anchor, commitFiles, store.get().files) : {
|
|
7556
|
+
...anchor,
|
|
7557
|
+
commit: body.commit
|
|
7558
|
+
} : anchor;
|
|
7559
|
+
let capture = store ? captureAnchor(store.get(), placed, review.get().layers) : null;
|
|
7560
|
+
if (store && !capture && commitFiles) capture = captureAnchor({
|
|
7561
|
+
...store.get(),
|
|
7562
|
+
files: commitFiles
|
|
7563
|
+
}, anchor);
|
|
7564
|
+
const created = review.createThread(placed, text, capture, intent, "reviewer", {
|
|
7370
7565
|
...audience ? { audience } : {},
|
|
7371
7566
|
...parentId ? { parentId } : {},
|
|
7372
7567
|
...origin ? { origin } : {}
|
|
@@ -7879,13 +8074,35 @@ function createApp(ctx, store, review, queue) {
|
|
|
7879
8074
|
throw err;
|
|
7880
8075
|
}
|
|
7881
8076
|
});
|
|
7882
|
-
app.get("/api/
|
|
8077
|
+
app.get("/api/commits", async (c) => {
|
|
8078
|
+
try {
|
|
8079
|
+
return c.json(await listCommits(ctx.root, ctx.spec));
|
|
8080
|
+
} catch (err) {
|
|
8081
|
+
if (err instanceof MissingBaseError) return c.json({ error: err.message }, 400);
|
|
8082
|
+
throw err;
|
|
8083
|
+
}
|
|
8084
|
+
});
|
|
8085
|
+
app.get("/api/commits/:sha", async (c) => {
|
|
8086
|
+
try {
|
|
8087
|
+
const commit = await getCommitChangeset(ctx.root, ctx.spec, c.req.param("sha"));
|
|
8088
|
+
return commit ? c.json(commit) : c.json({ error: "no such commit in this changeset" }, 404);
|
|
8089
|
+
} catch (err) {
|
|
8090
|
+
if (err instanceof MissingBaseError) return c.json({ error: err.message }, 400);
|
|
8091
|
+
throw err;
|
|
8092
|
+
}
|
|
8093
|
+
});
|
|
8094
|
+
app.get("/api/file", async (c) => {
|
|
7883
8095
|
const path = c.req.query("path");
|
|
7884
8096
|
const side = c.req.query("side");
|
|
8097
|
+
const rev = c.req.query("rev");
|
|
7885
8098
|
if (!path || side !== "base" && side !== "head") return c.json({ error: "need ?path= and ?side=base|head" }, 400);
|
|
7886
8099
|
let bytes;
|
|
7887
8100
|
try {
|
|
7888
|
-
|
|
8101
|
+
if (rev !== void 0) {
|
|
8102
|
+
if (side !== "head") return c.json({ error: "?rev= reads the head side only" }, 400);
|
|
8103
|
+
if (!await isListedCommit(ctx.root, ctx.spec, rev)) return c.json({ error: "no such commit in this changeset" }, 404);
|
|
8104
|
+
bytes = getBaseFileBytes(ctx.root, rev, path);
|
|
8105
|
+
} else bytes = side === "head" ? getHeadFileBytes(ctx.root, path) : getBaseFileBytes(ctx.root, resolveBaseRef(ctx.root, ctx.spec), path);
|
|
7889
8106
|
} catch (err) {
|
|
7890
8107
|
if (err instanceof MissingBaseError) return c.json({ error: err.message }, 400);
|
|
7891
8108
|
throw err;
|
|
@@ -9198,11 +9415,11 @@ async function printAgentNextStep(port, pr) {
|
|
|
9198
9415
|
const review = await fetchReviewState(port);
|
|
9199
9416
|
const D = doctrineFor(pr ? { pr } : null);
|
|
9200
9417
|
if (pr) console.log(`note: ${prOpenNote(repoPath, pr)}`);
|
|
9418
|
+
console.log(pr ? `next: run \`${CLI_COMMANDS.poll}\` ${POLL_NOW}. Hold it as ${POLL_STANCE}; ${POLL_NOW_FOREGROUND}. It receives the reviewer's private questions; the pull request already names their tab (\`diffo help agent\` prints the whole loop)` : `next: run \`${CLI_COMMANDS.firstPoll}\` ${POLL_NOW}. Hold it as ${POLL_STANCE}; ${POLL_NOW_FOREGROUND}. It receives the reviewer's feedback; the title is ${TAB_TITLE.what} (${TAB_TITLE.examples}): ${TAB_TITLE.why} (\`diffo help agent\` prints the whole loop)`);
|
|
9201
9419
|
const nudge = review ? guideNudge(findGuideThread(review) !== void 0, D) : null;
|
|
9202
|
-
if (nudge) console.log(`
|
|
9420
|
+
if (nudge) console.log(`then: ${nudge}`);
|
|
9203
9421
|
const layers = review ? layersNudge(review, D) : null;
|
|
9204
9422
|
if (layers) console.log(`also: ${layers}`);
|
|
9205
|
-
console.log(pr ? `next: run \`${CLI_COMMANDS.poll}\` to receive the reviewer's private questions; the pull request already names their tab (\`diffo help agent\` prints the whole loop)` : `next: run \`${CLI_COMMANDS.firstPoll}\` to receive the reviewer's feedback; the title is ${TAB_TITLE.what} (${TAB_TITLE.examples}): ${TAB_TITLE.why} (\`diffo help agent\` prints the whole loop)`);
|
|
9206
9423
|
}
|
|
9207
9424
|
/**
|
|
9208
9425
|
* Dev builds serve the client from the checkout's `dist/client`, which nothing
|