prreviewbuddy 0.30.0 → 0.31.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.31.0
4
+
5
+ **Review a single commit.** `prreviewbuddy review --commit <commit>` reviews the change one commit
6
+ made, compared against the commit before it. Name the commit however git lets you: a sha, `HEAD~2`
7
+ or a tag. A merge commit is compared against its first parent, and the review says so; the very
8
+ first commit in a repository is compared against nothing, so every file in it is new.
9
+
10
+ A commit never changes, so its review does not go out of date. The review page says "Freshness:
11
+ Fixed" instead of offering to update it, and `--update` is refused with a pointer to `--reanalyse`,
12
+ which analyses the same commit again and keeps the earlier analysis beside the new one. If the commit
13
+ later disappears from your repository, after a force-push for example, the review still opens in
14
+ full and says the commit is gone. `--commit` cannot be combined with `--base`, because the commit
15
+ before it is the base; to compare a branch against a base of your choosing, use `--branch` with
16
+ `--base` as before.
17
+
18
+ Reviews of the same commit share one card on the reviews list, named `commit` and its short sha, and
19
+ searching the list for the sha finds it. A review of a commit and a review of the branch that
20
+ contains it stay separate.
21
+
22
+ **A detached checkout points at `--commit HEAD`.** Running `prreviewbuddy review` on a detached HEAD
23
+ is still refused, because there is no branch to file the review under, and the message now offers
24
+ `prreviewbuddy review --commit HEAD` to review the commit you are on.
25
+
26
+ **A calmer Files tab.** Each file's issue and question counts are now small icons with a number, in
27
+ a column of their own on the right, instead of labels that squeezed long filenames onto several
28
+ lines. The label naming a second theme that also mentions a file is gone: the file is already listed
29
+ under its own theme, and its counts still include every finding. Added, modified, removed and renamed
30
+ files now each have their own colour, and the labels are all one width so the paths line up.
31
+
32
+ **The review header stays on one line.** On a narrow window the branch, commit and controls at the
33
+ top of a review no longer wrap onto a second line.
34
+
3
35
  ## 0.30.0
4
36
 
5
37
  **Usage telemetry is now on by default.** Non-identifying usage telemetry is now enabled by default
@@ -2060,6 +2060,17 @@ var EmptyChangeError = class extends Error {
2060
2060
  }
2061
2061
  };
2062
2062
  /**
2063
+ * Whether a stored change set is a review of one commit. See `ChangeSet.targetType`.
2064
+ *
2065
+ * A function rather than the comparison written out, and not only for tidiness:
2066
+ * `freshness_read_only.test.ts` refuses any quoted git subcommand in `freshness.ts`, and the
2067
+ * discriminator's value is spelled the same as one. That guard is right to be blunt, so the
2068
+ * comparison lives here, beside the field it reads.
2069
+ */
2070
+ function isCommitReview(changeSet) {
2071
+ return changeSet.targetType === "commit";
2072
+ }
2073
+ /**
2063
2074
  * Decide what this change is being compared against.
2064
2075
  *
2065
2076
  * Refuses rather than guessing. A base we picked wrongly produces a guide that looks completely
@@ -2142,7 +2153,7 @@ async function defaultRemoteBranch(repoPath) {
2142
2153
  async function buildChangeSet(options) {
2143
2154
  const repoPath = (await git(options.repoPath, ["rev-parse", "--show-toplevel"])).trim();
2144
2155
  const base = options.resolvedBase ?? await resolveBase(repoPath, options.base);
2145
- const mergeBase = await mergeBaseOf(repoPath, base, "HEAD");
2156
+ const mergeBase = base.exact ? base.revision : await mergeBaseOf(repoPath, base, "HEAD");
2146
2157
  const headRef = await currentBranch(repoPath);
2147
2158
  const isLinkedWorktree = await linkedWorktree(repoPath);
2148
2159
  const files = await collectFiles(repoPath, [mergeBase, "HEAD"]);
@@ -2150,7 +2161,7 @@ async function buildChangeSet(options) {
2150
2161
  disagreement(files.length, base.display, options.expected);
2151
2162
  const blobShas = await headBlobShas(repoPath);
2152
2163
  for (const file of files) file.sha = blobShas.get(file.name) ?? "";
2153
- const { subjects, authors } = await commitLog(repoPath, mergeBase);
2164
+ const { subjects, authors } = await commitLog(repoPath, base.exact && !await refExists(repoPath, mergeBase) ? "HEAD" : `${mergeBase}..HEAD`);
2154
2165
  return {
2155
2166
  data: {
2156
2167
  pullRequest: {
@@ -2286,12 +2297,12 @@ async function currentBranch(repoPath) {
2286
2297
  * Uncommitted work has no author in the log, and needs none. It belongs to whoever is sitting at
2287
2298
  * this checkout, and that is the person reading the page.
2288
2299
  */
2289
- async function commitLog(repoPath, mergeBase) {
2300
+ async function commitLog(repoPath, range) {
2290
2301
  const out = await git(repoPath, [
2291
2302
  "log",
2292
2303
  "--reverse",
2293
2304
  "--format=%an%x1f%s",
2294
- `${mergeBase}..HEAD`
2305
+ range
2295
2306
  ]);
2296
2307
  const subjects = [];
2297
2308
  const counts = /* @__PURE__ */ new Map();
@@ -2365,7 +2376,7 @@ async function mergeBaseOf(repoPath, base, head) {
2365
2376
  */
2366
2377
  async function hasCommittedChanges(repoPath, base, head) {
2367
2378
  return parseNameStatus(await git(repoPath, [
2368
- ...rangeDiff([await mergeBaseOf(repoPath, base, head), head]),
2379
+ ...rangeDiff([base.exact ? base.revision : await mergeBaseOf(repoPath, base, head), head]),
2369
2380
  "--name-status",
2370
2381
  "-z"
2371
2382
  ])).length > 0;
@@ -2610,6 +2621,97 @@ function ambiguous(named, found) {
2610
2621
  ].join("\n");
2611
2622
  }
2612
2623
  //#endregion
2624
+ //#region ../../packages/review-harness/src/workspace/commit_target.ts
2625
+ /**
2626
+ * One commit the reviewer named, resolved to what it introduced, or refused.
2627
+ *
2628
+ * `prreviewbuddy review --commit 9907c64` reviews *the change that commit made*, which is the diff
2629
+ * against its first parent. That is a different object from everything else this product reviews,
2630
+ * and the difference is not the spelling of the target but what can be done to it afterwards.
2631
+ *
2632
+ * **A branch is followed; a commit is not.** `branch_target.ts` refuses a commit and says why: a
2633
+ * review follows its branch so it can be brought up to date as commits arrive, and a commit has
2634
+ * nowhere to move to. That refusal stands. This module is the other surface that docblock asked for,
2635
+ * and the thing it has to supply that `--branch` could not is an answer to what the lineage key is:
2636
+ * the commit's own full sha, never a ref manufactured from it. See `lineageKeyFor`.
2637
+ *
2638
+ * **The parent is the base, and the reviewer cannot name another one.** `--commit` with `--base` is
2639
+ * refused in the parser, because the two together are arbitrary range review arriving by the back
2640
+ * door: the findings would be about a different change under a page still naming the commit.
2641
+ *
2642
+ * **Nothing fetches,** for the same reason as `branch_target.ts`: the reviewer named something they
2643
+ * believe is here, and a commit-ish that does not resolve locally is far more often a typo than a
2644
+ * missing fetch.
2645
+ */
2646
+ async function resolveNamedCommit(repoPath, value) {
2647
+ const named = value.trim();
2648
+ if (!named) throw new TargetError("No commit was named. Give `--commit` a commit, such as `--commit 9907c64` or `--commit HEAD~2`.");
2649
+ const sha = await peel(repoPath, named);
2650
+ const parents = (await git(repoPath, [
2651
+ "rev-list",
2652
+ "--parents",
2653
+ "-n",
2654
+ "1",
2655
+ sha
2656
+ ])).trim().split(/\s+/).slice(1);
2657
+ const subject = (await git(repoPath, [
2658
+ "log",
2659
+ "-1",
2660
+ "--format=%s",
2661
+ sha
2662
+ ])).trim();
2663
+ return {
2664
+ sha,
2665
+ parentSha: parents[0] ?? null,
2666
+ parentCount: parents.length,
2667
+ subject
2668
+ };
2669
+ }
2670
+ /**
2671
+ * The empty tree, asked of the repository rather than written down.
2672
+ *
2673
+ * A root commit is compared against nothing, and git spells nothing as the empty tree object. The
2674
+ * constant everybody knows, `4b825dc642cb6eb9a060e54bf8d69288fbee4904`, is the **sha-1** spelling of
2675
+ * it, and a repository created with `--object-format=sha256` has a different one. Hardcoding it
2676
+ * would make root-commit review silently wrong there rather than loudly broken, so the repository is
2677
+ * asked. `/dev/null` is an empty file, and hashing one as a tree is the documented way to name it.
2678
+ */
2679
+ async function emptyTreeSha(repoPath) {
2680
+ return (await git(repoPath, [
2681
+ "hash-object",
2682
+ "-t",
2683
+ "tree",
2684
+ "/dev/null"
2685
+ ])).trim();
2686
+ }
2687
+ /**
2688
+ * The full commit sha behind whatever was typed, or a refusal naming what it found instead.
2689
+ *
2690
+ * A tree or a blob is refused **by name**, the way `branch_target.ts` refuses `refs/tags/v1` by
2691
+ * name: `--commit HEAD^{tree}` resolves perfectly well to an object that is right there, and telling
2692
+ * the reviewer it could not be found would send them looking for a commit they have.
2693
+ */
2694
+ async function peel(repoPath, named) {
2695
+ try {
2696
+ return (await git(repoPath, [
2697
+ "rev-parse",
2698
+ "--verify",
2699
+ "--quiet",
2700
+ `${named}^{commit}`
2701
+ ])).trim();
2702
+ } catch {}
2703
+ let type = "";
2704
+ try {
2705
+ type = (await git(repoPath, [
2706
+ "cat-file",
2707
+ "-t",
2708
+ named
2709
+ ])).trim();
2710
+ } catch {}
2711
+ if (type === "tree" || type === "blob") throw new TargetError(`\`${named}\` is a ${type}, and a review is of a commit.\n\nA commit review is the change that commit introduced, which is its diff against its parent. A ${type} is a snapshot with nothing to compare it to. Name the commit instead.`);
2712
+ throw new TargetError(`There is nothing called \`${named}\` in this repository.\n\n\`--commit\` takes anything git can resolve to a commit: a sha, \`HEAD~2\`, a tag. If the commit is on a remote this clone has not seen, run \`git fetch\` and try again.`);
2713
+ }
2714
+ //#endregion
2613
2715
  //#region ../../packages/review-harness/src/workspace/target.ts
2614
2716
  /**
2615
2717
  * What is being reviewed, decided once and used everywhere.
@@ -2628,6 +2730,7 @@ function ambiguous(named, found) {
2628
2730
  */
2629
2731
  async function resolveTarget(input) {
2630
2732
  const root = await repositoryRoot(input.repoPath);
2733
+ if (input.commitRef) return commitTarget(root, input.commitRef);
2631
2734
  if (input.base) await verifyBase(root, input.base);
2632
2735
  else await verifyDefaultBase(root);
2633
2736
  const named = input.base ? { base: input.base } : {};
@@ -2684,6 +2787,46 @@ async function resolveTarget(input) {
2684
2787
  };
2685
2788
  }
2686
2789
  /**
2790
+ * One commit, and the parent it will be compared against, settled here and never re-derived.
2791
+ *
2792
+ * The base is built as a `ResolvedBase` rather than left as a name for `resolveBase` to work out,
2793
+ * which is the seam the pull request path already uses: `ChangeSetOptions.resolvedBase` is
2794
+ * documented as "a base already decided by the caller... The caller that set it had an authority
2795
+ * this module does not". The forge is that authority there. Here it is git's own commit graph, which
2796
+ * is a stronger one still, and there is nothing for a ladder of branch names to contribute to it.
2797
+ *
2798
+ * `followRef` is deliberately absent. It exists so a later rebuild can re-resolve a moving base, and
2799
+ * this base cannot move: a commit's parent is the same commit for ever. Recording a ref here would
2800
+ * invite exactly the re-resolution the immutability is for.
2801
+ *
2802
+ * `exact` is set because the parent *is* the comparison point. For an ordinary commit that agrees
2803
+ * with `merge-base(parent, commit)` anyway; for a merge it is what pins the diff to the first parent
2804
+ * rather than to wherever the two histories converge, and for a root commit it is the only way the
2805
+ * empty tree can be a base at all.
2806
+ */
2807
+ async function commitTarget(root, commitRef) {
2808
+ const commit = await resolveNamedCommit(root, commitRef);
2809
+ const short = commit.sha.slice(0, 7);
2810
+ const parent = commit.parentSha;
2811
+ const resolvedBase = parent ? {
2812
+ revision: parent,
2813
+ display: parent.slice(0, 7),
2814
+ exact: true
2815
+ } : {
2816
+ revision: await emptyTreeSha(root),
2817
+ display: "the empty tree",
2818
+ exact: true
2819
+ };
2820
+ return {
2821
+ originRepoPath: root,
2822
+ branch: short,
2823
+ sha: commit.sha,
2824
+ base: resolvedBase.display,
2825
+ resolvedBase,
2826
+ commit
2827
+ };
2828
+ }
2829
+ /**
2687
2830
  * Which commit this request was cut from, decided before anything expensive happens.
2688
2831
  *
2689
2832
  * Strict, and it refuses rather than falling back to a local branch of the right name. The local
@@ -2818,6 +2961,14 @@ async function uncommittedIn(root, target) {
2818
2961
  * to the display name, which `resolveBase` now resolves remote-first anyway.
2819
2962
  */
2820
2963
  function baseOf(changeSet) {
2964
+ if (changeSet.targetType === "commit" && changeSet.mergeBase) return {
2965
+ base: changeSet.baseRef,
2966
+ resolvedBase: {
2967
+ revision: changeSet.mergeBase,
2968
+ display: changeSet.baseRef,
2969
+ exact: true
2970
+ }
2971
+ };
2821
2972
  if (!changeSet.baseRef) return {};
2822
2973
  const followRef = changeSet.baseResolvedRef;
2823
2974
  return {
@@ -2885,7 +3036,7 @@ async function branchOf(root) {
2885
3036
  branch = "";
2886
3037
  }
2887
3038
  if (branch) return branch;
2888
- throw new TargetError("This checkout is on a detached HEAD, so there is no branch to file a review under. Check out a branch, or name the pull request you want reviewed.");
3039
+ throw new TargetError("This checkout is on a detached HEAD, so there is no branch to file a review under. To review the commit you are on, run `prreviewbuddy review --commit HEAD`. Otherwise check out a branch, or name the pull request you want reviewed.");
2889
3040
  }
2890
3041
  /**
2891
3042
  * The commit has to be here before a worktree can be pinned to it.
@@ -4036,9 +4187,22 @@ function reviewedRepositories() {
4036
4187
  *
4037
4188
  * A newline separates the halves because it cannot appear in any of them, so no set of values can
4038
4189
  * collide by running together.
4190
+ *
4191
+ * **A commit review keys by the commit, not by a branch.** It follows nothing, so the ref half has
4192
+ * no meaning for it, and its display name is a short sha that two different commits can share.
4039
4193
  */
4040
4194
  function lineageKey(review) {
4041
- return lineageKeyFor(review.repoPath, review.branch, review.headResolvedRef);
4195
+ if (review.targetType === "commit") return lineageKeyFor({
4196
+ kind: "commit",
4197
+ repoPath: review.repoPath,
4198
+ sha: review.commitSha ?? ""
4199
+ });
4200
+ return lineageKeyFor({
4201
+ kind: "branch",
4202
+ repoPath: review.repoPath,
4203
+ branch: review.branch,
4204
+ followedRef: review.headResolvedRef
4205
+ });
4042
4206
  }
4043
4207
  /**
4044
4208
  * The same key, from the pieces, so a target and a stored review cannot arrive at it differently.
@@ -4047,8 +4211,19 @@ function lineageKey(review) {
4047
4211
  * asks whether a target is the target of a stored review, and if it compared its own three fields
4048
4212
  * by hand then widening the key here and widening the match there would be two changes that have
4049
4213
  * to stay in step forever.
4214
+ *
4215
+ * **The commit arm is decided before the branch arm's defaulting, and that order is the point.** The
4216
+ * branch arm reads an absent ref as `refs/heads/<branch>`, so a commit reaching it would derive
4217
+ * `refs/heads/<sha>` and file under a ref nobody has. The middle segment is the literal `commit`,
4218
+ * which no branch key can produce in that position with a full sha after it, because a branch key's
4219
+ * last segment always starts with `refs/`.
4220
+ *
4221
+ * The branch arm is byte-for-byte the key every review on disk already has. Changing it re-files
4222
+ * every stored review under a new card, silently, on every machine.
4050
4223
  */
4051
- function lineageKeyFor(repoPath, branch, followedRef) {
4224
+ function lineageKeyFor(subject) {
4225
+ if (subject.kind === "commit") return `${subject.repoPath}\ncommit\n${subject.sha}`;
4226
+ const { repoPath, branch, followedRef } = subject;
4052
4227
  return `${repoPath}\n${branch}\n${followedRef ?? `refs/heads/${branch}`}`;
4053
4228
  }
4054
4229
  /**
@@ -4206,6 +4381,11 @@ function summarise(workspace) {
4206
4381
  branch: workspace.changeSet.headRef,
4207
4382
  baseRef: workspace.changeSet.baseRef ?? "",
4208
4383
  ...workspace.changeSet.headResolvedRef ? { headResolvedRef: workspace.changeSet.headResolvedRef } : {},
4384
+ ...workspace.changeSet.targetType === "commit" ? {
4385
+ targetType: "commit",
4386
+ parentSha: workspace.changeSet.parentSha ?? null,
4387
+ ...workspace.changeSet.parentCount !== void 0 ? { parentCount: workspace.changeSet.parentCount } : {}
4388
+ } : {},
4209
4389
  repoPath: workspace.repoPath,
4210
4390
  changedFiles: totalChangedFiles(workspace.changeSet.data),
4211
4391
  additions: files.reduce((total, file) => total + (file.additions ?? 0), 0),
@@ -4224,7 +4404,7 @@ function summarise(workspace) {
4224
4404
  pinned: workspace.pinned === true,
4225
4405
  paths: files.slice(0, MAX_INDEXED_PATHS).map((file) => file.name),
4226
4406
  prNumber: workspace.changeSet.data.pullRequest?.number || workspace.prContext?.pullRequest.number,
4227
- commitSha: workspace.session.metadata?.commitSha || void 0,
4407
+ commitSha: workspace.session.metadata?.commitSha || (workspace.changeSet.targetType === "commit" ? workspace.changeSet.data.pullRequest.head.sha : "") || void 0,
4228
4408
  author: describeAuthorship(workspace.changeSet, workspace.prContext?.pullRequest)?.label,
4229
4409
  authorHandle: workspace.prContext?.pullRequest.author?.handle || void 0
4230
4410
  };
@@ -5110,18 +5290,47 @@ async function refSha(repoPath, ref) {
5110
5290
  * - **Describe, do not re-analyse.** The workspace says what changed. Deciding what it means for
5111
5291
  * the findings is the analyser's job, and the analyser is Claude Code.
5112
5292
  */
5113
- async function checkFreshness(workspace) {
5293
+ /**
5294
+ * Freshness, and for a commit review whether its commit is still here, in one pass.
5295
+ *
5296
+ * **`latest()` is never asked about a commit review.** Its null already means "the branch is not in
5297
+ * this repository", a real answer with two documented causes. A commit review has no branch to look
5298
+ * for at all, and teaching that function to return null for this too would make one value mean two
5299
+ * unrelated things, told apart only by knowing the target type: the ambiguity a commit target exists
5300
+ * to remove. So this path does not reach it.
5301
+ */
5302
+ async function checkReview(workspace) {
5114
5303
  const reviewedSha = workspace.session.metadata?.commitSha || workspace.changeSet.data.pullRequest.head.sha || null;
5115
5304
  const headSha = await readHead(workspace.repoPath);
5305
+ const commitReview = isCommitReview(workspace.changeSet);
5116
5306
  return {
5117
- checkedAt: Date.now(),
5118
- code: await codeFreshness(workspace, reviewedSha, headSha, await latest(workspace)),
5119
- checkout: await checkoutFreshness(workspace, headSha),
5120
- conversation: workspace.prContext ? {
5121
- attached: true,
5122
- state: "unknown",
5123
- importedAt: workspace.prContext.discussion.fetchedAt
5124
- } : { attached: false }
5307
+ freshness: {
5308
+ checkedAt: Date.now(),
5309
+ code: commitReview ? fixedCode(reviewedSha) : await codeFreshness(workspace, reviewedSha, headSha, await latest(workspace)),
5310
+ checkout: await checkoutFreshness(workspace, headSha),
5311
+ conversation: workspace.prContext ? {
5312
+ attached: true,
5313
+ state: "unknown",
5314
+ importedAt: workspace.prContext.discussion.fetchedAt
5315
+ } : { attached: false }
5316
+ },
5317
+ availability: commitReview && reviewedSha ? {
5318
+ state: await commitExists(workspace.repoPath, reviewedSha) ? "available" : "missing",
5319
+ sha: reviewedSha
5320
+ } : null
5321
+ };
5322
+ }
5323
+ /** The code answer for a commit review. Nothing is compared, so every count is absent rather than zero. */
5324
+ function fixedCode(reviewedSha) {
5325
+ return {
5326
+ state: "fixed",
5327
+ reviewedSha,
5328
+ latestSha: null,
5329
+ comparison: null,
5330
+ newCommits: null,
5331
+ changedFiles: null,
5332
+ rewritten: false,
5333
+ reason: null
5125
5334
  };
5126
5335
  }
5127
5336
  /**
@@ -5139,6 +5348,7 @@ async function checkFreshness(workspace) {
5139
5348
  * a whole `FreshnessReport` around a single run would imply it had its own answer to both.
5140
5349
  */
5141
5350
  async function checkCodeFreshness(workspace, reviewedSha) {
5351
+ if (isCommitReview(workspace.changeSet)) return fixedCode(reviewedSha);
5142
5352
  return codeFreshness(workspace, reviewedSha, await readHead(workspace.repoPath), await latest(workspace));
5143
5353
  }
5144
5354
  /**
@@ -9748,6 +9958,7 @@ async function drive(jobId, workspaceId, runners, driver, signal) {
9748
9958
  repoPath: job.target.originRepoPath,
9749
9959
  headRef: job.target.branch,
9750
9960
  ...job.target.ref ? { headResolvedRef: job.target.ref } : {},
9961
+ ...commitIdentity(job.target, stored.changeSet),
9751
9962
  checkoutName,
9752
9963
  isLinkedWorktree,
9753
9964
  data: {
@@ -9916,7 +10127,7 @@ function pendingChangeSet(target) {
9916
10127
  data: {
9917
10128
  pullRequest: {
9918
10129
  number: target.pullRequest?.number ?? 0,
9919
- title: target.branch,
10130
+ title: target.commit?.subject || target.branch,
9920
10131
  body: null,
9921
10132
  base: {
9922
10133
  ref: target.base ?? "",
@@ -9935,6 +10146,7 @@ function pendingChangeSet(target) {
9935
10146
  repoPath: target.originRepoPath,
9936
10147
  headRef: target.branch,
9937
10148
  ...target.ref ? { headResolvedRef: target.ref } : {},
10149
+ ...commitIdentity(target, void 0),
9938
10150
  baseRef: target.base ?? "",
9939
10151
  mergeBase: "",
9940
10152
  includesUncommitted: false,
@@ -9943,6 +10155,27 @@ function pendingChangeSet(target) {
9943
10155
  };
9944
10156
  }
9945
10157
  /**
10158
+ * What makes a stored change set a commit review, carried from wherever it is known.
10159
+ *
10160
+ * Two sources, because a target is rebuilt from a stored review in three places (`reanalyseReview`,
10161
+ * `pinned_checkout.ts`, `kiss_run.ts`) and none of them has the `NamedCommit` the original command
10162
+ * resolved. They do not need it: the stored record already says what it is, and that answer is the
10163
+ * one to keep. A fresh target wins when there is one, since it is where the answer was decided.
10164
+ */
10165
+ function commitIdentity(target, stored) {
10166
+ if (target.commit) return {
10167
+ targetType: "commit",
10168
+ parentSha: target.commit.parentSha,
10169
+ parentCount: target.commit.parentCount
10170
+ };
10171
+ if (stored?.targetType === "commit") return {
10172
+ targetType: "commit",
10173
+ parentSha: stored.parentSha ?? null,
10174
+ ...stored.parentCount !== void 0 ? { parentCount: stored.parentCount } : {}
10175
+ };
10176
+ return {};
10177
+ }
10178
+ /**
9946
10179
  * Rebuild the prompt a phase needs, from what the job and the workspace already hold.
9947
10180
  *
9948
10181
  * Not cached in memory: the daemon can restart between `context` finishing and `analysing`
@@ -10965,6 +11198,8 @@ function explainUpdateFailure(error, env, requestedModel) {
10965
11198
  var RefreshUnavailableError = class extends Error {};
10966
11199
  /** A fetch reaches the network, so it gets a ceiling. Nothing else here can hang. */
10967
11200
  var FETCH_TIMEOUT_MS = 6e4;
11201
+ /** Said wherever an update is asked of a commit review. One sentence, so both surfaces agree. */
11202
+ var COMMIT_REVIEW_CANNOT_UPDATE = "This review is of one commit, and a commit cannot move, so there is nothing to bring it up to date with. To analyse the same commit again, reanalyse it instead.";
10968
11203
  /**
10969
11204
  * Computes and mutates the workspace in memory; does not persist.
10970
11205
  *
@@ -10973,6 +11208,7 @@ var FETCH_TIMEOUT_MS = 6e4;
10973
11208
  */
10974
11209
  async function updateReview(workspace, runners = {}) {
10975
11210
  const reviewedSha = workspace.session.metadata?.commitSha || null;
11211
+ if (workspace.changeSet.targetType === "commit") return refuse(reviewedSha, false, COMMIT_REVIEW_CANNOT_UPDATE);
10976
11212
  const held = await claimCheckoutJob(workspace, reviewedSha ?? "", {
10977
11213
  purpose: "update",
10978
11214
  ...workspace.prContext ? { conversation: {
@@ -11578,4 +11814,4 @@ function lineageIds(id) {
11578
11814
  return [.../* @__PURE__ */ new Set([id, ...lineage.map((review) => review.id)])];
11579
11815
  }
11580
11816
  //#endregion
11581
- export { fillFileUrlTemplate as $, writeAgentPreference as $t, removeWorktree as A, workspaceRevision as At, workspaceUrl as B, isQuestionOutstanding as Bt, recordTelemetryUploadConsent as C, previousKissResult as Ct, ReviewBeingDeletedError as D, saveWorkspace as Dt, EXPLAIN_SIMPLY_PROMPT as E, reviewedRepositories as Et, clearServerRecord as F, queueStamp as Ft, DEFAULT_WORKSPACE_PORT as G, describeAuthorship as Gt, BUILD_VERSION as H, questionsOutstanding as Ht, ensureServer as I, readQueue as It, parseWorkspacePort as J, git as Jt, MAX_WORKSPACE_PORT as K, resolveTarget as Kt, readServerRecord as L, writeQueue as Lt, relativeTime as M, dequeueOnComplete as Mt, readIndexToken as N, queueAdd as Nt, MANAGED_ROOT as O, summarise as Ot, bootstrapUrl as P, queueRemove as Pt, forgeResolver as Q, readAgentPreference as Qt, reviewsUrl as R, doneVerb as Rt, record as S, positionInLineage as St, withUsageRecorded as T, reviewedCommit as Tt, PACKAGE_NAME as U, reviewerDispositions as Ut, writeServerRecord as V, issuesOutstanding as Vt, feedbackUrl as W, processDiscussion as Wt, statedWorkspacePort as X, resolveModel as Xt, resolveWorkspacePort as Y, resolveAgent as Yt, writePortPreference as Z, clearAgentPreference as Zt, startKissJob as _, latestKissRun as _t, wasBlocked as a, agentFor as an, isTerminal as at, startJob as b, loadWorkspace as bt, updateReview as c, AgentUnavailableError as cn, PHASES as ct, askCheckout as d, purposeOf as dt, CONFIG_PATH as en, checkCodeFreshness as et, UpdateAlreadyRunningError as f, clearClaim as ft, recordKissRun as g, indexReviewGroups as gt, agentEnvOf as h, groupByLineage as ht, liveJobsFor as i, agentById as in, fail as it, modelHelpLines as j, workspaceStamps as jt, readMarker as k, touchWorkspace as kt, refreshPrContext as l, phasesFor as lt, runningJobs as m, followedRefName as mt, deleteReview as n, AGENT_IDS as nn, allJobs as nt, discardJob as o, detectAgents as on, loadJob as ot, liveUpdateFor as p, isClaimed as pt, MIN_WORKSPACE_PORT as q, displayRef as qt, lineageIds as r, DEFAULT_AGENT_ID as rn, endedAt as rt, RefreshUnavailableError as s, AgentCancelledError as sn, saveJob as st, deleteLineage as t, STORE_ROOT as tn, checkFreshness as tt, isUnchanged as u, progressSteps as ut, reanalyseReview as v, lineageKeyFor as vt, telemetryUploadConsent as w, recentWorkspaces as wt, readEvents as x, matchingWorkspaceIds as xt, runJob as y, lineagePosition as yt, stopServer as z, isIssueOutstanding as zt };
11817
+ export { fillFileUrlTemplate as $, writeAgentPreference as $t, removeWorktree as A, workspaceRevision as At, workspaceUrl as B, isQuestionOutstanding as Bt, recordTelemetryUploadConsent as C, previousKissResult as Ct, ReviewBeingDeletedError as D, saveWorkspace as Dt, EXPLAIN_SIMPLY_PROMPT as E, reviewedRepositories as Et, clearServerRecord as F, queueStamp as Ft, DEFAULT_WORKSPACE_PORT as G, describeAuthorship as Gt, BUILD_VERSION as H, questionsOutstanding as Ht, ensureServer as I, readQueue as It, parseWorkspacePort as J, git as Jt, MAX_WORKSPACE_PORT as K, resolveTarget as Kt, readServerRecord as L, writeQueue as Lt, relativeTime as M, dequeueOnComplete as Mt, readIndexToken as N, queueAdd as Nt, MANAGED_ROOT as O, summarise as Ot, bootstrapUrl as P, queueRemove as Pt, forgeResolver as Q, readAgentPreference as Qt, reviewsUrl as R, doneVerb as Rt, record as S, positionInLineage as St, withUsageRecorded as T, reviewedCommit as Tt, PACKAGE_NAME as U, reviewerDispositions as Ut, writeServerRecord as V, issuesOutstanding as Vt, feedbackUrl as W, processDiscussion as Wt, statedWorkspacePort as X, resolveModel as Xt, resolveWorkspacePort as Y, resolveAgent as Yt, writePortPreference as Z, clearAgentPreference as Zt, startKissJob as _, latestKissRun as _t, wasBlocked as a, agentFor as an, isTerminal as at, startJob as b, loadWorkspace as bt, updateReview as c, AgentUnavailableError as cn, PHASES as ct, askCheckout as d, purposeOf as dt, CONFIG_PATH as en, checkCodeFreshness as et, UpdateAlreadyRunningError as f, clearClaim as ft, recordKissRun as g, indexReviewGroups as gt, agentEnvOf as h, groupByLineage as ht, liveJobsFor as i, agentById as in, fail as it, modelHelpLines as j, workspaceStamps as jt, readMarker as k, touchWorkspace as kt, refreshPrContext as l, phasesFor as lt, runningJobs as m, followedRefName as mt, deleteReview as n, AGENT_IDS as nn, allJobs as nt, discardJob as o, detectAgents as on, loadJob as ot, liveUpdateFor as p, isClaimed as pt, MIN_WORKSPACE_PORT as q, displayRef as qt, lineageIds as r, DEFAULT_AGENT_ID as rn, endedAt as rt, RefreshUnavailableError as s, AgentCancelledError as sn, saveJob as st, deleteLineage as t, STORE_ROOT as tn, checkReview as tt, isUnchanged as u, progressSteps as ut, reanalyseReview as v, lineageKeyFor as vt, telemetryUploadConsent as w, recentWorkspaces as wt, readEvents as x, matchingWorkspaceIds as xt, runJob as y, lineagePosition as yt, stopServer as z, isIssueOutstanding as zt };