@moritzwilksch/diffle 0.1.3 → 0.1.5

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 (81) hide show
  1. package/README.md +32 -9
  2. package/dist/client/assets/{angular-html-DCs801AK.js → angular-html-DmrG9JtS.js} +1 -1
  3. package/dist/client/assets/{angular-ts-BWhN0w8_.js → angular-ts-ViWJivwt.js} +1 -1
  4. package/dist/client/assets/{apl-Djz0ErNW.js → apl-C5EQbFuR.js} +1 -1
  5. package/dist/client/assets/{astro-MWC3rLUN.js → astro-Dn81Z0Ad.js} +1 -1
  6. package/dist/client/assets/{blade-B8pycf1w.js → blade-Bw99tw7z.js} +1 -1
  7. package/dist/client/assets/{c-B-Jcxs--.js → c-BUkR06RC.js} +1 -1
  8. package/dist/client/assets/{chapel-CuJCeKLf.js → chapel-BkX4HVjh.js} +1 -1
  9. package/dist/client/assets/{cobol-DgquORVs.js → cobol-DHYNHdYd.js} +1 -1
  10. package/dist/client/assets/{coffee-ECOaIhT5.js → coffee-CwXH1Vh3.js} +1 -1
  11. package/dist/client/assets/{cpp-CqnSivHO.js → cpp-CH4TrKz5.js} +1 -1
  12. package/dist/client/assets/{crystal-Cs7ZYBfN.js → crystal-DKLVzvRx.js} +1 -1
  13. package/dist/client/assets/{css-p0qk9Lci.js → css-Nq8Uu_QZ.js} +1 -1
  14. package/dist/client/assets/{edge-mMULBvUs.js → edge-CJRhOJkj.js} +1 -1
  15. package/dist/client/assets/{elixir-VIosceup.js → elixir-B6z04L_L.js} +1 -1
  16. package/dist/client/assets/{elm-C3kw87lA.js → elm-CylqdfmR.js} +1 -1
  17. package/dist/client/assets/{erb-DZjmloke.js → erb--LFUnTAk.js} +1 -1
  18. package/dist/client/assets/{git-rebase-qAMhScBw.js → git-rebase-CJezoI_U.js} +1 -1
  19. package/dist/client/assets/{glimmer-js-DJdA7CEB.js → glimmer-js-CVs-5jG6.js} +1 -1
  20. package/dist/client/assets/{glimmer-ts-6xtV4JQb.js → glimmer-ts-C8GkuLHj.js} +1 -1
  21. package/dist/client/assets/{glsl-BRqSJ3dN.js → glsl-Cre-e57h.js} +1 -1
  22. package/dist/client/assets/{graphql-nm_X3Hg7.js → graphql-Bflp-kge.js} +1 -1
  23. package/dist/client/assets/{hack-CQ7j-SLC.js → hack-BVXbQcT2.js} +1 -1
  24. package/dist/client/assets/{haml-nw30ZCFe.js → haml-De4W5AvJ.js} +1 -1
  25. package/dist/client/assets/{handlebars-CbbUPaYB.js → handlebars-DrAV6XbB.js} +1 -1
  26. package/dist/client/assets/{html-C-E44L0y.js → html-DWrE5ZtW.js} +1 -1
  27. package/dist/client/assets/{html-derivative-DTCnXXkR.js → html-derivative-4y8Ldk_X.js} +1 -1
  28. package/dist/client/assets/{http-Cg4Jw-kz.js → http-BlF3sH0P.js} +1 -1
  29. package/dist/client/assets/{hurl-CITbwYVL.js → hurl-8Yd7qaL-.js} +1 -1
  30. package/dist/client/assets/{index-BdA8MWLx.js → index-B5j0g-YF.js} +90 -90
  31. package/dist/client/assets/{java-DNT_EPVr.js → java-Da6TVA-D.js} +1 -1
  32. package/dist/client/assets/{javascript-DBrMK690.js → javascript-9H4TWodB.js} +1 -1
  33. package/dist/client/assets/{jinja-DqPnwJo-.js → jinja-CB5h6IWK.js} +1 -1
  34. package/dist/client/assets/{jison-A5ieRkCJ.js → jison-DdpPjkKm.js} +1 -1
  35. package/dist/client/assets/{json-BolWLdCd.js → json-lKrYnKUy.js} +1 -1
  36. package/dist/client/assets/{jsx-CkIysgAJ.js → jsx-Cq_s8bb1.js} +1 -1
  37. package/dist/client/assets/{julia-jBzm267c.js → julia-B2fUm1la.js} +1 -1
  38. package/dist/client/assets/{just-cr8UASbW.js → just-EU9poDOS.js} +1 -1
  39. package/dist/client/assets/{latex-DcdC0vak.js → latex-D94S8ANd.js} +1 -1
  40. package/dist/client/assets/{liquid-BBR6eAOA.js → liquid-Dq3Y6XOW.js} +1 -1
  41. package/dist/client/assets/{lua-CnUVAA9w.js → lua-Dl3Y8EmE.js} +1 -1
  42. package/dist/client/assets/{marko-BxH5DxB_.js → marko-BUvlzVCc.js} +1 -1
  43. package/dist/client/assets/{mdc-CVc0_tcy.js → mdc-5MteepiP.js} +1 -1
  44. package/dist/client/assets/{nginx-B1DQmN_N.js → nginx-DPAEohPR.js} +1 -1
  45. package/dist/client/assets/{nim-C3rsdIhh.js → nim-Dy-zxDpe.js} +1 -1
  46. package/dist/client/assets/{org-DICsbmv1.js → org-a4b5Lf2G.js} +1 -1
  47. package/dist/client/assets/{perl-B-6tgIaz.js → perl-Ba0EQGS8.js} +1 -1
  48. package/dist/client/assets/{php-CSZf1YiM.js → php-BwWPnF9y.js} +1 -1
  49. package/dist/client/assets/{pug-BvQ97HKe.js → pug-DiduM0zv.js} +1 -1
  50. package/dist/client/assets/{qml-B_gSWBeZ.js → qml-Zm_NS-qG.js} +1 -1
  51. package/dist/client/assets/{r-Dbh43Ti8.js → r-Dq0UKcOZ.js} +1 -1
  52. package/dist/client/assets/{razor-Dks3_W39.js → razor-BOvDDN47.js} +1 -1
  53. package/dist/client/assets/{regexp-cZE9On7Z.js → regexp-D3YxDQ6s.js} +1 -1
  54. package/dist/client/assets/{rst-Cgn-1Zks.js → rst-DHbzSb6z.js} +1 -1
  55. package/dist/client/assets/{ruby-DkGM-R-6.js → ruby-CvI-mn5Q.js} +1 -1
  56. package/dist/client/assets/{sas-D6c_PGbn.js → sas-CWQFfwiE.js} +1 -1
  57. package/dist/client/assets/{scss-BKxWJNFh.js → scss-CCpN0AyS.js} +1 -1
  58. package/dist/client/assets/{shellscript-Bcg5lXvK.js → shellscript-BfpoO4g6.js} +1 -1
  59. package/dist/client/assets/{shellsession-Bf400BZo.js → shellsession-agjx723h.js} +1 -1
  60. package/dist/client/assets/{soy-VDZj_scj.js → soy-pEVWulWp.js} +1 -1
  61. package/dist/client/assets/{sql-ushyJdRf.js → sql-DS0UAPd6.js} +1 -1
  62. package/dist/client/assets/{stata-87RYcwm-.js → stata-D8L-Tcxs.js} +1 -1
  63. package/dist/client/assets/{surrealql-DiJkNDgM.js → surrealql-DOkpssDf.js} +1 -1
  64. package/dist/client/assets/{svelte-B6zEQCqX.js → svelte-B4ieXAtz.js} +1 -1
  65. package/dist/client/assets/{templ-BbUkdxgW.js → templ-BdVEAAo4.js} +1 -1
  66. package/dist/client/assets/{tex-8DUlt0ja.js → tex-CYjNIefm.js} +1 -1
  67. package/dist/client/assets/{ts-tags-B9TjJt8W.js → ts-tags-BFD-4qTD.js} +1 -1
  68. package/dist/client/assets/{tsx-KtVv-1tC.js → tsx-CZLDU0bf.js} +1 -1
  69. package/dist/client/assets/{twig-SRZcBkws.js → twig-DYcKmD5x.js} +1 -1
  70. package/dist/client/assets/{typescript-XxBZhs1X.js → typescript-d_JvksIp.js} +1 -1
  71. package/dist/client/assets/{typst-zdrwuFs3.js → typst-B0Uo-CKu.js} +1 -1
  72. package/dist/client/assets/{vue-CIJzu_ou.js → vue-B3Iiop2I.js} +1 -1
  73. package/dist/client/assets/{vue-html-Cbh4Xclf.js → vue-html-B9s0WJG6.js} +1 -1
  74. package/dist/client/assets/{vue-vine-B3rP3Gia.js → vue-vine-CIMF412S.js} +1 -1
  75. package/dist/client/assets/{xml-B8FuGGpH.js → xml-BzXG-icR.js} +1 -1
  76. package/dist/client/assets/{xsl-DGb4ZSQs.js → xsl-Dp1sXze8.js} +1 -1
  77. package/dist/client/assets/{yaml-DgFlGzOM.js → yaml-BW2dXlES.js} +1 -1
  78. package/dist/client/index.html +1 -1
  79. package/dist/server/{Watcher-C_X03LRa.js → Watcher-CKwvGuPB.js} +5 -4
  80. package/dist/server/main.js +312 -135
  81. package/package.json +1 -1
@@ -3951,7 +3951,7 @@ function generateCompletion(program, { shell, executable = program.name() }) {
3951
3951
  }
3952
3952
  //#endregion
3953
3953
  //#region package.json
3954
- var version$1 = "0.1.3";
3954
+ var version$1 = "0.1.5";
3955
3955
  //#endregion
3956
3956
  //#region src/server/comments/anchor.ts
3957
3957
  function splitLines(contents) {
@@ -3996,10 +3996,15 @@ function quoteRange(contents, startLine, endLine) {
3996
3996
  if (startLine < 1 || endLine < startLine || endLine > lines.length) return null;
3997
3997
  return lines.slice(startLine - 1, endLine).join("\n");
3998
3998
  }
3999
- /** Review order: by path, then line, then when the thread was opened. */
3999
+ /** The line a thread sorts under: a file thread comes before every line of its file. */
4000
+ function anchorLine(anchor) {
4001
+ return anchor.kind === "line" ? anchor.startLine : 0;
4002
+ }
4003
+ /** Review order: by path, then line (file threads first), then when the thread was opened. */
4000
4004
  function compareThreads(a, b) {
4001
4005
  if (a.anchor.path !== b.anchor.path) return a.anchor.path < b.anchor.path ? -1 : 1;
4002
- if (a.anchor.startLine !== b.anchor.startLine) return a.anchor.startLine - b.anchor.startLine;
4006
+ const line = anchorLine(a.anchor) - anchorLine(b.anchor);
4007
+ if (line !== 0) return line;
4003
4008
  return (a.messages[0]?.createdAt ?? 0) - (b.messages[0]?.createdAt ?? 0);
4004
4009
  }
4005
4010
  //#endregion
@@ -4007,21 +4012,23 @@ function compareThreads(a, b) {
4007
4012
  /**
4008
4013
  * Agent-prompt format, one block per thread:
4009
4014
  *
4010
- * path:line (or path:start-end)
4015
+ * path:line (or path:start-end; `(file) path` for a thread on the whole file)
4011
4016
  *
4012
- * > quoted line
4017
+ * > quoted line (absent for a file thread)
4013
4018
  *
4014
4019
  * body (a thread's messages joined by a blank line)
4015
4020
  *
4016
4021
  * ---
4017
4022
  *
4018
- * A ```suggestion fence in a body expands to ORIGINAL / SUGGESTED fences.
4023
+ * A ```suggestion fence in a body expands to ORIGINAL / SUGGESTED fences; a file
4024
+ * thread has no original, so its fences stay as written.
4019
4025
  */
4020
4026
  function formatPrompt(threads) {
4021
4027
  return [...threads].sort(compareThreads).map(formatOne).join("\n");
4022
4028
  }
4023
4029
  function formatOne(t) {
4024
4030
  const { anchor } = t;
4031
+ if (anchor.kind === "file") return `${`(file) ${t.stale ? "(stale) " : ""}${anchor.path}`}\n\n${t.messages.map((m) => m.body.trim()).join("\n\n")}\n\n---\n`;
4025
4032
  const range = anchor.startLine === anchor.endLine ? `${anchor.startLine}` : `${anchor.startLine}-${anchor.endLine}`;
4026
4033
  const prefixes = [];
4027
4034
  if (anchor.side === "old") prefixes.push("(removed)");
@@ -4720,6 +4727,31 @@ var GitRepo = class GitRepo {
4720
4727
  await fh?.close();
4721
4728
  }
4722
4729
  }
4730
+ /**
4731
+ * Boolean value of a gitattribute per path: `attr` or `attr=true` reads
4732
+ * true, `-attr` or `attr=false` false; unspecified paths are absent. A
4733
+ * revision reads `.gitattributes` from that tree, the worktree from the
4734
+ * checkout. `--source` needs git 2.40.
4735
+ */
4736
+ async attr(name, paths, rev) {
4737
+ const out = /* @__PURE__ */ new Map();
4738
+ if (paths.length === 0) return out;
4739
+ const source = rev === "worktree" ? [] : [`--source=${rev}`];
4740
+ const fields = splitZ(await this.exec([
4741
+ "check-attr",
4742
+ "-z",
4743
+ "--stdin",
4744
+ ...source,
4745
+ name,
4746
+ "--"
4747
+ ], { input: paths.join("\0") }));
4748
+ for (let i = 0; i + 2 < fields.length; i += 3) {
4749
+ const value = fields[i + 2];
4750
+ if (value === "set" || value === "true") out.set(fields[i], true);
4751
+ else if (value === "unset" || value === "false") out.set(fields[i], false);
4752
+ }
4753
+ return out;
4754
+ }
4723
4755
  /** Paths git ignores, from a candidate list. */
4724
4756
  async ignored(paths) {
4725
4757
  if (paths.length === 0) return /* @__PURE__ */ new Set();
@@ -5076,11 +5108,15 @@ var GithubError = class extends Error {
5076
5108
  this.status = status;
5077
5109
  }
5078
5110
  };
5111
+ function isFileComment(c) {
5112
+ return "subject_type" in c;
5113
+ }
5079
5114
  /**
5080
5115
  * Threads → the comments of one pending GitHub review, one per thread. Stale threads are skipped:
5081
5116
  * their lines no longer sit in the diff, and GitHub would refuse them anyway. With
5082
5117
  * `threadIds`, only those (resolved included, the user asked for them by hand);
5083
- * without, every unresolved thread.
5118
+ * without, every unresolved thread. `review.comments` holds the line comments REST can
5119
+ * create; the file comments are appended afterwards, but `ids` counts both, in review order.
5084
5120
  */
5085
5121
  function buildReview(threads, commitId, threadIds) {
5086
5122
  const skipped = [];
@@ -5113,20 +5149,27 @@ function buildReview(threads, commitId, threadIds) {
5113
5149
  return {
5114
5150
  review: {
5115
5151
  commit_id: commitId,
5116
- comments
5152
+ comments: comments.filter((c) => !isFileComment(c))
5117
5153
  },
5154
+ comments,
5118
5155
  ids,
5119
5156
  skipped
5120
5157
  };
5121
5158
  }
5122
5159
  function toReviewComment(t) {
5123
5160
  const { anchor } = t;
5161
+ const body = formatBody(t.messages);
5162
+ if (anchor.kind === "file") return {
5163
+ path: anchor.path,
5164
+ subject_type: "file",
5165
+ body
5166
+ };
5124
5167
  const side = anchor.side === "old" ? "LEFT" : "RIGHT";
5125
5168
  const c = {
5126
5169
  path: anchor.path,
5127
5170
  line: anchor.endLine,
5128
5171
  side,
5129
- body: formatBody(t.messages)
5172
+ body
5130
5173
  };
5131
5174
  if (anchor.startLine !== anchor.endLine) {
5132
5175
  c.start_line = anchor.startLine;
@@ -5171,15 +5214,25 @@ var GithubExporter = class {
5171
5214
  */
5172
5215
  async function exportToGithub({ snap, pullRequest: pr, threads, threadIds, run = runGh }) {
5173
5216
  if (snap.newSha === "worktree") throw new GithubError("GitHub cannot anchor comments to the worktree");
5174
- const { review, ids, skipped } = buildReview(threads, snap.newSha, threadIds);
5175
- if (review.comments.length === 0) throw new GithubError(skipped.length ? `nothing to post: ${describe(skipped)}` : "nothing to post", 400);
5176
- for (const [i, comment] of review.comments.entries()) comment.body += `\n\n${threadMarker(ids[i])}`;
5217
+ const { review, comments, ids, skipped } = buildReview(threads, snap.newSha, threadIds);
5218
+ if (comments.length === 0) throw new GithubError(skipped.length ? `nothing to post: ${describe(skipped)}` : "nothing to post", 400);
5219
+ for (const [i, comment] of comments.entries()) comment.body += `\n\n${threadMarker(ids[i])}`;
5177
5220
  const pending = await findPendingReview(run, snap.root, pr);
5178
5221
  if (!pending) {
5179
- await createPendingReview(run, snap.root, pr.repository, pr.number, review);
5222
+ const created = await createPendingReview(run, snap.root, pr.repository, pr.number, review);
5223
+ let appended = 0;
5224
+ try {
5225
+ for (const c of comments.filter(isFileComment)) {
5226
+ await addToPendingReview(run, snap.root, created.id, c);
5227
+ appended++;
5228
+ }
5229
+ } catch (e) {
5230
+ const detail = e instanceof Error ? e.message : String(e);
5231
+ throw new GithubError(`created the pending review with ${review.comments.length + appended} comments, then ${detail}`, 502);
5232
+ }
5180
5233
  return {
5181
5234
  url: pr.url,
5182
- posted: review.comments.length,
5235
+ posted: comments.length,
5183
5236
  updated: 0,
5184
5237
  review: "created",
5185
5238
  skipped
@@ -5190,7 +5243,7 @@ async function exportToGithub({ snap, pullRequest: pr, threads, threadIds, run =
5190
5243
  let updated = 0;
5191
5244
  let posted = 0;
5192
5245
  try {
5193
- for (const [i, c] of review.comments.entries()) {
5246
+ for (const [i, c] of comments.entries()) {
5194
5247
  const id = ids[i];
5195
5248
  const at = existing.get(anchorKey(c));
5196
5249
  const index = at?.findIndex((comment) => comment.body.trimEnd().endsWith(threadMarker(id))) ?? -1;
@@ -5234,6 +5287,7 @@ function threadMarker(id) {
5234
5287
  }
5235
5288
  /** The anchor must still match before we rewrite an exported thread. */
5236
5289
  function anchorKey(c) {
5290
+ if (isFileComment(c)) return [c.path, "FILE"].join("\0");
5237
5291
  return [
5238
5292
  c.path,
5239
5293
  c.side,
@@ -5264,7 +5318,7 @@ async function findPendingReview(run, cwd, pr) {
5264
5318
  const THREADS_QUERY = `query($owner:String!,$repo:String!,$number:Int!,$after:String){
5265
5319
  repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewThreads(first:100,after:$after){
5266
5320
  pageInfo{hasNextPage endCursor}
5267
- nodes{path line startLine diffSide comments(first:1){nodes{id body pullRequestReview{id}}}}
5321
+ nodes{path line startLine diffSide subjectType comments(first:1){nodes{id body pullRequestReview{id}}}}
5268
5322
  }}}
5269
5323
  }`;
5270
5324
  /**
@@ -5288,15 +5342,8 @@ async function pendingComments(run, cwd, pr, reviewId) {
5288
5342
  for (const t of threads.nodes) {
5289
5343
  const c = t?.comments?.nodes?.[0];
5290
5344
  if (!t?.path || !c?.id || c.pullRequestReview?.id !== reviewId) continue;
5291
- const line = t.line;
5292
- if (line == null) continue;
5293
- const side = t.diffSide === "LEFT" ? "LEFT" : "RIGHT";
5294
- const key = anchorKey({
5295
- path: t.path,
5296
- side,
5297
- line,
5298
- start_line: t.startLine ?? void 0
5299
- });
5345
+ const key = pendingKey(t);
5346
+ if (key == null) continue;
5300
5347
  const at = by.get(key);
5301
5348
  if (at) at.push({
5302
5349
  nodeId: c.id,
@@ -5313,10 +5360,30 @@ async function pendingComments(run, cwd, pr, reviewId) {
5313
5360
  }
5314
5361
  throw new GithubError("pending review exceeds the 1000-thread lookup limit; export stopped without changes", 502);
5315
5362
  }
5363
+ /** The anchor key of a thread already in the review, or null for one GitHub reports without a position. */
5364
+ function pendingKey(t) {
5365
+ const path = t.path;
5366
+ if (t.subjectType === "FILE") return anchorKey({
5367
+ path,
5368
+ subject_type: "file",
5369
+ body: ""
5370
+ });
5371
+ if (t.line == null) return null;
5372
+ return anchorKey({
5373
+ path,
5374
+ side: t.diffSide === "LEFT" ? "LEFT" : "RIGHT",
5375
+ line: t.line,
5376
+ start_line: t.startLine ?? void 0,
5377
+ body: ""
5378
+ });
5379
+ }
5316
5380
  const UPDATE_COMMENT = `mutation($input:UpdatePullRequestReviewCommentInput!){updatePullRequestReviewComment(input:$input){pullRequestReviewComment{id}}}`;
5317
- /** No `event` in the body, so GitHub keeps the new review pending with all of its comments. */
5381
+ /**
5382
+ * No `event` in the body, so GitHub keeps the new review pending with all of its comments.
5383
+ * Resolves the review's node id, for the comments REST cannot carry.
5384
+ */
5318
5385
  async function createPendingReview(run, cwd, repository, number, review) {
5319
- await run([
5386
+ const out = await run([
5320
5387
  "api",
5321
5388
  "--method",
5322
5389
  "POST",
@@ -5327,20 +5394,31 @@ async function createPendingReview(run, cwd, repository, number, review) {
5327
5394
  cwd,
5328
5395
  input: JSON.stringify(review)
5329
5396
  });
5397
+ let id;
5398
+ try {
5399
+ id = JSON.parse(out).node_id;
5400
+ } catch {
5401
+ throw new GithubError(`unexpected output from gh api: ${out.slice(0, 200)}`, 502);
5402
+ }
5403
+ if (typeof id !== "string") throw new GithubError(`unexpected output from gh api: ${out.slice(0, 200)}`, 502);
5404
+ return { id };
5330
5405
  }
5331
5406
  const ADD_THREAD = `mutation($input:AddPullRequestReviewThreadInput!){addPullRequestReviewThread(input:$input){thread{id}}}`;
5332
- /** Appends one thread through GraphQL; REST cannot extend a pending review. */
5407
+ /** Appends one thread through GraphQL; REST cannot extend a pending review, nor place a file comment. */
5333
5408
  async function addToPendingReview(run, cwd, reviewId, c) {
5334
5409
  const input = {
5335
5410
  pullRequestReviewId: reviewId,
5336
5411
  path: c.path,
5337
- line: c.line,
5338
- side: c.side,
5339
5412
  body: c.body
5340
5413
  };
5341
- if (c.start_line != null) {
5342
- input.startLine = c.start_line;
5343
- input.startSide = c.start_side;
5414
+ if (isFileComment(c)) input.subjectType = "FILE";
5415
+ else {
5416
+ input.line = c.line;
5417
+ input.side = c.side;
5418
+ if (c.start_line != null) {
5419
+ input.startLine = c.start_line;
5420
+ input.startSide = c.start_side;
5421
+ }
5344
5422
  }
5345
5423
  await graphql(run, cwd, ADD_THREAD, { input });
5346
5424
  }
@@ -11164,7 +11242,7 @@ const ChangedFileSchema = object({
11164
11242
  binary: boolean(),
11165
11243
  /** New-side blob sha. '' for deletions. Keys the viewed state. */
11166
11244
  blob: string(),
11167
- /** A path pattern or a content sniff said this file is generated. Feeds auto-collapse and risk ranking. */
11245
+ /** The `linguist-generated` gitattribute, a path pattern or a content sniff said this file is generated. Feeds auto-collapse and risk ranking. */
11168
11246
  generated: boolean(),
11169
11247
  /** A gitlink (mode 160000): `blob` is the recorded commit, the patch shows the commit-id change, and there is no file to open. */
11170
11248
  submodule: literal(true).optional()
@@ -11192,7 +11270,8 @@ object({
11192
11270
  contents: string(),
11193
11271
  binary: boolean()
11194
11272
  });
11195
- const CommentAnchorSchema = object({
11273
+ const CommentAnchorSchema = discriminatedUnion("kind", [object({
11274
+ kind: literal("line"),
11196
11275
  path: string(),
11197
11276
  /** Column the user selected in a diff; 'new' for file items. */
11198
11277
  side: SideSchema,
@@ -11200,7 +11279,11 @@ const CommentAnchorSchema = object({
11200
11279
  endLine: number(),
11201
11280
  /** Exact text of [startLine, endLine] at creation. */
11202
11281
  quoted: string()
11203
- });
11282
+ }), object({
11283
+ kind: literal("file"),
11284
+ /** The changed file's path (its new path for a rename), or any tree path. */
11285
+ path: string()
11286
+ })]);
11204
11287
  const CommentMessageSchema = object({
11205
11288
  id: string(),
11206
11289
  body: string(),
@@ -11214,24 +11297,31 @@ object({
11214
11297
  messages: CommentMessageSchema.array().min(1),
11215
11298
  resolved: boolean(),
11216
11299
  resolvedAt: number().optional(),
11217
- /** Relocation failed after a snapshot refresh. */
11300
+ /** Relocation failed after a snapshot refresh: the lines left the diff, or the file left the review. */
11218
11301
  stale: boolean(),
11219
- /** Original startLine, shown in export when stale. */
11302
+ /** Original startLine of a line thread, shown in export when stale. */
11220
11303
  staleFromLine: number().optional()
11221
11304
  });
11222
- /** Create payload. `quoted` is optional: the server quotes the range from the snapshot. */
11305
+ /**
11306
+ * Create payload. Without `startLine` the thread is on the file as a whole; then `side`,
11307
+ * `endLine` and `quoted` are refused rather than ignored, so a payload that meant a line
11308
+ * never lands on the file. `quoted` is optional: the server quotes the range from the snapshot.
11309
+ */
11223
11310
  const ThreadCreateSchema = object({
11224
11311
  path: string().min(1),
11225
11312
  /** Default 'new'. */
11226
11313
  side: SideSchema.optional(),
11227
- startLine: number().int().positive(),
11314
+ startLine: number().int().positive().optional(),
11228
11315
  /** Default startLine. */
11229
11316
  endLine: number().int().positive().optional(),
11230
11317
  body: string().refine((body) => body.trim().length > 0, "body required"),
11231
11318
  quoted: string().optional()
11232
- }).refine((t) => t.endLine == null || t.endLine >= t.startLine, {
11319
+ }).refine((t) => t.startLine == null || t.endLine == null || t.endLine >= t.startLine, {
11233
11320
  path: ["endLine"],
11234
11321
  message: "endLine must be ≥ startLine"
11322
+ }).refine((t) => t.startLine != null || t.side == null && t.endLine == null && t.quoted == null, {
11323
+ path: ["startLine"],
11324
+ message: "side, endLine and quoted need a startLine; leave all four out for a comment on the whole file"
11235
11325
  });
11236
11326
  const ReplyCreateSchema = object({ body: string().refine((body) => body.trim().length > 0, "body required") });
11237
11327
  const ThreadStateSchema = _enum([
@@ -15780,38 +15870,39 @@ const LOOPBACK = /* @__PURE__ */ new Set([
15780
15870
  "::1",
15781
15871
  "localhost"
15782
15872
  ]);
15873
+ const HINT = "if a reverse proxy sent it, trust that origin with --allowed-origin";
15783
15874
  /**
15784
- * Host/Origin policy for API and WebSocket requests. Pure given its inputs;
15785
- * `bindHost` is the `--host` value. An Origin, when sent, must name the same
15786
- * host:port as Host (blocks cross-site pages). On a loopback bind, Host must be
15787
- * loopback too. On any other bind, Host must be an IP literal, loopback, the
15788
- * bind host, or one of `names` (the machine's own hostnames by default). Both
15789
- * rules block DNS rebinding: an attacker's domain is never an IP literal and
15790
- * never one of ours.
15875
+ * Restricts API and WebSocket requests to local hosts and the configured public origin.
15876
+ * Proxies may preserve the public Host or rewrite it to an allowed upstream Host.
15877
+ * Forwarded headers never grant trust; a public Origin must be explicitly configured.
15791
15878
  */
15792
- function requestGuard(bindHost, names = ownNames()) {
15879
+ function requestGuard(bindHost, names = ownNames(), allowedOrigin) {
15793
15880
  const loopback = LOOPBACK.has(bindHost);
15794
15881
  const allowed = /* @__PURE__ */ new Set([
15795
15882
  ...LOOPBACK,
15796
15883
  bindHost.toLowerCase(),
15797
15884
  ...names.map((n) => n.toLowerCase())
15798
15885
  ]);
15886
+ const publicHost = allowedOrigin == null ? void 0 : new URL(allowedOrigin).host;
15799
15887
  return ({ host, origin }) => {
15800
- if (!host) return false;
15801
- if (origin != null) {
15802
- let originHost;
15803
- try {
15804
- originHost = new URL(origin).host;
15805
- } catch {
15806
- return false;
15807
- }
15808
- if (originHost !== host) return false;
15809
- }
15888
+ if (!host) return "missing Host header";
15810
15889
  const name = hostnameOf(host).toLowerCase();
15811
- if (loopback) return LOOPBACK.has(name);
15812
- return isIP(name) !== 0 || allowed.has(name);
15890
+ const localHost = loopback ? LOOPBACK.has(name) : isIP(name) !== 0 || allowed.has(name);
15891
+ const proxyHost = publicHost != null && host.toLowerCase() === publicHost;
15892
+ if (!localHost && !proxyHost) return `forbidden Host "${host}"; ${HINT}`;
15893
+ if (origin == null || allowedOrigin != null && origin === allowedOrigin) return null;
15894
+ if (!proxyHost && parseHost(origin) === host) return null;
15895
+ return `forbidden Origin "${origin}" for Host "${host}"; ${HINT}`;
15813
15896
  };
15814
15897
  }
15898
+ /** The host of a syntactically valid origin; undefined for `null` and other non-URLs. */
15899
+ function parseHost(origin) {
15900
+ try {
15901
+ return new URL(origin).host;
15902
+ } catch {
15903
+ return;
15904
+ }
15905
+ }
15815
15906
  /** The machine's hostname, its short form, and its mDNS name, so LAN visitors can use any of them. */
15816
15907
  function ownNames() {
15817
15908
  const full = hostname();
@@ -15881,15 +15972,17 @@ function toThreadCreate(entry, where) {
15881
15972
  }
15882
15973
  /** True when `t` duplicates an open thread at the same anchor whose opening message has the same body. */
15883
15974
  function isDuplicate(existing, t) {
15975
+ const body = t.body.trim();
15976
+ const open = existing.filter((e) => !e.resolved && e.anchor.path === t.path && e.messages[0]?.body.trim() === body);
15977
+ if (t.startLine == null) return open.some((e) => e.anchor.kind === "file");
15884
15978
  const side = t.side ?? "new";
15885
15979
  const endLine = t.endLine ?? t.startLine;
15886
- const body = t.body.trim();
15887
- return existing.some((e) => !e.resolved && e.anchor.path === t.path && e.anchor.side === side && e.anchor.startLine === t.startLine && e.anchor.endLine === endLine && e.messages[0]?.body.trim() === body);
15980
+ return open.some((e) => e.anchor.kind === "line" && e.anchor.side === side && e.anchor.startLine === t.startLine && e.anchor.endLine === endLine);
15888
15981
  }
15889
15982
  //#endregion
15890
15983
  //#region src/server/comments/CommentStore.ts
15891
15984
  var NotFoundError = class extends Error {};
15892
- /** A range the snapshot cannot quote: path absent on that side, or lines past the end. */
15985
+ /** An anchor the snapshot cannot place: path absent on that side, or lines past the end. */
15893
15986
  var UnquotableError = class extends Error {};
15894
15987
  /**
15895
15988
  * Owns this mode's set in `<gitDir>/diffle/comments.json`: threads and viewed
@@ -15906,14 +15999,14 @@ var CommentStore = class CommentStore {
15906
15999
  this.file = file;
15907
16000
  this.key = key;
15908
16001
  }
15909
- /** Opens the store; a v1 file is backed up to `comments.v1.bak` and rewritten as v2 first. */
16002
+ /** Opens the store; an older file is backed up to `comments.v<n>.bak` and rewritten in the current format first. */
15910
16003
  static async open(gitDir, modeKey) {
15911
16004
  const file = join(gitDir, "diffle", "comments.json");
15912
16005
  await withFileLock(file, async () => {
15913
16006
  const all = await readStoreFile(file);
15914
- if (all.version !== 1) return;
15915
- await copyFile(file, join(gitDir, "diffle", "comments.v1.bak"));
15916
- await writeStoreFile(file, migrateV1(all));
16007
+ if (all.version === 3) return;
16008
+ await copyFile(file, join(gitDir, "diffle", `comments.v${all.version}.bak`));
16009
+ await writeStoreFile(file, upgrade(all));
15917
16010
  });
15918
16011
  return new CommentStore(file, modeKey);
15919
16012
  }
@@ -15989,12 +16082,13 @@ var CommentStore = class CommentStore {
15989
16082
  });
15990
16083
  }
15991
16084
  /**
15992
- * Adds each import that is not already an open duplicate. Every range is
15993
- * checked against the snapshot through `quote`; a payload's own `quoted` (the
15994
- * text the browser showed) is kept once the range is known to exist. Throws
15995
- * UnquotableError, adding nothing, when any range cannot be read.
16085
+ * Adds each import that is not already an open duplicate. Every anchor is
16086
+ * checked against the snapshot through `source`: a range is quoted (a payload's
16087
+ * own `quoted`, the text the browser showed, is kept once the range is known to
16088
+ * exist) and a file thread's path must be in the review. Throws UnquotableError,
16089
+ * adding nothing, when any anchor cannot be placed.
15996
16090
  */
15997
- importThreads(imports, quote) {
16091
+ importThreads(imports, source) {
15998
16092
  return this.mutate(async (set) => {
15999
16093
  const added = [];
16000
16094
  const pending = [];
@@ -16005,18 +16099,7 @@ var CommentStore = class CommentStore {
16005
16099
  skipped++;
16006
16100
  continue;
16007
16101
  }
16008
- const side = t.side ?? "new";
16009
- const startLine = t.startLine;
16010
- const endLine = t.endLine ?? startLine;
16011
- const fromSnapshot = await quote(t.path, side, startLine, endLine);
16012
- if (fromSnapshot == null) throw new UnquotableError(`cannot quote ${t.path}:${startLine}${endLine !== startLine ? `-${endLine}` : ""} on the ${side} side`);
16013
- const anchor = {
16014
- path: t.path,
16015
- side,
16016
- startLine,
16017
- endLine,
16018
- quoted: t.quoted ?? fromSnapshot
16019
- };
16102
+ const anchor = await placeImport(t, source);
16020
16103
  pending.push({
16021
16104
  anchor,
16022
16105
  t
@@ -16097,17 +16180,18 @@ var CommentStore = class CommentStore {
16097
16180
  });
16098
16181
  }
16099
16182
  /**
16100
- * Re-anchor every thread against the current review. A thread is stale when
16101
- * its side is gone, its text is not found, or the relocated range falls
16102
- * outside what the review shows. Returns true if anything changed.
16183
+ * Re-anchor every thread against the current review. A line thread is stale
16184
+ * when its side is gone, its text is not found, or the relocated range falls
16185
+ * outside what the review shows; a file thread when its file left the review.
16186
+ * Returns true if anything changed.
16103
16187
  */
16104
- async relocateAll(view) {
16188
+ async relocateAll(review) {
16105
16189
  const cache = /* @__PURE__ */ new Map();
16106
16190
  const load = (path, side) => {
16107
16191
  const k = `${side}:${path}`;
16108
16192
  let p = cache.get(k);
16109
16193
  if (!p) {
16110
- p = view(path, side);
16194
+ p = review.side(path, side);
16111
16195
  cache.set(k, p);
16112
16196
  }
16113
16197
  return p;
@@ -16115,6 +16199,14 @@ var CommentStore = class CommentStore {
16115
16199
  return this.mutate(async (set) => {
16116
16200
  let changed = false;
16117
16201
  for (const t of set.threads) {
16202
+ if (t.anchor.kind === "file") {
16203
+ const stale = !review.hasFile(t.anchor.path);
16204
+ if (stale !== t.stale) {
16205
+ t.stale = stale;
16206
+ changed = true;
16207
+ }
16208
+ continue;
16209
+ }
16118
16210
  const v = await load(t.anchor.path, t.anchor.side);
16119
16211
  let moved = v == null ? null : relocate(t.anchor, v.contents);
16120
16212
  if (moved && v?.shown && !isShown(v.shown, moved.startLine, moved.endLine)) moved = null;
@@ -16151,7 +16243,7 @@ var CommentStore = class CommentStore {
16151
16243
  viewed: []
16152
16244
  };
16153
16245
  }
16154
- return ownSet(parsed.version === 2 ? parsed : migrateV1(parsed), this.key);
16246
+ return ownSet(upgrade(parsed), this.key);
16155
16247
  }
16156
16248
  /**
16157
16249
  * Locked read-modify-write: `fn` edits this key's set as read from disk under
@@ -16161,12 +16253,11 @@ var CommentStore = class CommentStore {
16161
16253
  */
16162
16254
  mutate(fn, changed = () => true) {
16163
16255
  return withFileLock(this.file, async () => {
16164
- const onDisk = await readStoreFile(this.file);
16165
- const all = onDisk.version === 2 ? onDisk : migrateV1(onDisk);
16256
+ const all = upgrade(await readStoreFile(this.file));
16166
16257
  const set = ownSet(all, this.key);
16167
16258
  const r = await fn(set);
16168
16259
  if (changed(r)) await writeStoreFile(this.file, {
16169
- version: 2,
16260
+ version: 3,
16170
16261
  sets: {
16171
16262
  ...all.sets,
16172
16263
  [this.key]: set
@@ -16188,7 +16279,33 @@ function findThread(set, id) {
16188
16279
  if (!t) throw new NotFoundError(id);
16189
16280
  return t;
16190
16281
  }
16191
- /** The file as stored, or an empty v2 store when absent. Malformed JSON propagates. */
16282
+ /**
16283
+ * The anchor an import lands on, checked against the snapshot. Throws
16284
+ * UnquotableError for a range that cannot be quoted or a file the review lacks.
16285
+ */
16286
+ async function placeImport(t, source) {
16287
+ if (t.startLine == null) {
16288
+ if (!await source.hasFile(t.path)) throw new UnquotableError(`${t.path} is not in the review`);
16289
+ return {
16290
+ kind: "file",
16291
+ path: t.path
16292
+ };
16293
+ }
16294
+ const side = t.side ?? "new";
16295
+ const startLine = t.startLine;
16296
+ const endLine = t.endLine ?? startLine;
16297
+ const fromSnapshot = await source.quote(t.path, side, startLine, endLine);
16298
+ if (fromSnapshot == null) throw new UnquotableError(`cannot quote ${t.path}:${startLine}${endLine !== startLine ? `-${endLine}` : ""} on the ${side} side`);
16299
+ return {
16300
+ kind: "line",
16301
+ path: t.path,
16302
+ side,
16303
+ startLine,
16304
+ endLine,
16305
+ quoted: t.quoted ?? fromSnapshot
16306
+ };
16307
+ }
16308
+ /** The file as stored, or an empty store when absent. Malformed JSON propagates. */
16192
16309
  async function readStoreFile(file) {
16193
16310
  try {
16194
16311
  return parseStoreFile(await readFile(file, "utf8"));
@@ -16196,20 +16313,26 @@ async function readStoreFile(file) {
16196
16313
  if (e.code !== "ENOENT") throw e;
16197
16314
  }
16198
16315
  return {
16199
- version: 2,
16316
+ version: 3,
16200
16317
  sets: {}
16201
16318
  };
16202
16319
  }
16203
- /** Unknown shapes read as an empty v2 store. */
16320
+ /** Unknown shapes read as an empty store. */
16204
16321
  function parseStoreFile(text) {
16205
16322
  const parsed = JSON.parse(text);
16323
+ if (parsed.version === 3 && parsed.sets) return parsed;
16206
16324
  if (parsed.version === 2 && parsed.sets) return parsed;
16207
16325
  if (parsed.version === 1 && parsed.sets) return parsed;
16208
16326
  return {
16209
- version: 2,
16327
+ version: 3,
16210
16328
  sets: {}
16211
16329
  };
16212
16330
  }
16331
+ /** Any stored format as the current one. */
16332
+ function upgrade(all) {
16333
+ if (all.version === 3) return all;
16334
+ return migrateV2(all.version === 2 ? all : migrateV1(all));
16335
+ }
16213
16336
  /** Atomic write. Quoted source lines: owner-only. */
16214
16337
  function writeStoreFile(file, data) {
16215
16338
  return writeFileAtomic(file, JSON.stringify(data, null, 2), 384);
@@ -16223,6 +16346,24 @@ function newMessage(msg) {
16223
16346
  updatedAt: now
16224
16347
  };
16225
16348
  }
16349
+ /** Every v2 anchor is a line range; v3 says so. */
16350
+ function migrateV2(v2) {
16351
+ const sets = {};
16352
+ for (const [key, set] of Object.entries(v2.sets)) sets[key] = {
16353
+ threads: (set.threads ?? []).map((t) => ({
16354
+ ...t,
16355
+ anchor: {
16356
+ kind: "line",
16357
+ ...t.anchor
16358
+ }
16359
+ })),
16360
+ viewed: set.viewed ?? []
16361
+ };
16362
+ return {
16363
+ version: 3,
16364
+ sets
16365
+ };
16366
+ }
16226
16367
  /** Each v1 comment becomes a single-message, unresolved thread. */
16227
16368
  function migrateV1(v1) {
16228
16369
  const sets = {};
@@ -16353,7 +16494,7 @@ function createApi(deps) {
16353
16494
  });
16354
16495
  app.post("/api/threads", async (c) => {
16355
16496
  const imports = parseImports(await readJson(c));
16356
- const { added } = await session.comments.importThreads(imports, session.quoter());
16497
+ const { added } = await session.comments.importThreads(imports, session.anchorSource());
16357
16498
  if (added.length) hub.broadcast({ type: "threads" });
16358
16499
  return c.json(added, 201);
16359
16500
  });
@@ -16501,12 +16642,13 @@ var Server = class {
16501
16642
  }
16502
16643
  async listen() {
16503
16644
  const app = new Hono();
16504
- const guard = requestGuard(this.opts.host);
16645
+ const guard = requestGuard(this.opts.host, void 0, this.opts.allowedOrigin);
16505
16646
  app.use("/api/*", async (c, next) => {
16506
- if (!guard({
16647
+ const rejected = guard({
16507
16648
  host: c.req.header("host"),
16508
16649
  origin: c.req.header("origin")
16509
- })) return c.json({ error: "forbidden origin" }, 403);
16650
+ });
16651
+ if (rejected != null) return c.json({ error: rejected }, 403);
16510
16652
  await next();
16511
16653
  });
16512
16654
  app.use("/api/*", async (c, next) => {
@@ -16896,6 +17038,8 @@ function looksGenerated(path, head) {
16896
17038
  //#endregion
16897
17039
  //#region src/server/Snapshotter.ts
16898
17040
  const PREWARM = 3;
17041
+ /** The gitattribute GitHub reads to collapse generated files; `=false` opts a file out of the heuristics. */
17042
+ const GENERATED_ATTR = "linguist-generated";
16899
17043
  /** Concurrent worktree reads while flagging generated files. */
16900
17044
  const SNIFF_CONCURRENCY = 8;
16901
17045
  /** Sniff verdicts remembered by blob sha; the map is emptied, not evicted, past this. */
@@ -17004,14 +17148,23 @@ var Snapshotter = class {
17004
17148
  return snap;
17005
17149
  }
17006
17150
  /**
17007
- * Sets `generated` on each non-binary changed file: by path first, then by a
17008
- * SNIFF_BYTES content sniff. Blobs already classified are not read again;
17009
- * commit-side blobs go through one `cat-file --batch`, worktree files through
17010
- * a bounded read each. Never fails the snapshot: a read error means false.
17151
+ * Sets `generated` on each non-binary changed file. A `linguist-generated`
17152
+ * gitattribute on the new side decides outright, as on GitHub; otherwise a
17153
+ * path convention, then a SNIFF_BYTES content sniff. Blobs already sniffed
17154
+ * are not read again; commit-side blobs go through one `cat-file --batch`,
17155
+ * worktree files through a bounded read each. Never fails the snapshot: a
17156
+ * failed attribute lookup or read means the next heuristic, then false.
17011
17157
  * Submodules have no contents to sniff.
17012
17158
  */
17013
17159
  async fillGenerated(files, newSha) {
17014
- const sniff = files.filter((f) => f.status !== "D" && !f.binary && !f.submodule).filter((f) => !(f.generated = looksGenerated(f.path, null)));
17160
+ const textual = files.filter((f) => f.status !== "D" && !f.binary && !f.submodule);
17161
+ const marked = await this.repo.attr(GENERATED_ATTR, textual.map((f) => f.path), newSha).catch(() => /* @__PURE__ */ new Map());
17162
+ const sniff = [];
17163
+ for (const f of textual) {
17164
+ const attr = marked.get(f.path);
17165
+ if (attr != null) f.generated = attr;
17166
+ else if (!(f.generated = looksGenerated(f.path, null))) sniff.push(f);
17167
+ }
17015
17168
  const unknown = [];
17016
17169
  for (const f of sniff) {
17017
17170
  const known = f.blob ? this.sniffed.get(f.blob) : void 0;
@@ -17236,21 +17389,24 @@ var Session = class {
17236
17389
  /**
17237
17390
  * Re-anchors threads against what `snap` shows. A changed file shows its
17238
17391
  * hunks; an unchanged tree file shows its new side whole (the file view) and
17239
- * nothing of its old side.
17392
+ * nothing of its old side. A file thread only needs its file in the review.
17240
17393
  */
17241
17394
  async relocateComments(a, snap) {
17242
- await a.comments.relocateAll(async (path, side) => {
17243
- const buf = await this.readSide(snap, path, side);
17244
- if (buf == null) return null;
17245
- const contents = buf.toString("utf8");
17246
- if (!snap.changed.some((f) => f.path === path)) return side === "new" ? {
17247
- contents,
17248
- shown: null
17249
- } : null;
17250
- return {
17251
- contents,
17252
- shown: shownRanges(await a.snapshotter.patch(path) ?? "", side)
17253
- };
17395
+ await a.comments.relocateAll({
17396
+ side: async (path, side) => {
17397
+ const buf = await this.readSide(snap, path, side);
17398
+ if (buf == null) return null;
17399
+ const contents = buf.toString("utf8");
17400
+ if (!snap.changed.some((f) => f.path === path)) return side === "new" ? {
17401
+ contents,
17402
+ shown: null
17403
+ } : null;
17404
+ return {
17405
+ contents,
17406
+ shown: shownRanges(await a.snapshotter.patch(path) ?? "", side)
17407
+ };
17408
+ },
17409
+ hasFile: (path) => this.hasFile(snap, path)
17254
17410
  });
17255
17411
  }
17256
17412
  /**
@@ -17269,15 +17425,24 @@ var Session = class {
17269
17425
  hasSide(snap, path, side) {
17270
17426
  return sidePath(this.readablePaths(snap), path, side) != null;
17271
17427
  }
17428
+ /** Whether `path` is a file of the review: a changed file (by its new path) or a tree path. A rename's old path is not. */
17429
+ hasFile(snap, path) {
17430
+ const r = this.readablePaths(snap);
17431
+ return r.tree.has(path) || r.changed.has(path);
17432
+ }
17272
17433
  /**
17273
- * Quotes a line range from the current snapshot, for imports that carry no
17274
- * `quoted`. Goes through `readSide`, so the allowlist applies.
17434
+ * Places imported anchors against the current snapshot: quotes a line range for
17435
+ * imports that carry no `quoted`, and says whether a file thread's file is in the
17436
+ * review. Goes through `readSide`, so the allowlist applies.
17275
17437
  */
17276
- quoter() {
17277
- return async (path, side, startLine, endLine) => {
17278
- const snap = await this.snapshotter.current();
17279
- const buf = await this.readSide(snap, path, side);
17280
- return buf == null ? null : quoteRange(buf.toString("utf8"), startLine, endLine);
17438
+ anchorSource() {
17439
+ return {
17440
+ quote: async (path, side, startLine, endLine) => {
17441
+ const snap = await this.snapshotter.current();
17442
+ const buf = await this.readSide(snap, path, side);
17443
+ return buf == null ? null : quoteRange(buf.toString("utf8"), startLine, endLine);
17444
+ },
17445
+ hasFile: async (path) => this.hasFile(await this.snapshotter.current(), path)
17281
17446
  };
17282
17447
  }
17283
17448
  readablePaths(snap) {
@@ -17329,7 +17494,7 @@ var Session = class {
17329
17494
  }
17330
17495
  };
17331
17496
  async function defaultWatcherFactory() {
17332
- const { Watcher } = await import("./Watcher-C_X03LRa.js");
17497
+ const { Watcher } = await import("./Watcher-CKwvGuPB.js");
17333
17498
  return (target) => new Watcher(target);
17334
17499
  }
17335
17500
  function readablePaths(snap) {
@@ -21017,14 +21182,16 @@ var WsHub = class {
21017
21182
  maxPayload: MAX_PAYLOAD_BYTES
21018
21183
  });
21019
21184
  clientListeners = /* @__PURE__ */ new Set();
21020
- attach(server, guard = () => true) {
21185
+ attach(server, guard = () => null) {
21021
21186
  server.on("upgrade", (req, socket, head) => {
21022
21187
  if (new URL(req.url ?? "/", "http://localhost").pathname !== "/ws") return;
21023
- if (!guard({
21188
+ const rejected = guard({
21024
21189
  host: req.headers.host,
21025
21190
  origin: req.headers.origin
21026
- })) {
21027
- socket.destroy();
21191
+ });
21192
+ if (rejected != null) {
21193
+ socket.once("finish", () => socket.destroy());
21194
+ socket.end(`HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Type: text/plain\r\n\r\n${rejected}\n`);
21028
21195
  return;
21029
21196
  }
21030
21197
  this.wss.handleUpgrade(req, socket, head, (ws) => {
@@ -21067,6 +21234,16 @@ function intArg(min, max) {
21067
21234
  }
21068
21235
  const parsePort = intArg(0, 65535);
21069
21236
  const parseContext = intArg(0, 1e4);
21237
+ /** Accepts an HTTP(S) origin without credentials, a path prefix, query, or fragment. */
21238
+ function parseAllowedOrigin(raw) {
21239
+ try {
21240
+ const url = new URL(raw);
21241
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.pathname !== "/" || url.search || url.hash || !/^https?:\/\/[^/?#]+\/?$/i.test(raw)) throw new Error("invalid origin");
21242
+ return url.origin;
21243
+ } catch {
21244
+ throw new InvalidArgumentError("expected an HTTP(S) origin, e.g. https://proxy.example (without a path)");
21245
+ }
21246
+ }
21070
21247
  /** Commander parser for a language diffle knows; anything else lists the ones it does. */
21071
21248
  function parseLanguage(raw) {
21072
21249
  const language = raw.trim().toLowerCase();
@@ -21238,7 +21415,7 @@ const c = {
21238
21415
  bold: paint("1"),
21239
21416
  dim: paint("2")
21240
21417
  };
21241
- const program = new Command().name("diffle").description("Review a git diff in the browser and export line comments as an agent prompt.").version(version$1, "-v, --version", "print the version and exit").addOption(completionHint(new Option("-C <path>", "run as if started in <path> (any directory inside a git worktree)"), { kind: "directory" })).option("-p, --port <port>", `port to listen on (default: ${DEFAULT_PORT}, or the next free one; 0 = random)`, parsePort).option("-H, --host <host>", "address to bind; use 0.0.0.0 to expose on the network", "127.0.0.1").option("--no-open", "do not open a browser").option("--keep-alive", "keep the server running after all browser tabs close").option("--no-watch", "do not watch for changes").addOption(new Option("--auto-viewed <glob>", "mark matching files viewed for this session (repeatable)").argParser(collect).default([], "none")).option("-U, --context <n>", "context lines around changes for this session (default: config, 5)", parseContext).addOption(new Option("--lsp <language=command>", "language server command for one language, started for this run (repeatable); by default the diff's languages are served by whatever is on PATH").argParser(collectLspOverride).default(true, "the diff's languages, resolved on PATH")).option("--no-lsp", "do not start any language server").option("--timing", "print startup phase timings to stderr").option("--dev", "serve the client through Vite (development)", process.env.DIFFLE_DEV === "1" ? true : void 0).addArgument(completionHint(new Argument("[revs...]", "git-diff style revisions: <rev> | <a>..<b> | <a>...<b> | <a> <b>"), {
21418
+ const program = new Command().exitOverride().name("diffle").description("Review a git diff in the browser and export line comments as an agent prompt.").version(version$1, "-v, --version", "print the version and exit").addOption(completionHint(new Option("-C <path>", "run as if started in <path> (any directory inside a git worktree)"), { kind: "directory" })).option("-p, --port <port>", `port to listen on (default: ${DEFAULT_PORT}, or the next free one; 0 = random)`, parsePort).option("-H, --host <host>", "address to bind; use 0.0.0.0 to expose on the network", "127.0.0.1").option("--allowed-origin <origin>", "trusted public HTTP(S) origin behind a reverse proxy (without a path)", parseAllowedOrigin).option("--no-open", "do not open a browser").option("--keep-alive", "keep the server running after all browser tabs close").option("--no-watch", "do not watch for changes").addOption(new Option("--auto-viewed <glob>", "mark matching files viewed for this session (repeatable)").argParser(collect).default([], "none")).option("-U, --context <n>", "context lines around changes for this session (default: config, 5)", parseContext).addOption(new Option("--lsp <language=command>", "language server command for one language, started for this run (repeatable); by default the diff's languages are served by whatever is on PATH").argParser(collectLspOverride).default(true, "the diff's languages, resolved on PATH")).option("--no-lsp", "do not start any language server").option("--timing", "print startup phase timings to stderr").option("--dev", "serve the client through Vite (development)", process.env.DIFFLE_DEV === "1" ? true : void 0).addArgument(completionHint(new Argument("[revs...]", "git-diff style revisions: <rev> | <a>..<b> | <a>...<b> | <a> <b>"), {
21242
21419
  kind: "choices",
21243
21420
  values: ["working", "pr"]
21244
21421
  })).addHelpText("after", `
@@ -21374,6 +21551,7 @@ async function serve(req, opts, repo, closeRepo, config, timing) {
21374
21551
  port: opts.port ?? 4966,
21375
21552
  probe: opts.port == null,
21376
21553
  host: opts.host,
21554
+ allowedOrigin: opts.allowedOrigin,
21377
21555
  dev: opts.dev || !hasClientBuild()
21378
21556
  });
21379
21557
  let stopBrowserWatch = () => {};
@@ -21471,9 +21649,8 @@ function startLsp(repo, session, hub, overrides, preload) {
21471
21649
  });
21472
21650
  return lsp;
21473
21651
  }
21474
- program.exitOverride();
21475
21652
  program.parseAsync(process.argv).catch((e) => {
21476
- if (e instanceof CommanderError) process.exit(e.exitCode);
21653
+ if (e instanceof CommanderError) process.exit(e.exitCode === 0 ? 0 : 2);
21477
21654
  console.error(`${c.red("✖")} ${e instanceof Error ? e.message : e}`);
21478
21655
  process.exit(e instanceof RevspecError || e instanceof GitError || e instanceof GithubError ? 2 : 1);
21479
21656
  });