__init__.py 415 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843484448454846484748484849485048514852485348544855485648574858485948604861486248634864486548664867486848694870487148724873487448754876487748784879488048814882488348844885488648874888488948904891489248934894489548964897489848994900490149024903490449054906490749084909491049114912491349144915491649174918491949204921492249234924492549264927492849294930493149324933493449354936493749384939494049414942494349444945494649474948494949504951495249534954495549564957495849594960496149624963496449654966496749684969497049714972497349744975497649774978497949804981498249834984498549864987498849894990499149924993499449954996499749984999500050015002500350045005500650075008500950105011501250135014501550165017501850195020502150225023502450255026502750285029503050315032503350345035503650375038503950405041504250435044504550465047504850495050505150525053505450555056505750585059506050615062506350645065506650675068506950705071507250735074507550765077507850795080508150825083508450855086508750885089509050915092509350945095509650975098509951005101510251035104510551065107510851095110511151125113511451155116511751185119512051215122512351245125512651275128512951305131513251335134513551365137513851395140514151425143514451455146514751485149515051515152515351545155515651575158515951605161516251635164516551665167516851695170517151725173517451755176517751785179518051815182518351845185518651875188518951905191519251935194519551965197519851995200520152025203520452055206520752085209521052115212521352145215521652175218521952205221522252235224522552265227522852295230523152325233523452355236523752385239524052415242524352445245524652475248524952505251525252535254525552565257525852595260526152625263526452655266526752685269527052715272527352745275527652775278527952805281528252835284528552865287528852895290529152925293529452955296529752985299530053015302530353045305530653075308530953105311531253135314531553165317531853195320532153225323532453255326532753285329533053315332533353345335533653375338533953405341534253435344534553465347534853495350535153525353535453555356535753585359536053615362536353645365536653675368536953705371537253735374537553765377537853795380538153825383538453855386538753885389539053915392539353945395539653975398539954005401540254035404540554065407540854095410541154125413541454155416541754185419542054215422542354245425542654275428542954305431543254335434543554365437543854395440544154425443544454455446544754485449545054515452545354545455545654575458545954605461546254635464546554665467546854695470547154725473547454755476547754785479548054815482548354845485548654875488548954905491549254935494549554965497549854995500550155025503550455055506550755085509551055115512551355145515551655175518551955205521552255235524552555265527552855295530553155325533553455355536553755385539554055415542554355445545554655475548554955505551555255535554555555565557555855595560556155625563556455655566556755685569557055715572557355745575557655775578557955805581558255835584558555865587558855895590559155925593559455955596559755985599560056015602560356045605560656075608560956105611561256135614561556165617561856195620562156225623562456255626562756285629563056315632563356345635563656375638563956405641564256435644564556465647564856495650565156525653565456555656565756585659566056615662566356645665566656675668566956705671567256735674567556765677567856795680568156825683568456855686568756885689569056915692569356945695569656975698569957005701570257035704570557065707570857095710571157125713571457155716571757185719572057215722572357245725572657275728572957305731573257335734573557365737573857395740574157425743574457455746574757485749575057515752575357545755575657575758575957605761576257635764576557665767576857695770577157725773577457755776577757785779578057815782578357845785578657875788578957905791579257935794579557965797579857995800580158025803580458055806580758085809581058115812581358145815581658175818581958205821582258235824582558265827582858295830583158325833583458355836583758385839584058415842584358445845584658475848584958505851585258535854585558565857585858595860586158625863586458655866586758685869587058715872587358745875587658775878587958805881588258835884588558865887588858895890589158925893589458955896589758985899590059015902590359045905590659075908590959105911591259135914591559165917591859195920592159225923592459255926592759285929593059315932593359345935593659375938593959405941594259435944594559465947594859495950595159525953595459555956595759585959596059615962596359645965596659675968596959705971597259735974597559765977597859795980598159825983598459855986598759885989599059915992599359945995599659975998599960006001600260036004600560066007600860096010601160126013601460156016601760186019602060216022602360246025602660276028602960306031603260336034603560366037603860396040604160426043604460456046604760486049605060516052605360546055605660576058605960606061606260636064606560666067606860696070607160726073607460756076607760786079608060816082608360846085608660876088608960906091609260936094609560966097609860996100610161026103610461056106610761086109611061116112611361146115611661176118611961206121612261236124612561266127612861296130613161326133613461356136613761386139614061416142614361446145614661476148614961506151615261536154615561566157615861596160616161626163616461656166616761686169617061716172617361746175617661776178617961806181618261836184618561866187618861896190619161926193619461956196619761986199620062016202620362046205620662076208620962106211621262136214621562166217621862196220622162226223622462256226622762286229623062316232623362346235623662376238623962406241624262436244624562466247624862496250625162526253625462556256625762586259626062616262626362646265626662676268626962706271627262736274627562766277627862796280628162826283628462856286628762886289629062916292629362946295629662976298629963006301630263036304630563066307630863096310631163126313631463156316631763186319632063216322632363246325632663276328632963306331633263336334633563366337633863396340634163426343634463456346634763486349635063516352635363546355635663576358635963606361636263636364636563666367636863696370637163726373637463756376637763786379638063816382638363846385638663876388638963906391639263936394639563966397639863996400640164026403640464056406640764086409641064116412641364146415641664176418641964206421642264236424642564266427642864296430643164326433643464356436643764386439644064416442644364446445644664476448644964506451645264536454645564566457645864596460646164626463646464656466646764686469647064716472647364746475647664776478647964806481648264836484648564866487648864896490649164926493649464956496649764986499650065016502650365046505650665076508650965106511651265136514651565166517651865196520652165226523652465256526652765286529653065316532653365346535653665376538653965406541654265436544654565466547654865496550655165526553655465556556655765586559656065616562656365646565656665676568656965706571657265736574657565766577657865796580658165826583658465856586658765886589659065916592659365946595659665976598659966006601660266036604660566066607660866096610661166126613661466156616661766186619662066216622662366246625662666276628662966306631663266336634663566366637663866396640664166426643664466456646664766486649665066516652665366546655665666576658665966606661666266636664666566666667666866696670667166726673667466756676667766786679668066816682668366846685668666876688668966906691669266936694669566966697669866996700670167026703670467056706670767086709671067116712671367146715671667176718671967206721672267236724672567266727672867296730673167326733673467356736673767386739674067416742674367446745674667476748674967506751675267536754675567566757675867596760676167626763676467656766676767686769677067716772677367746775677667776778677967806781678267836784678567866787678867896790679167926793679467956796679767986799680068016802680368046805680668076808680968106811681268136814681568166817681868196820682168226823682468256826682768286829683068316832683368346835683668376838683968406841684268436844684568466847684868496850685168526853685468556856685768586859686068616862686368646865686668676868686968706871687268736874687568766877687868796880688168826883688468856886688768886889689068916892689368946895689668976898689969006901690269036904690569066907690869096910691169126913691469156916691769186919692069216922692369246925692669276928692969306931693269336934693569366937693869396940694169426943694469456946694769486949695069516952695369546955695669576958695969606961696269636964696569666967696869696970697169726973697469756976697769786979698069816982698369846985698669876988698969906991699269936994699569966997699869997000700170027003700470057006700770087009701070117012701370147015701670177018701970207021702270237024702570267027702870297030703170327033703470357036703770387039704070417042704370447045704670477048704970507051705270537054705570567057705870597060706170627063706470657066706770687069707070717072707370747075707670777078707970807081708270837084708570867087708870897090709170927093709470957096709770987099710071017102710371047105710671077108710971107111711271137114711571167117711871197120712171227123712471257126712771287129713071317132713371347135713671377138713971407141714271437144714571467147714871497150715171527153715471557156715771587159716071617162716371647165716671677168716971707171717271737174717571767177717871797180718171827183718471857186718771887189719071917192719371947195719671977198719972007201720272037204720572067207720872097210721172127213721472157216721772187219722072217222722372247225722672277228722972307231723272337234723572367237723872397240724172427243724472457246724772487249725072517252725372547255725672577258725972607261726272637264726572667267726872697270727172727273727472757276727772787279728072817282728372847285728672877288728972907291729272937294729572967297729872997300730173027303730473057306730773087309731073117312731373147315731673177318731973207321732273237324732573267327732873297330733173327333733473357336733773387339734073417342734373447345734673477348734973507351735273537354735573567357735873597360736173627363736473657366736773687369737073717372737373747375737673777378737973807381738273837384738573867387738873897390739173927393739473957396739773987399740074017402740374047405740674077408740974107411741274137414741574167417741874197420742174227423742474257426742774287429743074317432743374347435743674377438743974407441744274437444744574467447744874497450745174527453745474557456745774587459746074617462746374647465746674677468746974707471747274737474747574767477747874797480748174827483748474857486748774887489749074917492749374947495749674977498749975007501750275037504750575067507750875097510751175127513751475157516751775187519752075217522752375247525752675277528752975307531753275337534753575367537753875397540754175427543754475457546754775487549755075517552755375547555755675577558755975607561756275637564756575667567756875697570757175727573757475757576757775787579758075817582758375847585758675877588758975907591759275937594759575967597759875997600760176027603760476057606760776087609761076117612761376147615761676177618761976207621762276237624762576267627762876297630763176327633763476357636763776387639764076417642764376447645764676477648764976507651765276537654765576567657765876597660766176627663766476657666766776687669767076717672767376747675767676777678767976807681768276837684768576867687768876897690769176927693769476957696769776987699770077017702770377047705770677077708770977107711771277137714771577167717771877197720772177227723772477257726772777287729773077317732773377347735773677377738773977407741774277437744774577467747774877497750775177527753775477557756775777587759776077617762776377647765776677677768776977707771777277737774777577767777777877797780778177827783778477857786778777887789779077917792779377947795779677977798779978007801780278037804780578067807780878097810781178127813781478157816781778187819782078217822782378247825782678277828782978307831783278337834783578367837783878397840784178427843784478457846784778487849785078517852785378547855785678577858785978607861786278637864786578667867786878697870787178727873787478757876787778787879788078817882788378847885788678877888788978907891789278937894789578967897789878997900790179027903790479057906790779087909791079117912791379147915791679177918791979207921792279237924792579267927792879297930793179327933793479357936793779387939794079417942794379447945794679477948794979507951795279537954795579567957795879597960796179627963796479657966796779687969797079717972797379747975797679777978797979807981798279837984798579867987798879897990799179927993799479957996799779987999800080018002800380048005800680078008800980108011801280138014801580168017801880198020802180228023802480258026802780288029803080318032803380348035803680378038803980408041804280438044804580468047804880498050805180528053805480558056805780588059806080618062806380648065806680678068806980708071807280738074807580768077807880798080808180828083808480858086808780888089809080918092809380948095809680978098809981008101810281038104810581068107810881098110811181128113811481158116811781188119812081218122812381248125812681278128812981308131813281338134813581368137813881398140814181428143814481458146814781488149815081518152815381548155815681578158815981608161816281638164816581668167816881698170817181728173817481758176817781788179818081818182818381848185818681878188818981908191819281938194819581968197819881998200820182028203820482058206820782088209821082118212821382148215821682178218821982208221822282238224822582268227822882298230823182328233823482358236823782388239824082418242824382448245824682478248824982508251825282538254825582568257825882598260826182628263826482658266826782688269827082718272827382748275827682778278827982808281828282838284828582868287828882898290829182928293829482958296829782988299830083018302830383048305830683078308830983108311831283138314831583168317831883198320832183228323832483258326832783288329833083318332833383348335833683378338833983408341834283438344834583468347834883498350835183528353835483558356835783588359836083618362836383648365836683678368836983708371837283738374837583768377837883798380838183828383838483858386838783888389839083918392839383948395839683978398839984008401840284038404840584068407840884098410841184128413841484158416841784188419842084218422842384248425842684278428842984308431843284338434843584368437843884398440844184428443844484458446844784488449845084518452845384548455845684578458845984608461846284638464846584668467846884698470847184728473847484758476847784788479848084818482848384848485848684878488848984908491849284938494849584968497849884998500850185028503850485058506850785088509851085118512851385148515851685178518851985208521852285238524852585268527852885298530853185328533853485358536853785388539854085418542854385448545854685478548854985508551855285538554855585568557855885598560856185628563856485658566856785688569857085718572857385748575857685778578857985808581858285838584858585868587858885898590859185928593859485958596859785988599860086018602860386048605860686078608860986108611861286138614861586168617861886198620862186228623862486258626862786288629863086318632863386348635863686378638863986408641864286438644864586468647864886498650865186528653865486558656865786588659866086618662866386648665866686678668866986708671867286738674867586768677867886798680868186828683868486858686868786888689869086918692869386948695869686978698869987008701870287038704870587068707870887098710871187128713871487158716871787188719872087218722872387248725872687278728872987308731873287338734873587368737873887398740874187428743874487458746874787488749875087518752875387548755875687578758875987608761876287638764876587668767876887698770877187728773877487758776877787788779878087818782878387848785878687878788878987908791879287938794879587968797879887998800880188028803880488058806880788088809881088118812881388148815881688178818881988208821882288238824882588268827882888298830883188328833883488358836883788388839884088418842884388448845884688478848884988508851885288538854885588568857885888598860886188628863886488658866886788688869887088718872887388748875887688778878887988808881888288838884888588868887888888898890889188928893889488958896889788988899890089018902890389048905890689078908890989108911891289138914891589168917891889198920892189228923892489258926892789288929893089318932893389348935893689378938893989408941894289438944894589468947894889498950895189528953895489558956895789588959896089618962896389648965896689678968896989708971897289738974897589768977897889798980898189828983898489858986898789888989899089918992899389948995899689978998899990009001900290039004900590069007900890099010901190129013901490159016901790189019902090219022902390249025902690279028902990309031903290339034903590369037903890399040904190429043904490459046904790489049905090519052905390549055905690579058905990609061906290639064906590669067906890699070907190729073907490759076907790789079908090819082908390849085908690879088908990909091909290939094909590969097909890999100910191029103910491059106910791089109911091119112911391149115911691179118911991209121912291239124912591269127912891299130913191329133913491359136913791389139914091419142914391449145914691479148914991509151915291539154915591569157915891599160916191629163916491659166916791689169917091719172917391749175917691779178917991809181918291839184918591869187918891899190919191929193919491959196919791989199920092019202920392049205920692079208920992109211921292139214921592169217921892199220922192229223922492259226922792289229923092319232923392349235923692379238923992409241924292439244924592469247924892499250925192529253925492559256925792589259926092619262926392649265926692679268926992709271927292739274927592769277927892799280928192829283928492859286928792889289929092919292929392949295929692979298929993009301930293039304930593069307930893099310931193129313931493159316931793189319932093219322932393249325932693279328932993309331933293339334933593369337933893399340934193429343934493459346934793489349935093519352935393549355935693579358935993609361936293639364936593669367936893699370937193729373937493759376937793789379938093819382938393849385938693879388938993909391939293939394939593969397939893999400940194029403940494059406940794089409941094119412941394149415941694179418941994209421942294239424942594269427942894299430943194329433943494359436943794389439944094419442944394449445944694479448944994509451945294539454945594569457945894599460946194629463946494659466946794689469947094719472947394749475947694779478947994809481948294839484948594869487948894899490949194929493949494959496949794989499950095019502950395049505950695079508950995109511951295139514951595169517951895199520952195229523952495259526952795289529953095319532953395349535953695379538953995409541954295439544954595469547954895499550955195529553955495559556955795589559956095619562956395649565956695679568956995709571957295739574957595769577957895799580958195829583958495859586958795889589959095919592959395949595959695979598959996009601960296039604960596069607960896099610961196129613961496159616961796189619962096219622962396249625962696279628962996309631963296339634963596369637963896399640964196429643964496459646964796489649965096519652965396549655965696579658965996609661966296639664966596669667966896699670967196729673967496759676967796789679968096819682968396849685968696879688968996909691969296939694969596969697969896999700970197029703970497059706970797089709971097119712971397149715971697179718971997209721972297239724972597269727972897299730973197329733973497359736973797389739974097419742974397449745974697479748974997509751975297539754975597569757975897599760976197629763976497659766976797689769977097719772977397749775977697779778977997809781978297839784978597869787978897899790979197929793979497959796979797989799980098019802980398049805980698079808980998109811981298139814981598169817981898199820982198229823982498259826982798289829983098319832983398349835983698379838983998409841984298439844984598469847984898499850985198529853985498559856985798589859986098619862986398649865986698679868986998709871987298739874987598769877987898799880988198829883988498859886988798889889989098919892989398949895989698979898989999009901990299039904990599069907990899099910991199129913991499159916991799189919992099219922992399249925992699279928992999309931993299339934993599369937993899399940994199429943994499459946994799489949995099519952995399549955995699579958995999609961996299639964996599669967996899699970997199729973997499759976997799789979998099819982998399849985998699879988998999909991999299939994999599969997999899991000010001100021000310004100051000610007100081000910010100111001210013100141001510016100171001810019100201002110022100231002410025100261002710028100291003010031100321003310034100351003610037100381003910040100411004210043100441004510046100471004810049100501005110052100531005410055100561005710058100591006010061100621006310064100651006610067100681006910070100711007210073100741007510076100771007810079100801008110082100831008410085100861008710088100891009010091100921009310094100951009610097100981009910100101011010210103101041010510106101071010810109101101011110112101131011410115101161011710118101191012010121101221012310124101251012610127101281012910130101311013210133101341013510136101371013810139101401014110142101431014410145101461014710148101491015010151101521015310154101551015610157101581015910160101611016210163101641016510166101671016810169101701017110172101731017410175101761017710178101791018010181101821018310184101851018610187101881018910190101911019210193101941019510196101971019810199102001020110202102031020410205102061020710208102091021010211102121021310214102151021610217102181021910220102211022210223102241022510226102271022810229102301023110232102331023410235102361023710238102391024010241102421024310244102451024610247102481024910250102511025210253102541025510256102571025810259102601026110262102631026410265102661026710268102691027010271102721027310274102751027610277102781027910280102811028210283102841028510286102871028810289102901029110292102931029410295102961029710298102991030010301103021030310304103051030610307103081030910310103111031210313103141031510316103171031810319103201032110322103231032410325103261032710328103291033010331103321033310334103351033610337103381033910340103411034210343103441034510346103471034810349103501035110352103531035410355103561035710358103591036010361103621036310364103651036610367103681036910370103711037210373103741037510376103771037810379103801038110382103831038410385103861038710388103891039010391103921039310394103951039610397103981039910400104011040210403104041040510406104071040810409104101041110412104131041410415104161041710418104191042010421104221042310424104251042610427104281042910430104311043210433104341043510436104371043810439104401044110442104431044410445104461044710448104491045010451104521045310454104551045610457104581045910460104611046210463104641046510466104671046810469104701047110472104731047410475104761047710478104791048010481104821048310484104851048610487104881048910490104911049210493104941049510496104971049810499105001050110502105031050410505105061050710508105091051010511105121051310514105151051610517105181051910520105211052210523105241052510526105271052810529105301053110532105331053410535105361053710538105391054010541105421054310544105451054610547105481054910550105511055210553105541055510556105571055810559105601056110562105631056410565105661056710568105691057010571105721057310574105751057610577105781057910580105811058210583105841058510586105871058810589105901059110592105931059410595105961059710598105991060010601106021060310604106051060610607106081060910610106111061210613106141061510616106171061810619106201062110622106231062410625106261062710628106291063010631106321063310634106351063610637106381063910640106411064210643106441064510646106471064810649106501065110652106531065410655106561065710658106591066010661106621066310664106651066610667106681066910670106711067210673106741067510676106771067810679106801068110682106831068410685106861068710688106891069010691106921069310694106951069610697106981069910700107011070210703107041070510706107071070810709107101071110712107131071410715107161071710718107191072010721107221072310724107251072610727107281072910730107311073210733107341073510736107371073810739107401074110742107431074410745107461074710748107491075010751107521075310754107551075610757107581075910760107611076210763107641076510766107671076810769107701077110772107731077410775107761077710778107791078010781107821078310784107851078610787107881078910790107911079210793107941079510796107971079810799108001080110802108031080410805108061080710808108091081010811108121081310814108151081610817108181081910820108211082210823108241082510826108271082810829108301083110832108331083410835108361083710838108391084010841108421084310844108451084610847108481084910850108511085210853108541085510856108571085810859108601086110862108631086410865108661086710868108691087010871108721087310874108751087610877108781087910880108811088210883108841088510886108871088810889108901089110892108931089410895108961089710898108991090010901109021090310904109051090610907109081090910910109111091210913109141091510916109171091810919109201092110922109231092410925109261092710928109291093010931109321093310934109351093610937109381093910940109411094210943109441094510946109471094810949109501095110952109531095410955109561095710958109591096010961109621096310964109651096610967109681096910970109711097210973109741097510976109771097810979109801098110982109831098410985109861098710988109891099010991109921099310994109951099610997109981099911000110011100211003110041100511006110071100811009110101101111012110131101411015110161101711018110191102011021110221102311024110251102611027110281102911030110311103211033110341103511036110371103811039110401104111042110431104411045110461104711048110491105011051110521105311054110551105611057110581105911060110611106211063110641106511066110671106811069110701107111072110731107411075110761107711078110791108011081110821108311084110851108611087110881108911090110911109211093110941109511096110971109811099111001110111102111031110411105111061110711108111091111011111111121111311114111151111611117111181111911120111211112211123111241112511126111271112811129111301113111132111331113411135111361113711138111391114011141111421114311144111451114611147111481114911150111511115211153111541115511156111571115811159111601116111162111631116411165111661116711168111691117011171111721117311174111751117611177111781117911180111811118211183111841118511186111871118811189111901119111192111931119411195111961119711198111991120011201112021120311204112051120611207112081120911210112111121211213112141121511216112171121811219112201122111222112231122411225112261122711228112291123011231112321123311234112351123611237112381123911240112411124211243112441124511246112471124811249112501125111252112531125411255112561125711258112591126011261112621126311264112651126611267112681126911270112711127211273112741127511276112771127811279112801128111282112831128411285112861128711288112891129011291112921129311294112951129611297112981129911300113011130211303113041130511306113071130811309113101131111312113131131411315
  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. # Disable pagers for tests
  71. self.overrideEnv("PAGER", "false")
  72. self.overrideEnv("GIT_PAGER", "false")
  73. self.overrideEnv("DULWICH_PAGER", "false")
  74. self.test_dir = tempfile.mkdtemp()
  75. self.addCleanup(shutil.rmtree, self.test_dir)
  76. self.repo_path = os.path.join(self.test_dir, "repo")
  77. self.repo = Repo.init(self.repo_path, mkdir=True)
  78. self.addCleanup(self.repo.close)
  79. def assertRecentTimestamp(self, ts) -> None:
  80. # On some slow CIs it does actually take more than 5 seconds to go from
  81. # creating the tag to here.
  82. self.assertLess(time.time() - ts, 50)
  83. @skipIf(gpg is None, "gpg is not available")
  84. class PorcelainGpgTestCase(PorcelainTestCase):
  85. DEFAULT_KEY = """
  86. -----BEGIN PGP PRIVATE KEY BLOCK-----
  87. lQVYBGBjIyIBDADAwydvMPQqeEiK54FG1DHwT5sQejAaJOb+PsOhVa4fLcKsrO3F
  88. g5CxO+/9BHCXAr8xQAtp/gOhDN05fyK3MFyGlL9s+Cd8xf34S3R4rN/qbF0oZmaa
  89. FW0MuGnniq54HINs8KshadVn1Dhi/GYSJ588qNFRl/qxFTYAk+zaGsgX/QgFfy0f
  90. djWXJLypZXu9D6DlyJ0cPSzUlfBkI2Ytx6grzIquRjY0FbkjK3l+iGsQ+ebRMdcP
  91. Sqd5iTN9XuzIUVoBFAZBRjibKV3N2wxlnCbfLlzCyDp7rktzSThzjJ2pVDuLrMAx
  92. 6/L9hIhwmFwdtY4FBFGvMR0b0Ugh3kCsRWr8sgj9I7dUoLHid6ObYhJFhnD3GzRc
  93. U+xX1uy3iTCqJDsG334aQIhC5Giuxln4SUZna2MNbq65ksh38N1aM/t3+Dc/TKVB
  94. rb5KWicRPCQ4DIQkHMDCSPyj+dvRLCPzIaPvHD7IrCfHYHOWuvvPGCpwjo0As3iP
  95. IecoMeguPLVaqgcAEQEAAQAL/i5/pQaUd4G7LDydpbixPS6r9UrfPrU/y5zvBP/p
  96. DCynPDutJ1oq539pZvXQ2VwEJJy7x0UVKkjyMndJLNWly9wHC7o8jkHx/NalVP47
  97. LXR+GWbCdOOcYYbdAWcCNB3zOtzPnWhdAEagkc2G9xRQDIB0dLHLCIUpCbLP/CWM
  98. qlHnDsVMrVTWjgzcpsnyGgw8NeLYJtYGB8dsN+XgCCjo7a9LEvUBKNgdmWBbf14/
  99. iBw7PCugazFcH9QYfZwzhsi3nqRRagTXHbxFRG0LD9Ro9qCEutHYGP2PJ59Nj8+M
  100. zaVkJj/OxWxVOGvn2q16mQBCjKpbWfqXZVVl+G5DGOmiSTZqXy+3j6JCKdOMy6Qd
  101. JBHOHhFZXYmWYaaPzoc33T/C3QhMfY5sOtUDLJmV05Wi4dyBeNBEslYgUuTk/jXb
  102. 5ZAie25eDdrsoqkcnSs2ZguMF7AXhe6il2zVhUUMs/6UZgd6I7I4Is0HXT/pnxEp
  103. uiTRFu4v8E+u+5a8O3pffe5boQYA3TsIxceen20qY+kRaTOkURHMZLn/y6KLW8bZ
  104. rNJyXWS9hBAcbbSGhfOwYfzbDCM17yPQO3E2zo8lcGdRklUdIIaCxQwtu36N5dfx
  105. OLCCQc5LmYdl/EAm91iAhrr7dNntZ18MU09gdzUu+ONZwu4CP3cJT83+qYZULso8
  106. 4Fvd/X8IEfGZ7kM+ylrdqBwtlrn8yYXtom+ows2M2UuNR53B+BUOd73kVLTkTCjE
  107. JH63+nE8BqG7tDLCMws+23SAA3xxBgDfDrr0x7zCozQKVQEqBzQr9Uoo/c/ZjAfi
  108. syzNSrDz+g5gqJYtuL9XpPJVWf6V1GXVyJlSbxR9CjTkBxmlPxpvV25IsbVSsh0o
  109. aqkf2eWpbCL6Qb2E0jd1rvf8sGeTTohzYfiSVVsC2t9ngRO/CmetizwQBvRzLGMZ
  110. 4mtAPiy7ZEDc2dFrPp7zlKISYmJZUx/DJVuZWuOrVMpBP+bSgJXoMTlICxZUqUnE
  111. 2VKVStb/L+Tl8XCwIWdrZb9BaDnHqfcGAM2B4HNPxP88Yj1tEDly/vqeb3vVMhj+
  112. S1lunnLdgxp46YyuTMYAzj88eCGurRtzBsdxxlGAsioEnZGebEqAHQbieKq/DO6I
  113. MOMZHMSVBDqyyIx3assGlxSX8BSFW0lhKyT7i0XqnAgCJ9f/5oq0SbFGq+01VQb7
  114. jIx9PbcYJORxsE0JG/CXXPv27bRtQXsudkWGSYvC0NLOgk4z8+kQpQtyFh16lujq
  115. WRwMeriu0qNDjCa1/eHIKDovhAZ3GyO5/9m1tBlUZXN0IFVzZXIgPHRlc3RAdGVz
  116. dC5jb20+iQHOBBMBCAA4AhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAFiEEjrR8
  117. MQ4fJK44PYMvfN2AClLmXiYFAmDcEZEACgkQfN2AClLmXibZzgv/ZfeTpTuqQE1W
  118. C1jT5KpQExnt0BizTX0U7BvSn8Fr6VXTyol6kYc3u71GLUuJyawCLtIzOXqOXJvz
  119. bjcZqymcMADuftKcfMy513FhbF6MhdVd6QoeBP6+7/xXOFJCi+QVYF7SQ2h7K1Qm
  120. +yXOiAMgSxhCZQGPBNJLlDUOd47nSIMANvlumFtmLY/1FD7RpG7WQWjeX1mnxNTw
  121. hUU+Yv7GuFc/JprXCIYqHbhWfvXyVtae2ZK4xuVi5eqwA2RfggOVM7drb+CgPhG0
  122. +9aEDDLOZqVi65wK7J73Puo3rFTbPQMljxw5s27rWqF+vB6hhVdJOPNomWy3naPi
  123. k5MW0mhsacASz1WYndpZz+XaQTq/wJF5HUyyeUWJ0vlOEdwx021PHcqSTyfNnkjD
  124. KncrE21t2sxWRsgGDETxIwkd2b2HNGAvveUD0ffFK/oJHGSXjAERFGc3wuiDj3mQ
  125. BvKm4wt4QF9ZMrCdhMAA6ax5kfEUqQR4ntmrJk/khp/mV7TILaI4nQVYBGBjIyIB
  126. DADghIo9wXnRxzfdDTvwnP8dHpLAIaPokgdpyLswqUCixJWiW2xcV6weUjEWwH6n
  127. eN/t1uZYVehbrotxVPla+MPvzhxp6/cmG+2lhzEBOp6zRwnL1wIB6HoKJfpREhyM
  128. c8rLR0zMso1L1bJTyydvnu07a7BWo3VWKjilb0rEZZUSD/2hidx5HxMOJSoidLWe
  129. d/PPuv6yht3NtA4UThlcfldm9G6PbqCdm1kMEKAkq0wVJvhPJ6gEFRNJimgygfUw
  130. MDFXEIhQtxjgdV5Uoz3O5452VLoRsDlgpi3E0WDGj7WXDaO5uSU0T5aJgVgHCP/f
  131. xZhHuQFk2YYIl5nCBpOZyWWI0IKmscTuEwzpkhICQDQFvcMZ5ibsl7wA2P7YTrQf
  132. FDMjjzuaK80GYPfxDFlyKUyLqFt8w/QzsZLDLX7+jxIEpbRAaMw/JsWqm5BMxxbS
  133. 3CIQiS5S3oSKDsNINelqWFfwvLhvlQra8gIxyNTlek25OdgG66BiiX+seH8A/ql+
  134. F+MAEQEAAQAL/1jrNSLjMt9pwo6qFKClVQZP2vf7+sH7v7LeHIDXr3EnYUnVYnOq
  135. B1FU5PspTp/+J9W25DB9CZLx7Gj8qeslFdiuLSOoIBB4RCToB3kAoeTH0DHqW/Gs
  136. hFTrmJkuDp9zpo/ek6SIXJx5rHAyR9KVw0fizQprH2f6PcgLbTWeM61dJuqowmg3
  137. 7eCOyIKv7VQvFqEhYokLD+JNmrvg+Htg0DXGvdjRjAwPf/NezEXpj67a6cHTp1/C
  138. hwp7pevG+3fTxaCJFesl5/TxxtnaBLE8m2uo/S6Hxgn9l0edonroe1QlTjEqGLy2
  139. 7qi2z5Rem+v6GWNDRgvAWur13v8FNdyduHlioG/NgRsU9mE2MYeFsfi3cfNpJQp/
  140. wC9PSCIXrb/45mkS8KyjZpCrIPB9RV/m0MREq01TPom7rstZc4A1pD0Ot7AtUYS3
  141. e95zLyEmeLziPJ9fV4fgPmEudDr1uItnmV0LOskKlpg5sc0hhdrwYoobfkKt2dx6
  142. DqfMlcM1ZkUbLQYA4jwfpFJG4HmYvjL2xCJxM0ycjvMbqFN+4UjgYWVlRfOrm1V4
  143. Op86FjbRbV6OOCNhznotAg7mul4xtzrrTkK8o3YLBeJseDgl4AWuzXtNa9hE0XpK
  144. 9gJoEHUuBOOsamVh2HpXESFyE5CclOV7JSh541TlZKfnqfZYCg4JSbp0UijkawCL
  145. 5bJJUiGGMD9rZUxIAKQO1DvUEzptS7Jl6S3y5sbIIhilp4KfYWbSk3PPu9CnZD5b
  146. LhEQp0elxnb/IL8PBgD+DpTeC8unkGKXUpbe9x0ISI6V1D6FmJq/FxNg7fMa3QCh
  147. fGiAyoTm80ZETynj+blRaDO3gY4lTLa3Opubof1EqK2QmwXmpyvXEZNYcQfQ2CCS
  148. GOWUCK8jEQamUPf1PWndZXJUmROI1WukhlL71V/ir6zQeVCv1wcwPwclJPnAe87u
  149. pEklnCYpvsEldwHUX9u0BWzoULIEsi+ddtHmT0KTeF/DHRy0W15jIHbjFqhqckj1
  150. /6fmr7l7kIi/kN4vWe0F/0Q8IXX+cVMgbl3aIuaGcvENLGcoAsAtPGx88SfRgmfu
  151. HK64Y7hx1m+Bo215rxJzZRjqHTBPp0BmCi+JKkaavIBrYRbsx20gveI4dzhLcUhB
  152. kiT4Q7oz0/VbGHS1CEf9KFeS/YOGj57s4yHauSVI0XdP9kBRTWmXvBkzsooB2cKH
  153. hwhUN7iiT1k717CiTNUT6Q/pcPFCyNuMoBBGQTU206JEgIjQvI3f8xMUMGmGVVQz
  154. 9/k716ycnhb2JZ/Q/AyQIeHJiQG2BBgBCAAgAhsMFiEEjrR8MQ4fJK44PYMvfN2A
  155. ClLmXiYFAmDcEa4ACgkQfN2AClLmXiZxxQv/XaMN0hPCygtrQMbCsTNb34JbvJzh
  156. hngPuUAfTbRHrR3YeATyQofNbL0DD3fvfzeFF8qESqvzCSZxS6dYsXPd4MCJTzlp
  157. zYBZ2X0sOrgDqZvqCZKN72RKgdk0KvthdzAxsIm2dfcQOxxowXMxhJEXZmsFpusx
  158. jKJxOcrfVRjXJnh9isY0NpCoqMQ+3k3wDJ3VGEHV7G+A+vFkWfbLJF5huQ96uaH9
  159. Uc+jUsREUH9G82ZBqpoioEN8Ith4VXpYnKdTMonK/+ZcyeraJZhXrvbjnEomKdzU
  160. 0pu4bt1HlLR3dcnpjN7b009MBf2xLgEfQk2nPZ4zzY+tDkxygtPllaB4dldFjBpT
  161. j7Q+t49sWMjmlJUbLlHfuJ7nUUK5+cGjBsWVObAEcyfemHWCTVFnEa2BJslGC08X
  162. rFcjRRcMEr9ct4551QFBHsv3O/Wp3/wqczYgE9itSnGT05w+4vLt4smG+dnEHjRJ
  163. brMb2upTHa+kjktjdO96/BgSnKYqmNmPB/qB
  164. =ivA/
  165. -----END PGP PRIVATE KEY BLOCK-----
  166. """
  167. DEFAULT_KEY_ID = "8EB47C310E1F24AE383D832F7CDD800A52E65E26"
  168. NON_DEFAULT_KEY = """
  169. -----BEGIN PGP PRIVATE KEY BLOCK-----
  170. lQVYBGBjI0ABDADGWBRp+t02emfzUlhrc1psqIhhecFm6Em0Kv33cfDpnfoMF1tK
  171. Yy/4eLYIR7FmpdbFPcDThFNHbXJzBi00L1mp0XQE2l50h/2bDAAgREdZ+NVo5a7/
  172. RSZjauNU1PxW6pnXMehEh1tyIQmV78jAukaakwaicrpIenMiFUN3fAKHnLuFffA6
  173. t0f3LqJvTDhUw/o2vPgw5e6UDQhA1C+KTv1KXVrhJNo88a3hZqCZ76z3drKR411Q
  174. zYgT4DUb8lfnbN+z2wfqT9oM5cegh2k86/mxAA3BYOeQrhmQo/7uhezcgbxtdGZr
  175. YlbuaNDTSBrn10ZoaxLPo2dJe2zWxgD6MpvsGU1w3tcRW508qo/+xoWp2/pDzmok
  176. +uhOh1NAj9zB05VWBz1r7oBgCOIKpkD/LD4VKq59etsZ/UnrYDwKdXWZp7uhshkU
  177. M7N35lUJcR76a852dlMdrgpmY18+BP7+o7M+5ElHTiqQbMuE1nHTg8RgVpdV+tUx
  178. dg6GWY/XHf5asm8AEQEAAQAL/A85epOp+GnymmEQfI3+5D178D//Lwu9n86vECB6
  179. xAHCqQtdjZnXpDp/1YUsL59P8nzgYRk7SoMskQDoQ/cB/XFuDOhEdMSgHaTVlnrj
  180. ktCCq6rqGnUosyolbb64vIfVaSqd/5SnCStpAsnaBoBYrAu4ZmV4xfjDQWwn0q5s
  181. u+r56mD0SkjPgbwk/b3qTVagVmf2OFzUgWwm1e/X+bA1oPag1NV8VS4hZPXswT4f
  182. qhiyqUFOgP6vUBcqehkjkIDIl/54xII7/P5tp3LIZawvIXqHKNTqYPCqaCqCj+SL
  183. vMYDIb6acjescfZoM71eAeHAANeFZzr/rwfBT+dEP6qKmPXNcvgE11X44ZCr04nT
  184. zOV/uDUifEvKT5qgtyJpSFEVr7EXubJPKoNNhoYqq9z1pYU7IedX5BloiVXKOKTY
  185. 0pk7JkLqf3g5fYtXh/wol1owemITJy5V5PgaqZvk491LkI6S+kWC7ANYUg+TDPIW
  186. afxW3E5N1CYV6XDAl0ZihbLcoQYAy0Ky/p/wayWKePyuPBLwx9O89GSONK2pQljZ
  187. yaAgxPQ5/i1vx6LIMg7k/722bXR9W3zOjWOin4eatPM3d2hkG96HFvnBqXSmXOPV
  188. 03Xqy1/B5Tj8E9naLKUHE/OBQEc363DgLLG9db5HfPlpAngeppYPdyWkhzXyzkgS
  189. PylaE5eW3zkdjEbYJ6RBTecTZEgBaMvJNPdWbn//frpP7kGvyiCg5Es+WjLInUZ6
  190. 0sdifcNTCewzLXK80v/y5mVOdJhPBgD5zs9cYdyiQJayqAuOr+He1eMHMVUbm9as
  191. qBmPrst398eBW9ZYF7eBfTSlUf6B+WnvyLKEGsUf/7IK0EWDlzoBuWzWiHjUAY1g
  192. m9eTV2MnvCCCefqCErWwfFo2nWOasAZA9sKD+ICIBY4tbtvSl4yfLBzTMwSvs9ZS
  193. K1ocPSYUnhm2miSWZ8RLZPH7roHQasNHpyq/AX7DahFf2S/bJ+46ZGZ8Pigr7hA+
  194. MjmpQ4qVdb5SaViPmZhAKO+PjuCHm+EF/2H0Y3Sl4eXgxZWoQVOUeXdWg9eMfYrj
  195. XDtUMIFppV/QxbeztZKvJdfk64vt/crvLsOp0hOky9cKwY89r4QaHfexU3qR+qDq
  196. UlMvR1rHk7dS5HZAtw0xKsFJNkuDxvBkMqv8Los8zp3nUl+U99dfZOArzNkW38wx
  197. FPa0ixkC9za2BkDrWEA8vTnxw0A2upIFegDUhwOByrSyfPPnG3tKGeqt3Izb/kDk
  198. Q9vmo+HgxBOguMIvlzbBfQZwtbd/gXzlvPqCtCJBbm90aGVyIFRlc3QgVXNlciA8
  199. dGVzdDJAdGVzdC5jb20+iQHOBBMBCAA4AhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4B
  200. AheAFiEEapM5P1DF5qzT1vtFuTYhLttOFMAFAmDcEeEACgkQuTYhLttOFMDe0Qv/
  201. Qx/bzXztJ3BCc+CYAVDx7Kr37S68etwwLgcWzhG+CDeMB5F/QE+upKgxy2iaqQFR
  202. mxfOMgf/TIQkUfkbaASzK1LpnesYO85pk7XYjoN1bYEHiXTkeW+bgB6aJIxrRmO2
  203. SrWasdBC/DsI3Mrya8YMt/TiHC6VpRJVxCe5vv7/kZC4CXrgTBnZocXx/YXimbke
  204. poPMVdbvhYh6N0aGeS38jRKgyN10KXmhDTAQDwseVFavBWAjVfx3DEwjtK2Z2GbA
  205. aL8JvAwRtqiPFkDMIKPL4UwxtXFws8SpMt6juroUkNyf6+BxNWYqmwXHPy8zCJAb
  206. xkxIJMlEc+s7qQsP3fILOo8Xn+dVzJ5sa5AoARoXm1GMjsdqaKAzq99Dic/dHnaQ
  207. Civev1PQsdwlYW2C2wNXNeIrxMndbDMFfNuZ6BnGHWJ/wjcp/pFs4YkyyZN8JH7L
  208. hP2FO4Jgham3AuP13kC3Ivea7V6hR8QNcDZRwFPOMIX4tXwQv1T72+7DZGaA25O7
  209. nQVXBGBjI0ABDADJMBYIcG0Yil9YxFs7aYzNbd7alUAr89VbY8eIGPHP3INFPM1w
  210. lBQCu+4j6xdEbhMpppLBZ9A5TEylP4C6qLtPa+oLtPeuSw8gHDE10XE4lbgPs376
  211. rL60XdImSOHhiduACUefYjqpcmFH9Bim1CC+koArYrSQJQx1Jri+OpnTaL/8UID0
  212. KzD/kEgMVGlHIVj9oJmb4+j9pW8I/g0wDSnIaEKFMxqu6SIVJ1GWj+MUMvZigjLC
  213. sNCZd7PnbOC5VeU3SsXj6he74Jx0AmGMPWIHi9M0DjHO5d1cCbXTnud8xxM1bOh4
  214. 7aCTnMK5cVyIr+adihgJpVVhrndSM8aklBPRgtozrGNCgF2CkYU2P1blxfloNr/8
  215. UZpM83o+s1aObBszzRNLxnpNORqoLqjfPtLEPQnagxE+4EapCq0NZ/x6yO5VTwwp
  216. NljdFAEk40uGuKyn1QA3uNMHy5DlpLl+tU7t1KEovdZ+OVYsYKZhVzw0MTpKogk9
  217. JI7AN0q62ronPskAEQEAAQAL+O8BUSt1ZCVjPSIXIsrR+ZOSkszZwgJ1CWIoh0IH
  218. YD2vmcMHGIhFYgBdgerpvhptKhaw7GcXDScEnYkyh5s4GE2hxclik1tbj/x1gYCN
  219. 8BNoyeDdPFxQG73qN12D99QYEctpOsz9xPLIDwmL0j1ehAfhwqHIAPm9Ca+i8JYM
  220. x/F+35S/jnKDXRI+NVlwbiEyXKXxxIqNlpy9i8sDBGexO5H5Sg0zSN/B1duLekGD
  221. biDw6gLc6bCgnS+0JOUpU07Z2fccMOY9ncjKGD2uIb/ePPUaek92GCQyq0eorCIV
  222. brcQsRc5sSsNtnRKQTQtxioROeDg7kf2oWySeHTswlXW/219ihrSXgteHJd+rPm7
  223. DYLEeGLRny8bRKv8rQdAtApHaJE4dAATXeY4RYo4NlXHYaztGYtU6kiM/3zCfWAe
  224. 9Nn+Wh9jMTZrjefUCagS5r6ZqAh7veNo/vgIGaCLh0a1Ypa0Yk9KFrn3LYEM3zgk
  225. 3m3bn+7qgy5cUYXoJ3DGJJEhBgDPonpW0WElqLs5ZMem1ha85SC38F0IkAaSuzuz
  226. v3eORiKWuyJGF32Q2XHa1RHQs1JtUKd8rxFer3b8Oq71zLz6JtVc9dmRudvgcJYX
  227. 0PC11F6WGjZFSSp39dajFp0A5DKUs39F3w7J1yuDM56TDIN810ywufGAHARY1pZb
  228. UJAy/dTqjFnCbNjpAakor3hVzqxcmUG+7Y2X9c2AGncT1MqAQC3M8JZcuZvkK8A9
  229. cMk8B914ryYE7VsZMdMhyTwHmykGAPgNLLa3RDETeGeGCKWI+ZPOoU0ib5JtJZ1d
  230. P3tNwfZKuZBZXKW9gqYqyBa/qhMip84SP30pr/TvulcdAFC759HK8sQZyJ6Vw24P
  231. c+5ssRxrQUEw1rvJPWhmQCmCOZHBMQl5T6eaTOpR5u3aUKTMlxPKhK9eC1dCSTnI
  232. /nyL8An3VKnLy+K/LI42YGphBVLLJmBewuTVDIJviWRdntiG8dElyEJMOywUltk3
  233. 2CEmqgsD9tPO8rXZjnMrMn3gfsiaoQYA6/6/e2utkHr7gAoWBgrBBdqVHsvqh5Ro
  234. 2DjLAOpZItO/EdCJfDAmbTYOa04535sBDP2tcH/vipPOPpbr1Y9Y/mNsKCulNxed
  235. yqAmEkKOcerLUP5UHju0AB6VBjHJFdU2mqT+UjPyBk7WeKXgFomyoYMv3KpNOFWR
  236. xi0Xji4kKHbttA6Hy3UcGPr9acyUAlDYeKmxbSUYIPhw32bbGrX9+F5YriTufRsG
  237. 3jftQVo9zqdcQSD/5pUTMn3EYbEcohYB2YWJAbYEGAEIACACGwwWIQRqkzk/UMXm
  238. rNPW+0W5NiEu204UwAUCYNwR6wAKCRC5NiEu204UwOPnC/92PgB1c3h9FBXH1maz
  239. g29fndHIHH65VLgqMiQ7HAMojwRlT5Xnj5tdkCBmszRkv5vMvdJRa3ZY8Ed/Inqr
  240. hxBFNzpjqX4oj/RYIQLKXWWfkTKYVLJFZFPCSo00jesw2gieu3Ke/Yy4gwhtNodA
  241. v+s6QNMvffTW/K3XNrWDB0E7/LXbdidzhm+MBu8ov2tuC3tp9liLICiE1jv/2xT4
  242. CNSO6yphmk1/1zEYHS/mN9qJ2csBmte2cdmGyOcuVEHk3pyINNMDOamaURBJGRwF
  243. XB5V7gTKUFU4jCp3chywKrBHJHxGGDUmPBmZtDtfWAOgL32drK7/KUyzZL/WO7Fj
  244. akOI0hRDFOcqTYWL20H7+hAiX3oHMP7eou3L5C7wJ9+JMcACklN/WMjG9a536DFJ
  245. 4UgZ6HyKPP+wy837Hbe8b25kNMBwFgiaLR0lcgzxj7NyQWjVCMOEN+M55tRCjvL6
  246. ya6JVZCRbMXfdCy8lVPgtNQ6VlHaj8Wvnn2FLbWWO2n2r3s=
  247. =9zU5
  248. -----END PGP PRIVATE KEY BLOCK-----
  249. """
  250. NON_DEFAULT_KEY_ID = "6A93393F50C5E6ACD3D6FB45B936212EDB4E14C0"
  251. def setUp(self) -> None:
  252. super().setUp()
  253. self.gpg_dir = os.path.join(self.test_dir, "gpg")
  254. os.mkdir(self.gpg_dir, mode=0o700)
  255. # Ignore errors when deleting GNUPGHOME, because of race conditions
  256. # (e.g. the gpg-agent socket having been deleted). See
  257. # https://github.com/jelmer/dulwich/issues/1000
  258. self.addCleanup(shutil.rmtree, self.gpg_dir, ignore_errors=True)
  259. self.overrideEnv("GNUPGHOME", self.gpg_dir)
  260. def import_default_key(self) -> None:
  261. subprocess.run(
  262. ["gpg", "--import"],
  263. stdout=subprocess.DEVNULL,
  264. stderr=subprocess.DEVNULL,
  265. input=PorcelainGpgTestCase.DEFAULT_KEY,
  266. text=True,
  267. )
  268. def import_non_default_key(self) -> None:
  269. subprocess.run(
  270. ["gpg", "--import"],
  271. stdout=subprocess.DEVNULL,
  272. stderr=subprocess.DEVNULL,
  273. input=PorcelainGpgTestCase.NON_DEFAULT_KEY,
  274. text=True,
  275. )
  276. class ArchiveTests(PorcelainTestCase):
  277. """Tests for the archive command."""
  278. def test_simple(self) -> None:
  279. _c1, _c2, c3 = build_commit_graph(
  280. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  281. )
  282. self.repo.refs[b"refs/heads/master"] = c3.id
  283. out = BytesIO()
  284. err = BytesIO()
  285. porcelain.archive(
  286. self.repo.path, b"refs/heads/master", outstream=out, errstream=err
  287. )
  288. self.assertEqual(b"", err.getvalue())
  289. tf = tarfile.TarFile(fileobj=out)
  290. self.addCleanup(tf.close)
  291. self.assertEqual([], tf.getnames())
  292. class UpdateServerInfoTests(PorcelainTestCase):
  293. def test_simple(self) -> None:
  294. _c1, _c2, c3 = build_commit_graph(
  295. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  296. )
  297. self.repo.refs[b"refs/heads/foo"] = c3.id
  298. porcelain.update_server_info(self.repo.path)
  299. self.assertTrue(
  300. os.path.exists(os.path.join(self.repo.controldir(), "info", "refs"))
  301. )
  302. class CommitTests(PorcelainTestCase):
  303. def test_custom_author(self) -> None:
  304. _c1, _c2, c3 = build_commit_graph(
  305. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  306. )
  307. self.repo.refs[b"refs/heads/foo"] = c3.id
  308. sha = porcelain.commit(
  309. self.repo.path,
  310. message=b"Some message",
  311. author=b"Joe <joe@example.com>",
  312. committer=b"Bob <bob@example.com>",
  313. )
  314. self.assertIsInstance(sha, bytes)
  315. self.assertEqual(len(sha), 40)
  316. def test_unicode(self) -> None:
  317. _c1, _c2, c3 = build_commit_graph(
  318. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  319. )
  320. self.repo.refs[b"refs/heads/foo"] = c3.id
  321. sha = porcelain.commit(
  322. self.repo.path,
  323. message="Some message",
  324. author="Joe <joe@example.com>",
  325. committer="Bob <bob@example.com>",
  326. )
  327. self.assertIsInstance(sha, bytes)
  328. self.assertEqual(len(sha), 40)
  329. def test_no_verify(self) -> None:
  330. if os.name != "posix":
  331. self.skipTest("shell hook tests requires POSIX shell")
  332. self.assertTrue(os.path.exists("/bin/sh"))
  333. hooks_dir = os.path.join(self.repo.controldir(), "hooks")
  334. os.makedirs(hooks_dir, exist_ok=True)
  335. self.addCleanup(shutil.rmtree, hooks_dir)
  336. _c1, _c2, _c3 = build_commit_graph(
  337. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  338. )
  339. hook_fail = "#!/bin/sh\nexit 1"
  340. # hooks are executed in pre-commit, commit-msg order
  341. # test commit-msg failure first, then pre-commit failure, then
  342. # no_verify to skip both hooks
  343. commit_msg = os.path.join(hooks_dir, "commit-msg")
  344. with open(commit_msg, "w") as f:
  345. f.write(hook_fail)
  346. os.chmod(commit_msg, stat.S_IREAD | stat.S_IWRITE | stat.S_IEXEC)
  347. with self.assertRaises(CommitError):
  348. porcelain.commit(
  349. self.repo.path,
  350. message="Some message",
  351. author="Joe <joe@example.com>",
  352. committer="Bob <bob@example.com>",
  353. )
  354. pre_commit = os.path.join(hooks_dir, "pre-commit")
  355. with open(pre_commit, "w") as f:
  356. f.write(hook_fail)
  357. os.chmod(pre_commit, stat.S_IREAD | stat.S_IWRITE | stat.S_IEXEC)
  358. with self.assertRaises(CommitError):
  359. porcelain.commit(
  360. self.repo.path,
  361. message="Some message",
  362. author="Joe <joe@example.com>",
  363. committer="Bob <bob@example.com>",
  364. )
  365. sha = porcelain.commit(
  366. self.repo.path,
  367. message="Some message",
  368. author="Joe <joe@example.com>",
  369. committer="Bob <bob@example.com>",
  370. no_verify=True,
  371. )
  372. self.assertIsInstance(sha, bytes)
  373. self.assertEqual(len(sha), 40)
  374. def test_timezone(self) -> None:
  375. _c1, _c2, c3 = build_commit_graph(
  376. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  377. )
  378. self.repo.refs[b"refs/heads/foo"] = c3.id
  379. sha = porcelain.commit(
  380. self.repo.path,
  381. message="Some message",
  382. author="Joe <joe@example.com>",
  383. author_timezone=18000,
  384. committer="Bob <bob@example.com>",
  385. commit_timezone=18000,
  386. )
  387. self.assertIsInstance(sha, bytes)
  388. self.assertEqual(len(sha), 40)
  389. commit = self.repo.get_object(sha)
  390. assert isinstance(commit, Commit)
  391. self.assertEqual(commit._author_timezone, 18000)
  392. self.assertEqual(commit._commit_timezone, 18000)
  393. self.overrideEnv("GIT_AUTHOR_DATE", "1995-11-20T19:12:08-0501")
  394. self.overrideEnv("GIT_COMMITTER_DATE", "1995-11-20T19:12:08-0501")
  395. sha = porcelain.commit(
  396. self.repo.path,
  397. message="Some message",
  398. author="Joe <joe@example.com>",
  399. committer="Bob <bob@example.com>",
  400. )
  401. self.assertIsInstance(sha, bytes)
  402. self.assertEqual(len(sha), 40)
  403. commit = self.repo.get_object(sha)
  404. assert isinstance(commit, Commit)
  405. self.assertEqual(commit._author_timezone, -18060)
  406. self.assertEqual(commit._commit_timezone, -18060)
  407. self.overrideEnv("GIT_AUTHOR_DATE", None)
  408. self.overrideEnv("GIT_COMMITTER_DATE", None)
  409. local_timezone = time.localtime().tm_gmtoff
  410. sha = porcelain.commit(
  411. self.repo.path,
  412. message="Some message",
  413. author="Joe <joe@example.com>",
  414. committer="Bob <bob@example.com>",
  415. )
  416. self.assertIsInstance(sha, bytes)
  417. self.assertEqual(len(sha), 40)
  418. commit = self.repo.get_object(sha)
  419. assert isinstance(commit, Commit)
  420. self.assertEqual(commit._author_timezone, local_timezone)
  421. self.assertEqual(commit._commit_timezone, local_timezone)
  422. def test_commit_all(self) -> None:
  423. # Create initial commit
  424. filename = os.path.join(self.repo.path, "test.txt")
  425. with open(filename, "wb") as f:
  426. f.write(b"initial content")
  427. porcelain.add(self.repo.path, paths=["test.txt"])
  428. initial_sha = porcelain.commit(self.repo.path, message=b"Initial commit")
  429. # Modify the file without staging
  430. with open(filename, "wb") as f:
  431. f.write(b"modified content")
  432. # Create an untracked file
  433. untracked_file = os.path.join(self.repo.path, "untracked.txt")
  434. with open(untracked_file, "wb") as f:
  435. f.write(b"untracked content")
  436. # Commit with all=True should stage modified files but not untracked
  437. sha = porcelain.commit(self.repo.path, message=b"Modified commit", all=True)
  438. self.assertIsInstance(sha, bytes)
  439. self.assertEqual(len(sha), 40)
  440. self.assertNotEqual(sha, initial_sha)
  441. # Verify the commit contains the modification
  442. commit = self.repo.get_object(sha)
  443. assert isinstance(commit, Commit)
  444. tree = self.repo.get_object(commit.tree)
  445. # The modified file should be in the commit
  446. self.assertIn(b"test.txt", tree)
  447. # The untracked file should not be in the commit
  448. self.assertNotIn(b"untracked.txt", tree)
  449. def test_commit_all_no_changes(self) -> None:
  450. # Create initial commit
  451. filename = os.path.join(self.repo.path, "test.txt")
  452. with open(filename, "wb") as f:
  453. f.write(b"initial content")
  454. porcelain.add(self.repo.path, paths=["test.txt"])
  455. initial_sha = porcelain.commit(self.repo.path, message=b"Initial commit")
  456. # Try to commit with all=True when there are no unstaged changes
  457. sha = porcelain.commit(self.repo.path, message=b"No changes commit", all=True)
  458. self.assertIsInstance(sha, bytes)
  459. self.assertEqual(len(sha), 40)
  460. self.assertNotEqual(sha, initial_sha)
  461. def test_commit_all_multiple_files(self) -> None:
  462. # Create initial commit with multiple files
  463. file1 = os.path.join(self.repo.path, "file1.txt")
  464. file2 = os.path.join(self.repo.path, "file2.txt")
  465. with open(file1, "wb") as f:
  466. f.write(b"content1")
  467. with open(file2, "wb") as f:
  468. f.write(b"content2")
  469. porcelain.add(self.repo.path, paths=["file1.txt", "file2.txt"])
  470. initial_sha = porcelain.commit(self.repo.path, message=b"Initial commit")
  471. # Modify both files
  472. with open(file1, "wb") as f:
  473. f.write(b"modified content1")
  474. with open(file2, "wb") as f:
  475. f.write(b"modified content2")
  476. # Commit with all=True should stage both modified files
  477. sha = porcelain.commit(self.repo.path, message=b"Modified both files", all=True)
  478. self.assertIsInstance(sha, bytes)
  479. self.assertEqual(len(sha), 40)
  480. self.assertNotEqual(sha, initial_sha)
  481. # Verify both modifications are in the commit
  482. commit = self.repo.get_object(sha)
  483. assert isinstance(commit, Commit)
  484. tree = self.repo.get_object(commit.tree)
  485. self.assertIn(b"file1.txt", tree)
  486. self.assertIn(b"file2.txt", tree)
  487. def test_commit_amend_message(self) -> None:
  488. # Create initial commit
  489. filename = os.path.join(self.repo.path, "test.txt")
  490. with open(filename, "wb") as f:
  491. f.write(b"initial content")
  492. porcelain.add(self.repo.path, paths=["test.txt"])
  493. original_sha = porcelain.commit(self.repo.path, message=b"Original commit")
  494. # Amend with new message
  495. amended_sha = porcelain.commit(
  496. self.repo.path, message=b"Amended commit", amend=True
  497. )
  498. self.assertIsInstance(amended_sha, bytes)
  499. self.assertEqual(len(amended_sha), 40)
  500. self.assertNotEqual(amended_sha, original_sha)
  501. # Check that the amended commit has the new message
  502. amended_commit = self.repo.get_object(amended_sha)
  503. assert isinstance(amended_commit, Commit)
  504. self.assertEqual(amended_commit.message, b"Amended commit")
  505. # Check that the amended commit uses the original commit's parents
  506. original_commit = self.repo.get_object(original_sha)
  507. assert isinstance(original_commit, Commit)
  508. # Since this was the first commit, it should have no parents,
  509. # and the amended commit should also have no parents
  510. self.assertEqual(amended_commit.parents, original_commit.parents)
  511. def test_commit_amend_no_message(self) -> None:
  512. # Create initial commit
  513. filename = os.path.join(self.repo.path, "test.txt")
  514. with open(filename, "wb") as f:
  515. f.write(b"initial content")
  516. porcelain.add(self.repo.path, paths=["test.txt"])
  517. original_sha = porcelain.commit(self.repo.path, message=b"Original commit")
  518. # Modify file and stage it
  519. with open(filename, "wb") as f:
  520. f.write(b"modified content")
  521. porcelain.add(self.repo.path, paths=["test.txt"])
  522. # Amend without providing message (should reuse original message)
  523. amended_sha = porcelain.commit(self.repo.path, amend=True)
  524. self.assertIsInstance(amended_sha, bytes)
  525. self.assertEqual(len(amended_sha), 40)
  526. self.assertNotEqual(amended_sha, original_sha)
  527. # Check that the amended commit has the original message
  528. amended_commit = self.repo.get_object(amended_sha)
  529. assert isinstance(amended_commit, Commit)
  530. self.assertEqual(amended_commit.message, b"Original commit")
  531. def test_commit_amend_no_existing_commit(self) -> None:
  532. # Try to amend when there's no existing commit
  533. with self.assertRaises(ValueError) as cm:
  534. porcelain.commit(self.repo.path, message=b"Should fail", amend=True)
  535. self.assertIn("Cannot amend: no existing commit found", str(cm.exception))
  536. @skipIf(
  537. platform.python_implementation() == "PyPy" or sys.platform == "win32",
  538. "gpgme not easily available or supported on Windows and PyPy",
  539. )
  540. class CommitSignTests(PorcelainGpgTestCase):
  541. def test_default_key(self) -> None:
  542. _c1, _c2, c3 = build_commit_graph(
  543. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  544. )
  545. self.repo.refs[b"HEAD"] = c3.id
  546. cfg = self.repo.get_config()
  547. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  548. self.import_default_key()
  549. sha = porcelain.commit(
  550. self.repo.path,
  551. message="Some message",
  552. author="Joe <joe@example.com>",
  553. committer="Bob <bob@example.com>",
  554. sign=True,
  555. )
  556. self.assertIsInstance(sha, bytes)
  557. self.assertEqual(len(sha), 40)
  558. commit = self.repo.get_object(sha)
  559. assert isinstance(commit, Commit)
  560. # GPG Signatures aren't deterministic, so we can't do a static assertion.
  561. from dulwich.signature import (
  562. BadSignature,
  563. UntrustedSignature,
  564. get_signature_vendor_for_signature,
  565. )
  566. self.assertIsNotNone(commit.gpgsig)
  567. vendor = get_signature_vendor_for_signature(commit.gpgsig)
  568. vendor.verify(commit.raw_without_sig(), commit.gpgsig)
  569. # Verify with specific keyid
  570. vendor_with_keyid = get_signature_vendor_for_signature(
  571. commit.gpgsig, keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID]
  572. )
  573. vendor_with_keyid.verify(commit.raw_without_sig(), commit.gpgsig)
  574. self.import_non_default_key()
  575. # Verify with wrong keyid - should raise UntrustedSignature
  576. vendor_wrong_keyid = get_signature_vendor_for_signature(
  577. commit.gpgsig, keyids=[PorcelainGpgTestCase.NON_DEFAULT_KEY_ID]
  578. )
  579. self.assertRaises(
  580. UntrustedSignature,
  581. vendor_wrong_keyid.verify,
  582. commit.raw_without_sig(),
  583. commit.gpgsig,
  584. )
  585. assert isinstance(commit, Commit)
  586. commit.committer = b"Alice <alice@example.com>"
  587. self.assertRaises(
  588. BadSignature,
  589. vendor.verify,
  590. commit.raw_without_sig(),
  591. commit.gpgsig,
  592. )
  593. def test_non_default_key(self) -> None:
  594. _c1, _c2, c3 = build_commit_graph(
  595. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  596. )
  597. self.repo.refs[b"HEAD"] = c3.id
  598. cfg = self.repo.get_config()
  599. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  600. self.import_non_default_key()
  601. sha = porcelain.commit(
  602. self.repo.path,
  603. message="Some message",
  604. author="Joe <joe@example.com>",
  605. committer="Bob <bob@example.com>",
  606. sign=PorcelainGpgTestCase.NON_DEFAULT_KEY_ID,
  607. )
  608. self.assertIsInstance(sha, bytes)
  609. self.assertEqual(len(sha), 40)
  610. commit = self.repo.get_object(sha)
  611. assert isinstance(commit, Commit)
  612. # GPG Signatures aren't deterministic, so we can't do a static assertion.
  613. from dulwich.signature import get_signature_vendor_for_signature
  614. self.assertIsNotNone(commit.gpgsig)
  615. vendor = get_signature_vendor_for_signature(commit.gpgsig)
  616. vendor.verify(commit.raw_without_sig(), commit.gpgsig)
  617. def test_sign_uses_config_signingkey(self) -> None:
  618. """Test that sign=True uses user.signingKey from config."""
  619. _c1, _c2, c3 = build_commit_graph(
  620. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  621. )
  622. self.repo.refs[b"HEAD"] = c3.id
  623. # Set up user.signingKey in config
  624. cfg = self.repo.get_config()
  625. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  626. cfg.write_to_path()
  627. self.import_default_key()
  628. # Create commit with sign=True (should use signingKey from config)
  629. sha = porcelain.commit(
  630. self.repo.path,
  631. message="Signed with configured key",
  632. author="Joe <joe@example.com>",
  633. committer="Bob <bob@example.com>",
  634. sign=True, # This should read user.signingKey from config
  635. )
  636. self.assertIsInstance(sha, bytes)
  637. self.assertEqual(len(sha), 40)
  638. commit = self.repo.get_object(sha)
  639. assert isinstance(commit, Commit)
  640. # Verify the commit is signed with the configured key
  641. from dulwich.signature import get_signature_vendor_for_signature
  642. self.assertIsNotNone(commit.gpgsig)
  643. vendor = get_signature_vendor_for_signature(commit.gpgsig)
  644. vendor.verify(commit.raw_without_sig(), commit.gpgsig)
  645. # Verify with specific keyid
  646. vendor_with_keyid = get_signature_vendor_for_signature(
  647. commit.gpgsig, keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID]
  648. )
  649. vendor_with_keyid.verify(commit.raw_without_sig(), commit.gpgsig)
  650. def test_commit_gpg_sign_config_enabled(self) -> None:
  651. """Test that commit.gpgSign=true automatically signs commits."""
  652. _c1, _c2, c3 = build_commit_graph(
  653. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  654. )
  655. self.repo.refs[b"HEAD"] = c3.id
  656. # Set up user.signingKey and commit.gpgSign in config
  657. cfg = self.repo.get_config()
  658. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  659. cfg.set(("commit",), "gpgSign", True)
  660. cfg.write_to_path()
  661. self.import_default_key()
  662. # Create commit without explicit signoff parameter (should auto-sign due to config)
  663. sha = porcelain.commit(
  664. self.repo.path,
  665. message="Auto-signed commit",
  666. author="Joe <joe@example.com>",
  667. committer="Bob <bob@example.com>",
  668. # No signoff parameter - should use commit.gpgSign config
  669. )
  670. self.assertIsInstance(sha, bytes)
  671. self.assertEqual(len(sha), 40)
  672. commit = self.repo.get_object(sha)
  673. assert isinstance(commit, Commit)
  674. # Verify the commit is signed due to config
  675. from dulwich.signature import get_signature_vendor_for_signature
  676. self.assertIsNotNone(commit.gpgsig)
  677. vendor = get_signature_vendor_for_signature(commit.gpgsig)
  678. vendor.verify(commit.raw_without_sig(), commit.gpgsig)
  679. # Verify with specific keyid
  680. vendor_with_keyid = get_signature_vendor_for_signature(
  681. commit.gpgsig, keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID]
  682. )
  683. vendor_with_keyid.verify(commit.raw_without_sig(), commit.gpgsig)
  684. def test_commit_gpg_sign_config_disabled(self) -> None:
  685. """Test that commit.gpgSign=false does not sign commits."""
  686. _c1, _c2, c3 = build_commit_graph(
  687. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  688. )
  689. self.repo.refs[b"HEAD"] = c3.id
  690. # Set up user.signingKey and commit.gpgSign=false in config
  691. cfg = self.repo.get_config()
  692. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  693. cfg.set(("commit",), "gpgSign", False)
  694. cfg.write_to_path()
  695. self.import_default_key()
  696. # Create commit without explicit signoff parameter (should not sign)
  697. sha = porcelain.commit(
  698. self.repo.path,
  699. message="Unsigned commit",
  700. author="Joe <joe@example.com>",
  701. committer="Bob <bob@example.com>",
  702. # No signoff parameter - should use commit.gpgSign=false config
  703. )
  704. self.assertIsInstance(sha, bytes)
  705. self.assertEqual(len(sha), 40)
  706. commit = self.repo.get_object(sha)
  707. assert isinstance(commit, Commit)
  708. # Verify the commit is not signed
  709. self.assertIsNone(commit._gpgsig)
  710. def test_commit_gpg_sign_config_no_signing_key(self) -> None:
  711. """Test that commit.gpgSign=true works without user.signingKey (uses default)."""
  712. _c1, _c2, c3 = build_commit_graph(
  713. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  714. )
  715. self.repo.refs[b"HEAD"] = c3.id
  716. # Set up commit.gpgSign but no user.signingKey
  717. cfg = self.repo.get_config()
  718. cfg.set(("commit",), "gpgSign", True)
  719. cfg.write_to_path()
  720. self.import_default_key()
  721. # Create commit without explicit signoff parameter (should auto-sign with default key)
  722. sha = porcelain.commit(
  723. self.repo.path,
  724. message="Default signed commit",
  725. author="Joe <joe@example.com>",
  726. committer="Bob <bob@example.com>",
  727. # No signoff parameter - should use commit.gpgSign config with default key
  728. )
  729. self.assertIsInstance(sha, bytes)
  730. self.assertEqual(len(sha), 40)
  731. commit = self.repo.get_object(sha)
  732. assert isinstance(commit, Commit)
  733. # Verify the commit is signed with default key
  734. from dulwich.signature import get_signature_vendor_for_signature
  735. self.assertIsNotNone(commit.gpgsig)
  736. vendor = get_signature_vendor_for_signature(commit.gpgsig)
  737. vendor.verify(commit.raw_without_sig(), commit.gpgsig)
  738. def test_explicit_signoff_overrides_config(self) -> None:
  739. """Test that explicit signoff parameter overrides commit.gpgSign config."""
  740. _c1, _c2, c3 = build_commit_graph(
  741. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  742. )
  743. self.repo.refs[b"HEAD"] = c3.id
  744. # Set up commit.gpgSign=false but explicitly pass sign=True
  745. cfg = self.repo.get_config()
  746. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  747. cfg.set(("commit",), "gpgSign", False)
  748. cfg.write_to_path()
  749. self.import_default_key()
  750. # Create commit with explicit sign=True (should override config)
  751. sha = porcelain.commit(
  752. self.repo.path,
  753. message="Explicitly signed commit",
  754. author="Joe <joe@example.com>",
  755. committer="Bob <bob@example.com>",
  756. sign=True, # This should override commit.gpgSign=false
  757. )
  758. self.assertIsInstance(sha, bytes)
  759. self.assertEqual(len(sha), 40)
  760. commit = self.repo.get_object(sha)
  761. assert isinstance(commit, Commit)
  762. # Verify the commit is signed despite config=false
  763. from dulwich.signature import get_signature_vendor_for_signature
  764. self.assertIsNotNone(commit.gpgsig)
  765. vendor = get_signature_vendor_for_signature(commit.gpgsig)
  766. vendor.verify(commit.raw_without_sig(), commit.gpgsig)
  767. # Verify with specific keyid
  768. vendor_with_keyid = get_signature_vendor_for_signature(
  769. commit.gpgsig, keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID]
  770. )
  771. vendor_with_keyid.verify(commit.raw_without_sig(), commit.gpgsig)
  772. def test_explicit_false_disables_signing(self) -> None:
  773. """Test that explicit signoff=False disables signing even with config=true."""
  774. _c1, _c2, c3 = build_commit_graph(
  775. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  776. )
  777. self.repo.refs[b"HEAD"] = c3.id
  778. # Set up commit.gpgSign=true but explicitly pass sign=False
  779. cfg = self.repo.get_config()
  780. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  781. cfg.set(("commit",), "gpgSign", True)
  782. cfg.write_to_path()
  783. self.import_default_key()
  784. # Create commit with explicit sign=False (should disable signing)
  785. sha = porcelain.commit(
  786. self.repo.path,
  787. message="Explicitly unsigned commit",
  788. author="Joe <joe@example.com>",
  789. committer="Bob <bob@example.com>",
  790. sign=False, # This should override commit.gpgSign=true
  791. )
  792. self.assertIsInstance(sha, bytes)
  793. self.assertEqual(len(sha), 40)
  794. commit = self.repo.get_object(sha)
  795. assert isinstance(commit, Commit)
  796. # Verify the commit is NOT signed despite config=true
  797. self.assertIsNone(commit._gpgsig)
  798. @skipIf(
  799. platform.python_implementation() == "PyPy" or sys.platform == "win32",
  800. "gpgme not easily available or supported on Windows and PyPy",
  801. )
  802. class VerifyCommitTests(PorcelainGpgTestCase):
  803. def test_verify_commit_valid_signature(self) -> None:
  804. """Test verifying a commit with a valid GPG signature."""
  805. _c1, _c2, c3 = build_commit_graph(
  806. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  807. )
  808. self.repo.refs[b"HEAD"] = c3.id
  809. cfg = self.repo.get_config()
  810. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  811. cfg.write_to_path()
  812. self.import_default_key()
  813. # Create a signed commit
  814. sha = porcelain.commit(
  815. self.repo.path,
  816. message="Signed commit",
  817. author="Joe <joe@example.com>",
  818. committer="Bob <bob@example.com>",
  819. sign=True,
  820. )
  821. # Verify should not raise
  822. porcelain.verify_commit(self.repo.path, sha)
  823. porcelain.verify_commit(
  824. self.repo.path, sha, keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID]
  825. )
  826. def test_verify_commit_with_wrong_key(self) -> None:
  827. """Test that verifying with wrong keyid raises UntrustedSignature."""
  828. from dulwich.signature import UntrustedSignature
  829. _c1, _c2, c3 = build_commit_graph(
  830. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  831. )
  832. self.repo.refs[b"HEAD"] = c3.id
  833. cfg = self.repo.get_config()
  834. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  835. cfg.write_to_path()
  836. self.import_default_key()
  837. sha = porcelain.commit(
  838. self.repo.path,
  839. message="Signed commit",
  840. author="Joe <joe@example.com>",
  841. committer="Bob <bob@example.com>",
  842. sign=True,
  843. )
  844. self.import_non_default_key()
  845. self.assertRaises(
  846. UntrustedSignature,
  847. porcelain.verify_commit,
  848. self.repo.path,
  849. sha,
  850. keyids=[PorcelainGpgTestCase.NON_DEFAULT_KEY_ID],
  851. )
  852. def test_verify_commit_unsigned(self) -> None:
  853. """Test that verifying an unsigned commit succeeds (no signature to verify)."""
  854. _c1, _c2, c3 = build_commit_graph(
  855. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  856. )
  857. self.repo.refs[b"HEAD"] = c3.id
  858. sha = porcelain.commit(
  859. self.repo.path,
  860. message="Unsigned commit",
  861. author="Joe <joe@example.com>",
  862. committer="Bob <bob@example.com>",
  863. sign=False,
  864. )
  865. # Verify should not raise for unsigned commits
  866. porcelain.verify_commit(self.repo.path, sha)
  867. @skipIf(
  868. platform.python_implementation() == "PyPy" or sys.platform == "win32",
  869. "gpgme not easily available or supported on Windows and PyPy",
  870. )
  871. class VerifyTagTests(PorcelainGpgTestCase):
  872. def test_verify_tag_valid_signature(self) -> None:
  873. """Test verifying a tag with a valid GPG signature."""
  874. _c1, _c2, c3 = build_commit_graph(
  875. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  876. )
  877. self.repo.refs[b"HEAD"] = c3.id
  878. cfg = self.repo.get_config()
  879. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  880. cfg.write_to_path()
  881. self.import_default_key()
  882. # Create a signed tag
  883. porcelain.tag_create(
  884. self.repo.path,
  885. b"signed-tag",
  886. b"Tagger <tagger@example.com>",
  887. b"Signed tag message",
  888. annotated=True,
  889. sign=True,
  890. )
  891. # Verify should not raise
  892. porcelain.verify_tag(self.repo.path, b"signed-tag")
  893. porcelain.verify_tag(
  894. self.repo.path, b"signed-tag", keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID]
  895. )
  896. def test_verify_tag_with_wrong_key(self) -> None:
  897. """Test that verifying with wrong keyid raises UntrustedSignature."""
  898. from dulwich.signature import UntrustedSignature
  899. _c1, _c2, c3 = build_commit_graph(
  900. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  901. )
  902. self.repo.refs[b"HEAD"] = c3.id
  903. cfg = self.repo.get_config()
  904. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  905. cfg.write_to_path()
  906. self.import_default_key()
  907. porcelain.tag_create(
  908. self.repo.path,
  909. b"signed-tag",
  910. b"Tagger <tagger@example.com>",
  911. b"Signed tag message",
  912. annotated=True,
  913. sign=True,
  914. )
  915. self.import_non_default_key()
  916. self.assertRaises(
  917. UntrustedSignature,
  918. porcelain.verify_tag,
  919. self.repo.path,
  920. b"signed-tag",
  921. keyids=[PorcelainGpgTestCase.NON_DEFAULT_KEY_ID],
  922. )
  923. def test_verify_tag_unsigned(self) -> None:
  924. """Test that verifying an unsigned tag succeeds (no signature to verify)."""
  925. _c1, _c2, c3 = build_commit_graph(
  926. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  927. )
  928. self.repo.refs[b"HEAD"] = c3.id
  929. porcelain.tag_create(
  930. self.repo.path,
  931. b"unsigned-tag",
  932. b"Tagger <tagger@example.com>",
  933. b"Unsigned tag message",
  934. annotated=True,
  935. sign=False,
  936. )
  937. # Verify should not raise for unsigned tags
  938. porcelain.verify_tag(self.repo.path, b"unsigned-tag")
  939. class TimezoneTests(PorcelainTestCase):
  940. def put_envs(self, value) -> None:
  941. self.overrideEnv("GIT_AUTHOR_DATE", value)
  942. self.overrideEnv("GIT_COMMITTER_DATE", value)
  943. def fallback(self, value) -> None:
  944. self.put_envs(value)
  945. self.assertRaises(porcelain.TimezoneFormatError, porcelain.get_user_timezones)
  946. def test_internal_format(self) -> None:
  947. self.put_envs("0 +0500")
  948. self.assertTupleEqual((18000, 18000), porcelain.get_user_timezones())
  949. def test_rfc_2822(self) -> None:
  950. self.put_envs("Mon, 20 Nov 1995 19:12:08 -0500")
  951. self.assertTupleEqual((-18000, -18000), porcelain.get_user_timezones())
  952. self.put_envs("Mon, 20 Nov 1995 19:12:08")
  953. self.assertTupleEqual((0, 0), porcelain.get_user_timezones())
  954. def test_iso8601(self) -> None:
  955. self.put_envs("1995-11-20T19:12:08-0501")
  956. self.assertTupleEqual((-18060, -18060), porcelain.get_user_timezones())
  957. self.put_envs("1995-11-20T19:12:08+0501")
  958. self.assertTupleEqual((18060, 18060), porcelain.get_user_timezones())
  959. self.put_envs("1995-11-20T19:12:08-05:01")
  960. self.assertTupleEqual((-18060, -18060), porcelain.get_user_timezones())
  961. self.put_envs("1995-11-20 19:12:08-05")
  962. self.assertTupleEqual((-18000, -18000), porcelain.get_user_timezones())
  963. # https://github.com/git/git/blob/96b2d4fa927c5055adc5b1d08f10a5d7352e2989/t/t6300-for-each-ref.sh#L128
  964. self.put_envs("2006-07-03 17:18:44 +0200")
  965. self.assertTupleEqual((7200, 7200), porcelain.get_user_timezones())
  966. def test_missing_or_malformed(self) -> None:
  967. # TODO: add more here
  968. self.fallback("0 + 0500")
  969. self.fallback("a +0500")
  970. self.fallback("1995-11-20T19:12:08")
  971. self.fallback("1995-11-20T19:12:08-05:")
  972. self.fallback("1995.11.20")
  973. self.fallback("11/20/1995")
  974. self.fallback("20.11.1995")
  975. def test_different_envs(self) -> None:
  976. self.overrideEnv("GIT_AUTHOR_DATE", "0 +0500")
  977. self.overrideEnv("GIT_COMMITTER_DATE", "0 +0501")
  978. self.assertTupleEqual((18000, 18060), porcelain.get_user_timezones())
  979. def test_no_envs(self) -> None:
  980. local_timezone = time.localtime().tm_gmtoff
  981. self.put_envs("0 +0500")
  982. self.assertTupleEqual((18000, 18000), porcelain.get_user_timezones())
  983. self.overrideEnv("GIT_COMMITTER_DATE", None)
  984. self.assertTupleEqual((18000, local_timezone), porcelain.get_user_timezones())
  985. self.put_envs("0 +0500")
  986. self.overrideEnv("GIT_AUTHOR_DATE", None)
  987. self.assertTupleEqual((local_timezone, 18000), porcelain.get_user_timezones())
  988. self.put_envs("0 +0500")
  989. self.overrideEnv("GIT_AUTHOR_DATE", None)
  990. self.overrideEnv("GIT_COMMITTER_DATE", None)
  991. self.assertTupleEqual(
  992. (local_timezone, local_timezone), porcelain.get_user_timezones()
  993. )
  994. class CleanTests(PorcelainTestCase):
  995. def put_files(self, tracked, ignored, untracked, empty_dirs) -> None:
  996. """Put the described files in the wd."""
  997. all_files = tracked | ignored | untracked
  998. for file_path in all_files:
  999. abs_path = os.path.join(self.repo.path, file_path)
  1000. # File may need to be written in a dir that doesn't exist yet, so
  1001. # create the parent dir(s) as necessary
  1002. parent_dir = os.path.dirname(abs_path)
  1003. try:
  1004. os.makedirs(parent_dir)
  1005. except FileExistsError:
  1006. pass
  1007. with open(abs_path, "w") as f:
  1008. f.write("")
  1009. with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
  1010. f.writelines(ignored)
  1011. for dir_path in empty_dirs:
  1012. os.mkdir(os.path.join(self.repo.path, "empty_dir"))
  1013. files_to_add = [os.path.join(self.repo.path, t) for t in tracked]
  1014. porcelain.add(repo=self.repo.path, paths=files_to_add)
  1015. porcelain.commit(repo=self.repo.path, message="init commit")
  1016. def assert_wd(self, expected_paths) -> None:
  1017. """Assert paths of files and dirs in wd are same as expected_paths."""
  1018. control_dir_rel = os.path.relpath(self.repo._controldir, self.repo.path)
  1019. # normalize paths to simplify comparison across platforms
  1020. found_paths = {
  1021. os.path.normpath(p)
  1022. for p in flat_walk_dir(self.repo.path)
  1023. if not p.split(os.sep)[0] == control_dir_rel
  1024. }
  1025. norm_expected_paths = {os.path.normpath(p) for p in expected_paths}
  1026. self.assertEqual(found_paths, norm_expected_paths)
  1027. def test_from_root(self) -> None:
  1028. self.put_files(
  1029. tracked={"tracked_file", "tracked_dir/tracked_file", ".gitignore"},
  1030. ignored={"ignored_file"},
  1031. untracked={
  1032. "untracked_file",
  1033. "tracked_dir/untracked_dir/untracked_file",
  1034. "untracked_dir/untracked_dir/untracked_file",
  1035. },
  1036. empty_dirs={"empty_dir"},
  1037. )
  1038. porcelain.clean(repo=self.repo.path, target_dir=self.repo.path)
  1039. self.assert_wd(
  1040. {
  1041. "tracked_file",
  1042. "tracked_dir/tracked_file",
  1043. ".gitignore",
  1044. "ignored_file",
  1045. "tracked_dir",
  1046. }
  1047. )
  1048. def test_from_subdir(self) -> None:
  1049. self.put_files(
  1050. tracked={"tracked_file", "tracked_dir/tracked_file", ".gitignore"},
  1051. ignored={"ignored_file"},
  1052. untracked={
  1053. "untracked_file",
  1054. "tracked_dir/untracked_dir/untracked_file",
  1055. "untracked_dir/untracked_dir/untracked_file",
  1056. },
  1057. empty_dirs={"empty_dir"},
  1058. )
  1059. porcelain.clean(
  1060. repo=self.repo,
  1061. target_dir=os.path.join(self.repo.path, "untracked_dir"),
  1062. )
  1063. self.assert_wd(
  1064. {
  1065. "tracked_file",
  1066. "tracked_dir/tracked_file",
  1067. ".gitignore",
  1068. "ignored_file",
  1069. "untracked_file",
  1070. "tracked_dir/untracked_dir/untracked_file",
  1071. "empty_dir",
  1072. "untracked_dir",
  1073. "tracked_dir",
  1074. "tracked_dir/untracked_dir",
  1075. }
  1076. )
  1077. class CloneTests(PorcelainTestCase):
  1078. def test_simple_local(self) -> None:
  1079. f1_1 = make_object(Blob, data=b"f1")
  1080. commit_spec = [[1], [2, 1], [3, 1, 2]]
  1081. trees = {
  1082. 1: [(b"f1", f1_1), (b"f2", f1_1)],
  1083. 2: [(b"f1", f1_1), (b"f2", f1_1)],
  1084. 3: [(b"f1", f1_1), (b"f2", f1_1)],
  1085. }
  1086. _c1, _c2, c3 = build_commit_graph(self.repo.object_store, commit_spec, trees)
  1087. self.repo.refs[b"refs/heads/master"] = c3.id
  1088. self.repo.refs[b"refs/tags/foo"] = c3.id
  1089. target_path = tempfile.mkdtemp()
  1090. errstream = BytesIO()
  1091. self.addCleanup(shutil.rmtree, target_path)
  1092. r = porcelain.clone(
  1093. self.repo.path, target_path, checkout=False, errstream=errstream
  1094. )
  1095. self.addCleanup(r.close)
  1096. self.assertEqual(r.path, target_path)
  1097. target_repo = Repo(target_path)
  1098. self.addCleanup(target_repo.close)
  1099. self.assertEqual(0, len(target_repo.open_index()))
  1100. self.assertEqual(c3.id, target_repo.refs[b"refs/tags/foo"])
  1101. self.assertNotIn(b"f1", os.listdir(target_path))
  1102. self.assertNotIn(b"f2", os.listdir(target_path))
  1103. c = r.get_config()
  1104. encoded_path = self.repo.path
  1105. if not isinstance(encoded_path, bytes):
  1106. encoded_path_bytes = encoded_path.encode("utf-8")
  1107. else:
  1108. encoded_path_bytes = encoded_path
  1109. self.assertEqual(encoded_path_bytes, c.get((b"remote", b"origin"), b"url"))
  1110. self.assertEqual(
  1111. b"+refs/heads/*:refs/remotes/origin/*",
  1112. c.get((b"remote", b"origin"), b"fetch"),
  1113. )
  1114. def test_simple_local_with_checkout(self) -> None:
  1115. f1_1 = make_object(Blob, data=b"f1")
  1116. commit_spec = [[1], [2, 1], [3, 1, 2]]
  1117. trees = {
  1118. 1: [(b"f1", f1_1), (b"f2", f1_1)],
  1119. 2: [(b"f1", f1_1), (b"f2", f1_1)],
  1120. 3: [(b"f1", f1_1), (b"f2", f1_1)],
  1121. }
  1122. _c1, _c2, c3 = build_commit_graph(self.repo.object_store, commit_spec, trees)
  1123. self.repo.refs[b"refs/heads/master"] = c3.id
  1124. target_path = tempfile.mkdtemp()
  1125. errstream = BytesIO()
  1126. self.addCleanup(shutil.rmtree, target_path)
  1127. with porcelain.clone(
  1128. self.repo.path, target_path, checkout=True, errstream=errstream
  1129. ) as r:
  1130. self.assertEqual(r.path, target_path)
  1131. with Repo(target_path) as r:
  1132. self.assertEqual(r.head(), c3.id)
  1133. self.assertIn("f1", os.listdir(target_path))
  1134. self.assertIn("f2", os.listdir(target_path))
  1135. def test_bare_local_with_checkout(self) -> None:
  1136. f1_1 = make_object(Blob, data=b"f1")
  1137. commit_spec = [[1], [2, 1], [3, 1, 2]]
  1138. trees = {
  1139. 1: [(b"f1", f1_1), (b"f2", f1_1)],
  1140. 2: [(b"f1", f1_1), (b"f2", f1_1)],
  1141. 3: [(b"f1", f1_1), (b"f2", f1_1)],
  1142. }
  1143. _c1, _c2, c3 = build_commit_graph(self.repo.object_store, commit_spec, trees)
  1144. self.repo.refs[b"refs/heads/master"] = c3.id
  1145. target_path = tempfile.mkdtemp()
  1146. errstream = BytesIO()
  1147. self.addCleanup(shutil.rmtree, target_path)
  1148. with porcelain.clone(
  1149. self.repo.path, target_path, bare=True, errstream=errstream
  1150. ) as r:
  1151. self.assertEqual(r.path, target_path)
  1152. with Repo(target_path) as r:
  1153. r.head()
  1154. self.assertRaises(NoIndexPresent, r.open_index)
  1155. self.assertNotIn(b"f1", os.listdir(target_path))
  1156. self.assertNotIn(b"f2", os.listdir(target_path))
  1157. def test_no_checkout_with_bare(self) -> None:
  1158. f1_1 = make_object(Blob, data=b"f1")
  1159. commit_spec = [[1]]
  1160. trees = {1: [(b"f1", f1_1), (b"f2", f1_1)]}
  1161. (c1,) = build_commit_graph(self.repo.object_store, commit_spec, trees)
  1162. self.repo.refs[b"refs/heads/master"] = c1.id
  1163. self.repo.refs[b"HEAD"] = c1.id
  1164. target_path = tempfile.mkdtemp()
  1165. errstream = BytesIO()
  1166. self.addCleanup(shutil.rmtree, target_path)
  1167. self.assertRaises(
  1168. porcelain.Error,
  1169. porcelain.clone,
  1170. self.repo.path,
  1171. target_path,
  1172. checkout=True,
  1173. bare=True,
  1174. errstream=errstream,
  1175. )
  1176. def test_no_head_no_checkout(self) -> None:
  1177. f1_1 = make_object(Blob, data=b"f1")
  1178. commit_spec = [[1]]
  1179. trees = {1: [(b"f1", f1_1), (b"f2", f1_1)]}
  1180. (c1,) = build_commit_graph(self.repo.object_store, commit_spec, trees)
  1181. self.repo.refs[b"refs/heads/master"] = c1.id
  1182. target_path = tempfile.mkdtemp()
  1183. self.addCleanup(shutil.rmtree, target_path)
  1184. errstream = BytesIO()
  1185. r = porcelain.clone(
  1186. self.repo.path, target_path, checkout=True, errstream=errstream
  1187. )
  1188. r.close()
  1189. def test_no_head_no_checkout_outstream_errstream_autofallback(self) -> None:
  1190. f1_1 = make_object(Blob, data=b"f1")
  1191. commit_spec = [[1]]
  1192. trees = {1: [(b"f1", f1_1), (b"f2", f1_1)]}
  1193. (c1,) = build_commit_graph(self.repo.object_store, commit_spec, trees)
  1194. self.repo.refs[b"refs/heads/master"] = c1.id
  1195. target_path = tempfile.mkdtemp()
  1196. self.addCleanup(shutil.rmtree, target_path)
  1197. errstream = porcelain.NoneStream()
  1198. r = porcelain.clone(
  1199. self.repo.path, target_path, checkout=True, errstream=errstream
  1200. )
  1201. r.close()
  1202. def test_source_broken(self) -> None:
  1203. with tempfile.TemporaryDirectory() as parent:
  1204. target_path = os.path.join(parent, "target")
  1205. self.assertRaises(
  1206. Exception, porcelain.clone, "/nonexistent/repo", target_path
  1207. )
  1208. self.assertFalse(os.path.exists(target_path))
  1209. def test_fetch_symref(self) -> None:
  1210. f1_1 = make_object(Blob, data=b"f1")
  1211. trees = {1: [(b"f1", f1_1), (b"f2", f1_1)]}
  1212. [c1] = build_commit_graph(self.repo.object_store, [[1]], trees)
  1213. self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/else")
  1214. self.repo.refs[b"refs/heads/else"] = c1.id
  1215. target_path = tempfile.mkdtemp()
  1216. errstream = BytesIO()
  1217. self.addCleanup(shutil.rmtree, target_path)
  1218. r = porcelain.clone(
  1219. self.repo.path, target_path, checkout=False, errstream=errstream
  1220. )
  1221. self.addCleanup(r.close)
  1222. self.assertEqual(r.path, target_path)
  1223. target_repo = Repo(target_path)
  1224. self.addCleanup(target_repo.close)
  1225. self.assertEqual(0, len(target_repo.open_index()))
  1226. self.assertEqual(c1.id, target_repo.refs[b"refs/heads/else"])
  1227. self.assertEqual(c1.id, target_repo.refs[b"HEAD"])
  1228. self.assertEqual(
  1229. {
  1230. b"HEAD": b"refs/heads/else",
  1231. b"refs/remotes/origin/HEAD": b"refs/remotes/origin/else",
  1232. },
  1233. target_repo.refs.get_symrefs(),
  1234. )
  1235. def test_detached_head(self) -> None:
  1236. f1_1 = make_object(Blob, data=b"f1")
  1237. commit_spec = [[1], [2, 1], [3, 1, 2]]
  1238. trees = {
  1239. 1: [(b"f1", f1_1), (b"f2", f1_1)],
  1240. 2: [(b"f1", f1_1), (b"f2", f1_1)],
  1241. 3: [(b"f1", f1_1), (b"f2", f1_1)],
  1242. }
  1243. _c1, c2, c3 = build_commit_graph(self.repo.object_store, commit_spec, trees)
  1244. self.repo.refs[b"refs/heads/master"] = c2.id
  1245. self.repo.refs.remove_if_equals(b"HEAD", None)
  1246. self.repo.refs[b"HEAD"] = c3.id
  1247. target_path = tempfile.mkdtemp()
  1248. self.addCleanup(shutil.rmtree, target_path)
  1249. errstream = porcelain.NoneStream()
  1250. with porcelain.clone(
  1251. self.repo.path, target_path, checkout=True, errstream=errstream
  1252. ) as r:
  1253. self.assertEqual(c3.id, r.refs[b"HEAD"])
  1254. def test_clone_pathlib(self) -> None:
  1255. from pathlib import Path
  1256. f1_1 = make_object(Blob, data=b"f1")
  1257. commit_spec = [[1]]
  1258. trees = {1: [(b"f1", f1_1)]}
  1259. c1 = build_commit_graph(self.repo.object_store, commit_spec, trees)[0]
  1260. self.repo.refs[b"refs/heads/master"] = c1.id
  1261. target_dir = tempfile.mkdtemp()
  1262. self.addCleanup(shutil.rmtree, target_dir)
  1263. target_path = Path(target_dir) / "clone_repo"
  1264. errstream = BytesIO()
  1265. r = porcelain.clone(
  1266. self.repo.path, target_path, checkout=False, errstream=errstream
  1267. )
  1268. self.addCleanup(r.close)
  1269. self.assertEqual(r.path, str(target_path))
  1270. self.assertTrue(os.path.exists(str(target_path)))
  1271. def test_clone_with_recurse_submodules(self) -> None:
  1272. # Create a submodule repository
  1273. sub_repo_path = tempfile.mkdtemp()
  1274. self.addCleanup(shutil.rmtree, sub_repo_path)
  1275. sub_repo = Repo.init(sub_repo_path)
  1276. self.addCleanup(sub_repo.close)
  1277. # Add a file to the submodule repo
  1278. sub_file = os.path.join(sub_repo_path, "subfile.txt")
  1279. with open(sub_file, "w") as f:
  1280. f.write("submodule content")
  1281. porcelain.add(sub_repo, paths=[sub_file])
  1282. sub_commit = porcelain.commit(
  1283. sub_repo,
  1284. message=b"Initial submodule commit",
  1285. author=b"Test Author <test@example.com>",
  1286. committer=b"Test Committer <test@example.com>",
  1287. )
  1288. # Create main repository with submodule
  1289. main_file = os.path.join(self.repo.path, "main.txt")
  1290. with open(main_file, "w") as f:
  1291. f.write("main content")
  1292. porcelain.add(self.repo, paths=[main_file])
  1293. porcelain.submodule_add(self.repo, sub_repo_path, "sub")
  1294. # Manually add the submodule to the index since submodule_add doesn't do it
  1295. # when the repository is local (to maintain backward compatibility)
  1296. from dulwich.index import IndexEntry
  1297. from dulwich.objects import S_IFGITLINK
  1298. index = self.repo.open_index()
  1299. index[b"sub"] = IndexEntry(
  1300. ctime=0,
  1301. mtime=0,
  1302. dev=0,
  1303. ino=0,
  1304. mode=S_IFGITLINK,
  1305. uid=0,
  1306. gid=0,
  1307. size=0,
  1308. sha=sub_commit,
  1309. flags=0,
  1310. )
  1311. index.write()
  1312. porcelain.add(self.repo, paths=[".gitmodules"])
  1313. porcelain.commit(
  1314. self.repo,
  1315. message=b"Add submodule",
  1316. author=b"Test Author <test@example.com>",
  1317. committer=b"Test Committer <test@example.com>",
  1318. )
  1319. # Clone with recurse_submodules
  1320. target_path = tempfile.mkdtemp()
  1321. self.addCleanup(shutil.rmtree, target_path)
  1322. cloned = porcelain.clone(
  1323. self.repo.path,
  1324. target_path,
  1325. recurse_submodules=True,
  1326. )
  1327. self.addCleanup(cloned.close)
  1328. # Check main file exists
  1329. cloned_main = os.path.join(target_path, "main.txt")
  1330. self.assertTrue(os.path.exists(cloned_main))
  1331. with open(cloned_main) as f:
  1332. self.assertEqual(f.read(), "main content")
  1333. # Check submodule file exists
  1334. cloned_sub_file = os.path.join(target_path, "sub", "subfile.txt")
  1335. self.assertTrue(os.path.exists(cloned_sub_file))
  1336. with open(cloned_sub_file) as f:
  1337. self.assertEqual(f.read(), "submodule content")
  1338. def test_clone_with_refspec_kwarg(self) -> None:
  1339. """Test that clone accepts refspec as a kwarg without TypeError.
  1340. This reproduces a bug where passing refspec as a kwarg to clone()
  1341. would cause a TypeError because it was being passed to
  1342. get_transport_and_path() which doesn't accept that parameter.
  1343. """
  1344. f1_1 = make_object(Blob, data=b"f1")
  1345. commit_spec = [[1]]
  1346. trees = {1: [(b"f1", f1_1)]}
  1347. (c1,) = build_commit_graph(self.repo.object_store, commit_spec, trees)
  1348. self.repo.refs[b"refs/heads/master"] = c1.id
  1349. target_path = tempfile.mkdtemp()
  1350. errstream = BytesIO()
  1351. self.addCleanup(shutil.rmtree, target_path)
  1352. # This should not raise TypeError
  1353. r = porcelain.clone(
  1354. self.repo.path,
  1355. target_path,
  1356. checkout=False,
  1357. errstream=errstream,
  1358. refspec="refs/heads/master",
  1359. )
  1360. self.addCleanup(r.close)
  1361. self.assertEqual(r.path, target_path)
  1362. class InitTests(TestCase):
  1363. def test_non_bare(self) -> None:
  1364. repo_dir = tempfile.mkdtemp()
  1365. self.addCleanup(shutil.rmtree, repo_dir)
  1366. porcelain.init(repo_dir)
  1367. def test_bare(self) -> None:
  1368. repo_dir = tempfile.mkdtemp()
  1369. self.addCleanup(shutil.rmtree, repo_dir)
  1370. porcelain.init(repo_dir, bare=True)
  1371. def test_init_pathlib(self) -> None:
  1372. from pathlib import Path
  1373. repo_dir = tempfile.mkdtemp()
  1374. self.addCleanup(shutil.rmtree, repo_dir)
  1375. repo_path = Path(repo_dir)
  1376. # Test non-bare repo with pathlib
  1377. repo = porcelain.init(repo_path)
  1378. self.assertTrue(os.path.exists(os.path.join(repo_dir, ".git")))
  1379. repo.close()
  1380. def test_init_bare_pathlib(self) -> None:
  1381. from pathlib import Path
  1382. repo_dir = tempfile.mkdtemp()
  1383. self.addCleanup(shutil.rmtree, repo_dir)
  1384. repo_path = Path(repo_dir)
  1385. # Test bare repo with pathlib
  1386. repo = porcelain.init(repo_path, bare=True)
  1387. self.assertTrue(os.path.exists(os.path.join(repo_dir, "refs")))
  1388. repo.close()
  1389. class AddTests(PorcelainTestCase):
  1390. def test_add_default_paths(self) -> None:
  1391. # create a file for initial commit
  1392. fullpath = os.path.join(self.repo.path, "blah")
  1393. with open(fullpath, "w") as f:
  1394. f.write("\n")
  1395. porcelain.add(repo=self.repo.path, paths=[fullpath])
  1396. porcelain.commit(
  1397. repo=self.repo.path,
  1398. message=b"test",
  1399. author=b"test <email>",
  1400. committer=b"test <email>",
  1401. )
  1402. # Add a second test file and a file in a directory
  1403. with open(os.path.join(self.repo.path, "foo"), "w") as f:
  1404. f.write("\n")
  1405. os.mkdir(os.path.join(self.repo.path, "adir"))
  1406. with open(os.path.join(self.repo.path, "adir", "afile"), "w") as f:
  1407. f.write("\n")
  1408. cwd = os.getcwd()
  1409. self.addCleanup(os.chdir, cwd)
  1410. os.chdir(self.repo.path)
  1411. self.assertEqual({"foo", "blah", "adir", ".git"}, set(os.listdir(".")))
  1412. added, ignored = porcelain.add(self.repo.path)
  1413. # Normalize paths to use forward slashes for comparison
  1414. added_normalized = [path.replace(os.sep, "/") for path in added]
  1415. self.assertEqual(
  1416. (added_normalized, ignored),
  1417. (["foo", "adir/afile"], set()),
  1418. )
  1419. # Check that foo was added and nothing in .git was modified
  1420. index = self.repo.open_index()
  1421. self.assertEqual(sorted(index), [b"adir/afile", b"blah", b"foo"])
  1422. def test_add_default_paths_subdir(self) -> None:
  1423. os.mkdir(os.path.join(self.repo.path, "foo"))
  1424. with open(os.path.join(self.repo.path, "blah"), "w") as f:
  1425. f.write("\n")
  1426. with open(os.path.join(self.repo.path, "foo", "blie"), "w") as f:
  1427. f.write("\n")
  1428. cwd = os.getcwd()
  1429. self.addCleanup(os.chdir, cwd)
  1430. os.chdir(os.path.join(self.repo.path, "foo"))
  1431. porcelain.add(repo=self.repo.path)
  1432. porcelain.commit(
  1433. repo=self.repo.path,
  1434. message=b"test",
  1435. author=b"test <email>",
  1436. committer=b"test <email>",
  1437. )
  1438. index = self.repo.open_index()
  1439. # After fix: add() with no paths should behave like git add -A (add everything)
  1440. self.assertEqual(sorted(index), [b"blah", b"foo/blie"])
  1441. def test_add_file(self) -> None:
  1442. fullpath = os.path.join(self.repo.path, "foo")
  1443. with open(fullpath, "w") as f:
  1444. f.write("BAR")
  1445. porcelain.add(self.repo.path, paths=[fullpath])
  1446. self.assertIn(b"foo", self.repo.open_index())
  1447. def test_add_ignored(self) -> None:
  1448. with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
  1449. f.write("foo\nsubdir/")
  1450. with open(os.path.join(self.repo.path, "foo"), "w") as f:
  1451. f.write("BAR")
  1452. with open(os.path.join(self.repo.path, "bar"), "w") as f:
  1453. f.write("BAR")
  1454. os.mkdir(os.path.join(self.repo.path, "subdir"))
  1455. with open(os.path.join(self.repo.path, "subdir", "baz"), "w") as f:
  1456. f.write("BAZ")
  1457. (added, ignored) = porcelain.add(
  1458. self.repo.path,
  1459. paths=[
  1460. os.path.join(self.repo.path, "foo"),
  1461. os.path.join(self.repo.path, "bar"),
  1462. os.path.join(self.repo.path, "subdir"),
  1463. ],
  1464. )
  1465. self.assertIn(b"bar", self.repo.open_index())
  1466. self.assertEqual({"bar"}, set(added))
  1467. self.assertEqual({"foo", "subdir/"}, ignored)
  1468. def test_add_from_ignored_directory(self) -> None:
  1469. # Test for issue #550 - adding files when cwd is in ignored directory
  1470. # Create .gitignore that ignores build/
  1471. with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
  1472. f.write("build/\n")
  1473. # Create ignored directory
  1474. build_dir = os.path.join(self.repo.path, "build")
  1475. os.mkdir(build_dir)
  1476. # Create a file in the repo (not in ignored directory)
  1477. src_file = os.path.join(self.repo.path, "source.py")
  1478. with open(src_file, "w") as f:
  1479. f.write("print('hello')\n")
  1480. # Save current directory and change to ignored directory
  1481. original_cwd = os.getcwd()
  1482. try:
  1483. os.chdir(build_dir)
  1484. # Add file using absolute path from within ignored directory
  1485. (added, _ignored) = porcelain.add(self.repo.path, paths=[src_file])
  1486. self.assertIn(b"source.py", self.repo.open_index())
  1487. self.assertEqual({"source.py"}, set(added))
  1488. finally:
  1489. os.chdir(original_cwd)
  1490. def test_add_file_absolute_path(self) -> None:
  1491. # Absolute paths are (not yet) supported
  1492. with open(os.path.join(self.repo.path, "foo"), "w") as f:
  1493. f.write("BAR")
  1494. porcelain.add(self.repo, paths=[os.path.join(self.repo.path, "foo")])
  1495. self.assertIn(b"foo", self.repo.open_index())
  1496. def test_add_not_in_repo(self) -> None:
  1497. with open(os.path.join(self.test_dir, "foo"), "w") as f:
  1498. f.write("BAR")
  1499. self.assertRaises(
  1500. ValueError,
  1501. porcelain.add,
  1502. self.repo,
  1503. paths=[os.path.join(self.test_dir, "foo")],
  1504. )
  1505. self.assertRaises(
  1506. (ValueError, FileNotFoundError),
  1507. porcelain.add,
  1508. self.repo,
  1509. paths=["../foo"],
  1510. )
  1511. self.assertEqual([], list(self.repo.open_index()))
  1512. def test_add_file_clrf_conversion(self) -> None:
  1513. from dulwich.index import IndexEntry
  1514. # Set the right configuration to the repo
  1515. c = self.repo.get_config()
  1516. c.set("core", "autocrlf", "input")
  1517. c.write_to_path()
  1518. # Add a file with CRLF line-ending
  1519. fullpath = os.path.join(self.repo.path, "foo")
  1520. with open(fullpath, "wb") as f:
  1521. f.write(b"line1\r\nline2")
  1522. porcelain.add(self.repo.path, paths=[fullpath])
  1523. # The line-endings should have been converted to LF
  1524. index = self.repo.open_index()
  1525. self.assertIn(b"foo", index)
  1526. entry = index[b"foo"]
  1527. assert isinstance(entry, IndexEntry)
  1528. blob = self.repo[entry.sha]
  1529. self.assertEqual(blob.data, b"line1\nline2")
  1530. def test_add_symlink_outside_repo(self) -> None:
  1531. """Test adding a symlink that points outside the repository."""
  1532. # Create a symlink pointing outside the repository
  1533. symlink_path = os.path.join(self.repo.path, "symlink_to_nowhere")
  1534. os.symlink("/nonexistent/path", symlink_path)
  1535. # Adding the symlink should succeed (matching Git's behavior)
  1536. added, ignored = porcelain.add(self.repo.path, paths=[symlink_path])
  1537. # Should successfully add the symlink
  1538. self.assertIn("symlink_to_nowhere", added)
  1539. self.assertEqual(len(ignored), 0)
  1540. # Verify symlink is actually staged
  1541. index = self.repo.open_index()
  1542. self.assertIn(b"symlink_to_nowhere", index)
  1543. def test_add_symlink_to_file_inside_repo(self) -> None:
  1544. """Test adding a symlink that points to a file inside the repository."""
  1545. # Create a regular file
  1546. target_file = os.path.join(self.repo.path, "target.txt")
  1547. with open(target_file, "w") as f:
  1548. f.write("target content")
  1549. # Create a symlink to the file
  1550. symlink_path = os.path.join(self.repo.path, "link_to_target")
  1551. os.symlink("target.txt", symlink_path)
  1552. # Add both the target and the symlink
  1553. added, ignored = porcelain.add(
  1554. self.repo.path, paths=[target_file, symlink_path]
  1555. )
  1556. # Both should be added successfully
  1557. self.assertIn("target.txt", added)
  1558. self.assertIn("link_to_target", added)
  1559. self.assertEqual(len(ignored), 0)
  1560. # Verify both are in the index
  1561. index = self.repo.open_index()
  1562. self.assertIn(b"target.txt", index)
  1563. self.assertIn(b"link_to_target", index)
  1564. def test_add_symlink_to_directory_inside_repo(self) -> None:
  1565. """Test adding a symlink that points to a directory inside the repository."""
  1566. # Create a directory with some files
  1567. target_dir = os.path.join(self.repo.path, "target_dir")
  1568. os.mkdir(target_dir)
  1569. with open(os.path.join(target_dir, "file1.txt"), "w") as f:
  1570. f.write("content1")
  1571. with open(os.path.join(target_dir, "file2.txt"), "w") as f:
  1572. f.write("content2")
  1573. # Create a symlink to the directory
  1574. symlink_path = os.path.join(self.repo.path, "link_to_dir")
  1575. os.symlink("target_dir", symlink_path)
  1576. # Add the symlink
  1577. added, ignored = porcelain.add(self.repo.path, paths=[symlink_path])
  1578. # When adding a symlink to a directory, it follows the symlink and adds contents
  1579. self.assertEqual(len(added), 2)
  1580. self.assertIn("link_to_dir/file1.txt", added)
  1581. self.assertIn("link_to_dir/file2.txt", added)
  1582. self.assertEqual(len(ignored), 0)
  1583. # Verify files are added through the symlink path
  1584. index = self.repo.open_index()
  1585. self.assertIn(b"link_to_dir/file1.txt", index)
  1586. self.assertIn(b"link_to_dir/file2.txt", index)
  1587. # The original target directory files are not added
  1588. self.assertNotIn(b"target_dir/file1.txt", index)
  1589. self.assertNotIn(b"target_dir/file2.txt", index)
  1590. def test_add_symlink_chain(self) -> None:
  1591. """Test adding a chain of symlinks (symlink to symlink)."""
  1592. # Create a regular file
  1593. target_file = os.path.join(self.repo.path, "original.txt")
  1594. with open(target_file, "w") as f:
  1595. f.write("original content")
  1596. # Create first symlink
  1597. first_link = os.path.join(self.repo.path, "link1")
  1598. os.symlink("original.txt", first_link)
  1599. # Create second symlink pointing to first
  1600. second_link = os.path.join(self.repo.path, "link2")
  1601. os.symlink("link1", second_link)
  1602. # Add all files
  1603. added, _ignored = porcelain.add(
  1604. self.repo.path, paths=[target_file, first_link, second_link]
  1605. )
  1606. # All should be added
  1607. self.assertEqual(len(added), 3)
  1608. self.assertIn("original.txt", added)
  1609. self.assertIn("link1", added)
  1610. self.assertIn("link2", added)
  1611. # Verify all are in the index
  1612. index = self.repo.open_index()
  1613. self.assertIn(b"original.txt", index)
  1614. self.assertIn(b"link1", index)
  1615. self.assertIn(b"link2", index)
  1616. def test_add_broken_symlink(self) -> None:
  1617. """Test adding a broken symlink (points to non-existent target)."""
  1618. # Create a symlink to a non-existent file
  1619. broken_link = os.path.join(self.repo.path, "broken_link")
  1620. os.symlink("does_not_exist.txt", broken_link)
  1621. # Add the broken symlink
  1622. added, ignored = porcelain.add(self.repo.path, paths=[broken_link])
  1623. # Should be added successfully (Git tracks the symlink, not its target)
  1624. self.assertIn("broken_link", added)
  1625. self.assertEqual(len(ignored), 0)
  1626. # Verify it's in the index
  1627. index = self.repo.open_index()
  1628. self.assertIn(b"broken_link", index)
  1629. def test_add_symlink_relative_outside_repo(self) -> None:
  1630. """Test adding a symlink that uses '..' to point outside the repository."""
  1631. # Create a file outside the repo
  1632. outside_file = os.path.join(self.test_dir, "outside.txt")
  1633. with open(outside_file, "w") as f:
  1634. f.write("outside content")
  1635. # Create a symlink using relative path to go outside
  1636. symlink_path = os.path.join(self.repo.path, "link_outside")
  1637. os.symlink("../outside.txt", symlink_path)
  1638. # Add the symlink
  1639. added, ignored = porcelain.add(self.repo.path, paths=[symlink_path])
  1640. # Should be added successfully
  1641. self.assertIn("link_outside", added)
  1642. self.assertEqual(len(ignored), 0)
  1643. # Verify it's in the index
  1644. index = self.repo.open_index()
  1645. self.assertIn(b"link_outside", index)
  1646. def test_add_symlink_absolute_to_system(self) -> None:
  1647. """Test adding a symlink with absolute path to system directory."""
  1648. # Create a symlink to a system directory
  1649. symlink_path = os.path.join(self.repo.path, "link_to_tmp")
  1650. if os.name == "nt":
  1651. # On Windows, use a system directory like TEMP
  1652. symlink_target = os.environ["TEMP"]
  1653. else:
  1654. # On Unix-like systems, use /tmp
  1655. symlink_target = os.environ.get("TMPDIR", "/tmp")
  1656. os.symlink(symlink_target, symlink_path)
  1657. # Adding a symlink to a directory outside the repo should raise ValueError
  1658. with self.assertRaises(ValueError) as cm:
  1659. porcelain.add(self.repo.path, paths=[symlink_path])
  1660. # Check that the error indicates the path is outside the repository
  1661. self.assertIn("is not in the subpath of", str(cm.exception))
  1662. def test_add_file_through_symlink(self) -> None:
  1663. """Test adding a file through a symlinked directory."""
  1664. # Create a directory with a file
  1665. real_dir = os.path.join(self.repo.path, "real_dir")
  1666. os.mkdir(real_dir)
  1667. real_file = os.path.join(real_dir, "file.txt")
  1668. with open(real_file, "w") as f:
  1669. f.write("content")
  1670. # Create a symlink to the directory
  1671. link_dir = os.path.join(self.repo.path, "link_dir")
  1672. os.symlink("real_dir", link_dir)
  1673. # Try to add the file through the symlink path
  1674. symlink_file_path = os.path.join(link_dir, "file.txt")
  1675. # This should add the real file, not create a new entry
  1676. added, _ignored = porcelain.add(self.repo.path, paths=[symlink_file_path])
  1677. # The real file should be added
  1678. self.assertIn("real_dir/file.txt", added)
  1679. self.assertEqual(len(added), 1)
  1680. # Verify correct path in index
  1681. index = self.repo.open_index()
  1682. self.assertIn(b"real_dir/file.txt", index)
  1683. # Should not create a separate entry for the symlink path
  1684. self.assertNotIn(b"link_dir/file.txt", index)
  1685. def test_add_repo_path(self) -> None:
  1686. """Test adding the repository path itself should add all untracked files."""
  1687. # Create some untracked files
  1688. with open(os.path.join(self.repo.path, "file1.txt"), "w") as f:
  1689. f.write("content1")
  1690. with open(os.path.join(self.repo.path, "file2.txt"), "w") as f:
  1691. f.write("content2")
  1692. # Add the repository path itself
  1693. added, _ignored = porcelain.add(self.repo.path, paths=[self.repo.path])
  1694. # Should add all untracked files, not stage './'
  1695. self.assertIn("file1.txt", added)
  1696. self.assertIn("file2.txt", added)
  1697. self.assertNotIn("./", added)
  1698. # Verify files are actually staged
  1699. index = self.repo.open_index()
  1700. self.assertIn(b"file1.txt", index)
  1701. self.assertIn(b"file2.txt", index)
  1702. def test_add_directory_contents(self) -> None:
  1703. """Test adding a directory adds all files within it."""
  1704. # Create a subdirectory with multiple files
  1705. subdir = os.path.join(self.repo.path, "subdir")
  1706. os.mkdir(subdir)
  1707. with open(os.path.join(subdir, "file1.txt"), "w") as f:
  1708. f.write("content1")
  1709. with open(os.path.join(subdir, "file2.txt"), "w") as f:
  1710. f.write("content2")
  1711. with open(os.path.join(subdir, "file3.txt"), "w") as f:
  1712. f.write("content3")
  1713. # Add the directory
  1714. added, _ignored = porcelain.add(self.repo.path, paths=["subdir"])
  1715. # Should add all files in the directory
  1716. self.assertEqual(len(added), 3)
  1717. # Normalize paths to use forward slashes for comparison
  1718. added_normalized = [path.replace(os.sep, "/") for path in added]
  1719. self.assertIn("subdir/file1.txt", added_normalized)
  1720. self.assertIn("subdir/file2.txt", added_normalized)
  1721. self.assertIn("subdir/file3.txt", added_normalized)
  1722. # Verify files are actually staged
  1723. index = self.repo.open_index()
  1724. self.assertIn(b"subdir/file1.txt", index)
  1725. self.assertIn(b"subdir/file2.txt", index)
  1726. self.assertIn(b"subdir/file3.txt", index)
  1727. def test_add_nested_directories(self) -> None:
  1728. """Test adding a directory with nested subdirectories."""
  1729. # Create nested directory structure
  1730. dir1 = os.path.join(self.repo.path, "dir1")
  1731. dir2 = os.path.join(dir1, "dir2")
  1732. dir3 = os.path.join(dir2, "dir3")
  1733. os.makedirs(dir3)
  1734. # Add files at each level
  1735. with open(os.path.join(dir1, "file1.txt"), "w") as f:
  1736. f.write("level1")
  1737. with open(os.path.join(dir2, "file2.txt"), "w") as f:
  1738. f.write("level2")
  1739. with open(os.path.join(dir3, "file3.txt"), "w") as f:
  1740. f.write("level3")
  1741. # Add the top-level directory
  1742. added, _ignored = porcelain.add(self.repo.path, paths=["dir1"])
  1743. # Should add all files recursively
  1744. self.assertEqual(len(added), 3)
  1745. # Normalize paths to use forward slashes for comparison
  1746. added_normalized = [path.replace(os.sep, "/") for path in added]
  1747. self.assertIn("dir1/file1.txt", added_normalized)
  1748. self.assertIn("dir1/dir2/file2.txt", added_normalized)
  1749. self.assertIn("dir1/dir2/dir3/file3.txt", added_normalized)
  1750. # Verify files are actually staged
  1751. index = self.repo.open_index()
  1752. self.assertIn(b"dir1/file1.txt", index)
  1753. self.assertIn(b"dir1/dir2/file2.txt", index)
  1754. self.assertIn(b"dir1/dir2/dir3/file3.txt", index)
  1755. def test_add_directory_with_tracked_files(self) -> None:
  1756. """Test adding a directory with some files already tracked."""
  1757. # Create a subdirectory with files
  1758. subdir = os.path.join(self.repo.path, "mixed")
  1759. os.mkdir(subdir)
  1760. # Create and commit one file
  1761. tracked_file = os.path.join(subdir, "tracked.txt")
  1762. with open(tracked_file, "w") as f:
  1763. f.write("already tracked")
  1764. porcelain.add(self.repo.path, paths=[tracked_file])
  1765. porcelain.commit(
  1766. repo=self.repo.path,
  1767. message=b"Add tracked file",
  1768. author=b"test <email>",
  1769. committer=b"test <email>",
  1770. )
  1771. # Add more untracked files
  1772. with open(os.path.join(subdir, "untracked1.txt"), "w") as f:
  1773. f.write("new file 1")
  1774. with open(os.path.join(subdir, "untracked2.txt"), "w") as f:
  1775. f.write("new file 2")
  1776. # Add the directory
  1777. added, _ignored = porcelain.add(self.repo.path, paths=["mixed"])
  1778. # Should only add the untracked files
  1779. self.assertEqual(len(added), 2)
  1780. # Normalize paths to use forward slashes for comparison
  1781. added_normalized = [path.replace(os.sep, "/") for path in added]
  1782. self.assertIn("mixed/untracked1.txt", added_normalized)
  1783. self.assertIn("mixed/untracked2.txt", added_normalized)
  1784. self.assertNotIn("mixed/tracked.txt", added)
  1785. # Verify the index contains all files
  1786. index = self.repo.open_index()
  1787. self.assertIn(b"mixed/tracked.txt", index)
  1788. self.assertIn(b"mixed/untracked1.txt", index)
  1789. self.assertIn(b"mixed/untracked2.txt", index)
  1790. def test_add_directory_with_gitignore(self) -> None:
  1791. """Test adding a directory respects .gitignore patterns."""
  1792. # Create .gitignore
  1793. with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
  1794. f.write("*.log\n*.tmp\nbuild/\n")
  1795. # Create directory with mixed files
  1796. testdir = os.path.join(self.repo.path, "testdir")
  1797. os.mkdir(testdir)
  1798. # Create various files
  1799. with open(os.path.join(testdir, "important.txt"), "w") as f:
  1800. f.write("keep this")
  1801. with open(os.path.join(testdir, "debug.log"), "w") as f:
  1802. f.write("ignore this")
  1803. with open(os.path.join(testdir, "temp.tmp"), "w") as f:
  1804. f.write("ignore this too")
  1805. with open(os.path.join(testdir, "readme.md"), "w") as f:
  1806. f.write("keep this too")
  1807. # Create a build directory that should be ignored
  1808. builddir = os.path.join(testdir, "build")
  1809. os.mkdir(builddir)
  1810. with open(os.path.join(builddir, "output.txt"), "w") as f:
  1811. f.write("ignore entire directory")
  1812. # Add the directory
  1813. added, ignored = porcelain.add(self.repo.path, paths=["testdir"])
  1814. # Should only add non-ignored files
  1815. # Normalize paths to use forward slashes for comparison
  1816. added_normalized = {path.replace(os.sep, "/") for path in added}
  1817. self.assertEqual(
  1818. added_normalized, {"testdir/important.txt", "testdir/readme.md"}
  1819. )
  1820. # Check ignored files
  1821. # Normalize paths to use forward slashes for comparison
  1822. ignored_normalized = {path.replace(os.sep, "/") for path in ignored}
  1823. self.assertIn("testdir/debug.log", ignored_normalized)
  1824. self.assertIn("testdir/temp.tmp", ignored_normalized)
  1825. self.assertIn("testdir/build/", ignored_normalized)
  1826. def test_add_multiple_directories(self) -> None:
  1827. """Test adding multiple directories in one call."""
  1828. # Create multiple directories
  1829. for dirname in ["dir1", "dir2", "dir3"]:
  1830. dirpath = os.path.join(self.repo.path, dirname)
  1831. os.mkdir(dirpath)
  1832. # Add files to each directory
  1833. for i in range(2):
  1834. with open(os.path.join(dirpath, f"file{i}.txt"), "w") as f:
  1835. f.write(f"content {dirname} {i}")
  1836. # Add all directories at once
  1837. added, _ignored = porcelain.add(self.repo.path, paths=["dir1", "dir2", "dir3"])
  1838. # Should add all files from all directories
  1839. self.assertEqual(len(added), 6)
  1840. # Normalize paths to use forward slashes for comparison
  1841. added_normalized = [path.replace(os.sep, "/") for path in added]
  1842. for dirname in ["dir1", "dir2", "dir3"]:
  1843. for i in range(2):
  1844. self.assertIn(f"{dirname}/file{i}.txt", added_normalized)
  1845. # Verify all files are staged
  1846. index = self.repo.open_index()
  1847. self.assertEqual(len(index), 6)
  1848. def test_add_default_paths_includes_modified_files(self) -> None:
  1849. """Test that add() with no paths includes both untracked and modified files."""
  1850. # Create and commit initial file
  1851. initial_file = os.path.join(self.repo.path, "existing.txt")
  1852. with open(initial_file, "w") as f:
  1853. f.write("initial content\n")
  1854. porcelain.add(repo=self.repo.path, paths=[initial_file])
  1855. porcelain.commit(
  1856. repo=self.repo.path,
  1857. message=b"initial commit",
  1858. author=b"test <email>",
  1859. committer=b"test <email>",
  1860. )
  1861. # Modify the existing file (this creates an unstaged change)
  1862. with open(initial_file, "w") as f:
  1863. f.write("modified content\n")
  1864. # Create a new untracked file
  1865. new_file = os.path.join(self.repo.path, "new.txt")
  1866. with open(new_file, "w") as f:
  1867. f.write("new file content\n")
  1868. # Call add() with no paths - should stage both modified and untracked files
  1869. added_files, ignored_files = porcelain.add(repo=self.repo.path)
  1870. # Verify both files were added
  1871. self.assertIn("existing.txt", added_files)
  1872. self.assertIn("new.txt", added_files)
  1873. self.assertEqual(len(ignored_files), 0)
  1874. # Verify both files are now staged
  1875. index = self.repo.open_index()
  1876. self.assertIn(b"existing.txt", index)
  1877. self.assertIn(b"new.txt", index)
  1878. def test_add_empty_paths_list(self) -> None:
  1879. """Test that passing paths=[] does not add any files."""
  1880. # Create an untracked file
  1881. with open(os.path.join(self.repo.path, "file.txt"), "w") as f:
  1882. f.write("content")
  1883. # Add with empty paths list
  1884. added, _ignored = porcelain.add(self.repo.path, paths=[])
  1885. # Should not add any files
  1886. self.assertEqual(len(added), 0)
  1887. class RemoveTests(PorcelainTestCase):
  1888. def test_remove_file(self) -> None:
  1889. fullpath = os.path.join(self.repo.path, "foo")
  1890. with open(fullpath, "w") as f:
  1891. f.write("BAR")
  1892. porcelain.add(self.repo.path, paths=[fullpath])
  1893. porcelain.commit(
  1894. repo=self.repo,
  1895. message=b"test",
  1896. author=b"test <email>",
  1897. committer=b"test <email>",
  1898. )
  1899. self.assertTrue(os.path.exists(os.path.join(self.repo.path, "foo")))
  1900. cwd = os.getcwd()
  1901. self.addCleanup(os.chdir, cwd)
  1902. os.chdir(self.repo.path)
  1903. porcelain.remove(self.repo.path, paths=["foo"])
  1904. self.assertFalse(os.path.exists(os.path.join(self.repo.path, "foo")))
  1905. def test_remove_file_staged(self) -> None:
  1906. fullpath = os.path.join(self.repo.path, "foo")
  1907. with open(fullpath, "w") as f:
  1908. f.write("BAR")
  1909. cwd = os.getcwd()
  1910. self.addCleanup(os.chdir, cwd)
  1911. os.chdir(self.repo.path)
  1912. porcelain.add(self.repo.path, paths=[fullpath])
  1913. self.assertRaises(Exception, porcelain.rm, self.repo.path, paths=["foo"])
  1914. def test_remove_file_removed_on_disk(self) -> None:
  1915. fullpath = os.path.join(self.repo.path, "foo")
  1916. with open(fullpath, "w") as f:
  1917. f.write("BAR")
  1918. porcelain.add(self.repo.path, paths=[fullpath])
  1919. cwd = os.getcwd()
  1920. self.addCleanup(os.chdir, cwd)
  1921. os.chdir(self.repo.path)
  1922. os.remove(fullpath)
  1923. porcelain.remove(self.repo.path, paths=["foo"])
  1924. self.assertFalse(os.path.exists(os.path.join(self.repo.path, "foo")))
  1925. def test_remove_from_different_directory(self) -> None:
  1926. # Create a subdirectory with a file
  1927. subdir = os.path.join(self.repo.path, "mydir")
  1928. os.makedirs(subdir)
  1929. fullpath = os.path.join(subdir, "myfile")
  1930. with open(fullpath, "w") as f:
  1931. f.write("BAR")
  1932. # Add and commit the file
  1933. porcelain.add(self.repo.path, paths=[fullpath])
  1934. porcelain.commit(
  1935. repo=self.repo,
  1936. message=b"test",
  1937. author=b"test <email>",
  1938. committer=b"test <email>",
  1939. )
  1940. # Change to a different directory
  1941. cwd = os.getcwd()
  1942. tempdir = tempfile.mkdtemp()
  1943. def cleanup():
  1944. os.chdir(cwd)
  1945. shutil.rmtree(tempdir)
  1946. self.addCleanup(cleanup)
  1947. os.chdir(tempdir)
  1948. # Remove the file using relative path from repository root
  1949. porcelain.remove(self.repo.path, paths=["mydir/myfile"])
  1950. # Verify file was removed
  1951. self.assertFalse(os.path.exists(fullpath))
  1952. def test_remove_with_absolute_path(self) -> None:
  1953. # Create a file
  1954. fullpath = os.path.join(self.repo.path, "foo")
  1955. with open(fullpath, "w") as f:
  1956. f.write("BAR")
  1957. # Add and commit the file
  1958. porcelain.add(self.repo.path, paths=[fullpath])
  1959. porcelain.commit(
  1960. repo=self.repo,
  1961. message=b"test",
  1962. author=b"test <email>",
  1963. committer=b"test <email>",
  1964. )
  1965. # Change to a different directory
  1966. cwd = os.getcwd()
  1967. tempdir = tempfile.mkdtemp()
  1968. def cleanup():
  1969. os.chdir(cwd)
  1970. shutil.rmtree(tempdir)
  1971. self.addCleanup(cleanup)
  1972. os.chdir(tempdir)
  1973. # Remove the file using absolute path
  1974. porcelain.remove(self.repo.path, paths=[fullpath])
  1975. # Verify file was removed
  1976. self.assertFalse(os.path.exists(fullpath))
  1977. def test_remove_with_filter_normalization(self) -> None:
  1978. # Enable autocrlf to normalize line endings
  1979. config = self.repo.get_config()
  1980. config.set(("core",), "autocrlf", b"true")
  1981. config.write_to_path()
  1982. # Create a file with LF line endings (will be stored with LF in index)
  1983. fullpath = os.path.join(self.repo.path, "foo.txt")
  1984. with open(fullpath, "wb") as f:
  1985. f.write(b"line1\nline2\nline3")
  1986. # Add and commit the file (stored with LF in index)
  1987. porcelain.add(self.repo.path, paths=[fullpath])
  1988. porcelain.commit(
  1989. repo=self.repo,
  1990. message=b"Add file with LF",
  1991. author=b"test <email>",
  1992. committer=b"test <email>",
  1993. )
  1994. # Simulate checkout with CRLF conversion (as would happen on Windows)
  1995. with open(fullpath, "wb") as f:
  1996. f.write(b"line1\r\nline2\r\nline3")
  1997. # Verify file exists
  1998. self.assertTrue(os.path.exists(fullpath))
  1999. # Remove the file - this should not fail even though working tree has CRLF
  2000. # and index has LF (thanks to the normalization in the commit)
  2001. cwd = os.getcwd()
  2002. os.chdir(self.repo.path)
  2003. self.addCleanup(os.chdir, cwd)
  2004. porcelain.remove(self.repo.path, paths=["foo.txt"])
  2005. # Verify file was removed
  2006. self.assertFalse(os.path.exists(fullpath))
  2007. class MvTests(PorcelainTestCase):
  2008. def test_mv_file(self) -> None:
  2009. # Create a file
  2010. fullpath = os.path.join(self.repo.path, "foo")
  2011. with open(fullpath, "w") as f:
  2012. f.write("BAR")
  2013. # Add and commit the file
  2014. porcelain.add(self.repo.path, paths=[fullpath])
  2015. porcelain.commit(
  2016. repo=self.repo,
  2017. message=b"test",
  2018. author=b"test <email>",
  2019. committer=b"test <email>",
  2020. )
  2021. # Move the file
  2022. porcelain.mv(self.repo.path, "foo", "bar")
  2023. # Verify old path doesn't exist and new path does
  2024. self.assertFalse(os.path.exists(os.path.join(self.repo.path, "foo")))
  2025. self.assertTrue(os.path.exists(os.path.join(self.repo.path, "bar")))
  2026. # Verify index was updated
  2027. index = self.repo.open_index()
  2028. self.assertNotIn(b"foo", index)
  2029. self.assertIn(b"bar", index)
  2030. def test_mv_file_to_existing_directory(self) -> None:
  2031. # Create a file and a directory
  2032. fullpath = os.path.join(self.repo.path, "foo")
  2033. with open(fullpath, "w") as f:
  2034. f.write("BAR")
  2035. dirpath = os.path.join(self.repo.path, "mydir")
  2036. os.makedirs(dirpath)
  2037. # Add and commit the file
  2038. porcelain.add(self.repo.path, paths=[fullpath])
  2039. porcelain.commit(
  2040. repo=self.repo,
  2041. message=b"test",
  2042. author=b"test <email>",
  2043. committer=b"test <email>",
  2044. )
  2045. # Move the file into the directory
  2046. porcelain.mv(self.repo.path, "foo", "mydir")
  2047. # Verify file moved into directory
  2048. self.assertFalse(os.path.exists(os.path.join(self.repo.path, "foo")))
  2049. self.assertTrue(os.path.exists(os.path.join(self.repo.path, "mydir", "foo")))
  2050. # Verify index was updated
  2051. index = self.repo.open_index()
  2052. self.assertNotIn(b"foo", index)
  2053. self.assertIn(b"mydir/foo", index)
  2054. def test_mv_file_force_overwrite(self) -> None:
  2055. # Create two files
  2056. fullpath1 = os.path.join(self.repo.path, "foo")
  2057. with open(fullpath1, "w") as f:
  2058. f.write("FOO")
  2059. fullpath2 = os.path.join(self.repo.path, "bar")
  2060. with open(fullpath2, "w") as f:
  2061. f.write("BAR")
  2062. # Add and commit both files
  2063. porcelain.add(self.repo.path, paths=[fullpath1, fullpath2])
  2064. porcelain.commit(
  2065. repo=self.repo,
  2066. message=b"test",
  2067. author=b"test <email>",
  2068. committer=b"test <email>",
  2069. )
  2070. # Try to move without force (should fail)
  2071. self.assertRaises(porcelain.Error, porcelain.mv, self.repo.path, "foo", "bar")
  2072. # Move with force
  2073. porcelain.mv(self.repo.path, "foo", "bar", force=True)
  2074. # Verify foo doesn't exist and bar has foo's content
  2075. self.assertFalse(os.path.exists(os.path.join(self.repo.path, "foo")))
  2076. with open(os.path.join(self.repo.path, "bar")) as f:
  2077. self.assertEqual(f.read(), "FOO")
  2078. def test_mv_file_not_tracked(self) -> None:
  2079. # Create an untracked file
  2080. fullpath = os.path.join(self.repo.path, "untracked")
  2081. with open(fullpath, "w") as f:
  2082. f.write("UNTRACKED")
  2083. # Try to move it (should fail)
  2084. self.assertRaises(
  2085. porcelain.Error, porcelain.mv, self.repo.path, "untracked", "tracked"
  2086. )
  2087. def test_mv_file_not_exists(self) -> None:
  2088. # Try to move a non-existent file
  2089. self.assertRaises(
  2090. porcelain.Error, porcelain.mv, self.repo.path, "nonexistent", "destination"
  2091. )
  2092. def test_mv_absolute_paths(self) -> None:
  2093. # Create a file
  2094. fullpath = os.path.join(self.repo.path, "foo")
  2095. with open(fullpath, "w") as f:
  2096. f.write("BAR")
  2097. # Add and commit the file
  2098. porcelain.add(self.repo.path, paths=[fullpath])
  2099. porcelain.commit(
  2100. repo=self.repo,
  2101. message=b"test",
  2102. author=b"test <email>",
  2103. committer=b"test <email>",
  2104. )
  2105. # Move using absolute paths
  2106. dest_path = os.path.join(self.repo.path, "bar")
  2107. porcelain.mv(self.repo.path, fullpath, dest_path)
  2108. # Verify file moved
  2109. self.assertFalse(os.path.exists(fullpath))
  2110. self.assertTrue(os.path.exists(dest_path))
  2111. def test_mv_from_different_directory(self) -> None:
  2112. # Create a subdirectory with a file
  2113. subdir = os.path.join(self.repo.path, "mydir")
  2114. os.makedirs(subdir)
  2115. fullpath = os.path.join(subdir, "myfile")
  2116. with open(fullpath, "w") as f:
  2117. f.write("BAR")
  2118. # Add and commit the file
  2119. porcelain.add(self.repo.path, paths=[fullpath])
  2120. porcelain.commit(
  2121. repo=self.repo,
  2122. message=b"test",
  2123. author=b"test <email>",
  2124. committer=b"test <email>",
  2125. )
  2126. # Change to a different directory and move the file
  2127. cwd = os.getcwd()
  2128. tempdir = tempfile.mkdtemp()
  2129. def cleanup():
  2130. os.chdir(cwd)
  2131. shutil.rmtree(tempdir)
  2132. self.addCleanup(cleanup)
  2133. os.chdir(tempdir)
  2134. # Move the file using relative path from repository root
  2135. porcelain.mv(self.repo.path, "mydir/myfile", "renamed")
  2136. # Verify file was moved
  2137. self.assertFalse(os.path.exists(fullpath))
  2138. self.assertTrue(os.path.exists(os.path.join(self.repo.path, "renamed")))
  2139. class LogTests(PorcelainTestCase):
  2140. def test_simple(self) -> None:
  2141. _c1, _c2, c3 = build_commit_graph(
  2142. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2143. )
  2144. self.repo.refs[b"HEAD"] = c3.id
  2145. self.maxDiff = None
  2146. outstream = StringIO()
  2147. porcelain.log(self.repo.path, outstream=outstream)
  2148. self.assertEqual(
  2149. outstream.getvalue(),
  2150. """\
  2151. --------------------------------------------------
  2152. commit: 4a3b887baa9ecb2d054d2469b628aef84e2d74f0
  2153. merge: 7508036b1cfec5aa9cef0d5a7f04abcecfe09112
  2154. Author: Test Author <test@nodomain.com>
  2155. Committer: Test Committer <test@nodomain.com>
  2156. Date: Fri Jan 01 2010 00:00:00 +0000
  2157. Commit 3
  2158. --------------------------------------------------
  2159. commit: 7508036b1cfec5aa9cef0d5a7f04abcecfe09112
  2160. Author: Test Author <test@nodomain.com>
  2161. Committer: Test Committer <test@nodomain.com>
  2162. Date: Fri Jan 01 2010 00:00:00 +0000
  2163. Commit 2
  2164. --------------------------------------------------
  2165. commit: 11d3cf672a19366435c1983c7340b008ec6b8bf3
  2166. Author: Test Author <test@nodomain.com>
  2167. Committer: Test Committer <test@nodomain.com>
  2168. Date: Fri Jan 01 2010 00:00:00 +0000
  2169. Commit 1
  2170. """,
  2171. )
  2172. def test_max_entries(self) -> None:
  2173. _c1, _c2, c3 = build_commit_graph(
  2174. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2175. )
  2176. self.repo.refs[b"HEAD"] = c3.id
  2177. outstream = StringIO()
  2178. porcelain.log(self.repo.path, outstream=outstream, max_entries=1)
  2179. self.assertEqual(1, outstream.getvalue().count("-" * 50))
  2180. def test_no_revisions(self) -> None:
  2181. outstream = StringIO()
  2182. porcelain.log(self.repo.path, outstream=outstream)
  2183. self.assertEqual("", outstream.getvalue())
  2184. def test_empty_message(self) -> None:
  2185. c1 = make_commit(message="")
  2186. self.repo.object_store.add_object(c1)
  2187. self.repo.refs[b"HEAD"] = c1.id
  2188. outstream = StringIO()
  2189. porcelain.log(self.repo.path, outstream=outstream)
  2190. self.assertEqual(
  2191. outstream.getvalue(),
  2192. """\
  2193. --------------------------------------------------
  2194. commit: 4a7ad5552fad70647a81fb9a4a923ccefcca4b76
  2195. Author: Test Author <test@nodomain.com>
  2196. Committer: Test Committer <test@nodomain.com>
  2197. Date: Fri Jan 01 2010 00:00:00 +0000
  2198. """,
  2199. )
  2200. class ShowTests(PorcelainTestCase):
  2201. def test_nolist(self) -> None:
  2202. _c1, _c2, c3 = build_commit_graph(
  2203. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2204. )
  2205. self.repo.refs[b"HEAD"] = c3.id
  2206. outstream = StringIO()
  2207. porcelain.show(self.repo.path, objects=c3.id, outstream=outstream)
  2208. self.assertTrue(outstream.getvalue().startswith("-" * 50))
  2209. def test_simple(self) -> None:
  2210. _c1, _c2, c3 = build_commit_graph(
  2211. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2212. )
  2213. self.repo.refs[b"HEAD"] = c3.id
  2214. outstream = StringIO()
  2215. porcelain.show(self.repo.path, objects=[c3.id], outstream=outstream)
  2216. self.assertTrue(outstream.getvalue().startswith("-" * 50))
  2217. def test_blob(self) -> None:
  2218. b = Blob.from_string(b"The Foo\n")
  2219. self.repo.object_store.add_object(b)
  2220. outstream = StringIO()
  2221. porcelain.show(self.repo.path, objects=[b.id], outstream=outstream)
  2222. self.assertEqual(outstream.getvalue(), "The Foo\n")
  2223. def test_commit_no_parent(self) -> None:
  2224. a = Blob.from_string(b"The Foo\n")
  2225. ta = Tree()
  2226. ta.add(b"somename", 0o100644, a.id)
  2227. ca = make_commit(tree=ta.id)
  2228. self.repo.object_store.add_objects([(a, None), (ta, None), (ca, None)])
  2229. outstream = StringIO()
  2230. porcelain.show(self.repo.path, objects=[ca.id], outstream=outstream)
  2231. self.assertMultiLineEqual(
  2232. outstream.getvalue(),
  2233. """\
  2234. --------------------------------------------------
  2235. commit: 344da06c1bb85901270b3e8875c988a027ec087d
  2236. Author: Test Author <test@nodomain.com>
  2237. Committer: Test Committer <test@nodomain.com>
  2238. Date: Fri Jan 01 2010 00:00:00 +0000
  2239. Test message.
  2240. diff --git a/somename b/somename
  2241. new file mode 100644
  2242. index 0000000..ea5c7bf
  2243. --- /dev/null
  2244. +++ b/somename
  2245. @@ -0,0 +1 @@
  2246. +The Foo
  2247. """,
  2248. )
  2249. def test_tag(self) -> None:
  2250. a = Blob.from_string(b"The Foo\n")
  2251. ta = Tree()
  2252. ta.add(b"somename", 0o100644, a.id)
  2253. ca = make_commit(tree=ta.id)
  2254. self.repo.object_store.add_objects([(a, None), (ta, None), (ca, None)])
  2255. porcelain.tag_create(
  2256. self.repo.path,
  2257. b"tryme",
  2258. b"foo <foo@bar.com>",
  2259. b"bar",
  2260. annotated=True,
  2261. objectish=ca.id,
  2262. tag_time=1552854211,
  2263. tag_timezone=0,
  2264. )
  2265. outstream = StringIO()
  2266. porcelain.show(self.repo, objects=[b"refs/tags/tryme"], outstream=outstream)
  2267. self.maxDiff = None
  2268. self.assertMultiLineEqual(
  2269. outstream.getvalue(),
  2270. """\
  2271. Tagger: foo <foo@bar.com>
  2272. Date: Sun Mar 17 2019 20:23:31 +0000
  2273. bar
  2274. --------------------------------------------------
  2275. commit: 344da06c1bb85901270b3e8875c988a027ec087d
  2276. Author: Test Author <test@nodomain.com>
  2277. Committer: Test Committer <test@nodomain.com>
  2278. Date: Fri Jan 01 2010 00:00:00 +0000
  2279. Test message.
  2280. diff --git a/somename b/somename
  2281. new file mode 100644
  2282. index 0000000..ea5c7bf
  2283. --- /dev/null
  2284. +++ b/somename
  2285. @@ -0,0 +1 @@
  2286. +The Foo
  2287. """,
  2288. )
  2289. def test_tag_unicode(self) -> None:
  2290. a = Blob.from_string(b"The Foo\n")
  2291. ta = Tree()
  2292. ta.add(b"somename", 0o100644, a.id)
  2293. ca = make_commit(tree=ta.id)
  2294. self.repo.object_store.add_objects([(a, None), (ta, None), (ca, None)])
  2295. porcelain.tag_create(
  2296. self.repo.path,
  2297. "tryme",
  2298. "foo <foo@bar.com>",
  2299. "bar",
  2300. annotated=True,
  2301. objectish=ca.id,
  2302. tag_time=1552854211,
  2303. tag_timezone=0,
  2304. )
  2305. outstream = StringIO()
  2306. porcelain.show(self.repo, objects=[b"refs/tags/tryme"], outstream=outstream)
  2307. self.maxDiff = None
  2308. self.assertMultiLineEqual(
  2309. outstream.getvalue(),
  2310. """\
  2311. Tagger: foo <foo@bar.com>
  2312. Date: Sun Mar 17 2019 20:23:31 +0000
  2313. bar
  2314. --------------------------------------------------
  2315. commit: 344da06c1bb85901270b3e8875c988a027ec087d
  2316. Author: Test Author <test@nodomain.com>
  2317. Committer: Test Committer <test@nodomain.com>
  2318. Date: Fri Jan 01 2010 00:00:00 +0000
  2319. Test message.
  2320. diff --git a/somename b/somename
  2321. new file mode 100644
  2322. index 0000000..ea5c7bf
  2323. --- /dev/null
  2324. +++ b/somename
  2325. @@ -0,0 +1 @@
  2326. +The Foo
  2327. """,
  2328. )
  2329. def test_commit_with_change(self) -> None:
  2330. a = Blob.from_string(b"The Foo\n")
  2331. ta = Tree()
  2332. ta.add(b"somename", 0o100644, a.id)
  2333. ca = make_commit(tree=ta.id)
  2334. b = Blob.from_string(b"The Bar\n")
  2335. tb = Tree()
  2336. tb.add(b"somename", 0o100644, b.id)
  2337. cb = make_commit(tree=tb.id, parents=[ca.id])
  2338. self.repo.object_store.add_objects(
  2339. [
  2340. (a, None),
  2341. (b, None),
  2342. (ta, None),
  2343. (tb, None),
  2344. (ca, None),
  2345. (cb, None),
  2346. ]
  2347. )
  2348. outstream = StringIO()
  2349. porcelain.show(self.repo.path, objects=[cb.id], outstream=outstream)
  2350. self.assertMultiLineEqual(
  2351. outstream.getvalue(),
  2352. """\
  2353. --------------------------------------------------
  2354. commit: 2c6b6c9cb72c130956657e1fdae58e5b103744fa
  2355. Author: Test Author <test@nodomain.com>
  2356. Committer: Test Committer <test@nodomain.com>
  2357. Date: Fri Jan 01 2010 00:00:00 +0000
  2358. Test message.
  2359. diff --git a/somename b/somename
  2360. index ea5c7bf..fd38bcb 100644
  2361. --- a/somename
  2362. +++ b/somename
  2363. @@ -1 +1 @@
  2364. -The Foo
  2365. +The Bar
  2366. """,
  2367. )
  2368. class FormatPatchTests(PorcelainTestCase):
  2369. def test_format_patch_single_commit(self) -> None:
  2370. # Create initial commit
  2371. tree1 = Tree()
  2372. c1 = make_commit(
  2373. tree=tree1,
  2374. message=b"Initial commit",
  2375. )
  2376. self.repo.object_store.add_objects([(tree1, None), (c1, None)])
  2377. # Create second commit
  2378. b = Blob.from_string(b"modified")
  2379. tree = Tree()
  2380. tree.add(b"test.txt", 0o100644, b.id)
  2381. c2 = make_commit(
  2382. tree=tree,
  2383. parents=[c1.id],
  2384. message=b"Add test.txt",
  2385. )
  2386. self.repo.object_store.add_objects([(b, None), (tree, None), (c2, None)])
  2387. self.repo[b"HEAD"] = c2.id
  2388. # Generate patch for single commit
  2389. with tempfile.TemporaryDirectory() as tmpdir:
  2390. patches = porcelain.format_patch(
  2391. self.repo.path,
  2392. committish=c2.id,
  2393. outdir=tmpdir,
  2394. )
  2395. self.assertEqual(len(patches), 1)
  2396. self.assertTrue(patches[0].endswith("-Add-test.txt.patch"))
  2397. # Verify patch content
  2398. with open(patches[0], "rb") as f:
  2399. content = f.read()
  2400. self.assertIn(b"Subject: [PATCH 1/1] Add test.txt", content)
  2401. self.assertIn(b"+modified", content)
  2402. def test_format_patch_multiple_commits(self) -> None:
  2403. # Create commit chain
  2404. commits = []
  2405. for i in range(3):
  2406. blob = Blob.from_string(f"content {i}".encode())
  2407. tree = Tree()
  2408. tree.add(f"file{i}.txt".encode(), 0o100644, blob.id)
  2409. parents = [commits[-1].id] if commits else []
  2410. commit = make_commit(
  2411. tree=tree,
  2412. parents=parents,
  2413. message=f"Commit {i}".encode(),
  2414. )
  2415. self.repo.object_store.add_objects(
  2416. [(blob, None), (tree, None), (commit, None)]
  2417. )
  2418. commits.append(commit)
  2419. self.repo[b"HEAD"] = commits[-1].id
  2420. # Test generating last 2 commits
  2421. with tempfile.TemporaryDirectory() as tmpdir:
  2422. patches = porcelain.format_patch(
  2423. self.repo.path,
  2424. n=2,
  2425. outdir=tmpdir,
  2426. )
  2427. self.assertEqual(len(patches), 2)
  2428. self.assertTrue(patches[0].endswith("-Commit-1.patch"))
  2429. self.assertTrue(patches[1].endswith("-Commit-2.patch"))
  2430. # Check patch numbering
  2431. with open(patches[0], "rb") as f:
  2432. self.assertIn(b"Subject: [PATCH 1/2] Commit 1", f.read())
  2433. with open(patches[1], "rb") as f:
  2434. self.assertIn(b"Subject: [PATCH 2/2] Commit 2", f.read())
  2435. def test_format_patch_range(self) -> None:
  2436. # Create commit chain
  2437. c1, _c2, c3 = build_commit_graph(self.repo.object_store, [[1], [2, 1], [3, 2]])
  2438. self.repo[b"HEAD"] = c3.id
  2439. # Test commit range
  2440. with tempfile.TemporaryDirectory() as tmpdir:
  2441. patches = porcelain.format_patch(
  2442. self.repo.path,
  2443. committish=(c1.id, c3.id),
  2444. outdir=tmpdir,
  2445. )
  2446. # Should include c2 and c3
  2447. self.assertEqual(len(patches), 2)
  2448. def test_format_patch_stdout(self) -> None:
  2449. # Create a commit
  2450. blob = Blob.from_string(b"test content")
  2451. tree = Tree()
  2452. tree.add(b"test.txt", 0o100644, blob.id)
  2453. commit = make_commit(
  2454. tree=tree,
  2455. message=b"Test commit",
  2456. )
  2457. self.repo.object_store.add_objects([(blob, None), (tree, None), (commit, None)])
  2458. self.repo[b"HEAD"] = commit.id
  2459. # Test stdout output
  2460. outstream = BytesIO()
  2461. patches = porcelain.format_patch(
  2462. self.repo.path,
  2463. committish=commit.id,
  2464. stdout=True,
  2465. outstream=outstream,
  2466. )
  2467. # Should return empty list when writing to stdout
  2468. self.assertEqual(patches, [])
  2469. # Check stdout content
  2470. outstream.seek(0)
  2471. content = outstream.read()
  2472. self.assertIn(b"Subject: [PATCH 1/1] Test commit", content)
  2473. self.assertIn(b"diff --git", content)
  2474. def test_format_patch_no_commits(self) -> None:
  2475. # Test with a new repository with no commits
  2476. # Just remove HEAD to simulate empty repo
  2477. patches = porcelain.format_patch(
  2478. self.repo.path,
  2479. n=5,
  2480. )
  2481. self.assertEqual(patches, [])
  2482. class SymbolicRefTests(PorcelainTestCase):
  2483. def test_set_wrong_symbolic_ref(self) -> None:
  2484. _c1, _c2, c3 = build_commit_graph(
  2485. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2486. )
  2487. self.repo.refs[b"HEAD"] = c3.id
  2488. self.assertRaises(
  2489. porcelain.Error, porcelain.symbolic_ref, self.repo.path, b"foobar"
  2490. )
  2491. def test_set_force_wrong_symbolic_ref(self) -> None:
  2492. _c1, _c2, c3 = build_commit_graph(
  2493. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2494. )
  2495. self.repo.refs[b"HEAD"] = c3.id
  2496. porcelain.symbolic_ref(self.repo.path, b"force_foobar", force=True)
  2497. # test if we actually changed the file
  2498. with self.repo.get_named_file("HEAD") as f:
  2499. new_ref = f.read()
  2500. self.assertEqual(new_ref, b"ref: refs/heads/force_foobar\n")
  2501. def test_set_symbolic_ref(self) -> None:
  2502. _c1, _c2, c3 = build_commit_graph(
  2503. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2504. )
  2505. self.repo.refs[b"HEAD"] = c3.id
  2506. porcelain.symbolic_ref(self.repo.path, b"master")
  2507. def test_set_symbolic_ref_other_than_master(self) -> None:
  2508. _c1, _c2, c3 = build_commit_graph(
  2509. self.repo.object_store,
  2510. [[1], [2, 1], [3, 1, 2]],
  2511. attrs=dict(refs="develop"),
  2512. )
  2513. self.repo.refs[b"HEAD"] = c3.id
  2514. self.repo.refs[b"refs/heads/develop"] = c3.id
  2515. porcelain.symbolic_ref(self.repo.path, b"develop")
  2516. # test if we actually changed the file
  2517. with self.repo.get_named_file("HEAD") as f:
  2518. new_ref = f.read()
  2519. self.assertEqual(new_ref, b"ref: refs/heads/develop\n")
  2520. class DiffTreeTests(PorcelainTestCase):
  2521. def test_empty(self) -> None:
  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. outstream = BytesIO()
  2527. porcelain.diff_tree(self.repo.path, c2.tree, c3.tree, outstream=outstream)
  2528. self.assertEqual(outstream.getvalue(), b"")
  2529. class DiffTests(PorcelainTestCase):
  2530. def test_diff_uncommitted_stage(self) -> None:
  2531. # Test diff in repository with no commits yet
  2532. fullpath = os.path.join(self.repo.path, "test.txt")
  2533. with open(fullpath, "w") as f:
  2534. f.write("Hello, world!\n")
  2535. porcelain.add(self.repo.path, paths=["test.txt"])
  2536. outstream = BytesIO()
  2537. porcelain.diff(self.repo.path, staged=True, outstream=outstream)
  2538. diff_output = outstream.getvalue()
  2539. self.assertIn(b"diff --git a/test.txt b/test.txt", diff_output)
  2540. self.assertIn(b"new file mode", diff_output)
  2541. self.assertIn(b"+Hello, world!", diff_output)
  2542. def test_diff_uncommitted_working_tree(self) -> None:
  2543. # Test unstaged changes in repository with no commits
  2544. fullpath = os.path.join(self.repo.path, "test.txt")
  2545. with open(fullpath, "w") as f:
  2546. f.write("Hello, world!\n")
  2547. porcelain.add(self.repo.path, paths=["test.txt"])
  2548. # Modify file in working tree
  2549. with open(fullpath, "w") as f:
  2550. f.write("Hello, world!\nNew line\n")
  2551. outstream = BytesIO()
  2552. porcelain.diff(self.repo.path, staged=False, outstream=outstream)
  2553. diff_output = outstream.getvalue()
  2554. self.assertIn(b"diff --git a/test.txt b/test.txt", diff_output)
  2555. self.assertIn(b"+New line", diff_output)
  2556. def test_diff_with_commits_staged(self) -> None:
  2557. # Test staged changes with existing commits
  2558. fullpath = os.path.join(self.repo.path, "test.txt")
  2559. with open(fullpath, "w") as f:
  2560. f.write("Initial content\n")
  2561. porcelain.add(self.repo.path, paths=["test.txt"])
  2562. porcelain.commit(self.repo.path, message=b"Initial commit")
  2563. # Modify and stage
  2564. with open(fullpath, "w") as f:
  2565. f.write("Initial content\nModified\n")
  2566. porcelain.add(self.repo.path, paths=["test.txt"])
  2567. outstream = BytesIO()
  2568. porcelain.diff(self.repo.path, staged=True, outstream=outstream)
  2569. diff_output = outstream.getvalue()
  2570. self.assertIn(b"diff --git a/test.txt b/test.txt", diff_output)
  2571. self.assertIn(b"+Modified", diff_output)
  2572. def test_diff_with_commits_unstaged(self) -> None:
  2573. # Test unstaged changes with existing commits
  2574. fullpath = os.path.join(self.repo.path, "test.txt")
  2575. with open(fullpath, "w") as f:
  2576. f.write("Initial content\n")
  2577. porcelain.add(self.repo.path, paths=["test.txt"])
  2578. porcelain.commit(self.repo.path, message=b"Initial commit")
  2579. # Modify without staging
  2580. with open(fullpath, "w") as f:
  2581. f.write("Initial content\nModified\n")
  2582. outstream = BytesIO()
  2583. porcelain.diff(self.repo.path, staged=False, outstream=outstream)
  2584. diff_output = outstream.getvalue()
  2585. self.assertIn(b"diff --git a/test.txt b/test.txt", diff_output)
  2586. self.assertIn(b"+Modified", diff_output)
  2587. def test_diff_file_deletion(self) -> None:
  2588. # Test showing file deletion
  2589. fullpath = os.path.join(self.repo.path, "test.txt")
  2590. with open(fullpath, "w") as f:
  2591. f.write("Content to delete\n")
  2592. porcelain.add(self.repo.path, paths=["test.txt"])
  2593. porcelain.commit(self.repo.path, message=b"Add file")
  2594. # Delete file
  2595. os.unlink(fullpath)
  2596. outstream = BytesIO()
  2597. porcelain.diff(self.repo.path, staged=False, outstream=outstream)
  2598. diff_output = outstream.getvalue()
  2599. self.assertIn(b"diff --git a/test.txt b/test.txt", diff_output)
  2600. self.assertIn(b"deleted file mode", diff_output)
  2601. self.assertIn(b"-Content to delete", diff_output)
  2602. def test_diff_with_paths(self) -> None:
  2603. # Test diff with specific paths
  2604. # Create two files
  2605. fullpath1 = os.path.join(self.repo.path, "file1.txt")
  2606. fullpath2 = os.path.join(self.repo.path, "file2.txt")
  2607. with open(fullpath1, "w") as f:
  2608. f.write("File 1 content\n")
  2609. with open(fullpath2, "w") as f:
  2610. f.write("File 2 content\n")
  2611. porcelain.add(self.repo.path, paths=["file1.txt", "file2.txt"])
  2612. porcelain.commit(self.repo.path, message=b"Add two files")
  2613. # Modify both files
  2614. with open(fullpath1, "w") as f:
  2615. f.write("File 1 modified\n")
  2616. with open(fullpath2, "w") as f:
  2617. f.write("File 2 modified\n")
  2618. # Test diff with specific path
  2619. outstream = BytesIO()
  2620. porcelain.diff(self.repo.path, paths=["file1.txt"], outstream=outstream)
  2621. diff_output = outstream.getvalue()
  2622. self.assertIn(b"diff --git a/file1.txt b/file1.txt", diff_output)
  2623. self.assertIn(b"-File 1 content", diff_output)
  2624. self.assertIn(b"+File 1 modified", diff_output)
  2625. # file2.txt should not appear in diff
  2626. self.assertNotIn(b"file2.txt", diff_output)
  2627. def test_diff_with_paths_multiple(self) -> None:
  2628. # Test diff with multiple paths
  2629. # Create three files
  2630. fullpath1 = os.path.join(self.repo.path, "file1.txt")
  2631. fullpath2 = os.path.join(self.repo.path, "file2.txt")
  2632. fullpath3 = os.path.join(self.repo.path, "file3.txt")
  2633. with open(fullpath1, "w") as f:
  2634. f.write("File 1 content\n")
  2635. with open(fullpath2, "w") as f:
  2636. f.write("File 2 content\n")
  2637. with open(fullpath3, "w") as f:
  2638. f.write("File 3 content\n")
  2639. porcelain.add(self.repo.path, paths=["file1.txt", "file2.txt", "file3.txt"])
  2640. porcelain.commit(self.repo.path, message=b"Add three files")
  2641. # Modify all files
  2642. with open(fullpath1, "w") as f:
  2643. f.write("File 1 modified\n")
  2644. with open(fullpath2, "w") as f:
  2645. f.write("File 2 modified\n")
  2646. with open(fullpath3, "w") as f:
  2647. f.write("File 3 modified\n")
  2648. # Test diff with two specific paths
  2649. outstream = BytesIO()
  2650. porcelain.diff(
  2651. self.repo.path, paths=["file1.txt", "file3.txt"], outstream=outstream
  2652. )
  2653. diff_output = outstream.getvalue()
  2654. self.assertIn(b"diff --git a/file1.txt b/file1.txt", diff_output)
  2655. self.assertIn(b"diff --git a/file3.txt b/file3.txt", diff_output)
  2656. # file2.txt should not appear in diff
  2657. self.assertNotIn(b"file2.txt", diff_output)
  2658. def test_diff_with_paths_directory(self) -> None:
  2659. # Test diff with directory paths
  2660. # Create files in subdirectory
  2661. os.mkdir(os.path.join(self.repo.path, "subdir"))
  2662. fullpath1 = os.path.join(self.repo.path, "subdir", "file1.txt")
  2663. fullpath2 = os.path.join(self.repo.path, "subdir", "file2.txt")
  2664. fullpath3 = os.path.join(self.repo.path, "root.txt")
  2665. with open(fullpath1, "w") as f:
  2666. f.write("Subdir file 1\n")
  2667. with open(fullpath2, "w") as f:
  2668. f.write("Subdir file 2\n")
  2669. with open(fullpath3, "w") as f:
  2670. f.write("Root file\n")
  2671. porcelain.add(
  2672. self.repo.path, paths=["subdir/file1.txt", "subdir/file2.txt", "root.txt"]
  2673. )
  2674. porcelain.commit(self.repo.path, message=b"Add files in subdir")
  2675. # Modify all files
  2676. with open(fullpath1, "w") as f:
  2677. f.write("Subdir file 1 modified\n")
  2678. with open(fullpath2, "w") as f:
  2679. f.write("Subdir file 2 modified\n")
  2680. with open(fullpath3, "w") as f:
  2681. f.write("Root file modified\n")
  2682. # Test diff with directory path
  2683. outstream = BytesIO()
  2684. porcelain.diff(self.repo.path, paths=["subdir"], outstream=outstream)
  2685. diff_output = outstream.getvalue()
  2686. self.assertIn(b"subdir/file1.txt", diff_output)
  2687. self.assertIn(b"subdir/file2.txt", diff_output)
  2688. # root.txt should not appear in diff
  2689. self.assertNotIn(b"root.txt", diff_output)
  2690. def test_diff_staged_with_paths(self) -> None:
  2691. # Test staged diff with specific paths
  2692. # Create two files
  2693. fullpath1 = os.path.join(self.repo.path, "file1.txt")
  2694. fullpath2 = os.path.join(self.repo.path, "file2.txt")
  2695. with open(fullpath1, "w") as f:
  2696. f.write("File 1 content\n")
  2697. with open(fullpath2, "w") as f:
  2698. f.write("File 2 content\n")
  2699. porcelain.add(self.repo.path, paths=["file1.txt", "file2.txt"])
  2700. porcelain.commit(self.repo.path, message=b"Add two files")
  2701. # Modify and stage both files
  2702. with open(fullpath1, "w") as f:
  2703. f.write("File 1 staged\n")
  2704. with open(fullpath2, "w") as f:
  2705. f.write("File 2 staged\n")
  2706. porcelain.add(self.repo.path, paths=["file1.txt", "file2.txt"])
  2707. # Test staged diff with specific path
  2708. outstream = BytesIO()
  2709. porcelain.diff(
  2710. self.repo.path, staged=True, paths=["file1.txt"], outstream=outstream
  2711. )
  2712. diff_output = outstream.getvalue()
  2713. self.assertIn(b"diff --git a/file1.txt b/file1.txt", diff_output)
  2714. self.assertIn(b"-File 1 content", diff_output)
  2715. self.assertIn(b"+File 1 staged", diff_output)
  2716. # file2.txt should not appear in diff
  2717. self.assertNotIn(b"file2.txt", diff_output)
  2718. def test_diff_with_commit_and_paths(self) -> None:
  2719. # Test diff against specific commit with paths
  2720. # Create initial file
  2721. fullpath1 = os.path.join(self.repo.path, "file1.txt")
  2722. fullpath2 = os.path.join(self.repo.path, "file2.txt")
  2723. with open(fullpath1, "w") as f:
  2724. f.write("Initial content 1\n")
  2725. with open(fullpath2, "w") as f:
  2726. f.write("Initial content 2\n")
  2727. porcelain.add(self.repo.path, paths=["file1.txt", "file2.txt"])
  2728. first_commit = porcelain.commit(self.repo.path, message=b"Initial commit")
  2729. # Make second commit
  2730. with open(fullpath1, "w") as f:
  2731. f.write("Second content 1\n")
  2732. with open(fullpath2, "w") as f:
  2733. f.write("Second content 2\n")
  2734. porcelain.add(self.repo.path, paths=["file1.txt", "file2.txt"])
  2735. porcelain.commit(self.repo.path, message=b"Second commit")
  2736. # Modify working tree
  2737. with open(fullpath1, "w") as f:
  2738. f.write("Working content 1\n")
  2739. with open(fullpath2, "w") as f:
  2740. f.write("Working content 2\n")
  2741. # Test diff against first commit with specific path
  2742. outstream = BytesIO()
  2743. porcelain.diff(
  2744. self.repo.path,
  2745. commit=first_commit,
  2746. paths=["file1.txt"],
  2747. outstream=outstream,
  2748. )
  2749. diff_output = outstream.getvalue()
  2750. self.assertIn(b"diff --git a/file1.txt b/file1.txt", diff_output)
  2751. self.assertIn(b"-Initial content 1", diff_output)
  2752. self.assertIn(b"+Working content 1", diff_output)
  2753. # file2.txt should not appear in diff
  2754. self.assertNotIn(b"file2.txt", diff_output)
  2755. class CommitTreeTests(PorcelainTestCase):
  2756. def test_simple(self) -> None:
  2757. _c1, _c2, _c3 = build_commit_graph(
  2758. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2759. )
  2760. b = Blob()
  2761. b.data = b"foo the bar"
  2762. t = Tree()
  2763. t.add(b"somename", 0o100644, b.id)
  2764. self.repo.object_store.add_object(t)
  2765. self.repo.object_store.add_object(b)
  2766. sha = porcelain.commit_tree(
  2767. self.repo.path,
  2768. t.id,
  2769. message=b"Withcommit.",
  2770. author=b"Joe <joe@example.com>",
  2771. committer=b"Jane <jane@example.com>",
  2772. )
  2773. self.assertIsInstance(sha, bytes)
  2774. self.assertEqual(len(sha), 40)
  2775. class RevListTests(PorcelainTestCase):
  2776. def test_simple(self) -> None:
  2777. c1, c2, c3 = build_commit_graph(
  2778. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2779. )
  2780. outstream = BytesIO()
  2781. porcelain.rev_list(self.repo.path, [c3.id], outstream=outstream)
  2782. self.assertEqual(
  2783. c3.id + b"\n" + c2.id + b"\n" + c1.id + b"\n", outstream.getvalue()
  2784. )
  2785. @skipIf(
  2786. platform.python_implementation() == "PyPy" or sys.platform == "win32",
  2787. "gpgme not easily available or supported on Windows and PyPy",
  2788. )
  2789. class TagCreateSignTests(PorcelainGpgTestCase):
  2790. def test_default_key(self) -> None:
  2791. _c1, _c2, c3 = build_commit_graph(
  2792. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2793. )
  2794. self.repo.refs[b"HEAD"] = c3.id
  2795. cfg = self.repo.get_config()
  2796. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  2797. self.import_default_key()
  2798. porcelain.tag_create(
  2799. self.repo.path,
  2800. b"tryme",
  2801. b"foo <foo@bar.com>",
  2802. b"bar",
  2803. annotated=True,
  2804. sign=True,
  2805. )
  2806. tags = self.repo.refs.as_dict(b"refs/tags")
  2807. self.assertEqual(list(tags.keys()), [b"tryme"])
  2808. tag = self.repo[b"refs/tags/tryme"]
  2809. self.assertIsInstance(tag, Tag)
  2810. self.assertEqual(b"foo <foo@bar.com>", tag.tagger)
  2811. self.assertEqual(b"bar\n", tag.message)
  2812. self.assertRecentTimestamp(tag.tag_time)
  2813. tag = self.repo[b"refs/tags/tryme"]
  2814. assert isinstance(tag, Tag)
  2815. # GPG Signatures aren't deterministic, so we can't do a static assertion.
  2816. from dulwich.signature import (
  2817. BadSignature,
  2818. UntrustedSignature,
  2819. get_signature_vendor_for_signature,
  2820. )
  2821. self.assertIsNotNone(tag.signature)
  2822. vendor = get_signature_vendor_for_signature(tag.signature)
  2823. vendor.verify(tag.raw_without_sig(), tag.signature)
  2824. # Verify with specific keyid
  2825. vendor_with_keyid = get_signature_vendor_for_signature(
  2826. tag.signature, keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID]
  2827. )
  2828. vendor_with_keyid.verify(tag.raw_without_sig(), tag.signature)
  2829. self.import_non_default_key()
  2830. # Verify with wrong keyid - should raise UntrustedSignature
  2831. vendor_wrong_keyid = get_signature_vendor_for_signature(
  2832. tag.signature, keyids=[PorcelainGpgTestCase.NON_DEFAULT_KEY_ID]
  2833. )
  2834. self.assertRaises(
  2835. UntrustedSignature,
  2836. vendor_wrong_keyid.verify,
  2837. tag.raw_without_sig(),
  2838. tag.signature,
  2839. )
  2840. assert tag.signature is not None
  2841. tag._chunked_text = [b"bad data", tag.signature]
  2842. self.assertRaises(
  2843. BadSignature,
  2844. vendor.verify,
  2845. tag.raw_without_sig(),
  2846. tag.signature,
  2847. )
  2848. def test_non_default_key(self) -> None:
  2849. _c1, _c2, c3 = build_commit_graph(
  2850. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2851. )
  2852. self.repo.refs[b"HEAD"] = c3.id
  2853. cfg = self.repo.get_config()
  2854. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  2855. self.import_non_default_key()
  2856. porcelain.tag_create(
  2857. self.repo.path,
  2858. b"tryme",
  2859. b"foo <foo@bar.com>",
  2860. b"bar",
  2861. annotated=True,
  2862. sign=True,
  2863. )
  2864. tags = self.repo.refs.as_dict(b"refs/tags")
  2865. self.assertEqual(list(tags.keys()), [b"tryme"])
  2866. tag = self.repo[b"refs/tags/tryme"]
  2867. self.assertIsInstance(tag, Tag)
  2868. self.assertEqual(b"foo <foo@bar.com>", tag.tagger)
  2869. self.assertEqual(b"bar\n", tag.message)
  2870. self.assertRecentTimestamp(tag.tag_time)
  2871. tag = self.repo[b"refs/tags/tryme"]
  2872. assert isinstance(tag, Tag)
  2873. # GPG Signatures aren't deterministic, so we can't do a static assertion.
  2874. from dulwich.signature import get_signature_vendor_for_signature
  2875. self.assertIsNotNone(tag.signature)
  2876. vendor = get_signature_vendor_for_signature(tag.signature)
  2877. vendor.verify(tag.raw_without_sig(), tag.signature)
  2878. def test_sign_uses_config_signingkey(self) -> None:
  2879. """Test that sign=True uses user.signingKey from config."""
  2880. _c1, _c2, c3 = build_commit_graph(
  2881. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2882. )
  2883. self.repo.refs[b"HEAD"] = c3.id
  2884. # Set up user.signingKey in config
  2885. cfg = self.repo.get_config()
  2886. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  2887. cfg.write_to_path()
  2888. self.import_default_key()
  2889. # Create tag with sign=True (should use signingKey from config)
  2890. porcelain.tag_create(
  2891. self.repo.path,
  2892. b"signed-tag",
  2893. b"foo <foo@bar.com>",
  2894. b"Tag with configured key",
  2895. annotated=True,
  2896. sign=True, # This should read user.signingKey from config
  2897. )
  2898. tags = self.repo.refs.as_dict(b"refs/tags")
  2899. self.assertEqual(list(tags.keys()), [b"signed-tag"])
  2900. tag = self.repo[b"refs/tags/signed-tag"]
  2901. self.assertIsInstance(tag, Tag)
  2902. # Verify the tag is signed with the configured key
  2903. from dulwich.signature import get_signature_vendor_for_signature
  2904. self.assertIsNotNone(tag.signature)
  2905. vendor = get_signature_vendor_for_signature(tag.signature)
  2906. vendor.verify(tag.raw_without_sig(), tag.signature)
  2907. # Verify with specific keyid
  2908. vendor_with_keyid = get_signature_vendor_for_signature(
  2909. tag.signature, keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID]
  2910. )
  2911. vendor_with_keyid.verify(tag.raw_without_sig(), tag.signature)
  2912. def test_tag_gpg_sign_config_enabled(self) -> None:
  2913. """Test that tag.gpgSign=true automatically signs tags."""
  2914. _c1, _c2, c3 = build_commit_graph(
  2915. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2916. )
  2917. self.repo.refs[b"HEAD"] = c3.id
  2918. # Set up user.signingKey and tag.gpgSign in config
  2919. cfg = self.repo.get_config()
  2920. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  2921. cfg.set(("tag",), "gpgSign", True)
  2922. cfg.write_to_path()
  2923. self.import_default_key()
  2924. # Create tag without explicit sign parameter (should auto-sign due to config)
  2925. porcelain.tag_create(
  2926. self.repo.path,
  2927. b"auto-signed-tag",
  2928. b"foo <foo@bar.com>",
  2929. b"Auto-signed tag",
  2930. annotated=True,
  2931. # No sign parameter - should use tag.gpgSign config
  2932. )
  2933. tags = self.repo.refs.as_dict(b"refs/tags")
  2934. self.assertEqual(list(tags.keys()), [b"auto-signed-tag"])
  2935. tag = self.repo[b"refs/tags/auto-signed-tag"]
  2936. self.assertIsInstance(tag, Tag)
  2937. # Verify the tag is signed due to config
  2938. from dulwich.signature import get_signature_vendor_for_signature
  2939. self.assertIsNotNone(tag.signature)
  2940. vendor = get_signature_vendor_for_signature(tag.signature)
  2941. vendor.verify(tag.raw_without_sig(), tag.signature)
  2942. # Verify with specific keyid
  2943. vendor_with_keyid = get_signature_vendor_for_signature(
  2944. tag.signature, keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID]
  2945. )
  2946. vendor_with_keyid.verify(tag.raw_without_sig(), tag.signature)
  2947. def test_tag_gpg_sign_config_disabled(self) -> None:
  2948. """Test that tag.gpgSign=false does not sign tags."""
  2949. _c1, _c2, c3 = build_commit_graph(
  2950. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2951. )
  2952. self.repo.refs[b"HEAD"] = c3.id
  2953. # Set up user.signingKey and tag.gpgSign=false in config
  2954. cfg = self.repo.get_config()
  2955. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  2956. cfg.set(("tag",), "gpgSign", False)
  2957. cfg.write_to_path()
  2958. self.import_default_key()
  2959. # Create tag without explicit sign parameter (should not sign)
  2960. porcelain.tag_create(
  2961. self.repo.path,
  2962. b"unsigned-tag",
  2963. b"foo <foo@bar.com>",
  2964. b"Unsigned tag",
  2965. annotated=True,
  2966. # No sign parameter - should use tag.gpgSign=false config
  2967. )
  2968. tags = self.repo.refs.as_dict(b"refs/tags")
  2969. self.assertEqual(list(tags.keys()), [b"unsigned-tag"])
  2970. tag = self.repo[b"refs/tags/unsigned-tag"]
  2971. self.assertIsInstance(tag, Tag)
  2972. # Verify the tag is not signed
  2973. self.assertIsNone(tag._signature)
  2974. def test_tag_gpg_sign_config_no_signing_key(self) -> None:
  2975. """Test that tag.gpgSign=true works without user.signingKey (uses default)."""
  2976. _c1, _c2, c3 = build_commit_graph(
  2977. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  2978. )
  2979. self.repo.refs[b"HEAD"] = c3.id
  2980. # Set up tag.gpgSign but no user.signingKey
  2981. cfg = self.repo.get_config()
  2982. cfg.set(("tag",), "gpgSign", True)
  2983. cfg.write_to_path()
  2984. self.import_default_key()
  2985. # Create tag without explicit sign parameter (should auto-sign with default key)
  2986. porcelain.tag_create(
  2987. self.repo.path,
  2988. b"default-signed-tag",
  2989. b"foo <foo@bar.com>",
  2990. b"Default signed tag",
  2991. annotated=True,
  2992. # No sign parameter - should use tag.gpgSign config with default key
  2993. )
  2994. tags = self.repo.refs.as_dict(b"refs/tags")
  2995. self.assertEqual(list(tags.keys()), [b"default-signed-tag"])
  2996. tag = self.repo[b"refs/tags/default-signed-tag"]
  2997. self.assertIsInstance(tag, Tag)
  2998. # Verify the tag is signed with default key
  2999. from dulwich.signature import get_signature_vendor_for_signature
  3000. self.assertIsNotNone(tag.signature)
  3001. vendor = get_signature_vendor_for_signature(tag.signature)
  3002. vendor.verify(tag.raw_without_sig(), tag.signature)
  3003. def test_explicit_sign_overrides_config(self) -> None:
  3004. """Test that explicit sign parameter overrides tag.gpgSign config."""
  3005. _c1, _c2, c3 = build_commit_graph(
  3006. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  3007. )
  3008. self.repo.refs[b"HEAD"] = c3.id
  3009. # Set up tag.gpgSign=false but explicitly pass sign=True
  3010. cfg = self.repo.get_config()
  3011. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  3012. cfg.set(("tag",), "gpgSign", False)
  3013. cfg.write_to_path()
  3014. self.import_default_key()
  3015. # Create tag with explicit sign=True (should override config)
  3016. porcelain.tag_create(
  3017. self.repo.path,
  3018. b"explicit-signed-tag",
  3019. b"foo <foo@bar.com>",
  3020. b"Explicitly signed tag",
  3021. annotated=True,
  3022. sign=True, # This should override tag.gpgSign=false
  3023. )
  3024. tags = self.repo.refs.as_dict(b"refs/tags")
  3025. self.assertEqual(list(tags.keys()), [b"explicit-signed-tag"])
  3026. tag = self.repo[b"refs/tags/explicit-signed-tag"]
  3027. self.assertIsInstance(tag, Tag)
  3028. # Verify the tag is signed despite config=false
  3029. from dulwich.signature import get_signature_vendor_for_signature
  3030. self.assertIsNotNone(tag.signature)
  3031. vendor = get_signature_vendor_for_signature(tag.signature)
  3032. vendor.verify(tag.raw_without_sig(), tag.signature)
  3033. # Verify with specific keyid
  3034. vendor_with_keyid = get_signature_vendor_for_signature(
  3035. tag.signature, keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID]
  3036. )
  3037. vendor_with_keyid.verify(tag.raw_without_sig(), tag.signature)
  3038. def test_explicit_false_disables_tag_signing(self) -> None:
  3039. """Test that explicit sign=False disables signing even with config=true."""
  3040. _c1, _c2, c3 = build_commit_graph(
  3041. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  3042. )
  3043. self.repo.refs[b"HEAD"] = c3.id
  3044. # Set up tag.gpgSign=true but explicitly pass sign=False
  3045. cfg = self.repo.get_config()
  3046. cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
  3047. cfg.set(("tag",), "gpgSign", True)
  3048. cfg.write_to_path()
  3049. self.import_default_key()
  3050. # Create tag with explicit sign=False (should disable signing)
  3051. porcelain.tag_create(
  3052. self.repo.path,
  3053. b"explicit-unsigned-tag",
  3054. b"foo <foo@bar.com>",
  3055. b"Explicitly unsigned tag",
  3056. annotated=True,
  3057. sign=False, # This should override tag.gpgSign=true
  3058. )
  3059. tags = self.repo.refs.as_dict(b"refs/tags")
  3060. self.assertEqual(list(tags.keys()), [b"explicit-unsigned-tag"])
  3061. tag = self.repo[b"refs/tags/explicit-unsigned-tag"]
  3062. self.assertIsInstance(tag, Tag)
  3063. # Verify the tag is NOT signed despite config=true
  3064. self.assertIsNone(tag._signature)
  3065. class TagCreateTests(PorcelainTestCase):
  3066. def test_annotated(self) -> None:
  3067. _c1, _c2, c3 = build_commit_graph(
  3068. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  3069. )
  3070. self.repo.refs[b"HEAD"] = c3.id
  3071. porcelain.tag_create(
  3072. self.repo.path,
  3073. b"tryme",
  3074. b"foo <foo@bar.com>",
  3075. b"bar",
  3076. annotated=True,
  3077. )
  3078. tags = self.repo.refs.as_dict(b"refs/tags")
  3079. self.assertEqual(list(tags.keys()), [b"tryme"])
  3080. tag = self.repo[b"refs/tags/tryme"]
  3081. self.assertIsInstance(tag, Tag)
  3082. self.assertEqual(b"foo <foo@bar.com>", tag.tagger)
  3083. self.assertEqual(b"bar\n", tag.message)
  3084. self.assertRecentTimestamp(tag.tag_time)
  3085. def test_unannotated(self) -> None:
  3086. _c1, _c2, c3 = build_commit_graph(
  3087. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  3088. )
  3089. self.repo.refs[b"HEAD"] = c3.id
  3090. porcelain.tag_create(self.repo.path, b"tryme", annotated=False)
  3091. tags = self.repo.refs.as_dict(b"refs/tags")
  3092. self.assertEqual(list(tags.keys()), [b"tryme"])
  3093. self.repo[b"refs/tags/tryme"]
  3094. self.assertEqual(list(tags.values()), [self.repo.head()])
  3095. def test_unannotated_unicode(self) -> None:
  3096. _c1, _c2, c3 = build_commit_graph(
  3097. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  3098. )
  3099. self.repo.refs[b"HEAD"] = c3.id
  3100. porcelain.tag_create(self.repo.path, "tryme", annotated=False)
  3101. tags = self.repo.refs.as_dict(b"refs/tags")
  3102. self.assertEqual(list(tags.keys()), [b"tryme"])
  3103. self.repo[b"refs/tags/tryme"]
  3104. self.assertEqual(list(tags.values()), [self.repo.head()])
  3105. class TagListTests(PorcelainTestCase):
  3106. def test_empty(self) -> None:
  3107. tags = porcelain.tag_list(self.repo.path)
  3108. self.assertEqual([], tags)
  3109. def test_simple(self) -> None:
  3110. self.repo.refs[b"refs/tags/foo"] = b"aa" * 20
  3111. self.repo.refs[b"refs/tags/bar/bla"] = b"bb" * 20
  3112. tags = porcelain.tag_list(self.repo.path)
  3113. self.assertEqual([b"bar/bla", b"foo"], tags)
  3114. class TagDeleteTests(PorcelainTestCase):
  3115. def test_simple(self) -> None:
  3116. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  3117. self.repo[b"HEAD"] = c1.id
  3118. porcelain.tag_create(self.repo, b"foo")
  3119. self.assertIn(b"foo", porcelain.tag_list(self.repo))
  3120. porcelain.tag_delete(self.repo, b"foo")
  3121. self.assertNotIn(b"foo", porcelain.tag_list(self.repo))
  3122. class ResetTests(PorcelainTestCase):
  3123. def test_hard_head(self) -> None:
  3124. fullpath = os.path.join(self.repo.path, "foo")
  3125. with open(fullpath, "w") as f:
  3126. f.write("BAR")
  3127. porcelain.add(self.repo.path, paths=[fullpath])
  3128. porcelain.commit(
  3129. self.repo.path,
  3130. message=b"Some message",
  3131. committer=b"Jane <jane@example.com>",
  3132. author=b"John <john@example.com>",
  3133. )
  3134. with open(os.path.join(self.repo.path, "foo"), "wb") as f:
  3135. f.write(b"OOH")
  3136. porcelain.reset(self.repo, "hard", b"HEAD")
  3137. index = self.repo.open_index()
  3138. changes = list(
  3139. tree_changes(
  3140. self.repo.object_store,
  3141. index.commit(self.repo.object_store),
  3142. self.repo[b"HEAD"].tree,
  3143. )
  3144. )
  3145. self.assertEqual([], changes)
  3146. def test_hard_commit(self) -> None:
  3147. fullpath = os.path.join(self.repo.path, "foo")
  3148. with open(fullpath, "w") as f:
  3149. f.write("BAR")
  3150. porcelain.add(self.repo.path, paths=[fullpath])
  3151. sha = porcelain.commit(
  3152. self.repo.path,
  3153. message=b"Some message",
  3154. committer=b"Jane <jane@example.com>",
  3155. author=b"John <john@example.com>",
  3156. )
  3157. with open(fullpath, "wb") as f:
  3158. f.write(b"BAZ")
  3159. porcelain.add(self.repo.path, paths=[fullpath])
  3160. porcelain.commit(
  3161. self.repo.path,
  3162. message=b"Some other message",
  3163. committer=b"Jane <jane@example.com>",
  3164. author=b"John <john@example.com>",
  3165. )
  3166. porcelain.reset(self.repo, "hard", sha)
  3167. index = self.repo.open_index()
  3168. changes = list(
  3169. tree_changes(
  3170. self.repo.object_store,
  3171. index.commit(self.repo.object_store),
  3172. self.repo[sha].tree,
  3173. )
  3174. )
  3175. self.assertEqual([], changes)
  3176. def test_hard_commit_short_hash(self) -> None:
  3177. fullpath = os.path.join(self.repo.path, "foo")
  3178. with open(fullpath, "w") as f:
  3179. f.write("BAR")
  3180. porcelain.add(self.repo.path, paths=[fullpath])
  3181. sha = porcelain.commit(
  3182. self.repo.path,
  3183. message=b"Some message",
  3184. committer=b"Jane <jane@example.com>",
  3185. author=b"John <john@example.com>",
  3186. )
  3187. with open(fullpath, "wb") as f:
  3188. f.write(b"BAZ")
  3189. porcelain.add(self.repo.path, paths=[fullpath])
  3190. porcelain.commit(
  3191. self.repo.path,
  3192. message=b"Some other message",
  3193. committer=b"Jane <jane@example.com>",
  3194. author=b"John <john@example.com>",
  3195. )
  3196. # Test with short hash (7 characters)
  3197. short_sha = sha[:7].decode("ascii")
  3198. porcelain.reset(self.repo, "hard", short_sha)
  3199. index = self.repo.open_index()
  3200. changes = list(
  3201. tree_changes(
  3202. self.repo.object_store,
  3203. index.commit(self.repo.object_store),
  3204. self.repo[sha].tree,
  3205. )
  3206. )
  3207. self.assertEqual([], changes)
  3208. def test_hard_deletes_untracked_files(self) -> None:
  3209. """Test that reset --hard deletes files that don't exist in target tree."""
  3210. # Create and commit a file
  3211. fullpath = os.path.join(self.repo.path, "foo")
  3212. with open(fullpath, "w") as f:
  3213. f.write("BAR")
  3214. porcelain.add(self.repo.path, paths=[fullpath])
  3215. sha1 = porcelain.commit(
  3216. self.repo.path,
  3217. message=b"First commit",
  3218. committer=b"Jane <jane@example.com>",
  3219. author=b"John <john@example.com>",
  3220. )
  3221. # Create another file and commit
  3222. fullpath2 = os.path.join(self.repo.path, "bar")
  3223. with open(fullpath2, "w") as f:
  3224. f.write("BAZ")
  3225. porcelain.add(self.repo.path, paths=[fullpath2])
  3226. porcelain.commit(
  3227. self.repo.path,
  3228. message=b"Second commit",
  3229. committer=b"Jane <jane@example.com>",
  3230. author=b"John <john@example.com>",
  3231. )
  3232. # Reset hard to first commit - this should delete 'bar'
  3233. porcelain.reset(self.repo, "hard", sha1)
  3234. # Check that 'foo' still exists and 'bar' is deleted
  3235. self.assertTrue(os.path.exists(fullpath))
  3236. self.assertFalse(os.path.exists(fullpath2))
  3237. # Check index matches first commit
  3238. index = self.repo.open_index()
  3239. self.assertIn(b"foo", index)
  3240. self.assertNotIn(b"bar", index)
  3241. def test_hard_deletes_files_in_subdirs(self) -> None:
  3242. """Test that reset --hard deletes files in subdirectories."""
  3243. # Create and commit files in subdirectory
  3244. subdir = os.path.join(self.repo.path, "subdir")
  3245. os.makedirs(subdir)
  3246. file1 = os.path.join(subdir, "file1")
  3247. file2 = os.path.join(subdir, "file2")
  3248. with open(file1, "w") as f:
  3249. f.write("content1")
  3250. with open(file2, "w") as f:
  3251. f.write("content2")
  3252. porcelain.add(self.repo.path, paths=[file1, file2])
  3253. porcelain.commit(
  3254. self.repo.path,
  3255. message=b"First commit",
  3256. committer=b"Jane <jane@example.com>",
  3257. author=b"John <john@example.com>",
  3258. )
  3259. # Remove one file from subdirectory and commit
  3260. porcelain.rm(self.repo.path, paths=[file2])
  3261. sha2 = porcelain.commit(
  3262. self.repo.path,
  3263. message=b"Remove file2",
  3264. committer=b"Jane <jane@example.com>",
  3265. author=b"John <john@example.com>",
  3266. )
  3267. # Create file2 again (untracked)
  3268. with open(file2, "w") as f:
  3269. f.write("new content")
  3270. # Reset to commit that has file2 removed - untracked file2 should remain
  3271. porcelain.reset(self.repo, "hard", sha2)
  3272. self.assertTrue(os.path.exists(file1))
  3273. # Untracked files are not removed by reset --hard
  3274. self.assertTrue(os.path.exists(file2))
  3275. def test_hard_reset_to_remote_branch(self) -> None:
  3276. """Test reset --hard to remote branch deletes local files not in remote."""
  3277. # Create a file and commit
  3278. file1 = os.path.join(self.repo.path, "file1")
  3279. with open(file1, "w") as f:
  3280. f.write("content1")
  3281. porcelain.add(self.repo.path, paths=[file1])
  3282. sha1 = porcelain.commit(
  3283. self.repo.path,
  3284. message=b"Initial commit",
  3285. committer=b"Jane <jane@example.com>",
  3286. author=b"John <john@example.com>",
  3287. )
  3288. # Create a "remote" ref that doesn't have additional files
  3289. self.repo.refs[b"refs/remotes/origin/master"] = sha1
  3290. # Add another file locally and commit
  3291. file2 = os.path.join(self.repo.path, "file2")
  3292. with open(file2, "w") as f:
  3293. f.write("content2")
  3294. porcelain.add(self.repo.path, paths=[file2])
  3295. porcelain.commit(
  3296. self.repo.path,
  3297. message=b"Add file2",
  3298. committer=b"Jane <jane@example.com>",
  3299. author=b"John <john@example.com>",
  3300. )
  3301. # Both files should exist
  3302. self.assertTrue(os.path.exists(file1))
  3303. self.assertTrue(os.path.exists(file2))
  3304. # Reset to remote branch - should delete file2
  3305. porcelain.reset(self.repo, "hard", b"refs/remotes/origin/master")
  3306. # file1 should exist, file2 should be deleted
  3307. self.assertTrue(os.path.exists(file1))
  3308. self.assertFalse(os.path.exists(file2))
  3309. def test_mixed_reset(self) -> None:
  3310. # Create initial commit
  3311. fullpath = os.path.join(self.repo.path, "foo")
  3312. with open(fullpath, "w") as f:
  3313. f.write("BAR")
  3314. porcelain.add(self.repo.path, paths=[fullpath])
  3315. first_sha = porcelain.commit(
  3316. self.repo.path,
  3317. message=b"First commit",
  3318. committer=b"Jane <jane@example.com>",
  3319. author=b"John <john@example.com>",
  3320. )
  3321. # Make second commit with modified content
  3322. with open(fullpath, "w") as f:
  3323. f.write("BAZ")
  3324. porcelain.add(self.repo.path, paths=[fullpath])
  3325. porcelain.commit(
  3326. self.repo.path,
  3327. message=b"Second commit",
  3328. committer=b"Jane <jane@example.com>",
  3329. author=b"John <john@example.com>",
  3330. )
  3331. # Modify working tree without staging
  3332. with open(fullpath, "w") as f:
  3333. f.write("MODIFIED")
  3334. # Mixed reset to first commit
  3335. porcelain.reset(self.repo, "mixed", first_sha)
  3336. # Check that HEAD points to first commit
  3337. self.assertEqual(self.repo.head(), first_sha)
  3338. # Check that index matches first commit
  3339. index = self.repo.open_index()
  3340. changes = list(
  3341. tree_changes(
  3342. self.repo.object_store,
  3343. index.commit(self.repo.object_store),
  3344. self.repo[first_sha].tree,
  3345. )
  3346. )
  3347. self.assertEqual([], changes)
  3348. # Check that working tree is unchanged (still has "MODIFIED")
  3349. with open(fullpath) as f:
  3350. self.assertEqual(f.read(), "MODIFIED")
  3351. def test_soft_reset(self) -> None:
  3352. # Create initial commit
  3353. fullpath = os.path.join(self.repo.path, "foo")
  3354. with open(fullpath, "w") as f:
  3355. f.write("BAR")
  3356. porcelain.add(self.repo.path, paths=[fullpath])
  3357. first_sha = porcelain.commit(
  3358. self.repo.path,
  3359. message=b"First commit",
  3360. committer=b"Jane <jane@example.com>",
  3361. author=b"John <john@example.com>",
  3362. )
  3363. # Make second commit with modified content
  3364. with open(fullpath, "w") as f:
  3365. f.write("BAZ")
  3366. porcelain.add(self.repo.path, paths=[fullpath])
  3367. porcelain.commit(
  3368. self.repo.path,
  3369. message=b"Second commit",
  3370. committer=b"Jane <jane@example.com>",
  3371. author=b"John <john@example.com>",
  3372. )
  3373. # Stage a new change
  3374. with open(fullpath, "w") as f:
  3375. f.write("STAGED")
  3376. porcelain.add(self.repo.path, paths=[fullpath])
  3377. # Soft reset to first commit
  3378. porcelain.reset(self.repo, "soft", first_sha)
  3379. # Check that HEAD points to first commit
  3380. self.assertEqual(self.repo.head(), first_sha)
  3381. # Check that index still has the staged change (not reset)
  3382. index = self.repo.open_index()
  3383. # The index should still contain the staged content, not the first commit's content
  3384. self.assertIn(b"foo", index)
  3385. # Check that working tree is unchanged
  3386. with open(fullpath) as f:
  3387. self.assertEqual(f.read(), "STAGED")
  3388. class ResetFileTests(PorcelainTestCase):
  3389. def test_reset_modify_file_to_commit(self) -> None:
  3390. file = "foo"
  3391. full_path = os.path.join(self.repo.path, file)
  3392. with open(full_path, "w") as f:
  3393. f.write("hello")
  3394. porcelain.add(self.repo, paths=[full_path])
  3395. sha = porcelain.commit(
  3396. self.repo,
  3397. message=b"unitest",
  3398. committer=b"Jane <jane@example.com>",
  3399. author=b"John <john@example.com>",
  3400. )
  3401. with open(full_path, "a") as f:
  3402. f.write("something new")
  3403. porcelain.reset_file(self.repo, file, target=sha)
  3404. with open(full_path) as f:
  3405. self.assertEqual("hello", f.read())
  3406. def test_reset_remove_file_to_commit(self) -> None:
  3407. file = "foo"
  3408. full_path = os.path.join(self.repo.path, file)
  3409. with open(full_path, "w") as f:
  3410. f.write("hello")
  3411. porcelain.add(self.repo, paths=[full_path])
  3412. sha = porcelain.commit(
  3413. self.repo,
  3414. message=b"unitest",
  3415. committer=b"Jane <jane@example.com>",
  3416. author=b"John <john@example.com>",
  3417. )
  3418. os.remove(full_path)
  3419. porcelain.reset_file(self.repo, file, target=sha)
  3420. with open(full_path) as f:
  3421. self.assertEqual("hello", f.read())
  3422. def test_resetfile_with_dir(self) -> None:
  3423. os.mkdir(os.path.join(self.repo.path, "new_dir"))
  3424. full_path = os.path.join(self.repo.path, "new_dir", "foo")
  3425. with open(full_path, "w") as f:
  3426. f.write("hello")
  3427. porcelain.add(self.repo, paths=[full_path])
  3428. sha = porcelain.commit(
  3429. self.repo,
  3430. message=b"unitest",
  3431. committer=b"Jane <jane@example.com>",
  3432. author=b"John <john@example.com>",
  3433. )
  3434. with open(full_path, "a") as f:
  3435. f.write("something new")
  3436. porcelain.commit(
  3437. self.repo,
  3438. message=b"unitest 2",
  3439. committer=b"Jane <jane@example.com>",
  3440. author=b"John <john@example.com>",
  3441. )
  3442. porcelain.reset_file(self.repo, os.path.join("new_dir", "foo"), target=sha)
  3443. with open(full_path) as f:
  3444. self.assertEqual("hello", f.read())
  3445. def _commit_file_with_content(repo, filename, content):
  3446. file_path = os.path.join(repo.path, filename)
  3447. with open(file_path, "w") as f:
  3448. f.write(content)
  3449. porcelain.add(repo, paths=[file_path])
  3450. sha = porcelain.commit(
  3451. repo,
  3452. message=b"add " + filename.encode(),
  3453. committer=b"Jane <jane@example.com>",
  3454. author=b"John <john@example.com>",
  3455. )
  3456. return sha, file_path
  3457. class RevertTests(PorcelainTestCase):
  3458. def test_revert_simple(self) -> None:
  3459. # Create initial commit
  3460. fullpath = os.path.join(self.repo.path, "foo")
  3461. with open(fullpath, "w") as f:
  3462. f.write("initial content\n")
  3463. porcelain.add(self.repo.path, paths=[fullpath])
  3464. porcelain.commit(
  3465. self.repo.path,
  3466. message=b"Initial commit",
  3467. committer=b"Jane <jane@example.com>",
  3468. author=b"John <john@example.com>",
  3469. )
  3470. # Make a change
  3471. with open(fullpath, "w") as f:
  3472. f.write("modified content\n")
  3473. porcelain.add(self.repo.path, paths=[fullpath])
  3474. change_sha = porcelain.commit(
  3475. self.repo.path,
  3476. message=b"Change content",
  3477. committer=b"Jane <jane@example.com>",
  3478. author=b"John <john@example.com>",
  3479. )
  3480. # Revert the change
  3481. revert_sha = porcelain.revert(self.repo.path, commits=[change_sha])
  3482. # Check the file content is back to initial
  3483. with open(fullpath) as f:
  3484. self.assertEqual("initial content\n", f.read())
  3485. # Check the revert commit message
  3486. revert_commit = self.repo[revert_sha]
  3487. self.assertIn(b'Revert "Change content"', revert_commit.message)
  3488. self.assertIn(change_sha[:7], revert_commit.message)
  3489. def test_revert_multiple(self) -> None:
  3490. # Create initial commit
  3491. fullpath = os.path.join(self.repo.path, "foo")
  3492. with open(fullpath, "w") as f:
  3493. f.write("line1\n")
  3494. porcelain.add(self.repo.path, paths=[fullpath])
  3495. porcelain.commit(
  3496. self.repo.path,
  3497. message=b"Initial commit",
  3498. committer=b"Jane <jane@example.com>",
  3499. author=b"John <john@example.com>",
  3500. )
  3501. # Add line2
  3502. with open(fullpath, "a") as f:
  3503. f.write("line2\n")
  3504. porcelain.add(self.repo.path, paths=[fullpath])
  3505. commit1 = porcelain.commit(
  3506. self.repo.path,
  3507. message=b"Add line2",
  3508. committer=b"Jane <jane@example.com>",
  3509. author=b"John <john@example.com>",
  3510. )
  3511. # Add line3
  3512. with open(fullpath, "a") as f:
  3513. f.write("line3\n")
  3514. porcelain.add(self.repo.path, paths=[fullpath])
  3515. commit2 = porcelain.commit(
  3516. self.repo.path,
  3517. message=b"Add line3",
  3518. committer=b"Jane <jane@example.com>",
  3519. author=b"John <john@example.com>",
  3520. )
  3521. # Revert both commits (in reverse order)
  3522. porcelain.revert(self.repo.path, commits=[commit2, commit1])
  3523. # Check file is back to initial state
  3524. with open(fullpath) as f:
  3525. self.assertEqual("line1\n", f.read())
  3526. def test_revert_no_commit(self) -> None:
  3527. # Create initial commit
  3528. fullpath = os.path.join(self.repo.path, "foo")
  3529. with open(fullpath, "w") as f:
  3530. f.write("initial\n")
  3531. porcelain.add(self.repo.path, paths=[fullpath])
  3532. porcelain.commit(
  3533. self.repo.path,
  3534. message=b"Initial",
  3535. committer=b"Jane <jane@example.com>",
  3536. author=b"John <john@example.com>",
  3537. )
  3538. # Make a change
  3539. with open(fullpath, "w") as f:
  3540. f.write("changed\n")
  3541. porcelain.add(self.repo.path, paths=[fullpath])
  3542. change_sha = porcelain.commit(
  3543. self.repo.path,
  3544. message=b"Change",
  3545. committer=b"Jane <jane@example.com>",
  3546. author=b"John <john@example.com>",
  3547. )
  3548. # Revert with no_commit
  3549. result = porcelain.revert(self.repo.path, commits=[change_sha], no_commit=True)
  3550. # Should return None
  3551. self.assertIsNone(result)
  3552. # File should be reverted
  3553. with open(fullpath) as f:
  3554. self.assertEqual("initial\n", f.read())
  3555. # HEAD should still point to the change commit
  3556. self.assertEqual(self.repo.refs[b"HEAD"], change_sha)
  3557. def test_revert_custom_message(self) -> None:
  3558. # Create commits
  3559. fullpath = os.path.join(self.repo.path, "foo")
  3560. with open(fullpath, "w") as f:
  3561. f.write("initial\n")
  3562. porcelain.add(self.repo.path, paths=[fullpath])
  3563. porcelain.commit(
  3564. self.repo.path,
  3565. message=b"Initial",
  3566. committer=b"Jane <jane@example.com>",
  3567. author=b"John <john@example.com>",
  3568. )
  3569. with open(fullpath, "w") as f:
  3570. f.write("changed\n")
  3571. porcelain.add(self.repo.path, paths=[fullpath])
  3572. change_sha = porcelain.commit(
  3573. self.repo.path,
  3574. message=b"Change",
  3575. committer=b"Jane <jane@example.com>",
  3576. author=b"John <john@example.com>",
  3577. )
  3578. # Revert with custom message
  3579. custom_msg = "Custom revert message"
  3580. revert_sha = porcelain.revert(
  3581. self.repo.path, commits=[change_sha], message=custom_msg
  3582. )
  3583. # Check the message
  3584. revert_commit = self.repo[revert_sha]
  3585. self.assertEqual(custom_msg.encode("utf-8"), revert_commit.message)
  3586. def test_revert_no_parent(self) -> None:
  3587. # Try to revert the initial commit (no parent)
  3588. fullpath = os.path.join(self.repo.path, "foo")
  3589. with open(fullpath, "w") as f:
  3590. f.write("content\n")
  3591. porcelain.add(self.repo.path, paths=[fullpath])
  3592. initial_sha = porcelain.commit(
  3593. self.repo.path,
  3594. message=b"Initial",
  3595. committer=b"Jane <jane@example.com>",
  3596. author=b"John <john@example.com>",
  3597. )
  3598. # Should raise an error
  3599. with self.assertRaises(porcelain.Error) as cm:
  3600. porcelain.revert(self.repo.path, commits=[initial_sha])
  3601. self.assertIn("no parents", str(cm.exception))
  3602. class CheckoutTests(PorcelainTestCase):
  3603. def setUp(self) -> None:
  3604. super().setUp()
  3605. self._sha, self._foo_path = _commit_file_with_content(
  3606. self.repo, "foo", "hello\n"
  3607. )
  3608. porcelain.branch_create(self.repo, "uni")
  3609. def test_checkout_to_existing_branch(self) -> None:
  3610. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  3611. porcelain.checkout(self.repo, b"uni")
  3612. self.assertEqual(b"uni", porcelain.active_branch(self.repo))
  3613. def test_checkout_to_non_existing_branch(self) -> None:
  3614. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  3615. with self.assertRaises(KeyError):
  3616. porcelain.checkout(self.repo, b"bob")
  3617. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  3618. def test_checkout_to_branch_with_modified_files(self) -> None:
  3619. with open(self._foo_path, "a") as f:
  3620. f.write("new message\n")
  3621. porcelain.add(self.repo, paths=[self._foo_path])
  3622. status = list(porcelain.status(self.repo))
  3623. self.assertEqual(
  3624. [{"add": [], "delete": [], "modify": [b"foo"]}, [], []], status
  3625. )
  3626. # The new checkout behavior prevents switching with staged changes
  3627. with self.assertRaises(porcelain.CheckoutError):
  3628. porcelain.checkout(self.repo, b"uni")
  3629. # Should still be on master
  3630. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  3631. # Force checkout should work
  3632. porcelain.checkout(self.repo, b"uni", force=True)
  3633. self.assertEqual(b"uni", porcelain.active_branch(self.repo))
  3634. def test_checkout_with_deleted_files(self) -> None:
  3635. porcelain.remove(self.repo.path, [os.path.join(self.repo.path, "foo")])
  3636. status = list(porcelain.status(self.repo))
  3637. self.assertEqual(
  3638. [{"add": [], "delete": [b"foo"], "modify": []}, [], []], status
  3639. )
  3640. # The new checkout behavior prevents switching with staged deletions
  3641. with self.assertRaises(porcelain.CheckoutError):
  3642. porcelain.checkout(self.repo, b"uni")
  3643. # Should still be on master
  3644. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  3645. # Force checkout should work
  3646. porcelain.checkout(self.repo, b"uni", force=True)
  3647. self.assertEqual(b"uni", porcelain.active_branch(self.repo))
  3648. def test_checkout_to_branch_with_added_files(self) -> None:
  3649. file_path = os.path.join(self.repo.path, "bar")
  3650. with open(file_path, "w") as f:
  3651. f.write("bar content\n")
  3652. porcelain.add(self.repo, paths=[file_path])
  3653. status = list(porcelain.status(self.repo))
  3654. self.assertEqual(
  3655. [{"add": [b"bar"], "delete": [], "modify": []}, [], []], status
  3656. )
  3657. # Both branches have file 'foo' checkout should be fine.
  3658. porcelain.checkout(self.repo, b"uni")
  3659. self.assertEqual(b"uni", porcelain.active_branch(self.repo))
  3660. status = list(porcelain.status(self.repo))
  3661. self.assertEqual(
  3662. [{"add": [b"bar"], "delete": [], "modify": []}, [], []], status
  3663. )
  3664. def test_checkout_to_branch_with_modified_file_not_present(self) -> None:
  3665. # Commit a new file that the other branch doesn't have.
  3666. _, nee_path = _commit_file_with_content(self.repo, "nee", "Good content\n")
  3667. # Modify the file the other branch doesn't have.
  3668. with open(nee_path, "a") as f:
  3669. f.write("bar content\n")
  3670. porcelain.add(self.repo, paths=[nee_path])
  3671. status = list(porcelain.status(self.repo))
  3672. self.assertEqual(
  3673. [{"add": [], "delete": [], "modify": [b"nee"]}, [], []], status
  3674. )
  3675. # Checkout should fail when there are staged changes that would be lost
  3676. # This matches Git's behavior to prevent data loss
  3677. from dulwich.errors import WorkingTreeModifiedError
  3678. with self.assertRaises(WorkingTreeModifiedError) as cm:
  3679. porcelain.checkout(self.repo, b"uni")
  3680. self.assertIn("nee", str(cm.exception))
  3681. # Should still be on master branch
  3682. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  3683. # The staged changes should still be present
  3684. status = list(porcelain.status(self.repo))
  3685. self.assertEqual(
  3686. [{"add": [], "delete": [], "modify": [b"nee"]}, [], []], status
  3687. )
  3688. self.assertTrue(os.path.exists(nee_path))
  3689. # Force checkout should work and lose the changes
  3690. porcelain.checkout(self.repo, b"uni", force=True)
  3691. self.assertEqual(b"uni", porcelain.active_branch(self.repo))
  3692. # Now the file should be gone
  3693. self.assertFalse(os.path.exists(nee_path))
  3694. def test_checkout_to_branch_with_modified_file_not_present_forced(self) -> None:
  3695. # Commit a new file that the other branch doesn't have.
  3696. _, nee_path = _commit_file_with_content(self.repo, "nee", "Good content\n")
  3697. # Modify the file the other branch doesn't have.
  3698. with open(nee_path, "a") as f:
  3699. f.write("bar content\n")
  3700. porcelain.add(self.repo, paths=[nee_path])
  3701. status = list(porcelain.status(self.repo))
  3702. self.assertEqual(
  3703. [{"add": [], "delete": [], "modify": [b"nee"]}, [], []], status
  3704. )
  3705. # 'uni' branch doesn't have 'nee' and it has been modified, but we force to reset the entire index.
  3706. porcelain.checkout(self.repo, b"uni", force=True)
  3707. self.assertEqual(b"uni", porcelain.active_branch(self.repo))
  3708. status = list(porcelain.status(self.repo))
  3709. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)
  3710. def test_checkout_to_branch_with_unstaged_files(self) -> None:
  3711. # Edit `foo`.
  3712. with open(self._foo_path, "a") as f:
  3713. f.write("new message")
  3714. status = list(porcelain.status(self.repo))
  3715. self.assertEqual(
  3716. [{"add": [], "delete": [], "modify": []}, [b"foo"], []], status
  3717. )
  3718. # The new checkout behavior prevents switching with unstaged changes
  3719. with self.assertRaises(porcelain.CheckoutError):
  3720. porcelain.checkout(self.repo, b"uni")
  3721. # Should still be on master
  3722. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  3723. # Force checkout should work
  3724. porcelain.checkout(self.repo, b"uni", force=True)
  3725. self.assertEqual(b"uni", porcelain.active_branch(self.repo))
  3726. def test_checkout_to_branch_with_untracked_files(self) -> None:
  3727. with open(os.path.join(self.repo.path, "neu"), "a") as f:
  3728. f.write("new message\n")
  3729. status = list(porcelain.status(self.repo))
  3730. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], ["neu"]], status)
  3731. porcelain.checkout(self.repo, b"uni")
  3732. status = list(porcelain.status(self.repo))
  3733. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], ["neu"]], status)
  3734. def test_checkout_to_branch_with_new_files(self) -> None:
  3735. porcelain.checkout(self.repo, b"uni")
  3736. sub_directory = os.path.join(self.repo.path, "sub1")
  3737. os.mkdir(sub_directory)
  3738. for index in range(5):
  3739. _commit_file_with_content(
  3740. self.repo, "new_file_" + str(index + 1), "Some content\n"
  3741. )
  3742. _commit_file_with_content(
  3743. self.repo,
  3744. os.path.join("sub1", "new_file_" + str(index + 10)),
  3745. "Good content\n",
  3746. )
  3747. status = list(porcelain.status(self.repo))
  3748. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)
  3749. porcelain.checkout(self.repo, b"master")
  3750. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  3751. status = list(porcelain.status(self.repo))
  3752. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)
  3753. porcelain.checkout(self.repo, b"uni")
  3754. self.assertEqual(b"uni", porcelain.active_branch(self.repo))
  3755. status = list(porcelain.status(self.repo))
  3756. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)
  3757. def test_checkout_to_branch_with_file_in_sub_directory(self) -> None:
  3758. sub_directory = os.path.join(self.repo.path, "sub1", "sub2")
  3759. os.makedirs(sub_directory)
  3760. sub_directory_file = os.path.join(sub_directory, "neu")
  3761. with open(sub_directory_file, "w") as f:
  3762. f.write("new message\n")
  3763. porcelain.add(self.repo, paths=[sub_directory_file])
  3764. porcelain.commit(
  3765. self.repo,
  3766. message=b"add " + sub_directory_file.encode(),
  3767. committer=b"Jane <jane@example.com>",
  3768. author=b"John <john@example.com>",
  3769. )
  3770. status = list(porcelain.status(self.repo))
  3771. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)
  3772. self.assertTrue(os.path.isdir(sub_directory))
  3773. self.assertTrue(os.path.isdir(os.path.dirname(sub_directory)))
  3774. porcelain.checkout(self.repo, b"uni")
  3775. status = list(porcelain.status(self.repo))
  3776. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)
  3777. self.assertFalse(os.path.isdir(sub_directory))
  3778. self.assertFalse(os.path.isdir(os.path.dirname(sub_directory)))
  3779. porcelain.checkout(self.repo, b"master")
  3780. self.assertTrue(os.path.isdir(sub_directory))
  3781. self.assertTrue(os.path.isdir(os.path.dirname(sub_directory)))
  3782. def test_checkout_to_branch_with_multiple_files_in_sub_directory(self) -> None:
  3783. sub_directory = os.path.join(self.repo.path, "sub1", "sub2")
  3784. os.makedirs(sub_directory)
  3785. sub_directory_file_1 = os.path.join(sub_directory, "neu")
  3786. with open(sub_directory_file_1, "w") as f:
  3787. f.write("new message\n")
  3788. sub_directory_file_2 = os.path.join(sub_directory, "gus")
  3789. with open(sub_directory_file_2, "w") as f:
  3790. f.write("alternative message\n")
  3791. porcelain.add(self.repo, paths=[sub_directory_file_1, sub_directory_file_2])
  3792. porcelain.commit(
  3793. self.repo,
  3794. message=b"add files neu and gus.",
  3795. committer=b"Jane <jane@example.com>",
  3796. author=b"John <john@example.com>",
  3797. )
  3798. status = list(porcelain.status(self.repo))
  3799. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)
  3800. self.assertTrue(os.path.isdir(sub_directory))
  3801. self.assertTrue(os.path.isdir(os.path.dirname(sub_directory)))
  3802. porcelain.checkout(self.repo, b"uni")
  3803. status = list(porcelain.status(self.repo))
  3804. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)
  3805. self.assertFalse(os.path.isdir(sub_directory))
  3806. self.assertFalse(os.path.isdir(os.path.dirname(sub_directory)))
  3807. def _commit_something_wrong(self):
  3808. with open(self._foo_path, "a") as f:
  3809. f.write("something wrong")
  3810. porcelain.add(self.repo, paths=[self._foo_path])
  3811. return porcelain.commit(
  3812. self.repo,
  3813. message=b"I may added something wrong",
  3814. committer=b"Jane <jane@example.com>",
  3815. author=b"John <john@example.com>",
  3816. )
  3817. def test_checkout_to_commit_sha(self) -> None:
  3818. self._commit_something_wrong()
  3819. porcelain.checkout(self.repo, self._sha)
  3820. self.assertEqual(self._sha, self.repo.head())
  3821. def test_checkout_to_head(self) -> None:
  3822. new_sha = self._commit_something_wrong()
  3823. porcelain.checkout(self.repo, b"HEAD")
  3824. self.assertEqual(new_sha, self.repo.head())
  3825. def _checkout_remote_branch(self):
  3826. errstream = BytesIO()
  3827. outstream = BytesIO()
  3828. porcelain.commit(
  3829. repo=self.repo.path,
  3830. message=b"init",
  3831. author=b"author <email>",
  3832. committer=b"committer <email>",
  3833. )
  3834. # Setup target repo cloned from temp test repo
  3835. clone_path = tempfile.mkdtemp()
  3836. self.addCleanup(shutil.rmtree, clone_path)
  3837. target_repo = porcelain.clone(
  3838. self.repo.path, target=clone_path, errstream=errstream
  3839. )
  3840. self.addCleanup(target_repo.close)
  3841. self.assertEqual(target_repo[b"HEAD"], self.repo[b"HEAD"])
  3842. # create a second file to be pushed back to origin
  3843. handle, fullpath = tempfile.mkstemp(dir=clone_path)
  3844. os.close(handle)
  3845. porcelain.add(repo=clone_path, paths=[fullpath])
  3846. porcelain.commit(
  3847. repo=clone_path,
  3848. message=b"push",
  3849. author=b"author <email>",
  3850. committer=b"committer <email>",
  3851. )
  3852. # Setup a non-checked out branch in the remote
  3853. refs_path = b"refs/heads/foo"
  3854. new_id = self.repo[b"HEAD"].id
  3855. self.assertNotEqual(new_id, ZERO_SHA)
  3856. self.repo.refs[refs_path] = new_id
  3857. # Push to the remote
  3858. porcelain.push(
  3859. clone_path,
  3860. "origin",
  3861. b"HEAD:" + refs_path,
  3862. outstream=outstream,
  3863. errstream=errstream,
  3864. )
  3865. self.assertEqual(
  3866. target_repo.refs[b"refs/remotes/origin/foo"],
  3867. target_repo.refs[b"HEAD"],
  3868. )
  3869. # The new checkout behavior treats origin/foo as a ref and creates detached HEAD
  3870. porcelain.checkout(target_repo, b"origin/foo")
  3871. original_id = target_repo[b"HEAD"].id
  3872. uni_id = target_repo[b"refs/remotes/origin/uni"].id
  3873. # Should be in detached HEAD state
  3874. with self.assertRaises((ValueError, IndexError)):
  3875. porcelain.active_branch(target_repo)
  3876. expected_refs = {
  3877. b"HEAD": original_id,
  3878. b"refs/heads/master": original_id,
  3879. # No local foo branch is created anymore
  3880. b"refs/remotes/origin/foo": original_id,
  3881. b"refs/remotes/origin/uni": uni_id,
  3882. b"refs/remotes/origin/HEAD": new_id,
  3883. b"refs/remotes/origin/master": new_id,
  3884. }
  3885. self.assertEqual(expected_refs, target_repo.get_refs())
  3886. return target_repo
  3887. def test_checkout_remote_branch(self) -> None:
  3888. repo = self._checkout_remote_branch()
  3889. repo.close()
  3890. def test_checkout_remote_branch_then_master_then_remote_branch_again(self) -> None:
  3891. target_repo = self._checkout_remote_branch()
  3892. # Should be in detached HEAD state
  3893. with self.assertRaises((ValueError, IndexError)):
  3894. porcelain.active_branch(target_repo)
  3895. # Save the commit SHA before adding bar
  3896. detached_commit_sha, _ = _commit_file_with_content(
  3897. target_repo, "bar", "something\n"
  3898. )
  3899. self.assertTrue(os.path.isfile(os.path.join(target_repo.path, "bar")))
  3900. porcelain.checkout(target_repo, b"master")
  3901. self.assertEqual(b"master", porcelain.active_branch(target_repo))
  3902. self.assertFalse(os.path.isfile(os.path.join(target_repo.path, "bar")))
  3903. # Going back to origin/foo won't have bar because the commit was made in detached state
  3904. porcelain.checkout(target_repo, b"origin/foo")
  3905. # Should be in detached HEAD state again
  3906. with self.assertRaises((ValueError, IndexError)):
  3907. porcelain.active_branch(target_repo)
  3908. # bar is NOT there because we're back at the original origin/foo commit
  3909. self.assertFalse(os.path.isfile(os.path.join(target_repo.path, "bar")))
  3910. # But we can checkout the specific commit to get bar back
  3911. porcelain.checkout(target_repo, detached_commit_sha.decode())
  3912. self.assertTrue(os.path.isfile(os.path.join(target_repo.path, "bar")))
  3913. target_repo.close()
  3914. def test_checkout_new_branch_from_remote_sets_tracking(self) -> None:
  3915. # Create a "remote" repository
  3916. remote_path = tempfile.mkdtemp()
  3917. self.addCleanup(shutil.rmtree, remote_path)
  3918. remote_repo = porcelain.init(remote_path)
  3919. # Add a commit to the remote
  3920. remote_sha, _ = _commit_file_with_content(
  3921. remote_repo, "bar", "remote content\n"
  3922. )
  3923. # Clone the remote repository
  3924. target_path = tempfile.mkdtemp()
  3925. self.addCleanup(shutil.rmtree, target_path)
  3926. target_repo = porcelain.clone(remote_path, target_path)
  3927. self.addCleanup(target_repo.close)
  3928. # Create a remote tracking branch reference
  3929. remote_branch_ref = b"refs/remotes/origin/feature"
  3930. target_repo.refs[remote_branch_ref] = remote_sha
  3931. # Checkout a new branch from the remote branch
  3932. porcelain.checkout(target_repo, remote_branch_ref, new_branch=b"local-feature")
  3933. # Verify the branch was created and is active
  3934. self.assertEqual(b"local-feature", porcelain.active_branch(target_repo))
  3935. # Verify tracking configuration was set
  3936. config = target_repo.get_config()
  3937. self.assertEqual(
  3938. b"origin", config.get((b"branch", b"local-feature"), b"remote")
  3939. )
  3940. self.assertEqual(
  3941. b"refs/heads/feature", config.get((b"branch", b"local-feature"), b"merge")
  3942. )
  3943. target_repo.close()
  3944. remote_repo.close()
  3945. class RestoreTests(PorcelainTestCase):
  3946. """Tests for the restore command."""
  3947. def setUp(self) -> None:
  3948. super().setUp()
  3949. self._sha, self._foo_path = _commit_file_with_content(
  3950. self.repo, "foo", "original\n"
  3951. )
  3952. def test_restore_worktree_from_index(self) -> None:
  3953. # Modify the working tree file
  3954. with open(self._foo_path, "w") as f:
  3955. f.write("modified\n")
  3956. # Restore from index (should restore to original)
  3957. porcelain.restore(self.repo, paths=["foo"])
  3958. with open(self._foo_path) as f:
  3959. content = f.read()
  3960. self.assertEqual("original\n", content)
  3961. def test_restore_worktree_from_head(self) -> None:
  3962. # Modify and stage the file
  3963. with open(self._foo_path, "w") as f:
  3964. f.write("staged\n")
  3965. porcelain.add(self.repo, paths=[self._foo_path])
  3966. # Now modify it again in worktree
  3967. with open(self._foo_path, "w") as f:
  3968. f.write("worktree\n")
  3969. # Restore from HEAD (should restore to original, not staged)
  3970. porcelain.restore(self.repo, paths=["foo"], source="HEAD")
  3971. with open(self._foo_path) as f:
  3972. content = f.read()
  3973. self.assertEqual("original\n", content)
  3974. def test_restore_staged_from_head(self) -> None:
  3975. # Modify and stage the file
  3976. with open(self._foo_path, "w") as f:
  3977. f.write("staged\n")
  3978. porcelain.add(self.repo, paths=[self._foo_path])
  3979. # Verify it's staged
  3980. status = list(porcelain.status(self.repo))
  3981. self.assertEqual(
  3982. [{"add": [], "delete": [], "modify": [b"foo"]}, [], []], status
  3983. )
  3984. # Restore staged from HEAD
  3985. porcelain.restore(self.repo, paths=["foo"], staged=True, worktree=False)
  3986. # Verify it's no longer staged
  3987. status = list(porcelain.status(self.repo))
  3988. # Now it should show as unstaged modification
  3989. self.assertEqual(
  3990. [{"add": [], "delete": [], "modify": []}, [b"foo"], []], status
  3991. )
  3992. def test_restore_both_staged_and_worktree(self) -> None:
  3993. # Modify and stage the file
  3994. with open(self._foo_path, "w") as f:
  3995. f.write("staged\n")
  3996. porcelain.add(self.repo, paths=[self._foo_path])
  3997. # Now modify it again in worktree
  3998. with open(self._foo_path, "w") as f:
  3999. f.write("worktree\n")
  4000. # Restore both from HEAD
  4001. porcelain.restore(self.repo, paths=["foo"], staged=True, worktree=True)
  4002. # Verify content is restored
  4003. with open(self._foo_path) as f:
  4004. content = f.read()
  4005. self.assertEqual("original\n", content)
  4006. # Verify nothing is staged
  4007. status = list(porcelain.status(self.repo))
  4008. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)
  4009. def test_restore_nonexistent_path(self) -> None:
  4010. with self.assertRaises(porcelain.CheckoutError):
  4011. porcelain.restore(self.repo, paths=["nonexistent"])
  4012. class SwitchTests(PorcelainTestCase):
  4013. """Tests for the switch command."""
  4014. def setUp(self) -> None:
  4015. super().setUp()
  4016. self._sha, self._foo_path = _commit_file_with_content(
  4017. self.repo, "foo", "hello\n"
  4018. )
  4019. porcelain.branch_create(self.repo, "dev")
  4020. def test_switch_to_existing_branch(self) -> None:
  4021. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  4022. porcelain.switch(self.repo, "dev")
  4023. self.assertEqual(b"dev", porcelain.active_branch(self.repo))
  4024. def test_switch_to_non_existing_branch(self) -> None:
  4025. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  4026. with self.assertRaises(KeyError):
  4027. porcelain.switch(self.repo, "nonexistent")
  4028. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  4029. def test_switch_with_create(self) -> None:
  4030. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  4031. porcelain.switch(self.repo, "master", create="feature")
  4032. self.assertEqual(b"feature", porcelain.active_branch(self.repo))
  4033. def test_switch_with_detach(self) -> None:
  4034. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  4035. porcelain.switch(self.repo, self._sha.decode(), detach=True)
  4036. # In detached HEAD state, active_branch raises IndexError
  4037. with self.assertRaises(IndexError):
  4038. porcelain.active_branch(self.repo)
  4039. def test_switch_with_uncommitted_changes(self) -> None:
  4040. # Modify the file
  4041. with open(self._foo_path, "a") as f:
  4042. f.write("new content\n")
  4043. porcelain.add(self.repo, paths=[self._foo_path])
  4044. # Switch should fail due to uncommitted changes
  4045. with self.assertRaises(porcelain.CheckoutError):
  4046. porcelain.switch(self.repo, "dev")
  4047. # Should still be on master
  4048. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  4049. def test_switch_with_force(self) -> None:
  4050. # Modify the file
  4051. with open(self._foo_path, "a") as f:
  4052. f.write("new content\n")
  4053. porcelain.add(self.repo, paths=[self._foo_path])
  4054. # Force switch should work
  4055. porcelain.switch(self.repo, "dev", force=True)
  4056. self.assertEqual(b"dev", porcelain.active_branch(self.repo))
  4057. def test_switch_to_commit_without_detach(self) -> None:
  4058. # Switching to a commit SHA without --detach should fail
  4059. with self.assertRaises(porcelain.CheckoutError):
  4060. porcelain.switch(self.repo, self._sha.decode())
  4061. class GeneralCheckoutTests(PorcelainTestCase):
  4062. """Tests for the general checkout function that handles branches, tags, and commits."""
  4063. def setUp(self) -> None:
  4064. super().setUp()
  4065. # Create initial commit
  4066. self._sha1, self._foo_path = _commit_file_with_content(
  4067. self.repo, "foo", "initial content\n"
  4068. )
  4069. # Create a branch
  4070. porcelain.branch_create(self.repo, "feature")
  4071. # Create another commit on master
  4072. self._sha2, self._bar_path = _commit_file_with_content(
  4073. self.repo, "bar", "bar content\n"
  4074. )
  4075. # Create a tag
  4076. porcelain.tag_create(self.repo, "v1.0", objectish=self._sha1)
  4077. def test_checkout_branch(self) -> None:
  4078. """Test checking out a branch."""
  4079. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  4080. # Checkout feature branch
  4081. porcelain.checkout(self.repo, "feature")
  4082. self.assertEqual(b"feature", porcelain.active_branch(self.repo))
  4083. # File 'bar' should not exist in feature branch
  4084. self.assertFalse(os.path.exists(self._bar_path))
  4085. # Go back to master
  4086. porcelain.checkout(self.repo, "master")
  4087. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  4088. # File 'bar' should exist again
  4089. self.assertTrue(os.path.exists(self._bar_path))
  4090. def test_checkout_commit(self) -> None:
  4091. """Test checking out a specific commit (detached HEAD)."""
  4092. # Checkout first commit by SHA
  4093. porcelain.checkout(self.repo, self._sha1.decode("ascii"))
  4094. # Should be in detached HEAD state - active_branch raises IndexError
  4095. with self.assertRaises((ValueError, IndexError)):
  4096. porcelain.active_branch(self.repo)
  4097. # File 'bar' should not exist
  4098. self.assertFalse(os.path.exists(self._bar_path))
  4099. # HEAD should point to the commit
  4100. self.assertEqual(self._sha1, self.repo.refs[b"HEAD"])
  4101. def test_checkout_tag(self) -> None:
  4102. """Test checking out a tag (detached HEAD)."""
  4103. # Checkout tag
  4104. porcelain.checkout(self.repo, "v1.0")
  4105. # Should be in detached HEAD state - active_branch raises IndexError
  4106. with self.assertRaises((ValueError, IndexError)):
  4107. porcelain.active_branch(self.repo)
  4108. # File 'bar' should not exist (tag points to first commit)
  4109. self.assertFalse(os.path.exists(self._bar_path))
  4110. # HEAD should point to the tagged commit
  4111. self.assertEqual(self._sha1, self.repo.refs[b"HEAD"])
  4112. def test_checkout_new_branch(self) -> None:
  4113. """Test creating a new branch during checkout (like git checkout -b)."""
  4114. # Create and checkout new branch from current HEAD
  4115. porcelain.checkout(self.repo, "master", new_branch="new-feature")
  4116. self.assertEqual(b"new-feature", porcelain.active_branch(self.repo))
  4117. self.assertTrue(os.path.exists(self._bar_path))
  4118. # Create and checkout new branch from specific commit
  4119. porcelain.checkout(self.repo, self._sha1.decode("ascii"), new_branch="from-old")
  4120. self.assertEqual(b"from-old", porcelain.active_branch(self.repo))
  4121. self.assertFalse(os.path.exists(self._bar_path))
  4122. def test_checkout_with_uncommitted_changes(self) -> None:
  4123. """Test checkout behavior with uncommitted changes."""
  4124. # Modify a file
  4125. with open(self._foo_path, "w") as f:
  4126. f.write("modified content\n")
  4127. # Should raise error when trying to checkout
  4128. with self.assertRaises(porcelain.CheckoutError) as cm:
  4129. porcelain.checkout(self.repo, "feature")
  4130. self.assertIn("local changes", str(cm.exception))
  4131. self.assertIn("foo", str(cm.exception))
  4132. # Should still be on master
  4133. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  4134. def test_checkout_force(self) -> None:
  4135. """Test forced checkout discards local changes for files that differ between branches."""
  4136. # Modify a file
  4137. with open(self._foo_path, "w") as f:
  4138. f.write("modified content\n")
  4139. # Force checkout should succeed
  4140. porcelain.checkout(self.repo, "feature", force=True)
  4141. self.assertEqual(b"feature", porcelain.active_branch(self.repo))
  4142. # Since foo has the same content in master and feature branches,
  4143. # checkout should NOT restore it - the modified content should remain
  4144. with open(self._foo_path) as f:
  4145. content = f.read()
  4146. self.assertEqual("modified content\n", content)
  4147. def test_checkout_nonexistent_ref(self) -> None:
  4148. """Test checkout of non-existent branch/commit."""
  4149. with self.assertRaises(KeyError):
  4150. porcelain.checkout(self.repo, "nonexistent")
  4151. def test_checkout_partial_sha(self) -> None:
  4152. """Test checkout with partial SHA."""
  4153. # Git typically allows checkout with partial SHA
  4154. partial_sha = self._sha1.decode("ascii")[:7]
  4155. porcelain.checkout(self.repo, partial_sha)
  4156. # Should be in detached HEAD state at the right commit
  4157. self.assertEqual(self._sha1, self.repo.refs[b"HEAD"])
  4158. def test_checkout_preserves_untracked_files(self) -> None:
  4159. """Test that checkout preserves untracked files."""
  4160. # Create an untracked file
  4161. untracked_path = os.path.join(self.repo.path, "untracked.txt")
  4162. with open(untracked_path, "w") as f:
  4163. f.write("untracked content\n")
  4164. # Checkout another branch
  4165. porcelain.checkout(self.repo, "feature")
  4166. # Untracked file should still exist
  4167. self.assertTrue(os.path.exists(untracked_path))
  4168. with open(untracked_path) as f:
  4169. content = f.read()
  4170. self.assertEqual("untracked content\n", content)
  4171. def test_checkout_full_ref_paths(self) -> None:
  4172. """Test checkout with full ref paths."""
  4173. # Test checkout with full branch ref path
  4174. porcelain.checkout(self.repo, "refs/heads/feature")
  4175. self.assertEqual(b"feature", porcelain.active_branch(self.repo))
  4176. # Test checkout with full tag ref path
  4177. porcelain.checkout(self.repo, "refs/tags/v1.0")
  4178. # Should be in detached HEAD state
  4179. with self.assertRaises((ValueError, IndexError)):
  4180. porcelain.active_branch(self.repo)
  4181. self.assertEqual(self._sha1, self.repo.refs[b"HEAD"])
  4182. def test_checkout_bytes_vs_string_target(self) -> None:
  4183. """Test that checkout works with both bytes and string targets."""
  4184. # Test with string target
  4185. porcelain.checkout(self.repo, "feature")
  4186. self.assertEqual(b"feature", porcelain.active_branch(self.repo))
  4187. # Test with bytes target
  4188. porcelain.checkout(self.repo, b"master")
  4189. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  4190. def test_checkout_new_branch_from_commit(self) -> None:
  4191. """Test creating a new branch from a specific commit."""
  4192. # Create new branch from first commit
  4193. porcelain.checkout(self.repo, self._sha1.decode(), new_branch="from-commit")
  4194. self.assertEqual(b"from-commit", porcelain.active_branch(self.repo))
  4195. # Should be at the first commit (no bar file)
  4196. self.assertFalse(os.path.exists(self._bar_path))
  4197. def test_checkout_with_staged_addition(self) -> None:
  4198. """Test checkout behavior with staged file additions."""
  4199. # Create and stage a new file that doesn't exist in target branch
  4200. new_file_path = os.path.join(self.repo.path, "new.txt")
  4201. with open(new_file_path, "w") as f:
  4202. f.write("new file content\n")
  4203. porcelain.add(self.repo, [new_file_path])
  4204. # This should succeed because the file doesn't exist in target branch
  4205. porcelain.checkout(self.repo, "feature")
  4206. # Should be on feature branch
  4207. self.assertEqual(b"feature", porcelain.active_branch(self.repo))
  4208. # The new file should still exist and be staged
  4209. self.assertTrue(os.path.exists(new_file_path))
  4210. status = porcelain.status(self.repo)
  4211. self.assertIn(b"new.txt", status.staged["add"])
  4212. def test_checkout_with_staged_modification_conflict(self) -> None:
  4213. """Test checkout behavior with staged modifications that would conflict."""
  4214. # Stage changes to a file that exists in both branches
  4215. with open(self._foo_path, "w") as f:
  4216. f.write("modified content\n")
  4217. porcelain.add(self.repo, [self._foo_path])
  4218. # Should prevent checkout due to staged changes to existing file
  4219. with self.assertRaises(porcelain.CheckoutError) as cm:
  4220. porcelain.checkout(self.repo, "feature")
  4221. self.assertIn("local changes", str(cm.exception))
  4222. self.assertIn("foo", str(cm.exception))
  4223. def test_checkout_head_reference(self) -> None:
  4224. """Test checkout of HEAD reference."""
  4225. # Move to feature branch first
  4226. porcelain.checkout(self.repo, "feature")
  4227. # Checkout HEAD creates detached HEAD state
  4228. porcelain.checkout(self.repo, "HEAD")
  4229. # Should be in detached HEAD state
  4230. with self.assertRaises((ValueError, IndexError)):
  4231. porcelain.active_branch(self.repo)
  4232. def test_checkout_error_messages(self) -> None:
  4233. """Test that checkout error messages are helpful."""
  4234. # Create uncommitted changes
  4235. with open(self._foo_path, "w") as f:
  4236. f.write("uncommitted changes\n")
  4237. # Try to checkout
  4238. with self.assertRaises(porcelain.CheckoutError) as cm:
  4239. porcelain.checkout(self.repo, "feature")
  4240. error_msg = str(cm.exception)
  4241. self.assertIn("local changes", error_msg)
  4242. self.assertIn("foo", error_msg)
  4243. self.assertIn("overwritten", error_msg)
  4244. self.assertIn("commit or stash", error_msg)
  4245. class SubmoduleTests(PorcelainTestCase):
  4246. def test_empty(self) -> None:
  4247. porcelain.commit(
  4248. repo=self.repo.path,
  4249. message=b"init",
  4250. author=b"author <email>",
  4251. committer=b"committer <email>",
  4252. )
  4253. self.assertEqual([], list(porcelain.submodule_list(self.repo)))
  4254. def test_add(self) -> None:
  4255. porcelain.submodule_add(self.repo, "../bar.git", "bar")
  4256. with open(f"{self.repo.path}/.gitmodules") as f:
  4257. self.assertEqual(
  4258. """\
  4259. [submodule "bar"]
  4260. \turl = ../bar.git
  4261. \tpath = bar
  4262. """,
  4263. f.read(),
  4264. )
  4265. def test_init(self) -> None:
  4266. porcelain.submodule_add(self.repo, "../bar.git", "bar")
  4267. porcelain.submodule_init(self.repo)
  4268. def test_update(self) -> None:
  4269. # Create a submodule repository
  4270. sub_repo_path = tempfile.mkdtemp()
  4271. self.addCleanup(shutil.rmtree, sub_repo_path)
  4272. sub_repo = Repo.init(sub_repo_path)
  4273. self.addCleanup(sub_repo.close)
  4274. # Add a file to the submodule repo
  4275. sub_file = os.path.join(sub_repo_path, "test.txt")
  4276. with open(sub_file, "w") as f:
  4277. f.write("submodule content")
  4278. porcelain.add(sub_repo, paths=[sub_file])
  4279. sub_commit = porcelain.commit(
  4280. sub_repo,
  4281. message=b"Initial submodule commit",
  4282. author=b"Test Author <test@example.com>",
  4283. committer=b"Test Committer <test@example.com>",
  4284. )
  4285. # Add the submodule to the main repository
  4286. porcelain.submodule_add(self.repo, sub_repo_path, "test_submodule")
  4287. # Manually add the submodule to the index
  4288. from dulwich.index import IndexEntry
  4289. from dulwich.objects import S_IFGITLINK
  4290. index = self.repo.open_index()
  4291. index[b"test_submodule"] = IndexEntry(
  4292. ctime=0,
  4293. mtime=0,
  4294. dev=0,
  4295. ino=0,
  4296. mode=S_IFGITLINK,
  4297. uid=0,
  4298. gid=0,
  4299. size=0,
  4300. sha=sub_commit,
  4301. flags=0,
  4302. )
  4303. index.write()
  4304. porcelain.add(self.repo, paths=[".gitmodules"])
  4305. porcelain.commit(
  4306. self.repo,
  4307. message=b"Add submodule",
  4308. author=b"Test Author <test@example.com>",
  4309. committer=b"Test Committer <test@example.com>",
  4310. )
  4311. # Initialize and update the submodule
  4312. porcelain.submodule_init(self.repo)
  4313. porcelain.submodule_update(self.repo)
  4314. # Check that the submodule directory exists
  4315. submodule_path = os.path.join(self.repo.path, "test_submodule")
  4316. self.assertTrue(os.path.exists(submodule_path))
  4317. # Check that the submodule file exists
  4318. submodule_file = os.path.join(submodule_path, "test.txt")
  4319. self.assertTrue(os.path.exists(submodule_file))
  4320. with open(submodule_file) as f:
  4321. self.assertEqual(f.read(), "submodule content")
  4322. def test_update_recursive(self) -> None:
  4323. # Create a nested (innermost) submodule repository
  4324. nested_repo_path = tempfile.mkdtemp()
  4325. self.addCleanup(shutil.rmtree, nested_repo_path)
  4326. nested_repo = Repo.init(nested_repo_path)
  4327. self.addCleanup(nested_repo.close)
  4328. # Add a file to the nested repo
  4329. nested_file = os.path.join(nested_repo_path, "nested.txt")
  4330. with open(nested_file, "w") as f:
  4331. f.write("nested submodule content")
  4332. porcelain.add(nested_repo, paths=[nested_file])
  4333. nested_commit = porcelain.commit(
  4334. nested_repo,
  4335. message=b"Initial nested commit",
  4336. author=b"Test Author <test@example.com>",
  4337. committer=b"Test Committer <test@example.com>",
  4338. )
  4339. # Create a middle submodule repository
  4340. middle_repo_path = tempfile.mkdtemp()
  4341. self.addCleanup(shutil.rmtree, middle_repo_path)
  4342. middle_repo = Repo.init(middle_repo_path)
  4343. self.addCleanup(middle_repo.close)
  4344. # Add a file to the middle repo
  4345. middle_file = os.path.join(middle_repo_path, "middle.txt")
  4346. with open(middle_file, "w") as f:
  4347. f.write("middle submodule content")
  4348. porcelain.add(middle_repo, paths=[middle_file])
  4349. # Add the nested submodule to the middle repository
  4350. porcelain.submodule_add(middle_repo, nested_repo_path, "nested")
  4351. # Manually add the nested submodule to the index
  4352. from dulwich.index import IndexEntry
  4353. from dulwich.objects import S_IFGITLINK
  4354. middle_index = middle_repo.open_index()
  4355. middle_index[b"nested"] = IndexEntry(
  4356. ctime=0,
  4357. mtime=0,
  4358. dev=0,
  4359. ino=0,
  4360. mode=S_IFGITLINK,
  4361. uid=0,
  4362. gid=0,
  4363. size=0,
  4364. sha=nested_commit,
  4365. flags=0,
  4366. )
  4367. middle_index.write()
  4368. porcelain.add(middle_repo, paths=[".gitmodules"])
  4369. middle_commit = porcelain.commit(
  4370. middle_repo,
  4371. message=b"Add nested submodule",
  4372. author=b"Test Author <test@example.com>",
  4373. committer=b"Test Committer <test@example.com>",
  4374. )
  4375. # Add the middle submodule to the main repository
  4376. porcelain.submodule_add(self.repo, middle_repo_path, "middle")
  4377. # Manually add the middle submodule to the index
  4378. main_index = self.repo.open_index()
  4379. main_index[b"middle"] = IndexEntry(
  4380. ctime=0,
  4381. mtime=0,
  4382. dev=0,
  4383. ino=0,
  4384. mode=S_IFGITLINK,
  4385. uid=0,
  4386. gid=0,
  4387. size=0,
  4388. sha=middle_commit,
  4389. flags=0,
  4390. )
  4391. main_index.write()
  4392. porcelain.add(self.repo, paths=[".gitmodules"])
  4393. porcelain.commit(
  4394. self.repo,
  4395. message=b"Add middle submodule",
  4396. author=b"Test Author <test@example.com>",
  4397. committer=b"Test Committer <test@example.com>",
  4398. )
  4399. # Initialize and recursively update the submodules
  4400. porcelain.submodule_init(self.repo)
  4401. porcelain.submodule_update(self.repo, recursive=True)
  4402. # Check that the middle submodule directory and file exist
  4403. middle_submodule_path = os.path.join(self.repo.path, "middle")
  4404. self.assertTrue(os.path.exists(middle_submodule_path))
  4405. middle_submodule_file = os.path.join(middle_submodule_path, "middle.txt")
  4406. self.assertTrue(os.path.exists(middle_submodule_file))
  4407. with open(middle_submodule_file) as f:
  4408. self.assertEqual(f.read(), "middle submodule content")
  4409. # Check that the nested submodule directory and file exist
  4410. nested_submodule_path = os.path.join(self.repo.path, "middle", "nested")
  4411. self.assertTrue(os.path.exists(nested_submodule_path))
  4412. nested_submodule_file = os.path.join(nested_submodule_path, "nested.txt")
  4413. self.assertTrue(os.path.exists(nested_submodule_file))
  4414. with open(nested_submodule_file) as f:
  4415. self.assertEqual(f.read(), "nested submodule content")
  4416. class PushTests(PorcelainTestCase):
  4417. def test_simple(self) -> None:
  4418. """Basic test of porcelain push where self.repo is the remote. First
  4419. clone the remote, commit a file to the clone, then push the changes
  4420. back to the remote.
  4421. """
  4422. outstream = BytesIO()
  4423. errstream = BytesIO()
  4424. porcelain.commit(
  4425. repo=self.repo.path,
  4426. message=b"init",
  4427. author=b"author <email>",
  4428. committer=b"committer <email>",
  4429. )
  4430. # Setup target repo cloned from temp test repo
  4431. clone_path = tempfile.mkdtemp()
  4432. self.addCleanup(shutil.rmtree, clone_path)
  4433. target_repo = porcelain.clone(
  4434. self.repo.path, target=clone_path, errstream=errstream
  4435. )
  4436. self.addCleanup(target_repo.close)
  4437. self.assertEqual(target_repo[b"HEAD"], self.repo[b"HEAD"])
  4438. # create a second file to be pushed back to origin
  4439. handle, fullpath = tempfile.mkstemp(dir=clone_path)
  4440. os.close(handle)
  4441. porcelain.add(repo=clone_path, paths=[fullpath])
  4442. porcelain.commit(
  4443. repo=clone_path,
  4444. message=b"push",
  4445. author=b"author <email>",
  4446. committer=b"committer <email>",
  4447. )
  4448. # Setup a non-checked out branch in the remote
  4449. refs_path = b"refs/heads/foo"
  4450. new_id = self.repo[b"HEAD"].id
  4451. self.assertNotEqual(new_id, ZERO_SHA)
  4452. self.repo.refs[refs_path] = new_id
  4453. # Push to the remote
  4454. porcelain.push(
  4455. clone_path,
  4456. "origin",
  4457. b"HEAD:" + refs_path,
  4458. outstream=outstream,
  4459. errstream=errstream,
  4460. )
  4461. self.assertEqual(
  4462. target_repo.refs[b"refs/remotes/origin/foo"],
  4463. target_repo.refs[b"HEAD"],
  4464. )
  4465. # Check that the target and source
  4466. with Repo(clone_path) as r_clone:
  4467. self.assertEqual(
  4468. {
  4469. b"HEAD": new_id,
  4470. b"refs/heads/foo": r_clone[b"HEAD"].id,
  4471. b"refs/heads/master": new_id,
  4472. },
  4473. self.repo.get_refs(),
  4474. )
  4475. self.assertEqual(r_clone[b"HEAD"].id, self.repo[refs_path].id)
  4476. # Get the change in the target repo corresponding to the add
  4477. # this will be in the foo branch.
  4478. change = next(
  4479. iter(
  4480. tree_changes(
  4481. self.repo.object_store,
  4482. self.repo[b"HEAD"].tree,
  4483. self.repo[b"refs/heads/foo"].tree,
  4484. )
  4485. )
  4486. )
  4487. self.assertEqual(
  4488. os.path.basename(fullpath), change.new.path.decode("ascii")
  4489. )
  4490. def test_local_missing(self) -> None:
  4491. """Pushing a new branch."""
  4492. outstream = BytesIO()
  4493. errstream = BytesIO()
  4494. # Setup target repo cloned from temp test repo
  4495. clone_path = tempfile.mkdtemp()
  4496. self.addCleanup(shutil.rmtree, clone_path)
  4497. target_repo = porcelain.init(clone_path)
  4498. target_repo.close()
  4499. self.assertRaises(
  4500. porcelain.Error,
  4501. porcelain.push,
  4502. self.repo,
  4503. clone_path,
  4504. b"HEAD:refs/heads/master",
  4505. outstream=outstream,
  4506. errstream=errstream,
  4507. )
  4508. def test_new(self) -> None:
  4509. """Pushing a new branch."""
  4510. outstream = BytesIO()
  4511. errstream = BytesIO()
  4512. # Setup target repo cloned from temp test repo
  4513. clone_path = tempfile.mkdtemp()
  4514. self.addCleanup(shutil.rmtree, clone_path)
  4515. target_repo = porcelain.init(clone_path)
  4516. target_repo.close()
  4517. # create a second file to be pushed back to origin
  4518. handle, fullpath = tempfile.mkstemp(dir=clone_path)
  4519. os.close(handle)
  4520. porcelain.add(repo=clone_path, paths=[fullpath])
  4521. new_id = porcelain.commit(
  4522. repo=self.repo,
  4523. message=b"push",
  4524. author=b"author <email>",
  4525. committer=b"committer <email>",
  4526. )
  4527. # Push to the remote
  4528. porcelain.push(
  4529. self.repo,
  4530. clone_path,
  4531. b"HEAD:refs/heads/master",
  4532. outstream=outstream,
  4533. errstream=errstream,
  4534. )
  4535. with Repo(clone_path) as r_clone:
  4536. self.assertEqual(
  4537. {
  4538. b"HEAD": new_id,
  4539. b"refs/heads/master": new_id,
  4540. },
  4541. r_clone.get_refs(),
  4542. )
  4543. def test_delete(self) -> None:
  4544. """Basic test of porcelain push, removing a branch."""
  4545. outstream = BytesIO()
  4546. errstream = BytesIO()
  4547. porcelain.commit(
  4548. repo=self.repo.path,
  4549. message=b"init",
  4550. author=b"author <email>",
  4551. committer=b"committer <email>",
  4552. )
  4553. # Setup target repo cloned from temp test repo
  4554. clone_path = tempfile.mkdtemp()
  4555. self.addCleanup(shutil.rmtree, clone_path)
  4556. target_repo = porcelain.clone(
  4557. self.repo.path, target=clone_path, errstream=errstream
  4558. )
  4559. target_repo.close()
  4560. # Setup a non-checked out branch in the remote
  4561. refs_path = b"refs/heads/foo"
  4562. new_id = self.repo[b"HEAD"].id
  4563. self.assertNotEqual(new_id, ZERO_SHA)
  4564. self.repo.refs[refs_path] = new_id
  4565. # Push to the remote
  4566. porcelain.push(
  4567. clone_path,
  4568. self.repo.path,
  4569. b":" + refs_path,
  4570. outstream=outstream,
  4571. errstream=errstream,
  4572. )
  4573. self.assertEqual(
  4574. {
  4575. b"HEAD": new_id,
  4576. b"refs/heads/master": new_id,
  4577. },
  4578. self.repo.get_refs(),
  4579. )
  4580. def test_diverged(self) -> None:
  4581. outstream = BytesIO()
  4582. errstream = BytesIO()
  4583. porcelain.commit(
  4584. repo=self.repo.path,
  4585. message=b"init",
  4586. author=b"author <email>",
  4587. committer=b"committer <email>",
  4588. )
  4589. # Setup target repo cloned from temp test repo
  4590. clone_path = tempfile.mkdtemp()
  4591. self.addCleanup(shutil.rmtree, clone_path)
  4592. target_repo = porcelain.clone(
  4593. self.repo.path, target=clone_path, errstream=errstream
  4594. )
  4595. target_repo.close()
  4596. remote_id = porcelain.commit(
  4597. repo=self.repo.path,
  4598. message=b"remote change",
  4599. author=b"author <email>",
  4600. committer=b"committer <email>",
  4601. )
  4602. local_id = porcelain.commit(
  4603. repo=clone_path,
  4604. message=b"local change",
  4605. author=b"author <email>",
  4606. committer=b"committer <email>",
  4607. )
  4608. outstream = BytesIO()
  4609. errstream = BytesIO()
  4610. # Push to the remote
  4611. self.assertRaises(
  4612. porcelain.DivergedBranches,
  4613. porcelain.push,
  4614. clone_path,
  4615. self.repo.path,
  4616. b"refs/heads/master",
  4617. outstream=outstream,
  4618. errstream=errstream,
  4619. )
  4620. self.assertEqual(
  4621. {
  4622. b"HEAD": remote_id,
  4623. b"refs/heads/master": remote_id,
  4624. },
  4625. self.repo.get_refs(),
  4626. )
  4627. self.assertEqual(b"", outstream.getvalue())
  4628. self.assertEqual(b"", errstream.getvalue())
  4629. outstream = BytesIO()
  4630. errstream = BytesIO()
  4631. # Push to the remote with --force
  4632. porcelain.push(
  4633. clone_path,
  4634. self.repo.path,
  4635. b"refs/heads/master",
  4636. outstream=outstream,
  4637. errstream=errstream,
  4638. force=True,
  4639. )
  4640. self.assertEqual(
  4641. {
  4642. b"HEAD": local_id,
  4643. b"refs/heads/master": local_id,
  4644. },
  4645. self.repo.get_refs(),
  4646. )
  4647. self.assertEqual(b"", outstream.getvalue())
  4648. self.assertTrue(re.search(b"Push to .* successful.\n", errstream.getvalue()))
  4649. def test_push_returns_sendpackresult(self) -> None:
  4650. """Test that push returns a SendPackResult with per-ref information."""
  4651. outstream = BytesIO()
  4652. errstream = BytesIO()
  4653. # Create initial commit
  4654. porcelain.commit(
  4655. repo=self.repo.path,
  4656. message=b"init",
  4657. author=b"author <email>",
  4658. committer=b"committer <email>",
  4659. )
  4660. # Setup target repo cloned from temp test repo
  4661. clone_path = tempfile.mkdtemp()
  4662. self.addCleanup(shutil.rmtree, clone_path)
  4663. target_repo = porcelain.clone(
  4664. self.repo.path, target=clone_path, errstream=errstream
  4665. )
  4666. target_repo.close()
  4667. # Create a commit in the clone
  4668. handle, fullpath = tempfile.mkstemp(dir=clone_path)
  4669. os.close(handle)
  4670. porcelain.add(repo=clone_path, paths=[fullpath])
  4671. porcelain.commit(
  4672. repo=clone_path,
  4673. message=b"push",
  4674. author=b"author <email>",
  4675. committer=b"committer <email>",
  4676. )
  4677. # Push and check the return value
  4678. result = porcelain.push(
  4679. clone_path,
  4680. "origin",
  4681. b"HEAD:refs/heads/new-branch",
  4682. outstream=outstream,
  4683. errstream=errstream,
  4684. )
  4685. # Verify that we get a SendPackResult
  4686. self.assertIsInstance(result, SendPackResult)
  4687. # Verify that it contains refs
  4688. self.assertIsNotNone(result.refs)
  4689. self.assertIn(b"refs/heads/new-branch", result.refs)
  4690. # Verify ref_status - should be None for successful updates
  4691. if result.ref_status:
  4692. self.assertIsNone(result.ref_status.get(b"refs/heads/new-branch"))
  4693. def test_mirror_mode(self) -> None:
  4694. """Test push with remote.<name>.mirror configuration."""
  4695. outstream = BytesIO()
  4696. errstream = BytesIO()
  4697. # Create initial commit
  4698. porcelain.commit(
  4699. repo=self.repo.path,
  4700. message=b"init",
  4701. author=b"author <email>",
  4702. committer=b"committer <email>",
  4703. )
  4704. # Setup target repo cloned from temp test repo
  4705. clone_path = tempfile.mkdtemp()
  4706. self.addCleanup(shutil.rmtree, clone_path)
  4707. target_repo = porcelain.clone(
  4708. self.repo.path, target=clone_path, errstream=errstream
  4709. )
  4710. target_repo.close()
  4711. # Create multiple refs in the clone
  4712. with Repo(clone_path) as r_clone:
  4713. # Create a new branch
  4714. r_clone.refs[b"refs/heads/feature"] = r_clone[b"HEAD"].id
  4715. # Create a tag
  4716. r_clone.refs[b"refs/tags/v1.0"] = r_clone[b"HEAD"].id
  4717. # Create a remote tracking branch
  4718. r_clone.refs[b"refs/remotes/upstream/main"] = r_clone[b"HEAD"].id
  4719. # Create a branch in the remote that doesn't exist in clone
  4720. self.repo.refs[b"refs/heads/to-be-deleted"] = self.repo[b"HEAD"].id
  4721. # Configure mirror mode
  4722. with Repo(clone_path) as r_clone:
  4723. config = r_clone.get_config()
  4724. config.set((b"remote", b"origin"), b"mirror", True)
  4725. config.write_to_path()
  4726. # Push with mirror mode
  4727. porcelain.push(
  4728. clone_path,
  4729. "origin",
  4730. outstream=outstream,
  4731. errstream=errstream,
  4732. )
  4733. # Verify refs were properly mirrored
  4734. with Repo(clone_path) as r_clone:
  4735. # All local branches should be pushed
  4736. self.assertEqual(
  4737. r_clone.refs[b"refs/heads/feature"],
  4738. self.repo.refs[b"refs/heads/feature"],
  4739. )
  4740. # All tags should be pushed
  4741. self.assertEqual(
  4742. r_clone.refs[b"refs/tags/v1.0"], self.repo.refs[b"refs/tags/v1.0"]
  4743. )
  4744. # Remote tracking branches should be pushed
  4745. self.assertEqual(
  4746. r_clone.refs[b"refs/remotes/upstream/main"],
  4747. self.repo.refs[b"refs/remotes/upstream/main"],
  4748. )
  4749. # Verify the extra branch was deleted
  4750. self.assertNotIn(b"refs/heads/to-be-deleted", self.repo.refs)
  4751. def test_mirror_mode_disabled(self) -> None:
  4752. """Test that mirror mode is properly disabled when set to false."""
  4753. outstream = BytesIO()
  4754. errstream = BytesIO()
  4755. # Create initial commit
  4756. porcelain.commit(
  4757. repo=self.repo.path,
  4758. message=b"init",
  4759. author=b"author <email>",
  4760. committer=b"committer <email>",
  4761. )
  4762. # Setup target repo cloned from temp test repo
  4763. clone_path = tempfile.mkdtemp()
  4764. self.addCleanup(shutil.rmtree, clone_path)
  4765. target_repo = porcelain.clone(
  4766. self.repo.path, target=clone_path, errstream=errstream
  4767. )
  4768. target_repo.close()
  4769. # Create a branch in the remote that doesn't exist in clone
  4770. self.repo.refs[b"refs/heads/should-not-be-deleted"] = self.repo[b"HEAD"].id
  4771. # Explicitly set mirror mode to false
  4772. with Repo(clone_path) as r_clone:
  4773. config = r_clone.get_config()
  4774. config.set((b"remote", b"origin"), b"mirror", False)
  4775. config.write_to_path()
  4776. # Push normally (not mirror mode)
  4777. porcelain.push(
  4778. clone_path,
  4779. "origin",
  4780. outstream=outstream,
  4781. errstream=errstream,
  4782. )
  4783. # Verify the extra branch was NOT deleted
  4784. self.assertIn(b"refs/heads/should-not-be-deleted", self.repo.refs)
  4785. class PullTests(PorcelainTestCase):
  4786. def setUp(self) -> None:
  4787. super().setUp()
  4788. # create a file for initial commit
  4789. handle, fullpath = tempfile.mkstemp(dir=self.repo.path)
  4790. os.close(handle)
  4791. porcelain.add(repo=self.repo.path, paths=fullpath)
  4792. porcelain.commit(
  4793. repo=self.repo.path,
  4794. message=b"test",
  4795. author=b"test <email>",
  4796. committer=b"test <email>",
  4797. )
  4798. # Setup target repo
  4799. self.target_path = tempfile.mkdtemp()
  4800. self.addCleanup(shutil.rmtree, self.target_path)
  4801. target_repo = porcelain.clone(
  4802. self.repo.path, target=self.target_path, errstream=BytesIO()
  4803. )
  4804. target_repo.close()
  4805. # create a second file to be pushed
  4806. handle, fullpath = tempfile.mkstemp(dir=self.repo.path)
  4807. os.close(handle)
  4808. porcelain.add(repo=self.repo.path, paths=fullpath)
  4809. porcelain.commit(
  4810. repo=self.repo.path,
  4811. message=b"test2",
  4812. author=b"test2 <email>",
  4813. committer=b"test2 <email>",
  4814. )
  4815. self.assertIn(b"refs/heads/master", self.repo.refs)
  4816. self.assertIn(b"refs/heads/master", target_repo.refs)
  4817. def test_simple(self) -> None:
  4818. outstream = BytesIO()
  4819. errstream = BytesIO()
  4820. # Pull changes into the cloned repo
  4821. porcelain.pull(
  4822. self.target_path,
  4823. self.repo.path,
  4824. b"refs/heads/master",
  4825. outstream=outstream,
  4826. errstream=errstream,
  4827. )
  4828. # Check the target repo for pushed changes
  4829. with Repo(self.target_path) as r:
  4830. self.assertEqual(r[b"HEAD"].id, self.repo[b"HEAD"].id)
  4831. def test_diverged(self) -> None:
  4832. outstream = BytesIO()
  4833. errstream = BytesIO()
  4834. c3a = porcelain.commit(
  4835. repo=self.target_path,
  4836. message=b"test3a",
  4837. author=b"test2 <email>",
  4838. committer=b"test2 <email>",
  4839. )
  4840. porcelain.commit(
  4841. repo=self.repo.path,
  4842. message=b"test3b",
  4843. author=b"test2 <email>",
  4844. committer=b"test2 <email>",
  4845. )
  4846. # Pull changes into the cloned repo
  4847. self.assertRaises(
  4848. porcelain.DivergedBranches,
  4849. porcelain.pull,
  4850. self.target_path,
  4851. self.repo.path,
  4852. b"refs/heads/master",
  4853. outstream=outstream,
  4854. errstream=errstream,
  4855. )
  4856. # Check the target repo for pushed changes
  4857. with Repo(self.target_path) as r:
  4858. self.assertEqual(r[b"refs/heads/master"].id, c3a)
  4859. # Pull with merge should now work
  4860. porcelain.pull(
  4861. self.target_path,
  4862. self.repo.path,
  4863. b"refs/heads/master",
  4864. outstream=outstream,
  4865. errstream=errstream,
  4866. fast_forward=False,
  4867. )
  4868. # Check the target repo for merged changes
  4869. with Repo(self.target_path) as r:
  4870. # HEAD should now be a merge commit
  4871. head = r[b"HEAD"]
  4872. # It should have two parents
  4873. self.assertEqual(len(head.parents), 2)
  4874. # One parent should be the previous HEAD (c3a)
  4875. self.assertIn(c3a, head.parents)
  4876. # The other parent should be from the source repo
  4877. self.assertIn(self.repo[b"HEAD"].id, head.parents)
  4878. def test_no_refspec(self) -> None:
  4879. outstream = BytesIO()
  4880. errstream = BytesIO()
  4881. # Pull changes into the cloned repo
  4882. porcelain.pull(
  4883. self.target_path,
  4884. self.repo.path,
  4885. outstream=outstream,
  4886. errstream=errstream,
  4887. )
  4888. # Check the target repo for pushed changes
  4889. with Repo(self.target_path) as r:
  4890. self.assertEqual(r[b"HEAD"].id, self.repo[b"HEAD"].id)
  4891. def test_no_remote_location(self) -> None:
  4892. outstream = BytesIO()
  4893. errstream = BytesIO()
  4894. # Pull changes into the cloned repo
  4895. porcelain.pull(
  4896. self.target_path,
  4897. refspecs=b"refs/heads/master",
  4898. outstream=outstream,
  4899. errstream=errstream,
  4900. )
  4901. # Check the target repo for pushed changes
  4902. with Repo(self.target_path) as r:
  4903. self.assertEqual(r[b"HEAD"].id, self.repo[b"HEAD"].id)
  4904. def test_pull_updates_working_tree(self) -> None:
  4905. """Test that pull updates the working tree with new files."""
  4906. outstream = BytesIO()
  4907. errstream = BytesIO()
  4908. # Create a new file with content in the source repo
  4909. new_file = os.path.join(self.repo.path, "newfile.txt")
  4910. with open(new_file, "w") as f:
  4911. f.write("This is new content")
  4912. porcelain.add(repo=self.repo.path, paths=[new_file])
  4913. porcelain.commit(
  4914. repo=self.repo.path,
  4915. message=b"Add new file",
  4916. author=b"test <email>",
  4917. committer=b"test <email>",
  4918. )
  4919. # Before pull, the file should not exist in target
  4920. target_file = os.path.join(self.target_path, "newfile.txt")
  4921. self.assertFalse(os.path.exists(target_file))
  4922. # Pull changes into the cloned repo
  4923. porcelain.pull(
  4924. self.target_path,
  4925. self.repo.path,
  4926. b"refs/heads/master",
  4927. outstream=outstream,
  4928. errstream=errstream,
  4929. )
  4930. # After pull, the file should exist with correct content
  4931. self.assertTrue(os.path.exists(target_file))
  4932. with open(target_file) as f:
  4933. self.assertEqual(f.read(), "This is new content")
  4934. # Check the HEAD is updated too
  4935. with Repo(self.target_path) as r:
  4936. self.assertEqual(r[b"HEAD"].id, self.repo[b"HEAD"].id)
  4937. def test_pull_protects_modified_files(self) -> None:
  4938. """Test that pull refuses to overwrite uncommitted changes by default."""
  4939. from dulwich.errors import WorkingTreeModifiedError
  4940. outstream = BytesIO()
  4941. errstream = BytesIO()
  4942. # Create a file with content in the source repo
  4943. test_file = os.path.join(self.repo.path, "testfile.txt")
  4944. with open(test_file, "w") as f:
  4945. f.write("original content")
  4946. porcelain.add(repo=self.repo.path, paths=[test_file])
  4947. porcelain.commit(
  4948. repo=self.repo.path,
  4949. message=b"Add test file",
  4950. author=b"test <email>",
  4951. committer=b"test <email>",
  4952. )
  4953. # Pull this change to target first
  4954. porcelain.pull(
  4955. self.target_path,
  4956. self.repo.path,
  4957. b"refs/heads/master",
  4958. outstream=outstream,
  4959. errstream=errstream,
  4960. )
  4961. # Now modify the file in source repo
  4962. with open(test_file, "w") as f:
  4963. f.write("updated content")
  4964. porcelain.add(repo=self.repo.path, paths=[test_file])
  4965. porcelain.commit(
  4966. repo=self.repo.path,
  4967. message=b"Update test file",
  4968. author=b"test <email>",
  4969. committer=b"test <email>",
  4970. )
  4971. # Modify the same file in target working directory (uncommitted)
  4972. target_file = os.path.join(self.target_path, "testfile.txt")
  4973. with open(target_file, "w") as f:
  4974. f.write("local modifications")
  4975. # Pull should fail because of uncommitted changes
  4976. with self.assertRaises(WorkingTreeModifiedError) as cm:
  4977. porcelain.pull(
  4978. self.target_path,
  4979. self.repo.path,
  4980. b"refs/heads/master",
  4981. outstream=outstream,
  4982. errstream=errstream,
  4983. )
  4984. self.assertIn("Your local changes", str(cm.exception))
  4985. self.assertIn("testfile.txt", str(cm.exception))
  4986. # Verify the file still has local modifications
  4987. with open(target_file) as f:
  4988. self.assertEqual(f.read(), "local modifications")
  4989. def test_pull_force_overwrites_modified_files(self) -> None:
  4990. """Test that pull with force=True overwrites uncommitted changes."""
  4991. outstream = BytesIO()
  4992. errstream = BytesIO()
  4993. # Create a file with content in the source repo
  4994. test_file = os.path.join(self.repo.path, "testfile.txt")
  4995. with open(test_file, "w") as f:
  4996. f.write("original content")
  4997. porcelain.add(repo=self.repo.path, paths=[test_file])
  4998. porcelain.commit(
  4999. repo=self.repo.path,
  5000. message=b"Add test file",
  5001. author=b"test <email>",
  5002. committer=b"test <email>",
  5003. )
  5004. # Pull this change to target first
  5005. porcelain.pull(
  5006. self.target_path,
  5007. self.repo.path,
  5008. b"refs/heads/master",
  5009. outstream=outstream,
  5010. errstream=errstream,
  5011. )
  5012. # Now modify the file in source repo
  5013. with open(test_file, "w") as f:
  5014. f.write("updated content")
  5015. porcelain.add(repo=self.repo.path, paths=[test_file])
  5016. porcelain.commit(
  5017. repo=self.repo.path,
  5018. message=b"Update test file",
  5019. author=b"test <email>",
  5020. committer=b"test <email>",
  5021. )
  5022. # Modify the same file in target working directory (uncommitted)
  5023. target_file = os.path.join(self.target_path, "testfile.txt")
  5024. with open(target_file, "w") as f:
  5025. f.write("local modifications")
  5026. # Pull with force=True should succeed and overwrite local changes
  5027. porcelain.pull(
  5028. self.target_path,
  5029. self.repo.path,
  5030. b"refs/heads/master",
  5031. outstream=outstream,
  5032. errstream=errstream,
  5033. force=True,
  5034. )
  5035. # Verify the file now has the remote content
  5036. with open(target_file) as f:
  5037. self.assertEqual(f.read(), "updated content")
  5038. # Check the HEAD is updated too
  5039. with Repo(self.target_path) as r:
  5040. self.assertEqual(r[b"HEAD"].id, self.repo[b"HEAD"].id)
  5041. def test_pull_allows_unmodified_files(self) -> None:
  5042. """Test that pull allows updating files that haven't been modified locally."""
  5043. outstream = BytesIO()
  5044. errstream = BytesIO()
  5045. # Create a file with content in the source repo
  5046. test_file = os.path.join(self.repo.path, "testfile.txt")
  5047. with open(test_file, "w") as f:
  5048. f.write("original content")
  5049. porcelain.add(repo=self.repo.path, paths=[test_file])
  5050. porcelain.commit(
  5051. repo=self.repo.path,
  5052. message=b"Add test file",
  5053. author=b"test <email>",
  5054. committer=b"test <email>",
  5055. )
  5056. # Pull this change to target first
  5057. porcelain.pull(
  5058. self.target_path,
  5059. self.repo.path,
  5060. b"refs/heads/master",
  5061. outstream=outstream,
  5062. errstream=errstream,
  5063. )
  5064. # Now modify the file in source repo
  5065. with open(test_file, "w") as f:
  5066. f.write("updated content")
  5067. porcelain.add(repo=self.repo.path, paths=[test_file])
  5068. porcelain.commit(
  5069. repo=self.repo.path,
  5070. message=b"Update test file",
  5071. author=b"test <email>",
  5072. committer=b"test <email>",
  5073. )
  5074. # Don't modify the file in target - it should be safe to update
  5075. target_file = os.path.join(self.target_path, "testfile.txt")
  5076. # Pull should succeed since the file wasn't modified locally
  5077. porcelain.pull(
  5078. self.target_path,
  5079. self.repo.path,
  5080. b"refs/heads/master",
  5081. outstream=outstream,
  5082. errstream=errstream,
  5083. )
  5084. # Verify the file now has the remote content
  5085. with open(target_file) as f:
  5086. self.assertEqual(f.read(), "updated content")
  5087. # Check the HEAD is updated too
  5088. with Repo(self.target_path) as r:
  5089. self.assertEqual(r[b"HEAD"].id, self.repo[b"HEAD"].id)
  5090. class StatusTests(PorcelainTestCase):
  5091. def test_empty(self) -> None:
  5092. results = porcelain.status(self.repo)
  5093. self.assertEqual({"add": [], "delete": [], "modify": []}, results.staged)
  5094. self.assertEqual([], results.unstaged)
  5095. def test_status_base(self) -> None:
  5096. """Integration test for `status` functionality."""
  5097. # Commit a dummy file then modify it
  5098. fullpath = os.path.join(self.repo.path, "foo")
  5099. with open(fullpath, "w") as f:
  5100. f.write("origstuff")
  5101. porcelain.add(repo=self.repo.path, paths=[fullpath])
  5102. porcelain.commit(
  5103. repo=self.repo.path,
  5104. message=b"test status",
  5105. author=b"author <email>",
  5106. committer=b"committer <email>",
  5107. )
  5108. # modify access and modify time of path
  5109. os.utime(fullpath, (0, 0))
  5110. with open(fullpath, "wb") as f:
  5111. f.write(b"stuff")
  5112. # Make a dummy file and stage it
  5113. filename_add = "bar"
  5114. fullpath = os.path.join(self.repo.path, filename_add)
  5115. with open(fullpath, "w") as f:
  5116. f.write("stuff")
  5117. porcelain.add(repo=self.repo.path, paths=fullpath)
  5118. results = porcelain.status(self.repo)
  5119. self.assertEqual(results.staged["add"][0], filename_add.encode("ascii"))
  5120. self.assertEqual(results.unstaged, [b"foo"])
  5121. def test_status_with_core_preloadindex(self) -> None:
  5122. """Test status with core.preloadIndex enabled."""
  5123. # Set core.preloadIndex to true
  5124. config = self.repo.get_config()
  5125. config.set(b"core", b"preloadIndex", b"true")
  5126. config.write_to_path()
  5127. # Create multiple files
  5128. files = []
  5129. for i in range(10):
  5130. filename = f"file{i}"
  5131. fullpath = os.path.join(self.repo.path, filename)
  5132. with open(fullpath, "w") as f:
  5133. f.write(f"content{i}")
  5134. files.append(fullpath)
  5135. porcelain.add(repo=self.repo.path, paths=files)
  5136. porcelain.commit(
  5137. repo=self.repo.path,
  5138. message=b"test preload status",
  5139. author=b"author <email>",
  5140. committer=b"committer <email>",
  5141. )
  5142. # Modify some files
  5143. modified_files = ["file1", "file3", "file5", "file7"]
  5144. for filename in modified_files:
  5145. fullpath = os.path.join(self.repo.path, filename)
  5146. with open(fullpath, "w") as f:
  5147. f.write("modified content")
  5148. os.utime(fullpath, (0, 0))
  5149. # Status should work correctly with preloadIndex enabled
  5150. results = porcelain.status(self.repo)
  5151. # Check that we detected the correct unstaged changes
  5152. unstaged_sorted = sorted(results.unstaged)
  5153. expected_sorted = sorted([f.encode("ascii") for f in modified_files])
  5154. self.assertEqual(unstaged_sorted, expected_sorted)
  5155. def test_status_all(self) -> None:
  5156. del_path = os.path.join(self.repo.path, "foo")
  5157. mod_path = os.path.join(self.repo.path, "bar")
  5158. add_path = os.path.join(self.repo.path, "baz")
  5159. us_path = os.path.join(self.repo.path, "blye")
  5160. ut_path = os.path.join(self.repo.path, "blyat")
  5161. with open(del_path, "w") as f:
  5162. f.write("origstuff")
  5163. with open(mod_path, "w") as f:
  5164. f.write("origstuff")
  5165. with open(us_path, "w") as f:
  5166. f.write("origstuff")
  5167. porcelain.add(repo=self.repo.path, paths=[del_path, mod_path, us_path])
  5168. porcelain.commit(
  5169. repo=self.repo.path,
  5170. message=b"test status",
  5171. author=b"author <email>",
  5172. committer=b"committer <email>",
  5173. )
  5174. porcelain.remove(self.repo.path, [del_path])
  5175. with open(add_path, "w") as f:
  5176. f.write("origstuff")
  5177. with open(mod_path, "w") as f:
  5178. f.write("more_origstuff")
  5179. with open(us_path, "w") as f:
  5180. f.write("more_origstuff")
  5181. porcelain.add(repo=self.repo.path, paths=[add_path, mod_path])
  5182. with open(us_path, "w") as f:
  5183. f.write("\norigstuff")
  5184. with open(ut_path, "w") as f:
  5185. f.write("origstuff")
  5186. results = porcelain.status(self.repo.path)
  5187. self.assertDictEqual(
  5188. {"add": [b"baz"], "delete": [b"foo"], "modify": [b"bar"]},
  5189. results.staged,
  5190. )
  5191. self.assertListEqual(results.unstaged, [b"blye"])
  5192. results_no_untracked = porcelain.status(self.repo.path, untracked_files="no")
  5193. self.assertListEqual(results_no_untracked.untracked, [])
  5194. def test_status_wrong_untracked_files_value(self) -> None:
  5195. with self.assertRaises(ValueError):
  5196. porcelain.status(self.repo.path, untracked_files="antani")
  5197. def test_status_untracked_path(self) -> None:
  5198. untracked_dir = os.path.join(self.repo_path, "untracked_dir")
  5199. os.mkdir(untracked_dir)
  5200. untracked_file = os.path.join(untracked_dir, "untracked_file")
  5201. with open(untracked_file, "w") as fh:
  5202. fh.write("untracked")
  5203. _, _, untracked = porcelain.status(self.repo.path, untracked_files="all")
  5204. self.assertEqual(untracked, ["untracked_dir/untracked_file"])
  5205. def test_status_untracked_path_normal(self) -> None:
  5206. # Create an untracked directory with multiple files
  5207. untracked_dir = os.path.join(self.repo_path, "untracked_dir")
  5208. os.mkdir(untracked_dir)
  5209. untracked_file1 = os.path.join(untracked_dir, "file1")
  5210. untracked_file2 = os.path.join(untracked_dir, "file2")
  5211. with open(untracked_file1, "w") as fh:
  5212. fh.write("untracked1")
  5213. with open(untracked_file2, "w") as fh:
  5214. fh.write("untracked2")
  5215. # Create a nested untracked directory
  5216. nested_dir = os.path.join(untracked_dir, "nested")
  5217. os.mkdir(nested_dir)
  5218. nested_file = os.path.join(nested_dir, "file3")
  5219. with open(nested_file, "w") as fh:
  5220. fh.write("untracked3")
  5221. # Test "normal" mode - should only show the directory, not individual files
  5222. _, _, untracked = porcelain.status(self.repo.path, untracked_files="normal")
  5223. self.assertEqual(untracked, ["untracked_dir/"])
  5224. # Test "all" mode - should show all files
  5225. _, _, untracked_all = porcelain.status(self.repo.path, untracked_files="all")
  5226. self.assertEqual(
  5227. sorted(untracked_all),
  5228. [
  5229. "untracked_dir/file1",
  5230. "untracked_dir/file2",
  5231. "untracked_dir/nested/file3",
  5232. ],
  5233. )
  5234. def test_status_mixed_tracked_untracked(self) -> None:
  5235. # Create a directory with both tracked and untracked files
  5236. mixed_dir = os.path.join(self.repo_path, "mixed_dir")
  5237. os.mkdir(mixed_dir)
  5238. # Add a tracked file
  5239. tracked_file = os.path.join(mixed_dir, "tracked.txt")
  5240. with open(tracked_file, "w") as fh:
  5241. fh.write("tracked content")
  5242. porcelain.add(self.repo.path, paths=[tracked_file])
  5243. porcelain.commit(
  5244. repo=self.repo.path,
  5245. message=b"add tracked file",
  5246. author=b"author <email>",
  5247. committer=b"committer <email>",
  5248. )
  5249. # Add untracked files to the same directory
  5250. untracked_file = os.path.join(mixed_dir, "untracked.txt")
  5251. with open(untracked_file, "w") as fh:
  5252. fh.write("untracked content")
  5253. # In "normal" mode, should show individual untracked files in mixed dirs
  5254. _, _, untracked = porcelain.status(self.repo.path, untracked_files="normal")
  5255. self.assertEqual(untracked, ["mixed_dir/untracked.txt"])
  5256. # In "all" mode, should be the same for mixed directories
  5257. _, _, untracked_all = porcelain.status(self.repo.path, untracked_files="all")
  5258. self.assertEqual(untracked_all, ["mixed_dir/untracked.txt"])
  5259. def test_status_crlf_mismatch(self) -> None:
  5260. # First make a commit as if the file has been added on a Linux system
  5261. # or with core.autocrlf=True
  5262. file_path = os.path.join(self.repo.path, "crlf")
  5263. with open(file_path, "wb") as f:
  5264. f.write(b"line1\nline2")
  5265. porcelain.add(repo=self.repo.path, paths=[file_path])
  5266. porcelain.commit(
  5267. repo=self.repo.path,
  5268. message=b"test status",
  5269. author=b"author <email>",
  5270. committer=b"committer <email>",
  5271. )
  5272. # Then update the file as if it was created by CGit on a Windows
  5273. # system with core.autocrlf=true
  5274. with open(file_path, "wb") as f:
  5275. f.write(b"line1\r\nline2")
  5276. results = porcelain.status(self.repo)
  5277. self.assertDictEqual({"add": [], "delete": [], "modify": []}, results.staged)
  5278. self.assertListEqual(results.unstaged, [b"crlf"])
  5279. self.assertListEqual(results.untracked, [])
  5280. def test_status_autocrlf_true(self) -> None:
  5281. # First make a commit as if the file has been added on a Linux system
  5282. # or with core.autocrlf=True
  5283. file_path = os.path.join(self.repo.path, "crlf")
  5284. with open(file_path, "wb") as f:
  5285. f.write(b"line1\nline2")
  5286. porcelain.add(repo=self.repo.path, paths=[file_path])
  5287. porcelain.commit(
  5288. repo=self.repo.path,
  5289. message=b"test status",
  5290. author=b"author <email>",
  5291. committer=b"committer <email>",
  5292. )
  5293. # Then update the file as if it was created by CGit on a Windows
  5294. # system with core.autocrlf=true
  5295. with open(file_path, "wb") as f:
  5296. f.write(b"line1\r\nline2")
  5297. # TODO: It should be set automatically by looking at the configuration
  5298. c = self.repo.get_config()
  5299. c.set("core", "autocrlf", True)
  5300. c.write_to_path()
  5301. results = porcelain.status(self.repo)
  5302. self.assertDictEqual({"add": [], "delete": [], "modify": []}, results.staged)
  5303. self.assertListEqual(results.unstaged, [])
  5304. self.assertListEqual(results.untracked, [])
  5305. def test_status_autocrlf_input(self) -> None:
  5306. # Commit existing file with CRLF
  5307. file_path = os.path.join(self.repo.path, "crlf-exists")
  5308. with open(file_path, "wb") as f:
  5309. f.write(b"line1\r\nline2")
  5310. porcelain.add(repo=self.repo.path, paths=[file_path])
  5311. porcelain.commit(
  5312. repo=self.repo.path,
  5313. message=b"test status",
  5314. author=b"author <email>",
  5315. committer=b"committer <email>",
  5316. )
  5317. # Get the index entry mtime and set the file mtime to match it exactly
  5318. # This ensures stat matching works correctly with nanosecond precision
  5319. index = self.repo.open_index()
  5320. entry = index[b"crlf-exists"]
  5321. if isinstance(entry.mtime, tuple):
  5322. mtime_nsec = entry.mtime[0] * 1_000_000_000 + entry.mtime[1]
  5323. else:
  5324. mtime_nsec = int(entry.mtime * 1_000_000_000)
  5325. # Use ns parameter to preserve nanosecond precision
  5326. os.utime(file_path, ns=(mtime_nsec, mtime_nsec))
  5327. c = self.repo.get_config()
  5328. c.set("core", "autocrlf", "input")
  5329. c.write_to_path()
  5330. # Add new (untracked) file
  5331. file_path = os.path.join(self.repo.path, "crlf-new")
  5332. with open(file_path, "wb") as f:
  5333. f.write(b"line1\r\nline2")
  5334. porcelain.add(repo=self.repo.path, paths=[file_path])
  5335. results = porcelain.status(self.repo)
  5336. self.assertDictEqual(
  5337. {"add": [b"crlf-new"], "delete": [], "modify": []}, results.staged
  5338. )
  5339. # File committed with CRLF before autocrlf=input was enabled
  5340. # will NOT appear as unstaged because stat matching optimization
  5341. # skips filter processing when file hasn't been modified.
  5342. # This matches Git's behavior, which uses stat matching to avoid
  5343. # expensive filter operations. Git shows a warning instead.
  5344. self.assertListEqual(results.unstaged, [])
  5345. self.assertListEqual(results.untracked, [])
  5346. def test_status_autocrlf_input_modified(self) -> None:
  5347. """Test that modified files with CRLF are correctly detected with autocrlf=input."""
  5348. # Commit existing file with CRLF
  5349. file_path = os.path.join(self.repo.path, "crlf-file.txt")
  5350. with open(file_path, "wb") as f:
  5351. f.write(b"line1\r\nline2\r\nline3\r\n")
  5352. porcelain.add(repo=self.repo.path, paths=[file_path])
  5353. porcelain.commit(
  5354. repo=self.repo.path,
  5355. message=b"initial commit",
  5356. author=b"author <email>",
  5357. committer=b"committer <email>",
  5358. )
  5359. c = self.repo.get_config()
  5360. c.set("core", "autocrlf", "input")
  5361. c.write_to_path()
  5362. # Modify the file content but keep CRLF
  5363. with open(file_path, "wb") as f:
  5364. f.write(b"line1\r\nline2 modified\r\nline3\r\n")
  5365. results = porcelain.status(self.repo)
  5366. # Modified file should be detected as unstaged
  5367. self.assertListEqual(results.unstaged, [b"crlf-file.txt"])
  5368. def test_status_autocrlf_input_binary(self) -> None:
  5369. """Test that binary files are not affected by autocrlf=input."""
  5370. # Set autocrlf=input first
  5371. c = self.repo.get_config()
  5372. c.set("core", "autocrlf", "input")
  5373. c.write_to_path()
  5374. # Commit binary file with CRLF-like sequences
  5375. file_path = os.path.join(self.repo.path, "binary.dat")
  5376. with open(file_path, "wb") as f:
  5377. f.write(b"binary\r\ndata\x00\xff\r\nmore")
  5378. porcelain.add(repo=self.repo.path, paths=[file_path])
  5379. porcelain.commit(
  5380. repo=self.repo.path,
  5381. message=b"add binary",
  5382. author=b"author <email>",
  5383. committer=b"committer <email>",
  5384. )
  5385. # Status should be clean - binary files not normalized
  5386. results = porcelain.status(self.repo)
  5387. self.assertListEqual(results.unstaged, [])
  5388. def test_status_autocrlf_input_mixed_endings(self) -> None:
  5389. """Test files with mixed line endings with autocrlf=input."""
  5390. # Set autocrlf=input
  5391. c = self.repo.get_config()
  5392. c.set("core", "autocrlf", "input")
  5393. c.write_to_path()
  5394. # Create file with mixed line endings
  5395. file_path = os.path.join(self.repo.path, "mixed.txt")
  5396. with open(file_path, "wb") as f:
  5397. f.write(b"line1\r\nline2\nline3\r\n")
  5398. porcelain.add(repo=self.repo.path, paths=[file_path])
  5399. porcelain.commit(
  5400. repo=self.repo.path,
  5401. message=b"add mixed",
  5402. author=b"author <email>",
  5403. committer=b"committer <email>",
  5404. )
  5405. # The file was normalized on commit, so working tree should match
  5406. results = porcelain.status(self.repo)
  5407. self.assertListEqual(results.unstaged, [])
  5408. def test_status_autocrlf_input_issue_1770(self) -> None:
  5409. """Test the specific scenario from issue #1770.
  5410. Files with CRLF committed with autocrlf=input should not show as unstaged
  5411. when their content hasn't changed.
  5412. """
  5413. # Set autocrlf=input BEFORE committing
  5414. c = self.repo.get_config()
  5415. c.set("core", "autocrlf", "input")
  5416. c.write_to_path()
  5417. # Create and commit file with CRLF endings
  5418. file_path = os.path.join(self.repo.path, "crlf-test.dsp")
  5419. with open(file_path, "wb") as f:
  5420. f.write(b"# Microsoft DSP file\r\n\r\nContent here\r\n")
  5421. porcelain.add(repo=self.repo.path, paths=[file_path])
  5422. porcelain.commit(
  5423. repo=self.repo.path,
  5424. message=b"add dsp file",
  5425. author=b"author <email>",
  5426. committer=b"committer <email>",
  5427. )
  5428. # File was normalized to LF in index, but working tree still has CRLF
  5429. # Status should be clean because comparison normalizes working tree too
  5430. results = porcelain.status(self.repo)
  5431. self.assertListEqual(results.unstaged, [])
  5432. def test_get_tree_changes_add(self) -> None:
  5433. """Unit test for get_tree_changes add."""
  5434. # Make a dummy file, stage
  5435. filename = "bar"
  5436. fullpath = os.path.join(self.repo.path, filename)
  5437. with open(fullpath, "w") as f:
  5438. f.write("stuff")
  5439. porcelain.add(repo=self.repo.path, paths=fullpath)
  5440. porcelain.commit(
  5441. repo=self.repo.path,
  5442. message=b"test status",
  5443. author=b"author <email>",
  5444. committer=b"committer <email>",
  5445. )
  5446. filename = "foo"
  5447. fullpath = os.path.join(self.repo.path, filename)
  5448. with open(fullpath, "w") as f:
  5449. f.write("stuff")
  5450. porcelain.add(repo=self.repo.path, paths=fullpath)
  5451. changes = porcelain.get_tree_changes(self.repo.path)
  5452. self.assertEqual(changes["add"][0], filename.encode("ascii"))
  5453. self.assertEqual(len(changes["add"]), 1)
  5454. self.assertEqual(len(changes["modify"]), 0)
  5455. self.assertEqual(len(changes["delete"]), 0)
  5456. def test_get_tree_changes_modify(self) -> None:
  5457. """Unit test for get_tree_changes modify."""
  5458. # Make a dummy file, stage, commit, modify
  5459. filename = "foo"
  5460. fullpath = os.path.join(self.repo.path, filename)
  5461. with open(fullpath, "w") as f:
  5462. f.write("stuff")
  5463. porcelain.add(repo=self.repo.path, paths=fullpath)
  5464. porcelain.commit(
  5465. repo=self.repo.path,
  5466. message=b"test status",
  5467. author=b"author <email>",
  5468. committer=b"committer <email>",
  5469. )
  5470. with open(fullpath, "w") as f:
  5471. f.write("otherstuff")
  5472. porcelain.add(repo=self.repo.path, paths=fullpath)
  5473. changes = porcelain.get_tree_changes(self.repo.path)
  5474. self.assertEqual(changes["modify"][0], filename.encode("ascii"))
  5475. self.assertEqual(len(changes["add"]), 0)
  5476. self.assertEqual(len(changes["modify"]), 1)
  5477. self.assertEqual(len(changes["delete"]), 0)
  5478. def test_get_tree_changes_delete(self) -> None:
  5479. """Unit test for get_tree_changes delete."""
  5480. # Make a dummy file, stage, commit, remove
  5481. filename = "foo"
  5482. fullpath = os.path.join(self.repo.path, filename)
  5483. with open(fullpath, "w") as f:
  5484. f.write("stuff")
  5485. porcelain.add(repo=self.repo.path, paths=fullpath)
  5486. porcelain.commit(
  5487. repo=self.repo.path,
  5488. message=b"test status",
  5489. author=b"author <email>",
  5490. committer=b"committer <email>",
  5491. )
  5492. cwd = os.getcwd()
  5493. self.addCleanup(os.chdir, cwd)
  5494. os.chdir(self.repo.path)
  5495. porcelain.remove(repo=self.repo.path, paths=[filename])
  5496. changes = porcelain.get_tree_changes(self.repo.path)
  5497. self.assertEqual(changes["delete"][0], filename.encode("ascii"))
  5498. self.assertEqual(len(changes["add"]), 0)
  5499. self.assertEqual(len(changes["modify"]), 0)
  5500. self.assertEqual(len(changes["delete"]), 1)
  5501. def test_get_untracked_paths(self) -> None:
  5502. with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
  5503. f.write("ignored\n")
  5504. with open(os.path.join(self.repo.path, "ignored"), "w") as f:
  5505. f.write("blah\n")
  5506. with open(os.path.join(self.repo.path, "notignored"), "w") as f:
  5507. f.write("blah\n")
  5508. os.symlink(
  5509. os.path.join(self.repo.path, os.pardir, "external_target"),
  5510. os.path.join(self.repo.path, "link"),
  5511. )
  5512. self.assertEqual(
  5513. {"ignored", "notignored", ".gitignore", "link"},
  5514. set(
  5515. porcelain.get_untracked_paths(
  5516. self.repo.path, self.repo.path, self.repo.open_index()
  5517. )
  5518. ),
  5519. )
  5520. self.assertEqual(
  5521. {".gitignore", "notignored", "link"},
  5522. set(porcelain.status(self.repo).untracked),
  5523. )
  5524. self.assertEqual(
  5525. {".gitignore", "notignored", "ignored", "link"},
  5526. set(porcelain.status(self.repo, ignored=True).untracked),
  5527. )
  5528. def test_get_untracked_paths_subrepo(self) -> None:
  5529. with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
  5530. f.write("nested/\n")
  5531. with open(os.path.join(self.repo.path, "notignored"), "w") as f:
  5532. f.write("blah\n")
  5533. subrepo = Repo.init(os.path.join(self.repo.path, "nested"), mkdir=True)
  5534. with open(os.path.join(subrepo.path, "ignored"), "w") as f:
  5535. f.write("bleep\n")
  5536. with open(os.path.join(subrepo.path, "with"), "w") as f:
  5537. f.write("bloop\n")
  5538. with open(os.path.join(subrepo.path, "manager"), "w") as f:
  5539. f.write("blop\n")
  5540. self.assertEqual(
  5541. {".gitignore", "notignored", os.path.join("nested", "")},
  5542. set(
  5543. porcelain.get_untracked_paths(
  5544. self.repo.path, self.repo.path, self.repo.open_index()
  5545. )
  5546. ),
  5547. )
  5548. self.assertEqual(
  5549. {".gitignore", "notignored"},
  5550. set(
  5551. porcelain.get_untracked_paths(
  5552. self.repo.path,
  5553. self.repo.path,
  5554. self.repo.open_index(),
  5555. exclude_ignored=True,
  5556. )
  5557. ),
  5558. )
  5559. self.assertEqual(
  5560. {"ignored", "with", "manager"},
  5561. set(
  5562. porcelain.get_untracked_paths(
  5563. subrepo.path, subrepo.path, subrepo.open_index()
  5564. )
  5565. ),
  5566. )
  5567. self.assertEqual(
  5568. set(),
  5569. set(
  5570. porcelain.get_untracked_paths(
  5571. subrepo.path,
  5572. self.repo.path,
  5573. self.repo.open_index(),
  5574. )
  5575. ),
  5576. )
  5577. self.assertEqual(
  5578. {
  5579. os.path.join("nested", "ignored"),
  5580. os.path.join("nested", "with"),
  5581. os.path.join("nested", "manager"),
  5582. },
  5583. set(
  5584. porcelain.get_untracked_paths(
  5585. self.repo.path,
  5586. subrepo.path,
  5587. self.repo.open_index(),
  5588. )
  5589. ),
  5590. )
  5591. def test_get_untracked_paths_nested_gitignore(self) -> None:
  5592. """Test directories with nested .gitignore files that ignore all contents."""
  5593. # Create cache directories with .gitignore files that contain "*"
  5594. cache_dirs = [".ruff_cache", ".pytest_cache", "__pycache__"]
  5595. for cache_dir in cache_dirs:
  5596. cache_path = os.path.join(self.repo.path, cache_dir)
  5597. os.mkdir(cache_path)
  5598. # Create .gitignore with * pattern (ignores everything)
  5599. with open(os.path.join(cache_path, ".gitignore"), "w") as f:
  5600. f.write("*\n")
  5601. # Create some files in the cache directory
  5602. with open(os.path.join(cache_path, "somefile.txt"), "w") as f:
  5603. f.write("cached data\n")
  5604. with open(os.path.join(cache_path, "data.json"), "w") as f:
  5605. f.write("{}\n")
  5606. # Create a normal untracked file
  5607. with open(os.path.join(self.repo.path, "untracked.txt"), "w") as f:
  5608. f.write("untracked content\n")
  5609. # Test with exclude_ignored=True (default for status)
  5610. untracked = set(
  5611. porcelain.get_untracked_paths(
  5612. self.repo.path,
  5613. self.repo.path,
  5614. self.repo.open_index(),
  5615. exclude_ignored=True,
  5616. untracked_files="normal",
  5617. )
  5618. )
  5619. # Cache directories should NOT be in untracked since all their contents are ignored
  5620. self.assertEqual({"untracked.txt"}, untracked)
  5621. # Test with exclude_ignored=False
  5622. untracked_with_ignored = set(
  5623. porcelain.get_untracked_paths(
  5624. self.repo.path,
  5625. self.repo.path,
  5626. self.repo.open_index(),
  5627. exclude_ignored=False,
  5628. untracked_files="normal",
  5629. )
  5630. )
  5631. # Cache directories should be included when not excluding ignored
  5632. expected = {"untracked.txt"}
  5633. for cache_dir in cache_dirs:
  5634. expected.add(cache_dir + os.sep)
  5635. self.assertEqual(expected, untracked_with_ignored)
  5636. # Test status() which uses exclude_ignored=True by default
  5637. status = porcelain.status(self.repo)
  5638. self.assertEqual(["untracked.txt"], status.untracked)
  5639. # Test status() with ignored=True which uses exclude_ignored=False
  5640. status_with_ignored = porcelain.status(self.repo, ignored=True)
  5641. # Should include cache directories
  5642. self.assertIn("untracked.txt", status_with_ignored.untracked)
  5643. for cache_dir in cache_dirs:
  5644. self.assertIn(cache_dir + "/", status_with_ignored.untracked)
  5645. def test_get_untracked_paths_mixed_directory(self) -> None:
  5646. """Test directory with both ignored and non-ignored files."""
  5647. # Create a directory with mixed content
  5648. mixed_dir = os.path.join(self.repo.path, "mixed")
  5649. os.mkdir(mixed_dir)
  5650. # Create .gitignore that ignores .log files
  5651. with open(os.path.join(mixed_dir, ".gitignore"), "w") as f:
  5652. f.write("*.log\n")
  5653. # Create ignored and non-ignored files
  5654. with open(os.path.join(mixed_dir, "debug.log"), "w") as f:
  5655. f.write("debug info\n")
  5656. with open(os.path.join(mixed_dir, "readme.txt"), "w") as f:
  5657. f.write("important\n")
  5658. # Test with exclude_ignored=True and normal mode
  5659. untracked = set(
  5660. porcelain.get_untracked_paths(
  5661. self.repo.path,
  5662. self.repo.path,
  5663. self.repo.open_index(),
  5664. exclude_ignored=True,
  5665. untracked_files="normal",
  5666. )
  5667. )
  5668. # In normal mode, should show the directory (matching git behavior)
  5669. self.assertEqual({os.path.join("mixed", "")}, untracked)
  5670. # Test with untracked_files="all"
  5671. untracked_all = set(
  5672. porcelain.get_untracked_paths(
  5673. self.repo.path,
  5674. self.repo.path,
  5675. self.repo.open_index(),
  5676. exclude_ignored=True,
  5677. untracked_files="all",
  5678. )
  5679. )
  5680. # Should list the non-ignored files
  5681. expected = {
  5682. os.path.join("mixed", ".gitignore"),
  5683. os.path.join("mixed", "readme.txt"),
  5684. }
  5685. self.assertEqual(expected, untracked_all)
  5686. def test_get_untracked_paths_specific_ignore_pattern(self) -> None:
  5687. """Test directory with .gitignore that ignores specific files, not all."""
  5688. # Create a directory
  5689. test_dir = os.path.join(self.repo.path, "testdir")
  5690. os.mkdir(test_dir)
  5691. # Create .gitignore that ignores only files named "test"
  5692. with open(os.path.join(test_dir, ".gitignore"), "w") as f:
  5693. f.write("test\n")
  5694. # Create files
  5695. with open(os.path.join(test_dir, "test"), "w") as f:
  5696. f.write("ignored\n")
  5697. with open(os.path.join(test_dir, "other.txt"), "w") as f:
  5698. f.write("not ignored\n")
  5699. # Test with exclude_ignored=True and normal mode
  5700. untracked = set(
  5701. porcelain.get_untracked_paths(
  5702. self.repo.path,
  5703. self.repo.path,
  5704. self.repo.open_index(),
  5705. exclude_ignored=True,
  5706. untracked_files="normal",
  5707. )
  5708. )
  5709. # Directory should be shown because it has non-ignored files
  5710. self.assertEqual({os.path.join("testdir", "")}, untracked)
  5711. def test_get_untracked_paths_nested_subdirs_all_ignored(self) -> None:
  5712. """Test directory containing only subdirectories where all files are ignored."""
  5713. # Create parent directory with .gitignore that ignores everything
  5714. parent_dir = os.path.join(self.repo.path, "parent")
  5715. os.mkdir(parent_dir)
  5716. with open(os.path.join(parent_dir, ".gitignore"), "w") as f:
  5717. f.write("*\n")
  5718. # Create subdirectories with files (all should be ignored by parent's .gitignore)
  5719. sub1 = os.path.join(parent_dir, "sub1")
  5720. sub2 = os.path.join(parent_dir, "sub2")
  5721. os.mkdir(sub1)
  5722. os.mkdir(sub2)
  5723. # Create files in subdirectories
  5724. with open(os.path.join(sub1, "file1.txt"), "w") as f:
  5725. f.write("content1\n")
  5726. with open(os.path.join(sub2, "file2.txt"), "w") as f:
  5727. f.write("content2\n")
  5728. # Create another normal untracked file
  5729. with open(os.path.join(self.repo.path, "normal.txt"), "w") as f:
  5730. f.write("normal\n")
  5731. # Test with exclude_ignored=True
  5732. untracked = set(
  5733. porcelain.get_untracked_paths(
  5734. self.repo.path,
  5735. self.repo.path,
  5736. self.repo.open_index(),
  5737. exclude_ignored=True,
  5738. untracked_files="normal",
  5739. )
  5740. )
  5741. # Parent directory should NOT be shown since all nested files are ignored
  5742. self.assertEqual({"normal.txt"}, untracked)
  5743. def test_get_untracked_paths_nested_subdirs_mixed(self) -> None:
  5744. """Test directory containing only subdirectories where some files are ignored, some aren't."""
  5745. # Create parent directory with .gitignore that ignores .log files
  5746. parent_dir = os.path.join(self.repo.path, "parent")
  5747. os.mkdir(parent_dir)
  5748. with open(os.path.join(parent_dir, ".gitignore"), "w") as f:
  5749. f.write("*.log\n")
  5750. # Create subdirectories
  5751. sub1 = os.path.join(parent_dir, "sub1")
  5752. sub2 = os.path.join(parent_dir, "sub2")
  5753. os.mkdir(sub1)
  5754. os.mkdir(sub2)
  5755. # sub1: only ignored files
  5756. with open(os.path.join(sub1, "debug.log"), "w") as f:
  5757. f.write("log content\n")
  5758. with open(os.path.join(sub1, "error.log"), "w") as f:
  5759. f.write("error log\n")
  5760. # sub2: mix of ignored and non-ignored files
  5761. with open(os.path.join(sub2, "access.log"), "w") as f:
  5762. f.write("access log\n")
  5763. with open(os.path.join(sub2, "readme.txt"), "w") as f:
  5764. f.write("important info\n")
  5765. # Test with exclude_ignored=True
  5766. untracked = set(
  5767. porcelain.get_untracked_paths(
  5768. self.repo.path,
  5769. self.repo.path,
  5770. self.repo.open_index(),
  5771. exclude_ignored=True,
  5772. untracked_files="normal",
  5773. )
  5774. )
  5775. # Parent directory SHOULD be shown since sub2 has non-ignored files
  5776. self.assertEqual({os.path.join("parent", "")}, untracked)
  5777. def test_get_untracked_paths_deeply_nested_all_ignored(self) -> None:
  5778. """Test deeply nested directories where all files are eventually ignored."""
  5779. # Create nested structure: parent/sub/subsub/
  5780. parent_dir = os.path.join(self.repo.path, "parent")
  5781. sub_dir = os.path.join(parent_dir, "sub")
  5782. subsub_dir = os.path.join(sub_dir, "subsub")
  5783. os.makedirs(subsub_dir)
  5784. # Parent has .gitignore that ignores everything
  5785. with open(os.path.join(parent_dir, ".gitignore"), "w") as f:
  5786. f.write("*\n")
  5787. # Create files at different levels
  5788. with open(os.path.join(subsub_dir, "deep_file.txt"), "w") as f:
  5789. f.write("deep content\n")
  5790. with open(os.path.join(sub_dir, "mid_file.txt"), "w") as f:
  5791. f.write("mid content\n")
  5792. # Test with exclude_ignored=True
  5793. untracked = set(
  5794. porcelain.get_untracked_paths(
  5795. self.repo.path,
  5796. self.repo.path,
  5797. self.repo.open_index(),
  5798. exclude_ignored=True,
  5799. untracked_files="normal",
  5800. )
  5801. )
  5802. # Parent directory should NOT be shown since all nested files are ignored
  5803. self.assertEqual(set(), untracked)
  5804. def test_get_untracked_paths_subdir(self) -> None:
  5805. with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
  5806. f.write("subdir/\nignored")
  5807. with open(os.path.join(self.repo.path, "notignored"), "w") as f:
  5808. f.write("blah\n")
  5809. os.mkdir(os.path.join(self.repo.path, "subdir"))
  5810. with open(os.path.join(self.repo.path, "ignored"), "w") as f:
  5811. f.write("foo")
  5812. with open(os.path.join(self.repo.path, "subdir", "ignored"), "w") as f:
  5813. f.write("foo")
  5814. self.assertEqual(
  5815. {
  5816. ".gitignore",
  5817. "notignored",
  5818. "ignored",
  5819. os.path.join("subdir", ""),
  5820. },
  5821. set(
  5822. porcelain.get_untracked_paths(
  5823. self.repo.path,
  5824. self.repo.path,
  5825. self.repo.open_index(),
  5826. )
  5827. ),
  5828. )
  5829. self.assertEqual(
  5830. {".gitignore", "notignored"},
  5831. set(
  5832. porcelain.get_untracked_paths(
  5833. self.repo.path,
  5834. self.repo.path,
  5835. self.repo.open_index(),
  5836. exclude_ignored=True,
  5837. )
  5838. ),
  5839. )
  5840. def test_get_untracked_paths_invalid_untracked_files(self) -> None:
  5841. with self.assertRaises(ValueError):
  5842. list(
  5843. porcelain.get_untracked_paths(
  5844. self.repo.path,
  5845. self.repo.path,
  5846. self.repo.open_index(),
  5847. untracked_files="invalid_value",
  5848. )
  5849. )
  5850. def test_get_untracked_paths_normal(self) -> None:
  5851. # Create an untracked directory with files
  5852. untracked_dir = os.path.join(self.repo.path, "untracked_dir")
  5853. os.mkdir(untracked_dir)
  5854. with open(os.path.join(untracked_dir, "file1.txt"), "w") as f:
  5855. f.write("untracked content")
  5856. with open(os.path.join(untracked_dir, "file2.txt"), "w") as f:
  5857. f.write("more untracked content")
  5858. # Test that "normal" mode works and returns only the directory
  5859. _, _, untracked = porcelain.status(
  5860. repo=self.repo.path, untracked_files="normal"
  5861. )
  5862. self.assertEqual(untracked, ["untracked_dir/"])
  5863. def test_get_untracked_paths_top_level_issue_1247(self) -> None:
  5864. """Test for issue #1247: ensure top-level untracked files are detected."""
  5865. # Create a single top-level untracked file
  5866. with open(os.path.join(self.repo.path, "sample.txt"), "w") as f:
  5867. f.write("test content")
  5868. # Test get_untracked_paths directly
  5869. untracked = list(
  5870. porcelain.get_untracked_paths(
  5871. self.repo.path, self.repo.path, self.repo.open_index()
  5872. )
  5873. )
  5874. self.assertIn(
  5875. "sample.txt",
  5876. untracked,
  5877. "Top-level file 'sample.txt' should be in untracked list",
  5878. )
  5879. # Test via status
  5880. status = porcelain.status(self.repo)
  5881. self.assertIn(
  5882. "sample.txt",
  5883. status.untracked,
  5884. "Top-level file 'sample.txt' should be in status.untracked",
  5885. )
  5886. # TODO(jelmer): Add test for dulwich.porcelain.daemon
  5887. class ShortlogTests(PorcelainTestCase):
  5888. def test_shortlog(self) -> None:
  5889. """Test porcelain.shortlog function with multiple authors and commits."""
  5890. # Create first file and commit
  5891. file_a = os.path.join(self.repo.path, "a.txt")
  5892. with open(file_a, "w") as f:
  5893. f.write("hello")
  5894. porcelain.add(self.repo.path, paths=[file_a])
  5895. porcelain.commit(
  5896. repo=self.repo.path,
  5897. message=b"Initial commit",
  5898. author=b"John <john@example.com>",
  5899. )
  5900. # Create second file and commit
  5901. file_b = os.path.join(self.repo.path, "b.txt")
  5902. with open(file_b, "w") as f:
  5903. f.write("update")
  5904. porcelain.add(self.repo.path, paths=[file_b])
  5905. porcelain.commit(
  5906. repo=self.repo.path,
  5907. message=b"Update file",
  5908. author=b"Doe <doe@example.com>",
  5909. )
  5910. # Call shortlog
  5911. output = porcelain.shortlog(self.repo.path)
  5912. expected = [
  5913. {"author": "John <john@example.com>", "messages": "Initial commit"},
  5914. {"author": "Doe <doe@example.com>", "messages": "Update file"},
  5915. ]
  5916. self.assertCountEqual(output, expected)
  5917. # Test summary output (count of messages)
  5918. output_summary = [
  5919. {"author": entry["author"], "count": len(entry["messages"].splitlines())}
  5920. for entry in output
  5921. ]
  5922. expected_summary = [
  5923. {"author": "John <john@example.com>", "count": 1},
  5924. {"author": "Doe <doe@example.com>", "count": 1},
  5925. ]
  5926. self.assertCountEqual(output_summary, expected_summary)
  5927. class UploadPackTests(PorcelainTestCase):
  5928. """Tests for upload_pack."""
  5929. def test_upload_pack(self) -> None:
  5930. outf = BytesIO()
  5931. exitcode = porcelain.upload_pack(self.repo.path, BytesIO(b"0000"), outf)
  5932. outlines = outf.getvalue().splitlines()
  5933. self.assertEqual([b"0000"], outlines)
  5934. self.assertEqual(0, exitcode)
  5935. class ReceivePackTests(PorcelainTestCase):
  5936. """Tests for receive_pack."""
  5937. def test_receive_pack(self) -> None:
  5938. filename = "foo"
  5939. fullpath = os.path.join(self.repo.path, filename)
  5940. with open(fullpath, "w") as f:
  5941. f.write("stuff")
  5942. porcelain.add(repo=self.repo.path, paths=fullpath)
  5943. self.repo.get_worktree().commit(
  5944. message=b"test status",
  5945. committer=b"committer <email>",
  5946. author=b"author <email>",
  5947. commit_timestamp=1402354300,
  5948. commit_timezone=0,
  5949. author_timestamp=1402354300,
  5950. author_timezone=0,
  5951. )
  5952. outf = BytesIO()
  5953. exitcode = porcelain.receive_pack(self.repo.path, BytesIO(b"0000"), outf)
  5954. outlines = outf.getvalue().splitlines()
  5955. self.assertEqual(
  5956. [
  5957. b"00a4319b56ce3aee2d489f759736a79cc552c9bb86d9 HEAD\x00 report-status "
  5958. b"delete-refs quiet ofs-delta side-band-64k "
  5959. b"no-done object-format=sha1 symref=HEAD:refs/heads/master",
  5960. b"003f319b56ce3aee2d489f759736a79cc552c9bb86d9 refs/heads/master",
  5961. b"0000",
  5962. ],
  5963. outlines,
  5964. )
  5965. self.assertEqual(0, exitcode)
  5966. class BranchListTests(PorcelainTestCase):
  5967. def test_standard(self) -> None:
  5968. self.assertEqual(set(), set(porcelain.branch_list(self.repo)))
  5969. def test_new_branch(self) -> None:
  5970. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  5971. self.repo[b"HEAD"] = c1.id
  5972. porcelain.branch_create(self.repo, b"foo")
  5973. self.assertEqual({b"master", b"foo"}, set(porcelain.branch_list(self.repo)))
  5974. def test_sort_by_refname(self) -> None:
  5975. """Test branch.sort=refname (default alphabetical)."""
  5976. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  5977. self.repo[b"HEAD"] = c1.id
  5978. # Create branches in non-alphabetical order
  5979. porcelain.branch_create(self.repo, b"zebra")
  5980. porcelain.branch_create(self.repo, b"alpha")
  5981. porcelain.branch_create(self.repo, b"beta")
  5982. # Set branch.sort to refname (though it's the default)
  5983. config = self.repo.get_config()
  5984. config.set((b"branch",), b"sort", b"refname")
  5985. config.write_to_path()
  5986. # Should be sorted alphabetically
  5987. branches = porcelain.branch_list(self.repo)
  5988. self.assertEqual([b"alpha", b"beta", b"master", b"zebra"], branches)
  5989. def test_sort_by_refname_reverse(self) -> None:
  5990. """Test branch.sort=-refname (reverse alphabetical)."""
  5991. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  5992. self.repo[b"HEAD"] = c1.id
  5993. # Create branches
  5994. porcelain.branch_create(self.repo, b"zebra")
  5995. porcelain.branch_create(self.repo, b"alpha")
  5996. porcelain.branch_create(self.repo, b"beta")
  5997. # Set branch.sort to -refname
  5998. config = self.repo.get_config()
  5999. config.set((b"branch",), b"sort", b"-refname")
  6000. config.write_to_path()
  6001. # Should be sorted reverse alphabetically
  6002. branches = porcelain.branch_list(self.repo)
  6003. self.assertEqual([b"zebra", b"master", b"beta", b"alpha"], branches)
  6004. def test_sort_by_committerdate(self) -> None:
  6005. """Test branch.sort=committerdate."""
  6006. # Use build_commit_graph to create proper commits with specific times
  6007. c1, c2, c3 = build_commit_graph(
  6008. self.repo.object_store,
  6009. [[1], [2], [3]],
  6010. attrs={
  6011. 1: {"commit_time": 1000}, # oldest
  6012. 2: {"commit_time": 2000}, # newest
  6013. 3: {"commit_time": 1500}, # middle
  6014. },
  6015. )
  6016. self.repo[b"HEAD"] = c1.id
  6017. # Create branches pointing to different commits
  6018. self.repo.refs[b"refs/heads/master"] = c1.id # master points to oldest
  6019. self.repo.refs[b"refs/heads/oldest"] = c1.id
  6020. self.repo.refs[b"refs/heads/newest"] = c2.id
  6021. self.repo.refs[b"refs/heads/middle"] = c3.id
  6022. # Set branch.sort to committerdate
  6023. config = self.repo.get_config()
  6024. config.set((b"branch",), b"sort", b"committerdate")
  6025. config.write_to_path()
  6026. # Should be sorted by commit time (oldest first)
  6027. branches = porcelain.branch_list(self.repo)
  6028. self.assertEqual([b"master", b"oldest", b"middle", b"newest"], branches)
  6029. def test_sort_by_committerdate_reverse(self) -> None:
  6030. """Test branch.sort=-committerdate."""
  6031. # Use build_commit_graph to create proper commits with specific times
  6032. c1, c2, c3 = build_commit_graph(
  6033. self.repo.object_store,
  6034. [[1], [2], [3]],
  6035. attrs={
  6036. 1: {"commit_time": 1000}, # oldest
  6037. 2: {"commit_time": 2000}, # newest
  6038. 3: {"commit_time": 1500}, # middle
  6039. },
  6040. )
  6041. self.repo[b"HEAD"] = c1.id
  6042. # Create branches pointing to different commits
  6043. self.repo.refs[b"refs/heads/master"] = c1.id # master points to oldest
  6044. self.repo.refs[b"refs/heads/oldest"] = c1.id
  6045. self.repo.refs[b"refs/heads/newest"] = c2.id
  6046. self.repo.refs[b"refs/heads/middle"] = c3.id
  6047. # Set branch.sort to -committerdate
  6048. config = self.repo.get_config()
  6049. config.set((b"branch",), b"sort", b"-committerdate")
  6050. config.write_to_path()
  6051. # Should be sorted by commit time (newest first)
  6052. branches = porcelain.branch_list(self.repo)
  6053. self.assertEqual([b"newest", b"middle", b"master", b"oldest"], branches)
  6054. def test_sort_default(self) -> None:
  6055. """Test default sorting (no config)."""
  6056. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  6057. self.repo[b"HEAD"] = c1.id
  6058. # Create branches in non-alphabetical order
  6059. porcelain.branch_create(self.repo, b"zebra")
  6060. porcelain.branch_create(self.repo, b"alpha")
  6061. porcelain.branch_create(self.repo, b"beta")
  6062. # No config set - should default to alphabetical
  6063. branches = porcelain.branch_list(self.repo)
  6064. self.assertEqual([b"alpha", b"beta", b"master", b"zebra"], branches)
  6065. class BranchRemoteListTests(PorcelainTestCase):
  6066. def test_no_remote_branches(self) -> None:
  6067. """Test with no remote branches."""
  6068. result = porcelain.branch_remotes_list(self.repo)
  6069. self.assertEqual([], result)
  6070. def test_remote_branches_refname_sort(self) -> None:
  6071. """Test remote branches sorting with refname (alphabetical)."""
  6072. # Create some remote branches
  6073. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  6074. # Create remote branches is non-alphabetical order
  6075. self.repo.refs[b"refs/remotes/origin/feature-1"] = c1.id
  6076. self.repo.refs[b"refs/remotes/origin/feature-2"] = c1.id
  6077. self.repo.refs[b"refs/remotes/origin/feature-3"] = c1.id
  6078. self.repo.refs[b"refs/remotes/origin/feature-4"] = c1.id
  6079. # Set branch.sort to refname
  6080. config = self.repo.get_config()
  6081. config.set((b"branch",), b"sort", b"refname")
  6082. config.write_to_path()
  6083. # Should return only branch names, sorted alphabetically
  6084. branches = porcelain.branch_remotes_list(self.repo)
  6085. expected = [
  6086. b"origin/feature-1",
  6087. b"origin/feature-2",
  6088. b"origin/feature-3",
  6089. b"origin/feature-4",
  6090. ]
  6091. self.assertEqual(expected, branches)
  6092. def test_remote_branches_refname_reverse_sort(self) -> None:
  6093. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  6094. self.repo.refs[b"refs/remotes/origin/feature-1"] = c1.id
  6095. self.repo.refs[b"refs/remotes/origin/feature-2"] = c1.id
  6096. self.repo.refs[b"refs/remotes/origin/feature-3"] = c1.id
  6097. self.repo.refs[b"refs/remotes/origin/feature-4"] = c1.id
  6098. # Set branch.sort to -refname
  6099. config = self.repo.get_config()
  6100. config.set((b"branch",), b"sort", b"-refname")
  6101. config.write_to_path()
  6102. branches = porcelain.branch_remotes_list(self.repo)
  6103. expected = [
  6104. b"origin/feature-4",
  6105. b"origin/feature-3",
  6106. b"origin/feature-2",
  6107. b"origin/feature-1",
  6108. ]
  6109. self.assertEqual(expected, branches)
  6110. def test_remote_branches_committerdate_sort(self) -> None:
  6111. """Test remote branches sorting with committerdate"""
  6112. # Create commits with different timestamps
  6113. c1, c2, c3 = build_commit_graph(
  6114. self.repo.object_store,
  6115. [[1], [2], [3]],
  6116. attrs={
  6117. 1: {"commit_time": 1000},
  6118. 2: {"commit_time": 2000},
  6119. 3: {"commit_time": 3000},
  6120. },
  6121. )
  6122. self.repo.refs[b"refs/remotes/origin/oldest"] = c1.id
  6123. self.repo.refs[b"refs/remotes/origin/middle"] = c2.id
  6124. self.repo.refs[b"refs/remotes/origin/newest"] = c3.id
  6125. # Set branch.sort to committerdate
  6126. config = self.repo.get_config()
  6127. config.set((b"branch",), b"sort", b"committerdate")
  6128. config.write_to_path()
  6129. branches = porcelain.branch_remotes_list(self.repo)
  6130. # Should be sorted by commit time (oldest first), then alphabetically for same time
  6131. expected = [b"origin/oldest", b"origin/middle", b"origin/newest"]
  6132. self.assertEqual(expected, branches)
  6133. def test_remote_branches_committerdate_reverse_sort(self) -> None:
  6134. """Test remote branches sorting with -committerdate."""
  6135. c1, c2, c3 = build_commit_graph(
  6136. self.repo.object_store,
  6137. [[1], [2], [3]],
  6138. attrs={
  6139. 1: {"commit_time": 1000},
  6140. 2: {"commit_time": 2000},
  6141. 3: {"commit_time": 1500},
  6142. },
  6143. )
  6144. self.repo.refs[b"refs/remotes/origin/oldest"] = c1.id
  6145. self.repo.refs[b"refs/remotes/origin/newest"] = c2.id
  6146. self.repo.refs[b"refs/remotes/origin/middle"] = c3.id
  6147. # Set branch.sort to -committerdate
  6148. config = self.repo.get_config()
  6149. config.set((b"branch",), b"sort", b"-committerdate")
  6150. config.write_to_path()
  6151. branches = porcelain.branch_remotes_list(self.repo)
  6152. # Should be sorted by commit time (newest first), then alphabetically for same time
  6153. expected = [b"origin/newest", b"origin/middle", b"origin/oldest"]
  6154. self.assertEqual(expected, branches)
  6155. def test_remote_branches_authordate_sort(self) -> None:
  6156. """Test remote branches sorting with authordate."""
  6157. c1, c2, c3 = build_commit_graph(
  6158. self.repo.object_store,
  6159. [[1], [2], [3]],
  6160. attrs={
  6161. 1: {"author_time": 1500},
  6162. 2: {"author_time": 2000},
  6163. 3: {"author_time": 1000},
  6164. },
  6165. )
  6166. self.repo.refs[b"refs/remotes/origin/middle"] = c1.id
  6167. self.repo.refs[b"refs/remotes/origin/newest"] = c2.id
  6168. self.repo.refs[b"refs/remotes/origin/oldest"] = c3.id
  6169. # Set branch.sort to authordate
  6170. config = self.repo.get_config()
  6171. config.set((b"branch",), b"sort", b"authordate")
  6172. config.write_to_path()
  6173. branches = porcelain.branch_remotes_list(self.repo)
  6174. expected = [b"origin/oldest", b"origin/middle", b"origin/newest"]
  6175. self.assertEqual(expected, branches)
  6176. def test_unknown_sort_key(self) -> None:
  6177. """Test that unknown sort key raises ValueError."""
  6178. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  6179. self.repo.refs[b"refs/remotes/origin/master"] = c1.id
  6180. # Set branch.sort to unknown key
  6181. config = self.repo.get_config()
  6182. config.set((b"branch",), b"sort", b"unknown")
  6183. config.write_to_path()
  6184. with self.assertRaises(ValueError) as cm:
  6185. porcelain.branch_remotes_list(self.repo)
  6186. self.assertIn("Unknown sort key: unknown", str(cm.exception))
  6187. def test_default_sort_no_config(self) -> None:
  6188. """Test default sorting when no config is set."""
  6189. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  6190. self.repo.refs[b"refs/remotes/origin/feature-1"] = c1.id
  6191. self.repo.refs[b"refs/remotes/origin/feature-2"] = c1.id
  6192. self.repo.refs[b"refs/remotes/origin/feature-3"] = c1.id
  6193. # No config set - should default to alphabetical
  6194. branches = porcelain.branch_remotes_list(self.repo)
  6195. expected = [b"origin/feature-1", b"origin/feature-2", b"origin/feature-3"]
  6196. self.assertEqual(expected, branches)
  6197. class BranchMergedTests(PorcelainTestCase):
  6198. def test_no_merged_branches(self) -> None:
  6199. """Test with no merged branches."""
  6200. # Create complete graph: c1 → c2 (master), c1 → c3 (feature)
  6201. [_c1, c2, c3] = build_commit_graph(
  6202. self.repo.object_store,
  6203. [
  6204. [1], # c1
  6205. [2, 1], # c2 → c1 (master line)
  6206. [3, 1], # c3 → c1 (diverged feature branch)
  6207. ],
  6208. )
  6209. self.repo.refs[b"HEAD"] = c2.id
  6210. self.repo.refs[b"refs/heads/master"] = c2.id
  6211. self.repo.refs[b"refs/heads/feature"] = c3.id
  6212. result = list(porcelain.merged_branches(self.repo))
  6213. self.assertEqual([b"master"], result)
  6214. def test_all_branches_merged(self) -> None:
  6215. """Test when all branches are merged into current."""
  6216. # Create linear history: c1 → c2 → c3 (HEAD)
  6217. [c1, c2, c3] = build_commit_graph(self.repo.object_store, [[1], [2, 1], [3, 2]])
  6218. self.repo.refs[b"HEAD"] = c3.id
  6219. self.repo.refs[b"refs/heads/master"] = c3.id
  6220. self.repo.refs[b"refs/heads/feature-1"] = c2.id # Merged (ancestor)
  6221. self.repo.refs[b"refs/heads/feature-2"] = c1.id # Merged (ancestor)
  6222. branches = list(porcelain.merged_branches(self.repo))
  6223. expected = [b"feature-1", b"master", b"feature-2"]
  6224. expected.sort()
  6225. branches.sort()
  6226. self.assertEqual(expected, branches)
  6227. def test_some_branches_merged(self) -> None:
  6228. """Test when some branches are merged, some are not."""
  6229. # c1 → c2 → c3 (HEAD/master)
  6230. # c1 → c4 → c5 (feature-1 - diverged)
  6231. [_c1, c2, c3, _c4, c5] = build_commit_graph(
  6232. self.repo.object_store,
  6233. [
  6234. [1], # c1
  6235. [2, 1], # c2 → c1 (master line)
  6236. [3, 2], # c3 → c2 (HEAD)
  6237. [4, 1], # c4 → c1 (feature branch)
  6238. [5, 4], # c5 → c4 (feature branch)
  6239. ],
  6240. )
  6241. self.repo.refs[b"HEAD"] = c3.id
  6242. self.repo.refs[b"refs/heads/master"] = c3.id
  6243. self.repo.refs[b"refs/heads/feature-1"] = c5.id # Not merged (diverged)
  6244. self.repo.refs[b"refs/heads/feature-2"] = c2.id # Merged (ancestor)
  6245. branches = list(porcelain.merged_branches(self.repo))
  6246. expected = [b"feature-2", b"master"]
  6247. expected.sort()
  6248. branches.sort()
  6249. self.assertEqual(expected, branches)
  6250. def test_only_current_branch_merged(self) -> None:
  6251. """Test when only current branch exists."""
  6252. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  6253. self.repo.refs[b"HEAD"] = c1.id
  6254. self.repo.refs[b"refs/heads/master"] = c1.id
  6255. result = list(porcelain.merged_branches(self.repo))
  6256. self.assertEqual([b"master"], result)
  6257. class BranchNoMergedTests(PorcelainTestCase):
  6258. def test_all_branches_merged(self) -> None:
  6259. """Test when all branches are merged - should return empty list."""
  6260. # Create linear history: c1 → c2 → c3 (HEAD)
  6261. [c1, c2, c3] = build_commit_graph(self.repo.object_store, [[1], [2, 1], [3, 2]])
  6262. self.repo.refs[b"HEAD"] = c3.id
  6263. self.repo.refs[b"refs/heads/master"] = c3.id
  6264. self.repo.refs[b"refs/heads/feature-1"] = c2.id # Merged (ancestor)
  6265. self.repo.refs[b"refs/heads/feature-2"] = c1.id # Merged (ancestor)
  6266. result = list(porcelain.no_merged_branches(self.repo))
  6267. self.assertEqual([], result)
  6268. def test_no_merged_branches(self) -> None:
  6269. """Test with some non-merged branches."""
  6270. # Create complete graph: c1 → c2 (master), c1 → c3 (feature)
  6271. [_c1, c2, c3] = build_commit_graph(
  6272. self.repo.object_store,
  6273. [
  6274. [1], # c1
  6275. [2, 1], # c2 → c1 (master line)
  6276. [3, 1], # c3 → c1 (diverged feature branch)
  6277. ],
  6278. )
  6279. self.repo.refs[b"HEAD"] = c2.id
  6280. self.repo.refs[b"refs/heads/master"] = c2.id
  6281. self.repo.refs[b"refs/heads/feature"] = c3.id
  6282. result = list(porcelain.no_merged_branches(self.repo))
  6283. self.assertEqual([b"feature"], result)
  6284. def test_some_branches_not_merged(self) -> None:
  6285. """Test when some branches are merged, some are not."""
  6286. # c1 → c2 → c3 (HEAD/master)
  6287. # c1 → c4 → c5 (feature-1 - diverged)
  6288. [_c1, c2, c3, _c4, c5] = build_commit_graph(
  6289. self.repo.object_store,
  6290. [
  6291. [1], # c1
  6292. [2, 1], # c2 → c1 (master line)
  6293. [3, 2], # c3 → c2 (HEAD)
  6294. [4, 1], # c4 → c1 (feature branch)
  6295. [5, 4], # c5 → c4 (feature branch)
  6296. ],
  6297. )
  6298. self.repo.refs[b"HEAD"] = c3.id
  6299. self.repo.refs[b"refs/heads/master"] = c3.id
  6300. self.repo.refs[b"refs/heads/feature-1"] = c5.id # Not merged (diverged)
  6301. self.repo.refs[b"refs/heads/feature-2"] = c2.id # Merged (ancestor)
  6302. result = list(porcelain.no_merged_branches(self.repo))
  6303. self.assertEqual([b"feature-1"], result)
  6304. def test_multiple_branches_not_merged(self) -> None:
  6305. """Test with multiple non-merged branches."""
  6306. # c1 → c2 (HEAD/master)
  6307. # c1 → c3 (feature-1 - diverged)
  6308. # c1 → c4 (feature-2 - diverged)
  6309. [_c1, c2, c3, c4] = build_commit_graph(
  6310. self.repo.object_store,
  6311. [
  6312. [1], # c1
  6313. [2, 1], # c2 → c1 (master line)
  6314. [3, 1], # c3 → c1 (feature-1 branch)
  6315. [4, 1], # c4 → c1 (feature-2 branch)
  6316. ],
  6317. )
  6318. self.repo.refs[b"HEAD"] = c2.id
  6319. self.repo.refs[b"refs/heads/master"] = c2.id
  6320. self.repo.refs[b"refs/heads/feature-1"] = c3.id # Not merged (diverged)
  6321. self.repo.refs[b"refs/heads/feature-2"] = c4.id # Not merged (diverged)
  6322. branches = list(porcelain.no_merged_branches(self.repo))
  6323. expected = [b"feature-1", b"feature-2"]
  6324. expected.sort()
  6325. branches.sort()
  6326. self.assertEqual(expected, branches)
  6327. def test_only_current_branch_exists(self) -> None:
  6328. """Test when only current branch exists - should return empty list."""
  6329. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  6330. self.repo.refs[b"HEAD"] = c1.id
  6331. self.repo.refs[b"refs/heads/master"] = c1.id
  6332. result = list(porcelain.no_merged_branches(self.repo))
  6333. self.assertEqual([], result)
  6334. class BranchContainsTests(PorcelainTestCase):
  6335. def test_commit_in_single_branch(self) -> None:
  6336. """Test commit contained in only one branch."""
  6337. # Create: c1 → c2 (master), c1 → c3 (feature)
  6338. [_c1, c2, c3] = build_commit_graph(
  6339. self.repo.object_store,
  6340. [
  6341. [1], # c1
  6342. [2, 1], # c2 → c1 (master line)
  6343. [3, 1], # c3 → c1 (feature branch)
  6344. ],
  6345. )
  6346. self.repo.refs[b"HEAD"] = c2.id
  6347. self.repo.refs[b"refs/heads/master"] = c2.id
  6348. self.repo.refs[b"refs/heads/feature"] = c3.id
  6349. # c2 is only in master branch
  6350. result = list(porcelain.branches_containing(self.repo, c2.id.decode()))
  6351. self.assertEqual([b"master"], result)
  6352. # c3 is only in feature branch
  6353. result = list(porcelain.branches_containing(self.repo, c3.id.decode()))
  6354. self.assertEqual([b"feature"], result)
  6355. def test_commit_in_multiple_branches(self) -> None:
  6356. """Test commit contained in multiple branches."""
  6357. # Create: c1 → c2 → c3 (master), c2 → c4 (feature)
  6358. [c1, c2, c3, c4] = build_commit_graph(
  6359. self.repo.object_store,
  6360. [
  6361. [1], # c1
  6362. [2, 1], # c2 → c1
  6363. [3, 2], # c3 → c2 (master)
  6364. [4, 2], # c4 → c2 (feature)
  6365. ],
  6366. )
  6367. self.repo.refs[b"HEAD"] = c3.id
  6368. self.repo.refs[b"refs/heads/master"] = c3.id
  6369. self.repo.refs[b"refs/heads/feature"] = c4.id
  6370. # c2 is in both branches (common ancestor)
  6371. branches = list(porcelain.branches_containing(self.repo, c2.id.decode()))
  6372. expected = [b"master", b"feature"]
  6373. expected.sort()
  6374. branches.sort()
  6375. self.assertEqual(expected, branches)
  6376. # c1 is in both branches (older common ancestor)
  6377. branches = list(porcelain.branches_containing(self.repo, c1.id.decode()))
  6378. expected = [b"master", b"feature"]
  6379. expected.sort()
  6380. branches.sort()
  6381. self.assertEqual(expected, branches)
  6382. def test_commit_in_all_branches(self) -> None:
  6383. """Test commit contained in all branches."""
  6384. # Create linear history: c1 → c2 → c3 (HEAD/master)
  6385. [c1, c2, c3] = build_commit_graph(self.repo.object_store, [[1], [2, 1], [3, 2]])
  6386. self.repo.refs[b"HEAD"] = c3.id
  6387. self.repo.refs[b"refs/heads/master"] = c3.id
  6388. self.repo.refs[b"refs/heads/feature-1"] = c3.id # Same as master
  6389. self.repo.refs[b"refs/heads/feature-2"] = c2.id # Ancestor
  6390. # c1 is in all branches
  6391. branches = list(porcelain.branches_containing(self.repo, c1.id.decode()))
  6392. expected = [b"master", b"feature-1", b"feature-2"]
  6393. expected.sort()
  6394. branches.sort()
  6395. self.assertEqual(expected, branches)
  6396. def test_commit_in_no_branches(self) -> None:
  6397. """Test commit not contained in any branch."""
  6398. # Create: c1 → c2 (master), c1 → c3 (feature), orphan c4
  6399. [_c1, c2, c3, c4] = build_commit_graph(
  6400. self.repo.object_store,
  6401. [
  6402. [1], # c1
  6403. [2, 1], # c2 → c1 (master)
  6404. [3, 1], # c3 → c1 (feature)
  6405. [4], # c4 (orphan commit)
  6406. ],
  6407. )
  6408. self.repo.refs[b"HEAD"] = c2.id
  6409. self.repo.refs[b"refs/heads/master"] = c2.id
  6410. self.repo.refs[b"refs/heads/feature"] = c3.id
  6411. # c4 is not in any branch
  6412. result = list(porcelain.branches_containing(self.repo, c4.id.decode()))
  6413. self.assertEqual([], result)
  6414. def test_commit_ref_by_branch_name(self) -> None:
  6415. """Test using branch name as commit reference."""
  6416. # Create: c1 → c2 (master), c1 → c3 (feature)
  6417. [_c1, c2, c3] = build_commit_graph(
  6418. self.repo.object_store,
  6419. [
  6420. [1], # c1
  6421. [2, 1], # c2 → c1 (master)
  6422. [3, 1], # c3 → c1 (feature)
  6423. ],
  6424. )
  6425. self.repo.refs[b"HEAD"] = c2.id
  6426. self.repo.refs[b"refs/heads/master"] = c2.id
  6427. self.repo.refs[b"refs/heads/feature"] = c3.id
  6428. # Use "master" as commit reference - should find master branch
  6429. result = list(porcelain.branches_containing(self.repo, "master"))
  6430. self.assertEqual([b"master"], result)
  6431. # Use "feature" as commit reference - should find feature branch
  6432. result = list(porcelain.branches_containing(self.repo, "feature"))
  6433. self.assertEqual([b"feature"], result)
  6434. def test_commit_ref_by_head(self) -> None:
  6435. """Test using HEAD as commit reference."""
  6436. # Create: c1 → c2 → c3 (HEAD/master)
  6437. [_c1, c2, c3] = build_commit_graph(
  6438. self.repo.object_store, [[1], [2, 1], [3, 2]]
  6439. )
  6440. self.repo.refs[b"HEAD"] = c3.id
  6441. self.repo.refs[b"refs/heads/master"] = c3.id
  6442. self.repo.refs[b"refs/heads/feature"] = c2.id # Ancestor
  6443. # Use "HEAD" as commit reference
  6444. result = list(porcelain.branches_containing(self.repo, "HEAD"))
  6445. self.assertEqual([b"master"], result)
  6446. def test_invalid_commit_ref(self) -> None:
  6447. """Test with invalid commit reference."""
  6448. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  6449. self.repo.refs[b"HEAD"] = c1.id
  6450. self.repo.refs[b"refs/heads/master"] = c1.id
  6451. # Test with non-existent commit
  6452. with self.assertRaises(KeyError) as cm:
  6453. list(porcelain.branches_containing(self.repo, "nonexistent"))
  6454. self.assertEqual(b"nonexistent", cm.exception.args[0])
  6455. # Test with invalid SHA
  6456. with self.assertRaises(KeyError) as cm:
  6457. list(porcelain.branches_containing(self.repo, "invalid-sha"))
  6458. self.assertEqual(b"invalid-sha", cm.exception.args[0])
  6459. def test_short_sha_reference(self) -> None:
  6460. """Test using short SHA as commit reference."""
  6461. # Create: c1 → c2 (master)
  6462. [c1, c2] = build_commit_graph(self.repo.object_store, [[1], [2, 1]])
  6463. self.repo.refs[b"HEAD"] = c2.id
  6464. self.repo.refs[b"refs/heads/master"] = c2.id
  6465. # Use short SHA (first 7 characters)
  6466. short_sha = c1.id.decode()[:7]
  6467. result = list(porcelain.branches_containing(self.repo, short_sha))
  6468. self.assertEqual([b"master"], result)
  6469. class FilterBranchesByPatternTests(PorcelainTestCase):
  6470. """Tests for filter_branches_by_pattern function."""
  6471. def test_empty_branches(self) -> None:
  6472. """Test with empty branches list."""
  6473. result = porcelain.filter_branches_by_pattern([], "feature-*")
  6474. self.assertEqual([], result)
  6475. def test_star_pattern(self) -> None:
  6476. """Test wildcard pattern matching."""
  6477. branches = [b"main", b"feature-1", b"feature-2", b"develop", b"hotfix-bug"]
  6478. result = porcelain.filter_branches_by_pattern(branches, "feature-*")
  6479. expected = [b"feature-1", b"feature-2"]
  6480. self.assertEqual(expected, result)
  6481. def test_question_mark_pattern(self) -> None:
  6482. """Test single character wildcard pattern."""
  6483. branches = [b"main", b"feature-1", b"feature-2", b"feature-a", b"develop"]
  6484. result = porcelain.filter_branches_by_pattern(branches, "feature-?")
  6485. expected = [b"feature-1", b"feature-2", b"feature-a"]
  6486. self.assertEqual(expected, result)
  6487. def test_specific_pattern(self) -> None:
  6488. """Test exact match pattern."""
  6489. branches = [b"main", b"feature-1", b"feature-2", b"develop"]
  6490. result = porcelain.filter_branches_by_pattern(branches, "feature-1")
  6491. expected = [b"feature-1"]
  6492. self.assertEqual(expected, result)
  6493. def test_no_matches(self) -> None:
  6494. """Test pattern that matches no branches."""
  6495. branches = [b"main", b"feature-1", b"develop"]
  6496. result = porcelain.filter_branches_by_pattern(branches, "release-*")
  6497. self.assertEqual([], result)
  6498. def test_case_sensitive_pattern(self) -> None:
  6499. """Test case-sensitive pattern matching."""
  6500. branches = [b"Main", b"main", b"MAIN", b"develop"]
  6501. result = porcelain.filter_branches_by_pattern(branches, "main")
  6502. expected = [b"main"]
  6503. self.assertEqual(expected, result)
  6504. def test_multiple_patterns_behavior(self) -> None:
  6505. """Test that pattern works with multiple wildcards."""
  6506. branches = [
  6507. b"feature-login",
  6508. b"feature-signup",
  6509. b"bugfix-login",
  6510. b"hotfix-signup",
  6511. ]
  6512. result = porcelain.filter_branches_by_pattern(branches, "feature-*")
  6513. expected = [b"feature-login", b"feature-signup"]
  6514. self.assertEqual(expected, result)
  6515. def test_mixed_encoding_branches(self) -> None:
  6516. """Test with branches that have special characters."""
  6517. branches = [b"feature-1", b"feature/test", b"feature@prod", b"develop"]
  6518. result = porcelain.filter_branches_by_pattern(branches, "feature/*")
  6519. expected = [b"feature/test"]
  6520. self.assertEqual(expected, result)
  6521. def test_pattern_with_square_brackets(self) -> None:
  6522. """Test pattern with character classes."""
  6523. branches = [b"feature-1", b"feature-2", b"feature-a", b"feature-b", b"develop"]
  6524. result = porcelain.filter_branches_by_pattern(branches, "feature-[12]")
  6525. expected = [b"feature-1", b"feature-2"]
  6526. self.assertEqual(expected, result)
  6527. class BranchCreateTests(PorcelainTestCase):
  6528. def test_branch_exists(self) -> None:
  6529. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  6530. self.repo[b"HEAD"] = c1.id
  6531. porcelain.branch_create(self.repo, b"foo")
  6532. self.assertRaises(porcelain.Error, porcelain.branch_create, self.repo, b"foo")
  6533. porcelain.branch_create(self.repo, b"foo", force=True)
  6534. def test_new_branch(self) -> None:
  6535. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  6536. self.repo[b"HEAD"] = c1.id
  6537. porcelain.branch_create(self.repo, b"foo")
  6538. self.assertEqual({b"master", b"foo"}, set(porcelain.branch_list(self.repo)))
  6539. def test_auto_setup_merge_true_from_remote_tracking(self) -> None:
  6540. """Test branch.autoSetupMerge=true sets up tracking from remote-tracking branch."""
  6541. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  6542. self.repo[b"HEAD"] = c1.id
  6543. # Create a remote-tracking branch
  6544. self.repo.refs[b"refs/remotes/origin/feature"] = c1.id
  6545. # Set branch.autoSetupMerge to true (default)
  6546. config = self.repo.get_config()
  6547. config.set((b"branch",), b"autoSetupMerge", b"true")
  6548. config.write_to_path()
  6549. # Create branch from remote-tracking branch
  6550. porcelain.branch_create(self.repo, "myfeature", "origin/feature")
  6551. # Verify tracking was set up
  6552. config = self.repo.get_config()
  6553. self.assertEqual(config.get((b"branch", b"myfeature"), b"remote"), b"origin")
  6554. self.assertEqual(
  6555. config.get((b"branch", b"myfeature"), b"merge"), b"refs/heads/feature"
  6556. )
  6557. def test_auto_setup_merge_false(self) -> None:
  6558. """Test branch.autoSetupMerge=false disables tracking setup."""
  6559. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  6560. self.repo[b"HEAD"] = c1.id
  6561. # Create a remote-tracking branch
  6562. self.repo.refs[b"refs/remotes/origin/feature"] = c1.id
  6563. # Set branch.autoSetupMerge to false
  6564. config = self.repo.get_config()
  6565. config.set((b"branch",), b"autoSetupMerge", b"false")
  6566. config.write_to_path()
  6567. # Create branch from remote-tracking branch
  6568. porcelain.branch_create(self.repo, "myfeature", "origin/feature")
  6569. # Verify tracking was NOT set up
  6570. config = self.repo.get_config()
  6571. self.assertRaises(KeyError, config.get, (b"branch", b"myfeature"), b"remote")
  6572. self.assertRaises(KeyError, config.get, (b"branch", b"myfeature"), b"merge")
  6573. def test_auto_setup_merge_always(self) -> None:
  6574. """Test branch.autoSetupMerge=always sets up tracking even from local branches."""
  6575. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  6576. self.repo[b"HEAD"] = c1.id
  6577. self.repo.refs[b"refs/heads/main"] = c1.id
  6578. # Set branch.autoSetupMerge to always
  6579. config = self.repo.get_config()
  6580. config.set((b"branch",), b"autoSetupMerge", b"always")
  6581. config.write_to_path()
  6582. # Create branch from local branch - normally wouldn't set up tracking
  6583. porcelain.branch_create(self.repo, "feature", "main")
  6584. # With always, tracking should NOT be set up from local branches
  6585. # (Git only sets up tracking from remote-tracking branches even with always)
  6586. config = self.repo.get_config()
  6587. self.assertRaises(KeyError, config.get, (b"branch", b"feature"), b"remote")
  6588. self.assertRaises(KeyError, config.get, (b"branch", b"feature"), b"merge")
  6589. def test_auto_setup_merge_always_from_remote(self) -> None:
  6590. """Test branch.autoSetupMerge=always still sets up tracking from remote branches."""
  6591. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  6592. self.repo[b"HEAD"] = c1.id
  6593. # Create a remote-tracking branch
  6594. self.repo.refs[b"refs/remotes/origin/feature"] = c1.id
  6595. # Set branch.autoSetupMerge to always
  6596. config = self.repo.get_config()
  6597. config.set((b"branch",), b"autoSetupMerge", b"always")
  6598. config.write_to_path()
  6599. # Create branch from remote-tracking branch
  6600. porcelain.branch_create(self.repo, "myfeature", "origin/feature")
  6601. # Verify tracking was set up
  6602. config = self.repo.get_config()
  6603. self.assertEqual(config.get((b"branch", b"myfeature"), b"remote"), b"origin")
  6604. self.assertEqual(
  6605. config.get((b"branch", b"myfeature"), b"merge"), b"refs/heads/feature"
  6606. )
  6607. def test_auto_setup_merge_default(self) -> None:
  6608. """Test default behavior (no config) is same as true."""
  6609. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  6610. self.repo[b"HEAD"] = c1.id
  6611. # Create a remote-tracking branch
  6612. self.repo.refs[b"refs/remotes/origin/feature"] = c1.id
  6613. # Don't set any config - should default to true
  6614. # Create branch from remote-tracking branch
  6615. porcelain.branch_create(self.repo, "myfeature", "origin/feature")
  6616. # Verify tracking was set up
  6617. config = self.repo.get_config()
  6618. self.assertEqual(config.get((b"branch", b"myfeature"), b"remote"), b"origin")
  6619. self.assertEqual(
  6620. config.get((b"branch", b"myfeature"), b"merge"), b"refs/heads/feature"
  6621. )
  6622. class BranchDeleteTests(PorcelainTestCase):
  6623. def test_simple(self) -> None:
  6624. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  6625. self.repo[b"HEAD"] = c1.id
  6626. porcelain.branch_create(self.repo, b"foo")
  6627. self.assertIn(b"foo", porcelain.branch_list(self.repo))
  6628. porcelain.branch_delete(self.repo, b"foo")
  6629. self.assertNotIn(b"foo", porcelain.branch_list(self.repo))
  6630. def test_simple_unicode(self) -> None:
  6631. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  6632. self.repo[b"HEAD"] = c1.id
  6633. porcelain.branch_create(self.repo, "foo")
  6634. self.assertIn(b"foo", porcelain.branch_list(self.repo))
  6635. porcelain.branch_delete(self.repo, "foo")
  6636. self.assertNotIn(b"foo", porcelain.branch_list(self.repo))
  6637. class FetchTests(PorcelainTestCase):
  6638. def test_simple(self) -> None:
  6639. outstream = BytesIO()
  6640. errstream = BytesIO()
  6641. # create a file for initial commit
  6642. handle, fullpath = tempfile.mkstemp(dir=self.repo.path)
  6643. os.close(handle)
  6644. porcelain.add(repo=self.repo.path, paths=fullpath)
  6645. porcelain.commit(
  6646. repo=self.repo.path,
  6647. message=b"test",
  6648. author=b"test <email>",
  6649. committer=b"test <email>",
  6650. )
  6651. # Setup target repo
  6652. target_path = tempfile.mkdtemp()
  6653. self.addCleanup(shutil.rmtree, target_path)
  6654. target_repo = porcelain.clone(
  6655. self.repo.path, target=target_path, errstream=errstream
  6656. )
  6657. # create a second file to be pushed
  6658. handle, fullpath = tempfile.mkstemp(dir=self.repo.path)
  6659. os.close(handle)
  6660. porcelain.add(repo=self.repo.path, paths=fullpath)
  6661. porcelain.commit(
  6662. repo=self.repo.path,
  6663. message=b"test2",
  6664. author=b"test2 <email>",
  6665. committer=b"test2 <email>",
  6666. )
  6667. self.assertNotIn(self.repo[b"HEAD"].id, target_repo)
  6668. target_repo.close()
  6669. # Fetch changes into the cloned repo
  6670. porcelain.fetch(target_path, "origin", outstream=outstream, errstream=errstream)
  6671. # Assert that fetch updated the local image of the remote
  6672. self.assert_correct_remote_refs(target_repo.get_refs(), self.repo.get_refs())
  6673. # Check the target repo for pushed changes
  6674. with Repo(target_path) as r:
  6675. self.assertIn(self.repo[b"HEAD"].id, r)
  6676. def test_with_remote_name(self) -> None:
  6677. remote_name = "origin"
  6678. outstream = BytesIO()
  6679. errstream = BytesIO()
  6680. # create a file for initial commit
  6681. handle, fullpath = tempfile.mkstemp(dir=self.repo.path)
  6682. os.close(handle)
  6683. porcelain.add(repo=self.repo.path, paths=fullpath)
  6684. porcelain.commit(
  6685. repo=self.repo.path,
  6686. message=b"test",
  6687. author=b"test <email>",
  6688. committer=b"test <email>",
  6689. )
  6690. # Setup target repo
  6691. target_path = tempfile.mkdtemp()
  6692. self.addCleanup(shutil.rmtree, target_path)
  6693. target_repo = porcelain.clone(
  6694. self.repo.path, target=target_path, errstream=errstream
  6695. )
  6696. # Capture current refs
  6697. target_refs = target_repo.get_refs()
  6698. # create a second file to be pushed
  6699. handle, fullpath = tempfile.mkstemp(dir=self.repo.path)
  6700. os.close(handle)
  6701. porcelain.add(repo=self.repo.path, paths=fullpath)
  6702. porcelain.commit(
  6703. repo=self.repo.path,
  6704. message=b"test2",
  6705. author=b"test2 <email>",
  6706. committer=b"test2 <email>",
  6707. )
  6708. self.assertNotIn(self.repo[b"HEAD"].id, target_repo)
  6709. target_config = target_repo.get_config()
  6710. target_config.set(
  6711. (b"remote", remote_name.encode()), b"url", self.repo.path.encode()
  6712. )
  6713. target_repo.close()
  6714. # Fetch changes into the cloned repo
  6715. porcelain.fetch(
  6716. target_path, remote_name, outstream=outstream, errstream=errstream
  6717. )
  6718. # Assert that fetch updated the local image of the remote
  6719. self.assert_correct_remote_refs(target_repo.get_refs(), self.repo.get_refs())
  6720. # Check the target repo for pushed changes, as well as updates
  6721. # for the refs
  6722. with Repo(target_path) as r:
  6723. self.assertIn(self.repo[b"HEAD"].id, r)
  6724. self.assertNotEqual(self.repo.get_refs(), target_refs)
  6725. def assert_correct_remote_refs(
  6726. self, local_refs, remote_refs, remote_name=b"origin"
  6727. ) -> None:
  6728. """Assert that known remote refs corresponds to actual remote refs."""
  6729. local_ref_prefix = b"refs/heads"
  6730. remote_ref_prefix = b"refs/remotes/" + remote_name
  6731. locally_known_remote_refs = {
  6732. k[len(remote_ref_prefix) + 1 :]: v
  6733. for k, v in local_refs.items()
  6734. if k.startswith(remote_ref_prefix)
  6735. }
  6736. normalized_remote_refs = {
  6737. k[len(local_ref_prefix) + 1 :]: v
  6738. for k, v in remote_refs.items()
  6739. if k.startswith(local_ref_prefix)
  6740. }
  6741. if b"HEAD" in locally_known_remote_refs and b"HEAD" in remote_refs:
  6742. normalized_remote_refs[b"HEAD"] = remote_refs[b"HEAD"]
  6743. self.assertEqual(locally_known_remote_refs, normalized_remote_refs)
  6744. class RepackTests(PorcelainTestCase):
  6745. def test_empty(self) -> None:
  6746. porcelain.repack(self.repo)
  6747. def test_simple(self) -> None:
  6748. handle, fullpath = tempfile.mkstemp(dir=self.repo.path)
  6749. os.close(handle)
  6750. porcelain.add(repo=self.repo.path, paths=fullpath)
  6751. porcelain.repack(self.repo)
  6752. def test_write_bitmaps(self) -> None:
  6753. """Test that write_bitmaps generates bitmap files."""
  6754. # Create some content
  6755. handle, fullpath = tempfile.mkstemp(dir=self.repo.path)
  6756. os.close(handle)
  6757. with open(fullpath, "w") as f:
  6758. f.write("test content")
  6759. porcelain.add(repo=self.repo.path, paths=fullpath)
  6760. porcelain.commit(
  6761. repo=self.repo.path,
  6762. message=b"test commit",
  6763. author=b"Test Author <test@example.com>",
  6764. committer=b"Test Committer <test@example.com>",
  6765. )
  6766. # Repack with bitmaps
  6767. porcelain.repack(self.repo, write_bitmaps=True)
  6768. # Check that bitmap files were created
  6769. pack_dir = os.path.join(self.repo.path, ".git", "objects", "pack")
  6770. bitmap_files = [f for f in os.listdir(pack_dir) if f.endswith(".bitmap")]
  6771. self.assertGreater(len(bitmap_files), 0)
  6772. class LsTreeTests(PorcelainTestCase):
  6773. def test_empty(self) -> None:
  6774. porcelain.commit(
  6775. repo=self.repo.path,
  6776. message=b"test status",
  6777. author=b"author <email>",
  6778. committer=b"committer <email>",
  6779. )
  6780. f = StringIO()
  6781. porcelain.ls_tree(self.repo, b"HEAD", outstream=f)
  6782. self.assertEqual(f.getvalue(), "")
  6783. def test_simple(self) -> None:
  6784. # Commit a dummy file then modify it
  6785. fullpath = os.path.join(self.repo.path, "foo")
  6786. with open(fullpath, "w") as f:
  6787. f.write("origstuff")
  6788. porcelain.add(repo=self.repo.path, paths=[fullpath])
  6789. porcelain.commit(
  6790. repo=self.repo.path,
  6791. message=b"test status",
  6792. author=b"author <email>",
  6793. committer=b"committer <email>",
  6794. )
  6795. output = StringIO()
  6796. porcelain.ls_tree(self.repo, b"HEAD", outstream=output)
  6797. self.assertEqual(
  6798. output.getvalue(),
  6799. "100644 blob 8b82634d7eae019850bb883f06abf428c58bc9aa\tfoo\n",
  6800. )
  6801. def test_recursive(self) -> None:
  6802. # Create a directory then write a dummy file in it
  6803. dirpath = os.path.join(self.repo.path, "adir")
  6804. filepath = os.path.join(dirpath, "afile")
  6805. os.mkdir(dirpath)
  6806. with open(filepath, "w") as f:
  6807. f.write("origstuff")
  6808. porcelain.add(repo=self.repo.path, paths=[filepath])
  6809. porcelain.commit(
  6810. repo=self.repo.path,
  6811. message=b"test status",
  6812. author=b"author <email>",
  6813. committer=b"committer <email>",
  6814. )
  6815. output = StringIO()
  6816. porcelain.ls_tree(self.repo, b"HEAD", outstream=output)
  6817. self.assertEqual(
  6818. output.getvalue(),
  6819. "40000 tree b145cc69a5e17693e24d8a7be0016ed8075de66d\tadir\n",
  6820. )
  6821. output2 = StringIO()
  6822. porcelain.ls_tree(self.repo, b"HEAD", outstream=output2, recursive=True)
  6823. self.assertEqual(
  6824. output2.getvalue(),
  6825. "40000 tree b145cc69a5e17693e24d8a7be0016ed8075de66d\tadir\n"
  6826. "100644 blob 8b82634d7eae019850bb883f06abf428c58bc9aa\tadir"
  6827. "/afile\n",
  6828. )
  6829. class LsRemoteTests(PorcelainTestCase):
  6830. def test_empty(self) -> None:
  6831. result = porcelain.ls_remote(self.repo.path)
  6832. self.assertEqual({}, result.refs)
  6833. self.assertEqual({}, result.symrefs)
  6834. def test_some(self) -> None:
  6835. cid = porcelain.commit(
  6836. repo=self.repo.path,
  6837. message=b"test status",
  6838. author=b"author <email>",
  6839. committer=b"committer <email>",
  6840. )
  6841. result = porcelain.ls_remote(self.repo.path)
  6842. self.assertEqual(
  6843. {b"refs/heads/master": cid, b"HEAD": cid},
  6844. result.refs,
  6845. )
  6846. # HEAD should be a symref to refs/heads/master
  6847. self.assertEqual({b"HEAD": b"refs/heads/master"}, result.symrefs)
  6848. class LsFilesTests(PorcelainTestCase):
  6849. def test_empty(self) -> None:
  6850. self.assertEqual([], list(porcelain.ls_files(self.repo)))
  6851. def test_simple(self) -> None:
  6852. # Commit a dummy file then modify it
  6853. fullpath = os.path.join(self.repo.path, "foo")
  6854. with open(fullpath, "w") as f:
  6855. f.write("origstuff")
  6856. porcelain.add(repo=self.repo.path, paths=[fullpath])
  6857. self.assertEqual([b"foo"], list(porcelain.ls_files(self.repo)))
  6858. class RemoteAddTests(PorcelainTestCase):
  6859. def test_new(self) -> None:
  6860. porcelain.remote_add(self.repo, "jelmer", "git://jelmer.uk/code/dulwich")
  6861. c = self.repo.get_config()
  6862. self.assertEqual(
  6863. c.get((b"remote", b"jelmer"), b"url"),
  6864. b"git://jelmer.uk/code/dulwich",
  6865. )
  6866. def test_exists(self) -> None:
  6867. porcelain.remote_add(self.repo, "jelmer", "git://jelmer.uk/code/dulwich")
  6868. self.assertRaises(
  6869. porcelain.RemoteExists,
  6870. porcelain.remote_add,
  6871. self.repo,
  6872. "jelmer",
  6873. "git://jelmer.uk/code/dulwich",
  6874. )
  6875. class RemoteRemoveTests(PorcelainTestCase):
  6876. def test_remove(self) -> None:
  6877. porcelain.remote_add(self.repo, "jelmer", "git://jelmer.uk/code/dulwich")
  6878. c = self.repo.get_config()
  6879. self.assertEqual(
  6880. c.get((b"remote", b"jelmer"), b"url"),
  6881. b"git://jelmer.uk/code/dulwich",
  6882. )
  6883. porcelain.remote_remove(self.repo, "jelmer")
  6884. self.assertRaises(KeyError, porcelain.remote_remove, self.repo, "jelmer")
  6885. c = self.repo.get_config()
  6886. self.assertRaises(KeyError, c.get, (b"remote", b"jelmer"), b"url")
  6887. class CheckIgnoreTests(PorcelainTestCase):
  6888. def test_check_ignored(self) -> None:
  6889. with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
  6890. f.write("foo")
  6891. foo_path = os.path.join(self.repo.path, "foo")
  6892. with open(foo_path, "w") as f:
  6893. f.write("BAR")
  6894. bar_path = os.path.join(self.repo.path, "bar")
  6895. with open(bar_path, "w") as f:
  6896. f.write("BAR")
  6897. self.assertEqual(["foo"], list(porcelain.check_ignore(self.repo, [foo_path])))
  6898. self.assertEqual([], list(porcelain.check_ignore(self.repo, [bar_path])))
  6899. def test_check_added_abs(self) -> None:
  6900. path = os.path.join(self.repo.path, "foo")
  6901. with open(path, "w") as f:
  6902. f.write("BAR")
  6903. self.repo.get_worktree().stage(["foo"])
  6904. with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
  6905. f.write("foo\n")
  6906. self.assertEqual([], list(porcelain.check_ignore(self.repo, [path])))
  6907. self.assertEqual(
  6908. ["foo"],
  6909. list(porcelain.check_ignore(self.repo, [path], no_index=True)),
  6910. )
  6911. def test_check_added_rel(self) -> None:
  6912. with open(os.path.join(self.repo.path, "foo"), "w") as f:
  6913. f.write("BAR")
  6914. self.repo.get_worktree().stage(["foo"])
  6915. with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
  6916. f.write("foo\n")
  6917. cwd = os.getcwd()
  6918. self.addCleanup(os.chdir, cwd)
  6919. os.mkdir(os.path.join(self.repo.path, "bar"))
  6920. os.chdir(os.path.join(self.repo.path, "bar"))
  6921. self.assertEqual(list(porcelain.check_ignore(self.repo, ["../foo"])), [])
  6922. self.assertEqual(
  6923. ["../foo"],
  6924. list(porcelain.check_ignore(self.repo, ["../foo"], no_index=True)),
  6925. )
  6926. class UpdateHeadTests(PorcelainTestCase):
  6927. def test_set_to_branch(self) -> None:
  6928. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  6929. self.repo.refs[b"refs/heads/blah"] = c1.id
  6930. porcelain.update_head(self.repo, "blah")
  6931. self.assertEqual(c1.id, self.repo.head())
  6932. self.assertEqual(b"ref: refs/heads/blah", self.repo.refs.read_ref(b"HEAD"))
  6933. def test_set_to_branch_detached(self) -> None:
  6934. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  6935. self.repo.refs[b"refs/heads/blah"] = c1.id
  6936. porcelain.update_head(self.repo, "blah", detached=True)
  6937. self.assertEqual(c1.id, self.repo.head())
  6938. self.assertEqual(c1.id, self.repo.refs.read_ref(b"HEAD"))
  6939. def test_set_to_commit_detached(self) -> None:
  6940. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  6941. self.repo.refs[b"refs/heads/blah"] = c1.id
  6942. porcelain.update_head(self.repo, c1.id, detached=True)
  6943. self.assertEqual(c1.id, self.repo.head())
  6944. self.assertEqual(c1.id, self.repo.refs.read_ref(b"HEAD"))
  6945. def test_set_new_branch(self) -> None:
  6946. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  6947. self.repo.refs[b"refs/heads/blah"] = c1.id
  6948. porcelain.update_head(self.repo, "blah", new_branch="bar")
  6949. self.assertEqual(c1.id, self.repo.head())
  6950. self.assertEqual(b"ref: refs/heads/bar", self.repo.refs.read_ref(b"HEAD"))
  6951. class MailmapTests(PorcelainTestCase):
  6952. def test_no_mailmap(self) -> None:
  6953. self.assertEqual(
  6954. b"Jelmer Vernooij <jelmer@samba.org>",
  6955. porcelain.check_mailmap(self.repo, b"Jelmer Vernooij <jelmer@samba.org>"),
  6956. )
  6957. def test_mailmap_lookup(self) -> None:
  6958. with open(os.path.join(self.repo.path, ".mailmap"), "wb") as f:
  6959. f.write(
  6960. b"""\
  6961. Jelmer Vernooij <jelmer@debian.org>
  6962. """
  6963. )
  6964. self.assertEqual(
  6965. b"Jelmer Vernooij <jelmer@debian.org>",
  6966. porcelain.check_mailmap(self.repo, b"Jelmer Vernooij <jelmer@samba.org>"),
  6967. )
  6968. class FsckTests(PorcelainTestCase):
  6969. def test_none(self) -> None:
  6970. self.assertEqual([], list(porcelain.fsck(self.repo)))
  6971. def test_git_dir(self) -> None:
  6972. obj = Tree()
  6973. a = Blob()
  6974. a.data = b"foo"
  6975. obj.add(b".git", 0o100644, a.id)
  6976. self.repo.object_store.add_objects([(a, None), (obj, None)])
  6977. self.assertEqual(
  6978. [(obj.id, "invalid name .git")],
  6979. [(sha, str(e)) for (sha, e) in porcelain.fsck(self.repo)],
  6980. )
  6981. class DescribeTests(PorcelainTestCase):
  6982. def test_no_commits(self) -> None:
  6983. self.assertRaises(KeyError, porcelain.describe, self.repo.path)
  6984. def test_single_commit(self) -> None:
  6985. fullpath = os.path.join(self.repo.path, "foo")
  6986. with open(fullpath, "w") as f:
  6987. f.write("BAR")
  6988. porcelain.add(repo=self.repo.path, paths=[fullpath])
  6989. sha = porcelain.commit(
  6990. self.repo.path,
  6991. message=b"Some message",
  6992. author=b"Joe <joe@example.com>",
  6993. committer=b"Bob <bob@example.com>",
  6994. )
  6995. self.assertEqual(
  6996. "g{}".format(sha[:7].decode("ascii")),
  6997. porcelain.describe(self.repo.path),
  6998. )
  6999. def test_tag(self) -> None:
  7000. fullpath = os.path.join(self.repo.path, "foo")
  7001. with open(fullpath, "w") as f:
  7002. f.write("BAR")
  7003. porcelain.add(repo=self.repo.path, paths=[fullpath])
  7004. porcelain.commit(
  7005. self.repo.path,
  7006. message=b"Some message",
  7007. author=b"Joe <joe@example.com>",
  7008. committer=b"Bob <bob@example.com>",
  7009. )
  7010. porcelain.tag_create(
  7011. self.repo.path,
  7012. b"tryme",
  7013. b"foo <foo@bar.com>",
  7014. b"bar",
  7015. annotated=True,
  7016. )
  7017. self.assertEqual("tryme", porcelain.describe(self.repo.path))
  7018. def test_tag_and_commit(self) -> None:
  7019. fullpath = os.path.join(self.repo.path, "foo")
  7020. with open(fullpath, "w") as f:
  7021. f.write("BAR")
  7022. porcelain.add(repo=self.repo.path, paths=[fullpath])
  7023. porcelain.commit(
  7024. self.repo.path,
  7025. message=b"Some message",
  7026. author=b"Joe <joe@example.com>",
  7027. committer=b"Bob <bob@example.com>",
  7028. )
  7029. porcelain.tag_create(
  7030. self.repo.path,
  7031. b"tryme",
  7032. b"foo <foo@bar.com>",
  7033. b"bar",
  7034. annotated=True,
  7035. )
  7036. with open(fullpath, "w") as f:
  7037. f.write("BAR2")
  7038. porcelain.add(repo=self.repo.path, paths=[fullpath])
  7039. sha = porcelain.commit(
  7040. self.repo.path,
  7041. message=b"Some message",
  7042. author=b"Joe <joe@example.com>",
  7043. committer=b"Bob <bob@example.com>",
  7044. )
  7045. self.assertEqual(
  7046. "tryme-1-g{}".format(sha[:7].decode("ascii")),
  7047. porcelain.describe(self.repo.path),
  7048. )
  7049. def test_tag_and_commit_full(self) -> None:
  7050. fullpath = os.path.join(self.repo.path, "foo")
  7051. with open(fullpath, "w") as f:
  7052. f.write("BAR")
  7053. porcelain.add(repo=self.repo.path, paths=[fullpath])
  7054. porcelain.commit(
  7055. self.repo.path,
  7056. message=b"Some message",
  7057. author=b"Joe <joe@example.com>",
  7058. committer=b"Bob <bob@example.com>",
  7059. )
  7060. porcelain.tag_create(
  7061. self.repo.path,
  7062. b"tryme",
  7063. b"foo <foo@bar.com>",
  7064. b"bar",
  7065. annotated=True,
  7066. )
  7067. with open(fullpath, "w") as f:
  7068. f.write("BAR2")
  7069. porcelain.add(repo=self.repo.path, paths=[fullpath])
  7070. sha = porcelain.commit(
  7071. self.repo.path,
  7072. message=b"Some message",
  7073. author=b"Joe <joe@example.com>",
  7074. committer=b"Bob <bob@example.com>",
  7075. )
  7076. self.assertEqual(
  7077. "tryme-1-g{}".format(sha.decode("ascii")),
  7078. porcelain.describe(self.repo.path, abbrev=40),
  7079. )
  7080. def test_untagged_commit_abbreviation(self) -> None:
  7081. _, _, c3 = build_commit_graph(self.repo.object_store, [[1], [2, 1], [3, 1, 2]])
  7082. self.repo.refs[b"HEAD"] = c3.id
  7083. brief_description, complete_description = (
  7084. porcelain.describe(self.repo),
  7085. porcelain.describe(self.repo, abbrev=40),
  7086. )
  7087. self.assertTrue(complete_description.startswith(brief_description))
  7088. self.assertEqual(
  7089. "g{}".format(c3.id.decode("ascii")),
  7090. complete_description,
  7091. )
  7092. def test_hash_length_dynamic(self) -> None:
  7093. """Test that hash length adjusts based on uniqueness."""
  7094. fullpath = os.path.join(self.repo.path, "foo")
  7095. with open(fullpath, "w") as f:
  7096. f.write("content")
  7097. porcelain.add(repo=self.repo.path, paths=[fullpath])
  7098. sha = porcelain.commit(
  7099. self.repo.path,
  7100. message=b"commit",
  7101. author=b"Joe <joe@example.com>",
  7102. committer=b"Bob <bob@example.com>",
  7103. )
  7104. # When abbrev is None, it should use find_unique_abbrev
  7105. result = porcelain.describe(self.repo.path)
  7106. # Should start with 'g' and have at least 7 characters
  7107. self.assertTrue(result.startswith("g"))
  7108. self.assertGreaterEqual(len(result[1:]), 7)
  7109. # Should be a prefix of the full SHA
  7110. self.assertTrue(sha.decode("ascii").startswith(result[1:]))
  7111. class PathToTreeTests(PorcelainTestCase):
  7112. def setUp(self) -> None:
  7113. super().setUp()
  7114. self.fp = os.path.join(self.test_dir, "bar")
  7115. with open(self.fp, "w") as f:
  7116. f.write("something")
  7117. oldcwd = os.getcwd()
  7118. self.addCleanup(os.chdir, oldcwd)
  7119. os.chdir(self.test_dir)
  7120. def test_path_to_tree_path_base(self) -> None:
  7121. self.assertEqual(b"bar", porcelain.path_to_tree_path(self.test_dir, self.fp))
  7122. self.assertEqual(b"bar", porcelain.path_to_tree_path(".", "./bar"))
  7123. self.assertEqual(b"bar", porcelain.path_to_tree_path(".", "bar"))
  7124. cwd = os.getcwd()
  7125. self.assertEqual(
  7126. b"bar", porcelain.path_to_tree_path(".", os.path.join(cwd, "bar"))
  7127. )
  7128. self.assertEqual(b"bar", porcelain.path_to_tree_path(cwd, "bar"))
  7129. def test_path_to_tree_path_syntax(self) -> None:
  7130. self.assertEqual(b"bar", porcelain.path_to_tree_path(".", "./bar"))
  7131. def test_path_to_tree_path_error(self) -> None:
  7132. with self.assertRaises(ValueError):
  7133. with tempfile.TemporaryDirectory() as od:
  7134. porcelain.path_to_tree_path(od, self.fp)
  7135. def test_path_to_tree_path_rel(self) -> None:
  7136. cwd = os.getcwd()
  7137. self.addCleanup(os.chdir, cwd)
  7138. os.mkdir(os.path.join(self.repo.path, "foo"))
  7139. os.mkdir(os.path.join(self.repo.path, "foo/bar"))
  7140. os.chdir(os.path.join(self.repo.path, "foo/bar"))
  7141. with open("baz", "w") as f:
  7142. f.write("contents")
  7143. self.assertEqual(b"bar/baz", porcelain.path_to_tree_path("..", "baz"))
  7144. self.assertEqual(
  7145. b"bar/baz",
  7146. porcelain.path_to_tree_path(
  7147. os.path.join(os.getcwd(), ".."),
  7148. os.path.join(os.getcwd(), "baz"),
  7149. ),
  7150. )
  7151. self.assertEqual(
  7152. b"bar/baz",
  7153. porcelain.path_to_tree_path("..", os.path.join(os.getcwd(), "baz")),
  7154. )
  7155. self.assertEqual(
  7156. b"bar/baz",
  7157. porcelain.path_to_tree_path(os.path.join(os.getcwd(), ".."), "baz"),
  7158. )
  7159. class GetObjectByPathTests(PorcelainTestCase):
  7160. def test_simple(self) -> None:
  7161. fullpath = os.path.join(self.repo.path, "foo")
  7162. with open(fullpath, "w") as f:
  7163. f.write("BAR")
  7164. porcelain.add(repo=self.repo.path, paths=[fullpath])
  7165. porcelain.commit(
  7166. self.repo.path,
  7167. message=b"Some message",
  7168. author=b"Joe <joe@example.com>",
  7169. committer=b"Bob <bob@example.com>",
  7170. )
  7171. self.assertEqual(b"BAR", porcelain.get_object_by_path(self.repo, "foo").data)
  7172. self.assertEqual(b"BAR", porcelain.get_object_by_path(self.repo, b"foo").data)
  7173. def test_encoding(self) -> None:
  7174. fullpath = os.path.join(self.repo.path, "foo")
  7175. with open(fullpath, "w") as f:
  7176. f.write("BAR")
  7177. porcelain.add(repo=self.repo.path, paths=[fullpath])
  7178. porcelain.commit(
  7179. self.repo.path,
  7180. message=b"Some message",
  7181. author=b"Joe <joe@example.com>",
  7182. committer=b"Bob <bob@example.com>",
  7183. encoding=b"utf-8",
  7184. )
  7185. self.assertEqual(b"BAR", porcelain.get_object_by_path(self.repo, "foo").data)
  7186. self.assertEqual(b"BAR", porcelain.get_object_by_path(self.repo, b"foo").data)
  7187. def test_missing(self) -> None:
  7188. self.assertRaises(KeyError, porcelain.get_object_by_path, self.repo, "foo")
  7189. class WriteTreeTests(PorcelainTestCase):
  7190. def test_simple(self) -> None:
  7191. fullpath = os.path.join(self.repo.path, "foo")
  7192. with open(fullpath, "w") as f:
  7193. f.write("BAR")
  7194. porcelain.add(repo=self.repo.path, paths=[fullpath])
  7195. self.assertEqual(
  7196. b"d2092c8a9f311f0311083bf8d177f2ca0ab5b241",
  7197. porcelain.write_tree(self.repo),
  7198. )
  7199. class ActiveBranchTests(PorcelainTestCase):
  7200. def test_simple(self) -> None:
  7201. self.assertEqual(b"master", porcelain.active_branch(self.repo))
  7202. class BranchTrackingTests(PorcelainTestCase):
  7203. def test_get_branch_merge(self) -> None:
  7204. # Set up branch tracking configuration
  7205. config = self.repo.get_config()
  7206. config.set((b"branch", b"master"), b"remote", b"origin")
  7207. config.set((b"branch", b"master"), b"merge", b"refs/heads/main")
  7208. config.write_to_path()
  7209. # Test getting merge ref for current branch
  7210. merge_ref = porcelain.get_branch_merge(self.repo)
  7211. self.assertEqual(b"refs/heads/main", merge_ref)
  7212. # Test getting merge ref for specific branch
  7213. merge_ref = porcelain.get_branch_merge(self.repo, b"master")
  7214. self.assertEqual(b"refs/heads/main", merge_ref)
  7215. # Test branch without merge config
  7216. with self.assertRaises(KeyError):
  7217. porcelain.get_branch_merge(self.repo, b"nonexistent")
  7218. def test_set_branch_tracking(self) -> None:
  7219. # Create a new branch
  7220. _sha, _ = _commit_file_with_content(self.repo, "foo", "content\n")
  7221. porcelain.branch_create(self.repo, "feature")
  7222. # Set up tracking
  7223. porcelain.set_branch_tracking(
  7224. self.repo, b"feature", b"upstream", b"refs/heads/main"
  7225. )
  7226. # Verify configuration was written
  7227. config = self.repo.get_config()
  7228. self.assertEqual(b"upstream", config.get((b"branch", b"feature"), b"remote"))
  7229. self.assertEqual(
  7230. b"refs/heads/main", config.get((b"branch", b"feature"), b"merge")
  7231. )
  7232. class FindUniqueAbbrevTests(PorcelainTestCase):
  7233. def test_simple(self) -> None:
  7234. c1, _c2, c3 = build_commit_graph(
  7235. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  7236. )
  7237. self.repo.refs[b"HEAD"] = c3.id
  7238. self.assertEqual(
  7239. c1.id.decode("ascii")[:7],
  7240. porcelain.find_unique_abbrev(self.repo.object_store, c1.id),
  7241. )
  7242. class PackRefsTests(PorcelainTestCase):
  7243. def test_all(self) -> None:
  7244. c1, c2, c3 = build_commit_graph(
  7245. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  7246. )
  7247. self.repo.refs[b"HEAD"] = c3.id
  7248. self.repo.refs[b"refs/heads/master"] = c2.id
  7249. self.repo.refs[b"refs/tags/foo"] = c1.id
  7250. porcelain.pack_refs(self.repo, all=True)
  7251. self.assertEqual(
  7252. self.repo.refs.get_packed_refs(),
  7253. {
  7254. b"refs/heads/master": c2.id,
  7255. b"refs/tags/foo": c1.id,
  7256. },
  7257. )
  7258. def test_not_all(self) -> None:
  7259. c1, c2, c3 = build_commit_graph(
  7260. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  7261. )
  7262. self.repo.refs[b"HEAD"] = c3.id
  7263. self.repo.refs[b"refs/heads/master"] = c2.id
  7264. self.repo.refs[b"refs/tags/foo"] = c1.id
  7265. porcelain.pack_refs(self.repo)
  7266. self.assertEqual(
  7267. self.repo.refs.get_packed_refs(),
  7268. {
  7269. b"refs/tags/foo": c1.id,
  7270. },
  7271. )
  7272. class ServerTests(PorcelainTestCase):
  7273. @contextlib.contextmanager
  7274. def _serving(self):
  7275. with make_server("localhost", 0, self.app) as server:
  7276. thread = threading.Thread(target=server.serve_forever, daemon=True)
  7277. thread.start()
  7278. try:
  7279. yield f"http://localhost:{server.server_port}"
  7280. finally:
  7281. server.shutdown()
  7282. thread.join(10)
  7283. def setUp(self) -> None:
  7284. super().setUp()
  7285. self.served_repo_path = os.path.join(self.test_dir, "served_repo.git")
  7286. self.served_repo = Repo.init_bare(self.served_repo_path, mkdir=True)
  7287. self.addCleanup(self.served_repo.close)
  7288. backend = DictBackend({"/": self.served_repo})
  7289. self.app = make_wsgi_chain(backend)
  7290. def test_pull(self) -> None:
  7291. (c1,) = build_commit_graph(self.served_repo.object_store, [[1]])
  7292. self.served_repo.refs[b"refs/heads/master"] = c1.id
  7293. with self._serving() as url:
  7294. porcelain.pull(self.repo, url, "master")
  7295. def test_push(self) -> None:
  7296. (c1,) = build_commit_graph(self.repo.object_store, [[1]])
  7297. self.repo.refs[b"refs/heads/master"] = c1.id
  7298. with self._serving() as url:
  7299. porcelain.push(self.repo, url, "master")
  7300. class ForEachTests(PorcelainTestCase):
  7301. def setUp(self) -> None:
  7302. super().setUp()
  7303. c1, c2, c3, c4 = build_commit_graph(
  7304. self.repo.object_store, [[1], [2, 1], [3, 1, 2], [4]]
  7305. )
  7306. porcelain.tag_create(
  7307. self.repo.path,
  7308. b"v0.1",
  7309. objectish=c1.id,
  7310. annotated=True,
  7311. message=b"0.1",
  7312. )
  7313. porcelain.tag_create(
  7314. self.repo.path,
  7315. b"v1.0",
  7316. objectish=c2.id,
  7317. annotated=True,
  7318. message=b"1.0",
  7319. )
  7320. porcelain.tag_create(self.repo.path, b"simple-tag", objectish=c3.id)
  7321. porcelain.tag_create(
  7322. self.repo.path,
  7323. b"v1.1",
  7324. objectish=c4.id,
  7325. annotated=True,
  7326. message=b"1.1",
  7327. )
  7328. porcelain.branch_create(
  7329. self.repo.path, b"feat", objectish=c2.id.decode("ascii")
  7330. )
  7331. self.repo.refs[b"HEAD"] = c4.id
  7332. def test_for_each_ref(self) -> None:
  7333. refs = porcelain.for_each_ref(self.repo)
  7334. self.assertEqual(
  7335. [(object_type, tag) for _, object_type, tag in refs],
  7336. [
  7337. (b"commit", b"refs/heads/feat"),
  7338. (b"commit", b"refs/heads/master"),
  7339. (b"commit", b"refs/tags/simple-tag"),
  7340. (b"tag", b"refs/tags/v0.1"),
  7341. (b"tag", b"refs/tags/v1.0"),
  7342. (b"tag", b"refs/tags/v1.1"),
  7343. ],
  7344. )
  7345. def test_for_each_ref_pattern(self) -> None:
  7346. versions = porcelain.for_each_ref(self.repo, pattern="refs/tags/v*")
  7347. self.assertEqual(
  7348. [(object_type, tag) for _, object_type, tag in versions],
  7349. [
  7350. (b"tag", b"refs/tags/v0.1"),
  7351. (b"tag", b"refs/tags/v1.0"),
  7352. (b"tag", b"refs/tags/v1.1"),
  7353. ],
  7354. )
  7355. versions = porcelain.for_each_ref(self.repo, pattern="refs/tags/v1.?")
  7356. self.assertEqual(
  7357. [(object_type, tag) for _, object_type, tag in versions],
  7358. [
  7359. (b"tag", b"refs/tags/v1.0"),
  7360. (b"tag", b"refs/tags/v1.1"),
  7361. ],
  7362. )
  7363. class SparseCheckoutTests(PorcelainTestCase):
  7364. """Integration tests for Dulwich's sparse checkout feature."""
  7365. # NOTE: We do NOT override `setUp()` here because the parent class
  7366. # (PorcelainTestCase) already:
  7367. # 1) Creates self.test_dir = a unique temp dir
  7368. # 2) Creates a subdir named "repo"
  7369. # 3) Calls Repo.init() on that path
  7370. # Re-initializing again caused FileExistsError.
  7371. #
  7372. # Utility/Placeholder
  7373. #
  7374. def sparse_checkout(self, repo, patterns, force=False):
  7375. """Wrapper around the actual porcelain.sparse_checkout function
  7376. to handle any test-specific setup or logging.
  7377. """
  7378. return porcelain.sparse_checkout(repo, patterns, force=force)
  7379. def _write_file(self, rel_path, content):
  7380. """Helper to write a file in the repository working tree."""
  7381. abs_path = os.path.join(self.repo_path, rel_path)
  7382. os.makedirs(os.path.dirname(abs_path), exist_ok=True)
  7383. with open(abs_path, "w") as f:
  7384. f.write(content)
  7385. return abs_path
  7386. def _commit_file(self, rel_path, content):
  7387. """Helper to write, add, and commit a file."""
  7388. abs_path = self._write_file(rel_path, content)
  7389. add(self.repo_path, paths=[abs_path])
  7390. commit(self.repo_path, message=b"Added " + rel_path.encode("utf-8"))
  7391. def _list_wtree_files(self):
  7392. """Return a set of all files (not dirs) present
  7393. in the working tree, ignoring .git/.
  7394. """
  7395. found_files = set()
  7396. for root, dirs, files in os.walk(self.repo_path):
  7397. # Skip .git in the walk
  7398. if ".git" in dirs:
  7399. dirs.remove(".git")
  7400. for filename in files:
  7401. file_rel = os.path.relpath(os.path.join(root, filename), self.repo_path)
  7402. found_files.add(file_rel)
  7403. return found_files
  7404. def test_only_included_paths_appear_in_wtree(self):
  7405. """Only included paths remain in the working tree, excluded paths are removed.
  7406. Commits two files, "keep_me.txt" and "exclude_me.txt". Then applies a
  7407. sparse-checkout pattern containing only "keep_me.txt". Ensures that
  7408. the latter remains in the working tree, while "exclude_me.txt" is
  7409. removed. This verifies correct application of sparse-checkout patterns
  7410. to remove files not listed.
  7411. """
  7412. self._commit_file("keep_me.txt", "I'll stay\n")
  7413. self._commit_file("exclude_me.txt", "I'll be excluded\n")
  7414. patterns = ["keep_me.txt"]
  7415. self.sparse_checkout(self.repo, patterns)
  7416. actual_files = self._list_wtree_files()
  7417. expected_files = {"keep_me.txt"}
  7418. self.assertEqual(
  7419. expected_files,
  7420. actual_files,
  7421. f"Expected only {expected_files}, but found {actual_files}",
  7422. )
  7423. def test_previously_included_paths_become_excluded(self):
  7424. """Previously included files become excluded after pattern changes.
  7425. Verifies that files initially brought into the working tree (e.g.,
  7426. by including `data/`) can later be excluded by narrowing the
  7427. sparse-checkout pattern to just `data/included_1.txt`. Confirms that
  7428. the file `data/included_2.txt` remains in the index with
  7429. skip-worktree set (rather than being removed entirely), ensuring
  7430. data is not lost and Dulwich correctly updates the index flags.
  7431. """
  7432. self._commit_file("data/included_1.txt", "some content\n")
  7433. self._commit_file("data/included_2.txt", "other content\n")
  7434. initial_patterns = ["data/"]
  7435. self.sparse_checkout(self.repo, initial_patterns)
  7436. updated_patterns = ["data/included_1.txt"]
  7437. self.sparse_checkout(self.repo, updated_patterns)
  7438. actual_files = self._list_wtree_files()
  7439. expected_files = {os.path.join("data", "included_1.txt")}
  7440. self.assertEqual(expected_files, actual_files)
  7441. idx = self.repo.open_index()
  7442. self.assertIn(b"data/included_2.txt", idx)
  7443. entry = idx[b"data/included_2.txt"]
  7444. self.assertTrue(entry.skip_worktree)
  7445. def test_force_removes_local_changes_for_excluded_paths(self):
  7446. """Forced sparse checkout removes local modifications for newly excluded paths.
  7447. Verifies that specifying force=True allows destructive operations
  7448. which discard uncommitted changes. First, we commit "file1.txt" and
  7449. then modify it. Next, we apply a pattern that excludes the file,
  7450. using force=True. The local modifications (and the file) should
  7451. be removed, leaving the working tree empty.
  7452. """
  7453. self._commit_file("file1.txt", "original content\n")
  7454. file1_path = os.path.join(self.repo_path, "file1.txt")
  7455. with open(file1_path, "a") as f:
  7456. f.write("local changes!\n")
  7457. new_patterns = ["some_other_file.txt"]
  7458. self.sparse_checkout(self.repo, new_patterns, force=True)
  7459. actual_files = self._list_wtree_files()
  7460. self.assertEqual(
  7461. set(),
  7462. actual_files,
  7463. "Force-sparse-checkout did not remove file with local changes.",
  7464. )
  7465. def test_destructive_refuse_uncommitted_changes_without_force(self):
  7466. """Fail on uncommitted changes for newly excluded paths without force.
  7467. Ensures that a sparse checkout is blocked if it would remove local
  7468. modifications from the working tree. We commit 'config.yaml', then
  7469. modify it, and finally attempt to exclude it via new patterns without
  7470. using force=True. This should raise a CheckoutError rather than
  7471. discarding the local changes.
  7472. """
  7473. self._commit_file("config.yaml", "initial\n")
  7474. cfg_path = os.path.join(self.repo_path, "config.yaml")
  7475. with open(cfg_path, "a") as f:
  7476. f.write("local modifications\n")
  7477. exclude_patterns = ["docs/"]
  7478. with self.assertRaises(CheckoutError):
  7479. self.sparse_checkout(self.repo, exclude_patterns, force=False)
  7480. def test_fnmatch_gitignore_pattern_expansion(self):
  7481. """Reading/writing patterns align with gitignore/fnmatch expansions.
  7482. Ensures that `sparse_checkout` interprets wildcard patterns (like `*.py`)
  7483. in the same way Git's sparse-checkout would. Multiple files are committed
  7484. to `src/` (e.g. `foo.py`, `foo_test.py`, `foo_helper.py`) and to `docs/`.
  7485. Then the pattern `src/foo*.py` is applied, confirming that only the
  7486. matching Python files remain in the working tree while the Markdown file
  7487. under `docs/` is excluded.
  7488. Finally, verifies that the `.git/info/sparse-checkout` file contains the
  7489. specified wildcard pattern (`src/foo*.py`), ensuring correct round-trip
  7490. of user-supplied patterns.
  7491. """
  7492. self._commit_file("src/foo.py", "print('hello')\n")
  7493. self._commit_file("src/foo_test.py", "print('test')\n")
  7494. self._commit_file("docs/readme.md", "# docs\n")
  7495. self._commit_file("src/foo_helper.py", "print('helper')\n")
  7496. patterns = ["src/foo*.py"]
  7497. self.sparse_checkout(self.repo, patterns)
  7498. actual_files = self._list_wtree_files()
  7499. expected_files = {
  7500. os.path.join("src", "foo.py"),
  7501. os.path.join("src", "foo_test.py"),
  7502. os.path.join("src", "foo_helper.py"),
  7503. }
  7504. self.assertEqual(
  7505. expected_files,
  7506. actual_files,
  7507. "Wildcard pattern not matched as expected. Either too strict or too broad.",
  7508. )
  7509. sc_file = os.path.join(self.repo_path, ".git", "info", "sparse-checkout")
  7510. self.assertTrue(os.path.isfile(sc_file))
  7511. with open(sc_file) as f:
  7512. lines = f.read().strip().split()
  7513. self.assertIn("src/foo*.py", lines)
  7514. class ConeModeTests(PorcelainTestCase):
  7515. """Provide integration tests for Dulwich's cone mode sparse checkout.
  7516. This test suite verifies the expected behavior for:
  7517. * cone_mode_init
  7518. * cone_mode_set
  7519. * cone_mode_add
  7520. Although Dulwich does not yet implement cone mode, these tests are
  7521. prepared in advance to guide future development.
  7522. """
  7523. def setUp(self):
  7524. """Set up a fresh repository for each test.
  7525. This method creates a new empty repo_path and Repo object
  7526. as provided by the PorcelainTestCase base class.
  7527. """
  7528. super().setUp()
  7529. def _commit_file(self, rel_path, content=b"contents"):
  7530. """Add a file at the given relative path and commit it.
  7531. Creates necessary directories, writes the file content,
  7532. stages, and commits. The commit message and author/committer
  7533. are also provided.
  7534. """
  7535. full_path = os.path.join(self.repo_path, rel_path)
  7536. os.makedirs(os.path.dirname(full_path), exist_ok=True)
  7537. with open(full_path, "wb") as f:
  7538. f.write(content)
  7539. porcelain.add(self.repo_path, paths=[full_path])
  7540. porcelain.commit(
  7541. self.repo_path,
  7542. message=b"Adding " + rel_path.encode("utf-8"),
  7543. author=b"Test Author <author@example.com>",
  7544. committer=b"Test Committer <committer@example.com>",
  7545. )
  7546. def _list_wtree_files(self):
  7547. """Return a set of all file paths relative to the repository root.
  7548. Walks the working tree, skipping the .git directory.
  7549. """
  7550. found_files = set()
  7551. for root, dirs, files in os.walk(self.repo_path):
  7552. if ".git" in dirs:
  7553. dirs.remove(".git")
  7554. for fn in files:
  7555. relp = os.path.relpath(os.path.join(root, fn), self.repo_path)
  7556. found_files.add(relp)
  7557. return found_files
  7558. def test_init_excludes_everything(self):
  7559. """Verify that cone_mode_init writes minimal patterns and empties the working tree.
  7560. Make some dummy files, commit them, then call cone_mode_init. Confirm
  7561. that the working tree is empty, the sparse-checkout file has the
  7562. minimal patterns (/*, !/*/), and the relevant config values are set.
  7563. """
  7564. self._commit_file("docs/readme.md", b"# doc\n")
  7565. self._commit_file("src/main.py", b"print('hello')\n")
  7566. porcelain.cone_mode_init(self.repo)
  7567. actual_files = self._list_wtree_files()
  7568. self.assertEqual(
  7569. set(),
  7570. actual_files,
  7571. "cone_mode_init did not exclude all files from the working tree.",
  7572. )
  7573. sp_path = os.path.join(self.repo_path, ".git", "info", "sparse-checkout")
  7574. with open(sp_path) as f:
  7575. lines = [ln.strip() for ln in f if ln.strip()]
  7576. self.assertIn("/*", lines)
  7577. self.assertIn("!/*/", lines)
  7578. config = self.repo.get_config()
  7579. self.assertEqual(config.get((b"core",), b"sparseCheckout"), b"true")
  7580. self.assertEqual(config.get((b"core",), b"sparseCheckoutCone"), b"true")
  7581. def test_set_specific_dirs(self):
  7582. """Verify that cone_mode_set overwrites the included directories to only the specified ones.
  7583. Initializes cone mode, commits some files, then calls cone_mode_set with
  7584. a list of directories. Expects that only those directories remain in the
  7585. working tree.
  7586. """
  7587. porcelain.cone_mode_init(self.repo)
  7588. self._commit_file("docs/readme.md", b"# doc\n")
  7589. self._commit_file("src/main.py", b"print('hello')\n")
  7590. self._commit_file("tests/test_foo.py", b"# tests\n")
  7591. # Everything is still excluded initially by init.
  7592. porcelain.cone_mode_set(self.repo, dirs=["docs", "src"])
  7593. actual_files = self._list_wtree_files()
  7594. expected_files = {
  7595. os.path.join("docs", "readme.md"),
  7596. os.path.join("src", "main.py"),
  7597. }
  7598. self.assertEqual(
  7599. expected_files,
  7600. actual_files,
  7601. "Did not see only the 'docs/' and 'src/' dirs in the working tree.",
  7602. )
  7603. sp_path = os.path.join(self.repo_path, ".git", "info", "sparse-checkout")
  7604. with open(sp_path) as f:
  7605. lines = [ln.strip() for ln in f if ln.strip()]
  7606. # For standard cone mode, we'd expect lines like:
  7607. # /* (include top-level files)
  7608. # !/*/ (exclude subdirectories)
  7609. # !/docs/ (re-include docs)
  7610. # !/src/ (re-include src)
  7611. # Instead of the wildcard-based lines the old test used.
  7612. self.assertIn("/*", lines)
  7613. self.assertIn("!/*/", lines)
  7614. self.assertIn("/docs/", lines)
  7615. self.assertIn("/src/", lines)
  7616. self.assertNotIn("/tests/", lines)
  7617. def test_set_overwrites_old_dirs(self):
  7618. """Ensure that calling cone_mode_set again overwrites old includes.
  7619. Initializes cone mode, includes two directories, then calls
  7620. cone_mode_set again with a different directory to confirm the
  7621. new set of includes replaces the old.
  7622. """
  7623. porcelain.cone_mode_init(self.repo)
  7624. self._commit_file("docs/readme.md")
  7625. self._commit_file("src/main.py")
  7626. self._commit_file("tests/test_bar.py")
  7627. porcelain.cone_mode_set(self.repo, dirs=["docs", "src"])
  7628. self.assertEqual(
  7629. {os.path.join("docs", "readme.md"), os.path.join("src", "main.py")},
  7630. self._list_wtree_files(),
  7631. )
  7632. # Overwrite includes, now only 'tests'
  7633. porcelain.cone_mode_set(self.repo, dirs=["tests"], force=True)
  7634. actual_files = self._list_wtree_files()
  7635. expected_files = {os.path.join("tests", "test_bar.py")}
  7636. self.assertEqual(expected_files, actual_files)
  7637. def test_force_removal_of_local_mods(self):
  7638. """Confirm that force=True removes local changes in excluded paths.
  7639. cone_mode_init and cone_mode_set are called, a file is locally modified,
  7640. and then cone_mode_set is called again with force=True to exclude that path.
  7641. The excluded file should be removed with no CheckoutError.
  7642. """
  7643. porcelain.cone_mode_init(self.repo)
  7644. porcelain.cone_mode_set(self.repo, dirs=["docs"])
  7645. self._commit_file("docs/readme.md", b"Docs stuff\n")
  7646. self._commit_file("src/main.py", b"print('hello')\n")
  7647. # Modify src/main.py
  7648. with open(os.path.join(self.repo_path, "src/main.py"), "ab") as f:
  7649. f.write(b"extra line\n")
  7650. # Exclude src/ with force=True
  7651. porcelain.cone_mode_set(self.repo, dirs=["docs"], force=True)
  7652. actual_files = self._list_wtree_files()
  7653. expected_files = {os.path.join("docs", "readme.md")}
  7654. self.assertEqual(expected_files, actual_files)
  7655. def test_add_and_merge_dirs(self):
  7656. """Verify that cone_mode_add merges new directories instead of overwriting them.
  7657. After initializing cone mode and including a single directory, call
  7658. cone_mode_add with a new directory. Confirm that both directories
  7659. remain included. Repeat for an additional directory to ensure it
  7660. is merged, not overwritten.
  7661. """
  7662. porcelain.cone_mode_init(self.repo)
  7663. self._commit_file("docs/readme.md", b"# doc\n")
  7664. self._commit_file("src/main.py", b"print('hello')\n")
  7665. self._commit_file("tests/test_bar.py", b"# tests\n")
  7666. # Include "docs" only
  7667. porcelain.cone_mode_set(self.repo, dirs=["docs"])
  7668. self.assertEqual({os.path.join("docs", "readme.md")}, self._list_wtree_files())
  7669. # Add "src"
  7670. porcelain.cone_mode_add(self.repo, dirs=["src"])
  7671. actual_files = self._list_wtree_files()
  7672. self.assertEqual(
  7673. {os.path.join("docs", "readme.md"), os.path.join("src", "main.py")},
  7674. actual_files,
  7675. )
  7676. # Add "tests" as well
  7677. porcelain.cone_mode_add(self.repo, dirs=["tests"])
  7678. actual_files = self._list_wtree_files()
  7679. expected_files = {
  7680. os.path.join("docs", "readme.md"),
  7681. os.path.join("src", "main.py"),
  7682. os.path.join("tests", "test_bar.py"),
  7683. }
  7684. self.assertEqual(expected_files, actual_files)
  7685. # Check .git/info/sparse-checkout
  7686. sp_path = os.path.join(self.repo_path, ".git", "info", "sparse-checkout")
  7687. with open(sp_path) as f:
  7688. lines = [ln.strip() for ln in f if ln.strip()]
  7689. # Standard cone mode lines:
  7690. # "/*" -> include top-level
  7691. # "!/*/" -> exclude subdirectories
  7692. # "!/docs/", "!/src/", "!/tests/" -> re-include the directories we added
  7693. self.assertIn("/*", lines)
  7694. self.assertIn("!/*/", lines)
  7695. self.assertIn("/docs/", lines)
  7696. self.assertIn("/src/", lines)
  7697. self.assertIn("/tests/", lines)
  7698. class UnpackObjectsTest(PorcelainTestCase):
  7699. def test_unpack_objects(self):
  7700. """Test unpacking objects from a pack file."""
  7701. # Create a test repository with some objects
  7702. b1 = Blob()
  7703. b1.data = b"test content 1"
  7704. b2 = Blob()
  7705. b2.data = b"test content 2"
  7706. # Add objects to the repo
  7707. self.repo.object_store.add_object(b1)
  7708. self.repo.object_store.add_object(b2)
  7709. # Create a pack file with these objects
  7710. pack_path = os.path.join(self.test_dir, "test_pack")
  7711. with (
  7712. open(pack_path + ".pack", "wb") as pack_f,
  7713. open(pack_path + ".idx", "wb") as idx_f,
  7714. ):
  7715. porcelain.pack_objects(
  7716. self.repo,
  7717. [b1.id, b2.id],
  7718. pack_f,
  7719. idx_f,
  7720. )
  7721. # Create a new repository to unpack into
  7722. target_repo_path = os.path.join(self.test_dir, "target_repo")
  7723. target_repo = Repo.init(target_repo_path, mkdir=True)
  7724. self.addCleanup(target_repo.close)
  7725. # Unpack the objects
  7726. count = porcelain.unpack_objects(pack_path + ".pack", target_repo_path)
  7727. # Verify the objects were unpacked
  7728. self.assertEqual(2, count)
  7729. self.assertIn(b1.id, target_repo.object_store)
  7730. self.assertIn(b2.id, target_repo.object_store)
  7731. # Verify the content is correct
  7732. unpacked_b1 = target_repo.object_store[b1.id]
  7733. unpacked_b2 = target_repo.object_store[b2.id]
  7734. self.assertEqual(b1.data, unpacked_b1.data)
  7735. self.assertEqual(b2.data, unpacked_b2.data)
  7736. class CountObjectsTests(PorcelainTestCase):
  7737. def test_count_objects_empty_repo(self):
  7738. """Test counting objects in an empty repository."""
  7739. stats = porcelain.count_objects(self.repo)
  7740. self.assertEqual(0, stats.count)
  7741. self.assertEqual(0, stats.size)
  7742. def test_count_objects_verbose_empty_repo(self):
  7743. """Test verbose counting in an empty repository."""
  7744. stats = porcelain.count_objects(self.repo, verbose=True)
  7745. self.assertEqual(0, stats.count)
  7746. self.assertEqual(0, stats.size)
  7747. self.assertEqual(0, stats.in_pack)
  7748. self.assertEqual(0, stats.packs)
  7749. self.assertEqual(0, stats.size_pack)
  7750. def test_count_objects_with_loose_objects(self):
  7751. """Test counting loose objects."""
  7752. # Create some loose objects
  7753. blob1 = make_object(Blob, data=b"data1")
  7754. blob2 = make_object(Blob, data=b"data2")
  7755. self.repo.object_store.add_object(blob1)
  7756. self.repo.object_store.add_object(blob2)
  7757. stats = porcelain.count_objects(self.repo)
  7758. self.assertEqual(2, stats.count)
  7759. self.assertGreater(stats.size, 0)
  7760. def test_count_objects_verbose_with_objects(self):
  7761. """Test verbose counting with both loose and packed objects."""
  7762. # Add some loose objects
  7763. for i in range(3):
  7764. blob = make_object(Blob, data=f"data{i}".encode())
  7765. self.repo.object_store.add_object(blob)
  7766. # Create a simple commit to have some objects in a pack
  7767. tree = Tree()
  7768. c1 = make_commit(tree=tree.id, message=b"Test commit")
  7769. self.repo.object_store.add_objects([(tree, None), (c1, None)])
  7770. self.repo.refs[b"HEAD"] = c1.id
  7771. # Repack to create a pack file
  7772. porcelain.repack(self.repo)
  7773. stats = porcelain.count_objects(self.repo, verbose=True)
  7774. # After repacking, loose objects might be cleaned up
  7775. self.assertIsInstance(stats.count, int)
  7776. self.assertIsInstance(stats.size, int)
  7777. self.assertGreater(stats.in_pack, 0) # Should have packed objects
  7778. self.assertGreater(stats.packs, 0) # Should have at least one pack
  7779. self.assertGreater(stats.size_pack, 0) # Pack should have size
  7780. # Verify it's the correct dataclass type
  7781. self.assertIsInstance(stats, CountObjectsResult)
  7782. class PruneTests(PorcelainTestCase):
  7783. def test_prune_removes_old_tempfiles(self):
  7784. """Test that prune removes old temporary files."""
  7785. # Create an old temporary file in the objects directory
  7786. objects_dir = os.path.join(self.repo.path, ".git", "objects")
  7787. tmp_pack_path = os.path.join(objects_dir, "tmp_pack_test")
  7788. with open(tmp_pack_path, "wb") as f:
  7789. f.write(b"old temporary data")
  7790. # Make it old
  7791. old_time = time.time() - (DEFAULT_TEMPFILE_GRACE_PERIOD + 3600)
  7792. os.utime(tmp_pack_path, (old_time, old_time))
  7793. # Run prune
  7794. porcelain.prune(self.repo.path)
  7795. # Verify the file was removed
  7796. self.assertFalse(os.path.exists(tmp_pack_path))
  7797. def test_prune_keeps_recent_tempfiles(self):
  7798. """Test that prune keeps recent temporary files."""
  7799. # Create a recent temporary file
  7800. objects_dir = os.path.join(self.repo.path, ".git", "objects")
  7801. tmp_pack_path = os.path.join(objects_dir, "tmp_pack_recent")
  7802. with open(tmp_pack_path, "wb") as f:
  7803. f.write(b"recent temporary data")
  7804. self.addCleanup(os.remove, tmp_pack_path)
  7805. # Run prune
  7806. porcelain.prune(self.repo.path)
  7807. # Verify the file was NOT removed
  7808. self.assertTrue(os.path.exists(tmp_pack_path))
  7809. def test_prune_with_custom_grace_period(self):
  7810. """Test prune with custom grace period."""
  7811. # Create a 1-hour-old temporary file
  7812. objects_dir = os.path.join(self.repo.path, ".git", "objects")
  7813. tmp_pack_path = os.path.join(objects_dir, "tmp_pack_1hour")
  7814. with open(tmp_pack_path, "wb") as f:
  7815. f.write(b"1 hour old data")
  7816. # Make it 1 hour old
  7817. old_time = time.time() - 3600
  7818. os.utime(tmp_pack_path, (old_time, old_time))
  7819. # Prune with 30-minute grace period should remove it
  7820. porcelain.prune(self.repo.path, grace_period=1800)
  7821. # Verify the file was removed
  7822. self.assertFalse(os.path.exists(tmp_pack_path))
  7823. def test_prune_dry_run(self):
  7824. """Test prune in dry-run mode."""
  7825. # Create an old temporary file
  7826. objects_dir = os.path.join(self.repo.path, ".git", "objects")
  7827. tmp_pack_path = os.path.join(objects_dir, "tmp_pack_dryrun")
  7828. with open(tmp_pack_path, "wb") as f:
  7829. f.write(b"old temporary data")
  7830. self.addCleanup(os.remove, tmp_pack_path)
  7831. # Make it old
  7832. old_time = time.time() - (DEFAULT_TEMPFILE_GRACE_PERIOD + 3600)
  7833. os.utime(tmp_pack_path, (old_time, old_time))
  7834. # Run prune in dry-run mode
  7835. porcelain.prune(self.repo.path, dry_run=True)
  7836. # Verify the file was NOT removed (dry run)
  7837. self.assertTrue(os.path.exists(tmp_pack_path))
  7838. class FilterBranchTests(PorcelainTestCase):
  7839. def setUp(self):
  7840. super().setUp()
  7841. # Create initial commits with different authors
  7842. from dulwich.objects import Commit, Tree
  7843. # Create actual tree and blob objects
  7844. tree = Tree()
  7845. self.repo.object_store.add_object(tree)
  7846. c1 = Commit()
  7847. c1.tree = tree.id
  7848. c1.parents = []
  7849. c1.author = b"Old Author <old@example.com>"
  7850. c1.author_time = 1000
  7851. c1.author_timezone = 0
  7852. c1.committer = b"Old Committer <old@example.com>"
  7853. c1.commit_time = 1000
  7854. c1.commit_timezone = 0
  7855. c1.message = b"Initial commit"
  7856. self.repo.object_store.add_object(c1)
  7857. c2 = Commit()
  7858. c2.tree = tree.id
  7859. c2.parents = [c1.id]
  7860. c2.author = b"Another Author <another@example.com>"
  7861. c2.author_time = 2000
  7862. c2.author_timezone = 0
  7863. c2.committer = b"Another Committer <another@example.com>"
  7864. c2.commit_time = 2000
  7865. c2.commit_timezone = 0
  7866. c2.message = b"Second commit\n\nWith body"
  7867. self.repo.object_store.add_object(c2)
  7868. c3 = Commit()
  7869. c3.tree = tree.id
  7870. c3.parents = [c2.id]
  7871. c3.author = b"Third Author <third@example.com>"
  7872. c3.author_time = 3000
  7873. c3.author_timezone = 0
  7874. c3.committer = b"Third Committer <third@example.com>"
  7875. c3.commit_time = 3000
  7876. c3.commit_timezone = 0
  7877. c3.message = b"Third commit"
  7878. self.repo.object_store.add_object(c3)
  7879. self.repo.refs[b"refs/heads/master"] = c3.id
  7880. self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/master")
  7881. # Store IDs for test assertions
  7882. self.c1_id = c1.id
  7883. self.c2_id = c2.id
  7884. self.c3_id = c3.id
  7885. def test_filter_branch_author(self):
  7886. """Test filtering branch with author changes."""
  7887. def filter_author(author):
  7888. # Change all authors to "New Author"
  7889. return b"New Author <new@example.com>"
  7890. result = porcelain.filter_branch(
  7891. self.repo_path, "master", filter_author=filter_author
  7892. )
  7893. # Check that we have mappings for all commits
  7894. self.assertEqual(len(result), 3)
  7895. # Verify the branch ref was updated
  7896. new_head = self.repo.refs[b"refs/heads/master"]
  7897. self.assertNotEqual(new_head, self.c3_id)
  7898. # Verify the original ref was saved
  7899. original_ref = self.repo.refs[b"refs/original/refs/heads/master"]
  7900. self.assertEqual(original_ref, self.c3_id)
  7901. # Check that authors were updated
  7902. new_commit = self.repo[new_head]
  7903. self.assertEqual(new_commit.author, b"New Author <new@example.com>")
  7904. # Check parent chain
  7905. parent = self.repo[new_commit.parents[0]]
  7906. self.assertEqual(parent.author, b"New Author <new@example.com>")
  7907. def test_filter_branch_message(self):
  7908. """Test filtering branch with message changes."""
  7909. def filter_message(message):
  7910. # Add prefix to all messages
  7911. return b"[FILTERED] " + message
  7912. porcelain.filter_branch(self.repo_path, "master", filter_message=filter_message)
  7913. # Verify messages were updated
  7914. new_head = self.repo.refs[b"refs/heads/master"]
  7915. new_commit = self.repo[new_head]
  7916. self.assertTrue(new_commit.message.startswith(b"[FILTERED] "))
  7917. def test_filter_branch_custom_filter(self):
  7918. """Test filtering branch with custom filter function."""
  7919. def custom_filter(commit):
  7920. # Change both author and message
  7921. return {
  7922. "author": b"Custom Author <custom@example.com>",
  7923. "message": b"Custom: " + commit.message,
  7924. }
  7925. porcelain.filter_branch(self.repo_path, "master", filter_fn=custom_filter)
  7926. # Verify custom filter was applied
  7927. new_head = self.repo.refs[b"refs/heads/master"]
  7928. new_commit = self.repo[new_head]
  7929. self.assertEqual(new_commit.author, b"Custom Author <custom@example.com>")
  7930. self.assertTrue(new_commit.message.startswith(b"Custom: "))
  7931. def test_filter_branch_no_changes(self):
  7932. """Test filtering branch with no changes."""
  7933. result = porcelain.filter_branch(self.repo_path, "master")
  7934. # All commits should map to themselves
  7935. for old_sha, new_sha in result.items():
  7936. self.assertEqual(old_sha, new_sha)
  7937. # HEAD should be unchanged
  7938. self.assertEqual(self.repo.refs[b"refs/heads/master"], self.c3_id)
  7939. def test_filter_branch_force(self):
  7940. """Test force filtering a previously filtered branch."""
  7941. # First filter
  7942. porcelain.filter_branch(
  7943. self.repo_path, "master", filter_message=lambda m: b"First: " + m
  7944. )
  7945. # Try again without force - should fail
  7946. with self.assertRaises(porcelain.Error):
  7947. porcelain.filter_branch(
  7948. self.repo_path, "master", filter_message=lambda m: b"Second: " + m
  7949. )
  7950. # Try again with force - should succeed
  7951. porcelain.filter_branch(
  7952. self.repo_path,
  7953. "master",
  7954. filter_message=lambda m: b"Second: " + m,
  7955. force=True,
  7956. )
  7957. # Verify second filter was applied
  7958. new_head = self.repo.refs[b"refs/heads/master"]
  7959. new_commit = self.repo[new_head]
  7960. self.assertTrue(new_commit.message.startswith(b"Second: First: "))
  7961. class StashTests(PorcelainTestCase):
  7962. def setUp(self) -> None:
  7963. super().setUp()
  7964. # Create initial commit
  7965. with open(os.path.join(self.repo.path, "initial.txt"), "wb") as f:
  7966. f.write(b"initial content")
  7967. porcelain.add(repo=self.repo.path, paths=["initial.txt"])
  7968. porcelain.commit(
  7969. repo=self.repo.path,
  7970. message=b"Initial commit",
  7971. author=b"Test Author <test@example.com>",
  7972. committer=b"Test Committer <test@example.com>",
  7973. )
  7974. def test_stash_push_and_pop(self) -> None:
  7975. # Create a new file and stage it
  7976. new_file = os.path.join(self.repo.path, "new.txt")
  7977. with open(new_file, "wb") as f:
  7978. f.write(b"new file content")
  7979. porcelain.add(repo=self.repo.path, paths=["new.txt"])
  7980. # Modify existing file
  7981. with open(os.path.join(self.repo.path, "initial.txt"), "wb") as f:
  7982. f.write(b"modified content")
  7983. # Push to stash
  7984. porcelain.stash_push(self.repo.path)
  7985. # Verify files are reset
  7986. self.assertFalse(os.path.exists(new_file))
  7987. with open(os.path.join(self.repo.path, "initial.txt"), "rb") as f:
  7988. self.assertEqual(b"initial content", f.read())
  7989. # Pop the stash
  7990. porcelain.stash_pop(self.repo.path)
  7991. # Verify files are restored
  7992. self.assertTrue(os.path.exists(new_file))
  7993. with open(new_file, "rb") as f:
  7994. self.assertEqual(b"new file content", f.read())
  7995. with open(os.path.join(self.repo.path, "initial.txt"), "rb") as f:
  7996. self.assertEqual(b"modified content", f.read())
  7997. # Verify new file is in the index
  7998. from dulwich.index import Index
  7999. index = Index(os.path.join(self.repo.path, ".git", "index"))
  8000. self.assertIn(b"new.txt", index)
  8001. def test_stash_list(self) -> None:
  8002. # Initially no stashes
  8003. stashes = list(porcelain.stash_list(self.repo.path))
  8004. self.assertEqual(0, len(stashes))
  8005. # Create a file and stash it
  8006. test_file = os.path.join(self.repo.path, "test.txt")
  8007. with open(test_file, "wb") as f:
  8008. f.write(b"test content")
  8009. porcelain.add(repo=self.repo.path, paths=["test.txt"])
  8010. # Push first stash
  8011. porcelain.stash_push(self.repo.path)
  8012. # Create another file and stash it
  8013. test_file2 = os.path.join(self.repo.path, "test2.txt")
  8014. with open(test_file2, "wb") as f:
  8015. f.write(b"test content 2")
  8016. porcelain.add(repo=self.repo.path, paths=["test2.txt"])
  8017. # Push second stash
  8018. porcelain.stash_push(self.repo.path)
  8019. # Check stash list
  8020. stashes = list(porcelain.stash_list(self.repo.path))
  8021. self.assertEqual(2, len(stashes))
  8022. # Stashes are returned in order (most recent first)
  8023. self.assertEqual(0, stashes[0][0])
  8024. self.assertEqual(1, stashes[1][0])
  8025. def test_stash_drop(self) -> None:
  8026. # Create and stash some changes
  8027. test_file = os.path.join(self.repo.path, "test.txt")
  8028. with open(test_file, "wb") as f:
  8029. f.write(b"test content")
  8030. porcelain.add(repo=self.repo.path, paths=["test.txt"])
  8031. porcelain.stash_push(self.repo.path)
  8032. # Create another stash
  8033. test_file2 = os.path.join(self.repo.path, "test2.txt")
  8034. with open(test_file2, "wb") as f:
  8035. f.write(b"test content 2")
  8036. porcelain.add(repo=self.repo.path, paths=["test2.txt"])
  8037. porcelain.stash_push(self.repo.path)
  8038. # Verify we have 2 stashes
  8039. stashes = list(porcelain.stash_list(self.repo.path))
  8040. self.assertEqual(2, len(stashes))
  8041. # Drop the first stash (index 0)
  8042. porcelain.stash_drop(self.repo.path, 0)
  8043. # Verify we have 1 stash left
  8044. stashes = list(porcelain.stash_list(self.repo.path))
  8045. self.assertEqual(1, len(stashes))
  8046. # The remaining stash should be the one we created first
  8047. # Pop it and verify it's the first file
  8048. porcelain.stash_pop(self.repo.path)
  8049. self.assertTrue(os.path.exists(test_file))
  8050. self.assertFalse(os.path.exists(test_file2))
  8051. def test_stash_pop_empty(self) -> None:
  8052. # Attempting to pop from empty stash should raise an error
  8053. with self.assertRaises(IndexError):
  8054. porcelain.stash_pop(self.repo.path)
  8055. def test_stash_with_untracked_files(self) -> None:
  8056. # Create an untracked file
  8057. untracked_file = os.path.join(self.repo.path, "untracked.txt")
  8058. with open(untracked_file, "wb") as f:
  8059. f.write(b"untracked content")
  8060. # Create a tracked change
  8061. tracked_file = os.path.join(self.repo.path, "tracked.txt")
  8062. with open(tracked_file, "wb") as f:
  8063. f.write(b"tracked content")
  8064. porcelain.add(repo=self.repo.path, paths=["tracked.txt"])
  8065. # Stash (by default, untracked files are not included)
  8066. porcelain.stash_push(self.repo.path)
  8067. # Untracked file should still exist
  8068. self.assertTrue(os.path.exists(untracked_file))
  8069. # Tracked file should be gone
  8070. self.assertFalse(os.path.exists(tracked_file))
  8071. # Pop the stash
  8072. porcelain.stash_pop(self.repo.path)
  8073. # Tracked file should be restored
  8074. self.assertTrue(os.path.exists(tracked_file))
  8075. class BisectTests(PorcelainTestCase):
  8076. """Tests for bisect porcelain functions."""
  8077. def test_bisect_start(self):
  8078. """Test starting a bisect session."""
  8079. # Create some commits
  8080. _c1, _c2, c3 = build_commit_graph(
  8081. self.repo.object_store,
  8082. [[1], [2, 1], [3, 2]],
  8083. attrs={
  8084. 1: {"message": b"initial"},
  8085. 2: {"message": b"second"},
  8086. 3: {"message": b"third"},
  8087. },
  8088. )
  8089. self.repo.refs[b"refs/heads/master"] = c3.id
  8090. self.repo.refs[b"HEAD"] = c3.id
  8091. # Start bisect
  8092. porcelain.bisect_start(self.repo_path)
  8093. # Check that bisect state files exist
  8094. self.assertTrue(
  8095. os.path.exists(os.path.join(self.repo.controldir(), "BISECT_START"))
  8096. )
  8097. self.assertTrue(
  8098. os.path.exists(os.path.join(self.repo.controldir(), "BISECT_TERMS"))
  8099. )
  8100. self.assertTrue(
  8101. os.path.exists(os.path.join(self.repo.controldir(), "BISECT_NAMES"))
  8102. )
  8103. self.assertTrue(
  8104. os.path.exists(os.path.join(self.repo.controldir(), "BISECT_LOG"))
  8105. )
  8106. def test_bisect_workflow(self):
  8107. """Test a complete bisect workflow."""
  8108. # Create some commits
  8109. c1, c2, c3, c4 = build_commit_graph(
  8110. self.repo.object_store,
  8111. [[1], [2, 1], [3, 2], [4, 3]],
  8112. attrs={
  8113. 1: {"message": b"good commit 1"},
  8114. 2: {"message": b"good commit 2"},
  8115. 3: {"message": b"bad commit"},
  8116. 4: {"message": b"bad commit 2"},
  8117. },
  8118. )
  8119. self.repo.refs[b"refs/heads/master"] = c4.id
  8120. self.repo.refs[b"HEAD"] = c4.id
  8121. # Start bisect with bad and good
  8122. next_sha = porcelain.bisect_start(self.repo_path, bad=c4.id, good=c1.id)
  8123. # Should return the middle commit
  8124. self.assertIsNotNone(next_sha)
  8125. self.assertIn(next_sha, [c2.id, c3.id])
  8126. # Mark the middle commit as good or bad
  8127. if next_sha == c2.id:
  8128. # c2 is good, next should be c3
  8129. next_sha = porcelain.bisect_good(self.repo_path)
  8130. self.assertEqual(next_sha, c3.id)
  8131. # Mark c3 as bad - bisect complete
  8132. next_sha = porcelain.bisect_bad(self.repo_path)
  8133. self.assertIsNone(next_sha)
  8134. else:
  8135. # c3 is bad, next should be c2
  8136. next_sha = porcelain.bisect_bad(self.repo_path)
  8137. self.assertEqual(next_sha, c2.id)
  8138. # Mark c2 as good - bisect complete
  8139. next_sha = porcelain.bisect_good(self.repo_path)
  8140. self.assertIsNone(next_sha)
  8141. def test_bisect_log(self):
  8142. """Test getting bisect log."""
  8143. # Create some commits
  8144. c1, _c2, c3 = build_commit_graph(
  8145. self.repo.object_store,
  8146. [[1], [2, 1], [3, 2]],
  8147. attrs={
  8148. 1: {"message": b"initial"},
  8149. 2: {"message": b"second"},
  8150. 3: {"message": b"third"},
  8151. },
  8152. )
  8153. self.repo.refs[b"refs/heads/master"] = c3.id
  8154. self.repo.refs[b"HEAD"] = c3.id
  8155. # Start bisect and mark commits
  8156. porcelain.bisect_start(self.repo_path)
  8157. porcelain.bisect_bad(self.repo_path, c3.id)
  8158. porcelain.bisect_good(self.repo_path, c1.id)
  8159. # Get log
  8160. log = porcelain.bisect_log(self.repo_path)
  8161. self.assertIn("git bisect start", log)
  8162. self.assertIn("git bisect bad", log)
  8163. self.assertIn("git bisect good", log)
  8164. def test_bisect_reset(self):
  8165. """Test resetting bisect state."""
  8166. # Create some commits
  8167. c1, _c2, c3 = build_commit_graph(
  8168. self.repo.object_store,
  8169. [[1], [2, 1], [3, 2]],
  8170. attrs={
  8171. 1: {"message": b"initial"},
  8172. 2: {"message": b"second"},
  8173. 3: {"message": b"third"},
  8174. },
  8175. )
  8176. self.repo.refs[b"refs/heads/master"] = c3.id
  8177. self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/master")
  8178. # Start bisect
  8179. porcelain.bisect_start(self.repo_path)
  8180. porcelain.bisect_bad(self.repo_path)
  8181. porcelain.bisect_good(self.repo_path, c1.id)
  8182. # Reset
  8183. porcelain.bisect_reset(self.repo_path)
  8184. # Check that bisect state files are removed
  8185. self.assertFalse(
  8186. os.path.exists(os.path.join(self.repo.controldir(), "BISECT_START"))
  8187. )
  8188. self.assertFalse(
  8189. os.path.exists(os.path.join(self.repo.controldir(), "refs", "bisect"))
  8190. )
  8191. # HEAD should be back to being a symbolic ref to master
  8192. head_target, _ = self.repo.refs.follow(b"HEAD")
  8193. self.assertEqual(head_target[-1], b"refs/heads/master")
  8194. def test_bisect_skip(self):
  8195. """Test skipping commits during bisect."""
  8196. # Create some commits
  8197. c1, c2, _c3, _c4, c5 = build_commit_graph(
  8198. self.repo.object_store,
  8199. [[1], [2, 1], [3, 2], [4, 3], [5, 4]],
  8200. attrs={
  8201. 1: {"message": b"good"},
  8202. 2: {"message": b"skip this"},
  8203. 3: {"message": b"bad"},
  8204. 4: {"message": b"bad"},
  8205. 5: {"message": b"bad"},
  8206. },
  8207. )
  8208. self.repo.refs[b"refs/heads/master"] = c5.id
  8209. self.repo.refs[b"HEAD"] = c5.id
  8210. # Start bisect
  8211. porcelain.bisect_start(self.repo_path, bad=c5.id, good=c1.id)
  8212. # Skip c2 if it's selected
  8213. next_sha = porcelain.bisect_skip(self.repo_path, [c2.id])
  8214. self.assertIsNotNone(next_sha)
  8215. class ReflogTest(PorcelainTestCase):
  8216. def test_reflog_head(self):
  8217. """Test reading HEAD reflog."""
  8218. # Create a commit
  8219. blob = Blob.from_string(b"test content")
  8220. self.repo.object_store.add_object(blob)
  8221. tree = Tree()
  8222. tree.add(b"test", 0o100644, blob.id)
  8223. self.repo.object_store.add_object(tree)
  8224. commit = Commit()
  8225. commit.tree = tree.id
  8226. commit.author = b"Test Author <test@example.com>"
  8227. commit.committer = b"Test Author <test@example.com>"
  8228. commit.commit_time = 1234567890
  8229. commit.commit_timezone = 0
  8230. commit.author_time = 1234567890
  8231. commit.author_timezone = 0
  8232. commit.message = b"Initial commit"
  8233. self.repo.object_store.add_object(commit)
  8234. # Write a reflog entry
  8235. self.repo._write_reflog(
  8236. b"HEAD",
  8237. ZERO_SHA,
  8238. commit.id,
  8239. b"Test Author <test@example.com>",
  8240. 1234567890,
  8241. 0,
  8242. b"commit (initial): Initial commit",
  8243. )
  8244. # Read reflog using porcelain
  8245. entries = list(porcelain.reflog(self.repo_path))
  8246. self.assertEqual(1, len(entries))
  8247. self.assertEqual(ZERO_SHA, entries[0].old_sha)
  8248. self.assertEqual(commit.id, entries[0].new_sha)
  8249. self.assertEqual(b"Test Author <test@example.com>", entries[0].committer)
  8250. self.assertEqual(1234567890, entries[0].timestamp)
  8251. self.assertEqual(0, entries[0].timezone)
  8252. self.assertEqual(b"commit (initial): Initial commit", entries[0].message)
  8253. def test_reflog_with_string_ref(self):
  8254. """Test reading reflog with string reference."""
  8255. # Create a commit
  8256. blob = Blob.from_string(b"test content")
  8257. self.repo.object_store.add_object(blob)
  8258. tree = Tree()
  8259. tree.add(b"test", 0o100644, blob.id)
  8260. self.repo.object_store.add_object(tree)
  8261. commit = Commit()
  8262. commit.tree = tree.id
  8263. commit.author = b"Test Author <test@example.com>"
  8264. commit.committer = b"Test Author <test@example.com>"
  8265. commit.commit_time = 1234567890
  8266. commit.commit_timezone = 0
  8267. commit.author_time = 1234567890
  8268. commit.author_timezone = 0
  8269. commit.message = b"Initial commit"
  8270. self.repo.object_store.add_object(commit)
  8271. # Write a reflog entry
  8272. self.repo._write_reflog(
  8273. b"refs/heads/master",
  8274. ZERO_SHA,
  8275. commit.id,
  8276. b"Test Author <test@example.com>",
  8277. 1234567890,
  8278. 0,
  8279. b"commit (initial): Initial commit",
  8280. )
  8281. # Read reflog using porcelain with string ref
  8282. entries = list(porcelain.reflog(self.repo_path, "refs/heads/master"))
  8283. self.assertEqual(1, len(entries))
  8284. self.assertEqual(commit.id, entries[0].new_sha)
  8285. def test_reflog_all(self):
  8286. """Test reading all reflogs."""
  8287. # Create a commit
  8288. blob = Blob.from_string(b"test content")
  8289. self.repo.object_store.add_object(blob)
  8290. tree = Tree()
  8291. tree.add(b"test", 0o100644, blob.id)
  8292. self.repo.object_store.add_object(tree)
  8293. commit = Commit()
  8294. commit.tree = tree.id
  8295. commit.author = b"Test Author <test@example.com>"
  8296. commit.committer = b"Test Author <test@example.com>"
  8297. commit.commit_time = 1234567890
  8298. commit.commit_timezone = 0
  8299. commit.author_time = 1234567890
  8300. commit.author_timezone = 0
  8301. commit.message = b"Initial commit"
  8302. self.repo.object_store.add_object(commit)
  8303. # Write reflog entries for HEAD and master
  8304. self.repo._write_reflog(
  8305. b"HEAD",
  8306. ZERO_SHA,
  8307. commit.id,
  8308. b"Test Author <test@example.com>",
  8309. 1234567890,
  8310. 0,
  8311. b"commit (initial): Initial commit",
  8312. )
  8313. self.repo._write_reflog(
  8314. b"refs/heads/master",
  8315. ZERO_SHA,
  8316. commit.id,
  8317. b"Test Author <test@example.com>",
  8318. 1234567891,
  8319. 0,
  8320. b"branch: Created from HEAD",
  8321. )
  8322. # Read all reflogs using porcelain
  8323. entries = list(porcelain.reflog(self.repo_path, all=True))
  8324. # Should have at least 2 entries (HEAD and refs/heads/master)
  8325. self.assertGreaterEqual(len(entries), 2)
  8326. # Check that we got entries from different refs
  8327. refs_seen = set()
  8328. for ref, entry in entries:
  8329. refs_seen.add(ref)
  8330. self.assertEqual(commit.id, entry.new_sha)
  8331. # Should have seen at least HEAD and refs/heads/master
  8332. self.assertIn(b"HEAD", refs_seen)
  8333. self.assertIn(b"refs/heads/master", refs_seen)
  8334. def test_reflog_expire_by_time(self):
  8335. """Test expiring reflog entries by timestamp."""
  8336. # Create commits
  8337. blob = Blob.from_string(b"test content")
  8338. self.repo.object_store.add_object(blob)
  8339. tree = Tree()
  8340. tree.add(b"test", 0o100644, blob.id)
  8341. self.repo.object_store.add_object(tree)
  8342. commit1 = Commit()
  8343. commit1.tree = tree.id
  8344. commit1.author = b"Test Author <test@example.com>"
  8345. commit1.committer = b"Test Author <test@example.com>"
  8346. commit1.commit_time = 1000000000
  8347. commit1.commit_timezone = 0
  8348. commit1.author_time = 1000000000
  8349. commit1.author_timezone = 0
  8350. commit1.message = b"Old commit"
  8351. self.repo.object_store.add_object(commit1)
  8352. commit2 = Commit()
  8353. commit2.tree = tree.id
  8354. commit2.author = b"Test Author <test@example.com>"
  8355. commit2.committer = b"Test Author <test@example.com>"
  8356. commit2.commit_time = 2000000000
  8357. commit2.commit_timezone = 0
  8358. commit2.author_time = 2000000000
  8359. commit2.author_timezone = 0
  8360. commit2.message = b"Recent commit"
  8361. self.repo.object_store.add_object(commit2)
  8362. # Write reflog entries with different timestamps
  8363. self.repo._write_reflog(
  8364. b"HEAD",
  8365. ZERO_SHA,
  8366. commit1.id,
  8367. b"Test Author <test@example.com>",
  8368. 1000000000,
  8369. 0,
  8370. b"commit: Old commit",
  8371. )
  8372. self.repo._write_reflog(
  8373. b"HEAD",
  8374. commit1.id,
  8375. commit2.id,
  8376. b"Test Author <test@example.com>",
  8377. 2000000000,
  8378. 0,
  8379. b"commit: Recent commit",
  8380. )
  8381. # Expire entries older than timestamp 1500000000
  8382. result = porcelain.reflog_expire(
  8383. self.repo_path, ref=b"HEAD", expire_time=1500000000
  8384. )
  8385. self.assertEqual(1, result[b"HEAD"])
  8386. # Check that only the recent entry remains
  8387. entries = list(porcelain.reflog(self.repo_path, b"HEAD"))
  8388. self.assertEqual(1, len(entries))
  8389. self.assertEqual(commit2.id, entries[0].new_sha)
  8390. def test_reflog_expire_all(self):
  8391. """Test expiring reflog entries for all refs."""
  8392. # Create a commit
  8393. blob = Blob.from_string(b"test content")
  8394. self.repo.object_store.add_object(blob)
  8395. tree = Tree()
  8396. tree.add(b"test", 0o100644, blob.id)
  8397. self.repo.object_store.add_object(tree)
  8398. commit = Commit()
  8399. commit.tree = tree.id
  8400. commit.author = b"Test Author <test@example.com>"
  8401. commit.committer = b"Test Author <test@example.com>"
  8402. commit.commit_time = 1000000000
  8403. commit.commit_timezone = 0
  8404. commit.author_time = 1000000000
  8405. commit.author_timezone = 0
  8406. commit.message = b"Test commit"
  8407. self.repo.object_store.add_object(commit)
  8408. # Write old reflog entries for multiple refs
  8409. self.repo._write_reflog(
  8410. b"HEAD",
  8411. ZERO_SHA,
  8412. commit.id,
  8413. b"Test Author <test@example.com>",
  8414. 1000000000,
  8415. 0,
  8416. b"commit: Test commit",
  8417. )
  8418. self.repo._write_reflog(
  8419. b"refs/heads/master",
  8420. ZERO_SHA,
  8421. commit.id,
  8422. b"Test Author <test@example.com>",
  8423. 1000000000,
  8424. 0,
  8425. b"commit: Test commit",
  8426. )
  8427. # Expire all old entries
  8428. result = porcelain.reflog_expire(
  8429. self.repo_path, all=True, expire_time=2000000000
  8430. )
  8431. # Should have expired entries from both refs
  8432. self.assertIn(b"HEAD", result)
  8433. self.assertIn(b"refs/heads/master", result)
  8434. self.assertEqual(1, result[b"HEAD"])
  8435. self.assertEqual(1, result[b"refs/heads/master"])
  8436. def test_reflog_expire_dry_run(self):
  8437. """Test dry-run mode for reflog expire."""
  8438. # Create a commit
  8439. blob = Blob.from_string(b"test content")
  8440. self.repo.object_store.add_object(blob)
  8441. tree = Tree()
  8442. tree.add(b"test", 0o100644, blob.id)
  8443. self.repo.object_store.add_object(tree)
  8444. commit = Commit()
  8445. commit.tree = tree.id
  8446. commit.author = b"Test Author <test@example.com>"
  8447. commit.committer = b"Test Author <test@example.com>"
  8448. commit.commit_time = 1000000000
  8449. commit.commit_timezone = 0
  8450. commit.author_time = 1000000000
  8451. commit.author_timezone = 0
  8452. commit.message = b"Test commit"
  8453. self.repo.object_store.add_object(commit)
  8454. # Write old reflog entry
  8455. self.repo._write_reflog(
  8456. b"HEAD",
  8457. ZERO_SHA,
  8458. commit.id,
  8459. b"Test Author <test@example.com>",
  8460. 1000000000,
  8461. 0,
  8462. b"commit: Test commit",
  8463. )
  8464. # Dry run expire
  8465. result = porcelain.reflog_expire(
  8466. self.repo_path, ref=b"HEAD", expire_time=2000000000, dry_run=True
  8467. )
  8468. self.assertEqual(1, result[b"HEAD"])
  8469. # Entry should still exist
  8470. entries = list(porcelain.reflog(self.repo_path, b"HEAD"))
  8471. self.assertEqual(1, len(entries))
  8472. def test_reflog_delete(self):
  8473. """Test deleting specific reflog entry."""
  8474. # Create commits
  8475. blob = Blob.from_string(b"test content")
  8476. self.repo.object_store.add_object(blob)
  8477. tree = Tree()
  8478. tree.add(b"test", 0o100644, blob.id)
  8479. self.repo.object_store.add_object(tree)
  8480. commit1 = Commit()
  8481. commit1.tree = tree.id
  8482. commit1.author = b"Test Author <test@example.com>"
  8483. commit1.committer = b"Test Author <test@example.com>"
  8484. commit1.commit_time = 1000000000
  8485. commit1.commit_timezone = 0
  8486. commit1.author_time = 1000000000
  8487. commit1.author_timezone = 0
  8488. commit1.message = b"First commit"
  8489. self.repo.object_store.add_object(commit1)
  8490. commit2 = Commit()
  8491. commit2.tree = tree.id
  8492. commit2.author = b"Test Author <test@example.com>"
  8493. commit2.committer = b"Test Author <test@example.com>"
  8494. commit2.commit_time = 2000000000
  8495. commit2.commit_timezone = 0
  8496. commit2.author_time = 2000000000
  8497. commit2.author_timezone = 0
  8498. commit2.message = b"Second commit"
  8499. self.repo.object_store.add_object(commit2)
  8500. # Write two reflog entries
  8501. self.repo._write_reflog(
  8502. b"HEAD",
  8503. ZERO_SHA,
  8504. commit1.id,
  8505. b"Test Author <test@example.com>",
  8506. 1000000000,
  8507. 0,
  8508. b"commit: First commit",
  8509. )
  8510. self.repo._write_reflog(
  8511. b"HEAD",
  8512. commit1.id,
  8513. commit2.id,
  8514. b"Test Author <test@example.com>",
  8515. 2000000000,
  8516. 0,
  8517. b"commit: Second commit",
  8518. )
  8519. # Delete the most recent entry (index 0)
  8520. porcelain.reflog_delete(self.repo_path, ref=b"HEAD", index=0)
  8521. # Should only have one entry left
  8522. entries = list(porcelain.reflog(self.repo_path, b"HEAD"))
  8523. self.assertEqual(1, len(entries))
  8524. self.assertEqual(commit1.id, entries[0].new_sha)
  8525. def test_reflog_expire_unreachable(self):
  8526. """Test expiring unreachable reflog entries."""
  8527. # Create commits
  8528. blob = Blob.from_string(b"test content")
  8529. self.repo.object_store.add_object(blob)
  8530. tree = Tree()
  8531. tree.add(b"test", 0o100644, blob.id)
  8532. self.repo.object_store.add_object(tree)
  8533. # Create commit 1 - will be reachable (pointed to by HEAD)
  8534. commit1 = Commit()
  8535. commit1.tree = tree.id
  8536. commit1.author = b"Test Author <test@example.com>"
  8537. commit1.committer = b"Test Author <test@example.com>"
  8538. commit1.commit_time = 1000000000
  8539. commit1.commit_timezone = 0
  8540. commit1.author_time = 1000000000
  8541. commit1.author_timezone = 0
  8542. commit1.message = b"Reachable commit"
  8543. self.repo.object_store.add_object(commit1)
  8544. # Create commit 2 - will be unreachable
  8545. commit2 = Commit()
  8546. commit2.tree = tree.id
  8547. commit2.author = b"Test Author <test@example.com>"
  8548. commit2.committer = b"Test Author <test@example.com>"
  8549. commit2.commit_time = 1500000000
  8550. commit2.commit_timezone = 0
  8551. commit2.author_time = 1500000000
  8552. commit2.author_timezone = 0
  8553. commit2.message = b"Unreachable commit"
  8554. self.repo.object_store.add_object(commit2)
  8555. # Create commit 3 - will also be reachable (pointed to by master)
  8556. commit3 = Commit()
  8557. commit3.tree = tree.id
  8558. commit3.author = b"Test Author <test@example.com>"
  8559. commit3.committer = b"Test Author <test@example.com>"
  8560. commit3.commit_time = 2000000000
  8561. commit3.commit_timezone = 0
  8562. commit3.author_time = 2000000000
  8563. commit3.author_timezone = 0
  8564. commit3.message = b"Another reachable commit"
  8565. self.repo.object_store.add_object(commit3)
  8566. # Set up refs to make commit1 and commit3 reachable
  8567. # HEAD is a symbolic ref to refs/heads/master by default, so we set master first
  8568. self.repo.refs[b"refs/heads/master"] = commit1.id
  8569. # Create another branch pointing to commit3
  8570. self.repo.refs[b"refs/heads/feature"] = commit3.id
  8571. # Write reflog entries for all three commits
  8572. self.repo._write_reflog(
  8573. b"HEAD",
  8574. ZERO_SHA,
  8575. commit1.id,
  8576. b"Test Author <test@example.com>",
  8577. 1000000000,
  8578. 0,
  8579. b"commit: Reachable commit",
  8580. )
  8581. self.repo._write_reflog(
  8582. b"HEAD",
  8583. commit1.id,
  8584. commit2.id,
  8585. b"Test Author <test@example.com>",
  8586. 1500000000,
  8587. 0,
  8588. b"commit: Unreachable commit",
  8589. )
  8590. self.repo._write_reflog(
  8591. b"HEAD",
  8592. commit2.id,
  8593. commit3.id,
  8594. b"Test Author <test@example.com>",
  8595. 2000000000,
  8596. 0,
  8597. b"commit: Another reachable commit",
  8598. )
  8599. # Expire unreachable entries older than a time that includes commit2
  8600. # but not the reachable commits
  8601. result = porcelain.reflog_expire(
  8602. self.repo_path,
  8603. ref=b"HEAD",
  8604. expire_unreachable_time=1600000000, # After commit2, before commit3
  8605. )
  8606. # Should have expired only commit2
  8607. self.assertEqual(1, result[b"HEAD"])
  8608. # Verify the remaining entries
  8609. entries = list(porcelain.reflog(self.repo_path, b"HEAD"))
  8610. self.assertEqual(2, len(entries))
  8611. # commit1 and commit3 should remain (both reachable)
  8612. self.assertEqual(commit1.id, entries[0].new_sha)
  8613. self.assertEqual(commit3.id, entries[1].new_sha)
  8614. class WriteCommitGraphTests(PorcelainTestCase):
  8615. """Tests for the write_commit_graph porcelain function."""
  8616. def test_write_commit_graph_empty_repo(self):
  8617. """Test writing commit graph on empty repository."""
  8618. # Should not fail on empty repo
  8619. porcelain.write_commit_graph(self.repo_path)
  8620. # No commit graph should be created for empty repo
  8621. graph_path = os.path.join(
  8622. self.repo_path, ".git", "objects", "info", "commit-graph"
  8623. )
  8624. self.assertFalse(os.path.exists(graph_path))
  8625. def test_write_commit_graph_with_commits(self):
  8626. """Test writing commit graph with commits."""
  8627. # Create some commits
  8628. c1, c2, c3 = build_commit_graph(
  8629. self.repo.object_store,
  8630. [[1], [2, 1], [3, 1, 2]],
  8631. attrs={
  8632. 1: {"message": b"First commit"},
  8633. 2: {"message": b"Second commit"},
  8634. 3: {"message": b"Merge commit"},
  8635. },
  8636. )
  8637. self.repo.refs[b"refs/heads/master"] = c3.id
  8638. self.repo.refs[b"refs/heads/branch"] = c2.id
  8639. # Write commit graph
  8640. porcelain.write_commit_graph(self.repo_path)
  8641. # Verify commit graph was created
  8642. graph_path = os.path.join(
  8643. self.repo_path, ".git", "objects", "info", "commit-graph"
  8644. )
  8645. self.assertTrue(os.path.exists(graph_path))
  8646. # Load and verify the commit graph
  8647. from dulwich.commit_graph import read_commit_graph
  8648. commit_graph = read_commit_graph(graph_path)
  8649. self.assertIsNotNone(commit_graph)
  8650. self.assertEqual(3, len(commit_graph))
  8651. # Verify all commits are in the graph
  8652. for commit_obj in [c1, c2, c3]:
  8653. entry = commit_graph.get_entry_by_oid(commit_obj.id)
  8654. self.assertIsNotNone(entry)
  8655. def test_write_commit_graph_config_disabled(self):
  8656. """Test that commit graph is not written when disabled by config."""
  8657. # Create a commit
  8658. (c1,) = build_commit_graph(
  8659. self.repo.object_store, [[1]], attrs={1: {"message": b"First commit"}}
  8660. )
  8661. self.repo.refs[b"refs/heads/master"] = c1.id
  8662. # Disable commit graph in config
  8663. config = self.repo.get_config()
  8664. config.set((b"core",), b"commitGraph", b"false")
  8665. config.write_to_path()
  8666. # Write commit graph (should respect config)
  8667. porcelain.write_commit_graph(self.repo_path)
  8668. # Verify commit graph still gets written
  8669. # (porcelain.write_commit_graph explicitly writes regardless of config)
  8670. graph_path = os.path.join(
  8671. self.repo_path, ".git", "objects", "info", "commit-graph"
  8672. )
  8673. self.assertTrue(os.path.exists(graph_path))
  8674. def test_write_commit_graph_reachable_false(self):
  8675. """Test writing commit graph with reachable=False."""
  8676. # Create commits
  8677. _c1, _c2, c3 = build_commit_graph(
  8678. self.repo.object_store,
  8679. [[1], [2, 1], [3, 2]],
  8680. attrs={
  8681. 1: {"message": b"First commit"},
  8682. 2: {"message": b"Second commit"},
  8683. 3: {"message": b"Third commit"},
  8684. },
  8685. )
  8686. # Only reference the third commit
  8687. self.repo.refs[b"refs/heads/master"] = c3.id
  8688. # Write commit graph with reachable=False
  8689. porcelain.write_commit_graph(self.repo_path, reachable=False)
  8690. # Verify commit graph was created
  8691. graph_path = os.path.join(
  8692. self.repo_path, ".git", "objects", "info", "commit-graph"
  8693. )
  8694. self.assertTrue(os.path.exists(graph_path))
  8695. # Load and verify the commit graph
  8696. from dulwich.commit_graph import read_commit_graph
  8697. commit_graph = read_commit_graph(graph_path)
  8698. self.assertIsNotNone(commit_graph)
  8699. # With reachable=False, only directly referenced commits should be included
  8700. # In this case, only c3 (from refs/heads/master)
  8701. self.assertEqual(1, len(commit_graph))
  8702. entry = commit_graph.get_entry_by_oid(c3.id)
  8703. self.assertIsNotNone(entry)
  8704. class WorktreePorcelainTests(PorcelainTestCase):
  8705. """Tests for porcelain worktree functions."""
  8706. def test_worktree_list_single(self):
  8707. """Test listing worktrees when only main worktree exists."""
  8708. # Create initial commit
  8709. with open(os.path.join(self.repo_path, "test.txt"), "w") as f:
  8710. f.write("test content")
  8711. porcelain.add(self.repo_path, ["test.txt"])
  8712. porcelain.commit(self.repo_path, message=b"Initial commit")
  8713. # List worktrees
  8714. worktrees = porcelain.worktree_list(self.repo_path)
  8715. self.assertEqual(len(worktrees), 1)
  8716. self.assertEqual(worktrees[0].path, self.repo_path)
  8717. self.assertFalse(worktrees[0].bare)
  8718. self.assertIsNotNone(worktrees[0].head)
  8719. self.assertIsNotNone(worktrees[0].branch)
  8720. def test_worktree_add_branch(self):
  8721. """Test adding a worktree with a new branch."""
  8722. # Create initial commit
  8723. with open(os.path.join(self.repo_path, "test.txt"), "w") as f:
  8724. f.write("test content")
  8725. porcelain.add(self.repo_path, ["test.txt"])
  8726. porcelain.commit(self.repo_path, message=b"Initial commit")
  8727. # Add worktree
  8728. wt_path = os.path.join(self.test_dir, "worktree1")
  8729. result_path = porcelain.worktree_add(self.repo_path, wt_path, branch=b"feature")
  8730. self.assertEqual(result_path, wt_path)
  8731. self.assertTrue(os.path.exists(wt_path))
  8732. self.assertTrue(os.path.exists(os.path.join(wt_path, ".git")))
  8733. # Check it appears in the list
  8734. worktrees = porcelain.worktree_list(self.repo_path)
  8735. self.assertEqual(len(worktrees), 2)
  8736. # Find the new worktree
  8737. new_wt = None
  8738. for wt in worktrees:
  8739. if wt.path == wt_path:
  8740. new_wt = wt
  8741. break
  8742. self.assertIsNotNone(new_wt)
  8743. self.assertEqual(new_wt.branch, b"refs/heads/feature")
  8744. self.assertFalse(new_wt.detached)
  8745. def test_worktree_add_detached(self):
  8746. """Test adding a detached worktree."""
  8747. # Create initial commit
  8748. with open(os.path.join(self.repo_path, "test.txt"), "w") as f:
  8749. f.write("test content")
  8750. porcelain.add(self.repo_path, ["test.txt"])
  8751. sha = porcelain.commit(self.repo_path, message=b"Initial commit")
  8752. # Add detached worktree
  8753. wt_path = os.path.join(self.test_dir, "detached")
  8754. porcelain.worktree_add(self.repo_path, wt_path, commit=sha, detach=True)
  8755. # Check it's detached
  8756. worktrees = porcelain.worktree_list(self.repo_path)
  8757. for wt in worktrees:
  8758. if wt.path == wt_path:
  8759. self.assertTrue(wt.detached)
  8760. self.assertIsNone(wt.branch)
  8761. self.assertEqual(wt.head, sha)
  8762. def test_worktree_remove(self):
  8763. """Test removing a worktree."""
  8764. # Create initial commit
  8765. with open(os.path.join(self.repo_path, "test.txt"), "w") as f:
  8766. f.write("test content")
  8767. porcelain.add(self.repo_path, ["test.txt"])
  8768. porcelain.commit(self.repo_path, message=b"Initial commit")
  8769. # Add and remove worktree
  8770. wt_path = os.path.join(self.test_dir, "to-remove")
  8771. porcelain.worktree_add(self.repo_path, wt_path)
  8772. # Verify it exists
  8773. self.assertTrue(os.path.exists(wt_path))
  8774. self.assertEqual(len(porcelain.worktree_list(self.repo_path)), 2)
  8775. # Remove it
  8776. porcelain.worktree_remove(self.repo_path, wt_path)
  8777. # Verify it's gone
  8778. self.assertFalse(os.path.exists(wt_path))
  8779. self.assertEqual(len(porcelain.worktree_list(self.repo_path)), 1)
  8780. def test_worktree_prune(self):
  8781. """Test pruning worktrees."""
  8782. # Create initial commit
  8783. with open(os.path.join(self.repo_path, "test.txt"), "w") as f:
  8784. f.write("test content")
  8785. porcelain.add(self.repo_path, ["test.txt"])
  8786. porcelain.commit(self.repo_path, message=b"Initial commit")
  8787. # Add worktree
  8788. wt_path = os.path.join(self.test_dir, "to-prune")
  8789. porcelain.worktree_add(self.repo_path, wt_path)
  8790. # Manually remove directory
  8791. shutil.rmtree(wt_path)
  8792. # Prune should remove the administrative files
  8793. pruned = porcelain.worktree_prune(self.repo_path)
  8794. self.assertEqual(len(pruned), 1)
  8795. # Verify it's gone from the list
  8796. worktrees = porcelain.worktree_list(self.repo_path)
  8797. self.assertEqual(len(worktrees), 1)
  8798. def test_worktree_lock_unlock(self):
  8799. """Test locking and unlocking worktrees."""
  8800. # Create initial commit
  8801. with open(os.path.join(self.repo_path, "test.txt"), "w") as f:
  8802. f.write("test content")
  8803. porcelain.add(self.repo_path, ["test.txt"])
  8804. porcelain.commit(self.repo_path, message=b"Initial commit")
  8805. # Add worktree
  8806. wt_path = os.path.join(self.test_dir, "lockable")
  8807. porcelain.worktree_add(self.repo_path, wt_path)
  8808. # Lock it
  8809. porcelain.worktree_lock(self.repo_path, wt_path, reason="Testing")
  8810. # Verify it's locked
  8811. worktrees = porcelain.worktree_list(self.repo_path)
  8812. for wt in worktrees:
  8813. if wt.path == wt_path:
  8814. self.assertTrue(wt.locked)
  8815. # Unlock it
  8816. porcelain.worktree_unlock(self.repo_path, wt_path)
  8817. # Verify it's unlocked
  8818. worktrees = porcelain.worktree_list(self.repo_path)
  8819. for wt in worktrees:
  8820. if wt.path == wt_path:
  8821. self.assertFalse(wt.locked)
  8822. def test_worktree_move(self):
  8823. """Test moving a worktree."""
  8824. # Create initial commit
  8825. with open(os.path.join(self.repo_path, "test.txt"), "w") as f:
  8826. f.write("test content")
  8827. porcelain.add(self.repo_path, ["test.txt"])
  8828. porcelain.commit(self.repo_path, message=b"Initial commit")
  8829. # Add worktree
  8830. old_path = os.path.join(self.test_dir, "old-location")
  8831. porcelain.worktree_add(self.repo_path, old_path)
  8832. # Create a file in the worktree
  8833. test_file = os.path.join(old_path, "workspace.txt")
  8834. with open(test_file, "w") as f:
  8835. f.write("workspace content")
  8836. # Move it
  8837. new_path = os.path.join(self.test_dir, "new-location")
  8838. porcelain.worktree_move(self.repo_path, old_path, new_path)
  8839. # Verify old path doesn't exist, new path does
  8840. self.assertFalse(os.path.exists(old_path))
  8841. self.assertTrue(os.path.exists(new_path))
  8842. self.assertTrue(os.path.exists(os.path.join(new_path, "workspace.txt")))
  8843. # Verify it's in the list at new location
  8844. worktrees = porcelain.worktree_list(self.repo_path)
  8845. paths = [wt.path for wt in worktrees]
  8846. self.assertIn(new_path, paths)
  8847. self.assertNotIn(old_path, paths)
  8848. class VarTests(PorcelainTestCase):
  8849. """Tests for the var command."""
  8850. def test_var_author_ident(self):
  8851. """Test getting GIT_AUTHOR_IDENT."""
  8852. # Set up user config
  8853. config = self.repo.get_config()
  8854. config.set((b"user",), b"name", b"Test Author")
  8855. config.set((b"user",), b"email", b"author@example.com")
  8856. config.write_to_path()
  8857. result = porcelain.var(self.repo_path, variable="GIT_AUTHOR_IDENT")
  8858. self.assertIn("Test Author <author@example.com>", result)
  8859. # Check that timestamp and timezone are included
  8860. # Format: Name <email> timestamp timezone
  8861. # "Test Author" is 2 words, so we have: Test, Author, <email>, timestamp, timezone
  8862. parts = result.split()
  8863. self.assertGreaterEqual(
  8864. len(parts), 4
  8865. ) # At least name, <email>, timestamp, timezone
  8866. # Check last two parts are timestamp and timezone
  8867. self.assertTrue(parts[-2].isdigit()) # timestamp
  8868. self.assertRegex(parts[-1], r"[+-]\d{4}") # timezone
  8869. def test_var_committer_ident(self):
  8870. """Test getting GIT_COMMITTER_IDENT."""
  8871. # Set up user config
  8872. config = self.repo.get_config()
  8873. config.set((b"user",), b"name", b"Test Committer")
  8874. config.set((b"user",), b"email", b"committer@example.com")
  8875. config.write_to_path()
  8876. result = porcelain.var(self.repo_path, variable="GIT_COMMITTER_IDENT")
  8877. self.assertIn("Test Committer <committer@example.com>", result)
  8878. # Check that timestamp and timezone are included
  8879. parts = result.split()
  8880. self.assertGreaterEqual(
  8881. len(parts), 4
  8882. ) # At least name, <email>, timestamp, timezone
  8883. # Check last two parts are timestamp and timezone
  8884. self.assertTrue(parts[-2].isdigit()) # timestamp
  8885. self.assertRegex(parts[-1], r"[+-]\d{4}") # timezone
  8886. def test_var_editor(self):
  8887. """Test getting GIT_EDITOR."""
  8888. # Test with environment variable
  8889. self.overrideEnv("GIT_EDITOR", "vim")
  8890. result = porcelain.var(self.repo_path, variable="GIT_EDITOR")
  8891. self.assertEqual(result, "vim")
  8892. def test_var_editor_from_config(self):
  8893. """Test getting GIT_EDITOR from config."""
  8894. # Set up editor in config
  8895. config = self.repo.get_config()
  8896. config.set((b"core",), b"editor", b"emacs")
  8897. config.write_to_path()
  8898. # Make sure env var is not set
  8899. self.overrideEnv("GIT_EDITOR", None)
  8900. result = porcelain.var(self.repo_path, variable="GIT_EDITOR")
  8901. self.assertEqual(result, "emacs")
  8902. def test_var_pager(self):
  8903. """Test getting GIT_PAGER."""
  8904. # Test with environment variable
  8905. self.overrideEnv("GIT_PAGER", "less")
  8906. result = porcelain.var(self.repo_path, variable="GIT_PAGER")
  8907. self.assertEqual(result, "less")
  8908. def test_var_pager_from_config(self):
  8909. """Test getting GIT_PAGER from config."""
  8910. # Set up pager in config
  8911. config = self.repo.get_config()
  8912. config.set((b"core",), b"pager", b"more")
  8913. config.write_to_path()
  8914. # Make sure env var is not set
  8915. self.overrideEnv("GIT_PAGER", None)
  8916. result = porcelain.var(self.repo_path, variable="GIT_PAGER")
  8917. self.assertEqual(result, "more")
  8918. def test_var_default_branch(self):
  8919. """Test getting GIT_DEFAULT_BRANCH."""
  8920. # Set up default branch in config
  8921. config = self.repo.get_config()
  8922. config.set((b"init",), b"defaultBranch", b"main")
  8923. config.write_to_path()
  8924. result = porcelain.var(self.repo_path, variable="GIT_DEFAULT_BRANCH")
  8925. self.assertEqual(result, "main")
  8926. def test_var_default_branch_default(self):
  8927. """Test getting GIT_DEFAULT_BRANCH with default value."""
  8928. result = porcelain.var(self.repo_path, variable="GIT_DEFAULT_BRANCH")
  8929. self.assertEqual(result, "master")
  8930. def test_var_list_all(self):
  8931. """Test listing all logical variables."""
  8932. # Set up some config
  8933. config = self.repo.get_config()
  8934. config.set((b"user",), b"name", b"Test User")
  8935. config.set((b"user",), b"email", b"test@example.com")
  8936. config.write_to_path()
  8937. result = porcelain.var_list(self.repo_path)
  8938. self.assertIsInstance(result, dict)
  8939. # Check that logical variables are present
  8940. self.assertIn("GIT_AUTHOR_IDENT", result)
  8941. self.assertIn("GIT_COMMITTER_IDENT", result)
  8942. self.assertIn("GIT_DEFAULT_BRANCH", result)
  8943. # Config variables should NOT be included (deprecated feature)
  8944. self.assertNotIn("user.name", result)
  8945. self.assertNotIn("user.email", result)
  8946. # Verify only logical variables are present
  8947. for key in result.keys():
  8948. self.assertTrue(key.startswith("GIT_"))
  8949. def test_var_unknown_variable(self):
  8950. """Test requesting an unknown variable."""
  8951. with self.assertRaises(KeyError):
  8952. porcelain.var(self.repo_path, variable="UNKNOWN_VARIABLE")
  8953. class MergeBaseTests(PorcelainTestCase):
  8954. """Tests for merge-base, is_ancestor, and independent_commits."""
  8955. def test_merge_base_linear_history(self):
  8956. """Test merge-base with linear history."""
  8957. # Create linear history: c1 <- c2 <- c3
  8958. _c1, c2, c3 = build_commit_graph(self.repo.object_store, [[1], [2, 1], [3, 2]])
  8959. self.repo.refs[b"refs/heads/branch1"] = c2.id
  8960. self.repo.refs[b"refs/heads/branch2"] = c3.id
  8961. result = porcelain.merge_base(
  8962. self.repo.path, committishes=["refs/heads/branch1", "refs/heads/branch2"]
  8963. )
  8964. self.assertEqual([c2.id], result)
  8965. def test_merge_base_diverged(self):
  8966. """Test merge-base with diverged branches."""
  8967. # Create diverged history: c1 <- c2a, c1 <- c2b
  8968. c1, c2a, c2b = build_commit_graph(self.repo.object_store, [[1], [2, 1], [3, 1]])
  8969. self.repo.refs[b"refs/heads/branch-a"] = c2a.id
  8970. self.repo.refs[b"refs/heads/branch-b"] = c2b.id
  8971. result = porcelain.merge_base(
  8972. self.repo.path, committishes=["refs/heads/branch-a", "refs/heads/branch-b"]
  8973. )
  8974. self.assertEqual([c1.id], result)
  8975. def test_merge_base_all(self):
  8976. """Test merge-base with --all flag."""
  8977. # Create history with multiple common ancestors
  8978. commits = build_commit_graph(
  8979. self.repo.object_store,
  8980. [[1], [2, 1], [3, 1], [4, 2, 3], [5, 2, 3]],
  8981. )
  8982. _c1, c2, c3, c4, c5 = commits
  8983. self.repo.refs[b"refs/heads/branch1"] = c4.id
  8984. self.repo.refs[b"refs/heads/branch2"] = c5.id
  8985. # Without --all, should return only first result
  8986. result = porcelain.merge_base(
  8987. self.repo.path,
  8988. committishes=["refs/heads/branch1", "refs/heads/branch2"],
  8989. all=False,
  8990. )
  8991. self.assertEqual(1, len(result))
  8992. # With --all, should return all merge bases
  8993. result_all = porcelain.merge_base(
  8994. self.repo.path,
  8995. committishes=["refs/heads/branch1", "refs/heads/branch2"],
  8996. all=True,
  8997. )
  8998. self.assertEqual(2, len(result_all))
  8999. self.assertIn(c2.id, result_all)
  9000. self.assertIn(c3.id, result_all)
  9001. def test_merge_base_octopus(self):
  9002. """Test merge-base with --octopus flag."""
  9003. # Create three-way diverged history
  9004. commits = build_commit_graph(
  9005. self.repo.object_store, [[1], [2, 1], [3, 1], [4, 1]]
  9006. )
  9007. c1, c2, c3, c4 = commits
  9008. self.repo.refs[b"refs/heads/a"] = c2.id
  9009. self.repo.refs[b"refs/heads/b"] = c3.id
  9010. self.repo.refs[b"refs/heads/c"] = c4.id
  9011. result = porcelain.merge_base(
  9012. self.repo.path,
  9013. committishes=["refs/heads/a", "refs/heads/b", "refs/heads/c"],
  9014. octopus=True,
  9015. )
  9016. self.assertEqual([c1.id], result)
  9017. def test_merge_base_requires_two_commits(self):
  9018. """Test merge-base requires at least two commits."""
  9019. with self.assertRaises(ValueError):
  9020. porcelain.merge_base(self.repo.path, committishes=["HEAD"])
  9021. def test_is_ancestor_true(self):
  9022. """Test is_ancestor returns True when commit is an ancestor."""
  9023. # Create linear history: c1 <- c2 <- c3
  9024. c1, c2, c3 = build_commit_graph(self.repo.object_store, [[1], [2, 1], [3, 2]])
  9025. self.repo.refs[b"refs/heads/main"] = c3.id
  9026. # c1 is ancestor of c3
  9027. result = porcelain.is_ancestor(
  9028. self.repo.path, ancestor=c1.id.decode(), descendant=c3.id.decode()
  9029. )
  9030. self.assertTrue(result)
  9031. # c2 is ancestor of c3
  9032. result = porcelain.is_ancestor(
  9033. self.repo.path, ancestor=c2.id.decode(), descendant=c3.id.decode()
  9034. )
  9035. self.assertTrue(result)
  9036. def test_is_ancestor_false(self):
  9037. """Test is_ancestor returns False when commit is not an ancestor."""
  9038. # Create diverged history: c1 <- c2a, c1 <- c2b
  9039. _c1, c2a, c2b = build_commit_graph(
  9040. self.repo.object_store, [[1], [2, 1], [3, 1]]
  9041. )
  9042. # c2a is not ancestor of c2b
  9043. result = porcelain.is_ancestor(
  9044. self.repo.path, ancestor=c2a.id.decode(), descendant=c2b.id.decode()
  9045. )
  9046. self.assertFalse(result)
  9047. # c2b is not ancestor of c2a
  9048. result = porcelain.is_ancestor(
  9049. self.repo.path, ancestor=c2b.id.decode(), descendant=c2a.id.decode()
  9050. )
  9051. self.assertFalse(result)
  9052. def test_is_ancestor_requires_both(self):
  9053. """Test is_ancestor requires both ancestor and descendant."""
  9054. with self.assertRaises(ValueError):
  9055. porcelain.is_ancestor(self.repo.path, ancestor="HEAD", descendant=None)
  9056. with self.assertRaises(ValueError):
  9057. porcelain.is_ancestor(self.repo.path, ancestor=None, descendant="HEAD")
  9058. def test_independent_commits_linear(self):
  9059. """Test independent_commits with linear history."""
  9060. # Create linear history: c1 <- c2 <- c3
  9061. c1, c2, c3 = build_commit_graph(self.repo.object_store, [[1], [2, 1], [3, 2]])
  9062. # Only c3 is independent (c1 and c2 are ancestors)
  9063. result = porcelain.independent_commits(
  9064. self.repo.path,
  9065. committishes=[c1.id.decode(), c2.id.decode(), c3.id.decode()],
  9066. )
  9067. self.assertEqual([c3.id], result)
  9068. def test_independent_commits_diverged(self):
  9069. """Test independent_commits with diverged branches."""
  9070. # Create diverged history: c1 <- c2a, c1 <- c2b
  9071. _c1, c2a, c2b = build_commit_graph(
  9072. self.repo.object_store, [[1], [2, 1], [3, 1]]
  9073. )
  9074. # c2a and c2b are both independent (neither is ancestor of the other)
  9075. result = porcelain.independent_commits(
  9076. self.repo.path, committishes=[c2a.id.decode(), c2b.id.decode()]
  9077. )
  9078. self.assertEqual(2, len(result))
  9079. self.assertIn(c2a.id, result)
  9080. self.assertIn(c2b.id, result)
  9081. def test_independent_commits_mixed(self):
  9082. """Test independent_commits with mixed history."""
  9083. # Create mixed history
  9084. commits = build_commit_graph(
  9085. self.repo.object_store, [[1], [2, 1], [3, 1], [4, 2]]
  9086. )
  9087. _c1, c2, c3, c4 = commits
  9088. # c4 and c3 are independent; c2 is ancestor of c4
  9089. result = porcelain.independent_commits(
  9090. self.repo.path,
  9091. committishes=[c2.id.decode(), c3.id.decode(), c4.id.decode()],
  9092. )
  9093. self.assertEqual(2, len(result))
  9094. self.assertIn(c3.id, result)
  9095. self.assertIn(c4.id, result)
  9096. self.assertNotIn(c2.id, result)
  9097. def test_independent_commits_empty(self):
  9098. """Test independent_commits with empty list."""
  9099. result = porcelain.independent_commits(self.repo.path, committishes=[])
  9100. self.assertEqual([], result)
  9101. class CherryTests(PorcelainTestCase):
  9102. """Tests for cherry command."""
  9103. def test_cherry_no_changes(self):
  9104. """Test cherry when head and upstream are the same."""
  9105. # Create a simple commit
  9106. commit_sha = self.repo.get_worktree().commit(
  9107. b"Initial commit", committer=b"Test <test@example.com>"
  9108. )
  9109. # Cherry should return empty when comparing a commit to itself
  9110. results = porcelain.cherry(
  9111. self.repo.path, upstream=commit_sha.decode(), head=commit_sha.decode()
  9112. )
  9113. self.assertEqual([], results)
  9114. def test_cherry_unique_commits(self):
  9115. """Test cherry with commits unique to head."""
  9116. # Create initial commit
  9117. with open(os.path.join(self.repo_path, "file1.txt"), "w") as f:
  9118. f.write("base content\n")
  9119. self.repo.get_worktree().stage(["file1.txt"])
  9120. base_commit = self.repo.get_worktree().commit(
  9121. b"Base commit", committer=b"Test <test@example.com>"
  9122. )
  9123. # Create a new commit on head
  9124. with open(os.path.join(self.repo_path, "file2.txt"), "w") as f:
  9125. f.write("new content\n")
  9126. self.repo.get_worktree().stage(["file2.txt"])
  9127. head_commit = self.repo.get_worktree().commit(
  9128. b"New commit", committer=b"Test <test@example.com>"
  9129. )
  9130. # Cherry should show the new commit as unique
  9131. results = porcelain.cherry(
  9132. self.repo.path, upstream=base_commit.decode(), head=head_commit.decode()
  9133. )
  9134. self.assertEqual(1, len(results))
  9135. status, commit_sha, message = results[0]
  9136. self.assertEqual("+", status)
  9137. self.assertEqual(head_commit, commit_sha)
  9138. self.assertIsNone(message)
  9139. def test_cherry_verbose(self):
  9140. """Test cherry with verbose flag."""
  9141. # Create initial commit
  9142. with open(os.path.join(self.repo_path, "file1.txt"), "w") as f:
  9143. f.write("base content\n")
  9144. self.repo.get_worktree().stage(["file1.txt"])
  9145. base_commit = self.repo.get_worktree().commit(
  9146. b"Base commit", committer=b"Test <test@example.com>"
  9147. )
  9148. # Create a new commit on head
  9149. with open(os.path.join(self.repo_path, "file2.txt"), "w") as f:
  9150. f.write("new content\n")
  9151. self.repo.get_worktree().stage(["file2.txt"])
  9152. head_commit = self.repo.get_worktree().commit(
  9153. b"New commit on head", committer=b"Test <test@example.com>"
  9154. )
  9155. # Cherry with verbose should include commit message
  9156. results = porcelain.cherry(
  9157. self.repo.path,
  9158. upstream=base_commit.decode(),
  9159. head=head_commit.decode(),
  9160. verbose=True,
  9161. )
  9162. self.assertEqual(1, len(results))
  9163. status, commit_sha, message = results[0]
  9164. self.assertEqual("+", status)
  9165. self.assertEqual(head_commit, commit_sha)
  9166. self.assertEqual(b"New commit on head", message)
  9167. def test_cherry_equivalent_patches(self):
  9168. """Test cherry with equivalent patches (cherry-picked commits)."""
  9169. # Create base commit
  9170. with open(os.path.join(self.repo_path, "file.txt"), "w") as f:
  9171. f.write("line1\n")
  9172. self.repo.get_worktree().stage(["file.txt"])
  9173. base_commit = self.repo.get_worktree().commit(
  9174. b"Base commit", committer=b"Test <test@example.com>"
  9175. )
  9176. # Create upstream branch with a change
  9177. with open(os.path.join(self.repo_path, "file.txt"), "w") as f:
  9178. f.write("line1\nline2\n")
  9179. self.repo.get_worktree().stage(["file.txt"])
  9180. upstream_commit = self.repo.get_worktree().commit(
  9181. b"Add line2", committer=b"Test <test@example.com>"
  9182. )
  9183. # Reset to base and create same change on head branch
  9184. self.repo.refs[b"HEAD"] = base_commit
  9185. self.repo.get_worktree().reset_index()
  9186. with open(os.path.join(self.repo_path, "file.txt"), "w") as f:
  9187. f.write("line1\nline2\n")
  9188. self.repo.get_worktree().stage(["file.txt"])
  9189. head_commit = self.repo.get_worktree().commit(
  9190. b"Add line2 (different metadata)",
  9191. committer=b"Different <different@example.com>",
  9192. )
  9193. # Cherry should mark this as equivalent (-)
  9194. results = porcelain.cherry(
  9195. self.repo.path,
  9196. upstream=upstream_commit.decode(),
  9197. head=head_commit.decode(),
  9198. )
  9199. self.assertEqual(1, len(results))
  9200. status, commit_sha, _message = results[0]
  9201. self.assertEqual("-", status)
  9202. self.assertEqual(head_commit, commit_sha)
  9203. class GrepTests(PorcelainTestCase):
  9204. def test_basic_grep(self) -> None:
  9205. """Test basic pattern matching in files."""
  9206. # Create some test files
  9207. with open(os.path.join(self.repo_path, "foo.txt"), "w") as f:
  9208. f.write("hello world\ngoodbye world\n")
  9209. with open(os.path.join(self.repo_path, "bar.txt"), "w") as f:
  9210. f.write("foo bar\nbaz qux\n")
  9211. porcelain.add(self.repo, paths=["foo.txt", "bar.txt"])
  9212. porcelain.commit(self.repo, message=b"Add test files")
  9213. # Search for "world"
  9214. outstream = StringIO()
  9215. porcelain.grep(self.repo, "world", outstream=outstream)
  9216. output = outstream.getvalue().replace("\r\n", "\n")
  9217. self.assertEqual("foo.txt:hello world\nfoo.txt:goodbye world\n", output)
  9218. def test_grep_with_line_numbers(self) -> None:
  9219. """Test grep with line numbers."""
  9220. with open(os.path.join(self.repo_path, "test.txt"), "w") as f:
  9221. f.write("line one\nline two\nline three\n")
  9222. porcelain.add(self.repo, paths=["test.txt"])
  9223. porcelain.commit(self.repo, message=b"Add test file")
  9224. outstream = StringIO()
  9225. porcelain.grep(self.repo, "line", outstream=outstream, line_number=True)
  9226. output = outstream.getvalue().replace("\r\n", "\n")
  9227. self.assertEqual(
  9228. "test.txt:1:line one\ntest.txt:2:line two\ntest.txt:3:line three\n",
  9229. output,
  9230. )
  9231. def test_grep_case_insensitive(self) -> None:
  9232. """Test case-insensitive grep."""
  9233. with open(os.path.join(self.repo_path, "case.txt"), "w") as f:
  9234. f.write("Hello WORLD\nGoodbye world\n")
  9235. porcelain.add(self.repo, paths=["case.txt"])
  9236. porcelain.commit(self.repo, message=b"Add case file")
  9237. outstream = StringIO()
  9238. porcelain.grep(self.repo, "HELLO", outstream=outstream, ignore_case=True)
  9239. output = outstream.getvalue().replace("\r\n", "\n")
  9240. self.assertEqual("case.txt:Hello WORLD\n", output)
  9241. def test_grep_with_pathspec(self) -> None:
  9242. """Test grep with pathspec filtering."""
  9243. os.makedirs(os.path.join(self.repo_path, "subdir"))
  9244. with open(os.path.join(self.repo_path, "file1.txt"), "w") as f:
  9245. f.write("pattern match\n")
  9246. with open(os.path.join(self.repo_path, "subdir", "file2.txt"), "w") as f:
  9247. f.write("pattern match\n")
  9248. porcelain.add(self.repo, paths=["file1.txt", "subdir/file2.txt"])
  9249. porcelain.commit(self.repo, message=b"Add files")
  9250. # Search only in subdir
  9251. outstream = StringIO()
  9252. porcelain.grep(self.repo, "pattern", outstream=outstream, pathspecs=["subdir/"])
  9253. output = outstream.getvalue().replace("\r\n", "\n")
  9254. self.assertEqual("subdir/file2.txt:pattern match\n", output)
  9255. def test_grep_no_matches(self) -> None:
  9256. """Test grep with no matches."""
  9257. with open(os.path.join(self.repo_path, "empty.txt"), "w") as f:
  9258. f.write("nothing to see here\n")
  9259. porcelain.add(self.repo, paths=["empty.txt"])
  9260. porcelain.commit(self.repo, message=b"Add empty file")
  9261. outstream = StringIO()
  9262. porcelain.grep(self.repo, "nonexistent", outstream=outstream)
  9263. output = outstream.getvalue()
  9264. self.assertEqual("", output)
  9265. def test_grep_regex_pattern(self) -> None:
  9266. """Test grep with regex patterns."""
  9267. with open(os.path.join(self.repo_path, "regex.txt"), "w") as f:
  9268. f.write("test123\ntest456\nnotest\n")
  9269. porcelain.add(self.repo, paths=["regex.txt"])
  9270. porcelain.commit(self.repo, message=b"Add regex file")
  9271. # Search for "test" followed by digits
  9272. outstream = StringIO()
  9273. porcelain.grep(self.repo, r"test\d+", outstream=outstream)
  9274. output = outstream.getvalue().replace("\r\n", "\n")
  9275. self.assertEqual("regex.txt:test123\nregex.txt:test456\n", output)
  9276. def test_grep_invalid_pattern(self) -> None:
  9277. """Test grep with invalid regex pattern."""
  9278. with open(os.path.join(self.repo_path, "test.txt"), "w") as f:
  9279. f.write("test\n")
  9280. porcelain.add(self.repo, paths=["test.txt"])
  9281. porcelain.commit(self.repo, message=b"Add test file")
  9282. outstream = StringIO()
  9283. with self.assertRaises(ValueError):
  9284. porcelain.grep(self.repo, "[invalid", outstream=outstream)
  9285. def test_grep_no_head(self) -> None:
  9286. """Test grep fails when there's no HEAD commit."""
  9287. # Create a fresh repo with no commits
  9288. empty_repo_path = os.path.join(self.test_dir, "empty_repo")
  9289. empty_repo = Repo.init(empty_repo_path, mkdir=True)
  9290. self.addCleanup(empty_repo.close)
  9291. outstream = StringIO()
  9292. with self.assertRaises(ValueError):
  9293. porcelain.grep(empty_repo, "pattern", outstream=outstream)
  9294. class ReplaceListTests(PorcelainTestCase):
  9295. def test_empty(self) -> None:
  9296. """Test listing replacements when there are none."""
  9297. replacements = porcelain.replace_list(self.repo)
  9298. self.assertEqual([], replacements)
  9299. def test_list_replacements(self) -> None:
  9300. """Test listing replacement refs."""
  9301. [c1, c2] = build_commit_graph(self.repo.object_store, [[1], [2]])
  9302. self.repo[b"HEAD"] = c1.id
  9303. # Create a replacement
  9304. porcelain.replace_create(self.repo, c1.id, c2.id)
  9305. # List replacements
  9306. replacements = porcelain.replace_list(self.repo)
  9307. self.assertEqual(1, len(replacements))
  9308. self.assertEqual((c1.id, c2.id), replacements[0])
  9309. class ReplaceCreateTests(PorcelainTestCase):
  9310. def test_create_replacement(self) -> None:
  9311. """Test creating a replacement ref."""
  9312. [c1, c2] = build_commit_graph(self.repo.object_store, [[1], [2]])
  9313. self.repo[b"HEAD"] = c1.id
  9314. # Create a replacement
  9315. porcelain.replace_create(self.repo, c1.id, c2.id)
  9316. # Verify the replacement ref was created (c1.id is already 40-char hex bytes)
  9317. replace_ref = b"refs/replace/" + c1.id
  9318. self.assertIn(replace_ref, self.repo.refs)
  9319. self.assertEqual(c2.id, self.repo.refs[replace_ref])
  9320. def test_create_replacement_with_bytes(self) -> None:
  9321. """Test creating a replacement ref with bytes arguments."""
  9322. [c1, c2] = build_commit_graph(self.repo.object_store, [[1], [2]])
  9323. self.repo[b"HEAD"] = c1.id
  9324. # Create a replacement using bytes arguments
  9325. porcelain.replace_create(self.repo, c1.id, c2.id)
  9326. # Verify the replacement ref was created
  9327. replace_ref = b"refs/replace/" + c1.id
  9328. self.assertIn(replace_ref, self.repo.refs)
  9329. self.assertEqual(c2.id, self.repo.refs[replace_ref])
  9330. class ReplaceDeleteTests(PorcelainTestCase):
  9331. def test_delete_replacement(self) -> None:
  9332. """Test deleting a replacement ref."""
  9333. [c1, c2] = build_commit_graph(self.repo.object_store, [[1], [2]])
  9334. self.repo[b"HEAD"] = c1.id
  9335. # Create a replacement
  9336. porcelain.replace_create(self.repo, c1.id, c2.id)
  9337. # Verify it exists
  9338. replacements = porcelain.replace_list(self.repo)
  9339. self.assertEqual(1, len(replacements))
  9340. # Delete the replacement
  9341. porcelain.replace_delete(self.repo, c1.id)
  9342. # Verify it's gone
  9343. replacements = porcelain.replace_list(self.repo)
  9344. self.assertEqual(0, len(replacements))
  9345. def test_delete_replacement_with_bytes(self) -> None:
  9346. """Test deleting a replacement ref with bytes argument."""
  9347. [c1, c2] = build_commit_graph(self.repo.object_store, [[1], [2]])
  9348. self.repo[b"HEAD"] = c1.id
  9349. # Create a replacement
  9350. porcelain.replace_create(self.repo, c1.id, c2.id)
  9351. # Delete using bytes argument
  9352. porcelain.replace_delete(self.repo, c1.id)
  9353. # Verify it's gone
  9354. replacements = porcelain.replace_list(self.repo)
  9355. self.assertEqual(0, len(replacements))
  9356. def test_delete_nonexistent_replacement(self) -> None:
  9357. """Test deleting a replacement ref that doesn't exist raises KeyError."""
  9358. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  9359. self.repo[b"HEAD"] = c1.id
  9360. # Try to delete a non-existent replacement
  9361. with self.assertRaises(KeyError):
  9362. porcelain.replace_delete(self.repo, c1.id)
  9363. class GitReflogActionTests(PorcelainTestCase):
  9364. """Tests for GIT_REFLOG_ACTION environment variable support."""
  9365. def test_reset_with_git_reflog_action(self) -> None:
  9366. """Test that reset respects GIT_REFLOG_ACTION environment variable."""
  9367. [c1, c2] = build_commit_graph(self.repo.object_store, [[1], [2]])
  9368. self.repo.refs[b"HEAD"] = c2.id
  9369. # Set GIT_REFLOG_ACTION environment variable
  9370. self.overrideEnv("GIT_REFLOG_ACTION", "custom reset action")
  9371. # Reset to c1
  9372. porcelain.reset(self.repo, "hard", c1.id.decode())
  9373. # Check reflog message - HEAD is a symref to refs/heads/master
  9374. entries = list(porcelain.reflog(self.repo_path, b"refs/heads/master"))
  9375. self.assertEqual(1, len(entries))
  9376. self.assertEqual(b"custom reset action", entries[0].message)
  9377. def test_commit_amend_with_git_reflog_action(self) -> None:
  9378. """Test that commit --amend respects GIT_REFLOG_ACTION environment variable."""
  9379. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  9380. self.repo.refs[b"HEAD"] = c1.id
  9381. # Set GIT_REFLOG_ACTION environment variable
  9382. self.overrideEnv("GIT_REFLOG_ACTION", "custom amend action")
  9383. # Amend the commit
  9384. porcelain.commit(self.repo, b"amended message", amend=True)
  9385. # Check reflog message - HEAD is a symref to refs/heads/master
  9386. entries = list(porcelain.reflog(self.repo_path, b"refs/heads/master"))
  9387. self.assertEqual(1, len(entries))
  9388. self.assertEqual(b"custom amend action", entries[0].message)
  9389. def test_branch_create_with_git_reflog_action(self) -> None:
  9390. """Test that branch_create respects GIT_REFLOG_ACTION environment variable."""
  9391. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  9392. self.repo.refs[b"HEAD"] = c1.id
  9393. # Set GIT_REFLOG_ACTION environment variable
  9394. self.overrideEnv("GIT_REFLOG_ACTION", "custom branch action")
  9395. # Create a new branch
  9396. porcelain.branch_create(self.repo, b"test-branch")
  9397. # Check reflog message
  9398. entries = list(porcelain.reflog(self.repo_path, b"refs/heads/test-branch"))
  9399. self.assertEqual(1, len(entries))
  9400. self.assertEqual(b"custom branch action", entries[0].message)
  9401. def test_reset_without_git_reflog_action(self) -> None:
  9402. """Test that reset uses default message when GIT_REFLOG_ACTION is not set."""
  9403. [c1, c2] = build_commit_graph(self.repo.object_store, [[1], [2]])
  9404. self.repo.refs[b"HEAD"] = c2.id
  9405. # Reset to c1 without GIT_REFLOG_ACTION
  9406. porcelain.reset(self.repo, "hard", c1.id.decode())
  9407. # Check reflog message contains default format - HEAD is a symref to refs/heads/master
  9408. entries = list(porcelain.reflog(self.repo_path, b"refs/heads/master"))
  9409. self.assertEqual(1, len(entries))
  9410. self.assertTrue(entries[0].message.startswith(b"reset: moving to"))
  9411. def test_branch_create_without_git_reflog_action(self) -> None:
  9412. """Test that branch_create uses default message when GIT_REFLOG_ACTION is not set."""
  9413. [c1] = build_commit_graph(self.repo.object_store, [[1]])
  9414. self.repo.refs[b"HEAD"] = c1.id
  9415. # Create a new branch without GIT_REFLOG_ACTION
  9416. porcelain.branch_create(self.repo, b"test-branch")
  9417. # Check reflog message contains default format
  9418. entries = list(porcelain.reflog(self.repo_path, b"refs/heads/test-branch"))
  9419. self.assertEqual(1, len(entries))
  9420. self.assertTrue(entries[0].message.startswith(b"branch: Created from"))
  9421. class PorcelainMailinfoTests(TestCase):
  9422. """Tests for porcelain.mailinfo function."""
  9423. def test_mailinfo_basic(self) -> None:
  9424. """Test basic mailinfo functionality."""
  9425. from io import BytesIO
  9426. email_content = b"""From: Test User <test@example.com>
  9427. Subject: [PATCH] Add feature
  9428. Date: Mon, 1 Jan 2024 12:00:00 +0000
  9429. Commit message here.
  9430. ---
  9431. diff --git a/file.txt b/file.txt
  9432. """
  9433. result = porcelain.mailinfo(BytesIO(email_content))
  9434. self.assertEqual("Test User", result.author_name)
  9435. self.assertEqual("test@example.com", result.author_email)
  9436. self.assertEqual("Add feature", result.subject)
  9437. self.assertIn("Commit message here.", result.message)
  9438. self.assertIn("diff --git", result.patch)
  9439. def test_mailinfo_write_to_files(self) -> None:
  9440. """Test mailinfo writing message and patch to files."""
  9441. from io import BytesIO
  9442. email_content = b"""From: Test <test@example.com>
  9443. Subject: Test
  9444. Message content
  9445. ---
  9446. Patch content
  9447. """
  9448. with tempfile.TemporaryDirectory() as tmpdir:
  9449. msg_file = os.path.join(tmpdir, "msg")
  9450. patch_file = os.path.join(tmpdir, "patch")
  9451. result = porcelain.mailinfo(
  9452. BytesIO(email_content), msg_file=msg_file, patch_file=patch_file
  9453. )
  9454. # Check that files were written
  9455. self.assertTrue(os.path.exists(msg_file))
  9456. self.assertTrue(os.path.exists(patch_file))
  9457. # Check file contents
  9458. with open(msg_file) as f:
  9459. msg_content = f.read()
  9460. self.assertIn("Message content", msg_content)
  9461. with open(patch_file) as f:
  9462. patch_content = f.read()
  9463. self.assertIn("Patch content", patch_content)
  9464. # Check return value
  9465. self.assertEqual("Test", result.subject)
  9466. def test_mailinfo_with_options(self) -> None:
  9467. """Test mailinfo with various options."""
  9468. from io import BytesIO
  9469. email_content = b"""From: Test <test@example.com>
  9470. Subject: [RFC][PATCH] Feature
  9471. Message-ID: <id@example.com>
  9472. Text before scissors
  9473. -- >8 --
  9474. Text after scissors
  9475. """
  9476. # Test with keep_non_patch, scissors, and message_id
  9477. result = porcelain.mailinfo(
  9478. BytesIO(email_content),
  9479. keep_non_patch=True,
  9480. scissors=True,
  9481. message_id=True,
  9482. )
  9483. self.assertEqual("[RFC] Feature", result.subject)
  9484. self.assertIn("Text after scissors", result.message)
  9485. self.assertNotIn("Text before scissors", result.message)
  9486. self.assertIn("Message-ID:", result.message)
  9487. def test_mailinfo_from_file_path(self) -> None:
  9488. """Test mailinfo reading from file path."""
  9489. email_content = b"""From: Test <test@example.com>
  9490. Subject: Test
  9491. Body
  9492. """
  9493. with tempfile.TemporaryDirectory() as tmpdir:
  9494. email_path = os.path.join(tmpdir, "email.txt")
  9495. with open(email_path, "wb") as f:
  9496. f.write(email_content)
  9497. result = porcelain.mailinfo(input_path=email_path)
  9498. self.assertEqual("Test", result.subject)
  9499. self.assertEqual("test@example.com", result.author_email)