@moritzwilksch/diffle 0.1.8 → 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 (82) hide show
  1. package/README.md +90 -131
  2. package/dist/client/assets/{angular-html-C3AR-WKY.js → angular-html-NJfvXMBu.js} +1 -1
  3. package/dist/client/assets/{angular-ts-bZzNmyh2.js → angular-ts-3VmQ16GC.js} +1 -1
  4. package/dist/client/assets/{apl-DKjQ1uUu.js → apl-CaYQRkUE.js} +1 -1
  5. package/dist/client/assets/{astro-D6YBrldj.js → astro-CGsy8jQJ.js} +1 -1
  6. package/dist/client/assets/{blade-QC3ty25r.js → blade-CbyPPxmo.js} +1 -1
  7. package/dist/client/assets/{c-CEfiNUIv.js → c-BaJwvHpK.js} +1 -1
  8. package/dist/client/assets/{chapel-XvVgmIw8.js → chapel-BokLUCVc.js} +1 -1
  9. package/dist/client/assets/{cobol-Cyvb7uZ8.js → cobol-I7_VFdfF.js} +1 -1
  10. package/dist/client/assets/{coffee-DKSUNEAJ.js → coffee-BJ7WLX7y.js} +1 -1
  11. package/dist/client/assets/{cpp-DvHlatt5.js → cpp-DSFvuOlH.js} +1 -1
  12. package/dist/client/assets/{crystal-BfxbaHfR.js → crystal-DLLJ7kug.js} +1 -1
  13. package/dist/client/assets/{css-CvzcDzFO.js → css-CW7SLGV7.js} +1 -1
  14. package/dist/client/assets/{edge-rOBhrNV9.js → edge-DQEyTxuu.js} +1 -1
  15. package/dist/client/assets/{elixir-D3B6PlMg.js → elixir-Dwvydtjv.js} +1 -1
  16. package/dist/client/assets/{elm-BnfJhLi3.js → elm-DPrZsI-D.js} +1 -1
  17. package/dist/client/assets/{erb-CkLdBLG4.js → erb-By39__m0.js} +1 -1
  18. package/dist/client/assets/{git-rebase-wqI2zI5G.js → git-rebase-CBF3buEb.js} +1 -1
  19. package/dist/client/assets/{glimmer-js-CorAzX48.js → glimmer-js-C4TvHlaw.js} +1 -1
  20. package/dist/client/assets/{glimmer-ts-DphgSML7.js → glimmer-ts-BPhq137Q.js} +1 -1
  21. package/dist/client/assets/{glsl-Mt2MgDCN.js → glsl-BlwDtIwk.js} +1 -1
  22. package/dist/client/assets/{graphql-CR58itij.js → graphql-BgMqs-o1.js} +1 -1
  23. package/dist/client/assets/{hack-BYLE2FB3.js → hack-DrMUHl8k.js} +1 -1
  24. package/dist/client/assets/{haml-Bo5vpKMC.js → haml-BWbuOop2.js} +1 -1
  25. package/dist/client/assets/{handlebars-Cd6w9Qte.js → handlebars-D-TP7YFr.js} +1 -1
  26. package/dist/client/assets/{html-EAc4fezP.js → html-RkKdupbJ.js} +1 -1
  27. package/dist/client/assets/{html-derivative-BCmkEMhH.js → html-derivative-BiXkRfVa.js} +1 -1
  28. package/dist/client/assets/{http-dQXVRwNo.js → http-CF05Tv0R.js} +1 -1
  29. package/dist/client/assets/{hurl-CTzL1YrB.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-DWcJ2X-_.js → index-Dw_tHgT2.js} +118 -102
  32. package/dist/client/assets/{java-C6GmB9Ie.js → java-DZgPGAdX.js} +1 -1
  33. package/dist/client/assets/{javascript-C3Xp2ZJG.js → javascript-UpC6uRwd.js} +1 -1
  34. package/dist/client/assets/{jinja-Dm5uNF1A.js → jinja-BwlD6UdE.js} +1 -1
  35. package/dist/client/assets/{jison-DWN1PAPH.js → jison-CXzrtNYv.js} +1 -1
  36. package/dist/client/assets/{json-CD4Jl2TN.js → json-CvzeYtbd.js} +1 -1
  37. package/dist/client/assets/{jsx-Dk_mg_Sq.js → jsx-CRRNRy_v.js} +1 -1
  38. package/dist/client/assets/{julia-ChQLeZ-J.js → julia-n9zQTAkT.js} +1 -1
  39. package/dist/client/assets/{just-Dw1MNaQ7.js → just-Cqgx3k16.js} +1 -1
  40. package/dist/client/assets/{latex-CHuqntlZ.js → latex-CtWS0r44.js} +1 -1
  41. package/dist/client/assets/{liquid-CdqvKBd5.js → liquid-DYOfxp51.js} +1 -1
  42. package/dist/client/assets/{lua-DtCcLDnQ.js → lua-BH2jjxbT.js} +1 -1
  43. package/dist/client/assets/{marko-COZ6SLmm.js → marko-yGXqh8Mu.js} +1 -1
  44. package/dist/client/assets/{mdc-BTrsC_q_.js → mdc-DBTGzC9c.js} +1 -1
  45. package/dist/client/assets/{nginx-B_JY_bIl.js → nginx-C6OOwetR.js} +1 -1
  46. package/dist/client/assets/{nim-C7Boj9eE.js → nim-B-51OZwP.js} +1 -1
  47. package/dist/client/assets/{org-CYdh74lA.js → org-rdh6JnaZ.js} +1 -1
  48. package/dist/client/assets/{perl-CRiTh3Pi.js → perl-BEpLn7O_.js} +1 -1
  49. package/dist/client/assets/{php-DSko-lNI.js → php-uM1K2nIh.js} +1 -1
  50. package/dist/client/assets/{pug-BfUPln3e.js → pug-CX5jxSmg.js} +1 -1
  51. package/dist/client/assets/{qml-2jRMRran.js → qml-BjaL9kT9.js} +1 -1
  52. package/dist/client/assets/{r-sDW5nKEb.js → r-CbP_oADb.js} +1 -1
  53. package/dist/client/assets/{razor-Sa7GlYLb.js → razor-ClgwHmG8.js} +1 -1
  54. package/dist/client/assets/{regexp-AWVIUt-C.js → regexp-DkGv1hXs.js} +1 -1
  55. package/dist/client/assets/{rst-D20G5Wbg.js → rst-KbuceK-d.js} +1 -1
  56. package/dist/client/assets/{ruby-DevA-y7a.js → ruby-IyWK5TqJ.js} +1 -1
  57. package/dist/client/assets/{sas-DRiLtfNK.js → sas-CGY91YIG.js} +1 -1
  58. package/dist/client/assets/{scss-Ca4Dfxoe.js → scss-C5azgOdY.js} +1 -1
  59. package/dist/client/assets/{shellscript-BNnaYtXN.js → shellscript-Do9KSXv1.js} +1 -1
  60. package/dist/client/assets/{shellsession-B0nV-fpO.js → shellsession-DG2DOiiH.js} +1 -1
  61. package/dist/client/assets/{soy-CDZEZMSk.js → soy-BLO2pcf_.js} +1 -1
  62. package/dist/client/assets/{sql-s_6SuPDf.js → sql-NhQC2aCy.js} +1 -1
  63. package/dist/client/assets/{stata-CaVdvC6P.js → stata-BT8bwSiP.js} +1 -1
  64. package/dist/client/assets/{surrealql-DZ5TdMgT.js → surrealql-DeFUr0Sz.js} +1 -1
  65. package/dist/client/assets/{svelte-BkFQboE2.js → svelte-TRjcWU3h.js} +1 -1
  66. package/dist/client/assets/{templ-QUoUDGUH.js → templ-C1Ef7mnt.js} +1 -1
  67. package/dist/client/assets/{tex-89lMVAG3.js → tex-DWZKUBNC.js} +1 -1
  68. package/dist/client/assets/{ts-tags-B2u4oUPE.js → ts-tags-BPGHRwAa.js} +1 -1
  69. package/dist/client/assets/{tsx-ftFKhLnx.js → tsx-DFPjrnBq.js} +1 -1
  70. package/dist/client/assets/{twig-DQCCfpmq.js → twig-BnaDylmD.js} +1 -1
  71. package/dist/client/assets/{typescript-DhCWy8LC.js → typescript-CtjU5LIe.js} +1 -1
  72. package/dist/client/assets/{typst-N-Gv0lEU.js → typst-CKVv919t.js} +1 -1
  73. package/dist/client/assets/{vue-C7rUB8rE.js → vue-DQGhQ-pk.js} +1 -1
  74. package/dist/client/assets/{vue-html-D-dp6lDI.js → vue-html-BtU_VFsU.js} +1 -1
  75. package/dist/client/assets/{vue-vine-DEpfajiz.js → vue-vine-Bg_4C7Q_.js} +1 -1
  76. package/dist/client/assets/{xml-BGDSWH1X.js → xml-DupdE_s3.js} +1 -1
  77. package/dist/client/assets/{xsl-D8Xmp-zV.js → xsl-BkEIn0qW.js} +1 -1
  78. package/dist/client/assets/{yaml-B3GKVR80.js → yaml-BuH0PmqQ.js} +1 -1
  79. package/dist/client/index.html +2 -2
  80. package/dist/server/main.js +987 -68
  81. package/package.json +5 -3
  82. package/dist/client/assets/index-BkCL4UDj.css +0 -2
@@ -11,6 +11,7 @@ import { copyFile, lstat, mkdir, mkdtemp, open, readFile, readlink, realpath, re
11
11
  import { fileURLToPath, pathToFileURL } from "node:url";
12
12
  import { homedir, hostname, tmpdir } from "node:os";
13
13
  import { createServer } from "node:http";
14
+ import { getAssetKeys, getRawAsset, isSea } from "node:sea";
14
15
  import { Http2ServerRequest, constants as constants$1 } from "http2";
15
16
  import { Readable } from "stream";
16
17
  import crypto from "crypto";
@@ -3951,7 +3952,7 @@ function generateCompletion(program, { shell, executable = program.name() }) {
3951
3952
  }
3952
3953
  //#endregion
3953
3954
  //#region package.json
3954
- var version$1 = "0.1.8";
3955
+ var version$1 = "0.2.0";
3955
3956
  //#endregion
3956
3957
  //#region src/server/comments/anchor.ts
3957
3958
  function splitLines(contents) {
@@ -4108,6 +4109,12 @@ function patchArgs(oldRev) {
4108
4109
  }
4109
4110
  /** An idle `cat-file --batch` is reaped after this long; the next request respawns one. */
4110
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;
4111
4118
  var GitError = class extends Error {
4112
4119
  args;
4113
4120
  code;
@@ -4184,6 +4191,16 @@ var GitRepo = class GitRepo {
4184
4191
  `${rev}^{commit}`
4185
4192
  ])).trim();
4186
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
+ }
4187
4204
  /** Commit messages for two ancestor offsets, pinned to one HEAD. */
4188
4205
  async lastCommitsPreview(oldOffset, newOffset) {
4189
4206
  const head = await this.commitInfo("HEAD");
@@ -4198,6 +4215,7 @@ var GitRepo = class GitRepo {
4198
4215
  new: next
4199
4216
  };
4200
4217
  }
4218
+ /** A commit's hash, abbreviation and full message; null when `rev` names no commit. */
4201
4219
  async commitInfo(rev) {
4202
4220
  let sha;
4203
4221
  try {
@@ -4221,6 +4239,66 @@ var GitRepo = class GitRepo {
4221
4239
  message: output.slice(separator + 1).trimEnd()
4222
4240
  };
4223
4241
  }
4242
+ /**
4243
+ * `sha`'s first parent, and the commit toward HEAD whose first parent it is. The
4244
+ * child is null at HEAD, on an unborn branch, or when `sha` is off HEAD's
4245
+ * first-parent line, e.g. on a merged side branch.
4246
+ */
4247
+ async commitNeighbours(sha) {
4248
+ const [parent, list] = await Promise.all([this.resolve(`${sha}^`).catch((e) => {
4249
+ if (e instanceof GitError && e.code === 1) return null;
4250
+ throw e;
4251
+ }), this.text([
4252
+ "rev-list",
4253
+ "--first-parent",
4254
+ "--ancestry-path",
4255
+ "--parents",
4256
+ `${sha}..HEAD`,
4257
+ "--"
4258
+ ]).catch(() => "")]);
4259
+ const [child, firstParent] = list.trim().split("\n").pop()?.split(" ") ?? [];
4260
+ return {
4261
+ parent,
4262
+ child: child && firstParent === sha ? child : null
4263
+ };
4264
+ }
4265
+ /** The commits a revision range such as `a..b` or `c^!` selects, oldest first: the newest `limit`, and the total. */
4266
+ async rangeCommits(range, limit) {
4267
+ const [count, log] = await Promise.all([this.text([
4268
+ "rev-list",
4269
+ "--count",
4270
+ "--end-of-options",
4271
+ range,
4272
+ "--"
4273
+ ]), this.text([
4274
+ "log",
4275
+ "-z",
4276
+ "--no-show-signature",
4277
+ "--reverse",
4278
+ `--max-count=${limit}`,
4279
+ "--format=%H%x00%h%x00%an%x00%ae%x00%at%x00%B",
4280
+ "--end-of-options",
4281
+ range,
4282
+ "--"
4283
+ ])]);
4284
+ const fields = log.split("\0");
4285
+ const list = [];
4286
+ for (let i = 0; i + 5 < fields.length; i += 6) {
4287
+ const [sha = "", short = "", author = "", email = "", time = "", message = ""] = fields.slice(i, i + 6);
4288
+ list.push({
4289
+ sha,
4290
+ short,
4291
+ message: message.trimEnd(),
4292
+ author,
4293
+ email,
4294
+ date: Number(time) * 1e3
4295
+ });
4296
+ }
4297
+ return {
4298
+ list,
4299
+ total: Number(count.trim())
4300
+ };
4301
+ }
4224
4302
  /** The empty tree under the repository's hash algorithm: the old side of an unborn branch. */
4225
4303
  async emptyTree() {
4226
4304
  return (await this.text([
@@ -4238,6 +4316,89 @@ var GitRepo = class GitRepo {
4238
4316
  b
4239
4317
  ])).trim();
4240
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
+ }
4241
4402
  /** origin/HEAD → main → master. */
4242
4403
  async defaultBranch() {
4243
4404
  try {
@@ -11828,6 +11989,20 @@ const ZodRecord = /*@__PURE__*/ $constructor("ZodRecord", (inst, def) => {
11828
11989
  inst.keyType = def.keyType;
11829
11990
  inst.valueType = def.valueType;
11830
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
+ }
11831
12006
  function partialRecord(keyType, valueType, params) {
11832
12007
  return new ZodRecord({
11833
12008
  type: "record",
@@ -12099,14 +12274,48 @@ const ModeRequestSchema = discriminatedUnion("kind", [
12099
12274
  object({
12100
12275
  kind: literal("revspec"),
12101
12276
  args: string().array()
12277
+ }),
12278
+ object({
12279
+ kind: literal("focus"),
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()
12102
12290
  })
12103
12291
  ]);
12104
- /** A comparison; both endpoints accept a Git revision or "worktree". */
12105
- const ModeSpecSchema = object({
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
+ /**
12306
+ * How the old side is derived: `old` itself, merge-base(old, new) with worktree
12307
+ * taken as HEAD, or new's first parent (the empty tree for a root commit).
12308
+ */
12309
+ const ComparisonBaseSchema = _enum([
12310
+ "direct",
12311
+ "merge-base",
12312
+ "parent"
12313
+ ]);
12314
+ /** A comparison; both endpoints accept a Git revision or "worktree". Under `parent`, both name the one commit. */
12315
+ const ComparisonSchema = object({
12106
12316
  old: string(),
12107
12317
  new: string(),
12108
- /** Compare merge-base(old, new) to new; worktree uses HEAD for the merge base. */
12109
- mergeBase: boolean(),
12318
+ base: ComparisonBaseSchema,
12110
12319
  live: _enum([
12111
12320
  "worktree",
12112
12321
  "refs",
@@ -12115,10 +12324,88 @@ const ModeSpecSchema = object({
12115
12324
  /** Fixed when the comparison is entered; discovery never changes comment storage. */
12116
12325
  commentKey: string()
12117
12326
  });
12118
- /** Display comparison endpoints with branch names instead of internal ref namespaces. */
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
+ });
12392
+ /**
12393
+ * Display comparison endpoints with branch names instead of internal ref namespaces; a single commit as
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.
12396
+ */
12119
12397
  function comparisonLabel(mode) {
12120
- const name = (rev) => rev.replace(/^refs\/diffle\/[^/]+\/\d+\/(?:base|head)\//, "").replace(/^refs\/(?:heads|remotes)\//, "");
12121
- return `${name(mode.old)}${mode.mergeBase ? "..." : ".."}${name(mode.new)}`;
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
+ }
12402
+ if (mode.within) return `${comparisonLabel(mode.within)} @ ${mode.new.slice(0, 7)}`;
12403
+ if (mode.base === "parent") return `${mode.new.slice(0, 7)}^!`;
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)\//, "");
12122
12409
  }
12123
12410
  const GithubPullRequestSchema = object({
12124
12411
  repository: string(),
@@ -12168,6 +12455,18 @@ const ChangedFileSchema = object({
12168
12455
  /** A gitlink (mode 160000): `blob` is the recorded commit, the patch shows the commit-id change, and there is no file to open. */
12169
12456
  submodule: literal(true).optional()
12170
12457
  });
12458
+ /**
12459
+ * The commits a comparison spans: old..new, with the worktree taken as HEAD, or the one commit under `parent`.
12460
+ * A focused commit lists its range's.
12461
+ */
12462
+ const RangeCommitsSchema = object({
12463
+ /** Oldest first; the newest MAX_RANGE_COMMITS when there are more. */
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()
12469
+ });
12171
12470
  object({
12172
12471
  root: string(),
12173
12472
  mode: ModeSpecSchema,
@@ -12182,7 +12481,23 @@ object({
12182
12481
  context: number(),
12183
12482
  changed: ChangedFileSchema.array(),
12184
12483
  /** All paths on the new side: the new commit's tree, or index ∪ untracked for the worktree. Sorted. */
12185
- tree: string().array()
12484
+ tree: string().array(),
12485
+ /** The reviewed commit when the comparison is a single commit against its parent; otherwise null. */
12486
+ commit: ReviewedCommitSchema.nullable(),
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()
12186
12501
  });
12187
12502
  /** Body of `POST /api/patch`: the changed files whose patches to return, concatenated. Unknown paths are skipped. */
12188
12503
  const PatchRequestSchema = object({ paths: string().array().max(200) });
@@ -12279,11 +12594,6 @@ const ViewedEntrySchema = object({
12279
12594
  blob: string(),
12280
12595
  viewed: boolean()
12281
12596
  });
12282
- const CommitInfoSchema = object({
12283
- sha: string(),
12284
- short: string(),
12285
- message: string()
12286
- });
12287
12597
  object({
12288
12598
  old: CommitInfoSchema.nullable(),
12289
12599
  new: CommitInfoSchema.nullable()
@@ -12319,11 +12629,24 @@ object({
12319
12629
  matches: SearchMatchSchema.array(),
12320
12630
  truncated: boolean()
12321
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
+ ]);
12322
12643
  object({
12323
12644
  /** Globs (picomatch syntax) for files that start viewed + collapsed. Empty by default. */
12324
12645
  autoViewed: string().array(),
12325
12646
  /** Unchanged lines shown around each change (git -U). Default 5. */
12326
12647
  contextLines: number(),
12648
+ /** When a review between refs recomputes as its refs move; see `FollowRefsSchema`. Default auto. */
12649
+ followRefs: FollowRefsSchema,
12327
12650
  /**
12328
12651
  * Language server command per language, replacing the built-in candidate for it; an empty
12329
12652
  * string disables the language. Read-only over HTTP; set with `diffle config set-lsp`.
@@ -12333,6 +12656,7 @@ object({
12333
12656
  const DEFAULT_USER_CONFIG = {
12334
12657
  autoViewed: [],
12335
12658
  contextLines: 5,
12659
+ followRefs: "auto",
12336
12660
  lspCommands: {}
12337
12661
  };
12338
12662
  /** A point in a new-side file. LSP semantics: the column counts UTF-16 units, like a JS string index. */
@@ -12403,6 +12727,18 @@ object({
12403
12727
  object({
12404
12728
  /** Null when the position is not inside a token or the server does not classify tokens. */
12405
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() });
12406
12742
  object({
12407
12743
  name: string(),
12408
12744
  /** LSP SymbolKind number; the client maps it to a label. */
@@ -12425,6 +12761,10 @@ discriminatedUnion("type", [
12425
12761
  type: literal("snapshot"),
12426
12762
  version: number()
12427
12763
  }),
12764
+ object({
12765
+ type: literal("moved"),
12766
+ moved: MovedSchema.nullable()
12767
+ }),
12428
12768
  object({ type: literal("threads") }),
12429
12769
  object({ type: literal("viewed") }),
12430
12770
  object({ type: literal("config") }),
@@ -12518,7 +12858,8 @@ function noServer(lsp, language) {
12518
12858
  /** Writable HTTP config fields; unknown keys, including CLI-only lspCommands, are stripped. */
12519
12859
  const ConfigUpdateSchema = object({
12520
12860
  autoViewed: string().array().optional(),
12521
- contextLines: number().int().min(0).max(1e4).optional()
12861
+ contextLines: number().int().min(0).max(1e4).optional(),
12862
+ followRefs: FollowRefsSchema.optional()
12522
12863
  });
12523
12864
  const ResolvedRequestSchema = object({ resolved: boolean() });
12524
12865
  const ViewedBulkRequestSchema = object({ entries: ViewedEntrySchema.array() });
@@ -12564,6 +12905,76 @@ const ClearThreadsQuerySchema = object({ stale: literal("1").optional() });
12564
12905
  const PatchQuerySchema = object({ path: string().optional() });
12565
12906
  object({ error: string() });
12566
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
12567
12978
  //#region src/server/lsp/hover.ts
12568
12979
  /**
12569
12980
  * One markdown document for the tooltip, or null when the server sent nothing
@@ -13133,6 +13544,30 @@ var LspBridge = class LspBridge {
13133
13544
  }
13134
13545
  return { kind: null };
13135
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
+ }
13136
13571
  async references(pos, opts = {}) {
13137
13572
  const result = await this.query("textDocument/references", {
13138
13573
  textDocument: { uri: this.uri(pos.path) },
@@ -13215,7 +13650,7 @@ var LspBridge = class LspBridge {
13215
13650
  }
13216
13651
  /**
13217
13652
  * Symbols the server knows for `query`, limited to paths the snapshot
13218
- * 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
13219
13654
  * per-keystroke search must not read every hit's text.
13220
13655
  */
13221
13656
  async workspaceSymbols(query, limit = 200) {
@@ -13227,7 +13662,7 @@ var LspBridge = class LspBridge {
13227
13662
  const seen = /* @__PURE__ */ new Set();
13228
13663
  result.forEach((s, i) => {
13229
13664
  const path = paths[i];
13230
- if (path == null || !inside.get(path) || out.length >= limit) return;
13665
+ if (path == null || !inside.get(path)) return;
13231
13666
  const start = "range" in s.location ? s.location.range.start : {
13232
13667
  line: 0,
13233
13668
  character: 0
@@ -13246,7 +13681,7 @@ var LspBridge = class LspBridge {
13246
13681
  col: start.character
13247
13682
  });
13248
13683
  });
13249
- return out;
13684
+ return fuzzyRank(out, query, (s) => s.name).slice(0, limit);
13250
13685
  }
13251
13686
  /**
13252
13687
  * Contents of a file outside the snapshot that a recent result named as
@@ -13360,6 +13795,7 @@ var LspBridge = class LspBridge {
13360
13795
  definition: { linkSupport: true },
13361
13796
  typeDefinition: { linkSupport: true },
13362
13797
  references: {},
13798
+ documentHighlight: {},
13363
13799
  hover: { contentFormat: ["markdown", "plaintext"] },
13364
13800
  documentSymbol: { hierarchicalDocumentSymbolSupport: true },
13365
13801
  semanticTokens: {
@@ -13945,11 +14381,14 @@ var LspPool = class {
13945
14381
  async references(pos) {
13946
14382
  return this.bridgeFor(pos.path).references(pos);
13947
14383
  }
14384
+ async occurrences(pos) {
14385
+ return this.bridgeFor(pos.path).occurrences(pos);
14386
+ }
13948
14387
  async documentSymbols(path) {
13949
14388
  return this.bridgeFor(path).documentSymbols(path);
13950
14389
  }
13951
14390
  /**
13952
- * 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
13953
14392
  * failing drops its share; only an outage everywhere is an error.
13954
14393
  */
13955
14394
  async workspaceSymbols(query, limit = 200) {
@@ -13959,7 +14398,7 @@ var LspPool = class {
13959
14398
  const out = results.flatMap((r) => r.status === "fulfilled" ? r.value : []);
13960
14399
  const failed = results.find((r) => r.status === "rejected");
13961
14400
  if (!out.length && failed) throw failed.reason;
13962
- return out.slice(0, limit);
14401
+ return fuzzyRank(out, query, (s) => s.name).slice(0, limit);
13963
14402
  }
13964
14403
  /**
13965
14404
  * Keeps the diff's files open in the server for their language, starting one for each
@@ -14044,6 +14483,7 @@ const WORKTREE = "worktree";
14044
14483
  * "a..b" → a vs b
14045
14484
  * "a...b" → merge-base(a, b) vs b
14046
14485
  * "a" "b" → a vs b
14486
+ * "a^!" → a vs its first parent: the one commit, as git show shows it
14047
14487
  * Empty sides default to HEAD. Either side accepts "worktree" for the uncommitted
14048
14488
  * tree; a merge base against it is taken against HEAD, the commit it sits on.
14049
14489
  */
@@ -14052,34 +14492,45 @@ function parseRevspec(args) {
14052
14492
  if (args.length === 2) {
14053
14493
  const [a, b] = args;
14054
14494
  if (a.includes("..") || b.includes("..")) throw new RevspecError("cannot combine \"..\" with two revisions");
14495
+ if (a.endsWith("^!") || b.endsWith("^!")) throw new RevspecError("cannot combine \"^!\" with two revisions");
14055
14496
  return {
14056
14497
  old: a,
14057
14498
  new: b,
14058
- mergeBase: false
14499
+ base: "direct"
14059
14500
  };
14060
14501
  }
14061
14502
  const arg = args[0];
14503
+ if (arg.endsWith("^!")) {
14504
+ const commit = arg.slice(0, -2) || "HEAD";
14505
+ if (commit.includes("..")) throw new RevspecError("cannot combine \"^!\" with \"..\"");
14506
+ if (commit === WORKTREE) throw new RevspecError("the worktree is not a commit");
14507
+ return {
14508
+ old: commit,
14509
+ new: commit,
14510
+ base: "parent"
14511
+ };
14512
+ }
14062
14513
  const three = arg.indexOf("...");
14063
14514
  if (three !== -1) return {
14064
14515
  old: arg.slice(0, three) || "HEAD",
14065
14516
  new: arg.slice(three + 3) || "HEAD",
14066
- mergeBase: true
14517
+ base: "merge-base"
14067
14518
  };
14068
14519
  const two = arg.indexOf("..");
14069
14520
  if (two !== -1) return {
14070
14521
  old: arg.slice(0, two) || "HEAD",
14071
14522
  new: arg.slice(two + 2) || "HEAD",
14072
- mergeBase: false
14523
+ base: "direct"
14073
14524
  };
14074
14525
  if (arg === WORKTREE) return {
14075
14526
  old: "HEAD",
14076
14527
  new: WORKTREE,
14077
- mergeBase: false
14528
+ base: "direct"
14078
14529
  };
14079
14530
  return {
14080
14531
  old: arg,
14081
14532
  new: "HEAD",
14082
- mergeBase: true
14533
+ base: "merge-base"
14083
14534
  };
14084
14535
  }
14085
14536
  var RevspecError = class extends Error {};
@@ -17418,6 +17869,14 @@ function createApi(deps) {
17418
17869
  await next();
17419
17870
  });
17420
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
+ });
17421
17880
  app.post("/api/mode", async (c) => {
17422
17881
  const req = ModeRequestSchema.parse(await readJson(c));
17423
17882
  return c.json(await session.switchMode(req));
@@ -17613,6 +18072,12 @@ function createApi(deps) {
17613
18072
  if ("error" in r) return c.json({ error: r.error }, 409);
17614
18073
  return c.json(await r.lsp.references(pos));
17615
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
+ });
17616
18081
  app.get("/api/lsp/symbols", async (c) => {
17617
18082
  const { path, q: query } = SymbolsQuerySchema.parse(c.req.query());
17618
18083
  const r = await lspFor(path);
@@ -17625,6 +18090,7 @@ function createApi(deps) {
17625
18090
  await deps.config.set(body);
17626
18091
  hub.broadcast({ type: "config" });
17627
18092
  if (body.contextLines != null) await session.setContext(deps.config.get().contextLines);
18093
+ if (body.followRefs != null) session.setFollowRefs(deps.config.get().followRefs);
17628
18094
  return c.json(effectiveConfig(deps));
17629
18095
  });
17630
18096
  return app;
@@ -17705,7 +18171,8 @@ var Server = class {
17705
18171
  app.use("/api/*", compress());
17706
18172
  app.route("/", createApi(this.deps));
17707
18173
  app.all("/api/*", (c) => c.json({ error: "API endpoint not found. Restart diffle to load the latest server." }, 404));
17708
- if (!this.opts.dev) {
18174
+ if (!this.opts.dev && isSea()) app.get("*", serveEmbedded);
18175
+ else if (!this.opts.dev) {
17709
18176
  const clientDir = resolveClientDir();
17710
18177
  app.use("*", serveStatic({ root: clientDir }));
17711
18178
  app.get("*", serveStatic({
@@ -17792,7 +18259,14 @@ function resolveClientDir() {
17792
18259
  return join(projectRoot(), "dist", "client");
17793
18260
  }
17794
18261
  function hasClientBuild() {
17795
- return existsSync(join(resolveClientDir(), "index.html"));
18262
+ return isSea() || existsSync(join(resolveClientDir(), "index.html"));
18263
+ }
18264
+ const embedded = isSea() ? new Set(getAssetKeys()) : /* @__PURE__ */ new Set();
18265
+ function serveEmbedded(c) {
18266
+ const path = c.req.path.slice(1);
18267
+ const key = embedded.has(path) ? path : "index.html";
18268
+ const type = getMimeType(key) ?? "application/octet-stream";
18269
+ return c.body(new Uint8Array(getRawAsset(key)), 200, { "content-type": type });
17796
18270
  }
17797
18271
  //#endregion
17798
18272
  //#region src/server/github/pulls.ts
@@ -18296,6 +18770,102 @@ function describe(skipped) {
18296
18770
  for (const s of skipped) counts.set(s.reason, (counts.get(s.reason) ?? 0) + 1);
18297
18771
  return [...counts].map(([reason, n]) => `${n} ${reason}`).join(", ");
18298
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
+ }
18299
18869
  //#endregion
18300
18870
  //#region src/server/mode.ts
18301
18871
  /**
@@ -18307,36 +18877,43 @@ function describe(skipped) {
18307
18877
  * A--B--C main
18308
18878
  * \--D--E HEAD
18309
18879
  *
18310
- * old new mergeBase oldSha newSha live
18311
- * main HEAD false C E refs
18312
- * main HEAD true B E refs
18313
- * HEAD worktree false E worktree worktree
18314
- * worktree HEAD false worktree E worktree
18315
- * main worktree true B worktree worktree
18316
- * <C hash> <E hash> false C E none
18880
+ * old new base oldSha newSha live
18881
+ * main HEAD direct C E refs
18882
+ * main HEAD merge-base B E refs
18883
+ * HEAD worktree direct E worktree worktree
18884
+ * worktree HEAD direct worktree E worktree
18885
+ * main worktree merge-base B worktree worktree
18886
+ * <C hash> <E hash> direct C E none
18887
+ * <E hash> <E hash> parent D E none
18317
18888
  *
18318
- * mergeBase replaces only the old endpoint with the common ancestor, using
18319
- * HEAD for any worktree input. live is "worktree" if either input is worktree,
18320
- * "none" if both inputs are pinned hashes, and "refs" otherwise.
18889
+ * merge-base replaces only the old endpoint with the common ancestor, using
18890
+ * HEAD for any worktree input; parent replaces it with new's first parent, or
18891
+ * the empty tree for a root commit. live is "worktree" if either input is
18892
+ * worktree, "none" if both inputs are pinned hashes, and "refs" otherwise.
18321
18893
  *
18322
18894
  * In an unborn repository, HEAD compared directly with worktree resolves to
18323
18895
  * the empty tree. Invalid revisions or missing merge bases throw RevspecError.
18324
18896
  */
18325
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
+ };
18326
18903
  const endpoints = [comparison.old, comparison.new];
18327
18904
  const resolve = async (rev) => {
18328
18905
  if (rev === "worktree") return rev;
18329
18906
  try {
18330
18907
  return await repo.resolve(rev);
18331
18908
  } catch (e) {
18332
- if (!comparison.mergeBase && rev === "HEAD" && endpoints.includes("worktree") && e instanceof GitError && e.code === 1) return repo.emptyTree();
18909
+ if (comparison.base === "direct" && rev === "HEAD" && endpoints.includes("worktree") && e instanceof GitError && e.code === 1) return repo.emptyTree();
18333
18910
  if (e instanceof GitError) throw new RevspecError(`unknown revision: ${rev}`);
18334
18911
  throw e;
18335
18912
  }
18336
18913
  };
18337
18914
  const [old, next] = await Promise.all([resolve(comparison.old), resolve(comparison.new)]);
18338
18915
  let oldSha = old;
18339
- if (comparison.mergeBase) {
18916
+ if (comparison.base === "merge-base") {
18340
18917
  const [a, b] = await Promise.all([old === "worktree" ? resolve("HEAD") : old, next === "worktree" ? resolve("HEAD") : next]);
18341
18918
  try {
18342
18919
  oldSha = await repo.mergeBase(a, b);
@@ -18345,6 +18922,13 @@ async function resolveComparison(repo, comparison) {
18345
18922
  throw e;
18346
18923
  }
18347
18924
  }
18925
+ if (comparison.base === "parent") {
18926
+ if (next === "worktree") throw new RevspecError("the worktree is not a commit");
18927
+ oldSha = await repo.resolve(`${next}^`).catch((e) => {
18928
+ if (e instanceof GitError && e.code === 1) return repo.emptyTree();
18929
+ throw e;
18930
+ });
18931
+ }
18348
18932
  const pinned = (rev, sha) => rev.length >= 7 && sha.startsWith(rev.toLowerCase());
18349
18933
  return {
18350
18934
  oldSha,
@@ -18352,14 +18936,29 @@ async function resolveComparison(repo, comparison) {
18352
18936
  live: endpoints.includes("worktree") ? "worktree" : pinned(comparison.old, old) && pinned(comparison.new, next) ? "none" : "refs"
18353
18937
  };
18354
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
+ }
18355
18944
  /** Resolves an input command into a comparison and optional explicit PR identity. */
18356
18945
  async function resolveReview(req, repo, github) {
18357
18946
  if (req.kind === "pr") return resolvePr(req, repo, github);
18358
18947
  const parsed = req.kind === "working" ? {
18359
18948
  old: "HEAD",
18360
18949
  new: "worktree",
18361
- mergeBase: false
18950
+ base: "direct"
18362
18951
  } : parseRevspec(req.args);
18952
+ if (parsed.base === "parent") {
18953
+ const { newSha: sha } = await resolveComparison(repo, parsed);
18954
+ return { mode: {
18955
+ old: sha,
18956
+ new: sha,
18957
+ base: "parent",
18958
+ live: "none",
18959
+ commentKey: `commit:${sha}`
18960
+ } };
18961
+ }
18363
18962
  const { oldSha, newSha, live } = await resolveComparison(repo, parsed);
18364
18963
  return { mode: {
18365
18964
  ...parsed,
@@ -18369,19 +18968,21 @@ async function resolveReview(req, repo, github) {
18369
18968
  }
18370
18969
  /**
18371
18970
  * Branch identities keep review state stable across pushes and local branch aliases.
18971
+ * Keys read like the comparison, with ... for a merge base and .. otherwise.
18372
18972
  * For origin = github.com/o/r and HEAD on feat tracking origin/feat:
18373
- * origin/main...HEAD → branches:["o/r:main","o/r:feat",true]
18374
- * origin/main..HEAD → branches:["o/r:main","o/r:feat",false]
18973
+ * origin/main...HEAD → range:o/r:main...o/r:feat
18974
+ * origin/main..HEAD → range:o/r:main..o/r:feat
18375
18975
  * The matching explicit PR uses the first key too. If feat tracks a fork,
18376
18976
  * its identity becomes e.g. "contributor/r:feat", keeping forks distinct.
18377
18977
  *
18378
18978
  * Without a GitHub upstream, branches use full refs:
18379
- * main...feat → branches:["refs/heads/main","refs/heads/feat",true]
18979
+ * main...feat → range:refs/heads/main...refs/heads/feat
18380
18980
  * Non-branch endpoints use their resolved hash or literal "worktree":
18381
- * main..worktree → branches:["refs/heads/main","worktree",false]
18382
- * If neither endpoint is a branch, the key is revspec:<oldSha>..<newSha>.
18981
+ * main..worktree → range:refs/heads/main..worktree
18982
+ * <C>..<E> → range:<C hash>..<E hash>
18383
18983
  * Hash placeholders denote full resolved hashes, with oldSha already adjusted
18384
- * for mergeBase. The working command bypasses this helper and uses "working".
18984
+ * for the merge base. The working command bypasses this helper and uses
18985
+ * "working"; a single commit uses "commit:<hash>".
18385
18986
  */
18386
18987
  async function commentKey(repo, comparison, oldSha, newSha) {
18387
18988
  const remotes = await repo.remotes();
@@ -18395,14 +18996,10 @@ async function commentKey(repo, comparison, oldSha, newSha) {
18395
18996
  return repo.branchRef(rev);
18396
18997
  };
18397
18998
  const [old, next] = await Promise.all([identity(comparison.old), identity(comparison.new)]);
18398
- return old || next ? branchKey(old ?? oldSha, next ?? newSha, comparison.mergeBase) : `revspec:${oldSha}..${newSha}`;
18999
+ return rangeKey(old ?? oldSha, next ?? newSha, comparison.base === "merge-base");
18399
19000
  }
18400
- function branchKey(old, next, mergeBase) {
18401
- return `branches:${JSON.stringify([
18402
- old,
18403
- next,
18404
- mergeBase
18405
- ])}`;
19001
+ function rangeKey(old, next, mergeBase) {
19002
+ return `range:${old}${mergeBase ? "..." : ".."}${next}`;
18406
19003
  }
18407
19004
  /**
18408
19005
  * A GitHub pull request, as GitHub shows it: merge-base(base tip, head) vs head.
@@ -18421,16 +19018,16 @@ async function resolvePr(req, repo, github) {
18421
19018
  await resolveComparison(repo, {
18422
19019
  old: base,
18423
19020
  new: head,
18424
- mergeBase: true
19021
+ base: "merge-base"
18425
19022
  });
18426
19023
  return {
18427
19024
  prUrl: pr.url,
18428
19025
  mode: {
18429
19026
  old: base,
18430
19027
  new: head,
18431
- mergeBase: true,
19028
+ base: "merge-base",
18432
19029
  live: "none",
18433
- commentKey: branchKey(`${pr.repository.toLowerCase()}:${pr.baseRefName}`, `${pr.headRepository?.toLowerCase() ?? `deleted:${pr.url}`}:${pr.headRefName}`, true)
19030
+ commentKey: rangeKey(`${pr.repository.toLowerCase()}:${pr.baseRefName}`, `${pr.headRepository?.toLowerCase() ?? `deleted:${pr.url}`}:${pr.headRefName}`, true)
18434
19031
  }
18435
19032
  };
18436
19033
  }
@@ -18504,16 +19101,18 @@ var Snapshotter = class {
18504
19101
  mode;
18505
19102
  version;
18506
19103
  context;
19104
+ iterations;
18507
19105
  cache = null;
18508
19106
  patches = /* @__PURE__ */ new Map();
18509
19107
  all = null;
18510
19108
  /** Content-sniff verdict by blob sha. A blob is immutable, so a refresh re-reads only new content. */
18511
19109
  sniffed = /* @__PURE__ */ new Map();
18512
- constructor(repo, mode, version, context) {
19110
+ constructor(repo, mode, version, context, iterations = null) {
18513
19111
  this.repo = repo;
18514
19112
  this.mode = mode;
18515
19113
  this.version = version;
18516
19114
  this.context = context;
19115
+ this.iterations = iterations;
18517
19116
  }
18518
19117
  /** Change context lines: drops cached patches; the next snapshot carries the new value. */
18519
19118
  setContext(context, version) {
@@ -18579,8 +19178,13 @@ var Snapshotter = class {
18579
19178
  }
18580
19179
  async compute(version) {
18581
19180
  const [{ oldSha, newSha }, headSha] = await Promise.all([resolveComparison(this.repo, this.mode), this.repo.resolve("HEAD").catch(() => "")]);
18582
- const [tracked, changed] = await Promise.all([newSha === "worktree" ? Promise.all([this.repo.lsFiles(), this.repo.untracked()]).then((lists) => lists.flat().filter((p) => !p.endsWith("/"))) : this.repo.lsTree(newSha), this.repo.numstat(oldSha, newSha)]);
18583
- await this.fillGenerated(changed, newSha);
19181
+ const [tracked, changed, commit, commits] = await Promise.all([
19182
+ newSha === "worktree" ? Promise.all([this.repo.lsFiles(), this.repo.untracked()]).then((lists) => lists.flat().filter((p) => !p.endsWith("/"))) : this.repo.lsTree(newSha),
19183
+ this.repo.numstat(oldSha, newSha),
19184
+ this.mode.base === "parent" ? this.reviewedCommit(newSha) : null,
19185
+ this.rangeCommits(oldSha, newSha, headSha)
19186
+ ]);
19187
+ const [iterations] = await Promise.all([this.recordIteration(commits), this.fillGenerated(changed, newSha)]);
18584
19188
  const tree = new Set(tracked);
18585
19189
  for (const f of changed) {
18586
19190
  if (f.status === "D") tree.delete(f.path);
@@ -18596,11 +19200,49 @@ var Snapshotter = class {
18596
19200
  headSha,
18597
19201
  context: this.context,
18598
19202
  changed,
18599
- tree: [...tree].sort()
19203
+ tree: [...tree].sort(),
19204
+ commit,
19205
+ commits,
19206
+ iterations
18600
19207
  };
18601
19208
  for (const f of changed.slice(0, PREWARM)) this.patchFor(snap, f).catch(() => {});
18602
19209
  return snap;
18603
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
+ }
19216
+ async reviewedCommit(sha) {
19217
+ const [info, neighbours] = await Promise.all([this.repo.commitInfo(sha), this.repo.commitNeighbours(sha)]);
19218
+ return info && {
19219
+ ...info,
19220
+ ...neighbours
19221
+ };
19222
+ }
19223
+ async rangeCommits(oldSha, newSha, headSha) {
19224
+ if (this.mode.within) {
19225
+ const range = await resolveComparison(this.repo, this.mode.within);
19226
+ return this.spanned(range.oldSha, range.newSha, headSha);
19227
+ }
19228
+ if (this.mode.base === "parent") return {
19229
+ ...await this.repo.rangeCommits(`${newSha}^!`, 250),
19230
+ oldSha,
19231
+ newSha
19232
+ };
19233
+ return this.spanned(oldSha, newSha, headSha);
19234
+ }
19235
+ async spanned(oldSha, newSha, headSha) {
19236
+ const [from, to] = [oldSha, newSha].map((sha) => sha === "worktree" ? headSha : sha);
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
+ };
19245
+ }
18604
19246
  /**
18605
19247
  * Sets `generated` on each non-binary changed file. A `linguist-generated`
18606
19248
  * gitattribute on the new side decides outright, as on GitHub; otherwise a
@@ -18649,6 +19291,10 @@ var Snapshotter = class {
18649
19291
  * while its task holds the queue, so published versions are monotonic and the
18650
19292
  * active snapshotter always carries the session's context. Requests that arrive
18651
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.
18652
19298
  */
18653
19299
  var Session = class {
18654
19300
  repo;
@@ -18663,6 +19309,8 @@ var Session = class {
18663
19309
  queue = Promise.resolve();
18664
19310
  /** The refresh waiting for the queue, if any. Later requests join it. */
18665
19311
  queuedRefresh = null;
19312
+ /** The refs check waiting for the queue, if any. Later signals join it. */
19313
+ queuedCheck = null;
18666
19314
  readable = /* @__PURE__ */ new WeakMap();
18667
19315
  snapshotListeners = /* @__PURE__ */ new Set();
18668
19316
  constructor(repo, hub, opts) {
@@ -18684,6 +19332,24 @@ var Session = class {
18684
19332
  get snapshotter() {
18685
19333
  return this.require().snapshotter;
18686
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
+ }
18687
19353
  /** Cached independently of snapshot construction; callers never hold the transition queue. */
18688
19354
  github(snap) {
18689
19355
  const active = this.require();
@@ -18771,7 +19437,7 @@ var Session = class {
18771
19437
  }
18772
19438
  /** Switch modes at runtime. Broadcasts a snapshot bump on success. */
18773
19439
  async switchMode(req) {
18774
- const snap = await this.run(async () => this.activate(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)));
18775
19441
  this.hub.broadcast({
18776
19442
  type: "snapshot",
18777
19443
  version: snap.version
@@ -18779,6 +19445,150 @@ var Session = class {
18779
19445
  return snap;
18780
19446
  }
18781
19447
  /**
19448
+ * Focuses `commit`, by hash or short hash, of the active range's listed commits, or with null returns to
19449
+ * the range. A focused commit keeps its own comments, as when entered alone, and the range's PR identity.
19450
+ */
19451
+ async focus(commit) {
19452
+ const a = this.require();
19453
+ const { within, ...self } = a.mode;
19454
+ if (!within && self.base === "parent") throw new RevspecError("a single commit has no commits to focus");
19455
+ const range = within ?? self;
19456
+ if (commit === null) return {
19457
+ mode: range,
19458
+ prUrl: a.prUrl
19459
+ };
19460
+ const sha = (await a.snapshotter.current()).commits.list.find((c) => c.sha === commit || c.short === commit)?.sha;
19461
+ if (!sha) throw new RevspecError(`not a listed commit of ${comparisonLabel(range)}: ${commit}`);
19462
+ return {
19463
+ prUrl: a.prUrl,
19464
+ mode: {
19465
+ old: sha,
19466
+ new: sha,
19467
+ base: "parent",
19468
+ live: range.live === "none" ? "none" : "refs",
19469
+ commentKey: `commit:${sha}`,
19470
+ within: range
19471
+ }
19472
+ };
19473
+ }
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
+ /**
18782
19592
  * Runs `fn` after every earlier mutation settled. The only way to touch
18783
19593
  * `active`, `version` or `opts.context`.
18784
19594
  */
@@ -18788,14 +19598,22 @@ var Session = class {
18788
19598
  return r;
18789
19599
  }
18790
19600
  async activate({ mode, prUrl }) {
18791
- const snapshotter = new Snapshotter(this.repo, mode, ++this.version, this.opts.context);
18792
- 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
+ ]);
18793
19609
  const next = {
18794
19610
  mode,
18795
19611
  prUrl,
18796
19612
  snapshotter,
18797
19613
  comments,
18798
- watcher: null
19614
+ watcher: null,
19615
+ moved: null,
19616
+ branches
18799
19617
  };
18800
19618
  await this.relocateComments(next, snap);
18801
19619
  if (this.opts.watch && mode.live !== "none") next.watcher = await this.startWatcher(next);
@@ -18825,13 +19643,61 @@ var Session = class {
18825
19643
  if (this.queuedRefresh) return this.queuedRefresh;
18826
19644
  const r = this.run(async () => {
18827
19645
  this.queuedRefresh = null;
18828
- const a = this.require();
18829
- a.snapshotter.invalidate(++this.version);
18830
- await this.publish(a);
19646
+ await this.recompute();
18831
19647
  }).catch((e) => console.error("[diffle] refresh failed:", e));
18832
19648
  this.queuedRefresh = r;
18833
19649
  return r;
18834
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
+ }
18835
19701
  /**
18836
19702
  * Computes `a`'s current snapshot, re-anchors its threads (hunks may have
18837
19703
  * grown, shrunk or moved), then broadcasts and announces it.
@@ -18839,11 +19705,40 @@ var Session = class {
18839
19705
  async publish(a) {
18840
19706
  const snap = await a.snapshotter.current();
18841
19707
  await this.relocateComments(a, snap);
19708
+ a.moved = null;
18842
19709
  this.hub.broadcast({
18843
19710
  type: "snapshot",
18844
19711
  version: snap.version
18845
19712
  });
18846
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;
18847
19742
  }
18848
19743
  /**
18849
19744
  * Re-anchors threads against what `snap` shows. A changed file shows its
@@ -18907,6 +19802,7 @@ var Session = class {
18907
19802
  * review. Goes through `readSide`, so the allowlist applies.
18908
19803
  */
18909
19804
  anchorSource() {
19805
+ if (this.mode.interdiff) throw new RevspecError("comments are off while comparing iterations");
18910
19806
  return {
18911
19807
  quote: async (path, side, startLine, endLine) => {
18912
19808
  const snap = await this.snapshotter.current();
@@ -18947,6 +19843,10 @@ var Session = class {
18947
19843
  });
18948
19844
  watcher.on("dirty", () => {
18949
19845
  if (this.active !== a) return;
19846
+ if (!this.follows) {
19847
+ this.checkMoved(a);
19848
+ return;
19849
+ }
18950
19850
  this.refresh();
18951
19851
  this.run(async () => {
18952
19852
  if (this.active === a && a.mode.live === "worktree") await refreshIgnored();
@@ -19037,6 +19937,7 @@ function normalize(c) {
19037
19937
  return {
19038
19938
  autoViewed: Array.isArray(c.autoViewed) ? c.autoViewed.filter((p) => typeof p === "string").map((p) => p.trim()).filter(Boolean) : DEFAULT_USER_CONFIG.autoViewed,
19039
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,
19040
19941
  lspCommands: lspCommands(c)
19041
19942
  };
19042
19943
  }
@@ -22744,6 +23645,12 @@ function parseLspOverride(raw) {
22744
23645
  function collectLspOverride(raw, prev) {
22745
23646
  return [...Array.isArray(prev) ? prev : [], parseLspOverride(raw)];
22746
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
+ }
22747
23654
  //#endregion
22748
23655
  //#region src/cli/browserLifetime.ts
22749
23656
  /**
@@ -22895,6 +23802,7 @@ Shorthands (in place of <revs>):
22895
23802
  working same as HEAD..worktree: uncommitted changes, staged and untracked included
22896
23803
  pr [number|url] a GitHub pull request: its base...head, fetched if needed
22897
23804
  (without an argument: the pull request for this branch)
23805
+ show [rev] same as <rev>^!: one commit against its first parent, pinned (default: HEAD)
22898
23806
 
22899
23807
  Revisions follow git diff, except that a lone one compares from the merge base:
22900
23808
  diffle main same as main...HEAD: what this branch added since it left main
@@ -22902,6 +23810,7 @@ Revisions follow git diff, except that a lone one compares from the merge base:
22902
23810
  diffle main..feat main vs feat
22903
23811
  diffle main...feat what feat added since it left main
22904
23812
  diffle main..worktree main vs the uncommitted tree ("worktree" works on either side)
23813
+ diffle a1b2c3d^! the commit a1b2c3d alone, like git show
22905
23814
 
22906
23815
  Without revisions, diffle compares HEAD..worktree.
22907
23816
 
@@ -22914,6 +23823,10 @@ Ctrl+C always stops it.`).action(async (revs, _o, cmd) => {
22914
23823
  }, cmd.optsWithGlobals());
22915
23824
  });
22916
23825
  program.command("working", { hidden: true }).summary("same as HEAD..worktree").description("Same as `diffle HEAD..worktree`: uncommitted changes, staged and untracked files included.").action(async (_o, cmd) => run({ kind: "working" }, cmd.optsWithGlobals()));
23826
+ program.command("show", { hidden: true }).argument("[rev]", "the commit to review", "HEAD").summary("one commit").description("Same as `diffle <rev>^!`: one commit against its first parent, as git show shows it.").action(async (rev, _o, cmd) => run({
23827
+ kind: "revspec",
23828
+ args: [`${rev}^!`]
23829
+ }, cmd.optsWithGlobals()));
22917
23830
  program.command("pr", { hidden: true }).argument("[pr]", "pull request number or url; default: the pull request for this branch").summary("a GitHub pull request").description("A GitHub pull request, as GitHub shows it: merge-base(base, head) vs head.").action(async (pr, _o, cmd) => run({
22918
23831
  kind: "pr",
22919
23832
  pr
@@ -22934,6 +23847,11 @@ config.command("set-context").description("set context lines around changes").ar
22934
23847
  await store.set({ contextLines: n });
22935
23848
  console.log(store.get().contextLines);
22936
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
+ });
22937
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) => {
22938
23856
  const store = await UserConfigStore.open();
22939
23857
  await store.set({ lspCommands: {
@@ -23009,6 +23927,7 @@ async function serve(req, opts, repo, closeRepo, config, github, timing) {
23009
23927
  const session = new Session(repo, hub, {
23010
23928
  watch: opts.watch,
23011
23929
  context: opts.context ?? config.get().contextLines,
23930
+ followRefs: config.get().followRefs,
23012
23931
  github: github.client ?? void 0
23013
23932
  });
23014
23933
  const named = Array.isArray(opts.lsp) ? opts.lsp : [];
@@ -23071,7 +23990,7 @@ async function serve(req, opts, repo, closeRepo, config, github, timing) {
23071
23990
  console.error(`🔍 ${c.dim("comparing")} ${c.bold(comparisonLabel(snap.mode))}`);
23072
23991
  if (n === 0) console.error(`${c.yellow("!")} No differences. Open any file from the tree to comment on it.`);
23073
23992
  else console.error(`📝 ${n} changed file${n === 1 ? "" : "s"} ${c.green(`+${adds}`)} ${c.red(`−${dels}`)}`);
23074
- 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)")}`);
23075
23994
  if (lsp) {
23076
23995
  const { servers, missing } = lsp.status();
23077
23996
  const names = servers.map((s) => `${s.name} ${c.dim(`(${s.languages.join(", ")})`)}`).join(", ");