native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314731573167317731873197320732173227323732473257326732773287329733073317332733373347335733673377338733973407341734273437344734573467347734873497350735173527353735473557356735773587359736073617362736373647365736673677368736973707371737273737374737573767377737873797380738173827383738473857386738773887389739073917392739373947395739673977398739974007401740274037404740574067407740874097410741174127413741474157416741774187419742074217422742374247425742674277428742974307431743274337434743574367437743874397440744174427443744474457446744774487449745074517452745374547455745674577458745974607461746274637464746574667467746874697470747174727473747474757476747774787479748074817482748374847485748674877488748974907491749274937494749574967497749874997500750175027503750475057506750775087509751075117512751375147515751675177518751975207521752275237524752575267527752875297530753175327533753475357536753775387539754075417542754375447545754675477548754975507551755275537554755575567557755875597560756175627563756475657566756775687569757075717572757375747575757675777578757975807581758275837584758575867587758875897590759175927593759475957596759775987599760076017602760376047605760676077608760976107611761276137614761576167617761876197620762176227623762476257626762776287629763076317632763376347635763676377638763976407641764276437644764576467647764876497650765176527653765476557656765776587659766076617662766376647665766676677668766976707671767276737674767576767677767876797680768176827683768476857686768776887689769076917692769376947695769676977698769977007701770277037704770577067707770877097710771177127713771477157716771777187719772077217722772377247725772677277728772977307731773277337734773577367737773877397740774177427743774477457746774777487749775077517752775377547755775677577758775977607761776277637764776577667767776877697770777177727773777477757776777777787779778077817782778377847785778677877788778977907791779277937794779577967797779877997800780178027803780478057806780778087809781078117812781378147815781678177818781978207821782278237824782578267827782878297830783178327833783478357836783778387839784078417842784378447845784678477848import Clocksimport ComposableArchitectureimport CustomDumpimport DependenciesTestSupportimport Foundationimport IdentifiedCollectionsimport ProwlCLISharedimport Sharingimport Testing
@testable import Prowl
@MainActorstruct RepositoriesFeatureTests { @Test func requestCanvasCommandSetsPendingRequestWithIncrementingID() async { let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() }
await store.send(.requestCanvasCommand(.toggleExpand)) { $0.nextCanvasCommandRequestID = 1 $0.pendingCanvasCommandRequest = CanvasCommandRequest(id: 1, command: .toggleExpand) } await store.send(.requestCanvasCommand(.arrange)) { $0.nextCanvasCommandRequestID = 2 $0.pendingCanvasCommandRequest = CanvasCommandRequest(id: 2, command: .arrange) } }
@Test func consumeCanvasCommandRequestClearsOnlyMatchingID() async { var initialState = RepositoriesFeature.State() initialState.nextCanvasCommandRequestID = 5 initialState.pendingCanvasCommandRequest = CanvasCommandRequest(id: 5, command: .organize) let store = TestStore(initialState: initialState) { RepositoriesFeature() }
// A stale id is ignored. await store.send(.consumeCanvasCommandRequest(4)) // The matching id clears the request. await store.send(.consumeCanvasCommandRequest(5)) { $0.pendingCanvasCommandRequest = nil } }
@Test func selectNextShelfBookRoutesToRightNavigationInCanvas() async { await assertCanvasNavigationActionRoutesToCommand( action: .selectNextShelfBook, command: .navigate(.moveRight) ) }
@Test func selectPreviousShelfBookRoutesToLeftNavigationInCanvas() async { await assertCanvasNavigationActionRoutesToCommand( action: .selectPreviousShelfBook, command: .navigate(.moveLeft) ) }
@Test func selectNextWorktreeRoutesToDownNavigationInCanvas() async { await assertCanvasNavigationActionRoutesToCommand( action: .selectNextWorktree, command: .navigate(.moveDown) ) }
@Test func selectPreviousWorktreeRoutesToUpNavigationInCanvas() async { await assertCanvasNavigationActionRoutesToCommand( action: .selectPreviousWorktree, command: .navigate(.moveUp) ) }
private func assertCanvasNavigationActionRoutesToCommand( action: RepositoriesFeature.Action, command: CanvasCommandRequest.Command ) async { var initialState = RepositoriesFeature.State() initialState.selection = .canvas let store = TestStore(initialState: initialState) { RepositoriesFeature() }
await store.send(action) await store.receive(\.requestCanvasCommand) { $0.nextCanvasCommandRequestID = 1 $0.pendingCanvasCommandRequest = CanvasCommandRequest(id: 1, command: command) } }
@Test func refreshWorktreesSetsRefreshingStateUntilLoadCompletes() async { let worktree = makeWorktree(id: "/tmp/repo/main", name: "main") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.gitClient.worktrees = { _ in [worktree] } }
await store.send(.refreshWorktrees) { $0.isRefreshingWorktrees = true } await store.receive(\.reloadRepositories) await store.receive(\.repositoriesLoaded) { $0.isRefreshingWorktrees = false $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } }
@Test func refreshWorktreesWithoutRootsStopsRefreshingImmediately() async { let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() }
await store.send(.refreshWorktrees) { $0.isRefreshingWorktrees = true } await store.receive(\.reloadRepositories) { $0.isRefreshingWorktrees = false } }
@Test func repositoriesLoadedClearsRefreshingState() async { let worktree = makeWorktree(id: "/tmp/repo/main", name: "main") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var initialState = makeState(repositories: [repository]) initialState.isRefreshingWorktrees = true let store = TestStore(initialState: initialState) { RepositoriesFeature() }
await store.send( .repositoriesLoaded( [repository], failures: [], roots: [repository.rootURL], animated: false ) ) { $0.isRefreshingWorktrees = false $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } }
@Test func customTitlesLoadedReplacesEntireDictionary() async { let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() }
await store.send(.customTitlesLoaded(["repo-a": "Alpha", "repo-b": "Beta"])) { $0.repositoryCustomTitles = ["repo-a": "Alpha", "repo-b": "Beta"] }
// Re-sending the same dict is a no-op (state mutation guard avoids // gratuitous TCA-driven view refreshes). await store.send(.customTitlesLoaded(["repo-a": "Alpha", "repo-b": "Beta"]))
await store.send(.customTitlesLoaded(["repo-c": "Gamma"])) { $0.repositoryCustomTitles = ["repo-c": "Gamma"] } }
@Test func customTitleUpdatedSetsAndRemovesSingleEntry() async { var initialState = RepositoriesFeature.State() initialState.repositoryCustomTitles = ["repo-a": "Alpha"] let store = TestStore(initialState: initialState) { RepositoriesFeature() }
await store.send(.customTitleUpdated("repo-b", "Beta")) { $0.repositoryCustomTitles = ["repo-a": "Alpha", "repo-b": "Beta"] }
// Same value → no state change await store.send(.customTitleUpdated("repo-b", "Beta"))
// nil removes the entry await store.send(.customTitleUpdated("repo-a", nil)) { $0.repositoryCustomTitles = ["repo-b": "Beta"] }
// Removing a non-existent entry is a no-op await store.send(.customTitleUpdated("repo-a", nil)) }
@Test func updateWorktreeLineChangesReturnsFalseWhenCountsMatchExistingEntry() { let worktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var state = makeState(repositories: [repository]) state.worktreeInfoByID[worktree.id] = WorktreeInfoEntry( addedLines: 12, removedLines: 4, pullRequest: nil )
let changed = updateWorktreeLineChanges( worktreeID: worktree.id, changes: GitLineChanges(added: 12, removed: 4), state: &state )
#expect(changed == false) #expect( state.worktreeInfoByID[worktree.id] == WorktreeInfoEntry(addedLines: 12, removedLines: 4, pullRequest: nil) ) }
@Test func updateWorktreeLineChangesReturnsFalseWhenClearingAlreadyEmptyDiffs() { let worktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var state = makeState(repositories: [repository]) let pullRequest = makePullRequest(state: "OPEN", headRefName: worktree.name) state.worktreeInfoByID[worktree.id] = WorktreeInfoEntry( addedLines: nil, removedLines: nil, pullRequest: pullRequest )
let changed = updateWorktreeLineChanges( worktreeID: worktree.id, changes: GitLineChanges(added: 0, removed: 0), state: &state )
#expect(changed == false) #expect( state.worktreeInfoByID[worktree.id] == WorktreeInfoEntry(addedLines: nil, removedLines: nil, pullRequest: pullRequest) ) }
@Test func updateWorktreeLineChangesReturnsTrueWhenCountsChange() { let worktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var state = makeState(repositories: [repository])
let changed = updateWorktreeLineChanges( worktreeID: worktree.id, changes: GitLineChanges(added: 12, removed: 4), state: &state )
#expect(changed == true) #expect( state.worktreeInfoByID[worktree.id] == WorktreeInfoEntry(addedLines: 12, removedLines: 4, pullRequest: nil) ) }
@Test func updateWorktreeLineChangesKeepsAnIncompleteZeroCountVisible() { let worktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var state = makeState(repositories: [repository])
let changed = updateWorktreeLineChanges( worktreeID: worktree.id, changes: GitLineChanges(added: 0, removed: 0, skippedUntrackedFileCount: 1), state: &state )
#expect(changed == true) #expect( state.worktreeInfoByID[worktree.id] == WorktreeInfoEntry( addedLines: 0, removedLines: 0, pullRequest: nil, skippedUntrackedFileCount: 1 ) ) }
@Test func filesChangedSkipsLineChangeActionWhenGitCountsMatchCurrentState() async { let worktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var state = makeState(repositories: [repository]) state.worktreeInfoByID[worktree.id] = WorktreeInfoEntry( addedLines: 12, removedLines: 4, pullRequest: nil )
let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.gitClient.lineChanges = { _ in GitLineChanges(added: 12, removed: 4) } }
await store.send(.worktreeInfoEvent(.filesChanged(worktreeID: worktree.id))) await store.finish() }
@Test func filesChangedSkipsLineChangeActionWhenGitReportsAlreadyEmptyDiff() async { let worktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) let pullRequest = makePullRequest(state: "OPEN", headRefName: worktree.name) var state = makeState(repositories: [repository]) state.worktreeInfoByID[worktree.id] = WorktreeInfoEntry( addedLines: nil, removedLines: nil, pullRequest: pullRequest )
let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.gitClient.lineChanges = { _ in GitLineChanges(added: 0, removed: 0) } }
await store.send(.worktreeInfoEvent(.filesChanged(worktreeID: worktree.id))) await store.finish() }
@Test func filesChangedSkipsLineChangeActionWhenEntryExplicitlyHoldsZeros() async { let worktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var state = makeState(repositories: [repository]) state.worktreeInfoByID[worktree.id] = WorktreeInfoEntry( addedLines: 0, removedLines: 0, pullRequest: nil )
let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.gitClient.lineChanges = { _ in GitLineChanges(added: 0, removed: 0) } }
await store.send(.worktreeInfoEvent(.filesChanged(worktreeID: worktree.id))) await store.finish() }
@Test func filesChangedEmitsLineChangeActionWhenGitCountsDiffer() async { let worktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var state = makeState(repositories: [repository]) state.worktreeInfoByID[worktree.id] = WorktreeInfoEntry( addedLines: 12, removedLines: 4, pullRequest: nil )
let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.gitClient.lineChanges = { _ in GitLineChanges(added: 15, removed: 9) } }
await store.send(.worktreeInfoEvent(.filesChanged(worktreeID: worktree.id))) await store.receive(\.worktreeLineChangesLoaded) { $0.worktreeInfoByID[worktree.id] = WorktreeInfoEntry( addedLines: 15, removedLines: 9, pullRequest: nil ) } }
@Test func filesChangedKeepsAnIncompleteLineCountVisible() async { let worktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.gitClient.lineChanges = { _ in GitLineChanges(added: 0, removed: 0, skippedUntrackedFileCount: 1) } }
await store.send(.worktreeInfoEvent(.filesChanged(worktreeID: worktree.id))) await store.receive(\.worktreeLineChangesLoaded) { $0.worktreeInfoByID[worktree.id] = WorktreeInfoEntry( addedLines: 0, removedLines: 0, pullRequest: nil, skippedUntrackedFileCount: 1 ) } }
@Test(.dependencies) func filesChangedSkipsLineChangesWhenObservationDisabled() async { let worktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var state = makeState(repositories: [repository]) state.worktreeInfoByID[worktree.id] = WorktreeInfoEntry( addedLines: 12, removedLines: 4, pullRequest: nil )
@Shared(.repositorySettings(repository.rootURL)) var repositorySettings $repositorySettings.withLock { $0.observeLineDiffsAutomatically = false }
let lineChangeRequests = LockIsolated(0) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.gitClient.lineChanges = { _ in lineChangeRequests.withValue { $0 += 1 } return GitLineChanges(added: 15, removed: 9) } }
await store.send(.worktreeInfoEvent(.filesChanged(worktreeID: worktree.id))) await store.finish()
#expect(lineChangeRequests.value == 0) }
@Test func repositoryWorktreesChangedReloadsRepositories() async { let existingWorktree = makeWorktree(id: "/tmp/repo/main", name: "main") let discoveredWorktree = makeWorktree(id: "/tmp/repo/feature", name: "feature") let repository = makeRepository(id: "/tmp/repo", worktrees: [existingWorktree]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.gitClient.worktrees = { _ in [existingWorktree, discoveredWorktree] } }
await store.send( .worktreeInfoEvent(.repositoryWorktreesChanged(repositoryRootURL: repository.rootURL))) await store.receive(\.reloadRepositories) await store.receive(\.repositoriesLoaded) { $0.repositories[id: repository.id] = makeRepository( id: repository.id, worktrees: [existingWorktree, discoveredWorktree] ) $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } await store.receive(\.delegate.repositoriesChanged) await store.finish() }
@Test func plainRepositoryBecameGitRepositoryReloadsRepositories() async { let repoRoot = "/tmp/new-project" let rootURL = URL(fileURLWithPath: repoRoot) let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let plainRepository = makeRepository( id: repoRoot, name: "new-project", kind: .plain, worktrees: []) let store = TestStore(initialState: makeState(repositories: [plainRepository])) { RepositoriesFeature() } withDependencies: { $0.repositoryPersistence.loadRepositoryEntries = { [PersistedRepositoryEntry(path: repoRoot, kind: .plain)] } $0.repositoryPersistence.saveRepositoryEntries = { _ in } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } $0.gitClient.repoRoot = { _ in rootURL } $0.gitClient.worktrees = { _ in [mainWorktree] } $0.gitClient.pruneWorktrees = { _ in } $0.gitClient.repositoryWebURL = { _ in nil } }
await store.send( .worktreeInfoEvent(.plainRepositoryBecameGitRepository(rootURL))) await store.receive(\.reloadRepositories) await store.receive(\.repositoriesLoaded) { $0.repositories[id: plainRepository.id] = makeRepository( id: repoRoot, name: "new-project", kind: .git, worktrees: [mainWorktree] ) $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } await store.receive(\.delegate.repositoriesChanged) }
@Test func repositoryRemoteConfigurationChangedRefreshesPullRequestsAndCodeHost() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree(id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) var initialState = makeState(repositories: [repository]) initialState.githubIntegrationAvailability = .unavailable initialState.codeHostByRepositoryID[repository.id] = .github let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.gitClient.repositoryWebURL = { _ in nil } }
await store.send( .worktreeInfoEvent( .repositoryRemoteConfigurationChanged(repositoryRootURL: repository.rootURL) ) ) { $0.pullRequestRefreshForcedWorktreeIDs = [mainWorktree.id, featureWorktree.id] } await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { $0.pendingPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( repositoryRootURL: repository.rootURL, worktreeIDs: [mainWorktree.id, featureWorktree.id] ) } await store.receive(\.codeHostsDetected) { $0.codeHostByRepositoryID[repository.id] = .unknown } }
@Test func repositoryRemoteConfigurationChangedMarksEveryBranchDue() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree(id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) var initialState = makeState(repositories: [repository]) initialState.githubIntegrationAvailability = .unavailable initialState.codeHostByRepositoryID[repository.id] = .github let answeredAt = Date(timeIntervalSince1970: 1_000_000) initialState.pullRequestRefreshCheckpointByWorktreeID = [ mainWorktree.id: PullRequestRefreshCadence.Checkpoint(branch: "main", pullRequest: nil, answeredAt: answeredAt), featureWorktree.id: PullRequestRefreshCadence.Checkpoint( branch: "feature", pullRequest: nil, answeredAt: answeredAt ), ] let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.gitClient.repositoryWebURL = { _ in nil } } store.exhaustivity = .off
// A changed remote can point every branch at other pull requests, so all of them are due, // whatever an answer from the old remote records meanwhile. await store.send( .worktreeInfoEvent( .repositoryRemoteConfigurationChanged(repositoryRootURL: repository.rootURL) ) ) { $0.pullRequestRefreshForcedWorktreeIDs = [mainWorktree.id, featureWorktree.id] } await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { $0.pendingPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( repositoryRootURL: repository.rootURL, worktreeIDs: [mainWorktree.id, featureWorktree.id] ) } }
@Test func repositoriesLoadedForgetsRefreshHistoryOfRemovedWorktrees() async { let worktree = makeWorktree(id: "/tmp/repo/main", name: "main") let removed = makeWorktree(id: "/tmp/repo/removed", name: "removed") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var initialState = makeState(repositories: [repository]) let answeredAt = Date(timeIntervalSince1970: 1_000_000) let kept = PullRequestRefreshCadence.Checkpoint(branch: "main", pullRequest: nil, answeredAt: answeredAt) initialState.pullRequestRefreshCheckpointByWorktreeID = [ worktree.id: kept, removed.id: PullRequestRefreshCadence.Checkpoint(branch: "removed", pullRequest: nil, answeredAt: answeredAt), ] initialState.pullRequestRefreshForcedWorktreeIDs = [removed.id]
let store = TestStore(initialState: initialState) { RepositoriesFeature() } store.exhaustivity = .off
await store.send( .repositoriesLoaded([repository], failures: [], roots: [repository.rootURL], animated: false) ) { $0.pullRequestRefreshCheckpointByWorktreeID = [worktree.id: kept] $0.pullRequestRefreshForcedWorktreeIDs = [] } }
@Test func repositoriesLoadedEmitsChangedDelegateWhenTransitioningFromRestoring() async { let worktree = makeWorktree(id: "/tmp/repo/main", name: "main") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var initialState = makeState(repositories: [repository]) initialState.snapshotPersistencePhase = .restoring
let store = TestStore(initialState: initialState) { RepositoriesFeature() }
await store.send( .repositoriesLoaded( [repository], failures: [], roots: [repository.rootURL], animated: false ) ) { $0.isRefreshingWorktrees = false $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } // Even though repos didn't change, the delegate must fire because we // transitioned from .restoring → .active. Layout restore depends on // receiving repositoriesChanged while phase is .active. await store.receive(\.delegate.repositoriesChanged) }
@Test func taskRestoresRepositorySnapshotBeforeLiveRefreshCompletes() async { let repoRoot = "/tmp/repo" let worktree = makeWorktree(id: "\(repoRoot)/main", name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [worktree]) let worktreeID = worktree.id let liveRefreshGate = AsyncGate()
let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() } withDependencies: { $0.repositoryPersistence.loadLastFocusedWorktreeID = { worktreeID } $0.repositoryPersistence.loadRepositorySnapshot = { [repository] } $0.repositoryPersistence.loadRoots = { [repoRoot] } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } $0.gitClient.worktrees = { _ in await liveRefreshGate.wait() return [worktree] } }
await store.send(.task) { $0.snapshotPersistencePhase = .restoring } await store.receive(\.pinnedWorktreeIDsLoaded) await store.receive(\.archivedWorktreesLoaded) await store.receive(\.repositoryOrderIDsLoaded) await store.receive(\.worktreeOrderByRepositoryLoaded) await store.receive(\.lastFocusedWorktreeIDLoaded) { $0.lastFocusedWorktreeID = worktreeID $0.shouldRestoreLastFocusedWorktree = true } await store.receive(\.repositorySnapshotLoaded) { $0.repositories = [repository] $0.repositoryRoots = [URL(fileURLWithPath: repoRoot)] $0.selection = .worktree(worktreeID) $0.shouldRestoreLastFocusedWorktree = false $0.isInitialLoadComplete = true } await store.receive(\.delegate.repositoriesChanged) await store.receive(\.delegate.selectedWorktreeChanged) await store.receive(\.loadPersistedRepositories)
await liveRefreshGate.resume()
await store.receive(\.repositoriesLoaded) { $0.snapshotPersistencePhase = .active } // After the fix, repositoriesLoaded also emits repositoriesChanged // when transitioning from .restoring → .active (even if repos are identical). await store.receive(\.delegate.repositoriesChanged) await store.finish() }
@Test func taskFallsBackToLiveLoadWhenRepositorySnapshotIsMissing() async { let repoRoot = "/tmp/repo" let worktree = makeWorktree(id: "\(repoRoot)/main", name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [worktree])
let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() } withDependencies: { $0.repositoryPersistence.loadRoots = { [repoRoot] } $0.repositoryPersistence.loadRepositorySnapshot = { nil } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } $0.gitClient.worktrees = { _ in [worktree] } }
await store.send(.task) { $0.snapshotPersistencePhase = .restoring } await store.receive(\.pinnedWorktreeIDsLoaded) await store.receive(\.archivedWorktreesLoaded) await store.receive(\.repositoryOrderIDsLoaded) await store.receive(\.worktreeOrderByRepositoryLoaded) await store.receive(\.lastFocusedWorktreeIDLoaded) { $0.shouldRestoreLastFocusedWorktree = true } await store.receive(\.repositorySnapshotLoaded) await store.receive(\.loadPersistedRepositories) await store.receive(\.repositoriesLoaded) { $0.repositories = [repository] $0.repositoryRoots = [URL(fileURLWithPath: repoRoot)] $0.shouldRestoreLastFocusedWorktree = false $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } await store.receive(\.delegate.repositoriesChanged) await store.finish() }
@Test func loadPersistedRepositoriesLoadsMixedGitAndPlainEntries() async { let repoRoot = "/tmp/repo" let plainRoot = "/tmp/folder" let worktree = makeWorktree(id: "\(repoRoot)/main", name: "main", repoRoot: repoRoot) let gitRepository = makeRepository(id: repoRoot, worktrees: [worktree]) let plainRepository = makeRepository( id: plainRoot, name: "folder", kind: .plain, worktrees: [] )
let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() } withDependencies: { $0.repositoryPersistence.loadRepositoryEntries = { [ PersistedRepositoryEntry(path: repoRoot, kind: .git), PersistedRepositoryEntry(path: plainRoot, kind: .plain), ] } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } $0.gitClient.worktrees = { root in let path = root.path(percentEncoded: false) if path == repoRoot { return [worktree] } Issue.record("worktrees should not load for plain repository: \(path)") return [] } }
await store.send(.loadPersistedRepositories) await store.receive(\.repositoriesLoaded) { $0.repositories = [gitRepository, plainRepository] $0.repositoryRoots = [repoRoot, plainRoot].map { URL(fileURLWithPath: $0) } $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } await store.receive(\.delegate.repositoriesChanged) await store.finish() }
@Test func loadPersistedRepositoriesLoadsWorkspaceMetadataAsPlainRepository() async throws { let rootURL = FileManager.default.temporaryDirectory .appending(path: "prowl-workspace-\(UUID().uuidString)") .standardizedFileURL defer { try? FileManager.default.removeItem(at: rootURL) }
try FileManager.default.createDirectory( at: rootURL.appending(path: ProjectWorkspace.metadataDirectoryName), withIntermediateDirectories: true ) try Data( """ { "title": "Multi Repo Workspace", "repositories": [ { "name": "App", "role": "client", "path": "app", "source_kind": "existing_path" } ] } """.utf8 ) .write(to: ProjectWorkspace.metadataURL(for: rootURL))
let rootPath = rootURL.path(percentEncoded: false) let loadedRootURL = URL(fileURLWithPath: rootPath).standardizedFileURL let workspace = try #require(ProjectWorkspace.load(from: loadedRootURL)) let repository = makeRepository( id: loadedRootURL.path(percentEncoded: false), name: "Multi Repo Workspace", kind: .plain, worktrees: [], workspace: workspace )
let childID = repository.rootURL.appending(path: "app").standardizedFileURL.path( percentEncoded: false) let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() } withDependencies: { $0.repositoryPersistence.loadRepositoryEntries = { [PersistedRepositoryEntry(path: rootPath, kind: .plain)] } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } $0.gitClient.repoRoot = { url in Issue.record( "workspace should load as plain without git probing: \(url.path(percentEncoded: false))") return url } $0.gitClient.worktrees = { url in Issue.record("workspace should not load git worktrees: \(url.path(percentEncoded: false))") return [] } // The workspace's child repository is refreshed via the child pipeline // (live branch + diff), distinct from the worktree probing above. $0.gitClient.branchName = { _ in "main" } $0.gitClient.lineChanges = { _ in nil } $0.gitClient.remoteInfo = { _ in nil } }
await store.send(.loadPersistedRepositories) await store.receive(\.repositoriesLoaded) { $0.repositories = [repository] $0.repositoryRoots = [URL(fileURLWithPath: rootPath)] $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } await store.receive(\.delegate.repositoriesChanged) await store.receive(\.workspaceChildrenInfoLoaded) { $0.workspaceChildBranchByID = [childID: "main"] } await store.finish() }
@Test func workspaceEditorOffersOpenedRepositoriesWithoutAddingThem() async { let repository = makeRepository(id: "/tmp/repo-a", name: "Repo A", worktrees: []) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() }
let title = "Workspace" let requestedRootPath = defaultWorkspaceBaseRootPath(for: title) let resolvedRootPath = expectedDefaultWorkspaceRootPath(for: title) await store.send(.workspaceCreation(.promptRequested)) { $0.workspaceEditor = WorkspaceEditorFeature.State( repositories: [], title: title, rootPath: requestedRootPath, openedRepositoryCandidates: [ ProjectWorkspaceCreationRepository( id: "/tmp/repo-a", name: "Repo A", rootURL: URL(fileURLWithPath: "/tmp/repo-a") ) ] ) } if resolvedRootPath == requestedRootPath { await store.receive(\.workspaceCreation.defaultRootPathResolved) } else { await store.receive(\.workspaceCreation.defaultRootPathResolved) { $0.workspaceEditor?.rootPath = resolvedRootPath } } }
@Test func requestRemoveWorkspaceShowsConfirmationSheet() async { let workspaceID = "/tmp/ws" let repository = makeRepository( id: workspaceID, name: "WS", kind: .plain, worktrees: [], workspace: ProjectWorkspace(title: "WS") ) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() }
await store.send(.repositoryManagement(.requestRemoveRepository(workspaceID))) { $0.removeWorkspaceConfirmation = RemoveWorkspaceConfirmation( repositoryID: workspaceID, workspaceTitle: "WS", rootPath: workspaceID ) } await store.send(.repositoryManagement(.removeWorkspaceDeleteFilesChanged(true))) { $0.removeWorkspaceConfirmation?.deleteFiles = true } await store.send(.repositoryManagement(.removeWorkspacePromptDismissed)) { $0.removeWorkspaceConfirmation = nil } }
@Test func requestRemoveWorkspaceBuildsBranchOptions() async { let workspaceID = "/tmp/ws" let workspace = ProjectWorkspace( title: "WS", repositories: [ ProjectWorkspace.RepositoryEntry( id: "api", name: "API", path: "api", sourceKind: .bareRepository, sourceLocation: "/tmp/api.git", branchName: "chore/x" ), ProjectWorkspace.RepositoryEntry( id: "app", name: "App", path: "app", sourceKind: .existingPath, sourceLocation: "/tmp/app" ), ProjectWorkspace.RepositoryEntry( id: "web", name: "Web", path: "web", sourceKind: .remote, sourceLocation: "git@github.com:onevcat/web.git", branchName: "feature/web" ), ] ) let repository = makeRepository( id: workspaceID, name: "WS", kind: .plain, worktrees: [], workspace: workspace ) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() }
await store.send(.repositoryManagement(.requestRemoveRepository(workspaceID))) { $0.removeWorkspaceConfirmation = RemoveWorkspaceConfirmation( repositoryID: workspaceID, workspaceTitle: "WS", rootPath: workspaceID, branchOptions: [ RemoveWorkspaceConfirmation.BranchOption( id: "api", repositoryName: "API", branchName: "chore/x" ) ] ) } await store.send(.repositoryManagement(.removeWorkspaceDeleteBranchChanged("api", true))) { $0.removeWorkspaceConfirmation?.branchOptions[0].isSelected = true } }
@Test func removeWorkspaceConfirmedRemovesEntry() async { let workspaceID = "/tmp/ws" let repository = makeRepository( id: workspaceID, name: "WS", kind: .plain, worktrees: [], workspace: ProjectWorkspace(title: "WS") ) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.repositoryPersistence.loadRepositoryEntries = { [] } $0.repositoryPersistence.saveRepositoryEntries = { _ in } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } } store.exhaustivity = .off
await store.send(.repositoryManagement(.requestRemoveRepository(workspaceID))) await store.send(.repositoryManagement(.removeWorkspacePromptConfirmed)) { $0.removeWorkspaceConfirmation = nil $0.removingRepositoryIDs = [workspaceID] } await store.receive(\.repositoryManagement.repositoryRemoved) await store.finish() }
@Test func removeSelectedWorkspaceSelectsNextRepository() async { let workspaceID = "/tmp/ws" let otherWorktree = makeWorktree(id: "/tmp/other/main", name: "main", repoRoot: "/tmp/other") let otherRepo = makeRepository(id: "/tmp/other", name: "other", worktrees: [otherWorktree]) let wsRepo = makeRepository( id: workspaceID, name: "WS", kind: .plain, worktrees: [], workspace: ProjectWorkspace(title: "WS") ) var state = makeState(repositories: [wsRepo, otherRepo]) state.selection = .repository(workspaceID) state.selectedWorkspaceChildID = "child-1" let otherEntry = PersistedRepositoryEntry(path: "/tmp/other", kind: .git) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.repositoryPersistence.loadRepositoryEntries = { [otherEntry] } $0.repositoryPersistence.saveRepositoryEntries = { _ in } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } $0.gitClient.repoRoot = { @Sendable url in url } $0.gitClient.worktrees = { @Sendable _ in [otherWorktree] } $0.gitClient.isBareRepository = { @Sendable _ in false } } store.exhaustivity = .off
await store.send(.repositoryManagement(.requestRemoveRepository(workspaceID))) await store.send(.repositoryManagement(.removeWorkspacePromptConfirmed)) await store.receive(\.repositoryManagement.repositoryRemoved) await store.receive(\.repositoriesLoaded) await store.finish()
#expect(store.state.selectedWorkspaceChildID == nil) #expect(store.state.selectedWorkspaceChildID == nil) // After removing the currently-selected workspace with another repo available, // the sidebar should auto-select the next item — not leave an empty selection. #expect( store.state.selection == .worktree(otherWorktree.id), "Expected auto-selection of next worktree, got \(String(describing: store.state.selection))" ) }
@Test func removeWorkspaceRoutesBranchDeletionThroughGuardedClient() async throws { let rootURL = FileManager.default.temporaryDirectory .appending(path: "prowl-ws-\(UUID().uuidString)") try FileManager.default.createDirectory( at: rootURL.appending(path: "api"), withIntermediateDirectories: true ) defer { try? FileManager.default.removeItem(at: rootURL) } let workspaceID = rootURL.path(percentEncoded: false) let workspace = ProjectWorkspace( repositories: [ ProjectWorkspace.RepositoryEntry( id: "api", name: "API", path: "api", sourceKind: .bareRepository, sourceLocation: "/tmp/api.git", branchName: "chore/x" ) ] ) let repository = makeRepository( id: workspaceID, name: "WS", kind: .plain, worktrees: [], workspace: workspace ) struct DeleteBranchCall: Sendable, Equatable { let name: String let root: String let force: Bool } let deleteCalls = LockIsolated<[DeleteBranchCall]>([]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.repositoryPersistence.loadRepositoryEntries = { [] } $0.repositoryPersistence.saveRepositoryEntries = { _ in } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } $0.shellClient.runLoginImpl = { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } $0.gitClient.deleteLocalBranch = { name, url, force in deleteCalls.withValue { $0.append( DeleteBranchCall(name: name, root: url.path(percentEncoded: false), force: force)) } return .deleted } } store.exhaustivity = .off
await store.send(.repositoryManagement(.requestRemoveRepository(workspaceID))) await store.send(.repositoryManagement(.removeWorkspaceDeleteFilesChanged(true))) await store.send(.repositoryManagement(.removeWorkspaceDeleteBranchChanged("api", true))) await store.send(.repositoryManagement(.removeWorkspacePromptConfirmed)) await store.receive(\.repositoryManagement.repositoryRemoved) await store.finish()
// Branch deletion must go through the guarded GitClient entry point with // force = true, never a raw `git branch -D`. #expect(deleteCalls.value.count == 1) #expect(deleteCalls.value.first?.name == "chore/x") #expect(deleteCalls.value.first?.root == "/tmp/api.git") #expect(deleteCalls.value.first?.force == true) // All worktrees unregistered, so the folder is deleted without prompting. #expect(!FileManager.default.fileExists(atPath: workspaceID)) }
@Test func removeWorkspaceAsksBeforeDeletingFolderWhenWorktreeRemovalFails() async throws { let rootURL = FileManager.default.temporaryDirectory .appending(path: "prowl-ws-\(UUID().uuidString)") try FileManager.default.createDirectory( at: rootURL.appending(path: "api"), withIntermediateDirectories: true ) defer { try? FileManager.default.removeItem(at: rootURL) } let workspaceID = rootURL.path(percentEncoded: false) let workspace = ProjectWorkspace( repositories: [ ProjectWorkspace.RepositoryEntry( id: "api", name: "API", path: "api", sourceKind: .bareRepository, sourceLocation: "/tmp/api.git" ) ] ) let repository = makeRepository( id: workspaceID, name: "WS", kind: .plain, worktrees: [], workspace: workspace ) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.repositoryPersistence.loadRepositoryEntries = { [] } $0.repositoryPersistence.saveRepositoryEntries = { _ in } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } $0.shellClient.runLoginImpl = { _, _, _, _ in throw ShellClientError(command: "git", stdout: "", stderr: "broken", exitCode: 1) } } store.exhaustivity = .off
await store.send(.repositoryManagement(.requestRemoveRepository(workspaceID))) await store.send(.repositoryManagement(.removeWorkspaceDeleteFilesChanged(true))) await store.send(.repositoryManagement(.removeWorkspacePromptConfirmed)) await store.receive(\.repositoryManagement.workspaceCleanupReportedFailures)
// The folder must NOT be deleted yet — the user is asked first. #expect(store.state.alert != nil) #expect(FileManager.default.fileExists(atPath: workspaceID))
await store.send( .alert( .presented( .confirmWorkspaceRootDeletion( repositoryID: workspaceID, rootPath: workspaceID, selectionWasRemoved: false )))) await store.receive(\.repositoryManagement.repositoryRemoved) await store.finish()
#expect(!FileManager.default.fileExists(atPath: workspaceID)) }
@Test func keepWorkspaceFolderAfterCleanupFailureRemovesEntryButKeepsFolder() async throws { let rootURL = FileManager.default.temporaryDirectory .appending(path: "prowl-ws-\(UUID().uuidString)") try FileManager.default.createDirectory( at: rootURL.appending(path: "api"), withIntermediateDirectories: true ) defer { try? FileManager.default.removeItem(at: rootURL) } let workspaceID = rootURL.path(percentEncoded: false) let workspace = ProjectWorkspace( repositories: [ ProjectWorkspace.RepositoryEntry( id: "api", name: "API", path: "api", sourceKind: .bareRepository, sourceLocation: "/tmp/api.git" ) ] ) let repository = makeRepository( id: workspaceID, name: "WS", kind: .plain, worktrees: [], workspace: workspace ) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.repositoryPersistence.loadRepositoryEntries = { [] } $0.repositoryPersistence.saveRepositoryEntries = { _ in } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } $0.shellClient.runLoginImpl = { _, _, _, _ in throw ShellClientError(command: "git", stdout: "", stderr: "broken", exitCode: 1) } } store.exhaustivity = .off
await store.send(.repositoryManagement(.requestRemoveRepository(workspaceID))) await store.send(.repositoryManagement(.removeWorkspaceDeleteFilesChanged(true))) await store.send(.repositoryManagement(.removeWorkspacePromptConfirmed)) await store.receive(\.repositoryManagement.workspaceCleanupReportedFailures)
await store.send( .alert( .presented( .keepWorkspaceFolderAfterCleanupFailure( repositoryID: workspaceID, selectionWasRemoved: false )))) await store.receive(\.repositoryManagement.repositoryRemoved) await store.finish()
// Entry removed from Prowl, but the on-disk folder is preserved. #expect(FileManager.default.fileExists(atPath: workspaceID)) }
@Test func repositoryRemovedDropsEntryDespiteTrailingSlashMismatch() async { let repository = makeRepository( id: "/tmp/ws/", name: "WS", kind: .plain, worktrees: [], workspace: ProjectWorkspace(title: "WS") ) let savedEntries = LockIsolated<[[PersistedRepositoryEntry]]>([]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.repositoryPersistence.loadRepositoryEntries = { [PersistedRepositoryEntry(path: "/tmp/ws", kind: .plain)] } $0.repositoryPersistence.saveRepositoryEntries = { entries in savedEntries.withValue { $0.append(entries) } } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } $0.gitClient.repoRoot = { _ in throw GitClientError.notRepository } } store.exhaustivity = .off
await store.send( .repositoryManagement(.repositoryRemoved("/tmp/ws/", selectionWasRemoved: false))) await store.finish()
#expect(savedEntries.value.last == []) }
@Test func workspaceCreationCancelWhileCreatingShowsToast() async { var initialState = RepositoriesFeature.State() initialState.workspaceEditor = WorkspaceEditorFeature.State( repositories: [], title: "Workspace", rootPath: "/tmp/workspace" ) initialState.workspaceEditor?.isSaving = true let store = TestStore(initialState: initialState) { RepositoriesFeature() } store.exhaustivity = .off
await store.send(.workspaceCreation(.promptCanceled)) { $0.workspaceEditor = nil } await store.receive(\.showToast, .warning("Workspace creation canceled")) }
@Test func workspaceGitRunnerUsesLoginShellForGitCommands() async throws { struct LoginCall: Equatable, Sendable { let executablePath: String let arguments: [String] let currentDirectoryPath: String? } let directRunCalls = LockIsolated(0) let loginCalls = LockIsolated<[LoginCall]>([]) let runner = RepositoriesFeature.workspaceGitRunner( shellClient: ShellClient( run: { _, _, _ in directRunCalls.withValue { $0 += 1 } throw ShellClientError(command: "git", stdout: "", stderr: "direct run used", exitCode: 1) }, runLoginImpl: { executableURL, arguments, currentDirectoryURL, _ in loginCalls.withValue { $0.append( LoginCall( executablePath: executableURL.path(percentEncoded: false), arguments: arguments, currentDirectoryPath: currentDirectoryURL?.path(percentEncoded: false) )) } return ShellOutput(stdout: "", stderr: "", exitCode: 0) } ), resolveGit: { .testExecutable } )
try await runner.run( ProjectWorkspaceGitCommand( arguments: ["-C", "/tmp/repo", "worktree", "add", "/tmp/workspace/repo"], currentDirectoryURL: URL(fileURLWithPath: "/tmp/repo") ))
#expect(directRunCalls.value == 0) #expect( loginCalls.value == [ LoginCall( executablePath: "/usr/bin/env", arguments: GitExecutable.testExecutable.environmentArguments + [ "/usr/bin/git", "-C", "/tmp/repo", "worktree", "add", "/tmp/workspace/repo", ], currentDirectoryPath: "/tmp/repo" ) ]) }
@Test func workspaceCreationSuccessOpensWorkspaceAndShowsToast() async throws { let rootURL = FileManager.default.temporaryDirectory .appending(path: "prowl-feature-success-\(UUID().uuidString)", directoryHint: .isDirectory) .standardizedFileURL defer { try? FileManager.default.removeItem(at: rootURL) } var initialState = RepositoriesFeature.State() initialState.workspaceEditor = WorkspaceEditorFeature.State( repositories: [], title: "Feature", rootPath: rootURL.path(percentEncoded: false) ) let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.date.now = Date(timeIntervalSince1970: 1_700_000_000) $0.shellClient.runLoginImpl = { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } $0.repositoryPersistence.loadRepositoryEntries = { [] } $0.repositoryPersistence.saveRepositoryEntries = { _ in } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } } store.exhaustivity = .off
let draft = ProjectWorkspaceCreationDraft( title: "Feature", rootURL: rootURL, repositories: [ ProjectWorkspaceRepositoryPlan( id: "app", name: "App", path: "app", sourceKind: .remote, sourceLocation: "git@github.com:onevcat/app.git", checkout: .useExistingRef("origin/main") ), ProjectWorkspaceRepositoryPlan( id: "api", name: "API", path: "api", sourceKind: .remote, sourceLocation: "git@github.com:onevcat/api.git", checkout: .useExistingRef("origin/main") ), ] )
await store.send(.workspaceCreation(.createWorkspace(draft))) { $0.workspaceEditor?.isSaving = true } await store.receive(\.workspaceCreation.workspaceCreated) { $0.workspaceEditor = nil } await store.receive(\.showToast, .success("Workspace created")) { $0.statusToast = .success("Workspace created") } await store.receive(\.repositoryManagement.openRepositories) await store.finish()
#expect(ProjectWorkspace.load(from: rootURL)?.title == "Feature") }
@Test func loadPersistedRepositoriesAutoUpgradesPlainFolderWhenItBecomesGitRoot() async { let root = "/tmp/folder" let worktree = makeWorktree(id: root, name: "folder", repoRoot: root) let upgradedRepository = makeRepository( id: root, name: "folder", kind: .git, worktrees: [worktree] ) let savedEntries = LockIsolated<[[PersistedRepositoryEntry]]>([])
let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() } withDependencies: { $0.repositoryPersistence.loadRepositoryEntries = { [PersistedRepositoryEntry(path: root, kind: .plain)] } $0.repositoryPersistence.saveRepositoryEntries = { entries in savedEntries.withValue { $0.append(entries) } } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } $0.gitClient.repoRoot = { url in #expect(url.path(percentEncoded: false) == root) return URL(fileURLWithPath: root) } $0.gitClient.worktrees = { url in #expect(url.path(percentEncoded: false) == root) return [worktree] } $0.gitClient.repositoryWebURL = { _ in nil } }
await store.send(.loadPersistedRepositories) await store.receive(\.repositoriesLoaded) { $0.repositories = [upgradedRepository] $0.repositoryRoots = [URL(fileURLWithPath: root)] $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } await store.receive(\.delegate.repositoriesChanged) await store.finish()
let expectedSavedEntries = [ [PersistedRepositoryEntry(path: root, kind: .git)] ] #expect(savedEntries.value == expectedSavedEntries) }
@Test func loadPersistedRepositoriesResolvesSymlinkedGitRepositoryRoot() async throws { let tempRoot = FileManager.default.temporaryDirectory .appending(path: UUID().uuidString, directoryHint: .isDirectory) let realRoot = tempRoot.appending(path: "real", directoryHint: .isDirectory) let symlinkRoot = tempRoot.appending(path: "link", directoryHint: .isDirectory) defer { try? FileManager.default.removeItem(at: tempRoot) } try FileManager.default.createDirectory(at: realRoot, withIntermediateDirectories: true) try FileManager.default.createSymbolicLink(at: symlinkRoot, withDestinationURL: realRoot)
let realPath = realRoot.standardizedFileURL.path(percentEncoded: false) let symlinkPath = symlinkRoot.standardizedFileURL.path(percentEncoded: false) let worktree = makeWorktree(id: realPath, name: "main", repoRoot: realPath) let repository = makeRepository(id: realPath, name: "real", kind: .git, worktrees: [worktree]) let savedEntries = LockIsolated<[[PersistedRepositoryEntry]]>([])
let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() } withDependencies: { $0.repositoryPersistence.loadRepositoryEntries = { [PersistedRepositoryEntry(path: symlinkPath, kind: .git)] } $0.repositoryPersistence.saveRepositoryEntries = { entries in savedEntries.withValue { $0.append(entries) } } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } $0.gitClient.repoRoot = { url in #expect(url.standardizedFileURL.path(percentEncoded: false) == symlinkPath) return realRoot } $0.gitClient.worktrees = { url in #expect(url.standardizedFileURL.path(percentEncoded: false) == realPath) return [worktree] } $0.gitClient.repositoryWebURL = { _ in nil } }
await store.send(.loadPersistedRepositories) await store.receive(\.repositoriesLoaded) { $0.repositories = [repository] $0.repositoryRoots = [realRoot.standardizedFileURL] $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } await store.receive(\.delegate.repositoriesChanged) await store.finish()
#expect(savedEntries.value == [[PersistedRepositoryEntry(path: realPath, kind: .git)]]) }
@Test func loadPersistedRepositoriesDoesNotUpgradePlainFolderWhenOnlyAncestorIsGitRoot() async { let root = "/tmp/folder" let ancestorRoot = "/tmp" let plainRepository = makeRepository( id: root, name: "folder", kind: .plain, worktrees: [] ) let savedEntries = LockIsolated<[[PersistedRepositoryEntry]]>([])
let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() } withDependencies: { $0.repositoryPersistence.loadRepositoryEntries = { [PersistedRepositoryEntry(path: root, kind: .plain)] } $0.repositoryPersistence.saveRepositoryEntries = { entries in savedEntries.withValue { $0.append(entries) } } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } $0.gitClient.repoRoot = { url in #expect(url.path(percentEncoded: false) == root) return URL(fileURLWithPath: ancestorRoot) } $0.gitClient.worktrees = { url in Issue.record("plain folder should not load worktrees: \(url.path(percentEncoded: false))") return [] } }
await store.send(.loadPersistedRepositories) await store.receive(\.repositoriesLoaded) { $0.repositories = [plainRepository] $0.repositoryRoots = [URL(fileURLWithPath: root)] $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } await store.receive(\.delegate.repositoriesChanged) await store.finish()
#expect(savedEntries.value.isEmpty) }
@Test func loadPersistedRepositoriesAutoDowngradesGitRepoWhenItStopsBeingRepoRoot() async { let root = "/tmp/repo" let ancestorRoot = "/tmp" let downgradedRepository = makeRepository( id: root, name: "repo", kind: .plain, worktrees: [] ) let savedEntries = LockIsolated<[[PersistedRepositoryEntry]]>([])
let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() } withDependencies: { $0.repositoryPersistence.loadRepositoryEntries = { [PersistedRepositoryEntry(path: root, kind: .git)] } $0.repositoryPersistence.saveRepositoryEntries = { entries in savedEntries.withValue { $0.append(entries) } } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } $0.gitClient.repoRoot = { url in #expect(url.path(percentEncoded: false) == root) return URL(fileURLWithPath: ancestorRoot) } $0.gitClient.worktrees = { url in Issue.record( "downgraded git entry should not load worktrees: \(url.path(percentEncoded: false))") return [] } }
await store.send(.loadPersistedRepositories) await store.receive(\.repositoriesLoaded) { $0.repositories = [downgradedRepository] $0.repositoryRoots = [URL(fileURLWithPath: root)] $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } await store.receive(\.delegate.repositoriesChanged) await store.finish()
let expectedSavedEntries = [ [PersistedRepositoryEntry(path: root, kind: .plain)] ] #expect(savedEntries.value == expectedSavedEntries) }
@Test func loadPersistedRepositoriesDoesNotDowngradeGitRepoOnUnexpectedProbeError() async { let root = "/tmp/repo" let worktree = makeWorktree(id: "\(root)/main", name: "main", repoRoot: root) let repository = makeRepository(id: root, name: "repo", kind: .git, worktrees: [worktree]) let savedEntries = LockIsolated<[[PersistedRepositoryEntry]]>([])
let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() } withDependencies: { $0.repositoryPersistence.loadRepositoryEntries = { [PersistedRepositoryEntry(path: root, kind: .git)] } $0.repositoryPersistence.saveRepositoryEntries = { entries in savedEntries.withValue { $0.append(entries) } } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } $0.gitClient.repoRoot = { _ in throw GitClientError.commandFailed(command: "wt root", message: "permission denied") } $0.gitClient.worktrees = { url in #expect(url.path(percentEncoded: false) == root) return [worktree] } $0.gitClient.repositoryWebURL = { _ in nil } }
await store.send(.loadPersistedRepositories) await store.receive(\.repositoriesLoaded) { $0.repositories = [repository] $0.repositoryRoots = [URL(fileURLWithPath: root)] $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } await store.receive(\.delegate.repositoriesChanged) await store.finish()
#expect(savedEntries.value.isEmpty) }
@Test func openRepositoriesAddsPlainFoldersInsteadOfRejectingThem() async { let repoSelection = "/tmp/repo/subdir" let repoRoot = "/tmp/repo" let plainRoot = "/tmp/plain" let worktree = makeWorktree(id: "\(repoRoot)/main", name: "main", repoRoot: repoRoot) let gitRepository = makeRepository(id: repoRoot, worktrees: [worktree]) let plainRepository = makeRepository( id: plainRoot, name: "plain", kind: .plain, worktrees: [] ) let savedEntries = LockIsolated<[[PersistedRepositoryEntry]]>([])
let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() } withDependencies: { $0.repositoryPersistence.loadRepositoryEntries = { [] } $0.repositoryPersistence.saveRepositoryEntries = { entries in savedEntries.withValue { $0.append(entries) } } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } $0.gitClient.repoRoot = { url in let path = url.path(percentEncoded: false) if path == repoSelection { return URL(fileURLWithPath: repoRoot) } if path == plainRoot { throw GitClientError.notRepository } Issue.record("Unexpected repoRoot lookup: \(path)") return URL(fileURLWithPath: repoRoot) } $0.gitClient.worktrees = { root in let path = root.path(percentEncoded: false) if path == repoRoot { return [worktree] } Issue.record("worktrees should not load for plain repository: \(path)") return [] } }
await store.send( .repositoryManagement( .openRepositories([ URL(fileURLWithPath: repoSelection), URL(fileURLWithPath: plainRoot), ])) ) await store.receive(\.repositoryManagement.openRepositoriesFinished) { $0.repositories = [gitRepository, plainRepository] $0.repositoryRoots = [repoRoot, plainRoot].map { URL(fileURLWithPath: $0) } $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } await store.receive(\.delegate.repositoriesChanged) await store.finish()
let expectedSavedEntries = [ [ PersistedRepositoryEntry(path: repoRoot, kind: .git), PersistedRepositoryEntry(path: plainRoot, kind: .plain), ] ] #expect(savedEntries.value == expectedSavedEntries) }
@Test func openRepositoriesFinishedAutoSelectsNewGitRepositoryAfterInitialLoad() async { let existingWorktree = makeWorktree(id: "/tmp/existing/main", name: "main", repoRoot: "/tmp/existing") let existingRepository = makeRepository(id: "/tmp/existing", worktrees: [existingWorktree]) let newWorktree = makeWorktree(id: "/tmp/new/main", name: "main", repoRoot: "/tmp/new") let newRepository = makeRepository(id: "/tmp/new", name: "new", worktrees: [newWorktree]) var initialState = makeState(repositories: [existingRepository]) initialState.isInitialLoadComplete = true initialState.snapshotPersistencePhase = .active
let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.gitClient.repositoryWebURL = { _ in nil } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } }
await store.send( .repositoryManagement( .openRepositoriesFinished( [existingRepository, newRepository], failures: [], invalidRoots: [], openFailures: [], roots: [existingRepository.rootURL, newRepository.rootURL] )) ) { $0.repositories = [existingRepository, newRepository] $0.repositoryRoots = [existingRepository.rootURL, newRepository.rootURL] } await store.receive(\.delegate.repositoriesChanged) await store.receive(\.selectWorktree) { $0.selection = .worktree(newWorktree.id) $0.sidebarSelectedWorktreeIDs = [newWorktree.id] $0.pendingTerminalFocusWorktreeIDs = [newWorktree.id] $0.openedWorktreeIDs = [newWorktree.id] } await store.receive(\.delegate.selectedWorktreeChanged) await store.finish() }
@Test func openRepositoriesFinishedAutoSelectsNewPlainRepositoryAfterInitialLoad() async { let existingWorktree = makeWorktree(id: "/tmp/existing/main", name: "main", repoRoot: "/tmp/existing") let existingRepository = makeRepository(id: "/tmp/existing", worktrees: [existingWorktree]) let plainRepository = makeRepository( id: "/tmp/plain", name: "plain", kind: .plain, worktrees: [] ) var initialState = makeState(repositories: [existingRepository]) initialState.isInitialLoadComplete = true initialState.snapshotPersistencePhase = .active
let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.gitClient.repositoryWebURL = { _ in nil } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } }
await store.send( .repositoryManagement( .openRepositoriesFinished( [existingRepository, plainRepository], failures: [], invalidRoots: [], openFailures: [], roots: [existingRepository.rootURL, plainRepository.rootURL] )) ) { $0.repositories = [existingRepository, plainRepository] $0.repositoryRoots = [existingRepository.rootURL, plainRepository.rootURL] $0.pendingTerminalFocusWorktreeIDs = [plainRepository.id] } await store.receive(\.delegate.repositoriesChanged) await store.receive(\.selectRepository) { $0.selection = .repository(plainRepository.id) $0.openedWorktreeIDs = [plainRepository.id] } await store.receive(\.delegate.selectedWorktreeChanged) await store.finish() }
@Test func openRepositoriesFinishedDoesNotAutoSelectDuringInitialLoad() async { let worktree = makeWorktree(id: "/tmp/repo/main", name: "main", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() } withDependencies: { $0.gitClient.repositoryWebURL = { _ in nil } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } }
await store.send( .repositoryManagement( .openRepositoriesFinished( [repository], failures: [], invalidRoots: [], openFailures: [], roots: [repository.rootURL] )) ) { $0.repositories = [repository] $0.repositoryRoots = [repository.rootURL] $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } await store.receive(\.delegate.repositoriesChanged) await store.finish() }
@Test func openRepositoriesFinishedDoesNotAutoSelectWhileRestoringSnapshot() async { let existingWorktree = makeWorktree(id: "/tmp/existing/main", name: "main", repoRoot: "/tmp/existing") let existingRepository = makeRepository(id: "/tmp/existing", worktrees: [existingWorktree]) let newWorktree = makeWorktree(id: "/tmp/new/main", name: "main", repoRoot: "/tmp/new") let newRepository = makeRepository(id: "/tmp/new", name: "new", worktrees: [newWorktree]) var initialState = makeState(repositories: [existingRepository]) initialState.isInitialLoadComplete = true initialState.snapshotPersistencePhase = .restoring
let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.gitClient.repositoryWebURL = { _ in nil } }
await store.send( .repositoryManagement( .openRepositoriesFinished( [existingRepository, newRepository], failures: [], invalidRoots: [], openFailures: [], roots: [existingRepository.rootURL, newRepository.rootURL] )) ) { $0.repositories = [existingRepository, newRepository] $0.repositoryRoots = [existingRepository.rootURL, newRepository.rootURL] $0.snapshotPersistencePhase = .active } await store.receive(\.delegate.repositoriesChanged) await store.finish() }
@Test func revealInSidebarExpandsCollapsedRepository() async { let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var initialState = makeState(repositories: [repository]) initialState.selection = .worktree(worktree.id) initialState.sidebarSelectedWorktreeIDs = [worktree.id] initialState.$collapsedRepositoryIDs.withLock { $0 = [repository.id] } let store = TestStore(initialState: initialState) { RepositoriesFeature() }
await store.send(.revealSelectedWorktreeInSidebar) { $0.$collapsedRepositoryIDs.withLock { $0 = [] } $0.nextPendingSidebarRevealID = 1 $0.pendingSidebarReveal = .init(id: 1, worktreeID: worktree.id) } }
@Test func revealInSidebarWithNoSelectionIsNoOp() async { let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) let initialState = makeState(repositories: [repository]) let store = TestStore(initialState: initialState) { RepositoriesFeature() }
await store.send(.revealSelectedWorktreeInSidebar) }
@Test func revealInSidebarKeepsOtherRepositoriesCollapsed() async { let worktree1 = makeWorktree(id: "/tmp/repo-a/wt", name: "wt", repoRoot: "/tmp/repo-a") let worktree2 = makeWorktree(id: "/tmp/repo-b/wt", name: "wt", repoRoot: "/tmp/repo-b") let repoA = makeRepository(id: "/tmp/repo-a", worktrees: [worktree1]) let repoB = makeRepository(id: "/tmp/repo-b", worktrees: [worktree2]) var initialState = makeState(repositories: [repoA, repoB]) initialState.selection = .worktree(worktree1.id) initialState.sidebarSelectedWorktreeIDs = [worktree1.id] initialState.$collapsedRepositoryIDs.withLock { $0 = [repoA.id, repoB.id] } let store = TestStore(initialState: initialState) { RepositoriesFeature() }
await store.send(.revealSelectedWorktreeInSidebar) { $0.$collapsedRepositoryIDs.withLock { $0 = [repoB.id] } $0.nextPendingSidebarRevealID = 1 $0.pendingSidebarReveal = .init(id: 1, worktreeID: worktree1.id) } }
@Test func consumePendingSidebarRevealClearsMatchingRequest() async { let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var initialState = makeState(repositories: [repository]) initialState.nextPendingSidebarRevealID = 1 initialState.pendingSidebarReveal = .init(id: 1, worktreeID: worktree.id) let store = TestStore(initialState: initialState) { RepositoriesFeature() }
await store.send(.consumePendingSidebarReveal(1)) { $0.pendingSidebarReveal = nil } }
@Test func consumePendingSidebarRevealIgnoresStaleRequest() async { let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var initialState = makeState(repositories: [repository]) initialState.nextPendingSidebarRevealID = 2 initialState.pendingSidebarReveal = .init(id: 2, worktreeID: worktree.id) let store = TestStore(initialState: initialState) { RepositoriesFeature() }
// Stale ID should be ignored, pendingSidebarReveal remains unchanged. await store.send(.consumePendingSidebarReveal(1)) }
@Test func openRepositoriesDoesNotDowngradeFoldersOnUnexpectedProbeError() async { let repoSelection = "/tmp/repo/subdir" let repoRoot = "/tmp/repo" let blockedRoot = "/tmp/blocked" let worktree = makeWorktree(id: "\(repoRoot)/main", name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [worktree]) let savedEntries = LockIsolated<[[PersistedRepositoryEntry]]>([])
let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() } withDependencies: { $0.repositoryPersistence.loadRepositoryEntries = { [] } $0.repositoryPersistence.saveRepositoryEntries = { entries in savedEntries.withValue { $0.append(entries) } } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } $0.gitClient.repoRoot = { url in let path = url.path(percentEncoded: false) if path == repoSelection { return URL(fileURLWithPath: repoRoot) } if path == blockedRoot { throw GitClientError.commandFailed(command: "wt root", message: "permission denied") } Issue.record("Unexpected repoRoot lookup: \(path)") return URL(fileURLWithPath: repoRoot) } $0.gitClient.worktrees = { url in #expect(url.path(percentEncoded: false) == repoRoot) return [worktree] } }
let expectedAlert = AlertState<RepositoriesFeature.Alert> { TextState("Some folders couldn't be opened") } actions: { ButtonState(role: .cancel) { TextState("OK") } } message: { TextState("\(blockedRoot): permission denied") }
await store.send( .repositoryManagement( .openRepositories([ URL(fileURLWithPath: repoSelection), URL(fileURLWithPath: blockedRoot), ])) ) await store.receive(\.repositoryManagement.openRepositoriesFinished) { $0.repositories = [repository] $0.repositoryRoots = [repoRoot].map { URL(fileURLWithPath: $0) } $0.isInitialLoadComplete = true $0.alert = expectedAlert $0.snapshotPersistencePhase = .active } await store.receive(\.delegate.repositoriesChanged) await store.finish()
let expectedSavedEntries = [ [PersistedRepositoryEntry(path: repoRoot, kind: .git)] ] #expect(savedEntries.value == expectedSavedEntries) }
@Test func repositoriesLoadedSkipsRepositorySnapshotPersistenceWhileRestoring() async { let repoRoot = "/tmp/repo" let worktree = makeWorktree(id: "\(repoRoot)/main", name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [worktree]) let savedSnapshots = LockIsolated<[[Repository]]>([]) var state = RepositoriesFeature.State() state.snapshotPersistencePhase = .restoring
let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.repositoryPersistence.saveRepositorySnapshot = { repositories in savedSnapshots.withValue { $0.append(repositories) } } }
await store.send( .repositoriesLoaded( [repository], failures: [], roots: [URL(fileURLWithPath: repoRoot)], animated: false ) ) { $0.repositories = [repository] $0.repositoryRoots = [URL(fileURLWithPath: repoRoot)] $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } await store.receive(\.delegate.repositoriesChanged) await store.finish()
#expect(savedSnapshots.value.isEmpty) }
@Test func repositoriesLoadedPersistsRepositorySnapshotOnSuccess() async { let repoRoot = "/tmp/repo" let worktree = makeWorktree(id: "\(repoRoot)/main", name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [worktree]) let savedSnapshots = LockIsolated<[[Repository]]>([])
let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() } withDependencies: { $0.repositoryPersistence.saveRepositorySnapshot = { repositories in savedSnapshots.withValue { $0.append(repositories) } } }
await store.send( .repositoriesLoaded( [repository], failures: [], roots: [URL(fileURLWithPath: repoRoot)], animated: false ) ) { $0.repositories = [repository] $0.repositoryRoots = [URL(fileURLWithPath: repoRoot)] $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } await store.receive(\.delegate.repositoriesChanged) await store.finish()
#expect(savedSnapshots.value == [[repository]]) }
@Test func repositoriesLoadedSkipsRepositorySnapshotPersistenceWhenLoadFails() async { let repoRoot = "/tmp/repo" let worktree = makeWorktree(id: "\(repoRoot)/main", name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [worktree]) let savedSnapshots = LockIsolated<[[Repository]]>([])
let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() } withDependencies: { $0.repositoryPersistence.saveRepositorySnapshot = { repositories in savedSnapshots.withValue { $0.append(repositories) } } }
await store.send( .repositoriesLoaded( [repository], failures: [.init(rootID: repoRoot, message: "wt failed")], roots: [URL(fileURLWithPath: repoRoot)], animated: false ) ) { $0.repositories = [repository] $0.repositoryRoots = [URL(fileURLWithPath: repoRoot)] $0.isInitialLoadComplete = true $0.loadFailuresByID = [repoRoot: "wt failed"] } await store.receive(\.delegate.repositoriesChanged) await store.finish()
#expect(savedSnapshots.value.isEmpty) }
@Test func selectWorktreeSendsDelegate() async { let worktree = makeWorktree(id: "/tmp/wt", name: "fox") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() }
await store.send(.selectWorktree(worktree.id)) { $0.selection = .worktree(worktree.id) $0.sidebarSelectedWorktreeIDs = [worktree.id] $0.openedWorktreeIDs = [worktree.id] } await store.receive(\.delegate.selectedWorktreeChanged) }
@Test func newTerminalTabSelectsWorktreeAndCreatesRootTab() async { let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) let sentCommands = LockIsolated<[TerminalClient.Command]>([]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.terminalClient.send = { command in sentCommands.withValue { $0.append(command) } } }
await store.send(.newTerminalTab(worktree.id)) await store.receive(\.selectWorktree) { $0.selection = .worktree(worktree.id) $0.sidebarSelectedWorktreeIDs = [worktree.id] $0.openedWorktreeIDs = [worktree.id] $0.pendingTerminalFocusWorktreeIDs = [worktree.id] } await store.receive(\.delegate.selectedWorktreeChanged) await store.finish()
#expect( sentCommands.value == [ .createTabInDirectory(worktree, directory: worktree.workingDirectory) ] ) }
@Test func newTerminalTabInCanvasCreatesAndFocusesNewCanvasTab() async { let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) let createdTabID = TerminalTabID(rawValue: UUID()) var initialState = makeState(repositories: [repository]) initialState.selection = .canvas
let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.terminalClient.createTabInDirectory = { actualWorktree, actualDirectory in #expect(actualWorktree == worktree) #expect(actualDirectory == worktree.workingDirectory) return createdTabID } }
await store.send(.newTerminalTab(worktree.id)) await store.receive(\.newTerminalTabCreatedInCanvas) { $0.nextCanvasFocusRequestID = 1 $0.pendingCanvasFocusRequest = CanvasFocusRequest( id: 1, target: .tab(createdTabID) ) $0.openedWorktreeIDs = [worktree.id] } await store.finish()
#expect(store.state.selection == .canvas) }
@Test func newTerminalTabWithUnknownWorktreeDoesNothing() async { let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() }
await store.send(.newTerminalTab("/tmp/unknown")) }
@Test func activeAgentEntryTappedFocusesSurfaceBeforeSelectingWorktree() async { let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var state = makeState(repositories: [repository]) let surfaceID = UUID() let entry = ActiveAgentEntry( id: surfaceID, worktreeID: worktree.id, worktreeName: worktree.name, workingDirectory: nil, tabID: TerminalTabID(rawValue: UUID()), paneTitle: "agent", surfaceID: surfaceID, paneIndex: 0, iconLookupToken: DetectedAgent.codex.iconLookupToken, agent: .codex, rawState: .working, displayState: .working, lastChangedAt: Date(timeIntervalSince1970: 0) ) state.activeAgents.entries = [entry]
let focusedSurface = LockIsolated<(Worktree.ID, UUID)?>(nil) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.terminalClient.focusSurface = { worktreeID, surface in focusedSurface.setValue((worktreeID, surface)) return true } }
// Tapping mirrors the surface into the keyboard-nav anchor synchronously... await store.send(.activeAgents(.entryTapped(entry.id))) { $0.activeAgents.focusedSurfaceID = surfaceID } // ...then the worktree is selected with `focusTerminal` (proved by the pending // focus set) only after the target surface has already been focused, so the // worktree shows the right tab the instant it becomes visible. await store.receive(\.selectWorktree) { $0.selection = .worktree(worktree.id) $0.sidebarSelectedWorktreeIDs = [worktree.id] $0.openedWorktreeIDs = [worktree.id] $0.pendingTerminalFocusWorktreeIDs = [worktree.id] } await store.receive(\.delegate.selectedWorktreeChanged)
#expect(focusedSurface.value?.0 == worktree.id) #expect(focusedSurface.value?.1 == surfaceID) }
@Test func activeAgentEntryTappedSelectsPlainRepository() async { let repository = makeRepository( id: "/tmp/folder", name: "folder", kind: .plain, worktrees: [] ) var state = makeState(repositories: [repository]) let surfaceID = UUID() let entry = ActiveAgentEntry( id: surfaceID, worktreeID: repository.id, worktreeName: repository.name, workingDirectory: nil, tabID: TerminalTabID(rawValue: UUID()), paneTitle: "agent", surfaceID: surfaceID, paneIndex: 0, iconLookupToken: DetectedAgent.codex.iconLookupToken, agent: .codex, rawState: .working, displayState: .working, lastChangedAt: Date(timeIntervalSince1970: 0) ) state.activeAgents.entries = [entry]
let focusedSurface = LockIsolated<(Worktree.ID, UUID)?>(nil) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.terminalClient.focusSurface = { worktreeID, surface in focusedSurface.setValue((worktreeID, surface)) return true } }
await store.send(.activeAgents(.entryTapped(entry.id))) { $0.pendingTerminalFocusWorktreeIDs = [repository.id] $0.activeAgents.focusedSurfaceID = surfaceID } await store.receive(\.selectRepository) { $0.selection = .repository(repository.id) $0.sidebarSelectedWorktreeIDs = [] $0.openedWorktreeIDs = [repository.id] } await store.receive(\.delegate.selectedWorktreeChanged)
#expect(focusedSurface.value?.0 == repository.id) #expect(focusedSurface.value?.1 == surfaceID) }
@Test func activeAgentMarkAsReadTappedMarksSurfaceNotificationsRead() async { let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var state = makeState(repositories: [repository]) let surfaceID = UUID() let entry = ActiveAgentEntry( id: surfaceID, worktreeID: worktree.id, worktreeName: worktree.name, workingDirectory: nil, tabID: TerminalTabID(rawValue: UUID()), paneTitle: "agent", surfaceID: surfaceID, paneIndex: 0, iconLookupToken: DetectedAgent.codex.iconLookupToken, agent: .codex, rawState: .working, displayState: .working, lastChangedAt: Date(timeIntervalSince1970: 0) ) state.activeAgents.entries = [entry]
let markedRead = LockIsolated<(Worktree.ID, UUID)?>(nil) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.terminalClient.markNotificationsReadForSurface = { worktreeID, surface in markedRead.setValue((worktreeID, surface)) } }
// Mark as Read clears the entry's pane without selecting or focusing it. await store.send(.activeAgents(.markAsReadTapped(entry.id)))
#expect(markedRead.value?.0 == worktree.id) #expect(markedRead.value?.1 == surfaceID) }
@Test func selectWorktreeCollapsesSidebarSelectedWorktreeIDs() async { let wt1 = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: "/tmp/repo") let wt2 = makeWorktree(id: "/tmp/repo/wt2", name: "wt2", repoRoot: "/tmp/repo") let wt3 = makeWorktree(id: "/tmp/repo/wt3", name: "wt3", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2, wt3]) var initialState = makeState(repositories: [repository]) initialState.selection = .worktree(wt1.id) initialState.sidebarSelectedWorktreeIDs = [wt1.id, wt2.id, wt3.id] let store = TestStore(initialState: initialState) { RepositoriesFeature() }
await store.send(.selectWorktree(wt2.id)) { $0.selection = .worktree(wt2.id) $0.sidebarSelectedWorktreeIDs = [wt2.id] $0.openedWorktreeIDs = [wt2.id] $0.worktreeHistoryBackStack = [wt1.id] } await store.receive(\.delegate.selectedWorktreeChanged) }
@Test func selectRepositoryClearsWorktreeSelectionAndSendsNilDelegate() async { let worktree = makeWorktree(id: "/tmp/repo/main", name: "main", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var initialState = makeState(repositories: [repository]) initialState.selection = .worktree(worktree.id) initialState.sidebarSelectedWorktreeIDs = [worktree.id] let store = TestStore(initialState: initialState) { RepositoriesFeature() }
await store.send(.selectRepository(repository.id)) { $0.worktreeHistoryBackStack = [worktree.id] $0.selection = .repository(repository.id) $0.sidebarSelectedWorktreeIDs = [] } await store.receive(\.delegate.selectedWorktreeChanged) }
@Test func focusCanvasRepositoryRequestsCanvasFocusWithoutLeavingCanvas() async { let worktree = makeWorktree(id: "/tmp/repo/main", name: "main", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var initialState = makeState(repositories: [repository]) initialState.selection = .canvas
let sentCommands = LockIsolated<[TerminalClient.Command]>([]) let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.terminalClient.send = { command in sentCommands.withValue { $0.append(command) } } }
await store.send(.focusCanvasRepository(repository.id)) { $0.nextCanvasFocusRequestID = 1 $0.pendingCanvasFocusRequest = CanvasFocusRequest( id: 1, target: .worktree(worktree.id) ) $0.openedWorktreeIDs = [worktree.id] } await store.finish()
#expect(store.state.selection == .canvas) #expect( sentCommands.value == [ .ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false) ] ) }
@Test func focusCanvasPlainRepositoryRequestsCanvasFocusWithoutLeavingCanvas() async { let repository = makeRepository( id: "/tmp/folder", name: "folder", kind: .plain, worktrees: [] ) var initialState = makeState(repositories: [repository]) initialState.selection = .canvas
let expectedWorktree = Worktree( id: repository.id, name: repository.name, detail: repository.rootURL.path(percentEncoded: false), workingDirectory: repository.rootURL, repositoryRootURL: repository.rootURL ) let sentCommands = LockIsolated<[TerminalClient.Command]>([]) let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.terminalClient.send = { command in sentCommands.withValue { $0.append(command) } } }
await store.send(.focusCanvasRepository(repository.id)) { $0.nextCanvasFocusRequestID = 1 $0.pendingCanvasFocusRequest = CanvasFocusRequest( id: 1, target: .worktree(repository.id) ) $0.openedWorktreeIDs = [repository.id] } await store.finish()
#expect(store.state.selection == .canvas) #expect( sentCommands.value == [ .ensureInitialTab(expectedWorktree, runSetupScriptIfNew: false, focusing: false) ] ) }
@Test func focusCanvasWorktreeRequestsCanvasFocusWithoutLeavingCanvas() async { let worktree = makeWorktree(id: "/tmp/repo/main", name: "main", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var initialState = makeState(repositories: [repository]) initialState.selection = .canvas
let sentCommands = LockIsolated<[TerminalClient.Command]>([]) let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.terminalClient.send = { command in sentCommands.withValue { $0.append(command) } } }
await store.send(.focusCanvasWorktree(worktree.id)) { $0.nextCanvasFocusRequestID = 1 $0.pendingCanvasFocusRequest = CanvasFocusRequest( id: 1, target: .worktree(worktree.id) ) $0.openedWorktreeIDs = [worktree.id] } await store.finish()
#expect(store.state.selection == .canvas) #expect( sentCommands.value == [ .ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false) ] ) }
@Test func activeAgentEntryTappedInCanvasRequestsExactCardFocusWithoutLeavingCanvas() async { let worktree = makeWorktree(id: "/tmp/repo/main", name: "main", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var state = makeState(repositories: [repository]) state.selection = .canvas let surfaceID = UUID() let tabID = TerminalTabID(rawValue: UUID()) let entry = ActiveAgentEntry( id: surfaceID, worktreeID: worktree.id, worktreeName: worktree.name, workingDirectory: nil, tabID: tabID, paneTitle: "agent", surfaceID: surfaceID, paneIndex: 0, iconLookupToken: DetectedAgent.codex.iconLookupToken, agent: .codex, rawState: .working, displayState: .working, lastChangedAt: Date(timeIntervalSince1970: 0) ) state.activeAgents.entries = [entry]
let focusedSurface = LockIsolated<(Worktree.ID, UUID)?>(nil) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.terminalClient.focusSurface = { worktreeID, surface in focusedSurface.setValue((worktreeID, surface)) return true } }
await store.send(.activeAgents(.entryTapped(entry.id))) { $0.activeAgents.focusedSurfaceID = surfaceID $0.nextCanvasFocusRequestID = 1 $0.pendingCanvasFocusRequest = CanvasFocusRequest( id: 1, target: .tab(tabID) ) $0.openedWorktreeIDs = [worktree.id] } await store.finish()
#expect(store.state.selection == .canvas) #expect(focusedSurface.value?.0 == worktree.id) #expect(focusedSurface.value?.1 == surfaceID) }
@Test func selectPlainRepositorySendsPlainFolderTerminalTargetDelegate() async { let repository = makeRepository( id: "/tmp/folder", name: "folder", kind: .plain, worktrees: [] ) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() }
await store.send(.selectRepository(repository.id)) { $0.selection = .repository(repository.id) $0.sidebarSelectedWorktreeIDs = [] $0.openedWorktreeIDs = [repository.id] } #expect( store.state.selectedTerminalWorktree == Worktree( id: repository.id, name: repository.name, detail: repository.rootURL.path(percentEncoded: false), workingDirectory: repository.rootURL, repositoryRootURL: repository.rootURL ) ) await store.receive(\.delegate.selectedWorktreeChanged) }
@Test func toggleCanvasRestoresFocusedPlainRepositorySelection() async { let repository = makeRepository( id: "/tmp/folder", name: "folder", kind: .plain, worktrees: [] ) var initialState = makeState(repositories: [repository]) initialState.selection = .canvas let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.terminalClient.canvasFocusedWorktreeID = { repository.id } }
await store.send(.toggleCanvas) { $0.pendingTerminalFocusWorktreeIDs = [repository.id] } await store.receive(\.selectRepository) { $0.selection = .repository(repository.id) $0.sidebarSelectedWorktreeIDs = [] $0.openedWorktreeIDs = [repository.id] } await store.receive(\.delegate.selectedWorktreeChanged) }
@Test func toggleCanvasFallsBackToPreCanvasPlainRepositorySelection() async { let repository = makeRepository( id: "/tmp/folder", name: "folder", kind: .plain, worktrees: [] ) var initialState = makeState(repositories: [repository]) initialState.selection = .canvas initialState.preCanvasTerminalTargetID = repository.id let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.terminalClient.canvasFocusedWorktreeID = { nil } }
await store.send(.toggleCanvas) { $0.pendingTerminalFocusWorktreeIDs = [repository.id] } await store.receive(\.selectRepository) { $0.selection = .repository(repository.id) $0.sidebarSelectedWorktreeIDs = [] $0.openedWorktreeIDs = [repository.id] } await store.receive(\.delegate.selectedWorktreeChanged) }
@Test func selectTabbedFromCanvasExitsCanvasInsteadOfFocusingCard() async { let worktree = makeWorktree(id: "/tmp/repo/main", name: "main", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var initialState = makeState(repositories: [repository]) initialState.selection = .canvas
let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.terminalClient.canvasFocusedWorktreeID = { worktree.id } }
await store.send(.setTopSegment(.tabbed)) await store.receive(\.selectTabbed) await store.receive(\.toggleCanvas) await store.receive(\.selectWorktree) { $0.selection = .worktree(worktree.id) $0.sidebarSelectedWorktreeIDs = [worktree.id] $0.pendingTerminalFocusWorktreeIDs = [worktree.id] $0.openedWorktreeIDs = [worktree.id] } await store.receive(\.delegate.selectedWorktreeChanged) #expect(store.state.pendingCanvasFocusRequest == nil) }
@Test func selectShelfFromCanvasExitsCanvasInsteadOfFocusingCard() async { let worktree = makeWorktree(id: "/tmp/repo/main", name: "main", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var initialState = makeState(repositories: [repository]) initialState.selection = .canvas
let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.terminalClient.canvasFocusedWorktreeID = { worktree.id } }
await store.send(.setTopSegment(.shelf)) await store.receive(\.selectShelf) await store.receive(\.toggleShelf) { $0.isShelfActive = true } await store.receive(\.selectWorktree) { $0.selection = .worktree(worktree.id) $0.sidebarSelectedWorktreeIDs = [worktree.id] $0.pendingTerminalFocusWorktreeIDs = [worktree.id] $0.openedWorktreeIDs = [worktree.id] } await store.receive(\.delegate.selectedWorktreeChanged) #expect(store.state.pendingCanvasFocusRequest == nil) #expect(store.state.isShowingShelf) }
@Test func selectCanvasSeedsInitialTabForSelectedWorktree() async { // Canvas only renders cards for worktrees with a live terminal surface. // Entering Canvas must seed the selected worktree's tab so launching // straight into Canvas shows that worktree's card — Normal/Shelf open one // tab on launch, but Canvas mounts no per-worktree view to do it lazily. let worktree = makeWorktree(id: "/tmp/repo/main", name: "main", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var initialState = makeState(repositories: [repository]) initialState.selection = .worktree(worktree.id)
let sentCommands = LockIsolated<[TerminalClient.Command]>([]) let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.terminalClient.send = { command in sentCommands.withValue { $0.append(command) } } }
await store.send(.selectCanvas) { $0.preCanvasWorktreeID = worktree.id $0.preCanvasTerminalTargetID = worktree.id $0.selection = .canvas } await store.finish()
#expect( sentCommands.value == [ .ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false), .setCanvasMode(true), ] ) }
@Test func selectCanvasWithoutSelectionOnlyEntersCanvasMode() async { // With nothing selected there is no surface to seed — Canvas just flips // into canvas mode and renders an empty board. let sentCommands = LockIsolated<[TerminalClient.Command]>([]) let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() } withDependencies: { $0.terminalClient.send = { command in sentCommands.withValue { $0.append(command) } } }
await store.send(.selectCanvas) { $0.selection = .canvas } await store.finish()
#expect(sentCommands.value == [.setCanvasMode(true)]) }
@Test func selectRepositoryIgnoresUnknownRepository() async { let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() }
await store.send(.selectRepository("/tmp/missing")) }
@Test func setSidebarSelectedWorktreeIDsKeepsSelectedAndPrunesUnknown() async { let worktree1 = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: "/tmp/repo") let worktree2 = makeWorktree(id: "/tmp/repo/wt2", name: "wt2", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree1, worktree2]) var initialState = makeState(repositories: [repository]) initialState.selection = .worktree(worktree1.id) let store = TestStore(initialState: initialState) { RepositoriesFeature() }
await store.send( .setSidebarSelectedWorktreeIDs( [worktree2.id, "/tmp/repo/unknown"] ) ) { $0.sidebarSelectedWorktreeIDs = [worktree1.id, worktree2.id] } }
@Test func selectArchivedWorktreesClearsSidebarSelectedWorktreeIDs() async { let worktree1 = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: "/tmp/repo") let worktree2 = makeWorktree(id: "/tmp/repo/wt2", name: "wt2", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree1, worktree2]) var initialState = makeState(repositories: [repository]) initialState.selection = .worktree(worktree1.id) initialState.sidebarSelectedWorktreeIDs = [worktree1.id, worktree2.id] let store = TestStore(initialState: initialState) { RepositoriesFeature() }
await store.send(.selectArchivedWorktrees) { $0.worktreeHistoryBackStack = [worktree1.id] $0.selection = .archivedWorktrees $0.sidebarSelectedWorktreeIDs = [] $0.preArchivedWorktreeID = worktree1.id } await store.receive(\.delegate.selectedWorktreeChanged) }
@Test func createRandomWorktreeWithoutRepositoriesShowsAlert() async { let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() }
let expectedAlert = AlertState<RepositoriesFeature.Alert> { TextState("Unable to create worktree") } actions: { ButtonState(role: .cancel) { TextState("OK") } } message: { TextState("Open a repository to create a worktree.") }
await store.send(.worktreeCreation(.createRandomWorktree)) { $0.alert = expectedAlert } }
@Test func canCreateWorktreeIsFalseForSelectedPlainRepository() { let repository = makeRepository(id: "/tmp/folder", kind: .plain, worktrees: []) var state = makeState(repositories: [repository]) state.selection = .repository(repository.id)
#expect(state.canCreateWorktree == false) }
@Test func createRandomWorktreeInPlainRepositoryShowsAlert() async { let repository = makeRepository(id: "/tmp/folder", kind: .plain, worktrees: []) var initialState = makeState(repositories: [repository]) initialState.selection = .repository(repository.id) let store = TestStore(initialState: initialState) { RepositoriesFeature() }
let expectedAlert = AlertState<RepositoriesFeature.Alert> { TextState("Unable to create worktree") } actions: { ButtonState(role: .cancel) { TextState("OK") } } message: { TextState("This folder doesn't support worktrees.") }
await store.send(.worktreeCreation(.createRandomWorktree)) { $0.alert = expectedAlert } }
@Test func createRandomWorktreeInRepositoryWithPromptEnabledPresentsPrompt() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } $0.gitClient.branchRefs = { _ in ["origin/main", "origin/dev"] } }
store.exhaustivity = .off await store.send(.worktreeCreation(.createRandomWorktreeInRepository(repository.id))) await store.receive(\.worktreeCreation.promptedWorktreeCreationDataLoaded) { #expect($0.worktreeCreationPrompt?.repositoryID == repository.id) #expect($0.worktreeCreationPrompt?.branchName == "") #expect($0.worktreeCreationPrompt?.baseRefOptions == ["origin/dev", "origin/main"]) #expect($0.worktreeCreationPrompt?.isSuggestingName == true) #expect($0.worktreeCreationPrompt?.randomPlaceholder.isEmpty == false) } }
@Test func promptedWorktreeCreationCancelDismissesPrompt() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) var state = makeState(repositories: [repository]) state.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( repositoryID: repository.id, repositoryRootURL: repository.rootURL, repositoryName: repository.name, automaticBaseRef: "origin/main", baseRefOptions: ["origin/main"], branchName: "feature/new-branch", selectedBaseRef: nil, fetchRemote: true, defaultWorktreeBaseDirectory: "", validationMessage: nil, randomPlaceholder: "bold-cat-042" ) let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.worktreeCreationPrompt(.presented(.delegate(.cancel)))) await store.receive(\.worktreeCreation.promptCanceled) { $0.worktreeCreationPrompt = nil } }
@Test func startPromptedWorktreeCreationWithDuplicateLocalBranchShowsValidation() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) var state = makeState(repositories: [repository]) state.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( repositoryID: repository.id, repositoryRootURL: repository.rootURL, repositoryName: repository.name, automaticBaseRef: "origin/main", baseRefOptions: ["origin/main"], branchName: "feature/existing", selectedBaseRef: nil, fetchRemote: true, defaultWorktreeBaseDirectory: "", validationMessage: nil, randomPlaceholder: "bold-cat-042" ) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.gitClient.localBranchNames = { _ in ["feature/existing"] } }
await store.send( .worktreeCreation( .startPromptedWorktreeCreation( repositoryID: repository.id, branchName: "feature/existing", baseRef: nil )) ) { $0.worktreeCreationPrompt?.validationMessage = nil $0.worktreeCreationPrompt?.isValidating = true } await store.receive(\.worktreeCreation.promptedWorktreeCreationChecked) { $0.worktreeCreationPrompt?.validationMessage = "Branch name already exists." $0.worktreeCreationPrompt?.isValidating = false } }
@Test func createRandomWorktreeInRepositoryLatestPromptRequestWins() async { actor PromptLoadGate { var continuation: CheckedContinuation<Void, Never>?
func wait() async { await withCheckedContinuation { continuation in self.continuation = continuation } }
func waitUntilArmed() async { while continuation == nil { await Task.yield() } }
func resume() { continuation?.resume() continuation = nil } }
let repoRootA = "/tmp/repo-a" let repoRootB = "/tmp/repo-b" let promptLoadGate = PromptLoadGate() let repoA = makeRepository( id: repoRootA, worktrees: [makeWorktree(id: repoRootA, name: "main", repoRoot: repoRootA)] ) let repoB = makeRepository( id: repoRootB, worktrees: [makeWorktree(id: repoRootB, name: "main", repoRoot: repoRootB)] ) let store = TestStore(initialState: makeState(repositories: [repoA, repoB])) { RepositoriesFeature() } withDependencies: { $0.gitClient.automaticWorktreeBaseRef = { root in if root.path(percentEncoded: false) == repoRootA { await promptLoadGate.wait() } return "origin/main" } $0.gitClient.branchRefs = { _ in ["origin/main"] } }
store.exhaustivity = .off await store.send(.worktreeCreation(.createRandomWorktreeInRepository(repoA.id))) await promptLoadGate.waitUntilArmed() await store.send(.worktreeCreation(.createRandomWorktreeInRepository(repoB.id))) await promptLoadGate.resume() await store.receive(\.worktreeCreation.promptedWorktreeCreationDataLoaded) { #expect($0.worktreeCreationPrompt?.repositoryID == repoB.id) #expect($0.worktreeCreationPrompt?.branchName == "") #expect($0.worktreeCreationPrompt?.isSuggestingName == true) } }
@Test func promptedWorktreeCreationCancelDuringValidationStopsCreation() async { let validationClock = TestClock() let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) var state = makeState(repositories: [repository]) state.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( repositoryID: repository.id, repositoryRootURL: repository.rootURL, repositoryName: repository.name, automaticBaseRef: "origin/main", baseRefOptions: ["origin/main"], branchName: "feature/new-branch", selectedBaseRef: nil, fetchRemote: true, defaultWorktreeBaseDirectory: "", validationMessage: nil, randomPlaceholder: "bold-cat-042" ) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.gitClient.localBranchNames = { _ in try? await validationClock.sleep(for: .seconds(1)) return [] } }
await store.send( .worktreeCreation( .startPromptedWorktreeCreation( repositoryID: repository.id, branchName: "feature/new-branch", baseRef: nil )) ) { $0.worktreeCreationPrompt?.validationMessage = nil $0.worktreeCreationPrompt?.isValidating = true } await store.send(.worktreeCreationPrompt(.presented(.delegate(.cancel)))) await store.receive(\.worktreeCreation.promptCanceled) { $0.worktreeCreationPrompt = nil } await validationClock.advance(by: .seconds(1)) await store.finish() }
@Test func createWorktreeInRepositoryWithInvalidBranchNameFails() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.uuid = .incrementing $0.gitClient.isValidBranchName = { _, _ in false } $0.gitClient.localBranchNames = { _ in [] } } store.exhaustivity = .off
let expectedAlert = AlertState<RepositoriesFeature.Alert> { TextState("Branch name invalid") } actions: { ButtonState(role: .cancel) { TextState("OK") } } message: { TextState("Enter a valid git branch name and try again.") }
await store.send( .worktreeCreation( .createWorktreeInRepository( repositoryID: repository.id, nameSource: .explicit("../../Desktop"), baseRefSource: .repositorySetting, fetchRemote: true )) ) await store.receive(\.worktreeCreation.createRandomWorktreeFailed) { $0.alert = expectedAlert } #expect(store.state.pendingWorktrees.isEmpty) await store.finish() }
@Test func createRandomWorktreeFailedWithTraversalNameSkipsCleanup() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) let removed = LockIsolated(false) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.gitClient.removeWorktree = { _, _ in removed.withValue { $0 = true } return URL(fileURLWithPath: "/tmp/removed") } $0.gitClient.pruneWorktrees = { _ in } $0.gitClient.worktrees = { _ in [mainWorktree] } }
let expectedAlert = AlertState<RepositoriesFeature.Alert> { TextState("Unable to create worktree") } actions: { ButtonState(role: .cancel) { TextState("OK") } } message: { TextState("boom") }
await store.send( .worktreeCreation( .createRandomWorktreeFailed( title: "Unable to create worktree", message: "boom", pendingID: "pending:1", previousSelection: nil, repositoryID: repository.id, name: "../../Desktop", baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees") )) ) { $0.alert = expectedAlert } await store.finish() #expect(removed.value == false) }
@Test(.dependencies) func createRandomWorktreeInRepositoryStreamsOutputLines() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) let createdWorktree = makeWorktree( id: "/tmp/repo/swift-otter", name: "swift-otter", repoRoot: repoRoot ) @Shared(.settingsFile) var settingsFile $settingsFile.withLock { $0.global.promptForWorktreeCreation = false $0.global.fetchOriginBeforeWorktreeCreation = false } let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.uuid = .incrementing $0.gitClient.localBranchNames = { _ in [] } $0.gitClient.isBareRepository = { _ in false } $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } $0.gitClient.ignoredFileCount = { _ in 2 } $0.gitClient.untrackedFileCount = { _ in 1 } $0.gitClient.createWorktreeStream = { _ in AsyncThrowingStream { continuation in continuation.yield(.outputLine(ShellStreamLine(source: .stderr, text: "[1/2] copy .env"))) continuation.yield( .outputLine(ShellStreamLine(source: .stderr, text: "[2/2] copy .cache"))) continuation.yield(.finished(createdWorktree)) continuation.finish() } } $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } } store.exhaustivity = .off
await store.send(.worktreeCreation(.createRandomWorktreeInRepository(repository.id))) await store.receive(\.worktreeCreation.createRandomWorktreeSucceeded) await store.finish()
#expect(store.state.pendingWorktrees.isEmpty) #expect(store.state.selection == .worktree(createdWorktree.id)) #expect(store.state.sidebarSelectedWorktreeIDs == [createdWorktree.id]) #expect(store.state.pendingSetupScriptWorktreeIDs.contains(createdWorktree.id)) #expect(store.state.pendingTerminalFocusWorktreeIDs.contains(createdWorktree.id)) #expect(store.state.repositories[id: repository.id]?.worktrees[id: createdWorktree.id] != nil) #expect(store.state.alert == nil) }
@Test(.dependencies) func createWorktreeFetchesMatchedRemoteBeforeCreatingWorktree() async { let repoRoot = "/tmp/\(UUID().uuidString)-repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) let createdWorktree = makeWorktree( id: "\(repoRoot)/swift-otter", name: "swift-otter", repoRoot: repoRoot ) let events = LockIsolated<[String]>([]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.uuid = .incrementing $0.gitClient.localBranchNames = { _ in [] } $0.gitClient.isBareRepository = { _ in false } $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } $0.gitClient.remoteNames = { _ in ["origin", "upstream"] } $0.gitClient.fetchRemote = { remote, _ in events.withValue { $0.append("fetch:\(remote)") } } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } $0.gitClient.createWorktreeStream = { _ in events.withValue { $0.append("create") } return AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) continuation.finish() } } $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } } store.exhaustivity = .off
await store.send( .worktreeCreation( .createWorktreeInRepository( repositoryID: repository.id, nameSource: .random, baseRefSource: .repositorySetting, fetchRemote: true )) ) await store.receive(\.worktreeCreation.createRandomWorktreeSucceeded) await store.finish()
#expect(events.value == ["fetch:origin", "create"]) }
@Test(.dependencies) func createWorktreeSkipsFetchWhenDisabled() async { let repoRoot = "/tmp/\(UUID().uuidString)-repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) let createdWorktree = makeWorktree( id: "\(repoRoot)/swift-otter", name: "swift-otter", repoRoot: repoRoot ) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.uuid = .incrementing $0.gitClient.localBranchNames = { _ in [] } $0.gitClient.isBareRepository = { _ in false } $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } $0.gitClient.remoteNames = { _ in Issue.record("remoteNames should not be requested when fetch is disabled") return ["origin"] } $0.gitClient.fetchRemote = { _, _ in Issue.record("fetchRemote should not run when fetch is disabled") } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } $0.gitClient.createWorktreeStream = { _ in AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) continuation.finish() } } $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } } store.exhaustivity = .off
await store.send( .worktreeCreation( .createWorktreeInRepository( repositoryID: repository.id, nameSource: .random, baseRefSource: .repositorySetting, fetchRemote: false )) ) await store.receive(\.worktreeCreation.createRandomWorktreeSucceeded) await store.finish() }
@Test(.dependencies) func createWorktreeContinuesWhenFetchFails() async { let repoRoot = "/tmp/\(UUID().uuidString)-repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) let createdWorktree = makeWorktree( id: "\(repoRoot)/swift-otter", name: "swift-otter", repoRoot: repoRoot ) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.uuid = .incrementing $0.gitClient.localBranchNames = { _ in [] } $0.gitClient.isBareRepository = { _ in false } $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } $0.gitClient.remoteNames = { _ in ["origin"] } $0.gitClient.fetchRemote = { _, _ in throw NSError( domain: "git", code: 128, userInfo: [NSLocalizedDescriptionKey: "network unreachable"]) } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } $0.gitClient.createWorktreeStream = { _ in AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) continuation.finish() } } $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } } store.exhaustivity = .off
await store.send( .worktreeCreation( .createWorktreeInRepository( repositoryID: repository.id, nameSource: .random, baseRefSource: .repositorySetting, fetchRemote: true )) ) await store.receive(\.worktreeCreation.createRandomWorktreeSucceeded) await store.finish() }
@Test(.dependencies) func createWorktreeSkipsFetchWhenNoMatchedRemote() async { let repoRoot = "/tmp/\(UUID().uuidString)-repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) let createdWorktree = makeWorktree( id: "\(repoRoot)/swift-otter", name: "swift-otter", repoRoot: repoRoot ) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.uuid = .incrementing $0.gitClient.localBranchNames = { _ in [] } $0.gitClient.isBareRepository = { _ in false } $0.gitClient.automaticWorktreeBaseRef = { _ in "local-branch" } $0.gitClient.remoteNames = { _ in ["origin", "upstream"] } $0.gitClient.fetchRemote = { _, _ in Issue.record("fetchRemote should not run when no remote matches the base ref") } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } $0.gitClient.createWorktreeStream = { _ in AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) continuation.finish() } } $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } } store.exhaustivity = .off
await store.send( .worktreeCreation( .createWorktreeInRepository( repositoryID: repository.id, nameSource: .random, baseRefSource: .repositorySetting, fetchRemote: true )) ) await store.receive(\.worktreeCreation.createRandomWorktreeSucceeded) await store.finish() }
@Test(.dependencies) func createRandomWorktreeUsesRepositoryWorktreeBaseDirectoryOverride() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) let createdWorktree = makeWorktree( id: "/tmp/repo/swift-otter", name: "swift-otter", repoRoot: repoRoot ) let observedBaseDirectory = LockIsolated<URL?>(nil) @Shared(.settingsFile) var settingsFile $settingsFile.withLock { $0.global.promptForWorktreeCreation = false $0.global.fetchOriginBeforeWorktreeCreation = false $0.global.defaultWorktreeBaseDirectoryPath = "/tmp/global-worktrees" } @Shared(.repositorySettings(repository.rootURL)) var repositorySettings $repositorySettings.withLock { $0.worktreeBaseDirectoryPath = "/tmp/repo-override" } let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.uuid = .incrementing $0.gitClient.localBranchNames = { _ in [] } $0.gitClient.isBareRepository = { _ in false } $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } $0.gitClient.createWorktreeStream = { request in observedBaseDirectory.withValue { $0 = request.baseDirectory } return AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) continuation.finish() } } $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } } store.exhaustivity = .off
await store.send(.worktreeCreation(.createRandomWorktreeInRepository(repository.id))) await store.receive(\.worktreeCreation.createRandomWorktreeSucceeded) await store.finish()
let expectedBaseDirectory = ProwlPaths.worktreeBaseDirectory( for: repository.rootURL, globalDefaultPath: "/tmp/global-worktrees", repositoryOverridePath: "/tmp/repo-override" ) #expect(observedBaseDirectory.value == expectedBaseDirectory) }
@Test(.dependencies) func createRandomWorktreeUsesGlobalWorktreeBaseDirectoryWhenRepositoryOverrideMissing() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) let createdWorktree = makeWorktree( id: "/tmp/repo/swift-otter", name: "swift-otter", repoRoot: repoRoot ) let observedBaseDirectory = LockIsolated<URL?>(nil) @Shared(.settingsFile) var settingsFile $settingsFile.withLock { $0.global.promptForWorktreeCreation = false $0.global.fetchOriginBeforeWorktreeCreation = false $0.global.defaultWorktreeBaseDirectoryPath = "/tmp/global-worktrees" } @Shared(.repositorySettings(repository.rootURL)) var repositorySettings $repositorySettings.withLock { $0.worktreeBaseDirectoryPath = nil } let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.uuid = .incrementing $0.gitClient.localBranchNames = { _ in [] } $0.gitClient.isBareRepository = { _ in false } $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } $0.gitClient.createWorktreeStream = { request in observedBaseDirectory.withValue { $0 = request.baseDirectory } return AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) continuation.finish() } } $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } } store.exhaustivity = .off
await store.send(.worktreeCreation(.createRandomWorktreeInRepository(repository.id))) await store.receive(\.worktreeCreation.createRandomWorktreeSucceeded) await store.finish()
let expectedBaseDirectory = ProwlPaths.worktreeBaseDirectory( for: repository.rootURL, globalDefaultPath: "/tmp/global-worktrees", repositoryOverridePath: nil ) #expect(observedBaseDirectory.value == expectedBaseDirectory) }
@Test(.dependencies) func createRandomWorktreeUsesGlobalCopyFlagsWhenRepositoryOverridesMissing() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) let createdWorktree = makeWorktree( id: "/tmp/repo/swift-otter", name: "swift-otter", repoRoot: repoRoot ) let observedCopyFlags = LockIsolated<(Bool, Bool)?>(nil) @Shared(.settingsFile) var settingsFile $settingsFile.withLock { $0.global.promptForWorktreeCreation = false $0.global.copyIgnoredOnWorktreeCreate = true $0.global.copyUntrackedOnWorktreeCreate = true } @Shared(.repositorySettings(repository.rootURL)) var repositorySettings $repositorySettings.withLock { $0.copyIgnoredOnWorktreeCreate = nil $0.copyUntrackedOnWorktreeCreate = nil } let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.uuid = .incrementing $0.gitClient.localBranchNames = { _ in [] } $0.gitClient.isBareRepository = { _ in false } $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } $0.gitClient.createWorktreeStream = { request in observedCopyFlags.withValue { $0 = (request.copyFiles.ignored, request.copyFiles.untracked) } return AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) continuation.finish() } } $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } } store.exhaustivity = .off
await store.send(.worktreeCreation(.createRandomWorktreeInRepository(repository.id))) await store.receive(\.worktreeCreation.createRandomWorktreeSucceeded) await store.finish()
#expect(observedCopyFlags.value?.0 == true) #expect(observedCopyFlags.value?.1 == true) }
@Test(.dependencies) func createRandomWorktreeInBareRepositoryIgnoresGlobalCopyFlags() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) let createdWorktree = makeWorktree( id: "/tmp/repo/swift-otter", name: "swift-otter", repoRoot: repoRoot ) let observedCopyFlags = LockIsolated<(Bool, Bool)?>(nil) @Shared(.settingsFile) var settingsFile $settingsFile.withLock { $0.global.promptForWorktreeCreation = false $0.global.copyIgnoredOnWorktreeCreate = true $0.global.copyUntrackedOnWorktreeCreate = true } @Shared(.repositorySettings(repository.rootURL)) var repositorySettings $repositorySettings.withLock { $0.copyIgnoredOnWorktreeCreate = nil $0.copyUntrackedOnWorktreeCreate = nil } let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.uuid = .incrementing $0.gitClient.localBranchNames = { _ in [] } $0.gitClient.isBareRepository = { _ in true } $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } $0.gitClient.createWorktreeStream = { request in observedCopyFlags.withValue { $0 = (request.copyFiles.ignored, request.copyFiles.untracked) } return AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) continuation.finish() } } $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } } store.exhaustivity = .off
await store.send(.worktreeCreation(.createRandomWorktreeInRepository(repository.id))) await store.receive(\.worktreeCreation.createRandomWorktreeSucceeded) await store.finish()
#expect(observedCopyFlags.value?.0 == false) #expect(observedCopyFlags.value?.1 == false) }
@Test(.dependencies) func createRandomWorktreeInRepositoryStreamFailureRemovesPendingWorktree() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) @Shared(.settingsFile) var settingsFile $settingsFile.withLock { $0.global.promptForWorktreeCreation = false $0.global.fetchOriginBeforeWorktreeCreation = false } let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.uuid = .incrementing $0.gitClient.localBranchNames = { _ in [] } $0.gitClient.isBareRepository = { _ in false } $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } $0.gitClient.ignoredFileCount = { _ in 2 } $0.gitClient.untrackedFileCount = { _ in 1 } $0.gitClient.createWorktreeStream = { _ in AsyncThrowingStream { continuation in continuation.yield(.outputLine(ShellStreamLine(source: .stderr, text: "[1/2] copy .env"))) continuation.finish( throwing: GitClientError.commandFailed(command: "wt sw", message: "boom")) } } } store.exhaustivity = .off
await store.send(.worktreeCreation(.createRandomWorktreeInRepository(repository.id))) await store.receive(\.worktreeCreation.createRandomWorktreeFailed) await store.finish()
let expectedAlert = AlertState<RepositoriesFeature.Alert> { TextState("Unable to create worktree") } actions: { ButtonState(role: .cancel) { TextState("OK") } } message: { TextState("Git command failed: wt sw\nboom") }
#expect(store.state.pendingWorktrees.isEmpty) #expect(store.state.selection == nil) #expect(store.state.alert == expectedAlert) #expect(store.state.repositories[id: repository.id]?.worktrees[id: mainWorktree.id] != nil) }
@Test(.dependencies) func createRandomWorktreeFailureUsesProvidedBaseDirectoryForCleanup() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) let createTimeBaseDirectory = ProwlPaths.worktreeBaseDirectory( for: repository.rootURL, globalDefaultPath: "/tmp/worktrees-original", repositoryOverridePath: nil ) let changedBaseDirectory = ProwlPaths.worktreeBaseDirectory( for: repository.rootURL, globalDefaultPath: "/tmp/worktrees-changed", repositoryOverridePath: nil ) let removedWorktree = LockIsolated<(path: String, deleteBranch: Bool)?>(nil) @Shared(.settingsFile) var settingsFile $settingsFile.withLock { $0.global.defaultWorktreeBaseDirectoryPath = "/tmp/worktrees-changed" } let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.gitClient.removeWorktree = { worktree, deleteBranch in let workingDirectory = await MainActor.run { worktree.workingDirectory } removedWorktree.withValue { $0 = (workingDirectory.path(percentEncoded: false), deleteBranch) } return workingDirectory } $0.gitClient.pruneWorktrees = { _ in } } store.exhaustivity = .off
let expectedAlert = AlertState<RepositoriesFeature.Alert> { TextState("Unable to create worktree") } actions: { ButtonState(role: .cancel) { TextState("OK") } } message: { TextState("boom") }
await store.send( .worktreeCreation( .createRandomWorktreeFailed( title: "Unable to create worktree", message: "boom", pendingID: "pending:test", previousSelection: nil, repositoryID: repository.id, name: "new-branch", baseDirectory: createTimeBaseDirectory )) ) { $0.alert = expectedAlert } await store.finish()
#expect(changedBaseDirectory != createTimeBaseDirectory) #expect(removedWorktree.value != nil) #expect(removedWorktree.value?.deleteBranch == false) #expect( removedWorktree.value?.path == createTimeBaseDirectory .appending(path: "new-branch", directoryHint: .isDirectory) .path(percentEncoded: false) ) #expect( removedWorktree.value?.path != changedBaseDirectory .appending(path: "new-branch", directoryHint: .isDirectory) .path(percentEncoded: false) ) }
@Test func pendingProgressUpdateUpdatesPendingWorktreeState() async { let repoRoot = "/tmp/repo" let repository = makeRepository( id: repoRoot, worktrees: [makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot)] ) let pendingID = "pending:test" var state = makeState(repositories: [repository]) state.selection = .worktree(pendingID) state.pendingWorktrees = [ PendingWorktree( id: pendingID, repositoryID: repository.id, progress: WorktreeCreationProgress(stage: .loadingLocalBranches) ) ] let store = TestStore(initialState: state) { RepositoriesFeature() }
let nextProgress = WorktreeCreationProgress( stage: .creatingWorktree, worktreeName: "swift-otter", baseRef: "origin/main", copyIgnored: false, copyUntracked: true ) await store.send( .worktreeCreation( .pendingWorktreeProgressUpdated( id: pendingID, progress: nextProgress )) ) { $0.pendingWorktrees[0].progress = nextProgress } }
@Test func pendingProgressUpdateIsIgnoredAfterCreateFailureRemovesPendingWorktree() async { let repoRoot = "/tmp/repo" let repository = makeRepository( id: repoRoot, worktrees: [makeWorktree(id: repoRoot, name: "main")]) let pendingID = "pending:test" var state = makeState(repositories: [repository]) state.selection = .worktree(pendingID) state.pendingWorktrees = [ PendingWorktree( id: pendingID, repositoryID: repository.id, progress: WorktreeCreationProgress( stage: .checkingRepositoryMode, worktreeName: "swift-otter" ) ) ] let store = TestStore(initialState: state) { RepositoriesFeature() }
let expectedAlert = AlertState<RepositoriesFeature.Alert> { TextState("Unable to create worktree") } actions: { ButtonState(role: .cancel) { TextState("OK") } } message: { TextState("boom") }
await store.send( .worktreeCreation( .createRandomWorktreeFailed( title: "Unable to create worktree", message: "boom", pendingID: pendingID, previousSelection: nil, repositoryID: repository.id, name: nil, baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees") )) ) { $0.pendingWorktrees = [] $0.selection = nil $0.alert = expectedAlert }
await store.send( .worktreeCreation( .pendingWorktreeProgressUpdated( id: pendingID, progress: WorktreeCreationProgress(stage: .creatingWorktree) )) ) #expect(store.state.pendingWorktrees.isEmpty) }
@Test func requestDeleteWorktreeShowsConfirmation() async { let worktree = makeWorktree(id: "/tmp/wt", name: "owl") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() }
await store.send(.worktreeLifecycle(.requestDeleteWorktree(worktree.id, repository.id))) { $0.deleteWorktreeConfirmation = DeleteWorktreeConfirmation( id: 0, title: "Delete worktree?", message: "Delete \(worktree.name)? The worktree directory will be removed.", targets: [ RepositoriesFeature.DeleteWorktreeTarget( worktreeID: worktree.id, repositoryID: repository.id) ], deleteBranch: false ) $0.nextDeleteWorktreeConfirmationID = 1 } }
@Test(.dependencies) func requestDeleteWorktreePreselectsRememberedBranchChoice() async { let worktree = makeWorktree(id: "/tmp/wt", name: "owl") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) let state = makeState(repositories: [repository]) state.$deleteBranchOnManualWorktreeDelete.withLock { $0 = true } let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.worktreeLifecycle(.requestDeleteWorktree(worktree.id, repository.id))) { $0.deleteWorktreeConfirmation = DeleteWorktreeConfirmation( id: 0, title: "Delete worktree?", message: "Delete \(worktree.name)? The worktree directory will be removed.", targets: [ RepositoriesFeature.DeleteWorktreeTarget( worktreeID: worktree.id, repositoryID: repository.id) ], deleteBranch: true ) $0.nextDeleteWorktreeConfirmationID = 1 } }
@Test(.dependencies) func deletePromptConfirmedRemembersBranchChoice() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let worktree = makeWorktree(id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, worktree]) var state = makeState(repositories: [repository]) state.deleteWorktreeConfirmation = DeleteWorktreeConfirmation( id: 0, title: "Delete worktree?", message: "Delete feature? The worktree directory will be removed.", targets: [ RepositoriesFeature.DeleteWorktreeTarget( worktreeID: worktree.id, repositoryID: repository.id) ], deleteBranch: true ) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.gitClient.removeWorktree = { worktree, _ in worktree.workingDirectory } $0.gitClient.deleteLocalBranch = { _, _, _ in .deleted } $0.gitClient.worktrees = { _ in [mainWorktree] } } store.exhaustivity = .off
await store.send(.worktreeLifecycle(.deleteWorktreePromptConfirmed)) { $0.deleteWorktreeConfirmation = nil $0.$deleteBranchOnManualWorktreeDelete.withLock { $0 = true } } await store.receive(\.worktreeLifecycle.worktreeDeleted) }
@Test(.dependencies) func deletePromptConfirmedRemembersUncheckedBranchChoice() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let worktree = makeWorktree(id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, worktree]) var state = makeState(repositories: [repository]) state.deleteWorktreeConfirmation = DeleteWorktreeConfirmation( id: 0, title: "Delete worktree?", message: "Delete feature? The worktree directory will be removed.", targets: [ RepositoriesFeature.DeleteWorktreeTarget( worktreeID: worktree.id, repositoryID: repository.id) ], deleteBranch: false ) state.$deleteBranchOnManualWorktreeDelete.withLock { $0 = true } let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.gitClient.removeWorktree = { worktree, _ in worktree.workingDirectory } $0.gitClient.worktrees = { _ in [mainWorktree] } } store.exhaustivity = .off
await store.send(.worktreeLifecycle(.deleteWorktreePromptConfirmed)) { $0.deleteWorktreeConfirmation = nil $0.$deleteBranchOnManualWorktreeDelete.withLock { $0 = false } } await store.receive(\.worktreeLifecycle.worktreeDeleted) }
@Test(.dependencies) func dismissingDeletePromptDoesNotRememberChangedChoice() async { let worktree = makeWorktree(id: "/tmp/wt", name: "owl") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() }
await store.send(.worktreeLifecycle(.requestDeleteWorktree(worktree.id, repository.id))) { $0.deleteWorktreeConfirmation = DeleteWorktreeConfirmation( id: 0, title: "Delete worktree?", message: "Delete \(worktree.name)? The worktree directory will be removed.", targets: [ RepositoriesFeature.DeleteWorktreeTarget( worktreeID: worktree.id, repositoryID: repository.id) ], deleteBranch: false ) $0.nextDeleteWorktreeConfirmationID = 1 } await store.send(.worktreeLifecycle(.deleteWorktreePromptDeleteBranchChanged(true))) { $0.deleteWorktreeConfirmation?.deleteBranch = true } await store.send(.worktreeLifecycle(.deleteWorktreePromptDismissed)) { $0.deleteWorktreeConfirmation = nil } await store.send(.worktreeLifecycle(.requestDeleteWorktree(worktree.id, repository.id))) { $0.deleteWorktreeConfirmation = DeleteWorktreeConfirmation( id: 1, title: "Delete worktree?", message: "Delete \(worktree.name)? The worktree directory will be removed.", targets: [ RepositoriesFeature.DeleteWorktreeTarget( worktreeID: worktree.id, repositoryID: repository.id) ], deleteBranch: false ) $0.nextDeleteWorktreeConfirmationID = 2 } }
@Test(.dependencies) func deletePromptConfirmedAsksBeforeForceDeletingBranch() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let worktree = makeWorktree(id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, worktree]) var state = makeState(repositories: [repository]) state.deleteWorktreeConfirmation = DeleteWorktreeConfirmation( id: 0, title: "Delete worktree?", message: "Delete feature? The worktree directory will be removed.", targets: [ RepositoriesFeature.DeleteWorktreeTarget( worktreeID: worktree.id, repositoryID: repository.id) ], deleteBranch: true ) let forceDeleteAttempts = LockIsolated<[Bool]>([]) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.gitClient.removeWorktree = { worktree, deleteBranch in #expect(deleteBranch == false) return worktree.workingDirectory } $0.gitClient.deleteLocalBranch = { _, _, force in forceDeleteAttempts.withValue { $0.append(force) } if force { return .deleted } throw GitClientError.commandFailed( command: "git branch -d feature", message: "not fully merged") } $0.gitClient.worktrees = { _ in [mainWorktree] } } store.exhaustivity = .off
await store.send(.worktreeLifecycle(.deleteWorktreePromptConfirmed)) { $0.deleteWorktreeConfirmation = nil } await store.receive(\.worktreeLifecycle.deleteWorktreeConfirmed) { $0.deletingWorktreeIDs = [worktree.id] } await store.receive(\.worktreeLifecycle.worktreeDeleted) { $0.deletingWorktreeIDs = [] $0.repositories = [makeRepository(id: repoRoot, worktrees: [mainWorktree])] #expect($0.alert != nil) } await store.send( .alert( .presented( .confirmForceDeleteBranch( ForceDeleteBranchRequest( branchName: "feature", repositoryRootURL: URL(fileURLWithPath: repoRoot), errorMessage: "Git command failed: git branch -d feature\nnot fully merged" )))))
#expect(forceDeleteAttempts.value == [false, true]) }
@Test func deleteWorktreeFailureKeepsWorktreeAndShowsGitError() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let worktree = makeWorktree(id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, worktree]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.gitClient.removeWorktree = { _, _ in throw GitClientError.commandFailed( command: "git worktree remove --force \(worktree.id)", message: "fatal: cannot remove a locked working tree" ) } }
await store.send( .worktreeLifecycle( .deleteWorktreeConfirmed(worktree.id, repository.id, deleteBranch: false) ) ) { $0.deletingWorktreeIDs = [worktree.id] } await store.receive(\.worktreeLifecycle.deleteWorktreeFailed) { $0.deletingWorktreeIDs = [] $0.alert = AlertState { TextState("Unable to delete worktree") } actions: { ButtonState(role: .cancel) { TextState("OK") } } message: { TextState( "Git command failed: git worktree remove --force \(worktree.id)\n" + "fatal: cannot remove a locked working tree" ) } } await store.finish()
#expect(store.state.repositories[id: repository.id]?.worktrees[id: worktree.id] != nil) }
@Test(.dependencies) func worktreeDeletedPresentsQueuedForceDeleteBranchPromptsInOrder() async { let repoRoot = "/tmp/repo" let firstRequest = ForceDeleteBranchRequest( branchName: "first", repositoryRootURL: URL(fileURLWithPath: repoRoot), errorMessage: "first failed" ) let secondRequest = ForceDeleteBranchRequest( branchName: "second", repositoryRootURL: URL(fileURLWithPath: repoRoot), errorMessage: "second failed" ) let store = TestStore(initialState: makeState(repositories: [])) { RepositoriesFeature() } withDependencies: { $0.gitClient.deleteLocalBranch = { _, _, _ in .deleted } } store.exhaustivity = .off
await store.send( .worktreeLifecycle( .worktreeDeleted( "/tmp/repo/first", repositoryID: repoRoot, selectionWasRemoved: false, nextSelection: nil, forceDeleteBranchRequest: firstRequest )) ) { $0.pendingForceDeleteBranchRequests = [firstRequest] #expect($0.alert != nil) } await store.send( .worktreeLifecycle( .worktreeDeleted( "/tmp/repo/second", repositoryID: repoRoot, selectionWasRemoved: false, nextSelection: nil, forceDeleteBranchRequest: secondRequest )) ) { $0.pendingForceDeleteBranchRequests = [firstRequest, secondRequest] #expect($0.alert != nil) } await store.send(.alert(.presented(.confirmForceDeleteBranch(firstRequest)))) await store.receive(\.worktreeLifecycle.forceDeleteBranchConfirmed) { $0.pendingForceDeleteBranchRequests = [secondRequest] #expect($0.alert != nil) } await store.send(.alert(.dismiss)) { $0.alert = nil $0.pendingForceDeleteBranchRequests = [] } }
@Test func requestDeleteMainWorktreeShowsNotAllowedAlert() async { let mainWorktree = makeWorktree(id: "/tmp/repo", name: "main") let repository = makeRepository(id: "/tmp/repo", worktrees: [mainWorktree])
let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() }
let expectedAlert = AlertState<RepositoriesFeature.Alert> { TextState("Delete not allowed") } actions: { ButtonState(role: .cancel) { TextState("OK") } } message: { TextState("Deleting the main worktree is not allowed.") }
await store.send(.worktreeLifecycle(.requestDeleteWorktree(mainWorktree.id, repository.id))) { $0.alert = expectedAlert } } @Test func requestDeleteWorktreesShowsBatchConfirmation() async { let worktree1 = makeWorktree(id: "/tmp/repo/wt1", name: "owl", repoRoot: "/tmp/repo") let worktree2 = makeWorktree(id: "/tmp/repo/wt2", name: "hawk", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree1, worktree2]) let targets = [ RepositoriesFeature.DeleteWorktreeTarget( worktreeID: worktree1.id, repositoryID: repository.id), RepositoriesFeature.DeleteWorktreeTarget( worktreeID: worktree2.id, repositoryID: repository.id), ] let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() }
await store.send(.worktreeLifecycle(.requestDeleteWorktrees(targets))) { $0.deleteWorktreeConfirmation = DeleteWorktreeConfirmation( id: 0, title: "Delete 2 worktrees?", message: "Delete 2 worktrees? Their worktree directories will be removed.", targets: targets, deleteBranch: false ) $0.nextDeleteWorktreeConfirmationID = 1 } }
@Test func requestArchiveWorktreeShowsConfirmation() async { let worktree = makeWorktree(id: "/tmp/wt", name: "owl") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() }
let archivedDisplay = AppShortcuts.archivedWorktrees.display let expectedAlert = AlertState<RepositoriesFeature.Alert> { TextState("Archive worktree?") } actions: { ButtonState(role: .destructive, action: .confirmArchiveWorktree(worktree.id, repository.id)) { TextState("Archive (⌘↩)") } ButtonState(role: .cancel) { TextState("Cancel") } } message: { TextState( "Find \(worktree.name) later in Menu Bar > Worktrees > Archived Worktrees (\(archivedDisplay))." ) }
await store.send(.worktreeLifecycle(.requestArchiveWorktree(worktree.id, repository.id))) { $0.alert = expectedAlert } }
@Test func requestArchiveWorktreesShowsBatchConfirmation() async { let worktree1 = makeWorktree(id: "/tmp/repo/wt1", name: "owl", repoRoot: "/tmp/repo") let worktree2 = makeWorktree(id: "/tmp/repo/wt2", name: "hawk", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree1, worktree2]) let targets = [ RepositoriesFeature.ArchiveWorktreeTarget( worktreeID: worktree1.id, repositoryID: repository.id), RepositoriesFeature.ArchiveWorktreeTarget( worktreeID: worktree2.id, repositoryID: repository.id), ] let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() }
let archivedDisplay = AppShortcuts.archivedWorktrees.display let expectedAlert = AlertState<RepositoriesFeature.Alert> { TextState("Archive 2 worktrees?") } actions: { ButtonState(role: .destructive, action: .confirmArchiveWorktrees(targets)) { TextState("Archive 2 (⌘↩)") } ButtonState(role: .cancel) { TextState("Cancel") } } message: { TextState( "Find them later in Menu Bar > Worktrees > Archived Worktrees (\(archivedDisplay))." ) }
await store.send(.worktreeLifecycle(.requestArchiveWorktrees(targets))) { $0.alert = expectedAlert } }
@Test func requestArchiveWorktreeMergedArchivesImmediately() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) var state = makeState(repositories: [repository]) state.selection = .worktree(featureWorktree.id) state.pinnedWorktreeIDs = [featureWorktree.id] state.worktreeOrderByRepository[repoRoot] = [featureWorktree.id] state.worktreeInfoByID = [ featureWorktree.id: WorktreeInfoEntry( addedLines: nil, removedLines: nil, pullRequest: makePullRequest(state: "MERGED") ) ] let fixedDate = Date(timeIntervalSince1970: 1_000_000) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.date = .constant(fixedDate) }
await store.send(.worktreeLifecycle(.requestArchiveWorktree(featureWorktree.id, repository.id))) await store.receive(\.worktreeLifecycle.archiveWorktreeConfirmed) await store.receive(\.worktreeLifecycle.archiveWorktreeApply) { $0.archivedWorktrees = [ArchivedWorktree(id: featureWorktree.id, archivedAt: fixedDate)] $0.pinnedWorktreeIDs = [] $0.worktreeOrderByRepository = [:] $0.selection = .worktree(mainWorktree.id) } await store.receive(\.delegate.repositoriesChanged) await store.receive(\.delegate.selectedWorktreeChanged) }
@Test(.dependencies) func archiveWorktreeConfirmedRunsArchiveScriptAndShowsProgress() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) var state = makeState(repositories: [repository]) state.selection = .worktree(mainWorktree.id) @Shared(.repositorySettings(repository.rootURL)) var repositorySettings $repositorySettings.withLock { $0.archiveScript = "echo syncing\necho done" } let fixedDate = Date(timeIntervalSince1970: 1_000_000) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.date = .constant(fixedDate) $0.shellClient.runLoginStreamImpl = { _, _, _, _ in AsyncThrowingStream { continuation in continuation.yield(.line(ShellStreamLine(source: .stdout, text: "syncing"))) continuation.yield(.line(ShellStreamLine(source: .stdout, text: "done"))) continuation.yield( .finished(ShellOutput(stdout: "syncing\ndone", stderr: "", exitCode: 0))) continuation.finish() } } }
await store.send( .worktreeLifecycle(.archiveWorktreeConfirmed(featureWorktree.id, repository.id)) ) { $0.archivingWorktreeIDs = [featureWorktree.id] $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( titleText: "Running archive script", detailText: "Preparing archive script", commandText: "bash -lc 'echo syncing\\necho done'" ) } await store.receive(\.worktreeLifecycle.archiveScriptProgressUpdated) { $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( titleText: "Running archive script", detailText: "syncing", commandText: "bash -lc 'echo syncing\\necho done'", outputLines: ["syncing"] ) } await store.receive(\.worktreeLifecycle.archiveScriptProgressUpdated) { $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( titleText: "Running archive script", detailText: "done", commandText: "bash -lc 'echo syncing\\necho done'", outputLines: ["syncing", "done"] ) } await store.receive(\.worktreeLifecycle.archiveScriptSucceeded) { $0.archivingWorktreeIDs = [] $0.archiveScriptProgressByWorktreeID = [:] } await store.receive(\.worktreeLifecycle.archiveWorktreeApply) { $0.archivedWorktrees = [ArchivedWorktree(id: featureWorktree.id, archivedAt: fixedDate)] } await store.receive(\.delegate.repositoriesChanged) }
@Test(.dependencies) func archiveWorktreeConfirmedScriptFailureBlocksArchive() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) @Shared(.repositorySettings(repository.rootURL)) var repositorySettings $repositorySettings.withLock { $0.archiveScript = "exit 7" } let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.shellClient.runLoginStreamImpl = { _, _, _, _ in AsyncThrowingStream { continuation in continuation.finish( throwing: ShellClientError( command: "bash -lc exit 7", stdout: "", stderr: "fail", exitCode: 7 ) ) } } }
let expectedAlert = AlertState<RepositoriesFeature.Alert> { TextState("Archive script failed") } actions: { ButtonState(role: .cancel) { TextState("OK") } } message: { TextState("Command failed: bash -lc exit 7\nstderr:\nfail") }
await store.send( .worktreeLifecycle(.archiveWorktreeConfirmed(featureWorktree.id, repository.id)) ) { $0.archivingWorktreeIDs = [featureWorktree.id] $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( titleText: "Running archive script", detailText: "Preparing archive script", commandText: "bash -lc 'exit 7'" ) } await store.receive(\.worktreeLifecycle.archiveScriptFailed) { $0.archivingWorktreeIDs = [] $0.archiveScriptProgressByWorktreeID = [:] $0.alert = expectedAlert } #expect(store.state.archivedWorktrees.isEmpty) }
@Test func archiveScriptSucceededIgnoredWhenNotArchiving() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() }
await store.send( .worktreeLifecycle( .archiveScriptSucceeded(worktreeID: featureWorktree.id, repositoryID: repository.id)) ) #expect(store.state.archivedWorktrees.isEmpty) }
@Test func archiveScriptFailedIgnoredWhenNotArchiving() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() }
await store.send( .worktreeLifecycle( .archiveScriptFailed(worktreeID: featureWorktree.id, message: "late failure"))) #expect(store.state.alert == nil) #expect(store.state.archivedWorktrees.isEmpty) }
@Test func repositoriesLoadedKeepsArchiveInFlightUntilSuccessCompletion() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) let reloadedRepository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) var state = makeState(repositories: [repository]) state.archivingWorktreeIDs = [featureWorktree.id] state.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( titleText: "Running archive script", detailText: "still running" ) let store = TestStore(initialState: state) { RepositoriesFeature() } store.exhaustivity = .off
await store.send( .repositoriesLoaded( [reloadedRepository], failures: [], roots: [repository.rootURL], animated: false ) ) #expect(store.state.archivingWorktreeIDs.contains(featureWorktree.id)) #expect(store.state.archiveScriptProgressByWorktreeID[featureWorktree.id] != nil)
await store.send( .worktreeLifecycle( .archiveScriptSucceeded(worktreeID: featureWorktree.id, repositoryID: repository.id)) ) #expect(store.state.archivingWorktreeIDs.isEmpty) #expect(store.state.archiveScriptProgressByWorktreeID.isEmpty) }
@Test func repositoriesLoadedKeepsArchiveInFlightUntilFailureCompletion() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) let reloadedRepository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) var state = makeState(repositories: [repository]) state.archivingWorktreeIDs = [featureWorktree.id] state.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( titleText: "Running archive script", detailText: "still running" ) let store = TestStore(initialState: state) { RepositoriesFeature() } store.exhaustivity = .off
await store.send( .repositoriesLoaded( [reloadedRepository], failures: [], roots: [repository.rootURL], animated: false ) ) #expect(store.state.archivingWorktreeIDs.contains(featureWorktree.id)) #expect(store.state.archiveScriptProgressByWorktreeID[featureWorktree.id] != nil)
await store.send( .worktreeLifecycle( .archiveScriptFailed(worktreeID: featureWorktree.id, message: "script failed"))) #expect(store.state.archivingWorktreeIDs.isEmpty) #expect(store.state.archiveScriptProgressByWorktreeID.isEmpty) #expect(store.state.alert != nil) }
@Test func repositoriesLoadedClearsRenamePromptWhenTargetDisappears() async { let worktree = makeWorktree(id: "/tmp/repo/wt", name: "eagle", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) let reloadedRepository = makeRepository(id: "/tmp/repo", worktrees: []) var state = makeState(repositories: [repository]) state.nextPendingRenameBranchRequestID = 1 state.pendingRenameBranchRequest = PendingRenameBranchRequest( id: 1, worktreeID: worktree.id, currentName: worktree.name ) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.repositoryPersistence.saveRepositorySnapshot = { _ in } } store.exhaustivity = .off
await store.send( .repositoriesLoaded( [reloadedRepository], failures: [], roots: [repository.rootURL], animated: false ) ) { $0.pendingRenameBranchRequest = nil } }
@Test func archivingTargetClearsRenamePrompt() async { let worktree = makeWorktree(id: "/tmp/repo/wt", name: "eagle", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var state = makeState(repositories: [repository]) state.nextPendingRenameBranchRequestID = 1 state.pendingRenameBranchRequest = PendingRenameBranchRequest( id: 1, worktreeID: worktree.id, currentName: worktree.name ) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.date.now = Date(timeIntervalSince1970: 1_760_000_000) $0.repositoryPersistence.saveArchivedWorktrees = { _ in } } store.exhaustivity = .off
await store.send(.worktreeLifecycle(.archiveWorktreeApply(worktree.id, repository.id))) { $0.pendingRenameBranchRequest = nil } }
@Test func requestRenameBranchWithEmptyNameShowsAlert() async { let worktree = makeWorktree(id: "/tmp/wt", name: "eagle") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() }
let expectedAlert = AlertState<RepositoriesFeature.Alert> { TextState("Branch name required") } actions: { ButtonState(role: .cancel) { TextState("OK") } } message: { TextState("Enter a branch name to rename.") }
await store.send(.requestRenameBranch(worktree.id, " ")) { $0.alert = expectedAlert } }
@Test func requestRenameBranchWithWhitespaceShowsAlert() async { let worktree = makeWorktree(id: "/tmp/wt", name: "eagle") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() }
let expectedAlert = AlertState<RepositoriesFeature.Alert> { TextState("Branch name invalid") } actions: { ButtonState(role: .cancel) { TextState("OK") } } message: { TextState("Branch names can't contain spaces.") }
await store.send(.requestRenameBranch(worktree.id, "feature branch")) { $0.alert = expectedAlert } }
@Test func worktreeNotificationReceivedDoesNotShowStatusToast() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) var state = makeState(repositories: [repository]) state.worktreeOrderByRepository[repoRoot] = [featureWorktree.id] let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureWorktree.id))) #expect(store.state.statusToast == nil) }
@Test func worktreeNotificationReceivedReordersUnpinnedWorktrees() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) let featureB = makeWorktree(id: "/tmp/repo/b", name: "b", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA, featureB]) var state = makeState(repositories: [repository]) state.worktreeOrderByRepository[repoRoot] = [featureA.id, featureB.id] let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureB.id))) { $0.worktreeOrderByRepository[repoRoot] = [featureB.id, featureA.id] } #expect(store.state.statusToast == nil) }
@Test func worktreeNotificationReceivedDoesNotReorderWhenMoveToTopDisabled() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) let featureB = makeWorktree(id: "/tmp/repo/b", name: "b", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA, featureB]) var state = makeState(repositories: [repository]) state.worktreeOrderByRepository[repoRoot] = [featureA.id, featureB.id] state.moveNotifiedWorktreeToTop = false let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureB.id))) #expect(store.state.worktreeOrderByRepository[repoRoot] == [featureA.id, featureB.id]) #expect(store.state.statusToast == nil) }
@Test func worktreeNotificationDuringSidebarDragDefersReorder() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) let featureB = makeWorktree(id: "/tmp/repo/b", name: "b", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA, featureB]) var state = makeState(repositories: [repository]) state.worktreeOrderByRepository[repoRoot] = [featureA.id, featureB.id] let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.worktreeOrdering(.setSidebarDragActive(true))) { $0.isSidebarDragActive = true } await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureB.id))) { $0.pendingSidebarNotifyReorderIDs = [featureB.id] } #expect(store.state.worktreeOrderByRepository[repoRoot] == [featureA.id, featureB.id]) }
@Test func endingSidebarDragAppliesPendingNotificationReordersInOrder() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) let featureB = makeWorktree(id: "/tmp/repo/b", name: "b", repoRoot: repoRoot) let featureC = makeWorktree(id: "/tmp/repo/c", name: "c", repoRoot: repoRoot) let repository = makeRepository( id: repoRoot, worktrees: [mainWorktree, featureA, featureB, featureC]) var state = makeState(repositories: [repository]) state.isSidebarDragActive = true state.pendingSidebarNotifyReorderIDs = [featureA.id, featureC.id, featureB.id] state.worktreeOrderByRepository[repoRoot] = [featureA.id, featureB.id, featureC.id] let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.worktreeOrdering(.setSidebarDragActive(false))) { $0.isSidebarDragActive = false $0.pendingSidebarNotifyReorderIDs = [] $0.worktreeOrderByRepository[repoRoot] = [featureB.id, featureC.id, featureA.id] } }
@Test func repeatedNotificationDuringSidebarDragKeepsLatestPosition() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) let featureB = makeWorktree(id: "/tmp/repo/b", name: "b", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA, featureB]) var state = makeState(repositories: [repository]) state.isSidebarDragActive = true state.worktreeOrderByRepository[repoRoot] = [featureA.id, featureB.id] let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureA.id))) { $0.pendingSidebarNotifyReorderIDs = [featureA.id] } await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureB.id))) { $0.pendingSidebarNotifyReorderIDs = [featureA.id, featureB.id] } await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureA.id))) { $0.pendingSidebarNotifyReorderIDs = [featureB.id, featureA.id] } await store.send(.worktreeOrdering(.setSidebarDragActive(false))) { $0.isSidebarDragActive = false $0.pendingSidebarNotifyReorderIDs = [] $0.worktreeOrderByRepository[repoRoot] = [featureA.id, featureB.id] } }
@Test func stalePendingNotificationReordersAreIgnoredWhenDragEnds() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA]) var state = makeState(repositories: [repository]) state.isSidebarDragActive = true state.pendingSidebarNotifyReorderIDs = ["/tmp/repo/stale", featureA.id] state.worktreeOrderByRepository[repoRoot] = [featureA.id] let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.worktreeOrdering(.setSidebarDragActive(false))) { $0.isSidebarDragActive = false $0.pendingSidebarNotifyReorderIDs = [] } }
@Test func notificationDuringSidebarDragDoesNotRecordWhenMoveToTopDisabled() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA]) var state = makeState(repositories: [repository]) state.isSidebarDragActive = true state.moveNotifiedWorktreeToTop = false state.worktreeOrderByRepository[repoRoot] = [featureA.id] let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureA.id))) #expect(store.state.pendingSidebarNotifyReorderIDs.isEmpty) #expect(store.state.worktreeOrderByRepository[repoRoot] == [featureA.id]) }
@Test func setMoveNotifiedWorktreeToTopUpdatesState() async { var state = makeState(repositories: []) state.moveNotifiedWorktreeToTop = true let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.worktreeOrdering(.setMoveNotifiedWorktreeToTop(false))) { $0.moveNotifiedWorktreeToTop = false } }
@Test func worktreeBranchNameLoadedPreservesCreatedAt() async { let createdAt = Date(timeIntervalSince1970: 1_737_303_600) let worktree = makeWorktree(id: "/tmp/wt", name: "eagle", createdAt: createdAt) let renamedWorktree = makeWorktree(id: "/tmp/wt", name: "falcon", createdAt: createdAt) let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() }
await store.send(.worktreeBranchNameLoaded(worktreeID: worktree.id, name: "falcon")) { var repository = $0.repositories[id: repository.id]! var worktrees = repository.worktrees worktrees[id: worktree.id] = renamedWorktree repository = Repository( id: repository.id, rootURL: repository.rootURL, name: repository.name, worktrees: worktrees ) $0.repositories[id: repository.id] = repository } #expect( store.state.repositories[id: repository.id]?.worktrees[id: worktree.id]?.name == "falcon") #expect( store.state.repositories[id: repository.id]?.worktrees[id: worktree.id]?.createdAt == createdAt) }
@Test func orderedWorktreeRowsAreGlobal() { let repoA = makeRepository( id: "/tmp/repo-a", worktrees: [ makeWorktree(id: "/tmp/repo-a/wt1", name: "wt1", repoRoot: "/tmp/repo-a"), makeWorktree(id: "/tmp/repo-a/wt2", name: "wt2", repoRoot: "/tmp/repo-a"), ] ) let repoB = makeRepository( id: "/tmp/repo-b", worktrees: [ makeWorktree(id: "/tmp/repo-b/wt3", name: "wt3", repoRoot: "/tmp/repo-b") ] ) let state = makeState(repositories: [repoA, repoB])
expectNoDifference( state.orderedWorktreeRows().map(\.id), [ "/tmp/repo-a/wt1", "/tmp/repo-a/wt2", "/tmp/repo-b/wt3", ] ) }
@Test func orderedWorktreeRowsRespectRepositoryOrderIDs() { let repoA = makeRepository( id: "/tmp/repo-a", worktrees: [ makeWorktree(id: "/tmp/repo-a/wt1", name: "wt1", repoRoot: "/tmp/repo-a") ] ) let repoB = makeRepository( id: "/tmp/repo-b", worktrees: [ makeWorktree(id: "/tmp/repo-b/wt2", name: "wt2", repoRoot: "/tmp/repo-b") ] ) var state = makeState(repositories: [repoA, repoB]) state.repositoryOrderIDs = [repoB.id, repoA.id]
expectNoDifference( state.orderedWorktreeRows().map(\.id), [ "/tmp/repo-b/wt2", "/tmp/repo-a/wt1", ] ) }
@Test func orderedWorktreeRowsCanFilterCollapsedRepositoriesForHotkeys() { let repoA = makeRepository( id: "/tmp/repo-a", worktrees: [ makeWorktree(id: "/tmp/repo-a/wt1", name: "wt1", repoRoot: "/tmp/repo-a") ] ) let repoB = makeRepository( id: "/tmp/repo-b", worktrees: [ makeWorktree(id: "/tmp/repo-b/wt2", name: "wt2", repoRoot: "/tmp/repo-b") ] ) var state = makeState(repositories: [repoA, repoB]) state.repositoryOrderIDs = [repoA.id, repoB.id]
expectNoDifference( state.orderedWorktreeRows(includingRepositoryIDs: [repoB.id]).map(\.id), [ "/tmp/repo-b/wt2" ] ) }
@Test func orderedRepositoryRootsAppendMissing() { let repoA = makeRepository(id: "/tmp/repo-a", worktrees: []) let repoB = makeRepository(id: "/tmp/repo-b", worktrees: []) var state = makeState(repositories: [repoA, repoB]) state.repositoryOrderIDs = [repoB.id]
expectNoDifference( state.orderedRepositoryRoots().map { $0.path(percentEncoded: false) }, [ repoB.id, repoA.id, ] ) }
@Test func orderedUnpinnedWorktreesPutMissingFirst() { let repoRoot = "/tmp/repo" let worktree1 = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: repoRoot) let worktree2 = makeWorktree(id: "/tmp/repo/wt2", name: "wt2", repoRoot: repoRoot) let worktree3 = makeWorktree(id: "/tmp/repo/wt3", name: "wt3", repoRoot: repoRoot) let repository = makeRepository( id: repoRoot, worktrees: [worktree1, worktree2, worktree3] ) var state = makeState(repositories: [repository]) state.worktreeOrderByRepository[repoRoot] = [worktree2.id]
expectNoDifference( state.orderedUnpinnedWorktreeIDs(in: repository), [ worktree1.id, worktree3.id, worktree2.id, ] ) }
@Test func unpinnedWorktreeMoveUpdatesOrder() async { let repoRoot = "/tmp/repo" let worktree1 = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: repoRoot) let worktree2 = makeWorktree(id: "/tmp/repo/wt2", name: "wt2", repoRoot: repoRoot) let worktree3 = makeWorktree(id: "/tmp/repo/wt3", name: "wt3", repoRoot: repoRoot) let repository = makeRepository( id: repoRoot, worktrees: [worktree1, worktree2, worktree3] ) var state = makeState(repositories: [repository]) state.worktreeOrderByRepository[repoRoot] = [worktree1.id, worktree2.id, worktree3.id] let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send( .worktreeOrdering(.unpinnedWorktreesMoved(repositoryID: repoRoot, IndexSet(integer: 0), 3)) ) { $0.worktreeOrderByRepository[repoRoot] = [worktree2.id, worktree3.id, worktree1.id] } }
@Test func pinnedWorktreeMoveUpdatesSubsetOrder() async { let repoA = "/tmp/repo-a" let repoB = "/tmp/repo-b" let worktreeA1 = makeWorktree(id: "/tmp/repo-a/wt1", name: "wt1", repoRoot: repoA) let worktreeA2 = makeWorktree(id: "/tmp/repo-a/wt2", name: "wt2", repoRoot: repoA) let worktreeB1 = makeWorktree(id: "/tmp/repo-b/wt1", name: "wt1", repoRoot: repoB) let repositoryA = makeRepository(id: repoA, worktrees: [worktreeA1, worktreeA2]) let repositoryB = makeRepository(id: repoB, worktrees: [worktreeB1]) var state = makeState(repositories: [repositoryA, repositoryB]) state.pinnedWorktreeIDs = [worktreeA1.id, worktreeB1.id, worktreeA2.id] let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send( .worktreeOrdering(.pinnedWorktreesMoved(repositoryID: repoA, IndexSet(integer: 1), 0)) ) { $0.pinnedWorktreeIDs = [worktreeA2.id, worktreeB1.id, worktreeA1.id] } }
@Test func loadRepositoriesFailureKeepsPreviousState() async { let repository = makeRepository(id: "/tmp/repo", worktrees: []) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() }
await store.send( .repositoriesLoaded( [], failures: [RepositoriesFeature.LoadFailure(rootID: repository.id, message: "boom")], roots: [repository.rootURL], animated: false ) ) { $0.loadFailuresByID = [repository.id: "boom"] $0.repositories = [] $0.isInitialLoadComplete = true }
await store.receive(\.delegate.repositoriesChanged) }
@Test func worktreeOrderPreservedWhenRepositoryLoadFails() async { let repoRoot = "/tmp/repo" let worktree1 = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: repoRoot) let worktree2 = makeWorktree(id: "/tmp/repo/wt2", name: "wt2", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [worktree1, worktree2]) var initialState = makeState(repositories: [repository]) initialState.worktreeOrderByRepository = [ repoRoot: [worktree1.id, worktree2.id] ] let store = TestStore(initialState: initialState) { RepositoriesFeature() }
await store.send( .repositoriesLoaded( [], failures: [RepositoriesFeature.LoadFailure(rootID: repository.id, message: "boom")], roots: [repository.rootURL], animated: false ) ) { $0.loadFailuresByID = [repository.id: "boom"] $0.repositories = [] $0.isInitialLoadComplete = true }
await store.receive(\.delegate.repositoriesChanged) expectNoDifference( store.state.worktreeOrderByRepository, [repoRoot: [worktree1.id, worktree2.id]] ) }
@Test func archivedWorktreesPreservedWhenRepositoryLoadFails() async { let repoRoot = "/tmp/repo" let worktree = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [worktree]) let fixedDate = Date(timeIntervalSince1970: 1_000_000) var initialState = makeState(repositories: [repository]) initialState.archivedWorktrees = [ArchivedWorktree(id: worktree.id, archivedAt: fixedDate)] let store = TestStore(initialState: initialState) { RepositoriesFeature() }
await store.send( .repositoriesLoaded( [], failures: [RepositoriesFeature.LoadFailure(rootID: repository.id, message: "boom")], roots: [repository.rootURL], animated: false ) ) { $0.loadFailuresByID = [repository.id: "boom"] $0.repositories = [] $0.isInitialLoadComplete = true }
await store.receive(\.delegate.repositoriesChanged) #expect( store.state.archivedWorktrees == [ArchivedWorktree(id: worktree.id, archivedAt: fixedDate)]) }
@Test func repositoriesLoadedSkipsSelectionChangeWhenOnlyDisplayDataChanges() async { let repoRoot = "/tmp/repo" let worktree = makeWorktree(id: "/tmp/repo/main", name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [worktree]) let updatedWorktree = makeWorktree( id: "/tmp/repo/main", name: "main-updated", repoRoot: repoRoot) let updatedRepository = makeRepository(id: repoRoot, worktrees: [updatedWorktree]) var initialState = makeState(repositories: [repository]) initialState.selection = .worktree(worktree.id) let store = TestStore(initialState: initialState) { RepositoriesFeature() }
await store.send( .repositoriesLoaded( [updatedRepository], failures: [], roots: [repository.rootURL], animated: false ) ) { $0.repositories = [updatedRepository] $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } await store.receive(\.delegate.repositoriesChanged) await store.finish() }
@Test func repositoriesLoadedUpdatesSelectedWorktreeDelegateOnSelectionChange() async { let repoRoot = "/tmp/repo" let selectedWorktree = makeWorktree(id: "/tmp/repo/main", name: "main", repoRoot: repoRoot) let remainingWorktree = makeWorktree(id: "/tmp/repo/next", name: "next", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [selectedWorktree, remainingWorktree]) let updatedRepository = makeRepository(id: repoRoot, worktrees: [remainingWorktree]) var initialState = makeState(repositories: [repository]) initialState.selection = .worktree(selectedWorktree.id) let store = TestStore(initialState: initialState) { RepositoriesFeature() }
await store.send( .repositoriesLoaded( [updatedRepository], failures: [], roots: [repository.rootURL], animated: false ) ) { $0.repositories = [updatedRepository] $0.selection = nil $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } await store.receive(\.delegate.repositoriesChanged) await store.receive(\.delegate.selectedWorktreeChanged) await store.finish() }
@Test func worktreeDeletedPrunesStateAndSendsDelegates() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: "/tmp/repo/main", name: "main", repoRoot: repoRoot) let removedWorktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, removedWorktree]) let updatedRepository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) var initialState = makeState(repositories: [repository]) initialState.selection = .worktree(mainWorktree.id) initialState.deletingWorktreeIDs = [removedWorktree.id] initialState.pendingSetupScriptWorktreeIDs = [removedWorktree.id] initialState.pendingTerminalFocusWorktreeIDs = [removedWorktree.id] initialState.pendingWorktrees = [ PendingWorktree( id: removedWorktree.id, repositoryID: repository.id, progress: WorktreeCreationProgress(stage: .choosingWorktreeName) ) ] initialState.pinnedWorktreeIDs = [removedWorktree.id] initialState.worktreeInfoByID = [ removedWorktree.id: WorktreeInfoEntry(addedLines: 1, removedLines: 2, pullRequest: nil) ] let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.gitClient.worktrees = { _ in [mainWorktree] } }
await store.send( .worktreeLifecycle( .worktreeDeleted( removedWorktree.id, repositoryID: repository.id, selectionWasRemoved: false, nextSelection: nil, forceDeleteBranchRequest: nil )) ) { $0.deletingWorktreeIDs = [] $0.pendingSetupScriptWorktreeIDs = [] $0.pendingTerminalFocusWorktreeIDs = [] $0.pendingWorktrees = [] $0.pinnedWorktreeIDs = [] $0.worktreeInfoByID = [:] $0.repositories = [updatedRepository] } await store.receive(\.delegate.repositoriesChanged) await store.receive(\.reloadRepositories) await store.receive(\.repositoriesLoaded) { $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } }
@Test func worktreeDeletedResetsSelectionWhenDriftedToDeletingWorktree() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: "/tmp/repo/main", name: "main", repoRoot: repoRoot) let removedWorktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, removedWorktree]) let updatedRepository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) var initialState = makeState(repositories: [repository]) initialState.selection = .worktree(removedWorktree.id) initialState.deletingWorktreeIDs = [removedWorktree.id] let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.gitClient.worktrees = { _ in [mainWorktree] } }
await store.send( .worktreeLifecycle( .worktreeDeleted( removedWorktree.id, repositoryID: repository.id, selectionWasRemoved: false, nextSelection: nil, forceDeleteBranchRequest: nil )) ) { $0.deletingWorktreeIDs = [] $0.repositories = [updatedRepository] $0.selection = .worktree(mainWorktree.id) } await store.receive(\.delegate.repositoriesChanged) await store.receive(\.delegate.selectedWorktreeChanged) await store.receive(\.reloadRepositories) await store.receive(\.repositoriesLoaded) { $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } }
@Test func createRandomWorktreeSucceededSendsRepositoriesChanged() async { let repoRoot = "/tmp/repo" let existingWorktree = makeWorktree(id: "/tmp/repo/wt-main", name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [existingWorktree]) let newWorktree = makeWorktree(id: "/tmp/repo/wt-new", name: "new", repoRoot: repoRoot) let updatedRepository = makeRepository( id: repoRoot, worktrees: [newWorktree, existingWorktree]) let pendingID = "pending:\(UUID().uuidString)" var initialState = makeState(repositories: [repository]) initialState.pendingWorktrees = [ PendingWorktree( id: pendingID, repositoryID: repository.id, progress: WorktreeCreationProgress(stage: .loadingLocalBranches) ) ] initialState.selection = .worktree(pendingID) initialState.sidebarSelectedWorktreeIDs = [existingWorktree.id, pendingID] let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.gitClient.worktrees = { _ in [newWorktree, existingWorktree] } }
await store.send( .worktreeCreation( .createRandomWorktreeSucceeded( newWorktree, repositoryID: repository.id, pendingID: pendingID )) ) { $0.pendingSetupScriptWorktreeIDs.insert(newWorktree.id) $0.pendingTerminalFocusWorktreeIDs.insert(newWorktree.id) $0.pendingWorktrees = [] $0.selection = .worktree(newWorktree.id) $0.sidebarSelectedWorktreeIDs = [newWorktree.id] $0.$prowlCreatedWorktreeIDs.withLock { $0.append(newWorktree.id) } $0.repositories = [updatedRepository] }
await store.receive(\.reloadRepositories) await store.receive(\.delegate.repositoriesChanged) await store.receive(\.delegate.selectedWorktreeChanged) await store.receive(\.delegate.worktreeCreated) await store.receive(\.repositoriesLoaded) { $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } }
@Test func repositoryPullRequestsLoadedAutoArchivesWhenEnabled() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) var state = makeState(repositories: [repository]) state.mergedWorktreeAction = .archive let fixedDate = Date(timeIntervalSince1970: 1_000_000) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.date = .constant(fixedDate) } let mergedPullRequest = makePullRequest(state: "MERGED", headRefName: featureWorktree.name)
await store.send( .githubIntegration( .repositoryPullRequestsLoaded( repositoryID: repository.id, pullRequestsByWorktreeID: [featureWorktree.id: mergedPullRequest] )) ) { $0.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( addedLines: nil, removedLines: nil, pullRequest: mergedPullRequest ) } await store.receive(\.worktreeLifecycle.archiveWorktreeConfirmed) await store.receive(\.worktreeLifecycle.archiveWorktreeApply) { $0.archivedWorktrees = [ArchivedWorktree(id: featureWorktree.id, archivedAt: fixedDate)] } await store.receive(\.delegate.repositoriesChanged) }
@Test func repositoryPullRequestsLoadedSkipsAutoArchiveForMainWorktree() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) var state = makeState(repositories: [repository]) state.mergedWorktreeAction = .archive let store = TestStore(initialState: state) { RepositoriesFeature() } let mergedPullRequest = makePullRequest(state: "MERGED", headRefName: mainWorktree.name)
await store.send( .githubIntegration( .repositoryPullRequestsLoaded( repositoryID: repository.id, pullRequestsByWorktreeID: [mainWorktree.id: mergedPullRequest] )) ) await store.finish() }
@Test func repositoryPullRequestsLoadedAutoDeletesWhenEnabled() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) var state = makeState(repositories: [repository]) state.mergedWorktreeAction = .delete let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.gitClient.removeWorktree = { worktree, _ in worktree.workingDirectory } } store.exhaustivity = .off let mergedPullRequest = makePullRequest(state: "MERGED", headRefName: featureWorktree.name)
await store.send( .githubIntegration( .repositoryPullRequestsLoaded( repositoryID: repository.id, pullRequestsByWorktreeID: [featureWorktree.id: mergedPullRequest] )) ) { $0.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( addedLines: nil, removedLines: nil, pullRequest: mergedPullRequest ) } await store.receive(\.worktreeLifecycle.deleteWorktreeConfirmed) { $0.deletingWorktreeIDs = [featureWorktree.id] } }
@Test(.dependencies) func repositoryPullRequestsLoadedAutoDeleteOnlyDeletesProwlCreatedBranches() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let externalWorktree = makeWorktree( id: "\(repoRoot)/external", name: "external", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, externalWorktree]) var state = makeState(repositories: [repository]) state.mergedWorktreeAction = .delete @Shared(.settingsFile) var settingsFile $settingsFile.withLock { $0.global.deleteBranchOnAutomaticCleanup = true } let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.gitClient.removeWorktree = { worktree, deleteBranch in #expect(worktree.id == externalWorktree.id) #expect(deleteBranch == false) return worktree.workingDirectory } } store.exhaustivity = .off let mergedPullRequest = makePullRequest(state: "MERGED", headRefName: externalWorktree.name)
await store.send( .githubIntegration( .repositoryPullRequestsLoaded( repositoryID: repository.id, pullRequestsByWorktreeID: [externalWorktree.id: mergedPullRequest] )) ) await store.receive(\.worktreeLifecycle.deleteWorktreeConfirmed) { $0.deletingWorktreeIDs = [externalWorktree.id] } await store.receive(\.worktreeLifecycle.worktreeDeleted) }
@Test(.dependencies) func repositoryPullRequestsLoadedAutoDeleteDeletesProwlCreatedBranch() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let prowlWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, prowlWorktree]) var state = makeState(repositories: [repository]) state.mergedWorktreeAction = .delete state.$prowlCreatedWorktreeIDs.withLock { $0 = [prowlWorktree.id] } @Shared(.settingsFile) var settingsFile $settingsFile.withLock { $0.global.deleteBranchOnAutomaticCleanup = true } let deletedBranches = LockIsolated<[String]>([]) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.gitClient.removeWorktree = { worktree, _ in worktree.workingDirectory } $0.gitClient.deleteLocalBranch = { branchName, _, force in #expect(force == false) deletedBranches.withValue { $0.append(branchName) } return .deleted } } store.exhaustivity = .off let mergedPullRequest = makePullRequest(state: "MERGED", headRefName: prowlWorktree.name)
await store.send( .githubIntegration( .repositoryPullRequestsLoaded( repositoryID: repository.id, pullRequestsByWorktreeID: [prowlWorktree.id: mergedPullRequest] )) ) await store.receive(\.worktreeLifecycle.deleteWorktreeConfirmed) { $0.deletingWorktreeIDs = [prowlWorktree.id] } await store.receive(\.worktreeLifecycle.worktreeDeleted)
#expect(deletedBranches.value == [prowlWorktree.name]) }
@Test func repositoryPullRequestsLoadedSkipsAutoDeleteForMainWorktree() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) var state = makeState(repositories: [repository]) state.mergedWorktreeAction = .delete let store = TestStore(initialState: state) { RepositoriesFeature() } let mergedPullRequest = makePullRequest(state: "MERGED", headRefName: mainWorktree.name)
await store.send( .githubIntegration( .repositoryPullRequestsLoaded( repositoryID: repository.id, pullRequestsByWorktreeID: [mainWorktree.id: mergedPullRequest] )) ) await store.finish() }
@Test func pullRequestActionMergeRefreshesImmediatelyWithoutSyntheticMergedState() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) let openPullRequest = makePullRequest( state: "OPEN", headRefName: featureWorktree.name, number: 12) var state = makeState(repositories: [repository]) state.githubIntegrationAvailability = .disabled state.mergedWorktreeAction = .archive state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( addedLines: nil, removedLines: nil, pullRequest: openPullRequest ) let upstreamRemoteInfo = GithubRemoteInfo( host: "github.com", owner: "supabitapp", repo: "supacode") let mergedNumbers = LockIsolated<[Int]>([]) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.githubIntegration.isAvailable = { true } $0.githubCLI.resolveRemoteInfo = { _ in upstreamRemoteInfo } $0.githubCLI.mergePullRequest = { _, _, number, _, _ in mergedNumbers.withValue { $0.append(number) } } } store.exhaustivity = .off
await store.send(.githubIntegration(.pullRequestAction(featureWorktree.id, .merge))) await store.receive(\.showToast) { $0.statusToast = .inProgress("Merging pull request…") } await store.receive(\.showToast) { $0.statusToast = .success("Pull request merged") } await store.receive(\.worktreeInfoEvent) #expect(store.state.worktreeInfoByID[featureWorktree.id]?.pullRequest?.state == "OPEN") #expect(store.state.archivedWorktrees.isEmpty) #expect(mergedNumbers.value == [12]) await store.finish() }
@Test func pullRequestActionMergeUsesGlobalStrategyWhenRepositoryOverrideMissing() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) let openPullRequest = makePullRequest( state: "OPEN", headRefName: featureWorktree.name, number: 12) var state = makeState(repositories: [repository]) state.githubIntegrationAvailability = .disabled state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( addedLines: nil, removedLines: nil, pullRequest: openPullRequest ) let upstreamRemoteInfo = GithubRemoteInfo( host: "github.com", owner: "supabitapp", repo: "supacode") let mergedStrategies = LockIsolated<[PullRequestMergeStrategy]>([]) @Shared(.settingsFile) var settingsFile $settingsFile.withLock { $0.global.pullRequestMergeStrategy = .squash } @Shared(.repositorySettings(repository.rootURL)) var repositorySettings $repositorySettings.withLock { $0.pullRequestMergeStrategy = nil } let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.githubIntegration.isAvailable = { true } $0.githubCLI.resolveRemoteInfo = { _ in upstreamRemoteInfo } $0.githubCLI.mergePullRequest = { _, _, _, strategy, _ in mergedStrategies.withValue { $0.append(strategy) } } } store.exhaustivity = .off
await store.send(.githubIntegration(.pullRequestAction(featureWorktree.id, .merge))) await store.receive(\.showToast) { $0.statusToast = .inProgress("Merging pull request…") } await store.receive(\.showToast) { $0.statusToast = .success("Pull request merged") } await store.receive(\.worktreeInfoEvent) #expect(mergedStrategies.value == [.squash]) await store.finish() }
@Test func pullRequestActionMergeUsesResolvedRemoteInfo() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) let openPullRequest = makePullRequest( state: "OPEN", headRefName: featureWorktree.name, number: 12) let upstreamRemoteInfo = GithubRemoteInfo( host: "github.com", owner: "supabitapp", repo: "supacode") var state = makeState(repositories: [repository]) state.githubIntegrationAvailability = .disabled state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( addedLines: nil, removedLines: nil, pullRequest: openPullRequest ) let mutationRemoteInfos = LockIsolated<[GithubRemoteInfo?]>([]) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.githubIntegration.isAvailable = { true } $0.gitClient.remoteInfo = { root in #expect(root == URL(fileURLWithPath: repoRoot)) return upstreamRemoteInfo } $0.githubCLI.resolveRemoteInfo = { _ in Issue.record("gh resolveRemoteInfo should not run when git remote resolves") return nil } $0.githubCLI.mergePullRequest = { root, remoteInfo, number, _, _ in #expect(root == featureWorktree.workingDirectory) #expect(number == 12) mutationRemoteInfos.withValue { $0.append(remoteInfo) } } } store.exhaustivity = .off
await store.send(.githubIntegration(.pullRequestAction(featureWorktree.id, .merge))) await store.receive(\.showToast) { $0.statusToast = .inProgress("Merging pull request…") } await store.receive(\.showToast) { $0.statusToast = .success("Pull request merged") } await store.receive(\.worktreeInfoEvent) #expect(mutationRemoteInfos.value == [upstreamRemoteInfo]) await store.finish() }
@Test func pullRequestMergeUsesPullRequestURLRemoteInfo() async { let fixture = makePullRequestURLRemoteInfoFixture(repoRoot: "/tmp/repo-pr-url-merge") let remoteInfos = LockIsolated<[GithubRemoteInfo]>([]) let store = TestStore(initialState: fixture.state) { RepositoriesFeature() } withDependencies: { $0.githubIntegration.isAvailable = { true } $0.gitClient.remoteInfo = { _ in Issue.record("git remoteInfo should not run when PR URL resolves") return nil } $0.githubCLI.resolveRemoteInfo = { _ in Issue.record("gh resolveRemoteInfo should not run when PR URL resolves") return nil } $0.githubCLI.mergePullRequest = { _, remoteInfo, _, _, _ in remoteInfos.withValue { $0.append(remoteInfo) } } } store.exhaustivity = .off
await store.send(.githubIntegration(.pullRequestAction(fixture.featureWorktree.id, .merge))) await store.receive(\.showToast) { $0.statusToast = .inProgress("Merging pull request…") } await store.receive(\.showToast) { $0.statusToast = .success("Pull request merged") } await store.receive(\.worktreeInfoEvent) #expect(remoteInfos.value == [fixture.expectedRemoteInfo]) await store.finish() }
@Test func pullRequestCloseUsesPullRequestURLRemoteInfo() async { let fixture = makePullRequestURLRemoteInfoFixture(repoRoot: "/tmp/repo-pr-url-close") let remoteInfos = LockIsolated<[GithubRemoteInfo]>([]) let store = TestStore(initialState: fixture.state) { RepositoriesFeature() } withDependencies: { $0.githubIntegration.isAvailable = { true } $0.gitClient.remoteInfo = { _ in Issue.record("git remoteInfo should not run when PR URL resolves") return nil } $0.githubCLI.resolveRemoteInfo = { _ in Issue.record("gh resolveRemoteInfo should not run when PR URL resolves") return nil } $0.githubCLI.closePullRequest = { _, remoteInfo, _, _ in remoteInfos.withValue { $0.append(remoteInfo) } } } store.exhaustivity = .off
await store.send(.githubIntegration(.pullRequestAction(fixture.featureWorktree.id, .close))) await store.receive(\.showToast) { $0.statusToast = .inProgress("Closing pull request…") } await store.receive(\.showToast) { $0.statusToast = .success("Pull request closed") } await store.receive(\.worktreeInfoEvent) #expect(remoteInfos.value == [fixture.expectedRemoteInfo]) await store.finish() }
@Test func pullRequestMarkReadyUsesPullRequestURLRemoteInfo() async { let fixture = makePullRequestURLRemoteInfoFixture(repoRoot: "/tmp/repo-pr-url-ready") let remoteInfos = LockIsolated<[GithubRemoteInfo]>([]) let store = TestStore(initialState: fixture.state) { RepositoriesFeature() } withDependencies: { $0.githubIntegration.isAvailable = { true } $0.gitClient.remoteInfo = { _ in Issue.record("git remoteInfo should not run when PR URL resolves") return nil } $0.githubCLI.resolveRemoteInfo = { _ in Issue.record("gh resolveRemoteInfo should not run when PR URL resolves") return nil } $0.githubCLI.markPullRequestReady = { _, remoteInfo, _, _ in remoteInfos.withValue { $0.append(remoteInfo) } } } store.exhaustivity = .off
await store.send(.githubIntegration(.pullRequestAction(fixture.featureWorktree.id, .markReadyForReview))) await store.receive(\.showToast) { $0.statusToast = .inProgress("Marking PR ready…") } await store.receive(\.showToast) { $0.statusToast = .success("Pull request marked ready") } #expect(remoteInfos.value == [fixture.expectedRemoteInfo]) await store.finish() }
@Test func pullRequestActionMergeRequiresResolvedRemoteInfo() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) let openPullRequest = makePullRequest( state: "OPEN", headRefName: featureWorktree.name, number: 12) var state = makeState(repositories: [repository]) state.githubIntegrationAvailability = .disabled state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( addedLines: nil, removedLines: nil, pullRequest: openPullRequest ) let mergeAttempts = LockIsolated(0) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.githubIntegration.isAvailable = { true } $0.githubCLI.resolveRemoteInfo = { _ in nil } $0.gitClient.remoteInfo = { _ in nil } $0.githubCLI.mergePullRequest = { _, _, _, _, _ in mergeAttempts.withValue { $0 += 1 } } } store.exhaustivity = .off
await store.send(.githubIntegration(.pullRequestAction(featureWorktree.id, .merge))) await store.receive(\.showToast) { $0.statusToast = .inProgress("Merging pull request…") } await store.receive(\.dismissToast) { $0.statusToast = nil } await store.receive(\.presentAlert) { $0.alert = AlertState<RepositoriesFeature.Alert> { TextState("GitHub repository not resolved") } actions: { ButtonState(role: .cancel) { TextState("OK") } } message: { TextState( String( localized: """ Prowl could not determine which GitHub repository owns this pull request. \ Check the repository remote and try again. """ ) ) } } #expect(mergeAttempts.value == 0) await store.finish() }
@Test func pullRequestActionCloseRefreshesImmediately() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) let openPullRequest = makePullRequest( state: "OPEN", headRefName: featureWorktree.name, number: 12) var state = makeState(repositories: [repository]) state.githubIntegrationAvailability = .disabled state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( addedLines: nil, removedLines: nil, pullRequest: openPullRequest ) let upstreamRemoteInfo = GithubRemoteInfo( host: "github.com", owner: "supabitapp", repo: "supacode") let closedNumbers = LockIsolated<[Int]>([]) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.githubIntegration.isAvailable = { true } $0.githubCLI.resolveRemoteInfo = { _ in upstreamRemoteInfo } $0.githubCLI.closePullRequest = { _, _, number, _ in closedNumbers.withValue { $0.append(number) } } } store.exhaustivity = .off
await store.send(.githubIntegration(.pullRequestAction(featureWorktree.id, .close))) await store.receive(\.showToast) { $0.statusToast = .inProgress("Closing pull request…") } await store.receive(\.showToast) { $0.statusToast = .success("Pull request closed") } await store.receive(\.worktreeInfoEvent) #expect(closedNumbers.value == [12]) await store.finish() }
@Test func pullRequestActionOpenOnCodeHostOpensPullRequestURLWhenAvailable() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) let pullRequest = makePullRequest( state: "OPEN", headRefName: featureWorktree.name, number: 12, url: "https://github.com/octo/repo/pull/12" ) var state = makeState(repositories: [repository]) state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( addedLines: nil, removedLines: nil, pullRequest: pullRequest ) let openedURLs = LockIsolated<[URL]>([]) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.gitClient.repositoryWebURL = { _ in Issue.record("repositoryWebURL should not be requested when a pull request URL exists") return nil } $0.openURLClient.open = { url in openedURLs.withValue { $0.append(url) } } }
await store.send(.githubIntegration(.pullRequestAction(featureWorktree.id, .openOnCodeHost))) await store.finish()
#expect(openedURLs.value == [URL(string: "https://github.com/octo/repo/pull/12")!]) }
@Test func pullRequestActionOpenOnCodeHostFallsBackToRepositoryURL() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) let repositoryURL = URL(string: "https://gitlab.com/group/subgroup/repo")! let openedURLs = LockIsolated<[URL]>([]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.gitClient.repositoryWebURL = { _ in repositoryURL } $0.openURLClient.open = { url in openedURLs.withValue { $0.append(url) } } }
await store.send(.githubIntegration(.pullRequestAction(featureWorktree.id, .openOnCodeHost))) await store.finish()
#expect(openedURLs.value == [repositoryURL]) }
@Test func pullRequestActionOpenOnCodeHostFallsBackWhenPullRequestURLIsInvalid() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) let pullRequest = makePullRequest( state: "OPEN", headRefName: featureWorktree.name, number: 12, url: "/octo/repo/pull/12" ) var state = makeState(repositories: [repository]) state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( addedLines: nil, removedLines: nil, pullRequest: pullRequest ) let repositoryURL = URL(string: "https://git.example.com/scm/repo")! let openedURLs = LockIsolated<[URL]>([]) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.gitClient.repositoryWebURL = { _ in repositoryURL } $0.openURLClient.open = { url in openedURLs.withValue { $0.append(url) } } }
await store.send(.githubIntegration(.pullRequestAction(featureWorktree.id, .openOnCodeHost))) await store.finish()
#expect(openedURLs.value == [repositoryURL]) }
@Test func pullRequestActionOpenOnCodeHostShowsAlertWhenRepositoryURLUnavailable() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.gitClient.repositoryWebURL = { _ in nil } }
await store.send(.githubIntegration(.pullRequestAction(featureWorktree.id, .openOnCodeHost))) await store.receive(\.presentAlert) { $0.alert = AlertState<RepositoriesFeature.Alert> { TextState("Repository URL not available") } actions: { ButtonState(role: .cancel) { TextState("OK") } } message: { TextState("Prowl could not determine a code host URL for this repository.") } } }
@Test func worktreeInfoEventRepositoryPullRequestRefreshMarksInFlightThenCompletes() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) var initialState = makeState(repositories: [repository]) initialState.githubIntegrationAvailability = .available let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.gitClient.githubRemoteInfos = { _ in [] } $0.githubCLI.resolveRemoteInfo = { _ in nil } $0.pullRequestRefreshCoordinator.enqueue = { _ in Issue.record("Coordinator should not enqueue when GitHub remotes are unavailable") } }
await store.send( .worktreeInfoEvent( .repositoryPullRequestRefresh( repositoryRootURL: URL(fileURLWithPath: repoRoot), worktreeIDs: [mainWorktree.id, featureWorktree.id] ) ) ) await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { $0.inFlightPullRequestRefreshRepositoryIDs = [repository.id] } await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { $0.inFlightPullRequestRefreshRepositoryIDs = [] } await store.finish() }
@Test func worktreeInfoEventRepositoryPullRequestRefreshQueuesWhileAvailabilityUnknown() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.continuousClock = TestClock() $0.githubIntegration.isAvailable = { false } $0.gitClient.remoteInfo = { _ in Issue.record("remoteInfo should not be requested when GitHub integration is unavailable") return nil } $0.githubCLI.batchPullRequests = { _, _, _, _, _, _ in Issue.record("batchPullRequests should not run when GitHub integration is unavailable") return [:] } }
await store.send( .worktreeInfoEvent( .repositoryPullRequestRefresh( repositoryRootURL: URL(fileURLWithPath: repoRoot), worktreeIDs: [mainWorktree.id, featureWorktree.id] ) ) ) await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { $0.pendingPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( repositoryRootURL: URL(fileURLWithPath: repoRoot), worktreeIDs: [mainWorktree.id, featureWorktree.id] ) } await store.receive(\.githubIntegration.refreshGithubIntegrationAvailability) { $0.githubIntegrationAvailability = .checking } await store.receive(\.githubIntegration.githubIntegrationAvailabilityUpdated) { $0.githubIntegrationAvailability = .unavailable $0.queuedPullRequestRefreshByRepositoryID = [:] $0.inFlightPullRequestRefreshRepositoryIDs = [] } await store.send(.githubIntegration(.setGithubIntegrationEnabled(false))) { $0.githubIntegrationAvailability = .disabled $0.pendingPullRequestRefreshByRepositoryID = [:] $0.queuedPullRequestRefreshByRepositoryID = [:] $0.inFlightPullRequestRefreshRepositoryIDs = [] } await store.finish() }
@Test func worktreeInfoEventRepositoryPullRequestRefreshQueuesWhileAvailabilityUnavailable() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) var initialState = makeState(repositories: [repository]) initialState.githubIntegrationAvailability = .unavailable let store = TestStore(initialState: initialState) { RepositoriesFeature() }
await store.send( .worktreeInfoEvent( .repositoryPullRequestRefresh( repositoryRootURL: URL(fileURLWithPath: repoRoot), worktreeIDs: [mainWorktree.id, featureWorktree.id] ) ) ) await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { $0.pendingPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( repositoryRootURL: URL(fileURLWithPath: repoRoot), worktreeIDs: [mainWorktree.id, featureWorktree.id] ) } await store.finish() }
@Test func githubIntegrationAvailabilityRecoveryReplaysPendingRefreshes() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) var initialState = makeState(repositories: [repository]) initialState.githubIntegrationAvailability = .unavailable initialState.pendingPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( repositoryRootURL: URL(fileURLWithPath: repoRoot), worktreeIDs: [mainWorktree.id, featureWorktree.id] ) let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.gitClient.githubRemoteInfos = { _ in [] } $0.githubCLI.resolveRemoteInfo = { _ in nil } $0.pullRequestRefreshCoordinator.enqueue = { _ in Issue.record("Coordinator should not enqueue when GitHub remotes are unavailable") } }
await store.send(.githubIntegration(.githubIntegrationAvailabilityUpdated(true))) { $0.githubIntegrationAvailability = .available $0.pendingPullRequestRefreshByRepositoryID = [:] } await store.receive(\.worktreeInfoEvent) await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { $0.inFlightPullRequestRefreshRepositoryIDs = [repository.id] } await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { $0.inFlightPullRequestRefreshRepositoryIDs = [] } await store.finish() }
@Test func githubIntegrationAvailabilityUnavailablePromotesQueuedRefreshesToPending() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) var initialState = makeState(repositories: [repository]) initialState.githubIntegrationAvailability = .available initialState.inFlightPullRequestRefreshRepositoryIDs = [repository.id] initialState.queuedPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( repositoryRootURL: URL(fileURLWithPath: repoRoot), worktreeIDs: [mainWorktree.id, featureWorktree.id] ) let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.continuousClock = TestClock() }
await store.send(.githubIntegration(.githubIntegrationAvailabilityUpdated(false))) { $0.githubIntegrationAvailability = .unavailable $0.pendingPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( repositoryRootURL: URL(fileURLWithPath: repoRoot), worktreeIDs: [mainWorktree.id, featureWorktree.id] ) $0.queuedPullRequestRefreshByRepositoryID = [:] $0.inFlightPullRequestRefreshRepositoryIDs = [] } await store.send(.githubIntegration(.setGithubIntegrationEnabled(false))) { $0.githubIntegrationAvailability = .disabled $0.pendingPullRequestRefreshByRepositoryID = [:] $0.queuedPullRequestRefreshByRepositoryID = [:] $0.inFlightPullRequestRefreshRepositoryIDs = [] } await store.finish() }
@Test func githubIntegrationAvailabilityUpdatedWhileDisabledIsIgnored() async { var state = makeState(repositories: []) state.githubIntegrationAvailability = .disabled state.pendingPullRequestRefreshByRepositoryID["repo"] = RepositoriesFeature.PendingPullRequestRefresh( repositoryRootURL: URL(fileURLWithPath: "/tmp/repo"), worktreeIDs: [] ) let expectedState = state let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.githubIntegration(.githubIntegrationAvailabilityUpdated(false))) await store.send(.githubIntegration(.githubIntegrationAvailabilityUpdated(true))) #expect(store.state == expectedState) await store.finish() }
@Test func repositoryPullRequestRefreshCompletedReplaysQueuedRefresh() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) var state = makeState(repositories: [repository]) state.githubIntegrationAvailability = .available state.inFlightPullRequestRefreshRepositoryIDs = [repository.id] state.queuedPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature .PendingPullRequestRefresh( repositoryRootURL: URL(fileURLWithPath: repoRoot), worktreeIDs: [mainWorktree.id, featureWorktree.id] ) let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.gitClient.githubRemoteInfos = { _ in [] } $0.githubCLI.resolveRemoteInfo = { _ in nil } $0.pullRequestRefreshCoordinator.enqueue = { _ in Issue.record("Coordinator should not enqueue when GitHub remotes are unavailable") } }
await store.send( .githubIntegration(.repositoryPullRequestRefreshCompleted(repository.id)) ) { $0.inFlightPullRequestRefreshRepositoryIDs = [] $0.queuedPullRequestRefreshByRepositoryID = [:] } await store.receive(\.worktreeInfoEvent) await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { $0.inFlightPullRequestRefreshRepositoryIDs = [repository.id] } await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { $0.inFlightPullRequestRefreshRepositoryIDs = [] } await store.finish() }
@Test func repositoryPullRequestsLoadedSkipsNoopPayload() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) let pullRequest = makePullRequest(state: "OPEN", headRefName: featureWorktree.name) var state = makeState(repositories: [repository]) state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( addedLines: nil, removedLines: nil, pullRequest: pullRequest ) let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send( .githubIntegration( .repositoryPullRequestsLoaded( repositoryID: repository.id, pullRequestsByWorktreeID: [featureWorktree.id: pullRequest] )) ) await store.finish() }
@Test func repositoryPullRequestsLoadedClearsStalePullRequestWhenNil() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) var state = makeState(repositories: [repository]) state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( addedLines: nil, removedLines: nil, pullRequest: makePullRequest(state: "OPEN", headRefName: featureWorktree.name) ) let store = TestStore(initialState: state) { RepositoriesFeature() } let pullRequestsByWorktreeID: [Worktree.ID: GithubPullRequest?] = [featureWorktree.id: nil]
await store.send( .githubIntegration( .repositoryPullRequestsLoaded( repositoryID: repository.id, pullRequestsByWorktreeID: pullRequestsByWorktreeID )) ) { $0.worktreeInfoByID.removeValue(forKey: featureWorktree.id) } }
@Test func repositoryPullRequestsLoadedClearsMergedPullRequestForMainWorktree() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) let mergedPullRequest = makePullRequest(state: "MERGED", headRefName: mainWorktree.name) var state = makeState(repositories: [repository]) state.worktreeInfoByID[mainWorktree.id] = WorktreeInfoEntry( addedLines: nil, removedLines: nil, pullRequest: mergedPullRequest ) let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send( .githubIntegration( .repositoryPullRequestsLoaded( repositoryID: repository.id, pullRequestsByWorktreeID: [mainWorktree.id: mergedPullRequest] )) ) { $0.worktreeInfoByID.removeValue(forKey: mainWorktree.id) } }
@Test func repositoryPullRequestsLoadedPreservesMergeableWhenNewIsUnknown() async { let repoRoot = "/tmp/repo" let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [featureWorktree]) let previousPR = GithubPullRequest( number: 1, title: "PR", state: "OPEN", additions: 10, deletions: 5, isDraft: false, reviewDecision: nil, mergeable: "MERGEABLE", mergeStateStatus: "CLEAN", updatedAt: nil, url: "https://example.com/pull/1", headRefName: "feature", baseRefName: "main", commitsCount: 1, authorLogin: "khoi", statusCheckRollup: nil ) var newPR = previousPR newPR.mergeable = "UNKNOWN" newPR.mergeStateStatus = "BEHIND" var state = makeState(repositories: [repository]) state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( addedLines: nil, removedLines: nil, pullRequest: previousPR ) let store = TestStore(initialState: state) { RepositoriesFeature() } await store.send( .githubIntegration( .repositoryPullRequestsLoaded( repositoryID: repository.id, pullRequestsByWorktreeID: [featureWorktree.id: newPR] )) ) await store.finish() }
@Test func repositoryPullRequestsLoadedPreservesMergeableButUpdatesOtherFields() async { let repoRoot = "/tmp/repo" let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [featureWorktree]) let previousPR = GithubPullRequest( number: 1, title: "Old title", state: "OPEN", additions: 10, deletions: 5, isDraft: false, reviewDecision: nil, mergeable: "MERGEABLE", mergeStateStatus: "CLEAN", updatedAt: nil, url: "https://example.com/pull/1", headRefName: "feature", baseRefName: "main", commitsCount: 1, authorLogin: "khoi", statusCheckRollup: nil ) let newPR = GithubPullRequest( number: 1, title: "New title", state: "OPEN", additions: 10, deletions: 5, isDraft: false, reviewDecision: nil, mergeable: "UNKNOWN", mergeStateStatus: "BEHIND", updatedAt: nil, url: "https://example.com/pull/1", headRefName: "feature", baseRefName: "main", commitsCount: 1, authorLogin: "khoi", statusCheckRollup: nil ) var expectedPR = newPR expectedPR.mergeable = "MERGEABLE" expectedPR.mergeStateStatus = "CLEAN" var state = makeState(repositories: [repository]) state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( addedLines: nil, removedLines: nil, pullRequest: previousPR ) let store = TestStore(initialState: state) { RepositoriesFeature() } await store.send( .githubIntegration( .repositoryPullRequestsLoaded( repositoryID: repository.id, pullRequestsByWorktreeID: [featureWorktree.id: newPR] )) ) { $0.worktreeInfoByID[featureWorktree.id]?.pullRequest = expectedPR } }
@Test func repositoryPullRequestsLoadedKeepsUnknownWhenNoPreviousPR() async { let repoRoot = "/tmp/repo" let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [featureWorktree]) let newPR = GithubPullRequest( number: 1, title: "PR", state: "OPEN", additions: 10, deletions: 5, isDraft: false, reviewDecision: nil, mergeable: "UNKNOWN", mergeStateStatus: nil, updatedAt: nil, url: "https://example.com/pull/1", headRefName: "feature", baseRefName: "main", commitsCount: 1, authorLogin: "khoi", statusCheckRollup: nil ) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } await store.send( .githubIntegration( .repositoryPullRequestsLoaded( repositoryID: repository.id, pullRequestsByWorktreeID: [featureWorktree.id: newPR] )) ) { $0.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( addedLines: nil, removedLines: nil, pullRequest: newPR ) } }
@Test func repositoryPullRequestsLoadedKeepsUnknownWhenPreviousAlsoUnknown() async { let repoRoot = "/tmp/repo" let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [featureWorktree]) let previousPR = GithubPullRequest( number: 1, title: "Old title", state: "OPEN", additions: 10, deletions: 5, isDraft: false, reviewDecision: nil, mergeable: "UNKNOWN", mergeStateStatus: "BEHIND", updatedAt: nil, url: "https://example.com/pull/1", headRefName: "feature", baseRefName: "main", commitsCount: 1, authorLogin: "khoi", statusCheckRollup: nil ) let newPR = GithubPullRequest( number: 1, title: "New title", state: "OPEN", additions: 10, deletions: 5, isDraft: false, reviewDecision: nil, mergeable: "UNKNOWN", mergeStateStatus: "BEHIND", updatedAt: nil, url: "https://example.com/pull/1", headRefName: "feature", baseRefName: "main", commitsCount: 1, authorLogin: "khoi", statusCheckRollup: nil ) var state = makeState(repositories: [repository]) state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( addedLines: nil, removedLines: nil, pullRequest: previousPR ) let store = TestStore(initialState: state) { RepositoriesFeature() } await store.send( .githubIntegration( .repositoryPullRequestsLoaded( repositoryID: repository.id, pullRequestsByWorktreeID: [featureWorktree.id: newPR] )) ) { $0.worktreeInfoByID[featureWorktree.id]?.pullRequest = newPR } }
@Test func unarchiveWorktreeNoopsWhenNotArchived() async { let worktree = makeWorktree(id: "/tmp/wt", name: "owl") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() }
await store.send(.worktreeLifecycle(.unarchiveWorktree(worktree.id))) expectNoDifference(store.state.archivedWorktrees, []) }
// MARK: - Auto-delete Archived Worktrees
@Test func autoDeleteExpiredArchivedWorktreesDeletesExpired() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let expiredWorktree = makeWorktree(id: "/tmp/repo/expired", name: "expired", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, expiredWorktree]) let fixedDate = Date(timeIntervalSince1970: 1_000_000) let archivedAt = fixedDate.addingTimeInterval(-2 * 86_400) // 2 days ago var state = makeState(repositories: [repository]) state.archivedWorktrees = [ArchivedWorktree(id: expiredWorktree.id, archivedAt: archivedAt)] state.archivedAutoDeletePeriod = .oneDay let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.date = .constant(fixedDate) $0.gitClient.removeWorktree = { worktree, _ in worktree.workingDirectory } } store.exhaustivity = .off
await store.send(.autoDeleteExpiredArchivedWorktrees) await store.receive(\.worktreeLifecycle.deleteWorktreeConfirmed) { $0.deletingWorktreeIDs = [expiredWorktree.id] } await store.receive(\.worktreeLifecycle.worktreeDeleted) }
@Test(.dependencies) func autoDeleteExpiredArchivedWorktreesOnlyDeletesProwlCreatedBranches() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let expiredWorktree = makeWorktree(id: "/tmp/repo/expired", name: "expired", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, expiredWorktree]) let fixedDate = Date(timeIntervalSince1970: 1_000_000) let archivedAt = fixedDate.addingTimeInterval(-2 * 86_400) var state = makeState(repositories: [repository]) state.archivedWorktrees = [ArchivedWorktree(id: expiredWorktree.id, archivedAt: archivedAt)] state.archivedAutoDeletePeriod = .oneDay @Shared(.settingsFile) var settingsFile $settingsFile.withLock { $0.global.deleteBranchOnAutomaticCleanup = true } let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.date = .constant(fixedDate) $0.gitClient.removeWorktree = { worktree, deleteBranch in #expect(worktree.id == expiredWorktree.id) #expect(deleteBranch == false) return worktree.workingDirectory } } store.exhaustivity = .off
await store.send(.autoDeleteExpiredArchivedWorktrees) await store.receive(\.worktreeLifecycle.deleteWorktreeConfirmed) { $0.deletingWorktreeIDs = [expiredWorktree.id] } }
@Test func autoDeleteKeepsUnexpiredArchivedWorktrees() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let recentWorktree = makeWorktree(id: "/tmp/repo/recent", name: "recent", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, recentWorktree]) let fixedDate = Date(timeIntervalSince1970: 1_000_000) let archivedAt = fixedDate.addingTimeInterval(-1 * 86_400) // 1 day ago var state = makeState(repositories: [repository]) state.archivedWorktrees = [ArchivedWorktree(id: recentWorktree.id, archivedAt: archivedAt)] state.archivedAutoDeletePeriod = .sevenDays let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.date = .constant(fixedDate) }
await store.send(.autoDeleteExpiredArchivedWorktrees) // No effects — worktree is not expired yet }
@Test func autoDeleteNilPeriodDoesNothing() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, worktree]) let fixedDate = Date(timeIntervalSince1970: 1_000_000) var state = makeState(repositories: [repository]) state.archivedWorktrees = [ ArchivedWorktree(id: worktree.id, archivedAt: .distantPast) ] state.archivedAutoDeletePeriod = nil let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.date = .constant(fixedDate) }
await store.send(.autoDeleteExpiredArchivedWorktrees) // No effects — auto-delete is disabled }
@Test func autoDeleteSkipsMainWorktree() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) let fixedDate = Date(timeIntervalSince1970: 1_000_000) var state = makeState(repositories: [repository]) state.archivedWorktrees = [ ArchivedWorktree(id: mainWorktree.id, archivedAt: .distantPast) ] state.archivedAutoDeletePeriod = .oneDay let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.date = .constant(fixedDate) }
await store.send(.autoDeleteExpiredArchivedWorktrees) // No effects — main worktree must not be deleted }
@Test func autoDeleteMultipleExpiredWorktreesConcurrently() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let expired1 = makeWorktree(id: "/tmp/repo/expired1", name: "expired1", repoRoot: repoRoot) let expired2 = makeWorktree(id: "/tmp/repo/expired2", name: "expired2", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, expired1, expired2]) let fixedDate = Date(timeIntervalSince1970: 1_000_000) let archivedAt = fixedDate.addingTimeInterval(-2 * 86_400) var state = makeState(repositories: [repository]) state.archivedWorktrees = [ ArchivedWorktree(id: expired1.id, archivedAt: archivedAt), ArchivedWorktree(id: expired2.id, archivedAt: archivedAt), ] state.archivedAutoDeletePeriod = .oneDay let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.date = .constant(fixedDate) $0.gitClient.removeWorktree = { worktree, _ in worktree.workingDirectory } $0.gitClient.worktrees = { _ in [mainWorktree] } } store.exhaustivity = .off
await store.send(.autoDeleteExpiredArchivedWorktrees) // Both deleteWorktreeConfirmed should fire await store.receive(\.worktreeLifecycle.deleteWorktreeConfirmed) await store.receive(\.worktreeLifecycle.deleteWorktreeConfirmed) // Both worktreeDeleted should complete without crash await store.receive(\.worktreeLifecycle.worktreeDeleted) await store.receive(\.worktreeLifecycle.worktreeDeleted) // State should be consistent: both worktrees removed from archives #expect(store.state.archivedWorktrees.isEmpty) #expect(store.state.deletingWorktreeIDs.isEmpty) }
// MARK: - Select Next/Previous Worktree
@Test func selectNextWorktreeWrapsForward() async { let wt1 = makeWorktree(id: "/tmp/wt1", name: "alpha") let wt2 = makeWorktree(id: "/tmp/wt2", name: "beta") let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2]) var state = makeState(repositories: [repository]) state.selection = .worktree(wt2.id) let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.selectNextWorktree) await store.receive(\.selectWorktree) { $0.selection = .worktree(wt1.id) $0.sidebarSelectedWorktreeIDs = [wt1.id] $0.openedWorktreeIDs = [wt1.id] $0.worktreeHistoryBackStack = [wt2.id] $0.pendingTerminalFocusWorktreeIDs = [wt1.id] } await store.receive(\.delegate.selectedWorktreeChanged) }
@Test func selectPreviousWorktreeWrapsBackward() async { let wt1 = makeWorktree(id: "/tmp/wt1", name: "alpha") let wt2 = makeWorktree(id: "/tmp/wt2", name: "beta") let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2]) var state = makeState(repositories: [repository]) state.selection = .worktree(wt1.id) let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.selectPreviousWorktree) await store.receive(\.selectWorktree) { $0.selection = .worktree(wt2.id) $0.sidebarSelectedWorktreeIDs = [wt2.id] $0.openedWorktreeIDs = [wt2.id] $0.worktreeHistoryBackStack = [wt1.id] $0.pendingTerminalFocusWorktreeIDs = [wt2.id] } await store.receive(\.delegate.selectedWorktreeChanged) }
@Test func selectNextWorktreeWithNoSelectionSelectsFirst() async { let wt1 = makeWorktree(id: "/tmp/wt1", name: "alpha") let wt2 = makeWorktree(id: "/tmp/wt2", name: "beta") let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() }
await store.send(.selectNextWorktree) await store.receive(\.selectWorktree) { $0.selection = .worktree(wt1.id) $0.sidebarSelectedWorktreeIDs = [wt1.id] $0.openedWorktreeIDs = [wt1.id] $0.pendingTerminalFocusWorktreeIDs = [wt1.id] } await store.receive(\.delegate.selectedWorktreeChanged) }
@Test func selectNextWorktreeCollapsesSidebarSelectionToSingleWorktree() async { let wt1 = makeWorktree(id: "/tmp/wt1", name: "alpha") let wt2 = makeWorktree(id: "/tmp/wt2", name: "beta") let wt3 = makeWorktree(id: "/tmp/wt3", name: "gamma") let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2, wt3]) var state = makeState(repositories: [repository]) state.selection = .worktree(wt1.id) state.sidebarSelectedWorktreeIDs = [wt1.id, wt3.id] let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.selectNextWorktree) await store.receive(\.selectWorktree) { $0.selection = .worktree(wt2.id) $0.sidebarSelectedWorktreeIDs = [wt2.id] $0.openedWorktreeIDs = [wt2.id] $0.worktreeHistoryBackStack = [wt1.id] $0.pendingTerminalFocusWorktreeIDs = [wt2.id] } await store.receive(\.delegate.selectedWorktreeChanged) }
@Test func selectPreviousWorktreeWithNoSelectionSelectsLast() async { let wt1 = makeWorktree(id: "/tmp/wt1", name: "alpha") let wt2 = makeWorktree(id: "/tmp/wt2", name: "beta") let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() }
await store.send(.selectPreviousWorktree) await store.receive(\.selectWorktree) { $0.selection = .worktree(wt2.id) $0.sidebarSelectedWorktreeIDs = [wt2.id] $0.openedWorktreeIDs = [wt2.id] $0.pendingTerminalFocusWorktreeIDs = [wt2.id] } await store.receive(\.delegate.selectedWorktreeChanged) }
@Test func selectNextWorktreeWithEmptyRowsIsNoOp() async { let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() }
await store.send(.selectNextWorktree) }
@Test func selectNextWorktreeSingleWorktreeReturnsSame() async { let worktree = makeWorktree(id: "/tmp/wt", name: "solo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) var state = makeState(repositories: [repository]) state.selection = .worktree(worktree.id) let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.selectNextWorktree) await store.receive(\.selectWorktree) { $0.selection = .worktree(worktree.id) $0.sidebarSelectedWorktreeIDs = [worktree.id] $0.openedWorktreeIDs = [worktree.id] $0.pendingTerminalFocusWorktreeIDs = [worktree.id] } await store.receive(\.delegate.selectedWorktreeChanged) }
@Test func selectNextWorktreeSkipsCollapsedRepository() async { let wt1 = makeWorktree(id: "/tmp/repo1/wt1", name: "alpha", repoRoot: "/tmp/repo1") let wt2 = makeWorktree(id: "/tmp/repo2/wt2", name: "beta", repoRoot: "/tmp/repo2") let wt3 = makeWorktree(id: "/tmp/repo3/wt3", name: "gamma", repoRoot: "/tmp/repo3") let repo1 = makeRepository(id: "/tmp/repo1", worktrees: [wt1]) let repo2 = makeRepository(id: "/tmp/repo2", worktrees: [wt2]) let repo3 = makeRepository(id: "/tmp/repo3", worktrees: [wt3]) var state = makeState(repositories: [repo1, repo2, repo3]) state.selection = .worktree(wt1.id) state.$collapsedRepositoryIDs.withLock { $0 = [repo2.id] } let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.selectNextWorktree) await store.receive(\.selectWorktree) { $0.selection = .worktree(wt3.id) $0.sidebarSelectedWorktreeIDs = [wt3.id] $0.openedWorktreeIDs = [wt3.id] $0.worktreeHistoryBackStack = [wt1.id] $0.pendingTerminalFocusWorktreeIDs = [wt3.id] } await store.receive(\.delegate.selectedWorktreeChanged) }
@Test func selectPreviousWorktreeSkipsCollapsedRepository() async { let wt1 = makeWorktree(id: "/tmp/repo1/wt1", name: "alpha", repoRoot: "/tmp/repo1") let wt2 = makeWorktree(id: "/tmp/repo2/wt2", name: "beta", repoRoot: "/tmp/repo2") let wt3 = makeWorktree(id: "/tmp/repo3/wt3", name: "gamma", repoRoot: "/tmp/repo3") let repo1 = makeRepository(id: "/tmp/repo1", worktrees: [wt1]) let repo2 = makeRepository(id: "/tmp/repo2", worktrees: [wt2]) let repo3 = makeRepository(id: "/tmp/repo3", worktrees: [wt3]) var state = makeState(repositories: [repo1, repo2, repo3]) state.selection = .worktree(wt3.id) state.$collapsedRepositoryIDs.withLock { $0 = [repo2.id] } let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.selectPreviousWorktree) await store.receive(\.selectWorktree) { $0.selection = .worktree(wt1.id) $0.sidebarSelectedWorktreeIDs = [wt1.id] $0.openedWorktreeIDs = [wt1.id] $0.worktreeHistoryBackStack = [wt3.id] $0.pendingTerminalFocusWorktreeIDs = [wt1.id] } await store.receive(\.delegate.selectedWorktreeChanged) }
@Test func selectNextWorktreeAllCollapsedIsNoOp() async { let wt1 = makeWorktree(id: "/tmp/repo1/wt1", name: "alpha", repoRoot: "/tmp/repo1") let repo1 = makeRepository(id: "/tmp/repo1", worktrees: [wt1]) var state = makeState(repositories: [repo1]) state.selection = .worktree(wt1.id) state.$collapsedRepositoryIDs.withLock { $0 = [repo1.id] } let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.selectNextWorktree) }
@Test func selectPreviousWorktreeAllCollapsedIsNoOp() async { let wt1 = makeWorktree(id: "/tmp/repo1/wt1", name: "alpha", repoRoot: "/tmp/repo1") let repo1 = makeRepository(id: "/tmp/repo1", worktrees: [wt1]) var state = makeState(repositories: [repo1]) state.selection = .worktree(wt1.id) state.$collapsedRepositoryIDs.withLock { $0 = [repo1.id] } let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.selectPreviousWorktree) }
@Test func selectNextWorktreeWrapsAroundSkippingCollapsedRepo() async { let wt1 = makeWorktree(id: "/tmp/repo1/wt1", name: "alpha", repoRoot: "/tmp/repo1") let wt2 = makeWorktree(id: "/tmp/repo2/wt2", name: "beta", repoRoot: "/tmp/repo2") let wt3 = makeWorktree(id: "/tmp/repo3/wt3", name: "gamma", repoRoot: "/tmp/repo3") let repo1 = makeRepository(id: "/tmp/repo1", worktrees: [wt1]) let repo2 = makeRepository(id: "/tmp/repo2", worktrees: [wt2]) let repo3 = makeRepository(id: "/tmp/repo3", worktrees: [wt3]) var state = makeState(repositories: [repo1, repo2, repo3]) state.selection = .worktree(wt3.id) state.$collapsedRepositoryIDs.withLock { $0 = [repo2.id] } let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.selectNextWorktree) await store.receive(\.selectWorktree) { $0.selection = .worktree(wt1.id) $0.sidebarSelectedWorktreeIDs = [wt1.id] $0.openedWorktreeIDs = [wt1.id] $0.worktreeHistoryBackStack = [wt3.id] $0.pendingTerminalFocusWorktreeIDs = [wt1.id] } await store.receive(\.delegate.selectedWorktreeChanged) }
// MARK: - Worktree History Back/Forward
@Test func selectingDifferentWorktreePushesPreviousOntoBackStack() async { let wt1 = makeWorktree(id: "/tmp/wt1", name: "alpha") let wt2 = makeWorktree(id: "/tmp/wt2", name: "beta") let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2]) var state = makeState(repositories: [repository]) state.selection = .worktree(wt1.id) state.worktreeHistoryForwardStack = [wt2.id] let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.selectWorktree(wt2.id)) { $0.selection = .worktree(wt2.id) $0.sidebarSelectedWorktreeIDs = [wt2.id] $0.openedWorktreeIDs = [wt2.id] $0.worktreeHistoryBackStack = [wt1.id] $0.worktreeHistoryForwardStack = [] } await store.receive(\.delegate.selectedWorktreeChanged) }
@Test func worktreeHistoryBackPopsPreviousAndPushesCurrentToForward() async { let wt1 = makeWorktree(id: "/tmp/wt1", name: "alpha") let wt2 = makeWorktree(id: "/tmp/wt2", name: "beta") let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2]) var state = makeState(repositories: [repository]) state.selection = .worktree(wt2.id) state.worktreeHistoryBackStack = [wt1.id] let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.worktreeHistoryBack) { $0.selection = .worktree(wt1.id) $0.sidebarSelectedWorktreeIDs = [wt1.id] $0.openedWorktreeIDs = [wt1.id] $0.worktreeHistoryBackStack = [] $0.worktreeHistoryForwardStack = [wt2.id] $0.pendingTerminalFocusWorktreeIDs = [wt1.id] } await store.receive(\.delegate.selectedWorktreeChanged) }
@Test func worktreeHistoryForwardPopsNextAndPushesCurrentToBack() async { let wt1 = makeWorktree(id: "/tmp/wt1", name: "alpha") let wt2 = makeWorktree(id: "/tmp/wt2", name: "beta") let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2]) var state = makeState(repositories: [repository]) state.selection = .worktree(wt1.id) state.worktreeHistoryForwardStack = [wt2.id] let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.worktreeHistoryForward) { $0.selection = .worktree(wt2.id) $0.sidebarSelectedWorktreeIDs = [wt2.id] $0.openedWorktreeIDs = [wt2.id] $0.worktreeHistoryBackStack = [wt1.id] $0.worktreeHistoryForwardStack = [] $0.pendingTerminalFocusWorktreeIDs = [wt2.id] } await store.receive(\.delegate.selectedWorktreeChanged) }
@Test func worktreeHistoryBackSkipsStaleEntriesUntilValidIDFound() async { let wt1 = makeWorktree(id: "/tmp/wt1", name: "alpha") let wt3 = makeWorktree(id: "/tmp/wt3", name: "gamma") let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt3]) var state = makeState(repositories: [repository]) state.selection = .worktree(wt3.id) state.worktreeHistoryBackStack = [wt1.id, "/tmp/wt2-deleted"] let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.worktreeHistoryBack) { $0.selection = .worktree(wt1.id) $0.sidebarSelectedWorktreeIDs = [wt1.id] $0.openedWorktreeIDs = [wt1.id] $0.worktreeHistoryBackStack = [] $0.worktreeHistoryForwardStack = [wt3.id] $0.pendingTerminalFocusWorktreeIDs = [wt1.id] } await store.receive(\.delegate.selectedWorktreeChanged) }
@Test func canNavigateBackwardFiltersStaleAndSelfReferentialEntries() { let wt1 = makeWorktree(id: "/tmp/wt1", name: "alpha") let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1]) var state = makeState(repositories: [repository]) state.selection = .worktree(wt1.id)
state.worktreeHistoryBackStack = ["/tmp/gone-a", "/tmp/gone-b"] #expect(!state.canNavigateWorktreeHistoryBackward)
state.worktreeHistoryBackStack = [wt1.id] #expect(!state.canNavigateWorktreeHistoryBackward)
let wt2 = makeWorktree(id: "/tmp/wt2", name: "beta") state.repositories = [makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2])] state.worktreeHistoryBackStack = [wt2.id, "/tmp/gone"] #expect(state.canNavigateWorktreeHistoryBackward) }
@Test func canNavigateWorktreeHistoryDisabledWhenSelectionIsNotAWorktree() { let wt1 = makeWorktree(id: "/tmp/wt1", name: "alpha") let wt2 = makeWorktree(id: "/tmp/wt2", name: "beta") let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2]) var state = makeState(repositories: [repository]) state.worktreeHistoryBackStack = [wt1.id] state.worktreeHistoryForwardStack = [wt2.id]
state.selection = .repository(repository.id) #expect(!state.canNavigateWorktreeHistoryBackward) #expect(!state.canNavigateWorktreeHistoryForward)
state.selection = .archivedWorktrees #expect(!state.canNavigateWorktreeHistoryBackward) #expect(!state.canNavigateWorktreeHistoryForward)
state.selection = nil #expect(!state.canNavigateWorktreeHistoryBackward) #expect(!state.canNavigateWorktreeHistoryForward)
state.selection = .worktree(wt1.id) #expect(state.canNavigateWorktreeHistoryForward) }
@Test func selectRepositoryPushesWorktreeOntoBackStackAndClearsForwardStack() async { let wt1 = makeWorktree(id: "/tmp/wt1", name: "alpha") let wt2 = makeWorktree(id: "/tmp/wt2", name: "beta") let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2]) var state = makeState(repositories: [repository]) state.selection = .worktree(wt1.id) state.sidebarSelectedWorktreeIDs = [wt1.id] state.worktreeHistoryForwardStack = [wt2.id] let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.selectRepository(repository.id)) { $0.worktreeHistoryBackStack = [wt1.id] $0.worktreeHistoryForwardStack = [] $0.selection = .repository(repository.id) $0.sidebarSelectedWorktreeIDs = [] } await store.receive(\.delegate.selectedWorktreeChanged) }
@Test func selectArchivedWorktreesPushesWorktreeOntoBackStackAndClearsForwardStack() async { let wt1 = makeWorktree(id: "/tmp/wt1", name: "alpha") let wt2 = makeWorktree(id: "/tmp/wt2", name: "beta") let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2]) var state = makeState(repositories: [repository]) state.selection = .worktree(wt1.id) state.sidebarSelectedWorktreeIDs = [wt1.id] state.worktreeHistoryForwardStack = [wt2.id] let store = TestStore(initialState: state) { RepositoriesFeature() }
await store.send(.selectArchivedWorktrees) { $0.worktreeHistoryBackStack = [wt1.id] $0.worktreeHistoryForwardStack = [] $0.selection = .archivedWorktrees $0.sidebarSelectedWorktreeIDs = [] $0.preArchivedWorktreeID = wt1.id } await store.receive(\.delegate.selectedWorktreeChanged) }
@Test func reselectingWorktreeAfterRepositoryDoesNotResurrectStaleForwardEntry() async { // Reproduces the scenario from the Copilot review: after the user // navigates Back and then leaves the worktree view through a // non-worktree selection, the stale forward target must not survive // the next worktree selection. let wt1 = makeWorktree(id: "/tmp/wt1", name: "alpha") let wt2 = makeWorktree(id: "/tmp/wt2", name: "beta") let wt3 = makeWorktree(id: "/tmp/wt3", name: "gamma") let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2, wt3]) var state = makeState(repositories: [repository]) // User landed on wt2 via Back, leaving wt3 in the forward stack. state.selection = .worktree(wt2.id) state.worktreeHistoryBackStack = [wt1.id] state.worktreeHistoryForwardStack = [wt3.id] let store = TestStore(initialState: state) { RepositoriesFeature() }
// Detour through repository view. await store.send(.selectRepository(repository.id)) { $0.worktreeHistoryBackStack = [wt1.id, wt2.id] $0.worktreeHistoryForwardStack = [] $0.selection = .repository(repository.id) $0.sidebarSelectedWorktreeIDs = [] } await store.receive(\.delegate.selectedWorktreeChanged)
// Reselect a worktree — no leftover forward target should remain. await store.send(.selectWorktree(wt2.id)) { $0.selection = .worktree(wt2.id) $0.sidebarSelectedWorktreeIDs = [wt2.id] $0.openedWorktreeIDs = [wt2.id] } await store.receive(\.delegate.selectedWorktreeChanged)
#expect(!store.state.canNavigateWorktreeHistoryForward) #expect(store.state.canNavigateWorktreeHistoryBackward) }
// MARK: - Workspace child rows
private func makeWorkspaceRepository( id: String, children: [ProjectWorkspace.RepositoryEntry] ) -> Repository { makeRepository( id: id, name: "Workspace", kind: .plain, worktrees: [], workspace: ProjectWorkspace(title: "Workspace", repositories: children) ) }
@Test func applyWorkspaceChildrenInfoWritesBranchDiffAndPR() { var state = RepositoriesFeature.State() let pullRequest = makePullRequest(state: "OPEN", headRefName: "feature") applyWorkspaceChildrenInfo( [ WorkspaceChildInfoUpdate( id: "/ws/app", branch: "feature", lineChanges: GitLineChanges(added: 7, removed: 2), pullRequest: pullRequest ), WorkspaceChildInfoUpdate( id: "/ws/api", branch: " ", lineChanges: GitLineChanges(added: 0, removed: 0), pullRequest: nil ), ], state: &state )
#expect(state.workspaceChildBranchByID["/ws/app"] == "feature") #expect(state.workspaceChildInfoByID["/ws/app"]?.addedLines == 7) #expect(state.workspaceChildInfoByID["/ws/app"]?.removedLines == 2) #expect(state.workspaceChildInfoByID["/ws/app"]?.pullRequest == pullRequest) // Blank branch + empty diff + no PR → no entries. #expect(state.workspaceChildBranchByID["/ws/api"] == nil) #expect(state.workspaceChildInfoByID["/ws/api"] == nil) }
@Test func workspaceChildRowsMergesLiveBranchAndInfo() { let entry = ProjectWorkspace.RepositoryEntry( id: "app", name: "App", path: "app", sourceKind: .existingPath, branchName: "metadata-branch" ) let repository = makeWorkspaceRepository(id: "/tmp/ws", children: [entry]) var state = makeState(repositories: [repository]) let childID = entry.resolvedURL(relativeTo: repository.rootURL).path(percentEncoded: false) state.workspaceChildBranchByID[childID] = "live-branch" state.workspaceChildInfoByID[childID] = WorktreeInfoEntry( addedLines: 3, removedLines: 1, pullRequest: nil)
let rows = state.workspaceChildRows(in: repository)
#expect(rows.count == 1) #expect(rows.first?.repositoryName == "App") // Live branch wins over the metadata branch. #expect(rows.first?.branchName == "live-branch") #expect(rows.first?.info?.addedLines == 3) }
@Test func workspaceChildRowsFallsBackToMetadataBranch() { let entry = ProjectWorkspace.RepositoryEntry( id: "app", name: "App", path: "app", sourceKind: .bareRepository, branchName: "metadata-branch" ) let repository = makeWorkspaceRepository(id: "/tmp/ws2", children: [entry]) let state = makeState(repositories: [repository])
let rows = state.workspaceChildRows(in: repository) #expect(rows.first?.branchName == "metadata-branch") #expect(rows.first?.info == nil) }
@Test func diffTargetForWorkspaceChildDiffsChildRepoAndHostsHunkInWorkspace() { let entry = ProjectWorkspace.RepositoryEntry( id: "app", name: "App", path: "app", sourceKind: .existingPath, branchName: "metadata-branch" ) let repository = makeWorkspaceRepository(id: "/tmp/ws-diff", children: [entry]) var state = makeState(repositories: [repository]) let childID = entry.resolvedURL(relativeTo: repository.rootURL).path(percentEncoded: false) state.workspaceChildBranchByID[childID] = "live-branch"
let target = state.diffTarget(for: .workspaceChild(workspaceID: repository.id, path: childID))
let childURL = URL(fileURLWithPath: childID) #expect(target?.id == .workspaceChild(workspaceID: repository.id, path: childID)) #expect(target?.workingDirectory == childURL) // Live branch wins over the metadata branch. #expect(target?.branchName == "live-branch") #expect(target?.repositoryRootURL == childURL) // Hunk stays hosted in the workspace terminal, running in the child dir. #expect(target?.terminalHost.id == repository.id) #expect(target?.terminalHost.workingDirectory == repository.rootURL) #expect(target?.terminalWorkingDirectory == childURL) }
@Test func diffTargetForWorkspaceChildFallsBackBranchToMetadataThenRepositoryName() { let withMetadataBranch = ProjectWorkspace.RepositoryEntry( id: "app", name: "App", path: "app", sourceKind: .existingPath, branchName: "metadata-branch" ) let withoutAnyBranch = ProjectWorkspace.RepositoryEntry( id: "api", name: "Api", path: "api", sourceKind: .existingPath ) let repository = makeWorkspaceRepository( id: "/tmp/ws-diff-fallback", children: [withMetadataBranch, withoutAnyBranch] ) let state = makeState(repositories: [repository]) let metadataChildID = withMetadataBranch.resolvedURL(relativeTo: repository.rootURL) .path(percentEncoded: false) let namelessChildID = withoutAnyBranch.resolvedURL(relativeTo: repository.rootURL) .path(percentEncoded: false)
#expect( state.diffTarget( for: .workspaceChild(workspaceID: repository.id, path: metadataChildID) )?.branchName == "metadata-branch" ) #expect( state.diffTarget( for: .workspaceChild(workspaceID: repository.id, path: namelessChildID) )?.branchName == "Api" ) }
@Test func diffTargetResolvesWorktreesAndRejectsUnknownChildren() { let worktree = makeWorktree(id: "/tmp/repo-dt/wt", name: "feature") let repository = makeRepository(id: "/tmp/repo-dt", worktrees: [worktree]) let state = makeState(repositories: [repository])
#expect(state.diffTarget(for: .worktree(worktree.id)) == DiffTarget(worktree: worktree)) #expect(state.diffTarget(for: .worktree("/tmp/missing")) == nil) #expect( state.diffTarget(for: .workspaceChild(workspaceID: repository.id, path: "/tmp/missing")) == nil ) }
@Test func diffTargetScopesWorkspaceChildToItsOwnWorkspace() { // Two workspaces referencing the same absolute child path: each target // must resolve within its own workspace so Hunk lands in the right // terminal. let sharedEntry = ProjectWorkspace.RepositoryEntry( id: "shared", name: "Shared", path: "/tmp/shared-child", sourceKind: .existingPath ) let firstWorkspace = makeWorkspaceRepository(id: "/tmp/ws-dup-1", children: [sharedEntry]) let secondWorkspace = makeWorkspaceRepository(id: "/tmp/ws-dup-2", children: [sharedEntry]) let state = makeState(repositories: [firstWorkspace, secondWorkspace]) let childID = sharedEntry.resolvedURL(relativeTo: firstWorkspace.rootURL).path(percentEncoded: false)
let firstTarget = state.diffTarget( for: .workspaceChild(workspaceID: firstWorkspace.id, path: childID) ) let secondTarget = state.diffTarget( for: .workspaceChild(workspaceID: secondWorkspace.id, path: childID) )
#expect(firstTarget?.terminalHost.id == firstWorkspace.id) #expect(secondTarget?.terminalHost.id == secondWorkspace.id) #expect(firstTarget?.workingDirectory == secondTarget?.workingDirectory) }
@Test func diffTargetUsesRecordedSourceRootForSettingsAndTemplates() { // Linked and worktree checkouts live inside the workspace folder but // belong to a source repository elsewhere; settings and {repoPath} must // follow the recorded source. Remote clones have no local source and stay // rooted at the checkout. let linkedEntry = ProjectWorkspace.RepositoryEntry( id: "app", name: "App", path: "app", sourceKind: .existingPath, sourceLocation: "/tmp/source-repo" ) let remoteEntry = ProjectWorkspace.RepositoryEntry( id: "api", name: "Api", path: "api", sourceKind: .remote, sourceLocation: "https://example.com/api.git" ) let repository = makeWorkspaceRepository( id: "/tmp/ws-roots", children: [linkedEntry, remoteEntry] ) let state = makeState(repositories: [repository]) let linkedChildID = linkedEntry.resolvedURL(relativeTo: repository.rootURL) .path(percentEncoded: false) let remoteChildID = remoteEntry.resolvedURL(relativeTo: repository.rootURL) .path(percentEncoded: false)
let linkedTarget = state.diffTarget( for: .workspaceChild(workspaceID: repository.id, path: linkedChildID) ) let remoteTarget = state.diffTarget( for: .workspaceChild(workspaceID: repository.id, path: remoteChildID) )
#expect(linkedTarget?.repositoryRootURL == URL(fileURLWithPath: "/tmp/source-repo")) #expect(linkedTarget?.workingDirectory == URL(fileURLWithPath: linkedChildID)) #expect(remoteTarget?.repositoryRootURL == URL(fileURLWithPath: remoteChildID)) }
@Test func pruneClearsSelectedChildRemovedFromItsWorkspaceDespiteDuplicatePath() { // Two workspaces reference the same absolute child path. Removing the // child from the selected workspace must clear the selection even though // the path stays globally valid through the other workspace — otherwise // View/Palette advertise Diff for a target that resolves to nil. let sharedEntry = ProjectWorkspace.RepositoryEntry( id: "shared", name: "Shared", path: "/tmp/shared-prune-child", sourceKind: .existingPath ) let selectedWorkspace = makeWorkspaceRepository(id: "/tmp/ws-prune-1", children: [sharedEntry]) let otherWorkspace = makeWorkspaceRepository(id: "/tmp/ws-prune-2", children: [sharedEntry]) var state = makeState(repositories: [selectedWorkspace, otherWorkspace]) let childID = sharedEntry.resolvedURL(relativeTo: selectedWorkspace.rootURL) .path(percentEncoded: false) state.selection = .repository(selectedWorkspace.id) state.selectedWorkspaceChildID = childID
// Reload with the child still present: selection survives. pruneWorkspaceChildInfo(state: &state) #expect(state.selectedWorkspaceChildID == childID)
// Reload after the child was removed from the selected workspace only. let emptiedWorkspace = makeWorkspaceRepository(id: "/tmp/ws-prune-1", children: []) state.repositories = [emptiedWorkspace, otherWorkspace] pruneWorkspaceChildInfo(state: &state) #expect(state.selectedWorkspaceChildID == nil) #expect(state.selectedDiffTargetID == nil) }
@Test func pullRequestForDiffTargetDispatchesToTheMatchingCache() { let entry = ProjectWorkspace.RepositoryEntry( id: "app", name: "App", path: "app", sourceKind: .existingPath ) let workspace = makeWorkspaceRepository(id: "/tmp/ws-pr", children: [entry]) let worktree = makeWorktree(id: "/tmp/repo-pr/wt", name: "feature") let repository = makeRepository(id: "/tmp/repo-pr", worktrees: [worktree]) var state = makeState(repositories: [repository, workspace]) let childID = entry.resolvedURL(relativeTo: workspace.rootURL).path(percentEncoded: false) let worktreePR = makePullRequest(state: "OPEN", headRefName: "feature", number: 1) let childPR = makePullRequest(state: "OPEN", headRefName: "child-branch", number: 2) state.worktreeInfoByID[worktree.id] = WorktreeInfoEntry(pullRequest: worktreePR) state.workspaceChildInfoByID[childID] = WorktreeInfoEntry(pullRequest: childPR)
#expect(state.pullRequest(for: .worktree(worktree.id)) == worktreePR) #expect( state.pullRequest(for: .workspaceChild(workspaceID: workspace.id, path: childID)) == childPR ) #expect(state.pullRequest(for: .worktree("/tmp/missing")) == nil) }
@Test func selectedDiffTargetIDFollowsWorktreeThenWorkspaceChild() { let entry = ProjectWorkspace.RepositoryEntry( id: "app", name: "App", path: "app", sourceKind: .existingPath ) let workspace = makeWorkspaceRepository(id: "/tmp/ws-selected", children: [entry]) let worktree = makeWorktree(id: "/tmp/repo-sel/wt", name: "alpha") let repository = makeRepository(id: "/tmp/repo-sel", worktrees: [worktree]) var state = makeState(repositories: [repository, workspace]) let childID = entry.resolvedURL(relativeTo: workspace.rootURL).path(percentEncoded: false)
state.selection = .worktree(worktree.id) #expect(state.selectedDiffTargetID == .worktree(worktree.id))
state.selection = .repository(workspace.id) state.selectedWorkspaceChildID = childID #expect(state.selectedDiffTargetID == .workspaceChild(workspaceID: workspace.id, path: childID))
// A stale child id must not leak once a non-workspace repository is selected. state.selection = .repository(repository.id) #expect(state.selectedDiffTargetID == nil)
state.selection = .repository(workspace.id) state.selectedWorkspaceChildID = nil #expect(state.selectedDiffTargetID == nil) }
@Test func openWorkspaceChildFocusesOrCreatesBoundTerminalTabInChildDirectory() async { let entry = ProjectWorkspace.RepositoryEntry( id: "app", name: "App", path: "app", sourceKind: .existingPath ) let repository = makeWorkspaceRepository(id: "/tmp/ws-open-child", children: [entry]) let childID = entry.resolvedURL(relativeTo: repository.rootURL).path(percentEncoded: false) let sentCommands = LockIsolated<[TerminalClient.Command]>([]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() } withDependencies: { $0.terminalClient.send = { command in sentCommands.withValue { $0.append(command) } } } store.exhaustivity = .off
await store.send(.openWorkspaceChild(childID)) { $0.selection = .repository(repository.id) $0.selectedWorkspaceChildID = childID $0.openedWorktreeIDs = [repository.id] $0.pendingTerminalFocusWorktreeIDs = [repository.id] } await store.finish()
let workspaceWorktree = Worktree( id: repository.id, name: repository.name, detail: repository.rootURL.path(percentEncoded: false), workingDirectory: repository.rootURL, repositoryRootURL: repository.rootURL ) #expect( sentCommands.value == [ .focusOrCreateTabInDirectory( workspaceWorktree, directory: URL(fileURLWithPath: childID), title: "App" ) ]) }
@Test func repositoriesLoadedRefreshesAndPrunesWorkspaceChildren() async { let entry = ProjectWorkspace.RepositoryEntry( id: "app", name: "App", path: "app", sourceKind: .existingPath, branchName: "metadata" ) let repository = makeWorkspaceRepository(id: "/tmp/ws-refresh", children: [entry]) let childID = entry.resolvedURL(relativeTo: repository.rootURL).path(percentEncoded: false) var initialState = makeState(repositories: [repository]) // A stale child entry from a workspace that no longer exists must be pruned. initialState.workspaceChildInfoByID["/tmp/gone/app"] = WorktreeInfoEntry( addedLines: 1, removedLines: 1, pullRequest: nil ) let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.gitClient.branchName = { _ in "feature/live" } $0.gitClient.lineChanges = { _ in GitLineChanges(added: 7, removed: 2, skippedUntrackedFileCount: 1) } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } } store.exhaustivity = .off
await store.send( .repositoriesLoaded([repository], failures: [], roots: [repository.rootURL], animated: false) ) await store.receive(\.workspaceChildrenInfoLoaded) await store.finish()
#expect(store.state.workspaceChildInfoByID["/tmp/gone/app"] == nil) #expect(store.state.workspaceChildBranchByID[childID] == "feature/live") #expect(store.state.workspaceChildInfoByID[childID]?.addedLines == 7) #expect(store.state.workspaceChildInfoByID[childID]?.removedLines == 2) #expect(store.state.workspaceChildInfoByID[childID]?.skippedUntrackedFileCount == 1) }
@Test func workspaceChildrenInfoLoadedIgnoresUpdatesForRemovedChildren() async { // An in-flight refresh can land after a reload removed its child; the // stale update must not repopulate the just-pruned maps. let entry = ProjectWorkspace.RepositoryEntry( id: "app", name: "App", path: "app", sourceKind: .existingPath ) let repository = makeWorkspaceRepository(id: "/tmp/ws-stale", children: [entry]) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() }
await store.send( .workspaceChildrenInfoLoaded([ WorkspaceChildInfoUpdate( id: "/tmp/ws-removed/app", branch: "stale", lineChanges: GitLineChanges(added: 1, removed: 1), pullRequest: nil ) ]) )
#expect(store.state.workspaceChildBranchByID.isEmpty) #expect(store.state.workspaceChildInfoByID.isEmpty) }
@Test func openRepositoriesFinishedRefreshesWorkspaceChildren() async { let entry = ProjectWorkspace.RepositoryEntry( id: "app", name: "App", path: "app", sourceKind: .existingPath, branchName: "metadata" ) let repository = makeWorkspaceRepository(id: "/tmp/ws-open-refresh", children: [entry]) let childID = entry.resolvedURL(relativeTo: repository.rootURL).path(percentEncoded: false) let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() } withDependencies: { $0.gitClient.branchName = { _ in "feature/live" } $0.gitClient.lineChanges = { _ in nil } $0.gitClient.remoteInfo = { _ in nil } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } } store.exhaustivity = .off
await store.send( .repositoryManagement( .openRepositoriesFinished( [repository], failures: [], invalidRoots: [], openFailures: [], roots: [repository.rootURL] )) ) { $0.repositories = [repository] $0.repositoryRoots = [repository.rootURL] $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } await store.receive(\.delegate.repositoriesChanged) await store.receive(\.workspaceChildrenInfoLoaded) { $0.workspaceChildBranchByID = [childID: "feature/live"] } await store.finish() }
@Test func workspaceChildRefreshLoadsPullRequestWhenGithubIntegrationAvailable() async { let entry = ProjectWorkspace.RepositoryEntry( id: "app", name: "App", path: "app", sourceKind: .existingPath, branchName: "metadata" ) let repository = makeWorkspaceRepository(id: "/tmp/ws-pr", children: [entry]) let childID = entry.resolvedURL(relativeTo: repository.rootURL).path(percentEncoded: false) let pullRequest = makePullRequest(state: "OPEN", headRefName: "feature/live", number: 42) var initialState = makeState(repositories: [repository]) initialState.githubIntegrationAvailability = .available struct PullRequestBatchCall: Equatable, Sendable { let host: String let owner: String let repo: String let branches: [String] } let calls = LockIsolated<[PullRequestBatchCall]>([]) let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.gitClient.branchName = { _ in "feature/live" } $0.gitClient.lineChanges = { _ in nil } $0.gitClient.remoteInfo = { url in #expect(url.path(percentEncoded: false) == childID) return GithubRemoteInfo(host: "github.com", owner: "onevcat", repo: "app") } $0.githubCLI.batchPullRequests = { host, owner, repo, branches, _, accountOverride in #expect(accountOverride == nil) calls.withValue { $0.append( PullRequestBatchCall(host: host, owner: owner, repo: repo, branches: branches)) } return ["feature/live": pullRequest] } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } } store.exhaustivity = .off
await store.send( .repositoriesLoaded([repository], failures: [], roots: [repository.rootURL], animated: false) ) await store.receive(\.workspaceChildrenInfoLoaded) await store.finish()
#expect(calls.value.count == 1) #expect(calls.value.first?.host == "github.com") #expect(calls.value.first?.owner == "onevcat") #expect(calls.value.first?.repo == "app") #expect(calls.value.first?.branches == ["feature/live"]) #expect(store.state.workspaceChildInfoByID[childID]?.pullRequest == pullRequest) }
private func makeWorktree( id: String, name: String, repoRoot: String = "/tmp/repo", createdAt: Date? = nil ) -> Worktree { Worktree( id: id, name: name, detail: "detail", workingDirectory: URL(fileURLWithPath: id), repositoryRootURL: URL(fileURLWithPath: repoRoot), createdAt: createdAt ) }
private func makePullRequest( state: String, headRefName: String? = nil, number: Int = 1, url: String? = nil ) -> GithubPullRequest { GithubPullRequest( number: number, title: "PR", state: state, additions: 0, deletions: 0, isDraft: false, reviewDecision: nil, mergeable: nil, mergeStateStatus: nil, updatedAt: nil, url: url ?? "https://example.com/pull/\(number)", headRefName: headRefName, baseRefName: "main", commitsCount: 1, authorLogin: "khoi", statusCheckRollup: nil ) }
private func makeRepository( id: String, name: String = "repo", kind: Repository.Kind = .git, worktrees: [Worktree], workspace: ProjectWorkspace? = nil ) -> Repository { Repository( id: id, rootURL: URL(fileURLWithPath: id), name: name, kind: kind, worktrees: IdentifiedArray(uniqueElements: worktrees), workspace: workspace ) }
private func makeState(repositories: [Repository]) -> RepositoriesFeature.State { var state = RepositoriesFeature.State() state.repositories = IdentifiedArray(uniqueElements: repositories) state.repositoryRoots = repositories.map(\.rootURL) return state }
private func makePullRequestURLRemoteInfoFixture(repoRoot: String) -> PullRequestURLRemoteInfoFixture { let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let featureWorktree = makeWorktree( id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) let pullRequest = makePullRequest( state: "OPEN", headRefName: featureWorktree.name, number: 456, url: "https://github.com/onevcat/Prowl/pull/456" ) var state = makeState(repositories: [repository]) state.githubIntegrationAvailability = .disabled state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( addedLines: nil, removedLines: nil, pullRequest: pullRequest ) return PullRequestURLRemoteInfoFixture( featureWorktree: featureWorktree, state: state, expectedRemoteInfo: GithubRemoteInfo(host: "github.com", owner: "onevcat", repo: "Prowl") ) }
private struct PullRequestURLRemoteInfoFixture { let featureWorktree: Worktree let state: RepositoriesFeature.State let expectedRemoteInfo: GithubRemoteInfo }
@Test func loadPersistedRepositoriesStartsFetchesConcurrentlyAndPreservesRootOrder() async { let testID = UUID().uuidString let repoRootA = "/tmp/\(testID)-repo-a" let repoRootB = "/tmp/\(testID)-repo-b" let worktreeA = makeWorktree(id: "\(repoRootA)/main", name: "main", repoRoot: repoRootA) let worktreeB = makeWorktree(id: "\(repoRootB)/main", name: "main", repoRoot: repoRootB) let repoA = makeRepository( id: repoRootA, name: URL(fileURLWithPath: repoRootA).lastPathComponent, worktrees: [worktreeA] ) let repoB = makeRepository( id: repoRootB, name: URL(fileURLWithPath: repoRootB).lastPathComponent, worktrees: [worktreeB] ) let clock = TestClock() let fetchStarted = AsyncGate() let startedRoots = LockIsolated<Set<String>>([])
let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() } withDependencies: { $0.repositoryPersistence.loadRoots = { [repoRootA, repoRootB] } $0.gitClient.worktrees = { root in let path = root.path(percentEncoded: false) _ = startedRoots.withValue { $0.insert(path) } await fetchStarted.resume() if path == repoRootA { try? await clock.sleep(for: .seconds(1)) return [worktreeA] } if path == repoRootB { return [worktreeB] } Issue.record("Unexpected root: \(path)") return [] } }
await store.send(.loadPersistedRepositories) await fetchStarted.wait() await clock.advance() #expect(startedRoots.value == [repoRootA, repoRootB]) await clock.advance(by: .seconds(1))
await store.receive(\.repositoriesLoaded) { $0.repositories = [repoA, repoB] $0.repositoryRoots = [repoRootA, repoRootB].map { URL(fileURLWithPath: $0) } $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } await store.receive(\.delegate.repositoriesChanged) await store.finish() }
@Test func loadPersistedRepositoriesRestoresLastFocusedSelectionAfterFullLoad() async { let testID = UUID().uuidString let repoRootA = "/tmp/\(testID)-repo-a" let repoRootB = "/tmp/\(testID)-repo-b" let worktreeA = makeWorktree(id: "\(repoRootA)/main", name: "main", repoRoot: repoRootA) let worktreeB = makeWorktree(id: "\(repoRootB)/main", name: "main", repoRoot: repoRootB) let repoA = makeRepository( id: repoRootA, name: URL(fileURLWithPath: repoRootA).lastPathComponent, worktrees: [worktreeA] ) let repoB = makeRepository( id: repoRootB, name: URL(fileURLWithPath: repoRootB).lastPathComponent, worktrees: [worktreeB] )
var state = RepositoriesFeature.State() state.lastFocusedWorktreeID = worktreeB.id state.shouldRestoreLastFocusedWorktree = true
let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.repositoryPersistence.loadRoots = { [repoRootA, repoRootB] } $0.gitClient.worktrees = { root in switch root.path(percentEncoded: false) { case repoRootA: return [worktreeA] case repoRootB: return [worktreeB] default: Issue.record("Unexpected root: \(root.path(percentEncoded: false))") return [] } } }
await store.send(.loadPersistedRepositories) await store.receive(\.repositoriesLoaded) { $0.repositories = [repoA, repoB] $0.repositoryRoots = [repoRootA, repoRootB].map { URL(fileURLWithPath: $0) } $0.selection = .worktree(worktreeB.id) $0.shouldRestoreLastFocusedWorktree = false $0.isInitialLoadComplete = true $0.snapshotPersistencePhase = .active } await store.receive(\.delegate.repositoriesChanged) await store.receive(\.delegate.selectedWorktreeChanged) await store.finish() }
@Test func cliOpenLaunchModeSkipsLastFocusedRestoreSelection() async { let testID = UUID().uuidString let repoRootA = "/tmp/\(testID)-repo-a" let repoRootB = "/tmp/\(testID)-repo-b" let worktreeA = makeWorktree(id: "\(repoRootA)/main", name: "main", repoRoot: repoRootA) let worktreeB = makeWorktree(id: "\(repoRootB)/main", name: "main", repoRoot: repoRootB) let repoA = makeRepository( id: repoRootA, name: URL(fileURLWithPath: repoRootA).lastPathComponent, worktrees: [worktreeA] ) let repoB = makeRepository( id: repoRootB, name: URL(fileURLWithPath: repoRootB).lastPathComponent, worktrees: [worktreeB] )
var state = RepositoriesFeature.State() state.launchRestoreMode = .cliOpenPath(repoRootA)
let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { $0.repositoryPersistence.loadLastFocusedWorktreeID = { worktreeB.id } $0.repositoryPersistence.loadRepositorySnapshot = { [repoA, repoB] } $0.repositoryPersistence.loadRoots = { [repoRootA, repoRootB] } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } $0.gitClient.worktrees = { root in switch root.path(percentEncoded: false) { case repoRootA: return [worktreeA] case repoRootB: return [worktreeB] default: return [] } } }
await store.send(.task) { $0.snapshotPersistencePhase = .restoring } await store.receive(\.pinnedWorktreeIDsLoaded) await store.receive(\.archivedWorktreesLoaded) await store.receive(\.repositoryOrderIDsLoaded) await store.receive(\.worktreeOrderByRepositoryLoaded) await store.receive(\.lastFocusedWorktreeIDLoaded) { $0.lastFocusedWorktreeID = worktreeB.id $0.shouldRestoreLastFocusedWorktree = false } await store.receive(\.repositorySnapshotLoaded) { $0.repositories = [repoA, repoB] $0.repositoryRoots = [repoRootA, repoRootB].map { URL(fileURLWithPath: $0) } $0.selection = nil $0.isInitialLoadComplete = true } await store.receive(\.delegate.repositoriesChanged) await store.receive(\.loadPersistedRepositories) await store.receive(\.repositoriesLoaded) { $0.snapshotPersistencePhase = .active $0.selection = nil $0.shouldRestoreLastFocusedWorktree = false } await store.receive(\.delegate.repositoriesChanged) await store.finish() }
private actor AsyncGate { var continuation: CheckedContinuation<Void, Never>? var isOpen = false
func wait() async { guard !isOpen else { return } await withCheckedContinuation { continuation in self.continuation = continuation } }
func resume() { if let continuation { continuation.resume() self.continuation = nil } else { isOpen = true } } }}
// The collision-free base path seeded synchronously by `.promptRequested`// before the unique-path effect resolves (no filesystem lookup).func defaultWorkspaceBaseRootPath(for title: String) -> String { let folderName = ProjectWorkspace.defaultWorkspaceFolderName(for: title) return ProwlPaths.workspacesDirectory .appending(path: folderName, directoryHint: .isDirectory) .standardizedFileURL .path(percentEncoded: false)}
func expectedDefaultWorkspaceRootPath(for title: String) -> String { let folderName = ProjectWorkspace.defaultWorkspaceFolderName(for: title) return ProjectWorkspace.uniqueWorkspaceRootPath(folderName: folderName)}