Krolyxon
Tag v0.1.0

ferreniumsrchandlerscommit.rs

375 lines · 11.6 KB · Rust

1//! `/{repo}/commit/{rev}` — message, metadata, diffstat and the unified
2//! diff against the first parent (`?context=N`, `?ws=ignore`).
3//! `/{repo}/commit/{rev}.patch` — the same as a plain-text patch.
4
5use askama::Template;
6use axum::http::{HeaderValue, StatusCode, header};
7use axum::response::{IntoResponse, Response};
8use gix::bstr::ByteSlice;
9
10use super::raw::RAW_CSP;
11use super::repo::{Active, Crumb, Ctx, RepoNav, set_cache_headers, short};
12use super::{Layout, render};
13use crate::cache::Key;
14use crate::git::diff::Status;
15use crate::git::refs::{self, Rev};
16use crate::git::{self, CommitDetail};
17use crate::render::diff::{self, Body, CommitDiff, FileDiff};
18use crate::render::time;
19
20const DEFAULT_CONTEXT: u32 = 3;
21const MAX_CONTEXT: u32 = 50;
22
23pub struct Meta {
24 pub label: &'static str,
25 pub value: String,
26 pub title: Option<String>,
27 pub links: Vec<(String, String)>,
28}
29
30pub struct FileRow<'a> {
31 pub file: &'a FileDiff,
32 pub anchor: String,
33 pub path: String,
34 /// Source path of a rename or copy.
35 pub from: Option<String>,
36 pub status: &'static str,
37 /// Single-letter status (`A`, `M`, `D`, `R`, `C`).
38 pub letter: &'static str,
39 /// Five blocks: `add`, `del` or `none`.
40 pub bar: [&'static str; 5],
41 /// The file at this commit (or its parent, for deletions).
42 pub url: Option<String>,
43}
44
45pub struct Toggle {
46 pub label: &'static str,
47 pub url: String,
48 pub active: bool,
49}
50
51#[derive(Template)]
52#[template(path = "commit.html")]
53pub struct CommitPage<'a> {
54 pub layout: Layout,
55 pub nav: RepoNav,
56 pub crumbs: Vec<Crumb>,
57 pub here: Option<String>,
58 pub subject: String,
59 pub body: String,
60 pub meta: Vec<Meta>,
61 pub added: u64,
62 pub removed: u64,
63 pub files: Vec<FileRow<'a>>,
64 pub diff: &'a CommitDiff,
65 pub patch_url: String,
66 pub tree_url: String,
67 pub ws_toggle: Vec<Toggle>,
68 pub context_less: Option<String>,
69 pub context_more: Option<String>,
70 pub context: u32,
71}
72
73pub fn page(ctx: &Ctx, segments: &[Vec<u8>]) -> Response {
74 let Some((rev, patch)) = resolve(ctx, segments) else {
75 return ctx.not_found("UNKNOWN REVISION");
76 };
77 let context = ctx
78 .query
79 .context
80 .as_deref()
81 .and_then(|c| c.parse::<u32>().ok())
82 .map_or(DEFAULT_CONTEXT, |c| c.min(MAX_CONTEXT));
83 let ignore_ws = ctx.query.ws.as_deref() == Some("ignore");
84 let etag = if patch {
85 ctx.etag(("patch", rev.commit))
86 } else {
87 ctx.etag(("commit", rev.commit, context, ignore_ws))
88 };
89 let immutable = rev.is_immutable();
90 if let Some(res) = ctx.not_modified(&etag, immutable) {
91 return res;
92 }
93 let detail = match git::commit_detail(&ctx.git, rev.commit) {
94 Ok(detail) => detail,
95 Err(e) => return ctx.internal_error(e),
96 };
97 let changes = match git::diff::changes(&ctx.git, rev.commit) {
98 Ok(changes) => changes,
99 Err(e) => return ctx.internal_error(e),
100 };
101 if patch {
102 return match diff::patch(&ctx.git, &detail, &changes) {
103 Ok(text) => {
104 let mut res = text.into_response();
105 let headers = res.headers_mut();
106 headers.insert(
107 header::CONTENT_TYPE,
108 HeaderValue::from_static("text/plain; charset=utf-8"),
109 );
110 headers.insert(
111 header::CONTENT_SECURITY_POLICY,
112 HeaderValue::from_static(RAW_CSP),
113 );
114 headers.insert(
115 header::X_CONTENT_TYPE_OPTIONS,
116 HeaderValue::from_static("nosniff"),
117 );
118 set_cache_headers(&mut res, &etag, immutable);
119 res
120 }
121 Err(e) => ctx.internal_error(e),
122 };
123 }
124
125 let options = diff::Options {
126 context,
127 ignore_ws,
128 max_files: ctx.config.limits.diff_files,
129 max_lines: ctx.config.limits.diff_lines,
130 max_file_lines: ctx.config.limits.diff_file_lines,
131 max_blob_bytes: ctx.config.ui.max_blob_kb * 1024,
132 max_highlight_bytes: ctx.config.ui.max_highlight_kb * 1024,
133 };
134 let key = Key::Diff {
135 commit: rev.commit,
136 options,
137 };
138 let built = ctx.state.cache.diff(key, || {
139 diff::build(&ctx.git, &ctx.state.cache, &changes, &options)
140 });
141 let built = match built {
142 Ok(built) => built,
143 Err(e) => return ctx.internal_error(e),
144 };
145
146 let mut page = commit_page(ctx, &rev, &detail, &built, changes.parent);
147 page.context = context;
148 let base = ctx.commit_url(&rev.commit);
149 let url = |context: u32, ignore_ws: bool| {
150 let mut params = Vec::new();
151 if context != DEFAULT_CONTEXT {
152 params.push(format!("context={context}"));
153 }
154 if ignore_ws {
155 params.push("ws=ignore".to_string());
156 }
157 if params.is_empty() {
158 base.clone()
159 } else {
160 format!("{base}?{}", params.join("&"))
161 }
162 };
163 page.ws_toggle = vec![
164 Toggle {
165 label: "Whitespace",
166 url: url(context, false),
167 active: !ignore_ws,
168 },
169 Toggle {
170 label: "Ignore",
171 url: url(context, true),
172 active: ignore_ws,
173 },
174 ];
175 page.context_less = (context > 0).then(|| url(context.saturating_sub(3), ignore_ws));
176 page.context_more =
177 (context < MAX_CONTEXT).then(|| url((context + 3).min(MAX_CONTEXT), ignore_ws));
178
179 let mut res = render(StatusCode::OK, &page);
180 set_cache_headers(&mut res, &etag, immutable);
181 res
182}
183
184/// The revision and whether a patch was asked for. `{rev}.patch` is a
185/// patch unless a ref with that literal name exists.
186fn resolve(ctx: &Ctx, segments: &[Vec<u8>]) -> Option<(Rev, bool)> {
187 let name = std::str::from_utf8(&segments.join(&b'/')).ok()?.to_string();
188 if name.is_empty() {
189 return None;
190 }
191 if let Some(stem) = name.strip_suffix(".patch")
192 && let Some(rev) = refs::resolve(&ctx.git, stem)
193 && refs::resolve(&ctx.git, &name).is_none()
194 {
195 return Some((rev, true));
196 }
197 refs::resolve(&ctx.git, &name).map(|rev| (rev, false))
198}
199
200fn commit_page<'a>(
201 ctx: &Ctx,
202 rev: &Rev,
203 detail: &CommitDetail,
204 built: &'a CommitDiff,
205 parent: Option<git::ObjectId>,
206) -> CommitPage<'a> {
207 let mailmap = ctx.mailmap();
208 let id = detail.info.id.to_string();
209 let (author, author_email) = mailmap.resolve(&detail.author.name, &detail.author.email);
210 let (committer, committer_email) =
211 mailmap.resolve(&detail.committer.name, &detail.committer.email);
212
213 let mut meta = vec![Meta {
214 label: "Author",
215 value: author.clone(),
216 title: Some(author_email.clone()),
217 links: Vec::new(),
218 }];
219 meta.push(Meta {
220 label: "Date",
221 value: time::local(detail.author.time, detail.author.offset),
222 title: Some(time::iso(detail.author.time)),
223 links: Vec::new(),
224 });
225 if (&committer, &committer_email) != (&author, &author_email)
226 || detail.committer.time != detail.author.time
227 {
228 meta.push(Meta {
229 label: "Committer",
230 value: format!(
231 "{committer} · {}",
232 time::local(detail.committer.time, detail.committer.offset)
233 ),
234 title: Some(committer_email),
235 links: Vec::new(),
236 });
237 }
238 meta.push(Meta {
239 label: if detail.parents.len() == 1 {
240 "Parent"
241 } else {
242 "Parents"
243 },
244 value: if detail.parents.is_empty() {
245 "None".into()
246 } else {
247 String::new()
248 },
249 title: None,
250 links: detail
251 .parents
252 .iter()
253 .map(|p| (short(&p.to_string()), ctx.commit_url(p)))
254 .collect(),
255 });
256 meta.push(Meta {
257 label: "Commit",
258 value: id.clone(),
259 title: None,
260 links: Vec::new(),
261 });
262
263 let tree_url = ctx.url("tree", &id, b"");
264 let parent_id = parent.map(|p| p.to_string());
265 let files = built
266 .files
267 .iter()
268 .enumerate()
269 .map(|(i, file)| {
270 let change = &file.change;
271 let path = change.path().to_str_lossy().into_owned();
272 let url = match change.status {
273 Status::Deleted => parent_id
274 .as_ref()
275 .map(|p| ctx.url("blob", p, change.path())),
276 _ => Some(ctx.url("blob", &id, change.path())),
277 };
278 let from = match change.status {
279 Status::Renamed | Status::Copied => change
280 .old
281 .as_ref()
282 .map(|o| o.path.to_str_lossy().into_owned()),
283 _ => None,
284 };
285 let (status, letter) = match change.status {
286 Status::Added => ("Added", "A"),
287 Status::Deleted => ("Deleted", "D"),
288 Status::Modified => ("Modified", "M"),
289 Status::Renamed => ("Renamed", "R"),
290 Status::Copied => ("Copied", "C"),
291 };
292 // Submodules and binaries have no line counts but still have
293 // a URL; only blobs get one.
294 let url = url.filter(|_| !matches!(file.body, Body::Submodule { .. }));
295 FileRow {
296 file,
297 anchor: format!("f{}", i + 1),
298 path,
299 from,
300 status,
301 letter,
302 bar: bar(file.stat),
303 url,
304 }
305 })
306 .collect();
307
308 CommitPage {
309 layout: Layout::new(
310 ctx.state,
311 ctx.config,
312 Some(&format!("{} · {}", detail.info.subject, ctx.repo.name)),
313 ),
314 nav: ctx.nav(Active::Log, Some(rev), |name| ctx.url("log", name, b"")),
315 crumbs: vec![Crumb {
316 name: ctx.repo.name.clone(),
317 url: ctx.repo_url(),
318 }],
319 here: Some(short(&id)),
320 subject: detail.info.subject.clone(),
321 body: detail.body.clone(),
322 meta,
323 added: built.added,
324 removed: built.removed,
325 files,
326 diff: built,
327 patch_url: format!("{}.patch", ctx.commit_url(&detail.info.id)),
328 tree_url,
329 ws_toggle: Vec::new(),
330 context_less: None,
331 context_more: None,
332 context: DEFAULT_CONTEXT,
333 }
334}
335
336/// Five blocks split between additions and removals; fewer than five
337/// changed lines leave the rest empty.
338fn bar(stat: Option<(u32, u32)>) -> [&'static str; 5] {
339 let mut out = ["none"; 5];
340 let Some((added, removed)) = stat else {
341 return out;
342 };
343 let total = added + removed;
344 if total == 0 {
345 return out;
346 }
347 let filled = total.min(5);
348 let mut adds = ((filled * added) as f64 / total as f64).round() as u32;
349 // Keep at least one block for each side that changed.
350 if added > 0 && adds == 0 {
351 adds = 1;
352 }
353 if removed > 0 && adds == filled {
354 adds = filled - 1;
355 }
356 for (i, slot) in out.iter_mut().enumerate().take(filled as usize) {
357 *slot = if (i as u32) < adds { "add" } else { "del" };
358 }
359 out
360}
361
362#[cfg(test)]
363mod tests {
364 use super::bar;
365
366 #[test]
367 fn diffstat_bars() {
368 assert_eq!(bar(None), ["none"; 5]);
369 assert_eq!(bar(Some((0, 0))), ["none"; 5]);
370 assert_eq!(bar(Some((2, 0))), ["add", "add", "none", "none", "none"]);
371 assert_eq!(bar(Some((100, 1))), ["add", "add", "add", "add", "del"]);
372 assert_eq!(bar(Some((1, 100))), ["add", "del", "del", "del", "del"]);
373 assert_eq!(bar(Some((30, 20))), ["add", "add", "add", "del", "del"]);
374 }
375}