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::filters; 23use crate::git::{self, GitResultExt as _, commit_date, committer_date}; 24use crate::routes::{AppState, RepoName}; 25 26use gix::diff::blob::UnifiedDiff; 27use gix::diff::blob::platform::prepare_diff::Operation; 28use gix::diff::blob::unified_diff::{ConsumeHunk, ContextSize, DiffLineKind, HunkHeader}; 29 30/// Number of commits per page in the log view. 31const PAGE_SIZE: usize = 50; 32 33// SVG graph layout constants. 34// `ROW_H` is the per-row height, `LANE_W` is the per-lane width (spacing 35// between parallel tracks), `NODE_R` is the dot radius, and `BEND_R` is 36// the corner radius used when an edge changes lanes. 37const ROW_H: f64 = 40.0; 38const LANE_W: f64 = 20.0; 39const NODE_R: f64 = 5.0; 40const OUTER_R: f64 = ROW_H / 2.0; 41const STROKE: f64 = 2.0; 42const Y_MID: f64 = ROW_H / 2.0; 43const BEND_R: f64 = 6.0; 44 45/// Color palette used for SVG graph lanes. 46/// Each lane gets one color from this cycle. 47const COLORS: &[&str] = &[ 48 "var(--blue)", 49 "var(--red)", 50 "var(--green)", 51 "var(--yellow)", 52 "var(--violet)", 53 "var(--magenta)", 54 "var(--cyan)", 55 "var(--orange)", 56]; 57 58/// (row, column) positions for every commit in the current page, plus a 59/// snapshot of active lanes entering the page from above so the SVG renderer 60/// can draw continuation edges. 61struct Layout { 62 positions: HashMap<String, (usize, usize)>, 63 active_at_page_start: Vec<Option<String>>, 64} 65 66/// Assigns each commit a (row, column) position for the SVG graph. 67/// 68/// Uses a column-stealing algorithm: a commit claims the column its first 69/// parent occupies, freeing the previous column for other branches. Lane 70/// re-use keeps the graph narrow. 71#[allow(clippy::option_if_let_else)] 72fn compute_layout(commits: &[git::CommitEntry], page_start: usize) -> Layout { 73 let mut active: Vec<Option<&str>> = vec![]; 74 let mut positions = HashMap::new(); 75 let mut active_at_page_start = vec![]; 76 77 for (row, commit) in commits.iter().enumerate() { 78 if row == page_start { 79 active_at_page_start = active 80 .iter() 81 .map(|opt| opt.map(std::string::ToString::to_string)) 82 .collect(); 83 } 84 85 let sha = commit.sha.as_str(); 86 let col = match active.iter().position(|s| *s == Some(sha)) { 87 Some(i) => i, 88 None => { 89 if let Some(i) = active.iter().position(std::option::Option::is_none) { 90 active[i] = Some(sha); 91 i 92 } else { 93 active.push(Some(sha)); 94 active.len() - 1 95 } 96 } 97 }; 98 99 positions.insert(commit.sha.clone(), (row, col)); 100 101 if commit.parents.is_empty() { 102 active[col] = None; 103 while matches!(active.last(), Some(None)) { 104 active.pop(); 105 } 106 } else { 107 let first = commit.parents[0].as_str(); 108 if active.iter().position(|s| *s == Some(first)) != Some(col) { 109 active[col] = None; 110 while matches!(active.last(), Some(None)) { 111 active.pop(); 112 } 113 if active.iter().all(|s| *s != Some(first)) { 114 match active.iter().position(std::option::Option::is_none) { 115 Some(i) => active[i] = Some(first), 116 None => active.push(Some(first)), 117 } 118 } 119 } 120 121 for parent in commit.parents.iter().skip(1) { 122 if active.contains(&Some(parent.as_str())) { 123 continue; 124 } 125 match active.iter().position(std::option::Option::is_none) { 126 Some(i) => active[i] = Some(parent), 127 None => active.push(Some(parent)), 128 } 129 } 130 } 131 } 132 133 Layout { 134 positions, 135 active_at_page_start, 136 } 137} 138 139/// Renders the SVG ancestry graph for commits[`page_start..page_end`]. 140/// 141/// Three passes: 142/// 1. Continuation edges entering from above the viewport. 143/// 2. Edges from each visible commit to its parents (straight lines for 144/// same-column, L-shaped bends for lane changes, S-shaped curves for 145/// merge arrows). 146/// 3. Node circles and lane-highlight backgrounds on top. 147#[allow(clippy::cast_precision_loss, clippy::too_many_lines)] 148fn render_page_svg( 149 commits: &[git::CommitEntry], 150 layout: &Layout, 151 page_start: usize, 152 page_end: usize, 153) -> String { 154 if page_start >= page_end { 155 return String::new(); 156 } 157 158 let mut all_cols: Vec<usize> = Vec::new(); 159 for c in &commits[page_start..page_end] { 160 if let Some(&(_, col)) = layout.positions.get(&c.sha) { 161 all_cols.push(col); 162 } 163 } 164 165 let min_col = all_cols.iter().copied().min().unwrap_or(0); 166 let page_max_col = all_cols.iter().copied().max().unwrap_or(0); 167 let clamp_col = |c: usize| c.max(min_col).min(page_max_col); 168 169 let svg_h = (page_end - page_start) as f64 * ROW_H; 170 let svg_w = ((page_max_col - min_col + 1) as f64).mul_add(LANE_W, OUTER_R); 171 172 let lx = |col: usize| -> f64 { ((col - min_col) as f64).mul_add(LANE_W, OUTER_R) }; 173 let ry = |abs_row: usize| -> f64 { 174 ((abs_row.saturating_sub(page_start)) as f64).mul_add(ROW_H, Y_MID) 175 }; 176 177 let mut body = String::new(); 178 179 // Pass 1: continuation edges entering from above. 180 for (col, opt_sha) in layout.active_at_page_start.iter().enumerate() { 181 let Some(sha) = opt_sha else { continue }; 182 let Some(&(abs_row, _)) = layout.positions.get(sha.as_str()) else { 183 continue; 184 }; 185 let x = lx(clamp_col(col)); 186 let c = COLORS[col % COLORS.len()]; 187 let y_to = if abs_row < page_start { 188 0.0 189 } else if abs_row < page_end { 190 ry(abs_row) 191 } else { 192 svg_h 193 }; 194 195 let _ = write!( 196 body, 197 r#"<line x1="{x:.1}" y1="0.0" x2="{x:.1}" y2="{y_to:.1}" stroke="{c}" stroke-width="{STROKE}"/>"# 198 ); 199 } 200 201 // Pass 2: edges originating from commits on this page. 202 for commit in &commits[page_start..page_end] { 203 let &(abs_row, col) = layout 204 .positions 205 .get(&commit.sha) 206 .expect("commit must have a layout position"); 207 let x1 = lx(col); 208 let y1 = ry(abs_row); 209 let c = COLORS[col % COLORS.len()]; 210 211 for (idx, parent_sha) in commit.parents.iter().enumerate() { 212 let (x2, y2, p_col) = match layout.positions.get(parent_sha.as_str()) { 213 Some(&(p_row, p_col)) if p_row >= page_start && p_row < page_end => { 214 (lx(p_col), ry(p_row), p_col) 215 } 216 Some(&(p_row, p_col)) => ( 217 lx(clamp_col(p_col)), 218 if p_row < page_start { 0.0 } else { svg_h }, 219 p_col, 220 ), 221 None => { 222 let _ = write!( 223 body, 224 r#"<line x1="{x1:.1}" y1="{y1:.1}" x2="{x1:.1}" y2="{svg_h:.1}" stroke="{c}" stroke-width="{STROKE}"/>"# 225 ); 226 continue; 227 } 228 }; 229 230 if col == p_col { 231 let _ = write!( 232 body, 233 r#"<line x1="{x1:.1}" y1="{y1:.1}" x2="{x2:.1}" y2="{y2:.1}" stroke="{c}" stroke-width="{STROKE}"/>"# 234 ); 235 } else if idx == 0 { 236 let r = BEND_R.min((x2 - x1).abs()); 237 let (arc_x, sweep) = if x2 < x1 { (x1 - r, 1) } else { (x1 + r, 0) }; 238 239 let _ = write!( 240 body, 241 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"/>"#, 242 y2 - r, 243 ); 244 } else { 245 let pc = COLORS[p_col % COLORS.len()]; 246 let r = BEND_R.min((x2 - x1).abs()); 247 let (arc_x, sweep) = if x2 > x1 { (x2 - r, 1) } else { (x2 + r, 0) }; 248 249 let _ = write!( 250 body, 251 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"/>"#, 252 y1 + r, 253 ); 254 } 255 } 256 } 257 258 // Pass 3: circles on top. 259 for commit in &commits[page_start..page_end] { 260 let &(abs_row, col) = layout 261 .positions 262 .get(&commit.sha) 263 .expect("commit must have a layout position"); 264 let cx = lx(col); 265 let cy = ry(abs_row); 266 let c = COLORS[col % COLORS.len()]; 267 let row_top = cy - Y_MID; 268 let rect_x = cx - OUTER_R; 269 270 let _ = write!( 271 body, 272 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"/>"#, 273 top = row_top + OUTER_R, 274 inner = OUTER_R, 275 right = rect_x + OUTER_R, 276 bot = row_top + ROW_H, 277 bot_sub = row_top + ROW_H - OUTER_R, 278 ); 279 280 let _ = write!( 281 body, 282 r#"<circle cx="{cx:.1}" cy="{cy:.1}" r="{NODE_R}" fill="{c}"/>"# 283 ); 284 } 285 286 format!( 287 r#"<svg width="{svg_w:.1}" height="{svg_h:.1}" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">{body}</svg>"# 288 ) 289} 290 291/// Query parameters for the commit log page. 292#[derive(Deserialize, Default)] 293pub struct PageQuery { 294 /// Cursor commit SHA — show commits older than this one (exclusive). 295 /// When absent, the log starts from HEAD. 296 #[serde(default)] 297 after: Option<String>, 298} 299 300/// A branch or tag ref pointing at a commit. 301struct RefLabel { 302 name: String, 303 kind: String, 304} 305 306/// A single row of the commit log, ready for the template. 307struct CommitRow { 308 sha: String, 309 message: String, 310 date: String, 311 color: String, 312 refs: Vec<RefLabel>, 313} 314 315/// Template data for the paginated commit log page. 316#[derive(Template, WebTemplate)] 317#[template(path = "commit_list.html")] 318struct CommitList { 319 repo: String, 320 graph_svg: String, 321 commits: Vec<CommitRow>, 322 /// Cursor used for this page (`None` means we started from HEAD). 323 after: Option<String>, 324 /// Cursor for the next page of older commits (`None` means no more pages). 325 next_cursor: Option<String>, 326 site: std::sync::Arc<SiteConfig>, 327} 328 329/// Renders a paginated commit log with an SVG ancestry graph. 330pub async fn list( 331 State(state): State<AppState>, 332 RepoName(repo): RepoName, 333 Query(q): Query<PageQuery>, 334) -> Result<impl IntoResponse> { 335 let git_repo = git::open_repo(&state.root, &repo)?; 336 337 let after = q.after.filter(|s| !s.is_empty()); 338 339 // Load cursor commit (for graph context) + PAGE_SIZE visible + 1 overflow. 340 // The cursor commit (index 0) provides incoming-edge context for the SVG 341 // renderer; it is skipped below via `page_start`. 342 let load_count = 1 + PAGE_SIZE + 1; 343 let entries = git::load_commits(&git_repo, after.as_deref(), load_count)?; 344 345 let page_start = if after.is_some() { 1 } else { 0 }; 346 let visible_end = (page_start + PAGE_SIZE).min(entries.len()); 347 let has_next = visible_end > page_start && entries.len() > page_start + PAGE_SIZE; 348 349 let layout = compute_layout(&entries, page_start); 350 let graph_svg = render_page_svg(&entries, &layout, page_start, visible_end); 351 352 let mut refs_by_sha: HashMap<String, Vec<RefLabel>> = HashMap::new(); 353 if let Ok(refs) = git_repo.references() { 354 for mut branch in git::flatten_refs(refs.local_branches()) { 355 if let Ok(commit) = branch.peel_to_commit() { 356 refs_by_sha 357 .entry(commit.id().to_string()) 358 .or_default() 359 .push(RefLabel { 360 name: branch.name().shorten().to_string(), 361 kind: "branch".into(), 362 }); 363 } 364 } 365 366 for mut tag in git::flatten_refs(refs.tags()) { 367 if let Ok(commit) = tag.peel_to_commit() { 368 refs_by_sha 369 .entry(commit.id().to_string()) 370 .or_default() 371 .push(RefLabel { 372 name: tag.name().shorten().to_string(), 373 kind: "tag".into(), 374 }); 375 } 376 } 377 } 378 379 let commits = entries[page_start..visible_end] 380 .iter() 381 .map(|e| CommitRow { 382 color: layout 383 .positions 384 .get(&e.sha) 385 .map_or("", |&(_, col)| COLORS[col % COLORS.len()]) 386 .to_string(), 387 sha: e.sha.clone(), 388 message: e.message.clone(), 389 date: e.date.clone(), 390 refs: refs_by_sha.remove(e.sha.as_str()).unwrap_or_default(), 391 }) 392 .collect(); 393 394 let next_cursor = if has_next { 395 // has_next is only true when visible_end > page_start, so this is safe. 396 Some(entries[visible_end - 1].sha.clone()) 397 } else { 398 None 399 }; 400 401 Ok(CommitList { 402 repo, 403 graph_svg, 404 commits, 405 after, 406 next_cursor, 407 site: state.site, 408 }) 409} 410 411/// Path parameter for the commit detail page. 412#[derive(Deserialize)] 413pub(super) struct CommitPath { 414 sha: String, 415} 416 417/// A single line inside a diff hunk, ready for the template. 418/// 419/// `marker` is the visible prefix character (`+`, `-`, or `~`). 420/// `css_class` is the CSS class suffix used to color the marker. 421/// `content` is the rest of the line. 422struct DiffLine { 423 marker: String, 424 css_class: String, 425 content: String, 426} 427 428/// Collects hunks produced by `UnifiedDiff` into a `Vec<Vec<DiffLine>>`, 429/// counting added and removed lines at the same time. 430struct DiffHunkCollector<'a> { 431 hunks: &'a mut Vec<Vec<DiffLine>>, 432 file_added: &'a mut u64, 433 file_removed: &'a mut u64, 434} 435 436impl ConsumeHunk for DiffHunkCollector<'_> { 437 type Out = (); 438 439 fn consume_hunk( 440 &mut self, 441 _header: HunkHeader, 442 lines: &[(DiffLineKind, &[u8])], 443 ) -> std::io::Result<()> { 444 let mut hunk = Vec::new(); 445 for (kind, content) in lines { 446 let content_str = std::str::from_utf8(content) 447 .unwrap_or("<binary>") 448 .to_string(); 449 450 let (marker, css_class) = match kind { 451 DiffLineKind::Context => ("~".into(), "ctx".into()), 452 DiffLineKind::Add => ("+".into(), "+".into()), 453 DiffLineKind::Remove => ("-".into(), "-".into()), 454 }; 455 456 match kind { 457 DiffLineKind::Add => *self.file_added += 1, 458 DiffLineKind::Remove => *self.file_removed += 1, 459 DiffLineKind::Context => {} 460 } 461 462 hunk.push(DiffLine { 463 marker, 464 css_class, 465 content: content_str, 466 }); 467 } 468 469 self.hunks.push(hunk); 470 Ok(()) 471 } 472 473 fn finish(self) -> Self::Out {} 474} 475 476/// A single file touched by a commit, with its inline diff hunks. 477struct FileChange { 478 path: String, 479 change_type: String, 480 hunks: Vec<Vec<DiffLine>>, 481} 482 483/// Template data for the commit detail page. 484#[derive(Template, WebTemplate)] 485#[template(path = "commit.html")] 486struct CommitDetail { 487 repo: String, 488 sha: String, 489 author: String, 490 committer: String, 491 date: String, 492 committer_date: String, 493 message: String, 494 parents: Vec<(String, String)>, 495 refs: Vec<RefLabel>, 496 files_changed: u64, 497 lines_added: u64, 498 lines_removed: u64, 499 file_changes: Vec<FileChange>, 500 site: std::sync::Arc<SiteConfig>, 501} 502 503/// Displays a single commit: metadata, diff stat, and per-file inline diffs. 504#[allow(clippy::too_many_lines)] 505pub async fn detail( 506 State(state): State<AppState>, 507 RepoName(repo): RepoName, 508 Path(params): Path<CommitPath>, 509) -> Result<impl IntoResponse> { 510 let sha = params.sha; 511 512 let git_repo = git::open_repo(&state.root, &repo)?; 513 514 let oid = gix::ObjectId::from_hex(sha.as_bytes()) 515 .map_err(|_| Error::BadRequest(format!("invalid sha: {sha}")))?; 516 517 let commit = git_repo 518 .find_object(oid) 519 .map_err(|_| Error::NotFound(format!("commit {sha}")))? 520 .try_into_commit() 521 .map_err(|_| Error::BadRequest(format!("{sha} is not a commit")))?; 522 523 let sha = commit.id().to_string(); 524 525 let author = commit 526 .author() 527 .map(|a| format!("{} <{}>", a.name, a.email)) 528 .unwrap_or_default(); 529 530 let committer = commit 531 .committer() 532 .map(|c| format!("{} <{}>", c.name, c.email)) 533 .unwrap_or_default(); 534 535 let date = commit_date(&commit).unwrap_or_default(); 536 let committer_date = committer_date(&commit).unwrap_or_default(); 537 let message = commit.message_raw_sloppy().to_str_lossy().into_owned(); 538 539 let parents: Vec<(String, String)> = commit 540 .parent_ids() 541 .map(|id| { 542 let s = id.to_string(); 543 let short = s[..8].to_string(); 544 (s, short) 545 }) 546 .collect(); 547 548 // Collect refs (branches + tags) pointing to this commit. 549 let mut refs: Vec<RefLabel> = Vec::new(); 550 if let Ok(rs) = git_repo.references() { 551 for mut branch in git::flatten_refs(rs.local_branches()) { 552 if let Ok(c) = branch.peel_to_commit() 553 && c.id().to_string() == sha 554 { 555 refs.push(RefLabel { 556 name: branch.name().shorten().to_string(), 557 kind: "branch".into(), 558 }); 559 } 560 } 561 562 for mut tag in git::flatten_refs(rs.tags()) { 563 if let Ok(c) = tag.peel_to_commit() 564 && c.id().to_string() == sha 565 { 566 refs.push(RefLabel { 567 name: tag.name().shorten().to_string(), 568 kind: "tag".into(), 569 }); 570 } 571 } 572 } 573 574 // Compute diff stat against the first parent (or empty tree for root commits). 575 let commit_tree = commit.tree().corrupt()?; 576 577 let parent_tree = commit 578 .parent_ids() 579 .next() 580 .and_then(|pid| pid.object().ok()) 581 .and_then(|o| o.try_into_commit().ok()) 582 .and_then(|c| c.tree().ok()); 583 584 let mut resource_cache = git_repo.diff_resource_cache_for_tree_diff().corrupt()?; 585 586 let mut files_changed = 0u64; 587 let mut lines_added = 0u64; 588 let mut lines_removed = 0u64; 589 let mut file_changes: Vec<FileChange> = Vec::new(); 590 591 #[allow(clippy::option_if_let_else)] 592 let source_tree = match parent_tree { 593 Some(ref t) => t, 594 None => &git_repo.empty_tree(), 595 }; 596 597 source_tree 598 .changes() 599 .corrupt()? 600 .for_each_to_obtain_tree(&commit_tree, |change| { 601 // Skip directory entries - only show leaf-level file changes. 602 if change.entry_mode().is_tree() { 603 return Ok::<_, std::convert::Infallible>(ControlFlow::Continue(())); 604 } 605 606 let path = change.location().to_str_lossy().into_owned(); 607 let change_type = match change { 608 gix::object::tree::diff::Change::Addition { .. } => "A", 609 gix::object::tree::diff::Change::Deletion { .. } => "D", 610 gix::object::tree::diff::Change::Modification { .. } => "M", 611 gix::object::tree::diff::Change::Rewrite { .. } => "R", 612 }; 613 614 files_changed += 1; 615 616 let mut hunks = Vec::new(); 617 let mut added = 0u64; 618 let mut removed = 0u64; 619 620 if let Ok(platform) = change.diff(&mut resource_cache) { 621 platform 622 .resource_cache 623 .options 624 .skip_internal_diff_if_external_is_configured = false; 625 if let Ok(prep) = platform.resource_cache.prepare_diff() 626 && let Operation::InternalDiff { algorithm } = prep.operation 627 { 628 let input = prep.interned_input(); 629 let collector = DiffHunkCollector { 630 hunks: &mut hunks, 631 file_added: &mut added, 632 file_removed: &mut removed, 633 }; 634 let sink = UnifiedDiff::new(&input, collector, ContextSize::symmetrical(3)); 635 let _ = gix::diff::blob::diff(algorithm, &input, sink); 636 } 637 } 638 639 resource_cache.clear_resource_cache_keep_allocation(); 640 641 lines_added += added; 642 lines_removed += removed; 643 644 file_changes.push(FileChange { 645 path, 646 change_type: change_type.to_string(), 647 hunks, 648 }); 649 650 Ok::<_, std::convert::Infallible>(ControlFlow::Continue(())) 651 }) 652 .corrupt()?; 653 654 Ok(CommitDetail { 655 repo, 656 sha, 657 author, 658 committer, 659 date, 660 committer_date, 661 message, 662 parents, 663 refs, 664 files_changed, 665 lines_added, 666 lines_removed, 667 file_changes, 668 site: state.site, 669 }) 670} 671 672#[cfg(test)] 673mod tests { 674 use super::*; 675 676 fn commit(sha: &str, parents: &[&str]) -> git::CommitEntry { 677 git::CommitEntry { 678 sha: sha.to_string(), 679 message: String::new(), 680 date: String::new(), 681 parents: parents.iter().map(|s| s.to_string()).collect(), 682 } 683 } 684 685 // --- compute_layout --- 686 687 #[test] 688 fn layout_linear_chain() { 689 let commits = vec![ 690 commit("C", &["B"]), 691 commit("B", &["A"]), 692 commit("A", &[]), 693 ]; 694 let layout = compute_layout(&commits, 0); 695 assert_eq!(layout.positions.len(), 3); 696 assert_eq!(layout.positions.get("C"), Some(&(0, 0))); 697 assert_eq!(layout.positions.get("B"), Some(&(1, 0))); 698 assert_eq!(layout.positions.get("A"), Some(&(2, 0))); 699 } 700 701 #[test] 702 fn layout_diamond_merge() { 703 let commits = vec![ 704 commit("D", &["B", "C"]), 705 commit("C", &["A"]), 706 commit("B", &["A"]), 707 commit("A", &[]), 708 ]; 709 let layout = compute_layout(&commits, 0); 710 assert_eq!(layout.positions.get("D"), Some(&(0, 0))); 711 assert_eq!(layout.positions.get("C"), Some(&(1, 1))); 712 assert_eq!(layout.positions.get("B"), Some(&(2, 0))); 713 assert_eq!(layout.positions.get("A"), Some(&(3, 1))); 714 } 715 716 #[test] 717 fn layout_root_commit() { 718 let commits = vec![commit("A", &[])]; 719 let layout = compute_layout(&commits, 0); 720 assert_eq!(layout.positions.get("A"), Some(&(0, 0))); 721 assert!(layout.active_at_page_start.is_empty()); 722 } 723 724 #[test] 725 fn layout_octopus_merge() { 726 let commits = vec![ 727 commit("E", &["B", "C", "D"]), 728 commit("D", &["A"]), 729 commit("C", &["A"]), 730 commit("B", &["A"]), 731 commit("A", &[]), 732 ]; 733 let layout = compute_layout(&commits, 0); 734 assert_eq!(layout.positions.get("E"), Some(&(0, 0))); 735 assert_eq!(layout.positions.get("D"), Some(&(1, 2))); 736 assert_eq!(layout.positions.get("C"), Some(&(2, 1))); 737 assert_eq!(layout.positions.get("B"), Some(&(3, 0))); 738 assert_eq!(layout.positions.get("A"), Some(&(4, 2))); 739 } 740 741 #[test] 742 fn layout_empty_input() { 743 let layout = compute_layout(&[], 0); 744 assert!(layout.positions.is_empty()); 745 assert!(layout.active_at_page_start.is_empty()); 746 } 747 748 #[test] 749 fn layout_page_start_captures_active() { 750 let commits = vec![ 751 commit("D", &["C"]), 752 commit("C", &["B"]), 753 commit("B", &["A"]), 754 commit("A", &[]), 755 ]; 756 let layout = compute_layout(&commits, 1); 757 assert_eq!(layout.active_at_page_start.len(), 1); 758 assert_eq!(layout.active_at_page_start[0], Some("C".to_string())); 759 } 760 761 #[test] 762 fn layout_lane_steal_then_free() { 763 let commits = vec![ 764 commit("C", &["B"]), 765 commit("B", &["A"]), 766 commit("A", &[]), 767 ]; 768 let layout = compute_layout(&commits, 0); 769 // C steals lane 0, B inherits it, A frees it 770 assert_eq!(layout.positions.get("C"), Some(&(0, 0))); 771 assert_eq!(layout.positions.get("B"), Some(&(1, 0))); 772 assert_eq!(layout.positions.get("A"), Some(&(2, 0))); 773 } 774 775 // --- render_page_svg --- 776 777 #[test] 778 fn svg_empty_range() { 779 let layout = compute_layout(&[], 0); 780 assert_eq!(render_page_svg(&[], &layout, 0, 0), ""); 781 } 782 783 #[test] 784 fn svg_single_commit() { 785 let commits = vec![commit("A", &[])]; 786 let layout = compute_layout(&commits, 0); 787 let svg = render_page_svg(&commits, &layout, 0, 1); 788 assert!(svg.starts_with(r#"<svg width="#), "expected SVG start, got: {svg}"); 789 assert!(svg.contains("<circle"), "expected circle element"); 790 assert!(svg.ends_with("</svg>")); 791 } 792 793 #[test] 794 fn svg_two_commits_same_lane() { 795 let commits = vec![commit("B", &["A"]), commit("A", &[])]; 796 let layout = compute_layout(&commits, 0); 797 let svg = render_page_svg(&commits, &layout, 0, 2); 798 assert!(svg.contains("<line"), "expected line element"); 799 assert_eq!(svg.matches("<line").count(), 1); 800 assert_eq!(svg.matches("<circle").count(), 2); 801 } 802 803 // --- DiffHunkCollector --- 804 805 #[test] 806 fn diff_hunk_context_only() { 807 let mut hunks = Vec::new(); 808 let mut added = 0; 809 let mut removed = 0; 810 let mut collector = DiffHunkCollector { 811 hunks: &mut hunks, 812 file_added: &mut added, 813 file_removed: &mut removed, 814 }; 815 let header = gix::diff::blob::unified_diff::HunkHeader { 816 before_hunk_start: 1, 817 before_hunk_len: 1, 818 after_hunk_start: 1, 819 after_hunk_len: 1, 820 }; 821 collector 822 .consume_hunk(header, &[(DiffLineKind::Context, b"keep")]) 823 .unwrap(); 824 assert_eq!(hunks.len(), 1); 825 assert_eq!(hunks[0].len(), 1); 826 assert_eq!(hunks[0][0].marker, "~"); 827 assert_eq!(hunks[0][0].content, "keep"); 828 assert_eq!(added, 0); 829 assert_eq!(removed, 0); 830 } 831 832 #[test] 833 fn diff_hunk_mixed_lines() { 834 let mut hunks = Vec::new(); 835 let mut added = 0; 836 let mut removed = 0; 837 let mut collector = DiffHunkCollector { 838 hunks: &mut hunks, 839 file_added: &mut added, 840 file_removed: &mut removed, 841 }; 842 let header = gix::diff::blob::unified_diff::HunkHeader { 843 before_hunk_start: 1, 844 before_hunk_len: 2, 845 after_hunk_start: 1, 846 after_hunk_len: 2, 847 }; 848 collector 849 .consume_hunk( 850 header, 851 &[ 852 (DiffLineKind::Context, b"ctx"), 853 (DiffLineKind::Add, b"new"), 854 (DiffLineKind::Remove, b"old"), 855 ], 856 ) 857 .unwrap(); 858 assert_eq!(hunks.len(), 1); 859 assert_eq!(hunks[0].len(), 3); 860 assert_eq!(hunks[0][0].marker, "~"); 861 assert_eq!(hunks[0][1].marker, "+"); 862 assert_eq!(hunks[0][2].marker, "-"); 863 assert_eq!(added, 1); 864 assert_eq!(removed, 1); 865 } 866 867 #[test] 868 fn diff_hunk_binary_content() { 869 let mut hunks = Vec::new(); 870 let mut added = 0; 871 let mut removed = 0; 872 let mut collector = DiffHunkCollector { 873 hunks: &mut hunks, 874 file_added: &mut added, 875 file_removed: &mut removed, 876 }; 877 let header = gix::diff::blob::unified_diff::HunkHeader { 878 before_hunk_start: 1, 879 before_hunk_len: 1, 880 after_hunk_start: 1, 881 after_hunk_len: 1, 882 }; 883 collector 884 .consume_hunk(header, &[(DiffLineKind::Add, b"\xff\xfe\x00\x01")]) 885 .unwrap(); 886 // Invalid UTF-8 should fall back to <binary> 887 assert_eq!(hunks[0][0].content, "<binary>"); 888 } 889 890 #[test] 891 fn diff_hunk_empty_input() { 892 let mut hunks = Vec::new(); 893 let mut added = 0; 894 let mut removed = 0; 895 let mut collector = DiffHunkCollector { 896 hunks: &mut hunks, 897 file_added: &mut added, 898 file_removed: &mut removed, 899 }; 900 let header = gix::diff::blob::unified_diff::HunkHeader { 901 before_hunk_start: 0, 902 before_hunk_len: 0, 903 after_hunk_start: 0, 904 after_hunk_len: 0, 905 }; 906 collector.consume_hunk(header, &[]).unwrap(); 907 assert_eq!(hunks.len(), 1); 908 assert!(hunks[0].is_empty()); 909 } 910}