@moritzwilksch/diffle 0.1.9 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +5 -2
  2. package/dist/client/assets/{angular-html-CJY2n6PT.js → angular-html-ulrXXC4T.js} +1 -1
  3. package/dist/client/assets/{angular-ts-CZTM8_xo.js → angular-ts-cWUkbqrc.js} +1 -1
  4. package/dist/client/assets/{apl-Dcp77-Tz.js → apl-CYwKvDlF.js} +1 -1
  5. package/dist/client/assets/{astro-C0gdsdoF.js → astro-BleB-Dvw.js} +1 -1
  6. package/dist/client/assets/{blade-BYpI0fWU.js → blade-UxS1gLIJ.js} +1 -1
  7. package/dist/client/assets/{c-DvFVe1b3.js → c-Dy61fyeR.js} +1 -1
  8. package/dist/client/assets/{chapel-qAWjGK-V.js → chapel-DA_5Jb29.js} +1 -1
  9. package/dist/client/assets/{cobol-BeFQKGES.js → cobol-DF_09Fay.js} +1 -1
  10. package/dist/client/assets/{coffee-XLni1sjM.js → coffee-Cv1DVgPp.js} +1 -1
  11. package/dist/client/assets/{cpp-ChsJNYBC.js → cpp-BlnMjTh9.js} +1 -1
  12. package/dist/client/assets/{crystal-D3SbG8yD.js → crystal-B8onZGWh.js} +1 -1
  13. package/dist/client/assets/{css-VXnGhPbY.js → css-D-x-Ue0G.js} +1 -1
  14. package/dist/client/assets/{edge-Dbzmpk4W.js → edge-Fqle09cH.js} +1 -1
  15. package/dist/client/assets/{elixir-DvQULd_0.js → elixir-CL3sU1gy.js} +1 -1
  16. package/dist/client/assets/{elm-BM5mpULp.js → elm-Blr9Ia2N.js} +1 -1
  17. package/dist/client/assets/{erb-nYCgf5TS.js → erb-Ck5t-jn2.js} +1 -1
  18. package/dist/client/assets/{git-rebase-CcDsi2L8.js → git-rebase-Dt2TndDV.js} +1 -1
  19. package/dist/client/assets/{glimmer-js-94igFmPU.js → glimmer-js-Cwn-aFNH.js} +1 -1
  20. package/dist/client/assets/{glimmer-ts-8aOBlhVe.js → glimmer-ts-CKev0hwM.js} +1 -1
  21. package/dist/client/assets/{glsl-DAW_sXZj.js → glsl-ClWhfdlL.js} +1 -1
  22. package/dist/client/assets/{graphql-BEeRZxhy.js → graphql-C8TyhWXe.js} +1 -1
  23. package/dist/client/assets/{hack-e4pIPigB.js → hack-C9oIcSTK.js} +1 -1
  24. package/dist/client/assets/{haml-DvZH4eyc.js → haml-BP7tD858.js} +1 -1
  25. package/dist/client/assets/{handlebars-Bod10MIb.js → handlebars-D7ZldTVB.js} +1 -1
  26. package/dist/client/assets/{html-BOKVgXUz.js → html-BxXjaAye.js} +1 -1
  27. package/dist/client/assets/{html-derivative-B5DftdEs.js → html-derivative-CEoRM3ro.js} +1 -1
  28. package/dist/client/assets/{http-Bli9WHgJ.js → http-DVWDINjr.js} +1 -1
  29. package/dist/client/assets/{hurl-C45hn_gr.js → hurl-BjNoiLI6.js} +1 -1
  30. package/dist/client/assets/index-C-uvIS11.css +2 -0
  31. package/dist/client/assets/index-DrpXuuVn.js +3005 -0
  32. package/dist/client/assets/{java-BxezylI4.js → java-Cbx3IDuw.js} +1 -1
  33. package/dist/client/assets/{javascript-CGUJoh42.js → javascript-C1JF7iBX.js} +1 -1
  34. package/dist/client/assets/{jinja-Dhrq1TMH.js → jinja-BMe2GcVb.js} +1 -1
  35. package/dist/client/assets/{jison-CK02w6aE.js → jison-BVbXCft7.js} +1 -1
  36. package/dist/client/assets/{json-DbjMq68q.js → json-CRqSOZnD.js} +1 -1
  37. package/dist/client/assets/{jsx-BVYjBFnX.js → jsx-DcDmlRfr.js} +1 -1
  38. package/dist/client/assets/{julia-DUG0dPfw.js → julia-CPcuPmIV.js} +1 -1
  39. package/dist/client/assets/{just-BzILq64e.js → just-B1zV9CiR.js} +1 -1
  40. package/dist/client/assets/{latex-DJ-r-_gD.js → latex-Cuj3EXmX.js} +1 -1
  41. package/dist/client/assets/{liquid-CUeuBds8.js → liquid-C1T4UF1w.js} +1 -1
  42. package/dist/client/assets/{lua-CUx56wFu.js → lua-46-qG00m.js} +1 -1
  43. package/dist/client/assets/{marko-zidFMx4y.js → marko-Cc6TCGIc.js} +1 -1
  44. package/dist/client/assets/{mdc-B8YDeE-G.js → mdc-qnFt69dC.js} +1 -1
  45. package/dist/client/assets/{nginx-CLmnmibq.js → nginx-7n4L4W73.js} +1 -1
  46. package/dist/client/assets/{nim-D_ow61_F.js → nim-BLvMDnWX.js} +1 -1
  47. package/dist/client/assets/{org-D6yFID6y.js → org-C8fiTt0a.js} +1 -1
  48. package/dist/client/assets/{perl-DlRNqEum.js → perl-x9VdolYn.js} +1 -1
  49. package/dist/client/assets/{php-CPe77eH-.js → php-mFFqmEsM.js} +1 -1
  50. package/dist/client/assets/{pug-CzDq8hOp.js → pug-H1OMV43P.js} +1 -1
  51. package/dist/client/assets/{qml-CmVqwUZS.js → qml-B8D1BoQx.js} +1 -1
  52. package/dist/client/assets/{r-CZql-PDg.js → r-DAih_iaQ.js} +1 -1
  53. package/dist/client/assets/{razor-Dp5yql5G.js → razor-BeJgOpe8.js} +1 -1
  54. package/dist/client/assets/{regexp-DjyDhvtX.js → regexp-CAppT72h.js} +1 -1
  55. package/dist/client/assets/{rst-B-Q6Ljb6.js → rst-RMbVAAkg.js} +1 -1
  56. package/dist/client/assets/{ruby-BQD6FS2h.js → ruby-DLm1ZwEN.js} +1 -1
  57. package/dist/client/assets/{sas-DDGcym6k.js → sas-B7ihlrSP.js} +1 -1
  58. package/dist/client/assets/{scss-DNPPx0rx.js → scss-3DF1Is0M.js} +1 -1
  59. package/dist/client/assets/{shellscript-CHWpWo83.js → shellscript-Bq08mUIc.js} +1 -1
  60. package/dist/client/assets/{shellsession-_KE7LUMO.js → shellsession-DJY5846a.js} +1 -1
  61. package/dist/client/assets/{soy-B57xVh7W.js → soy-Bkld_lfE.js} +1 -1
  62. package/dist/client/assets/{sql-8OXlDHCR.js → sql-B35yhKok.js} +1 -1
  63. package/dist/client/assets/{stata-C452rT5l.js → stata-BgW5egxh.js} +1 -1
  64. package/dist/client/assets/{surrealql-B5gNeOUk.js → surrealql-B-aUVDek.js} +1 -1
  65. package/dist/client/assets/{svelte-Cv_BwjgR.js → svelte-C7MrEpG_.js} +1 -1
  66. package/dist/client/assets/{templ-qZdOs42N.js → templ-CUYQL3Op.js} +1 -1
  67. package/dist/client/assets/{tex-BK-MJpDM.js → tex-Dbrd9bB3.js} +1 -1
  68. package/dist/client/assets/{ts-tags-C5Q-Olrs.js → ts-tags-wpbSYL5U.js} +1 -1
  69. package/dist/client/assets/{tsx-DwPm0lid.js → tsx-B8rF--RV.js} +1 -1
  70. package/dist/client/assets/{twig-DKj0IG75.js → twig-CpaRBIem.js} +1 -1
  71. package/dist/client/assets/{typescript-BeWKsdVP.js → typescript-cqbK9R71.js} +1 -1
  72. package/dist/client/assets/{typst-B7u-3Y0n.js → typst-BUVX7J-L.js} +1 -1
  73. package/dist/client/assets/{vue-BkC1F40W.js → vue-B3rM_CiH.js} +1 -1
  74. package/dist/client/assets/{vue-html-BeYXHyr2.js → vue-html-uBxsJ3nV.js} +1 -1
  75. package/dist/client/assets/{vue-vine-ymsHpgRC.js → vue-vine-CH44uMD8.js} +1 -1
  76. package/dist/client/assets/{xml-B6M3xnfe.js → xml-BnoqKVtb.js} +1 -1
  77. package/dist/client/assets/{xsl-CSlbm6tK.js → xsl-r6Si1Xjc.js} +1 -1
  78. package/dist/client/assets/{yaml-fMwD58zK.js → yaml-tinlnfn7.js} +1 -1
  79. package/dist/client/index.html +2 -2
  80. package/dist/server/main.js +931 -55
  81. package/package.json +1 -1
  82. package/dist/client/assets/index-BugDKSp3.js +0 -2994
  83. package/dist/client/assets/index-CvgG7WNQ.css +0 -2
@@ -3952,7 +3952,7 @@ function generateCompletion(program, { shell, executable = program.name() }) {
3952
3952
  }
3953
3953
  //#endregion
3954
3954
  //#region package.json
3955
- var version$1 = "0.1.9";
3955
+ var version$1 = "0.2.1";
3956
3956
  //#endregion
3957
3957
  //#region src/server/comments/anchor.ts
3958
3958
  function splitLines(contents) {
@@ -4109,6 +4109,12 @@ function patchArgs(oldRev) {
4109
4109
  }
4110
4110
  /** An idle `cat-file --batch` is reaped after this long; the next request respawns one. */
4111
4111
  const CAT_FILE_IDLE_MS = 2e3;
4112
+ /**
4113
+ * `git range-diff` pairs two commits while the diff between their patches costs less than this
4114
+ * percentage of a patch's size. Git's default of 60 leaves a reworded three-line commit unpaired,
4115
+ * since the message change alone outweighs it; at 100 the message may change as much as the patch.
4116
+ */
4117
+ const CREATION_FACTOR = 100;
4112
4118
  var GitError = class extends Error {
4113
4119
  args;
4114
4120
  code;
@@ -4185,6 +4191,16 @@ var GitRepo = class GitRepo {
4185
4191
  `${rev}^{commit}`
4186
4192
  ])).trim();
4187
4193
  }
4194
+ /** The tree a commit points at. */
4195
+ async tree(rev) {
4196
+ return (await this.text([
4197
+ "rev-parse",
4198
+ "--verify",
4199
+ "--quiet",
4200
+ "--end-of-options",
4201
+ `${rev}^{tree}`
4202
+ ])).trim();
4203
+ }
4188
4204
  /** Commit messages for two ancestor offsets, pinned to one HEAD. */
4189
4205
  async lastCommitsPreview(oldOffset, newOffset) {
4190
4206
  const head = await this.commitInfo("HEAD");
@@ -4300,6 +4316,89 @@ var GitRepo = class GitRepo {
4300
4316
  b
4301
4317
  ])).trim();
4302
4318
  }
4319
+ /**
4320
+ * Replays what `head` changed since `base` onto `onto`, in memory: the resulting tree, and the
4321
+ * paths the merge could not settle, which the tree holds with conflict markers. `--write-tree`
4322
+ * needs git 2.38. With `-z`, the tree, each conflicted path and the sections end in NUL.
4323
+ */
4324
+ async replay(base, onto, head) {
4325
+ const [tree = "", ...rest] = (await this.text([
4326
+ "merge-tree",
4327
+ "--write-tree",
4328
+ "-z",
4329
+ "--name-only",
4330
+ `--merge-base=${base}`,
4331
+ "--end-of-options",
4332
+ onto,
4333
+ head
4334
+ ], { okCodes: [1] })).split("\0");
4335
+ const conflicts = [];
4336
+ for (const path of rest) {
4337
+ if (path === "") break;
4338
+ conflicts.push(path);
4339
+ }
4340
+ return {
4341
+ tree,
4342
+ conflicts
4343
+ };
4344
+ }
4345
+ /**
4346
+ * How `git range-diff` pairs the commits of two ranges, in its output order: abbreviated
4347
+ * hashes, null for the side a commit has no counterpart on, and its marker (`=` identical,
4348
+ * `!` changed, `<` only in the first range, `>` only in the second). Summary only, no patches.
4349
+ * Ranges are given as hashes: range-diff learnt `--end-of-options` only in git 2.44.
4350
+ */
4351
+ async rangeDiff(oldRange, newRange) {
4352
+ const out = await this.text([
4353
+ "range-diff",
4354
+ "-s",
4355
+ "--no-color",
4356
+ `--creation-factor=${CREATION_FACTOR}`,
4357
+ oldRange,
4358
+ newRange
4359
+ ]);
4360
+ const pairs = [];
4361
+ for (const line of out.split("\n")) {
4362
+ const m = /^\s*(?:\d+:\s+([0-9a-f]+)|-:\s+-+)\s+([=!<>])\s+(?:\d+:\s+([0-9a-f]+)|-:\s+-+)\s/.exec(line);
4363
+ if (m) pairs.push({
4364
+ old: m[1] ?? null,
4365
+ new: m[3] ?? null,
4366
+ marker: m[2]
4367
+ });
4368
+ }
4369
+ return pairs;
4370
+ }
4371
+ /** Each commit's stable patch id, so two commits with the same change under different messages compare equal. */
4372
+ async patchIds(shas) {
4373
+ const ids = /* @__PURE__ */ new Map();
4374
+ if (!shas.length) return ids;
4375
+ const patches = await this.exec([
4376
+ "log",
4377
+ "-p",
4378
+ "--no-walk",
4379
+ "--no-show-signature",
4380
+ "--format=%H",
4381
+ "--end-of-options",
4382
+ ...shas
4383
+ ]);
4384
+ for (const line of (await this.text(["patch-id", "--stable"], { input: patches })).split("\n")) {
4385
+ const [id, sha] = line.split(" ");
4386
+ if (id && sha) ids.set(sha, id);
4387
+ }
4388
+ return ids;
4389
+ }
4390
+ /**
4391
+ * Points refs at objects so they survive `gc` and a force-push, or with null deletes them. Refs
4392
+ * must stay inside `refs/diffle/iterations/`: nothing the user owns moves.
4393
+ */
4394
+ async pin(refs) {
4395
+ const lines = [];
4396
+ for (const [ref, sha] of Object.entries(refs)) {
4397
+ if (!ref.startsWith("refs/diffle/iterations/")) throw new GitError(`refusing to update ${ref}`, ["update-ref"], null, "");
4398
+ lines.push(sha === null ? `delete ${ref}\n` : `update ${ref} ${sha}\n`);
4399
+ }
4400
+ if (lines.length) await this.exec(["update-ref", "--stdin"], { input: lines.join("") });
4401
+ }
4303
4402
  /** origin/HEAD → main → master. */
4304
4403
  async defaultBranch() {
4305
4404
  try {
@@ -11890,6 +11989,20 @@ const ZodRecord = /*@__PURE__*/ $constructor("ZodRecord", (inst, def) => {
11890
11989
  inst.keyType = def.keyType;
11891
11990
  inst.valueType = def.valueType;
11892
11991
  });
11992
+ function record(keyType, valueType, params) {
11993
+ if (!valueType || !valueType._zod) return new ZodRecord({
11994
+ type: "record",
11995
+ keyType: string(),
11996
+ valueType: keyType,
11997
+ ...normalizeParams(valueType)
11998
+ });
11999
+ return new ZodRecord({
12000
+ type: "record",
12001
+ keyType,
12002
+ valueType,
12003
+ ...normalizeParams(params)
12004
+ });
12005
+ }
11893
12006
  function partialRecord(keyType, valueType, params) {
11894
12007
  return new ZodRecord({
11895
12008
  type: "record",
@@ -12165,9 +12278,35 @@ const ModeRequestSchema = discriminatedUnion("kind", [
12165
12278
  object({
12166
12279
  kind: literal("focus"),
12167
12280
  commit: string().nullable()
12281
+ }),
12282
+ object({
12283
+ kind: literal("interdiff"),
12284
+ from: number().int().positive(),
12285
+ to: number().int().positive()
12286
+ }),
12287
+ object({
12288
+ kind: literal("range-diff"),
12289
+ args: string().array()
12290
+ }),
12291
+ object({
12292
+ kind: literal("pair"),
12293
+ commit: string().nullable()
12168
12294
  })
12169
12295
  ]);
12170
12296
  /**
12297
+ * One state of a reviewed range: its resolved endpoints when a snapshot of it was taken. A new
12298
+ * one is recorded whenever the endpoints differ from the last, so a push, rebase or amend
12299
+ * yields the next iteration; the commits are pinned under `refs/diffle/iterations/`.
12300
+ */
12301
+ const IterationSchema = object({
12302
+ /** 1-based, in recording order. */
12303
+ n: number(),
12304
+ oldSha: string(),
12305
+ newSha: string(),
12306
+ /** Epoch milliseconds. */
12307
+ recordedAt: number()
12308
+ });
12309
+ /**
12171
12310
  * How the old side is derived: `old` itself, merge-base(old, new) with worktree
12172
12311
  * taken as HEAD, or new's first parent (the empty tree for a root commit).
12173
12312
  */
@@ -12189,18 +12328,118 @@ const ComparisonSchema = object({
12189
12328
  /** Fixed when the comparison is entered; discovery never changes comment storage. */
12190
12329
  commentKey: string()
12191
12330
  });
12192
- const ModeSpecSchema = ComparisonSchema.extend({
12193
- /** Set while one commit of a range is focused: the range, whose commits the snapshot keeps listing. */
12194
- within: ComparisonSchema.optional() });
12331
+ const CommitInfoSchema = object({
12332
+ sha: string(),
12333
+ short: string(),
12334
+ message: string()
12335
+ });
12336
+ /** A single reviewed commit and its neighbours along HEAD's first-parent history, for stepping through it. */
12337
+ const ReviewedCommitSchema = CommitInfoSchema.extend({
12338
+ /** First parent; null for a root commit. */
12339
+ parent: string().nullable(),
12340
+ /** The commit whose first parent this is, toward HEAD; null at HEAD or off HEAD's first-parent line. */
12341
+ child: string().nullable()
12342
+ });
12343
+ /** One commit of the compared range. */
12344
+ const RangeCommitSchema = CommitInfoSchema.extend({
12345
+ author: string(),
12346
+ email: string(),
12347
+ /** Author date, epoch milliseconds. */
12348
+ date: number()
12349
+ });
12350
+ /** How a commit of the older iteration relates to one of the newer, as `git range-diff` pairs them. */
12351
+ const PairStatusSchema = _enum([
12352
+ "identical",
12353
+ "changed",
12354
+ "message",
12355
+ "added",
12356
+ "dropped"
12357
+ ]);
12358
+ /**
12359
+ * One row of a range-diff: `identical` and `changed` pairs have both commits, a `message` pair
12360
+ * the same patch under a new message, an `added` commit no old side and a `dropped` one no new side.
12361
+ */
12362
+ const RangePairSchema = object({
12363
+ old: RangeCommitSchema.nullable(),
12364
+ new: RangeCommitSchema.nullable(),
12365
+ status: PairStatusSchema
12366
+ });
12367
+ /**
12368
+ * One range of a range-diff, `old..new`: the revisions that named it, so moved refs can be
12369
+ * resolved again, and the commits they resolved to. A recorded iteration of the enclosing
12370
+ * range carries its number; a range the reviewer named carries null.
12371
+ */
12372
+ const RangeSideSchema = object({
12373
+ old: string(),
12374
+ new: string(),
12375
+ oldSha: string(),
12376
+ newSha: string(),
12377
+ iteration: number().nullable()
12378
+ });
12379
+ /**
12380
+ * Two ranges compared: two iterations of one range, or any two the reviewer named. The old
12381
+ * side is `from`'s head replayed onto `to`'s base (a tree, not a commit), so a rebase in
12382
+ * between does not show up as the upstream's changes; the new side is `to`'s head.
12383
+ */
12384
+ const InterdiffSchema = object({
12385
+ from: RangeSideSchema,
12386
+ to: RangeSideSchema,
12387
+ /** Paths the replay could not merge; they carry conflict markers on the old side. */
12388
+ conflicts: string().array(),
12389
+ /** The two ranges' commits paired, in `git range-diff` order: the newer range's, dropped commits where they were. */
12390
+ pairs: RangePairSchema.array()
12391
+ });
12392
+ /**
12393
+ * One pair of an interdiff shown alone: `new` against `old` replayed onto `new`'s parent, so the
12394
+ * pair's own amendments show without the rebase; an added commit (`old` null) against its parent.
12395
+ */
12396
+ const PairSpecSchema = object({
12397
+ old: string().nullable(),
12398
+ new: string(),
12399
+ conflicts: string().array()
12400
+ });
12401
+ const ModeSpecSchema = ComparisonSchema.extend({
12402
+ /**
12403
+ * Set while one commit of a range is focused or two ranges are compared: the range, whose commits the
12404
+ * snapshot keeps listing. For a range-diff it is the newer range, where the reviewer continues.
12405
+ */
12406
+ within: ComparisonSchema.optional(),
12407
+ interdiff: InterdiffSchema.optional(),
12408
+ /** Set with `interdiff` while one of its pairs is shown. */
12409
+ pair: PairSpecSchema.optional()
12410
+ });
12195
12411
  /**
12196
12412
  * Display comparison endpoints with branch names instead of internal ref namespaces; a single commit as
12197
- * `<sha>^!`, or as `<range> @ <sha>` when focused within a range.
12413
+ * `<sha>^!`, as `<range> @ <sha>` when focused within a range, as `<range> #1→#2` for two of its
12414
+ * iterations, as `<a>..<b> → <c>..<d>` for two named ranges, and with ` @ <sha>` for one pair of either.
12198
12415
  */
12199
12416
  function comparisonLabel(mode) {
12417
+ if (mode.within && mode.interdiff) {
12418
+ const { from, to } = mode.interdiff;
12419
+ const iterations = shownIterations(mode);
12420
+ const label = iterations ? `${comparisonLabel(mode.within)} #${iterations.from}→#${iterations.to}` : `${rangeLabel(from)} → ${rangeLabel(to)}`;
12421
+ return mode.pair ? `${label} @ ${mode.pair.new.slice(0, 7)}` : label;
12422
+ }
12200
12423
  if (mode.within) return `${comparisonLabel(mode.within)} @ ${mode.new.slice(0, 7)}`;
12201
12424
  if (mode.base === "parent") return `${mode.new.slice(0, 7)}^!`;
12202
- const name = (rev) => rev.replace(/^refs\/diffle\/[^/]+\/\d+\/(?:base|head)\//, "").replace(/^refs\/(?:heads|remotes)\//, "");
12203
- return `${name(mode.old)}${mode.base === "merge-base" ? "..." : ".."}${name(mode.new)}`;
12425
+ return `${refName(mode.old)}${mode.base === "merge-base" ? "..." : ".."}${refName(mode.new)}`;
12426
+ }
12427
+ /** One side of a range-diff as the reviewer named it, `<old>..<new>`, with full hashes abbreviated. */
12428
+ function rangeLabel(side) {
12429
+ const short = (rev) => /^[0-9a-f]{40}$/.test(rev) ? rev.slice(0, 7) : refName(rev);
12430
+ return `${short(side.old)}..${short(side.new)}`;
12431
+ }
12432
+ /** The iteration numbers an interdiff compares, or null when it compares ranges the reviewer named. */
12433
+ function shownIterations(mode) {
12434
+ const inter = mode.interdiff;
12435
+ return inter && inter.from.iteration != null && inter.to.iteration != null ? {
12436
+ from: inter.from.iteration,
12437
+ to: inter.to.iteration
12438
+ } : null;
12439
+ }
12440
+ /** A comparison endpoint as the user named it: without diffle's PR namespace or `refs/heads/`. */
12441
+ function refName(rev) {
12442
+ return rev.replace(/^refs\/diffle\/[^/]+\/\d+\/(?:base|head)\//, "").replace(/^refs\/(?:heads|remotes)\//, "");
12204
12443
  }
12205
12444
  const GithubPullRequestSchema = object({
12206
12445
  repository: string(),
@@ -12250,31 +12489,17 @@ const ChangedFileSchema = object({
12250
12489
  /** A gitlink (mode 160000): `blob` is the recorded commit, the patch shows the commit-id change, and there is no file to open. */
12251
12490
  submodule: literal(true).optional()
12252
12491
  });
12253
- const CommitInfoSchema = object({
12254
- sha: string(),
12255
- short: string(),
12256
- message: string()
12257
- });
12258
- /** A single reviewed commit and its neighbours along HEAD's first-parent history, for stepping through it. */
12259
- const ReviewedCommitSchema = CommitInfoSchema.extend({
12260
- /** First parent; null for a root commit. */
12261
- parent: string().nullable(),
12262
- /** The commit whose first parent this is, toward HEAD; null at HEAD or off HEAD's first-parent line. */
12263
- child: string().nullable()
12264
- });
12265
12492
  /**
12266
12493
  * The commits a comparison spans: old..new, with the worktree taken as HEAD, or the one commit under `parent`.
12267
12494
  * A focused commit lists its range's.
12268
12495
  */
12269
12496
  const RangeCommitsSchema = object({
12270
12497
  /** Oldest first; the newest MAX_RANGE_COMMITS when there are more. */
12271
- list: CommitInfoSchema.extend({
12272
- author: string(),
12273
- email: string(),
12274
- /** Author date, epoch milliseconds. */
12275
- date: number()
12276
- }).array(),
12277
- total: number()
12498
+ list: RangeCommitSchema.array(),
12499
+ total: number(),
12500
+ /** The spanned comparison's resolved endpoints: the snapshot's own, or the range's when a commit is focused. */
12501
+ oldSha: string(),
12502
+ newSha: string()
12278
12503
  });
12279
12504
  object({
12280
12505
  root: string(),
@@ -12293,7 +12518,20 @@ object({
12293
12518
  tree: string().array(),
12294
12519
  /** The reviewed commit when the comparison is a single commit against its parent; otherwise null. */
12295
12520
  commit: ReviewedCommitSchema.nullable(),
12296
- commits: RangeCommitsSchema
12521
+ commits: RangeCommitsSchema,
12522
+ /** The recorded iterations of the range `commits` spans, oldest first; empty for comparisons that are not followed. */
12523
+ iterations: IterationSchema.array()
12524
+ });
12525
+ /**
12526
+ * The compared refs resolve elsewhere than when the shown snapshot was taken. A refs-live
12527
+ * comparison is not recomputed under the reviewer; it reloads on request.
12528
+ */
12529
+ const MovedSchema = object({
12530
+ /** The snapshot a reload would supersede. */
12531
+ version: number(),
12532
+ /** Where the comparison `Snapshot.commits` spans resolves now. */
12533
+ oldSha: string(),
12534
+ newSha: string()
12297
12535
  });
12298
12536
  /** Body of `POST /api/patch`: the changed files whose patches to return, concatenated. Unknown paths are skipped. */
12299
12537
  const PatchRequestSchema = object({ paths: string().array().max(200) });
@@ -12425,11 +12663,24 @@ object({
12425
12663
  matches: SearchMatchSchema.array(),
12426
12664
  truncated: boolean()
12427
12665
  });
12666
+ /**
12667
+ * How a review between refs reacts when one of them moves. `on`: recompute at once, as a worktree
12668
+ * review follows edits. `off`: announce the move and wait for Reload. `auto`: wait for Reload when an
12669
+ * endpoint names a branch, whose pushes and rebases should not swap the review out under the
12670
+ * reviewer; recompute at once otherwise (a detached HEAD, a tag, an expression).
12671
+ */
12672
+ const FollowRefsSchema = _enum([
12673
+ "on",
12674
+ "auto",
12675
+ "off"
12676
+ ]);
12428
12677
  object({
12429
12678
  /** Globs (picomatch syntax) for files that start viewed + collapsed. Empty by default. */
12430
12679
  autoViewed: string().array(),
12431
12680
  /** Unchanged lines shown around each change (git -U). Default 5. */
12432
12681
  contextLines: number(),
12682
+ /** When a review between refs recomputes as its refs move; see `FollowRefsSchema`. Default auto. */
12683
+ followRefs: FollowRefsSchema,
12433
12684
  /**
12434
12685
  * Language server command per language, replacing the built-in candidate for it; an empty
12435
12686
  * string disables the language. Read-only over HTTP; set with `diffle config set-lsp`.
@@ -12439,6 +12690,7 @@ object({
12439
12690
  const DEFAULT_USER_CONFIG = {
12440
12691
  autoViewed: [],
12441
12692
  contextLines: 5,
12693
+ followRefs: "auto",
12442
12694
  lspCommands: {}
12443
12695
  };
12444
12696
  /** A point in a new-side file. LSP semantics: the column counts UTF-16 units, like a JS string index. */
@@ -12509,6 +12761,18 @@ object({
12509
12761
  object({
12510
12762
  /** Null when the position is not inside a token or the server does not classify tokens. */
12511
12763
  kind: string().nullable() });
12764
+ object({ occurrences: object({
12765
+ line: number().int().positive(),
12766
+ col: number().int().nonnegative(),
12767
+ endLine: number().int().positive(),
12768
+ endCol: number().int().nonnegative(),
12769
+ /** LSP DocumentHighlightKind: a plain textual match, a read, or a write. */
12770
+ kind: _enum([
12771
+ "text",
12772
+ "read",
12773
+ "write"
12774
+ ])
12775
+ }).array() });
12512
12776
  object({
12513
12777
  name: string(),
12514
12778
  /** LSP SymbolKind number; the client maps it to a label. */
@@ -12531,6 +12795,10 @@ discriminatedUnion("type", [
12531
12795
  type: literal("snapshot"),
12532
12796
  version: number()
12533
12797
  }),
12798
+ object({
12799
+ type: literal("moved"),
12800
+ moved: MovedSchema.nullable()
12801
+ }),
12534
12802
  object({ type: literal("threads") }),
12535
12803
  object({ type: literal("viewed") }),
12536
12804
  object({ type: literal("config") }),
@@ -12624,7 +12892,8 @@ function noServer(lsp, language) {
12624
12892
  /** Writable HTTP config fields; unknown keys, including CLI-only lspCommands, are stripped. */
12625
12893
  const ConfigUpdateSchema = object({
12626
12894
  autoViewed: string().array().optional(),
12627
- contextLines: number().int().min(0).max(1e4).optional()
12895
+ contextLines: number().int().min(0).max(1e4).optional(),
12896
+ followRefs: FollowRefsSchema.optional()
12628
12897
  });
12629
12898
  const ResolvedRequestSchema = object({ resolved: boolean() });
12630
12899
  const ViewedBulkRequestSchema = object({ entries: ViewedEntrySchema.array() });
@@ -12670,6 +12939,76 @@ const ClearThreadsQuerySchema = object({ stale: literal("1").optional() });
12670
12939
  const PatchQuerySchema = object({ path: string().optional() });
12671
12940
  object({ error: string() });
12672
12941
  //#endregion
12942
+ //#region src/shared/fuzzy.ts
12943
+ const MATCH = 1;
12944
+ const WORD_START = 8;
12945
+ const CONSECUTIVE = 4;
12946
+ const GAP = 1;
12947
+ /**
12948
+ * Score `candidate` against `query`, matched case-insensitively as an ordered subsequence;
12949
+ * null when it does not match. Higher is better: matches at word starts (after `_-./`, camelCase
12950
+ * humps, digit runs) and unbroken runs count most, so `AbsBuiFa` finds `AbstractBuilderFactory`
12951
+ * and `evruca` finds `evaluate_run_cases.py`.
12952
+ */
12953
+ function fuzzyScore(query, candidate) {
12954
+ const q = Array.from(query.toLowerCase());
12955
+ const chars = Array.from(candidate);
12956
+ const n = q.length;
12957
+ const m = chars.length;
12958
+ if (n === 0) return 0;
12959
+ if (n > m) return null;
12960
+ const lower = chars.map((ch) => ch.toLowerCase());
12961
+ const bonus = chars.map((ch, j) => MATCH + wordStart(chars[j - 1], ch));
12962
+ let prev = new Float64Array(m).fill(-Infinity);
12963
+ for (let i = 0; i < n; i++) {
12964
+ const cur = new Float64Array(m).fill(-Infinity);
12965
+ let carry = -Infinity;
12966
+ for (let j = i; j < m; j++) {
12967
+ if (i > 0) carry = Math.max(carry - GAP, prev[j - 1]);
12968
+ if (lower[j] !== q[i]) continue;
12969
+ cur[j] = (i === 0 ? 0 : Math.max(carry, prev[j - 1] + CONSECUTIVE)) + bonus[j];
12970
+ }
12971
+ prev = cur;
12972
+ }
12973
+ const best = Math.max(...prev);
12974
+ return best === -Infinity ? null : best;
12975
+ }
12976
+ function wordStart(before, ch) {
12977
+ if (before === void 0) return 10;
12978
+ if (!ALNUM.test(before)) return WORD_START;
12979
+ if (UPPER.test(ch) && !UPPER.test(before)) return WORD_START;
12980
+ if (DIGIT.test(ch) && !DIGIT.test(before)) return WORD_START;
12981
+ return 0;
12982
+ }
12983
+ const ALNUM = /[\p{L}\p{N}]/u;
12984
+ const UPPER = /\p{Lu}/u;
12985
+ const DIGIT = /\p{N}/u;
12986
+ /**
12987
+ * `items` whose `key` matches `query`, best first; ties prefer the shorter key, then input order.
12988
+ * A blank query keeps every item in order.
12989
+ */
12990
+ function fuzzyFilter(items, query, key) {
12991
+ const q = query.trim();
12992
+ if (!q) return [...items];
12993
+ const scored = [];
12994
+ for (const item of items) {
12995
+ const k = key(item);
12996
+ const score = fuzzyScore(q, k);
12997
+ if (score !== null) scored.push({
12998
+ item,
12999
+ score,
13000
+ length: k.length
13001
+ });
13002
+ }
13003
+ return scored.sort((a, b) => b.score - a.score || a.length - b.length).map((s) => s.item);
13004
+ }
13005
+ /** `fuzzyFilter`'s order, followed by the items it rejects in input order: re-ranks hits another matcher chose. */
13006
+ function fuzzyRank(items, query, key) {
13007
+ const matched = fuzzyFilter(items, query, key);
13008
+ const kept = new Set(matched);
13009
+ return [...matched, ...items.filter((item) => !kept.has(item))];
13010
+ }
13011
+ //#endregion
12673
13012
  //#region src/server/lsp/hover.ts
12674
13013
  /**
12675
13014
  * One markdown document for the tooltip, or null when the server sent nothing
@@ -13239,6 +13578,30 @@ var LspBridge = class LspBridge {
13239
13578
  }
13240
13579
  return { kind: null };
13241
13580
  }
13581
+ /**
13582
+ * Where else the symbol at `pos` occurs in its file, via `textDocument/documentHighlight`,
13583
+ * so scope and shadowing are the server's call. A server without the method, or a
13584
+ * position holding no symbol, yields no occurrences: the answer is advisory.
13585
+ */
13586
+ async occurrences(pos) {
13587
+ let result;
13588
+ try {
13589
+ result = await this.query("textDocument/documentHighlight", {
13590
+ textDocument: { uri: this.uri(pos.path) },
13591
+ position: toLsp(pos)
13592
+ }, pos.path);
13593
+ } catch (e) {
13594
+ if (e instanceof LspUnavailableError) throw e;
13595
+ return { occurrences: [] };
13596
+ }
13597
+ return { occurrences: (result ?? []).map(({ range: { start, end }, kind }) => ({
13598
+ line: start.line + 1,
13599
+ col: start.character,
13600
+ endLine: end.line + 1,
13601
+ endCol: end.character,
13602
+ kind: kind === 3 ? "write" : kind === 2 ? "read" : "text"
13603
+ })) };
13604
+ }
13242
13605
  async references(pos, opts = {}) {
13243
13606
  const result = await this.query("textDocument/references", {
13244
13607
  textDocument: { uri: this.uri(pos.path) },
@@ -13321,7 +13684,7 @@ var LspBridge = class LspBridge {
13321
13684
  }
13322
13685
  /**
13323
13686
  * Symbols the server knows for `query`, limited to paths the snapshot
13324
- * exposes. Membership comes from `has`, never from reading the file: a
13687
+ * exposes, best `fuzzyRank` match first. Membership comes from `has`, never from reading the file: a
13325
13688
  * per-keystroke search must not read every hit's text.
13326
13689
  */
13327
13690
  async workspaceSymbols(query, limit = 200) {
@@ -13333,7 +13696,7 @@ var LspBridge = class LspBridge {
13333
13696
  const seen = /* @__PURE__ */ new Set();
13334
13697
  result.forEach((s, i) => {
13335
13698
  const path = paths[i];
13336
- if (path == null || !inside.get(path) || out.length >= limit) return;
13699
+ if (path == null || !inside.get(path)) return;
13337
13700
  const start = "range" in s.location ? s.location.range.start : {
13338
13701
  line: 0,
13339
13702
  character: 0
@@ -13352,7 +13715,7 @@ var LspBridge = class LspBridge {
13352
13715
  col: start.character
13353
13716
  });
13354
13717
  });
13355
- return out;
13718
+ return fuzzyRank(out, query, (s) => s.name).slice(0, limit);
13356
13719
  }
13357
13720
  /**
13358
13721
  * Contents of a file outside the snapshot that a recent result named as
@@ -13466,6 +13829,7 @@ var LspBridge = class LspBridge {
13466
13829
  definition: { linkSupport: true },
13467
13830
  typeDefinition: { linkSupport: true },
13468
13831
  references: {},
13832
+ documentHighlight: {},
13469
13833
  hover: { contentFormat: ["markdown", "plaintext"] },
13470
13834
  documentSymbol: { hierarchicalDocumentSymbolSupport: true },
13471
13835
  semanticTokens: {
@@ -14051,11 +14415,14 @@ var LspPool = class {
14051
14415
  async references(pos) {
14052
14416
  return this.bridgeFor(pos.path).references(pos);
14053
14417
  }
14418
+ async occurrences(pos) {
14419
+ return this.bridgeFor(pos.path).occurrences(pos);
14420
+ }
14054
14421
  async documentSymbols(path) {
14055
14422
  return this.bridgeFor(path).documentSymbols(path);
14056
14423
  }
14057
14424
  /**
14058
- * Symbols matching `query` from every running server, in server order. One server
14425
+ * Symbols matching `query` from every running server, best `fuzzyRank` match first. One server
14059
14426
  * failing drops its share; only an outage everywhere is an error.
14060
14427
  */
14061
14428
  async workspaceSymbols(query, limit = 200) {
@@ -14065,7 +14432,7 @@ var LspPool = class {
14065
14432
  const out = results.flatMap((r) => r.status === "fulfilled" ? r.value : []);
14066
14433
  const failed = results.find((r) => r.status === "rejected");
14067
14434
  if (!out.length && failed) throw failed.reason;
14068
- return out.slice(0, limit);
14435
+ return fuzzyRank(out, query, (s) => s.name).slice(0, limit);
14069
14436
  }
14070
14437
  /**
14071
14438
  * Keeps the diff's files open in the server for their language, starting one for each
@@ -14200,6 +14567,53 @@ function parseRevspec(args) {
14200
14567
  base: "merge-base"
14201
14568
  };
14202
14569
  }
14570
+ /**
14571
+ * `git range-diff` style arguments, as two `old..new` ranges:
14572
+ * "a..b" "c..d" → a..b against c..d
14573
+ * "base" "b" "d" → base..b against base..d
14574
+ * "b...d" → d..b against b..d: each tip's commits since their divergence
14575
+ * A range also takes `<rev>^!`, the one commit. Empty sides default to HEAD; the worktree is
14576
+ * not a commit and has no place in a range.
14577
+ */
14578
+ function parseRangeDiff(args) {
14579
+ if (args.length === 3) {
14580
+ const [base, b, d] = args;
14581
+ for (const arg of args) if (arg.includes("..") || arg.endsWith("^!")) throw new RevspecError("expected <base> <rev1> <rev2> as three plain revisions");
14582
+ return [range(base, b), range(base, d)];
14583
+ }
14584
+ if (args.length === 2) {
14585
+ const [a, b] = args;
14586
+ return [parseRange(a), parseRange(b)];
14587
+ }
14588
+ if (args.length === 1) {
14589
+ const arg = args[0];
14590
+ const three = arg.indexOf("...");
14591
+ if (three === -1 || arg.endsWith("^!")) throw new RevspecError("expected <range1> <range2>, <rev1>...<rev2> or <base> <rev1> <rev2>");
14592
+ const b = arg.slice(0, three) || "HEAD";
14593
+ const d = arg.slice(three + 3) || "HEAD";
14594
+ if (b.includes("..") || d.includes("..")) throw new RevspecError("cannot combine \"...\" with \"..\"");
14595
+ return [range(d, b), range(b, d)];
14596
+ }
14597
+ throw new RevspecError("expected <range1> <range2>, <rev1>...<rev2> or <base> <rev1> <rev2>");
14598
+ }
14599
+ function parseRange(arg) {
14600
+ if (arg.endsWith("^!")) {
14601
+ const commit = arg.slice(0, -2) || "HEAD";
14602
+ if (commit.includes("..")) throw new RevspecError("cannot combine \"^!\" with \"..\"");
14603
+ return range(`${commit}^`, commit);
14604
+ }
14605
+ if (arg.includes("...")) throw new RevspecError(`a range of a range-diff is <old>..<new>, not ${arg}`);
14606
+ const two = arg.indexOf("..");
14607
+ if (two === -1) throw new RevspecError(`not a range: ${arg} (expected <old>..<new>)`);
14608
+ return range(arg.slice(0, two) || "HEAD", arg.slice(two + 2) || "HEAD");
14609
+ }
14610
+ function range(old, next) {
14611
+ for (const rev of [old, next]) if (rev === WORKTREE || rev === `${WORKTREE}^`) throw new RevspecError("the worktree is not a commit");
14612
+ return {
14613
+ old,
14614
+ new: next
14615
+ };
14616
+ }
14203
14617
  var RevspecError = class extends Error {};
14204
14618
  //#endregion
14205
14619
  //#region node_modules/@hono/node-server/dist/index.mjs
@@ -17536,6 +17950,14 @@ function createApi(deps) {
17536
17950
  await next();
17537
17951
  });
17538
17952
  app.get("/api/snapshot", async (c) => c.json(await session.snapshotter.current()));
17953
+ app.get("/api/moved", (c) => c.json(session.moved));
17954
+ app.post("/api/reload", async (c) => c.json(await session.reload()));
17955
+ app.delete("/api/iterations", async (c) => c.json(await session.clearIterations()));
17956
+ app.delete("/api/iterations/:n", async (c) => {
17957
+ const n = Number(c.req.param("n"));
17958
+ if (!Number.isInteger(n) || n < 1) throw new RevspecError(`not an iteration number: ${c.req.param("n")}`);
17959
+ return c.json(await session.deleteIteration(n));
17960
+ });
17539
17961
  app.post("/api/mode", async (c) => {
17540
17962
  const req = ModeRequestSchema.parse(await readJson(c));
17541
17963
  return c.json(await session.switchMode(req));
@@ -17731,6 +18153,12 @@ function createApi(deps) {
17731
18153
  if ("error" in r) return c.json({ error: r.error }, 409);
17732
18154
  return c.json(await r.lsp.references(pos));
17733
18155
  });
18156
+ app.post("/api/lsp/occurrences", async (c) => {
18157
+ const pos = LspPositionSchema.parse(await readJson(c));
18158
+ const r = await lspFor(pos.path);
18159
+ if ("error" in r) return c.json({ error: r.error }, 409);
18160
+ return c.json(await r.lsp.occurrences(pos));
18161
+ });
17734
18162
  app.get("/api/lsp/symbols", async (c) => {
17735
18163
  const { path, q: query } = SymbolsQuerySchema.parse(c.req.query());
17736
18164
  const r = await lspFor(path);
@@ -17743,6 +18171,7 @@ function createApi(deps) {
17743
18171
  await deps.config.set(body);
17744
18172
  hub.broadcast({ type: "config" });
17745
18173
  if (body.contextLines != null) await session.setContext(deps.config.get().contextLines);
18174
+ if (body.followRefs != null) session.setFollowRefs(deps.config.get().followRefs);
17746
18175
  return c.json(effectiveConfig(deps));
17747
18176
  });
17748
18177
  return app;
@@ -18422,6 +18851,163 @@ function describe(skipped) {
18422
18851
  for (const s of skipped) counts.set(s.reason, (counts.get(s.reason) ?? 0) + 1);
18423
18852
  return [...counts].map(([reason, n]) => `${n} ${reason}`).join(", ");
18424
18853
  }
18854
+ const FileSchema = object({
18855
+ version: literal(1),
18856
+ ranges: record(string(), IterationSchema.array())
18857
+ });
18858
+ /**
18859
+ * The iterations of one range, under its comment key in `<gitDir>/diffle/iterations.json`.
18860
+ * Like the comment store, nothing is cached: every mutation is a read-modify-write of fresh
18861
+ * disk state under the file lock, so two servers on one repository keep each other's records.
18862
+ * Each iteration's commits are pinned under `refs/diffle/iterations/<key hash>/<n>/{old,new}`.
18863
+ */
18864
+ var IterationStore = class {
18865
+ repo;
18866
+ key;
18867
+ file;
18868
+ refs;
18869
+ constructor(repo, key) {
18870
+ this.repo = repo;
18871
+ this.key = key;
18872
+ this.file = join(repo.gitDir, "diffle", "iterations.json");
18873
+ this.refs = `refs/diffle/iterations/${createHash("sha1").update(key).digest("hex").slice(0, 16)}`;
18874
+ }
18875
+ /** Oldest first. */
18876
+ async list() {
18877
+ return (await read(this.file)).ranges[this.key] ?? [];
18878
+ }
18879
+ /** The iteration numbered `n`, or null. */
18880
+ async get(n) {
18881
+ return (await this.list()).find((it) => it.n === n) ?? null;
18882
+ }
18883
+ /**
18884
+ * Records the endpoints as the next iteration, unless the latest already has them. Returns
18885
+ * the list afterwards. Pins the commits first, so a recorded iteration is always reachable.
18886
+ */
18887
+ async record(oldSha, newSha) {
18888
+ return withFileLock(this.file, async () => {
18889
+ const all = await read(this.file);
18890
+ const list = all.ranges[this.key] ?? [];
18891
+ const last = list.at(-1);
18892
+ if (last && last.oldSha === oldSha && last.newSha === newSha) return list;
18893
+ const n = (last?.n ?? 0) + 1;
18894
+ const next = [...list, {
18895
+ n,
18896
+ oldSha,
18897
+ newSha,
18898
+ recordedAt: Date.now()
18899
+ }];
18900
+ const dropped = next.splice(0, Math.max(0, next.length - 50));
18901
+ await this.repo.pin({
18902
+ [`${this.refs}/${n}/old`]: oldSha,
18903
+ [`${this.refs}/${n}/new`]: newSha,
18904
+ ...Object.fromEntries(dropped.flatMap((it) => [[`${this.refs}/${it.n}/old`, null], [`${this.refs}/${it.n}/new`, null]]))
18905
+ });
18906
+ all.ranges[this.key] = next;
18907
+ await writeFileAtomic(this.file, JSON.stringify(all, null, 2) + "\n");
18908
+ return next;
18909
+ });
18910
+ }
18911
+ /** Forgets the iteration numbered `n`, if recorded, and unpins its commits. The rest keep their numbers. */
18912
+ async remove(n) {
18913
+ return withFileLock(this.file, async () => {
18914
+ const all = await read(this.file);
18915
+ const list = all.ranges[this.key] ?? [];
18916
+ if (!list.some((it) => it.n === n)) return list;
18917
+ await this.repo.pin({
18918
+ [`${this.refs}/${n}/old`]: null,
18919
+ [`${this.refs}/${n}/new`]: null
18920
+ });
18921
+ const next = list.filter((it) => it.n !== n);
18922
+ if (next.length) all.ranges[this.key] = next;
18923
+ else delete all.ranges[this.key];
18924
+ await writeFileAtomic(this.file, JSON.stringify(all, null, 2) + "\n");
18925
+ return next;
18926
+ });
18927
+ }
18928
+ /** Forgets every iteration of this range and unpins their commits. */
18929
+ async clear() {
18930
+ await withFileLock(this.file, async () => {
18931
+ const all = await read(this.file);
18932
+ const list = all.ranges[this.key] ?? [];
18933
+ await this.repo.pin(Object.fromEntries(list.flatMap((it) => [[`${this.refs}/${it.n}/old`, null], [`${this.refs}/${it.n}/new`, null]])));
18934
+ delete all.ranges[this.key];
18935
+ await writeFileAtomic(this.file, JSON.stringify(all, null, 2) + "\n");
18936
+ });
18937
+ }
18938
+ };
18939
+ async function read(file) {
18940
+ try {
18941
+ return FileSchema.parse(JSON.parse(await readFile(file, "utf8")));
18942
+ } catch (e) {
18943
+ if (e.code === "ENOENT") return {
18944
+ version: 1,
18945
+ ranges: {}
18946
+ };
18947
+ throw e;
18948
+ }
18949
+ }
18950
+ //#endregion
18951
+ //#region src/server/rangediff.ts
18952
+ /**
18953
+ * The mode that compares `older` with `newer`: `older`'s head replayed onto `newer`'s base against
18954
+ * `newer`'s head, so only what the series itself changed in between shows, with the two ranges'
18955
+ * commits paired. `range` is where the reviewer continues afterwards; its refs keep being watched,
18956
+ * so the comparison is pinned under them. Comments are off in it: the old side is a tree no
18957
+ * revision names.
18958
+ */
18959
+ async function compareRanges(repo, range, older, newer) {
18960
+ const commentKey = older.iteration != null && newer.iteration != null ? `${range.commentKey}:interdiff:${older.iteration}-${newer.iteration}` : `range-diff:${older.oldSha}..${older.newSha}:${newer.oldSha}..${newer.newSha}`;
18961
+ const [{ tree, conflicts }, pairs] = await Promise.all([older.oldSha === newer.oldSha ? repo.tree(older.newSha).then((tree) => ({
18962
+ tree,
18963
+ conflicts: []
18964
+ })) : repo.replay(older.oldSha, newer.oldSha, older.newSha), pairRanges(repo, older, newer)]);
18965
+ return {
18966
+ old: tree,
18967
+ new: newer.newSha,
18968
+ base: "direct",
18969
+ live: range.live === "none" ? "none" : "refs",
18970
+ commentKey,
18971
+ within: range,
18972
+ interdiff: {
18973
+ from: older,
18974
+ to: newer,
18975
+ conflicts,
18976
+ pairs
18977
+ }
18978
+ };
18979
+ }
18980
+ /**
18981
+ * Pairs the two ranges' commits as `git range-diff` does and tells a pair whose patch is the
18982
+ * same (a reworded commit) from an amended one by patch id. range-diff compares every commit
18983
+ * with every other, so a range past MAX_RANGE_COMMITS is refused rather than left running.
18984
+ */
18985
+ async function pairRanges(repo, older, newer) {
18986
+ const oldRange = `${older.oldSha}..${older.newSha}`;
18987
+ const newRange = `${newer.oldSha}..${newer.newSha}`;
18988
+ const [oldCommits, newCommits] = await Promise.all([repo.rangeCommits(oldRange, 250), repo.rangeCommits(newRange, 250)]);
18989
+ for (const [side, commits] of [[older, oldCommits], [newer, newCommits]]) if (commits.total > 250) throw new RevspecError(`${comparisonLabel({
18990
+ ...side,
18991
+ base: "direct"
18992
+ })} spans ${commits.total} commits; a range-diff takes at most 250`);
18993
+ const rows = await repo.rangeDiff(oldRange, newRange);
18994
+ const find = async (list, abbrev) => {
18995
+ if (abbrev === null) return null;
18996
+ return list.find((c) => c.sha.startsWith(abbrev)) ?? (await repo.rangeCommits(`${abbrev}^!`, 1)).list[0] ?? null;
18997
+ };
18998
+ const paired = await Promise.all(rows.map(async (row) => ({
18999
+ old: await find(oldCommits.list, row.old),
19000
+ new: await find(newCommits.list, row.new),
19001
+ marker: row.marker
19002
+ })));
19003
+ const changed = paired.filter((p) => p.marker === "!" && p.old && p.new);
19004
+ const ids = await repo.patchIds(changed.flatMap((p) => [p.old.sha, p.new.sha]));
19005
+ return paired.map(({ old, new: next, marker }) => ({
19006
+ old,
19007
+ new: next,
19008
+ status: marker === "=" ? "identical" : marker === "<" ? "dropped" : marker === ">" ? "added" : old && next && ids.get(old.sha) !== void 0 && ids.get(old.sha) === ids.get(next.sha) ? "message" : "changed"
19009
+ }));
19010
+ }
18425
19011
  //#endregion
18426
19012
  //#region src/server/mode.ts
18427
19013
  /**
@@ -18451,6 +19037,11 @@ function describe(skipped) {
18451
19037
  * the empty tree. Invalid revisions or missing merge bases throw RevspecError.
18452
19038
  */
18453
19039
  async function resolveComparison(repo, comparison) {
19040
+ if (comparison.interdiff && comparison.base !== "parent") return {
19041
+ oldSha: comparison.old,
19042
+ newSha: comparison.new,
19043
+ live: "none"
19044
+ };
18454
19045
  const endpoints = [comparison.old, comparison.new];
18455
19046
  const resolve = async (rev) => {
18456
19047
  if (rev === "worktree") return rev;
@@ -18487,9 +19078,15 @@ async function resolveComparison(repo, comparison) {
18487
19078
  live: endpoints.includes("worktree") ? "worktree" : pinned(comparison.old, old) && pinned(comparison.new, next) ? "none" : "refs"
18488
19079
  };
18489
19080
  }
19081
+ /** Whether either endpoint names a local or remote branch, HEAD on one included. */
19082
+ async function namesBranch(repo, comparison) {
19083
+ const [old, next] = await Promise.all([repo.branchRef(comparison.old), repo.branchRef(comparison.new)]);
19084
+ return old != null || next != null;
19085
+ }
18490
19086
  /** Resolves an input command into a comparison and optional explicit PR identity. */
18491
19087
  async function resolveReview(req, repo, github) {
18492
19088
  if (req.kind === "pr") return resolvePr(req, repo, github);
19089
+ if (req.kind === "range-diff") return rangeDiffReview(repo, parseRangeDiff(req.args));
18493
19090
  const parsed = req.kind === "working" ? {
18494
19091
  old: "HEAD",
18495
19092
  new: "worktree",
@@ -18505,12 +19102,48 @@ async function resolveReview(req, repo, github) {
18505
19102
  commentKey: `commit:${sha}`
18506
19103
  } };
18507
19104
  }
19105
+ return { mode: await resolveRange(repo, parsed, req.kind === "working" ? "working" : void 0) };
19106
+ }
19107
+ /** A range as a comparison: resolved for its liveness and keyed for its comments, unless `key` names them. */
19108
+ async function resolveRange(repo, parsed, key) {
18508
19109
  const { oldSha, newSha, live } = await resolveComparison(repo, parsed);
18509
- return { mode: {
19110
+ return {
18510
19111
  ...parsed,
18511
19112
  live,
18512
- commentKey: req.kind === "working" ? "working" : await commentKey(repo, parsed, oldSha, newSha)
18513
- } };
19113
+ commentKey: key ?? await commentKey(repo, parsed, oldSha, newSha)
19114
+ };
19115
+ }
19116
+ /**
19117
+ * Two ranges the reviewer named, compared. Both resolve now and the comparison is pinned; the
19118
+ * newer range is where "All changes" leads and whose refs are watched. Resolving again, as a
19119
+ * reload does, follows the revisions either range was named by.
19120
+ */
19121
+ async function rangeDiffReview(repo, [older, newer], prUrl) {
19122
+ const resolveSide = async (parsed) => {
19123
+ const { oldSha, newSha } = await resolveComparison(repo, {
19124
+ ...parsed,
19125
+ base: "direct"
19126
+ });
19127
+ return {
19128
+ old: parsed.old,
19129
+ new: parsed.new,
19130
+ oldSha,
19131
+ newSha,
19132
+ iteration: null
19133
+ };
19134
+ };
19135
+ const [range, from, to] = await Promise.all([
19136
+ resolveRange(repo, {
19137
+ ...newer,
19138
+ base: "direct"
19139
+ }),
19140
+ resolveSide(older),
19141
+ resolveSide(newer)
19142
+ ]);
19143
+ return {
19144
+ prUrl,
19145
+ mode: await compareRanges(repo, range, from, to)
19146
+ };
18514
19147
  }
18515
19148
  /**
18516
19149
  * Branch identities keep review state stable across pushes and local branch aliases.
@@ -18647,16 +19280,18 @@ var Snapshotter = class {
18647
19280
  mode;
18648
19281
  version;
18649
19282
  context;
19283
+ iterations;
18650
19284
  cache = null;
18651
19285
  patches = /* @__PURE__ */ new Map();
18652
19286
  all = null;
18653
19287
  /** Content-sniff verdict by blob sha. A blob is immutable, so a refresh re-reads only new content. */
18654
19288
  sniffed = /* @__PURE__ */ new Map();
18655
- constructor(repo, mode, version, context) {
19289
+ constructor(repo, mode, version, context, iterations = null) {
18656
19290
  this.repo = repo;
18657
19291
  this.mode = mode;
18658
19292
  this.version = version;
18659
19293
  this.context = context;
19294
+ this.iterations = iterations;
18660
19295
  }
18661
19296
  /** Change context lines: drops cached patches; the next snapshot carries the new value. */
18662
19297
  setContext(context, version) {
@@ -18728,7 +19363,7 @@ var Snapshotter = class {
18728
19363
  this.mode.base === "parent" ? this.reviewedCommit(newSha) : null,
18729
19364
  this.rangeCommits(oldSha, newSha, headSha)
18730
19365
  ]);
18731
- await this.fillGenerated(changed, newSha);
19366
+ const [iterations] = await Promise.all([this.recordIteration(commits), this.fillGenerated(changed, newSha)]);
18732
19367
  const tree = new Set(tracked);
18733
19368
  for (const f of changed) {
18734
19369
  if (f.status === "D") tree.delete(f.path);
@@ -18746,11 +19381,17 @@ var Snapshotter = class {
18746
19381
  changed,
18747
19382
  tree: [...tree].sort(),
18748
19383
  commit,
18749
- commits
19384
+ commits,
19385
+ iterations
18750
19386
  };
18751
19387
  for (const f of changed.slice(0, PREWARM)) this.patchFor(snap, f).catch(() => {});
18752
19388
  return snap;
18753
19389
  }
19390
+ /** The snapshot taken is the range's current iteration; the worktree has no commit to record. */
19391
+ async recordIteration(commits) {
19392
+ if (!this.iterations || commits.oldSha === "worktree" || commits.newSha === "worktree") return [];
19393
+ return this.iterations.record(commits.oldSha, commits.newSha);
19394
+ }
18754
19395
  async reviewedCommit(sha) {
18755
19396
  const [info, neighbours] = await Promise.all([this.repo.commitInfo(sha), this.repo.commitNeighbours(sha)]);
18756
19397
  return info && {
@@ -18763,16 +19404,23 @@ var Snapshotter = class {
18763
19404
  const range = await resolveComparison(this.repo, this.mode.within);
18764
19405
  return this.spanned(range.oldSha, range.newSha, headSha);
18765
19406
  }
18766
- if (this.mode.base === "parent") return this.repo.rangeCommits(`${newSha}^!`, 250);
19407
+ if (this.mode.base === "parent") return {
19408
+ ...await this.repo.rangeCommits(`${newSha}^!`, 250),
19409
+ oldSha,
19410
+ newSha
19411
+ };
18767
19412
  return this.spanned(oldSha, newSha, headSha);
18768
19413
  }
18769
- spanned(oldSha, newSha, headSha) {
19414
+ async spanned(oldSha, newSha, headSha) {
18770
19415
  const [from, to] = [oldSha, newSha].map((sha) => sha === "worktree" ? headSha : sha);
18771
- if (!from || !to || from === to) return Promise.resolve({
18772
- list: [],
18773
- total: 0
18774
- });
18775
- return this.repo.rangeCommits(`${from}..${to}`, 250);
19416
+ return {
19417
+ ...!from || !to || from === to ? {
19418
+ list: [],
19419
+ total: 0
19420
+ } : await this.repo.rangeCommits(`${from}..${to}`, 250),
19421
+ oldSha,
19422
+ newSha
19423
+ };
18776
19424
  }
18777
19425
  /**
18778
19426
  * Sets `generated` on each non-binary changed file. A `linguist-generated`
@@ -18822,6 +19470,10 @@ var Snapshotter = class {
18822
19470
  * while its task holds the queue, so published versions are monotonic and the
18823
19471
  * active snapshotter always carries the session's context. Requests that arrive
18824
19472
  * before the first mode resolves await `ready()`.
19473
+ *
19474
+ * A worktree-live comparison recomputes on every change. A refs-live one only
19475
+ * announces that its refs moved (`moved`) and recomputes on `reload()`, so a
19476
+ * push or rebase does not swap the review out from under the reviewer.
18825
19477
  */
18826
19478
  var Session = class {
18827
19479
  repo;
@@ -18836,6 +19488,8 @@ var Session = class {
18836
19488
  queue = Promise.resolve();
18837
19489
  /** The refresh waiting for the queue, if any. Later requests join it. */
18838
19490
  queuedRefresh = null;
19491
+ /** The refs check waiting for the queue, if any. Later signals join it. */
19492
+ queuedCheck = null;
18839
19493
  readable = /* @__PURE__ */ new WeakMap();
18840
19494
  snapshotListeners = /* @__PURE__ */ new Set();
18841
19495
  constructor(repo, hub, opts) {
@@ -18857,6 +19511,24 @@ var Session = class {
18857
19511
  get snapshotter() {
18858
19512
  return this.require().snapshotter;
18859
19513
  }
19514
+ /** The pending reload of a refs-live comparison, for a client that connects after the push. */
19515
+ get moved() {
19516
+ return this.require().moved;
19517
+ }
19518
+ /** Whether moved refs recompute the review, as worktree edits always do, or only announce themselves. */
19519
+ get follows() {
19520
+ const a = this.require();
19521
+ if (a.mode.live === "worktree") return true;
19522
+ switch (this.opts.followRefs) {
19523
+ case "on": return true;
19524
+ case "off": return false;
19525
+ case "auto": return !a.branches;
19526
+ }
19527
+ }
19528
+ /** Change the user's choice for the running session; a pending notice stands until a reload picks the refs up. */
19529
+ setFollowRefs(followRefs) {
19530
+ this.opts.followRefs = followRefs;
19531
+ }
18860
19532
  /** Cached independently of snapshot construction; callers never hold the transition queue. */
18861
19533
  github(snap) {
18862
19534
  const active = this.require();
@@ -18944,7 +19616,7 @@ var Session = class {
18944
19616
  }
18945
19617
  /** Switch modes at runtime. Broadcasts a snapshot bump on success. */
18946
19618
  async switchMode(req) {
18947
- const snap = await this.run(async () => this.activate(req.kind === "focus" ? await this.focus(req.commit) : await resolveReview(req, this.repo, this.opts.github)));
19619
+ const snap = await this.run(async () => this.activate(req.kind === "focus" ? await this.focus(req.commit) : req.kind === "interdiff" ? await this.interdiff(req.from, req.to) : req.kind === "pair" ? await this.pair(req.commit) : await resolveReview(req, this.repo, this.opts.github)));
18948
19620
  this.hub.broadcast({
18949
19621
  type: "snapshot",
18950
19622
  version: snap.version
@@ -18979,6 +19651,87 @@ var Session = class {
18979
19651
  };
18980
19652
  }
18981
19653
  /**
19654
+ * Compares two recorded iterations of the active range, so only what the branch itself changed
19655
+ * in between shows. The interdiff keeps its own comments and the range's PR identity; its refs
19656
+ * keep being watched.
19657
+ */
19658
+ async interdiff(from, to) {
19659
+ const a = this.require();
19660
+ const range = a.mode.within ?? a.mode;
19661
+ if (range.base === "parent") throw new RevspecError("a single commit has no iterations");
19662
+ if (from >= to) throw new RevspecError("compare an older iteration with a newer one");
19663
+ const store = new IterationStore(this.repo, range.commentKey);
19664
+ const [older, newer] = await Promise.all([store.get(from), store.get(to)]);
19665
+ if (!older || !newer) throw new RevspecError(`no iteration #${older ? to : from} of ${comparisonLabel(range)}`);
19666
+ const side = (it) => ({
19667
+ old: it.oldSha,
19668
+ new: it.newSha,
19669
+ oldSha: it.oldSha,
19670
+ newSha: it.newSha,
19671
+ iteration: it.n
19672
+ });
19673
+ return {
19674
+ prUrl: a.prUrl,
19675
+ mode: await compareRanges(this.repo, range, side(older), side(newer))
19676
+ };
19677
+ }
19678
+ /**
19679
+ * Shows one pair of the active interdiff by its new-side commit: an added commit against its
19680
+ * parent, an amended or reworded one against its old self replayed onto its parent. With null,
19681
+ * the whole interdiff again. Identical pairs have nothing to show; dropped ones have no new side.
19682
+ */
19683
+ async pair(commit) {
19684
+ const a = this.require();
19685
+ const { interdiff, within: range } = a.mode;
19686
+ if (!interdiff || !range) throw new RevspecError("no interdiff to pick a pair from");
19687
+ if (commit === null) return {
19688
+ prUrl: a.prUrl,
19689
+ mode: await compareRanges(this.repo, range, interdiff.from, interdiff.to)
19690
+ };
19691
+ const found = interdiff.pairs.find((p) => p.new && (p.new.sha === commit || p.new.short === commit));
19692
+ if (!found?.new) throw new RevspecError(`not a commit of ${comparisonLabel(a.mode)}: ${commit}`);
19693
+ if (found.status === "identical") throw new RevspecError(`${found.new.short} is identical in both ranges`);
19694
+ const base = {
19695
+ live: range.live === "none" ? "none" : "refs",
19696
+ within: range,
19697
+ interdiff
19698
+ };
19699
+ if (!found.old) {
19700
+ const sha = found.new.sha;
19701
+ return {
19702
+ prUrl: a.prUrl,
19703
+ mode: {
19704
+ ...base,
19705
+ old: sha,
19706
+ new: sha,
19707
+ base: "parent",
19708
+ commentKey: `commit:${sha}`,
19709
+ pair: {
19710
+ old: null,
19711
+ new: sha,
19712
+ conflicts: []
19713
+ }
19714
+ }
19715
+ };
19716
+ }
19717
+ const { tree, conflicts } = await this.repo.replay(`${found.old.sha}^`, `${found.new.sha}^`, found.old.sha);
19718
+ return {
19719
+ prUrl: a.prUrl,
19720
+ mode: {
19721
+ ...base,
19722
+ old: tree,
19723
+ new: found.new.sha,
19724
+ base: "direct",
19725
+ commentKey: `${range.commentKey}:pair:${found.old.sha}-${found.new.sha}`,
19726
+ pair: {
19727
+ old: found.old.sha,
19728
+ new: found.new.sha,
19729
+ conflicts
19730
+ }
19731
+ }
19732
+ };
19733
+ }
19734
+ /**
18982
19735
  * Runs `fn` after every earlier mutation settled. The only way to touch
18983
19736
  * `active`, `version` or `opts.context`.
18984
19737
  */
@@ -18988,14 +19741,22 @@ var Session = class {
18988
19741
  return r;
18989
19742
  }
18990
19743
  async activate({ mode, prUrl }) {
18991
- const snapshotter = new Snapshotter(this.repo, mode, ++this.version, this.opts.context);
18992
- const [comments, snap] = await Promise.all([CommentStore.open(this.repo.gitDir, mode.commentKey), snapshotter.current()]);
19744
+ const range = mode.within ?? mode;
19745
+ const followed = range.base !== "parent" && (range.live === "refs" || prUrl != null);
19746
+ const snapshotter = new Snapshotter(this.repo, mode, ++this.version, this.opts.context, followed ? new IterationStore(this.repo, range.commentKey) : null);
19747
+ const [comments, snap, branches] = await Promise.all([
19748
+ CommentStore.open(this.repo.gitDir, mode.commentKey),
19749
+ snapshotter.current(),
19750
+ range.live === "refs" && namesBranch(this.repo, range)
19751
+ ]);
18993
19752
  const next = {
18994
19753
  mode,
18995
19754
  prUrl,
18996
19755
  snapshotter,
18997
19756
  comments,
18998
- watcher: null
19757
+ watcher: null,
19758
+ moved: null,
19759
+ branches
18999
19760
  };
19000
19761
  await this.relocateComments(next, snap);
19001
19762
  if (this.opts.watch && mode.live !== "none") next.watcher = await this.startWatcher(next);
@@ -19025,13 +19786,71 @@ var Session = class {
19025
19786
  if (this.queuedRefresh) return this.queuedRefresh;
19026
19787
  const r = this.run(async () => {
19027
19788
  this.queuedRefresh = null;
19028
- const a = this.require();
19029
- a.snapshotter.invalidate(++this.version);
19030
- await this.publish(a);
19789
+ await this.recompute();
19031
19790
  }).catch((e) => console.error("[diffle] refresh failed:", e));
19032
19791
  this.queuedRefresh = r;
19033
19792
  return r;
19034
19793
  }
19794
+ /** The reviewer asks for the moved refs: recompute now. Unlike `refresh`, a failure is the caller's. */
19795
+ reload() {
19796
+ return this.run(async () => {
19797
+ const a = this.require();
19798
+ if (a.mode.within && shownIterations(a.mode)) return this.leaveInterdiff(a);
19799
+ return this.recompute();
19800
+ });
19801
+ }
19802
+ /** Forgets the range's recorded iterations and unpins their commits; its current state is recorded anew as #1. */
19803
+ clearIterations() {
19804
+ return this.run(async () => {
19805
+ const a = this.require();
19806
+ const range = a.mode.within ?? a.mode;
19807
+ await new IterationStore(this.repo, range.commentKey).clear();
19808
+ return shownIterations(a.mode) ? this.leaveInterdiff(a) : this.recompute();
19809
+ });
19810
+ }
19811
+ /**
19812
+ * Forgets one recorded iteration and unpins its commits. The latest stays: it is the range's
19813
+ * current state and would be recorded again at once.
19814
+ */
19815
+ deleteIteration(n) {
19816
+ return this.run(async () => {
19817
+ const a = this.require();
19818
+ const range = a.mode.within ?? a.mode;
19819
+ const store = new IterationStore(this.repo, range.commentKey);
19820
+ const list = await store.list();
19821
+ if (!list.some((it) => it.n === n)) throw new RevspecError(`no iteration #${n}`);
19822
+ if (list.at(-1).n === n) throw new RevspecError(`#${n} is the latest iteration, the range's current state`);
19823
+ await store.remove(n);
19824
+ const shown = shownIterations(a.mode);
19825
+ return shown && (shown.from === n || shown.to === n) ? this.leaveInterdiff(a) : this.recompute();
19826
+ });
19827
+ }
19828
+ leaveInterdiff(a) {
19829
+ return this.enter({
19830
+ mode: a.mode.within,
19831
+ prUrl: a.prUrl
19832
+ });
19833
+ }
19834
+ /** Activates a review in place of the current one, as a switch does, and tells clients. */
19835
+ async enter(review) {
19836
+ const snap = await this.activate(review);
19837
+ this.hub.broadcast({
19838
+ type: "snapshot",
19839
+ version: snap.version
19840
+ });
19841
+ return snap;
19842
+ }
19843
+ /**
19844
+ * Recomputes the active snapshot. A range-diff of two named ranges resolves them again
19845
+ * instead, since its sides are pinned trees: that is how it follows the refs it was named by.
19846
+ */
19847
+ recompute() {
19848
+ const a = this.require();
19849
+ const inter = a.mode.interdiff;
19850
+ if (inter && !shownIterations(a.mode) && !a.mode.pair) return rangeDiffReview(this.repo, [inter.from, inter.to], a.prUrl).then((review) => this.enter(review));
19851
+ a.snapshotter.invalidate(++this.version);
19852
+ return this.publish(a);
19853
+ }
19035
19854
  /**
19036
19855
  * Computes `a`'s current snapshot, re-anchors its threads (hunks may have
19037
19856
  * grown, shrunk or moved), then broadcasts and announces it.
@@ -19039,11 +19858,40 @@ var Session = class {
19039
19858
  async publish(a) {
19040
19859
  const snap = await a.snapshotter.current();
19041
19860
  await this.relocateComments(a, snap);
19861
+ a.moved = null;
19042
19862
  this.hub.broadcast({
19043
19863
  type: "snapshot",
19044
19864
  version: snap.version
19045
19865
  });
19046
19866
  this.announce(snap);
19867
+ return snap;
19868
+ }
19869
+ /**
19870
+ * Resolves the refs the live comparison follows and tells clients when they
19871
+ * left the snapshot behind, or came back to it. At most one check waits
19872
+ * behind the running one, as with `refresh`.
19873
+ */
19874
+ checkMoved(a) {
19875
+ if (this.queuedCheck) return this.queuedCheck;
19876
+ const r = this.run(async () => {
19877
+ this.queuedCheck = null;
19878
+ if (this.active !== a) return;
19879
+ const snap = await a.snapshotter.current();
19880
+ const { oldSha, newSha } = await resolveComparison(this.repo, a.mode.within ?? a.mode);
19881
+ const moved = oldSha === snap.commits.oldSha && newSha === snap.commits.newSha ? null : {
19882
+ version: snap.version,
19883
+ oldSha,
19884
+ newSha
19885
+ };
19886
+ if (moved?.oldSha === a.moved?.oldSha && moved?.newSha === a.moved?.newSha) return;
19887
+ a.moved = moved;
19888
+ this.hub.broadcast({
19889
+ type: "moved",
19890
+ moved
19891
+ });
19892
+ }).catch((e) => console.error("[diffle] refs check failed:", e));
19893
+ this.queuedCheck = r;
19894
+ return r;
19047
19895
  }
19048
19896
  /**
19049
19897
  * Re-anchors threads against what `snap` shows. A changed file shows its
@@ -19107,6 +19955,7 @@ var Session = class {
19107
19955
  * review. Goes through `readSide`, so the allowlist applies.
19108
19956
  */
19109
19957
  anchorSource() {
19958
+ if (this.mode.interdiff) throw new RevspecError(shownIterations(this.mode) ? "comments are off while comparing iterations" : "comments are off in a range-diff");
19110
19959
  return {
19111
19960
  quote: async (path, side, startLine, endLine) => {
19112
19961
  const snap = await this.snapshotter.current();
@@ -19147,6 +19996,10 @@ var Session = class {
19147
19996
  });
19148
19997
  watcher.on("dirty", () => {
19149
19998
  if (this.active !== a) return;
19999
+ if (!this.follows) {
20000
+ this.checkMoved(a);
20001
+ return;
20002
+ }
19150
20003
  this.refresh();
19151
20004
  this.run(async () => {
19152
20005
  if (this.active === a && a.mode.live === "worktree") await refreshIgnored();
@@ -19237,6 +20090,7 @@ function normalize(c) {
19237
20090
  return {
19238
20091
  autoViewed: Array.isArray(c.autoViewed) ? c.autoViewed.filter((p) => typeof p === "string").map((p) => p.trim()).filter(Boolean) : DEFAULT_USER_CONFIG.autoViewed,
19239
20092
  contextLines: typeof c.contextLines === "number" && Number.isFinite(c.contextLines) ? Math.max(0, Math.min(1e4, Math.floor(c.contextLines))) : DEFAULT_USER_CONFIG.contextLines,
20093
+ followRefs: FollowRefsSchema.safeParse(c.followRefs).data ?? DEFAULT_USER_CONFIG.followRefs,
19240
20094
  lspCommands: lspCommands(c)
19241
20095
  };
19242
20096
  }
@@ -22944,6 +23798,12 @@ function parseLspOverride(raw) {
22944
23798
  function collectLspOverride(raw, prev) {
22945
23799
  return [...Array.isArray(prev) ? prev : [], parseLspOverride(raw)];
22946
23800
  }
23801
+ /** A `set-follow-refs` value. */
23802
+ function parseFollowRefs(value) {
23803
+ const parsed = FollowRefsSchema.safeParse(value);
23804
+ if (!parsed.success) throw new InvalidArgumentError("expected on, auto or off");
23805
+ return parsed.data;
23806
+ }
22947
23807
  //#endregion
22948
23808
  //#region src/cli/browserLifetime.ts
22949
23809
  /**
@@ -23089,13 +23949,19 @@ const c = {
23089
23949
  };
23090
23950
  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>"), {
23091
23951
  kind: "choices",
23092
- values: ["working", "pr"]
23952
+ values: [
23953
+ "working",
23954
+ "pr",
23955
+ "range-diff"
23956
+ ]
23093
23957
  })).addHelpText("after", `
23094
23958
  Shorthands (in place of <revs>):
23095
23959
  working same as HEAD..worktree: uncommitted changes, staged and untracked included
23096
23960
  pr [number|url] a GitHub pull request: its base...head, fetched if needed
23097
23961
  (without an argument: the pull request for this branch)
23098
23962
  show [rev] same as <rev>^!: one commit against its first parent, pinned (default: HEAD)
23963
+ range-diff <ranges...> how one series of commits became another, like git range-diff:
23964
+ <a>..<b> <c>..<d> | <base> <b> <d> | <b>...<d>
23099
23965
 
23100
23966
  Revisions follow git diff, except that a lone one compares from the merge base:
23101
23967
  diffle main same as main...HEAD: what this branch added since it left main
@@ -23124,6 +23990,10 @@ program.command("pr", { hidden: true }).argument("[pr]", "pull request number or
23124
23990
  kind: "pr",
23125
23991
  pr
23126
23992
  }, cmd.optsWithGlobals()));
23993
+ program.command("range-diff", { hidden: true }).argument("<ranges...>", "two ranges: <a>..<b> <c>..<d>, <base> <b> <d>, or <b>...<d>").summary("two series of commits").description("How one series of commits became another, as git range-diff shows it: the older range replayed onto the newer one’s base against the newer range, with the commits paired.").action(async (ranges, _o, cmd) => run({
23994
+ kind: "range-diff",
23995
+ args: ranges
23996
+ }, cmd.optsWithGlobals()));
23127
23997
  const config = program.command("config").description("show or edit the user config (same settings as the UI dialog)");
23128
23998
  config.command("show", { isDefault: true }).description("print the config file path and contents").action(async () => {
23129
23999
  const store = await UserConfigStore.open();
@@ -23140,6 +24010,11 @@ config.command("set-context").description("set context lines around changes").ar
23140
24010
  await store.set({ contextLines: n });
23141
24011
  console.log(store.get().contextLines);
23142
24012
  });
24013
+ config.command("set-follow-refs").description("follow moved refs: on, auto (not branches), off").argument("<mode>", "on: reload at once; auto: so unless a branch is compared; off: offer a Reload button", parseFollowRefs).action(async (followRefs) => {
24014
+ const store = await UserConfigStore.open();
24015
+ await store.set({ followRefs });
24016
+ console.log(store.get().followRefs);
24017
+ });
23143
24018
  config.command("set-lsp").description("set the language-server command for one language").addArgument(languageArgument("<language>", `one of: ${LANGUAGE_IDS.join(", ")}`).argParser(parseLanguage)).argument("<command>", "shell command that starts a stdio language server, e.g. \"pyrefly lsp\"; \"\" to turn it off").action(async (language, command) => {
23144
24019
  const store = await UserConfigStore.open();
23145
24020
  await store.set({ lspCommands: {
@@ -23215,6 +24090,7 @@ async function serve(req, opts, repo, closeRepo, config, github, timing) {
23215
24090
  const session = new Session(repo, hub, {
23216
24091
  watch: opts.watch,
23217
24092
  context: opts.context ?? config.get().contextLines,
24093
+ followRefs: config.get().followRefs,
23218
24094
  github: github.client ?? void 0
23219
24095
  });
23220
24096
  const named = Array.isArray(opts.lsp) ? opts.lsp : [];
@@ -23277,7 +24153,7 @@ async function serve(req, opts, repo, closeRepo, config, github, timing) {
23277
24153
  console.error(`🔍 ${c.dim("comparing")} ${c.bold(comparisonLabel(snap.mode))}`);
23278
24154
  if (n === 0) console.error(`${c.yellow("!")} No differences. Open any file from the tree to comment on it.`);
23279
24155
  else console.error(`📝 ${n} changed file${n === 1 ? "" : "s"} ${c.green(`+${adds}`)} ${c.red(`−${dels}`)}`);
23280
- if (snap.mode.live !== "none") console.error(`👀 ${c.dim(snap.mode.live === "worktree" ? "watching the worktree" : "watching refs")}${opts.watch ? "" : c.dim(" (disabled with --no-watch)")}`);
24156
+ if (snap.mode.live !== "none") console.error(`👀 ${c.dim(snap.mode.live === "worktree" ? "watching the worktree" : session.follows ? "watching refs" : "watching refs; moved refs show a Reload button")}${opts.watch ? "" : c.dim(" (disabled with --no-watch)")}`);
23281
24157
  if (lsp) {
23282
24158
  const { servers, missing } = lsp.status();
23283
24159
  const names = servers.map((s) => `${s.name} ${c.dim(`(${s.languages.join(", ")})`)}`).join(", ");