@moritzwilksch/diffle 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +4 -2
  2. package/dist/client/assets/{angular-html-CJY2n6PT.js → angular-html-NJfvXMBu.js} +1 -1
  3. package/dist/client/assets/{angular-ts-CZTM8_xo.js → angular-ts-3VmQ16GC.js} +1 -1
  4. package/dist/client/assets/{apl-Dcp77-Tz.js → apl-CaYQRkUE.js} +1 -1
  5. package/dist/client/assets/{astro-C0gdsdoF.js → astro-CGsy8jQJ.js} +1 -1
  6. package/dist/client/assets/{blade-BYpI0fWU.js → blade-CbyPPxmo.js} +1 -1
  7. package/dist/client/assets/{c-DvFVe1b3.js → c-BaJwvHpK.js} +1 -1
  8. package/dist/client/assets/{chapel-qAWjGK-V.js → chapel-BokLUCVc.js} +1 -1
  9. package/dist/client/assets/{cobol-BeFQKGES.js → cobol-I7_VFdfF.js} +1 -1
  10. package/dist/client/assets/{coffee-XLni1sjM.js → coffee-BJ7WLX7y.js} +1 -1
  11. package/dist/client/assets/{cpp-ChsJNYBC.js → cpp-DSFvuOlH.js} +1 -1
  12. package/dist/client/assets/{crystal-D3SbG8yD.js → crystal-DLLJ7kug.js} +1 -1
  13. package/dist/client/assets/{css-VXnGhPbY.js → css-CW7SLGV7.js} +1 -1
  14. package/dist/client/assets/{edge-Dbzmpk4W.js → edge-DQEyTxuu.js} +1 -1
  15. package/dist/client/assets/{elixir-DvQULd_0.js → elixir-Dwvydtjv.js} +1 -1
  16. package/dist/client/assets/{elm-BM5mpULp.js → elm-DPrZsI-D.js} +1 -1
  17. package/dist/client/assets/{erb-nYCgf5TS.js → erb-By39__m0.js} +1 -1
  18. package/dist/client/assets/{git-rebase-CcDsi2L8.js → git-rebase-CBF3buEb.js} +1 -1
  19. package/dist/client/assets/{glimmer-js-94igFmPU.js → glimmer-js-C4TvHlaw.js} +1 -1
  20. package/dist/client/assets/{glimmer-ts-8aOBlhVe.js → glimmer-ts-BPhq137Q.js} +1 -1
  21. package/dist/client/assets/{glsl-DAW_sXZj.js → glsl-BlwDtIwk.js} +1 -1
  22. package/dist/client/assets/{graphql-BEeRZxhy.js → graphql-BgMqs-o1.js} +1 -1
  23. package/dist/client/assets/{hack-e4pIPigB.js → hack-DrMUHl8k.js} +1 -1
  24. package/dist/client/assets/{haml-DvZH4eyc.js → haml-BWbuOop2.js} +1 -1
  25. package/dist/client/assets/{handlebars-Bod10MIb.js → handlebars-D-TP7YFr.js} +1 -1
  26. package/dist/client/assets/{html-BOKVgXUz.js → html-RkKdupbJ.js} +1 -1
  27. package/dist/client/assets/{html-derivative-B5DftdEs.js → html-derivative-BiXkRfVa.js} +1 -1
  28. package/dist/client/assets/{http-Bli9WHgJ.js → http-CF05Tv0R.js} +1 -1
  29. package/dist/client/assets/{hurl-C45hn_gr.js → hurl-p-K5zx-F.js} +1 -1
  30. package/dist/client/assets/index-C-uvIS11.css +2 -0
  31. package/dist/client/assets/index-Dw_tHgT2.js +3005 -0
  32. package/dist/client/assets/{java-BxezylI4.js → java-DZgPGAdX.js} +1 -1
  33. package/dist/client/assets/{javascript-CGUJoh42.js → javascript-UpC6uRwd.js} +1 -1
  34. package/dist/client/assets/{jinja-Dhrq1TMH.js → jinja-BwlD6UdE.js} +1 -1
  35. package/dist/client/assets/{jison-CK02w6aE.js → jison-CXzrtNYv.js} +1 -1
  36. package/dist/client/assets/{json-DbjMq68q.js → json-CvzeYtbd.js} +1 -1
  37. package/dist/client/assets/{jsx-BVYjBFnX.js → jsx-CRRNRy_v.js} +1 -1
  38. package/dist/client/assets/{julia-DUG0dPfw.js → julia-n9zQTAkT.js} +1 -1
  39. package/dist/client/assets/{just-BzILq64e.js → just-Cqgx3k16.js} +1 -1
  40. package/dist/client/assets/{latex-DJ-r-_gD.js → latex-CtWS0r44.js} +1 -1
  41. package/dist/client/assets/{liquid-CUeuBds8.js → liquid-DYOfxp51.js} +1 -1
  42. package/dist/client/assets/{lua-CUx56wFu.js → lua-BH2jjxbT.js} +1 -1
  43. package/dist/client/assets/{marko-zidFMx4y.js → marko-yGXqh8Mu.js} +1 -1
  44. package/dist/client/assets/{mdc-B8YDeE-G.js → mdc-DBTGzC9c.js} +1 -1
  45. package/dist/client/assets/{nginx-CLmnmibq.js → nginx-C6OOwetR.js} +1 -1
  46. package/dist/client/assets/{nim-D_ow61_F.js → nim-B-51OZwP.js} +1 -1
  47. package/dist/client/assets/{org-D6yFID6y.js → org-rdh6JnaZ.js} +1 -1
  48. package/dist/client/assets/{perl-DlRNqEum.js → perl-BEpLn7O_.js} +1 -1
  49. package/dist/client/assets/{php-CPe77eH-.js → php-uM1K2nIh.js} +1 -1
  50. package/dist/client/assets/{pug-CzDq8hOp.js → pug-CX5jxSmg.js} +1 -1
  51. package/dist/client/assets/{qml-CmVqwUZS.js → qml-BjaL9kT9.js} +1 -1
  52. package/dist/client/assets/{r-CZql-PDg.js → r-CbP_oADb.js} +1 -1
  53. package/dist/client/assets/{razor-Dp5yql5G.js → razor-ClgwHmG8.js} +1 -1
  54. package/dist/client/assets/{regexp-DjyDhvtX.js → regexp-DkGv1hXs.js} +1 -1
  55. package/dist/client/assets/{rst-B-Q6Ljb6.js → rst-KbuceK-d.js} +1 -1
  56. package/dist/client/assets/{ruby-BQD6FS2h.js → ruby-IyWK5TqJ.js} +1 -1
  57. package/dist/client/assets/{sas-DDGcym6k.js → sas-CGY91YIG.js} +1 -1
  58. package/dist/client/assets/{scss-DNPPx0rx.js → scss-C5azgOdY.js} +1 -1
  59. package/dist/client/assets/{shellscript-CHWpWo83.js → shellscript-Do9KSXv1.js} +1 -1
  60. package/dist/client/assets/{shellsession-_KE7LUMO.js → shellsession-DG2DOiiH.js} +1 -1
  61. package/dist/client/assets/{soy-B57xVh7W.js → soy-BLO2pcf_.js} +1 -1
  62. package/dist/client/assets/{sql-8OXlDHCR.js → sql-NhQC2aCy.js} +1 -1
  63. package/dist/client/assets/{stata-C452rT5l.js → stata-BT8bwSiP.js} +1 -1
  64. package/dist/client/assets/{surrealql-B5gNeOUk.js → surrealql-DeFUr0Sz.js} +1 -1
  65. package/dist/client/assets/{svelte-Cv_BwjgR.js → svelte-TRjcWU3h.js} +1 -1
  66. package/dist/client/assets/{templ-qZdOs42N.js → templ-C1Ef7mnt.js} +1 -1
  67. package/dist/client/assets/{tex-BK-MJpDM.js → tex-DWZKUBNC.js} +1 -1
  68. package/dist/client/assets/{ts-tags-C5Q-Olrs.js → ts-tags-BPGHRwAa.js} +1 -1
  69. package/dist/client/assets/{tsx-DwPm0lid.js → tsx-DFPjrnBq.js} +1 -1
  70. package/dist/client/assets/{twig-DKj0IG75.js → twig-BnaDylmD.js} +1 -1
  71. package/dist/client/assets/{typescript-BeWKsdVP.js → typescript-CtjU5LIe.js} +1 -1
  72. package/dist/client/assets/{typst-B7u-3Y0n.js → typst-CKVv919t.js} +1 -1
  73. package/dist/client/assets/{vue-BkC1F40W.js → vue-DQGhQ-pk.js} +1 -1
  74. package/dist/client/assets/{vue-html-BeYXHyr2.js → vue-html-BtU_VFsU.js} +1 -1
  75. package/dist/client/assets/{vue-vine-ymsHpgRC.js → vue-vine-Bg_4C7Q_.js} +1 -1
  76. package/dist/client/assets/{xml-B6M3xnfe.js → xml-DupdE_s3.js} +1 -1
  77. package/dist/client/assets/{xsl-CSlbm6tK.js → xsl-BkEIn0qW.js} +1 -1
  78. package/dist/client/assets/{yaml-fMwD58zK.js → yaml-BuH0PmqQ.js} +1 -1
  79. package/dist/client/index.html +2 -2
  80. package/dist/server/main.js +764 -51
  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.0";
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,31 @@ 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("pair"),
12289
+ commit: string().nullable()
12168
12290
  })
12169
12291
  ]);
12170
12292
  /**
12293
+ * One state of a reviewed range: its resolved endpoints when a snapshot of it was taken. A new
12294
+ * one is recorded whenever the endpoints differ from the last, so a push, rebase or amend
12295
+ * yields the next iteration; the commits are pinned under `refs/diffle/iterations/`.
12296
+ */
12297
+ const IterationSchema = object({
12298
+ /** 1-based, in recording order. */
12299
+ n: number(),
12300
+ oldSha: string(),
12301
+ newSha: string(),
12302
+ /** Epoch milliseconds. */
12303
+ recordedAt: number()
12304
+ });
12305
+ /**
12171
12306
  * How the old side is derived: `old` itself, merge-base(old, new) with worktree
12172
12307
  * taken as HEAD, or new's first parent (the empty tree for a root commit).
12173
12308
  */
@@ -12189,18 +12324,88 @@ const ComparisonSchema = object({
12189
12324
  /** Fixed when the comparison is entered; discovery never changes comment storage. */
12190
12325
  commentKey: string()
12191
12326
  });
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() });
12327
+ const CommitInfoSchema = object({
12328
+ sha: string(),
12329
+ short: string(),
12330
+ message: string()
12331
+ });
12332
+ /** A single reviewed commit and its neighbours along HEAD's first-parent history, for stepping through it. */
12333
+ const ReviewedCommitSchema = CommitInfoSchema.extend({
12334
+ /** First parent; null for a root commit. */
12335
+ parent: string().nullable(),
12336
+ /** The commit whose first parent this is, toward HEAD; null at HEAD or off HEAD's first-parent line. */
12337
+ child: string().nullable()
12338
+ });
12339
+ /** One commit of the compared range. */
12340
+ const RangeCommitSchema = CommitInfoSchema.extend({
12341
+ author: string(),
12342
+ email: string(),
12343
+ /** Author date, epoch milliseconds. */
12344
+ date: number()
12345
+ });
12346
+ /** How a commit of the older iteration relates to one of the newer, as `git range-diff` pairs them. */
12347
+ const PairStatusSchema = _enum([
12348
+ "identical",
12349
+ "changed",
12350
+ "message",
12351
+ "added",
12352
+ "dropped"
12353
+ ]);
12354
+ /**
12355
+ * One row of a range-diff: `identical` and `changed` pairs have both commits, a `message` pair
12356
+ * the same patch under a new message, an `added` commit no old side and a `dropped` one no new side.
12357
+ */
12358
+ const RangePairSchema = object({
12359
+ old: RangeCommitSchema.nullable(),
12360
+ new: RangeCommitSchema.nullable(),
12361
+ status: PairStatusSchema
12362
+ });
12363
+ /**
12364
+ * Two iterations of a range compared. The old side is `from`'s head replayed onto `to`'s base
12365
+ * (a tree, not a commit), so a rebase in between does not show up as the upstream's changes;
12366
+ * the new side is `to`'s head.
12367
+ */
12368
+ const InterdiffSchema = object({
12369
+ from: IterationSchema,
12370
+ to: IterationSchema,
12371
+ /** Paths the replay could not merge; they carry conflict markers on the old side. */
12372
+ conflicts: string().array(),
12373
+ /** The two ranges' commits paired, in `git range-diff` order: the newer range's, dropped commits where they were. */
12374
+ pairs: RangePairSchema.array()
12375
+ });
12376
+ /**
12377
+ * One pair of an interdiff shown alone: `new` against `old` replayed onto `new`'s parent, so the
12378
+ * pair's own amendments show without the rebase; an added commit (`old` null) against its parent.
12379
+ */
12380
+ const PairSpecSchema = object({
12381
+ old: string().nullable(),
12382
+ new: string(),
12383
+ conflicts: string().array()
12384
+ });
12385
+ const ModeSpecSchema = ComparisonSchema.extend({
12386
+ /** Set while one commit of a range is focused, or two of its iterations compared: the range, whose commits the snapshot keeps listing. */
12387
+ within: ComparisonSchema.optional(),
12388
+ interdiff: InterdiffSchema.optional(),
12389
+ /** Set with `interdiff` while one of its pairs is shown. */
12390
+ pair: PairSpecSchema.optional()
12391
+ });
12195
12392
  /**
12196
12393
  * 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.
12394
+ * `<sha>^!`, as `<range> @ <sha>` when focused within a range, as `<range> #1→#2` for an interdiff and
12395
+ * `<range> #1→#2 @ <sha>` for one of its pairs.
12198
12396
  */
12199
12397
  function comparisonLabel(mode) {
12398
+ if (mode.within && mode.interdiff) {
12399
+ const label = `${comparisonLabel(mode.within)} #${mode.interdiff.from.n}→#${mode.interdiff.to.n}`;
12400
+ return mode.pair ? `${label} @ ${mode.pair.new.slice(0, 7)}` : label;
12401
+ }
12200
12402
  if (mode.within) return `${comparisonLabel(mode.within)} @ ${mode.new.slice(0, 7)}`;
12201
12403
  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)}`;
12404
+ return `${refName(mode.old)}${mode.base === "merge-base" ? "..." : ".."}${refName(mode.new)}`;
12405
+ }
12406
+ /** A comparison endpoint as the user named it: without diffle's PR namespace or `refs/heads/`. */
12407
+ function refName(rev) {
12408
+ return rev.replace(/^refs\/diffle\/[^/]+\/\d+\/(?:base|head)\//, "").replace(/^refs\/(?:heads|remotes)\//, "");
12204
12409
  }
12205
12410
  const GithubPullRequestSchema = object({
12206
12411
  repository: string(),
@@ -12250,31 +12455,17 @@ const ChangedFileSchema = object({
12250
12455
  /** A gitlink (mode 160000): `blob` is the recorded commit, the patch shows the commit-id change, and there is no file to open. */
12251
12456
  submodule: literal(true).optional()
12252
12457
  });
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
12458
  /**
12266
12459
  * The commits a comparison spans: old..new, with the worktree taken as HEAD, or the one commit under `parent`.
12267
12460
  * A focused commit lists its range's.
12268
12461
  */
12269
12462
  const RangeCommitsSchema = object({
12270
12463
  /** 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()
12464
+ list: RangeCommitSchema.array(),
12465
+ total: number(),
12466
+ /** The spanned comparison's resolved endpoints: the snapshot's own, or the range's when a commit is focused. */
12467
+ oldSha: string(),
12468
+ newSha: string()
12278
12469
  });
12279
12470
  object({
12280
12471
  root: string(),
@@ -12293,7 +12484,20 @@ object({
12293
12484
  tree: string().array(),
12294
12485
  /** The reviewed commit when the comparison is a single commit against its parent; otherwise null. */
12295
12486
  commit: ReviewedCommitSchema.nullable(),
12296
- commits: RangeCommitsSchema
12487
+ commits: RangeCommitsSchema,
12488
+ /** The recorded iterations of the range `commits` spans, oldest first; empty for comparisons that are not followed. */
12489
+ iterations: IterationSchema.array()
12490
+ });
12491
+ /**
12492
+ * The compared refs resolve elsewhere than when the shown snapshot was taken. A refs-live
12493
+ * comparison is not recomputed under the reviewer; it reloads on request.
12494
+ */
12495
+ const MovedSchema = object({
12496
+ /** The snapshot a reload would supersede. */
12497
+ version: number(),
12498
+ /** Where the comparison `Snapshot.commits` spans resolves now. */
12499
+ oldSha: string(),
12500
+ newSha: string()
12297
12501
  });
12298
12502
  /** Body of `POST /api/patch`: the changed files whose patches to return, concatenated. Unknown paths are skipped. */
12299
12503
  const PatchRequestSchema = object({ paths: string().array().max(200) });
@@ -12425,11 +12629,24 @@ object({
12425
12629
  matches: SearchMatchSchema.array(),
12426
12630
  truncated: boolean()
12427
12631
  });
12632
+ /**
12633
+ * How a review between refs reacts when one of them moves. `on`: recompute at once, as a worktree
12634
+ * review follows edits. `off`: announce the move and wait for Reload. `auto`: wait for Reload when an
12635
+ * endpoint names a branch, whose pushes and rebases should not swap the review out under the
12636
+ * reviewer; recompute at once otherwise (a detached HEAD, a tag, an expression).
12637
+ */
12638
+ const FollowRefsSchema = _enum([
12639
+ "on",
12640
+ "auto",
12641
+ "off"
12642
+ ]);
12428
12643
  object({
12429
12644
  /** Globs (picomatch syntax) for files that start viewed + collapsed. Empty by default. */
12430
12645
  autoViewed: string().array(),
12431
12646
  /** Unchanged lines shown around each change (git -U). Default 5. */
12432
12647
  contextLines: number(),
12648
+ /** When a review between refs recomputes as its refs move; see `FollowRefsSchema`. Default auto. */
12649
+ followRefs: FollowRefsSchema,
12433
12650
  /**
12434
12651
  * Language server command per language, replacing the built-in candidate for it; an empty
12435
12652
  * string disables the language. Read-only over HTTP; set with `diffle config set-lsp`.
@@ -12439,6 +12656,7 @@ object({
12439
12656
  const DEFAULT_USER_CONFIG = {
12440
12657
  autoViewed: [],
12441
12658
  contextLines: 5,
12659
+ followRefs: "auto",
12442
12660
  lspCommands: {}
12443
12661
  };
12444
12662
  /** A point in a new-side file. LSP semantics: the column counts UTF-16 units, like a JS string index. */
@@ -12509,6 +12727,18 @@ object({
12509
12727
  object({
12510
12728
  /** Null when the position is not inside a token or the server does not classify tokens. */
12511
12729
  kind: string().nullable() });
12730
+ object({ occurrences: object({
12731
+ line: number().int().positive(),
12732
+ col: number().int().nonnegative(),
12733
+ endLine: number().int().positive(),
12734
+ endCol: number().int().nonnegative(),
12735
+ /** LSP DocumentHighlightKind: a plain textual match, a read, or a write. */
12736
+ kind: _enum([
12737
+ "text",
12738
+ "read",
12739
+ "write"
12740
+ ])
12741
+ }).array() });
12512
12742
  object({
12513
12743
  name: string(),
12514
12744
  /** LSP SymbolKind number; the client maps it to a label. */
@@ -12531,6 +12761,10 @@ discriminatedUnion("type", [
12531
12761
  type: literal("snapshot"),
12532
12762
  version: number()
12533
12763
  }),
12764
+ object({
12765
+ type: literal("moved"),
12766
+ moved: MovedSchema.nullable()
12767
+ }),
12534
12768
  object({ type: literal("threads") }),
12535
12769
  object({ type: literal("viewed") }),
12536
12770
  object({ type: literal("config") }),
@@ -12624,7 +12858,8 @@ function noServer(lsp, language) {
12624
12858
  /** Writable HTTP config fields; unknown keys, including CLI-only lspCommands, are stripped. */
12625
12859
  const ConfigUpdateSchema = object({
12626
12860
  autoViewed: string().array().optional(),
12627
- contextLines: number().int().min(0).max(1e4).optional()
12861
+ contextLines: number().int().min(0).max(1e4).optional(),
12862
+ followRefs: FollowRefsSchema.optional()
12628
12863
  });
12629
12864
  const ResolvedRequestSchema = object({ resolved: boolean() });
12630
12865
  const ViewedBulkRequestSchema = object({ entries: ViewedEntrySchema.array() });
@@ -12670,6 +12905,76 @@ const ClearThreadsQuerySchema = object({ stale: literal("1").optional() });
12670
12905
  const PatchQuerySchema = object({ path: string().optional() });
12671
12906
  object({ error: string() });
12672
12907
  //#endregion
12908
+ //#region src/shared/fuzzy.ts
12909
+ const MATCH = 1;
12910
+ const WORD_START = 8;
12911
+ const CONSECUTIVE = 4;
12912
+ const GAP = 1;
12913
+ /**
12914
+ * Score `candidate` against `query`, matched case-insensitively as an ordered subsequence;
12915
+ * null when it does not match. Higher is better: matches at word starts (after `_-./`, camelCase
12916
+ * humps, digit runs) and unbroken runs count most, so `AbsBuiFa` finds `AbstractBuilderFactory`
12917
+ * and `evruca` finds `evaluate_run_cases.py`.
12918
+ */
12919
+ function fuzzyScore(query, candidate) {
12920
+ const q = Array.from(query.toLowerCase());
12921
+ const chars = Array.from(candidate);
12922
+ const n = q.length;
12923
+ const m = chars.length;
12924
+ if (n === 0) return 0;
12925
+ if (n > m) return null;
12926
+ const lower = chars.map((ch) => ch.toLowerCase());
12927
+ const bonus = chars.map((ch, j) => MATCH + wordStart(chars[j - 1], ch));
12928
+ let prev = new Float64Array(m).fill(-Infinity);
12929
+ for (let i = 0; i < n; i++) {
12930
+ const cur = new Float64Array(m).fill(-Infinity);
12931
+ let carry = -Infinity;
12932
+ for (let j = i; j < m; j++) {
12933
+ if (i > 0) carry = Math.max(carry - GAP, prev[j - 1]);
12934
+ if (lower[j] !== q[i]) continue;
12935
+ cur[j] = (i === 0 ? 0 : Math.max(carry, prev[j - 1] + CONSECUTIVE)) + bonus[j];
12936
+ }
12937
+ prev = cur;
12938
+ }
12939
+ const best = Math.max(...prev);
12940
+ return best === -Infinity ? null : best;
12941
+ }
12942
+ function wordStart(before, ch) {
12943
+ if (before === void 0) return 10;
12944
+ if (!ALNUM.test(before)) return WORD_START;
12945
+ if (UPPER.test(ch) && !UPPER.test(before)) return WORD_START;
12946
+ if (DIGIT.test(ch) && !DIGIT.test(before)) return WORD_START;
12947
+ return 0;
12948
+ }
12949
+ const ALNUM = /[\p{L}\p{N}]/u;
12950
+ const UPPER = /\p{Lu}/u;
12951
+ const DIGIT = /\p{N}/u;
12952
+ /**
12953
+ * `items` whose `key` matches `query`, best first; ties prefer the shorter key, then input order.
12954
+ * A blank query keeps every item in order.
12955
+ */
12956
+ function fuzzyFilter(items, query, key) {
12957
+ const q = query.trim();
12958
+ if (!q) return [...items];
12959
+ const scored = [];
12960
+ for (const item of items) {
12961
+ const k = key(item);
12962
+ const score = fuzzyScore(q, k);
12963
+ if (score !== null) scored.push({
12964
+ item,
12965
+ score,
12966
+ length: k.length
12967
+ });
12968
+ }
12969
+ return scored.sort((a, b) => b.score - a.score || a.length - b.length).map((s) => s.item);
12970
+ }
12971
+ /** `fuzzyFilter`'s order, followed by the items it rejects in input order: re-ranks hits another matcher chose. */
12972
+ function fuzzyRank(items, query, key) {
12973
+ const matched = fuzzyFilter(items, query, key);
12974
+ const kept = new Set(matched);
12975
+ return [...matched, ...items.filter((item) => !kept.has(item))];
12976
+ }
12977
+ //#endregion
12673
12978
  //#region src/server/lsp/hover.ts
12674
12979
  /**
12675
12980
  * One markdown document for the tooltip, or null when the server sent nothing
@@ -13239,6 +13544,30 @@ var LspBridge = class LspBridge {
13239
13544
  }
13240
13545
  return { kind: null };
13241
13546
  }
13547
+ /**
13548
+ * Where else the symbol at `pos` occurs in its file, via `textDocument/documentHighlight`,
13549
+ * so scope and shadowing are the server's call. A server without the method, or a
13550
+ * position holding no symbol, yields no occurrences: the answer is advisory.
13551
+ */
13552
+ async occurrences(pos) {
13553
+ let result;
13554
+ try {
13555
+ result = await this.query("textDocument/documentHighlight", {
13556
+ textDocument: { uri: this.uri(pos.path) },
13557
+ position: toLsp(pos)
13558
+ }, pos.path);
13559
+ } catch (e) {
13560
+ if (e instanceof LspUnavailableError) throw e;
13561
+ return { occurrences: [] };
13562
+ }
13563
+ return { occurrences: (result ?? []).map(({ range: { start, end }, kind }) => ({
13564
+ line: start.line + 1,
13565
+ col: start.character,
13566
+ endLine: end.line + 1,
13567
+ endCol: end.character,
13568
+ kind: kind === 3 ? "write" : kind === 2 ? "read" : "text"
13569
+ })) };
13570
+ }
13242
13571
  async references(pos, opts = {}) {
13243
13572
  const result = await this.query("textDocument/references", {
13244
13573
  textDocument: { uri: this.uri(pos.path) },
@@ -13321,7 +13650,7 @@ var LspBridge = class LspBridge {
13321
13650
  }
13322
13651
  /**
13323
13652
  * Symbols the server knows for `query`, limited to paths the snapshot
13324
- * exposes. Membership comes from `has`, never from reading the file: a
13653
+ * exposes, best `fuzzyRank` match first. Membership comes from `has`, never from reading the file: a
13325
13654
  * per-keystroke search must not read every hit's text.
13326
13655
  */
13327
13656
  async workspaceSymbols(query, limit = 200) {
@@ -13333,7 +13662,7 @@ var LspBridge = class LspBridge {
13333
13662
  const seen = /* @__PURE__ */ new Set();
13334
13663
  result.forEach((s, i) => {
13335
13664
  const path = paths[i];
13336
- if (path == null || !inside.get(path) || out.length >= limit) return;
13665
+ if (path == null || !inside.get(path)) return;
13337
13666
  const start = "range" in s.location ? s.location.range.start : {
13338
13667
  line: 0,
13339
13668
  character: 0
@@ -13352,7 +13681,7 @@ var LspBridge = class LspBridge {
13352
13681
  col: start.character
13353
13682
  });
13354
13683
  });
13355
- return out;
13684
+ return fuzzyRank(out, query, (s) => s.name).slice(0, limit);
13356
13685
  }
13357
13686
  /**
13358
13687
  * Contents of a file outside the snapshot that a recent result named as
@@ -13466,6 +13795,7 @@ var LspBridge = class LspBridge {
13466
13795
  definition: { linkSupport: true },
13467
13796
  typeDefinition: { linkSupport: true },
13468
13797
  references: {},
13798
+ documentHighlight: {},
13469
13799
  hover: { contentFormat: ["markdown", "plaintext"] },
13470
13800
  documentSymbol: { hierarchicalDocumentSymbolSupport: true },
13471
13801
  semanticTokens: {
@@ -14051,11 +14381,14 @@ var LspPool = class {
14051
14381
  async references(pos) {
14052
14382
  return this.bridgeFor(pos.path).references(pos);
14053
14383
  }
14384
+ async occurrences(pos) {
14385
+ return this.bridgeFor(pos.path).occurrences(pos);
14386
+ }
14054
14387
  async documentSymbols(path) {
14055
14388
  return this.bridgeFor(path).documentSymbols(path);
14056
14389
  }
14057
14390
  /**
14058
- * Symbols matching `query` from every running server, in server order. One server
14391
+ * Symbols matching `query` from every running server, best `fuzzyRank` match first. One server
14059
14392
  * failing drops its share; only an outage everywhere is an error.
14060
14393
  */
14061
14394
  async workspaceSymbols(query, limit = 200) {
@@ -14065,7 +14398,7 @@ var LspPool = class {
14065
14398
  const out = results.flatMap((r) => r.status === "fulfilled" ? r.value : []);
14066
14399
  const failed = results.find((r) => r.status === "rejected");
14067
14400
  if (!out.length && failed) throw failed.reason;
14068
- return out.slice(0, limit);
14401
+ return fuzzyRank(out, query, (s) => s.name).slice(0, limit);
14069
14402
  }
14070
14403
  /**
14071
14404
  * Keeps the diff's files open in the server for their language, starting one for each
@@ -17536,6 +17869,14 @@ function createApi(deps) {
17536
17869
  await next();
17537
17870
  });
17538
17871
  app.get("/api/snapshot", async (c) => c.json(await session.snapshotter.current()));
17872
+ app.get("/api/moved", (c) => c.json(session.moved));
17873
+ app.post("/api/reload", async (c) => c.json(await session.reload()));
17874
+ app.delete("/api/iterations", async (c) => c.json(await session.clearIterations()));
17875
+ app.delete("/api/iterations/:n", async (c) => {
17876
+ const n = Number(c.req.param("n"));
17877
+ if (!Number.isInteger(n) || n < 1) throw new RevspecError(`not an iteration number: ${c.req.param("n")}`);
17878
+ return c.json(await session.deleteIteration(n));
17879
+ });
17539
17880
  app.post("/api/mode", async (c) => {
17540
17881
  const req = ModeRequestSchema.parse(await readJson(c));
17541
17882
  return c.json(await session.switchMode(req));
@@ -17731,6 +18072,12 @@ function createApi(deps) {
17731
18072
  if ("error" in r) return c.json({ error: r.error }, 409);
17732
18073
  return c.json(await r.lsp.references(pos));
17733
18074
  });
18075
+ app.post("/api/lsp/occurrences", async (c) => {
18076
+ const pos = LspPositionSchema.parse(await readJson(c));
18077
+ const r = await lspFor(pos.path);
18078
+ if ("error" in r) return c.json({ error: r.error }, 409);
18079
+ return c.json(await r.lsp.occurrences(pos));
18080
+ });
17734
18081
  app.get("/api/lsp/symbols", async (c) => {
17735
18082
  const { path, q: query } = SymbolsQuerySchema.parse(c.req.query());
17736
18083
  const r = await lspFor(path);
@@ -17743,6 +18090,7 @@ function createApi(deps) {
17743
18090
  await deps.config.set(body);
17744
18091
  hub.broadcast({ type: "config" });
17745
18092
  if (body.contextLines != null) await session.setContext(deps.config.get().contextLines);
18093
+ if (body.followRefs != null) session.setFollowRefs(deps.config.get().followRefs);
17746
18094
  return c.json(effectiveConfig(deps));
17747
18095
  });
17748
18096
  return app;
@@ -18422,6 +18770,102 @@ function describe(skipped) {
18422
18770
  for (const s of skipped) counts.set(s.reason, (counts.get(s.reason) ?? 0) + 1);
18423
18771
  return [...counts].map(([reason, n]) => `${n} ${reason}`).join(", ");
18424
18772
  }
18773
+ const FileSchema = object({
18774
+ version: literal(1),
18775
+ ranges: record(string(), IterationSchema.array())
18776
+ });
18777
+ /**
18778
+ * The iterations of one range, under its comment key in `<gitDir>/diffle/iterations.json`.
18779
+ * Like the comment store, nothing is cached: every mutation is a read-modify-write of fresh
18780
+ * disk state under the file lock, so two servers on one repository keep each other's records.
18781
+ * Each iteration's commits are pinned under `refs/diffle/iterations/<key hash>/<n>/{old,new}`.
18782
+ */
18783
+ var IterationStore = class {
18784
+ repo;
18785
+ key;
18786
+ file;
18787
+ refs;
18788
+ constructor(repo, key) {
18789
+ this.repo = repo;
18790
+ this.key = key;
18791
+ this.file = join(repo.gitDir, "diffle", "iterations.json");
18792
+ this.refs = `refs/diffle/iterations/${createHash("sha1").update(key).digest("hex").slice(0, 16)}`;
18793
+ }
18794
+ /** Oldest first. */
18795
+ async list() {
18796
+ return (await read(this.file)).ranges[this.key] ?? [];
18797
+ }
18798
+ /** The iteration numbered `n`, or null. */
18799
+ async get(n) {
18800
+ return (await this.list()).find((it) => it.n === n) ?? null;
18801
+ }
18802
+ /**
18803
+ * Records the endpoints as the next iteration, unless the latest already has them. Returns
18804
+ * the list afterwards. Pins the commits first, so a recorded iteration is always reachable.
18805
+ */
18806
+ async record(oldSha, newSha) {
18807
+ return withFileLock(this.file, async () => {
18808
+ const all = await read(this.file);
18809
+ const list = all.ranges[this.key] ?? [];
18810
+ const last = list.at(-1);
18811
+ if (last && last.oldSha === oldSha && last.newSha === newSha) return list;
18812
+ const n = (last?.n ?? 0) + 1;
18813
+ const next = [...list, {
18814
+ n,
18815
+ oldSha,
18816
+ newSha,
18817
+ recordedAt: Date.now()
18818
+ }];
18819
+ const dropped = next.splice(0, Math.max(0, next.length - 50));
18820
+ await this.repo.pin({
18821
+ [`${this.refs}/${n}/old`]: oldSha,
18822
+ [`${this.refs}/${n}/new`]: newSha,
18823
+ ...Object.fromEntries(dropped.flatMap((it) => [[`${this.refs}/${it.n}/old`, null], [`${this.refs}/${it.n}/new`, null]]))
18824
+ });
18825
+ all.ranges[this.key] = next;
18826
+ await writeFileAtomic(this.file, JSON.stringify(all, null, 2) + "\n");
18827
+ return next;
18828
+ });
18829
+ }
18830
+ /** Forgets the iteration numbered `n`, if recorded, and unpins its commits. The rest keep their numbers. */
18831
+ async remove(n) {
18832
+ return withFileLock(this.file, async () => {
18833
+ const all = await read(this.file);
18834
+ const list = all.ranges[this.key] ?? [];
18835
+ if (!list.some((it) => it.n === n)) return list;
18836
+ await this.repo.pin({
18837
+ [`${this.refs}/${n}/old`]: null,
18838
+ [`${this.refs}/${n}/new`]: null
18839
+ });
18840
+ const next = list.filter((it) => it.n !== n);
18841
+ if (next.length) all.ranges[this.key] = next;
18842
+ else delete all.ranges[this.key];
18843
+ await writeFileAtomic(this.file, JSON.stringify(all, null, 2) + "\n");
18844
+ return next;
18845
+ });
18846
+ }
18847
+ /** Forgets every iteration of this range and unpins their commits. */
18848
+ async clear() {
18849
+ await withFileLock(this.file, async () => {
18850
+ const all = await read(this.file);
18851
+ const list = all.ranges[this.key] ?? [];
18852
+ await this.repo.pin(Object.fromEntries(list.flatMap((it) => [[`${this.refs}/${it.n}/old`, null], [`${this.refs}/${it.n}/new`, null]])));
18853
+ delete all.ranges[this.key];
18854
+ await writeFileAtomic(this.file, JSON.stringify(all, null, 2) + "\n");
18855
+ });
18856
+ }
18857
+ };
18858
+ async function read(file) {
18859
+ try {
18860
+ return FileSchema.parse(JSON.parse(await readFile(file, "utf8")));
18861
+ } catch (e) {
18862
+ if (e.code === "ENOENT") return {
18863
+ version: 1,
18864
+ ranges: {}
18865
+ };
18866
+ throw e;
18867
+ }
18868
+ }
18425
18869
  //#endregion
18426
18870
  //#region src/server/mode.ts
18427
18871
  /**
@@ -18451,6 +18895,11 @@ function describe(skipped) {
18451
18895
  * the empty tree. Invalid revisions or missing merge bases throw RevspecError.
18452
18896
  */
18453
18897
  async function resolveComparison(repo, comparison) {
18898
+ if (comparison.interdiff && comparison.base !== "parent") return {
18899
+ oldSha: comparison.old,
18900
+ newSha: comparison.new,
18901
+ live: "none"
18902
+ };
18454
18903
  const endpoints = [comparison.old, comparison.new];
18455
18904
  const resolve = async (rev) => {
18456
18905
  if (rev === "worktree") return rev;
@@ -18487,6 +18936,11 @@ async function resolveComparison(repo, comparison) {
18487
18936
  live: endpoints.includes("worktree") ? "worktree" : pinned(comparison.old, old) && pinned(comparison.new, next) ? "none" : "refs"
18488
18937
  };
18489
18938
  }
18939
+ /** Whether either endpoint names a local or remote branch, HEAD on one included. */
18940
+ async function namesBranch(repo, comparison) {
18941
+ const [old, next] = await Promise.all([repo.branchRef(comparison.old), repo.branchRef(comparison.new)]);
18942
+ return old != null || next != null;
18943
+ }
18490
18944
  /** Resolves an input command into a comparison and optional explicit PR identity. */
18491
18945
  async function resolveReview(req, repo, github) {
18492
18946
  if (req.kind === "pr") return resolvePr(req, repo, github);
@@ -18647,16 +19101,18 @@ var Snapshotter = class {
18647
19101
  mode;
18648
19102
  version;
18649
19103
  context;
19104
+ iterations;
18650
19105
  cache = null;
18651
19106
  patches = /* @__PURE__ */ new Map();
18652
19107
  all = null;
18653
19108
  /** Content-sniff verdict by blob sha. A blob is immutable, so a refresh re-reads only new content. */
18654
19109
  sniffed = /* @__PURE__ */ new Map();
18655
- constructor(repo, mode, version, context) {
19110
+ constructor(repo, mode, version, context, iterations = null) {
18656
19111
  this.repo = repo;
18657
19112
  this.mode = mode;
18658
19113
  this.version = version;
18659
19114
  this.context = context;
19115
+ this.iterations = iterations;
18660
19116
  }
18661
19117
  /** Change context lines: drops cached patches; the next snapshot carries the new value. */
18662
19118
  setContext(context, version) {
@@ -18728,7 +19184,7 @@ var Snapshotter = class {
18728
19184
  this.mode.base === "parent" ? this.reviewedCommit(newSha) : null,
18729
19185
  this.rangeCommits(oldSha, newSha, headSha)
18730
19186
  ]);
18731
- await this.fillGenerated(changed, newSha);
19187
+ const [iterations] = await Promise.all([this.recordIteration(commits), this.fillGenerated(changed, newSha)]);
18732
19188
  const tree = new Set(tracked);
18733
19189
  for (const f of changed) {
18734
19190
  if (f.status === "D") tree.delete(f.path);
@@ -18746,11 +19202,17 @@ var Snapshotter = class {
18746
19202
  changed,
18747
19203
  tree: [...tree].sort(),
18748
19204
  commit,
18749
- commits
19205
+ commits,
19206
+ iterations
18750
19207
  };
18751
19208
  for (const f of changed.slice(0, PREWARM)) this.patchFor(snap, f).catch(() => {});
18752
19209
  return snap;
18753
19210
  }
19211
+ /** The snapshot taken is the range's current iteration; the worktree has no commit to record. */
19212
+ async recordIteration(commits) {
19213
+ if (!this.iterations || commits.oldSha === "worktree" || commits.newSha === "worktree") return [];
19214
+ return this.iterations.record(commits.oldSha, commits.newSha);
19215
+ }
18754
19216
  async reviewedCommit(sha) {
18755
19217
  const [info, neighbours] = await Promise.all([this.repo.commitInfo(sha), this.repo.commitNeighbours(sha)]);
18756
19218
  return info && {
@@ -18763,16 +19225,23 @@ var Snapshotter = class {
18763
19225
  const range = await resolveComparison(this.repo, this.mode.within);
18764
19226
  return this.spanned(range.oldSha, range.newSha, headSha);
18765
19227
  }
18766
- if (this.mode.base === "parent") return this.repo.rangeCommits(`${newSha}^!`, 250);
19228
+ if (this.mode.base === "parent") return {
19229
+ ...await this.repo.rangeCommits(`${newSha}^!`, 250),
19230
+ oldSha,
19231
+ newSha
19232
+ };
18767
19233
  return this.spanned(oldSha, newSha, headSha);
18768
19234
  }
18769
- spanned(oldSha, newSha, headSha) {
19235
+ async spanned(oldSha, newSha, headSha) {
18770
19236
  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);
19237
+ return {
19238
+ ...!from || !to || from === to ? {
19239
+ list: [],
19240
+ total: 0
19241
+ } : await this.repo.rangeCommits(`${from}..${to}`, 250),
19242
+ oldSha,
19243
+ newSha
19244
+ };
18776
19245
  }
18777
19246
  /**
18778
19247
  * Sets `generated` on each non-binary changed file. A `linguist-generated`
@@ -18822,6 +19291,10 @@ var Snapshotter = class {
18822
19291
  * while its task holds the queue, so published versions are monotonic and the
18823
19292
  * active snapshotter always carries the session's context. Requests that arrive
18824
19293
  * before the first mode resolves await `ready()`.
19294
+ *
19295
+ * A worktree-live comparison recomputes on every change. A refs-live one only
19296
+ * announces that its refs moved (`moved`) and recomputes on `reload()`, so a
19297
+ * push or rebase does not swap the review out from under the reviewer.
18825
19298
  */
18826
19299
  var Session = class {
18827
19300
  repo;
@@ -18836,6 +19309,8 @@ var Session = class {
18836
19309
  queue = Promise.resolve();
18837
19310
  /** The refresh waiting for the queue, if any. Later requests join it. */
18838
19311
  queuedRefresh = null;
19312
+ /** The refs check waiting for the queue, if any. Later signals join it. */
19313
+ queuedCheck = null;
18839
19314
  readable = /* @__PURE__ */ new WeakMap();
18840
19315
  snapshotListeners = /* @__PURE__ */ new Set();
18841
19316
  constructor(repo, hub, opts) {
@@ -18857,6 +19332,24 @@ var Session = class {
18857
19332
  get snapshotter() {
18858
19333
  return this.require().snapshotter;
18859
19334
  }
19335
+ /** The pending reload of a refs-live comparison, for a client that connects after the push. */
19336
+ get moved() {
19337
+ return this.require().moved;
19338
+ }
19339
+ /** Whether moved refs recompute the review, as worktree edits always do, or only announce themselves. */
19340
+ get follows() {
19341
+ const a = this.require();
19342
+ if (a.mode.live === "worktree") return true;
19343
+ switch (this.opts.followRefs) {
19344
+ case "on": return true;
19345
+ case "off": return false;
19346
+ case "auto": return !a.branches;
19347
+ }
19348
+ }
19349
+ /** Change the user's choice for the running session; a pending notice stands until a reload picks the refs up. */
19350
+ setFollowRefs(followRefs) {
19351
+ this.opts.followRefs = followRefs;
19352
+ }
18860
19353
  /** Cached independently of snapshot construction; callers never hold the transition queue. */
18861
19354
  github(snap) {
18862
19355
  const active = this.require();
@@ -18944,7 +19437,7 @@ var Session = class {
18944
19437
  }
18945
19438
  /** Switch modes at runtime. Broadcasts a snapshot bump on success. */
18946
19439
  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)));
19440
+ 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
19441
  this.hub.broadcast({
18949
19442
  type: "snapshot",
18950
19443
  version: snap.version
@@ -18979,6 +19472,123 @@ var Session = class {
18979
19472
  };
18980
19473
  }
18981
19474
  /**
19475
+ * Compares two recorded iterations of the active range: `from`'s head replayed onto `to`'s base
19476
+ * against `to`'s head, so only what the branch itself changed in between shows. The interdiff
19477
+ * keeps its own comments and the range's PR identity; its refs keep being watched.
19478
+ */
19479
+ async interdiff(from, to) {
19480
+ const a = this.require();
19481
+ const range = a.mode.within ?? a.mode;
19482
+ if (range.base === "parent") throw new RevspecError("a single commit has no iterations");
19483
+ if (from >= to) throw new RevspecError("compare an older iteration with a newer one");
19484
+ const store = new IterationStore(this.repo, range.commentKey);
19485
+ const [older, newer] = await Promise.all([store.get(from), store.get(to)]);
19486
+ if (!older || !newer) throw new RevspecError(`no iteration #${older ? to : from} of ${comparisonLabel(range)}`);
19487
+ const [{ tree, conflicts }, pairs] = await Promise.all([older.oldSha === newer.oldSha ? this.repo.tree(older.newSha).then((tree) => ({
19488
+ tree,
19489
+ conflicts: []
19490
+ })) : this.repo.replay(older.oldSha, newer.oldSha, older.newSha), this.pairs(older, newer)]);
19491
+ return {
19492
+ prUrl: a.prUrl,
19493
+ mode: {
19494
+ old: tree,
19495
+ new: newer.newSha,
19496
+ base: "direct",
19497
+ live: range.live === "none" ? "none" : "refs",
19498
+ commentKey: `${range.commentKey}:interdiff:${from}-${to}`,
19499
+ within: range,
19500
+ interdiff: {
19501
+ from: older,
19502
+ to: newer,
19503
+ conflicts,
19504
+ pairs
19505
+ }
19506
+ }
19507
+ };
19508
+ }
19509
+ /**
19510
+ * Pairs the two iterations' commits as `git range-diff` does and tells a pair whose patch is
19511
+ * the same (a reworded commit) from an amended one by patch id.
19512
+ */
19513
+ async pairs(older, newer) {
19514
+ const oldRange = `${older.oldSha}..${older.newSha}`;
19515
+ const newRange = `${newer.oldSha}..${newer.newSha}`;
19516
+ const [rows, oldCommits, newCommits] = await Promise.all([
19517
+ this.repo.rangeDiff(oldRange, newRange),
19518
+ this.repo.rangeCommits(oldRange, 250),
19519
+ this.repo.rangeCommits(newRange, 250)
19520
+ ]);
19521
+ const find = async (list, abbrev) => {
19522
+ if (abbrev === null) return null;
19523
+ return list.find((c) => c.sha.startsWith(abbrev)) ?? (await this.repo.rangeCommits(`${abbrev}^!`, 1)).list[0] ?? null;
19524
+ };
19525
+ const paired = await Promise.all(rows.map(async (row) => ({
19526
+ old: await find(oldCommits.list, row.old),
19527
+ new: await find(newCommits.list, row.new),
19528
+ marker: row.marker
19529
+ })));
19530
+ const changed = paired.filter((p) => p.marker === "!" && p.old && p.new);
19531
+ const ids = await this.repo.patchIds(changed.flatMap((p) => [p.old.sha, p.new.sha]));
19532
+ return paired.map(({ old, new: next, marker }) => ({
19533
+ old,
19534
+ new: next,
19535
+ 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"
19536
+ }));
19537
+ }
19538
+ /**
19539
+ * Shows one pair of the active interdiff by its new-side commit: an added commit against its
19540
+ * parent, an amended or reworded one against its old self replayed onto its parent. With null,
19541
+ * the whole interdiff again. Identical pairs have nothing to show; dropped ones have no new side.
19542
+ */
19543
+ async pair(commit) {
19544
+ const a = this.require();
19545
+ const { interdiff, within: range } = a.mode;
19546
+ if (!interdiff || !range) throw new RevspecError("no interdiff to pick a pair from");
19547
+ if (commit === null) return this.interdiff(interdiff.from.n, interdiff.to.n);
19548
+ const found = interdiff.pairs.find((p) => p.new && (p.new.sha === commit || p.new.short === commit));
19549
+ if (!found?.new) throw new RevspecError(`not a commit of ${comparisonLabel(a.mode)}: ${commit}`);
19550
+ if (found.status === "identical") throw new RevspecError(`${found.new.short} is identical in both iterations`);
19551
+ const base = {
19552
+ live: range.live === "none" ? "none" : "refs",
19553
+ within: range,
19554
+ interdiff
19555
+ };
19556
+ if (!found.old) {
19557
+ const sha = found.new.sha;
19558
+ return {
19559
+ prUrl: a.prUrl,
19560
+ mode: {
19561
+ ...base,
19562
+ old: sha,
19563
+ new: sha,
19564
+ base: "parent",
19565
+ commentKey: `commit:${sha}`,
19566
+ pair: {
19567
+ old: null,
19568
+ new: sha,
19569
+ conflicts: []
19570
+ }
19571
+ }
19572
+ };
19573
+ }
19574
+ const { tree, conflicts } = await this.repo.replay(`${found.old.sha}^`, `${found.new.sha}^`, found.old.sha);
19575
+ return {
19576
+ prUrl: a.prUrl,
19577
+ mode: {
19578
+ ...base,
19579
+ old: tree,
19580
+ new: found.new.sha,
19581
+ base: "direct",
19582
+ commentKey: `${range.commentKey}:pair:${found.old.sha}-${found.new.sha}`,
19583
+ pair: {
19584
+ old: found.old.sha,
19585
+ new: found.new.sha,
19586
+ conflicts
19587
+ }
19588
+ }
19589
+ };
19590
+ }
19591
+ /**
18982
19592
  * Runs `fn` after every earlier mutation settled. The only way to touch
18983
19593
  * `active`, `version` or `opts.context`.
18984
19594
  */
@@ -18988,14 +19598,22 @@ var Session = class {
18988
19598
  return r;
18989
19599
  }
18990
19600
  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()]);
19601
+ const range = mode.within ?? mode;
19602
+ const followed = range.base !== "parent" && (range.live === "refs" || prUrl != null);
19603
+ const snapshotter = new Snapshotter(this.repo, mode, ++this.version, this.opts.context, followed ? new IterationStore(this.repo, range.commentKey) : null);
19604
+ const [comments, snap, branches] = await Promise.all([
19605
+ CommentStore.open(this.repo.gitDir, mode.commentKey),
19606
+ snapshotter.current(),
19607
+ range.live === "refs" && namesBranch(this.repo, range)
19608
+ ]);
18993
19609
  const next = {
18994
19610
  mode,
18995
19611
  prUrl,
18996
19612
  snapshotter,
18997
19613
  comments,
18998
- watcher: null
19614
+ watcher: null,
19615
+ moved: null,
19616
+ branches
18999
19617
  };
19000
19618
  await this.relocateComments(next, snap);
19001
19619
  if (this.opts.watch && mode.live !== "none") next.watcher = await this.startWatcher(next);
@@ -19025,13 +19643,61 @@ var Session = class {
19025
19643
  if (this.queuedRefresh) return this.queuedRefresh;
19026
19644
  const r = this.run(async () => {
19027
19645
  this.queuedRefresh = null;
19028
- const a = this.require();
19029
- a.snapshotter.invalidate(++this.version);
19030
- await this.publish(a);
19646
+ await this.recompute();
19031
19647
  }).catch((e) => console.error("[diffle] refresh failed:", e));
19032
19648
  this.queuedRefresh = r;
19033
19649
  return r;
19034
19650
  }
19651
+ /** The reviewer asks for the moved refs: recompute now. Unlike `refresh`, a failure is the caller's. */
19652
+ reload() {
19653
+ return this.run(async () => {
19654
+ const a = this.require();
19655
+ if (!a.mode.interdiff || !a.mode.within) return this.recompute();
19656
+ return this.leaveInterdiff(a);
19657
+ });
19658
+ }
19659
+ /** Forgets the range's recorded iterations and unpins their commits; its current state is recorded anew as #1. */
19660
+ clearIterations() {
19661
+ return this.run(async () => {
19662
+ const a = this.require();
19663
+ const range = a.mode.within ?? a.mode;
19664
+ await new IterationStore(this.repo, range.commentKey).clear();
19665
+ return a.mode.interdiff ? this.leaveInterdiff(a) : this.recompute();
19666
+ });
19667
+ }
19668
+ /**
19669
+ * Forgets one recorded iteration and unpins its commits. The latest stays: it is the range's
19670
+ * current state and would be recorded again at once.
19671
+ */
19672
+ deleteIteration(n) {
19673
+ return this.run(async () => {
19674
+ const a = this.require();
19675
+ const range = a.mode.within ?? a.mode;
19676
+ const store = new IterationStore(this.repo, range.commentKey);
19677
+ const list = await store.list();
19678
+ if (!list.some((it) => it.n === n)) throw new RevspecError(`no iteration #${n}`);
19679
+ if (list.at(-1).n === n) throw new RevspecError(`#${n} is the latest iteration, the range's current state`);
19680
+ await store.remove(n);
19681
+ const inter = a.mode.interdiff;
19682
+ return inter && (inter.from.n === n || inter.to.n === n) ? this.leaveInterdiff(a) : this.recompute();
19683
+ });
19684
+ }
19685
+ async leaveInterdiff(a) {
19686
+ const snap = await this.activate({
19687
+ mode: a.mode.within,
19688
+ prUrl: a.prUrl
19689
+ });
19690
+ this.hub.broadcast({
19691
+ type: "snapshot",
19692
+ version: snap.version
19693
+ });
19694
+ return snap;
19695
+ }
19696
+ recompute() {
19697
+ const a = this.require();
19698
+ a.snapshotter.invalidate(++this.version);
19699
+ return this.publish(a);
19700
+ }
19035
19701
  /**
19036
19702
  * Computes `a`'s current snapshot, re-anchors its threads (hunks may have
19037
19703
  * grown, shrunk or moved), then broadcasts and announces it.
@@ -19039,11 +19705,40 @@ var Session = class {
19039
19705
  async publish(a) {
19040
19706
  const snap = await a.snapshotter.current();
19041
19707
  await this.relocateComments(a, snap);
19708
+ a.moved = null;
19042
19709
  this.hub.broadcast({
19043
19710
  type: "snapshot",
19044
19711
  version: snap.version
19045
19712
  });
19046
19713
  this.announce(snap);
19714
+ return snap;
19715
+ }
19716
+ /**
19717
+ * Resolves the refs the live comparison follows and tells clients when they
19718
+ * left the snapshot behind, or came back to it. At most one check waits
19719
+ * behind the running one, as with `refresh`.
19720
+ */
19721
+ checkMoved(a) {
19722
+ if (this.queuedCheck) return this.queuedCheck;
19723
+ const r = this.run(async () => {
19724
+ this.queuedCheck = null;
19725
+ if (this.active !== a) return;
19726
+ const snap = await a.snapshotter.current();
19727
+ const { oldSha, newSha } = await resolveComparison(this.repo, a.mode.within ?? a.mode);
19728
+ const moved = oldSha === snap.commits.oldSha && newSha === snap.commits.newSha ? null : {
19729
+ version: snap.version,
19730
+ oldSha,
19731
+ newSha
19732
+ };
19733
+ if (moved?.oldSha === a.moved?.oldSha && moved?.newSha === a.moved?.newSha) return;
19734
+ a.moved = moved;
19735
+ this.hub.broadcast({
19736
+ type: "moved",
19737
+ moved
19738
+ });
19739
+ }).catch((e) => console.error("[diffle] refs check failed:", e));
19740
+ this.queuedCheck = r;
19741
+ return r;
19047
19742
  }
19048
19743
  /**
19049
19744
  * Re-anchors threads against what `snap` shows. A changed file shows its
@@ -19107,6 +19802,7 @@ var Session = class {
19107
19802
  * review. Goes through `readSide`, so the allowlist applies.
19108
19803
  */
19109
19804
  anchorSource() {
19805
+ if (this.mode.interdiff) throw new RevspecError("comments are off while comparing iterations");
19110
19806
  return {
19111
19807
  quote: async (path, side, startLine, endLine) => {
19112
19808
  const snap = await this.snapshotter.current();
@@ -19147,6 +19843,10 @@ var Session = class {
19147
19843
  });
19148
19844
  watcher.on("dirty", () => {
19149
19845
  if (this.active !== a) return;
19846
+ if (!this.follows) {
19847
+ this.checkMoved(a);
19848
+ return;
19849
+ }
19150
19850
  this.refresh();
19151
19851
  this.run(async () => {
19152
19852
  if (this.active === a && a.mode.live === "worktree") await refreshIgnored();
@@ -19237,6 +19937,7 @@ function normalize(c) {
19237
19937
  return {
19238
19938
  autoViewed: Array.isArray(c.autoViewed) ? c.autoViewed.filter((p) => typeof p === "string").map((p) => p.trim()).filter(Boolean) : DEFAULT_USER_CONFIG.autoViewed,
19239
19939
  contextLines: typeof c.contextLines === "number" && Number.isFinite(c.contextLines) ? Math.max(0, Math.min(1e4, Math.floor(c.contextLines))) : DEFAULT_USER_CONFIG.contextLines,
19940
+ followRefs: FollowRefsSchema.safeParse(c.followRefs).data ?? DEFAULT_USER_CONFIG.followRefs,
19240
19941
  lspCommands: lspCommands(c)
19241
19942
  };
19242
19943
  }
@@ -22944,6 +23645,12 @@ function parseLspOverride(raw) {
22944
23645
  function collectLspOverride(raw, prev) {
22945
23646
  return [...Array.isArray(prev) ? prev : [], parseLspOverride(raw)];
22946
23647
  }
23648
+ /** A `set-follow-refs` value. */
23649
+ function parseFollowRefs(value) {
23650
+ const parsed = FollowRefsSchema.safeParse(value);
23651
+ if (!parsed.success) throw new InvalidArgumentError("expected on, auto or off");
23652
+ return parsed.data;
23653
+ }
22947
23654
  //#endregion
22948
23655
  //#region src/cli/browserLifetime.ts
22949
23656
  /**
@@ -23140,6 +23847,11 @@ config.command("set-context").description("set context lines around changes").ar
23140
23847
  await store.set({ contextLines: n });
23141
23848
  console.log(store.get().contextLines);
23142
23849
  });
23850
+ 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) => {
23851
+ const store = await UserConfigStore.open();
23852
+ await store.set({ followRefs });
23853
+ console.log(store.get().followRefs);
23854
+ });
23143
23855
  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
23856
  const store = await UserConfigStore.open();
23145
23857
  await store.set({ lspCommands: {
@@ -23215,6 +23927,7 @@ async function serve(req, opts, repo, closeRepo, config, github, timing) {
23215
23927
  const session = new Session(repo, hub, {
23216
23928
  watch: opts.watch,
23217
23929
  context: opts.context ?? config.get().contextLines,
23930
+ followRefs: config.get().followRefs,
23218
23931
  github: github.client ?? void 0
23219
23932
  });
23220
23933
  const named = Array.isArray(opts.lsp) ? opts.lsp : [];
@@ -23277,7 +23990,7 @@ async function serve(req, opts, repo, closeRepo, config, github, timing) {
23277
23990
  console.error(`🔍 ${c.dim("comparing")} ${c.bold(comparisonLabel(snap.mode))}`);
23278
23991
  if (n === 0) console.error(`${c.yellow("!")} No differences. Open any file from the tree to comment on it.`);
23279
23992
  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)")}`);
23993
+ 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
23994
  if (lsp) {
23282
23995
  const { servers, missing } = lsp.status();
23283
23996
  const names = servers.map((s) => `${s.name} ${c.dim(`(${s.languages.join(", ")})`)}`).join(", ");