char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
806cc3c
main
1// Git-format patches, as `git diff` and `jj diff --git` write them, read into files and their hunks. 2 3/** One file's change. */ 4export type FileDiff = { 5/** Where it is after the change; where it was, for a deletion. */ 6path :string ; 7/** Where it was before, when it was renamed or copied. */ 8from :string | null ; 9status :"added" | "deleted" | "modified" | "renamed" | "copied" ; 10/** Its modes either side, as far as the patch says: such as `100755` once a script is executable. */ 11modes :{ old :string | null ; new :string | null }; 12binary :boolean ; 13hunks :Hunk []; 14}; 15 16/** 17* A stretch of the file: the line it starts at on either side, and its lines, each marked `" "`, `"-"` or `"+"`, or 18* `"\\"` when the line before it has no newline. 19*/ 20export type Hunk = { old :number ; new :number ; lines :string [] }; 21 22const HUNK = / ^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@ / ; 23 24/** The files `patch` changes, in its order; whatever surrounds them, such as a commit's header, is skipped. */ 25export function parsePatch ( patch :string ) :FileDiff [] { 26const lines = patch . split ( "\n" ); 27const files :FileDiff [] = []; 28let i = 0 ; 29while ( i < lines . length ) { 30const header = lines [ i ++ ]; 31if ( ! header . startsWith ( "diff --git " )) continue ; 32const [ a , b ] = headerPaths ( header . slice ( "diff --git " . length )); 33let old :string | null = a ; 34let neu :string | null = b ; 35let status :FileDiff [ "status" ] = "modified" ; 36const modes :FileDiff [ "modes" ] = { old :null , new :null }; 37let binary = false ; 38const hunks :Hunk [] = []; 39for (; i < lines . length && ! lines [ i ]. startsWith ( "diff --git " ); i ++ ) { 40const line = lines [ i ]; 41const hunk = line . match ( HUNK ); 42if ( hunk ) { 43let oldLeft = hunk [ 2 ] === undefined ?1 :Number ( hunk [ 2 ]); 44let newLeft = hunk [ 4 ] === undefined ?1 :Number ( hunk [ 4 ]); 45const body :string [] = []; 46while ( i + 1 < lines . length && ( oldLeft > 0 || newLeft > 0 || lines [ i + 1 ][ 0 ] === "\\" )) { 47const next = lines [ ++ i ]; 48// `diff.suppressBlankEmpty` writes an empty context line without its mark 49const mark = next === "" ?" " :next [ 0 ]; 50if ( mark === " " ) oldLeft -- , newLeft -- ; 51else if ( mark === "-" ) oldLeft -- ; 52else if ( mark === "+" ) newLeft -- ; 53else if ( mark !== "\\" ) { 54i -- ; 55break ; 56} 57body . push ( next === "" ?" " :next ); 58} 59hunks . push ({ old :Number ( hunk [ 1 ]), new :Number ( hunk [ 3 ]), lines :body }); 60continue ; 61} 62const [, key , value ] = line . match ( 63/ ^(new file mode|deleted file mode|old mode|new mode|rename from|rename to|copy from|copy to|index) (.*)$ / , 64) ?? []; 65if ( line . startsWith ( "--- " )) old = sidePath ( line . slice ( 4 ), "a/" ); 66else if ( line . startsWith ( "+++ " )) neu = sidePath ( line . slice ( 4 ), "b/" ); 67else if ( key === "new file mode" ) ( status = "added" ), ( modes . new = value ), ( old = null ); 68else if ( key === "deleted file mode" ) ( status = "deleted" ), ( modes . old = value ), ( neu = null ); 69else if ( key === "old mode" ) modes . old = value ; 70else if ( key === "new mode" ) modes . new = value ; 71else if ( key === "rename from" ) ( status = "renamed" ), ( old = unquote ( value )); 72else if ( key === "rename to" ) neu = unquote ( value ); 73else if ( key === "copy from" ) ( status = "copied" ), ( old = unquote ( value )); 74else if ( key === "copy to" ) neu = unquote ( value ); 75else if ( key === "index" ) { 76const mode = value . split ( " " )[ 1 ]; 77if ( mode ) modes . old ??= mode , modes . new ??= mode ; 78} else if ( line . startsWith ( "Binary files " ) || line === "GIT binary patch" ) binary = true ; 79} 80if ( status === "modified" && old !== null && neu !== null && old !== neu ) status = "renamed" ; 81files . push ({ 82path :neu ?? old ?? b , 83from :status === "renamed" || status === "copied" ?old :null , 84 status, 85 modes, 86 binary, 87 hunks, 88}); 89} 90return files ; 91} 92 93/** The two paths of `diff --git a/x b/y`, which are ambiguous unquoted; patches say them again where it matters. */ 94function headerPaths ( rest :string ) :[ string , string ] { 95if ( rest . startsWith ( '"' )) { 96const [ a , end ] = quoted ( rest ); 97const after = rest . slice ( end + 2 ); 98return [ strip ( a , "a/" ), strip ( after . startsWith ( '"' ) ?quoted ( after )[ 0 ] :after , "b/" )]; 99} 100if ( rest . endsWith ( '"' )) { 101const at = rest . lastIndexOf ( ' "' ); 102return [ strip ( rest . slice ( 0 , at ), "a/" ), strip ( quoted ( rest . slice ( at + 1 ))[ 0 ], "b/" )]; 103} 104// unless one was renamed or copied, which says so on its own lines, both sides are the same path 105const half = ( rest . length - 1 ) / 2 ; 106if ( Number . isInteger ( half ) && rest . slice ( 2 , half ) === rest . slice ( half + 3 )) { 107return [ rest . slice ( 2 , half ), rest . slice ( half + 3 )]; 108} 109const at = rest . indexOf ( " b/" ); 110return [ strip ( rest . slice ( 0 , at ), "a/" ), rest . slice ( at + 3 )]; 111} 112 113/** A `---` or `+++` line's path, or `null` for `/dev/null`; git ends those with spaces in them with a tab. */ 114function sidePath ( text :string , prefix :string ) :string | null { 115const path = unquote ( text . replace ( / \t$ / , "" )); 116return path === "/dev/null" ?null :strip ( path , prefix ); 117} 118 119const strip = ( path :string , prefix :string ) => 120path . startsWith ( prefix ) ?path . slice ( prefix . length ) :path ; 121 122const unquote = ( text :string ) => text . startsWith ( '"' ) ?quoted ( text )[ 0 ] :text ; 123 124const ESCAPES :Record < string , number > = { 125a :7 , 126b :8 , 127t :9 , 128n :10 , 129v :11 , 130f :12 , 131r :13 , 132'"' :34 , 133"\\" :92 , 134}; 135 136/** A C-quoted string at the start of `text`, as git quotes unusual paths, and the index of its closing quote. */ 137function quoted ( text :string ) :[ string , number ] { 138const bytes :number [] = []; 139const encoder = new TextEncoder (); 140let i = 1 ; 141for (; i < text . length && text [ i ] !== '"' ; i ++ ) { 142if ( text [ i ] !== "\\" ) { 143bytes . push ( ...encoder . encode ( text [ i ])); 144continue ; 145} 146const octal = text . slice ( i + 1 , i + 4 ); 147if ( / ^[0-7]{3}$ / . test ( octal )) { 148bytes . push ( parseInt ( octal , 8 )); 149i += 3 ; 150} else { 151i ++ ; 152bytes . push ( ESCAPES [ text [ i ]] ?? text . charCodeAt ( i )); 153} 154} 155return [ new TextDecoder (). decode ( new Uint8Array ( bytes )), i ]; 156} 157 158/** `file` with only `context` unchanged lines around each change, for files too long to send whole. */ 159export function narrow ( file :FileDiff , context :number ) :FileDiff { 160const hunks :Hunk [] = []; 161for ( const hunk of file . hunks ) { 162let oldLine = hunk . old ; 163let newLine = hunk . new ; 164const changed = hunk . lines . map (( line ) => line [ 0 ] === "-" || line [ 0 ] === "+" ); 165// a missing newline belongs to the line before it 166const near = hunk . lines . map (( line , i ) => 167line [ 0 ] === "\\" || 168changed . slice ( Math . max ( 0 , i - context ), i + context + 1 ). some ( Boolean ) 169); 170let current :Hunk | null = null ; 171for ( const [ i , line ] of hunk . lines . entries ()) { 172if ( near [ i ]) { 173current ??= hunks [ hunks . push ({ old :oldLine , new :newLine , lines :[] }) - 1 ]; 174current . lines . push ( line ); 175} else current = null ; 176if ( line [ 0 ] === " " || line [ 0 ] === "-" ) oldLine ++ ; 177if ( line [ 0 ] === " " || line [ 0 ] === "+" ) newLine ++ ; 178} 179} 180return { ...file , hunks}; 181}