char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
15e7101
main
1// A conversation's repository through its environment: jj's when there is one, else git's. 2 3import type { Context } from "@earendil-works/chord" ; 4import { getOrThrow } from "@earendil-works/pi-durable/env" ; 5import type { Environment } from "@berry/api" ; 6import { type FileDiff , narrow , parsePatch } from "./patch.ts" ; 7import type { Id , Revision , Shown } from "./types.ts" ; 8 9/** Enough context that each file's one hunk is the whole file, so the view can highlight and unfold all of it. */ 10const WHOLE = 1_000_000 ; 11/** Files longer than this keep only some context around their changes. */ 12const LONG = 20_000 ; 13const CONTEXT = 5 ; 14/** How many untracked files git's working tree shows in full. */ 15const UNTRACKED = 100 ; 16 17type Repository = { vcs :"jj" | "git" ; root :string }; 18 19async function run ( 20env :Environment , 21argv :string [], 22context :Context , 23{ ok= [ 0 ], cwd= env . cwd } :{ ok ?:number []; cwd ?:string } = {}, 24) { 25const streams = { stdout :"" , stderr :"" }; 26const result = getOrThrow ( 27await env . exec ( argv , { 28 cwd, 29onOutput :( text , _ , { stream}) => void ( streams [ stream ] += text ), 30}, context ), 31); 32if ( ! ok . includes ( result . exitCode ?? - 1 )) { 33throw new Error ( streams . stderr . trim () || ` ${ argv [ 0 ]} exited with ${ result . exitCode } ` ); 34} 35return streams . stdout ; 36} 37 38const jj = ( env :Environment , repo :Repository , args :string [], context :Context ) => 39run ( env , [ "jj" , "--no-pager" , "--color=never" , ...args ], context , { cwd :repo . root }); 40 41const git = ( 42env :Environment , 43repo :Repository , 44args :string [], 45context :Context , 46ok ?:number [], 47) => 48run ( 49env , 50[ "git" , "--no-pager" , "-c" , "core.quotePath=false" , "-c" , "color.ui=false" , ...args ], 51context , 52{ cwd :repo . root , ok}, 53); 54 55/** The repository around the environment's working directory: jj's first, so a colocated one reads as jj. */ 56async function repository ( env :Environment , context :Context ) :Promise < Repository > { 57const attempt = async ( vcs :Repository [ "vcs" ], argv :string []) => { 58try { 59return { vcs, root :( await run ( env , argv , context )). trim () }; 60} catch { 61return null ; 62} 63}; 64const found = await attempt ( "jj" , [ "jj" , "--ignore-working-copy" , "root" ]) ?? 65await attempt ( "git" , [ "git" , "rev-parse" , "--show-toplevel" ]); 66if ( found === null ) throw new Error ( ` ${ env . cwd } is in neither a jj nor a git repository` ); 67return found ; 68} 69 70const id = ( full :string , prefix :string , rest :string ) :Id => ({ full, prefix, rest}); 71 72const JJ_REVISION = [ 73"json(self)" , 74"change_id.shortest(8).prefix()" , 75"change_id.shortest(8).rest()" , 76"commit_id.shortest(8).prefix()" , 77"commit_id.shortest(8).rest()" , 78'if(current_working_copy, "@") ++ if(empty, "e") ++ if(immutable, "i") ++ if(conflict, "c")' , 79'separate(" ", bookmarks, tags)' , 80]. join ( ' ++ "\\x1f" ++ ' ) + ' ++ "\\n"' ; 81 82async function jjRevisions ( 83env :Environment , 84repo :Repository , 85revset :string , 86limit :number , 87context :Context , 88snapshot :boolean , 89) :Promise < Revision []> { 90const out = await jj ( env , repo , [ 91"log" , 92 ...( snapshot ?[] :[ "--ignore-working-copy" ]), 93"--no-graph" , 94`--revisions= ${ revset } ` , 95`--limit= ${ limit } ` , 96`--template= ${ JJ_REVISION } ` , 97], context ); 98return out . split ( "\n" ). filter ( Boolean ). map (( line ) => { 99const [ json , changePrefix , changeRest , commitPrefix , commitRest , flags , refs ] = line . split ( 100"\x1f" , 101); 102const commit = JSON . parse ( json ) as { 103change_id :string ; 104commit_id :string ; 105description :string ; 106author :{ name :string ; string ; timestamp :string }; 107}; 108return { 109route :flags . includes ( "@" ) ?"" :commit . change_id , 110change :id ( commit . change_id , changePrefix , changeRest ), 111commit :id ( commit . commit_id , commitPrefix , commitRest ), 112description :commit . description , 113author :commit . author , 114refs :refs ?refs . split ( " " ) :[], 115workingCopy :flags . includes ( "@" ), 116empty :flags . includes ( "e" ), 117immutable :flags . includes ( "i" ), 118conflict :flags . includes ( "c" ), 119}; 120}); 121} 122 123const GIT_REVISION = "%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%B%x1e" ; 124 125async function gitRevisions ( 126env :Environment , 127repo :Repository , 128args :string [], 129context :Context , 130) :Promise < Revision []> { 131const out = await git ( env , repo , [ "log" , `--format= ${ GIT_REVISION } ` , ...args , "--" ], context ); 132return out . split ( "\x1e" ). map (( record ) => record . replace ( / ^\n / , "" )). filter ( Boolean ). map ( 133( record ) => { 134const [ hash , short , name , , timestamp , refs , description ] = record . split ( "\x1f" ); 135return { 136route :hash , 137change :null , 138commit :id ( hash , "" , short ), 139 description, 140author :{ name, email, timestamp}, 141refs :refs ?refs . split ( ", " ) :[], 142workingCopy :false , 143empty :false , 144immutable :false , 145conflict :false , 146}; 147}, 148); 149} 150 151const WORKING_TREE :Revision = { 152route :"" , 153change :null , 154commit :null , 155description :"" , 156author :null , 157refs :[], 158workingCopy :true , 159empty :false , 160immutable :false , 161conflict :false , 162}; 163 164/** jj in the repository at `root`, for edits to its history. */ 165export const jjAt = ( env :Environment , root :string , args :string [], context :Context ) => 166jj ( env , { vcs :"jj" , root}, args , context ); 167 168/** A jj change as it is now; `snapshot` takes in the working copy's edits first. */ 169export async function jjRevision ( 170env :Environment , 171root :string , 172change :string , 173context :Context , 174snapshot :boolean , 175) :Promise < Revision | undefined > { 176return ( await jjRevisions ( env , { vcs :"jj" , root}, checked ( change , "jj" ), 1 , context , snapshot ))[ 1770 178]; 179} 180 181/** Routes name a jj change or a commit by its id, never by a revset or an option. */ 182function checked ( route :string , vcs :Repository [ "vcs" ]) { 183const valid = vcs === "jj" ?/ ^(?:[k-z]{1,64}|[0-9a-f]{4,64})$ / :/ ^[0-9a-f]{4,64}$ / ; 184if ( ! valid . test ( route )) { 185throw new Error ( `Not a ${ vcs === "jj" ? "change or commit" : "commit" } id: ${ route } ` ); 186} 187return route ; 188} 189 190export const bounded = ( files :FileDiff []) => 191files . map (( file ) => 192file . hunks . reduce (( sum , hunk ) => sum + hunk . lines . length , 0 ) > LONG 193 ?narrow ( file , CONTEXT ) 194 :file 195); 196 197/** 198* The repository's recent history and one revision's changes: `route` names it, or the working copy when empty. jj 199* snapshots the working copy once, first, so that the log and the changes agree. 200*/ 201export async function show ( 202env :Environment , 203route :string , 204limit :number , 205context :Context , 206whole = false , 207) :Promise < Shown > { 208const repo = await repository ( env , context ); 209if ( repo . vcs === "jj" ) { 210const log = await jjRevisions ( env , repo , "ancestors(@) ~ root()" , limit , context , true ); 211const revset = route === "" ?"@" :checked ( route , "jj" ); 212const revision = log . find (( revision ) => revision . route === route ) ?? 213( await jjRevisions ( env , repo , revset , 1 , context , false ))[ 0 ]; 214if ( revision === undefined ) throw new Error ( `No revision ${ route } ` ); 215const patch = await jj ( env , repo , [ 216"diff" , 217"--ignore-working-copy" , 218"--git" , 219`--context= ${ WHOLE } ` , 220`--revisions= ${ revset } ` , 221], context ); 222const files = parsePatch ( patch ); 223return { ...repo , log, revision, files :whole ?files :bounded ( files ), omitted :0 }; 224} 225 226const head = await git ( env , repo , [ "rev-parse" , "--verify" , "--quiet" , "HEAD" ], context , [ 0 , 1 ]); 227const log = [ 228{ 229 ...WORKING_TREE , 230conflict :!! ( await git ( env , repo , [ "ls-files" , "--unmerged" , "-z" ], context )), 231}, 232 ...( head . trim () 233 ?await gitRevisions ( env , repo , [ `--max-count= ${ limit - 1 } ` , "HEAD" ], context ) 234 :[]), 235]; 236const options = [ 237"-M" , 238"--no-ext-diff" , 239"--no-relative" , 240"--src-prefix=a/" , 241"--dst-prefix=b/" , 242`--unified= ${ WHOLE } ` , 243]; 244if ( route !== "" ) { 245const hash = checked ( route , "git" ); 246const revision = log . find (( revision ) => revision . route === hash ) ?? 247( await gitRevisions ( env , repo , [ "--max-count=1" , hash ], context ))[ 0 ]; 248const patch = await git ( env , repo , [ 249"show" , 250"--format=" , 251"--diff-merges=first-parent" , 252 ...options , 253revision . route , 254"--" , 255], context ); 256const files = parsePatch ( patch ); 257return { ...repo , log, revision, files :whole ?files :bounded ( files ), omitted :0 }; 258} 259 260// the working tree against HEAD, or against nothing before the first commit 261const base = head . trim () || "4b825dc642cb6eb9a060e54bf8d69288fbee4904" ; 262const tracked = parsePatch ( await git ( env , repo , [ "diff" , ...options , base , "--" ], context )); 263const untracked = 264( await git ( env , repo , [ "ls-files" , "--others" , "--exclude-standard" , "-z" ], context )) 265. split ( "\0" ). filter ( Boolean ); 266const added = await Promise . all ( 267untracked . slice ( 0 , UNTRACKED ). map ( async ( path ) => 268parsePatch ( 269await git ( env , repo , [ "diff" , "--no-index" , ...options , "--" , "/dev/null" , path ], context , [ 2700 , 2711 , 272]), 273) 274), 275); 276const files = [ ...tracked , ...added . flat ()]. sort (( a , b ) => 277a . path < b . path ?- 1 :a . path > b . path ?1 :0 278); 279return { 280 ...repo , 281 log, 282revision :log [ 0 ], 283files :whole ?files :bounded ( files ), 284omitted :Math . max ( 0 , untracked . length - UNTRACKED ), 285}; 286}