git-ents.gitmain
⌘K
foforge
commits.rs1062 lines · 40.3 KB · rusthistorycomment on this file
1//! `GET /commits`, `GET /commit/{oid}`: a read-only commit history and
2//! per-commit unified diff over `HEAD` -- a tab of its own (both routes
3//! render with `super::Tab::Commits` active; see [`super`]'s own doc),
4//! also reached from [`super::files`]'s "history" link.
5//!
6//! Reads go through `gix`'s high-level `Repository`/`Commit`/`Tree` types,
7//! opened fresh per request from `state.path`, exactly as
8//! [`super::files`]/[`super::dashboard`] browse `HEAD` -- `facet-git-tree`'s
9//! typed-tree convention is for meta-ref entities, not browsing arbitrary
10//! repository history. The unified diff itself is built directly on top of
11//! `gix::diff::blob` (`gix_diff`'s own re-export through the `gix`
12//! facade): [`gix::diff::blob::InternedInput`] interns each side's lines,
13//! [`gix::diff::blob::diff_with_slider_heuristics`] computes the hunks, and
14//! [`gix::diff::blob::unified_diff::ConsumeBinaryHunk`] renders them as the
15//! same textual unified-diff format `git diff` itself produces, which
16//! `diff_class` then colorizes line by line -- no new dependency, since
17//! `gix`'s default features already enable `blob-diff`.
18//!
19//! `GET /commit/{oid}` also lists a "conversation": every comment whose
20//! anchor was captured against that exact commit
21//! (`crate::pages::comments::for_commit`), rendered below the diff via the
22//! same `crate::pages::comments::comment_card` a blob view uses, each
23//! naming its `path#lines` and linking into `crate::pages::files`'s own
24//! `#L<n>` gutter. A "comment on this commit" link beside the parents list
25//! reaches `crate::pages::comments::list`'s add form with `rev` prefilled
26//! to this commit's own oid.
27
28use std::sync::Arc;
29
30use axum::Form;
31use axum::extract::{Path, Query, State};
32use axum::response::{IntoResponse, Redirect};
33use gix::bstr::ByteSlice as _;
34use gix::diff::blob::unified_diff::{ConsumeBinaryHunk, ContextSize};
35use gix::diff::blob::{Algorithm, InternedInput, UnifiedDiff, diff_with_slider_heuristics};
36use gix::object::tree::diff::Change;
37use gix_hash::ObjectId;
38use gix_object::{Find, Write};
39use maud::{Markup, html};
40use serde::Deserialize;
41
42use crate::error::{Error, Result};
43use crate::session::Session;
44use crate::state::AppState;
45
46/// One page of `GET /commits`.
47const PAGE_SIZE: usize = 50;
48
49/// The largest total diff rendered in full on `GET /commit/{oid}` -- past
50/// it, reading every changed blob into memory and diffing it would be
51/// unbounded, so the page shows a truncation notice instead (mirrors
52/// `pre-redo:crates/git-ents-server/src/web/pages.rs`'s own
53/// `MAX_RENDER_BYTES`, at this page's own, smaller budget).
54const MAX_DIFF_BYTES: usize = 1024 * 1024;
55
56/// The query parameters `GET /commits` accepts.
57#[derive(Debug, Deserialize)]
58pub struct ListQuery {
59 /// Continue the walk just past this previously shown commit (the
60 /// "older" link) -- omitted for the first page.
61 from: Option<String>,
62}
63
64/// One row of `GET /commits` -- also what [`super::dashboard`]'s History
65/// card renders, at its own smaller limit, so the two pages share one
66/// history read.
67pub(crate) struct CommitRow {
68 /// The full commit id, the `/commit/{oid}` link target.
69 pub(crate) oid: ObjectId,
70 /// [`super::short_oid`] of `oid`, the row's displayed, mono id.
71 pub(crate) short: String,
72 /// The commit message's title line.
73 pub(crate) subject: String,
74 /// The commit author's display name.
75 pub(crate) author: String,
76 /// [`super::ago`] of the commit author's time.
77 pub(crate) ago: String,
78}
79
80/// `GET /commits`: the repository's commit history, newest first, 50 per
81/// page.
82///
83/// # Errors
84///
85/// Never fails on an unopenable repository or an unborn `HEAD` -- both
86/// degrade to a blankslate.
87pub async fn list<O>(
88 State(state): State<Arc<AppState<O>>>,
89 Query(params): Query<ListQuery>,
90) -> Result<Markup>
91where
92 O: Find + Write + Send + 'static,
93{
94 let (rows, older) = commit_rows(&state, params.from.as_deref(), PAGE_SIZE);
95 Ok(super::layout(
96 &super::RepoHeader::from_state(&state),
97 &super::identity_label(&state),
98 super::Tab::Commits,
99 "Commits",
100 html! {
101 @if rows.is_empty() {
102 (blankslate())
103 } @else {
104 div.card.history {
105 @for row in &rows {
106 (commit_row(row))
107 }
108 }
109 @if let Some(from) = older {
110 nav.crumbs {
111 a href={ "/commits?from=" (from) } { "older" }
112 }
113 }
114 }
115 },
116 ))
117}
118
119/// Up to `limit` rows starting at `from` (or `HEAD` when `from` is
120/// `None`), newest first, plus the oid to continue from for an "older"
121/// link when more commits remain -- [`list`] passes [`PAGE_SIZE`],
122/// [`super::dashboard`]'s History card its own smaller cap. Best-effort:
123/// an unopenable repository, an unborn `HEAD`, or an
124/// unparsable/unresolvable `from` all degrade to an empty page rather
125/// than an error.
126pub(crate) fn commit_rows<O>(
127 state: &AppState<O>,
128 from: Option<&str>,
129 limit: usize,
130) -> (Vec<CommitRow>, Option<String>) {
131 let Ok(repo) = gix::open(&state.path) else {
132 return (Vec::new(), None);
133 };
134 let continuing = from.and_then(|hex| ObjectId::from_hex(hex.as_bytes()).ok());
135 let tip = match continuing {
136 Some(oid) => oid,
137 None => {
138 let Ok(head) = repo.head_id() else {
139 return (Vec::new(), None);
140 };
141 head.detach()
142 }
143 };
144 let Ok(walk) = repo
145 .rev_walk([tip])
146 .sorting(gix::revision::walk::Sorting::ByCommitTime(
147 gix::traverse::commit::simple::CommitTimeOrder::NewestFirst,
148 ))
149 .all()
150 else {
151 return (Vec::new(), None);
152 };
153
154 let skip = if continuing.is_some() { 1 } else { 0 };
155 let mut rows: Vec<CommitRow> = Vec::new();
156 let mut has_more = false;
157 for info in walk.skip(skip) {
158 let Ok(info) = info else { break };
159 if rows.len() == limit {
160 has_more = true;
161 break;
162 }
163 let Ok(commit) = info.object() else { continue };
164 let Ok(message) = commit.message() else {
165 continue;
166 };
167 let Ok(author) = commit.author() else {
168 continue;
169 };
170 let seconds = author.time().map(|time| time.seconds).unwrap_or(0);
171 let oid = info.id().detach();
172 rows.push(CommitRow {
173 oid,
174 short: super::short_oid(&oid),
175 subject: message.title.to_str_lossy().into_owned(),
176 author: author.name.to_str_lossy().into_owned(),
177 ago: super::ago(seconds),
178 });
179 }
180 let older = has_more
181 .then(|| rows.last().map(|row| row.oid.to_string()))
182 .flatten();
183 (rows, older)
184}
185
186/// One [`CommitRow`] as a `.card-row` (the design's `CommitRow` component,
187/// README's Commits/Commit-detail screens): its short oid as an accent mono
188/// link, a `.scope` chip ([`super::split_scope`]/[`super::scope_class`])
189/// when the subject carries a Scoped-Commits prefix, the (possibly
190/// stripped) description ellipsized in the remaining space, the author,
191/// and its relative age -- the one place a commit row's markup is spelled,
192/// so [`list`]'s pager reads the same row [`super::dashboard`]'s History
193/// card already renders.
194fn commit_row(row: &CommitRow) -> Markup {
195 html! {
196 div.card-row {
197 a href={ "/commit/" (row.oid) } { code { (row.short) } }
198 @match super::split_scope(&row.subject) {
199 Some((scope, rest)) => {
200 span class={ "scope " (super::scope_class(scope)) } { (scope) }
201 span.desk-subject { (rest) }
202 },
203 None => { span.desk-subject { (row.subject) } },
204 }
205 span.row-author { (row.author) }
206 span.row-when { (row.ago) }
207 }
208 }
209}
210
211/// The empty-history placeholder ([`super::blankslate`]): an unborn
212/// `HEAD`, or a repository this page could not open at all.
213fn blankslate() -> Markup {
214 super::blankslate(
215 "No commits yet",
216 html! { "This repository has no history to show." },
217 )
218}
219
220/// `GET /commit/{oid}`: a single commit's full message, metadata, and a
221/// unified diff against its first parent (or the empty tree, for a root
222/// commit).
223///
224/// # Errors
225///
226/// [`Error::NotFound`] if `oid` is not a well-formed object id or does not
227/// name a commit in the served repository.
228pub async fn show<O>(
229 State(state): State<Arc<AppState<O>>>,
230 axum::Extension(session): axum::Extension<Session>,
231 Path(oid): Path<String>,
232) -> Result<Markup>
233where
234 O: Find + Write + Send + 'static,
235{
236 let object_id = parse_oid(&oid)?;
237 let repo = gix::open(&state.path).map_err(|source| Error::Repo(source.to_string()))?;
238 let commit = repo
239 .find_commit(object_id)
240 .ok()
241 .ok_or_else(|| Error::NotFound { what: oid.clone() })?;
242 let message = commit
243 .message()
244 .map_err(|source| Error::Repo(source.to_string()))?;
245 let subject = message.title.to_str_lossy().into_owned();
246 let body = message
247 .body
248 .map(|body| body.to_str_lossy().into_owned())
249 .filter(|body| !body.is_empty());
250 let author = commit
251 .author()
252 .map_err(|source| Error::Repo(source.to_string()))?;
253 let author_name = author.name.to_str_lossy().into_owned();
254 let ago = author.time().map(|time| super::ago(time.seconds)).ok();
255 let parents: Vec<ObjectId> = commit.parent_ids().map(|id| id.detach()).collect();
256 let new_tree = commit
257 .tree()
258 .map_err(|source| Error::Repo(source.to_string()))?;
259
260 let old_tree = match parents.first() {
261 Some(parent) => Some(
262 repo.find_commit(*parent)
263 .map_err(|source| Error::Repo(source.to_string()))?
264 .tree()
265 .map_err(|source| Error::Repo(source.to_string()))?,
266 ),
267 None => None,
268 };
269 let empty_tree = repo.empty_tree();
270 let old_tree_ref = old_tree.as_ref().unwrap_or(&empty_tree);
271 let (diff, truncated) = diff_sections(&state, &repo, old_tree_ref, &new_tree);
272 let comments = super::comments::for_commit(&state, object_id);
273 let checks = checks_section(&state, object_id);
274 let reviews = reviews_section(&state, &session, object_id, &oid);
275 let (sidebar_rows, _older) = commit_rows(&state, None, PAGE_SIZE);
276 let commit_context = format!("commits/{oid}");
277 let commit_thread = ents_forge::comment::thread(
278 state.refs.as_ref(),
279 &*state.objects(),
280 &commit_context,
281 )
282 .unwrap_or_default();
283
284 Ok(super::layout_split(
285 &super::RepoHeader::from_state(&state),
286 &super::identity_label(&state),
287 super::Tab::Commits,
288 &subject,
289 false,
290 commits_sidebar(&sidebar_rows, object_id),
291 html! {
292 (super::child_crumbs("commits", "/commits", &super::short_oid(&object_id)))
293 // The commit card, its reviews, and the conversation are
294 // single-column reading content, capped at `.readable`'s
295 // narrow width; only the diff sections between them keep the
296 // shell's full width (see `ents.css`'s own `.readable` note).
297 div.readable {
298 div.card {
299 div.card-header { "commit " code { (super::short_oid(&object_id)) } }
300 div.commit {
301 div.commit-subject { (subject) }
302 @if let Some(body) = &body {
303 div.commit-msg.doc-body {
304 (crate::asciidoc::to_html(body).unwrap_or_else(|_| html! { p { (body) } }))
305 }
306 }
307 div.commit-meta {
308 (author_name)
309 @if let Some(ago) = &ago { " \u{b7} " (ago) }
310 }
311 div.commit-meta {
312 "tree " a href={ "/files" } { "browse at HEAD" }
313 @if !parents.is_empty() {
314 " \u{b7} parents: "
315 @for (index, parent) in parents.iter().enumerate() {
316 @if index > 0 { ", " }
317 a href={ "/commit/" (parent) } { code { (super::short_oid(parent)) } }
318 }
319 } @else {
320 " \u{b7} root commit"
321 }
322 " \u{b7} "
323 a.composer-trigger data-composer="commit-composer-template"
324 href={ "/comments?rev=" (object_id) } { "comment on this commit" }
325 }
326 (commit_comment_template(&oid, &session))
327 }
328 }
329 (checks)
330 (reviews)
331 }
332 (diff)
333 @if truncated {
334 div.card { div.binary { "Diff truncated (over " (MAX_DIFF_BYTES / (1024 * 1024)) " MiB)." } }
335 }
336 @if !comments.is_empty() || !commit_thread.is_empty() {
337 div.readable {
338 h2 { "Conversation" }
339 @for (index, comment) in comments.iter().enumerate() {
340 (super::comments::comment_card(index, comment, super::comments::LinkMode::CrossFile))
341 }
342 (super::comments::thread_section(&state, &session, &commit_thread, &format!("/commit/{oid}")))
343 }
344 }
345 },
346 ))
347}
348
349/// The Review split's `.tree` sidebar (`crate::pages::layout_split`): the
350/// most recent commits, the viewed one active, each row its short oid and
351/// subject on one ellipsized line, closed by a link into the full pager.
352/// A commit older than the newest [`PAGE_SIZE`] simply highlights nothing
353/// -- the sidebar is a recency lane, not a second pager.
354fn commits_sidebar(rows: &[CommitRow], current: ObjectId) -> Markup {
355 html! {
356 @if rows.is_empty() {
357 span.tree-note { "No history to show." }
358 }
359 @for row in rows {
360 a.active[row.oid == current] href={ "/commit/" (row.oid) } {
361 (row.short) " " (row.subject)
362 }
363 }
364 a href="/commits" { "all commits \u{2192}" }
365 }
366}
367
368/// One row of the commit page's "Checks" card: a recorded result targeting
369/// the shown commit.
370struct CheckRow {
371 /// The recording effect's name ([`ents_model::ResultRecord`]'s own
372 /// `effect` field), the row's `/effects/{name}` link.
373 effect: String,
374 /// The run's outcome, one of the closed taxonomy's three values.
375 status: ents_model::Status,
376 /// The self-run mirror's `<member>` segment when the result lives
377 /// there rather than the canonical namespace (`effect.self-run`).
378 self_run: Option<String>,
379 /// The result ref tip's author time, for [`super::ago`].
380 seconds: Option<i64>,
381}
382
383/// The "Checks" card on `GET /commit/{oid}`: every recorded result
384/// (`model.result-identity`) whose stored `target` field names this
385/// commit -- the canonical `refs/meta/results/<effect>/<short-oid>`
386/// namespace and every member's self-run mirror
387/// (`refs/meta/self/<member>/...`), matched on the tree's own `target`
388/// field (the same binding the gate verifies), never the refname's
389/// short-oid segment. Renders nothing at all when no result targets the
390/// commit: a result is only ever written by a run
391/// (`effect.result-taxonomy`), so "no checks" is the ordinary state of
392/// most commits, not a pending one. Best effort: a result ref whose tree
393/// cannot be read back is skipped from this card (it still lists on
394/// `git ents effect log`).
395// @relation(model.result-identity, model.result-taxonomy, scope=function)
396fn checks_section<O: Find + Write>(state: &AppState<O>, commit_id: ObjectId) -> Markup {
397 let mut rows: Vec<CheckRow> = Vec::new();
398 for prefix in ["refs/meta/results/", "refs/meta/self/"] {
399 let Ok(iter) = state.refs.iter_prefix(prefix) else {
400 continue;
401 };
402 for entry in iter {
403 let Ok((name, tip)) = entry else { continue };
404 // One `state.objects()` lock per read -- the same
405 // non-reentrant-`Mutex` care `crate::pages::effects::read_all`
406 // documents.
407 let record = {
408 let objects = state.objects();
409 super::commit_tree(&*objects, tip).ok().and_then(|tree| {
410 facet_git_tree::deserialize::<ents_model::ResultRecord>(&tree, &*objects).ok()
411 })
412 };
413 let Some(record) = record else { continue };
414 if record.target() != commit_id {
415 continue;
416 }
417 let path = name.as_bstr().to_string();
418 let self_run = path
419 .strip_prefix("refs/meta/self/")
420 .and_then(|rest| rest.split('/').next())
421 .map(str::to_owned);
422 let seconds = super::commit_authorship(&*state.objects(), tip)
423 .ok()
424 .map(|(_author, seconds)| seconds);
425 rows.push(CheckRow {
426 effect: record.effect,
427 status: record.status,
428 self_run,
429 seconds,
430 });
431 }
432 }
433 if rows.is_empty() {
434 return html! {};
435 }
436 rows.sort_by(|a, b| (&a.effect, &a.self_run).cmp(&(&b.effect, &b.self_run)));
437 html! {
438 div.card {
439 div.card-header { "Checks" }
440 @for row in &rows {
441 div.card-row {
442 (super::status_chip(row.status))
443 " "
444 a href={ "/effects/" (row.effect) } { (row.effect) }
445 @if let Some(member) = &row.self_run {
446 span.muted { " \u{b7} self-run by " (member) }
447 }
448 @if let Some(seconds) = row.seconds {
449 span.entry-size { (super::ago(seconds)) }
450 }
451 }
452 }
453 }
454 }
455}
456
457/// Every review targeting `commit_id` (`ents_forge::review::list` filtered
458/// to this commit, `model.review`), each rendering its verdict prominently,
459/// its body as AsciiDoc, and its reviewer (from the review ref's own tip
460/// commit chain, `meta-ref.identity-binding` -- a review stores no author
461/// field, only its composite `(target, member)` key), followed by its
462/// discussion: the comments naming `reviews/<target>/<member>` as their
463/// context (`ents_forge::comment::thread`, `model.comment-context`),
464/// rendered through the same shared `super::comments::thread_section` an
465/// issue's thread uses. A "start a review" form closes the section
466/// (`POST /commit/{oid}/review`). Best effort: a review whose ref cannot be
467/// listed degrades to just the start form rather than failing the page.
468fn reviews_section<O: Find + Write>(
469 state: &AppState<O>,
470 session: &Session,
471 commit_id: ObjectId,
472 oid: &str,
473) -> Markup {
474 let mut reviews = ents_forge::review::list(
475 state.refs.as_ref(),
476 &*state.objects(),
477 &state.path,
478 Some(&commit_id.to_string()),
479 )
480 .unwrap_or_default();
481 // Withdrawn reviews stay in history (`model.review`, append-only) but
482 // drop out of this section, same as `crate::pages::reviews`'s own list.
483 reviews.retain(|(_, review)| review.state != ents_forge::review::ReviewState::Withdrawn);
484 let return_to = format!("/commit/{oid}");
485 html! {
486 h2 { "Reviews" }
487 @if reviews.is_empty() {
488 p.muted { "No reviews of this commit yet \u{2014} record a verdict below." }
489 }
490 @for ((target, member), review) in &reviews {
491 div.card {
492 div.comment-meta {
493 (super::verdict_chip(review.verdict))
494 (super::avatar(member.as_str()))
495 span.author { (member) }
496 @let reviewer = ents_model::namespace::review_ref(target, member)
497 .ok()
498 .and_then(|ref_name| state.refs.get(ref_name.as_ref()).ok().flatten())
499 .and_then(|tip| super::commit_authorship(&*state.objects(), tip).ok());
500 @if let Some((_author, seconds)) = &reviewer {
501 span { (super::ago(*seconds)) }
502 }
503 }
504 div.doc-body {
505 (crate::asciidoc::to_html(&review.body).unwrap_or_else(|_| html! { p { (review.body) } }))
506 }
507 @let thread = ents_forge::comment::thread(
508 state.refs.as_ref(),
509 &*state.objects(),
510 &format!("reviews/{target}/{member}"),
511 ).unwrap_or_default();
512 (super::comments::thread_section(state, session, &thread, &return_to))
513 (review_comment_form(session, target, member, &return_to))
514 }
515 }
516 (start_review_form(session, oid))
517 }
518}
519
520/// The comment-on-this-review form (`POST /reviews/{target}/{member}/comment`):
521/// a contextual comment naming `reviews/<target>/<member>`
522/// (`model.comment-context`), so a review's discussion can start from the
523/// web and not only the CLI or lens. Shared with
524/// [`super::reviews::show`]'s own review detail page -- the same composer,
525/// not a second one, whether the review's thread renders on its home
526/// commit's page or on its own.
527pub(crate) fn review_comment_form(
528 session: &Session,
529 target: &str,
530 member: &ents_model::MemberId,
531 return_to: &str,
532) -> Markup {
533 html! {
534 form method="post" action=(format!("/reviews/{target}/{member}/comment")) {
535 (super::csrf_input(session))
536 input type="hidden" name="return_to" value=(return_to);
537 label { "Comment on this review" textarea name="body" {} }
538 button type="submit" { "Comment" }
539 }
540 }
541}
542
543/// The form fields `POST /reviews/{target}/{member}/comment` accepts.
544#[derive(Debug, Deserialize)]
545pub struct ReviewCommentForm {
546 /// The comment's body text.
547 body: String,
548 /// The per-session CSRF token (`roots.web-session`).
549 csrf: String,
550 /// Where to send the browser back to -- the commit page rendering the
551 /// review; honored only when it is a same-origin path.
552 #[serde(default)]
553 return_to: String,
554}
555
556/// `POST /reviews/{target}/{member}/comment`: a comment naming
557/// `reviews/<target>/<member>` as its context (`model.comment-context`) --
558/// an ordinary [`ents_forge::comment::add`], contextual and unanchored,
559/// joining the review's discussion thread the moment it lands.
560///
561/// # Errors
562///
563/// [`Error::BadCsrf`] if `form.csrf` does not match; otherwise propagates
564/// [`ents_forge::comment::add`]'s own failures.
565// @relation(model.comment-context, roots.web-signing, roots.web-session, scope=function)
566pub async fn review_comment<O>(
567 State(state): State<Arc<AppState<O>>>,
568 axum::Extension(session): axum::Extension<Session>,
569 Path((target, member)): Path<(String, String)>,
570 Form(form): Form<ReviewCommentForm>,
571) -> Result<impl IntoResponse>
572where
573 O: Find + Write + Send + 'static,
574{
575 super::require_csrf(&session, &form.csrf)?;
576 let identity = state.identity.as_ref();
577 let new = ents_forge::comment::NewComment {
578 body: form.body,
579 path: None,
580 lines: None,
581 rev: "HEAD".to_owned(),
582 worktree: false,
583 context: Some(format!("reviews/{target}/{member}")),
584 parent: None,
585 };
586 let (_comment_id, outcome) = ents_forge::comment::add(
587 state.refs.as_ref(),
588 &*state.objects(),
589 state.events.as_ref(),
590 &state.path,
591 new,
592 &crate::receive_identity!(identity, crate::pages::member_author(&session)),
593 state.mode,
594 )?;
595 crate::error::outcome_to_result(outcome)?;
596 let target = if form.return_to.starts_with('/') {
597 form.return_to
598 } else {
599 "/commits".to_owned()
600 };
601 Ok(Redirect::to(&target))
602}
603
604/// The commit-level comment composer's own hidden `<template>`
605/// (`crate::pages::files::composer_template`'s counterpart for a commit,
606/// which has no file of its own to anchor a path-based comment to): posts
607/// to [`comment`], naming `commits/<oid>` as its context
608/// (`model.comment-context`) rather than anchoring to a path, exactly the
609/// way [`review_comment_form`] names `reviews/<target>/<member>`. Cloned
610/// by `assets/ents.js`'s standalone-composer trigger, opened from the
611/// "comment on this commit" link beside the parents list; with JS
612/// disabled that link remains a real navigation to `/comments?rev=`
613/// instead (a page-less no-JS fallback for this specific context would be
614/// its own added surface, so it stays the plain path-anchored form for
615/// now).
616fn commit_comment_template(oid: &str, session: &Session) -> Markup {
617 html! {
618 template id="commit-composer-template" {
619 form.composer-form method="post" action=(format!("/commit/{oid}/comment")) {
620 (super::csrf_input(session))
621 input type="hidden" name="return_to" value=(format!("/commit/{oid}"));
622 textarea name="body" placeholder="Leave a comment on this commit" {}
623 div.composer-buttons {
624 button type="submit" { "Comment" }
625 button.composer-cancel type="button" { "Cancel" }
626 }
627 }
628 }
629 }
630}
631
632/// The form fields `POST /commit/{oid}/comment` accepts.
633#[derive(Debug, Deserialize)]
634pub struct CommitCommentForm {
635 /// The comment's body text.
636 body: String,
637 /// The per-session CSRF token (`roots.web-session`).
638 csrf: String,
639 /// Where to send the browser back to; honored only when it is a
640 /// same-origin path.
641 #[serde(default)]
642 return_to: String,
643}
644
645/// `POST /commit/{oid}/comment`: a comment naming `commits/<oid>` as its
646/// context (`model.comment-context`) -- unanchored, exactly like
647/// [`review_comment`], since a comment about the commit as a whole has no
648/// path to anchor to the way a file-anchored comment does.
649///
650/// # Errors
651///
652/// [`Error::BadCsrf`] if `form.csrf` does not match; otherwise propagates
653/// [`ents_forge::comment::add`]'s own failures.
654// @relation(model.comment-context, roots.web-signing, roots.web-session, scope=function)
655pub async fn comment<O>(
656 State(state): State<Arc<AppState<O>>>,
657 axum::Extension(session): axum::Extension<Session>,
658 Path(oid): Path<String>,
659 Form(form): Form<CommitCommentForm>,
660) -> Result<impl IntoResponse>
661where
662 O: Find + Write + Send + 'static,
663{
664 super::require_csrf(&session, &form.csrf)?;
665 let identity = state.identity.as_ref();
666 let new = ents_forge::comment::NewComment {
667 body: form.body,
668 path: None,
669 lines: None,
670 rev: "HEAD".to_owned(),
671 worktree: false,
672 context: Some(format!("commits/{oid}")),
673 parent: None,
674 };
675 let (_comment_id, outcome) = ents_forge::comment::add(
676 state.refs.as_ref(),
677 &*state.objects(),
678 state.events.as_ref(),
679 &state.path,
680 new,
681 &crate::receive_identity!(identity, crate::pages::member_author(&session)),
682 state.mode,
683 )?;
684 crate::error::outcome_to_result(outcome)?;
685 let target = if form.return_to.starts_with('/') {
686 form.return_to
687 } else {
688 format!("/commit/{oid}")
689 };
690 Ok(Redirect::to(&target))
691}
692
693/// The start-a-review form (`POST /commit/{oid}/review`), its fields
694/// [`ents_forge::review::ReviewAction::New`]'s own
695/// ([`crate::form::action_form`]): `target` is the page's commit, so no
696/// control renders for it, and the verdict is a closed `.picker`
697/// (README's `VerdictPicker`) of radio inputs over
698/// [`ents_forge::review::Verdict`]'s three variants -- `model.review`
699/// makes it a hard enum, unlike issue and comment states -- defaulting to
700/// `approve`, the same default a bare `select`'s first option would
701/// submit. The body stays the derived compose-field textarea.
702fn start_review_form(session: &Session, oid: &str) -> Markup {
703 html! {
704 h3 { "Start a review" }
705 (crate::form::action_form::<ents_forge::review::ReviewAction>(
706 "New",
707 session,
708 &crate::form::Spec {
709 action: &format!("/commit/{oid}/review"),
710 submit: "Start a Review",
711 cancel: None,
712 values: &[],
713 overrides: &[
714 ("target", html! {}),
715 ("verdict", html! {
716 p.muted { "verdict" }
717 div.picker {
718 label.opt {
719 input type="radio" name="verdict" value="approve" checked;
720 span.dot {}
721 "approve"
722 }
723 label.opt {
724 input type="radio" name="verdict" value="request-changes";
725 span.dot {}
726 "request-changes"
727 }
728 label.opt {
729 input type="radio" name="verdict" value="comment";
730 span.dot {}
731 "comment"
732 }
733 }
734 }),
735 ],
736 },
737 ))
738 }
739}
740
741/// `POST /commit/{oid}/review`: review the commit at `oid`
742/// (`ents_forge::review::new`), which writes both the review's entity ref
743/// and its retention pin (`model.review`, `model.review-pin`) -- the web is
744/// another caller of that one library func, never a second review or
745/// pin-writing path. Signed (`roots.web-signing`) on behalf of the current
746/// session (`roots.web-session`). The posted fields are
747/// [`ents_forge::review::ReviewAction::New`]'s own
748/// ([`crate::form::parse_action`]), the path's `oid` standing in for the
749/// CLI's `--target`.
750///
751/// # Errors
752///
753/// [`Error::BadCsrf`] if the posted token does not match; otherwise
754/// propagates [`ents_forge::review::new`]'s own failures (including an
755/// unresolvable target commit).
756// @relation(model.review, model.review-pin, roots.web-signing, roots.web-session, scope=function)
757pub async fn review<O>(
758 State(state): State<Arc<AppState<O>>>,
759 axum::Extension(session): axum::Extension<Session>,
760 Path(oid): Path<String>,
761 Form(mut pairs): Form<Vec<(String, String)>>,
762) -> Result<impl IntoResponse>
763where
764 O: Find + Write + Send + 'static,
765{
766 super::require_csrf(&session, crate::form::posted_csrf(&pairs))?;
767 pairs.retain(|(name, _)| name != "target");
768 pairs.push(("target".to_owned(), oid.clone()));
769 let ents_forge::review::ReviewAction::New {
770 target,
771 verdict,
772 body,
773 key: _,
774 } = crate::form::parse_action("New", &pairs)?
775 else {
776 return Err(Error::InvalidArgument(
777 "not a form-backed review action".to_owned(),
778 ));
779 };
780 let member = super::reviewer_member_id(&state);
781 let identity = state.identity.as_ref();
782 let new = ents_forge::review::NewReview {
783 target,
784 verdict: verdict
785 .parse()
786 .map_err(|_unknown| Error::InvalidArgument(format!("unknown verdict: {verdict}")))?,
787 body: body.unwrap_or_default(),
788 };
789 let (_target, outcome) = ents_forge::review::new(
790 state.refs.as_ref(),
791 &*state.objects(),
792 state.events.as_ref(),
793 &state.path,
794 new,
795 &member,
796 &crate::receive_identity!(identity, crate::pages::member_author(&session)),
797 state.mode,
798 )?;
799 crate::error::outcome_to_result(outcome)?;
800 Ok(Redirect::to(&format!("/commit/{oid}")))
801}
802
803/// Validate `text` as a full, well-formed object id -- hex characters only,
804/// at the exact length the served repository's hash kind expects (this
805/// page does not resolve abbreviated prefixes; [`super::commits::list`]'s
806/// own links always carry the full id).
807///
808/// # Errors
809///
810/// [`Error::NotFound`] if `text` is empty, not hex, or the wrong length.
811fn parse_oid(text: &str) -> Result<ObjectId> {
812 if text.is_empty() || text.len() > 64 || !text.bytes().all(|b| b.is_ascii_hexdigit()) {
813 return Err(Error::NotFound {
814 what: text.to_owned(),
815 });
816 }
817 ObjectId::from_hex(text.as_bytes())
818 .ok()
819 .ok_or_else(|| Error::NotFound {
820 what: text.to_owned(),
821 })
822}
823
824/// One `.diff` section per changed file between `old_tree` and `new_tree`,
825/// plus whether the total rendered bytes exceeded [`MAX_DIFF_BYTES`] (in
826/// which case the caller shows a truncation notice). Best-effort: a change
827/// this function cannot read renders as a bare file header with no hunks
828/// rather than failing the whole page.
829fn diff_sections<O>(
830 state: &AppState<O>,
831 repo: &gix::Repository,
832 old_tree: &gix::Tree<'_>,
833 new_tree: &gix::Tree<'_>,
834) -> (Markup, bool) {
835 let Ok(mut platform) = old_tree.changes() else {
836 return (html! {}, false);
837 };
838 let mut sections = Vec::new();
839 let mut total: usize = 0;
840 let mut truncated = false;
841 let _outcome = platform.for_each_to_obtain_tree(new_tree, |change| {
842 if truncated {
843 return Ok::<_, std::convert::Infallible>(std::ops::ControlFlow::Break(()));
844 }
845 // The walk yields every intermediate directory as its own tree
846 // change; only blob (and link) entries are files a reader can
847 // diff, so a tree entry renders nothing rather than a bare
848 // header per subdirectory.
849 if is_tree_change(&change) {
850 return Ok(std::ops::ControlFlow::Continue(()));
851 }
852 let (section, bytes) = render_change(state, repo, &change);
853 total = total.saturating_add(bytes);
854 sections.push(section);
855 if total > MAX_DIFF_BYTES {
856 truncated = true;
857 }
858 Ok(std::ops::ControlFlow::Continue(()))
859 });
860 (html! { @for section in &sections { (section) } }, truncated)
861}
862
863/// Whether `change` is a tree (directory) entry rather than a blob or
864/// link -- [`diff_sections`] skips these, since the walk names every
865/// intermediate directory on the way to a changed file.
866fn is_tree_change(change: &Change<'_, '_, '_>) -> bool {
867 match *change {
868 Change::Addition { entry_mode, .. }
869 | Change::Deletion { entry_mode, .. }
870 | Change::Modification { entry_mode, .. }
871 | Change::Rewrite { entry_mode, .. } => entry_mode.is_tree(),
872 }
873}
874
875/// One changed file's `.diff` section: a `.file`-classed header naming the
876/// path (and, on a rename, the old path it moved from) beside its own
877/// [`super::editor_open`] pill -- the web↔editor handoff motif beside every
878/// code location (README's `EditorPill` inventory entry names diff headers
879/// explicitly) -- followed by either a `.meta`-classed "binary file
880/// changed" notice or the colorized unified diff between its old and new
881/// blob content. Returns the section's rendered byte cost, so
882/// [`diff_sections`] can track the page's overall budget.
883fn render_change<O>(
884 state: &AppState<O>,
885 repo: &gix::Repository,
886 change: &Change<'_, '_, '_>,
887) -> (Markup, usize) {
888 let (old_id, new_id, path, rename_from) = match *change {
889 Change::Addition { location, id, .. } => (
890 None,
891 Some(id.detach()),
892 location.to_str_lossy().into_owned(),
893 None,
894 ),
895 Change::Deletion { location, id, .. } => (
896 Some(id.detach()),
897 None,
898 location.to_str_lossy().into_owned(),
899 None,
900 ),
901 Change::Modification {
902 location,
903 previous_id,
904 id,
905 ..
906 } => (
907 Some(previous_id.detach()),
908 Some(id.detach()),
909 location.to_str_lossy().into_owned(),
910 None,
911 ),
912 Change::Rewrite {
913 location,
914 source_location,
915 source_id,
916 id,
917 ..
918 } => (
919 Some(source_id.detach()),
920 Some(id.detach()),
921 location.to_str_lossy().into_owned(),
922 Some(source_location.to_str_lossy().into_owned()),
923 ),
924 };
925
926 let old_bytes = old_id.and_then(|id| blob_bytes(repo, id));
927 let new_bytes = new_id.and_then(|id| blob_bytes(repo, id));
928 let cost = old_bytes
929 .as_ref()
930 .map_or(0, Vec::len)
931 .saturating_add(new_bytes.as_ref().map_or(0, Vec::len));
932 let binary =
933 old_bytes.as_deref().is_some_and(is_binary) || new_bytes.as_deref().is_some_and(is_binary);
934
935 let header = html! {
936 span.ln.file {
937 @if let Some(from) = &rename_from { (from) " \u{2192} " }
938 (path)
939 " "
940 (super::editor_open(state, &path, None))
941 "\n"
942 }
943 };
944 let body = if binary {
945 html! { span.ln.meta { "Binary file changed.\n" } }
946 } else {
947 let old_text = old_bytes.as_deref().map_or_else(String::new, |bytes| {
948 String::from_utf8_lossy(bytes).into_owned()
949 });
950 let new_text = new_bytes.as_deref().map_or_else(String::new, |bytes| {
951 String::from_utf8_lossy(bytes).into_owned()
952 });
953 unified_diff(&old_text, &new_text)
954 };
955 (html! { div.diff { (header) (body) } }, cost)
956}
957
958/// `id`'s blob content, or `None` when it cannot be read as a blob (a
959/// submodule/gitlink entry, or a read failure) -- best-effort, mirroring
960/// [`diff_sections`]'s own degrade-don't-fail stance.
961fn blob_bytes(repo: &gix::Repository, id: ObjectId) -> Option<Vec<u8>> {
962 Some(
963 repo.find_object(id)
964 .ok()?
965 .try_into_blob()
966 .ok()?
967 .data
968 .clone(),
969 )
970}
971
972/// Whether `bytes` looks like binary content (a NUL byte in the leading
973/// chunk, the same heuristic [`super::files::is_binary`] and pre-redo's own
974/// `is_binary` use).
975fn is_binary(bytes: &[u8]) -> bool {
976 bytes.iter().take(8000).any(|b| *b == 0)
977}
978
979/// `old_text` and `new_text` rendered as a colorized unified diff: each
980/// hunk built via [`gix::diff::blob::InternedInput`] and
981/// [`diff_with_slider_heuristics`], then rendered to the textual unified
982/// diff format through [`ConsumeBinaryHunk`] and colorized line by line via
983/// [`diff_class`] -- mirrors `pre-redo:.../pages.rs`'s own `diff_view`,
984/// its `git diff`-shelled-out patch text replaced with this page's own
985/// `gix`-computed one.
986fn unified_diff(old_text: &str, new_text: &str) -> Markup {
987 if old_text == new_text {
988 return html! {};
989 }
990 let input = InternedInput::new(old_text, new_text);
991 let diff = diff_with_slider_heuristics(Algorithm::Histogram, &input);
992 let Ok(patch) = UnifiedDiff::new(
993 &diff,
994 &input,
995 ConsumeBinaryHunk::new(String::new(), "\n"),
996 ContextSize::symmetrical(3),
997 )
998 .consume() else {
999 return html! {};
1000 };
1001 html! {
1002 @for line in patch.lines() {
1003 span class={ "ln " (diff_class(line)) } { (line) "\n" }
1004 }
1005 }
1006}
1007
1008/// The CSS class for a unified-diff line, chosen from its leading marker
1009/// (mirrors `pre-redo:.../pages.rs`'s own `diff_class`).
1010fn diff_class(line: &str) -> &'static str {
1011 if line.starts_with("@@") {
1012 "hunk"
1013 } else if line.starts_with('+') {
1014 "add"
1015 } else if line.starts_with('-') {
1016 "del"
1017 } else {
1018 "ctx"
1019 }
1020}
1021
1022#[cfg(test)]
1023mod tests {
1024 #![allow(clippy::expect_used, reason = "unit test")]
1025
1026 use rstest::rstest;
1027
1028 use super::*;
1029
1030 #[rstest]
1031 #[case::empty("", false)]
1032 #[case::not_hex("zzzzzzz", false)]
1033 #[case::too_long(
1034 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1035 false
1036 )]
1037 #[case::sha1("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true)]
1038 fn parse_oid_accepts_only_well_formed_hex_ids(#[case] text: &str, #[case] valid: bool) {
1039 assert_eq!(parse_oid(text).is_ok(), valid);
1040 }
1041
1042 #[test]
1043 fn diff_class_colors_hunk_add_del_and_context_lines() {
1044 assert_eq!(diff_class("@@ -1,2 +1,2 @@"), "hunk");
1045 assert_eq!(diff_class("+added"), "add");
1046 assert_eq!(diff_class("-removed"), "del");
1047 assert_eq!(diff_class(" context"), "ctx");
1048 }
1049
1050 #[test]
1051 fn unified_diff_renders_colored_added_and_removed_lines() {
1052 let rendered = unified_diff("a\nb\n", "a\nc\n").into_string();
1053 assert!(rendered.contains("class=\"ln del\""));
1054 assert!(rendered.contains("class=\"ln add\""));
1055 }
1056
1057 #[test]
1058 fn unified_diff_of_identical_text_renders_nothing() {
1059 let rendered = unified_diff("same\n", "same\n").into_string();
1060 assert!(rendered.is_empty());
1061 }
1062}