forks/sorcery
Mirror of https://git.t4t.associates/char/sorcery
git clone https://git.gae.moe/forks/sorcery
768100d
main
1import { assert , assertEquals , assertRejects } from "jsr:@std/assert@1" ; 2import { type Fetcher , GitRepo } from "./repo.ts" ; 3import { diffLines } from "./diff.ts" ; 4import { log , treeDiff } from "./walk.ts" ; 5 6async function sh ( cwd :string , ...args :string []) :Promise < string > { 7const out = await new Deno . Command ( args [ 0 ], { 8args :args . slice ( 1 ), 9 cwd, 10env :{ 11GIT_AUTHOR_NAME :"t" , 12GIT_AUTHOR_EMAIL :"t@t" , 13GIT_COMMITTER_NAME :"t" , 14GIT_COMMITTER_EMAIL :"t@t" , 15}, 16}). output (); 17if ( ! out . success ) throw new Error ( new TextDecoder (). decode ( out . stderr )); 18return new TextDecoder (). decode ( out . stdout ). trim (); 19} 20 21/** reads from a bare repo dir like nginx would serve `.git/`, incl. ranges */ 22function fileFetcher ( gitDir :string ) :Fetcher { 23return async ( path , range ) => { 24if ( path === "gitinfo.json" ) { 25// stand-in for the server-generated manifest 26const packDir = ` ${ gitDir } /objects/pack` ; 27const packs :string [] = []; 28try { 29for await ( const f of Deno . readDir ( packDir )) { 30if ( f . name . endsWith ( ".pack" )) packs . push ( f . name . slice ( 0 , - 5 )); 31} 32} catch { /* no packs yet */ } 33const head = ( await sh ( gitDir , "git" , "symbolic-ref" , "--short" , "HEAD" )) || null ; 34const refs :unknown [] = []; 35for ( const line of ( await sh ( gitDir , "git" , "show-ref" )). split ( "\n" ). filter ( Boolean )) { 36const [ oid , name ] = line . split ( " " ); 37if ( name . startsWith ( "refs/heads/" )) { 38refs . push ({ kind :"branch" , name :name . slice ( 11 ), oid}); 39} 40} 41const json = JSON . stringify ({ head, refs, packs :packs . sort () }); 42return { bytes :new TextEncoder (). encode ( json ), total :json . length }; 43} 44 45const filePath = ` ${ gitDir } / ${ path . replace ( / ^\.git\/ / , "" )} ` ; 46let bytes :Uint8Array ; 47try { 48bytes = await Deno . readFile ( filePath ); 49} catch { 50return null ; 51} 52const total = bytes . length ; 53if ( range ) bytes = bytes . subarray ( range [ 0 ], range [ 1 ] ?? undefined ); 54return { bytes, total}; 55}; 56} 57 58function objectBundle ( specs :Array <[ string , number , Uint8Array ]>) :Uint8Array { 59const bundle = new Uint8Array ( 6 + specs . reduce (( size , [ oid , , data ]) => size + oid . length / 2 + 6 + data . length , 0 )); 60bundle . set ( new TextEncoder (). encode ( "SOBJ" )); 61bundle [ 4 ] = 1 ; 62let position = 6 ; 63for ( const [ oid , type , data ] of specs ) { 64const oidBytes = Uint8Array . from ( oid . match ( / .. / g ) ! , byte => parseInt ( byte , 16 )); 65bundle [ position ++ ] = oidBytes . length ; 66bundle . set ( oidBytes , position ); 67position += oidBytes . length ; 68bundle [ position ++ ] = type ; 69new DataView ( bundle . buffer ). setUint32 ( position , data . length ); 70position += 4 ; 71bundle . set ( data , position ); 72position += data . length ; 73} 74return bundle ; 75} 76 77async function makeFixture ( objectFormat :"sha1" | "sha256" = "sha1" ) :Promise <{ dir :string ; repo :GitRepo }> { 78const dir = await Deno . makeTempDir ({ prefix :"sgw-git-test" }); 79const format = objectFormat === "sha256" ?[ "--object-format=sha256" ] :[]; 80await sh ( dir , "git" , "init" , "-q" , "-b" , "main" , ...format , "." ); 81await Deno . mkdir ( ` ${ dir } /src` ); 82await Deno . writeTextFile ( ` ${ dir } /README.md` , "# fixture\n" ); 83await Deno . writeTextFile ( ` ${ dir } /src/lib.rs` , "fn one() {}\nfn two() {}\nfn three() {}\n" ); 84await sh ( dir , "git" , "add" , "-A" ); 85await sh ( dir , "git" , "commit" , "-q" , "-m" , "initial commit" ); 86await Deno . writeTextFile ( ` ${ dir } /src/library.rs` , "fn one() {}\nfn two() { todo!() }\nfn three() {}\nfn four() {}\n" ); 87await Deno . remove ( ` ${ dir } /src/lib.rs` ); 88await Deno . writeTextFile ( ` ${ dir } /NOTES` , "# fixture\n" ); 89await sh ( dir , "git" , "rm" , "-q" , "README.md" ); 90await sh ( dir , "git" , "add" , "-A" ); 91await sh ( dir , "git" , "commit" , "-q" , "-m" , "second commit" ); 92return { dir, repo :new GitRepo ( fileFetcher ( ` ${ dir } /.git` )) }; 93} 94 95async function assertHistory ( repo :GitRepo ) { 96const info = await repo . info (); 97assertEquals ( info . head , "main" ); 98const main = info . refs . find ( r => r . name === "main" ) ! ; 99 100const commits = []; 101for await ( const commit of log ( repo , [ main . oid ], 10 )) commits . push ( commit ); 102assertEquals ( commits . map ( c => c . message . trim ()), [ "second commit" , "initial commit" ]); 103assertEquals ( commits [ 0 ]. parents , [ commits [ 1 ]. oid ]); 104assertEquals ( commits [ 0 ]. author . name , "t" ); 105 106const changes = await treeDiff ( repo , commits [ 1 ]. tree , commits [ 0 ]. tree ); 107assertEquals ( 108changes . map ( c => ` ${ c . status } ${ c . from ?? "-" } ${ c . path } ` ), 109[ "renamed README.md NOTES" , "renamed src/lib.rs src/library.rs" ], 110); 111 112const modified = changes . find ( c => c . path === "src/library.rs" ) ! ; 113const oldData = await repo . blob ( modified . oldOid ! ); 114const newData = await repo . blob ( modified . newOid ! ); 115const oldText = new TextDecoder (). decode ( oldData ); 116const newText = new TextDecoder (). decode ( newData ); 117const hunks = diffLines ( oldText , newText ); 118assertEquals ( hunks . length , 1 ); 119assertEquals ( 120hunks [ 0 ]. lines . map ( l => l . sign + l . text ), 121[ 122" fn one() {}" , 123"-fn two() {}" , 124"+fn two() { todo!() }" , 125" fn three() {}" , 126"+fn four() {}" , 127], 128); 129} 130 131Deno . test ( "loose objects" , async () => { 132const { repo} = await makeFixture (); 133await assertHistory ( repo ); 134}); 135 136Deno . test ( "object queries seed the repository cache" , async () => { 137const { dir} = await makeFixture (); 138const oid = await sh ( dir , "git" , "rev-parse" , "HEAD" ); 139const treeOid = await sh ( dir , "git" , "show" , "-s" , "--format=%T" , "HEAD" ); 140const commit = await new Deno . Command ( "git" , { args :[ "cat-file" , "commit" , oid ], cwd :dir }). output (); 141const tree = await new Deno . Command ( "git" , { args :[ "cat-file" , "tree" , treeOid ], cwd :dir }). output (); 142assert ( commit . success && tree . success ); 143 144const bundle = objectBundle ([[ oid , 1 , commit . stdout ], [ treeOid , 2 , tree . stdout ]]); 145 146let rawReads = 0 ; 147const queries :unknown [] = []; 148const files = fileFetcher ( ` ${ dir } /.git` ); 149const fetch :Fetcher = ( path , range , query ) => { 150if ( path === "obj" && query ) { 151queries . push ( JSON . parse ( new TextDecoder (). decode ( query ))); 152return Promise . resolve ({ bytes :bundle , total :bundle . length }); 153} 154if ( path . startsWith ( "obj/" ) || path . startsWith ( ".git/objects/" )) rawReads ++ ; 155return files ( path , range ); 156}; 157const name = `sorcery-test- ${ crypto . randomUUID ()} ` ; 158try { 159const repo = new GitRepo ( fetch , name ); 160await repo . prefetch ([ oid ], { depth :2 }); 161await repo . prefetch ([ oid ], { smart :"tree" }); 162const loaded = await repo . commit ( oid ); 163await repo . tree ( loaded . tree ); 164 165const warm = new GitRepo ( fetch , name ); 166await warm . prefetch ([ oid ], { smart :"tree" }); 167await warm . tree (( await warm . commit ( oid )). tree ); 168assertEquals ( queries , [ 169{ oids :[ oid ], depth :2 }, 170{ oids :[ oid ], smart :"tree" }, 171]); 172assertEquals ( rawReads , 0 ); 173} finally { 174await caches . delete ( name ); 175} 176}); 177 178Deno . test ( "commit logs prefetch one page at a time" , async () => { 179const { dir} = await makeFixture (); 180for ( let i = 3 ; i <= 12 ; i ++ ) { 181await Deno . writeTextFile ( ` ${ dir } /n` , ` ${ i } \n` ); 182await sh ( dir , "git" , "add" , "n" ); 183await sh ( dir , "git" , "commit" , "-q" , "-m" , `commit ${ i } ` ); 184} 185const oids = ( await sh ( dir , "git" , "rev-list" , "HEAD" )). split ( "\n" ); 186const commits = new Map < string , Uint8Array >(); 187for ( const oid of oids ) { 188const result = await new Deno . Command ( "git" , { args :[ "cat-file" , "commit" , oid ], cwd :dir }). output (); 189assert ( result . success ); 190commits . set ( oid , result . stdout ); 191} 192 193const queries :unknown [] = []; 194let rawReads = 0 ; 195const files = fileFetcher ( ` ${ dir } /.git` ); 196const fetch :Fetcher = ( path , range , query ) => { 197if ( path === "obj" && query ) { 198const body = JSON . parse ( new TextDecoder (). decode ( query )) as { 199oids :string []; 200smart :string ; 201limit :number ; 202}; 203queries . push ( body ); 204const start = oids . indexOf ( body . oids [ 0 ]); 205const specs = oids . slice ( start , start + body . limit ). map ( oid => [ oid , 1 , commits . get ( oid ) ! ] as [ string , number , Uint8Array ]); 206const bundle = objectBundle ( specs ); 207return Promise . resolve ({ bytes :bundle , total :bundle . length }); 208} 209if ( path . startsWith ( ".git/objects/" )) rawReads ++ ; 210return files ( path , range ); 211}; 212const name = `sorcery-test- ${ crypto . randomUUID ()} ` ; 213try { 214const loaded = []; 215for await ( const commit of log ( new GitRepo ( fetch , name ), oids , 10 )) loaded . push ( commit . oid ); 216assertEquals ( loaded , oids ); 217assertEquals ( queries , [ 218{ oids :oids . slice ( 0 , 10 ), smart :"commit-pagination" , limit :10 }, 219{ oids :oids . slice ( 10 ), smart :"commit-pagination" , limit :10 }, 220]); 221assertEquals ( rawReads , 0 ); 222} finally { 223await caches . delete ( name ); 224} 225}); 226 227Deno . test ( "packed objects (with deltas)" , async () => { 228const { dir} = await makeFixture (); 229// window/depth defaults keep our similar blobs as deltas; prune loose ones 230await sh ( dir , "git" , "-c" , "gc.pruneExpire=now" , "gc" , "-q" , "--aggressive" , "--prune=now" ); 231const repo = new GitRepo ( fileFetcher ( ` ${ dir } /.git` )); 232const info = await repo . info (); 233assert ( info . packs . length > 0 , "expected a pack after gc" ); 234await assertHistory ( repo ); 235}); 236 237Deno . test ( "SHA-256 loose and packed objects" , async () => { 238const { dir, repo} = await makeFixture ( "sha256" ); 239const info = await repo . info (); 240assert ( info . refs . every ( ref => ref . oid . length === 64 )); 241await assertHistory ( repo ); 242 243await sh ( dir , "git" , "-c" , "gc.pruneExpire=now" , "gc" , "-q" , "--aggressive" , "--prune=now" ); 244const packed = new GitRepo ( fileFetcher ( ` ${ dir } /.git` )); 245const packs = ( await packed . info ()). packs ; 246assert ( packs . length > 0 && packs . every ( stem => stem . length === 69 )); 247await assertHistory ( packed ); 248}); 249 250Deno . test ( "pack ranges persist in the cache; stale packs are pruned" , async () => { 251const { dir} = await makeFixture (); 252await sh ( dir , "git" , "-c" , "gc.pruneExpire=now" , "gc" , "-q" , "--aggressive" , "--prune=now" ); 253const files = fileFetcher ( ` ${ dir } /.git` ); 254let packFetches = 0 ; 255const counting :Fetcher = ( path , range ) => { 256if ( path . includes ( "/pack/" )) packFetches ++ ; 257return files ( path , range ); 258}; 259const name = `sorcery-test- ${ crypto . randomUUID ()} ` ; 260try { 261const stale = "https://sorcery-pack-cache.invalid/x/pack-dead.idx?range=all" ; 262await ( await caches . open ( name )). put ( stale , new Response ( "junk" )); 263 264await assertHistory ( new GitRepo ( counting , name )); 265assert ( packFetches > 0 , "expected cold-cache pack fetches" ); 266 267packFetches = 0 ; 268await assertHistory ( new GitRepo ( counting , name )); 269assertEquals ( packFetches , 0 , "warm cache should serve all pack ranges" ); 270 271// pruning is fire-and-forget off the gitinfo fetch, so poll briefly 272const cache = await caches . open ( name ); 273for ( let i = 0 ; i < 20 && ( await cache . match ( stale )); i ++ ) { 274await new Promise ( resolve => setTimeout ( resolve , 50 )); 275} 276assertEquals ( await cache . match ( stale ), undefined , "stale pack entry should be pruned" ); 277} finally { 278await caches . delete ( name ); 279} 280}); 281 282Deno . test ( "failed fetches are retried, not cached" , async () => { 283const { dir} = await makeFixture (); 284await sh ( dir , "git" , "-c" , "gc.pruneExpire=now" , "gc" , "-q" , "--aggressive" , "--prune=now" ); 285const files = fileFetcher ( ` ${ dir } /.git` ); 286let failures = 1 ; 287const fetch :Fetcher = ( path , range ) => { 288if ( path . endsWith ( ".pack" ) && failures -- > 0 ) return Promise . reject ( new Error ( "aborted" )); 289return files ( path , range ); 290}; 291const repo = new GitRepo ( fetch ); 292const info = await repo . info (); 293const main = info . refs . find ( r => r . name === "main" ) ! ; 294await assertRejects (() => repo . commit ( main . oid ), Error , "aborted" ); 295assertEquals (( await repo . commit ( main . oid )). message . trim (), "second commit" ); 296}); 297 298Deno . test ( "myers diff edge cases" , () => { 299assertEquals ( diffLines ( "" , "" ), []); 300assertEquals ( diffLines ( "a\n" , "a\n" ), []); 301const addOnly = diffLines ( "" , "a\nb\n" ); 302assertEquals ( addOnly [ 0 ]. lines . map ( l => l . sign + l . text ), [ "+a" , "+b" ]); 303const delOnly = diffLines ( "a\nb\n" , "" ); 304assertEquals ( delOnly [ 0 ]. lines . map ( l => l . sign + l . text ), [ "-a" , "-b" ]); 305// no trailing newline handling 306const noEol = diffLines ( "a" , "b" ); 307assertEquals ( noEol [ 0 ]. lines . map ( l => l . sign + l . text ), [ "-a" , "+b" ]); 308});