char/sorcery
static-files based git repo viewer
git clone https://git.t4t.associates/char/sorcery
5d40e97
main
1//! `git log -- path` with git's default history simplification: walk commits 2//! newest-first, and at each merge that is TREESAME to a parent follow only 3//! that parent, so the walk never leaves the side of a merge that touched the 4//! path. The walk state is just the frontier, so a page can be resumed from 5//! whatever it hands back. 6 7use std:: cmp:: Reverse ; 8use std:: collections::{ BinaryHeap , HashMap , HashSet }; 9 10use anyhow:: Result ; 11 12const TREE : u16 =0o040000 ; 13 14# [ derive ( Clone , Copy , PartialEq , Eq )] 15struct Located { 16oid : gix:: ObjectId , 17mode : u16 , 18} 19 20pub struct Page { 21/// Commits that changed the path, in walk order. 22pub changes : Vec < gix:: ObjectId >, 23/// Where to resume; empty once history is exhausted. 24pub frontier : Vec < gix:: ObjectId >, 25} 26 27/// Walks from `frontier` until `limit` changes are found, `scan_limit` commits 28/// have been examined, or history is exhausted (empty returned frontier). 29pub fn page ( 30repo : & gix:: Repository , 31frontier : & [ gix:: ObjectId ], 32path : & [ String ], 33limit : usize , 34scan_limit : usize , 35) ->Result < Page > { 36// committer-date order with FIFO ties, like git's commit_list_insert_by_date 37let mut heap =BinaryHeap :: new (); 38let mut seen =HashSet :: new (); 39let mut located =HashMap :: new (); 40let mut changes =Vec :: new (); 41let mut scanned =0 ; 42 43let mut push = |heap : & mut BinaryHeap < _ >, mut oid : gix:: ObjectId | ->Result <()> { 44let commit =loop { 45let object = repo. find_object ( oid) ?; 46match object. kind { 47 gix:: object:: Kind :: Tag => oid = object. into_tag (). target_id () ?. detach (), 48 _ =>break object. try_into_commit () ?, 49} 50}; 51if seen. insert ( oid) { 52 heap. push (( commit. time () ?. seconds , Reverse ( seen. len ()), oid)); 53} 54Ok (()) 55}; 56for & oidin frontier{ 57push ( & mut heap, oid) ?; 58} 59 60while changes. len () < limit && scanned < scan_limit{ 61let Some (( _, _, oid)) = heap. pop () else { 62break ; 63}; 64 scanned +=1 ; 65let commit = repo. find_object ( oid) ?. into_commit (); 66let object =locate ( repo, & mut located, commit. tree_id () ?. detach (), path) ?; 67let parents = commit. parent_ids (). map ( |id| id. detach ()). collect ::< Vec < _ >>(); 68 69let mut treesame =None ; 70for & parentin & parents{ 71let tree = repo. find_object ( parent) ?. try_into_commit () ?. tree_id () ?. detach (); 72if locate ( repo, & mut located, tree, path) ? == object{ 73 treesame =Some ( parent); 74break ; 75} 76} 77match treesame{ 78Some ( parent) =>push ( & mut heap, parent) ?, 79None =>{ 80if parents. is_empty () && object. is_none () { 81continue ; 82} 83 changes. push ( oid); 84for parentin parents{ 85push ( & mut heap, parent) ?; 86} 87} 88} 89} 90 91Ok ( Page { 92 changes, 93// in walk order, so resuming re-pushes ties in the same order 94frontier : heap. into_sorted_vec (). into_iter (). rev (). map ( |( _, _, oid) | oid). collect (), 95}) 96} 97 98/// The entry at `path` within `tree`, memoised per tree since consecutive 99/// commits share nearly all of theirs. 100fn locate ( 101repo : & gix:: Repository , 102memo : & mut HashMap < gix:: ObjectId , Option < Located >>, 103tree : gix:: ObjectId , 104path : & [ String ], 105) ->Result < Option < Located >> { 106if let Some ( & found) = memo. get ( & tree) { 107return Ok ( found); 108} 109let mut found =Some ( Located { oid : tree, mode : TREE }); 110for componentin path{ 111let Some ( Located { oid, mode : TREE }) = foundelse { 112 found =None ; 113break ; 114}; 115 found =None ; 116for entryin repo. find_object ( oid) ?. try_into_tree () ?. iter () { 117let entry = entry?; 118if entry. filename () == component. as_bytes () { 119 found =Some ( Located { oid : entry. oid (). to_owned (), mode : entry. mode (). value () }); 120break ; 121} 122} 123} 124 memo. insert ( tree, found); 125Ok ( found) 126} 127 128# [ cfg ( test )] 129mod tests{ 130use std:: fs; 131 132use anyhow:: Result ; 133 134use crate :: testutil::{ TempDir , commit, git, init_repo}; 135 136use super :: page; 137 138/// A merge where the path changed on the side branch only, a merge that 139/// changed it on both sides, and a deletion. 140# [ test ] 141fn matches_git_log_simplification () ->Result <()> { 142let root =TempDir :: new ( "history" ); 143let repo = root. join ( "repo" ); 144init_repo ( & repo) ?; 145 fs:: create_dir ( repo. join ( "src" )) ?; 146 fs:: write ( repo. join ( "src/a.txt" ), "1" ) ?; 147 fs:: write ( repo. join ( "other" ), "x" ) ?; 148commit ( & repo, "one" ) ?; 149git ( & repo, & [ "checkout" , "-qb" , "side" ]) ?; 150 fs:: write ( repo. join ( "src/a.txt" ), "2" ) ?; 151commit ( & repo, "two (side)" ) ?; 152git ( & repo, & [ "checkout" , "-q" , "main" ]) ?; 153 fs:: write ( repo. join ( "other" ), "y" ) ?; 154commit ( & repo, "unrelated" ) ?; 155git ( & repo, & [ "merge" , "-q" , "--no-ff" , "-m" , "merge side" , "side" ]) ?; 156git ( & repo, & [ "checkout" , "-qb" , "conflict" ]) ?; 157 fs:: write ( repo. join ( "src/a.txt" ), "3" ) ?; 158commit ( & repo, "three (conflict)" ) ?; 159git ( & repo, & [ "checkout" , "-q" , "main" ]) ?; 160 fs:: write ( repo. join ( "src/a.txt" ), "4" ) ?; 161commit ( & repo, "four" ) ?; 162git ( & repo, & [ "merge" , "-q" , "conflict" ]). ok (); 163 fs:: write ( repo. join ( "src/a.txt" ), "5" ) ?; 164commit ( & repo, "resolve" ) ?; 165git ( & repo, & [ "rm" , "-q" , "src/a.txt" ]) ?; 166commit ( & repo, "delete" ) ?; 167let head =git ( & repo, & [ "rev-parse" , "HEAD" ]) ?; 168let expected =git ( & repo, & [ "log" , "--format=%H" , "--" , "src/a.txt" ]) ?; 169let expected = expected. lines (). collect ::< Vec < _ >>(); 170 171let gix = gix:: open ( & repo) ?; 172let path =[ "src" . to_owned (), "a.txt" . to_owned ()]; 173let head = gix:: ObjectId :: from_hex ( head. as_bytes ()) ?; 174 175let whole =page ( & gix, & [ head], & path, 100 , 1000 ) ?; 176assert_eq! ( 177 whole. changes . iter (). map ( ToString :: to_string). collect ::< Vec < _ >>(), 178 expected, 179); 180assert! ( whole. frontier . is_empty ()); 181 182// resuming through the frontier reproduces the same sequence 183let mut frontier =vec! [ head]; 184let mut paged =Vec :: new (); 185while !frontier. is_empty () { 186let result =page ( & gix, & frontier, & path, 2 , 2 ) ?; 187 paged. extend ( result. changes . iter (). map ( ToString :: to_string)); 188 frontier = result. frontier ; 189} 190assert_eq! ( paged, expected); 191Ok (()) 192} 193}