test_porcelain.py 292 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314731573167317731873197320732173227323732473257326732773287329733073317332733373347335733673377338733973407341734273437344734573467347734873497350735173527353735473557356735773587359736073617362736373647365736673677368736973707371737273737374737573767377737873797380738173827383738473857386738773887389739073917392739373947395739673977398739974007401740274037404740574067407740874097410741174127413741474157416741774187419742074217422742374247425742674277428742974307431743274337434743574367437743874397440744174427443744474457446744774487449745074517452745374547455745674577458745974607461746274637464746574667467746874697470747174727473747474757476747774787479748074817482748374847485748674877488748974907491749274937494749574967497749874997500750175027503750475057506750775087509751075117512751375147515751675177518751975207521752275237524752575267527752875297530753175327533753475357536753775387539754075417542754375447545754675477548754975507551755275537554755575567557755875597560756175627563756475657566756775687569757075717572757375747575757675777578757975807581758275837584758575867587758875897590759175927593759475957596759775987599760076017602760376047605760676077608760976107611761276137614761576167617761876197620762176227623762476257626762776287629763076317632763376347635763676377638763976407641764276437644764576467647764876497650765176527653765476557656765776587659766076617662766376647665766676677668766976707671767276737674767576767677767876797680768176827683768476857686768776887689769076917692769376947695769676977698769977007701770277037704770577067707770877097710771177127713771477157716771777187719772077217722772377247725772677277728772977307731773277337734773577367737773877397740774177427743774477457746774777487749775077517752775377547755775677577758775977607761776277637764776577667767776877697770777177727773777477757776777777787779778077817782778377847785778677877788778977907791779277937794779577967797779877997800780178027803780478057806780778087809781078117812781378147815781678177818781978207821782278237824782578267827782878297830783178327833783478357836783778387839784078417842784378447845784678477848784978507851785278537854785578567857785878597860786178627863786478657866786778687869787078717872787378747875787678777878787978807881788278837884788578867887788878897890789178927893789478957896789778987899790079017902790379047905790679077908790979107911791279137914791579167917791879197920792179227923792479257926792779287929793079317932793379347935793679377938793979407941794279437944794579467947794879497950795179527953795479557956795779587959796079617962796379647965796679677968796979707971797279737974797579767977797879797980798179827983798479857986798779887989799079917992799379947995799679977998799980008001800280038004800580068007800880098010801180128013801480158016801780188019802080218022802380248025802680278028802980308031803280338034803580368037803880398040804180428043804480458046804780488049805080518052805380548055805680578058805980608061806280638064806580668067806880698070807180728073
  1. # test_porcelain.py -- porcelain tests
  2. # Copyright (C) 2013 Jelmer Vernooij <jelmer@jelmer.uk>
  3. #
  4. # SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
  5. # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
  6. # General Public License as published by the Free Software Foundation; version 2.0
  7. # or (at your option) any later version. You can redistribute it and/or
  8. # modify it under the terms of either of these two licenses.
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. #
  16. # You should have received a copy of the licenses; if not, see
  17. # <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
  18. # and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
  19. # License, Version 2.0.
  20. #
  21. """Tests for dulwich.porcelain."""
  22. import contextlib
  23. import os
  24. import platform
  25. import re
  26. import shutil
  27. import stat
  28. import subprocess
  29. import sys
  30. import tarfile
  31. import tempfile
  32. import threading
  33. import time
  34. from io import BytesIO, StringIO
  35. from unittest import skipIf
  36. from dulwich import porcelain
  37. from dulwich.client import SendPackResult
  38. from dulwich.diff_tree import tree_changes
  39. from dulwich.errors import CommitError
  40. from dulwich.object_store import DEFAULT_TEMPFILE_GRACE_PERIOD
  41. from dulwich.objects import ZERO_SHA, Blob, Commit, Tag, Tree
  42. from dulwich.porcelain import (
  43. CheckoutError, # Hypothetical or real error class
  44. CountObjectsResult,
  45. add,
  46. commit,
  47. )
  48. from dulwich.repo import NoIndexPresent, Repo
  49. from dulwich.server import DictBackend
  50. from dulwich.tests.utils import build_commit_graph, make_commit, make_object
  51. from dulwich.web import make_server, make_wsgi_chain
  52. from . import TestCase
  53. try:
  54. import gpg
  55. except ImportError:
  56. gpg = None
  57. def flat_walk_dir(dir_to_walk):
  58. for dirpath, _, filenames in os.walk(dir_to_walk):
  59. rel_dirpath = os.path.relpath(dirpath, dir_to_walk)
  60. if not dirpath == dir_to_walk:
  61. yield rel_dirpath
  62. for filename in filenames:
  63. if dirpath == dir_to_walk:
  64. yield filename
  65. else:
  66. yield os.path.join(rel_dirpath, filename)
  67. class PorcelainTestCase(TestCase):
  68. def setUp(self) -> None:
  69. super().setUp()
  70. self.test_dir = tempfile.mkdtemp()
  71. self.addCleanup(shutil.rmtree, self.test_dir)
  72. self.repo_path = os.path.join(self.test_dir, "repo")
  73. self.repo = Repo.init(self.repo_path, mkdir=True)
  74. self.addCleanup(self.repo.close)
  75. def assertRecentTimestamp(self, ts) -> None:
  76. # On some slow CIs it does actually take more than 5 seconds to go from
  77. # creating the tag to here.
  78. self.assertLess(time.time() - ts, 50)
  79. @skipIf(gpg is None, "gpg is not available")
  80. class PorcelainGpgTestCase(PorcelainTestCase):
  81. DEFAULT_KEY = """
  82. -----BEGIN PGP PRIVATE KEY BLOCK-----
  83. lQVYBGBjIyIBDADAwydvMPQqeEiK54FG1DHwT5sQejAaJOb+PsOhVa4fLcKsrO3F
  84. g5CxO+/9BHCXAr8xQAtp/gOhDN05fyK3MFyGlL9s+Cd8xf34S3R4rN/qbF0oZmaa
  85. FW0MuGnniq54HINs8KshadVn1Dhi/GYSJ588qNFRl/qxFTYAk+zaGsgX/QgFfy0f
  86. djWXJLypZXu9D6DlyJ0cPSzUlfBkI2Ytx6grzIquRjY0FbkjK3l+iGsQ+ebRMdcP
  87. Sqd5iTN9XuzIUVoBFAZBRjibKV3N2wxlnCbfLlzCyDp7rktzSThzjJ2pVDuLrMAx
  88. 6/L9hIhwmFwdtY4FBFGvMR0b0Ugh3kCsRWr8sgj9I7dUoLHid6ObYhJFhnD3GzRc
  89. U+xX1uy3iTCqJDsG334aQIhC5Giuxln4SUZna2MNbq65ksh38N1aM/t3+Dc/TKVB
  90. rb5KWicRPCQ4DIQkHMDCSPyj+dvRLCPzIaPvHD7IrCfHYHOWuvvPGCpwjo0As3iP
  91. IecoMeguPLVaqgcAEQEAAQAL/i5/pQaUd4G7LDydpbixPS6r9UrfPrU/y5zvBP/p
  92. DCynPDutJ1oq539pZvXQ2VwEJJy7x0UVKkjyMndJLNWly9wHC7o8jkHx/NalVP47
  93. LXR+GWbCdOOcYYbdAWcCNB3zOtzPnWhdAEagkc2G9xRQDIB0dLHLCIUpCbLP/CWM
  94. qlHnDsVMrVTWjgzcpsnyGgw8NeLYJtYGB8dsN+XgCCjo7a9LEvUBKNgdmWBbf14/
  95. iBw7PCugazFcH9QYfZwzhsi3nqRRagTXHbxFRG0LD9Ro9qCEutHYGP2PJ59Nj8+M
  96. zaVkJj/OxWxVOGvn2q16mQBCjKpbWfqXZVVl+G5DGOmiSTZqXy+3j6JCKdOMy6Qd
  97. JBHOHhFZXYmWYaaPzoc33T/C3QhMfY5sOtUDLJmV05Wi4dyBeNBEslYgUuTk/jXb
  98. 5ZAie25eDdrsoqkcnSs2ZguMF7AXhe6il2zVhUUMs/6UZgd6I7I4Is0HXT/pnxEp
  99. uiTRFu4v8E+u+5a8O3pffe5boQYA3TsIxceen20qY+kRaTOkURHMZLn/y6KLW8bZ
  100. rNJyXWS9hBAcbbSGhfOwYfzbDCM17yPQO3E2zo8lcGdRklUdIIaCxQwtu36N5dfx
  101. OLCCQc5LmYdl/EAm91iAhrr7dNntZ18MU09gdzUu+ONZwu4CP3cJT83+qYZULso8
  102. 4Fvd/X8IEfGZ7kM+ylrdqBwtlrn8yYXtom+ows2M2UuNR53B+BUOd73kVLTkTCjE
  103. JH63+nE8BqG7tDLCMws+23SAA3xxBgDfDrr0x7zCozQKVQEqBzQr9Uoo/c/ZjAfi
  104. syzNSrDz+g5gqJYtuL9XpPJVWf6V1GXVyJlSbxR9CjTkBxmlPxpvV25IsbVSsh0o
  105. aqkf2eWpbCL6Qb2E0jd1rvf8sGeTTohzYfiSVVsC2t9ngRO/CmetizwQBvRzLGMZ
  106. 4mtAPiy7ZEDc2dFrPp7zlKISYmJZUx/DJVuZWuOrVMpBP+bSgJXoMTlICxZUqUnE
  107. 2VKVStb/L+Tl8XCwIWdrZb9BaDnHqfcGAM2B4HNPxP88Yj1tEDly/vqeb3vVMhj+
  108. S1lunnLdgxp46YyuTMYAzj88eCGurRtzBsdxxlGAsioEnZGebEqAHQbieKq/DO6I
  109. MOMZHMSVBDqyyIx3assGlxSX8BSFW0lhKyT7i0XqnAgCJ9f/5oq0SbFGq+01VQb7
  110. jIx9PbcYJORxsE0JG/CXXPv27bRtQXsudkWGSYvC0NLOgk4z8+kQpQtyFh16lujq
  111. WRwMeriu0qNDjCa1/eHIKDovhAZ3GyO5/9m1tBlUZXN0IFVzZXIgPHRlc3RAdGVz
  112. dC5jb20+iQHOBBMBCAA4AhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAFiEEjrR8
  113. MQ4fJK44PYMvfN2AClLmXiYFAmDcEZEACgkQfN2AClLmXibZzgv/ZfeTpTuqQE1W
  114. C1jT5KpQExnt0BizTX0U7BvSn8Fr6VXTyol6kYc3u71GLUuJyawCLtIzOXqOXJvz
  115. bjcZqymcMADuftKcfMy513FhbF6MhdVd6QoeBP6+7/xXOFJCi+QVYF7SQ2h7K1Qm
  116. +yXOiAMgSxhCZQGPBNJLlDUOd47nSIMANvlumFtmLY/1FD7RpG7WQWjeX1mnxNTw
  117. hUU+Yv7GuFc/JprXCIYqHbhWfvXyVtae2ZK4xuVi5eqwA2RfggOVM7drb+CgPhG0
  118. +9aEDDLOZqVi65wK7J73Puo3rFTbPQMljxw5s27rWqF+vB6hhVdJOPNomWy3naPi
  119. k5MW0mhsacASz1WYndpZz+XaQTq/wJF5HUyyeUWJ0vlOEdwx021PHcqSTyfNnkjD
  120. KncrE21t2sxWRsgGDETxIwkd2b2HNGAvveUD0ffFK/oJHGSXjAERFGc3wuiDj3mQ
  121. BvKm4wt4QF9ZMrCdhMAA6ax5kfEUqQR4ntmrJk/khp/mV7TILaI4nQVYBGBjIyIB
  122. DADghIo9wXnRxzfdDTvwnP8dHpLAIaPokgdpyLswqUCixJWiW2xcV6weUjEWwH6n
  123. eN/t1uZYVehbrotxVPla+MPvzhxp6/cmG+2lhzEBOp6zRwnL1wIB6HoKJfpREhyM
  124. c8rLR0zMso1L1bJTyydvnu07a7BWo3VWKjilb0rEZZUSD/2hidx5HxMOJSoidLWe
  125. d/PPuv6yht3NtA4UThlcfldm9G6PbqCdm1kMEKAkq0wVJvhPJ6gEFRNJimgygfUw
  126. MDFXEIhQtxjgdV5Uoz3O5452VLoRsDlgpi3E0WDGj7WXDaO5uSU0T5aJgVgHCP/f
  127. xZhHuQFk2YYIl5nCBpOZyWWI0IKmscTuEwzpkhICQDQFvcMZ5ibsl7wA2P7YTrQf
  128. FDMjjzuaK80GYPfxDFlyKUyLqFt8w/QzsZLDLX7+jxIEpbRAaMw/JsWqm5BMxxbS
  129. 3CIQiS5S3oSKDsNINelqWFfwvLhvlQra8gIxyNTlek25OdgG66BiiX+seH8A/ql+
  130. F+MAEQEAAQAL/1jrNSLjMt9pwo6qFKClVQZP2vf7+sH7v7LeHIDXr3EnYUnVYnOq
  131. B1FU5PspTp/+J9W25DB9CZLx7Gj8qeslFdiuLSOoIBB4RCToB3kAoeTH0DHqW/Gs
  132. hFTrmJkuDp9zpo/ek6SIXJx5rHAyR9KVw0fizQprH2f6PcgLbTWeM61dJuqowmg3
  133. 7eCOyIKv7VQvFqEhYokLD+JNmrvg+Htg0DXGvdjRjAwPf/NezEXpj67a6cHTp1/C
  134. hwp7pevG+3fTxaCJFesl5/TxxtnaBLE8m2uo/S6Hxgn9l0edonroe1QlTjEqGLy2
  135. 7qi2z5Rem+v6GWNDRgvAWur13v8FNdyduHlioG/NgRsU9mE2MYeFsfi3cfNpJQp/
  136. wC9PSCIXrb/45mkS8KyjZpCrIPB9RV/m0MREq01TPom7rstZc4A1pD0Ot7AtUYS3
  137. e95zLyEmeLziPJ9fV4fgPmEudDr1uItnmV0LOskKlpg5sc0hhdrwYoobfkKt2dx6
  138. DqfMlcM1ZkUbLQYA4jwfpFJG4HmYvjL2xCJxM0ycjvMbqFN+4UjgYWVlRfOrm1V4
  139. Op86FjbRbV6OOCNhznotAg7mul4xtzrrTkK8o3YLBeJseDgl4AWuzXtNa9hE0XpK
  140. 9gJoEHUuBOOsamVh2HpXESFyE5CclOV7JSh541TlZKfnqfZYCg4JSbp0UijkawCL
  141. 5bJJUiGGMD9rZUxIAKQO1DvUEzptS7Jl6S3y5sbIIhilp4KfYWbSk3PPu9CnZD5b
  142. LhEQp0elxnb/IL8PBgD+DpTeC8unkGKXUpbe9x0ISI6V1D6FmJq/FxNg7fMa3QCh
  143. fGiAyoTm80ZETynj+blRaDO3gY4lTLa3Opubof1EqK2QmwXmpyvXEZNYcQfQ2CCS
  144. GOWUCK8jEQamUPf1PWndZXJUmROI1WukhlL71V/ir6zQeVCv1wcwPwclJPnAe87u
  145. pEklnCYpvsEldwHUX9u0BWzoULIEsi+ddtHmT0KTeF/DHRy0W15jIHbjFqhqckj1
  146. /6fmr7l7kIi/kN4vWe0F/0Q8IXX+cVMgbl3aIuaGcvENLGcoAsAtPGx88SfRgmfu
  147. HK64Y7hx1m+Bo215rxJzZRjqHTBPp0BmCi+JKkaavIBrYRbsx20gveI4dzhLcUhB
  148. kiT4Q7oz0/VbGHS1CEf9KFeS/YOGj57s4yHauSVI0XdP9kBRTWmXvBkzsooB2cKH
  149. hwhUN7iiT1k717CiTNUT6Q/pcPFCyNuMoBBGQTU206JEgIjQvI3f8xMUMGmGVVQz
  150. 9/k716ycnhb2JZ/Q/AyQIeHJiQG2BBgBCAAgAhsMFiEEjrR8MQ4fJK44PYMvfN2A
  151. ClLmXiYFAmDcEa4ACgkQfN2AClLmXiZxxQv/XaMN0hPCygtrQMbCsTNb34JbvJzh
  152. hngPuUAfTbRHrR3YeATyQofNbL0DD3fvfzeFF8qESqvzCSZxS6dYsXPd4MCJTzlp
  153. zYBZ2X0sOrgDqZvqCZKN72RKgdk0KvthdzAxsIm2dfcQOxxowXMxhJEXZmsFpusx
  154. jKJxOcrfVRjXJnh9isY0NpCoqMQ+3k3wDJ3VGEHV7G+A+vFkWfbLJF5huQ96uaH9
  155. Uc+jUsREUH9G82ZBqpoioEN8Ith4VXpYnKdTMonK/+ZcyeraJZhXrvbjnEomKdzU
  156. 0pu4bt1HlLR3dcnpjN7b009MBf2xLgEfQk2nPZ4zzY+tDkxygtPllaB4dldFjBpT
  157. j7Q+t49sWMjmlJUbLlHfuJ7nUUK5+cGjBsWVObAEcyfemHWCTVFnEa2BJslGC08X
  158. rFcjRRcMEr9ct4551QFBHsv3O/Wp3/wqczYgE9itSnGT05w+4vLt4smG+dnEHjRJ
  159. brMb2upTHa+kjktjdO96/BgSnKYqmNmPB/qB
  160. =ivA/
  161. -----END PGP PRIVATE KEY BLOCK-----
  162. """
  163. DEFAULT_KEY_ID = "8EB47C310E1F24AE383D832F7CDD800A52E65E26"
  164. NON_DEFAULT_KEY = """
  165. -----BEGIN PGP PRIVATE KEY BLOCK-----
  166. lQVYBGBjI0ABDADGWBRp+t02emfzUlhrc1psqIhhecFm6Em0Kv33cfDpnfoMF1tK
  167. Yy/4eLYIR7FmpdbFPcDThFNHbXJzBi00L1mp0XQE2l50h/2bDAAgREdZ+NVo5a7/
  168. RSZjauNU1PxW6pnXMehEh1tyIQmV78jAukaakwaicrpIenMiFUN3fAKHnLuFffA6
  169. t0f3LqJvTDhUw/o2vPgw5e6UDQhA1C+KTv1KXVrhJNo88a3hZqCZ76z3drKR411Q
  170. zYgT4DUb8lfnbN+z2wfqT9oM5cegh2k86/mxAA3BYOeQrhmQo/7uhezcgbxtdGZr
  171. YlbuaNDTSBrn10ZoaxLPo2dJe2zWxgD6MpvsGU1w3tcRW508qo/+xoWp2/pDzmok
  172. +uhOh1NAj9zB05VWBz1r7oBgCOIKpkD/LD4VKq59etsZ/UnrYDwKdXWZp7uhshkU
  173. M7N35lUJcR76a852dlMdrgpmY18+BP7+o7M+5ElHTiqQbMuE1nHTg8RgVpdV+tUx
  174. dg6GWY/XHf5asm8AEQEAAQAL/A85epOp+GnymmEQfI3+5D178D//Lwu9n86vECB6
  175. xAHCqQtdjZnXpDp/1YUsL59P8nzgYRk7SoMskQDoQ/cB/XFuDOhEdMSgHaTVlnrj
  176. ktCCq6rqGnUosyolbb64vIfVaSqd/5SnCStpAsnaBoBYrAu4ZmV4xfjDQWwn0q5s
  177. u+r56mD0SkjPgbwk/b3qTVagVmf2OFzUgWwm1e/X+bA1oPag1NV8VS4hZPXswT4f
  178. qhiyqUFOgP6vUBcqehkjkIDIl/54xII7/P5tp3LIZawvIXqHKNTqYPCqaCqCj+SL
  179. vMYDIb6acjescfZoM71eAeHAANeFZzr/rwfBT+dEP6qKmPXNcvgE11X44ZCr04nT
  180. zOV/uDUifEvKT5qgtyJpSFEVr7EXubJPKoNNhoYqq9z1pYU7IedX5BloiVXKOKTY
  181. 0pk7JkLqf3g5fYtXh/wol1owemITJy5V5PgaqZvk491LkI6S+kWC7ANYUg+TDPIW
  182. afxW3E5N1CYV6XDAl0ZihbLcoQYAy0Ky/p/wayWKePyuPBLwx9O89GSONK2pQljZ
  183. yaAgxPQ5/i1vx6LIMg7k/722bXR9W3zOjWOin4eatPM3d2hkG96HFvnBqXSmXOPV
  184. 03Xqy1/B5Tj8E9naLKUHE/OBQEc363DgLLG9db5HfPlpAngeppYPdyWkhzXyzkgS
  185. PylaE5eW3zkdjEbYJ6RBTecTZEgBaMvJNPdWbn//frpP7kGvyiCg5Es+WjLInUZ6
  186. 0sdifcNTCewzLXK80v/y5mVOdJhPBgD5zs9cYdyiQJayqAuOr+He1eMHMVUbm9as
  187. qBmPrst398eBW9ZYF7eBfTSlUf6B+WnvyLKEGsUf/7IK0EWDlzoBuWzWiHjUAY1g
  188. m9eTV2MnvCCCefqCErWwfFo2nWOasAZA9sKD+ICIBY4tbtvSl4yfLBzTMwSvs9ZS
  189. K1ocPSYUnhm2miSWZ8RLZPH7roHQasNHpyq/AX7DahFf2S/bJ+46ZGZ8Pigr7hA+
  190. MjmpQ4qVdb5SaViPmZhAKO+PjuCHm+EF/2H0Y3Sl4eXgxZWoQVOUeXdWg9eMfYrj
  191. XDtUMIFppV/QxbeztZKvJdfk64vt/crvLsOp0hOky9cKwY89r4QaHfexU3qR+qDq
  192. UlMvR1rHk7dS5HZAtw0xKsFJNkuDxvBkMqv8Los8zp3nUl+U99dfZOArzNkW38wx
  193. FPa0ixkC9za2BkDrWEA8vTnxw0A2upIFegDUhwOByrSyfPPnG3tKGeqt3Izb/kDk
  194. Q9vmo+HgxBOguMIvlzbBfQZwtbd/gXzlvPqCtCJBbm90aGVyIFRlc3QgVXNlciA8
  195. dGVzdDJAdGVzdC5jb20+iQHOBBMBCAA4AhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4B
  196. AheAFiEEapM5P1DF5qzT1vtFuTYhLttOFMAFAmDcEeEACgkQuTYhLttOFMDe0Qv/
  197. Qx/bzXztJ3BCc+CYAVDx7Kr37S68etwwLgcWzhG+CDeMB5F/QE+upKgxy2iaqQFR
  198. mxfOMgf/TIQkUfkbaASzK1LpnesYO85pk7XYjoN1bYEHiXTkeW+bgB6aJIxrRmO2
  199. SrWasdBC/DsI3Mrya8YMt/TiHC6VpRJVxCe5vv7/kZC4CXrgTBnZocXx/YXimbke
  200. poPMVdbvhYh6N0aGeS38jRKgyN10KXmhDTAQDwseVFavBWAjVfx3DEwjtK2Z2GbA
  201. aL8JvAwRtqiPFkDMIKPL4UwxtXFws8SpMt6juroUkNyf6+BxNWYqmwXHPy8zCJAb
  202. xkxIJMlEc+s7qQsP3fILOo8Xn+dVzJ5sa5AoARoXm1GMjsdqaKAzq99Dic/dHnaQ
  203. Civev1PQsdwlYW2C2wNXNeIrxMndbDMFfNuZ6BnGHWJ/wjcp/pFs4YkyyZN8JH7L
  204. hP2FO4Jgham3AuP13kC3Ivea7V6hR8QNcDZRwFPOMIX4tXwQv1T72+7DZGaA25O7
  205. nQVXBGBjI0ABDADJMBYIcG0Yil9YxFs7aYzNbd7alUAr89VbY8eIGPHP3INFPM1w
  206. lBQCu+4j6xdEbhMpppLBZ9A5TEylP4C6qLtPa+oLtPeuSw8gHDE10XE4lbgPs376
  207. rL60XdImSOHhiduACUefYjqpcmFH9Bim1CC+koArYrSQJQx1Jri+OpnTaL/8UID0
  208. KzD/kEgMVGlHIVj9oJmb4+j9pW8I/g0wDSnIaEKFMxqu6SIVJ1GWj+MUMvZigjLC
  209. sNCZd7PnbOC5VeU3SsXj6he74Jx0AmGMPWIHi9M0DjHO5d1cCbXTnud8xxM1bOh4
  210. 7aCTnMK5cVyIr+adihgJpVVhrndSM8aklBPRgtozrGNCgF2CkYU2P1blxfloNr/8
  211. UZpM83o+s1aObBszzRNLxnpNORqoLqjfPtLEPQnagxE+4EapCq0NZ/x6yO5VTwwp
  212. NljdFAEk40uGuKyn1QA3uNMHy5DlpLl+tU7t1KEovdZ+OVYsYKZhVzw0MTpKogk9
  213. JI7AN0q62ronPskAEQEAAQAL+O8BUSt1ZCVjPSIXIsrR+ZOSkszZwgJ1CWIoh0IH
  214. YD2vmcMHGIhFYgBdgerpvhptKhaw7GcXDScEnYkyh5s4GE2hxclik1tbj/x1gYCN
  215. 8BNoyeDdPFxQG73qN12D99QYEctpOsz9xPLIDwmL0j1ehAfhwqHIAPm9Ca+i8JYM
  216. x/F+35S/jnKDXRI+NVlwbiEyXKXxxIqNlpy9i8sDBGexO5H5Sg0zSN/B1duLekGD
  217. biDw6gLc6bCgnS+0JOUpU07Z2fccMOY9ncjKGD2uIb/ePPUaek92GCQyq0eorCIV
  218. brcQsRc5sSsNtnRKQTQtxioROeDg7kf2oWySeHTswlXW/219ihrSXgteHJd+rPm7
  219. DYLEeGLRny8bRKv8rQdAtApHaJE4dAATXeY4RYo4NlXHYaztGYtU6kiM/3zCfWAe
  220. 9Nn+Wh9jMTZrjefUCagS5r6ZqAh7veNo/vgIGaCLh0a1Ypa0Yk9KFrn3LYEM3zgk
  221. 3m3bn+7qgy5cUYXoJ3DGJJEhBgDPonpW0WElqLs5ZMem1ha85SC38F0IkAaSuzuz
  222. v3eORiKWuyJGF32Q2XHa1RHQs1JtUKd8rxFer3b8Oq71zLz6JtVc9dmRudvgcJYX
  223. 0PC11F6WGjZFSSp39dajFp0A5DKUs39F3w7J1yuDM56TDIN810ywufGAHARY1pZb
  224. UJAy/dTqjFnCbNjpAakor3hVzqxcmUG+7Y2X9c2AGncT1MqAQC3M8JZcuZvkK8A9
  225. cMk8B914ryYE7VsZMdMhyTwHmykGAPgNLLa3RDETeGeGCKWI+ZPOoU0ib5JtJZ1d
  226. P3tNwfZKuZBZXKW9gqYqyBa/qhMip84SP30pr/TvulcdAFC759HK8sQZyJ6Vw24P
  227. c+5ssRxrQUEw1rvJPWhmQCmCOZHBMQl5T6eaTOpR5u3aUKTMlxPKhK9eC1dCSTnI
  228. /nyL8An3VKnLy+K/LI42YGphBVLLJmBewuTVDIJviWRdntiG8dElyEJMOywUltk3
  229. 2CEmqgsD9tPO8rXZjnMrMn3gfsiaoQYA6/6/e2utkHr7gAoWBgrBBdqVHsvqh5Ro
  230. 2DjLAOpZItO/EdCJfDAmbTYOa04535sBDP2tcH/vipPOPpbr1Y9Y/mNsKCulNxed
  231. yqAmEkKOcerLUP5UHju0AB6VBjHJFdU2mqT+UjPyBk7WeKXgFomyoYMv3KpNOFWR
  232. xi0Xji4kKHbttA6Hy3UcGPr9acyUAlDYeKmxbSUYIPhw32bbGrX9+F5YriTufRsG
  233. 3jftQVo9zqdcQSD/5pUTMn3EYbEcohYB2YWJAbYEGAEIACACGwwWIQRqkzk/UMXm
  234. rNPW+0W5NiEu204UwAUCYNwR6wAKCRC5NiEu204UwOPnC/92PgB1c3h9FBXH1maz
  235. g29fndHIHH65VLgqMiQ7HAMojwRlT5Xnj5tdkCBmszRkv5vMvdJRa3ZY8Ed/Inqr
  236. hxBFNzpjqX4oj/RYIQLKXWWfkTKYVLJFZFPCSo00jesw2gieu3Ke/Yy4gwhtNodA
  237. v+s6QNMvffTW/K3XNrWDB0E7/LXbdidzhm+MBu8ov2tuC3tp9liLICiE1jv/2xT4
  238. CNSO6yphmk1/1zEYHS/mN9qJ2csBmte2cdmGyOcuVEHk3pyINNMDOamaURBJGRwF
  239. XB5V7gTKUFU4jCp3chywKrBHJHxGGDUmPBmZtDtfWAOgL32drK7/KUyzZL/WO7Fj
  240. akOI0hRDFOcqTYWL20H7+hAiX3oHMP7eou3L5C7wJ9+JMcACklN/WMjG9a536DFJ
  241. 4UgZ6HyKPP+wy837Hbe8b25kNMBwFgiaLR0lcgzxj7NyQWjVCMOEN+M55tRCjvL6
  242. ya6JVZCRbMXfdCy8lVPgtNQ6VlHaj8Wvnn2FLbWWO2n2r3s=
  243. =9zU5
  244. -----END PGP PRIVATE KEY BLOCK-----
  245. """
  246. NON_DEFAULT_KEY_ID = "6A93393F50C5E6ACD3D6FB45B936212EDB4E14C0"
  247. def setUp(self) -> None:
  248. super().setUp()
  249. self.gpg_dir = os.path.join(self.test_dir, "gpg")
  250. os.mkdir(self.gpg_dir, mode=0o700)
  251. # Ignore errors when deleting GNUPGHOME, because of race conditions
  252. # (e.g. the gpg-agent socket having been deleted). See
  253. # https://github.com/jelmer/dulwich/issues/1000
  254. self.addCleanup(shutil.rmtree, self.gpg_dir, ignore_errors=True)
  255. self.overrideEnv("GNUPGHOME", self.gpg_dir)
  256. def import_default_key(self) -> None:
  257. subprocess.run(
  258. ["gpg", "--import"],
  259. stdout=subprocess.DEVNULL,
  260. stderr=subprocess.DEVNULL,
  261. input=PorcelainGpgTestCase.DEFAULT_KEY,
  262. text=True,
  263. )
  264. def import_non_default_key(self) -> None:
  265. subprocess.run(
  266. ["gpg", "--import"],
  267. stdout=subprocess.DEVNULL,
  268. stderr=subprocess.DEVNULL,
  269. input=PorcelainGpgTestCase.NON_DEFAULT_KEY,
  270. text=True,
  271. )
  272. class ArchiveTests(PorcelainTestCase):
  273. """Tests for the archive command."""
  274. def test_simple(self) -> None:
  275. c1, c2, c3 = build_commit_graph(
  276. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  277. )
  278. self.repo.refs[b"refs/heads/master"] = c3.id
  279. out = BytesIO()
  280. err = BytesIO()
  281. porcelain.archive(
  282. self.repo.path, b"refs/heads/master", outstream=out, errstream=err
  283. )
  284. self.assertEqual(b"", err.getvalue())
  285. tf = tarfile.TarFile(fileobj=out)
  286. self.addCleanup(tf.close)
  287. self.assertEqual([], tf.getnames())
  288. class UpdateServerInfoTests(PorcelainTestCase):
  289. def test_simple(self) -> None:
  290. c1, c2, c3 = build_commit_graph(
  291. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  292. )
  293. self.repo.refs[b"refs/heads/foo"] = c3.id
  294. porcelain.update_server_info(self.repo.path)
  295. self.assertTrue(
  296. os.path.exists(os.path.join(self.repo.controldir(), "info", "refs"))
  297. )
  298. class CommitTests(PorcelainTestCase):
  299. def test_custom_author(self) -> None:
  300. c1, c2, c3 = build_commit_graph(
  301. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  302. )
  303. self.repo.refs[b"refs/heads/foo"] = c3.id
  304. sha = porcelain.commit(
  305. self.repo.path,
  306. message=b"Some message",
  307. author=b"Joe <joe@example.com>",
  308. committer=b"Bob <bob@example.com>",
  309. )
  310. self.assertIsInstance(sha, bytes)
  311. self.assertEqual(len(sha), 40)
  312. def test_unicode(self) -> None:
  313. c1, c2, c3 = build_commit_graph(
  314. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  315. )
  316. self.repo.refs[b"refs/heads/foo"] = c3.id
  317. sha = porcelain.commit(
  318. self.repo.path,
  319. message="Some message",
  320. author="Joe <joe@example.com>",
  321. committer="Bob <bob@example.com>",
  322. )
  323. self.assertIsInstance(sha, bytes)
  324. self.assertEqual(len(sha), 40)
  325. def test_no_verify(self) -> None:
  326. if os.name != "posix":
  327. self.skipTest("shell hook tests requires POSIX shell")
  328. self.assertTrue(os.path.exists("/bin/sh"))
  329. hooks_dir = os.path.join(self.repo.controldir(), "hooks")
  330. os.makedirs(hooks_dir, exist_ok=True)
  331. self.addCleanup(shutil.rmtree, hooks_dir)
  332. c1, c2, c3 = build_commit_graph(
  333. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  334. )
  335. hook_fail = "#!/bin/sh\nexit 1"
  336. # hooks are executed in pre-commit, commit-msg order
  337. # test commit-msg failure first, then pre-commit failure, then
  338. # no_verify to skip both hooks
  339. commit_msg = os.path.join(hooks_dir, "commit-msg")
  340. with open(commit_msg, "w") as f:
  341. f.write(hook_fail)
  342. os.chmod(commit_msg, stat.S_IREAD | stat.S_IWRITE | stat.S_IEXEC)
  343. with self.assertRaises(CommitError):
  344. porcelain.commit(
  345. self.repo.path,
  346. message="Some message",
  347. author="Joe <joe@example.com>",
  348. committer="Bob <bob@example.com>",
  349. )
  350. pre_commit = os.path.join(hooks_dir, "pre-commit")
  351. with open(pre_commit, "w") as f:
  352. f.write(hook_fail)
  353. os.chmod(pre_commit, stat.S_IREAD | stat.S_IWRITE | stat.S_IEXEC)
  354. with self.assertRaises(CommitError):
  355. porcelain.commit(
  356. self.repo.path,
  357. message="Some message",
  358. author="Joe <joe@example.com>",
  359. committer="Bob <bob@example.com>",
  360. )
  361. sha = porcelain.commit(
  362. self.repo.path,
  363. message="Some message",
  364. author="Joe <joe@example.com>",
  365. committer="Bob <bob@example.com>",
  366. no_verify=True,
  367. )
  368. self.assertIsInstance(sha, bytes)
  369. self.assertEqual(len(sha), 40)
  370. def test_timezone(self) -> None:
  371. c1, c2, c3 = build_commit_graph(
  372. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  373. )
  374. self.repo.refs[b"refs/heads/foo"] = c3.id
  375. sha = porcelain.commit(
  376. self.repo.path,
  377. message="Some message",
  378. author="Joe <joe@example.com>",
  379. author_timezone=18000,
  380. committer="Bob <bob@example.com>",
  381. commit_timezone=18000,
  382. )
  383. self.assertIsInstance(sha, bytes)
  384. self.assertEqual(len(sha), 40)
  385. commit = self.repo.get_object(sha)
  386. assert isinstance(commit, Commit)
  387. self.assertEqual(commit._author_timezone, 18000)
  388. self.assertEqual(commit._commit_timezone, 18000)
  389. self.overrideEnv("GIT_AUTHOR_DATE", "1995-11-20T19:12:08-0501")
  390. self.overrideEnv("GIT_COMMITTER_DATE", "1995-11-20T19:12:08-0501")
  391. sha = porcelain.commit(
  392. self.repo.path,
  393. message="Some message",
  394. author="Joe <joe@example.com>",
  395. committer="Bob <bob@example.com>",
  396. )
  397. self.assertIsInstance(sha, bytes)
  398. self.assertEqual(len(sha), 40)
  399. commit = self.repo.get_object(sha)
  400. assert isinstance(commit, Commit)
  401. self.assertEqual(commit._author_timezone, -18060)
  402. self.assertEqual(commit._commit_timezone, -18060)
  403. self.overrideEnv("GIT_AUTHOR_DATE", None)
  404. self.overrideEnv("GIT_COMMITTER_DATE", None)
  405. local_timezone = time.localtime().tm_gmtoff
  406. sha = porcelain.commit(
  407. self.repo.path,
  408. message="Some message",
  409. author="Joe <joe@example.com>",
  410. committer="Bob <bob@example.com>",
  411. )
  412. self.assertIsInstance(sha, bytes)
  413. self.assertEqual(len(sha), 40)
  414. commit = self.repo.get_object(sha)
  415. assert isinstance(commit, Commit)
  416. self.assertEqual(commit._author_timezone, local_timezone)
  417. self.assertEqual(commit._commit_timezone, local_timezone)
  418. @skipIf(
  419. platform.python_implementation() == "PyPy" or sys.platform == "win32",
  420. "gpgme not easily available or supported on Windows and PyPy",
  421. )
  422. class CommitSignTests(PorcelainGpgTestCase):
  423. def test_default_key(self) -> None:
  424. c1, c2, c3 = build_commit_graph(
  425. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  426. )
  427. self.repo.refs[b"HEAD"] = c3.id
  428. cfg = self.repo.get_config()
  429. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  430. self.import_default_key()
  431. sha = porcelain.commit(
  432. self.repo.path,
  433. message="Some message",
  434. author="Joe <joe@example.com>",
  435. committer="Bob <bob@example.com>",
  436. signoff=True,
  437. )
  438. self.assertIsInstance(sha, bytes)
  439. self.assertEqual(len(sha), 40)
  440. commit = self.repo.get_object(sha)
  441. assert isinstance(commit, Commit)
  442. # GPG Signatures aren't deterministic, so we can't do a static assertion.
  443. commit.verify()
  444. commit.verify(keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID])
  445. self.import_non_default_key()
  446. self.assertRaises(
  447. gpg.errors.MissingSignatures,
  448. commit.verify,
  449. keyids=[PorcelainGpgTestCase.NON_DEFAULT_KEY_ID],
  450. )
  451. assert isinstance(commit, Commit)
  452. commit.committer = b"Alice <alice@example.com>"
  453. self.assertRaises(
  454. gpg.errors.BadSignatures,
  455. commit.verify,
  456. )
  457. def test_non_default_key(self) -> None:
  458. c1, c2, c3 = build_commit_graph(
  459. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  460. )
  461. self.repo.refs[b"HEAD"] = c3.id
  462. cfg = self.repo.get_config()
  463. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  464. self.import_non_default_key()
  465. sha = porcelain.commit(
  466. self.repo.path,
  467. message="Some message",
  468. author="Joe <joe@example.com>",
  469. committer="Bob <bob@example.com>",
  470. signoff=PorcelainGpgTestCase.NON_DEFAULT_KEY_ID,
  471. )
  472. self.assertIsInstance(sha, bytes)
  473. self.assertEqual(len(sha), 40)
  474. commit = self.repo.get_object(sha)
  475. assert isinstance(commit, Commit)
  476. # GPG Signatures aren't deterministic, so we can't do a static assertion.
  477. commit.verify()
  478. def test_sign_uses_config_signingkey(self) -> None:
  479. """Test that sign=True uses user.signingKey from config."""
  480. c1, c2, c3 = build_commit_graph(
  481. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  482. )
  483. self.repo.refs[b"HEAD"] = c3.id
  484. # Set up user.signingKey in config
  485. cfg = self.repo.get_config()
  486. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  487. cfg.write_to_path()
  488. self.import_default_key()
  489. # Create commit with sign=True (should use signingKey from config)
  490. sha = porcelain.commit(
  491. self.repo.path,
  492. message="Signed with configured key",
  493. author="Joe <joe@example.com>",
  494. committer="Bob <bob@example.com>",
  495. signoff=True, # This should read user.signingKey from config
  496. )
  497. self.assertIsInstance(sha, bytes)
  498. self.assertEqual(len(sha), 40)
  499. commit = self.repo.get_object(sha)
  500. assert isinstance(commit, Commit)
  501. # Verify the commit is signed with the configured key
  502. commit.verify()
  503. commit.verify(keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID])
  504. def test_commit_gpg_sign_config_enabled(self) -> None:
  505. """Test that commit.gpgSign=true automatically signs commits."""
  506. c1, c2, c3 = build_commit_graph(
  507. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  508. )
  509. self.repo.refs[b"HEAD"] = c3.id
  510. # Set up user.signingKey and commit.gpgSign in config
  511. cfg = self.repo.get_config()
  512. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  513. cfg.set(("commit",), "gpgSign", True)
  514. cfg.write_to_path()
  515. self.import_default_key()
  516. # Create commit without explicit signoff parameter (should auto-sign due to config)
  517. sha = porcelain.commit(
  518. self.repo.path,
  519. message="Auto-signed commit",
  520. author="Joe <joe@example.com>",
  521. committer="Bob <bob@example.com>",
  522. # No signoff parameter - should use commit.gpgSign config
  523. )
  524. self.assertIsInstance(sha, bytes)
  525. self.assertEqual(len(sha), 40)
  526. commit = self.repo.get_object(sha)
  527. assert isinstance(commit, Commit)
  528. # Verify the commit is signed due to config
  529. commit.verify()
  530. commit.verify(keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID])
  531. def test_commit_gpg_sign_config_disabled(self) -> None:
  532. """Test that commit.gpgSign=false does not sign commits."""
  533. c1, c2, c3 = build_commit_graph(
  534. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  535. )
  536. self.repo.refs[b"HEAD"] = c3.id
  537. # Set up user.signingKey and commit.gpgSign=false in config
  538. cfg = self.repo.get_config()
  539. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  540. cfg.set(("commit",), "gpgSign", False)
  541. cfg.write_to_path()
  542. self.import_default_key()
  543. # Create commit without explicit signoff parameter (should not sign)
  544. sha = porcelain.commit(
  545. self.repo.path,
  546. message="Unsigned commit",
  547. author="Joe <joe@example.com>",
  548. committer="Bob <bob@example.com>",
  549. # No signoff parameter - should use commit.gpgSign=false config
  550. )
  551. self.assertIsInstance(sha, bytes)
  552. self.assertEqual(len(sha), 40)
  553. commit = self.repo.get_object(sha)
  554. assert isinstance(commit, Commit)
  555. # Verify the commit is not signed
  556. self.assertIsNone(commit._gpgsig)
  557. def test_commit_gpg_sign_config_no_signing_key(self) -> None:
  558. """Test that commit.gpgSign=true works without user.signingKey (uses default)."""
  559. c1, c2, c3 = build_commit_graph(
  560. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  561. )
  562. self.repo.refs[b"HEAD"] = c3.id
  563. # Set up commit.gpgSign but no user.signingKey
  564. cfg = self.repo.get_config()
  565. cfg.set(("commit",), "gpgSign", True)
  566. cfg.write_to_path()
  567. self.import_default_key()
  568. # Create commit without explicit signoff parameter (should auto-sign with default key)
  569. sha = porcelain.commit(
  570. self.repo.path,
  571. message="Default signed commit",
  572. author="Joe <joe@example.com>",
  573. committer="Bob <bob@example.com>",
  574. # No signoff parameter - should use commit.gpgSign config with default key
  575. )
  576. self.assertIsInstance(sha, bytes)
  577. self.assertEqual(len(sha), 40)
  578. commit = self.repo.get_object(sha)
  579. assert isinstance(commit, Commit)
  580. # Verify the commit is signed with default key
  581. commit.verify()
  582. def test_explicit_signoff_overrides_config(self) -> None:
  583. """Test that explicit signoff parameter overrides commit.gpgSign config."""
  584. c1, c2, c3 = build_commit_graph(
  585. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  586. )
  587. self.repo.refs[b"HEAD"] = c3.id
  588. # Set up commit.gpgSign=false but explicitly pass signoff=True
  589. cfg = self.repo.get_config()
  590. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  591. cfg.set(("commit",), "gpgSign", False)
  592. cfg.write_to_path()
  593. self.import_default_key()
  594. # Create commit with explicit signoff=True (should override config)
  595. sha = porcelain.commit(
  596. self.repo.path,
  597. message="Explicitly signed commit",
  598. author="Joe <joe@example.com>",
  599. committer="Bob <bob@example.com>",
  600. signoff=True, # This should override commit.gpgSign=false
  601. )
  602. self.assertIsInstance(sha, bytes)
  603. self.assertEqual(len(sha), 40)
  604. commit = self.repo.get_object(sha)
  605. assert isinstance(commit, Commit)
  606. # Verify the commit is signed despite config=false
  607. commit.verify()
  608. commit.verify(keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID])
  609. def test_explicit_false_disables_signing(self) -> None:
  610. """Test that explicit signoff=False disables signing even with config=true."""
  611. c1, c2, c3 = build_commit_graph(
  612. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  613. )
  614. self.repo.refs[b"HEAD"] = c3.id
  615. # Set up commit.gpgSign=true but explicitly pass signoff=False
  616. cfg = self.repo.get_config()
  617. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  618. cfg.set(("commit",), "gpgSign", True)
  619. cfg.write_to_path()
  620. self.import_default_key()
  621. # Create commit with explicit signoff=False (should disable signing)
  622. sha = porcelain.commit(
  623. self.repo.path,
  624. message="Explicitly unsigned commit",
  625. author="Joe <joe@example.com>",
  626. committer="Bob <bob@example.com>",
  627. signoff=False, # This should override commit.gpgSign=true
  628. )
  629. self.assertIsInstance(sha, bytes)
  630. self.assertEqual(len(sha), 40)
  631. commit = self.repo.get_object(sha)
  632. assert isinstance(commit, Commit)
  633. # Verify the commit is NOT signed despite config=true
  634. self.assertIsNone(commit._gpgsig)
  635. class TimezoneTests(PorcelainTestCase):
  636. def put_envs(self, value) -> None:
  637. self.overrideEnv("GIT_AUTHOR_DATE", value)
  638. self.overrideEnv("GIT_COMMITTER_DATE", value)
  639. def fallback(self, value) -> None:
  640. self.put_envs(value)
  641. self.assertRaises(porcelain.TimezoneFormatError, porcelain.get_user_timezones)
  642. def test_internal_format(self) -> None:
  643. self.put_envs("0 +0500")
  644. self.assertTupleEqual((18000, 18000), porcelain.get_user_timezones())
  645. def test_rfc_2822(self) -> None:
  646. self.put_envs("Mon, 20 Nov 1995 19:12:08 -0500")
  647. self.assertTupleEqual((-18000, -18000), porcelain.get_user_timezones())
  648. self.put_envs("Mon, 20 Nov 1995 19:12:08")
  649. self.assertTupleEqual((0, 0), porcelain.get_user_timezones())
  650. def test_iso8601(self) -> None:
  651. self.put_envs("1995-11-20T19:12:08-0501")
  652. self.assertTupleEqual((-18060, -18060), porcelain.get_user_timezones())
  653. self.put_envs("1995-11-20T19:12:08+0501")
  654. self.assertTupleEqual((18060, 18060), porcelain.get_user_timezones())
  655. self.put_envs("1995-11-20T19:12:08-05:01")
  656. self.assertTupleEqual((-18060, -18060), porcelain.get_user_timezones())
  657. self.put_envs("1995-11-20 19:12:08-05")
  658. self.assertTupleEqual((-18000, -18000), porcelain.get_user_timezones())
  659. # https://github.com/git/git/blob/96b2d4fa927c5055adc5b1d08f10a5d7352e2989/t/t6300-for-each-ref.sh#L128
  660. self.put_envs("2006-07-03 17:18:44 +0200")
  661. self.assertTupleEqual((7200, 7200), porcelain.get_user_timezones())
  662. def test_missing_or_malformed(self) -> None:
  663. # TODO: add more here
  664. self.fallback("0 + 0500")
  665. self.fallback("a +0500")
  666. self.fallback("1995-11-20T19:12:08")
  667. self.fallback("1995-11-20T19:12:08-05:")
  668. self.fallback("1995.11.20")
  669. self.fallback("11/20/1995")
  670. self.fallback("20.11.1995")
  671. def test_different_envs(self) -> None:
  672. self.overrideEnv("GIT_AUTHOR_DATE", "0 +0500")
  673. self.overrideEnv("GIT_COMMITTER_DATE", "0 +0501")
  674. self.assertTupleEqual((18000, 18060), porcelain.get_user_timezones())
  675. def test_no_envs(self) -> None:
  676. local_timezone = time.localtime().tm_gmtoff
  677. self.put_envs("0 +0500")
  678. self.assertTupleEqual((18000, 18000), porcelain.get_user_timezones())
  679. self.overrideEnv("GIT_COMMITTER_DATE", None)
  680. self.assertTupleEqual((18000, local_timezone), porcelain.get_user_timezones())
  681. self.put_envs("0 +0500")
  682. self.overrideEnv("GIT_AUTHOR_DATE", None)
  683. self.assertTupleEqual((local_timezone, 18000), porcelain.get_user_timezones())
  684. self.put_envs("0 +0500")
  685. self.overrideEnv("GIT_AUTHOR_DATE", None)
  686. self.overrideEnv("GIT_COMMITTER_DATE", None)
  687. self.assertTupleEqual(
  688. (local_timezone, local_timezone), porcelain.get_user_timezones()
  689. )
  690. class CleanTests(PorcelainTestCase):
  691. def put_files(self, tracked, ignored, untracked, empty_dirs) -> None:
  692. """Put the described files in the wd."""
  693. all_files = tracked | ignored | untracked
  694. for file_path in all_files:
  695. abs_path = os.path.join(self.repo.path, file_path)
  696. # File may need to be written in a dir that doesn't exist yet, so
  697. # create the parent dir(s) as necessary
  698. parent_dir = os.path.dirname(abs_path)
  699. try:
  700. os.makedirs(parent_dir)
  701. except FileExistsError:
  702. pass
  703. with open(abs_path, "w") as f:
  704. f.write("")
  705. with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
  706. f.writelines(ignored)
  707. for dir_path in empty_dirs:
  708. os.mkdir(os.path.join(self.repo.path, "empty_dir"))
  709. files_to_add = [os.path.join(self.repo.path, t) for t in tracked]
  710. porcelain.add(repo=self.repo.path, paths=files_to_add)
  711. porcelain.commit(repo=self.repo.path, message="init commit")
  712. def assert_wd(self, expected_paths) -> None:
  713. """Assert paths of files and dirs in wd are same as expected_paths."""
  714. control_dir_rel = os.path.relpath(self.repo._controldir, self.repo.path)
  715. # normalize paths to simplify comparison across platforms
  716. found_paths = {
  717. os.path.normpath(p)
  718. for p in flat_walk_dir(self.repo.path)
  719. if not p.split(os.sep)[0] == control_dir_rel
  720. }
  721. norm_expected_paths = {os.path.normpath(p) for p in expected_paths}
  722. self.assertEqual(found_paths, norm_expected_paths)
  723. def test_from_root(self) -> None:
  724. self.put_files(
  725. tracked={"tracked_file", "tracked_dir/tracked_file", ".gitignore"},
  726. ignored={"ignored_file"},
  727. untracked={
  728. "untracked_file",
  729. "tracked_dir/untracked_dir/untracked_file",
  730. "untracked_dir/untracked_dir/untracked_file",
  731. },
  732. empty_dirs={"empty_dir"},
  733. )
  734. porcelain.clean(repo=self.repo.path, target_dir=self.repo.path)
  735. self.assert_wd(
  736. {
  737. "tracked_file",
  738. "tracked_dir/tracked_file",
  739. ".gitignore",
  740. "ignored_file",
  741. "tracked_dir",
  742. }
  743. )
  744. def test_from_subdir(self) -> None:
  745. self.put_files(
  746. tracked={"tracked_file", "tracked_dir/tracked_file", ".gitignore"},
  747. ignored={"ignored_file"},
  748. untracked={
  749. "untracked_file",
  750. "tracked_dir/untracked_dir/untracked_file",
  751. "untracked_dir/untracked_dir/untracked_file",
  752. },
  753. empty_dirs={"empty_dir"},
  754. )
  755. porcelain.clean(
  756. repo=self.repo,
  757. target_dir=os.path.join(self.repo.path, "untracked_dir"),
  758. )
  759. self.assert_wd(
  760. {
  761. "tracked_file",
  762. "tracked_dir/tracked_file",
  763. ".gitignore",
  764. "ignored_file",
  765. "untracked_file",
  766. "tracked_dir/untracked_dir/untracked_file",
  767. "empty_dir",
  768. "untracked_dir",
  769. "tracked_dir",
  770. "tracked_dir/untracked_dir",
  771. }
  772. )
  773. class CloneTests(PorcelainTestCase):
  774. def test_simple_local(self) -> None:
  775. f1_1 = make_object(Blob, data=b"f1")
  776. commit_spec = [[1], [2, 1], [3, 1, 2]]
  777. trees = {
  778. 1: [(b"f1", f1_1), (b"f2", f1_1)],
  779. 2: [(b"f1", f1_1), (b"f2", f1_1)],
  780. 3: [(b"f1", f1_1), (b"f2", f1_1)],
  781. }
  782. c1, c2, c3 = build_commit_graph(self.repo.object_store, commit_spec, trees)
  783. self.repo.refs[b"refs/heads/master"] = c3.id
  784. self.repo.refs[b"refs/tags/foo"] = c3.id
  785. target_path = tempfile.mkdtemp()
  786. errstream = BytesIO()
  787. self.addCleanup(shutil.rmtree, target_path)
  788. r = porcelain.clone(
  789. self.repo.path, target_path, checkout=False, errstream=errstream
  790. )
  791. self.addCleanup(r.close)
  792. self.assertEqual(r.path, target_path)
  793. target_repo = Repo(target_path)
  794. self.addCleanup(target_repo.close)
  795. self.assertEqual(0, len(target_repo.open_index()))
  796. self.assertEqual(c3.id, target_repo.refs[b"refs/tags/foo"])
  797. self.assertNotIn(b"f1", os.listdir(target_path))
  798. self.assertNotIn(b"f2", os.listdir(target_path))
  799. c = r.get_config()
  800. encoded_path = self.repo.path
  801. if not isinstance(encoded_path, bytes):
  802. encoded_path_bytes = encoded_path.encode("utf-8")
  803. else:
  804. encoded_path_bytes = encoded_path
  805. self.assertEqual(encoded_path_bytes, c.get((b"remote", b"origin"), b"url"))
  806. self.assertEqual(
  807. b"+refs/heads/*:refs/remotes/origin/*",
  808. c.get((b"remote", b"origin"), b"fetch"),
  809. )
  810. def test_simple_local_with_checkout(self) -> None:
  811. f1_1 = make_object(Blob, data=b"f1")
  812. commit_spec = [[1], [2, 1], [3, 1, 2]]
  813. trees = {
  814. 1: [(b"f1", f1_1), (b"f2", f1_1)],
  815. 2: [(b"f1", f1_1), (b"f2", f1_1)],
  816. 3: [(b"f1", f1_1), (b"f2", f1_1)],
  817. }
  818. c1, c2, c3 = build_commit_graph(self.repo.object_store, commit_spec, trees)
  819. self.repo.refs[b"refs/heads/master"] = c3.id
  820. target_path = tempfile.mkdtemp()
  821. errstream = BytesIO()
  822. self.addCleanup(shutil.rmtree, target_path)
  823. with porcelain.clone(
  824. self.repo.path, target_path, checkout=True, errstream=errstream
  825. ) as r:
  826. self.assertEqual(r.path, target_path)
  827. with Repo(target_path) as r:
  828. self.assertEqual(r.head(), c3.id)
  829. self.assertIn("f1", os.listdir(target_path))
  830. self.assertIn("f2", os.listdir(target_path))
  831. def test_bare_local_with_checkout(self) -> None:
  832. f1_1 = make_object(Blob, data=b"f1")
  833. commit_spec = [[1], [2, 1], [3, 1, 2]]
  834. trees = {
  835. 1: [(b"f1", f1_1), (b"f2", f1_1)],
  836. 2: [(b"f1", f1_1), (b"f2", f1_1)],
  837. 3: [(b"f1", f1_1), (b"f2", f1_1)],
  838. }
  839. c1, c2, c3 = build_commit_graph(self.repo.object_store, commit_spec, trees)
  840. self.repo.refs[b"refs/heads/master"] = c3.id
  841. target_path = tempfile.mkdtemp()
  842. errstream = BytesIO()
  843. self.addCleanup(shutil.rmtree, target_path)
  844. with porcelain.clone(
  845. self.repo.path, target_path, bare=True, errstream=errstream
  846. ) as r:
  847. self.assertEqual(r.path, target_path)
  848. with Repo(target_path) as r:
  849. r.head()
  850. self.assertRaises(NoIndexPresent, r.open_index)
  851. self.assertNotIn(b"f1", os.listdir(target_path))
  852. self.assertNotIn(b"f2", os.listdir(target_path))
  853. def test_no_checkout_with_bare(self) -> None:
  854. f1_1 = make_object(Blob, data=b"f1")
  855. commit_spec = [[1]]
  856. trees = {1: [(b"f1", f1_1), (b"f2", f1_1)]}
  857. (c1,) = build_commit_graph(self.repo.object_store, commit_spec, trees)
  858. self.repo.refs[b"refs/heads/master"] = c1.id
  859. self.repo.refs[b"HEAD"] = c1.id
  860. target_path = tempfile.mkdtemp()
  861. errstream = BytesIO()
  862. self.addCleanup(shutil.rmtree, target_path)
  863. self.assertRaises(
  864. porcelain.Error,
  865. porcelain.clone,
  866. self.repo.path,
  867. target_path,
  868. checkout=True,
  869. bare=True,
  870. errstream=errstream,
  871. )
  872. def test_no_head_no_checkout(self) -> None:
  873. f1_1 = make_object(Blob, data=b"f1")
  874. commit_spec = [[1]]
  875. trees = {1: [(b"f1", f1_1), (b"f2", f1_1)]}
  876. (c1,) = build_commit_graph(self.repo.object_store, commit_spec, trees)
  877. self.repo.refs[b"refs/heads/master"] = c1.id
  878. target_path = tempfile.mkdtemp()
  879. self.addCleanup(shutil.rmtree, target_path)
  880. errstream = BytesIO()
  881. r = porcelain.clone(
  882. self.repo.path, target_path, checkout=True, errstream=errstream
  883. )
  884. r.close()
  885. def test_no_head_no_checkout_outstream_errstream_autofallback(self) -> None:
  886. f1_1 = make_object(Blob, data=b"f1")
  887. commit_spec = [[1]]
  888. trees = {1: [(b"f1", f1_1), (b"f2", f1_1)]}
  889. (c1,) = build_commit_graph(self.repo.object_store, commit_spec, trees)
  890. self.repo.refs[b"refs/heads/master"] = c1.id
  891. target_path = tempfile.mkdtemp()
  892. self.addCleanup(shutil.rmtree, target_path)
  893. errstream = porcelain.NoneStream()
  894. r = porcelain.clone(
  895. self.repo.path, target_path, checkout=True, errstream=errstream
  896. )
  897. r.close()
  898. def test_source_broken(self) -> None:
  899. with tempfile.TemporaryDirectory() as parent:
  900. target_path = os.path.join(parent, "target")
  901. self.assertRaises(
  902. Exception, porcelain.clone, "/nonexistent/repo", target_path
  903. )
  904. self.assertFalse(os.path.exists(target_path))
  905. def test_fetch_symref(self) -> None:
  906. f1_1 = make_object(Blob, data=b"f1")
  907. trees = {1: [(b"f1", f1_1), (b"f2", f1_1)]}
  908. [c1] = build_commit_graph(self.repo.object_store, [[1]], trees)
  909. self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/else")
  910. self.repo.refs[b"refs/heads/else"] = c1.id
  911. target_path = tempfile.mkdtemp()
  912. errstream = BytesIO()
  913. self.addCleanup(shutil.rmtree, target_path)
  914. r = porcelain.clone(
  915. self.repo.path, target_path, checkout=False, errstream=errstream
  916. )
  917. self.addCleanup(r.close)
  918. self.assertEqual(r.path, target_path)
  919. target_repo = Repo(target_path)
  920. self.addCleanup(target_repo.close)
  921. self.assertEqual(0, len(target_repo.open_index()))
  922. self.assertEqual(c1.id, target_repo.refs[b"refs/heads/else"])
  923. self.assertEqual(c1.id, target_repo.refs[b"HEAD"])
  924. self.assertEqual(
  925. {
  926. b"HEAD": b"refs/heads/else",
  927. b"refs/remotes/origin/HEAD": b"refs/remotes/origin/else",
  928. },
  929. target_repo.refs.get_symrefs(),
  930. )
  931. def test_detached_head(self) -> None:
  932. f1_1 = make_object(Blob, data=b"f1")
  933. commit_spec = [[1], [2, 1], [3, 1, 2]]
  934. trees = {
  935. 1: [(b"f1", f1_1), (b"f2", f1_1)],
  936. 2: [(b"f1", f1_1), (b"f2", f1_1)],
  937. 3: [(b"f1", f1_1), (b"f2", f1_1)],
  938. }
  939. c1, c2, c3 = build_commit_graph(self.repo.object_store, commit_spec, trees)
  940. self.repo.refs[b"refs/heads/master"] = c2.id
  941. self.repo.refs.remove_if_equals(b"HEAD", None)
  942. self.repo.refs[b"HEAD"] = c3.id
  943. target_path = tempfile.mkdtemp()
  944. self.addCleanup(shutil.rmtree, target_path)
  945. errstream = porcelain.NoneStream()
  946. with porcelain.clone(
  947. self.repo.path, target_path, checkout=True, errstream=errstream
  948. ) as r:
  949. self.assertEqual(c3.id, r.refs[b"HEAD"])
  950. def test_clone_pathlib(self) -> None:
  951. from pathlib import Path
  952. f1_1 = make_object(Blob, data=b"f1")
  953. commit_spec = [[1]]
  954. trees = {1: [(b"f1", f1_1)]}
  955. c1 = build_commit_graph(self.repo.object_store, commit_spec, trees)[0]
  956. self.repo.refs[b"refs/heads/master"] = c1.id
  957. target_dir = tempfile.mkdtemp()
  958. self.addCleanup(shutil.rmtree, target_dir)
  959. target_path = Path(target_dir) / "clone_repo"
  960. errstream = BytesIO()
  961. r = porcelain.clone(
  962. self.repo.path, target_path, checkout=False, errstream=errstream
  963. )
  964. self.addCleanup(r.close)
  965. self.assertEqual(r.path, str(target_path))
  966. self.assertTrue(os.path.exists(str(target_path)))
  967. def test_clone_with_recurse_submodules(self) -> None:
  968. # Create a submodule repository
  969. sub_repo_path = tempfile.mkdtemp()
  970. self.addCleanup(shutil.rmtree, sub_repo_path)
  971. sub_repo = Repo.init(sub_repo_path)
  972. self.addCleanup(sub_repo.close)
  973. # Add a file to the submodule repo
  974. sub_file = os.path.join(sub_repo_path, "subfile.txt")
  975. with open(sub_file, "w") as f:
  976. f.write("submodule content")
  977. porcelain.add(sub_repo, paths=[sub_file])
  978. sub_commit = porcelain.commit(
  979. sub_repo,
  980. message=b"Initial submodule commit",
  981. author=b"Test Author <test@example.com>",
  982. committer=b"Test Committer <test@example.com>",
  983. )
  984. # Create main repository with submodule
  985. main_file = os.path.join(self.repo.path, "main.txt")
  986. with open(main_file, "w") as f:
  987. f.write("main content")
  988. porcelain.add(self.repo, paths=[main_file])
  989. porcelain.submodule_add(self.repo, sub_repo_path, "sub")
  990. # Manually add the submodule to the index since submodule_add doesn't do it
  991. # when the repository is local (to maintain backward compatibility)
  992. from dulwich.index import IndexEntry
  993. from dulwich.objects import S_IFGITLINK
  994. index = self.repo.open_index()
  995. index[b"sub"] = IndexEntry(
  996. ctime=0,
  997. mtime=0,
  998. dev=0,
  999. ino=0,
  1000. mode=S_IFGITLINK,
  1001. uid=0,
  1002. gid=0,
  1003. size=0,
  1004. sha=sub_commit,
  1005. flags=0,
  1006. )
  1007. index.write()
  1008. porcelain.add(self.repo, paths=[".gitmodules"])
  1009. porcelain.commit(
  1010. self.repo,
  1011. message=b"Add submodule",
  1012. author=b"Test Author <test@example.com>",
  1013. committer=b"Test Committer <test@example.com>",
  1014. )
  1015. # Clone with recurse_submodules
  1016. target_path = tempfile.mkdtemp()
  1017. self.addCleanup(shutil.rmtree, target_path)
  1018. cloned = porcelain.clone(
  1019. self.repo.path,
  1020. target_path,
  1021. recurse_submodules=True,
  1022. )
  1023. self.addCleanup(cloned.close)
  1024. # Check main file exists
  1025. cloned_main = os.path.join(target_path, "main.txt")
  1026. self.assertTrue(os.path.exists(cloned_main))
  1027. with open(cloned_main) as f:
  1028. self.assertEqual(f.read(), "main content")
  1029. # Check submodule file exists
  1030. cloned_sub_file = os.path.join(target_path, "sub", "subfile.txt")
  1031. self.assertTrue(os.path.exists(cloned_sub_file))
  1032. with open(cloned_sub_file) as f:
  1033. self.assertEqual(f.read(), "submodule content")
  1034. class InitTests(TestCase):
  1035. def test_non_bare(self) -> None:
  1036. repo_dir = tempfile.mkdtemp()
  1037. self.addCleanup(shutil.rmtree, repo_dir)
  1038. porcelain.init(repo_dir)
  1039. def test_bare(self) -> None:
  1040. repo_dir = tempfile.mkdtemp()
  1041. self.addCleanup(shutil.rmtree, repo_dir)
  1042. porcelain.init(repo_dir, bare=True)
  1043. def test_init_pathlib(self) -> None:
  1044. from pathlib import Path
  1045. repo_dir = tempfile.mkdtemp()
  1046. self.addCleanup(shutil.rmtree, repo_dir)
  1047. repo_path = Path(repo_dir)
  1048. # Test non-bare repo with pathlib
  1049. repo = porcelain.init(repo_path)
  1050. self.assertTrue(os.path.exists(os.path.join(repo_dir, ".git")))
  1051. repo.close()
  1052. def test_init_bare_pathlib(self) -> None:
  1053. from pathlib import Path
  1054. repo_dir = tempfile.mkdtemp()
  1055. self.addCleanup(shutil.rmtree, repo_dir)
  1056. repo_path = Path(repo_dir)
  1057. # Test bare repo with pathlib
  1058. repo = porcelain.init(repo_path, bare=True)
  1059. self.assertTrue(os.path.exists(os.path.join(repo_dir, "refs")))
  1060. repo.close()
  1061. class AddTests(PorcelainTestCase):
  1062. def test_add_default_paths(self) -> None:
  1063. # create a file for initial commit
  1064. fullpath = os.path.join(self.repo.path, "blah")
  1065. with open(fullpath, "w") as f:
  1066. f.write("\n")
  1067. porcelain.add(repo=self.repo.path, paths=[fullpath])
  1068. porcelain.commit(
  1069. repo=self.repo.path,
  1070. message=b"test",
  1071. author=b"test <email>",
  1072. committer=b"test <email>",
  1073. )
  1074. # Add a second test file and a file in a directory
  1075. with open(os.path.join(self.repo.path, "foo"), "w") as f:
  1076. f.write("\n")
  1077. os.mkdir(os.path.join(self.repo.path, "adir"))
  1078. with open(os.path.join(self.repo.path, "adir", "afile"), "w") as f:
  1079. f.write("\n")
  1080. cwd = os.getcwd()
  1081. self.addCleanup(os.chdir, cwd)
  1082. os.chdir(self.repo.path)
  1083. self.assertEqual({"foo", "blah", "adir", ".git"}, set(os.listdir(".")))
  1084. added, ignored = porcelain.add(self.repo.path)
  1085. # Normalize paths to use forward slashes for comparison
  1086. added_normalized = [path.replace(os.sep, "/") for path in added]
  1087. self.assertEqual(
  1088. (added_normalized, ignored),
  1089. (["foo", "adir/afile"], set()),
  1090. )
  1091. # Check that foo was added and nothing in .git was modified
  1092. index = self.repo.open_index()
  1093. self.assertEqual(sorted(index), [b"adir/afile", b"blah", b"foo"])
  1094. def test_add_default_paths_subdir(self) -> None:
  1095. os.mkdir(os.path.join(self.repo.path, "foo"))
  1096. with open(os.path.join(self.repo.path, "blah"), "w") as f:
  1097. f.write("\n")
  1098. with open(os.path.join(self.repo.path, "foo", "blie"), "w") as f:
  1099. f.write("\n")
  1100. cwd = os.getcwd()
  1101. self.addCleanup(os.chdir, cwd)
  1102. os.chdir(os.path.join(self.repo.path, "foo"))
  1103. porcelain.add(repo=self.repo.path)
  1104. porcelain.commit(
  1105. repo=self.repo.path,
  1106. message=b"test",
  1107. author=b"test <email>",
  1108. committer=b"test <email>",
  1109. )
  1110. index = self.repo.open_index()
  1111. # After fix: add() with no paths should behave like git add -A (add everything)
  1112. self.assertEqual(sorted(index), [b"blah", b"foo/blie"])
  1113. def test_add_file(self) -> None:
  1114. fullpath = os.path.join(self.repo.path, "foo")
  1115. with open(fullpath, "w") as f:
  1116. f.write("BAR")
  1117. porcelain.add(self.repo.path, paths=[fullpath])
  1118. self.assertIn(b"foo", self.repo.open_index())
  1119. def test_add_ignored(self) -> None:
  1120. with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
  1121. f.write("foo\nsubdir/")
  1122. with open(os.path.join(self.repo.path, "foo"), "w") as f:
  1123. f.write("BAR")
  1124. with open(os.path.join(self.repo.path, "bar"), "w") as f:
  1125. f.write("BAR")
  1126. os.mkdir(os.path.join(self.repo.path, "subdir"))
  1127. with open(os.path.join(self.repo.path, "subdir", "baz"), "w") as f:
  1128. f.write("BAZ")
  1129. (added, ignored) = porcelain.add(
  1130. self.repo.path,
  1131. paths=[
  1132. os.path.join(self.repo.path, "foo"),
  1133. os.path.join(self.repo.path, "bar"),
  1134. os.path.join(self.repo.path, "subdir"),
  1135. ],
  1136. )
  1137. self.assertIn(b"bar", self.repo.open_index())
  1138. self.assertEqual({"bar"}, set(added))
  1139. self.assertEqual({"foo", "subdir/"}, ignored)
  1140. def test_add_from_ignored_directory(self) -> None:
  1141. # Test for issue #550 - adding files when cwd is in ignored directory
  1142. # Create .gitignore that ignores build/
  1143. with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
  1144. f.write("build/\n")
  1145. # Create ignored directory
  1146. build_dir = os.path.join(self.repo.path, "build")
  1147. os.mkdir(build_dir)
  1148. # Create a file in the repo (not in ignored directory)
  1149. src_file = os.path.join(self.repo.path, "source.py")
  1150. with open(src_file, "w") as f:
  1151. f.write("print('hello')\n")
  1152. # Save current directory and change to ignored directory
  1153. original_cwd = os.getcwd()
  1154. try:
  1155. os.chdir(build_dir)
  1156. # Add file using absolute path from within ignored directory
  1157. (added, ignored) = porcelain.add(self.repo.path, paths=[src_file])
  1158. self.assertIn(b"source.py", self.repo.open_index())
  1159. self.assertEqual({"source.py"}, set(added))
  1160. finally:
  1161. os.chdir(original_cwd)
  1162. def test_add_file_absolute_path(self) -> None:
  1163. # Absolute paths are (not yet) supported
  1164. with open(os.path.join(self.repo.path, "foo"), "w") as f:
  1165. f.write("BAR")
  1166. porcelain.add(self.repo, paths=[os.path.join(self.repo.path, "foo")])
  1167. self.assertIn(b"foo", self.repo.open_index())
  1168. def test_add_not_in_repo(self) -> None:
  1169. with open(os.path.join(self.test_dir, "foo"), "w") as f:
  1170. f.write("BAR")
  1171. self.assertRaises(
  1172. ValueError,
  1173. porcelain.add,
  1174. self.repo,
  1175. paths=[os.path.join(self.test_dir, "foo")],
  1176. )
  1177. self.assertRaises(
  1178. (ValueError, FileNotFoundError),
  1179. porcelain.add,
  1180. self.repo,
  1181. paths=["../foo"],
  1182. )
  1183. self.assertEqual([], list(self.repo.open_index()))
  1184. def test_add_file_clrf_conversion(self) -> None:
  1185. from dulwich.index import IndexEntry
  1186. # Set the right configuration to the repo
  1187. c = self.repo.get_config()
  1188. c.set("core", "autocrlf", "input")
  1189. c.write_to_path()
  1190. # Add a file with CRLF line-ending
  1191. fullpath = os.path.join(self.repo.path, "foo")
  1192. with open(fullpath, "wb") as f:
  1193. f.write(b"line1\r\nline2")
  1194. porcelain.add(self.repo.path, paths=[fullpath])
  1195. # The line-endings should have been converted to LF
  1196. index = self.repo.open_index()
  1197. self.assertIn(b"foo", index)
  1198. entry = index[b"foo"]
  1199. assert isinstance(entry, IndexEntry)
  1200. blob = self.repo[entry.sha]
  1201. self.assertEqual(blob.data, b"line1\nline2")
  1202. def test_add_symlink_outside_repo(self) -> None:
  1203. """Test adding a symlink that points outside the repository."""
  1204. # Create a symlink pointing outside the repository
  1205. symlink_path = os.path.join(self.repo.path, "symlink_to_nowhere")
  1206. os.symlink("/nonexistent/path", symlink_path)
  1207. # Adding the symlink should succeed (matching Git's behavior)
  1208. added, ignored = porcelain.add(self.repo.path, paths=[symlink_path])
  1209. # Should successfully add the symlink
  1210. self.assertIn("symlink_to_nowhere", added)
  1211. self.assertEqual(len(ignored), 0)
  1212. # Verify symlink is actually staged
  1213. index = self.repo.open_index()
  1214. self.assertIn(b"symlink_to_nowhere", index)
  1215. def test_add_symlink_to_file_inside_repo(self) -> None:
  1216. """Test adding a symlink that points to a file inside the repository."""
  1217. # Create a regular file
  1218. target_file = os.path.join(self.repo.path, "target.txt")
  1219. with open(target_file, "w") as f:
  1220. f.write("target content")
  1221. # Create a symlink to the file
  1222. symlink_path = os.path.join(self.repo.path, "link_to_target")
  1223. os.symlink("target.txt", symlink_path)
  1224. # Add both the target and the symlink
  1225. added, ignored = porcelain.add(
  1226. self.repo.path, paths=[target_file, symlink_path]
  1227. )
  1228. # Both should be added successfully
  1229. self.assertIn("target.txt", added)
  1230. self.assertIn("link_to_target", added)
  1231. self.assertEqual(len(ignored), 0)
  1232. # Verify both are in the index
  1233. index = self.repo.open_index()
  1234. self.assertIn(b"target.txt", index)
  1235. self.assertIn(b"link_to_target", index)
  1236. def test_add_symlink_to_directory_inside_repo(self) -> None:
  1237. """Test adding a symlink that points to a directory inside the repository."""
  1238. # Create a directory with some files
  1239. target_dir = os.path.join(self.repo.path, "target_dir")
  1240. os.mkdir(target_dir)
  1241. with open(os.path.join(target_dir, "file1.txt"), "w") as f:
  1242. f.write("content1")
  1243. with open(os.path.join(target_dir, "file2.txt"), "w") as f:
  1244. f.write("content2")
  1245. # Create a symlink to the directory
  1246. symlink_path = os.path.join(self.repo.path, "link_to_dir")
  1247. os.symlink("target_dir", symlink_path)
  1248. # Add the symlink
  1249. added, ignored = porcelain.add(self.repo.path, paths=[symlink_path])
  1250. # When adding a symlink to a directory, it follows the symlink and adds contents
  1251. self.assertEqual(len(added), 2)
  1252. self.assertIn("link_to_dir/file1.txt", added)
  1253. self.assertIn("link_to_dir/file2.txt", added)
  1254. self.assertEqual(len(ignored), 0)
  1255. # Verify files are added through the symlink path
  1256. index = self.repo.open_index()
  1257. self.assertIn(b"link_to_dir/file1.txt", index)
  1258. self.assertIn(b"link_to_dir/file2.txt", index)
  1259. # The original target directory files are not added
  1260. self.assertNotIn(b"target_dir/file1.txt", index)
  1261. self.assertNotIn(b"target_dir/file2.txt", index)
  1262. def test_add_symlink_chain(self) -> None:
  1263. """Test adding a chain of symlinks (symlink to symlink)."""
  1264. # Create a regular file
  1265. target_file = os.path.join(self.repo.path, "original.txt")
  1266. with open(target_file, "w") as f:
  1267. f.write("original content")
  1268. # Create first symlink
  1269. first_link = os.path.join(self.repo.path, "link1")
  1270. os.symlink("original.txt", first_link)
  1271. # Create second symlink pointing to first
  1272. second_link = os.path.join(self.repo.path, "link2")
  1273. os.symlink("link1", second_link)
  1274. # Add all files
  1275. added, ignored = porcelain.add(
  1276. self.repo.path, paths=[target_file, first_link, second_link]
  1277. )
  1278. # All should be added
  1279. self.assertEqual(len(added), 3)
  1280. self.assertIn("original.txt", added)
  1281. self.assertIn("link1", added)
  1282. self.assertIn("link2", added)
  1283. # Verify all are in the index
  1284. index = self.repo.open_index()
  1285. self.assertIn(b"original.txt", index)
  1286. self.assertIn(b"link1", index)
  1287. self.assertIn(b"link2", index)
  1288. def test_add_broken_symlink(self) -> None:
  1289. """Test adding a broken symlink (points to non-existent target)."""
  1290. # Create a symlink to a non-existent file
  1291. broken_link = os.path.join(self.repo.path, "broken_link")
  1292. os.symlink("does_not_exist.txt", broken_link)
  1293. # Add the broken symlink
  1294. added, ignored = porcelain.add(self.repo.path, paths=[broken_link])
  1295. # Should be added successfully (Git tracks the symlink, not its target)
  1296. self.assertIn("broken_link", added)
  1297. self.assertEqual(len(ignored), 0)
  1298. # Verify it's in the index
  1299. index = self.repo.open_index()
  1300. self.assertIn(b"broken_link", index)
  1301. def test_add_symlink_relative_outside_repo(self) -> None:
  1302. """Test adding a symlink that uses '..' to point outside the repository."""
  1303. # Create a file outside the repo
  1304. outside_file = os.path.join(self.test_dir, "outside.txt")
  1305. with open(outside_file, "w") as f:
  1306. f.write("outside content")
  1307. # Create a symlink using relative path to go outside
  1308. symlink_path = os.path.join(self.repo.path, "link_outside")
  1309. os.symlink("../outside.txt", symlink_path)
  1310. # Add the symlink
  1311. added, ignored = porcelain.add(self.repo.path, paths=[symlink_path])
  1312. # Should be added successfully
  1313. self.assertIn("link_outside", added)
  1314. self.assertEqual(len(ignored), 0)
  1315. # Verify it's in the index
  1316. index = self.repo.open_index()
  1317. self.assertIn(b"link_outside", index)
  1318. def test_add_symlink_absolute_to_system(self) -> None:
  1319. """Test adding a symlink with absolute path to system directory."""
  1320. # Create a symlink to a system directory
  1321. symlink_path = os.path.join(self.repo.path, "link_to_tmp")
  1322. if os.name == "nt":
  1323. # On Windows, use a system directory like TEMP
  1324. symlink_target = os.environ["TEMP"]
  1325. else:
  1326. # On Unix-like systems, use /tmp
  1327. symlink_target = "/tmp"
  1328. os.symlink(symlink_target, symlink_path)
  1329. # Adding a symlink to a directory outside the repo should raise ValueError
  1330. with self.assertRaises(ValueError) as cm:
  1331. porcelain.add(self.repo.path, paths=[symlink_path])
  1332. # Check that the error indicates the path is outside the repository
  1333. self.assertIn("is not in the subpath of", str(cm.exception))
  1334. def test_add_file_through_symlink(self) -> None:
  1335. """Test adding a file through a symlinked directory."""
  1336. # Create a directory with a file
  1337. real_dir = os.path.join(self.repo.path, "real_dir")
  1338. os.mkdir(real_dir)
  1339. real_file = os.path.join(real_dir, "file.txt")
  1340. with open(real_file, "w") as f:
  1341. f.write("content")
  1342. # Create a symlink to the directory
  1343. link_dir = os.path.join(self.repo.path, "link_dir")
  1344. os.symlink("real_dir", link_dir)
  1345. # Try to add the file through the symlink path
  1346. symlink_file_path = os.path.join(link_dir, "file.txt")
  1347. # This should add the real file, not create a new entry
  1348. added, ignored = porcelain.add(self.repo.path, paths=[symlink_file_path])
  1349. # The real file should be added
  1350. self.assertIn("real_dir/file.txt", added)
  1351. self.assertEqual(len(added), 1)
  1352. # Verify correct path in index
  1353. index = self.repo.open_index()
  1354. self.assertIn(b"real_dir/file.txt", index)
  1355. # Should not create a separate entry for the symlink path
  1356. self.assertNotIn(b"link_dir/file.txt", index)
  1357. def test_add_repo_path(self) -> None:
  1358. """Test adding the repository path itself should add all untracked files."""
  1359. # Create some untracked files
  1360. with open(os.path.join(self.repo.path, "file1.txt"), "w") as f:
  1361. f.write("content1")
  1362. with open(os.path.join(self.repo.path, "file2.txt"), "w") as f:
  1363. f.write("content2")
  1364. # Add the repository path itself
  1365. added, ignored = porcelain.add(self.repo.path, paths=[self.repo.path])
  1366. # Should add all untracked files, not stage './'
  1367. self.assertIn("file1.txt", added)
  1368. self.assertIn("file2.txt", added)
  1369. self.assertNotIn("./", added)
  1370. # Verify files are actually staged
  1371. index = self.repo.open_index()
  1372. self.assertIn(b"file1.txt", index)
  1373. self.assertIn(b"file2.txt", index)
  1374. def test_add_directory_contents(self) -> None:
  1375. """Test adding a directory adds all files within it."""
  1376. # Create a subdirectory with multiple files
  1377. subdir = os.path.join(self.repo.path, "subdir")
  1378. os.mkdir(subdir)
  1379. with open(os.path.join(subdir, "file1.txt"), "w") as f:
  1380. f.write("content1")
  1381. with open(os.path.join(subdir, "file2.txt"), "w") as f:
  1382. f.write("content2")
  1383. with open(os.path.join(subdir, "file3.txt"), "w") as f:
  1384. f.write("content3")
  1385. # Add the directory
  1386. added, ignored = porcelain.add(self.repo.path, paths=["subdir"])
  1387. # Should add all files in the directory
  1388. self.assertEqual(len(added), 3)
  1389. # Normalize paths to use forward slashes for comparison
  1390. added_normalized = [path.replace(os.sep, "/") for path in added]
  1391. self.assertIn("subdir/file1.txt", added_normalized)
  1392. self.assertIn("subdir/file2.txt", added_normalized)
  1393. self.assertIn("subdir/file3.txt", added_normalized)
  1394. # Verify files are actually staged
  1395. index = self.repo.open_index()
  1396. self.assertIn(b"subdir/file1.txt", index)
  1397. self.assertIn(b"subdir/file2.txt", index)
  1398. self.assertIn(b"subdir/file3.txt", index)
  1399. def test_add_nested_directories(self) -> None:
  1400. """Test adding a directory with nested subdirectories."""
  1401. # Create nested directory structure
  1402. dir1 = os.path.join(self.repo.path, "dir1")
  1403. dir2 = os.path.join(dir1, "dir2")
  1404. dir3 = os.path.join(dir2, "dir3")
  1405. os.makedirs(dir3)
  1406. # Add files at each level
  1407. with open(os.path.join(dir1, "file1.txt"), "w") as f:
  1408. f.write("level1")
  1409. with open(os.path.join(dir2, "file2.txt"), "w") as f:
  1410. f.write("level2")
  1411. with open(os.path.join(dir3, "file3.txt"), "w") as f:
  1412. f.write("level3")
  1413. # Add the top-level directory
  1414. added, ignored = porcelain.add(self.repo.path, paths=["dir1"])
  1415. # Should add all files recursively
  1416. self.assertEqual(len(added), 3)
  1417. # Normalize paths to use forward slashes for comparison
  1418. added_normalized = [path.replace(os.sep, "/") for path in added]
  1419. self.assertIn("dir1/file1.txt", added_normalized)
  1420. self.assertIn("dir1/dir2/file2.txt", added_normalized)
  1421. self.assertIn("dir1/dir2/dir3/file3.txt", added_normalized)
  1422. # Verify files are actually staged
  1423. index = self.repo.open_index()
  1424. self.assertIn(b"dir1/file1.txt", index)
  1425. self.assertIn(b"dir1/dir2/file2.txt", index)
  1426. self.assertIn(b"dir1/dir2/dir3/file3.txt", index)
  1427. def test_add_directory_with_tracked_files(self) -> None:
  1428. """Test adding a directory with some files already tracked."""
  1429. # Create a subdirectory with files
  1430. subdir = os.path.join(self.repo.path, "mixed")
  1431. os.mkdir(subdir)
  1432. # Create and commit one file
  1433. tracked_file = os.path.join(subdir, "tracked.txt")
  1434. with open(tracked_file, "w") as f:
  1435. f.write("already tracked")
  1436. porcelain.add(self.repo.path, paths=[tracked_file])
  1437. porcelain.commit(
  1438. repo=self.repo.path,
  1439. message=b"Add tracked file",
  1440. author=b"test <email>",
  1441. committer=b"test <email>",
  1442. )
  1443. # Add more untracked files
  1444. with open(os.path.join(subdir, "untracked1.txt"), "w") as f:
  1445. f.write("new file 1")
  1446. with open(os.path.join(subdir, "untracked2.txt"), "w") as f:
  1447. f.write("new file 2")
  1448. # Add the directory
  1449. added, ignored = porcelain.add(self.repo.path, paths=["mixed"])
  1450. # Should only add the untracked files
  1451. self.assertEqual(len(added), 2)
  1452. # Normalize paths to use forward slashes for comparison
  1453. added_normalized = [path.replace(os.sep, "/") for path in added]
  1454. self.assertIn("mixed/untracked1.txt", added_normalized)
  1455. self.assertIn("mixed/untracked2.txt", added_normalized)
  1456. self.assertNotIn("mixed/tracked.txt", added)
  1457. # Verify the index contains all files
  1458. index = self.repo.open_index()
  1459. self.assertIn(b"mixed/tracked.txt", index)
  1460. self.assertIn(b"mixed/untracked1.txt", index)
  1461. self.assertIn(b"mixed/untracked2.txt", index)
  1462. def test_add_directory_with_gitignore(self) -> None:
  1463. """Test adding a directory respects .gitignore patterns."""
  1464. # Create .gitignore
  1465. with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
  1466. f.write("*.log\n*.tmp\nbuild/\n")
  1467. # Create directory with mixed files
  1468. testdir = os.path.join(self.repo.path, "testdir")
  1469. os.mkdir(testdir)
  1470. # Create various files
  1471. with open(os.path.join(testdir, "important.txt"), "w") as f:
  1472. f.write("keep this")
  1473. with open(os.path.join(testdir, "debug.log"), "w") as f:
  1474. f.write("ignore this")
  1475. with open(os.path.join(testdir, "temp.tmp"), "w") as f:
  1476. f.write("ignore this too")
  1477. with open(os.path.join(testdir, "readme.md"), "w") as f:
  1478. f.write("keep this too")
  1479. # Create a build directory that should be ignored
  1480. builddir = os.path.join(testdir, "build")
  1481. os.mkdir(builddir)
  1482. with open(os.path.join(builddir, "output.txt"), "w") as f:
  1483. f.write("ignore entire directory")
  1484. # Add the directory
  1485. added, ignored = porcelain.add(self.repo.path, paths=["testdir"])
  1486. # Should only add non-ignored files
  1487. # Normalize paths to use forward slashes for comparison
  1488. added_normalized = {path.replace(os.sep, "/") for path in added}
  1489. self.assertEqual(
  1490. added_normalized, {"testdir/important.txt", "testdir/readme.md"}
  1491. )
  1492. # Check ignored files
  1493. # Normalize paths to use forward slashes for comparison
  1494. ignored_normalized = {path.replace(os.sep, "/") for path in ignored}
  1495. self.assertIn("testdir/debug.log", ignored_normalized)
  1496. self.assertIn("testdir/temp.tmp", ignored_normalized)
  1497. self.assertIn("testdir/build/", ignored_normalized)
  1498. def test_add_multiple_directories(self) -> None:
  1499. """Test adding multiple directories in one call."""
  1500. # Create multiple directories
  1501. for dirname in ["dir1", "dir2", "dir3"]:
  1502. dirpath = os.path.join(self.repo.path, dirname)
  1503. os.mkdir(dirpath)
  1504. # Add files to each directory
  1505. for i in range(2):
  1506. with open(os.path.join(dirpath, f"file{i}.txt"), "w") as f:
  1507. f.write(f"content {dirname} {i}")
  1508. # Add all directories at once
  1509. added, ignored = porcelain.add(self.repo.path, paths=["dir1", "dir2", "dir3"])
  1510. # Should add all files from all directories
  1511. self.assertEqual(len(added), 6)
  1512. # Normalize paths to use forward slashes for comparison
  1513. added_normalized = [path.replace(os.sep, "/") for path in added]
  1514. for dirname in ["dir1", "dir2", "dir3"]:
  1515. for i in range(2):
  1516. self.assertIn(f"{dirname}/file{i}.txt", added_normalized)
  1517. # Verify all files are staged
  1518. index = self.repo.open_index()
  1519. self.assertEqual(len(index), 6)
  1520. def test_add_default_paths_includes_modified_files(self) -> None:
  1521. """Test that add() with no paths includes both untracked and modified files."""
  1522. # Create and commit initial file
  1523. initial_file = os.path.join(self.repo.path, "existing.txt")
  1524. with open(initial_file, "w") as f:
  1525. f.write("initial content\n")
  1526. porcelain.add(repo=self.repo.path, paths=[initial_file])
  1527. porcelain.commit(
  1528. repo=self.repo.path,
  1529. message=b"initial commit",
  1530. author=b"test <email>",
  1531. committer=b"test <email>",
  1532. )
  1533. # Modify the existing file (this creates an unstaged change)
  1534. with open(initial_file, "w") as f:
  1535. f.write("modified content\n")
  1536. # Create a new untracked file
  1537. new_file = os.path.join(self.repo.path, "new.txt")
  1538. with open(new_file, "w") as f:
  1539. f.write("new file content\n")
  1540. # Call add() with no paths - should stage both modified and untracked files
  1541. added_files, ignored_files = porcelain.add(repo=self.repo.path)
  1542. # Verify both files were added
  1543. self.assertIn("existing.txt", added_files)
  1544. self.assertIn("new.txt", added_files)
  1545. self.assertEqual(len(ignored_files), 0)
  1546. # Verify both files are now staged
  1547. index = self.repo.open_index()
  1548. self.assertIn(b"existing.txt", index)
  1549. self.assertIn(b"new.txt", index)
  1550. class RemoveTests(PorcelainTestCase):
  1551. def test_remove_file(self) -> None:
  1552. fullpath = os.path.join(self.repo.path, "foo")
  1553. with open(fullpath, "w") as f:
  1554. f.write("BAR")
  1555. porcelain.add(self.repo.path, paths=[fullpath])
  1556. porcelain.commit(
  1557. repo=self.repo,
  1558. message=b"test",
  1559. author=b"test <email>",
  1560. committer=b"test <email>",
  1561. )
  1562. self.assertTrue(os.path.exists(os.path.join(self.repo.path, "foo")))
  1563. cwd = os.getcwd()
  1564. self.addCleanup(os.chdir, cwd)
  1565. os.chdir(self.repo.path)
  1566. porcelain.remove(self.repo.path, paths=["foo"])
  1567. self.assertFalse(os.path.exists(os.path.join(self.repo.path, "foo")))
  1568. def test_remove_file_staged(self) -> None:
  1569. fullpath = os.path.join(self.repo.path, "foo")
  1570. with open(fullpath, "w") as f:
  1571. f.write("BAR")
  1572. cwd = os.getcwd()
  1573. self.addCleanup(os.chdir, cwd)
  1574. os.chdir(self.repo.path)
  1575. porcelain.add(self.repo.path, paths=[fullpath])
  1576. self.assertRaises(Exception, porcelain.rm, self.repo.path, paths=["foo"])
  1577. def test_remove_file_removed_on_disk(self) -> None:
  1578. fullpath = os.path.join(self.repo.path, "foo")
  1579. with open(fullpath, "w") as f:
  1580. f.write("BAR")
  1581. porcelain.add(self.repo.path, paths=[fullpath])
  1582. cwd = os.getcwd()
  1583. self.addCleanup(os.chdir, cwd)
  1584. os.chdir(self.repo.path)
  1585. os.remove(fullpath)
  1586. porcelain.remove(self.repo.path, paths=["foo"])
  1587. self.assertFalse(os.path.exists(os.path.join(self.repo.path, "foo")))
  1588. def test_remove_from_different_directory(self) -> None:
  1589. # Create a subdirectory with a file
  1590. subdir = os.path.join(self.repo.path, "mydir")
  1591. os.makedirs(subdir)
  1592. fullpath = os.path.join(subdir, "myfile")
  1593. with open(fullpath, "w") as f:
  1594. f.write("BAR")
  1595. # Add and commit the file
  1596. porcelain.add(self.repo.path, paths=[fullpath])
  1597. porcelain.commit(
  1598. repo=self.repo,
  1599. message=b"test",
  1600. author=b"test <email>",
  1601. committer=b"test <email>",
  1602. )
  1603. # Change to a different directory
  1604. cwd = os.getcwd()
  1605. tempdir = tempfile.mkdtemp()
  1606. def cleanup():
  1607. os.chdir(cwd)
  1608. shutil.rmtree(tempdir)
  1609. self.addCleanup(cleanup)
  1610. os.chdir(tempdir)
  1611. # Remove the file using relative path from repository root
  1612. porcelain.remove(self.repo.path, paths=["mydir/myfile"])
  1613. # Verify file was removed
  1614. self.assertFalse(os.path.exists(fullpath))
  1615. def test_remove_with_absolute_path(self) -> None:
  1616. # Create a file
  1617. fullpath = os.path.join(self.repo.path, "foo")
  1618. with open(fullpath, "w") as f:
  1619. f.write("BAR")
  1620. # Add and commit the file
  1621. porcelain.add(self.repo.path, paths=[fullpath])
  1622. porcelain.commit(
  1623. repo=self.repo,
  1624. message=b"test",
  1625. author=b"test <email>",
  1626. committer=b"test <email>",
  1627. )
  1628. # Change to a different directory
  1629. cwd = os.getcwd()
  1630. tempdir = tempfile.mkdtemp()
  1631. def cleanup():
  1632. os.chdir(cwd)
  1633. shutil.rmtree(tempdir)
  1634. self.addCleanup(cleanup)
  1635. os.chdir(tempdir)
  1636. # Remove the file using absolute path
  1637. porcelain.remove(self.repo.path, paths=[fullpath])
  1638. # Verify file was removed
  1639. self.assertFalse(os.path.exists(fullpath))
  1640. def test_remove_with_filter_normalization(self) -> None:
  1641. # Enable autocrlf to normalize line endings
  1642. config = self.repo.get_config()
  1643. config.set(("core",), "autocrlf", b"true")
  1644. config.write_to_path()
  1645. # Create a file with LF line endings (will be stored with LF in index)
  1646. fullpath = os.path.join(self.repo.path, "foo.txt")
  1647. with open(fullpath, "wb") as f:
  1648. f.write(b"line1\nline2\nline3")
  1649. # Add and commit the file (stored with LF in index)
  1650. porcelain.add(self.repo.path, paths=[fullpath])
  1651. porcelain.commit(
  1652. repo=self.repo,
  1653. message=b"Add file with LF",
  1654. author=b"test <email>",
  1655. committer=b"test <email>",
  1656. )
  1657. # Simulate checkout with CRLF conversion (as would happen on Windows)
  1658. with open(fullpath, "wb") as f:
  1659. f.write(b"line1\r\nline2\r\nline3")
  1660. # Verify file exists
  1661. self.assertTrue(os.path.exists(fullpath))
  1662. # Remove the file - this should not fail even though working tree has CRLF
  1663. # and index has LF (thanks to the normalization in the commit)
  1664. cwd = os.getcwd()
  1665. os.chdir(self.repo.path)
  1666. self.addCleanup(os.chdir, cwd)
  1667. porcelain.remove(self.repo.path, paths=["foo.txt"])
  1668. # Verify file was removed
  1669. self.assertFalse(os.path.exists(fullpath))
  1670. class MvTests(PorcelainTestCase):
  1671. def test_mv_file(self) -> None:
  1672. # Create a file
  1673. fullpath = os.path.join(self.repo.path, "foo")
  1674. with open(fullpath, "w") as f:
  1675. f.write("BAR")
  1676. # Add and commit the file
  1677. porcelain.add(self.repo.path, paths=[fullpath])
  1678. porcelain.commit(
  1679. repo=self.repo,
  1680. message=b"test",
  1681. author=b"test <email>",
  1682. committer=b"test <email>",
  1683. )
  1684. # Move the file
  1685. porcelain.mv(self.repo.path, "foo", "bar")
  1686. # Verify old path doesn't exist and new path does
  1687. self.assertFalse(os.path.exists(os.path.join(self.repo.path, "foo")))
  1688. self.assertTrue(os.path.exists(os.path.join(self.repo.path, "bar")))
  1689. # Verify index was updated
  1690. index = self.repo.open_index()
  1691. self.assertNotIn(b"foo", index)
  1692. self.assertIn(b"bar", index)
  1693. def test_mv_file_to_existing_directory(self) -> None:
  1694. # Create a file and a directory
  1695. fullpath = os.path.join(self.repo.path, "foo")
  1696. with open(fullpath, "w") as f:
  1697. f.write("BAR")
  1698. dirpath = os.path.join(self.repo.path, "mydir")
  1699. os.makedirs(dirpath)
  1700. # Add and commit the file
  1701. porcelain.add(self.repo.path, paths=[fullpath])
  1702. porcelain.commit(
  1703. repo=self.repo,
  1704. message=b"test",
  1705. author=b"test <email>",
  1706. committer=b"test <email>",
  1707. )
  1708. # Move the file into the directory
  1709. porcelain.mv(self.repo.path, "foo", "mydir")
  1710. # Verify file moved into directory
  1711. self.assertFalse(os.path.exists(os.path.join(self.repo.path, "foo")))
  1712. self.assertTrue(os.path.exists(os.path.join(self.repo.path, "mydir", "foo")))
  1713. # Verify index was updated
  1714. index = self.repo.open_index()
  1715. self.assertNotIn(b"foo", index)
  1716. self.assertIn(b"mydir/foo", index)
  1717. def test_mv_file_force_overwrite(self) -> None:
  1718. # Create two files
  1719. fullpath1 = os.path.join(self.repo.path, "foo")
  1720. with open(fullpath1, "w") as f:
  1721. f.write("FOO")
  1722. fullpath2 = os.path.join(self.repo.path, "bar")
  1723. with open(fullpath2, "w") as f:
  1724. f.write("BAR")
  1725. # Add and commit both files
  1726. porcelain.add(self.repo.path, paths=[fullpath1, fullpath2])
  1727. porcelain.commit(
  1728. repo=self.repo,
  1729. message=b"test",
  1730. author=b"test <email>",
  1731. committer=b"test <email>",
  1732. )
  1733. # Try to move without force (should fail)
  1734. self.assertRaises(porcelain.Error, porcelain.mv, self.repo.path, "foo", "bar")
  1735. # Move with force
  1736. porcelain.mv(self.repo.path, "foo", "bar", force=True)
  1737. # Verify foo doesn't exist and bar has foo's content
  1738. self.assertFalse(os.path.exists(os.path.join(self.repo.path, "foo")))
  1739. with open(os.path.join(self.repo.path, "bar")) as f:
  1740. self.assertEqual(f.read(), "FOO")
  1741. def test_mv_file_not_tracked(self) -> None:
  1742. # Create an untracked file
  1743. fullpath = os.path.join(self.repo.path, "untracked")
  1744. with open(fullpath, "w") as f:
  1745. f.write("UNTRACKED")
  1746. # Try to move it (should fail)
  1747. self.assertRaises(
  1748. porcelain.Error, porcelain.mv, self.repo.path, "untracked", "tracked"
  1749. )
  1750. def test_mv_file_not_exists(self) -> None:
  1751. # Try to move a non-existent file
  1752. self.assertRaises(
  1753. porcelain.Error, porcelain.mv, self.repo.path, "nonexistent", "destination"
  1754. )
  1755. def test_mv_absolute_paths(self) -> None:
  1756. # Create a file
  1757. fullpath = os.path.join(self.repo.path, "foo")
  1758. with open(fullpath, "w") as f:
  1759. f.write("BAR")
  1760. # Add and commit the file
  1761. porcelain.add(self.repo.path, paths=[fullpath])
  1762. porcelain.commit(
  1763. repo=self.repo,
  1764. message=b"test",
  1765. author=b"test <email>",
  1766. committer=b"test <email>",
  1767. )
  1768. # Move using absolute paths
  1769. dest_path = os.path.join(self.repo.path, "bar")
  1770. porcelain.mv(self.repo.path, fullpath, dest_path)
  1771. # Verify file moved
  1772. self.assertFalse(os.path.exists(fullpath))
  1773. self.assertTrue(os.path.exists(dest_path))
  1774. def test_mv_from_different_directory(self) -> None:
  1775. # Create a subdirectory with a file
  1776. subdir = os.path.join(self.repo.path, "mydir")
  1777. os.makedirs(subdir)
  1778. fullpath = os.path.join(subdir, "myfile")
  1779. with open(fullpath, "w") as f:
  1780. f.write("BAR")
  1781. # Add and commit the file
  1782. porcelain.add(self.repo.path, paths=[fullpath])
  1783. porcelain.commit(
  1784. repo=self.repo,
  1785. message=b"test",
  1786. author=b"test <email>",
  1787. committer=b"test <email>",
  1788. )
  1789. # Change to a different directory and move the file
  1790. cwd = os.getcwd()
  1791. tempdir = tempfile.mkdtemp()
  1792. def cleanup():
  1793. os.chdir(cwd)
  1794. shutil.rmtree(tempdir)
  1795. self.addCleanup(cleanup)
  1796. os.chdir(tempdir)
  1797. # Move the file using relative path from repository root
  1798. porcelain.mv(self.repo.path, "mydir/myfile", "renamed")
  1799. # Verify file was moved
  1800. self.assertFalse(os.path.exists(fullpath))
  1801. self.assertTrue(os.path.exists(os.path.join(self.repo.path, "renamed")))
  1802. class LogTests(PorcelainTestCase):
  1803. def test_simple(self) -> None:
  1804. c1, c2, c3 = build_commit_graph(
  1805. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  1806. )
  1807. self.repo.refs[b"HEAD"] = c3.id
  1808. self.maxDiff = None
  1809. outstream = StringIO()
  1810. porcelain.log(self.repo.path, outstream=outstream)
  1811. self.assertEqual(
  1812. outstream.getvalue(),
  1813. """\
  1814. --------------------------------------------------
  1815. commit: 4a3b887baa9ecb2d054d2469b628aef84e2d74f0
  1816. merge: 7508036b1cfec5aa9cef0d5a7f04abcecfe09112
  1817. Author: Test Author <test@nodomain.com>
  1818. Committer: Test Committer <test@nodomain.com>
  1819. Date: Fri Jan 01 2010 00:00:00 +0000
  1820. Commit 3
  1821. --------------------------------------------------
  1822. commit: 7508036b1cfec5aa9cef0d5a7f04abcecfe09112
  1823. Author: Test Author <test@nodomain.com>
  1824. Committer: Test Committer <test@nodomain.com>
  1825. Date: Fri Jan 01 2010 00:00:00 +0000
  1826. Commit 2
  1827. --------------------------------------------------
  1828. commit: 11d3cf672a19366435c1983c7340b008ec6b8bf3
  1829. Author: Test Author <test@nodomain.com>
  1830. Committer: Test Committer <test@nodomain.com>
  1831. Date: Fri Jan 01 2010 00:00:00 +0000
  1832. Commit 1
  1833. """,
  1834. )
  1835. def test_max_entries(self) -> None:
  1836. c1, c2, c3 = build_commit_graph(
  1837. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  1838. )
  1839. self.repo.refs[b"HEAD"] = c3.id
  1840. outstream = StringIO()
  1841. porcelain.log(self.repo.path, outstream=outstream, max_entries=1)
  1842. self.assertEqual(1, outstream.getvalue().count("-" * 50))
  1843. def test_no_revisions(self) -> None:
  1844. outstream = StringIO()
  1845. porcelain.log(self.repo.path, outstream=outstream)
  1846. self.assertEqual("", outstream.getvalue())
  1847. def test_empty_message(self) -> None:
  1848. c1 = make_commit(message="")
  1849. self.repo.object_store.add_object(c1)
  1850. self.repo.refs[b"HEAD"] = c1.id
  1851. outstream = StringIO()
  1852. porcelain.log(self.repo.path, outstream=outstream)
  1853. self.assertEqual(
  1854. outstream.getvalue(),
  1855. """\
  1856. --------------------------------------------------
  1857. commit: 4a7ad5552fad70647a81fb9a4a923ccefcca4b76
  1858. Author: Test Author <test@nodomain.com>
  1859. Committer: Test Committer <test@nodomain.com>
  1860. Date: Fri Jan 01 2010 00:00:00 +0000
  1861. """,
  1862. )
  1863. class ShowTests(PorcelainTestCase):
  1864. def test_nolist(self) -> None:
  1865. c1, c2, c3 = build_commit_graph(
  1866. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  1867. )
  1868. self.repo.refs[b"HEAD"] = c3.id
  1869. outstream = StringIO()
  1870. porcelain.show(self.repo.path, objects=c3.id, outstream=outstream)
  1871. self.assertTrue(outstream.getvalue().startswith("-" * 50))
  1872. def test_simple(self) -> None:
  1873. c1, c2, c3 = build_commit_graph(
  1874. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  1875. )
  1876. self.repo.refs[b"HEAD"] = c3.id
  1877. outstream = StringIO()
  1878. porcelain.show(self.repo.path, objects=[c3.id], outstream=outstream)
  1879. self.assertTrue(outstream.getvalue().startswith("-" * 50))
  1880. def test_blob(self) -> None:
  1881. b = Blob.from_string(b"The Foo\n")
  1882. self.repo.object_store.add_object(b)
  1883. outstream = StringIO()
  1884. porcelain.show(self.repo.path, objects=[b.id], outstream=outstream)
  1885. self.assertEqual(outstream.getvalue(), "The Foo\n")
  1886. def test_commit_no_parent(self) -> None:
  1887. a = Blob.from_string(b"The Foo\n")
  1888. ta = Tree()
  1889. ta.add(b"somename", 0o100644, a.id)
  1890. ca = make_commit(tree=ta.id)
  1891. self.repo.object_store.add_objects([(a, None), (ta, None), (ca, None)])
  1892. outstream = StringIO()
  1893. porcelain.show(self.repo.path, objects=[ca.id], outstream=outstream)
  1894. self.assertMultiLineEqual(
  1895. outstream.getvalue(),
  1896. """\
  1897. --------------------------------------------------
  1898. commit: 344da06c1bb85901270b3e8875c988a027ec087d
  1899. Author: Test Author <test@nodomain.com>
  1900. Committer: Test Committer <test@nodomain.com>
  1901. Date: Fri Jan 01 2010 00:00:00 +0000
  1902. Test message.
  1903. diff --git a/somename b/somename
  1904. new file mode 100644
  1905. index 0000000..ea5c7bf
  1906. --- /dev/null
  1907. +++ b/somename
  1908. @@ -0,0 +1 @@
  1909. +The Foo
  1910. """,
  1911. )
  1912. def test_tag(self) -> None:
  1913. a = Blob.from_string(b"The Foo\n")
  1914. ta = Tree()
  1915. ta.add(b"somename", 0o100644, a.id)
  1916. ca = make_commit(tree=ta.id)
  1917. self.repo.object_store.add_objects([(a, None), (ta, None), (ca, None)])
  1918. porcelain.tag_create(
  1919. self.repo.path,
  1920. b"tryme",
  1921. b"foo <foo@bar.com>",
  1922. b"bar",
  1923. annotated=True,
  1924. objectish=ca.id,
  1925. tag_time=1552854211,
  1926. tag_timezone=0,
  1927. )
  1928. outstream = StringIO()
  1929. porcelain.show(self.repo, objects=[b"refs/tags/tryme"], outstream=outstream)
  1930. self.maxDiff = None
  1931. self.assertMultiLineEqual(
  1932. outstream.getvalue(),
  1933. """\
  1934. Tagger: foo <foo@bar.com>
  1935. Date: Sun Mar 17 2019 20:23:31 +0000
  1936. bar
  1937. --------------------------------------------------
  1938. commit: 344da06c1bb85901270b3e8875c988a027ec087d
  1939. Author: Test Author <test@nodomain.com>
  1940. Committer: Test Committer <test@nodomain.com>
  1941. Date: Fri Jan 01 2010 00:00:00 +0000
  1942. Test message.
  1943. diff --git a/somename b/somename
  1944. new file mode 100644
  1945. index 0000000..ea5c7bf
  1946. --- /dev/null
  1947. +++ b/somename
  1948. @@ -0,0 +1 @@
  1949. +The Foo
  1950. """,
  1951. )
  1952. def test_tag_unicode(self) -> None:
  1953. a = Blob.from_string(b"The Foo\n")
  1954. ta = Tree()
  1955. ta.add(b"somename", 0o100644, a.id)
  1956. ca = make_commit(tree=ta.id)
  1957. self.repo.object_store.add_objects([(a, None), (ta, None), (ca, None)])
  1958. porcelain.tag_create(
  1959. self.repo.path,
  1960. "tryme",
  1961. "foo <foo@bar.com>",
  1962. "bar",
  1963. annotated=True,
  1964. objectish=ca.id,
  1965. tag_time=1552854211,
  1966. tag_timezone=0,
  1967. )
  1968. outstream = StringIO()
  1969. porcelain.show(self.repo, objects=[b"refs/tags/tryme"], outstream=outstream)
  1970. self.maxDiff = None
  1971. self.assertMultiLineEqual(
  1972. outstream.getvalue(),
  1973. """\
  1974. Tagger: foo <foo@bar.com>
  1975. Date: Sun Mar 17 2019 20:23:31 +0000
  1976. bar
  1977. --------------------------------------------------
  1978. commit: 344da06c1bb85901270b3e8875c988a027ec087d
  1979. Author: Test Author <test@nodomain.com>
  1980. Committer: Test Committer <test@nodomain.com>
  1981. Date: Fri Jan 01 2010 00:00:00 +0000
  1982. Test message.
  1983. diff --git a/somename b/somename
  1984. new file mode 100644
  1985. index 0000000..ea5c7bf
  1986. --- /dev/null
  1987. +++ b/somename
  1988. @@ -0,0 +1 @@
  1989. +The Foo
  1990. """,
  1991. )
  1992. def test_commit_with_change(self) -> None:
  1993. a = Blob.from_string(b"The Foo\n")
  1994. ta = Tree()
  1995. ta.add(b"somename", 0o100644, a.id)
  1996. ca = make_commit(tree=ta.id)
  1997. b = Blob.from_string(b"The Bar\n")
  1998. tb = Tree()
  1999. tb.add(b"somename", 0o100644, b.id)
  2000. cb = make_commit(tree=tb.id, parents=[ca.id])
  2001. self.repo.object_store.add_objects(
  2002. [
  2003. (a, None),
  2004. (b, None),
  2005. (ta, None),
  2006. (tb, None),
  2007. (ca, None),
  2008. (cb, None),
  2009. ]
  2010. )
  2011. outstream = StringIO()
  2012. porcelain.show(self.repo.path, objects=[cb.id], outstream=outstream)
  2013. self.assertMultiLineEqual(
  2014. outstream.getvalue(),
  2015. """\
  2016. --------------------------------------------------
  2017. commit: 2c6b6c9cb72c130956657e1fdae58e5b103744fa
  2018. Author: Test Author <test@nodomain.com>
  2019. Committer: Test Committer <test@nodomain.com>
  2020. Date: Fri Jan 01 2010 00:00:00 +0000
  2021. Test message.
  2022. diff --git a/somename b/somename
  2023. index ea5c7bf..fd38bcb 100644
  2024. --- a/somename
  2025. +++ b/somename
  2026. @@ -1 +1 @@
  2027. -The Foo
  2028. +The Bar
  2029. """,
  2030. )
  2031. class FormatPatchTests(PorcelainTestCase):
  2032. def test_format_patch_single_commit(self) -> None:
  2033. # Create initial commit
  2034. tree1 = Tree()
  2035. c1 = make_commit(
  2036. tree=tree1,
  2037. message=b"Initial commit",
  2038. )
  2039. self.repo.object_store.add_objects([(tree1, None), (c1, None)])
  2040. # Create second commit
  2041. b = Blob.from_string(b"modified")
  2042. tree = Tree()
  2043. tree.add(b"test.txt", 0o100644, b.id)
  2044. c2 = make_commit(
  2045. tree=tree,
  2046. parents=[c1.id],
  2047. message=b"Add test.txt",
  2048. )
  2049. self.repo.object_store.add_objects([(b, None), (tree, None), (c2, None)])
  2050. self.repo[b"HEAD"] = c2.id
  2051. # Generate patch for single commit
  2052. with tempfile.TemporaryDirectory() as tmpdir:
  2053. patches = porcelain.format_patch(
  2054. self.repo.path,
  2055. committish=c2.id,
  2056. outdir=tmpdir,
  2057. )
  2058. self.assertEqual(len(patches), 1)
  2059. self.assertTrue(patches[0].endswith("-Add-test.txt.patch"))
  2060. # Verify patch content
  2061. with open(patches[0], "rb") as f:
  2062. content = f.read()
  2063. self.assertIn(b"Subject: [PATCH 1/1] Add test.txt", content)
  2064. self.assertIn(b"+modified", content)
  2065. def test_format_patch_multiple_commits(self) -> None:
  2066. # Create commit chain
  2067. commits = []
  2068. for i in range(3):
  2069. blob = Blob.from_string(f"content {i}".encode())
  2070. tree = Tree()
  2071. tree.add(f"file{i}.txt".encode(), 0o100644, blob.id)
  2072. parents = [commits[-1].id] if commits else []
  2073. commit = make_commit(
  2074. tree=tree,
  2075. parents=parents,
  2076. message=f"Commit {i}".encode(),
  2077. )
  2078. self.repo.object_store.add_objects(
  2079. [(blob, None), (tree, None), (commit, None)]
  2080. )
  2081. commits.append(commit)
  2082. self.repo[b"HEAD"] = commits[-1].id
  2083. # Test generating last 2 commits
  2084. with tempfile.TemporaryDirectory() as tmpdir:
  2085. patches = porcelain.format_patch(
  2086. self.repo.path,
  2087. n=2,
  2088. outdir=tmpdir,
  2089. )
  2090. self.assertEqual(len(patches), 2)
  2091. self.assertTrue(patches[0].endswith("-Commit-1.patch"))
  2092. self.assertTrue(patches[1].endswith("-Commit-2.patch"))
  2093. # Check patch numbering
  2094. with open(patches[0], "rb") as f:
  2095. self.assertIn(b"Subject: [PATCH 1/2] Commit 1", f.read())
  2096. with open(patches[1], "rb") as f:
  2097. self.assertIn(b"Subject: [PATCH 2/2] Commit 2", f.read())
  2098. def test_format_patch_range(self) -> None:
  2099. # Create commit chain
  2100. c1, c2, c3 = build_commit_graph(self.repo.object_store, [[1], [2, 1], [3, 2]])
  2101. self.repo[b"HEAD"] = c3.id
  2102. # Test commit range
  2103. with tempfile.TemporaryDirectory() as tmpdir:
  2104. patches = porcelain.format_patch(
  2105. self.repo.path,
  2106. committish=(c1.id, c3.id),
  2107. outdir=tmpdir,
  2108. )
  2109. # Should include c2 and c3
  2110. self.assertEqual(len(patches), 2)
  2111. def test_format_patch_stdout(self) -> None:
  2112. # Create a commit
  2113. blob = Blob.from_string(b"test content")
  2114. tree = Tree()
  2115. tree.add(b"test.txt", 0o100644, blob.id)
  2116. commit = make_commit(
  2117. tree=tree,
  2118. message=b"Test commit",
  2119. )
  2120. self.repo.object_store.add_objects([(blob, None), (tree, None), (commit, None)])
  2121. self.repo[b"HEAD"] = commit.id
  2122. # Test stdout output
  2123. outstream = BytesIO()
  2124. patches = porcelain.format_patch(
  2125. self.repo.path,
  2126. committish=commit.id,
  2127. stdout=True,
  2128. outstream=outstream,
  2129. )
  2130. # Should return empty list when writing to stdout
  2131. self.assertEqual(patches, [])
  2132. # Check stdout content
  2133. outstream.seek(0)
  2134. content = outstream.read()
  2135. self.assertIn(b"Subject: [PATCH 1/1] Test commit", content)
  2136. self.assertIn(b"diff --git", content)
  2137. def test_format_patch_no_commits(self) -> None:
  2138. # Test with a new repository with no commits
  2139. # Just remove HEAD to simulate empty repo
  2140. patches = porcelain.format_patch(
  2141. self.repo.path,
  2142. n=5,
  2143. )
  2144. self.assertEqual(patches, [])
  2145. class SymbolicRefTests(PorcelainTestCase):
  2146. def test_set_wrong_symbolic_ref(self) -> None:
  2147. c1, c2, c3 = build_commit_graph(
  2148. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2149. )
  2150. self.repo.refs[b"HEAD"] = c3.id
  2151. self.assertRaises(
  2152. porcelain.Error, porcelain.symbolic_ref, self.repo.path, b"foobar"
  2153. )
  2154. def test_set_force_wrong_symbolic_ref(self) -> None:
  2155. c1, c2, c3 = build_commit_graph(
  2156. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2157. )
  2158. self.repo.refs[b"HEAD"] = c3.id
  2159. porcelain.symbolic_ref(self.repo.path, b"force_foobar", force=True)
  2160. # test if we actually changed the file
  2161. with self.repo.get_named_file("HEAD") as f:
  2162. new_ref = f.read()
  2163. self.assertEqual(new_ref, b"ref: refs/heads/force_foobar\n")
  2164. def test_set_symbolic_ref(self) -> None:
  2165. c1, c2, c3 = build_commit_graph(
  2166. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2167. )
  2168. self.repo.refs[b"HEAD"] = c3.id
  2169. porcelain.symbolic_ref(self.repo.path, b"master")
  2170. def test_set_symbolic_ref_other_than_master(self) -> None:
  2171. c1, c2, c3 = build_commit_graph(
  2172. self.repo.object_store,
  2173. [[1], [2, 1], [3, 1, 2]],
  2174. attrs=dict(refs="develop"),
  2175. )
  2176. self.repo.refs[b"HEAD"] = c3.id
  2177. self.repo.refs[b"refs/heads/develop"] = c3.id
  2178. porcelain.symbolic_ref(self.repo.path, b"develop")
  2179. # test if we actually changed the file
  2180. with self.repo.get_named_file("HEAD") as f:
  2181. new_ref = f.read()
  2182. self.assertEqual(new_ref, b"ref: refs/heads/develop\n")
  2183. class DiffTreeTests(PorcelainTestCase):
  2184. def test_empty(self) -> None:
  2185. c1, c2, c3 = build_commit_graph(
  2186. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2187. )
  2188. self.repo.refs[b"HEAD"] = c3.id
  2189. outstream = BytesIO()
  2190. porcelain.diff_tree(self.repo.path, c2.tree, c3.tree, outstream=outstream)
  2191. self.assertEqual(outstream.getvalue(), b"")
  2192. class DiffTests(PorcelainTestCase):
  2193. def test_diff_uncommitted_stage(self) -> None:
  2194. # Test diff in repository with no commits yet
  2195. fullpath = os.path.join(self.repo.path, "test.txt")
  2196. with open(fullpath, "w") as f:
  2197. f.write("Hello, world!\n")
  2198. porcelain.add(self.repo.path, paths=["test.txt"])
  2199. outstream = BytesIO()
  2200. porcelain.diff(self.repo.path, staged=True, outstream=outstream)
  2201. diff_output = outstream.getvalue()
  2202. self.assertIn(b"diff --git a/test.txt b/test.txt", diff_output)
  2203. self.assertIn(b"new file mode", diff_output)
  2204. self.assertIn(b"+Hello, world!", diff_output)
  2205. def test_diff_uncommitted_working_tree(self) -> None:
  2206. # Test unstaged changes in repository with no commits
  2207. fullpath = os.path.join(self.repo.path, "test.txt")
  2208. with open(fullpath, "w") as f:
  2209. f.write("Hello, world!\n")
  2210. porcelain.add(self.repo.path, paths=["test.txt"])
  2211. # Modify file in working tree
  2212. with open(fullpath, "w") as f:
  2213. f.write("Hello, world!\nNew line\n")
  2214. outstream = BytesIO()
  2215. porcelain.diff(self.repo.path, staged=False, outstream=outstream)
  2216. diff_output = outstream.getvalue()
  2217. self.assertIn(b"diff --git a/test.txt b/test.txt", diff_output)
  2218. self.assertIn(b"+New line", diff_output)
  2219. def test_diff_with_commits_staged(self) -> None:
  2220. # Test staged changes with existing commits
  2221. fullpath = os.path.join(self.repo.path, "test.txt")
  2222. with open(fullpath, "w") as f:
  2223. f.write("Initial content\n")
  2224. porcelain.add(self.repo.path, paths=["test.txt"])
  2225. porcelain.commit(self.repo.path, message=b"Initial commit")
  2226. # Modify and stage
  2227. with open(fullpath, "w") as f:
  2228. f.write("Initial content\nModified\n")
  2229. porcelain.add(self.repo.path, paths=["test.txt"])
  2230. outstream = BytesIO()
  2231. porcelain.diff(self.repo.path, staged=True, outstream=outstream)
  2232. diff_output = outstream.getvalue()
  2233. self.assertIn(b"diff --git a/test.txt b/test.txt", diff_output)
  2234. self.assertIn(b"+Modified", diff_output)
  2235. def test_diff_with_commits_unstaged(self) -> None:
  2236. # Test unstaged changes with existing commits
  2237. fullpath = os.path.join(self.repo.path, "test.txt")
  2238. with open(fullpath, "w") as f:
  2239. f.write("Initial content\n")
  2240. porcelain.add(self.repo.path, paths=["test.txt"])
  2241. porcelain.commit(self.repo.path, message=b"Initial commit")
  2242. # Modify without staging
  2243. with open(fullpath, "w") as f:
  2244. f.write("Initial content\nModified\n")
  2245. outstream = BytesIO()
  2246. porcelain.diff(self.repo.path, staged=False, outstream=outstream)
  2247. diff_output = outstream.getvalue()
  2248. self.assertIn(b"diff --git a/test.txt b/test.txt", diff_output)
  2249. self.assertIn(b"+Modified", diff_output)
  2250. def test_diff_file_deletion(self) -> None:
  2251. # Test showing file deletion
  2252. fullpath = os.path.join(self.repo.path, "test.txt")
  2253. with open(fullpath, "w") as f:
  2254. f.write("Content to delete\n")
  2255. porcelain.add(self.repo.path, paths=["test.txt"])
  2256. porcelain.commit(self.repo.path, message=b"Add file")
  2257. # Delete file
  2258. os.unlink(fullpath)
  2259. outstream = BytesIO()
  2260. porcelain.diff(self.repo.path, staged=False, outstream=outstream)
  2261. diff_output = outstream.getvalue()
  2262. self.assertIn(b"diff --git a/test.txt b/test.txt", diff_output)
  2263. self.assertIn(b"deleted file mode", diff_output)
  2264. self.assertIn(b"-Content to delete", diff_output)
  2265. def test_diff_with_paths(self) -> None:
  2266. # Test diff with specific paths
  2267. # Create two files
  2268. fullpath1 = os.path.join(self.repo.path, "file1.txt")
  2269. fullpath2 = os.path.join(self.repo.path, "file2.txt")
  2270. with open(fullpath1, "w") as f:
  2271. f.write("File 1 content\n")
  2272. with open(fullpath2, "w") as f:
  2273. f.write("File 2 content\n")
  2274. porcelain.add(self.repo.path, paths=["file1.txt", "file2.txt"])
  2275. porcelain.commit(self.repo.path, message=b"Add two files")
  2276. # Modify both files
  2277. with open(fullpath1, "w") as f:
  2278. f.write("File 1 modified\n")
  2279. with open(fullpath2, "w") as f:
  2280. f.write("File 2 modified\n")
  2281. # Test diff with specific path
  2282. outstream = BytesIO()
  2283. porcelain.diff(self.repo.path, paths=["file1.txt"], outstream=outstream)
  2284. diff_output = outstream.getvalue()
  2285. self.assertIn(b"diff --git a/file1.txt b/file1.txt", diff_output)
  2286. self.assertIn(b"-File 1 content", diff_output)
  2287. self.assertIn(b"+File 1 modified", diff_output)
  2288. # file2.txt should not appear in diff
  2289. self.assertNotIn(b"file2.txt", diff_output)
  2290. def test_diff_with_paths_multiple(self) -> None:
  2291. # Test diff with multiple paths
  2292. # Create three files
  2293. fullpath1 = os.path.join(self.repo.path, "file1.txt")
  2294. fullpath2 = os.path.join(self.repo.path, "file2.txt")
  2295. fullpath3 = os.path.join(self.repo.path, "file3.txt")
  2296. with open(fullpath1, "w") as f:
  2297. f.write("File 1 content\n")
  2298. with open(fullpath2, "w") as f:
  2299. f.write("File 2 content\n")
  2300. with open(fullpath3, "w") as f:
  2301. f.write("File 3 content\n")
  2302. porcelain.add(self.repo.path, paths=["file1.txt", "file2.txt", "file3.txt"])
  2303. porcelain.commit(self.repo.path, message=b"Add three files")
  2304. # Modify all files
  2305. with open(fullpath1, "w") as f:
  2306. f.write("File 1 modified\n")
  2307. with open(fullpath2, "w") as f:
  2308. f.write("File 2 modified\n")
  2309. with open(fullpath3, "w") as f:
  2310. f.write("File 3 modified\n")
  2311. # Test diff with two specific paths
  2312. outstream = BytesIO()
  2313. porcelain.diff(
  2314. self.repo.path, paths=["file1.txt", "file3.txt"], outstream=outstream
  2315. )
  2316. diff_output = outstream.getvalue()
  2317. self.assertIn(b"diff --git a/file1.txt b/file1.txt", diff_output)
  2318. self.assertIn(b"diff --git a/file3.txt b/file3.txt", diff_output)
  2319. # file2.txt should not appear in diff
  2320. self.assertNotIn(b"file2.txt", diff_output)
  2321. def test_diff_with_paths_directory(self) -> None:
  2322. # Test diff with directory paths
  2323. # Create files in subdirectory
  2324. os.mkdir(os.path.join(self.repo.path, "subdir"))
  2325. fullpath1 = os.path.join(self.repo.path, "subdir", "file1.txt")
  2326. fullpath2 = os.path.join(self.repo.path, "subdir", "file2.txt")
  2327. fullpath3 = os.path.join(self.repo.path, "root.txt")
  2328. with open(fullpath1, "w") as f:
  2329. f.write("Subdir file 1\n")
  2330. with open(fullpath2, "w") as f:
  2331. f.write("Subdir file 2\n")
  2332. with open(fullpath3, "w") as f:
  2333. f.write("Root file\n")
  2334. porcelain.add(
  2335. self.repo.path, paths=["subdir/file1.txt", "subdir/file2.txt", "root.txt"]
  2336. )
  2337. porcelain.commit(self.repo.path, message=b"Add files in subdir")
  2338. # Modify all files
  2339. with open(fullpath1, "w") as f:
  2340. f.write("Subdir file 1 modified\n")
  2341. with open(fullpath2, "w") as f:
  2342. f.write("Subdir file 2 modified\n")
  2343. with open(fullpath3, "w") as f:
  2344. f.write("Root file modified\n")
  2345. # Test diff with directory path
  2346. outstream = BytesIO()
  2347. porcelain.diff(self.repo.path, paths=["subdir"], outstream=outstream)
  2348. diff_output = outstream.getvalue()
  2349. self.assertIn(b"subdir/file1.txt", diff_output)
  2350. self.assertIn(b"subdir/file2.txt", diff_output)
  2351. # root.txt should not appear in diff
  2352. self.assertNotIn(b"root.txt", diff_output)
  2353. def test_diff_staged_with_paths(self) -> None:
  2354. # Test staged diff with specific paths
  2355. # Create two files
  2356. fullpath1 = os.path.join(self.repo.path, "file1.txt")
  2357. fullpath2 = os.path.join(self.repo.path, "file2.txt")
  2358. with open(fullpath1, "w") as f:
  2359. f.write("File 1 content\n")
  2360. with open(fullpath2, "w") as f:
  2361. f.write("File 2 content\n")
  2362. porcelain.add(self.repo.path, paths=["file1.txt", "file2.txt"])
  2363. porcelain.commit(self.repo.path, message=b"Add two files")
  2364. # Modify and stage both files
  2365. with open(fullpath1, "w") as f:
  2366. f.write("File 1 staged\n")
  2367. with open(fullpath2, "w") as f:
  2368. f.write("File 2 staged\n")
  2369. porcelain.add(self.repo.path, paths=["file1.txt", "file2.txt"])
  2370. # Test staged diff with specific path
  2371. outstream = BytesIO()
  2372. porcelain.diff(
  2373. self.repo.path, staged=True, paths=["file1.txt"], outstream=outstream
  2374. )
  2375. diff_output = outstream.getvalue()
  2376. self.assertIn(b"diff --git a/file1.txt b/file1.txt", diff_output)
  2377. self.assertIn(b"-File 1 content", diff_output)
  2378. self.assertIn(b"+File 1 staged", diff_output)
  2379. # file2.txt should not appear in diff
  2380. self.assertNotIn(b"file2.txt", diff_output)
  2381. def test_diff_with_commit_and_paths(self) -> None:
  2382. # Test diff against specific commit with paths
  2383. # Create initial file
  2384. fullpath1 = os.path.join(self.repo.path, "file1.txt")
  2385. fullpath2 = os.path.join(self.repo.path, "file2.txt")
  2386. with open(fullpath1, "w") as f:
  2387. f.write("Initial content 1\n")
  2388. with open(fullpath2, "w") as f:
  2389. f.write("Initial content 2\n")
  2390. porcelain.add(self.repo.path, paths=["file1.txt", "file2.txt"])
  2391. first_commit = porcelain.commit(self.repo.path, message=b"Initial commit")
  2392. # Make second commit
  2393. with open(fullpath1, "w") as f:
  2394. f.write("Second content 1\n")
  2395. with open(fullpath2, "w") as f:
  2396. f.write("Second content 2\n")
  2397. porcelain.add(self.repo.path, paths=["file1.txt", "file2.txt"])
  2398. porcelain.commit(self.repo.path, message=b"Second commit")
  2399. # Modify working tree
  2400. with open(fullpath1, "w") as f:
  2401. f.write("Working content 1\n")
  2402. with open(fullpath2, "w") as f:
  2403. f.write("Working content 2\n")
  2404. # Test diff against first commit with specific path
  2405. outstream = BytesIO()
  2406. porcelain.diff(
  2407. self.repo.path,
  2408. commit=first_commit,
  2409. paths=["file1.txt"],
  2410. outstream=outstream,
  2411. )
  2412. diff_output = outstream.getvalue()
  2413. self.assertIn(b"diff --git a/file1.txt b/file1.txt", diff_output)
  2414. self.assertIn(b"-Initial content 1", diff_output)
  2415. self.assertIn(b"+Working content 1", diff_output)
  2416. # file2.txt should not appear in diff
  2417. self.assertNotIn(b"file2.txt", diff_output)
  2418. class CommitTreeTests(PorcelainTestCase):
  2419. def test_simple(self) -> None:
  2420. c1, c2, c3 = build_commit_graph(
  2421. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2422. )
  2423. b = Blob()
  2424. b.data = b"foo the bar"
  2425. t = Tree()
  2426. t.add(b"somename", 0o100644, b.id)
  2427. self.repo.object_store.add_object(t)
  2428. self.repo.object_store.add_object(b)
  2429. sha = porcelain.commit_tree(
  2430. self.repo.path,
  2431. t.id,
  2432. message=b"Withcommit.",
  2433. author=b"Joe <joe@example.com>",
  2434. committer=b"Jane <jane@example.com>",
  2435. )
  2436. self.assertIsInstance(sha, bytes)
  2437. self.assertEqual(len(sha), 40)
  2438. class RevListTests(PorcelainTestCase):
  2439. def test_simple(self) -> None:
  2440. c1, c2, c3 = build_commit_graph(
  2441. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2442. )
  2443. outstream = BytesIO()
  2444. porcelain.rev_list(self.repo.path, [c3.id], outstream=outstream)
  2445. self.assertEqual(
  2446. c3.id + b"\n" + c2.id + b"\n" + c1.id + b"\n", outstream.getvalue()
  2447. )
  2448. @skipIf(
  2449. platform.python_implementation() == "PyPy" or sys.platform == "win32",
  2450. "gpgme not easily available or supported on Windows and PyPy",
  2451. )
  2452. class TagCreateSignTests(PorcelainGpgTestCase):
  2453. def test_default_key(self) -> None:
  2454. c1, c2, c3 = build_commit_graph(
  2455. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2456. )
  2457. self.repo.refs[b"HEAD"] = c3.id
  2458. cfg = self.repo.get_config()
  2459. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  2460. self.import_default_key()
  2461. porcelain.tag_create(
  2462. self.repo.path,
  2463. b"tryme",
  2464. b"foo <foo@bar.com>",
  2465. b"bar",
  2466. annotated=True,
  2467. sign=True,
  2468. )
  2469. tags = self.repo.refs.as_dict(b"refs/tags")
  2470. self.assertEqual(list(tags.keys()), [b"tryme"])
  2471. tag = self.repo[b"refs/tags/tryme"]
  2472. self.assertIsInstance(tag, Tag)
  2473. self.assertEqual(b"foo <foo@bar.com>", tag.tagger)
  2474. self.assertEqual(b"bar\n", tag.message)
  2475. self.assertRecentTimestamp(tag.tag_time)
  2476. tag = self.repo[b"refs/tags/tryme"]
  2477. assert isinstance(tag, Tag)
  2478. # GPG Signatures aren't deterministic, so we can't do a static assertion.
  2479. tag.verify()
  2480. tag.verify(keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID])
  2481. self.import_non_default_key()
  2482. self.assertRaises(
  2483. gpg.errors.MissingSignatures,
  2484. tag.verify,
  2485. keyids=[PorcelainGpgTestCase.NON_DEFAULT_KEY_ID],
  2486. )
  2487. assert tag.signature is not None
  2488. tag._chunked_text = [b"bad data", tag.signature]
  2489. self.assertRaises(
  2490. gpg.errors.BadSignatures,
  2491. tag.verify,
  2492. )
  2493. def test_non_default_key(self) -> None:
  2494. c1, c2, c3 = build_commit_graph(
  2495. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2496. )
  2497. self.repo.refs[b"HEAD"] = c3.id
  2498. cfg = self.repo.get_config()
  2499. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  2500. self.import_non_default_key()
  2501. porcelain.tag_create(
  2502. self.repo.path,
  2503. b"tryme",
  2504. b"foo <foo@bar.com>",
  2505. b"bar",
  2506. annotated=True,
  2507. sign=True,
  2508. )
  2509. tags = self.repo.refs.as_dict(b"refs/tags")
  2510. self.assertEqual(list(tags.keys()), [b"tryme"])
  2511. tag = self.repo[b"refs/tags/tryme"]
  2512. self.assertIsInstance(tag, Tag)
  2513. self.assertEqual(b"foo <foo@bar.com>", tag.tagger)
  2514. self.assertEqual(b"bar\n", tag.message)
  2515. self.assertRecentTimestamp(tag.tag_time)
  2516. tag = self.repo[b"refs/tags/tryme"]
  2517. assert isinstance(tag, Tag)
  2518. # GPG Signatures aren't deterministic, so we can't do a static assertion.
  2519. tag.verify()
  2520. def test_sign_uses_config_signingkey(self) -> None:
  2521. """Test that sign=True uses user.signingKey from config."""
  2522. c1, c2, c3 = build_commit_graph(
  2523. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2524. )
  2525. self.repo.refs[b"HEAD"] = c3.id
  2526. # Set up user.signingKey in config
  2527. cfg = self.repo.get_config()
  2528. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  2529. cfg.write_to_path()
  2530. self.import_default_key()
  2531. # Create tag with sign=True (should use signingKey from config)
  2532. porcelain.tag_create(
  2533. self.repo.path,
  2534. b"signed-tag",
  2535. b"foo <foo@bar.com>",
  2536. b"Tag with configured key",
  2537. annotated=True,
  2538. sign=True, # This should read user.signingKey from config
  2539. )
  2540. tags = self.repo.refs.as_dict(b"refs/tags")
  2541. self.assertEqual(list(tags.keys()), [b"signed-tag"])
  2542. tag = self.repo[b"refs/tags/signed-tag"]
  2543. self.assertIsInstance(tag, Tag)
  2544. # Verify the tag is signed with the configured key
  2545. tag.verify()
  2546. tag.verify(keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID])
  2547. def test_tag_gpg_sign_config_enabled(self) -> None:
  2548. """Test that tag.gpgSign=true automatically signs tags."""
  2549. c1, c2, c3 = build_commit_graph(
  2550. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2551. )
  2552. self.repo.refs[b"HEAD"] = c3.id
  2553. # Set up user.signingKey and tag.gpgSign in config
  2554. cfg = self.repo.get_config()
  2555. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  2556. cfg.set(("tag",), "gpgSign", True)
  2557. cfg.write_to_path()
  2558. self.import_default_key()
  2559. # Create tag without explicit sign parameter (should auto-sign due to config)
  2560. porcelain.tag_create(
  2561. self.repo.path,
  2562. b"auto-signed-tag",
  2563. b"foo <foo@bar.com>",
  2564. b"Auto-signed tag",
  2565. annotated=True,
  2566. # No sign parameter - should use tag.gpgSign config
  2567. )
  2568. tags = self.repo.refs.as_dict(b"refs/tags")
  2569. self.assertEqual(list(tags.keys()), [b"auto-signed-tag"])
  2570. tag = self.repo[b"refs/tags/auto-signed-tag"]
  2571. self.assertIsInstance(tag, Tag)
  2572. # Verify the tag is signed due to config
  2573. tag.verify()
  2574. tag.verify(keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID])
  2575. def test_tag_gpg_sign_config_disabled(self) -> None:
  2576. """Test that tag.gpgSign=false does not sign tags."""
  2577. c1, c2, c3 = build_commit_graph(
  2578. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2579. )
  2580. self.repo.refs[b"HEAD"] = c3.id
  2581. # Set up user.signingKey and tag.gpgSign=false in config
  2582. cfg = self.repo.get_config()
  2583. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  2584. cfg.set(("tag",), "gpgSign", False)
  2585. cfg.write_to_path()
  2586. self.import_default_key()
  2587. # Create tag without explicit sign parameter (should not sign)
  2588. porcelain.tag_create(
  2589. self.repo.path,
  2590. b"unsigned-tag",
  2591. b"foo <foo@bar.com>",
  2592. b"Unsigned tag",
  2593. annotated=True,
  2594. # No sign parameter - should use tag.gpgSign=false config
  2595. )
  2596. tags = self.repo.refs.as_dict(b"refs/tags")
  2597. self.assertEqual(list(tags.keys()), [b"unsigned-tag"])
  2598. tag = self.repo[b"refs/tags/unsigned-tag"]
  2599. self.assertIsInstance(tag, Tag)
  2600. # Verify the tag is not signed
  2601. self.assertIsNone(tag._signature)
  2602. def test_tag_gpg_sign_config_no_signing_key(self) -> None:
  2603. """Test that tag.gpgSign=true works without user.signingKey (uses default)."""
  2604. c1, c2, c3 = build_commit_graph(
  2605. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2606. )
  2607. self.repo.refs[b"HEAD"] = c3.id
  2608. # Set up tag.gpgSign but no user.signingKey
  2609. cfg = self.repo.get_config()
  2610. cfg.set(("tag",), "gpgSign", True)
  2611. cfg.write_to_path()
  2612. self.import_default_key()
  2613. # Create tag without explicit sign parameter (should auto-sign with default key)
  2614. porcelain.tag_create(
  2615. self.repo.path,
  2616. b"default-signed-tag",
  2617. b"foo <foo@bar.com>",
  2618. b"Default signed tag",
  2619. annotated=True,
  2620. # No sign parameter - should use tag.gpgSign config with default key
  2621. )
  2622. tags = self.repo.refs.as_dict(b"refs/tags")
  2623. self.assertEqual(list(tags.keys()), [b"default-signed-tag"])
  2624. tag = self.repo[b"refs/tags/default-signed-tag"]
  2625. self.assertIsInstance(tag, Tag)
  2626. # Verify the tag is signed with default key
  2627. tag.verify()
  2628. def test_explicit_sign_overrides_config(self) -> None:
  2629. """Test that explicit sign parameter overrides tag.gpgSign config."""
  2630. c1, c2, c3 = build_commit_graph(
  2631. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2632. )
  2633. self.repo.refs[b"HEAD"] = c3.id
  2634. # Set up tag.gpgSign=false but explicitly pass sign=True
  2635. cfg = self.repo.get_config()
  2636. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  2637. cfg.set(("tag",), "gpgSign", False)
  2638. cfg.write_to_path()
  2639. self.import_default_key()
  2640. # Create tag with explicit sign=True (should override config)
  2641. porcelain.tag_create(
  2642. self.repo.path,
  2643. b"explicit-signed-tag",
  2644. b"foo <foo@bar.com>",
  2645. b"Explicitly signed tag",
  2646. annotated=True,
  2647. sign=True, # This should override tag.gpgSign=false
  2648. )
  2649. tags = self.repo.refs.as_dict(b"refs/tags")
  2650. self.assertEqual(list(tags.keys()), [b"explicit-signed-tag"])
  2651. tag = self.repo[b"refs/tags/explicit-signed-tag"]
  2652. self.assertIsInstance(tag, Tag)
  2653. # Verify the tag is signed despite config=false
  2654. tag.verify()
  2655. tag.verify(keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID])
  2656. def test_explicit_false_disables_tag_signing(self) -> None:
  2657. """Test that explicit sign=False disables signing even with config=true."""
  2658. c1, c2, c3 = build_commit_graph(
  2659. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2660. )
  2661. self.repo.refs[b"HEAD"] = c3.id
  2662. # Set up tag.gpgSign=true but explicitly pass sign=False
  2663. cfg = self.repo.get_config()
  2664. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  2665. cfg.set(("tag",), "gpgSign", True)
  2666. cfg.write_to_path()
  2667. self.import_default_key()
  2668. # Create tag with explicit sign=False (should disable signing)
  2669. porcelain.tag_create(
  2670. self.repo.path,
  2671. b"explicit-unsigned-tag",
  2672. b"foo <foo@bar.com>",
  2673. b"Explicitly unsigned tag",
  2674. annotated=True,
  2675. sign=False, # This should override tag.gpgSign=true
  2676. )
  2677. tags = self.repo.refs.as_dict(b"refs/tags")
  2678. self.assertEqual(list(tags.keys()), [b"explicit-unsigned-tag"])
  2679. tag = self.repo[b"refs/tags/explicit-unsigned-tag"]
  2680. self.assertIsInstance(tag, Tag)
  2681. # Verify the tag is NOT signed despite config=true
  2682. self.assertIsNone(tag._signature)
  2683. class TagCreateTests(PorcelainTestCase):
  2684. def test_annotated(self) -> None:
  2685. c1, c2, c3 = build_commit_graph(
  2686. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2687. )
  2688. self.repo.refs[b"HEAD"] = c3.id
  2689. porcelain.tag_create(
  2690. self.repo.path,
  2691. b"tryme",
  2692. b"foo <foo@bar.com>",
  2693. b"bar",
  2694. annotated=True,
  2695. )
  2696. tags = self.repo.refs.as_dict(b"refs/tags")
  2697. self.assertEqual(list(tags.keys()), [b"tryme"])
  2698. tag = self.repo[b"refs/tags/tryme"]
  2699. self.assertIsInstance(tag, Tag)
  2700. self.assertEqual(b"foo <foo@bar.com>", tag.tagger)
  2701. self.assertEqual(b"bar\n", tag.message)
  2702. self.assertRecentTimestamp(tag.tag_time)
  2703. def test_unannotated(self) -> None:
  2704. c1, c2, c3 = build_commit_graph(
  2705. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2706. )
  2707. self.repo.refs[b"HEAD"] = c3.id
  2708. porcelain.tag_create(self.repo.path, b"tryme", annotated=False)
  2709. tags = self.repo.refs.as_dict(b"refs/tags")
  2710. self.assertEqual(list(tags.keys()), [b"tryme"])
  2711. self.repo[b"refs/tags/tryme"]
  2712. self.assertEqual(list(tags.values()), [self.repo.head()])
  2713. def test_unannotated_unicode(self) -> None:
  2714. c1, c2, c3 = build_commit_graph(
  2715. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2716. )
  2717. self.repo.refs[b"HEAD"] = c3.id
  2718. porcelain.tag_create(self.repo.path, "tryme", annotated=False)
  2719. tags = self.repo.refs.as_dict(b"refs/tags")
  2720. self.assertEqual(list(tags.keys()), [b"tryme"])
  2721. self.repo[b"refs/tags/tryme"]
  2722. self.assertEqual(list(tags.values()), [self.repo.head()])
  2723. class TagListTests(PorcelainTestCase):
  2724. def test_empty(self) -> None:
  2725. tags = porcelain.tag_list(self.repo.path)
  2726. self.assertEqual([], tags)
  2727. def test_simple(self) -> None:
  2728. self.repo.refs[b"refs/tags/foo"] = b"aa" * 20
  2729. self.repo.refs[b"refs/tags/bar/bla"] = b"bb" * 20
  2730. tags = porcelain.tag_list(self.repo.path)
  2731. self.assertEqual([b"bar/bla", b"foo"], tags)
  2732. class TagDeleteTests(PorcelainTestCase):
  2733. def test_simple(self) -> None:
  2734. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  2735. self.repo[b"HEAD"] = c1.id
  2736. porcelain.tag_create(self.repo, b"foo")
  2737. self.assertIn(b"foo", porcelain.tag_list(self.repo))
  2738. porcelain.tag_delete(self.repo, b"foo")
  2739. self.assertNotIn(b"foo", porcelain.tag_list(self.repo))
  2740. class ResetTests(PorcelainTestCase):
  2741. def test_hard_head(self) -> None:
  2742. fullpath = os.path.join(self.repo.path, "foo")
  2743. with open(fullpath, "w") as f:
  2744. f.write("BAR")
  2745. porcelain.add(self.repo.path, paths=[fullpath])
  2746. porcelain.commit(
  2747. self.repo.path,
  2748. message=b"Some message",
  2749. committer=b"Jane <jane@example.com>",
  2750. author=b"John <john@example.com>",
  2751. )
  2752. with open(os.path.join(self.repo.path, "foo"), "wb") as f:
  2753. f.write(b"OOH")
  2754. porcelain.reset(self.repo, "hard", b"HEAD")
  2755. index = self.repo.open_index()
  2756. changes = list(
  2757. tree_changes(
  2758. self.repo.object_store,
  2759. index.commit(self.repo.object_store),
  2760. self.repo[b"HEAD"].tree,
  2761. )
  2762. )
  2763. self.assertEqual([], changes)
  2764. def test_hard_commit(self) -> None:
  2765. fullpath = os.path.join(self.repo.path, "foo")
  2766. with open(fullpath, "w") as f:
  2767. f.write("BAR")
  2768. porcelain.add(self.repo.path, paths=[fullpath])
  2769. sha = porcelain.commit(
  2770. self.repo.path,
  2771. message=b"Some message",
  2772. committer=b"Jane <jane@example.com>",
  2773. author=b"John <john@example.com>",
  2774. )
  2775. with open(fullpath, "wb") as f:
  2776. f.write(b"BAZ")
  2777. porcelain.add(self.repo.path, paths=[fullpath])
  2778. porcelain.commit(
  2779. self.repo.path,
  2780. message=b"Some other message",
  2781. committer=b"Jane <jane@example.com>",
  2782. author=b"John <john@example.com>",
  2783. )
  2784. porcelain.reset(self.repo, "hard", sha)
  2785. index = self.repo.open_index()
  2786. changes = list(
  2787. tree_changes(
  2788. self.repo.object_store,
  2789. index.commit(self.repo.object_store),
  2790. self.repo[sha].tree,
  2791. )
  2792. )
  2793. self.assertEqual([], changes)
  2794. def test_hard_commit_short_hash(self) -> None:
  2795. fullpath = os.path.join(self.repo.path, "foo")
  2796. with open(fullpath, "w") as f:
  2797. f.write("BAR")
  2798. porcelain.add(self.repo.path, paths=[fullpath])
  2799. sha = porcelain.commit(
  2800. self.repo.path,
  2801. message=b"Some message",
  2802. committer=b"Jane <jane@example.com>",
  2803. author=b"John <john@example.com>",
  2804. )
  2805. with open(fullpath, "wb") as f:
  2806. f.write(b"BAZ")
  2807. porcelain.add(self.repo.path, paths=[fullpath])
  2808. porcelain.commit(
  2809. self.repo.path,
  2810. message=b"Some other message",
  2811. committer=b"Jane <jane@example.com>",
  2812. author=b"John <john@example.com>",
  2813. )
  2814. # Test with short hash (7 characters)
  2815. short_sha = sha[:7].decode("ascii")
  2816. porcelain.reset(self.repo, "hard", short_sha)
  2817. index = self.repo.open_index()
  2818. changes = list(
  2819. tree_changes(
  2820. self.repo.object_store,
  2821. index.commit(self.repo.object_store),
  2822. self.repo[sha].tree,
  2823. )
  2824. )
  2825. self.assertEqual([], changes)
  2826. def test_hard_deletes_untracked_files(self) -> None:
  2827. """Test that reset --hard deletes files that don't exist in target tree."""
  2828. # Create and commit a file
  2829. fullpath = os.path.join(self.repo.path, "foo")
  2830. with open(fullpath, "w") as f:
  2831. f.write("BAR")
  2832. porcelain.add(self.repo.path, paths=[fullpath])
  2833. sha1 = porcelain.commit(
  2834. self.repo.path,
  2835. message=b"First commit",
  2836. committer=b"Jane <jane@example.com>",
  2837. author=b"John <john@example.com>",
  2838. )
  2839. # Create another file and commit
  2840. fullpath2 = os.path.join(self.repo.path, "bar")
  2841. with open(fullpath2, "w") as f:
  2842. f.write("BAZ")
  2843. porcelain.add(self.repo.path, paths=[fullpath2])
  2844. porcelain.commit(
  2845. self.repo.path,
  2846. message=b"Second commit",
  2847. committer=b"Jane <jane@example.com>",
  2848. author=b"John <john@example.com>",
  2849. )
  2850. # Reset hard to first commit - this should delete 'bar'
  2851. porcelain.reset(self.repo, "hard", sha1)
  2852. # Check that 'foo' still exists and 'bar' is deleted
  2853. self.assertTrue(os.path.exists(fullpath))
  2854. self.assertFalse(os.path.exists(fullpath2))
  2855. # Check index matches first commit
  2856. index = self.repo.open_index()
  2857. self.assertIn(b"foo", index)
  2858. self.assertNotIn(b"bar", index)
  2859. def test_hard_deletes_files_in_subdirs(self) -> None:
  2860. """Test that reset --hard deletes files in subdirectories."""
  2861. # Create and commit files in subdirectory
  2862. subdir = os.path.join(self.repo.path, "subdir")
  2863. os.makedirs(subdir)
  2864. file1 = os.path.join(subdir, "file1")
  2865. file2 = os.path.join(subdir, "file2")
  2866. with open(file1, "w") as f:
  2867. f.write("content1")
  2868. with open(file2, "w") as f:
  2869. f.write("content2")
  2870. porcelain.add(self.repo.path, paths=[file1, file2])
  2871. porcelain.commit(
  2872. self.repo.path,
  2873. message=b"First commit",
  2874. committer=b"Jane <jane@example.com>",
  2875. author=b"John <john@example.com>",
  2876. )
  2877. # Remove one file from subdirectory and commit
  2878. porcelain.rm(self.repo.path, paths=[file2])
  2879. sha2 = porcelain.commit(
  2880. self.repo.path,
  2881. message=b"Remove file2",
  2882. committer=b"Jane <jane@example.com>",
  2883. author=b"John <john@example.com>",
  2884. )
  2885. # Create file2 again (untracked)
  2886. with open(file2, "w") as f:
  2887. f.write("new content")
  2888. # Reset to commit that has file2 removed - should delete untracked file2
  2889. porcelain.reset(self.repo, "hard", sha2)
  2890. self.assertTrue(os.path.exists(file1))
  2891. self.assertFalse(os.path.exists(file2))
  2892. def test_hard_reset_to_remote_branch(self) -> None:
  2893. """Test reset --hard to remote branch deletes local files not in remote."""
  2894. # Create a file and commit
  2895. file1 = os.path.join(self.repo.path, "file1")
  2896. with open(file1, "w") as f:
  2897. f.write("content1")
  2898. porcelain.add(self.repo.path, paths=[file1])
  2899. sha1 = porcelain.commit(
  2900. self.repo.path,
  2901. message=b"Initial commit",
  2902. committer=b"Jane <jane@example.com>",
  2903. author=b"John <john@example.com>",
  2904. )
  2905. # Create a "remote" ref that doesn't have additional files
  2906. self.repo.refs[b"refs/remotes/origin/master"] = sha1
  2907. # Add another file locally and commit
  2908. file2 = os.path.join(self.repo.path, "file2")
  2909. with open(file2, "w") as f:
  2910. f.write("content2")
  2911. porcelain.add(self.repo.path, paths=[file2])
  2912. porcelain.commit(
  2913. self.repo.path,
  2914. message=b"Add file2",
  2915. committer=b"Jane <jane@example.com>",
  2916. author=b"John <john@example.com>",
  2917. )
  2918. # Both files should exist
  2919. self.assertTrue(os.path.exists(file1))
  2920. self.assertTrue(os.path.exists(file2))
  2921. # Reset to remote branch - should delete file2
  2922. porcelain.reset(self.repo, "hard", b"refs/remotes/origin/master")
  2923. # file1 should exist, file2 should be deleted
  2924. self.assertTrue(os.path.exists(file1))
  2925. self.assertFalse(os.path.exists(file2))
  2926. def test_mixed_reset(self) -> None:
  2927. # Create initial commit
  2928. fullpath = os.path.join(self.repo.path, "foo")
  2929. with open(fullpath, "w") as f:
  2930. f.write("BAR")
  2931. porcelain.add(self.repo.path, paths=[fullpath])
  2932. first_sha = porcelain.commit(
  2933. self.repo.path,
  2934. message=b"First commit",
  2935. committer=b"Jane <jane@example.com>",
  2936. author=b"John <john@example.com>",
  2937. )
  2938. # Make second commit with modified content
  2939. with open(fullpath, "w") as f:
  2940. f.write("BAZ")
  2941. porcelain.add(self.repo.path, paths=[fullpath])
  2942. porcelain.commit(
  2943. self.repo.path,
  2944. message=b"Second commit",
  2945. committer=b"Jane <jane@example.com>",
  2946. author=b"John <john@example.com>",
  2947. )
  2948. # Modify working tree without staging
  2949. with open(fullpath, "w") as f:
  2950. f.write("MODIFIED")
  2951. # Mixed reset to first commit
  2952. porcelain.reset(self.repo, "mixed", first_sha)
  2953. # Check that HEAD points to first commit
  2954. self.assertEqual(self.repo.head(), first_sha)
  2955. # Check that index matches first commit
  2956. index = self.repo.open_index()
  2957. changes = list(
  2958. tree_changes(
  2959. self.repo.object_store,
  2960. index.commit(self.repo.object_store),
  2961. self.repo[first_sha].tree,
  2962. )
  2963. )
  2964. self.assertEqual([], changes)
  2965. # Check that working tree is unchanged (still has "MODIFIED")
  2966. with open(fullpath) as f:
  2967. self.assertEqual(f.read(), "MODIFIED")
  2968. def test_soft_reset(self) -> None:
  2969. # Create initial commit
  2970. fullpath = os.path.join(self.repo.path, "foo")
  2971. with open(fullpath, "w") as f:
  2972. f.write("BAR")
  2973. porcelain.add(self.repo.path, paths=[fullpath])
  2974. first_sha = porcelain.commit(
  2975. self.repo.path,
  2976. message=b"First commit",
  2977. committer=b"Jane <jane@example.com>",
  2978. author=b"John <john@example.com>",
  2979. )
  2980. # Make second commit with modified content
  2981. with open(fullpath, "w") as f:
  2982. f.write("BAZ")
  2983. porcelain.add(self.repo.path, paths=[fullpath])
  2984. porcelain.commit(
  2985. self.repo.path,
  2986. message=b"Second commit",
  2987. committer=b"Jane <jane@example.com>",
  2988. author=b"John <john@example.com>",
  2989. )
  2990. # Stage a new change
  2991. with open(fullpath, "w") as f:
  2992. f.write("STAGED")
  2993. porcelain.add(self.repo.path, paths=[fullpath])
  2994. # Soft reset to first commit
  2995. porcelain.reset(self.repo, "soft", first_sha)
  2996. # Check that HEAD points to first commit
  2997. self.assertEqual(self.repo.head(), first_sha)
  2998. # Check that index still has the staged change (not reset)
  2999. index = self.repo.open_index()
  3000. # The index should still contain the staged content, not the first commit's content
  3001. self.assertIn(b"foo", index)
  3002. # Check that working tree is unchanged
  3003. with open(fullpath) as f:
  3004. self.assertEqual(f.read(), "STAGED")
  3005. class ResetFileTests(PorcelainTestCase):
  3006. def test_reset_modify_file_to_commit(self) -> None:
  3007. file = "foo"
  3008. full_path = os.path.join(self.repo.path, file)
  3009. with open(full_path, "w") as f:
  3010. f.write("hello")
  3011. porcelain.add(self.repo, paths=[full_path])
  3012. sha = porcelain.commit(
  3013. self.repo,
  3014. message=b"unitest",
  3015. committer=b"Jane <jane@example.com>",
  3016. author=b"John <john@example.com>",
  3017. )
  3018. with open(full_path, "a") as f:
  3019. f.write("something new")
  3020. porcelain.reset_file(self.repo, file, target=sha)
  3021. with open(full_path) as f:
  3022. self.assertEqual("hello", f.read())
  3023. def test_reset_remove_file_to_commit(self) -> None:
  3024. file = "foo"
  3025. full_path = os.path.join(self.repo.path, file)
  3026. with open(full_path, "w") as f:
  3027. f.write("hello")
  3028. porcelain.add(self.repo, paths=[full_path])
  3029. sha = porcelain.commit(
  3030. self.repo,
  3031. message=b"unitest",
  3032. committer=b"Jane <jane@example.com>",
  3033. author=b"John <john@example.com>",
  3034. )
  3035. os.remove(full_path)
  3036. porcelain.reset_file(self.repo, file, target=sha)
  3037. with open(full_path) as f:
  3038. self.assertEqual("hello", f.read())
  3039. def test_resetfile_with_dir(self) -> None:
  3040. os.mkdir(os.path.join(self.repo.path, "new_dir"))
  3041. full_path = os.path.join(self.repo.path, "new_dir", "foo")
  3042. with open(full_path, "w") as f:
  3043. f.write("hello")
  3044. porcelain.add(self.repo, paths=[full_path])
  3045. sha = porcelain.commit(
  3046. self.repo,
  3047. message=b"unitest",
  3048. committer=b"Jane <jane@example.com>",
  3049. author=b"John <john@example.com>",
  3050. )
  3051. with open(full_path, "a") as f:
  3052. f.write("something new")
  3053. porcelain.commit(
  3054. self.repo,
  3055. message=b"unitest 2",
  3056. committer=b"Jane <jane@example.com>",
  3057. author=b"John <john@example.com>",
  3058. )
  3059. porcelain.reset_file(self.repo, os.path.join("new_dir", "foo"), target=sha)
  3060. with open(full_path) as f:
  3061. self.assertEqual("hello", f.read())
  3062. def _commit_file_with_content(repo, filename, content):
  3063. file_path = os.path.join(repo.path, filename)
  3064. with open(file_path, "w") as f:
  3065. f.write(content)
  3066. porcelain.add(repo, paths=[file_path])
  3067. sha = porcelain.commit(
  3068. repo,
  3069. message=b"add " + filename.encode(),
  3070. committer=b"Jane <jane@example.com>",
  3071. author=b"John <john@example.com>",
  3072. )
  3073. return sha, file_path
  3074. class RevertTests(PorcelainTestCase):
  3075. def test_revert_simple(self) -> None:
  3076. # Create initial commit
  3077. fullpath = os.path.join(self.repo.path, "foo")
  3078. with open(fullpath, "w") as f:
  3079. f.write("initial content\n")
  3080. porcelain.add(self.repo.path, paths=[fullpath])
  3081. porcelain.commit(
  3082. self.repo.path,
  3083. message=b"Initial commit",
  3084. committer=b"Jane <jane@example.com>",
  3085. author=b"John <john@example.com>",
  3086. )
  3087. # Make a change
  3088. with open(fullpath, "w") as f:
  3089. f.write("modified content\n")
  3090. porcelain.add(self.repo.path, paths=[fullpath])
  3091. change_sha = porcelain.commit(
  3092. self.repo.path,
  3093. message=b"Change content",
  3094. committer=b"Jane <jane@example.com>",
  3095. author=b"John <john@example.com>",
  3096. )
  3097. # Revert the change
  3098. revert_sha = porcelain.revert(self.repo.path, commits=[change_sha])
  3099. # Check the file content is back to initial
  3100. with open(fullpath) as f:
  3101. self.assertEqual("initial content\n", f.read())
  3102. # Check the revert commit message
  3103. revert_commit = self.repo[revert_sha]
  3104. self.assertIn(b'Revert "Change content"', revert_commit.message)
  3105. self.assertIn(change_sha[:7], revert_commit.message)
  3106. def test_revert_multiple(self) -> None:
  3107. # Create initial commit
  3108. fullpath = os.path.join(self.repo.path, "foo")
  3109. with open(fullpath, "w") as f:
  3110. f.write("line1\n")
  3111. porcelain.add(self.repo.path, paths=[fullpath])
  3112. porcelain.commit(
  3113. self.repo.path,
  3114. message=b"Initial commit",
  3115. committer=b"Jane <jane@example.com>",
  3116. author=b"John <john@example.com>",
  3117. )
  3118. # Add line2
  3119. with open(fullpath, "a") as f:
  3120. f.write("line2\n")
  3121. porcelain.add(self.repo.path, paths=[fullpath])
  3122. commit1 = porcelain.commit(
  3123. self.repo.path,
  3124. message=b"Add line2",
  3125. committer=b"Jane <jane@example.com>",
  3126. author=b"John <john@example.com>",
  3127. )
  3128. # Add line3
  3129. with open(fullpath, "a") as f:
  3130. f.write("line3\n")
  3131. porcelain.add(self.repo.path, paths=[fullpath])
  3132. commit2 = porcelain.commit(
  3133. self.repo.path,
  3134. message=b"Add line3",
  3135. committer=b"Jane <jane@example.com>",
  3136. author=b"John <john@example.com>",
  3137. )
  3138. # Revert both commits (in reverse order)
  3139. porcelain.revert(self.repo.path, commits=[commit2, commit1])
  3140. # Check file is back to initial state
  3141. with open(fullpath) as f:
  3142. self.assertEqual("line1\n", f.read())
  3143. def test_revert_no_commit(self) -> None:
  3144. # Create initial commit
  3145. fullpath = os.path.join(self.repo.path, "foo")
  3146. with open(fullpath, "w") as f:
  3147. f.write("initial\n")
  3148. porcelain.add(self.repo.path, paths=[fullpath])
  3149. porcelain.commit(
  3150. self.repo.path,
  3151. message=b"Initial",
  3152. committer=b"Jane <jane@example.com>",
  3153. author=b"John <john@example.com>",
  3154. )
  3155. # Make a change
  3156. with open(fullpath, "w") as f:
  3157. f.write("changed\n")
  3158. porcelain.add(self.repo.path, paths=[fullpath])
  3159. change_sha = porcelain.commit(
  3160. self.repo.path,
  3161. message=b"Change",
  3162. committer=b"Jane <jane@example.com>",
  3163. author=b"John <john@example.com>",
  3164. )
  3165. # Revert with no_commit
  3166. result = porcelain.revert(self.repo.path, commits=[change_sha], no_commit=True)
  3167. # Should return None
  3168. self.assertIsNone(result)
  3169. # File should be reverted
  3170. with open(fullpath) as f:
  3171. self.assertEqual("initial\n", f.read())
  3172. # HEAD should still point to the change commit
  3173. self.assertEqual(self.repo.refs[b"HEAD"], change_sha)
  3174. def test_revert_custom_message(self) -> None:
  3175. # Create commits
  3176. fullpath = os.path.join(self.repo.path, "foo")
  3177. with open(fullpath, "w") as f:
  3178. f.write("initial\n")
  3179. porcelain.add(self.repo.path, paths=[fullpath])
  3180. porcelain.commit(
  3181. self.repo.path,
  3182. message=b"Initial",
  3183. committer=b"Jane <jane@example.com>",
  3184. author=b"John <john@example.com>",
  3185. )
  3186. with open(fullpath, "w") as f:
  3187. f.write("changed\n")
  3188. porcelain.add(self.repo.path, paths=[fullpath])
  3189. change_sha = porcelain.commit(
  3190. self.repo.path,
  3191. message=b"Change",
  3192. committer=b"Jane <jane@example.com>",
  3193. author=b"John <john@example.com>",
  3194. )
  3195. # Revert with custom message
  3196. custom_msg = "Custom revert message"
  3197. revert_sha = porcelain.revert(
  3198. self.repo.path, commits=[change_sha], message=custom_msg
  3199. )
  3200. # Check the message
  3201. revert_commit = self.repo[revert_sha]
  3202. self.assertEqual(custom_msg.encode("utf-8"), revert_commit.message)
  3203. def test_revert_no_parent(self) -> None:
  3204. # Try to revert the initial commit (no parent)
  3205. fullpath = os.path.join(self.repo.path, "foo")
  3206. with open(fullpath, "w") as f:
  3207. f.write("content\n")
  3208. porcelain.add(self.repo.path, paths=[fullpath])
  3209. initial_sha = porcelain.commit(
  3210. self.repo.path,
  3211. message=b"Initial",
  3212. committer=b"Jane <jane@example.com>",
  3213. author=b"John <john@example.com>",
  3214. )
  3215. # Should raise an error
  3216. with self.assertRaises(porcelain.Error) as cm:
  3217. porcelain.revert(self.repo.path, commits=[initial_sha])
  3218. self.assertIn("no parents", str(cm.exception))
  3219. class CheckoutTests(PorcelainTestCase):
  3220. def setUp(self) -> None:
  3221. super().setUp()
  3222. self._sha, self._foo_path = _commit_file_with_content(
  3223. self.repo, "foo", "hello\n"
  3224. )
  3225. porcelain.branch_create(self.repo, "uni")
  3226. def test_checkout_to_existing_branch(self) -> None:
  3227. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  3228. porcelain.checkout(self.repo, b"uni")
  3229. self.assertEqual(b"uni", porcelain.active_branch(self.repo))
  3230. def test_checkout_to_non_existing_branch(self) -> None:
  3231. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  3232. with self.assertRaises(KeyError):
  3233. porcelain.checkout(self.repo, b"bob")
  3234. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  3235. def test_checkout_to_branch_with_modified_files(self) -> None:
  3236. with open(self._foo_path, "a") as f:
  3237. f.write("new message\n")
  3238. porcelain.add(self.repo, paths=[self._foo_path])
  3239. status = list(porcelain.status(self.repo))
  3240. self.assertEqual(
  3241. [{"add": [], "delete": [], "modify": [b"foo"]}, [], []], status
  3242. )
  3243. # The new checkout behavior prevents switching with staged changes
  3244. with self.assertRaises(porcelain.CheckoutError):
  3245. porcelain.checkout(self.repo, b"uni")
  3246. # Should still be on master
  3247. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  3248. # Force checkout should work
  3249. porcelain.checkout(self.repo, b"uni", force=True)
  3250. self.assertEqual(b"uni", porcelain.active_branch(self.repo))
  3251. def test_checkout_with_deleted_files(self) -> None:
  3252. porcelain.remove(self.repo.path, [os.path.join(self.repo.path, "foo")])
  3253. status = list(porcelain.status(self.repo))
  3254. self.assertEqual(
  3255. [{"add": [], "delete": [b"foo"], "modify": []}, [], []], status
  3256. )
  3257. # The new checkout behavior prevents switching with staged deletions
  3258. with self.assertRaises(porcelain.CheckoutError):
  3259. porcelain.checkout(self.repo, b"uni")
  3260. # Should still be on master
  3261. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  3262. # Force checkout should work
  3263. porcelain.checkout(self.repo, b"uni", force=True)
  3264. self.assertEqual(b"uni", porcelain.active_branch(self.repo))
  3265. def test_checkout_to_branch_with_added_files(self) -> None:
  3266. file_path = os.path.join(self.repo.path, "bar")
  3267. with open(file_path, "w") as f:
  3268. f.write("bar content\n")
  3269. porcelain.add(self.repo, paths=[file_path])
  3270. status = list(porcelain.status(self.repo))
  3271. self.assertEqual(
  3272. [{"add": [b"bar"], "delete": [], "modify": []}, [], []], status
  3273. )
  3274. # Both branches have file 'foo' checkout should be fine.
  3275. porcelain.checkout(self.repo, b"uni")
  3276. self.assertEqual(b"uni", porcelain.active_branch(self.repo))
  3277. status = list(porcelain.status(self.repo))
  3278. self.assertEqual(
  3279. [{"add": [b"bar"], "delete": [], "modify": []}, [], []], status
  3280. )
  3281. def test_checkout_to_branch_with_modified_file_not_present(self) -> None:
  3282. # Commit a new file that the other branch doesn't have.
  3283. _, nee_path = _commit_file_with_content(self.repo, "nee", "Good content\n")
  3284. # Modify the file the other branch doesn't have.
  3285. with open(nee_path, "a") as f:
  3286. f.write("bar content\n")
  3287. porcelain.add(self.repo, paths=[nee_path])
  3288. status = list(porcelain.status(self.repo))
  3289. self.assertEqual(
  3290. [{"add": [], "delete": [], "modify": [b"nee"]}, [], []], status
  3291. )
  3292. # The new checkout behavior allows switching if the file doesn't exist in target branch
  3293. # (changes can be preserved)
  3294. porcelain.checkout(self.repo, b"uni")
  3295. self.assertEqual(b"uni", porcelain.active_branch(self.repo))
  3296. # The staged changes are lost and the file is removed from working tree
  3297. # because it doesn't exist in the target branch
  3298. status = list(porcelain.status(self.repo))
  3299. # File 'nee' is gone completely
  3300. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)
  3301. self.assertFalse(os.path.exists(nee_path))
  3302. def test_checkout_to_branch_with_modified_file_not_present_forced(self) -> None:
  3303. # Commit a new file that the other branch doesn't have.
  3304. _, nee_path = _commit_file_with_content(self.repo, "nee", "Good content\n")
  3305. # Modify the file the other branch doesn't have.
  3306. with open(nee_path, "a") as f:
  3307. f.write("bar content\n")
  3308. porcelain.add(self.repo, paths=[nee_path])
  3309. status = list(porcelain.status(self.repo))
  3310. self.assertEqual(
  3311. [{"add": [], "delete": [], "modify": [b"nee"]}, [], []], status
  3312. )
  3313. # 'uni' branch doesn't have 'nee' and it has been modified, but we force to reset the entire index.
  3314. porcelain.checkout(self.repo, b"uni", force=True)
  3315. self.assertEqual(b"uni", porcelain.active_branch(self.repo))
  3316. status = list(porcelain.status(self.repo))
  3317. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)
  3318. def test_checkout_to_branch_with_unstaged_files(self) -> None:
  3319. # Edit `foo`.
  3320. with open(self._foo_path, "a") as f:
  3321. f.write("new message")
  3322. status = list(porcelain.status(self.repo))
  3323. self.assertEqual(
  3324. [{"add": [], "delete": [], "modify": []}, [b"foo"], []], status
  3325. )
  3326. # The new checkout behavior prevents switching with unstaged changes
  3327. with self.assertRaises(porcelain.CheckoutError):
  3328. porcelain.checkout(self.repo, b"uni")
  3329. # Should still be on master
  3330. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  3331. # Force checkout should work
  3332. porcelain.checkout(self.repo, b"uni", force=True)
  3333. self.assertEqual(b"uni", porcelain.active_branch(self.repo))
  3334. def test_checkout_to_branch_with_untracked_files(self) -> None:
  3335. with open(os.path.join(self.repo.path, "neu"), "a") as f:
  3336. f.write("new message\n")
  3337. status = list(porcelain.status(self.repo))
  3338. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], ["neu"]], status)
  3339. porcelain.checkout(self.repo, b"uni")
  3340. status = list(porcelain.status(self.repo))
  3341. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], ["neu"]], status)
  3342. def test_checkout_to_branch_with_new_files(self) -> None:
  3343. porcelain.checkout(self.repo, b"uni")
  3344. sub_directory = os.path.join(self.repo.path, "sub1")
  3345. os.mkdir(sub_directory)
  3346. for index in range(5):
  3347. _commit_file_with_content(
  3348. self.repo, "new_file_" + str(index + 1), "Some content\n"
  3349. )
  3350. _commit_file_with_content(
  3351. self.repo,
  3352. os.path.join("sub1", "new_file_" + str(index + 10)),
  3353. "Good content\n",
  3354. )
  3355. status = list(porcelain.status(self.repo))
  3356. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)
  3357. porcelain.checkout(self.repo, b"master")
  3358. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  3359. status = list(porcelain.status(self.repo))
  3360. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)
  3361. porcelain.checkout(self.repo, b"uni")
  3362. self.assertEqual(b"uni", porcelain.active_branch(self.repo))
  3363. status = list(porcelain.status(self.repo))
  3364. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)
  3365. def test_checkout_to_branch_with_file_in_sub_directory(self) -> None:
  3366. sub_directory = os.path.join(self.repo.path, "sub1", "sub2")
  3367. os.makedirs(sub_directory)
  3368. sub_directory_file = os.path.join(sub_directory, "neu")
  3369. with open(sub_directory_file, "w") as f:
  3370. f.write("new message\n")
  3371. porcelain.add(self.repo, paths=[sub_directory_file])
  3372. porcelain.commit(
  3373. self.repo,
  3374. message=b"add " + sub_directory_file.encode(),
  3375. committer=b"Jane <jane@example.com>",
  3376. author=b"John <john@example.com>",
  3377. )
  3378. status = list(porcelain.status(self.repo))
  3379. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)
  3380. self.assertTrue(os.path.isdir(sub_directory))
  3381. self.assertTrue(os.path.isdir(os.path.dirname(sub_directory)))
  3382. porcelain.checkout(self.repo, b"uni")
  3383. status = list(porcelain.status(self.repo))
  3384. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)
  3385. self.assertFalse(os.path.isdir(sub_directory))
  3386. self.assertFalse(os.path.isdir(os.path.dirname(sub_directory)))
  3387. porcelain.checkout(self.repo, b"master")
  3388. self.assertTrue(os.path.isdir(sub_directory))
  3389. self.assertTrue(os.path.isdir(os.path.dirname(sub_directory)))
  3390. def test_checkout_to_branch_with_multiple_files_in_sub_directory(self) -> None:
  3391. sub_directory = os.path.join(self.repo.path, "sub1", "sub2")
  3392. os.makedirs(sub_directory)
  3393. sub_directory_file_1 = os.path.join(sub_directory, "neu")
  3394. with open(sub_directory_file_1, "w") as f:
  3395. f.write("new message\n")
  3396. sub_directory_file_2 = os.path.join(sub_directory, "gus")
  3397. with open(sub_directory_file_2, "w") as f:
  3398. f.write("alternative message\n")
  3399. porcelain.add(self.repo, paths=[sub_directory_file_1, sub_directory_file_2])
  3400. porcelain.commit(
  3401. self.repo,
  3402. message=b"add files neu and gus.",
  3403. committer=b"Jane <jane@example.com>",
  3404. author=b"John <john@example.com>",
  3405. )
  3406. status = list(porcelain.status(self.repo))
  3407. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)
  3408. self.assertTrue(os.path.isdir(sub_directory))
  3409. self.assertTrue(os.path.isdir(os.path.dirname(sub_directory)))
  3410. porcelain.checkout(self.repo, b"uni")
  3411. status = list(porcelain.status(self.repo))
  3412. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)
  3413. self.assertFalse(os.path.isdir(sub_directory))
  3414. self.assertFalse(os.path.isdir(os.path.dirname(sub_directory)))
  3415. def _commit_something_wrong(self):
  3416. with open(self._foo_path, "a") as f:
  3417. f.write("something wrong")
  3418. porcelain.add(self.repo, paths=[self._foo_path])
  3419. return porcelain.commit(
  3420. self.repo,
  3421. message=b"I may added something wrong",
  3422. committer=b"Jane <jane@example.com>",
  3423. author=b"John <john@example.com>",
  3424. )
  3425. def test_checkout_to_commit_sha(self) -> None:
  3426. self._commit_something_wrong()
  3427. porcelain.checkout(self.repo, self._sha)
  3428. self.assertEqual(self._sha, self.repo.head())
  3429. def test_checkout_to_head(self) -> None:
  3430. new_sha = self._commit_something_wrong()
  3431. porcelain.checkout(self.repo, b"HEAD")
  3432. self.assertEqual(new_sha, self.repo.head())
  3433. def _checkout_remote_branch(self):
  3434. errstream = BytesIO()
  3435. outstream = BytesIO()
  3436. porcelain.commit(
  3437. repo=self.repo.path,
  3438. message=b"init",
  3439. author=b"author <email>",
  3440. committer=b"committer <email>",
  3441. )
  3442. # Setup target repo cloned from temp test repo
  3443. clone_path = tempfile.mkdtemp()
  3444. self.addCleanup(shutil.rmtree, clone_path)
  3445. target_repo = porcelain.clone(
  3446. self.repo.path, target=clone_path, errstream=errstream
  3447. )
  3448. self.addCleanup(target_repo.close)
  3449. self.assertEqual(target_repo[b"HEAD"], self.repo[b"HEAD"])
  3450. # create a second file to be pushed back to origin
  3451. handle, fullpath = tempfile.mkstemp(dir=clone_path)
  3452. os.close(handle)
  3453. porcelain.add(repo=clone_path, paths=[fullpath])
  3454. porcelain.commit(
  3455. repo=clone_path,
  3456. message=b"push",
  3457. author=b"author <email>",
  3458. committer=b"committer <email>",
  3459. )
  3460. # Setup a non-checked out branch in the remote
  3461. refs_path = b"refs/heads/foo"
  3462. new_id = self.repo[b"HEAD"].id
  3463. self.assertNotEqual(new_id, ZERO_SHA)
  3464. self.repo.refs[refs_path] = new_id
  3465. # Push to the remote
  3466. porcelain.push(
  3467. clone_path,
  3468. "origin",
  3469. b"HEAD:" + refs_path,
  3470. outstream=outstream,
  3471. errstream=errstream,
  3472. )
  3473. self.assertEqual(
  3474. target_repo.refs[b"refs/remotes/origin/foo"],
  3475. target_repo.refs[b"HEAD"],
  3476. )
  3477. # The new checkout behavior treats origin/foo as a ref and creates detached HEAD
  3478. porcelain.checkout(target_repo, b"origin/foo")
  3479. original_id = target_repo[b"HEAD"].id
  3480. uni_id = target_repo[b"refs/remotes/origin/uni"].id
  3481. # Should be in detached HEAD state
  3482. with self.assertRaises((ValueError, IndexError)):
  3483. porcelain.active_branch(target_repo)
  3484. expected_refs = {
  3485. b"HEAD": original_id,
  3486. b"refs/heads/master": original_id,
  3487. # No local foo branch is created anymore
  3488. b"refs/remotes/origin/foo": original_id,
  3489. b"refs/remotes/origin/uni": uni_id,
  3490. b"refs/remotes/origin/HEAD": new_id,
  3491. b"refs/remotes/origin/master": new_id,
  3492. }
  3493. self.assertEqual(expected_refs, target_repo.get_refs())
  3494. return target_repo
  3495. def test_checkout_remote_branch(self) -> None:
  3496. repo = self._checkout_remote_branch()
  3497. repo.close()
  3498. def test_checkout_remote_branch_then_master_then_remote_branch_again(self) -> None:
  3499. target_repo = self._checkout_remote_branch()
  3500. # Should be in detached HEAD state
  3501. with self.assertRaises((ValueError, IndexError)):
  3502. porcelain.active_branch(target_repo)
  3503. # Save the commit SHA before adding bar
  3504. detached_commit_sha, _ = _commit_file_with_content(
  3505. target_repo, "bar", "something\n"
  3506. )
  3507. self.assertTrue(os.path.isfile(os.path.join(target_repo.path, "bar")))
  3508. porcelain.checkout(target_repo, b"master")
  3509. self.assertEqual(b"master", porcelain.active_branch(target_repo))
  3510. self.assertFalse(os.path.isfile(os.path.join(target_repo.path, "bar")))
  3511. # Going back to origin/foo won't have bar because the commit was made in detached state
  3512. porcelain.checkout(target_repo, b"origin/foo")
  3513. # Should be in detached HEAD state again
  3514. with self.assertRaises((ValueError, IndexError)):
  3515. porcelain.active_branch(target_repo)
  3516. # bar is NOT there because we're back at the original origin/foo commit
  3517. self.assertFalse(os.path.isfile(os.path.join(target_repo.path, "bar")))
  3518. # But we can checkout the specific commit to get bar back
  3519. porcelain.checkout(target_repo, detached_commit_sha.decode())
  3520. self.assertTrue(os.path.isfile(os.path.join(target_repo.path, "bar")))
  3521. target_repo.close()
  3522. def test_checkout_new_branch_from_remote_sets_tracking(self) -> None:
  3523. # Create a "remote" repository
  3524. remote_path = tempfile.mkdtemp()
  3525. self.addCleanup(shutil.rmtree, remote_path)
  3526. remote_repo = porcelain.init(remote_path)
  3527. # Add a commit to the remote
  3528. remote_sha, _ = _commit_file_with_content(
  3529. remote_repo, "bar", "remote content\n"
  3530. )
  3531. # Clone the remote repository
  3532. target_path = tempfile.mkdtemp()
  3533. self.addCleanup(shutil.rmtree, target_path)
  3534. target_repo = porcelain.clone(remote_path, target_path)
  3535. self.addCleanup(target_repo.close)
  3536. # Create a remote tracking branch reference
  3537. remote_branch_ref = b"refs/remotes/origin/feature"
  3538. target_repo.refs[remote_branch_ref] = remote_sha
  3539. # Checkout a new branch from the remote branch
  3540. porcelain.checkout(target_repo, remote_branch_ref, new_branch=b"local-feature")
  3541. # Verify the branch was created and is active
  3542. self.assertEqual(b"local-feature", porcelain.active_branch(target_repo))
  3543. # Verify tracking configuration was set
  3544. config = target_repo.get_config()
  3545. self.assertEqual(
  3546. b"origin", config.get((b"branch", b"local-feature"), b"remote")
  3547. )
  3548. self.assertEqual(
  3549. b"refs/heads/feature", config.get((b"branch", b"local-feature"), b"merge")
  3550. )
  3551. target_repo.close()
  3552. remote_repo.close()
  3553. class GeneralCheckoutTests(PorcelainTestCase):
  3554. """Tests for the general checkout function that handles branches, tags, and commits."""
  3555. def setUp(self) -> None:
  3556. super().setUp()
  3557. # Create initial commit
  3558. self._sha1, self._foo_path = _commit_file_with_content(
  3559. self.repo, "foo", "initial content\n"
  3560. )
  3561. # Create a branch
  3562. porcelain.branch_create(self.repo, "feature")
  3563. # Create another commit on master
  3564. self._sha2, self._bar_path = _commit_file_with_content(
  3565. self.repo, "bar", "bar content\n"
  3566. )
  3567. # Create a tag
  3568. porcelain.tag_create(self.repo, "v1.0", objectish=self._sha1)
  3569. def test_checkout_branch(self) -> None:
  3570. """Test checking out a branch."""
  3571. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  3572. # Checkout feature branch
  3573. porcelain.checkout(self.repo, "feature")
  3574. self.assertEqual(b"feature", porcelain.active_branch(self.repo))
  3575. # File 'bar' should not exist in feature branch
  3576. self.assertFalse(os.path.exists(self._bar_path))
  3577. # Go back to master
  3578. porcelain.checkout(self.repo, "master")
  3579. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  3580. # File 'bar' should exist again
  3581. self.assertTrue(os.path.exists(self._bar_path))
  3582. def test_checkout_commit(self) -> None:
  3583. """Test checking out a specific commit (detached HEAD)."""
  3584. # Checkout first commit by SHA
  3585. porcelain.checkout(self.repo, self._sha1.decode("ascii"))
  3586. # Should be in detached HEAD state - active_branch raises IndexError
  3587. with self.assertRaises((ValueError, IndexError)):
  3588. porcelain.active_branch(self.repo)
  3589. # File 'bar' should not exist
  3590. self.assertFalse(os.path.exists(self._bar_path))
  3591. # HEAD should point to the commit
  3592. self.assertEqual(self._sha1, self.repo.refs[b"HEAD"])
  3593. def test_checkout_tag(self) -> None:
  3594. """Test checking out a tag (detached HEAD)."""
  3595. # Checkout tag
  3596. porcelain.checkout(self.repo, "v1.0")
  3597. # Should be in detached HEAD state - active_branch raises IndexError
  3598. with self.assertRaises((ValueError, IndexError)):
  3599. porcelain.active_branch(self.repo)
  3600. # File 'bar' should not exist (tag points to first commit)
  3601. self.assertFalse(os.path.exists(self._bar_path))
  3602. # HEAD should point to the tagged commit
  3603. self.assertEqual(self._sha1, self.repo.refs[b"HEAD"])
  3604. def test_checkout_new_branch(self) -> None:
  3605. """Test creating a new branch during checkout (like git checkout -b)."""
  3606. # Create and checkout new branch from current HEAD
  3607. porcelain.checkout(self.repo, "master", new_branch="new-feature")
  3608. self.assertEqual(b"new-feature", porcelain.active_branch(self.repo))
  3609. self.assertTrue(os.path.exists(self._bar_path))
  3610. # Create and checkout new branch from specific commit
  3611. porcelain.checkout(self.repo, self._sha1.decode("ascii"), new_branch="from-old")
  3612. self.assertEqual(b"from-old", porcelain.active_branch(self.repo))
  3613. self.assertFalse(os.path.exists(self._bar_path))
  3614. def test_checkout_with_uncommitted_changes(self) -> None:
  3615. """Test checkout behavior with uncommitted changes."""
  3616. # Modify a file
  3617. with open(self._foo_path, "w") as f:
  3618. f.write("modified content\n")
  3619. # Should raise error when trying to checkout
  3620. with self.assertRaises(porcelain.CheckoutError) as cm:
  3621. porcelain.checkout(self.repo, "feature")
  3622. self.assertIn("local changes", str(cm.exception))
  3623. self.assertIn("foo", str(cm.exception))
  3624. # Should still be on master
  3625. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  3626. def test_checkout_force(self) -> None:
  3627. """Test forced checkout discards local changes."""
  3628. # Modify a file
  3629. with open(self._foo_path, "w") as f:
  3630. f.write("modified content\n")
  3631. # Force checkout should succeed
  3632. porcelain.checkout(self.repo, "feature", force=True)
  3633. self.assertEqual(b"feature", porcelain.active_branch(self.repo))
  3634. # Local changes should be discarded
  3635. with open(self._foo_path) as f:
  3636. content = f.read()
  3637. self.assertEqual("initial content\n", content)
  3638. def test_checkout_nonexistent_ref(self) -> None:
  3639. """Test checkout of non-existent branch/commit."""
  3640. with self.assertRaises(KeyError):
  3641. porcelain.checkout(self.repo, "nonexistent")
  3642. def test_checkout_partial_sha(self) -> None:
  3643. """Test checkout with partial SHA."""
  3644. # Git typically allows checkout with partial SHA
  3645. partial_sha = self._sha1.decode("ascii")[:7]
  3646. porcelain.checkout(self.repo, partial_sha)
  3647. # Should be in detached HEAD state at the right commit
  3648. self.assertEqual(self._sha1, self.repo.refs[b"HEAD"])
  3649. def test_checkout_preserves_untracked_files(self) -> None:
  3650. """Test that checkout preserves untracked files."""
  3651. # Create an untracked file
  3652. untracked_path = os.path.join(self.repo.path, "untracked.txt")
  3653. with open(untracked_path, "w") as f:
  3654. f.write("untracked content\n")
  3655. # Checkout another branch
  3656. porcelain.checkout(self.repo, "feature")
  3657. # Untracked file should still exist
  3658. self.assertTrue(os.path.exists(untracked_path))
  3659. with open(untracked_path) as f:
  3660. content = f.read()
  3661. self.assertEqual("untracked content\n", content)
  3662. def test_checkout_full_ref_paths(self) -> None:
  3663. """Test checkout with full ref paths."""
  3664. # Test checkout with full branch ref path
  3665. porcelain.checkout(self.repo, "refs/heads/feature")
  3666. self.assertEqual(b"feature", porcelain.active_branch(self.repo))
  3667. # Test checkout with full tag ref path
  3668. porcelain.checkout(self.repo, "refs/tags/v1.0")
  3669. # Should be in detached HEAD state
  3670. with self.assertRaises((ValueError, IndexError)):
  3671. porcelain.active_branch(self.repo)
  3672. self.assertEqual(self._sha1, self.repo.refs[b"HEAD"])
  3673. def test_checkout_bytes_vs_string_target(self) -> None:
  3674. """Test that checkout works with both bytes and string targets."""
  3675. # Test with string target
  3676. porcelain.checkout(self.repo, "feature")
  3677. self.assertEqual(b"feature", porcelain.active_branch(self.repo))
  3678. # Test with bytes target
  3679. porcelain.checkout(self.repo, b"master")
  3680. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  3681. def test_checkout_new_branch_from_commit(self) -> None:
  3682. """Test creating a new branch from a specific commit."""
  3683. # Create new branch from first commit
  3684. porcelain.checkout(self.repo, self._sha1.decode(), new_branch="from-commit")
  3685. self.assertEqual(b"from-commit", porcelain.active_branch(self.repo))
  3686. # Should be at the first commit (no bar file)
  3687. self.assertFalse(os.path.exists(self._bar_path))
  3688. def test_checkout_with_staged_addition(self) -> None:
  3689. """Test checkout behavior with staged file additions."""
  3690. # Create and stage a new file that doesn't exist in target branch
  3691. new_file_path = os.path.join(self.repo.path, "new.txt")
  3692. with open(new_file_path, "w") as f:
  3693. f.write("new file content\n")
  3694. porcelain.add(self.repo, [new_file_path])
  3695. # This should succeed because the file doesn't exist in target branch
  3696. porcelain.checkout(self.repo, "feature")
  3697. # Should be on feature branch
  3698. self.assertEqual(b"feature", porcelain.active_branch(self.repo))
  3699. # The new file should still exist and be staged
  3700. self.assertTrue(os.path.exists(new_file_path))
  3701. status = porcelain.status(self.repo)
  3702. self.assertIn(b"new.txt", status.staged["add"])
  3703. def test_checkout_with_staged_modification_conflict(self) -> None:
  3704. """Test checkout behavior with staged modifications that would conflict."""
  3705. # Stage changes to a file that exists in both branches
  3706. with open(self._foo_path, "w") as f:
  3707. f.write("modified content\n")
  3708. porcelain.add(self.repo, [self._foo_path])
  3709. # Should prevent checkout due to staged changes to existing file
  3710. with self.assertRaises(porcelain.CheckoutError) as cm:
  3711. porcelain.checkout(self.repo, "feature")
  3712. self.assertIn("local changes", str(cm.exception))
  3713. self.assertIn("foo", str(cm.exception))
  3714. def test_checkout_head_reference(self) -> None:
  3715. """Test checkout of HEAD reference."""
  3716. # Move to feature branch first
  3717. porcelain.checkout(self.repo, "feature")
  3718. # Checkout HEAD creates detached HEAD state
  3719. porcelain.checkout(self.repo, "HEAD")
  3720. # Should be in detached HEAD state
  3721. with self.assertRaises((ValueError, IndexError)):
  3722. porcelain.active_branch(self.repo)
  3723. def test_checkout_error_messages(self) -> None:
  3724. """Test that checkout error messages are helpful."""
  3725. # Create uncommitted changes
  3726. with open(self._foo_path, "w") as f:
  3727. f.write("uncommitted changes\n")
  3728. # Try to checkout
  3729. with self.assertRaises(porcelain.CheckoutError) as cm:
  3730. porcelain.checkout(self.repo, "feature")
  3731. error_msg = str(cm.exception)
  3732. self.assertIn("local changes", error_msg)
  3733. self.assertIn("foo", error_msg)
  3734. self.assertIn("overwritten", error_msg)
  3735. self.assertIn("commit or stash", error_msg)
  3736. class SubmoduleTests(PorcelainTestCase):
  3737. def test_empty(self) -> None:
  3738. porcelain.commit(
  3739. repo=self.repo.path,
  3740. message=b"init",
  3741. author=b"author <email>",
  3742. committer=b"committer <email>",
  3743. )
  3744. self.assertEqual([], list(porcelain.submodule_list(self.repo)))
  3745. def test_add(self) -> None:
  3746. porcelain.submodule_add(self.repo, "../bar.git", "bar")
  3747. with open(f"{self.repo.path}/.gitmodules") as f:
  3748. self.assertEqual(
  3749. """\
  3750. [submodule "bar"]
  3751. \turl = ../bar.git
  3752. \tpath = bar
  3753. """,
  3754. f.read(),
  3755. )
  3756. def test_init(self) -> None:
  3757. porcelain.submodule_add(self.repo, "../bar.git", "bar")
  3758. porcelain.submodule_init(self.repo)
  3759. def test_update(self) -> None:
  3760. # Create a submodule repository
  3761. sub_repo_path = tempfile.mkdtemp()
  3762. self.addCleanup(shutil.rmtree, sub_repo_path)
  3763. sub_repo = Repo.init(sub_repo_path)
  3764. self.addCleanup(sub_repo.close)
  3765. # Add a file to the submodule repo
  3766. sub_file = os.path.join(sub_repo_path, "test.txt")
  3767. with open(sub_file, "w") as f:
  3768. f.write("submodule content")
  3769. porcelain.add(sub_repo, paths=[sub_file])
  3770. sub_commit = porcelain.commit(
  3771. sub_repo,
  3772. message=b"Initial submodule commit",
  3773. author=b"Test Author <test@example.com>",
  3774. committer=b"Test Committer <test@example.com>",
  3775. )
  3776. # Add the submodule to the main repository
  3777. porcelain.submodule_add(self.repo, sub_repo_path, "test_submodule")
  3778. # Manually add the submodule to the index
  3779. from dulwich.index import IndexEntry
  3780. from dulwich.objects import S_IFGITLINK
  3781. index = self.repo.open_index()
  3782. index[b"test_submodule"] = IndexEntry(
  3783. ctime=0,
  3784. mtime=0,
  3785. dev=0,
  3786. ino=0,
  3787. mode=S_IFGITLINK,
  3788. uid=0,
  3789. gid=0,
  3790. size=0,
  3791. sha=sub_commit,
  3792. flags=0,
  3793. )
  3794. index.write()
  3795. porcelain.add(self.repo, paths=[".gitmodules"])
  3796. porcelain.commit(
  3797. self.repo,
  3798. message=b"Add submodule",
  3799. author=b"Test Author <test@example.com>",
  3800. committer=b"Test Committer <test@example.com>",
  3801. )
  3802. # Initialize and update the submodule
  3803. porcelain.submodule_init(self.repo)
  3804. porcelain.submodule_update(self.repo)
  3805. # Check that the submodule directory exists
  3806. submodule_path = os.path.join(self.repo.path, "test_submodule")
  3807. self.assertTrue(os.path.exists(submodule_path))
  3808. # Check that the submodule file exists
  3809. submodule_file = os.path.join(submodule_path, "test.txt")
  3810. self.assertTrue(os.path.exists(submodule_file))
  3811. with open(submodule_file) as f:
  3812. self.assertEqual(f.read(), "submodule content")
  3813. class PushTests(PorcelainTestCase):
  3814. def test_simple(self) -> None:
  3815. """Basic test of porcelain push where self.repo is the remote. First
  3816. clone the remote, commit a file to the clone, then push the changes
  3817. back to the remote.
  3818. """
  3819. outstream = BytesIO()
  3820. errstream = BytesIO()
  3821. porcelain.commit(
  3822. repo=self.repo.path,
  3823. message=b"init",
  3824. author=b"author <email>",
  3825. committer=b"committer <email>",
  3826. )
  3827. # Setup target repo cloned from temp test repo
  3828. clone_path = tempfile.mkdtemp()
  3829. self.addCleanup(shutil.rmtree, clone_path)
  3830. target_repo = porcelain.clone(
  3831. self.repo.path, target=clone_path, errstream=errstream
  3832. )
  3833. self.addCleanup(target_repo.close)
  3834. self.assertEqual(target_repo[b"HEAD"], self.repo[b"HEAD"])
  3835. # create a second file to be pushed back to origin
  3836. handle, fullpath = tempfile.mkstemp(dir=clone_path)
  3837. os.close(handle)
  3838. porcelain.add(repo=clone_path, paths=[fullpath])
  3839. porcelain.commit(
  3840. repo=clone_path,
  3841. message=b"push",
  3842. author=b"author <email>",
  3843. committer=b"committer <email>",
  3844. )
  3845. # Setup a non-checked out branch in the remote
  3846. refs_path = b"refs/heads/foo"
  3847. new_id = self.repo[b"HEAD"].id
  3848. self.assertNotEqual(new_id, ZERO_SHA)
  3849. self.repo.refs[refs_path] = new_id
  3850. # Push to the remote
  3851. porcelain.push(
  3852. clone_path,
  3853. "origin",
  3854. b"HEAD:" + refs_path,
  3855. outstream=outstream,
  3856. errstream=errstream,
  3857. )
  3858. self.assertEqual(
  3859. target_repo.refs[b"refs/remotes/origin/foo"],
  3860. target_repo.refs[b"HEAD"],
  3861. )
  3862. # Check that the target and source
  3863. with Repo(clone_path) as r_clone:
  3864. self.assertEqual(
  3865. {
  3866. b"HEAD": new_id,
  3867. b"refs/heads/foo": r_clone[b"HEAD"].id,
  3868. b"refs/heads/master": new_id,
  3869. },
  3870. self.repo.get_refs(),
  3871. )
  3872. self.assertEqual(r_clone[b"HEAD"].id, self.repo[refs_path].id)
  3873. # Get the change in the target repo corresponding to the add
  3874. # this will be in the foo branch.
  3875. change = next(
  3876. iter(
  3877. tree_changes(
  3878. self.repo.object_store,
  3879. self.repo[b"HEAD"].tree,
  3880. self.repo[b"refs/heads/foo"].tree,
  3881. )
  3882. )
  3883. )
  3884. self.assertEqual(
  3885. os.path.basename(fullpath), change.new.path.decode("ascii")
  3886. )
  3887. def test_local_missing(self) -> None:
  3888. """Pushing a new branch."""
  3889. outstream = BytesIO()
  3890. errstream = BytesIO()
  3891. # Setup target repo cloned from temp test repo
  3892. clone_path = tempfile.mkdtemp()
  3893. self.addCleanup(shutil.rmtree, clone_path)
  3894. target_repo = porcelain.init(clone_path)
  3895. target_repo.close()
  3896. self.assertRaises(
  3897. porcelain.Error,
  3898. porcelain.push,
  3899. self.repo,
  3900. clone_path,
  3901. b"HEAD:refs/heads/master",
  3902. outstream=outstream,
  3903. errstream=errstream,
  3904. )
  3905. def test_new(self) -> None:
  3906. """Pushing a new branch."""
  3907. outstream = BytesIO()
  3908. errstream = BytesIO()
  3909. # Setup target repo cloned from temp test repo
  3910. clone_path = tempfile.mkdtemp()
  3911. self.addCleanup(shutil.rmtree, clone_path)
  3912. target_repo = porcelain.init(clone_path)
  3913. target_repo.close()
  3914. # create a second file to be pushed back to origin
  3915. handle, fullpath = tempfile.mkstemp(dir=clone_path)
  3916. os.close(handle)
  3917. porcelain.add(repo=clone_path, paths=[fullpath])
  3918. new_id = porcelain.commit(
  3919. repo=self.repo,
  3920. message=b"push",
  3921. author=b"author <email>",
  3922. committer=b"committer <email>",
  3923. )
  3924. # Push to the remote
  3925. porcelain.push(
  3926. self.repo,
  3927. clone_path,
  3928. b"HEAD:refs/heads/master",
  3929. outstream=outstream,
  3930. errstream=errstream,
  3931. )
  3932. with Repo(clone_path) as r_clone:
  3933. self.assertEqual(
  3934. {
  3935. b"HEAD": new_id,
  3936. b"refs/heads/master": new_id,
  3937. },
  3938. r_clone.get_refs(),
  3939. )
  3940. def test_delete(self) -> None:
  3941. """Basic test of porcelain push, removing a branch."""
  3942. outstream = BytesIO()
  3943. errstream = BytesIO()
  3944. porcelain.commit(
  3945. repo=self.repo.path,
  3946. message=b"init",
  3947. author=b"author <email>",
  3948. committer=b"committer <email>",
  3949. )
  3950. # Setup target repo cloned from temp test repo
  3951. clone_path = tempfile.mkdtemp()
  3952. self.addCleanup(shutil.rmtree, clone_path)
  3953. target_repo = porcelain.clone(
  3954. self.repo.path, target=clone_path, errstream=errstream
  3955. )
  3956. target_repo.close()
  3957. # Setup a non-checked out branch in the remote
  3958. refs_path = b"refs/heads/foo"
  3959. new_id = self.repo[b"HEAD"].id
  3960. self.assertNotEqual(new_id, ZERO_SHA)
  3961. self.repo.refs[refs_path] = new_id
  3962. # Push to the remote
  3963. porcelain.push(
  3964. clone_path,
  3965. self.repo.path,
  3966. b":" + refs_path,
  3967. outstream=outstream,
  3968. errstream=errstream,
  3969. )
  3970. self.assertEqual(
  3971. {
  3972. b"HEAD": new_id,
  3973. b"refs/heads/master": new_id,
  3974. },
  3975. self.repo.get_refs(),
  3976. )
  3977. def test_diverged(self) -> None:
  3978. outstream = BytesIO()
  3979. errstream = BytesIO()
  3980. porcelain.commit(
  3981. repo=self.repo.path,
  3982. message=b"init",
  3983. author=b"author <email>",
  3984. committer=b"committer <email>",
  3985. )
  3986. # Setup target repo cloned from temp test repo
  3987. clone_path = tempfile.mkdtemp()
  3988. self.addCleanup(shutil.rmtree, clone_path)
  3989. target_repo = porcelain.clone(
  3990. self.repo.path, target=clone_path, errstream=errstream
  3991. )
  3992. target_repo.close()
  3993. remote_id = porcelain.commit(
  3994. repo=self.repo.path,
  3995. message=b"remote change",
  3996. author=b"author <email>",
  3997. committer=b"committer <email>",
  3998. )
  3999. local_id = porcelain.commit(
  4000. repo=clone_path,
  4001. message=b"local change",
  4002. author=b"author <email>",
  4003. committer=b"committer <email>",
  4004. )
  4005. outstream = BytesIO()
  4006. errstream = BytesIO()
  4007. # Push to the remote
  4008. self.assertRaises(
  4009. porcelain.DivergedBranches,
  4010. porcelain.push,
  4011. clone_path,
  4012. self.repo.path,
  4013. b"refs/heads/master",
  4014. outstream=outstream,
  4015. errstream=errstream,
  4016. )
  4017. self.assertEqual(
  4018. {
  4019. b"HEAD": remote_id,
  4020. b"refs/heads/master": remote_id,
  4021. },
  4022. self.repo.get_refs(),
  4023. )
  4024. self.assertEqual(b"", outstream.getvalue())
  4025. self.assertEqual(b"", errstream.getvalue())
  4026. outstream = BytesIO()
  4027. errstream = BytesIO()
  4028. # Push to the remote with --force
  4029. porcelain.push(
  4030. clone_path,
  4031. self.repo.path,
  4032. b"refs/heads/master",
  4033. outstream=outstream,
  4034. errstream=errstream,
  4035. force=True,
  4036. )
  4037. self.assertEqual(
  4038. {
  4039. b"HEAD": local_id,
  4040. b"refs/heads/master": local_id,
  4041. },
  4042. self.repo.get_refs(),
  4043. )
  4044. self.assertEqual(b"", outstream.getvalue())
  4045. self.assertTrue(re.match(b"Push to .* successful.\n", errstream.getvalue()))
  4046. def test_push_returns_sendpackresult(self) -> None:
  4047. """Test that push returns a SendPackResult with per-ref information."""
  4048. outstream = BytesIO()
  4049. errstream = BytesIO()
  4050. # Create initial commit
  4051. porcelain.commit(
  4052. repo=self.repo.path,
  4053. message=b"init",
  4054. author=b"author <email>",
  4055. committer=b"committer <email>",
  4056. )
  4057. # Setup target repo cloned from temp test repo
  4058. clone_path = tempfile.mkdtemp()
  4059. self.addCleanup(shutil.rmtree, clone_path)
  4060. target_repo = porcelain.clone(
  4061. self.repo.path, target=clone_path, errstream=errstream
  4062. )
  4063. target_repo.close()
  4064. # Create a commit in the clone
  4065. handle, fullpath = tempfile.mkstemp(dir=clone_path)
  4066. os.close(handle)
  4067. porcelain.add(repo=clone_path, paths=[fullpath])
  4068. porcelain.commit(
  4069. repo=clone_path,
  4070. message=b"push",
  4071. author=b"author <email>",
  4072. committer=b"committer <email>",
  4073. )
  4074. # Push and check the return value
  4075. result = porcelain.push(
  4076. clone_path,
  4077. "origin",
  4078. b"HEAD:refs/heads/new-branch",
  4079. outstream=outstream,
  4080. errstream=errstream,
  4081. )
  4082. # Verify that we get a SendPackResult
  4083. self.assertIsInstance(result, SendPackResult)
  4084. # Verify that it contains refs
  4085. self.assertIsNotNone(result.refs)
  4086. self.assertIn(b"refs/heads/new-branch", result.refs)
  4087. # Verify ref_status - should be None for successful updates
  4088. if result.ref_status:
  4089. self.assertIsNone(result.ref_status.get(b"refs/heads/new-branch"))
  4090. def test_mirror_mode(self) -> None:
  4091. """Test push with remote.<name>.mirror configuration."""
  4092. outstream = BytesIO()
  4093. errstream = BytesIO()
  4094. # Create initial commit
  4095. porcelain.commit(
  4096. repo=self.repo.path,
  4097. message=b"init",
  4098. author=b"author <email>",
  4099. committer=b"committer <email>",
  4100. )
  4101. # Setup target repo cloned from temp test repo
  4102. clone_path = tempfile.mkdtemp()
  4103. self.addCleanup(shutil.rmtree, clone_path)
  4104. target_repo = porcelain.clone(
  4105. self.repo.path, target=clone_path, errstream=errstream
  4106. )
  4107. target_repo.close()
  4108. # Create multiple refs in the clone
  4109. with Repo(clone_path) as r_clone:
  4110. # Create a new branch
  4111. r_clone.refs[b"refs/heads/feature"] = r_clone[b"HEAD"].id
  4112. # Create a tag
  4113. r_clone.refs[b"refs/tags/v1.0"] = r_clone[b"HEAD"].id
  4114. # Create a remote tracking branch
  4115. r_clone.refs[b"refs/remotes/upstream/main"] = r_clone[b"HEAD"].id
  4116. # Create a branch in the remote that doesn't exist in clone
  4117. self.repo.refs[b"refs/heads/to-be-deleted"] = self.repo[b"HEAD"].id
  4118. # Configure mirror mode
  4119. with Repo(clone_path) as r_clone:
  4120. config = r_clone.get_config()
  4121. config.set((b"remote", b"origin"), b"mirror", True)
  4122. config.write_to_path()
  4123. # Push with mirror mode
  4124. porcelain.push(
  4125. clone_path,
  4126. "origin",
  4127. outstream=outstream,
  4128. errstream=errstream,
  4129. )
  4130. # Verify refs were properly mirrored
  4131. with Repo(clone_path) as r_clone:
  4132. # All local branches should be pushed
  4133. self.assertEqual(
  4134. r_clone.refs[b"refs/heads/feature"],
  4135. self.repo.refs[b"refs/heads/feature"],
  4136. )
  4137. # All tags should be pushed
  4138. self.assertEqual(
  4139. r_clone.refs[b"refs/tags/v1.0"], self.repo.refs[b"refs/tags/v1.0"]
  4140. )
  4141. # Remote tracking branches should be pushed
  4142. self.assertEqual(
  4143. r_clone.refs[b"refs/remotes/upstream/main"],
  4144. self.repo.refs[b"refs/remotes/upstream/main"],
  4145. )
  4146. # Verify the extra branch was deleted
  4147. self.assertNotIn(b"refs/heads/to-be-deleted", self.repo.refs)
  4148. def test_mirror_mode_disabled(self) -> None:
  4149. """Test that mirror mode is properly disabled when set to false."""
  4150. outstream = BytesIO()
  4151. errstream = BytesIO()
  4152. # Create initial commit
  4153. porcelain.commit(
  4154. repo=self.repo.path,
  4155. message=b"init",
  4156. author=b"author <email>",
  4157. committer=b"committer <email>",
  4158. )
  4159. # Setup target repo cloned from temp test repo
  4160. clone_path = tempfile.mkdtemp()
  4161. self.addCleanup(shutil.rmtree, clone_path)
  4162. target_repo = porcelain.clone(
  4163. self.repo.path, target=clone_path, errstream=errstream
  4164. )
  4165. target_repo.close()
  4166. # Create a branch in the remote that doesn't exist in clone
  4167. self.repo.refs[b"refs/heads/should-not-be-deleted"] = self.repo[b"HEAD"].id
  4168. # Explicitly set mirror mode to false
  4169. with Repo(clone_path) as r_clone:
  4170. config = r_clone.get_config()
  4171. config.set((b"remote", b"origin"), b"mirror", False)
  4172. config.write_to_path()
  4173. # Push normally (not mirror mode)
  4174. porcelain.push(
  4175. clone_path,
  4176. "origin",
  4177. outstream=outstream,
  4178. errstream=errstream,
  4179. )
  4180. # Verify the extra branch was NOT deleted
  4181. self.assertIn(b"refs/heads/should-not-be-deleted", self.repo.refs)
  4182. class PullTests(PorcelainTestCase):
  4183. def setUp(self) -> None:
  4184. super().setUp()
  4185. # create a file for initial commit
  4186. handle, fullpath = tempfile.mkstemp(dir=self.repo.path)
  4187. os.close(handle)
  4188. porcelain.add(repo=self.repo.path, paths=fullpath)
  4189. porcelain.commit(
  4190. repo=self.repo.path,
  4191. message=b"test",
  4192. author=b"test <email>",
  4193. committer=b"test <email>",
  4194. )
  4195. # Setup target repo
  4196. self.target_path = tempfile.mkdtemp()
  4197. self.addCleanup(shutil.rmtree, self.target_path)
  4198. target_repo = porcelain.clone(
  4199. self.repo.path, target=self.target_path, errstream=BytesIO()
  4200. )
  4201. target_repo.close()
  4202. # create a second file to be pushed
  4203. handle, fullpath = tempfile.mkstemp(dir=self.repo.path)
  4204. os.close(handle)
  4205. porcelain.add(repo=self.repo.path, paths=fullpath)
  4206. porcelain.commit(
  4207. repo=self.repo.path,
  4208. message=b"test2",
  4209. author=b"test2 <email>",
  4210. committer=b"test2 <email>",
  4211. )
  4212. self.assertIn(b"refs/heads/master", self.repo.refs)
  4213. self.assertIn(b"refs/heads/master", target_repo.refs)
  4214. def test_simple(self) -> None:
  4215. outstream = BytesIO()
  4216. errstream = BytesIO()
  4217. # Pull changes into the cloned repo
  4218. porcelain.pull(
  4219. self.target_path,
  4220. self.repo.path,
  4221. b"refs/heads/master",
  4222. outstream=outstream,
  4223. errstream=errstream,
  4224. )
  4225. # Check the target repo for pushed changes
  4226. with Repo(self.target_path) as r:
  4227. self.assertEqual(r[b"HEAD"].id, self.repo[b"HEAD"].id)
  4228. def test_diverged(self) -> None:
  4229. outstream = BytesIO()
  4230. errstream = BytesIO()
  4231. c3a = porcelain.commit(
  4232. repo=self.target_path,
  4233. message=b"test3a",
  4234. author=b"test2 <email>",
  4235. committer=b"test2 <email>",
  4236. )
  4237. porcelain.commit(
  4238. repo=self.repo.path,
  4239. message=b"test3b",
  4240. author=b"test2 <email>",
  4241. committer=b"test2 <email>",
  4242. )
  4243. # Pull changes into the cloned repo
  4244. self.assertRaises(
  4245. porcelain.DivergedBranches,
  4246. porcelain.pull,
  4247. self.target_path,
  4248. self.repo.path,
  4249. b"refs/heads/master",
  4250. outstream=outstream,
  4251. errstream=errstream,
  4252. )
  4253. # Check the target repo for pushed changes
  4254. with Repo(self.target_path) as r:
  4255. self.assertEqual(r[b"refs/heads/master"].id, c3a)
  4256. # Pull with merge should now work
  4257. porcelain.pull(
  4258. self.target_path,
  4259. self.repo.path,
  4260. b"refs/heads/master",
  4261. outstream=outstream,
  4262. errstream=errstream,
  4263. fast_forward=False,
  4264. )
  4265. # Check the target repo for merged changes
  4266. with Repo(self.target_path) as r:
  4267. # HEAD should now be a merge commit
  4268. head = r[b"HEAD"]
  4269. # It should have two parents
  4270. self.assertEqual(len(head.parents), 2)
  4271. # One parent should be the previous HEAD (c3a)
  4272. self.assertIn(c3a, head.parents)
  4273. # The other parent should be from the source repo
  4274. self.assertIn(self.repo[b"HEAD"].id, head.parents)
  4275. def test_no_refspec(self) -> None:
  4276. outstream = BytesIO()
  4277. errstream = BytesIO()
  4278. # Pull changes into the cloned repo
  4279. porcelain.pull(
  4280. self.target_path,
  4281. self.repo.path,
  4282. outstream=outstream,
  4283. errstream=errstream,
  4284. )
  4285. # Check the target repo for pushed changes
  4286. with Repo(self.target_path) as r:
  4287. self.assertEqual(r[b"HEAD"].id, self.repo[b"HEAD"].id)
  4288. def test_no_remote_location(self) -> None:
  4289. outstream = BytesIO()
  4290. errstream = BytesIO()
  4291. # Pull changes into the cloned repo
  4292. porcelain.pull(
  4293. self.target_path,
  4294. refspecs=b"refs/heads/master",
  4295. outstream=outstream,
  4296. errstream=errstream,
  4297. )
  4298. # Check the target repo for pushed changes
  4299. with Repo(self.target_path) as r:
  4300. self.assertEqual(r[b"HEAD"].id, self.repo[b"HEAD"].id)
  4301. def test_pull_updates_working_tree(self) -> None:
  4302. """Test that pull updates the working tree with new files."""
  4303. outstream = BytesIO()
  4304. errstream = BytesIO()
  4305. # Create a new file with content in the source repo
  4306. new_file = os.path.join(self.repo.path, "newfile.txt")
  4307. with open(new_file, "w") as f:
  4308. f.write("This is new content")
  4309. porcelain.add(repo=self.repo.path, paths=[new_file])
  4310. porcelain.commit(
  4311. repo=self.repo.path,
  4312. message=b"Add new file",
  4313. author=b"test <email>",
  4314. committer=b"test <email>",
  4315. )
  4316. # Before pull, the file should not exist in target
  4317. target_file = os.path.join(self.target_path, "newfile.txt")
  4318. self.assertFalse(os.path.exists(target_file))
  4319. # Pull changes into the cloned repo
  4320. porcelain.pull(
  4321. self.target_path,
  4322. self.repo.path,
  4323. b"refs/heads/master",
  4324. outstream=outstream,
  4325. errstream=errstream,
  4326. )
  4327. # After pull, the file should exist with correct content
  4328. self.assertTrue(os.path.exists(target_file))
  4329. with open(target_file) as f:
  4330. self.assertEqual(f.read(), "This is new content")
  4331. # Check the HEAD is updated too
  4332. with Repo(self.target_path) as r:
  4333. self.assertEqual(r[b"HEAD"].id, self.repo[b"HEAD"].id)
  4334. class StatusTests(PorcelainTestCase):
  4335. def test_empty(self) -> None:
  4336. results = porcelain.status(self.repo)
  4337. self.assertEqual({"add": [], "delete": [], "modify": []}, results.staged)
  4338. self.assertEqual([], results.unstaged)
  4339. def test_status_base(self) -> None:
  4340. """Integration test for `status` functionality."""
  4341. # Commit a dummy file then modify it
  4342. fullpath = os.path.join(self.repo.path, "foo")
  4343. with open(fullpath, "w") as f:
  4344. f.write("origstuff")
  4345. porcelain.add(repo=self.repo.path, paths=[fullpath])
  4346. porcelain.commit(
  4347. repo=self.repo.path,
  4348. message=b"test status",
  4349. author=b"author <email>",
  4350. committer=b"committer <email>",
  4351. )
  4352. # modify access and modify time of path
  4353. os.utime(fullpath, (0, 0))
  4354. with open(fullpath, "wb") as f:
  4355. f.write(b"stuff")
  4356. # Make a dummy file and stage it
  4357. filename_add = "bar"
  4358. fullpath = os.path.join(self.repo.path, filename_add)
  4359. with open(fullpath, "w") as f:
  4360. f.write("stuff")
  4361. porcelain.add(repo=self.repo.path, paths=fullpath)
  4362. results = porcelain.status(self.repo)
  4363. self.assertEqual(results.staged["add"][0], filename_add.encode("ascii"))
  4364. self.assertEqual(results.unstaged, [b"foo"])
  4365. def test_status_all(self) -> None:
  4366. del_path = os.path.join(self.repo.path, "foo")
  4367. mod_path = os.path.join(self.repo.path, "bar")
  4368. add_path = os.path.join(self.repo.path, "baz")
  4369. us_path = os.path.join(self.repo.path, "blye")
  4370. ut_path = os.path.join(self.repo.path, "blyat")
  4371. with open(del_path, "w") as f:
  4372. f.write("origstuff")
  4373. with open(mod_path, "w") as f:
  4374. f.write("origstuff")
  4375. with open(us_path, "w") as f:
  4376. f.write("origstuff")
  4377. porcelain.add(repo=self.repo.path, paths=[del_path, mod_path, us_path])
  4378. porcelain.commit(
  4379. repo=self.repo.path,
  4380. message=b"test status",
  4381. author=b"author <email>",
  4382. committer=b"committer <email>",
  4383. )
  4384. porcelain.remove(self.repo.path, [del_path])
  4385. with open(add_path, "w") as f:
  4386. f.write("origstuff")
  4387. with open(mod_path, "w") as f:
  4388. f.write("more_origstuff")
  4389. with open(us_path, "w") as f:
  4390. f.write("more_origstuff")
  4391. porcelain.add(repo=self.repo.path, paths=[add_path, mod_path])
  4392. with open(us_path, "w") as f:
  4393. f.write("\norigstuff")
  4394. with open(ut_path, "w") as f:
  4395. f.write("origstuff")
  4396. results = porcelain.status(self.repo.path)
  4397. self.assertDictEqual(
  4398. {"add": [b"baz"], "delete": [b"foo"], "modify": [b"bar"]},
  4399. results.staged,
  4400. )
  4401. self.assertListEqual(results.unstaged, [b"blye"])
  4402. results_no_untracked = porcelain.status(self.repo.path, untracked_files="no")
  4403. self.assertListEqual(results_no_untracked.untracked, [])
  4404. def test_status_wrong_untracked_files_value(self) -> None:
  4405. with self.assertRaises(ValueError):
  4406. porcelain.status(self.repo.path, untracked_files="antani")
  4407. def test_status_untracked_path(self) -> None:
  4408. untracked_dir = os.path.join(self.repo_path, "untracked_dir")
  4409. os.mkdir(untracked_dir)
  4410. untracked_file = os.path.join(untracked_dir, "untracked_file")
  4411. with open(untracked_file, "w") as fh:
  4412. fh.write("untracked")
  4413. _, _, untracked = porcelain.status(self.repo.path, untracked_files="all")
  4414. self.assertEqual(untracked, ["untracked_dir/untracked_file"])
  4415. def test_status_untracked_path_normal(self) -> None:
  4416. # Create an untracked directory with multiple files
  4417. untracked_dir = os.path.join(self.repo_path, "untracked_dir")
  4418. os.mkdir(untracked_dir)
  4419. untracked_file1 = os.path.join(untracked_dir, "file1")
  4420. untracked_file2 = os.path.join(untracked_dir, "file2")
  4421. with open(untracked_file1, "w") as fh:
  4422. fh.write("untracked1")
  4423. with open(untracked_file2, "w") as fh:
  4424. fh.write("untracked2")
  4425. # Create a nested untracked directory
  4426. nested_dir = os.path.join(untracked_dir, "nested")
  4427. os.mkdir(nested_dir)
  4428. nested_file = os.path.join(nested_dir, "file3")
  4429. with open(nested_file, "w") as fh:
  4430. fh.write("untracked3")
  4431. # Test "normal" mode - should only show the directory, not individual files
  4432. _, _, untracked = porcelain.status(self.repo.path, untracked_files="normal")
  4433. self.assertEqual(untracked, ["untracked_dir/"])
  4434. # Test "all" mode - should show all files
  4435. _, _, untracked_all = porcelain.status(self.repo.path, untracked_files="all")
  4436. self.assertEqual(
  4437. sorted(untracked_all),
  4438. [
  4439. "untracked_dir/file1",
  4440. "untracked_dir/file2",
  4441. "untracked_dir/nested/file3",
  4442. ],
  4443. )
  4444. def test_status_mixed_tracked_untracked(self) -> None:
  4445. # Create a directory with both tracked and untracked files
  4446. mixed_dir = os.path.join(self.repo_path, "mixed_dir")
  4447. os.mkdir(mixed_dir)
  4448. # Add a tracked file
  4449. tracked_file = os.path.join(mixed_dir, "tracked.txt")
  4450. with open(tracked_file, "w") as fh:
  4451. fh.write("tracked content")
  4452. porcelain.add(self.repo.path, paths=[tracked_file])
  4453. porcelain.commit(
  4454. repo=self.repo.path,
  4455. message=b"add tracked file",
  4456. author=b"author <email>",
  4457. committer=b"committer <email>",
  4458. )
  4459. # Add untracked files to the same directory
  4460. untracked_file = os.path.join(mixed_dir, "untracked.txt")
  4461. with open(untracked_file, "w") as fh:
  4462. fh.write("untracked content")
  4463. # In "normal" mode, should show individual untracked files in mixed dirs
  4464. _, _, untracked = porcelain.status(self.repo.path, untracked_files="normal")
  4465. self.assertEqual(untracked, ["mixed_dir/untracked.txt"])
  4466. # In "all" mode, should be the same for mixed directories
  4467. _, _, untracked_all = porcelain.status(self.repo.path, untracked_files="all")
  4468. self.assertEqual(untracked_all, ["mixed_dir/untracked.txt"])
  4469. def test_status_crlf_mismatch(self) -> None:
  4470. # First make a commit as if the file has been added on a Linux system
  4471. # or with core.autocrlf=True
  4472. file_path = os.path.join(self.repo.path, "crlf")
  4473. with open(file_path, "wb") as f:
  4474. f.write(b"line1\nline2")
  4475. porcelain.add(repo=self.repo.path, paths=[file_path])
  4476. porcelain.commit(
  4477. repo=self.repo.path,
  4478. message=b"test status",
  4479. author=b"author <email>",
  4480. committer=b"committer <email>",
  4481. )
  4482. # Then update the file as if it was created by CGit on a Windows
  4483. # system with core.autocrlf=true
  4484. with open(file_path, "wb") as f:
  4485. f.write(b"line1\r\nline2")
  4486. results = porcelain.status(self.repo)
  4487. self.assertDictEqual({"add": [], "delete": [], "modify": []}, results.staged)
  4488. self.assertListEqual(results.unstaged, [b"crlf"])
  4489. self.assertListEqual(results.untracked, [])
  4490. def test_status_autocrlf_true(self) -> None:
  4491. # First make a commit as if the file has been added on a Linux system
  4492. # or with core.autocrlf=True
  4493. file_path = os.path.join(self.repo.path, "crlf")
  4494. with open(file_path, "wb") as f:
  4495. f.write(b"line1\nline2")
  4496. porcelain.add(repo=self.repo.path, paths=[file_path])
  4497. porcelain.commit(
  4498. repo=self.repo.path,
  4499. message=b"test status",
  4500. author=b"author <email>",
  4501. committer=b"committer <email>",
  4502. )
  4503. # Then update the file as if it was created by CGit on a Windows
  4504. # system with core.autocrlf=true
  4505. with open(file_path, "wb") as f:
  4506. f.write(b"line1\r\nline2")
  4507. # TODO: It should be set automatically by looking at the configuration
  4508. c = self.repo.get_config()
  4509. c.set("core", "autocrlf", True)
  4510. c.write_to_path()
  4511. results = porcelain.status(self.repo)
  4512. self.assertDictEqual({"add": [], "delete": [], "modify": []}, results.staged)
  4513. self.assertListEqual(results.unstaged, [])
  4514. self.assertListEqual(results.untracked, [])
  4515. def test_status_autocrlf_input(self) -> None:
  4516. # Commit existing file with CRLF
  4517. file_path = os.path.join(self.repo.path, "crlf-exists")
  4518. with open(file_path, "wb") as f:
  4519. f.write(b"line1\r\nline2")
  4520. porcelain.add(repo=self.repo.path, paths=[file_path])
  4521. porcelain.commit(
  4522. repo=self.repo.path,
  4523. message=b"test status",
  4524. author=b"author <email>",
  4525. committer=b"committer <email>",
  4526. )
  4527. c = self.repo.get_config()
  4528. c.set("core", "autocrlf", "input")
  4529. c.write_to_path()
  4530. # Add new (untracked) file
  4531. file_path = os.path.join(self.repo.path, "crlf-new")
  4532. with open(file_path, "wb") as f:
  4533. f.write(b"line1\r\nline2")
  4534. porcelain.add(repo=self.repo.path, paths=[file_path])
  4535. results = porcelain.status(self.repo)
  4536. self.assertDictEqual(
  4537. {"add": [b"crlf-new"], "delete": [], "modify": []}, results.staged
  4538. )
  4539. self.assertListEqual(results.unstaged, [b"crlf-exists"])
  4540. self.assertListEqual(results.untracked, [])
  4541. def test_get_tree_changes_add(self) -> None:
  4542. """Unit test for get_tree_changes add."""
  4543. # Make a dummy file, stage
  4544. filename = "bar"
  4545. fullpath = os.path.join(self.repo.path, filename)
  4546. with open(fullpath, "w") as f:
  4547. f.write("stuff")
  4548. porcelain.add(repo=self.repo.path, paths=fullpath)
  4549. porcelain.commit(
  4550. repo=self.repo.path,
  4551. message=b"test status",
  4552. author=b"author <email>",
  4553. committer=b"committer <email>",
  4554. )
  4555. filename = "foo"
  4556. fullpath = os.path.join(self.repo.path, filename)
  4557. with open(fullpath, "w") as f:
  4558. f.write("stuff")
  4559. porcelain.add(repo=self.repo.path, paths=fullpath)
  4560. changes = porcelain.get_tree_changes(self.repo.path)
  4561. self.assertEqual(changes["add"][0], filename.encode("ascii"))
  4562. self.assertEqual(len(changes["add"]), 1)
  4563. self.assertEqual(len(changes["modify"]), 0)
  4564. self.assertEqual(len(changes["delete"]), 0)
  4565. def test_get_tree_changes_modify(self) -> None:
  4566. """Unit test for get_tree_changes modify."""
  4567. # Make a dummy file, stage, commit, modify
  4568. filename = "foo"
  4569. fullpath = os.path.join(self.repo.path, filename)
  4570. with open(fullpath, "w") as f:
  4571. f.write("stuff")
  4572. porcelain.add(repo=self.repo.path, paths=fullpath)
  4573. porcelain.commit(
  4574. repo=self.repo.path,
  4575. message=b"test status",
  4576. author=b"author <email>",
  4577. committer=b"committer <email>",
  4578. )
  4579. with open(fullpath, "w") as f:
  4580. f.write("otherstuff")
  4581. porcelain.add(repo=self.repo.path, paths=fullpath)
  4582. changes = porcelain.get_tree_changes(self.repo.path)
  4583. self.assertEqual(changes["modify"][0], filename.encode("ascii"))
  4584. self.assertEqual(len(changes["add"]), 0)
  4585. self.assertEqual(len(changes["modify"]), 1)
  4586. self.assertEqual(len(changes["delete"]), 0)
  4587. def test_get_tree_changes_delete(self) -> None:
  4588. """Unit test for get_tree_changes delete."""
  4589. # Make a dummy file, stage, commit, remove
  4590. filename = "foo"
  4591. fullpath = os.path.join(self.repo.path, filename)
  4592. with open(fullpath, "w") as f:
  4593. f.write("stuff")
  4594. porcelain.add(repo=self.repo.path, paths=fullpath)
  4595. porcelain.commit(
  4596. repo=self.repo.path,
  4597. message=b"test status",
  4598. author=b"author <email>",
  4599. committer=b"committer <email>",
  4600. )
  4601. cwd = os.getcwd()
  4602. self.addCleanup(os.chdir, cwd)
  4603. os.chdir(self.repo.path)
  4604. porcelain.remove(repo=self.repo.path, paths=[filename])
  4605. changes = porcelain.get_tree_changes(self.repo.path)
  4606. self.assertEqual(changes["delete"][0], filename.encode("ascii"))
  4607. self.assertEqual(len(changes["add"]), 0)
  4608. self.assertEqual(len(changes["modify"]), 0)
  4609. self.assertEqual(len(changes["delete"]), 1)
  4610. def test_get_untracked_paths(self) -> None:
  4611. with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
  4612. f.write("ignored\n")
  4613. with open(os.path.join(self.repo.path, "ignored"), "w") as f:
  4614. f.write("blah\n")
  4615. with open(os.path.join(self.repo.path, "notignored"), "w") as f:
  4616. f.write("blah\n")
  4617. os.symlink(
  4618. os.path.join(self.repo.path, os.pardir, "external_target"),
  4619. os.path.join(self.repo.path, "link"),
  4620. )
  4621. self.assertEqual(
  4622. {"ignored", "notignored", ".gitignore", "link"},
  4623. set(
  4624. porcelain.get_untracked_paths(
  4625. self.repo.path, self.repo.path, self.repo.open_index()
  4626. )
  4627. ),
  4628. )
  4629. self.assertEqual(
  4630. {".gitignore", "notignored", "link"},
  4631. set(porcelain.status(self.repo).untracked),
  4632. )
  4633. self.assertEqual(
  4634. {".gitignore", "notignored", "ignored", "link"},
  4635. set(porcelain.status(self.repo, ignored=True).untracked),
  4636. )
  4637. def test_get_untracked_paths_subrepo(self) -> None:
  4638. with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
  4639. f.write("nested/\n")
  4640. with open(os.path.join(self.repo.path, "notignored"), "w") as f:
  4641. f.write("blah\n")
  4642. subrepo = Repo.init(os.path.join(self.repo.path, "nested"), mkdir=True)
  4643. with open(os.path.join(subrepo.path, "ignored"), "w") as f:
  4644. f.write("bleep\n")
  4645. with open(os.path.join(subrepo.path, "with"), "w") as f:
  4646. f.write("bloop\n")
  4647. with open(os.path.join(subrepo.path, "manager"), "w") as f:
  4648. f.write("blop\n")
  4649. self.assertEqual(
  4650. {".gitignore", "notignored", os.path.join("nested", "")},
  4651. set(
  4652. porcelain.get_untracked_paths(
  4653. self.repo.path, self.repo.path, self.repo.open_index()
  4654. )
  4655. ),
  4656. )
  4657. self.assertEqual(
  4658. {".gitignore", "notignored"},
  4659. set(
  4660. porcelain.get_untracked_paths(
  4661. self.repo.path,
  4662. self.repo.path,
  4663. self.repo.open_index(),
  4664. exclude_ignored=True,
  4665. )
  4666. ),
  4667. )
  4668. self.assertEqual(
  4669. {"ignored", "with", "manager"},
  4670. set(
  4671. porcelain.get_untracked_paths(
  4672. subrepo.path, subrepo.path, subrepo.open_index()
  4673. )
  4674. ),
  4675. )
  4676. self.assertEqual(
  4677. set(),
  4678. set(
  4679. porcelain.get_untracked_paths(
  4680. subrepo.path,
  4681. self.repo.path,
  4682. self.repo.open_index(),
  4683. )
  4684. ),
  4685. )
  4686. self.assertEqual(
  4687. {
  4688. os.path.join("nested", "ignored"),
  4689. os.path.join("nested", "with"),
  4690. os.path.join("nested", "manager"),
  4691. },
  4692. set(
  4693. porcelain.get_untracked_paths(
  4694. self.repo.path,
  4695. subrepo.path,
  4696. self.repo.open_index(),
  4697. )
  4698. ),
  4699. )
  4700. def test_get_untracked_paths_subdir(self) -> None:
  4701. with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
  4702. f.write("subdir/\nignored")
  4703. with open(os.path.join(self.repo.path, "notignored"), "w") as f:
  4704. f.write("blah\n")
  4705. os.mkdir(os.path.join(self.repo.path, "subdir"))
  4706. with open(os.path.join(self.repo.path, "ignored"), "w") as f:
  4707. f.write("foo")
  4708. with open(os.path.join(self.repo.path, "subdir", "ignored"), "w") as f:
  4709. f.write("foo")
  4710. self.assertEqual(
  4711. {
  4712. ".gitignore",
  4713. "notignored",
  4714. "ignored",
  4715. os.path.join("subdir", ""),
  4716. },
  4717. set(
  4718. porcelain.get_untracked_paths(
  4719. self.repo.path,
  4720. self.repo.path,
  4721. self.repo.open_index(),
  4722. )
  4723. ),
  4724. )
  4725. self.assertEqual(
  4726. {".gitignore", "notignored"},
  4727. set(
  4728. porcelain.get_untracked_paths(
  4729. self.repo.path,
  4730. self.repo.path,
  4731. self.repo.open_index(),
  4732. exclude_ignored=True,
  4733. )
  4734. ),
  4735. )
  4736. def test_get_untracked_paths_invalid_untracked_files(self) -> None:
  4737. with self.assertRaises(ValueError):
  4738. list(
  4739. porcelain.get_untracked_paths(
  4740. self.repo.path,
  4741. self.repo.path,
  4742. self.repo.open_index(),
  4743. untracked_files="invalid_value",
  4744. )
  4745. )
  4746. def test_get_untracked_paths_normal(self) -> None:
  4747. # Create an untracked directory with files
  4748. untracked_dir = os.path.join(self.repo.path, "untracked_dir")
  4749. os.mkdir(untracked_dir)
  4750. with open(os.path.join(untracked_dir, "file1.txt"), "w") as f:
  4751. f.write("untracked content")
  4752. with open(os.path.join(untracked_dir, "file2.txt"), "w") as f:
  4753. f.write("more untracked content")
  4754. # Test that "normal" mode works and returns only the directory
  4755. _, _, untracked = porcelain.status(
  4756. repo=self.repo.path, untracked_files="normal"
  4757. )
  4758. self.assertEqual(untracked, ["untracked_dir/"])
  4759. def test_get_untracked_paths_top_level_issue_1247(self) -> None:
  4760. """Test for issue #1247: ensure top-level untracked files are detected."""
  4761. # Create a single top-level untracked file
  4762. with open(os.path.join(self.repo.path, "sample.txt"), "w") as f:
  4763. f.write("test content")
  4764. # Test get_untracked_paths directly
  4765. untracked = list(
  4766. porcelain.get_untracked_paths(
  4767. self.repo.path, self.repo.path, self.repo.open_index()
  4768. )
  4769. )
  4770. self.assertIn(
  4771. "sample.txt",
  4772. untracked,
  4773. "Top-level file 'sample.txt' should be in untracked list",
  4774. )
  4775. # Test via status
  4776. status = porcelain.status(self.repo)
  4777. self.assertIn(
  4778. "sample.txt",
  4779. status.untracked,
  4780. "Top-level file 'sample.txt' should be in status.untracked",
  4781. )
  4782. # TODO(jelmer): Add test for dulwich.porcelain.daemon
  4783. class UploadPackTests(PorcelainTestCase):
  4784. """Tests for upload_pack."""
  4785. def test_upload_pack(self) -> None:
  4786. outf = BytesIO()
  4787. exitcode = porcelain.upload_pack(self.repo.path, BytesIO(b"0000"), outf)
  4788. outlines = outf.getvalue().splitlines()
  4789. self.assertEqual([b"0000"], outlines)
  4790. self.assertEqual(0, exitcode)
  4791. class ReceivePackTests(PorcelainTestCase):
  4792. """Tests for receive_pack."""
  4793. def test_receive_pack(self) -> None:
  4794. filename = "foo"
  4795. fullpath = os.path.join(self.repo.path, filename)
  4796. with open(fullpath, "w") as f:
  4797. f.write("stuff")
  4798. porcelain.add(repo=self.repo.path, paths=fullpath)
  4799. self.repo.do_commit(
  4800. message=b"test status",
  4801. author=b"author <email>",
  4802. committer=b"committer <email>",
  4803. author_timestamp=1402354300,
  4804. commit_timestamp=1402354300,
  4805. author_timezone=0,
  4806. commit_timezone=0,
  4807. )
  4808. outf = BytesIO()
  4809. exitcode = porcelain.receive_pack(self.repo.path, BytesIO(b"0000"), outf)
  4810. outlines = outf.getvalue().splitlines()
  4811. self.assertEqual(
  4812. [
  4813. b"0091319b56ce3aee2d489f759736a79cc552c9bb86d9 HEAD\x00 report-status "
  4814. b"delete-refs quiet ofs-delta side-band-64k "
  4815. b"no-done symref=HEAD:refs/heads/master",
  4816. b"003f319b56ce3aee2d489f759736a79cc552c9bb86d9 refs/heads/master",
  4817. b"0000",
  4818. ],
  4819. outlines,
  4820. )
  4821. self.assertEqual(0, exitcode)
  4822. class BranchListTests(PorcelainTestCase):
  4823. def test_standard(self) -> None:
  4824. self.assertEqual(set(), set(porcelain.branch_list(self.repo)))
  4825. def test_new_branch(self) -> None:
  4826. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  4827. self.repo[b"HEAD"] = c1.id
  4828. porcelain.branch_create(self.repo, b"foo")
  4829. self.assertEqual({b"master", b"foo"}, set(porcelain.branch_list(self.repo)))
  4830. def test_sort_by_refname(self) -> None:
  4831. """Test branch.sort=refname (default alphabetical)."""
  4832. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  4833. self.repo[b"HEAD"] = c1.id
  4834. # Create branches in non-alphabetical order
  4835. porcelain.branch_create(self.repo, b"zebra")
  4836. porcelain.branch_create(self.repo, b"alpha")
  4837. porcelain.branch_create(self.repo, b"beta")
  4838. # Set branch.sort to refname (though it's the default)
  4839. config = self.repo.get_config()
  4840. config.set((b"branch",), b"sort", b"refname")
  4841. config.write_to_path()
  4842. # Should be sorted alphabetically
  4843. branches = porcelain.branch_list(self.repo)
  4844. self.assertEqual([b"alpha", b"beta", b"master", b"zebra"], branches)
  4845. def test_sort_by_refname_reverse(self) -> None:
  4846. """Test branch.sort=-refname (reverse alphabetical)."""
  4847. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  4848. self.repo[b"HEAD"] = c1.id
  4849. # Create branches
  4850. porcelain.branch_create(self.repo, b"zebra")
  4851. porcelain.branch_create(self.repo, b"alpha")
  4852. porcelain.branch_create(self.repo, b"beta")
  4853. # Set branch.sort to -refname
  4854. config = self.repo.get_config()
  4855. config.set((b"branch",), b"sort", b"-refname")
  4856. config.write_to_path()
  4857. # Should be sorted reverse alphabetically
  4858. branches = porcelain.branch_list(self.repo)
  4859. self.assertEqual([b"zebra", b"master", b"beta", b"alpha"], branches)
  4860. def test_sort_by_committerdate(self) -> None:
  4861. """Test branch.sort=committerdate."""
  4862. # Use build_commit_graph to create proper commits with specific times
  4863. c1, c2, c3 = build_commit_graph(
  4864. self.repo.object_store,
  4865. [[1], [2], [3]],
  4866. attrs={
  4867. 1: {"commit_time": 1000}, # oldest
  4868. 2: {"commit_time": 2000}, # newest
  4869. 3: {"commit_time": 1500}, # middle
  4870. },
  4871. )
  4872. self.repo[b"HEAD"] = c1.id
  4873. # Create branches pointing to different commits
  4874. self.repo.refs[b"refs/heads/master"] = c1.id # master points to oldest
  4875. self.repo.refs[b"refs/heads/oldest"] = c1.id
  4876. self.repo.refs[b"refs/heads/newest"] = c2.id
  4877. self.repo.refs[b"refs/heads/middle"] = c3.id
  4878. # Set branch.sort to committerdate
  4879. config = self.repo.get_config()
  4880. config.set((b"branch",), b"sort", b"committerdate")
  4881. config.write_to_path()
  4882. # Should be sorted by commit time (oldest first)
  4883. branches = porcelain.branch_list(self.repo)
  4884. self.assertEqual([b"master", b"oldest", b"middle", b"newest"], branches)
  4885. def test_sort_by_committerdate_reverse(self) -> None:
  4886. """Test branch.sort=-committerdate."""
  4887. # Use build_commit_graph to create proper commits with specific times
  4888. c1, c2, c3 = build_commit_graph(
  4889. self.repo.object_store,
  4890. [[1], [2], [3]],
  4891. attrs={
  4892. 1: {"commit_time": 1000}, # oldest
  4893. 2: {"commit_time": 2000}, # newest
  4894. 3: {"commit_time": 1500}, # middle
  4895. },
  4896. )
  4897. self.repo[b"HEAD"] = c1.id
  4898. # Create branches pointing to different commits
  4899. self.repo.refs[b"refs/heads/master"] = c1.id # master points to oldest
  4900. self.repo.refs[b"refs/heads/oldest"] = c1.id
  4901. self.repo.refs[b"refs/heads/newest"] = c2.id
  4902. self.repo.refs[b"refs/heads/middle"] = c3.id
  4903. # Set branch.sort to -committerdate
  4904. config = self.repo.get_config()
  4905. config.set((b"branch",), b"sort", b"-committerdate")
  4906. config.write_to_path()
  4907. # Should be sorted by commit time (newest first)
  4908. branches = porcelain.branch_list(self.repo)
  4909. self.assertEqual([b"newest", b"middle", b"master", b"oldest"], branches)
  4910. def test_sort_default(self) -> None:
  4911. """Test default sorting (no config)."""
  4912. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  4913. self.repo[b"HEAD"] = c1.id
  4914. # Create branches in non-alphabetical order
  4915. porcelain.branch_create(self.repo, b"zebra")
  4916. porcelain.branch_create(self.repo, b"alpha")
  4917. porcelain.branch_create(self.repo, b"beta")
  4918. # No config set - should default to alphabetical
  4919. branches = porcelain.branch_list(self.repo)
  4920. self.assertEqual([b"alpha", b"beta", b"master", b"zebra"], branches)
  4921. class BranchCreateTests(PorcelainTestCase):
  4922. def test_branch_exists(self) -> None:
  4923. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  4924. self.repo[b"HEAD"] = c1.id
  4925. porcelain.branch_create(self.repo, b"foo")
  4926. self.assertRaises(porcelain.Error, porcelain.branch_create, self.repo, b"foo")
  4927. porcelain.branch_create(self.repo, b"foo", force=True)
  4928. def test_new_branch(self) -> None:
  4929. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  4930. self.repo[b"HEAD"] = c1.id
  4931. porcelain.branch_create(self.repo, b"foo")
  4932. self.assertEqual({b"master", b"foo"}, set(porcelain.branch_list(self.repo)))
  4933. def test_auto_setup_merge_true_from_remote_tracking(self) -> None:
  4934. """Test branch.autoSetupMerge=true sets up tracking from remote-tracking branch."""
  4935. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  4936. self.repo[b"HEAD"] = c1.id
  4937. # Create a remote-tracking branch
  4938. self.repo.refs[b"refs/remotes/origin/feature"] = c1.id
  4939. # Set branch.autoSetupMerge to true (default)
  4940. config = self.repo.get_config()
  4941. config.set((b"branch",), b"autoSetupMerge", b"true")
  4942. config.write_to_path()
  4943. # Create branch from remote-tracking branch
  4944. porcelain.branch_create(self.repo, "myfeature", "origin/feature")
  4945. # Verify tracking was set up
  4946. config = self.repo.get_config()
  4947. self.assertEqual(config.get((b"branch", b"myfeature"), b"remote"), b"origin")
  4948. self.assertEqual(
  4949. config.get((b"branch", b"myfeature"), b"merge"), b"refs/heads/feature"
  4950. )
  4951. def test_auto_setup_merge_false(self) -> None:
  4952. """Test branch.autoSetupMerge=false disables tracking setup."""
  4953. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  4954. self.repo[b"HEAD"] = c1.id
  4955. # Create a remote-tracking branch
  4956. self.repo.refs[b"refs/remotes/origin/feature"] = c1.id
  4957. # Set branch.autoSetupMerge to false
  4958. config = self.repo.get_config()
  4959. config.set((b"branch",), b"autoSetupMerge", b"false")
  4960. config.write_to_path()
  4961. # Create branch from remote-tracking branch
  4962. porcelain.branch_create(self.repo, "myfeature", "origin/feature")
  4963. # Verify tracking was NOT set up
  4964. config = self.repo.get_config()
  4965. self.assertRaises(KeyError, config.get, (b"branch", b"myfeature"), b"remote")
  4966. self.assertRaises(KeyError, config.get, (b"branch", b"myfeature"), b"merge")
  4967. def test_auto_setup_merge_always(self) -> None:
  4968. """Test branch.autoSetupMerge=always sets up tracking even from local branches."""
  4969. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  4970. self.repo[b"HEAD"] = c1.id
  4971. self.repo.refs[b"refs/heads/main"] = c1.id
  4972. # Set branch.autoSetupMerge to always
  4973. config = self.repo.get_config()
  4974. config.set((b"branch",), b"autoSetupMerge", b"always")
  4975. config.write_to_path()
  4976. # Create branch from local branch - normally wouldn't set up tracking
  4977. porcelain.branch_create(self.repo, "feature", "main")
  4978. # With always, tracking should NOT be set up from local branches
  4979. # (Git only sets up tracking from remote-tracking branches even with always)
  4980. config = self.repo.get_config()
  4981. self.assertRaises(KeyError, config.get, (b"branch", b"feature"), b"remote")
  4982. self.assertRaises(KeyError, config.get, (b"branch", b"feature"), b"merge")
  4983. def test_auto_setup_merge_always_from_remote(self) -> None:
  4984. """Test branch.autoSetupMerge=always still sets up tracking from remote branches."""
  4985. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  4986. self.repo[b"HEAD"] = c1.id
  4987. # Create a remote-tracking branch
  4988. self.repo.refs[b"refs/remotes/origin/feature"] = c1.id
  4989. # Set branch.autoSetupMerge to always
  4990. config = self.repo.get_config()
  4991. config.set((b"branch",), b"autoSetupMerge", b"always")
  4992. config.write_to_path()
  4993. # Create branch from remote-tracking branch
  4994. porcelain.branch_create(self.repo, "myfeature", "origin/feature")
  4995. # Verify tracking was set up
  4996. config = self.repo.get_config()
  4997. self.assertEqual(config.get((b"branch", b"myfeature"), b"remote"), b"origin")
  4998. self.assertEqual(
  4999. config.get((b"branch", b"myfeature"), b"merge"), b"refs/heads/feature"
  5000. )
  5001. def test_auto_setup_merge_default(self) -> None:
  5002. """Test default behavior (no config) is same as true."""
  5003. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  5004. self.repo[b"HEAD"] = c1.id
  5005. # Create a remote-tracking branch
  5006. self.repo.refs[b"refs/remotes/origin/feature"] = c1.id
  5007. # Don't set any config - should default to true
  5008. # Create branch from remote-tracking branch
  5009. porcelain.branch_create(self.repo, "myfeature", "origin/feature")
  5010. # Verify tracking was set up
  5011. config = self.repo.get_config()
  5012. self.assertEqual(config.get((b"branch", b"myfeature"), b"remote"), b"origin")
  5013. self.assertEqual(
  5014. config.get((b"branch", b"myfeature"), b"merge"), b"refs/heads/feature"
  5015. )
  5016. class BranchDeleteTests(PorcelainTestCase):
  5017. def test_simple(self) -> None:
  5018. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  5019. self.repo[b"HEAD"] = c1.id
  5020. porcelain.branch_create(self.repo, b"foo")
  5021. self.assertIn(b"foo", porcelain.branch_list(self.repo))
  5022. porcelain.branch_delete(self.repo, b"foo")
  5023. self.assertNotIn(b"foo", porcelain.branch_list(self.repo))
  5024. def test_simple_unicode(self) -> None:
  5025. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  5026. self.repo[b"HEAD"] = c1.id
  5027. porcelain.branch_create(self.repo, "foo")
  5028. self.assertIn(b"foo", porcelain.branch_list(self.repo))
  5029. porcelain.branch_delete(self.repo, "foo")
  5030. self.assertNotIn(b"foo", porcelain.branch_list(self.repo))
  5031. class FetchTests(PorcelainTestCase):
  5032. def test_simple(self) -> None:
  5033. outstream = BytesIO()
  5034. errstream = BytesIO()
  5035. # create a file for initial commit
  5036. handle, fullpath = tempfile.mkstemp(dir=self.repo.path)
  5037. os.close(handle)
  5038. porcelain.add(repo=self.repo.path, paths=fullpath)
  5039. porcelain.commit(
  5040. repo=self.repo.path,
  5041. message=b"test",
  5042. author=b"test <email>",
  5043. committer=b"test <email>",
  5044. )
  5045. # Setup target repo
  5046. target_path = tempfile.mkdtemp()
  5047. self.addCleanup(shutil.rmtree, target_path)
  5048. target_repo = porcelain.clone(
  5049. self.repo.path, target=target_path, errstream=errstream
  5050. )
  5051. # create a second file to be pushed
  5052. handle, fullpath = tempfile.mkstemp(dir=self.repo.path)
  5053. os.close(handle)
  5054. porcelain.add(repo=self.repo.path, paths=fullpath)
  5055. porcelain.commit(
  5056. repo=self.repo.path,
  5057. message=b"test2",
  5058. author=b"test2 <email>",
  5059. committer=b"test2 <email>",
  5060. )
  5061. self.assertNotIn(self.repo[b"HEAD"].id, target_repo)
  5062. target_repo.close()
  5063. # Fetch changes into the cloned repo
  5064. porcelain.fetch(target_path, "origin", outstream=outstream, errstream=errstream)
  5065. # Assert that fetch updated the local image of the remote
  5066. self.assert_correct_remote_refs(target_repo.get_refs(), self.repo.get_refs())
  5067. # Check the target repo for pushed changes
  5068. with Repo(target_path) as r:
  5069. self.assertIn(self.repo[b"HEAD"].id, r)
  5070. def test_with_remote_name(self) -> None:
  5071. remote_name = "origin"
  5072. outstream = BytesIO()
  5073. errstream = BytesIO()
  5074. # create a file for initial commit
  5075. handle, fullpath = tempfile.mkstemp(dir=self.repo.path)
  5076. os.close(handle)
  5077. porcelain.add(repo=self.repo.path, paths=fullpath)
  5078. porcelain.commit(
  5079. repo=self.repo.path,
  5080. message=b"test",
  5081. author=b"test <email>",
  5082. committer=b"test <email>",
  5083. )
  5084. # Setup target repo
  5085. target_path = tempfile.mkdtemp()
  5086. self.addCleanup(shutil.rmtree, target_path)
  5087. target_repo = porcelain.clone(
  5088. self.repo.path, target=target_path, errstream=errstream
  5089. )
  5090. # Capture current refs
  5091. target_refs = target_repo.get_refs()
  5092. # create a second file to be pushed
  5093. handle, fullpath = tempfile.mkstemp(dir=self.repo.path)
  5094. os.close(handle)
  5095. porcelain.add(repo=self.repo.path, paths=fullpath)
  5096. porcelain.commit(
  5097. repo=self.repo.path,
  5098. message=b"test2",
  5099. author=b"test2 <email>",
  5100. committer=b"test2 <email>",
  5101. )
  5102. self.assertNotIn(self.repo[b"HEAD"].id, target_repo)
  5103. target_config = target_repo.get_config()
  5104. target_config.set(
  5105. (b"remote", remote_name.encode()), b"url", self.repo.path.encode()
  5106. )
  5107. target_repo.close()
  5108. # Fetch changes into the cloned repo
  5109. porcelain.fetch(
  5110. target_path, remote_name, outstream=outstream, errstream=errstream
  5111. )
  5112. # Assert that fetch updated the local image of the remote
  5113. self.assert_correct_remote_refs(target_repo.get_refs(), self.repo.get_refs())
  5114. # Check the target repo for pushed changes, as well as updates
  5115. # for the refs
  5116. with Repo(target_path) as r:
  5117. self.assertIn(self.repo[b"HEAD"].id, r)
  5118. self.assertNotEqual(self.repo.get_refs(), target_refs)
  5119. def assert_correct_remote_refs(
  5120. self, local_refs, remote_refs, remote_name=b"origin"
  5121. ) -> None:
  5122. """Assert that known remote refs corresponds to actual remote refs."""
  5123. local_ref_prefix = b"refs/heads"
  5124. remote_ref_prefix = b"refs/remotes/" + remote_name
  5125. locally_known_remote_refs = {
  5126. k[len(remote_ref_prefix) + 1 :]: v
  5127. for k, v in local_refs.items()
  5128. if k.startswith(remote_ref_prefix)
  5129. }
  5130. normalized_remote_refs = {
  5131. k[len(local_ref_prefix) + 1 :]: v
  5132. for k, v in remote_refs.items()
  5133. if k.startswith(local_ref_prefix)
  5134. }
  5135. if b"HEAD" in locally_known_remote_refs and b"HEAD" in remote_refs:
  5136. normalized_remote_refs[b"HEAD"] = remote_refs[b"HEAD"]
  5137. self.assertEqual(locally_known_remote_refs, normalized_remote_refs)
  5138. class RepackTests(PorcelainTestCase):
  5139. def test_empty(self) -> None:
  5140. porcelain.repack(self.repo)
  5141. def test_simple(self) -> None:
  5142. handle, fullpath = tempfile.mkstemp(dir=self.repo.path)
  5143. os.close(handle)
  5144. porcelain.add(repo=self.repo.path, paths=fullpath)
  5145. porcelain.repack(self.repo)
  5146. class LsTreeTests(PorcelainTestCase):
  5147. def test_empty(self) -> None:
  5148. porcelain.commit(
  5149. repo=self.repo.path,
  5150. message=b"test status",
  5151. author=b"author <email>",
  5152. committer=b"committer <email>",
  5153. )
  5154. f = StringIO()
  5155. porcelain.ls_tree(self.repo, b"HEAD", outstream=f)
  5156. self.assertEqual(f.getvalue(), "")
  5157. def test_simple(self) -> None:
  5158. # Commit a dummy file then modify it
  5159. fullpath = os.path.join(self.repo.path, "foo")
  5160. with open(fullpath, "w") as f:
  5161. f.write("origstuff")
  5162. porcelain.add(repo=self.repo.path, paths=[fullpath])
  5163. porcelain.commit(
  5164. repo=self.repo.path,
  5165. message=b"test status",
  5166. author=b"author <email>",
  5167. committer=b"committer <email>",
  5168. )
  5169. output = StringIO()
  5170. porcelain.ls_tree(self.repo, b"HEAD", outstream=output)
  5171. self.assertEqual(
  5172. output.getvalue(),
  5173. "100644 blob 8b82634d7eae019850bb883f06abf428c58bc9aa\tfoo\n",
  5174. )
  5175. def test_recursive(self) -> None:
  5176. # Create a directory then write a dummy file in it
  5177. dirpath = os.path.join(self.repo.path, "adir")
  5178. filepath = os.path.join(dirpath, "afile")
  5179. os.mkdir(dirpath)
  5180. with open(filepath, "w") as f:
  5181. f.write("origstuff")
  5182. porcelain.add(repo=self.repo.path, paths=[filepath])
  5183. porcelain.commit(
  5184. repo=self.repo.path,
  5185. message=b"test status",
  5186. author=b"author <email>",
  5187. committer=b"committer <email>",
  5188. )
  5189. output = StringIO()
  5190. porcelain.ls_tree(self.repo, b"HEAD", outstream=output)
  5191. self.assertEqual(
  5192. output.getvalue(),
  5193. "40000 tree b145cc69a5e17693e24d8a7be0016ed8075de66d\tadir\n",
  5194. )
  5195. output2 = StringIO()
  5196. porcelain.ls_tree(self.repo, b"HEAD", outstream=output2, recursive=True)
  5197. self.assertEqual(
  5198. output2.getvalue(),
  5199. "40000 tree b145cc69a5e17693e24d8a7be0016ed8075de66d\tadir\n"
  5200. "100644 blob 8b82634d7eae019850bb883f06abf428c58bc9aa\tadir"
  5201. "/afile\n",
  5202. )
  5203. class LsRemoteTests(PorcelainTestCase):
  5204. def test_empty(self) -> None:
  5205. result = porcelain.ls_remote(self.repo.path)
  5206. self.assertEqual({}, result.refs)
  5207. self.assertEqual({}, result.symrefs)
  5208. def test_some(self) -> None:
  5209. cid = porcelain.commit(
  5210. repo=self.repo.path,
  5211. message=b"test status",
  5212. author=b"author <email>",
  5213. committer=b"committer <email>",
  5214. )
  5215. result = porcelain.ls_remote(self.repo.path)
  5216. self.assertEqual(
  5217. {b"refs/heads/master": cid, b"HEAD": cid},
  5218. result.refs,
  5219. )
  5220. # HEAD should be a symref to refs/heads/master
  5221. self.assertEqual({b"HEAD": b"refs/heads/master"}, result.symrefs)
  5222. class LsFilesTests(PorcelainTestCase):
  5223. def test_empty(self) -> None:
  5224. self.assertEqual([], list(porcelain.ls_files(self.repo)))
  5225. def test_simple(self) -> None:
  5226. # Commit a dummy file then modify it
  5227. fullpath = os.path.join(self.repo.path, "foo")
  5228. with open(fullpath, "w") as f:
  5229. f.write("origstuff")
  5230. porcelain.add(repo=self.repo.path, paths=[fullpath])
  5231. self.assertEqual([b"foo"], list(porcelain.ls_files(self.repo)))
  5232. class RemoteAddTests(PorcelainTestCase):
  5233. def test_new(self) -> None:
  5234. porcelain.remote_add(self.repo, "jelmer", "git://jelmer.uk/code/dulwich")
  5235. c = self.repo.get_config()
  5236. self.assertEqual(
  5237. c.get((b"remote", b"jelmer"), b"url"),
  5238. b"git://jelmer.uk/code/dulwich",
  5239. )
  5240. def test_exists(self) -> None:
  5241. porcelain.remote_add(self.repo, "jelmer", "git://jelmer.uk/code/dulwich")
  5242. self.assertRaises(
  5243. porcelain.RemoteExists,
  5244. porcelain.remote_add,
  5245. self.repo,
  5246. "jelmer",
  5247. "git://jelmer.uk/code/dulwich",
  5248. )
  5249. class RemoteRemoveTests(PorcelainTestCase):
  5250. def test_remove(self) -> None:
  5251. porcelain.remote_add(self.repo, "jelmer", "git://jelmer.uk/code/dulwich")
  5252. c = self.repo.get_config()
  5253. self.assertEqual(
  5254. c.get((b"remote", b"jelmer"), b"url"),
  5255. b"git://jelmer.uk/code/dulwich",
  5256. )
  5257. porcelain.remote_remove(self.repo, "jelmer")
  5258. self.assertRaises(KeyError, porcelain.remote_remove, self.repo, "jelmer")
  5259. c = self.repo.get_config()
  5260. self.assertRaises(KeyError, c.get, (b"remote", b"jelmer"), b"url")
  5261. class CheckIgnoreTests(PorcelainTestCase):
  5262. def test_check_ignored(self) -> None:
  5263. with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
  5264. f.write("foo")
  5265. foo_path = os.path.join(self.repo.path, "foo")
  5266. with open(foo_path, "w") as f:
  5267. f.write("BAR")
  5268. bar_path = os.path.join(self.repo.path, "bar")
  5269. with open(bar_path, "w") as f:
  5270. f.write("BAR")
  5271. self.assertEqual(["foo"], list(porcelain.check_ignore(self.repo, [foo_path])))
  5272. self.assertEqual([], list(porcelain.check_ignore(self.repo, [bar_path])))
  5273. def test_check_added_abs(self) -> None:
  5274. path = os.path.join(self.repo.path, "foo")
  5275. with open(path, "w") as f:
  5276. f.write("BAR")
  5277. self.repo.stage(["foo"])
  5278. with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
  5279. f.write("foo\n")
  5280. self.assertEqual([], list(porcelain.check_ignore(self.repo, [path])))
  5281. self.assertEqual(
  5282. ["foo"],
  5283. list(porcelain.check_ignore(self.repo, [path], no_index=True)),
  5284. )
  5285. def test_check_added_rel(self) -> None:
  5286. with open(os.path.join(self.repo.path, "foo"), "w") as f:
  5287. f.write("BAR")
  5288. self.repo.stage(["foo"])
  5289. with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
  5290. f.write("foo\n")
  5291. cwd = os.getcwd()
  5292. self.addCleanup(os.chdir, cwd)
  5293. os.mkdir(os.path.join(self.repo.path, "bar"))
  5294. os.chdir(os.path.join(self.repo.path, "bar"))
  5295. self.assertEqual(list(porcelain.check_ignore(self.repo, ["../foo"])), [])
  5296. self.assertEqual(
  5297. ["../foo"],
  5298. list(porcelain.check_ignore(self.repo, ["../foo"], no_index=True)),
  5299. )
  5300. class UpdateHeadTests(PorcelainTestCase):
  5301. def test_set_to_branch(self) -> None:
  5302. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  5303. self.repo.refs[b"refs/heads/blah"] = c1.id
  5304. porcelain.update_head(self.repo, "blah")
  5305. self.assertEqual(c1.id, self.repo.head())
  5306. self.assertEqual(b"ref: refs/heads/blah", self.repo.refs.read_ref(b"HEAD"))
  5307. def test_set_to_branch_detached(self) -> None:
  5308. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  5309. self.repo.refs[b"refs/heads/blah"] = c1.id
  5310. porcelain.update_head(self.repo, "blah", detached=True)
  5311. self.assertEqual(c1.id, self.repo.head())
  5312. self.assertEqual(c1.id, self.repo.refs.read_ref(b"HEAD"))
  5313. def test_set_to_commit_detached(self) -> None:
  5314. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  5315. self.repo.refs[b"refs/heads/blah"] = c1.id
  5316. porcelain.update_head(self.repo, c1.id, detached=True)
  5317. self.assertEqual(c1.id, self.repo.head())
  5318. self.assertEqual(c1.id, self.repo.refs.read_ref(b"HEAD"))
  5319. def test_set_new_branch(self) -> None:
  5320. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  5321. self.repo.refs[b"refs/heads/blah"] = c1.id
  5322. porcelain.update_head(self.repo, "blah", new_branch="bar")
  5323. self.assertEqual(c1.id, self.repo.head())
  5324. self.assertEqual(b"ref: refs/heads/bar", self.repo.refs.read_ref(b"HEAD"))
  5325. class MailmapTests(PorcelainTestCase):
  5326. def test_no_mailmap(self) -> None:
  5327. self.assertEqual(
  5328. b"Jelmer Vernooij <jelmer@samba.org>",
  5329. porcelain.check_mailmap(self.repo, b"Jelmer Vernooij <jelmer@samba.org>"),
  5330. )
  5331. def test_mailmap_lookup(self) -> None:
  5332. with open(os.path.join(self.repo.path, ".mailmap"), "wb") as f:
  5333. f.write(
  5334. b"""\
  5335. Jelmer Vernooij <jelmer@debian.org>
  5336. """
  5337. )
  5338. self.assertEqual(
  5339. b"Jelmer Vernooij <jelmer@debian.org>",
  5340. porcelain.check_mailmap(self.repo, b"Jelmer Vernooij <jelmer@samba.org>"),
  5341. )
  5342. class FsckTests(PorcelainTestCase):
  5343. def test_none(self) -> None:
  5344. self.assertEqual([], list(porcelain.fsck(self.repo)))
  5345. def test_git_dir(self) -> None:
  5346. obj = Tree()
  5347. a = Blob()
  5348. a.data = b"foo"
  5349. obj.add(b".git", 0o100644, a.id)
  5350. self.repo.object_store.add_objects([(a, None), (obj, None)])
  5351. self.assertEqual(
  5352. [(obj.id, "invalid name .git")],
  5353. [(sha, str(e)) for (sha, e) in porcelain.fsck(self.repo)],
  5354. )
  5355. class DescribeTests(PorcelainTestCase):
  5356. def test_no_commits(self) -> None:
  5357. self.assertRaises(KeyError, porcelain.describe, self.repo.path)
  5358. def test_single_commit(self) -> None:
  5359. fullpath = os.path.join(self.repo.path, "foo")
  5360. with open(fullpath, "w") as f:
  5361. f.write("BAR")
  5362. porcelain.add(repo=self.repo.path, paths=[fullpath])
  5363. sha = porcelain.commit(
  5364. self.repo.path,
  5365. message=b"Some message",
  5366. author=b"Joe <joe@example.com>",
  5367. committer=b"Bob <bob@example.com>",
  5368. )
  5369. self.assertEqual(
  5370. "g{}".format(sha[:7].decode("ascii")),
  5371. porcelain.describe(self.repo.path),
  5372. )
  5373. def test_tag(self) -> None:
  5374. fullpath = os.path.join(self.repo.path, "foo")
  5375. with open(fullpath, "w") as f:
  5376. f.write("BAR")
  5377. porcelain.add(repo=self.repo.path, paths=[fullpath])
  5378. porcelain.commit(
  5379. self.repo.path,
  5380. message=b"Some message",
  5381. author=b"Joe <joe@example.com>",
  5382. committer=b"Bob <bob@example.com>",
  5383. )
  5384. porcelain.tag_create(
  5385. self.repo.path,
  5386. b"tryme",
  5387. b"foo <foo@bar.com>",
  5388. b"bar",
  5389. annotated=True,
  5390. )
  5391. self.assertEqual("tryme", porcelain.describe(self.repo.path))
  5392. def test_tag_and_commit(self) -> None:
  5393. fullpath = os.path.join(self.repo.path, "foo")
  5394. with open(fullpath, "w") as f:
  5395. f.write("BAR")
  5396. porcelain.add(repo=self.repo.path, paths=[fullpath])
  5397. porcelain.commit(
  5398. self.repo.path,
  5399. message=b"Some message",
  5400. author=b"Joe <joe@example.com>",
  5401. committer=b"Bob <bob@example.com>",
  5402. )
  5403. porcelain.tag_create(
  5404. self.repo.path,
  5405. b"tryme",
  5406. b"foo <foo@bar.com>",
  5407. b"bar",
  5408. annotated=True,
  5409. )
  5410. with open(fullpath, "w") as f:
  5411. f.write("BAR2")
  5412. porcelain.add(repo=self.repo.path, paths=[fullpath])
  5413. sha = porcelain.commit(
  5414. self.repo.path,
  5415. message=b"Some message",
  5416. author=b"Joe <joe@example.com>",
  5417. committer=b"Bob <bob@example.com>",
  5418. )
  5419. self.assertEqual(
  5420. "tryme-1-g{}".format(sha[:7].decode("ascii")),
  5421. porcelain.describe(self.repo.path),
  5422. )
  5423. def test_tag_and_commit_full(self) -> None:
  5424. fullpath = os.path.join(self.repo.path, "foo")
  5425. with open(fullpath, "w") as f:
  5426. f.write("BAR")
  5427. porcelain.add(repo=self.repo.path, paths=[fullpath])
  5428. porcelain.commit(
  5429. self.repo.path,
  5430. message=b"Some message",
  5431. author=b"Joe <joe@example.com>",
  5432. committer=b"Bob <bob@example.com>",
  5433. )
  5434. porcelain.tag_create(
  5435. self.repo.path,
  5436. b"tryme",
  5437. b"foo <foo@bar.com>",
  5438. b"bar",
  5439. annotated=True,
  5440. )
  5441. with open(fullpath, "w") as f:
  5442. f.write("BAR2")
  5443. porcelain.add(repo=self.repo.path, paths=[fullpath])
  5444. sha = porcelain.commit(
  5445. self.repo.path,
  5446. message=b"Some message",
  5447. author=b"Joe <joe@example.com>",
  5448. committer=b"Bob <bob@example.com>",
  5449. )
  5450. self.assertEqual(
  5451. "tryme-1-g{}".format(sha.decode("ascii")),
  5452. porcelain.describe(self.repo.path, abbrev=40),
  5453. )
  5454. def test_untagged_commit_abbreviation(self) -> None:
  5455. _, _, c3 = build_commit_graph(self.repo.object_store, [[1], [2, 1], [3, 1, 2]])
  5456. self.repo.refs[b"HEAD"] = c3.id
  5457. brief_description, complete_description = (
  5458. porcelain.describe(self.repo),
  5459. porcelain.describe(self.repo, abbrev=40),
  5460. )
  5461. self.assertTrue(complete_description.startswith(brief_description))
  5462. self.assertEqual(
  5463. "g{}".format(c3.id.decode("ascii")),
  5464. complete_description,
  5465. )
  5466. def test_hash_length_dynamic(self) -> None:
  5467. """Test that hash length adjusts based on uniqueness."""
  5468. fullpath = os.path.join(self.repo.path, "foo")
  5469. with open(fullpath, "w") as f:
  5470. f.write("content")
  5471. porcelain.add(repo=self.repo.path, paths=[fullpath])
  5472. sha = porcelain.commit(
  5473. self.repo.path,
  5474. message=b"commit",
  5475. author=b"Joe <joe@example.com>",
  5476. committer=b"Bob <bob@example.com>",
  5477. )
  5478. # When abbrev is None, it should use find_unique_abbrev
  5479. result = porcelain.describe(self.repo.path)
  5480. # Should start with 'g' and have at least 7 characters
  5481. self.assertTrue(result.startswith("g"))
  5482. self.assertGreaterEqual(len(result[1:]), 7)
  5483. # Should be a prefix of the full SHA
  5484. self.assertTrue(sha.decode("ascii").startswith(result[1:]))
  5485. class PathToTreeTests(PorcelainTestCase):
  5486. def setUp(self) -> None:
  5487. super().setUp()
  5488. self.fp = os.path.join(self.test_dir, "bar")
  5489. with open(self.fp, "w") as f:
  5490. f.write("something")
  5491. oldcwd = os.getcwd()
  5492. self.addCleanup(os.chdir, oldcwd)
  5493. os.chdir(self.test_dir)
  5494. def test_path_to_tree_path_base(self) -> None:
  5495. self.assertEqual(b"bar", porcelain.path_to_tree_path(self.test_dir, self.fp))
  5496. self.assertEqual(b"bar", porcelain.path_to_tree_path(".", "./bar"))
  5497. self.assertEqual(b"bar", porcelain.path_to_tree_path(".", "bar"))
  5498. cwd = os.getcwd()
  5499. self.assertEqual(
  5500. b"bar", porcelain.path_to_tree_path(".", os.path.join(cwd, "bar"))
  5501. )
  5502. self.assertEqual(b"bar", porcelain.path_to_tree_path(cwd, "bar"))
  5503. def test_path_to_tree_path_syntax(self) -> None:
  5504. self.assertEqual(b"bar", porcelain.path_to_tree_path(".", "./bar"))
  5505. def test_path_to_tree_path_error(self) -> None:
  5506. with self.assertRaises(ValueError):
  5507. with tempfile.TemporaryDirectory() as od:
  5508. porcelain.path_to_tree_path(od, self.fp)
  5509. def test_path_to_tree_path_rel(self) -> None:
  5510. cwd = os.getcwd()
  5511. self.addCleanup(os.chdir, cwd)
  5512. os.mkdir(os.path.join(self.repo.path, "foo"))
  5513. os.mkdir(os.path.join(self.repo.path, "foo/bar"))
  5514. os.chdir(os.path.join(self.repo.path, "foo/bar"))
  5515. with open("baz", "w") as f:
  5516. f.write("contents")
  5517. self.assertEqual(b"bar/baz", porcelain.path_to_tree_path("..", "baz"))
  5518. self.assertEqual(
  5519. b"bar/baz",
  5520. porcelain.path_to_tree_path(
  5521. os.path.join(os.getcwd(), ".."),
  5522. os.path.join(os.getcwd(), "baz"),
  5523. ),
  5524. )
  5525. self.assertEqual(
  5526. b"bar/baz",
  5527. porcelain.path_to_tree_path("..", os.path.join(os.getcwd(), "baz")),
  5528. )
  5529. self.assertEqual(
  5530. b"bar/baz",
  5531. porcelain.path_to_tree_path(os.path.join(os.getcwd(), ".."), "baz"),
  5532. )
  5533. class GetObjectByPathTests(PorcelainTestCase):
  5534. def test_simple(self) -> None:
  5535. fullpath = os.path.join(self.repo.path, "foo")
  5536. with open(fullpath, "w") as f:
  5537. f.write("BAR")
  5538. porcelain.add(repo=self.repo.path, paths=[fullpath])
  5539. porcelain.commit(
  5540. self.repo.path,
  5541. message=b"Some message",
  5542. author=b"Joe <joe@example.com>",
  5543. committer=b"Bob <bob@example.com>",
  5544. )
  5545. self.assertEqual(b"BAR", porcelain.get_object_by_path(self.repo, "foo").data)
  5546. self.assertEqual(b"BAR", porcelain.get_object_by_path(self.repo, b"foo").data)
  5547. def test_encoding(self) -> None:
  5548. fullpath = os.path.join(self.repo.path, "foo")
  5549. with open(fullpath, "w") as f:
  5550. f.write("BAR")
  5551. porcelain.add(repo=self.repo.path, paths=[fullpath])
  5552. porcelain.commit(
  5553. self.repo.path,
  5554. message=b"Some message",
  5555. author=b"Joe <joe@example.com>",
  5556. committer=b"Bob <bob@example.com>",
  5557. encoding=b"utf-8",
  5558. )
  5559. self.assertEqual(b"BAR", porcelain.get_object_by_path(self.repo, "foo").data)
  5560. self.assertEqual(b"BAR", porcelain.get_object_by_path(self.repo, b"foo").data)
  5561. def test_missing(self) -> None:
  5562. self.assertRaises(KeyError, porcelain.get_object_by_path, self.repo, "foo")
  5563. class WriteTreeTests(PorcelainTestCase):
  5564. def test_simple(self) -> None:
  5565. fullpath = os.path.join(self.repo.path, "foo")
  5566. with open(fullpath, "w") as f:
  5567. f.write("BAR")
  5568. porcelain.add(repo=self.repo.path, paths=[fullpath])
  5569. self.assertEqual(
  5570. b"d2092c8a9f311f0311083bf8d177f2ca0ab5b241",
  5571. porcelain.write_tree(self.repo),
  5572. )
  5573. class ActiveBranchTests(PorcelainTestCase):
  5574. def test_simple(self) -> None:
  5575. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  5576. class BranchTrackingTests(PorcelainTestCase):
  5577. def test_get_branch_merge(self) -> None:
  5578. # Set up branch tracking configuration
  5579. config = self.repo.get_config()
  5580. config.set((b"branch", b"master"), b"remote", b"origin")
  5581. config.set((b"branch", b"master"), b"merge", b"refs/heads/main")
  5582. config.write_to_path()
  5583. # Test getting merge ref for current branch
  5584. merge_ref = porcelain.get_branch_merge(self.repo)
  5585. self.assertEqual(b"refs/heads/main", merge_ref)
  5586. # Test getting merge ref for specific branch
  5587. merge_ref = porcelain.get_branch_merge(self.repo, b"master")
  5588. self.assertEqual(b"refs/heads/main", merge_ref)
  5589. # Test branch without merge config
  5590. with self.assertRaises(KeyError):
  5591. porcelain.get_branch_merge(self.repo, b"nonexistent")
  5592. def test_set_branch_tracking(self) -> None:
  5593. # Create a new branch
  5594. sha, _ = _commit_file_with_content(self.repo, "foo", "content\n")
  5595. porcelain.branch_create(self.repo, "feature")
  5596. # Set up tracking
  5597. porcelain.set_branch_tracking(
  5598. self.repo, b"feature", b"upstream", b"refs/heads/main"
  5599. )
  5600. # Verify configuration was written
  5601. config = self.repo.get_config()
  5602. self.assertEqual(b"upstream", config.get((b"branch", b"feature"), b"remote"))
  5603. self.assertEqual(
  5604. b"refs/heads/main", config.get((b"branch", b"feature"), b"merge")
  5605. )
  5606. class FindUniqueAbbrevTests(PorcelainTestCase):
  5607. def test_simple(self) -> None:
  5608. c1, c2, c3 = build_commit_graph(
  5609. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  5610. )
  5611. self.repo.refs[b"HEAD"] = c3.id
  5612. self.assertEqual(
  5613. c1.id.decode("ascii")[:7],
  5614. porcelain.find_unique_abbrev(self.repo.object_store, c1.id),
  5615. )
  5616. class PackRefsTests(PorcelainTestCase):
  5617. def test_all(self) -> None:
  5618. c1, c2, c3 = build_commit_graph(
  5619. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  5620. )
  5621. self.repo.refs[b"HEAD"] = c3.id
  5622. self.repo.refs[b"refs/heads/master"] = c2.id
  5623. self.repo.refs[b"refs/tags/foo"] = c1.id
  5624. porcelain.pack_refs(self.repo, all=True)
  5625. self.assertEqual(
  5626. self.repo.refs.get_packed_refs(),
  5627. {
  5628. b"refs/heads/master": c2.id,
  5629. b"refs/tags/foo": c1.id,
  5630. },
  5631. )
  5632. def test_not_all(self) -> None:
  5633. c1, c2, c3 = build_commit_graph(
  5634. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  5635. )
  5636. self.repo.refs[b"HEAD"] = c3.id
  5637. self.repo.refs[b"refs/heads/master"] = c2.id
  5638. self.repo.refs[b"refs/tags/foo"] = c1.id
  5639. porcelain.pack_refs(self.repo)
  5640. self.assertEqual(
  5641. self.repo.refs.get_packed_refs(),
  5642. {
  5643. b"refs/tags/foo": c1.id,
  5644. },
  5645. )
  5646. class ServerTests(PorcelainTestCase):
  5647. @contextlib.contextmanager
  5648. def _serving(self):
  5649. with make_server("localhost", 0, self.app) as server:
  5650. thread = threading.Thread(target=server.serve_forever, daemon=True)
  5651. thread.start()
  5652. try:
  5653. yield f"http://localhost:{server.server_port}"
  5654. finally:
  5655. server.shutdown()
  5656. thread.join(10)
  5657. def setUp(self) -> None:
  5658. super().setUp()
  5659. self.served_repo_path = os.path.join(self.test_dir, "served_repo.git")
  5660. self.served_repo = Repo.init_bare(self.served_repo_path, mkdir=True)
  5661. self.addCleanup(self.served_repo.close)
  5662. backend = DictBackend({"/": self.served_repo})
  5663. self.app = make_wsgi_chain(backend)
  5664. def test_pull(self) -> None:
  5665. (c1,) = build_commit_graph(self.served_repo.object_store, [[1]])
  5666. self.served_repo.refs[b"refs/heads/master"] = c1.id
  5667. with self._serving() as url:
  5668. porcelain.pull(self.repo, url, "master")
  5669. def test_push(self) -> None:
  5670. (c1,) = build_commit_graph(self.repo.object_store, [[1]])
  5671. self.repo.refs[b"refs/heads/master"] = c1.id
  5672. with self._serving() as url:
  5673. porcelain.push(self.repo, url, "master")
  5674. class ForEachTests(PorcelainTestCase):
  5675. def setUp(self) -> None:
  5676. super().setUp()
  5677. c1, c2, c3, c4 = build_commit_graph(
  5678. self.repo.object_store, [[1], [2, 1], [3, 1, 2], [4]]
  5679. )
  5680. porcelain.tag_create(
  5681. self.repo.path,
  5682. b"v0.1",
  5683. objectish=c1.id,
  5684. annotated=True,
  5685. message=b"0.1",
  5686. )
  5687. porcelain.tag_create(
  5688. self.repo.path,
  5689. b"v1.0",
  5690. objectish=c2.id,
  5691. annotated=True,
  5692. message=b"1.0",
  5693. )
  5694. porcelain.tag_create(self.repo.path, b"simple-tag", objectish=c3.id)
  5695. porcelain.tag_create(
  5696. self.repo.path,
  5697. b"v1.1",
  5698. objectish=c4.id,
  5699. annotated=True,
  5700. message=b"1.1",
  5701. )
  5702. porcelain.branch_create(
  5703. self.repo.path, b"feat", objectish=c2.id.decode("ascii")
  5704. )
  5705. self.repo.refs[b"HEAD"] = c4.id
  5706. def test_for_each_ref(self) -> None:
  5707. refs = porcelain.for_each_ref(self.repo)
  5708. self.assertEqual(
  5709. [(object_type, tag) for _, object_type, tag in refs],
  5710. [
  5711. (b"commit", b"refs/heads/feat"),
  5712. (b"commit", b"refs/heads/master"),
  5713. (b"commit", b"refs/tags/simple-tag"),
  5714. (b"tag", b"refs/tags/v0.1"),
  5715. (b"tag", b"refs/tags/v1.0"),
  5716. (b"tag", b"refs/tags/v1.1"),
  5717. ],
  5718. )
  5719. def test_for_each_ref_pattern(self) -> None:
  5720. versions = porcelain.for_each_ref(self.repo, pattern="refs/tags/v*")
  5721. self.assertEqual(
  5722. [(object_type, tag) for _, object_type, tag in versions],
  5723. [
  5724. (b"tag", b"refs/tags/v0.1"),
  5725. (b"tag", b"refs/tags/v1.0"),
  5726. (b"tag", b"refs/tags/v1.1"),
  5727. ],
  5728. )
  5729. versions = porcelain.for_each_ref(self.repo, pattern="refs/tags/v1.?")
  5730. self.assertEqual(
  5731. [(object_type, tag) for _, object_type, tag in versions],
  5732. [
  5733. (b"tag", b"refs/tags/v1.0"),
  5734. (b"tag", b"refs/tags/v1.1"),
  5735. ],
  5736. )
  5737. class SparseCheckoutTests(PorcelainTestCase):
  5738. """Integration tests for Dulwich's sparse checkout feature."""
  5739. # NOTE: We do NOT override `setUp()` here because the parent class
  5740. # (PorcelainTestCase) already:
  5741. # 1) Creates self.test_dir = a unique temp dir
  5742. # 2) Creates a subdir named "repo"
  5743. # 3) Calls Repo.init() on that path
  5744. # Re-initializing again caused FileExistsError.
  5745. #
  5746. # Utility/Placeholder
  5747. #
  5748. def sparse_checkout(self, repo, patterns, force=False):
  5749. """Wrapper around the actual porcelain.sparse_checkout function
  5750. to handle any test-specific setup or logging.
  5751. """
  5752. return porcelain.sparse_checkout(repo, patterns, force=force)
  5753. def _write_file(self, rel_path, content):
  5754. """Helper to write a file in the repository working tree."""
  5755. abs_path = os.path.join(self.repo_path, rel_path)
  5756. os.makedirs(os.path.dirname(abs_path), exist_ok=True)
  5757. with open(abs_path, "w") as f:
  5758. f.write(content)
  5759. return abs_path
  5760. def _commit_file(self, rel_path, content):
  5761. """Helper to write, add, and commit a file."""
  5762. abs_path = self._write_file(rel_path, content)
  5763. add(self.repo_path, paths=[abs_path])
  5764. commit(self.repo_path, message=b"Added " + rel_path.encode("utf-8"))
  5765. def _list_wtree_files(self):
  5766. """Return a set of all files (not dirs) present
  5767. in the working tree, ignoring .git/.
  5768. """
  5769. found_files = set()
  5770. for root, dirs, files in os.walk(self.repo_path):
  5771. # Skip .git in the walk
  5772. if ".git" in dirs:
  5773. dirs.remove(".git")
  5774. for filename in files:
  5775. file_rel = os.path.relpath(os.path.join(root, filename), self.repo_path)
  5776. found_files.add(file_rel)
  5777. return found_files
  5778. def test_only_included_paths_appear_in_wtree(self):
  5779. """Only included paths remain in the working tree, excluded paths are removed.
  5780. Commits two files, "keep_me.txt" and "exclude_me.txt". Then applies a
  5781. sparse-checkout pattern containing only "keep_me.txt". Ensures that
  5782. the latter remains in the working tree, while "exclude_me.txt" is
  5783. removed. This verifies correct application of sparse-checkout patterns
  5784. to remove files not listed.
  5785. """
  5786. self._commit_file("keep_me.txt", "I'll stay\n")
  5787. self._commit_file("exclude_me.txt", "I'll be excluded\n")
  5788. patterns = ["keep_me.txt"]
  5789. self.sparse_checkout(self.repo, patterns)
  5790. actual_files = self._list_wtree_files()
  5791. expected_files = {"keep_me.txt"}
  5792. self.assertEqual(
  5793. expected_files,
  5794. actual_files,
  5795. f"Expected only {expected_files}, but found {actual_files}",
  5796. )
  5797. def test_previously_included_paths_become_excluded(self):
  5798. """Previously included files become excluded after pattern changes.
  5799. Verifies that files initially brought into the working tree (e.g.,
  5800. by including `data/`) can later be excluded by narrowing the
  5801. sparse-checkout pattern to just `data/included_1.txt`. Confirms that
  5802. the file `data/included_2.txt` remains in the index with
  5803. skip-worktree set (rather than being removed entirely), ensuring
  5804. data is not lost and Dulwich correctly updates the index flags.
  5805. """
  5806. self._commit_file("data/included_1.txt", "some content\n")
  5807. self._commit_file("data/included_2.txt", "other content\n")
  5808. initial_patterns = ["data/"]
  5809. self.sparse_checkout(self.repo, initial_patterns)
  5810. updated_patterns = ["data/included_1.txt"]
  5811. self.sparse_checkout(self.repo, updated_patterns)
  5812. actual_files = self._list_wtree_files()
  5813. expected_files = {os.path.join("data", "included_1.txt")}
  5814. self.assertEqual(expected_files, actual_files)
  5815. idx = self.repo.open_index()
  5816. self.assertIn(b"data/included_2.txt", idx)
  5817. entry = idx[b"data/included_2.txt"]
  5818. self.assertTrue(entry.skip_worktree)
  5819. def test_force_removes_local_changes_for_excluded_paths(self):
  5820. """Forced sparse checkout removes local modifications for newly excluded paths.
  5821. Verifies that specifying force=True allows destructive operations
  5822. which discard uncommitted changes. First, we commit "file1.txt" and
  5823. then modify it. Next, we apply a pattern that excludes the file,
  5824. using force=True. The local modifications (and the file) should
  5825. be removed, leaving the working tree empty.
  5826. """
  5827. self._commit_file("file1.txt", "original content\n")
  5828. file1_path = os.path.join(self.repo_path, "file1.txt")
  5829. with open(file1_path, "a") as f:
  5830. f.write("local changes!\n")
  5831. new_patterns = ["some_other_file.txt"]
  5832. self.sparse_checkout(self.repo, new_patterns, force=True)
  5833. actual_files = self._list_wtree_files()
  5834. self.assertEqual(
  5835. set(),
  5836. actual_files,
  5837. "Force-sparse-checkout did not remove file with local changes.",
  5838. )
  5839. def test_destructive_refuse_uncommitted_changes_without_force(self):
  5840. """Fail on uncommitted changes for newly excluded paths without force.
  5841. Ensures that a sparse checkout is blocked if it would remove local
  5842. modifications from the working tree. We commit 'config.yaml', then
  5843. modify it, and finally attempt to exclude it via new patterns without
  5844. using force=True. This should raise a CheckoutError rather than
  5845. discarding the local changes.
  5846. """
  5847. self._commit_file("config.yaml", "initial\n")
  5848. cfg_path = os.path.join(self.repo_path, "config.yaml")
  5849. with open(cfg_path, "a") as f:
  5850. f.write("local modifications\n")
  5851. exclude_patterns = ["docs/"]
  5852. with self.assertRaises(CheckoutError):
  5853. self.sparse_checkout(self.repo, exclude_patterns, force=False)
  5854. def test_fnmatch_gitignore_pattern_expansion(self):
  5855. """Reading/writing patterns align with gitignore/fnmatch expansions.
  5856. Ensures that `sparse_checkout` interprets wildcard patterns (like `*.py`)
  5857. in the same way Git's sparse-checkout would. Multiple files are committed
  5858. to `src/` (e.g. `foo.py`, `foo_test.py`, `foo_helper.py`) and to `docs/`.
  5859. Then the pattern `src/foo*.py` is applied, confirming that only the
  5860. matching Python files remain in the working tree while the Markdown file
  5861. under `docs/` is excluded.
  5862. Finally, verifies that the `.git/info/sparse-checkout` file contains the
  5863. specified wildcard pattern (`src/foo*.py`), ensuring correct round-trip
  5864. of user-supplied patterns.
  5865. """
  5866. self._commit_file("src/foo.py", "print('hello')\n")
  5867. self._commit_file("src/foo_test.py", "print('test')\n")
  5868. self._commit_file("docs/readme.md", "# docs\n")
  5869. self._commit_file("src/foo_helper.py", "print('helper')\n")
  5870. patterns = ["src/foo*.py"]
  5871. self.sparse_checkout(self.repo, patterns)
  5872. actual_files = self._list_wtree_files()
  5873. expected_files = {
  5874. os.path.join("src", "foo.py"),
  5875. os.path.join("src", "foo_test.py"),
  5876. os.path.join("src", "foo_helper.py"),
  5877. }
  5878. self.assertEqual(
  5879. expected_files,
  5880. actual_files,
  5881. "Wildcard pattern not matched as expected. Either too strict or too broad.",
  5882. )
  5883. sc_file = os.path.join(self.repo_path, ".git", "info", "sparse-checkout")
  5884. self.assertTrue(os.path.isfile(sc_file))
  5885. with open(sc_file) as f:
  5886. lines = f.read().strip().split()
  5887. self.assertIn("src/foo*.py", lines)
  5888. class ConeModeTests(PorcelainTestCase):
  5889. """Provide integration tests for Dulwich's cone mode sparse checkout.
  5890. This test suite verifies the expected behavior for:
  5891. * cone_mode_init
  5892. * cone_mode_set
  5893. * cone_mode_add
  5894. Although Dulwich does not yet implement cone mode, these tests are
  5895. prepared in advance to guide future development.
  5896. """
  5897. def setUp(self):
  5898. """Set up a fresh repository for each test.
  5899. This method creates a new empty repo_path and Repo object
  5900. as provided by the PorcelainTestCase base class.
  5901. """
  5902. super().setUp()
  5903. def _commit_file(self, rel_path, content=b"contents"):
  5904. """Add a file at the given relative path and commit it.
  5905. Creates necessary directories, writes the file content,
  5906. stages, and commits. The commit message and author/committer
  5907. are also provided.
  5908. """
  5909. full_path = os.path.join(self.repo_path, rel_path)
  5910. os.makedirs(os.path.dirname(full_path), exist_ok=True)
  5911. with open(full_path, "wb") as f:
  5912. f.write(content)
  5913. porcelain.add(self.repo_path, paths=[full_path])
  5914. porcelain.commit(
  5915. self.repo_path,
  5916. message=b"Adding " + rel_path.encode("utf-8"),
  5917. author=b"Test Author <author@example.com>",
  5918. committer=b"Test Committer <committer@example.com>",
  5919. )
  5920. def _list_wtree_files(self):
  5921. """Return a set of all file paths relative to the repository root.
  5922. Walks the working tree, skipping the .git directory.
  5923. """
  5924. found_files = set()
  5925. for root, dirs, files in os.walk(self.repo_path):
  5926. if ".git" in dirs:
  5927. dirs.remove(".git")
  5928. for fn in files:
  5929. relp = os.path.relpath(os.path.join(root, fn), self.repo_path)
  5930. found_files.add(relp)
  5931. return found_files
  5932. def test_init_excludes_everything(self):
  5933. """Verify that cone_mode_init writes minimal patterns and empties the working tree.
  5934. Make some dummy files, commit them, then call cone_mode_init. Confirm
  5935. that the working tree is empty, the sparse-checkout file has the
  5936. minimal patterns (/*, !/*/), and the relevant config values are set.
  5937. """
  5938. self._commit_file("docs/readme.md", b"# doc\n")
  5939. self._commit_file("src/main.py", b"print('hello')\n")
  5940. porcelain.cone_mode_init(self.repo)
  5941. actual_files = self._list_wtree_files()
  5942. self.assertEqual(
  5943. set(),
  5944. actual_files,
  5945. "cone_mode_init did not exclude all files from the working tree.",
  5946. )
  5947. sp_path = os.path.join(self.repo_path, ".git", "info", "sparse-checkout")
  5948. with open(sp_path) as f:
  5949. lines = [ln.strip() for ln in f if ln.strip()]
  5950. self.assertIn("/*", lines)
  5951. self.assertIn("!/*/", lines)
  5952. config = self.repo.get_config()
  5953. self.assertEqual(config.get((b"core",), b"sparseCheckout"), b"true")
  5954. self.assertEqual(config.get((b"core",), b"sparseCheckoutCone"), b"true")
  5955. def test_set_specific_dirs(self):
  5956. """Verify that cone_mode_set overwrites the included directories to only the specified ones.
  5957. Initializes cone mode, commits some files, then calls cone_mode_set with
  5958. a list of directories. Expects that only those directories remain in the
  5959. working tree.
  5960. """
  5961. porcelain.cone_mode_init(self.repo)
  5962. self._commit_file("docs/readme.md", b"# doc\n")
  5963. self._commit_file("src/main.py", b"print('hello')\n")
  5964. self._commit_file("tests/test_foo.py", b"# tests\n")
  5965. # Everything is still excluded initially by init.
  5966. porcelain.cone_mode_set(self.repo, dirs=["docs", "src"])
  5967. actual_files = self._list_wtree_files()
  5968. expected_files = {
  5969. os.path.join("docs", "readme.md"),
  5970. os.path.join("src", "main.py"),
  5971. }
  5972. self.assertEqual(
  5973. expected_files,
  5974. actual_files,
  5975. "Did not see only the 'docs/' and 'src/' dirs in the working tree.",
  5976. )
  5977. sp_path = os.path.join(self.repo_path, ".git", "info", "sparse-checkout")
  5978. with open(sp_path) as f:
  5979. lines = [ln.strip() for ln in f if ln.strip()]
  5980. # For standard cone mode, we'd expect lines like:
  5981. # /* (include top-level files)
  5982. # !/*/ (exclude subdirectories)
  5983. # !/docs/ (re-include docs)
  5984. # !/src/ (re-include src)
  5985. # Instead of the wildcard-based lines the old test used.
  5986. self.assertIn("/*", lines)
  5987. self.assertIn("!/*/", lines)
  5988. self.assertIn("/docs/", lines)
  5989. self.assertIn("/src/", lines)
  5990. self.assertNotIn("/tests/", lines)
  5991. def test_set_overwrites_old_dirs(self):
  5992. """Ensure that calling cone_mode_set again overwrites old includes.
  5993. Initializes cone mode, includes two directories, then calls
  5994. cone_mode_set again with a different directory to confirm the
  5995. new set of includes replaces the old.
  5996. """
  5997. porcelain.cone_mode_init(self.repo)
  5998. self._commit_file("docs/readme.md")
  5999. self._commit_file("src/main.py")
  6000. self._commit_file("tests/test_bar.py")
  6001. porcelain.cone_mode_set(self.repo, dirs=["docs", "src"])
  6002. self.assertEqual(
  6003. {os.path.join("docs", "readme.md"), os.path.join("src", "main.py")},
  6004. self._list_wtree_files(),
  6005. )
  6006. # Overwrite includes, now only 'tests'
  6007. porcelain.cone_mode_set(self.repo, dirs=["tests"], force=True)
  6008. actual_files = self._list_wtree_files()
  6009. expected_files = {os.path.join("tests", "test_bar.py")}
  6010. self.assertEqual(expected_files, actual_files)
  6011. def test_force_removal_of_local_mods(self):
  6012. """Confirm that force=True removes local changes in excluded paths.
  6013. cone_mode_init and cone_mode_set are called, a file is locally modified,
  6014. and then cone_mode_set is called again with force=True to exclude that path.
  6015. The excluded file should be removed with no CheckoutError.
  6016. """
  6017. porcelain.cone_mode_init(self.repo)
  6018. porcelain.cone_mode_set(self.repo, dirs=["docs"])
  6019. self._commit_file("docs/readme.md", b"Docs stuff\n")
  6020. self._commit_file("src/main.py", b"print('hello')\n")
  6021. # Modify src/main.py
  6022. with open(os.path.join(self.repo_path, "src/main.py"), "ab") as f:
  6023. f.write(b"extra line\n")
  6024. # Exclude src/ with force=True
  6025. porcelain.cone_mode_set(self.repo, dirs=["docs"], force=True)
  6026. actual_files = self._list_wtree_files()
  6027. expected_files = {os.path.join("docs", "readme.md")}
  6028. self.assertEqual(expected_files, actual_files)
  6029. def test_add_and_merge_dirs(self):
  6030. """Verify that cone_mode_add merges new directories instead of overwriting them.
  6031. After initializing cone mode and including a single directory, call
  6032. cone_mode_add with a new directory. Confirm that both directories
  6033. remain included. Repeat for an additional directory to ensure it
  6034. is merged, not overwritten.
  6035. """
  6036. porcelain.cone_mode_init(self.repo)
  6037. self._commit_file("docs/readme.md", b"# doc\n")
  6038. self._commit_file("src/main.py", b"print('hello')\n")
  6039. self._commit_file("tests/test_bar.py", b"# tests\n")
  6040. # Include "docs" only
  6041. porcelain.cone_mode_set(self.repo, dirs=["docs"])
  6042. self.assertEqual({os.path.join("docs", "readme.md")}, self._list_wtree_files())
  6043. # Add "src"
  6044. porcelain.cone_mode_add(self.repo, dirs=["src"])
  6045. actual_files = self._list_wtree_files()
  6046. self.assertEqual(
  6047. {os.path.join("docs", "readme.md"), os.path.join("src", "main.py")},
  6048. actual_files,
  6049. )
  6050. # Add "tests" as well
  6051. porcelain.cone_mode_add(self.repo, dirs=["tests"])
  6052. actual_files = self._list_wtree_files()
  6053. expected_files = {
  6054. os.path.join("docs", "readme.md"),
  6055. os.path.join("src", "main.py"),
  6056. os.path.join("tests", "test_bar.py"),
  6057. }
  6058. self.assertEqual(expected_files, actual_files)
  6059. # Check .git/info/sparse-checkout
  6060. sp_path = os.path.join(self.repo_path, ".git", "info", "sparse-checkout")
  6061. with open(sp_path) as f:
  6062. lines = [ln.strip() for ln in f if ln.strip()]
  6063. # Standard cone mode lines:
  6064. # "/*" -> include top-level
  6065. # "!/*/" -> exclude subdirectories
  6066. # "!/docs/", "!/src/", "!/tests/" -> re-include the directories we added
  6067. self.assertIn("/*", lines)
  6068. self.assertIn("!/*/", lines)
  6069. self.assertIn("/docs/", lines)
  6070. self.assertIn("/src/", lines)
  6071. self.assertIn("/tests/", lines)
  6072. class UnpackObjectsTest(PorcelainTestCase):
  6073. def test_unpack_objects(self):
  6074. """Test unpacking objects from a pack file."""
  6075. # Create a test repository with some objects
  6076. b1 = Blob()
  6077. b1.data = b"test content 1"
  6078. b2 = Blob()
  6079. b2.data = b"test content 2"
  6080. # Add objects to the repo
  6081. self.repo.object_store.add_object(b1)
  6082. self.repo.object_store.add_object(b2)
  6083. # Create a pack file with these objects
  6084. pack_path = os.path.join(self.test_dir, "test_pack")
  6085. with (
  6086. open(pack_path + ".pack", "wb") as pack_f,
  6087. open(pack_path + ".idx", "wb") as idx_f,
  6088. ):
  6089. porcelain.pack_objects(
  6090. self.repo,
  6091. [b1.id, b2.id],
  6092. pack_f,
  6093. idx_f,
  6094. )
  6095. # Create a new repository to unpack into
  6096. target_repo_path = os.path.join(self.test_dir, "target_repo")
  6097. target_repo = Repo.init(target_repo_path, mkdir=True)
  6098. self.addCleanup(target_repo.close)
  6099. # Unpack the objects
  6100. count = porcelain.unpack_objects(pack_path + ".pack", target_repo_path)
  6101. # Verify the objects were unpacked
  6102. self.assertEqual(2, count)
  6103. self.assertIn(b1.id, target_repo.object_store)
  6104. self.assertIn(b2.id, target_repo.object_store)
  6105. # Verify the content is correct
  6106. unpacked_b1 = target_repo.object_store[b1.id]
  6107. unpacked_b2 = target_repo.object_store[b2.id]
  6108. self.assertEqual(b1.data, unpacked_b1.data)
  6109. self.assertEqual(b2.data, unpacked_b2.data)
  6110. class CountObjectsTests(PorcelainTestCase):
  6111. def test_count_objects_empty_repo(self):
  6112. """Test counting objects in an empty repository."""
  6113. stats = porcelain.count_objects(self.repo)
  6114. self.assertEqual(0, stats.count)
  6115. self.assertEqual(0, stats.size)
  6116. def test_count_objects_verbose_empty_repo(self):
  6117. """Test verbose counting in an empty repository."""
  6118. stats = porcelain.count_objects(self.repo, verbose=True)
  6119. self.assertEqual(0, stats.count)
  6120. self.assertEqual(0, stats.size)
  6121. self.assertEqual(0, stats.in_pack)
  6122. self.assertEqual(0, stats.packs)
  6123. self.assertEqual(0, stats.size_pack)
  6124. def test_count_objects_with_loose_objects(self):
  6125. """Test counting loose objects."""
  6126. # Create some loose objects
  6127. blob1 = make_object(Blob, data=b"data1")
  6128. blob2 = make_object(Blob, data=b"data2")
  6129. self.repo.object_store.add_object(blob1)
  6130. self.repo.object_store.add_object(blob2)
  6131. stats = porcelain.count_objects(self.repo)
  6132. self.assertEqual(2, stats.count)
  6133. self.assertGreater(stats.size, 0)
  6134. def test_count_objects_verbose_with_objects(self):
  6135. """Test verbose counting with both loose and packed objects."""
  6136. # Add some loose objects
  6137. for i in range(3):
  6138. blob = make_object(Blob, data=f"data{i}".encode())
  6139. self.repo.object_store.add_object(blob)
  6140. # Create a simple commit to have some objects in a pack
  6141. tree = Tree()
  6142. c1 = make_commit(tree=tree.id, message=b"Test commit")
  6143. self.repo.object_store.add_objects([(tree, None), (c1, None)])
  6144. self.repo.refs[b"HEAD"] = c1.id
  6145. # Repack to create a pack file
  6146. porcelain.repack(self.repo)
  6147. stats = porcelain.count_objects(self.repo, verbose=True)
  6148. # After repacking, loose objects might be cleaned up
  6149. self.assertIsInstance(stats.count, int)
  6150. self.assertIsInstance(stats.size, int)
  6151. self.assertGreater(stats.in_pack, 0) # Should have packed objects
  6152. self.assertGreater(stats.packs, 0) # Should have at least one pack
  6153. self.assertGreater(stats.size_pack, 0) # Pack should have size
  6154. # Verify it's the correct dataclass type
  6155. self.assertIsInstance(stats, CountObjectsResult)
  6156. class PruneTests(PorcelainTestCase):
  6157. def test_prune_removes_old_tempfiles(self):
  6158. """Test that prune removes old temporary files."""
  6159. # Create an old temporary file in the objects directory
  6160. objects_dir = os.path.join(self.repo.path, ".git", "objects")
  6161. tmp_pack_path = os.path.join(objects_dir, "tmp_pack_test")
  6162. with open(tmp_pack_path, "wb") as f:
  6163. f.write(b"old temporary data")
  6164. # Make it old
  6165. old_time = time.time() - (DEFAULT_TEMPFILE_GRACE_PERIOD + 3600)
  6166. os.utime(tmp_pack_path, (old_time, old_time))
  6167. # Run prune
  6168. porcelain.prune(self.repo.path)
  6169. # Verify the file was removed
  6170. self.assertFalse(os.path.exists(tmp_pack_path))
  6171. def test_prune_keeps_recent_tempfiles(self):
  6172. """Test that prune keeps recent temporary files."""
  6173. # Create a recent temporary file
  6174. objects_dir = os.path.join(self.repo.path, ".git", "objects")
  6175. tmp_pack_path = os.path.join(objects_dir, "tmp_pack_recent")
  6176. with open(tmp_pack_path, "wb") as f:
  6177. f.write(b"recent temporary data")
  6178. self.addCleanup(os.remove, tmp_pack_path)
  6179. # Run prune
  6180. porcelain.prune(self.repo.path)
  6181. # Verify the file was NOT removed
  6182. self.assertTrue(os.path.exists(tmp_pack_path))
  6183. def test_prune_with_custom_grace_period(self):
  6184. """Test prune with custom grace period."""
  6185. # Create a 1-hour-old temporary file
  6186. objects_dir = os.path.join(self.repo.path, ".git", "objects")
  6187. tmp_pack_path = os.path.join(objects_dir, "tmp_pack_1hour")
  6188. with open(tmp_pack_path, "wb") as f:
  6189. f.write(b"1 hour old data")
  6190. # Make it 1 hour old
  6191. old_time = time.time() - 3600
  6192. os.utime(tmp_pack_path, (old_time, old_time))
  6193. # Prune with 30-minute grace period should remove it
  6194. porcelain.prune(self.repo.path, grace_period=1800)
  6195. # Verify the file was removed
  6196. self.assertFalse(os.path.exists(tmp_pack_path))
  6197. def test_prune_dry_run(self):
  6198. """Test prune in dry-run mode."""
  6199. # Create an old temporary file
  6200. objects_dir = os.path.join(self.repo.path, ".git", "objects")
  6201. tmp_pack_path = os.path.join(objects_dir, "tmp_pack_dryrun")
  6202. with open(tmp_pack_path, "wb") as f:
  6203. f.write(b"old temporary data")
  6204. self.addCleanup(os.remove, tmp_pack_path)
  6205. # Make it old
  6206. old_time = time.time() - (DEFAULT_TEMPFILE_GRACE_PERIOD + 3600)
  6207. os.utime(tmp_pack_path, (old_time, old_time))
  6208. # Run prune in dry-run mode
  6209. porcelain.prune(self.repo.path, dry_run=True)
  6210. # Verify the file was NOT removed (dry run)
  6211. self.assertTrue(os.path.exists(tmp_pack_path))
  6212. class FilterBranchTests(PorcelainTestCase):
  6213. def setUp(self):
  6214. super().setUp()
  6215. # Create initial commits with different authors
  6216. from dulwich.objects import Commit, Tree
  6217. # Create actual tree and blob objects
  6218. tree = Tree()
  6219. self.repo.object_store.add_object(tree)
  6220. c1 = Commit()
  6221. c1.tree = tree.id
  6222. c1.parents = []
  6223. c1.author = b"Old Author <old@example.com>"
  6224. c1.author_time = 1000
  6225. c1.author_timezone = 0
  6226. c1.committer = b"Old Committer <old@example.com>"
  6227. c1.commit_time = 1000
  6228. c1.commit_timezone = 0
  6229. c1.message = b"Initial commit"
  6230. self.repo.object_store.add_object(c1)
  6231. c2 = Commit()
  6232. c2.tree = tree.id
  6233. c2.parents = [c1.id]
  6234. c2.author = b"Another Author <another@example.com>"
  6235. c2.author_time = 2000
  6236. c2.author_timezone = 0
  6237. c2.committer = b"Another Committer <another@example.com>"
  6238. c2.commit_time = 2000
  6239. c2.commit_timezone = 0
  6240. c2.message = b"Second commit\n\nWith body"
  6241. self.repo.object_store.add_object(c2)
  6242. c3 = Commit()
  6243. c3.tree = tree.id
  6244. c3.parents = [c2.id]
  6245. c3.author = b"Third Author <third@example.com>"
  6246. c3.author_time = 3000
  6247. c3.author_timezone = 0
  6248. c3.committer = b"Third Committer <third@example.com>"
  6249. c3.commit_time = 3000
  6250. c3.commit_timezone = 0
  6251. c3.message = b"Third commit"
  6252. self.repo.object_store.add_object(c3)
  6253. self.repo.refs[b"refs/heads/master"] = c3.id
  6254. self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/master")
  6255. # Store IDs for test assertions
  6256. self.c1_id = c1.id
  6257. self.c2_id = c2.id
  6258. self.c3_id = c3.id
  6259. def test_filter_branch_author(self):
  6260. """Test filtering branch with author changes."""
  6261. def filter_author(author):
  6262. # Change all authors to "New Author"
  6263. return b"New Author <new@example.com>"
  6264. result = porcelain.filter_branch(
  6265. self.repo_path, "master", filter_author=filter_author
  6266. )
  6267. # Check that we have mappings for all commits
  6268. self.assertEqual(len(result), 3)
  6269. # Verify the branch ref was updated
  6270. new_head = self.repo.refs[b"refs/heads/master"]
  6271. self.assertNotEqual(new_head, self.c3_id)
  6272. # Verify the original ref was saved
  6273. original_ref = self.repo.refs[b"refs/original/refs/heads/master"]
  6274. self.assertEqual(original_ref, self.c3_id)
  6275. # Check that authors were updated
  6276. new_commit = self.repo[new_head]
  6277. self.assertEqual(new_commit.author, b"New Author <new@example.com>")
  6278. # Check parent chain
  6279. parent = self.repo[new_commit.parents[0]]
  6280. self.assertEqual(parent.author, b"New Author <new@example.com>")
  6281. def test_filter_branch_message(self):
  6282. """Test filtering branch with message changes."""
  6283. def filter_message(message):
  6284. # Add prefix to all messages
  6285. return b"[FILTERED] " + message
  6286. porcelain.filter_branch(self.repo_path, "master", filter_message=filter_message)
  6287. # Verify messages were updated
  6288. new_head = self.repo.refs[b"refs/heads/master"]
  6289. new_commit = self.repo[new_head]
  6290. self.assertTrue(new_commit.message.startswith(b"[FILTERED] "))
  6291. def test_filter_branch_custom_filter(self):
  6292. """Test filtering branch with custom filter function."""
  6293. def custom_filter(commit):
  6294. # Change both author and message
  6295. return {
  6296. "author": b"Custom Author <custom@example.com>",
  6297. "message": b"Custom: " + commit.message,
  6298. }
  6299. porcelain.filter_branch(self.repo_path, "master", filter_fn=custom_filter)
  6300. # Verify custom filter was applied
  6301. new_head = self.repo.refs[b"refs/heads/master"]
  6302. new_commit = self.repo[new_head]
  6303. self.assertEqual(new_commit.author, b"Custom Author <custom@example.com>")
  6304. self.assertTrue(new_commit.message.startswith(b"Custom: "))
  6305. def test_filter_branch_no_changes(self):
  6306. """Test filtering branch with no changes."""
  6307. result = porcelain.filter_branch(self.repo_path, "master")
  6308. # All commits should map to themselves
  6309. for old_sha, new_sha in result.items():
  6310. self.assertEqual(old_sha, new_sha)
  6311. # HEAD should be unchanged
  6312. self.assertEqual(self.repo.refs[b"refs/heads/master"], self.c3_id)
  6313. def test_filter_branch_force(self):
  6314. """Test force filtering a previously filtered branch."""
  6315. # First filter
  6316. porcelain.filter_branch(
  6317. self.repo_path, "master", filter_message=lambda m: b"First: " + m
  6318. )
  6319. # Try again without force - should fail
  6320. with self.assertRaises(porcelain.Error):
  6321. porcelain.filter_branch(
  6322. self.repo_path, "master", filter_message=lambda m: b"Second: " + m
  6323. )
  6324. # Try again with force - should succeed
  6325. porcelain.filter_branch(
  6326. self.repo_path,
  6327. "master",
  6328. filter_message=lambda m: b"Second: " + m,
  6329. force=True,
  6330. )
  6331. # Verify second filter was applied
  6332. new_head = self.repo.refs[b"refs/heads/master"]
  6333. new_commit = self.repo[new_head]
  6334. self.assertTrue(new_commit.message.startswith(b"Second: First: "))
  6335. class StashTests(PorcelainTestCase):
  6336. def setUp(self) -> None:
  6337. super().setUp()
  6338. # Create initial commit
  6339. with open(os.path.join(self.repo.path, "initial.txt"), "wb") as f:
  6340. f.write(b"initial content")
  6341. porcelain.add(repo=self.repo.path, paths=["initial.txt"])
  6342. porcelain.commit(
  6343. repo=self.repo.path,
  6344. message=b"Initial commit",
  6345. author=b"Test Author <test@example.com>",
  6346. committer=b"Test Committer <test@example.com>",
  6347. )
  6348. def test_stash_push_and_pop(self) -> None:
  6349. # Create a new file and stage it
  6350. new_file = os.path.join(self.repo.path, "new.txt")
  6351. with open(new_file, "wb") as f:
  6352. f.write(b"new file content")
  6353. porcelain.add(repo=self.repo.path, paths=["new.txt"])
  6354. # Modify existing file
  6355. with open(os.path.join(self.repo.path, "initial.txt"), "wb") as f:
  6356. f.write(b"modified content")
  6357. # Push to stash
  6358. porcelain.stash_push(self.repo.path)
  6359. # Verify files are reset
  6360. self.assertFalse(os.path.exists(new_file))
  6361. with open(os.path.join(self.repo.path, "initial.txt"), "rb") as f:
  6362. self.assertEqual(b"initial content", f.read())
  6363. # Pop the stash
  6364. porcelain.stash_pop(self.repo.path)
  6365. # Verify files are restored
  6366. self.assertTrue(os.path.exists(new_file))
  6367. with open(new_file, "rb") as f:
  6368. self.assertEqual(b"new file content", f.read())
  6369. with open(os.path.join(self.repo.path, "initial.txt"), "rb") as f:
  6370. self.assertEqual(b"modified content", f.read())
  6371. # Verify new file is in the index
  6372. from dulwich.index import Index
  6373. index = Index(os.path.join(self.repo.path, ".git", "index"))
  6374. self.assertIn(b"new.txt", index)
  6375. def test_stash_list(self) -> None:
  6376. # Initially no stashes
  6377. stashes = list(porcelain.stash_list(self.repo.path))
  6378. self.assertEqual(0, len(stashes))
  6379. # Create a file and stash it
  6380. test_file = os.path.join(self.repo.path, "test.txt")
  6381. with open(test_file, "wb") as f:
  6382. f.write(b"test content")
  6383. porcelain.add(repo=self.repo.path, paths=["test.txt"])
  6384. # Push first stash
  6385. porcelain.stash_push(self.repo.path)
  6386. # Create another file and stash it
  6387. test_file2 = os.path.join(self.repo.path, "test2.txt")
  6388. with open(test_file2, "wb") as f:
  6389. f.write(b"test content 2")
  6390. porcelain.add(repo=self.repo.path, paths=["test2.txt"])
  6391. # Push second stash
  6392. porcelain.stash_push(self.repo.path)
  6393. # Check stash list
  6394. stashes = list(porcelain.stash_list(self.repo.path))
  6395. self.assertEqual(2, len(stashes))
  6396. # Stashes are returned in order (most recent first)
  6397. self.assertEqual(0, stashes[0][0])
  6398. self.assertEqual(1, stashes[1][0])
  6399. def test_stash_drop(self) -> None:
  6400. # Create and stash some changes
  6401. test_file = os.path.join(self.repo.path, "test.txt")
  6402. with open(test_file, "wb") as f:
  6403. f.write(b"test content")
  6404. porcelain.add(repo=self.repo.path, paths=["test.txt"])
  6405. porcelain.stash_push(self.repo.path)
  6406. # Create another stash
  6407. test_file2 = os.path.join(self.repo.path, "test2.txt")
  6408. with open(test_file2, "wb") as f:
  6409. f.write(b"test content 2")
  6410. porcelain.add(repo=self.repo.path, paths=["test2.txt"])
  6411. porcelain.stash_push(self.repo.path)
  6412. # Verify we have 2 stashes
  6413. stashes = list(porcelain.stash_list(self.repo.path))
  6414. self.assertEqual(2, len(stashes))
  6415. # Drop the first stash (index 0)
  6416. porcelain.stash_drop(self.repo.path, 0)
  6417. # Verify we have 1 stash left
  6418. stashes = list(porcelain.stash_list(self.repo.path))
  6419. self.assertEqual(1, len(stashes))
  6420. # The remaining stash should be the one we created first
  6421. # Pop it and verify it's the first file
  6422. porcelain.stash_pop(self.repo.path)
  6423. self.assertTrue(os.path.exists(test_file))
  6424. self.assertFalse(os.path.exists(test_file2))
  6425. def test_stash_pop_empty(self) -> None:
  6426. # Attempting to pop from empty stash should raise an error
  6427. with self.assertRaises(IndexError):
  6428. porcelain.stash_pop(self.repo.path)
  6429. def test_stash_with_untracked_files(self) -> None:
  6430. # Create an untracked file
  6431. untracked_file = os.path.join(self.repo.path, "untracked.txt")
  6432. with open(untracked_file, "wb") as f:
  6433. f.write(b"untracked content")
  6434. # Create a tracked change
  6435. tracked_file = os.path.join(self.repo.path, "tracked.txt")
  6436. with open(tracked_file, "wb") as f:
  6437. f.write(b"tracked content")
  6438. porcelain.add(repo=self.repo.path, paths=["tracked.txt"])
  6439. # Stash (by default, untracked files are not included)
  6440. porcelain.stash_push(self.repo.path)
  6441. # Untracked file should still exist
  6442. self.assertTrue(os.path.exists(untracked_file))
  6443. # Tracked file should be gone
  6444. self.assertFalse(os.path.exists(tracked_file))
  6445. # Pop the stash
  6446. porcelain.stash_pop(self.repo.path)
  6447. # Tracked file should be restored
  6448. self.assertTrue(os.path.exists(tracked_file))
  6449. class BisectTests(PorcelainTestCase):
  6450. """Tests for bisect porcelain functions."""
  6451. def test_bisect_start(self):
  6452. """Test starting a bisect session."""
  6453. # Create some commits
  6454. c1, c2, c3 = build_commit_graph(
  6455. self.repo.object_store,
  6456. [[1], [2, 1], [3, 2]],
  6457. attrs={
  6458. 1: {"message": b"initial"},
  6459. 2: {"message": b"second"},
  6460. 3: {"message": b"third"},
  6461. },
  6462. )
  6463. self.repo.refs[b"refs/heads/master"] = c3.id
  6464. self.repo.refs[b"HEAD"] = c3.id
  6465. # Start bisect
  6466. porcelain.bisect_start(self.repo_path)
  6467. # Check that bisect state files exist
  6468. self.assertTrue(
  6469. os.path.exists(os.path.join(self.repo.controldir(), "BISECT_START"))
  6470. )
  6471. self.assertTrue(
  6472. os.path.exists(os.path.join(self.repo.controldir(), "BISECT_TERMS"))
  6473. )
  6474. self.assertTrue(
  6475. os.path.exists(os.path.join(self.repo.controldir(), "BISECT_NAMES"))
  6476. )
  6477. self.assertTrue(
  6478. os.path.exists(os.path.join(self.repo.controldir(), "BISECT_LOG"))
  6479. )
  6480. def test_bisect_workflow(self):
  6481. """Test a complete bisect workflow."""
  6482. # Create some commits
  6483. c1, c2, c3, c4 = build_commit_graph(
  6484. self.repo.object_store,
  6485. [[1], [2, 1], [3, 2], [4, 3]],
  6486. attrs={
  6487. 1: {"message": b"good commit 1"},
  6488. 2: {"message": b"good commit 2"},
  6489. 3: {"message": b"bad commit"},
  6490. 4: {"message": b"bad commit 2"},
  6491. },
  6492. )
  6493. self.repo.refs[b"refs/heads/master"] = c4.id
  6494. self.repo.refs[b"HEAD"] = c4.id
  6495. # Start bisect with bad and good
  6496. next_sha = porcelain.bisect_start(self.repo_path, bad=c4.id, good=c1.id)
  6497. # Should return the middle commit
  6498. self.assertIsNotNone(next_sha)
  6499. self.assertIn(next_sha, [c2.id, c3.id])
  6500. # Mark the middle commit as good or bad
  6501. if next_sha == c2.id:
  6502. # c2 is good, next should be c3
  6503. next_sha = porcelain.bisect_good(self.repo_path)
  6504. self.assertEqual(next_sha, c3.id)
  6505. # Mark c3 as bad - bisect complete
  6506. next_sha = porcelain.bisect_bad(self.repo_path)
  6507. self.assertIsNone(next_sha)
  6508. else:
  6509. # c3 is bad, next should be c2
  6510. next_sha = porcelain.bisect_bad(self.repo_path)
  6511. self.assertEqual(next_sha, c2.id)
  6512. # Mark c2 as good - bisect complete
  6513. next_sha = porcelain.bisect_good(self.repo_path)
  6514. self.assertIsNone(next_sha)
  6515. def test_bisect_log(self):
  6516. """Test getting bisect log."""
  6517. # Create some commits
  6518. c1, c2, c3 = build_commit_graph(
  6519. self.repo.object_store,
  6520. [[1], [2, 1], [3, 2]],
  6521. attrs={
  6522. 1: {"message": b"initial"},
  6523. 2: {"message": b"second"},
  6524. 3: {"message": b"third"},
  6525. },
  6526. )
  6527. self.repo.refs[b"refs/heads/master"] = c3.id
  6528. self.repo.refs[b"HEAD"] = c3.id
  6529. # Start bisect and mark commits
  6530. porcelain.bisect_start(self.repo_path)
  6531. porcelain.bisect_bad(self.repo_path, c3.id)
  6532. porcelain.bisect_good(self.repo_path, c1.id)
  6533. # Get log
  6534. log = porcelain.bisect_log(self.repo_path)
  6535. self.assertIn("git bisect start", log)
  6536. self.assertIn("git bisect bad", log)
  6537. self.assertIn("git bisect good", log)
  6538. def test_bisect_reset(self):
  6539. """Test resetting bisect state."""
  6540. # Create some commits
  6541. c1, c2, c3 = build_commit_graph(
  6542. self.repo.object_store,
  6543. [[1], [2, 1], [3, 2]],
  6544. attrs={
  6545. 1: {"message": b"initial"},
  6546. 2: {"message": b"second"},
  6547. 3: {"message": b"third"},
  6548. },
  6549. )
  6550. self.repo.refs[b"refs/heads/master"] = c3.id
  6551. self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/master")
  6552. # Start bisect
  6553. porcelain.bisect_start(self.repo_path)
  6554. porcelain.bisect_bad(self.repo_path)
  6555. porcelain.bisect_good(self.repo_path, c1.id)
  6556. # Reset
  6557. porcelain.bisect_reset(self.repo_path)
  6558. # Check that bisect state files are removed
  6559. self.assertFalse(
  6560. os.path.exists(os.path.join(self.repo.controldir(), "BISECT_START"))
  6561. )
  6562. self.assertFalse(
  6563. os.path.exists(os.path.join(self.repo.controldir(), "refs", "bisect"))
  6564. )
  6565. # HEAD should be back to being a symbolic ref to master
  6566. head_target, _ = self.repo.refs.follow(b"HEAD")
  6567. self.assertEqual(head_target[-1], b"refs/heads/master")
  6568. def test_bisect_skip(self):
  6569. """Test skipping commits during bisect."""
  6570. # Create some commits
  6571. c1, c2, c3, c4, c5 = build_commit_graph(
  6572. self.repo.object_store,
  6573. [[1], [2, 1], [3, 2], [4, 3], [5, 4]],
  6574. attrs={
  6575. 1: {"message": b"good"},
  6576. 2: {"message": b"skip this"},
  6577. 3: {"message": b"bad"},
  6578. 4: {"message": b"bad"},
  6579. 5: {"message": b"bad"},
  6580. },
  6581. )
  6582. self.repo.refs[b"refs/heads/master"] = c5.id
  6583. self.repo.refs[b"HEAD"] = c5.id
  6584. # Start bisect
  6585. porcelain.bisect_start(self.repo_path, bad=c5.id, good=c1.id)
  6586. # Skip c2 if it's selected
  6587. next_sha = porcelain.bisect_skip(self.repo_path, [c2.id])
  6588. self.assertIsNotNone(next_sha)
  6589. class ReflogTest(PorcelainTestCase):
  6590. def test_reflog_head(self):
  6591. """Test reading HEAD reflog."""
  6592. # Create a commit
  6593. blob = Blob.from_string(b"test content")
  6594. self.repo.object_store.add_object(blob)
  6595. tree = Tree()
  6596. tree.add(b"test", 0o100644, blob.id)
  6597. self.repo.object_store.add_object(tree)
  6598. commit = Commit()
  6599. commit.tree = tree.id
  6600. commit.author = b"Test Author <test@example.com>"
  6601. commit.committer = b"Test Author <test@example.com>"
  6602. commit.commit_time = 1234567890
  6603. commit.commit_timezone = 0
  6604. commit.author_time = 1234567890
  6605. commit.author_timezone = 0
  6606. commit.message = b"Initial commit"
  6607. self.repo.object_store.add_object(commit)
  6608. # Write a reflog entry
  6609. self.repo._write_reflog(
  6610. b"HEAD",
  6611. ZERO_SHA,
  6612. commit.id,
  6613. b"Test Author <test@example.com>",
  6614. 1234567890,
  6615. 0,
  6616. b"commit (initial): Initial commit",
  6617. )
  6618. # Read reflog using porcelain
  6619. entries = list(porcelain.reflog(self.repo_path))
  6620. self.assertEqual(1, len(entries))
  6621. self.assertEqual(ZERO_SHA, entries[0].old_sha)
  6622. self.assertEqual(commit.id, entries[0].new_sha)
  6623. self.assertEqual(b"Test Author <test@example.com>", entries[0].committer)
  6624. self.assertEqual(1234567890, entries[0].timestamp)
  6625. self.assertEqual(0, entries[0].timezone)
  6626. self.assertEqual(b"commit (initial): Initial commit", entries[0].message)
  6627. def test_reflog_with_string_ref(self):
  6628. """Test reading reflog with string reference."""
  6629. # Create a commit
  6630. blob = Blob.from_string(b"test content")
  6631. self.repo.object_store.add_object(blob)
  6632. tree = Tree()
  6633. tree.add(b"test", 0o100644, blob.id)
  6634. self.repo.object_store.add_object(tree)
  6635. commit = Commit()
  6636. commit.tree = tree.id
  6637. commit.author = b"Test Author <test@example.com>"
  6638. commit.committer = b"Test Author <test@example.com>"
  6639. commit.commit_time = 1234567890
  6640. commit.commit_timezone = 0
  6641. commit.author_time = 1234567890
  6642. commit.author_timezone = 0
  6643. commit.message = b"Initial commit"
  6644. self.repo.object_store.add_object(commit)
  6645. # Write a reflog entry
  6646. self.repo._write_reflog(
  6647. b"refs/heads/master",
  6648. ZERO_SHA,
  6649. commit.id,
  6650. b"Test Author <test@example.com>",
  6651. 1234567890,
  6652. 0,
  6653. b"commit (initial): Initial commit",
  6654. )
  6655. # Read reflog using porcelain with string ref
  6656. entries = list(porcelain.reflog(self.repo_path, "refs/heads/master"))
  6657. self.assertEqual(1, len(entries))
  6658. self.assertEqual(commit.id, entries[0].new_sha)
  6659. def test_reflog_all(self):
  6660. """Test reading all reflogs."""
  6661. # Create a commit
  6662. blob = Blob.from_string(b"test content")
  6663. self.repo.object_store.add_object(blob)
  6664. tree = Tree()
  6665. tree.add(b"test", 0o100644, blob.id)
  6666. self.repo.object_store.add_object(tree)
  6667. commit = Commit()
  6668. commit.tree = tree.id
  6669. commit.author = b"Test Author <test@example.com>"
  6670. commit.committer = b"Test Author <test@example.com>"
  6671. commit.commit_time = 1234567890
  6672. commit.commit_timezone = 0
  6673. commit.author_time = 1234567890
  6674. commit.author_timezone = 0
  6675. commit.message = b"Initial commit"
  6676. self.repo.object_store.add_object(commit)
  6677. # Write reflog entries for HEAD and master
  6678. self.repo._write_reflog(
  6679. b"HEAD",
  6680. ZERO_SHA,
  6681. commit.id,
  6682. b"Test Author <test@example.com>",
  6683. 1234567890,
  6684. 0,
  6685. b"commit (initial): Initial commit",
  6686. )
  6687. self.repo._write_reflog(
  6688. b"refs/heads/master",
  6689. ZERO_SHA,
  6690. commit.id,
  6691. b"Test Author <test@example.com>",
  6692. 1234567891,
  6693. 0,
  6694. b"branch: Created from HEAD",
  6695. )
  6696. # Read all reflogs using porcelain
  6697. entries = list(porcelain.reflog(self.repo_path, all=True))
  6698. # Should have at least 2 entries (HEAD and refs/heads/master)
  6699. self.assertGreaterEqual(len(entries), 2)
  6700. # Check that we got entries from different refs
  6701. refs_seen = set()
  6702. for ref, entry in entries:
  6703. refs_seen.add(ref)
  6704. self.assertEqual(commit.id, entry.new_sha)
  6705. # Should have seen at least HEAD and refs/heads/master
  6706. self.assertIn(b"HEAD", refs_seen)
  6707. self.assertIn(b"refs/heads/master", refs_seen)
  6708. class WriteCommitGraphTests(PorcelainTestCase):
  6709. """Tests for the write_commit_graph porcelain function."""
  6710. def test_write_commit_graph_empty_repo(self):
  6711. """Test writing commit graph on empty repository."""
  6712. # Should not fail on empty repo
  6713. porcelain.write_commit_graph(self.repo_path)
  6714. # No commit graph should be created for empty repo
  6715. graph_path = os.path.join(
  6716. self.repo_path, ".git", "objects", "info", "commit-graph"
  6717. )
  6718. self.assertFalse(os.path.exists(graph_path))
  6719. def test_write_commit_graph_with_commits(self):
  6720. """Test writing commit graph with commits."""
  6721. # Create some commits
  6722. c1, c2, c3 = build_commit_graph(
  6723. self.repo.object_store,
  6724. [[1], [2, 1], [3, 1, 2]],
  6725. attrs={
  6726. 1: {"message": b"First commit"},
  6727. 2: {"message": b"Second commit"},
  6728. 3: {"message": b"Merge commit"},
  6729. },
  6730. )
  6731. self.repo.refs[b"refs/heads/master"] = c3.id
  6732. self.repo.refs[b"refs/heads/branch"] = c2.id
  6733. # Write commit graph
  6734. porcelain.write_commit_graph(self.repo_path)
  6735. # Verify commit graph was created
  6736. graph_path = os.path.join(
  6737. self.repo_path, ".git", "objects", "info", "commit-graph"
  6738. )
  6739. self.assertTrue(os.path.exists(graph_path))
  6740. # Load and verify the commit graph
  6741. from dulwich.commit_graph import read_commit_graph
  6742. commit_graph = read_commit_graph(graph_path)
  6743. self.assertIsNotNone(commit_graph)
  6744. self.assertEqual(3, len(commit_graph))
  6745. # Verify all commits are in the graph
  6746. for commit_obj in [c1, c2, c3]:
  6747. entry = commit_graph.get_entry_by_oid(commit_obj.id)
  6748. self.assertIsNotNone(entry)
  6749. def test_write_commit_graph_config_disabled(self):
  6750. """Test that commit graph is not written when disabled by config."""
  6751. # Create a commit
  6752. (c1,) = build_commit_graph(
  6753. self.repo.object_store, [[1]], attrs={1: {"message": b"First commit"}}
  6754. )
  6755. self.repo.refs[b"refs/heads/master"] = c1.id
  6756. # Disable commit graph in config
  6757. config = self.repo.get_config()
  6758. config.set((b"core",), b"commitGraph", b"false")
  6759. config.write_to_path()
  6760. # Write commit graph (should respect config)
  6761. porcelain.write_commit_graph(self.repo_path)
  6762. # Verify commit graph still gets written
  6763. # (porcelain.write_commit_graph explicitly writes regardless of config)
  6764. graph_path = os.path.join(
  6765. self.repo_path, ".git", "objects", "info", "commit-graph"
  6766. )
  6767. self.assertTrue(os.path.exists(graph_path))
  6768. def test_write_commit_graph_reachable_false(self):
  6769. """Test writing commit graph with reachable=False."""
  6770. # Create commits
  6771. c1, c2, c3 = build_commit_graph(
  6772. self.repo.object_store,
  6773. [[1], [2, 1], [3, 2]],
  6774. attrs={
  6775. 1: {"message": b"First commit"},
  6776. 2: {"message": b"Second commit"},
  6777. 3: {"message": b"Third commit"},
  6778. },
  6779. )
  6780. # Only reference the third commit
  6781. self.repo.refs[b"refs/heads/master"] = c3.id
  6782. # Write commit graph with reachable=False
  6783. porcelain.write_commit_graph(self.repo_path, reachable=False)
  6784. # Verify commit graph was created
  6785. graph_path = os.path.join(
  6786. self.repo_path, ".git", "objects", "info", "commit-graph"
  6787. )
  6788. self.assertTrue(os.path.exists(graph_path))
  6789. # Load and verify the commit graph
  6790. from dulwich.commit_graph import read_commit_graph
  6791. commit_graph = read_commit_graph(graph_path)
  6792. self.assertIsNotNone(commit_graph)
  6793. # With reachable=False, only directly referenced commits should be included
  6794. # In this case, only c3 (from refs/heads/master)
  6795. self.assertEqual(1, len(commit_graph))
  6796. entry = commit_graph.get_entry_by_oid(c3.id)
  6797. self.assertIsNotNone(entry)