char/sorcery
static-files based git repo viewer
git clone https://git.t4t.associates/char/sorcery
f92b8f4
main
1use std:: collections:: BTreeMap ; 2use std:: fmt:: Write as _; 3use std:: fs; 4use std:: path::{ Path , PathBuf }; 5use std:: sync:: Arc ; 6 7use anyhow::{ Context , Result }; 8use rayon:: prelude:: * ; 9// Safe for text and double-quoted attribute contexts. 10use html_escape:: encode_double_quoted_attributeas escape; 11use percent_encoding::{ AsciiSet , NON_ALPHANUMERIC , PercentEncode , percent_encode}; 12 13use crate :: catalog:: Repository ; 14use crate :: highlight::{ Cache as GrammarCache , Highlighter }; 15 16/// Blobs larger than this get a raw download instead of a rendered page. 17const MAX_RENDER_BYTES : u64 =1 <<20 ; 18const PATH : & AsciiSet =& NON_ALPHANUMERIC 19. remove ( b'-' ) 20. remove ( b'.' ) 21. remove ( b'_' ) 22. remove ( b'~' ) 23. remove ( b'/' ); 24 25/// Percent-encodes a slash-separated path for use in a URI. 26pub ( crate ) fn encode_path ( path : & str ) ->PercentEncode < ' _ > { 27percent_encode ( path. as_bytes (), PATH ) 28} 29 30/// `tip` names the page's ref and the commit it points at; the client reads 31/// them from `<main data-ref data-tip>` to resolve hash routes relative to 32/// the page. 33fn page ( 34instance_name : & str , 35title : & str , 36description : & str , 37tip : Option <( & str , gix:: ObjectId )>, 38body : & str , 39) ->String { 40format! ( 41"<!doctype html>\n\ 42<html lang=\"en\">\n\ 43<head>\n\ 44<meta charset=\"utf-8\">\n\ 45<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\ 46<title>{title}</title>\n\ 47<meta name=\"description\" content=\"{description}\">\n\ 48<meta property=\"og:title\" content=\"{title}\">\n\ 49<meta property=\"og:description\" content=\"{description}\">\n\ 50<meta property=\"og:type\" content=\"website\">\n\ 51<meta property=\"og:site_name\" content=\"{instance_name}\">\n\ 52<link rel=\"stylesheet\" href=\"/css/style.css?v={format_version}\">\n\ 53<script type=\"module\" src=\"/js/main.js?v={format_version}\"></script>\n\ 54</head>\n\ 55<body>\n<main{tip}>\n{noscript}{body}</main>\n</body>\n\ 56</html>\n" , 57 instance_name =escape ( instance_name), 58 title =escape ( title), 59 description =escape ( description), 60 format_version =crate :: generate:: OUTPUT_FORMAT_VERSION , 61 tip = tip 62. map ( |( name, oid) |format! ( " data-ref=\"{}\" data-tip=\"{oid}\"" , escape ( name))) 63. unwrap_or_default (), 64// only repo pages lose anything to a missing client 65 noscript =if tip. is_some () { NOSCRIPT } else { "" }, 66) 67} 68 69const NOSCRIPT : & str =r#"<noscript> 70<div class="callout"> 71<p> 72hey! this is a static git repo viewer. commit diffs, file history, 73older commits and other revisions won't load without javascript. 74</p> 75<p> 76you are still able to view source files on branch tips, 77but the experience is degraded. 78</p> 79</div> 80</noscript> 81"# ; 82 83pub ( crate ) fn catalog_index ( instance_name : & str , repositories : & [ Repository ]) ->String { 84let mut body =String :: from ( "<h1>repositories</h1>\n" ); 85if repositories. is_empty () { 86 body. push_str ( "<p class=\"meta\">no repositories found</p>\n" ); 87} 88 89let mut current_user =None ; 90for repoin repositories{ 91if current_user !=Some ( repo. user . as_str ()) { 92if current_user. is_some () { 93 body. push_str ( "</ul>\n" ); 94} 95 current_user =Some ( & repo. user ); 96 body. push_str ( & format! ( 97"<h2>{}</h2>\n<ul class=\"catalog\">\n" , 98escape ( & repo. user ), 99)); 100} 101let description = repo 102. description 103. as_deref () 104. map ( |d|escape ( d). to_string ()) 105. unwrap_or_default (); 106 body. push_str ( & format! ( 107"<li><a href=\"/{user}/{repo}/\"><span class=\"repo-name\">{repo_name}</span> <span class=\"msg\">{description}</span></a></li>\n" , 108 user =encode_path ( & repo. user ), 109 repo =encode_path ( & repo. name ), 110 repo_name =escape ( & repo. name ), 111)); 112} 113if current_user. is_some () { 114 body. push_str ( "</ul>\n" ); 115} 116 117let title =format! ( "repositories - {instance_name}" ); 118let description =format! ( "Git repositories hosted on {instance_name}." ); 119page ( instance_name, & title, & description, None , & body) 120} 121 122# [ derive ( Clone , Copy , Debug , PartialEq , Eq )] 123pub enum RefKind { 124Branch , 125Tag , 126} 127 128# [ derive ( Clone , Copy , PartialEq , Eq )] 129pub enum StaticMode { 130Highlighted , 131/// Blob pages carry escaped plain text; the client highlights them. 132Plain , 133} 134 135# [ derive ( Clone , PartialEq , Eq , serde :: Deserialize , serde :: Serialize )] 136pub ( crate ) struct BlobVersion { 137pub oid : String , 138pub mode : u16 , 139pub touched : String , 140} 141 142# [ derive ( Clone , Copy )] 143pub ( crate ) struct BlobReuse < ' a > { 144pub versions : &' a BTreeMap < String , BlobVersion >, 145pub previous : Option <( &' a Path , &' a BTreeMap < String , BlobVersion >)>, 146} 147 148pub struct Site < ' a > { 149pub repo : &' a gix:: Repository , 150pub instance_name : String , 151pub name : String , 152pub base_url : String , 153pub description : Option < String >, 154pub clone_url : Option < String >, 155} 156 157impl Site < ' _ > { 158/// Display names like `user/repo` shorten to the repo part in crumbs. 159fn crumb_name ( & self ) ->& str { 160self . name . rsplit ( '/' ). next (). unwrap_or ( & self . name ) 161} 162 163fn ref_href ( & self , tip : & RefTip ) ->String { 164match tip. static_mode { 165None =>format! ( "{}#{}" , self . base_url , tip. commit_id ), 166Some ( _) =>{ 167format! ( "{}ref/{}/" , self . base_url , encode_path ( & tip. name )) 168} 169} 170} 171 172/// Name, description and clone command; the same on every page of the repo. 173fn repo_header ( & self ) ->String { 174let mut html =format! ( "<header class=\"repo\"><div>\n<h1>{}</h1>\n" , escape ( & self . name )); 175if let Some ( desc) =& self . description { 176let _ =writeln! ( html, "<p class=\"desc\">{}</p>" , escape ( desc)); 177} 178 html. push_str ( "</div>\n" ); 179if let Some ( url) =& self . clone_url { 180let _ =writeln! ( html, "<pre class=\"clone\">git clone {}</pre>" , escape ( url)); 181} 182 html. push_str ( "</header>\n" ); 183 html 184} 185} 186 187/// Author, summary, short sha and date of one commit, as a status line. 188fn commit_panel ( repo : & gix:: Repository , oid : gix:: ObjectId ) ->String { 189let Ok ( commit) = repo. find_object ( oid). map ( |o| o. try_into_commit ()) else { 190return String :: new (); 191}; 192let Ok ( commit) = commitelse { 193return String :: new (); 194}; 195let summary = commit. message (). map ( |m| m. summary (). to_string ()). unwrap_or_default (); 196let author = commit. author (). map ( |a| a. name . to_string ()). unwrap_or_default (); 197format! ( 198"<p class=\"snapshot-commit\">\ 199<span class=\"commit-author\">{author}</span>\ 200<span class=\"commit-message\" data-commit=\"{oid}\">{summary}</span>\ 201<span class=\"snapshot-sha sha\" data-commit=\"{oid}\">{short}</span>\ 202<time>{date}</time></p>\n" , 203 author =escape ( & author), 204 summary =escape ( & summary), 205 short = commit. id (). shorten_or_id (), 206 date =escape ( & date_of ( & commit)), 207) 208} 209 210pub struct RefTip { 211pub kind : RefKind , 212pub name : String , 213pub commit_id : gix:: ObjectId , 214pub static_mode : Option < StaticMode >, 215} 216 217/// All local branches and tags, peeled to commits. Refs that don't peel to a 218/// commit (e.g. tags of blobs) are skipped. 219pub fn list_refs ( repo : & gix:: Repository ) ->Result < Vec < RefTip >> { 220let platform = repo. references () ?; 221let mut tips: Vec < RefTip > =Vec :: new (); 222for ( kind, iter) in [ 223( RefKind :: Branch , platform. local_branches () ?), 224( RefKind :: Tag , platform. tags () ?), 225] { 226for rin iter{ 227let mut r = r. map_err ( |e| anyhow:: anyhow!( "failed to iterate refs: {e}" )) ?; 228let name = r. name (). shorten (). to_string (); 229// A branch and tag may share a short name. The flat /ref/<name> 230// namespace gives the branch precedence. 231if kind ==RefKind :: Tag 232 && tips. iter (). any ( |t| t. kind ==RefKind :: Branch && t. name == name) 233{ 234continue ; 235} 236if let Ok ( commit) = r. peel_to_commit () { 237 tips. push ( RefTip { 238 kind, 239 name, 240commit_id : commit. id , 241static_mode : None , 242}); 243} 244} 245} 246Ok ( tips) 247} 248 249/// The branch HEAD points at, if it exists among `tips` (falling back to the 250/// first branch, matching forge behaviour for detached/unborn HEADs). 251pub fn head_tip < ' a >( repo : & gix:: Repository , tips : &' a [ RefTip ]) ->Option < &' a RefTip > { 252let head = repo. head_name (). ok (). flatten (). map ( |n| n. shorten (). to_string ()); 253 tips. iter () 254. find ( |t| t. kind ==RefKind :: Branch &&Some ( & t. name ) == head. as_ref ()) 255. or_else ( || tips. iter (). find ( |t| t. kind ==RefKind :: Branch )) 256} 257 258pub fn write_file ( path : & Path , contents : impl AsRef <[ u8 ]>) ->Result <()> { 259if let Some ( parent) = path. parent () { 260 fs:: create_dir_all ( parent) ?; 261} 262 fs:: write ( path, contents). with_context ( ||format! ( "writing {}" , path. display ())) 263} 264 265/// Renders the full static view of one ref (tree pages, blob pages, raw files 266/// for binaries) into `out`, which is typically a staging dir later swapped 267/// into place at `<site>/ref/<name>/`. 268pub fn render_ref ( 269site : & Site , 270tips : & [ RefTip ], 271tip : & RefTip , 272commit : & gix:: Commit , 273out : & Path , 274reuse : Option < BlobReuse < ' _ >>, 275highlights : Arc < GrammarCache >, 276) ->Result <()> { 277let mut r =RefRenderer :: new ( site, tips, tip, commit, out, highlights. clone ()); 278let tree = commit. tree () ?; 279let mut blobs =Vec :: new (); 280 r. walk ( & tree, & mut Vec :: new (), & mut blobs) ?; 281 282// Blob pages dominate build time and are independent of each other, so 283// fan out; the repository is only ever read, so each batch gets its own 284// thread-local handle and reusable highlighter. 285let ctx =BlobContext { 286instance_name : site. instance_name . clone (), 287name : site. name . clone (), 288base_url : site. base_url . clone (), 289repo_header : site. repo_header (), 290label : tip. name . clone (), 291tip_id : commit. id , 292highlight : tip. static_mode !=Some ( StaticMode :: Plain ), 293out : out. to_owned (), 294 reuse, 295 highlights, 296}; 297let repo = site. repo . clone (). into_sync (); 298let chunk_size = blobs. len (). div_ceil ( rayon:: current_num_threads ()). max ( 1 ); 299 blobs. par_chunks_mut ( chunk_size). try_for_each ( |jobs|{ 300let repo = repo. to_thread_local (); 301let mut highlighter =Highlighter :: new ( ctx. highlights . clone ()); 302 jobs. iter_mut () 303. try_for_each ( |job|blob_page ( & ctx, & repo, & mut highlighter, job)) 304}) 305} 306 307/// Everything a blob page needs besides the per-worker repository handle and 308/// highlighter. 309struct BlobContext < ' a > { 310instance_name : String , 311name : String , 312base_url : String , 313repo_header : String , 314label : String , 315tip_id : gix:: ObjectId , 316highlight : bool , 317out : PathBuf , 318reuse : Option < BlobReuse < ' a >>, 319highlights : Arc < GrammarCache >, 320} 321 322struct BlobJob { 323path : Vec < String >, 324oid : gix:: ObjectId , 325is_link : bool , 326/// Switcher and crumbs, prebuilt during the walk: they need ref-wide 327/// state that the workers don't carry. 328chrome : String , 329} 330 331fn blob_page ( 332ctx : & BlobContext , 333repo : & gix:: Repository , 334hl : & mut Highlighter , 335job : & mut BlobJob , 336) ->Result <()> { 337let joined = job. path . join ( "/" ); 338let rel =format! ( "blob/{joined}" ); 339if let Some ( reuse) = ctx. reuse 340 &&let Some (( previous, previous_versions)) = reuse. previous 341 &&reuse_blob ( 342 previous, 343 previous_versions, 344 reuse. versions , 345& joined, 346& ctx. out , 347) 348{ 349return Ok (()); 350} 351let data =& repo. find_object ( job. oid ) ?. data . to_vec (); 352 353let size = data. len () as u64 ; 354let looks_binary = data[ ..data. len (). min ( 8192 )]. contains ( & 0 ); 355let text =( !job. is_link && !looks_binary && size <=MAX_RENDER_BYTES ) 356. then ( ||String :: from_utf8_lossy ( data)); 357let lines = text. as_ref (). map_or ( 0 , |text| text. lines (). count ()); 358let stats =if job. is_link { 359format! ( "<span>symlink \u{2192} {}</span>" , escape ( & String :: from_utf8_lossy ( data))) 360} else if text. is_some () { 361format! ( 362"<span>{}</span><span>{lines} line{}</span>" , 363human_size ( size), 364if lines ==1 { "" } else { "s" }, 365) 366} else { 367format! ( 368"<span>{}</span><span>{}</span>" , 369if looks_binary{ "binary file" } else { "large file" }, 370human_size ( size), 371) 372}; 373let touched = ctx 374. reuse 375. and_then ( |reuse| reuse. versions . get ( & joined)) 376. and_then ( |version| gix:: ObjectId :: from_hex ( version. touched . as_bytes ()). ok ()) 377. unwrap_or ( ctx. tip_id ); 378let mut body =format! ( 379"{repo_header}{panel}<header class=\"topbar\">{chrome}<span class=\"view-stats\">{stats}</span>\ 380<span class=\"actions\"><a class=\"raw\" href=\"{base}raw/{oid}/{href}\">raw</a></span></header>\n" , 381 repo_header = ctx. repo_header , 382 panel =commit_panel ( repo, touched), 383 chrome = std:: mem:: take ( & mut job. chrome ), 384 base = ctx. base_url , 385 oid = job. oid , 386 href =encode_path ( & joined), 387); 388 389if let Some ( text) = text{ 390let name = job. path . last (). expect ( "blob path is never empty" ); 391// Plain mode defers highlighting to the client, which detects the 392// language from the path carried in data-hl. 393let ( src_attrs, code) =if ctx. highlight { 394let lang =crate :: grammar_manifest:: detect ( name); 395let code = lang 396. and_then ( |lang| hl. highlight_lines ( lang, & text). ok ()) 397. unwrap_or_else ( || text. lines (). map ( |line|escape ( line). to_string ()). collect ()); 398let class = lang. map ( |l|format! ( " language-{l}" )). unwrap_or_default (); 399( format! ( "class=\"code src{class}\"" ), code) 400} else { 401( 402format! ( "class=\"code src\" data-hl=\"{}\"" , escape ( & joined)), 403 text. lines (). map ( |line|escape ( line). to_string ()). collect (), 404) 405}; 406let mut source =format! ( "<pre {src_attrs}>" ); 407let line_count = code. len (); 408for ( i, line) in code. into_iter (). enumerate () { 409let number = i +1 ; 410let _ =write! ( 411 source, 412"<span class=\"code-line\"><a class=\"ln\" id=\"L{number}\" href=\"#L{number}\">{number}</a><span class=\"code-text\">{line}</span>" 413); 414if number < line_count{ 415 source. push_str ( "<span class=\"code-break\">\n</span>" ); 416} 417 source. push_str ( "</span>" ); 418} 419 source. push_str ( "</pre>" ); 420if is_markdown ( name) { 421let rendered =markdown ( hl, & text); 422let _ =writeln! ( 423 body, 424"<div class=\"markdown-view\">\ 425<input type=\"radio\" name=\"markdown-view\" id=\"markdown-rendered\" checked>\ 426<label for=\"markdown-rendered\">rendered</label>\ 427<input type=\"radio\" name=\"markdown-view\" id=\"markdown-code\">\ 428<label for=\"markdown-code\">code</label>\ 429<section class=\"readme rendered\">{rendered}</section>\ 430<div class=\"code-panel\">{source}</div>\ 431</div>" , 432); 433} else { 434let _ =writeln! ( body, "{source}" ); 435} 436} 437 438let title =format! ( "{} - {} @ {}" , joined, ctx. name , ctx. label ); 439let description =format! ( "{joined} in {} at {}." , ctx. name , ctx. label ); 440write_file ( 441& ctx. out . join ( & rel), 442page ( & ctx. instance_name , & title, & description, Some (( & ctx. label , ctx. tip_id )), & body), 443) 444} 445 446fn reuse_blob ( 447previous : & Path , 448previous_versions : & BTreeMap < String , BlobVersion >, 449versions : & BTreeMap < String , BlobVersion >, 450path : & str , 451out : & Path , 452) ->bool { 453let Some ( version) = versions. get ( path) else { 454return false ; 455}; 456if previous_versions. get ( path) !=Some ( version) { 457return false ; 458} 459let previous = previous. join ( "blob" ). join ( path); 460let out = out. join ( "blob" ). join ( path); 461if !previous. is_file () 462 || out. parent (). is_none_or ( |parent| fs:: create_dir_all ( parent). is_err ()) 463{ 464return false ; 465} 466if fs:: hard_link ( & previous, & out). is_ok () || fs:: copy ( & previous, & out). is_ok () { 467return true ; 468} 469let _ = fs:: remove_file ( out); 470false 471} 472 473/// The landing page: repo header plus the HEAD branch's root tree view, with 474/// entry links pointing into the branch's own pages under `ref/`. 475pub fn render_site_index ( 476site : & Site , 477tips : & [ RefTip ], 478out : & Path , 479highlights : Arc < GrammarCache >, 480) ->Result <()> { 481let mut body =format! ( "<a class=\"back\" href=\"/\">← back</a>\n{}" , site. repo_header ()); 482 483let head =head_tip ( site. repo , tips); 484match head{ 485None => body. push_str ( "<p class=\"meta\">no branches yet</p>\n" ), 486Some ( tip) =>{ 487let commit = site. repo . find_object ( tip. commit_id ) ?. try_into_commit () ?; 488let tree = commit. tree () ?; 489let mut r =RefRenderer :: new ( site, tips, tip, & commit, out, highlights); 490let entries =collect_entries ( & tree) ?; 491 body. push_str ( & commit_panel ( site. repo , tip. commit_id )); 492 body. push_str ( & r. tree_topbar ( & [], & entries)); 493if tip. static_mode ==Some ( StaticMode :: Highlighted ) { 494 body. push_str ( & render_languages ( 495& crate :: languages:: analyze ( site. repo , & tree) ?, 496 tip. commit_id , 497)); 498} 499 body. push_str ( & r. overview ( & entries)); 500 body. push_str ( & r. readme_section ( & entries)); 501} 502} 503 504let description = site 505. description 506. clone () 507. unwrap_or_else ( ||format! ( "{} repository on {}." , site. name , site. instance_name )); 508write_file ( 509& out. join ( "index.html" ), 510page ( & site. instance_name , & site. name , & description, head. map ( |tip|( tip. name . as_str (), tip. commit_id )), & body), 511) 512} 513 514/// `data-tip` and `data-language` let the client turn each label into a 515/// link to that language's files at the tip. 516fn render_languages ( stats : & [ crate :: languages:: Stat ], tip : gix:: ObjectId ) ->String { 517let total = stats. iter (). map ( |stat| stat. bytes ). sum ::< u64 >(); 518if total ==0 { 519return String :: new (); 520} 521 522let mut html =format! ( 523"<details class=\"languages\" data-tip=\"{tip}\" open>\n<summary><span class=\"language-label\">languages</span>" , 524); 525for statin stats{ 526let percentage = stat. bytes as f64 * 100.0 / totalas f64 ; 527let _ =write! ( 528 html, 529"<span title=\"{} {:.1}%\" style=\"background:{};width:{percentage:.6}%\"></span>" , 530escape ( stat. name ), 531 percentage, 532 stat. color , 533); 534} 535 html. push_str ( "</summary>\n<ul>\n" ); 536for statin stats{ 537let percentage = stat. bytes as f64 * 100.0 / totalas f64 ; 538let _ =writeln! ( 539 html, 540"<li data-language=\"{}\"><i style=\"background:{}\"></i><span>{} <small>{:.1}%</small></span></li>" , 541 stat. id , 542 stat. color , 543escape ( stat. name ), 544 percentage, 545); 546} 547 html. push_str ( "</ul>\n</details>\n" ); 548 html 549} 550 551/// `/refs`: branch and tag tables with tip summaries and dates. 552pub fn render_refs_page ( site : & Site , tips : & [ RefTip ], out : & Path ) ->Result <()> { 553let head =head_tip ( site. repo , tips); 554let mut body =format! ( 555"{}<header class=\"topbar\"><nav class=\"crumbs\"><a href=\"{}\">{}</a> / <span class=\"cur\">refs</span></nav></header>\n" , 556 site. repo_header (), 557 site. base_url , 558escape ( site. crumb_name ()), 559); 560 561for ( kind, heading) in [( RefKind :: Branch , "branches" ), ( RefKind :: Tag , "tags" )] { 562// HEAD branch first, then alphabetical; tags newest first 563let mut dated: Vec < _ > = tips 564. iter () 565. filter ( |t| t. kind == kind) 566. map ( |t|{ 567let commit = site 568. repo 569. find_object ( t. commit_id ) 570. map_err ( anyhow:: Error :: from) 571. and_then ( |o|Ok ( o. try_into_commit () ?)); 572let ( summary, date, secs) =match & commit{ 573Ok ( c) =>( 574 c. message (). map ( |m| m. summary (). to_string ()). unwrap_or_default (), 575date_of ( c), 576author_time ( c). map ( |t| t. seconds ). unwrap_or ( 0 ), 577), 578Err ( _) =>( String :: new (), String :: new (), 0 ), 579}; 580( t, summary, date, secs) 581}) 582. collect (); 583if dated. is_empty () { 584continue ; 585} 586match kind{ 587RefKind :: Branch => dated. sort_by ( |a, b|{ 588let is_head = |t : & RefTip | head. is_some_and ( |head| head. name == t. name ); 589is_head ( b. 0 ). cmp ( & is_head ( a. 0 )). then_with ( || a. 0 . name . cmp ( & b. 0 . name )) 590}), 591RefKind :: Tag => dated. sort_by_key ( |d| std:: cmp:: Reverse ( d. 3 )), 592} 593 594let _ =write! ( body, "<h2>{heading}</h2>\n<table class=\"list\">\n" ); 595for ( tip, summary, date, _) in dated{ 596let _ =writeln! ( 597 body, 598"<tr><td><a href=\"{href}\">{name}</a></td><td class=\"msg\">{summary}</td><td class=\"date\"><time>{date}</time></td></tr>" , 599 href = site. ref_href ( tip), 600 name =escape ( & tip. name ), 601 summary =escape ( & summary), 602 date =escape ( & date), 603); 604} 605 body. push_str ( "</table>\n" ); 606} 607 608let title =format! ( "refs - {}" , site. name ); 609let description =format! ( "Branches and tags for {}." , site. name ); 610write_file ( 611& out. join ( "refs" ), 612page ( & site. instance_name , & title, & description, head. map ( |tip|( tip. name . as_str (), tip. commit_id )), & body), 613) 614} 615 616struct RefRenderer < ' a > { 617site : &' a Site < ' a >, 618tips : &' a [ RefTip ], 619out : &' a Path , 620label : String , 621/// Absolute URL prefix of this ref's pages, e.g. `/user/repo/ref/main/`. 622urlbase : String , 623tip_id : gix:: ObjectId , 624hl : Highlighter , 625} 626 627enum EntryKind { 628Dir , 629File , 630Link , 631Submodule , 632} 633 634type Entry =( String , EntryKind , gix:: ObjectId ); 635 636fn collect_entries ( tree : & gix:: Tree < ' _ >) ->Result < Vec < Entry >> { 637let mut entries =Vec :: new (); 638for entryin tree. iter () { 639let entry = entry?; 640let mode = entry. mode (); 641let kind =if mode. is_tree () { 642EntryKind :: Dir 643} else if mode. is_link () { 644EntryKind :: Link 645} else if mode. is_commit () { 646EntryKind :: Submodule 647} else { 648EntryKind :: File 649}; 650 entries. push (( entry. filename (). to_string (), kind, entry. oid (). to_owned ())); 651} 652 entries. sort_by ( |a, b|{ 653let rank = |k : & EntryKind | !matches! ( k, EntryKind :: Dir ); 654rank ( & a. 1 ). cmp ( & rank ( & b. 1 )). then_with ( || a. 0 . cmp ( & b. 0 )) 655}); 656Ok ( entries) 657} 658 659/// `a/b/c` when `a` holds only `b`, which holds only `c`: a chain of lone 660/// directories reads better as one link than as three clicks. 661fn collapse_lone_dirs ( repo : & gix:: Repository , name : & str , mut oid : gix:: ObjectId ) ->String { 662let mut path = name. to_string (); 663while let Ok ( tree) = repo. find_tree ( oid) { 664let mut entries = tree. iter (); 665match ( entries. next (), entries. next ()) { 666( Some ( Ok ( only)), None ) if only. mode (). is_tree () =>{ 667let _ =write! ( path, "/{}" , only. filename ()); 668 oid = only. oid (). to_owned (); 669} 670 _ =>break , 671} 672} 673 path 674} 675 676impl < ' a > RefRenderer < ' a > { 677fn new ( 678site : &' a Site < ' a >, 679tips : &' a [ RefTip ], 680tip : & RefTip , 681commit : & gix:: Commit , 682out : &' a Path , 683highlights : Arc < GrammarCache >, 684) ->Self { 685RefRenderer { 686 site, 687 tips, 688 out, 689label : tip. name . clone (), 690urlbase : format! ( "{}ref/{}/" , site. base_url , encode_path ( & tip. name )), 691tip_id : commit. id , 692hl : Highlighter :: new ( highlights), 693} 694} 695 696fn walk ( 697& mut self , 698tree : & gix:: Tree < ' _ >, 699path : & mut Vec < String >, 700blobs : & mut Vec < BlobJob >, 701) ->Result <()> { 702let entries =collect_entries ( tree) ?; 703self . tree_page ( path, & entries) ?; 704 705for ( name, kind, oid) in entries{ 706 path. push ( name); 707match kind{ 708EntryKind :: Dir =>{ 709let subtree =self . site . repo . find_object ( oid) ?. try_into_tree () ?; 710self . walk ( & subtree, path, blobs) ?; 711} 712EntryKind :: File |EntryKind :: Link =>{ 713 blobs. push ( BlobJob { 714chrome : format! ( "{}{}" , self . switcher (), self . crumbs ( path, true )), 715path : path. clone (), 716 oid, 717is_link : matches! ( kind, EntryKind :: Link ), 718}); 719} 720EntryKind :: Submodule =>{} 721} 722 path. pop (); 723} 724Ok (()) 725} 726 727fn tree_page ( & mut self , path : & [ String ], entries : & [ Entry ]) ->Result <()> { 728let rel =if path. is_empty () { 729"index.html" . to_string () 730} else { 731format! ( "tree/{}/index.html" , path. join ( "/" )) 732}; 733let mut body =if path. is_empty () { 734"<a class=\"back\" href=\"/\">← back</a>\n" . to_string () 735} else { 736String :: new () 737}; 738 body. push_str ( & self . site . repo_header ()); 739 body. push_str ( & commit_panel ( self . site . repo , self . tip_id )); 740 body. push_str ( & self . tree_topbar ( path, entries)); 741if path. is_empty () { 742 body. push_str ( & self . overview ( entries)); 743} else { 744 body. push_str ( "<h2 class=\"file-heading\">files</h2>\n" ); 745 body. push_str ( & self . listing ( path, entries)); 746} 747 body. push_str ( & self . readme_section ( entries)); 748 749let title =if path. is_empty () { 750format! ( "{} @ {}" , self . site . name , self . label ) 751} else { 752format! ( "{}/ - {} @ {}" , path. join ( "/" ), self . site . name , self . label ) 753}; 754let description =if path. is_empty () { 755format! ( "Source tree for {} at {}." , self . site . name , self . label ) 756} else { 757format! ( 758"{} in {} at {}." , 759 path. join ( "/" ), 760self . site . name , 761self . label , 762) 763}; 764write_file ( 765& self . out . join ( & rel), 766page ( & self . site . instance_name , & title, & description, Some (( & self . label , self . tip_id )), & body), 767) 768} 769 770/// Root-page layout: file listing beside a recent-commits log. 771fn overview ( & self , entries : & [ Entry ]) ->String { 772format! ( 773"<div class=\"overview\"><section class=\"files\"><h2>files</h2>\n{}</section><aside class=\"commits\">{}</aside></div>\n" , 774self . listing ( & [], entries), 775self . log_section (), 776) 777} 778 779/// Recent commits from this ref's tip, jj-style: change id (from the 780/// `change-id` commit header jj can write), short sha, summary. 781fn log_section ( & self ) ->String { 782const SHOWN : usize =10 ; 783let mut s =String :: from ( "<h2 class=\"log-heading\">recent commits</h2>\n<ol class=\"log\">\n" ); 784let mut shown =Vec :: with_capacity ( SHOWN ); 785let mut parents =Vec :: new (); 786let mut truncated =false ; 787let Ok ( walk) =self . site . repo . rev_walk ([ self . tip_id ]). all () else { 788return String :: new (); 789}; 790for ( n, info) in walk. flatten (). enumerate () { 791if n ==SHOWN { 792 truncated =true ; 793break ; 794} 795let Ok ( commit) =self 796. site 797. repo 798. find_object ( info. id ) 799. map_err ( anyhow:: Error :: from) 800. and_then ( |o|Ok ( o. try_into_commit () ?)) 801else { 802continue ; 803}; 804 shown. push ( info. id ); 805 parents. extend ( commit. parent_ids (). map ( |id| id. detach ())); 806let change_id = commit 807. decode () 808. ok () 809. and_then ( |c| c. extra_headers (). find ( "change-id" ). map ( |v| v. to_string ())); 810let cid = change_id 811. as_deref () 812. map ( |c|{ 813format! ( 814"<span class=\"cid\" data-commit=\"{}\">{}</span> " , 815 info. id , 816escape ( & c[ ..c. len (). min ( 8 )]), 817) 818}) 819. unwrap_or_default (); 820let summary = commit. message (). map ( |m| m. summary (). to_string ()). unwrap_or_default (); 821let author = commit. author (). map ( |a| a. name . to_string ()). unwrap_or_default (); 822let _ =writeln! ( 823 s, 824"<li data-oid=\"{full}\">{cid}<span class=\"sha\" data-commit=\"{full}\">{sha}</span> <span class=\"who\"><span>{author}</span><time>{date}</time></span><span class=\"msg\">{summary}</span></li>" , 825 full = info. id , 826 sha = commit. id (). shorten_or_id (), 827 author =escape ( & author), 828 date =escape ( & date_of ( & commit)), 829 summary =escape ( & summary), 830); 831} 832 s. push_str ( "</ol>\n" ); 833if truncated{ 834let mut frontier =Vec :: new (); 835for parentin parents{ 836if !shown. contains ( & parent) && !frontier. contains ( & parent) { 837 frontier. push ( parent); 838} 839} 840let frontier = frontier 841. iter () 842. map ( ToString :: to_string) 843. collect ::< Vec < _ >>() 844. join ( " " ); 845let _ =writeln! ( 846 s, 847"<p class=\"meta log-pagination\" data-log-frontier=\"{frontier}\">⋯ older commits not shown</p>" , 848); 849} 850 s 851} 852 853fn listing ( & self , path : & [ String ], entries : & [ Entry ]) ->String { 854let mut sub = path. join ( "/" ); 855if !sub. is_empty () { 856 sub. push ( '/' ); 857} 858let mut body ="<table class=\"list\">\n" . to_string (); 859for ( name, kind, oid) in entries{ 860match kind{ 861EntryKind :: Dir =>{ 862let name =collapse_lone_dirs ( self . site . repo , name, * oid); 863let _ =writeln! ( 864 body, 865"<tr><td><a href=\"{base}tree/{href}/\">{name}/</a></td><td class=\"size\"></td></tr>" , 866 base =self . urlbase , 867 href =encode_path ( & format! ( "{sub}{name}" )), 868 name =escape ( & name), 869); 870} 871EntryKind :: File |EntryKind :: Link =>{ 872let size =self . site . repo . find_header ( * oid). map ( |h| h. size ()). unwrap_or ( 0 ); 873let _ =writeln! ( 874 body, 875"<tr><td><a href=\"{base}blob/{href}\">{name}{sigil}</a></td><td class=\"size\">{size}</td></tr>" , 876 base =self . urlbase , 877 href =encode_path ( & format! ( "{sub}{name}" )), 878 name =escape ( name), 879 sigil =if matches! ( kind, EntryKind :: Link ) { "@" } else { "" }, 880 size =human_size ( size), 881); 882} 883EntryKind :: Submodule =>{ 884let _ =writeln! ( 885 body, 886"<tr><td>{name} @ {oid:.8}</td><td class=\"size\"></td></tr>" , 887 name =escape ( name), 888); 889} 890} 891} 892 body. push_str ( "</table>\n" ); 893 body 894} 895 896fn readme_section ( & mut self , entries : & [ Entry ]) ->String { 897match find_readme ( self . site . repo , entries) { 898Some (( name, data)) =>{ 899let readme =readme_html ( & mut self . hl , & name, & data); 900format! ( "<section class=\"readme\">\n{readme}</section>\n" ) 901} 902None =>String :: new (), 903} 904} 905 906fn tree_topbar ( & self , path : & [ String ], entries : & [ Entry ]) ->String { 907let folders = entries. iter (). filter ( |( _, kind, _) |matches! ( kind, EntryKind :: Dir )). count (); 908let files = entries. len () - folders; 909let mut stats =String :: from ( "<span class=\"view-stats\">" ); 910if folders >0 { 911let _ =write! ( stats, "<span>{folders} folder{}</span>" , if folders ==1 { "" } else { "s" }); 912} 913if files >0 { 914let _ =write! ( stats, "<span>{files} file{}</span>" , if files ==1 { "" } else { "s" }); 915} 916 stats. push_str ( "</span>" ); 917// the empty actions slot is where the client puts the history link 918format! ( 919"<header class=\"topbar\">{}{}{stats}<span class=\"actions\"></span></header>\n" , 920self . switcher (), 921self . crumbs ( path, false ), 922) 923} 924 925/// A no-JS `<details>` dropdown listing all refs. Baked at build time, so 926/// pages of refs untouched since a ref was added/deleted list it stale; 927/// the always-rebuilt index and refs pages stay fresh. 928fn switcher ( & self ) ->String { 929let mut s =format! ( 930"<details class=\"switcher\"><summary>{}</summary><div class=\"menu\">" , 931escape ( & self . label ), 932); 933for ( kind, heading) in [( RefKind :: Branch , "branches" ), ( RefKind :: Tag , "tags" )] { 934let mut group =self 935. tips 936. iter () 937. filter ( |t| t. kind == kind && t. static_mode . is_some ()) 938. peekable (); 939if group. peek (). is_none () { 940continue ; 941} 942let _ =write! ( s, "<strong>{heading}</strong>" ); 943for tin group{ 944let current = t. name ==self . label ; 945let _ =write! ( 946 s, 947"<a{class} href=\"{href}\">{name}</a>" , 948 class =if current{ " class=\"current\"" } else { "" }, 949 href =self . site . ref_href ( t), 950 name =escape ( & t. name ), 951); 952} 953} 954let _ =write! ( 955 s, 956"<a class=\"all\" href=\"{}refs\">all refs →</a></div></details>" , 957self . site . base_url , 958); 959 s 960} 961 962/// Breadcrumb nav: site name / path components, all but the last linked. 963fn crumbs ( & self , path : & [ String ], last_is_file : bool ) ->String { 964let mut nav =format! ( 965"<nav class=\"crumbs\"><a href=\"{href}\">{site}</a>" , 966 href =self . site . base_url , 967 site =escape ( self . site . crumb_name ()), 968); 969for ( i, comp) in path. iter (). enumerate () { 970let is_last = i +1 == path. len (); 971if is_last && last_is_file{ 972let _ =write! ( nav, " / <span class=\"cur\">{}</span>" , escape ( comp)); 973} else if is_last{ 974let _ =write! ( nav, " / <span class=\"cur\">{}</span> /" , escape ( comp)); 975} else { 976let _ =write! ( 977 nav, 978" / <a href=\"{base}tree/{href}/\">{name}</a>" , 979 base =self . urlbase , 980 href =encode_path ( & path[ ..=i]. join ( "/" )), 981 name =escape ( comp), 982); 983} 984} 985 nav. push_str ( "</nav>\n" ); 986 nav 987} 988} 989 990/// Author date; the in-browser client also renders author time, so dates 991/// match site-wide. 992fn date_of ( commit : & gix:: Commit < ' _ >) ->String { 993author_time ( commit) 994. map ( |t| t. format_or_unix ( gix:: date:: time:: format:: SHORT )) 995. unwrap_or_default () 996} 997 998fn author_time ( commit : & gix:: Commit < ' _ >) ->Option < gix:: date:: Time > { 999 commit. author (). ok () ?. time (). ok () 1000} 1001 1002fn find_readme ( repo : & gix:: Repository , entries : & [ Entry ]) ->Option <( String , Vec < u8 >)> { 1003[ "readme.md" , "readme" , "readme.txt" ]. iter (). find_map ( |want|{ 1004 entries. iter (). find_map ( |( name, kind, oid) |{ 1005( matches! ( kind, EntryKind :: File ) && name. to_lowercase () ==* want) 1006. then ( ||Some (( name. clone (), repo. find_object ( * oid). ok () ?. data . to_vec ()))) 1007. flatten () 1008}) 1009}) 1010} 1011 1012fn is_markdown ( name : & str ) ->bool { 1013let name = name. to_lowercase (); 1014 name. ends_with ( ".md" ) || name. ends_with ( ".markdown" ) 1015} 1016 1017fn readme_html ( hl : & mut Highlighter , name : & str , data : & [ u8 ]) ->String { 1018let text =String :: from_utf8_lossy ( data); 1019if is_markdown ( name) { 1020markdown ( hl, & text) 1021} else { 1022format! ( "<pre>{}</pre>\n" , escape ( & text)) 1023} 1024} 1025 1026fn markdown ( hl : & mut Highlighter , src : & str ) ->String { 1027use pulldown_cmark::{ html, CodeBlockKind , Event , Options , Parser , Tag , TagEnd }; 1028let opts =Options :: ENABLE_TABLES 1029 |Options :: ENABLE_STRIKETHROUGH 1030 |Options :: ENABLE_FOOTNOTES 1031 |Options :: ENABLE_TASKLISTS ; 1032// Buffer fenced code blocks so they get the same arborium treatment as 1033// blob pages; escape raw HTML rather than pulling in a sanitizer. 1034let mut fence: Option <( Option < &' static str >, String )> =None ; 1035let events =Parser :: new_ext ( src, opts). filter_map ( |ev|match ev{ 1036Event :: Start ( Tag :: CodeBlock ( CodeBlockKind :: Fenced ( info))) =>{ 1037// only the first word names the language: ```rust,ignore 1038let lang = info 1039. split ( |c : char | c ==',' || c. is_whitespace ()) 1040. next () 1041. and_then ( crate :: grammar_manifest:: canonical); 1042 fence =Some (( lang, String :: new ())); 1043None 1044} 1045Event :: Text ( t) if fence. is_some () =>{ 1046 fence. as_mut (). expect ( "checked in guard" ). 1 . push_str ( & t); 1047None 1048} 1049Event :: End ( TagEnd :: CodeBlock ) if fence. is_some () =>{ 1050let ( lang, code) = fence. take (). expect ( "checked in guard" ); 1051let class = lang. map ( |l|format! ( " class=\"language-{l}\"" )). unwrap_or_default (); 1052let code = lang 1053. and_then ( |lang| hl. highlight ( lang, & code). ok ()) 1054. unwrap_or_else ( ||escape ( & code). to_string ()); 1055Some ( Event :: Html ( format! ( "<pre{class}>{code}</pre>\n" ). into ())) 1056} 1057Event :: Html ( s) =>Some ( Event :: Text ( s)), 1058Event :: InlineHtml ( s) =>Some ( Event :: Text ( s)), 1059 ev =>Some ( ev), 1060}); 1061let mut out =String :: new (); 1062 html:: push_html ( & mut out, events); 1063 out 1064} 1065 1066fn human_size ( bytes : u64 ) ->String { 1067const UNITS : & [ & str ] =& [ "KiB" , "MiB" , "GiB" , "TiB" ]; 1068if bytes <1024 { 1069return format! ( "{bytes} B" ); 1070} 1071let mut size = bytesas f64 ; 1072let mut unit ="" ; 1073for uin UNITS { 1074 size /=1024.0 ; 1075 unit = u; 1076if size <1024.0 { 1077break ; 1078} 1079} 1080format! ( "{size:.1} {unit}" ) 1081} 1082 1083# [ cfg ( test )] 1084mod tests{ 1085use std:: collections:: BTreeMap ; 1086use std:: fs; 1087use std:: os:: unix:: fs:: symlink; 1088 1089use anyhow:: Result ; 1090 1091use crate :: highlight:: Highlighter ; 1092use crate :: testutil::{ TempDir , commit, git, grammar_cache, init_repo}; 1093 1094use super ::{ 1095BlobVersion , EntryKind , RefKind , collapse_lone_dirs, collect_entries, encode_path, 1096 find_readme, head_tip, human_size, list_refs, markdown, reuse_blob, 1097}; 1098 1099# [ test ] 1100fn encodes_url_paths () { 1101assert_eq! ( encode_path ( "a b/c?d#\u{e9}" ). to_string (), "a%20b/c%3Fd%23%C3%A9" ); 1102} 1103 1104# [ test ] 1105fn human_sizes_switch_units_at_1024 () { 1106assert_eq! ( human_size ( 0 ), "0 B" ); 1107assert_eq! ( human_size ( 1023 ), "1023 B" ); 1108assert_eq! ( human_size ( 1024 ), "1.0 KiB" ); 1109assert_eq! ( human_size ( 1536 ), "1.5 KiB" ); 1110assert_eq! ( human_size ( 1 <<20 ), "1.0 MiB" ); 1111assert_eq! ( human_size ( 1 <<40 ), "1.0 TiB" ); 1112assert_eq! ( human_size ( 1 <<50 ), "1024.0 TiB" ); 1113} 1114 1115# [ test ] 1116fn tree_entries_list_directories_first_then_by_name () ->Result <()> { 1117let root =TempDir :: new ( "entries" ); 1118let repo_path = root. join ( "repo" ); 1119init_repo ( & repo_path) ?; 1120for dirin [ "zeta" , "alpha" ] { 1121 fs:: create_dir ( repo_path. join ( dir)) ?; 1122 fs:: write ( repo_path. join ( dir). join ( "keep" ), "" ) ?; 1123} 1124 fs:: write ( repo_path. join ( "beta" ), "" ) ?; 1125 fs:: write ( repo_path. join ( "aardvark" ), "" ) ?; 1126symlink ( "beta" , repo_path. join ( "gamma" )) ?; 1127commit ( & repo_path, "first" ) ?; 1128 1129let repo = gix:: open ( & repo_path) ?; 1130let entries =collect_entries ( & repo. head_commit () ?. tree () ?) ?; 1131let names: Vec < _ > = entries. iter (). map ( |( name, ..) | name. as_str ()). collect (); 1132assert_eq! ( names, [ "alpha" , "zeta" , "aardvark" , "beta" , "gamma" ]); 1133assert! ( matches! ( entries[ 1 ]. 1 , EntryKind :: Dir )); 1134assert! ( matches! ( entries[ 2 ]. 1 , EntryKind :: File )); 1135assert! ( matches! ( entries[ 4 ]. 1 , EntryKind :: Link )); 1136Ok (()) 1137} 1138 1139# [ test ] 1140fn lone_directory_chains_collapse_into_one_name () ->Result <()> { 1141let root =TempDir :: new ( "collapse" ); 1142let repo_path = root. join ( "repo" ); 1143init_repo ( & repo_path) ?; 1144 fs:: create_dir_all ( repo_path. join ( "a/b/c" )) ?; 1145 fs:: write ( repo_path. join ( "a/b/c/keep" ), "" ) ?; 1146 fs:: create_dir_all ( repo_path. join ( "x/y" )) ?; 1147 fs:: write ( repo_path. join ( "x/y/keep" ), "" ) ?; 1148 fs:: write ( repo_path. join ( "x/stop" ), "" ) ?; 1149commit ( & repo_path, "first" ) ?; 1150 1151let repo = gix:: open ( & repo_path) ?; 1152let entries =collect_entries ( & repo. head_commit () ?. tree () ?) ?; 1153let collapsed: Vec < _ > = entries 1154. iter () 1155. map ( |( name, _, oid) |collapse_lone_dirs ( & repo, name, * oid)) 1156. collect (); 1157assert_eq! ( collapsed, [ "a/b/c" , "x" ]); 1158Ok (()) 1159} 1160 1161# [ test ] 1162fn readme_lookup_prefers_markdown_ignores_case_and_skips_directories () ->Result <()> { 1163let root =TempDir :: new ( "readme" ); 1164let repo_path = root. join ( "repo" ); 1165init_repo ( & repo_path) ?; 1166 fs:: write ( repo_path. join ( "readme.txt" ), "txt" ) ?; 1167 fs:: write ( repo_path. join ( "README" ), "bare" ) ?; 1168 fs:: write ( repo_path. join ( "ReadMe.MD" ), "md" ) ?; 1169commit ( & repo_path, "first" ) ?; 1170let repo = gix:: open ( & repo_path) ?; 1171let entries =collect_entries ( & repo. head_commit () ?. tree () ?) ?; 1172let ( name, data) =find_readme ( & repo, & entries). unwrap (); 1173assert_eq! (( name. as_str (), data. as_slice ()), ( "ReadMe.MD" , b"md" . as_slice ())); 1174 1175 fs:: remove_file ( repo_path. join ( "ReadMe.MD" )) ?; 1176 fs:: create_dir ( repo_path. join ( "readme.md" )) ?; 1177 fs:: write ( repo_path. join ( "readme.md/keep" ), "" ) ?; 1178commit ( & repo_path, "second" ) ?; 1179let entries =collect_entries ( & repo. head_commit () ?. tree () ?) ?; 1180let ( name, data) =find_readme ( & repo, & entries). unwrap (); 1181assert_eq! (( name. as_str (), data. as_slice ()), ( "README" , b"bare" . as_slice ())); 1182Ok (()) 1183} 1184 1185# [ test ] 1186fn refs_skip_non_commit_tags_and_let_branches_shadow_tags () ->Result <()> { 1187let root =TempDir :: new ( "refs" ); 1188let repo_path = root. join ( "repo" ); 1189init_repo ( & repo_path) ?; 1190 fs:: write ( repo_path. join ( "file" ), "" ) ?; 1191commit ( & repo_path, "first" ) ?; 1192git ( & repo_path, & [ "tag" , "main" ]) ?; 1193git ( & repo_path, & [ "tag" , "v1" ]) ?; 1194let blob =git ( & repo_path, & [ "rev-parse" , "HEAD:file" ]) ?; 1195git ( & repo_path, & [ "tag" , "blobtag" , & blob]) ?; 1196 1197let repo = gix:: open ( & repo_path) ?; 1198let mut tips: Vec < _ > =list_refs ( & repo) ? 1199. into_iter () 1200. map ( |tip|( tip. kind , tip. name )) 1201. collect (); 1202 tips. sort_by ( |a, b| a. 1 . cmp ( & b. 1 )); 1203assert_eq! ( tips, [( RefKind :: Branch , "main" . into ()), ( RefKind :: Tag , "v1" . into ())]); 1204Ok (()) 1205} 1206 1207# [ test ] 1208fn head_tip_follows_head_and_falls_back_to_any_branch () ->Result <()> { 1209let root =TempDir :: new ( "head" ); 1210let repo_path = root. join ( "repo" ); 1211init_repo ( & repo_path) ?; 1212 fs:: write ( repo_path. join ( "file" ), "" ) ?; 1213commit ( & repo_path, "first" ) ?; 1214git ( & repo_path, & [ "branch" , "aaa" ]) ?; 1215let repo = gix:: open ( & repo_path) ?; 1216let tips =list_refs ( & repo) ?; 1217assert_eq! ( head_tip ( & repo, & tips). unwrap (). name , "main" ); 1218 1219git ( & repo_path, & [ "checkout" , "-q" , "--detach" ]) ?; 1220let repo = gix:: open ( & repo_path) ?; 1221assert_eq! ( head_tip ( & repo, & tips). unwrap (). kind , RefKind :: Branch ); 1222 1223git ( & repo_path, & [ "symbolic-ref" , "HEAD" , "refs/heads/unborn" ]) ?; 1224let repo = gix:: open ( & repo_path) ?; 1225assert_eq! ( head_tip ( & repo, & tips). unwrap (). kind , RefKind :: Branch ); 1226Ok (()) 1227} 1228 1229# [ test ] 1230fn markdown_never_passes_through_raw_html () { 1231let mut hl =Highlighter :: new ( grammar_cache ()); 1232let html =markdown ( 1233& mut hl, 1234"<script>alert(1)</script>\n\n\ 1235text <b onclick=\"x\">bold</b>\n\n\ 1236```no-such-lang\n<img src=x onerror=alert(1)>\n```\n" , 1237); 1238assert! ( html. contains ( "<script>" ), "{html}" ); 1239for forbiddenin [ "<script" , "<b " , "<img" ] { 1240assert! ( !html. contains ( forbidden), "{forbidden} leaked into {html}" ); 1241} 1242} 1243 1244# [ test ] 1245fn reuses_only_matching_blob_pages () ->Result <()> { 1246let root =TempDir :: new ( "blob-reuse" ); 1247let previous = root. join ( "previous" ); 1248let out = root. join ( "out" ); 1249 fs:: create_dir_all ( previous. join ( "blob/src" )) ?; 1250 fs:: write ( previous. join ( "blob/src/main.rs" ), "highlighted" ) ?; 1251let old =BlobVersion { 1252oid : "abc123" . into (), 1253mode : 0o100644 , 1254touched : "first" . into (), 1255}; 1256let previous_versions =BTreeMap :: from ([( "src/main.rs" . into (), old. clone ())]); 1257let changed =BlobVersion { 1258touched : "second" . into (), 1259 ..old. clone () 1260}; 1261let changed_versions =BTreeMap :: from ([( "src/main.rs" . into (), changed)]); 1262let versions =BTreeMap :: from ([( "src/main.rs" . into (), old)]); 1263 1264assert! ( !reuse_blob ( 1265& previous, 1266& previous_versions, 1267& changed_versions, 1268"src/main.rs" , 1269& out, 1270)); 1271assert! ( reuse_blob ( 1272& previous, 1273& previous_versions, 1274& versions, 1275"src/main.rs" , 1276& out, 1277)); 1278assert_eq! ( 1279 fs:: read_to_string ( out. join ( "blob/src/main.rs" )) ?, 1280"highlighted" , 1281); 1282Ok (()) 1283} 1284}