char/sorcery
static-files based git repo viewer
git clone https://git.t4t.associates/char/sorcery
42f80d8
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/lib.rs` , "fn one() {}\nfn two() { todo!() }\nfn three() {}\nfn four() {}\n" ); 87await Deno . writeTextFile ( ` ${ dir } /NOTES` , "hello\n" ); 88await sh ( dir , "git" , "rm" , "-q" , "README.md" ); 89await sh ( dir , "git" , "add" , "-A" ); 90await sh ( dir , "git" , "commit" , "-q" , "-m" , "second commit" ); 91return { dir, repo :new GitRepo ( fileFetcher ( ` ${ dir } /.git` )) }; 92} 93 94async function assertHistory ( repo :GitRepo ) { 95const info = await repo . info (); 96assertEquals ( info . head , "main" ); 97const main = info . refs . find ( r => r . name === "main" ) ! ; 98 99const commits = []; 100for await ( const commit of log ( repo , [ main . oid ], 10 )) commits . push ( commit ); 101assertEquals ( commits . map ( c => c . message . trim ()), [ "second commit" , "initial commit" ]); 102assertEquals ( commits [ 0 ]. parents , [ commits [ 1 ]. oid ]); 103assertEquals ( commits [ 0 ]. author . name , "t" ); 104 105const changes = await treeDiff ( repo , commits [ 1 ]. tree , commits [ 0 ]. tree ); 106assertEquals ( 107changes . map ( c => ` ${ c . status } ${ c . path } ` ), 108[ "added NOTES" , "deleted README.md" , "modified src/lib.rs" ], 109); 110 111const modified = changes . find ( c => c . path === "src/lib.rs" ) ! ; 112const oldData = await repo . blob ( modified . oldOid ! ); 113const newData = await repo . blob ( modified . newOid ! ); 114const oldText = new TextDecoder (). decode ( oldData ); 115const newText = new TextDecoder (). decode ( newData ); 116const hunks = diffLines ( oldText , newText ); 117assertEquals ( hunks . length , 1 ); 118assertEquals ( 119hunks [ 0 ]. lines . map ( l => l . sign + l . text ), 120[ 121" fn one() {}" , 122"-fn two() {}" , 123"+fn two() { todo!() }" , 124" fn three() {}" , 125"+fn four() {}" , 126], 127); 128} 129 130Deno . test ( "loose objects" , async () => { 131const { repo} = await makeFixture (); 132await assertHistory ( repo ); 133}); 134 135Deno . test ( "object queries seed the repository cache" , async () => { 136const { dir} = await makeFixture (); 137const oid = await sh ( dir , "git" , "rev-parse" , "HEAD" ); 138const treeOid = await sh ( dir , "git" , "show" , "-s" , "--format=%T" , "HEAD" ); 139const commit = await new Deno . Command ( "git" , { args :[ "cat-file" , "commit" , oid ], cwd :dir }). output (); 140const tree = await new Deno . Command ( "git" , { args :[ "cat-file" , "tree" , treeOid ], cwd :dir }). output (); 141assert ( commit . success && tree . success ); 142 143const bundle = objectBundle ([[ oid , 1 , commit . stdout ], [ treeOid , 2 , tree . stdout ]]); 144 145let rawReads = 0 ; 146const queries :unknown [] = []; 147const files = fileFetcher ( ` ${ dir } /.git` ); 148const fetch :Fetcher = ( path , range , query ) => { 149if ( path === "obj" && query ) { 150queries . push ( JSON . parse ( new TextDecoder (). decode ( query ))); 151return Promise . resolve ({ bytes :bundle , total :bundle . length }); 152} 153if ( path . startsWith ( "obj/" ) || path . startsWith ( ".git/objects/" )) rawReads ++ ; 154return files ( path , range ); 155}; 156const name = `sorcery-test- ${ crypto . randomUUID ()} ` ; 157try { 158const repo = new GitRepo ( fetch , name ); 159await repo . prefetch ([ oid ], { depth :2 }); 160await repo . prefetch ([ oid ], { smart :"tree" }); 161const loaded = await repo . commit ( oid ); 162await repo . tree ( loaded . tree ); 163 164const warm = new GitRepo ( fetch , name ); 165await warm . prefetch ([ oid ], { smart :"tree" }); 166await warm . tree (( await warm . commit ( oid )). tree ); 167assertEquals ( queries , [ 168{ oids :[ oid ], depth :2 }, 169{ oids :[ oid ], smart :"tree" }, 170]); 171assertEquals ( rawReads , 0 ); 172} finally { 173await caches . delete ( name ); 174} 175}); 176 177Deno . test ( "commit logs prefetch one page at a time" , async () => { 178const { dir} = await makeFixture (); 179for ( let i = 3 ; i <= 12 ; i ++ ) { 180await Deno . writeTextFile ( ` ${ dir } /n` , ` ${ i } \n` ); 181await sh ( dir , "git" , "add" , "n" ); 182await sh ( dir , "git" , "commit" , "-q" , "-m" , `commit ${ i } ` ); 183} 184const oids = ( await sh ( dir , "git" , "rev-list" , "HEAD" )). split ( "\n" ); 185const commits = new Map < string , Uint8Array >(); 186for ( const oid of oids ) { 187const result = await new Deno . Command ( "git" , { args :[ "cat-file" , "commit" , oid ], cwd :dir }). output (); 188assert ( result . success ); 189commits . set ( oid , result . stdout ); 190} 191 192const queries :unknown [] = []; 193let rawReads = 0 ; 194const files = fileFetcher ( ` ${ dir } /.git` ); 195const fetch :Fetcher = ( path , range , query ) => { 196if ( path === "obj" && query ) { 197const body = JSON . parse ( new TextDecoder (). decode ( query )) as { 198oids :string []; 199smart :string ; 200limit :number ; 201}; 202queries . push ( body ); 203const start = oids . indexOf ( body . oids [ 0 ]); 204const specs = oids . slice ( start , start + body . limit ). map ( oid => [ oid , 1 , commits . get ( oid ) ! ] as [ string , number , Uint8Array ]); 205const bundle = objectBundle ( specs ); 206return Promise . resolve ({ bytes :bundle , total :bundle . length }); 207} 208if ( path . startsWith ( ".git/objects/" )) rawReads ++ ; 209return files ( path , range ); 210}; 211const name = `sorcery-test- ${ crypto . randomUUID ()} ` ; 212try { 213const loaded = []; 214for await ( const commit of log ( new GitRepo ( fetch , name ), oids , 10 )) loaded . push ( commit . oid ); 215assertEquals ( loaded , oids ); 216assertEquals ( queries , [ 217{ oids :oids . slice ( 0 , 10 ), smart :"commit-pagination" , limit :10 }, 218{ oids :oids . slice ( 10 ), smart :"commit-pagination" , limit :10 }, 219]); 220assertEquals ( rawReads , 0 ); 221} finally { 222await caches . delete ( name ); 223} 224}); 225 226Deno . test ( "packed objects (with deltas)" , async () => { 227const { dir} = await makeFixture (); 228// window/depth defaults keep our similar blobs as deltas; prune loose ones 229await sh ( dir , "git" , "-c" , "gc.pruneExpire=now" , "gc" , "-q" , "--aggressive" , "--prune=now" ); 230const repo = new GitRepo ( fileFetcher ( ` ${ dir } /.git` )); 231const info = await repo . info (); 232assert ( info . packs . length > 0 , "expected a pack after gc" ); 233await assertHistory ( repo ); 234}); 235 236Deno . test ( "SHA-256 loose and packed objects" , async () => { 237const { dir, repo} = await makeFixture ( "sha256" ); 238const info = await repo . info (); 239assert ( info . refs . every ( ref => ref . oid . length === 64 )); 240await assertHistory ( repo ); 241 242await sh ( dir , "git" , "-c" , "gc.pruneExpire=now" , "gc" , "-q" , "--aggressive" , "--prune=now" ); 243const packed = new GitRepo ( fileFetcher ( ` ${ dir } /.git` )); 244const packs = ( await packed . info ()). packs ; 245assert ( packs . length > 0 && packs . every ( stem => stem . length === 69 )); 246await assertHistory ( packed ); 247}); 248 249Deno . test ( "pack ranges persist in the cache; stale packs are pruned" , async () => { 250const { dir} = await makeFixture (); 251await sh ( dir , "git" , "-c" , "gc.pruneExpire=now" , "gc" , "-q" , "--aggressive" , "--prune=now" ); 252const files = fileFetcher ( ` ${ dir } /.git` ); 253let packFetches = 0 ; 254const counting :Fetcher = ( path , range ) => { 255if ( path . includes ( "/pack/" )) packFetches ++ ; 256return files ( path , range ); 257}; 258const name = `sorcery-test- ${ crypto . randomUUID ()} ` ; 259try { 260const stale = "https://sorcery-pack-cache.invalid/x/pack-dead.idx?range=all" ; 261await ( await caches . open ( name )). put ( stale , new Response ( "junk" )); 262 263await assertHistory ( new GitRepo ( counting , name )); 264assert ( packFetches > 0 , "expected cold-cache pack fetches" ); 265 266packFetches = 0 ; 267await assertHistory ( new GitRepo ( counting , name )); 268assertEquals ( packFetches , 0 , "warm cache should serve all pack ranges" ); 269 270// pruning is fire-and-forget off the gitinfo fetch, so poll briefly 271const cache = await caches . open ( name ); 272for ( let i = 0 ; i < 20 && ( await cache . match ( stale )); i ++ ) { 273await new Promise ( resolve => setTimeout ( resolve , 50 )); 274} 275assertEquals ( await cache . match ( stale ), undefined , "stale pack entry should be pruned" ); 276} finally { 277await caches . delete ( name ); 278} 279}); 280 281Deno . test ( "failed fetches are retried, not cached" , async () => { 282const { dir} = await makeFixture (); 283await sh ( dir , "git" , "-c" , "gc.pruneExpire=now" , "gc" , "-q" , "--aggressive" , "--prune=now" ); 284const files = fileFetcher ( ` ${ dir } /.git` ); 285let failures = 1 ; 286const fetch :Fetcher = ( path , range ) => { 287if ( path . endsWith ( ".pack" ) && failures -- > 0 ) return Promise . reject ( new Error ( "aborted" )); 288return files ( path , range ); 289}; 290const repo = new GitRepo ( fetch ); 291const info = await repo . info (); 292const main = info . refs . find ( r => r . name === "main" ) ! ; 293await assertRejects (() => repo . commit ( main . oid ), Error , "aborted" ); 294assertEquals (( await repo . commit ( main . oid )). message . trim (), "second commit" ); 295}); 296 297Deno . test ( "myers diff edge cases" , () => { 298assertEquals ( diffLines ( "" , "" ), []); 299assertEquals ( diffLines ( "a\n" , "a\n" ), []); 300const addOnly = diffLines ( "" , "a\nb\n" ); 301assertEquals ( addOnly [ 0 ]. lines . map ( l => l . sign + l . text ), [ "+a" , "+b" ]); 302const delOnly = diffLines ( "a\nb\n" , "" ); 303assertEquals ( delOnly [ 0 ]. lines . map ( l => l . sign + l . text ), [ "-a" , "-b" ]); 304// no trailing newline handling 305const noEol = diffLines ( "a" , "b" ); 306assertEquals ( noEol [ 0 ]. lines . map ( l => l . sign + l . text ), [ "-a" , "+b" ]); 307});