@diffohq/diffo 0.17.2 → 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.
Files changed (106) hide show
  1. package/dist/cli.mjs +566 -363
  2. package/dist/client/assets/{abnfDiagram-YUKMZFIV-DCCl7rag.js → abnfDiagram-WEYOIA3C-B6Y8XL3T.js} +1 -1
  3. package/dist/client/assets/{arc-CTNHvfmK.js → arc-BMKLX6de.js} +1 -1
  4. package/dist/client/assets/{architecture-WOLXFQ4H-DedTjxXh.js → architecture-WOLXFQ4H-GCPjgG_6.js} +1 -1
  5. package/dist/client/assets/{architectureDiagram-47P4ROYG-lUM1bbDm.js → architectureDiagram-ZHVYKT53-D0haekvL.js} +1 -1
  6. package/dist/client/assets/{blockDiagram-E7TT5TSB-CcPMgsS5.js → blockDiagram-RJAE7QA3-9ozRFq3h.js} +1 -1
  7. package/dist/client/assets/{c4Diagram-CRA5TL53-bjY81K90.js → c4Diagram-XDPPPE54-3atKbk2-.js} +1 -1
  8. package/dist/client/assets/channel-CY0_tHjG.js +1 -0
  9. package/dist/client/assets/{chunk-AW2ZBBNX-DQVomlA0.js → chunk-2E7WHVMD-B-hVLdxW.js} +1 -1
  10. package/dist/client/assets/{chunk-HTAEGDNF-COsHxO6d.js → chunk-4EMNKR2T-9R_Ywqe-.js} +1 -1
  11. package/dist/client/assets/{chunk-7TKQ45FW-COALLfSo.js → chunk-55XCU76K-CRfSz7WS.js} +1 -1
  12. package/dist/client/assets/{chunk-3YJQHVM4-DyXpsW73.js → chunk-7S6XSTFO-jZvVFXd4.js} +1 -1
  13. package/dist/client/assets/{chunk-E2ZNV5FY-sBUMLz3a.js → chunk-AQDTSKZB-DM1KPnfu.js} +1 -1
  14. package/dist/client/assets/{chunk-VPRB5NB3-OEYWGbyv.js → chunk-DSNLSHJZ-D-0UEL_k.js} +2 -2
  15. package/dist/client/assets/chunk-HAGFOTED-C_UwN40v.js +62 -0
  16. package/dist/client/assets/chunk-HNE4Q36P-Ng5JYsLi.js +1 -0
  17. package/dist/client/assets/{chunk-EU5HNXII-DynXz8Hb.js → chunk-NQOKRVIF-BGCe7Zmm.js} +1 -1
  18. package/dist/client/assets/{chunk-4EA7E6EY-DXcHzA4c.js → chunk-UHW7PFBP-Hz5urMmR.js} +1 -1
  19. package/dist/client/assets/{chunk-KQW6MTUR-D6ZD4OBU.js → chunk-VQ5W62QI-CvIK79s8.js} +1 -1
  20. package/dist/client/assets/chunk-WQQL4CDK-DP0cdd_-.js +10 -0
  21. package/dist/client/assets/{chunk-XXDRQBXY-CwVoOjeq.js → chunk-XXDRQBXY-dz3bjW-Z.js} +1 -1
  22. package/dist/client/assets/{chunk-HJ2JQQFS-BPuBOIfd.js → chunk-YZO67HFG-BGaQ4Np3.js} +1 -1
  23. package/dist/client/assets/{chunk-DBDB3WZW-BGe0utmm.js → chunk-ZAUX2LTX-CaHpxHrZ.js} +1 -1
  24. package/dist/client/assets/{chunk-FVRAUYC3-B6UxZYLr.js → chunk-ZCHMQSOR-BQAh24-J.js} +1 -1
  25. package/dist/client/assets/{classDiagram-v2-K4WV4PDN-Cwjs5Fo7.js → classDiagram-v2-4MYWR3UA-C1uGZilp.js} +1 -1
  26. package/dist/client/assets/{core-DON0baQC.js → core-DmmPKspM.js} +1 -1
  27. package/dist/client/assets/{cose-bilkent-JH36ORCC-CanIWrad.js → cose-bilkent-JH36ORCC-CA0uoIGo.js} +1 -1
  28. package/dist/client/assets/{cynefin-EF2NZ3EQ-BcMV4FxN.js → cynefin-EF2NZ3EQ-D7-HiRmU.js} +1 -1
  29. package/dist/client/assets/{cynefinDiagram-3GCD6N5R-COPLrt2e.js → cynefinDiagram-WF24UDRP-Ct_qs0cP.js} +1 -1
  30. package/dist/client/assets/cytoscape.esm-BegBNzOV.js +321 -0
  31. package/dist/client/assets/{dagre-W4DXFKR2-DcO2bm-4.js → dagre-NK4GIZVX-Cffqg6NJ.js} +1 -1
  32. package/dist/client/assets/{diagram-OVF4WLC6-0c5jMwqP.js → diagram-CBK3MWTC-ByBNedZy.js} +1 -1
  33. package/dist/client/assets/{diagram-UMYRVEAY-_k3WfcjG.js → diagram-I5XIIV2D-ByMRPZMW.js} +1 -1
  34. package/dist/client/assets/{diagram-PFPMY2P6-DZEfp0lQ.js → diagram-ODIL2UCW-C0OayomU.js} +1 -1
  35. package/dist/client/assets/{diagram-ZIFT7M5P-CpFbAbYg.js → diagram-PFBEJUXE-BezcNJg0.js} +1 -1
  36. package/dist/client/assets/{diagram-YEKJPTXX-D0oEcVM1.js → diagram-RJA3I4VV-Bl3cl2pD.js} +1 -1
  37. package/dist/client/assets/{diagram-2UJZ2QOL-D9Ko2dkJ.js → diagram-TYUNFSNR-CyGZb5mO.js} +1 -1
  38. package/dist/client/assets/{dist-BDtfWmIE.js → dist-Uc27UV0T.js} +1 -1
  39. package/dist/client/assets/{dist-CnRLr4w2.js → dist-YWSBp2hZ.js} +1 -1
  40. package/dist/client/assets/{ebnfDiagram-VR2GEFS7-BbG7QvtS.js → ebnfDiagram-V4P3TTPT-11k4sRCB.js} +1 -1
  41. package/dist/client/assets/elk-QONTBCTQ-DM4AV9FF.js +27 -0
  42. package/dist/client/assets/{engine-oniguruma-5di9SuAb.js → engine-oniguruma-MxRvz1no.js} +1 -1
  43. package/dist/client/assets/{erDiagram-O2IAPWRE-BZ1E6x8g.js → erDiagram-KYQIGQAA-D38TGssZ.js} +1 -1
  44. package/dist/client/assets/{eventmodeling-K75KTNOO-6jgX-7pG.js → eventmodeling-K75KTNOO-DF0H3qdx.js} +1 -1
  45. package/dist/client/assets/flowDiagram-IUGLLWBU-rSziK2Bd.js +1 -0
  46. package/dist/client/assets/{ganttDiagram-R7TSEDQI-BRYFh2jn.js → ganttDiagram-R7GPNTJY-DXtKy5K6.js} +1 -1
  47. package/dist/client/assets/{gitGraph-VSP46ZUC-DxTvmQVO.js → gitGraph-VSP46ZUC-BJmPpwux.js} +1 -1
  48. package/dist/client/assets/{gitGraphDiagram-XJZIOB7I-DkJ3V-lX.js → gitGraphDiagram-PDYL7XHY-FwrK2brd.js} +1 -1
  49. package/dist/client/assets/gn-CAXYAgV-.js +1 -0
  50. package/dist/client/assets/index-Caehfsn0.css +1 -0
  51. package/dist/client/assets/index-_VrQDc2L.js +91 -0
  52. package/dist/client/assets/{info-OHQRW6UA-BKHS23vw.js → info-OHQRW6UA-W1kRy4TV.js} +1 -1
  53. package/dist/client/assets/{infoDiagram-5W2HQ5XZ-BVCz6PFV.js → infoDiagram-MVDRKAX5-giO2ZEe8.js} +1 -1
  54. package/dist/client/assets/{ishikawaDiagram-K3B6WC7H-B4tLpzvT.js → ishikawaDiagram-HPEHV2GE-DTOlB5LG.js} +1 -1
  55. package/dist/client/assets/{journeyDiagram-COXZFDF6-DpkzSOyg.js → journeyDiagram-BOSHW7O7-DqENuBSc.js} +1 -1
  56. package/dist/client/assets/{kanban-definition-P3RFRI5V-BkfcUIAv.js → kanban-definition-FNSA4HC5-CIYIECg_.js} +1 -1
  57. package/dist/client/assets/katex-B_JcIwqj.js +257 -0
  58. package/dist/client/assets/{line-CUU9NBUe.js → line-D_x4AjTF.js} +1 -1
  59. package/dist/client/assets/{linear-Cp1XQoq1.js → linear-CS4Fq7f5.js} +1 -1
  60. package/dist/client/assets/{mermaid-parser.core-Dk_N9wHc.js → mermaid-parser.core-720dQRcD.js} +3 -3
  61. package/dist/client/assets/{mermaid.core-DFKfv8Jn.js → mermaid.core-DNKhW2d1.js} +5 -5
  62. package/dist/client/assets/{mindmap-definition-LZFPQGKD-Dgw3CRjk.js → mindmap-definition-6OY57UG3-Tyz7I7bd.js} +11 -9
  63. package/dist/client/assets/objective-c-D1A_Heim.js +1 -0
  64. package/dist/client/assets/objective-cpp-BsSzOQcm.js +1 -0
  65. package/dist/client/assets/{packet-JDAUHWVQ-2Mzusa3F.js → packet-JDAUHWVQ-CIT4RofU.js} +1 -1
  66. package/dist/client/assets/{pegDiagram-BGZESJAR-DXCIRLqW.js → pegDiagram-ECIUJNBX-BDv64Oqu.js} +1 -1
  67. package/dist/client/assets/{pie-XZMESJXO-UECjOJM3.js → pie-XZMESJXO-B7TeMgE-.js} +1 -1
  68. package/dist/client/assets/{pieDiagram-CAPJLFHJ-DnDCb_c1.js → pieDiagram-GNMPY74T-DZ5ISynf.js} +1 -1
  69. package/dist/client/assets/proto-DB4EqR-F.js +1 -0
  70. package/dist/client/assets/{quadrantDiagram-GDMTTRAM-DRYlGsWP.js → quadrantDiagram-D57K4MC3-q3ZFhuD4.js} +1 -1
  71. package/dist/client/assets/{radar-ABXABTNO-D38H1WiB.js → radar-ABXABTNO-DfOTCpQ-.js} +1 -1
  72. package/dist/client/assets/{railroad-I3PHUGI6-Cdf9S4Mf.js → railroad-I3PHUGI6-DtLPMTjt.js} +1 -1
  73. package/dist/client/assets/{railroad-abnf-LNEFI6M7-Y5BNc5fo.js → railroad-abnf-LNEFI6M7-DPObEtfh.js} +1 -1
  74. package/dist/client/assets/{railroad-ebnf-DWYD2UWJ-C2o0Hwuh.js → railroad-ebnf-DWYD2UWJ-g6Y97jri.js} +1 -1
  75. package/dist/client/assets/{railroad-peg-JR7BVNR7-ByFwOhZy.js → railroad-peg-JR7BVNR7-CfUBWVcL.js} +1 -1
  76. package/dist/client/assets/{railroadDiagram-SM67HX2B-md_7ESU-.js → railroadDiagram-Y72NRBL7-CKGjfL5n.js} +1 -1
  77. package/dist/client/assets/{requirementDiagram-X7JNWC4A-DV9e6p3j.js → requirementDiagram-EQYSW5RA-kfSYKuPQ.js} +1 -1
  78. package/dist/client/assets/{sankeyDiagram-UM26HJRW-CeZSnliY.js → sankeyDiagram-SY24JHO5-Dn5ou0D7.js} +1 -1
  79. package/dist/client/assets/{sequenceDiagram-ZO4K6R2Y-2AFkjcMu.js → sequenceDiagram-BLEHNHQU-B6Qcw9U_.js} +1 -1
  80. package/dist/client/assets/{src-WAdTNUSJ.js → src-Bz4qgR7m.js} +1 -1
  81. package/dist/client/assets/{stateDiagram-v2-TBUQTH76-CHxwC_Ic.js → stateDiagram-v2-A6IESKWZ-B56x9sAq.js} +1 -1
  82. package/dist/client/assets/swimlanes-B3ENRKFQ-BCQJqf0x.js +1 -0
  83. package/dist/client/assets/swimlanesDiagram-AYQRV4AK-C1YHgM4T.js +8 -0
  84. package/dist/client/assets/{timeline-definition-YOQAKGHF-BUIhWEtW.js → timeline-definition-JXYKGLGB-lEItkXBD.js} +1 -1
  85. package/dist/client/assets/{treeView-D4JQ5SDB-xtykCGnU.js → treeView-D4JQ5SDB-CYZkbtcm.js} +1 -1
  86. package/dist/client/assets/{treemap-SAJKECNS-C51gkxvB.js → treemap-SAJKECNS-B96tHxAb.js} +1 -1
  87. package/dist/client/assets/{usecaseDiagram-VIAY4XPW-DG6OBLoV.js → usecaseDiagram-WPOLBNNX-CvTDEhna.js} +1 -1
  88. package/dist/client/assets/{vennDiagram-BLWOH2XV-BHp7FuOy.js → vennDiagram-Q3Y3IDCW-C2Ix7bQn.js} +1 -1
  89. package/dist/client/assets/{wardley-7MLQ67FV-D9GUzXWu.js → wardley-7MLQ67FV-EAIVeoto.js} +1 -1
  90. package/dist/client/assets/{wardleyDiagram-YMQ3BMBF-G-sqVoSl.js → wardleyDiagram-IVAIDKWA--Okbz3eY.js} +1 -1
  91. package/dist/client/assets/{xychartDiagram-TAQBALBS-BURBvnuK.js → xychartDiagram-4URNHDU4-CpPxhDeV.js} +1 -1
  92. package/dist/client/index.html +2 -2
  93. package/package.json +1 -1
  94. package/plugin.json +1 -1
  95. package/dist/client/assets/channel-pdhhFqps.js +0 -1
  96. package/dist/client/assets/chunk-2BW5OAIV-o5RcgrVm.js +0 -10
  97. package/dist/client/assets/chunk-NTY3LDVX-BBhs2zfd.js +0 -1
  98. package/dist/client/assets/chunk-XC4XBNZT-BHfIsy-X.js +0 -62
  99. package/dist/client/assets/cytoscape.esm-CHBj4VkZ.js +0 -321
  100. package/dist/client/assets/elk-IJKZMXRS-D9Wm7A4L.js +0 -27
  101. package/dist/client/assets/flowDiagram-OXPTDLAJ-DTCqNqx8.js +0 -1
  102. package/dist/client/assets/index-BSqcB6X4.js +0 -93
  103. package/dist/client/assets/index-ClMx9yVg.css +0 -1
  104. package/dist/client/assets/katex-DJhrJSbw.js +0 -257
  105. package/dist/client/assets/swimlanes-N4OXWK64-Cqxh_G4l.js +0 -1
  106. package/dist/client/assets/swimlanesDiagram-K3J5GTZL-CBFCal5k.js +0 -8
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
- for (const file of changeset.files) {
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
@@ -1377,74 +1426,6 @@ function parseLayersInput(raw) {
1377
1426
  items
1378
1427
  };
1379
1428
  }
1380
- //#endregion
1381
- //#region src/cliArgs.ts
1382
- const HELP_TEXT = `diffo: review a changeset the way you'd read a book
1383
-
1384
- Usage: diffo [options] open (or resume) the review for this repo
1385
- diffo <target> [options] review against a branch, or review a pull request
1386
- diffo <command> [...] manage the server, or talk to the review
1387
- diffo help [command] show help for a command
1388
-
1389
- The target:
1390
- <branch> everything since forking from that branch (same as --base)
1391
- <PR URL> a GitHub pull request (also owner/repo#N, #N, or N)
1392
- (the short forms resolve against this repo's origin)
1393
-
1394
- For the reviewer:
1395
- pr <target> Review a pull request (the same as \`diffo <target>\`,
1396
- and the explicit answer for a pull request of your own;
1397
- help pr explains the flow)
1398
- status Show this repo's review server, if one is running
1399
- (--json for a machine-readable answer)
1400
- stop Stop this repo's review server
1401
- clean Remove the worktrees diffo made for pull requests whose
1402
- review is over (--force takes dirty ones, and ones a
1403
- running server still serves; --all takes every one)
1404
- setup Register diffo with the coding agents on this machine
1405
- (Claude Code, Cursor, VS Code, Copilot, and the shared
1406
- ~/.agents skills dir read by Codex, Gemini, Amp, Goose, …)
1407
- telemetry Show whether anonymous usage data is on, or turn it
1408
- off / on (telemetry off · telemetry on)
1409
-
1410
- For the agent (the AI that wrote the change):
1411
- poll Wait for the reviewer's feedback (blocking long-poll;
1412
- prints one JSON payload; safe to re-run any time)
1413
- (--title "<what the change is>" on the first poll names
1414
- the reviewer's browser tab)
1415
- reply <threadId> Post a reply to a review thread
1416
- (--message "<text>", or pipe the text on stdin)
1417
- comment [<file>] Start a comment thread as the agent: on a line (--line),
1418
- a file, or (with no file) the whole changeset; the
1419
- reviewer replies to take it up, or resolves it
1420
- layers Outline the changeset as steps to read in order
1421
- (--suggest ["why"] flags it at open; --json '<Layer[]>'
1422
- or --stdin posts the list, replacing the last one)
1423
- end Detach from the review politely
1424
- help agent The agent's whole protocol on one page
1425
- help guide The guide comment — the map of the change — with an example
1426
-
1427
- Options (for opening the review):
1428
- --base <branch> Review everything since forking from <branch>
1429
- (default: working tree vs HEAD)
1430
- -p, --port <port> Port to serve on (default: the port this repo last used,
1431
- else the first free one)
1432
- --no-open Don't open the browser automatically
1433
- --foreground Run the server in this terminal instead of leaving a
1434
- background one behind (Ctrl-C stops it)
1435
- -v, --version Show version
1436
- -h, --help Show this help
1437
-
1438
- Examples:
1439
- diffo open the review for this repo in the browser
1440
- diffo main review everything since forking from main
1441
- diffo https://github.com/o/r/pull/482
1442
- review pull request #482 with your agent as copilot
1443
- diffo status is a server running here, and where?
1444
- diffo reply t-3 -m "fixed" answer a review thread (agent side)
1445
-
1446
- Feedback lives in the review, not in any process: diffo, poll, status, stop,
1447
- and setup are all safe to re-run after an interruption.`;
1448
1429
  const VERB_HELP = {
1449
1430
  agent: `diffo help agent: the agent's whole protocol on one page
1450
1431
 
@@ -1796,7 +1777,7 @@ Example:
1796
1777
  diffo telemetry off`
1797
1778
  };
1798
1779
  function helpFor(topic) {
1799
- return topic && VERB_HELP[topic] || HELP_TEXT;
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.";
1800
1781
  }
1801
1782
  const VERBS = /* @__PURE__ */ new Set([
1802
1783
  "poll",
@@ -3390,6 +3371,86 @@ async function getFullDiffAsync(root, spec) {
3390
3371
  const [raw, untracked] = await Promise.all([getRawDiffAsync(root, spec), getUntrackedDiffAsync(root)]);
3391
3372
  return [raw, untracked].filter(Boolean).join("\n");
3392
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
+ }
3393
3454
  function isSafeRepoPath(root, relPath) {
3394
3455
  return resolve(root, relPath).startsWith(resolve(root) + sep);
3395
3456
  }
@@ -3980,86 +4041,396 @@ const VERSION = (() => {
3980
4041
  return "0.0.0";
3981
4042
  })();
3982
4043
  //#endregion
3983
- //#region src/server/delivery.ts
3984
- const STALL_AFTER_MS = 3e5;
3985
- const REPLY_GRACE_MS = 9e4;
3986
- /** A re-poll within this window of a delivery reads as "hasn't started yet". */
3987
- const BATCH_SETTLE_MS = 2e3;
3988
- /** Signal 0 probes existence without touching the process; EPERM still proves it exists. */
3989
- function pidAlive(pid) {
3990
- try {
3991
- process.kill(pid, 0);
3992
- return true;
3993
- } catch (err) {
3994
- return err.code === "EPERM";
3995
- }
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());
3996
4064
  }
3997
- var DeliveryQueue = class DeliveryQueue {
3998
- stallAfterMs;
3999
- replyGraceMs;
4000
- liveness;
4001
- batchSettleMs;
4002
- buckets = /* @__PURE__ */ new Map();
4003
- scope = "";
4004
- waiter = null;
4005
- awaitingReply = false;
4006
- /** Thread id -> epoch ms it was delivered. */
4007
- deliveredAt = /* @__PURE__ */ new Map();
4008
- listeners = /* @__PURE__ */ new Set();
4009
- stalled = false;
4010
- stallTimer = null;
4011
- betweenPolls = false;
4012
- /** Why the agent is parked between polls: a reply just landed, its poll
4013
- * ended on its own and the re-poll is expected, or it opened the review and
4014
- * is still orienting itself before its first poll. */
4015
- parked = "replied";
4016
- graceTimer = null;
4017
- lastDetach = null;
4018
- since = Date.now();
4019
- lastDetail = {
4020
- state: "waiting",
4021
- 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)
4022
4078
  };
4023
- owner = null;
4024
- /** The session pid that last received the full reply protocol. Later
4025
- * deliveries to the same session carry the compact form — the full text is
4026
- * the loop's largest recurring token cost. A takeover (new pid) or an
4027
- * anonymous session (null pid) always gets the full form again. */
4028
- protocolPid = null;
4029
- batch = null;
4030
- batchWatch = null;
4031
- waiterWatch = null;
4032
- batchListeners = /* @__PURE__ */ new Set();
4033
- constructor(stallAfterMs = STALL_AFTER_MS, replyGraceMs = REPLY_GRACE_MS, liveness = {}, batchSettleMs = BATCH_SETTLE_MS) {
4034
- this.stallAfterMs = stallAfterMs;
4035
- this.replyGraceMs = replyGraceMs;
4036
- this.liveness = liveness;
4037
- this.batchSettleMs = batchSettleMs;
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
+ }
4038
4143
  }
4039
- onBatchClosed(listener) {
4040
- this.batchListeners.add(listener);
4041
- return () => this.batchListeners.delete(listener);
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;
4042
4169
  }
4043
- closeBatch(reason) {
4044
- const batch = this.batch;
4045
- if (!batch) return;
4046
- const closed = {
4047
- threadIds: batch.threadIds,
4048
- unanswered: [...this.deliveredAt.keys()],
4049
- deliveredAt: batch.deliveredAt,
4050
- closedAt: Date.now(),
4051
- reason
4052
- };
4053
- this.batch = null;
4054
- this.deliveredAt.clear();
4055
- this.awaitingReply = false;
4056
- this.stalled = false;
4057
- this.clearStall();
4058
- this.clearBatchWatch();
4059
- for (const listener of this.batchListeners) listener(closed);
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));
4060
4185
  }
4061
- armBatchWatch() {
4062
- this.clearBatchWatch();
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();
4063
4434
  const isAlive = this.liveness.isAlive ?? pidAlive;
4064
4435
  const timer = setInterval(() => {
4065
4436
  if (!this.batch) return this.clearBatchWatch();
@@ -4822,6 +5193,14 @@ function githubPosition(anchor, body, files) {
4822
5193
  downgraded: false
4823
5194
  };
4824
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
+ };
4825
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;
4826
5205
  const end = anchor.endLine ?? anchor.line;
4827
5206
  if (!has(end) || !has(anchor.line)) return {
@@ -6253,13 +6632,15 @@ function parseAnchor(value) {
6253
6632
  if (anchor.side !== "old" && anchor.side !== "new") return null;
6254
6633
  if (typeof anchor.line !== "number") return null;
6255
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;
6256
6636
  return {
6257
6637
  kind: "hunk",
6258
6638
  hunkId: anchor.hunkId,
6259
6639
  path: anchor.path,
6260
6640
  side: anchor.side,
6261
6641
  line: anchor.line,
6262
- ...endLine !== void 0 ? { endLine } : {}
6642
+ ...endLine !== void 0 ? { endLine } : {},
6643
+ ...commit !== void 0 ? { commit } : {}
6263
6644
  };
6264
6645
  }
6265
6646
  if (anchor.kind === "file") return typeof anchor.path === "string" ? {
@@ -6460,215 +6841,6 @@ function normalizePr(value) {
6460
6841
  };
6461
6842
  }
6462
6843
  //#endregion
6463
- //#region src/server/parse.ts
6464
- const IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
6465
- ".png",
6466
- ".jpg",
6467
- ".jpeg",
6468
- ".gif",
6469
- ".webp",
6470
- ".bmp",
6471
- ".svg",
6472
- ".ico",
6473
- ".avif"
6474
- ]);
6475
- function isImagePath(path) {
6476
- const dot = path.lastIndexOf(".");
6477
- return dot !== -1 && IMAGE_EXTENSIONS.has(path.slice(dot).toLowerCase());
6478
- }
6479
- /** Content-addressed hunk identity: the hash covers the file path and the changed
6480
- * lines only — not line numbers, not context. Editing the hunk itself mints a new
6481
- * id; the occurrence index separates identical hunks within one file. */
6482
- function hunkId(path, lines, occurrence) {
6483
- const changed = lines.filter((l) => l.kind !== "context").map((l) => `${l.kind === "add" ? "+" : "-"}${l.text}`).join("\n");
6484
- return createHash("sha256").update(`${path}\0${changed}\0${occurrence}`).digest("hex").slice(0, 16);
6485
- }
6486
- function parseSectionMeta(section) {
6487
- return {
6488
- renamedFrom: section.match(/^rename from (.+)$/m)?.[1] ?? null,
6489
- renamedTo: section.match(/^rename to (.+)$/m)?.[1] ?? null,
6490
- binary: /^Binary files .* differ$/m.test(section) || /^GIT binary patch$/m.test(section),
6491
- symlink: /^(?:new file|deleted file|new|old) mode 120000$/m.test(section) || /^index [0-9a-f.]+ 120000$/m.test(section)
6492
- };
6493
- }
6494
- function splitSections(patch) {
6495
- if (!patch.trim()) return [];
6496
- const sections = [];
6497
- const lines = patch.split("\n");
6498
- let current = null;
6499
- for (const line of lines) if (line.startsWith("diff --git ")) {
6500
- if (current) sections.push(current.join("\n"));
6501
- current = [line];
6502
- } else if (current) current.push(line);
6503
- if (current) sections.push(current.join("\n"));
6504
- return sections;
6505
- }
6506
- /**
6507
- * `@@ -oldStart,oldCount +newStart,newCount @@ scope`. Both counts are optional in
6508
- * the format and mean 1 when absent (`@@ -1 +1 @@`). The trailing scope is git's own
6509
- * guess at what you are inside.
6510
- */
6511
- const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/;
6512
- /**
6513
- * Read one hunk body, starting at the line after its header. The declared counts
6514
- * are authoritative: consume exactly that many old and new lines and stop, rather
6515
- * than reading up to the next `@@` — git's own rule, and what keeps the blank line
6516
- * every patch ends with out of the last hunk.
6517
- */
6518
- function parseHunkBody(lines, start, oldStart, newStart, oldCount, newCount) {
6519
- const out = [];
6520
- let oldNo = oldStart;
6521
- let newNo = newStart;
6522
- let remainingOld = oldCount;
6523
- let remainingNew = newCount;
6524
- let i = start;
6525
- while (remainingOld > 0 || remainingNew > 0) {
6526
- const line = lines[i];
6527
- if (line === void 0) break;
6528
- if (line.startsWith("@@") || line.startsWith("diff --git ")) break;
6529
- i++;
6530
- if (line.startsWith("\\")) continue;
6531
- if (line.startsWith("+")) {
6532
- out.push({
6533
- kind: "add",
6534
- oldNo: null,
6535
- newNo: newNo++,
6536
- text: line.slice(1)
6537
- });
6538
- remainingNew--;
6539
- } else if (line.startsWith("-")) {
6540
- out.push({
6541
- kind: "del",
6542
- oldNo: oldNo++,
6543
- newNo: null,
6544
- text: line.slice(1)
6545
- });
6546
- remainingOld--;
6547
- } else {
6548
- out.push({
6549
- kind: "context",
6550
- oldNo: oldNo++,
6551
- newNo: newNo++,
6552
- text: line.slice(1)
6553
- });
6554
- remainingOld--;
6555
- remainingNew--;
6556
- }
6557
- }
6558
- return {
6559
- lines: out,
6560
- next: i
6561
- };
6562
- }
6563
- function parseHunks(lines) {
6564
- const hunks = [];
6565
- let i = 0;
6566
- while (i < lines.length) {
6567
- const header = lines[i]?.match(HUNK_HEADER);
6568
- if (!header) {
6569
- i++;
6570
- continue;
6571
- }
6572
- const oldStart = Number(header[1]);
6573
- const newStart = Number(header[3]);
6574
- const body = parseHunkBody(lines, i + 1, oldStart, newStart, header[2] === void 0 ? 1 : Number(header[2]), header[4] === void 0 ? 1 : Number(header[4]));
6575
- const context = (header[5] ?? "").trim();
6576
- hunks.push({
6577
- oldStart,
6578
- newStart,
6579
- context: context === "" ? null : context,
6580
- lines: body.lines
6581
- });
6582
- i = body.next;
6583
- }
6584
- return hunks;
6585
- }
6586
- /**
6587
- * `--- a/x` / `+++ b/x`, read only from the part of the section before the first
6588
- * `@@`. That bound is load-bearing: inside a hunk body an added line starting with
6589
- * `++` is spelled `+++ …`, so scanning the whole section would read file content as
6590
- * a path header.
6591
- */
6592
- function parseSectionPaths(section, lines) {
6593
- let from = null;
6594
- let to = null;
6595
- for (const line of lines) {
6596
- if (line.startsWith("@@")) break;
6597
- if (from === null && line.startsWith("--- ")) from = diffPath(line.slice(4));
6598
- else if (to === null && line.startsWith("+++ ")) to = diffPath(line.slice(4));
6599
- }
6600
- return {
6601
- from,
6602
- to,
6603
- isNew: from === "/dev/null" || /^new file mode /m.test(section),
6604
- isDeleted: to === "/dev/null" || /^deleted file mode /m.test(section)
6605
- };
6606
- }
6607
- function diffPath(raw) {
6608
- const path = raw.split(" ", 1)[0] ?? raw;
6609
- return path === "/dev/null" ? path : stripAB(path);
6610
- }
6611
- function parsePatch(patch) {
6612
- const files = [];
6613
- for (const section of splitSections(patch)) {
6614
- const sectionLines = section.split("\n");
6615
- const meta = parseSectionMeta(section);
6616
- const paths = parseSectionPaths(section, sectionLines);
6617
- let path;
6618
- let oldPath = null;
6619
- let status;
6620
- if (meta.renamedTo) {
6621
- path = meta.renamedTo;
6622
- oldPath = meta.renamedFrom;
6623
- status = "renamed";
6624
- } else if (paths.isNew) {
6625
- path = paths.to ?? headerPath(section);
6626
- status = "added";
6627
- } else if (paths.isDeleted) {
6628
- path = paths.from ?? headerPath(section);
6629
- status = "deleted";
6630
- } else if (paths.to !== null && paths.to !== "/dev/null") {
6631
- path = paths.to;
6632
- status = "modified";
6633
- } else {
6634
- path = headerPath(section);
6635
- status = "modified";
6636
- }
6637
- const kind = meta.symlink ? "symlink" : meta.binary ? isImagePath(path) ? "image" : "binary" : isImagePath(path) ? "image" : "text";
6638
- const seen = /* @__PURE__ */ new Map();
6639
- const hunks = meta.binary ? [] : parseHunks(sectionLines).map((hunk) => {
6640
- const lines = hunk.lines;
6641
- const contentKey = lines.filter((l) => l.kind !== "context").map((l) => `${l.kind}:${l.text}`).join("\n");
6642
- const occurrence = seen.get(contentKey) ?? 0;
6643
- seen.set(contentKey, occurrence + 1);
6644
- return {
6645
- id: hunkId(path, lines, occurrence),
6646
- path,
6647
- oldStart: hunk.oldStart,
6648
- newStart: hunk.newStart,
6649
- lines,
6650
- ...hunk.context === null ? {} : { context: hunk.context }
6651
- };
6652
- });
6653
- files.push({
6654
- path,
6655
- oldPath,
6656
- status,
6657
- kind,
6658
- staged: false,
6659
- hunks
6660
- });
6661
- }
6662
- return files;
6663
- }
6664
- function stripAB(p) {
6665
- return p.startsWith("b/") || p.startsWith("a/") ? p.slice(2) : p;
6666
- }
6667
- function headerPath(section) {
6668
- const header = section.split("\n", 1)[0] ?? "";
6669
- return header.match(/^diff --git a\/(.*) b\/(.*)$/)?.[2] ?? header.replace(/^diff --git /, "");
6670
- }
6671
- //#endregion
6672
6844
  //#region src/server/changeset.ts
6673
6845
  function stats$1(files) {
6674
6846
  let additions = 0;
@@ -7379,8 +7551,17 @@ function createApp(ctx, store, review, queue) {
7379
7551
  edited: message.prComment.text.trim() !== text
7380
7552
  };
7381
7553
  }
7382
- const capture = store ? captureAnchor(store.get(), anchor, review.get().layers) : null;
7383
- const created = review.createThread(anchor, text, capture, intent, "reviewer", {
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", {
7384
7565
  ...audience ? { audience } : {},
7385
7566
  ...parentId ? { parentId } : {},
7386
7567
  ...origin ? { origin } : {}
@@ -7893,13 +8074,35 @@ function createApp(ctx, store, review, queue) {
7893
8074
  throw err;
7894
8075
  }
7895
8076
  });
7896
- app.get("/api/file", (c) => {
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) => {
7897
8095
  const path = c.req.query("path");
7898
8096
  const side = c.req.query("side");
8097
+ const rev = c.req.query("rev");
7899
8098
  if (!path || side !== "base" && side !== "head") return c.json({ error: "need ?path= and ?side=base|head" }, 400);
7900
8099
  let bytes;
7901
8100
  try {
7902
- bytes = side === "head" ? getHeadFileBytes(ctx.root, path) : getBaseFileBytes(ctx.root, resolveBaseRef(ctx.root, ctx.spec), path);
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);
7903
8106
  } catch (err) {
7904
8107
  if (err instanceof MissingBaseError) return c.json({ error: err.message }, 400);
7905
8108
  throw err;