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} 670 671#[cfg(test)] 672mod tests { 673 use super::*; 674 675 fn commit(sha: &str, parents: &[&str]) -> git::CommitEntry { 676 git::CommitEntry { 677 sha: sha.to_string(), 678 message: String::new(), 679 date: String::new(), 680 parents: parents.iter().map(|s| s.to_string()).collect(), 681 } 682 } 683 684 // --- compute_layout --- 685 686 #[test] 687 fn layout_linear_chain() { 688 let commits = vec![ 689 commit("C", &["B"]), 690 commit("B", &["A"]), 691 commit("A", &[]), 692 ]; 693 let layout = compute_layout(&commits, 0); 694 assert_eq!(layout.positions.len(), 3); 695 assert_eq!(layout.positions.get("C"), Some(&(0, 0))); 696 assert_eq!(layout.positions.get("B"), Some(&(1, 0))); 697 assert_eq!(layout.positions.get("A"), Some(&(2, 0))); 698 } 699 700 #[test] 701 fn layout_diamond_merge() { 702 let commits = vec![ 703 commit("D", &["B", "C"]), 704 commit("C", &["A"]), 705 commit("B", &["A"]), 706 commit("A", &[]), 707 ]; 708 let layout = compute_layout(&commits, 0); 709 assert_eq!(layout.positions.get("D"), Some(&(0, 0))); 710 assert_eq!(layout.positions.get("C"), Some(&(1, 1))); 711 assert_eq!(layout.positions.get("B"), Some(&(2, 0))); 712 assert_eq!(layout.positions.get("A"), Some(&(3, 1))); 713 } 714 715 #[test] 716 fn layout_root_commit() { 717 let commits = vec![commit("A", &[])]; 718 let layout = compute_layout(&commits, 0); 719 assert_eq!(layout.positions.get("A"), Some(&(0, 0))); 720 assert!(layout.active_at_page_start.is_empty()); 721 } 722 723 #[test] 724 fn layout_octopus_merge() { 725 let commits = vec![ 726 commit("E", &["B", "C", "D"]), 727 commit("D", &["A"]), 728 commit("C", &["A"]), 729 commit("B", &["A"]), 730 commit("A", &[]), 731 ]; 732 let layout = compute_layout(&commits, 0); 733 assert_eq!(layout.positions.get("E"), Some(&(0, 0))); 734 assert_eq!(layout.positions.get("D"), Some(&(1, 2))); 735 assert_eq!(layout.positions.get("C"), Some(&(2, 1))); 736 assert_eq!(layout.positions.get("B"), Some(&(3, 0))); 737 assert_eq!(layout.positions.get("A"), Some(&(4, 2))); 738 } 739 740 #[test] 741 fn layout_empty_input() { 742 let layout = compute_layout(&[], 0); 743 assert!(layout.positions.is_empty()); 744 assert!(layout.active_at_page_start.is_empty()); 745 } 746 747 #[test] 748 fn layout_page_start_captures_active() { 749 let commits = vec![ 750 commit("D", &["C"]), 751 commit("C", &["B"]), 752 commit("B", &["A"]), 753 commit("A", &[]), 754 ]; 755 let layout = compute_layout(&commits, 1); 756 assert_eq!(layout.active_at_page_start.len(), 1); 757 assert_eq!(layout.active_at_page_start[0], Some("C".to_string())); 758 } 759 760 #[test] 761 fn layout_lane_steal_then_free() { 762 let commits = vec![ 763 commit("C", &["B"]), 764 commit("B", &["A"]), 765 commit("A", &[]), 766 ]; 767 let layout = compute_layout(&commits, 0); 768 // C steals lane 0, B inherits it, A frees it 769 assert_eq!(layout.positions.get("C"), Some(&(0, 0))); 770 assert_eq!(layout.positions.get("B"), Some(&(1, 0))); 771 assert_eq!(layout.positions.get("A"), Some(&(2, 0))); 772 } 773 774 // --- render_page_svg --- 775 776 #[test] 777 fn svg_empty_range() { 778 let layout = compute_layout(&[], 0); 779 assert_eq!(render_page_svg(&[], &layout, 0, 0), ""); 780 } 781 782 #[test] 783 fn svg_single_commit() { 784 let commits = vec![commit("A", &[])]; 785 let layout = compute_layout(&commits, 0); 786 let svg = render_page_svg(&commits, &layout, 0, 1); 787 assert!(svg.starts_with(r#"<svg width="#), "expected SVG start, got: {svg}"); 788 assert!(svg.contains("<circle"), "expected circle element"); 789 assert!(svg.ends_with("</svg>")); 790 } 791 792 #[test] 793 fn svg_two_commits_same_lane() { 794 let commits = vec![commit("B", &["A"]), commit("A", &[])]; 795 let layout = compute_layout(&commits, 0); 796 let svg = render_page_svg(&commits, &layout, 0, 2); 797 assert!(svg.contains("<line"), "expected line element"); 798 assert_eq!(svg.matches("<line").count(), 1); 799 assert_eq!(svg.matches("<circle").count(), 2); 800 } 801 802 // --- DiffHunkCollector --- 803 804 #[test] 805 fn diff_hunk_context_only() { 806 let mut hunks = Vec::new(); 807 let mut added = 0; 808 let mut removed = 0; 809 let mut collector = DiffHunkCollector { 810 hunks: &mut hunks, 811 file_added: &mut added, 812 file_removed: &mut removed, 813 }; 814 let header = gix::diff::blob::unified_diff::HunkHeader { 815 before_hunk_start: 1, 816 before_hunk_len: 1, 817 after_hunk_start: 1, 818 after_hunk_len: 1, 819 }; 820 collector 821 .consume_hunk(header, &[(DiffLineKind::Context, b"keep")]) 822 .unwrap(); 823 assert_eq!(hunks.len(), 1); 824 assert_eq!(hunks[0].len(), 1); 825 assert_eq!(hunks[0][0].marker, "~"); 826 assert_eq!(hunks[0][0].content, "keep"); 827 assert_eq!(added, 0); 828 assert_eq!(removed, 0); 829 } 830 831 #[test] 832 fn diff_hunk_mixed_lines() { 833 let mut hunks = Vec::new(); 834 let mut added = 0; 835 let mut removed = 0; 836 let mut collector = DiffHunkCollector { 837 hunks: &mut hunks, 838 file_added: &mut added, 839 file_removed: &mut removed, 840 }; 841 let header = gix::diff::blob::unified_diff::HunkHeader { 842 before_hunk_start: 1, 843 before_hunk_len: 2, 844 after_hunk_start: 1, 845 after_hunk_len: 2, 846 }; 847 collector 848 .consume_hunk( 849 header, 850 &[ 851 (DiffLineKind::Context, b"ctx"), 852 (DiffLineKind::Add, b"new"), 853 (DiffLineKind::Remove, b"old"), 854 ], 855 ) 856 .unwrap(); 857 assert_eq!(hunks.len(), 1); 858 assert_eq!(hunks[0].len(), 3); 859 assert_eq!(hunks[0][0].marker, "~"); 860 assert_eq!(hunks[0][1].marker, "+"); 861 assert_eq!(hunks[0][2].marker, "-"); 862 assert_eq!(added, 1); 863 assert_eq!(removed, 1); 864 } 865 866 #[test] 867 fn diff_hunk_binary_content() { 868 let mut hunks = Vec::new(); 869 let mut added = 0; 870 let mut removed = 0; 871 let mut collector = DiffHunkCollector { 872 hunks: &mut hunks, 873 file_added: &mut added, 874 file_removed: &mut removed, 875 }; 876 let header = gix::diff::blob::unified_diff::HunkHeader { 877 before_hunk_start: 1, 878 before_hunk_len: 1, 879 after_hunk_start: 1, 880 after_hunk_len: 1, 881 }; 882 collector 883 .consume_hunk(header, &[(DiffLineKind::Add, b"\xff\xfe\x00\x01")]) 884 .unwrap(); 885 // Invalid UTF-8 should fall back to <binary> 886 assert_eq!(hunks[0][0].content, "<binary>"); 887 } 888 889 #[test] 890 fn diff_hunk_empty_input() { 891 let mut hunks = Vec::new(); 892 let mut added = 0; 893 let mut removed = 0; 894 let mut collector = DiffHunkCollector { 895 hunks: &mut hunks, 896 file_added: &mut added, 897 file_removed: &mut removed, 898 }; 899 let header = gix::diff::blob::unified_diff::HunkHeader { 900 before_hunk_start: 0, 901 before_hunk_len: 0, 902 after_hunk_start: 0, 903 after_hunk_len: 0, 904 }; 905 collector.consume_hunk(header, &[]).unwrap(); 906 assert_eq!(hunks.len(), 1); 907 assert!(hunks[0].is_empty()); 908 } 909}