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