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