mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
e8df06db7c
* docs(adr): spec for the commit-list review view (linear history rail)
* feat(review): commit:<sha> diff mode along the shared diff-type seams
A new git-only diff family for reviewing one historical commit against
its first parent (git-show style), threaded through every seam a diff
type crosses:
- runGitDiff: git diff <sha>^ <sha>; a root commit diffs against the
empty tree. Label is 'Commit <shortsha> — <subject>'. The sha is
validated as bare hex (parseCommitDiffType) before it reaches any
argv position, on top of --end-of-options.
- getGitDiffFingerprint: anchored to the sha alone (present/gone), NOT
headSha — new commits landing mid-review don't change this diff and
must not raise the staleness banner. A vanished commit (rebase + gc)
flips present→gone and fires it honestly.
- getFileContentsForDiff: old side <sha>^ (null on root), new side <sha>.
- parseWorktreeDiffType learns worktree:<path>:commit:<sha> (the
sub-type contains a colon, so it needs its own split) — commit review
composes with worktree sessions like every other mode.
- vcs-core: the git provider owns commit:*; staging stays gated off
(canStageFiles allowlist unchanged — nothing in a historical commit
is stageable).
- Ask AI context: a commit-mode inspect string ('git show <sha>', with
an explicit 'historical commit, not the working tree' note).
Also adds listCommitHistory for the upcoming /api/commits endpoint:
one --first-parent page from HEAD (before=<sha> continues at its first
parent, +1 fetch for an honest hasMore), with isHead / isRepoUser /
isPastBase flags. isPastBase is reachability from the base — computed
as the complement of 'rev-list --first-parent HEAD ^base', which is a
prefix of the walk, so the client's single divider is exhaustive. An
unresolvable base degrades to no divider (matches since-base's posture
on such repos).
* feat(review): GET /api/commits — linear history endpoint (Bun + Pi)
One page of the branch's --first-parent history (?limit=&before=),
served by listCommitHistory against the active diff's cwd (worktree-
aware via resolveVcsCwd) and the active base (so the divider tracks
the same baseline the review compares against, including the startup
origin/<default> upgrade).
Gated to plain local git sessions — PR / workspace / jj / p4 return
400, mirroring the client's commitsCapable gate. Both runtimes; the
route-parity test covers the pair.
commit:<sha> switching needs no endpoint changes: /api/diff/switch
already dispatches through runVcsDiff (the git provider owns the new
family), the epoch guard applies as-is, the sections sidecar correctly
stays since-base-only, staging 400s via the canStageFiles allowlist,
and baseRelevantDiffType keeps the behind-GitHub banner suppressed for
commit diffs.
* feat(ui): Commits panel view — the linear history rail
Third left-panel view: 'Git status | Commits | Tree'. The Commits view
is a pure commit list (short sha, subject, relative age compacted to
'2h', author only when it isn't the repo user, HEAD dot, a divider
where the branch meets the resolved base, 'Show more' paging). It
never becomes a file list: clicking a commit switches the diff to
commit:<sha> and the existing needsInitialDiffPanel flow lands the
center dock on the all-files surface; re-clicking the active commit
just re-focuses that panel.
Persistence: reviewPanelView gains 'commits' with NO diff coupling —
the view persists, the diff opens on the user's normal default until a
commit is clicked, and a sha is never persisted (it may not survive a
rebase). The sections⟺since-base coupling is untouched, but the
classic-diff→Tree snap in Settings/ReviewSetupDialog now only fires
when the current view is 'sections', so picking a default diff no
longer stomps a Commits preference. The load-time self-heal ignores
'commits' by construction (it keys on panelView === 'sections').
Gates: commitsCapable = plain local git session (no PR / workspace /
jj / p4, matching the server's /api/commits gate); the segment is
hidden elsewhere. Staging is inert in commit diffs on both ends
(STAGEABLE_DIFF_TYPES and the server allowlist never match commit:*).
Worktree sessions compose — the client parses
worktree:<path>:commit:<sha> and handleDiffSwitch prefixes as usual;
the rail refetches on worktree/base changes but deliberately not on
commit clicks, so paging state survives selection.
Accepted edges (v1): no j/k selection in the rail (each move would run
a full diff switch); toggling Commits→Tree keeps a commit diff active,
where the tree's diff-type dropdown marks nothing — the gitRef header
still names the commit.
* fix(review): self-review cleanups on the commit-list feature
- listCommitHistory: a repo with no commits yet (unborn HEAD) returns
an empty page instead of null — the panel showed a 500-backed 'Retry'
error where 'No commits' is the truth. Every other review surface
already degrades gracefully on a commit-less repo; now this one does.
Only a genuinely unanswerable repo (not a repo at all) stays null.
- Hoist the bare-hex sha rule into one BARE_HEX_SHA_RE shared by
parseCommitDiffType and the before-cursor validation (was written
twice).
- The 'all' diff case now uses the getEmptyTreeSha helper the commit
mode extracted, instead of keeping its own inline copy.
- Drop CommitsPanel's isLoadingDiff prop — declared and passed but
never used.
* feat(ui): commit cards, labeled groups, avatars — review round 1
Four items from the live review round:
- Toggle: Commits moves to the far right (Git status | Tree | Commits)
and the segment text goes 10px → 12px (padding trimmed to keep three
segments inside the 256px header).
- The bare '── origin/main ──' divider confused more than it explained.
Replaced with two labeled groups: 'On this branch' (commits not yet
reachable from the base) and 'In <base>' (shared history), each with
a tooltip spelling out what the boundary means.
- Rows become overview cards: subject (2-line clamp) on top; a meta row
with author avatar, author name (only when it isn't the repo user),
a HEAD badge, short sha, and compact age. Active card gets the
primary border/tint.
- Author avatars, reusing the PR machinery rather than reinventing it:
parseRemoteUrl/parseRemoteHost classify the origin forge; the gh/glab
invocation shape (incl. the --hostname self-hosted convention) and
GitLab's relative-avatar absolutization rule are mirrored from
pr-github/pr-gitlab. What could NOT be reused directly: PR avatars
are keyed by platform login, which local commits don't have — so
GitHub resolves via one repos/{o}/{r}/commits?per_page=100 call per
session building an author-EMAIL → avatar map (unpushed commits by
the same author resolve through it; verified live), and GitLab uses
its per-email /avatar endpoint (capped, deduped). Everything is
memoized per session and fails closed to the initials fallback — a
repo with no remote, an opaque self-hosted host, or an unauthenticated
CLI never delays or errors the commits endpoint.
The Avatar component moves out of PRCommentsTab into its own module,
now shared by the PR timeline and the commit cards. New shared module
commit-avatars.ts is vendored to Pi (vendor.sh) and the enrichment runs
in both runtimes. CommitListEntry gains authorEmail (resolver key) and
avatarUrl (server-enriched).
* ui(review): flush commit rows, prominent base boundary — review round 2
- Cards are gone: rows stack flush (no borders, no margins), hover tint
only. Subject is strictly one line, ellipsized (no clamp/wrap).
- Author name always shown (was: only when it differed from the repo
user), with the avatar, on the compact meta line under the subject.
- The base boundary is a prominent labeled rule ('— In origin/main —',
foreground-weight lines) instead of the faint border under a group
header; the 'On this branch' header above stays quiet.
* docs(adr): evaluate local stack-parent detection — scenario matrix, no build decision
Empirical stress-test of the merge-base-reachability algorithm across 9
scenarios plus this repo itself. Findings: the stacked-ness test is graph
truth (never lied); parent attribution has one content-harmless failure
(same-merge-base sibling labels) and one dangerous, provably graph-
undetectable one (branches pointing inside B's own history win and
silently hide the user's commits). Determination: only viable as a
suggest-and-confirm affordance with per-branch persisted choice — never
silent auto-defaulting. Decision to build deliberately left open.
* fix(ui): Commits view is session-only — never the opening view
Live-review catch: the panel toggle inherited the sections/tree
'last choice becomes the default' cookie write, so clicking Commits in
one session made the NEXT review open on the history rail instead of
the diff. Ruling: a review always opens on the persisted sections/tree
default; Commits is entered via the toggle each session.
- reviewPanelView narrows back to 'sections' | 'tree'; a stale
'commits' cookie is treated as unset (self-heals to the default).
- The live view is now a session overlay in App state: selecting
Commits flips it without touching config; selecting Git status/Tree
clears it and persists as before. Single choke point in
selectPanelView, so every toggle site inherits the rule.
- Settings loses the Commits segment (it configures the OPENING view,
which Commits can no longer be). Spec updated with the revision.
* ui(review): the panel toggle is session-only — it never writes the default
Second live-review ruling on persistence: the header toggle shouldn't
persist ANY choice, not just Commits. Looking at another view mid-review
must not silently change what the next review opens on.
- selectPanelView becomes a pure session override (local state layered
over the persisted value); no configStore writes from the toggle path.
- handleSwitchToSections drops its defaultDiffType write — that write
only existed to keep the persisted view/diff pair consistent, and the
toggle no longer persists a view. Settings and the setup dialog remain
the only reviewPanelView/defaultDiffType writers and keep enforcing
the sections ⟺ since-base coupling; the load-time self-heal still
repairs conflicted pairs those writers may leave.
- Spec + settings-registry comments updated to name the new rule.
* feat(ui): commit description card + collapsed all-files for commit diffs
Clicking a commit now answers 'what and why' before 'which lines':
- New commitInfo sidecar (Bun + Pi, same mode-conditional shape as
sections): when a commit:<sha> diff is active, /api/diff and
/api/diff/switch carry the commit's full metadata — subject, multiline
body, author (+ avatar via the session resolver), sha, age — from a
single 'git show -s' (getCommitDiffInfo in review-core, body as the
trailing format field so newlines survive).
- CommitDescriptionHeader heads the all-files surface: subject, author
row, and the full body rendered as markdown via the same MarkdownBody
the PR viewer uses. Long bodies scroll inside the card (max-h) so the
diff keeps the viewport.
- Commit diffs open FOLDED: AllFilesCodeView gains defaultCollapsed —
items seed collapsed at build time, the seed is part of fileSetKey
(CodeView seeds once per instance, so a seed change must remount),
and the collapse-all toggle state initializes to match. Every other
diff mode keeps opening expanded; leaving the commit diff clears the
card and the folded default together (the sidecar is absent).
Verified live through the compiled binary: switching to a real commit
returns subject/body/avatar, switching back to since-base clears it.
* ui(review): commit description scrolls with the diff — no pinned card
Round feedback: the fixed header with its own inner scrollbar felt
embedded; the description should read as the top of the document and
scroll away with the files.
- AllFilesCodeView gains leadingContent: rendered via a portal INTO
CodeView's scroll container (absolutely positioned at content top,
so it participates in the scrollable overflow), with its measured
height fed into layout.paddingTop so the virtualized items start
below it. CodeView stays the single scroll authority — no nested
scrollers, no wrapper flex row.
- CommitDescriptionHeader shows the full body inline (no inner
OverlayScrollArea). Only genuinely huge bodies (>24 lines / >1800
chars) get a CSS max-height clamp with a fade mask and a Show
more/Show less toggle — CSS clamping keeps the markdown intact, and
the ResizeObserver feeds the expanded height back into paddingTop.
Keyed by sha so the toggle resets per commit.
* ui(review): entering the Commits view auto-opens the HEAD commit
Toggling to Commits used to leave the previous mode's all-files diff on
screen — a rail full of commits next to content that belonged to none
of them. Now entering the view selects the HEAD commit (top of the
rail) so the center immediately shows its diff + description card.
Once per entry, ref-guarded: an already-active commit diff survives a
toggle round-trip, a user click supersedes it, and a failed switch
doesn't retry-loop. When entry races the first log fetch, the effect
fires as soon as the log lands.
* perf/ui(review): fast commit navigation — instant veil + skip context recompute
Rail clicks felt laggy and jumpy: the old diff sat on screen for the
whole switch round-trip and then snapped, and the switch itself was
doing a full getVcsContext recompute (branch + worktree + recent-commit
enumeration — three git walks) that a commit click can never invalidate.
- Same-cwd commit:<sha> switches skip the context recompute in both
runtimes (the client keeps its existing context when the field is
absent — long-standing contract). Measured through the compiled
binary: commit switches now ~40-60ms server-side.
- The center dock shows an immediate 'Loading commit…' veil whenever a
commit switch is in flight (or the Commits view was just entered and
HEAD auto-select hasn't landed) — the stale previous diff never shows
and the click reads as instant. Errors surface through the normal
empty-state, never trapped under the veil.
* fix(ui): PR-994 review round — toggle/settings separation + commit-nav fixes (App)
The headline fix: the load-time settings repair guarded on the live
panelView, which now carries the session toggle override — so clicking
'Git status' once mid-review (saved prefs tree + a classic diff) hit
the healer and silently persisted defaultDiffType='since-base'. The
toggle must never be a settings writer, directly or through a repair
path. Keyed to persistedPanelView now; audited the remaining
configStore.set call sites — no toggle path can reach a write.
Also in this file, same review round:
- Commit-navigation veil gains a real predicate: it drops on a commit-log
fetch error (rail shows Retry; center was stuck under the spinner
forever) AND on a genuinely empty history (zero commits — the second
stuck-veil trigger the automated review missed), while still covering
the log-loading and pre-auto-select frames.
- handleSelectCommit composes the worktree prefix once and calls
fetchDiffSwitch directly (the equality check and the switch previously
used two different composition paths).
- commitsCapable/showCommitsPanel hoisted above the global keyboard
handler; Cmd+F is no longer intercepted in the Commits view (no search
input exists there — the browser find takes over instead of a silent
no-op).
* fix(review): PR-994 review round — commit-log staleness, loading flags, avatar cap
- useCommitLog clears its cached list when the history context changes
(worktree/base switched while the view was away): the HEAD auto-select
acted on the stale rows and opened the previous context's commit. Same
contextKey still keeps the cache (no empty flash on re-entry).
- The hook's cleanup now resets both loading flags: a generation-skipped
finally never cleared them, leaving 'Show more' stuck disabled as
'Loading…' after leaving mid-page.
- commit-avatars memoizes misses only for emails an attempt actually
covered — the GitLab per-call cap (10) was recording every email past
it as permanently unresolvable for the session. Regression test added.
- GitLab host detection tightened from a bare substring to a structured
match (gitlab.com / gitlab.* / *.gitlab.*) — 'mygitlabproxy.example.com'
no longer classifies.
- Collapse-all mirror re-derives from live item state after per-file
toggles: with commit diffs seeding all-collapsed, expanding one file
left the dock button on 'Expand all', and clicking it re-collapsed the
file the user just opened.
- The commit description card element is memoized on commitInfo identity
so the measuring ResizeObserver stops churning on every context render.
* fix(ui): entering the Commits view ends any open search session (self-review)
The Cmd+F guard from the review round only blocked NEW searches in the
Commits view; a search opened in Git status/Tree survived the toggle as
hidden-but-live state — the query kept matching against the commit
diff, marks kept rendering in the all-files body, Enter/F3 kept
stepping matches, and the input to see or edit the query didn't exist
anywhere on screen. handlePanelViewSelect (the single choke point every
toggle site routes through) now clears and closes search on entry to
Commits.
Also re-traced the rest of the round under self-review — veil terminal
states (log error/empty/loaded, switch failure, background refresh with
a commit active), the hook's cleanup-before-rerun flag ordering, the
avatar attempted-set on broken platforms, and both collapse-mirror
paths — no further findings.
* fix(ui): PR-994 round 2 — live rail freshness, non-destructive errors
- The rail now keeps itself fresh: while the Commits view is visible, a
quiet 10s poll head-compares page 1 and adopts it only when history
actually moved. The commit DIFF's sha-anchored fingerprint stays as
designed (a historical commit never goes stale, so the banner
correctly stays quiet) — but the rail no longer freezes while an
agent commits in the background. Adoption bumps the fetch generation
so an in-flight 'Show more' from the old history can't append stale
rows, and transient poll failures disturb nothing.
- A page-1 fetch resets isLoadingMore: a refresh superseding an
in-flight paging request skipped that request's generation-guarded
finally, leaving 'Show more' stuck on 'Loading…' — rare before, a
live race once the poll exists.
- Errors are non-destructive when a list is on screen: a failed page or
background refresh renders as an inline row with Retry under the
list instead of replacing the whole rail; the full-panel error state
is reserved for an empty rail.
* perf/refactor(review): parallel GitLab avatar lookups; drop dead isRepoUser
- The ≤10 per-call GitLab avatar lookups run in parallel instead of
sequentially — they sit on /api/commits' critical path, and serial
subprocess spawns added seconds to the rail's first paint on
multi-author GitLab histories (PR-994 round 2 nit).
- CommitListEntry.isRepoUser removed: v1 showed the author only when it
wasn't the repo user; the live-review pivot to always-shown names left
the field computed (one git config subprocess per page), serialized,
and tested but never read, with a doc comment describing behavior the
panel no longer has. Spec updated to match the revised row design.
* fix(ui): commit-poll adoption clears all superseded fetch state (self-review)
Adopting a new history from the background poll bumps the generation,
which strands any in-flight fetch's generation-guarded finally — the
same stale-flag class just fixed for isLoadingMore, but for isLoading
(slow first load overtaken by the poll) — and a lingering inline error
from the replaced history would otherwise sit under the fresh list.
Adoption now clears isLoading, isLoadingMore, and error together.
* fix(ui)/docs: PR-994 round 3 — worktree switches drop commit diffs; API docs
- handleWorktreeSwitch treats commit:<sha> as non-portable: every other
diff mode recomputes meaningfully against the target worktree, but a
commit diff is context-bound content (worktrees share one object
database — the reviewer's claimed git error doesn't exist, verified
empirically), so 'preserving' it just re-rendered the OLD context's
commit byte-for-byte. It now falls back to the session default (same
option-availability rule resolveInitialDiffType applies). Swept every
other activeDiffBase/diffType carrier: the whitespace toggle,
staleness refresh, and fetch-base deliberately recompute the SAME
diff (valid for commits); handleBaseSelect and the base-picker row
already exclude commit mode; job diff-context labeling and the
guarded diff-type dropdown are display-only. This was the single leak.
- AGENTS.md (CLAUDE.md symlink): /api/commits row added to the Review
Server table; /api/diff and /api/diff/switch now document the
commitInfo sidecar and the commit:<sha> diffType family; the
since-main section describes the three-segment session-only toggle
and the never-persisted Commits view.
* docs: normalize arrow glyphs in the /api/commits table row (self-review)
* fix/refactor(review): PR-994 round 4 — rebase-safe paging, locale-proof ages, shared parsing
- Pagination is rebase-safe: a 'Show more' cursor from a history that was
rewritten mid-session (rebase/force-push) still resolves in the object
store but is no longer on the branch — paging from it walked the
orphaned pre-rewrite chain for the ≤10s window before the freshness
poll adopts the new history. listCommitHistory now ancestor-checks the
cursor (merge-base --is-ancestor) and returns an empty terminal page
for orphaned AND vanished cursors alike; the poll replaces the list
moments later. Regression test rewrites history between pages.
- Ages are locale-proof: git localizes %cr via gettext ('vor 2 Stunden'),
which the English-only compactAge regex silently couldn't shorten.
CommitListEntry/CommitDiffInfo now carry committedAt (epoch ms, %ct);
the rail and description card format it with the existing
formatRelativeTime — compactAge and the third formatter variant are
gone.
- The %x1f over-split repair (fixed head/tail fields, rejoined free-text
middle) was triplicated across listRecentCommits, listCommitHistory,
and getCommitDiffInfo; one splitCommitFormatFields helper (head/tail
counts, tail=0 covers the multiline-body shape) owns the edge case.
- Avatar resets its broken-image state when src changes — latent today
(every caller keys by identity), but it's a shared component and must
be safe for callers that update src in place.
Left alone from this round: hashString/hashFingerprintPart duplication is
pre-existing, and the suggested cross-package import would drag node:path
into the browser bundle — the documented reason client mirrors exist.
* fix(review): PR-994 round 5 — merge-commit prompts, boundary-aware poll, auto-select guard
- Agent prompts for commit:<sha> diffs instruct the exact on-screen
command: git diff <sha>^ <sha> (first parent), explicitly steering
away from git show — whose combined-diff presentation for a MERGE
commit renders a different (often empty) changeset than the one under
review. Root-commit fallback noted. One site covers Ask AI and every
launched reviewer (and Pi via vendoring); prompt test pins the command.
- The rail's freshness poll now adopts on boundary/base movement, not
just a new head: an agent running git fetch advances origin/<base>
while HEAD stays put, which re-partitions isPastBase — previously the
head-only compare skipped adoption and the 'In origin/main' divider
stayed stale until the view was reopened. Boundary is compared over
the page-1 overlap window (a divider paged deeper than the probe can
see re-syncs on the next full reload — accepted micro-edge). The
reviewer's proposed trigger (the Fetch banner) is unreachable in
commit mode; the external-fetch trigger is the real one.
- The HEAD auto-select never fires while any diff switch is in flight —
hardening only: the claimed first-click overwrite isn't reachable
(isLoadingDiff was never a dependency of that effect, and auto-select
settles before a human can click), but the guard makes the invariant
explicit.
* docs(adr): spec the pre-merge commits-view structure refactor
Three behavior-preserving moves scoped for PR #994 before merge, motivated
by the audit finding that every recent review-round bug lived at the
App.tsx <-> useCommitLog seam: (R1) unify the commits-session state
machine (poll, auto-select, veil) in one hook; (R2) lift the commit-rail
block out of the 1.6k-line review-core into commit-history.ts; (R3) share
the isSameCwdCommitSwitch predicate both runtimes inlined. Ownership
tables name what deliberately stays put; execution runs smallest-first
with full gates per commit.
* refactor(review): share isSameCwdCommitSwitch across runtimes (spec R3)
The ~10-line parse-and-compare predicate both /api/diff/switch handlers
inlined moves to review-core beside its inputs (parseWorktreeDiffType /
parseCommitDiffType — the canonical home for diff-type string logic);
both call sites collapse to one line. Unit tests cover plain, worktree,
cross-worktree, and non-commit-target cases. Behavior identical.
* refactor(shared): lift the commit-rail block into commit-history.ts (spec R2)
review-core.ts (1.6k lines) was absorbing the ~230-line Commits-panel
data layer it doesn't need to own: CommitListEntry/CommitHistoryPage/
listCommitHistory and CommitDiffInfo/getCommitDiffInfo move verbatim to
a new commit-history.ts, matching the package's per-concept split
(jj-core, pr-stack, commit-avatars). The commit:<sha> DIFF plumbing
(parseCommitDiffType, the runGitDiff/fingerprint/file-contents cases)
stays in review-core with the other diff types — it participates in the
dispatch; the rail does not.
Wiring: review-core exports its three parsing primitives (COMMIT_FIELD_
SEP, splitCommitFormatFields, BARE_HEX_SHA_RE — listRecentCommits still
uses them, so they can't move); package exports + vendor.sh gain the
module (vendored siblings import relatively, same as pr-provider →
pr-github); types.ts re-exports split by source; both runtimes import
from the new module. History/metadata test suites move to
commit-history.test.ts with the package-style per-file git harness;
commit-DIFF tests stay in review-core.test.ts. Behavior identical —
verified live through the compiled binary (/api/commits + commit
switch + commitInfo sidecar).
* refactor(ui): useCommitsView owns the whole commits-session machine (spec R1)
The list cache, freshness poll, HEAD auto-select, and center-dock veil
were split between useCommitLog and App.tsx — and every sync bug found
across three review rounds (stuck flags, stale auto-select, adoption
races) lived at exactly that seam. The auto-select effect and the veil
derivation move into the hook verbatim (renamed useCommitsView, git mv),
so the machine's invariants are locally checkable in one file.
App now supplies only what it owns — visibility, the active commit,
switch state, and onOpenCommit (its handleSelectCommit: the SAME path
user clicks take, so auto- and user-selection cannot diverge by
construction) — and consumes panel props plus veilActive. Net ~45 lines
of App.tsx's most delicate logic deleted.
One equivalence note: the hook's single "enabled" (showCommitsPanel &&
origin) now gates auto-select/veil where App used bare showCommitsPanel;
the two only differ when origin is unset, which implies no gitContext
(demo mode) and therefore showCommitsPanel === false — identical in all
reachable states.
Deliberately still in App (each guards a flow App owns): capability
gating, handleSelectCommit, the Cmd+F / worktree-fallback /
staleness-refresh / search-clear guards, and render wiring. Behavior
identical; spec: adr/specs/refactor-commits-view-structure-20260703.md.
220 lines
8.2 KiB
TypeScript
220 lines
8.2 KiB
TypeScript
/**
|
|
* Commit-history rail — backs GET /api/commits and the commitInfo sidecar.
|
|
*
|
|
* Runtime-agnostic like review-core (Pi consumes a build-time copy via
|
|
* vendor.sh). Deliberately separate from review-core: nothing here
|
|
* participates in the diff-type dispatch — it is the Commits panel's data
|
|
* layer (linear --first-parent pages + one commit's full metadata). The
|
|
* commit:<sha> DIFF plumbing (parseCommitDiffType, the runGitDiff /
|
|
* fingerprint / file-contents cases) stays in review-core with the other
|
|
* diff types.
|
|
*/
|
|
|
|
import {
|
|
BARE_HEX_SHA_RE,
|
|
COMMIT_FIELD_SEP,
|
|
splitCommitFormatFields,
|
|
type ReviewGitRuntime,
|
|
} from "./review-core";
|
|
|
|
// --- Commit history rail ------------------------------------------------------
|
|
//
|
|
// Backs GET /api/commits: the Commits panel's linear `--first-parent` walk from
|
|
// HEAD, newest first. Paged (before = the previous page's last sha), with a
|
|
// per-commit "past the base" flag so the client can draw the divider where the
|
|
// branch meets the resolved base.
|
|
|
|
export interface CommitListEntry {
|
|
/** Full SHA — sent back as `commit:<sha>` on click. */
|
|
sha: string;
|
|
shortSha: string;
|
|
subject: string;
|
|
author: string;
|
|
/** Author email — the key the avatar resolver matches on. */
|
|
authorEmail: string;
|
|
/** Committer time, epoch milliseconds. Clients format it themselves —
|
|
* git's `%cr` relative strings are locale-dependent (gettext), so a
|
|
* pre-formatted string couldn't be compacted reliably. */
|
|
committedAt: number;
|
|
isHead: boolean;
|
|
/** True once the walk is at/below the base (reachable from it) — everything
|
|
* above the first past-base commit is branch-local work. */
|
|
isPastBase: boolean;
|
|
/** Author profile image, when the forge could resolve one (server-enriched
|
|
* via commit-avatars; absent → the client renders an initials fallback). */
|
|
avatarUrl?: string;
|
|
}
|
|
|
|
export interface CommitHistoryPage {
|
|
commits: CommitListEntry[];
|
|
/** More history exists below this page. */
|
|
hasMore: boolean;
|
|
/** The base ref the divider represents (echoed for the divider label). */
|
|
base: string;
|
|
}
|
|
|
|
/** Full metadata for ONE commit — the description card above the all-files
|
|
* view when a `commit:<sha>` diff is active. */
|
|
export interface CommitDiffInfo {
|
|
sha: string;
|
|
shortSha: string;
|
|
subject: string;
|
|
/** Full message body (everything after the subject), "" when absent.
|
|
* Rendered as markdown client-side. */
|
|
body: string;
|
|
author: string;
|
|
authorEmail: string;
|
|
/** Committer time, epoch milliseconds. Clients format it themselves —
|
|
* git's `%cr` relative strings are locale-dependent (gettext), so a
|
|
* pre-formatted string couldn't be compacted reliably. */
|
|
committedAt: number;
|
|
/** Author profile image (server-enriched via commit-avatars). */
|
|
avatarUrl?: string;
|
|
}
|
|
|
|
/**
|
|
* Fetch one commit's metadata for the description card. Best-effort: null
|
|
* when the sha is invalid or doesn't resolve (callers omit the sidecar).
|
|
*/
|
|
export async function getCommitDiffInfo(
|
|
runtime: ReviewGitRuntime,
|
|
sha: string,
|
|
cwd?: string,
|
|
): Promise<CommitDiffInfo | null> {
|
|
if (!BARE_HEX_SHA_RE.test(sha)) return null;
|
|
// Body (%b) is multiline, so it must be the LAST field — the rejoin target
|
|
// of the shared splitter. A literal US byte in the subject would shift the
|
|
// split (same accepted pathological edge as the list parsers).
|
|
const fmt = ["%H", "%h", "%an", "%ae", "%ct", "%s", "%b"].join(COMMIT_FIELD_SEP);
|
|
const result = await runtime.runGit(
|
|
["--no-optional-locks", "show", "-s", `--pretty=format:${fmt}`, "--end-of-options", sha],
|
|
{ cwd },
|
|
);
|
|
if (result.exitCode !== 0) return null;
|
|
const fields = splitCommitFormatFields(result.stdout, 6, 0);
|
|
if (!fields) return null;
|
|
const [fullSha, shortSha, author, authorEmail, ct, subject, body] = fields;
|
|
return {
|
|
sha: fullSha,
|
|
shortSha,
|
|
author,
|
|
authorEmail,
|
|
committedAt: (Number(ct) || 0) * 1000,
|
|
subject,
|
|
body: body.trim(),
|
|
};
|
|
}
|
|
|
|
const COMMIT_HISTORY_LIMIT_DEFAULT = 50;
|
|
const COMMIT_HISTORY_LIMIT_MAX = 200;
|
|
|
|
/**
|
|
* One page of the linear (`--first-parent`) history from HEAD. Returns null
|
|
* when the repo can't answer at all (no HEAD, not a repo); an unresolvable
|
|
* `before` yields an empty terminal page instead (the commit paged past may
|
|
* be a root commit, whose `^` doesn't resolve).
|
|
*/
|
|
export async function listCommitHistory(
|
|
runtime: ReviewGitRuntime,
|
|
defaultBranch: string,
|
|
cwd?: string,
|
|
options?: { limit?: number; before?: string },
|
|
): Promise<CommitHistoryPage | null> {
|
|
const requested = options?.limit ?? COMMIT_HISTORY_LIMIT_DEFAULT;
|
|
const limit = Math.max(1, Math.min(Math.floor(requested), COMMIT_HISTORY_LIMIT_MAX));
|
|
const before = options?.before;
|
|
// `before` flows into a git argv position — same bare-hex rule as commit:<sha>.
|
|
if (before !== undefined && !BARE_HEX_SHA_RE.test(before)) return null;
|
|
const emptyPage: CommitHistoryPage = { commits: [], hasMore: false, base: defaultBranch };
|
|
|
|
// --no-optional-locks throughout: read-only queries that may run while the
|
|
// agent stages/commits concurrently.
|
|
const runReadOnlyGit = (args: string[]) =>
|
|
runtime.runGit(["--no-optional-locks", ...args], { cwd });
|
|
|
|
// A cursor from a rewritten history (rebase/force-push mid-session) still
|
|
// resolves in the object store but is no longer on the branch — paging on
|
|
// from it would walk the orphaned pre-rewrite chain. A non-ancestor (or
|
|
// vanished) cursor ends the pagination with an empty terminal page; the
|
|
// client's freshness poll replaces the list moments later.
|
|
if (before) {
|
|
const onBranch = await runReadOnlyGit([
|
|
"merge-base",
|
|
"--is-ancestor",
|
|
"--end-of-options",
|
|
before,
|
|
"HEAD",
|
|
]);
|
|
if (onBranch.exitCode !== 0) return emptyPage;
|
|
}
|
|
|
|
// Continue the first-parent walk from `before`'s first parent. +1 over the
|
|
// limit so hasMore is observed, not guessed.
|
|
const startRef = before ? `${before}^` : "HEAD";
|
|
const fmt = ["%H", "%h", "%s", "%ct", "%an", "%ae"].join(COMMIT_FIELD_SEP);
|
|
const log = await runReadOnlyGit([
|
|
"log",
|
|
"--first-parent",
|
|
`--max-count=${limit + 1}`,
|
|
`--pretty=format:${fmt}`,
|
|
"--end-of-options",
|
|
startRef,
|
|
]);
|
|
if (log.exitCode !== 0) {
|
|
// Paging past a root commit (`before^` unresolvable) is a normal terminal
|
|
// page. A first page failing because the repo simply has no commits yet
|
|
// (no HEAD) is also an empty page, not an error — every other review
|
|
// surface degrades gracefully on a commit-less repo. Anything else
|
|
// (not a repo at all) stays null → the endpoint reports a real error.
|
|
if (before) return emptyPage;
|
|
const headResolves =
|
|
(await runReadOnlyGit(["rev-parse", "--verify", "--quiet", "HEAD"])).exitCode === 0;
|
|
return headResolves ? null : emptyPage;
|
|
}
|
|
|
|
const parsed: Array<Omit<CommitListEntry, "isHead" | "isPastBase">> = [];
|
|
for (const line of log.stdout.split("\n")) {
|
|
if (!line) continue;
|
|
const fields = splitCommitFormatFields(line, 2, 3);
|
|
if (!fields) continue;
|
|
const [sha, shortSha, subject, ct, author, authorEmail] = fields;
|
|
parsed.push({
|
|
sha,
|
|
shortSha,
|
|
subject,
|
|
committedAt: (Number(ct) || 0) * 1000,
|
|
author,
|
|
authorEmail,
|
|
});
|
|
}
|
|
const hasMore = parsed.length > limit;
|
|
const page = parsed.slice(0, limit);
|
|
|
|
const [head, branchOnly] = await Promise.all([
|
|
runReadOnlyGit(["rev-parse", "HEAD"]),
|
|
// The branch-local set: first-parent commits from HEAD NOT reachable from
|
|
// the base. Reachability (not merge-base position) is what the divider
|
|
// means — a base merged INTO the branch keeps its commits below the line.
|
|
// Best-effort: an unresolvable base yields no divider (all isPastBase
|
|
// false), matching how since-base degrades on such repos.
|
|
defaultBranch
|
|
? runReadOnlyGit(["rev-list", "--first-parent", "--end-of-options", "HEAD", `^${defaultBranch}`])
|
|
: Promise.resolve(null),
|
|
]);
|
|
const headSha = head.exitCode === 0 ? head.stdout.trim() : "";
|
|
const branchLocal = branchOnly && branchOnly.exitCode === 0
|
|
? new Set(branchOnly.stdout.split("\n").filter(Boolean))
|
|
: null;
|
|
|
|
return {
|
|
commits: page.map((c) => ({
|
|
...c,
|
|
isHead: c.sha === headSha,
|
|
isPastBase: branchLocal ? !branchLocal.has(c.sha) : false,
|
|
})),
|
|
hasMore,
|
|
base: defaultBranch,
|
|
};
|
|
}
|
|
|