1
9use std::collections::HashMap;
10use std::fmt::Write;
11use std::ops::ControlFlow;
12
13use askama::Template;
14use askama_web::WebTemplate;
15use axum::extract::{Path, Query, State};
16use axum::response::IntoResponse;
17use gix::bstr::ByteSlice;
18use serde::Deserialize;
19
20use crate::error::{Error, Result};
21use crate::git::{self, GitResultExt as _, commit_date, committer_date};
22use crate::routes::{AppState, RepoName};
23
24use gix::diff::blob::UnifiedDiff;
25use gix::diff::blob::platform::prepare_diff::Operation;
26use gix::diff::blob::unified_diff::{ConsumeHunk, ContextSize, DiffLineKind, HunkHeader};
27
28const PAGE_SIZE: usize = 50;
30
31const ROW_H: f64 = 40.0;
36const LANE_W: f64 = 20.0;
37const NODE_R: f64 = 5.0;
38const OUTER_R: f64 = ROW_H / 2.0;
39const STROKE: f64 = 2.0;
40const Y_MID: f64 = ROW_H / 2.0;
41const BEND_R: f64 = 6.0;
42
43const COLORS: &[&str] = &[
46 "var(--blue)",
47 "var(--red)",
48 "var(--green)",
49 "var(--yellow)",
50 "var(--violet)",
51 "var(--magenta)",
52 "var(--cyan)",
53 "var(--orange)",
54];
55
56struct CommitEntry {
58 sha: String,
59 message: String,
60 date: String,
61 parents: Vec<String>,
62}
63
64fn load_commits(git_repo: &gix::Repository, limit: usize) -> Result<Vec<CommitEntry>> {
67 let Ok(head) = git_repo.head_commit() else {
68 return Ok(Vec::new());
69 };
70
71 let entries = head
72 .id()
73 .ancestors()
74 .all()
75 .corrupt()?
76 .filter_map(|info| {
77 let info = info.ok()?;
78 let commit = info.id().object().ok()?.try_into_commit().ok()?;
79
80 Some(CommitEntry {
81 sha: info.id().to_string(),
82 message: commit.message().ok()?.summary().to_string(),
83 date: commit_date(&commit).unwrap_or_default(),
84 parents: commit.parent_ids().map(|id| id.to_string()).collect(),
85 })
86 })
87 .take(limit)
88 .collect();
89
90 Ok(entries)
91}
92
93struct Layout {
97 positions: HashMap<String, (usize, usize)>,
98 active_at_page_start: Vec<Option<String>>,
99}
100
101#[allow(clippy::option_if_let_else)]
107fn compute_layout(commits: &[CommitEntry], page_start: usize) -> Layout {
108 let mut active: Vec<Option<&str>> = vec![];
109 let mut positions = HashMap::new();
110 let mut active_at_page_start = vec![];
111
112 for (row, commit) in commits.iter().enumerate() {
113 if row == page_start {
114 active_at_page_start = active
115 .iter()
116 .map(|opt| opt.map(std::string::ToString::to_string))
117 .collect();
118 }
119
120 let sha = commit.sha.as_str();
121 let col = match active.iter().position(|s| *s == Some(sha)) {
122 Some(i) => i,
123 None => {
124 if let Some(i) = active.iter().position(std::option::Option::is_none) {
125 active[i] = Some(sha);
126 i
127 } else {
128 active.push(Some(sha));
129 active.len() - 1
130 }
131 }
132 };
133
134 positions.insert(commit.sha.clone(), (row, col));
135
136 if commit.parents.is_empty() {
137 active[col] = None;
138 while matches!(active.last(), Some(None)) {
139 active.pop();
140 }
141 } else {
142 let first = commit.parents[0].as_str();
143 if active.iter().position(|s| *s == Some(first)) != Some(col) {
144 active[col] = None;
145 while matches!(active.last(), Some(None)) {
146 active.pop();
147 }
148 if active.iter().all(|s| *s != Some(first)) {
149 match active.iter().position(std::option::Option::is_none) {
150 Some(i) => active[i] = Some(first),
151 None => active.push(Some(first)),
152 }
153 }
154 }
155
156 for parent in commit.parents.iter().skip(1) {
157 if active.contains(&Some(parent.as_str())) {
158 continue;
159 }
160 match active.iter().position(std::option::Option::is_none) {
161 Some(i) => active[i] = Some(parent),
162 None => active.push(Some(parent)),
163 }
164 }
165 }
166 }
167
168 Layout {
169 positions,
170 active_at_page_start,
171 }
172}
173
174#[allow(clippy::cast_precision_loss, clippy::too_many_lines)]
183fn render_page_svg(
184 commits: &[CommitEntry],
185 layout: &Layout,
186 page_start: usize,
187 page_end: usize,
188) -> String {
189 if page_start >= page_end {
190 return String::new();
191 }
192
193 let mut all_cols: Vec<usize> = Vec::new();
194 for c in &commits[page_start..page_end] {
195 if let Some(&(_, col)) = layout.positions.get(&c.sha) {
196 all_cols.push(col);
197 }
198 }
199
200 let min_col = all_cols.iter().copied().min().unwrap_or(0);
201 let page_max_col = all_cols.iter().copied().max().unwrap_or(0);
202 let clamp_col = |c: usize| c.max(min_col).min(page_max_col);
203
204 let svg_h = (page_end - page_start) as f64 * ROW_H;
205 let svg_w = ((page_max_col - min_col + 1) as f64).mul_add(LANE_W, OUTER_R);
206
207 let lx = |col: usize| -> f64 { ((col - min_col) as f64).mul_add(LANE_W, OUTER_R) };
208 let ry = |abs_row: usize| -> f64 {
209 ((abs_row.saturating_sub(page_start)) as f64).mul_add(ROW_H, Y_MID)
210 };
211
212 let mut body = String::new();
213
214 for (col, opt_sha) in layout.active_at_page_start.iter().enumerate() {
216 let Some(sha) = opt_sha else { continue };
217 let Some(&(abs_row, _)) = layout.positions.get(sha.as_str()) else {
218 continue;
219 };
220 let x = lx(clamp_col(col));
221 let c = COLORS[col % COLORS.len()];
222 let y_to = if abs_row < page_start {
223 0.0
224 } else if abs_row < page_end {
225 ry(abs_row)
226 } else {
227 svg_h
228 };
229
230 let _ = write!(
231 body,
232 r#"<line x1="{x:.1}" y1="0.0" x2="{x:.1}" y2="{y_to:.1}" stroke="{c}" stroke-width="{STROKE}"/>"#
233 );
234 }
235
236 for commit in &commits[page_start..page_end] {
238 let &(abs_row, col) = layout.positions.get(&commit.sha).unwrap();
239 let x1 = lx(col);
240 let y1 = ry(abs_row);
241 let c = COLORS[col % COLORS.len()];
242
243 for (idx, parent_sha) in commit.parents.iter().enumerate() {
244 let (x2, y2, p_col) = match layout.positions.get(parent_sha.as_str()) {
245 Some(&(p_row, p_col)) if p_row >= page_start && p_row < page_end => {
246 (lx(p_col), ry(p_row), p_col)
247 }
248 Some(&(p_row, p_col)) => (
249 lx(clamp_col(p_col)),
250 if p_row < page_start { 0.0 } else { svg_h },
251 p_col,
252 ),
253 None => {
254 let _ = write!(
255 body,
256 r#"<line x1="{x1:.1}" y1="{y1:.1}" x2="{x1:.1}" y2="{svg_h:.1}" stroke="{c}" stroke-width="{STROKE}"/>"#
257 );
258 continue;
259 }
260 };
261
262 if col == p_col {
263 let _ = write!(
264 body,
265 r#"<line x1="{x1:.1}" y1="{y1:.1}" x2="{x2:.1}" y2="{y2:.1}" stroke="{c}" stroke-width="{STROKE}"/>"#
266 );
267 } else if idx == 0 {
268 let r = BEND_R.min((x2 - x1).abs());
269 let (arc_x, sweep) = if x2 < x1 { (x1 - r, 1) } else { (x1 + r, 0) };
270
271 let _ = write!(
272 body,
273 r#"<path d="M {x1:.1} {y1:.1} L {x1:.1} {:.1} A {r:.1} {r:.1} 0 0 {sweep} {arc_x:.1} {y2:.1} L {x2:.1} {y2:.1}" stroke="{c}" stroke-width="{STROKE}" fill="none"/>"#,
274 y2 - r,
275 );
276 } else {
277 let pc = COLORS[p_col % COLORS.len()];
278 let r = BEND_R.min((x2 - x1).abs());
279 let (arc_x, sweep) = if x2 > x1 { (x2 - r, 1) } else { (x2 + r, 0) };
280
281 let _ = write!(
282 body,
283 r#"<path d="M {x1:.1} {y1:.1} L {arc_x:.1} {y1:.1} A {r:.1} {r:.1} 0 0 {sweep} {x2:.1} {:.1} L {x2:.1} {y2:.1}" stroke="{pc}" stroke-width="{STROKE}" fill="none"/>"#,
284 y1 + r,
285 );
286 }
287 }
288 }
289
290 for commit in &commits[page_start..page_end] {
292 let &(abs_row, col) = layout.positions.get(&commit.sha).unwrap();
293 let cx = lx(col);
294 let cy = ry(abs_row);
295 let c = COLORS[col % COLORS.len()];
296 let row_top = cy - Y_MID;
297 let rect_x = cx - OUTER_R;
298
299 let _ = write!(
300 body,
301 r#"<path d="M {rect_x:.1} {top:.1} A {inner:.1} {inner:.1} 0 0 1 {right:.1} {row_top:.1} L {svg_w:.1} {row_top:.1} L {svg_w:.1} {bot:.1} L {right:.1} {bot:.1} A {inner:.1} {inner:.1} 0 0 1 {rect_x:.1} {bot_sub:.1} Z" fill="{c}" opacity="0.10"/>"#,
302 top = row_top + OUTER_R,
303 inner = OUTER_R,
304 right = rect_x + OUTER_R,
305 bot = row_top + ROW_H,
306 bot_sub = row_top + ROW_H - OUTER_R,
307 );
308
309 let _ = write!(
310 body,
311 r#"<circle cx="{cx:.1}" cy="{cy:.1}" r="{NODE_R}" fill="{c}"/>"#
312 );
313 }
314
315 format!(
316 r#"<svg width="{svg_w:.1}" height="{svg_h:.1}" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">{body}</svg>"#
317 )
318}
319
320#[derive(Deserialize, Default)]
322pub struct PageQuery {
323 #[serde(default)]
325 page: usize,
326}
327
328struct RefLabel {
330 name: String,
331 kind: String,
332}
333
334struct CommitRow {
336 sha: String,
337 message: String,
338 date: String,
339 color: String,
340 refs: Vec<RefLabel>,
341}
342
343#[derive(Template, WebTemplate)]
345#[template(path = "commit_list.html")]
346struct CommitList {
347 repo: String,
348 graph_svg: String,
349 commits: Vec<CommitRow>,
350 page: usize,
351 has_prev: bool,
352 has_next: bool,
353}
354
355pub async fn list(
357 State(state): State<AppState>,
358 RepoName(repo): RepoName,
359 Query(q): Query<PageQuery>,
360) -> Result<impl IntoResponse> {
361 let git_repo = git::open_repo(&state.root, &repo)?;
362
363 let page_start = q.page * PAGE_SIZE;
364 let page_end = page_start + PAGE_SIZE;
365
366 let entries = load_commits(&git_repo, page_end + 1)?;
367 let has_next = entries.len() > page_end;
368 let visible_end = page_end.min(entries.len());
369
370 let layout = compute_layout(&entries, page_start);
371 let graph_svg = render_page_svg(&entries, &layout, page_start, visible_end);
372
373 let mut refs_by_sha: HashMap<String, Vec<RefLabel>> = HashMap::new();
374 if let Ok(refs) = git_repo.references() {
375 for mut branch in git::flatten_refs(refs.local_branches()) {
376 if let Ok(commit) = branch.peel_to_commit() {
377 refs_by_sha
378 .entry(commit.id().to_string())
379 .or_default()
380 .push(RefLabel {
381 name: branch.name().shorten().to_string(),
382 kind: "branch".into(),
383 });
384 }
385 }
386
387 for mut tag in git::flatten_refs(refs.tags()) {
388 if let Ok(commit) = tag.peel_to_commit() {
389 refs_by_sha
390 .entry(commit.id().to_string())
391 .or_default()
392 .push(RefLabel {
393 name: tag.name().shorten().to_string(),
394 kind: "tag".into(),
395 });
396 }
397 }
398 }
399
400 let commits = entries[page_start..visible_end]
401 .iter()
402 .map(|e| CommitRow {
403 color: layout
404 .positions
405 .get(&e.sha)
406 .map_or("", |&(_, col)| COLORS[col % COLORS.len()])
407 .to_string(),
408 sha: e.sha.clone(),
409 message: e.message.clone(),
410 date: e.date.clone(),
411 refs: refs_by_sha.remove(e.sha.as_str()).unwrap_or_default(),
412 })
413 .collect();
414
415 Ok(CommitList {
416 repo,
417 graph_svg,
418 commits,
419 page: q.page,
420 has_prev: q.page > 0,
421 has_next,
422 })
423}
424
425#[derive(Deserialize)]
427pub(super) struct CommitPath {
428 sha: String,
429}
430
431struct DiffLine {
437 marker: String,
438 css_class: String,
439 content: String,
440}
441
442struct DiffHunkCollector<'a> {
445 hunks: &'a mut Vec<Vec<DiffLine>>,
446 file_added: &'a mut u64,
447 file_removed: &'a mut u64,
448}
449
450impl ConsumeHunk for DiffHunkCollector<'_> {
451 type Out = ();
452
453 fn consume_hunk(
454 &mut self,
455 _header: HunkHeader,
456 lines: &[(DiffLineKind, &[u8])],
457 ) -> std::io::Result<()> {
458 let mut hunk = Vec::new();
459 for (kind, content) in lines {
460 let content_str = std::str::from_utf8(content)
461 .unwrap_or("<binary>")
462 .to_string();
463
464 let (marker, css_class) = match kind {
465 DiffLineKind::Context => ("~".into(), "ctx".into()),
466 DiffLineKind::Add => ("+".into(), "+".into()),
467 DiffLineKind::Remove => ("-".into(), "-".into()),
468 };
469
470 match kind {
471 DiffLineKind::Add => *self.file_added += 1,
472 DiffLineKind::Remove => *self.file_removed += 1,
473 DiffLineKind::Context => {}
474 }
475
476 hunk.push(DiffLine {
477 marker,
478 css_class,
479 content: content_str,
480 });
481 }
482
483 self.hunks.push(hunk);
484 Ok(())
485 }
486
487 fn finish(self) -> Self::Out {}
488}
489
490struct FileChange {
492 path: String,
493 change_type: String,
494 hunks: Vec<Vec<DiffLine>>,
495}
496
497#[derive(Template, WebTemplate)]
499#[template(path = "commit.html")]
500struct CommitDetail {
501 repo: String,
502 sha: String,
503 author: String,
504 committer: String,
505 date: String,
506 committer_date: String,
507 message: String,
508 parents: Vec<(String, String)>,
509 refs: Vec<RefLabel>,
510 files_changed: u64,
511 lines_added: u64,
512 lines_removed: u64,
513 file_changes: Vec<FileChange>,
514}
515
516#[allow(clippy::too_many_lines)]
518pub async fn detail(
519 State(state): State<AppState>,
520 RepoName(repo): RepoName,
521 Path(params): Path<CommitPath>,
522) -> Result<impl IntoResponse> {
523 let sha = params.sha;
524
525 let git_repo = git::open_repo(&state.root, &repo)?;
526
527 let oid = gix::ObjectId::from_hex(sha.as_bytes())
528 .map_err(|_| Error::BadRequest(format!("invalid sha: {sha}")))?;
529
530 let commit = git_repo
531 .find_object(oid)
532 .map_err(|_| Error::NotFound(format!("commit {sha}")))?
533 .try_into_commit()
534 .map_err(|_| Error::BadRequest(format!("{sha} is not a commit")))?;
535
536 let sha = commit.id().to_string();
537
538 let author = commit
539 .author()
540 .map(|a| format!("{} <{}>", a.name, a.email))
541 .unwrap_or_default();
542
543 let committer = commit
544 .committer()
545 .map(|c| format!("{} <{}>", c.name, c.email))
546 .unwrap_or_default();
547
548 let date = commit_date(&commit).unwrap_or_default();
549 let committer_date = committer_date(&commit).unwrap_or_default();
550 let message = commit.message_raw_sloppy().to_str_lossy().into_owned();
551
552 let parents: Vec<(String, String)> = commit
553 .parent_ids()
554 .map(|id| {
555 let s = id.to_string();
556 let short = s[..8].to_string();
557 (s, short)
558 })
559 .collect();
560
561 let mut refs: Vec<RefLabel> = Vec::new();
563 if let Ok(rs) = git_repo.references() {
564 for mut branch in git::flatten_refs(rs.local_branches()) {
565 if let Ok(c) = branch.peel_to_commit()
566 && c.id().to_string() == sha
567 {
568 refs.push(RefLabel {
569 name: branch.name().shorten().to_string(),
570 kind: "branch".into(),
571 });
572 }
573 }
574
575 for mut tag in git::flatten_refs(rs.tags()) {
576 if let Ok(c) = tag.peel_to_commit()
577 && c.id().to_string() == sha
578 {
579 refs.push(RefLabel {
580 name: tag.name().shorten().to_string(),
581 kind: "tag".into(),
582 });
583 }
584 }
585 }
586
587 let commit_tree = commit.tree().corrupt()?;
589
590 let parent_tree = commit
591 .parent_ids()
592 .next()
593 .and_then(|pid| pid.object().ok())
594 .and_then(|o| o.try_into_commit().ok())
595 .and_then(|c| c.tree().ok());
596
597 let mut resource_cache = git_repo.diff_resource_cache_for_tree_diff().corrupt()?;
598
599 let mut files_changed = 0u64;
600 let mut lines_added = 0u64;
601 let mut lines_removed = 0u64;
602 let mut file_changes: Vec<FileChange> = Vec::new();
603
604 #[allow(clippy::option_if_let_else)]
605 let source_tree = match parent_tree {
606 Some(ref t) => t,
607 None => &git_repo.empty_tree(),
608 };
609
610 source_tree
611 .changes()
612 .corrupt()?
613 .for_each_to_obtain_tree(&commit_tree, |change| {
614 if change.entry_mode().is_tree() {
616 return Ok::<_, std::convert::Infallible>(ControlFlow::Continue(()));
617 }
618
619 let path = change.location().to_str_lossy().into_owned();
620 let change_type = match change {
621 gix::object::tree::diff::Change::Addition { .. } => "A",
622 gix::object::tree::diff::Change::Deletion { .. } => "D",
623 gix::object::tree::diff::Change::Modification { .. } => "M",
624 gix::object::tree::diff::Change::Rewrite { .. } => "R",
625 };
626
627 files_changed += 1;
628
629 let mut hunks = Vec::new();
630 let mut added = 0u64;
631 let mut removed = 0u64;
632
633 if let Ok(platform) = change.diff(&mut resource_cache) {
634 platform
635 .resource_cache
636 .options
637 .skip_internal_diff_if_external_is_configured = false;
638 if let Ok(prep) = platform.resource_cache.prepare_diff()
639 && let Operation::InternalDiff { algorithm } = prep.operation
640 {
641 let input = prep.interned_input();
642 let collector = DiffHunkCollector {
643 hunks: &mut hunks,
644 file_added: &mut added,
645 file_removed: &mut removed,
646 };
647 let sink = UnifiedDiff::new(&input, collector, ContextSize::symmetrical(3));
648 let _ = gix::diff::blob::diff(algorithm, &input, sink);
649 }
650 }
651
652 resource_cache.clear_resource_cache_keep_allocation();
653
654 lines_added += added;
655 lines_removed += removed;
656
657 file_changes.push(FileChange {
658 path,
659 change_type: change_type.to_string(),
660 hunks,
661 });
662
663 Ok::<_, std::convert::Infallible>(ControlFlow::Continue(()))
664 })
665 .corrupt()?;
666
667 Ok(CommitDetail {
668 repo,
669 sha,
670 author,
671 committer,
672 date,
673 committer_date,
674 message,
675 parents,
676 refs,
677 files_changed,
678 lines_added,
679 lines_removed,
680 file_changes,
681 })
682}