forks/sorcery
Mirror of https://git.t4t.associates/char/sorcery
git clone https://git.gae.moe/forks/sorcery
768100d
grace-patches
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 ; 31from ?:string ; 32status :"added" | "deleted" | "modified" | "renamed" ; 33oldOid :string | null ; 34newOid :string | null ; 35mode :number ; 36} 37 38const TREE = 0o040000 ; 39const GITLINK = 0o160000 ; 40const isTree = ( mode :number ) => ( mode & 0o170000 ) === TREE ; 41 42/** the entry at `path` within `tree`; null if absent (or under a non-tree) */ 43export async function objectAt ( repo :GitRepo , tree :string , path :string []) :Promise < LocatedObject | null > { 44let object :LocatedObject | null = { oid :tree , mode :TREE }; 45for ( const name of path ) { 46if ( ! object || ! isTree ( object . mode )) return null ; 47object = ( await repo . tree ( object . oid )). find ( entry => entry . name === name ) ?? null ; 48} 49return object ; 50} 51 52export interface PathChange { 53commit :Commit ; 54/** null when the commit deleted it */ 55object :LocatedObject | null ; 56} 57 58/** 59* `git log -- path`: commits whose object at `path` differs from every 60* parent's, following only a TREESAME parent through merges that didn't 61* touch it. the daemon computes pages when it can; otherwise the same walk 62* runs here, picking up from whatever frontier the daemon last returned. 63* `scanned` reports commits examined by the local walk, for progress. 64*/ 65export async function * pathHistory ( 66repo :GitRepo , 67tips :string [], 68path :string [], 69scanned :( count :number ) => void , 70) :AsyncGenerator < PathChange > { 71let frontier = tips ; 72while ( frontier . length > 0 ) { 73const page = await repo . pathHistory ( frontier , path , HISTORY_PAGE ); 74if ( ! page ) return yield * walkPathHistory ( repo , frontier , path , scanned ); 75for ( const commit of page . commits ) { 76yield { commit, object :await objectAt ( repo , commit . tree , path ) }; 77} 78frontier = page . frontier ; 79} 80} 81 82const sameObject = ( a :LocatedObject | null , b :LocatedObject | null ) => 83a ?. oid === b ?. oid && a ?. mode === b ?. mode ; 84 85async function * walkPathHistory ( 86repo :GitRepo , 87frontier :string [], 88path :string [], 89scanned :( count :number ) => void , 90) :AsyncGenerator < PathChange > { 91const seen = new Set < string >(); 92const queue :Commit [] = []; // newest committer date first, like git 93const located = new Map < string , LocatedObject | null >(); 94const locate = async ( tree :string ) => { 95let object = located . get ( tree ); 96if ( object === undefined ) located . set ( tree , object = await objectAt ( repo , tree , path )); 97return object ; 98}; 99const push = async ( oid :string ) => { 100const commit = await repo . commit ( oid ); // peels tags 101if ( seen . has ( commit . oid )) return ; 102seen . add ( commit . oid ); 103const at = queue . findIndex ( other => other . committer . time < commit . committer . time ); 104queue . splice ( at === - 1 ?queue . length :at , 0 , commit ); 105}; 106 107for ( const oid of frontier ) await push ( oid ); 108while ( queue . length > 0 ) { 109const commit = queue . shift () ! ; 110scanned ( 1 ); 111const object = await locate ( commit . tree ); 112let treesame :string | undefined ; 113for ( const parent of commit . parents ) { 114if ( sameObject ( object , await locate (( await repo . commit ( parent )). tree ))) { 115treesame = parent ; 116break ; 117} 118} 119if ( treesame !== undefined ) { 120await push ( treesame ); 121continue ; 122} 123if ( commit . parents . length === 0 && ! object ) continue ; 124yield { commit, object}; 125for ( const parent of commit . parents ) await push ( parent ); 126} 127} 128 129/** diff two trees, skipping identical subtrees by oid */ 130export async function treeDiff ( 131repo :GitRepo , 132oldTree :string | null , 133newTree :string | null , 134prefix = "" , 135) :Promise < FileChange []> { 136if ( oldTree === newTree ) return []; 137const changes :FileChange [] = []; 138let frontier = [{ oldTree, newTree, prefix}]; 139 140while ( frontier . length > 0 ) { 141await repo . prefetch ( 142frontier . flatMap ( pair => [ pair . oldTree , pair . newTree ]. filter ( oid => oid !== null )), 143{ depth :0 }, 144); 145const subtrees :typeof frontier = []; 146 147for ( const pair of frontier ) { 148if ( pair . oldTree === pair . newTree ) continue ; 149const [ olds , news ] = await Promise . all ([ 150pair . oldTree ?repo . tree ( pair . oldTree ) :Promise . resolve ([]), 151pair . newTree ?repo . tree ( pair . newTree ) :Promise . resolve ([]), 152]); 153const oldByName = new Map ( olds . map ( entry => [ entry . name , entry ])); 154 155for ( const entry of news ) { 156const old = oldByName . get ( entry . name ); 157oldByName . delete ( entry . name ); 158const path = pair . prefix + entry . name ; 159if ( old ?. oid === entry . oid && old . mode === entry . mode ) continue ; 160 161if ( isTree ( entry . mode ) || ( old && isTree ( old . mode ))) { 162subtrees . push ({ 163oldTree :old && isTree ( old . mode ) ?old . oid :null , 164newTree :isTree ( entry . mode ) ?entry . oid :null , 165prefix :path + "/" , 166}); 167// an entry that changed kind between tree and file also diffs as a file 168if ( ! isTree ( entry . mode ) && entry . mode !== GITLINK ) { 169changes . push ({ path, status :"added" , oldOid :null , newOid :entry . oid , mode :entry . mode }); 170} 171if ( old && ! isTree ( old . mode ) && old . mode !== GITLINK ) { 172changes . push ({ path, status :"deleted" , oldOid :old . oid , newOid :null , mode :old . mode }); 173} 174continue ; 175} 176if ( entry . mode === GITLINK || old ?. mode === GITLINK ) continue ; 177changes . push ({ 178 path, 179status :old ?"modified" :"added" , 180oldOid :old ?. oid ?? null , 181newOid :entry . oid , 182mode :entry . mode , 183}); 184} 185 186for ( const old of oldByName . values ()) { 187const path = pair . prefix + old . name ; 188if ( isTree ( old . mode )) { 189subtrees . push ({ oldTree :old . oid , newTree :null , prefix :path + "/" }); 190} else if ( old . mode !== GITLINK ) { 191changes . push ({ path, status :"deleted" , oldOid :old . oid , newOid :null , mode :old . mode }); 192} 193} 194} 195frontier = subtrees ; 196} 197 198return detectRenames ( repo , changes . sort (( a , b ) => ( a . path < b . path ?- 1 :1 ))); 199} 200 201const RENAME_SIMILARITY = 0.5 ; 202 203type LineProfile = { counts :Map < string , number >; lines :number }; 204 205function profile ( text :string ) :LineProfile { 206const lines = text === "" ?[] :text . split ( "\n" ); 207if ( lines . at ( - 1 ) === "" ) lines . pop (); 208const counts = new Map < string , number >(); 209for ( const line of lines ) counts . set ( line , ( counts . get ( line ) ?? 0 ) + 1 ); 210return { counts, lines :lines . length }; 211} 212 213/** 214* git-style rename detection over a tree diff's deletions and additions 215* 1. pair identical blobs at differing paths 216* 2. pair most-similar blobs that have >=50% shared lines 217*/ 218async function detectRenames ( repo :GitRepo , changes :FileChange []) :Promise < FileChange []> { 219// a path that changed kind is both deleted and added; its halves belong to each other, not to a rename 220const addedPaths = new Set ( changes . filter ( c => c . status === "added" ). map ( c => c . path )); 221const deleted = changes . filter ( c => c . status === "deleted" && ! addedPaths . has ( c . path )); 222const deletedPaths = new Set ( changes . filter ( c => c . status === "deleted" ). map ( c => c . path )); 223const added = changes . filter ( c => c . status === "added" && ! deletedPaths . has ( c . path )); 224if ( deleted . length === 0 || added . length === 0 ) return changes ; 225 226// pairing converts the added entry in place; `changes` is sorted by the new path, so it stays sorted 227const gone = new Set < FileChange >(); 228const taken = new Set < FileChange >(); 229const pair = ( from :FileChange , to :FileChange ) => { 230gone . add ( from ); 231taken . add ( to ); 232to . status = "renamed" ; 233to . from = from . path ; 234to . oldOid = from . oldOid ; 235}; 236 237const addedByOid = new Map < string , FileChange []>(); 238for ( const change of added ) { 239const queue = addedByOid . get ( change . newOid ! ) ?? []; 240queue . push ( change ); 241addedByOid . set ( change . newOid ! , queue ); 242} 243for ( const change of deleted ) { 244const match = addedByOid . get ( change . oldOid ! )?. shift (); 245if ( match ) pair ( change , match ); 246} 247 248const leftoverDeleted = deleted . filter ( c => ! gone . has ( c )); 249const leftoverAdded = added . filter ( c => ! taken . has ( c )); 250if ( leftoverDeleted . length > 0 && leftoverAdded . length > 0 ) { 251const leftover = [ ...leftoverDeleted , ...leftoverAdded ]; 252await repo . prefetch ( leftover . map ( c => ( c . oldOid ?? c . newOid ) ! ), { depth :0 }); 253const profiles = new Map < FileChange , LineProfile | null >(); 254await Promise . all ( leftover . map ( async change => { 255const data = await repo . blob (( change . oldOid ?? change . newOid ) ! ); 256profiles . set ( 257change , 258data . subarray ( 0 , 8192 ). includes ( 0 ) ?null :profile ( new TextDecoder (). decode ( data )), 259); 260})); 261const candidates :{ from :FileChange ; to :FileChange ; score :number }[] = []; 262for ( const from of leftoverDeleted ) { 263const a = profiles . get ( from ); 264if ( ! a || a . lines === 0 ) continue ; 265for ( const to of leftoverAdded ) { 266const b = profiles . get ( to ); 267if ( ! b || b . lines === 0 ) continue ; 268if ( Math . min ( a . lines , b . lines ) / Math . max ( a . lines , b . lines ) < RENAME_SIMILARITY ) continue ; 269let shared = 0 ; 270const [ smaller , larger ] = a . counts . size <= b . counts . size ?[ a , b ] :[ b , a ]; 271for ( const [ line , count ] of smaller . counts ) { 272shared += Math . min ( count , larger . counts . get ( line ) ?? 0 ); 273} 274const score = shared / Math . max ( a . lines , b . lines ); 275if ( score >= RENAME_SIMILARITY ) candidates . push ({ from, to, score}); 276} 277} 278candidates . sort (( x , y ) => 279y . score - x . score || ( x . from . path < y . from . path ?- 1 :1 ) || ( x . to . path < y . to . path ?- 1 :1 ) 280); 281for ( const { from, to} of candidates ) { 282if ( ! gone . has ( from ) && ! taken . has ( to )) pair ( from , to ); 283} 284} 285 286return gone . size === 0 ?changes :changes . filter ( c => ! gone . has ( c )); 287}