forks/sorcery
git clone https://git.gae.moe/forks/sorcery
38b4205
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 }; 8 9use crate :: highlight:: Cache as GrammarCache ; 10use crate :: render::{ self , RefKind , RefTip , Site , StaticMode }; 11 12pub const MANIFEST_FILE : & str =".sorcery-blobs.json" ; 13pub const STATE_FILE : & str =".sorcery-state" ; 14pub const STYLESHEET : & str =include_str! ( "style.css" ); 15pub ( crate ) const OUTPUT_FORMAT_VERSION : u32 =62 ; 16const MAX_STATIC_BRANCHES : usize =8 ; 17const MAX_STATIC_TAGS : usize =8 ; 18const MAX_HIGHLIGHTED_FILES : usize =10_000 ; 19 20# [ derive ( Clone )] 21pub struct Config { 22pub repo : PathBuf , 23pub out : PathBuf , 24pub instance_name : String , 25pub name : Option < String >, 26pub clone_url : Option < String >, 27} 28 29# [ derive ( serde :: Deserialize , serde :: Serialize )] 30struct BlobManifest { 31format : u32 , 32instance_name : String , 33name : String , 34refs : BTreeMap < String , RefManifest >, 35} 36 37# [ derive ( serde :: Deserialize , serde :: Serialize )] 38struct RefManifest { 39tip : String , 40files : BTreeMap < String , render:: BlobVersion >, 41} 42 43struct PendingBlob { 44oid : String , 45mode : u16 , 46touched : Option < String >, 47} 48 49impl BlobManifest { 50fn load ( out : & Path , instance_name : & str , name : & str ) ->Option < Self > { 51let data = fs:: read ( out. join ( MANIFEST_FILE )). ok () ?; 52let manifest = serde_json:: from_slice ::< Self >( & data). ok () ?; 53( manifest. format ==OUTPUT_FORMAT_VERSION 54 && manifest. instance_name == instance_name 55 && manifest. name == name) 56. then_some ( manifest) 57} 58} 59 60/// The complete input identity of generated output. Bump 61/// `OUTPUT_FORMAT_VERSION` whenever templates or styling change incompatibly. 62pub fn expected_state ( config : & Config ) ->Result < String > { 63let repo = gix:: open ( & config. repo ) 64. with_context ( ||format! ( "opening git repo at {}" , config. repo . display ())) ?; 65let name = config. name . clone (). unwrap_or_else ( ||repo_name ( & repo)); 66let description =description ( & repo); 67let tips = render:: list_refs ( & repo) ?; 68Ok ( state_for ( config, & repo, & tips, & name, description. as_deref ())) 69} 70 71pub fn full ( config : & Config , previous : Option < & Path >, highlights : Arc < GrammarCache >) ->Result <()> { 72full_cancellable ( config, previous, highlights, None ) 73} 74 75pub fn full_cancellable ( 76config : & Config , 77previous : Option < & Path >, 78highlights : Arc < GrammarCache >, 79cancel : Option < render:: BuildCancel >, 80) ->Result <()> { 81 render:: bail_if_cancelled ( & cancel) ?; 82let repo = gix:: open ( & config. repo ) 83. with_context ( ||format! ( "opening git repo at {}" , config. repo . display ())) ?; 84let name = config. name . clone (). unwrap_or_else ( ||repo_name ( & repo)); 85let description =description ( & repo); 86let mut tips = render:: list_refs ( & repo) ?; 87let state =state_for ( config, & repo, & tips, & name, description. as_deref ()); 88plan_rendering ( & repo, & mut tips) ?; 89let base_url =format! ( "/{}/" , render:: encode_path ( & name)); 90let site =Site { 91repo : & repo, 92instance_name : config. instance_name . clone (), 93 name, 94 base_url, 95 description, 96clone_url : config. clone_url . clone (), 97}; 98 99 fs:: create_dir_all ( & config. out ) ?; 100let previous_manifest = previous 101. and_then ( |out|BlobManifest :: load ( out, & site. instance_name , & site. name )); 102let refs =rebuild_all ( 103& site, 104& tips, 105& config. out , 106 previous, 107 previous_manifest. as_ref (), 108 highlights. clone (), 109 cancel. clone (), 110) ?; 111 render:: bail_if_cancelled ( & cancel) ?; 112 render:: render_refs_page ( & site, & tips, & config. out ) ?; 113 render:: render_site_index ( & site, & tips, & config. out , highlights, cancel) ?; 114 render:: write_file ( & config. out . join ( "gitinfo.json" ), gitinfo ( & repo, & tips) ?) ?; 115let manifest =BlobManifest { 116format : OUTPUT_FORMAT_VERSION , 117instance_name : site. instance_name . clone (), 118name : site. name . clone (), 119 refs, 120}; 121 render:: write_file ( 122& config. out . join ( MANIFEST_FILE ), 123 serde_json:: to_vec ( & manifest) ?, 124) ?; 125 render:: write_file ( & config. out . join ( STATE_FILE ), state) 126} 127 128/// Picks the refs that get static pages: every branch while there are few 129/// enough of them, and the newest tags while the repo is not tag-heavy. Each 130/// static ref costs a full tree of pages and one pass over its history, so 131/// release-heavy repositories only get their recent tags. 132fn plan_rendering ( repo : & gix:: Repository , tips : & mut [ RefTip ]) ->Result <()> { 133let branch_count = tips. iter (). filter ( |tip| tip. kind ==RefKind :: Branch ). count (); 134let head = render:: head_tip ( repo, tips). map ( |tip| tip. name . clone ()); 135 136let mut tags = tips 137. iter () 138. filter ( |tip| tip. kind ==RefKind :: Tag ) 139. map ( |tip|{ 140let seconds = repo 141. find_object ( tip. commit_id ) 142. ok () 143. and_then ( |object| object. try_into_commit (). ok ()) 144. and_then ( |commit| render:: author_time ( & commit). map ( |time| time. seconds )) 145. unwrap_or ( 0 ); 146( tip. name . clone (), seconds) 147}) 148. collect ::< Vec < _ >>(); 149 tags. sort_by_key ( |& ( _, seconds) | std:: cmp:: Reverse ( seconds)); 150let static_tags = tags 151. iter () 152. take ( MAX_STATIC_TAGS ) 153. map ( |( name, _) | name. as_str ()) 154. collect ::< Vec < _ >>(); 155 156for tipin tips. iter_mut () { 157let wanted =match tip. kind { 158RefKind :: Branch =>{ 159 branch_count <=MAX_STATIC_BRANCHES ||Some ( & tip. name ) == head. as_ref () 160} 161RefKind :: Tag => static_tags. contains ( & tip. name . as_str ()), 162}; 163if !wanted{ 164continue ; 165} 166let commit = repo. find_object ( tip. commit_id ) ?. try_into_commit () ?; 167 tip. static_mode =Some ( if exceeds_file_limit ( repo, & commit. tree () ?, MAX_HIGHLIGHTED_FILES ) ?{ 168StaticMode :: Plain 169} else { 170StaticMode :: Highlighted 171}); 172} 173Ok (()) 174} 175 176fn exceeds_file_limit ( 177repo : & gix:: Repository , 178tree : & gix:: Tree < ' _ >, 179limit : usize , 180) ->Result < bool > { 181fn walk ( 182repo : & gix:: Repository , 183tree : & gix:: Tree < ' _ >, 184files : & mut usize , 185limit : usize , 186) ->Result < bool > { 187for entryin tree. iter () { 188let entry = entry?; 189let mode = entry. mode (); 190if mode. is_tree () { 191let tree = repo. find_object ( entry. oid ()) ?. try_into_tree () ?; 192if walk ( repo, & tree, files, limit) ?{ 193return Ok ( true ); 194} 195} else if !mode. is_commit () { 196* files +=1 ; 197if * files > limit{ 198return Ok ( true ); 199} 200} 201} 202Ok ( false ) 203} 204 205let mut files =0 ; 206walk ( repo, tree, & mut files, limit) 207} 208 209/// Discovery manifest for the in-browser git client: HEAD, refs (peeled to 210/// commits, branch-shadows-tag like the rest of the site), and pack names so 211/// the client can find `.idx` files without directory listings. 212fn gitinfo ( repo : & gix:: Repository , tips : & [ RefTip ]) ->Result < String > { 213let head = render:: head_tip ( repo, tips). map ( |t| t. name . clone ()); 214let refs = tips 215. iter () 216. map ( |t|{ 217 serde_json:: json!({ 218"kind" : match t. kind { 219RefKind :: Branch =>"branch" , 220RefKind :: Tag =>"tag" , 221}, 222"name" : t. name , 223"oid" : t. commit_id . to_string (), 224}) 225}) 226. collect ::< Vec < _ >>(); 227Ok ( serde_json:: json!({ 228"head" : head, 229"refs" : refs, 230"packs" : pack_names ( repo), 231}) 232. to_string ()) 233} 234 235/// Sorted `pack-*` stems in the repo's object store. 236fn pack_names ( repo : & gix:: Repository ) ->Vec < String > { 237let mut packs: Vec < String > = fs:: read_dir ( repo. git_dir (). join ( "objects/pack" )) 238. into_iter () 239. flatten () 240. flatten () 241. filter_map ( |entry|{ 242let name = entry. file_name (). to_string_lossy (). into_owned (); 243 name. strip_suffix ( ".pack" ) 244. filter ( |stem| stem. starts_with ( "pack-" )) 245. map ( str:: to_owned) 246}) 247. collect (); 248 packs. sort (); 249 packs 250} 251 252pub ( crate ) fn description ( repo : & gix:: Repository ) ->Option < String > { 253 fs:: read_to_string ( repo. git_dir (). join ( "description" )) 254. ok () 255. map ( |d| d. trim (). to_string ()) 256. filter ( |d| !d. is_empty () && !d. starts_with ( "Unnamed repository" )) 257} 258 259fn state_for ( 260config : & Config , 261repo : & gix:: Repository , 262tips : & [ RefTip ], 263name : & str , 264description : Option < & str >, 265) ->String { 266let mut refs = tips. iter (). collect ::< Vec < _ >>(); 267 refs. sort_by ( |a, b| a. name . cmp ( & b. name )); 268let mut state =format! ( 269"format {OUTPUT_FORMAT_VERSION}\ninstance {:?}\nname {name:?}\nclone {:?}\ndescription {description:?}\nhead {:?}\n" , 270 config. instance_name , 271 config. clone_url , 272 repo. head_name (). ok (). flatten (). map ( |n| n. to_string ()), 273); 274for tipin refs{ 275let kind =match tip. kind { 276RefKind :: Branch =>"branch" , 277RefKind :: Tag =>"tag" , 278}; 279let _ =writeln! ( state, "{kind} {:?} {}" , tip. name , tip. commit_id ); 280} 281// a gc changes packs without changing refs; the git-dir client cares 282for packin pack_names ( repo) { 283let _ =writeln! ( state, "pack {pack}" ); 284} 285 state 286} 287 288/// `/srv/git/foo.git` → `foo`, `/home/x/foo/.git` → `foo` 289fn repo_name ( repo : & gix:: Repository ) ->String { 290let dir = repo. git_dir (). canonicalize (). unwrap_or_else ( |_| repo. git_dir (). into ()); 291let dir =if dir. file_name () ==Some ( ".git" . as_ref ()) { 292 dir. parent (). unwrap_or ( & dir) 293} else { 294& dir 295}; 296 dir. file_name () 297. map ( |n| n. to_string_lossy (). trim_end_matches ( ".git" ). to_string ()) 298. filter ( |n| !n. is_empty ()) 299. unwrap_or_else ( ||"repository" . into ()) 300} 301 302fn blob_versions ( 303repo : & gix:: Repository , 304tip : & gix:: Commit , 305previous : Option < & RefManifest >, 306cancel : & Option < render:: BuildCancel >, 307) ->Result < BTreeMap < String , render:: BlobVersion >> { 308let mut files =BTreeMap :: new (); 309collect_blobs ( repo, tip. tree_id () ?. detach (), & mut Vec :: new (), & mut files) ?; 310let mut unresolved = files. len (); 311let mut commit_id = tip. id ; 312let mut diff_state = gix:: diff:: tree:: State :: default (); 313 314// Match the browser's first-parent path history. Once its previous tip is 315// reached, paths untouched by newer commits can inherit their old result. 316while unresolved >0 { 317 render:: bail_if_cancelled ( cancel) ?; 318let commit = repo. find_object ( commit_id) ?. try_into_commit () ?; 319let commit_id_string = commit_id. to_string (); 320if let Some ( previous) = previous. filter ( |previous| previous. tip == commit_id_string) { 321for ( path, file) in & mut files{ 322if file. touched . is_some () { 323continue ; 324} 325if let Some ( old) = previous. files . get ( path) 326 && old. oid == file. oid 327 && old. mode == file. mode 328{ 329 file. touched =Some ( old. touched . clone ()); 330 unresolved -=1 ; 331} 332} 333if unresolved ==0 { 334break ; 335} 336} 337 338let tree = commit. tree () ?; 339let parent = commit. parent_ids (). next (). map ( |id| id. detach ()); 340let parent_tree =match parent{ 341Some ( id) => repo. find_commit ( id) ?. tree () ?, 342None => repo. empty_tree (), 343}; 344// repo.diff_tree_to_tree() sets up blob diffing per call, which in a 345// bare repo means rebuilding an index from HEAD every commit 346let mut changes = gix:: diff:: tree:: Recorder :: default (); 347 gix:: diff:: tree ( 348 gix:: objs:: TreeRefIter :: from_bytes ( & parent_tree. data , repo. object_hash ()), 349 gix:: objs:: TreeRefIter :: from_bytes ( & tree. data , repo. object_hash ()), 350& mut diff_state, 351& repo. objects , 352& mut changes, 353) ?; 354for changein changes. records { 355use gix:: diff:: tree:: recorder:: Change ; 356let ( Change :: Addition { path, ..} 357 |Change :: Deletion { path, ..} 358 |Change :: Modification { path, ..}) = change; 359if let Some ( file) = files. get_mut ( & path. to_string ()) 360 && file. touched . is_none () 361{ 362 file. touched =Some ( commit_id_string. clone ()); 363 unresolved -=1 ; 364} 365} 366let Some ( parent) = parentelse { 367break ; 368}; 369 commit_id = parent; 370} 371 372 files 373. into_iter () 374. map ( |( path, file) |{ 375Ok (( 376 path, 377 render:: BlobVersion { 378oid : file. oid , 379mode : file. mode , 380touched : file. touched . context ( "file has no introducing commit" ) ?, 381}, 382)) 383}) 384. collect () 385} 386 387fn collect_blobs ( 388repo : & gix:: Repository , 389tree_id : gix:: ObjectId , 390path : & mut Vec < String >, 391files : & mut BTreeMap < String , PendingBlob >, 392) ->Result <()> { 393let tree = repo. find_object ( tree_id) ?. try_into_tree () ?; 394for entryin tree. iter () { 395let entry = entry?; 396let mode = entry. mode (); 397 path. push ( entry. filename (). to_string ()); 398if mode. is_tree () { 399collect_blobs ( repo, entry. oid (). to_owned (), path, files) ?; 400} else if !mode. is_commit () { 401 files. insert ( 402 path. join ( "/" ), 403PendingBlob { 404oid : entry. oid (). to_string (), 405mode : mode. value (), 406touched : None , 407}, 408); 409} 410 path. pop (); 411} 412Ok (()) 413} 414 415/// Rebuild all visible refs, replacing `ref/` wholesale (which also prunes 416/// refs deleted since the last run). 417fn rebuild_all ( 418site : & Site , 419tips : & [ RefTip ], 420out : & Path , 421previous : Option < & Path >, 422previous_manifest : Option < & BlobManifest >, 423highlights : Arc < GrammarCache >, 424cancel : Option < render:: BuildCancel >, 425) ->Result < BTreeMap < String , RefManifest >> { 426let staging = out. join ( ".new-ref" ); 427if staging. exists () { 428 fs:: remove_dir_all ( & staging) ?; 429} 430 fs:: create_dir_all ( & staging) ?; 431let mut refs =BTreeMap :: new (); 432for tipin tips. iter (). filter ( |tip| tip. static_mode . is_some ()) { 433 render:: bail_if_cancelled ( & cancel) ?; 434let commit = site. repo . find_object ( tip. commit_id ) ?. try_into_commit () ?; 435let previous_ref = previous. map ( |out| out. join ( "ref" ). join ( & tip. name )); 436let previous_version = previous_manifest. and_then ( |manifest| manifest. refs . get ( & tip. name )); 437let files =if tip. static_mode ==Some ( StaticMode :: Highlighted ) { 438Some ( blob_versions ( site. repo , & commit, previous_version, & cancel) ?) 439} else { 440None 441}; 442let reuse = files. as_ref (). map ( |versions| render:: BlobReuse { 443 versions, 444previous : previous_ref 445. as_deref () 446. zip ( previous_version. map ( |version|& version. files )), 447}); 448 render:: render_ref ( 449 site, 450 tips, 451 tip, 452& commit, 453& staging. join ( & tip. name ), 454 reuse, 455 highlights. clone (), 456 cancel. clone (), 457) 458. with_context ( ||format! ( "rendering {}" , tip. name )) ?; 459if let Some ( files) = files{ 460 refs. insert ( 461 tip. name . clone (), 462RefManifest { 463tip : tip. commit_id . to_string (), 464 files, 465}, 466); 467} 468} 469swap_dir ( & staging, & out. join ( "ref" )) ?; 470Ok ( refs) 471} 472 473/// Move `new` into place at `dst`, displacing any existing tree with only a 474/// brief window where `dst` is missing (rename is atomic, recursive delete 475/// is not, so the old tree is renamed aside before removal, and restored if 476/// the install rename fails). 477pub ( crate ) fn swap_dir ( new : & Path , dst : & Path ) ->Result <()> { 478if let Some ( parent) = dst. parent () { 479 fs:: create_dir_all ( parent) ?; 480} 481let trash = new. with_extension ( "old" ); 482if trash. exists () { 483 fs:: remove_dir_all ( & trash) ?; 484} 485let displaced = dst. exists (); 486if displaced{ 487 fs:: rename ( dst, & trash) ?; 488} 489if let Err ( error) = fs:: rename ( new, dst) { 490if displaced{ 491let _ = fs:: rename ( & trash, dst); 492} 493return Err ( error). with_context ( ||format! ( "installing {}" , dst. display ())); 494} 495if displaced{ 496if let Err ( error) = fs:: remove_dir_all ( & trash) { 497eprintln! ( "sorcery: removing {}: {error}" , trash. display ()); 498} 499} 500Ok (()) 501} 502 503# [ cfg ( test )] 504mod tests{ 505use std:: collections:: BTreeMap ; 506use std:: fs; 507use std:: path::{ Path , PathBuf }; 508 509use anyhow:: Result ; 510use percent_encoding:: percent_decode_str; 511 512use crate :: testutil::{ TempDir , commit, git, grammar_cache, init_repo, init_sha256_repo}; 513 514use super ::{ 515BlobManifest , Config , MANIFEST_FILE , OUTPUT_FORMAT_VERSION , RefManifest , blob_versions, 516 full, full_cancellable, 517}; 518 519# [ test ] 520fn tracks_last_touch_across_incremental_rebuilds_and_reverts () ->Result <()> { 521let root =TempDir :: new ( "last-touch" ); 522let repo_path = root. join ( "repo" ); 523init_repo ( & repo_path) ?; 524 fs:: create_dir ( repo_path. join ( "src" )) ?; 525 fs:: write ( repo_path. join ( "src/stable.txt" ), "stable" ) ?; 526 fs:: write ( repo_path. join ( "changed.txt" ), "one" ) ?; 527let first =commit ( & repo_path, "first" ) ?; 528 fs:: write ( repo_path. join ( "changed.txt" ), "two" ) ?; 529let second =commit ( & repo_path, "second" ) ?; 530 fs:: write ( repo_path. join ( "changed.txt" ), "one" ) ?; 531let third =commit ( & repo_path, "revert" ) ?; 532 533let repo = gix:: open ( & repo_path) ?; 534let commit_at = |hex : & str | ->Result < gix:: Commit < ' _ >> { 535Ok ( repo 536. find_object ( gix:: ObjectId :: from_hex ( hex. as_bytes ()) ?) ? 537. try_into_commit () ?) 538}; 539let first_files =blob_versions ( & repo, & commit_at ( & first) ?, None , & None ) ?; 540assert_eq! ( first_files[ "src/stable.txt" ]. touched , first); 541assert_eq! ( first_files[ "changed.txt" ]. touched , first); 542 543let first_manifest =RefManifest { 544tip : first. clone (), 545files : first_files. clone (), 546}; 547let second_files =blob_versions ( & repo, & commit_at ( & second) ?, Some ( & first_manifest), & None ) ?; 548assert_eq! ( second_files[ "src/stable.txt" ]. touched , first); 549assert_eq! ( second_files[ "changed.txt" ]. touched , second); 550 551let third_files =blob_versions ( & repo, & commit_at ( & third) ?, Some ( & first_manifest), & None ) ?; 552assert_eq! ( third_files[ "changed.txt" ]. oid , first_files[ "changed.txt" ]. oid ); 553assert_eq! ( third_files[ "changed.txt" ]. touched , third); 554Ok (()) 555} 556 557# [ test ] 558fn cancellable_build_aborts_before_rendering () ->Result <()> { 559let root =TempDir :: new ( "cancelled" ); 560let repo = root. join ( "repo" ); 561init_repo ( & repo) ?; 562 fs:: write ( repo. join ( "README.md" ), "# hi\n" ) ?; 563commit ( & repo, "initial" ) ?; 564let config =Config { 565 repo, 566out : root. join ( "out" ), 567instance_name : "test" . into (), 568name : Some ( "u/r" . into ()), 569clone_url : None , 570}; 571let cancel = std:: sync:: Arc :: new ( std:: sync:: atomic:: AtomicBool :: new ( true )); 572assert! ( full_cancellable ( & config, None , grammar_cache (), Some ( cancel)). is_err ()); 573assert! ( !config. out . exists ()); 574Ok (()) 575} 576 577# [ test ] 578fn renders_sha256_repositories () ->Result <()> { 579let root =TempDir :: new ( "sha256" ); 580let repo = root. join ( "repo" ); 581init_sha256_repo ( & repo) ?; 582 fs:: write ( repo. join ( "README.md" ), "# SHA-256\n" ) ?; 583let head =commit ( & repo, "initial" ) ?; 584assert_eq! ( head. len (), 64 ); 585 586let out = root. join ( "out" ); 587full ( 588& Config { 589 repo, 590out : out. clone (), 591instance_name : "test" . into (), 592name : Some ( "sha256" . into ()), 593clone_url : None , 594}, 595None , 596grammar_cache (), 597) ?; 598 599let info: serde_json:: Value = serde_json:: from_slice ( & fs:: read ( out. join ( "gitinfo.json" )) ?) ?; 600assert_eq! ( info[ "refs" ][ 0 ][ "oid" ]. as_str (), Some ( head. as_str ())); 601assert! ( out. join ( "ref/main/blob/README.md" ). is_file ()); 602Ok (()) 603} 604 605# [ test ] 606fn loads_only_compatible_blob_manifests () ->Result <()> { 607let root =TempDir :: new ( "blob-manifest" ); 608let files =BTreeMap :: from ([( 609"odd\npath.rs" . into (), 610crate :: render:: BlobVersion { 611oid : "abc123" . into (), 612mode : 0o100644 , 613touched : "def456" . into (), 614}, 615)]); 616let manifest =BlobManifest { 617format : OUTPUT_FORMAT_VERSION , 618instance_name : "example" . into (), 619name : "alice/repo" . into (), 620refs : BTreeMap :: from ([( 621"main" . into (), 622RefManifest { 623tip : "def456" . into (), 624 files, 625}, 626)]), 627}; 628 fs:: write ( root. join ( MANIFEST_FILE ), serde_json:: to_vec ( & manifest) ?) ?; 629 630let loaded =BlobManifest :: load ( & root, "example" , "alice/repo" ). unwrap (); 631assert_eq! ( loaded. refs [ "main" ]. files [ "odd\npath.rs" ]. oid , "abc123" ); 632assert_eq! ( loaded. refs [ "main" ]. files [ "odd\npath.rs" ]. touched , "def456" ); 633assert! ( BlobManifest :: load ( & root, "other" , "alice/repo" ). is_none ()); 634assert! ( BlobManifest :: load ( & root, "example" , "other/repo" ). is_none ()); 635 636let incompatible =BlobManifest { 637format : OUTPUT_FORMAT_VERSION +1 , 638 ..manifest 639}; 640 fs:: write ( root. join ( MANIFEST_FILE ), serde_json:: to_vec ( & incompatible) ?) ?; 641assert! ( BlobManifest :: load ( & root, "example" , "alice/repo" ). is_none ()); 642 643 fs:: write ( root. join ( MANIFEST_FILE ), "not json" ) ?; 644assert! ( BlobManifest :: load ( & root, "example" , "alice/repo" ). is_none ()); 645Ok (()) 646} 647 648fn html_files ( dir : & Path , into : & mut Vec < PathBuf >) ->Result <()> { 649for entryin fs:: read_dir ( dir) ?{ 650let path = entry?. path (); 651if path. is_dir () { 652html_files ( & path, into) ?; 653} else if fs:: read ( & path) ?. starts_with ( b"<!doctype html>" ) { 654 into. push ( path); 655} 656} 657Ok (()) 658} 659 660# [ test ] 661fn previews_images_and_resolves_readme_images_at_each_revision () ->Result <()> { 662let root =TempDir :: new ( "images" ); 663let repo = root. join ( "repo" ); 664init_repo ( & repo) ?; 665 fs:: create_dir_all ( repo. join ( "demos" )) ?; 666 fs:: create_dir_all ( repo. join ( "docs" )) ?; 667let image ="demos/a #.png" ; 668 fs:: write ( repo. join ( image), b"\x89PNG\r\n\x1a\nold" ) ?; 669 fs:: write ( repo. join ( "large.PNG" ), vec! [ 0 ; ( 1 <<20 ) +1 ]) ?; 670 fs:: write ( repo. join ( "archive.bin" ), b"binary\0data" ) ?; 671 fs:: write ( repo. join ( "large.txt" ), vec! [ b'x' ; ( 1 <<20 ) +1 ]) ?; 672 fs:: write ( repo. join ( "README.md" ), "\n" ) ?; 673 fs:: write ( 674 repo. join ( "docs/README.md" ), 675"\n\n\n" , 676) ?; 677commit ( & repo, "first" ) ?; 678let config =Config { 679repo : repo. clone (), 680out : root. join ( "first" ), 681instance_name : "test" . into (), 682name : Some ( "u/r" . into ()), 683clone_url : None , 684}; 685full ( & config, None , grammar_cache ()) ?; 686let first_oid =git ( & repo, & [ "rev-parse" , "HEAD:demos/a #.png" ]) ?; 687let first_url =format! ( "/u/r/raw/{first_oid}/demos/a%20%23.png" ); 688for pagein [ 689"index.html" , 690"ref/main/index.html" , 691"ref/main/blob/README.md" , 692"ref/main/tree/docs/index.html" , 693"ref/main/blob/docs/README.md" , 694"ref/main/blob/demos/a #.png" , 695] { 696let html = fs:: read_to_string ( config. out . join ( page)) ?; 697assert! ( 698 html. contains ( & format! ( "src=\"{first_url}\"" )), 699"{page}: {html}" 700); 701} 702let nested = fs:: read_to_string ( config. out . join ( "ref/main/blob/docs/README.md" )) ?; 703assert! ( nested. contains ( "src=\"https://example.org/demo.png\"" )); 704let large = fs:: read_to_string ( config. out . join ( "ref/main/blob/large.PNG" )) ?; 705assert! ( large. contains ( "<img src=\"/u/r/raw/" )); 706for namein [ "archive.bin" , "large.txt" ] { 707let html = fs:: read_to_string ( config. out . join ( format! ( "ref/main/blob/{name}" ))) ?; 708let oid =git ( & repo, & [ "rev-parse" , & format! ( "HEAD:{name}" )]) ?; 709assert! ( html. contains ( & format! ( 710"href=\"/u/r/raw/{oid}/{name}\" download=\"{name}\"" 711))); 712assert! ( html. contains ( "cannot be previewed" )); 713assert! ( !html. contains ( "<img" )); 714} 715 716 fs:: write ( repo. join ( image), b"\x89PNG\r\n\x1a\nnew" ) ?; 717commit ( & repo, "image changed, markdown unchanged" ) ?; 718let second =Config { 719out : root. join ( "second" ), 720 ..config. clone () 721}; 722full ( & second, Some ( & config. out ), grammar_cache ()) ?; 723let second_oid =git ( & repo, & [ "rev-parse" , "HEAD:demos/a #.png" ]) ?; 724for pagein [ "ref/main/blob/README.md" , "ref/main/blob/docs/README.md" ] { 725let html = fs:: read_to_string ( second. out . join ( page)) ?; 726assert! ( html. contains ( & format! ( "src=\"/u/r/raw/{second_oid}/demos/a%20%23.png\"" ))); 727assert! ( !html. contains ( & first_url)); 728} 729Ok (()) 730} 731 732/// The generator's links and its output paths are produced by separate 733/// code, so a mismatch in encoding or layout between them only shows up 734/// by following the links. 735# [ test ] 736fn every_internal_link_resolves () ->Result <()> { 737let root =TempDir :: new ( "links" ); 738let repo = root. join ( "repo" ); 739init_repo ( & repo) ?; 740 fs:: create_dir_all ( repo. join ( "dir with space/nested" )) ?; 741 fs:: write ( 742 repo. join ( "README.md" ), 743"[folder](dir%20with%20space) [file](<dir with space/nested/f%231%3F.txt#L1>) \ 744[symlink](./link) [anchor](#top)\n" , 745) ?; 746 fs:: write ( 747 repo. join ( "dir with space/nested/notes.md" ), 748"[root](../..) [up](../../README.md) [sibling](f%231%3F.txt) [parent](../\u{e9}.txt)\n" , 749) ?; 750 fs:: write ( repo. join ( "dir with space/nested/f#1?.txt" ), "one" ) ?; 751 fs:: write ( repo. join ( "dir with space/\u{e9}.txt" ), "two" ) ?; 752 std:: os:: unix:: fs:: symlink ( "README.md" , repo. join ( "link" )) ?; 753commit ( & repo, "first" ) ?; 754git ( & repo, & [ "tag" , "v1" ]) ?; 755git ( & repo, & [ "branch" , "feature/x" ]) ?; 756 757let out = root. join ( "out" ); 758let config =Config { 759repo : repo. clone (), 760out : out. clone (), 761instance_name : "test" . into (), 762name : Some ( "alice/my repo" . into ()), 763clone_url : None , 764}; 765full ( & config, None , grammar_cache ()) ?; 766 767let base ="/alice/my%20repo/" ; 768let mut pages =Vec :: new (); 769html_files ( & out, & mut pages) ?; 770assert! ( !pages. is_empty ()); 771let mut checked =0 ; 772for pagein pages{ 773let html = fs:: read_to_string ( & page) ?; 774let links =[ "href=\"" , "src=\"" ] 775. iter () 776. flat_map ( |attr| html. split ( attr). skip ( 1 )) 777. map ( |rest| rest. split ( '"' ). next (). unwrap ()); 778for linkin links{ 779assert! ( link. starts_with ([ '/' , '#' ]), "{}: relative link {link}" , page. display ()); 780let Some ( rel) = link. strip_prefix ( base) else { 781continue ; 782}; 783let rel = rel. split ( '#' ). next (). unwrap (); 784// Raw blobs are served by the daemon straight from the repo. 785if rel. starts_with ( "raw/" ) { 786continue ; 787} 788let rel =percent_decode_str ( rel). decode_utf8 () ?; 789let target =if rel. is_empty () || rel. ends_with ( '/' ) { 790 out. join ( &* rel). join ( "index.html" ) 791} else { 792 out. join ( &* rel) 793}; 794assert! ( 795 target. is_file (), 796"{}: {link} -> missing {}" , 797 page. display (), 798 target. display (), 799); 800 checked +=1 ; 801} 802} 803assert! ( checked >0 ); 804Ok (()) 805} 806 807fn build ( repo : & Path , out : & Path ) ->Result <()> { 808full ( 809& Config { 810repo : repo. to_owned (), 811out : out. to_owned (), 812instance_name : "test" . into (), 813name : Some ( "u/r" . into ()), 814clone_url : None , 815}, 816None , 817grammar_cache (), 818) 819} 820}