char/sorcery
static-files based git repo viewer
git clone https://git.t4t.associates/char/sorcery
5d40e97
main
1import type { Commit , LocatedObject } from "./types.ts" ; 2import type { GitRepo } from "./repo.ts" ; 3 4/** 5* changes per path-history request. each comes with its trees along the 6* path, and the daemon's bundles cap out at 128 objects. 7*/ 8const HISTORY_PAGE = 10 ; 9 10/** breadth-first walk matching gix's default revision order */ 11export async function * log ( repo :GitRepo , tips :string [], pageSize :number ) :AsyncGenerator < Commit > { 12const seen = new Set < string >( tips ); 13const frontier = [ ...tips ]; 14 15while ( frontier . length > 0 ) { 16await repo . prefetch ( frontier . slice ( 0 , pageSize ), { smart :"commit-pagination" , limit :pageSize }); 17for ( let i = 0 ; i < pageSize && frontier . length > 0 ; i ++ ) { 18const commit = await repo . commit ( frontier . shift () ! ); 19yield commit ; 20for ( const parent of commit . parents ) { 21if ( seen . has ( parent )) continue ; 22seen . add ( parent ); 23frontier . push ( parent ); 24} 25} 26} 27} 28 29export interface FileChange { 30path :string ; 31status :"added" | "deleted" | "modified" ; 32oldOid :string | null ; 33newOid :string | null ; 34mode :number ; 35} 36 37const TREE = 0o040000 ; 38const GITLINK = 0o160000 ; 39const isTree = ( mode :number ) => ( mode & 0o170000 ) === TREE ; 40 41/** the entry at `path` within `tree`; null if absent (or under a non-tree) */ 42export async function objectAt ( repo :GitRepo , tree :string , path :string []) :Promise < LocatedObject | null > { 43let object :LocatedObject | null = { oid :tree , mode :TREE }; 44for ( const name of path ) { 45if ( ! object || ! isTree ( object . mode )) return null ; 46object = ( await repo . tree ( object . oid )). find ( entry => entry . name === name ) ?? null ; 47} 48return object ; 49} 50 51export interface PathChange { 52commit :Commit ; 53/** null when the commit deleted it */ 54object :LocatedObject | null ; 55} 56 57/** 58* `git log -- path`: commits whose object at `path` differs from every 59* parent's, following only a TREESAME parent through merges that didn't 60* touch it. the daemon computes pages when it can; otherwise the same walk 61* runs here, picking up from whatever frontier the daemon last returned. 62* `scanned` reports commits examined by the local walk, for progress. 63*/ 64export async function * pathHistory ( 65repo :GitRepo , 66tips :string [], 67path :string [], 68scanned :( count :number ) => void , 69) :AsyncGenerator < PathChange > { 70let frontier = tips ; 71while ( frontier . length > 0 ) { 72const page = await repo . pathHistory ( frontier , path , HISTORY_PAGE ); 73if ( ! page ) return yield * walkPathHistory ( repo , frontier , path , scanned ); 74for ( const commit of page . commits ) { 75yield { commit, object :await objectAt ( repo , commit . tree , path ) }; 76} 77frontier = page . frontier ; 78} 79} 80 81const sameObject = ( a :LocatedObject | null , b :LocatedObject | null ) => 82a ?. oid === b ?. oid && a ?. mode === b ?. mode ; 83 84async function * walkPathHistory ( 85repo :GitRepo , 86frontier :string [], 87path :string [], 88scanned :( count :number ) => void , 89) :AsyncGenerator < PathChange > { 90const seen = new Set < string >(); 91const queue :Commit [] = []; // newest committer date first, like git 92const located = new Map < string , LocatedObject | null >(); 93const locate = async ( tree :string ) => { 94let object = located . get ( tree ); 95if ( object === undefined ) located . set ( tree , object = await objectAt ( repo , tree , path )); 96return object ; 97}; 98const push = async ( oid :string ) => { 99const commit = await repo . commit ( oid ); // peels tags 100if ( seen . has ( commit . oid )) return ; 101seen . add ( commit . oid ); 102const at = queue . findIndex ( other => other . committer . time < commit . committer . time ); 103queue . splice ( at === - 1 ?queue . length :at , 0 , commit ); 104}; 105 106for ( const oid of frontier ) await push ( oid ); 107while ( queue . length > 0 ) { 108const commit = queue . shift () ! ; 109scanned ( 1 ); 110const object = await locate ( commit . tree ); 111let treesame :string | undefined ; 112for ( const parent of commit . parents ) { 113if ( sameObject ( object , await locate (( await repo . commit ( parent )). tree ))) { 114treesame = parent ; 115break ; 116} 117} 118if ( treesame !== undefined ) { 119await push ( treesame ); 120continue ; 121} 122if ( commit . parents . length === 0 && ! object ) continue ; 123yield { commit, object}; 124for ( const parent of commit . parents ) await push ( parent ); 125} 126} 127 128/** diff two trees, skipping identical subtrees by oid */ 129export async function treeDiff ( 130repo :GitRepo , 131oldTree :string | null , 132newTree :string | null , 133prefix = "" , 134) :Promise < FileChange []> { 135if ( oldTree === newTree ) return []; 136const changes :FileChange [] = []; 137let frontier = [{ oldTree, newTree, prefix}]; 138 139while ( frontier . length > 0 ) { 140await repo . prefetch ( 141frontier . flatMap ( pair => [ pair . oldTree , pair . newTree ]. filter ( oid => oid !== null )), 142{ depth :0 }, 143); 144const subtrees :typeof frontier = []; 145 146for ( const pair of frontier ) { 147if ( pair . oldTree === pair . newTree ) continue ; 148const [ olds , news ] = await Promise . all ([ 149pair . oldTree ?repo . tree ( pair . oldTree ) :Promise . resolve ([]), 150pair . newTree ?repo . tree ( pair . newTree ) :Promise . resolve ([]), 151]); 152const oldByName = new Map ( olds . map ( entry => [ entry . name , entry ])); 153 154for ( const entry of news ) { 155const old = oldByName . get ( entry . name ); 156oldByName . delete ( entry . name ); 157const path = pair . prefix + entry . name ; 158if ( old ?. oid === entry . oid && old . mode === entry . mode ) continue ; 159 160if ( isTree ( entry . mode ) || ( old && isTree ( old . mode ))) { 161subtrees . push ({ 162oldTree :old && isTree ( old . mode ) ?old . oid :null , 163newTree :isTree ( entry . mode ) ?entry . oid :null , 164prefix :path + "/" , 165}); 166// an entry that changed kind between tree and file also diffs as a file 167if ( ! isTree ( entry . mode ) && entry . mode !== GITLINK ) { 168changes . push ({ path, status :"added" , oldOid :null , newOid :entry . oid , mode :entry . mode }); 169} 170if ( old && ! isTree ( old . mode ) && old . mode !== GITLINK ) { 171changes . push ({ path, status :"deleted" , oldOid :old . oid , newOid :null , mode :old . mode }); 172} 173continue ; 174} 175if ( entry . mode === GITLINK || old ?. mode === GITLINK ) continue ; 176changes . push ({ 177 path, 178status :old ?"modified" :"added" , 179oldOid :old ?. oid ?? null , 180newOid :entry . oid , 181mode :entry . mode , 182}); 183} 184 185for ( const old of oldByName . values ()) { 186const path = pair . prefix + old . name ; 187if ( isTree ( old . mode )) { 188subtrees . push ({ oldTree :old . oid , newTree :null , prefix :path + "/" }); 189} else if ( old . mode !== GITLINK ) { 190changes . push ({ path, status :"deleted" , oldOid :old . oid , newOid :null , mode :old . mode }); 191} 192} 193} 194frontier = subtrees ; 195} 196 197return changes . sort (( a , b ) => ( a . path < b . path ?- 1 :1 )); 198}