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