Parent
[>]
1//! Commit log list and detail pages. 2//! 3//! Covers two routes: the commit log (`/{repo}/commits`) and the commit detail 4//! page (`/{repo}/commits/{sha}`). The `list` handler renders a paginated 5//! commit log with an SVG ancestry graph and inline ref indicators. The 6//! `detail` handler renders a single commit view with full metadata, a diff 7//! stat, and per-file inline diffs via `gix::diff::blob::UnifiedDiff`. 8 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::config::SiteConfig; 21use crate::error::{Error, Result}; 22use crate::git::{self, GitResultExt as _, commit_date, committer_date}; 23use crate::routes::{AppState, RepoName}; 24 25use gix::diff::blob::UnifiedDiff; 26use gix::diff::blob::platform::prepare_diff::Operation; 27use gix::diff::blob::unified_diff::{ConsumeHunk, ContextSize, DiffLineKind, HunkHeader}; 28 29/// Number of commits per page in the log view. 30const PAGE_SIZE: usize = 50; 31 32// SVG graph layout constants. 33// `ROW_H` is the per-row height, `LANE_W` is the per-lane width (spacing 34// between parallel tracks), `NODE_R` is the dot radius, and `BEND_R` is 35// the corner radius used when an edge changes lanes. 36const ROW_H: f64 = 40.0; 37const LANE_W: f64 = 20.0; 38const NODE_R: f64 = 5.0; 39const OUTER_R: f64 = ROW_H / 2.0; 40const STROKE: f64 = 2.0; 41const Y_MID: f64 = ROW_H / 2.0; 42const BEND_R: f64 = 6.0; 43 44/// Color palette used for SVG graph lanes. 45/// Each lane gets one color from this cycle. 46const COLORS: &[&str] = &[ 47 "var(--blue)", 48 "var(--red)", 49 "var(--green)", 50 "var(--yellow)", 51 "var(--violet)", 52 "var(--magenta)", 53 "var(--cyan)", 54 "var(--orange)", 55]; 56 57/// (row, column) positions for every commit in the current page, plus a 58/// snapshot of active lanes entering the page from above so the SVG renderer 59/// can draw continuation edges. 60struct Layout { 61 positions: HashMap<String, (usize, usize)>, 62 active_at_page_start: Vec<Option<String>>, 63} 64 65/// Assigns each commit a (row, column) position for the SVG graph. 66/// 67/// Uses a column-stealing algorithm: a commit claims the column its first 68/// parent occupies, freeing the previous column for other branches. Lane 69/// re-use keeps the graph narrow. 70#[allow(clippy::option_if_let_else)] 71fn compute_layout(commits: &[git::CommitEntry], page_start: usize) -> Layout { 72 let mut active: Vec<Option<&str>> = vec![]; 73 let mut positions = HashMap::new(); 74 let mut active_at_page_start = vec![]; 75 76 for (row, commit) in commits.iter().enumerate() { 77 if row == page_start { 78 active_at_page_start = active 79 .iter() 80 .map(|opt| opt.map(std::string::ToString::to_string)) 81 .collect(); 82 } 83 84 let sha = commit.sha.as_str(); 85 let col = match active.iter().position(|s| *s == Some(sha)) { 86 Some(i) => i, 87 None => { 88 if let Some(i) = active.iter().position(std::option::Option::is_none) { 89 active[i] = Some(sha); 90 i 91 } else { 92 active.push(Some(sha)); 93 active.len() - 1 94 } 95 } 96 }; 97 98 positions.insert(commit.sha.clone(), (row, col)); 99 100 if commit.parents.is_empty() { 101 active[col] = None; 102 while matches!(active.last(), Some(None)) { 103 active.pop(); 104 } 105 } else { 106 let first = commit.parents[0].as_str(); 107 if active.iter().position(|s| *s == Some(first)) != Some(col) { 108 active[col] = None; 109 while matches!(active.last(), Some(None)) { 110 active.pop(); 111 } 112 if active.iter().all(|s| *s != Some(first)) { 113 match active.iter().position(std::option::Option::is_none) { 114 Some(i) => active[i] = Some(first), 115 None => active.push(Some(first)), 116 } 117 } 118 } 119 120 for parent in commit.parents.iter().skip(1) { 121 if active.contains(&Some(parent.as_str())) { 122 continue; 123 } 124 match active.iter().position(std::option::Option::is_none) { 125 Some(i) => active[i] = Some(parent), 126 None => active.push(Some(parent)), 127 } 128 } 129 } 130 } 131 132 Layout { 133 positions, 134 active_at_page_start, 135 } 136} 137 138/// Renders the SVG ancestry graph for commits[`page_start..page_end`]. 139/// 140/// Three passes: 141/// 1. Continuation edges entering from above the viewport. 142/// 2. Edges from each visible commit to its parents (straight lines for 143/// same-column, L-shaped bends for lane changes, S-shaped curves for 144/// merge arrows). 145/// 3. Node circles and lane-highlight backgrounds on top. 146#[allow(clippy::cast_precision_loss, clippy::too_many_lines)] 147fn render_page_svg( 148 commits: &[git::CommitEntry], 149 layout: &Layout, 150 page_start: usize, 151 page_end: usize, 152) -> String { 153 if page_start >= page_end { 154 return String::new(); 155 } 156 157 let mut all_cols: Vec<usize> = Vec::new(); 158 for c in &commits[page_start..page_end] { 159 if let Some(&(_, col)) = layout.positions.get(&c.sha) { 160 all_cols.push(col); 161 } 162 } 163 164 let min_col = all_cols.iter().copied().min().unwrap_or(0); 165 let page_max_col = all_cols.iter().copied().max().unwrap_or(0); 166 let clamp_col = |c: usize| c.max(min_col).min(page_max_col); 167 168 let svg_h = (page_end - page_start) as f64 * ROW_H; 169 let svg_w = ((page_max_col - min_col + 1) as f64).mul_add(LANE_W, OUTER_R); 170 171 let lx = |col: usize| -> f64 { ((col - min_col) as f64).mul_add(LANE_W, OUTER_R) }; 172 let ry = |abs_row: usize| -> f64 { 173 ((abs_row.saturating_sub(page_start)) as f64).mul_add(ROW_H, Y_MID) 174 }; 175 176 let mut body = String::new(); 177 178 // Pass 1: continuation edges entering from above. 179 for (col, opt_sha) in layout.active_at_page_start.iter().enumerate() { 180 let Some(sha) = opt_sha else { continue }; 181 let Some(&(abs_row, _)) = layout.positions.get(sha.as_str()) else { 182 continue; 183 }; 184 let x = lx(clamp_col(col)); 185 let c = COLORS[col % COLORS.len()]; 186 let y_to = if abs_row < page_start { 187 0.0 188 } else if abs_row < page_end { 189 ry(abs_row) 190 } else { 191 svg_h 192 }; 193 194 let _ = write!( 195 body, 196 r#"<line x1="{x:.1}" y1="0.0" x2="{x:.1}" y2="{y_to:.1}" stroke="{c}" stroke-width="{STROKE}"/>"# 197 ); 198 } 199 200 // Pass 2: edges originating from commits on this page. 201 for commit in &commits[page_start..page_end] { 202 let &(abs_row, col) = layout 203 .positions 204 .get(&commit.sha) 205 .expect("commit must have a layout position"); 206 let x1 = lx(col); 207 let y1 = ry(abs_row); 208 let c = COLORS[col % COLORS.len()]; 209 210 for (idx, parent_sha) in commit.parents.iter().enumerate() { 211 let (x2, y2, p_col) = match layout.positions.get(parent_sha.as_str()) { 212 Some(&(p_row, p_col)) if p_row >= page_start && p_row < page_end => { 213 (lx(p_col), ry(p_row), p_col) 214 } 215 Some(&(p_row, p_col)) => ( 216 lx(clamp_col(p_col)), 217 if p_row < page_start { 0.0 } else { svg_h }, 218 p_col, 219 ), 220 None => { 221 let _ = write!( 222 body, 223 r#"<line x1="{x1:.1}" y1="{y1:.1}" x2="{x1:.1}" y2="{svg_h:.1}" stroke="{c}" stroke-width="{STROKE}"/>"# 224 ); 225 continue; 226 } 227 }; 228 229 if col == p_col { 230 let _ = write!( 231 body, 232 r#"<line x1="{x1:.1}" y1="{y1:.1}" x2="{x2:.1}" y2="{y2:.1}" stroke="{c}" stroke-width="{STROKE}"/>"# 233 ); 234 } else if idx == 0 { 235 let r = BEND_R.min((x2 - x1).abs()); 236 let (arc_x, sweep) = if x2 < x1 { (x1 - r, 1) } else { (x1 + r, 0) }; 237 238 let _ = write!( 239 body, 240 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"/>"#, 241 y2 - r, 242 ); 243 } else { 244 let pc = COLORS[p_col % COLORS.len()]; 245 let r = BEND_R.min((x2 - x1).abs()); 246 let (arc_x, sweep) = if x2 > x1 { (x2 - r, 1) } else { (x2 + r, 0) }; 247 248 let _ = write!( 249 body, 250 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"/>"#, 251 y1 + r, 252 ); 253 } 254 } 255 } 256 257 // Pass 3: circles on top. 258 for commit in &commits[page_start..page_end] { 259 let &(abs_row, col) = layout 260 .positions 261 .get(&commit.sha) 262 .expect("commit must have a layout position"); 263 let cx = lx(col); 264 let cy = ry(abs_row); 265 let c = COLORS[col % COLORS.len()]; 266 let row_top = cy - Y_MID; 267 let rect_x = cx - OUTER_R; 268 269 let _ = write!( 270 body, 271 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"/>"#, 272 top = row_top + OUTER_R, 273 inner = OUTER_R, 274 right = rect_x + OUTER_R, 275 bot = row_top + ROW_H, 276 bot_sub = row_top + ROW_H - OUTER_R, 277 ); 278 279 let _ = write!( 280 body, 281 r#"<circle cx="{cx:.1}" cy="{cy:.1}" r="{NODE_R}" fill="{c}"/>"# 282 ); 283 } 284 285 format!( 286 r#"<svg width="{svg_w:.1}" height="{svg_h:.1}" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">{body}</svg>"# 287 ) 288} 289 290/// Query parameters for the commit log page. 291#[derive(Deserialize, Default)] 292pub struct PageQuery { 293 /// Cursor commit SHA — show commits older than this one (exclusive). 294 /// When absent, the log starts from HEAD. 295 #[serde(default)] 296 after: Option<String>, 297} 298 299/// A branch or tag ref pointing at a commit. 300struct RefLabel { 301 name: String, 302 kind: String, 303} 304 305/// A single row of the commit log, ready for the template. 306struct CommitRow { 307 sha: String, 308 message: String, 309 date: String, 310 color: String, 311 refs: Vec<RefLabel>, 312} 313 314/// Template data for the paginated commit log page. 315#[derive(Template, WebTemplate)] 316#[template(path = "commit_list.html")] 317struct CommitList { 318 repo: String, 319 graph_svg: String, 320 commits: Vec<CommitRow>, 321 /// Cursor used for this page (`None` means we started from HEAD). 322 after: Option<String>, 323 /// Cursor for the next page of older commits (`None` means no more pages). 324 next_cursor: Option<String>, 325 site: std::sync::Arc<SiteConfig>, 326} 327 328/// Renders a paginated commit log with an SVG ancestry graph. 329pub async fn list( 330 State(state): State<AppState>, 331 RepoName(repo): RepoName, 332 Query(q): Query<PageQuery>, 333) -> Result<impl IntoResponse> { 334 let git_repo = git::open_repo(&state.root, &repo)?; 335 336 let after = q.after.filter(|s| !s.is_empty()); 337 338 // Load cursor commit (for graph context) + PAGE_SIZE visible + 1 overflow. 339 // The cursor commit (index 0) provides incoming-edge context for the SVG 340 // renderer; it is skipped below via `page_start`. 341 let load_count = 1 + PAGE_SIZE + 1; 342 let entries = git::load_commits(&git_repo, after.as_deref(), load_count)?; 343 344 let page_start = if after.is_some() { 1 } else { 0 }; 345 let visible_end = (page_start + PAGE_SIZE).min(entries.len()); 346 let has_next = visible_end > page_start && entries.len() > page_start + PAGE_SIZE; 347 348 let layout = compute_layout(&entries, page_start); 349 let graph_svg = render_page_svg(&entries, &layout, page_start, visible_end); 350 351 let mut refs_by_sha: HashMap<String, Vec<RefLabel>> = HashMap::new(); 352 if let Ok(refs) = git_repo.references() { 353 for mut branch in git::flatten_refs(refs.local_branches()) { 354 if let Ok(commit) = branch.peel_to_commit() { 355 refs_by_sha 356 .entry(commit.id().to_string()) 357 .or_default() 358 .push(RefLabel { 359 name: branch.name().shorten().to_string(), 360 kind: "branch".into(), 361 }); 362 } 363 } 364 365 for mut tag in git::flatten_refs(refs.tags()) { 366 if let Ok(commit) = tag.peel_to_commit() { 367 refs_by_sha 368 .entry(commit.id().to_string()) 369 .or_default() 370 .push(RefLabel { 371 name: tag.name().shorten().to_string(), 372 kind: "tag".into(), 373 }); 374 } 375 } 376 } 377 378 let commits = entries[page_start..visible_end] 379 .iter() 380 .map(|e| CommitRow { 381 color: layout 382 .positions 383 .get(&e.sha) 384 .map_or("", |&(_, col)| COLORS[col % COLORS.len()]) 385 .to_string(), 386 sha: e.sha.clone(), 387 message: e.message.clone(), 388 date: e.date.clone(), 389 refs: refs_by_sha.remove(e.sha.as_str()).unwrap_or_default(), 390 }) 391 .collect(); 392 393 let next_cursor = if has_next { 394 // has_next is only true when visible_end > page_start, so this is safe. 395 Some(entries[visible_end - 1].sha.clone()) 396 } else { 397 None 398 }; 399 400 Ok(CommitList { 401 repo, 402 graph_svg, 403 commits, 404 after, 405 next_cursor, 406 site: state.site, 407 }) 408} 409 410/// Path parameter for the commit detail page. 411#[derive(Deserialize)] 412pub(super) struct CommitPath { 413 sha: String, 414} 415 416/// A single line inside a diff hunk, ready for the template. 417/// 418/// `marker` is the visible prefix character (`+`, `-`, or `~`). 419/// `css_class` is the CSS class suffix used to color the marker. 420/// `content` is the rest of the line. 421struct DiffLine { 422 marker: String, 423 css_class: String, 424 content: String, 425} 426 427/// Collects hunks produced by `UnifiedDiff` into a `Vec<Vec<DiffLine>>`, 428/// counting added and removed lines at the same time. 429struct DiffHunkCollector<'a> { 430 hunks: &'a mut Vec<Vec<DiffLine>>, 431 file_added: &'a mut u64, 432 file_removed: &'a mut u64, 433} 434 435impl ConsumeHunk for DiffHunkCollector<'_> { 436 type Out = (); 437 438 fn consume_hunk( 439 &mut self, 440 _header: HunkHeader, 441 lines: &[(DiffLineKind, &[u8])], 442 ) -> std::io::Result<()> { 443 let mut hunk = Vec::new(); 444 for (kind, content) in lines { 445 let content_str = std::str::from_utf8(content) 446 .unwrap_or("<binary>") 447 .to_string(); 448 449 let (marker, css_class) = match kind { 450 DiffLineKind::Context => ("~".into(), "ctx".into()), 451 DiffLineKind::Add => ("+".into(), "+".into()), 452 DiffLineKind::Remove => ("-".into(), "-".into()), 453 }; 454 455 match kind { 456 DiffLineKind::Add => *self.file_added += 1, 457 DiffLineKind::Remove => *self.file_removed += 1, 458 DiffLineKind::Context => {} 459 } 460 461 hunk.push(DiffLine { 462 marker, 463 css_class, 464 content: content_str, 465 }); 466 } 467 468 self.hunks.push(hunk); 469 Ok(()) 470 } 471 472 fn finish(self) -> Self::Out {} 473} 474 475/// A single file touched by a commit, with its inline diff hunks. 476struct FileChange { 477 path: String, 478 change_type: String, 479 hunks: Vec<Vec<DiffLine>>, 480} 481 482/// Template data for the commit detail page. 483#[derive(Template, WebTemplate)] 484#[template(path = "commit.html")] 485struct CommitDetail { 486 repo: String, 487 sha: String, 488 author: String, 489 committer: String, 490 date: String, 491 committer_date: String, 492 message: String, 493 parents: Vec<(String, String)>, 494 refs: Vec<RefLabel>, 495 files_changed: u64, 496 lines_added: u64, 497 lines_removed: u64, 498 file_changes: Vec<FileChange>, 499 site: std::sync::Arc<SiteConfig>, 500} 501 502/// Displays a single commit: metadata, diff stat, and per-file inline diffs. 503#[allow(clippy::too_many_lines)] 504pub async fn detail( 505 State(state): State<AppState>, 506 RepoName(repo): RepoName, 507 Path(params): Path<CommitPath>, 508) -> Result<impl IntoResponse> { 509 let sha = params.sha; 510 511 let git_repo = git::open_repo(&state.root, &repo)?; 512 513 let oid = gix::ObjectId::from_hex(sha.as_bytes()) 514 .map_err(|_| Error::BadRequest(format!("invalid sha: {sha}")))?; 515 516 let commit = git_repo 517 .find_object(oid) 518 .map_err(|_| Error::NotFound(format!("commit {sha}")))? 519 .try_into_commit() 520 .map_err(|_| Error::BadRequest(format!("{sha} is not a commit")))?; 521 522 let sha = commit.id().to_string(); 523 524 let author = commit 525 .author() 526 .map(|a| format!("{} <{}>", a.name, a.email)) 527 .unwrap_or_default(); 528 529 let committer = commit 530 .committer() 531 .map(|c| format!("{} <{}>", c.name, c.email)) 532 .unwrap_or_default(); 533 534 let date = commit_date(&commit).unwrap_or_default(); 535 let committer_date = committer_date(&commit).unwrap_or_default(); 536 let message = commit.message_raw_sloppy().to_str_lossy().into_owned(); 537 538 let parents: Vec<(String, String)> = commit 539 .parent_ids() 540 .map(|id| { 541 let s = id.to_string(); 542 let short = s[..8].to_string(); 543 (s, short) 544 }) 545 .collect(); 546 547 // Collect refs (branches + tags) pointing to this commit. 548 let mut refs: Vec<RefLabel> = Vec::new(); 549 if let Ok(rs) = git_repo.references() { 550 for mut branch in git::flatten_refs(rs.local_branches()) { 551 if let Ok(c) = branch.peel_to_commit() 552 && c.id().to_string() == sha 553 { 554 refs.push(RefLabel { 555 name: branch.name().shorten().to_string(), 556 kind: "branch".into(), 557 }); 558 } 559 } 560 561 for mut tag in git::flatten_refs(rs.tags()) { 562 if let Ok(c) = tag.peel_to_commit() 563 && c.id().to_string() == sha 564 { 565 refs.push(RefLabel { 566 name: tag.name().shorten().to_string(), 567 kind: "tag".into(), 568 }); 569 } 570 } 571 } 572 573 // Compute diff stat against the first parent (or empty tree for root commits). 574 let commit_tree = commit.tree().corrupt()?; 575 576 let parent_tree = commit 577 .parent_ids() 578 .next() 579 .and_then(|pid| pid.object().ok()) 580 .and_then(|o| o.try_into_commit().ok()) 581 .and_then(|c| c.tree().ok()); 582 583 let mut resource_cache = git_repo.diff_resource_cache_for_tree_diff().corrupt()?; 584 585 let mut files_changed = 0u64; 586 let mut lines_added = 0u64; 587 let mut lines_removed = 0u64; 588 let mut file_changes: Vec<FileChange> = Vec::new(); 589 590 #[allow(clippy::option_if_let_else)] 591 let source_tree = match parent_tree { 592 Some(ref t) => t, 593 None => &git_repo.empty_tree(), 594 }; 595 596 source_tree 597 .changes() 598 .corrupt()? 599 .for_each_to_obtain_tree(&commit_tree, |change| { 600 // Skip directory entries - only show leaf-level file changes. 601 if change.entry_mode().is_tree() { 602 return Ok::<_, std::convert::Infallible>(ControlFlow::Continue(())); 603 } 604 605 let path = change.location().to_str_lossy().into_owned(); 606 let change_type = match change { 607 gix::object::tree::diff::Change::Addition { .. } => "A", 608 gix::object::tree::diff::Change::Deletion { .. } => "D", 609 gix::object::tree::diff::Change::Modification { .. } => "M", 610 gix::object::tree::diff::Change::Rewrite { .. } => "R", 611 }; 612 613 files_changed += 1; 614 615 let mut hunks = Vec::new(); 616 let mut added = 0u64; 617 let mut removed = 0u64; 618 619 if let Ok(platform) = change.diff(&mut resource_cache) { 620 platform 621 .resource_cache 622 .options 623 .skip_internal_diff_if_external_is_configured = false; 624 if let Ok(prep) = platform.resource_cache.prepare_diff() 625 && let Operation::InternalDiff { algorithm } = prep.operation 626 { 627 let input = prep.interned_input(); 628 let collector = DiffHunkCollector { 629 hunks: &mut hunks, 630 file_added: &mut added, 631 file_removed: &mut removed, 632 }; 633 let sink = UnifiedDiff::new(&input, collector, ContextSize::symmetrical(3)); 634 let _ = gix::diff::blob::diff(algorithm, &input, sink); 635 } 636 } 637 638 resource_cache.clear_resource_cache_keep_allocation(); 639 640 lines_added += added; 641 lines_removed += removed; 642 643 file_changes.push(FileChange { 644 path, 645 change_type: change_type.to_string(), 646 hunks, 647 }); 648 649 Ok::<_, std::convert::Infallible>(ControlFlow::Continue(())) 650 }) 651 .corrupt()?; 652 653 Ok(CommitDetail { 654 repo, 655 sha, 656 author, 657 committer, 658 date, 659 committer_date, 660 message, 661 parents, 662 refs, 663 files_changed, 664 lines_added, 665 lines_removed, 666 file_changes, 667 site: state.site, 668 }) 669}